/** * Frame-rate writing, against the standards rather than against one reader. * * A frame rate in a BWF file lives in up to five places, and different software * reads different ones: * * iXML SPEED/TIMECODE_RATE rational — "30/1", 29.97 is 30000/1001 * iXML SPEED/TIMECODE_FLAG NDF or DF, and DF only means anything on the * 1000/1001 rates * iXML SPEED/MASTER_SPEED same rational, unless the file describes a * iXML SPEED/CURRENT_SPEED pull-up/pull-down, in which case: hands off * bext Description a recorder's own "aSPEED=025.000-ND" tag; bext * has no frame-rate field of its own * * Each case here drives the real app through a real save, then reads the bytes * back off disk. * * Run: npm i jsdom && node build/test-framerate.js */ const fs = require("fs"); const os = require("os"); const path = require("path"); const assert = require("assert"); const { JSDOM, VirtualConsole } = require("jsdom"); const { build } = require("./make-sample.js"); const INDEX = path.join(__dirname, "..", "mac-app", "dist", "index.html"); const root = fs.mkdtempSync(path.join(os.tmpdir(), "bwf-rate-")); /* ------------------------------------------------------------------ */ /* Rust commands, mirrored (see test-tauri.js) */ /* ------------------------------------------------------------------ */ let dialogQueue = []; function describe(filePath, relativePath) { const stat = fs.statSync(filePath); return { path: filePath, relativePath, name: path.basename(filePath), size: stat.size, lastModified: Math.floor(stat.mtimeMs), }; } function hexDecode(hex) { return Buffer.from(hex, "hex").toString("utf8"); } function mockInvoke(realm) { const toRealmBuffer = (buffer) => { const view = new realm.Uint8Array(buffer.length); view.set(buffer); return view.buffer; }; return function invoke(command, payload, options) { if (command === "bwf_write") { const headers = (options && options.headers) || {}; const target = hexDecode(headers["x-bwf-path"]); const position = parseInt(headers["x-bwf-position"] || "0", 10); const truncate = headers["x-bwf-truncate"] === "1"; const bytes = Buffer.from(payload.buffer ? new Uint8Array(payload) : payload); const fd = fs.openSync(target, "r+"); fs.writeSync(fd, bytes, 0, bytes.length, position); if (truncate) fs.ftruncateSync(fd, position + bytes.length); fs.closeSync(fd); return Promise.resolve(bytes.length); } if (command === "plugin:dialog|open") { const next = dialogQueue.shift(); return Promise.resolve(next === undefined ? null : next); } if (command === "bwf_list_dir") { return Promise.resolve( fs.readdirSync(payload.path).sort() .filter((name) => !name.startsWith(".")) .map((name) => ({ name, path: path.join(payload.path, name), kind: fs.statSync(path.join(payload.path, name)).isDirectory() ? "directory" : "file", })) ); } if (command === "bwf_stat") { return Promise.resolve(describe(payload.path, path.basename(payload.path))); } if (command === "bwf_read_range") { const size = fs.statSync(payload.path).size; if (payload.offset >= size || payload.length === 0) { return Promise.resolve(toRealmBuffer(Buffer.alloc(0))); } const take = Math.min(payload.length, size - payload.offset); const fd = fs.openSync(payload.path, "r"); const buffer = Buffer.alloc(take); fs.readSync(fd, buffer, 0, take, payload.offset); fs.closeSync(fd); return Promise.resolve(toRealmBuffer(buffer)); } if (command === "bwf_read_all") { return Promise.resolve(toRealmBuffer(fs.readFileSync(payload.path))); } return Promise.reject(new Error("unexpected command " + command)); }; } /* ------------------------------------------------------------------ */ /* Reading the result back off disk */ /* ------------------------------------------------------------------ */ function inspect(file) { const d = fs.readFileSync(file); let offset = 12; const chunks = {}; while (offset + 8 <= d.length) { const id = d.slice(offset, offset + 4).toString("latin1"); const size = d.readUInt32LE(offset + 4); chunks[id] = { start: offset + 8, size }; offset += 8 + size + (size % 2); } const xml = chunks.iXML ? d.slice(chunks.iXML.start, chunks.iXML.start + chunks.iXML.size).toString("utf8") : ""; const tag = (name) => { const match = new RegExp("<" + name + ">([^<]*)").exec(xml); return match ? match[1] : null; }; return { bytes: d, xml, rate: tag("TIMECODE_RATE"), flag: tag("TIMECODE_FLAG"), master: tag("MASTER_SPEED"), current: tag("CURRENT_SPEED"), description: chunks.bext ? d.slice(chunks.bext.start, chunks.bext.start + 256).toString("latin1").replace(/\0.*$/, "") : null, audio: chunks.data ? d.slice(chunks.data.start, chunks.data.start + chunks.data.size) : Buffer.alloc(0), }; } /* ------------------------------------------------------------------ */ /* One case: open a folder, set the rate (and flag), save */ /* ------------------------------------------------------------------ */ const results = []; function check(name, fn) { try { fn(); results.push(["PASS", name]); } catch (e) { results.push(["FAIL", name + " — " + e.message]); } } function waitFor(fn, label, timeout = 15000) { return new Promise((resolve, reject) => { const started = Date.now(); (function poll() { let value; try { value = fn(); } catch (e) { return reject(e); } if (value) return resolve(value); if (Date.now() - started > timeout) return reject(new Error("timed out waiting for " + label)); setTimeout(poll, 30); })(); }); } async function runCase(label, fileOpts, choose) { const dir = fs.mkdtempSync(path.join(root, label.replace(/\W+/g, "-") + "-")); const file = path.join(dir, "A001.wav"); fs.writeFileSync(file, build(fileOpts)); const before = inspect(file); const consoleErrors = []; const virtualConsole = new VirtualConsole(); virtualConsole.on("jsdomError", (e) => consoleErrors.push(e.message)); const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), { runScripts: "dangerously", // jsdom treats a custom scheme as an opaque origin and then // refuses localStorage, which would leave every persistence path // untested. Tauri itself serves the app from this origin on Windows and // from tauri://localhost on macOS; either way the app code is the same. url: "http://tauri.localhost/", pretendToBeVisual: true, virtualConsole, beforeParse(win) { const ctxStub = new Proxy({}, { get: (target, prop) => (prop === "canvas" ? null : () => {}), set: () => true, }); win.HTMLCanvasElement.prototype.getContext = () => ctxStub; win.URL.createObjectURL = () => "blob:stub"; win.URL.revokeObjectURL = () => {}; win.__TAURI__ = { core: { invoke: mockInvoke(win) }, event: { listen: () => Promise.resolve(() => {}) }, }; }, }); const { window } = dom; await new Promise((r) => window.addEventListener("load", r)); const doc = window.document; dialogQueue = [dir]; doc.querySelector("[data-bwfa-edit-folder]").dispatchEvent( new window.MouseEvent("click", { bubbles: true })); const status = doc.querySelector("[data-bwfa-status]"); await waitFor(() => /Done/i.test(status.textContent), "folder open"); const row = doc.querySelector("[data-bwfa-table-body] tr"); const detailsBtn = Array.from(row.querySelectorAll("button")) .find((b) => /^(details|edit)$/i.test(b.textContent.trim())); detailsBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); await waitFor(() => doc.querySelector('[data-bwfa-edit-field="frameRate"]'), "edit form"); choose(doc, window); const saveBtn = doc.querySelector("[data-bwfa-modal-save]"); saveBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); await waitFor(() => /saved/i.test(saveBtn.textContent), "save (" + label + ")"); assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | ")); window.close(); return { before, after: inspect(file) }; } function setField(doc, window, key, value) { const el = doc.querySelector('[data-bwfa-edit-field="' + key + '"]'); assert(el, "no field " + key); el.value = value; el.dispatchEvent(new window.Event("change", { bubbles: true })); el.dispatchEvent(new window.Event("input", { bubbles: true })); } (async () => { /* --- 25 -> 30 on a complete, well-formed file --- */ const plain = await runCase("25 to 30", { description: "aSPEED=025.000-ND", speed: { masterSpeed: "25/1", currentSpeed: "25/1", timecodeRate: "25/1", timecodeFlag: "NDF" }, }, (doc, win) => setField(doc, win, "frameRate", "30")); check("the rate is written as a rational, not a decimal", () => { assert.strictEqual(plain.after.rate, "30/1", "TIMECODE_RATE is " + JSON.stringify(plain.after.rate)); }); check("MASTER_SPEED and CURRENT_SPEED follow the rate", () => { assert.strictEqual(plain.after.master, "30/1", "MASTER_SPEED is " + plain.after.master); assert.strictEqual(plain.after.current, "30/1", "CURRENT_SPEED is " + plain.after.current); }); check("the recorder's own SPEED tag in bext is brought along", () => { assert.strictEqual(plain.after.description, "aSPEED=030.000-ND", "Description is " + JSON.stringify(plain.after.description)); }); check("the flag stays valid and the audio is untouched", () => { assert.strictEqual(plain.after.flag, "NDF", "flag is " + plain.after.flag); assert(plain.before.audio.equals(plain.after.audio), "audio bytes changed"); }); /* --- 25 -> 29.97 drop frame --- */ const drop = await runCase("25 to 29.97 DF", { description: "sSPEED=025.000-NDF", speed: { masterSpeed: "25/1", currentSpeed: "25/1", timecodeRate: "25/1", timecodeFlag: "NDF" }, }, (doc, win) => { setField(doc, win, "frameRate", "29.97"); setField(doc, win, "frameRateFlag", "DF"); }); check("29.97 is written as 30000/1001", () => { assert.strictEqual(drop.after.rate, "30000/1001", "rate is " + drop.after.rate); assert.strictEqual(drop.after.master, "30000/1001", "master is " + drop.after.master); }); check("drop frame is accepted on a 1000/1001 rate", () => assert.strictEqual(drop.after.flag, "DF", "flag is " + drop.after.flag)); check("the bext tag keeps the file's own NDF/DF spelling", () => { // This file wrote the long form, so it gets the long form back. assert.strictEqual(drop.after.description, "sSPEED=029.970-DF", "Description is " + JSON.stringify(drop.after.description)); }); /* --- drop frame asked for on a rate that cannot drop frames --- */ const impossible = await runCase("DF on 25", { description: "aSPEED=030.000-DF", speed: { masterSpeed: "30/1", currentSpeed: "30/1", timecodeRate: "30/1", timecodeFlag: "DF" }, }, (doc, win) => { setField(doc, win, "frameRate", "25"); setField(doc, win, "frameRateFlag", "DF"); }); check("drop frame is refused on a rate that can't drop frames", () => { assert.strictEqual(impossible.after.rate, "25/1", "rate is " + impossible.after.rate); assert.strictEqual(impossible.after.flag, "NDF", "DF was accepted on 25fps, which is meaningless: flag is " + impossible.after.flag); assert.strictEqual(impossible.after.description, "aSPEED=025.000-ND", "Description is " + JSON.stringify(impossible.after.description)); }); /* --- a file with no flag at all, and almost no iXML slack: the write has to grow the chunk, which means a full rebuild --- */ const grown = await runCase("no flag, no slack", { description: "aSPEED=025.000-ND", speed: { masterSpeed: null, currentSpeed: null, timecodeRate: "25/1", timecodeFlag: null }, slack: 4, }, (doc, win) => setField(doc, win, "frameRate", "30")); check("a missing flag is supplied, even when the chunk has to grow", () => { assert.strictEqual(grown.after.rate, "30/1", "rate is " + grown.after.rate); assert.strictEqual(grown.after.flag, "NDF", "flag is " + grown.after.flag); assert.strictEqual(grown.after.master, "30/1", "master missing: " + grown.after.master); assert(grown.before.audio.equals(grown.after.audio), "audio changed during the rebuild path"); }); /* --- pull-down: master and current disagree, so they are not ours --- */ const pulldown = await runCase("pulldown untouched", { description: "aSPEED=023.976-ND", speed: { masterSpeed: "24/1", currentSpeed: "24000/1001", timecodeRate: "24/1", timecodeFlag: "NDF" }, }, (doc, win) => setField(doc, win, "frameRate", "25")); check("a pull-down relationship is left alone", () => { assert.strictEqual(pulldown.after.rate, "25/1", "rate is " + pulldown.after.rate); assert.strictEqual(pulldown.after.master, "24/1", "MASTER_SPEED was overwritten: " + pulldown.after.master); assert.strictEqual(pulldown.after.current, "24000/1001", "CURRENT_SPEED was overwritten: " + pulldown.after.current); }); /* --- saving something unrelated must not touch the SPEED block --- */ const unrelated = await runCase("scene only", { description: "aSPEED=025.000-ND", speed: { masterSpeed: "25/1", currentSpeed: "25/1", timecodeRate: "25/1", timecodeFlag: "NDF" }, }, (doc, win) => setField(doc, win, "scene", "77")); check("saving another field leaves the rate as it was", () => { assert(/77<\/SCENE>/.test(unrelated.after.xml), "scene not written"); assert.strictEqual(unrelated.after.rate, "25/1", "the rate was rewritten on an unrelated save: " + unrelated.after.rate); assert.strictEqual(unrelated.after.description, "aSPEED=025.000-ND", "Description touched on an unrelated save: " + JSON.stringify(unrelated.after.description)); }); console.log(""); results.forEach(([s, n]) => console.log((s === "PASS" ? " ok " : " FAIL") + " " + n)); const failed = results.filter(([s]) => s === "FAIL").length; console.log("\n" + (results.length - failed) + "/" + results.length + " checks passed"); fs.rmSync(root, { recursive: true, force: true }); process.exit(failed ? 1 : 0); })().catch((e) => { console.error("harness error:", e); process.exit(1); });