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.
149 lines
5.3 KiB
JavaScript
149 lines
5.3 KiB
JavaScript
// 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}`;
|
|
}
|