Commit Graph
412 Commits
Author SHA1 Message Date
Miguel Ángel 663b02297d feat(cli): add hyperframes clean to find and remove what HyperFrames left on disk (#4701)
* 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
2026-09-29 00:43:42 +00:00
Miguel Ángel e25bc3a806 fix(media-use): record agent-made voice, music and sound effects in the media manifest (#4648)
* 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
2026-09-29 00:07:16 +00:00
Miguel Ángel 0c2c4248e6 docs(skills): studio agents answer questions before editing and plan new films like the launch films (#4699)
* 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
2026-09-28 22:07:32 +00:00
Miguel Ángel 13bee74960 fix(lint): warn when a clip ends past the root composition duration (#4633) 2026-09-28 05:58:25 +00:00
Miguel Ángel fa044a89e5 feat(cli): tag telemetry and feedback with the app that launched the CLI (#4632)
* 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
2026-09-28 05:11:11 +00:00
Miguel Ángel 5cbef29d3a feat(cli): transcribe with Parakeet on macOS, Linux and Windows after an opt-in install (#4583)
* 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
2026-09-27 23:21:16 +00:00
James Russo 0a5e3c7fc4 feat(plugins): ship portable agent bundles and installation guides (#4608)
* 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
2026-09-27 21:58:51 +00:00
James Russo ff6e2103d7 fix(skills): keep plugin workflows on their installed release (#4607)
* fix(skills): keep plugin workflows on their installed release

* docs(skills): clarify plugin manifest discovery

* fix(plugins): suppress stale notices and isolate plugin context
2026-09-27 21:05:28 +00:00
Miguel Ángel e3ef033a31 docs: credit the background-removal model as Apache-2.0, matching upstream U-2-Net (#4561) 2026-09-27 03:06:06 +00:00
Miguel Ángel d290c05b09 fix(cli): --yes in an unattended run no longer overrides a no saved by another process (#4547)
* 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
2026-09-27 01:39:17 +00:00
Miguel Ángel c238abfe6f fix(media-use): tests never write into the person's media library (#4459)
* 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
2026-09-26 14:13:56 +00:00
Miguel Ángel 2734ee2c02 feat(cli): hyperframes history lists, undoes and restores a project's recorded changes (#4449)
* 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
2026-09-25 14:26:09 +00:00
Miguel Ángel 7c634426fd docs(cli): explain how catalog search works and which model it downloads (#4462)
* 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
2026-09-25 04:43:54 +00:00
Miguel Ángel b8dd023a97 feat(core): a composition's script can load its own files with __hyperframes.assetUrl (#4434)
* 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
2026-09-24 21:13:27 -04:00
Miguel Ángel 7556b95ac6 feat(cli): warn when canvas content touches the frame edge (#4427)
* 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
2026-09-24 16:04:44 -04:00
Miguel Ángel ff884845a8 fix(skills): push transitions reveal the incoming scene on every seek path (#4314)
* 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
2026-09-23 22:57:38 -04:00
James Russo 01601d1105 feat: add Gemini TTS models and service-account auth to media skills (#4377)
* feat: add opt-in Gemini 3.8 TTS to media skills

* feat: support Gemini service accounts and older TTS models

* fix(audio): isolate Gemini auth and narrow OAuth scope

* fix(audio): pin Gemini Python helpers to UTF-8
2026-09-24 02:21:23 +01:00
Miguel Ángel 29fc95395d fix(skills): keep script imports inside installed skill directories (#4356)
* fix(skills): keep script imports inside installed skill directories

* refactor(skills): simplify standalone module checks

* fix(skills): carry the brand-logo lookup into the media-use copy of media-fetch
2026-09-23 19:09:09 -04:00
Miguel Ángel 922be55e55 fix(skills): preserve captured font subsets (#4358) 2026-09-23 17:19:08 -04:00
d49e7b95e0 feat(media-use): resolve brand logos from theSVG (#4291)
* 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>
2026-09-23 16:56:16 -04:00
Miguel Ángel 8055961740 feat(registry): a Lottie character walk-in, and looping Lottie that keeps playing (#4316)
* 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
2026-09-23 09:20:48 -04:00
dd97478175 fix(media-use): remove clicks and ~25ms gaps at every transcript-cut splice (#4179)
* 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>
2026-09-22 20:28:50 -04:00
Miguel Ángel e5a0370727 feat(studio): transition seams blur and carry a badge (#4280)
* feat(studio): transition seams blur and carry a badge

* fix(studio): keep labelled transitions on one row

* fix(studio): render transition crossfade zone

* fix(studio): gate seams on transition labels

* fix(studio): bridge GSAP transition markers to clips

* fix(studio): clear transition seam CI failures

* fix(studio): register transition frame classes
2026-09-22 08:59:53 -04:00
Miguel Ángel 05d09a7452 chore(registry): remove low-use catalog items and variant tails (#4274)
* chore(registry): remove low-use catalog items and variant tails

* fix(registry): remove stale references to deleted catalog items

* style: format catalog fixture

* fix(ci): use surviving catalog preview canary

* chore(ci): allow intentional catalog deletions

* fix(ci): scope fallow audit for catalog fixtures

* chore(catalog): regenerate local search vectors

* chore(catalog): update vector revision

* fix(catalog): validate registry set delta
2026-09-22 07:04:54 -04:00
Miguel Ángel 3091dcb787 feat(cli): route media search through media-use (#4230)
* 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.
2026-09-21 18:35:47 -04:00
Miguel Ángel 0c158be165 feat(studio): edit a clip's speed ramp with a rate lane and presets (#4153)
* 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
2026-09-20 12:21:47 -04:00
Miguel Ángel fa44d7621d feat(core): a composition with no authored length takes it from its clips (#4174)
* 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
2026-09-19 11:27:33 -04:00
Miguel Ángel ac0d8ced2a fix(cli): timeline lists every clip of a kind in one flat list, nested ones included (#4173)
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.
2026-09-19 10:36:30 -04:00
Miguel Ángel 7597b80c95 fix(cli): timeline reports real media durations instead of unauthored (#4169)
* 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.
2026-09-19 09:27:28 -04:00
Miguel Ángel 2f24573395 docs(skills): an agent adds media with the duration Studio gives a dropped file (#4144)
* 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.
2026-09-19 09:07:32 -04:00
Miguel Ángel 09aadd86a5 feat(core): video, audio and image length come from one shared resolver (#4166)
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.
2026-09-19 08:01:28 -04:00
Miguel Ángel 0a5c235173 feat(cli): hyperframes timeline prints a project's tracks and clips (#4138)
* 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.
2026-09-19 04:27:45 -04:00
752b552789 feat(registry): blur an element by marking it, not by calling a function (#4045)
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>
2026-09-18 22:55:15 -07:00
Miguel Ángel 1a9668b6d6 docs(skills): the agent presents a tailor-made storyboard instead of a Studio board (#4150)
* 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.
2026-09-19 01:07:24 -04:00
Miguel Ángel a25da94b77 refactor(studio): remove the storyboard view and its Storyboard/Preview switch (#4140)
* 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
2026-09-18 23:47:12 -04:00
Miguel Ángel 2db126d4c8 docs(skills): one owner doc for everyday edits and a hyperframes-studio skill (#4125)
* 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
2026-09-18 23:19:40 -04:00
Miguel Ángel ae0e8fcae1 fix(skills): teach and ship the remotion translation as sub-compositions (#4141)
* 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
2026-09-18 21:01:59 -04:00
Miguel Ángel f2976536de fix(skills): mount music-to-video motion primitives as sub-compositions (#4136)
* 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
2026-09-18 20:57:54 -04:00
Miguel Ángel 6995a4c2f7 feat(studio): the agent sees every timeline clip, audio included (#4123) 2026-09-18 18:47:32 -04:00
Miguel Ángel e36a53d6b6 fix(core): hold render-ready on declared compute, not just adapter loaders (#4065)
* 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.
2026-09-18 11:12:44 -04:00
Aditya Poudyal e4b1832fad feat(cli): add hls output format (#4022) 2026-09-16 21:09:14 -04:00
miga-heygenandMiguel Ángel 3704863d96 feat(telemetry): per-tag, provenance, and failure-taxonomy properties on render events (#4004)
* 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>
2026-09-16 12:37:57 -04:00
heygengenesis[bot]andmiguel.sierra 5d2478c6fa fix: report HDR auto-promotion (#3985)
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>
2026-09-16 06:13:24 +00:00
Miguel Ángel f059a6eb53 feat(cli): default BeginFrame, agent blank, and looks quality (#3927)
* 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
2026-09-13 21:44:19 -04:00
Miguel Ángel 95bea1631f refactor(skills): keep hyperframes-core as the HTML contract (#3928)
* 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.
2026-09-13 21:20:58 -04:00
heygengenesis[bot]andmiguel.sierra 4aa6e17b76 docs: fix skill documentation cross-references (#3920)
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
2026-09-13 03:19:21 +00:00
Vance Ingalls b848d81681 Merge pull request #3878 from heygen-com/docs/require-voiceover-carve
feat(audio): require the voiceover carve, default strength 0.8, slower release
2026-09-11 14:20:04 -07:00
Miguel Ángel 849ce0176a fix(catalog): name the better search tier when a search finds nothing (#3872)
* 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.
2026-09-11 17:14:10 -04:00
Vance IngallsandClaude Fable 5.1 06897fe0be style(audio): format carve.test.mjs and re-sync the skills manifest
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>
2026-09-11 14:08:10 -07:00
Vance IngallsandClaude Fable 5.1 e488871ce1 fix(audio): pin the slower release and the carve default, fold the requirement, sync the manifest
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>
2026-09-11 13:57:07 -07:00