import { memo, useState, useRef, useEffect } from "react"; import { RenderQueueItem } from "./RenderQueueItem"; import type { RenderJob, ResolutionPreset } from "./useRenderQueue"; import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings"; import { trackStudioEvent } from "../../utils/studioTelemetry"; export interface CompositionDimensions { width: number; height: number; } type StartRenderHandler = ( format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high", resolution: ResolutionPreset | "auto", fps: 24 | 30 | 60, ) => void | Promise; interface RenderQueueProps { jobs: RenderJob[]; projectId: string; onDelete: (jobId: string) => void; onClearCompleted: () => void; onStartRender: StartRenderHandler; isRendering: boolean; /** * Authored dimensions of the active composition. Used to pick the * matching preset (landscape / portrait / square) when the user selects * a 1080p or 4K scale. `null` falls back to landscape (legacy default). */ compositionDimensions?: CompositionDimensions | null; } // Orientation is derived from the composition's authored aspect ratio, // not chosen by the user — picking "1080p portrait" for a landscape comp // would just produce a wrong-aspect render. type RenderScale = "auto" | "1080p" | "4k"; const SCALE_OPTION_ORDER: RenderScale[] = ["auto", "1080p", "4k"]; const SCALE_LABEL: Record = { auto: "Auto", "1080p": "1080p", "4k": "4K", }; // Mirrors `CANVAS_DIMENSIONS` in @hyperframes/core. Studio can't import from // the core barrel (it transitively pulls in node:fs) and the values are stable. const CANVAS_DIMENSIONS: Record = { landscape: { width: 1920, height: 1080 }, portrait: { width: 1080, height: 1920 }, "landscape-4k": { width: 3840, height: 2160 }, "portrait-4k": { width: 2160, height: 3840 }, square: { width: 1080, height: 1080 }, "square-4k": { width: 2160, height: 2160 }, }; type CompAspect = "landscape" | "portrait" | "square"; function compAspect(dims: CompositionDimensions | null | undefined): CompAspect { // Missing dims fall through to landscape (legacy default — "landscape" was // the first preset). Studio shows resolved dims inline, so the user can see // when this fallback is in effect. if (dims == null) return "landscape"; if (dims.width === dims.height) return "square"; return dims.height > dims.width ? "portrait" : "landscape"; } function resolveResolution( scale: RenderScale, dims: CompositionDimensions | null | undefined, ): ResolutionPreset | "auto" { if (scale === "auto") return "auto"; const aspect = compAspect(dims); if (scale === "1080p") return aspect; return aspect === "landscape" ? "landscape-4k" : aspect === "portrait" ? "portrait-4k" : "square-4k"; } function resolvedDimensions( scale: RenderScale, dims: CompositionDimensions | null | undefined, ): CompositionDimensions | null { if (scale === "auto") return dims ?? null; const preset = resolveResolution(scale, dims); return preset === "auto" ? null : CANVAS_DIMENSIONS[preset]; } // Mirrors the producer's resolveDeviceScaleFactor validation // (renderOrchestrator.ts:608): the chosen preset must match the comp's aspect // ratio exactly (cross-multiplied), can't downsample, and must be an integer // scale factor. Without this guard the user can pick a preset that throws at // render time — e.g. 1080p on a 1080×1080 square or 1080p on a 1280×720 comp // (1.5× isn't integer). function scaleApplies(scale: RenderScale, dims: CompositionDimensions | null | undefined): boolean { if (scale === "auto" || dims == null) return true; const preset = resolveResolution(scale, dims); if (preset === "auto") return true; const target = CANVAS_DIMENSIONS[preset]; if (target.width * dims.height !== target.height * dims.width) return false; if (target.width < dims.width) return false; return Number.isInteger(target.width / dims.width); } function scaleOptionLabel( scale: RenderScale, dims: CompositionDimensions | null | undefined, ): string { const resolved = resolvedDimensions(scale, dims); return resolved ? `${SCALE_LABEL[scale]} · ${resolved.width}×${resolved.height}` : SCALE_LABEL[scale]; } const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = { mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." }, mov: { label: "MOV (ProRes 4444)", desc: "Transparent video. Works in Final Cut Pro, DaVinci Resolve, and most video editors. Large files.", }, webm: { label: "WebM (VP9)", desc: "Transparent video for web. Smaller than MOV but limited editor support.", }, }; function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) { const [open, setOpen] = useState(false); const timeoutRef = useRef>(undefined); const show = () => { clearTimeout(timeoutRef.current); setOpen(true); }; const hide = () => { timeoutRef.current = setTimeout(() => setOpen(false), 120); }; useEffect(() => () => clearTimeout(timeoutRef.current), []); const info = FORMAT_INFO[format]; return (
{open && (

{info.label}

{info.desc}

{(["mp4", "mov", "webm"] as const) .filter((f) => f !== format) .map((f) => (

{FORMAT_INFO[f].label} {" — "} {FORMAT_INFO[f].desc}

))}
)}
); } const QUALITY_OPTIONS: { value: "draft" | "standard" | "high"; label: string; title: string; }[] = [ { value: "draft", label: "Draft", title: "Fast render, smaller file" }, { value: "standard", label: "Standard", title: "Good quality, balanced file size" }, { value: "high", label: "High Quality", title: "Best quality, larger file" }, ]; function FormatExportButton({ onStartRender, isRendering, compositionDimensions, }: { onStartRender: StartRenderHandler; isRendering: boolean; compositionDimensions?: CompositionDimensions | null; }) { const persisted = getPersistedRenderSettings(); const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format); const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality); const [resolution, setResolution] = useState("auto"); const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps); // MOV (ProRes) is a fixed-quality codec — quality selector has no effect. const showQuality = format !== "mov"; const selectCls = "h-7 w-full px-2 text-[11px] bg-panel-input rounded-md text-panel-text-1 outline-none cursor-pointer disabled:opacity-50 hover:bg-panel-hover transition-colors"; return (
Format
Resolution
Frame rate
{showQuality && (
Quality
)}
); } export const RenderQueue = memo(function RenderQueue({ jobs, projectId, onDelete, onClearCompleted, onStartRender, isRendering, compositionDimensions, }: RenderQueueProps) { const listRef = useRef(null); // Auto-scroll to bottom when new jobs are added. // Runs in an effect to avoid side effects during the render phase. useEffect(() => { if (listRef.current) { listRef.current.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" }); } }, [jobs.length]); const completedCount = jobs.filter((j) => j.status !== "rendering").length; return (
{/* Job list */}
{jobs.length === 0 ? (

No renders yet

) : (
{completedCount > 0 && (
{jobs.length} render{jobs.length === 1 ? "" : "s"}
)} {jobs.map((job) => ( onDelete(job.id)} /> ))}
)}
); });