@hyperframes/studio 0.4.38 → 0.5.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.
- package/dist/assets/index-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +1431 -196
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +445 -0
- package/src/components/editor/PropertyPanel.tsx +2466 -206
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +537 -0
- package/src/components/editor/domEditing.ts +762 -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/nle/NLELayout.tsx +17 -47
- package/src/components/nle/NLEPreview.tsx +50 -5
- 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 +34 -55
- package/src/icons/SystemIcons.tsx +0 -2
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.tsx +18 -70
- package/src/player/components/PlayerControls.tsx +44 -3
- package/src/player/components/Timeline.test.ts +12 -0
- package/src/player/components/Timeline.tsx +51 -20
- package/src/player/components/TimelineClip.tsx +20 -7
- package/src/player/components/timelineEditing.test.ts +2 -4
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineTheme.ts +3 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +59 -0
- package/src/player/hooks/useTimelinePlayer.ts +74 -32
- package/src/player/lib/time.test.ts +1 -11
- package/src/player/lib/time.ts +0 -6
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -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/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/dist/assets/index-18P_dZeo.js +0 -93
- package/dist/assets/index-BLrgRQSu.css +0 -1
- package/src/utils/frameCapture.test.ts +0 -26
- package/src/utils/frameCapture.ts +0 -38
|
@@ -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>
|
|
@@ -35,7 +35,6 @@ interface LeftSidebarProps {
|
|
|
35
35
|
codeChildren?: ReactNode;
|
|
36
36
|
onLint?: () => void;
|
|
37
37
|
linting?: boolean;
|
|
38
|
-
onToggleCollapse?: () => void;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
export const LeftSidebar = memo(function LeftSidebar({
|
|
@@ -58,7 +57,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|
|
58
57
|
codeChildren,
|
|
59
58
|
onLint,
|
|
60
59
|
linting,
|
|
61
|
-
onToggleCollapse,
|
|
62
60
|
}: LeftSidebarProps) {
|
|
63
61
|
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
|
64
62
|
|
|
@@ -90,64 +88,45 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|
|
90
88
|
style={{ width }}
|
|
91
89
|
>
|
|
92
90
|
{/* Tabs — Code first */}
|
|
93
|
-
<div className="
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
}`}
|
|
91
|
+
<div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
|
|
92
|
+
<div
|
|
93
|
+
className="grid gap-1 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
|
94
|
+
style={{ gridTemplateColumns: "0.9fr 1.25fr 0.9fr" }}
|
|
102
95
|
>
|
|
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 && (
|
|
128
96
|
<button
|
|
129
97
|
type="button"
|
|
130
|
-
onClick={
|
|
131
|
-
className=
|
|
132
|
-
|
|
133
|
-
|
|
98
|
+
onClick={() => selectTab("code")}
|
|
99
|
+
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
100
|
+
tab === "code"
|
|
101
|
+
? "bg-neutral-800 text-white"
|
|
102
|
+
: "text-neutral-500 hover:text-neutral-200"
|
|
103
|
+
}`}
|
|
134
104
|
>
|
|
135
|
-
|
|
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>
|
|
105
|
+
Code
|
|
149
106
|
</button>
|
|
150
|
-
|
|
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
|
+
}`}
|
|
126
|
+
>
|
|
127
|
+
Assets
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
151
130
|
</div>
|
|
152
131
|
|
|
153
132
|
{/* Tab content */}
|
|
@@ -53,7 +53,6 @@ import {
|
|
|
53
53
|
CaretRight,
|
|
54
54
|
ClipboardText,
|
|
55
55
|
ArrowCounterClockwise,
|
|
56
|
-
Camera as PhCamera,
|
|
57
56
|
Gear,
|
|
58
57
|
} from "@phosphor-icons/react";
|
|
59
58
|
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
|
@@ -128,5 +127,4 @@ export const ChevronDown = makeIcon(CaretDown);
|
|
|
128
127
|
export const ChevronRight = makeIcon(CaretRight);
|
|
129
128
|
export const ClipboardList = makeIcon(ClipboardText);
|
|
130
129
|
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
|
131
|
-
export const Camera = makeIcon(PhCamera);
|
|
132
130
|
export const Settings = makeIcon(Gear);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
|
|
3
|
+
|
|
4
|
+
describe("buildCompositionThumbnailUrl", () => {
|
|
5
|
+
it("includes selector and occurrence index for precise element thumbnails", () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildCompositionThumbnailUrl({
|
|
8
|
+
previewUrl: "/api/projects/demo/preview",
|
|
9
|
+
seekTime: 1,
|
|
10
|
+
duration: 2,
|
|
11
|
+
selector: ".card",
|
|
12
|
+
selectorIndex: 2,
|
|
13
|
+
origin: "http://localhost:3000",
|
|
14
|
+
}),
|
|
15
|
+
).toBe(
|
|
16
|
+
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v2&selector=.card&selectorIndex=2",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -7,6 +7,7 @@ interface CompositionThumbnailProps {
|
|
|
7
7
|
labelColor: string;
|
|
8
8
|
accentColor?: string;
|
|
9
9
|
selector?: string;
|
|
10
|
+
selectorIndex?: number;
|
|
10
11
|
seekTime?: number;
|
|
11
12
|
duration?: number;
|
|
12
13
|
width?: number;
|
|
@@ -16,12 +17,44 @@ interface CompositionThumbnailProps {
|
|
|
16
17
|
const CLIP_HEIGHT = 66;
|
|
17
18
|
const THUMBNAIL_URL_VERSION = "v2";
|
|
18
19
|
|
|
20
|
+
export function buildCompositionThumbnailUrl({
|
|
21
|
+
previewUrl,
|
|
22
|
+
seekTime = 2,
|
|
23
|
+
duration = 5,
|
|
24
|
+
selector,
|
|
25
|
+
selectorIndex,
|
|
26
|
+
origin,
|
|
27
|
+
}: {
|
|
28
|
+
previewUrl: string;
|
|
29
|
+
seekTime?: number;
|
|
30
|
+
duration?: number;
|
|
31
|
+
selector?: string;
|
|
32
|
+
selectorIndex?: number;
|
|
33
|
+
origin: string;
|
|
34
|
+
}): string {
|
|
35
|
+
const thumbnailBase = previewUrl
|
|
36
|
+
.replace("/preview/comp/", "/thumbnail/")
|
|
37
|
+
.replace(/\/preview$/, "/thumbnail/index.html");
|
|
38
|
+
const midTime = seekTime + duration / 2;
|
|
39
|
+
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
40
|
+
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
|
41
|
+
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
42
|
+
if (selector) {
|
|
43
|
+
thumbnailUrl.searchParams.set("selector", selector);
|
|
44
|
+
if (selectorIndex != null && selectorIndex > 0) {
|
|
45
|
+
thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return thumbnailUrl.toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
20
52
|
previewUrl,
|
|
21
53
|
label,
|
|
22
54
|
labelColor,
|
|
23
55
|
accentColor = "#6B7280",
|
|
24
56
|
selector,
|
|
57
|
+
selectorIndex,
|
|
25
58
|
seekTime = 2,
|
|
26
59
|
duration = 5,
|
|
27
60
|
}: CompositionThumbnailProps) {
|
|
@@ -48,15 +81,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
48
81
|
roRef.current?.disconnect();
|
|
49
82
|
});
|
|
50
83
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const url = thumbnailUrl.toString();
|
|
84
|
+
const url = buildCompositionThumbnailUrl({
|
|
85
|
+
previewUrl,
|
|
86
|
+
seekTime,
|
|
87
|
+
duration,
|
|
88
|
+
selector,
|
|
89
|
+
selectorIndex,
|
|
90
|
+
origin: window.location.origin,
|
|
91
|
+
});
|
|
60
92
|
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
|
61
93
|
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
62
94
|
|
|
@@ -66,7 +98,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
66
98
|
src={url}
|
|
67
99
|
alt=""
|
|
68
100
|
draggable={false}
|
|
69
|
-
loading="
|
|
101
|
+
loading="eager"
|
|
70
102
|
onLoad={(e) => {
|
|
71
103
|
const img = e.currentTarget;
|
|
72
104
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
4
|
import { formatTime } from "../lib/time";
|
|
5
5
|
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
|
6
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
6
7
|
|
|
7
8
|
interface EditPopoverProps {
|
|
8
9
|
rangeStart: number;
|
|
@@ -62,16 +63,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
62
63
|
}, [start, end, elementsInRange, prompt]);
|
|
63
64
|
|
|
64
65
|
const handleCopy = useCallback(async () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
} catch {
|
|
68
|
-
const ta = document.createElement("textarea");
|
|
69
|
-
ta.value = buildClipboardText();
|
|
70
|
-
document.body.appendChild(ta);
|
|
71
|
-
ta.select();
|
|
72
|
-
document.execCommand("copy");
|
|
73
|
-
document.body.removeChild(ta);
|
|
74
|
-
}
|
|
66
|
+
const copied = await copyTextToClipboard(buildClipboardText());
|
|
67
|
+
if (!copied) return;
|
|
75
68
|
setCopiedAgentPrompt(true);
|
|
76
69
|
setTimeout(() => {
|
|
77
70
|
setCopiedAgentPrompt(false);
|
|
@@ -82,16 +75,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
82
75
|
const handleCopyPrompt = useCallback(async () => {
|
|
83
76
|
const promptText = buildPromptCopyText(prompt);
|
|
84
77
|
if (!promptText) return;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
const ta = document.createElement("textarea");
|
|
89
|
-
ta.value = promptText;
|
|
90
|
-
document.body.appendChild(ta);
|
|
91
|
-
ta.select();
|
|
92
|
-
document.execCommand("copy");
|
|
93
|
-
document.body.removeChild(ta);
|
|
94
|
-
}
|
|
78
|
+
const copied = await copyTextToClipboard(promptText);
|
|
79
|
+
if (!copied) return;
|
|
95
80
|
setCopiedPromptOnly(true);
|
|
96
81
|
setTimeout(() => {
|
|
97
82
|
setCopiedPromptOnly(false);
|
|
@@ -1,36 +1,29 @@
|
|
|
1
1
|
import { forwardRef, useRef, useState } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
-
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
|
|
4
|
-
// at module load, which throws under SSR. Defer the import to the mount effect
|
|
5
|
-
// so it only runs in the browser.
|
|
6
3
|
|
|
7
4
|
interface PlayerProps {
|
|
8
5
|
projectId?: string;
|
|
9
6
|
directUrl?: string;
|
|
10
7
|
onLoad: () => void;
|
|
11
8
|
portrait?: boolean;
|
|
9
|
+
style?: React.CSSProperties;
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
interface HyperframesPlayerElement extends HTMLElement {
|
|
15
13
|
iframeElement: HTMLIFrameElement;
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
* more canonical home for the helper, but importing from the core package's
|
|
30
|
-
* root index pulls Node-only submodules (path, url) into this browser bundle
|
|
31
|
-
* and breaks Vite. If the helper grows, split a browser-safe submodule
|
|
32
|
-
* export in core and switch this to import it.
|
|
33
|
-
*/
|
|
16
|
+
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
|
17
|
+
const root = player.shadowRoot;
|
|
18
|
+
if (!root) return;
|
|
19
|
+
|
|
20
|
+
const container = root.querySelector<HTMLElement>(".hfp-container");
|
|
21
|
+
const iframe = root.querySelector<HTMLIFrameElement>(".hfp-iframe");
|
|
22
|
+
|
|
23
|
+
container?.style.setProperty("pointer-events", "auto");
|
|
24
|
+
iframe?.style.setProperty("pointer-events", "auto");
|
|
25
|
+
}
|
|
26
|
+
|
|
34
27
|
function isLottieAnimationReady(anim: unknown): boolean {
|
|
35
28
|
if (typeof anim !== "object" || anim === null) return true;
|
|
36
29
|
const maybe = anim as { isLoaded?: boolean; totalFrames?: number };
|
|
@@ -39,14 +32,6 @@ function isLottieAnimationReady(anim: unknown): boolean {
|
|
|
39
32
|
return false;
|
|
40
33
|
}
|
|
41
34
|
|
|
42
|
-
// Assets are considered ready when every `<video>`/`<audio>` has enough data
|
|
43
|
-
// to play through without buffering, and every registered Lottie animation has
|
|
44
|
-
// finished loading.
|
|
45
|
-
//
|
|
46
|
-
// Returns whichever value was returned last on cross-origin / transient DOM
|
|
47
|
-
// races so a brief access failure (e.g. an iframe that just swapped src)
|
|
48
|
-
// doesn't flicker the overlay state — we keep showing whatever was most
|
|
49
|
-
// recently true.
|
|
50
35
|
function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
|
|
51
36
|
try {
|
|
52
37
|
const win = iframe.contentWindow as unknown as (Window & { __hfLottie?: unknown[] }) | null;
|
|
@@ -72,18 +57,9 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
|
|
|
72
57
|
}
|
|
73
58
|
}
|
|
74
59
|
|
|
75
|
-
/**
|
|
76
|
-
* Renders a composition preview using the <hyperframes-player> web component.
|
|
77
|
-
*
|
|
78
|
-
* The web component handles iframe scaling, dimension detection, and
|
|
79
|
-
* ResizeObserver internally. This wrapper bridges its inner iframe to the
|
|
80
|
-
* forwarded ref so useTimelinePlayer can access it for clip manifest parsing,
|
|
81
|
-
* timeline probing, and DOM inspection.
|
|
82
|
-
*/
|
|
83
60
|
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
84
|
-
({ projectId, directUrl, onLoad, portrait }, ref) => {
|
|
61
|
+
({ projectId, directUrl, onLoad, portrait, style }, ref) => {
|
|
85
62
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
86
|
-
const loadCountRef = useRef(0);
|
|
87
63
|
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
88
64
|
const [assetsLoading, setAssetsLoading] = useState(false);
|
|
89
65
|
|
|
@@ -94,11 +70,9 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
94
70
|
let canceled = false;
|
|
95
71
|
let cleanup: (() => void) | undefined;
|
|
96
72
|
|
|
97
|
-
// Dynamic import registers the custom element in the browser only.
|
|
98
73
|
import("@hyperframes/player").then(() => {
|
|
99
74
|
if (canceled) return;
|
|
100
75
|
|
|
101
|
-
// Create the web component imperatively to avoid JSX custom-element typing.
|
|
102
76
|
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
|
|
103
77
|
const src = directUrl || `/api/projects/${projectId}/preview`;
|
|
104
78
|
player.setAttribute("src", src);
|
|
@@ -108,8 +82,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
108
82
|
player.style.height = "100%";
|
|
109
83
|
player.style.display = "block";
|
|
110
84
|
container.appendChild(player);
|
|
85
|
+
enableInteractiveIframe(player);
|
|
111
86
|
|
|
112
|
-
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
113
87
|
const iframe = player.iframeElement;
|
|
114
88
|
if (typeof ref === "function") {
|
|
115
89
|
ref(iframe);
|
|
@@ -117,35 +91,12 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
117
91
|
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
|
118
92
|
}
|
|
119
93
|
|
|
120
|
-
// Prevent the web component's built-in click-to-toggle behavior.
|
|
121
|
-
// The studio manages playback exclusively via useTimelinePlayer.
|
|
122
94
|
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
|
123
95
|
player.addEventListener("click", preventToggle, { capture: true });
|
|
124
96
|
|
|
125
|
-
// Forward the iframe's native load event to the studio's onIframeLoad.
|
|
126
97
|
const handleLoad = () => {
|
|
127
|
-
loadCountRef.current++;
|
|
128
|
-
// Reveal animation on reload (hot-reload, composition switch)
|
|
129
|
-
if (loadCountRef.current > 1) {
|
|
130
|
-
container.classList.remove("preview-revealing");
|
|
131
|
-
void container.offsetWidth;
|
|
132
|
-
container.classList.add("preview-revealing");
|
|
133
|
-
const onEnd = () => container.classList.remove("preview-revealing");
|
|
134
|
-
container.addEventListener("animationend", onEnd, { once: true });
|
|
135
|
-
}
|
|
136
98
|
onLoad();
|
|
137
99
|
|
|
138
|
-
// Show a loading overlay until every `<video>`/`<audio>` and Lottie
|
|
139
|
-
// asset is ready. Without this users can click play before audio has
|
|
140
|
-
// buffered — the runtime is resilient (queued play() resolves once
|
|
141
|
-
// data arrives), but the overlay communicates why the first frame
|
|
142
|
-
// or first audio beat may lag.
|
|
143
|
-
//
|
|
144
|
-
// Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap
|
|
145
|
-
// trips we hide the overlay so the UI doesn't appear stuck forever,
|
|
146
|
-
// but we log a debug warning so the case is diagnosable — a long
|
|
147
|
-
// cold video or a broken asset can legitimately exceed 10 s on a
|
|
148
|
-
// slow network.
|
|
149
100
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
150
101
|
let lastUnloaded = hasUnloadedAssets(iframe, false);
|
|
151
102
|
if (lastUnloaded) {
|
|
@@ -158,11 +109,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
158
109
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
159
110
|
assetPollRef.current = null;
|
|
160
111
|
setAssetsLoading(false);
|
|
161
|
-
if (lastUnloaded) {
|
|
162
|
-
console.debug(
|
|
163
|
-
"[Player] Asset-loading overlay timed out after 10s; hiding anyway. Check network or asset integrity.",
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
112
|
}
|
|
167
113
|
}, 100);
|
|
168
114
|
} else {
|
|
@@ -177,7 +123,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
177
123
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
178
124
|
assetPollRef.current = null;
|
|
179
125
|
container.removeChild(player);
|
|
180
|
-
// Clear the forwarded ref
|
|
181
126
|
if (typeof ref === "function") {
|
|
182
127
|
ref(null);
|
|
183
128
|
} else if (ref) {
|
|
@@ -193,7 +138,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
193
138
|
});
|
|
194
139
|
|
|
195
140
|
return (
|
|
196
|
-
<div
|
|
141
|
+
<div
|
|
142
|
+
className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
|
|
143
|
+
style={style}
|
|
144
|
+
>
|
|
197
145
|
<div ref={containerRef} className="w-full h-full" />
|
|
198
146
|
{assetsLoading && (
|
|
199
147
|
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-20 pointer-events-none">
|