@hyperframes/studio 0.5.5 → 0.6.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
  2. package/dist/assets/index-D04_ZoMm.js +107 -0
  3. package/dist/assets/index-UWFaHilT.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2621 -170
  7. package/src/components/LintModal.tsx +3 -4
  8. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  9. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  10. package/src/components/editor/MotionPanel.tsx +651 -0
  11. package/src/components/editor/PropertyPanel.test.ts +67 -0
  12. package/src/components/editor/PropertyPanel.tsx +2891 -207
  13. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  14. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  15. package/src/components/editor/colorValue.test.ts +82 -0
  16. package/src/components/editor/colorValue.ts +175 -0
  17. package/src/components/editor/domEditing.test.ts +872 -0
  18. package/src/components/editor/domEditing.ts +993 -0
  19. package/src/components/editor/floatingPanel.test.ts +34 -0
  20. package/src/components/editor/floatingPanel.ts +54 -0
  21. package/src/components/editor/fontAssets.ts +32 -0
  22. package/src/components/editor/fontCatalog.ts +126 -0
  23. package/src/components/editor/gradientValue.test.ts +89 -0
  24. package/src/components/editor/gradientValue.ts +445 -0
  25. package/src/components/editor/manualEditingAvailability.test.ts +120 -0
  26. package/src/components/editor/manualEditingAvailability.ts +60 -0
  27. package/src/components/editor/manualEdits.test.ts +945 -0
  28. package/src/components/editor/manualEdits.ts +1397 -0
  29. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  30. package/src/components/editor/manualOffsetDrag.ts +307 -0
  31. package/src/components/editor/studioMotion.test.ts +355 -0
  32. package/src/components/editor/studioMotion.ts +632 -0
  33. package/src/components/nle/NLELayout.tsx +27 -4
  34. package/src/components/nle/NLEPreview.tsx +50 -5
  35. package/src/components/renders/RenderQueue.tsx +13 -62
  36. package/src/components/renders/useRenderQueue.ts +6 -30
  37. package/src/components/sidebar/AssetsTab.tsx +3 -4
  38. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  39. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  40. package/src/components/sidebar/LeftSidebar.tsx +140 -125
  41. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  42. package/src/hooks/usePersistentEditHistory.ts +337 -0
  43. package/src/icons/SystemIcons.tsx +2 -0
  44. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  45. package/src/player/components/CompositionThumbnail.tsx +50 -13
  46. package/src/player/components/EditModal.tsx +5 -20
  47. package/src/player/components/Player.tsx +18 -2
  48. package/src/player/components/Timeline.test.ts +20 -0
  49. package/src/player/components/Timeline.tsx +103 -21
  50. package/src/player/components/TimelineClip.test.ts +92 -0
  51. package/src/player/components/TimelineClip.tsx +241 -7
  52. package/src/player/components/timelineEditing.test.ts +16 -3
  53. package/src/player/components/timelineEditing.ts +10 -3
  54. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  55. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  56. package/src/player/store/playerStore.ts +2 -0
  57. package/src/utils/clipboard.test.ts +89 -0
  58. package/src/utils/clipboard.ts +57 -0
  59. package/src/utils/editHistory.test.ts +244 -0
  60. package/src/utils/editHistory.ts +218 -0
  61. package/src/utils/editHistoryStorage.test.ts +37 -0
  62. package/src/utils/editHistoryStorage.ts +99 -0
  63. package/src/utils/mediaTypes.ts +1 -1
  64. package/src/utils/sourcePatcher.test.ts +128 -1
  65. package/src/utils/sourcePatcher.ts +130 -18
  66. package/src/utils/studioFileHistory.test.ts +156 -0
  67. package/src/utils/studioFileHistory.ts +61 -0
  68. package/src/utils/timelineAssetDrop.test.ts +31 -11
  69. package/src/utils/timelineAssetDrop.ts +22 -2
  70. package/src/utils/timelineInspector.test.ts +79 -0
  71. package/src/utils/timelineInspector.ts +116 -0
  72. package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
  73. package/dist/assets/index-04Mp2wOn.css +0 -1
  74. package/dist/assets/index-960mgQMI.js +0 -93
@@ -1,4 +1,4 @@
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 {
@@ -21,6 +21,17 @@ export function getPreviewPlayerKey({
21
21
  return directUrl ?? projectId;
22
22
  }
23
23
 
24
+ /**
25
+ * Manages the composition preview with crossfade on reload.
26
+ *
27
+ * When refreshKey changes, a new Player is mounted alongside the old one.
28
+ * The old Player stays visible (opacity 1) until the new one fires onLoad,
29
+ * at which point the old is removed. This avoids the flash that a simple
30
+ * key-swap remount would cause.
31
+ *
32
+ * Uses the render-time state adjustment pattern (React-sanctioned) to detect
33
+ * refreshKey changes — no useEffect needed.
34
+ */
24
35
  export const NLEPreview = memo(function NLEPreview({
25
36
  projectId,
26
37
  iframeRef,
@@ -29,22 +40,56 @@ export const NLEPreview = memo(function NLEPreview({
29
40
  directUrl,
30
41
  refreshKey,
31
42
  }: NLEPreviewProps) {
32
- const playerKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
43
+ const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
44
+ const prevRefreshKeyRef = useRef(refreshKey);
45
+ const [retiringKey, setRetiringKey] = useState<string | null>(null);
46
+ const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
47
+
48
+ // Detect refreshKey change during render (React-sanctioned derived state pattern).
49
+ // When the key changes, the current active player becomes the retiring player
50
+ // and a new active player is mounted alongside it.
51
+ if (refreshKey !== prevRefreshKeyRef.current) {
52
+ const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
53
+ prevRefreshKeyRef.current = refreshKey;
54
+ setRetiringKey(oldKey);
55
+ }
56
+
57
+ const activeKey = `${baseKey}:${refreshKey ?? 0}`;
58
+
59
+ const handleNewPlayerLoad = () => {
60
+ onIframeLoad();
61
+ if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
62
+ retiringTimerRef.current = setTimeout(() => {
63
+ setRetiringKey(null);
64
+ retiringTimerRef.current = null;
65
+ }, 160);
66
+ };
33
67
 
34
68
  return (
35
69
  <div className="flex flex-col h-full min-h-0">
36
70
  <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"
71
+ 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
72
  tabIndex={0}
39
73
  aria-label="Composition preview"
40
74
  >
75
+ {retiringKey && (
76
+ <Player
77
+ key={retiringKey}
78
+ projectId={directUrl ? undefined : projectId}
79
+ directUrl={directUrl}
80
+ onLoad={() => {}}
81
+ portrait={portrait}
82
+ style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
83
+ />
84
+ )}
41
85
  <Player
42
- key={playerKey}
86
+ key={activeKey}
43
87
  ref={iframeRef}
44
88
  projectId={directUrl ? undefined : projectId}
45
89
  directUrl={directUrl}
46
- onLoad={onIframeLoad}
90
+ onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
47
91
  portrait={portrait}
92
+ style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
48
93
  />
49
94
  </div>
50
95
  </div>
@@ -1,50 +1,21 @@
1
1
  import { memo, useState, useRef, useEffect } from "react";
2
2
  import { RenderQueueItem } from "./RenderQueueItem";
3
- import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
3
+ import type { RenderJob } from "./useRenderQueue";
4
+
5
+ type StartRenderHandler = (
6
+ format: "mp4" | "webm" | "mov",
7
+ quality: "draft" | "standard" | "high",
8
+ ) => void | Promise<void>;
4
9
 
5
10
  interface RenderQueueProps {
6
11
  jobs: RenderJob[];
7
12
  projectId: string;
8
13
  onDelete: (jobId: string) => void;
9
14
  onClearCompleted: () => void;
10
- onStartRender: (
11
- format: "mp4" | "webm" | "mov",
12
- quality: "draft" | "standard" | "high",
13
- resolution: ResolutionPreset | "auto",
14
- ) => void;
15
+ onStartRender: StartRenderHandler;
15
16
  isRendering: boolean;
16
17
  }
17
18
 
18
- // Indexing the table by `ResolutionPreset | "auto"` makes adding a new preset
19
- // to `core.types` (e.g. an 8K row) a TypeScript error here instead of a
20
- // silently missing dropdown entry. Order is fixed by the array below.
21
- const RESOLUTION_LABELS: Record<ResolutionPreset | "auto", { label: string; title: string }> = {
22
- auto: { label: "Auto", title: "Render at the composition's authored resolution" },
23
- landscape: { label: "1080p ↔", title: "1920×1080 landscape" },
24
- portrait: { label: "1080p ↕", title: "1080×1920 portrait" },
25
- "landscape-4k": {
26
- label: "4K ↔",
27
- title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.",
28
- },
29
- "portrait-4k": {
30
- label: "4K ↕",
31
- title: "2160×3840 — supersamples a 1080p portrait composition via Chrome DPR.",
32
- },
33
- };
34
-
35
- const RESOLUTION_OPTION_ORDER: (ResolutionPreset | "auto")[] = [
36
- "auto",
37
- "landscape",
38
- "portrait",
39
- "landscape-4k",
40
- "portrait-4k",
41
- ];
42
-
43
- const RESOLUTION_OPTIONS = RESOLUTION_OPTION_ORDER.map((value) => ({
44
- value,
45
- ...RESOLUTION_LABELS[value],
46
- }));
47
-
48
19
  const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
49
20
  mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
50
21
  mov: {
@@ -125,16 +96,11 @@ function FormatExportButton({
125
96
  onStartRender,
126
97
  isRendering,
127
98
  }: {
128
- onStartRender: (
129
- format: "mp4" | "webm" | "mov",
130
- quality: "draft" | "standard" | "high",
131
- resolution: ResolutionPreset | "auto",
132
- ) => void;
99
+ onStartRender: StartRenderHandler;
133
100
  isRendering: boolean;
134
101
  }) {
135
102
  const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
136
103
  const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
137
- const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
138
104
 
139
105
  // MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
140
106
  const showQuality = format !== "mov";
@@ -142,30 +108,13 @@ function FormatExportButton({
142
108
  return (
143
109
  <div className="flex items-center gap-1">
144
110
  <FormatInfoTooltip format={format} />
145
- {/* Resolution must remain the leftmost <select> in this row — it
146
- carries `rounded-l` for the joined-button look. If you ever hide it
147
- (feature-flag, etc.), move `rounded-l` to whichever element ends up
148
- leftmost. */}
149
- <select
150
- value={resolution}
151
- onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
152
- disabled={isRendering}
153
- title={RESOLUTION_OPTIONS.find((r) => r.value === resolution)?.title}
154
- className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
155
- >
156
- {RESOLUTION_OPTIONS.map((r) => (
157
- <option key={r.value} value={r.value} title={r.title}>
158
- {r.label}
159
- </option>
160
- ))}
161
- </select>
162
111
  {showQuality && (
163
112
  <select
164
113
  value={quality}
165
114
  onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
166
115
  disabled={isRendering}
167
116
  title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
168
- className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
117
+ className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
169
118
  >
170
119
  {QUALITY_OPTIONS.map((q) => (
171
120
  <option key={q.value} value={q.value} title={q.title}>
@@ -178,14 +127,16 @@ function FormatExportButton({
178
127
  value={format}
179
128
  onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
180
129
  disabled={isRendering}
181
- className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
130
+ className={`h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50 ${showQuality ? "" : "rounded-l"}`}
182
131
  >
183
132
  <option value="mp4">MP4</option>
184
133
  <option value="mov">MOV</option>
185
134
  <option value="webm">WebM</option>
186
135
  </select>
187
136
  <button
188
- onClick={() => onStartRender(format, quality, resolution)}
137
+ onClick={() => {
138
+ void onStartRender(format, quality);
139
+ }}
189
140
  disabled={isRendering}
190
141
  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
142
  >
@@ -11,20 +11,6 @@ export interface RenderJob {
11
11
  durationMs?: number;
12
12
  }
13
13
 
14
- // Mirrors `CanvasResolution` from @hyperframes/core. Kept local because
15
- // studio's tsconfig doesn't include node types, and the core barrel
16
- // transitively pulls in modules with `node:fs` imports. Drift risk is
17
- // low (4 string literals tied to a stable enum).
18
- export type ResolutionPreset = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
19
-
20
- export interface StartRenderOptions {
21
- fps?: number;
22
- quality?: "draft" | "standard" | "high";
23
- format?: "mp4" | "webm" | "mov";
24
- /** `"auto"` (default) renders at the composition's authored dimensions. */
25
- resolution?: ResolutionPreset | "auto";
26
- }
27
-
28
14
  export function useRenderQueue(projectId: string | null) {
29
15
  const [jobs, setJobs] = useState<RenderJob[]>([]);
30
16
  const eventSourceRef = useRef<EventSource | null>(null);
@@ -73,30 +59,20 @@ export function useRenderQueue(projectId: string | null) {
73
59
 
74
60
  // Start a render and track progress via SSE
75
61
  const startRender = useCallback(
76
- async (opts: StartRenderOptions = {}) => {
62
+ async (
63
+ fps = 30,
64
+ quality: "draft" | "standard" | "high" = "standard",
65
+ format: "mp4" | "webm" | "mov" = "mp4",
66
+ ) => {
77
67
  if (!projectId) return;
78
68
 
79
- const fps = opts.fps ?? 30;
80
- const quality = opts.quality ?? "standard";
81
- const format = opts.format ?? "mp4";
82
- const resolution = opts.resolution;
83
-
84
69
  const startTime = Date.now();
85
- // "auto" / undefined means "render at the composition's authored size".
86
- // Omit the field entirely — sending "auto" would trip the route's
87
- // enum validation set.
88
- const body: { fps: number; quality: string; format: string; resolution?: string } = {
89
- fps,
90
- quality,
91
- format,
92
- };
93
- if (resolution && resolution !== "auto") body.resolution = resolution;
94
70
  let res: Response;
95
71
  try {
96
72
  res = await fetch(`/api/projects/${projectId}/render`, {
97
73
  method: "POST",
98
74
  headers: { "Content-Type": "application/json" },
99
- body: JSON.stringify(body),
75
+ body: JSON.stringify({ fps, quality, format }),
100
76
  });
101
77
  } catch {
102
78
  const failedJob: RenderJob = {
@@ -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>