fix(studio): warn on anonymous timeline clips (#533)

## Problem

Studio timeline editing still had two rough edges that made the latest alpha feel less polished when testing it like a video editor would:

- Timeline clips for anonymous DOM nodes could surface internal fallback identities like `__node__index_*`, which made the timeline look broken instead of authored.
- Elements without a stable `id` could still appear in the timeline and canvas editor, but authors did not get direct lint guidance that those elements are weaker targets for Studio and agent edits.

## What this fixes

- Adds a non-blocking `studio_missing_editable_id` lint warning for timeline-visible elements that do not have an `id`.
- Makes the warning point to the exact element and recommend stable, human-readable ids such as `hero-title` or `scene-1-card`.
- Stops using synthetic node-index ids as runtime clip identity for anonymous DOM nodes.
- Gives anonymous clips readable labels from authored metadata, composition ids, DOM ids, class names, asset filenames, text content, or a simple ordinal fallback.
- Keeps those labels display-only in Studio and uses key-first identity for matching, dragging, resizing, and manifest merge preservation.
- Covers the duplicate-label case where two anonymous clips both render as `Card` but still stay separate timeline entries.

## Root cause

The runtime manifest used synthetic node-index ids as both identity and display fallback for timeline nodes that had no stable author-provided id. Studio then treated those internal values as user-facing clip names.

The first pass improved the display label, but it also risked using that friendly label as internal identity. Two anonymous clips with the same label could then collapse into the same logical timeline element. The fix separates display labels from internal identity and prefers the timeline key whenever Studio needs to match an element.

The linter also had correctness checks for render and runtime behavior, but it did not teach authors when a timeline-visible element would be harder for Studio and agents to patch reliably. That left missing ids as a silent authoring quality issue instead of actionable guidance.

## Verification

### Local checks

- `bun run --cwd packages/core test -- src/lint/rules/core.test.ts src/runtime/timeline.test.ts` -> 41 tests pass
- `bun run --cwd packages/studio test -- src/player/hooks/useTimelinePlayer.test.ts src/player/components/timelineTheme.test.ts` -> 23 tests pass
- `bun run --cwd packages/core typecheck`
- `bun run --cwd packages/studio typecheck`
- `bun run --cwd packages/studio build` -> passes with the existing Vite chunk-size warning
- `bunx oxlint $(git diff --name-only origin/main...HEAD)` -> 0 warnings, 0 errors
- `bunx oxfmt --check $(git diff --name-only origin/main...HEAD)`
- `git diff --check origin/main...HEAD`

### Browser verification

- Created a scratch project at `/tmp/hf-pr533-conflict-verify` with two timed anonymous `.card` clips that both label as `Card`.
- Started the local Studio dev server for `pr-533-conflict-verify`.
- Used `agent-browser` to verify the timeline renders two separate `Card` clips instead of collapsing duplicate anonymous labels.
- Used `agent-browser` to open the Studio lint modal and verify it shows human-readable missing-id warnings, not internal node-index labels.
- Used `agent-browser` to click Play after the lint pass and confirm the timeline remains usable.
- Recorded the tested Studio flow with `agent-browser`.

## Notes

- Rebased onto current `main`; conflict resolution preserved both the newer mainline Studio shortcut/lint behavior and this PR's anonymous-clip identity split.
- GitHub Actions are running on the rebased head.
- Scratch verification files are intentionally not committed.
- Local screenshots and recording from this rebase pass are under `.codex-artifacts/pr-533-conflict-rebase-2026-04-29/`.
This commit is contained in:
Miguel Ángel
2026-04-30 07:07:15 +02:00
committed by GitHub
parent 395fb9c084
commit 39b3997c78
13 changed files with 694 additions and 127 deletions
+9 -5
View File
@@ -61,6 +61,10 @@ interface AppToast {
tone: "error" | "info";
}
function getTimelineElementLabel(element: TimelineElement): string {
return element.label || element.id || element.tag;
}
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
image: 3,
video: 5,
@@ -392,7 +396,7 @@ export function StudioApp() {
return (
<CompositionThumbnail
previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
label={el.id || el.tag}
label={getTimelineElementLabel(el)}
labelColor={style.label}
accentColor={style.clip}
selector={el.selector}
@@ -408,7 +412,7 @@ export function StudioApp() {
return (
<CompositionThumbnail
previewUrl={activePreviewUrl}
label={el.id || el.tag}
label={getTimelineElementLabel(el)}
labelColor={style.label}
accentColor={style.clip}
selector={el.selector}
@@ -445,7 +449,7 @@ export function StudioApp() {
<AudioWaveform
audioUrl={audioUrl}
waveformUrl={waveformUrl}
label={el.id || el.tag}
label={getTimelineElementLabel(el)}
labelColor={style.label}
/>
);
@@ -458,7 +462,7 @@ export function StudioApp() {
return (
<VideoThumbnail
videoSrc={mediaSrc}
label={el.id || el.tag}
label={getTimelineElementLabel(el)}
labelColor={style.label}
duration={el.duration}
/>
@@ -469,7 +473,7 @@ export function StudioApp() {
return (
<CompositionThumbnail
previewUrl={`/api/projects/${pid}/preview`}
label={el.id || el.tag}
label={getTimelineElementLabel(el)}
labelColor={style.label}
accentColor={style.clip}
selector={el.selector}
@@ -1014,7 +1014,10 @@ export const Timeline = memo(function Timeline({
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => {
if (resizingClip?.element.id === element.id) {
if (
resizingClip &&
(resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
) {
return {
...element,
start: resizingClip.previewStart,
@@ -1242,7 +1245,7 @@ export const Timeline = memo(function Timeline({
draggedClip?.started === true && draggedElement
? getRenderedTimelineElement({
element: draggedElement,
draggedElementId: draggedElement.id,
draggedElementId: draggedElement.key ?? draggedElement.id,
previewStart: draggedClip.previewStart,
previewTrack: draggedClip.previewTrack,
})
@@ -61,6 +61,7 @@ export const TimelineClip = memo(function TimelineClip({
? theme.clipShadowHover
: theme.clipShadow;
const capabilities = getTimelineEditCapabilities(el);
const displayLabel = el.label || el.id || el.tag;
const showHandles = handleOpacity > 0.01;
return (
@@ -93,7 +94,7 @@ export const TimelineClip = memo(function TimelineClip({
title={
isComposition
? `${el.compositionSrc} \u2022 Double-click to open`
: `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
: `${displayLabel} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
}
onPointerEnter={onHoverStart}
onPointerLeave={onHoverEnd}
@@ -53,4 +53,23 @@ describe("getRenderedTimelineElement", () => {
}),
).toEqual({ ...element, start: 2.4, track: 3 });
});
it("uses key before id when matching the dragged clip", () => {
const element = {
id: "Card",
key: "index.html:.card:1",
tag: "div",
start: 1,
duration: 2,
track: 0,
};
expect(
getRenderedTimelineElement({
element,
draggedElementId: "index.html:.card:1",
previewStart: 2.4,
previewTrack: 3,
}),
).toEqual({ ...element, start: 2.4, track: 3 });
});
});
@@ -130,7 +130,11 @@ export function getRenderedTimelineElement({
previewStart: number | null;
previewTrack: number | null;
}): TimelineElement {
if (element.id !== draggedElementId || previewStart === null || previewTrack === null) {
if (
(element.key ?? element.id) !== draggedElementId ||
previewStart === null ||
previewTrack === null
) {
return element;
}
return {
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import {
buildStandaloneRootTimelineElement,
createTimelineElementFromManifestClip,
findTimelineDomNodeForClip,
getTimelineElementSelector,
parseTimelineFromDOM,
type ClipManifestClip,
mergeTimelineElementsPreservingDowngrades,
resolveStandaloneRootCompositionSrc,
shouldIgnorePlaybackShortcutEvent,
@@ -27,6 +33,29 @@ function mockKeyboardEvent(
};
}
function createDocument(markup: string): Document {
const window = new Window();
window.document.body.innerHTML = markup;
return window.document;
}
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
return {
id: null,
label: "Element",
start: 0,
duration: 4,
track: 0,
kind: "element",
tagName: "div",
compositionId: null,
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
...overrides,
};
}
describe("buildStandaloneRootTimelineElement", () => {
it("includes selector and source metadata for standalone composition fallback clips", () => {
expect(
@@ -39,6 +68,7 @@ describe("buildStandaloneRootTimelineElement", () => {
}),
).toEqual({
id: "hero",
label: "hero",
key: 'scenes/hero.html:[data-composition-id="hero"]:0',
tag: "div",
start: 0,
@@ -87,6 +117,115 @@ describe("resolveStandaloneRootCompositionSrc", () => {
});
});
describe("findTimelineDomNodeForClip", () => {
it("matches anonymous manifest clips back to repeated DOM nodes in timeline order", () => {
const doc = createDocument(`
<div data-composition-id="main" data-start="0" data-duration="8">
<section id="identity-card" class="clip identity-card" data-start="0" data-duration="4" data-track-index="0"></section>
<div class="clip duplicate-card first" data-start="0" data-duration="4" data-track-index="1"></div>
<div class="clip duplicate-card second" data-start="0" data-duration="4" data-track-index="2"></div>
</div>
`);
const used = new Set<Element>();
const first = findTimelineDomNodeForClip(
doc,
createClip({ id: "__node__index_2", track: 1 }),
1,
used,
) as HTMLElement;
used.add(first);
const second = findTimelineDomNodeForClip(
doc,
createClip({ id: "__node__index_3", track: 2 }),
2,
used,
) as HTMLElement;
expect(first.className).toBe("clip duplicate-card first");
expect(second.className).toBe("clip duplicate-card second");
expect(getTimelineElementSelector(first)).toBe(".duplicate-card");
expect(getTimelineElementSelector(second)).toBe(".duplicate-card");
});
});
describe("anonymous timeline identity", () => {
it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
const doc = createDocument(`
<div data-composition-id="main" data-start="0" data-duration="8">
<div class="clip card" data-label="Card" data-start="0" data-duration="3" data-track-index="0"></div>
<div class="clip card" data-label="Card" data-start="3" data-duration="3" data-track-index="1"></div>
</div>
`);
const elements = parseTimelineFromDOM(doc, 8);
expect(elements).toHaveLength(2);
expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
expect(new Set(elements.map((element) => element.id)).size).toBe(2);
expect(new Set(elements.map((element) => element.key)).size).toBe(2);
expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
});
it("keeps runtime-manifest anonymous clips distinct when labels match", () => {
const doc = createDocument(`
<div data-composition-id="main" data-start="0" data-duration="8">
<div class="clip card" data-start="0" data-duration="3" data-track-index="0"></div>
<div class="clip card" data-start="3" data-duration="3" data-track-index="1"></div>
</div>
`);
const clips = [
createClip({ id: null, label: "Card", start: 0, duration: 3, track: 0 }),
createClip({ id: null, label: "Card", start: 3, duration: 3, track: 1 }),
];
const used = new Set<Element>();
const elements = clips.map((clip, index) => {
const hostEl = findTimelineDomNodeForClip(doc, clip, index, used);
if (hostEl) used.add(hostEl);
return createTimelineElementFromManifestClip({
clip,
fallbackIndex: index,
doc,
hostEl,
});
});
expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
expect(new Set(elements.map((element) => element.id)).size).toBe(2);
expect(new Set(elements.map((element) => element.key)).size).toBe(2);
expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
});
it("reads media metadata from owner-window media elements", () => {
const doc = createDocument(`
<div data-composition-id="main" data-start="0" data-duration="8">
<div class="clip video-card" data-start="0" data-duration="3" data-track-index="0">
<video src="/clip.mp4" data-source-duration="12"></video>
</div>
</div>
`);
const hostEl = doc.querySelector(".video-card");
const video = hostEl?.querySelector("video");
if (!hostEl || !video) throw new Error("missing video test fixture");
Object.defineProperty(video, "defaultPlaybackRate", {
value: 1.5,
configurable: true,
});
const element = createTimelineElementFromManifestClip({
clip: createClip({ kind: "video", tagName: "div" }),
fallbackIndex: 0,
doc,
hostEl,
});
expect(element.tag).toBe("video");
expect(element.src).toBe("/clip.mp4");
expect(element.sourceDuration).toBe(12);
expect(element.playbackRate).toBe(1.5);
});
});
describe("mergeTimelineElementsPreservingDowngrades", () => {
it("preserves missing current elements when a shorter manifest arrives", () => {
expect(
@@ -115,6 +254,65 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
),
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
});
it("preserves distinct anonymous clips that share the same friendly id label", () => {
expect(
mergeTimelineElementsPreservingDowngrades(
[
{
id: "Card",
key: "index.html:.card:0",
label: "Card",
tag: "div",
start: 0,
duration: 3,
track: 0,
},
{
id: "Card",
key: "index.html:.card:1",
label: "Card",
tag: "div",
start: 3,
duration: 3,
track: 1,
},
],
[
{
id: "Card",
key: "index.html:.card:0",
label: "Card",
tag: "div",
start: 0,
duration: 3,
track: 0,
},
],
8,
8,
),
).toEqual([
{
id: "Card",
key: "index.html:.card:0",
label: "Card",
tag: "div",
start: 0,
duration: 3,
track: 0,
},
{
id: "Card",
key: "index.html:.card:1",
label: "Card",
tag: "div",
start: 3,
duration: 3,
track: 1,
},
]);
});
});
describe("shouldIgnorePlaybackShortcutTarget", () => {
@@ -64,9 +64,12 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
}
function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
if (el instanceof HTMLMediaElement || el instanceof HTMLImageElement) return el;
const win = el.ownerDocument.defaultView ?? window;
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
const candidate = el.querySelector("video, audio, img");
return candidate instanceof HTMLMediaElement || candidate instanceof HTMLImageElement
return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
? candidate
: null;
}
@@ -92,7 +95,9 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
const src = mediaEl.getAttribute("src");
if (src) entry.src = src;
if (!(mediaEl instanceof HTMLMediaElement)) return;
const win = mediaEl.ownerDocument.defaultView ?? window;
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
const sourceDurationAttr =
el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
@@ -165,11 +170,24 @@ export function shouldIgnorePlaybackShortcutEvent(
);
}
function getTimelineElementDisplayLabel(input: {
id?: string | null;
label?: string | null;
tag?: string | null;
}): string {
const label = input.label?.trim();
if (label) return label;
const id = input.id?.trim();
if (id) return id;
const tag = input.tag?.trim().toLowerCase();
return tag ? `${tag} clip` : "Timeline clip";
}
/**
* Parse [data-start] elements from a Document into TimelineElement[].
* Shared helper used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
*/
function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
const rootComp = doc.querySelector("[data-composition-id]");
const nodes = doc.querySelectorAll("[data-start]");
const els: TimelineElement[] = [];
@@ -200,17 +218,24 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
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 label = getTimelineElementDisplayLabel({
id: el.id || compId || null,
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
tag: tagLower,
});
const identity = buildTimelineElementIdentity({
preferredId: el.id || compId || null,
label,
fallbackIndex: els.length,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
});
const entry: TimelineElement = {
id,
key: buildTimelineElementKey({
id,
fallbackIndex: els.length,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
}),
id: identity.id,
label,
key: identity.key,
tag: tagLower,
start,
duration: dur,
@@ -253,12 +278,18 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
return els;
}
function getTimelineElementSelector(el: Element): string | undefined {
if (el instanceof HTMLElement && el.id) return `#${el.id}`;
function isHtmlElement(el: Element): el is HTMLElement {
const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
}
export function getTimelineElementSelector(el: Element): string | undefined {
if (isHtmlElement(el) && el.id) return `#${el.id}`;
const compId = el.getAttribute("data-composition-id");
if (compId) return `[data-composition-id="${compId}"]`;
if (el instanceof HTMLElement) {
const firstClass = el.className.split(/\s+/).find(Boolean);
if (isHtmlElement(el)) {
const classes = el.className.split(/\s+/).filter(Boolean);
const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
if (firstClass) return `.${firstClass}`;
}
return undefined;
@@ -305,6 +336,178 @@ function buildTimelineElementKey(params: {
return `${scope}:${params.id}:${params.fallbackIndex}`;
}
function buildTimelineElementIdentity(params: {
preferredId?: string | null;
label: string;
fallbackIndex: number;
domId?: string;
selector?: string;
selectorIndex?: number;
sourceFile?: string;
}): { id: string; key: string } {
const id =
params.preferredId?.trim() ||
buildTimelineElementKey({
id: params.label,
fallbackIndex: params.fallbackIndex,
domId: params.domId,
selector: params.selector,
selectorIndex: params.selectorIndex,
sourceFile: params.sourceFile,
});
const key = buildTimelineElementKey({
id,
fallbackIndex: params.fallbackIndex,
domId: params.domId,
selector: params.selector,
selectorIndex: params.selectorIndex,
sourceFile: params.sourceFile,
});
return { id, key };
}
function getTimelineElementIdentity(element: TimelineElement): string {
return element.key ?? element.id;
}
function getTimelineDomNodes(doc: Document): Element[] {
const rootComp = doc.querySelector("[data-composition-id]");
return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
}
function numbersNearlyEqual(a: number, b: number): boolean {
return Math.abs(a - b) < 0.001;
}
function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
const tagName = clip.tagName?.toLowerCase();
if (tagName && node.tagName.toLowerCase() !== tagName) return false;
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
if (Number.isFinite(track) && track !== clip.track) return false;
return true;
}
export function findTimelineDomNodeForClip(
doc: Document,
clip: ClipManifestClip,
fallbackIndex: number,
usedNodes = new Set<Element>(),
): Element | null {
const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
if (exact) return exact;
return candidates[fallbackIndex] ?? null;
}
export function createTimelineElementFromManifestClip(params: {
clip: ClipManifestClip;
fallbackIndex: number;
doc?: Document | null;
hostEl?: Element | null;
}): TimelineElement {
const { clip, fallbackIndex, doc } = params;
let hostEl = params.hostEl ?? null;
const label = getTimelineElementDisplayLabel({
id: clip.id,
label: clip.label,
tag: clip.tagName || clip.kind,
});
let domId: string | undefined;
let selector: string | undefined;
let selectorIndex: number | undefined;
let sourceFile: string | undefined;
if (hostEl) {
domId = hostEl.id || undefined;
selector = getTimelineElementSelector(hostEl);
selectorIndex =
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
sourceFile = getTimelineElementSourceFile(hostEl);
}
const identity = buildTimelineElementIdentity({
preferredId: clip.id,
label,
fallbackIndex,
domId,
selector,
selectorIndex,
sourceFile,
});
const entry: TimelineElement = {
id: identity.id,
label,
key: identity.key,
tag: clip.tagName || clip.kind,
start: clip.start,
duration: clip.duration,
track: clip.track,
domId,
selector,
selectorIndex,
sourceFile,
};
if (hostEl) {
applyMediaMetadataFromElement(entry, hostEl);
}
if (clip.assetUrl) entry.src = clip.assetUrl;
if (clip.kind === "composition" && clip.compositionId) {
let resolvedSrc = clip.compositionSrc;
if (!resolvedSrc) {
hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
resolvedSrc =
hostEl?.getAttribute("data-composition-src") ??
hostEl?.getAttribute("data-composition-file") ??
null;
}
if (resolvedSrc) {
entry.compositionSrc = resolvedSrc;
} else if (hostEl) {
const innerVideo = hostEl.querySelector("video[src]");
if (innerVideo) {
entry.src = innerVideo.getAttribute("src") || undefined;
entry.tag = "video";
}
}
if (hostEl) {
entry.domId = hostEl.id || undefined;
entry.selector = getTimelineElementSelector(hostEl);
entry.selectorIndex =
doc && entry.selector
? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
: undefined;
entry.sourceFile = getTimelineElementSourceFile(hostEl);
const nextIdentity = buildTimelineElementIdentity({
preferredId: clip.id,
label,
fallbackIndex,
domId: entry.domId,
selector: entry.selector,
selectorIndex: entry.selectorIndex,
sourceFile: entry.sourceFile,
});
entry.id = nextIdentity.id;
entry.key = nextIdentity.key;
}
}
return entry;
}
function findTimelineDomNode(doc: Document, id: string): Element | null {
return (
doc.getElementById(id) ??
@@ -333,6 +536,10 @@ export function buildStandaloneRootTimelineElement(params: {
return {
id: params.compositionId,
label: getTimelineElementDisplayLabel({
id: params.compositionId,
tag: params.tagName,
}),
key: buildTimelineElementKey({
id: params.compositionId,
fallbackIndex: 0,
@@ -454,8 +661,10 @@ export function mergeTimelineElementsPreservingDowngrades(
return nextElements;
}
const nextIds = new Set(nextElements.map((element) => element.id));
const preserved = currentElements.filter((element) => !nextIds.has(element.id));
const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
const preserved = currentElements.filter(
(element) => !nextIdentities.has(getTimelineElementIdentity(element)),
);
if (preserved.length === 0) return nextElements;
return [...nextElements, ...preserved];
}
@@ -822,85 +1031,24 @@ export function useTimelinePlayer() {
const filtered = data.clips.filter(
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
);
let iframeDoc: Document | null = null;
try {
iframeDoc = iframeRef.current?.contentDocument ?? null;
} catch {
iframeDoc = null;
}
const usedHostEls = new Set<Element>();
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,
tag: clip.tagName || clip.kind,
start: clip.start,
duration: clip.duration,
track: clip.track,
};
try {
const iframeDoc = iframeRef.current?.contentDocument;
if (iframeDoc && entry.id) {
hostEl = findTimelineDomNode(iframeDoc, entry.id);
}
} catch {
/* 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);
}
if (clip.assetUrl) entry.src = clip.assetUrl;
if (clip.kind === "composition" && clip.compositionId) {
// The bundler renames data-composition-src to data-composition-file
// after inlining, so the clip manifest may not have compositionSrc.
// Fall back to reading data-composition-file from the DOM.
let resolvedSrc = clip.compositionSrc;
let hostEl: Element | null = null;
if (!resolvedSrc) {
try {
const iframeDoc = iframeRef.current?.contentDocument;
hostEl =
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
resolvedSrc =
hostEl?.getAttribute("data-composition-src") ??
hostEl?.getAttribute("data-composition-file") ??
null;
} catch {
/* cross-origin */
}
}
if (resolvedSrc) {
entry.compositionSrc = resolvedSrc;
} else if (hostEl) {
// Inline composition (no external file) — expose inner video for thumbnails
const innerVideo = hostEl.querySelector("video[src]");
if (innerVideo) {
entry.src = innerVideo.getAttribute("src") || undefined;
entry.tag = "video";
}
}
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,
const hostEl = iframeDoc
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
: null;
if (hostEl) usedHostEls.add(hostEl);
return createTimelineElementFromManifestClip({
clip,
fallbackIndex: index,
domId: entry.domId,
selector: entry.selector,
selectorIndex: entry.selectorIndex,
sourceFile: entry.sourceFile,
doc: iframeDoc,
hostEl,
});
return entry;
});
const rawDuration = data.durationInFrames / 30;
// Clamp non-finite or absurdly large durations — the runtime can emit
@@ -1014,17 +1162,24 @@ export function useTimelinePlayer() {
const selector = getTimelineElementSelector(el);
const sourceFile = getTimelineElementSourceFile(el);
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
const id = el.id || compId;
const label = getTimelineElementDisplayLabel({
id: el.id || compId || null,
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
tag: el.tagName,
});
const identity = buildTimelineElementIdentity({
preferredId: el.id || compId || null,
label,
fallbackIndex: missing.length,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
});
const entry: TimelineElement = {
id,
key: buildTimelineElementKey({
id,
fallbackIndex: missing.length,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
}),
id: identity.id,
label,
key: identity.key,
tag: el.tagName.toLowerCase(),
start,
duration: dur,
@@ -2,6 +2,7 @@ import { create } from "zustand";
export interface TimelineElement {
id: string;
label?: string;
key?: string;
tag: string;
start: number;