@hyperframes/studio 0.5.7 → 0.6.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/assets/index-14zH9lqh.css +1 -0
  2. package/dist/assets/index-B-16fRnH.js +108 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +2965 -186
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  8. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  9. package/src/components/editor/MotionPanel.tsx +651 -0
  10. package/src/components/editor/PropertyPanel.test.ts +116 -0
  11. package/src/components/editor/PropertyPanel.tsx +2829 -205
  12. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  13. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  14. package/src/components/editor/colorValue.test.ts +82 -0
  15. package/src/components/editor/colorValue.ts +175 -0
  16. package/src/components/editor/domEditing.test.ts +1120 -0
  17. package/src/components/editor/domEditing.ts +1117 -0
  18. package/src/components/editor/floatingPanel.test.ts +34 -0
  19. package/src/components/editor/floatingPanel.ts +54 -0
  20. package/src/components/editor/fontAssets.ts +32 -0
  21. package/src/components/editor/fontCatalog.ts +126 -0
  22. package/src/components/editor/gradientValue.test.ts +89 -0
  23. package/src/components/editor/gradientValue.ts +445 -0
  24. package/src/components/editor/manualEditingAvailability.test.ts +131 -0
  25. package/src/components/editor/manualEditingAvailability.ts +62 -0
  26. package/src/components/editor/manualEdits.test.ts +945 -0
  27. package/src/components/editor/manualEdits.ts +1409 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  29. package/src/components/editor/manualOffsetDrag.ts +307 -0
  30. package/src/components/editor/studioMotion.test.ts +355 -0
  31. package/src/components/editor/studioMotion.ts +632 -0
  32. package/src/components/nle/NLELayout.test.ts +12 -0
  33. package/src/components/nle/NLELayout.tsx +84 -22
  34. package/src/components/nle/NLEPreview.tsx +56 -5
  35. package/src/components/renders/RenderQueue.tsx +24 -11
  36. package/src/components/sidebar/AssetsTab.tsx +3 -4
  37. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  38. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  39. package/src/components/sidebar/LeftSidebar.tsx +194 -179
  40. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  41. package/src/hooks/usePersistentEditHistory.ts +337 -0
  42. package/src/icons/SystemIcons.tsx +2 -0
  43. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  44. package/src/player/components/CompositionThumbnail.tsx +50 -13
  45. package/src/player/components/EditModal.tsx +5 -20
  46. package/src/player/components/Player.test.ts +58 -0
  47. package/src/player/components/Player.tsx +88 -5
  48. package/src/player/components/PlayerControls.tsx +20 -7
  49. package/src/player/components/Timeline.test.ts +20 -0
  50. package/src/player/components/Timeline.tsx +147 -40
  51. package/src/player/components/TimelineClip.test.ts +92 -0
  52. package/src/player/components/TimelineClip.tsx +241 -7
  53. package/src/player/components/timelineEditing.test.ts +16 -3
  54. package/src/player/components/timelineEditing.ts +10 -3
  55. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  56. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  57. package/src/player/store/playerStore.ts +2 -0
  58. package/src/utils/clipboard.test.ts +89 -0
  59. package/src/utils/clipboard.ts +57 -0
  60. package/src/utils/editHistory.test.ts +244 -0
  61. package/src/utils/editHistory.ts +218 -0
  62. package/src/utils/editHistoryStorage.test.ts +37 -0
  63. package/src/utils/editHistoryStorage.ts +99 -0
  64. package/src/utils/mediaTypes.ts +1 -1
  65. package/src/utils/sourcePatcher.test.ts +128 -1
  66. package/src/utils/sourcePatcher.ts +130 -18
  67. package/src/utils/studioFileHistory.test.ts +156 -0
  68. package/src/utils/studioFileHistory.ts +61 -0
  69. package/src/utils/timelineAssetDrop.test.ts +31 -11
  70. package/src/utils/timelineAssetDrop.ts +22 -2
  71. package/src/utils/timelineDiscovery.ts +1 -1
  72. package/src/utils/timelineInspector.test.ts +79 -0
  73. package/src/utils/timelineInspector.ts +116 -0
  74. package/dist/assets/index-04Mp2wOn.css +0 -1
  75. package/dist/assets/index-Dcw3BoVw.js +0 -93
@@ -51,18 +51,28 @@ interface NLELayoutProps {
51
51
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
52
52
  ) => Promise<void> | void;
53
53
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
54
+ onSelectTimelineElement?: (element: TimelineElement | null) => void;
55
+ onInspectTimelineElement?: (element: TimelineElement) => void;
56
+ inspectedTimelineElementId?: string | null;
57
+ timelineLayerChildCounts?: ReadonlyMap<string, number>;
54
58
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
55
59
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
56
60
  /** Whether the timeline panel is visible (default: true) */
57
61
  timelineVisible?: boolean;
58
62
  /** Callback to toggle timeline visibility */
59
63
  onToggleTimeline?: () => void;
64
+ /** Notifies parent when composition loading state changes */
65
+ onCompositionLoadingChange?: (loading: boolean) => void;
60
66
  }
61
67
 
62
68
  const MIN_TIMELINE_H = 100;
63
69
  const DEFAULT_TIMELINE_H = 220;
64
70
  const MIN_PREVIEW_H = 120;
65
71
 
72
+ export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
73
+ return compositionLoading;
74
+ }
75
+
66
76
  export const NLELayout = memo(function NLELayout({
67
77
  projectId,
68
78
  portrait,
@@ -80,16 +90,20 @@ export const NLELayout = memo(function NLELayout({
80
90
  onMoveElement,
81
91
  onResizeElement,
82
92
  onBlockedEditAttempt,
93
+ onSelectTimelineElement,
94
+ onInspectTimelineElement,
95
+ inspectedTimelineElementId,
96
+ timelineLayerChildCounts,
83
97
  onCompIdToSrcChange,
84
98
  timelineVisible,
85
99
  onToggleTimeline,
100
+ onCompositionLoadingChange: onCompositionLoadingChangeParent,
86
101
  }: NLELayoutProps) {
87
102
  const {
88
103
  iframeRef,
89
104
  togglePlay,
90
105
  seek,
91
106
  onIframeLoad: baseOnIframeLoad,
92
- refreshPlayer,
93
107
  saveSeekPosition,
94
108
  } = useTimelinePlayer();
95
109
 
@@ -103,13 +117,15 @@ export const NLELayout = memo(function NLELayout({
103
117
  usePlayerStore.getState().reset();
104
118
  }
105
119
 
106
- // Refresh the existing iframe in place when source files change.
120
+ // Save seek position before the Player component creates a new player
121
+ // on refreshKey change. The Player handles the actual reload via the
122
+ // dual-player crossfade; we just need to persist the current time.
107
123
  const prevRefreshKeyRef = useRef(refreshKey);
108
124
  useEffect(() => {
109
125
  if (refreshKey === prevRefreshKeyRef.current) return;
110
126
  prevRefreshKeyRef.current = refreshKey;
111
- refreshPlayer();
112
- }, [refreshKey, refreshPlayer]);
127
+ saveSeekPosition();
128
+ }, [refreshKey, saveSeekPosition]);
113
129
 
114
130
  // Wrap onIframeLoad to also notify parent of iframe ref
115
131
  const onIframeLoad = useCallback(() => {
@@ -201,6 +217,18 @@ export const NLELayout = memo(function NLELayout({
201
217
 
202
218
  // Resizable timeline height
203
219
  const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
220
+ const hasLoadedOnceRef = useRef(false);
221
+ const [compositionLoading, setCompositionLoadingRaw] = useState(true);
222
+ const setCompositionLoading = useCallback((loading: boolean) => {
223
+ if (!loading) hasLoadedOnceRef.current = true;
224
+ if (loading && hasLoadedOnceRef.current) return;
225
+ setCompositionLoadingRaw(loading);
226
+ }, []);
227
+ const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
228
+
229
+ useEffect(() => {
230
+ onCompositionLoadingChangeParent?.(compositionLoading);
231
+ }, [compositionLoading, onCompositionLoadingChangeParent]);
204
232
  const isTimelineVisible = timelineVisible ?? true;
205
233
  const isDragging = useRef(false);
206
234
  const containerRef = useRef<HTMLDivElement>(null);
@@ -209,6 +237,10 @@ export const NLELayout = memo(function NLELayout({
209
237
  const currentLevel = compositionStack[compositionStack.length - 1];
210
238
  const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
211
239
 
240
+ useEffect(() => {
241
+ onIframeRef?.(iframeRef.current);
242
+ }, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
243
+
212
244
  // Save master seek position before drilling down so we can restore it on back-navigation.
213
245
  // saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
214
246
  const masterSeekRef = useRef(0);
@@ -310,23 +342,31 @@ export const NLELayout = memo(function NLELayout({
310
342
  }, [activeCompositionPath, projectId, updateCompositionStack]);
311
343
 
312
344
  // Resize divider handlers
313
- const handleDividerPointerDown = useCallback((e: React.PointerEvent) => {
314
- e.preventDefault();
315
- isDragging.current = true;
316
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
317
- }, []);
345
+ const handleDividerPointerDown = useCallback(
346
+ (e: React.PointerEvent) => {
347
+ if (timelineDisabled) return;
348
+ e.preventDefault();
349
+ isDragging.current = true;
350
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
351
+ },
352
+ [timelineDisabled],
353
+ );
318
354
 
319
- const handleDividerPointerMove = useCallback((e: React.PointerEvent) => {
320
- if (!isDragging.current || !containerRef.current) return;
321
- const rect = containerRef.current.getBoundingClientRect();
322
- const mouseY = e.clientY - rect.top;
323
- const containerH = rect.height;
324
- const newTimelineH = Math.max(
325
- MIN_TIMELINE_H,
326
- Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
327
- );
328
- setTimelineH(newTimelineH);
329
- }, []);
355
+ const handleDividerPointerMove = useCallback(
356
+ (e: React.PointerEvent) => {
357
+ if (timelineDisabled) return;
358
+ if (!isDragging.current || !containerRef.current) return;
359
+ const rect = containerRef.current.getBoundingClientRect();
360
+ const mouseY = e.clientY - rect.top;
361
+ const containerH = rect.height;
362
+ const newTimelineH = Math.max(
363
+ MIN_TIMELINE_H,
364
+ Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
365
+ );
366
+ setTimelineH(newTimelineH);
367
+ },
368
+ [timelineDisabled],
369
+ );
330
370
 
331
371
  const handleDividerPointerUp = useCallback(() => {
332
372
  isDragging.current = false;
@@ -357,9 +397,11 @@ export const NLELayout = memo(function NLELayout({
357
397
  projectId={projectId}
358
398
  iframeRef={iframeRef}
359
399
  onIframeLoad={onIframeLoad}
400
+ onCompositionLoadingChange={setCompositionLoading}
360
401
  portrait={portrait}
361
402
  directUrl={directUrl}
362
403
  refreshKey={refreshKey}
404
+ suppressLoadingOverlay={hasLoadedOnceRef.current}
363
405
  />
364
406
  {previewOverlay}
365
407
  </div>
@@ -371,7 +413,7 @@ export const NLELayout = memo(function NLELayout({
371
413
  onNavigate={handleNavigateComposition}
372
414
  />
373
415
  )}
374
- <PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
416
+ <PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
375
417
  </div>
376
418
  </div>
377
419
 
@@ -389,13 +431,18 @@ export const NLELayout = memo(function NLELayout({
389
431
  </div>
390
432
 
391
433
  {/* Timeline section — fixed height, resizable */}
392
- <div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
434
+ <div
435
+ className="relative flex flex-col flex-shrink-0"
436
+ style={{ height: timelineH }}
437
+ aria-disabled={timelineDisabled || undefined}
438
+ >
393
439
  {/* Timeline tracks */}
394
440
  <div
395
441
  // flex-col: toolbar takes natural height, Timeline fills remainder.
396
442
  className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
397
443
  onDoubleClick={(e) => {
398
444
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
445
+ if (timelineDisabled) return;
399
446
  if (compositionStack.length > 1) {
400
447
  updateCompositionStack((prev) => prev.slice(0, -1));
401
448
  }
@@ -412,9 +459,24 @@ export const NLELayout = memo(function NLELayout({
412
459
  onMoveElement={onMoveElement}
413
460
  onResizeElement={onResizeElement}
414
461
  onBlockedEditAttempt={onBlockedEditAttempt}
462
+ onSelectElement={onSelectTimelineElement}
463
+ onInspectElement={onInspectTimelineElement}
464
+ inspectedElementId={inspectedTimelineElementId}
465
+ layerChildCounts={timelineLayerChildCounts}
466
+ disabled={timelineDisabled}
415
467
  />
416
468
  </div>
417
469
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
470
+ {timelineDisabled && (
471
+ <div
472
+ className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
473
+ data-testid="timeline-loading-disabled-overlay"
474
+ aria-hidden="true"
475
+ onPointerDown={(event) => event.preventDefault()}
476
+ onDragOver={(event) => event.preventDefault()}
477
+ onDrop={(event) => event.preventDefault()}
478
+ />
479
+ )}
418
480
  </div>
419
481
  </>
420
482
  ) : onToggleTimeline ? (
@@ -1,13 +1,15 @@
1
- import { memo, type Ref } from "react";
1
+ import { memo, useRef, useState, type Ref } from "react";
2
2
  import { Player } from "../../player";
3
3
 
4
4
  interface NLEPreviewProps {
5
5
  projectId: string;
6
6
  iframeRef: Ref<HTMLIFrameElement>;
7
7
  onIframeLoad: () => void;
8
+ onCompositionLoadingChange?: (loading: boolean) => void;
8
9
  portrait?: boolean;
9
10
  directUrl?: string;
10
11
  refreshKey?: number;
12
+ suppressLoadingOverlay?: boolean;
11
13
  }
12
14
 
13
15
  export function getPreviewPlayerKey({
@@ -21,30 +23,79 @@ export function getPreviewPlayerKey({
21
23
  return directUrl ?? projectId;
22
24
  }
23
25
 
26
+ /**
27
+ * Manages the composition preview with crossfade on reload.
28
+ *
29
+ * When refreshKey changes, a new Player is mounted alongside the old one.
30
+ * The old Player stays visible (opacity 1) until the new one fires onLoad,
31
+ * at which point the old is removed. This avoids the flash that a simple
32
+ * key-swap remount would cause.
33
+ *
34
+ * Uses the render-time state adjustment pattern (React-sanctioned) to detect
35
+ * refreshKey changes — no useEffect needed.
36
+ */
24
37
  export const NLEPreview = memo(function NLEPreview({
25
38
  projectId,
26
39
  iframeRef,
27
40
  onIframeLoad,
41
+ onCompositionLoadingChange,
28
42
  portrait,
29
43
  directUrl,
30
44
  refreshKey,
45
+ suppressLoadingOverlay,
31
46
  }: NLEPreviewProps) {
32
- const playerKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
47
+ const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
48
+ const prevRefreshKeyRef = useRef(refreshKey);
49
+ const [retiringKey, setRetiringKey] = useState<string | null>(null);
50
+ const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
51
+
52
+ // Detect refreshKey change during render (React-sanctioned derived state pattern).
53
+ // When the key changes, the current active player becomes the retiring player
54
+ // and a new active player is mounted alongside it.
55
+ if (refreshKey !== prevRefreshKeyRef.current) {
56
+ const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
57
+ prevRefreshKeyRef.current = refreshKey;
58
+ setRetiringKey(oldKey);
59
+ }
60
+
61
+ const activeKey = `${baseKey}:${refreshKey ?? 0}`;
62
+
63
+ const handleNewPlayerLoad = () => {
64
+ onIframeLoad();
65
+ if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
66
+ retiringTimerRef.current = setTimeout(() => {
67
+ setRetiringKey(null);
68
+ retiringTimerRef.current = null;
69
+ }, 160);
70
+ };
33
71
 
34
72
  return (
35
73
  <div className="flex flex-col h-full min-h-0">
36
74
  <div
37
- className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
75
+ className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
38
76
  tabIndex={0}
39
77
  aria-label="Composition preview"
40
78
  >
79
+ {retiringKey && (
80
+ <Player
81
+ key={retiringKey}
82
+ projectId={directUrl ? undefined : projectId}
83
+ directUrl={directUrl}
84
+ onLoad={() => {}}
85
+ portrait={portrait}
86
+ style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
87
+ />
88
+ )}
41
89
  <Player
42
- key={playerKey}
90
+ key={activeKey}
43
91
  ref={iframeRef}
44
92
  projectId={directUrl ? undefined : projectId}
45
93
  directUrl={directUrl}
46
- onLoad={onIframeLoad}
94
+ onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
95
+ onCompositionLoadingChange={onCompositionLoadingChange}
47
96
  portrait={portrait}
97
+ style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
98
+ suppressLoadingOverlay={suppressLoadingOverlay}
48
99
  />
49
100
  </div>
50
101
  </div>
@@ -2,16 +2,19 @@ import { memo, useState, useRef, useEffect } from "react";
2
2
  import { RenderQueueItem } from "./RenderQueueItem";
3
3
  import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
4
4
 
5
+ type StartRenderHandler = (
6
+ format: "mp4" | "webm" | "mov",
7
+ quality: "draft" | "standard" | "high",
8
+ resolution: ResolutionPreset | "auto",
9
+ fps: 24 | 30 | 60,
10
+ ) => void | Promise<void>;
11
+
5
12
  interface RenderQueueProps {
6
13
  jobs: RenderJob[];
7
14
  projectId: string;
8
15
  onDelete: (jobId: string) => void;
9
16
  onClearCompleted: () => void;
10
- onStartRender: (
11
- format: "mp4" | "webm" | "mov",
12
- quality: "draft" | "standard" | "high",
13
- resolution: ResolutionPreset | "auto",
14
- ) => void;
17
+ onStartRender: StartRenderHandler;
15
18
  isRendering: boolean;
16
19
  }
17
20
 
@@ -125,16 +128,13 @@ function FormatExportButton({
125
128
  onStartRender,
126
129
  isRendering,
127
130
  }: {
128
- onStartRender: (
129
- format: "mp4" | "webm" | "mov",
130
- quality: "draft" | "standard" | "high",
131
- resolution: ResolutionPreset | "auto",
132
- ) => void;
131
+ onStartRender: StartRenderHandler;
133
132
  isRendering: boolean;
134
133
  }) {
135
134
  const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
136
135
  const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
137
136
  const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
137
+ const [fps, setFps] = useState<24 | 30 | 60>(30);
138
138
 
139
139
  // MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
140
140
  const showQuality = format !== "mov";
@@ -174,6 +174,17 @@ function FormatExportButton({
174
174
  ))}
175
175
  </select>
176
176
  )}
177
+ <select
178
+ value={fps}
179
+ onChange={(e) => setFps(Number(e.target.value) as 24 | 30 | 60)}
180
+ disabled={isRendering}
181
+ title="Frames per second"
182
+ className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
183
+ >
184
+ <option value={24}>24fps</option>
185
+ <option value={30}>30fps</option>
186
+ <option value={60}>60fps</option>
187
+ </select>
177
188
  <select
178
189
  value={format}
179
190
  onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
@@ -185,7 +196,9 @@ function FormatExportButton({
185
196
  <option value="webm">WebM</option>
186
197
  </select>
187
198
  <button
188
- onClick={() => onStartRender(format, quality, resolution)}
199
+ onClick={() => {
200
+ void onStartRender(format, quality, resolution, fps);
201
+ }}
189
202
  disabled={isRendering}
190
203
  className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
191
204
  >
@@ -2,6 +2,7 @@ import { memo, useState, useCallback, useRef } from "react";
2
2
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
3
3
  import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
4
4
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
5
+ import { copyTextToClipboard } from "../../utils/clipboard";
5
6
 
6
7
  interface AssetsTabProps {
7
8
  projectId: string;
@@ -298,12 +299,10 @@ export const AssetsTab = memo(function AssetsTab({
298
299
  );
299
300
 
300
301
  const handleCopyPath = useCallback(async (path: string) => {
301
- try {
302
- await navigator.clipboard.writeText(path);
302
+ const copied = await copyTextToClipboard(path);
303
+ if (copied) {
303
304
  setCopiedPath(path);
304
305
  setTimeout(() => setCopiedPath(null), 1500);
305
- } catch {
306
- // ignore
307
306
  }
308
307
  }, []);
309
308
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { resolveCompositionPreviewScale } from "./CompositionsTab";
2
+ import { resolveCompositionPreviewScale, resolveThumbnailSeekTime } from "./CompositionsTab";
3
3
 
4
4
  describe("resolveCompositionPreviewScale", () => {
5
5
  it("scales a 16:9 stage to fit the composition card", () => {
@@ -35,3 +35,18 @@ describe("resolveCompositionPreviewScale", () => {
35
35
  ).toBeCloseTo(80 / 1920);
36
36
  });
37
37
  });
38
+
39
+ describe("resolveThumbnailSeekTime", () => {
40
+ it("uses the default 3s frame for compositions longer than 3s", () => {
41
+ expect(resolveThumbnailSeekTime(6)).toBe(3);
42
+ });
43
+
44
+ it("uses the midpoint for compositions shorter than 3s", () => {
45
+ expect(resolveThumbnailSeekTime(2)).toBe(1);
46
+ });
47
+
48
+ it("falls back to the default 3s frame when duration is unknown", () => {
49
+ expect(resolveThumbnailSeekTime(null)).toBe(3);
50
+ expect(resolveThumbnailSeekTime(Number.NaN)).toBe(3);
51
+ });
52
+ });
@@ -1,4 +1,4 @@
1
- import { memo, useRef, useState } from "react";
1
+ import { memo, useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  interface CompositionsTabProps {
4
4
  projectId: string;
@@ -8,6 +8,17 @@ interface CompositionsTabProps {
8
8
  }
9
9
 
10
10
  const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
11
+ const THUMBNAIL_SEEK_TIME_SECONDS = 3;
12
+ const THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS = 10;
13
+
14
+ type PreviewWindow = Window & {
15
+ __player?: {
16
+ play?: () => void;
17
+ pause?: () => void;
18
+ seek?: (time: number) => void;
19
+ getDuration?: () => number;
20
+ };
21
+ };
11
22
 
12
23
  export function resolveCompositionPreviewScale(input: {
13
24
  cardWidth: number;
@@ -28,6 +39,54 @@ export function resolveCompositionPreviewScale(input: {
28
39
  return Math.min(scaleX, scaleY);
29
40
  }
30
41
 
42
+ export function resolveThumbnailSeekTime(durationSeconds: number | null | undefined): number {
43
+ if (
44
+ Number.isFinite(durationSeconds) &&
45
+ durationSeconds != null &&
46
+ durationSeconds > 0 &&
47
+ durationSeconds < THUMBNAIL_SEEK_TIME_SECONDS
48
+ ) {
49
+ return durationSeconds / 2;
50
+ }
51
+
52
+ return THUMBNAIL_SEEK_TIME_SECONDS;
53
+ }
54
+
55
+ function parsePositiveNumber(value: string | null): number | null {
56
+ if (value == null) return null;
57
+ const parsed = Number.parseFloat(value);
58
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
59
+ }
60
+
61
+ function resolveIframeDuration(iframe: HTMLIFrameElement | null): number | null {
62
+ const win = iframe?.contentWindow as PreviewWindow | null;
63
+ const playerDuration = win?.__player?.getDuration?.();
64
+ if (Number.isFinite(playerDuration) && playerDuration != null && playerDuration > 0) {
65
+ return playerDuration;
66
+ }
67
+
68
+ const doc = iframe?.contentDocument;
69
+ const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null;
70
+ return (
71
+ parsePositiveNumber(root?.getAttribute("data-composition-duration") ?? null) ??
72
+ parsePositiveNumber(root?.getAttribute("data-duration") ?? null)
73
+ );
74
+ }
75
+
76
+ function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay: boolean): boolean {
77
+ const player = (iframe?.contentWindow as PreviewWindow | null)?.__player;
78
+ if (!player) return false;
79
+
80
+ if (shouldPlay) {
81
+ player.play?.();
82
+ return true;
83
+ }
84
+
85
+ player.pause?.();
86
+ player.seek?.(resolveThumbnailSeekTime(resolveIframeDuration(iframe)));
87
+ return true;
88
+ }
89
+
31
90
  function CompCard({
32
91
  projectId,
33
92
  comp,
@@ -41,7 +100,25 @@ function CompCard({
41
100
  }) {
42
101
  const [hovered, setHovered] = useState(false);
43
102
  const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
103
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
44
104
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
105
+ const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
106
+
107
+ const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => {
108
+ if (syncTimer.current) {
109
+ clearTimeout(syncTimer.current);
110
+ syncTimer.current = null;
111
+ }
112
+
113
+ const sync = (remainingAttempts: number) => {
114
+ if (syncIframePlayback(iframeRef.current, shouldPlay) || remainingAttempts <= 0) return;
115
+
116
+ syncTimer.current = setTimeout(() => sync(remainingAttempts - 1), 100);
117
+ };
118
+
119
+ sync(THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS);
120
+ }, []);
121
+
45
122
  const handleEnter = () => {
46
123
  hoverTimer.current = setTimeout(() => setHovered(true), 300);
47
124
  };
@@ -53,7 +130,6 @@ function CompCard({
53
130
  setHovered(false);
54
131
  };
55
132
  const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
56
- const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
57
133
  const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
58
134
  const previewScale = resolveCompositionPreviewScale({
59
135
  cardWidth: 80,
@@ -62,6 +138,17 @@ function CompCard({
62
138
  stageHeight: stageSize.height,
63
139
  });
64
140
 
141
+ useEffect(() => {
142
+ requestIframePlaybackSync(hovered);
143
+ }, [hovered, requestIframePlaybackSync]);
144
+
145
+ useEffect(() => {
146
+ return () => {
147
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
148
+ if (syncTimer.current) clearTimeout(syncTimer.current);
149
+ };
150
+ }, []);
151
+
65
152
  return (
66
153
  <div
67
154
  onClick={onSelect}
@@ -74,49 +161,34 @@ function CompCard({
74
161
  }`}
75
162
  >
76
163
  <div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
77
- {/* Live iframe preview on hover */}
78
- {hovered && (
79
- <iframe
80
- src={previewUrl}
81
- sandbox="allow-scripts allow-same-origin"
82
- className="absolute left-0 top-0 border-none pointer-events-none"
83
- style={{
84
- transformOrigin: "0 0",
85
- width: stageSize.width,
86
- height: stageSize.height,
87
- transform: `scale(${previewScale})`,
88
- }}
89
- onLoad={(e) => {
90
- try {
91
- const iframe = e.currentTarget;
92
- const root = iframe.contentDocument?.querySelector("[data-composition-id]");
93
- const width =
94
- Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
95
- const height =
96
- Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
97
- setStageSize({ width, height });
98
- } catch {
99
- setStageSize(DEFAULT_PREVIEW_STAGE);
100
- }
101
- }}
102
- tabIndex={-1}
103
- />
104
- )}
105
- {/* Static thumbnail — hidden while hovering */}
106
- <div
107
- className="absolute inset-0 transition-opacity duration-150"
108
- style={{ opacity: hovered ? 0 : 1 }}
109
- >
110
- <img
111
- src={thumbnailUrl}
112
- alt={name}
113
- loading="lazy"
114
- className="w-full h-full object-contain"
115
- onError={(e) => {
116
- (e.target as HTMLImageElement).style.display = "none";
117
- }}
118
- />
119
- </div>
164
+ <iframe
165
+ ref={iframeRef}
166
+ src={previewUrl}
167
+ sandbox="allow-scripts allow-same-origin"
168
+ loading="lazy"
169
+ className="absolute left-0 top-0 border-none pointer-events-none"
170
+ style={{
171
+ transformOrigin: "0 0",
172
+ width: stageSize.width,
173
+ height: stageSize.height,
174
+ transform: `scale(${previewScale})`,
175
+ }}
176
+ onLoad={(e) => {
177
+ try {
178
+ const iframe = e.currentTarget;
179
+ const root = iframe.contentDocument?.querySelector("[data-composition-id]");
180
+ const width = Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
181
+ const height =
182
+ Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
183
+ setStageSize({ width, height });
184
+ requestIframePlaybackSync(hovered);
185
+ } catch {
186
+ setStageSize(DEFAULT_PREVIEW_STAGE);
187
+ }
188
+ }}
189
+ title={`${name} preview`}
190
+ tabIndex={-1}
191
+ />
120
192
  </div>
121
193
  <div className="min-w-0 flex-1">
122
194
  <span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>