diff --git a/.omp/config.yml b/.omp/config.yml index 7428079..bd59b5e 100644 --- a/.omp/config.yml +++ b/.omp/config.yml @@ -1,12 +1,10 @@ -# omp project config — mirrors .opencode/opencode.jsonc + opencode-swarm.json -# Models: opencode/* in OpenCode == built-in `opencode-zen` provider in omp. +# omp project config modelRoles: - # architect/coder: opencode/deepseek-v4-flash @ reasoningEffort high - default: openrouter/z-ai/glm-5.3-flash:auto + default: opencode-zen/glm-5.3-flash:auto # cheap tier: swarm fallback model, also drives tiny/smol background work (titles, memory LLM) smol: opencode-zen/big-pickle - # reviewer/test_engineer/explorer/sme/researcher/critic/docs/designer/curators: opencode/big-pickle + # reviewer/test_engineer/explorer/sme/researcher/critic/docs/designer/curators slow: opencode-zen/big-pickle # Swarm agent fallback_models -> retry fallback chains diff --git a/assets/06-ai-a1111.png b/assets/06-ai-a1111.png new file mode 100644 index 0000000..06bd5ea --- /dev/null +++ b/assets/06-ai-a1111.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95aa02fde2a90c77869eaea9055cbf0d0d2fc8cbf2c5b592eab918b40f5196e6 +size 2319992 diff --git a/assets/06-ai-invokeai.png b/assets/06-ai-invokeai.png new file mode 100644 index 0000000..a0567d4 --- /dev/null +++ b/assets/06-ai-invokeai.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8c13516921d1312c5f5c1b353e7acbec52ffd3c3ecbe6a647d5a5c02466e87d +size 2316284 diff --git a/src/ai-parity.test.ts b/src/ai-parity.test.ts new file mode 100644 index 0000000..ee08375 --- /dev/null +++ b/src/ai-parity.test.ts @@ -0,0 +1,103 @@ +import { test } from 'vitest'; +/** + * AI-forensics read parity: generative-AI provenance tags (A1111 parameters + * chunk, InvokeAI XMP namespace, DMI DigitalSourceType, xmpMM:History + * flattening) must match reference exiftool's flat `-j` JSON. + * + * Requires the real `exiftool` CLI on PATH and healthy child argv; otherwise + * a single skipped placeholder keeps CI green (same contract as + * exiftool-parity.test.ts). + */ +import { assertEquals } from './test/asserts.js'; +import { ExifTool } from './exiftool.js'; +import { formatJSON } from './cli/output.js'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileP = promisify(execFile); + +const A1111_FIXTURE = 'assets/06-ai-a1111.png'; +const INVOKEAI_FIXTURE = 'assets/06-ai-invokeai.png'; +const BASE_AI = 'assets/04-ai.png'; + +let exiftoolAvailable = false; +try { + const r = await execFileP('exiftool', ['-ver']); + exiftoolAvailable = /^\d+\.\d+/.test(r.stdout.trim()); +} catch { + exiftoolAvailable = false; +} + +const tool = new ExifTool(); + +async function referenceJSON(file: string): Promise> { + const { stdout } = await execFileP('exiftool', ['-j', file]); + return JSON.parse(stdout)[0] as Record; +} + +async function ourJSON(file: string): Promise> { + const info = await tool.read(file); + return JSON.parse(formatJSON([info]))[0] as Record; +} + +/** Reference-equal assertion for a set of tag keys on one file. */ +async function assertReferenceEqual( + file: string, + keys: string[], +): Promise { + const [real, ours] = await Promise.all([referenceJSON(file), ourJSON(file)]); + const norm = (v: unknown) => (Array.isArray(v) ? JSON.stringify(v) : String(v)); + for (const key of keys) { + assertEquals( + key in ours, + true, + `${file}: key ${key} missing from our output`, + ); + assertEquals( + norm(ours[key]), + norm(real[key]), + `${file}: ${key} differs from reference`, + ); + } +} + +if (!exiftoolAvailable) { + test('ai-parity: skipped — exiftool not installed', () => { + console.log('ai-parity tests skipped (exiftool missing)'); + }); +} else { + test('ai-parity: A1111 parameters chunk surfaces Parameters reference-equal', async () => { + await assertReferenceEqual(A1111_FIXTURE, ['Parameters']); + const ours = await ourJSON(A1111_FIXTURE); + // The parsed parameters string carries prompt/seed/steps from the chunk. + const params = String(ours['Parameters']); + assertEquals(params.includes('prompt: a viking mug of beer, oil painting'), true); + assertEquals(params.includes('Seed: 12345'), true); + assertEquals(params.includes('Steps: 20'), true); + }); + + test('ai-parity: InvokeAI XMP metadata/graph + DMI + History flatten reference-equal', async () => { + await assertReferenceEqual(INVOKEAI_FIXTURE, [ + 'Metadata', + 'Graph', + 'DigitalSourceType', + 'HistoryAction', + 'HistorySoftwareAgent', + 'HistoryWhen', + ]); + // The raw History container never surfaces. + const ours = await ourJSON(INVOKEAI_FIXTURE); + assertEquals('History' in ours, false); + }); + + test('ai-parity: base AI PNG IHDR fields and XMP reader path reference-equal', async () => { + await assertReferenceEqual(BASE_AI, [ + 'Filter', + 'Interlace', + 'SRGBRendering', + 'Description', + 'CreatorTool', + 'UserComment', + ]); + }); +} diff --git a/src/exif/xmp.test.ts b/src/exif/xmp.test.ts index 81c92d1..474dcbe 100644 --- a/src/exif/xmp.test.ts +++ b/src/exif/xmp.test.ts @@ -39,10 +39,9 @@ test('parseXMP extracts attribute-form properties and skips xmlns/rdf/x attribut test('parseXMP parses self-closing attribute-form rdf:Description', () => { const xml = xmpDoc(``); - // Quirk: the self-closing-description pass skips rdf:-prefixed attributes but - // NOT xmlns:-prefixed ones, so an inline namespace declaration leaks through - // under its local name. Pinned as-is. - assertEquals(parseXMP(xml), { Make: 'Sony', Model: 'A7 IV', tiff: 'urn:tiff' }); + // Namespace declarations never surface as tags (reference exiftool + // behavior); every attribute pass skips xmlns:/x:/xml:-prefixed names. + assertEquals(parseXMP(xml), { Make: 'Sony', Model: 'A7 IV' }); }); test('parseXMP maps rdf:Bag with multiple items to an array', () => { @@ -212,16 +211,16 @@ test('parseXMP parses simple (non-list) elements like photoshop:DateCreated', () }); }); -test('parseXMP skips x-prefixed description attributes and strips history bookkeeping keys', () => { +test('parseXMP skips x-prefixed description attributes; unknown-namespace locals capitalize', () => { const xml = ` `; assertEquals(parseXMP(xml), { - xmptk: 'XMP Core 6.0', Title: 'T', XMPToolkit: 'XMP Core 6.0', + Action: 'converted', }); }); diff --git a/src/exif/xmp.ts b/src/exif/xmp.ts index 4b4dc13..06abbaa 100644 --- a/src/exif/xmp.ts +++ b/src/exif/xmp.ts @@ -326,6 +326,28 @@ export function parseXMP(_xml: string): Record { 'stEvt:parameters': 'HistoryParameters', 'stEvt:softwareAgent': 'HistorySoftwareAgent', 'stEvt:when': 'HistoryWhen', + // AI-forensics surface (Kelp): unknown-namespace locals already + // capitalize correctly, but pin the contract entries explicitly. + 'exif:UserComment': 'UserComment', + 'invokeai:metadata': 'Metadata', + 'invokeai:graph': 'Graph', + 'dmi:DigitalSourceType': 'DigitalSourceType', + 'Iptc4xmpExt:DigitalSourceType': 'DigitalSourceType', + // GPano panorama namespace (flat remap only — no compositing). + 'GPano:CroppedAreaLeftPixels': 'CroppedAreaLeftPixels', + 'GPano:CroppedAreaTopPixels': 'CroppedAreaTopPixels', + 'GPano:CroppedAreaImageWidthPixels': 'CroppedAreaImageWidthPixels', + 'GPano:CroppedAreaImageHeightPixels': 'CroppedAreaImageHeightPixels', + 'GPano:FullPanoWidthPixels': 'FullPanoWidthPixels', + 'GPano:FullPanoHeightPixels': 'FullPanoHeightPixels', + 'GPano:UsePanoramaViewer': 'UsePanoramaViewer', + 'IGPano:CroppedAreaLeft': 'CroppedAreaLeftPixels', + 'IGPano:CroppedAreaTop': 'CroppedAreaTopPixels', + 'IGPano:CroppedAreaWidth': 'CroppedAreaImageWidthPixels', + 'IGPano:CroppedAreaHeight': 'CroppedAreaImageHeightPixels', + 'IGPano:FullPanoWidth': 'FullPanoWidthPixels', + 'IGPano:FullPanoHeight': 'FullPanoHeightPixels', + 'IGPano:UsePanoramaViewer': 'UsePanoramaViewer', }; // MaskGroup flattening semantics (verified against exiftool on multi-mask @@ -383,7 +405,9 @@ export function parseXMP(_xml: string): Record { function mapTagName(prefix: string, local: string): string { const fullKey = `${prefix}:${local}`; if (TAG_REMAP[fullKey]) return TAG_REMAP[fullKey]; - return local; + // Unmapped properties (unknown namespaces like InvokeAI, DMI, GPano) + // surface with the local name capitalized, matching reference exiftool. + return local.charAt(0).toUpperCase() + local.slice(1); } /** @@ -434,7 +458,11 @@ export function parseXMP(_xml: string): Record { if (defaultVal) result[tagName] = defaultVal; } else { if (items.length === 1) { - result[tagName] = items[0]; + // Struct-content items (rdf:parseType="Resource", e.g. + // xmpMM:History) are flattened field-by-field by the child + // passes; the container itself is never emitted (exiftool + // behavior). Only store plain text items. + if (!items[0].startsWith('<')) result[tagName] = items[0]; } else { const existing = result[tagName]; if (existing) { @@ -550,7 +578,8 @@ export function parseXMP(_xml: string): Record { let attrMatch2: RegExpExecArray | null; while ((attrMatch2 = attrPattern2.exec(structMatch[1])) !== null) { const [_, fullName, value] = attrMatch2; - if (fullName.startsWith('rdf:')) continue; + if (fullName.startsWith('rdf:') || fullName.startsWith('xmlns:') || + fullName.startsWith('x:') || fullName.startsWith('xml:')) continue; const parsed = lookupPrefix(fullName); const tagName = mapTagName(parsed.prefix, parsed.local); @@ -605,7 +634,8 @@ export function parseXMP(_xml: string): Record { let attrMatch3: RegExpExecArray | null; while ((attrMatch3 = attrPattern3.exec(liMatch[1])) !== null) { const [_, fullName, value] = attrMatch3; - if (fullName.startsWith('rdf:')) continue; + if (fullName.startsWith('rdf:') || fullName.startsWith('xmlns:') || + fullName.startsWith('x:') || fullName.startsWith('xml:')) continue; const parsed = lookupPrefix(fullName); const tagName = mapTagName(parsed.prefix, parsed.local); @@ -668,11 +698,6 @@ export function parseXMP(_xml: string): Record { if (Array.isArray(result['MaskGroupBasedCorrMaskMasksDabs'])) { result['MaskGroupBasedCorrMaskMasksDabs'] = (result['MaskGroupBasedCorrMaskMasksDabs'] as string[]).join(','); } - for (const key of Object.keys(result)) { - if (key === 'XMP' || key === 'action' || key === 'changed' || key === 'instanceID' || key === 'parameters' || key === 'softwareAgent' || key === 'when' || key === 'lang' || key === 'pick' || key === 'Parameters' || key === 'Look') { - delete result[key]; - } - } // ExifTool prints XMP booleans lowercase, trims numeric tails and // renders ISO dates in EXIF style; normalize to match. @@ -705,7 +730,7 @@ export function parseXMP(_xml: string): Record { // XMP rationals ("39/100"): exiftool renders them as plain floats. const [, num, den] = v.match(/^(-?\d+)\/(\d+)$/) as RegExpMatchArray; if (Number(den) !== 0) result[k] = String(Number(num) / Number(den)); - } else if (/^\d{4}-\d{2}-\d{2}T[0-9:+.-]+$/.test(v)) { + } else if (/^\d{4}-\d{2}-\d{2}T[0-9:+.\-Z]+$/.test(v)) { result[k] = v.replace(/^([\d-]+)T/, (_, d) => d.replaceAll('-', ':') + ' '); } } else if (Array.isArray(v)) { @@ -716,6 +741,20 @@ export function parseXMP(_xml: string): Record { ); } } + // GPano panorama tags: exiftool's GPano table types the pixel-extent + // fields as integers, so -j emits numbers. + const GPANO_INT_TAGS = [ + 'CroppedAreaLeftPixels', + 'CroppedAreaTopPixels', + 'CroppedAreaImageWidthPixels', + 'CroppedAreaImageHeightPixels', + 'FullPanoWidthPixels', + 'FullPanoHeightPixels', + ]; + for (const tag of GPANO_INT_TAGS) { + const v = result[tag]; + if (typeof v === 'string' && /^-?\d+$/.test(v)) result[tag] = Number(v); + } // PerspectiveUpright: exiftool renders the crs enum with words. if (result['PerspectiveUpright'] !== undefined) { const enumMap: Record = { diff --git a/src/format/png.test.ts b/src/format/png.test.ts index 3e56493..b645d9e 100644 --- a/src/format/png.test.ts +++ b/src/format/png.test.ts @@ -2,6 +2,7 @@ import { test } from 'vitest'; import { assertEquals } from '../../src/test/asserts.js'; import { detectParser } from './mod.js'; import { pngParser } from './png.js'; +import { deflateSync } from 'node:zlib'; const encoder = new TextEncoder(); @@ -118,7 +119,7 @@ test('png iCCP without name NUL yields no tag', async () => { test('png tEXt keyword/value split on first NUL', async () => { const payload = encoder.encode('Title\0A Scene'); const result = await pngParser.parse(png(chunk('tEXt', payload), chunk('IEND', new Uint8Array(0))), 'text.png'); - assertEquals(result.tags['PNG_Title'], 'A Scene'); + assertEquals(result.tags['Title'], 'A Scene'); }); test('png tEXt without separator or leading NUL is skipped', async () => { @@ -127,8 +128,15 @@ test('png tEXt without separator or leading NUL is skipped', async () => { png(chunk('tEXt', new Uint8Array([0, 0x41])), chunk('IEND', new Uint8Array(0))), 'b.png', ); - assertEquals(Object.keys(noSep.tags).filter((k) => k.startsWith('PNG_')).length, 0); - assertEquals(Object.keys(leadNul.tags).filter((k) => k.startsWith('PNG_')).length, 0); + assertEquals(noSep.tags['Title'], undefined); + assertEquals(leadNul.tags['Title'], undefined); +}); + +test('png zTXt inflates to bare keyword tag', async () => { + const compressed = deflateSync(Buffer.from('hello compressed')); + const payload = concat(encoder.encode('Comment\0'), new Uint8Array([0]), new Uint8Array(compressed)); + const result = await pngParser.parse(png(chunk('zTXt', payload), chunk('IEND', new Uint8Array(0))), 'ztxt.png'); + assertEquals(result.tags['Comment'], 'hello compressed'); }); test('png iTXt uncompressed text extracted', async () => { diff --git a/src/format/png.ts b/src/format/png.ts index 6e86022..5395db4 100644 --- a/src/format/png.ts +++ b/src/format/png.ts @@ -6,6 +6,7 @@ import { parseTiff } from '../exif/tiff.js'; import { parseXMP } from '../exif/xmp.js'; import { computeCompositeTags } from '../exif/composite.js'; import { parseJUMBFFromSegment } from './jumbf.js'; +import { inflateSync } from 'node:zlib'; const PNG_HEADER = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); function readChunk( @@ -39,6 +40,9 @@ function readChunk( }, parse(bytes: Uint8Array, filePath: string, tagDb?: TagDb, hints?: ParseHints): Promise { const result: Record = {}; + // PNG text-chunk tags (tEXt/iTXt/zTXt) have the lowest precedence: + // EXIF/eXIf-derived and XMP-derived keys always win, text chunks yield. + const textTags: Record = {}; let offset = 8; while (offset < bytes.length) { @@ -61,8 +65,12 @@ function readChunk( const ct = view.getUint8(9); result['ColorType'] = colorTypes[ct] ?? String(ct); result['Compression'] = view.getUint8(10) === 0 ? 'Deflate/Inflate' : 'Unknown'; - result['FilterMethod'] = view.getUint8(11); - result['InterlaceMethod'] = view.getUint8(12); + result['Filter'] = view.getUint8(11) === 0 ? 'Adaptive' : 'Unknown'; + result['Interlace'] = view.getUint8(12) === 0 + ? 'Noninterlaced' + : view.getUint8(12) === 1 + ? 'Adam7' + : 'Unknown'; } if (chunk.type === 'eXIf') { @@ -84,7 +92,22 @@ function readChunk( if (nullIdx > 0) { const key = new TextDecoder().decode(chunk.data.slice(0, nullIdx)); const val = new TextDecoder().decode(chunk.data.slice(nullIdx + 1)); - result[`PNG_${key}`] = val; + textTags[key] = val; + } + } + + if (chunk.type === 'zTXt') { + // zTXt: keyword NUL compressionMethod deflateData. Node parser layer + // uses zlib (the compression method byte must be 0 = zlib/deflate). + const nullIdx = chunk.data.indexOf(0); + if (nullIdx > 0 && chunk.data[nullIdx + 1] === 0) { + const key = new TextDecoder().decode(chunk.data.slice(0, nullIdx)); + try { + const val = inflateSync(chunk.data.slice(nullIdx + 2)); + textTags[key] = new TextDecoder().decode(val); + } catch { + // Malformed compressed stream: skip, like exiftool ignoring it. + } } } @@ -121,7 +144,16 @@ function readChunk( } continue; } - result[keyword] = text; + textTags[keyword] = text; + } + if (chunk.type === 'sRGB') { + const intents: Record = { + 0: 'Perceptual', + 1: 'Relative Colorimetric', + 2: 'Saturation', + 3: 'Absolute Colorimetric', + }; + result['SRGBRendering'] = intents[chunk.data[0]] ?? String(chunk.data[0]); } if (chunk.type === 'gAMA') { const view = new DataView(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength); @@ -144,6 +176,14 @@ function readChunk( if (chunk.type === 'IEND') break; } + // PNG text-chunk keywords surface capitalized (exiftool behavior: + // tEXt keyword "parameters" reads as Parameters). EXIF/eXIf- and + // XMP-derived keys always win, text chunks yield. + for (const [k, v] of Object.entries(textTags)) { + const name = k.charAt(0).toUpperCase() + k.slice(1); + if (!(name in result)) result[name] = v; + } + computeCompositeTags(result); return Promise.resolve({ path: filePath, format: 'PNG', tags: result }); }, diff --git a/src/write/writers.ts b/src/write/writers.ts index 5fee6a7..fec7b89 100644 --- a/src/write/writers.ts +++ b/src/write/writers.ts @@ -160,6 +160,9 @@ export const pngWriter: ContainerWriter = (original, tags) => { } u32be(out, 0); for (const c of 'IEND') out.push(c.charCodeAt(0)); + const iendCrc = new Uint8Array(4); + iendCrc[0] = 0x49; iendCrc[1] = 0x45; iendCrc[2] = 0x4e; iendCrc[3] = 0x44; + u32be(out, crc32(iendCrc)); return { bytes: new Uint8Array(out), written, skipped }; };