diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index f48458a1f6..9c48b21e09 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -7,7 +7,7 @@ import { Hono, type Context } from "hono"; import { streamSSE } from "hono/streaming"; -import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync } from "node:fs"; import { resolve, join, basename } from "node:path"; import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js"; import { @@ -349,17 +349,34 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { rendersDir: () => join(projectDir, "renders"), startRender(opts): RenderJobState { + const abortController = new AbortController(); const state: RenderJobState = { id: opts.jobId, status: "rendering", progress: 0, outputPath: opts.outputPath, + cancel: () => abortController.abort(), }; // Run render asynchronously, mutating the state object const startTime = Date.now(); (async () => { let renderJob: RenderJob | undefined; + 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 suffix of ["", ".meta.json"]) { + const fp = suffix + ? opts.outputPath.replace(/\.(mp4|webm|mov)$/, suffix) + : opts.outputPath; + try { + if (existsSync(fp)) unlinkSync(fp); + } catch { + /* ignore */ + } + } + }; try { const { createRenderJob, executeRenderJob } = await import("@hyperframes/producer"); const { ensureBrowser } = await import("../browser/manager.js"); @@ -390,7 +407,19 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { 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"); @@ -400,6 +429,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { ); emitStudioRenderComplete(opts, Date.now() - startTime, job.perfSummary); } catch (err) { + if (abortController.signal.aborted) { + removeCancelledOutput(); + return; + } state.status = "failed"; state.error = err instanceof Error ? err.message : String(err); // fallow-ignore-next-line code-duplication diff --git a/packages/studio-server/src/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts index 461419b2d3..e3d4896a88 100644 --- a/packages/studio-server/src/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -429,6 +429,81 @@ describe("GET /projects/:id/renders/file/* — path safety", () => { }); }); +describe("POST /render/:jobId/cancel", () => { + async function startJob(app: Hono): Promise { + const res = await app.request("http://localhost/projects/demo/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }), + }); + expect(res.status).toBe(200); + return ((await res.json()) as { jobId: string }).jobId; + } + + it("marks a rendering job cancelled and invokes the adapter abort hook", async () => { + const spy = vi.fn(); + let aborted = false; + const { adapter, rendersDir } = createAdapter(spy); + const baseStartRender = adapter.startRender.bind(adapter); + adapter.startRender = (opts) => { + const state = baseStartRender(opts); + state.cancel = () => { + aborted = true; + }; + return state; + }; + const app = new Hono(); + registerRenderRoutes(app, adapter); + try { + const jobId = await startJob(app); + const res = await app.request(`http://localhost/render/${jobId}/cancel`, { method: "POST" }); + expect(res.status).toBe(200); + expect(((await res.json()) as { status: string }).status).toBe("cancelled"); + expect(aborted).toBe(true); + // SSE progress for a cancelled job must terminate (status is terminal). + const progress = await app.request(`http://localhost/render/${jobId}/progress`); + expect(progress.status).toBe(200); + } finally { + rmSync(rendersDir, { recursive: true, force: true }); + } + }); + + it("does not cancel a job that already completed", async () => { + const spy = vi.fn(); + const states: Array<{ status: string }> = []; + const { adapter, rendersDir } = createAdapter(spy); + const baseStartRender = adapter.startRender.bind(adapter); + adapter.startRender = (opts) => { + const state = baseStartRender(opts); + states.push(state); + return state; + }; + const app = new Hono(); + registerRenderRoutes(app, adapter); + try { + const jobId = await startJob(app); + const [state] = states; + if (state) state.status = "complete"; + const res = await app.request(`http://localhost/render/${jobId}/cancel`, { method: "POST" }); + expect(res.status).toBe(200); + expect(((await res.json()) as { status: string }).status).toBe("complete"); + } finally { + rmSync(rendersDir, { recursive: true, force: true }); + } + }); + + it("404s for unknown jobs", async () => { + const spy = vi.fn(); + const { app, cleanup } = buildApp(spy); + try { + const res = await app.request("http://localhost/render/nope/cancel", { method: "POST" }); + expect(res.status).toBe(404); + } finally { + cleanup(); + } + }); +}); + describe("POST /projects/:id/render — telemetryDistinctId forwarding", () => { it("forwards the browser telemetryDistinctId to the adapter as distinctId", async () => { const spy = vi.fn(); diff --git a/packages/studio-server/src/routes/render.ts b/packages/studio-server/src/routes/render.ts index 3cd4bb68e7..369fbe9057 100644 --- a/packages/studio-server/src/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -26,7 +26,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void const cleanupFinishedJobs = () => { const now = Date.now(); for (const [key, job] of renderJobs) { - if ((job.status === "complete" || job.status === "failed") && now - job.createdAt > TTL_MS) { + if (job.status !== "rendering" && now - job.createdAt > TTL_MS) { renderJobs.delete(key); } } @@ -142,12 +142,25 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void error: current.error, }), }); - if (current.status === "complete" || current.status === "failed") break; + if (current.status !== "rendering") break; await stream.sleep(500); } }); }); + // Cancel an in-flight render. Marks the job cancelled immediately (so the + // SSE stream terminates) and invokes the adapter's abort hook when present. + api.post("/render/:jobId/cancel", (c) => { + const { jobId } = c.req.param(); + const job = renderJobs.get(jobId); + if (!job) return c.json({ error: "not found" }, 404); + if (job.status === "rendering") { + job.status = "cancelled"; + job.cancel?.(); + } + return c.json({ status: job.status }); + }); + const RENDER_MIME: Record = { ".mp4": "video/mp4", ".webm": "video/webm", diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts index 97e5aca9fa..a4634effd9 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -12,11 +12,17 @@ export interface ResolvedProject { /** Observable render job state, polled by the SSE progress handler. */ export interface RenderJobState { id: string; - status: "rendering" | "complete" | "failed"; + status: "rendering" | "complete" | "failed" | "cancelled"; progress: number; stage?: string; outputPath: string; error?: string; + /** + * Optional abort hook set by the adapter. The cancel route calls this to + * stop an in-flight render; adapters that can't abort may omit it (the + * route still marks the job cancelled so the SSE stream terminates). + */ + cancel?: () => void; } /** Lint result from the core linter. */ diff --git a/packages/studio/src/components/nle/CompositionBreadcrumb.tsx b/packages/studio/src/components/nle/CompositionBreadcrumb.tsx index b5bcc220a1..43a38cf7a8 100644 --- a/packages/studio/src/components/nle/CompositionBreadcrumb.tsx +++ b/packages/studio/src/components/nle/CompositionBreadcrumb.tsx @@ -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" > diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 9e1dad4bb8..e91d861a43 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -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, 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(); @@ -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(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 && (
@@ -491,16 +493,13 @@ export const NLELayout = memo(function NLELayout({ {!isFullscreen && isTimelineVisible ? ( <> - {/* Resize divider */} -
-
-
+ {/* Timeline section */}
{timelineFooter}
} {timelineDisabled && ( )}
diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 3cd440dcec..6c02dd6e05 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -21,6 +21,8 @@ interface NLEPreviewProps { directUrl?: string; suppressLoadingOverlay?: boolean; onStageRef?: (ref: React.RefObject) => 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(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({
{!isPreviewAtFit(settledZoom) && ( diff --git a/packages/studio/src/components/nle/TimelineResizeDivider.tsx b/packages/studio/src/components/nle/TimelineResizeDivider.tsx new file mode 100644 index 0000000000..d3bb1c84ee --- /dev/null +++ b/packages/studio/src/components/nle/TimelineResizeDivider.tsx @@ -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>; + persistTimelineH: (h: number) => void; + containerRef: React.RefObject; + 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 ( +
+
+
+ ); +} diff --git a/packages/studio/src/components/nle/usePreviewBlockDrop.ts b/packages/studio/src/components/nle/usePreviewBlockDrop.ts index f1cb714bb5..9c88e1b9f0 100644 --- a/packages/studio/src/components/nle/usePreviewBlockDrop.ts +++ b/packages/studio/src/components/nle/usePreviewBlockDrop.ts @@ -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; 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 }; } diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index e1de4f21c2..71c142ad4c 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -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>(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 (
- 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" > - - - - + + {open && ( -
+ - + {isRendering ? "Rendering…" : "Export"} + + {lastRenderDurationMs !== undefined && !isRendering && ( +

+ Last render took {formatEta(lastRenderDurationMs)} +

+ )}
); } @@ -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(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 (
@@ -335,12 +404,40 @@ export const RenderQueue = memo(function RenderQueue({ onStartRender={onStartRender} isRendering={isRendering} compositionDimensions={compositionDimensions} + lastRenderDurationMs={lastRenderDurationMs} />
+ {actionError && ( +
+ {actionError} + {onDismissActionError && ( + + )} +
+ )} + {/* Job list */}
- {jobs.length === 0 ? ( + {loadError && jobs.length === 0 ? ( +
+

{loadError}

+ {onRetryLoad && ( + + )} +
+ ) : jobs.length === 0 ? (
{jobs.length} render{jobs.length === 1 ? "" : "s"} + {/* "Hide", not "Clear": files stay on disk (delete is per-row + and confirmed); hidden rows don't resurrect on reload. */}
)} @@ -390,6 +490,7 @@ export const RenderQueue = memo(function RenderQueue({ job={job} projectId={projectId} onDelete={() => onDelete(job.id)} + onCancel={() => onCancel?.(job.id)} /> ))}
diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx index 5c4377947a..4b0209e559 100644 --- a/packages/studio/src/components/renders/RenderQueueItem.tsx +++ b/packages/studio/src/components/renders/RenderQueueItem.tsx @@ -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 (
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" >
- {/* Thumbnail — static frame; swaps to live video on hover */} -
+ {/* Thumbnail — static frame; swaps to live video on hover. + A real button so keyboard users can open the render too. */} + {/* Info */}
@@ -118,13 +135,20 @@ export const RenderQueueItem = memo(function RenderQueueItem({ )}
- {job.status === "rendering" && ( + {isRendering && (
{job.stage || "Rendering"} {job.progress}%
-
+
{job.error} )} + {job.status === "cancelled" && ( + Cancelled + )} - {job.status !== "rendering" && ( + {!isRendering && ( {formatTimeAgo(job.createdAt)} )}
{/* Actions — always visible to prevent layout shifts */} -
- - + Cancel + + ) : confirmingDelete ? ( + <> + + + + ) : ( + <> + + + + )}
diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts index d135e4a6e8..0391313ac4 100644 --- a/packages/studio/src/components/renders/useRenderQueue.ts +++ b/packages/studio/src/components/renders/useRenderQueue.ts @@ -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 { + 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): 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([]); + // History fetch failure — distinguished from "no renders yet" so the panel + // never shows a false empty state. + const [loadError, setLoadError] = useState(null); + // Failure of a user action (delete/cancel), surfaced inline in the panel. + const [actionError, setActionError] = useState(null); const eventSourceRef = useRef(null); const activeJobRef = useRef(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, }), - [jobs, isRendering, deleteRender, clearCompleted, startRender], + [ + jobs, + isRendering, + loadError, + actionError, + dismissActionError, + loadRenders, + deleteRender, + cancelRender, + clearCompleted, + startRender, + ], ); } diff --git a/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx b/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx index 00b5e5e3d3..22300fe6b7 100644 --- a/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx +++ b/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx @@ -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({

🎙 Voiceover guide

- + {busy ? "Saving…" : "Save"} +