Commit Graph
943 Commits
Author SHA1 Message Date
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 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 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
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
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
Miguel Ángel e5a0370727 feat(studio): transition seams blur and carry a badge (#4280)
* feat(studio): transition seams blur and carry a badge

* fix(studio): keep labelled transitions on one row

* fix(studio): render transition crossfade zone

* fix(studio): gate seams on transition labels

* fix(studio): bridge GSAP transition markers to clips

* fix(studio): clear transition seam CI failures

* fix(studio): register transition frame classes
2026-09-22 08:59:53 -04:00
Miguel Ángel 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
2026-09-22 08:58:21 -04:00
Miguel Ángel 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
2026-09-22 06:45:11 -04:00
Vance IngallsandClaude Sonnet 5 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>
2026-09-22 01:40:47 -07:00
Miguel Ángel 5203312c34 feat(studio): timeline clips take the designed material (#4270)
* feat(studio): timeline clips take the designed material

Picture clips use an 8px radius and audio clips a 21px pill.
Clips sit one step above the lane. The label is a chip that
drops under 60px. Selection keeps the inset outline plus a
1px inner highlight. Hover background is the only transition.

* style(studio): format timeline clip shadow

* fix(studio): restore handle-bar transition and de-pin box-shadow test
2026-09-22 02:15:55 -04:00
Miguel Ángel 867787b2f9 feat(studio): menus and popovers paint instantly (#4268)
* feat(studio): menus and popovers paint instantly

Menus and popovers open and close with no fade. Hover colour is unchanged.

* fix(studio): fade menus in from a visible shape

Menus, popovers, and dropdowns start at opacity 0.8 and scale 0.97, then settle in 120ms. They close in 90ms. Tooltips use the same shape in 100ms.
2026-09-22 01:05:00 -04:00
Miguel Ángel 2cc384f1fe feat(studio): draw icons that say what the feature does (#4262) 2026-09-21 21:05:20 -04:00
Miguel Ángel 3e63de4778 fix(studio): keep timeline selection ring inside clip (#4251)
* fix(studio): keep timeline selection ring inside clip

* test(studio): cover active dragging selection ring

* fix(studio): keep selection rings above thumbnails
2026-09-21 21:03:56 -04:00
Miguel Ángel 1efe02a346 feat(studio): draw the audio meters toggle as a level meter (#4254) 2026-09-21 20:06:54 -04:00
Miguel Ángel eeabbe839f fix(studio): timeline colours all resolve from tokens (#4249)
* fix(studio): brighten unselected timeline clips

* test(studio): pin timeline clip contrast colors

* fix(studio): keep timeline clip contrast themeable

* chore(studio): ratchet hex color baseline

* fix(studio): route timeline colours through theme tokens

* fix(studio): route timeline colours through theme tokens

* test(studio): assert waveform token lookup

* test(studio): bank timeline colour reductions

* fix(studio): preserve timeline token defaults

* fix(studio): preserve active label theme value

* style(studio): format timeline token test

* fix(studio): bank colour literal reductions

* fix(studio): remove zero colour baseline
2026-09-21 20:03:18 -04:00
Miguel Ángel 6f6d24248c feat(studio): audio meter faders for groups and master (#4203)
* feat(studio): audio meter faders for groups and master

* fix(studio): meter's mid band uses amber-500, the theme's actual token

yellow-500 was reset to nothing by palette-reset.css (--color-*: initial)
and never redefined in theme.css, so the mask never revealed any color
for -6 to -3dB; the token gate test already catches this and was red.

* refactor(studio): rename YELLOW_AT to AMBER_AT to match the amber token

* fix(studio): fader nudge and aria-valuenow match the visual dB position

* refactor(studio): use core StereoLevel for meter channel shape

* fix(core): emit levelTap in the packed core build

* fix(studio): name the preview fader Monitor and share clampNumber

* fix(studio): split the meter rAF tick and cover its branches

* style(studio): oxfmt AudioMeterStrip tests

* fix(studio): preserve authored gain in audio meter faders

* fix(studio): make timeline audio meters opt-in
2026-09-21 19:25:04 -04:00
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>
2026-09-21 18:45:08 -04:00
Miguel Ángel 97420736d5 feat(studio): audio level meters at the timeline's right edge (#4193)
* feat(studio): audio level meters at the timeline's right edge

* test(core): cover metering started before the audio context finishes init

* fix(studio): keep meter follow after a dead preview and start bars at silence

* fix(studio): keep the 0 dB peak tick inside the meter

* fix(studio): keep the 0 dB peak tick visible without calc()

* fix(core): tap meters on the program bus, mute on a monitor gain

* style(core): wrap applyMasterGain to satisfy oxfmt
2026-09-21 16:19:30 -04:00
Miguel Ángel 0641921558 feat(studio): expose composable timeline parts (#4229)
* feat(studio): expose composable timeline parts

* refactor(studio): split timeline overlay parts

* fix(studio): resolve timeline fallow findings

* fix(studio): clarify timeline part contracts

* style(studio): trim timeline drag comment
2026-09-21 09:03:50 -04:00
Vance IngallsandClaude Code 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>
2026-09-20 19:40:06 -07:00
Miguel Ángel b4787082b2 fix(studio): keep narrow timeline headers readable (#4232)
* fix(studio): hide narrow timeline header labels

* test(studio): cover narrow automation headers

* test(studio): cover narrow automation headers

* test(studio): assert narrow lane tooltip

* style(studio): format narrow header changes

* test(studio): cover narrow speed lane labels

* fix(studio): truncate timeline header names

* test(studio): remove stale header room plumbing
2026-09-20 19:49:29 -04:00
Miguel Ángel 4ff4435e1d feat(studio): export player handle for hosts (#4227) 2026-09-20 17:47:35 -04:00
Miguel Ángel 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
2026-09-20 17:15:29 -04:00
Miguel Ángel e48eac7f5e feat(studio): track preview time to first frame (#4226) 2026-09-20 17:10:41 -04:00
Vance IngallsandClaude Code 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>
2026-09-20 13:53:52 -07:00
Vance IngallsandClaude Code 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>
2026-09-20 13:53:31 -07:00
Vance IngallsandClaude Code 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>
2026-09-20 13:30:35 -07:00
Miguel Ángel ab6cbb383e refactor(studio): compose timeline parts through TimelineProvider (#4222)
* refactor(studio): add timeline provider boundary

* fix(studio): preserve timeline context schema

* refactor(studio): own timeline composition context

* fix(studio): complete timeline context ownership

* refactor(studio): keep timeline helper exports in Timeline

* refactor(studio): extract timeline provider state builders

* refactor(studio): isolate timeline provider branches

* refactor(studio): extract timeline overlay state

* refactor(studio): extract timeline overlay state hook

* fix(studio): keep overlay internals out of context
2026-09-20 15:38:27 -04:00
Miguel Ángel 243ecd1d10 feat(studio): compose preview overlay guides (#4220)
* feat(studio): compose preview overlay guides

* fix(studio): sync late preview iframe mounts

* fix(studio): make preview iframe ownership explicit
2026-09-20 14:37:50 -04:00
Miguel Ángel 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.
2026-09-20 13:04:23 -04:00
Miguel Ángel 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
2026-09-20 12:21:47 -04:00
Miguel Ángel fa699b7d9b refactor(studio): move playback adapter resolution into its own module (#4199) 2026-09-20 12:07:53 -04:00
Miguel Ángel 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
2026-09-20 10:18:03 -04:00
Miguel Ángel 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
2026-09-20 10:10:57 -04:00
Miguel Ángel 9bd6da848f feat(studio): theme timeline colours with CSS tokens (#4211)
* feat(studio): the timeline's colours read CSS custom properties instead of literals

The timeline's surfaces, hairlines, ruler text and clip colours were hard-coded
hex/rgba, so a host with a light chrome saw a black slab. The 24 values move
into new --timeline-* tokens in theme.css (the same mechanism Button and Menu
already use), holding today's exact values, so a host themes the timeline by
overriding a token instead of the object staying opaque.

* fix(studio): route timeline clip colours through theme tokens

* chore(studio): bank the colour-literal baseline

* fix(studio): preserve timeline clip token defaults

* fix(studio): increase default timeline clip contrast

* fix(studio): keep timeline hover brighter

* test(studio): stabilize property panel flag coverage
2026-09-20 09:38:38 -04:00
Vance IngallsandClaude Opus 5 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>
2026-09-20 07:42:37 +00:00
Miguel Ángel 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
2026-09-19 23:59:13 -04:00
Vance IngallsandClaude Fable 5.1 83624d193e fix(studio): stop counting extension-leaked AbortError rejections as errors (#4209)
`studio:unhandled_promise_rejection` was dominated by two AbortError rows
("signal is aborted without reason", "The user aborted a request."), about
10,000 events across 265 users per week, every one with a stack rooted in
the thumbnail scheduler's `release()`.

The scheduler is correct: every promise on its abort signal is awaited, and
a Chrome 153 reproduction with the real bundle confirms that releasing a
lease mid-fetch produces no unhandled rejection on its own. The leak comes
from browser extensions that wrap `window.fetch` in the main world
(Requestly and Poper Blocker are two seen in field stacks) and derive a
promise from each request without a rejection handler. Chrome rejects that
derived promise with our abort reason, so the stack points at `release()`.
Injecting such a wrapper reproduces the field rows exactly. Among users
with both this leak and a "Failed to fetch" error, 69 of 72 have an
extension frame inside `window.fetch`; among the rest, 13 of 308.

An AbortError rejection is a cancellation we asked for, so record it as a
sampled `abort_rejection_filtered` counter instead of an error. The filter
sits before the ERROR_CAP check: a scroll burst previously spent the
50-event cap in under a second and dropped every real rejection for the
rest of the session (116 users hit the cap in the last 7 days).

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 19:51:00 -07:00
Miguel Ángel 2d84937e1f feat(studio): export the header logo, button sizes and a stable styles.css subpath (#4197)
A host mounting Studio's header reuses HyperframesLogo and buttonSizes for
matching chrome, and imports the compiled stylesheet as a fixed-name package
export instead of a hash that changes on every rebuild.
2026-09-19 19:06:01 -04:00
Miguel Ángel 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
2026-09-19 19:04:16 -04:00
Miguel Ángel 12d8c42440 feat(studio): the Renders panel on the shared primitives (#3772)
* feat(studio): the Renders panel on the shared primitives

The four native selects become the shared Select, so no OS popup is left in
the panel and the four controls read as the same control as everything
around them. Disabled options survive the move: SelectOption gains a
disabled flag, which is what keeps a resolution the composition cannot
reach out of the keyboard's path while its label still explains why.

Export drops its own type size. cn resolved that override by dropping the
size recipe's step, which is what left the panel Export a type step below
the header's; the computed-style table now reads 28px / 6px / 12px for
both. Its two per-row siblings become IconButton with a Tooltip carrying
what the title attribute used to say.

The panel's colours move to the semantic tokens.

* docs(studio): shorten two Renders-panel comments to fit the comment gate

* fix(studio): keep the renders-export test id the merge silently dropped

* fix(studio): type the Renders test mock against StartRenderHandler, not vitest 4's loose default

* fix(studio): type each mock declaration, not just the mountRenderQueue param
2026-09-19 19:01:36 -04:00
Vance IngallsandClaude Opus 5 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>
2026-09-19 14:33:54 -07:00
Miguel Ángel f9fc552188 feat(studio): move undo and redo into the timeline toolbar (#4196)
* feat(studio): move undo and redo into the timeline toolbar

Undo and Redo now live in the flat timeline toolbar next to Select,
Razor and the rest of the CapCut-style tools, instead of the header.
Same actions, shortcuts and disabled states, with a shortcut-aware
tooltip; disabled reads clearly dimmer than enabled at a glance.

The header's right side is now segmented: Capture and Inspector share
one rounded, bordered group with a hairline separator and one control
height and radius, Inspector fills when on, and Export stays the lone
separate primary action. Below about 1000px Capture drops to icon-only.

* test(studio): tighten the Inspector fill check and cover Undo/Redo hotkeys

An exact class token now proves the fill, since bg-hover also names a hover
variant that was always present. Undo and Redo at their new toolbar spot
get the same hotkey-classification proof the header used to carry for them.

* fix(studio): undo and redo lead the timeline toolbar, left of select

Miguel's call from the mockups: undo/redo read as the first tools reached
for, ahead of selection and razor, matching where his reference places them.
2026-09-19 17:14:18 -04:00
Miguel Ángel ef64323174 feat(studio): host apps can place overlays on the preview and restyle its stage (#4187)
* feat(studio): host apps can place overlays on the preview and restyle its stage

* feat(studio): stage background also recolours the preview panel; document the host layer
2026-09-19 16:32:31 -04:00
Miguel Ángel 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.
2026-09-19 16:08:15 -04:00