mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user