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
@@ -1,5 +1,69 @@
import { describe, expect, it } from "vitest";
import { mergeTimelineElementsPreservingDowngrades } from "./useTimelinePlayer";
import {
buildStandaloneRootTimelineElement,
mergeTimelineElementsPreservingDowngrades,
resolveStandaloneRootCompositionSrc,
} from "./useTimelinePlayer";
describe("buildStandaloneRootTimelineElement", () => {
it("includes selector and source metadata for standalone composition fallback clips", () => {
expect(
buildStandaloneRootTimelineElement({
compositionId: "hero",
tagName: "DIV",
rootDuration: 8,
iframeSrc: "http://127.0.0.1:4173/api/projects/demo/preview/comp/scenes/hero.html?_t=123",
selector: '[data-composition-id="hero"]',
}),
).toEqual({
id: "hero",
key: 'scenes/hero.html:[data-composition-id="hero"]:0',
tag: "div",
start: 0,
duration: 8,
track: 0,
compositionSrc: "scenes/hero.html",
selector: '[data-composition-id="hero"]',
selectorIndex: undefined,
sourceFile: "scenes/hero.html",
});
});
it("returns null for invalid fallback durations", () => {
expect(
buildStandaloneRootTimelineElement({
compositionId: "hero",
tagName: "div",
rootDuration: 0,
iframeSrc: "http://localhost/preview/comp/hero.html",
}),
).toBe(null);
expect(
buildStandaloneRootTimelineElement({
compositionId: "hero",
tagName: "div",
rootDuration: Number.NaN,
iframeSrc: "http://localhost/preview/comp/hero.html",
}),
).toBe(null);
});
});
describe("resolveStandaloneRootCompositionSrc", () => {
it("extracts the composition path from a preview iframe url", () => {
expect(
resolveStandaloneRootCompositionSrc(
"http://127.0.0.1:4173/api/projects/demo/preview/comp/scenes/hero.html?_t=123",
),
).toBe("scenes/hero.html");
});
it("returns undefined for non-composition preview urls", () => {
expect(
resolveStandaloneRootCompositionSrc("http://127.0.0.1:4173/api/projects/demo/preview"),
).toBe(undefined);
});
});
describe("mergeTimelineElementsPreservingDowngrades", () => {
it("preserves missing current elements when a shorter manifest arrives", () => {
@@ -137,14 +137,28 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
const trackStr = el.getAttribute("data-track-index");
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
const compId = el.getAttribute("data-composition-id");
const selector = getTimelineElementSelector(el);
const sourceFile = getTimelineElementSourceFile(el);
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
const id = el.id || compId || el.className?.split(" ")[0] || tagLower;
const entry: TimelineElement = {
id: el.id || compId || el.className?.split(" ")[0] || tagLower,
id,
key: buildTimelineElementKey({
id,
fallbackIndex: els.length,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
}),
tag: tagLower,
start,
duration: dur,
track: isNaN(track) ? 0 : track,
selector: getTimelineElementSelector(el),
sourceFile: getTimelineElementSourceFile(el),
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
};
const mediaEl = resolveMediaElement(el);
@@ -199,6 +213,38 @@ function getTimelineElementSourceFile(el: Element): string | undefined {
);
}
function getTimelineElementSelectorIndex(
doc: Document,
el: Element,
selector: string | undefined,
): number | undefined {
if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
return undefined;
}
try {
const matches = Array.from(doc.querySelectorAll(selector));
const matchIndex = matches.indexOf(el);
return matchIndex >= 0 ? matchIndex : undefined;
} catch {
return undefined;
}
}
function buildTimelineElementKey(params: {
id: string;
fallbackIndex: number;
domId?: string;
selector?: string;
selectorIndex?: number;
sourceFile?: string;
}): string {
const scope = params.sourceFile ?? "index.html";
if (params.domId) return `${scope}#${params.domId}`;
if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
return `${scope}:${params.id}:${params.fallbackIndex}`;
}
function findTimelineDomNode(doc: Document, id: string): Element | null {
return (
doc.getElementById(id) ??
@@ -208,6 +254,43 @@ function findTimelineDomNode(doc: Document, id: string): Element | null {
);
}
export function resolveStandaloneRootCompositionSrc(iframeSrc: string): string | undefined {
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
return compPathMatch ? decodeURIComponent(compPathMatch[1]) : undefined;
}
export function buildStandaloneRootTimelineElement(params: {
compositionId: string;
tagName: string;
rootDuration: number;
iframeSrc: string;
selector?: string;
selectorIndex?: number;
}): TimelineElement | null {
if (!Number.isFinite(params.rootDuration) || params.rootDuration <= 0) return null;
const compositionSrc = resolveStandaloneRootCompositionSrc(params.iframeSrc);
return {
id: params.compositionId,
key: buildTimelineElementKey({
id: params.compositionId,
fallbackIndex: 0,
selector: params.selector,
selectorIndex: params.selectorIndex,
sourceFile: compositionSrc,
}),
tag: params.tagName.toLowerCase() || "div",
start: 0,
duration: params.rootDuration,
track: 0,
compositionSrc,
selector: params.selector,
selectorIndex: params.selectorIndex,
sourceFile: compositionSrc,
};
}
function normalizePreviewViewport(doc: Document, win: Window): void {
if (doc.documentElement) {
doc.documentElement.style.overflow = "hidden";
@@ -486,10 +569,11 @@ export function useTimelinePlayer() {
const filtered = data.clips.filter(
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
);
const els: TimelineElement[] = filtered.map((clip) => {
const els: TimelineElement[] = filtered.map((clip, index) => {
let hostEl: Element | null = null;
const id = clip.id || clip.label || clip.tagName || "element";
const entry: TimelineElement = {
id: clip.id || clip.label || clip.tagName || "element",
id,
tag: clip.tagName || clip.kind,
start: clip.start,
duration: clip.duration,
@@ -504,7 +588,13 @@ export function useTimelinePlayer() {
/* cross-origin */
}
if (hostEl) {
const iframeDoc = iframeRef.current?.contentDocument;
entry.domId = hostEl.id || undefined;
entry.selector = getTimelineElementSelector(hostEl);
entry.selectorIndex =
iframeDoc && entry.selector
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
: undefined;
entry.sourceFile = getTimelineElementSourceFile(hostEl);
applyMediaMetadataFromElement(entry, hostEl);
}
@@ -539,10 +629,24 @@ export function useTimelinePlayer() {
}
}
if (hostEl) {
const iframeDoc = iframeRef.current?.contentDocument;
entry.domId = hostEl.id || undefined;
entry.selector = getTimelineElementSelector(hostEl);
entry.selectorIndex =
iframeDoc && entry.selector
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
: undefined;
entry.sourceFile = getTimelineElementSourceFile(hostEl);
}
}
entry.key = buildTimelineElementKey({
id,
fallbackIndex: index,
domId: entry.domId,
selector: entry.selector,
selectorIndex: entry.selectorIndex,
sourceFile: entry.sourceFile,
});
return entry;
});
const rawDuration = data.durationInFrames / 30;
@@ -654,14 +758,28 @@ export function useTimelinePlayer() {
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
const compSrc =
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
const selector = getTimelineElementSelector(el);
const sourceFile = getTimelineElementSourceFile(el);
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
const id = el.id || compId;
const entry: TimelineElement = {
id: el.id || compId,
id,
key: buildTimelineElementKey({
id,
fallbackIndex: missing.length,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
}),
tag: el.tagName.toLowerCase(),
start,
duration: dur,
track: isNaN(track) ? 0 : track,
selector: getTimelineElementSelector(el),
sourceFile: getTimelineElementSourceFile(el),
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
};
if (compSrc) {
entry.compositionSrc = compSrc;
@@ -771,26 +889,18 @@ export function useTimelinePlayer() {
const rootComp = doc.querySelector("[data-composition-id]");
const rootDuration = adapter.getDuration();
if (rootComp && rootDuration > 0) {
const rootId = rootComp.getAttribute("data-composition-id") || "composition";
// Derive compositionSrc from the iframe URL for thumbnail rendering.
// URL pattern: /api/projects/{id}/preview/comp/{path}
const iframeSrc = iframe?.src || "";
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
const compositionSrc = compPathMatch
? decodeURIComponent(compPathMatch[1])
: undefined;
// Always show the root composition as a single clip — guarantees
// the timeline is never empty when a valid composition is loaded.
syncTimelineElements([
{
id: rootId,
tag: (rootComp as HTMLElement).tagName?.toLowerCase() || "div",
start: 0,
duration: rootDuration,
track: 0,
compositionSrc,
},
]);
const fallbackElement = buildStandaloneRootTimelineElement({
compositionId: rootComp.getAttribute("data-composition-id") || "composition",
tagName: (rootComp as HTMLElement).tagName || "div",
rootDuration,
iframeSrc: iframe?.src || "",
selector: getTimelineElementSelector(rootComp),
});
if (fallbackElement) {
// Always show the root composition as a single clip — guarantees
// the timeline is never empty when a valid composition is loaded.
syncTimelineElements([fallbackElement]);
}
}
}
// The runtime will also postMessage the full timeline after all compositions load.