@hyperframes/studio 0.5.7 → 0.6.0-alpha.10

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.
Files changed (75) hide show
  1. package/dist/assets/index-14zH9lqh.css +1 -0
  2. package/dist/assets/index-B-16fRnH.js +108 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +2965 -186
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  8. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  9. package/src/components/editor/MotionPanel.tsx +651 -0
  10. package/src/components/editor/PropertyPanel.test.ts +116 -0
  11. package/src/components/editor/PropertyPanel.tsx +2829 -205
  12. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  13. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  14. package/src/components/editor/colorValue.test.ts +82 -0
  15. package/src/components/editor/colorValue.ts +175 -0
  16. package/src/components/editor/domEditing.test.ts +1120 -0
  17. package/src/components/editor/domEditing.ts +1117 -0
  18. package/src/components/editor/floatingPanel.test.ts +34 -0
  19. package/src/components/editor/floatingPanel.ts +54 -0
  20. package/src/components/editor/fontAssets.ts +32 -0
  21. package/src/components/editor/fontCatalog.ts +126 -0
  22. package/src/components/editor/gradientValue.test.ts +89 -0
  23. package/src/components/editor/gradientValue.ts +445 -0
  24. package/src/components/editor/manualEditingAvailability.test.ts +131 -0
  25. package/src/components/editor/manualEditingAvailability.ts +62 -0
  26. package/src/components/editor/manualEdits.test.ts +945 -0
  27. package/src/components/editor/manualEdits.ts +1409 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  29. package/src/components/editor/manualOffsetDrag.ts +307 -0
  30. package/src/components/editor/studioMotion.test.ts +355 -0
  31. package/src/components/editor/studioMotion.ts +632 -0
  32. package/src/components/nle/NLELayout.test.ts +12 -0
  33. package/src/components/nle/NLELayout.tsx +84 -22
  34. package/src/components/nle/NLEPreview.tsx +56 -5
  35. package/src/components/renders/RenderQueue.tsx +24 -11
  36. package/src/components/sidebar/AssetsTab.tsx +3 -4
  37. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  38. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  39. package/src/components/sidebar/LeftSidebar.tsx +194 -179
  40. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  41. package/src/hooks/usePersistentEditHistory.ts +337 -0
  42. package/src/icons/SystemIcons.tsx +2 -0
  43. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  44. package/src/player/components/CompositionThumbnail.tsx +50 -13
  45. package/src/player/components/EditModal.tsx +5 -20
  46. package/src/player/components/Player.test.ts +58 -0
  47. package/src/player/components/Player.tsx +88 -5
  48. package/src/player/components/PlayerControls.tsx +20 -7
  49. package/src/player/components/Timeline.test.ts +20 -0
  50. package/src/player/components/Timeline.tsx +147 -40
  51. package/src/player/components/TimelineClip.test.ts +92 -0
  52. package/src/player/components/TimelineClip.tsx +241 -7
  53. package/src/player/components/timelineEditing.test.ts +16 -3
  54. package/src/player/components/timelineEditing.ts +10 -3
  55. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  56. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  57. package/src/player/store/playerStore.ts +2 -0
  58. package/src/utils/clipboard.test.ts +89 -0
  59. package/src/utils/clipboard.ts +57 -0
  60. package/src/utils/editHistory.test.ts +244 -0
  61. package/src/utils/editHistory.ts +218 -0
  62. package/src/utils/editHistoryStorage.test.ts +37 -0
  63. package/src/utils/editHistoryStorage.ts +99 -0
  64. package/src/utils/mediaTypes.ts +1 -1
  65. package/src/utils/sourcePatcher.test.ts +128 -1
  66. package/src/utils/sourcePatcher.ts +130 -18
  67. package/src/utils/studioFileHistory.test.ts +156 -0
  68. package/src/utils/studioFileHistory.ts +61 -0
  69. package/src/utils/timelineAssetDrop.test.ts +31 -11
  70. package/src/utils/timelineAssetDrop.ts +22 -2
  71. package/src/utils/timelineDiscovery.ts +1 -1
  72. package/src/utils/timelineInspector.test.ts +79 -0
  73. package/src/utils/timelineInspector.ts +116 -0
  74. package/dist/assets/index-04Mp2wOn.css +0 -1
  75. package/dist/assets/index-Dcw3BoVw.js +0 -93
@@ -17,15 +17,67 @@ interface TimelineClipProps {
17
17
  theme?: TimelineTheme;
18
18
  trackStyle: TimelineTrackStyle;
19
19
  isComposition: boolean;
20
+ isInspectorActive?: boolean;
21
+ isThumbnailActive?: boolean;
22
+ thumbnailLabel?: string;
23
+ childCount?: number;
20
24
  onHoverStart: () => void;
21
25
  onHoverEnd: () => void;
22
26
  onPointerDown?: (e: React.PointerEvent) => void;
23
27
  onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void;
28
+ onInspectorClick?: (e: React.MouseEvent) => void;
29
+ onThumbnailClick?: (e: React.MouseEvent) => void;
24
30
  onClick: (e: React.MouseEvent) => void;
25
31
  onDoubleClick: (e: React.MouseEvent) => void;
26
32
  children?: ReactNode;
27
33
  }
28
34
 
35
+ export const TIMELINE_CLIP_CONTROL_Z_INDEX = 20;
36
+
37
+ const COMPACT_CLIP_CONTROL_WIDTH = 112;
38
+
39
+ interface TimelineClipControlPresentationInput {
40
+ widthPx: number;
41
+ isSelected: boolean;
42
+ isHovered: boolean;
43
+ isInspectorActive: boolean;
44
+ isThumbnailActive: boolean;
45
+ isDragging: boolean;
46
+ }
47
+
48
+ export interface TimelineClipControlPresentation {
49
+ compact: boolean;
50
+ showControls: boolean;
51
+ containerClassName: string;
52
+ buttonClassName: string;
53
+ iconSize: number;
54
+ }
55
+
56
+ export function getTimelineClipControlPresentation({
57
+ widthPx,
58
+ isSelected,
59
+ isHovered,
60
+ isInspectorActive,
61
+ isThumbnailActive,
62
+ isDragging,
63
+ }: TimelineClipControlPresentationInput): TimelineClipControlPresentation {
64
+ const compact = widthPx < COMPACT_CLIP_CONTROL_WIDTH;
65
+ const isInteractive = isHovered || isSelected || isInspectorActive || isThumbnailActive;
66
+ const showControls = !isDragging && (!compact || isInteractive);
67
+
68
+ return {
69
+ compact,
70
+ showControls,
71
+ containerClassName: compact
72
+ ? "absolute right-1 top-1 flex items-center gap-1"
73
+ : "absolute right-2 top-2 flex items-center gap-1",
74
+ buttonClassName: compact
75
+ ? "flex h-5 w-5 items-center justify-center rounded-[7px]"
76
+ : "flex h-6 w-6 items-center justify-center rounded-md",
77
+ iconSize: compact ? 12 : 14,
78
+ };
79
+ }
80
+
29
81
  export const TimelineClip = memo(function TimelineClip({
30
82
  el,
31
83
  pps,
@@ -37,10 +89,16 @@ export const TimelineClip = memo(function TimelineClip({
37
89
  theme = defaultTimelineTheme,
38
90
  trackStyle,
39
91
  isComposition,
92
+ isInspectorActive = false,
93
+ isThumbnailActive = false,
94
+ thumbnailLabel = "thumbnail",
95
+ childCount = 0,
40
96
  onHoverStart,
41
97
  onHoverEnd,
42
98
  onPointerDown,
43
99
  onResizeStart,
100
+ onInspectorClick,
101
+ onThumbnailClick,
44
102
  onClick,
45
103
  onDoubleClick,
46
104
  children,
@@ -62,7 +120,38 @@ export const TimelineClip = memo(function TimelineClip({
62
120
  : theme.clipShadow;
63
121
  const capabilities = getTimelineEditCapabilities(el);
64
122
  const displayLabel = el.label || el.id || el.tag;
123
+ const inspectorLabel =
124
+ childCount > 0
125
+ ? `${childCount} nested selectable layer${childCount === 1 ? "" : "s"}`
126
+ : "Inspect clip layer";
65
127
  const showHandles = handleOpacity > 0.01;
128
+ const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
129
+ const controlPresentation = getTimelineClipControlPresentation({
130
+ widthPx,
131
+ isSelected,
132
+ isHovered,
133
+ isInspectorActive,
134
+ isThumbnailActive,
135
+ isDragging,
136
+ });
137
+ const glossBackgroundImage = isSelected
138
+ ? "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0))"
139
+ : "linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))";
140
+ const accentBackgroundImage = `linear-gradient(120deg, ${trackStyle.accent}${
141
+ isSelected ? "22" : "1e"
142
+ }, transparent 28%)`;
143
+ const compositionStripeBackgroundImage =
144
+ isComposition && !hasCustomContent
145
+ ? "repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)"
146
+ : undefined;
147
+ const clipBackgroundImage = [
148
+ compositionStripeBackgroundImage,
149
+ glossBackgroundImage,
150
+ accentBackgroundImage,
151
+ baseBackgroundImage,
152
+ ]
153
+ .filter(Boolean)
154
+ .join(", ");
66
155
 
67
156
  return (
68
157
  <div
@@ -76,13 +165,7 @@ export const TimelineClip = memo(function TimelineClip({
76
165
  top: clipY,
77
166
  bottom: clipY,
78
167
  borderRadius: theme.clipRadius,
79
- background: isSelected
80
- ? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
81
- : `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
82
- backgroundImage:
83
- isComposition && !hasCustomContent
84
- ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
85
- : undefined,
168
+ backgroundImage: clipBackgroundImage,
86
169
  border: `1px solid ${borderColor}`,
87
170
  boxShadow,
88
171
  transition:
@@ -102,6 +185,157 @@ export const TimelineClip = memo(function TimelineClip({
102
185
  onClick={onClick}
103
186
  onDoubleClick={onDoubleClick}
104
187
  >
188
+ {childCount > 0 && controlPresentation.showControls && (
189
+ <button
190
+ type="button"
191
+ className={`absolute flex items-center gap-1 rounded-md border border-studio-accent/30 bg-neutral-950/75 text-[10px] font-semibold tabular-nums text-studio-accent shadow-lg shadow-black/25 backdrop-blur transition-colors hover:border-studio-accent/60 hover:bg-studio-accent/15 ${
192
+ controlPresentation.compact ? "left-1 top-1 h-5 px-1" : "left-2 top-2 h-6 px-1.5"
193
+ }`}
194
+ style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
195
+ title={inspectorLabel}
196
+ aria-label={inspectorLabel}
197
+ onPointerDown={(event) => {
198
+ event.stopPropagation();
199
+ }}
200
+ onClick={(event) => {
201
+ event.stopPropagation();
202
+ onInspectorClick?.(event);
203
+ }}
204
+ >
205
+ <svg
206
+ width={controlPresentation.compact ? "11" : "13"}
207
+ height={controlPresentation.compact ? "11" : "13"}
208
+ viewBox="0 0 24 24"
209
+ fill="none"
210
+ stroke="currentColor"
211
+ strokeWidth="1.8"
212
+ strokeLinecap="round"
213
+ strokeLinejoin="round"
214
+ aria-hidden="true"
215
+ >
216
+ <rect x="4" y="4" width="6" height="6" rx="1" />
217
+ <rect x="14" y="4" width="6" height="6" rx="1" />
218
+ <rect x="4" y="14" width="6" height="6" rx="1" />
219
+ <path d="M14 17h6" />
220
+ </svg>
221
+ {childCount}
222
+ </button>
223
+ )}
224
+ {onInspectorClick &&
225
+ controlPresentation.compact &&
226
+ !controlPresentation.showControls &&
227
+ !isDragging && (
228
+ <button
229
+ type="button"
230
+ className="group/clip-inspect absolute right-1 top-1/2 flex h-7 w-2 -translate-y-1/2 items-center justify-center rounded-full border border-white/15 bg-neutral-950/70 text-neutral-300 shadow-lg shadow-black/25 backdrop-blur transition-all hover:w-5 hover:border-white/30 hover:bg-neutral-950/90 focus:w-5 focus:border-studio-accent/60 focus:bg-studio-accent/15 focus:outline-none"
231
+ style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
232
+ title={inspectorLabel}
233
+ aria-label={inspectorLabel}
234
+ onPointerDown={(event) => {
235
+ event.stopPropagation();
236
+ }}
237
+ onClick={(event) => {
238
+ event.stopPropagation();
239
+ onInspectorClick(event);
240
+ }}
241
+ >
242
+ <svg
243
+ className="opacity-0 transition-opacity group-hover/clip-inspect:opacity-100 group-focus/clip-inspect:opacity-100"
244
+ width="12"
245
+ height="12"
246
+ viewBox="0 0 24 24"
247
+ fill="none"
248
+ stroke="currentColor"
249
+ strokeWidth="1.8"
250
+ strokeLinecap="round"
251
+ strokeLinejoin="round"
252
+ aria-hidden="true"
253
+ >
254
+ <path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
255
+ <circle cx="12" cy="12" r="3" />
256
+ </svg>
257
+ </button>
258
+ )}
259
+ {(onThumbnailClick || onInspectorClick) && controlPresentation.showControls && (
260
+ <div
261
+ className={controlPresentation.containerClassName}
262
+ style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
263
+ >
264
+ {onThumbnailClick && (
265
+ <button
266
+ type="button"
267
+ className={`${controlPresentation.buttonClassName} border shadow-lg shadow-black/25 backdrop-blur transition-colors ${
268
+ isThumbnailActive
269
+ ? "border-studio-accent/60 bg-studio-accent/18 text-studio-accent"
270
+ : "border-white/12 bg-neutral-950/70 text-neutral-400 hover:border-white/24 hover:text-neutral-100"
271
+ }`}
272
+ title={
273
+ isThumbnailActive ? `Hide clip ${thumbnailLabel}` : `Show clip ${thumbnailLabel}`
274
+ }
275
+ aria-label={
276
+ isThumbnailActive ? `Hide clip ${thumbnailLabel}` : `Show clip ${thumbnailLabel}`
277
+ }
278
+ onPointerDown={(event) => {
279
+ event.stopPropagation();
280
+ }}
281
+ onClick={(event) => {
282
+ event.stopPropagation();
283
+ onThumbnailClick(event);
284
+ }}
285
+ >
286
+ <svg
287
+ width={controlPresentation.iconSize}
288
+ height={controlPresentation.iconSize}
289
+ viewBox="0 0 24 24"
290
+ fill="none"
291
+ stroke="currentColor"
292
+ strokeWidth="1.8"
293
+ strokeLinecap="round"
294
+ strokeLinejoin="round"
295
+ aria-hidden="true"
296
+ >
297
+ <rect x="3" y="5" width="18" height="14" rx="2" />
298
+ <circle cx="8" cy="10" r="1.5" />
299
+ <path d="m4 17 5-5 4 4 2-2 5 5" />
300
+ </svg>
301
+ </button>
302
+ )}
303
+ {onInspectorClick && (
304
+ <button
305
+ type="button"
306
+ className={`${controlPresentation.buttonClassName} border shadow-lg shadow-black/25 backdrop-blur transition-colors ${
307
+ isInspectorActive
308
+ ? "border-studio-accent/60 bg-studio-accent/18 text-studio-accent"
309
+ : "border-white/12 bg-neutral-950/70 text-neutral-400 hover:border-white/24 hover:text-neutral-100"
310
+ }`}
311
+ title={inspectorLabel}
312
+ aria-label={inspectorLabel}
313
+ onPointerDown={(event) => {
314
+ event.stopPropagation();
315
+ }}
316
+ onClick={(event) => {
317
+ event.stopPropagation();
318
+ onInspectorClick(event);
319
+ }}
320
+ >
321
+ <svg
322
+ width={controlPresentation.iconSize}
323
+ height={controlPresentation.iconSize}
324
+ viewBox="0 0 24 24"
325
+ fill="none"
326
+ stroke="currentColor"
327
+ strokeWidth="1.8"
328
+ strokeLinecap="round"
329
+ strokeLinejoin="round"
330
+ aria-hidden="true"
331
+ >
332
+ <path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
333
+ <circle cx="12" cy="12" r="3" />
334
+ </svg>
335
+ </button>
336
+ )}
337
+ </div>
338
+ )}
105
339
  <div
106
340
  aria-hidden="true"
107
341
  role="presentation"
@@ -248,13 +248,28 @@ describe("getTimelineEditCapabilities", () => {
248
248
  });
249
249
  });
250
250
 
251
- it("disables move and trims for generic motion clips even when patchable", () => {
251
+ it("allows moving generic motion clips while keeping trims blocked", () => {
252
252
  expect(
253
253
  getTimelineEditCapabilities({
254
254
  tag: "section",
255
255
  duration: 2,
256
256
  selector: ".feature-card",
257
257
  }),
258
+ ).toEqual({
259
+ canMove: true,
260
+ canTrimStart: false,
261
+ canTrimEnd: false,
262
+ });
263
+ });
264
+
265
+ it("keeps implicit layout layers selectable but not timeline-editable", () => {
266
+ expect(
267
+ getTimelineEditCapabilities({
268
+ duration: 8,
269
+ selector: ".scene-shell",
270
+ tag: "div",
271
+ timingSource: "implicit",
272
+ }),
258
273
  ).toEqual({
259
274
  canMove: false,
260
275
  canTrimStart: false,
@@ -428,7 +443,6 @@ describe("buildClipRangeSelection", () => {
428
443
  });
429
444
  });
430
445
  });
431
-
432
446
  describe("resolveTimelineAutoScroll", () => {
433
447
  it("does not scroll when the pointer stays away from the edges", () => {
434
448
  expect(
@@ -512,7 +526,6 @@ describe("buildTimelineElementAgentPrompt", () => {
512
526
  ).toContain("If this clip is animated with GSAP");
513
527
  });
514
528
  });
515
-
516
529
  describe("resolveTimelineResize", () => {
517
530
  it("shrinks clip duration from the right edge", () => {
518
531
  expect(
@@ -228,12 +228,21 @@ export function getTimelineEditCapabilities(input: {
228
228
  playbackStart?: number;
229
229
  playbackStartAttr?: "media-start" | "playback-start";
230
230
  sourceDuration?: number;
231
+ timingSource?: "authored" | "implicit";
231
232
  }): TimelineEditCapabilities {
233
+ if (input.timingSource === "implicit") {
234
+ return {
235
+ canMove: false,
236
+ canTrimStart: false,
237
+ canTrimEnd: false,
238
+ };
239
+ }
240
+
232
241
  const canPatch = hasPatchableTimelineTarget(input);
233
242
  const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
234
243
  const hasDeterministicWindow = isDeterministicTimelineWindow(input);
235
244
  return {
236
- canMove: canPatch && hasDeterministicWindow,
245
+ canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
237
246
  canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
238
247
  canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
239
248
  };
@@ -273,7 +282,6 @@ export function buildClipRangeSelection(
273
282
  anchorY: anchor.anchorY,
274
283
  };
275
284
  }
276
-
277
285
  export function buildTimelineAgentPrompt({
278
286
  rangeStart,
279
287
  rangeEnd,
@@ -347,7 +355,6 @@ export function buildTimelineElementAgentPrompt(element: {
347
355
 
348
356
  return lines.join("\n");
349
357
  }
350
-
351
358
  export function formatTimelineAttributeNumber(value: number): string {
352
359
  return Number(roundToCentiseconds(value).toFixed(2)).toString();
353
360
  }
@@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest";
2
2
  import { Window } from "happy-dom";
3
3
  import {
4
4
  buildStandaloneRootTimelineElement,
5
+ createStaticSeekPlaybackAdapter,
5
6
  createTimelineElementFromManifestClip,
6
7
  findTimelineDomNodeForClip,
7
8
  getTimelineElementSelector,
8
9
  parseTimelineFromDOM,
10
+ readTimelineDurationFromDocument,
9
11
  type ClipManifestClip,
10
12
  mergeTimelineElementsPreservingDowngrades,
11
13
  resolveStandaloneRootCompositionSrc,
@@ -13,6 +15,30 @@ import {
13
15
  shouldIgnorePlaybackShortcutTarget,
14
16
  } from "./useTimelinePlayer";
15
17
 
18
+ function createDocument(markup: string): Document {
19
+ const window = new Window();
20
+ Object.assign(window, { SyntaxError });
21
+ window.document.body.innerHTML = markup;
22
+ return window.document;
23
+ }
24
+
25
+ function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
26
+ return {
27
+ id: null,
28
+ label: "",
29
+ start: 0,
30
+ duration: 4,
31
+ track: 0,
32
+ kind: "element",
33
+ tagName: "div",
34
+ compositionId: null,
35
+ parentCompositionId: null,
36
+ compositionSrc: null,
37
+ assetUrl: null,
38
+ ...overrides,
39
+ };
40
+ }
41
+
16
42
  function mockTargetMatching(selectorNeedle: string): EventTarget {
17
43
  return {
18
44
  closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
@@ -33,29 +59,102 @@ function mockKeyboardEvent(
33
59
  };
34
60
  }
35
61
 
36
- function createDocument(markup: string): Document {
37
- const window = new Window();
38
- window.document.body.innerHTML = markup;
39
- return window.document;
40
- }
41
-
42
- function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
62
+ function createManualAnimationClock() {
63
+ let now = 0;
64
+ let nextId = 0;
65
+ const callbacks = new Map<number, FrameRequestCallback>();
43
66
  return {
44
- id: null,
45
- label: "Element",
46
- start: 0,
47
- duration: 4,
48
- track: 0,
49
- kind: "element",
50
- tagName: "div",
51
- compositionId: null,
52
- parentCompositionId: null,
53
- compositionSrc: null,
54
- assetUrl: null,
55
- ...overrides,
67
+ now: () => now,
68
+ requestAnimationFrame: (callback: FrameRequestCallback) => {
69
+ nextId += 1;
70
+ callbacks.set(nextId, callback);
71
+ return nextId;
72
+ },
73
+ cancelAnimationFrame: (id: number) => {
74
+ callbacks.delete(id);
75
+ },
76
+ step: (milliseconds: number) => {
77
+ now += milliseconds;
78
+ const pending = Array.from(callbacks.entries());
79
+ callbacks.clear();
80
+ for (const [, callback] of pending) {
81
+ callback(now);
82
+ }
83
+ },
84
+ scheduledCount: () => callbacks.size,
56
85
  };
57
86
  }
58
87
 
88
+ describe("readTimelineDurationFromDocument", () => {
89
+ it("prefers the root composition duration", () => {
90
+ const doc = createDocument(`
91
+ <div data-composition-id="main" data-duration="3">
92
+ <section data-start="0" data-duration="8"></section>
93
+ </div>
94
+ `);
95
+
96
+ expect(readTimelineDurationFromDocument(doc)).toBe(3);
97
+ });
98
+
99
+ it("falls back to the maximum child end time", () => {
100
+ const doc = createDocument(`
101
+ <div data-composition-id="main">
102
+ <section data-start="1" data-duration="2"></section>
103
+ <section data-start="4" data-duration="1.5"></section>
104
+ </div>
105
+ `);
106
+
107
+ expect(readTimelineDurationFromDocument(doc)).toBe(5.5);
108
+ });
109
+ });
110
+
111
+ describe("createStaticSeekPlaybackAdapter", () => {
112
+ it("drives renderSeek while playing a duration-only composition", () => {
113
+ const clock = createManualAnimationClock();
114
+ const renderedTimes: number[] = [];
115
+ const adapter = createStaticSeekPlaybackAdapter(
116
+ {
117
+ getTime: () => 0,
118
+ renderSeek: (time: number) => {
119
+ renderedTimes.push(time);
120
+ },
121
+ },
122
+ 3,
123
+ clock,
124
+ );
125
+
126
+ adapter.seek(1);
127
+ adapter.play();
128
+ clock.step(500);
129
+ clock.step(2_000);
130
+
131
+ expect(renderedTimes).toEqual([1, 1.5, 3]);
132
+ expect(adapter.getTime()).toBe(3);
133
+ expect(adapter.isPlaying()).toBe(false);
134
+ expect(clock.scheduledCount()).toBe(0);
135
+ });
136
+
137
+ it("clamps explicit seeks to the fallback duration", () => {
138
+ const clock = createManualAnimationClock();
139
+ const renderedTimes: number[] = [];
140
+ const adapter = createStaticSeekPlaybackAdapter(
141
+ {
142
+ getTime: () => 0,
143
+ renderSeek: (time: number) => {
144
+ renderedTimes.push(time);
145
+ },
146
+ },
147
+ 2,
148
+ clock,
149
+ );
150
+
151
+ adapter.seek(9);
152
+
153
+ expect(renderedTimes).toEqual([2]);
154
+ expect(adapter.getTime()).toBe(2);
155
+ });
156
+ });
157
+
59
158
  describe("buildStandaloneRootTimelineElement", () => {
60
159
  it("includes selector and source metadata for standalone composition fallback clips", () => {
61
160
  expect(
@@ -150,6 +249,36 @@ describe("findTimelineDomNodeForClip", () => {
150
249
  });
151
250
 
152
251
  describe("anonymous timeline identity", () => {
252
+ it("adds root-level untimed DOM layers as implicit full-duration layers", () => {
253
+ const doc = createDocument(`
254
+ <div data-composition-id="compare" data-start="0" data-duration="18">
255
+ <link rel="stylesheet" href="styles.css" />
256
+ <div class="scene-shell">
257
+ <div class="topline">Title</div>
258
+ </div>
259
+ <video id="main-video" class="clip main-video" data-start="0" data-duration="18" data-track-index="1"></video>
260
+ <script></script>
261
+ </div>
262
+ `);
263
+
264
+ const elements = parseTimelineFromDOM(doc, 18);
265
+
266
+ expect(elements).toEqual(
267
+ expect.arrayContaining([
268
+ expect.objectContaining({
269
+ duration: 18,
270
+ label: "Scene Shell",
271
+ selector: ".scene-shell",
272
+ start: 0,
273
+ tag: "div",
274
+ timingSource: "implicit",
275
+ }),
276
+ ]),
277
+ );
278
+ expect(elements.find((element) => element.tag === "link")).toBeUndefined();
279
+ expect(elements.find((element) => element.tag === "script")).toBeUndefined();
280
+ });
281
+
153
282
  it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
154
283
  const doc = createDocument(`
155
284
  <div data-composition-id="main" data-start="0" data-duration="8">