diff --git a/docs/phase1_knowledge.md b/docs/phase1_knowledge.md index 743a236..ed6bf8e 100644 --- a/docs/phase1_knowledge.md +++ b/docs/phase1_knowledge.md @@ -11,6 +11,7 @@ Implement executable, testable milestones of P17 without drifting from locked re - Analyze media inspector expanded from `PPM` to `PNG`, `JPEG`, `MP4`, and `MOV`. - Video probing baseline added with `ffprobe` (`width`, `height`, `aspect`, `orientation`, `codec`, `fps`). - Added `export-image` CLI baseline using `ffmpeg` with deterministic profile-driven output sizing. +- Added `export-video` CLI baseline using `ffmpeg` with deterministic profile-driven output sizing (`MP4`/H.264 output). - Externalized rules in `config/ruleset.v1.json`. - White-canvas policy logic for feed profiles with workflow-gated `feed_app_direct` fallback. - Locked margin formula v1 and contain/no-crop invariant flags. @@ -32,9 +33,10 @@ Implement executable, testable milestones of P17 without drifting from locked re - Introduced raster fixture generation (`ffmpeg`) and analyze e2e coverage for PNG/JPEG fixtures. - Added video fixture generation (`ffmpeg`) and analyze coverage for MP4/MOV fixtures. - Added export integration/e2e snapshot tests with fixture outputs in `tests/fixtures/exports`. +- Added export-video integration/e2e snapshot tests and dedicated export-video snapshot fixtures. ## Deferred - Advanced video inspection fields (`bitrate`, `duration`, audio stream metadata, color metadata). -- Video export/transcode profiles beyond image-frame export baseline. +- Advanced video export/transcode policies (audio mapping, bitrate controls, codec variants beyond baseline MP4/H.264). - Empirical quality validation harness. diff --git a/package.json b/package.json index 47ae68d..e84e8a6 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "recommend": "bun run src/cli/recommend.ts", "analyze": "bun run src/cli/analyze.ts", "export-image": "bun run src/cli/export_image.ts", + "export-video": "bun run src/cli/export_video.ts", "test": "bun test", "test:unit": "bun test tests/recommend.test.ts tests/white_canvas.test.ts tests/media_inspector.test.ts tests/tier.test.ts", "test:integration": "bun test tests/integration", @@ -20,6 +21,7 @@ "fixtures:e2e": "bun run tests/fixtures/e2e/generate-snapshots.ts", "fixtures:e2e:analyze": "bun run tests/fixtures/e2e/generate-analyze-snapshots.ts", "fixtures:e2e:export": "bun run tests/fixtures/e2e/generate-export-snapshots.ts", + "fixtures:e2e:export-video": "bun run tests/fixtures/e2e/generate-export-video-snapshots.ts", "fixtures:visual": "bun run tests/fixtures/visual/generate-snapshots.ts", "fixtures:pixel": "bun run tests/fixtures/pixel/generate-snapshots.ts" }, diff --git a/src/cli/export_video.ts b/src/cli/export_video.ts new file mode 100644 index 0000000..e6d4651 --- /dev/null +++ b/src/cli/export_video.ts @@ -0,0 +1,140 @@ +import type { + CanvasProfile, + ExportVideoInput, + Mode, + Surface, + Workflow, +} from "../types/contracts"; +import { exportVideo } from "../domain/export_video"; +import { stableStringify } from "../domain/recommend"; + +type ParsedArgs = ExportVideoInput & { json: boolean }; + +function parseArgs(argv: string[]): ParsedArgs { + if (argv.length === 0 || argv[0]?.startsWith("--")) { + throw new Error("Missing required positional arg: "); + } + + const file = argv[0]!; + let out: string | undefined; + let mode: Mode | undefined; + let surface: Surface | undefined; + let workflow: Workflow = "unknown"; + let whiteCanvas = false; + let canvasProfile: CanvasProfile | undefined; + let crf: number | undefined; + let json = false; + + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]; + const next = argv[i + 1]; + + switch (token) { + case "--out": + if (!next || next.startsWith("--")) { + throw new Error("Missing value for --out"); + } + out = next; + i += 1; + break; + case "--mode": + if (!next || next.startsWith("--")) { + throw new Error("Missing value for --mode"); + } + mode = next as Mode; + i += 1; + break; + case "--surface": + if (!next || next.startsWith("--")) { + throw new Error("Missing value for --surface"); + } + surface = next as Surface; + i += 1; + break; + case "--workflow": + if (!next || next.startsWith("--")) { + throw new Error("Missing value for --workflow"); + } + workflow = next as Workflow; + i += 1; + break; + case "--white-canvas": + whiteCanvas = true; + break; + case "--canvas-profile": + if (!next || next.startsWith("--")) { + throw new Error("Missing value for --canvas-profile"); + } + canvasProfile = next as CanvasProfile; + i += 1; + break; + case "--crf": + if (!next || next.startsWith("--")) { + throw new Error("Missing value for --crf"); + } + crf = Number.parseInt(next ?? "", 10); + i += 1; + break; + case "--json": + json = true; + break; + default: + throw new Error(`Unknown argument: ${token}`); + } + } + + if (!out || !mode || !surface) { + throw new Error("Missing required args: --out --mode --surface"); + } + + if (!["reliable", "experimental"].includes(mode)) { + throw new Error(`Invalid mode: ${mode}`); + } + + if (!["feed", "story", "reel"].includes(surface)) { + throw new Error(`Invalid surface: ${surface}`); + } + + if (!["app_direct", "api_scheduler", "unknown"].includes(workflow)) { + throw new Error(`Invalid workflow: ${workflow}`); + } + + if (canvasProfile && !["feed_compat", "feed_app_direct"].includes(canvasProfile)) { + throw new Error(`Invalid canvas profile: ${canvasProfile}`); + } + + if (crf !== undefined && (!Number.isFinite(crf) || crf < 0 || crf > 51)) { + throw new Error(`Invalid crf: ${crf}`); + } + + return { file, out, mode, surface, workflow, whiteCanvas, canvasProfile, crf, json }; +} + +function printHumanOutput(result: ReturnType): void { + console.log(`Summary: exported ${result.input_path} -> ${result.output_path}`); + console.log(`Target: ${result.target_resolution}`); + console.log(`Profile: ${result.selected_profile}`); + console.log(`Video: codec=${result.video_codec} fps=${result.fps}`); + console.log(`White canvas: ${result.white_canvas_enabled ? "enabled" : "disabled"}`); + console.log("Next action: run with --json for machine-readable output."); +} + +function main(): void { + try { + const parsed = parseArgs(process.argv.slice(2)); + const result = exportVideo(parsed); + + if (parsed.json) { + console.log(stableStringify(result)); + return; + } + + printHumanOutput(result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } +} + +main(); diff --git a/src/domain/export_video.ts b/src/domain/export_video.ts new file mode 100644 index 0000000..3bd0cb6 --- /dev/null +++ b/src/domain/export_video.ts @@ -0,0 +1,115 @@ +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import type { ExportVideoInput, ExportVideoOutput } from "../types/contracts"; +import { inspectMedia } from "./media_inspector"; +import { recommend } from "./recommend"; +import { parseResolution } from "./rules"; + +function buildFilter(params: { + targetWidth: number; + targetHeight: number; + whiteCanvasEnabled: boolean; + margins: { left: number; top: number; right: number; bottom: number } | null; +}): string { + const { targetWidth, targetHeight, whiteCanvasEnabled, margins } = params; + + if (whiteCanvasEnabled && margins) { + const innerWidth = targetWidth - (margins.left + margins.right); + const innerHeight = targetHeight - (margins.top + margins.bottom); + + if (innerWidth <= 0 || innerHeight <= 0) { + throw new Error("Invalid white-canvas margins: non-positive inner frame"); + } + + return [ + `scale=${innerWidth}:${innerHeight}:force_original_aspect_ratio=decrease`, + `pad=${innerWidth}:${innerHeight}:(ow-iw)/2:(oh-ih)/2:white`, + `pad=${targetWidth}:${targetHeight}:${margins.left}:${margins.top}:white`, + ].join(","); + } + + return [ + `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=increase`, + `crop=${targetWidth}:${targetHeight}`, + ].join(","); +} + +export function exportVideo(input: ExportVideoInput): ExportVideoOutput { + const workflow = input.workflow ?? "unknown"; + const crf = input.crf ?? 23; + + const media = inspectMedia(input.file); + const recommendation = recommend({ + mode: input.mode, + surface: input.surface, + orientation: media.orientation, + workflow, + whiteCanvas: input.whiteCanvas, + canvasProfile: input.canvasProfile, + sourceRatio: media.width / media.height, + }); + + const { width, height } = parseResolution(recommendation.target_resolution); + const filter = buildFilter({ + targetWidth: width, + targetHeight: height, + whiteCanvasEnabled: recommendation.white_canvas.enabled, + margins: recommendation.white_canvas.margins, + }); + + const resolvedInputPath = resolve(input.file); + const resolvedOutputPath = resolve(input.out); + mkdirSync(dirname(resolvedOutputPath), { recursive: true }); + + const fps = media.fps > 0 ? media.fps : 30; + const proc = Bun.spawnSync({ + cmd: [ + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + resolvedInputPath, + "-map", + "0:v:0", + "-map", + "0:a?", + "-vf", + filter, + "-r", + String(fps), + "-c:v", + "libx264", + "-c:a", + "aac", + "-crf", + String(crf), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + resolvedOutputPath, + ], + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + + if (proc.exitCode !== 0) { + throw new Error( + `ffmpeg video export failed (input: ${resolvedInputPath}, filter: ${filter}): ${proc.stderr.toString().trim()}`, + ); + } + + return { + input_path: input.file, + output_path: input.out, + selected_profile: recommendation.selected_profile, + target_resolution: recommendation.target_resolution, + white_canvas_enabled: recommendation.white_canvas.enabled, + ffmpeg_filter: filter, + video_codec: "h264", + fps, + }; +} diff --git a/src/types/contracts.ts b/src/types/contracts.ts index 06a2423..4a0f4f8 100644 --- a/src/types/contracts.ts +++ b/src/types/contracts.ts @@ -136,3 +136,25 @@ export type ExportImageOutput = { white_canvas_enabled: boolean; ffmpeg_filter: string; }; + +export type ExportVideoInput = { + file: string; + out: string; + mode: Mode; + surface: Surface; + workflow?: Workflow; + whiteCanvas?: boolean; + canvasProfile?: CanvasProfile; + crf?: number; +}; + +export type ExportVideoOutput = { + input_path: string; + output_path: string; + selected_profile: string; + target_resolution: Resolution; + white_canvas_enabled: boolean; + ffmpeg_filter: string; + video_codec: string; + fps: number; +}; diff --git a/tests/e2e/export_video.snapshots.e2e.test.ts b/tests/e2e/export_video.snapshots.e2e.test.ts new file mode 100644 index 0000000..9fb13ab --- /dev/null +++ b/tests/e2e/export_video.snapshots.e2e.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseJsonStdout, runExportVideoCli } from "../helpers/cli"; +import type { ExportCase } from "../helpers/types"; + +const fixtureDir = join(import.meta.dir, "..", "fixtures", "e2e"); +const snapshotDir = join(fixtureDir, "snapshots", "export_video"); +const cases = JSON.parse(readFileSync(join(fixtureDir, "export_video_cases.json"), "utf8")) as ExportCase[]; + +function normalizeExportVideoPayload(payload: Record): Record { + const normalized = structuredClone(payload) as { input_path?: string; output_path?: string }; + if (typeof normalized.input_path === "string" && normalized.input_path.startsWith("/")) { + normalized.input_path = normalized.input_path.replace(/^.*tests\/fixtures\//, "tests/fixtures/"); + } + if (typeof normalized.output_path === "string" && normalized.output_path.startsWith("/")) { + normalized.output_path = normalized.output_path.replace(/^.*tests\/fixtures\//, "tests/fixtures/"); + } + return normalized as Record; +} + +describe("export-video e2e snapshots", () => { + for (const scenario of cases) { + test(scenario.id, async () => { + const result = await runExportVideoCli(scenario.args); + expect(result.exitCode).toBe(0); + const expected = normalizeExportVideoPayload( + JSON.parse(readFileSync(join(snapshotDir, `${scenario.id}.json`), "utf8")) as Record, + ); + const actual = normalizeExportVideoPayload(parseJsonStdout(result.stdout, scenario.id)); + expect(actual).toEqual(expected); + }); + } +}); diff --git a/tests/fixtures/e2e/export_video_cases.json b/tests/fixtures/e2e/export_video_cases.json new file mode 100644 index 0000000..2f487f5 --- /dev/null +++ b/tests/fixtures/e2e/export_video_cases.json @@ -0,0 +1,14 @@ +[ + { + "id": "export-video-reliable-reel-portrait", + "args": ["tests/fixtures/images/portrait_video_360x640.mp4", "--out", "tests/fixtures/exports/e2e_export_video_reliable_reel_portrait.mp4", "--mode", "reliable", "--surface", "reel", "--workflow", "unknown", "--json"] + }, + { + "id": "export-video-reliable-feed-landscape-white", + "args": ["tests/fixtures/images/landscape_video_640x360.mov", "--out", "tests/fixtures/exports/e2e_export_video_reliable_feed_landscape_white.mp4", "--mode", "reliable", "--surface", "feed", "--workflow", "unknown", "--white-canvas", "--canvas-profile", "feed_compat", "--json"] + }, + { + "id": "export-video-experimental-story", + "args": ["tests/fixtures/images/portrait_video_360x640.mp4", "--out", "tests/fixtures/exports/e2e_export_video_experimental_story.mp4", "--mode", "experimental", "--surface", "story", "--workflow", "unknown", "--json"] + } +] diff --git a/tests/fixtures/e2e/generate-export-video-snapshots.ts b/tests/fixtures/e2e/generate-export-video-snapshots.ts new file mode 100644 index 0000000..00fafce --- /dev/null +++ b/tests/fixtures/e2e/generate-export-video-snapshots.ts @@ -0,0 +1,49 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { parseJsonStdout } from "../../helpers/cli"; +import type { ExportCase } from "../../helpers/types"; + +const fixturesDir = import.meta.dir; +const repoRoot = join(fixturesDir, "..", "..", ".."); +const snapshotDir = join(fixturesDir, "snapshots", "export_video"); +mkdirSync(snapshotDir, { recursive: true }); + +const cases = JSON.parse(readFileSync(join(fixturesDir, "export_video_cases.json"), "utf8")) as ExportCase[]; + +for (const testCase of cases) { + const outIndex = testCase.args.findIndex((arg) => arg === "--out"); + if (outIndex >= 0) { + const outputPath = testCase.args[outIndex + 1]; + if (!outputPath) { + throw new Error(`test case ${testCase.id} has --out without a value`); + } + rmSync(join(repoRoot, outputPath), { force: true }); + } + + const proc = Bun.spawnSync({ + cmd: ["bun", "run", "export-video", ...testCase.args], + cwd: repoRoot, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + + if (proc.exitCode !== 0) { + throw new Error(`failed for ${testCase.id}: ${proc.stderr.toString()}`); + } + + const payload = parseJsonStdout(proc.stdout.toString(), testCase.id) as { + input_path?: string; + output_path?: string; + }; + if (payload.input_path?.startsWith("/")) { + payload.input_path = relative(repoRoot, payload.input_path); + } + if (payload.output_path?.startsWith("/")) { + payload.output_path = relative(repoRoot, payload.output_path); + } + + writeFileSync(join(snapshotDir, `${testCase.id}.json`), `${JSON.stringify(payload)}\n`, "utf8"); +} + +console.log(`generated ${cases.length} export-video snapshots`); diff --git a/tests/fixtures/e2e/snapshots/export_video/export-video-experimental-story.json b/tests/fixtures/e2e/snapshots/export_video/export-video-experimental-story.json new file mode 100644 index 0000000..71b2111 --- /dev/null +++ b/tests/fixtures/e2e/snapshots/export_video/export-video-experimental-story.json @@ -0,0 +1 @@ +{"ffmpeg_filter":"scale=2160:3840:force_original_aspect_ratio=increase,crop=2160:3840","fps":30,"input_path":"tests/fixtures/images/portrait_video_360x640.mp4","output_path":"tests/fixtures/exports/e2e_export_video_experimental_story.mp4","selected_profile":"experimental_story_2160","target_resolution":"2160x3840","video_codec":"h264","white_canvas_enabled":false} diff --git a/tests/fixtures/e2e/snapshots/export_video/export-video-reliable-feed-landscape-white.json b/tests/fixtures/e2e/snapshots/export_video/export-video-reliable-feed-landscape-white.json new file mode 100644 index 0000000..dbd2410 --- /dev/null +++ b/tests/fixtures/e2e/snapshots/export_video/export-video-reliable-feed-landscape-white.json @@ -0,0 +1 @@ +{"ffmpeg_filter":"scale=994:918:force_original_aspect_ratio=decrease,pad=994:918:(ow-iw)/2:(oh-ih)/2:white,pad=1080:1350:43:216:white","fps":24,"input_path":"tests/fixtures/images/landscape_video_640x360.mov","output_path":"tests/fixtures/exports/e2e_export_video_reliable_feed_landscape_white.mp4","selected_profile":"reliable_feed_white_canvas_feed_compat","target_resolution":"1080x1350","video_codec":"h264","white_canvas_enabled":true} diff --git a/tests/fixtures/e2e/snapshots/export_video/export-video-reliable-reel-portrait.json b/tests/fixtures/e2e/snapshots/export_video/export-video-reliable-reel-portrait.json new file mode 100644 index 0000000..ec67f3c --- /dev/null +++ b/tests/fixtures/e2e/snapshots/export_video/export-video-reliable-reel-portrait.json @@ -0,0 +1 @@ +{"ffmpeg_filter":"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920","fps":30,"input_path":"tests/fixtures/images/portrait_video_360x640.mp4","output_path":"tests/fixtures/exports/e2e_export_video_reliable_reel_portrait.mp4","selected_profile":"reliable_reel_safe","target_resolution":"1080x1920","video_codec":"h264","white_canvas_enabled":false} diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index cb632c4..a5f45ce 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -8,7 +8,10 @@ export type CliRunResult = { const REPO_ROOT = resolve(import.meta.dir, "..", ".."); -async function runCli(command: "recommend" | "analyze" | "export-image", args: string[]): Promise { +async function runCli( + command: "recommend" | "analyze" | "export-image" | "export-video", + args: string[], +): Promise { const proc = Bun.spawn({ cmd: ["bun", "run", command, ...args], cwd: REPO_ROOT, @@ -37,6 +40,10 @@ export async function runExportImageCli(args: string[]): Promise { return runCli("export-image", args); } +export async function runExportVideoCli(args: string[]): Promise { + return runCli("export-video", args); +} + export function parseJsonStdout(stdout: string, context = "stdout"): Record { const lines = stdout .split("\n") diff --git a/tests/integration/export_video.integration.test.ts b/tests/integration/export_video.integration.test.ts new file mode 100644 index 0000000..cd5c097 --- /dev/null +++ b/tests/integration/export_video.integration.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { parseJsonStdout, runAnalyzeCli, runExportVideoCli } from "../helpers/cli"; + +const fixtures = join(import.meta.dir, "..", "fixtures", "images"); +const outDir = join(import.meta.dir, "..", "fixtures", "exports"); + +function resetOut(name: string): string { + mkdirSync(outDir, { recursive: true }); + const output = join(outDir, name); + rmSync(output, { force: true }); + return output; +} + +describe("export-video integration", () => { + test("exports reliable reel portrait video to exact target resolution", async () => { + const input = join(fixtures, "portrait_video_360x640.mp4"); + const output = resetOut("portrait_reliable_reel.mp4"); + + const exportResult = await runExportVideoCli([ + input, + "--out", + output, + "--mode", + "reliable", + "--surface", + "reel", + "--workflow", + "unknown", + "--json", + ]); + + expect(exportResult.exitCode).toBe(0); + expect(existsSync(output)).toBe(true); + + const analyzeResult = await runAnalyzeCli([ + output, + "--mode", + "reliable", + "--surface", + "reel", + "--workflow", + "unknown", + "--json", + ]); + + expect(analyzeResult.exitCode).toBe(0); + const payload = parseJsonStdout(analyzeResult.stdout); + const inputMeta = payload.input as Record; + expect(inputMeta.width).toBe(1080); + expect(inputMeta.height).toBe(1920); + expect(inputMeta.orientation).toBe("portrait"); + expect(inputMeta.codec).toBe("h264"); + }); + + test("white-canvas export-video uses feed canvas target", async () => { + const input = join(fixtures, "landscape_video_640x360.mov"); + const output = resetOut("landscape_white_canvas_video.mp4"); + + const exportResult = await runExportVideoCli([ + input, + "--out", + output, + "--mode", + "reliable", + "--surface", + "feed", + "--workflow", + "unknown", + "--white-canvas", + "--canvas-profile", + "feed_compat", + "--json", + ]); + + expect(exportResult.exitCode).toBe(0); + expect(existsSync(output)).toBe(true); + + const payload = parseJsonStdout(exportResult.stdout); + expect(payload).toHaveProperty("target_resolution", "1080x1350"); + expect(payload).toHaveProperty("white_canvas_enabled", true); + }); + + test("missing value for --mode fails with explicit error", async () => { + const input = join(fixtures, "portrait_video_360x640.mp4"); + const output = resetOut("video_missing_mode.mp4"); + const result = await runExportVideoCli([input, "--out", output, "--mode", "--surface", "reel", "--json"]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Missing value for --mode"); + }); + + test("missing value for --crf fails with explicit error", async () => { + const input = join(fixtures, "portrait_video_360x640.mp4"); + const output = resetOut("video_missing_crf.mp4"); + const result = await runExportVideoCli([ + input, + "--out", + output, + "--mode", + "reliable", + "--surface", + "reel", + "--crf", + "--json", + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Missing value for --crf"); + }); +});