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.
This commit is contained in:
Miguel Ángel
2026-03-30 22:22:34 +02:00
parent 04cf7b2eb6
commit 7bc8d3ff66
2 changed files with 48 additions and 8 deletions
@@ -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();
@@ -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 (
<div
onPointerEnter={() => 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(" ")}
>
<div className="flex items-center gap-2">
{/* Status indicator */}
@@ -113,7 +129,10 @@ export const RenderQueueItem = memo(function RenderQueueItem({
</button>
)}
<button
onClick={onDelete}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
title="Remove"
>