diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index 523589d6..ea8cd24a 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -138,7 +138,16 @@ export class ExportCoordinator { const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("No 2D context"); - const videoSource = new CanvasSource(canvas, { codec: "avc", bitrate: cfg.bitrate }); + const videoSource = new CanvasSource(canvas, { + codec: "avc", + bitrate: cfg.bitrate, + // Chrome, Safari and Firefox all encode canvas frames as limited range, but Safari reports full range, + // which players honour by showing white as grey. + onEncodedPacket: (_packet, meta) => { + const colorSpace = meta?.decoderConfig?.colorSpace; + if (colorSpace) colorSpace.fullRange = false; + } + }); output.addVideoTrack(videoSource); report(0.15, "Mixing audio..."); diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts index c2e03206..6a135ca1 100644 --- a/tests/export-coordinator.test.ts +++ b/tests/export-coordinator.test.ts @@ -403,6 +403,18 @@ describe("output settings", () => { await expect(exporter.exportBlob()).resolves.toBeInstanceOf(Blob); exporter.dispose(); }); + + it("tags the video as limited range even when the encoder reports full range", async () => { + const { exporter } = createHarness(); + await exporter.exportBlob(); + + const [, config] = jest.mocked(CanvasSource).mock.calls[0]; + const meta = { decoderConfig: { codec: "avc1.42001f", colorSpace: { fullRange: true, matrix: "bt709" as const } } }; + config.onEncodedPacket?.({} as never, meta); + + expect(meta.decoderConfig.colorSpace).toEqual({ fullRange: false, matrix: "bt709" }); + exporter.dispose(); + }); }); describe("changes during export", () => { @@ -649,7 +661,10 @@ describe("output size", () => { 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 }); + expect(CanvasSource).toHaveBeenCalledWith( + expect.objectContaining({ width: 720, height: 1280 }), + expect.objectContaining({ codec: "avc", bitrate: 2_222_222 }) + ); exporter.dispose(); }); });