* feat(cli): add hyperframes clean to find and remove what HyperFrames left on disk
`hyperframes clean` lists leftover render work dirs, debug renders, the history of projects
that no longer exist, idle video proxies and extracted video frames, with sizes, and removes
them. `--dry-run` removes nothing, `--json` is for agents, `--snapshots` also removes
snapshot folders (otherwise listed as reclaimable). It never touches outputs, sources or
anything a running render or preview still owns.
* docs(cli): say that clean does not search a project's subfolders
* fix(cli): clean test compares folder names on Windows and the skills manifest is regenerated
* fix(studio-server): the proxy cache sweep removes only files the transcoder named
It removed any idle .mp4 or .webm in .transcode-cache, so a user file placed there, or reached through
a symlinked cache folder, could go. Proxies are always <sha256>.<ext> and their temp files
.tmp-<uuid>-<proxy name>; nothing else is swept.
* style(producer): keep a renderDirOwner doc comment within 120 columns
* fix(media-use): record agent-made voice, music and sound effects in the project's media manifest
* fix(media-use): record in place only when --source says the agent made the file
* test(media-use): a rewritten agent file keeps one manifest record
* fix(media-use): leave a person's own sound effect unlabelled and mark locally made music generated
* refactor(media-use): record agent audio in-process through the shared manifest library
* style(media-use): format the in-process recording and refresh the skills manifest
* chore(skills): refresh the skills manifest for the media-use changes
* fix(media-use): report a broken ffprobe setting plainly and document the project sfx source
* style(skills): format the pipeline manifest assertion
* chore(skills): refresh the skills manifest
* fix(media-use): a regenerated file gets a fresh record, and lookups skip the replaced one
* test(media-use): a replaced record no longer answers a prompt lookup in the CLI
* chore(skills): refresh the skills manifest
* chore(skills): refresh the skills manifest from a clean tree
* fix(media-use): never give a recorded file the id of a download in flight
* chore(skills): refresh the media-use manifest hash
* fix(media-use): list only a file's newest record in candidates and the media index
* revert(media-use): leave promote as it was, nothing calls it
* test(media-use): give the index test's two assets their own files
* chore(skills): refresh the media-use manifest hash
* fix(core): a figma import lists each media file once, like media-use
* docs(skills): studio agents answer questions before editing and plan new films like the launch films
* docs(skills): format the new studio sections and regenerate the manifest
* docs(skills): realign the storyboard table and regenerate the manifest
* docs(skills): defer studio film planning to the owning guides and read polite requests as changes
* docs(skills): format the studio sections and regenerate the manifest
* docs(skills): route felt notes and new films in a project through the studio skill
* docs(skills): keep the interview's opening line and regenerate the manifest
* docs(skills): fit the new entry row to the table
* docs(skills): realign the entry table and regenerate the manifest
* docs(skills): keep only follow-on films in an existing project
* docs(skills): realign the studio tables and regenerate the manifest
* feat(cli): tag telemetry and feedback with the app that launched the CLI
* fix(cli): keep the client tag to a short slug and correct the telemetry docs
* test(cli): read the delivered batch through the shared helper
* refactor(cli): let the tag pattern alone bound the client tag's length
* docs(cli): describe the client tag generically as the launching app's tag
* style(cli): format the client tag test cases
* test(cli): use a neutral example value for the client tag
* chore(skills): regenerate the skills manifest for the telemetry reference
* feat(cli): download Parakeet with models install parakeet and transcribe with it
`hyperframes models install parakeet` installs the sherpa-onnx runtime from
npm and the Parakeet TDT 0.6B v3 int8 model from Hugging Face, pinned to a
revision. Every file is checked against its pinned size and sha256 under a
temp name before it is renamed into place. Re-running it is a no-op. On
Linux with glibc older than 2.32 it refuses up front.
`transcribe --engine auto|parakeet` now prefers that engine when it is
installed, then an installed parakeet-mlx runner, then whisper. Audio is cut
into about 60 s windows at the quietest 100 ms near each cut, and decoding
runs in a child process. If the child cannot load the runtime or crashes,
transcribe falls back with one stderr line saying why. The JSON output and
transcript.json shape are unchanged.
* fix(skills): report the real word count and engine from transcribe fallbacks
The media-use transcribe script read `words` from the CLI's transcript,
which is a flat array, so its whisper path always reported an undefined
word count. The embedded-captions script labelled every CLI transcript
whisper.cpp even when `--engine auto` ran Parakeet; it now uses the engine
the CLI reports.
* fix(cli): pin the sherpa-onnx native package to the runtime's exact version
* test(cli): type the sherpa install test doubles
* fix(cli): transcribe audio with no speech as 0 words instead of failing
Parakeet writes an empty word list for a music bed, a silent intro or a
very short clip, and loading it failed with "Unrecognized JSON transcript
format". An empty list is now read as a transcript with no words, as
whisper's empty output already is. A non-empty list still has to look like
words.
* fix(cli): re-decode a Parakeet window that skipped the start of its speech
At some window lengths the model silently drops the first clause: on a
looped speech clip, 4 of 167 lengths between 55 and 60 s, and a 61.91 s
window, lost their first 9 words. A window with a token gap of at least 1 s
that is as loud as speech is decoded again with 0.5 s of leading silence,
and the result with more tokens wins. Clean windows are unchanged.
Decoding with 1.5 s of overlap before each cut was measured too and was
worse on 573 s of audio (2.62% WER against 0.98%), so it is not used.
Empty audio no longer makes a zero-length window, which aborted the
decoder.
* fix(cli): stop Parakeet on Ctrl-C and name the repair when its install breaks
- Ctrl-C during a Parakeet transcribe now stops with exit 130 instead of
starting whisper, and a SIGTERM to the CLI also stops its decode child.
- Ctrl-C during models install parakeet aborts the download and the npm
install, keeps no partial file, and exits 130. A staging dir left by a
killed install is swept by the next run.
- When Parakeet fails under --engine auto, the warning leads with the
Parakeet error and names hyperframes models install parakeet. If the
fallback fails too, the Parakeet error is reported rather than
whisper_unavailable, so --optional no longer hides a broken install.
- An explicit --engine parakeet no longer falls back: it fails with the
Parakeet error and the repair command.
- The install refuses up front on systems with no native package
(naming the six supported ones) and on musl, instead of a raw npm error.
- 24-bit and float WAV go through ffmpeg, since sherpa-onnx reads only
16-bit PCM. The decode timeout scales with the audio length.
* docs(cli): point Parakeet setup at models install parakeet
The transcribe docs and the media-use references named parakeet-mlx as the
only way to get Parakeet. They now name hyperframes models install
parakeet, and the CLI reference gets a models section.
* test(cli): cover the npm cancel signal and decode timeout, and fix a doubled period
The Parakeet failure line printed "failed.. To repair" when the decoder's
own message ended in a period. Tests now also pin that Ctrl-C reaches the
npm install and that the decode timeout scales with the audio length.
* fix(cli): report unreadable audio as an input error, not a Parakeet failure
Prepare the WAV once before the Parakeet fallback and hand the same file to whisper,
so ffmpeg runs once. Ctrl-C while ffmpeg runs now ends as a cancel (exit 130)
instead of starting whisper.
* fix(cli): recover a clause Parakeet drops after a pause by decoding the gap alone
Each loud gap without tokens is decoded on its own with 0.5 s either side, and only
its new tokens are spliced in. The padded whole-window retry stays as the fallback.
A window with no tokens at all is no longer decoded twice.
* perf(cli): size the decode timeout from the WAV header instead of reading the file
* docs(cli): say a broken Parakeet install still fails transcribe --optional
* fix(cli): treat Ctrl-C during audio preparation as a cancel and drop the partial WAV
ffmpeg traps SIGINT and exits 255 rather than dying by the signal, so that status now
counts as a cancel unless the timeout fired. A failed or stopped ffmpeg removes the
temp WAV it was writing. The decode timeout now sizes a prepared WAV from its file
size, so a large header can no longer hide its length.
* fix(cli): splice a re-decoded gap by whole words so its neighbours are never doubled
The gap decode splits edge words into other tokens than the window decode did. Match
neighbours as whole words within 0.3 s of each gap edge, add only whole words that start
inside the gap, and decode the gap behind 0.5 s of silence like the padded retry.
* fix(cli): count a cancel only when ffmpeg says it received a signal
ffmpeg 7 and later also exit 255 when a file is not permitted (EPERM), so exit 255
alone read a permission error as a cancel. ffmpeg's stderr is now captured, a cancel
needs its received-signal line, and any other failure reports ffmpeg's last line.
The output path is passed to the shared runner explicitly.
* fix(cli): keep a spliced gap word that has no leading space
The filter dropped such a word; it is now kept and given its word boundary.
* refactor(cli): trim three long comments in the Parakeet decoder and installer
* fix(cli): repair a Parakeet runtime missing its native binary, and pick Parakeet by language
The runtime dir is per platform and arch. Installed now needs the runtime
manifest and the native sherpa-onnx.node; a half-installed dir is removed
before npm runs, since install() keeps any dir holding the runtime manifest.
The decode worker flattens a multi-line loader error onto its prefixed line
and stops between windows once orphaned. transcribe opens its cancellation
scope before audio prep, so Ctrl-C under --json cleans up. auto uses
Parakeet only for its 25 languages and only where the host can run it;
--engine parakeet with another language fails.
* fix(cli): sweep reused-pid staging, name failed model downloads, keep ffmpeg's cause
sweepStaleStaging also removes a staging dir older than 6 hours whose pid
is alive, since containers reuse pids. A failed model download names the
file, the host and a retry. runFfmpeg keeps ffmpeg's stderr on its error,
so transcribe shows "Output file does not contain any stream" for a video
without audio, and reads a cancel only from ffmpeg's own signal line.
* fix(skills): make media-use --engine whisper run whisper and report the engine that ran
The whisper path now passes --engine whisper when asked, or --engine auto,
and reads the engine from the CLI's --json line, so a CLI Parakeet run is
no longer labelled whisper. Docs state the language rule, the real repair
behaviour and the platforms Parakeet runs on.
* test(cli): pin that a long Parakeet decoder error is kept whole up to a bound
* fix(cli): report a broken Parakeet runtime with the repair instead of skipping it
* refactor(cli): name the staging age limit instead of commenting it
* fix(skills): let media-use --engine parakeet use the CLI Parakeet when parakeet-mlx is absent
* fix(cli): repair a Parakeet runtime that fails to load, and read Ctrl-C after whisper
models install parakeet now loads the runtime in a child process, the way
the decode worker does, and reinstalls it when that fails, so a missing
shared library, a corrupt binary or missing runtime JS is repaired. It
reports changed: false when the runtime loads. transcribe keeps its cheap
manifest check. After a failure, transcribe lets the event loop turn once
before reading the Ctrl-C scope, since a signal that landed during the
synchronous whisper fallback is only handled then.
* fix(cli): read Ctrl-C from the whisper child, and re-check the Parakeet runtime after a repair
A whisper fallback killed by SIGINT or SIGTERM is a cancel (exit 130), the
same rule decode and runFfmpeg use; the loop-turn wait is gone. The
runtime probe treats a child stopped by Ctrl-C as a cancel and keeps the
runtime; a reinstall is probed again and fails with the loader's error if
it still does not load. media-use keeps the CLI's last lines, stdout
included, when hyperframes transcribe fails.
* fix(cli): give the user-stop rule one owner, SIGHUP included, and end a cancelled install at 130
stoppedByCancelSignal in renderCancellation.ts uses the scope's own signal
list (SIGINT, SIGTERM, SIGHUP) and is false whenever Node stopped the child
(a code, killed, or a spawn error). decode, runFfmpeg, the runtime probe and
the whisper fallback all route through it, so a probe killed by SIGHUP keeps
the runtime. isWhisperTimeoutError now keys on ETIMEDOUT only, which Node
sets on execFileSync's own timeout, so a SIGTERM reaches the cancel rule.
A cancelled models install sets exit 130 and returns instead of failing, and
the probe says what went wrong (timed out, crashed, no output).
* refactor(cli): let the stop-rule helper name carry its meaning
* feat(plugins): ship portable agent bundles and installation guides
* fix(plugins): align new manifests with current release
* test(plugins): split caption fixture setup and cleanup
* test(plugins): pass render fixture path as an argument
* fix(cli): --yes in an unattended run no longer overrides a no saved by another process
* fix(cli): every settings writer takes the lock, and --yes needs a real terminal to reverse a no
* fix(cli): the thin-results download offer also needs a real terminal
* fix(cli): one owner-checked settings lock, and an unreadable settings file never counts as a yes
* fix(cli): settings lock removals go through a reaper, and a non-boolean answer counts as a no
* fix(cli): a settings lock that cannot be released no longer fails a write that landed
* fix(cli): a leftover settings lock is never taken over, and doctor names it
* fix(cli): a background update that cannot be recorded is not launched again every run
* fix(cli): every on-device install re-reads the saved answer first, and an unsaved yes installs nothing
* fix(cli): the thin-results offer stops when a yes cannot be saved
* fix(cli): only the consent-checked step installs the on-device runtime, and a leftover lock warns once
* fix(cli): the leftover-lock warning follows the attended-terminal rule
* fix(media-use): tests never write into the person's media library
* fix(media-use): guard the media home only in this repo's test runs
* test(media-use): probe the media home from a plain child process
* fix(media-use): keep miss logging best-effort when home is unresolvable
* test(media-use): miss logging survives an unresolvable media home
* docs(media-use): describe the media library without naming an app
* chore(media-use): refresh the skills manifest hash
* feat(cli): hyperframes history lists, undoes and restores a project's recorded changes
* fix(cli): an agent's history turn ends after 10 idle minutes on every path
A turn left open no longer takes the person's later edits: the engine
files a CLI turn's writes by the same idle rule as a preview window.
Studio claims take their paths back from an agent's window; refusals,
--json shapes, --limit and time parsing follow the review.
* refactor(cli): the project-history opener lives in its own module
The history command and other commands open a project's history the same
way: through a running preview, or directly under the engine's lock.
* docs(skills): the history loop lines sit in one marked trial block per skill
* fix(cli): a turn marker without its last write time has ended
Idle-limit tests set file times instead of racing the clock, and the
restore refusal test picks a ref no entry id starts with.
* docs(cli): the history entry shape comment names no other app
* refactor(cli): history reuses the busy error class and its who helper
* fix(cli): undo --who reverts the agent's whole last turn and nothing older
When the open turn recorded nothing, undo fell back to the agent's
earlier turn; when a mid-turn command split the turn, it reverted only
the newest part. The turn now keeps the entries it filed, and undo --who
reverts exactly those or refuses. Engine tests pin the write timing
rules, and a date that rolls over (Feb 31) is refused.
* fix(cli): an undo --who that meets a conflict leaves the whole turn as it was
It redoes the parts it already undid, lists the newer entries as they
are now, and offers --just-this and --back-to-before for the whole turn.
The last turn is recorded atomically before its marker is cleared.
* chore(cli): history keeps each file's comment share at or under main's
* fix(cli): history keeps log order past a turn's idle limit and undoes a turn's cut parts
* fix(cli): history drops unused imports and splits undo and reopen steps
* docs(cli): explain how catalog search works and which model it downloads
* docs(cli): name the model catalog search was chosen against
* docs(cli): keep only live rules in the word-search header comment
* docs(cli): correct the hosted vector size in the local vector build comment
* feat(core): a composition's script can load its own files with __hyperframes.assetUrl
* test(core): a template composition inside a mounted file resolves assets from that file
* docs: assetUrl is for paths beside the composition, not project-root paths
* docs: say assetUrl misses a project-root path
* feat(cli): warn when canvas content touches the frame edge
* test(cli): pin which frame edge the canvas warning reads
* test(cli): type the canvas edge stub like the file's other getContext stubs
* fix(skills): push transitions reveal the incoming scene on every seek path
* fix(skills): keep the vendored push templates in step with css-push
* chore(skills): regenerate the skills manifest
* feat(media-use): resolve brand logos from theSVG
Replace the svgl and simple-icons tiers of the `logo` cascade with a
single theSVG (thesvg.org) tier. theSVG ships 7,400+ official brand
SVGs with color, mono, light/dark and wordmark variants.
The tier fetches one manifest pinned to a glincker/thesvg commit on
jsDelivr, memoizes it per process, and matches entities locally by
normalized slug, title, or alias. That drops the hand-kept svgl alias
and simple-icons slug maps. GitHub org avatar and favicon stay as the
fallbacks for brands theSVG lacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(media-use): sanitize fetched SVGs and gate theSVG by license
freezeUrl/freezeLocalFile wrote every fetched SVG to disk unsanitized;
the theSVG tier is now the sole SVG-returning logo provider, so an
inline script tag or on-event handler would land verbatim in a user
project. Route .svg destinations through the existing Figma sanitizer
(new @hyperframes/core/sanitize-svg subpath, synced via
package-subpaths).
thesvgMatch never checked theSVG's own license field: entries theSVG
itself marks unresolved (Proprietary, Unknown, Custom, TODO, or an
explicit no-redistribution-license string) could still resolve and
freeze into a project. Gate matches to the ten SPDX ids theSVG's
submission form accepts; a rejected entry falls through to a lower
ranked theSVG match, then to the github/favicon tiers.
Also caps the theSVG manifest fetch at 20MB, streamed, matching
freeze.mjs's existing cap discipline. It had a timeout but no bound.
* chore(skills): regenerate skills-manifest for media-use/motion-graphics
The original submission edited skills/media-use and
skills/motion-graphics docs without regenerating skills-manifest.json;
its own CI never ran (fork approval gate), so the drift went
unnoticed until this pass's first CI run caught it.
* fix(media-use): drop the sanitizer's workspace import, add parity test
freeze.mjs importing @hyperframes/core/sanitize-svg broke the
test-skills CI lane: packages/cli/src/media-use tests run with node
builtins only, zero install, by design (media-use is vendored
standalone into other projects, e.g. packages/desktop). Port the
sanitizer into media-use/lib/svg-sanitize.mjs instead, and drop the
now-unused core export.
Parity between the port and packages/core/src/figma/sanitizeSvg.ts is
enforced by a new test (scripts/check-svg-sanitize-parity.test.mjs,
run in the test:scripts lane, which does have an install) over a
hostile-SVG corpus, so the two cannot drift silently.
* fix(media-use): close three sanitizer bypasses, add fetch timeout
Independent review of the maintainer pass found the SVG sanitizer
(packages/core/src/figma/sanitizeSvg.ts, reused by media-use's own
port) was itself bypassable: an unquoted href/xlink:href survived,
inline style="..." attributes were never checked, and a href bound
through a renamed XML namespace prefix (xmlns:x=".../1999/xlink",
x:href=...) slipped past the literal "xlink:href" match.
Rewrote sanitizeSvg as a tag/attribute tokenizer instead of a set of
literal-spelling regexes: parse each tag's attributes in any quoting
form, resolve the local name after stripping any namespace prefix,
then drop by local name (any on* handler, any *href whose value is
not a #fragment or data:image/ embed, any style attribute containing
url(, @import, expression( or javascript:). Ported the fix into
media-use's svg-sanitize.mjs identically. Legitimate url(#id)
references on non-style attributes (clip-path, fill) are untouched.
Also adds a 10s AbortSignal.timeout to freezeUrl's fetch, matching
every other fetchMedia call in this package - it had a size cap but
no deadline.
New cases for all three bypasses in both sanitizeSvg.test.ts and the
parity corpus, each reproducibly failing against the prior head.
* fix(media-use): replace regex SVG sanitizer with a DOM allowlist
Two rounds of regex/tokenizer patches to sanitizeSvg found new bypasses
in the same shape each time (unquoted href, style= url(), aliased
xlink prefix; then presentation-attribute url(), unquoted '=' handling,
SMIL animate/set href rewrite, self-closing-slash tag corruption,
data:image/ navigation via <a>). Denylisting attribute names against a
hand-rolled tokenizer was the wrong approach regardless of how careful
the regex.
Rewrote sanitizeSvg (packages/core/src/figma/sanitizeSvg.ts) as a real
DOM walk (linkedom, already a dependency of core and cli) with an
allowlist: known-safe elements and attributes only, everything else
dropped by construction rather than pattern-matched as dangerous.
href/xlink:href (any namespace prefix) kept only for a same-document
fragment, or data:image/ off non-<a> elements. Any attribute value or
<style> text containing url() kept only when every url() argument is a
fragment; @import/expression(/javascript: dropped from style content.
Serializing from a real DOM means output is always well-formed, so the
self-closing-slash corruption class of bug is gone by construction.
media-use's own copy (packages/cli/src/media-use/lib/svg-sanitize.mjs)
can't import linkedom directly: its CI test lane runs bare `node --test`
with no install step, so even an external dependency fails to resolve,
not just a workspace import. It's now a generated file instead of a
hand-copy: scripts/generate-media-use-svg-sanitize.mjs bundles the core
source (esbuild, minified, zero remaining imports) and
scripts/check-media-use-svg-sanitize-generated.test.mjs fails the build
on drift. Verified the generated file runs with plain node in an empty
directory with no node_modules on the path.
Every payload from both independent-review reports is now a
table-driven test in sanitizeSvg.test.ts (46 cases) and the same corpus
in check-svg-sanitize-parity.test.mjs (39 cases) asserts core and the
generated media-use copy agree and each independently rejects every
non-local url(), javascript:, data: href on <a>, and on* handler.
Mutation-proved against the real pre-fix blob, not a synthetic case.
Sampled 56 real theSVG icons across all 7 collections to check the
allowlist doesn't over-strip legitimate content: found and fixed three
missing attributes (fx/fy, enable-background, a blanket data-*
allowance) and one real behavioral bug - <style> elements were being
dropped outright, silently unstyling any Illustrator-exported logo that
colors paths via CSS classes. Fixed by keeping <style> on the allowlist
and content-checking its text the same way as an inline style=
attribute, with a regression test using that exact shape.
Full suite after the fix: sanitizeSvg.test.ts 46/46, parity+drift tests
39/39, media-use suite 298/298, tsc/oxlint/oxfmt clean.
* fix(lint): ignore generated svg-sanitize.mjs bundle in oxlint
Minified esbuild output (packages/cli/src/media-use/lib/svg-sanitize.mjs,
generated by scripts/generate-media-use-svg-sanitize.mjs) trips oxlint's
correctness rules 504 times and fails `bun run lint`. Same shape as the
existing registry/blocks/**/*.iife.js exclusions: a generated, minified,
non-hand-edited bundle, not hand-written source.
* fix(format): ignore generated svg-sanitize.mjs bundle in oxfmt
Same generated esbuild bundle that needed an oxlint exclusion in the
previous commit also fails (minified, no formatting to
check against), cascading Preflight into Player perf, regression,
Windows render verification, and preview-regression on CI. Same
established pattern as the existing registry/blocks/**/*.iife.js and
skills/**/*.min.js entries in .prettierignore.
* refactor(media-use): reduce complexity flagged by fallow audit
isSafeAttribute (10 cyclomatic) and the parity test's aggregate-marker
check (11 cyclomatic, 26 cognitive, CRAP 132) both exceeded fallow's
per-function thresholds after the rebase pulled in a stricter baseline.
Extracted isXmlnsAttribute/isKnownAttribute as named predicates and
survivingUnsafeMarkers as a named per-output helper; no behavior change,
same guard order, same failure strings. Regenerated svg-sanitize.mjs.
* refactor(media-use): make the parity test's marker check table-driven
Fallow gives .mjs scripts no coverage credit, so its CRAP gate needs each
function at cyclomatic 4 or less. The extracted survivingUnsafeMarkers
still scored 9 (CRAP 90). The four regex markers are now a table, and the
data:image-on-<a> and non-local url() checks are small named helpers.
Same checks, same order, same failure strings.
* fix(media-use): close CSS-escape and image-set bypasses in sanitizeSvg
The url()/@import checks scanned raw text for the literal "url(" and
"@import". A browser decodes CSS escapes first, so fill="\75 rl(...)",
url\28...\29 and @\69mport all loaded an external resource, and
image-set("https://...") loads one with no url() at all. Reproduced in
headless Chrome: 8 of 8 payloads fired a request through the old code.
CSS can only load a resource through a function or @import, so CSS text
now passes only if it has no @import, every function is on a compute-only
allowlist (color, transform, filter and math functions), and every url()
is a same-document fragment, all checked on escape-decoded, lowercased
text. Same fix reaches media-use through the regenerated bundle.
Checked against all 13,384 files of the pinned theSVG corpus: 13,383
sanitize byte-identically; one drops an unreferenced id="XRP-(XRP)".
The parity oracle now fails on any surviving attacker host, so it no
longer shares the sanitizer's parsing blind spot.
* refactor(media-use): share one capped body reader for freeze and manifest
fetchJson copied freezeUrl's declared-length check and streaming size cap
line for line. Both now call readCappedBody in media-fetch.mjs, which both
already import; it has its own tests, since freeze's cap had none. The
freeze cap errors now read "freeze failed for <url>: ..." (no caller or
test matches the old wording). Also fixes freeze.mjs's comment, which
still called the sanitizer a lexical pass.
* style(media-use): format the parity test corpus
* fix(media-use): make the sanitizer's CSS scans linear on hostile input
The url() check used /url\(\s*(['"]?)([^)]*)\1\s*\)/ and the function
check /([-\w]*)\(/: on attacker padding these backtracked cubically and
quadratically (an unclosed url( plus 4,000 spaces took 20s; an 80,000
character word took 7.7s), and freeze runs the sanitizer synchronously on
bodies up to 256MB. Both are now single-pass indexOf scans (a million
characters in under 15ms), with the same verdict on ordinary input. A url()
value holding "(" is rejected, so a nested url( can't hide inside one.
A size test holds both payloads under 2s.
* test(media-use): size payloads that fail the old scans fast instead of hanging
* fix(media-use): stop the freeze timeout from aborting long downloads
The 10 s AbortSignal.timeout on freezeUrl also aborted the response body,
so any stock video, generated video or LUT that took longer than 10 s to
download failed. The timer now covers only the wait for response headers
and is cleared once they arrive; the 256 MB cap still bounds the body.
* refactor(media-use): trim logo-provider comments to the repo limit
The tier list and the thesvgMatch doc block ran past the four-line comment
cap that the merge check enforces. Same facts, shorter blocks.
* refactor(media-use): name the header wait and restore the variants note
Renames the freeze timeout controller to headerWait so it no longer reads
as HTTP headers, puts the theSVG variants fact dropped by the comment trim
next to the line that picks the default mark, and keeps thesvgMatch on the
file's JSDoc style.
---------
Co-authored-by: Imad Phelps <imad.phelps@heygen.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* fix(core): looping Lottie animations keep cycling instead of freezing after one pass
* feat(registry): add a Lottie character walk-in and document the character route
* chore(registry): index lottie-character-walk in the catalog and search vectors
* chore(docs): list lottie-character-walk in the catalog gallery
* docs(skills): say that lottie-web loops when loop is left out
* fix(core): a finished Lottie holds its last frame instead of disappearing
* fix(core): hold a finished Lottie on its exact last frame
* chore(skills): refresh the manifest hash for the Lottie skill edits
* fix(core): wrap looping Lottie by frame so a loop never holds at its seam
* fix(core): a looping Lottie starts each cycle on frame 0 despite float error
* fix(media-use): remove clicks and ~25ms gaps at every transcript-cut splice
transcript-cut extracts each kept range to its own final-codec file, then
concat-copies them. That produces two audible artifacts at every cut:
1. No boundary fades. Concat splices raw segment edges together, so the
waveform steps discontinuously and each cut clicks.
2. Encoder priming silence. Each segment is encoded to AAC independently,
so the encoder pads every one with priming samples. Stream-copy concat
bakes that padding in as a silent gap at each splice. Measured 24.4 ms
and 24.1 ms at the two splices of a three-range cut. It accumulates:
the nth splice lands roughly 25n ms late, so splice positions drift
away from the sum of the preceding segment durations.
The second survives the first -- adding fades alone leaves the gap.
Fix: intermediates carry PCM (.mkv for video, .wav for audio-only) instead
of the final codec, so audio is encoded exactly once, at concat. Boundary
fades of 30 ms are applied per segment, scaled down on segments shorter
than 4x the ramp and skipped on degenerate ones.
Measured on the same three-range cut, widest run of digital silence at
each splice:
before 24.4 ms / 24.1 ms
after 0.1 ms / 0.1 ms
--copy is unchanged: stream copy cannot filter, so it keeps producing
final-codec segments and a copy concat. Audio-only output is byte-identical
in duration before and after. scripts/lib test suite: 34/35, the one
failure (lut-preset-provider) pre-exists on main and is unrelated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(skills): regenerate manifest for media-use
* fix(media-use): honor the fadeIn/fadeOut flags fadeFilterFor was passed
The caller already computed {fadeIn, fadeOut} per segment, but
fadeFilterFor still took one parameter and always emitted both
afade ramps -- so a single kept segment (no splice at all) and every
segment's true start/end got faded regardless. Extracted the pure
function into lib/transcriptCutFade.mjs, made it honor both flags,
and added its first unit test: first/middle/last/single-segment
filter strings, no ffmpeg needed, fails against the previous version.
Also installs ffmpeg in the Test: skills CI job (same action the
Producer job uses), so transcript-cut.test.mjs's real-tone RED/GREEN
proof runs in CI instead of skipping silently for lack of the binary.
* fix(media-use): fix the packed-CLI import and lint on transcript-cut.mjs
build-copy.mjs replaces dist/skills/media-use/scripts/lib with a copy
of packages/cli/src/media-use/lib, so transcriptCutFade.mjs needed a
byte-identical twin there (and in MEDIA_USE_COPY_NAMES) or the packed
CLI's transcript-cut.mjs imported a file that no longer existed.
Moved its test alongside, matching every other lib helper's tests.
Also drops three redundant Boolean(args.copy) wrappers (a ternary
condition already coerces) that oxlint's no-extra-boolean-cast
flagged.
* style(media-use): reflow the shortened ext ternary to oxfmt's shape
Dropping the Boolean() wrapper shortened the line enough that oxfmt
0.41 now collapses the ternary onto one line; format:check was failing
on the stale multi-line version.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* feat(cli): move media-use engine into the CLI
* fix(cli): replace media-use compatibility asset
* fix(cli): replace moved media-use tree
* fix(cli): copy moved media-use files cleanly
* fix(cli): copy media-use engine files individually
* fix(cli): keep audio helper compatibility import
* fix(cli): clear copied skill destinations
* test(cli): preserve moved media-use test paths
* fix(cli): preserve media-use script compatibility imports
* fix(cli): preserve media-use telemetry shim
* fix(cli): resolve packaged media-use LUT assets
* fix(cli): resolve packaged bundled SFX assets
* chore(skills): regenerate media-use manifest
* fix(cli): preserve media-use parity coverage
* fix(cli): cover relocated media-use tests
* chore(skills): regenerate media-use manifest
* fix(cli): format bundled media provider
* fix(ci): run relocated media-use tests with node
* fix(ci): preserve fallow script exemptions
* fix(ci): scope media-use fallow exemption
* refactor(cli): trim media-use essay comments
* feat(cli): index media-use assets for catalog search
* feat(catalog): commit media-use search vectors
* chore(catalog): track media vector metadata
* fix(catalog): validate media vector row order
* fix(catalog): validate media artifact freshness
* fix(catalog): cover media metadata and credits
* fix(cli): reject malformed media rows
* fix(catalog): compare complete media rows
* feat(cli): route media search through media-use
* refactor(cli): trim relocated media-use comments
* fix(cli): make media search runtime-safe
* fix(cli): preserve sfx fallback diagnostics
* fix(cli): probe sfx fallback provider explicitly
* chore(skills): regenerate manifest
* refactor(cli): keep typed media ranker source
* fix(cli): align media search checks with local-first flow
* fix(cli): scope vector audit exceptions
* fix(cli): ship resolver registry beside skill
* fix(cli): keep media-use skill scripts standalone
* test(cli): cover local vector validation
* test(cli): correct media vector fixtures
* fix(cli): prefer exact media keys
* fix(cli): stem exact media keys
* test(cli): skip media lint for snippets without media
* fix(cli): align skills test discovery
Keep the CI skills job's bash discovery as the single source of truth so its empty-list guard covers the exact files Node executes. Make hasMediaVectors private because it has no external callers and needs no Fallow suppression.
* fix(cli): close media-use review gaps
Prioritize literal ids before stemmed ids and cover the collision alongside the plural regression. Add command seam tests for engine discovery, actionable failure, and argv passthrough. Add a deterministic parity check for standalone media-use copies, with documented allowlists for the standalone media-fetch shim and self-contained npx resolver. Keep the bash-discovered skills test list as the CI source of truth.
* fix(cli): lower parity check complexity
Split missing-copy and unallowlisted-drift checks so the parity guard keeps its behavior without a threshold CRAP score.
* fix(cli): close media-use review seams
Run the parity test in the skills job so skills-only edits exercise the same check. Export the verb mapping and inject command dispatch for direct coverage. Keep missing-copy diagnostics separate from drift reads.
* feat(studio): edit a clip's speed ramp with a rate lane and presets
* fix(studio): keep the live preview attribute in sync with what gets persisted
* fix(cli): snapshot picks the source frame through a rate lane and up to 10x
hyperframes snapshot clamped the constant playback rate at 5 and ignored a
rate lane in data-automation, so a sped-up or ramped clip showed the wrong
frame. It now reads the rate through the runtime's own reader and maps time
with the shared source-time function.
* docs(skills): teach speed ramps as a rate lane in one reference page
Adds the speed-ramps reference, corrects the 0.1 to 5 rate range to 0.1 to 10
and the 'speed ramps are not supported' lines in the skills, and regenerates
the skills manifest.
* docs(skills): say what fixes a ramped clip's length and how the render approximates pitch
* fix(studio): hide the speed preset until the clip has a known duration
A preset on a clip with no data-duration was stretched over a made-up one
second. The panel now offers presets only when the duration is known.
* docs(skills): fix the last stale ramp line in the editing recipes and the slice-cap wording
* feat(core): a composition with no authored length takes it from its clips
A new resolver in @hyperframes/parsers returns a composition length as authored,
derived from its latest clip end, or unresolved, and counts clips whose length is
still pending. The runtime uses it in its two existing max-end loops with identical
results, and as a last resort only when no timeline, floor or caller length exists,
so a composition that has a length today keeps it. The derived source is published
on window.__hf.durationSource for render telemetry.
Lint reports a root that has timed clips but no data-duration as a warning that says
where to author it, and still errors when there is nothing to derive from.
* refactor(core): resolve the runtime duration floor in one call
The declared duration and sub-composition ends were resolved separately and
maxed by hand; one resolver call over both gives the same latest end.
* fix(core): a derived composition length waits for every clip length and reports honestly
Review follow-up. The runtime returns a content-derived length only when no clip is
still pending, so a renderer that reads duration once cannot lock in a short one.
window.__hf.durationSource is cleared whenever a timeline or floor supplies the length,
and the diagnostic fires only for a derived length. Lint says "at least Ns", counts
reference-start clips as known only at runtime, and ignores a data-end before data-start.
* fix(core): a sub-composition with no known length counts as pending
A host whose own timeline is not registered yet no longer lets the content-derived length
show early. Tests pin the pending host and that the diagnostic fires only for a derived length.
* fix(core): a loaded Lottie with no registered animation keeps the length pending
Lottie registers from author scripts with no DOM marker, and nothing re-derives the length
after it registers. A composition with clips and a loaded Lottie library therefore stays at
zero, as before, instead of locking in the clips length and cutting the animation short.
* test(core): a registered Lottie supplies the length and the clips length is not used
Pins the other side of the pending Lottie rule. Once an animation reports a length it enters the
duration floor, so the content-derived fallback is never consulted.
* test(core): the declared and DotLottie pending branches are covered
Text: one absolute-start list per kind, nested clips marked with host and file.
JSON: a track's rows hold every clip of that kind; children are index pointers, not copies.
* fix(cli): timeline resolves real media durations instead of printing unauthored
Media rows call the shared resolveMediaDuration with an ffprobe source length.
Each row shows its duration source, or pending with the reason a probe failed.
Absolute time (absStart/absEnd) is carried across sub-compositions.
Remote, absolute and parent-relative srcs are never probed.
* fix(cli): timeline probe leaves the authored-duration rule to the resolver
The gate test now launches jobs while slots are held.
* fix(cli): timeline reports a zero-length probe as pending instead of a measurement
A still image or duration-less container is pending with a reason, never duration=media.
The runOneLiner test helper now cleans up its own fixture directory.
* fix(cli): timeline probe is injectable and the fixture path works on windows
Two unit tests used real ffprobe, which the Ubuntu Test job lacks, and the audio
fixture path doubled the drive letter on Windows. describeProject now takes the
measure function, the tests pass a recorded fake, and one real-ffprobe test remains.
* docs(skills): an agent adds media with the duration Studio gives a dropped file
* docs(skills): add-media recipe matches Studio's full-frame geometry and z-index
* docs(skills): recipes start with hyperframes timeline to see tracks and clips
* test(skills): split the add-media drift test so each check stays small and formatted
* test(skills): split addMediaFacts into single-purpose helpers to clear the CRAP-score gate
* docs(skills): add-media recipe needs only data-start for video and audio
* docs(skills): add-media intro names the video and audio difference and a test guards it
* docs(skills): add-media image duration is optional and defaults to 3 seconds
* chore(skills): regenerate the skills manifest after rebasing onto main
* docs(skills): tracks-and-clips says image duration is optional, and a test pins the 3
* test(skills): split the add-media example check into small helpers
Clears the Fallow complexity finding on the test callback.
A new resolver in @hyperframes/parsers decides how long a video, audio or image
occupies the timeline. The core runtime start resolver, clip manifest and clip
tree now read length through it. An image with data-start and no data-duration
gets the dropped-image default of 3 seconds; a bare image stays a static layer.
Video and audio take their length from the file until an authored duration trims it.
The playback rate bound (0.1 to 10) now lives in parsers, and core re-exports it.
A rate lane keeps its own arithmetic in core.
The lint rule that asked images for data-duration is removed, and the docs no
longer say an image requires it.
* feat(cli): hyperframes timeline prints a project's tracks and clips
* docs(cli): list the timeline command
* feat(cli): timeline command reports unreadable lanes; add skill docs
* fix(cli): timeline stays inside the project, shares one start cache, keeps small volumes
* fix(cli): timeline resolves symlinks before the project containment check
* fix(cli): timeline accepts project folders whose name starts with two dots
* refactor(cli): timeline and compositions share the engine's start resolver
* docs(skills): point agents at the timeline command where they decide how to read a project
* fix(cli): timeline prints absolute main-timeline times and the owning file for nested clips
Nested rows carry absStart/absEnd on the main timeline and their owning
file, alongside existing local start/end. Text prints absolute time first,
local time in parentheses. Adds jq/node one-liners for common queries,
tested against a fixture.
* fix(cli): timeline places nested media at the runtime start and tests its query one-liners
A nested media clip with a negative literal data-start was reported later than it plays,
because the raw start was clamped before the host offset was added. The skill's jq and node
one-liners now run against the fixture in the test suite.
* fix(cli): timeline clamps a negative media start to 0 when its host starts at 0
The runtime skips the host-offset formula for a host at t=0 and clamps the start, so the
timeline command now does the same.
* refactor(core): one media-start rule shared by the runtime and the timeline command
The runtime and the CLI each carried a copy of the rule for when a nested media clip's
start takes the host offset, and the copies had drifted. Both now call
resolveMediaStartSeconds.
* test(cli): timeline fixture for a media start written as an expression
parseNumeric and the runtime's parseStrictFiniteTimingNumber are the same function, so the
two callers cannot disagree on what counts as a literal; this pins the expression case.
attachMotionBlur(el, tl) has an ordering contract: it has to run after every
tween is defined and before the composition registers its timeline. Getting it
wrong makes the blur silently do nothing, which is the worst shape a failure can
take in a renderer nobody watches. An attribute has no order to get wrong.
data-hf-motion-blur on any element is now the contract. Empty means defaults;
anything else is a JSON object of the same options. The element finds its own
timeline: its nearest [data-composition-id] ancestor names the key, and the
snippet reads window.__timelines[key]. attachMotionBlur stays exported for a
target built after the page has settled.
claim(el) is the only thing that attaches, and two triggers call it. An accessor
on window.__timelines wraps the registry in a Proxy whose set trap sweeps on
every per-key registration, which is what attaches before the renderer captures
frame zero. A bounded poll, 32 ms by 250 ticks, reads the registry directly and
needs no cooperation from whoever wrote it. The poll is not belt and braces: a
registry reference captured before the snippet installs bypasses the trap
outright, and a composition that mounts its DOM after registering its timeline
has no marked element to sweep at trap time. It runs its whole budget every time
rather than stopping the first tick nothing is pending, because on an ordinary
page the top-level target is claimed on the registration write, so an early exit
kills the poll one tick in, before any late composition arrives.
Every refusal is by name, once, and never silent: a value that is not JSON, a
value that parses but is not an options object, an option name that is not one
of the four, an option value that is not a finite number, a target inside
another target, and a second timeline registered under one key. Parsing runs
before the timeline lookup, so a malformed value is reported as malformed rather
than as a composition that never registered.
Two properties of the primitive this change made load-bearing:
- Frame rate is resolved from the target's own composition root, not from the
first root in the document. The document-wide lookup was survivable while
every caller could pass fps explicitly; the declarative form has no per-call
argument. Two compositions on one page at different rates now each get their
own shutter window, and one call naming both says it cannot.
- An attach is all-or-nothing. The mark, the copies and the resize observer go
in together and come back out together, so a throw partway through leaves the
element untouched rather than marked blurred with copies in the page and no
tracker driving them. The sweep is also bounded per element, because the write
trap runs it inside the author's own registration statement.
Copies are stripped of data-hf-motion-blur, data-composition-id and data-fps,
on the clone and every descendant: the first would make a copy a target of its
own, and the other two would let a copy win the fps lookup, since copies are
inserted before the original.
skills/hyperframes-animation/references/motion-blur.md is the agent-facing half,
mirroring how colour grading is surfaced. It leads with when NOT to blur,
because the attribute makes blurring everything trivial and a smear on motion
that was never fast enough reads as a soft, cheap render.
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Vance Ingalls <vance@heygen.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
* docs(skills): the agent presents a tailor-made storyboard page instead of a Studio board
Adds the storyboard recipe (decisions first, beat fields, a self-contained
storyboard.html contact sheet, a review round) distilled from the launch
films, and rewrites the review loop, brief contract, storyboard format and
workflow steps that still described the deleted board and comments file.
* docs(skills): reconcile the sketch pass with the beautiful storyboard sheet
Review found: the recipe's storyboard.html cell spec (real fonts, real
colors) contradicted review-loop's own sketch definition (wireframe, no
brand treatment). The sketch is static, not unstyled; only motion is
deferred to the build pass. Also fixes a frame-packet boundary note, a
craft-device citation, and a provenance overclaim.
* refactor(studio): remove the storyboard view and its Storyboard/Preview switch
* refactor(studio): fold the thumbnail output branches left after the storyboard mode went
* docs(studio): drop leftover storyboard references after the view was removed
* ci: allow the storyboard view deletions in the no-deletions guard
* docs(studio): wrap an over-long comment line in the caption overlay helper
* style(studio): reindent App.tsx after the view-mode wrapper was removed
* docs(skills): one owner doc for everyday edits and a hyperframes-studio skill
Agents got contradictory advice on volume fades and were blocked from splits
and copies by the Ask-agent guardrails.
- creator-editing-recipes.md is the single owner: adds align-to-event,
copy-a-group and swap-media recipes and the one volume rule (use the lane)
- variables-and-media, data-attributes (skill and CLI copy), the gsap adapter
and tracks-and-clips now defer to it
- Ask-agent guardrails allow the neighbour edits a split, retime, copy or swap
requires and point at the owner doc
- new hyperframes-studio skill: sub-compositions for every scene, one caption
track, one element kind per track, safe zones
- drift test lints every html example in the owner doc with the real linter
and fails on any doc teaching a volume tween
* refactor(skills): drop unused tween allowlist from recipes drift test
* docs(skills): name the track kind attribute in the studio skill
* docs(skills): stop claiming lint rules that do not exist yet and fix track numbering
Review fixes:
- studio skill: drop the severity claim and the preview-overlay claim,
group tracks by data-track-kind instead of numeric ranges that clashed
with the recipes
- recipes: audio data-start is root time only in the root composition;
scene-local inside a sub-composition file
- guardrails: timeline edits are exempt from the element-only and timing
lines; the pointer names the skill reference, not a repo path
- montage doc defers to the volume lane; skill count is 21 everywhere
- drift test also scans the montage doc
* fix(skills): the ducking generator emits a volume lane and captions defer to the studio safe zones
- audio-duck.mjs prints a data-automation lane (clip-local times from the
target data-start) instead of a timeline volume tween; --json adds the lane
- operations.md shows the generated lane; the drift test scans it
- embedded-captions safe-zone lines point at the studio skill, which owns
the values
* chore(skills): regenerate the skills manifest after rebase
* docs(skills): remotion translation docs point volume ramps at the automation lane
* docs(skills): studio safe zones use the action-safe 90 and title-safe 80 boxes
* docs(skills): caption docs defer to the studio safe boxes
* chore(skills): regenerate the skills manifest after rebase
* fix(skills): studio skill names the safe margins; guardrail test matches the timeline-edit wording
* fix(skills): teach and ship the remotion translation as sub-compositions
The converter reference fixtures now emit one sub-composition per scene
under a mounting root, and the api map, sequencing guide and tier-2 README
teach that shape instead of the flat data-start div. SSIM per tier is
unchanged (t1 0.974415, t2 0.985993, t3 0.953275 to 0.953281).
* docs(skills): make every remotion translation doc teach the sub-composition shape
SKILL.md step 3, the sequencing guide (nested, series, crossfade), the api map
asset rows, and the parameters guide now match the shipped fixtures.
* docs(skills): say where scene cuts versus cross-scene fades live
* fix(skills): mount music-to-video motion primitives as sub-compositions
Each primitive's root now only mounts scene.html, so the root is built from
a sub-composition and the structure lint has nothing to flag. Renders match
the originals (31 of 37 identical, the rest above 54 dB PSNR).
Registry examples mark their caption layer with data-track-kind=captions.
* fix(skills): keep primitive variables and page styles where the runtime reads them
Composition variables move onto the sub-composition element, the dead
#scene rule is dropped, and the frame-worker doc names where page styles
live.
* fix(skills): shorten a carried-over comment in the kinetic-letter-in scene
* feat(core): hold render-ready on declared compute and a build-ready hold, not just duration
Adds the compute and paint-and-idle inputs to the composition readiness gate,
plus window.__hf.buildReady: a promise registry a piece registers when it has
setup work no adapter can see (mesh building, shader compiles). The runtime
holds render-ready on it and the player shows its loading state instead of a
blank frame while it resolves.
* refactor(core): dedupe the paint-and-idle readiness test setup
Simplify pass on 23810da1b: fallow flagged the two paintAndIdleReadinessInput
tests as a 12-line clone; extracted the shared doc/tracking setup.
* fix(core): stop the readiness settled-tracker hanging on 2+ pending promises
Promise.all allocates a new promise every call, so comparing ITS identity
across repeat polls (isAdapterReadinessSettled, isBuildReadinessSettled) never
observes settled once 2+ promises are combined: every poll rebuilds a new
combined promise, re-arms, and republishes forever without the caller ever
seeing true. Reproduced with two already-resolved window.__hf.buildReady
entries, which is the exact multi-piece usage the new authoring doc describes.
Fixes both trackers at once with a shared createSettledTracker that compares
the source promise array by element instead of the Promise.all wrapper.
* fix(core): cancel readiness inputs once settleCompositionReadiness settles
computeReadinessInput's poll timer and paintAndIdleReadinessInput's rAF chain kept
running after the shared timeout won the race, since nothing told the losing
inputs to stop. One AbortSignal now threads through all three inputs and is
aborted the instant the race settles.
* docs(core): fix a stale sync-callback claim in settleCompositionReadiness
paintAndIdleReadinessInput always has a frame to wait on for any real
document, so the sync fast path this docstring described is no longer
reachable with the default inputs except for a viewless document.
Found by the simplify pass on this branch.
* fix(core): clear a stale buildReady entry on runtime teardown
A never-resolved window.__hf.buildReady promise from a torn-down
composition survived into the next init in the same window (window.__hf
is never reset), permanently blocking render-ready for whatever loaded
next -- the exact black-screen class this branch fixes, reproduced
through its own new mechanism. Found by the independent review.
Also fixes a stale hyperframes-player.ts docstring (media/images/fonts
had not been updated to compute + paint-and-idle) and documents the
buildReady key-collision hazard in three.md.
* fix(core): skip the compute readiness wait when no runtime ever ran
computeReadinessInput polled window.__renderReady for the full shared
timeout on any same-origin document with no HyperFrames runtime
injected (a probe-only composition using native video/duration, or a
blank test iframe) — nothing was ever going to set that flag. Checking
window.__hf first (the same signal composition-probe.ts already treats
as "has a runtime") lets it return null instead of waiting.
* fix(player): debounce the loading overlay and diagnose compute timeouts
paintAndIdleReadinessInput always has a frame to wait on for an
attached iframe, so settleCompositionReadiness's synchronous-settle
path is unreachable in practice now — the loading overlay showed on
every single Play, even ones with nothing genuinely pending. Debounce
the overlay itself instead: a wait that resolves fast never shows it.
Also names compute (window.__renderReady) in the stuck-assets timeout
diagnostic, which previously only re-scanned media, images and fonts.
* fix(engine): name a stuck buildReady key in the zero-duration diagnostic
A build promise a composition registers on window.__hf.buildReady that
never resolves left the export path's zero-duration timeout naming
nothing useful — hasHf/hasSeek/duration all looked fine, since the
runtime was genuinely present and just waiting on that one promise.
Probes buildReady's keys for pending ones and names them in the hint.
* fix(engine): stop the buildReady diagnostic from crashing on a rejection
A microtask race against a bare resolved promise rejected the whole
diagnostic expression on any rejected buildReady entry (the runtime's
own createSettledTracker rejects deliberately) and mis-sorted a settled
non-native thenable as pending, since its extra adoption tick loses the
race. Both measured directly, not inferred.
Settle each key onto its own record past one macrotask instead: no
throw, and pending/resolved/rejected are distinguished correctly. A
rejected key gets its own hint, separate from a merely pending one.
* fix(player): report a backgrounded document instead of overclaiming a paint bound
MAX_PAINT_WAIT_MS only bounds a document that keeps producing frames; one
that stops entirely (backgrounded tab suspends rAF, or fewer than 3 frames
ever fire) still rides the full shared 8s timeout, same as before this
input existed. Corrected the comments that claimed otherwise.
That gap is exactly why the stuck-assets diagnostic left paint-and-idle
unreported: there was no accurate way to say it might be the cause. Added
documentHidden (doc.hidden) to the diagnostic instead, since a backgrounded
tab is the reachable, directly observable case.
* refactor(engine): simplify the buildReady diagnostic rewrite
Skip the macrotask wait entirely when a composition registers no
buildReady keys — the common case, since it's opt-in. Nothing to settle
means nothing to wait on.
Collapse buildZeroDurationDiagnostic's inline parameter type into the
existing HfDiagnostic interface instead of maintaining two copies of
the same shape.
* docs(engine): note the setTimeout(0)-thenable gap the review found
An independent adversarial review, executing hostile inputs rather than
tracing, found the macrotask wait can still misreport a thenable that
resolves via its own internal setTimeout(0), since the diagnostic's own
timer is registered before the thenable's. Bounded and self-correcting
(only feeds the message text, and the diagnostic re-runs every ~1s), so
documenting the boundary rather than chasing an unbounded fix.
* feat(telemetry): add per-tag element and a-roll counts to render_complete
countElementTags's single regex scan (the ~83% of renders that get no
live probe session) is extended into scanElementTags, which returns a
per-tag breakdown and an a-roll video count alongside the same total,
all from one pass over the same script/style-stripped markup — the
total is the sum of the per-tag map by construction, so the two
properties can't drift apart. countElementTags itself is removed; it
had zero production callers after this change.
New render_complete properties: composition_element_tags ({tag: count},
raw lowercase tag names, capped at the top 50 by count with the rest
folded into an "other" key) and aroll_video_count (<video
data-aroll="true"> elements from the same scan). Both are static-scan-
only, mirroring the existing composition_element_count_source split —
the live probe-DOM path never runs this scan.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* feat(telemetry): count HeyGen-provenance video from the same tag scan
heygenVideoCount joins arollVideoCount/byTag as a third property
derived from scanElementTags's single pass: <video
data-media-source="heygen"> elements. media-use's resolve.md now
documents that the skill stamps this attribute on a mounted video only
when the ledger record's provider is "heygen.video" (the mount itself
is always the agent's own composition edit, never written by resolve
scripts) — the ledger stays the single source of truth for provenance,
the HTML attribute is a projection of it, and no other provider value
is invented beyond this one signal.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* feat(telemetry): add a bucketable failure taxonomy to render_error
error_name (error.name, e.g. one of the ~20 typed producer error
classes) and failed_stage_code (job.currentStage/failedStage
normalized to a stable snake_case code via normalizeStageCode)
join the existing free-text error_message/failed_stage, so a fleet
query can bucket failures without parsing prose. Error-conditional by
nature — render_complete has no equivalent, since a successful render
has no error to name or stage to blame.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Report HDR auto-promotion consistently from the render media set, including remote media, and make its regression coverage portable across supported paths.
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
* feat(cli): render summary names the capture path, gpu mode and stage timings
* fix(lint): drop gsap_exit_missing_hard_kill, its hazard does not reproduce on cold workers
* fix(cli): render slow-path hint fires only when the gpu was auto-probed
* test(cli): isolate agent hint suite from ambient agent env keys
The suite runs inside agent sessions. Ambient env keys fill the 16-key
hint cap and fail assertions that a plain shell would pass.
* docs(music-to-video): drop leftover hard-kill mandate after lint rule removal
Frame workers still required a tl.set hard-kill that the dropped lint
rule no longer enforces. Align the self-check with montage.md.
* refactor(cli): share hint-key pattern and collapse render summary args
Export HINT_KEY_PATTERN for the test isolation strip. Read duration and
frame count from the perf summary instead of passing them beside it.
* test(cli): lock capture-mode preference and 16-key hint isolation
Session mode, including drawelement, must print over observability.
The hint suite now fails if ambient keys already fill the 16-key cap.
* feat(cli): default BeginFrame, agent blank, and looks quality
Local auto render opts out of the software-GPU screenshot clamp so
BeginFrame can run. init --agent scaffolds a centered Inter blank with
no prompts. --quality looks (now the default) is CRF 16; delivery is high.
* fix(cli): honor screenshot env and teach agent pitfalls on the happy path
Local auto still requests BeginFrame unless PRODUCER_FORCE_SCREENSHOT=true.
The agent blank fills its canvas so --resolution can resize it. Skills now
name the four pitfalls and tell agents to read the render summary line.
* feat(cli): default init to the centered blank
Bare init scaffolds the centered Inter stage. --agent is a hidden alias.
* fix(cli): pack from-file and use it for video init
TTY --video with no --example selects from-file. Pack copies that template.
* fix(cli): wire --video onto from-file even with --example blank
One resolver picks the scaffold. Spawned init --video asserts a-roll src.
* fix(cli): narrow looks CRF on the quality alias
Only looks carries crf. Typecheck failed on the union without a guard.
* style(cli): shorten render-pipeline summary comment
* refactor(skills): keep hyperframes-core as the HTML contract
Move brief, storyboard, review, production, dispatch, and
frame-worker docs to hyperframes. Slim remaining core references.
* fix(skills): restore camera recipes and root sizing
Restore Zoom, Ken Burns, crop, and clip-path recipes. #root is 100 percent.
* fix(skills): stamp size on the composition root
Runtime sizes the composition root, not html/body. Overlap pin requires is valid.
* fix(catalog): name the better search tier when a search finds nothing
The hint that tells a scripted caller the on-device tier exists was wired
to the branch where the search succeeded, and was silent on the branch
where it found nothing. The caller with the most reason to hear it was the
only one who never did.
It also only ever reached stderr, so a --json caller never saw it at all.
The hint is now a returned sentence pushed into the same warnings array the
zero-result envelope already serializes, and printed from that one value, so
the terminal and the envelope cannot drift.
Withheld when the query parsed to no searchable tokens, where the advice is
already to search in English, and when a warning has already explained why
the tier cannot run here.
The consent gate is untouched: nothing downloads, and the sentence still
asks the caller to check with a person before enabling it.
* fix(review): pass the resolved model status, and pin both hint guards
Deriving the status inside the hint helper re-read and re-hashed the
on-device model from disk to answer a question the caller had already
answered a few lines earlier. It now takes the resolved value.
Both guards below the status check were invisible to the suite: every
other case pinned a status that already returned null, so deleting either
guard passed everything. Two tests now hold them, and each fails for its
own mutation.
* docs(skills): tell agents to read the tier hint the envelope now carries
The CLI skill said that under --json nothing about the offline tier is
printed at all. An agent following that sentence would never read the
warnings array this branch writes the tier sentence into, so the fix
would land in the code and never reach the caller it was written for.
It also described warnings as firing only when a tier was asked for and
could not run. That is now too narrow: a zero-result search emits the
hint with no tier requested.
Regenerate skills-manifest.json for the changed content hash.
The pre-commit format hook's glob does not include .mjs, so the new test
file reached CI unformatted while oxfmt --check . covers it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review at fe53f51a: the moved test points passed at both 1.6s and 2.4s, so a
revert of DUCK_RELEASE_S was silent. Add the 9s probe that reds on 1.6s. Export
DEFAULT_STRENGTH and parseArgs from carve.mjs and pin 0.8 in carve.test.mjs.
Fold the new requirement paragraph into the existing 'Carve by default' one so
the rule is stated once. Regenerate skills-manifest.json after the last edit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>