mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-08 07:46:59 +00:00
sync/hyperframes-codegen-90fd0782
364
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8cb7d06db6 |
perf(studio): re-render only the edited scene's thumbnails after an edit (#4315)
* perf(studio): refresh only the edited scene's thumbnails and let the preview load first One scene edit regenerated every sidebar and timeline thumbnail, and those renders starved the new preview's requests on the Studio server. The thumbnail cache now keys on what a composition renders from, the server names the compositions a write affects, only those thumbnails refresh, and composition thumbnails wait while a shadow preview reload is in flight. * test(studio): pin scene-scoped thumbnail reuse and the preview-reload hold Memoizes the scoped thumbnail signature on the project signature so requests keep reusing the adapter's cached one, keeps the fresh-signature check that catches a write the watcher has not reported yet, and adds tests for sibling-scene reuse, root-head invalidation, the affected-composition set, per-path revisions and the scheduler hold. * refactor(studio): share the file-change payload guard * fix(studio): match thumbnail revisions for scenes mounted with a ./ path * perf(studio): keep signature cache keys constant-size * style(studio): format thumbnail-scope changes * docs(studio): trim thumbnail input comments |
||
|
|
6e5f460a0c |
fix(studio): split the empty-read reason and stop blaming the composition (#4325)
Two residuals from the cutover-dashboard work, both found in the data the first pass shipped (0.8.61/0.8.62). `absent_or_empty` was one label over two different failures: the route answers `content: ""` both from the `optional=1` shim (nothing resolved at that path) and for a real 0-byte file. The route now marks the shim with `missing: true`, and the read path with `missing: false` so the field's PRESENCE is what tells the client this server draws the distinction — a new server's empty file would otherwise look exactly like either case from an old one. Client splits them into `absent` and `empty_file`; a server without the field still reports `absent_or_empty`, so the existing series stays honest instead of silently folding into one of the new ones. Why it is worth splitting: on 0.8.62, 75 tabs hit this class and not one of them ever landed an SDK edit afterwards — it is terminal for the tab, while the tab keeps playing back and navigating, so nothing surfaces to the user. Every measured case with a loaded tree carries `path_in_tree: true`: a file the tree lists and this read cannot get. Which of the two causes that is decides the fix, and until now the event could not say. `res.json()` at the end of the read was unguarded, so a 200 carrying HTML — an SPA fallback or a proxy answering in the route's place — rejected out of the function entirely and the effect's outer catch filed it as `stage: "open"`, which asserts the COMPOSITION failed to parse. Observed in production on 0.8.62: `Unexpected token '<', "<!-- /*!"... is not valid JSON`. That is the third label to make the same wrong claim, after the fetch-rejection split in #4240 and #4269. Now `reason: "invalid_json"` with the response's content-type. `path_in_tree` now rides all three empty-read reasons, not just the combined one, so the split keeps the signal that justified it. Tests: 4 new (absent / empty_file / combined-label fallback / non-JSON 200) plus a server test pinning `missing` on both answers. 1172 tests green across studio hooks + studio-server routes; oxlint, oxfmt, tsc clean. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b9fe13ad8c |
feat(studio): export AudioWaveform, ImageThumbnail, useRenderClipContent (#4301)
@hyperframes/studio's public index doesn't export the clip-content thumbnail primitives, forcing a deep import. Desktop's Canva timeline lane needs to draw audio waveforms and image thumbnails from a host app without reaching into the package's internals. Adds a test that mounts each export through the package name the way a host app would (not a relative import), so a future removal or accidental un-export fails a test instead of a downstream deep-import workaround. |
||
|
|
94609daf44 |
feat(studio): audio pills show real peaks and loudness (#4282)
* feat(studio): audio clips draw real peaks shaded by loudness The pill fill is the track tint at 70 percent, and each bar's opacity follows the cached server peaks. Muting greys the pill in place. Linked video audio shows a 1px tick. * style(studio): format the audio waveform markup * fix(studio): map audio peaks across the full clip width * fix(studio): keep waveform visible through fade wedges * fix(studio): keep silent waveform bins visible * fix(studio): keep waveform baseline visible edge to edge * fix(studio): keep waveform visible over fades * fix(studio): keep quiet waveform bars visible * fix(studio): paint waveform above fade wedges * fix(studio): fit waveform inside audio clip * fix(studio): preserve waveform contrast tokens * test(studio): ratchet waveform color literals * fix(studio): keep loudness floor internal * chore(studio): document intentional fallow findings * refactor(studio): share fade handle rendering * fix(studio): pass fade handle styles explicitly * refactor(studio): split waveform drawing stages * test(studio): cover waveform canvas painting |
||
|
|
5ca2e78383 |
feat(studio): render geometry-sized video filmstrips (#4234)
* feat(studio): render geometry-sized video filmstrips * fix(studio): keep poster tier during timeline scroll * fix(studio): cap rendered thumbnail tiles * chore(studio): refresh filmstrip checks * chore(studio): refresh filmstrip captures * chore(studio): refresh four-times captures * fix(studio): stop capping filmstrip render tiles at 33 computeThumbnailStrip no longer clamps frameCount; the decode-side cap already lives in quantizeThumbnailFrameCount. VideoThumbnail only acquires the rich filmstrip lease when requestFrameCount > 1, so a narrow clip issues one decode job instead of two identical ones. Deletes the unreachable duplicate defaults in createVideoThumbnailRequest. * style(studio): format filmstrip changes |
||
|
|
24c3e197ef |
fix(studio): cutover gate classification, SSE cross-project bleed, benign races (#4269)
Implements the fixes from the 2026-09-21 cutover dashboard read
(plans/sdk/2026-09-21-cutover-dashboard-fixes-plan.md):
- sdk_cutover_failed now carries `family` and `error_kind`
(network/conflict/http/sdk). The one production gate event was a raw
`TypeError: Failed to fetch` from an unwrapped fetch (readProjectFile /
writeProjectFile's preflight), which an instanceof-only classifier would
still file as "sdk" and trip the rollback gate — added a message-based
fallback for the browser's fetch-rejection strings.
- studio:sdk_session_unavailable's `network` reason now carries `elapsed_ms`
and `hidden` (document.visibilityState at rejection), separating a
near-instant policy block (CSP/PNA/extension) from a dropped connection
the tab or server going away mid-flight.
- The ownership-race branch in useSdkSession's open effect now emits
sdk_session_superseded instead of sdk_session_unavailable{stage:ownership}
— it's a benign race (identity moved on mid-open), not a broken
precondition, and was being counted as one on the dashboard.
- Root-caused and fixed the read-404 "storm": /api/events (CLI host) and
the vite-dev hot-reload channel are one connection per SERVER, not per
project. A stale tab left open after `hyperframes preview` restarts on a
different folder (same port) received every save the CURRENT project
made, reloading its preview (which then 404s) and re-reading its own
composition on each one. Both transports now carry `projectId`; the
client-side coordinator drops a delivery whose projectId doesn't match
the tab's (absent field still passes, for one release of skew).
- studio-server's file routes report `why` on 403/404: `project_dir_missing`
(new — the CLI host's static resolveProject kept answering for a project
whose folder was renamed/deleted under a running server, which used to
fail closed as an indistinguishable path-traversal 403) vs `nul` vs
`outside_project`.
- absent_or_empty reads now carry `path_in_tree` (against the caller's
loaded file tree). Instrumentation only, per the graveyard page
(clearing-activecomppath-on-tree-absence) that refuted the "clear
activeCompPath" fix this was the proposed next step for.
Tests: unit coverage added for every change (sdkCutover.test.ts,
useSdkSession.lifecycle.test.tsx, useExternalFileChangeCoordinator.test.tsx,
studioServer.test.ts, files.pathSafety.test.ts). oxlint/oxfmt clean,
tsc --noEmit clean on studio/studio-server/cli.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
839ae426dd |
feat(studio): typed audio values, clip fades, audible video drops (#4250)
* feat(studio): typed audio values, clip fades, audible video drops Audio rows in the media inspector (Volume, Speed, Media start) take a typed value: click the readout, type "-6 dB", "1.5x" or "2.5s", Enter. Speed presets are hidden on <audio> clips. Clip-edge fades: data-fade-in / data-fade-out in seconds, honoured by the preview runtime (per-tick gain), the render mixer (afade after volume) and Studio (Fade in / Fade out rows, and drag dots at the top corners of audio clips and audible videos). Linear on both sides so preview and render agree; fades that outrun the clip scale to meet inside it. Dropped videos with an audio stream now land with data-has-audio="true" instead of muted, so the mixer keeps their sound. The media metadata endpoint reports hasAudio from the ffprobe call it already makes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(audio): one owner for fades, dB, and afade-after-volume Illegal fade values become no-fade at fadeGain; typed inspector input still returns null so the readout stays red. Master and group mixes share one filter chain with volume then afade. Fade-out stays anchored to the trimmed clip end. * fix(studio): share audio helpers and bound typed media values * fix(studio): reserve sibling fade duration when editing --------- Co-authored-by: Jake Moran <jakemoran2011@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com> |
||
|
|
5a8ee663f7 |
fix(studio): report a rejected read as a network reason, not an open failure (#4240)
* fix(studio): report a rejected read as a network reason, not an open failure `stage: "open"` is documented as `openComposition` threw — an unparseable composition, or OOM. Every event in that bucket on 0.8.56 and 0.8.57 carries a fetch-rejection message instead: "Failed to fetch" (28 users / 11 users), "Load failed", "NetworkError when attempting to fetch resource.". The cause is one missing catch. `readProjectFileOptional` awaits `fetch` with no try/catch, so a rejected request escapes the function and is caught by the effect's outer `.catch` — the handler written for `openComposition`. The read path has a five-member reason union precisely so this class of failure is diagnosable, and the one case that never produces a response was the case outside it. That made the largest SDK-session failure class in Studio (2.9% of session users on 0.8.56, 2.3% on 0.8.57) unaddressable: you cannot fix a network failure while it is reported as a composition that failed to parse. Now `readProjectFileOptional` catches its own rejection and returns `reason: "network"`, so it lands in `stage: read` with everything else, and `stage: open` means only what its comment says. Telemetry only. Nothing branches on the stage and no user-facing behaviour changes. Fifth split of this shape, after `ineligible_operation` (v0.8.49), `stage: read` (v0.8.51) and `block_detail` (#4214). Co-Authored-By: Claude Code <noreply@anthropic.com> * test(docs): point the catalog tab-row test at the page gutter #4238 moved the inset to `catalog-detail.test.ts` asserted the exact strings that #4238 deleted: `.hf-ve-tabs-row { margin: 20px 0 0 16px; }` and `.hf-ve-body { padding: 2rem 0 0 16px; }`. That PR removed both insets deliberately — its own comment says "the page gutter is the page's job, and a second 16px here put everything below the stage on a different left edge from the title, install block and stage above it" — and restored the gutter on `#content-area` in `docs/custom.css` instead. The test was left asserting the old mechanism, so main has been red on it since, unnoticed because the `Test` workflow does not run for a docs-only change. The invariant did not change: this content is still inset from the page edge. Only the mechanism did, so the test now asserts both halves of it — the two rules carry no inset of their own, and the gutter standing in for them is still present, at both its values. Asserting only the first would keep passing if the gutter were deleted outright, which is the regression the test exists to catch; both mutations were checked and both fail. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> |
||
|
|
2fb6f6987e |
test(studio): close shadow reload regression gaps (#4223)
* test(studio): close shadow reload regression gaps * chore: refresh follow-up checks * test: keep follow-up free of unused exports * test(studio): distinguish root timeline selection |
||
|
|
f476e78cc8 |
feat(studio): say which project this Studio is serving when a read 404s (#4228)
A tab whose project the running Studio cannot resolve failed every composition read with a 404 and said nothing. `useSdkSession` recorded the failure to telemetry and returned, so the editor looked normal while every edit silently took the server path instead of the SDK — nothing landed, and nothing said why. The `out-fb1` timeline sat in that state for an hour and a half, through a reload that passed mount validation. The banner names both sides: "This Studio is serving X. This tab was opened for Y." That is the whole truth in the host that produces these 404s. The CLI-embedded Studio serves exactly one project, and `hyperframes preview` reuses port 3002 when the folder matches, so starting it on a different folder takes the port out from under an open tab — and the project the old tab was pointed at is untouched on disk. "No longer available" would be false: nothing was lost, and the same timeline shows the project return. With several projects served (the Vite host) a rename cannot be told from a deletion, so that wording claims no cause. `reportReadFailure` answers which project a failed read identifies. Only a 404 does; a 5xx, a dropped request, an unexpected body or an empty file say nothing about which project the server serves, so none of them claim it. Also fixes a smaller lie on the same path: `useFileTree` parsed the 404's JSON body without checking `r.ok`, so the failure took the success branch and recorded an empty tree — and an empty provenance snapshot for a project that was never read — which then travelled out in bug reports. The 404s continue; this ends the silent part, not the failure. Co-authored-by: Claude Code <noreply@anthropic.com> |
||
|
|
74af8b4279 |
fix(studio): validate the hash on hashchange, and stop pushing dead ids (#4225)
The mount path has validated its hash since v0.8.52. The hashchange path did not — it adopted whatever id the URL offered, so changing projects walked straight around the mount fix and re-armed the same failure: every later /api/projects/<id>/... request 404s for the life of the tab, including the composition read that opens the SDK session. Same tri-state as mount: ok adopts, unknown adopts (a transient check failure must not rewrite a hash that is probably fine), missing falls back. Three details that are not obvious from the diff: - **The race guard reads the hash, not a counter.** This effect re-registers on every projectId change, so a `generation` in that closure resets and an in-flight verdict from the previous registration would read as current — adopting a project the user already navigated away from. The hash cannot go stale, so it is the ground truth to compare against after the await. - **The fallback re-fetches the project list.** tryConnect has `data.projects` in scope; this path does not, and caching the mount value would be stale in exactly the long-lived-tab case this exists for. - **Both rewrites use replaceState.** `location.hash =` pushes, leaving the dead id one Back away — and Back would then re-validate, fall back, and push again, forever. The mount fallback had this bug too; fixed here as well. Also emits the hashchange verdict so the `unknown` branch — which deliberately keeps the hash and is invisible from outside — is countable. Verified: 7 mutants, 7 caught, including adopting without validating, collapsing unknown into missing, trusting the verdict over the live hash, memoizing the fallback list, and pushing on either rewrite. Full studio suite 5,323 passed. Co-authored-by: Claude Code <noreply@anthropic.com> |
||
|
|
7abbe49638 |
feat(studio): a per-page tab id, and the hash-validation verdict (#4224)
Two telemetry gaps, both pure instrumentation with no behaviour change.
`tab_id` — distinct_id identifies a browser, not a page. Several
investigations stalled on "two tabs, or one tab switching?", because a burst
of events alternating between two project hashes is indistinguishable from a
user moving between projects, and `session_start` fires once per page load so
counting it cannot tell them apart either. One id per page load, from one
shared module, attached by BOTH transports (`studio:*` and `studio_*`) — those
two families already drifted once on how `agent_runtime` is encoded.
Deliberately not persisted. Storage would survive a reload and quietly turn
this into a tab-session id, changing what every query means, and blocked or
cleared storage would make the property intermittently absent — which reads as
"old client" rather than "storage unavailable".
`project_hash_validated` — a tab that keeps its hash because the check itself
failed ("unknown") is currently indistinguishable in production from one that
never ran the check. Different causes, different fixes, one silent bucket.
Emits `stage` (mount | hashchange), `outcome` (ok | missing | unknown), and
the HTTP status when the server answered.
Verified: 8 mutants, 8 caught — including dropping tab_id from either
transport, regenerating it per read, persisting it to storage, emptying it in
the no-DOM fallback, and collapsing "unknown" into "missing". Full studio
suite 5,314 passed.
Co-authored-by: Claude Code <noreply@anthropic.com>
|
||
|
|
85a1a8f789 |
fix(studio): swap the preview via a shadow iframe instead of blanking on reload (#4207)
* fix(studio): reloading the preview swaps in a ready copy instead of blanking * fix(player): report painted once assets are ready and the loader has faded out assetsready fires when the loader fade starts, so a consumer promoting on it can expose a raised or fading panel. painted fires once per document load after the fade completes, with a getter for late listeners; it resets on each load. * fix(studio): promote the shadow preview only when painted, under one wait budget Studio waited for ready plus two frames, which can land while the player's loader panel is still up or fading. It now also waits for the player's painted event. The shadow no longer arms the 5s adapter give-up, so a slow but live load fails only at the single 15s budget. Set-destination availability follows the live iframe across a swap. |
||
|
|
0c158be165 |
feat(studio): edit a clip's speed ramp with a rate lane and presets (#4153)
* feat(studio): edit a clip's speed ramp with a rate lane and presets * fix(studio): keep the live preview attribute in sync with what gets persisted * fix(cli): snapshot picks the source frame through a rate lane and up to 10x hyperframes snapshot clamped the constant playback rate at 5 and ignored a rate lane in data-automation, so a sped-up or ramped clip showed the wrong frame. It now reads the rate through the runtime's own reader and maps time with the shared source-time function. * docs(skills): teach speed ramps as a rate lane in one reference page Adds the speed-ramps reference, corrects the 0.1 to 5 rate range to 0.1 to 10 and the 'speed ramps are not supported' lines in the skills, and regenerates the skills manifest. * docs(skills): say what fixes a ramped clip's length and how the render approximates pitch * fix(studio): hide the speed preset until the clip has a known duration A preset on a clip with no data-duration was stretched over a made-up one second. The panel now offers presets only when the duration is known. * docs(skills): fix the last stale ramp line in the editing recipes and the slice-cap wording |
||
|
|
41b7202bca |
refactor(studio): make timeline rows use manifest elements (#4213)
* refactor(studio): make timeline rows use manifest elements * fix(studio): preserve timeline consumer prop names * test(studio): update asset row expectation * fix(studio): clear preflight lint nit * chore: allow intentional timeline hook removal * chore: refresh PR capture checks * test(studio): align visibility gate with store rows * test(studio): preserve canEdit literal type |
||
|
|
84e6531668 |
feat(studio): a host can make the preview read-only (#4200)
* feat(studio): a host can make the preview read-only Gates the shared geometry commit funnel (offset/size/rotation), so the inspector's manual X/Y/W/H/rotation fields refuse the same as canvas drag/resize/rotate. The flat inspector's fields render disabled with a host-supplied reason. Fixes a hooks-order crash on deselect found in review. * refactor(studio): derive the read-only preview flag from context, not a synced store EditorShell synced its readOnlyPreview prop into a zustand store via a raw useEffect behind an eslint-disable of no-restricted-syntax. Replace it with a PreviewReadOnlyProvider context computed fresh from the prop every render: Tier-1 consumers (genuine EditorShell descendants) read it via useContext; Tier-2 consumers (useAppHotkeys, useGestureCommit, the useDomEditSession/useDomEditCommits/useDomGeometryCommits chain), which run in App.tsx above EditorShell, take it as an explicit parameter threaded from StudioApp's own prop instead. Adds a host-mount test proving EditorShell gates its descendants from its own prop with no StudioApp/App.tsx involved. * fix(studio): keep preview hotkeys context explicit * fix(studio): close read-only interaction escapes * fix(studio): preserve timeline ownership in read-only preview * fix(studio): rollback read-only preview work * fix(studio): keep hotkey ownership on the active surface * fix(studio): format preview overlay and strengthen split test * fix(studio): clear preview gate findings * refactor(studio): share preview test fixtures |
||
|
|
05d5abb926 |
feat(studio): a blocked GSAP edit records which of seven causes it hit (#4214)
`edit_blocked` is the largest interaction failure in Studio: 575 users in 14 days, 17% of everyone who touches an editing control, and 41% of them are blocked on their first or second attempt. Only 30% ever commit anything afterwards. Almost all of it is one message. "This animation is computed at runtime. Edit the animation in the Code tab." covers ~460 of those users and is thrown from seven different places — runtime-dynamic provenance, unresolved keyframes, an unresolved selector, a geometry commit over unresolved source, and three live-tween-without-a-source-match cases across position and rotation. The telemetry carried only that string, so the classes were indistinguishable. The copy is also only literally true for the first. The rest are parser or source-matching limits where the animation may be plain authored source, and we send the user to the Code tab to rewrite something that could be fine. Adds a `detail` beside the coarse reason, set at each of the seven origins and emitted as `block_detail`. Telemetry only; nothing branches on it, and the user-facing copy is unchanged. Which cause dominates decides whether the fix is better copy, a parser gap, or an Unroll-style recovery path — and guessing that without the split is what this is meant to avoid. `directEditOutcomeForProperties` rebuilt blocked outcomes from `reason` alone, so it dropped the detail for the three causes that flow through it; it now carries it. Mutation testing caught that, not review. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5262062e7b |
feat(studio): export timeline editing for a host outside EditorShell (#4201)
* fix(studio): undo waits for in-flight hand edits before consuming history * docs(studio): trim comments to the 3-line house rule * feat(studio): export timeline editing, undo/redo, conflict banner and TimelinePane for a host * docs(studio): trim comments to the 3-line house rule * refactor(studio): simplify exports pass * docs(studio): trim comments to the 3-line house rule * fix(studio): resolve canEdit targets against expanded timeline rows * docs(studio): trim comment to the 3-line house rule * fix(studio): guard razor-split-all and the audio-group attribute write * fix(studio): gate the audio-group attribute for sub-composition-only members * fix(studio): keep blocked timeline edits truthful Refresh cached permission checks from the current host policy, gate auto-grouping before live patches, and restore optimistic attribute previews when a guarded commit is refused. * fix(studio): publish desktop compatibility exports Expose range selection and the canonical loader from the built Studio package while retaining timeline editing seams. * fix(studio): gate timeline drop writes Route asset, file, and composition drops through the shared edit permission gate before any side effect. * fix(studio): preserve release-owned style exports * fix(studio): clear file and Fallow gates * fix(studio): keep the carve gate and range slice under the size budgets * style(studio): format playerStore |
||
|
|
98e8372a0a |
feat(studio): all panels move onto the dockable, persisted layout (#4192)
* feat(studio): every Studio panel is a dock panel and the shell moves onto it
EditorShell, App.tsx, the left and right panel columns, and the
inspector hooks now render through Dock.Panel instead of the old
fixed-width sidebars and split-pane inspector. usePanelLayout becomes
a thin facade over the dock store; fitPanels, the resize divider and
the split-inspector hook are dead and removed.
* refactor(studio): drop dead inspector flags and stale fallow entries after dock migration
Collapse inspectorButtonActive into inspectorPanelActive, remove unread
layers/design active fields, un-export unused prop types, flatten a nested
ternary, and point fallow ignores at the renamed/deleted files.
* fix(studio): keep right-panel behaviours the dock migration dropped
Block params dismiss on selection or when Design is hidden, the slideshow
panel can be closed, the selection outline follows the inspector intent,
caption editing focuses Design, and a URL slideshow tab opens once the
panel exists.
* refactor(studio): name the inspector-open state once in the header
* fix(studio): right-panel behaviours survive selection, restore and reopen after the dock migration
Block params stay open over an element that was already selected, caption mode wins over block params, the slideshow panel waits for the dock, the Renders tab title survives a reopen, each side column remembers the tab it last showed, tab switches are reported only when the tab changes, and the e2e thumbnail selector follows the dock.
* docs(studio): describe the dock layout and point comments at the renamed right panels
* fix(studio): block params survive a preview reload of the selected element, and panel titles are covered
The dismissal compares the selected element by its target key instead of object identity, because a preview reload rebuilds the selection for the same element. A closed and reopened panel keeps its custom title, with a test, and the e2e selector is formatted.
* fix(studio): block params leave with any other tab pick, and a restored slideshow placement survives loading
Block params are now dismissed by the tab setter the way their old tab was, including a re-pick of the same element, instead of by comparing selections. The slideshow panel is only closed after a file that was a slideshow stops being one, so a restored layout is not discarded while the file loads.
* refactor(studio): drop the duplicate inspector-open computation
The rebase onto main left two identical inspectorButtonActive/inspectorOpen
constants after a conflict resolution; only the first was still referenced.
* chore(studio): allow the dock migration deletions in the guard
StudioLeftSidebar.tsx, LeftSidebar.tsx and its storage test,
TimelineResizeDivider.tsx, useInspectorSplitResize.ts and fitPanels
are replaced by the dock; the CI deletion guard needs each named
with its reason.
* style(studio): oxfmt the deletion-guard entries
* fix(studio): rename Timeline's aria-label so it does not collide with its dock panel wrapper
Every dock panel wrapper now carries aria-label=<title>, matching the "Timeline"
title for the timeline panel. Timeline.tsx also set aria-label="Timeline" on its
own content div, so `document.querySelector('[aria-label=\"Timeline\"]')` found
the dock wrapper first and the timeline viewport gate CI check timed out waiting
for an attribute the real content div actually carried. Renamed to "Timeline
track view" and updated every consumer: the production diagnostics selector,
its tests, and the e2e viewport-gate script.
* fix(studio): restore inspectorButtonActive as a real prop after a rebase regression
Rebasing PR2 onto main replayed an older local commit whose diff did not
textually overlap main's newer inspectorButtonActive prop, so git applied
it with no conflict marker and silently reverted the header to computing
the value itself instead of receiving it. StudioHeader.dom.test.tsx already
asserts the prop is respected; restoring it makes that test meaningful
again. App.tsx now computes it once (panelLayout.rightCollapsed is already
the dock's effective collapse state, no separate railed-by-width case) and
passes it down.
* chore(studio): wrap a long doc comment in useRightPanelIntents
|
||
|
|
3685b76a07 |
fix(studio): a stale project id in the URL no longer blocks editing for the tab (#4188)
The project id in the location hash is user-supplied and outlives the project it names: rename the folder, or open a bookmark from a project that is gone, and it points at nothing. useServerConnection trusted it unconditionally, so every later /api/projects/<id>/... request 404'd for the life of the tab — including the composition read that opens the SDK session. Studio then fell back to the server path for every edit, and the resolver shadow never ran either, so nothing recorded why. Telemetry, 24h after the read-reason split shipped in v0.8.51: 120 `stage: read` / `http_error` 404 reads across 5 users, about 24 each, none recovering. On this route a 404 has exactly one origin — resolveProject returned null — since the other failure modes answer 403 and the GET does not set mustExist. Validates through /api/projects/:id, which calls the same adapter.resolveProject the file routes use. Matching against the /api/projects list instead would be wrong twice over: that list omits session ids, which resolve fine, and skips project dirs without an index.html. The check is tri-state on purpose. Only a definite 404 counts as missing; a rejected request or a 5xx keeps the hash, so one network blip cannot rewrite a valid deep link out from under the user. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b09619faa8 |
refactor(studio): preview consumers follow the live iframe from one shared owner (#4182)
* fix(studio): preview consumers read the live iframe from one shared owner * fix(studio): clear the shared preview iframe when the studio unmounts The store outlives StudioApp, so a remount would start from the previous mount's detached iframe. The owner hook now writes null on unmount. * fix(studio): skip the preview-iframe notify when setIframe repeats the current value setIframe always merged a new state object, so zustand notified raw subscribers even when the iframe reference had not changed. Bail out by returning the same state reference on a no-op set. |
||
|
|
fd6049f356 |
refactor(studio): one hook owns file undo and redo (#4189)
* refactor(studio): move file undo/redo into one history-actions hook * refactor(studio): simplify history-actions hook * test(studio): cover history-actions ordering and motion-file reads * test(studio): type the history-actions test mock |
||
|
|
ff078accda |
feat(studio): say why the SDK session's file read came back empty (#4161)
* feat(studio): say why the SDK session's file read came back empty
`stage: read` was the largest remaining class of SDK-session failures and a
single opaque string covering four different outcomes. It is a hard block, not
a blip: 56 users hit it over a 7-day window and, between them, landed zero
successful SDK edits — while staying active in Studio (3107 playback events,
138 design inputs, 20 commit transactions). Every edit in those sessions fell
back to the server path with nothing recorded, because no session also means no
resolver-shadow event.
readProjectFileOptional now returns a discriminated result instead of
`string | undefined`, and the telemetry carries the branch that fired:
- unsafe_path — the traversal/NUL guard rejected the path client-side
- http_error — the request failed, with its status
- missing_content — 2xx whose body was not the shape we expect
- empty_file — `optional=1` answers an absent file with 200 + "", which
parses into a session with no elements; previously this
looked like a healthy session that merely went unused
Two candidate causes were investigated and ruled out first, with tests kept as
regression cover in studio-server (projectIdEncoding.test.ts): project ids that
percent-encode (the CLI derives the id from the folder name, so a space or
non-ASCII character produces one) and sub-composition paths whose separator
encodes as %2F. Both resolve correctly today because Hono hands over an
already-decoded `c.req.path`. The decoded/encoded seam in resolveProjectPath is
a real latent hazard, hence the tests, but it is not this bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(studio): fold the absent-file read reason into the result union
Review feedback on #4161. `empty_file` was synthesized by the caller after
the helper returned `{ok: true, content: ""}`, so reason attribution was split
across two places and `ProjectFileReadResult` was not the full reason surface.
It now joins the union, and the caller has one branch.
Renamed to `absent_or_empty`: the optional=1 absent-file shim and a genuinely
0-byte file are the same 200 on the wire and cannot be told apart here, so the
literal name says what the tag actually means. For a composition it is always
the former.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c2ca2291ca |
fix(studio): dropping an asset between or above timeline rows opens a new row (#4160)
* fix(studio): dropping an asset between or above timeline lanes opens a new track there * refactor(studio): reuse the source patcher and shared drop-placement type for track inserts * fix(studio): the dropped clip must carry its peers' sourceFile, not the resolved path planDropTrackInsert stamped the synthetic dropped element with the resolved target path string, but real elements on the main document carry no sourceFile at all. sameSourceFile compared the two directly, so the dropped element always read as belonging to a different file and every peer was skipped as unwritable, silently landing the drop on an existing track instead of opening one. * refactor(studio): trim drop-track comments and share one can-insert-track predicate Drop the TimelinePlacement alias, compute the file-or-asset check once for dragover and drop, and shorten two comments that narrated history. * fix(studio): plan a dropped-in track against the visible rows, not clips the timeline hides The insert row counts the rows the timeline shows, but the plan was built from every same-file clip. A clip the timeline does not show as its own row shifted the boundary and renumbered lanes it should have left alone. * test(studio): assert the dropped clip lands on the opened lane * fix(studio): a row drop measures its boundary on the same row order a clip drag does The drop path indexed the insert row against the rows the timeline shows, which include audio-group anchor rows, but planned it against a plain sorted track list. An audio group above the boundary shifted the new row by one. The placement now carries the displayed row order and the planner uses it, as the clip drag already does. |
||
|
|
f50df37671 |
feat(studio): the Lint badge pulses while an error finding exists (#4152)
* feat(studio): the Lint badge pulses while an error finding exists * refactor(studio): drop needless memo around the lint error flag * fix(studio): the Lint badge pulse reads the same findings as its count * fix(studio): name the badge severity for screen readers and cover a mixed finding list * fix(studio): the error badge uses the panel danger token |
||
|
|
a25da94b77 |
refactor(studio): remove the storyboard view and its Storyboard/Preview switch (#4140)
* refactor(studio): remove the storyboard view and its Storyboard/Preview switch * refactor(studio): fold the thumbnail output branches left after the storyboard mode went * docs(studio): drop leftover storyboard references after the view was removed * ci: allow the storyboard view deletions in the no-deletions guard * docs(studio): wrap an over-long comment line in the caption overlay helper * style(studio): reindent App.tsx after the view-mode wrapper was removed |
||
|
|
7797c5ead4 |
feat(studio): the timeline shows one row per top-level element and no child rows (#4132)
* feat(studio): the timeline shows one row per top-level element and no child rows * test(studio): cover the top-level id collection the timeline rows come from * chore(studio): shorten the row-elements comment * fix(studio): keep implicit layers as rows and say why a nested element cannot be hidden * refactor(studio): drop implicit layers; rows are exactly what topLevelElements returns * test(studio): untimed root wrappers are not timeline rows * fix(studio): an empty timeline message clears the rows; drop the unused implicit contract * fix(studio): an empty clip manifest reaches the timeline and clears the rows * fix(studio): an empty clip manifest does not shrink the transport to its 1s floor * chore(ci): allow deleting the child-row expansion hook and its test |
||
|
|
63d0ea4d28 |
fix(studio): show where a dragged file will land instead of a drop-to-add box (#4142)
Dragging a file over Studio covered the screen with a "Drop to add at the playhead" box, but a drop lands at the pointer time on the target row. The box is gone; the timeline now draws a landing preview at the exact start and track the drop will use (a new row below the last lane when that is the target), taken from the same placement function the drop commits. |
||
|
|
6995a4c2f7 | feat(studio): the agent sees every timeline clip, audio included (#4123) | ||
|
|
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. |
||
|
|
cc36905c29 | fix(studio): resolve a multi-clip selection's network probes in parallel (#4088) | ||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
4f77c282da | fix(studio): authenticate preview message senders (#3812) | ||
|
|
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 |
||
|
|
00c575d23b |
fix(studio): prevent preview hang on burst external file rewrites (#3648)
* fix(studio): prevent preview hang on burst external file rewrites Two interacting bugs caused Studio to freeze when multiple processes (generator, check, snapshot) burst-wrote index.html within seconds: 1. SSE listener leak: the /api/events handler added a watcher listener per client connection but never removed it on disconnect. Reconnects accumulated dead listeners, each triggering readFileSync on every file change and writing to closed streams. 2. Generation starvation: processChange incremented generationRef and awaited drainPendingChanges. A second event arriving mid-drain bumped the generation, causing the first drain to bail at the generation check. With rapid writes, no drain ever completed and Studio stayed frozen on stale content. Fix 1: use stream.onAbort() to remove the watcher listener when the SSE connection closes. Fix 2: gate processChange with a draining ref. While a drain is in progress, stash the latest event. On completion, process the stashed event — the last write in a burst always completes its reload. Closes #3646 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): align coordinator tests with drain serialization Update existing test to expect the new behavior: when two events fire in quick succession, the first drain completes and triggers a reload (previously it was silently discarded). The stashed event then starts a second drain. Also fix the burst-write test to use the drains array pattern and explicit act() flushes for stashed event processing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): stash events with allowDuplicate and harden listener cleanup Address Rames's review findings: - Stash with allowDuplicate: true so re-dispatched events are not swallowed by the duplicate guard (the identity was already written on the way in, so the stashed event matched itself on re-entry). - Wrap SSE keepalive loop in try/finally so the listener is removed on both abort and throw, not just abort. - Restore stale-completion guard test coverage lost in the rename. - Use await act(async () => {...}) for burst dispatches so assertions depend on the stash guard rather than scheduling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): simplify drain serialization and restore SSE cleanup Restructure processChange into intake + drain loop: - processChange is now synchronous — validates, dedupes, checks own echoes, enqueues the accepted payload, and starts the drain loop - startDrainLoop runs while the pending slot is non-null, draining one event per iteration via drainOnePending - No recursive void processChange(...) from finally, so no allowDuplicate escape hatch needed — stashed events never re-enter intake guards SSE listener: restore stream.onAbort alongside try/finally. Hono's sleep() never throws, so finally alone doesn't fire on disconnect. Both paths call removeListener (Set.delete is idempotent). Tests: remove stale-drain test that contaminated subsequent tests by emptying the shared roots array mid-test. Use sync act() for burst dispatches — the stash decision is synchronous. All 10 coordinator tests pass locally (NODE_ENV=test). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
3bc46a8ade | fix(studio-server): cascade GSAP cleanup when deleting subtrees (#3655) | ||
|
|
81f1a903e1 | fix(studio): rebind paused preview after live edits (#3596) | ||
|
|
6b5b4cb988 | feat(studio): make agent edits live and explicit (#3581) | ||
|
|
f84b4c23dc |
feat(studio): let an agent edit text and styles, guarded (#3518)
* feat(studio): let an agent drive Studio's selection and playhead Adds `studio_select` and `studio_seek`, so an agent and the human are looking at the same element and the same instant. Selecting reveals the inspector, exactly as a click does, which is what makes the agent's move visible. Selection is shared state, not a per-call argument, and that is forced rather than chosen. Most of Studio's edit handlers read the ambient React selection, and `applyDomSelection` only schedules a state update, so selecting and committing inside ONE call would write to whatever was selected before. Two tool calls are separated by a render, so the contract is select first, then act. That is also how a human works: click, then type. `studio_seek` uses `requestSeek`, not `setCurrentTime`. The latter only moves the timeline's displayed number and leaves the composition where it was. Two things the tools refuse to fake: Seek does not clamp. `seek()` already clamps against the adapter's duration, which can differ from the store's, and clamping again would give that invariant two owners that can disagree. The tool reports where the playhead actually landed instead, read back afterwards. `requestSeek` is fire-and-forget, so it cannot report that no adapter was mounted to receive it. The tool compares the playhead before and after and fails rather than claiming a seek that never happened. Select separates three failures that a single message would have merged: the preview is not mounted yet (wait), no element matches the handle (re-read), and the element cannot be selected (try a neighbour). The agent's next move differs for each, so collapsing them would cost it a round trip or a retry loop. * feat(studio): give an agent eyes with studio_frame Renders the composition to a PNG at a given time and returns the URL. This is what turns the tool set from a remote control into a loop: author a change, capture the instant it affects, look, adjust. No agent can judge motion from source, because "what does this look like at 2.4 seconds" is not a question a file answers. Reuses Studio's existing capture endpoint via `buildFrameCaptureUrl` rather than inventing a second one. Two things this does not fake: It reports the time the playhead LANDED on, not the time requested. The player clamps, so those differ at the ends, and attaching the wrong time to a frame is how an agent draws a confident wrong conclusion about motion. It waits before capturing, by default 150ms. The frame is rendered from the file on disk, and the render cache is cleared by a file watcher with a 40ms write-stability threshold, so a capture that beats the watcher renders the PRE-edit composition. That exact staleness was a real bug here once. An agent reading a stale frame as "my edit failed" would thrash, so the wait is on by default, `settleMs` makes it tunable, and the tool description names the failure rather than leaving it to be rediscovered. It probes with HEAD before returning, so a URL that 404s comes back as a failure with a hint instead of as a link the agent cannot render. * feat(studio): add studio_inspect, so an agent reads before it writes Everything about one element in one call: resolved styles, text fields, box, data attributes, GSAP animations, and what the element will and will not accept. The point is to prevent a failed write rather than to satisfy curiosity. `can.reasonIfDisabled` is passed through verbatim from Studio's own capabilities, so an agent that reads first should never attempt an edit the element would refuse. Three things it refuses to get wrong: Animations are reported ONLY for the current selection, because that is the only element Studio parses them for. Attributing them to any other element would be reporting the wrong element's motion, which is worse than reporting none. When a handle names something else the field is empty and `animationEditingBlocked` says why. `animationEditingBlocked` also carries the two states where animation editing is off entirely, multiple timelines and an unsupported timeline pattern. Both live on the selection context. Learning them from a read costs one call; learning them from a failed write costs a retry loop. Inspecting a handle does NOT change what is selected. It is a read, and stealing the human's selection would be a side effect they did not ask for. There is a test asserting `applySelection` is never called. Nothing selected and no handle given is a failure, not an empty result. An empty result would assert "this element has nothing", which is a different and false claim. * feat(studio): let an agent edit text and styles, guarded The first tools that change the composition. Both act on the current selection and take no handle, which is forced rather than chosen: the handlers read the ambient React selection, and `applyDomSelection` only schedules a state update, so selecting and committing inside one call would write to whatever was selected before. Select first, then edit. Also plumbs the write-blocked state, which was the blocker for shipping any write at all. `domEditSaveQueuePaused` and the external-file conflict both lived on App and were unreachable from the tool surface, so `canWrite` was optimistic and a comment said so. They now derive into a single `writeBlockedReason` on the shell context: one field, one owner, conflict taking precedence because resolving it is what unblocks the queue. That guard matters more than it looks. Both states are BANNERS in Studio with no lock behind them, so nothing else was stopping a programmatic write from landing on top of a conflict the user had been asked to adjudicate. Three things the tools refuse to fake: They check the outcome, not the absence of a throw. Studio has several paths where a failed commit resolves anyway, so awaiting the handler proves nothing. The tagged outcome added earlier is what proves the write landed. A partial style result is reported as partial. `handleDomStyleCommit` is one property per call, so N properties are N commits; the result carries `applied` and `rejected` maps rather than a single boolean that would have to pick a side. Style commits run sequentially, never concurrently. Two commits racing through Studio's client-side read-modify-write can record undo entries that both claim the same starting content. There is a test that measures concurrency rather than trusting the loop. Every decline reason maps to a hint naming what to do instead, so a refusal routes the agent rather than just stopping it. * feat(studio): add studio_inspect, so an agent reads before it writes (#3517) Everything about one element in one call: resolved styles, text fields, box, data attributes, GSAP animations, and what the element will and will not accept. The point is to prevent a failed write rather than to satisfy curiosity. `can.reasonIfDisabled` is passed through verbatim from Studio's own capabilities, so an agent that reads first should never attempt an edit the element would refuse. Three things it refuses to get wrong: Animations are reported ONLY for the current selection, because that is the only element Studio parses them for. Attributing them to any other element would be reporting the wrong element's motion, which is worse than reporting none. When a handle names something else the field is empty and `animationEditingBlocked` says why. `animationEditingBlocked` also carries the two states where animation editing is off entirely, multiple timelines and an unsupported timeline pattern. Both live on the selection context. Learning them from a read costs one call; learning them from a failed write costs a retry loop. Inspecting a handle does NOT change what is selected. It is a read, and stealing the human's selection would be a side effect they did not ask for. There is a test asserting `applySelection` is never called. Nothing selected and no handle given is a failure, not an empty result. An empty result would assert "this element has nothing", which is a different and false claim. * feat(studio): move, resize and rotate, verified by reading back (#3519) `studio_transform` does what a drag does, and then checks. The box in the result is READ BACK after the write, never echoed from the request, and `applied` lists what actually took effect. That is not belt-and-braces. The plan for this unit said to re-derive the geometry handlers' behaviour rather than trust any description of them, and doing that turned up three different behaviours behind one interface. The handlers on `DomEditActionsValue` are the GSAP-AWARE wrappers, aliased in `useDomEditSession.ts:534-538`, not the CSS ones in `useDomGeometryCommits.ts` that an earlier note in this workstream described. `handleGsapAwarePathOffsetCommit` and `handleGsapAwareRotationCommit` are `if (gsapCommitMutation) { ...intercept... }` with no else branch. Their own comments say the absence is deliberate: position and rotation are written as GSAP code and there is no CSS fallback to write to. So they can return having done nothing. `handleGsapAwareBoxSizeCommit` is not like the other two. It runs through `runGestureTransaction` with separate scale and width/height routes, so resize works more generally. Reading back is what turns that middle case from a silent lie into a reported one. A move that did nothing comes back in `unchanged` with a reason. Three smaller decisions: Operations re-read between each other, so a move is judged against the box AFTER a resize in the same call. Comparing against the original would credit the resize's change to the move. Rotation is reported as dispatched, not verified. `rotate` is an individual transform property and does not appear in the computed transform, so there is no honest box-derived signal, and claiming one would be worse than saying so. x pairs with y and width pairs with height. Accepting one alone would mean inventing the other from the current value, which moves the element somewhere the caller did not ask for. The pairing rule and its minimum live in one `parsePair` helper rather than as four separate branches. --------- Co-authored-by: miga-heygen <miguel.sierra_miga@heygen.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
28be8dddfa | fix(studio): correct save failure telemetry (#3499) | ||
|
|
21bcd5745c |
fix(studio): let a failed DOM edit report that it failed (#3510)
* fix(studio): let a failed text or style commit report itself `runDomEditCommit` catches a persist failure, reverts, fires `onError` and then resolves. That contract is deliberate and its docstring says so: the human path learns the write failed from the toast `onError` puts on screen, so a rejection would be redundant. It also means a caller awaiting `handleDomTextCommit` or `handleDomStyleCommit` cannot tell a landed write from a reverted one, because both resolve with `undefined`. The runner already offers `onSettled` as the way out. Text and style were the two commits that never got it wired. Add `runReportedDomEditCommit`, which owns `onSettled` (forwarding to a caller-supplied one rather than dropping it) and returns whether the write landed. Both handlers now return a tagged outcome, so the three preconditions that previously returned early and silently are each distinguishable: no selection, a manual-geometry property the style path refuses, and a selection that cannot edit styles. Same for text: no selection versus not text-editable. Human-facing behaviour is unchanged and the tests assert that: the toast still fires and the optimistic DOM change is still reverted. The callback props that carry these handlers ignore the result, so their declared type widens from `Promise<void>` to `Promise<unknown>`. That type is hand-copied in fourteen places; consolidating it is worth its own change. `useDomEditTextCommits.ts` is now 593 lines against the 600-line cap. The next change to it needs a split. * fix(studio): stop a paused save queue reporting a position edit as saved Two more commits that could not tell a caller they had failed. `useDomEditPositionPatchCommit` swallowed `DomEditSaveQueueOpenError` and resolved. The intent was right, a paused save queue already puts a banner on screen and one toast per blocked edit is noise, but swallowing it also skipped the caller's revert: `useDomGeometryCommits` only restores the optimistic offset, size or rotation from its `.catch`. So once the breaker opened, a drag left the element where the user dropped it while nothing reached the file, and the next reload snapped it back. It now rejects without toasting. The banner still does the telling; the caller gets to revert. `handleDomEditElementsDelete` caught everything and only toasted, so an unpatchable target and a completed delete were indistinguishable to a caller. It now returns an outcome, with `no-project` and `no-selection` separated from a failed write rather than all three sharing an early `return`. Adds the first test for `useDomEditPositionPatchCommit`, covering the paused queue, an ordinary failure, and success. * fix(studio): honor DOM edit failure outcomes * fix(studio): classify stale delete previews * fix(studio): enforce DOM edit outcome types |
||
|
|
9aaa7552fc | fix(studio): dedupe repeated selection telemetry (#3498) |