-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmp4_to_lsa.py
More file actions
144 lines (119 loc) · 4.72 KB
/
Copy pathmp4_to_lsa.py
File metadata and controls
144 lines (119 loc) · 4.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
#!/usr/bin/env python3
"""
Convert any FFmpeg-readable video (MP4, MKV, GIF, …) to .lsa for SD card playback.
The video is scaled to 160×32 and encoded as raw RGB24. No external Python
dependencies — requires only ffmpeg and ffprobe to be installed on PATH.
Usage:
python3 scripts/mp4_to_lsa.py clip.mp4 clip.lsa
python3 scripts/mp4_to_lsa.py clip.mp4 clip.lsa --fps 30
No external Python dependencies — standard library only.
"""
import argparse
import struct
import subprocess
import sys
# Must match Config.h kLiveLedCount / kLiveFrameBytes
LED_COUNT = 5120 # 160 x 32 (20 panels: 2 rows × 10 cols)
FRAME_BYTES = LED_COUNT * 3 # 15360 bytes per frame
WIDTH, HEIGHT = 160, 32
LSA_MAGIC = b'LSA1'
LSA_FLAG_LOOP = 0x01
# Physical ceiling imposed by WS2812 bitrate + lane count
MAX_FPS = 43
def probe_fps(path: str) -> float:
"""Return the video's native frame rate via ffprobe."""
try:
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=r_frame_rate",
"-of", "default=noprint_wrappers=1:nokey=1",
path,
],
capture_output=True, text=True, check=True,
)
except FileNotFoundError:
sys.exit("ERROR: ffprobe not found — install ffmpeg (e.g. sudo pacman -S ffmpeg).")
except subprocess.CalledProcessError as e:
sys.exit(f"ERROR: ffprobe failed:\n{e.stderr}")
line = result.stdout.strip()
if '/' in line:
num, den = line.split('/')
return float(num) / float(den) if float(den) else 25.0
try:
return float(line)
except ValueError:
print(f"WARNING: Could not parse fps '{line}', defaulting to 25.")
return 25.0
def write_lsa(input_path: str, output_path: str, fps: int) -> None:
"""
LSA header (16 bytes):
[0-3] 'LSA1'
[4-5] LED count (uint16 LE)
[6-7] FPS (uint16 LE)
[8-11] frame count (uint32 LE) ← patched after streaming
[12] flags (0x01 = loop)
[13-15] reserved
Followed immediately by frame_count * FRAME_BYTES of raw RGB data.
"""
cmd = [
"ffmpeg",
"-i", input_path,
"-vf", f"scale={WIDTH}:{HEIGHT}",
"-r", str(fps),
"-pix_fmt", "rgb24",
"-f", "rawvideo",
"-loglevel", "error",
"pipe:1",
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except FileNotFoundError:
sys.exit("ERROR: ffmpeg not found — install ffmpeg (e.g. sudo pacman -S ffmpeg).")
frames_written = 0
with open(output_path, 'wb') as out:
# Write header with placeholder frame count; patch it once streaming is done.
out.write(LSA_MAGIC)
out.write(struct.pack('<H', LED_COUNT))
out.write(struct.pack('<H', fps))
frame_count_pos = out.tell()
out.write(struct.pack('<I', 0)) # placeholder
out.write(bytes([LSA_FLAG_LOOP, 0, 0, 0]))
while True:
chunk = proc.stdout.read(FRAME_BYTES)
if len(chunk) < FRAME_BYTES:
break
out.write(chunk)
frames_written += 1
if frames_written % 100 == 0:
print(f" {frames_written} frames…", end='\r')
# Patch the frame count now that we know it.
out.seek(frame_count_pos)
out.write(struct.pack('<I', frames_written))
proc.wait()
if proc.returncode not in (0, 255): # 255 = SIGPIPE from closing pipe early, also fine
err = proc.stderr.read().decode()
if err:
sys.exit(f"ERROR: ffmpeg failed:\n{err}")
if frames_written == 0:
err = proc.stderr.read().decode()
sys.exit(f"ERROR: No frames decoded.\n{err}")
size_mb = (16 + frames_written * FRAME_BYTES) / 1_048_576
print(f"Wrote: {output_path}")
print(f" {frames_written} frames | {fps} fps | {size_mb:.1f} MB | playlist ready")
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('input', help='Input video file (MP4, MKV, GIF, …)')
ap.add_argument('output', help='Output .lsa file to copy to the SD card')
ap.add_argument('--fps', type=int, default=None,
help=f'Override FPS (default: source FPS, capped at {MAX_FPS})')
args = ap.parse_args()
src_fps = probe_fps(args.input)
fps = min(int(args.fps or round(src_fps)), MAX_FPS)
fps = max(fps, 1)
print(f"Source: {args.input} ({src_fps:.2f} fps → encoding at {fps} fps)")
write_lsa(args.input, args.output, fps)
if __name__ == '__main__':
main()