Skip to content

Commit 9871441

Browse files
committed
feat(studio): add resolution selector to render export bar
1 parent 00da353 commit 9871441

8 files changed

Lines changed: 202 additions & 9 deletions

File tree

docs/guides/4k-rendering.mdx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,17 @@ For a 4K render of a 30-second composition, plan on a few minutes of wall time o
126126

127127
## Studio support
128128

129-
A resolution selector in Studio is on the way (tracked separately). For now, switch resolution from the CLI:
129+
The export bar in Hyperframes Studio includes a resolution selector. Open the **Renders** panel, pick a preset from the resolution dropdown (default: `Auto` — render at the composition's authored size), and hit **Export**. The selected resolution applies per render — your composition files are not modified.
130+
131+
Available presets in the dropdown match the CLI:
132+
133+
- **Auto** — composition's native dimensions
134+
- **1080p** / **1080p ↕** — 1920×1080 / 1080×1920
135+
- **4K** / **4K ↕** — 3840×2160 / 2160×3840
136+
137+
The same constraints apply (aspect ratio must match, integer scale only, not yet combined with HDR). When the producer rejects a combination, the render exits before any frames are captured and the failure surfaces in the Studio render queue.
138+
139+
You can also drive resolution from the CLI:
130140

131141
- **New project**: `hyperframes init my-video --resolution 4k`
132142
- **Existing project**: `hyperframes render --resolution 4k --output 4k.mp4`

packages/cli/src/server/studioServer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
216216
fps: opts.fps as 24 | 30 | 60,
217217
quality: opts.quality as "draft" | "standard" | "high",
218218
format: opts.format,
219+
outputResolution: opts.outputResolution,
219220
});
220221
const startTime = Date.now();
221222
const onProgress = (j: { progress: number; currentStage?: string }) => {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { Hono } from "hono";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { registerRenderRoutes } from "./render";
7+
import type { StudioApiAdapter } from "../types";
8+
9+
function createAdapter(
10+
startRenderSpy: ReturnType<typeof vi.fn>,
11+
rendersDir = mkdtempSync(join(tmpdir(), "hf-render-test-")),
12+
): { adapter: StudioApiAdapter; rendersDir: string } {
13+
const adapter: StudioApiAdapter = {
14+
listProjects: () => [],
15+
resolveProject: async (id: string) => ({ id, dir: "/tmp/proj" }),
16+
bundle: async () => null,
17+
lint: async () => ({ findings: [] }),
18+
runtimeUrl: "/api/runtime.js",
19+
rendersDir: () => rendersDir,
20+
startRender: (opts) => {
21+
startRenderSpy(opts);
22+
return {
23+
id: opts.jobId,
24+
status: "rendering",
25+
progress: 0,
26+
outputPath: opts.outputPath,
27+
};
28+
},
29+
};
30+
return { adapter, rendersDir };
31+
}
32+
33+
function buildApp(spy: ReturnType<typeof vi.fn>): { app: Hono; cleanup: () => void } {
34+
const { adapter, rendersDir } = createAdapter(spy);
35+
const app = new Hono();
36+
registerRenderRoutes(app, adapter);
37+
return { app, cleanup: () => rmSync(rendersDir, { recursive: true, force: true }) };
38+
}
39+
40+
describe("POST /projects/:id/render — outputResolution forwarding", () => {
41+
it("forwards a valid resolution preset to the adapter", async () => {
42+
const spy = vi.fn();
43+
const { app, cleanup } = buildApp(spy);
44+
try {
45+
const res = await app.request("http://localhost/projects/demo/render", {
46+
method: "POST",
47+
headers: { "content-type": "application/json" },
48+
body: JSON.stringify({
49+
fps: 30,
50+
quality: "high",
51+
format: "mp4",
52+
resolution: "landscape-4k",
53+
}),
54+
});
55+
expect(res.status).toBe(200);
56+
expect(spy).toHaveBeenCalledOnce();
57+
const opts = spy.mock.calls[0][0];
58+
expect(opts.outputResolution).toBe("landscape-4k");
59+
} finally {
60+
cleanup();
61+
}
62+
});
63+
64+
it("omits outputResolution when the request does not specify one", async () => {
65+
const spy = vi.fn();
66+
const { app, cleanup } = buildApp(spy);
67+
try {
68+
const res = await app.request("http://localhost/projects/demo/render", {
69+
method: "POST",
70+
headers: { "content-type": "application/json" },
71+
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
72+
});
73+
expect(res.status).toBe(200);
74+
const opts = spy.mock.calls[0][0];
75+
expect(opts.outputResolution).toBeUndefined();
76+
} finally {
77+
cleanup();
78+
}
79+
});
80+
81+
it("drops an invalid resolution string (defense-in-depth, not a 400)", async () => {
82+
// The route is intentionally lenient on unknown enum values — the producer
83+
// is the source of truth for validation and emits a clear error message.
84+
// We just want to make sure garbage doesn't propagate as if it were valid.
85+
const spy = vi.fn();
86+
const { app, cleanup } = buildApp(spy);
87+
try {
88+
const res = await app.request("http://localhost/projects/demo/render", {
89+
method: "POST",
90+
headers: { "content-type": "application/json" },
91+
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: "8k" }),
92+
});
93+
expect(res.status).toBe(200);
94+
const opts = spy.mock.calls[0][0];
95+
expect(opts.outputResolution).toBeUndefined();
96+
} finally {
97+
cleanup();
98+
}
99+
});
100+
101+
it("accepts each of the four canonical preset values", async () => {
102+
for (const preset of ["landscape", "portrait", "landscape-4k", "portrait-4k"] as const) {
103+
const spy = vi.fn();
104+
const { app, cleanup } = buildApp(spy);
105+
try {
106+
await app.request("http://localhost/projects/demo/render", {
107+
method: "POST",
108+
headers: { "content-type": "application/json" },
109+
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: preset }),
110+
});
111+
expect(spy.mock.calls[0][0].outputResolution).toBe(preset);
112+
} finally {
113+
cleanup();
114+
}
115+
}
116+
});
117+
});

packages/core/src/studio-api/routes/render.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
5050
fps?: number;
5151
quality?: string;
5252
format?: string;
53+
resolution?: string;
5354
};
5455
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
5556
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
@@ -58,6 +59,10 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
5859
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
5960
? (body.quality as string)
6061
: "standard";
62+
const VALID_RESOLUTIONS = new Set(["landscape", "portrait", "landscape-4k", "portrait-4k"]);
63+
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "")
64+
? (body.resolution as "landscape" | "portrait" | "landscape-4k" | "portrait-4k")
65+
: undefined;
6166

6267
const now = new Date();
6368
const datePart = now.toISOString().slice(0, 10);
@@ -75,6 +80,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
7580
fps,
7681
quality,
7782
jobId,
83+
outputResolution,
7884
});
7985
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
8086
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });

packages/core/src/studio-api/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ export interface StudioApiAdapter {
6464
fps: number;
6565
quality: string;
6666
jobId: string;
67+
/**
68+
* Optional output resolution preset (e.g. "landscape-4k"). When set, the
69+
* producer supersamples the composition via Chrome `deviceScaleFactor`.
70+
* The composition's authored dimensions are unchanged. See the
71+
* `resolveDeviceScaleFactor` constraints in the producer.
72+
*/
73+
outputResolution?: "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
6774
}): RenderJobState;
6875

6976
/** Optional: generate a JPEG thumbnail via Puppeteer or similar. */

packages/studio/src/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1684,7 +1684,9 @@ export function StudioApp() {
16841684
projectId={projectId}
16851685
onDelete={renderQueue.deleteRender}
16861686
onClearCompleted={renderQueue.clearCompleted}
1687-
onStartRender={(format, quality) => renderQueue.startRender(30, quality, format)}
1687+
onStartRender={(format, quality, resolution) =>
1688+
renderQueue.startRender(30, quality, format, resolution)
1689+
}
16881690
isRendering={renderQueue.isRendering}
16891691
/>
16901692
)}

packages/studio/src/components/renders/RenderQueue.tsx

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
11
import { memo, useState, useRef, useEffect } from "react";
22
import { RenderQueueItem } from "./RenderQueueItem";
3-
import type { RenderJob } from "./useRenderQueue";
3+
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
44

55
interface RenderQueueProps {
66
jobs: RenderJob[];
77
projectId: string;
88
onDelete: (jobId: string) => void;
99
onClearCompleted: () => void;
10-
onStartRender: (format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high") => void;
10+
onStartRender: (
11+
format: "mp4" | "webm" | "mov",
12+
quality: "draft" | "standard" | "high",
13+
resolution: ResolutionPreset | "auto",
14+
) => void;
1115
isRendering: boolean;
1216
}
1317

18+
const RESOLUTION_OPTIONS: { value: ResolutionPreset | "auto"; label: string; title: string }[] = [
19+
{ value: "auto", label: "Auto", title: "Render at the composition's authored resolution" },
20+
{ value: "landscape", label: "1080p", title: "1920×1080 landscape" },
21+
{ value: "portrait", label: "1080p ↕", title: "1080×1920 portrait" },
22+
{
23+
value: "landscape-4k",
24+
label: "4K",
25+
title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.",
26+
},
27+
{
28+
value: "portrait-4k",
29+
label: "4K ↕",
30+
title: "2160×3840 — supersamples a 1080p portrait composition via Chrome DPR.",
31+
},
32+
];
33+
1434
const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
1535
mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
1636
mov: {
@@ -91,25 +111,43 @@ function FormatExportButton({
91111
onStartRender,
92112
isRendering,
93113
}: {
94-
onStartRender: (format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high") => void;
114+
onStartRender: (
115+
format: "mp4" | "webm" | "mov",
116+
quality: "draft" | "standard" | "high",
117+
resolution: ResolutionPreset | "auto",
118+
) => void;
95119
isRendering: boolean;
96120
}) {
97121
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
98122
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
123+
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
99124

100125
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
101126
const showQuality = format !== "mov";
102127

103128
return (
104129
<div className="flex items-center gap-1">
105130
<FormatInfoTooltip format={format} />
131+
<select
132+
value={resolution}
133+
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
134+
disabled={isRendering}
135+
title={RESOLUTION_OPTIONS.find((r) => r.value === resolution)?.title}
136+
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
137+
>
138+
{RESOLUTION_OPTIONS.map((r) => (
139+
<option key={r.value} value={r.value} title={r.title}>
140+
{r.label}
141+
</option>
142+
))}
143+
</select>
106144
{showQuality && (
107145
<select
108146
value={quality}
109147
onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
110148
disabled={isRendering}
111149
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
112-
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
150+
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
113151
>
114152
{QUALITY_OPTIONS.map((q) => (
115153
<option key={q.value} value={q.value} title={q.title}>
@@ -122,14 +160,14 @@ function FormatExportButton({
122160
value={format}
123161
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
124162
disabled={isRendering}
125-
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"}`}
163+
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
126164
>
127165
<option value="mp4">MP4</option>
128166
<option value="mov">MOV</option>
129167
<option value="webm">WebM</option>
130168
</select>
131169
<button
132-
onClick={() => onStartRender(format, quality)}
170+
onClick={() => onStartRender(format, quality, resolution)}
133171
disabled={isRendering}
134172
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"
135173
>

packages/studio/src/components/renders/useRenderQueue.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface RenderJob {
1111
durationMs?: number;
1212
}
1313

14+
export type ResolutionPreset = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
15+
1416
export function useRenderQueue(projectId: string | null) {
1517
const [jobs, setJobs] = useState<RenderJob[]>([]);
1618
const eventSourceRef = useRef<EventSource | null>(null);
@@ -63,16 +65,26 @@ export function useRenderQueue(projectId: string | null) {
6365
fps = 30,
6466
quality: "draft" | "standard" | "high" = "standard",
6567
format: "mp4" | "webm" | "mov" = "mp4",
68+
resolution: ResolutionPreset | "auto" = "auto",
6669
) => {
6770
if (!projectId) return;
6871

6972
const startTime = Date.now();
73+
// "auto" means "render at the composition's authored size" — omit the
74+
// field entirely so the producer's resolveDeviceScaleFactor returns 1.
75+
// Sending the string "auto" would fail the route's validation set.
76+
const body: { fps: number; quality: string; format: string; resolution?: string } = {
77+
fps,
78+
quality,
79+
format,
80+
};
81+
if (resolution !== "auto") body.resolution = resolution;
7082
let res: Response;
7183
try {
7284
res = await fetch(`/api/projects/${projectId}/render`, {
7385
method: "POST",
7486
headers: { "Content-Type": "application/json" },
75-
body: JSON.stringify({ fps, quality, format }),
87+
body: JSON.stringify(body),
7688
});
7789
} catch {
7890
const failedJob: RenderJob = {

0 commit comments

Comments
 (0)