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
@@ -117,6 +117,83 @@ describe("POST /projects/:id/render — outputResolution forwarding", () => {
});
});
describe("POST /projects/:id/render — composition forwarding", () => {
it("forwards a valid composition path to the adapter", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
composition: "compositions/intro.html",
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].composition).toBe("compositions/intro.html");
} finally {
cleanup();
}
});
it("omits composition when not specified", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].composition).toBeUndefined();
} finally {
cleanup();
}
});
it("omits composition when empty string", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition: "" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].composition).toBeUndefined();
} finally {
cleanup();
}
});
it("rejects path-traversal attempts with 400", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
composition: "../../../etc/passwd",
}),
});
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — fps wire format", () => {
// The fps fraction-syntax feature accepts JSON `number` (integer fps) and
// JSON `string` (ffmpeg-style rational) on the wire, normalizing both to
+11 -1
View File
@@ -1,7 +1,7 @@
import type { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { join, resolve, sep } from "node:path";
import type { StudioApiAdapter, RenderJobState } from "../types.js";
import { VALID_CANVAS_RESOLUTIONS, parseFps, type CanvasResolution } from "../../core.types.js";
@@ -59,6 +59,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
quality?: string;
format?: string;
resolution?: string;
composition?: string;
};
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
@@ -76,6 +77,14 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "")
? (body.resolution as CanvasResolution)
: undefined;
let composition: string | undefined;
if (typeof body.composition === "string" && body.composition.length > 0) {
const resolved = resolve(project.dir, body.composition);
if (!resolved.startsWith(resolve(project.dir) + sep)) {
return c.json({ error: "composition path must be within the project directory" }, 400);
}
composition = body.composition;
}
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
@@ -94,6 +103,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
quality,
jobId,
outputResolution,
composition,
});
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });
+2
View File
@@ -88,6 +88,8 @@ export interface StudioApiAdapter {
* the producer for the integer-scale + aspect + HDR constraints.
*/
outputResolution?: CanvasResolution;
/** Entry file relative to projectDir (e.g. "compositions/intro.html"). Defaults to index.html. */
composition?: string;
}): RenderJobState;
/** Optional: generate a JPEG thumbnail via Puppeteer or similar. */