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.
278 lines
10 KiB
JavaScript
278 lines
10 KiB
JavaScript
// Byte-exact ISO-BMFF (MP4/MOV/M4V) and Matroska (MKV/WebM) builders for the local
|
|
// video tag reader tests, laid out as ffmpeg's muxers write them (see the dumps in
|
|
// tests/fixtures/local-inspect). The File-like blobs come from png-bytes.mjs.
|
|
import { concat, u32, utf8, latin1 } from "./png-bytes.mjs";
|
|
|
|
export { concat, u32, utf8, latin1 };
|
|
export { sparseBlob, recordingBlob, slowBlob, failingBlob, blobOf } from "./png-bytes.mjs";
|
|
|
|
const toBytes = (part) => (typeof part === "string" ? utf8(part) : Buffer.from(part));
|
|
|
|
function u64(n) {
|
|
const out = new Uint8Array(8);
|
|
new DataView(out.buffer).setBigUint64(0, BigInt(n));
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- ISO-BMFF
|
|
|
|
/** A box: 32-bit size, 4-byte type (string or raw bytes), payload. `size` overrides the field. */
|
|
export function box(type, ...parts) {
|
|
const options = parts.length && parts[parts.length - 1] && parts[parts.length - 1].__options ? parts.pop() : {};
|
|
const payload = concat(...parts.map(toBytes));
|
|
const typeBytes = typeof type === "string" ? latin1(type) : Buffer.from(type);
|
|
const total = 8 + payload.length;
|
|
if (options.largesize) return concat(u32(1), typeBytes, u64(options.largesize === true ? total + 8 : options.largesize), payload);
|
|
return concat(u32(options.size ?? total), typeBytes, payload);
|
|
}
|
|
|
|
/** Marker for `box` options: box('ftyp', ..., opts({size: 3})). */
|
|
export function opts(options) {
|
|
return { __options: true, ...options };
|
|
}
|
|
|
|
export function fullBox(type, ...parts) {
|
|
return box(type, u32(0), ...parts);
|
|
}
|
|
|
|
export function ftyp(brand = "isom", compatible = "isomiso2avc1mp41") {
|
|
return box("ftyp", latin1(brand), u32(512), latin1(compatible));
|
|
}
|
|
|
|
export function mdat(payload = [0, 0, 0, 0]) {
|
|
return box("mdat", payload);
|
|
}
|
|
|
|
export const moov = (...children) => box("moov", ...children);
|
|
export const udta = (...children) => box("udta", ...children);
|
|
export const free = (count = 0) => box("free", new Uint8Array(count));
|
|
|
|
/** `meta` as ffmpeg writes it: a FullBox (version/flags) whose first child is hdlr. */
|
|
export function meta(...children) {
|
|
return fullBox("meta", ...children);
|
|
}
|
|
|
|
export function hdlr(handler = "mdta") {
|
|
return fullBox("hdlr", u32(0), latin1(handler), new Uint8Array(12), [0]);
|
|
}
|
|
|
|
/** `keys`: one mdta entry per name (an entry may be [name, namespace]); `count` overrides the count field. */
|
|
export function keys(names, { count } = {}) {
|
|
const entries = names.map((entry) => {
|
|
const [name, namespace] = Array.isArray(entry) ? entry : [entry, "mdta"];
|
|
const bytes = toBytes(name);
|
|
return concat(u32(8 + bytes.length), latin1(namespace), bytes);
|
|
});
|
|
return fullBox("keys", u32(count ?? names.length), ...entries);
|
|
}
|
|
|
|
export const ilst = (...items) => box("ilst", ...items);
|
|
|
|
/** A `data` atom: type indicator (1 = UTF-8), locale, payload. */
|
|
export function data(payload, { type = 1, locale = 0, size } = {}) {
|
|
return box("data", u32(type), u32(locale), toBytes(payload), ...(size === undefined ? [] : [opts({ size })]));
|
|
}
|
|
|
|
/** An ilst item addressed by its 1-based `keys` index. */
|
|
export function item(index, ...children) {
|
|
return box(u32(index), ...children);
|
|
}
|
|
|
|
/** An ilst item addressed by a legacy fourcc such as "©cmt" (Latin-1 bytes). */
|
|
export function legacyItem(fourcc, ...children) {
|
|
return box(latin1(fourcc), ...children);
|
|
}
|
|
|
|
/** `moov/udta/meta` carrying per-key mdta tags, as core SaveVideo writes through ffmpeg. */
|
|
export function mdtaUdta(tags) {
|
|
const names = Object.keys(tags);
|
|
return udta(meta(hdlr("mdta"), keys(names), ilst(...names.map((name, index) => item(index + 1, data(tags[name]))))));
|
|
}
|
|
|
|
/** `moov/udta/meta` with legacy ilst atoms: {comment, description} → ©cmt / desc. */
|
|
export function legacyUdta({ comment, description, tool = "Lavf" } = {}) {
|
|
const items = [legacyItem("©too", data(tool))];
|
|
if (comment !== undefined) items.push(legacyItem("©cmt", data(comment)));
|
|
if (description !== undefined) items.push(legacyItem("desc", data(description)));
|
|
return udta(meta(hdlr("mdir"), ilst(...items)));
|
|
}
|
|
|
|
/** A whole MP4: ftyp, (moov first when faststart), mdat, moov with the given udta. */
|
|
export function mp4(udtaBox, { brand = "isom", faststart = false, extraMoov = [], payload } = {}) {
|
|
const movie = moov(...extraMoov, udtaBox);
|
|
const media = mdat(payload);
|
|
return faststart ? concat(ftyp(brand), movie, media) : concat(ftyp(brand), free(), media, movie);
|
|
}
|
|
|
|
export function perKeyMp4(tags, options) {
|
|
return mp4(mdtaUdta(tags), options);
|
|
}
|
|
|
|
/** Top-level boxes of `bytes` as [{type, start, end}]. */
|
|
export function topLevelBoxes(bytes) {
|
|
const out = [];
|
|
let offset = 0;
|
|
while (offset + 8 <= bytes.length) {
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset + offset);
|
|
let size = view.getUint32(0);
|
|
if (size === 1) size = Number(view.getBigUint64(8));
|
|
if (size === 0) size = bytes.length - offset;
|
|
out.push({ type: latin1Of(bytes.subarray(offset + 4, offset + 8)), start: offset, end: offset + size });
|
|
offset += size;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function latin1Of(bytes) {
|
|
return Buffer.from(bytes).toString("latin1");
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Matroska
|
|
|
|
export const ID = {
|
|
EBML: 0x1a45dfa3,
|
|
EBMLVersion: 0x4286,
|
|
DocType: 0x4282,
|
|
DocTypeVersion: 0x4287,
|
|
Segment: 0x18538067,
|
|
SeekHead: 0x114d9b74,
|
|
Seek: 0x4dbb,
|
|
SeekID: 0x53ab,
|
|
SeekPosition: 0x53ac,
|
|
Void: 0xec,
|
|
CRC32: 0xbf,
|
|
Info: 0x1549a966,
|
|
Tracks: 0x1654ae6b,
|
|
Tags: 0x1254c367,
|
|
Tag: 0x7373,
|
|
Targets: 0x63c0,
|
|
TargetTypeValue: 0x68ca,
|
|
TagTrackUID: 0x63c5,
|
|
SimpleTag: 0x67c8,
|
|
TagName: 0x45a3,
|
|
TagString: 0x4487,
|
|
TagLanguage: 0x447a,
|
|
TagDefault: 0x4484,
|
|
TagBinary: 0x4485,
|
|
Cluster: 0x1f43b675,
|
|
Timestamp: 0xe7,
|
|
Cues: 0x1c53bb6b,
|
|
};
|
|
|
|
/** The ID's bytes (EBML IDs carry their own length marker). */
|
|
export function idBytes(id) {
|
|
const out = [];
|
|
let value = id;
|
|
while (value > 0) {
|
|
out.unshift(value & 0xff);
|
|
value = Math.floor(value / 256);
|
|
}
|
|
return Uint8Array.from(out);
|
|
}
|
|
|
|
/** A size VINT: minimal width unless `width` is given; `unknown` writes all ones. */
|
|
export function vint(value, { width, unknown = false } = {}) {
|
|
let length = width;
|
|
if (!length) {
|
|
length = 1;
|
|
while (length < 8 && value >= 2 ** (7 * length) - 1) length += 1;
|
|
}
|
|
const out = new Uint8Array(length);
|
|
if (unknown) {
|
|
out.fill(0xff);
|
|
out[0] = 0xff >> (length - 1);
|
|
return out;
|
|
}
|
|
let rest = BigInt(value);
|
|
for (let index = length - 1; index >= 0; index -= 1) {
|
|
out[index] = Number(rest & 0xffn);
|
|
rest >>= 8n;
|
|
}
|
|
out[0] |= 0x80 >> (length - 1);
|
|
return out;
|
|
}
|
|
|
|
/** An element: ID, size VINT, payload. `size`/`width`/`unknown` shape the size field. */
|
|
export function el(id, ...parts) {
|
|
const options = parts.length && parts[parts.length - 1] && parts[parts.length - 1].__options ? parts.pop() : {};
|
|
const payload = concat(...parts.map(toBytes));
|
|
const size = vint(options.size ?? payload.length, { width: options.width, unknown: options.unknown });
|
|
return concat(idBytes(id), size, payload);
|
|
}
|
|
|
|
export function uint(value, width) {
|
|
const out = [];
|
|
let rest = BigInt(value);
|
|
do {
|
|
out.unshift(Number(rest & 0xffn));
|
|
rest >>= 8n;
|
|
} while (rest > 0n);
|
|
while (width && out.length < width) out.unshift(0);
|
|
return Uint8Array.from(out);
|
|
}
|
|
|
|
export function ebmlHeader(docType = "matroska") {
|
|
return el(ID.EBML, el(ID.EBMLVersion, [1]), el(ID.DocType, docType), el(ID.DocTypeVersion, [4]));
|
|
}
|
|
|
|
/** Segment with an 8-byte size field as ffmpeg writes it; `unknown` makes it unknown-sized. */
|
|
export function segment(children, { unknown = false } = {}) {
|
|
return el(ID.Segment, ...children, opts({ width: 8, unknown }));
|
|
}
|
|
|
|
/** SeekHead entries: [[id, position]] with positions relative to the Segment payload. */
|
|
export function seekHead(entries) {
|
|
return el(ID.SeekHead, ...entries.map(([id, position]) => el(ID.Seek, el(ID.SeekID, idBytes(id)), el(ID.SeekPosition, uint(position)))));
|
|
}
|
|
|
|
export function voidEl(count = 4) {
|
|
return el(ID.Void, new Uint8Array(count));
|
|
}
|
|
|
|
/** A SimpleTag with TagName and TagString (`value` undefined omits the string). */
|
|
export function simpleTag(name, value, { lang, def, binary, nested = [], extra = [] } = {}) {
|
|
const children = [el(ID.TagName, name)];
|
|
if (lang !== undefined) children.push(el(ID.TagLanguage, lang));
|
|
if (def !== undefined) children.push(el(ID.TagDefault, [def]));
|
|
if (value !== undefined) children.push(el(ID.TagString, value));
|
|
if (binary !== undefined) children.push(el(ID.TagBinary, binary));
|
|
return el(ID.SimpleTag, ...children, ...nested, ...extra);
|
|
}
|
|
|
|
/** A Tag whose Targets carry the given track UID (none → global). */
|
|
export function tag(simpleTags, { trackUid, targetType } = {}) {
|
|
const targets = [];
|
|
if (targetType !== undefined) targets.push(el(ID.TargetTypeValue, uint(targetType)));
|
|
if (trackUid !== undefined) targets.push(el(ID.TagTrackUID, uint(trackUid, 8)));
|
|
return el(ID.Tag, el(ID.Targets, ...targets), ...simpleTags);
|
|
}
|
|
|
|
export function tagsEl(...tagElements) {
|
|
return el(ID.Tags, ...tagElements);
|
|
}
|
|
|
|
/** A Cluster of `size` payload bytes (zeros); `unknown` makes its size unknown. */
|
|
export function cluster(size = 8, { unknown = false, payload } = {}) {
|
|
return el(ID.Cluster, payload ?? new Uint8Array(size), opts({ unknown }));
|
|
}
|
|
|
|
/** A Cluster header only, declaring `size` payload bytes (for sparse layouts). */
|
|
export function clusterHeader(size) {
|
|
return concat(idBytes(ID.Cluster), vint(size, { width: 8 }));
|
|
}
|
|
|
|
/** A whole Matroska file: EBML header + Segment(children). */
|
|
export function mkv(children, { docType = "matroska", unknown = false } = {}) {
|
|
return concat(ebmlHeader(docType), segment(children, { unknown }));
|
|
}
|
|
|
|
/** A global Tags element from {name: value} (ffmpeg upper-cases names at mux time). */
|
|
export function globalTags(tags, options) {
|
|
return tagsEl(tag(Object.entries(tags).map(([name, value]) => simpleTag(name, value)), options));
|
|
}
|
|
|
|
/** A Matroska file whose global Tags hold {name: value}, ffmpeg layout. */
|
|
export function perKeyMkv(tags, { docType = "matroska" } = {}) {
|
|
return mkv([el(ID.Info, new Uint8Array(4)), globalTags(tags), cluster()], { docType });
|
|
}
|