mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
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:
@@ -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"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user