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.