feat(studio): add resolution selector to render export bar

This commit is contained in:
James
2026-05-07 16:58:26 +00:00
parent 2d3ad953ee
commit 25edba803a
8 changed files with 198 additions and 9 deletions
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerRenderRoutes } from "./render";
import type { StudioApiAdapter } from "../types";
function createAdapter(
startRenderSpy: ReturnType<typeof vi.fn>,
rendersDir = mkdtempSync(join(tmpdir(), "hf-render-test-")),
): { adapter: StudioApiAdapter; rendersDir: string } {
const adapter: StudioApiAdapter = {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: "/tmp/proj" }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => rendersDir,
startRender: (opts) => {
startRenderSpy(opts);
return {
id: opts.jobId,
status: "rendering",
progress: 0,
outputPath: opts.outputPath,
};
},
};
return { adapter, rendersDir };
}
function buildApp(spy: ReturnType<typeof vi.fn>): { app: Hono; cleanup: () => void } {
const { adapter, rendersDir } = createAdapter(spy);
const app = new Hono();
registerRenderRoutes(app, adapter);
return { app, cleanup: () => rmSync(rendersDir, { recursive: true, force: true }) };
}
describe("POST /projects/:id/render — outputResolution forwarding", () => {
it("forwards a valid resolution preset 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: "high",
format: "mp4",
resolution: "landscape-4k",
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBe("landscape-4k");
} finally {
cleanup();
}
});
it("omits outputResolution when the request does not specify one", 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);
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBeUndefined();
} finally {
cleanup();
}
});
it("drops an invalid resolution string (defense-in-depth, not a 400)", async () => {
// The route is intentionally lenient on unknown enum values — the producer
// is the source of truth for validation and emits a clear error message.
// We just want to make sure garbage doesn't propagate as if it were valid.
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", resolution: "8k" }),
});
expect(res.status).toBe(200);
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBeUndefined();
} finally {
cleanup();
}
});
it("accepts each of the four canonical preset values", async () => {
for (const preset of ["landscape", "portrait", "landscape-4k", "portrait-4k"] as const) {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: preset }),
});
expect(spy.mock.calls[0][0].outputResolution).toBe(preset);
} finally {
cleanup();
}
}
});
});
@@ -50,6 +50,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
fps?: number;
quality?: string;
format?: string;
resolution?: string;
};
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
@@ -58,6 +59,10 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
? (body.quality as string)
: "standard";
const VALID_RESOLUTIONS = new Set(["landscape", "portrait", "landscape-4k", "portrait-4k"]);
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "")
? (body.resolution as "landscape" | "portrait" | "landscape-4k" | "portrait-4k")
: undefined;
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
@@ -75,6 +80,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
fps,
quality,
jobId,
outputResolution,
});
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });
+7
View File
@@ -64,6 +64,13 @@ export interface StudioApiAdapter {
fps: number;
quality: string;
jobId: string;
/**
* Optional output resolution preset (e.g. "landscape-4k"). When set, the
* producer supersamples the composition via Chrome `deviceScaleFactor`.
* The composition's authored dimensions are unchanged. See the
* `resolveDeviceScaleFactor` constraints in the producer.
*/
outputResolution?: "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
}): RenderJobState;
/** Optional: generate a JPEG thumbnail via Puppeteer or similar. */