* 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.
623 lines
48 KiB
JavaScript
623 lines
48 KiB
JavaScript
// Run with: node --test tests/js/
|
||
// The browser video tag readers mirror what the server reads through ffprobe
|
||
// (utils/prompt_inspector.py _video_metadata) and the carrier rule of
|
||
// utils/video_metadata.py select_video_carrier. Each rule names its server twin;
|
||
// tests/test_local_inspect_parity.py proves the two agree on the committed fixtures
|
||
// and on recorded tag maps.
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import * as v from "./helpers/video-bytes.mjs";
|
||
|
||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||
const FIXTURES = path.join(here, "..", "fixtures", "local-inspect");
|
||
const RECORDED = JSON.parse(fs.readFileSync(path.join(FIXTURES, "video-tags-v1.json"), "utf8")).fixtures;
|
||
const m = await import("../../web/js/workspace/local-video.mjs").catch((error) => {
|
||
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
||
return {};
|
||
});
|
||
const { readVideoTags, selectVideoCarrier, pythonJsonDumps, pythonJsonLoads } = m;
|
||
|
||
const MiB = 1024 * 1024;
|
||
const GiB = 1024 * MiB;
|
||
const ROLE_BUDGET = 2 * MiB;
|
||
const WORKFLOW_BUDGET = 4 * MiB;
|
||
const TAG_READ_BYTES = 12 * MiB + 64 * 1024;
|
||
const PER_KEY = new Set(["prompt", "workflow", "parameters", "comfyassets_prompt_pairs"]);
|
||
const GRAPH = '{"3":{"class_type":"KSampler","inputs":{"seed":7}}}';
|
||
const WORKFLOW = '{"nodes":[],"version":0.4}';
|
||
|
||
// The tags ffprobe reports to the server: the six probed names, ASCII case folded.
|
||
function knownTags(map) {
|
||
return Object.fromEntries(Object.entries(map).filter(([name]) => PER_KEY.has(name.toLowerCase()) || ["comment", "description"].includes(name.toLowerCase())));
|
||
}
|
||
|
||
function fixture(name) {
|
||
return new Uint8Array(fs.readFileSync(path.join(FIXTURES, name)));
|
||
}
|
||
|
||
async function read(bytes, options = {}) {
|
||
return readVideoTags(v.blobOf(bytes), options);
|
||
}
|
||
|
||
function fill(byte, count) {
|
||
return Buffer.alloc(count, byte);
|
||
}
|
||
|
||
function complete(tags, extra = {}) {
|
||
return { status: "complete", tags, limited: [], ...extra };
|
||
}
|
||
|
||
test("contract: the readers, the carrier rule and the json port are exported", () => {
|
||
assert.equal(typeof readVideoTags, "function");
|
||
assert.equal(typeof selectVideoCarrier, "function");
|
||
assert.equal(typeof pythonJsonDumps, "function");
|
||
assert.equal(typeof pythonJsonLoads, "function");
|
||
});
|
||
|
||
test("iso-bmff reader finds per-key tags with moov after mdat", async () => {
|
||
for (const [name, recorded] of Object.entries(RECORDED)) {
|
||
const blob = v.recordingBlob(fixture(name));
|
||
const result = await readVideoTags(blob, { prefix: fixture(name).subarray(0, 32) });
|
||
assert.equal(result.status, "complete", name);
|
||
assert.deepEqual(result.tags, knownTags(recorded), name);
|
||
assert.equal(result.container, name.endsWith(".mkv") || name.endsWith(".webm") ? "matroska" : "iso-bmff", name);
|
||
assert.ok(["isom", "qt", "M4V", "matroska", "webm"].includes(result.format), `${name}: ${result.format}`);
|
||
assert.deepEqual(result.limited, [], name);
|
||
assert.ok(blob.ranges.length > 0, name);
|
||
}
|
||
// core-moov-end.mp4 behind a 3 GiB mdat: only the box headers and the tag bytes are sliced.
|
||
const bytes = fixture("core-moov-end.mp4");
|
||
const boxes = v.topLevelBoxes(bytes);
|
||
const mdat = boxes.find((box) => box.type === "mdat");
|
||
const moov = boxes.find((box) => box.type === "moov");
|
||
const mdatSize = 3 * GiB + 16;
|
||
const header = v.concat(v.u32(1), v.latin1("mdat"), new Uint8Array(8));
|
||
new DataView(header.buffer).setBigUint64(8, BigInt(mdatSize));
|
||
const moovOffset = mdat.start + mdatSize;
|
||
const blob = v.sparseBlob(
|
||
[
|
||
{ offset: 0, bytes: bytes.subarray(0, mdat.start) },
|
||
{ offset: mdat.start, bytes: header },
|
||
{ offset: moovOffset, bytes: bytes.subarray(moov.start, moov.end) },
|
||
],
|
||
moovOffset + (moov.end - moov.start),
|
||
);
|
||
const result = await readVideoTags(blob, { prefix: bytes.subarray(0, 32) });
|
||
assert.deepEqual(result.tags, knownTags(RECORDED["core-moov-end.mp4"]));
|
||
const inside = blob.ranges.filter(([start]) => start >= mdat.start && start < moovOffset);
|
||
assert.ok(inside.every(([start, end]) => start === mdat.start && end - start <= 16), JSON.stringify(inside));
|
||
const total = blob.ranges.reduce((sum, [start, end]) => sum + (end - start), 0);
|
||
assert.ok(total <= m.VIDEO_READ_CEILING && total < 64 * 1024, `${total}`);
|
||
});
|
||
|
||
test("iso-bmff: legacy ilst atoms, moov/meta, sticky mdta handler and data types", async () => {
|
||
// ©cmt and desc map to comment and description, as ffprobe reports them.
|
||
const legacy = await read(v.mp4(v.legacyUdta({ comment: '{"prompt":"{}"}', description: "note" })));
|
||
assert.deepEqual(legacy, complete({ comment: '{"prompt":"{}"}', description: "note" }, { container: "iso-bmff", format: "isom" }));
|
||
// moov/meta (ISO style) is read like moov/udta/meta.
|
||
const isoMeta = await read(v.mp4(v.box("meta", v.u32(0), v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH))))));
|
||
assert.deepEqual(isoMeta.tags, { prompt: GRAPH });
|
||
// meta without the version/flags word (QuickTime) still finds hdlr.
|
||
const qtMeta = await read(v.mp4(v.udta(v.box("meta", v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH)))))));
|
||
assert.deepEqual(qtMeta.tags, { prompt: GRAPH });
|
||
// Index items need an mdta handler and a keys box; with a legacy handler they are skipped.
|
||
const noKeys = await read(v.mp4(v.udta(v.meta(v.hdlr("mdir"), v.ilst(v.item(1, v.data(GRAPH)))))));
|
||
assert.deepEqual(noKeys.tags, {});
|
||
// The mdta handler is sticky across the file, as ffmpeg's found_hdlr_mdta.
|
||
const sticky = await read(v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"])), v.meta(v.hdlr("mdir"), v.ilst(v.item(1, v.data(GRAPH)))))));
|
||
assert.deepEqual(sticky.tags, { prompt: GRAPH });
|
||
// As ffprobe reports them: consecutive data children join with ";", integer data
|
||
// types print as decimals, other types are not tags, text types 0/1/4 are read, NUL
|
||
// truncates, and the run of data children stops at the first other child.
|
||
const mixed = await read(v.mp4(v.udta(v.meta(
|
||
v.hdlr("mdta"),
|
||
v.keys(["prompt", "workflow", "parameters", "comfyassets_prompt_pairs", "comment", "description"]),
|
||
v.ilst(
|
||
v.item(1, v.data("first"), v.data("second"), v.box("name", "x"), v.data("not read")),
|
||
v.item(2, v.data([0, 0, 0, 7], { type: 21 }), v.data([0xff, 0xfe], { type: 21 }), v.data([0xff, 0xfe], { type: 22 })),
|
||
v.item(3, v.data("cut\0off")),
|
||
v.item(4, v.data("sort", { type: 4 }), v.data("mac", { type: 0 })),
|
||
v.item(5, v.data([0, 0, 0, 0, 0, 0, 0], { type: 21 })),
|
||
v.item(6, v.data("float", { type: 23 })),
|
||
),
|
||
))));
|
||
assert.deepEqual(mixed.tags, { prompt: "first;second", workflow: "7;-2;65534", parameters: "cut", comfyassets_prompt_pairs: "sort;mac" });
|
||
// Keys outside the mdta namespace address nothing; unknown names are skipped unread.
|
||
const blob = v.recordingBlob(v.mp4(v.udta(v.meta(
|
||
v.hdlr("mdta"),
|
||
v.keys([["prompt", "udta"], "encoder", "workflow"]),
|
||
v.ilst(v.item(1, v.data(GRAPH)), v.item(2, v.data(fill(0x41, 5000))), v.item(3, v.data(WORKFLOW))),
|
||
))));
|
||
const result = await readVideoTags(blob);
|
||
assert.deepEqual(result.tags, { workflow: WORKFLOW });
|
||
assert.ok(blob.ranges.every(([start, end]) => end - start < 5000), "the encoder value was never sliced");
|
||
// Legacy fourccs are mapped even in mdta mode, and a repeated name joins with ";".
|
||
const repeated = await read(v.mp4(v.udta(v.meta(
|
||
v.hdlr("mdta"),
|
||
v.keys(["prompt", "prompt", "comment"]),
|
||
v.ilst(v.item(1, v.data("one")), v.item(2, v.data("two")), v.item(3, v.data("mdta")), v.legacyItem("©cmt", v.data("c"))),
|
||
))));
|
||
assert.deepEqual(repeated.tags, { prompt: "one;two", comment: "mdta;c" });
|
||
// Apple's namespaced keys are reported verbatim, so they are never an envelope.
|
||
const apple = await read(v.perKeyMp4({ "com.apple.quicktime.description": '{"prompt":"{}"}', prompt: GRAPH }));
|
||
assert.deepEqual(apple.tags, { prompt: GRAPH });
|
||
// An item whose first child is not a data atom, or a data atom shorter than its
|
||
// 16-byte header, is skipped as ffmpeg skips it.
|
||
const junk = await read(v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(1, v.box("name", "x"), v.data(GRAPH)))))));
|
||
assert.deepEqual(junk.tags, {});
|
||
const short = await read(v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH, { size: 12 })))))));
|
||
assert.deepEqual(short.tags, {});
|
||
// A box declared with size 0 runs to the end of its parent; faststart puts moov first.
|
||
const fast = await read(v.perKeyMp4({ prompt: GRAPH }, { faststart: true, brand: "mp42" }));
|
||
assert.deepEqual(fast, complete({ prompt: GRAPH }, { container: "iso-bmff", format: "mp42" }));
|
||
const toEnd = await read(v.concat(v.ftyp(), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH }), v.opts({ size: 0 }))));
|
||
assert.deepEqual(toEnd.tags, { prompt: GRAPH });
|
||
});
|
||
|
||
test("matroska reads global Tags and skips clusters", async () => {
|
||
const { ID } = v;
|
||
// ffmpeg layout: SeekHead, Info, Tracks, Tags, Cluster, Cues; names upper-cased.
|
||
const plain = await read(v.perKeyMkv({ PROMPT: GRAPH, WORKFLOW, ENCODER: "Lavf" }, { docType: "webm" }));
|
||
assert.deepEqual(plain, complete({ PROMPT: GRAPH, WORKFLOW }, { container: "matroska", format: "webm" }));
|
||
// Tags after a 3 GiB Cluster, located through the SeekHead: no slice inside the cluster.
|
||
const tags = v.globalTags({ PROMPT: GRAPH });
|
||
const clusterSize = 3 * GiB;
|
||
// The Tags position (relative to the Segment payload) is a 4-byte uint either way,
|
||
// so the SeekHead's length can be measured with a placeholder first.
|
||
const seekLength = v.seekHead([[ID.Tags, clusterSize]]).length;
|
||
const seekHead = v.seekHead([[ID.Tags, seekLength + 12 + clusterSize]]);
|
||
assert.equal(seekHead.length, seekLength);
|
||
const head = v.concat(v.ebmlHeader(), v.idBytes(ID.Segment), v.vint(seekHead.length + 12 + clusterSize + tags.length, { width: 8 }), seekHead, v.clusterHeader(clusterSize));
|
||
const clusterStart = v.ebmlHeader().length + 12 + seekHead.length;
|
||
const tagsOffset = head.length + clusterSize;
|
||
const blob = v.sparseBlob([{ offset: 0, bytes: head }, { offset: tagsOffset, bytes: tags }], tagsOffset + tags.length);
|
||
const seeked = await readVideoTags(blob, { prefix: head.subarray(0, 32) });
|
||
assert.deepEqual(seeked.tags, { PROMPT: GRAPH });
|
||
const inside = blob.ranges.filter(([start]) => start >= clusterStart && start < tagsOffset);
|
||
assert.ok(inside.every(([start, end]) => start === clusterStart && end - start <= 16), JSON.stringify(inside));
|
||
assert.ok(blob.ranges.reduce((sum, [start, end]) => sum + (end - start), 0) <= m.VIDEO_READ_CEILING);
|
||
// ffprobe reads level-1 elements up to the first Cluster: Tags behind it are found
|
||
// only through a SeekHead, so without one they are not read (nor sliced).
|
||
const behind = v.recordingBlob(v.mkv([v.cluster(64), v.globalTags({ PROMPT: GRAPH }), el(ID.Cues, new Uint8Array(2))]));
|
||
const sequential = await readVideoTags(behind);
|
||
assert.deepEqual(sequential.tags, {});
|
||
assert.ok(behind.ranges.every(([, end]) => end <= v.ebmlHeader().length + 12 + 12 + 32), JSON.stringify(behind.ranges));
|
||
// Track-targeted Tags are ignored; chapter and attachment targets too.
|
||
const targeted = await read(v.mkv([
|
||
v.tagsEl(
|
||
v.tag([v.simpleTag("PROMPT", "track")], { trackUid: 1 }),
|
||
v.tag([v.simpleTag("WORKFLOW", WORKFLOW)], { targetType: 50 }),
|
||
v.tag([v.simpleTag("PROMPT", "zero uid is global")], { trackUid: 0 }),
|
||
),
|
||
v.cluster(),
|
||
]));
|
||
assert.deepEqual(targeted.tags, { WORKFLOW, PROMPT: "zero uid is global" });
|
||
// An unknown-size Cluster (live recordings) is the same stop; a SeekHead still works.
|
||
const hidden = await read(v.mkv([v.cluster(8, { unknown: true }), v.globalTags({ PROMPT: GRAPH })]));
|
||
assert.deepEqual(hidden, complete({}, { container: "matroska", format: "matroska" }));
|
||
const before = await read(v.mkv([v.globalTags({ PROMPT: GRAPH }), v.cluster(8, { unknown: true })]));
|
||
assert.deepEqual(before.tags, { PROMPT: GRAPH });
|
||
const info = el(ID.Info, new Uint8Array(4));
|
||
const clusterUnknown = v.cluster(8, { unknown: true });
|
||
const seekToTags = v.seekHead([[ID.Tags, 0x7f]]); // a one-byte position, like the real one
|
||
const viaSeek = await read(v.mkv([v.seekHead([[ID.Tags, seekToTags.length + info.length + clusterUnknown.length]]), info, clusterUnknown, v.globalTags({ PROMPT: GRAPH })]));
|
||
assert.deepEqual(viaSeek.tags, { PROMPT: GRAPH });
|
||
// An unknown-size Segment is fine (live recordings); other unknown sizes are invalid.
|
||
const liveSegment = await read(v.mkv([v.globalTags({ PROMPT: GRAPH }), v.cluster()], { unknown: true }));
|
||
assert.deepEqual(liveSegment.tags, { PROMPT: GRAPH });
|
||
const unknownTags = await read(v.mkv([el(ID.Tags, v.tag([v.simpleTag("PROMPT", GRAPH)]), v.opts({ unknown: true }))]));
|
||
assert.deepEqual(unknownTags, { status: "unreadable", reason: "invalid-container" });
|
||
// ffmpeg's tag naming (checked against ffprobe 9.0.2): a language other than und
|
||
// files the tag under NAME-lang only, unless TagDefault is set; nested SimpleTags
|
||
// become PARENT/CHILD (never ours); a string-less tag deletes the key; a repeated
|
||
// name keeps the last value; NUL ends the string; Void and CRC-32 are skipped.
|
||
const naming = await read(v.mkv([
|
||
v.voidEl(8),
|
||
v.tagsEl(
|
||
el(ID.CRC32, new Uint8Array(4)),
|
||
v.tag([
|
||
v.simpleTag("PROMPT", "eng only", { lang: "eng" }),
|
||
v.simpleTag("WORKFLOW", "eng only", { lang: "eng", def: 0 }),
|
||
v.simpleTag("workflow", "default and eng", { lang: "eng", def: 1 }),
|
||
v.simpleTag("PARAMETERS", "und", { lang: "und", nested: [v.simpleTag("prompt", "nested")] }),
|
||
v.simpleTag("comfyassets_prompt_pairs", "cut\0here"),
|
||
v.simpleTag("description", undefined, { binary: [1, 2, 3] }),
|
||
v.simpleTag("comment", "note"),
|
||
v.simpleTag("comment", undefined),
|
||
v.simpleTag("prompt", "first"),
|
||
v.simpleTag("prompt", "last", { binary: [1] }),
|
||
]),
|
||
),
|
||
v.cluster(),
|
||
]));
|
||
assert.deepEqual(naming.tags, { workflow: "default and eng", PARAMETERS: "und", comfyassets_prompt_pairs: "cut", prompt: "last" });
|
||
// Long names are never ours and are skipped without reading their strings.
|
||
const longName = v.recordingBlob(v.mkv([v.tagsEl(v.tag([v.simpleTag("N".repeat(300), fill(0x41, 4000)), v.simpleTag("PROMPT", GRAPH)])), v.cluster()]));
|
||
const skipped = await readVideoTags(longName);
|
||
assert.deepEqual(skipped.tags, { PROMPT: GRAPH });
|
||
assert.ok(longName.ranges.every(([start, end]) => end - start < 300));
|
||
function el(id, ...parts) {
|
||
return v.el(id, ...parts);
|
||
}
|
||
});
|
||
|
||
test("containers fail closed", async () => {
|
||
const { ID } = v;
|
||
const invalid = { status: "unreadable", reason: "invalid-container" };
|
||
const rows = [
|
||
["box size below its header", v.concat(v.ftyp(), v.box("free", v.opts({ size: 4 })), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH })))],
|
||
["child box past its parent", v.concat(v.ftyp(), v.mdat(), v.moov(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH), v.opts({ size: 5000 })))))))],
|
||
["top-level box past the end of the file", v.concat(v.ftyp(), v.mdat(), v.box("moov", v.opts({ size: 4000 })))],
|
||
["64-bit size above MAX_SAFE_INTEGER", v.concat(v.ftyp(), v.box("mdat", v.opts({ largesize: 2 ** 60 })), v.moov(v.mdtaUdta({ prompt: GRAPH })))],
|
||
["64-bit size below its header", v.concat(v.ftyp(), v.box("mdat", v.opts({ largesize: 12 })), v.moov(v.mdtaUdta({ prompt: GRAPH })))],
|
||
["keys count larger than the entries", v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"], { count: 3 }), v.ilst(v.item(1, v.data(GRAPH))))))],
|
||
["keys entry overrunning the box", v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.box("keys", v.u32(0), v.u32(1), v.u32(90), v.latin1("mdta"), v.utf8("prompt")), v.ilst(v.item(1, v.data(GRAPH))))))],
|
||
["no moov box", v.concat(v.ftyp(), v.mdat(), v.free(32))],
|
||
["not a container at all", v.utf8("#EXTM3U\n#EXT-X-VERSION:3\n".padEnd(64, "x"))],
|
||
["bad VINT: zero ID byte", v.concat(v.ebmlHeader(), v.idBytes(ID.Segment), v.vint(20, { width: 8 }), [0x00, 0x81, 0x00], new Uint8Array(17))],
|
||
["bad VINT: zero size byte", v.concat(v.ebmlHeader(), v.idBytes(ID.Segment), v.vint(20, { width: 8 }), v.idBytes(ID.Info), [0x00], new Uint8Array(17))],
|
||
["element past its parent", v.mkv([v.el(ID.Tags, v.tag([v.simpleTag("PROMPT", GRAPH)]), v.opts({ size: 5000 }))])],
|
||
["element past the end of the file", v.concat(v.ebmlHeader(), v.idBytes(ID.Segment), v.vint(5000, { width: 8 }), v.globalTags({ PROMPT: GRAPH }))],
|
||
["unknown DocType", v.perKeyMkv({ PROMPT: GRAPH }, { docType: "avi" })],
|
||
["EBML header without a DocType", v.concat(v.el(ID.EBML, v.el(ID.EBMLVersion, [1])), v.segment([v.globalTags({ PROMPT: GRAPH })]))],
|
||
["Matroska bytes after a stray size", v.concat(v.ebmlHeader(), v.idBytes(ID.Segment), v.vint(3, { width: 8 }), [0xec, 0x82, 0x00])],
|
||
];
|
||
for (const [name, bytes] of rows) {
|
||
assert.deepEqual(await read(bytes), invalid, name);
|
||
}
|
||
// Trailing bytes shorter than a box header are ignored, as ffmpeg does.
|
||
assert.deepEqual((await read(v.concat(v.perKeyMp4({ prompt: GRAPH }), [1, 2, 3]))).tags, { prompt: GRAPH });
|
||
// 10,001 boxes stop the read.
|
||
const many = v.concat(v.ftyp(), ...Array.from({ length: 10001 }, () => v.free()), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH })));
|
||
assert.deepEqual(await read(many), { status: "limited", reason: "chunk-limit" });
|
||
const manyElements = v.mkv([...Array.from({ length: 10001 }, () => v.voidEl(1)), v.globalTags({ PROMPT: GRAPH })]);
|
||
assert.deepEqual(await read(manyElements), { status: "limited", reason: "chunk-limit" });
|
||
// Exactly 10,000 elements still read (ftyp, mdat, moov, udta, meta, hdlr, keys, its entry, ilst, the item and its data atom).
|
||
const exact = v.concat(v.ftyp(), ...Array.from({ length: 10000 - 11 }, () => v.free()), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH })));
|
||
assert.deepEqual((await read(exact)).tags, { prompt: GRAPH });
|
||
// A vanished file is a read failure, not a container error.
|
||
assert.deepEqual(await readVideoTags(v.failingBlob(4096), { prefix: v.ftyp() }), { status: "unreadable", reason: "read-failure" });
|
||
assert.deepEqual(await readVideoTags({}), { status: "unreadable", reason: "read-failure" });
|
||
});
|
||
|
||
// ffprobe's string_validation=replace (av_utf8_decode): one U+FFFD per failing
|
||
// decode step. A bad continuation or a truncated sequence fails one byte at a time;
|
||
// an overlong form, a surrogate, a code above U+10FFFF, U+FFFE/U+FFFF and 5/6-byte
|
||
// forms fail as one whole sequence. Each row was read through ffprobe 9.0.2.
|
||
const FFPROBE_REPLACEMENTS = [
|
||
["one Latin-1 byte", [0x63, 0x61, 0x66, 0xe9], "caf�"],
|
||
["truncated 4-byte sequence", [0x61, 0xf0, 0x9f, 0x22, 0x62], 'a��"b'],
|
||
["overlong 2-byte form", [0x61, 0xc0, 0xaf, 0x62], "a�b"],
|
||
["C1 overlong lead", [0x61, 0xc1, 0xbf, 0x62], "a�b"],
|
||
["overlong 3-byte form", [0x61, 0xe0, 0x80, 0x80, 0x62], "a�b"],
|
||
["overlong 4-byte form", [0x61, 0xf0, 0x80, 0x80, 0x80, 0x62], "a�b"],
|
||
["UTF-16 surrogate", [0x61, 0xed, 0xa0, 0x80, 0x62], "a�b"],
|
||
["U+FFFE", [0x61, 0xef, 0xbf, 0xbe, 0x62], "a�b"],
|
||
["U+FFFF", [0x61, 0xef, 0xbf, 0xbf, 0x62], "a�b"],
|
||
["U+FDD0 is kept", [0x61, 0xef, 0xb7, 0x90, 0x62], "ab"],
|
||
["5-byte form", [0x61, 0xf8, 0x88, 0x80, 0x80, 0x80, 0x62], "a�b"],
|
||
["6-byte form", [0x61, 0xfc, 0x84, 0x80, 0x80, 0x80, 0x80, 0x62], "a�b"],
|
||
["truncated 5-byte form", [0x61, 0xf8, 0x88, 0x62], "a��b"],
|
||
["above U+10FFFF", [0x61, 0xf4, 0x90, 0x80, 0x80, 0x62], "a�b"],
|
||
["0xFE", [0x61, 0xfe, 0x62], "a�b"],
|
||
["0xFF", [0x61, 0xff, 0x62], "a�b"],
|
||
["0xFE 0xFF", [0xfe, 0xff], "��"],
|
||
["lone continuation byte", [0x61, 0x80, 0x62], "a�b"],
|
||
["truncated at the end", [0x61, 0xe2, 0x82], "a��"],
|
||
["lead byte before a whole sequence", [0x61, 0xc3, 0xc3, 0xa9, 0x62], "a�éb"],
|
||
["valid emoji", [0x61, 0xf0, 0x9f, 0x98, 0x80, 0x62], "a\u{1f600}b"],
|
||
["control characters", [0x61, 0x01, 0x1f, 0x62], "a\u0001\u001fb"],
|
||
];
|
||
|
||
test("invalid UTF-8 reads as ffprobe replaces it; names that do not decode are not carriers", async () => {
|
||
for (const [name, bytes, expected] of FFPROBE_REPLACEMENTS) {
|
||
assert.deepEqual((await read(v.perKeyMp4({ prompt: bytes }))).tags, { prompt: expected }, name);
|
||
assert.deepEqual((await read(v.perKeyMkv({ PROMPT: bytes }))).tags, { PROMPT: expected }, `${name} (Matroska)`);
|
||
}
|
||
// A long value with one bad byte keeps every other byte.
|
||
const long = Buffer.concat([Buffer.from('{"a":"'), Buffer.alloc(100000, 0x78), Buffer.from([0xe9]), Buffer.from('"}')]);
|
||
assert.equal((await read(v.perKeyMp4({ workflow: long }))).tags.workflow, `{"a":"${"x".repeat(100000)}�"}`);
|
||
assert.deepEqual((await read(v.perKeyMp4({ prompt: [0x7b, 0xff, 0xfe, 0x7d] }))).tags, { prompt: "{��}" });
|
||
// A keys name with a bad byte is reported as "prom�pt", which is no carrier.
|
||
const badKey = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys([Uint8Array.from([0x70, 0x72, 0x6f, 0x6d, 0xff, 0x70, 0x74]), "prompt"]), v.ilst(v.item(1, v.data("junk")), v.item(2, v.data(GRAPH))))));
|
||
assert.deepEqual(await read(badKey), complete({ prompt: GRAPH }, { container: "iso-bmff", format: "isom" }));
|
||
const onlyBadKey = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys([Uint8Array.from([0xc3, 0x28])]), v.ilst(v.item(1, v.data(GRAPH))))));
|
||
assert.deepEqual((await read(onlyBadKey)).tags, {});
|
||
assert.deepEqual((await read(v.mkv([v.tagsEl(v.tag([v.simpleTag([0xff], GRAPH), v.simpleTag("WORKFLOW", WORKFLOW)]))]))).tags, { WORKFLOW: WORKFLOW });
|
||
assert.deepEqual((await read(v.mkv([v.tagsEl(v.tag([v.simpleTag("PROMPT", [0xff, 0xfe])]))]))).tags, { PROMPT: "��" });
|
||
});
|
||
|
||
test("ilst items with index 0 or past the keys are skipped; the first keys box wins", async () => {
|
||
const wf = '{"nodes":[]}';
|
||
const stray = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt", "workflow"]), v.ilst(v.item(1, v.data(GRAPH)), v.item(5, v.data("junk")), v.item(2, v.data(wf))))));
|
||
assert.deepEqual((await read(stray)).tags, { prompt: GRAPH, workflow: wf });
|
||
const zero = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt", "workflow"]), v.ilst(v.item(0, v.data("junk")), v.item(1, v.data(GRAPH)), v.item(2, v.data(wf))))));
|
||
assert.deepEqual((await read(zero)).tags, { prompt: GRAPH, workflow: wf });
|
||
assert.deepEqual((await read(v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.ilst(v.item(2, v.data(GRAPH)))))))).tags, {});
|
||
// ffmpeg reads one keys box: a second one (even malformed) addresses nothing.
|
||
const twoKeys = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.keys(["workflow"]), v.ilst(v.item(1, v.data(GRAPH))))));
|
||
assert.deepEqual((await read(twoKeys)).tags, { prompt: GRAPH });
|
||
const brokenSecond = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["prompt"]), v.keys(["workflow"], { count: 9 }), v.ilst(v.item(1, v.data(GRAPH))))));
|
||
assert.deepEqual((await read(brokenSecond)).tags, { prompt: GRAPH });
|
||
const emptyFirst = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys([]), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH))))));
|
||
assert.deepEqual((await read(emptyFirst)).tags, {});
|
||
// A keys box too short for its count field is not read, so the next one is.
|
||
const shortFirst = v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.fullBox("keys"), v.keys(["prompt"]), v.ilst(v.item(1, v.data(GRAPH))))));
|
||
assert.deepEqual((await read(shortFirst)).tags, { prompt: GRAPH });
|
||
});
|
||
|
||
test("one tag per ASCII-folded name and Mac Roman text, as ffmpeg's tag dictionary holds them", async () => {
|
||
const mdta = (names, items, legacy = []) => v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(names), v.ilst(...items.map(([index, value, type = 1]) => v.item(index, v.data(value, { type }))), ...legacy))));
|
||
// ISO-BMFF joins case variants with ";" under the spelling set last (checked through ffprobe).
|
||
assert.deepEqual((await read(mdta(["workflow", "Workflow"], [[1, "A"], [2, "B"]]))).tags, { Workflow: "A;B" });
|
||
// A legacy comment atom and a later mdta COMMENT key become one COMMENT tag: no exact-case envelope.
|
||
const merged = await read(mdta(["COMMENT"], [[1, "y"]], [v.legacyItem("©cmt", v.data("x"))]));
|
||
assert.deepEqual(merged.tags, { comment: "y;x" });
|
||
const mergedUpper = await read(v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["COMMENT"]), v.ilst(v.legacyItem("©cmt", v.data("x")), v.item(1, v.data("y")))))));
|
||
assert.deepEqual(mergedUpper.tags, { COMMENT: "x;y" });
|
||
assert.deepEqual(selectVideoCarrier(mergedUpper.tags).records, {});
|
||
// Matroska keeps the last spelling and value; a string-less SimpleTag deletes any spelling.
|
||
assert.deepEqual((await read(v.mkv([v.tagsEl(v.tag([v.simpleTag("PROMPT", GRAPH), v.simpleTag("Prompt", "B")]))]))).tags, { Prompt: "B" });
|
||
assert.deepEqual((await read(v.mkv([v.tagsEl(v.tag([v.simpleTag("PROMPT", GRAPH), v.simpleTag("Prompt", undefined)]))]))).tags, {});
|
||
// ffprobe's format_tags filter folds ASCII only: a Kelvin-sign "workflow" is never reported.
|
||
assert.deepEqual((await read(mdta(["prompt", "worKflow"], [[1, GRAPH], [2, WORKFLOW]]))).tags, { prompt: GRAPH });
|
||
// data types 0 and 3 are Mac Roman to ffmpeg (types 1 and 4 UTF-8, type 2 not text).
|
||
const cafe = [0x63, 0x61, 0x66, 0x8e];
|
||
for (const [type, expected] of [[0, "café"], [3, "café"], [1, "caf�"], [4, "caf�"]]) {
|
||
assert.deepEqual((await read(mdta(["prompt"], [[1, cafe, type]]))).tags, { prompt: expected }, `type ${type}`);
|
||
}
|
||
assert.deepEqual((await read(mdta(["prompt"], [[1, cafe, 2]]))).tags, {});
|
||
const high = Uint8Array.from({ length: 128 }, (_, index) => 0x80 + index);
|
||
const roomy = Buffer.concat([high, Buffer.alloc(400, 0x7c)]);
|
||
assert.equal((await read(mdta(["prompt"], [[1, roomy, 0]]))).tags.prompt, new TextDecoder("macintosh").decode(roomy));
|
||
// ffmpeg writes the UTF-8 into twice the input size: a long non-ASCII value is cut,
|
||
// here inside U+00DA, which ffprobe then replaces (its own output for these bytes).
|
||
const tight = Buffer.concat([high, Buffer.from([0x7c, 0x01, 0x7f])]);
|
||
assert.equal((await read(mdta(["prompt"], [[1, tight, 0]]))).tags.prompt, new TextDecoder("macintosh").decode(high).slice(0, 114) + "�");
|
||
});
|
||
|
||
test("budgets: roles share 2 MiB, an oversized workflow is left unread, duplicates and the total are bounded", async () => {
|
||
const limited = { status: "limited", reason: "metadata-limit" };
|
||
// Role tags over 2 MiB together stop the read (the server fails the whole read).
|
||
assert.deepEqual(await read(v.perKeyMp4({ prompt: fill(0x41, ROLE_BUDGET + 1) })), limited);
|
||
assert.deepEqual(await read(v.perKeyMkv({ PROMPT: fill(0x41, ROLE_BUDGET - 10), PARAMETERS: fill(0x42, 11) })), limited);
|
||
const atRole = await read(v.perKeyMp4({ prompt: fill(0x41, ROLE_BUDGET - 10), comfyassets_prompt_pairs: fill(0x42, 10) }));
|
||
assert.equal(atRole.status, "complete");
|
||
assert.equal(atRole.tags.prompt.length, ROLE_BUDGET - 10);
|
||
// A per-key workflow over 4 MiB is not sliced; the result says the workflow is limited.
|
||
const big = v.recordingBlob(v.perKeyMp4({ prompt: GRAPH, workflow: fill(0x7b, WORKFLOW_BUDGET + 1) }));
|
||
const over = await readVideoTags(big);
|
||
// It stays in the tag map as a marker, so the carrier choice still sees a per-key workflow.
|
||
assert.deepEqual(over, { status: "complete", container: "iso-bmff", format: "isom", tags: { prompt: GRAPH, workflow: m.OVERSIZED_WORKFLOW }, limited: ["workflow"] });
|
||
assert.ok(big.ranges.every(([start, end]) => end - start < WORKFLOW_BUDGET), "the oversized workflow was never sliced");
|
||
const bigMkv = await read(v.perKeyMkv({ WORKFLOW: fill(0x7b, WORKFLOW_BUDGET + 1), PROMPT: GRAPH }));
|
||
assert.deepEqual([bigMkv.limited, Object.keys(bigMkv.tags)], [["workflow"], ["WORKFLOW", "PROMPT"]]);
|
||
const atWorkflow = await read(v.perKeyMp4({ workflow: fill(0x7b, WORKFLOW_BUDGET) }));
|
||
assert.deepEqual([atWorkflow.limited, atWorkflow.tags.workflow.length], [[], WORKFLOW_BUDGET]);
|
||
// Fold-duplicates draw from the duplicate budgets (2x each class), then stop.
|
||
// (ffmpeg keeps one tag per ASCII-folded name: Matroska keeps the last spelling and value.)
|
||
const twice = await read(v.perKeyMkv({ PROMPT: fill(0x41, ROLE_BUDGET), prompt: fill(0x42, ROLE_BUDGET) }));
|
||
assert.deepEqual(Object.keys(twice.tags), ["prompt"]);
|
||
assert.equal(twice.tags.prompt[0], "B");
|
||
const thrice = await read(v.perKeyMkv({ PROMPT: fill(0x41, ROLE_BUDGET), prompt: fill(0x41, ROLE_BUDGET), Prompt: fill(0x41, ROLE_BUDGET + 1) }));
|
||
assert.deepEqual(thrice, limited);
|
||
const workflowTwice = await read(v.perKeyMkv({ WORKFLOW: fill(0x7b, WORKFLOW_BUDGET), workflow: fill(0x7b, WORKFLOW_BUDGET) }));
|
||
assert.equal(workflowTwice.status, "complete");
|
||
// Envelopes count against the total only; the total is the server's probe bound.
|
||
const envelope = await read(v.mp4(v.legacyUdta({ comment: fill(0x41, 5 * MiB) })));
|
||
assert.equal(envelope.tags.comment.length, 5 * MiB);
|
||
const tooMuch = await read(v.mp4(v.legacyUdta({ comment: fill(0x41, TAG_READ_BYTES - 10), description: fill(0x42, 11) })));
|
||
assert.deepEqual(tooMuch, limited);
|
||
assert.equal(m.VIDEO_TAG_READ_BYTES, TAG_READ_BYTES);
|
||
});
|
||
|
||
test("an oversized per-key workflow keeps its place in the carrier choice, as in the server's first probe", async () => {
|
||
const big = fill(0x7b, WORKFLOW_BUDGET + 1);
|
||
const envelope = '{"workflow": {"nodes": [1]}}';
|
||
const beside = (comment) => v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["workflow"]), v.ilst(v.item(1, v.data(big)), v.legacyItem("©cmt", v.data(comment))))));
|
||
const read1 = await read(beside(envelope));
|
||
assert.deepEqual([read1.tags, read1.limited], [{ workflow: m.OVERSIZED_WORKFLOW, comment: envelope }, ["workflow"]]);
|
||
// The server picks the per-key carrier (no role on either side) and marks the workflow limited.
|
||
assert.deepEqual(selectVideoCarrier(read1.tags, { limited: read1.limited }), { status: "complete", records: {}, record_formats: { workflow: "limited" }, carrierText: null, limited: ["workflow"] });
|
||
// A role envelope still wins and clears it.
|
||
const roleEnvelope = '{"prompt": "{}", "workflow": {"a": 1}}';
|
||
const read2 = await read(beside(roleEnvelope));
|
||
assert.deepEqual(selectVideoCarrier(read2.tags, { limited: read2.limited }), { status: "complete", records: { prompt: "{}", workflow: '{"a": 1}' }, record_formats: { prompt: "original-text", workflow: "reserialized-json" }, carrierText: roleEnvelope, limited: [] });
|
||
// ISO-BMFF joins a repeat onto the oversized value, which stays over budget and unread.
|
||
const repeat = (extra) => v.mp4(v.udta(v.meta(v.hdlr("mdta"), v.keys(["workflow", "prompt"]), v.ilst(v.item(1, v.data(big)), v.item(1, v.data(extra)), v.item(2, v.data(GRAPH))))));
|
||
const repeatedBlob = v.recordingBlob(repeat(WORKFLOW));
|
||
const repeated = await readVideoTags(repeatedBlob);
|
||
assert.deepEqual([repeated.tags, repeated.limited], [{ workflow: m.OVERSIZED_WORKFLOW, prompt: GRAPH }, ["workflow"]]);
|
||
assert.ok(repeatedBlob.ranges.every(([start, end]) => end - start < 1024), "neither workflow part was sliced");
|
||
// When the first probe would overflow its bound, the server re-probes without the
|
||
// per-key workflow: then it takes no part in the choice ...
|
||
const dropped = await read(repeat(fill(0x41, TAG_READ_BYTES - WORKFLOW_BUDGET)));
|
||
assert.deepEqual([dropped.tags, dropped.limited], [{ prompt: GRAPH }, ["workflow"]]);
|
||
// ... and the re-probe's own bound (twice the role budget plus 64 KiB) applies to the rest.
|
||
const wide = '{"workflow": {"n": "' + "x".repeat(TAG_READ_BYTES - WORKFLOW_BUDGET) + '"}}';
|
||
assert.deepEqual(await read(beside(wide)), { status: "limited", reason: "metadata-limit" });
|
||
assert.equal(m.VIDEO_REPROBE_BYTES, 2 * ROLE_BUDGET + 64 * 1024);
|
||
// Matroska keeps the last value of a name, and a string-less SimpleTag deletes it.
|
||
const replaced = await read(v.mkv([v.tagsEl(v.tag([v.simpleTag("WORKFLOW", big), v.simpleTag("WORKFLOW", WORKFLOW)]))]));
|
||
assert.deepEqual([replaced.tags, replaced.limited], [{ WORKFLOW: WORKFLOW }, []]);
|
||
const deleted = await read(v.mkv([v.tagsEl(v.tag([v.simpleTag("WORKFLOW", big), v.simpleTag("WORKFLOW", undefined), v.simpleTag("PROMPT", GRAPH)]))]));
|
||
assert.deepEqual([deleted.tags, deleted.limited], [{ PROMPT: GRAPH }, []]);
|
||
});
|
||
|
||
test("avi and asf are recognised but not read", async () => {
|
||
const avi = v.concat(v.latin1("RIFF"), v.u32(1000), v.latin1("AVI LIST"), new Uint8Array(16));
|
||
const asf = v.concat([0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c], new Uint8Array(16));
|
||
for (const bytes of [avi, asf]) {
|
||
const blob = v.recordingBlob(v.concat(bytes, new Uint8Array(4096)));
|
||
assert.deepEqual(await readVideoTags(blob, { prefix: bytes }), { status: "unsupported", reason: "video-container-unsupported" });
|
||
assert.deepEqual(blob.ranges, []);
|
||
}
|
||
// The classifier's kind can be handed in instead of a prefix.
|
||
const blob = v.recordingBlob(new Uint8Array(64));
|
||
assert.deepEqual(await readVideoTags(blob, { kind: "avi" }), { status: "unsupported", reason: "video-container-unsupported" });
|
||
assert.deepEqual(blob.ranges, []);
|
||
// Without a prefix the reader slices the first 32 bytes itself.
|
||
const mp4 = v.recordingBlob(v.perKeyMp4({ prompt: GRAPH }));
|
||
assert.deepEqual((await readVideoTags(mp4)).tags, { prompt: GRAPH });
|
||
assert.deepEqual(mp4.ranges[0], [0, 32]);
|
||
});
|
||
|
||
test("abort during a moov walk settles once", async () => {
|
||
const unhandled = [];
|
||
const onUnhandled = (reason) => unhandled.push(reason);
|
||
process.on("unhandledRejection", onUnhandled);
|
||
try {
|
||
const bytes = v.concat(v.ftyp(), ...Array.from({ length: 50 }, () => v.free()), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH, workflow: WORKFLOW })));
|
||
const controller = new AbortController();
|
||
let slices = 0;
|
||
const blob = {
|
||
size: bytes.length,
|
||
slice(start, end) {
|
||
slices += 1;
|
||
if (slices === 3) controller.abort();
|
||
return { arrayBuffer: () => new Promise((resolve) => setTimeout(() => resolve(bytes.slice(start, end).buffer), 2)) };
|
||
},
|
||
};
|
||
await assert.rejects(readVideoTags(blob, { signal: controller.signal }), { name: "AbortError" });
|
||
const already = new AbortController();
|
||
already.abort();
|
||
await assert.rejects(readVideoTags(v.blobOf(bytes), { signal: already.signal }), { name: "AbortError" });
|
||
const slow = v.slowBlob(bytes, 1);
|
||
const during = new AbortController();
|
||
const pending = readVideoTags(slow, { signal: during.signal, yieldControl: async () => during.abort() });
|
||
await assert.rejects(pending, { name: "AbortError" });
|
||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
assert.deepEqual(unhandled, []);
|
||
} finally {
|
||
process.off("unhandledRejection", onUnhandled);
|
||
}
|
||
});
|
||
|
||
test("yieldControl is awaited while walking many boxes", async () => {
|
||
let yields = 0;
|
||
const many = v.concat(v.ftyp(), ...Array.from({ length: 3000 }, () => v.free()), v.mdat(), v.moov(v.mdtaUdta({ prompt: GRAPH })));
|
||
const result = await readVideoTags(v.slowBlob(many, 0), { yieldControl: async () => { yields += 1; } });
|
||
assert.equal(result.status, "complete");
|
||
assert.ok(yields > 0, "the reader handed control back");
|
||
});
|
||
|
||
test("carrier selection: per-key tags, then the comment envelope, then description, never merged", () => {
|
||
const ENVELOPE_WORKFLOW = '{"last_node_id": 9, "nodes": [{"id": 3, "type": "KSampler", "widgets_values": [7, "fixed", 20, 8.0, "euler", "normal", 1.0]}, {"id": 6, "type": "CLIPTextEncode", "widgets_values": ["a red fox in snow, detailed fur"]}, {"id": 7, "type": "CLIPTextEncode", "widgets_values": ["blurry, lowres"]}], "links": [], "version": 0.4}';
|
||
const original = (keys) => Object.fromEntries(keys.map((key) => [key, "original-text"]));
|
||
const text = (tags, keys) => Object.fromEntries(keys.map((key) => [key, tags[key] ?? tags[key.toUpperCase()]]));
|
||
// The recorded fixtures.
|
||
for (const name of ["core-faststart.mp4", "core-moov-end.mp4", "core.mov", "core.m4v", "core.mkv", "core-tags.webm"]) {
|
||
const tags = RECORDED[name];
|
||
const keys = Object.keys(tags).map((key) => key.toLowerCase()).filter((key) => PER_KEY.has(key));
|
||
assert.deepEqual(selectVideoCarrier(tags), { status: "complete", records: text(tags, keys), record_formats: original(keys), carrierText: null, limited: [] }, name);
|
||
}
|
||
const legacy = RECORDED["legacy-comment.mp4"];
|
||
const comment = pythonJsonLoads(legacy.comment);
|
||
assert.deepEqual(selectVideoCarrier(legacy), {
|
||
status: "complete",
|
||
records: { prompt: comment.get("prompt"), workflow: ENVELOPE_WORKFLOW },
|
||
record_formats: { prompt: "original-text", workflow: "reserialized-json" },
|
||
carrierText: legacy.comment,
|
||
limited: [],
|
||
});
|
||
assert.ok(comment.get("prompt").startsWith('{"3":{"class_type":"KSampler"'));
|
||
// Matroska upper-cases COMMENT, and envelope names are exact-case (ruling M1): nothing is read.
|
||
assert.deepEqual(selectVideoCarrier(RECORDED["legacy-comment.webm"]), { status: "complete", records: {}, record_formats: {}, carrierText: null, limited: [] });
|
||
// Synthetic maps.
|
||
const envelope = JSON.stringify({ prompt: '{"9":{}}', workflow: { v: 1 } });
|
||
const cases = [
|
||
["per-key wins over comment", { prompt: GRAPH, workflow: WORKFLOW, comment: envelope }, { records: { prompt: GRAPH, workflow: WORKFLOW }, record_formats: original(["prompt", "workflow"]), carrierText: null }],
|
||
["Matroska names fold", { PROMPT: GRAPH, Workflow: WORKFLOW, ENCODER: "Lavf" }, { records: { prompt: GRAPH, workflow: WORKFLOW }, record_formats: original(["prompt", "workflow"]), carrierText: null }],
|
||
["identical fold-duplicates are one record", { PROMPT: GRAPH, prompt: GRAPH }, { records: { prompt: GRAPH }, record_formats: original(["prompt"]), carrierText: null }],
|
||
["differing fold-duplicates fail", { PROMPT: GRAPH, prompt: '{"9":{}}' }, { status: "unreadable", reason: "duplicate-metadata" }],
|
||
["differing workflow duplicates fail even with a prompt", { Workflow: WORKFLOW, WORKFLOW: "{}", prompt: GRAPH }, { status: "unreadable", reason: "duplicate-metadata" }],
|
||
["envelope names never fold", { comment: envelope, COMMENT: "a free-text note" }, { records: { prompt: '{"9":{}}', workflow: '{"v": 1}' }, record_formats: { prompt: "original-text", workflow: "reserialized-json" }, carrierText: envelope }],
|
||
["non-JSON comment falls through to description", { comment: "not json", description: envelope }, { records: { prompt: '{"9":{}}', workflow: '{"v": 1}' }, record_formats: { prompt: "original-text", workflow: "reserialized-json" }, carrierText: envelope }],
|
||
["a JSON array comment is not an envelope", { comment: '["prompt"]', description: '{"workflow": {}}' }, { records: { workflow: "{}" }, record_formats: { workflow: "reserialized-json" }, carrierText: '{"workflow": {}}' }],
|
||
["a role envelope beats a workflow-only per-key carrier", { workflow: WORKFLOW, comment: envelope }, { records: { prompt: '{"9":{}}', workflow: '{"v": 1}' }, record_formats: { prompt: "original-text", workflow: "reserialized-json" }, carrierText: envelope }],
|
||
["role description beats workflow-only comment", { comment: '{"workflow": {"a": 1}}', description: '{"parameters": "cat"}' }, { records: { parameters: "cat" }, record_formats: { parameters: "original-text" }, carrierText: '{"parameters": "cat"}' }],
|
||
["workflow-only carriers: the first wins, never merged", { comment: '{"workflow": {"a": 1}}', description: '{"workflow": {"b": 2}}' }, { records: { workflow: '{"a": 1}' }, record_formats: { workflow: "reserialized-json" }, carrierText: '{"workflow": {"a": 1}}' }],
|
||
["description only", { description: envelope }, { records: { prompt: '{"9":{}}', workflow: '{"v": 1}' }, record_formats: { prompt: "original-text", workflow: "reserialized-json" }, carrierText: envelope }],
|
||
["no known key", { encoder: "Lavf", major_brand: "isom" }, { records: {}, record_formats: {}, carrierText: null }],
|
||
["envelope with only unknown keys is no carrier", { comment: '{"title": "x"}' }, { records: {}, record_formats: {}, carrierText: null }],
|
||
["null role marker is refused", { comment: '{"prompt": null, "workflow": {}}' }, { status: "unreadable", reason: "invalid-metadata" }],
|
||
["numeric role is refused", { description: '{"parameters": 5}' }, { status: "unreadable", reason: "invalid-metadata" }],
|
||
["a non-text non-object workflow is invalid but the roles stay", { comment: '{"prompt": "{}", "workflow": null}' }, { records: { prompt: "{}" }, record_formats: { prompt: "original-text", workflow: "invalid" }, carrierText: '{"prompt": "{}", "workflow": null}' }],
|
||
["an object prompt is reserialized", { comment: '{"prompt": {"1": {"class_type": "A"}}}' }, { records: { prompt: '{"1": {"class_type": "A"}}' }, record_formats: { prompt: "reserialized-json" }, carrierText: '{"prompt": {"1": {"class_type": "A"}}}' }],
|
||
["a non-text per-key role marker is refused", { prompt: 5, workflow: WORKFLOW }, { status: "unreadable", reason: "invalid-metadata" }],
|
||
["a non-text per-key workflow is invalid", { prompt: GRAPH, workflow: 5 }, { records: { prompt: GRAPH }, record_formats: { prompt: "original-text", workflow: "invalid" }, carrierText: null }],
|
||
["a workflow-only envelope with an invalid workflow is still the carrier", { comment: '{"workflow": 5}' }, { records: {}, record_formats: { workflow: "invalid" }, carrierText: '{"workflow": 5}' }],
|
||
["not a map", null, { records: {}, record_formats: {}, carrierText: null }],
|
||
];
|
||
for (const [name, tags, expected] of cases) {
|
||
const want = expected.status ? expected : { status: "complete", limited: [], ...expected };
|
||
assert.deepEqual(selectVideoCarrier(tags), want, name);
|
||
}
|
||
// The server's budget loop: a 4 MiB workflow is limited, roles over 2 MiB fail, exact sizes pass.
|
||
const bigWorkflow = "{" + "0".repeat(WORKFLOW_BUDGET);
|
||
assert.deepEqual(selectVideoCarrier({ prompt: GRAPH, workflow: bigWorkflow }), { status: "complete", records: { prompt: GRAPH }, record_formats: { prompt: "original-text", workflow: "limited" }, carrierText: null, limited: ["workflow"] });
|
||
assert.deepEqual(selectVideoCarrier({ workflow: bigWorkflow.slice(1) }).records, { workflow: bigWorkflow.slice(1) });
|
||
assert.deepEqual(selectVideoCarrier({ comment: JSON.stringify({ prompt: "x", workflow: { k: "0".repeat(WORKFLOW_BUDGET) } }) }).record_formats, { prompt: "original-text", workflow: "limited" });
|
||
assert.deepEqual(selectVideoCarrier({ prompt: "a".repeat(ROLE_BUDGET), parameters: "b" }), { status: "limited", reason: "metadata-limit" });
|
||
assert.deepEqual(selectVideoCarrier({ prompt: "é".repeat(ROLE_BUDGET / 2) }).records.prompt.length, ROLE_BUDGET / 2);
|
||
assert.deepEqual(selectVideoCarrier({ prompt: "é".repeat(ROLE_BUDGET / 2) + "a" }), { status: "limited", reason: "metadata-limit" });
|
||
assert.deepEqual(selectVideoCarrier({ prompt: "\ud800".repeat(ROLE_BUDGET / 3) + "aaa" }), { status: "limited", reason: "metadata-limit" });
|
||
assert.deepEqual(selectVideoCarrier({ prompt: "\u{1f600}".repeat(ROLE_BUDGET / 4) }).records.prompt.length, ROLE_BUDGET / 2);
|
||
assert.deepEqual(selectVideoCarrier({ prompt: "\u{1f600}".repeat(ROLE_BUDGET / 4) + "a" }), { status: "limited", reason: "metadata-limit" });
|
||
// The reader's limited workflow (the server's role-only re-probe): kept with the per-key
|
||
// carrier or no carrier, cleared when an envelope wins.
|
||
assert.deepEqual(selectVideoCarrier({ prompt: GRAPH }, { limited: ["workflow"] }).record_formats, { prompt: "original-text", workflow: "limited" });
|
||
assert.deepEqual(selectVideoCarrier({ encoder: "x" }, { limited: ["workflow"] }), { status: "complete", records: {}, record_formats: { workflow: "limited" }, carrierText: null, limited: ["workflow"] });
|
||
assert.deepEqual(selectVideoCarrier({ comment: envelope }, { limited: ["workflow"] }).limited, []);
|
||
assert.deepEqual(selectVideoCarrier({ comment: envelope }, { limited: ["workflow"] }).record_formats, { prompt: "original-text", workflow: "reserialized-json" });
|
||
});
|
||
|
||
test("pythonJsonLoads and pythonJsonDumps spell values as Python's json module does", () => {
|
||
const rows = [
|
||
["1.0", "1.0"], ["8.0", "8.0"], ["0.1", "0.1"], ["1e16", "1e+16"], ["1e15", "1000000000000000.0"],
|
||
["123456789012345678", "123456789012345678"], ["0.0001", "0.0001"], ["0.00001", "1e-05"], ["1e-7", "1e-07"],
|
||
["1.5e300", "1.5e+300"], ["1e400", "Infinity"], ["-0", "0"], ["-0.0", "-0.0"], ["1E2", "100.0"], ["2.5", "2.5"],
|
||
["100.0", "100.0"], ["1e21", "1e+21"], ["123456.789", "123456.789"], ["0.000123", "0.000123"], ["-1e-05", "-1e-05"],
|
||
["9007199254740993", "9007199254740993"], ["1.7976931348623157e308", "1.7976931348623157e+308"], ["5e-324", "5e-324"],
|
||
["NaN", "NaN"], ["-Infinity", "-Infinity"], ["Infinity", "Infinity"], ["[1.0e+2]", "[100.0]"], ["-1e400", "-Infinity"],
|
||
["[]", "[]"], ["{}", "{}"], ["[true, false, null]", "[true, false, null]"], ['{"k":[1,{"z":1}]}', '{"k": [1, {"z": 1}]}'],
|
||
[' \t\n\r{"a" : 1 } ', '{"a": 1}'],
|
||
['{"a":"é\\u0001\u007f\\"\\\\\\n\\t\\b\\f\\r\\/ 😀 \\ud800"}', '{"a": "\\u00e9\\u0001\\u007f\\"\\\\\\n\\t\\b\\f\\r/ \\ud83d\\ude00 \\ud800"}'],
|
||
['{"a":"\\ud83d\\ude00"}', '{"a": "\\ud83d\\ude00"}'],
|
||
['{"10":1,"3":2,"b":3,"a":4}', '{"10": 1, "3": 2, "b": 3, "a": 4}'],
|
||
['{"a":1,"b":2,"a":3}', '{"a": 3, "b": 2}'],
|
||
];
|
||
for (const [input, output] of rows) {
|
||
assert.equal(pythonJsonDumps(pythonJsonLoads(input)), output, input);
|
||
}
|
||
const bad = ['{"a":1} x', '{"a":"\u0001"}', '{"a":"\\x"}', "01", "1.", ".5", "+1", "[1,]", '{"a":1,}', "{a:1}", "'a'", "", " ", "[", "nul", "-", "1e", '"\\u12"', "[1 2]", "tru", "Infinit"];
|
||
for (const input of bad) {
|
||
assert.throws(() => pythonJsonLoads(input), { name: "PythonJsonError" }, JSON.stringify(input));
|
||
}
|
||
assert.throws(() => pythonJsonLoads("[".repeat(2000) + "]".repeat(2000)), { name: "PythonJsonError", recursion: true });
|
||
// CPython's int() refuses more than 4300 digits (sign excluded); floats have no such limit.
|
||
const digits = (count) => "1" + "0".repeat(count - 1);
|
||
assert.equal(pythonJsonDumps(pythonJsonLoads(digits(4300))), digits(4300));
|
||
assert.equal(pythonJsonDumps(pythonJsonLoads("-" + digits(4300))), "-" + digits(4300));
|
||
for (const input of [digits(4301), "-" + digits(4301), `[${digits(5000)}]`]) {
|
||
assert.throws(() => pythonJsonLoads(input), (error) => error.name === "PythonJsonError" && error.recursion === false, input.slice(0, 8));
|
||
}
|
||
assert.equal(pythonJsonLoads(digits(4301) + ".0").isFloat, true);
|
||
// So an envelope holding such an integer is skipped, as the server's _carriers does.
|
||
const hugeInt = `{"workflow": {"a": ${digits(4400)}}}`;
|
||
assert.deepEqual(selectVideoCarrier({ comment: hugeInt, description: '{"workflow": {"b": 2}}' }).carrierText, '{"workflow": {"b": 2}}');
|
||
assert.throws(() => pythonJsonDumps(undefined), { name: "PythonJsonError" });
|
||
assert.throws(() => pythonJsonLoads(5), { name: "PythonJsonError" });
|
||
assert.equal(pythonJsonDumps(pythonJsonLoads("[".repeat(500) + "]".repeat(500))), "[".repeat(500) + "]".repeat(500));
|
||
const parsed = pythonJsonLoads('{"s":"x","n":1,"f":1.5,"b":true,"z":null,"l":[1]}');
|
||
assert.ok(parsed instanceof Map);
|
||
assert.deepEqual([...parsed.keys()], ["s", "n", "f", "b", "z", "l"]);
|
||
assert.equal(parsed.get("s"), "x");
|
||
assert.equal(parsed.get("b"), true);
|
||
assert.equal(parsed.get("z"), null);
|
||
assert.ok(Array.isArray(parsed.get("l")));
|
||
});
|