diff --git a/docs/guides/4k-rendering.mdx b/docs/guides/4k-rendering.mdx index dd9170036..198ec5be8 100644 --- a/docs/guides/4k-rendering.mdx +++ b/docs/guides/4k-rendering.mdx @@ -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: diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index c31928475..354be4f6b 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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 }) => { diff --git a/packages/core/src/studio-api/routes/render.test.ts b/packages/core/src/studio-api/routes/render.test.ts new file mode 100644 index 000000000..bb702cf92 --- /dev/null +++ b/packages/core/src/studio-api/routes/render.test.ts @@ -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, + 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): { 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(); + } + } + }); +}); diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/core/src/studio-api/routes/render.ts index a84b1943d..a5370975c 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/core/src/studio-api/routes/render.ts @@ -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 = { 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 }); diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts index 0c239ae6f..0a3f44579 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/core/src/studio-api/types.ts @@ -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. */ diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index ce4afc1c6..ba6f03d6b 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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} /> )} diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 8a6e69909..4e59caa5b 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -1,16 +1,50 @@ 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; } +// Indexing the table by `ResolutionPreset | "auto"` makes adding a new preset +// to `core.types` (e.g. an 8K row) a TypeScript error here instead of a +// silently missing dropdown entry. Order is fixed by the array below. +const RESOLUTION_LABELS: Record = { + auto: { label: "Auto", title: "Render at the composition's authored resolution" }, + landscape: { label: "1080p", title: "1920×1080 landscape" }, + portrait: { label: "1080p ↕", title: "1080×1920 portrait" }, + "landscape-4k": { + label: "4K", + title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.", + }, + "portrait-4k": { + label: "4K ↕", + title: "2160×3840 — supersamples a 1080p portrait composition via Chrome DPR.", + }, +}; + +const RESOLUTION_OPTION_ORDER: (ResolutionPreset | "auto")[] = [ + "auto", + "landscape", + "portrait", + "landscape-4k", + "portrait-4k", +]; + +const RESOLUTION_OPTIONS = RESOLUTION_OPTION_ORDER.map((value) => ({ + value, + ...RESOLUTION_LABELS[value], +})); + 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 +125,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("auto"); // MOV (ProRes) is a fixed-quality codec — quality selector has no effect. const showQuality = format !== "mov"; @@ -103,13 +142,30 @@ function FormatExportButton({ return (
+ {/* Resolution must remain the leftmost 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) => ( + + ))} + {showQuality && (