HF_DE_PARALLEL_STREAM was restored on every exit path, but the producer
server allows concurrent renders in one process — a router-eligible job's
mutation was still visible to an unrelated render already executing during
that window. Thread the router's decision as a per-render local instead of
a global env var; HF_DE_PARALLEL_STREAM stays as the manual opt-in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Address unanimous review feedback on PR #2095 (Miga, Magi REQUEST_CHANGES,
Rames D Jusso): the router's process.env.HF_DE_PARALLEL_STREAM mutation
was only cleared on the DE self-verify-retry branch — every other exit
(happy path, any non-DE-verify error, abort) left it set, leaking the
parallel-streaming opt-in into the next render sharing the same process
(a regression/benchmark harness, or any batch host).
Capture the prior value before mutating and restore it in the outer
`finally` (executeRenderJob's own top-level try/finally, which runs on
every exit path by construction), not just the narrow retry branch. Note
`deParallelRouter` and the new `deParallelStreamEnvBefore` had to move
above the outer `try` — a `let` declared inside `try {}` is not visible
in the sibling `finally {}` block in JS, so the original placement
alongside the other DE state would not have compiled once referenced
from the finally.
Also: renamed the shared `preInversionWorkerCount` local to
`preRoutingWorkerCount` (Miga + Rames nit — it now serves both the
inversion and the router), and pinned worker count 3 explicitly overrides
calibration by design (documented per Miga/Rames's question, not a bug).
Verified end-to-end (not just unit tests): ran two executeRenderJob calls
back-to-back in one process, router-eligible then not — env is restored
to undefined after render 1 and stays clean through render 2, the exact
leak scenario the reviews described. 117 orchestrator tests pass (3 new,
covering the restoreEnv primitive directly).
Blocker (flagged by all three reviewers, still open after the CI fix):
- Composition.serialize() interface in types.ts never got the { stripRuntime? }
param the implementation already accepts, so a consumer holding a
Composition-typed ref (exactly pacific's case) got a strict-TS arity error
calling comp.serialize({ stripRuntime: true }). Widened the interface.
Also addresses:
- getRootElements() now cached like elementsCache (same 3 invalidation sites) —
cheap insurance if a layer panel calls it every render tick.
- getElementTimings' resolver now uses the already-parsed expr.value for the
absolute-number case instead of silently re-parsing via parseFloat, via a
small resolveReferenceStart helper split out to keep resolveStart's own
branching low.
- bareId's `?? scopedId` fallback gets a comment: it's unreachable at runtime
(split() always returns >=1 element) but required by noUncheckedIndexedAccess.
- serialize({ stripRuntime }) docblock generalized past "the editing iframe" —
it's for any host driving its own clock.
- Documented (and pinned with a test) the bare-id reference resolution's
cross-scope behavior: a sub-composition element referencing a colliding bare
id resolves to the canonical top-level match, same as the runtime's own
resolver — consistent, but a real authoring footgun worth calling out.
- New tests: chained (A->B->C) references, a direct self-reference cycle, a
mutual A<->B cycle, the cross-scope bare-id collision above, and an import
assertion that RUNTIME_BOOTSTRAP_ATTR is actually reachable from
@hyperframes/core and matches the marker generators stamp.
422/422 sdk tests passing (417 + 5 new). Full workspace build (incl. studio)
verified clean.
* fix(cli): map zh to espeak-ng's cmn for Kokoro TTS synthesis
espeak-ng 1.52.0 recognizes Mandarin Chinese as the ISO 639-3 code "cmn",
not Kokoro's own voice-prefix convention "zh". `hyperframes tts --lang zh`
was forwarding "zh" straight through to kokoro_onnx.Kokoro.create(), which
failed with "language zh is not supported by espeak backend". Translate
only at the Python/espeak boundary; the public --lang value stays "zh"
since that matches Kokoro's own docs and voice-ID prefixes.
* test(cli): cover Kokoro zh language override
Promotes the opt-in HF_DE_PARALLEL_STREAM mechanism (#2056) into the
auto-routing decision, gated behind its own default-off flag
(HF_DE_PARALLEL_ROUTER). This is the next step from the 2026-07-08
parallel-DE benchmark verdict: par3/single 1.16-1.36x on real-work
comps >=2,000 frames, no comp anywhere losing to single-worker.
shouldPreferParallelDrawElement mirrors shouldPreferSingleWorkerDrawElement
(#2026) but takes priority over it when both are eligible — its higher
default threshold (HF_DE_PARALLEL_MIN_FRAMES=2000 vs the inversion's 900)
means it only ever picks up the long tail the inversion's own benchmark
didn't cover. Fixed at 3 workers (benchmark-validated; not calibration-
derived), same shape as the inversion pinning to a fixed 1.
resolveParallelRouterRetryPlan mirrors resolveInversionRetryPlan for the
self-verify-failure rollback path: falls back to the ordinary (non-DE)
parallel-disk path at the pre-router worker count. The caller must clear
HF_DE_PARALLEL_STREAM before recomputing useStreamingEncode or the retry
would keep resolving to the parallel-streaming shape.
New telemetry (de_parallel_router, de_pre_router_workers) tags which
render used the router, separate from de_worker_inversion — needed
before the planned telemetry soak can segment revert-rate and
de_verify_min_db to the parallel cohort specifically; today there's no
way to tell those apart from ordinary single-worker DE renders.
Verified end-to-end: HF_DE_PARALLEL_ROUTER=true on a 2,381-frame comp
resolves to 3 workers with 3 separate drawElement sessions and renders
successfully; without the flag, behavior is unchanged (falls through to
the existing single-worker inversion, workerCount=1) — no regression to
current production routing. 114 orchestrator tests pass (15 new).
Closes gaps surfaced by pacific#30298 (hyperframes layer panel), where consumer
code had to hand-roll fixes for things the SDK/core already solve or nearly solve:
- getRootElements(): getElements() flattens the tree, so every descendant also
appears as its own top-level entry. buildRoots() already computes true roots
internally; this exposes it directly instead of making consumers re-derive
roots by filtering out descendant ids.
- Export isNewHostBoundary + bareId from @hyperframes/sdk: both already existed
internally (engine/model.ts) but weren't exported, so consumers were
duplicating sub-composition-boundary detection and scoped-id-to-DOM-leaf
conversion by hand.
- Export stripEmbeddedRuntimeScripts + RUNTIME_BOOTSTRAP_ATTR from
@hyperframes/core, and wire serialize({ stripRuntime: true }) on the SDK
session: a proper tokenizing implementation already existed in
compiler/htmlDocument.ts (handles more runtime-script marker variants than a
naive regex), just never exported. The SDK itself imports these via narrow
subpaths (./runtime/start-expression, ./compiler/html-document) rather than
the wide ./compiler barrel, matching the SDK's existing import convention and
avoiding pulling Node-only compiler code (fs/path) into browser bundles.
- Fix getElementTimings(): data-start can be a relative-reference expression
("intro", "intro + 2" — see parseStartExpression's grammar), not just an
absolute number. The old code did a raw parseFloat() on it, which silently
resolved any reference expression to 0. Now resolves references recursively
against the target element's own resolved start + duration, Node-safe (no
live GSAP timeline needed for this case).
14 new tests (session.timings.test.ts, session.subcomp.test.ts). Full sdk
suite: 417/417 passing. Full workspace build (incl. studio) verified clean.
The storyboard view's empty state was a single "no storyboard yet" line. Add a
copy-to-clipboard prompt box (a ready-to-paste handoff prompt carrying the
canonical STORYBOARD.md frontmatter + per-frame format) so users can hand it to
their coding agent, plus a faded skeleton of the contact-sheet grid so landing
on an empty board previews what a filled one looks like instead of a dead end.
Crop is now part of the element selection instead of a separate mode. Selecting
a croppable element shows edge handles just outside each side and, once cropped,
the full content with the cropped-away area dimmed plus a center reposition
handle to pan the crop window. Dragging the body moves the element, edge handles
crop, the center handle pans; corners stay free for the resize handle. Removes
the crop-mode toggle (toolbar + property-panel buttons), the cropMode/
cropAvailable player-store state, and the double-click-to-crop gesture. The
clip-path inset model is unchanged.
Sub-composition <head> styles targeting html/body/:root (width/height/
overflow/background) were injected into the parent document unscoped by both
the Studio runtime mount (compositionLoader) and the render-time inliner
(inlineSubCompositions/htmlBundler). scopeCssToComposition deliberately passed
html/body/:root through unchanged, so a sub-composition smaller than the root
clobbered the host <body> dimensions and its overflow:hidden clipped the
composite to the last sub-comp's size. Only the top-left element painted;
everything else (and framework-owned video positioned outside that box) was
clipped away.
Add a scopeRootSelectors option to scopeCssToComposition that remaps
html/body/:root to the composition's own box, and enable it everywhere
sub-composition styles are scoped. The universal selector stays untouched.
Top-level composition scoping is unchanged (it legitimately owns the document).
Covered by new compositionScoping tests.
* feat(media-use): color grading — grade/lut resolve, smart-grade, grade-compare CLI
Add color grading to media-use as first-class resolve types plus a faithful
comparison command. All local, offline, deterministic — no model, no GPU.
- resolve -t grade / -t lut: produce a data-color-grading block (or a frozen
.cube). Look cascade: core preset (no file) -> bundled .cube library ->
parametric buildCube. Emitted .cube is Rec.709 and validated against core's
colorLuts constraints (LUT_3D_SIZE <= 64) before it is frozen.
- smart grade (grade --for <media>): ffmpeg signalstats -> adjust suggestion
(exposure / contrast / white balance), surfaced with the measured evidence on
stderr as a starting point; never auto-applied.
- hyperframes grade-compare: renders N candidate grades onto a reference frame
through the real runtime shader into one labeled comparison PNG, so an agent
picks a look without opening Studio. Prepends an "original" baseline cell by
default (--no-baseline to omit). Shares the headless-capture pipeline with
snapshot via capture/captureCompositionFrame.
- media-use SKILL: proactive "media opportunity pass" guidance (grounded
signal -> offer, ask once, surface don't mutate).
Verified: media-use 116/116, grade-compare 7/7, snapshot 9/9, lint + format
clean, full build green, comparison renders end to end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* test(cli): narrow grade-compare baseline assertion off unknown-typed grading
Assert the whole cell via toEqual instead of reaching into .grading.preset /
.grading.lut on the unknown-typed field, keeping the test typecheck-clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* feat(media-use): agent-authored LUTs via --params + validate --from cube; never-read-.cube guardrail
- resolve -t lut / -t grade --params '<json>': build a parametric .cube from
explicit params (bypassing the intent cascade), validate, and freeze in one
step. --intent becomes the optional description. Lets an agent commit a look
it computed itself.
- --from <file.cube> now validates the ingested LUT for lut/grade types and
rejects an invalid/oversized cube (no partial write) — the escape hatch for a
LUT the agent generated with its own code.
- SKILL.md: hard rule to never read a .cube body into context (~size^3 lines,
zero legible signal) — inspect via grade-compare (see it) or cube-validate
(ok/size), read the manifest description for meaning; plus both authoring
paths and the parametric-vs-film-stock ceiling note.
Verified: media-use 116/116, lint + format clean; smokes — --params builds a
valid frozen cube, grade --params returns a lut block, bad JSON and an oversized
--from cube are both rejected with no stray file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(cli): grade-compare validates referenced LUTs, warns on no-op cells, caps candidates
Bug-bash follow-ups — grade-compare silently accepted bad input:
- Validate LUT *content*, not just existence: each referenced .cube is parsed
with core's parseCubeLut (now exported from @hyperframes/core) and rejected
with a per-cell error ("LUT for \"<label>\" is not a valid .cube: ..."). A
file that exists but isn't a valid cube no longer renders a silent no-op cell.
- Warn on inactive cells: a grading that normalizes to inactive (e.g. a
malformed {lut:12345}) emits a stderr warning naming the cell; the
auto-prepended "original" baseline is intentionally inactive and stays silent.
stdout remains valid JSON.
- Cap candidates at 16 (excluding baseline): over-cap input renders the first N
and reports {truncated:true, total:M} on stdout + a stderr note — no silent
drop, no unbounded giant sheet.
Verified: grade-compare 10/10; non-cube LUT → clear error; {lut:12345} → warning
+ ok; 20 cells → cells=17 truncated total=20; valid runs unchanged. Lint/format
clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* feat(cli): general `hyperframes compare` visual-variant primitive
Generalize grade-compare's "render N variants → one labeled sheet → the agent
looks and picks" loop into a standalone command that works on ANY variation
(font, layout, motion, grade, whole compositions) — the tool never needs to
know what differs.
- `hyperframes compare <path...> [--at <sec>] [--labels a,b,c] [--out] [--cols]
[--json]`: renders each agent-authored composition variant through the real
runtime (captureCompositionFrame) and stitches one labeled comparison sheet +
JSON ({ok, sheet, rendered, variants, truncated?/total?}). 2+ paths required;
caps at 16 with loud truncation. It presents, it does not judge — choosing is
the caller's job.
- Factored the shared "render a labeled set → contact sheet" path so compare,
grade-compare, and snapshot all sit on it (no duplication). grade-compare is
now the first color-specific specialization of this primitive.
- New pathArgs util + contactSheet test; hyperframes-cli SKILL documents compare
as the agent's "see your own renders and choose" primitive.
Verified: 26/26 across compare + grade-compare + snapshot + contactSheet (no
regressions); compare renders 3 variants into one visibly-distinct labeled
sheet; 2+-path error path clean; lint/format clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(ci): green the skills CI — skip ffmpeg tests when absent, oxfmt markdown
The "Test: skills" CI job runs bare `node --test` with no ffmpeg on PATH (by
design — skills tests are meant to be node-builtin-only). The grade-analyzer +
smart-grade tests shell to ffmpeg and were failing there with ENOENT. Guard
them to skip when ffmpeg isn't on PATH; they still run locally / where it is.
Also oxfmt README.md + hyperframes/media-use SKILL.md (the whole-repo
`oxfmt --check .` Format job caught markdown left unformatted by the rebase
conflict resolution).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(ci): skip core-conformance test when tsx is unavailable
The "Test: skills" CI job installs no deps, so the normalizeHfColorGrading
conformance test (which imports core's TS via `node --import tsx`) failed there.
Guard it to skip when tsx can't resolve; runs locally / in the deps-installed
Test job. Completes the skills-CI greening (the ffmpeg guards handled the rest).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(cli): escape grade-compare src double-quotes (CodeQL XSS) + Windows-safe compare test
- grade-compare built `<img src="...">` (double-quoted) with the single-quote
escaper, leaving `"` unescaped — a `"` in the frame path could break out
(CodeQL: incomplete HTML attribute sanitization). Use escapeXml for src.
- compare label test hard-coded POSIX paths that can't match on Windows; assert
the derived labels (the subject); path resolution is covered elsewhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* refactor(media-use): generate LUT library from params (drop committed .cube files)
The 3 bundled .cube files were 733 lines each (2,199 total) and were themselves
buildCube output — pure repo bloat. Replace with compact per-look params in
luts/index.json, generated on resolve; add an optional `url` for future scanned
LUTs to be CDN-hosted + downloaded on demand (freezeUrl) instead of committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* feat(media-use): serve library LUTs from CDN on-demand (static.heygen.ai/luts), params fallback
Looks now carry a CDN `url` (hosted at s3://heygen-public/luts → static.heygen.ai/luts/<id>.cube);
resolve downloads + validates + freezes on demand, like bgm/image. `params` stays
as the deterministic offline fallback (--local-only, or if the download fails), so
resolution is never blocked on the network. Provider prefers url, falls back to params.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): address #2041 review — atomic LUT writes, compare telemetry, follow-ups
- Atomic .cube writes: library provider (url + params) and the parametric
generator now write to a .tmp path, validate, then rename, so a crash can
never orphan an invalid .cube at the final path (was validate-after-write).
- track("media_use_resolve") now emits provenance.via (url/params-fallback/params).
- grade-compare + compare: --timeout flag (was hardcoded 5000) and a
media_use_compare event (cells, truncated, total, render_ready_timed_out);
openSettledCompositionPage now surfaces the render-ready timeout.
- compare staging skips node_modules/.git; --for gets an upfront existence check.
- Rec.709 luma comment; HYPERFRAMES_ANALYZE_TIMEOUT_MS override; measured note
uses basename; LUT s3 hosting moved from index.json into luts/README.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`hyperframes transcribe --model large-v3` aborted with "unknown DTW preset
'large-v3'". whisper.cpp's --dtw flag wants a dotted alignment-heads preset
(large.v3), but we passed the hyphenated ggml file stem (large-v3). They
coincide for tiny/base/small/medium(+.en) — why it slipped through — but
diverge for the large-v* family. Map stem -> preset (- to .) so large-v1/v2/v3
(and large-v3-turbo) work; no-op for the others. Also fixes media-use, which
shells to `hyperframes transcribe`.
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address two max-effort code-review findings on PR #2056 not covered by
the earlier review-gap commit:
- captureFrameToBufferPipelined's static-dedup reuse branch never
advanced session.lastEncodeResultFrame, unlike its sibling real-capture
branches. The gap-check window is computed from that watermark, so
every consecutive reuse in a static run rescanned an ever-widening
window instead of just the newest frame — O(n^2) total work over a
long static stretch instead of O(n).
- The "single-threaded, no race" justification on the shared
parallelGuard closure was wrong: the guard has real internal await
points (recapture, PSNR) between reading and writing its
sizes/absFloor/acceptedSmall state, so concurrent workers' calls do
interleave there (confirmed). Replaced with the actual reason it's
safe: absFloor only ratchets down, sizes is append-only and
order-independent for the median, and acceptedSmall's fast path
re-validates by exact byte-equality regardless of which worker wrote
the reference buffer.
Address PR #2056 review feedback:
- Fix totalFrames progress inflation for interleaved tasks — divide
each task's span by its frameStride to match the actual per-worker
frame count (captureFrameRange steps by stride), instead of summing
raw endFrame-startFrame which double(N)-counts interleaved tasks.
- Attach a no-op .catch to each frame's pipelined encodeResult at kick
time so an abandoned promise (loop exits early on abort/error before
draining it) can't surface as an unhandled rejection during teardown.
- Document why the pipelined branch's stride=1 path is validation-only
in production (HF_DE_PARALLEL_STREAM always uses interleaved
distribution) so a future refactor doesn't unknowingly widen it.
- Comment the intentional single shared parallelGuard/parallelStats
across workers (safe single-threaded, better rolling-median signal).
Step 2 of the DE engagement plan: multi-worker drawElement capture through
the streaming encoder, with the full runtime self-verification net riding
along — the confinement rule that kept the parallel clamp in place is now
satisfied on this path. Opt-in via HF_DE_PARALLEL_STREAM=true; default
routing (including the #2026 single-worker inversion) is unchanged.
Mechanism:
- distributeFramesInterleaved + WorkerTask.frameStride: worker i captures
frames i, i+N, i+2N... — seek-based capture makes stride free and the
ordered writer's reorder window shrinks from totalFrames/N to N (contiguous
chunks serialize workers behind the writer).
- Depth-2 pipelined worker-encode produce in the parallel worker loop (the
same shape as the sequential loop; frame k's in-page encode overlaps
k+stride's produce). HF_DE_PAR_DEBUG=1 traces the first frames per worker.
- Drain guard extracted to createDrainFrameGuard (session-parameterized):
every parallel frame gets the SAME blank-guard + PSNR self-verify as the
sequential drain, against its owning worker's pre-injection ground truth
(all sessions arm identical sample indices from
CaptureOptions.compositionDurationSeconds).
- FrameReorderBuffer.abort(err): a failed worker (e.g. verification error)
rejects all parked and future waiters — without this, peers park forever
in waitForFrame and the pool (which awaits ALL workers before surfacing
errors) deadlocks. Found by the verify-trip test; unit-tested.
- The typed DrawElementVerificationError is preserved past the pool's
error-string flattening so the orchestrator's verify-retry recognizes it.
- Static-dedup stride hazard fixed: lastEncodeResult reuse now requires EVERY
frame in (lastEncodeResultFrame, i] to be predicted-static (sequential
capture reduces to the old has(i) check).
- Workers get separate browser PROCESSES under the flag: pages co-tenant in
one browser starve non-active pages of BeginFrames on the paint-wait path
(measured 86s vs 30s on a 3,245-frame rAF comp).
Validation:
- Happy path W3: verify samples pass across workers (4x inf on the 2,381f
comp), output vs single-worker DE = 59.3dB (encode noise floor) — the
interleave + dedup-stride produce identical pixels.
- Verify-trip (marginal comp + HF_DE_VERIFY_MIN_DB=45): fails at frame 649
(32.2dB < 45), peers abort instead of deadlocking, whole render retries
via parallel screenshot, RENDER_OK in 42.6s.
- Canary suite 7/7 with the flag off (default paths untouched); producer
orchestrator tests 99/99; engine suite 909 passed (14 pre-existing main
failures, stash-A/B verified); reorder-buffer abort unit tests.
Perf note: capture-only parallel speedup measured 1.38x (W2) / 1.52x (W3)
over single-worker DE in the spike; end-to-end numbers on this machine are
currently noisy (separate-browser init overhead + bench load) — clean
benchmarks before any default routing change. The flag stays explicit
opt-in; promoting it into the router replaces the #2026 W=1 pin for the
same cohort.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address the highest-severity max-effort code-review finding on the now-
merged #2082 (the drawElement Chrome-version-pin fix): findFromHyperframesCache
matched a cached Chrome by browser type only, never comparing its
buildId against CHROME_VERSION. Any machine that already rendered with
an older hyperframes version has an old build (this pin has moved
131 -> 151 -> 152 across releases) sitting in ~/.cache/hyperframes/chrome,
which satisfied the lookup and silently defeated the whole point of
#2082's version bump for exactly the population it was meant to fix —
drawElement's new capability probe would then permanently and silently
fall back to screenshot capture instead of ever fetching a build that
implements canvas.drawElementImage.
Verified directly (not just via review): seeded ~/.cache/hyperframes/chrome
with the old 131 build, confirmed a real render previously kept using it
forever; with this fix it's correctly ignored and 152 is downloaded.
New regression test locks in the buildId mismatch case.
Address max-effort code-review finding on PR #2045 (confirmed, not
addressed by the earlier review-gap commit): the script_failure bail
path skipped the composition-id enumeration entirely, so a render with
multiple sub-compositions sharing a failed script only logged the raw
failed URL(s), never which composition(s) were still waiting on it —
a real observability regression versus the pre-#2045 behavior, which
always logged the missing-id list on any non-ready outcome.
Now enumerate unregistered composition ids unconditionally and log them
alongside whichever reason (script_failure or natural timeout) fired.
Address PR #2045 review feedback:
- Share a SubTimelineWaitOutcome type (engine) end-to-end instead of
widening to string across CapturePerfSummary / RenderPerfSummary /
telemetry, so the three layers can't drift.
- Dedupe scriptLoadFailures on push — a 4xx response and its trailing
requestfailed both recorded the same URL, doubling the failed-URL
list in the fail-fast warning.
- Thread the sub-timeline-wait outcome into render_error (not just
render_complete): a render that fail-fasts and then fails downstream
(pollVideosReady, extract, encode) previously dropped this signal on
the floor. dedupPerfs is now function-scoped so the catch path can
read it, same treatment as the existing captureAttempts array.
pollSubCompositionTimelines waits for every [data-composition-id] host to
register window.__timelines[id]. When the script carrying that registration
fails to load (404 / request failure), the registration can never arrive —
but the poll still burned the full playerReadyTimeout (45s), then warned and
shipped a silently animation-less render. Wild scale: the capture-setup
histogram over 30 days of local renders decays smoothly (402/503/364/282/191
per 5s bucket) then spikes to 705 at the 45s bucket — ~1,000 renders/month
across 402 distinct users, ~15 user-hours of pure waiting.
- Sessions now record failed SCRIPT resources (requestfailed + HTTP>=400
response, listeners that already existed for diagnostics) in
session.scriptLoadFailures.
- pollSubCompositionTimelines takes a failure getter and cuts the wait to a
2s grace once any script failed, with a loud warning naming the URL(s).
Late-registering fetch-async comps are unaffected: no script failure means
the full timeout still applies, and a registration landing inside the
grace window still wins (tested).
- Outcome telemetry: session.subTimelineWaitOutcome ("ready" | "timeout" |
"script_failure") -> CapturePerfSummary -> RenderPerfSummary.subTimelineWait
(worst across sessions) -> render_complete sub_timeline_wait, so the wild
rate becomes directly trackable instead of setup-histogram forensics.
Validation: the discovery comp (0768f038, its animations.js unreachable)
drops from ~72s to 23.1s total — poll cut at 2.1s with the script named;
healthy comp reports "ready". Canary suite 7/7 (PSNRs identical). 4 new
poll unit tests; engine suite 907 passed (14 failures are PRE-EXISTING on
main at v0.7.42 — 18 fail on a clean checkout, stash A/B verified).
tsc/oxlint/oxfmt clean.
Corpus note: 258/1,762 corpus comps (14%) reference local scripts missing
from the corpus fetch — their historical eval INIT timings measured this
timeout, not the engine. Capture-stage ratios remain valid (both paths paid
it equally).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address PR #2082 review feedback:
- Route studio thumbnail + render call sites through preferManagedChrome
so studio renders no longer silently fall back to whatever system
Chrome happens to be installed.
- `hyperframes browser ensure` now resolves through the same
preferManagedChrome path render uses, so it reports what render will
actually pick instead of any system Chrome it happens to find.
- Point the unsupported-Chrome fallback log at `browser ensure --force`
instead of `doctor`, which doesn't check Chrome/drawElement capability.
- Fix stale findFromCache comment: the HF pin is now a Dev-channel build
that can be newer than a user's puppeteer-cache Stable install.
canvas.drawElementImage is an unlaunched Dev/Canary-only Blink feature
(~151+). The CLI's pinned CHROME_VERSION fallback was still 131.0.6778.85 —
a puppeteer 24→25.2.1 bump that pinned it to Chrome Dev 151.0.7912.0 was
written on 2026-06-29 but never merged (orphaned local commit, no PR). Any
render on that pin, or on the shared puppeteer-cache binary, or on system
Chrome (Stable, no drawElementImage at all) got a canvas.getContext("2d")
missing the method and crashed mid-capture with "ctx.drawElementImage is
not a function" instead of falling back (HF#2060).
Three changes:
- Bump puppeteer/puppeteer-core to ^25.2.1 across every package that
depends on it, and CHROME_VERSION to 152.0.7928.2 (today's Dev channel;
confirmed via direct probe to implement drawElementImage, unlike 131).
- `ensureBrowser({ preferManagedChrome: true })`, always used by `render`:
resolve straight to our pinned/cached build, skipping both the shared
puppeteer-cache preference and system Chrome. Rendering shouldn't depend
on whatever arbitrary Chrome a machine happens to have — that's exactly
how this regressed (any Mac with Chrome.app installed bypassed the CLI's
pin entirely).
- A runtime capability probe in the engine, right before any other
drawElement work: if `drawElementImage` isn't a function on the injected
canvas, route to the existing screenshot-fallback gate instead of
crashing. This is the real backstop — it protects every resolution path
(env override, stale cache entry, a future Chrome regression), not just
the ones `preferManagedChrome` reaches.
Verified end-to-end: rendering against chrome-headless-shell 131 (confirmed
to lack drawElementImage) now falls back cleanly and produces a valid MP4
instead of crashing; rendering against a capable build still engages
drawElement normally. 922 engine tests + 1373 CLI tests pass.
Fixes#2060.
* fix(cli): report unknown-flag errors + cover nested subcommands (HF#2033)
Two flag-hygiene gaps behind the assertKnownFlags arc:
1. Telemetry loss: assertKnownFlags ran BEFORE the try/catch in the command
wrapper, so an unknown-flag throw skipped reportCommandFailure entirely —
zero signal on how often users hit bad flags. Moved the assertion inside
the try so it reports like any other failure.
2. Nested-subcommand scope: cli.ts wraps only the top-level command loaders,
so command groups' leaves (cloud/*, auth/*, figma/*, lambda/*, capture/*,
skills) were never wrapped — citty dispatches to the leaf, whose run had no
assertion and no failure reporting. So `hyperframes cloud render --badflag`
silently ignored the flag. trackCommandFailures now recurses through
cmd.subCommands (normalizing citty's Resolvable entries to loaders) and
wraps every leaf. Identity is preserved for bare no-run/no-subcommand defs.
Verified: `auth status --badflag` now errors "Unknown flag: --badflag"
(previously silent); `auth --help` still dispatches; top-level `lint
--badflag` still rejected. Tests: unknown-flag rejection is reported, and a
nested subcommand's failure reaches onFailure.
* test(cli): guard indexed subCommands access for noUncheckedIndexedAccess
CI Typecheck (tsc, unlike the local tsup build) flagged the nested-subcommand
test: indexing `subCommands["render"]` yields `T | undefined` under
noUncheckedIndexedAccess, so invoking it tripped TS2722/TS18048. Guard the
loader before calling it.
#2066 fixed sub-composition data-variable-values on the render path for a single
mount, but the reusable-template pattern from #2064 (the same sub-comp mounted
multiple times with different values) still diverged from preview/snapshot:
every mount shared one __hfVariablesByComp key and one CSS scope selector, so
the last mount's values clobbered the earlier ones and all-but-one instance
rendered blank.
The producer now assigns per-instance runtime composition ids
(assignBundledRuntimeCompositionIds) and threads hostIdentityMap into the shared
inliner, mirroring the preview bundler. The shared inliner's default
buildScopeSelector already scopes by the runtime id, and timelines remap to it
via the scoping proxy, so each instance's variables, CSS, and timeline land
under its own id.
Pixel-verified end to end: two mounts of one sub-comp with different
data-variable-values now render their own content (green CARD_A / blue CARD_B),
matching snapshot; single-instance behavior is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The WCAG contrast audit estimated each text element's background by sampling a
4px pixel ring just OUTSIDE its bounding box. For an element that paints its
OWN opaque background (a caption pill, a CTA button, a solid card), the text is
composited over that solid color, not over whatever surrounds the box. Sampling
the ring there measured the text against the scene behind the element (often a
dark photo), producing false ~1:1 ratios and flagging perfectly readable CTAs
and captions. Users reported the warning persisting no matter how they changed
the background color, because the audit was never reading it.
Resolve the nearest fully-opaque background-color by walking the element up its
ancestor chain, and use it when present; keep sampling the ring only when the
text sits over image pixels (a background-image is hit first) or no opaque
background exists. The pure decision lives in a new commands/contrast-bg.ts with
unit tests; contrast-audit.browser.js (injected as a raw string, so it cannot
import) inlines the same logic, mirroring the existing duplicated-WCAG-math
note.
* docs(cli): fix render examples that pass a file as the project dir
The render command's positional argument is the project directory (default
"."), resolved via resolveProjectOrThrow; a specific composition file is
passed with -c/--composition. Several docs showed `hyperframes render
index.html` / `render ./my-composition.html`, which treats the HTML file as
the project dir and fails with "Not a directory". Correct the guide and the
cli README to render the project's index.html directly (or point at a file
with -c).
* docs: fix render index.html example in the Open Design guide too (R1)
R1 flagged that open-design-hyperframes.md carried the identical
`npx hyperframes render index.html` example this PR fixes in the Claude
guide — same failure vector ("Not a directory" for a file positional).
Corrected to `npx hyperframes render` run from the project directory.
The add command declared its flag literally as `"no-clipboard"`, but citty
treats `--no-<name>` as the negation of a boolean `<name>` arg. So
`--no-clipboard` parsed as negating a (nonexistent) `clipboard` arg and
assertKnownFlags threw "Unknown flag: --clipboard" — even though --help
advertised --no-clipboard as valid.
Declare the positive `clipboard` (boolean, default true) instead and read
`args.clipboard === false`; citty's built-in negation then handles
`--no-clipboard` correctly. --help still lists both spellings.
Verified: `hyperframes add data-chart --no-clipboard` now succeeds instead of
erroring on the flag.
* fix(cli): warn when a WebM render loses its requested alpha channel
HyperFrames always encodes WebM with an alpha-capable pixel format
(yuva420p), but some ffmpeg/libvpx builds silently emit opaque yuv420p
even when handed alpha input and -pix_fmt yuva420p. The render succeeds
and plays back fine, so the lost transparency is only discovered after
compositing (users report shipping a solid-black clip and colorkeying it
out by hand).
After a WebM render, best-effort ffprobe the output's pix_fmt; if it
lacks alpha, print a non-blocking warning that names the concrete remedy
(--format mov / ProRes 4444). Only WebM is checked (mp4 is intentionally
opaque; mov/png carry alpha through paths that don't hit libvpx-vp9), and
a failed probe stays silent rather than warning speculatively.
Pure decision (pixelFormatHasAlpha / webmAlphaAdvisory) unit-tested;
verified end-to-end that a transparent WebM render now surfaces the
warning while an MP4 render stays silent.
* fix(cli): key WebM alpha check on ALPHA_MODE tag, not pix_fmt (R1 blocker)
R1 (Rames/Via) correctly flagged the detection as ~100% false-positive on
working builds. libvpx-vp9 stores the alpha plane in a Matroska
BlockAdditional sidecar, so ffprobe ALWAYS reports pix_fmt=yuv420p for a
correct transparent WebM (per docs/guides/rendering.mdx #1823 and the
webm-concat-copy smoke test). The real signal is the stream-level
ALPHA_MODE=1 tag: a working encode writes it; a build that can't emit the
sidecar omits it and produces genuinely opaque output.
Re-cut the probe to read stream_tags=alpha_mode (JSON, case-insensitive) and
warn only when a probed WebM lacks ALPHA_MODE=1. Tests inverted accordingly
(alphaMode:true → silent; alphaMode:false → warn). Verified end-to-end: a
transparent webm render on an alpha-preserving build (ALPHA_MODE=1) now emits
0 warnings; previously it warned on every webm.
parseAudioElements read data-start with a bare parseFloat, so a relative
reference (data-start="introClip", the documented 'start when that clip
ends' pattern) resolved to NaN. The mixer then silently dropped the track,
rendering the whole segment as pure digital silence — even though the SAME
reference on the sibling <video> placed the visual correctly (#2030 taught
parseVideoElements/parseImageElements to resolve refs; audio never learned).
Root fix, single source of truth: extract the Node-side reference resolver
out of videoFrameExtractor into referenceResolver.ts and use it in
parseAudioElements for both <audio> and <video data-has-audio> tracks. Now
every media parser resolves relative timing identically, so audio and video
cannot drift again. The two near-identical parse loops share one builder;
end stays a numeric read (mixer derives real length downstream), NaN-guarded.
Verified end-to-end: a composition with <audio data-start="clipId"> now
renders an audio stream that is silent before the referenced clip ends and
audible after (matches the numeric-start control); previously the output had
no audio stream at all. 78 engine media tests pass (4 new).
Add Modal as a third official deployment template alongside Vercel and
Cloudflare: comparison-table row, a Modal tab (deploy commands, what-you-get,
performance, pricing, async spawned-function note), architecture/pre-baking
updates, swap-the-composition steps, and a source card. Update the cloud.mdx
cross-reference to include Modal.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
render left window.__hyperframes.getVariables() empty inside every
sub-composition mounted via data-composition-src, so each instance rendered
its declared JS defaults instead of the per-instance data-variable-values.
preview/snapshot injected them correctly, so the composition looked right in
every authoring/QA surface and then rendered wrong content silently (exit 0).
Any template-library workflow (reusable sub-comp scenes parametrized per
video) shipped placeholder/default text in the final MP4.
The plumbing already existed on main: htmlCompiler passes
readVariableDefaults/parseHostVariables and populates result.variablesByComp,
and the CSS-custom-property path (emitRootCompositionVariableStyles) reaches
the render. But the render compiler emitted only the CSS vars and never the
JS table window.__hfVariablesByComp that the scoped getVariables reads, while
the preview bundler (htmlBundler) did -- so getVariables() returned {} only
during render.
Fix, so the paths cannot drift again: buildVariablesByCompScript, colocated
with the reader in compositionScoping.ts and shared by both compile paths.
htmlBundler now calls it instead of an inline string; htmlCompiler injects it
before the inlined sub-comp scripts, using the already-populated
result.variablesByComp.
Verified end-to-end: a sub-comp painting its background from a color variable
now renders the injected value under render, matching snapshot; previously it
rendered the default. 3 new producer tests; 89 htmlCompiler + core-compiler
tests pass.
Closes#2064.
* feat(skills): add brief contract — interaction modes + shared intake fields across workflows
New hyperframes-core/references/brief-contract.md, the shared intake
contract every creation workflow now runs its brief against:
- §1 interaction mode: collaborative (default) vs autonomous, ongoing
vs one-time signals, mode set once and carried forward, and a gate
taxonomy (preference / checkpoint / quality / routing) — autonomous
skips waiting, never verification
- §2 field registry: destination→aspect derivation (feed → 1:1,
Shorts/TikTok → 9:16, else 16:9), message, angle, length, audience,
language, narration — each workflow binds fields as ask or state
- §3 question rules: one round with one question per asked field
(native question UI mandatory when available, recommended option
first with a receipt), never drop a question as inferable, and a
mode legend advertised in the intro text instead of asked
Wired into the surfaces:
- hyperframes router: detect mode at entry, derive aspect from
destination instead of stating 16:9
- product-launch-video / pr-to-video / faceless-explainer: ask/state
binding tables at Step 0; Step 3/6 checkpoint-gate branches
(autonomous posts a heads-up with a preview hint before render)
- website-to-video: local mode definition now defers to the contract
- music-to-video, general-video, embedded-captions, talking-head-recut,
slideshow, motion-graphics: mode semantics wired per gate type
- storyboard-format: new optional 'mode' frontmatter key
- pr-to-video: length tier is a ceiling, not a floor — a one-headline
PR recommends inside the 30–90s sweet spot regardless of diff size
* feat(skills): story spine + mode-first brief across creation workflows
Story — the reverse-iceberg feedback:
- New hyperframes-creative/references/story-spine.md, three rules for
the narrated workflows: the hook speaks the viewer's outcome
language, the value claim lands by beat 2 (implementation is the
footnote of the story, not the spine), and the storyboard is
presented as a proposal — 'This video tells [audience] that
[message]' plus a per-frame why: drawn from narrativeRole
- pr-to-video: feature-reveal reordered promise-first (impact leads,
diff/mechanism follow as evidence); hooks ban file/function names;
fix-explainer, refactor-walkthrough, changelog unchanged
- product-launch-video / faceless-explainer hook rules aligned to the
spine; website-to-video's beat summary gains the echo line + why:;
general-video points at the spine from its plan step
Brief — hardened after live-test drift:
- Mode is now the first question (Collaborative recommended vs
Autonomous), its own round, skipped when the request carries a
signal; autonomous asks nothing further until one final
preview-or-render question before render
- Step 0 rewritten as a literal two-round question script in each
shot-sequence workflow (website-to-video's editorial register,
channel-agnostic); brief-contract.md §3 reduced to invariants so the
procedure lives in exactly one place
* feat(skills): split type minimums by viewing context
typography.md: full-screen viewing keeps body 20px / headline 60px;
in-feed destinations (X / LinkedIn / Instagram — brief-contract's
destination field) scale to body >=32px, headline >=90px, data labels
>=24px. First-pass values, to be calibrated against real renders.
* feat(skills): storyboard proposal as a table + credits close by default
- story-spine § 3: the proposal presents frames as a markdown table
(frame · beat · on screen · why) instead of dense paragraphs; the
three shot-sequence workflows and website-to-video's beat summary
reference the same shape
- pr-to-video: the credits close is now the default ending — every PR
video ends on a contributors frame (committers by commit count, 1-6
avatars), with no taste judgment; the only skip is when no avatar
was fetched, and the user can cut the frame in the proposal
* fix(skills): address review nits on the brief/story contracts
- embedded-captions: the identity procedure now states both sides of the
preference gate inline (user picks; autonomous picks with a stated why)
- website-to-video step-2-brief: note that its mode section is the
workflow's application of brief-contract.md, not a second definition
- brief-contract: resuming a project reads mode from STORYBOARD.md
frontmatter — a recorded mode counts as set, closing the write-only gap
* docs(skills): add a non-code receipts example to the brief contract
Review nit (jrusso1020, #2058): the receipts example in § 3 was
PR-video-shaped only. A destination-shaped example joins it so the rule
reads as workflow-neutral.
* fix(cli,skills): install workflow skills on demand instead of re-pulling the full set
Users report every init re-pulls all 21 skills into ~/.agents/skills
whenever anything is stale or missing - heavy, noisy, and it re-expands
deliberate partial installs.
Split the set into two tiers:
- core: the /hyperframes router + hyperframes-* domain skills +
media-use, which every workflow references structurally. init and
bare 'skills update' keep these (plus anything already installed)
fresh, and never expand the install.
- on demand: the end-user workflow skills (and figma). They install at
trigger time via 'skills update <name...>' - positional names are
the only way update expands an install: one targeted
'skills add --skill <name>' covering only stale/missing targets, a
fast no-op when current, presence-verified after install, exit 1 on
unknown names, and a presence-only degrade when GitHub is
unreachable.
The /hyperframes router now runs 'skills update <workflow>' after
routing and before reading the workflow skill, so a routed workflow is
guaranteed present even on a machine that only has the core set. Each
on-demand skill also opens with the same self-maintenance step (run
'npx hyperframes skills update <name>' silently), so a workflow
triggered directly - without the router - still refreshes itself and
restores any missing core skill before relying on it.
When the manifest is unreachable (offline / rate-limited) the engine
degrades honestly instead of claiming success: named runs presence-check
the request plus a pinned fallback core list (unit-pinned to skills/)
and blind-install whatever is absent; a bare strict update fails loudly
so the 'check || update' chain can't pass while everything stays stale;
init reports the skipped freshness check. --json emits structured
errors on failure paths.
skills check still lists every skill, but exits non-zero only for
stale installed skills, an incomplete core set, or removed leftovers -
workflow skills not yet installed are reported as available on demand.
Bare 'hyperframes skills' (and 'skills add --all') remain the explicit
full-set installs.
Verified end-to-end with a sandboxed $HOME: fresh init installs the 9
core skills only; 'skills update slideshow' adds exactly that skill
(no-op on re-run, exit 1 on unknown names); bare update refreshes
without expanding; a live Claude Code run routed PR-to-video, executed
the router's update step, and the workflow skill appeared before use;
and a second live run triggered an installed workflow directly, whose
opening maintenance step restored a deliberately removed core skill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(skills): clarify update-engine contracts + document lazy-install model
- skills.ts: note the UpdateSkillsResult.unknown strict-mode contract,
verifyInstalled's non-strict (warn-not-throw) intent, and that a
partial install stays "refreshed but never expanded" (review nits).
- docs/guides/skills.mdx: add a "Keeping skills current" section covering
the core-eager / workflow-on-demand model and the skills check|update
commands, per the repo's catalog-maintenance rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: kiritowoo <295860553+kiritowoo@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Miao Yang <miao.yang@heygen.com>
* feat(media-use): resolve official brand logos via a four-tier cascade
Third-party brand logos (the meeting's 'credibility signals lost' gap)
had no acquisition path: capture only grabs the product's own site
assets, and HeyGen asset search returns generic look-alike icons for
brand queries (0/3 in testing — an X-in-a-circle for LinkedIn). Workers
could only fake a mark or drop it.
New resolve type 'logo', four tiers verified by a 54-brand stress test
(100% cascade hit across dev tools / big tech / non-tech / CN brands):
- svgl — official full-color vector SVGs + wordmark variants (40/54
first-hits); search is substring-based, so entities pass through
alias normalization (nextjs → 'next.js', aws → 'amazon web services')
- simple-icons (pinned CDN build) — official monochrome glyphs; catches
the long tail (nike, visa, toyota, wechat, bytedance)
- github org avatar — known-org map only; a brand name is not a GitHub
login, guessing risks same-named personal accounts
- domain favicon (DuckDuckGo ip3) — small-raster last resort; sub-500B
responses are DDG's placeholder and rejected; frozen with a low_res
provenance flag (chip-size use only)
logo joins the icon/image equivalence group (typesMatch) and the
images/ subdir, so entity cache hits interop with figma-imported marks.
A total miss falls through resolve's normal failure path — no special
casing. HeyGen search stays the icon provider; it is deliberately
absent from the logo cascade.
Docs: media-use gap/types/providers tables + example; the five
workflow banners now cover logos (catalog claim kept for media, 'from
their official sources' added for logos); product-launch story-design
and motion-graphics logo-reveal point at the new type; catalog
surfaces (CLAUDE.md / README / docs) updated in lockstep.
Verified: 19 unit tests + coverage row green; live smoke across all
four tiers (linkedin→svgl, nike→simple-icons, heygen→github.avatar,
amazon→favicon) plus a fabricated brand exiting 1 on the default miss
path. oxlint + oxfmt clean.
* test(media-use): sanction the four logo providers in the registry allowlist
svgl / simple-icons / github.avatar / favicon.ddg join the sanctioned
list — the logo cascade added in the previous commit. Full lib suite
95/95 green.
* test(media-use): gate the logo cascade behavior in CI + single-fetch favicon tier
Review follow-ups (miga-heygen, jrusso1020 on #2061):
- Eight mocked-network tests pin what the manual 54-brand stress test
only asserted: descriptor shape, alias retry (svgl non-array payload
→ next query, simple-icons 404 → next slug), network-error → null
fallthrough, the sub-500B placeholder rejection, github's
no-guessing (zero fetches for unmapped entities), and the real
cascade order landing tier by tier under a mocked network.
- faviconSearch now hands its verified bytes over as a local file, so
the freeze step copies instead of re-downloading — one round-trip,
and the size check is authoritative over what gets frozen.
- The header's hit counts are labeled as a stress-test snapshot, not a
live invariant.
Full lib suite 103/103; live smoke re-verified (amazon → favicon.ddg,
frozen .ico).
- slugify: replace the anchored alternated trim regex (/^-+|-+$/g) with a
character-scan trim — CodeQL js/polynomial-redos blocker.
- readRenderOverrides: fold the readOverrides wrapper into the exported
function (one name, no pass-through).
- getVariables: deduplicate declarers with a Set, matching
injectCompositionCssVariables.
- Move the tokenSlug import to the top of the file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live testing of the compile-time variable emission surfaced four gaps:
- The producer render path never emitted the compile-time stylesheet (only
the preview bundler did), so eval-time reads — GSAP .from immediateRender,
top-level getComputedStyle — saw undefined vars in rendered output. The
producer's inlineSubCompositions now calls the shared
emitRootCompositionVariableStyles and passes the variable hooks.
- --variables overrides weren't visible at eval time. They now thread from
the orchestrator / distributed plan through compileStage into the emitted
rules (window.__hfVariables still covers script reads).
- Per-declarer rules anchored on data-composition-id, which two inlined
instances of one sub-composition share — instance A's rule restyled
instance B, and a rule directly on the declarer defeated the host's
inherited data-variable-values. Rules now anchor on per-instance
data-hf-var-scope markers and layer nearest-host values over declared
defaults, mirroring the runtime loader.
- Emission ignored authored CSS; a declared default now yields to a var
already defined in an authored <style> block (define-if-absent, matching
the runtime injection).
Also: the figma importer emits background-color (longhand) for solid fills.
GSAP backgroundColor tweens cannot read a var() through the background
shorthand — its pending-substitution longhands serialize empty, so .from
captured nothing and settled on transparent (pre-existing GSAP interaction,
reproduced with no composition variables involved).
Validated live: eval-time default + override, .from + override, two-instance
host branding, authored :root precedence, SDS brand-loop pixel parity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brand-loop live test (SDS duplicate, plans/figma/brand-loop-test-plan.md)
proved the recolor chain end-to-end and surfaced three gaps:
- runtime now defines every declared composition variable as a CSS
custom property (document root at init + scoped sub-comp hosts in the
loader), so imported var(--slug, literal) fills resolve live — without
this the frozen literal always won and variable-driven rebranding
could not propagate. Slug kept byte-compatible with the figma
importer (parity test). render --variables overrides win.
- figma component --name: variant frames are often all named
'Platform=Desktop' and slug-collided across imports.
- imported fragments carry data-hf-snippet and the project linter skips
composition-root rules for them.
- /figma skill documents the field-tested non-Enterprise tokens path
(MCP get_variable_defs joined with REST boundVariables ids).
Shared-helper extractions (injectScopedStyles, flattenedRoot module,
parseHostVariableValues, rasterizeFallback, shapeCss) satisfy the
dedup/complexity audit the runtime changes tripped.
Validated live: brand-loop renders purple from the attribute alone (no
manual :root); 118 figma + 662 runtime/compiler + 331 lint tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When codex is a shell alias (e.g. codex → /Applications/Codex.app/...), a
spawned subprocess's PATH lookup can't see it, so media-use reported the bare
"codex CLI not on PATH" — which reads as "binary missing" and sent people
hunting for a bad install. Spell out the actual cause (aliases aren't visible
to child processes) and the one-line fix (symlink the real binary onto PATH).
No behavior change; clearer agent/user guidance only.