Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
379d74b
milestone 1: phase1 recommend engine with layered tests and pixel diffs
edhor1608 Feb 15, 2026
58aedd4
fix(pr1): address coderabbit review findings
edhor1608 Feb 18, 2026
b071c1e
fix(pr1): handle P6 header comments and assert exit before parse
edhor1608 Feb 18, 2026
43858c8
milestone 2: add analyze CLI with media inspector, tier engine, and s…
edhor1608 Feb 15, 2026
ac11e94
fix(pr2): harden analyze args and normalize snapshot paths
edhor1608 Feb 18, 2026
13a7916
fix(pr2): harden analyze error handling and path normalization
edhor1608 Feb 18, 2026
05f28de
milestone 3: extend analyze media inspector to PNG/JPEG with snapshot…
edhor1608 Feb 15, 2026
e744101
fix(pr3): normalize raster snapshot paths and use ffmpeg fixtures
edhor1608 Feb 18, 2026
be13180
fix(pr3): use minimal reads for PNG and JPEG probing
edhor1608 Feb 18, 2026
655c860
milestone 4: add ffmpeg export-image engine with integration and e2e …
edhor1608 Feb 15, 2026
f98895b
fix(pr4): normalize export snapshots and harden export cli
edhor1608 Feb 18, 2026
e24048e
fix(pr4): centralize export-image validation and snapshots
edhor1608 Feb 18, 2026
e74a645
milestone 5: add mp4/mov analyze baseline via ffprobe
edhor1608 Feb 15, 2026
f62b08c
fix(pr5): harden ffprobe handling and normalize video snapshots
edhor1608 Feb 18, 2026
7df489e
milestone 6: add export-video ffmpeg baseline with snapshots
edhor1608 Feb 15, 2026
3d6a80f
fix(pr6): preserve audio and normalize export-video snapshots
edhor1608 Feb 18, 2026
510c50f
fix(pr6): improve export-video snapshot assertions
edhor1608 Feb 18, 2026
b7f7a32
milestone 7: add video duration and bitrate to analyze metadata
edhor1608 Feb 15, 2026
b851c47
milestone 8: add analyze audio metadata baseline
edhor1608 Feb 15, 2026
5e6d0be
milestone 9: add detailed audio metadata to analyze
edhor1608 Feb 15, 2026
5970c49
fix(pr9): derive has_audio from audio stream presence
edhor1608 Feb 18, 2026
762b334
milestone 10: add output metadata to export-video contract
edhor1608 Feb 15, 2026
7805f57
milestone 11: add export-video audio strip proof fields
edhor1608 Feb 15, 2026
7082d52
milestone 12: add deterministic report cli slice
edhor1608 Feb 15, 2026
ec67e56
Merge remote-tracking branch 'origin/main' into codex/m12-report-cli-…
edhor1608 Feb 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/phase1_knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Implement executable, testable milestones of P17 without drifting from locked re

- Deterministic recommendation engine (`recommend`) with mode/surface/orientation/workflow inputs.
- Deterministic analyze engine (`analyze`) with file input and tier classification output.
- Deterministic report engine (`report`) layered on analyze output with checklist + next-actions contract.
- Analyze media inspector expanded from `PPM` to `PNG`, `JPEG`, `MP4`, and `MOV`.
- Video probing baseline added with `ffprobe` (`width`, `height`, `aspect`, `orientation`, `codec`, `fps`).
- Analyze metadata baseline extended for video to include `duration_seconds` and `bitrate_kbps`.
Expand All @@ -21,6 +22,7 @@ Implement executable, testable milestones of P17 without drifting from locked re
- White-canvas policy logic for feed profiles with workflow-gated `feed_app_direct` fallback.
- Locked margin formula v1 and contain/no-crop invariant flags.
- CLI command and JSON/human outputs.
- Added `report` CLI + domain slice with deterministic checklist checks and stable JSON snapshots.
- Layered test suite expansion:
- unit tests
- integration tests (CLI behavior and contract checks)
Expand All @@ -44,6 +46,7 @@ Implement executable, testable milestones of P17 without drifting from locked re
- Added export-video integration/e2e snapshot tests and dedicated export-video snapshot fixtures.
- Added failing-first export-video assertions to verify returned output metadata against the exported file.
- Added failing-first export-video assertions proving audio is stripped from audio-input videos in baseline export mode.
- Added report integration/e2e snapshot tests and deterministic report fixtures.

## Deferred

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"recommend": "bun run src/cli/recommend.ts",
"analyze": "bun run src/cli/analyze.ts",
"report": "bun run src/cli/report.ts",
"export-image": "bun run src/cli/export_image.ts",
"export-video": "bun run src/cli/export_video.ts",
"test": "bun test",
Expand All @@ -20,6 +21,7 @@
"fixtures:images:raster": "bun run tests/fixtures/images/generate-raster-fixtures.ts",
"fixtures:e2e": "bun run tests/fixtures/e2e/generate-snapshots.ts",
"fixtures:e2e:analyze": "bun run tests/fixtures/e2e/generate-analyze-snapshots.ts",
"fixtures:e2e:report": "bun run tests/fixtures/e2e/generate-report-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",
Expand Down
128 changes: 128 additions & 0 deletions src/cli/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
CANVAS_PROFILES,
MODES,
SURFACES,
WORKFLOWS,
type CanvasProfile,
type Mode,
type ReportInput,
type Surface,
type Workflow,
} from "../types/contracts";
import { stableStringify } from "../domain/recommend";
import { buildReport } from "../domain/report";

type ParsedArgs = ReportInput & { json: boolean };

function isAllowedValue<T extends string>(value: string, allowed: readonly T[]): value is T {
return (allowed as readonly string[]).includes(value);
}

function parseArgs(argv: string[]): ParsedArgs {
if (argv.length === 0 || argv[0]?.startsWith("--")) {
throw new Error("Missing required positional arg: <file>");
}

const file = argv[0]!;
let mode: Mode | undefined;
let surface: Surface | undefined;
let workflow: Workflow = "unknown";
let whiteCanvas = false;
let canvasProfile: CanvasProfile | 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 "--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 "--json":
json = true;
break;
default:
throw new Error(`Unknown argument: ${token}`);
}
}

if (!mode || !surface) {
throw new Error("Missing required args: --mode --surface");
}

if (!isAllowedValue(mode, MODES)) {
throw new Error(`Invalid mode: ${mode}`);
}

if (!isAllowedValue(surface, SURFACES)) {
throw new Error(`Invalid surface: ${surface}`);
}

if (!isAllowedValue(workflow, WORKFLOWS)) {
throw new Error(`Invalid workflow: ${workflow}`);
}

if (canvasProfile && !isAllowedValue(canvasProfile, CANVAS_PROFILES)) {
throw new Error(`Invalid canvas profile: ${canvasProfile}`);
}

return { file, mode, surface, workflow, whiteCanvas, canvasProfile, json };
}

function printHumanOutput(result: ReturnType<typeof buildReport>): void {
const analyzed = result.analyze;
console.log(`Summary: ${analyzed.input.path} -> ${analyzed.selection.target_resolution}`);
console.log(`Tier: ${analyzed.tier.name} (${analyzed.tier.risk_level})`);
console.log("Checks:");
for (const check of result.checks) {
console.log(`- [${check.status}] ${check.label}: ${check.message}`);
}
console.log("Next actions:");
for (const action of result.next_actions) {
console.log(`- ${action}`);
}
console.log("Next action: rerun with --json for machine-readable output.");
}

function main(): void {
const parsed = parseArgs(process.argv.slice(2));
const result = buildReport(parsed);

if (parsed.json) {
console.log(stableStringify(result));
return;
}

printHumanOutput(result);
}

main();
91 changes: 91 additions & 0 deletions src/domain/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { ReportInput, ReportOutput } from "../types/contracts";
import { analyze } from "./analyze";

export function buildReport(input: ReportInput): ReportOutput {
const analyzed = analyze(input);

const checks = [
analyzed.input.width >= 320
? {
id: "input_width_min",
label: "Input width baseline",
status: "pass" as const,
message: `Input width ${analyzed.input.width}px is within baseline threshold.`,
}
: {
id: "input_width_min",
label: "Input width baseline",
status: "warn" as const,
message: `Input width ${analyzed.input.width}px is below baseline threshold (320px).`,
},
analyzed.tier.name === "tier_aspect_correction"
? {
id: "aspect_fit",
label: "Aspect fit",
status: "warn" as const,
message: "Input aspect is outside supported bounds for selected surface.",
}
: {
id: "aspect_fit",
label: "Aspect fit",
status: "pass" as const,
message: "Input aspect is within supported bounds for selected surface.",
},
analyzed.input.codec === null
? {
id: "audio_present",
label: "Audio track",
status: "pass" as const,
message: "Still image input: audio track is not applicable.",
}
: analyzed.input.has_audio
? {
id: "audio_present",
label: "Audio track",
status: "pass" as const,
message: "Audio track detected in input video.",
}
: {
id: "audio_present",
label: "Audio track",
status: "warn" as const,
message: "No audio track detected in input video.",
},
analyzed.input.codec === null || analyzed.input.codec === "h264"
? {
id: "codec_preference",
label: "Codec preference",
status: "pass" as const,
message:
analyzed.input.codec === null
? "Still image input: codec preference is not applicable."
: "Input codec is h264 and matches baseline preference.",
}
: {
id: "codec_preference",
label: "Codec preference",
status: "warn" as const,
message: `Input codec ${analyzed.input.codec} differs from h264 baseline preference.`,
},
];

const nextActions = [
analyzed.input.codec === null
? "Use export-image for deterministic still export."
: "Use export-video for deterministic video export.",
];

if (checks.some((check) => check.status === "warn")) {
nextActions.push("Review warning checks before upload.");
}

if (analyzed.white_canvas.enabled) {
nextActions.push("Confirm white-canvas margins visually before posting.");
}

return {
analyze: analyzed,
checks,
next_actions: nextActions,
};
}
17 changes: 17 additions & 0 deletions src/types/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,20 @@ export type ExportVideoOutput = {
output_has_audio: boolean;
output_audio_codec: string | null;
};

export type ReportInput = AnalyzeInput;

export type ReportCheckStatus = "pass" | "warn";

export type ReportCheck = {
id: string;
label: string;
status: ReportCheckStatus;
message: string;
};

export type ReportOutput = {
analyze: AnalyzeOutput;
checks: ReportCheck[];
next_actions: string[];
};
45 changes: 45 additions & 0 deletions tests/e2e/report.snapshots.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { isAbsolute, join, relative } from "node:path";
import { parseJsonStdout, runReportCli } from "../helpers/cli";

type ReportCase = {
id: string;
args: string[];
};

const fixtureDir = join(import.meta.dir, "..", "fixtures", "e2e");
const snapshotDir = join(fixtureDir, "snapshots", "report");
const repoRoot = join(import.meta.dir, "..", "..");
const cases = JSON.parse(readFileSync(join(fixtureDir, "report_cases.json"), "utf8")) as ReportCase[];

function normalizeReportPayload(payload: Record<string, unknown>): Record<string, unknown> {
const normalized = structuredClone(payload) as { analyze?: { input?: { path?: string } } };
const pathValue = normalized.analyze?.input?.path;
if (typeof pathValue === "string") {
const normalizedSlashes = pathValue.replaceAll("\\", "/");
if (/^[A-Za-z]:\//.test(normalizedSlashes)) {
normalized.analyze!.input!.path = normalizedSlashes.replace(/^.*tests\/fixtures\//, "tests/fixtures/");
} else if (isAbsolute(pathValue)) {
normalized.analyze!.input!.path = relative(repoRoot, pathValue).replaceAll("\\", "/");
} else {
normalized.analyze!.input!.path = normalizedSlashes;
}
}
return normalized as Record<string, unknown>;
}

describe("report e2e snapshots", () => {
for (const scenario of cases) {
test(scenario.id, async () => {
const result = await runReportCli(scenario.args);
expect(result.exitCode).toBe(0);

const expected = normalizeReportPayload(
JSON.parse(readFileSync(join(snapshotDir, `${scenario.id}.json`), "utf8")) as Record<string, unknown>,
);
const actual = normalizeReportPayload(parseJsonStdout(result.stdout, scenario.id));
expect(actual).toEqual(expected);
});
}
});
64 changes: 64 additions & 0 deletions tests/fixtures/e2e/generate-report-snapshots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { isAbsolute, join, relative } from "node:path";

type ReportCase = {
id: string;
args: string[];
};

const fixturesDir = import.meta.dir;
const repoRoot = join(fixturesDir, "..", "..", "..");
const snapshotDir = join(fixturesDir, "snapshots", "report");
mkdirSync(snapshotDir, { recursive: true });

const cases = JSON.parse(readFileSync(join(fixturesDir, "report_cases.json"), "utf8")) as ReportCase[];

function normalizeReportPath(pathValue: string): string {
const normalizedSlashes = pathValue.replaceAll("\\", "/");
if (/^[A-Za-z]:\//.test(normalizedSlashes)) {
return normalizedSlashes.replace(/^.*tests\/fixtures\//, "tests/fixtures/");
}
if (isAbsolute(pathValue)) {
return relative(repoRoot, pathValue).replaceAll("\\", "/");
}
return normalizedSlashes;
}

for (const testCase of cases) {
const proc = Bun.spawnSync({
cmd: ["bun", "run", "report", ...testCase.args],
cwd: repoRoot,
stdout: "pipe",
stderr: "pipe",
});

if (proc.exitCode !== 0) {
throw new Error(`failed for ${testCase.id}: ${proc.stderr.toString()}`);
}

const lines = proc.stdout
.toString()
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const payload = lines[lines.length - 1];
if (!payload) {
throw new Error(`no json payload for ${testCase.id}`);
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(payload) as Record<string, unknown>;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`invalid json payload for ${testCase.id}: ${message}; payload=${payload}`);
}

const analyze = parsed.analyze as { input?: { path?: string } } | undefined;
if (typeof analyze?.input?.path === "string") {
analyze.input.path = normalizeReportPath(analyze.input.path);
}

writeFileSync(join(snapshotDir, `${testCase.id}.json`), `${JSON.stringify(parsed)}\n`, "utf8");
}

console.log(`generated ${cases.length} report snapshots`);
Loading