@hyperframes/studio 0.6.0-alpha.9 → 0.6.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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -1,897 +1,47 @@
1
1
  import { useRef, useCallback } from "react";
2
2
  import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
- import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
5
- import { useCaptionStore } from "../../captions/store";
6
-
7
- export interface PlaybackAdapter {
8
- play: () => void;
9
- pause: () => void;
10
- seek: (time: number) => void;
11
- getTime: () => number;
12
- getDuration: () => number;
13
- isPlaying: () => boolean;
14
- }
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
-
26
- interface TimelineLike {
27
- play: () => void;
28
- pause: () => void;
29
- seek: (time: number) => void;
30
- time: () => number;
31
- duration: () => number;
32
- isActive: () => boolean;
33
- }
34
-
35
- export interface ClipManifestClip {
36
- id: string | null;
37
- label: string;
38
- start: number;
39
- duration: number;
40
- track: number;
41
- kind: "video" | "audio" | "image" | "element" | "composition";
42
- tagName: string | null;
43
- compositionId: string | null;
44
- parentCompositionId: string | null;
45
- compositionSrc: string | null;
46
- assetUrl: string | null;
47
- }
48
-
49
- interface ClipManifest {
50
- clips: ClipManifestClip[];
51
- scenes: Array<{ id: string; label: string; start: number; duration: number }>;
52
- durationInFrames: number;
53
- }
54
-
55
- type IframeWindow = Window & {
56
- __player?: RuntimePlaybackAdapter;
57
- __timeline?: TimelineLike;
58
- __timelines?: Record<string, TimelineLike>;
59
- __clipManifest?: ClipManifest;
60
- };
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
-
183
- function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
184
- return {
185
- play: () => tl.play(),
186
- pause: () => tl.pause(),
187
- seek: (t) => {
188
- tl.pause();
189
- tl.seek(t);
190
- },
191
- getTime: () => tl.time(),
192
- getDuration: () => tl.duration(),
193
- isPlaying: () => tl.isActive(),
194
- };
195
- }
196
-
197
- function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
198
- const win = el.ownerDocument.defaultView ?? window;
199
- const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
200
- const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
201
- if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
202
- const candidate = el.querySelector("video, audio, img");
203
- return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
204
- ? candidate
205
- : null;
206
- }
207
-
208
- function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
209
- const mediaStartAttr = el.getAttribute("data-playback-start")
210
- ? "playback-start"
211
- : el.getAttribute("data-media-start")
212
- ? "media-start"
213
- : undefined;
214
- const mediaStartValue =
215
- el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
216
- if (mediaStartValue != null) {
217
- const playbackStart = parseFloat(mediaStartValue);
218
- if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
219
- }
220
- if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
221
-
222
- const mediaEl = resolveMediaElement(el);
223
- if (!mediaEl) return;
224
-
225
- entry.tag = mediaEl.tagName.toLowerCase();
226
- const src = mediaEl.getAttribute("src");
227
- if (src) entry.src = src;
228
-
229
- const win = mediaEl.ownerDocument.defaultView ?? window;
230
- const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
231
- if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
232
-
233
- const sourceDurationAttr =
234
- el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
235
- const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : mediaEl.duration;
236
- if (Number.isFinite(sourceDuration) && sourceDuration > 0) {
237
- entry.sourceDuration = sourceDuration;
238
- }
239
-
240
- const playbackRate = mediaEl.defaultPlaybackRate;
241
- if (Number.isFinite(playbackRate) && playbackRate > 0) {
242
- entry.playbackRate = playbackRate;
243
- }
244
- }
245
-
246
- const SHUTTLE_SPEEDS = [1, 2, 4] as const;
247
- const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
248
- const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
249
- "input",
250
- "textarea",
251
- "select",
252
- "button",
253
- "a[href]",
254
- "[contenteditable='true']",
255
- "[role='button']",
256
- "[role='checkbox']",
257
- "[role='combobox']",
258
- "[role='menuitem']",
259
- "[role='radio']",
260
- "[role='slider']",
261
- "[role='spinbutton']",
262
- "[role='switch']",
263
- "[role='textbox']",
264
- ].join(",");
265
-
266
- export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
267
- if (!target || typeof target !== "object") return false;
268
- const candidate = target as { closest?: unknown };
269
- if (typeof candidate.closest !== "function") return false;
270
- return (
271
- (candidate.closest as (selector: string) => Element | null).call(
272
- target,
273
- PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
274
- ) !== null
275
- );
276
- }
277
-
278
- interface PlaybackShortcutCaptionState {
279
- isCaptionEditMode: boolean;
280
- selectedCaptionSegmentCount: number;
281
- }
282
-
283
- type PlaybackShortcutEvent = Pick<
284
- KeyboardEvent,
285
- "altKey" | "ctrlKey" | "metaKey" | "code" | "target"
286
- >;
287
-
288
- export function shouldIgnorePlaybackShortcutEvent(
289
- event: PlaybackShortcutEvent,
290
- captionState: PlaybackShortcutCaptionState = {
291
- isCaptionEditMode: false,
292
- selectedCaptionSegmentCount: 0,
293
- },
294
- ): boolean {
295
- if (event.metaKey || event.ctrlKey || event.altKey) return true;
296
- if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
297
- return (
298
- PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
299
- captionState.isCaptionEditMode &&
300
- captionState.selectedCaptionSegmentCount > 0
301
- );
302
- }
303
-
304
- function getTimelineElementDisplayLabel(input: {
305
- id?: string | null;
306
- label?: string | null;
307
- tag?: string | null;
308
- }): string {
309
- const label = input.label?.trim();
310
- if (label) return label;
311
- const id = input.id?.trim();
312
- if (id) return id;
313
- const tag = input.tag?.trim().toLowerCase();
314
- return tag ? `${tag} clip` : "Timeline clip";
315
- }
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
-
411
- /**
412
- * Parse [data-start] elements from a Document into TimelineElement[].
413
- * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
414
- */
415
- export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
416
- const rootComp = doc.querySelector("[data-composition-id]");
417
- const nodes = doc.querySelectorAll("[data-start]");
418
- const els: TimelineElement[] = [];
419
- let trackCounter = 0;
420
-
421
- nodes.forEach((node) => {
422
- if (node === rootComp) return;
423
- const el = node as HTMLElement;
424
- const startStr = el.getAttribute("data-start");
425
- if (startStr == null) return;
426
- const start = parseFloat(startStr);
427
- if (isNaN(start)) return;
428
- if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
429
-
430
- const tagLower = el.tagName.toLowerCase();
431
- let dur = 0;
432
- const durStr = el.getAttribute("data-duration");
433
- if (durStr != null) dur = parseFloat(durStr);
434
- if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
435
- if (Number.isFinite(rootDuration) && rootDuration > 0) {
436
- dur = Math.min(dur, Math.max(0, rootDuration - start));
437
- }
438
- if (!Number.isFinite(dur) || dur <= 0) return;
439
-
440
- const trackStr = el.getAttribute("data-track-index");
441
- const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
442
- const compId = el.getAttribute("data-composition-id");
443
- const selector = getTimelineElementSelector(el);
444
- const sourceFile = getTimelineElementSourceFile(el);
445
- const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
446
- const label = getTimelineElementDisplayLabel({
447
- id: el.id || compId || null,
448
- label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
449
- tag: tagLower,
450
- });
451
- const identity = buildTimelineElementIdentity({
452
- preferredId: el.id || compId || null,
453
- label,
454
- fallbackIndex: els.length,
455
- domId: el.id || undefined,
456
- selector,
457
- selectorIndex,
458
- sourceFile,
459
- });
460
- const entry: TimelineElement = {
461
- id: identity.id,
462
- label,
463
- key: identity.key,
464
- tag: tagLower,
465
- start,
466
- duration: dur,
467
- track: isNaN(track) ? 0 : track,
468
- domId: el.id || undefined,
469
- selector,
470
- selectorIndex,
471
- sourceFile,
472
- timingSource: "authored",
473
- };
474
-
475
- const mediaEl = resolveMediaElement(el);
476
- if (mediaEl) {
477
- if (mediaEl.tagName === "IMG") {
478
- entry.tag = "img";
479
- }
480
- const src = mediaEl.getAttribute("src");
481
- if (src) entry.src = src;
482
- const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
483
- if (vol) entry.volume = parseFloat(vol);
484
- applyMediaMetadataFromElement(entry, el);
485
- }
486
-
487
- // Sub-compositions
488
- const compSrc =
489
- el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
490
- if (compSrc) {
491
- entry.compositionSrc = compSrc;
492
- } else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
493
- // Inline composition — expose inner video for thumbnails
494
- const innerVideo = el.querySelector("video[src]");
495
- if (innerVideo) {
496
- entry.src = innerVideo.getAttribute("src") || undefined;
497
- entry.tag = "video";
498
- }
499
- }
500
-
501
- els.push(entry);
502
- });
503
-
504
- return [...els, ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els)];
505
- }
506
-
507
- function isHtmlElement(el: Element): el is HTMLElement {
508
- const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
509
- return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
510
- }
511
-
512
- export function getTimelineElementSelector(el: Element): string | undefined {
513
- if (isHtmlElement(el) && el.id) return `#${el.id}`;
514
- const compId = el.getAttribute("data-composition-id");
515
- if (compId) return `[data-composition-id="${compId}"]`;
516
- if (isHtmlElement(el)) {
517
- const classes = el.className.split(/\s+/).filter(Boolean);
518
- const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
519
- if (firstClass) return `.${firstClass}`;
520
- }
521
- return undefined;
522
- }
523
-
524
- function getTimelineElementSourceFile(el: Element): string | undefined {
525
- const ownerRoot = el.parentElement?.closest("[data-composition-id]");
526
- return (
527
- ownerRoot?.getAttribute("data-composition-file") ??
528
- ownerRoot?.getAttribute("data-composition-src") ??
529
- undefined
530
- );
531
- }
532
-
533
- function getTimelineElementSelectorIndex(
534
- doc: Document,
535
- el: Element,
536
- selector: string | undefined,
537
- ): number | undefined {
538
- if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
539
- return undefined;
540
- }
541
-
542
- try {
543
- const matches = Array.from(doc.querySelectorAll(selector));
544
- const matchIndex = matches.indexOf(el);
545
- return matchIndex >= 0 ? matchIndex : undefined;
546
- } catch {
547
- return undefined;
548
- }
549
- }
550
-
551
- function buildTimelineElementKey(params: {
552
- id: string;
553
- fallbackIndex: number;
554
- domId?: string;
555
- selector?: string;
556
- selectorIndex?: number;
557
- sourceFile?: string;
558
- }): string {
559
- const scope = params.sourceFile ?? "index.html";
560
- if (params.domId) return `${scope}#${params.domId}`;
561
- if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
562
- return `${scope}:${params.id}:${params.fallbackIndex}`;
563
- }
564
- function buildTimelineElementIdentity(params: {
565
- preferredId?: string | null;
566
- label: string;
567
- fallbackIndex: number;
568
- domId?: string;
569
- selector?: string;
570
- selectorIndex?: number;
571
- sourceFile?: string;
572
- }): { id: string; key: string } {
573
- const id =
574
- params.preferredId?.trim() ||
575
- buildTimelineElementKey({
576
- id: params.label,
577
- fallbackIndex: params.fallbackIndex,
578
- domId: params.domId,
579
- selector: params.selector,
580
- selectorIndex: params.selectorIndex,
581
- sourceFile: params.sourceFile,
582
- });
583
- const key = buildTimelineElementKey({
584
- id,
585
- fallbackIndex: params.fallbackIndex,
586
- domId: params.domId,
587
- selector: params.selector,
588
- selectorIndex: params.selectorIndex,
589
- sourceFile: params.sourceFile,
590
- });
591
- return { id, key };
592
- }
593
-
594
- function getTimelineElementIdentity(element: TimelineElement): string {
595
- return element.key ?? element.id;
596
- }
597
-
598
- function getTimelineDomNodes(doc: Document): Element[] {
599
- const rootComp = doc.querySelector("[data-composition-id]");
600
- return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
601
- }
602
-
603
- function numbersNearlyEqual(a: number, b: number): boolean {
604
- return Math.abs(a - b) < 0.001;
605
- }
606
-
607
- function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
608
- const tagName = clip.tagName?.toLowerCase();
609
- if (tagName && node.tagName.toLowerCase() !== tagName) return false;
610
-
611
- const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
612
- if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
613
-
614
- const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
615
- if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
616
-
617
- const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
618
- if (Number.isFinite(track) && track !== clip.track) return false;
619
-
620
- return true;
621
- }
622
-
623
- export function findTimelineDomNodeForClip(
624
- doc: Document,
625
- clip: ClipManifestClip,
626
- fallbackIndex: number,
627
- usedNodes = new Set<Element>(),
628
- ): Element | null {
629
- const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
630
- if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
631
-
632
- const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
633
- const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
634
- if (exact) return exact;
635
-
636
- return candidates[fallbackIndex] ?? null;
637
- }
638
-
639
- export function createTimelineElementFromManifestClip(params: {
640
- clip: ClipManifestClip;
641
- fallbackIndex: number;
642
- doc?: Document | null;
643
- hostEl?: Element | null;
644
- }): TimelineElement {
645
- const { clip, fallbackIndex, doc } = params;
646
- let hostEl = params.hostEl ?? null;
647
- const label = getTimelineElementDisplayLabel({
648
- id: clip.id,
649
- label: clip.label,
650
- tag: clip.tagName || clip.kind,
651
- });
652
-
653
- let domId: string | undefined;
654
- let selector: string | undefined;
655
- let selectorIndex: number | undefined;
656
- let sourceFile: string | undefined;
657
-
658
- if (hostEl) {
659
- domId = hostEl.id || undefined;
660
- selector = getTimelineElementSelector(hostEl);
661
- selectorIndex =
662
- doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
663
- sourceFile = getTimelineElementSourceFile(hostEl);
664
- }
665
-
666
- const identity = buildTimelineElementIdentity({
667
- preferredId: clip.id,
668
- label,
669
- fallbackIndex,
670
- domId,
671
- selector,
672
- selectorIndex,
673
- sourceFile,
674
- });
675
- const entry: TimelineElement = {
676
- id: identity.id,
677
- label,
678
- key: identity.key,
679
- tag: clip.tagName || clip.kind,
680
- start: clip.start,
681
- duration: clip.duration,
682
- track: clip.track,
683
- domId,
684
- selector,
685
- selectorIndex,
686
- sourceFile,
687
- };
688
-
689
- if (hostEl) {
690
- applyMediaMetadataFromElement(entry, hostEl);
691
- }
692
- if (clip.assetUrl) entry.src = clip.assetUrl;
693
- if (clip.kind === "composition" && clip.compositionId) {
694
- let resolvedSrc = clip.compositionSrc;
695
- if (!resolvedSrc) {
696
- hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
697
- resolvedSrc =
698
- hostEl?.getAttribute("data-composition-src") ??
699
- hostEl?.getAttribute("data-composition-file") ??
700
- null;
701
- }
702
- if (resolvedSrc) {
703
- entry.compositionSrc = resolvedSrc;
704
- } else if (hostEl) {
705
- const innerVideo = hostEl.querySelector("video[src]");
706
- if (innerVideo) {
707
- entry.src = innerVideo.getAttribute("src") || undefined;
708
- entry.tag = "video";
709
- }
710
- }
711
- if (hostEl) {
712
- entry.domId = hostEl.id || undefined;
713
- entry.selector = getTimelineElementSelector(hostEl);
714
- entry.selectorIndex =
715
- doc && entry.selector
716
- ? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
717
- : undefined;
718
- entry.sourceFile = getTimelineElementSourceFile(hostEl);
719
- const nextIdentity = buildTimelineElementIdentity({
720
- preferredId: clip.id,
721
- label,
722
- fallbackIndex,
723
- domId: entry.domId,
724
- selector: entry.selector,
725
- selectorIndex: entry.selectorIndex,
726
- sourceFile: entry.sourceFile,
727
- });
728
- entry.id = nextIdentity.id;
729
- entry.key = nextIdentity.key;
730
- }
731
- }
732
-
733
- return entry;
734
- }
735
-
736
- function findTimelineDomNode(doc: Document, id: string): Element | null {
737
- return (
738
- doc.getElementById(id) ??
739
- doc.querySelector(`[data-composition-id="${id}"]`) ??
740
- doc.querySelector(`.${id}`) ??
741
- null
742
- );
743
- }
744
-
745
- export function resolveStandaloneRootCompositionSrc(iframeSrc: string): string | undefined {
746
- const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
747
- return compPathMatch ? decodeURIComponent(compPathMatch[1]) : undefined;
748
- }
749
-
750
- export function buildStandaloneRootTimelineElement(params: {
751
- compositionId: string;
752
- tagName: string;
753
- rootDuration: number;
754
- iframeSrc: string;
755
- selector?: string;
756
- selectorIndex?: number;
757
- }): TimelineElement | null {
758
- if (!Number.isFinite(params.rootDuration) || params.rootDuration <= 0) return null;
759
-
760
- const compositionSrc = resolveStandaloneRootCompositionSrc(params.iframeSrc);
761
-
762
- return {
763
- id: params.compositionId,
764
- label: getTimelineElementDisplayLabel({
765
- id: params.compositionId,
766
- tag: params.tagName,
767
- }),
768
- key: buildTimelineElementKey({
769
- id: params.compositionId,
770
- fallbackIndex: 0,
771
- selector: params.selector,
772
- selectorIndex: params.selectorIndex,
773
- sourceFile: compositionSrc,
774
- }),
775
- tag: params.tagName.toLowerCase() || "div",
776
- start: 0,
777
- duration: params.rootDuration,
778
- track: 0,
779
- compositionSrc,
780
- selector: params.selector,
781
- selectorIndex: params.selectorIndex,
782
- sourceFile: compositionSrc,
783
- };
784
- }
785
- function normalizePreviewViewport(doc: Document, win: Window): void {
786
- if (doc.documentElement) {
787
- doc.documentElement.style.overflow = "hidden";
788
- doc.documentElement.style.margin = "0";
789
- }
790
- if (doc.body) {
791
- doc.body.style.overflow = "hidden";
792
- doc.body.style.margin = "0";
793
- }
794
- win.scrollTo({ top: 0, left: 0, behavior: "auto" });
795
- }
796
-
797
- function autoHealMissingCompositionIds(doc: Document): void {
798
- const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
799
- const referencedIds = new Set<string>();
800
- const scopedNodes = Array.from(doc.querySelectorAll("style, script"));
801
- for (const node of scopedNodes) {
802
- const text = node.textContent || "";
803
- if (!text) continue;
804
- let match: RegExpExecArray | null;
805
- while ((match = compositionIdRe.exec(text)) !== null) {
806
- const id = (match[1] || "").trim();
807
- if (id) referencedIds.add(id);
808
- }
809
- }
810
-
811
- if (referencedIds.size === 0) return;
812
-
813
- const existingIds = new Set<string>();
814
- const existingNodes = Array.from(doc.querySelectorAll<HTMLElement>("[data-composition-id]"));
815
- for (const node of existingNodes) {
816
- const id = node.getAttribute("data-composition-id");
817
- if (id) existingIds.add(id);
818
- }
819
-
820
- for (const compId of referencedIds) {
821
- if (compId === "root" || existingIds.has(compId)) continue;
822
- const host =
823
- doc.getElementById(`${compId}-layer`) ||
824
- doc.getElementById(`${compId}-comp`) ||
825
- doc.getElementById(compId);
826
- if (!host) continue;
827
- if (!host.getAttribute("data-composition-id")) {
828
- host.setAttribute("data-composition-id", compId);
829
- }
830
- }
831
- }
832
-
833
- function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
834
- if (!iframe) return;
835
- try {
836
- iframe.contentWindow?.postMessage(
837
- { source: "hf-parent", type: "control", action: "set-muted", muted: false },
838
- "*",
839
- );
840
- } catch (err) {
841
- console.warn("[useTimelinePlayer] Failed to unmute preview media", err);
842
- }
843
- }
844
-
845
- /**
846
- * Resolve the underlying iframe from any host element. Supports:
847
- * - Direct `<iframe>` element (most common — studio's own `Player.tsx`)
848
- * - Custom elements (e.g. `<hyperframes-player>`) whose shadow DOM contains an iframe
849
- * - Wrapper elements whose light DOM contains a descendant iframe
850
- *
851
- * Exported so web-component consumers can pre-resolve the iframe before
852
- * assigning it to `iframeRef` returned by `useTimelinePlayer`. Returns `null`
853
- * when the element has no associated iframe yet.
854
- *
855
- * @example
856
- * ```tsx
857
- * const { iframeRef } = useTimelinePlayer();
858
- * const playerElRef = useRef<HyperframesPlayer>(null);
859
- *
860
- * useEffect(() => {
861
- * iframeRef.current = resolveIframe(playerElRef.current);
862
- * }, [iframeRef]);
863
- * ```
864
- */
865
- export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
866
- if (!el) return null;
867
- if (el instanceof HTMLIFrameElement) return el;
868
- return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
869
- }
870
-
871
- export function mergeTimelineElementsPreservingDowngrades(
872
- currentElements: TimelineElement[],
873
- nextElements: TimelineElement[],
874
- currentDuration: number,
875
- nextDuration: number,
876
- ): TimelineElement[] {
877
- const safeCurrentDuration = Number.isFinite(currentDuration) ? currentDuration : 0;
878
- const safeNextDuration = Number.isFinite(nextDuration) ? nextDuration : 0;
879
-
880
- if (
881
- currentElements.length === 0 ||
882
- nextElements.length >= currentElements.length ||
883
- safeNextDuration > safeCurrentDuration
884
- ) {
885
- return nextElements;
886
- }
887
-
888
- const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
889
- const preserved = currentElements.filter(
890
- (element) => !nextIdentities.has(getTimelineElementIdentity(element)),
891
- );
892
- if (preserved.length === 0) return nextElements;
893
- return [...nextElements, ...preserved];
894
- }
4
+ import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
5
+ import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
6
+
7
+ // Re-export public API consumed by tests and external modules.
8
+ // All of these were previously defined in this file; they now live in focused
9
+ // sub-modules but are re-exported here so existing import sites don't change.
10
+ export type { PlaybackAdapter, ClipManifestClip } from "../lib/playbackTypes";
11
+ export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
12
+ export {
13
+ getTimelineElementSelector,
14
+ readTimelineDurationFromDocument,
15
+ parseTimelineFromDOM,
16
+ createTimelineElementFromManifestClip,
17
+ findTimelineDomNodeForClip,
18
+ buildStandaloneRootTimelineElement,
19
+ mergeTimelineElementsPreservingDowngrades,
20
+ resolveStandaloneRootCompositionSrc,
21
+ resolveIframe,
22
+ } from "../lib/timelineDOM";
23
+ export {
24
+ shouldIgnorePlaybackShortcutEvent,
25
+ shouldIgnorePlaybackShortcutTarget,
26
+ } from "../lib/playbackShortcuts";
27
+
28
+ import type { PlaybackAdapter, RuntimePlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
29
+ import {
30
+ getAdapterDuration,
31
+ wrapTimeline,
32
+ createStaticSeekPlaybackAdapter,
33
+ getDefaultStaticSeekPlaybackClock,
34
+ } from "../lib/playbackAdapter";
35
+ import {
36
+ readTimelineDurationFromDocument,
37
+ mergeTimelineElementsPreservingDowngrades,
38
+ parseTimelineFromDOM,
39
+ } from "../lib/timelineDOM";
40
+ import { unmutePreviewMedia } from "../lib/timelineIframeHelpers";
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Hook
44
+ // ---------------------------------------------------------------------------
895
45
 
896
46
  export function useTimelinePlayer() {
897
47
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -902,10 +52,8 @@ export function useTimelinePlayer() {
902
52
  const reverseRafRef = useRef<number>(0);
903
53
  const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null);
904
54
  const shuttleSpeedIndexRef = useRef(0);
905
- const pressedCodesRef = useRef(new Set<string>());
906
55
  const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
907
- const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
908
- const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
56
+ const lastTimelineMessageRef = useRef<number>(0);
909
57
  const staticSeekAdapterRef = useRef<{
910
58
  player: RuntimePlaybackAdapter;
911
59
  duration: number;
@@ -920,17 +68,40 @@ export function useTimelinePlayer() {
920
68
  const syncTimelineElements = useCallback(
921
69
  (elements: TimelineElement[], nextDuration?: number) => {
922
70
  const state = usePlayerStore.getState();
71
+ const resolvedDuration = nextDuration ?? state.duration;
923
72
  const mergedElements = mergeTimelineElementsPreservingDowngrades(
924
73
  state.elements,
925
74
  elements,
926
75
  state.duration,
927
- nextDuration ?? state.duration,
76
+ resolvedDuration,
928
77
  );
929
- setElements(mergedElements);
930
- if (Number.isFinite(nextDuration) && (nextDuration ?? 0) > 0) {
78
+
79
+ const elementsChanged =
80
+ mergedElements.length !== state.elements.length ||
81
+ mergedElements.some((el, i) => {
82
+ const prev = state.elements[i];
83
+ return (
84
+ !prev ||
85
+ el.id !== prev.id ||
86
+ el.start !== prev.start ||
87
+ el.duration !== prev.duration ||
88
+ el.track !== prev.track
89
+ );
90
+ });
91
+
92
+ if (elementsChanged) {
93
+ setElements(mergedElements);
94
+ }
95
+ if (
96
+ Number.isFinite(nextDuration) &&
97
+ (nextDuration ?? 0) > 0 &&
98
+ nextDuration !== state.duration
99
+ ) {
931
100
  setDuration(nextDuration ?? 0);
932
101
  }
933
- setTimelineReady(true);
102
+ if (!state.timelineReady) {
103
+ setTimelineReady(true);
104
+ }
934
105
  },
935
106
  [setElements, setTimelineReady, setDuration],
936
107
  );
@@ -1138,14 +309,6 @@ export function useTimelinePlayer() {
1138
309
  stopRAFLoop();
1139
310
  }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
1140
311
 
1141
- const togglePlay = useCallback(() => {
1142
- if (usePlayerStore.getState().isPlaying) {
1143
- pause();
1144
- } else {
1145
- play();
1146
- }
1147
- }, [play, pause]);
1148
-
1149
312
  const seek = useCallback(
1150
313
  (time: number) => {
1151
314
  stopReverseLoop();
@@ -1157,7 +320,6 @@ export function useTimelinePlayer() {
1157
320
  liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
1158
321
  setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
1159
322
  stopRAFLoop();
1160
- // Only update store if state actually changes (avoids unnecessary re-renders)
1161
323
  if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
1162
324
  shuttleDirectionRef.current = null;
1163
325
  shuttleSpeedIndexRef.current = 0;
@@ -1165,451 +327,34 @@ export function useTimelinePlayer() {
1165
327
  [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
1166
328
  );
1167
329
 
1168
- const stepFrames = useCallback(
1169
- (deltaFrames: number) => {
1170
- const adapter = getAdapter();
1171
- const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
1172
- seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
1173
- },
1174
- [getAdapter, seek],
1175
- );
1176
-
1177
- const shuttle = useCallback(
1178
- (direction: "forward" | "backward") => {
1179
- if (shuttleDirectionRef.current === direction) {
1180
- shuttleSpeedIndexRef.current = Math.min(
1181
- shuttleSpeedIndexRef.current + 1,
1182
- SHUTTLE_SPEEDS.length - 1,
1183
- );
1184
- } else {
1185
- shuttleSpeedIndexRef.current = 0;
1186
- }
1187
- const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
1188
- usePlayerStore.getState().setPlaybackRate(speed);
1189
- if (direction === "forward") {
1190
- play();
1191
- } else {
1192
- playBackward(speed);
1193
- }
1194
- },
1195
- [play, playBackward],
1196
- );
1197
-
1198
- const handlePlaybackKeyDown = useCallback(
1199
- (e: KeyboardEvent) => {
1200
- if (e.defaultPrevented) return;
1201
- const captionState = useCaptionStore.getState();
1202
- if (
1203
- shouldIgnorePlaybackShortcutEvent(e, {
1204
- isCaptionEditMode: captionState.isEditMode,
1205
- selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
1206
- })
1207
- ) {
1208
- return;
1209
- }
1210
- pressedCodesRef.current.add(e.code);
1211
- if (e.code === "Space") {
1212
- e.preventDefault();
1213
- togglePlay();
1214
- return;
1215
- }
1216
- if (e.code === "ArrowLeft") {
1217
- e.preventDefault();
1218
- stepFrames(e.shiftKey ? -10 : -1);
1219
- return;
1220
- }
1221
- if (e.code === "ArrowRight") {
1222
- e.preventDefault();
1223
- stepFrames(e.shiftKey ? 10 : 1);
1224
- return;
1225
- }
1226
- if (e.repeat) return;
1227
- if (e.code === "KeyK") {
1228
- e.preventDefault();
1229
- pause();
1230
- return;
1231
- }
1232
- if (e.code === "KeyJ") {
1233
- e.preventDefault();
1234
- if (pressedCodesRef.current.has("KeyK")) {
1235
- stepFrames(-1);
1236
- return;
1237
- }
1238
- shuttle("backward");
1239
- return;
1240
- }
1241
- if (e.code === "KeyL") {
1242
- e.preventDefault();
1243
- if (pressedCodesRef.current.has("KeyK")) {
1244
- stepFrames(1);
1245
- return;
1246
- }
1247
- shuttle("forward");
1248
- }
1249
- },
1250
- [pause, shuttle, stepFrames, togglePlay],
1251
- );
1252
-
1253
- const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
1254
- pressedCodesRef.current.delete(e.code);
1255
- }, []);
1256
- playbackKeyDownRef.current = handlePlaybackKeyDown;
1257
- playbackKeyUpRef.current = handlePlaybackKeyUp;
1258
-
1259
- const attachIframeShortcutListeners = useCallback(() => {
1260
- iframeShortcutCleanupRef.current?.();
1261
- iframeShortcutCleanupRef.current = null;
1262
-
1263
- const iframeWin = iframeRef.current?.contentWindow;
1264
- const iframeDoc = iframeRef.current?.contentDocument;
1265
- if (!iframeWin && !iframeDoc) return;
1266
-
1267
- const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
1268
- const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
1269
- iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
1270
- iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
1271
- iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
1272
- iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
1273
- iframeShortcutCleanupRef.current = () => {
1274
- iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
1275
- iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
1276
- iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
1277
- iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
1278
- };
1279
- }, []);
1280
-
1281
- // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
1282
- const processTimelineMessage = useCallback(
1283
- (data: {
1284
- clips: ClipManifestClip[];
1285
- durationInFrames: number;
1286
- scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
1287
- }) => {
1288
- if (!data.clips || data.clips.length === 0) {
1289
- return;
1290
- }
1291
-
1292
- // Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
1293
- const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
1294
- const filtered = data.clips.filter(
1295
- (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
1296
- );
1297
- let iframeDoc: Document | null = null;
1298
- try {
1299
- iframeDoc = iframeRef.current?.contentDocument ?? null;
1300
- } catch {
1301
- iframeDoc = null;
1302
- }
1303
- const usedHostEls = new Set<Element>();
1304
- const els: TimelineElement[] = filtered.map((clip, index) => {
1305
- const hostEl = iframeDoc
1306
- ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
1307
- : null;
1308
- if (hostEl) usedHostEls.add(hostEl);
1309
- return createTimelineElementFromManifestClip({
1310
- clip,
1311
- fallbackIndex: index,
1312
- doc: iframeDoc,
1313
- hostEl,
1314
- });
1315
- });
1316
- const rawDuration = data.durationInFrames / 30;
1317
- // Clamp non-finite or absurdly large durations — the runtime can emit
1318
- // Infinity when it detects a loop-inflated GSAP timeline without an
1319
- // explicit data-duration on the root composition.
1320
- const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
1321
- const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
1322
- const clampedEls =
1323
- effectiveDuration > 0
1324
- ? els
1325
- .filter((element) => element.start < effectiveDuration)
1326
- .map((element) => ({
1327
- ...element,
1328
- duration: Math.min(element.duration, effectiveDuration - element.start),
1329
- }))
1330
- .filter((element) => element.duration > 0)
1331
- : els;
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);
1341
- }
1342
- },
1343
- [syncTimelineElements],
1344
- );
1345
-
1346
- /**
1347
- * Scan the iframe DOM for composition hosts missing from the current
1348
- * timeline elements and add them. The CDN runtime often fails to resolve
1349
- * element-reference starts (`data-start="intro"`) so composition hosts
1350
- * are silently dropped from `__clipManifest`. This pass reads the DOM +
1351
- * GSAP timeline registry directly to fill the gaps.
1352
- */
1353
- const enrichMissingCompositions = useCallback(() => {
1354
- try {
1355
- const iframe = iframeRef.current;
1356
- const doc = iframe?.contentDocument;
1357
- const iframeWin = iframe?.contentWindow as IframeWindow | null;
1358
- if (!doc || !iframeWin) return;
1359
-
1360
- const currentEls = usePlayerStore.getState().elements;
1361
- const existingIds = new Set(currentEls.map((e) => e.id));
1362
- const rootComp = doc.querySelector("[data-composition-id]");
1363
- const rootCompId = rootComp?.getAttribute("data-composition-id");
1364
- // Use [data-composition-id][data-start] — the composition loader strips
1365
- // data-composition-src after loading, so we can't rely on it.
1366
- const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
1367
- const missing: TimelineElement[] = [];
1368
-
1369
- hosts.forEach((host) => {
1370
- const el = host as HTMLElement;
1371
- const compId = el.getAttribute("data-composition-id");
1372
- if (!compId || compId === rootCompId) return;
1373
- if (existingIds.has(el.id) || existingIds.has(compId)) return;
1374
-
1375
- // Resolve start: numeric or element-reference
1376
- const startAttr = el.getAttribute("data-start") ?? "0";
1377
- let start = parseFloat(startAttr);
1378
- if (isNaN(start)) {
1379
- const ref =
1380
- doc.getElementById(startAttr) ||
1381
- doc.querySelector(`[data-composition-id="${startAttr}"]`);
1382
- if (ref) {
1383
- const refStartAttr = ref.getAttribute("data-start") ?? "0";
1384
- let refStart = parseFloat(refStartAttr);
1385
- // Recursively resolve one level of reference for the ref's own start
1386
- if (isNaN(refStart)) {
1387
- const refRef =
1388
- doc.getElementById(refStartAttr) ||
1389
- doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
1390
- const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
1391
- const rrCompId = refRef?.getAttribute("data-composition-id");
1392
- const rrDur =
1393
- parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
1394
- (rrCompId
1395
- ? ((
1396
- iframeWin.__timelines?.[rrCompId] as TimelineLike | undefined
1397
- )?.duration?.() ?? 0)
1398
- : 0);
1399
- refStart = rrStart + rrDur;
1400
- }
1401
- const refCompId = ref.getAttribute("data-composition-id");
1402
- const refDur =
1403
- parseFloat(ref.getAttribute("data-duration") ?? "") ||
1404
- (refCompId
1405
- ? ((iframeWin.__timelines?.[refCompId] as TimelineLike | undefined)?.duration?.() ??
1406
- 0)
1407
- : 0);
1408
- start = refStart + refDur;
1409
- } else {
1410
- start = 0;
1411
- }
1412
- }
1413
-
1414
- // Resolve duration from data-duration or GSAP timeline
1415
- let dur = parseFloat(el.getAttribute("data-duration") ?? "");
1416
- if (isNaN(dur) || dur <= 0) {
1417
- dur = (iframeWin.__timelines?.[compId] as TimelineLike | undefined)?.duration?.() ?? 0;
1418
- }
1419
- if (!Number.isFinite(dur) || dur <= 0) return;
1420
- if (!Number.isFinite(start)) start = 0;
1421
- const rootDuration = usePlayerStore.getState().duration;
1422
- if (Number.isFinite(rootDuration) && rootDuration > 0) {
1423
- if (start >= rootDuration) return;
1424
- dur = Math.min(dur, Math.max(0, rootDuration - start));
1425
- if (dur <= 0) return;
1426
- }
1427
-
1428
- const trackStr = el.getAttribute("data-track-index");
1429
- const track = trackStr != null ? parseInt(trackStr, 10) : 0;
1430
- const compSrc =
1431
- el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
1432
- const selector = getTimelineElementSelector(el);
1433
- const sourceFile = getTimelineElementSourceFile(el);
1434
- const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
1435
- const label = getTimelineElementDisplayLabel({
1436
- id: el.id || compId || null,
1437
- label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
1438
- tag: el.tagName,
1439
- });
1440
- const identity = buildTimelineElementIdentity({
1441
- preferredId: el.id || compId || null,
1442
- label,
1443
- fallbackIndex: missing.length,
1444
- domId: el.id || undefined,
1445
- selector,
1446
- selectorIndex,
1447
- sourceFile,
1448
- });
1449
- const entry: TimelineElement = {
1450
- id: identity.id,
1451
- label,
1452
- key: identity.key,
1453
- tag: el.tagName.toLowerCase(),
1454
- start,
1455
- duration: dur,
1456
- track: isNaN(track) ? 0 : track,
1457
- domId: el.id || undefined,
1458
- selector,
1459
- selectorIndex,
1460
- sourceFile,
1461
- };
1462
- if (compSrc) {
1463
- entry.compositionSrc = compSrc;
1464
- } else {
1465
- // Inline composition — expose inner video for thumbnails
1466
- const innerVideo = el.querySelector("video[src]");
1467
- if (innerVideo) {
1468
- entry.src = innerVideo.getAttribute("src") || undefined;
1469
- entry.tag = "video";
1470
- }
1471
- }
1472
- missing.push(entry);
1473
- });
1474
-
1475
- // Patch existing elements that are missing compositionSrc
1476
- let patched = false;
1477
- const updatedEls = currentEls.map((existing) => {
1478
- if (existing.compositionSrc) return existing;
1479
- // Find the matching DOM host by element id or composition id
1480
- const host =
1481
- doc.getElementById(existing.id) ??
1482
- doc.querySelector(`[data-composition-id="${existing.id}"]`);
1483
- if (!host) return existing;
1484
- const compSrc =
1485
- host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
1486
- if (compSrc) {
1487
- patched = true;
1488
- return { ...existing, compositionSrc: compSrc };
1489
- }
1490
- return existing;
1491
- });
1492
-
1493
- if (missing.length > 0 || patched) {
1494
- // Dedup: ensure no missing element duplicates an existing one
1495
- const finalIds = new Set(updatedEls.map((e) => e.id));
1496
- const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
1497
- syncTimelineElements([...updatedEls, ...dedupedMissing]);
1498
- }
1499
- } catch (err) {
1500
- console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
1501
- }
1502
- }, [syncTimelineElements]);
1503
-
1504
- const onIframeLoad = useCallback(() => {
1505
- unmutePreviewMedia(iframeRef.current);
1506
-
1507
- let attempts = 0;
1508
- const maxAttempts = 25;
1509
-
1510
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1511
-
1512
- probeIntervalRef.current = setInterval(() => {
1513
- attempts++;
1514
- const adapter = getAdapter();
1515
- if (adapter && adapter.getDuration() > 0) {
1516
- clearInterval(probeIntervalRef.current);
1517
- adapter.pause();
1518
-
1519
- const seekTo = pendingSeekRef.current;
1520
- pendingSeekRef.current = null;
1521
- const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
1522
-
1523
- adapter.seek(startTime);
1524
- const adapterDur = adapter.getDuration();
1525
- // Cap at 7200s (2h) to guard against loop-inflated GSAP timelines
1526
- if (Number.isFinite(adapterDur) && adapterDur > 0 && adapterDur < 7200)
1527
- setDuration(adapterDur);
1528
- setCurrentTime(startTime);
1529
- if (!isRefreshingRef.current) {
1530
- setTimelineReady(true);
1531
- }
1532
- isRefreshingRef.current = false;
1533
- setIsPlaying(false);
1534
-
1535
- try {
1536
- const iframe = iframeRef.current;
1537
- const doc = iframe?.contentDocument;
1538
- const iframeWin = iframe?.contentWindow as IframeWindow | null;
1539
- if (doc && iframeWin) {
1540
- normalizePreviewViewport(doc, iframeWin);
1541
- autoHealMissingCompositionIds(doc);
1542
- attachIframeShortcutListeners();
1543
- }
1544
-
1545
- // Try reading __clipManifest if already available (fast path)
1546
- const manifest = iframeWin?.__clipManifest;
1547
- if (manifest && manifest.clips.length > 0) {
1548
- processTimelineMessage(manifest);
1549
- }
1550
- // Enrich: fill in composition hosts the manifest missed
1551
- enrichMissingCompositions();
1552
-
1553
- // Run DOM fallback if still no elements were populated
1554
- // (manifest may exist but all clips filtered out by parentCompositionId logic)
1555
- if (usePlayerStore.getState().elements.length === 0 && doc) {
1556
- // Fallback: parse data-start elements directly from DOM (raw HTML without runtime)
1557
- const els = parseTimelineFromDOM(doc, adapter.getDuration());
1558
- if (els.length > 0) {
1559
- syncTimelineElements(els);
1560
- }
1561
- }
1562
-
1563
- // Final fallback for standalone composition previews: if still no
1564
- // elements, build timeline entries from the DOM inside the root
1565
- // composition. This ensures the timeline always shows content when
1566
- // viewing a single composition (where elements lack data-start).
1567
- if (usePlayerStore.getState().elements.length === 0 && doc) {
1568
- const rootComp = doc.querySelector("[data-composition-id]");
1569
- const rootDuration = adapter.getDuration();
1570
- if (rootComp && rootDuration > 0) {
1571
- const fallbackElement = buildStandaloneRootTimelineElement({
1572
- compositionId: rootComp.getAttribute("data-composition-id") || "composition",
1573
- tagName: (rootComp as HTMLElement).tagName || "div",
1574
- rootDuration,
1575
- iframeSrc: iframe?.src || "",
1576
- selector: getTimelineElementSelector(rootComp),
1577
- });
1578
- if (fallbackElement) {
1579
- // Always show the root composition as a single clip — guarantees
1580
- // the timeline is never empty when a valid composition is loaded.
1581
- syncTimelineElements([fallbackElement]);
1582
- }
1583
- }
1584
- }
1585
- // The runtime will also postMessage the full timeline after all compositions load.
1586
- // That message is handled by the window listener below, which will update elements
1587
- // with the complete data (including async-loaded compositions).
1588
- } catch (err) {
1589
- console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
1590
- }
330
+ const { playbackKeyDownRef, playbackKeyUpRef, attachIframeShortcutListeners, togglePlay } =
331
+ usePlaybackKeyboard({
332
+ iframeRef,
333
+ shuttleDirectionRef,
334
+ shuttleSpeedIndexRef,
335
+ iframeShortcutCleanupRef,
336
+ getAdapter,
337
+ play,
338
+ playBackward,
339
+ pause,
340
+ seek,
341
+ });
1591
342
 
1592
- return;
1593
- }
1594
- if (attempts >= maxAttempts) {
1595
- clearInterval(probeIntervalRef.current);
1596
- console.warn("Could not find __player, __timeline, or __timelines on iframe after 5s");
1597
- }
1598
- }, 200);
1599
- // eslint-disable-next-line react-hooks/exhaustive-deps
1600
- }, [
1601
- getAdapter,
1602
- setDuration,
1603
- setCurrentTime,
1604
- setTimelineReady,
1605
- setIsPlaying,
1606
- processTimelineMessage,
1607
- enrichMissingCompositions,
1608
- syncTimelineElements,
1609
- attachIframeShortcutListeners,
1610
- ]);
343
+ const { processTimelineMessageRef, enrichMissingCompositionsRef, onIframeLoad } =
344
+ useTimelineSyncCallbacks({
345
+ iframeRef,
346
+ probeIntervalRef,
347
+ pendingSeekRef,
348
+ isRefreshingRef,
349
+ getAdapter,
350
+ syncTimelineElements,
351
+ setDuration,
352
+ setCurrentTime,
353
+ setTimelineReady,
354
+ setIsPlaying,
355
+ attachIframeShortcutListeners,
356
+ });
1611
357
 
1612
- /** Save the current playback time so the next onIframeLoad restores it. */
1613
358
  const saveSeekPosition = useCallback(() => {
1614
359
  const adapter = getAdapter();
1615
360
  pendingSeekRef.current = adapter
@@ -1621,9 +366,6 @@ export function useTimelinePlayer() {
1621
366
  setIsPlaying(false);
1622
367
  }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
1623
368
 
1624
- const togglePlayRef = useRef(togglePlay);
1625
- togglePlayRef.current = togglePlay;
1626
-
1627
369
  const refreshPlayer = useCallback(() => {
1628
370
  const iframe = iframeRef.current;
1629
371
  if (!iframe) return;
@@ -1638,10 +380,6 @@ export function useTimelinePlayer() {
1638
380
 
1639
381
  const getAdapterRef = useRef(getAdapter);
1640
382
  getAdapterRef.current = getAdapter;
1641
- const processTimelineMessageRef = useRef(processTimelineMessage);
1642
- processTimelineMessageRef.current = processTimelineMessage;
1643
- const enrichMissingCompositionsRef = useRef(enrichMissingCompositions);
1644
- enrichMissingCompositionsRef.current = enrichMissingCompositions;
1645
383
 
1646
384
  useMountEffect(() => {
1647
385
  const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
@@ -1657,9 +395,7 @@ export function useTimelinePlayer() {
1657
395
  if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
1658
396
  return;
1659
397
  }
1660
- // Also handle the runtime's state message which includes timeline data
1661
398
  if (data?.source === "hf-preview" && data?.type === "state") {
1662
- // State message means the runtime is alive — check for elements
1663
399
  try {
1664
400
  if (usePlayerStore.getState().elements.length === 0) {
1665
401
  const iframeWin = ourIframe?.contentWindow as IframeWindow | null;
@@ -1668,24 +404,22 @@ export function useTimelinePlayer() {
1668
404
  processTimelineMessageRef.current(manifest);
1669
405
  }
1670
406
  }
1671
- // Always try to enrich timelines may have registered since the last check
1672
- enrichMissingCompositionsRef.current();
407
+ // Enrich only when the timeline has settled skip during the window
408
+ // right after a "timeline" message to avoid the enrichment adding
409
+ // elements that fight with the manifest's authoritative element list,
410
+ // causing duration oscillation.
411
+ const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
412
+ if (msSinceTimeline > 500) {
413
+ enrichMissingCompositionsRef.current();
414
+ }
1673
415
  } catch (err) {
1674
416
  console.warn("[useTimelinePlayer] Could not read clip manifest from iframe", err);
1675
417
  }
1676
418
  }
1677
419
  if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
420
+ lastTimelineMessageRef.current = Date.now();
1678
421
  processTimelineMessageRef.current(data);
1679
- // Fill in composition hosts the manifest missed (element-reference starts)
1680
422
  enrichMissingCompositionsRef.current();
1681
- if (data.durationInFrames > 0 && Number.isFinite(data.durationInFrames)) {
1682
- const fps = 30;
1683
- const dur = data.durationInFrames / fps;
1684
- if (dur > 0 && dur < 7200) {
1685
- usePlayerStore.getState().setDuration(dur);
1686
- }
1687
- }
1688
- // If manifest produced 0 elements after filtering, try DOM fallback
1689
423
  if (usePlayerStore.getState().elements.length === 0) {
1690
424
  try {
1691
425
  const doc = ourIframe?.contentDocument;
@@ -1706,7 +440,7 @@ export function useTimelinePlayer() {
1706
440
  }
1707
441
  };
1708
442
 
1709
- // Pause video when tab loses focus (user switches away)
443
+ // Pause video when tab loses focus
1710
444
  const handleVisibilityChange = () => {
1711
445
  if (document.hidden && usePlayerStore.getState().isPlaying) {
1712
446
  const adapter = getAdapterRef.current?.();