Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .omp/config.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions assets/06-ai-a1111.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/06-ai-invokeai.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
103 changes: 103 additions & 0 deletions src/ai-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> {
const { stdout } = await execFileP('exiftool', ['-j', file]);
return JSON.parse(stdout)[0] as Record<string, unknown>;
}

async function ourJSON(file: string): Promise<Record<string, unknown>> {
const info = await tool.read(file);
return JSON.parse(formatJSON([info]))[0] as Record<string, unknown>;
}

/** Reference-equal assertion for a set of tag keys on one file. */
async function assertReferenceEqual(
file: string,
keys: string[],
): Promise<void> {
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',
]);
});
}
11 changes: 5 additions & 6 deletions src/exif/xmp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<rdf:Description xmlns:tiff="urn:tiff" tiff:Make="Sony" tiff:Model="A7 IV"/>`);
// 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', () => {
Expand Down Expand Up @@ -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 = `<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 6.0">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" x:xmptk="XMP Core 6.0" acme:action="converted" dc:title="T"/>
</rdf:RDF>
</x:xmpmeta>`;
assertEquals(parseXMP(xml), {
xmptk: 'XMP Core 6.0',
Title: 'T',
XMPToolkit: 'XMP Core 6.0',
Action: 'converted',
});
});

Expand Down
59 changes: 49 additions & 10 deletions src/exif/xmp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,28 @@ export function parseXMP(_xml: string): Record<string, TagValue> {
'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
Expand Down Expand Up @@ -383,7 +405,9 @@ export function parseXMP(_xml: string): Record<string, TagValue> {
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);
}

/**
Expand Down Expand Up @@ -434,7 +458,11 @@ export function parseXMP(_xml: string): Record<string, TagValue> {
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) {
Expand Down Expand Up @@ -550,7 +578,8 @@ export function parseXMP(_xml: string): Record<string, TagValue> {
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);
Expand Down Expand Up @@ -605,7 +634,8 @@ export function parseXMP(_xml: string): Record<string, TagValue> {
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);

Expand Down Expand Up @@ -668,11 +698,6 @@ export function parseXMP(_xml: string): Record<string, TagValue> {
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.
Expand Down Expand Up @@ -705,7 +730,7 @@ export function parseXMP(_xml: string): Record<string, TagValue> {
// 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)) {
Expand All @@ -716,6 +741,20 @@ export function parseXMP(_xml: string): Record<string, TagValue> {
);
}
}
// 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<string, string> = {
Expand Down
14 changes: 11 additions & 3 deletions src/format/png.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Loading
Loading