@hyperframes/studio 0.5.5 → 0.6.0-alpha.1

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 (74) hide show
  1. package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
  2. package/dist/assets/index-D04_ZoMm.js +107 -0
  3. package/dist/assets/index-UWFaHilT.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2621 -170
  7. package/src/components/LintModal.tsx +3 -4
  8. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  9. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  10. package/src/components/editor/MotionPanel.tsx +651 -0
  11. package/src/components/editor/PropertyPanel.test.ts +67 -0
  12. package/src/components/editor/PropertyPanel.tsx +2891 -207
  13. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  14. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  15. package/src/components/editor/colorValue.test.ts +82 -0
  16. package/src/components/editor/colorValue.ts +175 -0
  17. package/src/components/editor/domEditing.test.ts +872 -0
  18. package/src/components/editor/domEditing.ts +993 -0
  19. package/src/components/editor/floatingPanel.test.ts +34 -0
  20. package/src/components/editor/floatingPanel.ts +54 -0
  21. package/src/components/editor/fontAssets.ts +32 -0
  22. package/src/components/editor/fontCatalog.ts +126 -0
  23. package/src/components/editor/gradientValue.test.ts +89 -0
  24. package/src/components/editor/gradientValue.ts +445 -0
  25. package/src/components/editor/manualEditingAvailability.test.ts +120 -0
  26. package/src/components/editor/manualEditingAvailability.ts +60 -0
  27. package/src/components/editor/manualEdits.test.ts +945 -0
  28. package/src/components/editor/manualEdits.ts +1397 -0
  29. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  30. package/src/components/editor/manualOffsetDrag.ts +307 -0
  31. package/src/components/editor/studioMotion.test.ts +355 -0
  32. package/src/components/editor/studioMotion.ts +632 -0
  33. package/src/components/nle/NLELayout.tsx +27 -4
  34. package/src/components/nle/NLEPreview.tsx +50 -5
  35. package/src/components/renders/RenderQueue.tsx +13 -62
  36. package/src/components/renders/useRenderQueue.ts +6 -30
  37. package/src/components/sidebar/AssetsTab.tsx +3 -4
  38. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  39. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  40. package/src/components/sidebar/LeftSidebar.tsx +140 -125
  41. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  42. package/src/hooks/usePersistentEditHistory.ts +337 -0
  43. package/src/icons/SystemIcons.tsx +2 -0
  44. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  45. package/src/player/components/CompositionThumbnail.tsx +50 -13
  46. package/src/player/components/EditModal.tsx +5 -20
  47. package/src/player/components/Player.tsx +18 -2
  48. package/src/player/components/Timeline.test.ts +20 -0
  49. package/src/player/components/Timeline.tsx +103 -21
  50. package/src/player/components/TimelineClip.test.ts +92 -0
  51. package/src/player/components/TimelineClip.tsx +241 -7
  52. package/src/player/components/timelineEditing.test.ts +16 -3
  53. package/src/player/components/timelineEditing.ts +10 -3
  54. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  55. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  56. package/src/player/store/playerStore.ts +2 -0
  57. package/src/utils/clipboard.test.ts +89 -0
  58. package/src/utils/clipboard.ts +57 -0
  59. package/src/utils/editHistory.test.ts +244 -0
  60. package/src/utils/editHistory.ts +218 -0
  61. package/src/utils/editHistoryStorage.test.ts +37 -0
  62. package/src/utils/editHistoryStorage.ts +99 -0
  63. package/src/utils/mediaTypes.ts +1 -1
  64. package/src/utils/sourcePatcher.test.ts +128 -1
  65. package/src/utils/sourcePatcher.ts +130 -18
  66. package/src/utils/studioFileHistory.test.ts +156 -0
  67. package/src/utils/studioFileHistory.ts +61 -0
  68. package/src/utils/timelineAssetDrop.test.ts +31 -11
  69. package/src/utils/timelineAssetDrop.ts +22 -2
  70. package/src/utils/timelineInspector.test.ts +79 -0
  71. package/src/utils/timelineInspector.ts +116 -0
  72. package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
  73. package/dist/assets/index-04Mp2wOn.css +0 -1
  74. package/dist/assets/index-960mgQMI.js +0 -93
@@ -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">
@@ -4,7 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
5
5
  import { useCaptionStore } from "../../captions/store";
6
6
 
7
- interface PlaybackAdapter {
7
+ export interface PlaybackAdapter {
8
8
  play: () => void;
9
9
  pause: () => void;
10
10
  seek: (time: number) => void;
@@ -13,6 +13,16 @@ interface PlaybackAdapter {
13
13
  isPlaying: () => boolean;
14
14
  }
15
15
 
16
+ type RuntimePlaybackAdapter = PlaybackAdapter & {
17
+ renderSeek?: (time: number) => void;
18
+ };
19
+
20
+ interface StaticSeekPlaybackClock {
21
+ now: () => number;
22
+ requestAnimationFrame: (callback: FrameRequestCallback) => number;
23
+ cancelAnimationFrame: (handle: number) => void;
24
+ }
25
+
16
26
  interface TimelineLike {
17
27
  play: () => void;
18
28
  pause: () => void;
@@ -22,7 +32,7 @@ interface TimelineLike {
22
32
  isActive: () => boolean;
23
33
  }
24
34
 
25
- interface ClipManifestClip {
35
+ export interface ClipManifestClip {
26
36
  id: string | null;
27
37
  label: string;
28
38
  start: number;
@@ -43,12 +53,133 @@ interface ClipManifest {
43
53
  }
44
54
 
45
55
  type IframeWindow = Window & {
46
- __player?: PlaybackAdapter;
56
+ __player?: RuntimePlaybackAdapter;
47
57
  __timeline?: TimelineLike;
48
58
  __timelines?: Record<string, TimelineLike>;
49
59
  __clipManifest?: ClipManifest;
50
60
  };
51
61
 
62
+ function isFinitePositive(value: number): boolean {
63
+ return Number.isFinite(value) && value > 0;
64
+ }
65
+
66
+ function clampTime(time: number, duration: number): number {
67
+ const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
68
+ const safeTime = Math.max(0, Number.isFinite(time) ? time : 0);
69
+ return safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime;
70
+ }
71
+
72
+ function readDurationAttribute(el: Element | null | undefined): number {
73
+ if (!el) return 0;
74
+ const duration =
75
+ Number.parseFloat(el.getAttribute("data-duration") ?? "") ||
76
+ Number.parseFloat(el.getAttribute("data-hf-authored-duration") ?? "");
77
+ return isFinitePositive(duration) ? duration : 0;
78
+ }
79
+
80
+ export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
81
+ if (!doc) return 0;
82
+ const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
83
+ if (rootDuration > 0) return rootDuration;
84
+
85
+ let maxEnd = 0;
86
+ for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
87
+ const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
88
+ const duration = readDurationAttribute(node);
89
+ if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
90
+ maxEnd = Math.max(maxEnd, start + duration);
91
+ }
92
+ return maxEnd;
93
+ }
94
+
95
+ function getAdapterDuration(adapter: PlaybackAdapter | null | undefined): number {
96
+ if (!adapter) return 0;
97
+ try {
98
+ const duration = Number(adapter.getDuration());
99
+ return isFinitePositive(duration) ? duration : 0;
100
+ } catch {
101
+ return 0;
102
+ }
103
+ }
104
+
105
+ function getDefaultStaticSeekPlaybackClock(win: Window): StaticSeekPlaybackClock {
106
+ return {
107
+ now: () => win.performance.now(),
108
+ requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
109
+ cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle),
110
+ };
111
+ }
112
+
113
+ export function createStaticSeekPlaybackAdapter(
114
+ player: Pick<RuntimePlaybackAdapter, "getTime"> &
115
+ Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>,
116
+ duration: number,
117
+ clock: StaticSeekPlaybackClock,
118
+ getPlaybackRate: () => number = () => 1,
119
+ ): PlaybackAdapter {
120
+ const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
121
+ let currentTime = clampTime(Number(player.getTime?.() ?? 0), safeDuration);
122
+ let playing = false;
123
+ let rafId = 0;
124
+ let playStartTime = currentTime;
125
+ let playStartNow = clock.now();
126
+
127
+ const renderSeek = (time: number) => {
128
+ currentTime = clampTime(time, safeDuration);
129
+ if (typeof player.renderSeek === "function") {
130
+ player.renderSeek(currentTime);
131
+ return;
132
+ }
133
+ player.seek?.(currentTime);
134
+ };
135
+
136
+ const stopTicker = () => {
137
+ if (rafId) {
138
+ clock.cancelAnimationFrame(rafId);
139
+ rafId = 0;
140
+ }
141
+ };
142
+
143
+ const tick: FrameRequestCallback = (now) => {
144
+ if (!playing) return;
145
+ const playbackRate = Math.max(0.1, Number(getPlaybackRate()) || 1);
146
+ const elapsed = ((now - playStartNow) / 1000) * playbackRate;
147
+ renderSeek(playStartTime + elapsed);
148
+ if (currentTime >= safeDuration) {
149
+ playing = false;
150
+ rafId = 0;
151
+ return;
152
+ }
153
+ rafId = clock.requestAnimationFrame(tick);
154
+ };
155
+
156
+ return {
157
+ play: () => {
158
+ if (playing || safeDuration <= 0) return;
159
+ if (currentTime >= safeDuration) renderSeek(0);
160
+ playing = true;
161
+ playStartTime = currentTime;
162
+ playStartNow = clock.now();
163
+ stopTicker();
164
+ rafId = clock.requestAnimationFrame(tick);
165
+ },
166
+ pause: () => {
167
+ playing = false;
168
+ stopTicker();
169
+ },
170
+ seek: (time) => {
171
+ renderSeek(time);
172
+ if (playing) {
173
+ playStartTime = currentTime;
174
+ playStartNow = clock.now();
175
+ }
176
+ },
177
+ getTime: () => currentTime,
178
+ getDuration: () => safeDuration,
179
+ isPlaying: () => playing,
180
+ };
181
+ }
182
+
52
183
  function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
53
184
  return {
54
185
  play: () => tl.play(),
@@ -183,6 +314,100 @@ function getTimelineElementDisplayLabel(input: {
183
314
  return tag ? `${tag} clip` : "Timeline clip";
184
315
  }
185
316
 
317
+ const IMPLICIT_TIMELINE_LAYER_SKIP_TAGS = new Set([
318
+ "base",
319
+ "link",
320
+ "meta",
321
+ "noscript",
322
+ "script",
323
+ "style",
324
+ "template",
325
+ ]);
326
+
327
+ function humanizeTimelineIdentifier(value: string): string {
328
+ return value
329
+ .trim()
330
+ .replace(/[_-]+/g, " ")
331
+ .replace(/\s+/g, " ")
332
+ .replace(/\b\w/g, (match) => match.toUpperCase());
333
+ }
334
+
335
+ function getImplicitTimelineLayerLabel(el: HTMLElement): string {
336
+ const explicitLabel =
337
+ el.getAttribute("data-timeline-label") ??
338
+ el.getAttribute("data-label") ??
339
+ el.getAttribute("aria-label");
340
+ if (explicitLabel?.trim()) return explicitLabel.trim();
341
+ if (el.id.trim()) return humanizeTimelineIdentifier(el.id);
342
+ const classes = el.className.split(/\s+/).filter(Boolean);
343
+ const className = classes.find((value) => value !== "clip") ?? classes[0];
344
+ if (className) return humanizeTimelineIdentifier(className);
345
+ return getTimelineElementDisplayLabel({ tag: el.tagName });
346
+ }
347
+
348
+ function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
349
+ if (!isHtmlElement(el)) return false;
350
+ if (el.parentElement !== root) return false;
351
+ const tagName = el.tagName.toLowerCase();
352
+ if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
353
+ if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false;
354
+ return Boolean(getTimelineElementSelector(el));
355
+ }
356
+
357
+ export function createImplicitTimelineLayersFromDOM(
358
+ doc: Document,
359
+ rootDuration: number,
360
+ existingElements: readonly TimelineElement[] = [],
361
+ ): TimelineElement[] {
362
+ if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
363
+ const rootComp = doc.querySelector("[data-composition-id]");
364
+ if (!rootComp) return [];
365
+
366
+ const existingKeys = new Set(existingElements.map(getTimelineElementIdentity));
367
+ const maxTrack = existingElements.reduce(
368
+ (max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0),
369
+ -1,
370
+ );
371
+ const layers: TimelineElement[] = [];
372
+
373
+ for (const child of Array.from(rootComp.children)) {
374
+ if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue;
375
+
376
+ const selector = getTimelineElementSelector(child);
377
+ if (!selector) continue;
378
+ const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector);
379
+ const sourceFile = getTimelineElementSourceFile(child);
380
+ const label = getImplicitTimelineLayerLabel(child);
381
+ const identity = buildTimelineElementIdentity({
382
+ preferredId: child.id || null,
383
+ label,
384
+ fallbackIndex: existingElements.length + layers.length,
385
+ domId: child.id || undefined,
386
+ selector,
387
+ selectorIndex,
388
+ sourceFile,
389
+ });
390
+ if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
391
+
392
+ layers.push({
393
+ domId: child.id || undefined,
394
+ duration: rootDuration,
395
+ id: identity.id,
396
+ key: identity.key,
397
+ label,
398
+ selector,
399
+ selectorIndex,
400
+ sourceFile,
401
+ start: 0,
402
+ tag: child.tagName.toLowerCase(),
403
+ timingSource: "implicit",
404
+ track: maxTrack + 1 + layers.length,
405
+ });
406
+ }
407
+
408
+ return layers;
409
+ }
410
+
186
411
  /**
187
412
  * Parse [data-start] elements from a Document into TimelineElement[].
188
413
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
@@ -244,6 +469,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
244
469
  selector,
245
470
  selectorIndex,
246
471
  sourceFile,
472
+ timingSource: "authored",
247
473
  };
248
474
 
249
475
  const mediaEl = resolveMediaElement(el);
@@ -275,7 +501,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
275
501
  els.push(entry);
276
502
  });
277
503
 
278
- return els;
504
+ return [...els, ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els)];
279
505
  }
280
506
 
281
507
  function isHtmlElement(el: Element): el is HTMLElement {
@@ -335,7 +561,6 @@ function buildTimelineElementKey(params: {
335
561
  if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
336
562
  return `${scope}:${params.id}:${params.fallbackIndex}`;
337
563
  }
338
-
339
564
  function buildTimelineElementIdentity(params: {
340
565
  preferredId?: string | null;
341
566
  label: string;
@@ -557,7 +782,6 @@ export function buildStandaloneRootTimelineElement(params: {
557
782
  sourceFile: compositionSrc,
558
783
  };
559
784
  }
560
-
561
785
  function normalizePreviewViewport(doc: Document, win: Window): void {
562
786
  if (doc.documentElement) {
563
787
  doc.documentElement.style.overflow = "hidden";
@@ -682,6 +906,11 @@ export function useTimelinePlayer() {
682
906
  const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
683
907
  const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
684
908
  const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
909
+ const staticSeekAdapterRef = useRef<{
910
+ player: RuntimePlaybackAdapter;
911
+ duration: number;
912
+ adapter: PlaybackAdapter;
913
+ } | null>(null);
685
914
 
686
915
  // ZERO store subscriptions — this hook never causes re-renders.
687
916
  // All reads use getState() (point-in-time), all writes use the stable setters.
@@ -710,13 +939,18 @@ export function useTimelinePlayer() {
710
939
  try {
711
940
  const iframe = iframeRef.current;
712
941
  const win = iframe?.contentWindow as IframeWindow | null;
713
- if (!win) return null;
942
+ if (!iframe || !win) return null;
714
943
 
715
- if (win.__player && typeof win.__player.play === "function") {
716
- return win.__player;
944
+ const playerAdapter =
945
+ win.__player && typeof win.__player.play === "function" ? win.__player : null;
946
+ if (getAdapterDuration(playerAdapter) > 0) {
947
+ return playerAdapter;
717
948
  }
718
949
 
719
- if (win.__timeline) return wrapTimeline(win.__timeline);
950
+ if (win.__timeline) {
951
+ const adapter = wrapTimeline(win.__timeline);
952
+ if (getAdapterDuration(adapter) > 0) return adapter;
953
+ }
720
954
 
721
955
  if (win.__timelines) {
722
956
  const keys = Object.keys(win.__timelines);
@@ -729,11 +963,40 @@ export function useTimelinePlayer() {
729
963
  ?.querySelector("[data-composition-id]")
730
964
  ?.getAttribute("data-composition-id");
731
965
  const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
732
- return wrapTimeline(win.__timelines[key]);
966
+ const adapter = wrapTimeline(win.__timelines[key]);
967
+ if (getAdapterDuration(adapter) > 0) return adapter;
733
968
  }
734
969
  }
735
970
 
736
- return null;
971
+ const fallbackDuration = Math.max(
972
+ usePlayerStore.getState().duration,
973
+ readTimelineDurationFromDocument(iframe.contentDocument),
974
+ );
975
+ if (
976
+ playerAdapter &&
977
+ fallbackDuration > 0 &&
978
+ (typeof playerAdapter.renderSeek === "function" || typeof playerAdapter.seek === "function")
979
+ ) {
980
+ const cached = staticSeekAdapterRef.current;
981
+ if (cached?.player === playerAdapter && cached.duration === fallbackDuration) {
982
+ return cached.adapter;
983
+ }
984
+ cached?.adapter.pause();
985
+ const adapter = createStaticSeekPlaybackAdapter(
986
+ playerAdapter,
987
+ fallbackDuration,
988
+ getDefaultStaticSeekPlaybackClock(win),
989
+ () => usePlayerStore.getState().playbackRate,
990
+ );
991
+ staticSeekAdapterRef.current = {
992
+ player: playerAdapter,
993
+ duration: fallbackDuration,
994
+ adapter,
995
+ };
996
+ return adapter;
997
+ }
998
+
999
+ return playerAdapter;
737
1000
  } catch (err) {
738
1001
  console.warn("[useTimelinePlayer] Could not get playback adapter (cross-origin)", err);
739
1002
  return null;
@@ -1066,8 +1329,15 @@ export function useTimelinePlayer() {
1066
1329
  }))
1067
1330
  .filter((element) => element.duration > 0)
1068
1331
  : els;
1069
- if (clampedEls.length > 0) {
1070
- syncTimelineElements(clampedEls, newDuration > 0 ? newDuration : undefined);
1332
+ const timelineEls =
1333
+ iframeDoc && effectiveDuration > 0
1334
+ ? [
1335
+ ...clampedEls,
1336
+ ...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls),
1337
+ ]
1338
+ : clampedEls;
1339
+ if (timelineEls.length > 0) {
1340
+ syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
1071
1341
  }
1072
1342
  },
1073
1343
  [syncTimelineElements],
@@ -1351,6 +1621,9 @@ export function useTimelinePlayer() {
1351
1621
  setIsPlaying(false);
1352
1622
  }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
1353
1623
 
1624
+ const togglePlayRef = useRef(togglePlay);
1625
+ togglePlayRef.current = togglePlay;
1626
+
1354
1627
  const refreshPlayer = useCallback(() => {
1355
1628
  const iframe = iframeRef.current;
1356
1629
  if (!iframe) return;
@@ -1459,8 +1732,6 @@ export function useTimelinePlayer() {
1459
1732
  stopRAFLoop();
1460
1733
  stopReverseLoop();
1461
1734
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1462
- // Don't reset() on cleanup — preserve timeline elements across iframe refreshes
1463
- // to prevent blink. New data will replace old when the iframe reloads.
1464
1735
  };
1465
1736
  });
1466
1737
 
@@ -23,6 +23,8 @@ export interface TimelineElement {
23
23
  volume?: number;
24
24
  /** Path from data-composition-src — identifies sub-composition elements */
25
25
  compositionSrc?: string;
26
+ /** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
27
+ timingSource?: "authored" | "implicit";
26
28
  }
27
29
 
28
30
  export type ZoomMode = "fit" | "manual";