mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user