feat(inspect): bounded browser PNG reader matching the server's
The Inspect page will read a chosen file entirely in the browser, and the
record it shows must equal what Images shows for the same bytes. This adds
to web/js/png-metadata.js (loaded by the legacy pages as before, with
parsePngTextChunks unchanged):
- readPngMetadataFile(file, {signal, prefix, yieldControl, decompress}):
a port of utils/prompt_inspector.py's _png_metadata, _PngRecords,
_png_text, _inflate and _batch_normalized. It slices only chunk headers,
80-byte keyword prefixes and the retained prompt, workflow, parameters
and comfyassets_prompt_pairs records, CRC-checks what it keeps, applies
the same budgets (2 MiB shared role budget encoded and decoded, 4 MiB
workflow that becomes limited, 8 MiB and 4 MiB duplicate budgets, 4 MiB
duplicate decoded parameters, 10,000 chunks, the batch-index rule for
parameters copies) and compares duplicate chunks byte for byte. Every
range is checked against file.size before slicing and charged against a
19,794,389-byte ceiling, so a multi-gigabyte PNG costs only its metadata.
Compressed text inflates through DecompressionStream with one 256-byte
write in flight and the output capped at the record's allowance; an
AbortSignal rejects the read with an AbortError and leaves no unhandled
rejection; yieldControl runs about every 8 ms of work.
- classifyPrefix(bytes): png, jpeg, webp, iso-bmff, matroska, avi, asf or
null from the first 32 bytes.
- readImageHeader(file, kind): JPEG (SOFn walk as Pillow) and WebP (VP8,
VP8L, VP8X) dimensions from at most 64 KiB.
tests/test_local_inspect_parity.py drives tests/js/support/local-inspect-
oracle.mjs over the committed and test-time inspection parity corpus plus
compressed, oversized, Latin-1, batch-duplicate and all-four-record shapes,
and asserts records, record formats, dimensions and readability equal
read_inspection_file on each file; the two JPEG/WebP fixtures are checked
against Pillow. tests/js/png-local-reader.test.js pins each server rule,
the sparse 3 GiB read, the prefix reuse, inflation back-pressure and abort.
tests/fixtures/local-inspect/tiny.{jpg,webp} are CC0 FFmpeg test patterns.
This commit is contained in:
Vendored
+15
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 470 B |
@@ -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}`;
|
||||
}
|
||||
@@ -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", "<!DOCTYPE html><html>", '<?xml version="1.0"?><svg', "#EXTM3U\n#EXT-X-VERSION:3", "RIFF\0\0\0\0WAVEfmt ", "\0\0\0\x18moov", ""]) {
|
||||
assert.equal(classifyPrefix(latin1(text)), null, text);
|
||||
}
|
||||
assert.equal(classifyPrefix(bytes.SIGNATURE.subarray(0, 7)), null);
|
||||
assert.equal(classifyPrefix(Uint8Array.from([0xff, 0xd8])), null);
|
||||
assert.equal(classifyPrefix("not bytes"), null);
|
||||
assert.equal(classifyPrefix(null), null);
|
||||
});
|
||||
|
||||
test("image header: JPEG and WebP fixtures match Pillow's dimensions", async () => {
|
||||
// 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]]);
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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()
|
||||
+607
-4
@@ -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<string,string>, 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);
|
||||
|
||||
Reference in New Issue
Block a user