import { failCommand } from "../utils/commandResult.js"; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, extname, join, resolve } from "node:path"; import { HF_COLOR_GRADING_ATTR, isHfColorGradingActive, normalizeHfColorGrading, parseCubeLut, serializeHfColorGrading, } from "@hyperframes/core"; import { defineCommand } from "citty"; import sharp from "sharp"; import type { Example } from "./_examples.js"; import { openSettledCompositionPage, runFfmpegOnce } from "../capture/captureCompositionFrame.js"; import { findFFmpeg } from "../browser/ffmpeg.js"; import { c } from "../ui/colors.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { displayPathFromBase, readOptionalString, resolveFromBase } from "../utils/pathArgs.js"; import { trackCompareSheet } from "../telemetry/events.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; import { withMeta } from "../utils/updateCheck.js"; const COMPOSITION_ID = "grade-compare"; const COMPOSITION_DURATION = "1"; const DEFAULT_CELL_WIDTH = 560; const MAX_CANDIDATE_CELLS = 16; const MAX_COLUMNS = 4; const GRID_PADDING = 16; const LABEL_HEIGHT = 32; const FFMPEG_EXTRACT_TIMEOUT_MS = 30_000; export interface GradeCompareCell { label: string; grading: unknown; } interface ParsedGradeCompareArgs { framePath: string; projectDir: string; outPath: string; source: { kind: "grades"; path: string } | { kind: "luts"; value: string }; json: boolean; timeoutMs: number; } interface PreparedGradeCompareProject { tempDir: string; html: string; cells: GradeCompareCell[]; } export interface CandidateCellCapResult { cells: GradeCompareCell[]; truncated: boolean; total: number; } export interface GradeCompareSuccessPayload { ok: true; sheet: string; cells: number; truncated?: true; total?: number; } interface GradeCompareHtmlOptions { cells: readonly GradeCompareCell[]; frameSrc: string; frameWidth: number; frameHeight: number; } interface ReferenceFrame { buffer: Buffer; width: number; height: number; stagedName: string; } export const examples: Example[] = [ [ "Compare grade presets on one reference frame", "hyperframes grade-compare --for frame.png --grades grades.json", ], [ "Compare LUT files and print agent-friendly JSON", "hyperframes grade-compare --for frame.png --luts looks/a.cube,looks/b.cube --json", ], ]; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function hasOwn(record: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(record, key); } function cloneRecord(record: Record): Record { const next: Record = {}; for (const [key, value] of Object.entries(record)) { next[key] = value; } return next; } function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function escapeSingleQuotedAttr(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/'/g, "'"); } function validateCell(label: string, grading: unknown): GradeCompareCell { if (!normalizeHfColorGrading(grading)) { throw new Error(`Invalid color grading for cell "${label}"`); } return { label, grading }; } export function warnInactiveGradingCells(cells: readonly GradeCompareCell[]): void { for (const cell of cells) { const normalized = normalizeHfColorGrading(cell.grading); if (!isHfColorGradingActive(normalized)) { console.error( c.warn(`Warning: grading for "${cell.label}" is inactive/no-op — it will render ungraded`), ); } } } export function capCandidateCells(cells: readonly GradeCompareCell[]): CandidateCellCapResult { const total = cells.length; if (total <= MAX_CANDIDATE_CELLS) { return { cells: [...cells], truncated: false, total }; } console.error( c.warn( `Warning: ${total} candidate grades exceed the ${MAX_CANDIDATE_CELLS}-cell cap — rendering the first ${MAX_CANDIDATE_CELLS} of ${total}; re-run with fewer grades or split into multiple runs.`, ), ); return { cells: cells.slice(0, MAX_CANDIDATE_CELLS), truncated: true, total }; } export function buildGradeCompareSuccessPayload( sheet: string, cells: number, capResult: CandidateCellCapResult, ): GradeCompareSuccessPayload { if (!capResult.truncated) { return { ok: true, sheet, cells }; } return { ok: true, sheet, cells, truncated: true, total: capResult.total }; } function serializedGradingForCell(cell: GradeCompareCell): string { const normalized = normalizeHfColorGrading(cell.grading); if (!normalized) { throw new Error(`Invalid color grading for cell "${cell.label}"`); } return serializeHfColorGrading(normalized); } function lutSrcFromGrading(grading: unknown): string | null { if (!isRecord(grading) || !hasOwn(grading, "lut")) return null; const lut = grading.lut; if (typeof lut === "string" && lut.trim()) return lut.trim(); if (!isRecord(lut)) return null; const src = lut.src; return typeof src === "string" && src.trim() ? src.trim() : null; } function rewriteGradingLutSrc(grading: unknown, src: string): unknown { if (!isRecord(grading) || !hasOwn(grading, "lut")) return grading; const lut = grading.lut; const next = cloneRecord(grading); if (typeof lut === "string") { next.lut = { src }; return next; } if (isRecord(lut)) { const nextLut = cloneRecord(lut); nextLut.src = src; next.lut = nextLut; } return next; } export function parseGradesFile(filePath: string): GradeCompareCell[] { if (!existsSync(filePath)) { throw new Error(`Grades file not found: ${filePath}`); } let parsed: unknown; try { parsed = JSON.parse(readFileSync(filePath, "utf-8")); } catch (err) { throw new Error(`Could not parse grades JSON: ${normalizeErrorMessage(err)}`); } if (!Array.isArray(parsed)) { throw new Error("Grades file must be a JSON array of { label, grading } objects"); } return parsed.map((entry, index) => { if (!isRecord(entry)) { throw new Error(`Grade entry ${index + 1} must be an object with label and grading`); } const label = entry.label; if (typeof label !== "string" || !label.trim()) { throw new Error(`Grade entry ${index + 1} must have a non-empty string label`); } if (!hasOwn(entry, "grading")) { throw new Error(`Grade entry "${label}" must include a grading value`); } return validateCell(label, entry.grading); }); } export function resolveLutCells(luts: string): GradeCompareCell[] { const paths = luts .split(",") .map((part) => part.trim()) .filter(Boolean); if (paths.length === 0) { throw new Error("--luts must include at least one LUT path"); } return paths.map((lutPath) => validateCell(basename(lutPath, extname(lutPath)), { lut: { src: lutPath } }), ); } // The ungraded frame as a leading reference cell. An empty grading object // normalizes to inactive, so the runtime renders the source image untouched — // giving the agent a baseline to judge every candidate look against. export function prependBaselineCell(cells: GradeCompareCell[]): GradeCompareCell[] { return [validateCell("original", {}), ...cells]; } export function parseGradeCompareArgs(args: { for?: unknown; grades?: unknown; luts?: unknown; project?: unknown; out?: unknown; json?: unknown; timeout?: unknown; }): ParsedGradeCompareArgs { const frameArg = readOptionalString(args.for); if (!frameArg) throw new Error("--for is required"); const gradesArg = readOptionalString(args.grades); const lutsArg = readOptionalString(args.luts); if (Boolean(gradesArg) === Boolean(lutsArg)) { throw new Error("Exactly one of --grades or --luts is required"); } const projectDir = resolve(readOptionalString(args.project) ?? process.cwd()); const framePath = resolveFromBase(projectDir, frameArg); const outPath = resolveFromBase(projectDir, readOptionalString(args.out) ?? "grade-compare.png"); return { framePath, projectDir, outPath, source: gradesArg ? { kind: "grades", path: resolveFromBase(projectDir, gradesArg) } : { kind: "luts", value: lutsArg ?? "" }, json: args.json === true, timeoutMs: Number.parseInt(readOptionalString(args.timeout) ?? "", 10) || 5000, }; } function gridMetrics( cellCount: number, frameWidth: number, frameHeight: number, ): { columns: number; rows: number; cellImageWidth: number; cellImageHeight: number; width: number; height: number; } { const columns = Math.max(1, Math.min(MAX_COLUMNS, Math.ceil(Math.sqrt(cellCount)))); const rows = Math.ceil(cellCount / columns); const cellImageWidth = DEFAULT_CELL_WIDTH; const aspect = frameHeight > 0 && frameWidth > 0 ? frameHeight / frameWidth : 9 / 16; const cellImageHeight = Math.max(1, Math.round(cellImageWidth * aspect)); return { columns, rows, cellImageWidth, cellImageHeight, width: columns * cellImageWidth + (columns + 1) * GRID_PADDING, height: rows * (cellImageHeight + LABEL_HEIGHT) + (rows + 1) * GRID_PADDING, }; } export function buildGradeCompareHtml(options: GradeCompareHtmlOptions): string { if (options.cells.length === 0) { throw new Error("At least one grade cell is required"); } const metrics = gridMetrics(options.cells.length, options.frameWidth, options.frameHeight); const cellHtml = options.cells .map((cell, index) => { const serialized = escapeSingleQuotedAttr(serializedGradingForCell(cell)); const label = escapeXml(cell.label); const row = Math.floor(index / metrics.columns); const col = index % metrics.columns; const left = GRID_PADDING + col * (metrics.cellImageWidth + GRID_PADDING); const top = GRID_PADDING + row * (metrics.cellImageHeight + LABEL_HEIGHT + GRID_PADDING); return `
${label}
${label}
`; }) .join("\n"); return ` HyperFrames Grade Compare
${cellHtml}
`; } function frameFileNameForPath(framePath: string): string { const ext = extname(framePath).toLowerCase(); if (ext === ".jpg" || ext === ".jpeg") return `frame${ext}`; return "frame.png"; } export async function prepareGradeCompareTempProject(opts: { projectDir: string; framePath: string; frameBuffer: Buffer; cells: readonly GradeCompareCell[]; frameWidth: number; frameHeight: number; frameFileName?: string; }): Promise { const tempDir = mkdtempSync(join(tmpdir(), "hf-grade-compare-")); try { const frameFileName = opts.frameFileName ?? frameFileNameForPath(opts.framePath); writeFileSync(join(tempDir, frameFileName), opts.frameBuffer); let lutIndex = 0; const stagedCells = opts.cells.map((cell) => { const lutSrc = lutSrcFromGrading(cell.grading); if (!lutSrc) return cell; const sourcePath = resolveFromBase(opts.projectDir, lutSrc); if (!existsSync(sourcePath)) { throw new Error(`LUT file not found for "${cell.label}": ${sourcePath}`); } const lutText = readFileSync(sourcePath, "utf-8"); try { parseCubeLut(lutText, { maxSize: 64 }); } catch (err) { throw new Error( `LUT for "${cell.label}" is not a valid .cube: ${normalizeErrorMessage(err)}`, ); } const lutExt = extname(sourcePath) || ".cube"; const stagedName = `lut-${lutIndex}${lutExt}`; lutIndex += 1; copyFileSync(sourcePath, join(tempDir, stagedName)); return { label: cell.label, grading: rewriteGradingLutSrc(cell.grading, stagedName), }; }); const html = buildGradeCompareHtml({ cells: stagedCells, frameSrc: frameFileName, frameWidth: opts.frameWidth, frameHeight: opts.frameHeight, }); writeFileSync(join(tempDir, "index.html"), html); return { tempDir, html, cells: stagedCells }; } catch (err) { rmSync(tempDir, { recursive: true, force: true }); throw err; } } function isVideoPath(filePath: string): boolean { const ext = extname(filePath).toLowerCase(); return [".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".mpeg", ".mpg", ".ogv"].includes(ext); } async function extractVideoFrameToBuffer(videoPath: string): Promise { const tmp = mkdtempSync(join(tmpdir(), "hf-grade-compare-frame-")); const outPath = join(tmp, "frame.png"); try { const ffmpegPath = findFFmpeg(); if (!ffmpegPath) return null; const args = [ "-hide_banner", "-loglevel", "error", "-ss", "0", "-i", videoPath, "-frames:v", "1", "-q:v", "2", "-y", outPath, ]; const result = await runFfmpegOnce(ffmpegPath, args, FFMPEG_EXTRACT_TIMEOUT_MS); if (result.timedOut) { throw new Error(`ffmpeg timed out extracting first frame from ${videoPath}`); } if (result.code !== 0 || !existsSync(outPath)) { const detail = result.stderr.trim() ? `: ${result.stderr.trim()}` : ""; throw new Error(`ffmpeg could not extract first frame from ${videoPath}${detail}`); } return readFileSync(outPath); } finally { rmSync(tmp, { recursive: true, force: true }); } } async function loadReferenceFrame(framePath: string): Promise { if (!existsSync(framePath)) { throw new Error(`Reference frame not found: ${framePath}`); } const buffer = isVideoPath(framePath) ? await extractVideoFrameToBuffer(framePath) : readFileSync(framePath); if (!buffer) { throw new Error(`Could not extract a frame from video: ${framePath}`); } const metadata = await sharp(buffer).metadata(); if (!metadata.width || !metadata.height) { throw new Error(`Could not read reference frame dimensions: ${framePath}`); } return { buffer, width: metadata.width, height: metadata.height, stagedName: isVideoPath(framePath) ? "frame.png" : frameFileNameForPath(framePath), }; } async function captureGradeCompareSheet( projectDir: string, timeoutMs: number, ): Promise<{ sheetPath: string; renderReadyTimedOut: boolean }> { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); const html = await bundleToSingleHtml(projectDir); const server = await serveStaticProjectHtml(projectDir, html); const sheetPath = join(projectDir, "grade-compare.png"); try { const { browser: chromeBrowser, page, renderReadyTimedOut, } = await openSettledCompositionPage(html, server.url, { renderReadyTimeoutMs: timeoutMs, renderReadyWarningSuffix: "grade comparison may be inaccurate", }); try { await page.screenshot({ path: sheetPath, type: "png" }); return { sheetPath, renderReadyTimedOut }; } finally { await chromeBrowser.close(); } } finally { await server.close(); } } function printJson(payload: object): void { console.log(JSON.stringify(withMeta(payload), null, 2)); } export default defineCommand({ meta: { name: "grade-compare", description: "Render candidate color grades onto a reference frame as one comparison PNG", }, args: { for: { type: "string", description: "Reference image path, or a video path to sample at t=0", required: true, }, grades: { type: "string", description: "JSON array of { label, grading } candidate grades", }, luts: { type: "string", description: "Comma-separated .cube LUT paths to compare", }, project: { type: "string", description: "Base directory for relative --for, --grades, and LUT paths (default: cwd)", }, out: { type: "string", description: "Output PNG path (default: /grade-compare.png)", }, json: { type: "boolean", description: "Output result as JSON", default: false, }, timeout: { type: "string", description: "Render-ready timeout in ms before capture (default: 5000)", }, baseline: { type: "boolean", description: "Prepend the ungraded frame as an 'original' reference cell (--no-baseline to omit)", default: true, }, }, async run({ args }) { const jsonRequested = args.json === true; let preparedDir: string | null = null; try { const parsed = parseGradeCompareArgs({ for: args.for, grades: args.grades, luts: args.luts, project: args.project, out: args.out, json: args.json, timeout: args.timeout, }); let cells = parsed.source.kind === "grades" ? parseGradesFile(parsed.source.path) : resolveLutCells(parsed.source.value); if (cells.length === 0) { throw new Error("At least one grade candidate is required"); } const capResult = capCandidateCells(cells); cells = capResult.cells; warnInactiveGradingCells(cells); if (args.baseline !== false) { cells = prependBaselineCell(cells); } if (!parsed.json) { console.log( `${c.accent("◆")} Rendering ${cells.length} grade candidates from ${c.accent(basename(parsed.framePath))}`, ); } const frame = await loadReferenceFrame(parsed.framePath); const prepared = await prepareGradeCompareTempProject({ projectDir: parsed.projectDir, framePath: parsed.framePath, frameBuffer: frame.buffer, frameWidth: frame.width, frameHeight: frame.height, frameFileName: frame.stagedName, cells, }); preparedDir = prepared.tempDir; const { sheetPath: tempSheet, renderReadyTimedOut } = await captureGradeCompareSheet( prepared.tempDir, parsed.timeoutMs, ); mkdirSync(dirname(parsed.outPath), { recursive: true }); copyFileSync(tempSheet, parsed.outPath); trackCompareSheet({ command: "grade-compare", cells: prepared.cells.length, truncated: capResult.truncated, total: capResult.total, renderReadyTimedOut, }); const sheet = displayPathFromBase(parsed.projectDir, parsed.outPath); if (parsed.json) { printJson(buildGradeCompareSuccessPayload(sheet, prepared.cells.length, capResult)); } else { console.log(`\n${c.success("◇")} Grade comparison saved to ${sheet}`); } } catch (err) { const message = normalizeErrorMessage(err); if (jsonRequested) { printJson({ ok: false, error: message }); } else { console.error(`\n${c.error("✗")} Grade compare failed: ${message}`); } failCommand(); } finally { if (preparedDir) { rmSync(preparedDir, { recursive: true, force: true }); } } }, });