Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/local-projection.mjs
T
Vito Sansevero 0eaa0ba598 perf(inspect): measure the local response size natively and yield between steps
fitInspectionPayload counted the UTF-8 size of an 8-12 MB wire with a
per-character loop and ran every omission step on the main thread in one
turn (PR #277 final review M7). The size is now counted by TextEncoder
encodeInto through one bounded scratch buffer, and each re-measure
follows a yieldControl turn, with an abort signal checked between steps.
The fit is async; the fitted payload is the same as before.
2026-10-22 08:48:01 -07:00

405 lines
18 KiB
JavaScript

// Browser port of utils/inspection_response.py: the strict parse (Decimal
// spelling, no duplicate keys, NaN/Infinity as null, integers past 2^53-1 as
// text), the prompt/workflow/carrier projection with its depth and node
// ceilings, `_metadata`, and the response-size omission steps of
// `_encode_inspection`. A local file must project exactly as the server does for
// the same bytes; tests/test_local_inspect_parity.py holds both to one answer.
// Pure: no DOM, no I/O.
export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
export const MAX_PROJECTION_DEPTH = 64;
// Backstop, not an expected outcome: executable API nodes for a prompt; UI nodes
// including subgraph definitions for a workflow.
export const GRAPH_NODE_CEILING = 20000;
export const RECORD_KEYS = Object.freeze(['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']);
const FORMATS = new Set(['original-text', 'reserialized-json', 'invalid', 'limited']);
const PROMPT_NODE_KEYS = new Set(['class_type', 'inputs']);
const MAX_SAFE_DIGITS = '9007199254740991';
// Python's int() refuses more digits than sys.get_int_max_str_digits() (4300).
export const INT_MAX_STR_DIGITS = 4300;
// decimal's exact constructor (libmpdec, 64-bit) refuses values outside its
// maximal context: adjusted exponent above MAX_EMAX or exponent below Etiny.
const DECIMAL_MAX_EMAX = 999999999999999999n;
const DECIMAL_ETINY = -1999999999999999997n;
const NUMBER = /-?(?:0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?/y;
const STRING_STOP = /["\\\u0000-\u001f]/g;
const HEX4 = /^[0-9a-fA-F]{4}$/;
const ESCAPES = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
// Python's json: `null`/`true`/`false`, then the constants parse_constant receives.
const LITERALS = [['null', null], ['true', true], ['false', false], ['NaN', null], ['Infinity', null], ['-Infinity', null]];
/** A present graph cannot be projected within inspection limits (`_ProjectionLimit`). */
export class ProjectionLimit extends Error {
constructor() {
super('Projection exceeds inspection limits');
this.name = 'ProjectionLimit';
}
}
/** The server's ValueError/TypeError family: the text or tree is malformed. */
export class ProjectionError extends Error {
constructor(message) {
super(message);
this.name = 'ProjectionError';
}
}
const isArray = Array.isArray;
const isObject = value => value !== null && typeof value === 'object' && !isArray(value);
const isContainer = value => value !== null && typeof value === 'object';
/** Python's `str(Decimal(literal))` from the literal's digits and exponent, never through a float. */
export function spellDecimal(literal) {
const negative = literal[0] === '-';
const body = negative ? literal.slice(1) : literal;
const exponentAt = body.search(/[eE]/);
const mantissa = exponentAt < 0 ? body : body.slice(0, exponentAt);
const dot = mantissa.indexOf('.');
const integer = dot < 0 ? mantissa : mantissa.slice(0, dot), fraction = dot < 0 ? '' : mantissa.slice(dot + 1);
const exponent = (exponentAt < 0 ? 0n : BigInt(body.slice(exponentAt + 1))) - BigInt(fraction.length);
const digits = (integer + fraction).replace(/^0+/, '') || '0';
const adjusted = exponent + BigInt(digits.length) - 1n;
if (adjusted > DECIMAL_MAX_EMAX || exponent < DECIMAL_ETINY) throw new ProjectionError('JSON decimal exceeds supported range');
const leftDigits = exponent + BigInt(digits.length);
const dotPlace = exponent <= 0n && leftDigits > -6n ? leftDigits : 1n;
let intPart, fracPart;
if (dotPlace <= 0n) {
intPart = '0'; fracPart = '.' + '0'.repeat(Number(-dotPlace)) + digits;
} else if (dotPlace >= BigInt(digits.length)) {
intPart = digits + '0'.repeat(Number(dotPlace - BigInt(digits.length))); fracPart = '';
} else {
intPart = digits.slice(0, Number(dotPlace)); fracPart = '.' + digits.slice(Number(dotPlace));
}
const shift = leftDigits - dotPlace;
const exp = shift === 0n ? '' : `E${shift > 0n ? '+' : '-'}${shift < 0n ? -shift : shift}`;
return (negative ? '-' : '') + intPart + fracPart + exp;
}
function numberValue(match) {
const [literal, fraction, exponent] = match;
if (fraction || exponent) return spellDecimal(literal);
const digits = literal[0] === '-' ? literal.slice(1) : literal;
if (digits.length > INT_MAX_STR_DIGITS) throw new ProjectionError('Exceeds the limit for integer string conversion');
if (digits.length > MAX_SAFE_DIGITS.length || (digits.length === MAX_SAFE_DIGITS.length && digits > MAX_SAFE_DIGITS)) return literal;
return Number(literal) || 0;
}
/**
* `json.loads(text, parse_float=Decimal, object_pairs_hook=_pairs, parse_constant=_nonfinite)`
* with its results already normalized: objects have a null prototype, fractions and
* exponents are Decimal text, integers past 2^53-1 are their literal text, and
* NaN/Infinity are null. Iterative, so nesting depth never overflows the stack.
*/
export function parseStrictJson(text) {
if (typeof text !== 'string') throw new ProjectionError('JSON text must be a string');
let pos = 0;
const fail = message => new ProjectionError(`${message} at ${pos}`);
const skip = () => {
while (pos < text.length) {
const c = text.charCodeAt(pos);
if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) break;
pos++;
}
};
function string() {
pos++;
let out = '';
for (;;) {
STRING_STOP.lastIndex = pos;
const stop = STRING_STOP.exec(text);
if (!stop) throw fail('Unterminated string');
out += text.slice(pos, stop.index);
pos = stop.index + 1;
if (stop[0] === '"') return out;
if (stop[0] !== '\\') throw fail('Invalid control character');
const escape = text[pos];
if (escape === 'u') {
const hex = text.slice(pos + 1, pos + 5);
if (!HEX4.test(hex)) throw fail('Invalid \\uXXXX escape');
pos += 5;
const code = parseInt(hex, 16);
if (code >= 0xd800 && code <= 0xdbff && text[pos] === '\\' && text[pos + 1] === 'u') {
const low = text.slice(pos + 2, pos + 6);
const lowCode = HEX4.test(low) ? parseInt(low, 16) : -1;
if (lowCode >= 0xdc00 && lowCode <= 0xdfff) {
out += String.fromCharCode(code, lowCode);
pos += 6;
continue;
}
}
out += String.fromCharCode(code);
} else if (Object.hasOwn(ESCAPES, escape)) {
out += ESCAPES[escape];
pos++;
} else throw fail('Invalid \\escape');
}
}
function key() {
if (text[pos] !== '"') throw fail('Expecting property name enclosed in double quotes');
const name = string();
skip();
if (text[pos] !== ':') throw fail("Expecting ':' delimiter");
pos++;
skip();
return name;
}
function scalar() {
if (text[pos] === '"') return string();
for (const [literal, value] of LITERALS) {
if (text.startsWith(literal, pos)) {
pos += literal.length;
return value;
}
}
NUMBER.lastIndex = pos;
const match = NUMBER.exec(text);
if (!match) throw fail('Expecting value');
pos += match[0].length;
return numberValue(match);
}
skip();
const stack = [];
let result;
for (;;) {
let value;
if (text[pos] === '{') {
pos++; skip();
const frame = { value: Object.create(null), object: true, key: null, duplicate: false };
if (text[pos] === '}') pos++;
else { frame.key = key(); stack.push(frame); continue; }
value = frame.value;
} else if (text[pos] === '[') {
pos++; skip();
const frame = { value: [], object: false };
if (text[pos] === ']') pos++;
else { stack.push(frame); continue; }
value = frame.value;
} else value = scalar();
// Attach the finished value, closing containers whose last member it was.
for (;;) {
if (!stack.length) { result = value; break; }
const frame = stack[stack.length - 1];
if (frame.object) {
if (Object.hasOwn(frame.value, frame.key)) frame.duplicate = true;
frame.value[frame.key] = value;
} else frame.value.push(value);
skip();
if (text[pos] === ',') {
pos++; skip();
if (frame.object) frame.key = key();
break;
}
if (text[pos] !== (frame.object ? '}' : ']')) throw fail("Expecting ',' delimiter");
pos++;
stack.pop();
// `_pairs` runs when the object closes, after everything inside it parsed.
if (frame.object && frame.duplicate) throw new ProjectionError('Duplicate JSON key');
value = frame.value;
}
if (!stack.length) break;
}
skip();
if (pos !== text.length) throw fail('Extra data');
return result;
}
/** `_normalize`: walk once; unretained values are only checked against the depth bound. */
function normalize(value, depth, retain) {
if (!isContainer(value)) return retain ? value : null;
if (depth > MAX_PROJECTION_DEPTH) throw new ProjectionLimit();
if (isArray(value)) {
const result = retain ? [] : null;
for (const child of value) {
const normalized = normalize(child, depth + 1, retain);
if (retain) result.push(normalized);
}
return result;
}
const result = retain ? Object.create(null) : null;
for (const key of Object.keys(value)) {
const normalized = normalize(value[key], depth + 1, retain);
if (retain) result[key] = normalized;
}
return result;
}
/** `_project_prompt`: each API node keeps exactly `class_type` and `inputs`; other keys are validated and dropped. */
function projectPrompt(tree, depth) {
if (!isObject(tree)) throw new ProjectionError('Prompt graph must be an object of nodes');
if (depth + 1 > MAX_PROJECTION_DEPTH) throw new ProjectionLimit();
const result = Object.create(null);
for (const id of Object.keys(tree)) {
const node = tree[id];
if (!isObject(node)) {
result[id] = normalize(node, depth + 1, true);
continue;
}
const projected = Object.create(null);
for (const key of Object.keys(node)) {
const keep = PROMPT_NODE_KEYS.has(key);
const normalized = normalize(node[key], depth + 2, keep);
if (keep) projected[key] = normalized;
}
result[id] = projected;
}
return result;
}
function project(tree, kind, depth) {
if (kind === 'prompt') return projectPrompt(tree, depth);
if (kind === 'workflow') return normalize(tree, depth, true);
if (!isObject(tree)) throw new ProjectionError('Malformed carrier');
const result = Object.create(null);
for (const key of Object.keys(tree)) {
const member = tree[key];
if (key !== 'prompt' && key !== 'workflow') normalize(member, depth + 1, false);
else if (typeof member === 'string') result[key] = member; // projected by its own kind when read
else if (key === 'prompt' && !isObject(member)) result[key] = normalize(member, depth + 1, true);
else result[key] = project(member, key, depth + 1);
}
return result;
}
/** `_strict_projection`: parse exactly and project by record kind ('prompt'|'workflow'|'carrier'). */
export function strictProjection(text, kind) {
return project(parseStrictJson(text), kind, 1);
}
function nodeCount(value, workflow) {
if (!workflow) return Object.keys(value).length;
const nodes = value.nodes;
let count = isArray(nodes) ? nodes.length : 0;
const definitions = value.definitions;
const subgraphs = isObject(definitions) ? definitions.subgraphs : null;
for (const subgraph of isArray(subgraphs) ? subgraphs : []) {
const inner = isObject(subgraph) ? subgraph.nodes : null;
count += isArray(inner) ? inner.length : 0;
}
return count;
}
function graph(value, workflow) {
if (!isObject(value)) return { status: 'malformed', value: null };
if (nodeCount(value, workflow) > GRAPH_NODE_CEILING) return { status: 'limited', value: null };
return { status: 'parsed', value };
}
function outcome(error) {
if (error instanceof ProjectionLimit) return { status: 'limited', value: null };
if (error instanceof ProjectionError) return { status: 'malformed', value: null };
throw error;
}
/**
* One record's graph as the inspector projects it: `{status, value}` with `status`
* `parsed` (the projected graph the facts parser receives), `limited` (past the
* depth or node bound) or `malformed`.
*/
export function projectGraph(text, kind) {
try {
return graph(strictProjection(text, kind), kind === 'workflow');
} catch (error) {
return outcome(error);
}
}
/**
* `_metadata` for one local read: `{records, record_formats, carrierText, limited}`
* (the server's InspectionRead) or null when the file's metadata is unreadable.
*/
export function projectMetadata(inspection) {
const formats = {}, raw = {};
if (inspection) {
for (const [key, value] of Object.entries(inspection.record_formats ?? {})) {
if (RECORD_KEYS.includes(key) && FORMATS.has(value)) formats[key] = value;
}
// A record over its read budget has no text: like `invalid`, raw omits it,
// and its graph is `limited` rather than parsed from nothing.
for (const key of inspection.limited ?? []) if (RECORD_KEYS.includes(key)) formats[key] = 'limited';
for (const [key, value] of Object.entries(inspection.records ?? {})) {
if (RECORD_KEYS.includes(key) && typeof value === 'string' && formats[key] !== 'limited') raw[key] = value;
}
}
const result = {
status: inspection === null || inspection === undefined ? 'unreadable' : Object.keys(raw).length || Object.keys(formats).length ? 'available' : 'absent',
omitted_reason: null, raw, record_formats: formats, parameters: Object.hasOwn(raw, 'parameters') ? raw.parameters : null,
};
let carrier = null, failed = null;
if (inspection && inspection.carrierText !== null && inspection.carrierText !== undefined) {
try { carrier = strictProjection(inspection.carrierText, 'carrier'); } catch (error) { failed = outcome(error).status; }
}
for (const [key, field] of [['prompt', 'prompt_graph'], ['workflow', 'workflow_graph']]) {
let value = { status: 'absent', value: null };
if (Object.hasOwn(raw, key) || Object.hasOwn(formats, key)) {
if (failed || formats[key] === 'limited') value.status = failed || 'limited';
else if (formats[key] === 'invalid') value.status = 'malformed';
else if (carrier === null && formats[key] === 'reserialized-json') value.status = 'limited';
else if (carrier === null) value = projectGraph(raw[key], key);
else {
try {
let member = carrier[key];
if (typeof member === 'string') member = strictProjection(member, key);
value = graph(member, key === 'workflow');
} catch (error) { value.status = outcome(error).status; }
}
}
result[field] = value;
}
return result;
}
const UTF8 = new TextEncoder();
const MEASURE_CHUNK_BYTES = 256 * 1024;
/**
* UTF-8 length of `JSON.stringify(value)`: the server's `_bytes` count. The text
* holds no lone surrogate (JSON.stringify escapes them), so the encoder's count is
* exact; it encodes into one bounded scratch buffer and never splits a pair.
*/
export function inspectionBytes(value) {
const text = JSON.stringify(value);
const scratch = new Uint8Array(Math.min(MEASURE_CHUNK_BYTES, text.length * 3 + 4));
let count = 0;
for (let offset = 0; offset < text.length; ) {
const { read, written } = UTF8.encodeInto(offset ? text.slice(offset) : text, scratch);
offset += read;
count += written;
}
return count;
}
const withMetadata = (payload, changes) => ({ ...payload, metadata: { ...payload.metadata, ...changes } });
function defaultYield() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
/**
* The omission steps of `_encode_inspection`: the workflow graph first, then the
* prompt graph, then all raw records atomically, each measured as encoded bytes.
* Each re-measure of a multi-megabyte payload follows a `yieldControl` turn, and
* `signal` aborts between steps. Resolves to a new payload; rejects when even the
* bare payload is over `limit`.
*/
export async function fitInspectionPayload(payload, limit = MAX_RESPONSE_BYTES, { yieldControl = defaultYield, signal } = {}) {
const measure = async (candidate) => {
await yieldControl();
if (signal && signal.aborted) throw new DOMException('The inspection was aborted', 'AbortError');
return inspectionBytes(candidate);
};
let fitted = payload, body = inspectionBytes(payload);
for (const field of ['workflow_graph', 'prompt_graph']) {
if (body <= limit) break;
if (fitted.metadata[field].status !== 'absent') {
fitted = withMetadata(fitted, { [field]: { status: 'limited', value: null } });
body = await measure(fitted);
}
}
if (body > limit) {
fitted = withMetadata(fitted, { raw: {}, parameters: null, status: 'limited', omitted_reason: 'response-size-limit' });
body = await measure(fitted);
}
if (body > limit) throw new RangeError('Inspection response exceeds limits');
return fitted;
}