Files
hyperframes/packages/cli/src/commands/remove-background.ts
T
JamesandClaude Opus 4.7 688052d368 fix(cli): correct sharp 3-channel mask + BT.709 + quality presets in remove-background
- inference.ts: force `.toColourspace("b-w")` on the resized mask. Sharp upcasts
  the 1-channel raw input to RGB-interleaved during resize, so `fullMask[i]`
  was reading R,G,B,R,G,B... of pixels 0..691199 instead of the alpha for all
  2,073,600 pixels. Visible symptom: horizontal scanline alpha artifact in
  every transparent webm — the avatar appeared semi-transparent throughout.
- pipeline.ts: add BT.709 + limited-range colorspace tags so Chrome's YUV→RGB
  matches the source mp4 (without these, ffmpeg's default RGB→YUV is BT.601
  and skin tones drift visibly when the cutout is overlaid on its source).
- pipeline.ts: add Quality preset type ("fast"/"balanced"/"best" → CRF 30/18/12).
  Default raised from CRF 30 → 18 ("balanced") so the most common pattern
  (text-behind-subject) works out of the box without visible doubling.
- remove-background.ts: wire `--quality` flag with validation, +2 examples.
- Tests: BT.709 tags present, quality preset → CRF mapping, default is balanced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:27:56 -07:00

203 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineCommand } from "citty";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { isDevice, DEVICES } from "../background-removal/manager.js";
import { DEFAULT_QUALITY, QUALITIES, isQuality } from "../background-removal/pipeline.js";
import type { Example } from "./_examples.js";
export const examples: Example[] = [
[
"Remove background from a video, output transparent VP9 WebM (default)",
"hyperframes remove-background avatar.mp4 -o transparent.webm",
],
[
"Output ProRes 4444 .mov for editing round-trip",
"hyperframes remove-background avatar.mp4 -o transparent.mov",
],
[
"Remove background from a single image, output transparent PNG",
"hyperframes remove-background portrait.jpg -o cutout.png",
],
[
"Force CPU (skip CoreML/CUDA)",
"hyperframes remove-background avatar.mp4 -o transparent.webm --device cpu",
],
[
"Smaller file at the cost of color match (text-behind-subject won't blend as cleanly)",
"hyperframes remove-background avatar.mp4 -o transparent.webm --quality fast",
],
[
"Visually-lossless WebM (master / re-encode source)",
"hyperframes remove-background avatar.mp4 -o transparent.webm --quality best",
],
["Show detected providers without rendering", "hyperframes remove-background --info"],
];
export default defineCommand({
meta: {
name: "remove-background",
description:
"Remove background from a video or image using a local AI model — outputs transparent WebM, ProRes 4444, or PNG",
},
args: {
input: {
type: "positional",
description: "Source video (.mp4/.mov/.webm/.mkv) or image (.jpg/.png/.webp)",
required: false,
},
output: {
type: "string",
description: "Output path. Format inferred from extension: .webm (default), .mov, .png",
alias: "o",
},
device: {
type: "string",
description: `Execution provider: ${DEVICES.join(", ")}`,
default: "auto",
},
quality: {
type: "string",
description: `Encoder quality preset for .webm output: ${QUALITIES.join(", ")} (default: ${DEFAULT_QUALITY}). Higher quality = closer color match when overlaying on the source mp4, larger file. Ignored for .mov / .png.`,
default: DEFAULT_QUALITY,
},
info: {
type: "boolean",
description: "Print detected execution providers and exit (no render)",
default: false,
},
json: {
type: "boolean",
description: "Output result as JSON",
default: false,
},
},
async run({ args }) {
if (args.info) {
return showInfo(args.json);
}
if (!args.input) {
console.error(
c.error(
"Input file is required. Run `hyperframes remove-background --info` for providers.",
),
);
process.exit(1);
}
if (!args.output) {
console.error(c.error("--output (-o) is required. Use a .webm, .mov, or .png path."));
process.exit(1);
}
if (!isDevice(args.device)) {
console.error(
c.error(`Invalid --device '${String(args.device)}'. Use: ${DEVICES.join(", ")}.`),
);
process.exit(1);
}
if (!isQuality(args.quality)) {
console.error(
c.error(`Invalid --quality '${String(args.quality)}'. Use: ${QUALITIES.join(", ")}.`),
);
process.exit(1);
}
const inputPath = resolve(args.input);
const outputPath = resolve(args.output);
const { render } = await import("../background-removal/pipeline.js");
const spin = args.json ? null : clack.spinner();
spin?.start("Preparing background-removal pipeline...");
try {
const result = await render({
inputPath,
outputPath,
device: args.device,
quality: args.quality,
onProgress: (event) => {
if (event.kind === "info") {
spin?.message(event.message);
} else if (event.kind === "metadata") {
const dims = `${event.width}×${event.height}`;
const frames = event.frameCount ? ` · ${event.frameCount} frames` : "";
spin?.message(`Source ${dims} @ ${event.fps.toFixed(0)}fps${frames}`);
} else if (event.kind === "frame") {
const pct = event.total ? ` (${Math.floor((100 * event.index) / event.total)}%)` : "";
spin?.message(
`Frame ${event.index}${event.total ? `/${event.total}` : ""}${pct}${Math.round(event.avgMsPerFrame)}ms/frame avg`,
);
}
},
});
if (args.json) {
console.log(
JSON.stringify({
ok: true,
outputPath: result.outputPath,
framesProcessed: result.framesProcessed,
durationSeconds: Number(result.durationSeconds.toFixed(2)),
avgMsPerFrame: Number(result.avgMsPerFrame.toFixed(1)),
provider: result.provider,
format: result.format,
}),
);
} else {
const fpsThroughput = result.durationSeconds
? (result.framesProcessed / result.durationSeconds).toFixed(1)
: "n/a";
spin?.stop(
c.success(
`Removed background from ${c.accent(String(result.framesProcessed))} frames in ${result.durationSeconds.toFixed(1)}s (${fpsThroughput} fps, ${c.accent(result.provider)}) → ${c.accent(result.outputPath)}`,
),
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (args.json) {
console.log(JSON.stringify({ ok: false, error: message }));
} else {
spin?.stop(c.error(`Background removal failed: ${message}`));
}
process.exit(1);
}
},
});
async function showInfo(json: boolean): Promise<void> {
const { selectProviders, listAvailableProviders, DEFAULT_MODEL, MODEL_MEMORY_MB, modelPath } =
await import("../background-removal/manager.js");
const providers = listAvailableProviders();
const auto = selectProviders("auto");
const cached = existsSync(modelPath());
if (json) {
console.log(
JSON.stringify({
defaultModel: DEFAULT_MODEL,
modelCached: cached,
modelPath: modelPath(),
peakMemoryMb: MODEL_MEMORY_MB[DEFAULT_MODEL],
availableProviders: providers,
autoProvider: auto.label,
}),
);
return;
}
console.log(c.bold("hyperframes remove-background — system info"));
console.log("");
console.log(` ${c.dim("Default model:")} ${c.accent(DEFAULT_MODEL)}`);
console.log(` ${c.dim("Peak memory:")} ~${MODEL_MEMORY_MB[DEFAULT_MODEL]} MB`);
console.log(
` ${c.dim("Weights cached:")} ${cached ? c.success("yes") : c.dim("no (will download on first run)")}`,
);
console.log(` ${c.dim("Cache path:")} ${modelPath()}`);
console.log("");
console.log(` ${c.dim("Available providers:")} ${providers.join(", ")}`);
console.log(` ${c.dim("Auto-selected:")} ${c.accent(auto.label)}`);
}