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.
This commit is contained in:
Vito Sansevero
2026-10-22 08:59:11 -07:00
parent 0eaa0ba598
commit 283d5557ba
3 changed files with 175 additions and 30 deletions
+36 -3
View File
@@ -29,8 +29,9 @@ const PER_KEY = new Set(["prompt", "workflow", "parameters", "comfyassets_prompt
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()) || name === "comment" || name === "description"));
return Object.fromEntries(Object.entries(map).filter(([name]) => PER_KEY.has(name.toLowerCase()) || ["comment", "description"].includes(name.toLowerCase())));
}
function fixture(name) {
@@ -348,6 +349,36 @@ test("ilst items with index 0 or past the keys are skipped; the first keys box w
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).
@@ -367,8 +398,10 @@ test("budgets: roles share 2 MiB, an oversized workflow is left unread, duplicat
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.
const twice = await read(v.perKeyMkv({ PROMPT: fill(0x41, ROLE_BUDGET), prompt: fill(0x41, ROLE_BUDGET) }));
assert.deepEqual(Object.keys(twice.tags).sort(), ["PROMPT", "prompt"]);
// (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) }));
+58 -7
View File
@@ -367,11 +367,11 @@ def _run_oracle(testcase, entries):
def _known_tags(tags):
"""The tags the local reader keeps: per-key names folded, envelope names exact."""
"""The tags ffprobe reports to the server: the six probed names, ASCII folded."""
return {
key: value
for key, value in tags.items()
if key.lower() in PER_KEY_NAMES or key in ("comment", "description")
if key.lower() in PER_KEY_NAMES or key.lower() in ("comment", "description")
}
@@ -451,7 +451,8 @@ def _mdta_udta(tags):
def _mdta_items(names, items, keys_boxes=(), legacy=()):
"""``moov/udta/meta`` from raw bytes: mdta key ``names``, ``(index, value)``
items, extra ``keys`` boxes after the first (each a list of names) and
``(fourcc, value)`` legacy atoms in the same ``ilst``."""
``(fourcc, value)`` legacy atoms in the same ``ilst``. An item may carry a
third member, its ``data`` type (default 1, UTF-8)."""
def keys(entries):
return _full_box(
@@ -460,12 +461,12 @@ def _mdta_items(names, items, keys_boxes=(), legacy=()):
*(struct.pack(">I", 8 + len(name)) + b"mdta" + name for name in entries),
)
def data(value):
return _box(b"data", struct.pack(">II", 1, 0), value)
def data(value, kind=1):
return _box(b"data", struct.pack(">II", kind, 0), value)
ilst = _box(
b"ilst",
*(_box(struct.pack(">I", index), data(value)) for index, value in items),
*(_box(struct.pack(">I", item[0]), data(*item[1:])) for item in items),
*(_box(fourcc.encode("latin-1"), data(value)) for fourcc, value in legacy),
)
hdlr = _full_box(b"hdlr", b"\0\0\0\0", b"mdta", bytes(12), b"\0")
@@ -751,6 +752,28 @@ class LocalVideoCarrierParityTests(unittest.TestCase):
"oversized_workflow_beside_envelope": _mdta_items(
[b"workflow"], [(1, oversized)], legacy=[("\u00a9cmt", envelope)]
),
# ffmpeg holds one tag per ASCII-folded name (MP4 joins with ";"
# under the last spelling), its format_tags filter folds ASCII only,
# and data types 0 and 3 are Mac Roman text.
"case_variant_keys": _mdta_items(
[b"prompt", b"Prompt", b"workflow"],
[(1, graph), (2, b"x"), (3, workflow)],
),
"legacy_comment_then_upper_key": _mdta_items(
[b"COMMENT"],
[(1, b"y")],
legacy=[("\u00a9cmt", envelope)],
),
"kelvin_sign_workflow": _mdta_items(
[b"prompt", "wor\u212aflow".encode()], [(1, graph), (2, workflow)]
),
"mac_roman_types": _mdta_items(
[b"prompt", b"workflow"],
[
(1, b'{"1":{"class_type":"caf\x8e","inputs":{}}}', 0),
(2, b'{"n":"\x80\x81' + bytes(range(0x80, 0x100)) + b'|"}', 3),
],
),
# A repeat joined onto it makes the first probe overflow: the server's
# role-only re-probe reports no per-key workflow.
"first_probe_overflows": _mdta_items(
@@ -768,6 +791,13 @@ class LocalVideoCarrierParityTests(unittest.TestCase):
mkv[at] = 0xFF
files["matroska_invalid_byte"] = self.directory / "invalid-byte.mkv"
files["matroska_invalid_byte"].write_bytes(bytes(mkv))
# Its WORKFLOW TagName renamed to "Prompt": ffmpeg keeps the last of the
# two spellings, so the server reads one prompt, not a duplicate.
case_mkv = (VIDEO_FIXTURES / "core.mkv").read_bytes()
files["matroska_case_variants"] = self.directory / "case-variants.mkv"
files["matroska_case_variants"].write_bytes(
case_mkv.replace(b"WORKFLOW", b"Prompt\0\0")
)
observed = _run_oracle(
self, [{"name": name, "path": str(path)} for name, path in files.items()]
)
@@ -783,7 +813,14 @@ class LocalVideoCarrierParityTests(unittest.TestCase):
_canonical_bytes(observed[name]["metadata"]),
_canonical_bytes(metadata),
)
self.assertEqual(metadata["status"], "available")
self.assertEqual(
metadata["status"],
(
"absent"
if name == "legacy_comment_then_upper_key"
else "available"
),
)
# The re-probe has its own bound: an 8 MiB envelope beside the oversized
# workflow fails the server's read and stops the local one.
wide = self.directory / "reprobe_overflows.mp4"
@@ -804,6 +841,20 @@ class LocalVideoCarrierParityTests(unittest.TestCase):
with self.assertRaises(MetadataReadError):
read_inspection_file(wide)
# The server outcomes these shapes pin.
variants = read_inspection_file(files["case_variant_keys"])
self.assertEqual(variants.metadata["prompt"], GRAPH + ";x")
upper = read_inspection_file(files["legacy_comment_then_upper_key"])
self.assertEqual((upper.metadata, upper.video_carrier_text), ({}, None))
self.assertNotIn(
"workflow", read_inspection_file(files["kelvin_sign_workflow"]).metadata
)
mac = read_inspection_file(files["mac_roman_types"])
self.assertIn("caf\u00e9", mac.metadata["prompt"])
self.assertIn("\u00c4\u00c5", mac.metadata["workflow"])
self.assertIn(
'"last_node_id"',
read_inspection_file(files["matroska_case_variants"]).metadata["prompt"],
)
latin1 = read_inspection_file(files["latin1_byte_in_workflow"])
self.assertEqual(latin1.metadata["workflow"], '{"a":"caf\ufffd"}')
self.assertEqual(
+81 -20
View File
@@ -10,9 +10,11 @@
//
// 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). Envelope names are
// exact-case (comment, description) and the per-key names fold ASCII case in the
// selector, exactly as the server folds them.
// 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";
@@ -45,6 +47,20 @@ const METADATA_KEYS = ["prompt", "workflow", "parameters", "comfyassets_prompt_p
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];
@@ -195,6 +211,28 @@ function ffprobeText(bytes) {
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;
@@ -281,12 +319,20 @@ function createWalk(reader, signal, pacer) {
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; // {name, size}: the unread first per-key workflow
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,
@@ -297,13 +343,16 @@ function createWalk(reader, signal, pacer) {
return reader.read(offset, offset + length);
},
read: (start, end) => reader.read(start, end),
/** Whether `name` is a carrier the server reads (per-key folded, envelope exact). */
/** Whether ffprobe reports `name` to the server (the probed names, ASCII case folded). */
known(name) {
return PER_KEY_NAMES.has(name.toLowerCase()) || ENVELOPE_NAMES.has(name);
return PROBED_NAMES.has(asciiLower(name));
},
/** A string-less Matroska SimpleTag deletes the name, ignoring ASCII case. */
remove(name) {
if (oversized && oversized.name === name) forgetOversized();
delete tags[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
@@ -312,32 +361,37 @@ function createWalk(reader, signal, pacer) {
*/
finish() {
if (!oversized || total + oversized.size <= VIDEO_TAG_READ_BYTES) return;
for (const name of Object.keys(tags)) if (name.toLowerCase() === "workflow") delete tags[name];
// 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
* with that separator (ISO-BMFF repeats, as ffprobe reports them); `text`
* stores an already decoded 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 } = {}) {
async value(name, start, end, { join = null, text = null, mac = false } = {}) {
const size = end - start;
const folded = name.toLowerCase();
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 = { name, size };
tags[name] = OVERSIZED_WORKFLOW;
oversized = { size };
store(name, folded, OVERSIZED_WORKFLOW);
limitedRecords.push("workflow");
return;
}
if (tags[name] === OVERSIZED_WORKFLOW) {
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.
@@ -355,8 +409,12 @@ function createWalk(reader, signal, pacer) {
}
total += size;
if (total > VIDEO_TAG_READ_BYTES) throw overLimit();
const value = text ?? ffprobeText(untilNul(await reader.read(start, end)));
tags[name] = join !== null && name in tags ? `${tags[name]}${join}${value}` : value;
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);
},
};
}
@@ -481,7 +539,8 @@ function integerText(bytes, signed) {
/**
* 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 0, 1 and 4 are text, 21 and 22 are integers, other types are not tags.
* 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;
@@ -505,7 +564,9 @@ async function readIlst(walk, ilst, state) {
const start = inner + 16;
const end = inner + dataSize;
inner = end;
if (type === 0 || type === 1 || type === 4) await walk.value(name, start, end, { join: ";" });
// 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 });