@hyperframes/studio 0.5.5 → 0.6.0-alpha.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.
- package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
- package/dist/assets/index-UWFaHilT.css +1 -0
- package/dist/assets/index-cPJbxeAk.js +107 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2621 -170
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +67 -0
- package/src/components/editor/PropertyPanel.tsx +2891 -207
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +872 -0
- package/src/components/editor/domEditing.ts +993 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +129 -0
- package/src/components/editor/manualEditingAvailability.ts +60 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1397 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.tsx +27 -4
- package/src/components/nle/NLEPreview.tsx +50 -5
- package/src/components/renders/RenderQueue.tsx +13 -62
- package/src/components/renders/useRenderQueue.ts +6 -30
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +140 -125
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.tsx +18 -2
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +103 -21
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
- package/dist/assets/index-04Mp2wOn.css +0 -1
- 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
|
|
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={
|
|
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
|
|
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=
|
|
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={() =>
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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>
|