From f3e341d823f4c6e6e41410c60f8e1023a0e189a0 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 19 Oct 2026 21:28:07 +1100 Subject: [PATCH] fix(export): size the output from the edit and scale bitrate with it --- src/core/export/export-coordinator.ts | 8 +++--- src/core/export/export-utils.ts | 8 ++++++ tests/export-coordinator.test.ts | 35 +++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index 7dfa677b..8e1a2e57 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -7,7 +7,7 @@ import * as pixi from "pixi.js"; import { AudioProcessor } from "./audio-processor"; import { findBrowserIssue, findEditIssues } from "./export-preflight"; import { ExportProgressUI } from "./export-progress-ui"; -import { ExportError } from "./export-utils"; +import { ExportError, exportBitrate } from "./export-utils"; import { VideoFrameProcessor } from "./video-frame-processor"; interface ExportConfig { @@ -257,13 +257,13 @@ export class ExportCoordinator { } private prepareConfig(fps: number): ExportConfig { - const outputSize = this.edit.getEdit().output?.size; - const size = { width: outputSize?.width ?? 1920, height: outputSize?.height ?? 1080 }; + // The size the canvas lays clips out in: the resolution preset shaped by aspectRatio, else output.size. + const size = { width: this.edit.size.width, height: this.edit.size.height }; // totalDuration is in seconds return { fps, size, - bitrate: 5_000_000, + bitrate: exportBitrate(size), frames: Math.ceil(this.edit.totalDuration * fps), frameDuration: 1 / fps // seconds per frame }; diff --git a/src/core/export/export-utils.ts b/src/core/export/export-utils.ts index b65ed45d..74b934d1 100644 --- a/src/core/export/export-utils.ts +++ b/src/core/export/export-utils.ts @@ -59,3 +59,11 @@ export class ExportError extends Error { this.cause = cause; } } + +/** + * 5 Mbps at 1920×1080 scaled by pixel count, so every output size gets the bits per pixel that 1080p + * had, with a 1 Mbps floor: small outputs still carry text, which blurs first when starved. + */ +export function exportBitrate({ width, height }: { width: number; height: number }): number { + return Math.max(1_000_000, Math.round((5_000_000 * width * height) / (1920 * 1080))); +} diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts index decc752f..796b77ad 100644 --- a/tests/export-coordinator.test.ts +++ b/tests/export-coordinator.test.ts @@ -4,7 +4,8 @@ import type { Canvas } from "@canvas/shotstack-canvas"; import type { Edit } from "@core/edit-session"; import { ASSET_SETTLE_TIMEOUT_MS, ExportCoordinator } from "@core/export/export-coordinator"; import { ExportProgressUI } from "@core/export/export-progress-ui"; -import { ExportError } from "@core/export/export-utils"; +import { ExportError, exportBitrate } from "@core/export/export-utils"; +import { calculateSizeFromPreset } from "@core/output-settings-manager"; import { canEncodeVideo, CanvasSink, CanvasSource, Input, Output } from "mediabunny"; const mockOutputs: Array<{ cancel: jest.Mock; state: string }> = []; @@ -63,8 +64,9 @@ function createHarness() { isPlaying: false, playbackTime: 1.5, totalDuration: 1, + size: { ...SIZE }, tracks: [] as Array>, - getEdit: () => ({ output: { fps: 2, size: SIZE } }), + getEdit: () => ({ output: { fps: 2 } }), getOutputFormat: jest.fn(() => "mp4"), getResolvedEdit: () => ({ timeline: { tracks: [] }, output: { format: "mp4" } }), getFontUrlByFamilyAndWeight: () => null, @@ -413,6 +415,35 @@ describe("editor restart", () => { }); }); +describe("output size", () => { + it("draws and encodes at the size the editor lays clips out in, with a bitrate for that size", async () => { + const { exporter, edit, renderer } = createHarness(); + // An edit sized by resolution "hd" and aspectRatio "9:16", with no output.size. + edit.size = calculateSizeFromPreset("hd", "9:16"); + + await exporter.exportBlob(); + + expect(renderer.extract.pixels).toHaveBeenCalledWith(expect.objectContaining({ frame: { x: 0, y: 0, width: 720, height: 1280 } })); + expect(canEncodeVideo).toHaveBeenCalledWith("avc", { width: 720, height: 1280, bitrate: 2_222_222 }); + expect(CanvasSource).toHaveBeenCalledWith(expect.objectContaining({ width: 720, height: 1280 }), { codec: "avc", bitrate: 2_222_222 }); + exporter.dispose(); + }); +}); + +describe("exportBitrate", () => { + it("keeps 5 Mbps at 1080p in either orientation and scales with pixel count", () => { + expect(exportBitrate({ width: 1920, height: 1080 })).toBe(5_000_000); + expect(exportBitrate({ width: 1080, height: 1920 })).toBe(5_000_000); + expect(exportBitrate({ width: 3840, height: 2160 })).toBe(20_000_000); + expect(exportBitrate({ width: 1280, height: 720 })).toBe(2_222_222); + }); + + it("never drops below 1 Mbps", () => { + expect(exportBitrate({ width: 640, height: 360 })).toBe(1_000_000); + expect(exportBitrate({ width: 512, height: 288 })).toBe(1_000_000); + }); +}); + describe("ExportCoordinator.export", () => { it("wraps a failed download as an export-phase error and removes the overlay", async () => { const { exporter } = createHarness();