* docs(telemetry): say signed-in usage is linked to your account, not anonymous
After hyperframes auth login the CLI sends a PostHog $identify with the
account email (username as fallback) and merges the install's earlier
anonymous usage; media-use does the same when it finds a signed-in
account. Several places still called all telemetry anonymous. Say it is
anonymous until you sign in, in the CLI reference (media-use, --skill,
feedback, events, telemetry), the hyperframes-cli skill references, and
the CLI help and notice strings.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(telemetry): identify signed-in CLI users by their HyperFrames user id
The CLI and media-use identified a signed-in install by account email, so
CLI usage landed on a different PostHog person than the same account in
the HyperFrames web app, which keys on toUUID(username). Identify by that
same sha256-derived id instead, and $set email plus heygen_username on the
person. heygen_username is the cross-project join column for the Snowflake
event table (HeyGen's own project stores it as `name`). An account with no
username still falls back to the lowercased email.
Notices and docs now say usage is keyed on an id derived from the username
with email and username stored on it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore(telemetry): trim comments to the package's comment share
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(telemetry): put heygen_username on every event while signed in
The Snowflake event sync may not carry PostHog person properties, so a
$set alone does not let an event row join across projects. The CLI now
adds the signed-in HeyGen username (raw, from ~/.heygen/credentials) to
every event and drops it once the credentials hold no user; media-use
does the same on each event it sends.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* ci: count auth/paths.ts as an edit-accuracy bench input
Telemetry now imports it to read the signed-in username.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(telemetry): stat and read the credentials through one file handle
CodeQL flagged the stat-then-read as a file system race.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(telemetry): key signed-in events on the account id; fix stale anonymity copy
Genesis review on #6703:
- telemetry --help listed 'Any personally identifiable information' as
never collected, and media-use's meta.md still said anonymous.
- Installs already identified by email under an older CLI never merge
into the new person, so send signed-in events with distinct_id = the
account id (back to the install id after logout), in the CLI and in
media-use, and stop claiming earlier usage always merges.
- A user block with no credential no longer counts as signed in.
- A UUID-shaped username passes through unhashed, as on the web.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cli): audio windows read beats off the whole clip; beats snap to the signal and mark silence
A film query that covers part of a clip measures beats, downbeats, bpm and sections on the
whole clip and cuts them to the window, as #6653 did for kind, so a window under 6 s still
has its beats.
One step after every provider ladder moves a provider's beats and downbeats onto the
signal's own beats within a quarter beat (the snapping api did alone) and marks each with
`silent`, true where the beat falls in silence. Beats are marked, never dropped; the JSON
change is the additive `silent` array.
— Jerrai
* fix(cli): beats with no pulse are unheard; a window with none answers no steady beat
Review fixes for #6692:
- The per-beat flag is now `unheard` (renamed from `silent`, not released): true in silence
or where no onset falls within 50 ms of the beat, so hiss, room tone or a held pad no
longer pass as beats.
- A window keeps the whole clip's beats only when at least two of its beats are heard;
otherwise it answers "no steady beat" as before, with no downbeats and no bpm.
- A window's bpm is the tempo of its heard beats, never the whole clip's.
- Comment lines trimmed so analyze.ts, run.ts and api.ts are at or under main's share.
— Jerrai
* fix(cli): a beat is unheard only where two beats in a row have no onset
Scored on the ground-truth set, flagging every onset-less beat also flagged heard beats the
onset detector misses: the drop downbeat after a snare roll, single beats under narration,
quiet beats late in a fade. A pulse gap now needs two onset-less beats in a row; hiss, room
tone and held pads span many beats and stay flagged, and silence is flagged as before.
— Jerrai
* fix(cli): a window with no pulse on the clip's grid reads its own beats, as before
Genesis found a window over a section far under the clip's loudest part answered "no steady
beat" where main, reading the window alone, found every beat: the clip's grid and onsets
skip a section that quiet (from about 40 dB under, for clicks). When a window keeps fewer than
two heard beats from the clip's grid, its beats, downbeats and bpm now come from the window
alone, as they did on main. Hiss still answers "no steady beat" on its own.
The hiss test's noise() repeats every 10,466 samples (0.22 s at 48 kHz), which the window's
own reading took for a pulse; that test now uses a xorshift generator.
— Jerrai
* fix(audio): unheard comes from level-free rhythm evidence, so quiet music stays heard
A beat was flagged by an absolute -50 dBFS silence and by onsets under 10% of the clip's
loudest hit, so music 30 dB or more under the loudest part read as unheard. Now:
- core localBeatEvidence reads the cached standardised onset curve over the 6 s around each
beat: the tracker's steady-beat confidence there (clarity at the local beat lag times the
beat's own lift) and the beat's lift over the span's mean.
- A beat is unheard in digital silence (-80 dBFS), or when its span keeps no steady beat
(confidence under 0.15) and its own onset does not stand out (lift under 1.5). Lift alone
keeps beats under narration heard, where syllables lower the span's clarity.
- A window keeps the clip's beats when at least two, and at least half, of them are heard.
— Jerrai
* fix(producer): a BeginFrame capture that draws no first frame in 20 s switches to screenshot
* test(cli): cover the Chrome launch failure messages a failed render prints
* fix(cli): snapshot reads https video in the Desktop app instead of failing at ffprobe
* fix(cli): give a remote clip download the same 30 s a stalled host had before
* fix(cli): a stopped model download resumes on the next run instead of starting over
* fix(cli): resume whisper models too and take over stale download locks safely
* style(cli): shorten a download lock doc line
A composition with no GSAP timeline was treated as zero length after an edit, which dropped preview audio, and an edit made during playback left the transport stopped. The runtime now keeps the declared length when it rebinds, and the soft reload resumes a transport that was playing.
* fix(studio): an undo or redo counts as a preview change from its key, not from its reload
* refactor(studio): count a history step from its key with the page flag on noteIdle
* fix(producer): a capture that landed every frame is not failed for the work after its last frame
* chore(producer): drop a comment the ratchet counts
* perf(producer): the audio true-peak check seeks to each segment instead of decoding up to it
Each parallel segment scan decoded the AAC from the start and trimmed after, so the last one decoded the whole file. It now seeks to a whole second one second before its segment and trims relative to it: the same peak, measured in about half the time.
* test(producer): the true-peak check's ffmpeg tests run in the integration lane
* fix(cli): check no longer treats a transparent canvas as covering the frame
* fix(cli): a canvas with an opaque CSS background still counts as covering
* fix(studio): a tab's save keeps the newer video version another tab recorded
The thumbnail index merge let this tab's ETags win for every source, including
ones it only read at startup, so another tab's move to a new version was undone
by this tab's next save: the next session validated the old ETag, missed the
disk and decoded once more. Now another tab's ETag wins except for sources this
tab recorded a new version of since its last save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): the ETag merge says whose moves it keeps in its names, not comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(studio): the stored-thumbnail reset forgets this tab's drops and moves too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
An inert <hf-color-group> holds a lane's data-color-grading; <video>, <img>
and <canvas> join with data-color-group. A member's own grading overrides
its group's. The runtime discovers members, regrades them when the group's
grading or a member's data-color-group changes, and ungrades them when the
group element or its grading goes, drawing only members on screen.
Render keeps group-graded video at full size like directly graded video,
and lint accepts (and still validates) grading on hf-color-group.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
bakeHfColorGradingCube evaluates the runtime shader's colour path on the
CPU (primary, tonal wheels, RGB and hue curves, secondaries, output LUT,
intensity mix) through the same 8-bit texture quantisation, and writes a
red-fastest .cube for ffmpeg's lut3d. Grades with spatial stages (grain,
vignette, effects) throw, as does a grade whose LUT was not passed in.
A Chromium test draws a 4,096-colour lattice through the real runtime
and compares every tile with the bake.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(studio): timeline thumbnails come back from disk in the next session
Reopening a project re-read every video's index and re-decoded every strip:
the frame cache and the source facts lived in memory, keyed by the session.
Now each source's facts (aspect, duration) and every decoded frame are kept
in Cache Storage under the file's ETag, the version the server says it is. A
new session checks each source once with a conditional one-byte read: a 304
means the stored frames are still that file's, and a strip they cover paints
without opening a reader. A new ETag drops what was stored for the old one.
Sources are dropped whole, least recently used first, past 256 MiB. A store
that is missing or fails (private window, quota) warns once and the strip
decodes as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a reopened project checks its videos again, and the disk index counts a frame once
The stored version a session validated was kept per video for the whole page,
so reopening a project after one of its videos was replaced at the same path
painted the old file's frames until a full reload. It is now kept per video
and project open (its contentVersion): each open re-validates once.
The index now counts a frame stored again under its name once, and merges
with what other tabs saved before writing, keeping caches this tab dropped
out, so the 256 MiB budget holds across tabs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): a font declared in a linked stylesheet no longer trips the bundler's font warning
* fix(core): drop the guard's explanatory comment
* fix(core): bundled fonts named only in a CSS font shorthand load
* fix(core): a font shorthand loads faces but never fails the cloud system-font check
* test(producer): regenerate static-volume-future-set for the font shorthand face
* test(producer): regenerate two more fixtures for the font shorthand face
* fix(cli): website capture stops inlining slow images and stylesheets and says what it skipped
* fix(cli): a capture ends at four minutes with its partial bundle unless told otherwise
* fix(cli): a capture's deadline starts once its browser is up, and the kept-image count is exact
* fix(cli): an image whose fetch the deadline cut off always counts as kept
* fix(core): a preview media load that fails is reported, not swallowed
* fix(core): an audio fed by source children reports its failed load; the shape lives in the protocol
* fix(core): the audio load watcher finds elements from any realm
* build(core): regenerate the inline position-edits render script
* fix(cli): check probes each audio file once, however many clips are cut from it
* fix(cli): drop an empty stray test file from the audio probe change
* fix(studio): a timeline edit on a clip the file lacks says how the clip was looked up
* fix(studio): name only the lookups a missing clip was really found by
* refactor(studio): split the missing-clip lookup words into small helpers
* refactor(studio): fit the missing-clip lookup words under the no-visible-change size
* fix(cli): a late teardown error no longer turns a finished command into exit 1
* test(cli): keep only the live invariant in the late-rejection test comment
* test(cli): shorten the late-rejection test comment
* fix(engine): late-starting MP4 and WebM videos render every frame the preview shows
The browser counts an MP4 or WebM from timestamp 0, but FFmpeg seeks from the file's
earliest stream start. A file whose first frame comes after 0 (a fragment cut from a
longer stream, an edit-list offset, a WebM with a start offset) was extracted early and
short, so renders tripped the video coverage gate. Every video seek now maps media time
to FFmpeg's timeline, and the video begins where the browser shows it.
* fix(engine): audio from late-starting MP4 and WebM plays when the preview plays it
The mixer seeked and trimmed audio by FFmpeg's clock, which starts at the file's earliest
stream. In an MP4 or WebM whose streams all start after 0, the browser plays that audio
later, so the render heard it early by the same amount. The mixer now maps media time onto
FFmpeg's clock with the same offset frame extraction uses.
* fix(engine): a late-starting file's picture shows on the clock its audio plays on
* test(producer): vfr-screen-recording baseline shows each frame at its timestamp
* fix(audio): a sound is voice only with a voice-range pitch, judged on the whole clip
Two ways the kind guess called a non-voice sound voice:
- A short hit, click or whoosh can have a speech-shaped envelope. classify() now also
measures the share of 40 ms frames with a 70-500 Hz fundamental (YIN, moved from the
CLI into core) and calls a sound voice only when that share reaches 0.15. Speech
measured 0.22-0.43; impacts, clicks and whooshes 0.
- A range query judged a clip's kind on the slice it covered, so 1.7 s of a music bed
read as voice. A film clip's kind is now judged on all of the clip that plays.
Even-paced speech (a 64 s TTS take) moves less at syllable rate and was called music;
with no beat and a voiced share of 0.25 or more it is now voice.
* chore(audio): keep each touched file's comment share at or under its base
The Comments check failed on run.ts, targets.ts and audioAnalysis.ts because the
PR added comment lines to files whose share may only go down. Drops the added
doc comments; no code change.
— Jerrai
* fix(audio): preserve clip evidence and stereo pitch during analysis
* fix(audio): bound and cache whole-clip kind analysis
* fix(audio): retain native-rate kind evidence when available
---------
Co-authored-by: Jerrai <jerrai@heygen.com>
* fix(cli): exit 2 for remaining flag validation errors
Flag and argument checks that commands make themselves still exited 1 and
reported runtime_error under --json. They now take the existing usage-error
path (failUsage / CliUsageError): exit 2 and a usage_error envelope. This
covers lambda and cloudrun render/render-batch (--width/--height, --fps,
positive-number, enum and --output-resolution flags, unknown subcommands),
capture, feedback, benchmark --runs, tts, init, snapshot, compare,
grade-compare, check, normalize-audio, media-treatment, play/present --port,
models, docs, browser and telemetry unknown subcommands, cloud render
conflicting inputs, cloud delete without --no-confirm, and inline --variables.
A few checks now run before project or stack lookup so a bad flag is reported
first. Runtime failures (missing files, network, render errors) still exit 1.
An unattended spinner now writes its start and progress text as plain stderr
lines, not only the outcome, so tools watching init can see "Transcoding".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cli): reject a bad snapshot --frames, quiet spinner progress, drop added comments
- `snapshot --frames abc` (or 0, -3, 2.5) is now a usage error: exit 2, and a
usage_error envelope under --json. It used to fall back to 5 frames silently.
- An unattended spinner prints its start line once and its outcome, but no
longer one line per progress update, which flooded stderr during
remove-background and the Chrome download.
- Removes the comment lines this branch added, so the comment ratchet passes.
- The render --json test now expects exit 2 and usage_error for bad inline
--variables.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cli): every --json failure answers with one envelope, and --json only where supported
Under --json, failures came back as a bare string (`"error": "Command failed"`
from lint), a command-specific shape, or nothing at all on stdout (info,
compositions, render, snapshot, cloud get). Now every failure is
`{"ok": false, "error": {"code", "message", "hint"?}}`:
- jsonFailure() in utils/jsonError.ts builds it. The commands that printed
`{ok: false, error: "<text>"}` use it, keeping their extra fields (lint's
findings, validate's errors). A refused timeline edit now writes it to stdout
instead of stderr, and keeps its `reason` and `fix` fields.
- When a --json run fails without writing anything to stdout, cli.ts writes the
envelope itself: usage errors carry their own code (unknown_flag,
unknown_command, json_unsupported, confirmation_required), anything else is
runtime_error with the message the command printed on stderr.
--json is no longer accepted by every command. A command supports it by
declaring a `json` arg; the others reject it with exit 2 and the code
json_unsupported, instead of accepting the flag and printing prose.
The contract test now checks every command's --json path, and packages/cli
gets an AGENTS.md with the rules: stdout is data, stderr is messages, never
prompt without a TTY, exit 2 for usage errors, --json always returns an
envelope.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cli): put the real failure in the --json envelope, never the generic text or a notice
lint --json and check --json in a directory with no project answered
"message": "Command failed". The project check reports the problem, then
throws a presented "Command failed" with the real error as its cause, and the
command's own catch serialized that generic text. failureMessage() now returns
the cause, or else what the command reported through errorBox, and every
command catch that writes a --json failure uses it (lint, check, validate,
layout, snapshot, compare, grade-compare, media-treatment, tts,
remove-background, add, skills, lambda policies). compare, grade-compare and
add share failCaught() for that catch.
The boundary envelope follows the same order: the error's own message unless
it is the generic one, then its cause, then the errorBox title and hint, and
only then the stderr text. Notices (the telemetry disclosure, deprecation
lines, diag progress) are no longer captured, so validate, layout and inspect
no longer put "is deprecated" in the error message.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cli): render --json keeps human output off stdout so a failure answers with the envelope
Under --json a single render printed its header, lint findings and progress on
stdout, so a later failure such as `render --variables '{bad' --json` ended
with no JSON envelope. --json now silences that output the way --batch --json
already did, and --variables is parsed before the browser check so a bad value
fails without a Chrome download.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cli): a --strict lint abort says why, even under --json
Under --json the render is quiet, so a --strict lint abort printed nothing and
the envelope said only "Command failed". The abort is now an error box on
stderr in every mode, naming the error and warning counts and the first
finding, and the --json envelope carries the same message. Lint now runs
before the browser check, so the abort never waits on a Chrome download.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(cli): time the render setup cancel from the wrapper's exit
The 1 s budget started when the command did. With lint now running before the
browser check, its subprocess (about 250 ms here) sat inside that budget, and
the test failed once under load. The budget now starts when the wrapper exits,
which is the latency the test is about.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(inspector): live queries see every composition, wherever the playhead is
The Studio preview parks scenes away from the playhead, so a live inspect, timeline or
element-map query answered missing for every element in a parked composition. Each live
query now holds every scene mounted under its own holder, then hands the scene window back.
__hfHoldScenes keeps holds per holder, so the inspector letting go never drops a scene
Studio holds.
* fix(inspector): isolate concurrent live query scene holds
* fix(cli): resume a model download that stalls partway instead of failing
* fix(cli): pin the idle-only timeout; one-line doc for the resume
* fix(cli): resume from the bytes on disk and check the size before caching
* refactor(cli): split the download response handling into named steps
* fix(studio): a thumbnail reader forgets a start's failures after a minute
A pooled thumbnail reader lives for as long as clips keep asking it, and each start
byte has a retry budget of two. That budget never reset, so two transient failures at
one start early in a session made a third, twenty minutes later after the cache evicted
it, give up at once: the reader retired and the clip showed an error.
A start's failures now count for a minute. One bout of retries (8 s stalls, 0.5 s and
1 s waits) fits inside it, so a start that keeps failing still gives up; one met again
much later starts afresh. Clearing the count when a read from that start delivers bytes
would not do: a server that ignores Range sends bytes 0 onward again before it breaks at
the same byte, and would retry forever.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): the start-failure window says what it is in its name, not a comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a start's retry budget resets when a read from it ends cleanly, not after a minute
The one-minute window only held while each attempt took under a minute. An attempt has
no limit before its answer, and a body that keeps sending never stalls, so a server that
ignores Range, sends slowly and breaks past a minute re-downloaded from 0 forever.
A start's failures are now forgotten when a read from it ends cleanly: its body finishes,
or mediabunny cancels it at its target. A server that breaks before the target never ends
a read cleanly, so its budget still runs out, however slowly it fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a read mediabunny stops at its target ends cleanly and resets its start's budget
mediabunny's UrlSource never cancels the body: once a read reaches its target, _runWorker
aborts the signal it passed in, and the pending pull fails with an AbortError. So the reset
on cancel never fired, and any open bytes=N- read stopped at its target kept its start's
failures.
The request now listens on its own signal, which follows mediabunny's and is unlinked once
the read ends: an abort while live counts as a clean end only when mediabunny's signal is
the one aborted, never a stall or a dispose.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* ci: keep the bun cache warm and skip binary downloads in preflight
* test(ci): pin the single cache writer and preflight's skipped downloads
* test(ci): keep the cache-writer scan out of the test body
* fix(studio-server): the render route passes a MOV ProRes profile to the render
* fix(studio-server): drop the added comment line so the comment share does not rise