Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 101 additions & 1 deletion packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe("buildEncoderArgs anti-banding", () => {
);
const paramIdx = args.indexOf("-x264-params");
expect(paramIdx).toBeGreaterThan(-1);
expect(args[paramIdx + 1]).toBe("aq-mode=3");
expect(args[paramIdx + 1]).toContain("aq-mode=3");
expect(args[paramIdx + 1]).not.toContain("deblock");
});

Expand All @@ -132,3 +132,103 @@ describe("buildEncoderArgs anti-banding", () => {
expect(args.indexOf("-x265-params")).toBe(-1);
});
});

describe("buildEncoderArgs color space", () => {
const baseOptions = { fps: 30, width: 1920, height: 1080 };
const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];

it("adds bt709 color space metadata for h264 CPU encoding", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
inputArgs,
"out.mp4",
);
// FFmpeg-level metadata tags
expect(args).toContain("-colorspace:v");
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
// x264-params VUI embedding
const paramIdx = args.indexOf("-x264-params");
expect(args[paramIdx + 1]).toContain("colorprim=bt709");
expect(args[paramIdx + 1]).toContain("transfer=bt709");
expect(args[paramIdx + 1]).toContain("colormatrix=bt709");
});

it("adds bt709 color space metadata for h265 CPU encoding", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
inputArgs,
"out.mp4",
);
expect(args).toContain("-colorspace:v");
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
// x265-params VUI embedding
const paramIdx = args.indexOf("-x265-params");
expect(args[paramIdx + 1]).toContain("colorprim=bt709");
});

it("adds range conversion filter for CPU h264 encoding", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
inputArgs,
"out.mp4",
);
const vfIdx = args.indexOf("-vf");
expect(vfIdx).toBeGreaterThan(-1);
expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
});

it("prepends range conversion to VAAPI filter chain", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23, useGpu: true },
inputArgs,
"out.mp4",
"vaapi",
);
const vfIdx = args.indexOf("-vf");
expect(vfIdx).toBeGreaterThan(-1);
expect(args[vfIdx + 1]).toBe("scale=in_range=pc:out_range=tv,format=nv12,hwupload");
});

it("skips range conversion filter for non-VAAPI GPU encoding", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23, useGpu: true },
inputArgs,
"out.mp4",
"nvenc",
);
expect(args.indexOf("-vf")).toBe(-1);
// but still has color metadata
expect(args).toContain("-colorspace:v");
});

it("does not add color metadata for VP9", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "vp9", preset: "good", quality: 23 },
inputArgs,
"out.webm",
);
expect(args).not.toContain("-colorspace:v");
});

it("adds video_track_timescale for h264", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
inputArgs,
"out.mp4",
);
expect(args).toContain("-video_track_timescale");
expect(args[args.indexOf("-video_track_timescale") + 1]).toBe("90000");
});

it("does not add timescale for VP9", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "vp9", preset: "good", quality: 23 },
inputArgs,
"out.webm",
);
expect(args).not.toContain("-video_track_timescale");
});
});
40 changes: 36 additions & 4 deletions packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,15 @@ export function buildEncoderArgs(
if (bitrate) args.push("-b:v", bitrate);
else args.push("-crf", String(quality));

// Anti-banding: aq-mode=3 redistributes bits to dark flat areas (gradients),
// deblock smooths quantization boundaries that cause visible bands.
// Encoder-specific params: anti-banding + bt709 color space.
// aq-mode=3 redistributes bits to dark flat areas (gradients).
// colorprim/transfer/colormatrix embed bt709 in the H.264/H.265 VUI.
const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
const colorParams = "colorprim=bt709:transfer=bt709:colormatrix=bt709";
if (preset === "ultrafast") {
args.push(xParamsFlag, "aq-mode=3");
args.push(xParamsFlag, `aq-mode=3:${colorParams}`);
} else {
args.push(xParamsFlag, "aq-mode=3:aq-strength=0.8:deblock=1,1");
args.push(xParamsFlag, `aq-mode=3:aq-strength=0.8:deblock=1,1:${colorParams}`);
}
}
} else if (codec === "vp9") {
Expand All @@ -122,6 +124,36 @@ export function buildEncoderArgs(
return [...args, "-y", outputPath];
}

// BT.709 color space metadata — Chrome screenshots are sRGB which maps to bt709.
// Tags the output so players interpret colors correctly across devices.
if (codec === "h264" || codec === "h265") {
args.push(
"-colorspace:v",
"bt709",
"-color_primaries:v",
"bt709",
"-color_trc:v",
"bt709",
"-color_range",
"tv",
);

// Convert full-range RGB input (Chrome screenshots) to limited/TV range for H.264.
// VAAPI already has a -vf chain for hwupload; prepend range conversion to it.
if (gpuEncoder === "vaapi") {
// Replace the existing VAAPI -vf with one that includes range conversion
const vfIdx = args.indexOf("-vf");
if (vfIdx !== -1) {
args[vfIdx + 1] = `scale=in_range=pc:out_range=tv,${args[vfIdx + 1]}`;
}
} else if (!shouldUseGpu) {
args.push("-vf", "scale=in_range=pc:out_range=tv");
}

// Fixed timescale for consistent A/V timing across platforms.
args.push("-video_track_timescale", "90000");
}

if (gpuEncoder !== "vaapi") {
args.push("-pix_fmt", pixelFormat);
}
Expand Down
38 changes: 34 additions & 4 deletions packages/engine/src/services/streamingEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,15 @@ function buildStreamingArgs(
if (bitrate) args.push("-b:v", bitrate);
else args.push("-crf", String(quality));

// Anti-banding: aq-mode=3 redistributes bits to dark flat areas (gradients),
// deblock smooths quantization boundaries that cause visible bands.
// Encoder-specific params: anti-banding + bt709 color space.
// aq-mode=3 redistributes bits to dark flat areas (gradients).
// colorprim/transfer/colormatrix embed bt709 in the H.264/H.265 VUI.
const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
const colorParams = "colorprim=bt709:transfer=bt709:colormatrix=bt709";
if (preset === "ultrafast") {
args.push(xParamsFlag, "aq-mode=3");
args.push(xParamsFlag, `aq-mode=3:${colorParams}`);
} else {
args.push(xParamsFlag, "aq-mode=3:aq-strength=0.8:deblock=1,1");
args.push(xParamsFlag, `aq-mode=3:aq-strength=0.8:deblock=1,1:${colorParams}`);
}
}
} else if (codec === "vp9") {
Expand All @@ -191,6 +193,34 @@ function buildStreamingArgs(
return [...args, "-y", outputPath];
}

// BT.709 color space metadata — Chrome screenshots are sRGB which maps to bt709.
// Tags the output so players interpret colors correctly across devices.
if (codec === "h264" || codec === "h265") {
args.push(
"-colorspace:v",
"bt709",
"-color_primaries:v",
"bt709",
"-color_trc:v",
"bt709",
"-color_range",
"tv",
);

// Convert full-range RGB input (Chrome screenshots) to limited/TV range for H.264.
if (gpuEncoder === "vaapi") {
const vfIdx = args.indexOf("-vf");
if (vfIdx !== -1) {
args[vfIdx + 1] = `scale=in_range=pc:out_range=tv,${args[vfIdx + 1]}`;
}
} else if (!shouldUseGpu) {
args.push("-vf", "scale=in_range=pc:out_range=tv");
}

// Fixed timescale for consistent A/V timing across platforms.
args.push("-video_track_timescale", "90000");
}

if (gpuEncoder !== "vaapi") {
args.push("-pix_fmt", pixelFormat);
}
Expand Down
Loading