mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* feat(studio): add per-composition render button in compositions tab Thread composition path through the full render pipeline so individual compositions can be rendered independently from the studio UI. - Add download icon button on each comp card (visible on hover) - Accept `composition` field in POST /projects/:id/render - Pass composition as `entryFile` to the producer's createRenderJob - Make the Export button in the Renders panel composition-aware (renders the active composition instead of always index.html) * fix(studio): make composition render buttons always visible The hover-only opacity made them undiscoverable. * fix(studio): address PR review — CLI adapter, path guard, a11y, tests, settings sync - Wire `composition` → `entryFile` in CLI studio adapter (studioServer.ts) so `hyperframes preview` renders the correct composition, not always index.html - Add path-traversal guard: reject composition paths that resolve outside projectDir - Add `aria-label` to the icon-only render button for screen readers - Add 4 tests: forwarding, empty/missing → undefined, path-traversal → 400 - Persist render settings (format/quality/fps) to localStorage so comp card buttons use the same settings as the Export panel * refactor(studio): extract render settings persistence to own module Move getPersistedRenderSettings/persistRenderSettings out of RenderQueue.tsx into renderSettings.ts so code-splitting the component doesn't drag along the helper.
240 lines
7.1 KiB
TypeScript
240 lines
7.1 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
|
|
export interface RenderJob {
|
|
id: string;
|
|
status: "rendering" | "complete" | "failed" | "cancelled";
|
|
progress: number;
|
|
stage?: string;
|
|
error?: string;
|
|
filename: string;
|
|
createdAt: number;
|
|
durationMs?: number;
|
|
}
|
|
|
|
// Mirrors `CanvasResolution` from @hyperframes/core. Kept local because
|
|
// studio's tsconfig doesn't include node types, and the core barrel
|
|
// transitively pulls in modules with `node:fs` imports. Drift risk is
|
|
// low (6 string literals kept in sync manually with CANVAS_DIMENSIONS).
|
|
export type ResolutionPreset =
|
|
| "landscape"
|
|
| "portrait"
|
|
| "landscape-4k"
|
|
| "portrait-4k"
|
|
| "square"
|
|
| "square-4k";
|
|
|
|
export interface StartRenderOptions {
|
|
fps?: number;
|
|
quality?: "draft" | "standard" | "high";
|
|
format?: "mp4" | "webm" | "mov";
|
|
/** `"auto"` (default) renders at the composition's authored dimensions. */
|
|
resolution?: ResolutionPreset | "auto";
|
|
/** Render a specific composition file instead of index.html. */
|
|
composition?: string;
|
|
}
|
|
|
|
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;
|
|
status?: string;
|
|
durationMs?: number;
|
|
}) => ({
|
|
id: r.id,
|
|
status: (r.status === "failed" ? "failed" : "complete") as "complete" | "failed",
|
|
progress: 100,
|
|
filename: r.filename,
|
|
createdAt: r.createdAt,
|
|
durationMs: r.durationMs,
|
|
}),
|
|
);
|
|
return [...prev, ...fromServer];
|
|
});
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
loadRenders();
|
|
}, [loadRenders]);
|
|
|
|
// Start a render and track progress via SSE
|
|
const startRender = useCallback(
|
|
async (opts: StartRenderOptions = {}) => {
|
|
if (!projectId) return;
|
|
|
|
const fps = opts.fps ?? 30;
|
|
const quality = opts.quality ?? "standard";
|
|
const format = opts.format ?? "mp4";
|
|
const resolution = opts.resolution;
|
|
const composition = opts.composition;
|
|
|
|
const startTime = Date.now();
|
|
// "auto" / undefined means "render at the composition's authored size".
|
|
// Omit the field entirely — sending "auto" would trip the route's
|
|
// enum validation set.
|
|
const body: {
|
|
fps: number;
|
|
quality: string;
|
|
format: string;
|
|
resolution?: string;
|
|
composition?: string;
|
|
} = {
|
|
fps,
|
|
quality,
|
|
format,
|
|
};
|
|
if (resolution && resolution !== "auto") body.resolution = resolution;
|
|
if (composition) body.composition = composition;
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`/api/projects/${projectId}/render`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch {
|
|
const failedJob: RenderJob = {
|
|
id: crypto.randomUUID(),
|
|
status: "failed",
|
|
progress: 0,
|
|
error: "Could not reach render server. Use `hyperframes render` from the CLI instead.",
|
|
filename: "Export failed",
|
|
createdAt: startTime,
|
|
};
|
|
setJobs((prev) => [...prev, failedJob]);
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
const failedJob: RenderJob = {
|
|
id: crypto.randomUUID(),
|
|
status: "failed",
|
|
progress: 0,
|
|
error: `Server error (${res.status}). Check the terminal for details.`,
|
|
filename: "Export failed",
|
|
createdAt: startTime,
|
|
};
|
|
setJobs((prev) => [...prev, failedJob]);
|
|
return;
|
|
}
|
|
const { jobId } = await res.json();
|
|
|
|
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
|
|
const ext = FORMAT_EXT[format] ?? ".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,
|
|
error: data.error ?? j.error,
|
|
}
|
|
: 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" as const,
|
|
error: "Connection lost. Is the render server running?",
|
|
}
|
|
: 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"),
|
|
};
|
|
}
|