From 7bc8d3ff66b3ab254e51fbcd10010867eaa82eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 30 Mar 2026 18:57:04 +0200 Subject: [PATCH] feat(studio): open rendered video in new tab on row click Clicking a completed render row now opens the video inline in the browser instead of forcing a download. Added /render/:jobId/view endpoint with Content-Disposition: inline so the browser plays the video natively. Download button is preserved on hover for explicit saves. stopPropagation on action buttons prevents row click conflicts. --- packages/core/src/studio-api/routes/render.ts | 21 +++++++++++ .../components/renders/RenderQueueItem.tsx | 35 ++++++++++++++----- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/core/src/studio-api/routes/render.ts index 6d0f036648..a9662abc31 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/core/src/studio-api/routes/render.ts @@ -126,6 +126,27 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void }); }); + // Serve render inline (for in-browser playback — opens in a new tab) + api.get("/render/:jobId/view", (c) => { + const { jobId } = c.req.param(); + const job = renderJobs.get(jobId); + if (!job?.outputPath || !existsSync(job.outputPath)) { + return c.json({ error: "not found" }, 404); + } + const isWebm = job.outputPath.endsWith(".webm"); + const contentType = isWebm ? "video/webm" : "video/mp4"; + const filename = job.outputPath.split("/").pop() ?? `render.mp4`; + const content = readFileSync(job.outputPath); + return new Response(content, { + headers: { + "Content-Type": contentType, + "Content-Disposition": `inline; filename="${filename}"`, + "Accept-Ranges": "bytes", + "Content-Length": String(content.length), + }, + }); + }); + // Download render api.get("/render/:jobId/download", (c) => { const { jobId } = c.req.param(); diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx index a0925ef571..01ab8dc214 100644 --- a/packages/studio/src/components/renders/RenderQueueItem.tsx +++ b/packages/studio/src/components/renders/RenderQueueItem.tsx @@ -25,18 +25,34 @@ export const RenderQueueItem = memo(function RenderQueueItem({ }: RenderQueueItemProps) { const [hovered, setHovered] = useState(false); - const handleDownload = useCallback(() => { - const a = document.createElement("a"); - a.href = `/api/render/${job.id}/download`; - a.download = job.filename; - a.click(); - }, [job.id, job.filename]); + const handleOpen = useCallback(() => { + window.open(`/api/render/${job.id}/view`, "_blank"); + }, [job.id]); + + const handleDownload = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + const a = document.createElement("a"); + a.href = `/api/render/${job.id}/download`; + a.download = job.filename; + a.click(); + }, + [job.id, job.filename], + ); + + const isClickable = job.status === "complete"; return (
setHovered(true)} onPointerLeave={() => setHovered(false)} - className="px-3 py-2.5 border-b border-neutral-800/30 last:border-0" + onClick={isClickable ? handleOpen : undefined} + className={[ + "px-3 py-2.5 border-b border-neutral-800/30 last:border-0 transition-colors duration-150", + isClickable ? "cursor-pointer hover:bg-neutral-800/30" : "", + ] + .filter(Boolean) + .join(" ")} >
{/* Status indicator */} @@ -113,7 +129,10 @@ export const RenderQueueItem = memo(function RenderQueueItem({ )}