* fix(studio-server): history never files a hidden file as a change
* fix(studio-server): an older log never makes history write a hidden file
* fix(studio-server): an older log's hidden-file entry no longer stalls Undo
* fix(studio-server): an older log's emptied Undo still keeps its target undone
* fix(studio-server): an older log keeps every entry, and undoing an emptied one still lands
* refactor(studio-server): history's hidden-path rule says itself in names, not comments
* 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
* refactor(studio): the auto-id write posts through the one patch-element client
* test(studio): pin the auto-id write's saves, refusals and unsafe targets
* test(studio): pin the auto-id write's plain refusal and missing changed flag
* test(studio): the auto-id write needs a toast handler and sends the write token
* refactor(studio): one request for every GSAP mutation write
* fix(studio): a refused id write no longer leaves the id on the previewed element
* style(studio): drop the two comments the names already carry
* fix(studio): keep minting the id on the element so overlapping adds never share one
* fix(studio): an id the file already holds counts as saved, and no project leaves no id
* fix(studio): the server picks an added effect's element id, so ids never repeat
The browser minted the id from the live preview and rolled it back when the
write failed. A reply lost after the id was saved left the file holding an id
the preview had dropped, so the next add on a sibling wrote the same id again;
and a refused add could strip the id an overlapping saved add had just given
the same element.
A new patch-element op, ensure-id, keeps the element's id or makes the proposed
one unique in the file, and every patch-element reply names the element's id.
Studio sets the id on the preview only from that reply, so there is nothing to
roll back. nextUniqueId moves to sourceMutation as the one owner (split and
duplicate already used it).
* test(studio): pin the gsap write token and the refused-write toast
* refactor(studio): one unique-id helper on the server, and shorter id comments
* style(studio): keep the id helper doc under the line limit and the test-utils header true
* fix(studio): an added effect's id stays unique across every composition file
* style(studio): keep the files route's comment share at the base
* fix(studio): count an id written right after a quote or slash as taken
* fix(studio): a quote-ended string no longer hides another file's id
* 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(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
* fix(studio): undoing a freeze frame removes the still it made
* fix(studio-server): a failed frame extraction leaves no partial still
* fix(studio-server): a freeze claims its still's name before extracting
* fix(studio-server): a freeze past the media's end fails instead of saving an empty still
* refactor(studio-server): freeze route names say what its comments said
* fix(studio): an edit on a GSAP-animated layer writes its value at the playhead into the file's tween
* fix(studio): undo pressed while an edit saves undoes that edit, not one started after the key
* fix(studio): an edit stays saved when the history reply after it cannot be read
* feat(studio): export ColorField and GradientField; a session style commit takes a map
* refactor(studio): name the map form instead of commenting it
* feat(studio): a host can move layers by exact film px, one undo per call
handleDomGroupMoveBy(moves) on useDomEditSession and DomEditValue moves each
selection by its delta in composition px, for Desktop's align and distribute.
It reuses the group drag's own path: a drag member per element (press-time
route, measured offset map), the delta carried to screen px through the
preview frames, then handleGsapAwareGroupPathOffsetCommit, so one shared
coalesce key gives one undo entry and GSAP members take the GSAP write.
A member that can't move refuses the call before anything is written; the
refusal rejects with its reason and shows no Studio toast (the group commit
takes refusalToast: false; telemetry stays). Zero deltas are skipped.
Tests: a pre-offset element and one under a half-scale parent move by exactly
{10,0} and {0,-5} under one group key; a half-scale parent with a 30 deg
rotated ancestor lands within the 0.001 px a translate is written with; a
GSAP member goes through the GSAP write; three refusal kinds write nothing,
leave the boxes where they were, show no toast and leave no gesture mark,
while a canvas group drag still toasts; the session hands the call to the
group commit with refusalToast: false. Each mutation (no frame mapping, no
zero skip, no restore, no teardown, no capability check, toast on refusal)
fails at least one of them.
* fix(studio): an outside change during a drag waits for the drop to show
While a gesture mark is live in the preview, refreshPlayer takes the shadow load instead of
swapping scenes in place. A swap replaced the node under the pointer mid-drag; the shadow load
is then held by the existing gesture hold and, if the drop saved, reloaded fresh by the stale
rule, so this adds no second "released" signal.
Test: an outside change during a live drag on a preview that can swap scenes leaves the dragged
node in place, shows nothing under the pointer, and after the drop loads fresh with the dropped
position. It fails on the parent without the condition ("swapped under the pointer").
Cost case, measured in a real headless preview on the edit-accuracy bench's per-frame teleport
check. Every GSAP instant patch was forced to miss and fall back to reloadPreview during its own
commit; move and rotate of a held element (gsap.set and tl.set holds), px and xPercent
placement, r0 and r30, root and nested, at 100%:
- The gesture mark was live when the fallback asked for the reload, but had always ended by the
time refreshPlayer ran (refreshKey is consumed in an effect after the commit settles). So the
new condition never fired there: root previews offer no scene swap and load shadow on both
builds, and nested previews swap on both builds.
- Worst post-release frame offset, 32 cases on this build and 32 on the parent: 0.00 px over
76-106 frames each (release to settle). No frame was off by more than 0.5 px.
- Sensitivity: a 3 px nudge injected 50 ms after the fallback reads back as 3.00 px.
* fix(studio): an edit no longer reopens the live-updates stream, and thumbnails ask one at a time
* fix(studio): an undo no longer waits behind paused preview videos for a connection
* feat(studio): a host whose API needs same-origin cookies can have Studio send them
* docs(studio-server): a host on same-origin cookies sets the Studio credentials opt-out
* test(studio): an undo is not held up by the preview's paused videos
* fix(studio): a resize inside one gesture writes a mixed tween once, in place, never beside it
* chore(studio): keep the carried fixes inside the comment ratchet; telemetry posts through studioApiFetch
* test(studio): a drag on a mixed tween is one write under the gesture key
* fix(studio): freeze-frame, loudness and peak-map requests go through studioApiFetch too
* fix(studio): an undo with no project open reloads in full instead of skipping nested files
* refactor(studio): drop the soft reload's reparse seeding, the held-element sweep covers it
* test(studio): keep the undo-restore browser test off the network and inside its timeout
* test(studio): nested composition reads expect the cookie-free request studioApiFetch sends
* fix(studio): an edit at the playhead rewrites its tween in place, so later tweens keep their times
* fix(studio): a rotate on a tween that turns another axis holds its angle and never turns early
* fix(studio): plan every group member before the first write so one refusal moves none
A group drag or nudge preflighted each member against the move refusal rule only. The value-at-playhead planner ran later, in the commit loop, after earlier members were already flushed to disk, so a tween with extras, eased keyframes or an unknown ease left the group half moved.
The group preflight now runs the commit's own route and keyframe plan for every member with its real offset, so any refusal refuses the whole group with nothing written and one toast attributed to the refusing member. The plan is not reused in the commit: earlier members' writes change the file under it, so each member re-plans against the fresh tween, from the same planValueEdit.
* chore(studio): the fetch lint rule also catches window.fetch, globalThis.fetch and self.fetch
* docs(studio-server): set the credentials flag before Studio loads; a mounting host injects it
* test(studio): the undo socket test needs six pinned videos under Vite and checks each undo restores
* fix(studio): Studio leaves cookies out of its requests only when served from loopback
* fix(studio): undo takes back the first edit, and the first save never waits on media
* fix(studio): undo of a move whose save failed stays undone and steps back no older edit
* fix(studio): a style set by its JavaScript name saves under its CSS name
* test(studio): the undo socket test holds round 0 to a full restore too
* fix(studio): the host revert keeps returning what shows the edit again
* fix(studio): an entrance rewritten at the playhead still shows its start state before it starts
* fix(studio): undo takes back the edit pending at the key even after a later edit claims
* chore(studio): lift the late-write quarantine now that undo never waits on videos; format
* test(studio): pin an entrance's start render on the written tween, not a happy-dom GSAP run
* revert(studio): leave entrances as on main, immediateRender does not render a keyframed start
This reverts commit ee9d130d3c and its written-vars test.
* revert(studio): undo goes back to the newest claim after the key, as on main
This reverts commit 5b9009a823.
* revert(studio): first-undo history opening moves to the next PR
This reverts commit 4e1e75d7f4.
* revert(studio): the undo socket test exempts round 0 again until first-undo lands
This reverts commit 1f8f97047c.
* test(studio): bank the edit accuracy cases this PR makes pass
* refactor(studio): share one project file fetch between the plain and optional reads
* test(studio): share the WebMCP host shim between the browser gates
* test(studio-server): share one stub adapter across the route tests
* refactor(studio): type the preview slot props from the NLE context
* test(studio): share the setup the new editing tests copied between files
* refactor(studio): split playhead tween picking and undo path merging into small tested helpers
* test(studio): pin when a resize at the playhead also writes the anchor move
* test(parsers): pin rewriting a tween in place as keyframes
* chore(studio): cut comments the new helper names already say
* fix(studio): composition thumbnails keep two fetches in flight, as the document lifetime fix expects
* fix(studio): a playhead edit parses a tween not yet played and matches only its own tween
* fix(parsers): a new position argument goes after the last argument, so trailing commas stay valid
* fix(studio): a second undo pressed during one save steps back to the edit before it
* test(studio): pin the slot an aborted thumbnail frees and the shared tween refusal
* refactor(studio): split the unplayed-tween parse into small helpers and name the claim release
* fix(studio): a playhead edit keeps the tween's default ease, delay timing and loop settings
* fix(studio): a group move refuses layers animated in two files and plans at the real drop
* style(studio): format two test files
* refactor(studio): check a group's files inside the preflight helper
* refactor(studio): move the group preflight checks out of the editing hook, under 600 lines
* fix(studio): read an unreached tween without redrawing other layers, and compare write files
* test(studio): use the shared selection helper in main's keyframe-at-time telemetry test
* chore(studio): fix a comment that read as a repo path and cut one added comment
* fix(studio): read a later tween's start as playback does, and count every keyframe add
* fix(studio): two undo presses during one save step back two edits, not one twice
* fix(studio): an undo that succeeds by any route retires the claim on the edit it undid
* fix(studio): a claim is never handed back to an edit another undo has already undone
* fix(studio): an undo picks its claimed edit at the press and skips it once undone
* fix(studio): a press refuses, as main did, once its edit is undone and a newer edit exists
* fix(studio): an unplayed keyframe tween clears every keyframe's channels before it is read
* fix(studio): reading an unplayed tween puts back every transform value its clear removed
* fix(studio): reading an unplayed tween restores each layer's whole style and GSAP state
* fix(studio): reading an unplayed tween skips plain-object targets and restores attributes
* fix(studio): shared tween-target filter uses GSAP's own styled-element test
* fix(studio): match a parsed tween on any target, and check a later keyframe channel's start
* docs(studio): say the unplayed-tween read restores styled elements only
* test(studio): a drag on a fromTo start shows the dropped x through the runtime seek
* perf(studio): keep timeline bulk actions under a second, hand-move at most 3 clips
Dragging or keyboard-moving a selection of 4 or more clips is refused with a
toast pointing at the prompt box; 1 to 3 clips move as before.
Root causes of the multi-second waits on large projects:
- every selected clip fired its own source-existence probe; they now leave as
one request per file
- a timeline delete sent one request per clip, each re-parsing the file; it is
now one request that parses once
- each click on a clip from another file re-fetched GSAP data for every
composition; covered files are kept and unchanged files answer 304
- any root index.html write re-captured every composition thumbnail; only a
root head edit does now, since a scene page borrows nothing else from it
Adds timeline-bulk-actions.mjs, a browser benchmark of select-all, move 3 and
delete-all on a real project.
* chore(studio): trim the bulk benchmark header to the comment limit
* refactor(studio): tidy the bulk-action fixes after review
Root head memo is keyed by the root file's content so every subscriber of one write gets one answer, and a thumbnail request can no longer overwrite it. The GSAP read reuses the shared content version as its ETag.
* fix(studio): keep a head edit's thumbnail refresh when a later write replaces it
File changes coalesce to the newest while a drain is in flight, so the affected-composition lists are merged instead of replaced. A failed GSAP read stays unloaded and is retried, and the GSAP read's ETag is salted with the parser version.
* feat(studio): cap hand-resizing a multi-selection at 3 clips like moving
Dragging a resize edge with 4 or more clips selected is refused with the same toast as a move; the rule is one constant and one helper shared by pointer move, pointer resize and keyboard pickup.
* fix(studio): keep a head edit's thumbnail refresh through a replaced conflict
A newer blocked change replaces the held one, so it now inherits the thumbnails the held one owed.
* fix(studio): keep a held head edit's thumbnail refresh when a later write saves cleanly
The clean-save path and the failed-cleanup path now merge the held blocked change's affected compositions, and the blocked-state merge runs inside the state update so two conflicts in one tick cannot lose the first.
* chore(studio): keep useTimelineClipDrag under the 600-line cap
* feat(studio): cap by the clips an edit would change, and cut the added comments
The 3-clip rule counts the clips a move or trim would write to: a locked clip swept into a move stays put, and a trim some member cannot take edits only the grabbed clip. Comment share is back at or under the base in every changed file.
* fix(studio): clear fallow audit and comment length on the multi-select cap
* fix(studio): keep bench script comments under the block limit
* fix(studio): keep the failed file's payload when another file's snapshot delete fails
* test(cli): time history turns on one injected clock instead of real waits
* fix(studio-server): end an idle history window by its own clock, not a bare timer
* fix(studio-server): cap a history window's idle timer at Node's timer range
* 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(studio-server): a rename rewrites only references that name the renamed path
* fix(studio-server): rename keeps root leads and reads escaped separators
* fix(studio-server): rename matches whole references only and cannot backtrack
* fix(studio-server): rename leaves paths that exist alone, whatever their names hold
* fix(studio-server): rename checks every existing path once and reads escaped spellings
* fix(studio-server): rename finds existing longer paths by lookup, on normalised text
* fix(studio-server): rename inventories every path and indexes lengths per suffix
* fix(studio-server): rename inventory follows linked folders
* fix(studio-server): rename classifies its source before moving it
* fix(cli): hide console windows on Windows for background/detached spawns
Add windowsHide: true to 8 spawn/execFile call sites that were missing
it: ffprobe/ffmpeg probes in the producer and engine packages, the
telemetry exit-flush and studio dev-server spawns, and the browser-open
and background-preview detached launches in the CLI.
previewLifecycle.ts's spawnDetachedPreview redirects stdout/stderr to a
raw log-file descriptor; libuv only adds Windows' CREATE_NO_WINDOW flag
when no stdio slot inherits a raw handle, so windowsHide alone does not
fully suppress a console flash there (documented inline) — it's still
added for consistency and because it correctly sets the process's
show-window state regardless.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli): correct windowsHide flash-risk claims and add missing dev-server spawn tests
- The previewLifecycle.ts comment (and its mirrored test comment)
overstated the residual console-flash risk on the detached background
preview spawn: DETACHED_PROCESS already leaves that child without a
console regardless of whether CREATE_NO_WINDOW gets applied.
- Add the two missing regression tests for preview.ts's runDevMode and
runLocalStudioMode spawns, exporting both for test access.
- Drop an unused export in audioPadTrim.ts flagged by the repo's dead-code
gate; the constant is only ever used within its own file.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(engine): hide console window for the drawElement self-verify PSNR comparison
psnr.ts's execFile call runs in-process inside the Studio server's render
path (parallelCoordinator.ts's drawElement disk self-verify), so a render
started from Studio's UI under a detached preview hits this call from a
console-less parent — the same flash case the other windowsHide sites in
this PR already cover. psnrFilterAvailability.ts's probe is on the same
in-process render path and already had the flag.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli): keep only the spawn sites main still misses and leave browser windows visible
windowsHide maps to libuv UV_PROCESS_WINDOWS_HIDE, which also starts GUI apps with SW_HIDE,
so the detached browser launch must not set it. The background preview child already has
no console (DETACHED_PROCESS with inherited log fds), so its flag was a no-op. The
telemetry, psnr and audioPadTrim sites shipped on main via #3839 and #4604.
* fix(cli): stop the studio dev server when the terminal closes
With windowsHide the dev server runs in its own hidden console on Windows, so closing the terminal
only signals the wrapper (SIGHUP). Handle it like SIGINT and SIGTERM and kill the process tree.
* fix(studio-server): hide the console window for loudness measurement
The background preview has no console, so its ffmpeg loudness run opened a visible window on Windows.
* refactor(cli): drop the comment on the studio shutdown signals
---------
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>
* 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
* 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>
* 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>
* fix(studio): resizing an element GSAP only positions saves its CSS width and height
* fix(studio): a resize never saves GSAP's scale mask as the element's CSS
* fix(studio): keep the resize size writer inside the file and comment budgets
* fix(studio): a GSAP-positioned resize saves its CSS size before the GSAP write renders
* fix(studio): undoing a crop leaves a GSAP-animated layer where its file puts it
* test(studio): an undo on a GSAP-parsed layer in a two-script file reloads the preview
* fix(studio): a move keeps an authored xPercent centring set in the saved file
* fix(studio): a drag on a centred element no longer flashes off-centre on release
* fix(studio): a font pick saves the edit and its @font-face in one write
* fix(studio): a focused combobox, switch or plain-text editor counts as an unsaved edit
* test(scripts): pin that a slash after i-- before a quoted slash is division
* fix(studio): an inspector edit saved while a preview reload loads stays on screen
* fix(studio-server): a font save accepts exactly one @font-face rule and nothing after it
* fix(studio-server): a font name or path with braces in quotes still saves its @font-face
* fix(studio-server): a CR or form feed in a quoted font name cannot end the @font-face rule
* test(studio): bank the 99 edit accuracy cases the GSAP fixes now pass
* 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
* feat(studio-server): a write receipt names the version the write replaced
* test(studio-server): route receipts carry the version each write replaced
* refactor(studio-server): the receipt's from field needs no comment
* fix(studio): the preview serves a same-size edit written right after the last one
* chore(studio): keep the comment share and complexity gates green
* refactor(studio): keep the project signature doc comment on the function it describes
* fix(studio): the preview re-reads files written in the last two seconds, whoever wrote them
* fix(studio-server): a folder rename past an unreadable folder or file answers ok
* fix(studio-server): a rename skips only folders and files it may not read
* fix(studio-server): the rename's skip helpers say it by name
* test(studio-server): the rename tests share one request
* fix(studio): open projects whose folder name has a colon
* fix(studio): open projects named with a colon or '#' in Studio and its preview
* fix(studio): refuse a Windows stream id reached through a session alias
One adapter rule, isServableProjectId, now guards the requested id, a
session's projectId and the folder listing. The bundler-failure preview
places its <base> inside an attributed <head>, and the media metadata
cache keys on the encoded URL so ids with ':' can't collide.