@hyperframes/studio 0.5.0-alpha.8 → 0.5.0
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-CoI5h1xv.js +353 -0
- package/dist/assets/index-BKjcNNNd.css +1 -0
- package/dist/assets/index-CqiisJmo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +208 -1436
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/LintModal.tsx +4 -3
- package/src/components/editor/PropertyPanel.tsx +206 -2462
- package/src/components/nle/NLELayout.tsx +47 -17
- package/src/components/nle/NLEPreview.tsx +9 -50
- package/src/components/sidebar/AssetsTab.tsx +4 -3
- package/src/components/sidebar/CompositionsTab.test.ts +1 -16
- package/src/components/sidebar/CompositionsTab.tsx +45 -117
- package/src/components/sidebar/LeftSidebar.tsx +55 -34
- package/src/components/ui/HyperframesLoader.tsx +104 -0
- package/src/components/ui/index.ts +2 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -42
- package/src/player/components/EditModal.tsx +20 -5
- package/src/player/components/Player.tsx +129 -28
- package/src/player/components/PlayerControls.tsx +117 -49
- package/src/player/components/Timeline.test.ts +0 -12
- package/src/player/components/Timeline.tsx +25 -52
- package/src/player/components/TimelineClip.tsx +9 -21
- package/src/player/components/timelineEditing.test.ts +4 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +219 -1
- package/src/player/hooks/useTimelinePlayer.ts +487 -106
- package/src/player/lib/time.test.ts +29 -1
- package/src/player/lib/time.ts +26 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +6 -1
- package/src/styles/studio.css +112 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +40 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/src/utils/sourcePatcher.test.ts +1 -128
- package/src/utils/sourcePatcher.ts +18 -130
- package/src/utils/timelineAssetDrop.test.ts +11 -31
- package/src/utils/timelineAssetDrop.ts +2 -22
- package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
- package/dist/assets/index-0Zt0t13W.css +0 -1
- package/dist/assets/index-C9f5eif8.js +0 -105
- package/src/components/editor/DomEditOverlay.tsx +0 -442
- package/src/components/editor/colorValue.test.ts +0 -82
- package/src/components/editor/colorValue.ts +0 -175
- package/src/components/editor/domEditing.test.ts +0 -537
- package/src/components/editor/domEditing.ts +0 -762
- package/src/components/editor/floatingPanel.test.ts +0 -34
- package/src/components/editor/floatingPanel.ts +0 -54
- package/src/components/editor/fontAssets.ts +0 -32
- package/src/components/editor/fontCatalog.ts +0 -126
- package/src/components/editor/gradientValue.test.ts +0 -89
- package/src/components/editor/gradientValue.ts +0 -445
- package/src/player/components/CompositionThumbnail.test.ts +0 -19
- package/src/utils/clipboard.test.ts +0 -88
- package/src/utils/clipboard.ts +0 -57
|
@@ -5,6 +5,10 @@ import type { TimelineElement } from "../../player";
|
|
|
5
5
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
6
6
|
import { NLEPreview } from "./NLEPreview";
|
|
7
7
|
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
|
|
8
|
+
import {
|
|
9
|
+
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
10
|
+
getTimelineToggleTitle,
|
|
11
|
+
} from "../../utils/timelineDiscovery";
|
|
8
12
|
|
|
9
13
|
interface NLELayoutProps {
|
|
10
14
|
projectId: string;
|
|
@@ -85,6 +89,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
85
89
|
togglePlay,
|
|
86
90
|
seek,
|
|
87
91
|
onIframeLoad: baseOnIframeLoad,
|
|
92
|
+
refreshPlayer,
|
|
88
93
|
saveSeekPosition,
|
|
89
94
|
} = useTimelinePlayer();
|
|
90
95
|
|
|
@@ -98,15 +103,13 @@ export const NLELayout = memo(function NLELayout({
|
|
|
98
103
|
usePlayerStore.getState().reset();
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
//
|
|
102
|
-
// on refreshKey change. The Player handles the actual reload via the
|
|
103
|
-
// dual-player crossfade; we just need to persist the current time.
|
|
106
|
+
// Refresh the existing iframe in place when source files change.
|
|
104
107
|
const prevRefreshKeyRef = useRef(refreshKey);
|
|
105
108
|
useEffect(() => {
|
|
106
109
|
if (refreshKey === prevRefreshKeyRef.current) return;
|
|
107
110
|
prevRefreshKeyRef.current = refreshKey;
|
|
108
|
-
|
|
109
|
-
}, [refreshKey,
|
|
111
|
+
refreshPlayer();
|
|
112
|
+
}, [refreshKey, refreshPlayer]);
|
|
110
113
|
|
|
111
114
|
// Wrap onIframeLoad to also notify parent of iframe ref
|
|
112
115
|
const onIframeLoad = useCallback(() => {
|
|
@@ -198,6 +201,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
198
201
|
|
|
199
202
|
// Resizable timeline height
|
|
200
203
|
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
|
204
|
+
const isTimelineVisible = timelineVisible ?? true;
|
|
201
205
|
const isDragging = useRef(false);
|
|
202
206
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
203
207
|
|
|
@@ -205,10 +209,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
205
209
|
const currentLevel = compositionStack[compositionStack.length - 1];
|
|
206
210
|
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
|
207
211
|
|
|
208
|
-
useEffect(() => {
|
|
209
|
-
onIframeRef?.(iframeRef.current);
|
|
210
|
-
}, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
|
|
211
|
-
|
|
212
212
|
// Save master seek position before drilling down so we can restore it on back-navigation.
|
|
213
213
|
// saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
|
|
214
214
|
const masterSeekRef = useRef(0);
|
|
@@ -371,16 +371,11 @@ export const NLELayout = memo(function NLELayout({
|
|
|
371
371
|
onNavigate={handleNavigateComposition}
|
|
372
372
|
/>
|
|
373
373
|
)}
|
|
374
|
-
<PlayerControls
|
|
375
|
-
onTogglePlay={togglePlay}
|
|
376
|
-
onSeek={seek}
|
|
377
|
-
timelineVisible={timelineVisible ?? true}
|
|
378
|
-
onToggleTimeline={onToggleTimeline}
|
|
379
|
-
/>
|
|
374
|
+
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
|
380
375
|
</div>
|
|
381
376
|
</div>
|
|
382
377
|
|
|
383
|
-
{
|
|
378
|
+
{isTimelineVisible ? (
|
|
384
379
|
<>
|
|
385
380
|
{/* Resize divider */}
|
|
386
381
|
<div
|
|
@@ -422,7 +417,42 @@ export const NLELayout = memo(function NLELayout({
|
|
|
422
417
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
|
423
418
|
</div>
|
|
424
419
|
</>
|
|
425
|
-
)
|
|
420
|
+
) : onToggleTimeline ? (
|
|
421
|
+
<div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
|
|
422
|
+
<div className="flex h-10 items-center justify-between px-3">
|
|
423
|
+
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
424
|
+
Timeline
|
|
425
|
+
</div>
|
|
426
|
+
<button
|
|
427
|
+
type="button"
|
|
428
|
+
onClick={onToggleTimeline}
|
|
429
|
+
className="flex h-7 items-center gap-1.5 rounded-md border border-neutral-800 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:bg-neutral-900 hover:text-neutral-100"
|
|
430
|
+
title={getTimelineToggleTitle(false)}
|
|
431
|
+
aria-label="Show timeline editor"
|
|
432
|
+
>
|
|
433
|
+
<svg
|
|
434
|
+
width="13"
|
|
435
|
+
height="13"
|
|
436
|
+
viewBox="0 0 24 24"
|
|
437
|
+
fill="none"
|
|
438
|
+
stroke="currentColor"
|
|
439
|
+
strokeWidth="1.7"
|
|
440
|
+
strokeLinecap="round"
|
|
441
|
+
strokeLinejoin="round"
|
|
442
|
+
aria-hidden="true"
|
|
443
|
+
>
|
|
444
|
+
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
445
|
+
<path d="M7 9h10" />
|
|
446
|
+
<path d="M8 5h8" />
|
|
447
|
+
</svg>
|
|
448
|
+
<span>Show</span>
|
|
449
|
+
<span className="hidden rounded bg-white/5 px-1 py-0.5 font-mono text-[9px] text-neutral-500 sm:inline">
|
|
450
|
+
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
451
|
+
</span>
|
|
452
|
+
</button>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
) : null}
|
|
426
456
|
</div>
|
|
427
457
|
);
|
|
428
458
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo,
|
|
1
|
+
import { memo, type Ref } from "react";
|
|
2
2
|
import { Player } from "../../player";
|
|
3
3
|
|
|
4
4
|
interface NLEPreviewProps {
|
|
@@ -21,17 +21,6 @@ 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
|
-
*/
|
|
35
24
|
export const NLEPreview = memo(function NLEPreview({
|
|
36
25
|
projectId,
|
|
37
26
|
iframeRef,
|
|
@@ -40,52 +29,22 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
40
29
|
directUrl,
|
|
41
30
|
refreshKey,
|
|
42
31
|
}: NLEPreviewProps) {
|
|
43
|
-
const
|
|
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
|
-
};
|
|
32
|
+
const playerKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
|
|
67
33
|
|
|
68
34
|
return (
|
|
69
35
|
<div className="flex flex-col h-full min-h-0">
|
|
70
|
-
<div
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
directUrl={directUrl}
|
|
76
|
-
onLoad={() => {}}
|
|
77
|
-
portrait={portrait}
|
|
78
|
-
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
|
|
79
|
-
/>
|
|
80
|
-
)}
|
|
36
|
+
<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"
|
|
38
|
+
tabIndex={0}
|
|
39
|
+
aria-label="Composition preview"
|
|
40
|
+
>
|
|
81
41
|
<Player
|
|
82
|
-
key={
|
|
42
|
+
key={playerKey}
|
|
83
43
|
ref={iframeRef}
|
|
84
44
|
projectId={directUrl ? undefined : projectId}
|
|
85
45
|
directUrl={directUrl}
|
|
86
|
-
onLoad={
|
|
46
|
+
onLoad={onIframeLoad}
|
|
87
47
|
portrait={portrait}
|
|
88
|
-
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
|
|
89
48
|
/>
|
|
90
49
|
</div>
|
|
91
50
|
</div>
|
|
@@ -2,7 +2,6 @@ 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";
|
|
6
5
|
|
|
7
6
|
interface AssetsTabProps {
|
|
8
7
|
projectId: string;
|
|
@@ -299,10 +298,12 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
299
298
|
);
|
|
300
299
|
|
|
301
300
|
const handleCopyPath = useCallback(async (path: string) => {
|
|
302
|
-
|
|
303
|
-
|
|
301
|
+
try {
|
|
302
|
+
await navigator.clipboard.writeText(path);
|
|
304
303
|
setCopiedPath(path);
|
|
305
304
|
setTimeout(() => setCopiedPath(null), 1500);
|
|
305
|
+
} catch {
|
|
306
|
+
// ignore
|
|
306
307
|
}
|
|
307
308
|
}, []);
|
|
308
309
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { resolveCompositionPreviewScale
|
|
2
|
+
import { resolveCompositionPreviewScale } from "./CompositionsTab";
|
|
3
3
|
|
|
4
4
|
describe("resolveCompositionPreviewScale", () => {
|
|
5
5
|
it("scales a 16:9 stage to fit the composition card", () => {
|
|
@@ -35,18 +35,3 @@ 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,
|
|
1
|
+
import { memo, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
interface CompositionsTabProps {
|
|
4
4
|
projectId: string;
|
|
@@ -8,17 +8,6 @@ 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
|
-
};
|
|
22
11
|
|
|
23
12
|
export function resolveCompositionPreviewScale(input: {
|
|
24
13
|
cardWidth: number;
|
|
@@ -39,54 +28,6 @@ export function resolveCompositionPreviewScale(input: {
|
|
|
39
28
|
return Math.min(scaleX, scaleY);
|
|
40
29
|
}
|
|
41
30
|
|
|
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
|
-
|
|
90
31
|
function CompCard({
|
|
91
32
|
projectId,
|
|
92
33
|
comp,
|
|
@@ -100,25 +41,7 @@ function CompCard({
|
|
|
100
41
|
}) {
|
|
101
42
|
const [hovered, setHovered] = useState(false);
|
|
102
43
|
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
|
103
|
-
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
104
44
|
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
|
-
|
|
122
45
|
const handleEnter = () => {
|
|
123
46
|
hoverTimer.current = setTimeout(() => setHovered(true), 300);
|
|
124
47
|
};
|
|
@@ -130,6 +53,7 @@ function CompCard({
|
|
|
130
53
|
setHovered(false);
|
|
131
54
|
};
|
|
132
55
|
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
56
|
+
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
|
|
133
57
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
|
134
58
|
const previewScale = resolveCompositionPreviewScale({
|
|
135
59
|
cardWidth: 80,
|
|
@@ -138,17 +62,6 @@ function CompCard({
|
|
|
138
62
|
stageHeight: stageSize.height,
|
|
139
63
|
});
|
|
140
64
|
|
|
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
|
-
|
|
152
65
|
return (
|
|
153
66
|
<div
|
|
154
67
|
onClick={onSelect}
|
|
@@ -161,34 +74,49 @@ function CompCard({
|
|
|
161
74
|
}`}
|
|
162
75
|
>
|
|
163
76
|
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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>
|
|
192
120
|
</div>
|
|
193
121
|
<div className="min-w-0 flex-1">
|
|
194
122
|
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
|
@@ -35,6 +35,7 @@ interface LeftSidebarProps {
|
|
|
35
35
|
codeChildren?: ReactNode;
|
|
36
36
|
onLint?: () => void;
|
|
37
37
|
linting?: boolean;
|
|
38
|
+
onToggleCollapse?: () => void;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export const LeftSidebar = memo(function LeftSidebar({
|
|
@@ -57,6 +58,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|
|
57
58
|
codeChildren,
|
|
58
59
|
onLint,
|
|
59
60
|
linting,
|
|
61
|
+
onToggleCollapse,
|
|
60
62
|
}: LeftSidebarProps) {
|
|
61
63
|
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
|
62
64
|
|
|
@@ -88,45 +90,64 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|
|
88
90
|
style={{ width }}
|
|
89
91
|
>
|
|
90
92
|
{/* Tabs — Code first */}
|
|
91
|
-
<div className="border-b border-neutral-800/50
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
<div className="flex border-b border-neutral-800/50 flex-shrink-0">
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => selectTab("code")}
|
|
97
|
+
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
|
98
|
+
tab === "code"
|
|
99
|
+
? "text-neutral-200 border-b-2 border-studio-accent"
|
|
100
|
+
: "text-neutral-500 hover:text-neutral-400"
|
|
101
|
+
}`}
|
|
95
102
|
>
|
|
103
|
+
Code
|
|
104
|
+
</button>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => selectTab("compositions")}
|
|
108
|
+
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
|
109
|
+
tab === "compositions"
|
|
110
|
+
? "text-neutral-200 border-b-2 border-studio-accent"
|
|
111
|
+
: "text-neutral-500 hover:text-neutral-400"
|
|
112
|
+
}`}
|
|
113
|
+
>
|
|
114
|
+
Compositions
|
|
115
|
+
</button>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={() => selectTab("assets")}
|
|
119
|
+
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
|
120
|
+
tab === "assets"
|
|
121
|
+
? "text-neutral-200 border-b-2 border-studio-accent"
|
|
122
|
+
: "text-neutral-500 hover:text-neutral-400"
|
|
123
|
+
}`}
|
|
124
|
+
>
|
|
125
|
+
Assets
|
|
126
|
+
</button>
|
|
127
|
+
{onToggleCollapse && (
|
|
96
128
|
<button
|
|
97
129
|
type="button"
|
|
98
|
-
onClick={
|
|
99
|
-
className=
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
103
|
-
}`}
|
|
104
|
-
>
|
|
105
|
-
Code
|
|
106
|
-
</button>
|
|
107
|
-
<button
|
|
108
|
-
type="button"
|
|
109
|
-
onClick={() => selectTab("compositions")}
|
|
110
|
-
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
111
|
-
tab === "compositions"
|
|
112
|
-
? "bg-neutral-800 text-white"
|
|
113
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
114
|
-
}`}
|
|
115
|
-
>
|
|
116
|
-
Compositions
|
|
117
|
-
</button>
|
|
118
|
-
<button
|
|
119
|
-
type="button"
|
|
120
|
-
onClick={() => selectTab("assets")}
|
|
121
|
-
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
122
|
-
tab === "assets"
|
|
123
|
-
? "bg-neutral-800 text-white"
|
|
124
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
125
|
-
}`}
|
|
130
|
+
onClick={onToggleCollapse}
|
|
131
|
+
className="mx-1 my-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
|
|
132
|
+
title="Hide sidebar"
|
|
133
|
+
aria-label="Hide sidebar"
|
|
126
134
|
>
|
|
127
|
-
|
|
135
|
+
<svg
|
|
136
|
+
width="14"
|
|
137
|
+
height="14"
|
|
138
|
+
viewBox="0 0 24 24"
|
|
139
|
+
fill="none"
|
|
140
|
+
stroke="currentColor"
|
|
141
|
+
strokeWidth="1.5"
|
|
142
|
+
strokeLinecap="round"
|
|
143
|
+
strokeLinejoin="round"
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
>
|
|
146
|
+
<path d="m14 7-5 5 5 5" />
|
|
147
|
+
<path d="M19 4v16" />
|
|
148
|
+
</svg>
|
|
128
149
|
</button>
|
|
129
|
-
|
|
150
|
+
)}
|
|
130
151
|
</div>
|
|
131
152
|
|
|
132
153
|
{/* Tab content */}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export interface HyperframesLoaderProps {
|
|
2
|
+
/** Status text shown below the mark. */
|
|
3
|
+
title: string;
|
|
4
|
+
/** Optional secondary detail line. */
|
|
5
|
+
detail?: string;
|
|
6
|
+
/** Optional monospace third line for IDs, counts, or percentages. */
|
|
7
|
+
mono?: string;
|
|
8
|
+
/** Pixel size of the mark itself; status text scales independently. */
|
|
9
|
+
size?: number;
|
|
10
|
+
/** Optional normalized progress value from 0 to 1. */
|
|
11
|
+
progress?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function HyperframesLoader({
|
|
15
|
+
title,
|
|
16
|
+
detail,
|
|
17
|
+
mono,
|
|
18
|
+
size = 64,
|
|
19
|
+
progress,
|
|
20
|
+
}: HyperframesLoaderProps) {
|
|
21
|
+
const boundedProgress =
|
|
22
|
+
typeof progress === "number" && Number.isFinite(progress)
|
|
23
|
+
? Math.min(1, Math.max(0, progress))
|
|
24
|
+
: undefined;
|
|
25
|
+
const markFrameSize = Math.round(size * 1.16);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="hf-loader" draggable={false}>
|
|
29
|
+
<div
|
|
30
|
+
className="hf-loader-mark-frame"
|
|
31
|
+
style={{ width: markFrameSize, height: markFrameSize }}
|
|
32
|
+
draggable={false}
|
|
33
|
+
>
|
|
34
|
+
<svg
|
|
35
|
+
className="hf-loader-mark"
|
|
36
|
+
width={size}
|
|
37
|
+
height={size}
|
|
38
|
+
viewBox="0 0 100 100"
|
|
39
|
+
fill="none"
|
|
40
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
>
|
|
43
|
+
<g className="hf-loader-mark__mark" transform="translate(50 50)">
|
|
44
|
+
<g className="hf-loader-mark__core" transform="scale(1)" opacity=".92">
|
|
45
|
+
<g transform="translate(-50 -50)">
|
|
46
|
+
<path
|
|
47
|
+
d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z"
|
|
48
|
+
fill="url(#hf-loader-grad-left)"
|
|
49
|
+
/>
|
|
50
|
+
<path
|
|
51
|
+
d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z"
|
|
52
|
+
fill="url(#hf-loader-grad-right)"
|
|
53
|
+
/>
|
|
54
|
+
</g>
|
|
55
|
+
</g>
|
|
56
|
+
</g>
|
|
57
|
+
<defs>
|
|
58
|
+
<linearGradient
|
|
59
|
+
id="hf-loader-grad-left"
|
|
60
|
+
x1="48.5676"
|
|
61
|
+
y1="25"
|
|
62
|
+
x2="44.7804"
|
|
63
|
+
y2="71.9384"
|
|
64
|
+
gradientUnits="userSpaceOnUse"
|
|
65
|
+
>
|
|
66
|
+
<stop stopColor="#06E3FA" />
|
|
67
|
+
<stop offset="1" stopColor="#4FDB5E" />
|
|
68
|
+
</linearGradient>
|
|
69
|
+
<linearGradient
|
|
70
|
+
id="hf-loader-grad-right"
|
|
71
|
+
x1="54.8282"
|
|
72
|
+
y1="73.8392"
|
|
73
|
+
x2="72.0989"
|
|
74
|
+
y2="32.8932"
|
|
75
|
+
gradientUnits="userSpaceOnUse"
|
|
76
|
+
>
|
|
77
|
+
<stop stopColor="#06E3FA" />
|
|
78
|
+
<stop offset="1" stopColor="#4FDB5E" />
|
|
79
|
+
</linearGradient>
|
|
80
|
+
</defs>
|
|
81
|
+
</svg>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="hf-loader-title">{title}</div>
|
|
84
|
+
{detail && <div className="hf-loader-detail">{detail}</div>}
|
|
85
|
+
{boundedProgress !== undefined && (
|
|
86
|
+
<div className="hf-loader-progress" aria-hidden="true">
|
|
87
|
+
<div
|
|
88
|
+
className="hf-loader-progress__fill"
|
|
89
|
+
style={{ transform: `scaleX(${boundedProgress})` }}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
{mono && <div className="hf-loader-mono">{mono}</div>}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function StatusFrame(props: HyperframesLoaderProps) {
|
|
99
|
+
return (
|
|
100
|
+
<div className="hf-frame">
|
|
101
|
+
<HyperframesLoader {...props} />
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|