From f5e6a40e3e361395307c3e1010b003fbd22df49a Mon Sep 17 00:00:00 2001 From: Jonas Rohde Date: Thu, 14 May 2026 15:04:00 +0200 Subject: [PATCH] feat: add prepare image cli --- docs/plans/v1-prepare-image-cli.md | 20 +++ package.json | 3 +- src/cli/prepare_image.ts | 80 +++++++++ src/domain/prepare_image.ts | 154 ++++++++++++++++++ tests/helpers/cli.ts | 8 +- tests/integration/cli.integration.test.ts | 1 + .../prepare_image.integration.test.ts | 36 ++++ 7 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 docs/plans/v1-prepare-image-cli.md create mode 100644 src/cli/prepare_image.ts create mode 100644 src/domain/prepare_image.ts create mode 100644 tests/integration/prepare_image.integration.test.ts diff --git a/docs/plans/v1-prepare-image-cli.md b/docs/plans/v1-prepare-image-cli.md new file mode 100644 index 0000000..c09641b --- /dev/null +++ b/docs/plans/v1-prepare-image-cli.md @@ -0,0 +1,20 @@ +# V1 Prepare Image CLI + +## Context + +The second implementation slice makes the v1 product runnable without deleting the existing lab commands yet. +It builds on the pure layout and output path branch. + +## Decisions + +- Added `prepare-image` as a Bun script and CLI entrypoint. +- Kept parsing command-local and limited to ``, `--out`, `--border-px`, and `--help`. +- Added `prepareImage` as the domain entrypoint for probing, layout, FFmpeg export, metadata stripping, and final path reporting. +- Used `ffprobe` for source dimensions so TIFF and later EXIF-orientation cases can be handled through the media tool boundary. +- Used one FFmpeg filter graph with a white background and overlay so transparent inputs can composite onto white. + +## Validation + +- `bun test tests/integration/prepare_image.integration.test.ts` +- `bunx tsc --noEmit` +- `biome lint src/cli/prepare_image.ts src/domain/prepare_image.ts tests/integration/prepare_image.integration.test.ts tests/helpers/cli.ts package.json` diff --git a/package.json b/package.json index 92000f1..5dec52a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "report": "bun run src/cli/report.ts", "report-export": "bun run src/cli/report_export.ts", "benchmark": "bun run src/cli/benchmark.ts", + "prepare-image": "bun run src/cli/prepare_image.ts", "export-image": "bun run src/cli/export_image.ts", "export-video": "bun run src/cli/export_video.ts", "doctor": "bun run src/cli/doctor.ts", @@ -22,7 +23,7 @@ "check": "bun run typecheck && bun run lint && bun run test:fast", "test": "bun test --timeout 30000", "test:unit": "bun test tests/*.test.ts tests/helpers/*.test.ts", - "test:fast": "bun run test:unit && bun test --timeout 30000 tests/integration/cli.integration.test.ts tests/integration/overlay.integration.test.ts tests/integration/grid_preview.integration.test.ts tests/integration/watch_folder.integration.test.ts", + "test:fast": "bun run test:unit && bun test --timeout 30000 tests/integration/cli.integration.test.ts tests/integration/prepare_image.integration.test.ts tests/integration/overlay.integration.test.ts tests/integration/grid_preview.integration.test.ts tests/integration/watch_folder.integration.test.ts", "test:ci": "bun run typecheck && bun run lint && bun run doctor && bun run test:fast", "test:slow": "bun test --timeout 30000 tests/integration/analyze.integration.test.ts tests/integration/export.integration.test.ts tests/integration/export_video.integration.test.ts tests/integration/report.integration.test.ts tests/integration/report_export.integration.test.ts tests/integration/benchmark.integration.test.ts tests/integration/validate_matrix.integration.test.ts tests/e2e tests/visual tests/property", "test:integration": "bun test --timeout 30000 tests/integration", diff --git a/src/cli/prepare_image.ts b/src/cli/prepare_image.ts new file mode 100644 index 0000000..29aea70 --- /dev/null +++ b/src/cli/prepare_image.ts @@ -0,0 +1,80 @@ +import { DEFAULT_PREPARE_IMAGE_BORDER_PX, prepareImage } from "../domain/prepare_image"; + +const USAGE = "Usage: bun run prepare-image --out [--border-px ]"; + +type ParsedArgs = { + inputPath: string; + outputPath: string; + borderPx: number; +}; + +function parseArgs(args: string[]): ParsedArgs | "help" { + if (args.length === 1 && args[0] === "--help") { + return "help"; + } + + const inputPath = args[0]; + if (!inputPath || inputPath.startsWith("--")) { + throw new Error(USAGE); + } + + let outputPath: string | undefined; + let borderPx = DEFAULT_PREPARE_IMAGE_BORDER_PX; + + for (let index = 1; index < args.length; index += 1) { + const flag = args[index]; + + if (flag === "--out") { + outputPath = readFlagValue(args, index, "--out"); + index += 1; + continue; + } + + if (flag === "--border-px") { + const value = readFlagValue(args, index, "--border-px"); + borderPx = parseBorderPx(value); + index += 1; + continue; + } + + throw new Error(`Unknown option: ${flag}`); + } + + if (!outputPath) { + throw new Error("Missing required --out "); + } + + return { borderPx, inputPath, outputPath }; +} + +function readFlagValue(args: string[], index: number, flag: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for ${flag}`); + } + + return value; +} + +function parseBorderPx(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!/^\d+$/.test(value) || !Number.isInteger(parsed)) { + throw new Error("Invalid --border-px value"); + } + + return parsed; +} + +try { + const parsed = parseArgs(Bun.argv.slice(2)); + if (parsed === "help") { + console.log(USAGE); + process.exit(0); + } + + const result = prepareImage(parsed); + console.log(result.outputPath); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/src/domain/prepare_image.ts b/src/domain/prepare_image.ts new file mode 100644 index 0000000..d02a482 --- /dev/null +++ b/src/domain/prepare_image.ts @@ -0,0 +1,154 @@ +import { resolve } from "node:path"; +import { computePrepareImageLayout, type PrepareImageLayout } from "./prepare_image_layout"; +import { runFfmpeg, runFfprobe } from "./media_process"; +import { resolvePrepareImageOutputPath } from "./output_path"; + +export const DEFAULT_PREPARE_IMAGE_BORDER_PX = 165; + +type PrepareImageInput = { + inputPath: string; + outputPath: string; + borderPx?: number; +}; + +type PrepareImageOutput = { + outputPath: string; + layout: PrepareImageLayout; +}; + +type SourceDimensions = { + width: number; + height: number; +}; + +export function prepareImage(input: PrepareImageInput): PrepareImageOutput { + const sourcePath = resolve(input.inputPath); + const outputPath = resolvePrepareImageOutputPath(resolve(input.outputPath)); + const source = inspectSourceDimensions(sourcePath); + const layout = computePrepareImageLayout({ + borderPx: input.borderPx ?? DEFAULT_PREPARE_IMAGE_BORDER_PX, + sourceHeight: source.height, + sourceWidth: source.width, + }); + + const proc = runFfmpeg([ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + sourcePath, + "-filter_complex", + buildPrepareImageFilter(layout), + "-map", + "[out]", + "-frames:v", + "1", + "-q:v", + "1", + "-pix_fmt", + "yuvj420p", + "-map_metadata", + "-1", + outputPath, + ]); + + if (proc.exitCode !== 0) { + throw new Error(`ffmpeg export failed: ${proc.stderr.trim()}`); + } + + return { layout, outputPath }; +} + +function inspectSourceDimensions(path: string): SourceDimensions { + const proc = runFfprobe([ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height:stream_tags=rotate:stream_side_data=rotation", + "-of", + "json", + path, + ]); + + if (proc.exitCode !== 0) { + throw new Error(`ffprobe failed: ${proc.stderr.trim()}`); + } + + const payload = JSON.parse(proc.stdout) as unknown; + const stream = getFirstStream(payload); + const width = getNumber(stream, "width"); + const height = getNumber(stream, "height"); + const rotation = getRotation(stream); + + if (rotation === 90 || rotation === 270) { + return { height: width, width: height }; + } + + return { height, width }; +} + +function buildPrepareImageFilter(layout: PrepareImageLayout): string { + const sourceFilters = []; + if (layout.sourceCrop) { + sourceFilters.push( + `crop=${layout.sourceCrop.width}:${layout.sourceCrop.height}:${layout.sourceCrop.x}:${layout.sourceCrop.y}`, + ); + } + sourceFilters.push(`scale=${layout.renderWidth}:${layout.renderHeight}`, "setsar=1"); + + return [ + `[0:v]${sourceFilters.join(",")}[fg]`, + `color=c=white:s=${layout.outputWidth}x${layout.outputHeight}[bg]`, + `[bg][fg]overlay=${layout.renderOffsetX}:${layout.renderOffsetY}:format=auto,format=yuvj420p[out]`, + ].join(";"); +} + +function getFirstStream(value: unknown): Record { + if (!isRecord(value) || !Array.isArray(value.streams) || !isRecord(value.streams[0])) { + throw new Error("ffprobe did not return an image stream"); + } + + return value.streams[0]; +} + +function getNumber(record: Record, key: string): number { + const value = record[key]; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new Error(`ffprobe returned invalid ${key}`); + } + + return value; +} + +function getRotation(stream: Record): number { + const tags = stream.tags; + if (isRecord(tags) && typeof tags.rotate === "string") { + return normalizeRotation(Number.parseInt(tags.rotate, 10)); + } + + const sideData = stream.side_data_list; + if (Array.isArray(sideData)) { + for (const entry of sideData) { + if (isRecord(entry) && typeof entry.rotation === "number") { + return normalizeRotation(entry.rotation); + } + } + } + + return 0; +} + +function normalizeRotation(rotation: number): number { + if (!Number.isFinite(rotation)) { + return 0; + } + + return ((rotation % 360) + 360) % 360; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index 12882ee..ac705db 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -20,7 +20,8 @@ export type CliCommand = | "report" | "report-export" | "benchmark" - | "doctor"; + | "doctor" + | "prepare-image"; const CLI_ENTRYPOINTS = { recommend: "src/cli/recommend.ts", @@ -35,6 +36,7 @@ const CLI_ENTRYPOINTS = { "report-export": "src/cli/report_export.ts", benchmark: "src/cli/benchmark.ts", doctor: "src/cli/doctor.ts", + "prepare-image": "src/cli/prepare_image.ts", } as const satisfies Record; export async function runCli( @@ -105,6 +107,10 @@ export async function runDoctorCli(args: string[]): Promise { return runCli("doctor", args); } +export async function runPrepareImageCli(args: string[]): Promise { + return runCli("prepare-image", args); +} + export function parseJsonStdout(stdout: string): Record { const lines = stdout .split("\n") diff --git a/tests/integration/cli.integration.test.ts b/tests/integration/cli.integration.test.ts index c0dd8ba..31fb163 100644 --- a/tests/integration/cli.integration.test.ts +++ b/tests/integration/cli.integration.test.ts @@ -25,6 +25,7 @@ const cliCommands = [ "report-export", "benchmark", "doctor", + "prepare-image", ] as const satisfies readonly CliCommand[]; describe("cli integration", () => { diff --git a/tests/integration/prepare_image.integration.test.ts b/tests/integration/prepare_image.integration.test.ts new file mode 100644 index 0000000..723e4b4 --- /dev/null +++ b/tests/integration/prepare_image.integration.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectMedia } from "../../src/domain/media_inspector"; +import { runPrepareImageCli } from "../helpers/cli"; + +const outDir = mkdtempSync(join(tmpdir(), "passepartout-prepare-image-")); + +describe("prepare-image cli", () => { + test("prints minimal help", async () => { + const result = await runPrepareImageCli(["--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("Usage: bun run prepare-image --out [--border-px ]\n"); + expect(result.stderr).toBe(""); + }); + + test("exports a PNG as a JPEG and prints the actual output path", async () => { + const requestedOut = join(outDir, "photo.png"); + const result = await runPrepareImageCli([ + "tests/fixtures/images/landscape_sample_48x32.png", + "--out", + requestedOut, + "--border-px", + "0", + ]); + + const actualOut = join(outDir, "photo.jpg"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(`${actualOut}\n`); + expect(result.stderr).toBe(""); + expect(existsSync(actualOut)).toBe(true); + expect(inspectMedia(actualOut)).toMatchObject({ height: 32, width: 48 }); + }); +});