* fix(core): the preview redraw leaves animations after the playhead as the seek drew them
The redraw that re-renders a timeline at its playhead steps silently to just
below it and back. That backward step drew every reversed child that starts
later at its end (a reversed set showed 0.8 where the seek showed 0.2), and the
landing never revisited it. Children starting after the playhead now sit out
both steps. The redraw also puts back the zero-duration state of every
zero-duration child, not only those with callbacks, so two sets on the playhead
keep their authored order on the next seek and a call in an otherwise empty
nested timeline fires as often as without the redraw.
* refactor(core): collect children after the playhead without a per-child array
* fix(core): a scene's opening set disappears when you step back off the scene's start
The redraw's silent round trip left a nested timeline's start-crossing mark
(_zTime) on the wrong side, so the next backward seek skipped the scene and its
opening set stayed drawn. The redraw now restores that mark for every nested
timeline as well as every zero-duration child.
* refactor(core): name the redraw's playhead tolerance instead of commenting it
* fix(core): a pause inside a reversed repeating scene fires once after a redraw
The redraw renders a nested timeline the seek had left inactive, which marks it
active. With its start-crossing mark restored but not its active flag, the next
seek re-ran the scene's pause. The redraw now restores the active flag too.
* fix(core): a held redraw no longer cuts a paused scene short in renders
Holding later children at time scale 0 let GSAP recompute a nested timeline's
duration during the redraw's round trip without them, so a paused
sub-composition kept a shortened length and froze for the rest of the render
(style-5-prod's typed intro). After restoring the time scales, every ancestor
of a held child is marked dirty and the duration is settled again.
* fix(core): the redraw puts back the scene lengths it measured, not re-measures them
Re-settling after the hold recomputed the root's length from whatever was paused
at that moment, which the redraw should not change. The cached lengths of the
held children's ancestors are now snapshotted after the entry settle and
restored in the finally.
* fix(core): composition scripts start after web fonts are ready
A scene script that measures text (offsetHeight, getBoundingClientRect) ran while
the page parsed, often before its web font loaded, so it laid the scene out with
fallback metrics. Renders of the same film differed run to run, and preview could
differ from export.
The compilers now give every body script a type the browser does not run
(text/hf-after-fonts, or +module), through one helper in scriptRuns.ts called
at the end of the bundler, the producer compile and the sub-composition preview.
The Studio motion script is emitted the same way so it still runs after them.
The runtime entry owns the wait: on DOMContentLoaded it forces a layout, waits for
document.fonts.ready or FONT_WAIT_TIMEOUT_MS (5 s), then re-creates each script
in its own place in parser order (classic in order, awaiting src loads; then
module and defer scripts in insertion order), boots as before, and calls the
DOMContentLoaded and load listeners the scripts added. On timeout the scripts
run anyway and runtime_font_wait_timeout names the families still loading.
Scene swaps and the composition loader use the same wait.
* fix(core): a page whose runtime predates the font gate still runs its scripts
A bundle can be served with a runtime loaded from HYPERFRAME_RUNTIME_URL. If that
runtime is older than the font gate, nothing would run the deferred composition
scripts and the film would be blank.
Whenever the marking helper defers scripts, it also puts one small inline script
first in the head. At DOMContentLoaded it runs the deferred scripts in parser
order itself, without waiting for fonts and with a console warning, unless a
runtime has claimed them. The gate's runtime claims them when it loads, before
DOMContentLoaded, so the scripts run exactly once either way.
* fix(studio-server): motion paths animate in Studio preview when gsap loads in the body
The MotionPathPlugin tag Studio inserts after a body gsap script now carries
that script's type, so the runtime runs it right after gsap once fonts are
ready, instead of before gsap exists.
* fix(cli): layout, motion-shot and validate wait for the composition scripts to run
The three commands waited on the timeline registry, which exists as soon as the
runtime file loads, or on a flat sleep. They now wait for the runtime's
render-ready flag through one shared helper, keeping each command's timeout and
proceeding as before when it passes.
* fix(core): a script that wraps addEventListener keeps its wrapper after its scripts run
While composition scripts run, the runtime patches addEventListener to hold
load events that already passed. Putting the original back now happens only if
the patch is still in place, so a library wrapper installed meanwhile survives.
* docs: comments on script timing match scripts that wait for web fonts
The early stub's flush and the player's iframe-load handler described page
scripts as done by DOMContentLoaded and the runtime as ready before load.
Composition scripts now wait for web fonts, so both can come later. Comment-only.
* test(core): pin script order after an external script and the scene swap's font wait
An inline composition script that follows an external one sees its global, and
a scene swap runs the new scene's script only once web fonts are ready.
* docs(producer): the early stub comment names what drains late font-gated work
* fix(studio): motion paths add without a flash when gsap loads after web fonts
Studio preloads MotionPathPlugin on iframe load. A body gsap now runs after web fonts, often after that load, so the preload retries once the runtime reports ready.
* test(cli): pin the runtime wait before each command first samples the page
* fix(studio): the motion path preload stops waiting once the preview runtime has booted
A composition with no global gsap posts ready before the preload runs; the retry listener then never detached and kept each retired preview document alive. It now waits only while the runtime has not booted, and the tests pin the cleanup and the ready filter.
* fix(studio): a preview frame that never boots is not kept alive by the motion path preload
Only the latest preview waits for the ready message of its runtime; a newer load drops the older wait, so a crashed or missing composition frame can be collected.
* style: comments added by the font gate move into names
* test(core): pin the authored floor's direct children, the one-frame media skip and lottie sources
* test(core): pin the loaded-Lottie-library half of the pending rule
* test(core): pin how the runtime resolves a composition's length and size
* refactor(core): move composition length and root size rules into a pure module
* test(core): pin the one-frame timeline floor against a derived length
* fix(core): build and declare the composition-length subpath, reject a non-finite declared length
* fix(studio-server): freeze still appears whole, so a slow extraction cannot break its undo
The freeze route reserved the still's final name with an empty file before
ffmpeg ran. When extraction outlasted the history's quiet time, history
committed that empty file as an outside change, and Cmd+Z then restored a
0-byte PNG. ffmpeg now writes under an atomic temp name the history skips,
and the still is published with createFileAtomically (EEXIST still refuses
a taken name).
* fix(core): a direct exclusive write that fails midway leaves no partial file
Without hard links, createFileAtomically writes the destination directly.
A full disk then left a partial file behind (a corrupt upload or freeze
still); it is now removed, and a name another writer holds is never
touched. Freeze tests: the slow-extraction test leaves a half-written temp
file while history commits, and concurrent extractions meet at a barrier
instead of a real timer.
* fix(core): a quota reported only at close still removes the partial file
* test(core): the direct write closes once and keeps its write error when cleanup fails
* test(core): a failed direct write closes its descriptor exactly once
* refactor(studio-server): freeze names carry what their comments said
* fix(core): a call on the playhead fires once when the preview redraws it
The silent redraw steps off the playhead and back, which re-armed a call sitting exactly on it, so the following seek fired it again. Keep each such call's fired state across the redraw.
* fix(core): a redraw keeps every call's fired state, wherever the call sits
Restoring one field for calls found by position missed a fresh call at 0, calls in repeating or reversed timelines and double-speed scenes. Snapshot ratio and _zTime of every zero-duration callback tween instead.
* fix(studio): load MotionPathPlugin at the composition's own gsap version
* fix(core): list the gsap-cdn subpath where core's exports are generated from
* fix(core): every default gsap URL comes from the one gsap-cdn owner
* fix(core): the gsap a new composition or first tween gets comes from the gsap-cdn owner
* perf(core): a paused Studio drag lets the preview loop sleep until the drag ends
A paused manual-edit gesture marked a reconciling seek as owed, and an owed seek kept the transport ticking every frame for the whole drag: timing reads, a full clip-manifest post every 20 frames that Studio rebuilds its clip list from, and media rebinds every 30. Nothing is owed until the gesture marker clears, and the gesture watch already wakes the loop at that moment, so the loop now parks mid-gesture.
* docs(core): keep the park comment to one line
* fix(core): releasing a paused-media lease wakes the parked loop to stop that media
Release only dropped the lease, so a parked transport kept letting the released element play until an unrelated wake. The lease test passed only because it started the media again after release, which fires a play event.
* test(core): load the runtime once per file so the entry tests' first timeout covers only its own work
* fix(core): a torn-down runtime's pending readiness check no longer touches the next document
* fix(core): a seek onto a tween's start shows that tween, not the earlier one's end
The seek re-render stepped to just below the target and back. A child
starting exactly at the target sits at local time 0 both before and after
that step, so GSAP skips it going forward and an earlier tween on the same
property keeps the frame (fromto edits at the playhead read the from()
end, 35-50 px off). One forced silent render at the target walks every
child forward in authored order instead, so same-time steps still apply in
order.
* style(core): format the seek re-render change
* fix(core): re-render a seek from just above too, not with a forced render
A forced render at the seek time redrew every tween GSAP had ever rendered,
including ones that start later, at their recorded start values: after a
seek past the end, a later tween's start showed early (style-5-prod,
hdr-regression). The re-render now steps silently just above the target,
then just below, then to it. The step up makes a child that starts exactly
at the target cross its start on the way down, so it renders; tweens that
have not started are left as the seek put them, and the last move is still
forward, so same-time steps keep their authored order.
* fix(core): re-cross only the tweens that start at the seek time
The step up past the target (d5c5d797) initialised tweens starting within
the next millisecond, so an overwrite: "auto" tween killed the one before
it, and it crossed a repeatRefresh boundary sitting on the target. The
re-render is back to #4911's step from just below. That step skips a
keyframes or stagger tween already at its start (GSAP passes no start
crossing into the tween's own timeline), so after it each such tween that
starts exactly at the target, at any nesting depth, re-crosses its own
start alone. No other child moves.
* refactor(core): keep the GSAP members the re-cross reads local to the adapter
The shared child type gained members that clash with RuntimeTimelineLike in
the runtime typecheck. The adapter now types them locally and skips any child
that is not a GSAP animation, which also keeps the walk simple.
* fix(core): re-cross only forward-playing tweens, by a microsecond
GSAP skips paused and reversed children when it renders a timeline, so the
re-cross does too: it read a reversed keyframed tween's start instead of its
end and reset a paused one. The step past the start shrinks from 1 ms to
1 us, so it cannot start a stagger's next target or cross a short tween's
repeat.
* fix(core): re-cross only keyframed tweens, by less than GSAP's time step
A 1 us step still reached a stagger target 0.5 us in and a 0.4 us repeat,
both sizes GSAP can represent. The step is now 2e-8: above GSAP's zero
threshold, so the tween leaves its start, but below its 1e-7 time rounding,
so the tween's own timeline does not move. Only keyframed tweens are
re-crossed; a stagger is left as main renders it.
* fix(core): move keyframed tweens across their start before the forward step
Re-crossing a keyframed tween after the timeline's forward render made it
win over a later same-time sibling (a set after it at the same time showed
the keyframe, not the set). The keyframed tweens that start at the target
are now moved across their start during the step from below, and the
timeline's own forward step renders them with every sibling, in authored
order.
The seek path skips its silent re-render on timelines with a zero-duration callback, because that
re-render re-arms the callback and the next forward seek fires it again. The answer was cached per
timeline on the first seek, so a tl.call added later was never seen: the re-render ran and the call
fired twice. The check now reads the timeline's current children on every seek.
* fix(core): music keeps playing smoothly across video cuts in preview
While no Web Audio buffer plays, the preview playhead follows an audio element. It followed the first
one in page order, so a sub-composition whose shots each carry a short sound clip took the playhead
away from the music bed. At every cut the next shot's sound starts late, the playhead waited for it,
the music played on, and media sync then pulled the music back: a jump at each cut. The followed clip
still keeps the playhead; otherwise the in-window clip that runs longest takes it.
* refactor(core): check the followed clip first and drop the clock pick's flag
* fix(core): an ended clip never leads the playhead, and the followed clip keeps it
A bed whose file ends before its authored length no longer wins the pick and leaves the playhead on wall
time. Pins the rule that a longer clip starting late cannot take the playhead from the followed one.
* fix(studio): show each timeline row its own layer instead of a grey bar
A row got a frame only if it spanned under 92% of the film and its id did not
look like a backdrop; every other row drew a plain grey bar. Every element row
now gets its frame, and the capture fades the row element's siblings first, so
the frame shows that layer and not the whole film. Studio's dev server and the
CLI share one capture helper; Studio's own copy is gone.
* fix(studio): read timeline rows from any realm so each keeps its selector
* refactor(studio-server): always isolate the element in a thumbnail clip
* fix(cli): undo a thumbnail's isolation before its page takes the next capture
* chore(core): generate the dom-realm export and trim thumbnail clip comments
* fix(studio-server): restore sibling opacity after a clip, hash selectors in cache keys
* fix(studio-server): keep faded siblings in a page-owned list so restore is exact
* feat(studio): draw a text layer row as its own live text instead of a captured picture
* refactor(studio): move timeline text reading into its own module
* fix(studio): keep text rows readable and current, and leave painted layers as pictures
* fix(studio): refresh a canvas-edited text row by its own identity and source file
* fix(studio): refresh a canvas-edited text row known only by its data-hf-id
* fix(core): render tl.set steps on the frame they land on
The runtime re-rendered a seek by stepping just past the target and back.
Arriving backwards, GSAP applies same-time zero-duration steps in reverse,
so a film that hides every frame then shows one, on its frame grid, lost
that layer on every step: in renders, previews and thumbnails. It also
fired a tl.call on the target time again on each repeated seek. Both seek
paths now re-render from just below the target, silently.
* test(core): expect the seek re-render to arrive from below
* test(core): pin the seek re-render from below in the runtime seek script
* fix(core): keep re-rendering a seek to 0 from above
* test(producer): rebase style-4-prod on caption frames that render forward
* 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>
* 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>
* 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>
* 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(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>
* 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>
* fix(core): a composition made only of a set shows it at a seek in the preview
* chore(core): keep the comment share of the runtime init file level
* test(studio): bank the four resize-hold rows the preview now gets right
* fix: a save's temp file no longer reaches Studio as an outside change
* refactor: the project file walk and its tests carry no new duplication or complexity
* test(studio): the edit accuracy gate judges the two nudge sequences again