renderer
Source: renderer.js:10
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.
Instance Methods
getAudioCtx(): AudioContext
Return the shared AudioContext, creating a fresh one if none exists or the previous one was closed.
Returns
AudioContext— The live shared context.
decodeAudio(blobUrl: string): Promise.<AudioBuffer>
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.
Parameters
blobUrl(string) — Object URL of the audio file to decode.
Returns
Promise.<AudioBuffer>— The decoded buffer.
peaksFromBuffer(
audioBuf: AudioBuffer,
numSamples?: number,
): Float32Array
Reduce a decoded buffer to numSamples normalized peak magnitudes for the waveform. Peaks are normalized so the loudest one fills the canvas height.
Parameters
audioBuf(AudioBuffer) — Decoded audio to summarize.numSamples(number, optional, default: 1000) — Number of peak buckets to produce.
Returns
Float32Array— Normalized (0-1) peak magnitudes, one per bucket.
drawPeaks(
canvas: HTMLCanvasElement,
peaks: Float32Array | Array.<number>,
color: string,
): void
Draw a static bar waveform onto a canvas from a peaks array, scaled for the device pixel ratio.
Parameters
canvas(HTMLCanvasElement) — Target canvas.peaks(Float32Array | Array.<number>) — Normalized peak magnitudes.color(string) — Fill color for the bars.
Returns
void
rng(seed: number): function
Seeded pseudo-random number generator for deterministic placeholder waveforms.
Parameters
seed(number) — Seed value; the same seed reproduces the same sequence.
Returns
function— Function returning the next pseudo-random value in [0, 1).
drawWavePlaceholder(
canvas: HTMLCanvasElement,
seed: number,
color: string,
): void
Draw a seeded random placeholder waveform, shown while the real decode runs in the background.
Parameters
canvas(HTMLCanvasElement) — Target canvas.seed(number) — RNG seed so the placeholder is stable across redraws.color(string) — Fill color for the bars.
Returns
void
getTrackColor(active: boolean): string
Waveform bar color for a track card.
Parameters
active(boolean) — Whether the card is the active track.
Returns
string— CSS color string.
redrawAllWaveforms(): void
Redraw the OA waveform and every track card's waveform (real peaks where known, placeholder otherwise). Bound to window load and resize.
Returns
void
showVofxPeaks(peaks: Array.<number>, videoPath: string): void
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.
Parameters
peaks(Array.<number>) — Normalized peak magnitudes from the worker.videoPath(string) — Path the analysis ran for; identity guard against swaps.
Returns
void
oaDisplayPeaks(
peaks: Array.<number> | Float32Array,
srcDurationSec: number,
): Float32Array
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.
Parameters
peaks(Array.<number> | Float32Array) — Source peak magnitudes.srcDurationSec(number) — Duration of the source audio in seconds.
Returns
Float32Array— Peaks laid out on the video timeline.
setOaWaveform(peaks: Array.<number> | Float32Array): void
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.
Parameters
peaks(Array.<number> | Float32Array) — Normalized peak magnitudes.
Returns
void
analyzeTrack(id: number, blobUrl: string): Promise.<void>
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.
Parameters
id(number) — Track id in trackMap.blobUrl(string) — The track's object URL; identity guard against removal.
Returns
Promise.<void>
loadPreviewViaBackend(
id: number,
blobUrl: string,
): Promise.<void>
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.
Parameters
id(number) — Track id in trackMap.blobUrl(string) — The track's object URL; identity guard against removal.
Returns
Promise.<void>
drawPeaksWithPlayhead(
canvas: HTMLCanvasElement,
peaks: Float32Array | Array.<number>,
playedColor: string,
unplayedColor: string,
progress: number,
): void
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.
Parameters
canvas(HTMLCanvasElement) — Target canvas.peaks(Float32Array | Array.<number>) — Normalized peak magnitudes.playedColor(string) — Fill for bars behind the playhead.unplayedColor(string) — Fill for bars ahead of the playhead.progress(number) — Played fraction, 0-1.
Returns
void
_anchorVideo(): void
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
getVideoTime(): number
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.
_anchorAudio(el: HTMLAudioElement): void
Refresh an audio element's clock anchor from its current time.
Parameters
el(HTMLAudioElement) — Element to anchor.
Returns
void
getAudioTime(el: HTMLAudioElement): number
Smooth, extrapolated playback position for an audio element, mirroring getVideoTime. Falls back to the raw currentTime when paused, unanchored, or duration is unknown.
Parameters
el(HTMLAudioElement) — Element to read.
Returns
number— Interpolated audio time in seconds.
startWaveformAnimation(): void
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
resetVofxWaveform(): void
Repaint the OA waveform in its resting (no playhead) state — real peaks if known, placeholder otherwise.
Returns
void
resetTrackWaveform(id: number): void
Repaint a track's waveform in its resting (no playhead) state — real peaks if known, placeholder otherwise.
Parameters
id(number) — Track id in trackMap.
Returns
void
drawTrackShade(id: number, progress: number): void
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.
Parameters
id(number) — Track id in trackMap.progress(number) — Played fraction 0-1; 1 shades the whole playable region, signalling the track is at/past its end.
Returns
void
refreshTrackShade(id: number, videoTime: number): void
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.
Parameters
id(number) — Track id in trackMap.videoTime(number) — Video-timeline position in seconds.
Returns
void
drawStaticVideoPlayhead(time: number): void
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.
Parameters
time(number) — Video time in seconds.
Returns
void
clamp01(x: number): number
Clamp a number into [0, 1].
Parameters
x(number) — Value to clamp.
Returns
number— Clamped value.
layoutTrim(
els: Object,
startFrac: number,
endFrac: number,
): void
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.
Parameters
els(Object) — Elements of one trim surface.startFrac(number) — In position as a fraction of the surface width (0-1).endFrac(number) — Out position as a fraction of the surface width (0-1).
Returns
void
videoTrimEls(): object
Trim-surface elements for the scrub bar.
Returns
object— Element map for layoutTrim.
oaTrimEls(): object
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.
updateVideoTrimUI(): void
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
updateMusicStartUI(): void
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
trackTrimEls(card: HTMLElement): object
Trim-surface elements for one track card's waveform.
Parameters
card(HTMLElement) — The track card element.
Returns
object— Element map for layoutTrim.
trackDuration(t: object | undefined): number
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).
Parameters
t(object | undefined) — Track entry from trackMap.
Returns
number— Duration in seconds, or 0 while unknown.
updateTrackTrimUI(id: number): void
Lay out one track's trim handles from its stored trim state. No-op until the track's duration is known.
Parameters
id(number) — Track id in trackMap.
Returns
void
videoCapForTrack(t: object | undefined): number
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).
Parameters
t(object | undefined) — Track entry from trackMap.
Returns
number— Cap in track-timeline seconds, or Infinity.
syncTrackEndToVideo(id: number): void
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.
Parameters
id(number) — Track id in trackMap.
Returns
void
syncAudioEndsToVideo(): void
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
paintPreviewFrame(sec: number): void
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.
Parameters
sec(number) — Video time in seconds whose frame to paint.
Returns
void
isPlaying(): boolean
True when the main player is running (full preview OR bare video playback).
Returns
boolean— Whether anything is playing.
previewFrameAt(sec: number, caption?: string): void
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.
Parameters
sec(number) — Video time in seconds.caption(string, optional, default: "''") — 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
hideFramePreview(): void
Hide the frame-preview thumbnail and give the filename its corner back.
Returns
void
attachTrimDrag(opt: object): void
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.
Parameters
opt(object) — Wiring options.opt.rectEl(HTMLElement) — Bar element measured for pixel-to-seconds math.opt.handleIn(HTMLElement) — In handle.opt.handleOut(HTMLElement) — Out handle.opt.handleRegion(HTMLElement, optional) — Draggable band between the handles.opt.tooltipEl(HTMLElement) — Tooltip shown while dragging.opt.getDuration(function) — Duration of the trimmed media in seconds.opt.getStart(function) — Current In point in seconds.opt.getEnd(function) — Current Out point (null = full extent).opt.getMaxEnd(function, optional) — Ceiling for the Out handle.opt.setStart(function) — Write the In point.opt.setEnd(function) — Write the Out point.opt.relayout(function) — Re-lay-out the handles after a state change.opt.onPreview(function, optional) — 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.opt.onRegionClick(function, optional) — Click-to-seek handler for a band click that never became a drag.
Returns
void
vIn(): number
Video trim In point in seconds (0 when untrimmed).
Returns
number
vOut(): number
Video trim Out point in seconds; defaults to the full duration when untrimmed.
Returns
number
aIn(t: object | undefined): number
A track's audio In point in seconds (0 when untrimmed).
Parameters
t(object | undefined) — Track entry from trackMap.
Returns
number
aOut(t: object | undefined): number
A track's effective audio Out point in seconds; falls back to the track duration (or Infinity while unknown) when untrimmed.
Parameters
t(object | undefined) — Track entry from trackMap.
Returns
number
trackTimeFor(t: object | undefined, videoTime: number): number
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)).
Parameters
t(object | undefined) — Track entry from trackMap.videoTime(number) — Video-timeline position in seconds.
Returns
number— Track-timeline position in seconds.
videoTimeForTrack(
t: object | undefined,
trackTime: number,
): number
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.
Parameters
t(object | undefined) — Track entry from trackMap.trackTime(number) — Track-timeline position in seconds.
Returns
number— Video-timeline position in seconds.
trimGap(): number
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.
mStart(): number
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.
parkTrackAt(t: object | undefined, videoTime: number): number
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.
Parameters
t(object | undefined) — Track entry from trackMap.videoTime(number) — Video-timeline position in seconds.
Returns
number— Track-timeline park position in seconds.
setMusicStart(sec: number | null): void
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.
Parameters
sec(number | null) — New marker position in seconds, or null to unset.
Returns
void
showMarkerReadout(sec: number): void
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.
Parameters
sec(number) — Marker time to display.
Returns
void
hideMarkerReadout(): void
Hide the Music Start readout and restore the filename's visibility.
Returns
void
attachMarkerDrag(opt: object): void
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.
Parameters
opt(object) — Wiring options.opt.rectEl(HTMLElement) — Bar element measured for pixel-to-seconds math (cached once at mousedown, like attachTrimDrag).opt.marker(HTMLElement) — The marker element to wire.
Returns
void
fmt(s: number): string
Format seconds as M:SS for tooltips and readouts. Non-finite input reads "0:00".
Parameters
s(number) — Time in seconds.
Returns
string— Formatted time.
scheduleTrackFade(
t: object | undefined,
videoCurrentTime: number,
videoDuration: number,
opts?: object,
): boolean
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.
Parameters
t(object | undefined) — Track entry from trackMap.videoCurrentTime(number) — Current video time in seconds.videoDuration(number) — End of the video window in seconds (vOut).opts(object, optional) — Overrides.opts.gain(number, optional) — Level the fade holds at, overriding gain.value — a slider's TARGET value, which gain.value doesn't yet read as mid-glide.opts.rampSec(number, optional, default: 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.
setTrackGain(
t: object | undefined,
id: number,
linear: number,
opts?: object,
): void
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.
Parameters
t(object | undefined) — Track entry from trackMap.id(number) — Track id in trackMap.linear(number) — New linear gain value.opts(object, optional) — Options.opts.smooth(boolean, optional, default: false) — Glide to the value instead of jumping (slider drags).
Returns
void
exportFadeOutFor(t: object): number
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.
Parameters
t(object) — Track entry from trackMap.
Returns
number— Fade length in seconds (TRACK_FADE_SEC or 0).
buildExportMetadata(cfg: object): object
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.
Parameters
cfg(object) — Settings object from window.ezl.getSettings().
Returns
object— Tag map for the update_metadata worker message.
applyExportMetadata(
outputPaths: Array.<string>,
cfg: object,
): Promise.<void>
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.
Parameters
outputPaths(Array.<string>) — Paths of the exported files.cfg(object) — Settings object feeding buildExportMetadata.
Returns
Promise.<void>
cancelTrackFade(t: object | undefined, id: number): void
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.
Parameters
t(object | undefined) — Track entry from trackMap.id(number) — Track id, used to find the card's slider.
Returns
void
pauseAll(): void
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
showError(msg: string, detail?: string): void
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.
Parameters
msg(string) — Friendly, user-facing message.detail(string, optional) — Raw technical detail for the collapsed disclosure.
Returns
void
showNotice(title: string, msg: string): void
Positive counterpart to showError: same modal treatment, custom title, no technical-detail disclosure, and nothing recorded to lastDiagnosticError.
Parameters
title(string) — Modal title.msg(string) — Message body.
Returns
void
showExpired(msg: string): void
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()).
Parameters
msg(string) — Message explaining why saving is blocked.
Returns
void
mountModals(): Promise.<void>
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>
closeOnBackdropClick(
modal: HTMLDialogElement,
close?: function,
): void
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.
Parameters
modal(HTMLDialogElement) — Dialog to wire.close(function, optional) — Close action; defaults to modal.close().
Returns
void
initSettingsModal(): void
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
initUserModal(): void
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
renderHelpMarkdown(md: string): Object
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.
Parameters
md(string) — Help markdown source.
Returns
Object— Left-rail link markup and section markup.
initHelpModal(): Promise.<void>
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>
populateSupportForm(): Promise.<void>
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>
showToast(msg: string, ms?: number): void
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.
Parameters
msg(string) — Message to show.ms(number, optional, default: 3000) — Auto-dismiss delay in milliseconds.
Returns
void
setupOaAudioGraph(): void
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
isOaAudioName(name: string): boolean
Whether a filename has an extension accepted as an OA replacement.
Parameters
name(string) — Filename to test.
Returns
boolean
setupOaReplaceGraph(playUrl: string): void
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.
Parameters
playUrl(string) — Object URL of the playable source.
Returns
void
loadOaReplacement(file: File): Promise.<void>
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.
Parameters
file(File) — The dropped/picked replacement audio file.
Returns
Promise.<void>
resetOaReplaceState(): void
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
clearOaReplacement(): void
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
updateOaReplaceChip(): void
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
oaReplacePlayFrom(videoTime: number): Promise.<void> | null
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.
Parameters
videoTime(number) — 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.
oaReplaceSeek(videoTime: number): void
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.
Parameters
videoTime(number) — Video-timeline position in seconds.
Returns
void
isVideoName(name: string): boolean
Whether a filename has a supported video-container extension.
Parameters
name(string) — Filename to test.
Returns
boolean
descriptorFromFile(file: File): Object
Build an import descriptor from a dropped/picked File.
Parameters
file(File) — Source file.
Returns
Object— Descriptor.
descriptorFromPath(path: string, name?: string): Object
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
Parameters
path(string) — Absolute filesystem path.name(string, optional) — Display name; defaults to the path's basename.
Returns
Object— Descriptor.
loadVideoFile(file: File | undefined): void
Drag/drop + file-dialog entry point for video: validate by MIME, then hand off as a descriptor.
Parameters
file(File | undefined) — Dropped/picked file.
Returns
void
loadVideoDescriptor(desc: Object): void
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.
Parameters
desc(Object) — Import descriptor.
Returns
void
unloadVideo(): void
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
showVideoProxyOverlay(text: string, busy: boolean): void
Show the proxy overlay with a message and optional busy spinner.
Parameters
text(string) — Overlay message.busy(boolean) — Whether to show the spinner.
Returns
void
resetVideoProxyState(): void
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
beginVideoProxy(): Promise.<void>
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>
isTextEntry(el: Element | null): boolean
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.
Parameters
el(Element | null) — Element to test (usually document.activeElement).
Returns
boolean— True when the element accepts typed text.
syncActiveTrackToVideo(videoTime: number): void
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.
Parameters
videoTime(number) — Video-timeline position in seconds.
Returns
void
doSeek(t: number): void
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).
Parameters
t(number) — Target video time in seconds.
Returns
void
homePausedPlayheadToIn(): void
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
resyncAfterTrimRelease(): void
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
isAudioFile(file: File): boolean
Whether a file is importable as a track, by MIME type or extension.
Parameters
file(File) — File to test.
Returns
boolean
stripExt(name: string): string
Strip the final extension from a filename.
Parameters
name(string) — Filename.
Returns
string— Name without its extension.
escHtml(str: string): string
Escape a string for safe interpolation into HTML.
Parameters
str(string) — Raw string.
Returns
string— Escaped string.
genEzlFolder(
baseDir: string,
videoPath: string,
): Promise.<string>
Generate a free batch-output folder path: /EZL_. 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.
Parameters
baseDir(string) — Parent directory.videoPath(string) — Path of the loaded video, source of the basename.
Returns
Promise.<string>— A non-existing directory path.
applyInitialGain(id: number): void
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.
Parameters
id(number) — Track id in trackMap.
Returns
void
updateHeader(): void
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
setActiveTrack(id: number): Promise.<void>
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.
Parameters
id(number) — Track id to activate.
Returns
Promise.<void>
removeTrack(id: number): void
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.
Parameters
id(number) — Track id to remove.
Returns
void
createTrackCard(
id: number,
name: string,
seed: number,
audio: HTMLAudioElement,
): HTMLElement
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).
Parameters
id(number) — Track id in trackMap (entry must already exist).name(string) — Display name (extension already stripped).seed(number) — Placeholder-waveform RNG seed.audio(HTMLAudioElement) — The track's audio element.
Returns
HTMLElement— The card element, ready to append.
addTrackDescriptor(desc: Object, restore?: Object): number
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).
Parameters
desc(Object) — Import descriptor (blob: URL for fresh import, ezl-file:// on restore).restore(Object, optional) — Saved session state, applied after the card is built.
Returns
number— The new track id.
addTrackFiles(files: FileList | Array.<File>): void
Import a batch of dropped/picked files as tracks, skipping non-audio files. Errors if none are supported.
Parameters
files(FileList | Array.<File>) — Candidate files.
Returns
void
setOaEnabled(enabled: boolean): void
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).
Parameters
enabled(boolean) — Desired OA state.
Returns
void
markAutomixReady(): void
Light the AUTO-MIX button's ready glow.
Returns
void
clearAutomixReady(): void
Clear the AUTO-MIX button's ready glow.
Returns
void
updateMusicLevelVisibility(): void
Show the Music Level slider only in OA mode — hidden when OA is absent or disabled.
Returns
void
getActivePreviewBtn(): HTMLButtonElement | null
The active track card's Preview button, or null when no track is active.
Returns
HTMLButtonElement | null
getActiveRestartBtn(): HTMLButtonElement | null
The active track card's Restart button, or null when no track is active.
Returns
HTMLButtonElement | null
syncRestartBtn(): void
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
updatePreviewBtn(): void
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
playVideoBounded(): void
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
startPreview(): Promise.<void>
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>
stopPreview(): void
Stop preview. pauseAll resets isPreviewing and the button label.
Returns
void
fitFolderPath(el: HTMLElement, dir: string): void
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 "…/" 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.
Parameters
el(HTMLElement) — Field to write into.dir(string) — Full path to fit (Windows-style separators honored).
Returns
void
clearSession(): void
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
newProject(): void
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
saveProject(): Promise.<void>
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>
openProject(): Promise.<void>
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>
restoreProject(project: object): Promise.<void>
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.
Parameters
project(object) — Parsed project (ProjectFile.parse output).
Returns
Promise.<void>
showMissingFiles(paths: Array.<string>): void
Non-fatal "files not found" modal — the rest of the project has already loaded.
Parameters
paths(Array.<string>) — Paths that couldn't be found (basenames are shown).
Returns
void
requireCanSave(): boolean
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.
showAuthGate(): void
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
hideAuthGate(): void
Hide the auth gate, revealing the app.
Returns
void
setEntitlement(payload: object | null): void
Store the verified entitlement payload and re-evaluate the Save Laybacks gate.
Parameters
payload(object | null) — Verified payload, or null on sign-out.
Returns
void
trialDaysLeft(): number | null
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.
offlineDaysLeft(): number | null
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.
offlineMessage(): string
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.
trialMessage(): string | null
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.
expiredMessage(): string | null
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.
sessionNotice(): Object | null
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
Object | null— Notice descriptor, or null.
humanDate(iso: string): string
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.
Parameters
iso(string) — ISO date string.
Returns
string— "today", "tomorrow", "in N days", or a formatted date.
describePlan(): string
Plain-English plan line for the account modal (no internal jargon).
Returns
string— e.g. "Pro (free trial)" or "Pro".
describeStatus(): string
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.
Instance Fields
_audioCtx
MARK: AUDIO CONTEXT
vofxPeaks
MARK: VOFX ANALYSIS — Original Audio waveform state and display helpers.
_videoAnchorTime
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.
_fpSeeking
MARK: Trim-handle frame preview
lastDiagnosticError: Object | null
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.
videoTrimStart
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.
musicStartTime: number | null
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").
loopEnabled: boolean
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).
_lastMusicLevel
MARK: TOOLBAR SLIDERS
entitlement
Verified entitlement payload from main, or null when signed out.
sessionOffline: boolean
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.
Other
SHOW_STATUS_TEXT: boolean
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.
BACKEND_PREVIEW_EXTS: Set.<string>
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.)
_audioAnchors
Per-audio-element interpolation anchors: el → { time, perf }.
_vofxWaveCanvas
MARK: RAF ANIMATION LOOP
TRACK_FADE_SEC
Length of the music tail fade in seconds — shared by preview and export.
GAIN_SMOOTH_SEC: number
Time constant for smoothing a live gain change (slider drags) — short enough to feel instant, long enough not to click.
TAGGABLE_OUTPUT_EXT: RegExp
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.
videoDrop
MARK: VIDEO — player, scrub bar, and OA-surface DOM references.
scrubMusicMarker
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.
PLATFORM_LABELS
Human-readable OS names for the support form's App line.
OA_AUDIO_EXTS
Audio extensions accepted as an OA replacement (superset of the video importer).
VIDEO_EXTS
Supported video containers (matches the product spec: MP4 primary, MOV secondary).
videoProxyOverlay
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.
tracksList
MARK: TRACKS — track list state, import, cards, and per-track wiring.
AUDIO_EXTS
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.