import { memo, useState, useRef, useEffect } from "react"; import { RenderQueueItem } from "./RenderQueueItem"; import type { RenderJob, ResolutionPreset } from "./useRenderQueue"; export interface CompositionDimensions { width: number; height: number; } interface RenderQueueProps { jobs: RenderJob[]; projectId: string; onDelete: (jobId: string) => void; onClearCompleted: () => void; onStartRender: ( format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high", resolution: ResolutionPreset | "auto", ) => void; isRendering: boolean; /** * Authored dimensions of the active composition, used to derive * landscape vs portrait when the user picks 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", }; function isPortraitComp(dims: CompositionDimensions | null | undefined): boolean { // Squares and missing dims fall through to landscape — matches the legacy // default ("landscape" was the first preset). The auto option exists for // users who want exact authored dimensions. return dims != null && dims.height > dims.width; } function resolveResolution( scale: RenderScale, dims: CompositionDimensions | null | undefined, ): ResolutionPreset | "auto" { if (scale === "auto") return "auto"; const portrait = isPortraitComp(dims); if (scale === "1080p") return portrait ? "portrait" : "landscape"; return portrait ? "portrait-4k" : "landscape-4k"; } function resolvedDimensions( scale: RenderScale, dims: CompositionDimensions | null | undefined, ): CompositionDimensions | null { if (scale === "auto") return dims ?? null; const portrait = isPortraitComp(dims); if (scale === "1080p") { return portrait ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 }; } return portrait ? { width: 2160, height: 3840 } : { width: 3840, height: 2160 }; } 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 CapCut, Final Cut Pro, Premiere, DaVinci Resolve, After Effects. 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: ( format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high", resolution: ResolutionPreset | "auto", ) => void; isRendering: boolean; compositionDimensions?: CompositionDimensions | null; }) { const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4"); const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard"); const [scale, setScale] = useState("auto"); // MOV (ProRes) is a fixed-quality codec — quality selector has no effect. const showQuality = format !== "mov"; return (
{/* Resolution must remain the leftmost setScale(e.target.value as RenderScale)} disabled={isRendering} className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50" > {SCALE_OPTION_ORDER.map((value) => ( ))} {showQuality && ( )}
); } 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 (
{/* Header — no title, already shown in header button */}
{completedCount > 0 && ( )}
{/* Job list */}
{jobs.length === 0 ? (

No renders yet

) : ( jobs.map((job) => ( onDelete(job.id)} /> )) )}
); });