Commit Graph
5091 Commits
Author SHA1 Message Date
Miguel Ángel 4bbcf650ae fix(player): perf harness waits for assets-ready before play instead of a fixed 5s (#4137) 2026-09-18 20:58:30 -04:00
Vance IngallsandClaude Opus 5 d5e1de9201 fix(studio): a composition with a <canvas> no longer kills the SDK session
linkedom's HTMLCanvasElement constructor calls createCanvas(300, 150) from the
Node-only `canvas` package, behind this guard:

    try { module.exports = require('canvas') }
    catch { module.exports = require('./canvas-shim.cjs') }

The guard only works with a runtime require. Vite resolves the specifier
statically, so the bundled form is `try { exports = <module> } catch {...}` — a
plain assignment that cannot throw. The catch never runs, linkedom's own shim is
bundled but unreachable, and createCanvas lands undefined. Verified in the built
artifact: the canvas module namespace carried only a `default` export.

Parsing any <canvas> tag therefore threw inside openComposition, so Studio lost
its SDK session for the entire composition. Every edit then fell back to the
server path, and the resolver shadow did not run either, which is why this was
invisible before v0.8.47 added studio:sdk_session_unavailable. In the 72h after
the flip it accounted for 1065 of 1544 session-open failures, across a large
share of 197 users. Canvas is common in compositions: Three.js, shaders,
particle effects, charts.

Aliases `canvas` to a local browser stub that mirrors linkedom's shim
(measurement-only; nothing in the SDK rasterizes through it). Reimplemented
locally rather than pointing at linkedom's commonjs/canvas-shim.cjs, which its
exports map does not expose.

Confirmed against a rebuild: the namespace now carries createCanvas, so the
call returns a stub instead of throwing.

Note the unit tests here pass with or without the fix — vitest resolves the
require at runtime, so only the bundled build was ever broken. The regression
guard therefore pins the alias itself, since nothing else in the suite can
observe the bundler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:38:39 -07:00
Miguel Ángel 898d27869e feat(studio): preview ruler and safe-margin toggles (#4127)
* feat(studio): preview ruler and safe-margin toggles

* test(studio): cover guides placement in the preview pane and the caption band box

* feat(studio): safe margins are two nested 90 and 80 percent boxes with edge ticks

* fix(studio): safe box ticks centred on the line, no halo, tighter skill wording test
2026-09-18 19:47:27 -04:00
Miguel Ángel 14e5f57670 fix(core): script coalescing no longer moves an inline script past a src script (#4133)
* fix(core): script coalescing no longer moves an inline script past a src script

* test(core): pin how the script coalescers group inline runs

* test(core): simplify the pinned-script grouping test
2026-09-18 19:35:08 -04:00
Miguel Ángel c80ae95084 fix(studio): hovering or selecting a timeline clip no longer swaps its filmstrip (#4130) 2026-09-18 19:27:45 -04:00
Miguel Ángel a192701adb feat(studio): add per-project UI preference scoping (#4093)
* feat(studio): scope UI preferences per project

Panel widths, timeline zoom, and other UI preferences shared one
localStorage entry across every project. Key by the active project id
(from the existing hash route), falling back once to the pre-scoping
entry so an existing project's first load doesn't look reset.

* docs(studio): trim a comment past the repo's 4-line cap

* fix(studio): make per-project preference scoping opt-in

Auto-deriving the project id from the URL by default would have
silently flipped every existing caller into per-project mode, most of
which read once at mount and never again across a live project
switch, and some of which (playback rate, mute, snap) are documented
as deliberately project-agnostic in playerStore's reset. Require an
explicit projectId instead so today's 9 call sites are unaffected.
2026-09-18 18:47:44 -04:00
Miguel Ángel 6995a4c2f7 feat(studio): the agent sees every timeline clip, audio included (#4123) 2026-09-18 18:47:32 -04:00
Miguel Ángel 02c415dd8b feat(parsers): share top-level timeline element and track kind definitions (#4124)
* feat(parsers): share top-level timeline element and track kind definitions

One pure definition of which elements own a timeline row (sub-composition
hosts and timed elements; untimed wrappers are descended) and of a row's
track kind, so the linter and Studio's timeline agree.

* fix(parsers): align top-level rows with the runtime clip selector

Rows now follow the runtime's clip selector (data-start, data-track-index,
media tags), match tags case-insensitively for DOM adapters, and skip noscript.

* fix(parsers): treat a track-index layer without a duration as a wrapper

Matches the runtime, which skips such elements as clips, so their timed
children stay visible to the lint.
2026-09-18 18:47:18 -04:00
Miguel Ángel 0b6d1039ed feat(studio): copy, paste and duplicate clip groups on the timeline (#4064)
* feat(studio): copy, paste and duplicate clip groups on the timeline

Cmd+C on a multi-clip selection now copies all of them, not just the
first. Cmd+V pastes the group at the playhead keeping each clip's offset
from the earliest one and its original track when free. Cmd+D duplicates
the selection in place. Context menu gains Copy/Paste/Duplicate, and a
failed copy now toasts why instead of silently returning false.

Root cause was a DOM lookup that couldn't address a composition-instance
clip's root; group copy reuses the composition-aware resolver every other
timeline editor already uses. Also fixes a duplicated data-hf-id on
pasted/duplicated clips found during live testing, and reorders the
post-paste selection to land on the new clips instead of stale ones.

* fix(studio): scope the data-hf-id strip to tag brackets, not text content

A clip's visible text can contain the literal string data-hf-id="...";
the previous whole-string regex stripped it as if it were the attribute,
corrupting the copied text. Reproduced and confirmed the fix with a test.

* fix(studio): extend root duration and coalesce cut on group paste/duplicate

Paste and duplicate could silently truncate a group from export: they
inserted clips past the composition's data-duration without growing it,
so content existed in the file but never played or rendered. Both now
route through the same grow-only ratchet blockInstaller already uses.

Cmd+X on a multi-clip selection now deletes the whole selection in one
call (the existing batched delete already used by the Delete hotkey),
so one Cmd+Z restores all of them instead of needing one per clip.

The data-hf-id strip and id-capture on paste now go through DOMParser
and a tighter regex respectively, closing two escape hatches: a > inside
an earlier attribute value, and a data-id attribute preceding the real id.

* refactor(studio): share the id-attribute regex and hoist the clip DOMParser

Simplify pass on the previous commit: the id-capture lookbehind was a
second copy of the same pattern deduplicateIds already uses, now a
single exported ID_ATTR_RE both call. The per-clip DOMParser instance
in pasteTimelineClips is now created once per call, not once per clip.
Adds a test pinning stripHfIds's no-parseable-root fallback.

* fix(studio): close the stripHfIds hoist gap

stripHfIds silently returned html unstripped when DOMParser found no
body-level element (a tag the parser hoists into <head>, e.g. title or
meta). Closes it with a regex fallback and a test reproducing the exact
hoisted-tag case, not just the no-tag-at-all case from the prior commit.

* refactor(studio): dedupe clip menu item classes into one helper

The new Copy/Paste/Duplicate rows in ClipContextMenu each got their own
static enabled/disabled className string instead of the parameterized
itemClass(enabled) pattern the sibling TrackGapContextMenu already uses
for the same job. Consolidated to one function; rendered classes are
unchanged for every state.

* refactor(studio): reuse timeRangesOverlap for clip paste placement

tracksOverlap re-derived the exact half-open interval check
timelineCollision.ts already exports and uses elsewhere on the timeline.
2026-09-18 18:25:12 -04:00
Miguel Ángel 93921dccc8 fix(cli): stop the workspace build check test from depending on wall-clock mtimes (#4098) 2026-09-18 18:24:57 -04:00
Miguel Ángel 9981d9afb7 fix(capture): never silently render a webgpu composition blank (#4091)
* fix(capture): never silently render a webgpu composition blank

A composition declaring data-requires-webgpu only got the Chrome WebGPU
flag when hardware GPU auto-detection succeeded. On a software-GPU host
it launched without the flag and rendered blank with no error.

compositionRequiresWebGpu (static declaration check) and
assertWebGpuAdapterAvailable (live per-page adapter probe) now live once
in packages/engine's browserManager, the shared choke point every launch
site already goes through. All six launch sites (frame capture, the four
CLI diagnostic commands, the studio thumbnail server, and the render
pipeline's capture-options builder) route through those two functions;
the old CLI-only static-heuristic guard is deleted, not kept alongside
the new one. A software host that cannot obtain any WebGPU adapter now
fails loudly naming the requirement instead of producing a blank frame.

* fix(capture): thread requiresWebGpu through the distributed chunk-render path

The distributed renderChunk() path built its CaptureOptions without
requiresWebGpu, so it fell through to frameCapture's own fetch-based
detection, whose failure path silently treats "requires webgpu" as
"doesn't" if that fetch ever fails. Every other launch site (frame
capture, all four CLI diagnostic commands, the studio thumbnail server,
and the in-process render pipeline) already reads the value from HTML
already in memory or on disk instead of relying on that fallback.

Reads the compiled composition's index.html the same way probeStage
already does and derives requiresWebGpu from it before building
CaptureOptions. Adds a regression test that pins the exact path
renderChunk() reads at render time, since the composition doesn't
actually exercise WebGPU so an end-to-end render can't distinguish the
two cases on this host.

* test(producer): observe the real CaptureOptions renderChunk() builds

The previous regression test called plan() and compositionRequiresWebGpu()
directly, bypassing renderChunk() entirely, so it could not detect a
regression to the wiring it was meant to protect (confirmed: it stayed
green with the fix reverted).

Exports the existing createCaptureSession dependency seam so a test can
substitute a spy that delegates to the real implementation while
capturing the CaptureOptions object renderChunk() actually constructs.
Verified non-vacuous by reverting the fix and confirming the test fails.

* fix(gcp-cloud-run): resolve puppeteer-core from a real file in the webgpu probe

The image build's WebGPU adapter probe imported puppeteer-core from an
inline `bun -e` eval string. The eval's synthetic /app/[eval] location
can't resolve the workspace-hoisted dependency, so every build of this
stage failed closed with "Cannot find package 'puppeteer-core'" before
the probe itself ever ran.

Moves the probe into a real script file next to probe-beginframe.ts,
which already imports puppeteer-core dynamically the same way and
resolves fine from its real workspace location. Verified locally against
a cached chrome-headless-shell binary.

* fix(engine): bound the webgpu adapter probe so a stuck driver can't hang the caller

requestAdapter() has no native timeout. A broken software GPU driver
that never settles it would hang assertWebGpuAdapterAvailable
indefinitely, including inside the GCP image build's probe RUN step,
producing an opaque build timeout instead of a clean probe failure.

Races the in-page request against a bounded timeout so a stuck adapter
reads as unavailable and fails the same way a genuinely absent one does.

* test(cli): mock the engine's webgpu exports in the browser-fallback test

* test(producer): run the typegpu adapter fixture only on hosts with a hardware WebGPU adapter

The fixture declares data-requires-webgpu, so a software-only host now refuses it
instead of silently rendering the shader blank. The old golden had no shader in it.
Tag it webgpu, skip that tag in the CI image and the test scripts, and re-record the
golden from a Metal render with the shader visible.

* fix(engine): bound the composition fetch that decides the webgpu flag

createCaptureSession fetched the served page with no timeout before launching the
browser, so a hung server stalled a render forever and hung a fake-timer test. Give it a
five second limit, and let that test say it has no WebGPU requirement the way the
producer does.

* fix(capture): refuse a software fallback WebGPU adapter instead of rendering on it

swiftshader reports a WebGPU adapter but cannot render canvas WebGPU, so a
data-requires-webgpu composition on a software-only host crashed on its first frame.
Refuse fallback adapters with the same clear WebGPU-unavailable error. The GCP image
probe now serves its page over http (file:// hides navigator.gpu) and reports a
software-only host instead of failing the image build.

* fix(engine): parse the composition root tag without a backtracking regex

* fix(engine): keep quoted attribute values whole when reading the composition root tag
2026-09-18 18:06:23 -04:00
Miguel Ángel cb9ca4f0af chore: release v0.8.49 (#4122) 2026-09-18 17:44:18 -04:00
Miguel Ángel a7282dcd0e fix(player): wait for a cross-origin composition's assets before playing (#4103)
* fix(player): wait for a cross-origin composition's assets before playing

The player's readiness gate scans the iframe document, which is null when the
composition is embedded from another origin, so it released playback at once
and the first frames could play with images still loading.

The runtime now runs the same readiness scan inside the iframe and posts
assets-ready; its timeline message reports whether assets have settled so an
older runtime, which never posts it, is not waited on. Also declares the
animation-frame abort handler before a host whose requestAnimationFrame calls
back synchronously can reach it.

* fix(player): keep a ready cross-origin player ready across a late iframe load

The iframe load event reset readiness for any document the player cannot
read. The runtime posts its timeline before load and a paused runtime never
posts it again, so a load arriving after the assets settled left the player
not ready. A ready cross-origin player already holds this document's
handshake, because every navigation clears readiness first.

* fix(core): stop RuntimeReadinessWindow widening Window.__hf so the runtime typecheck passes

* fix(core): a torn-down runtime no longer posts assets-ready
2026-09-18 17:27:16 -04:00
Vance Ingalls f92944a2e7 Merge pull request #4100 from heygen-com/sdk-cutover-decline-reason
feat(studio): split ineligible_operation into its four real causes
2026-09-18 13:34:39 -07:00
Miguel Ángel 707f9a44c3 feat(studio): the first clip on an empty main track starts at 0 (#4099)
* feat(studio): a clip dropped onto an empty main track lands at 0

AD96 PR 1: the existing-clip-drag path (timelineCollision.ts) forces
start=0 for a clip landing on the main track for the first time, since
a track with one clip has no leading gap by convention. The file-drop
path is deferred to a follow-up once #4067 clears its file.

* refactor(studio): share the AD96 main-track drag test setup

Fallow flagged the two new tests as a 22-line clone; factor the shared
DraggedClipState builder into one helper.

* fix(studio): don't retime the rest of a multi-selection on the main-track snap

Adversarial review caught it: forcing the grabbed clip's previewStart to 0
fed straight into resolveMultiSelection's group delta, silently dragging
every other selected clip's time on a vertical-only gesture. Skip the
magnetic snap for a multi-selection drag; generalize
resolveMainTrackDropStart to also serve the upcoming file-drop path
(originTrack/isAudio instead of a full dragged element).

* feat(studio): dropping a new asset or file onto an empty main track lands at 0

AD96 PR 1, file-drop half (unblocked now that #4067 merged): the same
magnetic snap the existing-clip-drag path got now also covers a
catalog-asset drop and an OS file drop, applied once at the single DOM
drop dispatcher (timelineDragDrop.ts) so a multi-file batch still
sequences correctly after the snapped first placement. Composition and
block drops are left untouched — out of scope, per the original scout.

* refactor(studio): share the drag-over-then-drop test act() block

Fallow flagged the repeated act(() => { dragOver; drop }) pattern as
two clone groups; factor it into one dropAt helper.

* fix(studio): a mixed audio+video file drop always snaps, regardless of order

Adversarial review caught it: classifying a multi-file drop by only
the first file's kind meant [song.mp3, clip.mp4] and [clip.mp4,
song.mp3] snapped differently, purely from Finder selection order.
Classify the whole batch as audio only when EVERY file is audio, and
add the missing all-audio and mixed-order test coverage.

* refactor(studio): tighten main-track snap comment and dedupe file-drop array

* fix(studio): snap a top-gutter track-insert that lands on literal track 0

Adversarial review caught it: dragging a clip into the top insert
gutter creates a fractional lane that's never literal track 0 during
preview, but normalizeToZones can renumber it to literal 0 on commit
when the topmost visual lane was non-zero (main track genuinely
empty). That persisted start was never routed through
resolveMainTrackDropStart. Apply the same snap at commit time, using
the real post-normalize track, guarded against multi-selection the
same way the drag-preview path already is.

* fix(studio): compute the track-insert snap once so the z-sync sees it too

The previous fix snapped only the persisted edit after the fact, so the
stacking sync still read the unsnapped start. Resolve the snap inside
buildTrackInsertEdits from the final renumbered track and feed the one
value to both the edit and the z-sync candidate.

* refactor(studio): drop internal ticket codes from main-track snap comments and test names

* refactor(studio): simplify main-track snap

* fix(studio): snap the first main-track clip on its final lane and append new drops

One landing rule now runs in both the drag preview and the commit, after the
top-gutter renumber and the expanded-child-to-host conversion, so the ghost
matches the commit. New file and asset drops onto a filled main track land
after its last clip. The unused excludeKey check is gone.

* refactor(studio): keep media-panel drops out of this change

* refactor(studio): simplify main-track landing

* test(studio): pin the main-track snap on a plain lane change at commit level
2026-09-18 16:07:29 -04:00
Miguel Ángel a7ec66b124 fix(producer): cache the google fonts css lookup per process (#4097)
* fix(producer): cache the google fonts css lookup per process

Studio compiles every composition in a project separately for its
sidebar thumbnails and for gsap-animation extraction. Each compile
called injectDeterministicFontFaces, which re-fetched the same font
family's CSS metadata from fonts.googleapis.com over the network every
single time -- only the woff2 binary files were cached to disk, not
the CSS lookup itself. A project with a dozen small compositions and
three declared font families fired dozens of redundant network round
trips serially, holding the editor's asset-readiness overlay well past
its own 8-second timeout.

Adds an in-process cache for the CSS lookup keyed by the exact request
URL, self-evicting on failure so a later call still retries. Measured
on a real project: gsap-animation extraction across 12 compositions
dropped from ~16.4s to ~2.9s wall time, and per-composition thumbnail
generation dropped from 9-11s each to 10-14ms each once the cache is
warm.

* fix(producer): isolate the google fonts css cache from any one caller's abort

An adversarial review of the process-wide CSS-lookup cache found two real
bugs before it shipped. First, the cached promise carried whichever caller
reached it first's AbortSignal, so one render's cancellation could fail a
completely unrelated concurrent render sharing the same font family. Second,
the cache has no reset between test files running in one process, so two
sibling test suites asserting per-call fetch counts against the same
Google Fonts URL silently saw each other's cached result.

Strips the caller-specific AbortSignal from the shared fetch and races each
caller's own signal against it independently, so a caller can still bail out
on its own cancellation without affecting anyone else awaiting the same
lookup. Clears the cache in the two sibling test files' own beforeEach.

* fix(producer): key the google fonts css cache by fail-closed mode and drop transient statuses
2026-09-18 13:47:24 -04:00
Vance IngallsandClaude Opus 5 61db453133 feat(studio): split ineligible_operation into its four real causes
The first 24h of v0.8.47 telemetry showed 30 declines against 25 successes on
the dom family, and 26 of those 30 were `ineligible_operation` -- a single
opaque string standing in for four unrelated causes. That made the number
unactionable: a structural edit the SDK has no vocabulary for (expected, and
permanent until the op mapping grows) looked exactly like a reserved-attribute
or unsafe-attribute decline (narrow, and possibly worth fixing).

`sdkCutoverIneligibleReason` now names which check failed: unsupported_op_type,
child_scoped_op, reserved_attribute, unsafe_html_attribute, no_ops, or
target_unaddressable. `shouldUseSdkCutover` is derived from it rather than
duplicating the conjunction, so the predicate and its explanation cannot drift
-- a test pins that agreement across every case.

Also separates the two conditions that are not properties of the op batch at
all: with the dom family disabled, sdkCutoverPersist reported
`ineligible_operation`, which read as "the SDK cannot express this edit" when
the truth was "this family is switched off". It now reports feature_disabled
and session_unavailable distinctly, matching the other four chokepoints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:37:30 -07:00
Miguel Ángel 15460ec541 fix(core): resolve already-errored media immediately in the asset-readiness gate (#4096)
The shared composition-readiness gate only listened for future canplay/error
events on pending media, so an asset whose error already fired before the
gate started listening was never seen -- a DOM error event doesn't refire.
That left the gate burning its full 8s timeout on every load. Measured on a
production composition with 85 duplicate small audio elements pointing at
missing files: 8.68s to 0.83s with this fix, no timeout warning.
2026-09-18 13:19:46 -04:00
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
heygengenesis[bot]andmiguel.sierra 27303658c1 fix(core): scope selector arrays within compositions (#4035)
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-09-18 11:12:52 -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 2a9912ec99 fix(cli): stop the cancellable-process test reading a pid file mid-write (#4080)
Same defect shape as the earlier preflight cancel test fix: the wait
condition only checked that the ready file existed, which goes true
as soon as the write opens (and truncates) it, before its content
lands. A read landing in that window parsed an empty string to 0.
The wait condition now also validates the parsed value is a real
positive pid before accepting it, retrying otherwise. Reproduced
with a busy-wait harness that hit the empty-file race in roughly
two of five attempts and confirmed clean after the fix.
2026-09-18 10:37:31 -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 856ac34c53 test(engine): time the bun --eval spawn in resolveHeadlessShellPath tests (#4078)
These tests spawn a bun process to check platform-specific chrome
binary resolution, and have hit the default test timeout on Windows
CI runners before. The log only ever shows the bare timeout, with no
way to tell whether the spawn itself was slow or something else in
the test body stalled. Wrapping each spawn in console.time/
console.timeEnd costs nothing when the test is fast and names the
slow step the next time it isn't. No behavior change.
2026-09-18 10:07:58 -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 a7cc1d2cff fix(cli): stop the preflight cancel test reading a pid file mid-write (#4077)
The test polled a pid file written by `echo $$ > path`, whose shell
redirection creates (truncates) the file before the echo writes to
it. A read landing in that window parsed an empty string to 0, which
satisfied "not undefined" and exited the wait loop early, failing
the next assertion with "expected 0 to be greater than 0" — the
exact shape of the intermittent CI failure. The loop now only
accepts a parsed value that's a positive integer, reproduced with a
busy-wait harness that hit the empty-file race in most iterations
and confirmed clean after the fix.
2026-09-18 09:46:12 -04:00
Miguel Ángel de9206e0f8 fix(cli): detect a missing or stale studio build before it crashes (#4076)
* fix(cli): name the missing or stale workspace package before preview crashes

Running hyperframes preview from source with an unbuilt or stale
package previously crashed deep inside that package's own import
graph with a raw resolver error naming neither the package nor the
fix. Add a preflight that runs before the preview command's own
module import, so it also catches the crash that happens at CLI load
time before the studio dev server ever spawns.

* test(cli): cover the preview workspace-build gate wiring

Adds a test that exercises the real CLI dispatch path (mocked module
loaders, not source-text matching) to prove the preview command's
build-check runs before its module loads and that a failure exits
non-zero without double-printing. Also guards the exports-map reader
against a malformed package.json instead of crashing raw.

* fix(cli): correct why the audio-fx generated file is checked

The comment claimed core/src/index.ts imports both generated files
directly. Only runtime-inline.ts is; audio-fx-runtime-inline.ts is
reachable solely via core's own audio-fx-runtime export subpath,
which the engine package imports. The check itself was already
correct to require both, only the stated reason was wrong.
2026-09-18 09:27:39 -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 3867d8da5d test(cli): time each native Sharp call in the flaky Windows contact-sheet test (#4070)
This test has hit its 60s ceiling on Windows CI runners before, and the log
only ever shows the bare timeout, not which of the four native Sharp calls
actually stalled. Wrapping each one in console.time/console.timeEnd costs
nothing when the test is fast and names the slow step the next time it
isn't. No behavior change.
2026-09-18 08:45:04 -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
James Russo 2a559714ee fix(cli): distinguish agent and execution harness telemetry (#4071) 2026-09-18 10:10:22 +00: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
Miguel Ángel 096459e76d fix(cli): collapse startup lint output to a one-line summary (#4052)
* fix(cli): collapse startup lint output to a one-line summary

preview, publish, and render's non-abort lint check all dumped every
finding to the terminal before their real result line, drowning it in
noise unrelated to whether the project is in good shape. Full findings
already surface in Studio's own Lint badge; all three now print a
one-line summary by default, with --lint-verbose for the old full
output. One formatter (formatLintStartupMessage), three call sites.

* fix(cli): show lint findings in full on every abort path

Any path that calls presentRenderLintAbort now prints full findings
even without --lint-verbose, since the one-line collapse was hiding
the reason a render stopped.

* fix(cli): scope the lint summary pointer to what each command has open

The one-line lint summary always pointed at "the Lint badge in Studio",
even from render and publish where no Studio is running. It now points
at --lint-verbose for those, and preview additionally names the
hyperframes lint command since its own stdout is what an agent reads,
not the badge.

publish now forces full findings on its own entry-mismatch abort, the
same fix render already shipped, so the reason for the abort isn't
hidden behind a bare count.

* docs(cli): trim a doc comment past the 4-line limit
2026-09-18 04:34:53 -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 Ingalls eb2ac41f3e Merge pull request #4055 from heygen-com/worktree-glowing-giggling-toucan
feat(studio): flip the SDK cutover and the GSAP writer to default-on
2026-09-17 23:47:37 -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
Miguel Ángel 2de3ca5f86 fix(player): the interactive playback clock absorbs a main-thread stall instead of skipping past it (#4053)
* fix(player): the interactive playback clock absorbs a main-thread stall instead of skipping past it

TransportClock's monotonic time is a bare wall-clock read with no
protection against a long gap between reads. On a composition whose
first real paint blocks the main thread for several seconds, nothing
reads the clock during that time; the next read reported the whole
gap as elapsed playback, so the composition jumped forward instead of
continuing from where it froze.

A gap over 500ms is now treated as a stall and corrected to a 33ms
advance instead of reported whole, matching GSAP's own ticker default.
The correction lives inside now() itself so every reader benefits,
since this clock is read every real frame by more than one driver.
Render/export capture is unaffected: it always pauses and seeks to an
exact time rather than playing, so the correction never engages there.

* chore(player): drop internal tracker references from stall-policy comments

* fix(player): stop the stall correction from corrupting the clock while audio is authoritative

An independent review found that the previous commit's correction ran
unconditionally on every read, including while an attached audio
source was providing the time. Audio keeps playing through a
JS-thread stall, so a gap between reads there is not a stall — but the
correction still shifted the clock's internal reference point, and
that corruption only surfaced later, on an unrelated read, once the
clock fell back to monotonic timing (audio pausing or buffering).

The correction now runs only in the monotonic branch, after audio has
had its chance to answer. Added a regression test that fails against
the previous placement and passes against this one.

* fix(player): keep the stall correction's read timestamp fresh through an audio-authoritative stretch

A second independent review found the previous commit's fix was
incomplete: it stopped a stall correction from firing while audio was
answering a read, but the internal timestamp it compares against only
moved on monotonic reads. Any monotonic read from before audio took
over stayed the comparison point for as long as audio kept answering,
so the whole audio-authoritative stretch read as one giant stall the
moment audio dropped out again — reproducing the exact corruption the
previous commit set out to fix, through a different door. The same
gap also meant a real, non-stalled audio outage got smoothed away to
almost nothing instead of advancing normally.

The read timestamp now moves on every read, audio-answered or not;
only the correction itself stays scoped to the monotonic branch. Two
regression tests reproduce both failure modes and pass here; both were
confirmed to fail against the prior placement.

* fix(player): stop comparing a monotonic read against a timestamp audio made meaningless

A third review found the previous commit's fix was still incomplete:
keeping the read timestamp fresh during audio-authoritative reads
stops it going stale, but a real gap with no reads at all — the main
thread genuinely frozen while an attached audio element kept playing
on its own, unaffected by JS scheduling — still read as a stall the
moment audio next failed to answer, discarding real elapsed time that
audio had been correctly tracking the whole way.

An audio-answered read now clears the timestamp instead of stamping
it, the same as a seek. The next monotonic read then starts from
`_playStartMs`, which nothing but a genuine discontinuity (play, seek,
rate change, detach) ever touches — an anchor that already reconstructs
real elapsed time correctly on its own, without needing the correction
to reason about a comparison point it has no way to make meaningful
once audio was involved. A stall between two purely monotonic reads,
with no audio in between, is unaffected and still corrected exactly as
before.

Two regression tests cover the transition: a real unread gap while
audio was tracking correctly must not be discarded, and a sustained
audio outage must still advance at real speed rather than being
smoothed to nothing. Both were confirmed to fail against the prior
commit's placement.

* chore(core): trim clock comments
2026-09-18 01:50:05 -04: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 407cacf0ae feat(studio-server): default the GSAP writer to acorn
HYPERFRAMES_GSAP_WRITER=recast stays as the fallback for one watch window
before recast is deleted. acornDefaultBlockers -> behavioralParityOps: the 18
ops without a byte-diff oracle are now pinned in a test, not a gate.

files.test.ts "stale semantic no-op" pinned to the recast writer: its
interleave seam is recast's lazy import, which acorn (statically imported,
fully synchronous) never performs. The 409 revalidation it covers is
writer-independent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 21:38:38 -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
Vance IngallsandClaude Opus 5 ac946ef11b test(producer): add long-form render fixture generator and manual gate notes
The §8 fixtures every phase's manual gate renders against: one 300 s video
spanning the whole composition, and the same source cut into 60 clips.
Committed as a generator rather than fixtures because the source alone is
~600 MB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:03:40 -07:00
Vance IngallsandClaude Fable 5.1 457687e77c fix(producer): point the disk-capture preflight at routes that actually stream
The message told users to raise PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS,
which never helps a multi-worker render. Name the disk-path routes and the
two flags that change them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:34:53 -07:00
Vance IngallsandClaude Fable 5.1 172cac433d docs(engine,producer): describe the opt-in duration cap where the gate's callers read it
The `parallelStreamingAvailable` doc comment still presented the 240 s cap as
unconditional; since streamingEncodeDurationCapEnabled landed the cap is an
operator opt-in, so with stock config long compositions are eligible for the
DE parallel router and HF_CAPTURE_PARALLEL_STREAM too. The config comment now
also states that PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS=0 no longer
disables streaming on its own and names PRODUCER_ENABLE_STREAMING_ENCODE=false
as the supported off switch. Comment-only; no behaviour change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:25:39 -07:00