mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(studio): per-composition render button in compositions tab (#874)
* 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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { memo, useState, useRef, useEffect } from "react";
|
||||
import { RenderQueueItem } from "./RenderQueueItem";
|
||||
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
||||
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
||||
|
||||
export interface CompositionDimensions {
|
||||
width: number;
|
||||
@@ -198,10 +199,11 @@ function FormatExportButton({
|
||||
isRendering: boolean;
|
||||
compositionDimensions?: CompositionDimensions | null;
|
||||
}) {
|
||||
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
|
||||
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
|
||||
const persisted = getPersistedRenderSettings();
|
||||
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
|
||||
const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
|
||||
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
|
||||
const [fps, setFps] = useState<24 | 30 | 60>(30);
|
||||
const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
|
||||
|
||||
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
||||
const showQuality = format !== "mov";
|
||||
@@ -228,7 +230,11 @@ function FormatExportButton({
|
||||
{showQuality && (
|
||||
<select
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as "draft" | "standard" | "high";
|
||||
setQuality(v);
|
||||
persistRenderSettings(format, v, fps);
|
||||
}}
|
||||
disabled={isRendering}
|
||||
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
|
||||
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
||||
@@ -242,7 +248,11 @@ function FormatExportButton({
|
||||
)}
|
||||
<select
|
||||
value={fps}
|
||||
onChange={(e) => setFps(Number(e.target.value) as 24 | 30 | 60)}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value) as 24 | 30 | 60;
|
||||
setFps(v);
|
||||
persistRenderSettings(format, quality, v);
|
||||
}}
|
||||
disabled={isRendering}
|
||||
title="Frames per second"
|
||||
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
||||
@@ -253,7 +263,11 @@ function FormatExportButton({
|
||||
</select>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as "mp4" | "webm" | "mov";
|
||||
setFormat(v);
|
||||
persistRenderSettings(v, quality, fps);
|
||||
}}
|
||||
disabled={isRendering}
|
||||
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const RENDER_SETTINGS_KEY = "hf-studio-render-settings";
|
||||
|
||||
export interface PersistedRenderSettings {
|
||||
format: "mp4" | "webm" | "mov";
|
||||
quality: "draft" | "standard" | "high";
|
||||
fps: 24 | 30 | 60;
|
||||
}
|
||||
|
||||
export function getPersistedRenderSettings(): PersistedRenderSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(RENDER_SETTINGS_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
format: ["mp4", "webm", "mov"].includes(parsed.format) ? parsed.format : "mp4",
|
||||
quality: ["draft", "standard", "high"].includes(parsed.quality)
|
||||
? parsed.quality
|
||||
: "standard",
|
||||
fps: [24, 30, 60].includes(parsed.fps) ? parsed.fps : 30,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { format: "mp4", quality: "standard", fps: 30 };
|
||||
}
|
||||
|
||||
export function persistRenderSettings(
|
||||
format: PersistedRenderSettings["format"],
|
||||
quality: PersistedRenderSettings["quality"],
|
||||
fps: PersistedRenderSettings["fps"],
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify({ format, quality, fps }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export interface StartRenderOptions {
|
||||
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) {
|
||||
@@ -86,17 +88,25 @@ export function useRenderQueue(projectId: string | null) {
|
||||
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 } = {
|
||||
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`, {
|
||||
|
||||
Reference in New Issue
Block a user