* fix(cli): reject extra positional arguments passed to add
`add <a> <b> <c>` silently installed only `<a>` and exited 0, discarding
`<b>` and `<c>` with no warning. citty binds only the first positional
token to `name`; every token after it still lands in `args._`, but
nothing read it. `add` now rejects the extra arguments up front, before
touching the registry, naming exactly what was dropped.
Co-Authored-By: Miguel Angel <miguel.sierra@heygen.com>
* refactor(cli): inline the add extra-argument message and test it through citty parsing
---------
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>
* fix(cli): keep check --json stdout pure by sending proxy pre-resolve diagnostics to stderr
preResolveHostileMediaProxies() logged its summary and scan-failure lines via
console.info, which writes to stdout. With autoProxy on (the default), any
project containing HEVC/ProRes/AV1 media emitted that line ahead of the JSON
envelope from `check --json`, so JSON.parse(stdout) failed for consumers of
the machine-readable output.
Route both diagnostics through console.error (stderr), matching how the check
command already reports its own failures. Add regression tests asserting the
summary and scan-failure lines land on stderr and that stdout stays untouched.
* refactor(cli): shorten the pre-resolve stderr comments
* refactor(cli): drop the pre-resolve doc line the comment ratchet rejects
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* docs(studio): map filmstrip clip rendering contract
* feat(studio): give timeline clips a separate filmstrip name band
* test(studio): require the filmstrip picture wrapper
* fix(studio): keep fallback clip warnings clear of menu badges
* fix(studio): reserve clip text space beside status badges
* fix(studio): separate clip FX and fade hit areas
* fix(studio): separate clip tool pointer targets
* fix(studio): reserve FX space without lowering fades
* refactor(studio): trim fade geometry measurement
* test(studio): refresh fade target after closing FX menu
* test(studio): collapse keyframe lanes before pointer witness
* test(studio): register filmstrip control browser gate
* fix(cli): connector dash gate honours pathLength
`shaftDashHidden` compared the stroke's dash window against
`getTotalLength()`, but a `pathLength` attribute rescales every distance
along the path, so the CSS draw-on idiom (`<path pathLength="1">` with
`stroke-dasharray: 1; stroke-dashoffset: 1`) — which renders zero ink —
still fired `connector_detached`. The window is now judged in the author's
units: `pathLength` when set (`SVGAnimatedNumber.baseVal`, 0 when unset),
the real length otherwise. `dashLength`/`dashArrayLengths` stay the only
owners of parsing and units.
Two adjacent changes to the gate's arithmetic:
- The `total > period` shortcut is removed so the 10% rule applies
uniformly. A window that spans a whole period without enclosing a whole
dash (`2 97` on a 100-long path at offset 0.5 paints 3%) is hidden, like
any other stroke painting under 10%; any pattern that does enclose a
whole dash is still caught by the in-loop check.
- The dash-offset wrap adds the period only to negative remainders. The
previous `((offset % period) + period) % period` is off by one ulp for
fractional offsets (`0.9 % 2` → 0.8999…), which flipped the exact 10%
boundary once dashes are expressed in `pathLength` units.
Tests: rows for `pathLength="1"` (`1/1` hidden, `1/0.9` on the boundary
hidden, `1/0.5` painted, `0.1 0.9` round dot painted), a negative offset
that only the wrap gets right (`100` at -150), a percentage row (`10%`
resolves against the viewport, not as a bare number), and the `2 97`
full-period row. `installConnectorGeometry` mirrors the DOM's `pathLength`
for happy-dom, which has no SVGGeometryElement.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli): treat a zero pathLength as a solid stroke in the dash gate
A present `pathLength` that resolves to 0 (`"0"` or an unparseable value)
zeroes the browser's dash scale factor, so the stroke paints solid. The gate
saw `baseVal` 0, fell back to the real length, judged the window inside a gap
and dropped the shaft, suppressing a real `connector_detached` finding.
Return painted for that case. Add a `pathLength=0` row to the dash table and
make the happy-dom `pathLength` mirror parse like the DOM (finite SVG number
only; `Infinity`, hex, `1.` read as 0).
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli): check a zero pathLength before the butt-cap dots shortcut
A pathLength resolving to 0 paints the stroke solid, so a butt-capped `0 4` pattern on it is
visible; the dots shortcut used to call it hidden first. Also trims the dash-table comment.
* test(cli): keep the pathLength helper apart from the geometry installer comment
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Since v0.7.71 (#2763), gsap_infinite_repeat demotes to a warning when the
root declares a finite data-duration: deterministic seeking and export clip
to that explicit window. Without one it remains a hard error (unbounded
timeline duration breaks render planning).
Update every doc site that still teaches the old absolute ban — the core
determinism contract, the animation adapter notes, and the design-guide
tables/checklists — to state the bounded allowance and keep the finite-count
formula for loops that must end before the composition does.
skills/hyperframes-animation/rules/ambient-glow-bloom.md is intentionally
untouched: its shimmer must stop before the composition ends, which is
exactly the case where a computed finite count is still required.
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli): refuse capture directories that resolve outside the output root
writeCaptureFileSync guards the final path entry, but a directory in the
path that is itself a planted symlink (assets -> ~/somewhere) still carried
the staging directory and the rename into wherever it pointed, with a
filename the captured page chose.
Every capture directory is now created through ensureCaptureDirSync(root,
dir), which walks from the output root one level at a time and refuses any
directory whose real path is not the one it has under the root, before
creating anything inside it. The root itself stays trusted as given, so a
symlinked root like /tmp on macOS keeps working.
Fixes#4304
* fix(cli): vet assets/svgs before the SVG contact sheet writes into it
Under --skip-assets, downloadAssets never runs, so nothing checked
assets/svgs before createSvgContactSheet wrote its thumbnails and
contact-sheet.jpg into it; a planted assets/svgs link received them.
The sheet now runs ensureCaptureDirSync on the directory first, and a
refusal just skips the sheet.
A dangling or looping directory link is now refused with the same
message instead of a raw ENOENT/ELOOP, and a comment records why the
check uses the JS realpathSync rather than realpathSync.native.
Refs #4304
* fix(cli): vet capture --video's download folder
Video mode created assets/videos (or capture/assets/videos in a W2H
project) with a plain mkdirSync, so a planted assets or capture link
carried the downloaded clip outside the project. It now goes through
ensureCaptureDirSync with the project folder as the trusted root, and
a refused folder is reported before anything is downloaded.
* fix(cli): warn when a best-effort capture phase refuses a folder
Folder refusals now throw CaptureDirRefusedError. The contact sheets
and the video manifest still skip on any error, but a refusal adds a
warning so a planted link doesn't go unnoticed.
saveLottieAnimations now vets assets/lottie itself, taking the output
root like renderLottiePreviews, and every folder check in the capture
pipeline is pinned by a test.
* style(cli): trim the ensureCaptureDirSync doc comment to two lines
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* fix(media-use): honour HYPERFRAMES_PYTHON in the audio engine (#4614)
resolvePythonCommand() only probed python3/python on PATH, so on PEP 668
systems set up with a venv via HYPERFRAMES_PYTHON (the documented setup),
doctor reported MusicGen installed while BGM was silently skipped.
Check HYPERFRAMES_PYTHON first, as the CLI's findPython() does, and fall
through to the PATH probe when the override doesn't run.
* fix(media-use): require a Python 3 version from the interpreter probe
Address review on #4723:
- The probe accepted any executable that exits 0, so a Python 2
HYPERFRAMES_PYTHON (or python on PATH) was taken while the CLI's
validatePythonOverride() rejects it. Require "Python 3" in the
--version output, reading stderr too since Python 2 prints there.
- Regenerate skills-manifest.json for the media-use change.
* chore(skills): regenerate manifest without local __pycache__
* chore(media-use): trim python resolver comments to the comment limit
* style(media-use): format the python probe test
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* fix(cli): connector_detached now skips dash-hidden connector shafts
connector_detached fired on SVG connector paths that were 100% hidden
via stroke-dashoffset/stroke-dasharray draw-on entrances, evaluating
rendered/user-space geometry unconditionally. The sibling
connector_orphan check already gates on shaftDashHidden(path); add the
same gate here, in the same position.
Co-Authored-By: Miguel Angel <miguel.sierra@heygen.com>
* fix(cli): connector dash gate reads the whole stroke-dasharray; one owner for connector enumeration
`shaftDashHidden` read only the first `stroke-dasharray` entry, so any
pattern starting with 0 (`0 4` dotted) or a long first dash counted as
hidden and suppressed both connector findings on a visible stroke. It now
resolves the full computed list (comma/space separated, units,
percentages against the SVG viewport diagonal, odd lists repeated;
`none`, all-zero and negative lists render solid) and treats the shaft as
hidden only when the window `[dashoffset, dashoffset + length]` sits in a
single gap, with up to 10% of the length overlapping a neighbouring dash.
Zero-length dashes paint only under `stroke-linecap: round | square`;
under the default `butt` they render nothing, which also covers the
`0px, 999999px` state a finished draw-off tween leaves behind.
`connector_detached` and `connector_orphan` each walked SVG paths with
their own copy of the candidate/gate logic, which had already drifted.
A single `connectorShafts(root)` generator now owns enumeration, the
dash gate and endpoint resolution, and yields the `painted` verdict;
both findings consume it. The path length is queried once per shaft
(`pathUserEndpoints` returns it) instead of twice, and the attach
threshold formula has one owner.
Tests pin `0 4` (butt and round), `0, 4`, `4 0`, `0`, `none`, px units,
negative offsets, a draw-off end state, and both sides of the 10%
boundary.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* refactor(cli): shorten the connector and dash-window comments
* test(cli): shorten the dash-gate table comment
---------
Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
* fix(lint): block on digit-leading ids instead of only warning
A digit-leading id (e.g. id="1-hook") breaks any bare `#1-hook` CSS
selector with a SyntaxError, including inline GSAP string selectors
like tl.fromTo("#1-hook", ...). That throw silently aborts the rest of
the <script> block that built the selector, so the referenced element
never animates — but the render itself still reports success, and the
freeze has no visible error anywhere.
id_requires_css_escape already detects this correctly; it was only
"warning" severity, which hyperframes render and hyperframes check
both treat as non-blocking by default. Promote it to "error" so
`hyperframes check` (which always treats errors as blocking) catches
this unconditionally instead of only under --strict.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* refactor(lint): drop the digit-leading id severity comments
* fix(lint): error on digit-leading ids only when a selector targets them
* fix(lint): say a CSS-only digit selector drops the rule instead of throwing
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* fix(lint): flag an id override written with reduced-specificity selector forms
An attribute selector on id (`[id="x"]`, class-level specificity) or a
`:where(#x)`-wrapped id selector (spec-defined zero specificity) genuinely
loses to a compound class selector like `.parent .row` under standard CSS
cascade rules — unlike a bare `#id`, which always wins regardless of how the
competing class rule is written. When either form is used to override a
position property (left/top/right/bottom), the element can silently render
at the class-default position instead of its authored one, with lint,
validate, inspect, and layout all reporting clean (a real, resolved position
exists, it's just the wrong one).
No existing check performs any CSS rule-specificity analysis — confirmed at
the source level (dist/cli.js has zero "specificity" occurrences, and
createInspectCommand reports position via getBoundingClientRect() only).
Adds `id_override_reduced_specificity`, a new core lint rule flagging this
authoring pattern specifically (not general cascade-conflict detection,
which would require full rule-graph modeling well beyond this P3's scope).
Extracted `rightmostCompoundNodes()` out of `selectorAliasesRuntimeHiddenStyle`
so the new rule's selector-subject walk doesn't duplicate it.
* fix(lint): share the selector-safety parse and skip compounds that keep a bare id
* style(lint): wrap the id attribute check for oxfmt
* refactor(lint): move the reduced-specificity id check into its own helper
* fix(lint): hint an escaped #id and skip prefix and !important id overrides
* style(lint): wrap the prefix-selector test for oxfmt
* refactor(lint): split the weak-id node check out of the selector walk
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* fix(engine): warn when a video input declares alpha but decodes opaque
- Problem: a video input whose alpha_mode=1 tag outlives its alpha plane (a remux drops the BlockAdditional sidecar while keeping the tag) composites as a solid rectangle, and nothing tells the user their file, not the renderer, is the problem (#3220 / #3226).
- Fix: during extraction preflight, when an input declares alpha (hasAlpha) and its codec can carry alpha, sample the decoded alpha plane (up to 3 frames at 8x8 rgba, bounded 30s, ~768-byte ceiling). If uniformly opaque, emit a non-blocking stderr warning naming the file with the re-export remedy. Inconclusive probes stay silent; opaque full-frame backgrounds are legitimate and never fail the render.
- Verification: 8 new unit tests (byte logic + message); engine suite 1483/1487 with 4 pre-existing failures on clean main (missing hdr-regression PNG fixture in this clone); typecheck/lint/format clean; real-fixture checks: lying-tag WebM warns, genuine-alpha WebM stays silent, both agree with ffmpeg alphaextract.
Signed-off-by: Santhi Prakash <b.santhiprakash@gmail.com>
* fix(engine): probe input alpha with the decoder that matches its codec
* style(engine): format the alpha probe test
* fix(engine): keep the alpha probe from failing or racing a render
---------
Signed-off-by: Santhi Prakash <b.santhiprakash@gmail.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli): route FFmpeg exec-failure hint through getFFmpegInstallHint()
checkFFmpeg()'s "cannot start" branch (binary found but fails to run
-version) hardcoded a Windows/DLL-flavored install hint on every
platform instead of delegating to the already platform-aware
getFFmpegInstallHint() used by every other branch in this file.
* test(cli): pin the ffmpeg cannot-start hint through the async probe mock
* fix(cli): keep the runtime-DLL cannot-start hint on Windows
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* fix: honor FFMPEG_PROCESS_TIMEOUT_MS during video-frame extraction
FFMPEG_PROCESS_TIMEOUT_MS resolves correctly into EngineConfig and is
correctly forwarded to the audio-extraction ffmpeg call, but
runExtractVideosStage (shared by both the in-process renderer and
distributed plan()) built the config object it passes to
extractAllVideoFrames with only { extractCacheDir, extractCacheMaxBytes },
silently dropping ffmpegProcessTimeout even though the full EngineConfig
was already in scope. That config flows straight into
extractVideoFramesRange, which falls back to a hardcoded 300s deadline
whenever the field is undefined -- so video-frame extraction always
used a fixed 300s timeout no matter what the env var was set to.
Threads cfg.ffmpegProcessTimeout into that config literal the same way
the two cache fields already are.
Co-Authored-By: Miguel Angel <miguel.sierra@heygen.com>
* fix(producer): pass the whole engine config to video-frame extraction
The stage hand-picked fields from its resolved config, so any field it forgot
(the ffmpeg process timeout) silently fell back to the engine default.
---------
Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
* fix: hide console windows spawned by ffmpeg and nvidia-smi on Windows
Node's `windowsHide` defaults to false, so every child process that is a
console application pops a window on the user's desktop. During a render
or a regression run that is a stream of black windows stealing focus.
The repo already sets `windowsHide: true` across cli, studio-server and
most of engine; these were the remaining gaps, all on paths that run
during a normal render:
- core/mediaGradeAnalyzer: ffprobe + ffmpeg, once per analyzed media file
- engine/browserManager: `nvidia-smi` VRAM probe. This one goes through a
shell (`execSync`), so it flashes a cmd.exe window even on machines with
no NVIDIA GPU, where the command exists only to fail
- producer: the regression harness ffmpeg runner, PSNR/audio comparison
helpers, fixture synthesis, and the ffprobe call in audioPadTrim
No behavior change on POSIX, where the flag is ignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(producer): escape PSNR stats_file for ffmpeg's two-pass filtergraph parse
An ffmpeg filtergraph argument is unescaped twice: once when the graph is
split into filters and their options, then again when the option value is
read. `psnrAtFrames` escaped `\` and `:` a single time, which only survives
the first pass.
On POSIX that is invisible, because mkdtemp under tmpdir() produces neither
character. On Windows the stats path is always `C:\Users\...`, so it reaches
the option parser with a bare `:`, which starts a new option and fails the
whole graph:
[AVFilterGraph] No option name near '\Users\...\psnr.log'
Error parsing filterchain '[rv][gv]psnr=...:stats_file=C\:\Users\...'
Every suite that got as far as quality validation died there, so a Windows
regression run reported 0 passes with only 2 genuine compilation failures.
Escaping for both passes fixes it and stays a no-op on POSIX.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(producer): pin PSNR stats path escaping with a colon and backslash path
* fix(producer): keep the PSNR stats path out of the filtergraph
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* fix(engine): pin capture surface scale to 1 device pixel per CSS pixel
Capture reads the browser's compositor surface (`Page.captureScreenshot`
with `fromSurface: true`, and `HeadlessExperimental.beginFrame` likewise),
and that surface is rasterized at the host display's backing scale.
`page.setViewport({ deviceScaleFactor })` goes through
`Emulation.setDeviceMetricsOverride`, which only moves
`window.devicePixelRatio` on the page side — it does not resize the
surface.
On a HiDPI host every render therefore came out at twice the composition:
a 1920x1080 composition encoded to a 3840x2160 file, and no flag could
bring it down, because the pipeline's own deviceScaleFactor was already 1.
Supersampling stacked on top of it, so `--resolution landscape-4k`
produced 7680x4320 rather than 3840x2160.
Pinning the surface with `--force-device-scale-factor=1` leaves the
deliberate supersampling path (the capture clip's `scale`, from
`resolveDeviceScaleFactor`) as the only thing that multiplies output
pixels. The flag is a no-op on Linux, where the surface is already 1x, so
CI and cloud output are unchanged.
* fix(engine): drop the capture-scale comments the ratchet rejects
---------
Co-authored-by: Leo Xu <xuliyou@bytedance.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* test(studio): score edits on GSAP-animated size, scale, rotation, keyframes and from/fromTo
* test(studio): settle the edit bench only once no shadow preview is waiting to be swapped in
* test(studio): end a drag at its recorded pointer-up, even when no frame painted while held
* ci(studio): run the keyframed edit accuracy cases in CI across 20 shards
* refactor(studio): split the keyframe render and stray-css checks into small helpers
* test(studio): bank the edit bench's smooth verdict as smooth, judged on the worst frame's work
* style(studio): name the edit gate's smooth count helper
* test(studio): count smooth edit cases only among the accurate ones
* test(studio): only keyframed cases wait for the preview swap, so others keep main's undo timing
* refactor(studio): keep the edit bench's keyframe check apart from the hidden preview scan
* test(studio): check the keyframed cases are in the full grid without pinning its size
* test(studio): quarantine the nudge sequence whose redo waits behind preview videos (#4889)
* test(studio): bank the 255 keyframed edit cases that pass
* fix(release): use portable Git paths in release guards
* fix(release): drop the comment on a path git never compares
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* feat(studio): let a host's undo key paint a still-saving move back at once
* test(studio): prove the public revert from a .ts test and drop the export comment
* test(studio): a failing revert check cannot leave an edit pending, and a second revert paints nothing
* test(studio): load the package index once at collection, not inside the revert test
* feat(studio): show clip speed in the clip name as [150%] or [ramp]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): one fx badge per clip replaces the per-tool and link badges
Grey with no effects, white with any. The tooltip lists look, voice, other
fx, crop and ducking; a click opens the clip menu. The volume badge stays
when volume is off 100%.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): premiere-style audio gain dialog on G
Set Gain to, Adjust Gain by, Normalize Max Peak to, Normalize All Peaks to
and Normalize loudness to -16 LUFS, with the selection's peak amplitude read
from the peak map. Writes data-volume on every selected clip with sound as
one undo step. The clip menu's Normalize loudness becomes Audio Gain... (G).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): use a theme blue for the audio gain OK button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep shortcuts out of the audio gain modal; refuse while recording
Keys pressed inside an aria-modal dialog no longer reach the global hotkeys,
so Delete on the focused OK button cannot delete the clips the dialog edits.
The multi-clip write refuses during a recording like the single-clip one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): offer link for any unlinked video and audio pair
Link no longer requires the same file and identical timing, matching Premiere.
Same-file pairs get a sync origin so a trimmed or moved audio shows its
offset; different files link without one. Merge back is disabled until the
pair is in sync. Linked moves keep the offset, and a trim only carries to a
partner whose edge is at the same time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): shift and cmd-click add timeline clips to the selection
A modifier click on a clip replaced the selection, so a video and an audio
could never both be selected and Link never appeared. Shift, Cmd and Ctrl
now toggle the clip (and its link partners unless Alt) in the selection;
Shift no longer turns a clip click into a time-range popover.
Right-clicking a lone unlinked video or audio offers Link when exactly one
opposite-kind clip from the same file sits on another track.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep audio inside its partner video and make cmd-click add in every host
A modifier click passed the toggle as an argument to an optional
onSelectElement call, so hosts without that handler (desktop) never ran it.
An audio clip linked to a video, or sharing its sync origin, now stays
inside that video's span while dragged or trimmed, Alt edits included,
unless the video moves or trims in the same gesture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): hold the drag actor at the partner-video clamp
The dragged audio's ghost followed the pointer past the video's end while
the landing stayed clamped; it now stops where the clip will land.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): preview a look on hover in the clip menu before applying it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(studio): trim comments the clip-link names already carry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): unlinking clears the timeline selection
After Unlink both clips stayed selected, so the next edge trim ran the
multi-select group resize and the former partner still followed. Unlink
from the menu, the shortcut or an Alt edit now leaves nothing selected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sdk): keep a linked partner's end when a trim changes the grabbed duration
Copying the grabbed clip's duration to a partner that starts later pushed the
partner's end past the clip both ended on. The partner's duration is now the
shared new end minus its own start.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep every moving detached audio inside its video, after placement too
The containment clamp ran before collision placement and only for the
grabbed clip, so a drop pushed past an obstacle, or audio carried along in a
multi-selection, could land outside its video. The allowed shift is now the
intersection over every moving audio, and a placement outside it is
rejected. Link partners are matched within their source file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): carry the normalize refusal and save outcomes into the gain dialog
Normalize moved from the clip menu into the Audio Gain dialog here, so the
volume-lane refusal and the saved/refused/failed checks go with it: a lane
owned clip is refused, and a failed single or multi-clip save rejects instead
of reporting success.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(studio): trim comments the gain and multiselect layer added
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(sdk): split the linked partner's duration out of partnerTiming
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sdk): refuse a linked trim whose new end would cross the partner's start
With linked video [2,8] and audio [3,8], setTiming(video, {duration: 0.5})
computed a partner duration of -0.5, skipped it, and committed the video alone,
leaving the audio outside its video. The partner edits are now planned before
any mutation; a non-positive partner duration throws ("Linked audio would start
after the new end — unlink or trim the audio first.") and can() reports
E_LINKED_PARTNER_CROSSED, so nothing is written and the undo stack is
untouched. In Studio the refusal fails the cutover (never declines into the
legacy path) and the timeline resize toast shows the reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sdk): key a linked edit's grabbed baselines by composition scope
planLinkedTiming stored each target's timing under its raw data-link id, while
linkedPartnerIds scopes link ids to their host composition. One setTiming naming
a root video and a nested video that both use lk-1 kept only the nested baseline,
so the root audio was skipped and left past its trimmed video. Baselines are now
keyed by host scope and link id, the same rule linkedPartnerIds uses, for both
the partner trim and the crossing refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(sdk): run the two-scope grabbed-baseline cases on an inline composition too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a hand drag moves only with its own pointer pressed
Chromium re-sends the last known pointer point with no button held after a
layout change. The overlay's move handler took those as drag travel, so the
element flipped between the pointer and that stale point, and the release
committed whichever came last.
* test(studio): the edit accuracy grid drops a drag after a buttonless move at the press point
Nine pathstray cases: a 180,90 drag whose last move before the release is a buttonless pointermove back at the press
point, as Chromium resends after a layout change. Main saves the element where it started; this head saves it where
the pointer let go. The frame sampler ignores that untrusted move as pointer position.
* fix(studio): keep the drag rule's comment to one line
* fix(studio): crop handles and the marquee band also ignore a buttonless move mid-gesture
* test(studio): bank the nine stray-move cases in the edit accuracy baseline
* test(studio): one crop drag helper, the band's candidate refresh in its own step, and formatting
* style(studio): wrap the pointer gesture comment under 120 characters
* feat(studio): order the clip menu into time, sound, picture and delete groups
Split and Freeze frame; Normalize, Voice, link items and Duck; Look and Crop;
then Delete beside Delete this clip only. Each item file renders its pieces
separately, empty groups collapse, and the viewport clamp uses the measured
menu height instead of a row-count estimate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): mint freeze-frame right-half links with the shared lk-N helper
Freeze frame now calls core relinkSplitHalves, so split halves and freeze
halves share one link-id convention instead of L -> L-2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): apply speed edits to every data-link member in one undo step
The inspector speed slider, rate lane edits and ramp presets on a linked clip
now commit once, writing the selection and, via expandToLinkedMembers, each
partner from its own current value. Unlinked clips keep their usual path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(studio): drop the audio parent tick in favour of the data-link chain badge
A linked clip now shows one link indicator: the chain badge driven by
data-link. The waveform no longer draws its 1px parent tick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): follow late clip-menu rows and target link partners like S2
The menu height follows a ResizeObserver so rows that read the live preview
after mount still clamp inside the viewport. Speed fan-out builds partner
targets with buildPatchTarget, matching the link edit plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): show the sound strip and audio menu items without studio's shell
The audible-video waveform strip drops AudioWaveform's 16px label band, which
left a 0px canvas on short lanes. Normalize loudness and Duck under voice fall
back to the timeline session's project, the live preview iframe store and a new
onNotice edit callback when no StudioShellProvider is mounted.
usePreviewIframeStore is exported so a host can publish its preview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): show duck under voice only when a voice overlaps
The item now appears only when an overlapping voice exists or the bed is
already ducked. Its check mark moves to the right edge so unchecked rows
align with siblings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): name the partner in the clip menu link and unlink items
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): clip menu stops offering unlink once the clips are unlinked
The timeline skipped store updates when only data-link changed,
so the menu kept reading the stale link.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(core): sync origin, offset math and move/slip into sync for linked pairs
data-sync-origin survives Unlink; split gives right halves a fresh origin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): split re-mints the sync origin of unlinked pairs too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): detach, link and keep-sound cutout write a sync origin that unlink keeps
TimelineElement.syncOrigin is read on both parse paths; merge back drops it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(studio): expect sync-origin on keep-sound cutout write
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): linked selection toggle in the timeline toolbar, on by default
Off: a click selects one clip; drag, trim, razor, speed and delete stay on it
(delete unlinks the survivor). Alt-click still selects one half when on.
Right-click holds the out-of-sync indicator preference. Both persist in UI prefs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): link groups share a label colour; out-of-sync badges with move and slip
Red badge on both halves of a sync-origin pair that drifted (frames, s:ff past
a second); hidden when rates differ or the preference is off. The whole badge
opens Move / Slip into Sync; each edits that clip alone in one undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): take link label colours from theme tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(sdk): syncOffset, moveIntoSync and slipIntoSync for sync-origin pairs
Core gains findSyncPartner, the DOM pairing shared by the SDK and the CLI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(cli): timeline rows report syncOffsetFrames and print out-of-sync
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(cli): the timeline row fixture carries syncOffsetFrames
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): data-sync-origin in hyperframes-core
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core,sdk): keep a one-sided cut's sync origin and slip the in-point playback reads
relinkSplitHalves reminted the right half's sync origin even when only one
member of an unlinked source pair was cut, so the uncut partner kept the old
origin and the new half lost its badge. The origin is now reminted only when
both the video and the audio were cut.
Slip into Sync wrote data-media-start whenever it existed, but playback
reads data-playback-start first, so a clip carrying both never moved. The
link timing reader now uses playback's precedence and the slip writes the
attribute that wins; lint compares the same field. A sync partner is also
looked up only in the clip's own composition (core) or source file (Studio).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): flip the out-of-sync menu above the pointer near the bottom edge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor: trim comments the sync-origin layer added
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(core): split the scoped candidate lookup out of findSyncPartner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core,studio): find sync partners inside the link scope
findSyncPartner and Studio's syncPartnerOf now scope by the same rule as
link groups: the nearest data-composition-id or data-composition-file
ancestor, plus the row's compositionScope in Studio. An origin reused in a
file host or an inline composition no longer pairs across it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): soft reloads put a nested element's tweened style back from its own file
* fix(studio): timing edits, held elements and DOM-building scripts reload from the files
* refactor(studio): one owner settles the nested file reads for every soft reload
* fix(studio): Renders dropdown options take a mouse click again
* test(studio): name a Select whose list never opens and keep both Studio layout checks running
* feat(core): add data-link media link helpers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(lint): warn on out-of-sync and orphaned linked clips
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): select, drag and trim linked clips together
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): detach, merge, link and unlink source transforms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): link-aware split, delete, alt-edit unlink and link edit actions
Split and delete fan out to link partners. Alt drag/trim edits one member
and unlinks it. onLinkEdit runs unlink, link, detach and merge as one undo
step each. Single-clip timing edits of a linked clip skip the SDK path so
its linked default cannot move the partner behind the store.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio-server): give split right halves of a linked pair their own link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): clip menu detach, unlink, link, merge back and delete-one items
Items live in clipMenuLinkItems.tsx with a one-line hook in ClipContextMenu;
the same resolver drives the Cmd+L, Opt+Shift+D and Opt+Delete shortcuts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(sdk): setTiming applies to data-link partners by default
{ linked: false } edits one member and unlinks it, matching Studio's Alt-edit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): document data-link linked clips in hyperframes-core
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(studio): keep comment share at or below base in link plumbing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(studio): audio pill link follows data-link, not a shared file name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): alt-click selects one linked member even inside the pair selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(lint): drop the rule-list comment for the linked clip rules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sdk,lint): keep a link group inside its own composition
Link ids are minted per file, so lk-1 is normal in the root and in a child
composition. The SDK's linked-timing query descended into nested hosts and
addressed every match with the initiating scope, so moving a root clip moved
the child's pair too; lint grouped them into one drifting group. Both now
group only members of the same composition scope.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): match linked media by full asset path, scope links, keep hidden sound silent
Merge and Link compared only a lowercased basename, so assets/one/talk.mp4
and assets/two/talk.mp4 enabled a destructive Merge. Clips now compare the
src resolved against each clip's own source file, and Merge re-checks the
two srcs in the source before it runs. Link membership in the timeline
(linked members, gesture expansion, unlink orphans) is scoped to a source
file. Detach and the keep-sound cutout carry a hidden video's data-hidden
to its audio, and Merge refuses when only one member is hidden.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(studio): move the timeline move-edits handler into its adapter
App.tsx sat at 600 lines on this layer and 602 once merged with main.
useTimelineMoveEditsHandler now lives beside persistTimelineMoveEditsAtomically,
same callback and dependencies, bringing App.tsx to 592.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sdk,studio,lint): keep link groups inside inline compositions
A link group now stays inside the nearest data-composition-id or
data-composition-file ancestor. The SDK used to recognise only file hosts,
and Studio compared only sourceFile, so an inline child composition that
reused a link id was edited together with the root group. core/media-link
linkScopeOf is the shared rule for the SDK and Studio. Lint cannot import
core, so it uses the same two attributes directly. Studio rows record their
compositionScope, and link and merge refuse pairs that cross it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sdk): refuse a link edit whose partner address names another clip
A partner addressed in the caller's scope can resolve to a different element
when an inline composition reuses a root clip's data-hf-id. setTiming now
checks that each partner's address resolves back to that partner and throws
before mutating when it does not, instead of editing the root clip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a remote media url keeps its query when matching an asset
Two different videos at one remote path with different queries were treated as
the same file, so Merge could delete the audio and use the wrong video's
sound. Project-local srcs still drop query and hash.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(studio): keep a remote url's query exactly as written when matching assets
Decoding the whole url folded %26 into & so two different media urls matched.
Decode only the path; the query is compared as authored.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(studio): compare a remote url as serialized, decode only local paths
A remote pathname with an encoded reserved character (%3F) matched the same
path with a literal ?, though they can return different media. Remote urls
keep origin, path and query exactly; local srcs are still decoded and
stripped of query and hash.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a text edited in place shows its caret in a scaled preview
* fix(studio): the drawn caret follows a resized preview and stands right at bold words and empty lines
* fix(studio-server): a write receipt's replaced version comes only from the bytes it overwrote
* fix(studio): the drawn caret stands on the selection inside a transformed canvas
* test(studio): unmount the caret tests' roots before clearing the page
* test(studio): the caret gate waits for each key to land and retries a text that does not open
* test(studio): the caret gate checks what the DOM can tell at a soft wrap, and shows a text that would not open
* test(studio): the caret gate opens a text with a double press when click and Enter do not
* fix(studio): the drawn caret follows a pan or zoom of the preview
* fix(studio): the inline text style bar stands by the selected text inside a transformed canvas
* fix(studio): after End at a soft wrap the drawn caret stays at the line's end, as the system caret does
* fix(studio): only End keeps the drawn caret at a wrapped line's end, as Chrome's own caret
* refactor(studio): share the test mount helper and split the caret's placement for the code gates
* docs(studio): trim the drawn caret's comments to the package's share
* fix(studio): open inline edit on styled text whose nodes come from another window
* refactor(studio): name the realm-safe html check instead of commenting it
* docs(studio): shorten the caret fixture comment to the 120-character limit
* fix(studio): keep a pick made just before a reload swaps the preview frame
* fix(studio): trust only frames that were the primary preview, one pick helper in the tests
* test(studio): a zoomed frame is not trusted once the zoom is gone
* refactor(studio): name the trusted frames instead of commenting them
* feat(studio): draw a waveform strip under video clips that carry sound
An audible video keeps its thumbnails and gains the audio waveform along the bottom
38% of the clip (also when thumbnails are off); muted and silent video are unchanged.
The waveform route already decodes video audio; a test now proves it on an mp4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): latch a CLIP light on the master meter at the export ceiling
The light turns red when the preview master sample peak reaches -1 dBFS (the export
true-peak ceiling) and stays lit until clicked. Mutation-tested threshold and latch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): paint the CLIP light from theme colour tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): mark where each clip redlines on its waveform
studio-server computes an absolute per-50ms sample-peak map once per media file
(native channels, cached by size+mtime next to the waveform cache); the timeline
paints red marks where the source at the clip's own volume reaches -1 dBFS and
names the loudest point.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): let a video with sound be the beat source
Studio's isMusicSourceElement accepts an audible, unmuted video for the music track
and the beat fallback (lane zoning unchanged); core findMusicAudioSrc accepts
<video data-has-audio="true"> without muted; the beats CLI message mentions video.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): decode each peak map once and never offer Duck on a carve source
Concurrent peak requests for one file share a single ffmpeg decode; Duck under voice
is withheld on a clip another bed already carves against.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): decode the same audio stream the peak map probes
The channel probe read stream a:0 while the decode left stream choice to
ffmpeg, which picks a later stereo stream over a mono first one; every frame
was then read at the wrong width, doubling the bins and taking the other
stream's level. Both commands now select a:0, and the cache key moves to
peaks-v2 so maps built from the wrong stream are rebuilt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): terminate the peak map's ffprobe options before the input
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): reject a peak map whose decode exits nonzero
A decode that emits some samples and then fails no longer resolves as a
complete map, so /peaks cannot cache a truncated clipping map.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio-server): share loudness measurement with an absolute target and video support
normalize-audio moves its measure/plan code into @hyperframes/studio-server/loudness,
gains an absolute mode (--lufs, default -16, clamped at +12 dB and -1.5 dBTP) and
accepts <video> clips with sound. Adds the @hyperframes/core/audible-video subpath.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio-server): add a measure-only loudness normalize endpoint
POST /projects/:id/loudness/normalize measures a clip's played window (audio or
video) and answers the -16 LUFS data-volume plan; Studio writes the attribute.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore: register the loudness and audible-video subpaths in their descriptors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* style: format the loudness and audible-video subpath descriptors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): add Normalize loudness and Duck under voice to the clip menu
Normalize measures the clip on studio-server and writes data-volume as one undoable
edit; Duck toggles a carve against every overlapping voice (audio or video with
sound) at the default strength. Both live in clipMenuAudioItems with one hook-in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(studio): tighten carveOutput docs to the package comment share
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(producer): report how far the true-peak limiter lowered the export
The AAC true-peak pass now returns its attenuation; the render logs "Audio lowered
by X.X dB to stay under -1 dBTP" and sets RenderJob.audioLoweredDb, which Studio
servers carry in the complete SSE event and the /renders record. Also exports
useAudioMetersVisible from @hyperframes/studio for hosts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): scan HTML start tags without a backtracking regex
The start-tag regex backtracked quadratically on '<' followed by many spaces
(CodeQL js/polynomial-redos). A linear character scan reads the same name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): refuse to normalize a volume-automated clip and honour save outcomes
A volume lane owns the clip's gain in preview and render, so writing
data-volume changed nothing while the toast claimed success; Normalize now
refuses with a clear message. Normalize and Duck also check each quiet-save
outcome, stop at the first failed or refused write and report it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(core): drop the duplicate audible-video subpath descriptor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): undo paints a move or nudge back at once while its save is still running
* test(studio): the edit accuracy gate judges the two part C sequences again
* fix(studio): a save lasts until undo can paint it from the history
* test(studio): the bench reads the box after a key's frame, as a user sees it
The nudge step already waits a frame per key (sequences.mjs:162); undo and the text step's Enter now do too.
* refactor(studio): say which registrations a pending edit adopts
* feat(core): add gentle ramp-in, ramp-out and slow-mo-middle speed presets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): add voice, look and crop clip-menu items and clip badges
Voice and Look are single-choice submenus writing data-fx-chain and
data-color-grading; None clears. Crop opens the canvas crop preset bar.
ClipBadges reads the live preview node and follows attribute edits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): drop the unstyled badge classes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): add crop aspect presets to the canvas crop handles
Free, 16:9, 9:16, 1:1, 4:5, Reset and Done float above the clip opened from
the clip menu. Presets commit a centred inset clip-path; Reset removes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(studio): read the committed crop from the inline clip-path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio-server): add freeze-frame file mutation
Extracts the frame under the playhead with ffmpeg (media time honours
data-media-start, playback rate and a rate lane) into assets/freeze/, then in
one write splits the video, inserts a 2 s <img> still with the video's box
styles, shifts the rest of the track and splits a data-link partner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): import replaceFileAtomically from core atomic-file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): add freeze frame to the clip menu
Freeze frame on a video under the playhead posts the studio-server
freeze-frame mutation and records its before/after as one undo step.
onFreezeFrame is threaded through the timeline edit callbacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): keep the freeze still inside assets/freeze
The clip id from the project HTML went straight into ffmpeg's -y output
path, so an id with ../ segments wrote outside the project. The id is now
reduced to one safe filename component, the output is pinned inside
assets/freeze before extraction, and the still's src is built from that
name rather than from a filesystem path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): give every freeze still its own immutable name
Two clip ids that sanitise alike (a.b / a_b), ids sharing an 80-character
prefix, or a second freeze of one clip at the same playhead all produced the
same still path, and ffmpeg's -y replaced the first still's pixels. The name
now carries a hash of the raw id and a per-extraction token, the route
refuses a name that already exists, and ffmpeg runs with -n so it can never
overwrite. The freeze media time reads data-playback-start before
data-media-start, as playback does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(core): add isAudibleVideoElement predicate and audible media selector
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(runtime): route processed audible videos through web audio in preview
An audible <video> joins the Web Audio schedule, hidden-toggle and
group-capture passes when it needs a gain el.volume cannot express: a
boost above unity, an fx chain, automation, or an audio group. A plain
audible video at unity stays on native output. The clock master stays
audio-only; a video whose capture fails stays on native output (never
decoded, never failed closed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(audio): let audible videos join audio groups in preview and render
resolveAudioGroups, audioGroupOf, group render ids, the engine mixer and
the audio_group_no_members lint rule accept an audible <video>. A hidden
group drops its video members from the mix; muted videos stay inert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(editing): offer the audio fx section on audible videos
EditableElementFacts gains hasAudio; studio and sdk derive it from muted and data-has-audio.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): show audio meters when a project has an audible video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): offer audible videos as carve voices
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): let audible video clips join audio groups
Drops the video refusal; grouping and group audition count audible videos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): honor hidden audio groups for auto-detected video audio
Muted videos that declare data-has-audio are no longer mixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep authored src, loop and honest status when keeping cutout sound
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): treat only data-has-audio=true as audible in runtime and lint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(core): trim the web audio route and group-key comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(runtime): capture ramped clips so a group's bus carries them in preview
A rate lane skipped Web Audio entirely, so a ramped video or audio clip in a
group played native output without the group's fader, mute or FX while the
export applied them. Only the decoded-buffer fallback cannot follow a rate
curve; the media-element capture can, so the skip now guards that fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): show meters only for videos Web Audio carries
A plain audible video at unity stays on native output, which the meters
cannot read, so it no longer advertises them. The runtime's join rule and the
meter gate now share audibleVideoNeedsWebAudio (core/audible-video).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>