From 49fb397c9814d09131a0c28ed2e511edc63e7a62 Mon Sep 17 00:00:00 2001 From: young Date: Tue, 25 Aug 2026 19:06:02 +1000 Subject: [PATCH] Preserve the correct colour range in video exports --- electron/ipc/nativeVideoExport.test.ts | 33 ++++++++++++++++++++++++ electron/ipc/nativeVideoExport.ts | 25 ++++++++++++++++-- src/lib/exporter/modernVideoExporter.ts | 18 +++++-------- src/lib/exporter/videoColorSpace.test.ts | 13 ++++++++++ src/lib/exporter/videoColorSpace.ts | 18 +++++++++++++ src/lib/exporter/videoExporter.ts | 25 +++++------------- 6 files changed, 99 insertions(+), 33 deletions(-) create mode 100644 src/lib/exporter/videoColorSpace.test.ts create mode 100644 src/lib/exporter/videoColorSpace.ts diff --git a/electron/ipc/nativeVideoExport.test.ts b/electron/ipc/nativeVideoExport.test.ts index 5992cadf..957450ca 100644 --- a/electron/ipc/nativeVideoExport.test.ts +++ b/electron/ipc/nativeVideoExport.test.ts @@ -8,6 +8,7 @@ import { buildNativePrecompositedStaticLayoutArgs, buildNativeStaticBackgroundRenderArgs, buildNativeStaticLayoutChunks, + buildNativeVideoExportArgs, buildTrimmedSourceAudioFilter, createNativeSquircleMaskPgmBuffer, isNativeCudaOutOfMemory, @@ -158,6 +159,38 @@ describe("native static layout command builders", () => { expect(args).toContain("p1"); expect(args).not.toContain("yuv420p"); expect(args).toEqual(expect.arrayContaining(["-ss", "120.000", "-t", "60.000"])); + expect(args).toEqual( + expect.arrayContaining(["-colorspace", "bt709", "-color_range", "tv"]), + ); + }); + + it("converts full-range canvas pixels and tags native H.264 as BT.709 video range", () => { + const args = buildNativeVideoExportArgs( + "h264_videotoolbox", + { + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 30_000_000, + encodingMode: "quality", + }, + "out.mp4", + ); + + expect(args).toEqual( + expect.arrayContaining([ + "-vf", + "vflip,scale=in_range=full:out_range=tv", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", + ]), + ); }); it("builds the stable CUDA scale plus CPU pad fallback command", () => { diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts index 07aa64a9..f15e35d0 100644 --- a/electron/ipc/nativeVideoExport.ts +++ b/electron/ipc/nativeVideoExport.ts @@ -9,6 +9,17 @@ const NATIVE_EXPORT_INPUT_BYTES_PER_PIXEL = 4; const MIN_EDITED_TRACK_TEMPO_SPEED = 0.5; const MAX_EDITED_TRACK_TEMPO_SPEED = 2; +export const FFMPEG_BT709_VIDEO_COLOR_ARGS = [ + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", +] as const; + export type NativeExportEncodingMode = "fast" | "balanced" | "quality"; export type NativeVideoExportAudioMode = "none" | "copy-source" | "trim-source" | "edited-track"; @@ -296,7 +307,7 @@ export function buildNativeVideoExportArgs( "-i", "pipe:0", "-vf", - "vflip", + "vflip,scale=in_range=full:out_range=tv", "-an", "-c:v", encoder, @@ -309,7 +320,14 @@ export function buildNativeVideoExportArgs( args.push(...getLibx264ModeArgs(options.encodingMode)); } - args.push("-pix_fmt", "yuv420p", "-movflags", "+faststart", outputPath); + args.push( + "-pix_fmt", + "yuv420p", + ...FFMPEG_BT709_VIDEO_COLOR_ARGS, + "-movflags", + "+faststart", + outputPath, + ); return args; } @@ -338,6 +356,7 @@ export function buildNativeCudaOverlayStaticLayoutArgs( "h264_nvenc", ...getNvencStaticLayoutModeArgs(config.encodingMode), ...getBitrateArgs(config.bitrate), + ...FFMPEG_BT709_VIDEO_COLOR_ARGS, "-movflags", "+faststart", config.outputPath, @@ -371,6 +390,7 @@ export function buildNativeCudaScaleCpuPadStaticLayoutArgs( ...getBitrateArgs(config.bitrate), "-pix_fmt", "yuv420p", + ...FFMPEG_BT709_VIDEO_COLOR_ARGS, "-movflags", "+faststart", config.outputPath, @@ -533,6 +553,7 @@ export function buildNativePrecompositedStaticLayoutArgs( ...getBitrateArgs(config.bitrate), "-pix_fmt", "yuv420p", + ...FFMPEG_BT709_VIDEO_COLOR_ARGS, "-movflags", "+faststart", config.outputPath, diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index a5e4e8fe..2f889872 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -93,6 +93,7 @@ import type { ExportRenderBackend, ExportResult, } from "./types"; +import { ENCODED_H264_COLOR_SPACE_FALLBACK, EXPORT_CANVAS_COLOR_SPACE } from "./videoColorSpace"; interface VideoExporterConfig extends ExportConfig { videoUrl: string; @@ -2726,9 +2727,11 @@ export class ModernVideoExporter { if (this.nativeEncoderError) throw this.nativeEncoderError; } const canvas = this.renderer!.getCanvas(); + // @ts-expect-error - colorSpace is supported at runtime but missing from this DOM typing. const frame = new VideoFrame(canvas, { timestamp, duration: frameDuration, + colorSpace: EXPORT_CANVAS_COLOR_SPACE, }); this.nativeH264Encoder.encode(frame, { keyFrame: frameIndex % 300 === 0 }); frame.close(); @@ -2957,12 +2960,7 @@ export class ModernVideoExporter { const exportFrame = new VideoFrame(canvas, { timestamp, duration: frameDuration, - colorSpace: { - primaries: "bt709", - transfer: "iec61966-2-1", - matrix: "rgb", - fullRange: true, - }, + colorSpace: EXPORT_CANVAS_COLOR_SPACE, }); while ( @@ -3377,12 +3375,8 @@ export class ModernVideoExporter { try { if (isFirstChunk && this.videoDescription) { // Add decoder config for the first chunk - const colorSpace = this.videoColorSpace || { - primaries: "bt709", - transfer: "iec61966-2-1", - matrix: "rgb", - fullRange: true, - }; + const colorSpace = + this.videoColorSpace || ENCODED_H264_COLOR_SPACE_FALLBACK; const metadata: EncodedVideoChunkMetadata = { decoderConfig: { diff --git a/src/lib/exporter/videoColorSpace.test.ts b/src/lib/exporter/videoColorSpace.test.ts new file mode 100644 index 00000000..a19af92b --- /dev/null +++ b/src/lib/exporter/videoColorSpace.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { ENCODED_H264_COLOR_SPACE_FALLBACK, EXPORT_CANVAS_COLOR_SPACE } from "./videoColorSpace"; + +describe("export colour metadata", () => { + it("does not confuse full-range RGB input with encoded YUV output", () => { + expect(EXPORT_CANVAS_COLOR_SPACE).toMatchObject({ matrix: "rgb", fullRange: true }); + expect(ENCODED_H264_COLOR_SPACE_FALLBACK).toMatchObject({ + matrix: "bt709", + transfer: "bt709", + fullRange: false, + }); + }); +}); diff --git a/src/lib/exporter/videoColorSpace.ts b/src/lib/exporter/videoColorSpace.ts new file mode 100644 index 00000000..69df56bc --- /dev/null +++ b/src/lib/exporter/videoColorSpace.ts @@ -0,0 +1,18 @@ +/** The renderer composites into an sRGB canvas, whose pixels are full-range RGB. */ +export const EXPORT_CANVAS_COLOR_SPACE = { + primaries: "bt709", + transfer: "iec61966-2-1", + matrix: "rgb", + fullRange: true, +} as const satisfies VideoColorSpaceInit; + +/** + * H.264 encoders normally convert the canvas to video-range YUV. Use this only + * when the encoder does not report its own output colour metadata. + */ +export const ENCODED_H264_COLOR_SPACE_FALLBACK = { + primaries: "bt709", + transfer: "bt709", + matrix: "bt709", + fullRange: false, +} as const satisfies VideoColorSpaceInit; diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index b99f5f96..11ea3b1e 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -8,8 +8,8 @@ import type { CursorStyle, CursorTelemetryPoint, Padding, - SpeedRegion, SourceAudioTrackSettings, + SpeedRegion, TrimRegion, WebcamOverlaySettings, ZoomMotionBlurTuning, @@ -38,6 +38,7 @@ import type { ExportProgress, ExportResult, } from "./types"; +import { ENCODED_H264_COLOR_SPACE_FALLBACK, EXPORT_CANVAS_COLOR_SPACE } from "./videoColorSpace"; const DEFAULT_MAX_ENCODE_QUEUE = 240; const PROGRESS_SAMPLE_WINDOW_MS = 1_000; @@ -825,12 +826,7 @@ export class VideoExporter { const frame = new VideoFrame(canvas, { timestamp, duration: frameDuration, - colorSpace: { - primaries: "bt709", - transfer: "iec61966-2-1", - matrix: "rgb", - fullRange: true, - }, + colorSpace: EXPORT_CANVAS_COLOR_SPACE, }); this.nativeH264Encoder.encode(frame, { keyFrame: frameIndex % 300 === 0 }); frame.close(); @@ -1077,12 +1073,7 @@ export class VideoExporter { const exportFrame = new VideoFrame(canvas, { timestamp, duration: frameDuration, - colorSpace: { - primaries: "bt709", - transfer: "iec61966-2-1", - matrix: "rgb", - fullRange: true, - }, + colorSpace: EXPORT_CANVAS_COLOR_SPACE, }); while ( @@ -1270,12 +1261,8 @@ export class VideoExporter { try { if (isFirstChunk && this.videoDescription) { // Add decoder config for the first chunk - const colorSpace = this.videoColorSpace || { - primaries: "bt709", - transfer: "iec61966-2-1", - matrix: "rgb", - fullRange: true, - }; + const colorSpace = + this.videoColorSpace || ENCODED_H264_COLOR_SPACE_FALLBACK; const metadata: EncodedVideoChunkMetadata = { decoderConfig: {