Commit Graph
1408 Commits
Author SHA1 Message Date
Miguel Ángel dd88b5f5b7 chore: release v0.8.77 (#4489) 2026-09-25 12:51:35 +00:00
Miguel Ángel 4dc1bee9c2 fix(studio): preview lookups use the right element when an id repeats across files (#4486)
* fix(studio): preview lookups find the element the timeline bound when an id repeats

* fix(studio): preview lookups prefer the element's own file, and a pasted element gets its own hf-id

* fix(studio): scrub audio, panel reads and gestures act on the bound element, one scan per pass

* refactor(studio): one owner for the selected node lookup, and scrub indexes only audio

* fix(studio): the panel's tween list matches class tweens against the selected element itself
2026-09-25 11:42:10 +00:00
Miguel Ángel 224388ad29 fix(studio): every project opens its preview at Fit, and a zoom says so (#4473)
* fix(studio): every project opens its preview at Fit, and a zoom says so

Preview zoom was one saved value for every project, so a single pinch
made each project open at 245%. It now lasts only while the project is
open. While the preview is not at Fit, a chip at the top says how far it
is zoomed with a Fit button, and a navigator shows the visible part.

* fix(studio): Fit keeps the selection, and the zoom chip and navigator follow review

A click on Fit no longer reaches the pane that clears the timeline
selection, focus returns to the preview, one rule decides at-Fit, and a
project switch resets the zoom before paint.

* chore(studio): the zoom change keeps each file's comment share at or under main's

* refactor(studio): the zoom chip and navigator live beside the preview, under the file size cap
2026-09-25 11:36:44 +00:00
Miguel Ángel 8b4c3a1d3d chore: release v0.8.76 (#4485) 2026-09-25 11:20:26 +00:00
Miguel Ángel caf7049503 fix(studio): undo works right after pasting or duplicating clips (#4482) 2026-09-25 10:53:54 +00:00
Miguel Ángel 828c434f76 refactor(studio): drop recordEdit's kind, which the project history never read (#4474) 2026-09-25 10:48:46 +00:00
Miguel Ángel 11ac9bce69 fix(studio-server): previewing a project never rewrites its files (#4480)
* fix(studio-server): previewing a project never rewrites its files

* fix(studio-server): a GSAP save on a served id writes that id to the file too
2026-09-25 10:04:38 +00:00
Miguel Ángel 93df8a814d fix(studio): a timeline row binds to its own composition's element when an id repeats (#4481)
* fix(studio): a timeline row binds to its own composition's element when an id repeats

* fix(studio): keep the scoped lookup's comments within the file's comment share

* fix(studio): a unique id stays bound through a healed host, and Layers re-finds in its own file

* fix(studio): try every lookup in the clip's composition before a lone match

* perf(studio): skip the id selector when the id is not in the document
2026-09-25 09:36:15 +00:00
Miguel Ángel 36f8bd0e91 feat(studio): undo and redo step through the project history, whoever made the change (#4456)
* feat(studio-server): an undo taken from Studio is not mistaken for an outside edit

* feat(studio): undo and redo step through the project history, whoever made the change

* feat(studio): the dev server keeps each project history, in the same folder as preview

* fix(studio-server): an undo that deletes a file reads as Studio's, and dev histories close

* fix(studio-server): a claim keeps an agent's earlier write the agent's and takes back Studio's own

* chore: allow the removal of Studio's browser edit history files

* test(studio): mock the project history in the runtime smoke

* fix(studio): step past a jammed undo and say why a step fails

* fix(studio-server): an agent turn cut by a claim commits its part before the claim

* fix(studio-server): a claim's cut ends a held drag and takes only the cut files out of a turn

* fix(studio-server): a held drag ends when someone else changed a file it already holds
2026-09-25 08:43:04 +00:00
Miguel Ángel 4064b183e6 fix(studio): render a missing preview poster in either order and bank the open counts (#4479)
* ci(studio): bank the open-journey counts after #4401 and allow a reasoned raise

* fix(studio): render a missing preview poster whichever of the 404 and the live frame comes first
2026-09-25 08:39:46 +00:00
Miguel Ángel 73519b6452 ci(studio): gate opening a film in Studio on work counts that only go down (#4457)
* ci(studio): gate Studio open and timeline scroll on work counts that only go down

Beside the timeline viewport gate's ms budgets, count the work two Studio journeys do and hold
each count under a checked-in ceiling in perf-ceilings.json: thumbnail renders, page loads and
requests for opening a 12-scene film, and React commits, style recalcs and layouts per scroll
tick. Counts repeat exactly run to run, so a rise names the work that grew.

* ci(studio): hold style recalcs per scroll tick at the CI runner's count

* ci(studio): gate the median scroll run and list which thumbnails rendered

* ci(studio): set work-count ceilings from the CI runner's counts on main

* ci(studio): fail the open count loudly when Studio never starts

* ci(studio): fail a work-count gate that measured nothing

* ci(studio): a scroll counter missing from any measured run reads as not measured

* ci(studio): split the perf ratchet's CLI and page counters into small steps, and let its unit test count

* ci(studio): count scroll work in an untimed pass and commit the open fixture's scenes

* ci(studio): gate only the open journey's counts; scroll counts follow runner speed

* ci(studio): fail a dropped journey and an unreadable base in the work-count ratchet

* ci(studio): split the ratchet's checks into small functions
2026-09-25 05:39:44 +00:00
Miguel Ángel f873734cc4 perf(studio): open a large film ready to play in about 4 s instead of 32 s (#4401)
* perf(studio): keep lint off the preview open path

The bundler linted every build only to print a StaticGuard warning; a preview bundle now
skips it through a staticGuard option, since Studio lints on its own route. That route now
lints in the CLI child-process worker the render path already uses, so seconds of parsing
no longer stall the preview and every other Studio request on the server event loop.

* perf(parsers): parse identical GSAP source once

Studio re-requests the parsed animations of unchanged source on every open, and several lint
rules parse the same script in one run. A small memo keyed by the script text serves repeats;
each caller gets its own copy.

* perf(studio): build each preview document once per content version

The preview route rebuilt the document for every browser without a cached copy. It now serves
one build per ETag from memory, and the CLI keeps the last build on disk salted with its own
file, so a restarted server serves the previous document without stale code.

* perf(studio): build the opening film's preview while the browser starts

The preview server built the document only when the player asked for it, after
Studio had booted, so a first open waited about a second of idle server time.
The CLI now requests the opening project's preview right after listening, and
requests that arrive while a build runs share it instead of starting another.

* perf(studio): request the preview document at page load

Studio asked for the preview only once the player mounted, about 640 ms into a
first open. It now requests it the moment the page loads, and again when the
hash names another project, so the build or cache read overlaps Studio's boot.

* feat(studio): show the cached frame 0 while the live preview boots

Studio covers the live preview with frame 0 as the thumbnail route last rendered it, sized
from the image so the stage does not shift, and removes it when the live frame is ready to
show. The request is cache-only, so a miss never starts a render on the open path; the
missing poster is rendered after the live frame shows, for the next open.

* perf(cli): start Studio before the startup lint finishes

hyperframes preview linted the whole project before it listened, 4 to 6 s on a large film, and
did so in --json mode where the result is discarded. The lint now runs in the CLI worker while
Studio starts and prints its summary when done; --json skips it.

* perf(studio): render thumbnails from one loaded page, after the preview boots

Every thumbnail opened a page and loaded the whole composition to shoot one frame, and the
Compositions card asked for one while the live preview was still booting. The server now keeps
one loaded page per preview document and seeks it per frame, reloading only when the project
changes; card thumbnails wait for the project first ready preview.

* perf(studio): parse GSAP animations after the preview boots

Studio asked the server to parse the composition animations while the preview was opening; on
a fresh server that cold parse ran on the event loop ahead of the preview build. The request
now waits for the live preview to boot, or to fail loading, since only keyframes need it.

* perf(cli): find orphaned browsers with one process-table read

preview ran pgrep three times and then ps once per matching browser, all
synchronously before listening: about half a second on a machine running
other headless Chrome. One ps listing now finds every orphan in ~20 ms.

* perf(studio): keep lint off the boot

Opening a project started two lint processes while Studio booted: the
terminal's startup lint and Studio's background lint, ~4 s of CPU next to the
boot. Studio's background lint now waits until the live preview has booted,
and lint processes run at the lowest CPU priority so they only take idle CPU.

* perf(studio): open the editing session after the preview boots

Opening a project read the composition source and parsed it for the editing
session (linkedom plus hf-id assignment) on the main thread while the live
preview was booting on the same thread: ~300 ms on a large film. The session
now opens once the preview has booted; nothing the first frame shows needs it.

* perf(studio): park the editor overlays until the preview boots

The overlay frame loop polled preview geometry and off-canvas indicators from
the first frame, forcing layout on a preview document that was still loading,
on the thread its boot runs on: ~200 ms before the first frame on a large film.
The loop now skips its subscribers until the preview has booted, then wakes.

* perf(studio): treat the preview as booted once it can play

Work deferred until the boot (editing session, lint, overlays, GSAP parse,
card thumbnails) started at timeline-ready, which fires before the asset gate,
so the editing-session parse ran inside the wait for play. The Player now
latches the boot when the preview can show and play, or fails to load.

* fix(studio): keep a left project's deferred work out of the one now open

Deferred open-time work (source parse, background lint, editing session) waited on one global
boot flag, so leaving a project before its preview booted let the next project's boot run the
old project's work and write its results. The wait is now per project and resolves false once
another project opens. A preview stuck without an error stops deferring that work after 5 s.

* perf(cli): let the play page revalidate instead of refetching

hyperframes play served the composition, its assets, the player and the runtime with no
validator, so every reload refetched everything, the mp4 in full three times. They now carry
an ETag and stay no-cache, so unchanged bytes cost a 304 and an edit is still seen.

* fix(cli): serve an edited text asset even when its size and mtime match

The play page tagged every asset by mtime and size, so a same-size rewrite within one
filesystem tick kept the old tag and the browser kept stale bytes. Text assets now get a
content-hash tag; media keeps the mtime and size tag Studio already uses.

* fix(studio): close idle thumbnail pages so an idle Studio runs no composition

Kept-loaded thumbnail pages stayed open for the life of the server, each running a live composition,
so an idle Studio cost server CPU and memory that main does not. A page now closes after 10 s unused,
and a page is closed only after the frame still running on it finishes.

* fix(studio): stop a first open from reloading the preview once per scene file

Studio stamped hf-ids into a composition the first time that composition was served, so opening a
film of external scenes wrote to index.html and every scene file mid-session. Each write reached
Studio as an outside edit and reloaded the preview, which failed for a large film. The server now
stamps every composition once before it starts watching the project.

* fix(studio): ignore play until the preview can play, so it is never undone

Space (and any play() caller) could start playback before the preview frame finished loading. Its
load step then paused and rewound to the restore point, so the play was silently lost. play() now
waits for timelineReady, the same gate the Play button already uses.

* fix(studio): hold play on a composition switch until the new preview loads

Readiness was reset only when the project changed. Drilling into another composition kept the
old ready flag while the new preview loaded, so Play or Space could start a play that its load
step then undid.

* refactor(studio): split background lint and thumbnail page reuse into single-purpose steps

Keeps the boot wait in the background lint only, moves page lookup and idle close out of
withPage, and drops a suppression the startup worker no longer needs. Behaviour unchanged.

* chore(cli): declare the render-setup worker as a fallow entry

It is spawned by path from cancellableProcess.ts, so it has no import-graph referrer; the entry list
is where the repo records that, replacing the per-file ignore removed in the previous commit.

* fix(cli): close a thumbnail page whose load fails

The page never leaves the promise that loads it, so when the load throws (a WebGPU composition with
no adapter, or the navigation timeout on a big film) nothing could close it.

* fix(cli): match text assets by exact type and keep Range on them

The pattern anchored only its first alternative, and text assets skipped buildRangeResponse, so a
ranged request for a stylesheet or SVG got a full 200.

* test: open the next project as Studio does, and read the stamped file once

The absent-read guard test switched project without beginning its timeline session, so the
per-project boot wait held the read. The stamp test checked then read the same file.

* test(cli): rewrite the scene file without opening it first

Opening the file by path counted as a check before the later read of the same path.

* perf(cli): take a thumbnail's version from the cached project signature

Each thumbnail walked the project and hashed every text file for its page version; the adapter
already caches that signature and clears it from the watcher. An aborted request now also stops
before its screenshot.

* fix(studio): keep Play off until the preview's load step has run

Play could enable before the preview document's load event, from the runtime's timeline message
or from the blank page's load step settling on the preview's first message. The load step then
paused and rewound the preview, so a Play pressed in between did nothing; a film with large
images loads late enough for this to hit a cold open. Readiness now waits for the document's load
step, whichever path asks for it.

* refactor(studio): move the preview poster's state into usePreviewPoster

NLEPreview.tsx passed the 600-line limit once main grew it; the poster's state and callbacks belong
beside PreviewPoster. No behaviour change.

* fix(cli): take an earlier thumbnail on a fresh page

A composition need not draw the same frame when seeked back, so a kept page that had shown 20 s
drew a different 10 s thumbnail than a fresh load. A kept page now only seeks forward, as a render
does; an earlier time loads a fresh page.

* perf(cli): close a kept thumbnail page a second after its last frame

Held for 10 s, the page went on running its composition after the open's thumbnail
burst, about 5 % of a core in the thumbnail browser while Studio sat idle. A burst's
requests arrive back to back, so it still loads the page once.

* fix(studio): count the preview as booted at its first frame

The boot waited for every video and audio in the preview to finish buffering, which on a
project with media took until the 5 s fallback. The thumbnails, editing session and lint
that wait for the boot then started seconds late and kept an idle Studio busy. They now
start once the first frame is up; media finishes buffering alongside.

* perf(studio): render one thumbnail per composition for its card and clip

A composition's sidebar card asked for its frame at 3.00 s and its timeline clip for the
clip's midpoint, so opening a project rendered every scene twice. The clip now asks for the
card's frame (the midpoint still, for compositions under 3 s), and the thumbnail cache
serves both from one render: 20 scene renders drop to 12 on a 12-scene project.

* chore: keep each changed file's comment share at or under main's

The comment ratchet counts the comment lines this branch added. Each is cut, or kept as one
line where it states an invariant (the GSAP parse cache returns copies; the boot starts waiting
work at the first frame). The orphan-cleanup docblock is condensed and now names the process-table
read it uses instead of the pgrep scan it replaced. No code line changed.

* fix(studio): keep a 3 s clip off its end frame and give card and clip one encoded URL

A composition exactly 3 s long asked for its thumbnail at 3.00 s, its end instant, which after a
fade-out is an empty frame; compositions of 3 s or less now use their midpoint. The card built its
thumbnail URL from the raw path while the clip encoded it, so names with & + [ or % asked for two
different URLs, and a % in the name made the card's request fail on the server; both now encode.
The readiness comment says the boot latches at the first frame, as it has since the boot moved.

* fix(studio): encode the card's live preview URL too, and document the bundler's static guard

The card's hover preview built its URL from the raw composition path, so a % in the file name made
the preview route fail to decode it. The card now builds its composition URL in one place, encoded,
for both its thumbnail and its hover preview. The static-guard bundle option keeps a one-line doc
(its default and what it warns about); a neighbouring docblock is rewrapped to keep the comment
share.

* test(cli): mock studio-server once so the signature spy and the history hook both apply

The main merge brought a second vi.mock of @hyperframes/studio-server into studioServer.test.ts;
the later one replaced the first, so createProjectSignature was no longer a spy and the cached
signature test read .mock of undefined. One mock now carries both overrides.
2026-09-25 04:56:55 +00:00
Miguel Ángel 2623b666c2 test(studio): wait for the off-canvas indicator instead of a fixed number of frames (#4458) 2026-09-24 22:45:17 -04:00
Miguel Angel Simon Sierra a08433acef chore: release v0.8.75 2026-09-24 21:51:48 -04:00
Miguel Ángel 225c22b6b9 fix(studio): a playhead scrolled off to the left no longer draws over the track names (#4436)
* fix(studio): a playhead scrolled off to the left no longer draws over the track names

The playhead layer (z 100) drew over the sticky track headers when the timeline was scrolled past it. Once a scroll settles, its layer is clipped at the header edge, less the glow's half-width so 00:00 looks as before. The settle-time scroll subscription moves out of the ruler into useSettledScrollLeft, now shared by both.

* test(studio): find the timeline playhead through its layer, not its z-index
2026-09-24 21:28:03 -04:00
Miguel Ángel 65a266b29d fix(studio): a deleted clip leaves the timeline as soon as the file drops it (#4447)
Deleting a sub-composition clip left it drawn on the timeline indefinitely. The preview still held the removed host until its reload landed, composition enrichment re-read it from there and put it back, and the merge then kept it on every shorter scan. The delete now takes its clips out of the live preview at once, and updates the timeline right after its write instead of after the ripple finishes saving; a ripple that fails to save puts the gap back.
2026-09-24 20:41:14 -04:00
Miguel Ángel 2fe37e3406 fix(studio): a preview reload keeps a playing film playing where it was (#4438)
An edit that reloads the preview used to stop playback: the reload saved the
time, paused the live frame and marked the player paused, and the promoted
document started paused. Now forward playback runs on in the live frame while
the new document loads. At promotion the new document takes the live frame's
time, so playback, a stop at the end or a seek made during the load carries
over, and it keeps playing if the live frame was, unless the edit cut the film short of that time. A paused film, a reverse
shuttle and a dropped reload behave as before.
2026-09-24 17:58:43 -04:00
Miguel Angel Simon Sierra 3a47b46057 chore: release v0.8.74 2026-09-24 17:04:02 -04:00
Miguel Ángel 69fea290a9 fix(studio): a ruler label that slides under the track header is hidden, not sliced (#4424)
* fix(studio): a ruler label that slides under the track header is covered once the scroll settles

The track-header corner of the ruler is sticky and opaque and tick labels scroll natively under it, so the ruler could rest on a fragment ('0:05', '0'). A single mask subscribes to the viewport scroll with useSyncExternalStore, finds the one label the header slices, and covers it when the scroll settles. Moving it live on every tick crossing cost 2-3x the viewport gate's budget on a large unvirtualized timeline, so mid-scroll a fragment can still show.

* fix(studio): the ruler mask never stays off after its scroll listener is swapped

Unsubscribing mid-scroll left scrollingRef true, so the mask stayed hidden until the next scroll. Reset it with the settle timer.
2026-09-24 14:03:23 -04:00
Miguel Ángel 6f7f463fc5 chore: release v0.8.73 2026-09-24 11:53:31 -04:00
Miguel Ángel a3d7736d40 fix(studio): trimming a scene no longer shrinks it to the length of a video inside (#4421)
A clip's source length came from the first video, audio or image found inside
its element. For a composition that is media on the scene's own timeline, so a
scene holding a 1.8 s video could not be longer than 1.8 s: trimming its end by
a few pixels collapsed it to that length. A composition host no longer lends its
inner media to the clip; a plain wrapper around one video still does. An inline
composition still shows its inner video or image as its thumbnail.
2026-09-24 11:49:11 -04:00
Miguel Ángel 1926903b6a fix(studio): an edit still reaches the preview when its reload is slow (#4418)
A shadow reload that was not ready within one 15 s budget was discarded for
good: the stage kept the previous document, the edit never showed, and nothing
retried. On a busy machine that dropped deletes, trims and agent writes. The
shadow now keeps loading for up to three budgets before it is dropped, so a
slow load still lands and a load that never becomes ready still fails.
2026-09-24 10:24:34 -04:00
Miguel Ángel 881481f56b feat(studio): an embedder can change what the shortcuts panel lists (#4416)
* feat(studio): an embedder can change what the shortcuts panel lists

The panel read a private list, so an app that embeds PlayerControls could not drop a key Studio binds but it does not (bare S split). The list is now exported as DEFAULT_SHORTCUT_SECTIONS, PlayerControls takes shortcutSections, and the dispatcher names its bare F, S and R keys from the same module. Defaults unchanged.

* fix(studio): an embedder's shortcuts list may repeat a key or a section title

Rows were keyed by their key text and sections by title, so a relabelled list with a repeat hit React duplicate keys. The list is static, so rows and sections are keyed by position.
2026-09-24 09:45:49 -04:00
Miguel Ángel aa2c2c68fe fix(studio): a timeline at 00:00 stays at 00:00 when the window resizes or the zoom is set (#4411)
The centre-anchor step ran on every scale change, including a window resize and a zoom set by a host, and from scroll 0 it always moved the view right, hiding 00:00 under the track header. A view at the start now stays there; a scrolled view still keeps its centre time.
2026-09-24 08:32:05 -04:00
Miguel Ángel 6a0fd68584 chore: release v0.8.72 2026-09-24 07:23:30 -04:00
Miguel Ángel 326df8def0 feat(studio): let an embedder draw the preview edge to edge with fillBox (#4408)
NLEPreview always drew its picture 8px in from each side on a dark band, so an embedder that sizes the box to the film saw a smaller picture in a black frame. fillBox drops that inset from both the layout and the stage-size math. Default look is unchanged.
2026-09-24 07:22:26 -04:00
Miguel Ángel 58c873d816 chore: release v0.8.71 (#4395) 2026-09-23 23:32:31 -07:00
Miguel Ángel d1048cf98f fix(studio): ship component styles as their own stylesheet for embedders (#4393)
* fix(studio): ship component styles as their own stylesheet for embedders

Studio's component rules (timeline clips, loaders, range inputs, toasts and
the rest) lived only in the app sheet, so a page that embeds Studio's
components with theme.css got unstyled clips and dark-on-dark labels. They
move unchanged into components.css, exported as
@hyperframes/studio/components.css; studio.css keeps the app shell and
imports it, so Studio's compiled CSS has the same rules.

* test(studio): read moved component rules from components.css

* test(studio): catch class rules nested in the app sheet's layers and media blocks
2026-09-24 01:35:04 -04:00
Vance IngallsandClaude Sonnet 5 c59326d72e fix(studio): refresh the file tree on external change, and say when a composition is gone (#4370)
The fix the cutover-dashboard investigation this week was building toward.
Proven 2026-09-23: `refreshFileTree` (useFileTree) is only ever called from
Studio's OWN file operations (create/delete/rename/upload/block install) —
never on an external change. An agent removing or replacing a composition
updates the preview and the SDK session (useExternalFileChangeCoordinator
already reloads both) but never the tree, so the file stays listed and
clickable forever. Opening it opens a session that can never resolve
(`reason: "absent"`), every edit through it silently takes the (also
failing) server path, and nothing tells the user why.

Two refresh triggers, belt and braces:

- `useExternalFileChangeCoordinator` gains an optional `refreshFileTree`
  collaborator, called alongside `reloadPreview`/`reloadSdkSession` on
  every accepted external change — closes staleness at the source, for
  every consumer of the tree, not just the SDK read. Wired through
  `useStudioExternalFileChanges` from `fileManager.refreshFileTree`
  (already in scope there).

- `useSdkSession` gains `onAbsentRead`, called once per (projectId, path)
  when a read reports `reason: "absent"` — the fallback for a change the
  SSE never delivered (server restart, a missed watcher event). Guarded by
  a ref-tracked set so a genuinely-gone file can't loop, cleared on
  project change so a different project's identical path can still fire.

New: `sdkHandle.compositionMissing`, true for exactly that read result on
the currently active path, and `CompositionMissingBanner` — sibling of
`ProjectUnreachableBanner` (that one fires when the whole PROJECT is
unreachable; this one when the project is fine and this one file inside
it is not) — shown in App.tsx alongside it. The read failure alone is
silent; this is what tells the user their edits stopped landing.

Deliberately not touched: `activeCompPath` is never cleared when the path
leaves the tree — the graveyard page (`clearing-activecomppath-on-tree-
absence`) is binding, and its reasons still hold (`useAutoOpenRootComposition`
latches once; a null path never recovers).

Tests: coordinator refresh fires once per accepted change, not for a
self-write echo, and no-ops when the collaborator is absent (4 new).
useSdkSession: compositionMissing set/cleared, onAbsentRead once per path,
fires again for a different path, guard resets on project change, no-ops
without the collaborator (5 new). Banner render test (1 new). 25/25 in the
touched suites; 2830/2831 across studio hooks+components overall (the one
failure is a pre-existing happy-dom `window.confirm` gap in
ExternalFileConflictBanner.test.tsx, confirmed unrelated via git stash).
oxlint, oxfmt, tsc --noEmit clean.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 20:13:21 -07:00
Miguel Ángel fc9071c1fc chore: release v0.8.70 (#4389) 2026-09-23 22:10:27 -04:00
Miguel Ángel 86fa10ced4 perf(studio): show a film's first frame sooner by loading preview assets by URL (#4357)
* fix(studio): serve preview assets by URL instead of inlining them as base64

The Studio preview route serves project files under a base href, but both preview adapters
called the bundler with its self-contained defaults, so every image and font was inlined
once per reference. One exported option set now owns how a preview bundle is built.

* test(studio): cover poster, srcset and sub-composition preview asset URLs

Also types the Vite adapter bundler with BundleOptions, so the shared preview options are
checked against the real option set.

* fix(studio): tag a preview asset for revalidation only once it has settled

Preview assets are now served by URL, so the asset route serves every image and video. It tagged
them by mtime and size; two same-size writes inside one filesystem tick share an mtime, and copy
tools (cp -p, rsync) set mtime back, so a browser could keep stale bytes after an edit. The route now
tags a file by inode, change time and size, and only once its last change is three seconds old; a
younger file is served without a tag and refetched.
2026-09-23 21:08:56 -04:00
Miguel Ángel 2c4bf075b5 chore: release v0.8.69 (#4376) 2026-09-23 20:13:08 -04:00
Miguel Ángel 86c5c53d25 chore: release v0.8.68 (#4361) 2026-09-23 17:16:00 -04:00
Miguel Ángel 9cee2bd345 chore: release v0.8.67 (#4354) 2026-09-23 16:14:31 -04:00
Miguel Ángel a57e095468 feat(studio): panel tabs take the decided Current design (#4266)
Studio's panels become cards on a dark shell, and each panel's tabs take the "Current" design: the open tab is a fill in the card's colour that slides to the tab you pick, only the open tab shows its icon and close glyph, and a strip that is too narrow scrolls with a soft edge instead of hiding tabs behind a menu. This replaces the pill, underline and close treatment that was on this PR before.

## Before

![Before 2x: main, dark](https://github.com/user-attachments/assets/00ec6f33-082a-4209-917d-dea3c73c5b3d)

![Before 2x: main, paper theme](https://github.com/user-attachments/assets/09b01028-fa94-4946-bc47-a9abe7111a2f)

## After

![After 2x: dark](https://github.com/user-attachments/assets/041dfebf-c2cd-48bb-9ad4-5200d9f2ef9a)

![After 2x: paper theme](https://github.com/user-attachments/assets/d901bda4-ed7a-462f-8b83-0a7e3d4a7eff)

Switching tabs (the fill slides in about 140 ms and the new icon fades in over 80 ms; the panel body switches at once):

https://github.com/user-attachments/assets/8d359ada-c9e1-4425-9c95-61d5d4f5271b

### Every label whole, and the tabs use the whole strip (2x)

An earlier head clipped the left strip's fourth tab next to empty strip space. That space was a 72 px slot (plus 4 px) held on every strip for the strip actions, even on strips whose panel is not focused and so draws no actions. Dockview sizes that slot by its content; this PR had pinned it. Now the slot takes room only where the actions are drawn, so an unfocused strip gives its tabs the whole width. Tab padding is 6 px with no gap between tabs, so the tabs also fit next to the actions. Labels, icons and close glyphs sit on the tab's centre in every strip.

Left strip at 1440 x 900, tabs width against the room before the action slot:

| | macOS | Linux (these captures) |
|---|---|---|
| Earlier head | 322 in 280, clipped | 312 in 280, clipped |
| Now, panel unfocused | 278 in 358 | 268 in 358 |
| Now, panel focused (actions shown) | 278 in 282 | 268 in 282 |

![Left strip 2x at 1440, earlier head: Catalog clipped](https://github.com/user-attachments/assets/42d8eae9-67f5-49b6-b581-19c5185a4bdc)

![Left strip 2x at 1440, now: every label whole](https://github.com/user-attachments/assets/fc5d9137-1cfe-42eb-a6cd-f8bd90c19b4d)

![Left strip 2x at 1440, panel focused: actions shown, labels still whole](https://github.com/user-attachments/assets/66ce47c2-5bd9-47ca-870e-9c3acf6001fc)

At 1280 x 800 the left column is narrower, and the pinned slot clipped Catalog next to the empty space. Room for the tabs there went from 241 px to 317 px:

![Left strip 2x at 1280, earlier head: Catalog clipped next to the empty action slot](https://github.com/user-attachments/assets/b0603454-7c2f-4a4c-98b4-9028bc9c0d31)

![Left strip 2x at 1280, now: the tabs use the whole strip](https://github.com/user-attachments/assets/f3dfb3ce-ccb8-4221-8339-e481ff31df7f)

Captured at the PR head (5fd6c7bd) from a fixture project (the `warm-grain` registry example) in headless Chrome on Linux, 2x, each state loaded fresh so Studio builds its default layout for that window. Before is `main`; the strip Befores use the earlier heads' `dock.css` (8079c635 at 1440, 8ed7ab69 at 1280) on the same build. The macOS column was measured live in Chrome on macOS. The paper theme only re-themes the timeline today, so the panels look the same in both themes.

## What changed

- **Cards on the shell.** Each dock group is a card with the only 1 px border (radius 10) and a 6 px gutter to its neighbours. The preview and timeline no longer draw a second rounded border inside it; the preview stage is an inset well. Dockview's tab dividers and separators are gone.
- **Tabs.** Tabs use the whole strip unless the panel shows its actions. Every label is whole at the default 1440 x 900 layout (6 px side padding, no gap between tabs), and labels and icons are centred in the tab. The open tab is a fill with rounded top corners, flush with the card body. Only the open tab shows its type icon and close glyph (the glyph appears on hover or keyboard focus, and its space is kept so the label never moves). Other tabs are labels.
- **One sliding fill per strip.** One element slides (transform and width, 140 ms ease-out) under the open tab. Reduced motion turns the slide and the icon fade off. Hover is instant.
- **Strip actions** (panel menu, maximise or restore, close group) sit on the active group only, at half opacity while the pointer is in the panel and full on hover or focus. They take room only on the strip that draws them, so the other strips give their tabs the whole width. The panel menu lists that side's panels as checkboxes, plus Reset layout.
- **Narrow strips** scroll sideways with a 12 px fade on the clipped edge. Dockview's overflow dropdown is off.
- Tokens: `--panel-shell-bg`, `--panel-card-bg`, `--panel-card-border`, `--panel-strip-bg`, `--panel-tab-bg-hover`, with contrast pairs registered.

## Timeline viewport gate

The first head of this rebuild (b20ff32a4) cost the timeline gate one frame on CI: interaction p95 33 ms on every run (first attempt 100 ms on three runs), against main's 20 ms. Main-thread traces were identical; the extra cost was compositing, from a fade mask on every tab strip. Masking only a strip that is actually clipped brought it back:

| Head | Interaction p95 per run (ms) | Frame p95 (ms) | Runs passing |
|---|---|---|---|
| main | 20, 20, 19, 20, 18 | 34 | 5/5 |
| b20ff32a4 (rerun) | 33, 33, 33, 33, 33 | 33 | 5/5 |
| 41302773 | 18, 20, 19, 20, 17 | 33 | 5/5 |
| eefaa839 | 18, 20, 18, 18, 20 | 17 | 5/5 |
| af4f2b9b (tab spacing), first attempt | 18, 79, 18, 116, 19 | 100 | 3/5 |
| af4f2b9b, rerun | 18, 16, 20, 18, 18 | 33 | 5/5 |
| 8ed7ab69 | 17, 16, 19, 24, 20 | 33 | 5/5 |
| 6a5edd89 | 33, 33, 33, 33, 33 | 17 | 5/5 |

From about 14:30Z `main` itself reads 33 ms on every run (three consecutive `main` CI runs), and CI tests this PR merged with the current `main`, so the 6a5edd89 row matches `main`'s level at that time.

The af4f2b9b first attempt had three runs at main's level and two single-run spikes; the same commit's rerun passed 5/5, and the change is tab spacing only.

## Tests

Each rule that could regress without anyone noticing has a test, and each test was proved by breaking the code once:

| Rule | Test | Break | Result |
|---|---|---|---|
| Icon and close glyph on the open tab only | `DockTabs.test.tsx` (dock.css applied, computed display) | delete the hide rule | 2 red |
| Actions on the active group only | `DockTabs.test.tsx` | drop the active-group check | 1 red |
| One fill per strip, following the open tab | `DockTabs.test.tsx` | fill stays at 0 / a second fill | 1 red each |
| One border per surface | `dockTabs.contract.test.ts` | add a strip border | 1 red |
| Only the slide and icon fade animate | `dockTabs.contract.test.ts` | add a hover transition | 1 red |
| Reduced motion turns them off | `dockTabs.contract.test.ts` | remove the reduced-motion rule | 1 red |
| Label and icon centred in the tab | `dockTabs.contract.test.ts` | drop the full-height rule | 1 red |
| Every tab label whole at 1440 x 900 on each strip while its panel is focused (the least room it gets), with 10 px for macOS's wider system font (CI renders a Linux fallback) | `scripts/studio-runtime-smoke.mjs` (CI "Studio: load smoke", real Chrome) | the "Ca" head's spacing; a spacing that fits on Linux but clips on macOS | red on both ("312px of tabs in 280px", "276px of tabs in 280px"); green on main and this head |
| Unfocused strips hold no room for actions they do not draw | `scripts/studio-runtime-smoke.mjs` | pin the action slot at 72 px again | red: "3 inactive dock strips hold width for actions they do not draw" |
| The shown tab stays whole when its strip narrows (its panel gains focus and the actions appear), comes back narrower than it was before it was hidden, or when a neighbouring tab closes and it moves | `DockTabs.test.tsx` | ignore each of those changes in turn | 1 red each; checked live at 1280 x 800: clicking into the left panel's body used to leave Catalog 37 px hidden, now it is whole |
| A strip the user scrolled by hand stays put when something else in the dock changes, when it widens, and through another panel's maximise and restore | `DockTabs.test.tsx` | reveal on every pass; on any width change; after a hidden spell | red each; checked live: a hand scroll of 20 px is still 20 px after a maximise and restore |

Run alone, each exits 0: `DockTabs.test.tsx` 5, `dockTabs.contract.test.ts` 5, `Dock.test.tsx` 14, `dockAccessibility.test.ts` 10, `PreviewPane.test.tsx` 1, `Menu.test.tsx` 10, `scripts/contrastRatchet.test.ts` passing.

## Independent review

An adversarial review (separate agent, mandate to refute) at 41302773, eefaa839, 8079c635, af4f2b9b and 8ed7ab69: **no blockers; one major, fixed.**

- MAJOR (af4f2b9b), fixed in 8ed7ab69: CI renders Studio's system font stack with a narrower Linux fallback, so the clip check could pass while a Mac clips. The check now keeps 10 px of room for the macOS font, and a spacing that fits on Linux but clips on macOS turns it red. The 8ed7ab69 review asked whether 10 px holds for every strip, not just the left one it was measured on. Measured macOS minus Linux tab width at 1440 x 900: left 10 px, right 6 px, Preview and Timeline 2 px each, so the left strip is the widest gap and 10 px covers all four.
- MINOR, left as is: the room check has no unit test of its own; it runs only in a real browser, and the smoke is its test (red on both broken spacings above).

- MINOR: a panel cannot be closed from the focused tab itself by keyboard. The keyboard path is focused tab, Tab to "Panel menu", Enter, uncheck the panel (checked live: closes that one panel; checking it again reopens it). The tab's close glyph is not focusable, the same as dockview's stock tab on main, because a tab cannot hold a focusable button.
- MINOR: the open tab is 16 px wider than on the design page (the close glyph's space is kept; see For Miguel).
- Resolved: a contrast pair cited stale line numbers.

## UX checklist

**Job:** someone editing a video moves between panels (Compositions, Assets, Code, Catalog; Design, Layers, Renders, Variables) many times an hour and needs to see which one is open and reach the next without hunting.
**One action:** click the tab of the panel you want.

| # | Question | Y/N | Evidence |
|---|---|---|---|
| 1 | First-time user knows what to do | Y | Tabs keep their labels; the open tab is joined to the body it controls (After captures) |
| 2 | Interactive things look interactive | N | Strip actions are hidden until the pointer is in the panel (Miguel's decision; see Traded off) |
| 3 | User's words, no internal ids | Y | Titles unchanged; controls named "Close Design", "Panel menu", "Maximize panel", "Close group" |
| 4 | Works like other tools | Y | Editor-style tabs with maximise and a panel menu |
| 5 | Guidance at the control | N | The three icon buttons have accessible names but no visible tooltip |
| 6 | Primary action reachable without reading | Y | The tabs are the strip |
| 7 | Exactly one primary action | Y | Tabs; actions are secondary and quiet |
| 8 | Counts match data | Y | Titles come from each panel through `Dock.Panel` (`Dock.tsx` setTitle) |
| 9 | Excluded items announce themselves | Y | Every label is whole at the default width (the load smoke checks it); a narrowed strip scrolls with an edge fade, and the panel menu lists every panel |
| 10 | Long names recoverable | Y | Tabs never truncate; the strip scrolls |
| 11 | Keyboard path | Y | Arrow keys move and activate tabs (existing); strip actions are buttons and show on focus |
| 12 | Nothing hover-only that must be acted on | N | Same as 2 |
| 13 | Refusals say what next | n/a | No refusals here |
| 14 | Refusal next to the thing | n/a | |
| 15 | Error prevented | n/a | |
| 16 | Can leave any state without losing work | Y | Closed panels reopen from Window or the panel menu; Reset layout restores the default |
| 17 | Destructive looks different | N | "Close group" uses the same x as a tab's close; it loses no work, and the Window menu reopens the panels |
| 18 | Empty state names what is empty | n/a | |
| 19 | Feedback within 400 ms | Y | The tab state and icon change in the same frame as the click; the fill arrives in 140 ms |
| 20 | Progress on waits | n/a | |
| 21 | Hand-built state survives reload | Y | Layout, open tab and maximised group persist per project (existing dock persistence) |

**Choice:** four tabs per side, one click each. **The end:** the panel body appears at once and the fill settles under the tab you picked.
**Not exercised:** a screen reader pass; touch input.
**Traded off:** hover-only strip actions (rows 2 and 12) and scroll-plus-fade instead of an overflow menu when a strip is narrowed are Miguel's decided design.

## For Miguel

- **Paper (light) theme:** Studio's paper block only re-themes the timeline today, so the panels are dark in both the Before and After paper shots. Panel paper values are Desktop's to set (design principles, section 7); this PR adds the token names Desktop can override.
- **Action slot:** the design review asked for the action slot to be reserved on every strip, so tabs never move when focus changes panel. That reservation is what clipped "Ca" next to empty space, so it now holds room only on the focused panel. The cost: when a strip that is already too narrow gains focus, its actions take 76 px. The strip then scrolls so the shown tab stays whole, and a neighbouring tab can go under the edge fade. At the default 1440 x 900 nothing clips either way.
- **Tab spacing:** side padding is 6 px (10 on the design page) and tabs have no gap. The design's 10 px would need 322 px on macOS; the left strip has 282 px beside its actions at 1440. A wider default left column would allow the design padding again. A narrower window scrolls the strip with the edge fade; per the decision there is no overflow chevron.
- **Close glyph space:** the open tab keeps its close glyph's space at rest, so it is 16 px wider than on the design page, where the label shifts on hover.
- **Not in this PR:** inspector section headers and the focused-card border step (later build steps).
2026-09-23 14:34:10 -04:00
Miguel Ángel 4efe5f206d chore: release v0.8.66 (#4344) 2026-09-23 13:29:38 -04:00
Miguel Ángel df97c599ee perf(studio): stop the manual-edit loop from running while the preview is paused (#4333)
* perf(studio): stop the manual-edit reapply loop from running while paused

* fix(studio): judge nested timelines by their parent, not the player alone

* fix(studio): bound the paused-ancestor walk and ignore unknown parents

* style(studio): format the manual-edit tests
2026-09-23 11:10:11 -04:00
Miguel Ángel aabfba5445 chore: release v0.8.65 (#4336) 2026-09-23 11:03:11 -04:00
Miguel Ángel 2c70e65784 fix(studio): timeline clips keep their pictures while you scroll (#4305)
* fix(studio): timeline clips keep their pictures while you scroll

With row virtualization on, the timeline dropped every clip's picture for the length of a scroll
gesture and put it back 100ms after the gesture stopped, so a zoomed-in timeline showed grey boxes
the whole time a person was moving through it. Clip content now stays mounted while scrolling;
pictures already loaded come straight from the thumbnail cache.

* perf(studio): clips that scroll into view get their pictures when the scroll stops

Keeping every clip's content mounted through a scroll made each jump of the 50,000-clip viewport
gate mount and lay out a screenful of new thumbnails, a 90 to 130 ms long task per run where main
has none. A clip's content now mounts only while the timeline is at rest and then stays through
later scrolls, so a picture already on screen never blanks and a scroll never mounts new ones.
2026-09-23 10:39:22 -04:00
Miguel Ángel 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
2026-09-23 10:29:01 -04:00
Miguel Ángel e02120fd0e perf(studio): find timeline transition seams once per track, not once per row (#4324)
Every timeline render re-sorted every clip in the project once per visible row to find transition
seams, then kept the row's own. On the 50,000-clip viewport fixture that sort was the largest CPU
cost in a scroll. Seams are now derived once per change of the clip set, grouped by track, and each
row looks up its own. Pairing within a track also fixes a missing transition badge when a clip on
another track starts between the transition's two clips.
2026-09-23 09:20:19 -04:00
Vance Ingalls ed75203cb6 chore: release v0.8.64 (#4326) 2026-09-23 04:03:02 -07:00
Vance IngallsandClaude Opus 5 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>
2026-09-23 03:44:04 -07:00
miga-heygenandMiguel Angel Simon Sierra 99221c50a5 chore: release v0.8.63 (#4312)
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-23 05:49:08 -04:00
Miguel Ángel 06340f656e fix(studio): clip pictures at full strength and a seam where touching clips meet (#4309)
* fix(studio): composition and text clips show their frame at full strength

CompositionThumbnail drew its frames at 0.7 opacity with a lighten blend, the only timeline
picture dimmed that way; on a light theme the frame nearly vanished into the clip. Opacity and
blend are now theme tokens, untinted by default like video and image clips.

* fix(studio): touching clips show a hairline seam where they meet

Two clips that meet end to start used to read as one long bar. Each exact join on a row now
gets a 1px line in the row colour, drawn over the clips. Clip positions, widths and keyframe
diamonds do not move, so the timeline still tells the truth about time.

* fix(studio): hide clip join seams while a clip is being moved

A moved clip is drawn by the drag overlay, not at its element position, so its join line stayed
behind at the old boundary until the drop. Rows draw no join lines during a move or multi-clip
drag; they come back on drop. The join math now reuses the lane helpers for sort order and the
touching tolerance.

* refactor(studio): drop a multi-drag check the move-drag check already covers

A multi-clip drag preview only exists once a move drag has started, so the join lines only need
the move-drag check.
2026-09-23 05:19:39 -04:00
Miguel Ángel 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.
2026-09-23 02:05:42 -04:00
Miguel Ángel b73df3549e refactor(studio): remove unreachable expanded-child timeline code (#4219)
* refactor(studio): keep legacy fixture state out of rendered rows

* fix(studio): keep timeline keyboard actor mounted

* refactor(studio): remove expansion telemetry and prove stable nested rows

* test(studio): type nested timeline fixture broadly

* test(studio): remove expansion lane assertions

* test(studio): assert nested row identity by key

* fix(studio): satisfy removal and format guards

* fix(studio): clear fallow findings in timeline cleanup

* test(studio): update timeline removal expectations

* fix(studio): remove stale timeline test helpers

* fix(studio): keep automation lanes mounted without disclosure

* refactor(studio): keep audio lanes while removing clip expansion

* fix(studio): restore audio disclosure state

* test(studio): remove obsolete expanded fixture assertions

* fix(studio): resolve provider rebase without clip auto expansion

* fix(studio): remove inline keyframe expansion rows

* fix(studio): restore automation disclosure state

* fix(studio): remove stale timeline model inputs

* fix(studio): pass animation state to timeline layout

* fix(studio): remove stale expansion tests and helpers

* test(studio): cover shared audio lane labels

* fix(studio): remove unused keyframe callback plumbing

* fix(studio): clear rebase lint leftovers

* fix(studio): remove unused timeline header export

* style(studio): format timeline cleanup

* fix(studio): restore composable timeline exports

* fix(studio): keep audio lanes open across selection

* fix(studio): align timeline lane test contract

* fix(studio): remove stale lane test import

* fix(studio): align track header test callback

* fix(studio): remove stale track header test prop

* test(studio): preserve ruler half pixel alignment

* test(studio): keep audio lane fixtures drawable

* test(studio): assert shared lane owner label

* fix(studio): align audio lane expansion with layout state

* test(studio): use the audio lane owner label

* fix(studio): unify audio lane expansion state

* fix(studio): align automation lane labels

* fix(studio): remove stale lane height import

* test(studio): assert automation labels against the real curve, not a formula

The previous test recomputed the expected top with the same formula
the fix uses, so a regression in TimelineAutomationLaneSlot's own
offset would still read green. Moved it to TimelineLanes.test.tsx,
which mounts both the header and the canvas, and compares their
rendered [data-automation-lane-label]/[data-automation-lane] tops
directly.

* revert(studio): restore the keyframe-row feature from main

Miguel: keep the per-property keyframe rows (diamonds, prev/next
arrows, disclosure, auto-open) exactly as main. This PR's real scope
is removing dead expandedParentStart consumers, not the keyframe-row
UX.

* refactor(studio): remove dead expanded-child timeline code

expandedParentStart is never set anywhere on main (the inline
sub-composition child-row expansion that produced it was already
removed in #4132/7797c5ead). Delete its now-dead consumers: the
TimelinePane move/resize/delete/split rebase wrappers, the ZMirror
call site that routed through them, the timelineDragLanding topology
guards, isMainTrackElement's expanded-child exclusion, laneGapFloor's
host-window-start math, and the paired expanded-clip-edit telemetry.
Keeps every keyframe-row feature (diamonds, prev/next arrows,
disclosure, auto-open, telemetry) untouched.
2026-09-23 01:38:50 -04:00
CasbaLandCasbaL e5eeeef972 fix(studio): stop VideoFrameThumbnail error→cleanup infinite loop (#4037)
The error handler called cleanup(), which sets video.src = "" and calls
load(). Clearing src itself fires another error event, re-entering the
handler at event-loop speed — every pass allocating a React update via
setFailed(true). The seeked path triggers the same loop: its cleanup
provokes the synthetic error right after a successful frame extract.

Detach the error listener inside cleanup() and ignore the synthetic
empty-src error. Instrumentation on a real session measured ~475k src
assignments/second and ~8.6 MB/s of update allocations before the fix.

Fixes #4036

Co-authored-by: CasbaL <26603379+CasbaL@users.noreply.github.com>
2026-09-23 01:00:15 -04:00
Miguel Ángel d4485b51f1 chore: release v0.8.62 (#4293) 2026-09-22 16:12:35 -04:00
James RussoandClaude Opus 5.5 16959deeac fix(studio): don't fail a shadow reload while the tab is hidden (#4292)
The shadow reload promotes on frame-driven readiness (the player's
paint-and-idle, then two requestAnimationFrames) but failed on a
wall-clock 15s setTimeout. A hidden tab renders no frames, so every
external edit made while Studio was in the background failed with
"it took too long to load", the toasts stacked, and the stale preview
stayed up even after returning to the tab.

The budget now only runs while the document is visible: hiding the tab
clears the timer, and showing it again restarts the full budget for the
pending shadow, which then promotes normally.

Fixes #4290

— Rames Jusso

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 20:05:15 +00:00