@hyperframes/studio 0.6.0-alpha.1 → 0.6.0-alpha.11
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-DjsVzYFP.js +418 -0
- package/dist/assets/index-FWg79aJz.css +1 -0
- package/dist/assets/index-xyVaWqe2.js +108 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +422 -71
- package/src/components/editor/PropertyPanel.test.ts +49 -0
- package/src/components/editor/PropertyPanel.tsx +277 -337
- package/src/components/editor/domEditing.test.ts +248 -0
- package/src/components/editor/domEditing.ts +126 -2
- package/src/components/editor/manualEditingAvailability.test.ts +15 -4
- package/src/components/editor/manualEditingAvailability.ts +4 -2
- package/src/components/editor/manualEdits.ts +15 -3
- package/src/components/nle/NLELayout.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +63 -24
- package/src/components/nle/NLEPreview.tsx +6 -0
- package/src/components/renders/RenderQueue.tsx +56 -4
- package/src/components/renders/useRenderQueue.ts +30 -6
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/player/components/Player.test.ts +58 -0
- package/src/player/components/Player.tsx +71 -4
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.tsx +45 -20
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-Cd8vYWxP.js +0 -198
- package/dist/assets/index-D04_ZoMm.js +0 -107
- package/dist/assets/index-UWFaHilT.css +0 -1
|
@@ -55,20 +55,24 @@ interface NLELayoutProps {
|
|
|
55
55
|
onInspectTimelineElement?: (element: TimelineElement) => void;
|
|
56
56
|
inspectedTimelineElementId?: string | null;
|
|
57
57
|
timelineLayerChildCounts?: ReadonlyMap<string, number>;
|
|
58
|
-
thumbnailedTimelineElementIds?: ReadonlySet<string>;
|
|
59
|
-
onToggleTimelineElementThumbnail?: (element: TimelineElement) => void;
|
|
60
58
|
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
|
61
59
|
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
62
60
|
/** Whether the timeline panel is visible (default: true) */
|
|
63
61
|
timelineVisible?: boolean;
|
|
64
62
|
/** Callback to toggle timeline visibility */
|
|
65
63
|
onToggleTimeline?: () => void;
|
|
64
|
+
/** Notifies parent when composition loading state changes */
|
|
65
|
+
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
const MIN_TIMELINE_H = 100;
|
|
69
69
|
const DEFAULT_TIMELINE_H = 220;
|
|
70
70
|
const MIN_PREVIEW_H = 120;
|
|
71
71
|
|
|
72
|
+
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
|
|
73
|
+
return compositionLoading;
|
|
74
|
+
}
|
|
75
|
+
|
|
72
76
|
export const NLELayout = memo(function NLELayout({
|
|
73
77
|
projectId,
|
|
74
78
|
portrait,
|
|
@@ -90,11 +94,10 @@ export const NLELayout = memo(function NLELayout({
|
|
|
90
94
|
onInspectTimelineElement,
|
|
91
95
|
inspectedTimelineElementId,
|
|
92
96
|
timelineLayerChildCounts,
|
|
93
|
-
thumbnailedTimelineElementIds,
|
|
94
|
-
onToggleTimelineElementThumbnail,
|
|
95
97
|
onCompIdToSrcChange,
|
|
96
98
|
timelineVisible,
|
|
97
99
|
onToggleTimeline,
|
|
100
|
+
onCompositionLoadingChange: onCompositionLoadingChangeParent,
|
|
98
101
|
}: NLELayoutProps) {
|
|
99
102
|
const {
|
|
100
103
|
iframeRef,
|
|
@@ -214,6 +217,18 @@ export const NLELayout = memo(function NLELayout({
|
|
|
214
217
|
|
|
215
218
|
// Resizable timeline height
|
|
216
219
|
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
|
220
|
+
const hasLoadedOnceRef = useRef(false);
|
|
221
|
+
const [compositionLoading, setCompositionLoadingRaw] = useState(true);
|
|
222
|
+
const setCompositionLoading = useCallback((loading: boolean) => {
|
|
223
|
+
if (!loading) hasLoadedOnceRef.current = true;
|
|
224
|
+
if (loading && hasLoadedOnceRef.current) return;
|
|
225
|
+
setCompositionLoadingRaw(loading);
|
|
226
|
+
}, []);
|
|
227
|
+
const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
onCompositionLoadingChangeParent?.(compositionLoading);
|
|
231
|
+
}, [compositionLoading, onCompositionLoadingChangeParent]);
|
|
217
232
|
const isTimelineVisible = timelineVisible ?? true;
|
|
218
233
|
const isDragging = useRef(false);
|
|
219
234
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -327,23 +342,31 @@ export const NLELayout = memo(function NLELayout({
|
|
|
327
342
|
}, [activeCompositionPath, projectId, updateCompositionStack]);
|
|
328
343
|
|
|
329
344
|
// Resize divider handlers
|
|
330
|
-
const handleDividerPointerDown = useCallback(
|
|
331
|
-
e.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
345
|
+
const handleDividerPointerDown = useCallback(
|
|
346
|
+
(e: React.PointerEvent) => {
|
|
347
|
+
if (timelineDisabled) return;
|
|
348
|
+
e.preventDefault();
|
|
349
|
+
isDragging.current = true;
|
|
350
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
351
|
+
},
|
|
352
|
+
[timelineDisabled],
|
|
353
|
+
);
|
|
335
354
|
|
|
336
|
-
const handleDividerPointerMove = useCallback(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
Math.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
355
|
+
const handleDividerPointerMove = useCallback(
|
|
356
|
+
(e: React.PointerEvent) => {
|
|
357
|
+
if (timelineDisabled) return;
|
|
358
|
+
if (!isDragging.current || !containerRef.current) return;
|
|
359
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
360
|
+
const mouseY = e.clientY - rect.top;
|
|
361
|
+
const containerH = rect.height;
|
|
362
|
+
const newTimelineH = Math.max(
|
|
363
|
+
MIN_TIMELINE_H,
|
|
364
|
+
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
|
365
|
+
);
|
|
366
|
+
setTimelineH(newTimelineH);
|
|
367
|
+
},
|
|
368
|
+
[timelineDisabled],
|
|
369
|
+
);
|
|
347
370
|
|
|
348
371
|
const handleDividerPointerUp = useCallback(() => {
|
|
349
372
|
isDragging.current = false;
|
|
@@ -374,9 +397,11 @@ export const NLELayout = memo(function NLELayout({
|
|
|
374
397
|
projectId={projectId}
|
|
375
398
|
iframeRef={iframeRef}
|
|
376
399
|
onIframeLoad={onIframeLoad}
|
|
400
|
+
onCompositionLoadingChange={setCompositionLoading}
|
|
377
401
|
portrait={portrait}
|
|
378
402
|
directUrl={directUrl}
|
|
379
403
|
refreshKey={refreshKey}
|
|
404
|
+
suppressLoadingOverlay={hasLoadedOnceRef.current}
|
|
380
405
|
/>
|
|
381
406
|
{previewOverlay}
|
|
382
407
|
</div>
|
|
@@ -388,7 +413,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
388
413
|
onNavigate={handleNavigateComposition}
|
|
389
414
|
/>
|
|
390
415
|
)}
|
|
391
|
-
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
|
416
|
+
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
|
|
392
417
|
</div>
|
|
393
418
|
</div>
|
|
394
419
|
|
|
@@ -406,13 +431,18 @@ export const NLELayout = memo(function NLELayout({
|
|
|
406
431
|
</div>
|
|
407
432
|
|
|
408
433
|
{/* Timeline section — fixed height, resizable */}
|
|
409
|
-
<div
|
|
434
|
+
<div
|
|
435
|
+
className="relative flex flex-col flex-shrink-0"
|
|
436
|
+
style={{ height: timelineH }}
|
|
437
|
+
aria-disabled={timelineDisabled || undefined}
|
|
438
|
+
>
|
|
410
439
|
{/* Timeline tracks */}
|
|
411
440
|
<div
|
|
412
441
|
// flex-col: toolbar takes natural height, Timeline fills remainder.
|
|
413
442
|
className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
|
|
414
443
|
onDoubleClick={(e) => {
|
|
415
444
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
445
|
+
if (timelineDisabled) return;
|
|
416
446
|
if (compositionStack.length > 1) {
|
|
417
447
|
updateCompositionStack((prev) => prev.slice(0, -1));
|
|
418
448
|
}
|
|
@@ -433,11 +463,20 @@ export const NLELayout = memo(function NLELayout({
|
|
|
433
463
|
onInspectElement={onInspectTimelineElement}
|
|
434
464
|
inspectedElementId={inspectedTimelineElementId}
|
|
435
465
|
layerChildCounts={timelineLayerChildCounts}
|
|
436
|
-
|
|
437
|
-
onToggleElementThumbnail={onToggleTimelineElementThumbnail}
|
|
466
|
+
disabled={timelineDisabled}
|
|
438
467
|
/>
|
|
439
468
|
</div>
|
|
440
469
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
|
470
|
+
{timelineDisabled && (
|
|
471
|
+
<div
|
|
472
|
+
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
|
|
473
|
+
data-testid="timeline-loading-disabled-overlay"
|
|
474
|
+
aria-hidden="true"
|
|
475
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
476
|
+
onDragOver={(event) => event.preventDefault()}
|
|
477
|
+
onDrop={(event) => event.preventDefault()}
|
|
478
|
+
/>
|
|
479
|
+
)}
|
|
441
480
|
</div>
|
|
442
481
|
</>
|
|
443
482
|
) : onToggleTimeline ? (
|
|
@@ -5,9 +5,11 @@ interface NLEPreviewProps {
|
|
|
5
5
|
projectId: string;
|
|
6
6
|
iframeRef: Ref<HTMLIFrameElement>;
|
|
7
7
|
onIframeLoad: () => void;
|
|
8
|
+
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
8
9
|
portrait?: boolean;
|
|
9
10
|
directUrl?: string;
|
|
10
11
|
refreshKey?: number;
|
|
12
|
+
suppressLoadingOverlay?: boolean;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export function getPreviewPlayerKey({
|
|
@@ -36,9 +38,11 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
36
38
|
projectId,
|
|
37
39
|
iframeRef,
|
|
38
40
|
onIframeLoad,
|
|
41
|
+
onCompositionLoadingChange,
|
|
39
42
|
portrait,
|
|
40
43
|
directUrl,
|
|
41
44
|
refreshKey,
|
|
45
|
+
suppressLoadingOverlay,
|
|
42
46
|
}: NLEPreviewProps) {
|
|
43
47
|
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
|
|
44
48
|
const prevRefreshKeyRef = useRef(refreshKey);
|
|
@@ -88,8 +92,10 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
88
92
|
projectId={directUrl ? undefined : projectId}
|
|
89
93
|
directUrl={directUrl}
|
|
90
94
|
onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
|
|
95
|
+
onCompositionLoadingChange={onCompositionLoadingChange}
|
|
91
96
|
portrait={portrait}
|
|
92
97
|
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
|
|
98
|
+
suppressLoadingOverlay={suppressLoadingOverlay}
|
|
93
99
|
/>
|
|
94
100
|
</div>
|
|
95
101
|
</div>
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { memo, useState, useRef, useEffect } from "react";
|
|
2
2
|
import { RenderQueueItem } from "./RenderQueueItem";
|
|
3
|
-
import type { RenderJob } from "./useRenderQueue";
|
|
3
|
+
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
|
4
|
+
|
|
5
|
+
export interface CompositionDimensions {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const RESOLUTION_OPTIONS: { value: ResolutionPreset | "auto"; label: string }[] = [
|
|
11
|
+
{ value: "auto", label: "Auto" },
|
|
12
|
+
{ value: "landscape", label: "1080p" },
|
|
13
|
+
{ value: "landscape-4k", label: "4K" },
|
|
14
|
+
{ value: "portrait", label: "1080p ↕" },
|
|
15
|
+
{ value: "portrait-4k", label: "4K ↕" },
|
|
16
|
+
{ value: "square", label: "Square" },
|
|
17
|
+
{ value: "square-4k", label: "Square 4K" },
|
|
18
|
+
];
|
|
4
19
|
|
|
5
20
|
type StartRenderHandler = (
|
|
6
21
|
format: "mp4" | "webm" | "mov",
|
|
7
22
|
quality: "draft" | "standard" | "high",
|
|
23
|
+
resolution: ResolutionPreset | "auto",
|
|
24
|
+
fps: 24 | 30 | 60,
|
|
8
25
|
) => void | Promise<void>;
|
|
9
26
|
|
|
10
27
|
interface RenderQueueProps {
|
|
@@ -14,6 +31,12 @@ interface RenderQueueProps {
|
|
|
14
31
|
onClearCompleted: () => void;
|
|
15
32
|
onStartRender: StartRenderHandler;
|
|
16
33
|
isRendering: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Authored dimensions of the active composition. Used to pick the
|
|
36
|
+
* matching preset (landscape / portrait / square) when the user selects
|
|
37
|
+
* a 1080p or 4K scale. `null` falls back to landscape (legacy default).
|
|
38
|
+
*/
|
|
39
|
+
compositionDimensions?: CompositionDimensions | null;
|
|
17
40
|
}
|
|
18
41
|
|
|
19
42
|
const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
|
|
@@ -101,6 +124,8 @@ function FormatExportButton({
|
|
|
101
124
|
}) {
|
|
102
125
|
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
|
|
103
126
|
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
|
|
127
|
+
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
|
|
128
|
+
const [fps, setFps] = useState<24 | 30 | 60>(30);
|
|
104
129
|
|
|
105
130
|
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
|
106
131
|
const showQuality = format !== "mov";
|
|
@@ -108,13 +133,29 @@ function FormatExportButton({
|
|
|
108
133
|
return (
|
|
109
134
|
<div className="flex items-center gap-1">
|
|
110
135
|
<FormatInfoTooltip format={format} />
|
|
136
|
+
{/* Resolution must remain the leftmost <select> in this row — it
|
|
137
|
+
carries `rounded-l` for the joined-button look. If you ever hide it
|
|
138
|
+
(feature-flag, etc.), move `rounded-l` to whichever element ends up
|
|
139
|
+
leftmost. */}
|
|
140
|
+
<select
|
|
141
|
+
value={resolution}
|
|
142
|
+
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
|
|
143
|
+
disabled={isRendering}
|
|
144
|
+
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
|
145
|
+
>
|
|
146
|
+
{RESOLUTION_OPTIONS.map((opt) => (
|
|
147
|
+
<option key={opt.value} value={opt.value}>
|
|
148
|
+
{opt.label}
|
|
149
|
+
</option>
|
|
150
|
+
))}
|
|
151
|
+
</select>
|
|
111
152
|
{showQuality && (
|
|
112
153
|
<select
|
|
113
154
|
value={quality}
|
|
114
155
|
onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
|
|
115
156
|
disabled={isRendering}
|
|
116
157
|
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
|
|
117
|
-
className="h-5 px-1 text-[10px]
|
|
158
|
+
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
|
118
159
|
>
|
|
119
160
|
{QUALITY_OPTIONS.map((q) => (
|
|
120
161
|
<option key={q.value} value={q.value} title={q.title}>
|
|
@@ -123,11 +164,22 @@ function FormatExportButton({
|
|
|
123
164
|
))}
|
|
124
165
|
</select>
|
|
125
166
|
)}
|
|
167
|
+
<select
|
|
168
|
+
value={fps}
|
|
169
|
+
onChange={(e) => setFps(Number(e.target.value) as 24 | 30 | 60)}
|
|
170
|
+
disabled={isRendering}
|
|
171
|
+
title="Frames per second"
|
|
172
|
+
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
|
173
|
+
>
|
|
174
|
+
<option value={24}>24fps</option>
|
|
175
|
+
<option value={30}>30fps</option>
|
|
176
|
+
<option value={60}>60fps</option>
|
|
177
|
+
</select>
|
|
126
178
|
<select
|
|
127
179
|
value={format}
|
|
128
180
|
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
|
|
129
181
|
disabled={isRendering}
|
|
130
|
-
className=
|
|
182
|
+
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
|
131
183
|
>
|
|
132
184
|
<option value="mp4">MP4</option>
|
|
133
185
|
<option value="mov">MOV</option>
|
|
@@ -135,7 +187,7 @@ function FormatExportButton({
|
|
|
135
187
|
</select>
|
|
136
188
|
<button
|
|
137
189
|
onClick={() => {
|
|
138
|
-
void onStartRender(format, quality);
|
|
190
|
+
void onStartRender(format, quality, resolution, fps);
|
|
139
191
|
}}
|
|
140
192
|
disabled={isRendering}
|
|
141
193
|
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"
|
|
@@ -11,6 +11,20 @@ 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
|
+
|
|
14
28
|
export function useRenderQueue(projectId: string | null) {
|
|
15
29
|
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
|
16
30
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
@@ -59,20 +73,30 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
59
73
|
|
|
60
74
|
// Start a render and track progress via SSE
|
|
61
75
|
const startRender = useCallback(
|
|
62
|
-
async (
|
|
63
|
-
fps = 30,
|
|
64
|
-
quality: "draft" | "standard" | "high" = "standard",
|
|
65
|
-
format: "mp4" | "webm" | "mov" = "mp4",
|
|
66
|
-
) => {
|
|
76
|
+
async (opts: StartRenderOptions = {}) => {
|
|
67
77
|
if (!projectId) return;
|
|
68
78
|
|
|
79
|
+
const fps = opts.fps ?? 30;
|
|
80
|
+
const quality = opts.quality ?? "standard";
|
|
81
|
+
const format = opts.format ?? "mp4";
|
|
82
|
+
const resolution = opts.resolution;
|
|
83
|
+
|
|
69
84
|
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;
|
|
70
94
|
let res: Response;
|
|
71
95
|
try {
|
|
72
96
|
res = await fetch(`/api/projects/${projectId}/render`, {
|
|
73
97
|
method: "POST",
|
|
74
98
|
headers: { "Content-Type": "application/json" },
|
|
75
|
-
body: JSON.stringify(
|
|
99
|
+
body: JSON.stringify(body),
|
|
76
100
|
});
|
|
77
101
|
} catch {
|
|
78
102
|
const failedJob: RenderJob = {
|