-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfseq_to_lsa.py
More file actions
139 lines (112 loc) · 4.4 KB
/
fseq_to_lsa.py
File metadata and controls
139 lines (112 loc) · 4.4 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
#!/usr/bin/env python3
"""
Convert an xLights FSEQ v2 (uncompressed) sequence to .lsa for SD card playback.
The output .lsa has the loop flag set so the Pico firmware loops it forever.
Usage:
python3 scripts/fseq_to_lsa.py animation.fseq animation.lsa
python3 scripts/fseq_to_lsa.py animation.fseq animation.lsa --fps 30
No external dependencies — standard library only.
"""
import argparse
import struct
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
LSA_MAGIC = b'LSA1'
LSA_FLAG_LOOP = 0x01
def read_fseq(path):
with open(path, 'rb') as f:
raw = f.read()
if len(raw) < 32:
sys.exit(f"ERROR: File too short to be a valid FSEQ: {path}")
# FSEQ v2 header layout (all little-endian):
# [0-3] identifier / magic
# [4-5] data offset — byte where frame data begins
# [6] minor version
# [7] major version (must be 2)
# [8-9] variable header length
# [10-13] channel count per frame
# [14-17] frame count
# [18] step time in ms (50 → 20 fps, 33 → ~30 fps, 25 → 40 fps)
# [19] flags
# [20] compression type (0 = none, 1 = zstd, 2 = zlib)
major_ver = raw[7]
minor_ver = raw[6]
if major_ver != 2:
sys.exit(
f"ERROR: Only FSEQ v2 is supported (got v{major_ver}.{minor_ver}).\n"
"Save your xLights sequence — the default .fseq file is always v2."
)
data_offset = struct.unpack_from('<H', raw, 4)[0]
channel_count = struct.unpack_from('<I', raw, 10)[0]
frame_count = struct.unpack_from('<I', raw, 14)[0]
step_ms = raw[18]
comp_type = raw[20]
if comp_type != 0:
sys.exit(
f"ERROR: FSEQ uses compression type {comp_type}.\n"
"Re-save from xLights with compression disabled:\n"
" File > Preferences > Sequence File Format > uncheck 'Compress FSEQ'"
)
if channel_count < FRAME_BYTES:
sys.exit(
f"ERROR: FSEQ has {channel_count} channels but {FRAME_BYTES} are needed "
f"for {LED_COUNT} LEDs ({LED_COUNT} x 3).\n"
f"Make sure the xLights layout/controller covers all {LED_COUNT} pixels."
)
fps = round(1000 / step_ms) if step_ms > 0 else 20
print(f"FSEQ v{major_ver}.{minor_ver}: {frame_count} frames, "
f"{channel_count} ch/frame, {step_ms} ms/frame → {fps} fps")
frames = []
pos = data_offset
for i in range(frame_count):
if pos + channel_count > len(raw):
print(f"WARNING: Truncated at frame {i} — file may be incomplete.")
break
# Take only the first FRAME_BYTES channels (our LED channels)
frames.append(raw[pos: pos + FRAME_BYTES])
pos += channel_count
return frames, fps
def write_lsa(frames, fps, path):
"""
LSA header (16 bytes):
[0-3] 'LSA1'
[4-5] LED count (uint16 LE)
[6-7] FPS (uint16 LE)
[8-11] frame count (uint32 LE)
[12] flags (0x01 = loop)
[13-15] reserved
Followed immediately by frame_count * FRAME_BYTES of raw RGB data.
"""
header = (
LSA_MAGIC
+ struct.pack('<H', LED_COUNT)
+ struct.pack('<H', min(fps, 0xFFFF))
+ struct.pack('<I', len(frames))
+ bytes([LSA_FLAG_LOOP, 0, 0, 0])
)
assert len(header) == 16
with open(path, 'wb') as f:
f.write(header)
for frame in frames:
f.write(frame)
size_kb = (16 + len(frames) * FRAME_BYTES) / 1024
print(f"Wrote: {path}")
print(f" {len(frames)} frames | {fps} fps | {size_kb:.0f} KB | loops forever")
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('input', help='Input .fseq file exported from xLights')
ap.add_argument('output', help='Output .lsa file to copy to the SD card')
ap.add_argument('--fps', type=int, default=None,
help='Override FPS (default: taken from FSEQ step time)')
args = ap.parse_args()
frames, fps = read_fseq(args.input)
if args.fps:
fps = args.fps
if not frames:
sys.exit("ERROR: No frames extracted.")
write_lsa(frames, fps, args.output)
if __name__ == '__main__':
main()