From 42f21d0110bf2957338fdf458eefd0c704095918 Mon Sep 17 00:00:00 2001 From: Sdergham Date: Wed, 16 Sep 2026 11:53:44 -0700 Subject: [PATCH 1/2] Cap the export canvas at 4K UHD so 5K captures export without a proxy The export canvas follows the source with no ceiling, so a full-screen capture of a 5K panel at backing scale 2 (5120 x 2880) produced a 2880 x 5120 canvas for 9:16 exports: 14.7 megapixels read back per frame and taller than H.264 hardware encoders accept. Exports stalled or failed at every tier. calculateMp4SourceDimensions now caps the derived canvas at 3840 on the long side and 2160 on the short side, for native and fixed-aspect exports alike, and every quality tier scales from the capped canvas. Sources at or below 4K are unchanged. Verified on a 179 s 5K capture: Medium at 9:16 exported 1620 x 2880, 4307 frames at 24 fps. Co-Authored-By: Claude Fable 5.1 --- .../video-editor/exportDimensions.test.ts | 84 +++++++++++++++++++ .../video-editor/exportDimensions.ts | 38 ++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/exportDimensions.test.ts b/src/components/video-editor/exportDimensions.test.ts index 5797daaed..6a2a0a928 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,66 @@ 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("applies the cap after native crop bounds", () => { + expect( + calculateMp4SourceDimensions(5120, 2880, "native", { + width: 0.5, + height: 1, + }), + ).toEqual({ + width: 2160, + height: 2430, + }); + }); + + 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("scales an ultrawide source down to the long-side ceiling", () => { + expect(capExportCanvasDimensions(5120, 2160)).toEqual({ width: 3840, height: 1620 }); + }); + + it("scales a 5K source down to the short-side ceiling", () => { + expect(capExportCanvasDimensions(5120, 2880)).toEqual({ width: 3840, height: 2160 }); + expect(capExportCanvasDimensions(2880, 5120)).toEqual({ width: 2160, height: 3840 }); + }); + + it("honours a custom ceiling", () => { + expect(capExportCanvasDimensions(2880, 5120, 2560, 1440)).toEqual({ + width: 1440, + height: 2560, + }); + }); }); describe("calculateMp4ExportDimensions", () => { @@ -81,6 +142,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..52039814d 100644 --- a/src/components/video-editor/exportDimensions.ts +++ b/src/components/video-editor/exportDimensions.ts @@ -35,6 +35,39 @@ function normalizeEvenDimension(value: number): number { return Math.max(2, Math.floor(value / 2) * 2); } +/** + * Ceiling 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 4K UHD (3840 on the long side, + * 2160 on the short side) keeps the largest tier encodable and every lower tier scales + * from the capped canvas. Sources at or below 4K are unaffected. + */ +export const MAX_EXPORT_LONG_SIDE = 3840; +export const MAX_EXPORT_SHORT_SIDE = 2160; + +export function capExportCanvasDimensions( + width: number, + height: number, + maxLongSide: number = MAX_EXPORT_LONG_SIDE, + maxShortSide: number = MAX_EXPORT_SHORT_SIDE, +): { width: number; height: number } { + const longSide = Math.max(width, height); + const shortSide = Math.min(width, height); + const scale = Math.min(1, maxLongSide / longSide, maxShortSide / shortSide); + + if (!Number.isFinite(scale) || scale >= 1) { + return { width: normalizeEvenDimension(width), height: normalizeEvenDimension(height) }; + } + + return { + width: normalizeEvenDimension(width * scale), + height: normalizeEvenDimension(height * scale), + }; +} + function fitAspectRatioWithinBounds( maxWidth: number, maxHeight: number, @@ -73,7 +106,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 +114,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( From 30c13408293e13d0af04960b1c035478ab1d79e5 Mon Sep 17 00:00:00 2001 From: Sdergham Date: Wed, 16 Sep 2026 13:32:54 -0700 Subject: [PATCH 2/2] Cap the export canvas by pixel budget instead of per-side ceilings A per-side ceiling (3840 long, 2160 short) also shrank wide or tall sources that were already inside the area of 4K UHD, such as a 5120 x 1440 ultrawide, whose Original export was fine. The cap is now a pixel budget equal to 4K UHD's area: a canvas over the budget scales by the square root of the ratio, keeping its shape, and anything under it keeps its size. The 5K-to-9:16 case lands on the same 2160 x 3840 canvas as before. The previous commit's claim that exports failed at every tier was not verified; only Original was observed failing before the proxy route. Co-Authored-By: Claude Fable 5.1 --- .../video-editor/exportDimensions.test.ts | 48 +++++++++++++++---- .../video-editor/exportDimensions.ts | 22 ++++----- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/components/video-editor/exportDimensions.test.ts b/src/components/video-editor/exportDimensions.test.ts index 6a2a0a928..05da6b631 100644 --- a/src/components/video-editor/exportDimensions.test.ts +++ b/src/components/video-editor/exportDimensions.test.ts @@ -73,15 +73,27 @@ describe("calculateMp4SourceDimensions", () => { }); }); - it("applies the cap after native crop bounds", () => { + 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: 2160, - height: 2430, + 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, }); }); @@ -103,17 +115,37 @@ describe("capExportCanvasDimensions", () => { expect(capExportCanvasDimensions(2160, 3840)).toEqual({ width: 2160, height: 3840 }); }); - it("scales an ultrawide source down to the long-side ceiling", () => { - expect(capExportCanvasDimensions(5120, 2160)).toEqual({ width: 3840, height: 1620 }); + it("leaves an ultrawide source inside the budget at its own size", () => { + expect(capExportCanvasDimensions(5120, 1440)).toEqual({ width: 5120, height: 1440 }); }); - it("scales a 5K source down to the short-side ceiling", () => { + 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("honours a custom ceiling", () => { - expect(capExportCanvasDimensions(2880, 5120, 2560, 1440)).toEqual({ + 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, }); diff --git a/src/components/video-editor/exportDimensions.ts b/src/components/video-editor/exportDimensions.ts index 52039814d..95dc9a08c 100644 --- a/src/components/video-editor/exportDimensions.ts +++ b/src/components/video-editor/exportDimensions.ts @@ -36,32 +36,30 @@ function normalizeEvenDimension(value: number): number { } /** - * Ceiling for the export canvas that the quality tiers scale from. + * 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 4K UHD (3840 on the long side, - * 2160 on the short side) keeps the largest tier encodable and every lower tier scales - * from the capped canvas. Sources at or below 4K are unaffected. + * 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_LONG_SIDE = 3840; -export const MAX_EXPORT_SHORT_SIDE = 2160; +export const MAX_EXPORT_CANVAS_PIXELS = 3840 * 2160; export function capExportCanvasDimensions( width: number, height: number, - maxLongSide: number = MAX_EXPORT_LONG_SIDE, - maxShortSide: number = MAX_EXPORT_SHORT_SIDE, + maxPixels: number = MAX_EXPORT_CANVAS_PIXELS, ): { width: number; height: number } { - const longSide = Math.max(width, height); - const shortSide = Math.min(width, height); - const scale = Math.min(1, maxLongSide / longSide, maxShortSide / shortSide); + const pixels = width * height; - if (!Number.isFinite(scale) || scale >= 1) { + 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),