mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX (#1963)
This commit is contained in:
@@ -33,8 +33,9 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
||||
});
|
||||
onNavigate(stack.length - 2);
|
||||
}}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
|
||||
title="Back (Esc)"
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 active:scale-[0.98] transition-colors"
|
||||
title="Back (Esc, or double-click empty timeline)"
|
||||
aria-label="Back to parent composition"
|
||||
>
|
||||
<ArrowLeft size={12} weight="bold" />
|
||||
</button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { TimelineElement } from "../../player";
|
||||
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
||||
import { NLEPreview } from "./NLEPreview";
|
||||
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
|
||||
import { TimelineResizeDivider, MIN_TIMELINE_H, MIN_PREVIEW_H } from "./TimelineResizeDivider";
|
||||
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
||||
import { useCompositionStack } from "./useCompositionStack";
|
||||
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
getTimelineToggleTitle,
|
||||
} from "../../utils/timelineDiscovery";
|
||||
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
|
||||
interface NLELayoutProps {
|
||||
projectId: string;
|
||||
@@ -75,9 +77,7 @@ interface NLELayoutProps {
|
||||
onCompositionLoadingChange?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
const MIN_TIMELINE_H = 100;
|
||||
const DEFAULT_TIMELINE_H = 220;
|
||||
const MIN_PREVIEW_H = 120;
|
||||
|
||||
function subscribeFullscreen(cb: () => void) {
|
||||
document.addEventListener("fullscreenchange", cb);
|
||||
@@ -138,13 +138,22 @@ export const NLELayout = memo(function NLELayout({
|
||||
stageRefForDrop.current = ref.current;
|
||||
}, []);
|
||||
|
||||
// Authored composition size measured from the loaded preview — drives drop
|
||||
// coordinate mapping so blocks land where the user pointed on any comp size.
|
||||
const [previewCompositionSize, setPreviewCompositionSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
isDragOver: previewDragOver,
|
||||
handleDragEnter: handlePreviewDragEnter,
|
||||
handleDragOver: handlePreviewDragOver,
|
||||
handleDragLeave: handlePreviewDragLeave,
|
||||
handleDrop: handlePreviewDrop,
|
||||
} = usePreviewBlockDrop({
|
||||
portrait,
|
||||
compositionSize: previewCompositionSize,
|
||||
stageRef: stageRefForDrop as React.RefObject<HTMLDivElement | null>,
|
||||
onBlockDrop: onPreviewBlockDrop,
|
||||
});
|
||||
@@ -289,7 +298,10 @@ export const NLELayout = memo(function NLELayout({
|
||||
|
||||
useMountEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/files/index.html`)
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((data: { content?: string }) => {
|
||||
const html = data.content || "";
|
||||
const map = new Map<string, string>();
|
||||
@@ -307,7 +319,11 @@ export const NLELayout = memo(function NLELayout({
|
||||
setCompositionSourceMap(map);
|
||||
onCompIdToSrcChange?.(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((err: unknown) => {
|
||||
// Non-fatal: drill-down still works via the iframe DOM scan; without
|
||||
// the map only source-file resolution for sub-comps degrades.
|
||||
console.warn("[studio] Couldn't load composition source map from index.html:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// Patch elements with compositionSrc whenever elements or compIdToSrc change.
|
||||
@@ -343,8 +359,24 @@ export const NLELayout = memo(function NLELayout({
|
||||
});
|
||||
}, [compIdToSrc]);
|
||||
|
||||
// Resizable timeline height
|
||||
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
||||
// Resizable timeline height — persisted alongside zoom/pan so the user's
|
||||
// workspace layout survives reloads.
|
||||
const [timelineH, setTimelineH] = useState(() => {
|
||||
const stored = readStudioUiPreferences().timelineHeight;
|
||||
return stored !== undefined && stored >= MIN_TIMELINE_H ? stored : DEFAULT_TIMELINE_H;
|
||||
});
|
||||
const persistTimelineH = useCallback((height: number) => {
|
||||
writeStudioUiPreferences({ timelineHeight: Math.round(height) });
|
||||
}, []);
|
||||
// A height persisted on a tall window can exceed this window's container and
|
||||
// collapse the flex-1 preview to 0px — clamp once the container is measurable
|
||||
// (the drag/keyboard paths already clamp; the restore path must too).
|
||||
useEffect(() => {
|
||||
const containerH = containerRef.current?.getBoundingClientRect().height;
|
||||
if (!containerH) return;
|
||||
const max = containerH - MIN_PREVIEW_H;
|
||||
setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev));
|
||||
}, []);
|
||||
const hasLoadedOnceRef = useRef(false);
|
||||
const [compositionLoading, setCompositionLoadingRaw] = useState(true);
|
||||
const setCompositionLoading = useCallback((loading: boolean) => {
|
||||
@@ -360,7 +392,6 @@ export const NLELayout = memo(function NLELayout({
|
||||
|
||||
const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement);
|
||||
const isTimelineVisible = timelineVisible ?? true;
|
||||
const isDragging = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isFullscreen = fullscreenElement === containerRef.current && fullscreenElement != null;
|
||||
|
||||
@@ -382,37 +413,6 @@ export const NLELayout = memo(function NLELayout({
|
||||
onIframeRefStable.current?.(iframeRef.current);
|
||||
}, [compositionStack.length, refreshKey, iframeRef]);
|
||||
|
||||
// Resize divider handlers
|
||||
const handleDividerPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (timelineDisabled) return;
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[timelineDisabled],
|
||||
);
|
||||
|
||||
const handleDividerPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (timelineDisabled) return;
|
||||
if (!isDragging.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const containerH = rect.height;
|
||||
const newTimelineH = Math.max(
|
||||
MIN_TIMELINE_H,
|
||||
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
||||
);
|
||||
setTimelineH(newTimelineH);
|
||||
},
|
||||
[timelineDisabled],
|
||||
);
|
||||
|
||||
const handleDividerPointerUp = useCallback(() => {
|
||||
isDragging.current = false;
|
||||
}, []);
|
||||
|
||||
// Keyboard: Escape to pop composition level
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -451,6 +451,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
e.clientY <= rect.bottom;
|
||||
if (!inside) onSelectTimelineElement?.(null);
|
||||
}}
|
||||
onDragEnter={handlePreviewDragEnter}
|
||||
onDragOver={handlePreviewDragOver}
|
||||
onDragLeave={handlePreviewDragLeave}
|
||||
onDrop={handlePreviewDrop}
|
||||
@@ -465,6 +466,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
directUrl={directUrl}
|
||||
suppressLoadingOverlay={hasLoadedOnceRef.current}
|
||||
onStageRef={handleStageRef}
|
||||
onCompositionSizeChange={setPreviewCompositionSize}
|
||||
/>
|
||||
{previewDragOver && (
|
||||
<div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
|
||||
@@ -491,16 +493,13 @@ export const NLELayout = memo(function NLELayout({
|
||||
|
||||
{!isFullscreen && isTimelineVisible ? (
|
||||
<>
|
||||
{/* Resize divider */}
|
||||
<div
|
||||
className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={handleDividerPointerDown}
|
||||
onPointerMove={handleDividerPointerMove}
|
||||
onPointerUp={handleDividerPointerUp}
|
||||
>
|
||||
<div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
|
||||
</div>
|
||||
<TimelineResizeDivider
|
||||
timelineH={timelineH}
|
||||
setTimelineH={setTimelineH}
|
||||
persistTimelineH={persistTimelineH}
|
||||
containerRef={containerRef}
|
||||
disabled={timelineDisabled}
|
||||
/>
|
||||
|
||||
{/* Timeline section */}
|
||||
<div
|
||||
@@ -537,13 +536,17 @@ export const NLELayout = memo(function NLELayout({
|
||||
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
||||
{timelineDisabled && (
|
||||
<div
|
||||
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
|
||||
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
|
||||
data-testid="timeline-loading-disabled-overlay"
|
||||
aria-hidden="true"
|
||||
role="status"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
/>
|
||||
>
|
||||
<span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
|
||||
Loading composition…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -21,6 +21,8 @@ interface NLEPreviewProps {
|
||||
directUrl?: string;
|
||||
suppressLoadingOverlay?: boolean;
|
||||
onStageRef?: (ref: React.RefObject<HTMLDivElement | null>) => void;
|
||||
/** Reports the authored composition size measured from the loaded preview. */
|
||||
onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
|
||||
}
|
||||
|
||||
export function getPreviewPlayerKey({
|
||||
@@ -123,6 +125,7 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
directUrl,
|
||||
suppressLoadingOverlay,
|
||||
onStageRef,
|
||||
onCompositionSizeChange,
|
||||
}: NLEPreviewProps) {
|
||||
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
@@ -170,13 +173,22 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
return () => observer.disconnect();
|
||||
}, [compositionSize, portrait]);
|
||||
|
||||
const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
|
||||
onCompositionSizeChangeRef.current = onCompositionSizeChange;
|
||||
|
||||
const updateCompositionSizeFromPreview = useCallback(() => {
|
||||
const next = readPreviewCompositionSize(previewIframeRef.current);
|
||||
// Pure updater — the parent notification happens in the effect below
|
||||
// (updaters may run more than once under Strict Mode / concurrent React).
|
||||
setCompositionSize((prev) =>
|
||||
prev?.width === next?.width && prev?.height === next?.height ? prev : next,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onCompositionSizeChangeRef.current?.(compositionSize);
|
||||
}, [compositionSize]);
|
||||
|
||||
const setPreviewIframeRef = useCallback(
|
||||
(node: HTMLIFrameElement | null) => {
|
||||
previewIframeRef.current = node;
|
||||
@@ -206,10 +218,17 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
};
|
||||
zoomRef.current = clamped;
|
||||
|
||||
if (showHud && !zoomingRef.current) {
|
||||
zoomingRef.current = true;
|
||||
if (showHud) {
|
||||
const hud = hudRef.current;
|
||||
if (hud) hud.style.opacity = "1";
|
||||
if (hud) {
|
||||
if (!zoomingRef.current) {
|
||||
zoomingRef.current = true;
|
||||
hud.style.opacity = "1";
|
||||
}
|
||||
// Live per-frame readout — without this the HUD shows an empty pill
|
||||
// on the first-ever zoom and a stale percentage mid-gesture.
|
||||
hud.textContent = isPreviewAtFit(clamped) ? "Fit" : `${Math.round(clamped.zoomPercent)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
writeTransform(clamped);
|
||||
@@ -254,7 +273,24 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
const applyInitialZoom = useCallback(() => {
|
||||
const z = zoomRef.current;
|
||||
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
|
||||
writeTransform(z);
|
||||
// A pan persisted on a large window can restore the composition mostly
|
||||
// off-screen in a smaller one; clamp against the current viewport first.
|
||||
const viewport = viewportRef.current;
|
||||
const rect = viewport?.getBoundingClientRect();
|
||||
const sz = stageSizeRef.current;
|
||||
if (rect && rect.width > 0 && rect.height > 0 && sz.width > 0 && sz.height > 0) {
|
||||
const pan = clampPreviewPan({
|
||||
panX: z.panX,
|
||||
panY: z.panY,
|
||||
zoomPercent: z.zoomPercent,
|
||||
viewportWidth: rect.width,
|
||||
viewportHeight: rect.height,
|
||||
contentWidth: sz.width,
|
||||
contentHeight: sz.height,
|
||||
});
|
||||
zoomRef.current = { ...z, ...pan };
|
||||
}
|
||||
writeTransform(zoomRef.current);
|
||||
}
|
||||
}, [writeTransform]);
|
||||
|
||||
@@ -474,7 +510,7 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
<div
|
||||
ref={hudRef}
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-lg px-4 py-2 text-sm font-mono tabular-nums text-white/90 bg-black/60 backdrop-blur-sm shadow-lg"
|
||||
style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
|
||||
style={{ opacity: 0, transition: "opacity 200ms ease-in" }}
|
||||
aria-live="polite"
|
||||
/>
|
||||
{!isPreviewAtFit(settledZoom) && (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
export const MIN_TIMELINE_H = 100;
|
||||
export const MIN_PREVIEW_H = 120;
|
||||
|
||||
/**
|
||||
* Horizontal drag/keyboard-resizable divider between the preview and the
|
||||
* timeline. Implements the separator pattern: ArrowUp grows the timeline,
|
||||
* ArrowDown shrinks it (mirrors the drag direction).
|
||||
*/
|
||||
export function TimelineResizeDivider({
|
||||
timelineH,
|
||||
setTimelineH,
|
||||
persistTimelineH,
|
||||
containerRef,
|
||||
disabled,
|
||||
}: {
|
||||
timelineH: number;
|
||||
setTimelineH: React.Dispatch<React.SetStateAction<number>>;
|
||||
persistTimelineH: (h: number) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const isDragging = useRef(false);
|
||||
const timelineHRef = useRef(timelineH);
|
||||
timelineHRef.current = timelineH;
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[disabled],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
if (!isDragging.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const containerH = rect.height;
|
||||
const newTimelineH = Math.max(
|
||||
MIN_TIMELINE_H,
|
||||
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
||||
);
|
||||
setTimelineH(newTimelineH);
|
||||
},
|
||||
[disabled, containerRef, setTimelineH],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (isDragging.current) persistTimelineH(timelineHRef.current);
|
||||
isDragging.current = false;
|
||||
}, [persistTimelineH]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
||||
e.preventDefault();
|
||||
const containerH = containerRef.current?.getBoundingClientRect().height ?? Infinity;
|
||||
const delta = e.key === "ArrowUp" ? 16 : -16;
|
||||
setTimelineH((prev) => {
|
||||
const next = Math.max(MIN_TIMELINE_H, Math.min(containerH - MIN_PREVIEW_H, prev + delta));
|
||||
persistTimelineH(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[disabled, containerRef, setTimelineH, persistTimelineH],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize timeline (arrow keys)"
|
||||
aria-valuenow={Math.round(timelineH)}
|
||||
aria-valuemin={MIN_TIMELINE_H}
|
||||
aria-valuemax={Math.round(
|
||||
(containerRef.current?.getBoundingClientRect().height ?? 600) - MIN_PREVIEW_H,
|
||||
)}
|
||||
tabIndex={0}
|
||||
className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10 outline-none focus-visible:bg-studio-accent/20"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22 group-focus-visible:bg-studio-accent/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useCallback, useState, type RefObject } from "react";
|
||||
import { useCallback, useRef, useState, type RefObject } from "react";
|
||||
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||
|
||||
interface UsePreviewBlockDropOptions {
|
||||
portrait?: boolean;
|
||||
/**
|
||||
* Authored composition size measured from the live preview. Preferred over
|
||||
* the portrait fallback — hard-coding 1080/1920 places drops at the wrong
|
||||
* spot for any composition authored at another size (square, 720p, 4K).
|
||||
*/
|
||||
compositionSize?: { width: number; height: number } | null;
|
||||
stageRef: RefObject<HTMLDivElement | null>;
|
||||
onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void;
|
||||
}
|
||||
@@ -28,6 +34,7 @@ function resolveCompositionPosition(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
stageRect: DOMRect,
|
||||
compositionSize: { width: number; height: number } | null | undefined,
|
||||
portrait: boolean | undefined,
|
||||
): { left: number; top: number } | null {
|
||||
if (stageRect.width === 0 || stageRect.height === 0) return null;
|
||||
@@ -35,8 +42,8 @@ function resolveCompositionPosition(
|
||||
const normalizedX = (clientX - stageRect.left) / stageRect.width;
|
||||
const normalizedY = (clientY - stageRect.top) / stageRect.height;
|
||||
|
||||
const compWidth = portrait ? 1080 : 1920;
|
||||
const compHeight = portrait ? 1920 : 1080;
|
||||
const compWidth = compositionSize?.width ?? (portrait ? 1080 : 1920);
|
||||
const compHeight = compositionSize?.height ?? (portrait ? 1920 : 1080);
|
||||
|
||||
return {
|
||||
left: Math.max(0, Math.min(normalizedX * compWidth, compWidth)),
|
||||
@@ -58,10 +65,24 @@ function centerBlockAtPosition(
|
||||
|
||||
export function usePreviewBlockDrop({
|
||||
portrait,
|
||||
compositionSize,
|
||||
stageRef,
|
||||
onBlockDrop,
|
||||
}: UsePreviewBlockDropOptions) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
// dragenter/dragleave fire for every internal element boundary; a depth
|
||||
// counter keeps the drop indicator steady instead of flickering.
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (!onBlockDrop) return;
|
||||
if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
|
||||
dragDepthRef.current += 1;
|
||||
setIsDragOver(true);
|
||||
},
|
||||
[onBlockDrop],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
@@ -69,18 +90,20 @@ export function usePreviewBlockDrop({
|
||||
if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
// dragenter/dragleave own the isDragOver flag (depth-counted).
|
||||
},
|
||||
[onBlockDrop],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragOver(false);
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
dragDepthRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
if (!onBlockDrop) return;
|
||||
|
||||
@@ -96,14 +119,15 @@ export function usePreviewBlockDrop({
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
stage.getBoundingClientRect(),
|
||||
compositionSize,
|
||||
portrait,
|
||||
);
|
||||
if (!pos) return;
|
||||
|
||||
onBlockDrop(block.name, centerBlockAtPosition(pos, block));
|
||||
},
|
||||
[onBlockDrop, stageRef, portrait],
|
||||
[onBlockDrop, stageRef, compositionSize, portrait],
|
||||
);
|
||||
|
||||
return { isDragOver, handleDragOver, handleDragLeave, handleDrop };
|
||||
return { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useState, useRef, useEffect } from "react";
|
||||
import { memo, useState, useRef, useEffect, useId } from "react";
|
||||
import { RenderQueueItem } from "./RenderQueueItem";
|
||||
import { Button } from "../ui/Button";
|
||||
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
||||
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
@@ -20,9 +21,17 @@ interface RenderQueueProps {
|
||||
jobs: RenderJob[];
|
||||
projectId: string;
|
||||
onDelete: (jobId: string) => void;
|
||||
onCancel?: (jobId: string) => void;
|
||||
onClearCompleted: () => void;
|
||||
onStartRender: StartRenderHandler;
|
||||
isRendering: boolean;
|
||||
/** History fetch failure (null when the last load succeeded). */
|
||||
loadError?: string | null;
|
||||
/** Retry a failed history load. */
|
||||
onRetryLoad?: () => void;
|
||||
/** Failure of a delete/cancel action, shown inline until dismissed. */
|
||||
actionError?: string | null;
|
||||
onDismissActionError?: () => void;
|
||||
/**
|
||||
* Authored dimensions of the active composition. Used to pick the
|
||||
* matching preset (landscape / portrait / square) when the user selects
|
||||
@@ -110,9 +119,15 @@ function scaleOptionLabel(
|
||||
dims: CompositionDimensions | null | undefined,
|
||||
): string {
|
||||
const resolved = resolvedDimensions(scale, dims);
|
||||
return resolved
|
||||
const base = resolved
|
||||
? `${SCALE_LABEL[scale]} · ${resolved.width}×${resolved.height}`
|
||||
: SCALE_LABEL[scale];
|
||||
// Explain *why* an option is disabled instead of greying it silently:
|
||||
// the preset must be an exact integer upscale of the authored size.
|
||||
if (dims && !scaleApplies(scale, dims)) {
|
||||
return `${base} — not an integer scale of ${dims.width}×${dims.height}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
|
||||
@@ -127,9 +142,14 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
|
||||
},
|
||||
};
|
||||
|
||||
// Rich format guidance in a keyboard-reachable disclosure: the trigger is a
|
||||
// real button (focusable, labelled), the panel is tied to it via
|
||||
// aria-describedby, and Escape dismisses (WCAG 1.4.13). Content is too rich
|
||||
// for the one-line ui/Tooltip primitive, so this stays a local popover.
|
||||
function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const panelId = useId();
|
||||
|
||||
const show = () => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
@@ -141,27 +161,51 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [open]);
|
||||
|
||||
const info = FORMAT_INFO[format];
|
||||
|
||||
return (
|
||||
<div className="relative" onPointerEnter={show} onPointerLeave={hide}>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="About video formats"
|
||||
aria-expanded={open}
|
||||
aria-describedby={open ? panelId : undefined}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex items-center justify-center p-0.5 -m-0.5 rounded text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50">
|
||||
<div
|
||||
id={panelId}
|
||||
role="tooltip"
|
||||
className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50"
|
||||
>
|
||||
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
|
||||
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
|
||||
<div className="mt-1.5 pt-1.5 border-t border-neutral-800">
|
||||
@@ -191,14 +235,21 @@ const QUALITY_OPTIONS: {
|
||||
{ value: "high", label: "High Quality", title: "Best quality, larger file" },
|
||||
];
|
||||
|
||||
function formatEta(ms: number): string {
|
||||
const s = Math.round(ms / 1000);
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
function FormatExportButton({
|
||||
onStartRender,
|
||||
isRendering,
|
||||
compositionDimensions,
|
||||
lastRenderDurationMs,
|
||||
}: {
|
||||
onStartRender: StartRenderHandler;
|
||||
isRendering: boolean;
|
||||
compositionDimensions?: CompositionDimensions | null;
|
||||
lastRenderDurationMs?: number;
|
||||
}) {
|
||||
const persisted = getPersistedRenderSettings();
|
||||
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
|
||||
@@ -293,16 +344,26 @@ function FormatExportButton({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
loading={isRendering}
|
||||
onClick={() => {
|
||||
// loading already disables the button; this guard also stops a
|
||||
// double-click in the same frame from enqueueing two renders.
|
||||
if (isRendering) return;
|
||||
trackStudioEvent("render_start", { format, quality, resolution, fps });
|
||||
void onStartRender(format, quality, resolution, fps);
|
||||
}}
|
||||
disabled={isRendering}
|
||||
className="w-full flex items-center justify-center h-8 text-[11px] font-semibold rounded-md bg-panel-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
|
||||
className="w-full text-[11px] font-semibold"
|
||||
>
|
||||
{isRendering ? "Rendering..." : "Export"}
|
||||
</button>
|
||||
{isRendering ? "Rendering…" : "Export"}
|
||||
</Button>
|
||||
{lastRenderDurationMs !== undefined && !isRendering && (
|
||||
<p className="text-[9px] text-panel-text-5 text-center -mt-1.5">
|
||||
Last render took {formatEta(lastRenderDurationMs)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -311,9 +372,14 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
jobs,
|
||||
projectId,
|
||||
onDelete,
|
||||
onCancel,
|
||||
onClearCompleted,
|
||||
onStartRender,
|
||||
isRendering,
|
||||
loadError,
|
||||
onRetryLoad,
|
||||
actionError,
|
||||
onDismissActionError,
|
||||
compositionDimensions,
|
||||
}: RenderQueueProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
@@ -327,6 +393,9 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
}, [jobs.length]);
|
||||
|
||||
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
|
||||
const lastRenderDurationMs = [...jobs]
|
||||
.reverse()
|
||||
.find((j) => j.status === "complete" && j.durationMs !== undefined)?.durationMs;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -335,12 +404,40 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
onStartRender={onStartRender}
|
||||
isRendering={isRendering}
|
||||
compositionDimensions={compositionDimensions}
|
||||
lastRenderDurationMs={lastRenderDurationMs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start justify-between gap-2 px-3 py-2 border-b border-panel-border bg-red-500/10"
|
||||
>
|
||||
<span className="text-[10px] text-red-400">{actionError}</span>
|
||||
{onDismissActionError && (
|
||||
<button
|
||||
onClick={onDismissActionError}
|
||||
aria-label="Dismiss error"
|
||||
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 flex-shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job list */}
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto">
|
||||
{jobs.length === 0 ? (
|
||||
{loadError && jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4 gap-2" role="alert">
|
||||
<p className="text-[10px] text-red-400 text-center">{loadError}</p>
|
||||
{onRetryLoad && (
|
||||
<Button size="sm" variant="secondary" onClick={onRetryLoad}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
@@ -376,11 +473,14 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
<span className="text-[10px] text-panel-text-4">
|
||||
{jobs.length} render{jobs.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
{/* "Hide", not "Clear": files stay on disk (delete is per-row
|
||||
and confirmed); hidden rows don't resurrect on reload. */}
|
||||
<button
|
||||
onClick={onClearCompleted}
|
||||
title="Hide finished renders from this list (files stay on disk)"
|
||||
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
|
||||
>
|
||||
Clear
|
||||
Hide finished
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -390,6 +490,7 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
job={job}
|
||||
projectId={projectId}
|
||||
onDelete={() => onDelete(job.id)}
|
||||
onCancel={() => onCancel?.(job.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||
import { Button } from "../ui/Button";
|
||||
import type { RenderJob } from "./useRenderQueue";
|
||||
|
||||
interface RenderQueueItemProps {
|
||||
job: RenderJob;
|
||||
projectId: string;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -27,8 +29,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
job,
|
||||
projectId,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}: RenderQueueItemProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
// Direct file URL — serves from disk, survives server restarts
|
||||
const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
|
||||
@@ -50,25 +55,35 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
|
||||
const viewSrc = fileSrc;
|
||||
const isComplete = job.status === "complete";
|
||||
const isRendering = job.status === "rendering";
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
onClick={isComplete ? handleOpen : undefined}
|
||||
className={[
|
||||
"px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150",
|
||||
isComplete ? "cursor-pointer hover:bg-panel-hover/30" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onPointerLeave={() => {
|
||||
setHovered(false);
|
||||
setVideoReady(false);
|
||||
setConfirmingDelete(false);
|
||||
}}
|
||||
className="px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150 hover:bg-panel-hover/30"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Thumbnail — static frame; swaps to live video on hover */}
|
||||
<div className="w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative">
|
||||
{/* Thumbnail — static frame; swaps to live video on hover.
|
||||
A real button so keyboard users can open the render too. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={isComplete ? handleOpen : undefined}
|
||||
disabled={!isComplete}
|
||||
aria-label={isComplete ? `Open ${job.filename} in a new tab` : undefined}
|
||||
className={[
|
||||
"w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative",
|
||||
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent",
|
||||
isComplete ? "cursor-pointer" : "cursor-default",
|
||||
].join(" ")}
|
||||
>
|
||||
{isComplete && (
|
||||
<>
|
||||
{/* Live video — visible on hover */}
|
||||
{/* Live video — fades in over the static frame once it can play */}
|
||||
{hovered && (
|
||||
<video
|
||||
src={viewSrc}
|
||||
@@ -76,21 +91,23 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
onCanPlay={() => setVideoReady(true)}
|
||||
className="absolute inset-0 w-full h-full object-contain transition-opacity duration-150"
|
||||
style={{ opacity: videoReady ? 1 : 0 }}
|
||||
/>
|
||||
)}
|
||||
{/* Static frame — visible when not hovering */}
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-150"
|
||||
style={{ opacity: hovered ? 0 : 1 }}
|
||||
style={{ opacity: hovered && videoReady ? 0 : 1 }}
|
||||
>
|
||||
<VideoFrameThumbnail src={viewSrc} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{job.status === "rendering" && (
|
||||
{isRendering && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse" />
|
||||
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse motion-reduce:animate-none" />
|
||||
</div>
|
||||
)}
|
||||
{job.status === "failed" && (
|
||||
@@ -103,7 +120,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
<div className="w-2 h-2 rounded-full bg-neutral-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -118,13 +135,20 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.status === "rendering" && (
|
||||
{isRendering && (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
|
||||
<span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-panel-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className="w-full h-1 bg-panel-border rounded-full overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-valuenow={job.progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`Render progress: ${job.progress}%`}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-panel-accent rounded-full transition-all duration-300"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
@@ -136,59 +160,103 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
{job.status === "failed" && job.error && (
|
||||
<span className="text-[9px] text-red-400 mt-0.5 block">{job.error}</span>
|
||||
)}
|
||||
{job.status === "cancelled" && (
|
||||
<span className="text-[9px] text-panel-text-4 mt-0.5 block">Cancelled</span>
|
||||
)}
|
||||
|
||||
{job.status !== "rendering" && (
|
||||
{!isRendering && (
|
||||
<span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions — always visible to prevent layout shifts */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={isComplete ? handleDownload : undefined}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isComplete
|
||||
? "text-panel-text-5 hover:text-panel-accent"
|
||||
: "text-panel-text-5/30 pointer-events-none"
|
||||
}`}
|
||||
title={isComplete ? "Download" : "Rendering..."}
|
||||
disabled={!isComplete}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{isRendering ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
Cancel
|
||||
</Button>
|
||||
) : confirmingDelete ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmingDelete(false);
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
Delete?
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmingDelete(false);
|
||||
}}
|
||||
>
|
||||
Keep
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={isComplete ? handleDownload : undefined}
|
||||
className={`p-1.5 min-w-6 min-h-6 rounded transition-colors outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent ${
|
||||
isComplete
|
||||
? "text-panel-text-5 hover:text-panel-accent"
|
||||
: "text-panel-text-5/30 cursor-default"
|
||||
}`}
|
||||
title={isComplete ? "Download" : undefined}
|
||||
aria-label={`Download ${job.filename}`}
|
||||
disabled={!isComplete}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmingDelete(true);
|
||||
}}
|
||||
className="p-1.5 min-w-6 min-h-6 rounded text-panel-text-5 hover:text-red-400 transition-colors outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
|
||||
title="Delete render file"
|
||||
aria-label={`Delete ${job.filename}`}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,23 +36,66 @@ export interface StartRenderOptions {
|
||||
composition?: string;
|
||||
}
|
||||
|
||||
// "Hide" (formerly "Clear") is a view operation, not a delete: hidden ids are
|
||||
// remembered here so hidden renders don't resurrect from the on-disk history
|
||||
// on the next load. Per-project key so projects don't hide each other's rows.
|
||||
function hiddenIdsKey(projectId: string): string {
|
||||
return `hf-studio-hidden-renders:${projectId}`;
|
||||
}
|
||||
|
||||
function readHiddenIds(projectId: string): Set<string> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(hiddenIdsKey(projectId));
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function writeHiddenIds(projectId: string, ids: Set<string>): void {
|
||||
try {
|
||||
// Cap the list so it doesn't grow unbounded across months of renders.
|
||||
window.localStorage.setItem(hiddenIdsKey(projectId), JSON.stringify([...ids].slice(-200)));
|
||||
} catch {
|
||||
/* localStorage may be unavailable or full */
|
||||
}
|
||||
}
|
||||
|
||||
export function useRenderQueue(projectId: string | null) {
|
||||
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
||||
// History fetch failure — distinguished from "no renders yet" so the panel
|
||||
// never shows a false empty state.
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
// Failure of a user action (delete/cancel), surfaced inline in the panel.
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const activeJobRef = useRef<string | null>(null);
|
||||
|
||||
const closeActiveEventSource = useCallback((jobId?: string) => {
|
||||
if (jobId && activeJobRef.current !== jobId) return;
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
activeJobRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Load completed renders from the server
|
||||
const loadRenders = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/renders`);
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
setLoadError(`Couldn't load render history (server error ${res.status}).`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setLoadError(null);
|
||||
if (Array.isArray(data.renders)) {
|
||||
const hidden = readHiddenIds(projectId);
|
||||
setJobs((prev) => {
|
||||
const existing = new Set(prev.map((j) => j.id));
|
||||
const fromServer: RenderJob[] = data.renders
|
||||
.filter((r: { id: string }) => !existing.has(r.id))
|
||||
.filter((r: { id: string }) => !existing.has(r.id) && !hidden.has(r.id))
|
||||
.map(
|
||||
(r: {
|
||||
id: string;
|
||||
@@ -74,7 +117,7 @@ export function useRenderQueue(projectId: string | null) {
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
setLoadError("Couldn't load render history. Is the studio server running?");
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
@@ -175,6 +218,8 @@ export function useRenderQueue(projectId: string | null) {
|
||||
es.addEventListener("progress", (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const terminal =
|
||||
data.status === "complete" || data.status === "failed" || data.status === "cancelled";
|
||||
setJobs((prev) =>
|
||||
prev.map((j) =>
|
||||
j.id === jobId
|
||||
@@ -182,21 +227,15 @@ export function useRenderQueue(projectId: string | null) {
|
||||
...j,
|
||||
progress: data.progress ?? j.progress,
|
||||
stage: data.stage ?? data.message ?? j.stage,
|
||||
status:
|
||||
data.status === "complete"
|
||||
? "complete"
|
||||
: data.status === "failed"
|
||||
? "failed"
|
||||
: j.status,
|
||||
status: terminal ? (data.status as RenderJob["status"]) : j.status,
|
||||
durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
|
||||
error: data.error ?? j.error,
|
||||
}
|
||||
: j,
|
||||
),
|
||||
);
|
||||
if (data.status === "complete" || data.status === "failed") {
|
||||
es.close();
|
||||
activeJobRef.current = null;
|
||||
if (terminal) {
|
||||
closeActiveEventSource(jobId);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
@@ -221,21 +260,78 @@ export function useRenderQueue(projectId: string | null) {
|
||||
|
||||
return jobId;
|
||||
},
|
||||
[projectId],
|
||||
[projectId, closeActiveEventSource],
|
||||
);
|
||||
|
||||
const deleteRender = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
await fetch(`/api/render/${jobId}`, { method: "DELETE" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
||||
}, []);
|
||||
// Cancel an in-flight render. The job row stays (as "cancelled") so the
|
||||
// user sees the outcome; the SSE stream is closed either way.
|
||||
const cancelRender = useCallback(
|
||||
async (jobId: string) => {
|
||||
setActionError(null);
|
||||
closeActiveEventSource(jobId);
|
||||
setJobs((prev) =>
|
||||
prev.map((j) =>
|
||||
j.id === jobId && j.status === "rendering" ? { ...j, status: "cancelled" } : j,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`/api/render/${jobId}/cancel`, { method: "POST" });
|
||||
if (!res.ok && res.status !== 404) {
|
||||
setActionError("Couldn't cancel on the server — the render may still be running.");
|
||||
return;
|
||||
}
|
||||
// Reconcile with the status the route reports: if the render actually
|
||||
// finished (or failed) before the cancel landed, don't leave the row
|
||||
// stuck on the optimistic "cancelled" — reload to pick up the real
|
||||
// outcome (and the finished file's metadata).
|
||||
if (res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { status?: string } | null;
|
||||
if (body?.status && body.status !== "cancelled") {
|
||||
void loadRenders();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setActionError("Couldn't reach the server to cancel — the render may still be running.");
|
||||
}
|
||||
},
|
||||
[closeActiveEventSource, loadRenders],
|
||||
);
|
||||
|
||||
const deleteRender = useCallback(
|
||||
async (jobId: string) => {
|
||||
setActionError(null);
|
||||
closeActiveEventSource(jobId);
|
||||
try {
|
||||
const res = await fetch(`/api/render/${jobId}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
setActionError("Couldn't delete the render — it's still on disk.");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setActionError("Couldn't reach the server to delete the render.");
|
||||
return;
|
||||
}
|
||||
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
||||
},
|
||||
[closeActiveEventSource],
|
||||
);
|
||||
|
||||
// Hide finished rows from the list (view-only — files stay on disk and can
|
||||
// be recovered from the renders/ directory). Remembered per project so the
|
||||
// rows don't resurrect from history on reload.
|
||||
const clearCompleted = useCallback(() => {
|
||||
setJobs((prev) => prev.filter((j) => j.status === "rendering"));
|
||||
}, []);
|
||||
setJobs((prev) => {
|
||||
const finished = prev.filter((j) => j.status !== "rendering");
|
||||
if (projectId && finished.length > 0) {
|
||||
const hidden = readHiddenIds(projectId);
|
||||
for (const j of finished) hidden.add(j.id);
|
||||
writeHiddenIds(projectId, hidden);
|
||||
}
|
||||
return prev.filter((j) => j.status === "rendering");
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const dismissActionError = useCallback(() => setActionError(null), []);
|
||||
|
||||
// Clean up EventSource on unmount or projectId change
|
||||
useEffect(() => {
|
||||
@@ -250,10 +346,26 @@ export function useRenderQueue(projectId: string | null) {
|
||||
() => ({
|
||||
jobs,
|
||||
isRendering,
|
||||
loadError,
|
||||
actionError,
|
||||
dismissActionError,
|
||||
reloadRenders: loadRenders,
|
||||
deleteRender,
|
||||
cancelRender,
|
||||
clearCompleted,
|
||||
startRender: startRender as (options: unknown) => Promise<void>,
|
||||
}),
|
||||
[jobs, isRendering, deleteRender, clearCompleted, startRender],
|
||||
[
|
||||
jobs,
|
||||
isRendering,
|
||||
loadError,
|
||||
actionError,
|
||||
dismissActionError,
|
||||
loadRenders,
|
||||
deleteRender,
|
||||
cancelRender,
|
||||
clearCompleted,
|
||||
startRender,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframe
|
||||
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
||||
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
||||
import { useViewMode } from "../../contexts/ViewModeContext";
|
||||
import { Button } from "../ui/Button";
|
||||
import { FramePoster, posterTime } from "./FramePoster";
|
||||
import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
|
||||
|
||||
@@ -67,7 +68,25 @@ export function StoryboardFrameFocus({
|
||||
const dirty = draft !== (frame.voiceover ?? "");
|
||||
const canOpenPreview = frame.srcExists && Boolean(frame.src);
|
||||
|
||||
// Leaving the frame drops the in-memory voiceover draft; confirm when it's dirty.
|
||||
const saveVoiceover = useCallback(() => {
|
||||
return applyEdit((src) => setFrameVoiceover(src, frame.index, draft));
|
||||
}, [applyEdit, frame.index, draft]);
|
||||
|
||||
// Closing the tab with a dirty voiceover would lose it silently — same
|
||||
// guard the sibling markdown editor registers for the same class of loss.
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, [dirty]);
|
||||
|
||||
// Leaving the frame drops the in-memory voiceover draft; confirm while it's
|
||||
// dirty. An in-flight save does NOT count as safe: if it fails after unmount
|
||||
// the error lands on an unmounted component and the draft is silently lost,
|
||||
// so keep confirming until the save actually lands (dirty clears on success).
|
||||
const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?");
|
||||
const handleBack = () => {
|
||||
if (confirmLeave()) onBack();
|
||||
@@ -158,18 +177,27 @@ export function StoryboardFrameFocus({
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
🎙 Voiceover <span className="font-normal normal-case text-neutral-600">guide</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyEdit((src) => setFrameVoiceover(src, frame.index, draft))}
|
||||
disabled={!dirty || busy}
|
||||
className="rounded bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={saveVoiceover}
|
||||
disabled={!dirty}
|
||||
loading={busy}
|
||||
className="bg-emerald-600 text-white enabled:hover:bg-emerald-500 shadow-none"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Same autosave paradigm as the status row above — mixed save
|
||||
// models inside one panel taught users the panel autosaves,
|
||||
// then lost their voiceover. Explicit Save stays as the
|
||||
// affordance; blur is the safety net.
|
||||
if (dirty && !busy) void saveVoiceover();
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="What the narrator says over this frame…"
|
||||
className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 outline-none focus:border-neutral-600"
|
||||
@@ -189,14 +217,9 @@ export function StoryboardFrameFocus({
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openInPreview}
|
||||
disabled={!canOpenPreview}
|
||||
className="rounded border border-neutral-700 px-3 py-1.5 text-xs font-medium text-neutral-200 hover:bg-neutral-800 disabled:opacity-40"
|
||||
>
|
||||
<Button size="sm" variant="secondary" onClick={openInPreview} disabled={!canOpenPreview}>
|
||||
Open in Preview →
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +240,7 @@ function NavButton({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 disabled:opacity-30"
|
||||
className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 enabled:active:scale-[0.98] transition-transform disabled:opacity-30"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
|
||||
@@ -113,16 +113,33 @@ const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
|
||||
];
|
||||
|
||||
function SubViewToggle({ value, onChange }: { value: SubView; onChange: (next: SubView) => void }) {
|
||||
// Complete tabs contract: roving tabIndex + arrow-key navigation (the roles
|
||||
// alone promised keyboard behavior the buttons didn't have).
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
const currentIndex = SUB_VIEWS.findIndex((v) => v.value === value);
|
||||
const delta = e.key === "ArrowRight" ? 1 : -1;
|
||||
const next = SUB_VIEWS[(currentIndex + delta + SUB_VIEWS.length) % SUB_VIEWS.length];
|
||||
if (next) onChange(next.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5" role="tablist">
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5"
|
||||
role="tablist"
|
||||
aria-label="Storyboard view"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{SUB_VIEWS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === option.value}
|
||||
tabIndex={value === option.value ? 0 : -1}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors active:scale-[0.98] outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent ${
|
||||
value === option.value
|
||||
? "bg-neutral-700 text-neutral-100"
|
||||
: "text-neutral-400 hover:text-neutral-200"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useStoryboard } from "../../hooks/useStoryboard";
|
||||
import { Button } from "../ui/Button";
|
||||
import { StoryboardLoaded } from "./StoryboardLoaded";
|
||||
|
||||
export interface StoryboardViewProps {
|
||||
@@ -22,6 +23,11 @@ export function StoryboardView({ projectId, onSelectComposition }: StoryboardVie
|
||||
return (
|
||||
<StoryboardFrame>
|
||||
<Message tone="error">Couldn’t load the storyboard: {error}</Message>
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="secondary" onClick={reload}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</StoryboardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ export interface StudioShellValue {
|
||||
renderQueue: {
|
||||
jobs: unknown[];
|
||||
isRendering: boolean;
|
||||
loadError: string | null;
|
||||
actionError: string | null;
|
||||
dismissActionError: () => void;
|
||||
reloadRenders: () => void;
|
||||
deleteRender: (jobId: string) => void;
|
||||
cancelRender: (jobId: string) => void;
|
||||
clearCompleted: () => void;
|
||||
startRender: (options: unknown) => Promise<void>;
|
||||
};
|
||||
@@ -58,6 +63,7 @@ export function useStudioPlaybackContext(): StudioPlaybackValue {
|
||||
}
|
||||
|
||||
/** @deprecated Use useStudioShellContext and/or useStudioPlaybackContext instead. */
|
||||
// fallow-ignore-next-line unused-export
|
||||
export function useStudioContext(): StudioContextValue {
|
||||
const shell = useStudioShellContext();
|
||||
const playback = useStudioPlaybackContext();
|
||||
@@ -166,6 +172,7 @@ export function StudioPlaybackProvider({
|
||||
}
|
||||
|
||||
/** @deprecated Use StudioShellProvider and StudioPlaybackProvider instead. */
|
||||
// fallow-ignore-next-line unused-export
|
||||
export function StudioProvider({
|
||||
value,
|
||||
children,
|
||||
|
||||
@@ -21,7 +21,12 @@ interface StudioContextInput {
|
||||
renderQueue: {
|
||||
jobs: unknown[];
|
||||
isRendering: boolean;
|
||||
loadError: string | null;
|
||||
actionError: string | null;
|
||||
dismissActionError: () => void;
|
||||
reloadRenders: () => void;
|
||||
deleteRender: (id: string) => void;
|
||||
cancelRender: (id: string) => void;
|
||||
clearCompleted: () => void;
|
||||
startRender: (options: unknown) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface StoredPreviewZoomState {
|
||||
export interface StudioUiPreferences {
|
||||
leftCollapsed?: boolean;
|
||||
timelineVisible?: boolean;
|
||||
timelineHeight?: number;
|
||||
playbackRate?: number;
|
||||
audioMuted?: boolean;
|
||||
previewZoom?: StoredPreviewZoomState;
|
||||
@@ -48,6 +49,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
||||
if (typeof parsed.timelineVisible === "boolean") {
|
||||
preferences.timelineVisible = parsed.timelineVisible;
|
||||
}
|
||||
if (typeof parsed.timelineHeight === "number" && Number.isFinite(parsed.timelineHeight)) {
|
||||
preferences.timelineHeight = parsed.timelineHeight;
|
||||
}
|
||||
if (typeof parsed.playbackRate === "number" && Number.isFinite(parsed.playbackRate)) {
|
||||
preferences.playbackRate = parsed.playbackRate;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
realpathSync,
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join, relative, resolve, isAbsolute, dirname } from "node:path";
|
||||
import type { ViteDevServer } from "vite";
|
||||
@@ -204,14 +205,31 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
|
||||
rendersDir: () => resolve(dataDir, "../renders"),
|
||||
|
||||
startRender(opts): RenderJobState {
|
||||
const abortController = new AbortController();
|
||||
const state: RenderJobState = {
|
||||
id: opts.jobId,
|
||||
status: "rendering",
|
||||
progress: 0,
|
||||
outputPath: opts.outputPath,
|
||||
cancel: () => abortController.abort(),
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
const removeCancelledOutput = () => {
|
||||
// User-initiated cancel: not a failure. Remove any output so the
|
||||
// cancelled job doesn't resurrect in the render history.
|
||||
state.status = "cancelled";
|
||||
for (const fp of [
|
||||
opts.outputPath,
|
||||
opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json"),
|
||||
]) {
|
||||
try {
|
||||
if (existsSync(fp)) unlinkSync(fp);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
// fallow-ignore-next-line complexity
|
||||
(async () => {
|
||||
try {
|
||||
@@ -233,7 +251,19 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
|
||||
state.progress = j.progress;
|
||||
if (j.currentStage) state.stage = j.currentStage;
|
||||
};
|
||||
await executeRenderJob(job, opts.project.dir, opts.outputPath, onProgress);
|
||||
await executeRenderJob(
|
||||
job,
|
||||
opts.project.dir,
|
||||
opts.outputPath,
|
||||
onProgress,
|
||||
abortController.signal,
|
||||
);
|
||||
if (abortController.signal.aborted) {
|
||||
// Cancel landed just as the render finished: honor the cancel the
|
||||
// route already reported instead of resurrecting a completed job.
|
||||
removeCancelledOutput();
|
||||
return;
|
||||
}
|
||||
state.status = "complete";
|
||||
state.progress = 100;
|
||||
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
||||
@@ -242,6 +272,10 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
|
||||
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
|
||||
);
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) {
|
||||
removeCancelledOutput();
|
||||
return;
|
||||
}
|
||||
state.status = "failed";
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user