feat(studio): render queue, layout restructure, home page, hover preview (#95)

## Summary
- Add render queue panel with progress tracking, download, and delete actions
- Restructure App layout: home page with project picker, session-based routing
- Add ExpandOnHover component for preview-on-hover interactions (uses motion/react)
- CompositionsTab now supports hover preview with expanded iframe view
- Vite config: guard setInterval cleanup to dev-only (fixes CI build timeout)
- Add favicon and update studio package deps

## Test plan
- [x] Render queue shows progress, completes, and allows download
- [x] Home page lists projects and navigates to session view
- [x] ExpandOnHover shows expanded preview on mouse hover with spring animation
- [x] `vite build` exits cleanly (no hanging process from setInterval)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Miguel Ángel
2026-03-28 21:28:43 +01:00
committed by GitHub
parent 9ae239d177
commit 40bd159103
24 changed files with 2495 additions and 796 deletions
@@ -0,0 +1,123 @@
import { memo, useState, useRef } from "react";
import { RenderQueueItem } from "./RenderQueueItem";
import type { RenderJob } from "./useRenderQueue";
interface RenderQueueProps {
jobs: RenderJob[];
onDelete: (jobId: string) => void;
onClearCompleted: () => void;
onStartRender: (format: "mp4" | "webm") => void;
isRendering: boolean;
}
function FormatExportButton({
onStartRender,
isRendering,
}: {
onStartRender: (format: "mp4" | "webm") => void;
isRendering: boolean;
}) {
const [format, setFormat] = useState<"mp4" | "webm">("mp4");
return (
<div className="flex items-center gap-0.5">
<select
value={format}
onChange={(e) => setFormat(e.target.value as "mp4" | "webm")}
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"
>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
<button
onClick={() => onStartRender(format)}
disabled={isRendering}
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-[#3CE6AC] text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
>
{isRendering ? "Rendering..." : "Export"}
</button>
</div>
);
}
export const RenderQueue = memo(function RenderQueue({
jobs,
onDelete,
onClearCompleted,
onStartRender,
isRendering,
}: RenderQueueProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevCount = useRef(jobs.length);
// Auto-scroll to bottom when new jobs are added (adjust during render)
if (jobs.length > prevCount.current && listRef.current) {
queueMicrotask(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
});
}
prevCount.current = jobs.length;
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
<span className="text-[11px] font-medium text-neutral-500 uppercase tracking-wider">
Renders ({jobs.length})
</span>
<div className="flex items-center gap-1.5">
{completedCount > 0 && (
<button
onClick={onClearCompleted}
className="text-[10px] text-neutral-600 hover:text-neutral-400 transition-colors"
>
Clear
</button>
)}
<FormatExportButton onStartRender={onStartRender} isRendering={isRendering} />
</div>
</div>
{/* Job list */}
<div ref={listRef} className="flex-1 overflow-y-auto">
{jobs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-neutral-700"
>
<rect
x="2"
y="2"
width="20"
height="20"
rx="2.18"
ry="2.18"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<p className="text-[10px] text-neutral-600 text-center">No renders yet</p>
</div>
) : (
jobs.map((job) => (
<RenderQueueItem key={job.id} job={job} onDelete={() => onDelete(job.id)} />
))
)}
</div>
</div>
);
});
@@ -0,0 +1,133 @@
import { memo, useCallback, useState } from "react";
import type { RenderJob } from "./useRenderQueue";
interface RenderQueueItemProps {
job: RenderJob;
onDelete: () => void;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const s = Math.round(ms / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
function formatTimeAgo(timestamp: number): string {
const diff = Date.now() - timestamp;
if (diff < 60000) return "just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
return `${Math.floor(diff / 3600000)}h ago`;
}
export const RenderQueueItem = memo(function RenderQueueItem({
job,
onDelete,
}: 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]);
return (
<div
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
className="px-3 py-2.5 border-b border-neutral-800/30 last:border-0"
>
<div className="flex items-center gap-2">
{/* Status indicator */}
<div className="flex-shrink-0">
{job.status === "rendering" && (
<div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
)}
{job.status === "complete" && <div className="w-2 h-2 rounded-full bg-green-400" />}
{job.status === "failed" && <div className="w-2 h-2 rounded-full bg-red-400" />}
{job.status === "cancelled" && <div className="w-2 h-2 rounded-full bg-neutral-600" />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-neutral-300 truncate">
{job.filename}
</span>
{job.durationMs && (
<span className="text-[9px] text-neutral-600 flex-shrink-0">
{formatDuration(job.durationMs)}
</span>
)}
</div>
{/* Progress bar + percentage */}
{job.status === "rendering" && (
<div className="mt-1">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
<span className="text-[9px] font-mono text-[#3CE6AC]">{job.progress}%</span>
</div>
<div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-[#3CE6AC] rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
)}
{job.status !== "rendering" && (
<span className="text-[9px] text-neutral-600">{formatTimeAgo(job.createdAt)}</span>
)}
</div>
{/* Actions */}
{hovered && (
<div className="flex items-center gap-1 flex-shrink-0">
{job.status === "complete" && (
<button
onClick={handleDownload}
className="p-1 rounded text-neutral-500 hover:text-green-400 transition-colors"
title="Download"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
)}
<button
onClick={onDelete}
className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
title="Remove"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
)}
</div>
</div>
);
});
@@ -0,0 +1,151 @@
import { useState, useEffect, useCallback, useRef } from "react";
export interface RenderJob {
id: string;
status: "rendering" | "complete" | "failed" | "cancelled";
progress: number;
stage?: string;
filename: string;
createdAt: number;
durationMs?: number;
}
export function useRenderQueue(projectId: string | null) {
const [jobs, setJobs] = useState<RenderJob[]>([]);
const eventSourceRef = useRef<EventSource | null>(null);
const activeJobRef = useRef<string | null>(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;
const data = await res.json();
if (Array.isArray(data.renders)) {
setJobs((prev) => {
const existing = new Set(prev.map((j) => j.id));
const fromServer: RenderJob[] = data.renders
.filter((r: { id: string }) => !existing.has(r.id))
.map((r: { id: string; filename: string; createdAt: number; size: number }) => ({
id: r.id,
status: "complete" as const,
progress: 100,
filename: r.filename,
createdAt: r.createdAt,
}));
return [...prev, ...fromServer];
});
}
} catch {
// ignore
}
}, [projectId]);
useEffect(() => {
loadRenders();
}, [loadRenders]);
// Start a render and track progress via SSE
const startRender = useCallback(
async (fps = 30, quality = "standard", format: "mp4" | "webm" = "mp4") => {
if (!projectId) return;
const startTime = Date.now();
const res = await fetch(`/api/projects/${projectId}/render`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fps, quality, format }),
});
if (!res.ok) return;
const { jobId } = await res.json();
const ext = format === "webm" ? ".webm" : ".mp4";
const job: RenderJob = {
id: jobId,
status: "rendering",
progress: 0,
filename: `${jobId}${ext}`,
createdAt: startTime,
};
setJobs((prev) => [...prev, job]);
activeJobRef.current = jobId;
// Track progress via SSE
const es = new EventSource(`/api/render/${jobId}/progress`);
eventSourceRef.current = es;
es.addEventListener("progress", (event) => {
try {
const data = JSON.parse(event.data);
setJobs((prev) =>
prev.map((j) =>
j.id === jobId
? {
...j,
progress: data.progress ?? j.progress,
stage: data.stage ?? data.message ?? j.stage,
status:
data.status === "complete"
? "complete"
: data.status === "failed"
? "failed"
: j.status,
durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
}
: j,
),
);
if (data.status === "complete" || data.status === "failed") {
es.close();
activeJobRef.current = null;
}
} catch {
// ignore parse errors
}
});
es.onerror = () => {
es.close();
setJobs((prev) =>
prev.map((j) =>
j.id === jobId && j.status === "rendering" ? { ...j, status: "failed" } : j,
),
);
activeJobRef.current = null;
};
return jobId;
},
[projectId],
);
const deleteRender = useCallback(async (jobId: string) => {
try {
await fetch(`/api/render/${jobId}`, { method: "DELETE" });
} catch {
// ignore
}
setJobs((prev) => prev.filter((j) => j.id !== jobId));
}, []);
const clearCompleted = useCallback(() => {
setJobs((prev) => prev.filter((j) => j.status === "rendering"));
}, []);
// Clean up EventSource on unmount or projectId change
useEffect(() => {
return () => {
eventSourceRef.current?.close();
eventSourceRef.current = null;
};
}, [projectId]);
return {
jobs,
startRender,
deleteRender,
clearCompleted,
isRendering: jobs.some((j) => j.status === "rendering"),
};
}