From 2f42de39e0962ce50aed8d337a5ec44f4e616e2f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 1 Nov 2026 16:17:09 -0500 Subject: [PATCH] fix(cli): remove-background --json streams progress lines on stderr --- .../remove-background.progress.test.ts | 72 +++++++++++++++++++ .../cli/src/commands/remove-background.ts | 16 +++++ 2 files changed, 88 insertions(+) create mode 100644 packages/cli/src/commands/remove-background.progress.test.ts diff --git a/packages/cli/src/commands/remove-background.progress.test.ts b/packages/cli/src/commands/remove-background.progress.test.ts new file mode 100644 index 0000000000..c51f420184 --- /dev/null +++ b/packages/cli/src/commands/remove-background.progress.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RemoveBackgroundProgress } from "../api/removeBackground.js"; + +const events: RemoveBackgroundProgress[] = [ + { kind: "info", message: "Loading the model" }, + { kind: "metadata", width: 1076, height: 1928, fps: 24, frameCount: 100 }, + ...Array.from( + { length: 100 }, + (_, i): RemoveBackgroundProgress => ({ + kind: "frame", + index: i + 1, + total: 100, + avgMsPerFrame: 900, + }), + ), +]; + +vi.mock("../api/removeBackground.js", async (importOriginal) => ({ + ...(await importOriginal()), + removeBackground: vi.fn( + async (options: { onProgress?: (e: RemoveBackgroundProgress) => void }) => { + for (const event of events) options.onProgress?.(event); + return { + outputPath: "/out/cut.webm", + framesProcessed: 100, + durationSeconds: 18, + avgMsPerFrame: 900, + provider: "CPU", + format: "webm", + }; + }, + ), +})); + +async function runJson(): Promise<{ stdout: string[]; stderr: string[] }> { + const stdout: string[] = []; + const stderr: string[] = []; + vi.spyOn(console, "log").mockImplementation((...a) => void stdout.push(a.join(" "))); + vi.spyOn(console, "error").mockImplementation((...a) => void stderr.push(a.join(" "))); + const command = (await import("./remove-background.js")).default as unknown as { + run: (context: { args: Record }) => Promise; + }; + await command.run({ + args: { input: "in.mp4", output: "out.webm", device: "auto", quality: "best", json: true }, + }); + return { stdout, stderr }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("remove-background --json", () => { + it("keeps stdout to the one result line and streams progress lines on stderr", async () => { + const { stdout, stderr } = await runJson(); + expect(stdout).toHaveLength(1); + expect(JSON.parse(stdout[0]!)).toMatchObject({ ok: true, outputPath: "/out/cut.webm" }); + const progress = stderr.map((line) => JSON.parse(line) as Record); + expect(progress.length).toBeGreaterThan(2); + expect(progress.every((line) => line["type"] === "progress")).toBe(true); + expect(progress.some((line) => line["kind"] === "metadata" && line["width"] === 1076)).toBe( + true, + ); + const frames = progress.filter((line) => line["kind"] === "frame"); + expect(frames.at(-1)).toMatchObject({ index: 100, total: 100, avgMsPerFrame: 900 }); + }); + + it("does not write a line for every frame", async () => { + const { stderr } = await runJson(); + const frames = stderr.filter((line) => JSON.parse(line).kind === "frame"); + expect(frames.length).toBeGreaterThan(0); + expect(frames.length).toBeLessThan(30); + }); +}); diff --git a/packages/cli/src/commands/remove-background.ts b/packages/cli/src/commands/remove-background.ts index 82cac9812f..55fa98c6ed 100644 --- a/packages/cli/src/commands/remove-background.ts +++ b/packages/cli/src/commands/remove-background.ts @@ -16,6 +16,8 @@ import { DEFAULT_QUALITY, QUALITIES, isQuality } from "../background-removal/pip import type { Example } from "./_examples.js"; import { RemoveBackgroundError, removeBackground } from "../api/removeBackground.js"; import { failureMessage, jsonFailure } from "../utils/jsonError.js"; +import { diag } from "../ui/diagnostics.js"; +import type { RemoveBackgroundProgress } from "../api/removeBackground.js"; export const examples: Example[] = [ [ @@ -133,6 +135,7 @@ export default defineCommand({ device: args.device, quality: args.quality, onProgress: (event) => { + if (args.json) return streamProgress(event); if (event.kind === "info") { spin?.message(event.message); } else if (event.kind === "metadata") { @@ -188,6 +191,19 @@ export default defineCommand({ }, }); +/** Frame lines in `--json`: about one per 5% of the clip, or every 25 frames when the length is unknown. */ +const UNKNOWN_TOTAL_STEP = 25; + +/** `--json` keeps stdout to the one result line, so progress goes to stderr as one JSON object per line. */ +function streamProgress(event: RemoveBackgroundProgress): void { + if (event.kind === "frame") { + const step = event.total ? Math.ceil(event.total / 20) : UNKNOWN_TOTAL_STEP; + const last = event.total > 0 && event.index === event.total; + if (event.index !== 1 && !last && event.index % step !== 0) return; + } + diag.notice(JSON.stringify({ type: "progress", ...event })); +} + /** The pipeline's own error, which the command reported before the API wrapped it. */ function pipelineError(err: unknown): unknown { return err instanceof RemoveBackgroundError ? err.cause : err;