diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 9cf262b1..f44f20c1 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -13,6 +13,21 @@ ffmpeg -f lavfi -i testsrc=size=32x32:rate=4:duration=1 \ Browser tests use the committed file; FFmpeg is not required to run them. +# Local Inspect header fixtures + +`local-inspect/tiny.jpg` (48x32) and `local-inspect/tiny.webp` (40x24) are original +synthetic FFmpeg color test patterns, under 4 KB, dedicated to the public domain +under CC0-1.0. `tests/js/png-local-reader.test.js` reads their dimensions with the +browser header reader and `tests/test_local_inspect_parity.py` checks the same two +files against Pillow. + +Reproduce with FFmpeg: + +```sh +ffmpeg -f lavfi -i testsrc=size=48x32:rate=1 -frames:v 1 -q:v 10 tiny.jpg +ffmpeg -f lavfi -i testsrc=size=40x24:rate=1 -frames:v 1 -c:v libwebp -quality 40 tiny.webp +``` + # Inspection parity fixtures `inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each diff --git a/tests/fixtures/local-inspect/tiny.jpg b/tests/fixtures/local-inspect/tiny.jpg new file mode 100644 index 00000000..da7ad9c4 Binary files /dev/null and b/tests/fixtures/local-inspect/tiny.jpg differ diff --git a/tests/fixtures/local-inspect/tiny.webp b/tests/fixtures/local-inspect/tiny.webp new file mode 100644 index 00000000..3e266983 Binary files /dev/null and b/tests/fixtures/local-inspect/tiny.webp differ diff --git a/tests/js/helpers/png-bytes.mjs b/tests/js/helpers/png-bytes.mjs new file mode 100644 index 00000000..ca5f5811 --- /dev/null +++ b/tests/js/helpers/png-bytes.mjs @@ -0,0 +1,148 @@ +// Byte-exact PNG builders for the local reader tests (real CRC32 and zlib), and +// File-like blobs: one that records every [start, end) slice over a sparse layout +// that is never allocated, one that answers slowly, one that fails or shrinks. +import zlib from "node:zlib"; + +export const SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export function u32(n) { + return Uint8Array.from([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); +} + +export function latin1(text) { + return Buffer.from(text, "latin1"); +} + +export function utf8(text) { + return Buffer.from(text, "utf8"); +} + +export function concat(...parts) { + return Uint8Array.from(Buffer.concat(parts.map((part) => Buffer.from(part)))); +} + +/** + * One chunk with a real CRC32 over type + data. `crc` overrides the checksum and + * `declaredLength` the length field (to declare more data than is present). + */ +export function chunk(type, data, { crc, declaredLength } = {}) { + const typeBytes = latin1(type); + const body = Buffer.from(data); + const sum = crc ?? zlib.crc32(Buffer.concat([typeBytes, body])); + return concat(u32(declaredLength ?? body.length), typeBytes, body, u32(sum)); +} + +export function ihdr(width = 1, height = 1, options) { + return chunk("IHDR", concat(u32(width), u32(height), [8, 6, 0, 0, 0]), options); +} + +export function idat(data = [0]) { + return chunk("IDAT", data); +} + +export function iend(options) { + return chunk("IEND", [], options); +} + +/** tEXt: keyword NUL text, Latin-1 (text may be a string or raw bytes). */ +export function tEXt(keyword, text, options) { + const body = typeof text === "string" ? latin1(text) : Buffer.from(text); + return chunk("tEXt", concat(latin1(keyword), [0], body), options); +} + +/** zTXt: keyword NUL method zlib(text). `compressed` replaces the deflated body. */ +export function zTXt(keyword, text, { method = 0, compressed, level = 6 } = {}) { + const body = compressed ?? zlib.deflateSync(typeof text === "string" ? latin1(text) : Buffer.from(text), { level }); + return chunk("zTXt", concat(latin1(keyword), [0, method], body)); +} + +/** iTXt: keyword NUL flag method lang NUL translated NUL text (UTF-8, deflated when flag). */ +export function iTXt(keyword, text, { flag = 0, method = 0, lang = "", translated = "", compressed } = {}) { + const raw = typeof text === "string" ? utf8(text) : Buffer.from(text); + const body = compressed ?? (flag ? zlib.deflateSync(raw) : raw); + const langBytes = typeof lang === "string" ? latin1(lang) : Buffer.from(lang); + const translatedBytes = typeof translated === "string" ? utf8(translated) : Buffer.from(translated); + return concat(chunk("iTXt", concat(latin1(keyword), [0, flag, method], langBytes, [0], translatedBytes, [0], body))); +} + +/** A whole PNG: signature, IHDR, IDAT, the given chunks, IEND. */ +export function png(...chunks) { + return concat(SIGNATURE, ihdr(), idat(), ...chunks, iend()); +} + +/** Signature plus exactly the given chunks (no implied IHDR/IDAT/IEND). */ +export function raw(...chunks) { + return concat(SIGNATURE, ...chunks); +} + +export function blobOf(bytes) { + return new Blob([bytes]); +} + +/** + * A File-like object over a sparse layout: `parts` are [{offset, bytes}] placed in a + * file of `size` bytes, zero everywhere else; nothing of `size` is allocated. Every + * slice is recorded in `ranges` as [start, end). + */ +export function sparseBlob(parts, size) { + const ranges = []; + function bytesOf(start, end) { + const out = new Uint8Array(end - start); + for (const part of parts) { + const from = Math.max(start, part.offset); + const to = Math.min(end, part.offset + part.bytes.length); + if (from < to) out.set(part.bytes.subarray(from - part.offset, to - part.offset), from - start); + } + return out; + } + return { + size, + ranges, + slice(start, end) { + ranges.push([start, end]); + return { arrayBuffer: async () => bytesOf(start, end).buffer }; + }, + }; +} + +/** A recording blob over real bytes. */ +export function recordingBlob(bytes) { + return sparseBlob([{ offset: 0, bytes }], bytes.length); +} + +/** Every slice resolves after `delayMs`. */ +export function slowBlob(bytes, delayMs) { + return { + size: bytes.length, + slice(start, end) { + return { + arrayBuffer: () => new Promise((resolve) => setTimeout(() => resolve(bytes.slice(start, end).buffer), delayMs)), + }; + }, + }; +} + +/** Slices fail (as a browser does when the file vanished). */ +export function failingBlob(size) { + return { + size, + slice() { + return { arrayBuffer: () => Promise.reject(new Error("NotReadableError")) }; + }, + }; +} + +/** Reports `size` but every slice beyond `actual` bytes comes back short. */ +export function shrunkBlob(bytes, size) { + return { + size, + slice(start, end) { + return { arrayBuffer: async () => bytes.slice(start, Math.min(end, bytes.length)).buffer }; + }, + }; +} + +/** An A1111 `parameters` record with one settings footer carrying a batch index. */ +export function parametersText(positive, batchIndex, { seedField = "Seed: 1" } = {}) { + return `${positive}\nNegative prompt: blurry\nSteps: 20, Sampler: Euler, Batch index: ${batchIndex}, ${seedField}`; +} diff --git a/tests/js/png-local-reader.test.js b/tests/js/png-local-reader.test.js new file mode 100644 index 00000000..dc0aedb1 --- /dev/null +++ b/tests/js/png-local-reader.test.js @@ -0,0 +1,549 @@ +// Run with: node --test tests/js/ +// The browser PNG File reader is a port of utils/prompt_inspector.py's bounded +// reader (_png_metadata, _PngRecords, _png_text, _inflate, _batch_normalized). Each +// rule below names the Python branch it mirrors; tests/test_local_inspect_parity.py +// proves the two agree on the committed parity corpus. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const zlib = require("node:zlib"); +const { classifyPrefix, readPngMetadataFile, readImageHeader } = require("../../web/js/png-metadata.js"); +const bytes = require("./helpers/png-bytes.mjs"); + +const { png, raw, ihdr, idat, iend, tEXt, zTXt, iTXt, chunk, blobOf, sparseBlob, recordingBlob, slowBlob, failingBlob, shrunkBlob, concat, u32, latin1, parametersText } = bytes; + +const MiB = 1024 * 1024; +const ROLE_BUDGET = 2 * MiB; +const WORKFLOW_BUDGET = 4 * MiB; +const PNG_READ_CEILING = 19794389; +const FIXTURES = path.join(__dirname, "..", "fixtures", "local-inspect"); + +async function read(fileBytes, options) { + return readPngMetadataFile(blobOf(fileBytes), options); +} + +function fill(byte, count) { + return Buffer.alloc(count, byte); +} + +test("contract: the reader, classifier and header reader are exported", () => { + assert.equal(typeof readPngMetadataFile, "function"); + assert.equal(typeof classifyPrefix, "function"); + assert.equal(typeof readImageHeader, "function"); +}); + +test("reads the four retained records and the IHDR size from a core SaveImage PNG", async () => { + const result = await read(raw( + ihdr(640, 480), + idat(), + tEXt("prompt", '{"1":{"class_type":"KSampler"}}'), + iTXt("workflow", '{"nodes":[]}'), + tEXt("parameters", "cat\nSteps: 20, Seed: 1"), + zTXt("comfyassets_prompt_pairs", '{"pairs":[]}'), + tEXt("Software", "ComfyUI"), + iend(), + )); + assert.deepEqual(result, { + status: "complete", + records: { + prompt: '{"1":{"class_type":"KSampler"}}', + workflow: '{"nodes":[]}', + parameters: "cat\nSteps: 20, Seed: 1", + comfyassets_prompt_pairs: '{"pairs":[]}', + }, + limited: [], + width: 640, + height: 480, + }); +}); + +test("local PNG skips sparse pixels and reads post-IDAT text", async () => { + // Two 1.5 GiB IDAT chunks (each under the 0x7FFFFFFF chunk ceiling) with the + // prompt after them: nothing of the 3 GiB is read or allocated. + const half = 1.5 * 1024 * 1024 * 1024; + const head = concat(bytes.SIGNATURE, ihdr(8192, 8192), u32(half), latin1("IDAT")); + const idat1 = { start: bytes.SIGNATURE.length + ihdr().length, end: bytes.SIGNATURE.length + ihdr().length + 8 + half + 4 }; + const idat2 = { start: idat1.end, end: idat1.end + 8 + half + 4 }; + const between = concat(u32(0), u32(half), latin1("IDAT")); + const tail = concat(u32(0), tEXt("prompt", '{"3":{"class_type":"KSampler"}}'), iend()); + const size = idat2.end - 4 + tail.length; + const file = sparseBlob([ + { offset: 0, bytes: head }, + { offset: idat1.end - 4, bytes: between }, + { offset: idat2.end - 4, bytes: tail }, + ], size); + + const result = await readPngMetadataFile(file); + + assert.equal(result.status, "complete"); + assert.deepEqual(result.records, { prompt: '{"3":{"class_type":"KSampler"}}' }); + assert.deepEqual([result.width, result.height], [8192, 8192]); + const insideIdat = file.ranges.filter(([start, end]) => [idat1, idat2].some((region) => start >= region.start && end <= region.end)); + assert.ok(insideIdat.length >= 2, "the IDAT headers were read"); + for (const [start, end] of insideIdat) assert.ok(end - start <= 12, `range inside IDAT longer than 12: [${start}, ${end})`); + const total = file.ranges.reduce((sum, [start, end]) => sum + (end - start), 0); + assert.ok(total <= PNG_READ_CEILING, `read ${total} bytes`); +}); + +test("serves the retained prefix without slicing it again", async () => { + const whole = png(tEXt("prompt", "p")); + const file = recordingBlob(whole); + const result = await readPngMetadataFile(file, { prefix: whole.subarray(0, 32) }); + assert.equal(result.status, "complete"); + assert.ok(file.ranges.every(([start]) => start >= 32), JSON.stringify(file.ranges)); + assert.deepEqual(result.records, { prompt: "p" }); +}); + +test("unknown text keys are skipped by length without reading their body", async () => { + const body = fill(0x41, 1000); + const whole = png(tEXt("Software", body), tEXt("prompt", "p")); + const softwareStart = bytes.SIGNATURE.length + ihdr().length + idat().length; + const bodyStart = softwareStart + 8 + "Software".length + 1; + const bodyEnd = bodyStart + body.length + 4; + const file = recordingBlob(whole); + const result = await readPngMetadataFile(file); + assert.equal(result.status, "complete"); + const insideBody = file.ranges.filter(([start, end]) => start < bodyEnd && end > softwareStart + 8 + 80); + assert.deepEqual(insideBody, [], "the body past the 80-byte keyword prefix was sliced"); +}); + +const WF_OVER = WORKFLOW_BUDGET + 1; +const bigWorkflow = () => tEXt("workflow", fill(0x7b, WF_OVER - "workflow".length - 1)); + +// One row per Python branch (prompt_inspector.py:91-260). +const RULES = [ + { name: "bad IHDR CRC", file: () => raw(ihdr(1, 1, { crc: 1 }), idat(), iend()), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "bad text CRC", file: () => png(tEXt("prompt", "p", { crc: 1 })), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "bad IEND CRC", file: () => raw(ihdr(), idat(), iend({ crc: 1 })), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "first chunk not IHDR", file: () => raw(idat(), ihdr(), iend()), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "IHDR not 13 bytes", file: () => raw(chunk("IHDR", concat(u32(1), u32(1), [8, 6, 0, 0])), idat(), iend()), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "second IHDR", file: () => raw(ihdr(), idat(), ihdr(), iend()), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "zero width", file: () => raw(ihdr(0, 1), idat(), iend()), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "zero height", file: () => raw(ihdr(1, 0), idat(), iend()), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "length 0x80000000", file: () => raw(ihdr(), idat(), chunk("tEXt", latin1("k\0v"), { declaredLength: 0x80000000 }), iend()), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "end past size", file: () => raw(ihdr(), idat(), chunk("tEXt", latin1("k\0v"), { declaredLength: 100 }), iend()), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "file ends inside a chunk header", file: () => raw(ihdr(), idat(), u32(5)), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "file ends before any chunk", file: () => raw(), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "too short for a signature", file: () => bytes.SIGNATURE.subarray(0, 5), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "not a PNG", file: () => latin1("GIF89a................................."), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "IEND without IDAT", file: () => raw(ihdr(), tEXt("prompt", "p"), iend()), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "nonzero IEND length", file: () => raw(ihdr(), idat(), chunk("IEND", [1])), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "duplicate key with a different length", file: () => png(tEXt("prompt", "one"), tEXt("prompt", "other")), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "duplicate key, same length, different bytes", file: () => png(tEXt("prompt", "one"), tEXt("prompt", "two")), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "duplicate key, same bytes, different chunk type", file: () => png(tEXt("prompt", "one"), chunk("zTXt", latin1("prompt\0one"))), expect: { status: "unreadable", reason: "invalid-png" } }, + { + name: "byte-identical duplicate kept once", + file: () => png(tEXt("prompt", "same"), tEXt("prompt", "same"), tEXt("prompt", "same")), + expect: { status: "complete", records: { prompt: "same" }, limited: [], width: 1, height: 1 }, + }, + { + name: "parameters copies differing only in Batch index keep the last", + file: () => png(tEXt("parameters", parametersText("cat", 0)), tEXt("parameters", parametersText("cat", 1)), tEXt("parameters", parametersText("cat", 12))), + expect: { status: "complete", records: { parameters: parametersText("cat", 12) }, limited: [], width: 1, height: 1 }, + }, + { name: "parameters copies with any other difference", file: () => png(tEXt("parameters", parametersText("cat", 0)), tEXt("parameters", parametersText("dog", 1))), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "parameters copies without a settings footer", file: () => png(tEXt("parameters", "cat"), tEXt("parameters", "dog")), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "parameters copies with two footers", file: () => png(tEXt("parameters", `${parametersText("cat", 0)}\nSteps: 1, x`), tEXt("parameters", `${parametersText("cat", 1)}\nSteps: 1, x`)), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "parameters copies with two batch indexes", file: () => png(tEXt("parameters", parametersText("cat", 0, { seedField: "Batch index: 0" })), tEXt("parameters", parametersText("cat", 1, { seedField: "Batch index: 1" }))), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "parameters copies whose batch index is not a whole settings field", file: () => png(tEXt("parameters", "Steps: 20, Batch index: 0 trailing"), tEXt("parameters", "Steps: 20, Batch index: 1 trailing")), expect: { status: "unreadable", reason: "invalid-png" } }, + { + name: "workflow 4 MiB + 1 encoded is limited, other records intact", + file: () => png(tEXt("prompt", "p"), bigWorkflow(), tEXt("parameters", "cat\nSteps: 1, x")), + expect: { status: "complete", records: { prompt: "p", parameters: "cat\nSteps: 1, x" }, limited: ["workflow"], width: 1, height: 1 }, + }, + { + name: "a duplicate of a limited workflow is skipped", + file: () => png(bigWorkflow(), bigWorkflow(), tEXt("prompt", "p")), + expect: { status: "complete", records: { prompt: "p" }, limited: ["workflow"], width: 1, height: 1 }, + }, + { + name: "workflow over 4 MiB decoded (zTXt) is limited", + file: () => png(zTXt("workflow", fill(0x7b, WORKFLOW_BUDGET + 1), { level: 9 }), tEXt("prompt", "p")), + expect: { status: "complete", records: { prompt: "p" }, limited: ["workflow"], width: 1, height: 1 }, + }, + { + name: "workflow at exactly 4 MiB decoded is kept", + file: () => png(zTXt("workflow", fill(0x7b, WORKFLOW_BUDGET), { level: 9 })), + check: (result) => { + assert.equal(result.status, "complete"); + assert.equal(result.records.workflow.length, WORKFLOW_BUDGET); + assert.deepEqual(result.limited, []); + }, + }, + { + name: "workflow under 4 MiB inflated but over 4 MiB as UTF-8 is limited", + file: () => png(zTXt("workflow", fill(0xe9, WORKFLOW_BUDGET - 10), { level: 9 }), tEXt("prompt", "p")), + expect: { status: "complete", records: { prompt: "p" }, limited: ["workflow"], width: 1, height: 1 }, + }, + { name: "roles 2 MiB + 1 encoded", file: () => png(tEXt("prompt", fill(0x41, ROLE_BUDGET + 1 - "prompt".length - 1))), expect: { status: "unreadable", reason: "metadata-limit" } }, + { name: "roles summed over 2 MiB encoded", file: () => png(tEXt("prompt", fill(0x41, 1.5 * MiB)), tEXt("parameters", fill(0x41, 0.5 * MiB))), expect: { status: "unreadable", reason: "metadata-limit" } }, + { name: "roles 2 MiB + 1 decoded via zTXt", file: () => png(zTXt("prompt", fill(0x41, ROLE_BUDGET + 1), { level: 9 })), expect: { status: "unreadable", reason: "metadata-limit" } }, + { name: "roles over 2 MiB as UTF-8 from Latin-1 high bytes", file: () => png(tEXt("prompt", fill(0xe9, MiB + 1))), expect: { status: "unreadable", reason: "metadata-limit" } }, + { + name: "roles share the decoded budget across records", + file: () => png(zTXt("prompt", fill(0x41, 1.5 * MiB), { level: 9 }), zTXt("parameters", fill(0x41, 0.5 * MiB + 1), { level: 9 })), + expect: { status: "unreadable", reason: "metadata-limit" }, + }, + { + name: "roles at exactly 2 MiB decoded are kept", + file: () => png(zTXt("prompt", fill(0x41, 1.5 * MiB), { level: 9 }), zTXt("parameters", fill(0x41, 0.5 * MiB), { level: 9 })), + check: (result) => { + assert.equal(result.status, "complete"); + assert.equal(result.records.prompt.length + result.records.parameters.length, ROLE_BUDGET); + }, + }, + { + name: "duplicate workflow bytes capped at 8 MiB: the workflow becomes limited", + file: () => { + const workflow = tEXt("workflow", fill(0x7b, 3 * MiB)); + return png(workflow, workflow, workflow, workflow, tEXt("prompt", "p")); + }, + expect: { status: "complete", records: { prompt: "p" }, limited: ["workflow"], width: 1, height: 1 }, + }, + { + name: "duplicate workflow bytes within 8 MiB keep the workflow", + file: () => { + const workflow = tEXt("workflow", fill(0x7b, 3 * MiB)); + return png(workflow, workflow, workflow); + }, + check: (result) => { + assert.equal(result.status, "complete"); + assert.equal(result.records.workflow.length, 3 * MiB); + assert.deepEqual(result.limited, []); + }, + }, + { + name: "duplicate role bytes capped at 4 MiB", + file: () => { + const prompt = tEXt("prompt", fill(0x41, 1.5 * MiB)); + return png(prompt, prompt, prompt, prompt); + }, + expect: { status: "unreadable", reason: "metadata-limit" }, + }, + { + name: "duplicate decoded parameters capped at 4 MiB", + file: () => { + const copy = (index) => zTXt("parameters", parametersText(fill(0x41, 1.5 * MiB).toString("latin1"), index), { level: 9 }); + return png(copy(0), copy(1), copy(2), copy(3)); + }, + expect: { status: "unreadable", reason: "metadata-limit" }, + }, + { + name: "a longer batch copy draws the growth from the role budget", + file: () => { + const base = parametersText("", 9); + const positive = fill(0x41, ROLE_BUDGET - base.length - 1).toString("latin1"); + return png(zTXt("parameters", parametersText(positive, 9), { level: 9 }), zTXt("parameters", parametersText(positive, 100), { level: 9 })); + }, + expect: { status: "unreadable", reason: "metadata-limit" }, + }, + { + name: "a longer batch copy within the role budget is kept", + file: () => { + const base = parametersText("", 9); + const positive = fill(0x41, ROLE_BUDGET - base.length - 2).toString("latin1"); + return png(zTXt("parameters", parametersText(positive, 9), { level: 9 }), zTXt("parameters", parametersText(positive, 10), { level: 9 })); + }, + check: (result) => { + assert.equal(result.status, "complete"); + assert.ok(result.records.parameters.endsWith("Batch index: 10, Seed: 1")); + }, + }, + { + name: "a batch copy inflating past the role budget", + file: () => png(tEXt("parameters", parametersText("cat", 0)), zTXt("parameters", parametersText(fill(0x41, ROLE_BUDGET).toString("latin1"), 1), { level: 9 })), + expect: { status: "unreadable", reason: "metadata-limit" }, + }, + { + name: "10,000 chunks without IEND", + file: () => concat(bytes.SIGNATURE, ihdr(), idat(), ...Array.from({ length: 9999 }, () => chunk("xxXx", [])), iend()), + expect: { status: "unreadable", reason: "metadata-limit" }, + }, + { + name: "IEND as the 10,000th chunk is read", + file: () => concat(bytes.SIGNATURE, ihdr(), idat(), ...Array.from({ length: 9997 }, () => chunk("xxXx", [])), iend()), + expect: { status: "complete", records: {}, limited: [], width: 1, height: 1 }, + }, + { name: "zTXt method not 0", file: () => png(zTXt("prompt", "p", { method: 1 })), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "zTXt without a method byte", file: () => png(chunk("zTXt", latin1("prompt\0"))), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "iTXt flag 2", file: () => png(iTXt("prompt", "p", { flag: 2 })), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "iTXt compressed with method 1", file: () => png(iTXt("prompt", "p", { flag: 1, method: 1 })), expect: { status: "unreadable", reason: "invalid-png" } }, + { + name: "iTXt uncompressed ignores the method byte", + file: () => png(iTXt("prompt", "p", { flag: 0, method: 7 })), + expect: { status: "complete", records: { prompt: "p" }, limited: [], width: 1, height: 1 }, + }, + { name: "iTXt shorter than flag and method", file: () => png(chunk("iTXt", latin1("prompt\0\0"))), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "iTXt missing the translated keyword separator", file: () => png(chunk("iTXt", concat(latin1("prompt\0"), [0, 0], latin1("en\0text")))), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "iTXt non-ASCII language tag", file: () => png(iTXt("prompt", "p", { lang: [0xc3, 0xa9] })), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "iTXt invalid UTF-8 translated keyword", file: () => png(iTXt("prompt", "p", { translated: [0xff] })), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "iTXt invalid UTF-8 text", file: () => png(iTXt("prompt", [0xc3, 0x28])), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "iTXt encoded surrogate", file: () => png(iTXt("prompt", [0xed, 0xa0, 0x80])), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "NUL in tEXt text", file: () => png(tEXt("prompt", "a\0b")), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "NUL in inflated zTXt text", file: () => png(zTXt("prompt", "a\0b")), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "NUL in iTXt text", file: () => png(iTXt("prompt", "a\0b")), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "keyword of 0 bytes", file: () => png(chunk("tEXt", latin1("\0v"))), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "keyword of 80 bytes", file: () => png(tEXt("k".repeat(80), "v")), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "keyword without a NUL", file: () => png(chunk("tEXt", latin1("nonul"))), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "empty text chunk", file: () => png(chunk("tEXt", [])), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "malformed keyword on an unknown key still fails", file: () => png(tEXt("prompt", "p"), chunk("tEXt", latin1("x".repeat(100)))), expect: { status: "unreadable", reason: "invalid-png" } }, + { + name: "keyword of 79 bytes is read", + file: () => png(tEXt("k".repeat(79), "v"), tEXt("prompt", "p")), + expect: { status: "complete", records: { prompt: "p" }, limited: [], width: 1, height: 1 }, + }, + { + name: "Latin-1 0xE9 is an e-acute in tEXt and zTXt", + file: () => png(tEXt("prompt", [0xe9]), zTXt("parameters", [0xe9, 0x0a, 0x53, 0x74, 0x65, 0x70, 0x73, 0x3a, 0x20, 0x31])), + expect: { status: "complete", records: { prompt: "é", parameters: "é\nSteps: 1" }, limited: [], width: 1, height: 1 }, + }, + { + name: "iTXt BOM is preserved, compressed and not", + file: () => png(iTXt("prompt", "{}"), iTXt("workflow", "[]", { flag: 1, lang: "en", translated: "wörkflow" })), + expect: { status: "complete", records: { prompt: "{}", workflow: "[]" }, limited: [], width: 1, height: 1 }, + }, + { name: "truncated zlib data", file: () => png(zTXt("prompt", "x".repeat(500), { compressed: zlib.deflateSync(latin1("x".repeat(500))).subarray(0, 8) })), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "trailing bytes after the zlib stream", file: () => png(zTXt("prompt", "p", { compressed: concat(zlib.deflateSync(latin1("p")), [1, 2, 3]) })), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "empty zlib data", file: () => png(zTXt("prompt", "p", { compressed: [] })), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "garbage zlib data", file: () => png(zTXt("prompt", "p", { compressed: [1, 2, 3, 4, 5, 6, 7, 8] })), expect: { status: "unreadable", reason: "invalid-png" } }, + { name: "truncated compressed iTXt", file: () => png(iTXt("prompt", "p", { flag: 1, compressed: zlib.deflateSync(latin1("x".repeat(500))).subarray(0, 8) })), expect: { status: "unreadable", reason: "invalid-png" } }, + { + name: "chunks after IEND are ignored", + file: () => concat(png(tEXt("prompt", "p")), tEXt("workflow", "w")), + expect: { status: "complete", records: { prompt: "p" }, limited: [], width: 1, height: 1 }, + }, + { + name: "non-ASCII chunk types are skipped", + file: () => png(chunk("\xff\xfe\x80\x81", [1, 2, 3]), tEXt("prompt", "p")), + expect: { status: "complete", records: { prompt: "p" }, limited: [], width: 1, height: 1 }, + }, +]; + +for (const rule of RULES) { + test(`server rules, one by one: ${rule.name}`, async () => { + const result = await read(rule.file()); + if (rule.check) rule.check(result); + else assert.deepEqual(result, rule.expect); + }); +} + +test("decompress: undefined with zTXt is decompression-unavailable and yields no records", async () => { + const result = await read(png(tEXt("prompt", "p"), zTXt("workflow", "w")), { decompress: undefined }); + assert.deepEqual(result, { status: "unreadable", reason: "decompression-unavailable" }); + const plain = await read(png(tEXt("prompt", "p"), zTXt("Comment", "skipped")), { decompress: undefined }); + assert.deepEqual(plain.records, { prompt: "p" }); +}); + +function instrumentedStream(transform) { + const stats = { writes: [], inFlight: 0, maxInFlight: 0 }; + return { + stats, + Stream: class { + constructor() { + const stream = new TransformStream({ + async transform(chunk, controller) { + stats.writes.push(chunk.byteLength); + stats.inFlight += 1; + stats.maxInFlight = Math.max(stats.maxInFlight, stats.inFlight); + await transform(chunk, controller); + stats.inFlight -= 1; + }, + }); + this.writable = stream.writable; + this.readable = stream.readable; + } + }, + }; +} + +test("inflation keeps one <=256-byte write in flight", async () => { + const body = fill(0x41, 3000); + const { stats, Stream } = instrumentedStream(async (chunk, controller) => { + await new Promise((resolve) => setTimeout(resolve, 1)); + controller.enqueue(chunk); + }); + // The fake "inflates" by passing bytes through, so the stored body is the text. + const result = await read(png(chunk("zTXt", concat(latin1("prompt\0"), [0], body))), { decompress: Stream }); + assert.equal(result.status, "complete"); + assert.equal(result.records.prompt, body.toString("latin1")); + assert.ok(stats.writes.every((size) => size <= 256), JSON.stringify(stats.writes)); + assert.equal(stats.writes.reduce((sum, size) => sum + size, 0), body.length); + assert.equal(stats.maxInFlight, 1); +}); + +test("inflation settles once on abort with an AbortError and no unhandled rejection", async () => { + const unhandled = []; + const onUnhandled = (reason) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + const { stats, Stream } = instrumentedStream(() => new Promise(() => {})); + const controller = new AbortController(); + const pending = readPngMetadataFile(blobOf(png(zTXt("prompt", "p".repeat(1000)))), { decompress: Stream, signal: controller.signal }); + let settled = 0; + pending.then(() => settled++, () => settled++); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(settled, 0, "the read waits on the stream"); + controller.abort(); + await assert.rejects(pending, (error) => error.name === "AbortError"); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(settled, 1); + assert.equal(stats.maxInFlight, 1); + assert.deepEqual(unhandled, []); + } finally { + process.off("unhandledRejection", onUnhandled); + } +}); + +test("a stream error under a live signal is still invalid-png, not an abort", async () => { + const controller = new AbortController(); + const result = await read(png(zTXt("prompt", "p", { compressed: [1, 2, 3, 4, 5, 6, 7, 8] })), { signal: controller.signal }); + assert.deepEqual(result, { status: "unreadable", reason: "invalid-png" }); + assert.equal(controller.signal.aborted, false); +}); + +test("an already aborted signal rejects before any slice", async () => { + const controller = new AbortController(); + controller.abort(); + const file = recordingBlob(png(tEXt("prompt", "p"))); + await assert.rejects(readPngMetadataFile(file, { signal: controller.signal }), (error) => error.name === "AbortError"); + assert.deepEqual(file.ranges, []); +}); + +test("aborting between slices rejects with an AbortError", async () => { + const controller = new AbortController(); + const whole = png(tEXt("prompt", "p")); + let slices = 0; + const file = { + size: whole.length, + slice(start, end) { + return { + arrayBuffer: async () => { + if (++slices === 3) controller.abort(); + return whole.slice(start, end).buffer; + }, + }; + }, + }; + await assert.rejects(readPngMetadataFile(file, { signal: controller.signal }), (error) => error.name === "AbortError"); +}); + +test("aborting while inflating a real deflate stream rejects with an AbortError", async () => { + const controller = new AbortController(); + let inflating = false; + class Tracked extends DecompressionStream { + constructor(format) { + super(format); + inflating = true; + } + } + // The slow slices make the pacer yield inside the inflater's feed loop; that + // yield aborts, so the real stream is torn down mid-inflation. + const yieldControl = async () => { + if (inflating) controller.abort(); + }; + const slow = slowBlob(png(zTXt("prompt", "p", { compressed: require("node:crypto").randomBytes(5000) })), 10); + await assert.rejects(readPngMetadataFile(slow, { signal: controller.signal, yieldControl, decompress: Tracked }), (error) => error.name === "AbortError"); + assert.equal(inflating, true); +}); + +test("yields control about every 8 ms while the file answers slowly", async () => { + let yields = 0; + const yieldControl = async () => { + yields += 1; + }; + const result = await readPngMetadataFile(slowBlob(png(tEXt("prompt", "p")), 10), { yieldControl }); + assert.equal(result.status, "complete"); + assert.ok(yields >= 1, `yielded ${yields} times`); +}); + +test("the default yield lets the event loop turn", async () => { + const result = await readPngMetadataFile(slowBlob(png(tEXt("prompt", "p")), 10)); + assert.deepEqual(result.records, { prompt: "p" }); +}); + +test("a slice that fails is a read failure", async () => { + assert.deepEqual(await readPngMetadataFile(failingBlob(100)), { status: "unreadable", reason: "read-failure" }); + assert.deepEqual(await readPngMetadataFile(null), { status: "unreadable", reason: "read-failure" }); +}); + +test("a file that shrank under the reader is a read failure", async () => { + const whole = png(tEXt("prompt", "p")); + assert.deepEqual(await readPngMetadataFile(shrunkBlob(whole.subarray(0, 40), whole.length)), { status: "unreadable", reason: "read-failure" }); +}); + +test("classifier", () => { + assert.deepEqual(classifyPrefix(bytes.SIGNATURE), { media: "image", kind: "png" }); + assert.deepEqual(classifyPrefix(Uint8Array.from([0xff, 0xd8, 0xff, 0xe0])), { media: "image", kind: "jpeg" }); + assert.deepEqual(classifyPrefix(latin1("RIFF\0\0\0\0WEBPVP8 ")), { media: "image", kind: "webp" }); + assert.deepEqual(classifyPrefix(latin1("\0\0\0\x18ftypisom\0\0\0\0")), { media: "video", kind: "iso-bmff" }); + assert.deepEqual(classifyPrefix(latin1("\0\0\0\x14ftypqt ")), { media: "video", kind: "iso-bmff" }); + assert.deepEqual(classifyPrefix(Uint8Array.from([0x1a, 0x45, 0xdf, 0xa3, 0x01])), { media: "video", kind: "matroska" }); + assert.deepEqual(classifyPrefix(latin1("RIFF\0\0\0\0AVI LIST")), { media: "video", kind: "avi" }); + const asf = Uint8Array.from([0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c, 0, 0]); + assert.deepEqual(classifyPrefix(asf), { media: "video", kind: "asf" }); + assert.deepEqual(classifyPrefix(asf.buffer), { media: "video", kind: "asf" }); + for (const text of ["plain text file", "", ' { + // tests/test_local_inspect_parity.py asserts the same two files against Pillow. + const jpeg = blobOf(fs.readFileSync(path.join(FIXTURES, "tiny.jpg"))); + assert.deepEqual(await readImageHeader(jpeg, "jpeg"), { format: "JPEG", width: 48, height: 32 }); + const webp = blobOf(fs.readFileSync(path.join(FIXTURES, "tiny.webp"))); + assert.deepEqual(await readImageHeader(webp, "webp"), { format: "WEBP", width: 40, height: 24 }); +}); + +test("image header: JPEG marker walk skips padding, junk, standalone markers and tables", async () => { + const segment = (marker, body) => concat([0xff, marker], u32(body.length + 2).subarray(2), body); + const sof = (marker, width, height) => segment(marker, concat([8], u32(height).subarray(2), u32(width).subarray(2), [3])); + const sized = concat([0xff, 0xd8], segment(0xe1, fill(0, 20)), [0xff, 0xff, 0xff, 0x00, 0x00, 0x11], [0xff, 0xd0], [0xff, 0x01], segment(0xc4, fill(0, 4)), segment(0xc8, []), segment(0xcc, [1]), sof(0xc2, 300, 200)); + assert.deepEqual(await readImageHeader(blobOf(sized), "jpeg"), { format: "JPEG", width: 300, height: 200 }); + for (const marker of [0xc0, 0xc1, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]) { + assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], sof(marker, 7, 9))), "jpeg"), { format: "JPEG", width: 7, height: 9 }, marker.toString(16)); + } + const none = { format: "JPEG", width: null, height: null }; + assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], segment(0xda, [1]))), "jpeg"), none, "SOS before SOF"); + assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], [0xff])), "jpeg"), none, "ends on a marker byte"); + assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], [0xff, 0xe1, 0x00])), "jpeg"), none, "ends inside a length"); + assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], [0xff, 0xc0, 0x00, 0x08, 8, 0])), "jpeg"), none, "ends inside SOF"); + assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], segment(0xe1, []))), "jpeg"), none, "no SOF at all"); + assert.deepEqual(await readImageHeader(blobOf(Uint8Array.from([0xff])), "jpeg"), none, "too short"); + assert.deepEqual(await readImageHeader(blobOf(latin1("RIFF")), "jpeg"), none, "not a JPEG"); + // A SOF past the 64 KiB read gives no dimensions rather than reading on. + assert.deepEqual(await readImageHeader(blobOf(concat([0xff, 0xd8], segment(0xe1, fill(0, 65000)), segment(0xe2, fill(0, 1000)), sof(0xc0, 5, 5))), "jpeg"), none, "SOF beyond 64 KiB"); +}); + +test("image header: WebP VP8, VP8L and VP8X headers", async () => { + const riff = (...rest) => concat(latin1("RIFF"), u32(0), latin1("WEBP"), ...rest); + const chunkHeader = (type, size) => concat(latin1(type), Uint8Array.from([size & 0xff, (size >>> 8) & 0xff, (size >>> 16) & 0xff, 0])); + const vp8 = riff(chunkHeader("VP8 ", 100), [0x30, 0x0b, 0x00, 0x9d, 0x01, 0x2a], [40, 0x40], [24, 0x80], fill(0, 20)); + assert.deepEqual(await readImageHeader(blobOf(vp8), "webp"), { format: "WEBP", width: 40, height: 24 }, "scale bits are masked"); + // 14-bit width-1 = 63 and height-1 = 47, little-endian bit-packed after the 0x2f signature. + const vp8l = riff(chunkHeader("VP8L", 100), [0x2f], [0x3f, 0xc0, 0x0b, 0x00, 0x00], fill(0, 20)); + assert.deepEqual(await readImageHeader(blobOf(vp8l), "webp"), { format: "WEBP", width: 64, height: 48 }); + const vp8x = riff(chunkHeader("VP8X", 10), [0x10, 0, 0, 0], [0xff, 0x00, 0x01], [0x2f, 0x00, 0x00], fill(0, 20)); + assert.deepEqual(await readImageHeader(blobOf(vp8x), "webp"), { format: "WEBP", width: 65792, height: 48 }); + const none = { format: "WEBP", width: null, height: null }; + assert.deepEqual(await readImageHeader(blobOf(riff(chunkHeader("VP8 ", 100), [0x30, 0x0b, 0x00, 0x00, 0x01, 0x2a], fill(0, 24))), "webp"), none, "bad start code"); + assert.deepEqual(await readImageHeader(blobOf(riff(chunkHeader("VP8L", 100), [0x00], fill(0, 24))), "webp"), none, "bad VP8L signature"); + assert.deepEqual(await readImageHeader(blobOf(riff(chunkHeader("ALPH", 100), fill(0, 24))), "webp"), none, "unknown first chunk"); + assert.deepEqual(await readImageHeader(blobOf(riff(chunkHeader("VP8 ", 100))), "webp"), none, "too short"); + assert.deepEqual(await readImageHeader(blobOf(latin1("RIFF\0\0\0\0WAVEfmt ".padEnd(40, "\0"))), "webp"), none, "not WebP"); +}); + +test("image header: other kinds give no format and the read stays within 64 KiB", async () => { + assert.deepEqual(await readImageHeader(blobOf(bytes.SIGNATURE), "png"), { format: null, width: null, height: null }); + const file = recordingBlob(concat([0xff, 0xd8], fill(0, 200000))); + await readImageHeader(file, "jpeg"); + assert.deepEqual(file.ranges, [[0, 65536]]); + const small = recordingBlob(concat([0xff, 0xd8, 0xff])); + await readImageHeader(small, "jpeg"); + assert.deepEqual(small.ranges, [[0, 3]]); +}); diff --git a/tests/js/support/local-inspect-oracle.mjs b/tests/js/support/local-inspect-oracle.mjs new file mode 100644 index 00000000..bb245227 --- /dev/null +++ b/tests/js/support/local-inspect-oracle.mjs @@ -0,0 +1,29 @@ +// The browser's local readers run over files on disk, for +// tests/test_local_inspect_parity.py, which compares every answer with the server's +// utils.prompt_inspector.read_inspection_file on the same file. Each file is opened +// as a Blob (fs.openAsBlob) so the reader slices it exactly as it slices a File. +// stdin: [{name, path}] +// stdout: {name: {classified, result}} where result is readPngMetadataFile's +// answer for a PNG, readImageHeader's for another image, else null. +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const { classifyPrefix, readPngMetadataFile, readImageHeader } = require(path.join(here, '..', '..', '..', 'web', 'js', 'png-metadata.js')); + +const chunks = []; +for await (const chunk of process.stdin) chunks.push(chunk); +const output = {}; +for (const { name, path: filePath } of JSON.parse(Buffer.concat(chunks).toString('utf8'))) { + const file = await fs.openAsBlob(filePath); + const prefix = new Uint8Array(await file.slice(0, Math.min(32, file.size)).arrayBuffer()); + const classified = classifyPrefix(prefix); + let result = null; + if (classified?.kind === 'png') result = await readPngMetadataFile(file, { prefix }); + else if (classified?.media === 'image') result = await readImageHeader(file, classified.kind); + output[name] = { classified, result }; +} +process.stdout.write(JSON.stringify(output)); diff --git a/tests/test_local_inspect_parity.py b/tests/test_local_inspect_parity.py new file mode 100644 index 00000000..780feb94 --- /dev/null +++ b/tests/test_local_inspect_parity.py @@ -0,0 +1,276 @@ +"""The browser's local PNG reader answers exactly as the server's on the same file. + +``web/js/png-metadata.js`` ``readPngMetadataFile`` ports the bounded PNG reader in +``utils/prompt_inspector.py``. This test hands the inspection parity corpus (the +committed ``f*.png`` and the test-time shapes), extra compressed, oversized, +Latin-1 and batch-duplicate PNGs, and the committed JPEG/WebP header fixtures to a +Node oracle, and asserts per file that records, record formats, dimensions and +readability equal ``read_inspection_file``. The oracle reads each file through +``fs.openAsBlob`` slices, as the page reads a chosen ``File``. +""" + +import io +import json +import os +import shutil +import struct +import subprocess # nosec B404 +import tempfile +import unittest +import zlib +from pathlib import Path + +from PIL import Image +from PIL.PngImagePlugin import PngInfo + +from utils.prompt_inspector import ( + ROLE_METADATA_BYTES, + WORKFLOW_METADATA_BYTES, + MetadataReadError, + read_inspection_file, +) + +try: + from tests import inspection_parity as parity + from tests.open_handles import assert_closed +except ImportError: # pragma: no cover - discover -s tests puts tests/ first + import inspection_parity as parity + from open_handles import assert_closed + +HERE = Path(__file__).resolve().parent +ORACLE = HERE / "js" / "support" / "local-inspect-oracle.mjs" +HEADER_FIXTURES = HERE / "fixtures" / "local-inspect" +NODE_TIMEOUT_SECONDS = 180 +PNG_CLASS = {"media": "image", "kind": "png"} +_FOOTER = "Steps: 20, Sampler: Euler, Batch index: {}, Seed: 7" + + +def _raw_chunk(kind, data): + return ( + struct.pack(">I", len(data)) + + kind + + data + + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + ) + + +def _text_chunk(key, text): + return _raw_chunk(b"tEXt", key.encode("latin-1") + b"\0" + text.encode("latin-1")) + + +def _write_png(path, info, extra_chunks=()): + """A 16x16 PNG with Pillow-written text records and spliced raw chunks.""" + buffer = io.BytesIO() + with Image.new("RGB", (16, 16), (10, 20, 30)) as image: + image.save(buffer, format="PNG", pnginfo=info, compress_level=4) + data = buffer.getvalue() + iend = data.rindex(b"IEND") - 4 + with open(path, "wb") as stream: + stream.write(data[:iend] + b"".join(extra_chunks) + data[iend:]) + + +def _info(**texts): + info = PngInfo() + for key, value in texts.items(): + if isinstance(value, tuple): + text, options = value + if options.get("itxt"): + info.add_itxt( + key, + text, + lang=options.get("lang", ""), + tkey=options.get("tkey", ""), + zip=options.get("zip", False), + ) + else: + info.add_text(key, text, zip=options.get("zip", False)) + else: + info.add_text(key, value) + return info + + +PROMPT = json.dumps(parity.build("f02")["prompt"]) +WORKFLOW = json.dumps(parity.build("f02")["workflow"]) + +# Shapes the committed corpus lacks: compressed chunks, the budgets, Latin-1 text, +# the batch-index rule and the pair execution record. +EXTRA_SHAPES = { + "ztxt_prompt_itxt_workflow": lambda path: _write_png( + path, + _info( + prompt=(PROMPT, {"zip": True}), + workflow=(WORKFLOW, {"itxt": True, "zip": True}), + ), + ), + "itxt_bom_lang": lambda path: _write_png( + path, + _info( + prompt=("" + PROMPT, {"itxt": True, "lang": "en", "tkey": "prompt"}), + workflow=(WORKFLOW, {"itxt": True}), + ), + ), + "latin1_prompt": lambda path: _write_png( + path, _info(prompt='{"1":{"class_type":"Note","inputs":{"text":"café ÿ"}}}') + ), + "all_four_records": lambda path: _write_png( + path, + _info( + prompt=PROMPT, + workflow=WORKFLOW, + parameters="cat\nNegative prompt: dog\n" + _FOOTER.format(0), + comfyassets_prompt_pairs='{"pairs":[]}', + ), + ), + "workflow_over_budget_limited": lambda path: _write_png( + path, + _info( + prompt=PROMPT, + workflow="[" + "0," * ((WORKFLOW_METADATA_BYTES + 1) // 2) + "]", + ), + ), + "workflow_at_budget_kept": lambda path: _write_png( + path, _info(workflow=("{" * WORKFLOW_METADATA_BYTES, {"zip": True})) + ), + "roles_over_budget_unreadable": lambda path: _write_png( + path, _info(prompt="A" * (ROLE_METADATA_BYTES + 1)) + ), + "roles_over_budget_decoded_unreadable": lambda path: _write_png( + path, _info(prompt=("A" * (ROLE_METADATA_BYTES + 1), {"zip": True})) + ), + "roles_at_budget_kept": lambda path: _write_png( + path, + _info( + prompt=("A" * (ROLE_METADATA_BYTES // 2), {"zip": True}), + parameters=("B" * (ROLE_METADATA_BYTES // 2), {"zip": True}), + ), + ), + "batch_parameters_last_kept": lambda path: _write_png( + path, + _info(prompt=PROMPT), + [ + _text_chunk("parameters", "cat\n" + _FOOTER.format(index)) + for index in (0, 1, 2, 10) + ], + ), + "batch_parameters_conflict_unreadable": lambda path: _write_png( + path, + PngInfo(), + [ + _text_chunk("parameters", "cat\n" + _FOOTER.format(0)), + _text_chunk("parameters", "dog\n" + _FOOTER.format(1)), + ], + ), + "duplicate_workflow_identical": lambda path: _write_png( + path, + _info(prompt=PROMPT), + [_text_chunk("workflow", WORKFLOW), _text_chunk("workflow", WORKFLOW)], + ), + "unknown_keys_only": lambda path: _write_png( + path, _info(Software="ComfyUI", Comment=("café", {"zip": True})) + ), +} + + +def _classes(results): + return {name: entry["classified"] for name, entry in results.items()} + + +class LocalPngReaderParityTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.addCleanup(assert_closed, self, temporary.name) + self.directory = Path(temporary.name) + + def _files(self): + files = {} + for name in parity.COMMITTED: + files[name] = parity.fixture_path(name) + for name in parity.TEST_TIME: + path = parity.fixture_path(name, self.directory) + parity.write_png(path, parity.build(name)) + files[name] = path + for name, write in EXTRA_SHAPES.items(): + path = self.directory / f"{name}.png" + write(path) + files[name] = path + for header in ("tiny.jpg", "tiny.webp"): + files[header] = HEADER_FIXTURES / header + return files + + def _oracle(self, files): + node = shutil.which("node") + if node is None: # pragma: no cover - CI runners and dev hosts ship Node + if os.environ.get("CI"): + self.fail("Node.js is required in CI for the local reader parity test") + self.skipTest("Node.js is not installed") + payload = json.dumps( + [{"name": name, "path": str(path)} for name, path in files.items()] + ) + # Fixed local Node harness, no shell. + completed = subprocess.run( # nosec B603 + [node, str(ORACLE)], + input=payload.encode("utf-8"), + capture_output=True, + timeout=NODE_TIMEOUT_SECONDS, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr.decode("utf-8")) + return json.loads(completed.stdout) + + def test_local_png_reader_matches_read_inspection_file(self): + files = self._files() + observed = self._oracle(files) + self.assertEqual(set(observed), set(files)) + outcomes = set() + for name, path in files.items(): + if path.suffix != ".png": + continue + with self.subTest(fixture=name): + self.assertEqual(observed[name]["classified"], PNG_CLASS) + result = observed[name]["result"] + try: + read = read_inspection_file(path) + except MetadataReadError: + outcomes.add("unreadable") + self.assertEqual(result["status"], "unreadable", result) + continue + self.assertEqual(result["status"], "complete", result) + self.assertEqual(result["records"], read.metadata) + self.assertEqual( + sorted(result["limited"]), sorted(read.limited_records) + ) + formats = {key: "original-text" for key in result["records"]} + formats.update({key: "limited" for key in result["limited"]}) + self.assertEqual(formats, read.record_formats) + self.assertEqual( + (result["width"], result["height"]), (read.width, read.height) + ) + self.assertEqual(read.format, "PNG") + outcomes.add("limited" if read.limited_records else "complete") + # The corpus must exercise every outcome, or agreement proves little. + self.assertEqual(outcomes, {"complete", "limited", "unreadable"}) + + def test_local_image_header_matches_pillow(self): + files = {name: HEADER_FIXTURES / name for name in ("tiny.jpg", "tiny.webp")} + observed = self._oracle(files) + self.assertEqual( + _classes(observed), + { + "tiny.jpg": {"media": "image", "kind": "jpeg"}, + "tiny.webp": {"media": "image", "kind": "webp"}, + }, + ) + for name, path in files.items(): + with self.subTest(fixture=name): + read = read_inspection_file(path) + self.assertEqual(read.metadata, {}) + self.assertEqual( + observed[name]["result"], + {"format": read.format, "width": read.width, "height": read.height}, + ) + self.assertLess(path.stat().st_size, 4096) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/js/png-metadata.js b/web/js/png-metadata.js index c813676d..fef8c457 100644 --- a/web/js/png-metadata.js +++ b/web/js/png-metadata.js @@ -2,9 +2,14 @@ * PNG text-chunk reader: the one place that walks a PNG's chunks to pull out * the tEXt/iTXt entries ComfyUI writes (`prompt`, `workflow`, ...). * - * Only uncompressed text is returned. zTXt chunks and iTXt chunks with the - * compression flag set are skipped rather than decoded as garbage; ComfyUI - * never writes those. Nothing throws: unreadable input yields {}. + * `parsePngTextChunks` (the legacy pages): only uncompressed text is returned. + * zTXt chunks and iTXt chunks with the compression flag set are skipped rather + * than decoded as garbage; ComfyUI never writes those. Nothing throws: + * unreadable input yields {}. + * + * `readPngMetadataFile`, `classifyPrefix` and `readImageHeader` (the Inspect + * page, #249): bounded readers over a File that port the server's inspection + * reader so a local file shows exactly what Images shows for the same bytes. * * Loadable in the browser (window.PngMetadata) and in Node for tests. */ @@ -94,7 +99,605 @@ return result; } - const api = { parsePngTextChunks, PNG_SIGNATURE }; + // ------------------------------------------------------------------------ + // Local File readers for the Inspect page (#249): a port of the server's + // bounded PNG reader (utils/prompt_inspector.py: _png_metadata, _PngRecords, + // _png_text, _inflate, _batch_normalized), a media classifier and a JPEG/WebP + // header reader. Nothing here uploads, stores or logs the file. The reader + // slices only chunk headers, keyword prefixes and the retained records, so a + // multi-gigabyte PNG costs at most PNG_READ_CEILING bytes of reads. + // tests/test_local_inspect_parity.py proves it answers as the server does. + // ------------------------------------------------------------------------ + + // Role records (prompt, parameters, the pair execution record) share one + // budget, encoded and decoded: past it the read fails. A workflow has its own + // budget; past it only the workflow is "limited". Duplicate chunks draw from + // separate budgets per class, so repeats cost bounded reads, not file size. + const ROLE_METADATA_BYTES = 2 * 1024 * 1024; + const WORKFLOW_METADATA_BYTES = 4 * 1024 * 1024; + const DUPLICATE_WORKFLOW_BYTES = 2 * WORKFLOW_METADATA_BYTES; + const DUPLICATE_ROLE_BYTES = 2 * ROLE_METADATA_BYTES; + const MAX_KEYWORD_BYTES = 80; // a 1-79 byte keyword plus its NUL separator + const MAX_PNG_CHUNKS = 10000; + const MAX_CHUNK_LENGTH = 0x7fffffff; + const IHDR_LENGTH = 13; + // Every byte the reader can slice from a PNG of any size: the signature, each + // chunk's header, CRC and keyword prefix, the retained-data budgets and IHDR. + const PNG_READ_CEILING = + PNG_SIGNATURE.length + + MAX_PNG_CHUNKS * (CHUNK_HEADER + CHUNK_CRC + MAX_KEYWORD_BYTES) + + ROLE_METADATA_BYTES + + WORKFLOW_METADATA_BYTES + + DUPLICATE_WORKFLOW_BYTES + + DUPLICATE_ROLE_BYTES + + IHDR_LENGTH; // 19,794,389 + const HEADER_READ_BYTES = 64 * 1024; + const INFLATE_WRITE_BYTES = 256; + const YIELD_INTERVAL_MS = 8; + const LATIN1_SLICE = 8192; + const ROLE_KEYS = new Set(["prompt", "parameters", "comfyassets_prompt_pairs"]); + const METADATA_KEYS = new Set([...ROLE_KEYS, "workflow"]); + const TEXT_CHUNKS = new Set(["tEXt", "zTXt", "iTXt"]); + // The server's _FOOTER (with its MULTILINE anchors spelled out: a line start is + // the text start or after "\n"; a line end is before "\n" or the text end) and + // _BATCH_INDEX, with Python's Unicode \d. + const FOOTER = /(?:^|(?<=\n))Steps:\s*\p{Nd}+\s*,[^\n]*(?=\n|$)/gu; + const BATCH_INDEX = /(?:^|(?<=, ))Batch index: \p{Nd}+(?=, |$)/gu; + const ASF_GUID = [0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c]; + const JPEG_SOF = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]); + const JPEG_SOS = 0xda; + + const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; + })(); + + function crc32(...parts) { + let c = 0xffffffff; + for (const bytes of parts) for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; + } + + function readUint32(bytes, offset) { + return bytes[offset] * 0x1000000 + ((bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]); + } + + function startsWith(bytes, prefix, at = 0) { + if (bytes.length < at + prefix.length) return false; + for (let i = 0; i < prefix.length; i++) if (bytes[at + i] !== prefix[i]) return false; + return true; + } + + function sameBytes(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; + } + + function concatBytes(parts, total) { + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; + } + + /** ISO-8859-1 in slices: TextDecoder's "latin1" label is windows-1252, not this. */ + function latin1Text(bytes) { + let out = ""; + for (let i = 0; i < bytes.length; i += LATIN1_SLICE) out += String.fromCharCode.apply(null, bytes.subarray(i, i + LATIN1_SLICE)); + return out; + } + + /** UTF-8 length of the Latin-1 decoding: each byte >= 0x80 becomes two bytes. */ + function latin1Utf8Length(bytes) { + let high = 0; + for (let i = 0; i < bytes.length; i++) if (bytes[i] >= 0x80) high++; + return bytes.length + high; + } + + function isAscii(bytes) { + for (let i = 0; i < bytes.length; i++) if (bytes[i] >= 0x80) return false; + return true; + } + + function utf8Length(text) { + return new TextEncoder().encode(text).length; + } + + class PngReadError extends Error { + constructor(reason, message) { + super(message); + this.name = "PngReadError"; + this.reason = reason; + } + } + + const invalid = (message) => new PngReadError("invalid-png", message); + const overLimit = () => new PngReadError("metadata-limit", "Metadata exceeds inspection limits"); + const readFailure = (message) => new PngReadError("read-failure", message); + + function abortError() { + return new DOMException("The file read was aborted", "AbortError"); + } + + function throwIfAborted(signal) { + if (signal && signal.aborted) throw abortError(); + } + + function quiet(promise) { + Promise.resolve(promise).catch(() => {}); + } + + /** `promise`, or an AbortError as soon as `signal` aborts (the promise is left handled). */ + function untilAborted(promise, signal) { + if (!signal) return promise; + if (signal.aborted) { + quiet(promise); + return Promise.reject(abortError()); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + quiet(promise); + reject(abortError()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); + } + + /** Strict UTF-8 (invalid sequences and encoded surrogates fail), BOM kept, as Python's decode. */ + function utf8Strict(bytes) { + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); + } catch (error) { + throw invalid("Media metadata could not be read"); + } + } + + function defaultYield() { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + /** Hands the event loop a turn about every YIELD_INTERVAL_MS of work. */ + function createPacer(yieldControl) { + let last = performance.now(); + return { + async maybeYield() { + if (performance.now() - last < YIELD_INTERVAL_MS) return; + await yieldControl(); + last = performance.now(); + }, + }; + } + + /** + * Logical byte ranges of `file`, served from the retained `prefix` where it + * covers them. Every sliced byte is charged against PNG_READ_CEILING, and every + * range is checked against `file.size` before it is sliced. + */ + function createSliceReader(file, prefix, signal) { + const retained = prefix || new Uint8Array(0); + let charged = 0; + return { + async read(start, end) { + if (!Number.isSafeInteger(end) || end > file.size) throw invalid("Incomplete PNG metadata"); + if (end <= retained.length) return retained.subarray(start, end); + const head = start < retained.length ? retained.subarray(start) : null; + const sliceStart = head ? retained.length : start; + charged += end - sliceStart; + if (charged > PNG_READ_CEILING) throw readFailure("PNG read exceeds the inspection ceiling"); + const buffer = await file.slice(sliceStart, end).arrayBuffer(); + throwIfAborted(signal); + const bytes = new Uint8Array(buffer); + if (bytes.length !== end - sliceStart) throw readFailure("The file changed while it was being read"); + return head ? concatBytes([head, bytes], end - start) : bytes; + }, + }; + } + + /** + * Inflates zlib data through `decompress` (a DecompressionStream constructor) + * with one <=256-byte write in flight, keeping at most `limit` output bytes. + * Returns null when the output is over `limit`, as the server's _inflate does. + */ + function createInflater(decompress, signal, pacer) { + return async function inflate(compressed, limit) { + if (typeof decompress !== "function") { + throw new PngReadError("decompression-unavailable", "This browser cannot inflate compressed PNG text"); + } + const stream = new decompress("deflate"); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + const parts = []; + let total = 0; + let over = false; + const stop = () => { + quiet(writer.abort()); + quiet(reader.cancel()); + }; + if (signal) signal.addEventListener("abort", stop, { once: true }); + try { + const drain = (async () => { + for (;;) { + const { value, done } = await untilAborted(reader.read(), signal); + if (done) return; + total += value.byteLength; + if (total > limit) { + over = true; + stop(); + return; + } + parts.push(value); + } + })(); + const feed = (async () => { + for (let offset = 0; offset < compressed.length; offset += INFLATE_WRITE_BYTES) { + await pacer.maybeYield(); + await untilAborted(writer.ready, signal); + await untilAborted(writer.write(compressed.subarray(offset, offset + INFLATE_WRITE_BYTES)), signal); + } + await untilAborted(writer.close(), signal); + })(); + const outcomes = await Promise.allSettled([drain, feed]); + throwIfAborted(signal); + if (over) return null; + if (outcomes.some((outcome) => outcome.status === "rejected")) { + throw invalid("Compressed metadata is invalid or exceeds limits"); + } + return concatBytes(parts, total); + } finally { + if (signal) signal.removeEventListener("abort", stop); + } + }; + } + + /** The keyword before the first NUL of a <=80-byte prefix: 1-79 Latin-1 bytes. */ + function keywordOf(prefix) { + const nul = indexOfNul(prefix, 0, prefix.length); + if (nul < 1 || nul > MAX_KEYWORD_BYTES - 1) throw invalid("Malformed PNG text metadata"); + return latin1(prefix, 0, nul); + } + + /** Splits on NUL at most `limit` times (Python's bytes.split(b"\\0", limit)). */ + function splitNul(bytes, limit) { + const fields = []; + let start = 0; + while (fields.length < limit) { + const nul = indexOfNul(bytes, start, bytes.length); + if (nul === -1) break; + fields.push(bytes.subarray(start, nul)); + start = nul + 1; + } + fields.push(bytes.subarray(start)); + return fields; + } + + /** + * Decodes one retained text chunk (the server's _png_text): {text, size} with + * size the UTF-8 length of text, or null when it inflates past `limit`. + */ + async function pngText(kind, data, limit, inflate) { + let body = data.subarray(indexOfNul(data, 0, data.length) + 1); + let unicode = false; + if (kind === "zTXt") { + if (body.length === 0 || body[0] !== 0) throw invalid("Unsupported PNG text compression"); + body = await inflate(body.subarray(1), limit); + } else if (kind === "iTXt") { + if (body.length < 2 || body[0] > 1) throw invalid("Malformed international PNG metadata"); + const flag = body[0]; + const method = body[1]; + const fields = splitNul(body.subarray(2), 2); + if (fields.length !== 3 || (flag && method !== 0)) throw invalid("Malformed international PNG metadata"); + if (!isAscii(fields[0])) throw invalid("Media metadata could not be read"); + utf8Strict(fields[1]); + body = flag ? await inflate(fields[2], limit) : fields[2]; + unicode = true; + } + if (body === null) return null; + if (indexOfNul(body, 0, body.length) !== -1) throw invalid("Malformed PNG text metadata"); + if (unicode) return { text: utf8Strict(body), size: body.length }; + return { text: latin1Text(body), size: latin1Utf8Length(body) }; + } + + /** The server's _batch_normalized: the footer's single batch index blanked, else null. */ + function batchNormalized(text) { + const footers = [...text.matchAll(FOOTER)]; + if (footers.length !== 1) return null; + const [footer] = footers; + let count = 0; + const line = footer[0].replace(BATCH_INDEX, () => { + count++; + return "Batch index: #"; + }); + if (count !== 1) return null; + return text.slice(0, footer.index) + line + text.slice(footer.index + footer[0].length); + } + + /** Retained records with per-class budgets and byte-identical duplicates (the server's _PngRecords). */ + class PngRecords { + constructor() { + this.found = {}; + this.limited = new Set(); + this.retained = new Map(); // key -> {kind, data} of the kept chunk, compared byte for byte + this.roleEncoded = 0; + this.roleDecoded = 0; + this.duplicateWorkflow = 0; + this.duplicateRole = 0; + this.duplicateDecoded = 0; + } + + /** Decide from the keyword and length alone whether to seek past a chunk. */ + skip(key, length) { + if (!METADATA_KEYS.has(key) || this.limited.has(key)) return true; // never retained, or a duplicate of a limited workflow + const kept = this.retained.get(key); + if (kept) { + if (kept.data.length !== length && key !== "parameters") throw invalid("Duplicate prompt metadata"); + if (key === "workflow") { + if (this.duplicateWorkflow + length > DUPLICATE_WORKFLOW_BYTES) { + this.limited.add(key); + delete this.found[key]; + return true; + } + this.duplicateWorkflow += length; + } else { + if (this.duplicateRole + length > DUPLICATE_ROLE_BYTES) throw overLimit(); + this.duplicateRole += length; + } + return false; // same length: read and compare bytes + } + if (key === "workflow") { + if (length > WORKFLOW_METADATA_BYTES) { + this.limited.add(key); + return true; + } + return false; + } + this.roleEncoded += length; + if (this.roleEncoded > ROLE_METADATA_BYTES) throw overLimit(); + return false; + } + + async add(kind, data, inflate) { + const key = keywordOf(data.subarray(0, MAX_KEYWORD_BYTES)); + const kept = this.retained.get(key); + if (kept) { + if (kept.kind !== kind || !sameBytes(kept.data, data)) { + if (key !== "parameters") throw invalid("Duplicate prompt metadata"); + await this.batchCopy(kind, data, inflate); + // The kept copy is the last one; later repeats compare with it. + this.retained.set(key, { kind, data }); + } + return; + } + this.retained.set(key, { kind, data }); + const workflow = key === "workflow"; + const limit = workflow ? WORKFLOW_METADATA_BYTES : ROLE_METADATA_BYTES - this.roleDecoded; + const decoded = await pngText(kind, data, limit, inflate); + if (workflow) { + if (decoded === null || decoded.size > WORKFLOW_METADATA_BYTES) { + this.limited.add(key); + return; + } + } else { + this.roleDecoded += decoded === null ? limit + 1 : decoded.size; + if (this.roleDecoded > ROLE_METADATA_BYTES) throw overLimit(); + } + this.found[key] = decoded.text; + } + + /** + * Keep a later `parameters` copy that differs only in its batch index. Each + * copy must carry exactly one `Batch index: N` settings field; any other + * difference is a conflict. Decoding every copy draws from the duplicate + * role budget, so small compressed copies stay bounded. + */ + async batchCopy(kind, data, inflate) { + const decoded = await pngText(kind, data, ROLE_METADATA_BYTES, inflate); + const size = decoded === null ? ROLE_METADATA_BYTES + 1 : decoded.size; + this.duplicateDecoded += size; + if (decoded === null || this.duplicateDecoded > DUPLICATE_ROLE_BYTES) throw overLimit(); + const kept = this.found.parameters; + const normalized = batchNormalized(kept); + if (normalized === null || normalized !== batchNormalized(decoded.text)) throw invalid("Duplicate prompt metadata"); + this.roleDecoded += Math.max(0, size - utf8Length(kept)); + if (this.roleDecoded > ROLE_METADATA_BYTES) throw overLimit(); + this.found.parameters = decoded.text; + } + } + + /** + * Walks the chunk layout (the server's _png_metadata): CRC-checks IHDR, IEND + * and retained text chunks, reads keyword prefixes, and seeks past everything + * else by length, so pixel data and unknown text are never read. + */ + async function readPngChunks(reader, fileSize, { signal, inflate, pacer }) { + const records = new PngRecords(); + let sawHeader = false; + let sawPixels = false; + let width = 0; + let height = 0; + let offset = PNG_SIGNATURE.length; + for (let index = 0; index < MAX_PNG_CHUNKS; index++) { + await pacer.maybeYield(); + throwIfAborted(signal); + const header = await reader.read(offset, offset + CHUNK_HEADER); + const length = readUint32(header, 0); + const kindBytes = header.subarray(4, 8); + const kind = latin1(header, 4, 8); + const dataStart = offset + CHUNK_HEADER; + const next = dataStart + length + CHUNK_CRC; + if (length > MAX_CHUNK_LENGTH || next > fileSize) throw invalid("Incomplete PNG chunk"); + if (index === 0 && (kind !== "IHDR" || length !== IHDR_LENGTH)) throw invalid("Missing PNG header"); + let data; + let checksum; + if (TEXT_CHUNKS.has(kind)) { + const prefix = await reader.read(dataStart, dataStart + Math.min(length, MAX_KEYWORD_BYTES)); + if (records.skip(keywordOf(prefix), length)) { + offset = next; + continue; + } + const rest = await reader.read(dataStart + prefix.length, next); + data = concatBytes([prefix, rest.subarray(0, rest.length - CHUNK_CRC)], length); + checksum = rest.subarray(rest.length - CHUNK_CRC); + } else if (kind === "IHDR" || kind === "IEND") { + const bytes = await reader.read(dataStart, next); + data = bytes.subarray(0, length); + checksum = bytes.subarray(length); + } else { + sawPixels = sawPixels || kind === "IDAT"; + offset = next; + continue; + } + if (crc32(kindBytes, data) !== readUint32(checksum, 0)) throw invalid("PNG metadata checksum mismatch"); + if (kind === "IHDR") { + const w = readUint32(data, 0); + const h = readUint32(data, 4); + if (sawHeader || !w || !h) throw invalid("Malformed PNG header"); + sawHeader = true; + width = w; + height = h; + } else if (kind === "IEND") { + if (length || !sawPixels) throw invalid("Incomplete PNG image"); + return { records: { ...records.found }, limited: [...records.limited].sort(), width, height }; + } else { + await records.add(kind, data, inflate); + } + offset = next; + } + throw new PngReadError("metadata-limit", "PNG chunk count exceeds inspection limits"); + } + + /** + * Reads the retained text records and the IHDR size of a PNG File without + * reading its pixels or sending it anywhere. + * + * @param {Blob} file - the chosen File (only `size` and `slice` are used) + * @param {Object} [options] + * @param {AbortSignal} [options.signal] - rejects the read with an AbortError + * @param {Uint8Array} [options.prefix] - the file's first bytes, already read for classification + * @param {Function} [options.yieldControl] - awaited about every 8 ms of work + * @param {Function} [options.decompress] - DecompressionStream constructor; pass undefined where none exists + * @returns {Promise<{status:'complete', records:Object, limited:string[], width:number, height:number} + * | {status:'unreadable', reason:'invalid-png'|'read-failure'|'metadata-limit'|'decompression-unavailable'}>} + */ + async function readPngMetadataFile(file, options = {}) { + const { signal, prefix, yieldControl = defaultYield } = options; + const decompress = Object.hasOwn(options, "decompress") ? options.decompress : globalThis.DecompressionStream; + try { + throwIfAborted(signal); + if (!file || !Number.isSafeInteger(file.size) || typeof file.slice !== "function") throw readFailure("Not a readable file"); + const reader = createSliceReader(file, toBytes(prefix), signal); + const pacer = createPacer(yieldControl); + const signature = await reader.read(0, PNG_SIGNATURE.length); + if (!hasSignature(signature)) throw invalid("Not a PNG file"); + const inflate = createInflater(decompress, signal, pacer); + const result = await readPngChunks(reader, file.size, { signal, inflate, pacer }); + return { status: "complete", ...result }; + } catch (error) { + if (error && error.name === "AbortError") throw error; + if (error instanceof PngReadError) return { status: "unreadable", reason: error.reason }; + return { status: "unreadable", reason: "read-failure" }; + } + } + + /** + * Media kind from a file's first bytes (<= 32), or null for anything else. + * @returns {{media:'image'|'video', kind:'png'|'jpeg'|'webp'|'iso-bmff'|'matroska'|'avi'|'asf'}|null} + */ + function classifyPrefix(input) { + const bytes = toBytes(input); + if (!bytes) return null; + if (hasSignature(bytes)) return { media: "image", kind: "png" }; + if (startsWith(bytes, [0xff, 0xd8, 0xff])) return { media: "image", kind: "jpeg" }; + if (bytes.length >= 12 && latin1(bytes, 0, 4) === "RIFF") { + const form = latin1(bytes, 8, 12); + if (form === "WEBP") return { media: "image", kind: "webp" }; + if (form === "AVI ") return { media: "video", kind: "avi" }; + return null; + } + if (bytes.length >= 8 && latin1(bytes, 4, 8) === "ftyp") return { media: "video", kind: "iso-bmff" }; + if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return { media: "video", kind: "matroska" }; + if (startsWith(bytes, ASF_GUID)) return { media: "video", kind: "asf" }; + return null; + } + + /** [width, height] from the first SOFn segment, walking markers as Pillow does. */ + function jpegSize(bytes) { + if (!startsWith(bytes, [0xff, 0xd8])) return null; + let offset = 2; + while (offset + 1 < bytes.length) { + if (bytes[offset] !== 0xff) { + offset++; // junk between segments + continue; + } + const marker = bytes[offset + 1]; + if (marker === 0xff) { + offset++; // padding + continue; + } + if (marker === 0x00 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { + offset += 2; // escaped 0xFF, TEM, RSTn, SOI, EOI: no length + continue; + } + if (marker === JPEG_SOS || offset + 4 > bytes.length) return null; + if (JPEG_SOF.has(marker)) { + if (offset + 9 > bytes.length) return null; + return [(bytes[offset + 7] << 8) | bytes[offset + 8], (bytes[offset + 5] << 8) | bytes[offset + 6]]; + } + offset += 2 + ((bytes[offset + 2] << 8) | bytes[offset + 3]); + } + return null; + } + + /** [width, height] from the first WebP chunk: VP8 frame, VP8L header or VP8X canvas. */ + function webpSize(bytes) { + if (bytes.length < 30 || latin1(bytes, 0, 4) !== "RIFF" || latin1(bytes, 8, 12) !== "WEBP") return null; + const kind = latin1(bytes, 12, 16); + if (kind === "VP8 ") { + if (!startsWith(bytes, [0x9d, 0x01, 0x2a], 23)) return null; + return [(bytes[26] | (bytes[27] << 8)) & 0x3fff, (bytes[28] | (bytes[29] << 8)) & 0x3fff]; + } + if (kind === "VP8L") { + if (bytes[20] !== 0x2f) return null; + const packed = bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24); + return [(packed & 0x3fff) + 1, ((packed >>> 14) & 0x3fff) + 1]; + } + if (kind === "VP8X") { + return [1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)), 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16))]; + } + return null; + } + + /** + * Format and dimensions of a JPEG or WebP File from at most 64 KiB of its head. + * @returns {Promise<{format:'JPEG'|'WEBP'|null, width:number|null, height:number|null}>} + */ + async function readImageHeader(file, kind) { + const format = kind === "jpeg" ? "JPEG" : kind === "webp" ? "WEBP" : null; + if (!format) return { format: null, width: null, height: null }; + const bytes = new Uint8Array(await file.slice(0, Math.min(file.size, HEADER_READ_BYTES)).arrayBuffer()); + const size = (format === "JPEG" ? jpegSize : webpSize)(bytes); + return { format, width: size ? size[0] : null, height: size ? size[1] : null }; + } + + const api = { parsePngTextChunks, PNG_SIGNATURE, classifyPrefix, readPngMetadataFile, readImageHeader }; if (typeof module !== "undefined" && module.exports) module.exports = api; else root.PngMetadata = api; })(typeof window !== "undefined" ? window : globalThis);