@hyperframes/studio 0.5.0-alpha.10 → 0.5.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ buildEditHistoryEntry,
4
+ createEmptyEditHistory,
5
+ hashEditHistoryContent,
6
+ pushEditHistoryEntry,
7
+ redoEditHistory,
8
+ undoEditHistory,
9
+ type BuildEditHistoryEntryInput,
10
+ type EditHistoryKind,
11
+ type EditHistoryState,
12
+ } from "../utils/editHistory";
13
+ import {
14
+ createIndexedDbEditHistoryStorage,
15
+ loadEditHistoryState,
16
+ saveEditHistoryState,
17
+ type EditHistoryStorageAdapter,
18
+ } from "../utils/editHistoryStorage";
19
+
20
+ interface RecordEditInput {
21
+ label: string;
22
+ kind: EditHistoryKind;
23
+ coalesceKey?: string;
24
+ files: BuildEditHistoryEntryInput["files"];
25
+ }
26
+
27
+ interface ApplyCallbacks {
28
+ readFile: (path: string) => Promise<string>;
29
+ writeFile: (path: string, content: string) => Promise<void>;
30
+ }
31
+
32
+ interface UsePersistentEditHistoryOptions {
33
+ projectId: string | null;
34
+ storage?: EditHistoryStorageAdapter;
35
+ now?: () => number;
36
+ }
37
+
38
+ interface ApplyResult {
39
+ ok: boolean;
40
+ reason?: "empty" | "content-mismatch";
41
+ label?: string;
42
+ }
43
+
44
+ interface PersistentEditHistoryStoreOptions {
45
+ projectId: string;
46
+ storage: EditHistoryStorageAdapter;
47
+ initialState: EditHistoryState;
48
+ now?: () => number;
49
+ onChange: (state: EditHistoryState) => void;
50
+ }
51
+
52
+ type EditHistoryMutation<T> = (state: EditHistoryState) => Promise<{
53
+ state: EditHistoryState;
54
+ result: T;
55
+ }>;
56
+
57
+ function createEntryId(now: number): string {
58
+ return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
59
+ }
60
+
61
+ function snapshotEditHistoryState(state: EditHistoryState) {
62
+ const undoEntry = state.undo[state.undo.length - 1] ?? null;
63
+ const redoEntry = state.redo[state.redo.length - 1] ?? null;
64
+ return {
65
+ canUndo: Boolean(undoEntry),
66
+ canRedo: Boolean(redoEntry),
67
+ undoLabel: undoEntry?.label ?? null,
68
+ redoLabel: redoEntry?.label ?? null,
69
+ undoPaths: undoEntry ? Object.keys(undoEntry.files) : [],
70
+ redoPaths: redoEntry ? Object.keys(redoEntry.files) : [],
71
+ state,
72
+ };
73
+ }
74
+
75
+ async function readCurrentFileHashes(
76
+ paths: string[],
77
+ readFile: (path: string) => Promise<string>,
78
+ ): Promise<{
79
+ currentFiles: Record<string, string>;
80
+ currentHashes: Record<string, string>;
81
+ }> {
82
+ const currentFiles: Record<string, string> = {};
83
+ const currentHashes: Record<string, string> = {};
84
+ for (const path of paths) {
85
+ const content = await readFile(path);
86
+ currentFiles[path] = content;
87
+ currentHashes[path] = hashEditHistoryContent(content);
88
+ }
89
+ return { currentFiles, currentHashes };
90
+ }
91
+
92
+ async function writeFilesWithRollback({
93
+ files,
94
+ rollbackFiles,
95
+ writeFile,
96
+ }: {
97
+ files: Record<string, string>;
98
+ rollbackFiles: Record<string, string>;
99
+ writeFile: (path: string, content: string) => Promise<void>;
100
+ }): Promise<void> {
101
+ const writtenPaths: string[] = [];
102
+ try {
103
+ for (const [path, content] of Object.entries(files)) {
104
+ await writeFile(path, content);
105
+ writtenPaths.push(path);
106
+ }
107
+ } catch (error) {
108
+ try {
109
+ for (const path of writtenPaths.reverse()) {
110
+ await writeFile(path, rollbackFiles[path]);
111
+ }
112
+ } catch (rollbackError) {
113
+ throw new AggregateError(
114
+ [error, rollbackError],
115
+ "Failed to apply edit history and rollback did not complete",
116
+ );
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ export function createPersistentEditHistoryStore({
123
+ projectId,
124
+ storage,
125
+ initialState,
126
+ now = Date.now,
127
+ onChange,
128
+ }: PersistentEditHistoryStoreOptions) {
129
+ let state = initialState;
130
+ let queue = Promise.resolve();
131
+
132
+ const save = async (nextState: EditHistoryState) => {
133
+ state = nextState;
134
+ onChange(nextState);
135
+ try {
136
+ await saveEditHistoryState(storage, projectId, nextState);
137
+ } catch {
138
+ // Keep in-memory history usable when IndexedDB is unavailable.
139
+ }
140
+ };
141
+
142
+ const mutate = async <T>(mutation: EditHistoryMutation<T>): Promise<T> => {
143
+ const run = queue.then(async () => {
144
+ const { state: nextState, result } = await mutation(state);
145
+ if (nextState !== state) await save(nextState);
146
+ return result;
147
+ });
148
+ queue = run.then(
149
+ () => undefined,
150
+ () => undefined,
151
+ );
152
+ return run;
153
+ };
154
+
155
+ return {
156
+ snapshot: () => snapshotEditHistoryState(state),
157
+ async recordEdit(input: RecordEditInput) {
158
+ await mutate<void>(async (currentState) => {
159
+ const timestamp = now();
160
+ const entry = buildEditHistoryEntry({
161
+ ...input,
162
+ id: createEntryId(timestamp),
163
+ projectId,
164
+ now: timestamp,
165
+ });
166
+ return {
167
+ state: pushEditHistoryEntry(currentState, entry),
168
+ result: undefined,
169
+ };
170
+ });
171
+ },
172
+ async undo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
173
+ return mutate<ApplyResult>(async (currentState) => {
174
+ const entry = currentState.undo[currentState.undo.length - 1];
175
+ if (!entry) {
176
+ return {
177
+ state: currentState,
178
+ result: { ok: false, reason: "empty" },
179
+ };
180
+ }
181
+ const { currentFiles, currentHashes } = await readCurrentFileHashes(
182
+ Object.keys(entry.files),
183
+ callbacks.readFile,
184
+ );
185
+ const result = undoEditHistory(currentState, currentHashes, now());
186
+ if (!result.ok) {
187
+ return {
188
+ state: currentState,
189
+ result: { ok: false, reason: result.reason },
190
+ };
191
+ }
192
+ await writeFilesWithRollback({
193
+ files: result.filesToWrite,
194
+ rollbackFiles: currentFiles,
195
+ writeFile: callbacks.writeFile,
196
+ });
197
+ return {
198
+ state: result.state,
199
+ result: { ok: true, label: result.entry.label },
200
+ };
201
+ });
202
+ },
203
+ async redo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
204
+ return mutate<ApplyResult>(async (currentState) => {
205
+ const entry = currentState.redo[currentState.redo.length - 1];
206
+ if (!entry) {
207
+ return {
208
+ state: currentState,
209
+ result: { ok: false, reason: "empty" },
210
+ };
211
+ }
212
+ const { currentFiles, currentHashes } = await readCurrentFileHashes(
213
+ Object.keys(entry.files),
214
+ callbacks.readFile,
215
+ );
216
+ const result = redoEditHistory(currentState, currentHashes, now());
217
+ if (!result.ok) {
218
+ return {
219
+ state: currentState,
220
+ result: { ok: false, reason: result.reason },
221
+ };
222
+ }
223
+ await writeFilesWithRollback({
224
+ files: result.filesToWrite,
225
+ rollbackFiles: currentFiles,
226
+ writeFile: callbacks.writeFile,
227
+ });
228
+ return {
229
+ state: result.state,
230
+ result: { ok: true, label: result.entry.label },
231
+ };
232
+ });
233
+ },
234
+ };
235
+ }
236
+
237
+ export async function createPersistentEditHistoryController({
238
+ projectId,
239
+ storage,
240
+ now = Date.now,
241
+ onChange,
242
+ }: {
243
+ projectId: string;
244
+ storage: EditHistoryStorageAdapter;
245
+ now?: () => number;
246
+ onChange: (state: EditHistoryState) => void;
247
+ }) {
248
+ let state = await loadEditHistoryState(storage, projectId);
249
+ const store = createPersistentEditHistoryStore({
250
+ projectId,
251
+ storage,
252
+ initialState: state,
253
+ now,
254
+ onChange: (nextState) => {
255
+ state = nextState;
256
+ onChange(nextState);
257
+ },
258
+ });
259
+
260
+ return store;
261
+ }
262
+
263
+ export function usePersistentEditHistory(options: UsePersistentEditHistoryOptions) {
264
+ const storage = useMemo(
265
+ () => options.storage ?? createIndexedDbEditHistoryStorage(),
266
+ [options.storage],
267
+ );
268
+ const now = options.now ?? Date.now;
269
+ const [state, setState] = useState<EditHistoryState>(() => createEmptyEditHistory());
270
+ const [loaded, setLoaded] = useState(false);
271
+ const projectId = options.projectId;
272
+ const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
273
+
274
+ useEffect(() => {
275
+ let cancelled = false;
276
+ const emptyState = createEmptyEditHistory();
277
+ storeRef.current = null;
278
+ setState(emptyState);
279
+ setLoaded(false);
280
+ if (!projectId) {
281
+ setLoaded(true);
282
+ return;
283
+ }
284
+
285
+ loadEditHistoryState(storage, projectId)
286
+ .then((loadedState) => {
287
+ if (cancelled) return;
288
+ storeRef.current = createPersistentEditHistoryStore({
289
+ projectId,
290
+ storage,
291
+ initialState: loadedState,
292
+ now,
293
+ onChange: setState,
294
+ });
295
+ setState(loadedState);
296
+ })
297
+ .catch(() => {
298
+ if (cancelled) return;
299
+ storeRef.current = createPersistentEditHistoryStore({
300
+ projectId,
301
+ storage,
302
+ initialState: emptyState,
303
+ now,
304
+ onChange: setState,
305
+ });
306
+ setState(emptyState);
307
+ })
308
+ .finally(() => {
309
+ if (!cancelled) setLoaded(true);
310
+ });
311
+
312
+ return () => {
313
+ cancelled = true;
314
+ };
315
+ }, [now, projectId, storage]);
316
+
317
+ const recordEdit = useCallback(async (input: RecordEditInput) => {
318
+ await storeRef.current?.recordEdit(input);
319
+ }, []);
320
+
321
+ const undo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
322
+ return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
323
+ }, []);
324
+
325
+ const redo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
326
+ return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
327
+ }, []);
328
+
329
+ return {
330
+ loaded,
331
+ ...snapshotEditHistoryState(state),
332
+ recordEdit,
333
+ undo,
334
+ redo,
335
+ };
336
+ }
@@ -53,6 +53,8 @@ import {
53
53
  CaretRight,
54
54
  ClipboardText,
55
55
  ArrowCounterClockwise,
56
+ Camera as PhCamera,
57
+ ArrowClockwise,
56
58
  Gear,
57
59
  } from "@phosphor-icons/react";
58
60
  import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
@@ -127,4 +129,6 @@ export const ChevronDown = makeIcon(CaretDown);
127
129
  export const ChevronRight = makeIcon(CaretRight);
128
130
  export const ClipboardList = makeIcon(ClipboardText);
129
131
  export const RotateCcw = makeIcon(ArrowCounterClockwise);
132
+ export const Camera = makeIcon(PhCamera);
133
+ export const RotateCw = makeIcon(ArrowClockwise);
130
134
  export const Settings = makeIcon(Gear);
@@ -1,10 +1,6 @@
1
1
  import { useRef, useState, useCallback, useEffect, memo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
- import {
4
- TIMELINE_TOGGLE_SHORTCUT_LABEL,
5
- getTimelineToggleTitle,
6
- } from "../../utils/timelineDiscovery";
7
- import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
3
+ import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
8
4
  import { usePlayerStore, liveTime } from "../store/playerStore";
9
5
 
10
6
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
@@ -30,15 +26,11 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
30
26
  interface PlayerControlsProps {
31
27
  onTogglePlay: () => void;
32
28
  onSeek: (time: number) => void;
33
- timelineVisible?: boolean;
34
- onToggleTimeline?: () => void;
35
29
  }
36
30
 
37
31
  export const PlayerControls = memo(function PlayerControls({
38
32
  onTogglePlay,
39
33
  onSeek,
40
- timelineVisible,
41
- onToggleTimeline,
42
34
  }: PlayerControlsProps) {
43
35
  // Subscribe to only the fields we render — each selector prevents cascading re-renders
44
36
  const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -216,10 +208,10 @@ export const PlayerControls = memo(function PlayerControls({
216
208
  const step = e.shiftKey ? 10 : 1;
217
209
  if (e.key === "ArrowLeft") {
218
210
  e.preventDefault();
219
- onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
211
+ onSeek(stepFrameTime(currentTimeRef.current, -step));
220
212
  } else if (e.key === "ArrowRight") {
221
213
  e.preventDefault();
222
- onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
214
+ onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
223
215
  }
224
216
  },
225
217
  [timelineReady, duration, onSeek],
@@ -437,39 +429,6 @@ export const PlayerControls = memo(function PlayerControls({
437
429
  </span>
438
430
  ))}
439
431
  </div>
440
-
441
- {/* Timeline toggle */}
442
- {onToggleTimeline !== undefined && (
443
- <button
444
- type="button"
445
- onClick={onToggleTimeline}
446
- className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
447
- timelineVisible
448
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
449
- : "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
450
- }`}
451
- title={getTimelineToggleTitle(Boolean(timelineVisible))}
452
- aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
453
- >
454
- <svg
455
- width="13"
456
- height="13"
457
- viewBox="0 0 24 24"
458
- fill="none"
459
- stroke="currentColor"
460
- strokeWidth="2"
461
- strokeLinecap="round"
462
- >
463
- <rect x="3" y="13" width="18" height="8" rx="1" />
464
- <line x1="3" y1="9" x2="21" y2="9" />
465
- <line x1="3" y1="5" x2="21" y2="5" />
466
- </svg>
467
- <span>Timeline</span>
468
- <span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
469
- {TIMELINE_TOGGLE_SHORTCUT_LABEL}
470
- </span>
471
- </button>
472
- )}
473
432
  </div>
474
433
  );
475
434
  });
@@ -1046,7 +1046,10 @@ export const Timeline = memo(function Timeline({
1046
1046
 
1047
1047
  const getPreviewElement = useCallback(
1048
1048
  (element: TimelineElement): TimelineElement => {
1049
- if (resizingClip?.element.id === element.id) {
1049
+ if (
1050
+ resizingClip &&
1051
+ (resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
1052
+ ) {
1050
1053
  return {
1051
1054
  ...element,
1052
1055
  start: resizingClip.previewStart,
@@ -1273,7 +1276,7 @@ export const Timeline = memo(function Timeline({
1273
1276
  draggedClip?.started === true && draggedElement
1274
1277
  ? getRenderedTimelineElement({
1275
1278
  element: draggedElement,
1276
- draggedElementId: draggedElement.id,
1279
+ draggedElementId: draggedElement.key ?? draggedElement.id,
1277
1280
  previewStart: draggedClip.previewStart,
1278
1281
  previewTrack: draggedClip.previewTrack,
1279
1282
  })
@@ -61,6 +61,7 @@ export const TimelineClip = memo(function TimelineClip({
61
61
  ? theme.clipShadowHover
62
62
  : theme.clipShadow;
63
63
  const capabilities = getTimelineEditCapabilities(el);
64
+ const displayLabel = el.label || el.id || el.tag;
64
65
  const showHandles = handleOpacity > 0.01;
65
66
  const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
66
67
  const glossBackgroundImage = isSelected
@@ -106,7 +107,7 @@ export const TimelineClip = memo(function TimelineClip({
106
107
  title={
107
108
  isComposition
108
109
  ? `${el.compositionSrc} \u2022 Double-click to open`
109
- : `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
110
+ : `${displayLabel} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
110
111
  }
111
112
  onPointerEnter={onHoverStart}
112
113
  onPointerLeave={onHoverEnd}
@@ -53,4 +53,23 @@ describe("getRenderedTimelineElement", () => {
53
53
  }),
54
54
  ).toEqual({ ...element, start: 2.4, track: 3 });
55
55
  });
56
+
57
+ it("uses key before id when matching the dragged clip", () => {
58
+ const element = {
59
+ id: "Card",
60
+ key: "index.html:.card:1",
61
+ tag: "div",
62
+ start: 1,
63
+ duration: 2,
64
+ track: 0,
65
+ };
66
+ expect(
67
+ getRenderedTimelineElement({
68
+ element,
69
+ draggedElementId: "index.html:.card:1",
70
+ previewStart: 2.4,
71
+ previewTrack: 3,
72
+ }),
73
+ ).toEqual({ ...element, start: 2.4, track: 3 });
74
+ });
56
75
  });
@@ -63,12 +63,12 @@ const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
63
63
  const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
64
64
 
65
65
  export const defaultTimelineTheme: TimelineTheme = {
66
- shellBackground: "#0A0E15",
66
+ shellBackground: "#0A0A0B",
67
67
  shellBorder: "rgba(255,255,255,0.05)",
68
68
  rulerBorder: "rgba(255,255,255,0.045)",
69
- rowBackground: "#0A0E15",
69
+ rowBackground: "#0A0A0B",
70
70
  rowBorder: "rgba(255,255,255,0.05)",
71
- gutterBackground: "#0D121B",
71
+ gutterBackground: "#0A0A0B",
72
72
  gutterBorder: "rgba(255,255,255,0.05)",
73
73
  textPrimary: "#E8EDF5",
74
74
  textSecondary: "#8391A8",
@@ -130,7 +130,11 @@ export function getRenderedTimelineElement({
130
130
  previewStart: number | null;
131
131
  previewTrack: number | null;
132
132
  }): TimelineElement {
133
- if (element.id !== draggedElementId || previewStart === null || previewTrack === null) {
133
+ if (
134
+ (element.key ?? element.id) !== draggedElementId ||
135
+ previewStart === null ||
136
+ previewTrack === null
137
+ ) {
134
138
  return element;
135
139
  }
136
140
  return {
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
2
2
  import { Window } from "happy-dom";
3
3
  import {
4
4
  buildStandaloneRootTimelineElement,
5
+ createTimelineElementFromManifestClip,
5
6
  findTimelineDomNodeForClip,
6
7
  getTimelineElementSelector,
8
+ parseTimelineFromDOM,
7
9
  type ClipManifestClip,
8
10
  mergeTimelineElementsPreservingDowngrades,
9
11
  resolveStandaloneRootCompositionSrc,
@@ -66,6 +68,7 @@ describe("buildStandaloneRootTimelineElement", () => {
66
68
  }),
67
69
  ).toEqual({
68
70
  id: "hero",
71
+ label: "hero",
69
72
  key: 'scenes/hero.html:[data-composition-id="hero"]:0',
70
73
  tag: "div",
71
74
  start: 0,
@@ -146,6 +149,83 @@ describe("findTimelineDomNodeForClip", () => {
146
149
  });
147
150
  });
148
151
 
152
+ describe("anonymous timeline identity", () => {
153
+ it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
154
+ const doc = createDocument(`
155
+ <div data-composition-id="main" data-start="0" data-duration="8">
156
+ <div class="clip card" data-label="Card" data-start="0" data-duration="3" data-track-index="0"></div>
157
+ <div class="clip card" data-label="Card" data-start="3" data-duration="3" data-track-index="1"></div>
158
+ </div>
159
+ `);
160
+
161
+ const elements = parseTimelineFromDOM(doc, 8);
162
+
163
+ expect(elements).toHaveLength(2);
164
+ expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
165
+ expect(new Set(elements.map((element) => element.id)).size).toBe(2);
166
+ expect(new Set(elements.map((element) => element.key)).size).toBe(2);
167
+ expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
168
+ });
169
+
170
+ it("keeps runtime-manifest anonymous clips distinct when labels match", () => {
171
+ const doc = createDocument(`
172
+ <div data-composition-id="main" data-start="0" data-duration="8">
173
+ <div class="clip card" data-start="0" data-duration="3" data-track-index="0"></div>
174
+ <div class="clip card" data-start="3" data-duration="3" data-track-index="1"></div>
175
+ </div>
176
+ `);
177
+ const clips = [
178
+ createClip({ id: null, label: "Card", start: 0, duration: 3, track: 0 }),
179
+ createClip({ id: null, label: "Card", start: 3, duration: 3, track: 1 }),
180
+ ];
181
+ const used = new Set<Element>();
182
+ const elements = clips.map((clip, index) => {
183
+ const hostEl = findTimelineDomNodeForClip(doc, clip, index, used);
184
+ if (hostEl) used.add(hostEl);
185
+ return createTimelineElementFromManifestClip({
186
+ clip,
187
+ fallbackIndex: index,
188
+ doc,
189
+ hostEl,
190
+ });
191
+ });
192
+
193
+ expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
194
+ expect(new Set(elements.map((element) => element.id)).size).toBe(2);
195
+ expect(new Set(elements.map((element) => element.key)).size).toBe(2);
196
+ expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
197
+ });
198
+
199
+ it("reads media metadata from owner-window media elements", () => {
200
+ const doc = createDocument(`
201
+ <div data-composition-id="main" data-start="0" data-duration="8">
202
+ <div class="clip video-card" data-start="0" data-duration="3" data-track-index="0">
203
+ <video src="/clip.mp4" data-source-duration="12"></video>
204
+ </div>
205
+ </div>
206
+ `);
207
+ const hostEl = doc.querySelector(".video-card");
208
+ const video = hostEl?.querySelector("video");
209
+ if (!hostEl || !video) throw new Error("missing video test fixture");
210
+ Object.defineProperty(video, "defaultPlaybackRate", {
211
+ value: 1.5,
212
+ configurable: true,
213
+ });
214
+
215
+ const element = createTimelineElementFromManifestClip({
216
+ clip: createClip({ kind: "video", tagName: "div" }),
217
+ fallbackIndex: 0,
218
+ doc,
219
+ hostEl,
220
+ });
221
+
222
+ expect(element.tag).toBe("video");
223
+ expect(element.src).toBe("/clip.mp4");
224
+ expect(element.sourceDuration).toBe(12);
225
+ expect(element.playbackRate).toBe(1.5);
226
+ });
227
+ });
228
+
149
229
  describe("mergeTimelineElementsPreservingDowngrades", () => {
150
230
  it("preserves missing current elements when a shorter manifest arrives", () => {
151
231
  expect(
@@ -174,6 +254,65 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
174
254
  ),
175
255
  ).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
176
256
  });
257
+
258
+ it("preserves distinct anonymous clips that share the same friendly id label", () => {
259
+ expect(
260
+ mergeTimelineElementsPreservingDowngrades(
261
+ [
262
+ {
263
+ id: "Card",
264
+ key: "index.html:.card:0",
265
+ label: "Card",
266
+ tag: "div",
267
+ start: 0,
268
+ duration: 3,
269
+ track: 0,
270
+ },
271
+ {
272
+ id: "Card",
273
+ key: "index.html:.card:1",
274
+ label: "Card",
275
+ tag: "div",
276
+ start: 3,
277
+ duration: 3,
278
+ track: 1,
279
+ },
280
+ ],
281
+ [
282
+ {
283
+ id: "Card",
284
+ key: "index.html:.card:0",
285
+ label: "Card",
286
+ tag: "div",
287
+ start: 0,
288
+ duration: 3,
289
+ track: 0,
290
+ },
291
+ ],
292
+ 8,
293
+ 8,
294
+ ),
295
+ ).toEqual([
296
+ {
297
+ id: "Card",
298
+ key: "index.html:.card:0",
299
+ label: "Card",
300
+ tag: "div",
301
+ start: 0,
302
+ duration: 3,
303
+ track: 0,
304
+ },
305
+ {
306
+ id: "Card",
307
+ key: "index.html:.card:1",
308
+ label: "Card",
309
+ tag: "div",
310
+ start: 3,
311
+ duration: 3,
312
+ track: 1,
313
+ },
314
+ ]);
315
+ });
177
316
  });
178
317
 
179
318
  describe("shouldIgnorePlaybackShortcutTarget", () => {