Commit Graph
1270 Commits
Author SHA1 Message Date
Miguel Ángel cc36905c29 fix(studio): resolve a multi-clip selection's network probes in parallel (#4088) 2026-09-18 12:08:06 -04:00
Miguel Ángel 42f947f4c3 fix(studio): select and reveal a timeline clip after a drop (#4067)
* fix(studio): select and reveal a timeline clip after it's dropped

Dropping an asset, block, or composition onto the timeline placed
the clip but never selected it, so the user had to go find it. Wire
the same select+reveal pair AssetCard/AudioRow already use into all
three drop paths via a shared selectAndRevealTimelineElement helper.

Root-caused a shared bug along the way: useTimelineSelectionPreviewSync
compared the resolved selection count against only the ids already
present in timelineElements, not against every id actually wanted. A
freshly-selected id not yet in timelineElements (a just-dropped,
pasted, or duplicated clip) made that comparison pass on zero
resolved out of zero expected, falling through into a loud selection
clear instead of the intended quiet retry. Fixed the comparison and
covered drop, paste, and duplicate-of-a-group.

* refactor(studio): drop the dead fallback in the new store-key helper

Simplify-pass finding: both new selectAndRevealTimelineElement call
sites always pass a DOM id they just minted, so the `?? fallback`
after deriveTimelineStoreKey (which only returns undefined for a
domId-less, selector-less caller) could never run, and the pattern
was duplicated at both call sites. Added a non-optional variant for
callers that already hold a guaranteed DOM id, and dropped the
now-redundant "old code" narration from a test comment.

* fix(studio): cap the selection-sync retry so a dead member cannot stall the group

The bail-and-retry added for the drop/paste/duplicate race had no
upper bound: a group member that can never resolve (deleted out of
band, a stale id) made the whole group's selection retry forever
instead of settling. Cap it at a few attempts, then apply whatever
did resolve, same as the pre-fix graceful degradation, only bounded
now instead of unconditional on every sync.

* fix(studio): select and reveal a block dropped onto the preview canvas

handlePreviewBlockDrop (a real drag-and-drop onto the preview pane, wired
from usePreviewBlockDrop) was the fourth caller of addBlockToProject's
hostKey but never selected the new element, unlike the other three drop
paths this change wires up.

* fix(studio): close a stale retry-budget gap and dedupe the store-key helpers

Post-rebase simplify pass and independent review against the new head:
deriveTimelineStoreKeyForDomId now calls deriveTimelineStoreKey instead of
reimplementing its body, the composition-insert path uses the same helper
instead of hand-building the sourceFile#domId format, and the selection-sync
retry counter resets on deselect so reselecting an already-exhausted,
permanently-unresolvable id gets a fresh grace window instead of degrading
on the first attempt. Adds coverage for the asset-drop selection wiring and
the deselect/reselect retry-budget reset.

* style(studio): shorten a retry-budget docstring past the comment-line cap

* fix(studio): quiet the just-dropped-clip toast and reveal the full clip

The selection-not-found toast fired on the routine first miss while a
freshly dropped or pasted clip was still resolving; it now waits until
the retry budget is exhausted. The timeline focus coordinator likewise
cleared a reveal request on its first miss before the dropped element
had landed in logicalRows, cutting the clip off at the viewport edge;
it now tolerates a bounded number of retries like its selection-sync
sibling, so AssetCard and AudioRow reveals benefit too.

* refactor(studio): share one retry-budget tracker between the two hooks

The selection-sync and timeline-focus retry counters were the same
shape, incremented and reset independently. Extracted recordRetryAttempt
so both hooks call one tracker instead of maintaining their own copy.

* fix(studio): stop the post-edit scroll restore from undoing a fresh reveal

A dropped clip's reveal could still land cut off at the viewport edge
even with the retry fix: Timeline's "restore scroll after an edit
re-derives the elements" effect and the focus coordinator's reveal both
react to the same element-list change, but the restore effect captured
lastScrollLeftRef before the reveal had a chance to update it, so its
deferred frame stomped the reveal's scroll position back to the stale
pre-drop value. The reveal now keeps lastScrollLeftRef in sync with its
own write, and the restore effect reads the ref inside its frame instead
of capturing it early, so whichever effect runs last within the commit
wins with the freshest value. Confirmed against the original repro (a
Playwright drop scripted through the real dev server) that scrollLeft
converges to the fully-revealed value and stays there.

* docs(studio): note the load-bearing hook order behind the scroll-race fix

Flagged by an independent review of the prior commit: nothing in the
suite would catch a future refactor that reorders these two hook calls
and reintroduces the stale-scroll race.

* fix(studio): bound an unresolved focus request by wall time, not just retries

Answers two review questions on the scroll-race fix. (1) The retry
budget only advances when this effect re-runs, which needs some other
dependency to change; a request whose target never appears and which
nothing else ever re-renders sat unresolved forever, staying eligible
to resolve against an unrelated element that later reused its id. A
setTimeout backstop now clears it after 4s regardless of re-renders,
independent of the retry-count path. New test proves the leak and the
fix (fails without the timeout, passes with it).

(2) The "hook order is load-bearing" comment in Timeline.tsx no longer
holds once the geometry effect's ref read is deferred into its own
frame: a new test co-renders both hooks in both registration orders
and both converge, so the order claim is false for the current code.
Deleted the comment; the same test fails in geometry-first order when
run against the pre-deferral code, confirming order mattered before
and does not now.

* refactor(studio): drop a nonce check the store already performs

Simplify pass on the wall-time backstop commit. The timeout callback
guarded clearTimelineFocus behind a nonce match, but the store action
already no-ops on a mismatched nonce (a zustand same-reference return
short-circuits it) — the guard duplicated a check the setter owns, and
was inconsistent with the sibling budget-exhausted branch two lines up
that already calls it unguarded. Confirmed no other findings: the
two-mechanism clearing (retry count plus wall time) is genuine, not
redundant, and the timer has no leak path.
2026-09-18 11:13:48 -04:00
Miguel Ángel e36a53d6b6 fix(core): hold render-ready on declared compute, not just adapter loaders (#4065)
* feat(core): hold render-ready on declared compute and a build-ready hold, not just duration

Adds the compute and paint-and-idle inputs to the composition readiness gate,
plus window.__hf.buildReady: a promise registry a piece registers when it has
setup work no adapter can see (mesh building, shader compiles). The runtime
holds render-ready on it and the player shows its loading state instead of a
blank frame while it resolves.

* refactor(core): dedupe the paint-and-idle readiness test setup

Simplify pass on 23810da1b: fallow flagged the two paintAndIdleReadinessInput
tests as a 12-line clone; extracted the shared doc/tracking setup.

* fix(core): stop the readiness settled-tracker hanging on 2+ pending promises

Promise.all allocates a new promise every call, so comparing ITS identity
across repeat polls (isAdapterReadinessSettled, isBuildReadinessSettled) never
observes settled once 2+ promises are combined: every poll rebuilds a new
combined promise, re-arms, and republishes forever without the caller ever
seeing true. Reproduced with two already-resolved window.__hf.buildReady
entries, which is the exact multi-piece usage the new authoring doc describes.

Fixes both trackers at once with a shared createSettledTracker that compares
the source promise array by element instead of the Promise.all wrapper.

* fix(core): cancel readiness inputs once settleCompositionReadiness settles

computeReadinessInput's poll timer and paintAndIdleReadinessInput's rAF chain kept
running after the shared timeout won the race, since nothing told the losing
inputs to stop. One AbortSignal now threads through all three inputs and is
aborted the instant the race settles.

* docs(core): fix a stale sync-callback claim in settleCompositionReadiness

paintAndIdleReadinessInput always has a frame to wait on for any real
document, so the sync fast path this docstring described is no longer
reachable with the default inputs except for a viewless document.
Found by the simplify pass on this branch.

* fix(core): clear a stale buildReady entry on runtime teardown

A never-resolved window.__hf.buildReady promise from a torn-down
composition survived into the next init in the same window (window.__hf
is never reset), permanently blocking render-ready for whatever loaded
next -- the exact black-screen class this branch fixes, reproduced
through its own new mechanism. Found by the independent review.

Also fixes a stale hyperframes-player.ts docstring (media/images/fonts
had not been updated to compute + paint-and-idle) and documents the
buildReady key-collision hazard in three.md.

* fix(core): skip the compute readiness wait when no runtime ever ran

computeReadinessInput polled window.__renderReady for the full shared
timeout on any same-origin document with no HyperFrames runtime
injected (a probe-only composition using native video/duration, or a
blank test iframe) — nothing was ever going to set that flag. Checking
window.__hf first (the same signal composition-probe.ts already treats
as "has a runtime") lets it return null instead of waiting.

* fix(player): debounce the loading overlay and diagnose compute timeouts

paintAndIdleReadinessInput always has a frame to wait on for an
attached iframe, so settleCompositionReadiness's synchronous-settle
path is unreachable in practice now — the loading overlay showed on
every single Play, even ones with nothing genuinely pending. Debounce
the overlay itself instead: a wait that resolves fast never shows it.

Also names compute (window.__renderReady) in the stuck-assets timeout
diagnostic, which previously only re-scanned media, images and fonts.

* fix(engine): name a stuck buildReady key in the zero-duration diagnostic

A build promise a composition registers on window.__hf.buildReady that
never resolves left the export path's zero-duration timeout naming
nothing useful — hasHf/hasSeek/duration all looked fine, since the
runtime was genuinely present and just waiting on that one promise.
Probes buildReady's keys for pending ones and names them in the hint.

* fix(engine): stop the buildReady diagnostic from crashing on a rejection

A microtask race against a bare resolved promise rejected the whole
diagnostic expression on any rejected buildReady entry (the runtime's
own createSettledTracker rejects deliberately) and mis-sorted a settled
non-native thenable as pending, since its extra adoption tick loses the
race. Both measured directly, not inferred.

Settle each key onto its own record past one macrotask instead: no
throw, and pending/resolved/rejected are distinguished correctly. A
rejected key gets its own hint, separate from a merely pending one.

* fix(player): report a backgrounded document instead of overclaiming a paint bound

MAX_PAINT_WAIT_MS only bounds a document that keeps producing frames; one
that stops entirely (backgrounded tab suspends rAF, or fewer than 3 frames
ever fire) still rides the full shared 8s timeout, same as before this
input existed. Corrected the comments that claimed otherwise.

That gap is exactly why the stuck-assets diagnostic left paint-and-idle
unreported: there was no accurate way to say it might be the cause. Added
documentHidden (doc.hidden) to the diagnostic instead, since a backgrounded
tab is the reachable, directly observable case.

* refactor(engine): simplify the buildReady diagnostic rewrite

Skip the macrotask wait entirely when a composition registers no
buildReady keys — the common case, since it's opt-in. Nothing to settle
means nothing to wait on.

Collapse buildZeroDurationDiagnostic's inline parameter type into the
existing HfDiagnostic interface instead of maintaining two copies of
the same shape.

* docs(engine): note the setTimeout(0)-thenable gap the review found

An independent adversarial review, executing hostile inputs rather than
tracing, found the macrotask wait can still misreport a thenable that
resolves via its own internal setTimeout(0), since the diagnostic's own
timer is registered before the thenable's. Bounded and self-correcting
(only feeds the message text, and the diagnostic re-runs every ~1s), so
documenting the boundary rather than chasing an unbounded fix.
2026-09-18 11:12:44 -04:00
Miguel Ángel 7b4f3a22b0 chore: release v0.8.48 (#4085) 2026-09-18 11:11:40 -04:00
Miguel Ángel 8fe480c0a0 feat(studio): operators get a magnetic main track that closes gaps on delete (#4069)
* feat(studio): closing a gap on the main track closes it automatically

Adds a Ripple toggle to the timeline toolbar (default on). With it on,
deleting a clip on the main track shifts every later main-track clip
left to close the gap, folded into the delete's own undo step. Linked
overlays and the auto-snap/insert behaviors are separate follow-ups.

* refactor(studio): one shared resolver for gap shifts, no scaffolding ahead of its caller

Consolidates three separate shift-to-element lookups (the gap-close menu's
two and the delete-ripple's one) into a single resolveShiftedElements,
built without a non-null assertion. Also drops resolveOpenGapShifts and its
tests from this branch: it ships with its actual caller in the next PR.

* fix(studio): canvas Delete on a timeline clip uses the timeline's own delete op

Clicking a clip then pressing Delete went through the DOM/canvas delete
path, which never ripples the main track — only the timeline's own
right-click Delete did. Both paths now converge: a DOM/canvas selection
that resolves to an exact timeline row hands off to the timeline delete
op instead of the REST remove-elements path, so ripple and undo-coalescing
apply from every gesture, not just one of two.

* test(studio): cover the delete-routing handoff and a stale-session ripple persist

Adds an end-to-end test that a canvas selection resolving to a timeline
row is handed to the timeline delete op rather than the REST path, plus
a test proving an SDK-eligible ripple persist after a delete rebases on
the post-delete disk bytes instead of resurrecting the deleted element
from a stale in-memory session. Also drops the unused single-element
handleDomEditElementDelete plumbing in useDomEditCommits.ts /
useElementLifecycleOps.ts, dead since before this branch.

* test(studio): cover the multi-member delete-routing handoff

Closes an adversarial-review should-fix: the marquee-group path
(expandGroup, 2+ members all resolving to timeline rows) was verified
at the resolver level but not through the useDomEditSession integration
wrapper end to end.

* fix(studio): ripple delete closes only the gap it opened

resolveMainTrackDeleteRippleShifts compacted the whole main track
(resolveAllTrackGaps) instead of the deleted clip's own span, so a
delete also swallowed any pre-existing gap further down the lane, or
pulled every clip to zero when nothing followed the deleted clip.
Now each survivor shifts left by exactly the duration of the deleted
clips before it.

* chore(studio): trim playerStore.ts under the file-size cap

Rebasing onto main put this file's line count over the cap once
combined with this branch's own additions; tightens the playbackRequest
doc comment, no behavior change.

* test(studio): cover multi-select ripple delete of non-adjacent clips

Closes the should-fix from the second adversarial review: a marquee
delete of two or more non-adjacent main-track clips in one batch had
no regression coverage, though the shift-sum logic already handled it.

* fix(studio): undo after a rippled delete names the delete, not the move

editHistory.ts coalesces a folded ripple move into the delete's own
undo entry by keeping the LAST recordEdit call's label — which was
always "Move timeline clips", so the toast read "Undid Move timeline
clips" after a keyboard Delete instead of naming what the user did.
handleTimelineGroupMove now accepts a label override, and the ripple
delete path passes its own "Delete timeline clip" label through it.

* fix(studio): surface and de-dup the ripple's post-delete failure path

A ripple that failed to persist after a committed delete was only
logged to the console, leaving the user with an unexplained gap; now
shows an error toast. The delete's history label was also duplicated
as a literal at both call sites (the delete write and the folded
ripple move) with no shared source; both now read from one constant
so they cannot drift apart.

* test(studio): dedupe the two delete-ops harness mounts

Fallow's duplication check flagged the two new ripple tests' harness
setup as a clone group; extracted a shared mountDeleteHarness helper.

* fix(studio): one toast, not two, on a ripple-persist failure

The generic group-move failure toast and the delete op's own specific
one both fired on the same ripple-persist error, telling the user
about one action twice. handleTimelineGroupMove now accepts a
suppressFailureToast option (console-logged instead, not silenced)
for a caller with its own more specific message, and the delete op's
unconditional "Deleted X" toast is skipped when the ripple failed.

* test(studio): cover suppressFailureToast through the real move hook

The prior test mocked handleTimelineGroupMove entirely, so it proved
the call site passes the option but not that the hook's own catch
honors it. A missing project id makes enqueueGroupOperation reject
before any persist runs, reaching the real catch block without
faking the SDK/server path.

* test(studio): dedupe the shared move-change fixture

Fallow flagged the two suppressFailureToast tests' identical "change"
object as a within-file clone group; hoisted to the describe scope.

* test(studio): unmount the React root in the group-move harness
2026-09-18 10:28:29 -04:00
Miguel Ángel 700b94834d refactor(studio): extract hotkey dispatch out of useAppHotkeys (#4079)
useAppHotkeys.ts sits right at the studio 600-line cap on its own, so any
unrelated growth in the same file (as landed independently in #4064) tips
it over on the merge ref, where the file-size check actually runs.

Moves dispatchModifierKey, dispatchPlainKey, their shared HotkeyCallbacks
type, and handleUndoRedoKey into a new appHotkeysDispatch.ts, the same
split useTimelineDeleteOps.ts already uses for this file's sibling. Pure
code motion: no behavior change, same exports, same call sites.
2026-09-18 10:07:31 -04:00
Miguel Ángel 3306dbcdc0 fix(studio): keep the volume slider editable while automation owns the level (#4066)
* fix(studio): keep the volume slider editable while automation owns the level

The Design panel's Volume slider disabled itself outright once a volume
automation lane existed, with no alternative write path ever wired in — so
turning automation on froze the control permanently.

A commit now inserts or moves a keyframe at the playhead instead, reusing
the exact merge logic the timeline automation lane's own double-click
gesture already uses. The slider also now shows the envelope's live value
at the playhead rather than the static, possibly stale attribute.

* fix(studio): sample the live playhead for volume automation, not the stale store

The store's currentTime only updates once at the end of playback, so a
keyframe committed mid-transport landed at the last seek instead of the
real playhead and could silently overwrite the lane's seed point.
useLivePlayheadTime subscribes to the same live signal the timeline
follows, matching how the audio FX panel already reads it.

Also stops treating a video element's automation as invisible on the
timeline: groupAutomationLanes, its paired row-height count, and the
lane-drawing filter all gated on audio tag, so a volume lane written
from the property panel never showed a track to expand or a keyframe
to see. All three now key off "has lanes" (or audio-or-video where the
element itself, not its attribute, has to gate a live edit), matching
groupAutomationLanes and useTimelineTrackLayout's documented invariant
that the row's reserved height and its drawn lanes count the same clips.

* refactor(studio): simplify pass on the volume-automation fix

Extracted mergeInsertPoint (automationLaneGeometry.ts) so the panel's
withPointAt and the timeline lane's own double-click gesture share one
merge/seed/sort rule instead of two copies of the same three lines.

Fixed a real duplicate subscription: useVolumeAutomation was calling
useLivePlayheadTime() itself even though its only caller already reads
the same value via the prop PropertyPanel.tsx computes and passes down.
Threads currentTime through as a parameter instead.

Unified the three places that decide whether an element can draw an
automation lane (groupAutomationLanes, automationLaneCountOf,
TimelineAutomationLaneSlot's clip filter) behind one exported predicate,
isAudioOrVideoTimelineElement, so the row's reserved height and its
drawn lanes can't independently drift on what counts.

* test(studio): pin the clip-local time translation for volume automation

The adversarial reviewer mutated out `currentTime - elStart` and every
existing test still passed — the duration clamp happened to mask its
absence in every case that touched it. New case starts the clip at
t=10 so the clamp can't hide a missing subtraction.
2026-09-18 09:48:37 -04:00
Miguel Ángel 5ef5419a9e feat(studio): the segment ease control is discoverable at rest (#4074)
* feat(studio): the segment ease control is discoverable at rest

The per-segment ease diamond only ever appeared on hover, so nothing on
the timeline hinted a segment had an editable ease at all. Now it sits
at low emphasis by default and reaches full opacity on hover or focus,
same as every other reveal-on-interaction control in this file.

Left-click still selects and seeks (the frequent gesture); the
right-click menu item is unchanged. The existing title attribute
already names the control on hover.

* fix(studio): keep the resting ease button clear of narrow diamonds

At fit zoom on short segments the always-on button sat on top of both
neighboring diamonds and read as a grey smear. Add hasRoomForRestingEase,
a pure width check (button box plus a gap each side), and only show the
button at rest when the segment clears it; narrower segments stay
hover-only, same as before this feature.
2026-09-18 09:46:57 -04:00
Miguel Ángel e174b1715b feat(studio): host apps can open the ask-agent flow from a picked element (#4075)
* feat(studio): host apps can open the ask-agent flow from a picked element

Exports AskAgentModal and its props type from the public barrel so a
host app embedding only the player can render the same modal. Factors
the DOM-edit prompt builders' shared formatting into functions keyed
on a minimal common shape, then adds buildPickerAgentPrompt and
buildPickerAgentContextPreview built from the runtime picker's own
payload (no Studio DOM access required) instead of DomEditSelection.
An empty selection yields a plain comment prompt.

* docs(studio): clarify agent-prompt-builder comments

Simplify pass: buildElementInfoLines's comment omitted the conditional
text line it emits, and selectorIndex's always-zero value from the
picker path was undocumented as expected rather than a gap.

* refactor(studio): share the selector/text line pair between context previews

Simplify pass: buildAgentContextPreview and buildPickerAgentContextPreview
each built the same [selector-tag-line, text-line] pair inline. Extracts
buildSelectorAndTextLines() so both call the one place that owns it.

* fix(studio): cut two over-length comment blocks to the 4-line cap

Both docstrings had grown past the repo's 4-contiguous-comment-line
limit before this branch's first push. Re-wraps each to 2 content
lines, keeping the same meaning.

* feat(studio): surface the picker's label and media src in agent prompts

A host app embedding only the player has no DOM to read an element's
label or image source from, so the picker-built prompt needs to
carry both explicitly. The Studio DOM-based builder is unaffected:
neither field is set on its mapping, so its existing output is
unchanged (covered by the golden test).
2026-09-18 09:08:14 -04:00
Miguel Ángel a8d44b2596 fix(studio): keep clips on their row through group, ungroup, and composition recovery (#4068)
* fix(studio): keep a split clip on its original track row

Splitting an unauthored clip cloned the DOM node without stamping
data-track-index, so the runtime's positional-index fallback renumbered
it onto a different track on insertion. Both halves now pin to the
element's current resolved track.

Also adds A, matching CapCut, to return from the razor tool to select
while it is armed.

* fix(studio): stop the in-point seek from also firing when A exits the razor tool

Both A bindings register on window in capture phase; the razor's listener
mounts after the playback one, so relying on preventDefault ordering let
the seek fire before the tool switch. Playback now checks activeTool
directly instead.

* fix(studio): stamp the resolved track through group, ungroup, and composition recovery

Same defect class as the split fix: an unauthored clip's track falls back to
its position in document order, and any DOM insertion elsewhere can shift
that position. Group and ungroup move elements into and out of a wrapper;
composition recovery clones a missing element back in. All three now stamp
each element's already-resolved track explicitly, the same way split does,
so none of them can drop a clip onto a different row.

Also closes a gap in the earlier "A" hotkey fix: the razor-armed guard was
unconditionally swallowing Shift+A and Alt+A, which should still seek to the
in-point since only plain A exits the razor tool.

* refactor(studio): reuse the existing patch-target and selection-match types

Simplify pass on the track-stamping fix: resolveGroupChildTracks built its
patch target by hand instead of calling buildDomEditPatchTarget, already used
one function up for the same shape. resolveAuthoredTrack's selection param
was a hand-typed copy of ElementMatchSelection instead of importing it.
Collapses the three duplicated optional-integer track checks added to the
wrap/unwrap/split routes into one isOptionalInteger helper. No behavior
change.

* fix(studio): match an ungroup child with no DOM id by its stable id

findMatchingTimelineElementId had no branch for hfId, the stable content-hash
id every element gets regardless of whether it has an authored id. An
ungroup child identified only by that id fell through every branch and kept
its old track instead of getting the resolved one stamped, leaving it
exposed to the same row-drift this PR closes for every other element.

* refactor(studio): resolve review lint suppressions with real fixes

Three lint suppressions from this branch's earlier commits, resolved instead
of left as noise:

- unwrapElementsFromHtml's complexity: extracted the child-track map build
  into its own named function.
- handlePlaybackKeyDown's complexity: extracted the razor/A-key check into
  its own named function.
- The duplicated param block between UseDomEditSessionParams and
  UseDomSelectionParams: pulled the shared fields into one
  DomEditTimelineParams type both interfaces now extend.

No behavior change.

* fix(studio): fall back to the resolved track when nothing was authored

resolveAuthoredTrack only ever returned authoredTrack, so an implicit or
ungrouped child with no explicit authoredTrack resolved to undefined
instead of its actual resolved track, silently skipping the row-pin for
exactly the unauthored population this change exists to fix. Falls back
to the resolved track, matching the function's own existing comment.

* fix(studio): pin the razor split's resolved track for unauthored clips

buildCutTarget had the identical gap resolveAuthoredTrack did: it only
stamped an explicitly authored track, omitting the field entirely for
an implicit/unauthored clip, so a split of it still drifted onto a new
row. Falls back to the element's resolved track, same as the group and
ungroup paths.

* refactor(studio): share one track-fallback rule between split and group

resolveAuthoredTrack and buildCutTarget each re-implemented "authored
track, else the runtime's resolved track" with a different rounding
rule (one rounded only the authored branch, the other neither). Extract
resolveElementTrack so both flows share the same rule and can't drift
out of sync again.
2026-09-18 09:01:15 -04:00
Miguel Ángel b0e4316a20 feat(studio): open a project's root composition automatically on load (#4057)
* feat(studio): open a project's root composition automatically on load

Opening any project landed on an empty canvas ("Drop media here") with
Play disabled, even when a root index.html composition existed, because
nothing auto-selected it. Studio now opens the root composition (or the
only one, if there's just one) as soon as the file tree loads and no
URL already named a selection, and marks it "Root" in the comps list.

The root path is resolved once, in LeftSidebar from the shared file
tree, and passed down as a prop rather than re-derived independently
in the comps list from a different, driftable source array.

The settle guard is keyed to projectId, not a bare boolean, so
switching projects in-session (the studio's hash-based project switch,
same component instance) still gets its own auto-open instead of
silently inheriting the previous project's settled state.

* fix(studio): reset selection state on an in-session project switch

Switching projects in-session left activeCompPath from the old project
in place, which read as "already open" and made the new project's
auto-open settle without ever firing. useResetSelectionOnProjectSwitch
clears both activeCompPath and activeCompPathHydrated when projectId
changes, composed inside useAutoOpenRootComposition so the same commit
sees the reset before auto-open's own effect runs.

Also extracts the composition-selection wiring (state, URL hydration,
auto-open, this reset) out of App.tsx into one hook,
useActiveComposition, so App.tsx loses more lines than this fix adds
and stays under the 600-line cap without trimming unrelated lines.

* refactor(studio): un-nest the project-switch reset from auto-open

An independent review of 0bd281127 found useAutoOpenRootComposition had
grown three pass-through params purely to forward into a nested
useResetSelectionOnProjectSwitch call; none were read by its own
auto-open decision. React's render-phase state adjustment gives the
same commit-timing guarantee for a sibling call, so this calls the
reset hook directly from useActiveComposition instead, restoring
useAutoOpenRootComposition to its original 5-param signature.

Also applies two smaller review findings: a shared isHydratedFromUrlState
helper replaces three copies of the same URL-state check, and
useActiveComposition's setEditingFile/showToast params use the
existing EditingFile/AppToast types instead of restating their shape.

* fix(studio): make the file tree's project-switch staleness impossible

An independent review of ca3776e14 traced a live regression: on an
in-session project switch A->B where B's URL carries no comp param,
the reset added in this stack lets useAutoOpenRootComposition's effect
fire in the same commit-flush, but masterCompPath (derived from
fileManager.fileTree) still held project A's data for at least one
render + effect cycle, because useFileTree only cleared fileTree/
fileTreeLoaded inside a useEffect keyed on projectId, itself timed one
cycle behind. Auto-open would call onSelectComposition with A's master
path for project B and permanently mark itself settled, so B's real
root composition never opens for the rest of the session when the two
projects don't share an entry filename.

Root cause fixed in useFileTree.ts: the fetched tree is now held
together with the projectId it was fetched for, and fileTree/
fileTreeLoaded/compositions/projectDir are derived by comparing that
stored id against the live projectId, returning empty/not-loaded
whenever they don't match. This makes reading another project's data
structurally impossible, instead of adding a second reset timed to
race the first. The now-unreachable raw setFileTree setter is removed
(zero call sites repo-wide).

Every other consumer of these four fields was exposed to the same
stale window and is fixed by the same change: App.tsx's URL-hydration
guard and useDomEditSession's fileTree/projectDir, StudioLeftSidebar's
composition/asset lists, StudioRightPanel's assets/fontAssets/
projectDir, and VariablesPanel's fileTree-derived fallback comp path.

Regression test added (useResetSelectionOnProjectSwitch.test.tsx):
real useFileTree + useActiveComposition wired together with a mocked
fetch for two projects with different root files. Verified it fails
on the pre-fix useFileTree.ts (asserts B's root, gets A's stale one)
and passes on the fix.

* refactor(studio): stop the file-tree catch handler re-zeroing state

The effect already resets fileTree/compositionPaths/projectDir to
empty synchronously before the fetch starts, so the catch branch only
needs to flip loaded:true; re-writing the same empty arrays clobbered
any concurrent refreshFileTree() result for the same projectId that
landed first. Found by a simplify-pass review of 9555e920f.

* fix(studio): resolve the auto-opened composition from the filtered list

resolveMasterCompositionPath scanned the raw file list, which can include
non-composition .html (a vendored preset, a file with no
data-composition-id). Auto-open now reads the server's filtered
compositions list instead, and settles once per project either way, so a
stray .html earlier in the list can no longer become the opened target.

* fix(studio): close the same master-composition mismatch at its two sibling call sites

resolveMasterCompositionPath had two more callers still reading the raw
file tree: the sidebar's Root badge and the Variables/Slideshow panel's
master-view target. Both already had the server's filtered composition
list in scope; switch them to it too, and reword the docstring so a future
caller reaches for the right list.

* fix(studio): refresh the composition list along with the file list

refreshFileTree only patched the raw file list, leaving compositions
frozen at the project's initial load. Two callers now read compositions
for their root-composition target, so a file operation that adds or
removes a composition (create, duplicate, block install, background
removal) would leave the sidebar's Root badge and the Variables/Slideshow
panel's master target stale until the next full project switch.

* fix(studio): fix refreshFileTree ordering race and stale-field wipe

Two overlapping refreshFileTree calls for the same project could apply
out of order, and a response missing the compositions field could wipe
a known-good list. Adds a request-id guard and a stale-only fallback,
plus regression coverage for both and for LeftSidebar's root badge
sourcing from the filtered compositions list.

* refactor(studio): cancel superseded refreshFileTree requests

Simplify pass on the previous commit: reuses the id+abort pattern
already established in useFileManager.ts's openSourceForSelection
instead of only discarding the stale response after it completes.

* test(studio): assert the superseded refresh's fetch actually aborts

Closes the coverage gap the independent review flagged: the abort
call had no test asserting on it, so a dropped abort() would have
stayed green. Also cuts two comment blocks over the repo's 4-line cap.

* refactor(studio): extract shared test harness setup in useFileTree.test.tsx

Simplify pass on d069763c6: the new abort-assertion test repeated the
render setup already duplicated three times in this file. Extracts
renderHarness() to match the pattern every sibling hook test file
already uses. Also restores the caller-facing consequence dropped
from useAutoOpenRootComposition's comment trim (skipping the reset
now explicitly reads as "this hook no-ops").

* fix(studio): keep the restored comment clause under the 4-line cap

The prior commit's restored "why" clause pushed this JSDoc block to 5
lines. Folds it back to 2 content lines, keeping both the reset
requirement and its consequence.
2026-09-18 08:44:05 -04:00
Miguel Ángel bf39a72787 fix(studio): dropped timeline assets land where you drop them (#4062)
* fix(studio): dropped timeline assets land where you drop them

Dropping an asset, an OS file, or a block onto the timeline always
placed the new clip at the current playhead time, discarding the
actual drop position; only the track (vertical position) tracked the
pointer. Composition drops already used the correct pointer position.

Unify all drop kinds onto that same, correct placement path, and drop
the now-pointless duration clamp so a drop past the end of the
timeline extends it, matching what composition drops already did.

* fix(studio): grow the timeline when a drop lands past its end

Asset/file and composition drops could place a clip past the root
composition's duration without growing it, so the editor showed the
clip fine but the render truncated it. Every sibling edit path (block
install, move, resize) already grows the duration on the same rule;
these two didn't. Also restores the pointer-placement decision's
provenance in the drop docblock and PR description, and marks the
now-production-dead clamp branch in resolveTimelineAssetDrop so the
next reader doesn't assume the default is live.

* fix(studio): revert the composition-drop duration extension

The composition-drop path already grows the root composition's
duration server-side (compositionInsertion.ts, before the response
is even returned): this was verified wrong via two independent
review passes after being merged in by mistake. The client-side
extend added on top of that was redundant, and in edge cases could
re-apply a slightly different rounding than the server's own.

* fix(studio): remove the dead clampStartToDuration option

resolveTimelineAssetDrop had one production caller and it always
passed clampStartToDuration: false; the clamped branch and the
duration field that only fed it were exercised solely by this
file's own tests, not by anything shipping. Drop the option, the
branch, and duration/durationRef everywhere they were only feeding
it (timelineDragDrop.ts, Timeline.tsx, and the touched test fixtures).
No test asserted a drop past duration relying on clamping, so this
is behavior-neutral for every existing test.
2026-09-18 04:47:38 -04:00
Vance Ingalls 415967befa Merge pull request #4063 from heygen-com/release/v0.8.47
chore: release v0.8.47
2026-09-18 01:12:02 -07:00
Miguel Ángel 72b223d57f fix(player): gate play on real composition readiness, not just duration (#4054)
* feat(player): wait for a composition's media, images and fonts before playing

The player used to fire ready/autoplay the moment the timeline resolved,
even if the composition's video, images or fonts hadn't loaded yet -
visible as a flash of missing content or a frozen first frame. It now
waits for those assets (readyState >= HAVE_FUTURE_DATA for media, decoded
images, document.fonts.ready) before play() proceeds, showing its own
loading overlay (reusing the shader-transition loading panel) while it
waits, bounded by an 8s timeout that plays anyway and logs which assets
were still stuck.

Settles synchronously with no overlay when nothing needs waiting (already
loaded, or a cross-origin composition the player has no DOM access to
inspect), so today's immediate-autoplay behavior is unchanged for the
common case. A new assetsReady getter and assetsready event mirror the
gate for hosts that want to observe it.

* chore(player): drop internal decision-ref from a public-repo comment

An internal tracking id leaked into a code comment and a test name in the
previous commit. This repo is public; internal references don't belong here.

* fix(player): stop a stale asset-wait from settling a newer composition

An independent review found that a composition swap mid-wait (a new src or
srcdoc arriving while the previous composition's media/images/fonts were
still loading) had no way to invalidate the old wait. If the old wait's
promise resolved after the swap, it could mark the NEW composition ready
and even play it, while the new composition's own assets were still
pending - defeating the point of waiting in the first place. The same gap
let a late-resolving wait flip a disconnected player's paused state back to
false and fire a spurious play event after removal.

Fixed both with one generation counter: every new wait captures the current
generation, disconnect and reload each bump it, and a wait's eventual
settle no-ops if its generation is no longer current.

Also fixed: the loading overlay's aria-label ("Loading assets") never got
restored once the asset wait was done, so a later shader-transition overlay
on the same player kept misannouncing itself to assistive tech.

* fix(player): invalidate the asset wait from every navigation path, not two

The generation-counter fix only bumped at two of the six places that set
_ready = false ahead of a navigation, reload, or disconnect - src/srcdoc
attribute changes, a sandbox-origin policy reload, and a shader-options
reload all started a new navigation without invalidating the previous
wait, leaving the same race open through those paths.

One _invalidateAssetsWait() helper now owns the reset (assetsReady,
pendingPlay, generation, the loading attribute, hiding the overlay) and
every one of the six sites calls it before starting whatever comes next.

* fix(player): gate play on real composition readiness, not just duration

Add a shared composition-readiness gate in packages/core, exported as the
@hyperframes/core/composition-readiness subpath. It resolves once every
declared input settles (media for now: video/audio/image/font loads), or
after a timeout, and calls back synchronously when nothing is pending so a
caller that plays immediately after sees the decision already applied.

The player element now queues an early play() call instead of dropping it
when called before the runtime is ready, and both of Studio's readiness
call sites (the direct-adapter path and the message-based fallback) go
through one store action, requestTimelineReady, instead of duplicating a
generation-counter-and-settle pattern across two hook files.

* fix(player): stop a queued early play() and a project switch from double-firing readiness

An independent review of the readiness gate found two real races:

play() queued before the runtime was ready re-ran its full body once ready,
dispatching the public "play" event twice for one user action. It now
dispatches "play" only on the call that actually starts playback.

Studio's requestTimelineReady generation guard was never invalidated by a
project switch or a full timeline reset, so a slow-loading project's
in-flight wait could resolve after the switch and mark the new, unchecked
project ready. Resetting the timeline now bumps the generation, and setting
timelineReady directly does too.

* fix(player): cancel a queued play on pause or seek during the asset wait

A play() queued while assets buffer (_pendingPlay) used to fire once they
settled even after an explicit pause or seek during the wait. pause() and
seek() now clear the flag, so a user pause/seek wins.
2026-09-18 03:23:21 -04:00
Vance Ingalls 5b7ff519a5 chore: release v0.8.47 2026-09-17 23:55:00 -07:00
Vance IngallsandClaude Fable 5.1 9f3a778fd1 feat(studio): report why a fallback happened, and when a session is missing
Three silent fallback paths, three instruments:

1. resolverDisagreement on sdk_cutover_declined. Composition.getElement is
   canonical-only for a bare id, while dispatch resolves a bare id anywhere
   (resolveScoped). An element under a scoped id is therefore dispatchable yet
   still fails getElement -- a dispatchable edit refused, and invisible in both
   telemetry events (the shadow stays silent when its broader resolver hits).
   resolveSnapshot is now exported so the cutover path can tell the two
   target_not_found cases apart.

2. sdk_session_unavailable. With no session every cutover chokepoint takes the
   server path and emits nothing, and the shadow never runs either -- a total,
   silent SDK bypass. It has exactly three origins, all in useSdkSession's open
   effect: unreadable content, a failed ownership check, and openComposition
   throwing. Each now emits with its stage (and the error message for "open").

3. isResolverDisagreement is deliberately fail-safe: a session shape without
   getElements reports no disagreement rather than throwing, so diagnostic
   context can never turn a clean decline into a failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 23:24:09 -07:00
Vance IngallsandClaude Fable 5.1 9ef37e5163 feat(studio): emit sdk_cutover_declined with reason + family
Declines fell back to the server silently; post-flip we could not tell
'SDK took it' from 'SDK bowed out'. Payload is reason + family only.
All 23 sdkCutover.ts decline sites now pass their family; the one
pre-family site (no_change) emits family:null.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 21:47:14 -07:00
Vance IngallsandClaude Fable 5.1 26ea8a0740 feat(studio): enable SDK cutover by default for every op family
Master flag defaults true; unset family list now means all five families.
VITE_STUDIO_SDK_CUTOVER_ENABLED=false / VITE_STUDIO_SDK_CUTOVER_FAMILIES=...
remain as runtime kill switches via __HF_STUDIO_ENV__. Gate: 0 divergences on
0.8.41-0.8.46 shadow tile, 3455/0 success/failed on dev builds (30d).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 21:33:40 -07:00
Miguel Ángel a8a9fdb0fe chore: prepare v0.8.46 (#4033)
* chore: prepare v0.8.46

Draft only, not tagged: bun run release:prepare 0.8.46 --no-tag.
Changelog rewritten in plain words for the one change since v0.8.45
(#4031, adaptive per-frame motion-blur sample count). Cutting the
release is a separate step on Miguel's word.

* docs(release): fix false render-time savings claim in v0.8.46 notes

Adaptive sampling never drops below the old fixed-16 default; it only
adds cost on frames that measure real spatial motion. Per Miga's review
on #4033.
2026-09-17 01:48:54 -04:00
miga-heygenandMiguel Ángel 484a06fb65 chore: release v0.8.45 (#4027)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 22:56:40 -04:00
miga-heygenandMiguel Ángel 3c9462feea chore: release v0.8.44 (#4016)
- Lint no longer reports root_dimensions_mismatch for sub-compositions, the
  false positive v0.8.43 shipped with; a top-level mismatch is still reported
  and a viewport-meta-only mismatch gets its own wording (#4012)
- The streaming encoder accepts lockGopForChunkConcat and gopSize, emitting the
  same arguments as the disk encoder. Off by default, so an existing render
  produces an unchanged FFmpeg arg list (#3973)

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 19:14:51 -04:00
miga-heygenandMiguel Ángel d6fe66707f chore: release v0.8.43 (#4007)
- Portrait compositions render the full frame when the project was
  scaffolded at a different size: html/body are sized to the composition
  root at runtime; new lint warning root_dimensions_mismatch (#4005,
  fixes #4001)
- render_complete telemetry adds composition_element_tags,
  aroll_video_count, heygen_video_count; render_error adds error_name and
  failed_stage_code (#4004)
- Catalog docs "Source" accordion titles are plain strings, so docs
  search no longer shows [object Object] (#4006)

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 14:05:29 -04:00
miga-heygenandMiguel Ángel 71b274bbfd chore: release v0.8.42 (#3988)
* chore: release v0.8.42

- Studio preview works for projects whose path has non-Latin-1 characters (#3982)
- Studio render downloads use RFC 6266 headers for non-Latin-1 filenames (#3984)
- check --json reports HDR auto-promotion; render prints one warning (#3985)
- Forced --sdr renders tone-map HDR sources before extraction (#3981)
- Render/preview workers no longer survive as orphans after Ctrl-C or parent exit;
  cancellation reaches final artifact promotion; HYPERFRAMES_RENDER_DETACHED=1 for
  intentionally detached renders (#3974)
- HDR preflight test coverage for held tails (#3963)

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: release v0.8.42 notes — name the #3982 symptom and scope

- #3982 entry: a Studio preview server crash for compositions whose file path
  has non-Latin-1 characters (the project directory is not involved)

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: release v0.8.42 notes — name the #3982 symptom

- #3982 entry: the uncaught ETag TypeError took the preview server down, so
  call it a Studio preview server crash rather than a preview failure

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 09:40:06 -04:00
terencecho edf6e4b373 chore: release v0.8.41 (#3966) 2026-09-15 17:24:09 -04:00
terencechoandClaude Opus 4.8 f98b8ead37 fix(studio): guard import.meta.env reads so the player survives non-Vite ESM hosts (#3948)
In non-Vite ESM hosts, import.meta.env is undefined and direct reads crash at module load.

Guard both player reads with optional chaining. Existing Vite defaults are unchanged.

Reject future unguarded import.meta.env property access in either module.

— tai

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-14 23:40:48 +00:00
Miguel Ángel cfe5dcfad3 chore: release v0.8.40 (#3938) 2026-09-14 12:41:47 -04:00
Miguel Ángel d13a89b670 chore: release v0.8.39 (#3936) 2026-09-14 11:13:53 -04:00
Miguel Ángel 10e8447ac0 chore: release v0.8.38 (#3929) 2026-09-13 22:02:19 -04:00
Miguel Ángel 95bea1631f refactor(skills): keep hyperframes-core as the HTML contract (#3928)
* refactor(skills): keep hyperframes-core as the HTML contract

Move brief, storyboard, review, production, dispatch, and
frame-worker docs to hyperframes. Slim remaining core references.

* fix(skills): restore camera recipes and root sizing

Restore Zoom, Ken Burns, crop, and clip-path recipes. #root is 100 percent.

* fix(skills): stamp size on the composition root

Runtime sizes the composition root, not html/body. Overlap pin requires is valid.
2026-09-13 21:20:58 -04:00
Miguel Angel Simon Sierra 7a5d2bf769 chore: release v0.8.37 2026-09-13 12:51:02 -04:00
Miguel Ángel f86aae655a chore: release v0.8.36 (#3911) 2026-09-12 13:03:01 -04:00
Miguel Ángel 4c6fa9a790 fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910)
* chore(studio): remove the write-only self-write timestamp ref

`domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read
nowhere. It used to feed a 2 s "suppress the watcher reload after our own
write" window; that mechanism was replaced by content-hash identity in
sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an
SDK self-write echo from an undo landing in the same window). The reader
went with that change, the writers did not, and three comments kept
describing the timestamp as protection that no longer existed.

Pure deletion: the ref, every prop and parameter that threaded it, every
assignment, the five comments citing it, and the orphaned test helpers.
No behaviour change; the studio suite, typecheck, lint and format are
green.

Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts.
It looked undefined from inside the studio package, but shader-transitions
installs it on the preview window (hyper-shader.ts) so soft reloads do not
invalidate cached transitions. It is live.

* docs(studio): plan for loading the preview during the shell's first layout

* fix(runtime): a paused preview stays still after any seek

Sub-compositions kept animating while the transport was paused and the
Studio button showed play. A render-seek unpauses every sibling timeline so
GSAP propagates the root's totalTime into them, and nothing paused them
again; parented to the global ticker, they free-ran at 1x while the master
timeline and the clock stayed stopped. The Studio reaches that seek path
through its seek-driven fallback adapter right after a preview reload,
which is why editing text was what set it off. Captured live in the user's
preview: seven child timelines advancing 0.210s per 200ms sample, all
gsapPaused=false, transport not playing.

Three fixes under one invariant: while the clock is paused nothing runs,
and the button never disagrees with the runtime.

- The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters
  now returns every timeline it unpaused to paused in a finally. The second
  rearm after the child re-seek only ever changed the leaked state and is
  gone, with the comment that justified the leak by describing one caller.
- The paused side of the transport tick policed nothing; it now stops any
  timed media element found running. A parked transport runs no ticks, so a
  capture-phase play listener wakes it.
- Studio's canvas click resumed by setting the store flag alone; it now
  requests playback through the player so adapter, rAF loop and flag agree.

Regression tests fail without each of the three changes.

* perf(studio-server): stop re-encoding videos the browser already plays on open

Opening a project kicked off a background transcode for every asset whose
codec some browser might not decode. VP9 is on that list for Safari's sake,
so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60
CPU-second re-encode across six cores on every open, concurrently with the
browser's first layout, for an output nothing ever requested: across four
recorded sessions the browser asked for the proxy zero times.

One predicate was doing two jobs. "Could some browser fail on this" is a
property of the asset and decides what gets injected into the page;
"will this client request the substitute" decides whether to spend CPU
before being asked. The codec table now carries an explicit prewarm flag:
HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are
injected but transcoded lazily by the existing ?hf-proxy= request, which
already reports failure as a 502.

Pre-warms requested and proxies served are now counted on the existing
structured-stderr telemetry channel, because a speculative job with a 0%
hit rate emits no error and had been invisible. A single lookup helper
also closes a prototype-key gap where one path used Object.hasOwn and the
other a bare index.

Regression test: a VP9 and an HEVC asset in one composition yield exactly
one pre-warm, for the HEVC. Fails on the previous gate.

* perf(cli): serve the studio bundle compressed and cache hashed assets

The 4.1MB studio bundle was served uncompressed with Cache-Control:
no-store, so every open of the studio re-downloaded and re-parsed it. Vite
names built assets with an 8-character content hash, so their bytes can
never change: those now ship gzip-compressed (1.25MB on the wire,
byte-identical after inflation) with a one-year immutable cache policy.
Unhashed files under public/ keep no-store, and the HTML shell, which
previously sent no Cache-Control at all, now sends no-store explicitly:
it is the only thing that names the current hashed bundle, and it must
keep revalidating for the immutable policy to be safe.

The hash test only accepts the segment after the last hyphen and requires
a digit, underscore or capital, so an ordinary hyphenated name like
user-Guide-v2.js is not mistaken for a hash and served forever.

* perf(cli): warm the preview route before the browser opens

The browser's first request for a project's preview paid the server's cold
compiler import and first bundle in the request path, after the studio
shell's own first-layout stall, so both costs landed on the user's
time-to-first-frame in series. The CLI now issues one fire-and-forget
request for the preview route from openStudioBrowser, the single funnel
for every launch path, before the --no-open early return so pasted URLs
benefit too. Measured on the demo project: first client request 231ms
before, 101ms after; the ETag 304 path afterwards is under 1ms.

The fetch carries a 10s abort so an unsettled connection cannot keep an
otherwise-finished CLI process alive, matching the package's existing
convention. The shader query params the player appends are not part of
the route's cache key, so the plain route URL warms the same entry.

* perf(studio): request the player chunk before the shell's first layout

The dynamic import of @hyperframes/player ran inside the preview's mount
effect, which React schedules after the shell's first layout. On a cold
open that layout stalls the main thread for seconds, so the chunk request
waited behind it for no reason. The import is now kicked at module scope,
behind a typeof window guard that preserves the documented SSR contract
(the module registers a custom element at load), and the mount effect
awaits the already-in-flight promise. Verified in the built bundle: the
preload call sits at module top level, so the request goes out on bundle
evaluation.

* fix(studio): editing a text property no longer reloads the preview

Every keystroke in the Design panel writes the composition file, the file
watcher announces the change to the studio, and the studio decides whether
the change was its own. Over the CLI's event stream that decision has
never worked: the browser hands the handler a MessageEvent whose data is
a JSON string, and three of the four payload readers (version, write
token, content) only understood an already-parsed object, so they read
every field as absent. An absent token means "someone else edited the
file", and the studio hard-reloaded the preview iframe on its own edit,
blanking it for seconds. The path reader alone knew how to unwrap the
string, which is why the event was recognised well enough to reload and
never well enough to suppress.

The envelope is now decoded once, at the boundary, by one function that
all three transports feed; the readers share one field accessor so they
cannot diverge again. The production event-stream rung is extracted into
an exported channel so a test can drive a real MessageEvent through the
listener it registers, which was impossible before because vitest defines
import.meta.hot and the selection never reached that rung under test.

A second, smaller cause: the server attached the write receipt to the
first subscriber only and deleted it on read, so any other listener saw
an unlabelled change. Reads are now non-destructive with the TTL as the
only eviction, scanning newest-first because identical bytes written
twice inside the TTL (undo, retyping a value) share a version and the
older token was already spent. The file version now ships with every
event, receipt or not, so duplicate deliveries of one change dedupe
instead of reloading once each.

shouldReloadSdkSession had no production callers and a signature that
invited an undecoded delivery straight back into this bug; it is removed.
consumeFileWriteReceipt stays as a deprecated alias for one release.

Regression tests: a Studio write delivered as a real SSE MessageEvent is
suppressed; two subscribers of one watcher event reload once; a genuinely
external write still reloads; a repeat of earlier bytes gets the newest
token; a receipt past the TTL is not recognised. Each fails on the code
before it.

* fix(runtime): paused-time media playback is borrowed, not banned

The paused-side enforcement added in the previous commit had no notion of
provenance, so it stopped two features that legitimately play media while the
transport clock is paused. Both were deterministic, not racy: the capture-phase
`play` listener means the very play() that starts them wakes the transport that
stops them.

- The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a
  video while paused to render grading previews. It went dark on the first
  paused tick.
- The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the
  music track for ~140 ms while paused so a playhead drag is audible. Same path
  killed it.

A runtime-owned lease fixes both without weakening the enforcement. One owner: a
WeakSet in the runtime closure, with lease/release published on the existing
window.__hf surface for the Studio, which reaches the element across the iframe
boundary and cannot call into the closure. The grading runtime is constructed
with the pair directly. Both the cheap probe and the sync path skip leased
elements while the clock is paused; during playback the transport owns everything
again. Anything that plays while paused without a lease is, by definition, the
defect the enforcement exists for, and is still stopped.

Two corrections to the previous commit's reasoning:

The old leak broke the render path too, deterministically, not only the preview.
packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the
virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and
before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left
unpaused therefore advanced by the full inter-frame delta into the captured
frame. The finally added in the previous commit fixes that as well.

Deleting the second activateSiblingTimelines is safe because nothing between
frames reads a sibling's paused(), verified by grep over the deterministic
adapters, syncTimedElementVisibility, the hf-timelines-built handler and
__hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each
child. The code comment now gives that reason.

Tests, each proven non-vacuous by reverting the piece it guards:
- a leased element survives repeated paused ticks and is stopped once released
- the colour-grading preview survives, through the real init wiring
- the scrub borrows the element and gives it back on stop
- the existing test that an unleased element is still stopped keeps passing

* test(runtime): a lease ends when the transport plays or the borrower stops

Two assertions the review found not load-bearing. Dropping the isPlaying
branch so leased media stayed exempt during playback left every test
green; a leased out-of-window clip is now asserted stopped the moment the
transport plays. Deleting the release in the grading stop closure also
left the suite green, because the pause on the next line satisfied the
assertion; a restart after stop is now asserted stopped, which only the
release makes true.

* test(studio): the event-stream channel must open /api/events

* fix(studio-server): stop printing a proxy diagnostic line per clip on every render

Review of the pre-warm commit found the diagnostic louder than the thing it
diagnoses, and the two counters measuring different things under one name.

The per-asset `prewarm_requested` line is now behind
HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in
packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile
clips printed fifty JSON lines into a clack-formatted terminal on every
re-render. One summary line is written at process exit instead, using the
same process.on("exit") shutdown hook as packages/cli/src/cli.ts.

The counters now share a unit. prewarmsRequested counts per asset per render;
proxyRequests counted every HTTP request, including 304s. It now increments
once per resolveProxy call, after the ETag shortcut, so a revalidated repeat
no longer reads as fresh demand. An unconditional Range refill still counts,
and the docstring says so rather than claiming otherwise.

The HEVC justification was false on macOS Chrome, which answers canPlayType
for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed
there only through the reactive zero-videoWidth path. prewarm stays true
because Chrome on Windows/Linux and Firefox do need the substitute; the
comment and test names now say "no cross-platform decode" instead of
"browsers never decode it".

Two test gaps closed. Nulling vp9's representativeMime left the suite green
while making the client skip canPlayType and proxy on every browser, which
would reinstate exactly the transcodes this work removed; the mimes are now
pinned. The Object.prototype test passed with the hasOwn guard deleted, so it
now goes through probeAssetCodec, the input that actually misbehaves without
it. Both fail when the change is reverted.

* perf(cli): stop gzipping the studio bundle on loopback

Compression made the cold open slower on the only transport this server has.
It binds 127.0.0.1 with no --host, and measured there the six bundle assets
took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms
against 3.1 ms. hono/compress is removed, which also retires the Vary header
question it raised. If remote serving ever matters, compress at build time
rather than per request.

The cache policy is now decided by route instead of by filename. Reading a
content hash out of a name cannot work: rollup's alphabet is base64url and
includes a hyphen, so roughly one hashed file in ten was misread as unhashed,
and the immutable header also leaked onto hand-authored public/ files served
by the same handler. packages/studio/vite.config.ts sets neither
build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed
emits and every public/ file lands at the dist root. /assets/* is therefore
immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic
in between.

The shell is no-cache rather than no-store. It carries no ETag, so both force
the same full refetch, but no-store puts the document on Chrome's bfcache
blocklist: leaving Studio and pressing Back would cold-boot the app instead
of restoring it.

* docs(runtime): say what the paused-media probe actually filters

* revert(cli): drop the preview prewarm that held the CLI event loop

The fire-and-forget warm added in c9b4263c7 keeps the CLI alive until it
settles. cli.ts drains the event loop instead of calling process.exit, and
two launch paths return straight into exit, so the warm delays them: 2660 ms
with the warm suppressed, 8779 ms against a server answering in 6 s, 13293 ms
against one that hangs. The 10 s abort bounds the hold, it does not remove it.
This is the hazard already documented at preview.ts:1605-1609.

Moving the warm into the server process was the obvious fix, but measurement
says there is nothing there to warm. tsup bundles @hyperframes/core/compiler
into cli.js -- bundleToSingleHtml is a plain inline function in the artifact --
so the await import() at studioServer.ts:397 resolves an already-evaluated
namespace. A probe at listen time measures that import at 1 ms, and a paired
run shows no gain: first request 256 ms without a compiler kick, 308 ms with
one. The cold cost the plan attributed to the module load is somewhere else.

Two claims in the reverted comment were also wrong: routes/preview.ts:332 is
an ETag, not a bundle cache, and the player appends no shader params -- the
only query param is variables, which IS part of the ETag.

Reverts packages/cli to origin/main exactly.

* fix(studio): let a remount retry the player chunk after a failed load

1037456aa hoisted the dynamic import to module scope and shared one promise
across every mount. On rejection the .then never ran again: retryPreviewRef
and previewError stayed null, compositionLoading stayed true, so the preview
sat on an infinite spinner with the retry button at Player.tsx:462 unreachable,
and remounting <Player key={activeKey}> -- the recovery path that used to work
-- awaited the same rejected promise.

Memoize through a getter that clears the memo on rejection instead. The
module-scope kick still fires the chunk request before the shell's first
layout, and a remount performs a fresh import as it did before the hoist.

Discloses what the earlier commit did not: the player barrel re-exports
Player, so every studio module importing that barrel now evaluates this one
and eagerly loads the real player bundle. That is 27 DOM-env test files;
the full studio suite passes, 439 files and 4838 tests.

* docs(studio-server): trim the proxy-counter comment to the repo's four-line cap

* fix(studio): stop one failed player import poisoning every later mount

The previous commit claimed a remount could retry the player chunk after a
failed load. In a browser it cannot: the module map caches a failed fetch
as an errored entry for the document's lifetime, so a repeated import of
the same specifier rejects from cache without touching the network
(measured in headless Chrome: three attempts, one request). Clearing the
memo on rejection is still right, because it stops every later mount
awaiting the same poisoned promise, but recovery is a page reload, and the
comment now says so.

The tests said what vitest does, not what the browser does. Two assertions
survived their own mutation: removing the memoisation left the attempt
counter unchanged because vitest caches a resolved mock, and deleting the
module-scope kick, the optimisation this branch exists for, left the suite
green. Both are now asserted directly: the kick has run once at import
time before any mount, and every mount receives the identical promise.

* chore(ci): allow the deletion of the dead sdk-session reload test

* fix(runtime): the timeline resolver no longer unpauses children it cannot drive

Exercising the branch in a real browser still showed sub-compositions
free-running while paused, on a path no seek follows: the timeline
resolver, which runs on every rebind (after an edit, on the periodic bind),
unpaused every registered child BEFORE trying to nest it into the root.
A child the root actually holds is driven by the paused root and is
harmless; a child the root never takes stays on GSAP's global ticker, and
unpaused there means running. The seek fix in this branch could not
reach it, because a rebind is not a seek.

The resolver now reads back which children the root holds and unpauses
only those; the same rule applies to the composite fallback timelines.
Standalone registry children stay paused, as the transport's per-child
seek already requires.

Regression test: a hosted registry child whose root cannot nest it stays
paused across a forced rebind. Fails on the previous resolver.

* test(runtime): pin that the resolver unpauses a child only once the root holds it

The resolver's positive half had no fixture: nothing anywhere nested a
candidate for real, so computing the held set before the add loop instead
of after it left the suite green. A root that reports children only once
added now distinguishes the two orders, and the unpause is asserted at
the moment it happens, because the transport's first seek pauses every
hosted child again straight after resolution. Also drops a cast that
re-declared getChildren, which RuntimeTimelineLike already has.

* fix(runtime): the paused side stops every clip the transport drives

The paused-side enforcement scanned only media carrying its own data-start.
A clip inside a composition inherits its timing from the host and has no
data-start of its own, yet the transport plays it, so it could start while
paused and run unopposed, exactly the hole the invariant was written to
close. Reviewer probe: a hosted video with data-duration only kept
running while paused; the same element started under __player.play().

"Media the transport drives" now has one definition, shared by the media
cache and the paused-side probe, so neither can be narrower than the
other. Regression test: hosted media with no data-start started while
paused is stopped. Fails against the data-start-only scan.
2026-09-12 12:56:00 -04:00
Vance IngallsandClaude Fable 5.1 4ba8396c27 chore: release v0.8.35
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:30:23 -07:00
miga-heygenandClaude Fable 5.1 14b9e2039b fix(studio): tolerate WebMCP execute calls without an options object (#3860)
* fix(studio): tolerate WebMCP execute calls without an options object

Every Studio write tool (studio_set_text, studio_set_style,
studio_transform, studio_add_animation, studio_update_animation,
studio_add_keyframe, studio_delete_animation) registered its handler as
`execute: (input, { signal }) => ...`. The W3C shape passes an options
object, but the bundled `@mcp-b/global` polyfill invokes a registered
`execute` with the input alone, both from its in-page BrowserMcpServer
wrapper and from the descriptor it mirrors into a native
`document.modelContext`. The destructure therefore threw
`TypeError: Cannot destructure property 'signal' of 'undefined'` before
the handler ran, while the read tools, which ignore the second
parameter, kept working.

Route the seven signal-taking tools through one `writeTool` helper in
`buildStudioTools` that reads `options?.signal`, so a missing options
object yields an undefined signal at a single boundary. The handlers
already default an undefined signal to a never-aborted one, so nothing
downstream changes. `ModelContextTool.execute` now declares `options`
optional to match what callers actually do.

Upstream check: `@mcp-b/global` 5.0.1 (pinned), 5.0.3 and 5.1.0 ship a
byte-identical `@mcp-b/webmcp-polyfill` chunk and the same one-argument
call in `@mcp-b/webmcp-ts-sdk`, so a dependency bump would not fix this.

Tests: drive `studio_set_text` through the real `@mcp-b/global`
package under jsdom (registry entry `execute` and Chromium-style
`executeTool`) and assert a saved result; call every write tool with
one argument against a fake model context and assert none reports an
`internal` failure. The existing spec-shaped tests, including the early
abort path, still pass, which proves a provided signal still reaches
the handler. Shared inert deps builders move to `webmcpTestUtils.ts`.

Closes #3858

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(studio): cover the native-mirror WebMCP execute path

Install a native-looking `document.modelContext` before importing
`@mcp-b/global` so the bridge wraps it and mirrors every Studio
registration into it. One setup then exercises both one-argument call
paths the package ships: the in-page BrowserMcpServer wrapper (registry
entry and Chromium-style `executeTool`) and the descriptor mirrored into
the native context. Both fail with "Cannot destructure property
'signal' of 'undefined'" without the fix and save with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 20:12:14 -04:00
Miguel Ángel 59def1b66d chore(release): v0.8.34 (#3854) 2026-09-10 14:45:17 -04:00
Miguel Ángel 9549e038c4 perf(studio): a paused editor stops redrawing its overlays sixty times a second (#3846)
* perf(studio): run the editor overlays on one parkable frame loop

The composition rect, the selection and hover boxes, the off-canvas indicators
and the snap guides each owned an animation-frame loop that re-armed
unconditionally. On a paused, untouched editor that is four callbacks per frame
reading layout, and the snap-guide loop writes style on every one of them, so
the compositor kept committing 55 frames a second with nothing moving.

None of the four has a clock of its own; each only changes when something
observable happens. They now share one loop that runs at full rate for a moment
after a pointer, key, wheel, scroll, resize or visibility change, after a
preview message reporting a frame the overlays have not drawn, or after a
mutation inside the preview document, and otherwise polls four times a second
so a wake source nobody thought of costs a quarter second of staleness rather
than a frozen overlay.

The frame comparison on preview messages is load-bearing rather than an
optimisation: the paused preview posts an unchanged status message every 80 ms
so any listener can confirm its position, and treating that as news held the
overlays at 60 fps for the life of the tab.

* fix(studio): one overlay's throw must not stop the other four

The shared frame loop re-armed after running its subscribers, so a subscriber
that threw took down the loop and its idle-poll safety net permanently. The
four loops it replaced each re-armed first, which kept that failure to the one
overlay that caused it. It now re-arms before running anything and isolates
each subscriber, rethrowing out of band so the error still reaches the page's
error reporting.

The wake also sat outside the recognised-message check, so postMessage traffic
from an extension, devtools or any other embed on the page held the overlays
awake for 400ms at a time. Only the preview's own messages wake it now, and a
repeated paused status post still does not.

useMotionPathData had the same unconditional loop and is live whenever a
keyframed element is selected; it joins the shared one.

* fix(studio): wake the overlays only for the preview
2026-09-10 18:20:37 +00:00
renovate[bot]andJames eae4892ae8 chore(deps): update dependency vitest to v4 [security] (#3789)
* chore(deps): update dependency vitest to v4 [security]

* fix(test): preserve test behavior on Vitest 4

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: James <james.russo@heygen.com>
2026-09-10 13:55:52 -04:00
James Russo c98d6fbac8 fix: keep caption transcript data inside generated scripts (#3847)
* fix: keep caption transcript data inside generated scripts

* test: locate caption data without an HTML filtering regex
2026-09-10 12:53:30 -04:00
Miguel Ángel 73dfe35e46 fix(preview): show the correct scene instead of every scene at once on some loads (#3848)
* fix(runtime): show the right scene when the preview DOM comes from another window

The Studio preview sometimes builds the composition body in the editor window
and adopts it into the preview frame's document. Adopted nodes keep the
prototypes of the realm that created them, so `node instanceof HTMLElement` is
false for every element in the composition even though the elements are
ordinary HTML sitting in that document.

Every guard in the runtime written as `if (!(node instanceof HTMLElement))`
then skipped the whole document, silently: nothing threw, nothing logged, and
readiness still reported success. The timed-element visibility pass wrote no
inline visibility at all, so on those loads the preview painted every scene on
top of every other and the editor drew off-canvas markers for elements the user
could not see. The auto-stamp pass stopped stamping too, which is why the
composition came up one timeline clip short.

Replace every realm-sensitive element check in the runtime with structural
predicates that ask what a node IS (node type, namespace, tag name) rather than
which window's constructor made it. Two local `doc.defaultView.HTMLElement`
workarounds are deleted with it: they fix only the case where the nodes belong
to the document's own realm, and adoption is exactly what breaks that.

* fix(studio): scrub the music track the timeline named, not the first audio

`resolveScrubAudioEl` tested `byId instanceof HTMLAudioElement` on a node from
the preview iframe's document. That node is an instance of the IFRAME's
`HTMLAudioElement`, never this module's, so the check was false on every load
and the `musicId` hint was dead. Scrub fell through to the first `<audio>` in
the document, which the comment right above it warns can be the voiceover, so
dragging the playhead could preview the wrong track. Ask what the node is
instead.

Also close the gaps an independent review found in the runtime fix:

- the cross-realm predicate test had no `<audio>` and no `<img>`, so reverting
  `isAudioElement` or `isImageElement` to `instanceof` left it green. It no
  longer does, and the audio case also pins `isMediaElement`, which composes
  from it and gates the media sync path.
- nothing stopped the runtime regressing. `lint-runtime-preview-guards.ts`
  gains a second check kind: patterns that must be ABSENT under a directory,
  seeded with DOM-typed `instanceof` under `src/runtime`, pointing at
  domRealm.ts. Comment lines and tests are exempt, both on purpose.
- domRealm.ts stated the adoption mechanism as settled fact. The mixed
  prototypes are measured; how the nodes get into the frame is not identified,
  and the docstring now says which is which. Its ownership claim is scoped to
  the runtime, since packages/studio still hand-rolls its own checks.
2026-09-10 08:06:43 -07:00
Miguel Ángel 0408fbcd99 perf(studio): scrubbing a large composition no longer stalls the editor (#3842)
* perf(studio): ask each ancestor once per off-canvas rebuild, not once per element

The dashed off-canvas markers are rebuilt whenever anything in the preview
changes, which during playback or a scrub is several times a second. Most of
what a rebuild asked about an element was really a question about its
ANCESTORS: does each node up to the root render, what does each contribute to
the composed transform, which node is the source-file boundary. Two siblings
share their whole chain, so a preview of a thousand elements asked the platform
the same questions about the same ancestors a thousand times over.

A rebuild now threads one measure pass through the walk, and each node answers
once. On a 1689-element carousel that takes computed-style reads from 11,159 to
2,526 per rebuild and the rebuild itself from 59 to 12-18 ms per seek, with the
rendered marker set byte-identical.

The pass is deliberately not a cache. A cache would have to say what could have
changed since last time, and for a measurement the answer is anything: an image
finishing decode, a font swapping in, a transition frame, a container query, an
inserted stylesheet rule all move an element's box with nothing written to the
DOM and no record to invalidate on. A pass lives inside one synchronous
measurement that only reads, so nothing can move under it, and it is dropped
when the measurement ends. Every rebuild still measures every element.

Layout reads are untouched on purpose: each element still takes its own client
rect every rebuild, because that is the read that cannot be shared and must not
be remembered.

* test(studio): give one PropertyPanel case the file's own render timeout

Every other render test in that file passes RENDER_TIMEOUT_MS; this it.each was
left on vitest's 5s default and times out when the whole suite runs on a loaded
machine. Unrelated to any behaviour: it passes on either side of the change
when the machine is quiet, and fails in a full-suite run on either side when it
is not.

* refactor(studio): share the source-boundary walk and dedupe the rebuild fixtures

Three follow-ups from review of the measure pass, none of them behaviour:

The source boundary was memoized against the element that asked for it, so two
siblings still walked their whole chain separately and only the answer was
reused. It now memoizes every node on the way up, like the other two walks:
an element's boundary IS its parent's unless the element is one itself.

`isElementVisibleThroughAncestors` returns what it always returned, but it now
resolves top-down, so the set of nodes it takes a style read for is different
(smaller for a subtree hidden near the root). Three callers use it with no
memo, so that is written down next to it rather than left to be discovered.

The three rebuild fixtures in the indicator tests each carried their own copy
of the iframe/overlay/layout-stub scaffolding, which is four clone groups and
126 duplicated lines. One `mountPreview` helper now serves all of them and the
pre-existing selector-index fixture too.

* test(studio): pin the measure pass to one rebuild through an ancestor-derived input

The existing no-mutation-record test varies an element's own box, and the pass
never memoizes a box — so hoisting the pass to module scope, which is exactly
the mistake that would reintroduce cross-rebuild staleness, left every test
green.

This one fades a wrapper between two rebuilds and asserts the marker under it
goes away. Visibility through ancestors IS memoized, so the pass surviving the
rebuild serves the stale answer: with `createOverlayMeasurePass()` hoisted the
suite exits 1 on this test alone, and exits 0 at head.
2026-09-10 14:48:30 +00:00
Miguel Ángel 8bf5b4423e perf: sublinear Studio rebuilds and seeks (#3837)
* perf(studio): resolve the overlay coordinate basis once per composition

The iframe->overlay basis (composition root, root scale, iframe and overlay
rects) was resolved inside every geometry call, so measuring a preview cost one
querySelector("[data-composition-id]") plus three layout reads PER ELEMENT to
rediscover something that is a property of the composition and the canvas zoom.

It is now threaded through orientedGroupAwareOverlayRect, groupAwareOverlayRect,
orientedOverlayRect, orientedVisibleOverlayRect and toVisibleOverlayRect the way
toVisibleOverlayRects already batched it, and resolved once by the two callers
that measure many elements in one synchronous pass: the off-canvas indicator
rebuild and the overlay RAF loop.

Both passes only read the DOM, so nothing can move the canvas between two
measurements inside one of them.

* perf(studio): rebuild the layer walk from the mutation, not the document

A MutationObserver marked the off-canvas indicators dirty and the rebuild then
re-derived every element in the preview from scratch. The observer's loudest
source is inline style, which is what animation writes, so a composition just
sitting there re-derived the whole document several times a second, and each
element costs two getComputedStyle reads, two ancestor walks for its source
file, and a textContent read over its whole subtree.

The records now say WHAT to drop, not merely that something changed, and the
per-element derivations are memoized between rebuilds. Two lifetimes, because
they do not go stale together: whether an element renders (and how many layer
children it has) dies on any nearby attribute write, while how it is ADDRESSED
survives every style write and dies only on something that can renumber a
selector. That split is what makes an ordinary animation frame cost no document
queries at all.

Not attributable to specific elements, so still a full rebuild: nodes added or
removed, and any change to an identity attribute, which renumbers every element
sharing a selector.

Two supporting changes:

- getDirectLayerChildren asked getDomLayerPatchTarget for a full patch target
  per child and used it as a boolean. The target carries the selector's
  occurrence index, which is a whole-document query the yes/no does not depend
  on. isDomLayerElement answers it without one, for every caller. Its unused
  options parameter goes with it.
- The observer no longer filters attributes. An attribute it never hears about
  is one the cache would answer stale for, and the old filter omitted id and the
  data-composition-* attributes that decide a layer's identity. Widening only
  makes indicators refresh sooner; a rebuild is a pure read and is throttled
  either way.

Every other caller of collectDomEditLayerItems passes no cache and is unchanged.

* perf(runtime): visit only the clips a seek can flip

Every seek, and every frame of playback, rebuilt the window of every video and
audio element in the document and handed all of them to the per-clip sync loop.
On a composition of eighty videos that is eighty window derivations and eighty
per-clip passes to conclude that one clip is on screen.

A clip is active only while start <= t < end, so a clip whose window excludes
the new time is inactive there whatever its element state is. Sorting the
windows by each endpoint turns a seek into two binary searches: the clips whose
start or end lies between the old time and the new one, plus the ones that were
in window at the old time. Anything outside both was out of window before and is
out of window now, and was already paused and already evicted by the pass that
last saw it go out. A one-frame step visits the clip or two that flip; a jump
over a hundred clips still visits the hundred boundaries it crosses.

The index carries only the windows, and it rides the revision the duration
floors already ride: the same timing attributes, the same media metadata events,
the same timeline registry signature. Nothing else is cached. Every field handed
to syncRuntimeMedia is re-read from the element on every pass, because
el.duration can be reset under us by the load() retry, and a cached copy of it
would be exactly the stale duration the two-resolver-scope rule exists to
prevent.

The render/export path does not consult the index at all and visits every
element on every frame, and a render seek clears the sweep so a later live seek
cannot inherit a position it did not establish.

Also folds the duration floors' own invalidation into that shared revision.
Draining the observer is what detects a change, and only the first reader in a
task gets the records, so two caches each checking for themselves would have had
the second told nothing changed.

* refactor(studio): split the overlay basis and the layer-walk read into their own modules

Clears the 600-line file cap and the fallow audit on this branch. Behaviour is
unchanged: this moves code, it does not alter any of it.

File size. Both files were already near the cap on main and my three levers
pushed them over (615 vs 598, and 605 vs 584):

- domEditOverlayGeometry.ts -> 561. The iframe->overlay coordinate basis
  (OverlayRootScale, computeOverlayRootScale and the root/dimension lookups it
  owns) moves to domEditOverlayBasis.ts. It is the one piece of that file every
  other piece depends on, and nothing in it is about a single element's
  geometry. Its two callers now import it from there.
- domEditingLayers.ts -> 587. The per-element read that the walk performs moves
  to readDomEditLayerWalkEntry in domEditLayerWalkCache.ts, the module that owns
  the memoization it reads through. The walk keeps the traversal and the depth
  bookkeeping, which are the parts that are actually about walking.

No unrelated code was trimmed to make room.

Duplication, all three clone groups fallow flagged:

- The runtime seek fixture (createMockTimeline, the synchronous animation-frame
  clock, the CSS.escape shim, stubDuration) was copied between
  init.timingResolver.test.ts and init.mediaClipIndex.test.ts. It moves to
  runtimeSeekFixture.test-helpers.ts. Each suite keeps its own vi.mock calls,
  which are file-scoped and cannot be shared. The file is excluded from
  tsconfig.runtime.json alongside the test files it serves, for the same reason.
- domEditLayerWalkCache.test.ts repeated its mount/observe/first-walk setup in
  three tests; that is now withWarmWalk.

Complexity. Only one of fallow's three findings is attributable to this branch,
and fallow agrees: it marks the other two inherited and excludes them from the
gate.

- offCanvasIndicatorRefresh.ts update was NEW (absent from main's report, 10
  cyclomatic / 31.6 CRAP here). The optional-chain-and-default I added to drain
  the observer becomes drainPendingLayerMutations, with its own unit tests, and
  the function drops off the report entirely.
- useDomEditOverlayRects.ts update: 37 cyclomatic / 69 cognitive on main AND
  here. My change added one statement and no branch; the function grew 139 -> 147
  lines, which is what resurfaced it. Left alone.
- domEditingDom.ts escapeCssIdentifier: 24 cyclomatic / 19 cognitive / 148.4
  CRAP on main AND here. Untouched by this branch; only its line number moved
  (173 -> 182) because the composition-source-map revision counter sits above it.
  Left alone.
2026-09-10 03:55:28 +00:00
Miguel Ángel ac27b1534f perf(studio): resolve a selector's occurrence index once per layer walk (#3831)
The preview flattens several composition files into one DOM, so a layer's
identity is its selector plus its occurrence index WITHIN its own source
file. `getSourceScopedSelectorIndex` derived that per element: a whole
-document `querySelectorAll(selector)`, `resolveSourceFile` on every match,
then `indexOf`. Every element sharing a class paid for all of them, so a
walk over n such elements did n whole-document queries and n^2 source-file
resolutions — the shape a composition of repeated cards or tiles has by
construction.

Build the occurrence index ONCE per selector instead and share it across
one walk. `withSelectorIndexPass(doc, run)` opens that scope; outside it
the helper behaves exactly as before, per call.

The pass lives in `collectDomEditLayerItems`, which owns the loop, rather
than at a call site — its four callers (off-canvas indicators, the layers
panel, the marquee hit-test and the agent look tool) all walked the same
way and all paid the same cost.

Behaviour is unchanged. The occurrence indices are identical, including
the misses: an element outside the requested source file, or one not
matching the selector, still yields undefined, as do `#`-prefixed and
`[data-composition-id=` selectors and an invalid selector.

Measured on a 1689-element preview over 80 single-frame seeks, per rebuild:
class-selector document queries 171.6 -> 10.8, and the walk's self-timed
cost 15.25ms -> 5.37ms. Elements walked per rebuild is unchanged at 973.7,
so the two arms did the same work.

Tests assert complexity invariance rather than a threshold: the query count
must be IDENTICAL at n and 4n elements sharing a selector, which a fixture
-sized threshold would not catch. Both fail on the previous algorithm.
2026-09-09 19:11:22 -07:00
James Russo 4f77c282da fix(studio): authenticate preview message senders (#3812) 2026-09-09 09:32:03 -04:00
James Russo f54ba56136 fix(studio): contain project IDs across client and server routes (#3808)
* fix(studio): contain project IDs across client and server routes

* test(studio): use a portable project directory fixture

* fix(studio): reject drive-relative IDs and filter project discovery
2026-09-09 05:27:36 -04:00
miga-heygenandMiguel Ángel 6e3308be4f chore: release v0.8.33 (#3796)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-08 22:12:51 -04:00
miga-heygenandMiguel Ángel 662f96b3f4 chore: release v0.8.32 (#3788)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-08 19:40:29 -04:00
Miguel Ángel ab07d67380 fix(studio): preserve timeline DOM identity during hydration (#3681) 2026-09-08 18:08:55 +00:00
Miguel Ángel 30d6f43bdb chore: release v0.8.31 (#3747)
* chore: release v0.8.31

* docs(release): describe the range fix on its own terms
2026-09-07 12:12:35 -04:00
Miguel Ángel 3874990449 chore: release v0.8.30 (#3733) 2026-09-05 23:14:12 -04:00
James Russo 7d7003aa64 fix(studio): match style attributes with explicit quote boundaries (#3712)
* fix(studio): match style attributes with explicit quote boundaries

* fix(studio): apply quote boundaries to active source writers
2026-09-05 14:50:27 -04:00