Skip to content

Commit a03c131

Browse files
committed
fix(export): size the output from the edit and scale bitrate with it
1 parent f14139a commit a03c131

3 files changed

Lines changed: 45 additions & 6 deletions

File tree

‎src/core/export/export-coordinator.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as pixi from "pixi.js";
77
import { AudioProcessor } from "./audio-processor";
88
import { findBrowserIssue, findEditIssues } from "./export-preflight";
99
import { ExportProgressUI } from "./export-progress-ui";
10-
import { ExportError } from "./export-utils";
10+
import { ExportError, exportBitrate } from "./export-utils";
1111
import { VideoFrameProcessor } from "./video-frame-processor";
1212

1313
interface ExportConfig {
@@ -257,13 +257,13 @@ export class ExportCoordinator {
257257
}
258258

259259
private prepareConfig(fps: number): ExportConfig {
260-
const outputSize = this.edit.getEdit().output?.size;
261-
const size = { width: outputSize?.width ?? 1920, height: outputSize?.height ?? 1080 };
260+
// The size the canvas lays clips out in: the resolution preset shaped by aspectRatio, else output.size.
261+
const size = { width: this.edit.size.width, height: this.edit.size.height };
262262
// totalDuration is in seconds
263263
return {
264264
fps,
265265
size,
266-
bitrate: 5_000_000,
266+
bitrate: exportBitrate(size),
267267
frames: Math.ceil(this.edit.totalDuration * fps),
268268
frameDuration: 1 / fps // seconds per frame
269269
};

‎src/core/export/export-utils.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,11 @@ export class ExportError extends Error {
5959
this.cause = cause;
6060
}
6161
}
62+
63+
/**
64+
* 5 Mbps at 1920×1080 scaled by pixel count, so every output size gets the bits per pixel that 1080p
65+
* had, with a 1 Mbps floor: small outputs still carry text, which blurs first when starved.
66+
*/
67+
export function exportBitrate({ width, height }: { width: number; height: number }): number {
68+
return Math.max(1_000_000, Math.round((5_000_000 * width * height) / (1920 * 1080)));
69+
}

‎tests/export-coordinator.test.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import type { Canvas } from "@canvas/shotstack-canvas";
44
import type { Edit } from "@core/edit-session";
55
import { ASSET_SETTLE_TIMEOUT_MS, ExportCoordinator } from "@core/export/export-coordinator";
66
import { ExportProgressUI } from "@core/export/export-progress-ui";
7-
import { ExportError } from "@core/export/export-utils";
7+
import { ExportError, exportBitrate } from "@core/export/export-utils";
8+
import { calculateSizeFromPreset } from "@core/output-settings-manager";
89
import { canEncodeVideo, CanvasSink, CanvasSource, Input, Output } from "mediabunny";
910

1011
const mockOutputs: Array<{ cancel: jest.Mock; state: string }> = [];
@@ -63,8 +64,9 @@ function createHarness() {
6364
isPlaying: false,
6465
playbackTime: 1.5,
6566
totalDuration: 1,
67+
size: { ...SIZE },
6668
tracks: [] as Array<Array<{ clipId: string; needsResolution: boolean }>>,
67-
getEdit: () => ({ output: { fps: 2, size: SIZE } }),
69+
getEdit: () => ({ output: { fps: 2 } }),
6870
getOutputFormat: jest.fn(() => "mp4"),
6971
getResolvedEdit: () => ({ timeline: { tracks: [] }, output: { format: "mp4" } }),
7072
getFontUrlByFamilyAndWeight: () => null,
@@ -413,6 +415,35 @@ describe("editor restart", () => {
413415
});
414416
});
415417

418+
describe("output size", () => {
419+
it("draws and encodes at the size the editor lays clips out in, with a bitrate for that size", async () => {
420+
const { exporter, edit, renderer } = createHarness();
421+
// An edit sized by resolution "hd" and aspectRatio "9:16", with no output.size.
422+
edit.size = calculateSizeFromPreset("hd", "9:16");
423+
424+
await exporter.exportBlob();
425+
426+
expect(renderer.extract.pixels).toHaveBeenCalledWith(expect.objectContaining({ frame: { x: 0, y: 0, width: 720, height: 1280 } }));
427+
expect(canEncodeVideo).toHaveBeenCalledWith("avc", { width: 720, height: 1280, bitrate: 2_222_222 });
428+
expect(CanvasSource).toHaveBeenCalledWith(expect.objectContaining({ width: 720, height: 1280 }), { codec: "avc", bitrate: 2_222_222 });
429+
exporter.dispose();
430+
});
431+
});
432+
433+
describe("exportBitrate", () => {
434+
it("keeps 5 Mbps at 1080p in either orientation and scales with pixel count", () => {
435+
expect(exportBitrate({ width: 1920, height: 1080 })).toBe(5_000_000);
436+
expect(exportBitrate({ width: 1080, height: 1920 })).toBe(5_000_000);
437+
expect(exportBitrate({ width: 3840, height: 2160 })).toBe(20_000_000);
438+
expect(exportBitrate({ width: 1280, height: 720 })).toBe(2_222_222);
439+
});
440+
441+
it("never drops below 1 Mbps", () => {
442+
expect(exportBitrate({ width: 640, height: 360 })).toBe(1_000_000);
443+
expect(exportBitrate({ width: 512, height: 288 })).toBe(1_000_000);
444+
});
445+
});
446+
416447
describe("ExportCoordinator.export", () => {
417448
it("wraps a failed download as an export-phase error and removes the overlay", async () => {
418449
const { exporter } = createHarness();

0 commit comments

Comments
 (0)