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.
This commit is contained in:
2026-08-18 00:14:36 +08:00
parent 2d0b7fe8b5
commit 16c3e6e103
13 changed files with 409 additions and 860 deletions
+164 -61
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env python3
"""Assemble the standalone single-file BWF Analyser.
"""Assemble the single-file frontend for the BWF Analyser Mac app.
Reads the original plugin's CSS/JS (unmodified), the ported markup, the
generated l10n object and a copy of jsPDF, and writes one index.html that
runs with no network access at all.
runs with no network access at all. Rust embeds that file into the binary.
python3 build/build.py -> ./index.html (open in a browser)
python3 build/build.py --tauri -> ./mac-app/dist/index.html (Tauri frontend)
python3 build/build.py -> ./mac-app/dist/index.html
The Tauri variant swaps the file:// permission warning for the native bridge,
which routes every read and write through Rust.
There used to be a second, browser-only page built from the same sources.
It is gone: every read and write now goes through the native bridge into
Rust, and there is one page and one build.
"""
import base64
@@ -17,11 +17,9 @@ import json
import pathlib
import sys
TAURI = "--tauri" in sys.argv
BUILD = pathlib.Path(__file__).resolve().parent
SRC = BUILD / "src"
OUT = (BUILD.parent / "mac-app" / "dist" / "index.html") if TAURI else (BUILD.parent / "index.html")
OUT = BUILD.parent / "mac-app" / "dist" / "index.html"
def read(path):
@@ -176,6 +174,13 @@ FRAME_RATE_WRITER_JS = r''' /* ------------------------------------------------
var flagEdit = null;
ixmlEdits.forEach( function ( edit ) {
// Not every edit is addressed by a path. A bulk track rename is
// keyed on the name it is replacing — { renameTrack: … }, no path
// at all — and reading one off it threw before a single byte was
// written, which failed every file in the run.
if ( ! edit.path ) {
return;
}
var leaf = edit.path[ edit.path.length - 1 ];
if ( leaf === "TIMECODE_RATE" ) {
rateEdit = edit;
@@ -654,10 +659,10 @@ EXPORT_PICKER_JS = r''' /* ---- export field picker ---- */
'''
# Correctness patches, applied to both builds: the analyser writes frame rate
# the way the standards define it, in every place a reader might look, plus the
# frame rate in the table and a picker for what the exports carry.
SHARED_APP_JS_PATCHES = [
# Correctness patches: the analyser writes frame rate the way the standards
# define it, in every place a reader might look, plus the frame rate in the
# table and a picker for what the exports carry.
BASE_APP_JS_PATCHES = [
# ---- sample rate is a list, not a free field ------------------------
#
# There are maybe nine sample rates in professional use and a typo in
@@ -931,7 +936,7 @@ SHARED_APP_JS_PATCHES = [
FRAME_RATE_SHARED_PATCHES = [
FRAME_RATE_PATCHES = [
(
''' function writeMetadataToFile( row, edits ) {''',
FRAME_RATE_WRITER_JS + ''' function writeMetadataToFile( row, edits ) {''',
@@ -1525,10 +1530,10 @@ PLAYBACK_PATCHES = [
]
SHARED_APP_JS_PATCHES = SHARED_APP_JS_PATCHES + FRAME_RATE_SHARED_PATCHES + PLAYBACK_PATCHES
BASE_APP_JS_PATCHES = BASE_APP_JS_PATCHES + FRAME_RATE_PATCHES + PLAYBACK_PATCHES
# Tauri-only patches to the analyser's JavaScript. The file in build/src stays
# Further patches to the analyser's JavaScript. The file in build/src stays
# exactly as the plugin ships it; these are applied on the way into the app
# build, and every one has to match or the build stops — a silent no-op here
# would look like a UI regression later.
@@ -2263,6 +2268,97 @@ MIXER_PATCHES = [
]
# ---- space is the transport ------------------------------------------
# The way it is in every editor: space plays and pauses, in the main window
# off the player at the bottom, and in the mixer off the same transport.
# It goes in here rather than in MIXER_PATCHES because it is not the mixer's
# key — the mixer is just one of the two places it has to work — but it is
# inserted into that closure, which is where mixerOpen and togglePlayPause
# both live.
#
# Swallowing the key earns its place twice over: it stops the page
# scrolling, and it stops a focused button taking the same press as a second
# activation. Two toggles in one press looks exactly like a dead spacebar.
TRANSPORT_KEY_PATCHES = [
(
"""\t\tcontainer.addEventListener( "click", function ( e ) {
\t\t\tif ( e.target.closest( "[data-bwfa-mixer-open]" ) ) {
\t\t\t\topenMixer();
\t\t\t\treturn;
\t\t\t}""",
"""\t\t/** True where a space is a character being typed, not a command. */
\t\tfunction typingInField( node ) {
\t\t\tif ( ! node || ! node.tagName ) {
\t\t\t\treturn false;
\t\t\t}
\t\t\tif ( node.isContentEditable ) {
\t\t\t\treturn true;
\t\t\t}
\t\t\tvar tag = node.tagName.toLowerCase();
\t\t\tif ( tag === "textarea" || tag === "select" ) {
\t\t\t\treturn true;
\t\t\t}
\t\t\tif ( tag !== "input" ) {
\t\t\t\treturn false;
\t\t\t}
\t\t\t// The faders are inputs too, and a space with one under the finger
\t\t\t// should still work the transport rather than do nothing.
\t\t\tvar type = ( node.getAttribute( "type" ) || "text" ).toLowerCase();
\t\t\treturn type !== "range" && type !== "checkbox" && type !== "radio" &&
\t\t\t\ttype !== "button" && type !== "submit";
\t\t}
\t\t/** Whether something on screen has a better claim to the spacebar.
\t\t *
\t\t * The mixer wins outright when it is up: it carries a transport of its
\t\t * own, and it can be opened over the file sheet, so "something else is
\t\t * also open" says nothing about what is in front. Otherwise any modal
\t\t * takes it — the file sheet, export, settings, the spectrogram are
\t\t * each their own screen with their own keyboard, and working the
\t\t * transport from underneath one would be a surprise.
\t\t *
\t\t * Both markers have to agree on open. Closing does not always take the
\t\t * .open class back off, and .modal without .open is display:none
\t\t * however the hidden attribute reads. */
\t\tfunction transportKeyBlocked() {
\t\t\tif ( mixerOpen() ) {
\t\t\t\treturn false;
\t\t\t}
\t\t\treturn document.querySelectorAll( ".modal.open:not([hidden])" ).length > 0;
\t\t}
\t\tdocument.addEventListener( "keydown", function ( e ) {
\t\t\t// "Spacebar" is what older WebKit reports, and this ships in a webview.
\t\t\tif ( e.key !== " " && e.key !== "Spacebar" ) {
\t\t\t\treturn;
\t\t\t}
\t\t\tif ( e.metaKey || e.ctrlKey || e.altKey ) {
\t\t\t\treturn;
\t\t\t}
\t\t\tif ( typingInField( e.target ) || transportKeyBlocked() ) {
\t\t\t\treturn;
\t\t\t}
\t\t\t// Taken whether or not a file is loaded: where the transport is the
\t\t\t// thing on screen, the space belongs to it, and scrolling instead is
\t\t\t// never what was meant by it.
\t\t\te.preventDefault();
\t\t\t// Holding the key must not toggle thirty times a second.
\t\t\tif ( e.repeat || ! state.playback ) {
\t\t\t\treturn;
\t\t\t}
\t\t\ttogglePlayPause();
\t\t\tsyncMixerTransport();
\t\t} );
\t\tcontainer.addEventListener( "click", function ( e ) {
\t\t\tif ( e.target.closest( "[data-bwfa-mixer-open]" ) ) {
\t\t\t\topenMixer();
\t\t\t\treturn;
\t\t\t}""",
),
]
PLAYER_PATCHES = [
(
"""\t\tfunction decodeFileToAudioBuffer( row ) {
@@ -2489,6 +2585,25 @@ PLAYER_PATCHES = [
]
APP_JS_PATCHES = [
# ---- a failed file has to say why -----------------------------------
#
# This swallowed the reason entirely, and a bulk run that writes nothing
# at all still ends with a tidy "Done" line — so the only signal that
# forty files were untouched was a count most people read as progress.
# The reason goes to the log (Cmd-Shift-L) with the file it belongs to.
(
"""\t\t\t\t} ).catch( function () {
\t\t\t\t\tfailed++;
\t\t\t\t} ).then( function () {
\t\t\t\t\tnext( index + 1 );""",
"""\t\t\t\t} ).catch( function ( err ) {
\t\t\t\t\tfailed++;
\t\t\t\t\tconsole.error( "BWF Analyser: bulk edit failed on " +
\t\t\t\t\t\t( ( row.parsed && row.parsed.fileName ) || "a file" ) + "" +
\t\t\t\t\t\t( ( err && ( err.message || err.name ) ) || err ) );
\t\t\t\t} ).then( function () {
\t\t\t\t\tnext( index + 1 );""",
),
# ---- the confirmation has to be visible to be a confirmation --------
#
# This was window.confirm, which in this webview returns false without
@@ -2858,14 +2973,12 @@ def guard(text, label, tag):
css = guard(read(SRC / "bwf-analyser.css"), "plugin css", "style")
overrides_css = guard(read(BUILD / "overrides.css"), "overrides css", "style")
shell_css = guard(read(BUILD / ("tauri-shell.css" if TAURI else "shell.css")), "shell css", "style")
shell_css = guard(read(BUILD / "tauri-shell.css"), "shell css", "style")
# The icon, inlined. 256px covers the launch screen's 88px at any scale
# factor, and keeps the payload to a few KB.
launch_logo = ""
if TAURI:
icon = BUILD.parent / "mac-app" / "src-tauri" / "icons" / "128x128@2x.png"
launch_logo = "data:image/png;base64," + base64.b64encode(icon.read_bytes()).decode("ascii")
icon = BUILD.parent / "mac-app" / "src-tauri" / "icons" / "128x128@2x.png"
launch_logo = "data:image/png;base64," + base64.b64encode(icon.read_bytes()).decode("ascii")
body = read(BUILD / "body.html")
l10n = json.loads(read(BUILD / "l10n.json"))
jspdf = guard(read(SRC / "jspdf.umd.min.js"), "jspdf", "script")
@@ -2911,18 +3024,16 @@ def apply_patches(source, patches, label):
return source
# Correctness fixes belong in every build; the UI reshuffles are app-only.
parser_js = apply_patches(parser_js, PARSER_PATCHES, "parser js patch")
app_js = apply_patches(app_js, SHARED_APP_JS_PATCHES, "shared app js patch")
app_js = apply_patches(app_js, BASE_APP_JS_PATCHES, "base app js patch")
app_js = apply_patches(app_js, MIXER_PATCHES, "mixer patch")
if TAURI:
app_js = apply_patches(app_js, APP_JS_PATCHES, "tauri app js patch")
app_js = apply_patches(app_js, PLAYER_PATCHES, "player patch")
app_js = apply_patches(app_js, SPECTRO_PATCHES, "spectrogram patch")
shim_js = guard(read(BUILD / ("tauri-bridge.js" if TAURI else "shim.js")), "shim js", "script")
app_js = apply_patches(app_js, TRANSPORT_KEY_PATCHES, "transport key patch")
app_js = apply_patches(app_js, APP_JS_PATCHES, "app js patch")
app_js = apply_patches(app_js, PLAYER_PATCHES, "player patch")
app_js = apply_patches(app_js, SPECTRO_PATCHES, "spectrogram patch")
shim_js = guard(read(BUILD / "tauri-bridge.js"), "shim js", "script")
if TAURI:
replacements = [
replacements = [
# The editor is the only way in now, so the button reads as an opener
# rather than as an alternative to something above it.
(
@@ -2967,30 +3078,30 @@ if TAURI:
data-bwfa-player-export>Export</button>''',
),
]
for old, new in replacements:
if old not in body:
sys.exit("tauri markup edit no longer matches: %.60s..." % old)
body = body.replace(old, new)
for old, new in replacements:
if old not in body:
sys.exit("tauri markup edit no longer matches: %.60s..." % old)
body = body.replace(old, new)
# Bulk edit moves out of the results panel and into a modal of its own,
# taking the export panel with it. Both end up after the detail modal,
# inside the scoped app container so the stylesheet still reaches them.
body, bulk_panel = cut(
body,
'<div class="bwfa-bulk-edit-panel" data-bwfa-bulk-edit-panel hidden>',
'<button type="button" class="btn btn-primary" data-bwfa-bulk-edit-apply disabled>Apply</button>\n\t\t\t</div>\n\t\t</div>',
"the bulk edit panel",
)
sheets = (
sheet("bulk", "Bulk Edit", bulk_panel.strip(), "bwfa-sheet-wide") +
sheet("export", "Export Files", EXPORT_PANEL_HTML) +
sheet("result", "Export report", EXPORT_RESULT_HTML, "bwfa-sheet-report")
)
# Bulk edit moves out of the results panel and into a modal of its own,
# taking the export panel with it. Both end up after the detail modal,
# inside the scoped app container so the stylesheet still reaches them.
body, bulk_panel = cut(
body,
'<div class="bwfa-bulk-edit-panel" data-bwfa-bulk-edit-panel hidden>',
'<button type="button" class="btn btn-primary" data-bwfa-bulk-edit-apply disabled>Apply</button>\n\t\t\t</div>\n\t\t</div>',
"the bulk edit panel",
)
sheets = (
sheet("bulk", "Bulk Edit", bulk_panel.strip(), "bwfa-sheet-wide") +
sheet("export", "Export Files", EXPORT_PANEL_HTML) +
sheet("result", "Export report", EXPORT_RESULT_HTML, "bwfa-sheet-report")
)
tail = "\t\t</div>\n\t</div>\n</div>\n"
if body.count(tail) != 1:
sys.exit("cannot place the modals: the end of body.html no longer matches")
body = body.replace(tail, "\t\t</div>\n\t</div>\n\n" + sheets + "</div>\n")
tail = "\t\t</div>\n\t</div>\n</div>\n"
if body.count(tail) != 1:
sys.exit("cannot place the modals: the end of body.html no longer matches")
body = body.replace(tail, "\t\t</div>\n\t</div>\n\n" + sheets + "</div>\n")
l10n_js = "window.bwfaL10n = " + json.dumps(l10n, ensure_ascii=False, indent=1) + ";"
@@ -3016,7 +3127,7 @@ template = """<!DOCTYPE html>
@@BODY@@
</div>
@@FOOTER_BLOCK@@<script>
<script>
/* UI strings. Edit any value here to relabel the interface. */
@@L10N@@
</script>
@@ -3054,14 +3165,6 @@ substitutions = {
"app": app_js,
"version": "1.5.1",
"launch_logo": launch_logo,
# The app's own window frame says what it is; a web page benefits from a
# line explaining itself.
"footer_block": "" if TAURI else (
'<div class="page-foot">\n'
"\tBWF Analyser 1.5.1 &mdash; standalone offline build. No network requests, no server, no\n"
"\tdependencies to install. Everything you select stays on this computer.\n"
"</div>\n\n"
),
}
html = template