Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions wled00/const.h
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
#define REALTIME_MODE_ARTNET 6
#define REALTIME_MODE_TPM2NET 7
#define REALTIME_MODE_DDP 8
#define REALTIME_MODE_FSEQ 10 //used 10 instead of 9 to keep compatibility with TPM2RECORD once merged

//realtime override modes
#define REALTIME_OVERRIDE_NONE 0
Expand Down
1 change: 1 addition & 0 deletions wled00/data/edit.htm
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@
case "json":
case "xml":
case "ini":
case "fseq":
lang = ext;
}
}
Expand Down
321 changes: 321 additions & 0 deletions wled00/fseqrecord.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
#include "fseqrecord.h"

// This adds FSEQ-file storing and playback capabilities to WLED.
//
// >> Credit goes to @constant-flow for the original idea and structure created for TPM2 playback! <<
// https://github.com/Aircoookie/WLED/pull/2292
//
// What does it mean:
// You can now store short recorded animations on the ESP32 (in the ROM: no SD required) with a connected LED stripe.
//
// How to transfer the animation:
// WLED offers a web file manager under <IP_OF_WLED>/edit here you can upload a recorded *.FSEQ file
//
// How to create a recording:
// You can record with tools like xLights or Vixen
//
// How to load the animation:
// You can specify a preset to playback this recording with the following API command
// {"fseq":{"file":"/record.fseq"}}
//
// You can specify a preset to playback this recording on a specific segment
// {"fseq":{"file":"/record.fseq", "seg":{"id":2}}
// {"fseq":{"file":"/record.fseq", "seg":2}
//
// How to trigger the animation:
// Presets can be triggered multiple interfaces e.g. via the json API, via the web interface or with a connected IR remote
//
// How to configure SD card:
// Arduino only supports up to SDHC 32gb, and the card must be formatted using FAT32. To optimize read efficiency, pixel
// data is parsed in "chunks". This may need to be adjusted if the default "chunk" size is either too large (out of memory),
// or is too small (too slow).
//
// Most devices will work with the SD_MCC library, however some devices such as LilyGO / TTGO may require the SPI interface
// to be used instead. This can be configured by using the WLED_USE_SD_SPI parameter. SPI PIN configuration will use the
// default PINs as defined in Arduino, but can be overridden if needed using WLED_PIN_(SCK|MISO|MOSI|SS) defines.
//
// For example, TTGO T8 can be configured in platformio(_override).ini as follows.
// -D WLED_USE_SD_SPI
// -D WLED_PIN_SCK=14
// -D WLED_PIN_MISO=2
// -D WLED_PIN_MOSI=15
// -D WLED_PIN_SS=13
//
// What next:
// - Add support for compressed FSEQ files, not explored yet.
// - Add support for "sparse ranges", did not seem to be used by xLights.
// - Add support for complete file header, including variable length header, however not required for any scenario yet

// reference spec of FSEQ: https://github.com/Cryptkeeper/fseq-file-format
// first-party FPP file format guide: https://github.com/FalconChristmas/fpp/blob/master/docs/FSEQ_Sequence_File_Format.txt

// --- Recording playback related ---
File FSEQFile::recordingFile;
uint8_t FSEQFile::colorChannels = 3;
int32_t FSEQFile::recordingRepeats = RECORDING_REPEAT_LOOP;
uint32_t FSEQFile::now = 0;
uint32_t FSEQFile::next_time = 0;
uint16_t FSEQFile::playbackLedStart = 0; // first led to play animation on
uint16_t FSEQFile::playbackLedStop = 0; // led after the last led to play animation on
uint32_t FSEQFile::frame = 0; // current frame
uint16_t FSEQFile::buffer_size = 48; // data buffer size for file read operations (1 byte buffer == ~4 fps, 3 byte buffer == ~20 fps, etc...)
FSEQFile::file_header_t FSEQFile::file_header;

// --- File reading functions ---
inline uint32_t FSEQFile::readUInt32() {
uint32_t len = 4;
char buffer[len];
if (recordingFile.readBytes(buffer, len) < len) return 0;
uint32_t u32 = (((uint32_t)buffer[0]) + (((uint32_t)buffer[1]) << 8) +
(((uint32_t)buffer[2]) << 16) + (((uint32_t)buffer[3]) << 24));
return u32;
}
inline uint32_t FSEQFile::readUInt24() {
uint32_t len = 3;
char buffer[len];
if (recordingFile.readBytes(buffer, len) < len) return 0;
uint32_t u24 =
(((uint32_t)buffer[0]) + (((uint32_t)buffer[1]) << 8) + (((uint32_t)buffer[2]) << 16));
return u24;
}
inline uint16_t FSEQFile::readUInt16() {
uint32_t len = 2;
char buffer[len];
if (recordingFile.readBytes(buffer, len) < len) return 0;
uint16_t u16 = (((uint16_t)buffer[0]) + (((uint16_t)buffer[1]) << 8));
return u16;
}
inline uint8_t FSEQFile::readUInt8() {
uint32_t len = 1;
char buffer[len];
if (recordingFile.readBytes(buffer, len) < len) return 0;
uint8_t u8 = (((uint8_t)buffer[0]));
return u8;
Comment on lines +65 to +93
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine the fseqrecord.cpp file
find . -name "fseqrecord.cpp" -type f

Repository: wled/WLED

Length of output: 76


🏁 Script executed:

# Read the specific lines mentioned in the review (65-93)
sed -n '65,93p' wled00/fseqrecord.cpp | cat -n

Repository: wled/WLED

Length of output: 1240


🏁 Script executed:

# Get broader context to understand the file structure
head -n 100 wled00/fseqrecord.cpp | cat -n

Repository: wled/WLED

Length of output: 5097


Use fixed uint8_t buffers for binary parsing.

char buffer[len] uses a VLA (Variable Length Array), which violates C++ guidelines. Additionally, on builds where plain char is signed, bytes ≥ 0x80 will sign-extend when cast to uint32_t/uint16_t, corrupting multi-byte integer values like header_length, channel_count, and frame_count.

Replace with fixed uint8_t arrays and cast to char* for readBytes():

Example fix
 inline uint32_t FSEQFile::readUInt32() {
-  uint32_t len = 4;
-  char buffer[len];
-  if (recordingFile.readBytes(buffer, len) < len) return 0;
+  uint8_t buffer[4];
+  if (recordingFile.readBytes(reinterpret_cast<char*>(buffer), 4) < 4) return 0;
   uint32_t u32 = (((uint32_t)buffer[0]) + (((uint32_t)buffer[1]) << 8) +
     (((uint32_t)buffer[2]) << 16) + (((uint32_t)buffer[3]) << 24));
   return u32;
 }

Apply the same pattern to readUInt24(), readUInt16(), and readUInt8().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wled00/fseqrecord.cpp` around lines 65 - 93, The four reader helpers
(FSEQFile::readUInt32, readUInt24, readUInt16, readUInt8) use VLA char buffer[]
and cast bytes to unsigned integers, which can cause signed-extension and VLA
UB; replace each local buffer with a fixed-size uint8_t array (e.g. uint8_t
buffer[4] for readUInt32, [3] for readUInt24, [2] for readUInt16, [1] for
readUInt8), call recordingFile.readBytes(reinterpret_cast<char*>(buffer), len)
and build the integer from buffer[i] (no intermediate signed char casts) to
avoid sign-extension and eliminate VLAs.

}

bool FSEQFile::fileOnSD(const char* filepath)
{
#if defined(WLED_USE_SD) || defined(WLED_USE_SD_SPI)
#ifdef WLED_USE_SD_SPI
SPI.begin(WLED_PIN_SCK, WLED_PIN_MISO, WLED_PIN_MOSI, WLED_PIN_SS);
if (!WLED_SD.begin(WLED_PIN_SS)) return false;
#else
if (!WLED_SD.begin("/sdcard", true)) return false; // mounting the card failed
#endif

uint8_t cardType = WLED_SD.cardType();
if (cardType == CARD_NONE) return false; // no SD card attached
if (cardType == CARD_MMC || cardType == CARD_SD || cardType == CARD_SDHC)
{
return WLED_SD.exists(filepath);
}
#endif

return false; // unknown card type
}
bool FSEQFile::fileOnFS(const char* filepath)
{
return WLED_FS.exists(filepath);
}

// --- Common functions ---
void FSEQFile::handlePlayRecording()
{
now = millis();
if (realtimeMode != REALTIME_MODE_FSEQ) return;
if (now < next_time) return;

playNextRecordingFrame();
}
void FSEQFile::loadRecording(const char* filepath, uint16_t startLed, uint16_t stopLed)
{
// close any potentially open file
if (recordingFile.available()) {
clearLastPlayback();
recordingFile.close();
}

playbackLedStart = startLed;
playbackLedStop = stopLed;

// No start/stop defined
if (playbackLedStart == uint16_t(-1) || playbackLedStop == uint16_t(-1)) {
WS2812FX::Segment sg = strip.getSegment(-1);
playbackLedStart = sg.start;
playbackLedStop = sg.stop;
}

DEBUG_PRINTF("FSEQ load animation on LED %d to %d\r\n", playbackLedStart, playbackLedStop);

#if defined(WLED_USE_SD) || defined(WLED_USE_SD_SPI)
if (fileOnSD(filepath)) {
DEBUG_PRINTF("Read file from SD: %s\r\n", filepath);
recordingFile = WLED_SD.open(filepath, "rb");
}
else
#endif
if (fileOnFS(filepath)) {
DEBUG_PRINTF("Read file from FS: %s\r\n", filepath);
recordingFile = WLED_FS.open(filepath, "rb");
}
else {
DEBUG_PRINTF("File %s not found (%s)\r\n", filepath, USED_STORAGE_FILESYSTEMS);
return;
}

// Parse header
if ((uint64_t)recordingFile.available() < sizeof(file_header)) {
DEBUG_PRINTF("Invalid file size: %d\r\n", recordingFile.available());
recordingFile.close();
return;
}
for (int i = 0; i < 4; i++) {
file_header.identifier[i] = readUInt8();
}
file_header.channel_data_offset = readUInt16();
file_header.minor_version = readUInt8();
file_header.major_version = readUInt8();
file_header.header_length = readUInt16();
file_header.channel_count = readUInt32();
file_header.frame_count = readUInt32();
file_header.step_time = readUInt8();
file_header.flags = readUInt8();

// Print debug info
printHeaderInfo();

// Verify file format
if (file_header.identifier[0] != 'P' || file_header.identifier[1] != 'S' || file_header.identifier[2] != 'E' || file_header.identifier[3] != 'Q') {
DEBUG_PRINTF("Error reading FSEQ file %s header, invalid identifier\r\n", filepath);
recordingFile.close();
return;
}
if ((file_header.minor_version != V1FSEQ_MINOR_VERSION && file_header.major_version != V1FSEQ_MAJOR_VERSION) || (file_header.minor_version != V2FSEQ_MINOR_VERSION && file_header.major_version != V2FSEQ_MAJOR_VERSION)) {
DEBUG_PRINTF("Error reading FSEQ file %s header, unknown version 0x%" PRIX8 " 0x%" PRIX8 "\r\n", filepath, file_header.minor_version, file_header.major_version);
recordingFile.close();
return;
}
Comment on lines +193 to +197
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix the version gate.

This condition accepts any header with minor_version == 0, so unsupported major versions will pass validation and then be parsed as if they were supported.

Suggested fix
-  if ((file_header.minor_version != V1FSEQ_MINOR_VERSION && file_header.major_version != V1FSEQ_MAJOR_VERSION) || (file_header.minor_version != V2FSEQ_MINOR_VERSION && file_header.major_version != V2FSEQ_MAJOR_VERSION)) {
+  if (!((file_header.minor_version == V1FSEQ_MINOR_VERSION && file_header.major_version == V1FSEQ_MAJOR_VERSION) ||
+        (file_header.minor_version == V2FSEQ_MINOR_VERSION && file_header.major_version == V2FSEQ_MAJOR_VERSION))) {
     DEBUG_PRINTF("Error reading FSEQ file %s header, unknown version 0x%" PRIX8 " 0x%" PRIX8 "\r\n", filepath, file_header.minor_version, file_header.major_version);
     recordingFile.close();
     return;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wled00/fseqrecord.cpp` around lines 193 - 197, The version check in
fseqrecord.cpp incorrectly uses combined != and &&/|| which lets invalid headers
through; update the condition that currently references
file_header.minor_version, file_header.major_version, V1FSEQ_MINOR_VERSION,
V1FSEQ_MAJOR_VERSION, V2FSEQ_MINOR_VERSION and V2FSEQ_MAJOR_VERSION so it only
accepts the header if it matches either the V1 pair OR the V2 pair (i.e. wrap
each pair comparison and OR the pairs), and negate that combined match for the
error path; keep the existing DEBUG_PRINTF and recordingFile.close() behavior
when the check fails.

if (((uint64_t)file_header.channel_count * (uint64_t)file_header.frame_count) + file_header.header_length > UINT32_MAX) {
DEBUG_PRINTF("Error reading FSEQ file %s header, file too long (max 4gb)\r\n", filepath);
recordingFile.close();
return;
}
if (file_header.step_time < 1) {
DEBUG_PRINTF("Invalid step time %d, using default %d instead\r\n", file_header.step_time, FSEQ_DEFAULT_STEP_TIME);
file_header.step_time = FSEQ_DEFAULT_STEP_TIME;
}

if (realtimeOverride == REALTIME_OVERRIDE_ONCE) {
realtimeOverride = REALTIME_OVERRIDE_NONE;
}

recordingRepeats = RECORDING_REPEAT_DEFAULT;
playNextRecordingFrame();
}

void FSEQFile::printHeaderInfo() {
DEBUG_PRINTLN("FSEQ file_header:");
DEBUG_PRINT(F(" channel_data_offset = ")); DEBUG_PRINTLN(file_header.channel_data_offset);
DEBUG_PRINT(F(" minor_version = ")); DEBUG_PRINTLN(file_header.minor_version);
DEBUG_PRINT(F(" major_version = ")); DEBUG_PRINTLN(file_header.major_version);
DEBUG_PRINT(F(" header_length = ")); DEBUG_PRINTLN(file_header.header_length);
DEBUG_PRINT(F(" channel_count = ")); DEBUG_PRINTLN(file_header.channel_count);
DEBUG_PRINT(F(" frame_count = ")); DEBUG_PRINTLN(file_header.frame_count);
DEBUG_PRINT(F(" step_time = ")); DEBUG_PRINTLN(file_header.step_time);
DEBUG_PRINT(F(" flags = ")); DEBUG_PRINTLN(file_header.flags);
}

void FSEQFile::processFrameData()
{
uint16_t packetLength = file_header.channel_count;
uint16_t lastLed = min(playbackLedStop, uint16_t(playbackLedStart + (packetLength / 3)));

// process data in "chunks" to speed up read operation
char frame_data[buffer_size];
CRGB* crgb = reinterpret_cast<CRGB*>(frame_data);

uint16_t bytes_remaining = packetLength;
uint16_t index = playbackLedStart;
while (index < lastLed && bytes_remaining > 0) {
uint16_t length = min(bytes_remaining, buffer_size);
recordingFile.readBytes(frame_data, length);
bytes_remaining -= length;

for (uint16_t offset = 0; offset < length / 3; offset++) {
setRealtimePixel(index, (byte)crgb[offset].r, (byte)crgb[offset].g, (byte)crgb[offset].b, 0);
if (++index > lastLed) break; // end of string or data
Comment on lines +230 to +246
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stop at the exclusive end bound.

playbackLedStop is documented as “the LED after the last LED to play animation on”, but the inner loop breaks only after index exceeds lastLed. When the frame fills the whole target range, this writes one pixel into the next segment.

Suggested fix
-      if (++index > lastLed) break; // end of string or data
+      if (++index >= lastLed) break; // end of string or data
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wled00/fseqrecord.cpp` around lines 230 - 246, The loop currently allows
writing one pixel past the exclusive end because the post-increment check uses
"> lastLed"; change the boundary check in the inner loop to enforce the
exclusive end by using ">= lastLed" (i.e., replace the if (++index > lastLed)
break; with if (++index >= lastLed) break;) so that index never reaches the
playbackLedStop/exclusive bound; keep the existing lastLed calculation and loop
conditions (lastLed, index, playbackLedStart, playbackLedStop, packetLength)
intact.

}
}

strip.show();

// tell ui we are playing the recording right now
uint8_t mode = REALTIME_MODE_FSEQ;
realtimeLock(realtimeTimeoutMs, mode);

next_time = now + file_header.step_time;
}

void FSEQFile::clearLastPlayback() {

for (uint16_t i = playbackLedStart; i < playbackLedStop; i++)
{
setRealtimePixel(i, 0, 0, 0, 0);
}

frame = 0; // reset frame index
}

bool FSEQFile::stopBecauseAtTheEnd()
{
// if recording reached end loop or stop playback
if (!recordingFile.available())
{
if (recordingRepeats == RECORDING_REPEAT_LOOP)
{
recordingFile.seek(0); // go back the beginning of the recording
}
else if (recordingRepeats > 0)
{
recordingFile.seek(0); // go back the beginning of the recording
recordingRepeats--;
DEBUG_PRINTF("Repeat recording again for: %" PRId32 "\r\n", recordingRepeats);
Comment on lines +269 to +282
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Reset the frame counter when looping or repeating.

After the first pass, frame == frame_count. Seeking back to the start without resetting frame makes the next computed offset land past EOF, so repeats/loops never restart correctly.

Suggested fix
   if (!recordingFile.available())
   {
     if (recordingRepeats == RECORDING_REPEAT_LOOP)
     {
+      frame = 0;
       recordingFile.seek(0); // go back the beginning of the recording
     }
     else if (recordingRepeats > 0)
     {
+      frame = 0;
       recordingFile.seek(0); // go back the beginning of the recording
       recordingRepeats--;
       DEBUG_PRINTF("Repeat recording again for: %" PRId32 "\r\n", recordingRepeats);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wled00/fseqrecord.cpp` around lines 269 - 282, In
FSEQFile::stopBecauseAtTheEnd(), when you call recordingFile.seek(0) for both
the loop case (recordingRepeats == RECORDING_REPEAT_LOOP) and the repeat case
(recordingRepeats > 0) you must also reset the frame counter (set frame = 0) so
subsequent offset calculations start from the beginning; update the two places
that call recordingFile.seek(0) (and any adjoining debug/repeat logic that
decrements recordingRepeats) to reset frame to zero immediately after seeking to
avoid computing offsets past EOF.

}
else
{
DEBUG_PRINTLN(F("Finished playing recording, disabling realtime mode"));
uint8_t mode = REALTIME_MODE_INACTIVE;
realtimeLock(10, mode);
recordingFile.close();
clearLastPlayback();
return true;
}
}

return false;
}

// scan and forward until next frame was read (this will process commands)
void FSEQFile::playNextRecordingFrame()
{
if (stopBecauseAtTheEnd()) return;

// go to next FSEQ frame offset
uint32_t offset = file_header.channel_count;
offset *= frame++;
offset += file_header.header_length;

if (!recordingFile.seek(offset)) {
// check position, avoid false error when already at correct offset
if (recordingFile.position() != offset) {
DEBUG_PRINTLN(F("Failed to seek to proper offset for channel data!"));
DEBUG_PRINT(F(" offset: ")); DEBUG_PRINTLN(offset);
DEBUG_PRINT(F(" position: ")); DEBUG_PRINTLN(recordingFile.position());
DEBUG_PRINT(F(" available: ")); DEBUG_PRINTLN(recordingFile.available());
return;
}
}

// process everything until the next frame
processFrameData();
}
Loading