renderer.jsjavascript
/**
* @file Renderer process for EZLaybacks. All UI logic lives here: video and track
* import, real-time Web Audio preview (per-track GainNodes, tail fades), waveform
* rendering and playhead animation, trim handles, the Music Start marker, auto-mix
* gain staging, the Save Laybacks modal and export IPC, project save/restore, and
* the auth gate. Talks to the main process only through the window.ezl preload bridge.
* @module renderer
*/
/** MARK: ROOT FLAGS */
/**
* Master toggle for the footer status readout. Hidden for now — errors surface via
* an overt popup (showError) instead. Flip to true to show the inline status line again.
* @type {boolean}
*/
const SHOW_STATUS_TEXT = false;
/** MARK: AUDIO CONTEXT */
let _audioCtx = null;
/**
* Return the shared AudioContext, creating a fresh one if none exists or the
* previous one was closed.
* @returns {AudioContext} The live shared context.
*/
function getAudioCtx() {
if (!_audioCtx || _audioCtx.state === 'closed') _audioCtx = new AudioContext();
return _audioCtx;
}
/** MARK: PEAK EXTRACTION */
/**
* Decode any blob URL to an AudioBuffer. Web Audio's decodeAudioData handles a broader
* set of formats than the audio element — notably AIFF — which is why waveforms render
* even for files the element refuses to play. decodeAudioData detaches the fetched
* ArrayBuffer, so it must not be reused after the call.
* @param {string} blobUrl - Object URL of the audio file to decode.
* @returns {Promise<AudioBuffer>} The decoded buffer.
*/
async function decodeAudio(blobUrl) {
const ctx = getAudioCtx();
const resp = await fetch(blobUrl);
const arrayBuf = await resp.arrayBuffer();
return ctx.decodeAudioData(arrayBuf);
}
/**
* Reduce a decoded buffer to numSamples normalized peak magnitudes for the waveform.
* Peaks are normalized so the loudest one fills the canvas height.
* @param {AudioBuffer} audioBuf - Decoded audio to summarize.
* @param {number} [numSamples=1000] - Number of peak buckets to produce.
* @returns {Float32Array} Normalized (0-1) peak magnitudes, one per bucket.
*/
function peaksFromBuffer(audioBuf, numSamples = 1000) {
const channels = audioBuf.numberOfChannels;
const length = audioBuf.length;
const block = Math.max(1, Math.floor(length / numSamples));
const peaks = new Float32Array(numSamples);
for (let i = 0; i < numSamples; i++) {
let max = 0;
const start = i * block;
const end = Math.min(start + block, length);
for (let ch = 0; ch < channels; ch++) {
const data = audioBuf.getChannelData(ch);
for (let j = start; j < end; j++) {
const abs = Math.abs(data[j]);
if (abs > max) max = abs;
}
}
peaks[i] = max;
}
const maxPeak = peaks.reduce((m, v) => v > m ? v : m, 0);
if (maxPeak > 0) for (let i = 0; i < numSamples; i++) peaks[i] /= maxPeak;
return peaks;
}
/** MARK: WAVEFORM DRAWING */
/**
* Draw a static bar waveform onto a canvas from a peaks array, scaled for the
* device pixel ratio.
* @param {HTMLCanvasElement} canvas - Target canvas.
* @param {Float32Array|number[]} peaks - Normalized peak magnitudes.
* @param {string} color - Fill color for the bars.
* @returns {void}
*/
function drawPeaks(canvas, peaks, color) {
const dpr = window.devicePixelRatio || 1;
const w = canvas.offsetWidth || 300;
const h = canvas.offsetHeight || 28;
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const bw = 2, gap = 1;
const bars = Math.floor(w / (bw + gap));
ctx.fillStyle = color;
for (let i = 0; i < bars; i++) {
const pi = Math.round(i / bars * (peaks.length - 1));
const bh = Math.max(1, peaks[pi] * h * 0.9);
ctx.fillRect(i * (bw + gap), (h - bh) / 2, bw, bh);
}
}
/**
* Seeded pseudo-random number generator for deterministic placeholder waveforms.
* @param {number} seed - Seed value; the same seed reproduces the same sequence.
* @returns {function(): number} Function returning the next pseudo-random value in [0, 1).
*/
function rng(seed) { let s = seed; return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; }
/**
* Draw a seeded random placeholder waveform, shown while the real decode runs in
* the background.
* @param {HTMLCanvasElement} canvas - Target canvas.
* @param {number} seed - RNG seed so the placeholder is stable across redraws.
* @param {string} color - Fill color for the bars.
* @returns {void}
*/
function drawWavePlaceholder(canvas, seed, color) {
const dpr = window.devicePixelRatio || 1;
const w = canvas.offsetWidth || 300;
const h = canvas.offsetHeight || 28;
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const rand = rng(seed);
const bw = 2, gap = 1;
const bars = Math.floor(w / (bw + gap));
let prev = 0.35;
ctx.fillStyle = color;
for (let i = 0; i < bars; i++) {
const amp = prev * 0.55 + rand() * 0.45;
prev = amp;
const bh = Math.max(2, amp * h * 0.88);
ctx.fillRect(i * (bw + gap), (h - bh) / 2, bw, bh);
}
}
/**
* Waveform bar color for a track card.
* @param {boolean} active - Whether the card is the active track.
* @returns {string} CSS color string.
*/
function getTrackColor(active) { return active ? '#3A6EBF' : '#283545'; }
/**
* Redraw the OA waveform and every track card's waveform (real peaks where known,
* placeholder otherwise). Bound to window load and resize.
* @returns {void}
*/
function redrawAllWaveforms() {
const vofxCanvas = document.getElementById('vofxWave');
if (vofxPeaks) drawPeaks(vofxCanvas, vofxPeaks, '#234572');
else drawWavePlaceholder(vofxCanvas, 101, '#1A2E48');
document.querySelectorAll('.track-card').forEach(card => {
const tw = card.querySelector('.tw');
if (!tw) return;
const id = parseInt(card.dataset.trackId);
const t = trackMap.get(id);
const color = getTrackColor(card.classList.contains('active'));
if (t?.peaks) drawPeaks(tw, t.peaks, color);
else drawWavePlaceholder(tw, parseInt(card.dataset.seed), color);
});
}
window.addEventListener('load', redrawAllWaveforms);
window.addEventListener('resize', redrawAllWaveforms);
/** MARK: VOFX ANALYSIS — Original Audio waveform state and display helpers. */
let vofxPeaks = null;
let _currentVideoBlobUrl = null;
/**
* Show the OA waveform once the video's loudness analysis returns. The peaks arrive
* with the analysis, drawn from samples the Python worker already decoded — reading
* the video in the renderer instead would pull the whole container into memory (past
* ~2 GB arrayBuffer() throws, and a sync cut is routinely 1-5 GB). No-op when the
* video was swapped while analysis ran.
* @param {number[]} peaks - Normalized peak magnitudes from the worker.
* @param {string} videoPath - Path the analysis ran for; identity guard against swaps.
* @returns {void}
*/
function showVofxPeaks(peaks, videoPath) {
if (currentVideoPath !== videoPath) return;
setOaWaveform(peaks);
}
/**
* Fit a replacement OA's peaks to the video timeline. The OA canvas spans the WHOLE
* video; a replacement shorter than the video should occupy only its proportional
* slice, with silence (a flat, zero-filled tail) filling the rest — not stretch to
* fill, which misrepresents timing and slides the playhead off the audio. Resamples
* the source peaks into the front srcDuration/videoDuration of a same-width buffer,
* max-pooling each source bucket into its column so transient peaks survive the
* squeeze. Returns the peaks unchanged when the source is as long as the video
* (embedded OA, or an exact-length replacement) or the video duration isn't known yet.
* @param {number[]|Float32Array} peaks - Source peak magnitudes.
* @param {number} srcDurationSec - Duration of the source audio in seconds.
* @returns {Float32Array} Peaks laid out on the video timeline.
*/
function oaDisplayPeaks(peaks, srcDurationSec) {
const vid = videoEl.duration || 0;
const src = Float32Array.from(peaks || []);
if (!src.length || !vid || !srcDurationSec || srcDurationSec >= vid - 0.05) return src;
const active = Math.max(1, Math.min(src.length, Math.round(src.length * srcDurationSec / vid)));
const out = new Float32Array(src.length);
for (let i = 0; i < active; i++) {
const start = Math.floor(i / active * src.length);
const end = Math.max(start + 1, Math.floor((i + 1) / active * src.length));
let m = 0;
for (let j = start; j < end && j < src.length; j++) if (src[j] > m) m = src[j];
out[i] = m;
}
return out;
}
/**
* Paint the OA waveform from a peaks array. Shared by the embedded-OA analysis and
* the OA replacement, which has no video-path identity to guard on — so the guard
* lives in the caller. No-op on an empty array (a silent video keeps its placeholder).
* Restarts the animation loop in case it exited before peaks were ready.
* @param {number[]|Float32Array} peaks - Normalized peak magnitudes.
* @returns {void}
*/
function setOaWaveform(peaks) {
if (!peaks || !peaks.length) return;
vofxPeaks = Float32Array.from(peaks);
document.querySelector('.oa-wave-wrap').classList.add('visible');
drawPeaks(document.getElementById('vofxWave'), vofxPeaks, '#234572');
startWaveformAnimation();
}
/** MARK: TRACK ANALYSIS — per-track decode, peaks, and worker-transcoded preview. */
/**
* Formats decoded in the Python worker and previewed from the returned WAV instead of
* streaming the original. Three reasons land a format here:
* - Chromium can't decode it at all — AIFF (no audio-element playback, no decodeAudioData peaks).
* - Chromium CAN decode it, but its seek isn't sample-accurate — MP3. A VBR MP3 (long files
* especially) only carries a coarse ~100-entry seek table, so the audio element ESTIMATES
* the byte offset on a seek and can land ~1 s off the true time. The export reads the same
* MP3 through ffmpeg and seeks/trims on the sample, so preview and export would drift.
* Routing MP3 through the worker means preview plays a WAV decoded by the SAME ffmpeg
* pipeline export uses — one shared, sample-accurate timeline (and identical encoder-delay
* handling), so a trim/scrub lines up between preview and the saved file.
* - It's a VIDEO container used as a track — MP4/MOV. decodeAudioData on a video container
* is unreliable (no peaks), and the native peaks path would fetch the ENTIRE video file
* (often 1-5 GB) into an ArrayBuffer just to read its audio. The worker's audio-only WAV
* sidesteps both, and shares the export decode's timeline like MP3 above.
* Either way the worker reads reliably and hands back a playable WAV; export still reads the
* original source. (m4a/aac carry accurate sample tables, so they keep streaming natively.)
* @type {Set<string>}
*/
const BACKEND_PREVIEW_EXTS = new Set(['aif', 'aiff', 'aifc', 'mp3', 'mp4', 'mov']);
/**
* Decode a track and draw its waveform. Formats in BACKEND_PREVIEW_EXTS are routed
* through the worker — decodeAudioData throws on those originals — and everything
* else is decoded natively. Bails silently if the track was removed during decode.
* Restarts the animation loop in case it exited before peaks were ready.
* @param {number} id - Track id in trackMap.
* @param {string} blobUrl - The track's object URL; identity guard against removal.
* @returns {Promise<void>}
*/
async function analyzeTrack(id, blobUrl) {
const t0 = trackMap.get(id);
if (t0 && BACKEND_PREVIEW_EXTS.has(t0.ext)) {
await loadPreviewViaBackend(id, blobUrl);
return;
}
try {
const audioBuf = await decodeAudio(blobUrl);
const t = trackMap.get(id);
if (!t || t.blobUrl !== blobUrl) return;
t.peaks = peaksFromBuffer(audioBuf);
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
if (!card) return;
drawPeaks(card.querySelector('.tw'), t.peaks, getTrackColor(card.classList.contains('active')));
startWaveformAnimation();
} catch (e) {
console.warn(`Track ${id} waveform decode failed:`, e);
}
}
/**
* Prepare preview playback for formats Chromium can't decode: ask the backend to
* transcode the source to WAV bytes, point the audio element at them (so Preview
* plays), and derive the waveform from that same WAV (which Web Audio CAN decode).
* The MediaElementAudioSourceNode is bound to the element, not its src, so the gain
* routing survives the swap; setting src also fires loadedmetadata, which re-lays
* the trim handles from the now-valid duration. Preview-only — export still reads
* the original file through Python. The friendly track name for error messages is
* resolved lazily: this function starts before the card is appended to the DOM
* (createTrackCard calls analyzeTrack first), so an up-front querySelector would
* always miss. Bails silently if the track was removed/replaced during transcode.
* @param {number} id - Track id in trackMap.
* @param {string} blobUrl - The track's object URL; identity guard against removal.
* @returns {Promise<void>}
*/
async function loadPreviewViaBackend(id, blobUrl) {
const trackName = () => document.querySelector(`.track-card[data-track-id="${id}"] .track-name`)?.textContent || 'This track';
const t = trackMap.get(id);
if (!t || !t.filePath || !window.ezl) {
console.warn(`[preview] no file path for track ${id}; can't transcode for playback`);
showError(`${trackName()} couldn't be prepared for playback — its file location is unavailable. Try removing and re-adding it.`);
return;
}
try {
const bytes = await window.ezl.getPreviewAudio(t.filePath);
const tt = trackMap.get(id);
if (!tt || tt.blobUrl !== blobUrl) return;
tt.previewUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' }));
tt.audio.src = tt.previewUrl;
tt.peaks = peaksFromBuffer(await decodeAudio(tt.previewUrl));
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
if (card) drawPeaks(card.querySelector('.tw'), tt.peaks, getTrackColor(card.classList.contains('active')));
startWaveformAnimation();
} catch (e) {
console.warn(`[preview] backend transcode failed for track ${id}:`, e?.message ?? e);
showError(`${trackName()} couldn't be prepared for playback.`, e?.message ?? String(e));
}
}
/** MARK: PLAYHEAD ANIMATION */
/**
* Draw a waveform with a played/unplayed split at the playhead. The backing store is
* only resized when dimensions actually change. The bars only span bars*slot px, with
* up to slot-1 px of unused slack on the right; the playhead is measured in that SAME
* bar-space (not the full canvas width) so the played/unplayed edge sits exactly on
* the true position instead of drifting ahead of the bars — the mismatch that made an
* earlier 1px marker (which used the full width) read a couple px ahead of the
* illumination. The bar under the playhead is split at the exact pixel so the edge
* tracks continuously instead of flipping whole bars, which jumps in ~60s steps on a
* multi-hour file.
* @param {HTMLCanvasElement} canvas - Target canvas.
* @param {Float32Array|number[]} peaks - Normalized peak magnitudes.
* @param {string} playedColor - Fill for bars behind the playhead.
* @param {string} unplayedColor - Fill for bars ahead of the playhead.
* @param {number} progress - Played fraction, 0-1.
* @returns {void}
*/
function drawPeaksWithPlayhead(canvas, peaks, playedColor, unplayedColor, progress) {
const dpr = window.devicePixelRatio || 1;
const w = canvas.offsetWidth || 300;
const h = canvas.offsetHeight || 28;
const needW = Math.round(w * dpr);
const needH = Math.round(h * dpr);
if (canvas.width !== needW || canvas.height !== needH) {
canvas.width = needW;
canvas.height = needH;
}
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const bw = 2, gap = 1, slot = bw + gap;
const bars = Math.floor(w / slot);
const drawnW = bars * slot;
const headX = Math.min(Math.max(progress, 0), 1) * drawnW;
for (let i = 0; i < bars; i++) {
const x = i * slot;
const pi = Math.round(i / bars * (peaks.length - 1));
const bh = Math.max(1, peaks[pi] * h * 0.9);
const y = (h - bh) / 2;
if (x + bw <= headX) {
ctx.fillStyle = playedColor;
ctx.fillRect(x, y, bw, bh);
} else if (x >= headX) {
ctx.fillStyle = unplayedColor;
ctx.fillRect(x, y, bw, bh);
} else {
const pw = headX - x;
ctx.fillStyle = playedColor;
ctx.fillRect(x, y, pw, bh);
ctx.fillStyle = unplayedColor;
ctx.fillRect(x + pw, y, bw - pw, bh);
}
}
}
/**
* MARK: CLOCK INTERPOLATION — HTMLMediaElement.currentTime updates in discrete OS
* audio-clock steps (~15-30 ms on macOS). Between ticks the value is stale, causing
* visible stutter even in a 60 fps rAF loop. Each element is anchored to its last
* known time + performance.now() and extrapolated forward each frame.
*/
let _videoAnchorTime = 0;
let _videoAnchorPerf = 0;
/** Per-audio-element interpolation anchors: el → { time, perf }. */
const _audioAnchors = new WeakMap();
/**
* Refresh the video clock anchor from the element's current time. Call after every
* seek or discrete timeupdate tick so extrapolation stays accurate.
* @returns {void}
*/
function _anchorVideo() {
_videoAnchorTime = videoEl.currentTime;
_videoAnchorPerf = performance.now();
}
/**
* Smooth, extrapolated video playback position. Falls back to the raw currentTime
* when paused or duration is unknown; otherwise anchor time plus elapsed wall clock
* scaled by playbackRate, clamped to the duration.
* @returns {number} Interpolated video time in seconds.
*/
function getVideoTime() {
if (videoEl.paused || !videoEl.duration) return videoEl.currentTime;
return Math.min(
_videoAnchorTime + (performance.now() - _videoAnchorPerf) / 1000 * (videoEl.playbackRate || 1),
videoEl.duration
);
}
/**
* Refresh an audio element's clock anchor from its current time.
* @param {HTMLAudioElement} el - Element to anchor.
* @returns {void}
*/
function _anchorAudio(el) {
_audioAnchors.set(el, { time: el.currentTime, perf: performance.now() });
}
/**
* Smooth, extrapolated playback position for an audio element, mirroring
* getVideoTime. Falls back to the raw currentTime when paused, unanchored, or
* duration is unknown.
* @param {HTMLAudioElement} el - Element to read.
* @returns {number} Interpolated audio time in seconds.
*/
function getAudioTime(el) {
if (el.paused || !el.duration) return el.currentTime;
const a = _audioAnchors.get(el);
if (!a) return el.currentTime;
return Math.min(a.time + (performance.now() - a.perf) / 1000 * (el.playbackRate || 1), el.duration);
}
/** MARK: RAF ANIMATION LOOP */
const _vofxWaveCanvas = document.getElementById('vofxWave');
let _rafId = null;
/**
* Start the requestAnimationFrame loop that animates every playhead while anything
* is playing. No-op if the loop is already running; the loop self-terminates when
* nothing plays. Each tick does four jobs:
* - Stops the video at its trim Out point whenever it's playing — covers both preview
* and bare (no active track) playback. In preview the whole preview is torn down;
* otherwise just the video transport pauses, matching the native 'ended' handler.
* With loop enabled it instead jumps seamlessly back to the In point and keeps
* playing (same restart path as the Restart button's wasPlaying branch), realigning
* the replacement OA and, in preview, the active track (which also reschedules the
* tail fade). Both checks are skipped while a trim handle is being dragged:
* shrinking Out past the live playhead must NOT stop playback mid-drag —
* resyncAfterTrimRelease() settles everything on release.
* - During preview, gates the active track's audibility to its own [In, Out] window:
* silences (pauses) it while the shared clock sits outside, resumes in sync when it
* re-enters. While parked before the Music Start marker the element is PRE-SEEKED
* to the track's In point so crossing the marker is a bare play() with no seek —
* an audio-element seek at the crossing frame costs ~50ms of silence, audible as a
* late entry on a hard downbeat. The pre-seek fires once (later frames land inside
* the tolerance), so it's free per-frame. On re-entry the tail fade is deliberately
* NOT rescheduled: it was laid down in AudioContext (wall-clock) time and doesn't
* care whether the element is paused, so it still lands on silence at the video Out;
* re-scheduling would re-anchor currentGain mid-crossing and mis-time the tail.
* - While the video plays, drives the scrub bar fill/head and the OA waveform playhead.
* - Draws a live playhead on every playing track's waveform.
* @returns {void}
*/
function startWaveformAnimation() {
if (_rafId) return;
function tick() {
let anyPlaying = false;
if (videoEl.duration && !videoEl.paused && !_trimDragging && getVideoTime() >= vOut()) {
if (loopEnabled) {
videoEl.currentTime = vIn();
_anchorVideo();
oaReplaceSeek(vIn());
if (isPreviewing) syncActiveTrackToVideo(vIn());
} else {
if (isPreviewing) stopPreview();
else { videoEl.pause(); btnPlay.innerHTML = '<span>▶</span>'; resetVofxWaveform(); }
// Clear the loop handle before bailing — the early return skips the assignment at
// the bottom of tick(), so without this _rafId stays stale and the next play()
// would see startWaveformAnimation()'s `if (_rafId) return` guard and never redraw.
_rafId = null;
return;
}
}
if (isPreviewing && videoEl.duration && !_trimDragging) {
const vt = getVideoTime();
const at = trackMap.get(activeTrackId);
if (at && at.audio && at.audio.duration) {
const tt = trackTimeFor(at, vt);
const within = tt >= aIn(at) && tt < aOut(at) && tt < at.audio.duration;
if (!within) {
if (!at.audio.paused) at.audio.pause();
if (tt < aIn(at) && Math.abs(at.audio.currentTime - aIn(at)) > 0.02) {
at.audio.currentTime = aIn(at);
}
} else if (at.audio.paused) {
if (Math.abs(at.audio.currentTime - tt) > 0.1) at.audio.currentTime = tt;
_anchorAudio(at.audio);
at.audio.play().catch(() => {});
}
}
}
if (!videoEl.paused && videoEl.duration) {
const t = getVideoTime();
const pct = t / videoEl.duration;
scrubFill.style.width = (pct * 100) + '%';
scrubHead.style.left = (pct * 100) + '%';
if (vofxPeaks) {
drawPeaksWithPlayhead(_vofxWaveCanvas, vofxPeaks, '#4A8EEF', '#234572', pct);
}
anyPlaying = true;
}
trackMap.forEach(({ audio, peaks }, id) => {
if (audio.paused || !peaks || !audio.duration) return;
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
if (!card) return;
const tw = card.querySelector('.tw');
const active = card.classList.contains('active');
drawPeaksWithPlayhead(
tw, peaks,
active ? '#6BAEF0' : '#3A5878',
active ? '#3A6EBF' : '#283545',
getAudioTime(audio) / audio.duration
);
anyPlaying = true;
});
_rafId = anyPlaying ? requestAnimationFrame(tick) : null;
}
_rafId = requestAnimationFrame(tick);
}
/**
* Repaint the OA waveform in its resting (no playhead) state — real peaks if known,
* placeholder otherwise.
* @returns {void}
*/
function resetVofxWaveform() {
const canvas = document.getElementById('vofxWave');
if (vofxPeaks) drawPeaks(canvas, vofxPeaks, '#234572');
else drawWavePlaceholder(canvas, 101, '#1A2E48');
}
/**
* Repaint a track's waveform in its resting (no playhead) state — real peaks if
* known, placeholder otherwise.
* @param {number} id - Track id in trackMap.
* @returns {void}
*/
function resetTrackWaveform(id) {
const t = trackMap.get(id);
if (!t) return;
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
if (!card) return;
const tw = card.querySelector('.tw');
const color = getTrackColor(card.classList.contains('active'));
if (t.peaks) drawPeaks(tw, t.peaks, color);
else drawWavePlaceholder(tw, t.seed, color);
}
/**
* Static (non-rAF) redraw of a track's waveform at a given played fraction, using the
* same colors as the live animation loop. Needed because the rAF loop skips paused
* tracks, so a track parked at its end after a seek would otherwise keep stale
* shading. No-op on placeholder waveforms, which have no progress concept.
* @param {number} id - Track id in trackMap.
* @param {number} progress - Played fraction 0-1; 1 shades the whole playable region,
* signalling the track is at/past its end.
* @returns {void}
*/
function drawTrackShade(id, progress) {
const t = trackMap.get(id);
if (!t || !t.peaks) return;
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
if (!card) return;
const active = card.classList.contains('active');
drawPeaksWithPlayhead(
card.querySelector('.tw'), t.peaks,
active ? '#6BAEF0' : '#3A5878',
active ? '#3A6EBF' : '#283545',
progress
);
}
/**
* Draw a track's waveform shading for a given video position WITHOUT touching
* playback, so a paused seek / track switch shows the correct state immediately (the
* rAF loop only runs while playing). At/past the track's end (duration or trim-out)
* the waveform is fully shaded, for any track. Within range the ACTIVE track shows a
* live playhead at the current position; inactive tracks stay at base so they don't
* freeze a stale playhead while another track is previewing. For any video time
* before the Music Start marker the shade holds at the track's In point — nothing has
* played yet, and the shade must not creep below the trimmed-in head.
* @param {number} id - Track id in trackMap.
* @param {number} videoTime - Video-timeline position in seconds.
* @returns {void}
*/
function refreshTrackShade(id, videoTime) {
const t = trackMap.get(id);
if (!t) return;
const dur = trackDuration(t);
if (!videoLoaded || dur <= 0 || !t.peaks) { resetTrackWaveform(id); return; }
const tt = trackTimeFor(t, videoTime);
if (tt >= aOut(t)) { drawTrackShade(id, 1); return; }
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
if (card?.classList.contains('active')) {
drawTrackShade(id, Math.max(aIn(t), Math.min(tt, dur)) / dur);
}
else resetTrackWaveform(id);
}
/**
* Update the paused video playhead UI — scrub bar fill/head plus OA waveform
* shading — to a given time. The rAF loop does this each frame while playing; when
* paused it must be done explicitly so a seek gives immediate feedback (the video
* frame already jumped via currentTime). Mirrors the video-playing draw block in the
* rAF loop.
* @param {number} time - Video time in seconds.
* @returns {void}
*/
function drawStaticVideoPlayhead(time) {
if (!videoEl.duration) return;
const pct = time / videoEl.duration;
scrubFill.style.width = (pct * 100) + '%';
scrubHead.style.left = (pct * 100) + '%';
if (vofxPeaks) drawPeaksWithPlayhead(_vofxWaveCanvas, vofxPeaks, '#4A8EEF', '#234572', pct);
else resetVofxWaveform();
}
/**
* MARK: TRIM UI — trim handles are plain DOM overlays positioned as a % of the
* bar/canvas width, so they sit on top of the live waveform without the 60fps
* redraw loop ever touching them.
*/
/**
* Clamp a number into [0, 1].
* @param {number} x - Value to clamp.
* @returns {number} Clamped value.
*/
const clamp01 = x => Math.max(0, Math.min(1, x));
/**
* Position a trim surface's In/Out handles, its two dim panels, and (when present)
* the draggable band spanning In to Out, from fractional positions.
* @param {{in: HTMLElement, out: HTMLElement, dimL: HTMLElement, dimR: HTMLElement,
* region: (HTMLElement|null|undefined)}} els - Elements of one trim surface.
* @param {number} startFrac - In position as a fraction of the surface width (0-1).
* @param {number} endFrac - Out position as a fraction of the surface width (0-1).
* @returns {void}
*/
function layoutTrim(els, startFrac, endFrac) {
els.in.style.left = (startFrac * 100) + '%';
els.out.style.left = (endFrac * 100) + '%';
els.dimL.style.width = (startFrac * 100) + '%';
els.dimR.style.width = ((1 - endFrac) * 100) + '%';
if (els.region) {
els.region.style.left = (startFrac * 100) + '%';
els.region.style.width = ((endFrac - startFrac) * 100) + '%';
}
}
/**
* Trim-surface elements for the scrub bar.
* @returns {object} Element map for layoutTrim.
*/
function videoTrimEls() {
return { in: scrubTrimIn, out: scrubTrimOut, dimL: scrubDimL, dimR: scrubDimR, region: scrubRegion };
}
/**
* Trim-surface elements for the OA waveform, which mirrors the SAME video trim —
* its handles are locked to the scrub handles.
* @returns {object} Element map for layoutTrim.
*/
function oaTrimEls() {
return { in: oaTrimIn, out: oaTrimOut, dimL: oaDimL, dimR: oaDimR, region: oaRegion };
}
/**
* Lay out the video trim handles on both surfaces (scrub bar + the OA waveform,
* locked to the same trim) from the current trim state. Also relays out the Music
* Start marker: it lives inside the same window, and doing it here rather than at
* each call site means it follows along at every existing relayout point
* (loadedmetadata reset, project restore, both video handle drags) for free.
* @returns {void}
*/
function updateVideoTrimUI() {
const dur = videoEl.duration;
if (!dur || !isFinite(dur)) return;
const startFrac = (videoTrimStart || 0) / dur;
const endFrac = (videoTrimEnd ?? dur) / dur;
layoutTrim(videoTrimEls(), startFrac, endFrac);
layoutTrim(oaTrimEls(), startFrac, endFrac);
updateMusicStartUI();
}
/**
* Position both Music Start markers from the same state — the OA marker is locked to
* the scrub marker exactly like the trim handles are.
* @returns {void}
*/
function updateMusicStartUI() {
const dur = videoEl.duration;
if (!dur || !isFinite(dur)) return;
const pct = (clamp01(mStart() / dur) * 100) + '%';
scrubMusicMarker.style.left = pct;
oaMusicMarker.style.left = pct;
}
/**
* Trim-surface elements for one track card's waveform.
* @param {HTMLElement} card - The track card element.
* @returns {object} Element map for layoutTrim.
*/
function trackTrimEls(card) {
return {
in: card.querySelector('.tw-trim-in'),
out: card.querySelector('.tw-trim-out'),
dimL: card.querySelector('.tw-dim-l'),
dimR: card.querySelector('.tw-dim-r'),
region: card.querySelector('.tw-region'),
};
}
/**
* Track length in seconds for the trim system. Returns 0 (not NaN) until the audio
* element reports a finite duration, so callers bail cleanly until it's known — the
* loadedmetadata listener re-runs the trim layout once it lands (for AIFF, that's
* when the transcoded WAV loads, since Chromium can't decode the source AIFF
* directly).
* @param {object|undefined} t - Track entry from trackMap.
* @returns {number} Duration in seconds, or 0 while unknown.
*/
function trackDuration(t) {
return (t?.audio?.duration && isFinite(t.audio.duration)) ? t.audio.duration : 0;
}
/**
* Lay out one track's trim handles from its stored trim state. No-op until the
* track's duration is known.
* @param {number} id - Track id in trackMap.
* @returns {void}
*/
function updateTrackTrimUI(id) {
const t = trackMap.get(id);
const dur = trackDuration(t);
if (!dur) return;
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
if (!card) return;
layoutTrim(trackTrimEls(card), (t.trimStart || 0) / dur, (t.trimEnd ?? dur) / dur);
}
/**
* The furthest a track's Out point may reach given the active video section,
* measured from the track's own In point: audioIn + (vOut − musicStart). Infinity
* when no video is loaded. The track only occupies [musicStart, vOut] of the
* picture, so pushing the Music Start marker right SHRINKS this cap — which is what
* pulls every track's effective Out handle inward live (via syncTrackEndToVideo) and
* what caps each track's own Out drag (via getMaxEnd).
* @param {object|undefined} t - Track entry from trackMap.
* @returns {number} Cap in track-timeline seconds, or Infinity.
*/
function videoCapForTrack(t) {
if (!t || !(videoLoaded && videoEl.duration && isFinite(videoEl.duration))) return Infinity;
return Timeline.videoCapFor({ audioIn: t.trimStart || 0, musicStart: mStart(), videoOut: vOut() });
}
/**
* Recompute a track's *effective* Out handle (trimEnd) from two inputs:
* trimEndTarget — the user's saved custom end (null = "use the whole track") — and
* videoCapForTrack — the ceiling imposed by the active video section. The effective
* end is the smaller of the two, so the handle only moves inward when the target
* would run past the video end, and relaxes back toward the saved target as the
* section grows. Stored as null when it reaches the track's full duration. Always
* relays out the handles, so it doubles as the "lay out once duration is known" call.
* @param {number} id - Track id in trackMap.
* @returns {void}
*/
function syncTrackEndToVideo(id) {
const t = trackMap.get(id);
const dur = trackDuration(t);
if (!dur) return;
const target = t.trimEndTarget ?? dur;
const effective = Math.min(target, videoCapForTrack(t));
t.trimEnd = (effective >= dur) ? null : effective;
updateTrackTrimUI(id);
}
/**
* Re-run the effective-Out auto-set for every loaded track — called on video load
* and on each video trim-handle change so the audio Out handles follow the active
* section live.
* @returns {void}
*/
function syncAudioEndsToVideo() {
trackMap.forEach((_t, id) => syncTrackEndToVideo(id));
}
/** MARK: Trim-handle frame preview */
let _fpSeeking = false, _fpPending = null;
/**
* Paint the frame at a given time into the popover canvas, sourced from the hidden
* #scrubVideo (a second decoder on the same file) so the MAIN player is never
* disturbed. Seeks are COALESCED: while one is in flight only the newest target is
* remembered and fired when the current seek resolves, so a fast drag catches up to
* the cursor instead of grinding through every intermediate position.
* @param {number} sec - Video time in seconds whose frame to paint.
* @returns {void}
*/
function paintPreviewFrame(sec) {
if (!scrubVideo.src) return;
if (_fpSeeking) { _fpPending = sec; return; }
_fpSeeking = true;
const draw = () => {
const ctx = framePreviewCanvas.getContext('2d');
ctx.drawImage(scrubVideo, 0, 0, framePreviewCanvas.width, framePreviewCanvas.height);
_fpSeeking = false;
if (_fpPending != null) { const next = _fpPending; _fpPending = null; paintPreviewFrame(next); }
};
scrubVideo.addEventListener('seeked', draw, { once: true });
scrubVideo.currentTime = Math.max(0, sec);
}
/**
* True when the main player is running (full preview OR bare video playback).
* @returns {boolean} Whether anything is playing.
*/
const isPlaying = () => isPreviewing || !videoEl.paused;
/**
* Show the frame at a given time for a video/OA handle or marker drag. Paused: move
* the MAIN player to it (full-size, familiar), parking the playhead on the cut point
* so the user sees it. Playing: the pinned thumbnail only, so playback isn't
* interrupted. The thumbnail sits in the filename/readout slot, so both step aside
* while it's up.
* @param {number} sec - Video time in seconds.
* @param {string} [caption=''] - Optional text rendered under the thumbnail (the
* marker passes its time there, since the thumbnail occupies the readout's corner
* while it's up).
* @returns {void}
*/
function previewFrameAt(sec, caption = '') {
if (isPlaying()) {
framePreviewCap.textContent = caption;
framePreview.hidden = false;
videoFilename.style.visibility = 'hidden';
videoMarkerReadout.classList.remove('visible');
paintPreviewFrame(sec);
} else {
videoEl.currentTime = sec;
_anchorVideo();
}
}
/**
* Hide the frame-preview thumbnail and give the filename its corner back.
* @returns {void}
*/
function hideFramePreview() {
framePreview.hidden = true;
framePreviewCap.textContent = '';
videoFilename.style.visibility = '';
}
/**
* Generic drag wiring for an In/Out trim-handle pair (plus an optional draggable
* band). opt supplies the bar element to measure against, the two handles, a tooltip
* element, the duration, and getters/setters for the trim state — so the same code
* drives both the video and per-track trims. Handles enforce a minimum In/Out gap,
* cap Out at getMaxEnd (the video-section cap for tracks; the full duration for the
* video bar, where getMaxEnd is omitted), and snap Out to "full extent" (null) when
* dragged to the end of the allowed range. Double-click resets a handle to its
* full-extent default (In to 0, Out to null). During a drag _trimDragging is set;
* it's cleared on a deferred timeout so the click event that follows mouseup is
* still suppressed. On release resyncAfterTrimRelease() re-syncs the previewing
* track to the new trim offset and jumps the playhead back into the window if it
* now sits outside it (the video keeps playing through the drag).
*
* setStart/setEnd receive a second argument naming the gesture ('in' | 'out' |
* 'slide') so a consumer can react differently to "one edge moved" vs "the whole
* window slid" — the video call sites use it to decide whether the Music Start
* marker is pushed or rides along. The per-track trims declare one parameter and
* simply drop it; they have nothing that rides.
* @param {object} opt - Wiring options.
* @param {HTMLElement} opt.rectEl - Bar element measured for pixel-to-seconds math.
* @param {HTMLElement} opt.handleIn - In handle.
* @param {HTMLElement} opt.handleOut - Out handle.
* @param {HTMLElement} [opt.handleRegion] - Draggable band between the handles.
* @param {HTMLElement} opt.tooltipEl - Tooltip shown while dragging.
* @param {function(): number} opt.getDuration - Duration of the trimmed media in seconds.
* @param {function(): number} opt.getStart - Current In point in seconds.
* @param {function(): (number|null)} opt.getEnd - Current Out point (null = full extent).
* @param {function(): number} [opt.getMaxEnd] - Ceiling for the Out handle.
* @param {function(number, string): void} opt.setStart - Write the In point.
* @param {function((number|null), string): void} opt.setEnd - Write the Out point.
* @param {function(): void} opt.relayout - Re-lay-out the handles after a state change.
* @param {function(number): void} [opt.onPreview] - Video/OA handles only: show the
* frame at this point (pinned thumbnail while playing, main-player seek while
* paused). Per-track audio handles omit it.
* @param {function(number): void} [opt.onRegionClick] - Click-to-seek handler for
* a band click that never became a drag.
* @returns {void}
*/
function attachTrimDrag(opt) {
function bind(handle, isIn) {
handle.addEventListener('mousedown', e => {
e.stopPropagation(); // don't let the bar's click-to-seek fire
e.preventDefault(); // don't start a text selection
const dur = opt.getDuration();
if (!dur || !isFinite(dur)) return;
_trimDragging = true;
handle.classList.add('dragging');
opt.tooltipEl.classList.add('visible');
const rect = opt.rectEl.getBoundingClientRect();
const gap = Math.max(0.05, 0.01 * dur); // keep In and Out at least this far apart
function move(ev) {
let sec = clamp01((ev.clientX - rect.left) / rect.width) * dur;
if (isIn) {
const end = opt.getEnd() ?? dur;
sec = Math.max(0, Math.min(sec, end - gap));
opt.setStart(sec, 'in');
} else {
const start = opt.getStart() || 0;
const maxEnd = opt.getMaxEnd ? opt.getMaxEnd() : dur;
sec = Math.max(sec, start + gap);
sec = Math.min(sec, maxEnd);
opt.setEnd(sec >= maxEnd - gap ? null : sec, 'out');
}
opt.relayout();
opt.tooltipEl.style.left = (clamp01(sec / dur) * 100) + '%';
opt.tooltipEl.textContent = fmt(sec);
opt.onPreview && opt.onPreview(sec);
}
function up() {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
handle.classList.remove('dragging');
opt.tooltipEl.classList.remove('visible');
if (opt.onPreview) hideFramePreview();
resyncAfterTrimRelease();
// Defer clearing so the click event that follows mouseup is still suppressed
setTimeout(() => { _trimDragging = false; }, 0);
}
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
handle.addEventListener('dblclick', e => {
e.stopPropagation();
e.preventDefault();
if (isIn) opt.setStart(0); else opt.setEnd(null);
opt.relayout();
});
}
/**
* Wire the draggable band: dragging slides the whole trim WINDOW by its midpoint —
* both edges move together, width fixed. A press that never crosses the 3px
* movement threshold is treated as a click and forwarded to opt.onRegionClick, so
* click-to-seek inside the window still works. The position ceiling for the Out
* edge is the full duration: the per-track video cap limits the window's *width*,
* not where it sits — and width is preserved throughout the drag, so a window that
* fits the video section stays valid wherever it slides. Out snaps to "full extent"
* (null) only at the true end. The tooltip is centred over the band and shows the
* window as "M:SS – M:SS"; the leading (In) edge is previewed as the window slides
* (the thumbnail is pinned to the picture's corner, so there's nothing to
* position). On release resyncAfterTrimRelease() re-aligns the previewing track
* and jumps the playhead back inside if the slide carried the window past it.
* @param {HTMLElement} region - The band element.
* @returns {void}
*/
function bindRegion(region) {
let didDrag = false;
region.addEventListener('mousedown', e => {
e.preventDefault(); // don't start a text selection
const dur = opt.getDuration();
if (!dur || !isFinite(dur)) return;
const rect = opt.rectEl.getBoundingClientRect();
const gap = Math.max(0.05, 0.01 * dur);
const startX = e.clientX;
const base0 = opt.getStart() || 0;
const base1 = opt.getEnd() ?? dur;
const width = base1 - base0;
const effMax = dur;
let dragging = false;
didDrag = false;
function move(ev) {
const dx = ev.clientX - startX;
if (!dragging && Math.abs(dx) < 3) return; // below threshold → still a click, not a drag
if (!dragging) {
dragging = true; didDrag = true; _trimDragging = true;
region.classList.add('dragging');
opt.tooltipEl.classList.add('visible');
}
const deltaSec = (dx / rect.width) * dur;
const newStart = Math.max(0, Math.min(base0 + deltaSec, effMax - width));
const newEnd = newStart + width;
opt.setStart(newStart, 'slide');
opt.setEnd(newEnd >= dur - gap ? null : newEnd, 'slide');
opt.relayout();
opt.tooltipEl.style.left = (clamp01(((newStart + newEnd) / 2) / dur) * 100) + '%';
opt.tooltipEl.textContent = fmt(newStart) + ' – ' + fmt(newEnd);
opt.onPreview && opt.onPreview(newStart);
}
function up() {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
region.classList.remove('dragging');
opt.tooltipEl.classList.remove('visible');
if (opt.onPreview) hideFramePreview();
resyncAfterTrimRelease();
setTimeout(() => { _trimDragging = false; }, 0);
}
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
// A click that never became a drag = a seek. stopPropagation keeps the scrub bar's own
// click handler from double-firing; OA/track seek via onRegionClick (their click handlers
// live on the canvas, which the region doesn't bubble up to).
region.addEventListener('click', e => {
e.stopPropagation();
if (didDrag) return;
opt.onRegionClick && opt.onRegionClick(e.clientX);
});
}
bind(opt.handleIn, true);
bind(opt.handleOut, false);
if (opt.handleRegion) bindRegion(opt.handleRegion);
}
/** MARK: Preview trim bounds */
/**
* Video trim In point in seconds (0 when untrimmed).
* @returns {number}
*/
const vIn = () => videoTrimStart || 0;
/**
* Video trim Out point in seconds; defaults to the full duration when untrimmed.
* @returns {number}
*/
const vOut = () => (videoTrimEnd ?? videoEl.duration);
/**
* A track's audio In point in seconds (0 when untrimmed).
* @param {object|undefined} t - Track entry from trackMap.
* @returns {number}
*/
const aIn = t => (t?.trimStart || 0);
/**
* A track's effective audio Out point in seconds; falls back to the track duration
* (or Infinity while unknown) when untrimmed.
* @param {object|undefined} t - Track entry from trackMap.
* @returns {number}
*/
const aOut = t => (t?.trimEnd ?? (trackDuration(t) || Infinity));
/**
* Map the video clock to the active track's own timeline so the track's In point
* plays at the MUSIC START marker (which defaults to the video In, matching export).
* Identity (trackTime === videoTime) when nothing is trimmed and no marker is placed.
*
* A video time BEFORE the marker maps BELOW the track's In point. That's not an edge
* case to guard against — it's the signal every playback path uses to mean "the
* music hasn't come in yet" (see the pre-marker branches in syncActiveTrackToVideo /
* setActiveTrack and the rAF audibility gate, all of which test tt < aIn(t)).
* @param {object|undefined} t - Track entry from trackMap.
* @param {number} videoTime - Video-timeline position in seconds.
* @returns {number} Track-timeline position in seconds.
*/
const trackTimeFor = (t, videoTime) =>
Timeline.trackTimeFor({ audioIn: aIn(t), musicStart: mStart(), videoTime });
/**
* The inverse of trackTimeFor — a moment in the SONG mapped to the video clock
* position that plays it. Needed by track-waveform click-to-seek, which measures in
* track time while doSeek() speaks video time.
* @param {object|undefined} t - Track entry from trackMap.
* @param {number} trackTime - Track-timeline position in seconds.
* @returns {number} Video-timeline position in seconds.
*/
const videoTimeForTrack = (t, trackTime) =>
Timeline.videoTimeForTrack({ audioIn: aIn(t), musicStart: mStart(), trackTime });
/** MARK: MUSIC START MARKER */
/**
* Minimum separation the trim engine keeps between In and Out — reused so the
* marker can never land on top of the Out handle's grab zone. Same expression as
* attachTrimDrag's local gap.
* @returns {number} Gap in seconds.
*/
const trimGap = () => Math.max(0.05, 0.01 * (videoEl.duration || 0));
/**
* The effective Music Start on the video timeline; reads as the video In point when
* the marker was never placed.
*
* Clamped on READ as well as on write. The trim-handle drags push the marker
* explicitly through their setStart/setEnd, so the two never disagree during a
* gesture — this guard is for the paths nobody pushed through: project restore, a
* proxy swap, a window that shrank around it. The !isFinite check matters: with no
* video loaded vOut() is videoEl.duration = NaN, and an unguarded Math.min would
* poison trackTimeFor with NaN for every track.
* @returns {number} Effective marker position in seconds.
*/
const mStart = () => {
const inPt = vIn();
if (musicStartTime == null || !isFinite(vOut())) return inPt;
return Timeline.clampMusicStart(musicStartTime, inPt, vOut(), trimGap());
};
/**
* Where to park a track's audio element for a given video time: its offset position,
* clamped into the track's own [In, duration]. Before the Music Start marker that's
* the In point — the track waits there, pre-seeked and silent, until the marker
* arrives.
* @param {object|undefined} t - Track entry from trackMap.
* @param {number} videoTime - Video-timeline position in seconds.
* @returns {number} Track-timeline park position in seconds.
*/
function parkTrackAt(t, videoTime) {
const dur = trackDuration(t) || Infinity;
return Math.max(aIn(t), Math.min(trackTimeFor(t, videoTime), dur));
}
/**
* SINGLE writer for the Music Start marker (null = "not placed"). Always clamps,
* relays out both surfaces, and re-fits every track's Out handle — moving the marker
* changes videoCapForTrack for every loaded track, so syncAudioEndsToVideo lives
* here rather than at each call site.
* @param {number|null} sec - New marker position in seconds, or null to unset.
* @returns {void}
*/
function setMusicStart(sec) {
musicStartTime = sec == null ? null : Timeline.clampMusicStart(sec, vIn(), vOut(), trimGap());
updateMusicStartUI();
syncAudioEndsToVideo();
}
/**
* Show the Music Start readout over the picture. The filename occupies the same
* slot, so it steps aside while the readout is up rather than stacking underneath
* it — via visibility, not display: loadVideoDescriptor owns display and must not be
* fought over.
* @param {number} sec - Marker time to display.
* @returns {void}
*/
function showMarkerReadout(sec) {
videoMarkerReadout.textContent = 'Music Start · ' + fmt(sec);
videoMarkerReadout.classList.add('visible');
videoFilename.style.visibility = 'hidden';
}
/**
* Hide the Music Start readout and restore the filename's visibility.
* @returns {void}
*/
function hideMarkerReadout() {
videoMarkerReadout.classList.remove('visible');
videoFilename.style.visibility = '';
}
/**
* Drag wiring for the Music Start marker. Deliberately NOT folded into
* attachTrimDrag: that engine also drives every per-track audio trim, which must
* never touch the marker, and the marker has no partner handle, no width to
* preserve, and no "snap to full extent" null state. It reuses the same
* _trimDragging / .dragging / resyncAfterTrimRelease conventions, so the two feel
* identical to use — but the time readout goes over the picture, not into a handle
* tooltip, so it sits beside the frame it's choosing.
*
* Setting _trimDragging during the gesture is intentional and does three things: it
* suppresses the click that follows mouseup, and it pauses the rAF loop's
* stop-at-Out and audibility blocks — so the active track keeps playing at its
* pre-drag position through the gesture instead of stuttering on every mousemove,
* and resyncAfterTrimRelease() settles it on release (it may need to fall silent, or
* to come in immediately). Same deal the trim handles already get.
*
* Each mousemove routes through setMusicStart, which clamps into [vIn, vOut − gap],
* relays out BOTH markers, and re-fits every track's Out handle — so the track cards
* visibly follow the drag. The drag also shows the same frame affordance as the
* video In/Out handles (pinned thumbnail while playing, main-player seek while
* paused): "where in the picture does the music come in" is exactly a frame
* decision, so this matters more here than on the trim handles. Paused, the readout
* lands directly on the frame it names; playing, the thumbnail takes that corner and
* carries the same text as its caption, so nothing is lost either way. Double-click
* resets the marker to "not placed" — i.e. back to the video In point.
* @param {object} opt - Wiring options.
* @param {HTMLElement} opt.rectEl - Bar element measured for pixel-to-seconds math
* (cached once at mousedown, like attachTrimDrag).
* @param {HTMLElement} opt.marker - The marker element to wire.
* @returns {void}
*/
function attachMarkerDrag(opt) {
const { rectEl, marker } = opt;
marker.addEventListener('mousedown', e => {
e.stopPropagation(); // don't let the bar's click-to-seek or the region's drag fire
e.preventDefault(); // don't start a text selection
const dur = videoEl.duration;
if (!videoLoaded || !dur || !isFinite(dur)) return;
_trimDragging = true;
marker.classList.add('dragging');
showMarkerReadout(mStart());
const rect = rectEl.getBoundingClientRect();
function move(ev) {
setMusicStart(clamp01((ev.clientX - rect.left) / rect.width) * dur);
const sec = mStart();
showMarkerReadout(sec);
previewFrameAt(sec, 'Music Start · ' + fmt(sec));
}
function up() {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
marker.classList.remove('dragging');
hideMarkerReadout();
hideFramePreview();
resyncAfterTrimRelease();
// Defer clearing so the click event that follows mouseup is still suppressed
setTimeout(() => { _trimDragging = false; }, 0);
}
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
marker.addEventListener('dblclick', e => {
e.stopPropagation();
e.preventDefault();
setMusicStart(null);
resyncAfterTrimRelease();
});
}
/** MARK: TIME FORMAT */
/**
* Format seconds as M:SS for tooltips and readouts. Non-finite input reads "0:00".
* @param {number} s - Time in seconds.
* @returns {string} Formatted time.
*/
function fmt(s) {
if (!isFinite(s)) return '0:00';
const m = Math.floor(s / 60);
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}
/** MARK: TRACK FADE HELPERS */
/** Length of the music tail fade in seconds — shared by preview and export. */
const TRACK_FADE_SEC = TrackFade.TRACK_FADE_SEC;
/**
* Time constant for smoothing a live gain change (slider drags) — short enough to
* feel instant, long enough not to click.
* @type {number}
*/
const GAIN_SMOOTH_SEC = 0.005;
/**
* Schedule a fade-out so the track lands on silence at the video's end. Only fades
* when the track's content runs past the trimmed video window — its Out gets capped
* to the window end (a hard cut that needs de-clicking). videoCapForTrack folds in
* both the track's In point and the window length, so a trimmed-in video (vIn > 0)
* is handled too. The event schedule lives in TrackFade.computeFadeSchedule; this
* just replays it onto the gain node.
* @param {object|undefined} t - Track entry from trackMap.
* @param {number} videoCurrentTime - Current video time in seconds.
* @param {number} videoDuration - End of the video window in seconds (vOut).
* @param {object} [opts] - Overrides.
* @param {number} [opts.gain] - Level the fade holds at, overriding gain.value — a
* slider's TARGET value, which gain.value doesn't yet read as mid-glide.
* @param {number} [opts.rampSec=0] - Glide to the hold level over this many seconds
* instead of jumping.
* @returns {boolean} True if a fade was actually scheduled — setTrackGain uses this
* to know whether the schedule already wrote the new level, or whether it still
* has to write it itself.
*/
function scheduleTrackFade(t, videoCurrentTime, videoDuration, { gain, rampSec = 0 } = {}) {
if (!t?.gainNode || !t.audio.duration) return false;
const intendedOut = t.trimEndTarget ?? t.audio.duration;
if (intendedOut <= videoCapForTrack(t)) return false; // track ends within the window — no fade
const audioCtx = getAudioCtx();
const events = TrackFade.computeFadeSchedule({
currentGain: gain ?? t.gainNode.gain.value,
videoCurrentTime,
videoDuration,
now: audioCtx.currentTime,
rampSec,
});
for (const e of events) {
if (e.method === 'cancelScheduledValues') t.gainNode.gain.cancelScheduledValues(e.time);
else if (e.method === 'setValueAtTime') t.gainNode.gain.setValueAtTime(e.value, e.time);
else if (e.method === 'setTargetAtTime') t.gainNode.gain.setTargetAtTime(e.value, e.time, e.timeConstant);
else if (e.method === 'linearRampToValueAtTime') t.gainNode.gain.linearRampToValueAtTime(e.value, e.time);
}
return true;
}
/**
* Write a track's preview gain, keeping any scheduled tail fade in step with it.
*
* The fade is laid down AHEAD of time as absolute Web Audio events (at preview start
* and on every seek): hold at the gain that was current then, until 0.5 s before the
* video window ends, then ramp to silence. A later Auto-Mix or slider move that
* writes only "now" leaves that stale hold value sitting in the future, so the level
* jumped back the instant the fade began — issue #342. Rebuilding the schedule from
* the new value keeps the fade continuous.
*
* Only the active track during preview can have live fade events on its timeline;
* every other write is a plain set. Past the window end there's no fade left to
* rebuild — and rescheduling there would put the ramp in the past; cancelTrackFade
* restores the slider level on stop anyway. Rescheduling writes the new level itself
* (its anchor event), so the bare write only happens when there was no fade to
* rebuild.
* @param {object|undefined} t - Track entry from trackMap.
* @param {number} id - Track id in trackMap.
* @param {number} linear - New linear gain value.
* @param {object} [opts] - Options.
* @param {boolean} [opts.smooth=false] - Glide to the value instead of jumping
* (slider drags).
* @returns {void}
*/
function setTrackGain(t, id, linear, { smooth = false } = {}) {
if (!t?.gainNode) return;
const live = isPreviewing && id === activeTrackId && videoEl.currentTime < vOut();
const rampSec = smooth ? GAIN_SMOOTH_SEC : 0;
if (live && scheduleTrackFade(t, videoEl.currentTime, vOut(), { gain: linear, rampSec })) return;
if (smooth) t.gainNode.gain.setTargetAtTime(linear, getAudioCtx().currentTime, GAIN_SMOOTH_SEC);
else t.gainNode.gain.value = linear;
}
/**
* Seconds of linear tail fade-out to apply to a track on export. Matches the preview
* fade in scheduleTrackFade: only when the track outlasts the trimmed video window —
* its intended Out (user target, or full duration) runs past the window cap and gets
* hard-cut at the video Out. Otherwise 0 — a track that ends inside the window fades
* naturally.
* @param {object} t - Track entry from trackMap.
* @returns {number} Fade length in seconds (TRACK_FADE_SEC or 0).
*/
function exportFadeOutFor(t) {
const dur = trackDuration(t);
if (!dur) return 0;
const intendedOut = t.trimEndTarget ?? dur;
return intendedOut > videoCapForTrack(t) ? TRACK_FADE_SEC : 0;
}
/** MARK: EXPORT METADATA */
/**
* Output containers that can be tagged. MP4 and MOV both tag through mutagen's MP4
* writer — they share the same ISO/iTunes atom layout (MOV being mutagen-readable is
* under empirical test). Any other container is skipped so spurious failures aren't
* logged for formats mutagen can't tag.
* @type {RegExp}
*/
const TAGGABLE_OUTPUT_EXT = /\.(mp4|mov)$/i;
/**
* Translate the persisted Settings into the worker's MP4 tag vocabulary, dropping
* any empty/unset field so only tags the user actually filled in are written. The
* worker keys off these names via the T table in mp4_metadata.py; for artwork it
* reads the image bytes from the given path.
* @param {object} cfg - Settings object from window.ezl.getSettings().
* @returns {object} Tag map for the update_metadata worker message.
*/
function buildExportMetadata(cfg) {
const m = cfg?.metadata ?? {};
const out = {};
if (m.artist) out.artist = m.artist;
if (m.album) out.album = m.album;
if (m.composer) out.composer = m.composer;
if (m.grouping) out.grouping = m.grouping;
if (m.comments) out.comments = m.comments;
if (m.artworkPath) out.artwork = m.artworkPath;
return out;
}
/**
* After a successful export, stamp metadata onto each output file via a SEPARATE
* worker message (update_metadata): ffmpeg writes the file, then mutagen patches its
* tags. Always sends — even with no user fields — because the worker also adds the
* branded "Created By" tag (ADD_CREATED_BY_TAG). Containers mutagen can't tag are
* skipped. Failures are non-fatal: the layback already saved.
* @param {string[]} outputPaths - Paths of the exported files.
* @param {object} cfg - Settings object feeding buildExportMetadata.
* @returns {Promise<void>}
*/
async function applyExportMetadata(outputPaths, cfg) {
const metadata = buildExportMetadata(cfg);
for (const path of outputPaths) {
if (!TAGGABLE_OUTPUT_EXT.test(path)) continue;
try {
const res = await window.ezl.updateMetadata({ path, metadata });
if (res.status !== 'ok') console.error('[metadata] failed for', path, res.error);
} catch (err) {
console.error('[metadata] error for', path, err);
}
}
}
/**
* Cancel any scheduled fade and snap the gain back to the track's slider value
* (immediate restore — no ramp needed). Called when pausing, stopping, or switching
* tracks so a mid-fade state is never left hanging on a gain node.
* @param {object|undefined} t - Track entry from trackMap.
* @param {number} id - Track id, used to find the card's slider.
* @returns {void}
*/
function cancelTrackFade(t, id) {
if (!t?.gainNode) return;
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
const slider = card?.querySelector('.s-trim');
const target = slider ? Math.pow(10, parseFloat(slider.value) / 20) : 1;
const audioCtx = getAudioCtx();
t.gainNode.gain.cancelScheduledValues(audioCtx.currentTime);
t.gainNode.gain.value = target;
}
/** MARK: STOP ALL PLAYBACK */
/**
* Pause everything: the video, every track, and the replacement OA (which follows
* the transport). Cancels any scheduled gain fades (restoring each track to its
* slider value), cancels any in-flight rAF frame so startWaveformAnimation can
* restart cleanly, and resets the preview state and the active card's Preview
* button.
* @returns {void}
*/
function pauseAll() {
if (!videoEl.paused) { videoEl.pause(); btnPlay.innerHTML = '<span>▶</span>'; }
trackMap.forEach(({ audio }) => { if (!audio.paused) audio.pause(); });
if (oaReplaceAudio && !oaReplaceAudio.paused) oaReplaceAudio.pause();
trackMap.forEach((t, id) => cancelTrackFade(t, id));
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
if (isPreviewing) {
isPreviewing = false;
const btn = getActivePreviewBtn();
if (btn) {
btn.innerHTML = `<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor"><polygon points="0,0 9,5 0,10"/></svg> Preview`;
btn.classList.remove('previewing');
}
}
}
/** MARK: VIDEO — player, scrub bar, and OA-surface DOM references. */
const videoDrop = document.getElementById('videoDrop');
const videoEl = document.getElementById('videoEl');
const videoRemove = document.getElementById('videoRemove');
const videoFilename = document.getElementById('videoFilename');
const videoFileInput= document.getElementById('videoFileInput');
const scrub = document.getElementById('scrub');
const scrubFill = document.getElementById('scrubFill');
const scrubHead = document.getElementById('scrubHead');
const scrubTime = document.getElementById('scrubTime');
const scrubDimL = document.getElementById('scrubDimL');
const scrubDimR = document.getElementById('scrubDimR');
const scrubTrimIn = document.getElementById('scrubTrimIn');
const scrubTrimOut = document.getElementById('scrubTrimOut');
const scrubTrimTip = document.getElementById('scrubTrimTip');
const scrubRegion = document.getElementById('scrubRegion');
const oaWrap = document.getElementById('oaWrap');
const oaDimL = document.getElementById('oaDimL');
const oaDimR = document.getElementById('oaDimR');
const oaTrimIn = document.getElementById('oaTrimIn');
const oaTrimOut = document.getElementById('oaTrimOut');
const oaTrimTip = document.getElementById('oaTrimTip');
const oaRegion = document.getElementById('oaRegion');
/**
* Music Start marker elements — one on each surface, both driven from the same
* musicStartTime. The readout is a single overlay on the PICTURE (shared by both
* markers), not a per-handle tooltip: the frame you land on is the decision, so the
* time belongs next to it.
*/
const scrubMusicMarker = document.getElementById('scrubMusicMarker');
const oaMusicMarker = document.getElementById('oaMusicMarker');
const videoMarkerReadout = document.getElementById('videoMarkerReadout');
const btnPlay = document.getElementById('btnPlay');
const btnRestart = document.getElementById('btnRestart');
const btnLoop = document.getElementById('btnLoop');
const statusText = document.getElementById('statusText');
// Honour the root flag: keep the status element in the DOM (so all the
// statusText.textContent writes keep working) but hide it unless flagged on.
statusText.style.display = SHOW_STATUS_TEXT ? '' : 'none';
const errorModal = document.getElementById('errorModal');
/**
* Most recent error captured for the Contact Support form (single snapshot,
* in-memory only — cleared on app relaunch). Every error flows through showError(),
* so recording it there covers all call sites with no extra wiring. The support
* form's "Paste most recent error" button reads this on demand.
* @type {{timestamp: string, msg: string, detail: (string|null)}|null}
*/
let lastDiagnosticError = null;
/**
* Overt error feedback: pops the error modal AND updates the (possibly hidden)
* status line, so behaviour stays consistent whether or not SHOW_STATUS_TEXT is on.
* Raw technical detail (e.g. FFmpeg stderr) goes in a disclosure that starts
* collapsed each time, only shown when it adds something beyond the friendly
* message. Also records the error to lastDiagnosticError.
* @param {string} msg - Friendly, user-facing message.
* @param {string} [detail] - Raw technical detail for the collapsed disclosure.
* @returns {void}
*/
function showError(msg, detail) {
lastDiagnosticError = { timestamp: new Date().toISOString(), msg, detail: detail ?? null };
statusText.textContent = msg;
document.getElementById('errorMessage').textContent = msg;
const detailEl = document.getElementById('errorDetail');
if (detail && detail !== msg) {
document.getElementById('errorDetailText').textContent = detail;
detailEl.open = false;
detailEl.style.display = '';
} else {
detailEl.style.display = 'none';
}
if (!errorModal.open) errorModal.showModal();
}
// Copy button lives inside the <summary>; preventDefault keeps the click from
// toggling the <details>.
document.getElementById('btnErrorCopy').addEventListener('click', e => {
e.preventDefault();
navigator.clipboard.writeText(document.getElementById('errorDetailText').textContent);
});
document.getElementById('btnErrorOk').addEventListener('click', () => errorModal.close());
document.getElementById('btnErrorClose').addEventListener('click', () => errorModal.close());
const noticeModal = document.getElementById('noticeModal');
/**
* Positive counterpart to showError: same modal treatment, custom title, no
* technical-detail disclosure, and nothing recorded to lastDiagnosticError.
* @param {string} title - Modal title.
* @param {string} msg - Message body.
* @returns {void}
*/
function showNotice(title, msg) {
document.getElementById('noticeTitle').textContent = title;
document.getElementById('noticeMessage').textContent = msg;
if (!noticeModal.open) noticeModal.showModal();
}
document.getElementById('btnNoticeOk').addEventListener('click', () => noticeModal.close());
document.getElementById('btnNoticeClose').addEventListener('click', () => noticeModal.close());
const expiredModal = document.getElementById('expiredModal');
/**
* Show the expired-subscription modal — a save was attempted without a live
* subscription. Carries the launch notice's copy and Manage Account action as an
* in-app modal (shown by requireCanSave()).
* @param {string} msg - Message explaining why saving is blocked.
* @returns {void}
*/
function showExpired(msg) {
document.getElementById('expiredMessage').textContent = msg;
if (!expiredModal.open) expiredModal.showModal();
}
document.getElementById('btnExpiredClose').addEventListener('click', () => expiredModal.close());
// Manage: same SSO handoff as the account modal.
document.getElementById('btnExpiredManage').addEventListener('click', () => {
window.ezl.openAccountPortal();
expiredModal.close();
});
/**
* MARK: SETTINGS / USER / HELP MODALS — these three live in their own files
* (src/partials/*.html) for ease of editing. Open via showModal(), close via
* .close(); Escape works natively (dialog 'cancel' event). Settings persistence is
* backed by window.ezl.getSettings()/setSettings() (main-process JSON store in
* userData); the modal repopulates from the store every time it opens, so edits that
* end with Cancel/Escape are simply discarded. Integrations (Frame.io/DISCO) and the
* App update toggle stay visual-only for now: they depend on OAuth and the updater
* flows that aren't built, so they're not bound.
*/
/**
* Inject the settings/user/help modal partials and wire their behaviour. The
* renderer can't fetch() local HTML under file:// + contextIsolation, so the main
* process reads each partial (window.ezl.loadPartial) and the returned markup is
* injected here. IMPORTANT: style tags injected via innerHTML DO apply, but script
* tags do NOT run — so all page behaviour is wired after injection, by the init*
* functions. initHelpModal is awaited because it fetches and renders help.md into
* the help.html shell.
* @returns {Promise<void>}
*/
async function mountModals() {
const host = document.getElementById('modalHost');
const names = ['settings', 'user', 'help'];
const htmls = await Promise.all(names.map(n => window.ezl.loadPartial(n)));
host.innerHTML = htmls.join('\n');
initSettingsModal();
initUserModal();
await initHelpModal();
}
/**
* Wire backdrop click-to-close on a dialog. The dialogs have padding:0, so a click
* whose target is the dialog element itself can only have landed on the ::backdrop.
* Armed on pointerdown (not just click) so a drag that STARTS inside the modal —
* e.g. selecting text in an input — and releases outside doesn't count as an outside
* click and dismiss the modal.
* @param {HTMLDialogElement} modal - Dialog to wire.
* @param {function(): void} [close] - Close action; defaults to modal.close().
* @returns {void}
*/
function closeOnBackdropClick(modal, close = () => modal.close()) {
let armedOnBackdrop = false;
modal.addEventListener('pointerdown', (e) => { armedOnBackdrop = (e.target === modal); });
modal.addEventListener('click', (e) => {
if (armedOnBackdrop && e.target === modal) close();
armedOnBackdrop = false;
});
}
/** MARK: Settings modal */
/**
* Wire the Settings modal: default save folder (native picker + reset-to-Downloads),
* layback filename affix with live preview, export metadata fields, cover artwork
* (click or drag-and-drop), and the auto-update switch. Populates from the persisted
* store on every open; Done (or clicking outside) saves, Cancel/Escape discards.
* @returns {void}
*/
function initSettingsModal() {
const modal = document.getElementById('settingsModal');
const folderPath = document.getElementById('setFolderPath');
const folderChange = document.getElementById('setFolderChange');
const folderReset = document.getElementById('setFolderReset');
const text = document.getElementById('setNameText');
const before = document.getElementById('setNameBefore');
const after = document.getElementById('setNameAfter');
const preview = document.getElementById('setNamePreview');
const metaArtist = document.getElementById('setMetaArtist');
const metaAlbum = document.getElementById('setMetaAlbum');
const metaComposer = document.getElementById('setMetaComposer');
const metaGrouping = document.getElementById('setMetaGrouping');
const metaComments = document.getElementById('setMetaComments');
const artDrop = document.getElementById('setArtDrop');
const artInput = document.getElementById('setArtInput');
const artName = document.getElementById('setArtName');
const artPreview = document.getElementById('ArtPreviewImg'); // <img> for the cover artwork preview
const setArtPreview = document.getElementById('setArtPreview'); // div that wraps the <img>
const artRemove = document.getElementById('setArtRemove'); // × overlay that clears the artwork
const autoUpdate = document.getElementById('setAutoUpdate'); // .switch — app.checkUpdatesOnLaunch
/*
* Working state for controls whose value isn't simply an input's .value:
* - chosenFolder: the absolute path picked via the native dialog this session.
* - folderChanged: did the user actually pick a folder? If not, defaultSaveFolder is
* OMITTED from the save patch so the store keeps its "unset → follow Downloads"
* intent (the main-process merge preserves any key absent from the patch).
* - folderIsReset: did the change come from the Reset button rather than the native
* picker? If so defaultSaveFolder is sent EXPLICITLY as null (rather than the
* resolved Downloads string) so the stored intent stays "follow Downloads",
* portable across machines — see the null comment in settings-store.js's DEFAULTS.
* - downloadsPath: the live system Downloads path, fetched on populate(); used to
* decide whether the Reset button should show at all.
* - chosenArtwork: absolute path to the cover image, or null.
*/
let chosenFolder = null;
let folderChanged = false;
let folderIsReset = false;
let downloadsPath = null;
let chosenArtwork = null;
/**
* Show the Reset button only when the current folder isn't already Downloads.
* @returns {void}
*/
function updateFolderResetVisibility() {
folderReset.style.display = (chosenFolder === downloadsPath) ? 'none' : 'inline-block';
}
/**
* Rebuild the live filename example from the affix text and Before/After toggle,
* using "My Song" as the sample track name. Built with textContent (not innerHTML)
* so a literal "<" in the affix can't break markup.
* @returns {void}
*/
function updateNamePreview() {
const extra = text.value;
const base = 'My Song';
const name = before.classList.contains('on') ? `${extra}${base}` : `${base}${extra}`;
preview.textContent = 'Example: ';
const b = document.createElement('b');
b.textContent = `${name}.mp4`;
preview.appendChild(b);
}
/**
* Swap between the artwork preview (image chosen) and the empty dropzone. Display
* is reset to '' (not 'block') so the stylesheet's flex layout centers the image
* or the "+" glyph.
* @returns {void}
*/
function renderArtwork() {
if (chosenArtwork) {
artDrop.style.display = 'none';
artName.textContent = chosenArtwork.split('/').pop();
artPreview.src = chosenArtwork ? `file://${chosenArtwork}` : '';
setArtPreview.style.display = '';
} else {
setArtPreview.style.display = 'none';
artDrop.style.display = '';
artDrop.textContent = '+';
artName.textContent = 'JPEG and PNG files supported.';
}
}
/**
* Pull current settings from the store and paint every field. Runs on each open so
* the modal always reflects what's persisted (and discards any uncommitted prior
* edits). defaultSaveFolder arrives already resolved to ~/Downloads if unset.
* @returns {Promise<void>}
*/
async function populate() {
const s = await window.ezl.getSettings();
chosenFolder = s.files.defaultSaveFolder;
folderChanged = false;
folderIsReset = false;
folderPath.textContent = chosenFolder;
downloadsPath = await window.ezl.getDownloadsPath();
updateFolderResetVisibility();
text.value = s.files.nameAffix.text;
const pos = s.files.nameAffix.position;
before.classList.toggle('on', pos !== 'after');
after.classList.toggle('on', pos === 'after');
metaArtist.value = s.metadata.artist;
metaAlbum.value = s.metadata.album;
metaComposer.value = s.metadata.composer;
metaGrouping.value = s.metadata.grouping;
metaComments.value = s.metadata.comments;
chosenArtwork = s.metadata.artworkPath;
setAutoUpdateOn(s.app.checkUpdatesOnLaunch);
renderArtwork();
updateNamePreview();
}
/**
* Drive the auto-update switch's visual and ARIA state. Only takes effect on the
* NEXT launch — updater.js reads the setting when it starts, and a running app has
* already made its decision.
* @param {boolean} on - Desired switch state.
* @returns {void}
*/
function setAutoUpdateOn(on) {
autoUpdate.classList.toggle('on', !!on);
autoUpdate.setAttribute('aria-checked', on ? 'true' : 'false');
}
autoUpdate.addEventListener('click', () => setAutoUpdateOn(!autoUpdate.classList.contains('on')));
autoUpdate.addEventListener('keydown', (e) => {
if (e.key !== ' ' && e.key !== 'Enter') return;
e.preventDefault(); // Space would scroll the modal
setAutoUpdateOn(!autoUpdate.classList.contains('on'));
});
/**
* Build the settings patch and persist it. defaultSaveFolder is only included if
* the user picked one this session (see the folderChanged note above); everything
* else round-trips by value.
* @returns {Promise<void>}
*/
async function save() {
const filesPatch = {
nameAffix: {
text: text.value,
position: before.classList.contains('on') ? 'before' : 'after',
},
};
if (folderChanged) filesPatch.defaultSaveFolder = folderIsReset ? null : chosenFolder;
await window.ezl.setSettings({
files: filesPatch,
metadata: {
artist: metaArtist.value.trim(),
album: metaAlbum.value.trim(),
composer: metaComposer.value.trim(),
grouping: metaGrouping.value.trim(),
comments: metaComments.value.trim(),
artworkPath: chosenArtwork,
},
app: { checkUpdatesOnLaunch: autoUpdate.classList.contains('on') },
});
}
// Open: repopulate first so stale DOM values never flash, then show. Fit runs
// AFTER showModal — populate() ran while the dialog was hidden (zero layout width).
document.getElementById('btnSettings').addEventListener('click', async () => {
if (modal.open) return;
await populate();
modal.showModal();
fitFolderPath(folderPath, chosenFolder);
});
// Cancel / Escape: discard — next open repopulates from the store.
['btnSettingsCancel'].forEach(id => {
document.getElementById(id).addEventListener('click', () => modal.close());
});
/**
* Persist, then close. On a write failure surface the error modal rather than
* closing silently, so the user knows their settings didn't save. Clicking outside
* the modal is equivalent to Done — it saves, not discards.
* @returns {Promise<void>}
*/
async function saveAndClose() {
try {
await save();
modal.close();
} catch (err) {
showError("Couldn't save settings.", err && err.message);
}
}
document.getElementById('btnSettingsDone').addEventListener('click', saveAndClose);
closeOnBackdropClick(modal, saveAndClose);
// Folder picker (native dialog in the main process). Cancelling keeps the current
// value; the modal is already open here, so the path fit is safe to measure.
folderChange.addEventListener('click', async () => {
const picked = await window.ezl.showFolderPicker();
if (!picked) return;
chosenFolder = picked;
folderChanged = true;
folderIsReset = false;
fitFolderPath(folderPath, picked);
updateFolderResetVisibility();
});
/*
* Reset: returns the folder to the system Downloads path. Sends defaultSaveFolder
* as null (see the folderIsReset note above) rather than the resolved path, so the
* stored intent stays "follow Downloads" instead of pinning today's Downloads
* location.
*/
folderReset.addEventListener('click', () => {
chosenFolder = downloadsPath;
folderChanged = true;
folderIsReset = true;
fitFolderPath(folderPath, chosenFolder);
updateFolderResetVisibility();
});
/*
* Cover artwork: the dropzone proxies clicks to the hidden file input; on
* selection the File resolves to an absolute path via webUtils and is stored.
* Re-clicking replaces it. renderArtwork() swaps setArtDrop out for setArtPreview
* once artwork is chosen, so the preview needs its own listener onto the same
* input or it becomes a dead end.
*/
artDrop.addEventListener('click', () => artInput.click());
setArtPreview.addEventListener('click', () => artInput.click());
/*
* Remove (×): clears the artwork and reverts to the empty dropzone. Stop
* propagation so this click doesn't also bubble up to setArtPreview's listener
* and reopen the picker.
*/
artRemove.addEventListener('click', (e) => {
e.stopPropagation();
chosenArtwork = null;
renderArtwork();
});
artInput.addEventListener('change', () => {
const file = artInput.files[0];
if (!file) return;
chosenArtwork = window.ezl.getFilePath(file);
artInput.value = ''; // reset so picking the same file again still fires change
renderArtwork();
});
/**
* Accept a dropped artwork file: validate JPEG/PNG, resolve to a path, store,
* render — mirroring the artInput change path. Drag-and-drop needs its own wiring
* because the document-level drop handler preventDefaults file drops. Drops land
* on either the empty dropzone or an existing preview (to replace it).
* @param {File|undefined} file - Dropped file.
* @returns {void}
*/
function acceptArtworkFile(file) {
if (!file) return;
if (file.type !== 'image/jpeg' && file.type !== 'image/png') {
showError('Unsupported image — cover artwork must be a JPEG or PNG');
return;
}
chosenArtwork = window.ezl.getFilePath(file);
renderArtwork();
}
[artDrop, setArtPreview].forEach(zone => {
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
zone.addEventListener('dragleave', e => { if (!zone.contains(e.relatedTarget)) zone.classList.remove('drag-over'); });
zone.addEventListener('drop', e => {
e.preventDefault();
e.stopPropagation();
zone.classList.remove('drag-over');
acceptArtworkFile(e.dataTransfer.files[0]);
});
});
// Live filename preview + Before/After toggle.
text.addEventListener('input', updateNamePreview);
[before, after].forEach(btn => btn.addEventListener('click', () => {
before.classList.toggle('on', btn === before);
after.classList.toggle('on', btn === after);
updateNamePreview();
}));
}
/** MARK: User Modal */
/**
* Wire the account (user) modal: populates identity/plan/status rows from the
* verified entitlement, refreshes the session on each open, and handles Manage
* Account and Sign Out.
* @returns {void}
*/
function initUserModal() {
const modal = document.getElementById('userModal');
/**
* Fill every row from the verified entitlement — nothing in the modal is
* hardcoded. The session-notice banner shows the offline fallback, an expired
* trial/subscription, or a trial in its final week (offline wins) — same copy as
* the launch-gate notice. The name prefers a real display name and falls back to
* the email's local-part so a stale hardcoded name is never shown; avatar initials
* derive from whichever is used. Owner-only rows are hidden for members (via
* style.display, not the hidden attribute: .field's display:flex wins over the
* UA's [hidden] rule). Seats only show on a multi-seat account — solo accounts
* would just read "1 of 1 used", and older cached payloads lack the fields.
* @returns {void}
*/
function populateUserModal() {
const banner = document.getElementById('userNoticeBanner');
const notice = sessionNotice();
banner.hidden = !notice;
if (notice) banner.textContent = notice.msg;
const email = entitlement.email || '';
const name = entitlement.display_name || email.split('@')[0] || '—';
const initials = name.trim().split(/[\s._-]+/).filter(Boolean)
.slice(0, 2).map(p => p[0]).join('').toUpperCase() || '—';
document.getElementById('userAvatar').textContent = initials;
document.getElementById('userName').textContent = name;
document.getElementById('userEmail').textContent = email;
const isOwner = entitlement.role === 'owner';
const roleEl = document.getElementById('userRole');
roleEl.textContent = isOwner ? 'Account owner' : 'Member';
roleEl.classList.toggle('member', !isOwner);
document.getElementById('btnManageAccount').style.display = isOwner ? '' : 'none';
const accountName = entitlement.account_name || '';
const showAccount = accountName && accountName !== email;
document.getElementById('rowAccount').style.display = showAccount ? '' : 'none';
document.getElementById('userAccount').textContent = accountName || '—';
document.getElementById('userPlan').textContent = describePlan();
document.getElementById('userStatus').textContent = describeStatus();
document.querySelectorAll('#userModal .field.admin').forEach((el) => {
el.style.display = isOwner ? '' : 'none';
});
const seats = entitlement.seats ?? 1;
const used = entitlement.seats_used ?? 1;
if (isOwner && seats > 1) {
document.getElementById('userSeats').textContent = `${used} of ${seats} used`;
} else {
document.getElementById('rowSeats').style.display = 'none';
}
}
/*
* Re-read the session on each open so a subscription change lands immediately,
* without waiting for the 24h background refresh. This is also what live-updates
* the offline banner: connectivity coming back (or dropping) is reflected the
* next time the modal opens.
*/
document.getElementById('btnAccount').addEventListener('click', async () => {
if (!entitlement) { showAuthGate(); return; }
const session = await window.ezl.authSession();
if (session.loggedIn) {
sessionOffline = !!session.offline;
setEntitlement(session.payload);
}
populateUserModal();
if (!modal.open) modal.showModal();
});
document.getElementById('btnUserClose').addEventListener('click', () => modal.close());
closeOnBackdropClick(modal);
// Account editing lives on the web portal; the app just opens it in the browser.
document.getElementById('btnManageAccount').addEventListener('click', () => {
window.ezl.openAccountPortal();
});
// Real sign-out: drop the tokens in main, clear local state, and fall back to the gate.
document.getElementById('btnUserSignOut').addEventListener('click', async () => {
await window.ezl.authLogout();
sessionOffline = false;
setEntitlement(null);
modal.close();
showAuthGate();
});
}
/** MARK: Help modal */
/**
* Tiny, purpose-built markdown-to-HTML converter for the Help modal. It deliberately
* handles ONLY the subset of syntax documented at the top of src/partials/help.md —
* it is NOT a general markdown parser. Supported: ## sections with optional
* {#id | Nav Label} suffixes, ### subheadings, ::: raw-HTML passthrough blocks,
* > callouts, numbered and bulleted lists, Q:/A: FAQ pairs, and paragraphs with an
* optional {lead}/{dim} class prefix. Inline formatting HTML-escapes FIRST (so copy
* can safely contain literal < > &), then converts `chip`, **bold**, and
* {icon:user}/{icon:gear} tokens — the icons are the same footer-button SVGs used by
* #btnAccount/#btnSettings, so the help copy can point at "the user icon" / "the
* gear icon" and readers see the literal glyph instead of a generic emoji. The nav
* is generated from the ## headings, so it can never drift out of sync with the
* content; the first nav link is active by default and each section after the first
* gets a top divider.
* @param {string} md - Help markdown source.
* @returns {{nav: string, body: string}} Left-rail link markup and section markup.
*/
function renderHelpMarkdown(md) {
const ICONS = {
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
};
const inline = (s) => s
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/`([^`]+)`/g, '<span class="kbd">$1</span>')
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
.replace(/\{icon:(user|gear)\}/g, (_, name) => `<span class="icon-inline">${ICONS[name]}</span>`);
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
const lines = md.replace(/\r\n/g, '\n').split('\n');
const nav = []; // { id, label } per section
const sections = []; // { id, html } per section
let cur = null; // section currently being built
let i = 0;
const flush = () => { if (cur) sections.push(cur); };
const add = (html) => { if (cur) cur.html += html; };
while (i < lines.length) {
const t = lines[i].trim();
// Blank lines and the leading <!-- ... --> doc-comment block are skipped.
if (!t) { i++; continue; }
if (t.startsWith('<!--')) { while (i < lines.length && !lines[i].includes('-->')) i++; i++; continue; }
// ## Section {#id | Nav Label} — opens a new section + nav entry (### handled below)
let m = t.match(/^##\s+(.+?)\s*(?:\{([^}]*)\})?\s*$/);
if (m && !t.startsWith('###')) {
flush();
const title = m[1];
let id = slug(title), label = title;
if (m[2]) for (const part of m[2].split('|').map(s => s.trim())) {
if (part.startsWith('#')) id = part.slice(1).trim(); // #anchor-id
else if (part) label = part; // nav label
}
nav.push({ id, label });
cur = { id, html: `<h2>${inline(title)}</h2>` };
i++; continue;
}
// ### Subheading
m = t.match(/^###\s+(.+)$/);
if (m) { add(`<h3>${inline(m[1])}</h3>`); i++; continue; }
// ::: raw HTML passthrough ::: (verbatim, not escaped)
if (t === ':::') {
i++; let raw = '';
while (i < lines.length && lines[i].trim() !== ':::') { raw += lines[i] + '\n'; i++; }
i++; // consume the closing :::
add(raw); continue;
}
// > callout (consecutive > lines fold into one box)
if (t.startsWith('>')) {
const buf = [];
while (i < lines.length && lines[i].trim().startsWith('>')) {
buf.push(lines[i].trim().replace(/^>\s?/, '')); i++;
}
add(`<div class="callout"><p>${inline(buf.join(' '))}</p></div>`); continue;
}
// Numbered list (consecutive n. lines)
if (/^\d+\.\s/.test(t)) {
const items = [];
while (i < lines.length && /^\d+\.\s/.test(lines[i].trim())) {
items.push(`<li>${inline(lines[i].trim().replace(/^\d+\.\s/, ''))}</li>`); i++;
}
add(`<ol>${items.join('')}</ol>`); continue;
}
// Bullet list (consecutive - lines)
if (/^-\s/.test(t)) {
const items = [];
while (i < lines.length && /^-\s/.test(lines[i].trim())) {
items.push(`<li>${inline(lines[i].trim().replace(/^-\s/, ''))}</li>`); i++;
}
add(`<ul>${items.join('')}</ul>`); continue;
}
// FAQ pairs
m = t.match(/^Q:\s*(.+)$/);
if (m) { add(`<p class="faq-q">${inline(m[1])}</p>`); i++; continue; }
m = t.match(/^A:\s*(.+)$/);
if (m) { add(`<p class="faq-a">${inline(m[1])}</p>`); i++; continue; }
// Paragraph, with optional {lead}/{dim} class prefix
let cls = '', text = t;
m = t.match(/^\{(lead|dim)\}\s*(.*)$/);
if (m) { cls = ` class="${m[1]}"`; text = m[2]; }
add(`<p${cls}>${inline(text)}</p>`); i++;
}
flush();
const navHtml = nav.map((n, idx) =>
`<a${idx === 0 ? ' class="active"' : ''} data-to="${n.id}">${inline(n.label)}</a>`).join('\n');
const bodyHtml = sections.map((s, idx) =>
`<section id="${s.id}">${idx > 0 ? '<div class="divider"></div>' : ''}${s.html}</section>`).join('\n');
return { nav: navHtml, body: bodyHtml };
}
/**
* Wire the Help modal: renders the help copy (src/partials/help.md) into the empty
* help.html shell, wires the Contact Support form, and sets up nav scrollspy.
* innerHTML is safe here ONLY because the markdown is app-authored and shipped in
* the bundle — never route user-supplied content through renderHelpMarkdown() or
* these assignments.
* @returns {Promise<void>}
*/
async function initHelpModal() {
const modal = document.getElementById('helpModal');
try {
const md = await window.ezl.loadPartial('help-md');
const { nav, body } = renderHelpMarkdown(md);
modal.querySelector('.nav').innerHTML = nav;
modal.querySelector('.content').innerHTML = body;
} catch (e) {
console.error('Help content failed to load:', e);
modal.querySelector('.content').innerHTML = '<section><p>Help content failed to load.</p></section>';
}
document.getElementById('btnHelp').addEventListener('click', () => {
populateSupportForm(); // refresh email/plan/app each open (entitlement may change)
if (!modal.open) modal.showModal();
});
document.getElementById('btnHelpClose').addEventListener('click', () => modal.close());
closeOnBackdropClick(modal);
/*
* Contact Support form. The form markup was injected from help.md above, so its
* elements only exist now. Paste/submit handlers are wired once; the read-only
* context fields are (re)populated on each open via populateSupportForm().
*/
const supPaste = modal.querySelector('#supPasteError');
if (supPaste) {
supPaste.addEventListener('click', () => {
const box = modal.querySelector('#supError');
if (!lastDiagnosticError) {
box.value = 'No errors recorded this session.';
return;
}
const { timestamp, msg, detail } = lastDiagnosticError;
box.value = `[${timestamp}]\n${msg}` + (detail ? `\n\n${detail}` : '');
});
}
const supSubmit = modal.querySelector('#supSubmit');
if (supSubmit) {
// One send in flight at a time. The disabled attribute alone isn't enough:
// the online/offline handler below rewrites disabled on every event, so a
// reconnection mid-send would re-arm the button under a pending request.
let supSending = false;
/*
* Submit sends to the licensing API's POST /support via main (support-request
* IPC). The server derives who it's from (Reply-To, plan) from the auth token,
* so the payload is only: message + optional error dump + a diagnostics bag.
* Validation + outcome wording live in support-form.js (unit-tested); this
* handler owns only the DOM and the IPC call. On a validation failure both
* sentences go in showError's first argument — its second argument is the
* collapsed technical-detail disclosure, not a subtitle.
*/
supSubmit.addEventListener('click', async () => {
const message = modal.querySelector('#supIssue').value.trim();
const invalid = SupportForm.validateMessage(message);
if (invalid) {
showError(`${invalid.title} ${invalid.message}`);
return;
}
supSending = true;
supSubmit.disabled = true; // one in flight at a time (server also rate-limits)
try {
const result = await window.ezl.sendSupportRequest({
message,
error: modal.querySelector('#supError').value.trim() || null,
diagnostics: { app: modal.querySelector('#supApp').textContent },
});
const failure = SupportForm.resultMessage(result);
if (!failure) {
showNotice('Message sent', 'Your support request is on its way — we’ll get back to you soon.');
modal.querySelector('#supIssue').value = '';
modal.querySelector('#supError').value = '';
} else {
showError(`${failure.title} ${failure.message}`);
}
} catch (e) {
console.error('Support submission failed:', e);
// The exception text is genuinely technical → it goes in the disclosure.
showError('Support submission failed. Something went wrong — please try again.',
e?.message ?? String(e));
} finally {
supSending = false;
// Re-enable only if still online — offline may have disabled the whole
// form while the send was in flight.
supSubmit.disabled = !navigator.onLine;
}
});
/*
* Offline handling: the whole form disables with a plain reason — a form that
* can't submit shouldn't look live. navigator.onLine is trusted only in the
* false direction (no network interface at all); connected-but-unreachable
* (e.g. on the internet but off the tailnet) is still caught at send time by
* the network branch above. Disabling never clears the fields — a draft typed
* before the connection dropped survives until it can be sent.
*/
const supForm = supSubmit.closest('form');
if (supForm) {
const supOfflineNote = document.createElement('div');
supOfflineNote.className = 'support-label';
supOfflineNote.style.display = 'none';
supOfflineNote.textContent = 'You’re offline — support needs a connection.';
supForm.appendChild(supOfflineNote);
const supSyncOnline = () => {
const off = !navigator.onLine;
supForm.querySelectorAll('textarea, button').forEach((el) => { el.disabled = off; });
if (supSending) supSubmit.disabled = true; // never re-arm Send mid-flight
supForm.style.opacity = off ? '0.55' : '';
supOfflineNote.style.display = off ? '' : 'none';
};
window.addEventListener('online', supSyncOnline);
window.addEventListener('offline', supSyncOnline);
supSyncOnline();
}
}
// Scrollspy: light the nav item for whichever section is in view; clicks jump to a section
const links = [...modal.querySelectorAll('.nav a')];
const content = modal.querySelector('.content');
links.forEach(a => a.addEventListener('click', () => {
modal.querySelector('#' + a.dataset.to).scrollIntoView({ block: 'start' });
}));
const obs = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
links.forEach(l => l.classList.toggle('active', l.dataset.to === e.target.id));
}
});
}, { root: content, rootMargin: '-10% 0px -75% 0px' });
modal.querySelectorAll('section').forEach(s => obs.observe(s));
}
/** Human-readable OS names for the support form's App line. */
const PLATFORM_LABELS = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' };
/**
* Fill the Contact Support form's read-only context (Email / Plan / App). Called on
* each Help open so it reflects the current sign-in. Identity comes from the
* already-verified entitlement payload; version + OS come from the get-app-info IPC.
* No-op when the form isn't present (e.g. help failed to load).
* @returns {Promise<void>}
*/
async function populateSupportForm() {
const emailEl = document.getElementById('supEmail');
const planEl = document.getElementById('supPlan');
const appEl = document.getElementById('supApp');
if (!emailEl) return;
if (entitlement) {
emailEl.textContent = entitlement.email || '—';
planEl.textContent = [entitlement.tier, entitlement.status].filter(Boolean).join(' · ') || '—';
} else {
emailEl.textContent = 'Not signed in';
planEl.textContent = '—';
}
try {
const info = await window.ezl.getAppInfo();
const os = PLATFORM_LABELS[info.platform] || info.platform;
appEl.textContent = `${os} ${info.osVersion} · v${info.version}`;
} catch {
appEl.textContent = '—';
}
}
mountModals(); // inject + wire the three modals on startup
const toastHost = document.getElementById('toastHost');
/**
* Transient, non-blocking confirmation toast (bottom-right). Used for low-stakes
* successes like quick-save, where a full modal would be too heavy. The message is
* written via textContent so HTML in filenames stays inert. A forced reflow (via
* requestAnimationFrame) makes the transition run from the hidden start state; after
* the timeout the toast fades out and is removed from the DOM once the transition
* finishes.
* @param {string} msg - Message to show.
* @param {number} [ms=3000] - Auto-dismiss delay in milliseconds.
* @returns {void}
*/
function showToast(msg, ms = 3000) {
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML =
`<span class="toast-check"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" ` +
`stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">` +
`<polyline points="20 6 9 17 4 12"/></svg></span>` +
`<span class="toast-msg"></span>`;
toast.querySelector('.toast-msg').textContent = msg;
toastHost.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
}, ms);
}
let videoLoaded = false;
let currentVideoPath = null; // native FS path used for export
// Preview-proxy state (see the VIDEO PREVIEW PROXY block below).
let _videoLoadToken = 0; // bumps on every load/unload — stales in-flight proxy results
let _usingVideoProxy = false; // videoEl.src is the proxy — don't re-run detection on it
let _videoProxyRequested = false; // one transcode per video: loadedmetadata(width 0) and
// error can BOTH fire for the same undecodable file
let _videoProxySwapPending = false; // next loadedmetadata is the proxy swap — keep the trim window
/**
* MARK: TRIM STATE — video trim window, in seconds. videoTrimEnd === null means
* "full extent" (= duration), which is also what the backend expects to mean "no
* out-trim". Per-track trim lives on each trackMap entry as { trimStart, trimEnd }
* so it survives active-track switches.
*/
let videoTrimStart = 0;
let videoTrimEnd = null;
let _trimDragging = false; // true during/just-after a handle drag — suppresses click-to-seek
/**
* MUSIC START MARKER — the video-timeline moment at which music tracks begin playing
* (each from its own audio In point). ONE global marker; every track shares it.
* null = "not placed" → the video In point, which is exactly the pre-marker
* behaviour: mStart() then reads as vIn() by construction, so every consuming path
* reduces to the marker-less code. Same idiom as videoTrimEnd === null ("full
* extent") and trimEndTarget === null ("whole track").
* @type {number|null}
*/
let musicStartTime = null;
/**
* Route videoEl through a GainNode so OA level can be normalised in preview. The
* node starts silent until analysis sets the real gain. createMediaElementSource
* takes ownership of the element and can only be called once, so this lazy-inits on
* first video load and the graph is reused indefinitely.
* @returns {void}
*/
function setupOaAudioGraph() {
if (oaGainNode) return;
const ctx = getAudioCtx();
oaGainNode = ctx.createGain();
oaGainNode.gain.value = 0.0;
ctx.createMediaElementSource(videoEl).connect(oaGainNode);
oaGainNode.connect(ctx.destination);
}
/** MARK: ORIGINAL AUDIO REPLACEMENT */
/** Audio extensions accepted as an OA replacement (superset of the video importer). */
const OA_AUDIO_EXTS = ['.wav', '.aiff', '.aif', '.aifc', '.flac', '.mp3', '.m4a', '.aac', '.ogg'];
/**
* Whether a filename has an extension accepted as an OA replacement.
* @param {string} name - Filename to test.
* @returns {boolean}
*/
function isOaAudioName(name) {
return OA_AUDIO_EXTS.some(ext => (name || '').toLowerCase().endsWith(ext));
}
/**
* Route a replacement audio element through the SAME oaGainNode as the embedded OA,
* so the ON/OFF toggle and normalisation gain apply with no extra wiring.
* createMediaElementSource binds once per element, so a fresh element is built each
* load and the old one torn down first. crossOrigin is set BEFORE src: an
* ezl-file:// / uncredited source taints the element and its MediaElementSource then
* outputs silence (same rule as the video + track elements). Volume is seeded from
* the Master Volume slider.
* @param {string} playUrl - Object URL of the playable source.
* @returns {void}
*/
function setupOaReplaceGraph(playUrl) {
const ctx = getAudioCtx();
oaReplaceAudio = new Audio();
oaReplaceAudio.crossOrigin = 'anonymous';
oaReplaceAudio.src = playUrl;
oaReplaceAudio.volume = parseInt(document.getElementById('masterVol').value) / 100;
oaReplaceSrc = ctx.createMediaElementSource(oaReplaceAudio);
oaReplaceSrc.connect(oaGainNode);
}
/**
* Load a file as the Original Audio replacement: analyse it exactly like the video's
* OA (same IPC, same peak-normalisation math to -0.1 dBTP), mute the embedded
* stream, and play the file through oaGainNode in preview. Export reads the original
* file (see the export payload). A token supersedes any earlier in-flight load, so a
* newer drop always wins. The playable source depends on format: Chromium can't
* decode AIFF and mis-seeks MP3, so those route through the worker's WAV (same
* helper the tracks use) while native formats stream as-is — measurements always
* come from the ORIGINAL file, so they're correct either way. The waveform is padded
* to the video timeline so a shorter replacement shows real audio up front and
* silence after, instead of stretching to fill (see oaDisplayPeaks). If tracks are
* loaded, AUTO-MIX lights up — gains can now be recalculated against the new anchor.
* @param {File} file - The dropped/picked replacement audio file.
* @returns {Promise<void>}
*/
async function loadOaReplacement(file) {
if (!videoLoaded) { showError('Load a video first, then replace its audio.'); return; }
if (!isOaAudioName(file?.name)) {
showError("That file type isn't supported. Try WAV, AIFF, FLAC, MP3, M4A, AAC, or OGG.");
return;
}
const desc = descriptorFromFile(file);
if (!desc.path) { showError('That file’s location is unavailable — try dragging it in again.'); URL.revokeObjectURL(desc.url); return; }
const token = ++oaReplaceToken;
setupOaAudioGraph(); // ensure oaGainNode exists (silent video load already calls this)
statusText.textContent = `Analysing ${desc.name}…`;
let r;
try { r = await window.ezl.analyzeLoudness(desc.path); }
catch (err) { if (token === oaReplaceToken) showError('That audio file couldn’t be read.', err?.message); URL.revokeObjectURL(desc.url); return; }
if (token !== oaReplaceToken) { URL.revokeObjectURL(desc.url); return; } // a newer drop won
if (r.status !== 'ok') { showError('That audio file couldn’t be read.', r.reason ?? r.error); URL.revokeObjectURL(desc.url); return; }
const ext = (desc.name.split('.').pop() || '').toLowerCase();
let playUrl = desc.url;
if (BACKEND_PREVIEW_EXTS.has(ext)) {
try {
const bytes = await window.ezl.getPreviewAudio(desc.path);
if (token !== oaReplaceToken) { URL.revokeObjectURL(desc.url); return; }
URL.revokeObjectURL(desc.url); // original blob not needed — play the decoded WAV instead
playUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' }));
} catch (e) {
if (token === oaReplaceToken) showError('That audio couldn’t be prepared for playback.', e?.message ?? String(e));
URL.revokeObjectURL(desc.url);
return;
}
}
// Commit: tear down any prior replacement, point OA at the new file.
resetOaReplaceState();
oaReplacePath = desc.path;
oaReplaceName = desc.name;
oaReplaceUrl = playUrl;
oaLufs = r.lufs;
oaPeakDbfs = r.peak_dbfs;
oaNormGain = Math.pow(10, (-0.1 - r.peak_dbfs) / 20);
videoEl.muted = true;
setupOaReplaceGraph(playUrl);
if (oaEnabled) oaGainNode.gain.value = oaNormGain;
setOaWaveform(oaDisplayPeaks(r.peaks, r.duration_sec));
updateMusicLevelVisibility();
if (trackMap.size > 0) markAutomixReady();
updateOaReplaceChip();
statusText.textContent = `Original Audio replaced with ${desc.name}`;
}
/**
* Disconnect and drop the replacement element (and revoke its URL), un-muting the
* embedded stream. Also stales any load still in flight via the token. Does NOT
* re-analyse the video — used by both the × revert and video unload.
* @returns {void}
*/
function resetOaReplaceState() {
oaReplaceToken++;
if (oaReplaceSrc) { try { oaReplaceSrc.disconnect(); } catch (_) {} oaReplaceSrc = null; }
if (oaReplaceAudio) { oaReplaceAudio.pause(); oaReplaceAudio.src = ''; oaReplaceAudio = null; }
if (oaReplaceUrl) { URL.revokeObjectURL(oaReplaceUrl); oaReplaceUrl = null; }
oaReplacePath = null;
oaReplaceName = null;
if (videoEl) videoEl.muted = false;
updateOaReplaceChip();
}
/**
* Handle × on the replacement chip: revert to the video's embedded OA (re-analysing
* it), or clear the OA measurements if the video is silent — in that case the (now
* empty) panel is kept so Replace stays reachable. Bails if the video changed while
* the re-analysis ran.
* @returns {void}
*/
function clearOaReplacement() {
if (!oaReplacePath) return;
resetOaReplaceState();
if (!currentVideoPath || !window.ezl) { updateMusicLevelVisibility(); return; }
const path = currentVideoPath;
statusText.textContent = 'Restoring original audio…';
window.ezl.analyzeLoudness(path).then(r => {
if (currentVideoPath !== path) return;
if (r.status === 'ok' && r.peaks && r.peaks.length) {
oaLufs = r.lufs; oaPeakDbfs = r.peak_dbfs;
oaNormGain = Math.pow(10, (-0.1 - r.peak_dbfs) / 20);
if (oaEnabled && oaGainNode) oaGainNode.gain.value = oaNormGain;
setOaWaveform(r.peaks);
statusText.textContent = 'Original Audio restored';
} else {
oaLufs = null; oaPeakDbfs = null; oaNormGain = 1.0;
if (oaGainNode) oaGainNode.gain.value = 0.0;
vofxPeaks = null;
drawWavePlaceholder(document.getElementById('vofxWave'), 101, '#1A2E48');
statusText.textContent = 'Original Audio removed';
}
updateMusicLevelVisibility();
if (trackMap.size > 0 && (oaLufs !== null || [...trackMap.values()].some(t => t.lufs !== null))) markAutomixReady();
}).catch(() => {});
}
/**
* Show/hide the OA-replacement chip. While a replacement is loaded its filename
* replaces the "Original Audio" section label; reverting to the embedded OA restores
* the label.
* @returns {void}
*/
function updateOaReplaceChip() {
const chip = document.getElementById('oaReplaceChip');
if (!chip) return;
const label = document.getElementById('oaSectionLabel');
if (oaReplacePath) {
document.getElementById('oaReplaceName').textContent = oaReplaceName;
chip.style.display = '';
if (label) label.style.display = 'none';
} else {
chip.style.display = 'none';
if (label) label.style.display = '';
}
}
/*
* Preview sync: the replacement maps 1:1 to the video clock (starts at :00), so its
* element is kept aligned with the video wherever the active track is aligned. Both
* helpers below are no-ops when no replacement is loaded.
*/
/**
* Start the replacement together with the video from a given time. When the video
* begins past a shorter replacement's end, the element is parked at its end and
* stays silent.
* @param {number} videoTime - Video-timeline position in seconds.
* @returns {Promise<void>|null} A play() promise to fold into the caller's
* Promise.all, or null when there's nothing to play.
*/
function oaReplacePlayFrom(videoTime) {
if (!oaReplaceAudio) return null;
const dur = oaReplaceAudio.duration || 0;
if (dur > 0 && videoTime >= dur) {
oaReplaceAudio.currentTime = dur;
if (!oaReplaceAudio.paused) oaReplaceAudio.pause();
return null;
}
oaReplaceAudio.currentTime = Math.max(0, videoTime);
_anchorAudio(oaReplaceAudio);
return oaReplaceAudio.play();
}
/**
* Realign the replacement to a new clock position (scrub / restart / loop). Resumes
* when the video is playing and within the OA extent; parks silent past the OA end.
* @param {number} videoTime - Video-timeline position in seconds.
* @returns {void}
*/
function oaReplaceSeek(videoTime) {
if (!oaReplaceAudio) return;
const dur = oaReplaceAudio.duration || 0;
if (dur > 0 && videoTime >= dur) {
oaReplaceAudio.currentTime = dur;
if (!oaReplaceAudio.paused) oaReplaceAudio.pause();
return;
}
oaReplaceAudio.currentTime = Math.max(0, videoTime);
_anchorAudio(oaReplaceAudio);
if (!videoEl.paused && oaReplaceAudio.paused) oaReplaceAudio.play().catch(() => {});
}
/** Supported video containers (matches the product spec: MP4 primary, MOV secondary). */
const VIDEO_EXTS = ['.mp4', '.mov'];
/**
* Whether a filename has a supported video-container extension.
* @param {string} name - Filename to test.
* @returns {boolean}
*/
function isVideoName(name) {
return VIDEO_EXTS.some(ext => (name || '').toLowerCase().endsWith(ext));
}
/**
* MARK: IMPORT DESCRIPTORS — both importers consume a { path, url, name } descriptor
* instead of reaching into a File. Fresh import (drag/drop) builds one from a File
* (blob: URL + native path); project restore builds one from a saved path
* (ezl-file:// URL streamed off disk). Everything downstream — audio src, card
* creation, export — only ever sees the URL + path.
*/
/**
* Build an import descriptor from a dropped/picked File.
* @param {File} file - Source file.
* @returns {{path: (string|null), url: string, name: string}} Descriptor.
*/
function descriptorFromFile(file) {
return { path: window.ezl?.getFilePath(file) ?? null, url: URL.createObjectURL(file), name: file.name };
}
/**
* Build an import descriptor from a saved absolute path (project restore). Each path
* segment is encoded so spaces / # / ? in filenames survive the URL round-trip; the
* separators stay literal so the main-process handler resolves the same path back.
* The explicit localhost host matters: ezl-file is a `standard` scheme, and an empty
* host (triple-slash) doesn't canonicalize an absolute path cleanly — the host keeps
* pathname reliable (= the absolute path). The handler ignores the host. e.g.
* /Users/me/My Clip.mov → ezl-file://localhost/Users/me/My%20Clip.mov
* @param {string} path - Absolute filesystem path.
* @param {string} [name] - Display name; defaults to the path's basename.
* @returns {{path: string, url: string, name: string}} Descriptor.
*/
function descriptorFromPath(path, name) {
const url = 'ezl-file://localhost' + String(path).split('/').map(encodeURIComponent).join('/');
return { path, url, name: name ?? String(path).split(/[\\/]/).pop() };
}
/**
* Drag/drop + file-dialog entry point for video: validate by MIME, then hand off as
* a descriptor.
* @param {File|undefined} file - Dropped/picked file.
* @returns {void}
*/
function loadVideoFile(file) {
if (!file || !file.type.startsWith('video/')) {
showError('Unsupported file — try MP4 or MOV');
return;
}
loadVideoDescriptor(descriptorFromFile(file));
}
/**
* Load a video from an import descriptor: swap out any prior video (dropping its
* proxy transcode and OA replacement, and un-muting the element), reset the OA gain
* to unity until analysis returns (silent if OA is toggled off), point both the main
* player and the hidden frame-preview decoder at the URL, seed the element's volume
* from the Master Volume slider (its handler only fires on 'input', so the displayed
* level — incl. the 75% default — applies without a slider nudge), and kick off the
* Python loudness analysis. The OA panel shows as soon as a video loads — even a
* silent cut — so the Replace affordance is reachable; the waveform stays a
* placeholder until embedded analysis or a replacement fills it. When analysis
* returns it applies the same peak-normalisation gain used in export (-0.1 dBTP
* ceiling) and lights AUTO-MIX if tracks are loaded.
* @param {{path: (string|null), url: string, name: string}} desc - Import descriptor.
* @returns {void}
*/
function loadVideoDescriptor(desc) {
if (!desc || !isVideoName(desc.name)) {
showError('Unsupported file — try MP4 or MOV');
return;
}
if (videoLoaded) {
videoEl.pause();
URL.revokeObjectURL(_currentVideoBlobUrl); // no-op for ezl-file:// URLs
}
resetVideoProxyState();
resetOaReplaceState();
setupOaAudioGraph();
oaNormGain = 1.0;
oaGainNode.gain.value = oaEnabled ? 1.0 : 0.0;
const url = desc.url;
_currentVideoBlobUrl = url;
vofxPeaks = null;
document.querySelector('.oa-wave-wrap').classList.add('visible');
videoEl.src = url;
videoEl.volume = parseInt(document.getElementById('masterVol').value) / 100;
scrubVideo.src = url; // hidden decode source for the trim-handle frame preview
videoEl.style.display = 'block';
videoDrop.classList.add('hidden');
videoRemove.style.display = 'block';
videoFilename.style.display = 'block';
videoFilename.textContent = desc.name;
scrub.classList.remove('disabled');
btnPlay.disabled = false;
btnRestart.disabled = false;
btnLoop.disabled = false;
videoLoaded = true;
statusText.textContent = desc.name;
updatePreviewBtn();
syncRestartBtn();
const videoPath = desc.path;
currentVideoPath = videoPath; // stored for export
if (videoPath && window.ezl) {
window.ezl.analyzeLoudness(videoPath)
.then(r => {
if (r.status === 'ok') {
console.log(`[OA] ${desc.name}: ${r.lufs} LUFS peak ${r.peak_dbfs} dBFS ${r.duration_sec}s`);
oaLufs = r.lufs;
oaPeakDbfs = r.peak_dbfs;
oaNormGain = Math.pow(10, (-0.1 - r.peak_dbfs) / 20);
if (oaEnabled) oaGainNode.gain.value = oaNormGain;
updateMusicLevelVisibility();
if (trackMap.size > 0) markAutomixReady();
showVofxPeaks(r.peaks, videoPath);
} else {
console.warn(`[OA] ${desc.name}: analysis ${r.status} — ${r.reason ?? r.error ?? ''}`);
}
})
.catch(err => console.warn('[OA analysis]', err));
}
// Show placeholder immediately; the analysis above replaces it with real peaks
drawWavePlaceholder(document.getElementById('vofxWave'), 101, '#1A2E48');
pauseAll();
updateHeader(); // re-evaluate Save Laybacks now that video is loaded
}
/**
* Unload the current video and return the panel to its drop-zone state. Stales any
* in-flight proxy transcode (freeing the proxy file), drops any OA replacement
* (revoking its URL and un-muting the element), clears the video trim window and the
* Music Start marker so the next clip starts fresh — the marker's DOM position is
* zeroed too, so the next clip can't flash the old marker before its loadedmetadata
* lands (both markers are hidden meanwhile by .scrub.disabled and .oa-wave-wrap
* losing .visible) — and hides the marker readout, which also clears the filename's
* visibility override so a stuck readout (e.g. video removed mid-drag) can't hide
* the next clip's filename. Resets OA measurements, gains, buttons, and the header.
* @returns {void}
*/
function unloadVideo() {
videoEl.pause();
resetVideoProxyState();
resetOaReplaceState();
URL.revokeObjectURL(_currentVideoBlobUrl);
_currentVideoBlobUrl = null;
vofxPeaks = null;
document.querySelector('.oa-wave-wrap').classList.remove('visible');
hideFramePreview();
scrubVideo.src = '';
videoEl.src = '';
videoEl.style.display = 'none';
videoDrop.classList.remove('hidden');
videoRemove.style.display = 'none';
videoFilename.style.display = 'none';
hideMarkerReadout();
scrub.classList.add('disabled');
scrubFill.style.width = '0';
scrubHead.style.left = '0';
scrubTime.innerHTML = '0:00 / 0:00';
videoTrimStart = 0;
videoTrimEnd = null;
musicStartTime = null;
scrubMusicMarker.style.left = '0';
oaMusicMarker.style.left = '0';
layoutTrim(videoTrimEls(), 0, 1);
layoutTrim(oaTrimEls(), 0, 1);
btnPlay.disabled = true;
btnPlay.innerHTML = '<span>▶</span>';
btnRestart.disabled = true;
btnLoop.disabled = true;
videoLoaded = false;
currentVideoPath = null;
oaLufs = null;
oaPeakDbfs = null;
oaNormGain = 1.0;
if (oaGainNode) oaGainNode.gain.value = 0.0;
updateMusicLevelVisibility();
statusText.textContent = 'Ready';
updatePreviewBtn();
syncRestartBtn();
clearAutomixReady();
drawWavePlaceholder(document.getElementById('vofxWave'), 101, '#1A2E48');
updateHeader(); // re-evaluate Save Laybacks now that video is gone
}
/**
* MARK: VIDEO PREVIEW PROXY — Chromium has no decoder for pro picture codecs
* (ProRes, DNxHD, …): such a file "loads", but its picture track is dropped — the
* preview stays black while the audio plays. Detection is empirical rather than a
* codec allowlist: metadata arrived with no picture (videoWidth 0), or the element
* hard-errored (zero playable tracks). That exactly tracks what THIS Chromium build
* can decode. Affected videos get a preview-only H.264/AAC proxy from the Python
* worker (streamed over ezl-file://); export never sees it — Save Laybacks reads the
* original file.
*/
const videoProxyOverlay = document.getElementById('videoProxyOverlay');
const videoProxySpinner = document.getElementById('videoProxySpinner');
const videoProxyText = document.getElementById('videoProxyText');
const VIDEO_NO_PREVIEW_MSG =
"This video can't be previewed here — Save Laybacks still delivers the full-quality picture.";
/**
* Show the proxy overlay with a message and optional busy spinner.
* @param {string} text - Overlay message.
* @param {boolean} busy - Whether to show the spinner.
* @returns {void}
*/
function showVideoProxyOverlay(text, busy) {
videoProxyText.textContent = text;
videoProxySpinner.hidden = !busy;
videoProxyOverlay.hidden = false;
}
/**
* Reset proxy state. Called on every video load/unload: hides the overlay,
* invalidates any transcode still in flight (its result is for a video that's no
* longer on screen), and lets the main process delete the previous proxy file (a
* no-op when no proxy exists).
* @returns {void}
*/
function resetVideoProxyState() {
_videoLoadToken++;
_usingVideoProxy = false;
_videoProxyRequested = false;
_videoProxySwapPending = false;
videoProxyOverlay.hidden = true;
window.ezl?.releaseVideoPreviewProxy?.();
}
/**
* Request a preview proxy for the current video and swap the player onto it. With no
* native path (blob-only import) or no bridge, transcoding is impossible, so the
* overlay just explains. The swap stops playback first (an src swap mid-play would
* desync the preview clock), keeps the user's trim window (_videoProxySwapPending —
* same clip, new src), points the frame-preview decoder at the proxy too, and parks
* the playhead where the user left it. The overlay stays up until the proxy actually
* has a renderable picture — hiding at src-swap would show a black beat while the
* proxy's metadata loads; that once-listener is token-guarded so if the user swaps
* videos first it must not touch the new video's overlay (which may be a fresh
* "Preparing…" of its own). If the video was swapped/removed mid-transcode, the
* stale proxy is released here rather than leaving the temp file until the next
* load/quit — main has already registered it as the live one. That release is safe
* against the replacement's own transcode: the worker is FIFO, so a newer proxy can
* only resolve — and re-register — after this call.
* @returns {Promise<void>}
*/
async function beginVideoProxy() {
_videoProxyRequested = true;
if (!currentVideoPath || !window.ezl?.getVideoPreviewProxy) {
showVideoProxyOverlay(VIDEO_NO_PREVIEW_MSG, false);
return;
}
const token = _videoLoadToken;
showVideoProxyOverlay('Preparing video preview…', true);
try {
const proxyPath = await window.ezl.getVideoPreviewProxy(currentVideoPath);
if (token !== _videoLoadToken) {
window.ezl?.releaseVideoPreviewProxy?.();
return;
}
pauseAll();
const { url } = descriptorFromPath(proxyPath);
const resumeAt = videoEl.currentTime || 0;
URL.revokeObjectURL(_currentVideoBlobUrl);
_currentVideoBlobUrl = url;
_usingVideoProxy = true;
_videoProxySwapPending = true;
videoEl.src = url;
scrubVideo.src = url;
videoEl.currentTime = resumeAt;
videoEl.addEventListener('loadedmetadata', () => {
if (token === _videoLoadToken) videoProxyOverlay.hidden = true;
}, { once: true });
} catch (err) {
if (token !== _videoLoadToken) return;
console.warn('[video preview proxy]', err);
showVideoProxyOverlay(VIDEO_NO_PREVIEW_MSG, false);
}
}
/*
* Proxy detection — one transcode per video (_videoProxyRequested:
* loadedmetadata-with-no-picture and a subsequent error can both fire for the same
* file). The 'error' case covers files with zero Chromium-playable tracks, where
* loadedmetadata never fires at all. A proxy that itself errors in the element can't
* be fixed by another transcode — swap the spinner for the terminal no-preview
* message instead of looping back into beginVideoProxy.
*/
videoEl.addEventListener('loadedmetadata', () => {
if (videoLoaded && !_videoProxyRequested && videoEl.videoWidth === 0) beginVideoProxy();
});
videoEl.addEventListener('error', () => {
if (!videoLoaded) return;
if (_usingVideoProxy) { showVideoProxyOverlay(VIDEO_NO_PREVIEW_MSG, false); return; }
if (!_videoProxyRequested) beginVideoProxy();
});
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => e.preventDefault());
videoDrop.addEventListener('click', () => videoFileInput.click());
videoFileInput.addEventListener('change', e => {
if (e.target.files[0]) loadVideoFile(e.target.files[0]);
e.target.value = '';
});
videoDrop.addEventListener('dragover', e => { e.preventDefault(); videoDrop.classList.add('drag-over'); });
videoDrop.addEventListener('dragleave', e => { if (!videoDrop.contains(e.relatedTarget)) videoDrop.classList.remove('drag-over'); });
videoDrop.addEventListener('drop', e => {
e.preventDefault();
e.stopPropagation();
videoDrop.classList.remove('drag-over');
loadVideoFile(e.dataTransfer.files[0]);
});
videoRemove.addEventListener('click', unloadVideo);
// OA replacement: the header button opens a picker; the OA waveform doubles as a drop target
document.getElementById('oaReplaceBtn').addEventListener('click', () => document.getElementById('oaFileInput').click());
document.getElementById('oaFileInput').addEventListener('change', e => {
if (e.target.files[0]) loadOaReplacement(e.target.files[0]);
e.target.value = ''; // allow re-selecting the same file
});
document.getElementById('oaReplaceClear').addEventListener('click', clearOaReplacement);
oaWrap.addEventListener('dragover', e => { if (videoLoaded) { e.preventDefault(); oaWrap.classList.add('oa-drop-hover'); } });
oaWrap.addEventListener('dragleave', e => { if (!oaWrap.contains(e.relatedTarget)) oaWrap.classList.remove('oa-drop-hover'); });
oaWrap.addEventListener('drop', e => {
e.preventDefault();
e.stopPropagation();
oaWrap.classList.remove('oa-drop-hover');
if (e.dataTransfer.files[0]) loadOaReplacement(e.dataTransfer.files[0]);
});
// Play toggles between stop and start (preview when a track is active, bare video
// playback otherwise).
btnPlay.addEventListener('click', () => {
if (!videoLoaded) return;
if (isPreviewing || !videoEl.paused) stopPreview();
else if (activeTrackId !== null) startPreview();
else playVideoBounded();
});
/*
* Restart seeks to the trim In point (0 when untrimmed). Playback state is captured
* before the seek to decide whether to resume; when paused, the scrub bar and
* waveform playheads are moved immediately (the rAF loop only runs while playing).
*/
btnRestart.addEventListener('click', () => {
if (!videoLoaded) return;
const wasPlaying = isPreviewing || !videoEl.paused;
videoEl.currentTime = vIn();
if (wasPlaying) {
if (activeTrackId !== null) startPreview();
else playVideoBounded();
} else {
homePausedPlayheadToIn();
}
});
/**
* LOOP toggle — when active, playback wraps at the trim-Out / end point back to the
* In point instead of stopping. Default ON (matches the .active class in the markup).
* @type {boolean}
*/
let loopEnabled = true;
btnLoop.addEventListener('click', () => {
loopEnabled = !loopEnabled;
btnLoop.classList.toggle('active', loopEnabled);
});
/*
* Native end-of-media (untrimmed full play, when the rAF guard didn't catch the last
* frame before the browser paused at duration). Single source of truth for
* end-of-clip behavior. Looping restarts from the In point via the proven restart
* path: a bare videoEl.play() would resume with a dead rAF loop (it self-terminated
* when the video paused at end), so it goes through startPreview() /
* playVideoBounded(), which re-arm the animation loop, re-anchor the clock, re-seek
* the active track, and reschedule the tail fade. Not looping stops cleanly.
*/
videoEl.addEventListener('ended', () => {
if (loopEnabled && videoLoaded) {
videoEl.currentTime = vIn();
if (activeTrackId !== null) startPreview();
else playVideoBounded();
return;
}
if (isPreviewing) stopPreview();
else { btnPlay.innerHTML = '<span>▶</span>'; resetVofxWaveform(); }
});
/*
* Reset the video trim window to full extent each time a new clip's duration is
* known, then lay out the handles (the handle DOM is permanent, so the drag is wired
* only once). Exception — a proxy swap: same clip, new src. The user may have
* trimmed or placed the Music Start marker while the proxy rendered, so both are
* kept instead of reset (durations match to within a frame). Otherwise the marker
* goes back to "not placed" (following the new clip's In point) and any
* already-loaded tracks are re-fitted to the new video's length.
*/
videoEl.addEventListener('loadedmetadata', () => {
if (_videoProxySwapPending) { _videoProxySwapPending = false; return; }
videoTrimStart = 0;
videoTrimEnd = null;
musicStartTime = null;
updateVideoTrimUI();
syncAudioEndsToVideo();
});
/*
* Video trim drag wiring — scrub bar handles. The Music Start marker rides the trim
* window and can never sit outside it:
* - In / Out drag → the marker holds its ABSOLUTE position, PUSHED only by an edge
* that runs into it.
* - band slide → the marker keeps its OFFSET inside the window, so the music/picture
* relationship the user set up survives moving the whole selection.
* In setStart, videoTrimStart still holds the PREVIOUS In — which is exactly the
* delta rideWindow needs for the slide case. This lives at the call site, not inside
* attachTrimDrag: that engine is shared with the per-track audio trims, which must
* never touch the marker. In setEnd, a slide already moved the marker via setStart
* (which always fires first) and preserves the window's width, so the Out edge
* cannot push it — only an Out DRAG can squeeze it; rideWindow rather than
* clampMusicStart so an unset (null) marker stays unset instead of hardening into a
* number at the current In point. relayout relays out the video trim AND re-fits
* every track's Out handle; onPreview shows the frame at the dragged point (popover
* while playing, main seek while paused); a plain click on the band seeks, same as
* clicking the scrub bar itself.
*/
attachTrimDrag({
rectEl: scrub, handleIn: scrubTrimIn, handleOut: scrubTrimOut, tooltipEl: scrubTrimTip,
handleRegion: scrubRegion,
getDuration: () => videoEl.duration,
getStart: () => videoTrimStart,
getEnd: () => videoTrimEnd,
setStart: (s, mode) => {
musicStartTime = Timeline.rideWindow({
musicStart: musicStartTime,
prevIn: videoTrimStart || 0,
nextIn: s,
nextOut: videoTrimEnd ?? videoEl.duration,
slid: mode === 'slide',
gap: trimGap(),
});
videoTrimStart = s;
},
setEnd: (e, mode) => {
videoTrimEnd = e;
if (mode !== 'slide') {
const inPt = videoTrimStart || 0;
musicStartTime = Timeline.rideWindow({
musicStart: musicStartTime,
prevIn: inPt, nextIn: inPt, // In didn't move
nextOut: videoTrimEnd ?? videoEl.duration,
slid: false, gap: trimGap(),
});
}
},
relayout: () => { updateVideoTrimUI(); syncAudioEndsToVideo(); },
onPreview: sec => previewFrameAt(sec),
onRegionClick: cx => {
if (!videoLoaded || !videoEl.duration) return;
const r = scrub.getBoundingClientRect();
doSeek(clamp01((cx - r.left) / r.width) * videoEl.duration);
},
});
/*
* OA waveform handles — wired to the SAME video-trim state and relayout as the scrub
* handles (including identical Music Start marker riding — see the block above), so
* the two sets are always locked together no matter which one the user drags.
*/
attachTrimDrag({
rectEl: oaWrap, handleIn: oaTrimIn, handleOut: oaTrimOut, tooltipEl: oaTrimTip,
handleRegion: oaRegion,
getDuration: () => videoEl.duration,
getStart: () => videoTrimStart,
getEnd: () => videoTrimEnd,
setStart: (s, mode) => {
musicStartTime = Timeline.rideWindow({
musicStart: musicStartTime,
prevIn: videoTrimStart || 0,
nextIn: s,
nextOut: videoTrimEnd ?? videoEl.duration,
slid: mode === 'slide',
gap: trimGap(),
});
videoTrimStart = s;
},
setEnd: (e, mode) => {
videoTrimEnd = e;
if (mode !== 'slide') {
const inPt = videoTrimStart || 0;
musicStartTime = Timeline.rideWindow({
musicStart: musicStartTime,
prevIn: inPt, nextIn: inPt, // In didn't move
nextOut: videoTrimEnd ?? videoEl.duration,
slid: false, gap: trimGap(),
});
}
},
relayout: () => { updateVideoTrimUI(); syncAudioEndsToVideo(); },
onPreview: sec => previewFrameAt(sec),
onRegionClick: cx => {
if (!videoLoaded || !videoEl.duration) return;
const r = oaWrap.getBoundingClientRect();
doSeek(clamp01((cx - r.left) / r.width) * videoEl.duration);
},
});
/*
* Music Start markers. Both are wired to the SAME state and the same single writer,
* so they stay locked together no matter which one the user drags — like the trim
* handles above.
*/
attachMarkerDrag({ rectEl: scrub, marker: scrubMusicMarker });
attachMarkerDrag({ rectEl: oaWrap, marker: oaMusicMarker });
/**
* Whether an element is a true text-entry field. Spacebar is reserved for playback
* start/stop (mirroring btnPlay click), and only true text-entry fields keep it so a
* space can be typed. Range sliders, buttons, checkboxes, etc. must NOT swallow it,
* so Space keeps toggling playback even while a button or slider has focus after a
* click.
* @param {Element|null} el - Element to test (usually document.activeElement).
* @returns {boolean} True when the element accepts typed text.
*/
function isTextEntry(el) {
if (!el) return false;
if (el.tagName === 'TEXTAREA' || el.isContentEditable) return true;
if (el.tagName === 'INPUT') {
const t = (el.type || 'text').toLowerCase();
return !['range', 'checkbox', 'radio', 'button', 'submit', 'reset', 'color', 'file'].includes(t);
}
return false;
}
/*
* Spacebar toggles playback. Auto-repeat is ignored and text fields keep the key.
* A non-playback dialog (settings/help/account/error/missing) being open leaves
* Space to the focused control — playback isn't toggled behind the modal. The Save
* modal is the one exception: playback runs under it, so Space must still stop it
* there (mirrors the Enter handler's dialog guard). preventDefault stops the
* page-scroll AND the native activation of a focused button/slider — this is what
* keeps Space toggling playback instead of "re-clicking" whatever button was last
* clicked.
*/
document.addEventListener('keydown', e => {
if (e.code !== 'Space') return;
if (e.repeat) return;
if (isTextEntry(document.activeElement)) return;
if ([...document.querySelectorAll('dialog[open]')].some(d => d.id !== 'saveModal')) return;
e.preventDefault();
if (!videoLoaded) return;
if (isPreviewing || !videoEl.paused) stopPreview();
else if (activeTrackId !== null) startPreview();
else playVideoBounded();
});
/*
* Enter is the "primary action" key.
* - Save modal open → presses whichever primary button is currently showing
* (Done/Close after a run, otherwise GO). This works no matter which button the
* dialog auto-focused, so Enter never accidentally closes via the × or re-opens
* the folder picker.
* - Settings modal open → presses Done (save + close), same as the Save modal.
* - Main screen (nothing focused) → presses "Save Laybacks".
* Enter is deliberately left alone when a control is focused (so it still activates
* that button/link the standard way) and while typing in a field.
*/
document.addEventListener('keydown', e => {
if (e.key !== 'Enter' || e.repeat) return;
if (isTextEntry(document.activeElement)) return;
const saveModal = document.getElementById('saveModal');
if (saveModal?.open) {
// Prefer Done/Close (completion state); otherwise GO if it's visible + enabled.
const btnDone = document.getElementById('btnSaveModalDone');
const btnGo = document.getElementById('btnSaveModalGo');
const primary = !btnDone.classList.contains('hidden') ? btnDone
: (!btnGo.classList.contains('hidden') && !btnGo.disabled) ? btnGo
: null;
if (primary) { e.preventDefault(); primary.click(); }
return;
}
const settingsModal = document.getElementById('settingsModal');
if (settingsModal?.open) {
e.preventDefault();
document.getElementById('btnSettingsDone').click();
return;
}
// Any other dialog open (help/account/error/missing) — leave Enter alone.
if (document.querySelector('dialog[open]')) return;
// Main screen: Enter is reserved for Save Laybacks — always preventDefault so a
// focused button isn't also activated (mirrors Space being reserved for playback).
e.preventDefault();
const btnSave = document.getElementById('btnSave');
if (btnSave && !btnSave.disabled) btnSave.click(); // no-op (but still reserved) when disabled
});
/*
* Restart (R), Loop toggle (L) and Music Start (M) — playback shortcuts, mirroring
* the Space/Enter guards above; main-screen only (no modal open), and OS/browser
* combos aren't hijacked. Disabled buttons ignore .click(), so R/L need no
* videoLoaded check; M writes state directly, so it checks explicitly. M drops the
* Music Start marker at the playhead — works paused or mid-preview. Previewing, this
* reads as a live "start the music here": resyncAfterTrimRelease lands the track at
* tt ≈ aIn, which is NOT below it, so the music comes in instantly at the playhead.
* The marker position comes from getVideoTime() — the interpolated clock the rAF
* loop draws from, which already falls back to videoEl.currentTime when paused, so
* one call covers both cases; reading currentTime directly while playing would drop
* the marker up to a frame BEHIND where the playhead was drawn when the key went
* down.
*/
document.addEventListener('keydown', e => {
if (e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;
if (isTextEntry(document.activeElement)) return;
if (document.querySelector('dialog[open]')) return;
const key = e.key.toLowerCase();
if (key === 'r') { e.preventDefault(); btnRestart.click(); }
else if (key === 'l') { e.preventDefault(); btnLoop.click(); }
else if (key === 'm') {
e.preventDefault();
if (!videoLoaded || !videoEl.duration) return;
setMusicStart(getVideoTime()); // clamps into [vIn, vOut − gap]; re-fits every track's Out
resyncAfterTrimRelease(); // re-gate the active track against the new marker
showToast(`Music Start · ${fmt(mStart())}`);
}
});
videoEl.addEventListener('timeupdate', () => {
_anchorVideo(); // refresh interpolation anchor on every discrete clock tick
if (!videoEl.duration) return;
scrubTime.innerHTML = `${fmt(videoEl.currentTime)} / ${fmt(videoEl.duration)}`;
syncRestartBtn();
});
/**
* Re-align the active (previewing) track to a given video time using the current
* trim offset (trackTimeFor folds in both the video In point and the track's own
* audio In point). Pulled out of doSeek so a trim-handle release can reuse it
* WITHOUT re-seeking the video. Three cases:
* - BEFORE the Music Start marker — the music hasn't come in yet. Park it silent and
* PRE-SEEKED at its In point, and reset the waveform: no progress has been made.
* Pre-seeking is the point: crossing the marker then costs a bare play() instead
* of a ~50ms audio-element seek, which is audible as a late entry against picture.
* Without this branch the aligned-seek case below would seek BELOW the In point
* and play the head the user trimmed off — on every seek-while-previewing and
* every loop restart. The epsilon absorbs the playhead sitting exactly ON the
* marker (e.g. right after pressing M mid-preview); that must count as "in", or
* the music stutters for a frame. The tail fade is re-armed from here: the video
* runs continuously to vOut from this point, so the ramp still lands on silence at
* the last frame even though the track is parked.
* - At/past the track's Out point (trim end, or duration when untrimmed) — park
* silent and fully shade the waveform so the user sees the track has reached its
* end; no tail fade needed past the end.
* - Otherwise — seek the track to its offset position so it stays aligned with the
* video, re-anchor its clock, resume it if it had ended (offset moved backward
* past its end), and refresh the tail fade from the new position.
* @param {number} videoTime - Video-timeline position in seconds.
* @returns {void}
*/
function syncActiveTrackToVideo(videoTime) {
const active = trackMap.get(activeTrackId);
if (!active?.audio) return;
const trackDur = active.audio.duration || 0;
const tt = trackTimeFor(active, videoTime);
if (tt < aIn(active) - 0.001) {
active.audio.currentTime = aIn(active);
if (!active.audio.paused) active.audio.pause();
cancelTrackFade(active, activeTrackId);
resetTrackWaveform(activeTrackId);
scheduleTrackFade(active, videoTime, vOut());
return;
}
if (trackDur > 0 && tt >= aOut(active)) {
active.audio.currentTime = Math.min(tt, trackDur);
if (!active.audio.paused) active.audio.pause();
cancelTrackFade(active, activeTrackId);
drawTrackShade(activeTrackId, 1);
} else {
active.audio.currentTime = Math.max(0, tt);
_anchorAudio(active.audio);
if (active.audio.paused) active.audio.play().catch(() => {});
cancelTrackFade(active, activeTrackId);
scheduleTrackFade(active, videoTime, vOut());
}
}
/**
* Seek the video to a time, sync the active track if previewing, and refresh the
* restart button. Called by the scrub bar, OA waveform, and track waveform click
* handlers. A non-finite target — NaN from a 0/0 fraction on a zero-width bar, or
* +/-Infinity from an Infinity-duration video — throws when assigned to currentTime
* (or is silently dropped), leaving the playhead and active track desynced; it is
* never a meaningful seek, so it bails. While previewing the seek is kept inside the
* video trim window; when paused the user is free to scrub anywhere (e.g. to drag a
* trim handle's reference point). The clock is re-anchored so the animation stays
* smooth right after the seek, and the replacement OA is kept aligned (paused or
* playing; no-op if none). Paused, there is no rAF loop, so the playhead UI is
* updated directly for immediate feedback — scrub bar + OA waveform, plus the active
* track's shading (live playhead / full-shade).
* @param {number} t - Target video time in seconds.
* @returns {void}
*/
function doSeek(t) {
if (!isFinite(t)) return;
if (isPreviewing) t = Math.max(vIn(), Math.min(t, vOut()));
videoEl.currentTime = t;
_anchorVideo();
oaReplaceSeek(t);
if (isPreviewing) {
syncActiveTrackToVideo(t);
} else {
drawStaticVideoPlayhead(t);
if (activeTrackId !== null) refreshTrackShade(activeTrackId, t);
}
syncRestartBtn();
}
/**
* Re-home the playhead to the In point while paused, and refresh the static
* playheads directly (the rAF loop only runs during playback). Re-homes the
* replacement OA too, and parks the active track via parkTrackAt (its In point when
* the marker is later). Mirrors the Restart button's paused path.
* @returns {void}
*/
function homePausedPlayheadToIn() {
videoEl.currentTime = vIn();
_anchorVideo();
oaReplaceSeek(vIn());
const pct = videoEl.duration ? vIn() / videoEl.duration : 0;
scrubFill.style.width = (pct * 100) + '%';
scrubHead.style.left = (pct * 100) + '%';
resetVofxWaveform();
if (activeTrackId !== null) {
const t = trackMap.get(activeTrackId);
if (t) {
t.audio.currentTime = parkTrackAt(t, vIn());
resetTrackWaveform(activeTrackId);
}
}
syncRestartBtn();
}
/**
* Settle playback after a trim handle/band release: if the playhead fell outside the
* (resized) window, snap it to the In point — both "before In" and "after Out" go to
* the start. Playing: doSeek moves the video AND re-syncs the track; inside the
* window just re-align the track without disturbing the still-playing video. Paused:
* re-home if the playhead now sits outside the window — the tolerance at Out absorbs
* the seek frame-snap that left it a hair inside, which is what made the next Play
* (whose re-home test is exact) need a couple of tries before jumping to the start.
* @returns {void}
*/
function resyncAfterTrimRelease() {
if (isPreviewing) {
const vt = getVideoTime();
if (vt < vIn() || vt > vOut()) doSeek(vIn());
else syncActiveTrackToVideo(vt);
return;
}
const ct = videoEl.currentTime;
if (ct < vIn() || ct >= vOut() - 0.05) homePausedPlayheadToIn();
}
/*
* Press anywhere on the bar (not on a handle/region, which own their drags) starts a
* live scrub: seek on press, then follow the cursor. doSeek() re-anchors the clock
* and syncs the active track, so this works paused (frame updates in the main
* player) or playing (position scrubs live). A plain click is just a zero-distance
* drag, so click-to-seek still works.
*/
scrub.addEventListener('mousedown', e => {
if (!videoLoaded || !videoEl.duration) return;
if (_trimDragging
|| e.target.classList.contains('trim-handle')
|| e.target.classList.contains('trim-region')) return;
const rect = scrub.getBoundingClientRect();
const seekFromX = x => doSeek(clamp01((x - rect.left) / rect.width) * videoEl.duration);
seekFromX(e.clientX); // jump immediately on press
const move = ev => seekFromX(ev.clientX);
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
// OA waveform click — same seek semantics as the scrub bar; ignored while grabbing
// a trim handle.
_vofxWaveCanvas.addEventListener('click', e => {
if (!videoLoaded || !videoEl.duration) return;
if (_trimDragging || e.target.classList.contains('trim-handle')) return;
const rect = _vofxWaveCanvas.getBoundingClientRect();
doSeek(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * videoEl.duration);
});
/** MARK: TRACKS — track list state, import, cards, and per-track wiring. */
const tracksList = document.getElementById('tracksList');
const tracksEmpty = document.getElementById('tracksEmpty');
const tracksCt = document.getElementById('tracksCt');
const trackDrop = document.getElementById('trackDrop');
const trackFileInput = document.getElementById('trackFileInput');
const panelRight = document.getElementById('panelRight');
const trackMap = new Map(); // id → { audio, blobUrl, seed, peaks, filePath, lufs }
let trackIdCounter = 0;
let activeTrackId = null;
let isPreviewing = false;
let oaEnabled = true;
let oaLufs = null; // integrated LUFS of the video's audio stream
let oaPeakDbfs = null; // sample peak of the video's audio stream
let oaGainNode = null; // Web Audio GainNode that normalises the OA signal in preview
let oaNormGain = 1.0; // current normalised linear gain applied to oaGainNode
/*
* Original Audio REPLACEMENT state: a separate audio file standing in for the
* video's embedded stream. When one is loaded, the embedded stream is muted and
* this element plays through oaGainNode instead — so the ON/OFF toggle and
* -0.1 dBTP normalisation apply unchanged. It maps 1:1 to the video clock (starts
* at :00) and is cropped by the video end on export.
*/
let oaReplacePath = null; // path of the replacement file (null = use the video's embedded OA)
let oaReplaceName = null; // filename, for the header chip
let oaReplaceUrl = null; // object URL attached to the <audio> element (revoked on clear)
let oaReplaceAudio = null; // hidden <audio> element for the replacement (created per load)
let oaReplaceSrc = null; // its MediaElementAudioSourceNode → oaGainNode
let oaReplaceToken = 0; // bumps per load; stales in-flight analysis/transcode on rapid re-drops
// Save Laybacks modal state.
let lastOutputDir = null; // remembers the folder used in this session to pre-fill on reopen
let modalDir = null; // live destination path while the modal is open
let nameAffixText = ''; // Settings: text to add to layback filenames ('' = none)
let nameAffixPos = 'after'; // Settings: 'before' | 'after' the track name
let exportCancelled = false; // set by Cancel; checked between per-track exports
let exportInProgress = false; // guard against GO handler re-firing during completion
let lastExportError = null; // friendly error message from the most recent failed track (shown)
let lastErrorDetail = null; // raw technical detail for that error (copied via the copy button)
let userSelectedFolder = false; // true only when the user explicitly picked a folder via "Change…"
/**
* Extensions accepted as tracks. .mp4/.mov are accepted too — the worker extracts
* their audio stream, so a video bounce from an editor can be auditioned like any
* music file.
*/
const AUDIO_EXTS = ['.wav', '.aiff', '.aif', '.mp3', '.m4a', '.aac', '.mp4', '.mov'];
/**
* Whether a file is importable as a track, by MIME type or extension.
* @param {File} file - File to test.
* @returns {boolean}
*/
function isAudioFile(file) {
if (file.type.startsWith('audio/')) return true;
const name = file.name.toLowerCase();
return AUDIO_EXTS.some(ext => name.endsWith(ext));
}
/**
* Strip the final extension from a filename.
* @param {string} name - Filename.
* @returns {string} Name without its extension.
*/
function stripExt(name) {
return name.replace(/\.[^.]+$/, '');
}
/**
* Escape a string for safe interpolation into HTML.
* @param {string} str - Raw string.
* @returns {string} Escaped string.
*/
function escHtml(str) {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
/**
* Generate a free batch-output folder path: <parent>/EZL_<video basename>. Used only
* when exporting more than one layback, so a batch lands in its own folder; a
* single-track export skips this and writes straight into the parent. The extension
* is stripped so it reads "EZL_myvideo", not "EZL_myvideo.mp4". If the directory
* already exists, a new name is enumerated (EZL_myvideo (2), (3), …) until a free
* one is found.
* @param {string} baseDir - Parent directory.
* @param {string} videoPath - Path of the loaded video, source of the basename.
* @returns {Promise<string>} A non-existing directory path.
*/
async function genEzlFolder(baseDir, videoPath) {
const base = videoPath.split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
let path = `${baseDir}/EZL_${base}`;
let n = 2;
let exists = await window.ezl.pathExists(path);
while (exists) {
path = `${baseDir}/EZL_${base} (${n})`;
exists = await window.ezl.pathExists(path);
n++;
}
return path;
}
/**
* Apply the auto-mix gain to one track. Gain math lives in automix.js (pure,
* unit-tested); this function just feeds it the measured loudness/peak values and
* writes the result back to the DOM + GainNode. OA mode: peak-normalise OA to
* -0.1 dBTP, then offset down by Music Level. No-OA mode: find the quietest track
* and peak-normalise it to -0.1 dBTP; all tracks target the same shared LUFS so the
* mix stays balanced, and the Music Level offset is not applied (the slider is
* hidden in this mode) — skipped entirely while no track has peak data yet. The
* slider snaps to its 0.5 dB step grid, so the snapped value is read back so the
* number, the thumb, and the GainNode all agree (and the next Music Level delta
* reads a slider value that matches what was displayed). Auto-Mix is a step change,
* not a drag — the gain is written hard, but through setTrackGain so a fade already
* scheduled on this track is rebuilt at the new level instead of holding the old
* one.
* @param {number} id - Track id in trackMap.
* @returns {void}
*/
function applyInitialGain(id) {
const t = trackMap.get(id);
if (!t || t.lufs === null) return;
let targetLufs;
if (oaEnabled && oaLufs !== null && oaPeakDbfs !== null) {
const offset = parseInt(document.getElementById('musicLevel').value);
targetLufs = AutoMix.targetLufsOA(oaLufs, oaPeakDbfs, offset);
} else {
let minLufs = Infinity, anchorPeak = null;
trackMap.forEach(tr => {
if (tr.lufs !== null && tr.peak_dbfs !== null && tr.lufs < minLufs) {
minLufs = tr.lufs;
anchorPeak = tr.peak_dbfs;
}
});
if (anchorPeak === null) return;
targetLufs = AutoMix.targetLufsNoOA(minLufs, anchorPeak);
}
const gainDb = AutoMix.trackGainDb(targetLufs, t.lufs);
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
if (!card) return;
const slider = card.querySelector('.s-trim');
const trimVal = card.querySelector('.trim-val');
slider.value = gainDb;
const applied = parseFloat(slider.value);
trimVal.textContent = applied === 0 ? '0'
: (applied > 0 ? '+' + applied.toFixed(1) : applied.toFixed(1));
setTrackGain(t, id, AutoMix.dbToLinear(applied));
}
/**
* Refresh the tracks header count and the AUTO-MIX / Save Laybacks button states.
* No-OA mode (no video) needs 2+ tracks to level relative to each other; OA mode
* needs 1+. Save needs a video + at least one track. Entitlement does NOT disable
* the Save button — an expired account clicks it normally and gets the expired
* modal from requireCanSave(), which explains itself better than a dead button.
* @returns {void}
*/
function updateHeader() {
const n = trackMap.size;
tracksCt.textContent = n === 0 ? 'No tracks'
: `${n} track${n !== 1 ? 's' : ''} — ${n} layback${n !== 1 ? 's' : ''} queued`;
tracksEmpty.style.display = n === 0 ? '' : 'none';
document.getElementById('btnAutomix').disabled = videoLoaded ? n < 1 : n < 2;
const btnSave = document.getElementById('btnSave');
btnSave.disabled = !(videoLoaded && n > 0);
}
/**
* Make a track the active one, updating card highlighting and — during preview —
* seamlessly swapping audio without stopping the video. Every card (active and
* inactive) is redrawn with correct end-shading for the current playhead, so
* switching tracks always shows the right state — full shade if that track is
* at/past its end; the previewing branch then refines the newly-active track (live
* playhead or park). On a preview swap the old track's gain is restored before it
* idles, and the new track lands in one of three states mirroring
* syncActiveTrackToVideo:
* - Playhead BEFORE the Music Start marker — parked silent and pre-seeked at its In
* point. Without this branch it would fall into the aligned case (it isn't past
* aOut either), get Math.max(0, …)-clamped, and play the trimmed-off head.
* - Already at/past its end for the current position — parked silent and fully
* shaded (not played).
* - Otherwise — seeked to the video position and played. The seek races 'seeked'
* against a 1s timeout: an undecoded track may never fire it, which would wedge
* the swap with the preview button stuck showing "Stop" while nothing plays
* (video keeps running, audio silent). Bails if the user switched again while
* seeking; play() errors are swallowed (AbortError on rapid re-switch, matching
* the other play() calls), and a fade-out is scheduled if the track outlasts the
* video trim window.
* @param {number} id - Track id to activate.
* @returns {Promise<void>}
*/
async function setActiveTrack(id) {
const previousId = activeTrackId;
activeTrackId = id;
document.querySelectorAll('.track-card').forEach(card => {
const cid = parseInt(card.dataset.trackId);
card.classList.toggle('active', cid === id);
refreshTrackShade(cid, videoEl.currentTime);
});
if (isPreviewing && previousId !== id) {
const oldT = trackMap.get(previousId);
const newT = trackMap.get(id);
if (oldT) {
oldT.audio.pause();
cancelTrackFade(oldT, previousId);
}
if (newT) {
const trackDur = newT.audio.duration || 0;
const target = trackTimeFor(newT, videoEl.currentTime);
const btn = getActivePreviewBtn();
if (btn) { btn.innerHTML = '■ Stop'; btn.classList.add('previewing'); }
if (target < aIn(newT) - 0.001) {
newT.audio.currentTime = aIn(newT);
if (!newT.audio.paused) newT.audio.pause();
cancelTrackFade(newT, id);
resetTrackWaveform(id);
scheduleTrackFade(newT, videoEl.currentTime, vOut());
} else if (trackDur > 0 && target >= aOut(newT)) {
newT.audio.currentTime = Math.min(target, trackDur);
if (!newT.audio.paused) newT.audio.pause();
cancelTrackFade(newT, id);
drawTrackShade(id, 1);
} else {
const syncTime = Math.max(0, trackDur > 0 ? Math.min(target, trackDur) : target);
await new Promise(resolve => {
if (Math.abs(newT.audio.currentTime - syncTime) < 0.05) { resolve(); return; }
let settled = false;
const done = () => { if (settled) return; settled = true; newT.audio.removeEventListener('seeked', done); resolve(); };
newT.audio.addEventListener('seeked', done, { once: true });
newT.audio.currentTime = syncTime;
setTimeout(done, 1000);
});
if (activeTrackId !== id) return;
_anchorAudio(newT.audio);
newT.audio.play().catch(() => {});
scheduleTrackFade(newT, videoEl.currentTime, vOut());
}
}
}
updatePreviewBtn();
syncRestartBtn();
}
/**
* Remove a track: stop it (tearing down the whole preview if it was previewing),
* revoke its blob URL and any transcoded-WAV preview URL, drop it from trackMap,
* remove its card, and — if it was the active track — activate the first remaining
* one.
* @param {number} id - Track id to remove.
* @returns {void}
*/
function removeTrack(id) {
const t = trackMap.get(id);
if (!t) return;
if (isPreviewing && id === activeTrackId) pauseAll();
t.audio.pause();
URL.revokeObjectURL(t.blobUrl);
if (t.previewUrl) URL.revokeObjectURL(t.previewUrl);
trackMap.delete(id);
if (activeTrackId === id) activeTrackId = null;
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
const wasActive = card?.classList.contains('active');
card?.remove();
if (wasActive && trackMap.size > 0) setActiveTrack(trackMap.keys().next().value);
updateHeader();
updatePreviewBtn();
}
/**
* Build a track card: markup, waveform canvas and trim overlays, Volume slider,
* Preview/Restart/quick-save buttons, and all their wiring (click-to-seek, per-track
* trim drag, Web Audio gain graph, analysis kickoff).
* @param {number} id - Track id in trackMap (entry must already exist).
* @param {string} name - Display name (extension already stripped).
* @param {number} seed - Placeholder-waveform RNG seed.
* @param {HTMLAudioElement} audio - The track's audio element.
* @returns {HTMLElement} The card element, ready to append.
*/
function createTrackCard(id, name, seed, audio) {
const card = document.createElement('div');
card.className = 'track-card';
card.dataset.trackId = id;
card.dataset.seed = seed;
card.innerHTML = `
<div class="track-top">
<div class="track-dot"></div>
<span class="track-name">${escHtml(name)}</span>
<div class="track-btns">
<button class="tbtn tbtn-remove" title="Remove">✕</button>
</div>
</div>
<div class="tw-wrap">
<canvas class="tw" height="28"></canvas>
<div class="trim-dim trim-dim-left tw-dim-l"></div>
<div class="trim-dim trim-dim-right tw-dim-r"></div>
<div class="trim-region tw-region"></div>
<div class="trim-handle trim-handle-in tw-trim-in"></div>
<div class="trim-handle trim-handle-out tw-trim-out"></div>
<div class="trim-tooltip tw-trim-tip"></div>
</div>
<div class="trim-row">
<span class="trim-label">Volume</span>
<input type="range" class="s-trim" min="-24" max="6" value="0" step="0.5">
<span class="trim-val">0</span>
</div>
<div class="track-actions">
<button class="btn-track-preview">
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor"><polygon points="0,0 9,5 0,10"/></svg>
Preview
</button>
<button class="btn-track-restart" title="Restart preview"><svg width="13" height="10" viewBox="0 0 13 10" fill="currentColor"><polygon points="6,0 6,10 0,5"/><polygon points="13,0 13,10 7,5"/></svg></button>
<button class="btn-track-save" title="Save this layback">
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="1" x2="6" y2="9"/><polyline points="2,5 6,9 10,5"/><line x1="1" y1="12" x2="11" y2="12"/></svg>
</button>
</div>`;
const btnRemoveEl = card.querySelector('.tbtn-remove');
const btnPreviewEl = card.querySelector('.btn-track-preview');
const btnRestartEl = card.querySelector('.btn-track-restart');
const btnSaveEl = card.querySelector('.btn-track-save');
const trimSlider = card.querySelector('.s-trim');
const trimVal = card.querySelector('.trim-val');
const tw = card.querySelector('.tw');
const twWrap = card.querySelector('.tw-wrap');
const twTrimIn = card.querySelector('.tw-trim-in');
const twTrimOut = card.querySelector('.tw-trim-out');
const twTrimTip = card.querySelector('.tw-trim-tip');
/**
* Track waveform click → seek. Shared by the canvas itself and by the trim band
* that sits on top of it, so both read the click the same way.
*
* The canvas spans the WHOLE song, but only [aIn, aOut] is playable —
* syncTrackEndToVideo auto-caps the Out handle to the active video window, so on
* the usual case (a 3-minute song under a 40-second cut) most of the waveform is
* dimmed and unreachable. A click out there is IGNORED rather than clamped back
* to the edge: clamping is what landed the music earlier than the point clicked
* (#341). The other half of that bug was feeding a TRACK time straight into
* doSeek(), which wants a VIDEO time — hence videoTimeForTrack. The band's edges
* are placed as a CSS percentage, so its outermost pixel column can read a hair
* outside [aIn, aOut]; one pixel's worth of song is tolerated either way —
* otherwise the first and last pixel of the band become dead zones — then snapped
* back inside. aOut ≤ videoCapForTrack = aIn + (vOut − mStart), so an in-band
* track time always maps inside the video window — doSeek's own clamp is a no-op
* here.
* @param {number} clientX - Click X in viewport coordinates.
* @returns {void}
*/
function seekFromTrackWave(clientX) {
const t = trackMap.get(id);
const dur = trackDuration(t);
if (!videoLoaded || !videoEl.duration || !dur) return;
const rect = tw.getBoundingClientRect();
const trackT = clamp01((clientX - rect.left) / rect.width) * dur;
const inT = aIn(t), outT = aOut(t);
const tol = dur / rect.width;
if (trackT < inT - tol || trackT > outT + tol) return; // dimmed = inert
doSeek(videoTimeForTrack(t, Math.max(inT, Math.min(trackT, outT))));
}
/*
* Canvas click: activate this track (no-op if already active — even out in the
* dim), then seek. Skipped when a handle drag just ended over the canvas;
* stopPropagation prevents the card handler from calling setActiveTrack twice.
*/
tw.addEventListener('click', e => {
if (_trimDragging) return;
e.stopPropagation();
setActiveTrack(id);
seekFromTrackWave(e.clientX);
});
/*
* Per-track audio trim handles — independent In/Out stored on this track's map
* entry. The Out handle edits the user's saved *target* (trimEndTarget); the
* effective Out (trimEnd) is then derived against the video cap by
* syncTrackEndToVideo (which relayout re-runs). getEnd returns the effective end,
* used for In-clamping. A plain click on the band seeks — the band covers the
* playable region, so it shares the canvas's handler verbatim (the band swallows
* the click before it reaches the canvas).
*/
attachTrimDrag({
rectEl: twWrap, handleIn: twTrimIn, handleOut: twTrimOut, tooltipEl: twTrimTip,
handleRegion: card.querySelector('.tw-region'),
getDuration: () => trackDuration(trackMap.get(id)),
getStart: () => trackMap.get(id)?.trimStart || 0,
getEnd: () => trackMap.get(id)?.trimEnd ?? null,
getMaxEnd: () => Math.min(trackDuration(trackMap.get(id)), videoCapForTrack(trackMap.get(id))),
setStart: s => { const t = trackMap.get(id); if (t) t.trimStart = s; },
setEnd: e => { const t = trackMap.get(id); if (t) t.trimEndTarget = e; },
relayout: () => syncTrackEndToVideo(id),
onRegionClick: cx => {
setActiveTrack(id);
seekFromTrackWave(cx);
},
});
/*
* Once the track's duration is known, lay out its handles and auto-fit the Out
* handle to the active video section (no-op when no video is loaded — handles
* default to full).
*/
audio.addEventListener('loadedmetadata', () => syncTrackEndToVideo(id));
if (audio.duration) syncTrackEndToVideo(id);
// Web Audio graph: MediaElementSource → GainNode (trim) → destination. The node is
// stored on the map entry so applyInitialGain can reach it.
const audioCtx = getAudioCtx();
const gainNode = audioCtx.createGain();
audioCtx.createMediaElementSource(audio).connect(gainNode);
gainNode.connect(audioCtx.destination);
trackMap.get(id).gainNode = gainNode;
// Show placeholder immediately; real peaks fill in after decode
requestAnimationFrame(() => drawWavePlaceholder(tw, seed, '#283545'));
analyzeTrack(id, trackMap.get(id).blobUrl);
// When track audio finishes, reset its waveform but let the video keep rolling
audio.addEventListener('ended', () => resetTrackWaveform(id));
card.addEventListener('click', e => {
if (e.target.classList.contains('tbtn') || e.target.tagName === 'INPUT') return;
setActiveTrack(id);
});
btnRemoveEl.addEventListener('click', e => {
e.stopPropagation();
removeTrack(id);
});
/*
* Volume slider: smooth glide (it's a drag). setTrackGain also re-anchors any
* scheduled tail fade at the new level, so the fade starts from where the slider
* now sits (#342).
*/
trimSlider.addEventListener('input', function() {
const v = parseFloat(this.value);
trimVal.textContent = v === 0 ? '0' : (v > 0 ? '+' + v : v);
setTrackGain(trackMap.get(id), id, Math.pow(10, v / 20), { smooth: true });
markAutomixReady();
});
btnPreviewEl.addEventListener('click', e => {
e.stopPropagation();
if (isPreviewing && activeTrackId === id) stopPreview();
else { setActiveTrack(id); startPreview(); }
});
/*
* Restart: seek to the trim In point (0 when untrimmed). Playing → startPreview
* seeks the track audio and replays from the In point; paused → move the scrub
* bar and waveform playheads immediately, parking the track via parkTrackAt (its
* In point when the marker is later).
*/
btnRestartEl.addEventListener('click', e => {
e.stopPropagation();
const wasPlaying = isPreviewing;
videoEl.currentTime = vIn();
if (wasPlaying) {
startPreview();
} else {
const pct = videoEl.duration ? vIn() / videoEl.duration : 0;
scrubFill.style.width = (pct * 100) + '%';
scrubHead.style.left = (pct * 100) + '%';
resetVofxWaveform();
const t = trackMap.get(id);
if (t) {
t.audio.currentTime = parkTrackAt(t, vIn());
resetTrackWaveform(id);
}
}
});
/*
* Quick-save (↓): export this one track's layback straight to Downloads,
* bypassing the Save modal. The name affix is read here too — otherwise the same
* track saved via ↓ and via the modal would come out with different filenames. No
* overwrite pre-check is needed: the worker enumerates a Finder-style " (n)"
* suffix on any existing file, so a quick-save never clobbers without a rename.
* Metadata is stamped onto the saved file before confirming (non-fatal if it
* fails); confirmation is an overt toast since the status line is hidden.
*/
btnSaveEl.addEventListener('click', async e => {
e.stopPropagation();
if (!requireCanSave()) return;
const t = trackMap.get(id);
if (!videoLoaded || !currentVideoPath) {
showError('Load a video before saving');
return;
}
if (!t?.filePath) {
showError('Track path unavailable — reload the file');
return;
}
const db = trimSlider ? parseFloat(trimSlider.value) : 0;
const level = Math.pow(10, db / 20);
btnSaveEl.disabled = true;
try {
const downloadsPath = await window.ezl.getDownloadsPath();
const quickCfg = await window.ezl.getSettings();
statusText.textContent = 'Saving layback…';
const result = await window.ezl.exportLaybacks({
video_path: currentVideoPath,
audio_paths: [t.filePath],
output_dir: downloadsPath,
added_text: quickCfg.files.nameAffix.text,
added_text_pos: quickCfg.files.nameAffix.position,
oa_peak_dbfs: oaPeakDbfs,
oa_enabled: oaEnabled,
// OA replacement: a separate file stands in for the video's embedded stream (null =
// use embedded). video_duration lets the worker crop the replacement to the video end.
oa_replace_path: oaReplacePath,
video_duration: videoEl.duration || 0,
audio_levels: [level],
audio_codec: 'aac',
video_codec: 'copy',
// Trim window in seconds; null end means "no out-trim" to the backend
video_trim_start: videoTrimStart || 0,
video_trim_end: videoTrimEnd,
audio_trim_starts: [t.trimStart || 0],
audio_trim_ends: [t.trimEnd],
// Music Start: seconds of silence before this track comes in, measured from the
// layback's own zero (the video In point). 0 = music starts with picture. Sent as a
// relative delay rather than an absolute marker time because the worker rebases each
// track to t=0 of the OUTPUT timeline (atrim + asetpts=PTS-STARTPTS), so a delay is
// exactly what its filter graph consumes — no video_trim_start arithmetic repeated
// across the IPC boundary.
audio_delays: [Math.max(0, mStart() - vIn())],
// Tail fade-out matching the preview fade (only when track outlasts the video)
audio_fade_outs: [exportFadeOutFor(t)],
});
if (result.status === 'ok') {
await applyExportMetadata(result.output_paths, quickCfg);
const filename = result.display_names?.[0]
?? result.output_paths[0]?.split(/[\\/]/).pop()
?? 'layback';
statusText.textContent = `Saved ${filename} → Downloads`;
showToast(`Saved ${filename} → Downloads`);
} else {
// result.error is the friendly message; result.error_detail is the raw FFmpeg text.
showError(result.error ?? 'The layback couldn\'t be saved.', result.error_detail);
}
} catch (err) {
showError('The layback couldn\'t be saved.', err.message);
} finally {
btnSaveEl.disabled = false;
}
});
return card;
}
/**
* Add one track from a { path, url, name } descriptor. Shared by fresh import
* (addTrackFiles) and project restore. The audio element's crossOrigin is set BEFORE
* src: an ezl-file:// source loaded without CORS taints the element, and its
* MediaElementSource then outputs pure silence (see videoEl's markup note). Its
* volume is seeded from the Master Volume slider's current position — that handler
* only fires on 'input', so a track added after the user moved it (or the 75%
* default on launch) is honoured immediately instead of playing at full volume until
* the next slider move. The extension (which drives the AIFF backend-preview
* routing) is derived from the PATH basename — the restored display name has its
* extension stripped, which previously made AIFF miss on restore (the raw AIFF then
* loaded over ezl-file://, undecodable → no duration → trim stuck at 0). On restore
* the saved per-track volume is applied to the slider + GainNode (createTrackCard
* has already created the GainNode synchronously); fresh imports keep the unity
* default. Fresh import auto-activates the first track; on restore the active track
* is chosen by path afterwards (so a project with no saved active track opens with
* none selected). Python loudness analysis is kicked off but gain is NOT
* auto-applied — the saved/auto value stands and AUTO-MIX merely glows "ready" —
* matching fresh-import behaviour. Readiness: OA mode when OA is analyzed; no-OA
* mode on track data alone; no-video mode when 2+ tracks are analyzed (levels
* relative to each other).
* @param {{path: (string|null), url: string, name: string}} desc - Import
* descriptor (blob: URL for fresh import, ezl-file:// on restore).
* @param {{volumeDb: number, trimStart: number, trimEndTarget: (number|null)}}
* [restore] - Saved session state, applied after the card is built.
* @returns {number} The new track id.
*/
function addTrackDescriptor(desc, restore) {
const id = ++trackIdCounter;
const blobUrl = desc.url;
const audio = new Audio();
audio.crossOrigin = 'anonymous';
audio.src = blobUrl;
audio.volume = parseInt(document.getElementById('masterVol').value) / 100;
const seed = id * 137 + 42;
const filePath = desc.path;
const extBase = (desc.path || desc.name || '').split(/[\\/]/).pop();
const ext = (extBase.includes('.') ? extBase.split('.').pop() : '').toLowerCase();
trackMap.set(id, {
audio, blobUrl, previewUrl: null, ext, seed, peaks: null, filePath,
lufs: null, peak_dbfs: null,
trimStart: restore ? (restore.trimStart || 0) : 0,
trimEnd: null, // effective end — derived by syncTrackEndToVideo
trimEndTarget: restore ? (restore.trimEndTarget ?? null) : null,
});
const card = createTrackCard(id, stripExt(desc.name), seed, audio);
tracksList.appendChild(card);
if (restore && typeof restore.volumeDb === 'number') {
const v = Math.max(-24, Math.min(6, restore.volumeDb));
const slider = card.querySelector('.s-trim');
const trimVal = card.querySelector('.trim-val');
slider.value = v;
trimVal.textContent = v === 0 ? '0' : (v > 0 ? '+' + v : String(v));
const t = trackMap.get(id);
if (t.gainNode) t.gainNode.gain.value = AutoMix.dbToLinear(v);
}
if (!restore && trackMap.size === 1) setActiveTrack(id);
if (filePath && window.ezl) {
window.ezl.analyzeLoudness(filePath).then(result => {
const t = trackMap.get(id);
if (!t) return;
if (result.status === 'ok') {
t.lufs = result.lufs;
t.peak_dbfs = result.peak_dbfs;
console.log(`[track] ${desc.name}: ${result.lufs} LUFS peak ${result.peak_dbfs} dBFS ${result.duration_sec}s`);
const analyzedCount = [...trackMap.values()].filter(t => t.lufs !== null).length;
if (oaLufs !== null || !oaEnabled || (!videoLoaded && analyzedCount >= 2))
markAutomixReady();
} else {
console.warn(`[track] ${desc.name}: analysis ${result.status} — ${result.reason ?? result.error ?? ''}`);
}
}).catch(err => console.warn('[analyzeLoudness]', err));
}
return id;
}
/**
* Import a batch of dropped/picked files as tracks, skipping non-audio files.
* Errors if none are supported.
* @param {FileList|File[]} files - Candidate files.
* @returns {void}
*/
function addTrackFiles(files) {
const audioFiles = Array.from(files).filter(isAudioFile);
if (!audioFiles.length) {
showError('No supported audio files — try WAV, AIFF, MP3, M4A, or MP4');
return;
}
audioFiles.forEach(file => addTrackDescriptor(descriptorFromFile(file)));
updateHeader();
updatePreviewBtn();
}
// Track drop zone — click
trackDrop.addEventListener('click', () => trackFileInput.click());
tracksEmpty.addEventListener('click', () => trackFileInput.click());
/*
* Empty area below the loaded track cards stays clickable — only clicks that land
* on the list's bare background count; card clicks bubble up and must keep their
* own behavior (activate, sliders, remove).
*/
tracksList.addEventListener('click', e => {
if (e.target === tracksList) trackFileInput.click();
});
trackFileInput.addEventListener('change', e => {
if (e.target.files.length) addTrackFiles(e.target.files);
e.target.value = '';
});
// Track drop zone — drag/drop onto panel or drop zone
panelRight.addEventListener('dragover', e => {
e.preventDefault();
e.stopPropagation();
trackDrop.classList.add('drag-over');
});
panelRight.addEventListener('dragleave', e => {
if (!panelRight.contains(e.relatedTarget)) trackDrop.classList.remove('drag-over');
});
panelRight.addEventListener('drop', e => {
e.preventDefault();
e.stopPropagation();
trackDrop.classList.remove('drag-over');
if (e.dataTransfer.files.length) addTrackFiles(e.dataTransfer.files);
});
/** MARK: AUTO-MIX */
// AUTO-MIX recalculates every analyzed track's gain, then clears the ready glow.
document.getElementById('btnAutomix').addEventListener('click', () => {
trackMap.forEach((t, id) => {
if (t.lufs !== null) applyInitialGain(id);
});
clearAutomixReady();
});
/** MARK: TOOLBAR SLIDERS */
let _lastMusicLevel = parseInt(document.getElementById('musicLevel').value);
/*
* Music Level slider: shift every calibrated track by the slider delta, preserving
* manual trims. Each track keeps an un-clamped logical gain so an offset pushed past
* a rail (-24/+6) is restored intact when the slider sweeps back; the slider value
* is re-adopted whenever an external write (Auto-Mix or a manual trim drag) has
* moved it off the last clamped view. Gains are written smooth (it's a drag), which
* also rebuilds any scheduled tail fade at the new level (#342).
*/
document.getElementById('musicLevel').addEventListener('input', function() {
const newLevel = parseInt(this.value);
const delta = newLevel - _lastMusicLevel;
_lastMusicLevel = newLevel;
document.getElementById('mlVal').textContent = this.value;
trackMap.forEach((t, id) => {
if (t.lufs === null) return;
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
if (!card) return;
const slider = card.querySelector('.s-trim');
const trimVal = card.querySelector('.trim-val');
const cur = parseFloat(slider.value);
if (t.logicalGainDb === undefined ||
Math.abs(Math.max(-24, Math.min(6, t.logicalGainDb)) - cur) > 1e-6) {
t.logicalGainDb = cur;
}
t.logicalGainDb += delta;
const v = Math.max(-24, Math.min(6, t.logicalGainDb));
slider.value = v;
trimVal.textContent = v === 0 ? '0' : (v > 0 ? '+' + v.toFixed(1) : v.toFixed(1));
setTrackGain(t, id, Math.pow(10, v / 20), { smooth: true });
});
});
// Master Volume: monitor-only — drives the video element, every track element, and
// the replacement OA; never written to export.
document.getElementById('masterVol').addEventListener('input', function() {
document.getElementById('mvVal').textContent = this.value + '%';
const vol = this.value / 100;
if (videoLoaded) videoEl.volume = vol;
trackMap.forEach(({ audio }) => { audio.volume = vol; });
if (oaReplaceAudio) oaReplaceAudio.volume = vol;
});
// Waveform zoom — live-tunes --waveform-scale (100..400 → 1.0..4.0×), then repaints
// all canvases (a CSS-var change alone won't trigger the resize-based redraw).
document.getElementById('waveZoom').addEventListener('input', function() {
const scale = this.value / 100;
document.getElementById('wzVal').textContent = scale.toFixed(1) + '×';
document.documentElement.style.setProperty('--waveform-scale', scale);
redrawAllWaveforms();
});
/** MARK: ORIGINAL AUDIO TOGGLE */
/**
* Drive the OA toggle to an explicit state (used by the click handler and by project
* restore). OFF mutes the OA gain node and — since no-OA mode changes the mix
* reference — lights AUTO-MIX if any tracks are already analyzed. ON restores the
* normalisation gain and lights AUTO-MIX when OA is analyzed and tracks are loaded
* (switching INTO OA mode makes OA the anchor, so a recalc is pending — mirroring
* the OFF branch and the OA-analysis path).
* @param {boolean} enabled - Desired OA state.
* @returns {void}
*/
function setOaEnabled(enabled) {
const toggle = document.getElementById('oaToggle');
const thumb = toggle.querySelector('.toggle-thumb');
const label = document.getElementById('oaLabel');
if (!enabled) {
toggle.style.background = '#1E2530'; thumb.style.right = '14px';
label.style.color = 'var(--text-dim)'; label.textContent = 'OFF'; toggle.dataset.off = '1';
oaEnabled = false;
if (oaGainNode) oaGainNode.gain.value = 0.0;
updateMusicLevelVisibility();
if ([...trackMap.values()].some(t => t.lufs !== null)) markAutomixReady();
} else {
toggle.style.background = 'var(--green)'; thumb.style.right = '2px';
label.style.color = '#2E7D4F'; label.textContent = 'ON'; delete toggle.dataset.off;
oaEnabled = true;
if (oaGainNode) oaGainNode.gain.value = oaNormGain;
updateMusicLevelVisibility();
if (oaLufs !== null && trackMap.size > 0) markAutomixReady();
}
}
document.getElementById('oaToggle').addEventListener('click', function() {
setOaEnabled(!!this.dataset.off); // dataset.off set → currently OFF → turn ON, and vice-versa
});
/** MARK: PREVIEW */
/**
* Light the AUTO-MIX button's ready glow.
* @returns {void}
*/
function markAutomixReady() { document.getElementById('btnAutomix').classList.add('ready'); }
/**
* Clear the AUTO-MIX button's ready glow.
* @returns {void}
*/
function clearAutomixReady() { document.getElementById('btnAutomix').classList.remove('ready'); }
/**
* Show the Music Level slider only in OA mode — hidden when OA is absent or
* disabled.
* @returns {void}
*/
function updateMusicLevelVisibility() {
const show = oaEnabled && oaLufs !== null;
document.getElementById('musicLevelGroup').style.display = show ? '' : 'none';
document.getElementById('musicLevelDivider').style.display = show ? '' : 'none';
}
/**
* The active track card's Preview button, or null when no track is active.
* @returns {HTMLButtonElement|null}
*/
function getActivePreviewBtn() { return document.querySelector('.track-card.active .btn-track-preview'); }
/**
* The active track card's Restart button, or null when no track is active.
* @returns {HTMLButtonElement|null}
*/
function getActiveRestartBtn() { return document.querySelector('.track-card.active .btn-track-restart'); }
/**
* Grey out the Restart buttons while the playhead is at the start. Restart seeks to
* the trim In point, so "at start" is measured from vIn() (not absolute 0);
* otherwise a video trimmed past 0.5s never greys the button when parked at the In
* point.
* @returns {void}
*/
function syncRestartBtn() {
const atStart = !videoLoaded || videoEl.currentTime < vIn() + 0.5;
btnRestart.disabled = atStart;
const btn = getActiveRestartBtn();
if (btn) btn.disabled = atStart;
}
/**
* Enable/disable the active card's Preview button based on whether a video is
* loaded, tearing down a running preview when it becomes unavailable.
* @returns {void}
*/
function updatePreviewBtn() {
const ready = videoLoaded;
const btn = getActivePreviewBtn();
if (btn) btn.disabled = !ready;
if (!ready && isPreviewing) pauseAll();
}
/**
* Plain (no active track) video playback, confined to the trim window. A fresh
* start outside the window (incl. at 0 or parked at Out) begins at the In point.
* Starts the replacement OA in sync (no-op if none) and arms the animation loop.
* @returns {void}
*/
function playVideoBounded() {
if (videoEl.currentTime < vIn() || videoEl.currentTime >= vOut() - 0.001)
videoEl.currentTime = vIn();
_anchorVideo();
videoEl.play();
oaReplacePlayFrom(videoEl.currentTime);
btnPlay.innerHTML = '<span>⏸</span>';
startWaveformAnimation();
}
/**
* Start preview: video + active track (+ replacement OA) from the current scrub
* position. If the video ran all the way to its end it rewinds to 00:00 before
* computing sync positions — browsers leave currentTime at duration after 'ended',
* so without this both video and track would try to start from the last frame. The
* start position is clamped into the video trim window so preview begins at the In
* point and never starts past Out (Out enforcement happens in the rAF loop). The
* track is seeked to its offset position so the two start together — unless the
* Music Start marker sits ahead of where the video is starting, in which case the
* track must NOT sound yet: it's parked pre-seeked at its own In point and the video
* starts alone, with the rAF audibility gate bringing it in with a bare play() when
* the playhead reaches the marker (without this, the target would be below aIn —
* possibly negative — and the clamp would start the trimmed-off head immediately).
* The audio seek is awaited before playing (audio.play() can otherwise start from
* the wrong position on tracks that haven't been played before, or when resuming
* mid-video; an identity seek resolves immediately to avoid a hung seeked-await).
* All elements then start simultaneously, and playback is confirmed before the
* animation loop launches — otherwise the loop could see paused=true on its first
* tick and immediately self-terminate. Bails at each await if the user switched
* tracks, removed this one, or stopped. Finally schedules the tail fade if the
* track outlasts the video trim window.
* @returns {Promise<void>}
*/
async function startPreview() {
const t = trackMap.get(activeTrackId);
if (!t) return;
pauseAll(); // resets isPreviewing=false and button state
if (videoEl.ended) videoEl.currentTime = 0;
if (videoEl.currentTime < vIn() || videoEl.currentTime >= vOut()) {
videoEl.currentTime = vIn();
}
const startId = activeTrackId;
const trackDur = t.audio.duration || 0;
const target = trackTimeFor(t, videoEl.currentTime);
const startsSilent = videoEl.currentTime < mStart() - 0.001;
const syncTime = startsSilent
? aIn(t)
: Math.max(0, trackDur > 0 ? Math.min(target, trackDur) : target);
await new Promise(resolve => {
if (Math.abs(t.audio.currentTime - syncTime) < 0.05) { resolve(); return; }
t.audio.addEventListener('seeked', resolve, { once: true });
t.audio.currentTime = syncTime;
});
if (activeTrackId !== startId || !trackMap.has(startId)) return;
_anchorVideo();
_anchorAudio(t.audio);
const playPromises = [videoEl.play()];
if (!startsSilent) playPromises.push(t.audio.play()); // silent pre-roll before the marker: video only
const oaPlay = oaReplacePlayFrom(videoEl.currentTime);
if (oaPlay) playPromises.push(oaPlay);
await Promise.all(playPromises);
if (activeTrackId !== startId || !trackMap.has(startId)) return;
isPreviewing = true;
btnPlay.innerHTML = '<span>⏸</span>';
const btn = getActivePreviewBtn();
if (btn) { btn.innerHTML = '■ Stop'; btn.classList.add('previewing'); }
scheduleTrackFade(t, videoEl.currentTime, vOut());
startWaveformAnimation();
}
/**
* Stop preview. pauseAll resets isPreviewing and the button label.
* @returns {void}
*/
function stopPreview() {
pauseAll();
}
/** MARK: SAVE LAYBACKS MODAL */
/*
* Save Laybacks opens the modal after pre-flight checks (entitlement, video + at
* least one track loaded, every track path available). Folder precedence: a folder
* the user picked THIS session (lastOutputDir) wins, so their in-session choice is
* sticky; otherwise the persisted default from Settings — get-settings resolves an
* unset default to ~/Downloads, so that single value already covers the "no
* configured default" case. The modal is then populated and reset to its initial
* ready state; the path fit must run AFTER showModal, since a closed dialog has no
* layout width to measure.
*/
document.getElementById('btnSave').addEventListener('click', async () => {
if (!requireCanSave()) return;
if (!videoLoaded || !currentVideoPath) {
showError('Load a video before saving');
return;
}
if (trackMap.size === 0) {
showError('Add at least one track before saving');
return;
}
const trackEntries = [...trackMap.entries()];
if (trackEntries.some(([, t]) => !t.filePath)) {
showError('Some track paths are unavailable — reload the file');
return;
}
const cfg = await window.ezl.getSettings();
modalDir = lastOutputDir ?? cfg.files.defaultSaveFolder; // lastOutputDir is only ever set from user-chosen folders
nameAffixText = cfg.files.nameAffix.text;
nameAffixPos = cfg.files.nameAffix.position;
document.getElementById('saveModalFolderPath').textContent = modalDir;
document.getElementById('saveModalTracks').innerHTML = trackEntries
.map(([, t]) => `<div class="modal-track-item">${escHtml(t.name ?? t.filePath.split(/[\\/]/).pop())}</div>`)
.join('');
const statusEl = document.getElementById('saveModalStatus');
const btnGo = document.getElementById('btnSaveModalGo');
const btnDone = document.getElementById('btnSaveModalDone');
const btnCancel = document.getElementById('btnSaveModalCancel');
const btnChange = document.getElementById('btnChangeFolder');
statusEl.innerHTML = '';
statusEl.classList.remove('error');
btnGo.textContent = 'GO';
btnGo.disabled = false;
btnGo.classList.remove('hidden');
btnDone.classList.add('hidden');
btnCancel.classList.add('hidden');
btnCancel.disabled = false;
btnChange.disabled = false;
exportCancelled = false;
userSelectedFolder = false;
lastExportError = null;
lastErrorDetail = null;
document.getElementById('saveModal').showModal();
fitFolderPath(document.getElementById('saveModalFolderPath'), modalDir);
});
/**
* Fit a filesystem path into a fixed-width, single-line field, favoring the TAIL —
* the folder that actually receives the files. Whole leading path segments are
* dropped one at a time (never chopping a name mid-word) and "…/" is prefixed, so a
* long path reads e.g. "…/VS_CODE/EZL_Redux/Laybacks_k7mq". If even the final
* segment alone overflows (a single folder name wider than the row), the "…/<name>"
* candidate is left and the CSS ellipsis clips its tail — rare edge. Relies on live
* layout, so the element must be visible when called (a closed dialog reports zero
* width). Shared by the Save and Settings modals.
* @param {HTMLElement} el - Field to write into.
* @param {string} dir - Full path to fit (Windows-style separators honored).
* @returns {void}
*/
function fitFolderPath(el, dir) {
el.textContent = dir;
if (el.scrollWidth <= el.clientWidth) return;
const sep = dir.includes('\\') ? '\\' : '/';
const parts = dir.split(sep);
for (let i = 1; i < parts.length; i++) {
el.textContent = '…' + sep + parts.slice(i).join(sep);
if (el.scrollWidth <= el.clientWidth) return;
}
}
// "Change…" — lets the user pick a different destination folder
document.getElementById('btnChangeFolder').addEventListener('click', async () => {
const chosen = await window.ezl.showFolderPicker();
if (chosen) {
modalDir = chosen;
userSelectedFolder = true;
fitFolderPath(document.getElementById('saveModalFolderPath'), modalDir);
}
});
// "×" close button — cancels any in-flight export and closes
document.getElementById('btnSaveModalClose').addEventListener('click', () => {
exportCancelled = true;
document.getElementById('saveModal').close();
});
/*
* Escape dismisses the native <dialog> on its own (fires 'cancel', then closes).
* Mirror the × button so a mid-export Escape cancels the batch too; without this
* the modal vanishes while the GO loop keeps writing the remaining files.
*/
document.getElementById('saveModal').addEventListener('cancel', () => {
exportCancelled = true;
});
// "Close" button — only visible after a full export failure
document.getElementById('btnSaveModalDone').addEventListener('click', () => {
document.getElementById('saveModal').close();
});
// Cancel button — sets flag; the GO loop checks it between tracks
document.getElementById('btnSaveModalCancel').addEventListener('click', () => {
exportCancelled = true;
document.getElementById('btnSaveModalCancel').disabled = true;
});
/*
* GO — exports one track at a time so the progress counter can advance. More than
* one layback gets wrapped in an EZL_<video> subfolder so the batch stays together;
* a single layback writes straight into the chosen parent (no wrapper). The worker
* creates whichever directory is sent and enumerates a Finder-style " (n)" suffix on
* any filename collision, so nothing is clobbered and no overwrite pre-check is
* needed. Settings feed the post-export metadata stamp (artist/album/artwork/etc),
* read once per batch — the open-handler's cfg is a different scope, so it can't be
* reused here. Per-file: the list auto-scrolls so the file being written stays in
* view ('nearest' scrolls only the list, and only when the item isn't already
* visible, so the done/failed colour and the " → filename" suffix land in view);
* successes stamp metadata (non-fatal) and record the worker's show_in_finder_path
* (a written file — showItemInFolder opens its containing folder with it selected;
* the last one feeds the Show-in-Finder link); failures record the friendly error
* (shown) and the raw detail (copied via the copy button). Completion shows saved
* count + Show in Finder, "Export cancelled." with GO restored, or — on full
* failure — the error with a copy button and GO swapped for Close. Only explicitly
* user-chosen folders are remembered for the next open.
*/
document.getElementById('btnSaveModalGo').addEventListener('click', async () => {
if (exportInProgress) return; // guard against double-fire during completion state
exportInProgress = true;
const trackEntries = [...trackMap.entries()];
const total = trackEntries.length;
const btnGo = document.getElementById('btnSaveModalGo');
const btnDone = document.getElementById('btnSaveModalDone');
const btnCancel = document.getElementById('btnSaveModalCancel');
const btnChange = document.getElementById('btnChangeFolder');
const statusEl = document.getElementById('saveModalStatus');
const items = document.getElementById('saveModalTracks').querySelectorAll('.modal-track-item');
if (total > 1) {
modalDir = await genEzlFolder(modalDir, currentVideoPath);
fitFolderPath(document.getElementById('saveModalFolderPath'), modalDir);
}
btnGo.disabled = true;
btnChange.disabled = true;
btnCancel.classList.remove('hidden');
exportCancelled = false;
lastExportError = null;
lastErrorDetail = null;
let savedCount = 0;
let failedCount = 0;
let lastRevealPath = modalDir // replaced with worker's show_in_finder_path from the last successful export
const cfg = await window.ezl.getSettings();
for (let i = 0; i < total; i++) {
if (exportCancelled) break;
const [id, t] = trackEntries[i];
btnGo.textContent = `${i + 1} / ${total}`;
statusEl.textContent = `Saving layback ${i + 1} of ${total}…`;
items[i]?.scrollIntoView({ block: 'nearest' });
const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
const db = parseFloat(card?.querySelector('.s-trim')?.value ?? '0');
const level = Math.pow(10, db / 20);
try {
const result = await window.ezl.exportLaybacks({
video_path: currentVideoPath,
audio_paths: [t.filePath],
output_dir: modalDir,
added_text: nameAffixText,
added_text_pos: nameAffixPos,
oa_peak_dbfs: oaPeakDbfs,
oa_enabled: oaEnabled,
// OA replacement: a separate file stands in for the video's embedded stream (null =
// use embedded). video_duration lets the worker crop the replacement to the video end.
oa_replace_path: oaReplacePath,
video_duration: videoEl.duration || 0,
audio_levels: [level],
audio_codec: 'aac',
video_codec: 'copy',
// Trim window in seconds; null end means "no out-trim" to the backend
video_trim_start: videoTrimStart || 0,
video_trim_end: videoTrimEnd,
audio_trim_starts: [t.trimStart || 0],
audio_trim_ends: [t.trimEnd],
// Music Start: seconds of silence before this track comes in, measured from the
// layback's own zero (the video In point). 0 = music starts with picture. Sent as a
// relative delay rather than an absolute marker time because the worker rebases each
// track to t=0 of the OUTPUT timeline (atrim + asetpts=PTS-STARTPTS), so a delay is
// exactly what its filter graph consumes — no video_trim_start arithmetic repeated
// across the IPC boundary.
audio_delays: [Math.max(0, mStart() - vIn())],
// Tail fade-out matching the preview fade (only when track outlasts the video)
audio_fade_outs: [exportFadeOutFor(t)],
});
if (result.status === 'ok') {
await applyExportMetadata(result.output_paths, cfg);
lastRevealPath = result.show_in_finder_paths?.[0] ?? lastRevealPath;
const displayName = result.display_names?.[0] ?? 'layback';
savedCount++;
if (items[i]) items[i].textContent += ` → ${displayName}`;
items[i]?.classList.add('done');
} else {
failedCount++;
lastExportError = result.error ?? 'Export failed'; // friendly (shown)
lastErrorDetail = result.error_detail ?? result.error ?? ''; // raw (copied)
items[i]?.classList.add('failed');
}
} catch (err) {
failedCount++;
lastExportError = 'The layback couldn\'t be saved.';
lastErrorDetail = err?.message ?? 'Unknown error';
items[i]?.classList.add('failed');
}
}
// Completion state
exportInProgress = false;
btnCancel.classList.add('hidden');
btnChange.disabled = false;
if (savedCount > 0) {
if (userSelectedFolder) lastOutputDir = modalDir; // only remember explicit user-chosen folders
const label = savedCount === 1 ? '1 layback saved.' : `${savedCount} laybacks saved.`;
statusEl.classList.remove('error');
statusEl.innerHTML =
`${label} <button class="btn-reveal" id="btnReveal">Show in Finder</button>`;
document.getElementById('btnReveal').addEventListener('click', () =>
window.ezl.revealInFolder(lastRevealPath ?? modalDir)
);
btnGo.classList.add('hidden');
btnDone.textContent = 'Done';
btnDone.classList.remove('hidden');
} else if (exportCancelled) {
statusEl.classList.remove('error');
statusEl.textContent = 'Export cancelled.';
btnGo.disabled = false;
btnGo.textContent = 'GO';
} else {
statusEl.classList.add('error');
statusEl.innerHTML =
escHtml(lastExportError ?? 'Export failed.') +
' <button class="btn-copy-error" id="btnCopyError" title="Copy error">' +
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">' +
'<rect x="9" y="9" width="13" height="13" rx="2"/>' +
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>' +
'</svg></button>';
document.getElementById('btnCopyError').addEventListener('click', () =>
navigator.clipboard.writeText(lastErrorDetail ?? lastExportError ?? '')
);
btnGo.classList.add('hidden');
btnDone.textContent = 'Close';
btnDone.classList.remove('hidden');
}
});
// Initial state: no video loaded, so Music Level slider is hidden until OA is present
updateMusicLevelVisibility();
/**
* MARK: SAVE / OPEN PROJECT — persists a working session — which video + tracks are
* loaded, their levels and trim windows, the Music Level offset, and the active
* track — to a .ezlproj JSON file. NOT rendered output (that's Save Laybacks). Menu
* clicks arrive from main via onMenu; the renderer owns all the state, so it does
* the actual serialize/restore.
*/
/**
* Tear the current session down to an empty slate. Reuses the existing remove-track
* and unload-video teardown so blob URLs, audio elements, and GainNodes are released
* cleanly. Track ids are snapshotted first because removeTrack mutates the map. The
* Music Start marker is cleared explicitly — redundant when a video was loaded
* (unloadVideo clears it), but this is the declared empty-slate function and its
* unload is conditional. Music Level returns to the -12 default.
* @returns {void}
*/
function clearSession() {
if (isPreviewing) stopPreview();
[...trackMap.keys()].forEach(id => removeTrack(id));
if (videoLoaded) unloadVideo();
musicStartTime = null;
const ml = document.getElementById('musicLevel');
ml.value = -12;
_lastMusicLevel = -12;
document.getElementById('mlVal').textContent = '-12';
clearAutomixReady();
}
/**
* New Project — just clear. An unsaved-changes prompt is intentionally out of scope
* for v1 (see design §11); a future "dirty" flag could warn here.
* @returns {void}
*/
function newProject() { clearSession(); }
/**
* Save the working session to a .ezlproj file chosen via the native dialog. Tracks
* are walked from the DOM cards (not Map order) so on-disk track order matches what
* the user sees; each persists its path, name, volume, trimStart, and trimEndTarget
* (the user's intent — the effective end is derived on restore). The video block
* saves the RAW musicStartTime, not mStart(): null = not placed, so "unset"
* round-trips as unset instead of hardening into a number at wherever In happens to
* sit — same discipline as trimEndTarget. trimEnd null = full extent (no out-trim).
* @returns {Promise<void>}
*/
async function saveProject() {
if (!window.ezl) return;
const filePath = await window.ezl.saveProjectDialog();
if (!filePath) return; // user cancelled the dialog
const tracks = [];
document.querySelectorAll('.track-card').forEach(card => {
const id = parseInt(card.dataset.trackId);
const t = trackMap.get(id);
if (!t) return;
tracks.push({
path: t.filePath,
name: card.querySelector('.track-name')?.textContent ?? null,
volumeDb: parseFloat(card.querySelector('.s-trim').value),
trimStart: t.trimStart || 0,
trimEndTarget: t.trimEndTarget ?? null,
});
});
const activeTrack = activeTrackId !== null ? trackMap.get(activeTrackId) : null;
const project = ProjectFile.build({
video: (videoLoaded && currentVideoPath) ? {
path: currentVideoPath,
oaEnabled,
trimStart: videoTrimStart || 0,
trimEnd: videoTrimEnd,
musicStart: musicStartTime,
} : null,
musicLevel: parseInt(document.getElementById('musicLevel').value),
activeTrackPath: activeTrack?.filePath ?? null,
tracks,
});
const res = await window.ezl.writeProjectFile(filePath, project);
if (res?.ok) showToast('Project saved');
else showError("The project couldn't be saved.", res?.error);
}
/**
* Open a .ezlproj file chosen via the native dialog and restore it.
* ProjectFile.parse throws on bad version / not-a-project with a friendly message.
* @returns {Promise<void>}
*/
async function openProject() {
if (!window.ezl) return;
const filePath = await window.ezl.openProjectDialog();
if (!filePath) return; // cancelled
const raw = await window.ezl.readProjectFile(filePath);
if (raw?.error) { showError("The project couldn't be opened.", raw.error); return; }
let project;
try {
project = ProjectFile.parse(raw);
} catch (e) {
showError(e.message);
return;
}
await restoreProject(project);
}
/**
* Restore a parsed project into a fresh session. The video loads first so its
* duration is known before trims resolve: the permanent loadedmetadata listener
* resets the video trim to full + re-fits tracks, and listeners fire in
* registration order, so a once-listener added here (later) runs AFTER it — applying
* the saved trim window and Music Start marker (set before the relayout, so the
* marker lands with the handles), then re-syncing every track's Out handle (the cap
* it computes is marker-aware). Each present track file is restored with its saved
* volume + trim; missing files are collected. Music Level is set AFTER tracks so the
* saved absolute per-track volumes aren't shifted by the slider's delta logic
* (updating .value doesn't fire 'input'). The active track is re-resolved by path
* (ids are minted fresh each session); if the saved active track is missing/absent,
* none is active — same as a fresh load with tracks. Ends with a non-fatal
* missing-files modal when anything couldn't be found.
* @param {object} project - Parsed project (ProjectFile.parse output).
* @returns {Promise<void>}
*/
async function restoreProject(project) {
clearSession();
const missing = [];
const exists = async p => { try { return !!p && await window.ezl.pathExists(p); } catch { return false; } };
if (project.video?.path) {
if (await exists(project.video.path)) {
const v = project.video;
videoEl.addEventListener('loadedmetadata', () => {
videoTrimStart = v.trimStart || 0;
videoTrimEnd = v.trimEnd ?? null;
musicStartTime = v.musicStart ?? null;
updateVideoTrimUI();
syncAudioEndsToVideo();
}, { once: true });
loadVideoDescriptor(descriptorFromPath(v.path));
setOaEnabled(v.oaEnabled !== false);
} else {
missing.push(project.video.path);
}
}
for (const tr of project.tracks) {
if (await exists(tr.path)) {
addTrackDescriptor(descriptorFromPath(tr.path, tr.name), {
volumeDb: tr.volumeDb,
trimStart: tr.trimStart,
trimEndTarget: tr.trimEndTarget,
});
} else {
missing.push(tr.path);
}
}
updateHeader();
updatePreviewBtn();
const ml = document.getElementById('musicLevel');
ml.value = project.musicLevel;
_lastMusicLevel = parseInt(ml.value);
document.getElementById('mlVal').textContent = ml.value;
if (project.activeTrackPath) {
for (const [id, t] of trackMap) {
if (t.filePath === project.activeTrackPath) { setActiveTrack(id); break; }
}
}
if (missing.length) showMissingFiles(missing);
}
/**
* Non-fatal "files not found" modal — the rest of the project has already loaded.
* @param {string[]} paths - Paths that couldn't be found (basenames are shown).
* @returns {void}
*/
function showMissingFiles(paths) {
const modal = document.getElementById('missingModal');
const plural = paths.length === 1 ? '' : 's';
document.getElementById('missingMessage').textContent =
`${paths.length} file${plural} couldn't be found and ${paths.length === 1 ? 'was' : 'were'} skipped:`;
document.getElementById('missingList').innerHTML =
paths.map(p => `<span class="fname">${escHtml(String(p).split(/[\\/]/).pop())}</span>`).join('');
modal.showModal();
}
document.getElementById('btnMissingOk').addEventListener('click', () => document.getElementById('missingModal').close());
document.getElementById('btnMissingClose').addEventListener('click', () => document.getElementById('missingModal').close());
// Wire the main→renderer menu events (Save/Open/New Project).
if (window.ezl?.onMenu) {
window.ezl.onMenu('menu:save-project', () => saveProject());
window.ezl.onMenu('menu:open-project', () => openProject());
window.ezl.onMenu('menu:new-project', () => newProject());
}
/**
* MARK: AUTH & SUBSCRIPTION — the renderer never touches tokens; it asks the main
* process for the verified entitlement payload and gates features on its flags.
*/
/** Verified entitlement payload from main, or null when signed out. */
let entitlement = null;
/**
* True when the current session came from the offline cache (main couldn't reach
* the API and fell back to the verified cached entitlement). Refreshed wherever a
* session is read: launch restore, sign-in, account-modal open.
* @type {boolean}
*/
let sessionOffline = false;
/**
* Shared guard for both save paths; explains itself instead of failing silently.
* The wording per state (signed out / device cap / expired) lives in
* device-state.js, unit-tested. Blocked saves get the expired modal — same copy and
* Manage Account action as the launch-gate notice.
* @returns {boolean} True when saving is allowed.
*/
function requireCanSave() {
const blocked = DeviceState.saveBlockedMessage(entitlement);
if (!blocked) return true;
showExpired(blocked);
return false;
}
const authGate = document.getElementById('authGate');
const authError = document.getElementById('authError');
/**
* Show the auth gate. It fully covers the app — no dismissing, no peeking behind
* it. Clears the checking/notice states so the sign-in form is visible (sign-out /
* lost-session callers want the form, not the splash or a session notice).
* @returns {void}
*/
function showAuthGate() {
authGate.classList.remove('hidden', 'checking', 'notice');
document.getElementById('authEmail').focus();
}
/**
* Hide the auth gate, revealing the app.
* @returns {void}
*/
function hideAuthGate() { authGate.classList.add('hidden'); }
/**
* Store the verified entitlement payload and re-evaluate the Save Laybacks gate.
* @param {object|null} payload - Verified payload, or null on sign-out.
* @returns {void}
*/
function setEntitlement(payload) {
entitlement = payload;
updateHeader();
}
/**
* Days left in the trial, or null when the status isn't trialing (or the payload
* lacks the field).
* @returns {number|null} Whole days remaining, floored at 0.
*/
function trialDaysLeft() {
if (entitlement?.status !== 'trialing' || !entitlement.trial_ends_at) return null;
return Math.max(0, Math.ceil((Date.parse(entitlement.trial_ends_at) - Date.now()) / 86400000));
}
/**
* Days until the cached entitlement's signed valid_until — the offline grace
* deadline. Main already refused the cache if valid_until passed, so this is only
* ever rendered while the window is still open.
* @returns {number|null} Whole days remaining, floored at 0, or null when unknown.
*/
function offlineDaysLeft() {
if (!entitlement?.valid_until) return null;
return Math.max(0, Math.ceil((Date.parse(entitlement.valid_until) - Date.now()) / 86400000));
}
/**
* Offline-notice copy shared by the gate notice and the account-modal banner. Plain
* language only — never mentions caches, entitlements, or signatures. "a few days"
* covers an old cached payload without valid_until.
* @returns {string} The message.
*/
function offlineMessage() {
const days = offlineDaysLeft();
const remaining = days === null ? 'a few days'
: days === 0 ? 'less than a day'
: `${days} day${days === 1 ? '' : 's'}`;
return `You’re currently offline. You have ${remaining} remaining before you need to sign in.`;
}
/**
* Trial warning, only during the final week. Silent before that (a fresh 14-day
* trial shouldn't nag) and for every non-trial status.
* @returns {string|null} The message, or null when no warning applies.
*/
function trialMessage() {
const days = trialDaysLeft();
if (days === null || days >= 7) return null;
return days === 0 ? 'Your free trial ends today.'
: `Your free trial ends in ${days} day${days === 1 ? '' : 's'}.`;
}
/**
* Review-only mode explained up front, instead of a silently disabled Save button.
* The sentence itself (trial vs subscription wording) lives in device-state.js —
* unit-tested, and shared with the expired modal and the account banner so the copy
* can never drift apart.
* @returns {string|null} The message, or null when not expired.
*/
function expiredMessage() {
if (entitlement?.status !== 'expired') return null;
return DeviceState.saveBlockedMessage(entitlement);
}
/**
* The one notice the current session warrants, or null. Offline outranks everything
* (the sign-in deadline is the harder one, and stacking two countdowns on the gate
* would bury both); expired outranks the trial countdown. The expired flag tells the
* gate which button to offer: Continue for informational notices, Manage Account
* for the expired one.
* @returns {{msg: string, expired: boolean}|null} Notice descriptor, or null.
*/
function sessionNotice() {
if (sessionOffline) return { msg: offlineMessage(), expired: false };
const expired = expiredMessage();
if (expired) return { msg: expired, expired: true };
const trial = trialMessage();
return trial ? { msg: trial, expired: false } : null;
}
/**
* Humanize an ISO date for near-term cases so they read like speech — "renews
* 6/13/2026" reads as a calendar date even when the date IS today. Compares
* CALENDAR days, not 24h chunks — otherwise a target of 11pm today would show as
* "tomorrow" from 1am, since the gap exceeds 24h. Distant dates always include the
* year — "Jul 13" is ambiguous when the cycle could be monthly or annual.
* @param {string} iso - ISO date string.
* @returns {string} "today", "tomorrow", "in N days", or a formatted date.
*/
function humanDate(iso) {
const startOfDay = t => { const d = new Date(t); d.setHours(0,0,0,0); return d.getTime(); };
const days = Math.round((startOfDay(Date.parse(iso)) - startOfDay(Date.now())) / 86400000);
if (days === 0) return 'today';
if (days === 1) return 'tomorrow';
if (days > 1 && days <= 7) return `in ${days} days`;
return new Date(iso).toLocaleDateString(undefined,
{ month: 'short', day: 'numeric', year: 'numeric' });
}
/**
* Plain-English plan line for the account modal (no internal jargon).
* @returns {string} e.g. "Pro (free trial)" or "Pro".
*/
function describePlan() {
const tier = entitlement.tier[0].toUpperCase() + entitlement.tier.slice(1);
const days = trialDaysLeft();
return days !== null ? `${tier} (free trial)` : tier;
}
/**
* Plain-English status line for the account modal. cancel_at_period_end keeps
* status 'active' but the sub won't renew, so it reads "expires" instead of
* "renews".
* @returns {string} The status line.
*/
function describeStatus() {
const days = trialDaysLeft();
const end = entitlement.current_period_end;
switch (entitlement.status) {
case 'trialing': return days === 0 ? 'Trial ends today' : `Trial — ${days} day${days === 1 ? '' : 's'} left`;
case 'active':
if (!end) return 'Active';
return entitlement.cancel_at_period_end
? `Active — expires ${humanDate(end)}`
: `Active — renews ${humanDate(end)}`;
case 'past_due': return 'Payment problem — update your payment method';
case 'canceled': return end ? `Canceled — works until ${humanDate(end)}` : 'Canceled';
default: return 'Expired — upgrade to save laybacks';
}
}
// Sign-in form: submit to main, surface the error inline on failure, otherwise
// store the payload and drop the gate. A successful sign-in is by definition online.
document.getElementById('authForm').addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('btnAuthSubmit');
btn.disabled = true;
authError.textContent = '';
const result = await window.ezl.authLogin({
email: document.getElementById('authEmail').value.trim(),
password: document.getElementById('authPassword').value,
});
btn.disabled = false;
if (!result.ok) { authError.textContent = result.error; return; }
sessionOffline = false;
setEntitlement(result.payload);
document.getElementById('authPassword').value = '';
hideAuthGate();
});
// Window title = just the version, from the packaged app itself (single source of
// truth; the static <title> is a fallback for the first paint).
window.ezl.getAppInfo().then(i => { document.title = `EZLaybacks v${i.version}`; }).catch(() => {});
/*
* Launch: silent session restore (refresh token → fresh entitlement, or the
* verified offline cache). No session → the app asks for one. The gate goes up
* immediately — the app must not flash while the session check runs — and
* `checking` shows only the branded splash (no form) so a returning user doesn't
* see the Sign In form flash before the app opens. If the session warrants a notice
* (offline fallback, expired trial/subscription, trial ending soon), the gate
* pauses on it instead of dismissing silently — interactive sign-ins never pause;
* the pause is a launch-restore feature only. Expired offers Manage Account (opens
* the portal AND enters the app), otherwise a plain Continue; both buttons are
* reset since the gate can re-show.
*/
(async () => {
authGate.classList.remove('hidden');
authGate.classList.add('checking');
const session = await window.ezl.authSession();
sessionOffline = !!session.offline;
if (session.loggedIn) {
setEntitlement(session.payload); // the app behind the gate is fully armed
const notice = sessionNotice();
if (notice) {
document.getElementById('authNoticeMsg').textContent = notice.msg;
document.getElementById('btnAuthNoticeContinue').hidden = notice.expired;
document.getElementById('btnAuthNoticeManage').hidden = !notice.expired;
authGate.classList.remove('checking');
authGate.classList.add('notice');
} else { hideAuthGate(); }
}
else { authGate.classList.remove('checking'); } // no session → reveal the form
})();
document.getElementById('btnAuthNoticeContinue').addEventListener('click', () => {
hideAuthGate();
});
/*
* Expired notice's one button: open the account portal (same SSO handoff as the
* account modal's Manage account) and enter the app — review-only mode still works,
* so the gate shouldn't hold the user hostage meanwhile.
*/
document.getElementById('btnAuthNoticeManage').addEventListener('click', () => {
window.ezl.openAccountPortal();
hideAuthGate();
});
/*
* Background entitlement refresh pushed from main (auth.js schedules one just past
* the trial/period boundary, else every 24h). This is how a subscription that
* lapses MID-SESSION reaches the UI without a relaunch or modal reopen. A session
* revoked server-side (or with tokens gone) goes back to the gate, like sign-out.
* If saving just flipped off under the user's feet, that's said once, with the same
* modal a blocked Save click shows — unless the Save modal is up (an export may be
* mid-batch; never stack dialogs over it — the guard on the next save attempt tells
* them instead).
*/
window.ezl.onMenu('auth:session', (session) => {
if (!session.loggedIn) {
setEntitlement(null);
sessionOffline = !!session.offline;
showAuthGate();
return;
}
const couldSave = !!entitlement?.features?.can_save;
sessionOffline = !!session.offline;
setEntitlement(session.payload); // updateHeader() picks up any state change
if (couldSave && !session.payload.features?.can_save
&& !document.getElementById('saveModal').open) {
showExpired(DeviceState.saveBlockedMessage(session.payload));
}
});