* 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.
* feat(inspect): bounded browser video tag readers
Add web/js/workspace/local-video.mjs: ISO-BMFF (MP4/MOV/M4V) and Matroska
(MKV/WebM) walkers that read only the container tags the server asks ffprobe
for, slicing box and element headers, carrier names and retained values within
the server's budgets and never a byte inside mdat or a Cluster; a port of
utils/video_metadata.select_video_carrier with the record budgets of
read_inspection_file; and a byte-exact json.loads/json.dumps round trip so
envelope objects reserialize as the server spells them.
Every naming rule mirrors ffprobe 9.0.2 on hand-built files: legacy ilst atoms,
mdta keys, joined repeats, data types, Matroska language and default handling,
and Tags behind a Cluster found only through a SeekHead.
Tests: node units over byte-exact container builders and the committed
fixtures; a Python parity test comparing the local carrier with the server's
selection from the same tag maps (mocked probe) and from the same files (real
ffprobe, including 1 MiB + 2.5 MiB per-key records); an ffprobe
characterization of the recorded fixture maps.
* feat(inspect): server-identical local projection and payload
Port utils/inspection_response.py to the browser: a strict parse with Python's
Decimal spelling, duplicate-key rejection, NaN/Infinity as null and integers
past 2^53-1 as text; the prompt, workflow and carrier projection with the
depth-64 and 20,000-node ceilings; _metadata; and the 8 MiB omission steps
(workflow graph, then prompt graph, then all raw) measured as UTF-8 bytes.
local-inspection.mjs builds the inspection_version 1 wire for one chosen File
from the Task 1 and Task 2 readers (observation selected-local-file, interim
unresolved roles, unavailable library), fits it to the response budget, and
exposes localFileAdapter for the shared controller, inspector and viewer:
handles are session-owned by a private token, so a tampered or hand-made
descriptor is rejected and blob: URLs are never resolved through the API
client. projectInspectionPayload admits selected-local-file only for a
local-file identity.
A Python oracle test compares projectMetadata with _metadata byte for byte over
the parity PNG corpus, the committed video fixtures and tag maps, 75 committed
adversarial record sets (projection-cases.json) and node-ceiling sets built at
test time.
* test(inspect): import oracle modules by file URL for Windows
The local-inspect oracle imported product modules with path.join(...), which
Node parses as a URL scheme on Windows (D:\... -> ERR_UNSUPPORTED_ESM_URL_SCHEME),
failing every tests/test_local_inspect_parity.py oracle test on the Windows CI
job. Every dynamic import in tests/js/support now goes through moduleUrl, a
pathToFileURL wrapper, and a node unit proves the Windows form on any host and
scans the oracles so a path import cannot return.
* fix(inspect): read video tags as ffprobe reports them
Same bytes now give the server's record in five shapes where the local
reader was stricter or kept different tags (PR #277 final review I1, I2,
M1, M2, M4):
- Invalid UTF-8 in a tag value is replaced as ffprobe's default
string_validation=replace does (av_utf8_decode, one U+FFFD per failing
decode step), instead of failing the read. Key names and TagNames that
do not decode are no carrier and are skipped.
- An ilst item whose keys index is 0 or out of range is skipped, as
ffmpeg does, instead of failing the read.
- Only the first keys box is read, as ffmpeg does.
- A first per-key workflow over 4 MiB keeps its place in the carrier
choice as an unread marker, so the server's carrier (per-key, workflow
limited) is chosen; when the server's first probe would overflow, the
re-probe leaves it out and its own bound applies.
- An envelope integer of more than 4300 digits fails the Python json
port, so that envelope is skipped as CPython's json.loads skips it.
Each shape was checked against ffprobe 9.0.2 and read_inspection_file;
the real-ffprobe parity test now builds them, and a 600-case random-byte
comparison of the replacement against ffprobe matched.
* 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.
* fix(inspect): hold video tags as ffmpeg's dictionary does, decode Mac Roman text
Three more same-bytes divergences, found while verifying the review
fixes against ffprobe 9.0.2 and read_inspection_file:
- ffmpeg keeps one tag per ASCII-folded name under the spelling set
last (ISO-BMFF joins the values with ";", Matroska keeps the last), so
PROMPT and Prompt are one record on the server; the local reader kept
both and failed the read as a duplicate. A legacy comment atom and a
later COMMENT key likewise leave no exact-case envelope.
- ffprobe's format_tags filter folds ASCII case only, so a name that is
"workflow" only under Unicode case rules is never reported; the local
reader took it.
- ISO-BMFF data types 0 and 3 are Mac Roman text to ffmpeg, written into
twice the input size (a long value is cut); the local reader decoded
type 0 as UTF-8 and skipped type 3.
The real-ffprobe parity test builds each shape.
* docs(testing): list the local Inspect divergences; raise touched coverage floors
TESTING.md now tracks the local Inspect (#249 PR L1) divergences that
remain after the review fixes, each checked on a host with ffprobe 9.0.2:
media kind by bytes versus extension (parity is for the same bytes with a
matching extension), JPEG dimensions past the 64 KiB header read, classic
QuickTime udta text atoms, raw versus escaped tag bounds, the 10,000
element ceiling, unknown EBML DocTypes, the JSON depth approximation and
the C _decimal assumption.
coverage:update raised the branch floors of the three local Inspect
modules this PR touches; the unrelated activity-scan raise it proposed is
left at main's values.
1053 lines
46 KiB
JavaScript
1053 lines
46 KiB
JavaScript
// Local video tag readers for the Inspect page (#249): bounded ISO-BMFF (MP4/MOV/M4V)
|
|
// and Matroska (MKV/WebM) walkers that read only the container tags the server asks
|
|
// ffprobe for (utils/prompt_inspector.py _video_metadata), and a port of the carrier
|
|
// rule utils/video_metadata.py select_video_carrier plus the record budgets of
|
|
// read_inspection_file. Nothing here uploads, stores or logs the file. The walkers
|
|
// slice box and element headers, the tag names and the retained tag values only, so
|
|
// a multi-gigabyte video costs at most VIDEO_READ_CEILING bytes of reads and never a
|
|
// byte inside mdat or a Cluster. tests/test_local_inspect_parity.py proves the
|
|
// answers equal the server's on the committed fixtures and recorded tag maps.
|
|
//
|
|
// What ffprobe reports is the oracle: ISO-BMFF mdta keys are reported verbatim and
|
|
// the legacy ilst atoms ©cmt/desc as comment/description; Matroska TagNames are
|
|
// reported as written (ffmpeg upper-cases them at mux time). ffmpeg keeps one tag
|
|
// per ASCII-folded name (the last spelling) and ffprobe's tag filter folds ASCII
|
|
// only, so the reader does too. Envelope names are exact-case (comment,
|
|
// description) and the per-key names fold case in the selector, exactly as the
|
|
// server folds them.
|
|
|
|
import { INT_MAX_STR_DIGITS } from "./local-projection.mjs";
|
|
|
|
// Role records (prompt, parameters, the pair execution record) share one budget:
|
|
// past it the read fails. A workflow has its own budget; past it only the workflow
|
|
// is "limited". Duplicate tags draw from separate budgets per class. The total of
|
|
// tag bytes read is the server's ffprobe stdout bound.
|
|
export const ROLE_METADATA_BYTES = 2 * 1024 * 1024;
|
|
export const WORKFLOW_METADATA_BYTES = 4 * 1024 * 1024;
|
|
export const DUPLICATE_WORKFLOW_BYTES = 2 * WORKFLOW_METADATA_BYTES;
|
|
export const DUPLICATE_ROLE_BYTES = 2 * ROLE_METADATA_BYTES;
|
|
export const VIDEO_TAG_READ_BYTES = 2 * (ROLE_METADATA_BYTES + WORKFLOW_METADATA_BYTES) + 64 * 1024;
|
|
// The server's role-only re-probe bound, taken when the first probe overflows.
|
|
export const VIDEO_REPROBE_BYTES = 2 * ROLE_METADATA_BYTES + 64 * 1024;
|
|
// A first per-key workflow over its budget is never sliced; this marker holds its
|
|
// place in the tag map, as the server's first probe still reports the tag.
|
|
export const OVERSIZED_WORKFLOW = Object.freeze({ oversized: "workflow" });
|
|
export const MAX_VIDEO_ELEMENTS = 10000;
|
|
const MAX_HEADER_BYTES = 16; // a 64-bit ISO-BMFF box header; an EBML header is at most 12
|
|
const MAX_NAME_BYTES = 64; // the longest carrier name is 24 bytes; longer names are never ours
|
|
// Every byte the readers can slice from a video of any size: each element's header
|
|
// and name plus the retained tag values.
|
|
export const VIDEO_READ_CEILING = MAX_VIDEO_ELEMENTS * (MAX_HEADER_BYTES + MAX_NAME_BYTES) + VIDEO_TAG_READ_BYTES;
|
|
const PREFIX_BYTES = 32;
|
|
const YIELD_INTERVAL_MS = 8;
|
|
const JSON_MAX_DEPTH = 1000; // Python's default recursion limit
|
|
|
|
const ROLE_KEYS = ["prompt", "parameters", "comfyassets_prompt_pairs"];
|
|
const METADATA_KEYS = ["prompt", "workflow", "parameters", "comfyassets_prompt_pairs"];
|
|
const ENVELOPE_KEYS = ["comment", "description"];
|
|
const PER_KEY_NAMES = new Set(METADATA_KEYS);
|
|
const ENVELOPE_NAMES = new Set(ENVELOPE_KEYS);
|
|
// The tags ffprobe is asked for (format_tags=...): its filter, like ffmpeg's tag
|
|
// dictionary, matches names ignoring ASCII case only.
|
|
const PROBED_NAMES = new Set([...METADATA_KEYS, ...ENVELOPE_KEYS]);
|
|
// ffmpeg's mac_to_unicode for bytes 0x80-0xFF (Mac OS Roman, checked through ffprobe).
|
|
const MAC_ROMAN =
|
|
"\u00c4\u00c5\u00c7\u00c9\u00d1\u00d6\u00dc\u00e1\u00e0\u00e2\u00e4\u00e3\u00e5\u00e7\u00e9\u00e8" +
|
|
"\u00ea\u00eb\u00ed\u00ec\u00ee\u00ef\u00f1\u00f3\u00f2\u00f4\u00f6\u00f5\u00fa\u00f9\u00fb\u00fc" +
|
|
"\u2020\u00b0\u00a2\u00a3\u00a7\u2022\u00b6\u00df\u00ae\u00a9\u2122\u00b4\u00a8\u2260\u00c6\u00d8" +
|
|
"\u221e\u00b1\u2264\u2265\u00a5\u00b5\u2202\u2211\u220f\u03c0\u222b\u00aa\u00ba\u03a9\u00e6\u00f8" +
|
|
"\u00bf\u00a1\u00ac\u221a\u0192\u2248\u2206\u00ab\u00bb\u2026\u00a0\u00c0\u00c3\u00d5\u0152\u0153" +
|
|
"\u2013\u2014\u201c\u201d\u2018\u2019\u00f7\u25ca\u00ff\u0178\u2044\u20ac\u2039\u203a\ufb01\ufb02" +
|
|
"\u2021\u00b7\u201a\u201e\u2030\u00c2\u00ca\u00c1\u00cb\u00c8\u00cd\u00ce\u00cf\u00cc\u00d3\u00d4" +
|
|
"\uf8ff\u00d2\u00da\u00db\u00d9\u0131\u02c6\u02dc\u00af\u02d8\u02d9\u02da\u00b8\u02dd\u02db\u02c7";
|
|
const MAC_ROMAN_UTF8 = Array.from(MAC_ROMAN, (char) => new TextEncoder().encode(char));
|
|
// ffprobe's names for the legacy ilst atoms that can carry an envelope.
|
|
const LEGACY_ATOMS = new Map([["©cmt", "comment"], ["desc", "description"], ["©des", "description"]]);
|
|
const ASF_GUID = [0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c];
|
|
|
|
const EBML = {
|
|
header: 0x1a45dfa3,
|
|
docType: 0x4282,
|
|
segment: 0x18538067,
|
|
seekHead: 0x114d9b74,
|
|
seek: 0x4dbb,
|
|
seekId: 0x53ab,
|
|
seekPosition: 0x53ac,
|
|
tags: 0x1254c367,
|
|
tag: 0x7373,
|
|
targets: 0x63c0,
|
|
trackUid: 0x63c5,
|
|
chapterUid: 0x63c4,
|
|
attachmentUid: 0x63c6,
|
|
simpleTag: 0x67c8,
|
|
tagName: 0x45a3,
|
|
tagString: 0x4487,
|
|
tagLanguage: 0x447a,
|
|
tagDefault: 0x4484,
|
|
cluster: 0x1f43b675,
|
|
};
|
|
const TAGS_ID_BYTES = [0x12, 0x54, 0xc3, 0x67];
|
|
|
|
class VideoReadError extends Error {
|
|
constructor(status, reason, message) {
|
|
super(message);
|
|
this.name = "VideoReadError";
|
|
this.status = status;
|
|
this.reason = reason;
|
|
}
|
|
}
|
|
|
|
const invalid = (message) => new VideoReadError("unreadable", "invalid-container", message);
|
|
const limited = (reason, message) => new VideoReadError("limited", reason, message);
|
|
const readFailure = (message) => new VideoReadError("unreadable", "read-failure", message);
|
|
|
|
function abortError() {
|
|
return new DOMException("The file read was aborted", "AbortError");
|
|
}
|
|
|
|
function throwIfAborted(signal) {
|
|
if (signal && signal.aborted) throw abortError();
|
|
}
|
|
|
|
function toBytes(input) {
|
|
if (input instanceof Uint8Array) return input;
|
|
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
if (Array.isArray(input)) return Uint8Array.from(input);
|
|
return null;
|
|
}
|
|
|
|
function latin1(bytes, start, end) {
|
|
let out = "";
|
|
for (let index = start; index < end; index += 1) out += String.fromCharCode(bytes[index]);
|
|
return out;
|
|
}
|
|
|
|
function startsWith(bytes, prefix, at = 0) {
|
|
if (bytes.length < at + prefix.length) return false;
|
|
for (let index = 0; index < prefix.length; index += 1) if (bytes[at + index] !== prefix[index]) return false;
|
|
return true;
|
|
}
|
|
|
|
function readUint32(bytes, offset) {
|
|
return ((bytes[offset] << 24) >>> 0) + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3];
|
|
}
|
|
|
|
/** A big-endian unsigned integer of up to 8 bytes as a Number, or null past MAX_SAFE_INTEGER. */
|
|
function readUintBytes(bytes, start, end) {
|
|
let value = 0n;
|
|
for (let index = start; index < end; index += 1) value = (value << 8n) | BigInt(bytes[index]);
|
|
return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** ffmpeg stores tag text as a C string: the first NUL ends it. */
|
|
function untilNul(bytes) {
|
|
const nul = bytes.indexOf(0);
|
|
return nul < 0 ? bytes : bytes.subarray(0, nul);
|
|
}
|
|
|
|
const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
const REPLACEMENT = "\ufffd";
|
|
// av_utf8_decode's smallest code point per continuation count (overlong below it).
|
|
const OVERLONG_MINIMUM = [0, 0x80, 0x800, 0x10000];
|
|
|
|
/**
|
|
* One av_utf8_decode step (flags 0, as ffprobe calls it) from `start`: the next
|
|
* offset and whether the sequence is valid. A lead byte that is a continuation or
|
|
* 0xFE/0xFF, an overlong form, a surrogate, a code above U+10FFFF, U+FFFE/U+FFFF
|
|
* and every 5/6-byte form fail as the whole sequence; a missing or bad
|
|
* continuation byte fails the lead byte alone.
|
|
*/
|
|
function ffprobeStep(bytes, start) {
|
|
const lead = bytes[start];
|
|
if ((lead & 0xc0) === 0x80 || lead >= 0xfe) return [start + 1, false];
|
|
let tail = 0;
|
|
if (lead & 0x80) while (lead & (0x40 >> tail)) tail += 1;
|
|
let code = tail ? lead & (0x3f >> tail) : lead;
|
|
let next = start + 1;
|
|
for (let index = 0; index < tail; index += 1, next += 1) {
|
|
if (next >= bytes.length || (bytes[next] & 0xc0) !== 0x80) return [start + 1, false];
|
|
code = code * 64 + (bytes[next] & 0x3f);
|
|
}
|
|
const valid = tail <= 3 && code >= OVERLONG_MINIMUM[tail] && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) && code !== 0xfffe && code !== 0xffff;
|
|
return [next, valid];
|
|
}
|
|
|
|
/**
|
|
* Tag text as ffprobe prints it with its default string_validation=replace
|
|
* (fftools validate_string): each failing av_utf8_decode step becomes one U+FFFD,
|
|
* so Python's json.loads of ffprobe's output never meets invalid UTF-8. This
|
|
* differs from WHATWG decoding in how many replacements a bad sequence yields.
|
|
*/
|
|
function ffprobeText(bytes) {
|
|
let text = null;
|
|
try {
|
|
text = STRICT_UTF8.decode(bytes);
|
|
} catch (error) {
|
|
text = null;
|
|
}
|
|
if (text !== null && !/[\ufffe\uffff]/.test(text)) return text;
|
|
const parts = [];
|
|
let run = 0;
|
|
for (let offset = 0; offset < bytes.length; ) {
|
|
const [next, valid] = ffprobeStep(bytes, offset);
|
|
if (!valid) {
|
|
if (offset > run) parts.push(STRICT_UTF8.decode(bytes.subarray(run, offset)));
|
|
parts.push(REPLACEMENT);
|
|
run = next;
|
|
}
|
|
offset = next;
|
|
}
|
|
if (bytes.length > run) parts.push(STRICT_UTF8.decode(bytes.subarray(run)));
|
|
return parts.join("");
|
|
}
|
|
|
|
/** ASCII-only lower case, as ffmpeg's av_strcasecmp compares names. */
|
|
function asciiLower(name) {
|
|
return name.replace(/[A-Z]+/g, (letters) => letters.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* ffmpeg's mov_read_mac_string: Mac Roman bytes as UTF-8 into a buffer of twice
|
|
* the input size, so a long non-ASCII value is cut, possibly inside a character
|
|
* (ffprobe then replaces the cut sequence).
|
|
*/
|
|
function macRomanBytes(bytes) {
|
|
const limit = bytes.length * 2;
|
|
const out = new Uint8Array(limit);
|
|
let length = 0;
|
|
for (const byte of bytes) {
|
|
if (length >= limit) break;
|
|
if (byte < 0x80) out[length++] = byte;
|
|
else for (const unit of MAC_ROMAN_UTF8[byte - 0x80]) if (length < limit) out[length++] = unit;
|
|
}
|
|
return out.subarray(0, length);
|
|
}
|
|
|
|
/** UTF-8 length as Python's ``encode("utf-8", "surrogatepass")`` counts it. */
|
|
function utf8Length(text) {
|
|
let total = 0;
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
const code = text.charCodeAt(index);
|
|
if (code < 0x80) total += 1;
|
|
else if (code < 0x800) total += 2;
|
|
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) {
|
|
const next = text.charCodeAt(index + 1);
|
|
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
total += 4;
|
|
index += 1;
|
|
} else total += 3;
|
|
} else total += 3;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
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, signal) {
|
|
let last = performance.now();
|
|
return {
|
|
async maybeYield() {
|
|
if (performance.now() - last < YIELD_INTERVAL_MS) return;
|
|
await yieldControl();
|
|
throwIfAborted(signal);
|
|
last = performance.now();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Logical byte ranges of `file`, served from the retained `prefix` where it covers
|
|
* them (the twin of png-metadata.js's reader). Every sliced byte is charged against
|
|
* VIDEO_READ_CEILING, and every range is checked against `file.size` before slicing.
|
|
*/
|
|
function createSliceReader(file, prefix, signal) {
|
|
const retained = prefix || new Uint8Array(0);
|
|
let charged = 0;
|
|
return {
|
|
async read(start, end) {
|
|
throwIfAborted(signal);
|
|
if (!Number.isSafeInteger(end) || end > file.size || start > end) throw invalid("Incomplete video container");
|
|
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 > VIDEO_READ_CEILING) throw readFailure("Video 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;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Container kind from the first bytes, mirroring png-metadata.js classifyPrefix. */
|
|
function detectKind(bytes) {
|
|
if (!bytes) return null;
|
|
if (bytes.length >= 12 && latin1(bytes, 0, 4) === "RIFF") return latin1(bytes, 8, 12) === "AVI " ? "avi" : null;
|
|
if (bytes.length >= 8 && latin1(bytes, 4, 8) === "ftyp") return "iso-bmff";
|
|
if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return "matroska";
|
|
if (startsWith(bytes, ASF_GUID)) return "asf";
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The shared walk state: the element count, the tag budgets and the collected tags.
|
|
* `value` is the one gate every tag value passes before it is sliced.
|
|
*/
|
|
function createWalk(reader, signal, pacer) {
|
|
const tags = {};
|
|
const limitedRecords = [];
|
|
const seen = new Set();
|
|
let count = 0;
|
|
let role = 0;
|
|
let workflow = 0;
|
|
let duplicateRole = 0;
|
|
let duplicateWorkflow = 0;
|
|
let total = 0;
|
|
let workflowRead = 0; // bytes of per-key workflow tags read, which the re-probe leaves out
|
|
let oversized = null; // {size}: the unread first per-key workflow
|
|
// ffmpeg keeps one tag per ASCII-folded name, under the spelling set last.
|
|
const spelling = new Map();
|
|
const overLimit = () => limited("metadata-limit", "Video metadata exceeds inspection limits");
|
|
const forgetOversized = () => {
|
|
oversized = null;
|
|
limitedRecords.splice(limitedRecords.indexOf("workflow"), 1);
|
|
};
|
|
const current = (folded) => (spelling.has(folded) ? tags[spelling.get(folded)] : undefined);
|
|
const store = (name, folded, value) => {
|
|
if (spelling.has(folded)) delete tags[spelling.get(folded)];
|
|
tags[name] = value;
|
|
spelling.set(folded, name);
|
|
};
|
|
return {
|
|
tags,
|
|
limited: limitedRecords,
|
|
async header(offset, length) {
|
|
count += 1;
|
|
if (count > MAX_VIDEO_ELEMENTS) throw limited("chunk-limit", "Video container exceeds inspection limits");
|
|
await pacer.maybeYield();
|
|
return reader.read(offset, offset + length);
|
|
},
|
|
read: (start, end) => reader.read(start, end),
|
|
/** Whether ffprobe reports `name` to the server (the probed names, ASCII case folded). */
|
|
known(name) {
|
|
return PROBED_NAMES.has(asciiLower(name));
|
|
},
|
|
/** A string-less Matroska SimpleTag deletes the name, ignoring ASCII case. */
|
|
remove(name) {
|
|
const folded = asciiLower(name);
|
|
if (current(folded) === OVERSIZED_WORKFLOW) forgetOversized();
|
|
if (spelling.has(folded)) delete tags[spelling.get(folded)];
|
|
spelling.delete(folded);
|
|
},
|
|
/**
|
|
* Settles the oversized workflow as the server's probes would: when the
|
|
* first probe fits, the tag keeps its marker; when it overflows, the
|
|
* role-only re-probe reports no per-key workflow and has its own bound.
|
|
*/
|
|
finish() {
|
|
if (!oversized || total + oversized.size <= VIDEO_TAG_READ_BYTES) return;
|
|
// The re-probe leaves the per-key workflow out; it stays "limited".
|
|
if (spelling.has("workflow")) delete tags[spelling.get("workflow")];
|
|
spelling.delete("workflow");
|
|
if (total - workflowRead > VIDEO_REPROBE_BYTES) throw overLimit();
|
|
},
|
|
/**
|
|
* Reads and keeps the tag `name` (a known carrier, see `known`) whose value
|
|
* spans [start, end), within the budgets. `join` appends to an existing value
|
|
* of the same folded name with that separator (ISO-BMFF repeats, as ffprobe
|
|
* reports them; Matroska replaces); `text` stores an already decoded value
|
|
* and `mac` decodes Mac Roman.
|
|
*/
|
|
async value(name, start, end, { join = null, text = null, mac = false } = {}) {
|
|
const size = end - start;
|
|
const folded = asciiLower(name);
|
|
const previous = current(folded);
|
|
if (PER_KEY_NAMES.has(folded)) {
|
|
const first = !seen.has(folded);
|
|
seen.add(folded);
|
|
if (folded === "workflow") {
|
|
if (first && size > WORKFLOW_METADATA_BYTES) {
|
|
oversized = { size };
|
|
store(name, folded, OVERSIZED_WORKFLOW);
|
|
limitedRecords.push("workflow");
|
|
return;
|
|
}
|
|
if (previous === OVERSIZED_WORKFLOW) {
|
|
// An ISO-BMFF repeat joins onto the unread value, still over budget.
|
|
if (join !== null) {
|
|
oversized.size += size;
|
|
store(name, folded, OVERSIZED_WORKFLOW);
|
|
return;
|
|
}
|
|
// Matroska keeps a name's last value: the oversized one is gone.
|
|
forgetOversized();
|
|
}
|
|
workflowRead += size;
|
|
if (first) workflow += size;
|
|
else duplicateWorkflow += size;
|
|
if (duplicateWorkflow > DUPLICATE_WORKFLOW_BYTES) throw overLimit();
|
|
} else {
|
|
if (first) role += size;
|
|
else duplicateRole += size;
|
|
if (role > ROLE_METADATA_BYTES || duplicateRole > DUPLICATE_ROLE_BYTES) throw overLimit();
|
|
}
|
|
}
|
|
total += size;
|
|
if (total > VIDEO_TAG_READ_BYTES) throw overLimit();
|
|
let value = text;
|
|
if (value === null) {
|
|
const bytes = await reader.read(start, end);
|
|
value = ffprobeText(untilNul(mac ? macRomanBytes(bytes) : bytes));
|
|
}
|
|
store(name, folded, join !== null && previous !== undefined ? `${previous}${join}${value}` : value);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------------ ISO-BMFF
|
|
|
|
/** One box header at `offset` inside a parent ending at `parentEnd`, or null when fewer than 8 bytes remain. */
|
|
async function readBox(walk, offset, parentEnd) {
|
|
if (parentEnd - offset < 8) return null;
|
|
const bytes = await walk.header(offset, Math.min(MAX_HEADER_BYTES, parentEnd - offset));
|
|
let size = readUint32(bytes, 0);
|
|
let bodyStart = offset + 8;
|
|
if (size === 1) {
|
|
if (bytes.length < 16) throw invalid("Truncated 64-bit box size");
|
|
const large = readUintBytes(bytes, 8, 16);
|
|
if (large === null || large < 16) throw invalid("Invalid 64-bit box size");
|
|
size = large;
|
|
bodyStart = offset + 16;
|
|
} else if (size === 0) {
|
|
size = parentEnd - offset;
|
|
} else if (size < 8) {
|
|
throw invalid("Box size below its header");
|
|
}
|
|
const end = offset + size;
|
|
if (end > parentEnd) throw invalid("Box extends past its parent");
|
|
return { type: latin1(bytes, 4, 8), raw: bytes.subarray(4, 8), start: offset, bodyStart, end };
|
|
}
|
|
|
|
async function readIsoBmff(walk, fileSize) {
|
|
const state = { hdlrMdta: false, keys: null, format: null };
|
|
let offset = 0;
|
|
let box;
|
|
while ((box = await readBox(walk, offset, fileSize))) {
|
|
if (box.type === "ftyp" && box.end - box.bodyStart >= 4) {
|
|
state.format = latin1(await walk.read(box.bodyStart, box.bodyStart + 4), 0, 4).replace(/[\s\0]+$/, "");
|
|
} else if (box.type === "moov") {
|
|
await readMoov(walk, box, state);
|
|
return state.format;
|
|
}
|
|
offset = box.end;
|
|
}
|
|
throw invalid("No moov box");
|
|
}
|
|
|
|
async function readMoov(walk, moov, state) {
|
|
let offset = moov.bodyStart;
|
|
let box;
|
|
while ((box = await readBox(walk, offset, moov.end))) {
|
|
if (box.type === "meta") await readMeta(walk, box, state);
|
|
else if (box.type === "udta") {
|
|
let inner = box.bodyStart;
|
|
let child;
|
|
while ((child = await readBox(walk, inner, box.end))) {
|
|
if (child.type === "meta") await readMeta(walk, child, state);
|
|
inner = child.end;
|
|
}
|
|
}
|
|
offset = box.end;
|
|
}
|
|
}
|
|
|
|
/** `meta` as ffmpeg reads it: scan 4-byte words for the first child's `hdlr` type. */
|
|
async function readMeta(walk, meta, state) {
|
|
const scan = await walk.read(meta.bodyStart, Math.min(meta.end, meta.bodyStart + MAX_NAME_BYTES));
|
|
let childrenStart = null;
|
|
for (let word = 4; word + 4 <= scan.length; word += 4) {
|
|
if (latin1(scan, word, word + 4) === "hdlr") {
|
|
childrenStart = meta.bodyStart + word - 4;
|
|
break;
|
|
}
|
|
}
|
|
if (childrenStart === null) return;
|
|
let offset = childrenStart;
|
|
let box;
|
|
while ((box = await readBox(walk, offset, meta.end))) {
|
|
if (box.type === "hdlr") {
|
|
if (box.end - box.bodyStart >= 12) {
|
|
const handler = await walk.read(box.bodyStart + 8, box.bodyStart + 12);
|
|
if (latin1(handler, 0, 4) === "mdta") state.hdlrMdta = true;
|
|
}
|
|
} else if (box.type === "keys") await readKeys(walk, box, state);
|
|
else if (box.type === "ilst") await readIlst(walk, box, state);
|
|
offset = box.end;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The `keys` box: 1-based mdta names; other namespaces and overlong names address
|
|
* nothing. As ffmpeg, only the first keys box with a count field is read.
|
|
*/
|
|
async function readKeys(walk, box, state) {
|
|
if (state.keys || box.end - box.bodyStart < 8) return;
|
|
const head = await walk.read(box.bodyStart, box.bodyStart + 8);
|
|
const count = readUint32(head, 4);
|
|
const keys = [null];
|
|
let offset = box.bodyStart + 8;
|
|
for (let index = 1; index <= count; index += 1) {
|
|
if (box.end - offset < 8) throw invalid("Keys box count exceeds its entries");
|
|
const entry = await walk.header(offset, 8);
|
|
const keySize = readUint32(entry, 0);
|
|
if (keySize < 8 || offset + keySize > box.end) throw invalid("Keys entry exceeds its box");
|
|
const nameLength = keySize - 8;
|
|
let name = null;
|
|
if (latin1(entry, 4, 8) === "mdta" && nameLength <= MAX_NAME_BYTES) {
|
|
name = ffprobeText(await walk.read(offset + 8, offset + keySize));
|
|
}
|
|
keys.push(name);
|
|
offset += keySize;
|
|
}
|
|
state.keys = keys;
|
|
}
|
|
|
|
/** ffprobe's text for an integer `data` atom (types 21 and 22) of 1, 2, 4 or 8 bytes, else null. */
|
|
function integerText(bytes, signed) {
|
|
if (![1, 2, 4, 8].includes(bytes.length)) return null;
|
|
let value = 0n;
|
|
for (const byte of bytes) value = (value << 8n) | BigInt(byte);
|
|
if (signed) value = BigInt.asIntN(bytes.length * 8, value);
|
|
return value.toString();
|
|
}
|
|
|
|
/**
|
|
* ilst items: a legacy fourcc or a `keys` index, then the leading run of `data`
|
|
* children. As ffprobe reports them, repeated values of one name join with ";",
|
|
* types 1 and 4 are UTF-8 text, 0 and 3 Mac Roman text, 21 and 22 integers, other
|
|
* types are not tags.
|
|
*/
|
|
async function readIlst(walk, ilst, state) {
|
|
let offset = ilst.bodyStart;
|
|
let item;
|
|
while ((item = await readBox(walk, offset, ilst.end))) {
|
|
offset = item.end;
|
|
let name = LEGACY_ATOMS.get(item.type);
|
|
if (name === undefined && state.hdlrMdta && state.keys) {
|
|
const index = readUint32(item.raw, 0);
|
|
// ffmpeg warns that the index is out of range and skips the item.
|
|
if (index === 0 || index >= state.keys.length) continue;
|
|
name = state.keys[index];
|
|
}
|
|
if (!name || !walk.known(name)) continue;
|
|
let inner = item.bodyStart;
|
|
while (item.end - inner >= 16) {
|
|
const data = await walk.header(inner, 16);
|
|
const dataSize = readUint32(data, 0);
|
|
if (latin1(data, 4, 8) !== "data" || dataSize < 16 || dataSize > item.end - inner) break;
|
|
const type = readUint32(data, 8);
|
|
const start = inner + 16;
|
|
const end = inner + dataSize;
|
|
inner = end;
|
|
// Types 0 and 3 are Mac Roman text to ffmpeg; 1 and 4 are UTF-8.
|
|
if (type === 0 || type === 3) await walk.value(name, start, end, { join: ";", mac: true });
|
|
else if (type === 1 || type === 4) await walk.value(name, start, end, { join: ";" });
|
|
else if ((type === 21 || type === 22) && end - start <= 8) {
|
|
const text = integerText(await walk.read(start, end), type === 21);
|
|
if (text !== null) await walk.value(name, start, end, { join: ";", text });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ Matroska
|
|
|
|
/** One EBML element header at `offset` inside a parent ending at `parentEnd`. */
|
|
async function readElement(walk, offset, parentEnd) {
|
|
if (parentEnd - offset < 2) return null;
|
|
const bytes = await walk.header(offset, Math.min(12, parentEnd - offset));
|
|
const idLength = Math.clz32(bytes[0]) - 23;
|
|
if (bytes[0] === 0 || idLength > 4 || bytes.length < idLength + 1) throw invalid("Invalid EBML element id");
|
|
const id = readUintBytes(bytes, 0, idLength);
|
|
const sizeByte = bytes[idLength];
|
|
const sizeLength = Math.clz32(sizeByte) - 23;
|
|
if (sizeByte === 0 || bytes.length < idLength + sizeLength) throw invalid("Invalid EBML element size");
|
|
let size = BigInt(sizeByte & (0xff >> sizeLength));
|
|
for (let index = idLength + 1; index < idLength + sizeLength; index += 1) size = (size << 8n) | BigInt(bytes[index]);
|
|
const unknown = size === (1n << BigInt(7 * sizeLength)) - 1n;
|
|
if (!unknown && size > BigInt(Number.MAX_SAFE_INTEGER)) throw invalid("EBML element size exceeds inspection limits");
|
|
const dataStart = offset + idLength + sizeLength;
|
|
const end = unknown ? null : dataStart + Number(size);
|
|
if (end !== null && end > parentEnd) throw invalid("EBML element extends past its parent");
|
|
return { id, dataStart, end, unknown };
|
|
}
|
|
|
|
/** Children of a sized master element, in order. */
|
|
async function* children(walk, element) {
|
|
let offset = element.dataStart;
|
|
let child;
|
|
while ((child = await readElement(walk, offset, element.end))) {
|
|
if (child.unknown) throw invalid("Unknown-size element inside a master element");
|
|
yield child;
|
|
offset = child.end;
|
|
}
|
|
}
|
|
|
|
async function readMatroska(walk, fileSize) {
|
|
const header = await readElement(walk, 0, fileSize);
|
|
if (!header || header.id !== EBML.header || header.unknown) throw invalid("Not an EBML file");
|
|
let docType = null;
|
|
for await (const child of children(walk, header)) {
|
|
if (child.id === EBML.docType && child.end - child.dataStart <= MAX_NAME_BYTES) {
|
|
docType = latin1(untilNul(await walk.read(child.dataStart, child.end)), 0, Math.min(child.end - child.dataStart, MAX_NAME_BYTES));
|
|
}
|
|
}
|
|
if (docType !== "matroska" && docType !== "webm") throw invalid("Unsupported EBML DocType");
|
|
const segment = await readElement(walk, header.end, fileSize);
|
|
if (!segment || segment.id !== EBML.segment) throw invalid("No Segment element");
|
|
const segmentEnd = segment.unknown ? fileSize : segment.end;
|
|
// As ffmpeg's demuxer: level-1 elements are read up to the first Cluster (of known
|
|
// or unknown size), then SeekHead entries locate Tags stored behind the clusters.
|
|
const seekTargets = [];
|
|
let offset = segment.dataStart;
|
|
let element;
|
|
while ((element = await readElement(walk, offset, segmentEnd))) {
|
|
if (element.id === EBML.cluster) break;
|
|
if (element.unknown) throw invalid("Unknown-size element that is not a Segment or Cluster");
|
|
if (element.id === EBML.seekHead) await readSeekHead(walk, element, segment.dataStart, seekTargets);
|
|
else if (element.id === EBML.tags) await readTags(walk, element);
|
|
offset = element.end;
|
|
}
|
|
for (const target of seekTargets) {
|
|
if (target < offset || target >= segmentEnd) continue;
|
|
const pointed = await readElement(walk, target, segmentEnd);
|
|
if (!pointed || pointed.id !== EBML.tags || pointed.unknown) continue;
|
|
await readTags(walk, pointed);
|
|
}
|
|
return docType;
|
|
}
|
|
|
|
async function readSeekHead(walk, seekHead, segmentStart, targets) {
|
|
for await (const seek of children(walk, seekHead)) {
|
|
if (seek.id !== EBML.seek) continue;
|
|
let isTags = false;
|
|
let position = null;
|
|
for await (const field of children(walk, seek)) {
|
|
const length = field.end - field.dataStart;
|
|
if (field.id === EBML.seekId && length <= 4) {
|
|
isTags = startsWith(await walk.read(field.dataStart, field.end), TAGS_ID_BYTES) && length === 4;
|
|
} else if (field.id === EBML.seekPosition && length <= 8) {
|
|
position = readUintBytes(await walk.read(field.dataStart, field.end), 0, length);
|
|
}
|
|
}
|
|
if (isTags && position !== null) targets.push(segmentStart + position);
|
|
}
|
|
}
|
|
|
|
async function readTags(walk, tags) {
|
|
for await (const tag of children(walk, tags)) {
|
|
if (tag.id !== EBML.tag) continue;
|
|
let targeted = false;
|
|
const simpleTags = [];
|
|
for await (const child of children(walk, tag)) {
|
|
if (child.id === EBML.simpleTag) simpleTags.push(child);
|
|
else if (child.id === EBML.targets) targeted = await readTargets(walk, child);
|
|
}
|
|
if (targeted) continue;
|
|
for (const simpleTag of simpleTags) await readSimpleTag(walk, simpleTag);
|
|
}
|
|
}
|
|
|
|
/** Whether the Targets name a track, chapter or attachment (ffmpeg files those tags elsewhere). */
|
|
async function readTargets(walk, targets) {
|
|
let targeted = false;
|
|
for await (const field of children(walk, targets)) {
|
|
const length = field.end - field.dataStart;
|
|
if ((field.id === EBML.trackUid || field.id === EBML.chapterUid || field.id === EBML.attachmentUid) && length <= 8) {
|
|
if ((await walk.read(field.dataStart, field.end)).some((byte) => byte !== 0)) targeted = true;
|
|
}
|
|
}
|
|
return targeted;
|
|
}
|
|
|
|
/**
|
|
* One SimpleTag as ffmpeg's matroska_convert_tag files it: under its plain name when
|
|
* it has no language other than und or TagDefault is set (the "-lang" name is never
|
|
* ours), a string-less tag deletes the name, nested SimpleTags become PARENT/CHILD
|
|
* (never ours).
|
|
*/
|
|
async function readSimpleTag(walk, simpleTag) {
|
|
let name = null;
|
|
let language = null;
|
|
let isDefault = 0;
|
|
let string = null;
|
|
for await (const field of children(walk, simpleTag)) {
|
|
const length = field.end - field.dataStart;
|
|
if (field.id === EBML.tagName) {
|
|
name = length <= MAX_NAME_BYTES ? ffprobeText(untilNul(await walk.read(field.dataStart, field.end))) : "";
|
|
} else if (field.id === EBML.tagLanguage && length <= MAX_NAME_BYTES) {
|
|
language = latin1(untilNul(await walk.read(field.dataStart, field.end)), 0, Math.min(length, MAX_NAME_BYTES));
|
|
} else if (field.id === EBML.tagDefault && length <= 8) {
|
|
isDefault = readUintBytes(await walk.read(field.dataStart, field.end), 0, length) ?? 0;
|
|
} else if (field.id === EBML.tagString) {
|
|
string = field;
|
|
}
|
|
}
|
|
if (name === null || !walk.known(name)) return;
|
|
const lang = language && language !== "und" ? language : null;
|
|
if (isDefault === 0 && lang) return;
|
|
if (string === null) walk.remove(name);
|
|
else await walk.value(name, string.dataStart, string.end);
|
|
}
|
|
|
|
/**
|
|
* Reads the prompt carrier tags of a video File without touching its media data.
|
|
*
|
|
* @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 {string} [options.kind] - the classifier's kind, when the prefix was not kept
|
|
* @returns {Promise<{status:'complete', container:'iso-bmff'|'matroska', format:string|null, tags:Object<string,string>, limited:string[]}
|
|
* | {status:'unreadable', reason:'invalid-container'|'read-failure'}
|
|
* | {status:'limited', reason:'metadata-limit'|'chunk-limit'}
|
|
* | {status:'unsupported', reason:'video-container-unsupported'}>}
|
|
*/
|
|
export async function readVideoTags(file, options = {}) {
|
|
const { signal, yieldControl = defaultYield } = options;
|
|
try {
|
|
throwIfAborted(signal);
|
|
if (!file || !Number.isSafeInteger(file.size) || typeof file.slice !== "function") throw readFailure("Not a readable file");
|
|
let prefix = toBytes(options.prefix);
|
|
if (!prefix && options.kind === undefined) {
|
|
prefix = await createSliceReader(file, null, signal).read(0, Math.min(PREFIX_BYTES, file.size));
|
|
}
|
|
const container = options.kind ?? detectKind(prefix);
|
|
if (container === "avi" || container === "asf") return { status: "unsupported", reason: "video-container-unsupported" };
|
|
if (container !== "iso-bmff" && container !== "matroska") throw invalid("Not a supported video container");
|
|
const walk = createWalk(createSliceReader(file, prefix, signal), signal, createPacer(yieldControl, signal));
|
|
const format = container === "iso-bmff" ? await readIsoBmff(walk, file.size) : await readMatroska(walk, file.size);
|
|
walk.finish();
|
|
return { status: "complete", container, format, tags: walk.tags, limited: walk.limited };
|
|
} catch (error) {
|
|
if (error && error.name === "AbortError") throw error;
|
|
if (error instanceof VideoReadError) return { status: error.status, reason: error.reason };
|
|
return { status: "unreadable", reason: "read-failure" };
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------- Python json port (loads/dumps)
|
|
|
|
export class PythonJsonError extends Error {
|
|
constructor(message, recursion = false) {
|
|
super(message);
|
|
this.name = "PythonJsonError";
|
|
this.recursion = recursion;
|
|
}
|
|
}
|
|
|
|
/** A JSON number as Python keeps it: the lexeme, and whether it is a float. */
|
|
class PyNumber {
|
|
constructor(text, isFloat) {
|
|
this.text = text;
|
|
this.isFloat = isFloat;
|
|
}
|
|
}
|
|
|
|
const NUMBER = /-?(?:0|[1-9]\d*)(\.\d+)?([eE][-+]?\d+)?/y;
|
|
const STRING_STOP = /["\\\u0000-\u001f]/g;
|
|
const WHITESPACE = /[ \t\n\r]*/y;
|
|
const ESCAPES = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
|
|
const LITERALS = [["true", true], ["false", false], ["null", null], ["NaN", new PyNumber("NaN", true)], ["Infinity", new PyNumber("Infinity", true)], ["-Infinity", new PyNumber("-Infinity", true)]];
|
|
|
|
/**
|
|
* Python's ``json.loads``: objects become Maps (insertion order, last duplicate wins in
|
|
* the first position), numbers keep their lexeme, NaN/Infinity are accepted, raw
|
|
* control characters and trailing data are rejected.
|
|
*/
|
|
export function pythonJsonLoads(text) {
|
|
if (typeof text !== "string") throw new PythonJsonError("Not a string");
|
|
let pos = 0;
|
|
const fail = (message) => new PythonJsonError(`${message} at ${pos}`);
|
|
const skip = () => {
|
|
WHITESPACE.lastIndex = pos;
|
|
pos = WHITESPACE.exec(text)[0].length + pos;
|
|
};
|
|
function string() {
|
|
pos += 1;
|
|
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;
|
|
const char = stop[0];
|
|
if (char === '"') return out;
|
|
if (char !== "\\") throw fail("Invalid control character");
|
|
const escape = text[pos];
|
|
if (escape === "u") {
|
|
const hex = text.slice(pos + 1, pos + 5);
|
|
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw fail("Invalid \\uXXXX escape");
|
|
pos += 5;
|
|
let code = parseInt(hex, 16);
|
|
if (code >= 0xd800 && code <= 0xdbff && text[pos] === "\\" && text[pos + 1] === "u") {
|
|
const low = text.slice(pos + 2, pos + 6);
|
|
if (/^[0-9a-fA-F]{4}$/.test(low)) {
|
|
const lowCode = parseInt(low, 16);
|
|
if (lowCode >= 0xdc00 && lowCode <= 0xdfff) {
|
|
out += String.fromCharCode(code, lowCode);
|
|
pos += 6;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
out += String.fromCharCode(code);
|
|
} else if (escape in ESCAPES) {
|
|
out += ESCAPES[escape];
|
|
pos += 1;
|
|
} else throw fail("Invalid \\escape");
|
|
}
|
|
}
|
|
function value(depth) {
|
|
if (depth > JSON_MAX_DEPTH) throw new PythonJsonError("Maximum recursion depth exceeded", true);
|
|
const char = text[pos];
|
|
if (char === '"') return string();
|
|
if (char === "{") {
|
|
pos += 1;
|
|
const map = new Map();
|
|
skip();
|
|
if (text[pos] === "}") {
|
|
pos += 1;
|
|
return map;
|
|
}
|
|
for (;;) {
|
|
if (text[pos] !== '"') throw fail("Expecting property name enclosed in double quotes");
|
|
const key = string();
|
|
skip();
|
|
if (text[pos] !== ":") throw fail("Expecting ':' delimiter");
|
|
pos += 1;
|
|
skip();
|
|
map.set(key, value(depth + 1));
|
|
skip();
|
|
if (text[pos] === "}") {
|
|
pos += 1;
|
|
return map;
|
|
}
|
|
if (text[pos] !== ",") throw fail("Expecting ',' delimiter");
|
|
pos += 1;
|
|
skip();
|
|
}
|
|
}
|
|
if (char === "[") {
|
|
pos += 1;
|
|
const list = [];
|
|
skip();
|
|
if (text[pos] === "]") {
|
|
pos += 1;
|
|
return list;
|
|
}
|
|
for (;;) {
|
|
list.push(value(depth + 1));
|
|
skip();
|
|
if (text[pos] === "]") {
|
|
pos += 1;
|
|
return list;
|
|
}
|
|
if (text[pos] !== ",") throw fail("Expecting ',' delimiter");
|
|
pos += 1;
|
|
skip();
|
|
}
|
|
}
|
|
for (const [literal, result] of LITERALS) {
|
|
if (text.startsWith(literal, pos)) {
|
|
pos += literal.length;
|
|
return result;
|
|
}
|
|
}
|
|
NUMBER.lastIndex = pos;
|
|
const number = NUMBER.exec(text);
|
|
if (!number || number.index !== pos || !number[0]) throw fail("Expecting value");
|
|
pos += number[0].length;
|
|
const isFloat = Boolean(number[1] || number[2]);
|
|
// CPython's int() refuses more digits than sys.get_int_max_str_digits().
|
|
if (!isFloat && number[0].replace("-", "").length > INT_MAX_STR_DIGITS) throw fail("Exceeds the limit for integer string conversion");
|
|
return new PyNumber(number[0], isFloat);
|
|
}
|
|
skip();
|
|
const result = value(0);
|
|
skip();
|
|
if (pos !== text.length) throw fail("Extra data");
|
|
return result;
|
|
}
|
|
|
|
/** Python's ``repr(float)``: shortest round-trip digits, exponent when decpt <= -4 or > 16. */
|
|
function pythonFloatRepr(number) {
|
|
if (Number.isNaN(number)) return "NaN";
|
|
if (number === Infinity) return "Infinity";
|
|
if (number === -Infinity) return "-Infinity";
|
|
if (number === 0) return Object.is(number, -0) ? "-0.0" : "0.0";
|
|
const sign = number < 0 ? "-" : "";
|
|
const [mantissa, exponent] = Math.abs(number).toExponential().split("e");
|
|
const digits = mantissa.replace(".", "");
|
|
const decpt = Number(exponent) + 1;
|
|
if (decpt <= -4 || decpt > 16) {
|
|
const shown = digits.length > 1 ? `${digits[0]}.${digits.slice(1)}` : digits;
|
|
const power = decpt - 1;
|
|
return `${sign}${shown}e${power < 0 ? "-" : "+"}${String(Math.abs(power)).padStart(2, "0")}`;
|
|
}
|
|
if (decpt <= 0) return `${sign}0.${"0".repeat(-decpt)}${digits}`;
|
|
if (decpt >= digits.length) return `${sign}${digits}${"0".repeat(decpt - digits.length)}.0`;
|
|
return `${sign}${digits.slice(0, decpt)}.${digits.slice(decpt)}`;
|
|
}
|
|
|
|
/** Python's ``json.dumps`` default ``ensure_ascii`` string escaping. */
|
|
function pythonEscape(text) {
|
|
let out = '"';
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
const char = text[index];
|
|
const code = text.charCodeAt(index);
|
|
if (char === '"') out += '\\"';
|
|
else if (char === "\\") out += "\\\\";
|
|
else if (char === "\n") out += "\\n";
|
|
else if (char === "\r") out += "\\r";
|
|
else if (char === "\t") out += "\\t";
|
|
else if (char === "\b") out += "\\b";
|
|
else if (char === "\f") out += "\\f";
|
|
else if (code < 0x20 || code > 0x7e) out += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
else out += char;
|
|
}
|
|
return `${out}"`;
|
|
}
|
|
|
|
/** Python's ``json.dumps(value)`` with default separators and ``ensure_ascii``, on a loads() value. */
|
|
export function pythonJsonDumps(value) {
|
|
if (value === null) return "null";
|
|
if (value === true) return "true";
|
|
if (value === false) return "false";
|
|
if (typeof value === "string") return pythonEscape(value);
|
|
if (value instanceof PyNumber) {
|
|
if (!value.isFloat) return value.text === "-0" ? "0" : value.text;
|
|
return pythonFloatRepr(Number(value.text));
|
|
}
|
|
if (Array.isArray(value)) return `[${value.map(pythonJsonDumps).join(", ")}]`;
|
|
if (value instanceof Map) return `{${[...value].map(([key, item]) => `${pythonEscape(key)}: ${pythonJsonDumps(item)}`).join(", ")}}`;
|
|
throw new PythonJsonError("Not a JSON value");
|
|
}
|
|
|
|
// ------------------------------------------------------------ carrier selection
|
|
|
|
/** utils/video_metadata._record: (text or null, record format or null). */
|
|
function record(value, key) {
|
|
if (key === "workflow" && !(typeof value === "string" || value instanceof Map)) return [null, "invalid"];
|
|
if (typeof value === "string") return [value, "original-text"];
|
|
if (value instanceof Map) return [pythonJsonDumps(value), "reserialized-json"];
|
|
return [null, null];
|
|
}
|
|
|
|
/**
|
|
* The one container carrier of prompt metadata, with the server's record budgets
|
|
* applied (utils/video_metadata.select_video_carrier, then the metadata loop of
|
|
* utils/prompt_inspector.read_inspection_file).
|
|
*
|
|
* @param {Object<string,string>} tags - container tags as ffprobe would report them
|
|
* @param {Object} [options]
|
|
* @param {string[]} [options.limited] - records the reader left out (`['workflow']`)
|
|
* @returns {{status:'complete', records:Object<string,string>, record_formats:Object<string,string>, carrierText:string|null, limited:string[]}
|
|
* | {status:'unreadable', reason:'duplicate-metadata'|'invalid-metadata'}
|
|
* | {status:'limited', reason:'metadata-limit'}}
|
|
*/
|
|
export function selectVideoCarrier(tags, options = {}) {
|
|
let limitedRecords = new Set(options.limited || []);
|
|
const folded = new Map();
|
|
const entries = tags && typeof tags === "object" && !Array.isArray(tags) ? Object.entries(tags) : [];
|
|
for (const [key, value] of entries) {
|
|
if (ENVELOPE_NAMES.has(key)) {
|
|
folded.set(key, value);
|
|
continue;
|
|
}
|
|
const name = key.toLowerCase();
|
|
if (!PER_KEY_NAMES.has(name)) continue;
|
|
if (folded.has(name) && folded.get(name) !== value) return { status: "unreadable", reason: "duplicate-metadata" };
|
|
folded.set(name, value);
|
|
}
|
|
const candidates = [];
|
|
const perKey = new Map(METADATA_KEYS.filter((key) => folded.has(key)).map((key) => [key, folded.get(key)]));
|
|
if (perKey.size) candidates.push([perKey, null]);
|
|
for (const tag of ENVELOPE_KEYS) {
|
|
const raw = folded.get(tag);
|
|
if (typeof raw !== "string") continue;
|
|
let envelope;
|
|
try {
|
|
envelope = pythonJsonLoads(raw);
|
|
} catch (error) {
|
|
if (error instanceof PythonJsonError && error.recursion) return { status: "unreadable", reason: "invalid-metadata" };
|
|
continue;
|
|
}
|
|
if (envelope instanceof Map) candidates.push([envelope, raw]);
|
|
}
|
|
const selected =
|
|
candidates.find(([fields]) => ROLE_KEYS.some((key) => fields.has(key))) ?? candidates.find(([fields]) => fields.has("workflow")) ?? null;
|
|
const formats = {};
|
|
if (selected === null) {
|
|
for (const key of limitedRecords) formats[key] = "limited";
|
|
return { status: "complete", records: {}, record_formats: formats, carrierText: null, limited: [...limitedRecords] };
|
|
}
|
|
const [fields, carrierText] = selected;
|
|
// An envelope carrier is whole; a left-out per-key workflow belonged to the carrier that lost.
|
|
if (carrierText !== null) limitedRecords = new Set();
|
|
const metadata = {};
|
|
for (const key of METADATA_KEYS) {
|
|
if (!fields.has(key)) continue;
|
|
if (fields.get(key) === OVERSIZED_WORKFLOW) {
|
|
// The reader left it unread: the server reads it, then finds it over budget.
|
|
limitedRecords.add(key);
|
|
continue;
|
|
}
|
|
const [text, format] = record(fields.get(key), key);
|
|
if (format === "invalid") {
|
|
formats[key] = format;
|
|
continue;
|
|
}
|
|
metadata[key] = text;
|
|
if (format !== null) formats[key] = format;
|
|
}
|
|
const records = {};
|
|
let roleSize = 0;
|
|
for (const key of METADATA_KEYS) {
|
|
if (!(key in metadata)) continue;
|
|
const value = metadata[key];
|
|
if (typeof value !== "string") return { status: "unreadable", reason: "invalid-metadata" };
|
|
const size = utf8Length(value);
|
|
if (key === "workflow") {
|
|
if (size > WORKFLOW_METADATA_BYTES) {
|
|
limitedRecords.add(key);
|
|
continue;
|
|
}
|
|
} else {
|
|
roleSize += size;
|
|
if (roleSize > ROLE_METADATA_BYTES) return { status: "limited", reason: "metadata-limit" };
|
|
}
|
|
records[key] = value;
|
|
if (!(key in formats)) formats[key] = "original-text";
|
|
}
|
|
for (const key of limitedRecords) formats[key] = "limited";
|
|
return { status: "complete", records, record_formats: formats, carrierText, limited: [...limitedRecords] };
|
|
}
|