Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/guides/4k-rendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
117 changes: 117 additions & 0 deletions packages/core/src/studio-api/routes/render.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
});
});
6 changes: 6 additions & 0 deletions packages/core/src/studio-api/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand All @@ -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);
Expand All @@ -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 });
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/studio-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
)}
Expand Down
68 changes: 62 additions & 6 deletions packages/studio/src/components/renders/RenderQueue.tsx
Original file line number Diff line number Diff line change
@@ -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<ResolutionPreset | "auto", { label: string; title: string }> = {
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: {
Expand Down Expand Up @@ -91,25 +125,47 @@ 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";

return (
<div className="flex items-center gap-1">
<FormatInfoTooltip format={format} />
{/* Resolution must remain the leftmost <select> in this row — it
carries `rounded-l` for the joined-button look. If you ever hide it
(feature-flag, etc.), move `rounded-l` to whichever element ends up
leftmost. */}
<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}>
Expand All @@ -122,14 +178,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"
>
Expand Down
14 changes: 13 additions & 1 deletion packages/studio/src/components/renders/useRenderQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: string;
outputResolution?: "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
}) => unknown;
executeRenderJob: (
job: unknown,
Expand Down Expand Up @@ -244,6 +245,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
fps: opts.fps as 24 | 30 | 60,
quality: opts.quality as "draft" | "standard" | "high",
format: opts.format,
outputResolution: opts.outputResolution,
});
const onProgress = (j: { progress: number; currentStage?: string }) => {
state.progress = j.progress;
Expand Down
Loading