diff --git a/docs/assets/light/effects/FseqPlayerEffect.gif b/docs/assets/light/effects/FseqPlayerEffect.gif index 35ccfaf2..41ac0d2b 100644 Binary files a/docs/assets/light/effects/FseqPlayerEffect.gif and b/docs/assets/light/effects/FseqPlayerEffect.gif differ diff --git a/docs/assets/light/effects/FseqPlayerEffect.png b/docs/assets/light/effects/FseqPlayerEffect.png index 9a4e9878..2bfcce6b 100644 Binary files a/docs/assets/light/effects/FseqPlayerEffect.png and b/docs/assets/light/effects/FseqPlayerEffect.png differ diff --git a/docs/help/help.json b/docs/help/help.json index dd5b6166..5c7fce6a 100644 --- a/docs/help/help.json +++ b/docs/help/help.json @@ -892,6 +892,62 @@ "source": "https://github.com/MoonModules/MoonLight/blob/main/src/light/effects/FreqSawsEffect.h", "tests": "https://moonmodules.org/MoonLight/reference/tests/unit-tests.html#freqsawseffect" }, + "FseqPlayerEffect": { + "line": "Plays FSEQ animation files from the filesystem onto this effect's layer, with a playlist and the standard transport controls.", + "summary": "FSEQ is the Falcon Player/xLights sequence format: a flat, per-frame byte stream with no pixel semantics of its own. The playlist follows `FixtureProfilesModule`'s `EditableListSource` shape, the transport `DemoReelEffect`'s cycle/shuffle logic.", + "attribution": "[FPP](https://github.com/FalconChristmas/fpp)", + "guide": [ + { + "title": "Compressed files need ESP32", + "text": "A zlib-compressed FSEQ (xLights/FPP's default) plays only on ESP32 boards; desktop and Teensy builds show a clean error instead of playing it.\n\nZstd-compressed files do not play on any target yet.\n\nExport a track uncompressed if it needs to play everywhere." + } + ], + "image": "https://moonmodules.org/MoonLight/assets/light/effects/FseqPlayerEffect.gif", + "controls": { + "play": { + "line": "Starts a stopped transport, or resumes a paused one." + }, + "pause": { + "line": "Holds the current frame on screen." + }, + "stop": { + "line": "Stops playback, resets the position and blanks the lights." + }, + "previous": { + "line": "Jumps to the previous track in the playlist." + }, + "next": { + "line": "Jumps to the next track in the playlist." + }, + "shuffle": { + "line": "Picks the next track at random instead of in playlist order." + }, + "loopMode": { + "line": "At a track's end: stop, repeat it, or repeat the playlist." + }, + "fpsSource": { + "line": "where the rate comes from: the file's step time, or fps_" + }, + "fps": { + "line": "the manual playback rate; 1000/fps milliseconds per frame" + }, + "playlist": { + "line": "The tracks to play, in order; each row picks a scanned .fseq file." + }, + "position": { + "line": "How far into the current track playback is, in frames." + }, + "rescan": { + "line": "Looks through the filesystem for .fseq files again." + } + }, + "role": "effect", + "tags": "🖼️", + "dim": 3, + "doc": "light/effects.md#fseqplayer", + "source": "https://github.com/MoonModules/MoonLight/blob/main/src/light/effects/FseqPlayerEffect.h", + "tests": "https://moonmodules.org/MoonLight/reference/tests/unit-tests.html#fseqplayereffect" + }, "GameOfLifeEffect": { "line": "Conway's cellular automaton generalized to 2D and 3D, with selectable rulesets and a custom `B#/S#` rule.", "summary": "Cells live, die and are born by counting their live neighbors, on a panel or through a cube. New cells take a neighbor's palette color, and dead ones blur toward the background.", @@ -1259,30 +1315,6 @@ "source": "https://github.com/MoonModules/MoonLight/blob/main/src/light/effects/ParticlesEffect.h", "tests": "https://moonmodules.org/MoonLight/reference/tests/unit-tests.html#particleseffect" }, - "FseqPlayerEffect": { - "line": "An FSEQ show played from a file: the frame-by-frame channel data xLights and Falcon Player write.", - "summary": "A show, uncompressed FSEQ v2, lands where a sender-mapped [NetworkReceive](https://moonmodules.org/MoonLight/moonmodules/light/effects.html#networkreceive) stream does, so alone on its layer it reaches the lights in one copy on any layout. Reading the file is the next step, and its status says so.", - "attribution": "[FPP](https://github.com/FalconChristmas/fpp)", - "image": "https://moonmodules.org/MoonLight/assets/light/effects/FseqPlayerEffect.gif", - "alt": "The reference sweep fseq_play writes, the show an FSEQ player plays", - "controls": { - "file": { - "line": "The show's path, an `.fseq` file on flash or under `/sd`." - }, - "loop": { - "line": "Start again at the end." - }, - "speed": { - "line": "The playback speed, in percent of the file's step time." - } - }, - "role": "effect", - "tags": "🖼️", - "dim": 3, - "doc": "light/effects.md#fseqplayer", - "source": "https://github.com/MoonModules/MoonLight/blob/main/src/light/effects/FseqPlayerEffect.h", - "tests": "https://moonmodules.org/MoonLight/reference/tests/unit-tests.html#fseqplayereffect" - }, "PictureEffect": { "line": "The active [Picture service](https://moonmodules.org/MoonLight/moonmodules/core/services.html#picture)'s picture on the layer, so a picture of any size shows on a layout of any size.", "summary": "A modifier rotates, mirrors or tiles it as it does any effect, and a cube shows it on every slice.", diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 99e845c3..3cc410d7 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,21 +4,21 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1611 finding(s)** across 322 file(s). 2.40% of all lines are duplicated, 3345 lines. +**1622 finding(s)** across 324 file(s). 2.38% of all lines are duplicated, 3363 lines. ## By rule | Rule | Findings | |---|---:| -| duplicated block | 742 | +| duplicated block | 748 | | complex function | 296 | -| blocking call on the render path | 191 | +| blocking call on the render path | 194 | | deeply nested | 111 | | module type in the UI | 78 | | long function | 49 | | long parameter list | 35 | | large stack frame | 33 | -| raw allocation in light | 28 | +| raw allocation in light | 30 | | large file | 26 | | name spread | 22 | @@ -44,8 +44,8 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta | Area | Findings | |---|---:| -| `test` | 557 | -| `light` | 402 | +| `test` | 561 | +| `light` | 409 | | `core` | 364 | | `platform` | 176 | | `ui` | 81 | @@ -63,7 +63,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/system/HttpServerModule.cpp` | 43 | `HttpServerModule::handleConnection`, complex function 114 | | `src/core/moonlive/MoonLiveCompiler.cpp` | 41 | `moonlive::Parser::parseDecl`, complex function 54 | | `src/platform/desktop/platform_desktop.cpp` | 40 | `platform::spawnEncoderProcess`, complex function 58 | -| `src/ui/app.js` | 35 | `app.js`, large file 9253 | +| `src/ui/app.js` | 35 | `app.js`, large file 9254 | | `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | | `src/light/drivers/ParallelLedDriver.h` | 27 | `ParallelLedDriver::reinit`, complex function 36 | | `src/light/drivers/PreviewDriver.h` | 27 | `PreviewDriver::buildCoordTable`, complex function 37 | @@ -120,6 +120,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/system/SystemModule.h` | 7 | `SystemModule in 30 files`, name spread 30 | | `src/core/util/JsonUtil.h` | 7 | `json::parseString`, complex function 34 | | `src/light/effects/FishTankEffect.h` | 7 | `lines 239-253, also in src/light/effects/PacmanEffect.h:239`, duplicated block 15 | +| `src/light/effects/FseqPlayerEffect.h` | 7 | `lines 229-236, also in src/core/system/WiFiModule.h:367`, duplicated block 8 | | `src/platform/esp32/platform_esp32_hub75.cpp` | 7 | `platform::hub75Init`, complex function 32 | | `src/platform/esp32/platform_esp32_rmt.cpp` | 7 | `platform::rmtWs2812LoopbackFrame`, complex function 31 | | `test/js/ui-ws-scheme.test.mjs` | 7 | `lines 10-22, also in test/js/ui-render-guard.test.mjs:13`, duplicated block 13 | @@ -127,7 +128,7 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_NdiDriver.cpp` | 7 | `lines 29-46, also in test/unit/light/unit_PanelCardDriver.cpp:22`, duplicated block 18 | | `test/unit/light/unit_PlasmaEffect.cpp` | 7 | `lines 24-39, also in test/unit/light/unit_MetaballsEffect.cpp:22`, duplicated block 16 | | `src/core/system/FirmwareUpdateModule.h` | 6 | `FirmwareUpdateModule in 13 files`, name spread 13 | -| `src/light/drivers/FixtureProfilesModule.h` | 6 | `lines 254-261, also in src/core/system/WiFiModule.h:367`, duplicated block 8 | +| `src/light/drivers/FixtureProfilesModule.h` | 6 | `lines 254-259, also in src/light/effects/FseqPlayerEffect.h:229`, duplicated block 6 | | `src/light/drivers/NetworkSendDriver.h` | 6 | `lines 236-244, also in src/light/drivers/PanelCardDriver.h:188`, duplicated block 9 | | `src/platform/desktop/moonlive_asm_x86_64.cpp` | 6 | `lines 556-563, also in src/platform/desktop/moonlive_asm_x86_64.cpp:514`, duplicated block 8 | | `src/platform/esp32/platform_esp32_improv.cpp` | 6 | `platform::improvTask`, complex function 22 | @@ -180,6 +181,7 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_BlurzEffect.cpp` | 4 | `lines 137-155, also in test/unit/light/unit_GEQEffect.cpp:159`, duplicated block 19 | | `test/unit/light/unit_BouncingBallsEffect.cpp` | 4 | `lines 75-88, also in test/unit/light/unit_RubiksCubeEffect.cpp:103`, duplicated block 14 | | `test/unit/light/unit_FixedRectangleEffect.cpp` | 4 | `lines 90-103, also in test/unit/light/unit_SolidEffect.cpp:9`, duplicated block 14 | +| `test/unit/light/unit_FseqPlayerEffect.cpp` | 4 | `lines 699-706, also in test/unit/light/unit_FseqPlayerEffect.cpp:189`, duplicated block 8 | | `test/unit/light/unit_Layer_extrude.cpp` | 4 | `lines 155-168, also in test/unit/light/unit_Layer_extrude.cpp:124`, duplicated block 14 | | `test/unit/light/unit_Layer_live_modifier.cpp` | 4 | `lines 96-105, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 10 | | `test/unit/light/unit_MoonLiveScripts.cpp` | 4 | `lines 292-300, also in test/unit/light/unit_MoonLiveScripts.cpp:264`, duplicated block 9 | @@ -300,7 +302,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/OscPacket.h` | 1 | `osc::parse`, complex function 32 | | `src/core/services/Services.h` | 1 | `Services in 17 files`, name spread 17 | | `src/core/system/FileManagerModule.cpp` | 1 | `platform::filesystemUsed in tick1s`, blocking call on the render path 1 | -| `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 25 files`, name spread 25 | +| `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 26 files`, name spread 26 | | `src/core/util/Addressing.h` | 1 | `sendAddressed`, complex function 11 | | `src/core/util/CaptivePortal.h` | 1 | `captive::dnsReply`, complex function 21 | | `src/core/util/HostResolver.h` | 1 | `HostResolver::lookup`, complex function 13 | @@ -347,7 +349,7 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | | `src/platform/esp32/platform_esp32_usb_midi.cpp` | 1 | `lines 253-259, also in src/platform/teensy/platform_teensy.cpp:767`, duplicated block 7 | | `src/platform/esp32/platform_esp32_usbvideo.cpp` | 1 | `lines 10-17, also in src/platform/teensy/platform_teensy.cpp:774`, duplicated block 8 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1492 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1497 | | `src/platform/teensy/platform_teensy_gpio_dma.cpp` | 1 | `lines 310-315, also in src/platform/esp32/platform_esp32_parlio.cpp:286`, duplicated block 6 | | `src/platform/teensy/platform_teensy_improv.cpp` | 1 | `platform::improvProvisioningInit`, long parameter list 9 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index 68f6410c..fb6c418c 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **3080 warning(s)** across 291 page(s). +**0 error(s)** and **3077 warning(s)** across 290 page(s). **4388 appendix line(s)** after `@technical`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. @@ -16,7 +16,7 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| over-wide comment lines | 0 | 1565 | +| over-wide comment lines | 0 | 1562 | | split paragraphs | 0 | 855 | | multi-line comment blocks | 0 | 311 | | appendix sections nothing refers to | 0 | 199 | @@ -31,7 +31,7 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | Summary page | Errors | Warnings | |---|---:|---:| -| `(tests, no card)` | 0 | 1955 | +| `(tests, no card)` | 0 | 1952 | | `platform/index.md` | 0 | 645 | | `core/supporting.md` | 0 | 113 | | `light/power-functions.md` | 0 | 112 | @@ -49,7 +49,7 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul ### (tests, no card) -**0 error(s)** and **1955 warning(s)** across 161 file(s). +**0 error(s)** and **1952 warning(s)** across 160 file(s). | Findings | File | | |---:|---|---| @@ -63,9 +63,9 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul | 46 | `test/unit/light/unit_ParallelSlots.cpp` | warning | | 45 | `test/unit/core/unit_FilesystemModule_persistence.cpp` | warning | | 43 | `test/unit/light/unit_MoonLiveScripts.cpp` | warning | -| 1-42 each | *151 more warning files, 1218 findings* | | +| 1-42 each | *150 more warning files, 1215 findings* | | -By rule, warnings: 1350 over-wide comment lines, 252 split paragraphs, 196 multi-line comment blocks, 76 over-long sentences, 39 member deep dives, 34 over-wide doc lines, 8 appendix sections nothing refers to. +By rule, warnings: 1347 over-wide comment lines, 252 split paragraphs, 196 multi-line comment blocks, 76 over-long sentences, 39 member deep dives, 34 over-wide doc lines, 8 appendix sections nothing refers to. ### platform/index.md diff --git a/docs/reference/metrics/prose.md b/docs/reference/metrics/prose.md index b4eff55c..5ad2817b 100644 --- a/docs/reference/metrics/prose.md +++ b/docs/reference/metrics/prose.md @@ -9,14 +9,14 @@ This number is the debt behind that gate, and it RATCHETS: a rise fails the chec docgen.md does. A comment is prose wherever it lives, so a `//` in the web interface and a `#` in a script are counted beside a `///` in a header. -**2396 finding(s)** across 335 file(s). +**2409 finding(s)** across 338 file(s). | rule | findings | |---|---| | `MoonLight.EmDash` | 1181 | -| `MoonLight.Weasel` | 618 | +| `MoonLight.Weasel` | 628 | | `MoonLight.SentenceLength` | 338 | -| `MoonLight.Temporal` | 187 | +| `MoonLight.Temporal` | 190 | | `MoonLight.Spelling` | 42 | | `MoonLight.SelfReference` | 21 | | `MoonLight.NegatedHeading` | 9 | diff --git a/docs/work/future/backlog-core.md b/docs/work/future/backlog-core.md index cc4171a4..88c0086d 100644 --- a/docs/work/future/backlog-core.md +++ b/docs/work/future/backlog-core.md @@ -2,6 +2,12 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/core/`, `src/platform/`, build, CI, network, persistence, UI). The light-domain counterpart is [backlog-light.md](backlog-light.md); items that genuinely span both are in [backlog-mixed.md](backlog-mixed.md). Index + overview: [README.md](index.md). Completed items are removed. +### A Progress total rides the value patch (2026-10-19) + +A Progress control's total travels only in `writeControlMetadata` (Control.cpp), so a changed total needs a schema resync, which `rebuildControls()` triggers because `aux` is in the schema hash. +One line in `writeControlValue` and one in `app.js`'s patch handler would let the total ride the value, and the hash could then skip it, so a track change in FseqPlayerEffect stops resyncing every browser. +Pin it with a test that changes the total, patches, and finds the browser-side max moved with no resync. + ### An SD card inserted after boot mounts only when its module restarts (2026-10-12) A mount attempt on an empty slot runs the SDMMC card-init sequence to its timeouts, so `SdCardModule` never retries one on the render thread: a card inserted later mounts when the module is switched off and on, on a pin edit, or at boot. A pull is still noticed within a second through a cheap presence probe. Detecting an insertion without that stall needs a card-detect pin where the slot has one, or a mount attempt off the render thread. The timed retry PR #136 carried was dropped for this reason before its merge. diff --git a/src/core/module/Control.cpp b/src/core/module/Control.cpp index 93951653..f185179b 100644 --- a/src/core/module/Control.cpp +++ b/src/core/module/Control.cpp @@ -219,8 +219,9 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { } case ControlType::Progress: // The unit rides in min (see addProgress); the UI scales by it and picks KB, MB or GB itself. + static constexpr const char* kProgressUnitNames[] = {"B", "KiB", "count"}; // indexed by ProgressUnit sink.appendf(",\"total\":%lu,\"unit\":\"%s\"", static_cast(c.aux), - static_cast(c.min) == ProgressUnit::KiB ? "KiB" : "B"); + kProgressUnitNames[static_cast(c.min)]); return; case ControlType::List: { // The detail an expanded row shows, as an array parallel to the summary `value`, which stays small for the collapsed list. diff --git a/src/core/module/Control.h b/src/core/module/Control.h index f81f9abc..72166e15 100644 --- a/src/core/module/Control.h +++ b/src/core/module/Control.h @@ -70,7 +70,8 @@ enum class ControlType : uint8_t { /// The unit a progress control's value and total count in; the UI sizes the label from it. enum class ProgressUnit : uint8_t { Bytes, ///< a byte count, which fits a uint32_t up to 4 GiB - KiB ///< kibibytes, for a store whose byte count overruns a uint32_t + KiB, ///< kibibytes, for a store whose byte count overruns a uint32_t + Count ///< a plain count (frames, items), shown as a number with no byte suffix }; class JsonSink; // forward-declared so the descriptor can hold a pointer diff --git a/src/light/effects/FseqPlayerEffect.h b/src/light/effects/FseqPlayerEffect.h index 8c8af26f..c2b346d5 100644 --- a/src/light/effects/FseqPlayerEffect.h +++ b/src/light/effects/FseqPlayerEffect.h @@ -1,44 +1,119 @@ #pragma once -#include "light/effects/ChannelDataSink.h" // where the frames land, held and placed, as Network Receive's do +#include "light/effects/ChannelDataSink.h" // where the decoded frame lands, held and placed in wiring order #include "light/effects/EffectBase.h" +#include "light/util/FseqFile.h" // the FSEQ header/sparse-range parser this effect plays back +#include "platform/platform.h" // fsReadStreamXxx, fsList, zlibInflateAll +#include "core/util/JsonSink.h" // the playlist's own ListSource serialization +#include "core/util/JsonUtil.h" // restoreList: recursive reader for the persisted playlist + +#include +#include // qsort: the picker's file order +#include + namespace mm { -/// An FSEQ show played from a file: the frame-by-frame channel data xLights and Falcon Player write. -/// @card FseqPlayerEffect.gif The reference sweep fseq_play writes, the show an FSEQ player plays +/// Plays FSEQ animation files from the filesystem onto this effect's layer, with a playlist and the standard transport controls. +/// @card FseqPlayerEffect.gif /// -/// A show, uncompressed FSEQ v2, lands where a sender-mapped @ref NetworkReceiveEffect stream does, so alone on its layer it reaches the lights in one copy on any layout. Reading the file is the next step, and its status says so. +/// FSEQ is the Falcon Player/xLights sequence format: a flat, per-frame byte stream with no pixel semantics of its own. The playlist follows `FixtureProfilesModule`'s `EditableListSource` shape, the transport `DemoReelEffect`'s cycle/shuffle logic. /// /// @attribution FPP -class FseqPlayerEffect : public EffectBase { +/// +/// @guide +/// +/// ## Compressed files need ESP32 +/// +/// A zlib-compressed FSEQ (xLights/FPP's default) plays only on ESP32 boards; desktop and Teensy builds show a clean error instead of playing it. +/// +/// Zstd-compressed files do not play on any target yet. +/// +/// Export a track uncompressed if it needs to play everywhere. +class FseqPlayerEffect : public EffectBase, public ListSource { public: + /// The transport's three states. + enum class PlayState : uint8_t { Stopped, Playing, Paused }; + /// The three loopMode values, public so a test can select one by name. + static constexpr uint8_t kLoopOff = 0, kLoopOne = 1, kLoopAll = 2; + /// The two fpsSource values, public so a test can select one by name. + static constexpr uint8_t kFpsFromFile = 0, kFpsManual = 1; + + /// Free the playlist's path strings, the one memory a disabled player keeps. + ~FseqPlayerEffect() override { clearTracks(); } + /// Catalog tags: a recorded show, played back. const char* tags() const override { return "🖼️"; } - /// The show's path, an `.fseq` file on flash or under `/sd`. - char file[64] = ""; - /// Start again at the end. - bool loop = true; - /// The playback speed, in percent of the file's step time. - uint8_t speed = 100; - - /// Publish the file, looping and the speed. + + /// Bind the transport buttons, the shuffle/loop controls, the playlist and the position gauge. void defineControls() override { - controls_.addText("file", file, sizeof(file)); // a path: the file picker opens a text editor, which a binary show has no use for - controls_.addControl("loop", loop); - controls_.addControl("speed", speed, 1, 255); + /// Starts a stopped transport, or resumes a paused one. + controls_.addButton("play"); + /// Holds the current frame on screen. + controls_.addButton("pause"); + /// Stops playback, resets the position and blanks the lights. + controls_.addButton("stop"); + /// Jumps to the previous track in the playlist. + controls_.addButton("previous"); + /// Jumps to the next track in the playlist. + controls_.addButton("next"); + /// Picks the next track at random instead of in playlist order. + controls_.addControl("shuffle", shuffle_); + /// At a track's end: stop, repeat it, or repeat the playlist. + controls_.addSelect("loopMode", loopMode_, kLoopModeOptions, 3); + controls_.addSelect("fpsSource", fpsSource_, kFpsSourceOptions, 2); + controls_.addControl("fps", fps_, 1, kMaxFps); + controls_.setHidden(controls_.count() - 1, fpsSource_ != kFpsManual); + /// The tracks to play, in order; each row picks a scanned .fseq file. + controls_.addList("playlist", *this); + /// How far into the current track playback is, in frames. + controls_.addProgress("position", frameIndex_, frameCount_, ProgressUnit::Count); + /// Looks through the filesystem for .fseq files again. + controls_.addButton("rescan"); } - /// A new file sizes the sink again. - bool affectsPrepare(const char* name) const override { return std::strcmp(name, "file") == 0; } + /// Scan the filesystem for animations once. + void setup() override { + EffectBase::setup(); + rng_.seed(platform::micros()); // a different shuffle order each boot + if (enabled()) rescanFiles(); + } - /// Size the sink to the layer in wiring order, a frame of the file being one light per physical light. + /// Size the sink to the layer, then start playing on its own once the effect becomes enabled. void prepare() override { - sink_.prepare(/*senderMapped=*/true); - setStatus("reads no FSEQ file yet: playback is the next step"); + sink_.prepare(/*senderMapped=*/true); // an FSEQ frame is already in wiring order, the same shape NetworkReceiveEffect's sender-mapped frames take, so a serpentine layout is not applied twice + sizeExpandBuf(); + if (!fileNames_) rescanFiles(); // the scan cache is freed on release, so an enabled effect rebuilds it + if (state_ == PlayState::Stopped && playlistCount_ > 0) { + visited_ = 0; + // Set before loadTrack: its own refreshStatus() must see Playing already, though loadTrack corrects this back on its own failure paths. + state_ = PlayState::Playing; + loadTrack(cursor_); + } + } + + /// Close the open stream and force a clean stop before the base frees the frame buffers. + void release() override { + closeTrackStream(); + state_ = PlayState::Stopped; + fileCount_ = 0; // the names below are freed with the scratch buffers + EffectBase::release(); + } + + /// Render last tick's decoded frame, then decode the next due one: the blocking read lives here, never in `tick()`, at most one frame per call. + void tick20ms() MM_NONBLOCKING override { + if (state_ != PlayState::Playing) return; + const uint32_t now = elapsed(); + const uint32_t stepMs = effectiveStepMs(); + if (now - lastFrameMs_ < stepMs) return; + lastFrameMs_ += stepMs; // advance by one step, not to `now`: a stall catches up one frame at a time + if (aheadFrameReady_) { writeFrameToSink(); aheadFrameReady_ = false; } + if (frameIndex_ >= header_.frameCount) { handleEndOfFile(); return; } // last frame already rendered above + if (!decodeCurrentFrame()) return; // a read failure above already stopped playback + frameIndex_++; } - /// Let the sink place the held frame. + /// Hand the sink's held frame to the output; tick20ms() is the only place new data reaches it. void tick() MM_NONBLOCKING override { sink_.tick(); } /// An FSEQ frame is in wiring order. @@ -46,8 +121,704 @@ class FseqPlayerEffect : public EffectBase { /// The sink hands its held frame to the output. void copyWiringFrame(uint8_t* out, size_t bytes, bool force) override { sink_.copyWiringFrame(out, bytes, force); } + /// Dispatch a transport button through a name-to-handler table, a Command row per button rather than a branch; a changed rate source rebuilds the controls so `fps` shows or hides. + void onControlChanged(const char* controlName) override { + if (controlName) { + if (std::strcmp(controlName, "fpsSource") == 0) rebuildControls(); + for (const auto& row : kButtonHandlers) { + if (std::strcmp(controlName, row.name) == 0) { (this->*row.handler)(); break; } + } + } + EffectBase::onControlChanged(controlName); + } + + /// Rescan when a show lands or leaves, so an upload through the File Manager reaches the playlist unasked. An unrelated file, or a disabled effect, skips the whole-tree walk. + void onFileChanged(const char* path) override { + if (!enabled() || (path && !hasExtension(path, ".fseq"))) return; + rescanFiles(); + } + + // --- ListSource (editable): the playlist ------------------------------------------- + + /// How many rows the playlist holds. + uint8_t listRowCount() const override { return playlistCount_; } + + /// Write one row, which is also the persisted form: the stable id and the chosen file. + void writeListRow(JsonSink& sink, uint8_t row) const override { + sink.appendf("{\"id\":%lu,\"file\":", static_cast(tracks_[row].id)); + sink.writeJsonString(trackFile(row)); + sink.append("}"); + } + + /// Emitted once per list: the full paths the scan found. + void writeListOptionSets(JsonSink& sink) const override { + sink.append("\"files\":["); + for (uint8_t i = 0; i < fileCount_; i++) { + if (i) sink.append(","); + sink.writeJsonString(fileName(i)); + } + sink.append("]"); + } + + /// One row's editable fields: the file picker and a "play this row now" button. + void writeListRowDetail(JsonSink& sink, uint8_t row) const override { + sink.append("{\"fields\":["); + sink.appendf("{\"name\":\"file\",\"type\":\"select\",\"value\":%d,\"optionsRef\":\"files\"},", + fileIndexOf(trackFile(row))); + sink.append("{\"name\":\"play\",\"type\":\"button\"}"); + sink.append("]}"); + } + + /// The playlist accepts add, delete, reorder and field edits. + bool isEditableList() const override { return true; } + + /// Append a row, defaulting to the first scanned file if one exists. + bool addListRow(uint32_t& outId) override { + if (playlistCount_ >= kMaxTracks) return false; + Track& t = tracks_[playlistCount_]; + if (fileCount_ > 0 && !setTrackFile(t, fileName(0))) return false; + t.id = nextId_++; + playlistCount_++; + outId = t.id; + return true; + } + + /// Remove a row by id, clamping the cursor back onto the shrunk list. + bool deleteListRow(uint32_t id) override { + const int i = indexOf(id); + if (i < 0) return false; + platform::free(tracks_[i].file); + for (uint8_t j = static_cast(i); j + 1 < playlistCount_; j++) tracks_[j] = tracks_[j + 1]; + playlistCount_--; + tracks_[playlistCount_] = Track{}; // the vacated slot no longer owns the path it was shifted from + if (cursor_ >= playlistCount_ && playlistCount_ > 0) cursor_ = static_cast(playlistCount_ - 1); + return true; + } + + /// Move a row by id, which never changes that id. + bool moveListRow(uint32_t id, uint8_t to) override { + const int i = indexOf(id); + if (i < 0) return false; + if (to >= playlistCount_) to = static_cast(playlistCount_ - 1); + const Track moved = tracks_[i]; + if (to > i) for (int j = i; j < to; j++) tracks_[j] = tracks_[j + 1]; + else for (int j = i; j > to; j--) tracks_[j] = tracks_[j - 1]; + tracks_[to] = moved; + return true; + } + + /// Edit a row's file, or act on its "play" field: jump to this row and start playing now. + bool setListRowField(uint32_t id, const char* field, const char* valueJson) override { + const int i = indexOf(id); + if (i < 0) return false; + if (std::strcmp(field, "file") == 0) { + const int idx = mm::json::parseInt(valueJson, "value"); + if (idx < 0 || idx >= fileCount_) return false; + return setTrackFile(tracks_[i], fileName(static_cast(idx))); + } + if (std::strcmp(field, "play") == 0) { + cursor_ = static_cast(i); + visited_ = 0; + state_ = PlayState::Playing; // set before loadTrack; see prepare()'s own comment + loadTrack(cursor_); + return true; + } + return false; + } + + /// Restore the playlist from persisted JSON, keeping each row's stable id. + bool restoreList(const char* json, const char* key) override { + mm::json::JsonDoc doc; + if (!mm::json::parse(json, doc)) return false; + const mm::json::JsonNode* arr = mm::json::member(doc, doc.rootNode(), key); + if (!arr || arr->type != mm::json::JsonType::Array) return false; + clearTracks(); + const int n = mm::json::arraySize(doc, arr); + for (int r = 0; r < n && playlistCount_ < kMaxTracks; r++) { + const mm::json::JsonNode* row = mm::json::element(doc, arr, r); + Track& t = tracks_[playlistCount_]; + t.id = static_cast(mm::json::readInt(mm::json::member(doc, row, "id"), 0)); + char path[kPathCap]; + mm::json::readString(mm::json::member(doc, row, "file"), path, sizeof(path)); + if (path[0]) setTrackFile(t, path); + if (t.id >= nextId_) nextId_ = t.id + 1; // never reissue a persisted id + playlistCount_++; + } + return true; + } + + // --- Test seams: inject state directly, since every real file call needs a test root to succeed on desktop --- + + /// Test seam: inject a parsed header directly, skipping the real file read. + void setHeaderForTest(const fseq::FseqHeader& h) { + header_ = h; + frameCount_ = h.frameCount; + frameBuf_.resize(h.channelCount); + sizeExpandBuf(); + frameIndex_ = 0; + currentBlockIndex_ = 0; + currentBlockFirstFrame_ = 0; + currentBlockFrameCount_ = 0; + aheadFrameReady_ = false; + } + /// The scratch bytes held right now across every runtime buffer: zero once released. + size_t scratchBytesForTest() const { + return fileNames_.bytes() + headBuf_.bytes() + frameBuf_.bytes() + expandBuf_.bytes() + compBuf_.bytes() + blockBuf_.bytes() + zlibWork_.bytes(); + } + /// Force the transport state directly. + void setStateForTest(PlayState s) { state_ = s; } + /// The current transport state. + PlayState stateForTest() const { return state_; } + /// The playlist row loaded, or about to load. + uint8_t cursorForTest() const { return cursor_; } + /// The frame position within the current track. + uint32_t frameIndexForTest() const { return frameIndex_; } + /// Run end-of-file handling without waiting out the real frame cadence. + void handleEndOfFileForTest() { handleEndOfFile(); } + /// Whether a full shuffle+loopOff pass has visited every playlist row. + bool allTracksVisitedForTest() const { return allTracksVisited(); } + /// Mark a row visited directly, as if its track had just loaded for real. + void markVisitedForTest(uint8_t idx) { markVisited(idx); } + /// Force the loop mode directly, one of kLoopOff/kLoopOne/kLoopAll below. + void setLoopModeForTest(uint8_t m) { loopMode_ = m; } + /// Force the shuffle flag directly. + void setShuffleForTest(bool s) { shuffle_ = s; } + /// Force the playback rate directly. + void setFpsForTest(uint8_t fps) { fps_ = fps; } + /// Force the fps source directly, one of kFpsFromFile/kFpsManual above. + void setFpsSourceForTest(uint8_t source) { fpsSource_ = source; } + /// The milliseconds-per-frame decode is actually pacing on right now. + uint32_t effectiveStepMsForTest() const { return effectiveStepMs(); } + /// Run one tick20ms decode step without waiting out the real frame cadence. + void decodeOneFrameForTest() { lastFrameMs_ = elapsed() - effectiveStepMs(); tick20ms(); } + /// Test seam for the scan's directory-join, the one piece of the scan desktop can exercise directly. + static bool joinPathForTest(const char* dir, const char* name, char* out, size_t outCap) { + return joinPath(dir, name, out, outCap); + } + private: - ChannelDataSink sink_{*this}; ///< where the frames land, held and placed + static constexpr const char* kLoopModeOptions[] = {"off", "one", "all"}; + static constexpr const char* kFpsSourceOptions[] = {"file", "manual"}; + static constexpr uint8_t kMaxTracks = 64; // a playlist, not a media library: bounded, no heap + static constexpr uint8_t kMaxFiles = 64; + static constexpr size_t kHeadBytes = 4096; + static constexpr uint8_t kMaxScanDepth = 8; // matches FilesystemModule::removeTree's own bound + /// 50, not the usual control range: tick20ms's own ~50 Hz cadence is the decode ceiling. + static constexpr uint8_t kMaxFps = 50; + /// The slowest step the decode cadence can honor: 1000 / kMaxFps, so a file-sourced rate above it clamps here. + static constexpr uint32_t kMinStepMs = 1000u / kMaxFps; + /// A ceiling against a corrupt block-length field: real exports ran up to ~300KB/block, so 4MB keeps headroom. + static constexpr size_t kMaxCompressedBlockBytes = 4 * 1024 * 1024; + + /// One path budget for a scanned name, a joined path and `Track::file` alike, matching `FilesystemModule::removeTree`'s own. + static constexpr size_t kPathCap = 192; + + /// One playlist row: its stable id and its full filesystem path, a heap string sized to the path (null while the row names no file). + struct Track { uint32_t id = 0; char* file = nullptr; }; + Track tracks_[kMaxTracks] = {}; + uint8_t playlistCount_ = 0; + uint32_t nextId_ = 1; + uint8_t cursor_ = 0; + + bool shuffle_ = false; + uint8_t loopMode_ = kLoopAll; + uint8_t fpsSource_ = kFpsFromFile; ///< where the rate comes from: the file's step time, or fps_ + uint8_t fps_ = 30; ///< the manual playback rate; 1000/fps milliseconds per frame + + PlayState state_ = PlayState::Stopped; + fseq::FseqHeader header_ = {}; + uint32_t frameIndex_ = 0; + uint32_t frameCount_ = 0; + uint32_t lastFrameMs_ = 0; + uint64_t visited_ = 0; ///< per-pass bitset over playlist rows, for shuffle+loopOff + Random8 rng_; + + /// The one open track stream, read forward only, plus its buffered header; a track restart or change reopens it. + platform::FsReadStream stream_{}; + bool streamOpen_ = false; + ScratchBuffer headBuf_{*this}; ///< the header, block table and sparse table of virtually any real file: kHeadBytes, sized when a track opens + size_t headLen_ = 0; ///< bytes actually filled in headBuf_ + size_t headPos_ = 0; ///< how far into headBuf_ sequential reads have already consumed + + ChannelDataSink sink_{*this}; ///< where the decoded frame lands, held and placed in wiring order + ScratchBuffer frameBuf_{*this}; ///< the one decoded frame, read straight off the file or a compression block + ScratchBuffer expandBuf_{*this}; ///< a sparse frame expanded to the sink's own size, before the one write into it + ScratchBuffer compBuf_{*this}; ///< one compression block's compressed bytes + ScratchBuffer blockBuf_{*this}; ///< that block, decompressed + ScratchBuffer zlibWork_{*this}; ///< the decoder's state, lent to platform::zlibInflateAll for a zlib track + uint32_t currentBlockIndex_ = 0; ///< which compression-block-table entry is loaded + uint32_t currentBlockFirstFrame_ = 0; ///< that block's first frame + uint32_t currentBlockFrameCount_ = 0; ///< how many frames blockBuf_ covers + bool aheadFrameReady_ = false; ///< frameBuf_ holds a frame decoded one tick ahead, not yet handed to sink_ + + ScratchBuffer fileNames_{*this}; ///< kMaxFiles full filesystem paths of kPathCap bytes each, found by the scan; held while enabled + uint8_t fileCount_ = 0; + bool scanSkipped_ = false; ///< a name or joined path did not fit kPathCap during the last scan + + /// Sized for "playing: " + a kPathCap-1-char path + " (999/999)", with headroom; setStatus holds the pointer, so this outlives the call. + char statusBuf_[kPathCap + 32] = {}; + + /// A plain bounded copy, not snprintf: GCC's -Wrestrict flags snprintf between two member arrays of the same `this`. + static void copyBounded(char* dst, size_t dstCap, const char* src) { + if (dstCap == 0) return; + size_t n = std::strlen(src); + if (n >= dstCap) n = dstCap - 1; + std::memcpy(dst, src, n); + dst[n] = '\0'; + } + /// dir + "/" + name, built by hand for the same -Wrestrict reason as copyBounded; skips the slash when dir already ends in one. False when the joined path did not fit and was truncated. + static bool joinPath(const char* dir, const char* name, char* out, size_t outCap) { + const size_t dirLen = std::strlen(dir); + const size_t needsSlash = dirLen == 0 || dir[dirLen - 1] != '/' ? 1 : 0; + const size_t fits = dirLen + needsSlash + std::strlen(name) < outCap; + size_t n = dirLen < outCap ? dirLen : (outCap > 0 ? outCap - 1 : 0); + std::memcpy(out, dir, n); + if (needsSlash && n < outCap) { out[n] = '/'; n++; } + if (n < outCap) copyBounded(out + n, outCap - n, name); + else if (outCap > 0) out[outCap - 1] = '\0'; + return fits; + } + + /// Row `i`'s path, empty while it names no file. + const char* trackFile(uint8_t i) const { return tracks_[i].file ? tracks_[i].file : ""; } + /// Scanned file `i`'s full path. + const char* fileName(uint8_t i) const { return fileNames_.data() + static_cast(i) * kPathCap; } + /// Give `t` its own copy of `path` (at most kPathCap - 1 bytes), keeping the old path when the allocation fails. + static bool setTrackFile(Track& t, const char* path) { + size_t n = std::strlen(path); + if (n >= kPathCap) n = kPathCap - 1; + char* copy = static_cast(platform::alloc(n + 1)); + if (!copy) return false; + std::memcpy(copy, path, n); + copy[n] = '\0'; + platform::free(t.file); + t.file = copy; + return true; + } + /// Free every row's path and empty the playlist. + void clearTracks() { + for (uint8_t i = 0; i < kMaxTracks; i++) { platform::free(tracks_[i].file); tracks_[i] = Track{}; } + playlistCount_ = 0; + } + + int indexOf(uint32_t id) const { + for (uint8_t i = 0; i < playlistCount_; i++) if (tracks_[i].id == id) return i; + return -1; + } + int fileIndexOf(const char* file) const { + for (uint8_t i = 0; i < fileCount_; i++) if (std::strcmp(fileName(i), file) == 0) return i; + return -1; // no scanned file matches, e.g. one deleted after a row picked it + } + void markVisited(uint8_t idx) { if (idx < 64) visited_ |= (uint64_t{1} << idx); } + + /// Milliseconds per frame: the manual fps slider, or the track's own stepTimeMs capped at kMaxFps. + uint32_t effectiveStepMs() const { + if (fpsSource_ == kFpsManual) { + if (fps_ == 0) return 1; // defensive only: the control's own min bound keeps this unreachable + const uint32_t ms = 1000u / fps_; + return ms ? ms : 1; + } + if (header_.stepTimeMs == 0) return kMinStepMs; // an unauthored rate defaults to the cap, not a division by zero + return header_.stepTimeMs < kMinStepMs ? kMinStepMs : header_.stepTimeMs; + } + + /// Warn when the file's own rate asks for more than kMaxFps, which file-sourced timing cannot honor; true if it did. + bool warnIfFileRateExceedsCap() { + if (fpsSource_ != kFpsFromFile || header_.stepTimeMs == 0 || header_.stepTimeMs >= kMinStepMs) return false; + const unsigned fileFps = 1000u / header_.stepTimeMs; + std::snprintf(statusBuf_, sizeof(statusBuf_), + "file requests %ufps, capped at %u", fileFps, static_cast(kMaxFps)); + setStatus(statusBuf_, Severity::Warning); + return true; + } + + bool allTracksVisited() const { + if (playlistCount_ == 0) return true; + const uint64_t full = playlistCount_ >= 64 ? ~uint64_t{0} : ((uint64_t{1} << playlistCount_) - 1); + return (visited_ & full) == full; + } + + /// The `play` button: start a stopped transport, or resume a paused one. + void handlePlay() { + if (state_ == PlayState::Stopped) { + if (playlistCount_ == 0) return; + visited_ = 0; + state_ = PlayState::Playing; // set before loadTrack; see prepare()'s own comment + loadTrack(cursor_); + return; + } + if (state_ != PlayState::Paused) return; + lastFrameMs_ = elapsed(); // rebase, so a long pause does not fire a catch-up burst + state_ = PlayState::Playing; + } + + /// The `pause` button: only takes effect while playing. + void handlePause() { + if (state_ != PlayState::Playing) return; + state_ = PlayState::Paused; + refreshStatus(); + } + + /// The `stop` button: close the stream and reset position, only while playing or paused. + void handleStop() { + if (state_ != PlayState::Playing && state_ != PlayState::Paused) return; + closeTrackStream(); // a later play reopens and reads frame 0 forward + state_ = PlayState::Stopped; + frameIndex_ = 0; + visited_ = 0; + aheadFrameReady_ = false; // about to black out anyway: no point rendering it first + writeBlackFrame(); + refreshStatus(); + } + + /// The `previous` button, wrapped to the uniform no-argument shape `kButtonHandlers` rows need. + void handlePrevious() { moveCursor(-1); } + /// The `next` button, under the same wrapper as `handlePrevious`. + void handleNext() { moveCursor(+1); } + + /// Move the cursor by one step, shuffled or sequential. + void moveCursor(int direction) { + if (playlistCount_ == 0) return; + advanceCursor(direction); + if (state_ == PlayState::Stopped) return; + loadTrack(cursor_); // success leaves state_ untouched; failure forces it to Stopped + } + + /// Pick the next playlist row, shuffled or sequential, the same shape as DemoReelEffect::advance(). + void advanceCursor(int direction) { + if (playlistCount_ == 0) return; + uint8_t next; + if (shuffle_ && playlistCount_ > 1) { + do { next = rng_.below(playlistCount_); } while (next == cursor_); + } else { + next = static_cast((cursor_ + playlistCount_ + direction) % playlistCount_); + } + cursor_ = next; + } + + /// End of the current track: loop it, advance to the next, or stop a completed pass. + void handleEndOfFile() { + // tick20ms() already rendered any pending frame before calling here; this only guards a direct call (handleEndOfFileForTest). + if (aheadFrameReady_) { writeFrameToSink(); aheadFrameReady_ = false; } + if (loopMode_ == kLoopOne) { + loadTrack(cursor_); // re-opens from frame 0: a forward-only stream cannot rewind in place + return; + } + if (loopMode_ == kLoopOff && allTracksVisited()) { + closeTrackStream(); + state_ = PlayState::Stopped; + frameIndex_ = 0; + visited_ = 0; + writeBlackFrame(); + refreshStatus(); + return; + } + advanceCursor(+1); + loadTrack(cursor_); // loopAll wraps forever; loopOff stops on the NEXT end-of-file once every row has played + } + + /// Close the open stream, if any; safe to call when nothing is open. + void closeTrackStream() { + if (streamOpen_) platform::fsReadStreamClose(stream_); + streamOpen_ = false; + headLen_ = 0; + headPos_ = 0; + } + + /// Open `path` fresh and buffer its header region; false when the file can't be opened or has no bytes. + bool openTrackStream(const char* path) { + closeTrackStream(); + if (!headBuf_.resize(kHeadBytes) || !platform::fsReadStreamOpen(stream_, path)) return false; + streamOpen_ = true; + size_t got = 0; + while (got < kHeadBytes) { + const int n = platform::fsReadStreamRead(stream_, reinterpret_cast(headBuf_.data()) + got, kHeadBytes - got); + if (n <= 0) break; + got += static_cast(n); + } + headLen_ = got; + headPos_ = 0; + return got > 0; + } + + /// Pull `n` sequential file bytes into `dst`: the buffered header region first, the live stream after. + bool readBytes(uint8_t* dst, size_t n) { + size_t fromHead = 0; + if (headPos_ < headLen_) { + fromHead = n < (headLen_ - headPos_) ? n : (headLen_ - headPos_); + std::memcpy(dst, headBuf_.data() + headPos_, fromHead); + headPos_ += fromHead; + } + const size_t remain = n - fromHead; + if (remain == 0) return true; + if (!streamOpen_) return false; + size_t got = 0; + while (got < remain) { + const int r = platform::fsReadStreamRead(stream_, reinterpret_cast(dst + fromHead + got), remain - got); + if (r <= 0) return false; + got += static_cast(r); + } + return true; + } + + /// Discards `n` live-stream bytes between the buffered header region and `channelDataOffset`, for a file whose variable-length metadata outgrows `headBuf_`. + bool skipHeaderGap(size_t n) { + uint8_t discard[256]; + while (n > 0) { + const size_t chunk = n < sizeof(discard) ? n : sizeof(discard); + if (!readBytes(discard, chunk)) return false; + n -= chunk; + } + return true; + } + + /// Report why a track did not load or play, stop the transport and rewind; always false, so a failure path returns it. + bool failLoad(const char* message) { + setStatus(message, Severity::Error); + state_ = PlayState::Stopped; + frameIndex_ = 0; + return false; + } + + /// Open and parse one playlist row's header; forces Stopped and reports the reason on failure. + bool loadTrack(uint8_t idx) { + if (idx >= playlistCount_) { state_ = PlayState::Stopped; return false; } + cursor_ = idx; + if (!openTrackStream(trackFile(cursor_))) return failLoad("file read failed"); + fseq::FseqHeader hdr; + const fseq::ParseError err = fseq::parseHeader(headBuf_.data(), headLen_, hdr); + const char* errMsg = nullptr; + switch (err) { + case fseq::ParseError::BadMagic: errMsg = "not an FSEQ file"; break; + case fseq::ParseError::UnsupportedVersion: errMsg = "unsupported FSEQ version"; break; + case fseq::ParseError::UnsupportedCompression: errMsg = "compressed (zstd) FSEQ not supported yet"; break; + case fseq::ParseError::Truncated: errMsg = "FSEQ header truncated or unreadable"; break; + case fseq::ParseError::Empty: errMsg = "empty show: 0 channels or frames"; break; + default: break; // None, or TooManySparseRanges (non-fatal, handled below) + } + if (!errMsg && hdr.compressionType == fseq::CompressionType::Zlib && hdr.compressionBlockCount == 0) { + errMsg = "zlib file declares no compression blocks"; + } + if (!errMsg && hdr.compressionType == fseq::CompressionType::Zlib && !platform::hasZlibInflate) { + errMsg = "zlib plays on ESP32 only"; + } + if (errMsg) return failLoad(errMsg); + header_ = hdr; + frameCount_ = header_.frameCount; + frameBuf_.resize(header_.channelCount); + if (!frameBuf_ || !sizeBlockBuffers() || !sizeExpandBuf()) return failLoad("out of memory for this track, or its block table is invalid"); + headPos_ = header_.channelDataOffset; // skip the header, land exactly where frame 0 starts + if (headPos_ > headLen_ && !skipHeaderGap(headPos_ - headLen_)) return failLoad("FSEQ header truncated or unreadable"); + currentBlockIndex_ = 0; + currentBlockFirstFrame_ = 0; + currentBlockFrameCount_ = 0; + aheadFrameReady_ = false; + frameIndex_ = 0; + lastFrameMs_ = elapsed(); + markVisited(cursor_); + rebuildControls(); // the "position" progress total (frameCount_) just changed + if (!warnIfFileRateExceedsCap()) { + if (err == fseq::ParseError::TooManySparseRanges) setStatus("FSEQ sparse range table clipped", Severity::Warning); + else refreshStatus(); + } + return true; + } + + /// Size the block buffers and decoder workspace once for the track's largest block, so the 50 Hz path never allocates. + bool sizeBlockBuffers() { + size_t maxCompressed = 0, maxDecompressed = 0; + if (header_.compressionType == fseq::CompressionType::Zlib) { + for (uint32_t i = 0; i < header_.compressionBlockCount; i++) { + uint32_t firstFrame = 0, byteLen = 0, frames = 0; + size_t decompLen = 0; + if (!blockExtent(i, firstFrame, byteLen, frames, decompLen)) return false; + if (byteLen > maxCompressed) maxCompressed = byteLen; + if (decompLen > maxDecompressed) maxDecompressed = decompLen; + } + } + const bool zlib = header_.compressionType == fseq::CompressionType::Zlib; + return compBuf_.resize(maxCompressed) && blockBuf_.resize(maxDecompressed) && zlibWork_.resize(zlib ? platform::zlibWorkspaceBytes() : 0); + } + + /// Size the sparse expansion buffer to the sink, once per track and per layer resize; a dense track frees it. + bool sizeExpandBuf() { + return expandBuf_.resize(header_.sparseRangeCount ? sink_.bytes() : 0); + } + + /// One compression block's table entry, validated: where it starts, its compressed length, and how many frames and decompressed bytes it covers. + bool blockExtent(uint32_t index, uint32_t& firstFrame, uint32_t& byteLen, uint32_t& frames, size_t& decompLen) const { + if (index >= header_.compressionBlockCount || !fseq::blockEntry(headBuf_.data(), headLen_, index, firstFrame, byteLen)) return false; + const bool hasNext = index + 1 < header_.compressionBlockCount; + uint32_t nextFirstFrame = 0, nextByteLen = 0; + if (hasNext && !fseq::blockEntry(headBuf_.data(), headLen_, index + 1, nextFirstFrame, nextByteLen)) return false; + if (!fseq::validateBlockRange(firstFrame, header_.frameCount, hasNext, nextFirstFrame, header_.channelCount, frames, decompLen)) + return false; + return byteLen != 0 && byteLen <= kMaxCompressedBlockBytes && frames != 0; + } + + /// Decode frame `frameIndex_` into `frameBuf_`, one tick ahead of its render. The one blocking read `tick20ms` makes, bounded to one frame or block. + bool decodeCurrentFrame() { + if (header_.compressionType == fseq::CompressionType::Zlib) { + const char* why = nullptr; + const uint8_t* rawFrame = frameFromCompressedBlock(frameIndex_, why); + if (!rawFrame) return failLoad(why); + std::memcpy(frameBuf_.data(), rawFrame, header_.channelCount); + } else if (!readBytes(frameBuf_.data(), header_.channelCount)) { + return failLoad("file read failed mid-file"); + } + aheadFrameReady_ = true; + return true; + } + + /// Place the just-decoded frame into `sink_`, sparse or dense. + void writeFrameToSink() { + if (header_.sparseRangeCount == 0) { + sink_.write(0, frameBuf_.data(), header_.channelCount); + return; + } + // Unmapped channels go dark: the sparse table only addresses the fixtures a show touches. + if (expandBuf_.bytes() != sink_.bytes()) return; // sized by sizeExpandBuf(), never here on the render path + std::memset(expandBuf_.data(), 0, expandBuf_.bytes()); + fseq::expandSparseFrame(header_, frameBuf_.data(), header_.channelCount, expandBuf_.data(), expandBuf_.bytes()); + sink_.write(0, expandBuf_.data(), expandBuf_.bytes()); + } + + /// Write one all-zero frame into the sink, through a small static chunk: the stop transition's one-time black-out. + void writeBlackFrame() { + static constexpr uint8_t kZero[256] = {}; + for (size_t off = 0; off < sink_.bytes(); off += sizeof(kZero)) { + const size_t n = sink_.bytes() - off < sizeof(kZero) ? sink_.bytes() - off : sizeof(kZero); + sink_.write(off, kZero, n); + } + } + + /// The raw bytes for `frameIndex` out of the current (or a freshly loaded) compression block, or null with `why` naming what failed. + const uint8_t* frameFromCompressedBlock(uint32_t frameIndex, const char*& why) { + while (frameIndex >= currentBlockFirstFrame_ + currentBlockFrameCount_) { + why = loadNextBlock(); + if (why) return nullptr; + } + // A block table not starting at frame 0 is malformed; fail rather than underflow this subtraction. + if (frameIndex < currentBlockFirstFrame_) { why = "FSEQ block table does not start at frame 0"; return nullptr; } + const size_t offset = static_cast(frameIndex - currentBlockFirstFrame_) * header_.channelCount; + return blockBuf_.data() + offset; + } + + /// Read, decompress and install the next compression block into the buffers `loadTrack` sized, or name what failed; its byte count sets this call's real cost. + const char* loadNextBlock() { + uint32_t firstFrame = 0, byteLen = 0, frames = 0; + size_t decompLen = 0; + if (!blockExtent(currentBlockIndex_, firstFrame, byteLen, frames, decompLen) || byteLen > compBuf_.bytes() || decompLen > blockBuf_.bytes()) + return "FSEQ block table entry invalid"; + if (!readBytes(compBuf_.data(), byteLen)) return "file read failed mid-file"; + size_t outLen = 0; + if (!platform::zlibInflateAll(zlibWork_.data(), compBuf_.data(), byteLen, blockBuf_.data(), decompLen, outLen) || outLen != decompLen) + return "compressed block does not match its declared size"; + + currentBlockFirstFrame_ = firstFrame; + currentBlockFrameCount_ = frames; + currentBlockIndex_++; + return nullptr; + } + + /// One directory level, collected: the same shape as FilesystemModule::removeTree's DirLevel. + struct ScanLevel { + static constexpr uint8_t kMax = 64; + char names[kMax][kPathCap]; + bool isDir[kMax]; + uint8_t count = 0; + bool skipped = false; ///< an entry's own name alone did not fit kPathCap + }; + + static void collectScanEntry(const char* name, bool isDir, uint32_t, void* user) { + auto* lvl = static_cast(user); + if (lvl->count >= ScanLevel::kMax) return; + if (!name || name[0] == '.') return; + if (std::strlen(name) >= sizeof(lvl->names[0])) { lvl->skipped = true; return; } + std::snprintf(lvl->names[lvl->count], sizeof(lvl->names[0]), "%s", name); + lvl->isDir[lvl->count] = isDir; + lvl->count++; + } + + /// Recurse into `path`, depth-bounded, one heap-allocated level at a time, the shape of `FilesystemModule::removeTree`. + void scanDir(const char* path, uint8_t depth) { + if (depth > kMaxScanDepth || fileCount_ >= kMaxFiles) return; + auto* raw = platform::alloc(sizeof(ScanLevel)); + if (!raw) return; + ScanLevel& lvl = *static_cast(raw); // plain data: the two counters are set here, the arrays are written before they are read + lvl.count = 0; + lvl.skipped = false; + struct Freer { void* p; ~Freer() { platform::free(p); } } freer{raw}; + platform::fsList(path, &collectScanEntry, &lvl); + if (lvl.skipped) scanSkipped_ = true; + for (uint8_t i = 0; i < lvl.count && fileCount_ < kMaxFiles; i++) { + char child[kPathCap]; + if (!joinPath(path, lvl.names[i], child, sizeof(child))) { scanSkipped_ = true; continue; } + if (lvl.isDir[i]) { + scanDir(child, static_cast(depth + 1)); + } else if (hasExtension(lvl.names[i], ".fseq")) { + copyBounded(fileNames_.data() + static_cast(fileCount_++) * kPathCap, kPathCap, child); + } + } + } + + /// Re-scan the filesystem root for `.fseq` files into the shared option set. + void rescanFiles() { + fileCount_ = 0; + scanSkipped_ = false; + if (fileNames_.resize(static_cast(kMaxFiles) * kPathCap)) { + scanDir("/", 0); + // A directory lists its entries in no order of its own (POSIX leaves it to the filesystem), so the picker sorts them, the same on every board. + std::qsort(fileNames_.data(), fileCount_, kPathCap, + [](const void* a, const void* b) { return std::strcmp(static_cast(a), static_cast(b)); }); + } else { + setStatus("out of memory for the file list", Severity::Error); + } + rebuildControls(); + // Fires the resync directly: rebuildControls()'s own change-detection does not hash a List's row/option-set content. + notifySchemaChanged(); + if (scanSkipped_) setStatus("some .fseq names or paths too long, skipped", Severity::Warning); + } + + /// One row of `onControlChanged`'s dispatch table: a button's name and its handler. + struct ButtonHandler { const char* name; void (FseqPlayerEffect::*handler)(); }; + /// Every transport button, in no particular order, declared this late so rescanFiles is already in scope. + static constexpr ButtonHandler kButtonHandlers[] = { + {"play", &FseqPlayerEffect::handlePlay}, + {"pause", &FseqPlayerEffect::handlePause}, + {"stop", &FseqPlayerEffect::handleStop}, + {"previous", &FseqPlayerEffect::handlePrevious}, + {"next", &FseqPlayerEffect::handleNext}, + {"rescan", &FseqPlayerEffect::rescanFiles}, + }; + + /// Case-insensitive suffix check, since a file may carry ".FSEQ" from a different export tool. + static bool hasExtension(const char* name, const char* ext) { + const size_t nameLen = std::strlen(name); + const size_t extLen = std::strlen(ext); + if (nameLen < extLen) return false; + for (size_t i = 0; i < extLen; i++) { + char a = name[nameLen - extLen + i], b = ext[i]; + if (a >= 'A' && a <= 'Z') a = static_cast(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = static_cast(b - 'A' + 'a'); + if (a != b) return false; + } + return true; + } + + /// Show the transport state, the current file, and its position in the playlist. + void refreshStatus() { + if (playlistCount_ == 0) { setStatus("no tracks in playlist"); return; } + const char* label = state_ == PlayState::Playing ? "playing" + : state_ == PlayState::Paused ? "paused" + : "stopped"; + // Copied to a local first: GCC's -Wrestrict cannot see past two member arrays of the same `this`. + char fileCopy[kPathCap]; + copyBounded(fileCopy, sizeof(fileCopy), trackFile(cursor_)); + std::snprintf(statusBuf_, sizeof(statusBuf_), "%s: %s (%u/%u)", label, fileCopy, + static_cast(cursor_ + 1), static_cast(playlistCount_)); + setStatus(statusBuf_); + } }; -} // namespace mm +} // namespace mm diff --git a/src/light/util/FseqFile.h b/src/light/util/FseqFile.h new file mode 100644 index 00000000..be3c77c1 --- /dev/null +++ b/src/light/util/FseqFile.h @@ -0,0 +1,168 @@ +#pragma once + +/// Parses an FSEQ sequence file's fixed header, compression flags and sparse-range table. +/// +/// FSEQ is the Falcon Player/xLights animation file format: a flat, per-frame byte stream with no pixel semantics of its own. +/// +/// This header follows FPP's `FSEQ_Sequence_File_Format.txt` and is pure: it reads caller-supplied byte buffers only, with no `platform::` calls and no I/O. + +#include +#include + +namespace mm::fseq { + +/// Why a header failed to parse, or (for the non-fatal cases) why it was accepted with a caveat. +enum class ParseError : uint8_t { + None, + BadMagic, ///< first 4 bytes are not "PSEQ"/"FSEQ" + UnsupportedVersion, ///< major version is neither 1 nor 2 + UnsupportedCompression, ///< zstd (type 1): no decoder exists in this build, not a malformed file + TooManySparseRanges, ///< non-fatal: the table was clipped to kMaxSparseRanges and parsed anyway + Truncated, ///< buffer ends before a field/table this header claims to have + Empty, ///< 0 channels or 0 frames: a real header, nothing to play +}; + +/// A file's declared compression, read from header byte 20 (v2 only). +enum class CompressionType : uint8_t { None = 0, Zstd = 1, Zlib = 2 }; + +/// One contiguous slice of a sparse frame, placed at an absolute channel offset by `expandSparseFrame`. +struct SparseRange { + uint32_t startChannel = 0; ///< the absolute (unsparsed) channel this slice starts at + uint32_t channelCount = 0; ///< how many raw bytes belong to this slice +}; + +/// Where the compression block table starts, right after the fixed header, and the bytes each entry (first frame, compressed length) takes. +constexpr size_t kBlockTableOffset = 32; +constexpr size_t kBlockEntryBytes = 8; + +/// Bound on parsed sparse ranges: large enough for any real show, small enough to hold inline. +constexpr uint8_t kMaxSparseRanges = 32; + +/// The fields of an FSEQ header this player actually needs; metadata blocks are not retained. +struct FseqHeader { + uint16_t channelDataOffset = 0; ///< byte offset of frame 0 within the file + uint8_t minorVersion = 0; ///< the file's minor version, carried for diagnostics only + uint8_t majorVersion = 0; ///< 1 or 2; anything else is UnsupportedVersion + uint32_t channelCount = 0; ///< bytes per frame (sum of sparse-range counts, if any) + uint32_t frameCount = 0; ///< total frames in the file + uint8_t stepTimeMs = 0; ///< milliseconds per frame, the player's tick pacing + CompressionType compressionType = CompressionType::None; ///< None, Zlib (supported), or Zstd (not yet) + uint8_t compressionBlockCount = 0; ///< v2 only; used to locate the sparse range table, not for seeking + uint8_t sparseRangeCount = 0; ///< entries actually populated in sparseRanges (<= kMaxSparseRanges) + SparseRange sparseRanges[kMaxSparseRanges] = {}; ///< the sparse range table, if sparseRangeCount > 0 +}; + +namespace detail { +inline uint16_t rdU16(const uint8_t* p) { + return static_cast(p[0]) | static_cast(static_cast(p[1]) << 8); +} +inline uint32_t rdU32(const uint8_t* p) { + return static_cast(p[0]) | (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | (static_cast(p[3]) << 24); +} +inline uint32_t rdU24(const uint8_t* p) { + return static_cast(p[0]) | (static_cast(p[1]) << 8) | (static_cast(p[2]) << 16); +} +} // namespace detail + +/// Whether `buf` opens with the FSEQ magic, legacy "FSEQ" accepted alongside current "PSEQ". +inline bool isValidMagic(const uint8_t* buf) { + return (buf[0] == 'P' || buf[0] == 'F') && buf[1] == 'S' && buf[2] == 'E' && buf[3] == 'Q'; +} + +/// Parse the v2-only compression type, block count and sparse-range table into `out`; `buf`/`len` are the whole header buffer, unchanged from `parseHeader`. +inline ParseError parseV2Tail(const uint8_t* buf, size_t len, FseqHeader& out) { + // Byte 20 is the whole compression-type byte; byte 21 is the whole block count, since only that lands `compressionBlockCount * 8 + 32` on a real export's `variableHeaderOffset`. + const uint8_t compressionByte = buf[20]; + out.compressionBlockCount = buf[21]; + const uint8_t sparseCountRaw = buf[22]; + + const size_t sparseTableOffset = kBlockTableOffset + static_cast(out.compressionBlockCount) * kBlockEntryBytes; + const uint8_t sparseCount = sparseCountRaw > kMaxSparseRanges ? kMaxSparseRanges : sparseCountRaw; + const size_t sparseTableEnd = sparseTableOffset + static_cast(sparseCount) * 6; + if (len < sparseTableEnd) return ParseError::Truncated; + if (out.channelDataOffset < sparseTableEnd) return ParseError::Truncated; + + for (uint8_t i = 0; i < sparseCount; i++) { + const uint8_t* entry = buf + sparseTableOffset + static_cast(i) * 6; + out.sparseRanges[i].startChannel = detail::rdU24(entry); + out.sparseRanges[i].channelCount = detail::rdU24(entry + 3); + } + out.sparseRangeCount = sparseCount; + + switch (compressionByte) { + case 0: out.compressionType = CompressionType::None; break; + case 2: out.compressionType = CompressionType::Zlib; break; + default: out.compressionType = CompressionType::Zstd; break; // 1, or any reserved value + } + + if (out.compressionType == CompressionType::Zstd) return ParseError::UnsupportedCompression; + if (sparseCountRaw > kMaxSparseRanges) return ParseError::TooManySparseRanges; + return ParseError::None; +} + +/// Parse the fixed header, compression flags and (v2) sparse-range table out of `buf`; it never reads past `len`. +inline ParseError parseHeader(const uint8_t* buf, size_t len, FseqHeader& out) { + out = FseqHeader{}; + if (len < 32) return ParseError::Truncated; + if (!isValidMagic(buf)) return ParseError::BadMagic; + + out.channelDataOffset = detail::rdU16(buf + 4); + out.minorVersion = buf[6]; + out.majorVersion = buf[7]; + out.channelCount = detail::rdU32(buf + 10); + out.frameCount = detail::rdU32(buf + 14); + out.stepTimeMs = buf[18]; + + if (out.majorVersion != 1 && out.majorVersion != 2) return ParseError::UnsupportedVersion; + if (out.channelCount == 0 || out.frameCount == 0) return ParseError::Empty; + if (out.majorVersion == 1) { + if (out.channelDataOffset < 32) return ParseError::Truncated; + return ParseError::None; // v1 has no compression/sparse fields + } + return parseV2Tail(buf, len, out); +} + +/// Reads compression block table entry `index` out of the header buffer: the block's first frame and compressed byte length; false when the entry lies past `len`. +inline bool blockEntry(const uint8_t* buf, size_t len, uint32_t index, uint32_t& firstFrame, uint32_t& byteLen) { + const size_t at = kBlockTableOffset + static_cast(index) * kBlockEntryBytes; + if (at + kBlockEntryBytes > len) return false; + firstFrame = detail::rdU32(buf + at); + byteLen = detail::rdU32(buf + at + 4); + return true; +} + +/// Validates one compression block's frame range against `frameCount` and the next table entry, and computes its decompressed byte length; false rejects it outright, before any buffer is sized by it. +inline bool validateBlockRange(uint32_t blockFirstFrame, uint32_t frameCount, bool hasNext, uint32_t nextFirstFrame, + uint32_t channelCount, uint32_t& outFrameCount, size_t& outDecompLen) { + if (blockFirstFrame >= frameCount) return false; + if (hasNext) { + if (nextFirstFrame <= blockFirstFrame) return false; // the table must strictly advance, or this subtraction underflows + outFrameCount = nextFirstFrame - blockFirstFrame; + } else { + outFrameCount = frameCount - blockFirstFrame; + } + if (channelCount != 0 && outFrameCount > SIZE_MAX / channelCount) return false; + outDecompLen = static_cast(outFrameCount) * channelCount; + return true; +} + +/// Place a sparse frame's concatenated raw bytes at their absolute channel offsets in `out`. +inline size_t expandSparseFrame(const FseqHeader& hdr, const uint8_t* rawFrame, size_t rawLen, + uint8_t* out, size_t outCap) { + size_t rawPos = 0; + for (uint8_t i = 0; i < hdr.sparseRangeCount && rawPos < rawLen; i++) { + const SparseRange& r = hdr.sparseRanges[i]; + size_t avail = rawLen - rawPos; + size_t n = static_cast(r.channelCount) < avail ? r.channelCount : avail; + if (r.startChannel < outCap) { + size_t room = outCap - r.startChannel; + size_t copy = n < room ? n : room; + for (size_t b = 0; b < copy; b++) out[r.startChannel + b] = rawFrame[rawPos + b]; + } + rawPos += n; + } + return rawPos; +} + +} // namespace mm::fseq diff --git a/src/module_types.cpp b/src/module_types.cpp index f78df79c..211b5bf3 100644 --- a/src/module_types.cpp +++ b/src/module_types.cpp @@ -63,7 +63,6 @@ #include "light/effects/FixedPointEffect.h" #include "light/effects/MovingHeadEffect.h" #include "light/effects/PacmanEffect.h" -#include "light/effects/FseqPlayerEffect.h" #include "light/effects/PictureEffect.h" #include "light/effects/PlasmaEffect.h" #include "light/effects/PulseEffect.h" @@ -121,6 +120,7 @@ #include "light/effects/TetrixEffect.h" #include "light/effects/TextEffect.h" #include "light/effects/FreqSawsEffect.h" +#include "light/effects/FseqPlayerEffect.h" #include "light/effects/BlurzEffect.h" #include "light/effects/FreqMatrixEffect.h" #include "light/effects/GEQEffect.h" @@ -251,6 +251,7 @@ void mm::registerModuleTypes() { mm::ModuleFactory::registerType("FixedRectangleEffect", "light/effects.md#fixedrectangle"); mm::ModuleFactory::registerType("FreqMatrixEffect", "light/effects.md#freqmatrix"); mm::ModuleFactory::registerType("FreqSawsEffect", "light/effects.md#freqsaws"); + mm::ModuleFactory::registerType("FseqPlayerEffect", "light/effects.md#fseqplayer"); mm::ModuleFactory::registerType("GameOfLifeEffect", "light/effects.md#gameoflife"); mm::ModuleFactory::registerType("GEQEffect", "light/effects.md#geq"); mm::ModuleFactory::registerType("GEQ3DEffect", "light/effects.md#geq3d"); @@ -264,7 +265,6 @@ void mm::registerModuleTypes() { mm::ModuleFactory::registerType("NoiseMeterEffect", "light/effects.md#noisemeter"); mm::ModuleFactory::registerType("PaintBrushEffect", "light/effects.md#paintbrush"); mm::ModuleFactory::registerType("ParticlesEffect", "light/effects.md#particles"); - mm::ModuleFactory::registerType("FseqPlayerEffect", "light/effects.md#fseqplayer"); mm::ModuleFactory::registerType("PictureEffect", "light/effects.md#picture"); mm::ModuleFactory::registerType("PlasmaEffect", "light/effects.md#plasma"); mm::ModuleFactory::registerType("PraxisEffect", "light/effects.md#praxis"); diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index 16e6c867..97f33434 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -127,6 +127,9 @@ constexpr bool hasSdCard = true; /// True: no slot is wired, so SdCardService offers every board preset, for the tests to exercise them. constexpr bool previewsSdPresets = true; +/// False: no zlib decoder is linked here; `zlibInflateAll` is a stub. +constexpr bool hasZlibInflate = false; + } // namespace mm::platform // 1 on arm64 and x86-64; any other host ISA falls through and compiles scripts to a clean failure. diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 66426ace..dcc1d6b1 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1172,6 +1172,10 @@ bool sdMount(const SdPinConfig& pins) { return true; } +// No decoder linked on desktop; the FSEQ player reports "unsupported" for a compressed file here. +bool zlibInflateAll(void*, const uint8_t*, size_t, uint8_t*, size_t, size_t&) { return false; } +size_t zlibWorkspaceBytes() { return 0; } + bool sdCardMounted() { return sdMounted_; } SdMountError sdMountError() { return sdMountError_; } diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index f8739faa..a1fc7e0d 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -266,6 +266,8 @@ constexpr bool hasSdCard = true; #else constexpr bool hasSdCard = false; #endif +/// True: `zlibInflateAll` decodes through the ROM's tinfl on every ESP32 board. +constexpr bool hasZlibInflate = true; /// False: SdCardService offers only the board presets this chip can wire. constexpr bool previewsSdPresets = false; diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 005e1d80..c0524796 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -117,6 +117,7 @@ #include "esp_log.h" #include "esp_rom_sys.h" // esp_rom_delay_us (delayUs) #include "mdns.h" +#include "miniz.h" // tinfl, the decoder zlibInflateAll calls #include "driver/gpio.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -2244,4 +2245,20 @@ void TcpServer::close() { // irRead (IR receive) lives in platform_esp32_ir.cpp, an RMT NEC decoder. +size_t zlibWorkspaceBytes() { return sizeof(tinfl_decompressor); } + +/// Inflate one zlib block in a single `tinfl_decompress` call over the whole input and output, against the caller's workspace rather than this task's stack. +bool zlibInflateAll(void* workspace, const uint8_t* src, size_t srcLen, uint8_t* dst, size_t dstCap, size_t& outLen) { + if (!workspace) return false; + auto* decomp = static_cast(workspace); + tinfl_init(decomp); + size_t inBytes = srcLen; + size_t outBytes = dstCap; + const tinfl_status status = tinfl_decompress(decomp, src, &inBytes, dst, dst, &outBytes, + TINFL_FLAG_PARSE_ZLIB_HEADER | TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF); + if (status != TINFL_STATUS_DONE) return false; // HAS_MORE_OUTPUT: the block inflates past dstCap, a file that lied about its size + outLen = outBytes; + return true; +} + } // namespace mm::platform diff --git a/src/platform/platform.h b/src/platform/platform.h index 59accf3a..e9c0951f 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -339,6 +339,11 @@ inline bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { return ok && committed; } +/// Inflate one self-contained zlib block in one call; `outLen` is set only on success. Real decoder only on ESP32 (the ROM's `tinfl_decompress`, zero flash cost); desktop and Teensy both stub to false, no decoder linked on either. +bool zlibInflateAll(void* workspace, const uint8_t* src, size_t srcLen, uint8_t* dst, size_t dstCap, size_t& outLen); +/// The bytes of decoder state the caller lends `zlibInflateAll` as its `workspace`, held only while it plays zlib; 0 where no decoder is linked. +size_t zlibWorkspaceBytes(); + // A second, independent mount (microSD, FAT) sharing the fsXxx surface above through its own `/sd` path prefix; inert wherever hasSdCard is false. /// Whether `path` names the filesystem's root: "" or "/", the two forms a listing of the root arrives as. diff --git a/src/platform/teensy/platform_config.h b/src/platform/teensy/platform_config.h index 8b90f3a0..4fedf78d 100644 --- a/src/platform/teensy/platform_config.h +++ b/src/platform/teensy/platform_config.h @@ -108,6 +108,9 @@ constexpr bool hasSdCard = true; /// False: SdCardService offers only the socket this board wires. constexpr bool previewsSdPresets = false; +/// False: no zlib decoder is linked here; `zlibInflateAll` is a stub. +constexpr bool hasZlibInflate = false; + /// The bits a call to emitted code adds to its address. Thumb state: a Cortex-M7 runs only Thumb code, and a call address carries it in bit 0. constexpr uintptr_t codeAddressBits = 1; diff --git a/src/platform/teensy/platform_teensy_fs.cpp b/src/platform/teensy/platform_teensy_fs.cpp index bea2e7ff..fd487621 100644 --- a/src/platform/teensy/platform_teensy_fs.cpp +++ b/src/platform/teensy/platform_teensy_fs.cpp @@ -15,6 +15,10 @@ extern unsigned long _flashimagelen; namespace mm::platform { +// No zlib/zstd decoder on Teensy: FseqPlayerEffect's compressed-block path never succeeds here. +bool zlibInflateAll(void*, const uint8_t*, size_t, uint8_t*, size_t, size_t&) { return false; } +size_t zlibWorkspaceBytes() { return 0; } + // --- Filesystem: LittleFS on the top of program flash ------------------------------------------- namespace { diff --git a/src/ui/app.js b/src/ui/app.js index 7617ce40..6620f010 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -4846,8 +4846,9 @@ function fmtTime(sec) { return parts.join(" "); } -// "used / total", each sized by fmtBytes from the unit the device declares. +// "used / total", each sized by fmtBytes from the unit the device declares; a count unit shows plain numbers. function fmtProgressLabel(ctrl) { + if (ctrl.unit === "count") return (Number(ctrl.value) || 0) + " / " + (Number(ctrl.total) || 0); const scale = ctrl.unit === "KiB" ? 1024 : 1; return fmtBytes((Number(ctrl.value) || 0) * scale) + " / " + fmtBytes((Number(ctrl.total) || 0) * scale); } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 12e670b0..0cc99ba7 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -136,6 +136,8 @@ add_executable(mm_tests unit/light/unit_FixedRectangleEffect.cpp unit/light/unit_FreqMatrixEffect.cpp unit/light/unit_FreqSawsEffect.cpp + unit/light/unit_FseqFile.cpp + unit/light/unit_FseqPlayerEffect.cpp unit/light/unit_GEQ3DEffect.cpp unit/light/unit_GEQEffect.cpp unit/light/unit_GridBlacksLayout.cpp @@ -145,7 +147,6 @@ add_executable(mm_tests unit/light/unit_NoiseMeterEffect.cpp unit/light/unit_PaintBrushEffect.cpp unit/light/unit_PictureEffect.cpp - unit/light/unit_FseqPlayerEffect.cpp unit/light/unit_PraxisEffect.cpp unit/light/unit_RandomEffect.cpp unit/light/unit_RippleXZModifier.cpp diff --git a/test/js/ui-progress-label.test.mjs b/test/js/ui-progress-label.test.mjs index 4e99b8a1..c3fd57da 100644 --- a/test/js/ui-progress-label.test.mjs +++ b/test/js/ui-progress-label.test.mjs @@ -17,6 +17,10 @@ test("a KiB gauge scales first, so a 32 GB card reads as gigabytes", () => { assert.equal(fmtProgressLabel({ value: 1536, total: 31166976, unit: "KiB" }), "1.5MB / 30GB"); }); +test("a count gauge shows plain numbers, never a byte suffix", () => { + assert.equal(fmtProgressLabel({ value: 3000, total: 6300, unit: "count" }), "3000 / 6300"); +}); + test("a missing value reads as zero rather than NaN", () => { assert.equal(fmtProgressLabel({ unit: "B" }), "0B / 0B"); }); diff --git a/test/unit/core/unit_MoonModule.cpp b/test/unit/core/unit_MoonModule.cpp index ff116cc9..8b5489f5 100644 --- a/test/unit/core/unit_MoonModule.cpp +++ b/test/unit/core/unit_MoonModule.cpp @@ -228,9 +228,11 @@ TEST_CASE("a progress control declares its unit to the UI, bytes unless told oth uint32_t used = 1, usedKiB = 1; mod.controls().addProgress("flash", used, 4096); mod.controls().addProgress("card", usedKiB, 31166976, mm::ProgressUnit::KiB); + mod.controls().addProgress("frames", used, 6300, mm::ProgressUnit::Count); const auto meta = [&](uint8_t i) { mm::JsonSink sink; mm::writeControlMetadata(sink, mod.controls()[i]); return std::string(sink.data(), sink.size()); }; CHECK(meta(0) == ",\"total\":4096,\"unit\":\"B\""); CHECK(meta(1) == ",\"total\":31166976,\"unit\":\"KiB\""); + CHECK(meta(2) == ",\"total\":6300,\"unit\":\"count\""); } // enabled defaults to true; setEnabled flips the universal enable gate (Scheduler and parent containers respect it). @@ -257,7 +259,7 @@ TEST_CASE("Bool control binding") { CHECK(*static_cast(ctrl.ptr) == false); } -// appearsInUi() defaults to true (every ordinary module shows in the UI) and is overridable to false so infrastructure modules (FilesystemModule, HttpServerModule) can hide, the flag the state serializer reads to skip a module. A base MoonModule and a control-bearing one both appear; only a module that overrides to false hides. +// appearsInUi() defaults to true (every module shows); overridable to false so infrastructure (FilesystemModule, HttpServerModule) hides, the flag the state serializer reads to skip a module. TEST_CASE("MoonModule appearsInUi defaults true, overridable false") { TestModule visible; CHECK(visible.appearsInUi()); // ordinary module: shown @@ -270,7 +272,7 @@ TEST_CASE("MoonModule appearsInUi defaults true, overridable false") { CHECK_FALSE(asBase->appearsInUi()); } -// readBool/readUint8, the shared generic control reader (reviewer #8): one implementation so the absent-control default can't disagree between callers (HttpServerModule + MqttModule both read Drivers.on through this). Returns the bound value; returns the caller's default when absent/wrong-type. +// readBool/readUint8, the shared generic control reader (reviewer #8): one implementation, so HttpServerModule and MqttModule can't disagree on Drivers.on's absent-control default. TEST_CASE("MoonModule readBool/readUint8 return the value, or the default when absent") { TestModule mod; mod.defineControls(); // binds brightness(Uint8), speed(Uint8), enabled(Bool) diff --git a/test/unit/light/unit_FseqFile.cpp b/test/unit/light/unit_FseqFile.cpp new file mode 100644 index 00000000..1797add3 --- /dev/null +++ b/test/unit/light/unit_FseqFile.cpp @@ -0,0 +1,323 @@ +/// @module FseqFile +/// @also FseqPlayerEffect + +/// Pins the FSEQ header/table parser against hand-built bytes: truncation at every offset, and sparse-range placement across a gap between two ranges. + +#include "doctest.h" + +#include "light/util/FseqFile.h" + +#include +#include +#include + +using namespace mm::fseq; + +namespace { + +void putU16(std::vector& b, size_t at, uint16_t v) { + b[at] = static_cast(v & 0xFF); + b[at + 1] = static_cast(v >> 8); +} +void putU32(std::vector& b, size_t at, uint32_t v) { + for (int i = 0; i < 4; i++) b[at + i] = static_cast(v >> (8 * i)); +} +void putU24(std::vector& b, size_t at, uint32_t v) { + for (int i = 0; i < 3; i++) b[at + i] = static_cast(v >> (8 * i)); +} + +/// A v1 header: no compression block table, no sparse range table, legacy bytes 20-31 unused. +std::vector makeV1(uint16_t channelDataOffset, uint32_t channelCount, uint32_t frameCount, + uint8_t stepTime) { + std::vector b(channelDataOffset, 0); + std::memcpy(b.data(), "PSEQ", 4); + putU16(b, 4, channelDataOffset); + b[6] = 0; // minor + b[7] = 1; // major + putU32(b, 10, channelCount); + putU32(b, 14, frameCount); + b[18] = stepTime; + return b; +} + +/// A v2 header, with an explicit compression block count and sparse range table; `sparse` entries are laid out right after `compressionBlockCount * 8` reserved bytes. +std::vector makeV2(uint16_t channelDataOffset, uint32_t channelCount, uint32_t frameCount, + uint8_t stepTime, uint8_t compressionType, uint8_t compressionBlockCount, + const std::vector& sparse) { + const size_t sparseTableOffset = 32 + static_cast(compressionBlockCount) * 8; + const size_t minLen = sparseTableOffset + sparse.size() * 6; + const size_t len = channelDataOffset > minLen ? channelDataOffset : minLen; + std::vector b(len, 0); + std::memcpy(b.data(), "PSEQ", 4); + putU16(b, 4, channelDataOffset); + b[6] = 0; // minor + b[7] = 2; // major + putU32(b, 10, channelCount); + putU32(b, 14, frameCount); + b[18] = stepTime; + // A whole byte each, not nibble-split, matching a real xLights v2 export. + b[20] = compressionType; + b[21] = compressionBlockCount; + b[22] = static_cast(sparse.size()); + for (size_t i = 0; i < sparse.size(); i++) { + const size_t at = sparseTableOffset + i * 6; + putU24(b, at, sparse[i].startChannel); + putU24(b, at + 3, sparse[i].channelCount); + } + return b; +} + +} // namespace + +TEST_CASE("a valid v1 header parses the fields the player needs") { + const auto buf = makeV1(/*channelDataOffset=*/32, /*channelCount=*/450, /*frameCount=*/600, + /*stepTime=*/25); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); + CHECK(hdr.channelDataOffset == 32); + CHECK(hdr.majorVersion == 1); + CHECK(hdr.channelCount == 450); + CHECK(hdr.frameCount == 600); + CHECK(hdr.stepTimeMs == 25); + CHECK(hdr.compressionType == CompressionType::None); + CHECK(hdr.sparseRangeCount == 0); +} + +TEST_CASE("a v2 header with no compression and no sparse ranges behaves like v1") { + const auto buf = makeV2(32, 450, 600, 25, /*compressionType=*/0, /*compressionBlockCount=*/0, {}); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); + CHECK(hdr.compressionType == CompressionType::None); + CHECK(hdr.sparseRangeCount == 0); +} + +TEST_CASE("a legacy FSEQ magic is accepted alongside the current PSEQ magic") { + auto buf = makeV1(32, 10, 1, 25); + std::memcpy(buf.data(), "FSEQ", 4); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); +} + +TEST_CASE("a wrong magic is refused") { + auto buf = makeV1(32, 10, 1, 25); + std::memcpy(buf.data(), "ZZZZ", 4); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::BadMagic); +} + +TEST_CASE("an unknown major version is refused") { + auto buf = makeV1(32, 10, 1, 25); + buf[7] = 3; + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::UnsupportedVersion); +} + +TEST_CASE("zstd compression is its own error, not a generic failure") { + // Type 1 is zstd: no decoder exists in this build, so it is reported distinctly from a malformed file. + const auto buf = makeV2(32, 100, 10, 25, /*compressionType=*/1, 0, {}); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::UnsupportedCompression); + CHECK(hdr.compressionType == CompressionType::Zstd); +} + +TEST_CASE("zlib compression parses cleanly, since a ROM decoder exists for it") { + const auto buf = makeV2(32, 100, 10, 25, /*compressionType=*/2, 0, {}); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); + CHECK(hdr.compressionType == CompressionType::Zlib); +} + +TEST_CASE("a reserved compression type byte is treated as unsupported rather than silently accepted") { + const auto buf = makeV2(32, 100, 10, 25, /*compressionType=*/7, 0, {}); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::UnsupportedCompression); +} + +TEST_CASE("sparse ranges land in the header in file order") { + const std::vector ranges = {{0, 100}, {500, 50}}; + const auto buf = makeV2(32 + 2 * 6, 150, 10, 25, 0, 0, ranges); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); + REQUIRE(hdr.sparseRangeCount == 2); + CHECK(hdr.sparseRanges[0].startChannel == 0); + CHECK(hdr.sparseRanges[0].channelCount == 100); + CHECK(hdr.sparseRanges[1].startChannel == 500); + CHECK(hdr.sparseRanges[1].channelCount == 50); +} + +TEST_CASE("a compression block table displaces the sparse range table, and parsing follows it") { + // compressionBlockCount=3 means 24 reserved bytes between the fixed header and the sparse table. + const std::vector ranges = {{10, 5}}; + const auto buf = makeV2(32 + 3 * 8 + 6, 50, 10, 25, 2, /*compressionBlockCount=*/3, ranges); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); + CHECK(hdr.compressionBlockCount == 3); + REQUIRE(hdr.sparseRangeCount == 1); + CHECK(hdr.sparseRanges[0].startChannel == 10); + CHECK(hdr.sparseRanges[0].channelCount == 5); +} + +TEST_CASE("a real xLights v2 export's header parses with the right block count") { + // Pinned against a real export that once misread compressionBlockCount as 336 instead of 21; padded to 200 bytes since parseHeader needs the whole compression-block table. + std::vector head(200, 0); + const uint8_t fixed[32] = { + 'P', 'S', 'E', 'Q', + 0xcf, 0x00, // channelDataOffset = 207 + 0x00, 0x02, // minor=0, major=2 + 0xc8, 0x00, // variableHeaderOffset = 200 + 0xc0, 0x5d, 0x00, 0x00, // channelCount = 24000 + 0x34, 0x08, 0x00, 0x00, // frameCount = 2100 + 0x1d, // stepTimeMs = 29 + 0x00, + 0x02, // compressionType = 2 (zlib) + 0x15, // compressionBlockCount = 21 + 0x00, // sparseRangeCount = 0 + 0x00, + 0, 0, 0, 0, 0, 0, 0, 0 // unique id, unused + }; + std::memcpy(head.data(), fixed, sizeof(fixed)); + FseqHeader hdr; + CHECK(parseHeader(head.data(), head.size(), hdr) == ParseError::None); + CHECK(hdr.compressionType == CompressionType::Zlib); + CHECK(hdr.compressionBlockCount == 21); + CHECK(hdr.channelCount == 24000); + CHECK(hdr.frameCount == 2100); + CHECK(hdr.channelDataOffset == 207); + CHECK(32 + static_cast(hdr.compressionBlockCount) * 8 == 200); // == variableHeaderOffset +} + +TEST_CASE("more sparse ranges than the cap are clipped, not overflowed, and reported non-fatally") { + std::vector ranges; + for (uint8_t i = 0; i < kMaxSparseRanges + 5; i++) ranges.push_back({static_cast(i * 10), 1}); + const auto buf = makeV2(32 + static_cast(ranges.size() * 6), 300, 10, 25, 0, 0, ranges); + FseqHeader hdr; + // The buffer only reserves room for the clipped count: parseHeader must not read past that. + std::vector clippedBuf(buf.begin(), buf.begin() + static_cast(32 + static_cast(kMaxSparseRanges) * 6)); + CHECK(parseHeader(clippedBuf.data(), clippedBuf.size(), hdr) == ParseError::TooManySparseRanges); + CHECK(hdr.sparseRangeCount == kMaxSparseRanges); +} + +TEST_CASE("a buffer truncated at every interesting point reports Truncated rather than reading past it") { + const std::vector ranges = {{0, 100}, {500, 50}}; + const auto full = makeV2(32 + 2 * 6 + 10, 150, 10, 25, 0, 0, ranges); + for (size_t n : {size_t{0}, size_t{1}, size_t{31}, size_t{32}, size_t{37}, size_t{40}}) { + CAPTURE(n); + FseqHeader hdr; + const auto err = parseHeader(full.data(), n, hdr); + if (n < 32) { CHECK(err == ParseError::Truncated); continue; } + // n==32..43 is mid-sparse-table (table spans [32, 44)); every one of those must truncate. + CHECK(err == ParseError::Truncated); + } + // One byte short of the full sparse table still truncates; the exact end succeeds. + FseqHeader hdrShort; + CHECK(parseHeader(full.data(), 32 + 2 * 6 - 1, hdrShort) == ParseError::Truncated); + FseqHeader hdrExact; + CHECK(parseHeader(full.data(), 32 + 2 * 6, hdrExact) == ParseError::None); +} + +TEST_CASE("a v1 header with 0 channels is Empty, not a crash waiting to happen") { + const auto buf = makeV1(32, /*channelCount=*/0, /*frameCount=*/10, 25); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::Empty); +} + +TEST_CASE("a v1 header with 0 frames is Empty") { + const auto buf = makeV1(32, /*channelCount=*/100, /*frameCount=*/0, 25); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::Empty); +} + +TEST_CASE("a v2 header with 0 channels or 0 frames is Empty before any v2-only field is read") { + const auto buf1 = makeV2(32, /*channelCount=*/0, /*frameCount=*/10, 25, 0, 0, {}); + FseqHeader hdr1; + CHECK(parseHeader(buf1.data(), buf1.size(), hdr1) == ParseError::Empty); + + const auto buf2 = makeV2(32, /*channelCount=*/100, /*frameCount=*/0, 25, 0, 0, {}); + FseqHeader hdr2; + CHECK(parseHeader(buf2.data(), buf2.size(), hdr2) == ParseError::Empty); +} + +TEST_CASE("channelDataOffset overlapping the v1 fixed header is refused") { + auto buf = makeV1(/*channelDataOffset=*/32, 10, 1, 25); + putU16(buf, 4, 20); // claims frame 0 starts inside the 32-byte fixed header + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::Truncated); +} + +TEST_CASE("channelDataOffset overlapping a v2 file's compression/sparse tables is refused") { + const std::vector ranges = {{0, 100}}; + auto buf = makeV2(32 + 6, 150, 10, 25, 0, 0, ranges); // tables end at 32+6=38 + putU16(buf, 4, 35); // claims frame 0 starts inside the sparse range table + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::Truncated); +} + +TEST_CASE("blockEntry reads one table entry's first frame and compressed length, and refuses an entry past the buffer") { + std::vector buf(32 + 2 * 8, 0); + putU32(buf, 32, 0); putU32(buf, 36, 1234); + putU32(buf, 40, 60); putU32(buf, 44, 5678); + uint32_t firstFrame = 99, byteLen = 99; + REQUIRE(blockEntry(buf.data(), buf.size(), 1, firstFrame, byteLen)); + CHECK(firstFrame == 60); + CHECK(byteLen == 5678); + CHECK_FALSE(blockEntry(buf.data(), buf.size(), 2, firstFrame, byteLen)); // the third entry would end past the buffer + CHECK_FALSE(blockEntry(buf.data(), buf.size() - 1, 1, firstFrame, byteLen)); +} + +TEST_CASE("validateBlockRange accepts a well-formed entry and computes its decompressed length") { + uint32_t frameCount = 0; + size_t decompLen = 0; + CHECK(validateBlockRange(/*blockFirstFrame=*/10, /*frameCount=*/100, /*hasNext=*/true, + /*nextFirstFrame=*/40, /*channelCount=*/50, frameCount, decompLen)); + CHECK(frameCount == 30); + CHECK(decompLen == 30u * 50u); + + // The last entry has no next: its span runs to the file's own frameCount. + CHECK(validateBlockRange(40, 100, /*hasNext=*/false, 0, 50, frameCount, decompLen)); + CHECK(frameCount == 60); +} + +TEST_CASE("validateBlockRange refuses a block table that does not strictly advance") { + uint32_t frameCount = 0; + size_t decompLen = 0; + // The next entry equal to this one would underflow the frame-count subtraction. + CHECK_FALSE(validateBlockRange(40, 100, /*hasNext=*/true, /*nextFirstFrame=*/40, 50, frameCount, decompLen)); + // The next entry going backwards is refused the same way. + CHECK_FALSE(validateBlockRange(40, 100, /*hasNext=*/true, /*nextFirstFrame=*/10, 50, frameCount, decompLen)); +} + +TEST_CASE("validateBlockRange refuses a block starting at or past the file's own frameCount") { + uint32_t frameCount = 0; + size_t decompLen = 0; + CHECK_FALSE(validateBlockRange(100, 100, false, 0, 50, frameCount, decompLen)); + CHECK_FALSE(validateBlockRange(150, 100, false, 0, 50, frameCount, decompLen)); +} + +// validateBlockRange's overflow guard only trips on a 32-bit size_t (two uint32_t values can't multiply past 64-bit); only real ESP32 hardware can exercise that branch. + +TEST_CASE("expandSparseFrame places each range at its absolute channel offset, leaving gaps alone") { + FseqHeader hdr; + hdr.sparseRangeCount = 2; + hdr.sparseRanges[0] = {0, 3}; // channels 0..2 + hdr.sparseRanges[1] = {10, 2}; // channels 10..11, a gap at 3..9 + const uint8_t raw[5] = {1, 2, 3, 9, 8}; // ranges concatenated with no padding + uint8_t out[16]; + std::memset(out, 0xAA, sizeof(out)); // a sentinel: gaps must stay untouched by this call + const size_t consumed = expandSparseFrame(hdr, raw, sizeof(raw), out, sizeof(out)); + CHECK(consumed == 5); + CHECK(out[0] == 1); CHECK(out[1] == 2); CHECK(out[2] == 3); + CHECK(out[3] == 0xAA); // the gap: caller's own zeroing (or lack of it) is preserved + CHECK(out[10] == 9); CHECK(out[11] == 8); +} + +TEST_CASE("expandSparseFrame clips to the output capacity rather than overflowing it") { + FseqHeader hdr; + hdr.sparseRangeCount = 1; + hdr.sparseRanges[0] = {14, 4}; // would write channels 14..17 into a 16-byte buffer + const uint8_t raw[4] = {1, 2, 3, 4}; + uint8_t out[16]; + std::memset(out, 0, sizeof(out)); + expandSparseFrame(hdr, raw, sizeof(raw), out, sizeof(out)); + CHECK(out[14] == 1); + CHECK(out[15] == 2); // bytes 3 and 4 fell outside outCap and were dropped, not overrun +} diff --git a/test/unit/light/unit_FseqPlayerEffect.cpp b/test/unit/light/unit_FseqPlayerEffect.cpp index 3f82ed68..f23f1797 100644 --- a/test/unit/light/unit_FseqPlayerEffect.cpp +++ b/test/unit/light/unit_FseqPlayerEffect.cpp @@ -1,46 +1,783 @@ /// @module FseqPlayerEffect -/// @also ChannelDataSink +/// @also FseqFile -/// The FSEQ player as it stands: its settings, its frames landing where a sender-mapped stream's do, and a status saying it reads no file yet. +/// Pins the FSEQ player: the transport state machine against injected state, and the scan/decode/blit path against real `.fseq` bytes under a test filesystem root, mounted or plain flash alike. #include "doctest.h" + #include "light/effects/FseqPlayerEffect.h" #include "light/layouts/GridLayout.h" -#include "light/layouts/Layouts.h" +#include "core/util/JsonSink.h" +#include #include +#include + +using namespace mm; namespace { -struct FseqRig { - mm::Layouts layouts; - mm::GridLayout grid; - mm::Layer layer; - mm::FseqPlayerEffect fx; - FseqRig() { - grid.width = 4; - grid.height = 4; - grid.depth = 1; - grid.serpentine = true; + +fseq::FseqHeader makeHeader(uint32_t channelCount, uint32_t frameCount, uint8_t stepTimeMs = 25) { + fseq::FseqHeader h; + h.channelDataOffset = 32; + h.channelCount = channelCount; + h.frameCount = frameCount; + h.stepTimeMs = stepTimeMs; + return h; +} + +// A one-frame v1 FSEQ file whose light k's red byte is k + 1, matching a real exporter's wiring order; a test can tell which position a byte reached. +std::vector makeFseqV1BytesNumbered(uint16_t lights) { + const uint32_t channelCount = static_cast(lights) * 3; + std::vector b(32 + channelCount, 0); + std::memcpy(b.data(), "PSEQ", 4); + b[4] = 32; b[5] = 0; + b[6] = 0; b[7] = 1; + for (int i = 0; i < 4; i++) b[10 + i] = static_cast(channelCount >> (8 * i)); + b[14] = 1; // frameCount = 1 + b[18] = 25; + for (uint16_t k = 0; k < lights; k++) b[32 + static_cast(k) * 3] = static_cast(k + 1); + return b; +} + +// A real v1 FSEQ file's bytes: frame i's channels all hold the byte value i, so a test can tell which frame decoded. +std::vector makeFseqV1Bytes(uint32_t channelCount, uint32_t frameCount, uint8_t stepTimeMs = 25) { + std::vector b(32 + static_cast(channelCount) * frameCount, 0); + std::memcpy(b.data(), "PSEQ", 4); + b[4] = 32; b[5] = 0; // channelDataOffset = 32 + b[6] = 0; b[7] = 1; // minor=0, major=1 + for (int i = 0; i < 4; i++) b[10 + i] = static_cast(channelCount >> (8 * i)); + for (int i = 0; i < 4; i++) b[14 + i] = static_cast(frameCount >> (8 * i)); + b[18] = stepTimeMs; + for (uint32_t f = 0; f < frameCount; f++) + std::memset(b.data() + 32 + static_cast(f) * channelCount, static_cast(f), channelCount); + return b; +} + +// A fresh temp dir as the flash root for one test (the unit_HttpServerModule_sdcard.cpp shape), mounting a card by default; pass false for flash-root-only. +struct SdFixture { + char root[256]; + bool mounted; + explicit SdFixture(bool mountSd = true) : mounted(mountSd) { + static unsigned counter = 0; + std::snprintf(root, sizeof(root), "%s/mm_fseq_test_%u", + std::filesystem::temp_directory_path().string().c_str(), counter++); + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + platform::fsSetRoot(root); + if (mounted) { + platform::SdPinConfig pins{}; + pins.clk = 43; pins.cmd = 44; pins.d0 = 39; + REQUIRE(platform::sdMount(pins)); + } + } + // NOLINTNEXTLINE(bugprone-exception-escape) + ~SdFixture() noexcept { + if (mounted) platform::sdUnmount(); + platform::fsSetRoot(""); + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + void writeFseq(const char* path, const std::vector& bytes) { + REQUIRE(platform::fsWriteAtomic(path, reinterpret_cast(bytes.data()), bytes.size())); + } +}; + +// A real 4x4 Layer the effect is parented to, the same fixture shape unit_DemoReelEffect.cpp uses so elapsed()/buffer() resolve through the parent. +struct Scene { + Layouts layouts; + GridLayout grid; + Layer layer; + FseqPlayerEffect effect; + Scene() { + grid.width = 4; grid.height = 4; grid.depth = 1; layouts.addChild(&grid); layer.setLayouts(&layouts); layer.setChannelsPerLight(3); - layer.addChild(&fx); - fx.defineControls(); - layer.applyState(); + layer.addChild(&effect); } }; -} // namespace -// An FSEQ frame is in wiring order, so alone on its layer it goes to the output as it is, on a serpentine grid too. -TEST_CASE("an FSEQ player alone on its layer hands its frame to the output") { - FseqRig r; - CHECK(r.fx.deliversWiringOrder()); - CHECK(r.layer.wiringSource() == &r.fx); +// A v1 file whose channelDataOffset is pushed past the header's own 32 fixed bytes, the way a real export's variable-length metadata block would; the padding is arbitrary (zero). +std::vector makeFseqV1BytesWithOffset(uint16_t channelDataOffset, uint32_t channelCount, uint32_t frameCount) { + std::vector b(static_cast(channelDataOffset) + static_cast(channelCount) * frameCount, 0); + std::memcpy(b.data(), "PSEQ", 4); + b[4] = static_cast(channelDataOffset & 0xFF); + b[5] = static_cast(channelDataOffset >> 8); + b[6] = 0; b[7] = 1; + for (int i = 0; i < 4; i++) b[10 + i] = static_cast(channelCount >> (8 * i)); + for (int i = 0; i < 4; i++) b[14 + i] = static_cast(frameCount >> (8 * i)); + b[18] = 25; + for (uint32_t f = 0; f < frameCount; f++) + std::memset(b.data() + channelDataOffset + static_cast(f) * channelCount, static_cast(f), channelCount); + return b; +} + +// A v2 file declaring zlib compression, with `blockCount` (empty) entries in its compression block table. +std::vector makeFseqV2ZlibBytes(uint32_t channelCount, uint32_t frameCount, uint8_t blockCount) { + const size_t channelDataOffset = 32 + static_cast(blockCount) * 8; + std::vector b(channelDataOffset + static_cast(channelCount) * frameCount, 0); + std::memcpy(b.data(), "PSEQ", 4); + b[4] = static_cast(channelDataOffset & 0xFF); + b[5] = static_cast(channelDataOffset >> 8); + b[6] = 0; b[7] = 2; // minor=0, major=2 + for (int i = 0; i < 4; i++) b[10 + i] = static_cast(channelCount >> (8 * i)); + for (int i = 0; i < 4; i++) b[14 + i] = static_cast(frameCount >> (8 * i)); + b[18] = 25; + b[20] = 2; // compressionType = zlib + b[21] = blockCount; + b[22] = 0; // sparseRangeCount + return b; +} + +// Two tracks written, scanned and added as playlist rows, for the two tests exercising a second row. +void twoTrackPlaylist(SdFixture& sd, FseqPlayerEffect& e, uint32_t& id1, uint32_t& id2) { + sd.writeFseq("/sd/a.fseq", makeFseqV1Bytes(10, 2)); + sd.writeFseq("/sd/b.fseq", makeFseqV1Bytes(10, 2)); + e.setup(); + e.addListRow(id1); + e.addListRow(id2); +} + +// Scans the fixture, adds its first file as the only playlist row and presses play. +void playFirstScannedFile(FseqPlayerEffect& e) { + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); +} + +} // namespace + +// A flash-only board scans exactly like one with a card: no SD-specific check or status, ever. +TEST_CASE("setup scans silently, with no SD-specific check or status") { + Scene s; + auto& e = s.effect; + e.setup(); + CHECK(e.status() == nullptr); +} + +TEST_CASE("rescan finds a real .fseq file at the flash root, with no SD mount at all") { + SdFixture flash(/*mountSd=*/false); + flash.writeFseq("/show.fseq", makeFseqV1Bytes(9, 3)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + // addListRow defaults to the first scanned file, so a non-empty default proves the scan found it at the flash root. + CHECK(e.listRowCount() == 1); +} + +TEST_CASE("playing a flash-found file decodes and blits its frames, same as an SD one") { + SdFixture flash(/*mountSd=*/false); + flash.writeFseq("/show.fseq", makeFseqV1Bytes(/*channelCount=*/48, /*frameCount=*/3)); // 48 = 4x4x1x3 + Scene s; + auto& e = s.effect; + playFirstScannedFile(e); + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()).rfind("playing", 0) == 0); + + const uint8_t* buf = s.layer.buffer().data(); + const size_t bytes = s.layer.buffer().count() * 3; + + e.decodeOneFrameForTest(); // primes the one-tick-ahead pipeline: decodes frame 0, renders nothing yet + e.tick(); + + e.decodeOneFrameForTest(); // frame 0: every byte is 0 + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); + + e.decodeOneFrameForTest(); // frame 1: every byte is 1 + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 1); +} + +TEST_CASE("setup with a mounted but empty card scans cleanly to an empty playlist") { + SdFixture sd; + Scene s; + auto& e = s.effect; + e.setup(); + CHECK(e.listRowCount() == 0); +} + +TEST_CASE("rescan finds a real .fseq file, case-insensitively, in a nested folder, skipping dotfiles") { + SdFixture sd; + sd.writeFseq("/sd/show.FSEQ", makeFseqV1Bytes(9, 3)); + sd.writeFseq("/sd/shows/nested.fseq", makeFseqV1Bytes(9, 3)); + sd.writeFseq("/sd/.hidden.fseq", makeFseqV1Bytes(9, 3)); + sd.writeFseq("/sd/notes.txt", {1, 2, 3}); + Scene s; + auto& e = s.effect; + e.setup(); // rescans once on its own + + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + // addListRow defaults to the first scanned file, so a non-empty default proves the scan found something real. + CHECK(e.listRowCount() == 1); +} + +TEST_CASE("onFileChanged rescans only for a .fseq path, skipping an unrelated upload") { + SdFixture sd; + Scene s; + auto& e = s.effect; + e.setup(); // scans the still-empty card + + sd.writeFseq("/sd/new.fseq", makeFseqV1Bytes(9, 3)); + // An unrelated file changed (and names no real file either): no crash, and no whole-tree rescan, so new.fseq stays unseen. + e.onFileChanged("/.config/SomeModule.json"); + { + JsonSink sink; + e.writeListOptionSets(sink); + CHECK(std::string(sink.data()).find("new.fseq") == std::string::npos); + } + + e.onFileChanged("/sd/new.fseq"); // the show's own path: this one does rescan + { + JsonSink sink; + e.writeListOptionSets(sink); + CHECK(std::string(sink.data()).find("new.fseq") != std::string::npos); + } +} + +TEST_CASE("prepare() starts playing on its own once there is a playlist to play") { + Scene s; + auto& e = s.effect; + // Empty playlist: nothing to start, and no crash from trying anyway. + e.prepare(); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + + uint32_t id = 0; + e.addListRow(id); + e.prepare(); + // The row's file is empty: nothing was ever scanned. An error status (not silence) proves prepare() tried to auto-start. + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + REQUIRE(e.status() != nullptr); + CHECK(e.severity() == MoonModule::Severity::Error); +} + +TEST_CASE("playing a real file decodes and blits its frames in order") { + SdFixture sd; + sd.writeFseq("/sd/show.fseq", makeFseqV1Bytes(/*channelCount=*/48, /*frameCount=*/3)); // 48 = 4x4x1x3 + Scene s; + auto& e = s.effect; + e.setup(); + e.prepare(); // sizes the sink; empty playlist here, so this alone does not start playback + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + // Pins a real bug: loadTrack's own refreshStatus() must see the state this call transitions to, not the one it leaves, or a successful play reports "stopped". + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()).rfind("playing", 0) == 0); + REQUIRE(e.deliversWiringOrder()); // an FSEQ frame is already in wiring order, and this effect is alone on its layer + + const size_t bytes = static_cast(s.layer.physicalLightCount()) * 3; + std::vector buf(bytes); + + e.decodeOneFrameForTest(); // primes the one-tick-ahead pipeline: decodes frame 0, renders nothing yet + e.tick(); + + e.decodeOneFrameForTest(); // frame 0: every byte is 0 + e.tick(); + e.copyWiringFrame(buf.data(), buf.size(), /*force=*/true); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); + + e.decodeOneFrameForTest(); // frame 1: every byte is 1 + e.tick(); + e.copyWiringFrame(buf.data(), buf.size(), /*force=*/true); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 1); + + e.decodeOneFrameForTest(); // frame 2, the last one + e.tick(); + e.copyWiringFrame(buf.data(), buf.size(), /*force=*/true); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 2); + + // Frame 3 does not exist: the call above rendered frame 2 then ran handleEndOfFile (loopAll default: reloads frame 0). + CHECK(e.frameIndexForTest() == 0); + + // Pause updates the status line too, not just the state. + e.onControlChanged("pause"); + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()).rfind("paused", 0) == 0); +} + +// A real exporter already writes wiring order; alone on a serpentine layout the frame must reach the output unmapped, not mapped a second time. +TEST_CASE("playing on a serpentine layout keeps the frame in wiring order") { + SdFixture sd; + sd.writeFseq("/sd/wiring.fseq", makeFseqV1BytesNumbered(16)); + + Layouts layouts; + GridLayout grid; + grid.width = 4; grid.height = 4; grid.depth = 1; + grid.serpentine = true; // the mapping a second application would scramble + layouts.addChild(&grid); + Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + FseqPlayerEffect e; + layer.addChild(&e); + + e.setup(); + e.defineControls(); + layer.applyState(); // builds the serpentine LUT too, not just this effect's own prepare() + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + e.onControlChanged("play"); + REQUIRE(e.deliversWiringOrder()); + REQUIRE(layer.lut().hasLUT()); // serpentine: the mapping is not the identity + REQUIRE(layer.wiringSource() == &e); // alone on its layer, sender-mapped: straight to the output, no layer remap + + e.decodeOneFrameForTest(); // primes the one-tick-ahead pipeline: decodes frame 0, renders nothing yet + e.tick(); + + e.decodeOneFrameForTest(); + e.tick(); + + std::vector out(16 * 3, 0); + e.copyWiringFrame(out.data(), out.size(), /*force=*/true); + for (uint8_t k = 0; k < 16; k++) CHECK(out[k * 3] == k + 1); // light k shows what was authored for k, not the serpentine's remap +} + +TEST_CASE("release() forces a clean stop, so a later re-enable never resumes into freed buffers") { + Scene s; + auto& e = s.effect; + e.setStateForTest(FseqPlayerEffect::PlayState::Playing); + e.release(); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); +} + +TEST_CASE("a stopped player owns its background (clears to black), but a paused one re-blits the same frame") { + Scene s; + auto& e = s.effect; + uint8_t* buf = const_cast(s.layer.buffer().data()); + const size_t bytes = s.layer.buffer().count() * 3; + constexpr uint8_t kStale = 0xA7; // a value no real frame would coincidentally produce + + std::memset(buf, kStale, bytes); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); // the default, no playlist + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); // owns its background: goes black + + std::memset(buf, kStale, bytes); + e.setStateForTest(FseqPlayerEffect::PlayState::Paused); + e.tick(); + // No frame has ever decoded, so a paused transport has nothing to show: the sink stays as it was, no crash. + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == kStale); +} + +TEST_CASE("the playlist is an editable list: add, delete, move, edit") { + Scene s; + auto& e = s.effect; + uint32_t id1 = 0, id2 = 0, id3 = 0; + CHECK(e.isEditableList()); + CHECK(e.addListRow(id1)); + CHECK(e.addListRow(id2)); + CHECK(e.listRowCount() == 2); + CHECK(id1 != id2); + + CHECK(e.deleteListRow(id1)); + CHECK(e.listRowCount() == 1); + CHECK_FALSE(e.deleteListRow(id1)); // already gone + + CHECK(e.addListRow(id3)); + CHECK(e.listRowCount() == 2); + CHECK(e.moveListRow(id3, 0)); // id3 now row 0, id2 row 1 + + // No SD files have been scanned, so the file picker's option index is out of range. + CHECK_FALSE(e.setListRowField(id2, "file", "{\"value\":0}")); + CHECK_FALSE(e.setListRowField(999999, "file", "{\"value\":0}")); // an unknown id + CHECK_FALSE(e.setListRowField(id2, "nonsense", "{\"value\":0}")); // an unknown field +} + +TEST_CASE("play with an empty playlist does nothing") { + Scene s; + auto& e = s.effect; + e.onControlChanged("play"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); +} + +TEST_CASE("play with no real file to open fails gracefully rather than pretending to start") { + Scene s; + auto& e = s.effect; + uint32_t id = 0; + e.addListRow(id); + e.onControlChanged("play"); + // The row's file is empty: loadTrack's open fails, so playback never actually starts. + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + REQUIRE(e.status() != nullptr); + CHECK(e.severity() == MoonModule::Severity::Error); +} + +TEST_CASE("pause only takes effect while playing") { + Scene s; + auto& e = s.effect; + e.setStateForTest(FseqPlayerEffect::PlayState::Stopped); + e.onControlChanged("pause"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + + e.setStateForTest(FseqPlayerEffect::PlayState::Playing); + e.onControlChanged("pause"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Paused); + + e.onControlChanged("pause"); // already paused: no further change + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Paused); +} + +TEST_CASE("stop resets the frame position and only applies while playing or paused") { + Scene s; + auto& e = s.effect; + e.onControlChanged("stop"); // Stopped -> Stopped: a no-op, not a crash + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + + e.setStateForTest(FseqPlayerEffect::PlayState::Playing); + e.setHeaderForTest(makeHeader(10, 100)); + e.onControlChanged("stop"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + CHECK(e.frameIndexForTest() == 0); +} + +TEST_CASE("previous and next move the cursor only, while the transport is stopped") { + Scene s; + auto& e = s.effect; + uint32_t id1 = 0, id2 = 0, id3 = 0; + e.addListRow(id1); + e.addListRow(id2); + e.addListRow(id3); + REQUIRE(e.cursorForTest() == 0); + + e.onControlChanged("next"); + CHECK(e.cursorForTest() == 1); + e.onControlChanged("next"); + CHECK(e.cursorForTest() == 2); + e.onControlChanged("next"); // wraps past the last row + CHECK(e.cursorForTest() == 0); + e.onControlChanged("previous"); // wraps back + CHECK(e.cursorForTest() == 2); + + // Stopped, so moving the cursor never touches playback state. + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); +} + +TEST_CASE("shuffle's cursor never repeats the row it just left") { + Scene s; + auto& e = s.effect; + uint32_t ids[5] = {}; + for (uint32_t& id : ids) e.addListRow(id); + e.setShuffleForTest(true); + + uint8_t prev = e.cursorForTest(); + for (int i = 0; i < 200; i++) { + e.onControlChanged("next"); + const uint8_t now = e.cursorForTest(); + CHECK(now < 5); + CHECK(now != prev); // the do-while non-repeat guard in advanceCursor + prev = now; + } +} + +TEST_CASE("the recursive scan's path join never doubles the slash at the SD root, and truncates safely") { + char out[32]; + CHECK(FseqPlayerEffect::joinPathForTest("/sd", "show.fseq", out, sizeof(out))); + CHECK(std::string(out) == "/sd/show.fseq"); + + CHECK(FseqPlayerEffect::joinPathForTest("/sd/shows", "intro.fseq", out, sizeof(out))); + CHECK(std::string(out) == "/sd/shows/intro.fseq"); + + CHECK(FseqPlayerEffect::joinPathForTest("/sd/shows/", "intro.fseq", out, sizeof(out))); + CHECK(std::string(out) == "/sd/shows/intro.fseq"); // trailing slash on dir: no double "//" + + char small[6]; + CHECK_FALSE(FseqPlayerEffect::joinPathForTest("/sd/shows", "averylongname.fseq", small, sizeof(small))); + CHECK(std::strlen(small) < sizeof(small)); // truncated, still NUL-terminated, never overrun +} + +// Plays `bytes` as the only playlist row and checks loadTrack refuses it: transport stopped, `status` named, severity Error. +static void expectRefused(const char* path, const std::vector& bytes, const char* status) { + SdFixture sd; + sd.writeFseq(path, bytes); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()) == status); + CHECK(e.severity() == MoonModule::Severity::Error); +} + +TEST_CASE("a header with 0 channels or 0 frames fails to load with its own status, not out of memory") { + expectRefused("/sd/empty.fseq", makeFseqV1Bytes(/*channelCount=*/10, /*frameCount=*/0), "empty show: 0 channels or frames"); +} + +TEST_CASE("a zlib file fails to load on desktop with its own status, not a generic read failure") { + expectRefused("/sd/z.fseq", makeFseqV2ZlibBytes(/*channelCount=*/10, /*frameCount=*/5, /*blockCount=*/2), "zlib plays on ESP32 only"); +} + +TEST_CASE("a zlib file declaring no compression blocks is refused by name, before the platform check") { + expectRefused("/sd/z0.fseq", makeFseqV2ZlibBytes(/*channelCount=*/10, /*frameCount=*/5, /*blockCount=*/0), "zlib file declares no compression blocks"); +} + +TEST_CASE("a scanned name past kPathCap is skipped, not truncated into a different file, with a named warning") { + SdFixture sd; + sd.writeFseq("/sd/good.fseq", makeFseqV1Bytes(10, 2)); + const std::string longName(200, 'x'); // past kPathCap (192) + sd.writeFseq(("/sd/" + longName + ".fseq").c_str(), makeFseqV1Bytes(10, 2)); + + Scene s; + auto& e = s.effect; + e.setup(); // rescans once on its own + + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + CHECK(e.listRowCount() == 1); // the oversized name was skipped; the short one still scanned + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()).find("too long") != std::string::npos); +} + +TEST_CASE("fpsSource=manual: the fps slider sets the playback rate directly, ignoring the file's own step time") { + Scene s; + auto& e = s.effect; + e.setFpsSourceForTest(FseqPlayerEffect::kFpsManual); + // The file's stepTimeMs (25ms, i.e. 40fps) deliberately differs from every fps set below, to prove the slider wins. + e.setHeaderForTest(makeHeader(10, 50, /*stepTimeMs=*/25)); + e.setFpsForTest(30); + CHECK(e.effectiveStepMsForTest() == 33); // 1000/30, rounded down + + e.setFpsForTest(20); + CHECK(e.effectiveStepMsForTest() == 50); // 1000/20 + e.setFpsForTest(1); + CHECK(e.effectiveStepMsForTest() == 1000); + e.setFpsForTest(50); + CHECK(e.effectiveStepMsForTest() == 20); // the ceiling the `fps` control allows: tick20ms's own cadence +} + +TEST_CASE("choosing fpsSource=manual shows the fps slider, and choosing file hides it again") { + Scene s; + auto& e = s.effect; + e.defineControls(); + const auto fpsHidden = [&e]() { + for (uint8_t i = 0; i < e.controls().count(); i++) + if (std::strcmp(e.controls()[i].name, "fps") == 0) return e.controls()[i].hidden; + FAIL("no fps control"); + return false; + }; + CHECK(fpsHidden()); // the file's own rate is the default, so the slider has nothing to say + + e.setFpsSourceForTest(FseqPlayerEffect::kFpsManual); + e.onControlChanged("fpsSource"); + CHECK_FALSE(fpsHidden()); + + e.setFpsSourceForTest(FseqPlayerEffect::kFpsFromFile); + e.onControlChanged("fpsSource"); + CHECK(fpsHidden()); +} + +TEST_CASE("fpsSource=file is the default, and reads the track's own stepTimeMs directly") { + Scene s; + auto& e = s.effect; + // Reading the file's rate without ever touching setFpsSourceForTest proves kFpsFromFile is the real default. + e.setHeaderForTest(makeHeader(10, 50, /*stepTimeMs=*/25)); + CHECK(e.effectiveStepMsForTest() == 25); // the file's own rate, not the fps_ default of 30 (33ms) + + e.setHeaderForTest(makeHeader(10, 50, /*stepTimeMs=*/100)); + CHECK(e.effectiveStepMsForTest() == 100); // 10fps, well under the cap +} + +TEST_CASE("fpsSource=file caps a rate faster than 50fps rather than honoring it") { + Scene s; + auto& e = s.effect; + e.setHeaderForTest(makeHeader(10, 50, /*stepTimeMs=*/10)); // 100fps authored, double the cap + CHECK(e.effectiveStepMsForTest() == 20); // clamped to 1000/50, tick20ms's own cadence + + e.setHeaderForTest(makeHeader(10, 50, /*stepTimeMs=*/0)); // an unauthored rate: default to the cap, not a /0 + CHECK(e.effectiveStepMsForTest() == 20); +} + +TEST_CASE("loading a file-sourced track faster than 50fps warns, naming the rate it asked for") { + SdFixture sd; + // stepTimeMs=10 is 100fps, double the cap this rate source can honor. + sd.writeFseq("/sd/fast.fseq", makeFseqV1Bytes(10, 3, /*stepTimeMs=*/10)); + Scene s; + auto& e = s.effect; + playFirstScannedFile(e); + REQUIRE(e.status() != nullptr); + CHECK(e.severity() == MoonModule::Severity::Warning); + CHECK(std::string(e.status()).find("100") != std::string::npos); + CHECK(std::string(e.status()).find("50") != std::string::npos); +} + +TEST_CASE("a manual-mode load never warns about the file's own rate, however fast it is authored") { + SdFixture sd; + sd.writeFseq("/sd/fast.fseq", makeFseqV1Bytes(10, 3, /*stepTimeMs=*/10)); + Scene s; + auto& e = s.effect; + e.setup(); + e.setFpsSourceForTest(FseqPlayerEffect::kFpsManual); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + REQUIRE(e.status() != nullptr); + CHECK(e.severity() != MoonModule::Severity::Warning); +} + +TEST_CASE("loopOne restarts the same track at frame 0 without advancing the cursor") { + SdFixture sd; + sd.writeFseq("/sd/show.fseq", makeFseqV1Bytes(10, 3)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + e.addListRow(id); + e.setLoopModeForTest(FseqPlayerEffect::kLoopOne); + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + + e.handleEndOfFileForTest(); + CHECK(e.frameIndexForTest() == 0); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); // stays playing: it is the same track + CHECK(e.cursorForTest() == 0); // never advanced +} + +TEST_CASE("loopOff stops once every row has already played this pass, with no further read needed") { + Scene s; + auto& e = s.effect; + uint32_t ids[3] = {}; + for (uint32_t& id : ids) e.addListRow(id); + e.setStateForTest(FseqPlayerEffect::PlayState::Playing); + e.setLoopModeForTest(FseqPlayerEffect::kLoopOff); + e.setHeaderForTest(makeHeader(10, 50)); + // Simulate a pass that already touched every row (as loadTrack would have, had the reads succeeded). + e.markVisitedForTest(0); + e.markVisitedForTest(1); + e.markVisitedForTest(2); + REQUIRE(e.allTracksVisitedForTest()); + + e.handleEndOfFileForTest(); // this branch returns before ever touching loadTrack/the card + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + CHECK(e.frameIndexForTest() == 0); + CHECK_FALSE(e.allTracksVisitedForTest()); // the bitset is cleared for the next pass +} + +TEST_CASE("loopAll advances to the next real track and keeps playing") { + SdFixture sd; + Scene s; + auto& e = s.effect; + uint32_t id1 = 0, id2 = 0; + twoTrackPlaylist(sd, e, id1, id2); + e.setLoopModeForTest(FseqPlayerEffect::kLoopAll); + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + REQUIRE(e.cursorForTest() == 0); + + e.handleEndOfFileForTest(); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); // the next row's open succeeded + CHECK(e.cursorForTest() == 1); + CHECK(e.frameIndexForTest() == 0); +} + +TEST_CASE("a channelDataOffset past the buffered header region is skipped, landing frame data correctly") { + SdFixture sd; + // channelDataOffset (5000) exceeds the 4096-byte buffered header region: skipHeaderGap must consume the gap from the live stream so frame 0 still starts at the right byte. + sd.writeFseq("/sd/gap.fseq", makeFseqV1BytesWithOffset(5000, /*channelCount=*/48, /*frameCount=*/3)); + Scene s; + auto& e = s.effect; + playFirstScannedFile(e); + + const uint8_t* buf = s.layer.buffer().data(); + const size_t bytes = s.layer.buffer().count() * 3; + e.decodeOneFrameForTest(); // primes the one-tick-ahead pipeline: decodes frame 0, renders nothing yet + e.tick(); + e.decodeOneFrameForTest(); // frame 0: every byte is 0, same as the no-gap decode test + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); + e.decodeOneFrameForTest(); // frame 1: every byte is 1 + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 1); +} + +TEST_CASE("a playlist row whose file no longer scans is rendered with no selection, not the first file") { + SdFixture sd; + sd.writeFseq("/sd/a.fseq", makeFseqV1Bytes(10, 2)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); // defaults to the one scanned file, "/sd/a.fseq" + + std::filesystem::remove(std::string(sd.root) + "/sd/a.fseq"); // gone from disk; a rescan drops it from the option set + e.onFileChanged("/sd/a.fseq"); + CHECK(e.listRowCount() == 1); // the row survives rescans; only the file options shrink + + JsonSink sink; + e.writeListRowDetail(sink, 0); + CHECK(std::string(sink.data()).find("\"value\":-1,\"optionsRef\":\"files\"") != std::string::npos); +} + +TEST_CASE("a playlist row's own play field jumps to that row and starts a fresh pass") { + SdFixture sd; + Scene s; + auto& e = s.effect; + uint32_t id1 = 0, id2 = 0; + twoTrackPlaylist(sd, e, id1, id2); + e.markVisitedForTest(0); // pretend row 0 already played this pass + + e.setListRowField(id2, "play", "{}"); + CHECK(e.cursorForTest() == 1); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); // the row's own file opened fine +} + +TEST_CASE("a released player holds no scan cache or buffers, keeps its playlist, and rebuilds the cache when enabled again") { + SdFixture sd; + sd.writeFseq("/sd/a.fseq", makeFseqV1Bytes(10, 3)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + CHECK(e.scratchBytesForTest() > 0); + + e.release(); + CHECK(e.scratchBytesForTest() == 0); + CHECK(e.listRowCount() == 1); // the playlist is the module's settings and survives a disable + mm::JsonSink row; + e.writeListRow(row, 0); + CHECK(std::string(row.data(), row.size()).find("/sd/a.fseq") != std::string::npos); + + e.prepare(); // enabled again: the scan cache comes back and the stopped player starts on its own + CHECK(e.scratchBytesForTest() > 0); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); } -// Until playback lands the player says so, rather than showing black with no reason. -TEST_CASE("an FSEQ player says it reads no file yet") { - FseqRig r; - REQUIRE(r.fx.status() != nullptr); - CHECK(std::string(r.fx.status()) == "reads no FSEQ file yet: playback is the next step"); +TEST_CASE("deleting and moving playlist rows keeps each row's own path") { + SdFixture sd; + sd.writeFseq("/sd/b.fseq", makeFseqV1Bytes(10, 2)); // written second-name first: the picker sorts, whatever order the filesystem lists + sd.writeFseq("/sd/a.fseq", makeFseqV1Bytes(10, 2)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id1 = 0, id2 = 0, id3 = 0; + REQUIRE(e.addListRow(id1)); + REQUIRE(e.addListRow(id2)); + REQUIRE(e.addListRow(id3)); + REQUIRE(e.setListRowField(id2, "file", "{\"value\":1}")); + const auto path = [&e](uint8_t row) { mm::JsonSink j; e.writeListRow(j, row); const std::string t(j.data(), j.size()); return t.substr(t.find("/sd/"), 10); }; + CHECK(path(0) == "/sd/a.fseq"); + CHECK(path(1) == "/sd/b.fseq"); + REQUIRE(e.moveListRow(id2, 0)); + CHECK(path(0) == "/sd/b.fseq"); + CHECK(path(1) == "/sd/a.fseq"); + REQUIRE(e.deleteListRow(id1)); + CHECK(e.listRowCount() == 2); + CHECK(path(0) == "/sd/b.fseq"); + CHECK(path(1) == "/sd/a.fseq"); }