From 9a314aed7d0c717fcf16c20db97e0a12ea7b1706 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 7 Apr 2026 05:03:43 +0000 Subject: [PATCH] fix(engine): add anti-banding x264/x265 params for dark gradients Add aq-mode=3 (auto-variance adaptive quantization) to CPU H.264/H.265 encoding. This redistributes bits from bright/textured areas to dark flat areas where color banding is most visible in 8-bit yuv420p output. - standard/high presets: aq-mode=3 + aq-strength=0.8 + deblock=1,1 - draft (ultrafast): aq-mode=3 only (deblock too slow for ultrafast) - GPU and VP9 encoders unaffected (have their own AQ implementations) Adds 6 regression tests verifying the params are emitted correctly. Fixes color banding on dark gradients (eval issue #3, prompts 3,5,10,14). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../engine/src/services/chunkEncoder.test.ts | 72 ++++++++++++++++++- packages/engine/src/services/chunkEncoder.ts | 11 ++- .../engine/src/services/streamingEncoder.ts | 9 +++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 28c714200..dd9c2ecc0 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { ENCODER_PRESETS, getEncoderPreset } from "./chunkEncoder.js"; +import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js"; describe("ENCODER_PRESETS", () => { it("has draft, standard, and high presets", () => { @@ -62,3 +62,73 @@ describe("getEncoderPreset", () => { expect(preset.pixelFormat).toBe("yuv420p"); }); }); + +describe("buildEncoderArgs anti-banding", () => { + const baseOptions = { fps: 30, width: 1920, height: 1080 }; + + it("adds aq-mode=3 x264-params for h264 CPU encoding", () => { + const args = buildEncoderArgs( + { ...baseOptions, codec: "h264", preset: "medium", quality: 23 }, + ["-framerate", "30", "-i", "frames/%04d.png"], + "out.mp4", + ); + const paramIdx = args.indexOf("-x264-params"); + expect(paramIdx).toBeGreaterThan(-1); + expect(args[paramIdx + 1]).toContain("aq-mode=3"); + }); + + it("adds aq-mode=3 x265-params for h265 CPU encoding", () => { + const args = buildEncoderArgs( + { ...baseOptions, codec: "h265", preset: "medium", quality: 23 }, + ["-framerate", "30", "-i", "frames/%04d.png"], + "out.mp4", + ); + const paramIdx = args.indexOf("-x265-params"); + expect(paramIdx).toBeGreaterThan(-1); + expect(args[paramIdx + 1]).toContain("aq-mode=3"); + }); + + it("includes deblock for non-ultrafast presets", () => { + for (const preset of ["medium", "slow"]) { + const args = buildEncoderArgs( + { ...baseOptions, codec: "h264", preset, quality: 23 }, + ["-framerate", "30", "-i", "frames/%04d.png"], + "out.mp4", + ); + const paramIdx = args.indexOf("-x264-params"); + expect(args[paramIdx + 1]).toContain("deblock=1,1"); + } + }); + + it("omits deblock for ultrafast (draft) preset", () => { + const args = buildEncoderArgs( + { ...baseOptions, codec: "h264", preset: "ultrafast", quality: 28 }, + ["-framerate", "30", "-i", "frames/%04d.png"], + "out.mp4", + ); + const paramIdx = args.indexOf("-x264-params"); + expect(paramIdx).toBeGreaterThan(-1); + expect(args[paramIdx + 1]).toBe("aq-mode=3"); + expect(args[paramIdx + 1]).not.toContain("deblock"); + }); + + it("does not add x264-params for GPU encoding", () => { + const args = buildEncoderArgs( + { ...baseOptions, codec: "h264", preset: "medium", quality: 23, useGpu: true }, + ["-framerate", "30", "-i", "frames/%04d.png"], + "out.mp4", + "nvenc", + ); + expect(args.indexOf("-x264-params")).toBe(-1); + }); + + it("does not add x264-params for VP9 encoding", () => { + const args = buildEncoderArgs( + { ...baseOptions, codec: "vp9", preset: "good", quality: 23 }, + ["-framerate", "30", "-i", "frames/%04d.png"], + "out.webm", + ); + expect(args.indexOf("-x264-params")).toBe(-1); + expect(args.indexOf("-x265-params")).toBe(-1); + }); +}); diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index 34286efd7..881a2cf9e 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -44,7 +44,7 @@ export function getEncoderPreset( // Re-export GPU utilities so existing consumers that import from chunkEncoder still work. export { detectGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js"; -function buildEncoderArgs( +export function buildEncoderArgs( options: EncoderOptions, inputArgs: string[], outputPath: string, @@ -99,6 +99,15 @@ function buildEncoderArgs( args.push("-c:v", encoderName, "-preset", preset); 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. + const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params"; + if (preset === "ultrafast") { + args.push(xParamsFlag, "aq-mode=3"); + } else { + args.push(xParamsFlag, "aq-mode=3:aq-strength=0.8:deblock=1,1"); + } } } else if (codec === "vp9") { args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality)); diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index 7a99fa115..26d8fb091 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -168,6 +168,15 @@ function buildStreamingArgs( args.push("-c:v", encoderName, "-preset", preset); 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. + const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params"; + if (preset === "ultrafast") { + args.push(xParamsFlag, "aq-mode=3"); + } else { + args.push(xParamsFlag, "aq-mode=3:aq-strength=0.8:deblock=1,1"); + } } } else if (codec === "vp9") { args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality));