feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX (#1963)

This commit is contained in:
Vance Ingalls
2026-07-06 16:43:48 -07:00
committed by GitHub
parent ec06f4bf89
commit 241f9d683e
19 changed files with 866 additions and 201 deletions
@@ -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">Couldnt 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;
}
+35 -1
View File
@@ -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 {