mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Read WebP/JPEG comps through a sibling PNG cache instead of forcing PNG
comp-spec, comp-diff, build-phase, font-match, and generate-image now decode any comp raster via loadRaster(), converting non-PNG input to <file>.png next to the source. Sessions used to hit 'not a PNG' and rewrite the .webp in place with PNG bytes, which broke transcript replay (a later step rewrites the comp beyond the cut) and left a mislabeled file. AI-assisted (Claude Code).
This commit is contained in:
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 `<name>.<ext>.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}` : ''}`);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user