feat: add studio timeline editing (#390)

## Summary

Add the actual Studio timeline editing layer on top of the preview/runtime foundation.

This PR includes:

- drag-to-move clips across time and tracks
- left/right resize handles with media-aware trim persistence
- edge auto-scroll and edge track creation while dragging
- selector-based source patching for `data-start`, `data-duration`, `data-track-index`, `z-index`, and media trim attributes
- timeline UI cleanup, theming, hover/drag states, and the `Copy Prompt` action

## Why This PR Is Separate

This is the user-facing editing behavior. It depends on the preview/runtime fixes in the base PR, but it is much easier to review once that plumbing is isolated.

## Verification

- `bun run --filter @hyperframes/studio test`
- `bun run --filter @hyperframes/studio typecheck`
- `bunx oxlint packages/studio/src/App.tsx packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/player/components/EditModal.tsx packages/studio/src/player/components/Timeline.tsx 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/components/timelineTheme.ts packages/studio/src/player/components/timelineTheme.test.ts packages/studio/src/utils/sourcePatcher.ts packages/studio/src/utils/sourcePatcher.test.ts`
- `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/player/components/EditModal.tsx packages/studio/src/player/components/Timeline.tsx 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/components/timelineTheme.ts packages/studio/src/player/components/timelineTheme.test.ts packages/studio/src/utils/sourcePatcher.ts packages/studio/src/utils/sourcePatcher.test.ts`

## Browser Proof

- verified timeline drag / resize / trim flows in Studio with `agent-browser`
- verified preview hot-refresh behavior without iframe remount flashes

## Stack

- depends on #389
- followed by `fix: smooth scrubber end seeking`

[result.mp4 <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.com/user-attachments/thumbnails/ca71c177-5042-468d-906f-b353938f40f8.mp4" />](https://app.graphite.com/user-attachments/video/ca71c177-5042-468d-906f-b353938f40f8.mp4)
This commit is contained in:
Miguel Ángel
2026-04-22 01:48:14 +02:00
committed by GitHub
parent 158204343d
commit 0ba56f9187
15 changed files with 2236 additions and 250 deletions
@@ -132,6 +132,19 @@ describe("usePlayerStore", () => {
usePlayerStore.getState().updateElement("nonexistent", { start: 10 });
expect(usePlayerStore.getState().elements[0].start).toBe(0);
});
it("prefers the stable element key when duplicate ids exist", () => {
usePlayerStore.getState().setElements([
{ id: "headline", key: "a", tag: "div", start: 0, duration: 5, track: 0 },
{ id: "headline", key: "b", tag: "div", start: 5, duration: 5, track: 1 },
]);
usePlayerStore.getState().updateElement("b", { start: 9 });
const elements = usePlayerStore.getState().elements;
expect(elements[0].start).toBe(0);
expect(elements[1].start).toBe(9);
});
});
describe("setZoomMode", () => {
@@ -2,12 +2,16 @@ import { create } from "zustand";
export interface TimelineElement {
id: string;
key?: string;
tag: string;
start: number;
duration: number;
track: number;
domId?: string;
/** Best-effort selector used when patching source HTML back from timeline edits */
selector?: string;
/** Zero-based occurrence index for non-unique selectors */
selectorIndex?: number;
/** Source composition file that owns this element, when known */
sourceFile?: string;
src?: string;
@@ -86,7 +90,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
setSelectedElementId: (id) => set({ selectedElementId: id }),
updateElement: (elementId, updates) =>
set((state) => ({
elements: state.elements.map((el) => (el.id === elementId ? { ...el, ...updates } : el)),
elements: state.elements.map((el) =>
(el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
),
})),
// Resets project-specific state when switching compositions.
// playbackRate, zoomMode, and pixelsPerSecond are intentionally preserved