mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): add resolution selector to render export bar
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user