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:
Miguel Ángel
2026-05-15 22:55:54 +02:00
committed by GitHub
parent 9b23ccf665
commit 4fd9520a90
12 changed files with 235 additions and 12 deletions
@@ -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`, {