Files
Vincent 16c3e6e103 Drop the browser build; space works the transport; fix bulk track rename
The browser page is gone, with its shim, its shell CSS and its suite. build.py
has no --tauri flag and one output: the app's page. The patch lists lose the
SHARED_ prefix, which only ever meant "shared between the two builds", and
test-pdf.js was quietly loading the browser page — it reads the app's now.

Space plays and pauses, in the main window off the player at the bottom and in
the mixer off the same transport. It is swallowed rather than passed on: that
stops the window scrolling, and stops a focused button taking the same press as
a second activation, which toggles twice and looks like a dead key. Typing, and
any dialog other than the mixer, keep it. Deciding what is in front needs both
markers — closing does not always remove .open, and .modal without it is
display:none regardless of the hidden attribute.

The mixer footer put the next file's name adrift in the middle of the row. The
margin-left: auto meant to park it beside Close could do nothing, because the
framework's flex: 1 1 auto had both nav groups growing to fill the row and left
no free space to absorb. Pinned to flex: 0 0 auto. The file-detail modal shares
the markup and follows.

Bulk track rename wrote nothing, on every file, and had never worked. A rename
is keyed on the name it replaces rather than a path, so the frame-rate pass
threw reading edit.path off it — before any byte was written, so nothing was
corrupted, only untouched. The failure handler discarded the reason and the run
still ended on a tidy Done, which is why 0 saved read as progress; it now names
the file and the cause in the log.

429 checks. The gap that hid the rename bug was end-to-end: the writer was unit
tested and the panel rendered, and nothing drove one into the other.
2026-08-18 00:14:36 +08:00

386 lines
15 KiB
JavaScript

/**
* The PDF, checked geometrically rather than by eye.
*
* With 26 fields selected the old writer gave every column 1/26th of A4
* landscape — 31pt — while a header like "Originator Reference" needs 69pt at
* that size, so labels printed straight over their neighbours and values
* truncated to junk. That's a measurable defect: parse the text-drawing
* operators out of the generated PDF, measure each string with the same font
* metrics jsPDF used, and assert nothing overlaps the column to its right.
*
* Run: npm i jsdom jspdf && node build/test-pdf.js
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
const zlib = require("zlib");
const assert = require("assert");
const { JSDOM, VirtualConsole } = require("jsdom");
const { jsPDF } = require("jspdf");
const { build } = require("./make-sample.js");
const INDEX = path.join(__dirname, "..", "mac-app", "dist", "index.html");
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 = 20000) {
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, 40);
})();
});
}
/* ------------------------------------------------------------------ */
/* Reading a PDF back */
/* ------------------------------------------------------------------ */
function pageWidthOf(bytes) {
const match = /\/MediaBox\s*\[([^\]]*)\]/.exec(bytes.toString("latin1"));
assert(match, "no MediaBox in the PDF");
return parseFloat(match[1].trim().split(/\s+/)[2]);
}
/** Every text-drawing op on the first page, with its font size and position. */
function textOps(bytes) {
const raw = bytes.toString("latin1");
const streams = [];
const re = /stream\r?\n([\s\S]*?)endstream/g;
let m;
while ((m = re.exec(raw)) !== null) {
const body = Buffer.from(m[1], "latin1");
try {
streams.push(zlib.inflateSync(body).toString("latin1"));
} catch (e) {
streams.push(m[1]);
}
}
const ops = [];
streams.forEach((content) => {
const opRe = /BT\s*\/F\d+\s+([\d.]+)\s+Tf[\s\S]*?([\d.]+)\s+([\d.]+)\s+Td\s*\((.*?)\)\s*Tj/g;
let op;
while ((op = opRe.exec(content)) !== null) {
ops.push({
size: parseFloat(op[1]),
x: parseFloat(op[2]),
y: parseFloat(op[3]),
text: op[4].replace(/\\([()\\])/g, "$1"),
});
}
});
return ops;
}
/** The header row: the widest band of ops sharing a y, below the title. */
function headerRow(ops) {
const byY = new Map();
ops.forEach((op) => {
if (op.size > 10) return; // title and its metadata line
byY.set(op.y, (byY.get(op.y) || []).concat(op));
});
let best = [];
byY.forEach((row) => {
if (row.length > best.length) best = row;
});
return best.slice().sort((a, b) => a.x - b.x);
}
function measure(text, size, bold) {
const probe = new jsPDF({ orientation: "landscape", unit: "pt", format: "a4" });
probe.setFont("helvetica", bold ? "bold" : "normal");
probe.setFontSize(size);
return probe.getTextWidth(text);
}
/* ------------------------------------------------------------------ */
/* Driving a real export */
/* ------------------------------------------------------------------ */
async function exportWith(fieldKeys, details) {
const pdfBytes = { value: null };
const consoleErrors = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", (e) => consoleErrors.push(e.message));
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
runScripts: "dangerously",
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 = () => {};
},
});
const { window } = dom;
await new Promise((r) => window.addEventListener("load", r));
const doc = window.document;
// jsPDF writes via an anchor; intercept at the instance instead.
const RealJsPDF = window.jspdf.jsPDF;
window.jspdf.jsPDF = function (options) {
const instance = new RealJsPDF(options);
instance.save = function () {
pdfBytes.value = Buffer.from(new Uint8Array(this.output("arraybuffer")));
return this;
};
return instance;
};
Object.keys(RealJsPDF).forEach((key) => { window.jspdf.jsPDF[key] = RealJsPDF[key]; });
const files = [
["A001_12A_T1.wav", {
scene: "12A", take: 1,
// A note with a line break in it, and the multi-line coding history
// EBU 3285 actually describes: both used to be drawn as extra lines
// straight through the rows below.
note: "boom a little hot on the wide,\r\nwatch it on the close",
codingHistory: "A=PCM,F=48000,W=24,M=stereo,T=833\r\n" +
"A=ANALOGUE,M=stereo,T=Schoeps CMIT 5U\r\n",
}],
["A002_12A_T2.wav", { scene: "12A", take: 2, note: "plane overhead from 00:12" }],
["A003_14B_T3.wav", { scene: "14B", take: 3, circled: true, description: "aSPEED=025.000-ND" }],
].map(([name, opts]) => {
const bytes = build(opts);
const view = new window.Uint8Array(bytes.length);
view.set(bytes);
return new window.File([view], name, { type: "audio/wav" });
});
const input = doc.querySelector("[data-bwfa-files-input]");
files.item = (i) => files[i];
Object.defineProperty(input, "files", { value: files, configurable: true });
input.dispatchEvent(new window.Event("change", { bubbles: true }));
await waitFor(() => /Done/i.test(doc.querySelector("[data-bwfa-status]").textContent), "parse");
// Set the export selection through the picker, as a user would.
doc.querySelector("[data-bwfa-export-fields-toggle]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
Array.from(doc.querySelectorAll("[data-bwfa-export-field]")).forEach((box) => {
const wanted = fieldKeys === "all" || fieldKeys.includes(box.getAttribute("data-bwfa-export-field"));
if (box.checked !== wanted) {
box.checked = wanted;
box.dispatchEvent(new window.Event("change", { bubbles: true }));
}
});
doc.querySelector("[data-bwfa-export-close]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
if (details) {
// Through the report modal, the way a user gets here: fill in the
// production details, leave the format on PDF, press Create Report.
doc.querySelector("[data-bwfa-report-open]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
Object.keys(details).forEach((key) => {
const input = doc.querySelector('[data-bwfa-report-field="' + key + '"]');
assert(input, "no report field called " + key);
input.value = details[key];
});
doc.querySelector("[data-bwfa-report-format]").value = "pdf";
doc.querySelector("[data-bwfa-report-create]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
} else {
doc.querySelector("[data-bwfa-export-pdf]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
}
await waitFor(() => pdfBytes.value, "pdf");
assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | "));
window.close();
return pdfBytes.value;
}
/* ------------------------------------------------------------------ */
(async () => {
const everything = await exportWith("all");
const withDetails = await exportWith(
["fileName", "scene", "take", "startTimecode"],
{
company: "Acme Films",
project: "The Long Weekend",
director: "R. Okonjo",
mixer: "Vincent Rozenberg",
phone: "+31 6 1234 5678",
email: "vincent@example.com",
note: "Day 4, ext. night",
}
);
const compact = await exportWith([
"fileName", "scene", "take", "startTimecode", "frameRate", "durationSeconds",
]);
const wideHeader = headerRow(textOps(everything));
const compactHeader = headerRow(textOps(compact));
check("all 26 fields reach the page", () => {
assert(wideHeader.length >= 24,
"only " + wideHeader.length + " header cells: " + wideHeader.map((o) => o.text).join(" | "));
});
check("no header prints over the column to its right", () => {
for (let i = 0; i < wideHeader.length - 1; i++) {
const cell = wideHeader[i];
const width = measure(cell.text, cell.size, true);
assert(cell.x + width <= wideHeader[i + 1].x + 0.5,
'"' + cell.text + '" ends at ' + (cell.x + width).toFixed(1) +
' but "' + wideHeader[i + 1].text + '" starts at ' + wideHeader[i + 1].x.toFixed(1));
}
});
check("the page grew sideways to hold them", () => {
const width = pageWidthOf(everything);
assert(width > 841.9, "still A4 landscape at " + width.toFixed(0) + "pt");
assert(width <= 2400, "page ran away to " + width.toFixed(0) + "pt");
});
check("every column is wide enough for its own header", () => {
for (let i = 0; i < wideHeader.length - 1; i++) {
const available = wideHeader[i + 1].x - wideHeader[i].x;
assert(available > 8, '"' + wideHeader[i].text + '" got only ' + available.toFixed(1) + "pt");
}
});
check("nothing anywhere is cut off", () => {
// The ellipsis was the writer's own doing — nothing in a BWF file
// contains one — so its presence is the defect, wherever it turns up.
[["all 26 fields", everything], ["a short selection", compact],
["a report with details", withDetails]].forEach(([what, bytes]) => {
const cut = textOps(bytes).filter((op) => /\u2026|\u0085/.test(op.text));
assert.deepStrictEqual(cut.map((op) => op.text), [],
what + " came out clipped: " + cut.map((op) => op.text).join(" | "));
});
});
check("no value prints over the column to its right", () => {
const ops = textOps(everything);
// The first data row: every cell measured as drawn, against where the
// next column starts.
const row = ops.filter((op) => op.y === ops.filter((o) => /^A001_/.test(o.text))[0].y)
.slice().sort((a, b) => a.x - b.x);
assert(row.length >= 20, "expected a cell per column, got " + row.length);
for (let i = 0; i < row.length - 1; i++) {
const width = measure(row[i].text, row[i].size, false);
assert(row[i].x + width <= row[i + 1].x + 0.5,
'"' + row[i].text + '" runs into "' + row[i + 1].text + '"');
}
});
check("a multi-line field is flattened, not spread over the rows below", () => {
const ops = textOps(everything);
ops.forEach((op) => {
assert(!/[\r\n]/.test(op.text), "a drawn string still has a line break: " + op.text);
});
const history = ops.filter((op) => /^A=PCM,F=48000,W=24,M=stereo,T=833/.test(op.text));
assert(history.length, "the coding history didn't make it into the report");
assert(/A=ANALOGUE/.test(history[0].text),
"the second history line was dropped rather than joined: " + history[0].text);
assert(/ \u00b7 /.test(history[0].text),
"the lines were run together with no separator: " + history[0].text);
const note = ops.filter((op) => /^boom a little hot/.test(op.text));
assert(note.length, "the note didn't make it into the report");
assert(/watch it on the close$/.test(note[0].text),
"the note lost its second line: " + note[0].text);
// Every row of the table on one baseline pitch: an extra line drawn by
// jsPDF for a \n would show up as a y that isn't on the grid.
const rowYs = Array.from(new Set(ops.filter((op) => op.size < 10).map((op) => op.y)))
.sort((a, b) => b - a);
const gaps = rowYs.slice(1).map((y, i) => rowYs[i] - y).filter((gap) => gap < 30);
gaps.forEach((gap) => assert(Math.abs(gap - 18) < 0.5 || Math.abs(gap - 12) < 0.5,
"an unexpected baseline gap of " + gap.toFixed(1) + "pt"));
});
check("values are not truncated to nonsense", () => {
const ops = textOps(everything);
const filenames = ops.filter((op) => /^A00\d_/.test(op.text));
assert(filenames.length >= 3, "expected a filename per row, got " + filenames.length);
assert(filenames.every((op) => /\.wav$/.test(op.text)),
"filenames lost their extension: " + filenames.map((o) => o.text).join(", "));
});
check("a short selection still fits A4 landscape", () => {
const width = pageWidthOf(compact);
assert(Math.abs(width - 841.89) < 1, "six columns should not resize the page: " + width.toFixed(1));
assert(compactHeader.length >= 6, "expected six header cells, got " + compactHeader.length);
});
check("a short selection spans the full page width", () => {
// Columns share out the spare room rather than huddling on the left.
const last = compactHeader[compactHeader.length - 1];
assert(last.x > 600, "the table stops at " + last.x.toFixed(0) + "pt of 842");
});
console.log("");
/** Where the table's own header sits, found by one of its column labels —
* headerRow() picks the widest band, and with four columns selected the
* detail block ties with it. */
const tableHeaderY = (ops) => {
const scene = ops.filter((op) => op.text === "Scene");
assert(scene.length, "no Scene column header in the PDF");
return scene[0].y;
};
check("the report carries the production details, above the table", () => {
const ops = textOps(withDetails);
const text = ops.map((op) => op.text).join(" | ");
["Acme Films", "The Long Weekend", "R. Okonjo", "Vincent Rozenberg",
"+31 6 1234 5678", "vincent@example.com", "Day 4, ext. night"].forEach((value) => {
assert(text.indexOf(value) !== -1, "missing from the report: " + value);
});
// In PDF space y counts up from the bottom, so "above" means larger.
const headerY = tableHeaderY(ops);
const detail = ops.filter((op) => /Acme Films|R\. Okonjo/.test(op.text));
detail.forEach((op) => {
assert(op.y > headerY,
"a detail line at y=" + op.y + " is below the table header at y=" + headerY);
});
// Two to a line, so seven details cost four lines, not seven.
const lines = new Set(detail.concat(ops.filter((op) =>
/Production company|Sound mixer|Mixer phone/.test(op.text))).map((op) => op.y));
assert(lines.size <= 4, "the details take " + lines.size + " lines");
});
check("the details push the table down rather than printing over it", () => {
const plain = tableHeaderY(textOps(compact));
const withHeader = tableHeaderY(textOps(withDetails));
assert(withHeader < plain,
"the table header didn't move: " + withHeader + " vs " + plain);
});
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");
process.exit(failed ? 1 : 0);
})().catch((e) => {
console.error("harness error:", e);
process.exit(1);
});