diff --git a/src/components/video-editor/exportDimensions.test.ts b/src/components/video-editor/exportDimensions.test.ts index 5797daaed..05da6b631 100644 --- a/src/components/video-editor/exportDimensions.test.ts +++ b/src/components/video-editor/exportDimensions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { calculateMp4ExportDimensions, calculateMp4SourceDimensions, + capExportCanvasDimensions, shouldDebounceMp4SupportProbe, } from "./exportDimensions"; @@ -57,6 +58,98 @@ describe("calculateMp4SourceDimensions", () => { height: 1080, }); }); + + it("caps a 5K full-screen capture at 4K UHD for native exports", () => { + expect(calculateMp4SourceDimensions(5120, 2880, "native")).toEqual({ + width: 3840, + height: 2160, + }); + }); + + it("caps the portrait canvas from a 5K capture at 4K UHD", () => { + expect(calculateMp4SourceDimensions(5120, 2880, "9:16")).toEqual({ + width: 2160, + height: 3840, + }); + }); + + it("leaves a native crop of a 5K capture alone once it fits the budget", () => { + expect( + calculateMp4SourceDimensions(5120, 2880, "native", { + width: 0.5, + height: 1, + }), + ).toEqual({ + width: 2560, + height: 2880, + }); + }); + + it("scales a native crop of a 5K capture that still exceeds the budget", () => { + expect( + calculateMp4SourceDimensions(5120, 2880, "native", { + width: 0.8, + height: 1, + }), + ).toEqual({ + width: 3434, + height: 2414, + }); + }); + + it("leaves a 4K UHD source untouched", () => { + expect(calculateMp4SourceDimensions(3840, 2160, "native")).toEqual({ + width: 3840, + height: 2160, + }); + expect(calculateMp4SourceDimensions(3840, 2160, "9:16")).toEqual({ + width: 2160, + height: 3840, + }); + }); +}); + +describe("capExportCanvasDimensions", () => { + it("returns dimensions inside the ceiling unchanged", () => { + expect(capExportCanvasDimensions(1920, 1080)).toEqual({ width: 1920, height: 1080 }); + expect(capExportCanvasDimensions(2160, 3840)).toEqual({ width: 2160, height: 3840 }); + }); + + it("leaves an ultrawide source inside the budget at its own size", () => { + expect(capExportCanvasDimensions(5120, 1440)).toEqual({ width: 5120, height: 1440 }); + }); + + it("scales an ultrawide source above the budget by area, keeping its shape", () => { + expect(capExportCanvasDimensions(5120, 2160)).toEqual({ width: 4434, height: 1870 }); + }); + + it("scales a 5K source down to the 4K UHD pixel budget", () => { + expect(capExportCanvasDimensions(5120, 2880)).toEqual({ width: 3840, height: 2160 }); + expect(capExportCanvasDimensions(2880, 5120)).toEqual({ width: 2160, height: 3840 }); + }); + + it("never exceeds the budget after rounding to even dimensions", () => { + for (const [width, height] of [ + [5120, 2880], + [6016, 3384], + [5120, 2160], + [7680, 4320], + [2880, 5120], + [3441, 1441], + ]) { + const capped = capExportCanvasDimensions(width, height); + expect(capped.width * capped.height).toBeLessThanOrEqual(3840 * 2160); + expect(capped.width % 2).toBe(0); + expect(capped.height % 2).toBe(0); + } + }); + + it("honours a custom budget", () => { + expect(capExportCanvasDimensions(2880, 5120, 2560 * 1440)).toEqual({ + width: 1440, + height: 2560, + }); + }); }); describe("calculateMp4ExportDimensions", () => { @@ -81,6 +174,29 @@ describe("calculateMp4ExportDimensions", () => { }); }); + it("scales every tier from the capped canvas on a 5K portrait export", () => { + const sourceDimensions = calculateMp4SourceDimensions(5120, 2880, "9:16"); + + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "source"), + ).toEqual({ + width: 2160, + height: 3840, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "good"), + ).toEqual({ + width: 1620, + height: 2880, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "medium"), + ).toEqual({ + width: 1296, + height: 2304, + }); + }); + it("scales portrait output dimensions from the aspect target", () => { const sourceDimensions = calculateMp4SourceDimensions(1920, 1080, "9:16"); diff --git a/src/components/video-editor/exportDimensions.ts b/src/components/video-editor/exportDimensions.ts index 2f9dcdc23..95dc9a08c 100644 --- a/src/components/video-editor/exportDimensions.ts +++ b/src/components/video-editor/exportDimensions.ts @@ -35,6 +35,37 @@ function normalizeEvenDimension(value: number): number { return Math.max(2, Math.floor(value / 2) * 2); } +/** + * Pixel budget for the export canvas that the quality tiers scale from. + * + * "Original" follows the source, and a full-screen capture of a 5K panel at backing + * scale 2 is 5120 x 2880. For a 9:16 export that produced a 2880 x 5120 canvas: + * 14.7 megapixels read back per frame and taller than H.264 hardware encoders accept, + * so the export stalled or failed. Capping the canvas at the area of 4K UHD keeps the + * largest tier encodable and every lower tier scales from the capped canvas, while a + * wide or tall source under that area (an ultrawide, for example) keeps its size. + * Sources at or below 4K UHD are unaffected. + */ +export const MAX_EXPORT_CANVAS_PIXELS = 3840 * 2160; + +export function capExportCanvasDimensions( + width: number, + height: number, + maxPixels: number = MAX_EXPORT_CANVAS_PIXELS, +): { width: number; height: number } { + const pixels = width * height; + + if (!Number.isFinite(pixels) || pixels <= 0 || pixels <= maxPixels) { + return { width: normalizeEvenDimension(width), height: normalizeEvenDimension(height) }; + } + + const scale = Math.sqrt(maxPixels / pixels); + return { + width: normalizeEvenDimension(width * scale), + height: normalizeEvenDimension(height * scale), + }; +} + function fitAspectRatioWithinBounds( maxWidth: number, maxHeight: number, @@ -73,7 +104,7 @@ export function calculateMp4SourceDimensions( const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio); if (aspectRatio === "native") { - return { width: safeSourceWidth, height: safeSourceHeight }; + return capExportCanvasDimensions(safeSourceWidth, safeSourceHeight); } const longSide = Math.max(safeSourceWidth, safeSourceHeight); @@ -81,7 +112,8 @@ export function calculateMp4SourceDimensions( const maxWidth = aspectRatioValue >= 1 ? longSide : shortSide; const maxHeight = aspectRatioValue >= 1 ? shortSide : longSide; - return fitAspectRatioWithinBounds(maxWidth, maxHeight, aspectRatioValue); + const fitted = fitAspectRatioWithinBounds(maxWidth, maxHeight, aspectRatioValue); + return capExportCanvasDimensions(fitted.width, fitted.height); } export function calculateMp4ExportDimensions(