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
+7 -1
View File
@@ -145,7 +145,13 @@ For a 4K render of a 30-second composition, plan on a few minutes of wall time o
The Renders panel in Studio includes a resolution dropdown next to the format and quality selectors. Pick `4K` (or `4K ↕` for portrait) and hit **Export** — the same supersampling path runs as the CLI flag, no composition edits required.
The dropdown defaults to `Auto` (render at the composition's authored size). The resolution applies per render, not per project — your composition files are unchanged.
The dropdown defaults to `Auto` (render at the composition's authored size). Available presets:
- **Auto** — composition's native dimensions
- **1080p ↔** / **1080p ↕** — 1920×1080 / 1080×1920
- **4K ↔** / **4K ↕** — 3840×2160 / 2160×3840
The resolution applies per render, not per project — your composition files are unchanged. The same [constraints](#constraints) apply; when the producer rejects a combination, the failure surfaces in the Studio render queue.
You can also drive resolution from the CLI:
+1
View File
@@ -216,6 +216,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
fps: opts.fps as 24 | 30 | 60,
quality: opts.quality as "draft" | "standard" | "high",
format: opts.format,
outputResolution: opts.outputResolution,
});
const startTime = Date.now();
const onProgress = (j: { progress: number; currentStage?: string }) => {
@@ -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. */
+3 -1
View File
@@ -1684,7 +1684,9 @@ export function StudioApp() {
projectId={projectId}
onDelete={renderQueue.deleteRender}
onClearCompleted={renderQueue.clearCompleted}
onStartRender={(format, quality) => renderQueue.startRender(30, quality, format)}
onStartRender={(format, quality, resolution) =>
renderQueue.startRender(30, quality, format, resolution)
}
isRendering={renderQueue.isRendering}
/>
)}
@@ -1,16 +1,36 @@
import { memo, useState, useRef, useEffect } from "react";
import { RenderQueueItem } from "./RenderQueueItem";
import type { RenderJob } from "./useRenderQueue";
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
interface RenderQueueProps {
jobs: RenderJob[];
projectId: string;
onDelete: (jobId: string) => void;
onClearCompleted: () => void;
onStartRender: (format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high") => void;
onStartRender: (
format: "mp4" | "webm" | "mov",
quality: "draft" | "standard" | "high",
resolution: ResolutionPreset | "auto",
) => void;
isRendering: boolean;
}
const RESOLUTION_OPTIONS: { value: ResolutionPreset | "auto"; label: string; title: string }[] = [
{ value: "auto", label: "Auto", title: "Render at the composition's authored resolution" },
{ value: "landscape", label: "1080p", title: "1920×1080 landscape" },
{ value: "portrait", label: "1080p ↕", title: "1080×1920 portrait" },
{
value: "landscape-4k",
label: "4K",
title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.",
},
{
value: "portrait-4k",
label: "4K ↕",
title: "2160×3840 — supersamples a 1080p portrait composition via Chrome DPR.",
},
];
const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
mov: {
@@ -91,11 +111,16 @@ function FormatExportButton({
onStartRender,
isRendering,
}: {
onStartRender: (format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high") => void;
onStartRender: (
format: "mp4" | "webm" | "mov",
quality: "draft" | "standard" | "high",
resolution: ResolutionPreset | "auto",
) => void;
isRendering: boolean;
}) {
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
const showQuality = format !== "mov";
@@ -103,13 +128,26 @@ function FormatExportButton({
return (
<div className="flex items-center gap-1">
<FormatInfoTooltip format={format} />
<select
value={resolution}
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
disabled={isRendering}
title={RESOLUTION_OPTIONS.find((r) => r.value === resolution)?.title}
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
>
{RESOLUTION_OPTIONS.map((r) => (
<option key={r.value} value={r.value} title={r.title}>
{r.label}
</option>
))}
</select>
{showQuality && (
<select
value={quality}
onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
disabled={isRendering}
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
>
{QUALITY_OPTIONS.map((q) => (
<option key={q.value} value={q.value} title={q.title}>
@@ -122,14 +160,14 @@ function FormatExportButton({
value={format}
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
disabled={isRendering}
className={`h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50 ${showQuality ? "" : "rounded-l"}`}
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
>
<option value="mp4">MP4</option>
<option value="mov">MOV</option>
<option value="webm">WebM</option>
</select>
<button
onClick={() => onStartRender(format, quality)}
onClick={() => onStartRender(format, quality, resolution)}
disabled={isRendering}
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
>
@@ -11,6 +11,8 @@ export interface RenderJob {
durationMs?: number;
}
export type ResolutionPreset = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
export function useRenderQueue(projectId: string | null) {
const [jobs, setJobs] = useState<RenderJob[]>([]);
const eventSourceRef = useRef<EventSource | null>(null);
@@ -63,16 +65,26 @@ export function useRenderQueue(projectId: string | null) {
fps = 30,
quality: "draft" | "standard" | "high" = "standard",
format: "mp4" | "webm" | "mov" = "mp4",
resolution: ResolutionPreset | "auto" = "auto",
) => {
if (!projectId) return;
const startTime = Date.now();
// "auto" means "render at the composition's authored size" — omit the
// field entirely so the producer's resolveDeviceScaleFactor returns 1.
// Sending the string "auto" would fail the route's validation set.
const body: { fps: number; quality: string; format: string; resolution?: string } = {
fps,
quality,
format,
};
if (resolution !== "auto") body.resolution = resolution;
let res: Response;
try {
res = await fetch(`/api/projects/${projectId}/render`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fps, quality, format }),
body: JSON.stringify(body),
});
} catch {
const failedJob: RenderJob = {