* feat(cli): init --keep-original copies a clip as is, without transcoding
`hyperframes init --keep-original` copies the --video file under its own
name and wires it into the project the way a web-safe clip is wired today:
no transcode and no prompt, interactive or not. Default behaviour is unchanged.
Theora leaves init's web-safe codecs and joins the preview proxy's
browser-hostile codecs, so init converts it and the preview proxy serves an
H.264 copy instead of a blank picture.
* fix(studio-server): keep the codec table's comment share at its base level
* feat(cli): save check, snapshot and render artifacts per revision
* fix(cli): keep clean --snapshots inside the project
A symlinked .hyperframes or artifacts folder was followed, so the command
deleted snapshot and check files outside the project.
* test(cli): keep check, snapshot, and render successful when recording fails
A held history or a corrupt output registry warns and leaves the command result alone.
* fix(cli): drop snapshot records when a replaced run is not recorded
A reused --output rewrites the files even when history is busy.
Those records are removed so the old revision does not keep the new pixels.
The preview checkpoint stops waiting after five seconds.
A bad PNG header no longer fails the snapshot.
* fix(studio-server): keep the video-state comment share from rising
The forget helper's doc comment pushed the file above its base share.
* fix(cli): post the preview checkpoint and keep cleanup inside artifacts
A preview checkpoint with no body was sent as GET, so Studio never ran it.
Cleanup now requires the real path to stay under the artifacts folder.
A failed follow-up drop does not warn twice.
The studio-server unpacked budget covers the record-dropping helper.
* docs(cli): shorten the snapshot skill notes
The command examples already show range and composition.
The prose only keeps the output path and the flags that are easy to misuse.
* chore: leave package headroom for artifact cleanup
* test(cli): compare artifact paths portably
* style(cli): format portable artifact assertion
* feat(cli): guard history turns with --exclusive, --who and --if-open
* fix(cli): make exclusive history turns atomic
Re-read the open turn only after acquiring history ownership so
concurrent exclusive begins cannot both claim the same project.
Cover the race with a deterministic two-process regression test.
* fix(cli): refuse a second exclusive turn while a preview is open
The preview's begin window now rejects another agent's live turn, and an
idle turn no longer blocks exclusive begin. The two-process test waits on
pids collected from claim drafts instead of seeing both files at once.
* feat(cli): scope check to a time range or a set of components
* fix(cli): keep ranged check gates truthful
Refuse ranged caption and frame gates when none of their seek times
falls inside the requested range. Make ranged motion reporting,
overlap timestamps, filtering tests, and scope documentation match.
Add the four commands that `hyperframes --help` lists but the CLI reference
page left out, with their options taken from each command's args and
examples checked against a sample project.
Closes#5386
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): media-treatment applies one treatment to every matched image or video
* fix(cli): media-treatment reports its action from every target and documents the per-element merge
* fix(cli): media-treatment patches each match by its stable id so one write cannot redirect another
* fix(cli): media-treatment sets every match in one parse instead of re-finding elements
* fix(cli): media-treatment refuses media that is not an HTML element, as before
* chore(skills): refresh the media-use manifest hash for the treatment reference edit
* fix(cli): check passes a still title card whose timeline follows the seek
* fix(cli): check warns on a still frame and fails only when an animation is stuck
* fix(cli): count a seek clock once per sample and point the error at the registered timeline
* fix(cli): judge a finished animation by its own completion state, not its end time
* test(cli): pin a Web Animation paused at its end as finished
* fix(cli): judge a reversed animation's end in its own direction, and read plain timelines
* test(cli): pin a plain timeline held at its end as finished
* test(cli): keep an unfinished plain timeline beside the finished one
* fix(cli): skip a timeline whose time cannot be read instead of calling it stuck
* fix(cli): skip an animation whose time is not a plain number, such as a scroll-driven one
* fix(cli): a partial timeline with plain values no longer stops the browser checks
* feat(cli): let transcribe --to write srt or vtt straight from a media file
* fix(cli): fail a missing transcribe --output folder before decoding
* fix(cli): reject a transcribe --output that names a folder
* test(cli): check CJK caption grouping on the parsed cues
* fix(cli): keep transcribe --output from overwriting the media or its transcript
- Refuse an --output that resolves to the input file or the transcript.json the run writes,
before the engine starts.
- When the caption file cannot be written after transcribing, say the transcript was saved
and name the caption path and reason, instead of reporting a failed transcription.
- Docs: a one-run export groups cues for languages without spaces; exporting the saved
transcript later may split them differently.
* fix(cli): refuse caption paths that alias the media or transcript
- The overwrite check compares files, not path text: an existing target is matched by device
and inode, a new one by its real folder and its name ignoring letter case. Links, folder
aliases such as /var and /private/var, and other letter case are all refused.
- The check also covers the default caption path, so exporting transcript.srt to srt no
longer rewrites its own input.
- A trailing -o with no path fails before transcribing.
- One constant names transcript.json for the engines, the import and the check.
* fix(cli): refuse caption links to the transcript before it exists
- A caption path that is a link is followed to where the write would land, so a link to the
transcript.json the run is about to create is refused before transcribing.
- A test pins the folder letter-case check on a case-insensitive disk, and says which branch ran.
* fix(cli): check the caption file against the transcript right before writing it
- Right before the caption write, by when the transcript exists, a destination that is the
same file as the input or transcript.json is refused. The OS resolves every link, relative
link and alias, so the hand-written link following is gone.
- The run keeps the transcript and says the caption file could not be written and why.
An export whose caption write fails now says so too, instead of crashing.
- The check before transcribing stays as an early refusal for the common cases.
* fix(cli): compare caption paths by name when the disk reports no inode
- Some network shares report inode 0 for every file; such a pair is compared by folder and
name instead of being called the same file. The check before transcribing and the one at
write time now share one comparison.
- The link-through-a-file tests accept ENOENT as well as ENOTDIR, the code Windows reports.
* fix(cli): resolve both caption paths fully when the disk reports inode 0
- When either file reports inode 0 and both exist, compare their fully resolved real paths,
so a link to the media or the transcript is still refused. The folder and name compare
stays for a path that does not exist yet.
- Tests zero the inode on every path, on the caption path only, or on the protected file only.
* test(cli): say plainly why the caption-write test accepts two error codes
* feat(cli): models list shows which speech models transcribe can use
* refactor(cli): models list and transcribe share one Parakeet runner check
* fix(cli): models list skips whisper links transcribe would download again
* style(cli): keep models list comments under the package share
* fix(cli): enforce capture screenshot limits and report interruptions
* refactor(cli): split screenshot step out of capture animation phase
Keeps the screenshot limit outside the mirrored core-extraction argument
list and moves scroll screenshots into their own step, so both stay under
the audit's duplication and complexity gates with no behaviour change.
* fix(core): write only authored hue curves, so a single hue curve passes lint
* fix(cli): apply over a grading saved with empty hue curves instead of refusing it
* fix(core): read grading properties from computed style, and stop advising an inline start
* fix(cli): preserve validation during legacy hue repair
* fix(cli): preview grading writes without apply
* fix(auth): accept host-managed HeyGen access tokens
* fix(auth): share environment header validation
* docs(auth): condense credential resolver guidance
* fix(auth): report the account a browser login just signed in
Identify the account from the tokens the login issued, not a fresh credential
lookup that an environment token or API key outranks.
* docs(auth): list the host access token in the CLI credential order
* fix(cli): name a rejected host access token on publish
Publish told a host-token user to log in again, which cannot help while the token outranks the login.
* docs(auth): show the media credential order main resolves
* fix(cli): warn at logout that a host access token still signs commands
One map of environment credential variables, owned by the resolver, now feeds the logout warning and both publish messages.
* docs(auth): scope the media credential order to the audio scripts
* refactor(cli): read every env credential name from the resolver's map
* docs(skills): ask agents inside a host app to report its friction as feedback
* feat(cli): send missing features and host-app friction as ratingless feedback
* fix(cli): keep the rating error, refuse blank reports and document ratingless feedback
* docs(skills): leave names and figures out of missing-feature reports
* docs(skills): keep anything private out of missing-feature reports
* fix(check): see color, filter and media time under seek, and name data-no-timeline for stills
* fix(check): warn instead of fail when only audio advances, and judge text overlap on glyphs
* fix(check): judge glyph overlap by the glyph box, and see capitalized words and one-side borders
* fix(check): capitalize past leading punctuation, test outline color, trim a comment
* fix(check): capitalize words the way Chromium does, so digit-led words stay lowercase
* fix(cli): let parakeet-mlx detect the language and name the Parakeet install under auto
* docs(cli): say what --language does under each engine
* docs(cli): whisper transcribes as the --language, it does not filter
* chore(cli): drop a comment the install-hint condition already says
* feat(cli): feedback says whether a person or an agent wrote it, with the email an app attaches
* refactor(cli): split the .env loader so each part stays simple
* fix(cli): a broken .env loader fails loudly; only a missing .env is ignored
* fix(cli): distinguish a skipped browser check from a genuinely clean one
`hyperframes check`'s runtime/layout/motion/contrast JSON sections reported
the exact same ok:true/zero-findings shape whether the browser session
actually ran and found nothing, or never ran at all (a blocking lint error,
a lint-step crash, or an exception thrown before the audit could start all
took the same "empty result" fallback). Only the top-level ok field and an
undocumented layout.duration === 0 tell separated the two cases apart.
Add a skipped boolean to CheckBrowserResult, set at the only two places one
is constructed (false in the real audit result, true in the empty-result
fallback), and mirror it to a new top-level browserSkipped field on
CheckReport. The human-readable report now prints a warning when the
browser never ran; the JSON envelope carries the same signal for callers
that only check individual sections.
Co-Authored-By: Miguel Angel <miguel.sierra@heygen.com>
* docs(cli): document browserSkipped in the check --json envelope
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* refactor(cli): trim browserSkipped docs and cover the linter-crash skip path
* chore(skills): refresh the hyperframes-cli manifest hash
* refactor(cli): drop the browserSkipped type comments to keep the comment share flat
---------
Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
* fix(cli): fingerprint textual sweep state
* fix(cli): share one motion classifier between sweep guard and liveness
The frozen-sweep guard (sweep_static) and keepsMoving liveness (motion_frozen)
each owned their own definition of "what counts as motion": the sweep
fingerprint in layout-audit.browser.js read nine channels while
motion-sample.browser.js compared box + opacity only. They meet in one decision
in checkPipeline.ts, so a fixed-width attr() countdown was accepted by the
sweep guard and still reported frozen by liveness.
Move the classifier into motion-signature.browser.js, injected before both
samplers, and make it the single owner:
- Per-element channels (box, opacity, font-variation axes, clip-path, direct
text, form-control state, ::before/::after content, media pixels) as a
reader list so further channels (e.g. SVG dash) plug in.
- Liveness consumes the same signature with 2px / 0.08 bucketing; the sweep
guard consumes it exact. motion-sample throws at install if the shared
script is missing.
- Counter state is a composition-level channel over box-generating elements
and root ancestors. display:none subtrees generate no boxes and are
excluded, and only declarations naming a counter that some painted
generated content (::before/::after/::marker on a non-ignored, paintable
host) consumes are folded in, so a decoy hidden counter cannot make a
frozen composition read as live.
- clip-path is a channel, replacing the fingerprint's former reliance on the
clipped-away visibility probe, so a wipe over a static box still counts.
- data-layout-ignore layers are excluded from the signature but not from the
per-selector `visible` bit reported for explicit motion assertions.
- Root resolution for the "*" liveness scope now matches the sweep guard and
the root element is part of its own signature.
Tests: motion-signature.browser.test.ts (happy-dom, per-element fake computed
style so ancestor handling comes from the classifier) and
motion-signature.browser.chromium.test.ts (real Chromium via puppeteer-core;
skips without a system browser) pin every branch: attr() substitution,
display:none decoys, named-counter gating, ::marker, zero-box hosts, ancestor
counters, textarea/indeterminate, root text, clip-path, ignore layers, and
sweep/liveness agreement on the countdown fixture.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli): prune unrendered content from the motion signature and measure asserted scopes
The shared motion classifier pruned only display:none subtrees, so text,
pseudo content, media pixels, and counter decoys inside skipped
content-visibility contents changed the frozen-sweep and liveness
signatures although nothing painted. Pruning is now platform-owned where
checkVisibility exists (display:none, skipped hidden/auto contents; a
display:contents host keeps its painting pseudo-elements), with the
computed-style fallback for happy-dom. A content-visibility:hidden host
signs its own box, opacity, clip-path, font axes, and widget state, but
not its text, pseudo content, control value, or media pixels; hosts where
size containment does not apply (non-atomic inline, display:contents,
table and internal table boxes, ruby) skip nothing. isVisibleElement
regains the checkVisibility floor layout-audit uses on its opacity-floor
path, so an asserted selector inside skipped contents reports hidden.
The layout-audit opt-out no longer applies to the element passed as the
signature root: a keepsMoving scope that is itself data-layout-ignore is
measured (an assertion naming an element outranks the opt-out) instead of
producing an empty signature that motionAudit reports as a missing
selector. Opt-outs inside the scope still apply.
motion-sample defers the missing-classifier error to the first call so
the driver's evaluate surfaces it instead of an opaque "is not a
function" page error; round() is shared.
The real-Chromium suite is opt-in on Windows (HYPERFRAMES_BROWSER_TESTS=1):
in the shared Windows package lane the runner's Chrome did not launch
within the package hookTimeout, and the suite already runs on Linux CI
where the system Chrome is found. The launch hook has its own 120s ceiling.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli): prune display:contents in skipped contents; table-cell skips, caption paints
A display:contents child of a content-visibility:hidden host has no box
of its own, so the platform check could not tell it apart from an
ordinary display:contents owner and it stayed a counter consumer: a
change of the owner's counter-reset altered the sweep signature although
nothing paints. The parent's skipsContents verdict now prunes it; boxed
descendants keep being decided by the platform.
NOT_CONTAINABLE_DISPLAY had table-cell and table-caption inverted
versus Chromium 152, where a table-cell host skips its contents and a
table-caption host paints them.
Four Chromium pins: the display:contents-in-skipped-contents counter, a
table-cell host's own text and pseudo content (frozen), a table-caption
host's own text and pseudo content (motion), and a counter reachable
only through a skipped host's hidden ::marker / ::after (frozen; also
asserts the skipped child is not visible for motion-sample).
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* chore(cli): trim sweep motion comments to the comment ratchet
* chore(cli): shorten sweep motion comments to the merge-hook limit
---------
Co-authored-by: miga-heygen <miguel.sierra_miga@heygen.com>
* feat(cli): one owner for init media placeholders, audio stays on the video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(cli): init keeps a video's sound on the clip; silent files scaffold muted
Supersedes the patchVideoSrc changes in #3977 and #3942 and carries their tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: miga-heygen <miga-heygen@users.noreply.github.com>
Co-Authored-By: kapelame <kapelame@users.noreply.github.com>
* feat(registry): example templates keep the a-roll's sound on the video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(lint): video audio rules recommend keeping sound on the video
Update video_missing_muted and video_audio_double_source message and
fixHint text to recommend the new default: keep audio on the video with
data-has-audio="true" (not muted). Tests verify the new wording is in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(parsers): a video is either audible (data-has-audio) or muted, never both
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): follow the compiler's audio rule in the audio-track toggle
Confirm before background removal drops a video's sound.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): hyperframes-core teaches sound on the video by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): workflows and scaffold guidance keep a video's sound on the clip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: footage with sound keeps it on the video clip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): init from-file a-roll is timed, muted strip is attribute-exact
The from-file a-roll video gets data-start="0" so a scaffold lints clean, and
the orphaned audio-only slot comment is dropped. setVideoAudio now walks the
tag attribute by attribute, so a class value containing the word muted
survives. The ffprobe-missing warning says the video is assumed to have sound
and how to switch to muted, and DEFAULT_META notes the failure surfaces at
render until lint support lands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep has-audio in step with the muted toggle, test cutout confirm
Muting a video now clears has-audio and unmuting sets it to true, in both the
Design and flat media panels, matching the Has audio track toggle. Audio
elements are unchanged. Adds coverage for both panels plus the cutout
window.confirm guard: declined leaves src alone, accepted swaps src and mutes,
a silent video never prompts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(parsers): updateElementInHtml hasAudio only applies to videos, true or false
An audio element or an undefined hasAudio no longer picks up a muted attribute;
only an explicit false on a video writes muted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: video_missing_muted rule text, silent-footage notes, consistent audible examples
lint.mdx and cli.mdx describe the current video_missing_muted rule instead of
the retired unmuted-video wording. The editing recipes say how to adapt for
silent footage and require the outgoing clip's sound to end at a J-cut. The
Remotion media example shows the audible mapping consistently, data-attributes
states that has-audio is required unless muted, and the hairline frame note no
longer claims the video is muted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): encode init media filenames and probe --audio duration
Function replacers stop dollar-sign pattern expansion in filenames; src is percent-encoded.
--audio now passes the probed track length as the composition duration in both init paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(lint): audio-state-aware media rules and time-aware double-source check
video_missing_muted honors any explicit data-has-audio.
video_audio_double_source only considers audible videos and only fires
when the same-src clips overlap in time.
duplicate_audio_track now covers audible videos.
audio_file_without_element no longer claims silence when an audible video
carries the sound.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): audio-stream check for remotion ports, followable J-cut recipe
Remotion Video maps to data-has-audio only when the file has an audio stream.
The J/L-cut recipe now shows both shots with muted pictures and separate audio.
embedded-captions removes the audio.mp3 scratch file it extracts.
skills-manifest.json regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(lint): trim comments to satisfy the comment ratchet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): atomic muted edits, cutout keeps sound on a linked audio
Muted and Has audio track commit one batched write (one persist, one
undo entry) and remove data-has-audio instead of writing an empty
marker. Unmuting a video probes its src: audible -> true, silent ->
false, probe failed -> absent. Remove background on an audible video
inserts a linked sibling <audio> with the original sound in the same
write, replacing the drop-the-sound confirm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(producer): skip hidden videos when auto-adding video audio
A video hidden in Studio is dropped by the mixer, so its src never reached
existingAudioSrcs and the render re-added it at full volume. The video parser
now records the shared hidden predicate and the auto-detect step skips hidden
videos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep useDomEditSession under the 600-line cap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: miga-heygen <miga-heygen@users.noreply.github.com>
Co-authored-by: kapelame <kapelame@users.noreply.github.com>
* 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
* 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
* docs(cli): render help says when page-side compositing is actually used
* docs(cli): page-side compositing help names webm and mov and the fast-capture flag
* docs(cli): say plainly that gif keeps page-side compositing
* fix(cli): commands exit without waiting on update checks or telemetry
The update and skills checks run in a detached child that refreshes the
caches for the next run; the parent only reads the caches. finalizeCli no
longer awaits the telemetry flush: the exit handler flushSync() delivers
what is queued. The events beacon, feedback and figma error paths stop
awaiting flush() too.
* fix(cli): retry a failed background check at most once an hour
Before starting the background worker, the CLI stamps a per-check attempt
time and skips a check it attempted within the hour, so a dead network no
longer starts a worker on every command. The stale-pin notice writes onto a
fresh read, detached spawns set windowsHide, and a test ties the worker path
to its build entry.
* fix(cli): a failed-check stamp dated in the future no longer silences update checks
* 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
* fix(cli): Studio's catalog install goes through add, so it records the item and honours paths
* fix(cli): Studio's catalog lists the project's registry and install paths survive symlinks
* refactor(cli): name the Studio catalog's registry option plainly
* feat(cli): registry_item_added says whether add or Studio installed the item
* fix(cli): dropping the same catalog block in Studio twice installs it again
* chore(cli): drop a comment that restates add's source option
* fix(cli): Studio's re-record finds lock entries by their resolved path
* test(cli): the symlinked-folder install test checks the resize happened
* fix(cli): Studio mounts the installed item's own file by the path add recorded
* fix(cli): Studio's install never hands back a file the project kept for its own edit
* 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
The catalog already loads every item's manifest, preview included, but the
--json rows dropped it, so an app listing the catalog had no way to show an
item before add downloads it. Rows now carry preview when the item has one.
* fix(cli): install onnxruntime-node and google-genai on first use
Both carry deprecated transitive dependencies (boolean, node-domexception)
that showed up as npm warnings on every npx hyperframes install. They move
to devDependencies (types and the Intel-binding check still need them at
build time; the bundler externalizes them) and load through a new
on-demand loader that installs into the CLI's own cache dir the first
time a feature needs them, with one stderr line, no prompt, and a
manual-install command named in the error if the network is unavailable.
hyperframes doctor lists each as installed (with version) or
not-installed-yet.
* fix(cli): sweep stale install staging and bound network retries
* fix(cli): optional-package install only sweeps staging dirs whose process is dead
Staging dirs now carry a per-call suffix after the pid, and the sweep skips any dir
whose pid is alive, so concurrent first runs no longer delete each other's install.
The install error suggests the network only for network failures, and the manifest
path has one owner. install() takes an injectable npm runner so tests drive the real code.
* fix(cli): optional-package sweep also clears old-format staging dirs and tests a live foreign dir
The concurrent-callers test shared one pid, so it never exercised the sweep rule; a live foreign
staging dir test now does. Old-format dirs of a dead pid are swept, and a failed cleanup no
longer aborts a viable install.
* fix(cli): optional-package sweep names a stale dir it could not remove
A locked leftover is still skipped so the install proceeds, but the warning makes it visible.
* docs(cli): shorten the install docblock to the comment limit
Comment-only change.
- HANDOFF section 4a: the single-worker fallback applies on default routing
(non-DE router and the manual opt-in); a DE-router plan keeps its
precomputed fallback. Adds the review's verdicts: three confirmed defects
fixed, one latent hardened, one split accepted, one low left open (a worker
wedged before its first capture is bounded only by player-ready and CDP
timeouts and is not retry-eligible).
- cli.mdx: the parallel-stream paragraph no longer promises a single-worker
retry for every case.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- tests/long-form/README: the two-clip in-flow fixture, the per-frame luma
check over clip B, the 2026-09-18 matrix (branch -w 1 / -w 2 disk / -w 2
streaming / absolute CSS / published 0.8.44 all 0 of 100 black; 0.8.35 and
0.8.36 99 of 100 as the positive control; fixed in 0.8.37 by #3893), and
why the multi-worker trace's captureMode was not the engine's mode.
- HANDOFF: R3 row now reads "hardening built"; new section 4a lists the
guards, the 694 gate result and what the flip still needs; counts refreshed.
- cli.mdx: the parallel-stream paragraph says the opt-in path now retries a
stalled pool or a mid-stream encoder exit on a fresh single-worker session.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three workers produced a byte-identical file to the single-worker segmented
render at 1.6x the speed, which is the property the gate exists to check:
segments finish out of order, so a mismatch would mean the concat list had
picked up completion order instead of the plan.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Explains which routes still write raw RGBA frames to disk and what the
preflight error is telling the user, and records that
PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS (including its 0 = "never
stream" setting) is inert unless the new enable flag is set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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
## What
`keyframes --shot --layout strip`'s help text, its type's inline doc comment, and the CLI reference docs all described `strip` as an unqualified "filmstrip by time." The tool doesn't actually do that for the overwhelmingly common case.
## Why
A real per-time pixel filmstrip is only produced when the sampled selector is an SVG element (gated by an internal shape check — `typeof element.getBBox === "function" && typeof element.getScreenCTM === "function"`). Any other selector — including every nested sub-composition host, which is always a `<div data-composition-src>` — silently falls back to one live screenshot plus vector position markers instead.
This isn't a capture bug: for a non-SVG selector, real per-time pixel compositing was never implemented, only 3D bbox/marker sampling. But the documented behavior over-promised what the tool does, so a user following the docs on the common case (a DOM/sub-composition selector) sees root captions and empty image boxes where they expected the nested composition's actual content to move across frames — the diagnostic strip is misleading, even though the real render is correct.
## How
Reworded all three descriptions (CLI help text, `ShotOptions.layout` TSDoc, and the reference docs table) to state the SVG-only condition and the DOM/sub-composition fallback explicitly. No behavior changed — this is a documentation-accuracy fix, per the ticket's own framing that a doc-only fix fully resolves the reported symptom (a silent, misleading omission) for a P3.
## Testing
Added a test asserting the CLI help text no longer makes the unqualified "filmstrip by time" claim and does disclose the SVG-only condition — guards against a future regression back to the misleading wording. Verified RED (fails against the pre-fix string) and GREEN (passes after the fix) via a local before/after comparison.
- `bunx vitest run src/commands/keyframes.test.ts src/commands/motionShotLayout.test.ts` — 46/46 passing
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean
- Full CLI suite (excluding known-broken-in-sandbox browser-launch tests, unrelated to this change): 217 test files / 3025 tests passing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Rebuilding footage means checking the render against the source at the same timestamps. `--against <video>` saves the reference frame at every captured time (frame-exact seek: -ss after -i) plus a labelled render|reference pair sheet built with the existing contact-sheet helper. Reference frames and pair sheets are derived artifacts, kept out of the snapshot list like contact-sheet.jpg. No new dependencies.
Capture drops assets for four reasons and reported none of them, so a folder
with thirty images and a folder truncated to thirty images were the same
object. Every drop site was a bare `continue`, `break`, `return null` or an
empty `catch`, and the only signals downstream were two hand-written warning
strings that fired when the budget was already gone before a download pass
started, which is the one case where the pass could not say how much it lost.
`downloadAssets` and `downloadAndRewriteFonts` now return an `AssetDropCounts`
tally beside their result, incremented at the single line that performs each
drop. `capture --json` carries it as `dropped`; the human summary prints a
`Dropped:` line when it is non-zero.
Four reasons, three decisions and one failure:
size-floor fetched, then judged too small to be a real asset
budget-exhausted the post-navigation clock ran out before this one
cap-reached 30 inline SVGs, 30 fonts, or 6 faces per family
unavailable the request or the write failed
A break now counts everything it did not reach rather than the one it stopped
on, because "how many did we lose" is the question and one is never the answer.
The two budget warnings are gone. Both existed only to cover the case where
the budget ran out before a pass was called, so both passes are now called
unconditionally: a zero budget makes each loop break on its first item and
record `budget-exhausted` for the rest, which costs no network and produces a
real number instead of the word "some". The single remaining warning is derived
from the tally, so the prose and the count cannot disagree.
Measured on a live capture of a large marketing site:
default budget 232 kept, 91 dropped (39 size-floor, 20 cap-reached,
32 unavailable)
15s budget 30 kept, 299 dropped (279 budget-exhausted, 20 cap-reached)
Same page, same command, and until now both runs described themselves the same
way.
A card centered at y=.860 can still cover the painted V2A pill. Intersect the element's getBoundingClientRect with the keepout instead of testing whether its center sits inside the band.
* feat(cli): add normalize-audio to match one clip's loudness to another
Measures two authored `<audio>` clips with FFmpeg's integrated EBU R128
loudness and writes the target's matching `data-volume`, leaving the
reference untouched.
The measurement is bounded to the window the composition actually plays.
`data-end` bounds a clip's timeline window just as `data-duration` does, and
`-ss`/`-t` belong before `-i`: after it they bound the OUTPUT, and with
`-f null` there is none, so ebur128 keeps integrating past the clip. On a
fixture whose played window is -61.8 LUFS inside a file that measures -27.9
whole, either mistake reports a loudness the composition never plays and
"corrects" an already-matched clip by tens of dB.
Two EBU R128 passes run between reading the composition and writing it, each
bounded only by a two-minute timeout, and the skill docs tell agents to keep
Studio open meanwhile — so the attribute patch is re-applied to a fresh read
and written through a temp file and a rename.
Under `--json` the failures are documents too: an agent doing
`JSON.parse(stdout)` on a bare error line throws. A pair needing more than the
+12 dB ceiling has a source-file problem rather than a mixer one — mixer gain
raises the noise floor with the signal — so the refusal names the remedy.
* fix(cli): validate --tolerance before paying for the measurement
Each EBU R128 pass is bounded at 120s and normalize-audio runs two, so
parsing the argument afterwards made a typo'd --tolerance cost both of them
before failing on something that was wrong from the start.
Not pinned by a test: the ordering is internal to the command and neither it
nor the parser is exported, so covering it would mean restructuring for a spy
rather than asserting the behaviour.
* docs(cli): restore the blank line between the preview and normalize-audio sections
Lost when I resolved the rebase conflict against the background-preview docs
by hand instead of letting the formatter near it. oxfmt --check failed on the
one file, which fails Preflight — and because preview-parity needs Preflight it
skipped, and the preview-regression gate fails closed on a skip, so a missing
newline read as a preview defect.
The quieter half: the same needs chain meant the required Test context was
never created at that head. Not failing — absent, so there was no test signal
at all on the PR.
`--background` was rejected outside the embedded server. It now re-execs the
CLI in foreground, which makes it mode-agnostic by construction: whichever
server the child resolves to serves the config endpoint the readiness probe
looks for. `--foreground` is its counterpart, for a non-interactive shell that
wants to stay attached, and a bare launch keeps the same promise — attached in
an interactive terminal, managed in an agent session.
That generalization exposed an existing hole. Local-studio mode runs Vite with
the studio package as its cwd and needs that package's own Vite config, which
the published tarball does not carry, but resolving the package was treated as
proof the mode was usable. An npm-installed studio therefore took a path that
can never come up — previously a clear error, now a ten-second silent timeout.
The predicate becomes "can this studio actually be served", so a published
install falls back to embedded mode, which works.
Over the 1k line budget at ~1.3k. The overage is one command file and its
tests carrying one invariant, and the seam that would split it further is
inside a single request-handling function — a split there would produce two
PRs neither of which starts a preview on its own.
* feat(cli): track which registry items `add` installs
`cli_command` records that `add` ran and nothing about what it installed, and
the registry is served from raw.githubusercontent.com, which gives no per-item
counter either — so there is no way to tell which block or component people
actually pull, and no way to know what is worth building more of.
Emit one `registry_item_added` event per item written into a project, from
`runAdd` after the install succeeds. That is the single choke point: the bulk
`add <tag>` path re-enters it per item, and a failed or compatibility-refused
install throws before it, so a refused install is never counted as a download.
`requested` separates the item the user named from the transitive
`registryDependencies` pulled in behind it; without it a popular dependency
outranks everything that depends on it.
Item names are public registry identifiers, never user content or project data,
and the event goes through `trackEvent` — an install that opted out via
`hyperframes telemetry disable`, `HYPERFRAMES_NO_TELEMETRY` or `DO_NOT_TRACK`
sends nothing.
* test(cli): cover `add` telemetry end to end against the built CLI
The unit tests assert the emit seam and nothing past it. `shouldTrack()`
short-circuits whenever `isDevMode()` is true, and that is true for any `.ts`
entry, so under vitest a real event and no event are indistinguishable and the
transport is never exercised at all.
Drive the built CLI instead and assert on the HTTP body it actually produces:
one event per installed item, the dependency reported with `requested: false`,
an opted-out install sending no request at all (not merely one without this
event), and a refused install counting nothing.
Two fixtures, because neither case is reachable through the real registry. The
registry origin is a first-class project setting, so a local one supplies the
`registryDependencies` edge that no shipped catalog item declares today; and
`globalThis.fetch` is wrapped to capture the batch rather than send it. The
faked 200 is load-bearing: only a failed flush leaves events queued, and only a
non-empty queue spawns the detached `flushSync` child that would bypass the
hook and reach production analytics.
Verified the check can fail — forcing `requested: true` for every item turns it
red on exactly the dependency assertion.
**The poster guard I added twice was unworkable and I never ran it.** It called
existsSync on docs/images/catalog/<name>.png. That directory is gitignored —
previews are generated locally, uploaded to the CDN and never committed — so the
check is false on every clean checkout and in CI. It would have stripped the
poster from all 168 pages, not the 13 with a missing file. It also referenced
REPO_ROOT, which does not exist in that file, so the script crashed on the first
item. I described this guard in two commit messages without once executing the
generator.
The poster is now gone entirely, which is the smaller and more honest fix. These
previews are autoPlay muted loop, so the poster is visible for a few hundred
milliseconds; 13 of the 168 files do not exist and the browser fetches the poster
before the video. Removing the attribute kills 13 x 403 and 168 needless image
requests, and there is nothing to keep in sync.
Also applied a complexity pass to the delivery encode:
- hasAudio() deleted. 17 lines and a spawnSync per item to choose between
"-c:a aac" and "-an". ffmpeg ignores -c:a when the input has no audio stream;
checked, exit 0, output carries no audio track.
- The 40-line spawn + Promise wrapper is execFileSync. Everything around it in
that script is already synchronous.
- The duplicated poster lookup is gone with the poster itself.
Net 76 lines lighter. Generator runs clean, emits 168 pages, carry-forward intact.
Miguel's three P2s and Rames' one finding on #2974, all verified in source
before changing anything.
**`render --json` is not a progress stream.** It prints exactly one
`batch-complete` document at the end (`batchRender.ts:408-418`), asserted as a
single `console.log` in `batchRender.test.ts`. Described as a final result now.
**The iframe drag example never captured the pointer.** `event.target` comes
from `iframe.contentDocument`, so `instanceof Element` against this window's
constructor is always false for a cross-realm node and `setPointerCapture()`
never ran — a pointer leaving the frame then loses `pointerup` and drag state
sticks. Structural feature detection instead, with the reason in a comment so it
does not get "simplified" back.
**The preview adapter example did not compile under strict TypeScript.** `comp`
was captured by the callback before definite assignment (TS2454). Optional, with
`comp?.dispatch(op)`.
**`ORIGIN_APPLY_PATCHES` was imported in a fence that did not use it and used in
fences that did not import it.** Imports do not cross fences, so both examples
were wrong in opposite directions. Rames found the pair in
`open-composition.mdx`; the same shape is in `composition.mdx:630`, which he did
not name. All three fences are self-contained now.
**And `types.mdx` claimed coverage it does not have.** It promised "every type
exported from `@hyperframes/sdk`" while omitting 13 of 42. Eleven are documented
on sibling pages, so the sentence now points at those instead of overclaiming.
The two with no home anywhere — `CompositionVariableType` and
`VariableUsageScan`, both re-exported from the barrel — have entries. The second
is worth having written down: `scanIncomplete` means `usedIds` is a lower bound,
so an id missing from it is unknown rather than unused.