mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX (#1963)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { memo, useState, useRef, useEffect } from "react";
|
||||
import { memo, useState, useRef, useEffect, useId } from "react";
|
||||
import { RenderQueueItem } from "./RenderQueueItem";
|
||||
import { Button } from "../ui/Button";
|
||||
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
||||
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
@@ -20,9 +21,17 @@ interface RenderQueueProps {
|
||||
jobs: RenderJob[];
|
||||
projectId: string;
|
||||
onDelete: (jobId: string) => void;
|
||||
onCancel?: (jobId: string) => void;
|
||||
onClearCompleted: () => void;
|
||||
onStartRender: StartRenderHandler;
|
||||
isRendering: boolean;
|
||||
/** History fetch failure (null when the last load succeeded). */
|
||||
loadError?: string | null;
|
||||
/** Retry a failed history load. */
|
||||
onRetryLoad?: () => void;
|
||||
/** Failure of a delete/cancel action, shown inline until dismissed. */
|
||||
actionError?: string | null;
|
||||
onDismissActionError?: () => void;
|
||||
/**
|
||||
* Authored dimensions of the active composition. Used to pick the
|
||||
* matching preset (landscape / portrait / square) when the user selects
|
||||
@@ -110,9 +119,15 @@ function scaleOptionLabel(
|
||||
dims: CompositionDimensions | null | undefined,
|
||||
): string {
|
||||
const resolved = resolvedDimensions(scale, dims);
|
||||
return resolved
|
||||
const base = resolved
|
||||
? `${SCALE_LABEL[scale]} · ${resolved.width}×${resolved.height}`
|
||||
: SCALE_LABEL[scale];
|
||||
// Explain *why* an option is disabled instead of greying it silently:
|
||||
// the preset must be an exact integer upscale of the authored size.
|
||||
if (dims && !scaleApplies(scale, dims)) {
|
||||
return `${base} — not an integer scale of ${dims.width}×${dims.height}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
|
||||
@@ -127,9 +142,14 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
|
||||
},
|
||||
};
|
||||
|
||||
// Rich format guidance in a keyboard-reachable disclosure: the trigger is a
|
||||
// real button (focusable, labelled), the panel is tied to it via
|
||||
// aria-describedby, and Escape dismisses (WCAG 1.4.13). Content is too rich
|
||||
// for the one-line ui/Tooltip primitive, so this stays a local popover.
|
||||
function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const panelId = useId();
|
||||
|
||||
const show = () => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
@@ -141,27 +161,51 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [open]);
|
||||
|
||||
const info = FORMAT_INFO[format];
|
||||
|
||||
return (
|
||||
<div className="relative" onPointerEnter={show} onPointerLeave={hide}>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="About video formats"
|
||||
aria-expanded={open}
|
||||
aria-describedby={open ? panelId : undefined}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex items-center justify-center p-0.5 -m-0.5 rounded text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50">
|
||||
<div
|
||||
id={panelId}
|
||||
role="tooltip"
|
||||
className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50"
|
||||
>
|
||||
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
|
||||
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
|
||||
<div className="mt-1.5 pt-1.5 border-t border-neutral-800">
|
||||
@@ -191,14 +235,21 @@ const QUALITY_OPTIONS: {
|
||||
{ value: "high", label: "High Quality", title: "Best quality, larger file" },
|
||||
];
|
||||
|
||||
function formatEta(ms: number): string {
|
||||
const s = Math.round(ms / 1000);
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
function FormatExportButton({
|
||||
onStartRender,
|
||||
isRendering,
|
||||
compositionDimensions,
|
||||
lastRenderDurationMs,
|
||||
}: {
|
||||
onStartRender: StartRenderHandler;
|
||||
isRendering: boolean;
|
||||
compositionDimensions?: CompositionDimensions | null;
|
||||
lastRenderDurationMs?: number;
|
||||
}) {
|
||||
const persisted = getPersistedRenderSettings();
|
||||
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
|
||||
@@ -293,16 +344,26 @@ function FormatExportButton({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
loading={isRendering}
|
||||
onClick={() => {
|
||||
// loading already disables the button; this guard also stops a
|
||||
// double-click in the same frame from enqueueing two renders.
|
||||
if (isRendering) return;
|
||||
trackStudioEvent("render_start", { format, quality, resolution, fps });
|
||||
void onStartRender(format, quality, resolution, fps);
|
||||
}}
|
||||
disabled={isRendering}
|
||||
className="w-full flex items-center justify-center h-8 text-[11px] font-semibold rounded-md bg-panel-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
|
||||
className="w-full text-[11px] font-semibold"
|
||||
>
|
||||
{isRendering ? "Rendering..." : "Export"}
|
||||
</button>
|
||||
{isRendering ? "Rendering…" : "Export"}
|
||||
</Button>
|
||||
{lastRenderDurationMs !== undefined && !isRendering && (
|
||||
<p className="text-[9px] text-panel-text-5 text-center -mt-1.5">
|
||||
Last render took {formatEta(lastRenderDurationMs)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -311,9 +372,14 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
jobs,
|
||||
projectId,
|
||||
onDelete,
|
||||
onCancel,
|
||||
onClearCompleted,
|
||||
onStartRender,
|
||||
isRendering,
|
||||
loadError,
|
||||
onRetryLoad,
|
||||
actionError,
|
||||
onDismissActionError,
|
||||
compositionDimensions,
|
||||
}: RenderQueueProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
@@ -327,6 +393,9 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
}, [jobs.length]);
|
||||
|
||||
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
|
||||
const lastRenderDurationMs = [...jobs]
|
||||
.reverse()
|
||||
.find((j) => j.status === "complete" && j.durationMs !== undefined)?.durationMs;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -335,12 +404,40 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
onStartRender={onStartRender}
|
||||
isRendering={isRendering}
|
||||
compositionDimensions={compositionDimensions}
|
||||
lastRenderDurationMs={lastRenderDurationMs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start justify-between gap-2 px-3 py-2 border-b border-panel-border bg-red-500/10"
|
||||
>
|
||||
<span className="text-[10px] text-red-400">{actionError}</span>
|
||||
{onDismissActionError && (
|
||||
<button
|
||||
onClick={onDismissActionError}
|
||||
aria-label="Dismiss error"
|
||||
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 flex-shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job list */}
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto">
|
||||
{jobs.length === 0 ? (
|
||||
{loadError && jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4 gap-2" role="alert">
|
||||
<p className="text-[10px] text-red-400 text-center">{loadError}</p>
|
||||
{onRetryLoad && (
|
||||
<Button size="sm" variant="secondary" onClick={onRetryLoad}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
@@ -376,11 +473,14 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
<span className="text-[10px] text-panel-text-4">
|
||||
{jobs.length} render{jobs.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
{/* "Hide", not "Clear": files stay on disk (delete is per-row
|
||||
and confirmed); hidden rows don't resurrect on reload. */}
|
||||
<button
|
||||
onClick={onClearCompleted}
|
||||
title="Hide finished renders from this list (files stay on disk)"
|
||||
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
|
||||
>
|
||||
Clear
|
||||
Hide finished
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -390,6 +490,7 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
job={job}
|
||||
projectId={projectId}
|
||||
onDelete={() => onDelete(job.id)}
|
||||
onCancel={() => onCancel?.(job.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||
import { Button } from "../ui/Button";
|
||||
import type { RenderJob } from "./useRenderQueue";
|
||||
|
||||
interface RenderQueueItemProps {
|
||||
job: RenderJob;
|
||||
projectId: string;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -27,8 +29,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
job,
|
||||
projectId,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}: RenderQueueItemProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
// Direct file URL — serves from disk, survives server restarts
|
||||
const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
|
||||
@@ -50,25 +55,35 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
|
||||
const viewSrc = fileSrc;
|
||||
const isComplete = job.status === "complete";
|
||||
const isRendering = job.status === "rendering";
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
onClick={isComplete ? handleOpen : undefined}
|
||||
className={[
|
||||
"px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150",
|
||||
isComplete ? "cursor-pointer hover:bg-panel-hover/30" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onPointerLeave={() => {
|
||||
setHovered(false);
|
||||
setVideoReady(false);
|
||||
setConfirmingDelete(false);
|
||||
}}
|
||||
className="px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150 hover:bg-panel-hover/30"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Thumbnail — static frame; swaps to live video on hover */}
|
||||
<div className="w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative">
|
||||
{/* Thumbnail — static frame; swaps to live video on hover.
|
||||
A real button so keyboard users can open the render too. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={isComplete ? handleOpen : undefined}
|
||||
disabled={!isComplete}
|
||||
aria-label={isComplete ? `Open ${job.filename} in a new tab` : undefined}
|
||||
className={[
|
||||
"w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative",
|
||||
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent",
|
||||
isComplete ? "cursor-pointer" : "cursor-default",
|
||||
].join(" ")}
|
||||
>
|
||||
{isComplete && (
|
||||
<>
|
||||
{/* Live video — visible on hover */}
|
||||
{/* Live video — fades in over the static frame once it can play */}
|
||||
{hovered && (
|
||||
<video
|
||||
src={viewSrc}
|
||||
@@ -76,21 +91,23 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
onCanPlay={() => setVideoReady(true)}
|
||||
className="absolute inset-0 w-full h-full object-contain transition-opacity duration-150"
|
||||
style={{ opacity: videoReady ? 1 : 0 }}
|
||||
/>
|
||||
)}
|
||||
{/* Static frame — visible when not hovering */}
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-150"
|
||||
style={{ opacity: hovered ? 0 : 1 }}
|
||||
style={{ opacity: hovered && videoReady ? 0 : 1 }}
|
||||
>
|
||||
<VideoFrameThumbnail src={viewSrc} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{job.status === "rendering" && (
|
||||
{isRendering && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse" />
|
||||
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse motion-reduce:animate-none" />
|
||||
</div>
|
||||
)}
|
||||
{job.status === "failed" && (
|
||||
@@ -103,7 +120,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
<div className="w-2 h-2 rounded-full bg-neutral-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -118,13 +135,20 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.status === "rendering" && (
|
||||
{isRendering && (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
|
||||
<span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-panel-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className="w-full h-1 bg-panel-border rounded-full overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-valuenow={job.progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`Render progress: ${job.progress}%`}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-panel-accent rounded-full transition-all duration-300"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
@@ -136,59 +160,103 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
{job.status === "failed" && job.error && (
|
||||
<span className="text-[9px] text-red-400 mt-0.5 block">{job.error}</span>
|
||||
)}
|
||||
{job.status === "cancelled" && (
|
||||
<span className="text-[9px] text-panel-text-4 mt-0.5 block">Cancelled</span>
|
||||
)}
|
||||
|
||||
{job.status !== "rendering" && (
|
||||
{!isRendering && (
|
||||
<span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions — always visible to prevent layout shifts */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={isComplete ? handleDownload : undefined}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isComplete
|
||||
? "text-panel-text-5 hover:text-panel-accent"
|
||||
: "text-panel-text-5/30 pointer-events-none"
|
||||
}`}
|
||||
title={isComplete ? "Download" : "Rendering..."}
|
||||
disabled={!isComplete}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{isRendering ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<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={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 rounded text-panel-text-5 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>
|
||||
Cancel
|
||||
</Button>
|
||||
) : confirmingDelete ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmingDelete(false);
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
Delete?
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmingDelete(false);
|
||||
}}
|
||||
>
|
||||
Keep
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={isComplete ? handleDownload : undefined}
|
||||
className={`p-1.5 min-w-6 min-h-6 rounded transition-colors outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent ${
|
||||
isComplete
|
||||
? "text-panel-text-5 hover:text-panel-accent"
|
||||
: "text-panel-text-5/30 cursor-default"
|
||||
}`}
|
||||
title={isComplete ? "Download" : undefined}
|
||||
aria-label={`Download ${job.filename}`}
|
||||
disabled={!isComplete}
|
||||
>
|
||||
<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={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmingDelete(true);
|
||||
}}
|
||||
className="p-1.5 min-w-6 min-h-6 rounded text-panel-text-5 hover:text-red-400 transition-colors outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
|
||||
title="Delete render file"
|
||||
aria-label={`Delete ${job.filename}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -36,23 +36,66 @@ export interface StartRenderOptions {
|
||||
composition?: string;
|
||||
}
|
||||
|
||||
// "Hide" (formerly "Clear") is a view operation, not a delete: hidden ids are
|
||||
// remembered here so hidden renders don't resurrect from the on-disk history
|
||||
// on the next load. Per-project key so projects don't hide each other's rows.
|
||||
function hiddenIdsKey(projectId: string): string {
|
||||
return `hf-studio-hidden-renders:${projectId}`;
|
||||
}
|
||||
|
||||
function readHiddenIds(projectId: string): Set<string> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(hiddenIdsKey(projectId));
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function writeHiddenIds(projectId: string, ids: Set<string>): void {
|
||||
try {
|
||||
// Cap the list so it doesn't grow unbounded across months of renders.
|
||||
window.localStorage.setItem(hiddenIdsKey(projectId), JSON.stringify([...ids].slice(-200)));
|
||||
} catch {
|
||||
/* localStorage may be unavailable or full */
|
||||
}
|
||||
}
|
||||
|
||||
export function useRenderQueue(projectId: string | null) {
|
||||
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
||||
// History fetch failure — distinguished from "no renders yet" so the panel
|
||||
// never shows a false empty state.
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
// Failure of a user action (delete/cancel), surfaced inline in the panel.
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const activeJobRef = useRef<string | null>(null);
|
||||
|
||||
const closeActiveEventSource = useCallback((jobId?: string) => {
|
||||
if (jobId && activeJobRef.current !== jobId) return;
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
activeJobRef.current = 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;
|
||||
if (!res.ok) {
|
||||
setLoadError(`Couldn't load render history (server error ${res.status}).`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setLoadError(null);
|
||||
if (Array.isArray(data.renders)) {
|
||||
const hidden = readHiddenIds(projectId);
|
||||
setJobs((prev) => {
|
||||
const existing = new Set(prev.map((j) => j.id));
|
||||
const fromServer: RenderJob[] = data.renders
|
||||
.filter((r: { id: string }) => !existing.has(r.id))
|
||||
.filter((r: { id: string }) => !existing.has(r.id) && !hidden.has(r.id))
|
||||
.map(
|
||||
(r: {
|
||||
id: string;
|
||||
@@ -74,7 +117,7 @@ export function useRenderQueue(projectId: string | null) {
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
setLoadError("Couldn't load render history. Is the studio server running?");
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
@@ -175,6 +218,8 @@ export function useRenderQueue(projectId: string | null) {
|
||||
es.addEventListener("progress", (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const terminal =
|
||||
data.status === "complete" || data.status === "failed" || data.status === "cancelled";
|
||||
setJobs((prev) =>
|
||||
prev.map((j) =>
|
||||
j.id === jobId
|
||||
@@ -182,21 +227,15 @@ export function useRenderQueue(projectId: string | null) {
|
||||
...j,
|
||||
progress: data.progress ?? j.progress,
|
||||
stage: data.stage ?? data.message ?? j.stage,
|
||||
status:
|
||||
data.status === "complete"
|
||||
? "complete"
|
||||
: data.status === "failed"
|
||||
? "failed"
|
||||
: j.status,
|
||||
status: terminal ? (data.status as RenderJob["status"]) : j.status,
|
||||
durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
|
||||
error: data.error ?? j.error,
|
||||
}
|
||||
: j,
|
||||
),
|
||||
);
|
||||
if (data.status === "complete" || data.status === "failed") {
|
||||
es.close();
|
||||
activeJobRef.current = null;
|
||||
if (terminal) {
|
||||
closeActiveEventSource(jobId);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
@@ -221,21 +260,78 @@ export function useRenderQueue(projectId: string | null) {
|
||||
|
||||
return jobId;
|
||||
},
|
||||
[projectId],
|
||||
[projectId, closeActiveEventSource],
|
||||
);
|
||||
|
||||
const deleteRender = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
await fetch(`/api/render/${jobId}`, { method: "DELETE" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
||||
}, []);
|
||||
// Cancel an in-flight render. The job row stays (as "cancelled") so the
|
||||
// user sees the outcome; the SSE stream is closed either way.
|
||||
const cancelRender = useCallback(
|
||||
async (jobId: string) => {
|
||||
setActionError(null);
|
||||
closeActiveEventSource(jobId);
|
||||
setJobs((prev) =>
|
||||
prev.map((j) =>
|
||||
j.id === jobId && j.status === "rendering" ? { ...j, status: "cancelled" } : j,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`/api/render/${jobId}/cancel`, { method: "POST" });
|
||||
if (!res.ok && res.status !== 404) {
|
||||
setActionError("Couldn't cancel on the server — the render may still be running.");
|
||||
return;
|
||||
}
|
||||
// Reconcile with the status the route reports: if the render actually
|
||||
// finished (or failed) before the cancel landed, don't leave the row
|
||||
// stuck on the optimistic "cancelled" — reload to pick up the real
|
||||
// outcome (and the finished file's metadata).
|
||||
if (res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { status?: string } | null;
|
||||
if (body?.status && body.status !== "cancelled") {
|
||||
void loadRenders();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setActionError("Couldn't reach the server to cancel — the render may still be running.");
|
||||
}
|
||||
},
|
||||
[closeActiveEventSource, loadRenders],
|
||||
);
|
||||
|
||||
const deleteRender = useCallback(
|
||||
async (jobId: string) => {
|
||||
setActionError(null);
|
||||
closeActiveEventSource(jobId);
|
||||
try {
|
||||
const res = await fetch(`/api/render/${jobId}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
setActionError("Couldn't delete the render — it's still on disk.");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setActionError("Couldn't reach the server to delete the render.");
|
||||
return;
|
||||
}
|
||||
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
||||
},
|
||||
[closeActiveEventSource],
|
||||
);
|
||||
|
||||
// Hide finished rows from the list (view-only — files stay on disk and can
|
||||
// be recovered from the renders/ directory). Remembered per project so the
|
||||
// rows don't resurrect from history on reload.
|
||||
const clearCompleted = useCallback(() => {
|
||||
setJobs((prev) => prev.filter((j) => j.status === "rendering"));
|
||||
}, []);
|
||||
setJobs((prev) => {
|
||||
const finished = prev.filter((j) => j.status !== "rendering");
|
||||
if (projectId && finished.length > 0) {
|
||||
const hidden = readHiddenIds(projectId);
|
||||
for (const j of finished) hidden.add(j.id);
|
||||
writeHiddenIds(projectId, hidden);
|
||||
}
|
||||
return prev.filter((j) => j.status === "rendering");
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const dismissActionError = useCallback(() => setActionError(null), []);
|
||||
|
||||
// Clean up EventSource on unmount or projectId change
|
||||
useEffect(() => {
|
||||
@@ -250,10 +346,26 @@ export function useRenderQueue(projectId: string | null) {
|
||||
() => ({
|
||||
jobs,
|
||||
isRendering,
|
||||
loadError,
|
||||
actionError,
|
||||
dismissActionError,
|
||||
reloadRenders: loadRenders,
|
||||
deleteRender,
|
||||
cancelRender,
|
||||
clearCompleted,
|
||||
startRender: startRender as (options: unknown) => Promise<void>,
|
||||
}),
|
||||
[jobs, isRendering, deleteRender, clearCompleted, startRender],
|
||||
[
|
||||
jobs,
|
||||
isRendering,
|
||||
loadError,
|
||||
actionError,
|
||||
dismissActionError,
|
||||
loadRenders,
|
||||
deleteRender,
|
||||
cancelRender,
|
||||
clearCompleted,
|
||||
startRender,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user