import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { relative, resolve } from "node:path"; import { HF_COLOR_GRADING_ATTR, getHfColorGradingCapabilities, hasHfColorGradingAuthoredValues, isPathInside, normalizeHfColorGrading, serializeHfColorGrading, } from "@hyperframes/core"; import { isColorGradingVariableRef, validateColorGradingContract, } from "@hyperframes/parsers/color-grading-contract"; import { cleanAssetUrl, isRemoteOrInlineUrl, resolveExistingLocalAsset, } from "@hyperframes/parsers/asset-resolution"; import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; import { patchElementInHtml } from "@hyperframes/studio-server/source-mutation"; import { defineCommand } from "citty"; import { parseHTML } from "linkedom"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; import { failCommand } from "../utils/commandResult.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { readOptionalString } from "../utils/pathArgs.js"; import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; import { analyzeMediaTreatment } from "./media-treatment-analysis.js"; export function getMediaTreatmentCapabilityOverview() { const capabilities = getHfColorGradingCapabilities(); const family = (id: string, label: string, description: string) => ({ id, label, description, }); return { version: capabilities.version, targetTags: capabilities.targetTags, colorSpace: capabilities.colorSpace, families: [ family("correction", "Adjust", "Fix exposure, tonal balance, color casts, and saturation."), family( "grading", "Color Grading", "Shape tonal color with wheels, RGB and hue curves, and selective HSL correction.", ), family( "presets", "Presets", "Apply a tested starting point, then tune only when the source or intent requires it.", ), family("finishing", "Finish", "Shape vignette and deterministic film grain."), ...capabilities.effectFamilies, family( "palettes", "Palettes", "Reusable two-to-six-color palettes for compatible art effects.", ), family( "animation", "Animation", "Seek-safe CSS properties that registered GSAP timelines may animate.", ), family("lut", "Custom LUT", "Apply a user-owned 3D .cube LUT."), { id: "overlays", label: "Overlays", description: "Install authored HUD, light-leak, flash, or freeze-frame overlay blocks.", owner: "registry", }, ], discovery: { detail: "Use --capability for exact controls and examples.", full: "Use --all only for tooling or exhaustive inspection.", }, }; } export function getMediaTreatmentCapabilityDetail(id: string): unknown { const capabilities = getHfColorGradingCapabilities(); const animation = (path: string) => { const property = capabilities.animatable.find((candidate) => candidate.path === path); if (!property) return null; return { property, initial: `style="${property.name}: "`, tween: `timeline.to("", { "${property.name}": , duration: })`, rules: [ "Author the initial value inline on the media element.", "Use finite keyframes on a paused timeline registered in window.__timelines.", "Do not use a frame-zero set, timers, random values, or onUpdate callbacks.", ], }; }; const effect = capabilities.effects.find(({ key }) => key === id); if (effect) { return { id, family: effect.family, label: effect.label, description: effect.description, renderLane: effect.renderLane, supportsPalette: effect.supportsPalette, apply: { effects: effect.apply }, controls: effect.controls, animation: animation(`effects.${id}`), }; } const effectFamily = capabilities.effectFamilies.find((family) => family.id === id); if (effectFamily) { return { ...effectFamily, effects: capabilities.effects .filter((candidate) => candidate.family === id) .map((candidate) => ({ id: candidate.key, label: candidate.label, description: candidate.description, renderLane: candidate.renderLane, supportsPalette: candidate.supportsPalette, animatable: capabilities.animatable.some( ({ path }) => path === `effects.${candidate.key}`, ), })), }; } const adjustment = capabilities.adjustments.find(({ key }) => key === id); if (adjustment) { return { id, family: "correction", description: `Adjust ${id} within the canonical correction range.`, control: adjustment, apply: { adjust: { [id]: adjustment.identity } }, animation: animation(`adjust.${id}`), }; } const finishing = capabilities.finishing.find(({ key }) => key === id); if (finishing) { return { id, family: "finishing", description: `Adjust ${id} within the canonical finishing range.`, control: finishing, apply: { details: { [id]: finishing.identity } }, }; } const preset = capabilities.presets.find((candidate) => candidate.id === id); if (preset) { return { id: preset.id, label: preset.label, description: "Tested built-in media preset.", apply: { preset: preset.id, intensity: preset.intensity }, }; } const palette = capabilities.palettes.find((candidate) => candidate.id === id); if (palette) return { ...palette, apply: { palette: palette.colors } }; const details = { grading: { id, description: "Shape media after primary correction with wheels, curves, HSL secondaries, and an optional user-owned LUT.", order: [ "adjust", "wheels", "curves", "hueCurves", "secondaries", "lut", "details", "effects", ], discover: ["wheels", "curves", "hue-curves", "secondary", "scopes", "lut"], }, correction: { id, description: "Fix exposure, tonal balance, color casts, and saturation.", controls: capabilities.adjustments, }, wheels: { id, description: "Shadow, midtone, and highlight color wheels.", contract: capabilities.wheels, apply: { wheels: { shadows: { hue: 205, amount: 0.08, level: 0 }, highlights: { hue: 35, amount: 0.06, level: 0 }, }, }, }, curves: { id, description: "Master and per-channel curves using normalized [input, output] points.", contract: capabilities.curves, apply: { curves: { master: [ [0, 0], [0.25, 0.2], [0.75, 0.82], [1, 1], ], }, }, }, "hue-curves": { id, description: "Hue-selective curves using [hueDegrees, delta] points.", contract: capabilities.hueCurves, apply: { hueCurves: { hueVsSaturation: [ [180, 0], [210, 0.15], [240, 0], ], }, }, }, secondary: { id, description: "Up to four ordered HSL selections with bounded color correction.", contract: capabilities.secondaries, apply: { secondaries: [ { key: { hue: { center: 215, range: 25, softness: 10 }, saturation: { min: 0.2, max: 1, softness: 0.08 }, luma: { min: 0.1, max: 0.9, softness: 0.08 }, }, correction: { saturation: 0.15, luma: 0.03 }, }, ], }, }, scopes: { id, description: "Deterministic source measurements for agent decisions; visual scopes remain a Studio display.", command: "hyperframes media-treatment --file compositions/scene.html --selector '#hero' --analyze --json", output: [ "source color metadata and HDR/LOG warnings", "luma percentile and clipping evidence", "bounded suggested primary correction", ], }, presets: { id, description: "Tested starting points for color and stylized media effects.", presets: capabilities.presets, }, finishing: { id, description: "Vignette and deterministic film-grain controls.", controls: capabilities.finishing, }, palettes: { id, description: "Named palettes plus the custom palette contract.", contract: capabilities.palette, palettes: capabilities.palettes, }, animation: { id, description: "Seek-safe CSS properties for registered GSAP timelines.", properties: capabilities.animatable, }, lut: { id, description: "User-owned 3D .cube LUT support.", contract: capabilities.lut, }, overlays: { id, description: "Authored overlay blocks owned by the HyperFrames Registry.", discover: "hyperframes catalog", apply: "hyperframes add --dir --no-clipboard --json", }, } as const; const detail = Object.hasOwn(details, id) ? Reflect.get(details, id) : undefined; if (detail) return detail; throw new Error(`Unknown media-treatment capability: ${id}`); } export const examples: Example[] = [ [ "Discover the complete treatment surface without loading every control", `hyperframes media-treatment --capabilities --json`, ], [ "Inspect one relevant effect in detail", `hyperframes media-treatment --capability kuwahara --json`, ], [ "Inspect the exhaustive machine-readable catalog", `hyperframes media-treatment --capabilities --all --json`, ], [ "Apply a resolved treatment to one media element", `hyperframes media-treatment --selector '#hero' --grading '{"preset":"skin-soft","intensity":0.6}' --apply`, ], [ "Preview the exact mutation without writing", `hyperframes media-treatment --file compositions/scene.html --selector 'video' --grading '{"preset":"warm-daylight"}' --apply --dry-run --json`, ], [ "Measure one local media source before choosing a correction", `hyperframes media-treatment --selector '#hero' --analyze --json`, ], ["Remove a treatment", `hyperframes media-treatment --selector '#hero' --clear`], ]; interface ApplyMediaTreatmentOptions { selector: string; selectorIndex?: number; grading?: unknown; clear?: boolean; } interface ApplyMediaTreatmentResult { html: string; changed: boolean; tag: "img" | "video"; value: string | null; before: unknown; after: unknown; } function parseSourceDocument(source: string): Document { if (/]/i.test(source)) return parseHTML(source).document; return parseHTML(`${source}`).document; } function assertKnownGradingShape(value: unknown): void { const issue = validateColorGradingContract(value)[0]; if (issue) { const hint = issue.hint ? ` ${issue.hint}` : ""; throw new Error(`Invalid color-grading ${issue.path}: ${issue.message}.${hint}`); } } function containsColorGradingVariableRef(value: unknown): boolean { if (isColorGradingVariableRef(value)) return true; if (Array.isArray(value)) return value.some(containsColorGradingVariableRef); if (typeof value !== "object" || value === null) return false; return Object.values(value).some(containsColorGradingVariableRef); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function parseStoredGrading(raw: string | null): unknown { if (raw === null) return null; try { return JSON.parse(raw); } catch { return raw; } } function mergeGradingPatch(current: unknown, patch: unknown): unknown { if (!isRecord(current) || !isRecord(patch)) return patch; const merged = { ...current }; for (const [key, value] of Object.entries(patch)) { merged[key] = isRecord(value) && isRecord(merged[key]) ? mergeGradingPatch(merged[key], value) : value; } return merged; } function serializeGradingPatch(before: unknown, patch: unknown): string | null { assertKnownGradingShape(patch); if (isColorGradingVariableRef(before) && isRecord(patch)) { throw new Error("Cannot merge a grading patch into an unresolved whole-grade variable"); } const current = typeof before === "string" && !isColorGradingVariableRef(before) ? { preset: before } : before; const grading = mergeGradingPatch(current, patch); assertKnownGradingShape(grading); if (containsColorGradingVariableRef(grading)) { return typeof grading === "string" ? grading.trim() : JSON.stringify(grading); } const normalized = normalizeHfColorGrading(grading); if (!normalized) throw new Error("--grading must be valid HyperFrames color-grading JSON"); return hasHfColorGradingAuthoredValues(normalized) ? serializeHfColorGrading(normalized) : null; } function queryIncludingTemplates(root: Document | Element, selector: string): Element[] { const matches = Array.from(root.querySelectorAll(selector)); if (matches.length > 0) return matches; for (const template of root.querySelectorAll("template")) { const nested = queryIncludingTemplates(template, selector); if (nested.length > 0) return nested; } return []; } function selectMediaElement( source: string, selector: string, selectorIndex?: number, ): { element: Element; selectorIndex: number; tag: "img" | "video" } { const document = parseSourceDocument(source); let matches: Element[]; try { matches = queryIncludingTemplates(document, selector); } catch { throw new Error(`Invalid selector: ${selector}`); } if (matches.length === 0) throw new Error(`Selector did not match: ${selector}`); if (selectorIndex === undefined && matches.length > 1) { throw new Error( `Selector matched ${matches.length} elements; use a unique selector or --selector-index`, ); } const resolvedIndex = selectorIndex ?? 0; const element = matches[resolvedIndex]; if (!element) { throw new Error(`--selector-index ${resolvedIndex} is outside ${matches.length} matches`); } const tag = element.tagName.toLowerCase(); if (tag !== "img" && tag !== "video") { throw new Error(`Color grading requires an or