diff options
Diffstat (limited to 'apps/web/src/services/renderer')
| -rw-r--r-- | apps/web/src/services/renderer/__tests__/export-encoding.test.ts | 60 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/export-encoding.ts | 22 | ||||
| -rw-r--r-- | apps/web/src/services/renderer/scene-exporter.ts | 17 |
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); } |
