BWF Analyser: browser page and macOS app
Reads and edits BWF metadata for production sound. One source tree builds a single self-contained page and a native Tauri app with a Rust audio engine and WAV writer. Around 370 checks across seven test suites. First commit of the existing state, so that from here every change can be seen and undone.
This commit is contained in:
Binary file not shown.
Binary file not shown.
+404
@@ -0,0 +1,404 @@
|
||||
<div id="bwfa-app-1" class="bwfa-scope bwfa-app" data-bwfa-app aria-label="BWF Metadata Analyser">
|
||||
<div class="bwfa-header">
|
||||
<h2 class="bwfa-title">BWF Metadata Analyser</h2>
|
||||
<p class="bwfa-privacy-note">
|
||||
<span class="badge badge-success" aria-hidden="true">Local only</span>
|
||||
Your files are read directly in this browser tab and never leave your computer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-dropzone" data-bwfa-dropzone tabindex="0" role="button"
|
||||
aria-label="Select or drop a folder or files of BWF/WAV recordings">
|
||||
<p class="bwfa-dropzone-text" data-bwfa-dropzone-text></p>
|
||||
<div class="bwfa-dropzone-actions">
|
||||
<button type="button" class="btn btn-primary" data-bwfa-select-folder>Select Folder</button>
|
||||
<button type="button" class="btn btn-secondary" data-bwfa-select-files>Select Files</button>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-clear disabled>Clear</button>
|
||||
</div>
|
||||
<p class="bwfa-dropzone-subnote">
|
||||
On iPhone/iPad, if the folder picker won't let you select a folder you've navigated into, use "Select Files" and choose the recordings directly instead — this is a known iOS quirk with folder pickers in general, not specific to this tool.
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
class="bwfa-visually-hidden"
|
||||
data-bwfa-file-input
|
||||
webkitdirectory
|
||||
directory
|
||||
multiple
|
||||
accept=".wav,.bwf,.broadcastwave"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
class="bwfa-visually-hidden"
|
||||
data-bwfa-files-input
|
||||
multiple
|
||||
accept=".wav,.bwf,.broadcastwave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-edit-entry" data-bwfa-edit-entry hidden>
|
||||
<div class="bwfa-edit-divider"><span>or</span></div>
|
||||
<div class="bwfa-launch-logo" aria-hidden="true"></div>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-edit-folder>Edit Metadata in a Folder…</button>
|
||||
<span class="bwfa-current-folder" data-bwfa-current-folder hidden></span>
|
||||
<p class="bwfa-dropzone-subnote">
|
||||
This opens files for editing rather than just viewing. Saving a change writes it directly back to the original file on your disk — there is no undo, and no automatic backup is made. Available in Chromium-based browsers only (Chrome, Edge, Opera, Arc); Firefox and Safari, including iPhone/iPad, cannot write to local files from a webpage at all.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="bwfa-fs-warning" data-bwfa-fs-warning>
|
||||
Your browser blocked the folder-write permission because this page was opened straight from disk
|
||||
(a <code>file://</code> address), where writing to local files isn't allowed. Reading, playback and
|
||||
export all still work. To edit metadata, serve this folder over http instead: run
|
||||
<code>python3 -m http.server</code> in the folder holding this file, then open
|
||||
<code>http://localhost:8000/</code>.
|
||||
</p>
|
||||
|
||||
<div class="bwfa-status" data-bwfa-status role="status" aria-live="polite"></div>
|
||||
|
||||
<div class="bwfa-progress progress" data-bwfa-progress-wrap hidden>
|
||||
<div class="progress-bar" data-bwfa-progress-bar style="width:0%"></div>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-results" data-bwfa-results hidden>
|
||||
<div class="bwfa-toolbar">
|
||||
<div class="form-group bwfa-search-group">
|
||||
<input
|
||||
type="search"
|
||||
class="form-control"
|
||||
data-bwfa-search
|
||||
placeholder="Filter by filename, scene, take…"
|
||||
/>
|
||||
</div>
|
||||
<div class="bwfa-export-actions">
|
||||
<!-- Columns moved to the cogwheel in the table's first header
|
||||
cell. The button stays here, hidden, because the analyser
|
||||
wires a listener to it at startup. -->
|
||||
<div class="dropdown bwfa-legacy-export" data-bwfa-columns-dropdown>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-columns-toggle>Columns</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-bulk-edit-toggle hidden>Bulk Edit</button>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-report-open>Sound Report</button>
|
||||
<!-- The three buttons the report modal replaced. Kept in the DOM,
|
||||
hidden by CSS: the analyser wires its export pipeline to
|
||||
them at startup, and the modal drives them rather than
|
||||
duplicating any of it. -->
|
||||
<button type="button" class="btn btn-outline bwfa-legacy-export" data-bwfa-export-fields-toggle>Export Fields</button>
|
||||
<button type="button" class="btn btn-secondary bwfa-legacy-export" data-bwfa-export-csv>Export CSV</button>
|
||||
<button type="button" class="btn btn-secondary bwfa-legacy-export" data-bwfa-export-pdf>Export PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-editing-note" data-bwfa-editing-note hidden>
|
||||
<span class="badge badge-warning">Editing</span>
|
||||
Changes save directly to the original files on disk.
|
||||
</div>
|
||||
|
||||
<div class="bwfa-bulk-edit-panel" data-bwfa-bulk-edit-panel hidden>
|
||||
<div class="bwfa-bulk-edit-header">
|
||||
<h4>Bulk Edit</h4>
|
||||
<p data-bwfa-bulk-edit-count></p>
|
||||
</div>
|
||||
<div class="bwfa-bulk-edit-fields" data-bwfa-bulk-edit-fields></div>
|
||||
<div class="bwfa-bulk-tracks" data-bwfa-bulk-tracks hidden></div>
|
||||
<div class="bwfa-bulk-edit-actions">
|
||||
<div class="bwfa-bulk-edit-progress" data-bwfa-bulk-edit-progress hidden></div>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-bulk-edit-cancel>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-bwfa-bulk-edit-apply disabled>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-player" data-bwfa-player hidden>
|
||||
<div class="bwfa-player-label">
|
||||
<span class="badge badge-info" data-bwfa-player-badge>Now playing</span>
|
||||
<span class="bwfa-player-filename" data-bwfa-player-filename></span>
|
||||
<button type="button" class="btn btn-sm btn-outline bwfa-player-edit"
|
||||
data-bwfa-player-edit>Edit</button>
|
||||
<span class="bwfa-player-decoding" data-bwfa-player-decoding hidden>Decoding…</span>
|
||||
<button type="button" class="bwfa-round-btn bwfa-mixer-open"
|
||||
data-bwfa-mixer-open aria-label="Mixer">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="5.6" fill="none" stroke="currentColor"
|
||||
stroke-width="1.4"/>
|
||||
<path d="M8 3.6 V 8" stroke="currentColor" stroke-width="1.6"
|
||||
stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="bwfa-round-btn bwfa-spectro-open"
|
||||
data-bwfa-spectro-open aria-label="Spectrogram">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<rect x="1" y="9" width="2" height="5" rx="0.6"/>
|
||||
<rect x="4.5" y="5" width="2" height="9" rx="0.6"/>
|
||||
<rect x="8" y="2" width="2" height="12" rx="0.6"/>
|
||||
<rect x="11.5" y="6.5" width="2" height="7.5" rx="0.6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop bwfa-mixer-backdrop" data-bwfa-mixer-backdrop hidden></div>
|
||||
<div class="modal bwfa-mixer-modal" data-bwfa-mixer hidden>
|
||||
<div class="modal-dialog bwfa-mixer-dialog" role="dialog" aria-modal="true"
|
||||
aria-label="Mixer">
|
||||
<button type="button" class="modal-close" data-bwfa-mixer-close
|
||||
aria-label="Close">×</button>
|
||||
<div class="modal-header">
|
||||
<div class="bwfa-modal-title-group">
|
||||
<h3 class="modal-title">Mixer</h3>
|
||||
<span class="bwfa-modal-filename" data-bwfa-mixer-filename></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="bwfa-mixer-strips" data-bwfa-mixer-strips></div>
|
||||
<div class="bwfa-player-transport bwfa-mixer-transport">
|
||||
<button type="button" class="btn btn-sm btn-primary bwfa-player-playpause"
|
||||
data-bwfa-mixer-playpause><span class="bwfa-btn-label"
|
||||
data-bwfa-label>Play</span></button>
|
||||
<span class="bwfa-player-time" data-bwfa-mixer-elapsed>00:00:00</span>
|
||||
<canvas class="bwfa-player-waveform" data-bwfa-mixer-waveform
|
||||
width="700" height="56"
|
||||
aria-label="Waveform — click to seek"></canvas>
|
||||
<span class="bwfa-player-time" data-bwfa-mixer-duration>00:00:00</span>
|
||||
</div>
|
||||
<p class="bwfa-mixer-note">This mix is for listening only. Exports and
|
||||
reports ignore it.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="bwfa-modal-nav bwfa-modal-nav-back">
|
||||
<button type="button" class="btn btn-sm btn-outline"
|
||||
data-bwfa-mixer-prev aria-label="Previous file">‹</button>
|
||||
<span class="bwfa-modal-nav-name" data-bwfa-mixer-prev-name></span>
|
||||
</div>
|
||||
<div class="bwfa-modal-nav bwfa-modal-nav-on">
|
||||
<span class="bwfa-modal-nav-name" data-bwfa-mixer-next-name></span>
|
||||
<button type="button" class="btn btn-sm btn-outline"
|
||||
data-bwfa-mixer-next aria-label="Next file">›</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-mixer-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop bwfa-spectro-backdrop" data-bwfa-spectro-backdrop hidden></div>
|
||||
<div class="modal bwfa-spectro-modal" data-bwfa-spectro hidden>
|
||||
<div class="modal-dialog bwfa-spectro-dialog" role="dialog" aria-modal="true"
|
||||
aria-label="Spectrogram">
|
||||
<button type="button" class="modal-close" data-bwfa-spectro-close
|
||||
aria-label="Close">×</button>
|
||||
<div class="bwfa-spectro-head">
|
||||
<h4 data-bwfa-spectro-title>Spectrogram</h4>
|
||||
<p data-bwfa-spectro-note></p>
|
||||
<button type="button" class="btn btn-sm btn-outline bwfa-spectro-save"
|
||||
data-bwfa-spectro-save>Save Image</button>
|
||||
</div>
|
||||
<div class="bwfa-spectro-plot">
|
||||
<div class="bwfa-spectro-scale" data-bwfa-spectro-scale></div>
|
||||
<canvas class="bwfa-spectro-canvas" data-bwfa-spectro-canvas></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bwfa-player-transport">
|
||||
<button type="button" class="btn btn-sm btn-primary bwfa-player-playpause" data-bwfa-player-playpause><span class="bwfa-btn-label" data-bwfa-label>Play</span></button>
|
||||
<span class="bwfa-player-time" data-bwfa-player-elapsed>00:00:00</span>
|
||||
<canvas class="bwfa-player-waveform" data-bwfa-player-waveform width="700" height="56"
|
||||
aria-label="Waveform — click to seek"></canvas>
|
||||
<span class="bwfa-player-time" data-bwfa-player-duration>00:00:00</span>
|
||||
</div>
|
||||
<div class="bwfa-channel-row" data-bwfa-channel-row>
|
||||
<span class="bwfa-channel-hint">Click a channel to mute it, double-click to solo it:</span>
|
||||
<div class="chip-group bwfa-channel-chips" data-bwfa-channel-chips></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover bwfa-table">
|
||||
<thead data-bwfa-table-head></thead>
|
||||
<tbody data-bwfa-table-body></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-empty" data-bwfa-empty>No files analysed yet.</div>
|
||||
|
||||
<!-- Which columns the table shows. Opened by the cogwheel above the Play
|
||||
column, since that's where you are when you want it. -->
|
||||
<div class="modal-backdrop" data-bwfa-columns-backdrop hidden></div>
|
||||
<div class="modal" data-bwfa-columns-modal role="dialog" aria-modal="true"
|
||||
aria-labelledby="bwfa-app-1-columns-title" hidden>
|
||||
<div class="modal-dialog bwfa-columns-dialog">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="bwfa-app-1-columns-title">Settings</h3>
|
||||
<button type="button" class="modal-close" data-bwfa-columns-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<details class="bwfa-settings-section" data-bwfa-settings="appearance">
|
||||
<summary>Appearance</summary>
|
||||
<div class="bwfa-settings-body">
|
||||
<div class="bwfa-choice-list">
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-theme" value="auto" checked data-bwfa-theme-mode>
|
||||
<span>Match the system<small>Follow macOS as it changes through the day</small></span>
|
||||
</label>
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-theme" value="light" data-bwfa-theme-mode>
|
||||
<span>Light<small>Paper white, whatever the system is doing</small></span>
|
||||
</label>
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-theme" value="dark" data-bwfa-theme-mode>
|
||||
<span>Dark<small>Anthracite, for a night shoot or a dark suite</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="bwfa-settings-section" data-bwfa-settings="playback">
|
||||
<summary>When a file finishes</summary>
|
||||
<div class="bwfa-settings-body">
|
||||
<div class="bwfa-choice-list">
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-playback-mode" value="stop" checked
|
||||
data-bwfa-playback-mode>
|
||||
<span>Stop<small>Hold it in the player, ready to play again</small></span>
|
||||
</label>
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-playback-mode" value="next" data-bwfa-playback-mode>
|
||||
<span>Play the next file<small>Work down the list as it's sorted and filtered</small></span>
|
||||
</label>
|
||||
<label class="bwfa-choice">
|
||||
<input type="radio" name="bwfa-app-1-playback-mode" value="repeat" data-bwfa-playback-mode>
|
||||
<span>Repeat it<small>Loop the same file until you stop it</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="bwfa-settings-action" data-bwfa-audio-reset-row>
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-audio-reset>Restart the app</button>
|
||||
<small>If a file plays with no sound at all, this rebuilds the audio path
|
||||
and reloads the window — the same thing quitting and reopening does,
|
||||
without the quitting. The folder comes back with it.</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="bwfa-settings-section" data-bwfa-settings="columns">
|
||||
<summary>Columns in the table</summary>
|
||||
<div class="bwfa-settings-body">
|
||||
<div class="bwfa-columns-menu" data-bwfa-columns-menu></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bwfa-columns-close>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The sound report: who it's for, what format it lands in, and which
|
||||
fields it carries. One field list for both formats, because a sound
|
||||
report is a sound report whichever way it's written. -->
|
||||
<div class="modal-backdrop" data-bwfa-export-backdrop hidden></div>
|
||||
<div class="modal" data-bwfa-export-modal role="dialog" aria-modal="true"
|
||||
aria-labelledby="bwfa-app-1-export-title" hidden>
|
||||
<div class="modal-dialog bwfa-export-dialog">
|
||||
<div class="modal-header">
|
||||
<div class="bwfa-modal-title-group">
|
||||
<h3 class="modal-title" id="bwfa-app-1-export-title">Sound Report</h3>
|
||||
<span class="bwfa-modal-filename" data-bwfa-report-subject></span>
|
||||
</div>
|
||||
<button type="button" class="modal-close" data-bwfa-export-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="bwfa-report-details">
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-company">Production company</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-company"
|
||||
data-bwfa-report-field="company" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-project">Project / show name</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-project"
|
||||
data-bwfa-report-field="project" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-director">Director</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-director"
|
||||
data-bwfa-report-field="director" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-mixer">Sound mixer</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-mixer"
|
||||
data-bwfa-report-field="mixer" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-phone">Mixer phone</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-phone"
|
||||
data-bwfa-report-field="phone" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-email">Mixer email</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-email"
|
||||
data-bwfa-report-field="email" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-note">Note</label>
|
||||
<input type="text" class="form-control" id="bwfa-app-1-report-note"
|
||||
data-bwfa-report-field="note" autocomplete="off">
|
||||
</div>
|
||||
<div class="bwfa-report-row">
|
||||
<label class="form-label" for="bwfa-app-1-report-format">Output format</label>
|
||||
<select class="form-select" id="bwfa-app-1-report-format" data-bwfa-report-format>
|
||||
<option value="pdf" selected>PDF (sound report)</option>
|
||||
<option value="csv">CSV (spreadsheet)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwfa-report-fields-head">
|
||||
<h4>Fields in the report</h4>
|
||||
<span class="bwfa-report-count" data-bwfa-export-count></span>
|
||||
<div class="bwfa-export-actions-row">
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-export-all>Select all</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-export-none>Select none</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-export-defaults>Restore defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bwfa-export-fields" data-bwfa-export-fields></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" data-bwfa-export-close>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-bwfa-report-create>Create Report</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" data-bwfa-modal-backdrop hidden></div>
|
||||
<div
|
||||
class="modal"
|
||||
data-bwfa-modal
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bwfa-app-1-modal-title"
|
||||
hidden
|
||||
>
|
||||
<div class="modal-dialog bwfa-modal-dialog">
|
||||
<div class="modal-header">
|
||||
<div class="bwfa-modal-title-group">
|
||||
<h3 class="modal-title" id="bwfa-app-1-modal-title">File metadata</h3>
|
||||
<span class="bwfa-modal-filename" data-bwfa-modal-filename></span>
|
||||
</div>
|
||||
<button type="button" class="modal-close" data-bwfa-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" data-bwfa-modal-body></div>
|
||||
<div class="modal-footer">
|
||||
<div class="bwfa-modal-nav bwfa-modal-nav-back">
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-modal-prev
|
||||
aria-label="Previous file">‹</button>
|
||||
<span class="bwfa-modal-nav-name" data-bwfa-modal-prev-name></span>
|
||||
</div>
|
||||
<div class="bwfa-modal-nav bwfa-modal-nav-on">
|
||||
<span class="bwfa-modal-nav-name" data-bwfa-modal-next-name></span>
|
||||
<button type="button" class="btn btn-sm btn-outline" data-bwfa-modal-next
|
||||
aria-label="Next file">›</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" data-bwfa-modal-save hidden disabled>Save</button>
|
||||
<button type="button" class="btn btn-secondary" data-bwfa-modal-play>Play</button>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-modal-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+3089
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* A log you can read inside the app.
|
||||
*
|
||||
* The app ships without developer tools, so when something goes wrong the
|
||||
* usual answer — "open the console and tell me what it says" — isn't
|
||||
* available. This captures everything the console would have shown, plus the
|
||||
* things that turn out to matter when a control looks fine and does nothing,
|
||||
* and puts it behind Cmd-Shift-L with a Copy button.
|
||||
*
|
||||
* Loaded before the app so it catches errors thrown during start-up.
|
||||
*/
|
||||
( function () {
|
||||
"use strict";
|
||||
|
||||
var LIMIT = 800;
|
||||
var lines = [];
|
||||
var panel = null;
|
||||
var output = null;
|
||||
|
||||
function stamp() {
|
||||
var now = new Date();
|
||||
function two( n ) { return ( n < 10 ? "0" : "" ) + n; }
|
||||
return two( now.getHours() ) + ":" + two( now.getMinutes() ) + ":" +
|
||||
two( now.getSeconds() ) + "." +
|
||||
( "00" + now.getMilliseconds() ).slice( -3 );
|
||||
}
|
||||
|
||||
/** Anything at all, as one short readable string. */
|
||||
function describe( value ) {
|
||||
if ( value === null ) { return "null"; }
|
||||
if ( value === undefined ) { return "undefined"; }
|
||||
if ( typeof value === "string" ) { return value; }
|
||||
if ( typeof value !== "object" ) { return String( value ); }
|
||||
if ( value instanceof Error ) {
|
||||
return value.name + ": " + value.message +
|
||||
( value.stack ? "\n " + String( value.stack ).split( "\n" ).slice( 1, 4 )
|
||||
.join( "\n " ) : "" );
|
||||
}
|
||||
if ( value.nodeType === 1 ) { return element( value ); }
|
||||
try {
|
||||
var json = JSON.stringify( value );
|
||||
return json && json.length > 300 ? json.slice( 0, 300 ) + "…" : String( json );
|
||||
} catch ( e ) {
|
||||
return Object.prototype.toString.call( value );
|
||||
}
|
||||
}
|
||||
|
||||
/** An element as you would point at it: tag, class, and its data hooks. */
|
||||
function element( node ) {
|
||||
if ( ! node || node.nodeType !== 1 ) { return String( node ); }
|
||||
var out = node.tagName.toLowerCase();
|
||||
if ( node.id ) { out += "#" + node.id; }
|
||||
var cls = typeof node.className === "string" ? node.className :
|
||||
( node.getAttribute && node.getAttribute( "class" ) ) || "";
|
||||
if ( cls ) { out += "." + cls.trim().split( /\s+/ ).join( "." ); }
|
||||
var hooks = [];
|
||||
if ( node.getAttributeNames ) {
|
||||
node.getAttributeNames().forEach( function ( name ) {
|
||||
if ( name.indexOf( "data-bwfa" ) === 0 ) {
|
||||
var v = node.getAttribute( name );
|
||||
hooks.push( v ? name + "=" + v : name );
|
||||
}
|
||||
} );
|
||||
}
|
||||
if ( hooks.length ) { out += " [" + hooks.join( " " ) + "]"; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function add( kind, text ) {
|
||||
lines.push( stamp() + " " + kind + " " + text );
|
||||
if ( lines.length > LIMIT ) { lines.shift(); }
|
||||
if ( output ) { render(); }
|
||||
}
|
||||
|
||||
// Console, kept working as well as captured.
|
||||
[ "log", "info", "warn", "error" ].forEach( function ( name ) {
|
||||
var original = window.console && window.console[ name ];
|
||||
window.console[ name ] = function () {
|
||||
var parts = Array.prototype.slice.call( arguments ).map( describe );
|
||||
add( name.toUpperCase(), parts.join( " " ) );
|
||||
if ( original ) { original.apply( window.console, arguments ); }
|
||||
};
|
||||
} );
|
||||
|
||||
window.addEventListener( "error", function ( e ) {
|
||||
if ( e.error ) {
|
||||
add( "THROWN", describe( e.error ) );
|
||||
} else {
|
||||
add( "THROWN", e.message + " (" + e.filename + ":" + e.lineno + ")" );
|
||||
}
|
||||
} );
|
||||
|
||||
window.addEventListener( "unhandledrejection", function ( e ) {
|
||||
add( "REJECTED", describe( e.reason ) );
|
||||
} );
|
||||
|
||||
/**
|
||||
* Every click, with what was actually hit.
|
||||
*
|
||||
* This is the line that matters when a button looks alive and isn't: the
|
||||
* thing under the pointer is not the button you aimed at, either because
|
||||
* something covers it or because it has been squashed to no width at all.
|
||||
*/
|
||||
document.addEventListener( "click", function ( e ) {
|
||||
var hit = e.target;
|
||||
var button = hit && hit.closest ? hit.closest( "button" ) : null;
|
||||
var note = "hit " + element( hit );
|
||||
if ( button && button !== hit ) { note += " inside " + element( button ); }
|
||||
if ( ! button ) { note += " (no button in the ancestry)"; }
|
||||
add( "CLICK", note );
|
||||
}, true );
|
||||
|
||||
/**
|
||||
* Measures the controls, because a control can be visible and unclickable.
|
||||
* For each one: its box, and whatever the browser says is on top at the
|
||||
* middle of that box. Those two disagreeing is the whole diagnosis.
|
||||
*/
|
||||
function probe() {
|
||||
var selectors = [
|
||||
"[data-bwfa-mixer-open]", "[data-bwfa-spectro-open]",
|
||||
"[data-bwfa-player-edit]", "[data-bwfa-player-export]",
|
||||
"[data-bwfa-player-playpause]"
|
||||
];
|
||||
add( "PROBE", "measuring the player's controls" );
|
||||
// This panel covers the bottom of the window, so measuring with it on
|
||||
// screen reports the panel as the topmost thing over every control.
|
||||
// Ask it to step out of the way first.
|
||||
var wasShowing = panel && panel.style.display !== "none";
|
||||
if ( wasShowing ) {
|
||||
panel.style.display = "none";
|
||||
}
|
||||
selectors.forEach( function ( selector ) {
|
||||
var node = document.querySelector( selector );
|
||||
if ( ! node ) {
|
||||
add( "PROBE", selector + " MISSING from the page" );
|
||||
return;
|
||||
}
|
||||
var box = node.getBoundingClientRect();
|
||||
var size = Math.round( box.width ) + "x" + Math.round( box.height ) +
|
||||
" at " + Math.round( box.left ) + "," + Math.round( box.top );
|
||||
// elementFromPoint is the whole point of this, but it does not
|
||||
// exist everywhere the tests run.
|
||||
var mid = document.elementFromPoint ? document.elementFromPoint(
|
||||
box.left + box.width / 2, box.top + box.height / 2 ) : null;
|
||||
var reaches = ! document.elementFromPoint ||
|
||||
( mid && ( mid === node || ( mid.closest && mid.closest( selector ) ) ) );
|
||||
add( "PROBE", selector + " " + size +
|
||||
( box.width < 4 || box.height < 4 ? " SQUASHED" : "" ) +
|
||||
" topmost: " + element( mid ) +
|
||||
( reaches ? " reachable" : " NOT REACHABLE — clicks land elsewhere" ) );
|
||||
} );
|
||||
if ( wasShowing ) {
|
||||
panel.style.display = "flex";
|
||||
}
|
||||
var modal = document.querySelector( "[data-bwfa-mixer]" );
|
||||
add( "PROBE", "mixer modal in the page: " +
|
||||
( modal ? "yes, hidden=" + modal.hidden + ", open class=" +
|
||||
modal.classList.contains( "open" ) : "NO — markup missing" ) );
|
||||
// Unhidden is not the same as visible, which is exactly the trap that
|
||||
// made this look like a dead button. Report what is actually painted.
|
||||
add( "PROBE", "mixer modal display: " +
|
||||
( modal ? window.getComputedStyle( modal ).display : "n/a" ) );
|
||||
add( "PROBE", "page built " + ( window.BWFA_BUILD || "unknown" ) );
|
||||
}
|
||||
|
||||
function render() {
|
||||
output.textContent = lines.join( "\n" );
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function build() {
|
||||
panel = document.createElement( "div" );
|
||||
// Inline styles throughout: this has to work when the app's own
|
||||
// stylesheet is the thing that's broken.
|
||||
panel.setAttribute( "style", [
|
||||
"position:fixed", "left:0", "right:0", "bottom:0", "height:45vh",
|
||||
"z-index:2147483647", "background:#1c1c1e", "color:#e8e8ed",
|
||||
"font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace",
|
||||
"display:flex", "flex-direction:column",
|
||||
"box-shadow:0 -8px 24px rgba(0,0,0,.4)"
|
||||
].join( ";" ) );
|
||||
|
||||
var bar = document.createElement( "div" );
|
||||
bar.setAttribute( "style", [
|
||||
"display:flex", "align-items:center", "gap:8px", "padding:8px 12px",
|
||||
"border-bottom:1px solid #3a3a3c", "flex:none"
|
||||
].join( ";" ) );
|
||||
|
||||
var title = document.createElement( "strong" );
|
||||
title.textContent = "BWF Analyser log";
|
||||
title.setAttribute( "style", "margin-right:auto;font-weight:600" );
|
||||
bar.appendChild( title );
|
||||
|
||||
function chip( label, onClick ) {
|
||||
var b = document.createElement( "button" );
|
||||
b.type = "button";
|
||||
b.textContent = label;
|
||||
b.setAttribute( "style", [
|
||||
"font:inherit", "padding:3px 10px", "border-radius:6px",
|
||||
"border:1px solid #5a5a5e", "background:#2c2c2e", "color:inherit",
|
||||
"cursor:pointer"
|
||||
].join( ";" ) );
|
||||
b.addEventListener( "click", onClick );
|
||||
bar.appendChild( b );
|
||||
return b;
|
||||
}
|
||||
|
||||
chip( "Copy", function () {
|
||||
var text = lines.join( "\n" );
|
||||
if ( navigator.clipboard && navigator.clipboard.writeText ) {
|
||||
navigator.clipboard.writeText( text ).then( function () {
|
||||
title.textContent = "BWF Analyser log — copied";
|
||||
}, function () {
|
||||
fallbackCopy( text, title );
|
||||
} );
|
||||
} else {
|
||||
fallbackCopy( text, title );
|
||||
}
|
||||
} );
|
||||
chip( "Measure controls", function () { probe(); } );
|
||||
chip( "Clear", function () { lines = []; render(); } );
|
||||
chip( "Close", function () { toggle( false ); } );
|
||||
|
||||
output = document.createElement( "pre" );
|
||||
output.setAttribute( "style", [
|
||||
"margin:0", "padding:10px 12px", "overflow:auto", "flex:1",
|
||||
"white-space:pre-wrap", "word-break:break-word"
|
||||
].join( ";" ) );
|
||||
|
||||
panel.appendChild( bar );
|
||||
panel.appendChild( output );
|
||||
document.body.appendChild( panel );
|
||||
}
|
||||
|
||||
function fallbackCopy( text, title ) {
|
||||
var area = document.createElement( "textarea" );
|
||||
area.value = text;
|
||||
document.body.appendChild( area );
|
||||
area.select();
|
||||
try {
|
||||
document.execCommand( "copy" );
|
||||
title.textContent = "BWF Analyser log — copied";
|
||||
} catch ( e ) {
|
||||
title.textContent = "BWF Analyser log — select the text and copy it";
|
||||
}
|
||||
area.remove();
|
||||
}
|
||||
|
||||
function toggle( wanted ) {
|
||||
if ( ! panel ) { build(); }
|
||||
var show = wanted === undefined ? panel.style.display === "none" : wanted;
|
||||
panel.style.display = show ? "flex" : "none";
|
||||
if ( show ) {
|
||||
probe();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener( "keydown", function ( e ) {
|
||||
if ( ( e.metaKey || e.ctrlKey ) && e.shiftKey &&
|
||||
String( e.key ).toLowerCase() === "l" ) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
} );
|
||||
|
||||
window.BWFA_DIAG = {
|
||||
lines: function () { return lines.slice(); },
|
||||
open: function () { toggle( true ); },
|
||||
close: function () { toggle( false ); },
|
||||
probe: probe,
|
||||
describe: element
|
||||
};
|
||||
}() );
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"dropHere": "Drag & drop a folder here, or",
|
||||
"dropHereActive": "Release to add these files",
|
||||
"notSupported": "Folder selection is not supported in this browser. You can still pick individual files.",
|
||||
"selectFolder": "Select Folder",
|
||||
"selectFiles": "Select Files",
|
||||
"clear": "Clear",
|
||||
"statusScanning": "Scanning folder…",
|
||||
"statusParsing": "Reading file %1$d of %2$d…",
|
||||
"statusDone": "Done — %1$d file(s) analysed, %2$d skipped.",
|
||||
"noFilesYet": "No files analysed yet.",
|
||||
"noFilesFound": "No BWF/WAV files were found in that selection.",
|
||||
"noResultsFilter": "No files match your search.",
|
||||
"colFileName": "File",
|
||||
"colFolder": "Folder",
|
||||
"colScene": "Scene",
|
||||
"colTake": "Take",
|
||||
"colTape": "Tape/Reel",
|
||||
"colCircled": "Circled",
|
||||
"colTimecode": "Start TC",
|
||||
"colDuration": "Duration",
|
||||
"colFrameRate": "FPS",
|
||||
"colSampleRate": "Sample Rate",
|
||||
"colBitDepth": "Bit Depth",
|
||||
"colChannels": "Channels",
|
||||
"colTrackNames": "Track Names",
|
||||
"trackNamePlaceholder": "Unnamed",
|
||||
"trackFileCount": "%1$d file(s)",
|
||||
"trackWasCount": "was %1$s · %2$d file(s)",
|
||||
"trackUnnamedChannel": "Channel %1$d — no name",
|
||||
"colOriginator": "Originator",
|
||||
"colOriginatorRef": "Originator Ref.",
|
||||
"colOriginationDate": "Origination Date",
|
||||
"colOriginationTime": "Origination Time",
|
||||
"colDescription": "Description",
|
||||
"colNote": "Note",
|
||||
"colProject": "Project",
|
||||
"colFileSize": "File Size",
|
||||
"colMarkers": "Markers",
|
||||
"colLoudness": "Integrated LUFS",
|
||||
"colCodingHistory": "Coding History",
|
||||
"colDetails": "Edit",
|
||||
"colPlay": "Play",
|
||||
"detailsBtn": "Edit",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"nowPlaying": "Now playing",
|
||||
"playbackError": "This file could not be played back (unsupported codec or unreadable data).",
|
||||
"timecodeFromDescription": "from Description tag, no iXML present",
|
||||
"channelLabel": "Ch",
|
||||
"colWaveform": "Waveform",
|
||||
"columnsBtn": "Settings",
|
||||
"editFolder": "Edit Metadata in a Folder…",
|
||||
"editing": "Editing",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved.",
|
||||
"saveError": "Could not save this file — it may have been moved, deleted, or you may need to re-grant permission.",
|
||||
"discardConfirm": "You have unsaved changes. Discard them?",
|
||||
"fieldScene": "Scene",
|
||||
"fieldTake": "Take",
|
||||
"fieldTape": "Tape/Reel",
|
||||
"fieldProject": "Project",
|
||||
"fieldCircled": "Circled",
|
||||
"fieldWildTrack": "Wild Track",
|
||||
"fieldUbits": "UBits",
|
||||
"fieldFrameRate": "Frame Rate",
|
||||
"fieldFrameRateFlag": "Drop Frame",
|
||||
"fieldTcSampleRate": "TC Sample Rate",
|
||||
"fieldDigitizerRate": "Digitizer Rate",
|
||||
"fieldNote": "Note",
|
||||
"notEditable": "This file was opened for viewing only — use \"Edit Metadata in a Folder…\" to make it editable.",
|
||||
"bulkEditBtn": "Bulk Edit…",
|
||||
"bulkEditTitle": "Bulk Edit",
|
||||
"bulkEditCount": "Applies to %1$d file(s) matching the current search/filter.",
|
||||
"bulkEditApply": "Apply",
|
||||
"bulkEditApplyN": "Apply to %1$d file(s)",
|
||||
"bulkEditCancel": "Cancel",
|
||||
"bulkEditConfirm": "This will overwrite %1$d file(s) on disk. Continue?",
|
||||
"bulkEditConfirmBtn": "Yes, rewrite %1$d file(s)",
|
||||
"bulkEditConfirmLine": "Rewrite the metadata in %1$d file(s)? The audio is not touched.",
|
||||
"bulkEditProgress": "Saving %1$d of %2$d…",
|
||||
"bulkEditDone": "Done — %1$d saved, %2$d failed.",
|
||||
"ndf": "Non-drop (NDF)",
|
||||
"df": "Drop-frame (DF)",
|
||||
"noChange": "(no change)",
|
||||
"modalTitle": "File metadata",
|
||||
"sectionFormat": "Format",
|
||||
"sectionBext": "Broadcast Extension (bext)",
|
||||
"sectionIxml": "iXML",
|
||||
"sectionInfo": "RIFF INFO",
|
||||
"sectionCue": "Cue Points / Markers",
|
||||
"sectionCart": "Cart Chunk",
|
||||
"sectionOther": "Other chunks found (not decoded)",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"unknown": "—",
|
||||
"errorParsing": "Could not read this file.",
|
||||
"errorNotWav": "Not a recognised WAV/BWF file.",
|
||||
"errorJsPdfLoad": "The PDF library could not be loaded (check your internet connection) — try again, or use Export CSV instead.",
|
||||
"exportCsv": "Export CSV",
|
||||
"exportPdf": "Export PDF",
|
||||
"pdfGenerating": "Building PDF…",
|
||||
"pdfTitle": "BWF Metadata Report",
|
||||
"pdfGeneratedOn": "Generated",
|
||||
"pdfFileCount": "Files",
|
||||
"close": "Close",
|
||||
"playerPlaying": "Now playing",
|
||||
"playerReady": "Ready to play",
|
||||
"playerNothing": "Player",
|
||||
"playerIdle": "Nothing loaded yet",
|
||||
"audioReopened": "Audio stopped coming through — the output was reopened.",
|
||||
"asRecorded": "(as recorded)",
|
||||
"hints": {
|
||||
"bwfa-edit-folder": "Open a folder of recordings",
|
||||
"bwfa-reset": "Close the folder and start again",
|
||||
"bwfa-play-btn": "Play this take",
|
||||
"bwfa-details-btn": "Edit this file's metadata",
|
||||
"bwfa-bulk-edit-toggle": "Change a field across every file listed",
|
||||
"bwfa-export-audio": "Write copies to another folder",
|
||||
"bwfa-report-open": "Make a sound report as CSV or PDF",
|
||||
"bwfa-settings-open": "Columns, appearance and playback",
|
||||
"bwfa-player-playpause": "Play or pause",
|
||||
"bwfa-player-edit": "Edit the file that's loaded",
|
||||
"bwfa-player-export": "Export just this file",
|
||||
"bwfa-export-choose": "Pick where the copies go",
|
||||
"bwfa-export-run": "Start writing the copies",
|
||||
"bwfa-export-depth": "Convert deep files to 24-bit, or leave them as recorded",
|
||||
"bwfa-export-channels": "Keep, split to mono, or combine into one poly",
|
||||
"bwfa-export-normalize": "Lift levels to a target, or only stop clipping",
|
||||
"bwfa-export-target": "The peak to aim for, in dBFS",
|
||||
"bwfa-export-collision": "What to do about a file that's already there",
|
||||
"bwfa-export-naming": "Name the copies from their metadata",
|
||||
"bwfa-export-pattern": "Tokens in braces, plus any text you like",
|
||||
"bwfa-bulk-edit-apply": "Write these changes to every file listed",
|
||||
"bwfa-bulk-edit-cancel": "Close without changing anything",
|
||||
"bwfa-result-save": "Save what happened as a text file",
|
||||
"bwfa-result-done": "Close this report",
|
||||
"bwfa-modal-save": "Write these changes to the file",
|
||||
"bwfa-audio-reset": "Restart the app if sound stops",
|
||||
"bwfa-channel-chip": "Click to mute, double-click to solo",
|
||||
"bwfa-name-chip": "Click to rename this track",
|
||||
"bwfa-bulk-track": "Rename this track everywhere it appears",
|
||||
"bwfa-status": "What the app is doing, and what it found",
|
||||
"bwfa-player-badge": "Which file the player is holding",
|
||||
"bwfa-player-elapsed": "How far into the file playback has reached",
|
||||
"bwfa-player-duration": "How long the file runs",
|
||||
"bwfa-search": "Narrow the list by filename, scene or take",
|
||||
"bwfa-settings-cog": "Choose which columns to show",
|
||||
"bwfa-sort": "Click to sort by this column",
|
||||
"bwfa-sort=waveform": "The shape of the audio. Click to sort",
|
||||
"bwfa-sort=fileName": "The name on disk. Click to sort",
|
||||
"bwfa-sort=folder": "The subfolder on the card. Click to sort",
|
||||
"bwfa-sort=scene": "Scene, from the recorder's iXML. Click to sort",
|
||||
"bwfa-sort=take": "Take number, from the recorder's iXML. Click to sort",
|
||||
"bwfa-sort=tape": "Roll or reel, usually the shoot day. Click to sort",
|
||||
"bwfa-sort=circled": "Marked as a good take. Click to sort",
|
||||
"bwfa-sort=startTimecode": "Where the take starts on the day's clock. Click to sort",
|
||||
"bwfa-sort=durationSeconds": "How long the take runs. Click to sort",
|
||||
"bwfa-sort=sampleRate": "Samples per second. Click to sort",
|
||||
"bwfa-sort=bitDepth": "Bits per sample, or 32-bit float. Click to sort",
|
||||
"bwfa-sort=channels": "How many tracks are in the file. Click to sort",
|
||||
"bwfa-sort=frameRate": "Frames per second the timecode counts in. Click to sort",
|
||||
"bwfa-sort=originator": "The recorder that made the file. Click to sort",
|
||||
"bwfa-sort=description": "The bext description field. Click to sort",
|
||||
"bwfa-sort=note": "The iXML note field. Click to sort",
|
||||
"bwfa-sheet-close": "Close this without doing anything",
|
||||
"bwfa-modal-close": "Close. You'll be asked about unsaved changes",
|
||||
"bwfa-modal-prev": "The previous file in the list",
|
||||
"bwfa-modal-next": "The next file in the list",
|
||||
"bwfa-modal-play": "Play this file",
|
||||
"bwfa-modal-filename": "The file this is about",
|
||||
"bwfa-export-cancel": "Close without exporting",
|
||||
"bwfa-export-dest": "Where the copies will be written",
|
||||
"bwfa-export-scope": "How many files this will act on",
|
||||
"bwfa-export-summary": "What the combined file would come out as",
|
||||
"bwfa-export-names": "What the copies would be called",
|
||||
"bwfa-export-tracks": "Choose which tracks to export from this file",
|
||||
"bwfa-export-track-chips": "Click a track to leave it out",
|
||||
"bwfa-export-report": "What happened to each file",
|
||||
"bwfa-edit-field=scene": "Scene, as the slate says it",
|
||||
"bwfa-edit-field=take": "Take number",
|
||||
"bwfa-edit-field=tape": "Roll or reel, usually the shoot day",
|
||||
"bwfa-edit-field=project": "Production name, the same on every file",
|
||||
"bwfa-edit-field=circled": "Mark this as a good take",
|
||||
"bwfa-edit-field=wildTrack": "Mark this as a wild track, recorded without picture",
|
||||
"bwfa-edit-field=ubits": "User bits, free text the recorder carries in timecode",
|
||||
"bwfa-edit-field=frameRate": "Frames per second the timecode counts in",
|
||||
"bwfa-edit-field=frameRateFlag": "Drop frame or not. Only 29.97 and 59.94 ever drop",
|
||||
"bwfa-edit-field=tcSampleRate": "The rate the timecode's sample count is based on",
|
||||
"bwfa-edit-field=digitizerRate": "The rate the converter actually ran at",
|
||||
"bwfa-edit-field=note": "Free text for post: anything worth knowing about this take",
|
||||
"bwfa-edit-field=description": "The bext description, 256 characters, read by most tools",
|
||||
"bwfa-bulk-tracks": "Every track name across these files. Click one to rename it everywhere",
|
||||
"bwfa-bulk-edit-panel": "Fill in only what you want to change",
|
||||
"bwfa-report-create": "Write the report",
|
||||
"bwfa-report-format": "CSV for a spreadsheet, PDF to hand over",
|
||||
"bwfa-report-subject": "Which files go in the report",
|
||||
"bwfa-report-field=company": "Your company, printed at the top",
|
||||
"bwfa-report-field=project": "Production name",
|
||||
"bwfa-report-field=director": "Director's name",
|
||||
"bwfa-report-field=mixer": "Whoever recorded it",
|
||||
"bwfa-report-field=phone": "Contact number on the report",
|
||||
"bwfa-report-field=email": "Contact address on the report",
|
||||
"bwfa-report-field=note": "Anything post should read first",
|
||||
"bwfa-settings=appearance": "Light, dark, or follow the system",
|
||||
"bwfa-settings=playback": "What happens when a file finishes",
|
||||
"bwfa-settings=columns": "Which columns the table shows",
|
||||
"bwfa-theme-mode": "Follow the system, or pick one and stay there",
|
||||
"bwfa-playback-mode": "Stop, play the next file, or loop this one",
|
||||
"bwfa-columns-modal": "Tick the columns you want to see",
|
||||
"bwfa-spectro-open": "See the frequencies in this take",
|
||||
"bwfa-spectro-canvas": "Time across, frequency up. Brighter is louder",
|
||||
"bwfa-mixer-open": "Balance the tracks while you listen",
|
||||
"bwfa-mixer-seek": "Drag to move through the file",
|
||||
"bwfa-mixer-strips": "One fader per track. Listening only",
|
||||
"bwfa-mixer-playpause": "Start or stop playback",
|
||||
"bwfa-mixer-prev": "Mix the previous file",
|
||||
"bwfa-mixer-next": "Mix the next file",
|
||||
"bwfa-mixer-close": "Close the mixer"
|
||||
},
|
||||
"spectroReading": "Reading the file…",
|
||||
"spectroFailed": "That file couldn't be read as a spectrogram.",
|
||||
"mixerNoFile": "Play a file to mix it.",
|
||||
"mixerMute": "M",
|
||||
"mixerSolo": "S"
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draws the app icon and packs the .icns, with no Xcode tooling involved.
|
||||
|
||||
macOS rounds the corners of nothing: an app icon has to bring its own squircle.
|
||||
This draws one at 1024px, puts a waveform on it, then downsamples to the sizes
|
||||
macOS asks for. The .icns container is written by hand — it is just a header
|
||||
plus one length-prefixed PNG per size.
|
||||
"""
|
||||
|
||||
import math
|
||||
import pathlib
|
||||
import struct
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
OUT = pathlib.Path(__file__).resolve().parent.parent / "mac-app" / "src-tauri" / "icons"
|
||||
|
||||
BASE = 1024
|
||||
SUPERSAMPLE = 2 # draw big, shrink down: cheap anti-aliasing
|
||||
|
||||
BACKGROUND_TOP = (39, 39, 42)
|
||||
BACKGROUND_BOTTOM = (24, 24, 27)
|
||||
WAVE = (250, 250, 250)
|
||||
ACCENT = (96, 165, 250)
|
||||
|
||||
|
||||
def squircle_mask(size, radius_ratio=0.2237):
|
||||
"""Apple's icon shape is close enough to a rounded rect at this radius."""
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
inset = int(size * 0.085)
|
||||
box = [inset, inset, size - inset, size - inset]
|
||||
draw.rounded_rectangle(box, radius=int(size * radius_ratio), fill=255)
|
||||
return mask
|
||||
|
||||
|
||||
def vertical_gradient(size, top, bottom):
|
||||
gradient = Image.new("RGB", (1, size))
|
||||
for y in range(size):
|
||||
t = y / max(1, size - 1)
|
||||
gradient.putpixel(
|
||||
(0, y),
|
||||
tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)),
|
||||
)
|
||||
return gradient.resize((size, size), Image.NEAREST)
|
||||
|
||||
|
||||
def draw_icon(size):
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
canvas.paste(vertical_gradient(size, BACKGROUND_TOP, BACKGROUND_BOTTOM), (0, 0))
|
||||
canvas.putalpha(squircle_mask(size))
|
||||
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# A waveform: a few overlaid sines so it reads as audio rather than a
|
||||
# perfect textbook sine.
|
||||
centre = size / 2
|
||||
left = size * 0.20
|
||||
right = size * 0.80
|
||||
span = right - left
|
||||
bars = 23
|
||||
bar_width = span / (bars * 1.9)
|
||||
gap = (span - bars * bar_width) / (bars - 1)
|
||||
|
||||
for index in range(bars):
|
||||
phase = index / (bars - 1)
|
||||
envelope = (
|
||||
0.55 * math.sin(phase * math.pi)
|
||||
+ 0.30 * math.sin(phase * math.pi * 3.7 + 0.6)
|
||||
+ 0.18 * math.sin(phase * math.pi * 7.3 + 1.9)
|
||||
)
|
||||
height = abs(envelope) * size * 0.30 + size * 0.022
|
||||
x0 = left + index * (bar_width + gap)
|
||||
colour = ACCENT if index % 5 == 2 else WAVE
|
||||
draw.rounded_rectangle(
|
||||
[x0, centre - height, x0 + bar_width, centre + height],
|
||||
radius=bar_width / 2,
|
||||
fill=colour,
|
||||
)
|
||||
|
||||
return canvas
|
||||
|
||||
|
||||
def render(size):
|
||||
big = draw_icon(size * SUPERSAMPLE)
|
||||
return big.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def write_icns(path, images):
|
||||
"""ICNS: 'icns' + total length, then (type, length, PNG payload) records."""
|
||||
# Type codes macOS reads PNG data from, per size.
|
||||
types = {
|
||||
16: b"icp4",
|
||||
32: b"icp5",
|
||||
64: b"icp6",
|
||||
128: b"ic07",
|
||||
256: b"ic08",
|
||||
512: b"ic09",
|
||||
1024: b"ic10",
|
||||
}
|
||||
|
||||
chunks = []
|
||||
for size, png in images.items():
|
||||
if size not in types:
|
||||
continue
|
||||
chunks.append(types[size] + struct.pack(">I", len(png) + 8) + png)
|
||||
|
||||
body = b"".join(chunks)
|
||||
path.write_bytes(b"icns" + struct.pack(">I", len(body) + 8) + body)
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sizes = [16, 32, 64, 128, 256, 512, 1024]
|
||||
rendered = {size: render(size) for size in sizes}
|
||||
|
||||
# What tauri.conf.json points at.
|
||||
rendered[32].save(OUT / "32x32.png")
|
||||
rendered[128].save(OUT / "128x128.png")
|
||||
rendered[256].save(OUT / "128x128@2x.png")
|
||||
rendered[512].save(OUT / "icon.png")
|
||||
|
||||
pngs = {}
|
||||
for size, image in rendered.items():
|
||||
temp = OUT / ("_%d.png" % size)
|
||||
image.save(temp)
|
||||
pngs[size] = temp.read_bytes()
|
||||
temp.unlink()
|
||||
|
||||
write_icns(OUT / "icon.icns", pngs)
|
||||
|
||||
for name in ("32x32.png", "128x128.png", "128x128@2x.png", "icon.png", "icon.icns"):
|
||||
print("%-16s %6d bytes" % (name, (OUT / name).stat().st_size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Builds a synthetic BWF/WAVE file with fmt / bext / iXML / cue / LIST
|
||||
* chunks, the way a location recorder would write one. Used both by the
|
||||
* jsdom test harness and as a sample file to open in the real app.
|
||||
*
|
||||
* Usage: node make-sample.js out.wav [scene] [take]
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
function chunk(id, body) {
|
||||
const pad = body.length % 2;
|
||||
const head = Buffer.alloc(8);
|
||||
head.write(id, 0, 4, "latin1");
|
||||
head.writeUInt32LE(body.length, 4);
|
||||
return Buffer.concat([head, body, Buffer.alloc(pad)]);
|
||||
}
|
||||
|
||||
function fixed(text, length) {
|
||||
const buf = Buffer.alloc(length);
|
||||
buf.write(String(text).slice(0, length), 0, "latin1");
|
||||
return buf;
|
||||
}
|
||||
|
||||
function build(opts) {
|
||||
const sampleRate = opts.sampleRate || 48000;
|
||||
const channels = opts.channels || 2;
|
||||
const bits = opts.bits || 24;
|
||||
const seconds = opts.seconds || 1;
|
||||
const bytesPerSample = bits / 8;
|
||||
const blockAlign = channels * bytesPerSample;
|
||||
|
||||
// 32-bit float is what a modern mixer-recorder writes, so the converter
|
||||
// needs samples of that shape to be tested against. `extensible` wraps the
|
||||
// same thing in WAVE_FORMAT_EXTENSIBLE, which is how plenty of recorders
|
||||
// declare anything past two channels.
|
||||
const float = !!opts.float;
|
||||
const tag = opts.extensible ? 0xFFFE : (float ? 3 : 1);
|
||||
const fmt = opts.extensible
|
||||
? Buffer.alloc(40)
|
||||
: Buffer.alloc(16);
|
||||
fmt.writeUInt16LE(tag, 0);
|
||||
fmt.writeUInt16LE(channels, 2);
|
||||
fmt.writeUInt32LE(sampleRate, 4);
|
||||
fmt.writeUInt32LE(sampleRate * blockAlign, 8);
|
||||
fmt.writeUInt16LE(blockAlign, 12);
|
||||
fmt.writeUInt16LE(bits, 14);
|
||||
if (opts.extensible) {
|
||||
fmt.writeUInt16LE(22, 16); // cbSize
|
||||
fmt.writeUInt16LE(bits, 18); // valid bits
|
||||
fmt.writeUInt32LE(channels === 2 ? 3 : 0, 20); // channel mask
|
||||
fmt.writeUInt16LE(float ? 3 : 1, 24);
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00,
|
||||
0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]).copy(fmt, 26);
|
||||
}
|
||||
|
||||
// A quiet sine so playback and the waveform have something to draw.
|
||||
// `amplitude` above 1.0 is only meaningful for float, and is exactly the
|
||||
// case that clips on the way to fixed point.
|
||||
const amplitude = opts.amplitude === undefined ? 0.3 : opts.amplitude;
|
||||
const frames = sampleRate * seconds;
|
||||
const data = Buffer.alloc(frames * blockAlign);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
const v = Math.sin((2 * Math.PI * 440 * i) / sampleRate) * amplitude;
|
||||
for (let c = 0; c < channels; c++) {
|
||||
// Each channel at its own level, so a split can be checked for
|
||||
// having put the right audio in the right file. Channel 2 stays at
|
||||
// half, which is what it always was.
|
||||
const amp = v / (c + 1);
|
||||
const off = i * blockAlign + c * bytesPerSample;
|
||||
if (float && bits === 64) {
|
||||
data.writeDoubleLE(amp, off);
|
||||
} else if (float) {
|
||||
data.writeFloatLE(amp, off);
|
||||
} else if (bits === 32) {
|
||||
data.writeInt32LE(Math.round(amp * 2147483647), off);
|
||||
} else if (bits === 24) {
|
||||
const s = Math.round(amp * 8388607);
|
||||
data.writeIntLE(s, off, 3);
|
||||
} else {
|
||||
data.writeInt16LE(Math.round(amp * 32767), off);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Speed block values are configurable so the frame-rate writer can be
|
||||
// tested against realistic shapes: a complete block, one with no flag, one
|
||||
// with a pull-down relationship between master and current.
|
||||
const speed = opts.speed === undefined ? {} : opts.speed;
|
||||
const speedEntries = [];
|
||||
const pushSpeed = (tag, value) => {
|
||||
if (value === null) return; // omit the element entirely
|
||||
speedEntries.push(" <" + tag + ">" + value + "</" + tag + ">");
|
||||
};
|
||||
pushSpeed("MASTER_SPEED", speed.masterSpeed === undefined ? "25/1" : speed.masterSpeed);
|
||||
pushSpeed("CURRENT_SPEED", speed.currentSpeed === undefined ? "25/1" : speed.currentSpeed);
|
||||
pushSpeed("TIMECODE_RATE", speed.timecodeRate === undefined ? "25/1" : speed.timecodeRate);
|
||||
pushSpeed("TIMECODE_FLAG", speed.timecodeFlag === undefined ? "NDF" : speed.timecodeFlag);
|
||||
|
||||
// 10:00:00:00 at 48k, in samples since midnight.
|
||||
const tcSamples = opts.tcSamples === undefined ? 10 * 3600 * sampleRate : opts.tcSamples;
|
||||
const bext = Buffer.concat([
|
||||
fixed(opts.description || "sSPEED=025.000-ND", 256),
|
||||
fixed("Sound Devices 833", 32),
|
||||
fixed("SD833-001-" + (opts.take || 1), 32),
|
||||
fixed("2026-08-12", 10),
|
||||
fixed("09:41:12", 8),
|
||||
(() => {
|
||||
const b = Buffer.alloc(8);
|
||||
b.writeUInt32LE(tcSamples >>> 0, 0);
|
||||
b.writeUInt32LE(Math.floor(tcSamples / 4294967296), 4);
|
||||
return b;
|
||||
})(),
|
||||
(() => {
|
||||
const b = Buffer.alloc(2);
|
||||
b.writeUInt16LE(2, 0);
|
||||
return b;
|
||||
})(),
|
||||
Buffer.alloc(64), // UMID
|
||||
(() => {
|
||||
const b = Buffer.alloc(10); // loudness values
|
||||
b.writeInt16LE(-230, 0);
|
||||
b.writeInt16LE(-15, 2);
|
||||
b.writeInt16LE(-40, 4);
|
||||
b.writeInt16LE(70, 6);
|
||||
b.writeInt16LE(-60, 8);
|
||||
return b;
|
||||
})(),
|
||||
Buffer.alloc(180), // reserved
|
||||
Buffer.from(opts.codingHistory === undefined
|
||||
? "A=PCM,F=48000,W=24,M=stereo,T=833\r\n"
|
||||
: opts.codingHistory, "latin1"),
|
||||
]);
|
||||
|
||||
// One track per channel. The two-channel default is the pair the other
|
||||
// tests already expect to find.
|
||||
const trackNames = opts.tracks || (channels === 2
|
||||
? ["Boom", "Lav Anna"]
|
||||
: Array.from({ length: channels }, function (unused, i) { return "Track " + (i + 1); }));
|
||||
|
||||
const ixml = Buffer.from(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
"<BWFXML>\n" +
|
||||
" <IXML_VERSION>1.5</IXML_VERSION>\n" +
|
||||
" <PROJECT>" + (opts.project || "Test Shoot") + "</PROJECT>\n" +
|
||||
" <SCENE>" + (opts.scene === undefined ? "12A" : opts.scene) + "</SCENE>\n" +
|
||||
" <TAKE>" + (opts.take === undefined ? 1 : opts.take) + "</TAKE>\n" +
|
||||
" <TAPE>" + (opts.tape === undefined ? "26AUG12" : opts.tape) + "</TAPE>\n" +
|
||||
" <CIRCLED>" + (opts.circled ? "TRUE" : "FALSE") + "</CIRCLED>\n" +
|
||||
" <NOTE>" + (opts.note || "boom a bit hot") + "</NOTE>\n" +
|
||||
" <SPEED>\n" +
|
||||
" <NOTE></NOTE>\n" +
|
||||
(speedEntries.length ? speedEntries.join("\n") + "\n" : "") +
|
||||
" <FILE_SAMPLE_RATE>" + sampleRate + "</FILE_SAMPLE_RATE>\n" +
|
||||
" <AUDIO_BIT_DEPTH>" + bits + "</AUDIO_BIT_DEPTH>\n" +
|
||||
" <DIGITIZER_SAMPLE_RATE>" + sampleRate + "</DIGITIZER_SAMPLE_RATE>\n" +
|
||||
" <TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_HI>" + Math.floor(tcSamples / 4294967296) + "</TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_HI>\n" +
|
||||
" <TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_LO>" + (tcSamples >>> 0) + "</TIMESTAMP_SAMPLES_SINCE_MIDNIGHT_LO>\n" +
|
||||
" </SPEED>\n" +
|
||||
" <TRACK_LIST>\n" +
|
||||
" <TRACK_COUNT>" + trackNames.length + "</TRACK_COUNT>\n" +
|
||||
trackNames.map(function (name, i) {
|
||||
return " <TRACK><CHANNEL_INDEX>" + (i + 1) + "</CHANNEL_INDEX><INTERLEAVE_INDEX>" +
|
||||
(i + 1) + "</INTERLEAVE_INDEX><NAME>" + name + "</NAME></TRACK>\n";
|
||||
}).join("") +
|
||||
" </TRACK_LIST>\n" +
|
||||
"</BWFXML>\n" +
|
||||
" ".repeat(opts.slack === undefined ? 512 : opts.slack),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const cuePoints = Buffer.alloc(4 + 24);
|
||||
cuePoints.writeUInt32LE(1, 0);
|
||||
cuePoints.writeUInt32LE(1, 4); // id
|
||||
cuePoints.writeUInt32LE(0, 8); // position
|
||||
cuePoints.write("data", 12, 4, "latin1");
|
||||
cuePoints.writeUInt32LE(0, 16);
|
||||
cuePoints.writeUInt32LE(0, 20);
|
||||
cuePoints.writeUInt32LE(Math.floor(sampleRate / 2), 24);
|
||||
|
||||
const labl = chunk("labl", Buffer.concat([
|
||||
(() => { const b = Buffer.alloc(4); b.writeUInt32LE(1, 0); return b; })(),
|
||||
Buffer.from("slate\0", "latin1"),
|
||||
]));
|
||||
const adtl = chunk("LIST", Buffer.concat([Buffer.from("adtl", "latin1"), labl]));
|
||||
|
||||
const info = chunk("LIST", Buffer.concat([
|
||||
Buffer.from("INFO", "latin1"),
|
||||
chunk("ISFT", Buffer.from("BWF Analyser test harness\0", "latin1")),
|
||||
chunk("INAM", Buffer.from("Scene " + (opts.scene || "12A") + "\0", "latin1")),
|
||||
]));
|
||||
|
||||
// Anything the recorder wrote that we don't understand. Odd-sized on
|
||||
// purpose in the tests: a chunk with an odd body needs a pad byte after it,
|
||||
// and getting that wrong shifts every chunk that follows.
|
||||
const extra = (opts.extra || []).map((pair) => chunk(pair[0], Buffer.from(pair[1], "latin1")));
|
||||
|
||||
const body = Buffer.concat([
|
||||
Buffer.from("WAVE", "latin1"),
|
||||
chunk("fmt ", fmt),
|
||||
chunk("bext", bext),
|
||||
chunk("iXML", ixml),
|
||||
...extra,
|
||||
chunk("data", data),
|
||||
chunk("cue ", cuePoints),
|
||||
adtl,
|
||||
info,
|
||||
]);
|
||||
|
||||
const riff = Buffer.alloc(8);
|
||||
riff.write("RIFF", 0, 4, "latin1");
|
||||
riff.writeUInt32LE(body.length, 4);
|
||||
return Buffer.concat([riff, body]);
|
||||
}
|
||||
|
||||
module.exports = { build };
|
||||
|
||||
if (require.main === module) {
|
||||
const out = process.argv[2] || "sample.wav";
|
||||
const scene = process.argv[3] || "12A";
|
||||
const take = process.argv[4] || "1";
|
||||
fs.writeFileSync(out, build({ scene: scene, take: Number(take), circled: take === "3" }));
|
||||
console.log("wrote", out, fs.statSync(out).size, "bytes");
|
||||
}
|
||||
+1557
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Draws the report.
|
||||
*
|
||||
* Two rules, in this order: nothing is ever cut off, and nothing ever
|
||||
* wraps. The original broke both — it sized columns as fixed fractions of
|
||||
* A4 landscape, so all 26 fields gave each column 31pt while a header like
|
||||
* "Originator Reference" needs 69pt, and it clipped whatever didn't fit
|
||||
* with an ellipsis. A report with "Origina…" and "…VERSIO…" in it is not a
|
||||
* report.
|
||||
*
|
||||
* So: every cell is flattened to one line, every column is measured from
|
||||
* its own widest content, and the page grows sideways to hold the lot. A
|
||||
* wide page is not a problem for a report — every print dialog scales to
|
||||
* fit, and on screen it just scrolls. Past a limit the *type* shrinks
|
||||
* rather than the columns, because scaling columns means cutting text and
|
||||
* scaling type doesn't: widths are linear in font size, so a smaller face
|
||||
* fits exactly the same words in less room.
|
||||
*/
|
||||
function buildPdf( rows ) {
|
||||
if ( ! window.jspdf || ! window.jspdf.jsPDF ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var columns = pdfColumnsForSelection();
|
||||
var margin = 32;
|
||||
var padding = 5;
|
||||
var rowHeight = 18;
|
||||
var fontSize = columns.length > 16 ? 6.5 : ( columns.length > 11 ? 7 : 8 );
|
||||
|
||||
var A4_WIDTH = 841.89;
|
||||
var A4_HEIGHT = 595.28;
|
||||
/** ~85cm. Past this the type shrinks instead of the page growing. */
|
||||
var MAX_PAGE = 2400;
|
||||
/** Smaller than this stops being readable, at which point a wider page
|
||||
* is the better trade — a cut value is worse than either. */
|
||||
var MIN_FONT = 4.5;
|
||||
/** Float noise in the width arithmetic must never cost a glyph. */
|
||||
var SLACK = 0.75;
|
||||
|
||||
/**
|
||||
* One line, always.
|
||||
*
|
||||
* Coding History is the obvious offender — EBU 3285 defines it as CRLF
|
||||
* separated lines, and there are usually two or three — but a Note or a
|
||||
* Description can carry newlines too. jsPDF renders those as extra
|
||||
* lines drawn *below* the baseline it was given, straight through the
|
||||
* rows underneath. Each break becomes a visible separator instead, so
|
||||
* the text is all still there and reads as the list it is.
|
||||
*/
|
||||
function oneLine( text ) {
|
||||
return String( text )
|
||||
.replace( /\s*[\r\n]+\s*/g, " \u00b7 " )
|
||||
.replace( /[\t\v\f]+/g, " " )
|
||||
.replace( / +/g, " " )
|
||||
.replace( / \u00b7 $/, "" )
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cellText( row, col ) {
|
||||
var raw = extract( row, col.key );
|
||||
if ( col.render ) {
|
||||
return oneLine( col.render( raw ) );
|
||||
}
|
||||
return ( raw === null || raw === undefined || raw === "" ) ? "-" : oneLine( raw );
|
||||
}
|
||||
|
||||
// Measuring needs a document to measure in, at the same font the table
|
||||
// will use. Headers are drawn bold, values normal, so each is measured
|
||||
// as it will be drawn.
|
||||
var doc = new window.jspdf.jsPDF( { orientation: "landscape", unit: "pt", format: "a4" } );
|
||||
|
||||
/** The width each column needs at a given size, cell padding included. */
|
||||
function measureColumns( size ) {
|
||||
doc.setFontSize( size );
|
||||
return columns.map( function ( col ) {
|
||||
doc.setFont( "helvetica", "bold" );
|
||||
var widest = doc.getTextWidth( col.label );
|
||||
doc.setFont( "helvetica", "normal" );
|
||||
rows.forEach( function ( row ) {
|
||||
var width = doc.getTextWidth( cellText( row, col ) );
|
||||
if ( width > widest ) {
|
||||
widest = width;
|
||||
}
|
||||
} );
|
||||
return widest + padding * 2 + SLACK;
|
||||
} );
|
||||
}
|
||||
|
||||
function total( list ) {
|
||||
return list.reduce( function ( sum, width ) { return sum + width; }, 0 );
|
||||
}
|
||||
|
||||
var widths = measureColumns( fontSize );
|
||||
|
||||
// Too wide for the page limit: shrink the type until it isn't, or until
|
||||
// the type would stop being readable — in which case the page grows
|
||||
// past the limit instead. Text is never the thing that gives.
|
||||
if ( total( widths ) + margin * 2 > MAX_PAGE && fontSize > MIN_FONT ) {
|
||||
var wanted = fontSize * ( MAX_PAGE - margin * 2 ) / total( widths );
|
||||
fontSize = Math.max( MIN_FONT, wanted );
|
||||
widths = measureColumns( fontSize );
|
||||
}
|
||||
|
||||
var naturalWidth = total( widths );
|
||||
var pageWidth = Math.max( A4_WIDTH, naturalWidth + margin * 2 );
|
||||
var pageHeight = A4_HEIGHT;
|
||||
var usableWidth = pageWidth - margin * 2;
|
||||
|
||||
// Spare room is shared out proportionally so the table spans the page
|
||||
// exactly rather than stopping short of the edge. This only ever widens
|
||||
// columns: the page is never narrower than the content needs.
|
||||
var scale = usableWidth / naturalWidth;
|
||||
if ( scale > 1 ) {
|
||||
widths = widths.map( function ( width ) { return width * scale; } );
|
||||
}
|
||||
|
||||
if ( pageWidth !== A4_WIDTH ) {
|
||||
doc = new window.jspdf.jsPDF( {
|
||||
orientation: "landscape",
|
||||
unit: "pt",
|
||||
format: [ pageWidth, pageHeight ]
|
||||
} );
|
||||
}
|
||||
|
||||
var y = margin;
|
||||
|
||||
function drawTitle() {
|
||||
doc.setFont( "helvetica", "bold" );
|
||||
doc.setFontSize( 14 );
|
||||
doc.text( t( "pdfTitle" ), margin, y );
|
||||
doc.setFont( "helvetica", "normal" );
|
||||
doc.setFontSize( 9 );
|
||||
doc.setTextColor( 100 );
|
||||
var meta = t( "pdfGeneratedOn" ) + ": " + new Date().toLocaleString() + " " +
|
||||
t( "pdfFileCount" ) + ": " + rows.length;
|
||||
doc.text( meta, margin, y + 16 );
|
||||
doc.setTextColor( 0 );
|
||||
y += 34;
|
||||
|
||||
// The production details. Two to a line where they fit, so seven of
|
||||
// them cost four lines rather than pushing the table down the page —
|
||||
// but measured first, because a long project name in a narrow column
|
||||
// would otherwise print into its neighbour. Only what was filled in
|
||||
// appears: an empty "Director:" is worse than no line at all.
|
||||
var pairs = reportDetailPairs();
|
||||
if ( pairs.length ) {
|
||||
var detailSize = 9;
|
||||
var lineHeight = 12;
|
||||
|
||||
function widestPair( size ) {
|
||||
doc.setFontSize( size );
|
||||
return pairs.reduce( function ( widest, pair ) {
|
||||
doc.setFont( "helvetica", "bold" );
|
||||
var width = doc.getTextWidth( pair[ 0 ] + ": " );
|
||||
doc.setFont( "helvetica", "normal" );
|
||||
width += doc.getTextWidth( oneLine( pair[ 1 ] ) );
|
||||
return Math.max( widest, width );
|
||||
}, 0 );
|
||||
}
|
||||
|
||||
var widest = widestPair( detailSize );
|
||||
var gutter = 24;
|
||||
// One pair per line if two won't fit; and if even one won't fit,
|
||||
// the type shrinks rather than the text being cut.
|
||||
var perLine = ( widest * 2 + gutter <= usableWidth ) ? 2 : 1;
|
||||
if ( widest > usableWidth ) {
|
||||
detailSize = Math.max( 6, detailSize * usableWidth / widest );
|
||||
widest = widestPair( detailSize );
|
||||
}
|
||||
// The second column starts just past the longest pair, not at half
|
||||
// the page: on a 1600pt sheet, half-and-half leaves a lake of
|
||||
// white between a label and its value.
|
||||
var columnWidth = perLine === 2
|
||||
? Math.min( widest + gutter, ( usableWidth - gutter ) / 2 + gutter )
|
||||
: usableWidth;
|
||||
|
||||
doc.setFontSize( detailSize );
|
||||
pairs.forEach( function ( pair, index ) {
|
||||
var x = margin + ( index % perLine ) * columnWidth;
|
||||
var top = y + Math.floor( index / perLine ) * lineHeight;
|
||||
doc.setFont( "helvetica", "bold" );
|
||||
var label = pair[ 0 ] + ": ";
|
||||
var labelWidth = doc.getTextWidth( label );
|
||||
doc.text( label, x, top );
|
||||
doc.setFont( "helvetica", "normal" );
|
||||
doc.text( oneLine( pair[ 1 ] ), x + labelWidth, top );
|
||||
} );
|
||||
y += Math.ceil( pairs.length / perLine ) * lineHeight + 10;
|
||||
}
|
||||
}
|
||||
|
||||
function drawHeaderRow() {
|
||||
doc.setFillColor( 242, 242, 242 );
|
||||
doc.rect( margin, y, usableWidth, rowHeight, "F" );
|
||||
doc.setFont( "helvetica", "bold" );
|
||||
doc.setFontSize( fontSize );
|
||||
|
||||
var x = margin;
|
||||
columns.forEach( function ( col, index ) {
|
||||
doc.text( col.label, x + padding, y + rowHeight - 6 );
|
||||
x += widths[ index ];
|
||||
} );
|
||||
|
||||
doc.setFont( "helvetica", "normal" );
|
||||
y += rowHeight;
|
||||
}
|
||||
|
||||
/** Column separators. With this many columns, a value and the one to
|
||||
* its right otherwise read as a single sentence. */
|
||||
function drawColumnRules( top, bottom ) {
|
||||
doc.setDrawColor( 232, 232, 232 );
|
||||
var x = margin;
|
||||
for ( var index = 0; index < widths.length - 1; index++ ) {
|
||||
x += widths[ index ];
|
||||
doc.line( x, top, x, bottom );
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSpace() {
|
||||
if ( y + rowHeight > pageHeight - margin ) {
|
||||
drawColumnRules( bandTop, y );
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
drawHeaderRow();
|
||||
bandTop = y - rowHeight;
|
||||
}
|
||||
}
|
||||
|
||||
drawTitle();
|
||||
drawHeaderRow();
|
||||
var bandTop = y - rowHeight;
|
||||
doc.setFontSize( fontSize );
|
||||
|
||||
rows.forEach( function ( row, rowIndex ) {
|
||||
ensureSpace();
|
||||
|
||||
if ( rowIndex % 2 === 1 ) {
|
||||
doc.setFillColor( 250, 250, 250 );
|
||||
doc.rect( margin, y, usableWidth, rowHeight, "F" );
|
||||
}
|
||||
|
||||
var x = margin;
|
||||
columns.forEach( function ( col, index ) {
|
||||
doc.text( cellText( row, col ), x + padding, y + rowHeight - 6 );
|
||||
x += widths[ index ];
|
||||
} );
|
||||
|
||||
doc.setDrawColor( 225, 225, 225 );
|
||||
doc.line( margin, y + rowHeight, margin + usableWidth, y + rowHeight );
|
||||
|
||||
y += rowHeight;
|
||||
} );
|
||||
|
||||
drawColumnRules( bandTop, y );
|
||||
|
||||
var totalPages = doc.internal.getNumberOfPages();
|
||||
for ( var p = 1; p <= totalPages; p++ ) {
|
||||
doc.setPage( p );
|
||||
doc.setFontSize( 8 );
|
||||
doc.setTextColor( 130 );
|
||||
doc.text( "Page " + p + " / " + totalPages, pageWidth - margin - 60, pageHeight - 14 );
|
||||
doc.setTextColor( 0 );
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* ---------------------------------------------------------------
|
||||
Standalone page shell. The framework above is scoped to
|
||||
.bwfa-scope, so the page around it needs its own few rules.
|
||||
--------------------------------------------------------------- */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f4f4f5;
|
||||
color: #18181b;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 64px;
|
||||
}
|
||||
|
||||
.page-foot {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 40px;
|
||||
font-size: 13px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.page-foot a {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app {
|
||||
background: #fff;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page {
|
||||
padding: 12px 10px 48px;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shown only when the browser refuses the folder-write API on file:// */
|
||||
.bwfa-fs-warning {
|
||||
display: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #f0c36d;
|
||||
background: #fdf6e3;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #6b4e00;
|
||||
}
|
||||
|
||||
.bwfa-fs-warning code {
|
||||
background: rgba(0, 0, 0, .06);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Standalone shim.
|
||||
*
|
||||
* The only thing this adds over the original plugin: a clearer message when
|
||||
* the browser refuses the File System Access API. Opening this page straight
|
||||
* off disk (file://) gives it an opaque origin in Chrome, and the folder
|
||||
* picker used for *writing* metadata rejects opaque origins with a
|
||||
* SecurityError. Reading, playback, CSV and PDF are unaffected — only the
|
||||
* "Edit Metadata in a Folder…" path needs the page served over http(s),
|
||||
* which locally means something like `python3 -m http.server` in this file's
|
||||
* folder and then visiting http://localhost:8000/.
|
||||
*
|
||||
* The app's own catch block runs before this one resolves its message, so
|
||||
* the warning is written on the next tick to make sure it lands last.
|
||||
*/
|
||||
( function () {
|
||||
"use strict";
|
||||
|
||||
if ( typeof window.showDirectoryPicker !== "function" ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var original = window.showDirectoryPicker.bind( window );
|
||||
|
||||
window.showDirectoryPicker = function ( options ) {
|
||||
return original( options ).catch( function ( err ) {
|
||||
if ( err && ( err.name === "SecurityError" || err.name === "NotAllowedError" ) &&
|
||||
window.location.protocol === "file:" ) {
|
||||
window.setTimeout( function () {
|
||||
var warning = document.querySelector( "[data-bwfa-fs-warning]" );
|
||||
if ( warning ) {
|
||||
warning.style.display = "block";
|
||||
}
|
||||
var status = document.querySelector( "[data-bwfa-status]" );
|
||||
if ( status ) {
|
||||
status.textContent = "";
|
||||
}
|
||||
}, 0 );
|
||||
}
|
||||
throw err;
|
||||
} );
|
||||
};
|
||||
}() );
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Vendored
+398
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,862 @@
|
||||
/* ---------------------------------------------------------------
|
||||
Native app shell.
|
||||
|
||||
In a window of its own there's no page to sit on, so the card
|
||||
chrome, the outer margin and the grey backdrop all go: the app is
|
||||
the window. The read-only entry points go too — this build opens
|
||||
folders read-write and nothing else.
|
||||
--------------------------------------------------------------- */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
/* The window itself is the scroll container; nothing here should
|
||||
ever produce a horizontal one. */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app {
|
||||
background: var(--color-bg);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
/* The plugin caps itself at --container-lg (960px) and centres inside
|
||||
that, which is right for a page in a theme and wrong for a window:
|
||||
widen the window past 960 and the extra space just sat there. In an
|
||||
app the window IS the container, so the cap goes and the padding
|
||||
below becomes the only gutter. */
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
margin-inline: 0;
|
||||
padding: 18px 20px 28px;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Read-only selection is gone, and so is the in-app title: the window
|
||||
already says what this is, and the privacy note was written for a
|
||||
web page. The markup stays put — the analyser wires listeners to
|
||||
these nodes at startup and the bridge still hands files back through
|
||||
the inputs inside them — it just isn't shown. */
|
||||
.bwfa-scope .bwfa-header,
|
||||
.bwfa-scope .bwfa-dropzone,
|
||||
.bwfa-scope .bwfa-fs-warning,
|
||||
.bwfa-scope .bwfa-edit-divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Which promotes the editor to the main event. */
|
||||
.bwfa-scope .bwfa-edit-entry {
|
||||
margin: 0 0 18px;
|
||||
padding: 18px 20px;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
text-align: center;
|
||||
transition: border-color .12s ease, background-color .12s ease;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry.is-dragover {
|
||||
border-color: var(--color-info);
|
||||
border-style: solid;
|
||||
background: var(--color-info-bg);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry .btn {
|
||||
font-size: 15px;
|
||||
padding: 9px 20px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry .bwfa-dropzone-subnote {
|
||||
max-width: 62ch;
|
||||
margin: 12px auto 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Header sits tighter now that it's a title bar rather than a page
|
||||
heading. */
|
||||
.bwfa-scope .bwfa-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-title {
|
||||
font-size: 19px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
/* Let the table use the full width of the window. */
|
||||
.bwfa-scope .table-responsive {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Keep both action columns tight; the rest of the width belongs to
|
||||
the metadata. */
|
||||
.bwfa-scope .bwfa-table thead th:nth-child(1),
|
||||
.bwfa-scope .bwfa-table thead th:nth-child(2) {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Sticky table header ------------------------------------------
|
||||
Two things in the framework break `position: sticky` on a table
|
||||
header in WebKit specifically, and both were in play:
|
||||
|
||||
- `border-collapse: collapse`. Collapsed borders belong to the
|
||||
table grid rather than to the cells, and Safari has never
|
||||
painted a sticky header correctly through one — rows bleed over
|
||||
the header and its underline detaches. `separate` with zero
|
||||
spacing looks identical and behaves.
|
||||
|
||||
- `-webkit-overflow-scrolling: touch` on the scroll container.
|
||||
It hands scrolling to a separate compositing layer, which is a
|
||||
long-standing way to get stale pixels and sticky elements that
|
||||
scroll away. Momentum scrolling is the platform's job on macOS
|
||||
anyway.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .table-responsive {
|
||||
-webkit-overflow-scrolling: auto;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* Collapse was what drew the row lines; with separate borders each cell
|
||||
draws its own, which comes to the same thing visually. */
|
||||
.bwfa-scope .bwfa-table th,
|
||||
.bwfa-scope .bwfa-table td {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/*
|
||||
* Both selectors, deliberately. The plugin gives every *sortable* header
|
||||
* `position: relative` (for the sort arrow), and
|
||||
* `.bwfa-table th[data-bwfa-sort]` outranks `.bwfa-table thead th` — so
|
||||
* naming only the second left fourteen of seventeen headers relative
|
||||
* rather than sticky. The three that held were the ones that aren't
|
||||
* sortable: the two action columns, Description and Note. Hence a header
|
||||
* that was half stuck and half scrolling, with rows painting through the
|
||||
* gap.
|
||||
*
|
||||
* Sticky is itself a positioned value, so the absolutely-positioned sort
|
||||
* arrow still anchors to its own cell.
|
||||
*/
|
||||
.bwfa-scope .bwfa-table thead th,
|
||||
.bwfa-scope .bwfa-table thead th[data-bwfa-sort] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Above the rows, which are unpositioned, and above the row
|
||||
backgrounds that were painting over it. */
|
||||
z-index: 3;
|
||||
background: var(--color-bg);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Nothing in the body may promote itself above the header. */
|
||||
.bwfa-scope .bwfa-table tbody td {
|
||||
position: static;
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.bwfa-scope.bwfa-app {
|
||||
padding: 12px 12px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Toolbar ------------------------------------------------------
|
||||
The window's minWidth is set so this row always fits; nowrap makes
|
||||
that a hard guarantee rather than a hope, and keeps the search field
|
||||
as the only thing that gives.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-toolbar {
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-search-group {
|
||||
flex: 1 1 auto;
|
||||
min-width: 180px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-actions {
|
||||
flex: none;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-actions .btn,
|
||||
.bwfa-scope .bwfa-toolbar .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Selection ----------------------------------------------------
|
||||
This is an app, not a document: dragging a selection across a table
|
||||
of takes only ever looks like a mistake. Fields you type into keep
|
||||
selection, since editing without it is miserable.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope,
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bwfa-scope input,
|
||||
.bwfa-scope textarea,
|
||||
.bwfa-scope select,
|
||||
.bwfa-scope [contenteditable="true"] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* ---- The folder bar, once a folder is open -------------------------
|
||||
Before: a dashed panel inviting you to open something. After: one
|
||||
line saying which folder you're in, with the button to change it.
|
||||
The invitation has served its purpose and the table needs the room.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-edit-entry.is-folder-open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 14px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry.is-folder-open .bwfa-dropzone-subnote {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry.is-folder-open .btn {
|
||||
font-size: var(--fs-sm);
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-current-folder {
|
||||
font-size: 15px;
|
||||
font-weight: var(--fw-medium);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-current-folder::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 11px;
|
||||
margin-right: 8px;
|
||||
vertical-align: -1px;
|
||||
background-color: var(--color-text-muted);
|
||||
-webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 12'%3E%3Cpath d='M1 2.5A1.5 1.5 0 0 1 2.5 1h2.2c.4 0 .8.2 1 .5l.7.9h5.1A1.5 1.5 0 0 1 13 3.9v5.6A1.5 1.5 0 0 1 11.5 11h-9A1.5 1.5 0 0 1 1 9.5v-7Z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 12'%3E%3Cpath d='M1 2.5A1.5 1.5 0 0 1 2.5 1h2.2c.4 0 .8.2 1 .5l.7.9h5.1A1.5 1.5 0 0 1 13 3.9v5.6A1.5 1.5 0 0 1 11.5 11h-9A1.5 1.5 0 0 1 1 9.5v-7Z'/%3E%3C/svg%3E");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ---- Window layout: the table is the only thing that scrolls -------
|
||||
The player has to stay put while a long day scrolls past it. Pinning
|
||||
it with position:fixed and reserving page padding didn't hold up —
|
||||
the plugin sets .table-responsive to overflow-y:hidden, so the table
|
||||
never scrolls itself and the window does, which puts the last rows
|
||||
under a fixed footer no matter how much padding is reserved.
|
||||
|
||||
So the layout is explicit instead: the app is a column exactly as
|
||||
tall as the window, the table region is the one scrolling box, and
|
||||
the player is an ordinary block ordered after it. There is no
|
||||
clearance to get wrong, because the scrolling box ends where the
|
||||
player begins. The sticky table header now sticks to the top of that
|
||||
box, which is also what you want.
|
||||
------------------------------------------------------------------ */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-results > .table-responsive {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Ordered last regardless of where it sits in the markup. */
|
||||
.bwfa-scope .bwfa-player {
|
||||
order: 99;
|
||||
flex: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 14px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-player-waveform {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
/* The plugin spaced the status line by giving the results panel a top
|
||||
margin; making that panel a flex child meant zeroing it, which left
|
||||
"Done — 35 files analysed" sitting on top of the search field. The
|
||||
status carries its own spacing now, and keeps its min-height so the
|
||||
layout doesn't jump when the line appears. */
|
||||
.bwfa-scope .bwfa-status {
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
|
||||
/* ---- Launch state --------------------------------------------------
|
||||
With nothing open there's nothing to report, so the status line and
|
||||
the "No files analysed yet" placeholder are noise. What's left is one
|
||||
panel, centred, which is the only thing there is to do.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope.bwfa-app:not(.has-folder) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app:not(.has-folder) .bwfa-status,
|
||||
.bwfa-scope.bwfa-app:not(.has-folder) .bwfa-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app:not(.has-folder) .bwfa-edit-entry {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 28px;
|
||||
}
|
||||
|
||||
/* The app's own icon, inlined at build time from the .icns source set so
|
||||
the frontend stays a single self-contained file. Launch screen only —
|
||||
once you're working, the window's title bar and Dock icon are enough. */
|
||||
.bwfa-scope .bwfa-launch-logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app:not(.has-folder) .bwfa-launch-logo {
|
||||
display: block;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
margin: 0 auto 22px;
|
||||
background-image: url("@@LAUNCH_LOGO@@");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Every folder this build opens is opened read-write, so a banner saying
|
||||
so on every screen is telling you something you can't change and
|
||||
already know. The warning that matters is on the launch panel, where
|
||||
you choose the folder in the first place. */
|
||||
.bwfa-scope .bwfa-editing-note {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ---- Bulk edit and export live in modals ---------------------------
|
||||
Both panels used to open as siblings of the table inside a column
|
||||
exactly as tall as the window. The table region is the flexible one
|
||||
(`min-height: 0`, so it may shrink to nothing), so a panel at its
|
||||
natural height squeezed the table out of existence — and since the
|
||||
window itself doesn't scroll, that left nothing scrollable anywhere.
|
||||
Capping the panel and flooring the table held, but only by dividing a
|
||||
space neither of them wanted to share.
|
||||
|
||||
As modals they're out of that fight: the dialog is positioned against
|
||||
the viewport and scrolls itself, and the table keeps the whole column.
|
||||
The panel inside gives up its own card — the dialog is the card now.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-sheet-dialog {
|
||||
position: relative;
|
||||
/* Width and height come from --modal-width with every other modal's.
|
||||
Setting them here is how the sheets ended up a different size from
|
||||
the rest, twice. */
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-sheet-wide {
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
/* Padding comes from --modal-pad with every other modal's. This file is
|
||||
loaded after overrides.css, so anything set here silently beats the
|
||||
shared rules — which is how Bulk Edit ended up with this panel's 24px
|
||||
on top of the shared header's 32px while Sound Report had only the 32.
|
||||
Three rounds of "why is that one different" traced back to here. */
|
||||
|
||||
/* The close button is a traffic light now, styled once for every modal in
|
||||
overrides.css. This rule used to pin it top right with padding of its
|
||||
own, which quietly made the sheets' button a different size and place
|
||||
from everything else's — it loaded after the shared rule and won. */
|
||||
.bwfa-scope .bwfa-sheet-close {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Room for the close button, so a long heading can't run under it. */
|
||||
/* Header padding comes from --modal-pad with every other modal's. */
|
||||
|
||||
.bwfa-scope .bwfa-results > .table-responsive {
|
||||
min-height: 20vh;
|
||||
}
|
||||
|
||||
|
||||
/* ---- Reset ---------------------------------------------------------
|
||||
Sits left of the folder button and undoes the session: closes the
|
||||
folder, empties the table, forgets what to reopen. Only meaningful
|
||||
once something is open, so that's the only time it's there.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-reset {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry.is-folder-open .bwfa-reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-reset::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 2.5 14.2 10.2H1.8z' fill='black'/%3E%3Crect x='1.8' y='11.9' width='12.4' height='2.1' rx='.7' fill='black'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 2.5 14.2 10.2H1.8z' fill='black'/%3E%3Crect x='1.8' y='11.9' width='12.4' height='2.1' rx='.7' fill='black'/%3E%3C/svg%3E");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: contain;
|
||||
mask-size: contain;
|
||||
}
|
||||
|
||||
/* ---- Export panel --------------------------------------------------
|
||||
Wears the bulk-edit panel's classes, so the card, the 38px control
|
||||
height, the label treatment and the 40vh cap all come for free. Only
|
||||
the parts it doesn't share are here: the destination row, which is a
|
||||
field with a button welded to its end, and the report.
|
||||
------------------------------------------------------------------ */
|
||||
/* Its own row class rather than the bulk-edit one: the app enumerates
|
||||
.bwfa-bulk-edit-row to work out which fields to write, so sharing the
|
||||
name would hand it five controls that have nothing to do with editing.
|
||||
The look is shared here instead, which is the part worth sharing. */
|
||||
.bwfa-scope .bwfa-export-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px 20px;
|
||||
margin-bottom: var(--space-4);
|
||||
max-width: 1080px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row > .form-label {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row > .form-control,
|
||||
.bwfa-scope .bwfa-export-row > .form-select {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
line-height: 1.2;
|
||||
box-sizing: border-box;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row > .form-select {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
padding-right: 30px;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%2371717a' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 11px center;
|
||||
background-size: 11px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-row input[type="number"]::-webkit-outer-spin-button,
|
||||
.bwfa-scope .bwfa-export-row input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Nothing to type into means nothing to look at: a disabled target reads
|
||||
as inactive rather than as a field you're failing to fill in. */
|
||||
.bwfa-scope .bwfa-export-row > .form-control:disabled {
|
||||
background-color: var(--color-surface-alt, #f4f4f5);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* The path is the long one, so it gets the width when there is any. */
|
||||
.bwfa-scope .bwfa-export-dest {
|
||||
grid-column: span 2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-dest-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-dest-row > .form-control {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
font-size: var(--fs-sm);
|
||||
box-sizing: border-box;
|
||||
direction: ltr;
|
||||
/* A long path should show its end — the folder — not its beginning. */
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-dest-row > .btn {
|
||||
flex: none;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- The report modal ----------------------------------------------
|
||||
A finished export is not a form, so it stops looking like one. The
|
||||
dialog is the only one that doesn't scroll as a whole: the heading,
|
||||
the counts and the buttons stay put and the list moves under them,
|
||||
which is the difference between reading a hundred rows and hunting
|
||||
for the button that dismisses them.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-sheet-report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
/* Width comes from --modal-width with every other modal's. This used
|
||||
to set its own, which is exactly how six dialogs ended up six
|
||||
different sizes. */
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-sheet-report .bwfa-result-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-panel .bwfa-bulk-edit-header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-panel .bwfa-bulk-edit-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-tally {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
flex: none;
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-stat strong {
|
||||
font-size: 22px;
|
||||
font-weight: var(--fw-semibold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-stat span {
|
||||
font-size: var(--fs-xs, 12px);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* A count of nothing still belongs on screen — "0 failed" is the whole
|
||||
reassurance — but it shouldn't compete with the counts that matter. */
|
||||
.bwfa-scope .bwfa-result-stat.is-zero strong,
|
||||
.bwfa-scope .bwfa-result-stat.is-zero span {
|
||||
color: var(--gray-400, #b0b0b8);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-stat.is-bad strong {
|
||||
color: var(--color-danger, #b42318);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-note {
|
||||
flex: none;
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-result-panel .bwfa-bulk-edit-actions {
|
||||
flex: none;
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--fs-sm);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* Room for the scrollbar so the last column doesn't sit under it. */
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report li {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 7px 2px;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 1px solid var(--color-border-subtle, var(--gray-100, #f1f1f4));
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report li.is-problem {
|
||||
color: var(--color-danger, #b42318);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report-name {
|
||||
flex: none;
|
||||
width: 38%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-report-detail {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Track picker in the export panel ------------------------------
|
||||
The same chips as the player's channel strip, because they answer
|
||||
the same question about the same file — there they mute, here they
|
||||
choose what gets written. Only offered for a single file: across a
|
||||
folder of takes, "track 3" isn't the same thing twice.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-export-tracks {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-tracks .bwfa-channel-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-tracks .chip {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Off reads as off: struck through and faded, the way a muted channel
|
||||
already reads in the player. */
|
||||
.bwfa-scope .bwfa-export-tracks .chip.is-off {
|
||||
opacity: 0.45;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* ---- The audio escape hatch ----------------------------------------
|
||||
Only in the app, where reloading the window costs a folder re-scan
|
||||
and nothing else: the last folder reopens by itself.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-settings-action {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- What a combine would make ------------------------------------
|
||||
Shown before anything is written, because the answer to "can this be
|
||||
done" is already in the metadata: rates, timecodes, lengths. A
|
||||
problem here disables Export rather than interrupting with a dialog.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-export-summary-line {
|
||||
margin: 4px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background-color: var(--color-surface);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-summary-line.is-blocked {
|
||||
border-color: var(--color-danger);
|
||||
background-color: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-summary-line small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-summary-line.is-blocked small {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---- Naming the copies ---------------------------------------------
|
||||
The pattern row and the preview both span, because a filename is a
|
||||
long thing and reading old against new is the entire point of
|
||||
showing it at all.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-export-pattern {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-tokens {
|
||||
margin: 6px 0 0;
|
||||
font-size: var(--fs-xs, 12px);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-spacing: 4px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-names {
|
||||
margin: 4px 0 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background-color: var(--color-surface);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-names.is-blocked {
|
||||
border-color: var(--color-danger);
|
||||
background-color: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-name-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The arrow is drawn rather than typed, so a name containing one can't
|
||||
be mistaken for the thing pointing at it. */
|
||||
.bwfa-scope .bwfa-export-name-row > span::after {
|
||||
content: "\2192";
|
||||
margin-left: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-name-row > span {
|
||||
flex: none;
|
||||
max-width: 46%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-name-row > strong {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--fw-medium, 500);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-export-names small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 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 + ">([^<]*)</" + 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(/<SCENE>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);
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 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, "..", "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);
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Playback maths.
|
||||
*
|
||||
* The device glue is cpal and cannot be compiled here, so what this covers is
|
||||
* everything the glue hands work to: the waveform bucketing, the resampler
|
||||
* that runs when a 44.1k file meets a 48k output, the per-frame channel sum
|
||||
* behind the mute and solo chips, and the arithmetic the elapsed time is read
|
||||
* from. Those are the parts that can be wrong in a way you would hear rather
|
||||
* than a way that fails outright.
|
||||
*
|
||||
* Run: node build/test-play.js
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const assert = require("assert");
|
||||
const { build } = require("./make-sample.js");
|
||||
const wav = require("./wav-convert.js");
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bwf-play-"));
|
||||
const results = [];
|
||||
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.push(["PASS", name]);
|
||||
} catch (e) {
|
||||
results.push(["FAIL", name + " — " + e.message]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- the waveform ---- */
|
||||
|
||||
const stereo = path.join(root, "stereo.wav");
|
||||
fs.writeFileSync(stereo, build({ bits: 24, channels: 2, seconds: 1, amplitude: 0.5 }));
|
||||
|
||||
check("peaks come back one column at a time, with the file's shape", () => {
|
||||
const p = wav.peaks(stereo, 100);
|
||||
assert.strictEqual(p.min.length, 100);
|
||||
assert.strictEqual(p.max.length, 100);
|
||||
assert.strictEqual(p.channels, 2);
|
||||
assert.strictEqual(p.sampleRate, 48000);
|
||||
assert.strictEqual(p.frames, 48000);
|
||||
assert(Math.abs(p.seconds - 1) < 1e-9, "duration reads " + p.seconds);
|
||||
});
|
||||
|
||||
check("a column spans the file, not just the first samples", () => {
|
||||
// A 440 Hz sine at 0.5: every column of a 1-second file holds whole
|
||||
// cycles, so each one should reach close to the peak in both directions.
|
||||
const p = wav.peaks(stereo, 50);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
assert(p.max[i] > 0.4, "column " + i + " tops out at " + p.max[i]);
|
||||
assert(p.min[i] < -0.4, "column " + i + " bottoms out at " + p.min[i]);
|
||||
}
|
||||
});
|
||||
|
||||
check("the loudest channel is the one that shows", () => {
|
||||
// make-sample puts channel 2 at half of channel 1, so the picture should
|
||||
// follow channel 1 rather than an average of the two.
|
||||
const p = wav.peaks(stereo, 20);
|
||||
const loudest = Math.max.apply(null, Array.from(p.max));
|
||||
assert(loudest > 0.49 && loudest <= 0.5001, "peak reads " + loudest);
|
||||
});
|
||||
|
||||
check("silence draws a line rather than nothing", () => {
|
||||
const quiet = path.join(root, "quiet.wav");
|
||||
fs.writeFileSync(quiet, build({ bits: 24, channels: 1, seconds: 1, amplitude: 0 }));
|
||||
const p = wav.peaks(quiet, 16);
|
||||
assert(Array.from(p.min).every((v) => v === 0), "min is not flat");
|
||||
assert(Array.from(p.max).every((v) => v === 0), "max is not flat");
|
||||
});
|
||||
|
||||
check("more columns than frames still draws a line, not a row of gaps", () => {
|
||||
// Asked for 64 columns of an 8-frame file, the browser build gives every
|
||||
// column a sample by widening any empty one. Leaving the gaps at zero
|
||||
// would draw eight spikes on a flat line instead of a waveform, and the
|
||||
// two builds would disagree about the same file.
|
||||
const tiny = path.join(root, "tiny.wav");
|
||||
fs.writeFileSync(tiny, build({ bits: 16, channels: 1, seconds: 1, sampleRate: 8 }));
|
||||
// 440 Hz sampled at 8 Hz is 55 whole cycles per sample, so make-sample's
|
||||
// sine comes out as eight zeroes. Written by hand instead.
|
||||
const shape = wav.parse(tiny);
|
||||
const bytes = Buffer.from(shape.buf);
|
||||
for (let f = 0; f < 8; f++) {
|
||||
bytes.writeInt16LE((f + 1) * 4000, shape.dataOffset + f * 2);
|
||||
}
|
||||
fs.writeFileSync(tiny, bytes);
|
||||
const p = wav.peaks(tiny, 64);
|
||||
assert.strictEqual(p.min.length, 64);
|
||||
assert.strictEqual(p.frames, 8);
|
||||
const drawn = Array.from(p.max).map((v) => Math.round(v * 32768 / 4000));
|
||||
// Eight frames spread evenly over sixty-four columns: each one drawn
|
||||
// eight times, so the picture is a staircase rather than eight spikes.
|
||||
const want = [];
|
||||
for (let f = 1; f <= 8; f++) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
want.push(f);
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(drawn, want, "columns read " + drawn.join(", "));
|
||||
});
|
||||
|
||||
check("a column boundary lands where the browser build puts it", () => {
|
||||
// Ten frames into four columns is 2, 3, 2, 3 — not 3, 2, 3, 2. One frame
|
||||
// either side of every boundary, which is invisible on a real take and
|
||||
// exactly the sort of thing that quietly diverges between two builds.
|
||||
const ten = path.join(root, "ten.wav");
|
||||
fs.writeFileSync(ten, build({ bits: 16, channels: 1, seconds: 1, sampleRate: 10 }));
|
||||
const source = wav.parse(ten);
|
||||
const raw = Buffer.from(source.buf);
|
||||
// A ramp, so which frames landed in which column can be read off directly.
|
||||
for (let f = 0; f < 10; f++) {
|
||||
raw.writeInt16LE(Math.round((f + 1) * 3000), source.dataOffset + f * 2);
|
||||
}
|
||||
fs.writeFileSync(ten, raw);
|
||||
const p = wav.peaks(ten, 4);
|
||||
const tops = Array.from(p.max).map((v) => Math.round(v * 32768 / 3000));
|
||||
assert.deepStrictEqual(tops, [2, 5, 7, 10], "columns topped out at " + tops.join(", "));
|
||||
});
|
||||
|
||||
/* ---- the resampler ---- */
|
||||
|
||||
/** Runs a whole signal through the resampler in blocks, as the reader does. */
|
||||
function through(samples, channels, ratio, blockFrames) {
|
||||
const state = wav.resampleState();
|
||||
const out = [];
|
||||
for (let at = 0; at < samples.length; at += blockFrames * channels) {
|
||||
const block = samples.slice(at, at + blockFrames * channels);
|
||||
wav.resample(block, channels, ratio, state, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
check("matching rates come through untouched, sample for sample", () => {
|
||||
// The reader skips the resampler entirely at 1:1, but the maths has to
|
||||
// agree with that decision or a rate change would sound like a step.
|
||||
const input = new Float32Array(1000);
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
input[i] = Math.sin(i / 10);
|
||||
}
|
||||
const out = through(input, 1, 1, 128);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
assert(Math.abs(out[i] - input[i]) < 1e-6, "sample " + i + " moved to " + out[i]);
|
||||
}
|
||||
});
|
||||
|
||||
check("a ramp stays a ramp across block boundaries", () => {
|
||||
// The join between two blocks is where a resampler goes wrong: it either
|
||||
// repeats a sample or drops one, and a straight line makes that visible.
|
||||
const frames = 4800;
|
||||
const input = new Float32Array(frames);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
input[i] = i / frames;
|
||||
}
|
||||
const ratio = 44100 / 48000;
|
||||
const out = through(input, 1, ratio, 512);
|
||||
for (let i = 1; i < out.length; i++) {
|
||||
const step = out[i] - out[i - 1];
|
||||
assert(step > 0, "the ramp went backwards at " + i);
|
||||
assert(Math.abs(step - ratio / frames) < 1e-6,
|
||||
"uneven step at " + i + ": " + step);
|
||||
}
|
||||
});
|
||||
|
||||
check("the output length follows the ratio", () => {
|
||||
const frames = 48000;
|
||||
const input = new Float32Array(frames);
|
||||
const out = through(input, 1, 44100 / 48000, 1024);
|
||||
// 44.1k of source at 48k out is about 48000/44100 as many frames, less
|
||||
// the one frame the interpolator always holds back.
|
||||
const want = frames * 48000 / 44100;
|
||||
assert(Math.abs(out.length - want) < 4, "got " + out.length + ", wanted about " + want);
|
||||
});
|
||||
|
||||
check("channels stay in their own lanes", () => {
|
||||
const frames = 600;
|
||||
const input = new Float32Array(frames * 2);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
input[i * 2] = 1;
|
||||
input[i * 2 + 1] = -1;
|
||||
}
|
||||
const out = through(input, 2, 96000 / 48000, 64);
|
||||
for (let i = 0; i < out.length; i += 2) {
|
||||
assert(Math.abs(out[i] - 1) < 1e-6, "left drifted at " + i);
|
||||
assert(Math.abs(out[i + 1] + 1) < 1e-6, "right drifted at " + i);
|
||||
}
|
||||
});
|
||||
|
||||
check("one frame in is held, not emitted as a guess", () => {
|
||||
const state = wav.resampleState();
|
||||
const out = [];
|
||||
wav.resample(new Float32Array([0.5]), 1, 0.5, state, out);
|
||||
assert.strictEqual(out.length, 0, "it invented " + out.length + " frames");
|
||||
assert.strictEqual(state.carry.length, 1, "it didn't keep the frame");
|
||||
});
|
||||
|
||||
/* ---- the channel sum behind the chips ---- */
|
||||
|
||||
check("every channel on sums them all", () => {
|
||||
assert.strictEqual(wav.mixFrame([0.25, 0.25], [1, 1]), 0.5);
|
||||
});
|
||||
|
||||
check("a muted channel contributes nothing", () => {
|
||||
assert.strictEqual(wav.mixFrame([0.5, 0.5], [1, 0]), 0.5);
|
||||
assert.strictEqual(wav.mixFrame([0.5, 0.5], [0, 0]), 0);
|
||||
});
|
||||
|
||||
check("soloing one track is every other gain at zero", () => {
|
||||
assert.strictEqual(wav.mixFrame([0.1, 0.7, 0.2, 0.3], [0, 1, 0, 0]), 0.7);
|
||||
});
|
||||
|
||||
check("a sum past full scale is clamped, not wrapped", () => {
|
||||
// Four hot tracks summed will pass 1.0. Wrapping sounds like the file is
|
||||
// broken; clamping sounds like the monitor is loud, which is the truth.
|
||||
assert.strictEqual(wav.mixFrame([0.5, 0.5, 0.5, 0.5], [1, 1, 1, 1]), 1);
|
||||
assert.strictEqual(wav.mixFrame([-0.5, -0.5, -0.5], [1, 1, 1]), -1);
|
||||
});
|
||||
|
||||
check("a channel with no gain given is treated as on", () => {
|
||||
// The gains array is whatever the frontend last sent; a file with more
|
||||
// channels than that must not fall silent.
|
||||
assert.strictEqual(wav.mixFrame([0.25, 0.25], [1]), 0.5);
|
||||
});
|
||||
|
||||
/* ---- the transport clock ---- */
|
||||
|
||||
check("elapsed counts from where the file was started", () => {
|
||||
// Seeking restarts the stream, so the frames the device has taken are
|
||||
// counted from the seek point rather than from the top of the file.
|
||||
assert.strictEqual(wav.elapsed(48000 * 10, 48000, 48000), 11);
|
||||
assert.strictEqual(wav.elapsed(0, 0, 48000), 0);
|
||||
});
|
||||
|
||||
check("elapsed uses the device's rate, not the file's", () => {
|
||||
// The frames counted are the ones written to the output, so a 44.1k file
|
||||
// on a 48k device still reports real seconds.
|
||||
assert.strictEqual(wav.elapsed(0, 48000, 48000), 1);
|
||||
});
|
||||
|
||||
check("no output means no clock, rather than a divide by zero", () => {
|
||||
assert.strictEqual(wav.elapsed(0, 1000, 0), 0);
|
||||
});
|
||||
|
||||
/* ---- the transform behind the spectrogram ---- */
|
||||
|
||||
check("the fast transform agrees with the slow, obviously-correct one", () => {
|
||||
// The FFT is written by hand because nothing here can be compiled where
|
||||
// it is written, so it is checked against a plain DFT: the one version
|
||||
// nobody can get subtly wrong.
|
||||
const n = 64;
|
||||
const signal = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
signal.push(Math.sin(i / 3) + 0.3 * Math.cos(i / 7) - 0.1 * i / n);
|
||||
}
|
||||
const slow = wav.dft(signal);
|
||||
const re = Float32Array.from(signal);
|
||||
const im = new Float32Array(n);
|
||||
wav.fft(re, im);
|
||||
for (let k = 0; k < n; k++) {
|
||||
assert(Math.abs(re[k] - slow[k][0]) < 1e-3,
|
||||
"bin " + k + " real: " + re[k] + " vs " + slow[k][0]);
|
||||
assert(Math.abs(im[k] - slow[k][1]) < 1e-3,
|
||||
"bin " + k + " imaginary: " + im[k] + " vs " + slow[k][1]);
|
||||
}
|
||||
});
|
||||
|
||||
check("a pure tone lands in the bin it belongs to", () => {
|
||||
// Eight cycles across 256 samples is bin 8, and nowhere else.
|
||||
const n = 256;
|
||||
const re = new Float32Array(n);
|
||||
const im = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
re[i] = Math.sin(2 * Math.PI * 8 * i / n);
|
||||
}
|
||||
wav.fft(re, im);
|
||||
const power = [];
|
||||
for (let k = 0; k < n / 2; k++) {
|
||||
power.push(Math.sqrt(re[k] * re[k] + im[k] * im[k]));
|
||||
}
|
||||
let loudest = 0;
|
||||
for (let k = 1; k < power.length; k++) {
|
||||
if (power[k] > power[loudest]) loudest = k;
|
||||
}
|
||||
assert.strictEqual(loudest, 8, "the tone landed in bin " + loudest);
|
||||
});
|
||||
|
||||
check("a spectrogram is the shape it was asked for", () => {
|
||||
const p = wav.spectrogram(stereo, 40, 256, [1, 1]);
|
||||
assert.strictEqual(p.columns, 40);
|
||||
assert.strictEqual(p.bins, 128);
|
||||
assert.strictEqual(p.cells.length, 40 * 128);
|
||||
assert.strictEqual(p.sampleRate, 48000);
|
||||
});
|
||||
|
||||
check("the 440 Hz test tone shows up where 440 Hz belongs", () => {
|
||||
// 48k over a 1024-point window is 46.9 Hz a bin, so 440 Hz is bin 9.
|
||||
const p = wav.spectrogram(stereo, 8, 1024, [1, 1]);
|
||||
const bins = p.bins;
|
||||
let loudest = 1;
|
||||
for (let bin = 2; bin < bins; bin++) {
|
||||
if (p.cells[4 * bins + bin] > p.cells[4 * bins + loudest]) loudest = bin;
|
||||
}
|
||||
assert(Math.abs(loudest - 9) <= 1, "the tone read as bin " + loudest + ", not 9");
|
||||
});
|
||||
|
||||
check("silence is the floor, not a picture of nothing in particular", () => {
|
||||
const quiet = path.join(root, "hush.wav");
|
||||
fs.writeFileSync(quiet, build({ bits: 24, channels: 1, seconds: 1, amplitude: 0 }));
|
||||
const p = wav.spectrogram(quiet, 10, 256, [1]);
|
||||
assert(Array.from(p.cells).every((v) => v === 0),
|
||||
"silence came back with something in it");
|
||||
});
|
||||
|
||||
check("muting a channel takes it out of the picture", () => {
|
||||
// The point of following the chips: solo the boom and you see the boom,
|
||||
// not the mono sum of everything.
|
||||
const both = wav.spectrogram(stereo, 6, 512, [1, 1]);
|
||||
const muted = wav.spectrogram(stereo, 6, 512, [0, 0]);
|
||||
assert(Array.from(muted.cells).every((v) => v === 0),
|
||||
"muting every channel still drew something");
|
||||
assert(Array.from(both.cells).some((v) => v > 0), "nothing was drawn at all");
|
||||
});
|
||||
|
||||
check("a meter reads each channel's own peak", () => {
|
||||
// What the audio callback raises into its meter cells: the largest
|
||||
// magnitude seen per channel across a block, positive or negative.
|
||||
const peaks = wav.channelPeaks([0.1, -0.9, 0.5, 0.2, -0.3, 0.4], 2);
|
||||
assert.deepStrictEqual(peaks, [0.5, 0.9],
|
||||
"read " + JSON.stringify(peaks) + " — a negative trough counts as level");
|
||||
});
|
||||
|
||||
check("a meter on silence reads nothing, and no channel is left out", () => {
|
||||
assert.deepStrictEqual(wav.channelPeaks(new Array(64).fill(0), 4), [0, 0, 0, 0],
|
||||
"silence metered above zero");
|
||||
// A ragged tail must not spill one channel's samples into another's peak.
|
||||
const ragged = wav.channelPeaks([0.2, 0.4, 0.8], 2);
|
||||
assert.deepStrictEqual(ragged, [0.2, 0.4],
|
||||
"a half frame at the end leaked: " + JSON.stringify(ragged));
|
||||
assert.deepStrictEqual(wav.channelPeaks([], 2), [0, 0], "an empty block should read zero");
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Reopening the last folder on launch.
|
||||
*
|
||||
* Three cases matter: the folder is remembered when you open one, it comes
|
||||
* back by itself next launch, and a folder that has since moved or been
|
||||
* ejected leaves the launch screen up rather than an error — with the stale
|
||||
* path dropped so it can't fail twice.
|
||||
*
|
||||
* Run: npm i jsdom && node build/test-restore.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 STORAGE_KEY = "bwfa_last_folder";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bwf-restore-"));
|
||||
const card = path.join(root, "Day 14 PR-2");
|
||||
fs.mkdirSync(card, { recursive: true });
|
||||
["A001.wav", "A002.wav"].forEach((name, i) =>
|
||||
fs.writeFileSync(path.join(card, name), build({ scene: "1", take: i + 1 })));
|
||||
|
||||
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 = 10000) {
|
||||
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);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/** A window, with the Rust commands mirrored and a note of what was asked. */
|
||||
function launch({ remembered, dialogAnswers = [] } = {}) {
|
||||
const asked = { dialog: 0 };
|
||||
const dialogQueue = dialogAnswers.slice();
|
||||
|
||||
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
|
||||
runScripts: "dangerously",
|
||||
url: "http://tauri.localhost/",
|
||||
pretendToBeVisual: true,
|
||||
virtualConsole: new VirtualConsole(),
|
||||
beforeParse(win) {
|
||||
win.HTMLCanvasElement.prototype.getContext = () => new Proxy({}, {
|
||||
get: (target, prop) => (prop === "canvas" ? null : () => {}),
|
||||
set: () => true,
|
||||
});
|
||||
win.URL.createObjectURL = () => "blob:stub";
|
||||
win.URL.revokeObjectURL = () => {};
|
||||
if (remembered !== undefined) {
|
||||
win.localStorage.setItem(STORAGE_KEY, remembered);
|
||||
}
|
||||
|
||||
const toRealm = (buffer) => {
|
||||
const view = new win.Uint8Array(buffer.length);
|
||||
view.set(buffer);
|
||||
return view.buffer;
|
||||
};
|
||||
|
||||
win.__TAURI__ = {
|
||||
event: { listen: () => Promise.resolve(() => {}) },
|
||||
core: {
|
||||
invoke(command, payload) {
|
||||
if (command === "plugin:dialog|open") {
|
||||
asked.dialog++;
|
||||
const next = dialogQueue.shift();
|
||||
return Promise.resolve(next === undefined ? null : next);
|
||||
}
|
||||
if (command === "bwf_list_dir") {
|
||||
// Rejects for a folder that isn't there, exactly as the
|
||||
// Rust command does.
|
||||
if (!fs.existsSync(payload.path)) {
|
||||
return Promise.reject(new Error(payload.path + ": No such file or directory"));
|
||||
}
|
||||
return Promise.resolve(fs.readdirSync(payload.path).sort().map((name) => ({
|
||||
name,
|
||||
path: path.join(payload.path, name),
|
||||
kind: fs.statSync(path.join(payload.path, name)).isDirectory() ? "directory" : "file",
|
||||
})));
|
||||
}
|
||||
if (command === "bwf_stat") {
|
||||
const stat = fs.statSync(payload.path);
|
||||
return Promise.resolve({
|
||||
path: payload.path,
|
||||
relativePath: path.basename(payload.path),
|
||||
name: path.basename(payload.path),
|
||||
size: stat.size,
|
||||
lastModified: Math.floor(stat.mtimeMs),
|
||||
});
|
||||
}
|
||||
if (command === "bwf_read_range") {
|
||||
const size = fs.statSync(payload.path).size;
|
||||
if (payload.offset >= size || !payload.length) return Promise.resolve(toRealm(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(toRealm(buffer));
|
||||
}
|
||||
if (command === "bwf_read_all") {
|
||||
return Promise.resolve(toRealm(fs.readFileSync(payload.path)));
|
||||
}
|
||||
return Promise.reject(new Error("unexpected command " + command));
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
dom.window.addEventListener("load", () => resolve({ dom, window: dom.window, asked }));
|
||||
});
|
||||
}
|
||||
|
||||
const rowsIn = (doc) => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr"));
|
||||
|
||||
(async () => {
|
||||
/* --- opening a folder is remembered --- */
|
||||
|
||||
const first = await launch({ dialogAnswers: [card] });
|
||||
first.window.document.querySelector("[data-bwfa-edit-folder]")
|
||||
.dispatchEvent(new first.window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => rowsIn(first.window.document).length === 2, "first open");
|
||||
|
||||
check("opening a folder remembers it", () =>
|
||||
assert.strictEqual(first.window.localStorage.getItem(STORAGE_KEY), card,
|
||||
"stored: " + first.window.localStorage.getItem(STORAGE_KEY)));
|
||||
first.window.close();
|
||||
|
||||
/* --- next launch reopens it, with no dialog --- */
|
||||
|
||||
const again = await launch({ remembered: card });
|
||||
await waitFor(() => rowsIn(again.window.document).length === 2, "reopen on launch");
|
||||
const doc = again.window.document;
|
||||
|
||||
check("the folder comes back on the next launch", () => {
|
||||
assert.strictEqual(rowsIn(doc).length, 2, "got " + rowsIn(doc).length + " rows");
|
||||
assert.strictEqual(again.asked.dialog, 0, "it opened a file dialog instead of restoring");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-current-folder]").textContent, "Day 14 PR-2");
|
||||
assert(doc.querySelector("[data-bwfa-app]").classList.contains("has-folder"),
|
||||
"still showing the launch screen");
|
||||
});
|
||||
|
||||
check("it comes back read-write, not read-only", () =>
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-editing-note]").hidden, false,
|
||||
"restored without edit mode, so saving would be impossible"));
|
||||
again.window.close();
|
||||
|
||||
/* --- a folder that has gone leaves the launch screen up --- */
|
||||
|
||||
const missing = path.join(root, "Ejected Card");
|
||||
const gone = await launch({ remembered: missing });
|
||||
// Nothing to wait for, so give the check a beat to have happened.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const goneDoc = gone.window.document;
|
||||
|
||||
check("a folder that isn't there leaves the launch screen alone", () => {
|
||||
assert.strictEqual(rowsIn(goneDoc).length, 0, "it loaded something");
|
||||
assert.strictEqual(goneDoc.querySelector("[data-bwfa-app]").classList.contains("has-folder"), false,
|
||||
"the app left its launch state for a folder that doesn't exist");
|
||||
assert.strictEqual(gone.asked.dialog, 0, "it prompted on launch, which is not the ask");
|
||||
assert.strictEqual(goneDoc.querySelector("[data-bwfa-status]").textContent.trim(), "",
|
||||
"an error was left on screen: " + goneDoc.querySelector("[data-bwfa-status]").textContent);
|
||||
});
|
||||
|
||||
check("the stale path is dropped so it can't fail twice", () =>
|
||||
assert.strictEqual(gone.window.localStorage.getItem(STORAGE_KEY), null,
|
||||
"still remembered: " + gone.window.localStorage.getItem(STORAGE_KEY)));
|
||||
gone.window.close();
|
||||
|
||||
/* --- eject puts the app back to a first run --- */
|
||||
|
||||
const open = await launch({ dialogAnswers: [card] });
|
||||
const openDoc = open.window.document;
|
||||
openDoc.querySelector("[data-bwfa-edit-folder]")
|
||||
.dispatchEvent(new open.window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => rowsIn(openDoc).length === 2, "open before reset");
|
||||
|
||||
check("eject only offers itself once a folder is open", () => {
|
||||
const reset = openDoc.querySelector("[data-bwfa-reset]");
|
||||
assert(reset, "no reset control in the markup");
|
||||
assert.notStrictEqual(open.window.getComputedStyle(reset).display, "none",
|
||||
"reset is hidden while a folder is open");
|
||||
});
|
||||
|
||||
openDoc.querySelector("[data-bwfa-reset]")
|
||||
.dispatchEvent(new open.window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => rowsIn(openDoc).length === 0, "reset to clear the table", 5000);
|
||||
|
||||
check("eject returns the app to its empty state", () => {
|
||||
const app = openDoc.querySelector("[data-bwfa-app]");
|
||||
assert.strictEqual(app.classList.contains("has-folder"), false, "still in folder mode");
|
||||
assert.strictEqual(openDoc.querySelector("[data-bwfa-results]").hidden, true,
|
||||
"the results panel survived");
|
||||
assert.strictEqual(openDoc.querySelector("[data-bwfa-player]").hidden, true,
|
||||
"the player survived");
|
||||
assert.strictEqual(openDoc.querySelector("[data-bwfa-current-folder]").hidden, true,
|
||||
"the folder name is still shown");
|
||||
assert(/open folder/i.test(openDoc.querySelector("[data-bwfa-edit-folder]").textContent),
|
||||
"the button still says Change Folder: " +
|
||||
openDoc.querySelector("[data-bwfa-edit-folder]").textContent);
|
||||
assert.strictEqual(openDoc.querySelector("[data-bwfa-status]").textContent.trim(), "",
|
||||
"a status message was left behind");
|
||||
assert.strictEqual(open.window.getComputedStyle(
|
||||
openDoc.querySelector("[data-bwfa-reset]")).display, "none",
|
||||
"reset is still offered with nothing open");
|
||||
});
|
||||
|
||||
check("eject also forgets what to reopen", () =>
|
||||
assert.strictEqual(open.window.localStorage.getItem(STORAGE_KEY), null,
|
||||
"next launch would reopen " + open.window.localStorage.getItem(STORAGE_KEY)));
|
||||
open.window.close();
|
||||
|
||||
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);
|
||||
});
|
||||
+2217
File diff suppressed because it is too large
Load Diff
+626
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* Loads the built index.html in jsdom exactly as a browser would (scripts
|
||||
* executed in the page's own realm), feeds it synthetic BWF files through
|
||||
* the real file input, and asserts the table renders the right metadata.
|
||||
*
|
||||
* Run: npm i jsdom && node build/test.js
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const assert = require("assert");
|
||||
const { JSDOM, VirtualConsole } = require("jsdom");
|
||||
const { build } = require("./make-sample.js");
|
||||
|
||||
const INDEX = path.join(__dirname, "..", "index.html");
|
||||
const css = fs.readFileSync(INDEX, "utf8");
|
||||
|
||||
const createdBlobs = [];
|
||||
const consoleErrors = [];
|
||||
const virtualConsole = new VirtualConsole();
|
||||
virtualConsole.on("jsdomError", (e) => consoleErrors.push("jsdomError: " + e.message));
|
||||
virtualConsole.on("error", (...a) => consoleErrors.push("console.error: " + a.join(" ")));
|
||||
|
||||
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
|
||||
runScripts: "dangerously",
|
||||
url: "file:///tmp/bwftest/index.html",
|
||||
pretendToBeVisual: true,
|
||||
virtualConsole,
|
||||
beforeParse(win) {
|
||||
// jsdom ships no canvas backend and no URL.createObjectURL; stub both
|
||||
// before any page script runs. The waveform paths are draw-only, and
|
||||
// blob URLs are only ever handed to a download anchor.
|
||||
const ctxStub = new Proxy({}, {
|
||||
get: (target, prop) => {
|
||||
if (prop === "canvas") return null;
|
||||
if (prop === "createLinearGradient") return () => ({ addColorStop() {} });
|
||||
if (prop === "getImageData") return () => ({ data: new Uint8ClampedArray(4) });
|
||||
if (typeof target[prop] === "undefined") return () => {};
|
||||
return target[prop];
|
||||
},
|
||||
set: () => true,
|
||||
});
|
||||
win.HTMLCanvasElement.prototype.getContext = () => ctxStub;
|
||||
win.URL.createObjectURL = (blob) => {
|
||||
createdBlobs.push(blob);
|
||||
return "blob:stub-" + createdBlobs.length;
|
||||
};
|
||||
win.URL.revokeObjectURL = () => {};
|
||||
},
|
||||
});
|
||||
|
||||
const { window } = dom;
|
||||
|
||||
function fileFrom(name, buf) {
|
||||
const f = new window.File([new window.Uint8Array(buf)], name, { type: "audio/wav" });
|
||||
Object.defineProperty(f, "webkitRelativePath", { value: "Day14/" + name });
|
||||
return f;
|
||||
}
|
||||
|
||||
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, 50);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.push(["PASS", name]);
|
||||
} catch (e) {
|
||||
results.push(["FAIL", name + " — " + e.message]);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await new Promise((r) => window.addEventListener("load", r));
|
||||
|
||||
const doc = window.document;
|
||||
const container = doc.querySelector("[data-bwfa-app]");
|
||||
assert(container, "app container missing");
|
||||
|
||||
check("bwfaL10n is defined", () => assert.strictEqual(typeof window.bwfaL10n, "object"));
|
||||
check("parser exposed on window.BWFA", () => assert(window.BWFA && window.BWFA.RiffParser));
|
||||
check("jsPDF bundled and loaded", () => assert(window.jspdf && window.jspdf.jsPDF));
|
||||
check("dropzone got its localized text", () =>
|
||||
assert(doc.querySelector("[data-bwfa-dropzone-text]").textContent.trim().length > 0));
|
||||
check("table head rendered on init", () =>
|
||||
assert(doc.querySelector("[data-bwfa-table-head] th")));
|
||||
|
||||
check("detail metadata stays two pairs to a row", () => {
|
||||
// The plugin's own rule (auto-fill, minmax(180px, 1fr)) lands on three
|
||||
// columns at this modal width, which splits the dt/dd pairs across
|
||||
// rows. The override pins it to label, value, label, value.
|
||||
const probe = doc.createElement("dl");
|
||||
probe.className = "bwfa-meta-grid";
|
||||
container.appendChild(probe);
|
||||
const style = window.getComputedStyle(probe);
|
||||
assert.strictEqual((style.gridTemplateColumns.match(/minmax/g) || []).length, 4,
|
||||
"expected 4 tracks, got: " + style.gridTemplateColumns);
|
||||
probe.remove();
|
||||
});
|
||||
|
||||
// Feed three files through the real input, as a folder selection would.
|
||||
const files = [
|
||||
fileFrom("A001_12A_T1.wav", build({ scene: "12A", take: 1 })),
|
||||
fileFrom("A002_12A_T2.wav", build({ scene: "12A", take: 2, note: "plane overhead" })),
|
||||
fileFrom("A003_14B_T3.wav", build({ scene: "14B", take: 3, circled: true, tcSamples: 11 * 3600 * 48000 })),
|
||||
];
|
||||
const notWav = fileFrom("notes.txt", Buffer.from("ignore me"));
|
||||
|
||||
// jsdom has no webkitdirectory support, so the app correctly removes the
|
||||
// folder input and leaves the plain multi-file one — use that.
|
||||
const input = doc.querySelector("[data-bwfa-files-input]");
|
||||
const list = files.concat([notWav]);
|
||||
list.item = (i) => list[i];
|
||||
Object.defineProperty(input, "files", { value: list, configurable: true });
|
||||
input.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
|
||||
const status = doc.querySelector("[data-bwfa-status]");
|
||||
await waitFor(() => /Done/i.test(status.textContent), "parsing to finish");
|
||||
|
||||
const rowsOf = () => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr"));
|
||||
|
||||
check("three files parsed, non-wav skipped", () => {
|
||||
assert.strictEqual(rowsOf().length, 3, "got " + rowsOf().length + " rows");
|
||||
assert(/1 skipped/.test(status.textContent), status.textContent);
|
||||
});
|
||||
|
||||
const headers = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th")).map((th) =>
|
||||
th.textContent.replace(/[▲▼\s]+$/, "").trim());
|
||||
const cellsFor = (rowIndex) => Array.from(rowsOf()[rowIndex].querySelectorAll("td")).map((td) => td.textContent.trim());
|
||||
const col = (rowIndex, header) => cellsFor(rowIndex)[headers.indexOf(header)];
|
||||
|
||||
check("the table reports the frame rate", () => {
|
||||
assert(headers.includes("FPS"), "no FPS column: " + headers.join(" | "));
|
||||
assert(/25/.test(col(0, "FPS")), "FPS cell is empty: " + col(0, "FPS"));
|
||||
});
|
||||
|
||||
check("the report modal offers every field, all on", () => {
|
||||
container.querySelector("[data-bwfa-report-open]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const boxes = Array.from(doc.querySelectorAll("[data-bwfa-export-field]"));
|
||||
assert(boxes.length >= 20, "only " + boxes.length + " fields offered");
|
||||
assert(boxes.every((b) => b.checked), "some fields start unticked");
|
||||
});
|
||||
|
||||
check("the columns cog sits in the table's first header cell", () => {
|
||||
const cells = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
|
||||
const cog = cells[0].querySelector("[data-bwfa-columns-open]");
|
||||
assert(cog, "no cog in the first header cell: " + cells[0].innerHTML);
|
||||
assert.strictEqual(cog.textContent.trim(), "", "it should be a glyph, not a word");
|
||||
assert(cog.getAttribute("aria-label"), "an icon-only button needs a label");
|
||||
// A smaller sibling of the eject button in the folder bar: round,
|
||||
// hairline border, glyph only.
|
||||
const style = window.getComputedStyle(cog);
|
||||
assert.strictEqual(style.borderRadius, "50%", "it isn't round: " + style.borderRadius);
|
||||
assert.strictEqual(style.width, style.height, "it isn't square, so it can't be a circle");
|
||||
assert.strictEqual(style.width, "22px", "it should be smaller than the 30px eject: " + style.width);
|
||||
// The border is declared with a custom property for its colour, which
|
||||
// jsdom won't substitute into a computed shorthand — so read the rule.
|
||||
const rules = Array.from(doc.querySelectorAll("style"))
|
||||
.map((el) => el.textContent).join("\n");
|
||||
const rule = rules.slice(rules.indexOf(".bwfa-columns-cog {"));
|
||||
assert(/border: 1px solid/.test(rule.slice(0, rule.indexOf("}"))),
|
||||
"no hairline border on the cog");
|
||||
// The dropdown it replaced is gone from view.
|
||||
assert.strictEqual(
|
||||
window.getComputedStyle(container.querySelector("[data-bwfa-columns-dropdown]")).display,
|
||||
"none", "the Columns dropdown is still in the toolbar");
|
||||
});
|
||||
|
||||
check("the cog opens a modal listing every column", () => {
|
||||
const modal = doc.querySelector("[data-bwfa-columns-modal]");
|
||||
assert.strictEqual(modal.hidden, true, "it was open before anything was clicked");
|
||||
doc.querySelector("[data-bwfa-columns-open]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
assert.strictEqual(modal.hidden, false, "the cog didn't open it");
|
||||
assert(modal.classList.contains("open"), "the framework needs .open to show a modal");
|
||||
const boxes = Array.from(modal.querySelectorAll("[data-bwfa-columns-menu] input[type=checkbox]"));
|
||||
assert(boxes.length >= 15, "only " + boxes.length + " columns offered");
|
||||
});
|
||||
|
||||
check("unticking a column takes it out of the table, then Done closes up", () => {
|
||||
const modal = doc.querySelector("[data-bwfa-columns-modal]");
|
||||
const labels = Array.from(modal.querySelectorAll("[data-bwfa-columns-menu] label"));
|
||||
const tape = labels.filter((row) => /^Tape\/Reel$/.test(row.textContent.trim()))[0];
|
||||
assert(tape, "no Tape column in the list: " + labels.map((l) => l.textContent.trim()).join(", "));
|
||||
const box = tape.querySelector("input");
|
||||
box.checked = false;
|
||||
box.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
|
||||
const headers = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
|
||||
.map((th) => th.textContent.replace(/[▲▼\s]+$/, "").trim());
|
||||
assert(!headers.includes("Tape/Reel"), "Tape is still a column: " + headers.join(" | "));
|
||||
// And the cog survived the redraw, since the head is rebuilt each time.
|
||||
assert(doc.querySelector("[data-bwfa-table-head] th [data-bwfa-columns-open]"),
|
||||
"the cog went missing when the table head was redrawn");
|
||||
|
||||
box.checked = true;
|
||||
box.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
doc.querySelector("[data-bwfa-columns-close]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
assert.strictEqual(modal.hidden, true, "Done didn't close it");
|
||||
});
|
||||
|
||||
check("one button in the toolbar, not three", () => {
|
||||
const visible = (selector) => {
|
||||
const el = container.querySelector(selector);
|
||||
assert(el, "no " + selector);
|
||||
return window.getComputedStyle(el).display !== "none";
|
||||
};
|
||||
assert(visible("[data-bwfa-report-open]"), "the Sound Report button is hidden");
|
||||
// And it looks like the rest of the row rather than announcing itself.
|
||||
const row = Array.from(container.querySelectorAll(".bwfa-export-actions .btn"))
|
||||
.filter((el) => window.getComputedStyle(el).display !== "none");
|
||||
const looks = new Set(row.map((el) => el.className.replace(/\s+/g, " ").trim()));
|
||||
assert.deepStrictEqual(Array.from(looks), ["btn btn-outline"],
|
||||
"the toolbar mixes button styles: " + Array.from(looks).join(" / "));
|
||||
// The originals stay in the DOM — the analyser wires the export
|
||||
// pipeline to them and the modal drives them — but out of sight.
|
||||
["[data-bwfa-export-csv]", "[data-bwfa-export-pdf]", "[data-bwfa-export-fields-toggle]"]
|
||||
.forEach((selector) => assert(!visible(selector), selector + " is still on show"));
|
||||
});
|
||||
|
||||
check("rates read in kHz everywhere, pull rates included", () => {
|
||||
// 47952 is the 0.1% pull, a rate in its own right — one decimal used to
|
||||
// round it to "48.0 kHz", which is a different rate.
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(48000), "48 kHz");
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(44100), "44.1 kHz");
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(47952), "47.952 kHz");
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(176400), "176.4 kHz");
|
||||
|
||||
// The table, and the file's own detail view.
|
||||
assert(/kHz/.test(col(0, "Sample Rate")), "table cell: " + col(0, "Sample Rate"));
|
||||
// The table and the detail view. The report's field list is exempt: it
|
||||
// names CSV columns, and that column really is in hertz.
|
||||
const onScreen = [doc.querySelector("[data-bwfa-table]"),
|
||||
doc.querySelector("[data-bwfa-modal-body]")]
|
||||
.map((el) => (el && el.textContent) || "").join(" ");
|
||||
assert(!/\bHz\b/.test(onScreen.replace(/kHz/g, "")),
|
||||
"something on screen still counts in hertz");
|
||||
});
|
||||
|
||||
check("and the sample-rate menus read the same way", () => {
|
||||
container.querySelector("[data-bwfa-bulk-edit-toggle]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const rows = Array.from(doc.querySelectorAll(".bwfa-bulk-edit-row"));
|
||||
const rateRow = rows.find((row) => /tc sample rate/i.test(row.textContent));
|
||||
assert(rateRow, "no TC Sample Rate field");
|
||||
const labels = Array.from(rateRow.querySelectorAll("option")).map((o) => o.textContent);
|
||||
assert(labels.includes("48 kHz"), "options read: " + labels.join(" | "));
|
||||
assert(labels.includes("47.952 kHz"), "the pull rate is missing: " + labels.join(" | "));
|
||||
assert(!labels.some((label) => / Hz$/.test(label)), "still in hertz: " + labels.join(" | "));
|
||||
doc.querySelector("[data-bwfa-bulk-edit-cancel]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
check("the two free-text fields sit side by side, the same size", () => {
|
||||
// They used to be laid out as leftovers: Note in whatever cell was
|
||||
// going spare, Description alone on a row below it.
|
||||
container.querySelector("[data-bwfa-bulk-edit-toggle]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const areas = Array.from(doc.querySelectorAll("[data-bwfa-bulk-edit-fields] textarea"));
|
||||
assert.strictEqual(areas.length, 2, "expected Note and Description, got " + areas.length);
|
||||
|
||||
const rows = areas.map((area) => area.closest(".bwfa-bulk-edit-row"));
|
||||
const styles = rows.map((row) => window.getComputedStyle(row));
|
||||
styles.forEach((style, i) => assert.strictEqual(style.gridColumn, "span 2",
|
||||
"textarea " + i + " isn't half a row: " + style.gridColumn));
|
||||
assert.strictEqual(window.getComputedStyle(rows[0]).gridColumnStart, "1",
|
||||
"the first of the pair doesn't start a fresh row, so they won't line up");
|
||||
|
||||
const heights = areas.map((area) => window.getComputedStyle(area).height);
|
||||
assert.strictEqual(new Set(heights).size, 1, "different heights: " + heights.join(" / "));
|
||||
|
||||
doc.querySelector("[data-bwfa-bulk-edit-cancel]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
check("the audio escape hatch is app-only", () => {
|
||||
// In a browser tab a reload loses the folder along with the audio, so
|
||||
// the button that reloads is not offered there.
|
||||
const action = container.querySelector("[data-bwfa-audio-reset-row]");
|
||||
assert(action, "the markup should be shared, only hidden");
|
||||
assert.strictEqual(window.getComputedStyle(action).display, "none",
|
||||
"the reload button is on show in the browser build");
|
||||
});
|
||||
|
||||
check("the report asks who it's for, and in what format", () => {
|
||||
["company", "project", "director", "mixer", "phone", "email", "note"].forEach((key) => {
|
||||
const input = container.querySelector('[data-bwfa-report-field="' + key + '"]');
|
||||
assert(input, "no field for " + key);
|
||||
assert.strictEqual(input.value, "", key + " starts filled in: " + input.value);
|
||||
});
|
||||
const format = container.querySelector("[data-bwfa-report-format]");
|
||||
assert(format, "no output format control");
|
||||
assert.deepStrictEqual(Array.from(format.options).map((o) => o.value), ["pdf", "csv"]);
|
||||
assert.strictEqual(format.value, "pdf", "the default should be the report, not the spreadsheet");
|
||||
});
|
||||
|
||||
container.querySelector("[data-bwfa-export-close]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
|
||||
check("scene/take read from iXML", () => {
|
||||
const scenes = rowsOf().map((_, i) => col(i, "Scene"));
|
||||
const takes = rowsOf().map((_, i) => col(i, "Take"));
|
||||
assert.deepStrictEqual(scenes.slice().sort(), ["12A", "12A", "14B"], scenes.join(","));
|
||||
assert.deepStrictEqual(takes.slice().sort(), ["1", "2", "3"], takes.join(","));
|
||||
});
|
||||
|
||||
check("start timecode reconstructed at 25fps", () => {
|
||||
const tcs = rowsOf().map((_, i) => col(i, "Start TC"));
|
||||
assert(tcs.includes("10:00:00:00"), "expected 10:00:00:00 among " + tcs.join(" | "));
|
||||
assert(tcs.includes("11:00:00:00"), "expected 11:00:00:00 among " + tcs.join(" | "));
|
||||
});
|
||||
|
||||
check("format fields read from fmt chunk", () => {
|
||||
assert(/48/.test(col(0, "Sample Rate")), col(0, "Sample Rate"));
|
||||
assert.strictEqual(col(0, "Bit Depth"), "24-bit");
|
||||
assert.strictEqual(col(0, "Channels"), "2");
|
||||
});
|
||||
|
||||
check("circled take flagged", () => {
|
||||
const circled = rowsOf().map((_, i) => col(i, "Circled"));
|
||||
assert.strictEqual(circled.filter((v) => /yes/i.test(v)).length, 1, circled.join(","));
|
||||
});
|
||||
|
||||
check("duration computed from data chunk", () => {
|
||||
const d = col(0, "Duration");
|
||||
assert(/00:00:01/.test(d), d);
|
||||
});
|
||||
|
||||
// Search filter
|
||||
const search = doc.querySelector("[data-bwfa-search]");
|
||||
search.value = "14B";
|
||||
search.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
await waitFor(() => rowsOf().length === 1, "filter to apply", 5000);
|
||||
check("search filters rows", () => assert.strictEqual(rowsOf().length, 1));
|
||||
search.value = "";
|
||||
search.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
await waitFor(() => rowsOf().length === 3, "filter to clear", 5000);
|
||||
|
||||
// Sorting
|
||||
const sceneHeader = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
|
||||
.find((th) => /Scene/.test(th.textContent));
|
||||
const clickable = sceneHeader.querySelector("button") || sceneHeader;
|
||||
clickable.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
check("sorting by a column works", () => {
|
||||
const scenes = rowsOf().map((_, i) => col(i, "Scene"));
|
||||
const sorted = scenes.slice().sort();
|
||||
const reversed = sorted.slice().reverse();
|
||||
assert(
|
||||
JSON.stringify(scenes) === JSON.stringify(sorted) ||
|
||||
JSON.stringify(scenes) === JSON.stringify(reversed),
|
||||
scenes.join(",")
|
||||
);
|
||||
});
|
||||
|
||||
// Details modal (Track Names live here and in the CSV, not in the table).
|
||||
const detailsBtn = Array.from(rowsOf()[0].querySelectorAll("button"))
|
||||
.find((b) => /^(details|edit)$/i.test(b.textContent.trim()));
|
||||
assert(detailsBtn, "no Edit button in the row");
|
||||
detailsBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const modal = doc.querySelector("[data-bwfa-modal]");
|
||||
check("details modal opens with metadata sections", () => {
|
||||
assert.strictEqual(modal.hidden, false, "modal still hidden");
|
||||
const text = doc.querySelector("[data-bwfa-modal-body]").textContent;
|
||||
assert(/iXML/i.test(text), "no iXML section");
|
||||
assert(/Broadcast Extension|bext/i.test(text), "no bext section");
|
||||
assert(/Sound Devices 833/.test(text), "originator missing");
|
||||
assert(/slate/.test(text), "cue label missing");
|
||||
assert(/BWF Analyser test harness/.test(text), "RIFF INFO missing");
|
||||
// Track names are fields now, not labels, so they live in values
|
||||
// rather than in the modal's text.
|
||||
const trackNames = Array.from(modal.querySelectorAll("[data-bwfa-track-name]"))
|
||||
.map((input) => input.value);
|
||||
assert(trackNames.indexOf("Boom") !== -1 && trackNames.indexOf("Lav Anna") !== -1,
|
||||
"iXML track names missing: " + trackNames.join(", "));
|
||||
});
|
||||
doc.querySelector("[data-bwfa-modal-close]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
|
||||
// CSV export — the blob is captured by the stubbed createObjectURL rather
|
||||
// than actually downloaded.
|
||||
window.HTMLAnchorElement.prototype.click = function () {};
|
||||
const isCsv = (b) => b && b.type && b.type.indexOf("csv") !== -1;
|
||||
doc.querySelector("[data-bwfa-export-csv]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => createdBlobs.filter(isCsv)[0], "csv blob", 5000);
|
||||
const csvBlob = createdBlobs.filter(isCsv)[0];
|
||||
// Blob.text() decodes as UTF-8, which swallows the BOM — check the raw
|
||||
// bytes for it separately.
|
||||
const csvBytes = new Uint8Array(await csvBlob.arrayBuffer());
|
||||
const csvText = await csvBlob.text();
|
||||
check("but the spreadsheet still counts in hertz, as it should", () => {
|
||||
// kHz is how a rate is read on screen and in a printed report. A CSV of
|
||||
// metadata is machine-read, and the convention there — Wave Agent, the
|
||||
// recorders' own exports, the file itself — is the integer in hertz.
|
||||
const head = csvText.replace(/^\ufeff/, "").split("\r\n")[0].split(",");
|
||||
const row = csvText.replace(/^\ufeff/, "").split("\r\n")[1].split(",");
|
||||
const at = head.indexOf("Sample Rate (Hz)");
|
||||
assert(at !== -1, "the column was relabelled: " + head.join(" | "));
|
||||
assert.strictEqual(row[at], "48000", "the cell says " + row[at]);
|
||||
});
|
||||
|
||||
check("CSV export contains header and all rows", () => {
|
||||
assert.deepStrictEqual(Array.from(csvBytes.slice(0, 3)), [0xef, 0xbb, 0xbf], "missing UTF-8 BOM");
|
||||
assert(/\r\n/.test(csvText), "expected CRLF line endings (RFC 4180)");
|
||||
assert(/Scene/.test(csvText.split("\n")[0]), "no header row");
|
||||
["A001_12A_T1.wav", "A002_12A_T2.wav", "A003_14B_T3.wav"].forEach((n) =>
|
||||
assert(csvText.indexOf(n) !== -1, "missing " + n));
|
||||
assert(/12A/.test(csvText) && /14B/.test(csvText), "scenes missing");
|
||||
assert(/Boom/.test(csvText) && /Lav Anna/.test(csvText), "track names missing from CSV");
|
||||
assert(/Track Names/.test(csvText.split("\n")[0]), "Track Names column missing");
|
||||
});
|
||||
// The same CSV, but asked for through the report modal with the production
|
||||
// details filled in.
|
||||
const details = {
|
||||
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",
|
||||
};
|
||||
createdBlobs.length = 0;
|
||||
doc.querySelector("[data-bwfa-report-open]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
Object.keys(details).forEach((key) => {
|
||||
doc.querySelector('[data-bwfa-report-field="' + key + '"]').value = details[key];
|
||||
});
|
||||
doc.querySelector("[data-bwfa-report-format]").value = "csv";
|
||||
doc.querySelector("[data-bwfa-report-create]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => createdBlobs.filter(isCsv)[0], "report csv blob", 5000);
|
||||
const reportCsv = await createdBlobs.filter(isCsv)[0].text();
|
||||
|
||||
check("Create Report writes the format that was chosen", () => {
|
||||
assert.strictEqual(createdBlobs.filter(isCsv).length, 1,
|
||||
"expected one CSV, got " + createdBlobs.filter(isCsv).length);
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-export-modal]").hidden, true,
|
||||
"the modal stayed open over the save panel");
|
||||
});
|
||||
|
||||
check("the CSV leads with the production details, then a blank line", () => {
|
||||
const lines = reportCsv.replace(/^\ufeff/, "").split("\r\n");
|
||||
assert.strictEqual(lines[0], 'Production company,Acme Films', "first line: " + lines[0]);
|
||||
assert.strictEqual(lines[1], 'Project / show,The Long Weekend', "second line: " + lines[1]);
|
||||
assert.strictEqual(lines[7], "", "no blank line between the details and the table: " + lines[7]);
|
||||
assert(/^File Name|Scene/.test(lines[8]) || /Scene/.test(lines[8]),
|
||||
"the table header should follow the blank line: " + lines[8]);
|
||||
assert(reportCsv.indexOf("A001_12A_T1.wav") !== -1, "the rows went missing");
|
||||
});
|
||||
|
||||
check("a detail with a comma in it is still one field", () => {
|
||||
assert(reportCsv.indexOf('"Day 4, ext. night"') !== -1,
|
||||
"the note wasn't quoted: " + reportCsv.split("\r\n").slice(0, 8).join(" / "));
|
||||
});
|
||||
|
||||
// PDF export — jsPDF copies its API onto each instance, so the only
|
||||
// reliable interception point is the constructor itself.
|
||||
let pdfBytes = null;
|
||||
const RealJsPDF = window.jspdf.jsPDF;
|
||||
window.jspdf.jsPDF = function (options) {
|
||||
const instance = new RealJsPDF(options);
|
||||
instance.save = function () {
|
||||
pdfBytes = this.output("arraybuffer");
|
||||
return this;
|
||||
};
|
||||
return instance;
|
||||
};
|
||||
doc.querySelector("[data-bwfa-export-pdf]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => pdfBytes, "pdf generation", 15000);
|
||||
window.jspdf.jsPDF = RealJsPDF;
|
||||
check("PDF export produces a valid PDF", () => {
|
||||
const head = Buffer.from(new Uint8Array(pdfBytes).slice(0, 5)).toString("latin1");
|
||||
assert.strictEqual(head, "%PDF-", "not a PDF: " + head);
|
||||
assert(pdfBytes.byteLength > 1000, "suspiciously small: " + pdfBytes.byteLength);
|
||||
});
|
||||
|
||||
// Metadata writer: round-trip an edit through the parser.
|
||||
const writer = window.BWFA && window.BWFA.MetadataWriter;
|
||||
check("metadata writer is available", () => assert(writer, "BWFA.MetadataWriter missing"));
|
||||
|
||||
// Clear hides the results panel and re-shows the empty state.
|
||||
const resultsEl = doc.querySelector("[data-bwfa-results]");
|
||||
const emptyEl = doc.querySelector("[data-bwfa-empty]");
|
||||
doc.querySelector("[data-bwfa-clear]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => resultsEl.hidden === true, "clear", 5000);
|
||||
check("clear resets back to the empty state", () => {
|
||||
assert.strictEqual(resultsEl.hidden, true, "results still visible");
|
||||
assert.strictEqual(emptyEl.hidden, false, "empty state not shown");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-clear]").disabled, true, "clear still enabled");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-status]").textContent.trim(), "", "status not cleared");
|
||||
});
|
||||
|
||||
// A feature wired into one build only looks exactly like a broken feature:
|
||||
// the control is there, pressing it does nothing. So check the knob in the
|
||||
// plain browser page as well, where there is no native engine.
|
||||
check("the mixer knob works in the browser build too", () => {
|
||||
const knob = doc.querySelector("[data-bwfa-mixer-open]");
|
||||
assert(knob, "no mixer knob on the page");
|
||||
knob.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const modal = doc.querySelector("[data-bwfa-mixer]");
|
||||
assert.strictEqual(modal.hidden, false, "the knob is dead: the modal stayed shut");
|
||||
// Unhidden is not visible. This framework keeps .modal at display:none
|
||||
// until it also has .open, so a test that only checks the attribute
|
||||
// passes while the user sees nothing at all — which is what happened.
|
||||
assert(modal.classList.contains("open"),
|
||||
"the modal is unhidden but has no .open class, so it is still display:none");
|
||||
const spectro = doc.querySelector("[data-bwfa-spectro]");
|
||||
assert(spectro, "no spectrogram modal to compare against");
|
||||
assert.strictEqual(window.getComputedStyle(modal).display,
|
||||
window.getComputedStyle(spectro).display === "none"
|
||||
? window.getComputedStyle(modal).display : "flex",
|
||||
"the mixer isn't shown the way the other modals are");
|
||||
const said = doc.querySelector("[data-bwfa-mixer-strips]").textContent;
|
||||
assert(said.trim().length > 0, "the mixer opened empty and said nothing");
|
||||
doc.querySelector("[data-bwfa-mixer-close]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
assert.strictEqual(modal.hidden, true, "it wouldn't close");
|
||||
assert(!modal.classList.contains("open"), "closing left the .open class behind");
|
||||
});
|
||||
|
||||
check("the player's round buttons cannot be squashed", () => {
|
||||
// The bug this exists for: a flex item with a set width still shrinks,
|
||||
// and because the icon inside overflows, the button goes on looking
|
||||
// perfectly normal while its hit area collapses to nothing. jsdom has
|
||||
// no layout, so no amount of clicking in a test can catch it — read
|
||||
// the rule instead.
|
||||
const probe = doc.createElement("button");
|
||||
probe.className = "bwfa-round-btn";
|
||||
container.appendChild(probe);
|
||||
const style = window.getComputedStyle(probe);
|
||||
const shrink = style.flexShrink || style.getPropertyValue("flex-shrink");
|
||||
probe.remove();
|
||||
assert.strictEqual(String(shrink), "0",
|
||||
"a round button can shrink (flex-shrink: " + shrink + "), which kills its hit area");
|
||||
assert(/\.bwfa-round-btn\s*\{[^}]*flex:\s*none/.test(css),
|
||||
"no flex: none on .bwfa-round-btn in the shipped stylesheet");
|
||||
});
|
||||
|
||||
check("the app carries a log you can read without developer tools", () => {
|
||||
// The app ships without an inspector, so "what does the console say"
|
||||
// has to be answerable from inside the window.
|
||||
assert(window.BWFA_DIAG, "no diagnostics on the page");
|
||||
window.console.error("a deliberate error, for the log");
|
||||
const captured = window.BWFA_DIAG.lines().join("\n");
|
||||
assert(/a deliberate error, for the log/.test(captured),
|
||||
"the log didn't catch a console error");
|
||||
assert(/page built/.test(window.BWFA_DIAG.lines().join("\n")) ||
|
||||
/ERROR/.test(captured), "nothing useful in the log");
|
||||
// And it measures the controls, which is what tells us a visible
|
||||
// button has no hit area — the failure that started all this.
|
||||
window.BWFA_DIAG.probe();
|
||||
const measured = window.BWFA_DIAG.lines().join("\n");
|
||||
assert(/PROBE.*data-bwfa-mixer-open/.test(measured),
|
||||
"the probe didn't measure the mixer knob");
|
||||
// jsdom reports every box as zero, so don't assert on the numbers —
|
||||
// only that the knob and the modal were both found on the page.
|
||||
assert(!/data-bwfa-mixer-open\] MISSING/.test(measured),
|
||||
"the probe says the knob isn't on the page at all");
|
||||
assert(/mixer modal in the page: yes/.test(measured),
|
||||
"the probe says the mixer modal is missing");
|
||||
consoleErrors.length = 0;
|
||||
});
|
||||
|
||||
check("mute and solo are dead centre in their circles", () => {
|
||||
// .chip pads 0.85rem left against 0.5rem right, which reads fine under
|
||||
// a word and visibly lopsided under a single letter.
|
||||
const probe = doc.createElement("button");
|
||||
probe.className = "chip bwfa-mixer-mute";
|
||||
container.appendChild(probe);
|
||||
const style = window.getComputedStyle(probe);
|
||||
const pad = [style.paddingLeft, style.paddingRight, style.paddingTop, style.paddingBottom];
|
||||
probe.remove();
|
||||
assert(pad.every((v) => parseFloat(v || 0) === 0),
|
||||
"the letter is pushed off centre by padding: " + pad.join(" "));
|
||||
assert(/\.bwfa-mixer-mute[^{]*\{[^}]*justify-content:\s*center/.test(css),
|
||||
"nothing centres the letter horizontally");
|
||||
assert(/\.bwfa-mixer-mute[^{]*\{[^}]*align-items:\s*center/.test(css),
|
||||
"nothing centres the letter vertically");
|
||||
});
|
||||
|
||||
check("the mixer borrows the app's accent, not the system's", () => {
|
||||
// The faders came up macOS blue because they asked for a --accent
|
||||
// variable this app has never defined, so every one of them fell
|
||||
// through to the hardcoded fallback.
|
||||
assert(!/accent-color:\s*var\(\s*--accent\b/.test(css),
|
||||
"something still asks for --accent, which this app doesn't define");
|
||||
assert(!/#0a84ff/i.test(css), "a hardcoded system blue is still in the stylesheet");
|
||||
assert(/accent-color:\s*var\(\s*--color-action\s*\)/.test(css),
|
||||
"the faders don't use the app's own action colour");
|
||||
});
|
||||
|
||||
check("the page stamps when it was built", () => {
|
||||
// So "is the app running the page I just built?" stops being a matter
|
||||
// of opinion. The native build embeds this page at compile time.
|
||||
assert(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(window.BWFA_BUILD || ""),
|
||||
"no build stamp on the page: " + window.BWFA_BUILD);
|
||||
assert.strictEqual(container.getAttribute("data-bwfa-build"), window.BWFA_BUILD,
|
||||
"the stamp on the page and the stamp on the app disagree");
|
||||
});
|
||||
|
||||
check("no script errors on the page", () =>
|
||||
assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | ")));
|
||||
|
||||
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");
|
||||
process.exit(failed ? 1 : 0);
|
||||
})().catch((e) => {
|
||||
console.error("harness error:", e);
|
||||
console.error(consoleErrors.join("\n"));
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user