diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs index 19ed4380f..63665c03a 100644 --- a/skill/scripts/build-phase.mjs +++ b/skill/scripts/build-phase.mjs @@ -66,7 +66,7 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; -import { decodePng } from './lib/png.mjs'; +import { decodePng, loadRaster } from './lib/png.mjs'; const require = createRequire(import.meta.url); import { crop, createImage, blit } from './lib/raster.mjs'; import { compare, verdictFor } from './comp-diff.mjs'; @@ -254,7 +254,7 @@ export function gatePlates(state, { specPath = SPEC_PATH } = {}) { const rasterRegions = spec.regions.filter((r) => r.medium === 'raster'); if (!rasterRegions.length) return { ok: true, reasons: [], summary: 'no plates owed', plates: [] }; let comp = null; - try { comp = decodePng(fs.readFileSync(spec.comp)); } catch { /* scored without the comp crop below */ } + try { comp = loadRaster(spec.comp).image; } catch { /* scored without the comp crop below */ } const reasons = [], plates = []; for (const r of rasterRegions) { const file = r.plate; @@ -561,7 +561,7 @@ export function advance(state, { force = false, reason = null, gateOpts = {} } = p.status = 'closed'; p.closedAt = now(); if (phase === 'comps' && gate.approved) { state.comp = gate.approved; - if (!state.breakpoint) { try { const i = decodePng(fs.readFileSync(gate.approved)); state.breakpoint = `${i.width}x${i.height}`; } catch { /* non-png comp: breakpoint stays unset */ } } + if (!state.breakpoint) { try { const i = loadRaster(gate.approved).image; state.breakpoint = `${i.width}x${i.height}`; } catch { /* non-png comp: breakpoint stays unset */ } } } const next = PHASES[idx + 1]; state.phase = next; @@ -627,7 +627,7 @@ async function main() { return; } let breakpoint = arg('breakpoint'); - if (!breakpoint && comp) { try { const i = decodePng(fs.readFileSync(comp)); breakpoint = `${i.width}x${i.height}`; } catch { /* leave null */ } } + if (!breakpoint && comp) { try { const i = loadRaster(comp).image; breakpoint = `${i.width}x${i.height}`; } catch { /* leave null */ } } const existing = loadState(); if (existing && !flag('reset')) { console.log(`build-phase: state exists (phase ${existing.phase}); pass --reset to start over`); diff --git a/skill/scripts/comp-diff.mjs b/skill/scripts/comp-diff.mjs index f47e85da6..92e0c0082 100644 --- a/skill/scripts/comp-diff.mjs +++ b/skill/scripts/comp-diff.mjs @@ -40,7 +40,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { decodePng, encodePng } from './lib/png.mjs'; +import { decodePng, encodePng, loadRaster } from './lib/png.mjs'; import { crop, resize, fit, blit, createImage, fillRect, strokeRect, drawLabel } from './lib/raster.mjs'; import { structureScore, colorScore, detailScore, diffMap, horizontalBands, bandScore, dominantColors, toGray, blurGray, ssimShifted } from './lib/image-metrics.mjs'; @@ -53,7 +53,7 @@ function arg(name, fallback = null) { const flag = (name) => process.argv.includes(`--${name}`); export function readPng(file) { - return decodePng(fs.readFileSync(file)); + return loadRaster(file).image; } /** diff --git a/skill/scripts/comp-spec.mjs b/skill/scripts/comp-spec.mjs index 41c242050..81f1158dc 100644 --- a/skill/scripts/comp-spec.mjs +++ b/skill/scripts/comp-spec.mjs @@ -38,7 +38,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { decodePng, encodePng } from './lib/png.mjs'; +import { decodePng, encodePng, loadRaster } from './lib/png.mjs'; import { crop, resize, fillRect, strokeRect, drawLabel, drawText } from './lib/raster.mjs'; import { dominantColors, horizontalBands, detailGrid } from './lib/image-metrics.mjs'; @@ -273,7 +273,7 @@ async function main() { if (!spec) { console.error(`comp-spec: no spec at ${specPath}`); process.exit(1); } const region = spec.regions.find((r) => r.id === arg('crop')); if (!region) { console.error(`comp-spec: no region ${arg('crop')}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); } - const comp = decodePng(fs.readFileSync(spec.comp)); + const comp = loadRaster(spec.comp).image; let c = region.medium === 'raster' && !flag('raw') ? plateReference(comp, spec, region) : crop(comp, region.px.x, region.px.y, region.px.w, region.px.h); const scale = parseFloat(arg('scale', '1')); if (scale > 1) c = resize(c, c.width * scale, c.height * scale); @@ -290,7 +290,7 @@ async function main() { process.exit(1); } let comp; - try { comp = decodePng(fs.readFileSync(compPath)); } catch (e) { console.error(`comp-spec: cannot read ${compPath}: ${e.message}`); process.exit(1); } + try { comp = loadRaster(compPath).image; } catch (e) { console.error(`comp-spec: cannot read ${compPath}: ${e.message}`); process.exit(1); } if (flag('grid')) { fs.mkdirSync(path.dirname(GRID_PATH), { recursive: true }); diff --git a/skill/scripts/font-match.mjs b/skill/scripts/font-match.mjs index 1c1ff10a1..f49b6b276 100644 --- a/skill/scripts/font-match.mjs +++ b/skill/scripts/font-match.mjs @@ -35,7 +35,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; -import { decodePng, encodePng } from './lib/png.mjs'; +import { decodePng, encodePng, loadRaster } from './lib/png.mjs'; import { crop } from './lib/raster.mjs'; import { fingerprint, distance } from './lib/font-fingerprint.mjs'; import { loadFontIndex, candidatesFromIndex, MIN_RANK_CAP_PX } from './lib/font-index.mjs'; @@ -311,7 +311,7 @@ async function main() { if (!spec) { console.error(`font-match: no spec at ${specPath}; run comp-spec.mjs first`); process.exit(1); } const region = spec.regions.find((r) => r.id === id); if (!region) { console.error(`font-match: no region ${id}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); } - const comp = decodePng(fs.readFileSync(spec.comp)); + const comp = loadRaster(spec.comp).image; const c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h); const fp = fingerprint(c); if (!fp) { diff --git a/skill/scripts/generate-image.mjs b/skill/scripts/generate-image.mjs index 41521145a..0b4f633dd 100644 --- a/skill/scripts/generate-image.mjs +++ b/skill/scripts/generate-image.mjs @@ -207,7 +207,7 @@ const plateId = arg('plate'); let plateCtx = null; if (plateId) { const { loadSpec, platePrompt, plateReference, SPEC_PATH } = await import('./comp-spec.mjs'); - const { decodePng, encodePng } = await import('./lib/png.mjs'); + const { decodePng, encodePng, loadRaster } = await import('./lib/png.mjs'); const { crop, resize } = await import('./lib/raster.mjs'); const specPath = arg('spec', SPEC_PATH); const spec = loadSpec(specPath); @@ -216,7 +216,7 @@ if (plateId) { if (!region) { console.error(`generate-image: no region ${plateId} in ${specPath}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); } if (region.medium !== 'raster') { console.error(`generate-image: region ${plateId} is ${region.medium}, not a plate; set its kind to plate|image|texture in the regions file`); process.exit(1); } let comp; - try { comp = decodePng(fs.readFileSync(spec.comp)); } catch (e) { console.error(`generate-image: cannot read comp ${spec.comp}: ${e.message}`); process.exit(1); } + try { comp = loadRaster(spec.comp).image; } catch (e) { console.error(`generate-image: cannot read comp ${spec.comp}: ${e.message}`); process.exit(1); } const ref = plateReference(comp, spec, region); const refPath = path.join(path.dirname(specPath), 'crops', `${region.id}.png`); fs.mkdirSync(path.dirname(refPath), { recursive: true }); diff --git a/skill/scripts/lib/png.mjs b/skill/scripts/lib/png.mjs index faa8d45a9..1d5130cc2 100644 --- a/skill/scripts/lib/png.mjs +++ b/skill/scripts/lib/png.mjs @@ -12,6 +12,8 @@ * only formats the comp-fidelity tooling has to read. */ import zlib from 'node:zlib'; +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); @@ -248,3 +250,32 @@ export function encodePng({ width, height, data }, { text = null, level = 6 } = parts.push(chunk('IEND', Buffer.alloc(0))); return Buffer.concat(parts); } + +/** + * Read any raster the comp pipeline meets (PNG natively; WebP / JPEG / GIF / + * AVIF through a converter on PATH) as RGBA. Non-PNG input is converted to a + * sibling cache file `..png` next to the source, never in place: + * a session that overwrites `comp.webp` with PNG bytes leaves a file the + * next tool cannot trust and a transcript replay cannot reconstruct. + * Returns { image, path } where path is the PNG actually decoded. + */ +export function loadRaster(file) { + const buf = fs.readFileSync(file); + if (isPng(buf)) return { image: decodePng(buf), path: file }; + const cache = `${file}.png`; + if (fs.existsSync(cache)) { + try { const b = fs.readFileSync(cache); if (isPng(b)) return { image: decodePng(b), path: cache }; } catch { /* reconvert */ } + } + const attempts = [ + ['dwebp', [file, '-o', cache]], + ['sips', ['-s', 'format', 'png', file, '--out', cache]], + ['magick', [file, cache]], + ['convert', [file, cache]], + ]; + let lastErr = null; + for (const [cmd, args] of attempts) { + try { execFileSync(cmd, args, { stdio: 'ignore' }); const b = fs.readFileSync(cache); if (isPng(b)) return { image: decodePng(b), path: cache }; } + catch (e) { lastErr = e; } + } + throw new Error(`png: ${file} is not a PNG and no converter (dwebp, sips, magick, convert) could produce ${cache}${lastErr ? `: ${lastErr.message}` : ''}`); +} diff --git a/tests/comp-diff.test.mjs b/tests/comp-diff.test.mjs index 1fdce1921..4b2d3d83e 100644 --- a/tests/comp-diff.test.mjs +++ b/tests/comp-diff.test.mjs @@ -1,12 +1,12 @@ import { describe, it, before } from 'node:test'; import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { spawnSync, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { decodePng, encodePng } from '../skill/scripts/lib/png.mjs'; +import { decodePng, encodePng, loadRaster } from '../skill/scripts/lib/png.mjs'; import { createImage, fillRect, blit, crop, resize, drawText } from '../skill/scripts/lib/raster.mjs'; import { compare, verdictFor, alignBuild } from '../skill/scripts/comp-diff.mjs'; import { dominantColors, structureScore, detailScore } from '../skill/scripts/lib/image-metrics.mjs'; @@ -223,3 +223,20 @@ describe('comp-diff CLI', () => { assert.match(res.stderr, /usage/); }); }); + +it('loadRaster reads a WebP comp through a sibling .png cache instead of rewriting the source', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'raster-')); + const src = path.join(dir, 'comp.webp'); + const png = encodePng((() => { const i = createImage(24, 16); fillRect(i, 0, 0, 24, 16, [200, 40, 40, 255]); return i; })()); + fs.writeFileSync(path.join(dir, 'seed.png'), png); + let ok = true; + try { execFileSync('cwebp', ['-lossless', path.join(dir, 'seed.png'), '-o', src], { stdio: 'ignore' }); } catch { ok = false; } + if (!ok) return; // no cwebp on this machine: nothing to assert + const before = fs.readFileSync(src); + const { image, path: decoded } = loadRaster(src); + assert.equal(image.width, 24); + assert.equal(image.height, 16); + assert.equal(decoded, `${src}.png`); + assert.ok(fs.readFileSync(src).equals(before), 'source webp bytes untouched'); + assert.ok(fs.existsSync(`${src}.png`), 'sibling cache written'); +});