diff --git a/packages/cli/src/background-removal/pipeline.ts b/packages/cli/src/background-removal/pipeline.ts index 03ce3cb63..99b95386e 100644 --- a/packages/cli/src/background-removal/pipeline.ts +++ b/packages/cli/src/background-removal/pipeline.ts @@ -20,15 +20,15 @@ import { type Device, type ModelId } from "./manager.js"; export type OutputFormat = "webm" | "mov" | "png"; -export type Quality = "fast" | "balanced" | "best"; - -export const QUALITIES: readonly Quality[] = ["fast", "balanced", "best"] as const; - -export const QUALITY_CRF: Record = { +export const QUALITY_CRF = { fast: 30, balanced: 18, best: 12, -}; +} as const; + +export type Quality = keyof typeof QUALITY_CRF; + +export const QUALITIES = Object.keys(QUALITY_CRF) as readonly Quality[]; export const DEFAULT_QUALITY: Quality = "balanced"; diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index a5c7ffe3e..6e9ea1b2d 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -139,6 +139,25 @@ export function buildEncoderArgs( else args.push("-global_quality", String(quality)); break; } + + // Same B-frame story as the SW branch below — nvenc emits B-frames + // by default (qsv via b_strategy, vaapi too), and the negative-DTS + // freeze hits the same downstream players. The unconditional + // `-avoid_negative_ts make_zero` near the bottom of this function + // covers the mux level, but we belt-and-suspenders the encoder too + // so even tools that consume the chunk file directly (without going + // through our mux step) play correctly. videotoolbox doesn't accept + // `-bf` so it's skipped — videotoolbox h264 also doesn't emit + // negative DTS in practice on macOS Sonoma+. + if ( + codec === "h264" && + (gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi") + ) { + args.push("-bf", "0"); + if (gpuEncoder === "qsv") { + args.push("-b_strategy", "0"); + } + } } else { const encoderName = codec === "h264" ? "libx264" : "libx265"; args.push("-c:v", encoderName, "-preset", preset); diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index a527df830..03c0d0735 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -221,6 +221,19 @@ export function buildStreamingArgs( else args.push("-global_quality", String(quality)); break; } + + // Mirror SW branch: GPU h264 paths emit B-frames by default (nvenc, qsv, + // vaapi) and produce the same negative-DTS freeze for downstream players. + // See chunkEncoder.buildEncoderArgs for the full explanation. + if ( + codec === "h264" && + (gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi") + ) { + args.push("-bf", "0"); + if (gpuEncoder === "qsv") { + args.push("-b_strategy", "0"); + } + } } else { const encoderName = codec === "h264" ? "libx264" : "libx265"; args.push("-c:v", encoderName, "-preset", preset); diff --git a/packages/engine/src/services/videoFrameExtractor.test.ts b/packages/engine/src/services/videoFrameExtractor.test.ts index e83ca1254..29fd98eed 100644 --- a/packages/engine/src/services/videoFrameExtractor.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.test.ts @@ -1,5 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -26,19 +34,11 @@ import { runFfmpeg } from "../utils/runFfmpeg.js"; // synthesized VFR fixture. const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0; -// Regression: a long-standing footgun where `