import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { withReservedFile, withReservedFileSync } from "./manifest.mjs"; import { freezeUrl } from "./freeze.mjs"; import { tokenOverlap } from "./match.mjs"; import { buildCube } from "./cube-build.mjs"; import { validateCube, validateCubeFile } from "./cube-validate.mjs"; const SKILL_DIR = join(import.meta.dirname, "..", ".."); const LUT_DIR = join(SKILL_DIR, "luts"); const LUT_INDEX = join(LUT_DIR, "index.json"); export const LIBRARY_LUT_OFFLINE_CODE = "MEDIA_USE_LIBRARY_LUT_OFFLINE"; // Presets this released resolver can safely emit at this stack layer. // Effect-backed treatment presets join after their runtime support lands. export const RESOLVABLE_PRESET_IDS = [ "neutral", "warm-daylight", "clean-studio", "skin-soft", "food-pop", "night-lift", "muted-editorial", "vintage-wash", "mono-clean", "mono-fade", "soft-boost", "bright-pop", "deep-contrast", "creator-camcorder", "vhs-playback", "home-movie-8mm", "editorial-halftone", "two-ink-print", ]; const PRESET_SYNONYMS = { neutral: ["neutral", "identity", "none", "ungraded", "natural base"], "warm-daylight": [ "warm daylight", "warm natural light", "golden daylight", "sunlit", "warm sunny", "warm clean", "clean warm", "warm product", ], "clean-studio": [ "clean studio", "studio clean", "cool studio", "product studio", "cool clean", "clean cool", "cool crisp", ], "skin-soft": ["skin soft", "soft skin", "portrait soft", "beauty skin"], "food-pop": ["food pop", "food vibrant", "appetizing", "restaurant color"], "night-lift": ["night lift", "night", "low light lift", "city night"], "muted-editorial": ["muted editorial", "editorial muted", "magazine muted"], "vintage-wash": ["vintage wash", "vintage", "retro wash", "aged film"], "mono-clean": ["mono clean", "black white clean", "monochrome clean"], "mono-fade": ["mono fade", "black white fade", "faded monochrome"], "soft-boost": [ "soft boost", "soft bright", "gentle boost", "natural lift", "natural light", "gentle lift", "soft natural", ], "bright-pop": [ "bright pop", "bright punchy", "vivid bright", "fresh pop", "fresh", "bright fresh", "clean colorful", ], "deep-contrast": ["deep contrast", "high contrast punchy", "punchy contrast", "bold contrast"], "creator-camcorder": ["creator camcorder", "creator video", "ugc camera", "handheld creator"], "vhs-playback": ["vhs playback", "vhs tape", "analog tape", "degraded tape"], "home-movie-8mm": ["8mm home movie", "8mm film", "family film", "small gauge film"], "editorial-halftone": ["editorial halftone", "halftone", "print dots", "newsprint"], "two-ink-print": ["two ink print", "two ink editorial", "duotone print", "poster print"], }; function presetCandidates() { return RESOLVABLE_PRESET_IDS.map((id) => ({ kind: "preset", preset: id, synonyms: PRESET_SYNONYMS[id] ?? [], text: [id, ...(PRESET_SYNONYMS[id] ?? [])].join(" "), })); } export function readBundledLutIndex() { if (!existsSync(LUT_INDEX)) return []; const parsed = JSON.parse(readFileSync(LUT_INDEX, "utf8")); const entries = Array.isArray(parsed) ? parsed : parsed.looks; if (!Array.isArray(entries)) return []; return entries.map((entry) => { const params = entry.params && typeof entry.params === "object" && !Array.isArray(entry.params) ? entry.params : null; const url = typeof entry.url === "string" && entry.url.trim() ? entry.url.trim() : null; return { id: String(entry.id), description: String(entry.description ?? entry.id), tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [], intensity: Number.isFinite(Number(entry.intensity)) ? Number(entry.intensity) : 1, ...(params && { params }), ...(url && { url }), }; }); } function libraryCandidates() { return readBundledLutIndex().map((entry) => ({ kind: "library", ...entry, text: [entry.id, entry.description, ...entry.tags].join(" "), })); } export function matchColorLook(intent) { const normalized = String(intent ?? "") .trim() .toLowerCase() .replace(/\s+/g, " "); if (RESOLVABLE_PRESET_IDS.includes(normalized)) { return { kind: "preset", preset: normalized, score: 99 }; } const candidates = [...presetCandidates(), ...libraryCandidates()] .map((candidate, index) => ({ ...candidate, index, score: tokenOverlap(intent, candidate.text), })) .filter((candidate) => candidate.score >= 2) .sort((a, b) => b.score - a.score || a.index - b.index); if (candidates.length === 0) return null; const best = candidates[0]; if (best.kind === "preset") { return { kind: "preset", preset: best.preset, score: best.score }; } return { kind: "library", id: best.id, description: best.description, tags: best.tags, intensity: best.intensity, ...(best.params && { params: best.params }), ...(best.url && { url: best.url }), score: best.score, }; } export function isLibraryLutOfflineMiss(err) { return err?.code === LIBRARY_LUT_OFFLINE_CODE; } function libraryRecord(match, { id, localPath, fullPath, via }) { return { id, localPath, fullPath, lut: { src: localPath, intensity: match.intensity }, source: "library", description: match.description, metadata: { provider: "cube_lut.library", provenance: { look_id: match.id, tags: match.tags, via, }, }, }; } function assertValidCubeText(cube, label) { const check = validateCube(cube); if (!check.ok) throw new Error(`${label}: ${check.error}`); } function assertValidCubeFile(path, label) { const check = validateCubeFile(path); if (!check.ok) throw new Error(`${label}: ${check.error}`); } function offlineLibraryMiss(match) { const err = new Error(`library LUT "${match.id}" is CDN-only and --local-only is set`); err.code = LIBRARY_LUT_OFFLINE_CODE; return err; } export async function freezeLibraryLut(match, { projectDir, type, localOnly = false }) { if (!match || match.kind !== "library") { throw new Error("freezeLibraryLut requires a library match"); } // Prefer the CDN url so looks download on-demand (like bgm/image). Fall back // to deterministic buildCube params when offline (--local-only) or if the // download/validation fails, so resolution is never blocked on the network. if (match.url && !localOnly) { try { return await withReservedFile( projectDir, type, ".cube", async ({ id, localPath, fullPath }) => { const tmpPath = `${fullPath}.tmp`; try { // Download + validate at a .tmp path, then atomically rename. A crash // (SIGKILL/OOM) between write and validate can't orphan an invalid .cube // at the final path — only a validated cube is ever renamed into place. await freezeUrl(match.url, tmpPath); assertValidCubeFile(tmpPath, `downloaded library LUT ${match.id} failed validation`); renameSync(tmpPath, fullPath); return libraryRecord(match, { id, localPath, fullPath, via: "url" }); } finally { rmSync(tmpPath, { force: true }); } }, ); } catch (err) { if (!match.params) { throw new Error(`failed to freeze library LUT ${match.id}: ${err.message}`); } // else: fall through to the params fallback below } } if (match.params) { return withReservedFileSync(projectDir, type, ".cube", ({ id, localPath, fullPath }) => { const tmpPath = `${fullPath}.tmp`; try { const cube = buildCube(match.params); assertValidCubeText(cube, `invalid library LUT ${match.id}`); // Write + validate at .tmp, then atomic rename — same no-orphan guarantee // as the url path above. writeFileSync(tmpPath, cube); assertValidCubeFile(tmpPath, `invalid frozen LUT ${localPath}`); renameSync(tmpPath, fullPath); return libraryRecord(match, { id, localPath, fullPath, via: match.url ? "params-fallback" : "params", }); } finally { rmSync(tmpPath, { force: true }); } }); } if (match.url) throw offlineLibraryMiss(match); // url-only entry, offline throw new Error(`misconfigured library LUT "${match.id}": expected params or url`); }