Skip to content

feat(fled): machine-enforce the video.color source-color contract - #508

Open
zackees wants to merge 2 commits into
mainfrom
feat/fled-color-enforcement
Open

feat(fled): machine-enforce the video.color source-color contract#508
zackees wants to merge 2 commits into
mainfrom
feat/fled-color-enforcement

Conversation

@zackees

@zackees zackees commented Aug 25, 2026

Copy link
Copy Markdown
Owner

What

Makes the .fled container's video.color source-color metadata machine-enforced. The field was already written by the recorder and described in prose, but nothing validated it on either side — a producer could emit a declaration the spec forbids and a consumer could reinterpret one, with no way to notice.

That asymmetry matters more than a normal missing validation: a wrong color declaration is invisible at runtime (nothing crashes, the pixels are just quietly wrong) and the file outlives the tool that wrote it.

Changes

  • docs/fled-format.md — new "Source color metadata" section: the four independent fields, the {bt709, srgb, rgb, full} default tuple, per-format color classes, seven validation rules, and the forward-compatibility rule. It names the implementation and both test suites, so a rule stated without tests is a documented bug.
  • render/fled-color.ts (both source trees) — validateFledColor / readVideoColor / buildVideoColor, throwing FledColorError with a code and, where field-scoped, a field. Rejects linear/pq/hlg transfers on display-encoded formats, limited range, non-rgb matrices, partial declarations on formats with no default tuple, and malformed custom primaries.
  • New pixel_format 0x05 rgb16_linear (6 B/LED), which requires transfer: "linear". Adding an enum value is not a version bump — unknown values already have defined rejection behavior.
  • recording.tsembedFps derives its color block from buildVideoColor(pixelFormat) instead of a literal, so the declaration cannot drift from the payload it describes.
  • scripts/inspect-fled.mjs — reports the resolved tuple and exits 11 on an invalid declaration.
  • 32 tests covering every rule, plus a parity guard on the duplicated src/ and packages/gfx/src/ copies, which nothing in the repo enforced before.

The invariant that shaped the design

An absent declaration is never an error. Every recording predating this field — including every public/demo/*.fled, written with no video block at all — must keep playing, so a format with a default tuple resolves to it. Only a declaration that is present and invalid is rejected. Verified against a real demo file:

pixel_format: 0x00 rgb8 (3 B/LED)
video.color:   absent -> default tuple {bt709, srgb, rgb, full}
VERDICT: file looks well-formed; Movie Player should accept it.

Validation

Check Result
npm test 764 pass, 0 fail (+32 new)
npm run typecheck 93 errors — identical count to the pre-change baseline, all pre-existing in unrelated test files
npm run lint exit 0
check:gfx-policy / test:gfx:package / test:gfx:parity / test:gfx:fastled-adapter all exit 0
check-gfx-api.mjs exit 0, 31 symbols verified
inspect-fled.mjs end-to-end valid → exit 0; transfer: linear on rgb8 → exit 11; range: limited → exit 11

Known follow-up (deliberately out of scope)

isSupportedFormat() is exported but called by nothing, so known-but-unrenderable pixel formats (gray8, rgba8, rgbw8, rgb565le — and now rgb16_linear) parse through instead of being rejected cleanly at the player boundary. This PR widens that pre-existing hole by one format rather than creating it, and no rgb16_linear file exists anywhere yet. Fixing it properly means deciding rejection policy for four formats that predate this work and touching the Movie Player / demo UI paths, which is a player-behavior change, not a color-spec change. Filed as a follow-up rather than smuggled in here.

Cross-repo

The FastLED consumer implements the same rules — companion PR: FastLED/FastLED#4045. The canonical spec in this PR cites src/fl/fled/color.h and tests/fl/fled/fled_color.cpp, which land there. The two test suites are halves of one contract: change a rule in one and the other must move in the same change.

One boundary worth a reviewer's eye

The transfer constraint (rule 2) applies to the display-encoded RGB family, not to gray8/rgbw8, so {pixel_format: rgbw8, transfer: "linear"} is currently accepted. I probed this deliberately.

The argument for tightening: rule 2's rationale is precision — 8 bits of linear light crushes the dark end — and that applies to any 8-bit format. The argument against, which won: gray8 is documented as a brightness/effect mask, and a linear mask is a legitimate thing to declare. Blanket-tightening every 8-bit format would forbid it for no benefit. Formats with no default tuple must already declare all four keys explicitly, and neither implementation interprets their color today.

Happy to tighten if a reviewer disagrees — it's a one-line change to the format lists in fled-color.ts, plus the same edit in FastLED's color.cpp.hpp.

Review round (second commit)

An independent review of the first commit found three real gaps, all fixed:

  • Spec/code contradiction (HIGH) — the color-classes table said gray8/rgbw8 could declare all four keys "or be absent", while all three implementations reject an absent declaration there. The strict reading is right; the document was wrong, and now also distinguishes unresolvable from malformed.
  • inspect-fled.mjs missed rules it advertised (HIGH) — it skipped primaries validation for non-string scalars and never implemented rule 7 at all, exiting 0 on files the reference validator rejects. The tool that exists to catch this was the one missing it. All five inputs the reviewer probed now match the validator.
  • embedFps swallowed video.fps (MEDIUM) — a color failure landed in the catch that returns the raw screenmap, silently dropping the playback rate too. It now writes the fps and warns.

Also addressed: the spec's "advisory" promise contradicted rule 1's hard rejection of unknown values (a newer reader would refuse a file an older one plays — exactly what advisory prevents), so the spec now separates a declaration verdict from consumer file policy; api-report.json records all seven new exports rather than three; DEFAULT_COLOR_TUPLE is frozen; reserved YCbCr matrix names read as "reserved" while a typo reads as "unrecognized".

Scope note

The validator is exported but not yet called from any decode path, here or in FastLED. That is the intended phase-1 landing: producers and tooling are enforced now, consumers opt in as the color pipeline lands. The spec's consumer-policy section defines what they must do when they do.

`video.color` was written by the recorder and described in the spec, but
nothing validated it on either side. A producer could emit a declaration the
spec forbids and a consumer could reinterpret one, with no way to notice: a
wrong color declaration is invisible at runtime and outlives the tool that
wrote it.

Turn the prose into enforced rules.

- docs/fled-format.md: new "Source color metadata" section - the four
  independent fields, the {bt709, srgb, rgb, full} default tuple, per-format
  color classes, seven validation rules, and the forward-compatibility rule
  that keeps video.color advisory for rgb8 while mandatory semantics gate on a
  new pixel_format. Names the implementation and both test suites, so a rule
  stated without tests is a documented bug.
- render/fled-color.ts: validateFledColor / readVideoColor / buildVideoColor,
  throwing FledColorError with a code and field. Rejects linear/pq/hlg on
  display-encoded formats, limited range, non-rgb matrices, partial
  declarations on formats with no default tuple, and malformed custom
  primaries. An absent declaration is never an error - every pre-color
  recording keeps playing.
- New pixel_format 0x05 rgb16_linear (6 B/LED), which requires
  transfer: linear. Not a version bump; unknown values already reject.
- recording.ts: embedFps derives its color block from buildVideoColor(format)
  instead of a literal, so the declaration cannot drift from the payload.
- inspect-fled.mjs reports the resolved tuple and exits 11 on an invalid
  declaration.
- 32 tests covering every rule, plus a parity guard on the duplicated
  src/ and packages/gfx/src/ copies, which nothing enforced before.

The FastLED consumer mirrors these rules in src/fl/fled/color.h with
tests/fl/fled/fled_color.cpp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review of the previous commit found the spec and the implementations
disagreeing in two places, and the diagnostic tool missing rules it advertised
enforcing.

- The color-classes table said gray8/rgbw8 may declare all four keys "or be
  absent", while all three implementations reject an absent declaration there.
  The strict reading is the correct one - an rgbw8 payload has no interpretable
  color without a declaration - so the document was wrong. It now also says
  that outcome is *unresolvable*, not *malformed*, since callers report it
  distinctly and may not care.
- Spec now separates a declaration verdict from a consumer's file policy. The
  old text promised video.color was "advisory" while rule 1 made an unknown
  value a hard rejection, so a reader on today's rules would refuse a file that
  a pre-color reader plays fine - exactly what "advisory" exists to prevent.
  Producers and tooling stay strict; playback consumers may fall back to the
  default tuple on advisory formats, and must refuse on mandatory ones.
- inspect-fled.mjs skipped primaries validation for non-string scalars and
  never implemented rule 7 at all, so it exited 0 on files the reference
  validator rejects - the tool that exists to catch this was missing it.
- embedFps no longer lets a color failure swallow video.fps along with it.
- api-report.json records all seven new exports, not three; FledChromaticity is
  nameable from the entry points; DEFAULT_COLOR_TUPLE is frozen.
- Reserved YCbCr matrix names now read as "reserved" while a typo reads as
  "unrecognized", and an explicit JSON null is treated as absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant