@hyperframes/studio 0.6.0 → 0.6.2

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 (58) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-hYc4aP7M.js +117 -0
  3. package/dist/index.html +1 -1
  4. package/package.json +4 -4
  5. package/src/App.tsx +2 -13
  6. package/src/captions/components/CaptionOverlay.tsx +13 -246
  7. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  8. package/src/components/StudioPreviewArea.tsx +6 -2
  9. package/src/components/editor/DomEditOverlay.tsx +88 -1007
  10. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  11. package/src/components/editor/FileTree.tsx +13 -621
  12. package/src/components/editor/FileTreeIcons.tsx +128 -0
  13. package/src/components/editor/FileTreeNodes.tsx +496 -0
  14. package/src/components/editor/MotionPanel.tsx +16 -390
  15. package/src/components/editor/MotionPanelFields.tsx +185 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  19. package/src/components/editor/domEditing.ts +44 -1150
  20. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  21. package/src/components/editor/domEditingDom.ts +266 -0
  22. package/src/components/editor/domEditingElement.ts +329 -0
  23. package/src/components/editor/domEditingLayers.ts +460 -0
  24. package/src/components/editor/domEditingTypes.ts +125 -0
  25. package/src/components/editor/manualEdits.ts +84 -1081
  26. package/src/components/editor/manualEditsDom.ts +436 -0
  27. package/src/components/editor/manualEditsParsing.ts +280 -0
  28. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  29. package/src/components/editor/manualEditsTypes.ts +141 -0
  30. package/src/components/editor/studioMotion.ts +47 -434
  31. package/src/components/editor/studioMotionOps.ts +299 -0
  32. package/src/components/editor/studioMotionTypes.ts +168 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  34. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  35. package/src/components/nle/NLELayout.tsx +60 -144
  36. package/src/components/nle/useCompositionStack.ts +126 -0
  37. package/src/hooks/useToast.ts +20 -0
  38. package/src/player/components/Timeline.tsx +189 -1418
  39. package/src/player/components/TimelineCanvas.tsx +434 -0
  40. package/src/player/components/TimelineEmptyState.tsx +102 -0
  41. package/src/player/components/TimelineRuler.tsx +90 -0
  42. package/src/player/components/timelineIcons.tsx +49 -0
  43. package/src/player/components/timelineLayout.ts +215 -0
  44. package/src/player/components/timelineUtils.ts +211 -0
  45. package/src/player/components/useTimelineClipDrag.ts +388 -0
  46. package/src/player/components/useTimelinePlayhead.ts +200 -0
  47. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  48. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  49. package/src/player/hooks/useTimelinePlayer.ts +69 -1372
  50. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  51. package/src/player/lib/playbackAdapter.ts +145 -0
  52. package/src/player/lib/playbackShortcuts.ts +68 -0
  53. package/src/player/lib/playbackTypes.ts +60 -0
  54. package/src/player/lib/timelineDOM.ts +373 -0
  55. package/src/player/lib/timelineElementHelpers.ts +303 -0
  56. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  57. package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
  58. package/dist/assets/index-DUqUmaoH.js +0 -117
@@ -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,7 @@ 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>(() => {});
909
56
  const lastTimelineMessageRef = useRef<number>(0);
910
57
  const staticSeekAdapterRef = useRef<{
911
58
  player: RuntimePlaybackAdapter;
@@ -1162,14 +309,6 @@ export function useTimelinePlayer() {
1162
309
  stopRAFLoop();
1163
310
  }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
1164
311
 
1165
- const togglePlay = useCallback(() => {
1166
- if (usePlayerStore.getState().isPlaying) {
1167
- pause();
1168
- } else {
1169
- play();
1170
- }
1171
- }, [play, pause]);
1172
-
1173
312
  const seek = useCallback(
1174
313
  (time: number) => {
1175
314
  stopReverseLoop();
@@ -1181,7 +320,6 @@ export function useTimelinePlayer() {
1181
320
  liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
1182
321
  setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
1183
322
  stopRAFLoop();
1184
- // Only update store if state actually changes (avoids unnecessary re-renders)
1185
323
  if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
1186
324
  shuttleDirectionRef.current = null;
1187
325
  shuttleSpeedIndexRef.current = 0;
@@ -1189,463 +327,34 @@ export function useTimelinePlayer() {
1189
327
  [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
1190
328
  );
1191
329
 
1192
- const stepFrames = useCallback(
1193
- (deltaFrames: number) => {
1194
- const adapter = getAdapter();
1195
- const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
1196
- seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
1197
- },
1198
- [getAdapter, seek],
1199
- );
1200
-
1201
- const shuttle = useCallback(
1202
- (direction: "forward" | "backward") => {
1203
- if (shuttleDirectionRef.current === direction) {
1204
- shuttleSpeedIndexRef.current = Math.min(
1205
- shuttleSpeedIndexRef.current + 1,
1206
- SHUTTLE_SPEEDS.length - 1,
1207
- );
1208
- } else {
1209
- shuttleSpeedIndexRef.current = 0;
1210
- }
1211
- const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
1212
- usePlayerStore.getState().setPlaybackRate(speed);
1213
- if (direction === "forward") {
1214
- play();
1215
- } else {
1216
- playBackward(speed);
1217
- }
1218
- },
1219
- [play, playBackward],
1220
- );
1221
-
1222
- const handlePlaybackKeyDown = useCallback(
1223
- (e: KeyboardEvent) => {
1224
- if (e.defaultPrevented) return;
1225
- const captionState = useCaptionStore.getState();
1226
- if (
1227
- shouldIgnorePlaybackShortcutEvent(e, {
1228
- isCaptionEditMode: captionState.isEditMode,
1229
- selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
1230
- })
1231
- ) {
1232
- return;
1233
- }
1234
- pressedCodesRef.current.add(e.code);
1235
- if (e.code === "Space") {
1236
- e.preventDefault();
1237
- togglePlay();
1238
- return;
1239
- }
1240
- if (e.code === "ArrowLeft") {
1241
- e.preventDefault();
1242
- stepFrames(e.shiftKey ? -10 : -1);
1243
- return;
1244
- }
1245
- if (e.code === "ArrowRight") {
1246
- e.preventDefault();
1247
- stepFrames(e.shiftKey ? 10 : 1);
1248
- return;
1249
- }
1250
- if (e.repeat) return;
1251
- if (e.code === "KeyK") {
1252
- e.preventDefault();
1253
- pause();
1254
- return;
1255
- }
1256
- if (e.code === "KeyJ") {
1257
- e.preventDefault();
1258
- if (pressedCodesRef.current.has("KeyK")) {
1259
- stepFrames(-1);
1260
- return;
1261
- }
1262
- shuttle("backward");
1263
- return;
1264
- }
1265
- if (e.code === "KeyL") {
1266
- e.preventDefault();
1267
- if (pressedCodesRef.current.has("KeyK")) {
1268
- stepFrames(1);
1269
- return;
1270
- }
1271
- shuttle("forward");
1272
- }
1273
- },
1274
- [pause, shuttle, stepFrames, togglePlay],
1275
- );
1276
-
1277
- const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
1278
- pressedCodesRef.current.delete(e.code);
1279
- }, []);
1280
- playbackKeyDownRef.current = handlePlaybackKeyDown;
1281
- playbackKeyUpRef.current = handlePlaybackKeyUp;
1282
-
1283
- const attachIframeShortcutListeners = useCallback(() => {
1284
- iframeShortcutCleanupRef.current?.();
1285
- iframeShortcutCleanupRef.current = null;
1286
-
1287
- const iframeWin = iframeRef.current?.contentWindow;
1288
- const iframeDoc = iframeRef.current?.contentDocument;
1289
- if (!iframeWin && !iframeDoc) return;
1290
-
1291
- const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
1292
- const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
1293
- iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
1294
- iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
1295
- iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
1296
- iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
1297
- iframeShortcutCleanupRef.current = () => {
1298
- iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
1299
- iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
1300
- iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
1301
- iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
1302
- };
1303
- }, []);
1304
-
1305
- // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
1306
- const processTimelineMessage = useCallback(
1307
- (data: {
1308
- clips: ClipManifestClip[];
1309
- durationInFrames: number;
1310
- scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
1311
- }) => {
1312
- if (!data.clips || data.clips.length === 0) {
1313
- return;
1314
- }
1315
-
1316
- // Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
1317
- const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
1318
- const filtered = data.clips.filter(
1319
- (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
1320
- );
1321
- let iframeDoc: Document | null = null;
1322
- try {
1323
- iframeDoc = iframeRef.current?.contentDocument ?? null;
1324
- } catch {
1325
- iframeDoc = null;
1326
- }
1327
- const usedHostEls = new Set<Element>();
1328
- const els: TimelineElement[] = filtered.map((clip, index) => {
1329
- const hostEl = iframeDoc
1330
- ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
1331
- : null;
1332
- if (hostEl) usedHostEls.add(hostEl);
1333
- return createTimelineElementFromManifestClip({
1334
- clip,
1335
- fallbackIndex: index,
1336
- doc: iframeDoc,
1337
- hostEl,
1338
- });
1339
- });
1340
- const rawDuration = data.durationInFrames / 30;
1341
- // Clamp non-finite or absurdly large durations — the runtime can emit
1342
- // Infinity when it detects a loop-inflated GSAP timeline without an
1343
- // explicit data-duration on the root composition.
1344
- const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
1345
- const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
1346
- const clampedEls =
1347
- effectiveDuration > 0
1348
- ? els
1349
- .filter((element) => element.start < effectiveDuration)
1350
- .map((element) => ({
1351
- ...element,
1352
- duration: Math.min(element.duration, effectiveDuration - element.start),
1353
- }))
1354
- .filter((element) => element.duration > 0)
1355
- : els;
1356
- const timelineEls =
1357
- iframeDoc && effectiveDuration > 0
1358
- ? [
1359
- ...clampedEls,
1360
- ...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls),
1361
- ]
1362
- : clampedEls;
1363
- if (timelineEls.length > 0) {
1364
- syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
1365
- }
1366
- },
1367
- [syncTimelineElements],
1368
- );
1369
-
1370
- /**
1371
- * Scan the iframe DOM for composition hosts missing from the current
1372
- * timeline elements and add them. The CDN runtime often fails to resolve
1373
- * element-reference starts (`data-start="intro"`) so composition hosts
1374
- * are silently dropped from `__clipManifest`. This pass reads the DOM +
1375
- * GSAP timeline registry directly to fill the gaps.
1376
- */
1377
- const enrichMissingCompositions = useCallback(() => {
1378
- try {
1379
- const iframe = iframeRef.current;
1380
- const doc = iframe?.contentDocument;
1381
- const iframeWin = iframe?.contentWindow as IframeWindow | null;
1382
- if (!doc || !iframeWin) return;
1383
-
1384
- const currentEls = usePlayerStore.getState().elements;
1385
- const existingIds = new Set(currentEls.map((e) => e.id));
1386
- const rootComp = doc.querySelector("[data-composition-id]");
1387
- const rootCompId = rootComp?.getAttribute("data-composition-id");
1388
- // Use [data-composition-id][data-start] — the composition loader strips
1389
- // data-composition-src after loading, so we can't rely on it.
1390
- const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
1391
- const missing: TimelineElement[] = [];
1392
-
1393
- hosts.forEach((host) => {
1394
- const el = host as HTMLElement;
1395
- const compId = el.getAttribute("data-composition-id");
1396
- if (!compId || compId === rootCompId) return;
1397
- if (existingIds.has(el.id) || existingIds.has(compId)) return;
1398
-
1399
- // Resolve start: numeric or element-reference
1400
- const startAttr = el.getAttribute("data-start") ?? "0";
1401
- let start = parseFloat(startAttr);
1402
- if (isNaN(start)) {
1403
- const ref =
1404
- doc.getElementById(startAttr) ||
1405
- doc.querySelector(`[data-composition-id="${startAttr}"]`);
1406
- if (ref) {
1407
- const refStartAttr = ref.getAttribute("data-start") ?? "0";
1408
- let refStart = parseFloat(refStartAttr);
1409
- // Recursively resolve one level of reference for the ref's own start
1410
- if (isNaN(refStart)) {
1411
- const refRef =
1412
- doc.getElementById(refStartAttr) ||
1413
- doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
1414
- const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
1415
- const rrCompId = refRef?.getAttribute("data-composition-id");
1416
- const rrDur =
1417
- parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
1418
- (rrCompId
1419
- ? ((
1420
- iframeWin.__timelines?.[rrCompId] as TimelineLike | undefined
1421
- )?.duration?.() ?? 0)
1422
- : 0);
1423
- refStart = rrStart + rrDur;
1424
- }
1425
- const refCompId = ref.getAttribute("data-composition-id");
1426
- const refDur =
1427
- parseFloat(ref.getAttribute("data-duration") ?? "") ||
1428
- (refCompId
1429
- ? ((iframeWin.__timelines?.[refCompId] as TimelineLike | undefined)?.duration?.() ??
1430
- 0)
1431
- : 0);
1432
- start = refStart + refDur;
1433
- } else {
1434
- start = 0;
1435
- }
1436
- }
1437
-
1438
- // Resolve duration from data-duration or GSAP timeline
1439
- let dur = parseFloat(el.getAttribute("data-duration") ?? "");
1440
- if (isNaN(dur) || dur <= 0) {
1441
- dur = (iframeWin.__timelines?.[compId] as TimelineLike | undefined)?.duration?.() ?? 0;
1442
- }
1443
- if (!Number.isFinite(dur) || dur <= 0) return;
1444
- if (!Number.isFinite(start)) start = 0;
1445
- const rootDuration = usePlayerStore.getState().duration;
1446
- if (Number.isFinite(rootDuration) && rootDuration > 0) {
1447
- if (start >= rootDuration) return;
1448
- dur = Math.min(dur, Math.max(0, rootDuration - start));
1449
- if (dur <= 0) return;
1450
- }
1451
-
1452
- const trackStr = el.getAttribute("data-track-index");
1453
- const track = trackStr != null ? parseInt(trackStr, 10) : 0;
1454
- const compSrc =
1455
- el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
1456
- const selector = getTimelineElementSelector(el);
1457
- const sourceFile = getTimelineElementSourceFile(el);
1458
- const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
1459
- const label = getTimelineElementDisplayLabel({
1460
- id: el.id || compId || null,
1461
- label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
1462
- tag: el.tagName,
1463
- });
1464
- const identity = buildTimelineElementIdentity({
1465
- preferredId: el.id || compId || null,
1466
- label,
1467
- fallbackIndex: missing.length,
1468
- domId: el.id || undefined,
1469
- selector,
1470
- selectorIndex,
1471
- sourceFile,
1472
- });
1473
- const entry: TimelineElement = {
1474
- id: identity.id,
1475
- label,
1476
- key: identity.key,
1477
- tag: el.tagName.toLowerCase(),
1478
- start,
1479
- duration: dur,
1480
- track: isNaN(track) ? 0 : track,
1481
- domId: el.id || undefined,
1482
- selector,
1483
- selectorIndex,
1484
- sourceFile,
1485
- };
1486
- if (compSrc) {
1487
- entry.compositionSrc = compSrc;
1488
- } else {
1489
- // Inline composition — expose inner video for thumbnails
1490
- const innerVideo = el.querySelector("video[src]");
1491
- if (innerVideo) {
1492
- entry.src = innerVideo.getAttribute("src") || undefined;
1493
- entry.tag = "video";
1494
- }
1495
- }
1496
- missing.push(entry);
1497
- });
1498
-
1499
- // Patch existing elements that are missing compositionSrc
1500
- let patched = false;
1501
- const updatedEls = currentEls.map((existing) => {
1502
- if (existing.compositionSrc) return existing;
1503
- // Find the matching DOM host by element id or composition id
1504
- const host =
1505
- doc.getElementById(existing.id) ??
1506
- doc.querySelector(`[data-composition-id="${existing.id}"]`);
1507
- if (!host) return existing;
1508
- const compSrc =
1509
- host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
1510
- if (compSrc) {
1511
- patched = true;
1512
- return { ...existing, compositionSrc: compSrc };
1513
- }
1514
- return existing;
1515
- });
1516
-
1517
- if (missing.length > 0 || patched) {
1518
- // Dedup: ensure no missing element duplicates an existing one
1519
- const finalIds = new Set(updatedEls.map((e) => e.id));
1520
- const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
1521
- syncTimelineElements([...updatedEls, ...dedupedMissing]);
1522
- }
1523
- } catch (err) {
1524
- console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
1525
- }
1526
- }, [syncTimelineElements]);
1527
-
1528
- const initializeAdapter = useCallback(() => {
1529
- const adapter = getAdapter();
1530
- if (!adapter || adapter.getDuration() <= 0) return false;
1531
-
1532
- adapter.pause();
1533
- const seekTo = pendingSeekRef.current;
1534
- pendingSeekRef.current = null;
1535
- const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
1536
-
1537
- adapter.seek(startTime);
1538
- const adapterDur = adapter.getDuration();
1539
- if (
1540
- Number.isFinite(adapterDur) &&
1541
- adapterDur > 0 &&
1542
- adapterDur < 7200 &&
1543
- adapterDur !== usePlayerStore.getState().duration
1544
- ) {
1545
- setDuration(adapterDur);
1546
- }
1547
- setCurrentTime(startTime);
1548
- if (!isRefreshingRef.current) {
1549
- setTimelineReady(true);
1550
- }
1551
- isRefreshingRef.current = false;
1552
- setIsPlaying(false);
1553
-
1554
- try {
1555
- const iframe = iframeRef.current;
1556
- const doc = iframe?.contentDocument;
1557
- const iframeWin = iframe?.contentWindow as IframeWindow | null;
1558
- if (doc && iframeWin) {
1559
- normalizePreviewViewport(doc, iframeWin);
1560
- autoHealMissingCompositionIds(doc);
1561
- attachIframeShortcutListeners();
1562
- }
1563
-
1564
- const manifest = iframeWin?.__clipManifest;
1565
- if (manifest && manifest.clips.length > 0) {
1566
- processTimelineMessage(manifest);
1567
- }
1568
- enrichMissingCompositions();
1569
-
1570
- if (usePlayerStore.getState().elements.length === 0 && doc) {
1571
- const els = parseTimelineFromDOM(doc, adapter.getDuration());
1572
- if (els.length > 0) syncTimelineElements(els);
1573
- }
1574
- if (usePlayerStore.getState().elements.length === 0 && doc) {
1575
- const rootComp = doc.querySelector("[data-composition-id]");
1576
- const rootDuration = adapter.getDuration();
1577
- if (rootComp && rootDuration > 0) {
1578
- const fallbackElement = buildStandaloneRootTimelineElement({
1579
- compositionId: rootComp.getAttribute("data-composition-id") || "composition",
1580
- tagName: (rootComp as HTMLElement).tagName || "div",
1581
- rootDuration,
1582
- iframeSrc: iframe?.src || "",
1583
- selector: getTimelineElementSelector(rootComp),
1584
- });
1585
- if (fallbackElement) syncTimelineElements([fallbackElement]);
1586
- }
1587
- }
1588
- } catch (err) {
1589
- console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
1590
- }
1591
- return true;
1592
- }, [
1593
- getAdapter,
1594
- setDuration,
1595
- setCurrentTime,
1596
- setTimelineReady,
1597
- setIsPlaying,
1598
- processTimelineMessage,
1599
- enrichMissingCompositions,
1600
- syncTimelineElements,
1601
- attachIframeShortcutListeners,
1602
- ]);
1603
-
1604
- const onIframeLoad = useCallback(() => {
1605
- unmutePreviewMedia(iframeRef.current);
1606
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1607
-
1608
- // Fast path: adapter already available (in-place reloads, cached compositions)
1609
- if (initializeAdapter()) return;
1610
-
1611
- // The runtime posts "state" or "timeline" messages once ready.
1612
- // Listen for those instead of polling. Use a short-lived message
1613
- // listener that fires initializeAdapter on the first signal.
1614
- const iframe = iframeRef.current;
1615
- let settled = false;
1616
-
1617
- const trySettle = () => {
1618
- if (settled) return;
1619
- if (initializeAdapter()) {
1620
- settled = true;
1621
- window.removeEventListener("message", onMessage);
1622
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1623
- }
1624
- };
1625
-
1626
- const onMessage = (e: MessageEvent) => {
1627
- if (e.source && iframe && e.source !== iframe.contentWindow) return;
1628
- const data = e.data;
1629
- if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
1630
- trySettle();
1631
- }
1632
- };
1633
- window.addEventListener("message", onMessage);
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
+ });
1634
342
 
1635
- // Safety net: if no message arrives within 5s, try one last time then give up.
1636
- // This replaces the old 25×200ms polling loop with a single delayed check.
1637
- probeIntervalRef.current = setTimeout(() => {
1638
- if (!settled) {
1639
- trySettle();
1640
- if (!settled) {
1641
- console.warn("[useTimelinePlayer] Runtime did not signal readiness within 5s");
1642
- }
1643
- }
1644
- window.removeEventListener("message", onMessage);
1645
- }, 5000) as unknown as ReturnType<typeof setInterval>;
1646
- }, [initializeAdapter]);
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
+ });
1647
357
 
1648
- /** Save the current playback time so the next onIframeLoad restores it. */
1649
358
  const saveSeekPosition = useCallback(() => {
1650
359
  const adapter = getAdapter();
1651
360
  pendingSeekRef.current = adapter
@@ -1657,9 +366,6 @@ export function useTimelinePlayer() {
1657
366
  setIsPlaying(false);
1658
367
  }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
1659
368
 
1660
- const togglePlayRef = useRef(togglePlay);
1661
- togglePlayRef.current = togglePlay;
1662
-
1663
369
  const refreshPlayer = useCallback(() => {
1664
370
  const iframe = iframeRef.current;
1665
371
  if (!iframe) return;
@@ -1674,10 +380,6 @@ export function useTimelinePlayer() {
1674
380
 
1675
381
  const getAdapterRef = useRef(getAdapter);
1676
382
  getAdapterRef.current = getAdapter;
1677
- const processTimelineMessageRef = useRef(processTimelineMessage);
1678
- processTimelineMessageRef.current = processTimelineMessage;
1679
- const enrichMissingCompositionsRef = useRef(enrichMissingCompositions);
1680
- enrichMissingCompositionsRef.current = enrichMissingCompositions;
1681
383
 
1682
384
  useMountEffect(() => {
1683
385
  const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
@@ -1693,9 +395,7 @@ export function useTimelinePlayer() {
1693
395
  if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
1694
396
  return;
1695
397
  }
1696
- // Also handle the runtime's state message which includes timeline data
1697
398
  if (data?.source === "hf-preview" && data?.type === "state") {
1698
- // State message means the runtime is alive — check for elements
1699
399
  try {
1700
400
  if (usePlayerStore.getState().elements.length === 0) {
1701
401
  const iframeWin = ourIframe?.contentWindow as IframeWindow | null;
@@ -1707,8 +407,7 @@ export function useTimelinePlayer() {
1707
407
  // Enrich only when the timeline has settled — skip during the window
1708
408
  // right after a "timeline" message to avoid the enrichment adding
1709
409
  // elements that fight with the manifest's authoritative element list,
1710
- // causing duration oscillation (the merge function alternates between
1711
- // REPLACE and PRESERVE when element counts fluctuate).
410
+ // causing duration oscillation.
1712
411
  const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
1713
412
  if (msSinceTimeline > 500) {
1714
413
  enrichMissingCompositionsRef.current();
@@ -1720,9 +419,7 @@ export function useTimelinePlayer() {
1720
419
  if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
1721
420
  lastTimelineMessageRef.current = Date.now();
1722
421
  processTimelineMessageRef.current(data);
1723
- // Fill in composition hosts the manifest missed (element-reference starts)
1724
422
  enrichMissingCompositionsRef.current();
1725
- // If manifest produced 0 elements after filtering, try DOM fallback
1726
423
  if (usePlayerStore.getState().elements.length === 0) {
1727
424
  try {
1728
425
  const doc = ourIframe?.contentDocument;
@@ -1743,7 +440,7 @@ export function useTimelinePlayer() {
1743
440
  }
1744
441
  };
1745
442
 
1746
- // Pause video when tab loses focus (user switches away)
443
+ // Pause video when tab loses focus
1747
444
  const handleVisibilityChange = () => {
1748
445
  if (document.hidden && usePlayerStore.getState().isPlaying) {
1749
446
  const adapter = getAdapterRef.current?.();