Skip to content

Commit e72f650

Browse files
committed
feat(producer): render at 720p and 480p from a larger composition
1 parent 41486b9 commit e72f650

11 files changed

Lines changed: 138 additions & 78 deletions

File tree

‎packages/cli/src/commands/render.test.ts‎

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,10 +1871,10 @@ describe("checkRenderResolutionPreflight", () => {
18711871
expect(result?.kind).toBe("hdr-incompatible");
18721872
});
18731873

1874-
it("classifies a preset smaller than the composition as downsampling", async () => {
1875-
// 3840×2160 comp + landscape (1920×1080): same 16:9 aspect, target smaller.
1874+
it("passes a preset smaller than the composition", async () => {
1875+
// 3840×2160 comp + landscape (1920×1080): same 16:9 aspect, rendered at 0.5× DPR.
18761876
const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", noModes);
1877-
expect(result?.kind).toBe("downsampling");
1877+
expect(result).toBeUndefined();
18781878
});
18791879

18801880
it("classifies a non-integer upscale as non-integer-scale", async () => {
@@ -1944,12 +1944,9 @@ describe("checkRenderResolutionPreflight", () => {
19441944
expect(result?.kind).toBe("hdr-incompatible");
19451945
});
19461946

1947-
it("still flags downsampling + aspect-agnostic (same-orientation, smaller preset)", async () => {
1948-
// 3840×2160 comp with `--resolution 1080p` → `landscape` (1920×1080).
1949-
// Same orientation, but tier smaller than comp — user asked for a
1950-
// downsample. That's a real incompatibility, not an orientation swap.
1947+
it("passes `--resolution 1080p` on a 4K composition (a smaller, same-orientation preset)", async () => {
19511948
const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", agnostic);
1952-
expect(result?.kind).toBe("downsampling");
1949+
expect(result).toBeUndefined();
19531950
});
19541951

19551952
it("does NOT auto-clear when the flag was explicit (orientation-locked preset stays strict)", async () => {
@@ -1983,14 +1980,10 @@ describe("checkRenderResolutionPreflight", () => {
19831980
expect(result?.message).toMatch(/preset whose orientation matches|omit --resolution/i);
19841981
});
19851982

1986-
it("blocks a portrait-4K comp + `1080p` downsample early (orientation-flip masks the tier gap)", async () => {
1987-
// 2160×3840 (portrait 4K) + `--resolution 1080p` — the compile stage
1988-
// remaps `landscape` → `portrait` (1080×1920), and *then* the preset
1989-
// is smaller than the composition. The un-remapped preflight let this
1990-
// slip through as an aspect-mismatch downgrade; the remap-then-check
1991-
// catches the real failure — downsampling — early.
1983+
it("passes a portrait-4K comp + `1080p` once remapped to portrait (a smaller preset)", async () => {
1984+
// 2160×3840 + `--resolution 1080p` remaps `landscape` → `portrait` (1080×1920), rendered at 0.5× DPR.
19921985
const result = await checkRenderResolutionPreflight(comp(2160, 3840), "landscape", agnostic);
1993-
expect(result?.kind).toBe("downsampling");
1986+
expect(result).toBeUndefined();
19941987
});
19951988

19961989
it("blocks a portrait 720p comp + `1080p` non-integer upscale early (orientation-flip masks the fractional DPR)", async () => {

‎packages/core/src/index.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ describe("@hyperframes/core public API exports", () => {
2222
"portrait-4k",
2323
"square",
2424
"square-4k",
25+
"landscape-720p",
26+
"portrait-720p",
27+
"square-720p",
28+
"landscape-480p",
29+
"portrait-480p",
30+
"square-480p",
2531
]);
2632
});
2733

‎packages/engine/src/services/frameCapture.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -907,18 +907,18 @@ async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boole
907907
* screenshots carry a real alpha channel (Chrome resets the override on every
908908
* navigation, so this must run after page load).
909909
*
910-
* drawElement is also skipped when supersampling (deviceScaleFactor > 1):
910+
* drawElement is also skipped when scaling (deviceScaleFactor other than 1):
911911
* `drawElementImage` reads the canvas at CSS pixels and has no equivalent of
912912
* `Page.captureScreenshot`'s clip+scale, so it would silently capture at 1x and
913913
* drop the requested supersample. Such renders fall through to the screenshot
914-
* path (preMode already forces "screenshot" for DPR > 1).
914+
* path (preMode already forces "screenshot" for DPR other than 1).
915915
*/
916916
async function initDrawElementOrTransparentBackground(
917917
session: CaptureSession,
918918
page: Page,
919919
logInitPhase: (phase: string) => void,
920920
): Promise<void> {
921-
const supersampling = (session.options.deviceScaleFactor ?? 1) > 1;
921+
const supersampling = (session.options.deviceScaleFactor ?? 1) !== 1;
922922
// forceScreenshot is an explicit routing decision made upstream (render-mode
923923
// compat hints like raw requestAnimationFrame, alpha formats, low-memory) —
924924
// drawElement must not override it. Concretely: an rAF-compat comp on
@@ -948,7 +948,7 @@ async function initDrawElementOrTransparentBackground(
948948
session.deFallbackTrigger = "supersampling";
949949
console.log(
950950
"[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
951-
"ignores deviceScaleFactor, so supersampled (DPR > 1) output uses screenshot capture.",
951+
"ignores deviceScaleFactor, so scaled (DPR other than 1) output uses screenshot capture.",
952952
);
953953
}
954954
if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) {
@@ -1286,8 +1286,8 @@ export async function createCaptureSession(
12861286
// (the captured surface is sized by the OS window in CSS pixels regardless
12871287
// of `Emulation.setDeviceMetricsOverride`'s DPR). When supersampling we
12881288
// need explicit clip+scale on `Page.captureScreenshot`, so fall back to
1289-
// the screenshot path for any DPR > 1.
1290-
const supersampling = (options.deviceScaleFactor ?? 1) > 1;
1289+
// the screenshot path for any DPR other than 1.
1290+
const supersampling = (options.deviceScaleFactor ?? 1) !== 1;
12911291
const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
12921292
const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
12931293
chromePath: headlessShell ?? undefined,

‎packages/parsers/src/outputResolutionCompatibility.test.ts‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,52 @@ describe("checkOutputResolutionCompatibility", () => {
116116
});
117117

118118
describe("scale constraints", () => {
119-
it("flags downsampling (preset smaller than composition)", () => {
120-
// 3840×2160 composition, landscape (1920×1080) preset → 0.5× DPR.
119+
it("allows a preset smaller than the composition (0.5× DPR)", () => {
121120
const result = checkOutputResolutionCompatibility({
122121
compositionWidth: 3840,
123122
compositionHeight: 2160,
124123
outputResolution: "landscape",
125124
});
126-
expect(result.ok).toBe(false);
127-
expect(result.kind).toBe("downsampling");
125+
expect(result.ok).toBe(true);
126+
});
127+
128+
it.each([
129+
[1920, 1080, "landscape-720p"],
130+
[1920, 1080, "landscape-480p"],
131+
[1080, 1920, "portrait-720p"],
132+
[1080, 1920, "portrait-480p"],
133+
[1080, 1080, "square-720p"],
134+
[1080, 1080, "square-480p"],
135+
] as const)(
136+
"allows %i×%i down to %s",
137+
(compositionWidth, compositionHeight, outputResolution) => {
138+
expect(
139+
checkOutputResolutionCompatibility({
140+
compositionWidth,
141+
compositionHeight,
142+
outputResolution,
143+
}),
144+
).toEqual({ ok: true });
145+
},
146+
);
147+
148+
it("keeps the 480p tier when suggesting a swap for the wrong orientation", () => {
149+
const result = checkOutputResolutionCompatibility({
150+
compositionWidth: 1920,
151+
compositionHeight: 1080,
152+
outputResolution: "portrait-480p",
153+
});
154+
expect(result.kind).toBe("aspect-mismatch");
155+
expect(result.suggestedResolution).toBe("landscape-480p");
156+
});
157+
158+
it("still refuses a near-16:9 composition a pixel too wide for an exact preset", () => {
159+
const result = checkOutputResolutionCompatibility({
160+
compositionWidth: 1921,
161+
compositionHeight: 1080,
162+
outputResolution: "landscape",
163+
});
164+
expect(result.kind).toBe("aspect-mismatch");
128165
});
129166

130167
it("flags a non-integer scale factor for a same-aspect custom composition", () => {

‎packages/parsers/src/outputResolutionCompatibility.ts‎

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import { CANVAS_DIMENSIONS, VALID_CANVAS_RESOLUTIONS, type CanvasResolution } fr
2828
export type OutputResolutionIssueKind =
2929
| "hdr-incompatible"
3030
| "aspect-mismatch"
31-
| "downsampling"
3231
| "non-integer-scale";
3332

3433
export interface OutputResolutionCompatibility {
@@ -69,24 +68,35 @@ export function suggestMatchingPreset(
6968
compositionHeight: number,
7069
chosen: CanvasResolution,
7170
): CanvasResolution | undefined {
72-
const aspectMatches: CanvasResolution[] = VALID_CANVAS_RESOLUTIONS.filter((preset) => {
73-
const { width, height } = CANVAS_DIMENSIONS[preset];
74-
// Integer-safe aspect compare (cross-multiplication), matching the
75-
// producer's mismatch check exactly.
76-
return width * compositionHeight === height * compositionWidth;
77-
});
71+
const aspectMatches: CanvasResolution[] = VALID_CANVAS_RESOLUTIONS.filter((preset) =>
72+
fitsAspect(CANVAS_DIMENSIONS[preset], compositionWidth, compositionHeight),
73+
);
7874
if (aspectMatches.length === 0) return undefined;
7975

8076
// Prefer the aspect-matching preset in the same resolution tier as the chosen
81-
// one so we don't silently downgrade 4K → HD (or vice versa). Tier is keyed
82-
// off the `-4k` suffix rather than long-side pixels: a 4K *square* (2160×2160)
77+
// one so we don't silently change 4K → HD (or vice versa). Tier is keyed
78+
// off the suffix (`-4k`, `-720p`, …) rather than long-side pixels: a 4K *square* (2160×2160)
8379
// has a shorter long side than 4K landscape (3840), so a pixel-based compare
8480
// would fail to recognise `square-4k` as the 4K peer and downgrade to `square`.
85-
const chosenIs4k = chosen.endsWith("-4k");
86-
const sameTier = aspectMatches.find((preset) => preset.endsWith("-4k") === chosenIs4k);
81+
const sameTier = aspectMatches.find((preset) => tierOf(preset) === tierOf(chosen));
8782
return sameTier ?? aspectMatches[0];
8883
}
8984

85+
/** `landscape-4k` → `-4k`, `portrait` → `` (1080p). */
86+
const tierOf = (preset: CanvasResolution) =>
87+
preset.includes("-") ? preset.slice(preset.indexOf("-")) : "";
88+
89+
/** Integer-safe aspect compare. A smaller preset may be under a pixel off on its long side (854×480 at 16:9). */
90+
function fitsAspect(
91+
target: { width: number; height: number },
92+
compositionWidth: number,
93+
compositionHeight: number,
94+
): boolean {
95+
const drift = Math.abs(target.width * compositionHeight - target.height * compositionWidth);
96+
if (drift === 0) return true;
97+
return target.width < compositionWidth && drift < Math.min(compositionWidth, compositionHeight);
98+
}
99+
90100
function describeOrientation(width: number, height: number): string {
91101
if (width > height) return "landscape";
92102
if (width < height) return "portrait";
@@ -154,26 +164,14 @@ export function checkOutputResolutionCompatibility(input: {
154164
}
155165

156166
const target = CANVAS_DIMENSIONS[outputResolution];
157-
// Aspect-ratio compare via cross-multiplication so the equality is integer-
158-
// safe. Float division (`target.width / compositionWidth`) loses precision
159-
// for non-power-of-2 ratios (e.g. cinema 4K 4096×2160 = 1.8963…) and a
160-
// future preset could trip a false-mismatch on otherwise valid input.
161-
if (target.width * compositionHeight !== target.height * compositionWidth) {
167+
if (!fitsAspect(target, compositionWidth, compositionHeight)) {
162168
return buildAspectMismatch(compositionWidth, compositionHeight, outputResolution, target);
163169
}
164170

165-
// Aspect ratios match → widthRatio === heightRatio. Compute once.
171+
// A smaller preset renders at a device scale factor under 1: Chrome lays the
172+
// page out at composition size and rasterizes it straight at the preset's.
166173
const widthRatio = target.width / compositionWidth;
167-
if (widthRatio < 1) {
168-
return {
169-
ok: false,
170-
kind: "downsampling",
171-
message:
172-
`outputResolution ${outputResolution} (${target.width}×${target.height}) ` +
173-
`is smaller than the composition (${compositionWidth}×${compositionHeight}). ` +
174-
`Downsampling via --resolution is not supported.`,
175-
};
176-
}
174+
if (widthRatio < 1) return OK;
177175

178176
if (!Number.isInteger(widthRatio)) {
179177
return {

‎packages/parsers/src/types.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export const CANVAS_DIMENSIONS = {
2323
"portrait-4k": { width: 2160, height: 3840 },
2424
square: { width: 1080, height: 1080 },
2525
"square-4k": { width: 2160, height: 2160 },
26+
"landscape-720p": { width: 1280, height: 720 },
27+
"portrait-720p": { width: 720, height: 1280 },
28+
"square-720p": { width: 720, height: 720 },
29+
// 480 tall at 16:9 is 853.33 wide: 854 keeps both sides even for 4:2:0 encoders.
30+
"landscape-480p": { width: 854, height: 480 },
31+
"portrait-480p": { width: 480, height: 854 },
32+
"square-480p": { width: 480, height: 480 },
2633
} as const;
2734

2835
// Single source of truth: derive the type from the table so adding a preset
@@ -49,6 +56,12 @@ const RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
4956
"1080p-square": "square",
5057
"square-1080p": "square",
5158
"4k-square": "square-4k",
59+
"720p": "landscape-720p",
60+
"720p-portrait": "portrait-720p",
61+
"720p-square": "square-720p",
62+
"480p": "landscape-480p",
63+
"480p-portrait": "portrait-480p",
64+
"480p-square": "square-480p",
5265
};
5366

5467
/**
@@ -70,6 +83,8 @@ const ASPECT_AGNOSTIC_RESOLUTION_ALIASES: ReadonlySet<string> = new Set([
7083
"hd",
7184
"4k",
7285
"uhd",
86+
"720p",
87+
"480p",
7388
]);
7489

7590
/**

‎packages/producer/src/services/distributed/renderChunk.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,8 +1072,8 @@ export async function renderChunk(
10721072
outputPath: outputChunkPath,
10731073
framesDir,
10741074
videoOnlyPath,
1075-
width: plan.dimensions.width * encoder.deviceScaleFactor,
1076-
height: plan.dimensions.height * encoder.deviceScaleFactor,
1075+
width: Math.round(plan.dimensions.width * encoder.deviceScaleFactor),
1076+
height: Math.round(plan.dimensions.height * encoder.deviceScaleFactor),
10771077
needsAlpha: plan.dimensions.format !== "mp4",
10781078
captureImageFormat: captureOptions.format ?? "jpeg",
10791079
// Each chunk produces video only — audio is muxed once at assemble

‎packages/producer/src/services/render/shared.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,16 @@ export function projectBrowserEndToCompositionTimeline(
105105
* `deviceScaleFactor`. The composition's intrinsic dimensions stay the
106106
* page-layout viewport; the screenshot lands at output dims via DPR.
107107
*
108-
* The scale must be a positive integer ≥ 1 — fractional DPRs introduce
108+
* Upsampling must be by a whole number — fractional DPRs above 1 introduce
109109
* visible aliasing and we'd rather fail loudly than produce a blurry
110-
* 4K render. Downsampling (output < composition) is rejected because
111-
* the user is unlikely to have intended it; if the use case appears
112-
* we can plumb a separate flag.
110+
* 4K render. A smaller preset (720p, 480p) takes a DPR under 1, so Chrome
111+
* rasterizes straight at the smaller size.
113112
*
114113
* Throws on:
115114
* - HDR + outputResolution (HDR compositor processes raw pixel buffers
116115
* at composition dimensions and would need parallel scaling).
117116
* - Aspect-ratio mismatch (e.g. landscape composition → portrait-4k).
118-
* - Non-integer scale ratio.
119-
* - Downsampling (output dimensions smaller than composition).
117+
* - Non-integer upsampling ratio.
120118
*/
121119
export function resolveDeviceScaleFactor(input: {
122120
compositionWidth: number;
@@ -140,8 +138,9 @@ export function resolveDeviceScaleFactor(input: {
140138
if (!compat.ok) throw new Error(compat.message);
141139

142140
const target = CANVAS_DIMENSIONS[input.outputResolution];
143-
// Aspect ratios match → widthRatio === heightRatio. Compute once.
144-
return target.width / input.compositionWidth;
141+
// The two ratios match, except a smaller preset's long side rounded to an even pixel (854×480): the long side's, so
142+
// Chrome draws all 854 and the short side's 480.375 rounds to 480.
143+
return Math.max(target.width / input.compositionWidth, target.height / input.compositionHeight);
145144
}
146145

147146
/**

‎packages/producer/src/services/render/stages/compileStage.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,10 +336,11 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
336336
hdrRequested: job.config.hdrMode === "force-hdr",
337337
alphaRequested: needsAlpha,
338338
});
339-
const outputWidth = width * deviceScaleFactor;
340-
const outputHeight = height * deviceScaleFactor;
341-
if (deviceScaleFactor > 1) {
342-
log.info("Supersampling composition via deviceScaleFactor", {
339+
// Rounded as Chrome sizes the screenshot: 854×480.375 → 854×480.
340+
const outputWidth = Math.round(width * deviceScaleFactor);
341+
const outputHeight = Math.round(height * deviceScaleFactor);
342+
if (deviceScaleFactor !== 1) {
343+
log.info("Scaling composition via deviceScaleFactor", {
343344
compositionWidth: width,
344345
compositionHeight: height,
345346
outputResolution: effectiveResolution,

‎packages/producer/src/services/renderOrchestrator.test.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1761,15 +1761,26 @@ describe("resolveDeviceScaleFactor", () => {
17611761
);
17621762
});
17631763

1764-
it("rejects downsampling (4K composition → 1080p output)", () => {
1765-
expect(() =>
1764+
it("returns 0.5 for a 4K composition → 1080p output", () => {
1765+
expect(
17661766
resolveDeviceScaleFactor({
17671767
...defaults,
17681768
compositionWidth: 3840,
17691769
compositionHeight: 2160,
17701770
outputResolution: "landscape",
17711771
}),
1772-
).toThrow(/Downsampling/);
1772+
).toBe(0.5);
1773+
});
1774+
1775+
it("scales 1080p down for 720p and 480p, so 480p frames are 854×480", () => {
1776+
const scale = (outputResolution: "landscape-720p" | "landscape-480p") =>
1777+
resolveDeviceScaleFactor({ ...defaults, outputResolution });
1778+
expect(scale("landscape-720p")).toBeCloseTo(2 / 3);
1779+
expect(scale("landscape-480p")).toBe(854 / 1920);
1780+
expect([
1781+
Math.round(1920 * scale("landscape-480p")),
1782+
Math.round(1080 * scale("landscape-480p")),
1783+
]).toEqual([854, 480]);
17731784
});
17741785

17751786
it("rejects non-integer scale factors", () => {

0 commit comments

Comments
 (0)