summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services/renderer')
-rw-r--r--apps/web/src/services/renderer/__tests__/export-encoding.test.ts60
-rw-r--r--apps/web/src/services/renderer/export-encoding.ts22
-rw-r--r--apps/web/src/services/renderer/scene-exporter.ts17
3 files changed, 96 insertions, 3 deletions
diff --git a/apps/web/src/services/renderer/__tests__/export-encoding.test.ts b/apps/web/src/services/renderer/__tests__/export-encoding.test.ts
new file mode 100644
index 00000000..fbf354eb
--- /dev/null
+++ b/apps/web/src/services/renderer/__tests__/export-encoding.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test";
+import { computeVideoBitrate } from "../export-encoding";
+
+describe("computeVideoBitrate", () => {
+ test("scales linearly with frame rate at the same resolution and quality", () => {
+ const at30 = computeVideoBitrate({
+ width: 1920,
+ height: 1080,
+ fps: 30,
+ quality: "high",
+ });
+ const at60 = computeVideoBitrate({
+ width: 1920,
+ height: 1080,
+ fps: 60,
+ quality: "high",
+ });
+
+ expect(at60).toBe(at30 * 2);
+ });
+
+ test("applies the bits-per-pixel ladder per quality tier", () => {
+ const params = { width: 1920, height: 1080, fps: 30 };
+
+ expect(computeVideoBitrate({ ...params, quality: "low" })).toBe(3_110_400);
+ expect(computeVideoBitrate({ ...params, quality: "medium" })).toBe(
+ 6_220_800,
+ );
+ expect(computeVideoBitrate({ ...params, quality: "high" })).toBe(
+ 12_441_600,
+ );
+ expect(computeVideoBitrate({ ...params, quality: "very_high" })).toBe(
+ 24_883_200,
+ );
+ });
+
+ test("increases monotonically across quality tiers", () => {
+ const params = { width: 1280, height: 720, fps: 30 };
+ const bitrates = (["low", "medium", "high", "very_high"] as const).map(
+ (quality) => computeVideoBitrate({ ...params, quality }),
+ );
+
+ for (let i = 1; i < bitrates.length; i++) {
+ expect(bitrates[i]).toBeGreaterThan(bitrates[i - 1]);
+ }
+ });
+
+ test("returns a positive integer for fractional frame rates", () => {
+ const bitrate = computeVideoBitrate({
+ width: 1920,
+ height: 1080,
+ fps: 29.97,
+ quality: "high",
+ });
+
+ expect(bitrate).toBe(12_429_158);
+ expect(Number.isInteger(bitrate)).toBe(true);
+ expect(bitrate).toBeGreaterThan(0);
+ });
+});
diff --git a/apps/web/src/services/renderer/export-encoding.ts b/apps/web/src/services/renderer/export-encoding.ts
new file mode 100644
index 00000000..cc333ea4
--- /dev/null
+++ b/apps/web/src/services/renderer/export-encoding.ts
@@ -0,0 +1,22 @@
+import type { ExportQuality } from "@/export";
+
+const videoBitsPerPixelFrame: Record<ExportQuality, number> = {
+ low: 0.05,
+ medium: 0.1,
+ high: 0.2,
+ very_high: 0.4,
+};
+
+export function computeVideoBitrate({
+ width,
+ height,
+ fps,
+ quality,
+}: {
+ width: number;
+ height: number;
+ fps: number;
+ quality: ExportQuality;
+}): number {
+ return Math.round(width * height * fps * videoBitsPerPixelFrame[quality]);
+}
diff --git a/apps/web/src/services/renderer/scene-exporter.ts b/apps/web/src/services/renderer/scene-exporter.ts
index 66df9485..04c14277 100644
--- a/apps/web/src/services/renderer/scene-exporter.ts
+++ b/apps/web/src/services/renderer/scene-exporter.ts
@@ -19,6 +19,7 @@ import { frameRateToFloat } from "@/fps/utils";
import type { RootNode } from "./nodes/root-node";
import type { ExportFormat, ExportQuality } from "@/export";
import { CanvasRenderer } from "./canvas-renderer";
+import { computeVideoBitrate } from "./export-encoding";
type ExportParams = {
width: number;
@@ -30,13 +31,15 @@ type ExportParams = {
audioBuffer?: AudioBuffer;
};
-const qualityMap = {
+const audioQualityMap = {
low: QUALITY_LOW,
medium: QUALITY_MEDIUM,
high: QUALITY_HIGH,
very_high: QUALITY_VERY_HIGH,
};
+const KEY_FRAME_INTERVAL_SECONDS = 2;
+
export type SceneExporterEvents = {
progress: [progress: number];
complete: [buffer: ArrayBuffer];
@@ -99,9 +102,17 @@ export class SceneExporter extends EventEmitter<SceneExporterEvents> {
target: new BufferTarget(),
});
+ const videoBitrate = computeVideoBitrate({
+ width: this.renderer.width,
+ height: this.renderer.height,
+ fps: fpsFloat,
+ quality: this.quality,
+ });
+
const videoSource = new CanvasSource(this.renderer.getOutputCanvas(), {
codec: this.format === "webm" ? "vp9" : "avc",
- bitrate: qualityMap[this.quality],
+ bitrate: videoBitrate,
+ keyFrameInterval: KEY_FRAME_INTERVAL_SECONDS,
});
output.addVideoTrack(videoSource, { frameRate: fpsFloat });
@@ -122,7 +133,7 @@ export class SceneExporter extends EventEmitter<SceneExporterEvents> {
audioSource = new AudioBufferSource({
codec: audioCodec,
- bitrate: qualityMap[this.quality],
+ bitrate: audioQualityMap[this.quality],
});
output.addAudioTrack(audioSource);
}