Skip to content

fix: harden studio timeline editing and local renders#463

Merged
miguel-heygen merged 5 commits intomainfrom
fix/studio-timeline-editing-and-local-render
Apr 23, 2026
Merged

fix: harden studio timeline editing and local renders#463
miguel-heygen merged 5 commits intomainfrom
fix/studio-timeline-editing-and-local-render

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented Apr 23, 2026

Problem

Studio timeline editing had a cluster of real regressions that showed up together while testing the new timeline flow:

  • audio clips were being blocked for the wrong reason (spans multiple scenes) even when Studio had a direct patch path for data-start / data-duration
  • Shift+click range editing was intended to work, but the real pointer flow could swallow the action and never open the edit popover
  • the timeline discoverability treatment regressed into an inline toolbar banner, and the floating version could also steal pointer events from clips underneath it
  • trimming a composition host in the timeline could persist the new authored duration in source, but preview visibility could still ignore that trimmed end after runtime sanitization
  • lower timeline rows could get clipped at the bottom of the scroll viewport
  • local Studio render could fail on first use in a dev worktree because the server assumed @hyperframes/producer/dist/index.js already existed
  • composition hover previews in the sidebar assumed a fixed 1920x1080 stage and could show dead space for narrower scenes

What this fixes

This PR makes the Studio timeline behave according to the actual product bar: only block edits when Studio does not have a safe authored patch path.

Timeline editing

  • removes the “audio spans multiple scenes” block for directly patchable media
  • allows plain audio/video front-trim from an implicit playback offset of 0, writing data-media-start on first trim
  • makes Shift+click reliably open the full-range edit popover through the real pointer-capture flow
  • keeps blocked-edit toasts only for genuinely unsupported clips

Timeline discoverability and interaction polish

  • restores the floating bottom-left timeline notice instead of the inline toolbar banner
  • makes the notice non-interactive except for the dismiss button so it cannot steal timeline drags/trims underneath it
  • adds bottom scroll buffer in the timeline canvas so lower rows can scroll fully into view instead of getting cut off

Runtime preview correctness

  • makes composition-host visibility respect preserved authored durations after timeline edits, so a scene trimmed shorter in the timeline actually disappears after its authored end in preview
  • fixes the local verification fixture so playback advances correctly and scene hosts register their own timelines

Local render resilience

  • makes Studio local render self-heal missing @hyperframes/producer build artifacts by building the producer package on demand in dev before importing it
  • adds explicit regression coverage for that fallback so this path does not silently break again

Sidebar composition previews

  • scales hover previews from the composition’s actual stage size instead of hardcoding 1920x1080, which removes the blank-space framing bug on scene cards

Root causes

There were three main failures behind the user-facing bugs:

1. Capability gating used the wrong proxy

The earlier logic treated “overlaps multiple scenes” as equivalent to “not safely editable.” That is not true for plain authored media clips that already have a direct patch target and deterministic timing attributes.

The fix removes that proxy and keeps capability gating tied to actual patchability and deterministic timing support.

2. Runtime visibility and interaction flows were inconsistent with authored edits

  • Shift+click lost its pending clip selection through the shared pointer-capture path
  • the discoverability notice could physically overlap clip edges and absorb pointer events
  • runtime visibility for composition hosts used stripped live host attrs and ignored preserved authored duration after timeline edits

That combination made the timeline feel unreliable even when the source patch had actually been written.

3. Studio dev render assumed prebuilt workspace artifacts

The Studio dev server imported the published @hyperframes/producer entrypoint directly. In a worktree without packages/producer/dist/, the first local render failed immediately.

The fix preserves the normal package import path but ensures the dist exists first in local dev.

Verification

Local checks

  • bunx oxlint packages/core/src/runtime/init.ts packages/studio/src/App.tsx packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/components/nle/TimelineEditorNotice.tsx packages/studio/src/components/sidebar/CompositionsTab.tsx packages/studio/src/components/sidebar/CompositionsTab.test.ts packages/studio/src/player/components/Timeline.tsx packages/studio/src/player/components/Timeline.test.ts packages/studio/src/player/components/TimelineClip.tsx packages/studio/src/player/components/timelineEditing.ts packages/studio/src/player/components/timelineEditing.test.ts packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/hooks/useTimelinePlayer.test.ts packages/studio/vite.config.ts packages/studio/vite.producer.ts packages/studio/vite.producer.test.ts
  • bun run --filter @hyperframes/studio typecheck
  • bun test packages/studio/vite.producer.test.ts packages/studio/src/components/sidebar/CompositionsTab.test.ts packages/studio/src/player/components/Timeline.test.ts packages/studio/src/player/components/timelineEditing.test.ts packages/studio/src/player/hooks/useTimelinePlayer.test.ts

Browser verification

Verified against a live local Studio fixture with two scene hosts plus background audio:

  • play/pause now advances the preview instead of sticking at 0:00
  • Shift+click opens the edit popover for full clip ranges
  • plain audio can move and front-trim without the old multi-scene block
  • the floating bottom-left notice no longer steals pointer events from the timeline
  • trimmed scene-host visibility now drops out after the authored end instead of continuing to show indefinitely
  • lower timeline rows scroll fully into view without being clipped at the bottom edge
  • composition hover previews now use the hovered scene’s actual stage size instead of a fixed 1920x1080

Local render verification

Verified local Studio render via POST /api/projects/timeline-trio-verify/render:

  • before the fix, render could fail with missing @hyperframes/producer/dist/index.js
  • after the fix, Studio built the producer package automatically and the render completed successfully
  • added packages/studio/vite.producer.test.ts as a smoke/regression test for the exact missing-dist fallback (dist present -> no build, dist missing -> build before import)

Notes

  • the timeline-trio-verify project used for local browser/render verification is local-only and not part of this PR
  • this PR is intentionally scoped to Studio timeline editing/runtime/render reliability and the sidebar hover-preview framing fix; it does not add timeline scene expansion or asset-drop authoring

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve — but do not merge until Tests on windows-latest is green

Clean scoping. The 6 fixes map cleanly to 6 distinct issues, and the test coverage (11 new Timeline assertions + 119 lines of timelineEditing tests + 37 lines of CompositionsTab tests + 61 lines of producer-autobuild tests) is proportional to the surface touched.

On the user feedback that triggered this: the Loom covers three asks. This PR addresses #1 (can't adjust scenes — capability gate was using "spans multiple scenes" as a wrong proxy) and explicitly defers #2 (scene expansion) and #3 (asset drop-onto-timeline) — the PR body is up-front about that, and the title ("harden studio timeline editing and local renders") matches the fix-the-broken-parts framing. Deferring the feature asks for a follow-up is the right call; conflating reliability fixes with new authoring UX would make this diff unreviewable.

Architecture spot-checks

Capability gating fix is exactly right. Dropping "spans multiple scenes" as a proxy was overdue — for directly patchable media (data-start / data-duration / seeded data-media-start), Studio IS the safe author of record regardless of scene spans. The block now only fires on clips that genuinely lack a patch path.

Floating TimelineEditorNotice (156 new lines) with constrained pointerEvents is the right shape for the "discoverability UI shouldn't steal pointer events from the timeline underneath it" class of bug. Without seeing the diff in detail I'm trusting that pointer-events: none on the card with pointer-events: auto on the dismiss button is the mechanism — which is the canonical pattern for this case.

Composition-host visibility respecting authored trimmed duration — the one-line change in packages/core/src/runtime/init.ts (includeAuthoredTimingAttrs: true) is the right seam. The runtime was sanitizing away the authored duration after live-host attrs got stripped; surfacing it back via this option unblocks preview from ignoring timeline trims.

On-demand @hyperframes/producer build in dev (vite.producer.ts / vite.producer.test.ts) is scoped to missing-dist-entry, gated behind existsSync, and uses execFileSync with arg arrays (not a shell). Won't leak into prod builds; won't run when dist exists.

🚨 Windows test failure — concrete diagnosis

Tests on windows-latest is red. Looked at the job and cross-referenced the diff — the cause is in packages/studio/vite.producer.test.ts:

expect(resolveProducerDistEntry("/repo/packages/studio")).toBe(
  "/repo/packages/producer/dist/index.js",
);

The production resolveProducerDistEntry uses node:path's resolve(), which on Windows produces backslash-separated paths (C:\repo\packages\producer\dist\index.js) — so the POSIX-style string assertion fails. Same trap on the workspace-root test and the execFileSync cwd: "/repo" expectation inside the "builds producer" test.

Two line-diffs to fix it:

// Option A — wrap the expected string with resolve() so both sides normalize identically:
expect(resolveProducerDistEntry("/repo/packages/studio")).toBe(
  resolve("/repo/packages/producer/dist/index.js"),
);

// Option B — switch the production code to path.posix.resolve if you want stable POSIX
// paths everywhere. Safer if anything downstream relies on the string shape.

Option A is the minimum-change fix for just the tests; option B is the minimum-surprise fix for any future consumer that inspects these strings. Either works. Render-on-windows-latest is green so the production code itself isn't Windows-broken — this is specifically a test-assertion issue.

Non-blocking notes

  • Jake/Leo deferred asks. Both scene-expansion and asset-drop should go on the backlog with tracking issues — if they stay as "future work" in a Slack Loom, they'll get rediscovered by the next user who hits the same moment.
  • The on-demand producer build uses stdio: "pipe". If the build fails, the error surface is opaque to the Studio dev server — a user hitting the first-render flow would see the request fail without seeing why bun run --filter @hyperframes/producer build couldn't complete. Logging the stderr when the build fails (or using "inherit" in dev) would cut a debugging step.

Stamp

Code merit → approve, conditional on the Windows test fix landing before merge. Don't flip the merge button until Tests on windows-latest is green. Once it is, the PR is ready to ship without another review pass from me.


Review by hyperframes

Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the existing feedback before posting this and skipped the Windows path-assertion issue plus the stdio: \"pipe\" observability note since those are already covered.

Two additional concerns I don't think are called out yet:

  1. packages/studio/src/player/components/timelineEditing.ts + packages/studio/src/player/components/Timeline.tsx

resolveBlockedTimelineEditIntent() prioritizes blocked resize zones before it checks canMove, and Timeline returns early on any blocked intent. That means clips that are intentionally movable but not start-trimmable can lose part or all of their draggable surface.

The branch's own test suite classifies composition hosts this way:

  • canMove: true
  • canTrimStart: false
  • canTrimEnd: true

So grabbing the left edge now resolves to \"resize-start\" and gets swallowed by the blocked-edit path instead of starting a move. For narrow clips (width <= handleWidth), the entire clip can fall into that blocked zone, leaving no draggable area at all.

Relevant spots:

  • packages/studio/src/player/components/timelineEditing.ts:252-259
  • packages/studio/src/player/components/Timeline.tsx:1198-1218
  • packages/studio/src/player/components/timelineEditing.test.ts:297-309
  1. packages/studio/vite.config.ts

getProducerModule() memoizes _producerModulePromise, but if the first ensureProducerDist() / dynamic import attempt fails, that rejected promise is cached forever. After that, every later local render attempt reuses the same rejection until the Studio dev server is restarted.

Relevant spot:

  • packages/studio/vite.config.ts:81-97

I reran the targeted @hyperframes/studio tests and typecheck locally with Bun; both passed, so these look like uncovered regressions rather than restating the already-known failing test issue.

@miguel-heygen
Copy link
Copy Markdown
Collaborator Author

@vanceingalls addressed both points in 9fc5b6e3.

  1. Movable clips keep a draggable surface even when a trim edge is unsupported.

    • resolveBlockedTimelineEditIntent() no longer lets blocked resize zones win over move when canMove is still true.
    • added regression coverage for both the composition-host case (canMove: true, canTrimStart: false) and the narrow-clip case where the old logic could swallow the whole surface.
  2. Studio render fallback now retries after an initial failure.

    • extracted the producer loader retry behavior into createRetryingModuleLoader().
    • if the first ensureProducerDist() / import attempt fails, the rejection is no longer cached forever; a later local render attempt can retry without restarting Studio.
    • added explicit regression coverage for fail once -> retry succeeds and success -> reuse cached promise.

Fresh verification after the fix:

  • bun run --filter @hyperframes/studio typecheck
  • bun test packages/studio/src/player/components/timelineEditing.test.ts packages/studio/vite.producer.test.ts

@miguel-heygen miguel-heygen merged commit 6610b8a into main Apr 23, 2026
32 of 36 checks passed
@miguel-heygen miguel-heygen deleted the fix/studio-timeline-editing-and-local-render branch April 23, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants