- 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.
The arm64 render image had no pinned browser: chrome-for-testing publishes
no linux-arm64 build, so Dockerfile.render fell back to Debian bookworm's
rolling `chromium` package. Its current arm64 build (150.0.7871.46) SIGTRAPs
at startup (exit 133), breaking `render --docker` 100% on Apple Silicon.
Install a pinned, non-Debian chrome-headless-shell from Playwright on arm64
(Google's build, not Debian's repackage). The wrapper wires whichever binary
landed into PRODUCER_HEADLESS_SHELL_PATH and now fails the build loudly if
neither is present, instead of silently using the broken Debian chromium.
Bonus: arm64 gains BeginFrame deterministic capture it previously lacked.
amd64 path is unchanged.
Verified on Apple Silicon: same arm64 image, Debian chromium 150 -> exit 133,
Playwright arm64 headless-shell (Chromium 149) -> exit 0.
* fix(media-use): codex gate misfires as 'not logged in' when piped
codexUnavailableReason() gated generation on parsing `codex login status`
stdout, but that command prints 'Logged in using ChatGPT' to stderr and
exits 0 — so the piped stdout media-use captures (execFileSync returns
stdout only on success) was empty, and the gate falsely reported 'not
logged in'. Every headless / CI / agent run was blocked from codex image
gen even when fully authed.
Gate on the durable credentials file ($CODEX_HOME/auth.json) instead of
the TTY/stderr-only human text. Token validity is still proven by the
exec, which fails cleanly on a stale login. The stdout `features list`
capability check is unchanged.
Verified: reproduced the false 'not logged in' block, then after the fix
generated end-to-end via `resolve -t image --provider codex` (valid
1254x1254 PNG, source=generated, provider=codex.image_gen).
* fix(media-use): bug-bash fixes — id race, provider/reuse/adopt guards
From the bug-bash against main:
- MU-23 (HIGH): concurrent resolves raced on nextId (read-max-then-append,
non-atomic), so parallel agents got duplicate ids and clobbered each
other's files. Add allocateId(): a coarse per-project lock (.media/.lock,
15s stale-steal) around id allocation that scans the manifest AND the
type dir for reserved ids, then O_EXCL-creates a placeholder file so the
slow download between allocate and append can't collide. 5 parallel
resolves now yield 5 distinct ids + files.
- X4: --reuse imported across a type mismatch (bgm asset under images/).
Apply typesMatch on the --reuse path; reject mismatches (icon<->image
still interchangeable).
- X5: --provider silently overrode --local-only and made a network call.
--local-only is now a hard guard: network providers are skipped even
under a forced provider; the miss message explains the conflict.
- BUG-2: --provider ignored the exact-cache floor and could hand back an
asset from a different provider. A forced --provider now bypasses all
reuse rungs (regenerate with THIS provider); the unforced floor is intact.
- MU-26/X6: 0-byte assets accepted. --adopt skips 0-byte files (loud); ingest
refuses a 0-byte local file (freezeUrl already rejects empty responses).
- BUG-4: unknown/unavailable --provider now errors with the available list
instead of a generic 'no provider could resolve' (typo != catalog miss).
- BUG-5: --reuse "" gave the wrong 'type and intent required' error; it now
routes to a clear empty-sha message.
- BUG-3: voice duration leaked an unrounded float into index.md; round all
durations to 0.1s centrally at record build (matches probe).
- Nits: whitespace-only --intent is rejected; nudge grammar (exists/exist).
Tests: allocateId reservation + registry local-only-wins added; full
media-use suite green. All fixes verified e2e.
* fix(cli): reject unknown flags instead of silently ignoring them
citty is permissive: an unrecognized flag was dropped, not rejected — so
`render . --out x` (the flag is --output/-o) silently ignored --out and
rendered to the default renders/<name>.mp4 path. A mistyped flag read as a
render/catalog miss.
Add assertKnownFlags(): validate every dash-prefixed token against the
command's declared args + aliases + the global set (help/version/json)
before the command runs, in the shared trackCommandFailures run-wrapper so
every leaf command is covered. Handles --flag=value, --no-<bool> negation,
camelCase<->kebab arg names, and combined shorts; stops at --; positionals
and flag values pass through.
Verified: `render . --out x` -> 'Error: Unknown flag: --out'; --output/-o/
--json/--help still accepted. Unit tests added.
* docs(skills): install with --full-depth so agents get current main
The documented `npx skills add heygen-com/hyperframes` fetched the
skills.sh registry blob, which lags GitHub main by hours — so users
following the docs got a stale skill (e.g. media-use v1: no --candidates,
voice stubbed). The CLI's own `hyperframes skills` command already forces
a full clone via --full-depth to bypass this; the docs didn't pass it.
Add --full-depth to every documented install command (README, CLAUDE.md,
docs/guides/skills.mdx) with a one-line note on the lag. Addresses the
user-facing half of the publish/registry lag (#2034).
* chore(media-use): collapse resolve.mjs import to satisfy oxfmt --check
* fix(cli): extract longFlagName to keep flag validator under complexity gate
Also regenerate skills-manifest.json (resolve.mjs formatting change re-hashed
the media-use skill). Fixes the Fallow audit + skills-manifest-in-sync CI gates.
* feat(producer,cli): drawElement priority inversion — single-worker streaming over auto-parallel
clamp:parallel eats 50% of local renders (1,326/fortnight; DE engagement
stuck at 3.8%) by routing multi-worker renders to unverified screenshot
capture. Benchmarks (2026-07-08, 4 comps x W1/W2/W3/W5) show that above the
~900-frame amortization crossover, single-worker VERIFIED drawElement
streaming beats screenshot-parallel at EVERY worker count (2,380f: 66s vs
109-127s; 3,600f: 33s vs 39-56s; parallel scaling flattens past W2), while
below it DE's fixed init cost loses by <=2.2s.
- shouldPreferSingleWorkerDrawElement (exported predicate + 7 unit tests):
inverts an AUTO-resolved multi-worker render to workerCount=1 when the
comp matches the benchmarked configuration — default-on DE (darwin
hardware clamp upstream), no compile gate, no forced-screenshot hint,
mp4 output, single-worker streaming eligible, and totalFrames >=
HF_DE_SINGLE_MIN_FRAMES (default 900; 0 disables). Explicit --workers N
is always honored.
- Inverted renders keep the probe session and land on the worker-encode
streaming drain — the ONLY path with runtime self-verification, so this
moves ~40% of previously-clamped renders onto the verified fast path.
Comps that later hit an init-time gate (~1.5% of local renders) render
single-worker screenshot streaming; accepted trade.
- Telemetry: de_worker_inversion on render_complete (orchestrator ->
perfSummary.workerInversion -> CLI), plus the worker_resolution
observability checkpoint now records deWorkerInversion.
Validation: e2e matrix on 2,381f comp — auto->5 workers inverted to 1,
DE verified 4x inf PSNR, RENDER_OK; short comp (360f) auto stays 5-worker;
explicit WORKERS=3 honored; HF_DE_SINGLE_MIN_FRAMES=0 disables. Canary
suite 7/7 (PSNRs identical). renderOrchestrator tests 86/86.
tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(producer): review fixes — inversion routing guards, calibration skip, retry revert
Max code-review round on the inversion (13 confirmed findings):
- Streaming spawn-failure disk fallback now clamps default-on drawElement
(deClampReason=disk_path, DE-mode probe closed) exactly like the
pre-capture clamp — previously it carried useDrawElement=true onto the
unverified disk path, the hole the verified-path confinement exists to
close, newly reachable for every inverted render.
- Predicate gained the routing knowledge it was blind to: layered/HDR and
shader-transition comps (drawElement never runs there), supersampling
(deviceScaleFactor>1 init gate), a probe session whose init gates already
disengaged DE, and the PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true explicit
parallel-DE opt-in (honored like --workers N).
- Eligibility is evaluated BEFORE capture calibration and skips it when the
inversion pins workers to 1 regardless of the estimate — the throwaway
calibration browser + sample captures cost ~41s on the 2,381-frame
benchmark comp (auto render: 111.6s -> 70.1s total).
- Self-verify retry reverts the inversion: the re-render returns to the
pre-inversion parallel screenshot path (disk) instead of single-worker
screenshot streaming, the slowest shape for exactly the comps drawElement
damages.
- HF_DE_SINGLE_MIN_FRAMES="" (set-but-empty) now falls back to the 900
default instead of aliasing the 0 kill switch.
- Timeout advisory uses the RESOLVED worker count — an inverted render that
times out no longer prints "Retry with --workers 1" (the configuration
that just failed).
- Telemetry: deWorkerInversion recorded in capture observability (failed
renders are attributable), emitted as literal false when not fired
(queryable denominator), and the drawElement perf input shape is one
exported DrawElementPerfInput type instead of three copies.
- Tests: requestedWorkers undefined (the value production actually passes)
+ the four new predicate guards; 91/91.
Validation: e2e auto render — calibration skipped (deInversionEligible),
inversion fires, DE verified 4x inf, total 70.1s (was 111.6s);
HF_DE_SINGLE_MIN_FRAMES=0 restores calibration + parallel; canary suite
7/7 (PSNRs identical); tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(producer,cli): review round 2 — loss-cohort telemetry, retry-plan helper, boundary tests
- de_worker_inversion is now a tri-state string ("inverted" | "reverted" |
"none") instead of a boolean: the self-verify retry marks the render
"reverted" rather than resetting to false, so the dashboard can segment
the lost-inversion cohort first-class instead of inferring it from
deSelfVerifyFallback + frame-count joins (james-russo #1).
- The retry rollback is extracted to resolveInversionRetryPlan (pure,
exported) with unit coverage: pre-inversion worker-count restore,
streaming re-resolution (multi-worker retry -> disk), "reverted" state,
null when never inverted (james-russo #2).
- WOULD_RESOLVE_MULTI_WORKER named constant replaces the bare sentinel 2
(james-russo #5); minFrames: -1 boundary case added (miga #3).
94/94 renderOrchestrator tests; tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(producer,cli): emit de_pre_inversion_workers for the parallel counterfactual
The ramp-down decision needs "did DE beat the parallel render it displaced",
not just "did DE beat single-worker screenshot". Emitting the worker count
the auto-resolution chose BEFORE the inversion pinned it to 1 makes the
parallel counterfactual computable per render (screenshot ms/frame from the
verify samples / W x the measured parallel-efficiency curve). Set only when
the inversion fired.
Smoke: 2,381f auto render -> de_worker_inversion="inverted",
de_pre_inversion_workers=5, mode=drawelement, verify armed 4. 99/99 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): upgrade + update-notice use the detected install method
hyperframes upgrade hardcoded 'npm install -g', so bun/pnpm/brew users either
saw it fail or silently got a shadowed npm copy while their real (older) binary
kept running. Route the install through detectInstaller() via a new
installInvocation() argv helper; for skip kinds (ephemeral npx/bunx,
project-local, unknown) print 'npx hyperframes@latest' instead of guessing.
The passive update notice now shows the detected manager's command too. Semver
safety guard consolidated into a shared isSafeVersion(). Suppression gates and
the background auto-update flow are unchanged.
* test(cli): pin the shell:false contract of the --yes install path
Export runDetectedInstall and add a mocked-execFileSync test asserting the
detected manager binary is spawned with the exact installInvocation argv,
{stdio:inherit, shell:false}, and that an install failure sets a non-zero exit
code without throwing. Addresses review nit on the untested --yes path.
* fix(cli): guard the registry version at the boundary; execFile the auto-installer
Security (addresses review): a poisoned registry data.version (e.g.
'1.2.3; rm -rf /') was cached unvalidated and flowed into the background
auto-updater, which ran it via exec() -- a shell -- so a registry compromise
meant RCE on the next CLI run. isSafeVersion only covered the two touched
consumers (upgrade, notice), not this third sibling (scheduleBackgroundInstall).
- Guard at the registry boundary in checkForUpdate: only a strict-semver STRING
is trusted; a non-string or metachar-bearing data.version is never cached and
falls back to the last known-good version. The cache-read and fallback paths
re-validate too, so a pre-existing poisoned cache can't leak through. One gate
closes all three consumers and any future one; per-consumer checks stay as
defense in depth.
- The detached auto-installer now runs via execFile(bin, args, shell:false),
reusing installInvocation, matching the interactive runDetectedInstall path --
the shell is gone from that path entirely.
Tests: reject poisoned / non-string registry version (never cached); accept a
valid semver.
* fix(engine): resolve relative data-start references in video-frame extraction
<video data-start="intro"> (a relative reference to another clip's end) is
resolved by the browser runtime but parseVideoElements/parseImageElements did a
raw parseFloat, yielding NaN start/end. The FrameLookupTable active-window
checks (start <= t <= end) are then always false, so the clip is never injected
and composites BLANK in the final render — while lint/validate/inspect/snapshot
and the live preview all look fine. The docs' Relative Timing section teaches
exactly this pattern on <video>.
Share the pure reference-syntax parser (parseStartExpression) out of the runtime
resolver into @hyperframes/core, and resolve references in the extractor against
the linkedom document it already holds: a reference resolves to the target
clip's resolved start + its duration (data-duration or data-end) + offset,
mirroring the runtime. Cycle-guarded; an unknown target or unknown duration
falls back to the target's start / 0 (never NaN), matching runtime semantics.
Natural-media-duration-only targets aren't known at parse time (same limit as
the runtime's fallback). parseImageElements gets the same fix.
Runtime resolver behavior is unchanged (its 25-case suite still passes).
* chore: re-trigger CI to refresh a stuck CodeQL aggregate check
The ElevenLabs provider spawns a Python helper that writes straight to wavAbs
via a bare open(), which (unlike heygen/kokoro) never creates the parent dir —
so on a fresh project the save throws ENOENT and the line is silently dropped
as 'TTS failed - omitted'. mkdir -p the dir first, guarded so a mkdir failure
(EACCES/EROFS) returns { ok:false } like the rest of the branch rather than
throwing. (Migrated from #1960, whose skills/hyperframes-media path was retired
into skills/media-use; the bug moved with it.)
* fix(producer): fall back to copying extracted frames when symlink hits EPERM
materializeExtractedFramesForCompiledDir stages each video's extracted
frames into the compiled dir via a single symlink (the in-process
renderer's default; distributed plan() already copies via
materializeSymlinks). Windows without Developer Mode (or Administrator)
cannot create symlinks and rejects with EPERM, so high/standard-quality
renders failed there — while draft quality worked because it avoids the
symlinked-cache path entirely.
Fix: a new stageExtractedFrameDir helper catches EPERM/EACCES from
symlinkSync and falls back to the same recursive cpSync the
materializeSymlinks path already uses. The extra disk is far better than
a hard render failure on a default Windows configuration. Non-permission
errors (ENOSPC, etc.) still propagate so real failures aren't masked as
silent copies. Extracting the helper also keeps the main function under
the complexity gate.
Test: two new cases via the injected fileSystem — symlinkSync throwing
EPERM triggers exactly one recursive cpSync (frames still remapped under
compiledDir), and a non-permission error (ENOSPC) rethrows without
falling back to copy. Full renderOrchestrator suite (81) passes.
* fix(producer): recover from a stale dangling frame-symlink (EEXIST)
Follow-up to this PR's EPERM copy fallback, from a further Windows report: the
symlink fails with EEXIST after the extraction cache is GC'd. A prior render's
symlink at the compiled linkPath dangles once its target is removed; the
caller's existsSync() guard follows the dead link and reads it as absent, so
staging runs again, but the link file still exists and symlinkSync collides
with EEXIST -> the render hard-fails.
Catch EEXIST in stageExtractedFrameDir, clear the stale entry (rmSync), and
re-stage (link, or copy on EPERM/EACCES). Factored the link-or-copy into a
helper reused by both the first attempt and the retry. rmSync is an optional
injected fs method (default fs supplies it; only the EEXIST path calls it).
New unit test covers the dangling-symlink recovery.
* fix(producer): widen symlink fallback to UNKNOWN and cover EEXIST on copy path
Addresses review nits on the frame-staging fallback:
- Widen the symlink no-privilege catch from EPERM/EACCES to also include
UNKNOWN (some Windows builds surface a symlink privilege denial as UNKNOWN).
- Wrap the EEXIST stale-entry recovery around BOTH staging branches, not just
the symlink one: after #2025 Windows uses the eager cpSync path, which can
collide with a dangling symlink left by a prior Linux run — now it clears the
stale entry and re-stages either way.
- Emit a one-time INFO log when symlinking degrades to copying, so a heavier
Windows render is self-explanatory.
The local render path (renderOrchestrator → runExtractVideosStage) materialized
each video's extracted frames into the compiled dir via symlinkSync, with no
materializeSymlinks flag. On Windows without Developer Mode/Administrator,
symlinkSync throws EPERM, so local video renders failed at the video_extract
stage (users worked around it with materializeSymlinks patches / snapshot-frame
hacks). The distributed plan() path already copies (materializeSymlinks: true).
Pass materializeSymlinks: shouldCopyExtractedFrames(process.platform) at the
local caller — copy on win32 (symlinks unavailable), symlink elsewhere (cheap).
New pure shouldCopyExtractedFrames() helper + unit tests.
* fix(cli): default render fps to the composition's data-fps
hyperframes render hard-coded fps to 30 when --fps was omitted, ignoring a
data-fps declared on the composition root — so a composition authored at
data-fps="24" silently rendered at 30fps unless the user knew to pass --fps 24.
The runtime already honors data-fps; the CLI now matches it.
Precedence: explicit --fps > composition root data-fps > 30. New pure
readCompositionFps() extracts the root data-fps via linkedom (mirrors the
runtime's root resolution: [data-composition-id][data-root=true], else the
outermost [data-composition-id]); render validates it through parseFps and
falls back to 30 on an absent/invalid value. Unit-tested.
* fix(cli): honor composition data-fps on cloud renders and --composition targets
The local render command read data-fps from project.dir/index.html even when
--composition rendered a different file, and the lambda/cloudrun render paths
ignored data-fps entirely (hardcoded ?? 30). Both are the same silently-wrong-
fps bug on other render entry points:
- render.ts resolves the entry file first, then reads data-fps from the file
actually being rendered (falling back to index.html).
- lambda render/render-batch and cloudrun render/render-batch default fps from
the composition's data-fps, accepted only when it is one of the cloud-allowed
values {24,30,60}, else the existing 30 default. Explicit --fps still wins.
* fix(cli): drop citty fps default so data-fps resolution actually runs
The fps arg had default: "30", so citty set args.fps="30" on omission and
resolveDefaultFpsArg short-circuited (explicitFps never null) — reverting the
command to always-30 and making the whole data-fps feature a no-op (caught in
review). Remove the arg default; the "30" fallback already lives at
parseFps(fpsArg ?? "30"). Adds a regression guard asserting the arg has no
default.
* test(cli): read citty args through a plain record in the fps-default guard
The regression guard accessed cmd.args.fps directly, but citty types args as
Resolvable<ArgsDef> so .fps failed typecheck in CI. Read it through a plain
record cast.
A partially-downloaded or interrupted chrome-headless-shell archive left in the
cache makes @puppeteer/browsers' install() throw "invalid
end-of-central-directory" during extraction. That error propagated out of the
browser check and hard-blocked the render, forcing users onto the fallback
renderer until they manually cleared the cache — a recurring Windows failure.
Detect the corrupt-archive extraction error (isCorruptArchiveError), clear the
cache to drop the bad archive, and retry the download exactly once; non-corrupt
errors and a second corruption still propagate (no infinite retry). The pure
predicate and the recovery wrapper are unit-tested.
`hyperframes lint --json` wrote the JSON payload with console.log() and
then immediately called process.exit(). process.exit() terminates the
process before Node flushes an asynchronously-buffered stdout, which is
what a non-TTY (piped) stdout is — so `hyperframes lint --json | tee`,
`> out.json`, or any agent/CI capture silently lost the entire payload
on Windows (reported on 0.7.31 non-TTY). The same console.log-then-exit
pattern was on all four exit sites (both --json branches and the
human-readable + thrown-error paths), so any of them could truncate.
Fix: set process.exitCode and return, letting run() unwind so Node
drains stdout before exiting with the code. This is exactly the pattern
the other commands (publish/transcribe/upgrade/play/present) already
use; lint was the outlier still calling process.exit() after writing.
Test: new lint.test.ts drives the command's run() with mocked
lintProject/resolveProject and a process.exit spy that throws if
called. Covers the --json-with-errors, --json-clean, --json-thrown, and
human-readable paths — each asserts process.exit is never called and
the correct exitCode is set. Fails against the pre-fix code (the spy
throws on the first process.exit).
Reuse now hands the semantic judgment to the coding agent instead of a
string heuristic, while keeping the deterministic normalize-exact match as
an automatic dedup floor. No LLM/embedding call enters resolve; it stays
offline-capable.
- lib/match.mjs: shared matchTokens + typesMatch (extracted from adopt.mjs
and resolve.mjs so the icon<->image equivalence and token rules can't
drift between the do-path and the look-path); adds tokenOverlap ranker.
- resolve --candidates: side-effect-free listing of reusable assets across
the project manifest AND the global ~/.media cache, ranked by lexical
overlap, capped per scope, --json or human table. Never hard-filters on
zero overlap (that would pre-empt the agent's judgment); the agent decides.
- resolve --reuse <sha>: import a specific global-cache asset by content
sha/prefix (from --candidates) into the project via importFromCache,
marked source=reused-explicit / provenance.reused_by=agent.
- Adherence nudge: on a resolve that misses the exact floor and is about to
fetch, print a one-line stderr hint when similar cached assets exist,
pointing at --candidates. Offline, stderr (safe under --json), never
auto-reuses a fuzzy match.
- cache.mjs: export readGlobalManifest; add findGlobalBySha (prefix resolve
with ambiguity/miss handling).
- Telemetry: media_use_candidates event + reused-explicit source on
media_use_resolve (type/scope/counts only, no intent text or paths).
- SKILL.md: 'Reuse before you resolve' guidance + trust guardrail
(prefer-fresh-when-unsure, entity-exact for brand, cross-project bleed).
- Tests: lib/candidates.test.mjs (ranking, no-hard-filter, cap/truncation,
icon<->image, sha resolution, formatter); adopt.mjs refactor covered by
existing lib/adopt.test.mjs.
Full media-use suite green; verified e2e against the live catalog
(cross-project resolve->candidates->reuse; hint fires on miss).
* fix(engine): commit render-frame siblings with a visual BeginFrame at init
Chunk-lambda renders drop a periodic near-black frame — one every
chunk_frames/worker_count frames (every 60 on a 4-worker single-video chunk),
YAVG ~22 against YMAX ~240 in signalstats. Local single-process renders don't
show it because they don't run under BeginFrame.
It's the isNewImage branch in injectVideoFramesBatch: the first time a session
paints a given videoId there's no __render_frame__ sibling yet, so it creates
the <img> on the spot (createElement + insertBefore) right before capture.
Under HeadlessExperimental.BeginFrame the compositor doesn't have that fresh
layer in the immediately-next frame, so the first captured frame per session
paints only body background + already-composited overlays. Each lambda worker
is its own session, hence the worker-boundary periodicity.
Pre-create the hidden sibling at the end of initializeSession, then drive one
non-capture visual BeginFrame (noDisplayUpdates: false) to composite the new
layers before the first real capture. The warmup ticks are noDisplayUpdates:
true (they advance the clock but don't paint) and the per-frame seek doesn't
tick, so this explicit visual frame is what actually commits the layers; its
tick sits in the gap between warmup and frame 0 so ticks stay monotonic and no
render frame is consumed. Every subsequent inject then takes the hasImg=true
(src-update) path; the isNewImage branch stays as a fallback for callers that
don't go through initializeSession.
* fix(engine): place the render-frame commit tick before the liveness probe
The commit tick at init sends its BeginFrame at `beginFrameTimeTicks - 1·interval`.
The producer's liveness probe then fires right after init at
`beginFrameTimeTicks - 5·interval` — an earlier tick. Per-session BeginFrame time
has to be monotonic, so the probe running backwards past the commit tick stalls
chrome-headless-shell indefinitely; the engine reads that timeout as a SwiftShader
heavy-layer stall and routes the render to screenshot capture, which then dies
relaunching and hangs the shard to the job timeout.
Reproduced on a native x86 SwiftShader host and bisected: with the commit tick
present the probe times out even with zero render-frame siblings created, so it's
the tick ordering, not layer count. Moving the commit tick to `-6·interval` (below
the probe, above the warmup ticks) keeps warmup < commit < probe < capture
monotonic and clears the stall on every affected comp — sub-composition-video,
chat, style-5-prod — while a healthy comp (style-18-prod) is unchanged. The commit
tick itself is untouched, so the black-frame fix it exists for still holds.
Two defects in the resolve cascade that made cache and asset-reuse
misbehave in practice:
- Prompt matching was byte-exact and case-sensitive. findByPrompt and
cacheGet compared provenance.prompt with ===, so "Calm piano" and
"calm piano" re-searched and re-downloaded instead of reusing the
cached asset (same project and cross-project). Add normalizePrompt
(trim + lowercase + collapse whitespace) and key both lookups on it;
the raw prompt is still stored for audit.
- findExistingAsset matched with name.includes(intent) ||
intent.includes(name), which silently returned the WRONG local file:
intent "whoosh" grabbed a stray who.mp3, and a one-letter filename
matched every intent. Require a shared word token (>= 3 chars, minus
stopwords) so a false negative just falls through to a catalog search
rather than shipping the wrong asset.
Adds lib/adopt.test.mjs and extends manifest.test.mjs. Full media-use
suite green; verified e2e against the live catalog (case-variant
cross-project resolve now reuses; whoosh no longer grabs who.mp3).
Live testing against a real community file (Ratings) found a nested
instance node figma refuses to render as svg — which aborted the entire
component import. The rasterize loop now retries the node as png, and
only if both formats fail warns and skips THAT node (placeholder keeps
its data-figma-rasterize marker, no src) instead of failing the import.
On the file that surfaced this, the png retry recovers the node — 31/31
placeholders get assets.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* refactor(engine,producer): adopt requestPaint contract, retire autoAlpha rewrite
crbug 529829538 was closed "working as intended": the html-in-canvas API's
contract is mutate -> canvas.requestPaint() -> await the canvas paint event ->
drawElementImage, which refreshes the subtree's paint records including
compositor-applied properties. Verified on the pinned 151 floor and 152
canary: root opacity, root filter, nested group opacity, and child transforms
(incl. will-change-promoted) all capture exactly; the root element's own
TRANSFORM is the one property still never baked.
- Paint invalidation: all three paint-wait sites (serial capture, worker
produce, batch produce) now call canvas.requestPaint() when available and
fall back to the __hf_de_tick sentinel background toggle on builds without
it. The 250ms unsynchronized-draw safety net is unchanged.
- Root-opacity ratio correction REMOVED (all three draw sites + base-opacity
recording at injection). Since 151 the paint wait bakes current root opacity
into the snapshot as pixel alpha, so the ratio correction DOUBLE-APPLIED
animated root fades: a root-fade A/B tripped the runtime self-verify at
30.1dB (frame 24, ~0.92 expected vs ~0.85 rendered). Post-removal the same
comp self-verifies at inf and matches the screenshot render at PSNR=inf.
The root TRANSFORM correction stays — verified still required.
- autoAlpha rewrite machinery DELETED: the opt-in opacity->autoAlpha tween
rewrite (default-off since the retraction fix; measured ~28dB damage on
comps whose fades it touched), its flush-time transparent-target hiding,
the __HF_FAST_CAPTURE_AUTOALPHA__ flag plumbing, and the deferral-time
retract/re-assert dance. The stub keeps tween-target tracking (3D
projection + at-risk scans depend on it).
Validation: canary suite 7/7 with PSNRs identical to baseline (58.30 /
43.13 / 54.15 dB); root-fade A/B PSNR=inf vs screenshot; engine suite 905
passed (1 pre-existing color-grading failure); tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(engine,producer): review fixes — gate opacity correction by paint mechanism
Max code-review findings on the requestPaint adoption:
- Root-opacity ratio correction RESTORED, gated per frame on how the paint
was produced: it applies on BeginFrame (sync=false) captures and on builds
without canvas.requestPaint() — the two paths where the snapshot holds the
root's load-time opacity — and is skipped only on requestPaint-driven
paints, where the snapshot bakes the current opacity and the ratio
double-applies (the proven 30.1dB root-fade failure). Base opacity is
recorded at injection again.
- Invalidation extracted to a page-scope helper (__hfDeInvalidate, installed
by injectDrawElementCanvas) shared by all three paint-wait sites: sentinel
toggle ALWAYS (a paint is guaranteed even if requestPaint elides one on a
clean subtree) + requestPaint() in a try/catch (a throwing implementation
degrades to sentinel-only instead of rejecting the capture). Returns
whether requestPaint ran, feeding the opacity-correction gate. Also removes
the triplicated inline block and its three anonymous `as T` casts.
- HF_FAST_CAPTURE_AUTOALPHA now logs a retirement warning instead of being a
silent no-op (the deleted rewrite's comment documented it as an operator
escape hatch).
- Batch producer docstring updated (still described the tick-toggle-only
paint wait); stub tween observer reshaped to a void fn (observeTweenCall)
so no arg-rewriting seam survives.
Validation: canary suite 7/7 (58.30/43.13/54.15dB, d95f20b6 clean);
root-fade A/B self-verify 4x inf + whole-video PSNR=inf; engine suite 905
passed (1 pre-existing); tsc/oxlint/oxfmt clean; stub regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: WaterrrForever <miao.yang@heygen.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The dashed indicators for elements outside the canvas stayed pinned at an element's
old position after a move or a seek: they were recomputed by an effect keyed on
compRect/activeCompositionPath, neither of which changes on an in-place soft-reload
edit or a playhead seek.
They now recompute via a MutationObserver on the preview document (coalesced to one
recompute per frame), so they follow the element live. Crop-hugging is preserved.
Adds a regression test that mutates an element in place and asserts the indicator moves.
Timeline UI
- Highlight clips visible at the playhead in the primary color; others share one neutral color
- Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels
- Per-track eye toggle and a per-element hide button in the design panel
- Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom
- Sticky gutter so track controls stay visible while scrolling
WYSIWYG visibility (data-hidden)
- Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview
- HTML stays the source of truth; hide state persists and round-trips on reload
Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
## What
Brief description of the change.
## Why
Why is this change needed?
## How
How was this implemented? Any notable design decisions?
## Test plan
How was this tested?
- [ ] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (if applicable)
* fix(skills): audit descriptions — trim routing prose, fix stale facts, add missing triggers
Descriptions are the always-loaded routing tier; this audit rebuilds them on
one principle: discriminate by input shape, not pipeline internals.
- Trim creation-workflow descriptions to positive trigger + nearest-neighbor
disambiguation + /hyperframes escape hatch; full routing prose already
lives in each skill body's route-confirm block and the router
- Codify the workflow-vs-domain split as ownership (owns the end-to-end
deliverable vs capability layer pulled in mid-flight) in /hyperframes,
and widen "make me a video" framing to deck / composition port
- Fix stale facts: embedded-captions identity count (desc 32, body 17 →
actual 36 = 10 classic + 26 themed), six→ten visual languages, retired
RVM/Standard wording in router details, figma shader transport
(MCP → MCP source / native export), keyframes "cursor demos" (no backing
content), hyperframes-media scripts/audio.mjs leak
- Register missing capabilities: motion-graphics maps category (was in
categories/ but absent from its own table, description, and router),
asset-fusion + news triggers, slideshow page-to-deck + presenter mode,
general-video editing, talking-head-recut 16:9/9:16/4:5 canvas,
cli feedback + lambda sites, product demos, mood-brief BGM generation
- website-to-video: relabel promo-shaped video types to keep the promo
boundary with /product-launch-video; drop headless-Chrome wording
- music-to-video: lyric timing via /hyperframes-media transcription or
user-supplied lyrics, placed on the beat grid
- Sync catalogs in lockstep (CLAUDE.md, AGENTS.md, README,
docs/guides/skills.mdx, CLI project templates): add music-to-video +
slideshow entries, complete the domain-skill lists, and extend the
catalog-maintenance rule to cover AGENTS.md and the templates
Validated with a 35-case description-only routing eval: 35/35 both before
and after the rewrite (including new maps / asset-fusion / news probes).
* fix(skills): post-media-v2 consistency — stale media ref, router figma wording, catalog rows
- music-to-video: lyric transcription now routes to /media-use (the
retired /hyperframes-media was still referenced)
- router capability map: figma row gains the shaders fact (MCP source /
native export), matching the SKILL.md source of truth
- media-use catalog rows (CLAUDE.md, README, docs/guides/skills.mdx):
add image models + captioning, aligning with the v2 description
- catalog rule #1: root AGENTS.md carries the workflow list only (it
has no domain-skill section) — rule wording now says so
* feat(cli): associate signed-in HeyGen account with telemetry
Sign-in telemetry currently attributes everything to the anonymous
install id, so the sign-in funnel can be counted but a completed sign-in
can't be tied to the account it produced. This associates the two.
- On a completed sign-in, emit a PostHog `$identify` alias whose
`$anon_distinct_id` is the install's anonymousId, so events recorded
before sign-in stitch to the same person, and tag `auth_login_completed`
with the account identity (the pre-plumbed `distinctId`).
- `/v3/users/me` exposes no opaque user_id, so the identity key is the
account email, falling back to username (single `identityKey` helper).
- Both no-op under the `telemetry disable` opt-out and only fire after
the user chooses to sign in.
Privacy disclosure updated in lockstep, since this is the first PII the
CLI attaches: the first-run telemetry notice and the telemetry section
of docs/packages/cli.mdx now state that signing in links your account
email to your usage.
Tests: identifyUser payload + no-op, completion attribution incl.
username fallback and no-identity-on-reject/empty. Verified end-to-end
against the built CLI: pre-auth events anonymous, $identify carries
$anon_distinct_id, completion carries the account email.
* docs(cli): disclose the username identity fallback
Review gating item: identityKey is `email ?? username`, but the
first-run notice and cli.mdx said only "email", so an emailless
account's username would reach PostHog undisclosed. `/v3/users/me`
treats email as optional (pickString), so the fallback is live code,
not dead — disclose it rather than assert an unverifiable email
guarantee. Both surfaces now say "email, or username if the account
has no email".
Also soften the identityKey comment: it implied username is "less
identifying", but HeyGen usernames are often email-shaped, so the note
now states username is a fallback, not a privacy win.
* feat(engine,producer,cli): verify video comps via deferred DE init + capture p50
Closes the two biggest gaps in the first day of v0.7.38 wild data: 88% of
drawElement renders (video comps initialized via probe sessions) ran with
self-verification unarmed, and speedup was measurable on only 3 of 76 renders.
- Deferred drawElement init: probe sessions initialize before video
extraction, so they have no frame injector — ground-truth screenshots
would capture black <video> boxes, and verification skipped the whole
comp. DE init now stops after the gates for injector-less video comps
(deInitDeferred; autoAlpha flag retracted in case no path completes it)
and completeDeferredDrawElementInit finishes verification + canvas
injection + worker-encode at capture time, once
prepareCaptureSessionForReuse has attached the injector. Validated
end-to-end: a probe-path video comp now arms 4 ground-truth frames with
real video pixels (3x inf + 64.7dB) and renders drawElement verified.
- capture_p50_ms: per-frame capture durations are sampled
(capturePerf.frameMs; batch frames get the batch mean) and the median
ships as CapturePerfSummary.p50TotalMs -> RenderPerfSummary.captureP50Ms
-> render_complete capture_p50_ms. Unlike capture_avg_ms it is immune
to first-frame warmup and stage-setup amortization — smoke: avg 15ms vs
p50 8ms on the same render, p50 matching the measured steady-state
floor. Dashboard speedup tiles can drop their frame-count floor once
this ships.
- video_count on render_complete: segments speedup by video-injection
comps (whose per-frame gain is legitimately lower) vs pure-graphics.
Canary suite 7/7; engine suite 905 passed (1 pre-existing upstream
failure); tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(producer): complete deferred drawElement init on the disk capture path
Review (miga): a probe-initialized video comp falling back to the disk path
kept deInitDeferred and silently stayed in screenshot mode — a regression
for PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true renders that previously ran
drawElement there. Complete the deferred init on the sequential disk path
under the same explicit-opt-in test the orchestrator clamp uses; default-on
renders stay on the screenshot baseline (this path has no drain-time
self-verification, per the #1998 confinement rule).
Validated: video comp + PRODUCER_ENABLE_STREAMING_ENCODE=false + explicit
opt-in logs "(deferred drawElement init)" completion on capture_disk and
renders correct video pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): align easing docs with motion doctrine, add baked springEase
The easing adapter contradicted the workflow doctrine: it taught a
power2.out entrance default and pitched back/elastic as playful
defaults, while motion-language.md says power3 / smooth-beats-bouncy.
A worker following the adapter produced exactly the flat, cheap motion
users complain about.
- gsap-easing-and-stagger: power3.out becomes the documented house
default; back/elastic/bounce capped as RARE playful-only; new
"Spring Eases (baked physics, seek-safe)" section — closed-form
damped-spring ease springEase(response, dampingFraction), measured
damping ladder, response/duration table, craft notes
- gsap-timeline-and-labels: last leftover power2.out default -> power3
- spring-pop-entrance: exact-physics option (zeta=1) for the settle;
playful variant now prefers spring zeta 0.6-0.7 over back.out
- motion-language x3 (product-launch-video, faceless-explainer,
pr-to-video): doctrine "Smooth beats bouncy" wired to the baked
springEase — real physics, same doctrine, not a license for bounce
Verified with node + GSAP 3.15: zeta=1 strictly monotone with zero
overshoot; scrambled out-of-order seeks return bit-identical values;
ease(0)=0 and ease(1)=1 exact; snippet re-extracted from the published
markdown and re-run.
* chore: format README.md (landed unformatted on main; unblocks the repo-wide Format check)
The media-use v2 row is the widest cell in the domain-skills table; oxfmt
re-pads every other row's trailing pipe to match. The merge added the row
without re-aligning, so oxfmt --check flagged README on CI (linux). Content
is unchanged; only trailing whitespace in 10 table rows.
* feat(cli): forward feedback submissions to backend endpoint
* fix(cli): truncate feedback fields to backend caps + ack before forwarding
Addresses PR review (via):
- Truncate comment (2000) / cli_version (100) / env (500) to the backend DTO
caps before POSTing, so a pasted stack trace is forwarded truncated instead
of rejected with a 422 the best-effort path swallows silently.
- Print "Thanks for the feedback!" before the best-effort forward so the ack
isn't blocked behind the (bounded) network call.
* fix(cli): type feedback fetch mock
Default-on drawElement ships with a runtime self-verification net (#1998);
this makes its in-the-wild behavior observable. Every render_complete event
now answers: which capture mode actually ran, why drawElement disengaged
when it did (compile gate / producer clamp / engine init gate), whether the
self-verify net fired and why, and how much margin verification had.
Follows the static-dedup telemetry pattern: engine session fields →
CapturePerfSummary → RenderPerfSummary.drawElement → snake_case props on
render_complete.
New event props: de_capture_mode, de_compile_gate, de_clamp_reason,
de_gate_reason, de_worker_encode, de_verify_armed, de_verify_checked,
de_verify_min_db (margin above the 32dB threshold — drift here is the
early-warning signal before fallbacks spike), de_verify_init_ms,
de_self_verify_fallback, de_fallback_reason, de_blank_suspects,
de_blank_deterministic_accepts, de_blank_recaptures, de_boundary_frames,
de_ncpr_fallbacks.
Validated end-to-end on live renders: drawelement path reports mode/verify
counters/minDb/init cost; a blur-gated comp reports mode=screenshot +
gate_reason=css_effect:filter; a forced verification failure reports
self_verify_fallback=true + fallback_reason=psnr.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
validate served the composition over a loopback origin and let headless
Chrome fetch remote <img crossorigin>/@font-face assets cross-origin, while
the render pipeline downloads them to disk first. Buckets whose CORS
allowlist omits the loopback origin then failed the CORS-mode request with a
false net::ERR_FAILED that never occurs in the real render, pushing authors
(and agent pipelines) to delete crossorigin — which disables WebGL
color-grading/shaders for that asset.
Reuse producer's localizeRemote{Media,Image,FontFace}Sources in validate,
downloading into a temp dir served as an extra static-server asset root
(project dir untouched, cleaned up after). validate now matches render.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>