timeline.jsjavascript
/**
* @file Video-timeline math for the Music Start marker — the video moment at which music
* tracks begin playing. Extracted so it's testable in plain Node (test/timeline.test.js).
* UMD-lite, same as automix.js / track-fade.js.
*
* The renderer keeps mStart() / trackTimeFor() / videoCapForTrack() as thin wrappers that
* read the DOM + globals and delegate here, so this module never touches either. What
* lives here is the part that's genuinely easy to get wrong and impossible to test through
* the DOM: the sign conventions of the video-to-track mapping, and how the marker follows
* a trim-window change.
*
* All times are in seconds. "Video time" is a position on the video's timeline; "track
* time" is a position on a track's own timeline.
* @module timeline
*/
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) {
module.exports = api;
} else {
root.Timeline = api;
}
})(typeof self !== 'undefined' ? self : this, function () {
/**
* Keep the marker inside the trim window. `gap` reserves room at the OUT end only: a
* marker sitting exactly on Out would mean "music never plays", and it would also cover
* the Out handle's grab zone and make it un-draggable. The IN end takes no gap — sitting
* on In is the marker's default position and the double-click reset target. The upper
* bound never inverts the range: it stays at least videoIn even when gap exceeds the
* window.
* @param {?number} musicStart Marker position in video time (seconds), or null when not
* placed — null resolves to the In point.
* @param {number} videoIn Trim window In point, video time (seconds).
* @param {number} videoOut Trim window Out point, video time (seconds).
* @param {number} [gap=0] Seconds reserved below the Out point.
* @returns {number} Clamped marker position, video time (seconds).
*/
function clampMusicStart(musicStart, videoIn, videoOut, gap = 0) {
if (musicStart == null) return videoIn;
const hi = Math.max(videoIn, videoOut - gap);
return Math.max(videoIn, Math.min(musicStart, hi));
}
/**
* How the marker follows a change to the video trim window. When `slid` is true, the
* whole In-to-Out band moved and the marker keeps its OFFSET inside the window;
* otherwise one edge moved and the marker holds its ABSOLUTE position, only PUSHED by
* an edge that ran into it.
*
* The slide case shifts by the pure In delta, not a fraction of the window: a band
* slide moves In and Out by the SAME delta, so adding it is exactly "preserve the
* offset inside the window" — the music/picture relationship the user set up survives
* moving the whole selection — and it makes slide-right-then-back land on the original
* value bit-for-bit instead of drifting. The slide result is deliberately NOT clamped:
* the caller runs setStart before setEnd, so `nextOut` is still the OLD Out mid-slide,
* and clamping against it would pin the marker there and silently destroy the offset.
* A preserved offset inside a preserved-width window is inside by construction, so the
* clamp isn't merely unnecessary — it's wrong.
* @param {Object} args
* @param {?number} args.musicStart Marker position in video time (seconds), or null
* when unset — unset is glued to In by definition, so there's nothing to move.
* @param {number} args.prevIn Previous In point, video time (seconds).
* @param {number} args.nextIn New In point, video time (seconds).
* @param {number} args.nextOut New Out point, video time (seconds).
* @param {boolean} args.slid True when the whole band moved rather than one edge.
* @param {number} [args.gap=0] Seconds reserved below the Out point when clamping.
* @returns {?number} New marker position in video time (seconds), or null when the
* marker was unset.
*/
function rideWindow({ musicStart, prevIn, nextIn, nextOut, slid, gap = 0 }) {
if (musicStart == null) return null;
if (slid) {
return musicStart + (nextIn - prevIn);
}
return clampMusicStart(musicStart, nextIn, nextOut, gap);
}
/**
* Map a video-timeline position to the active track's own timeline, so the track's In
* point plays at the Music Start marker. A video time BEFORE the marker maps below the
* track's In point — that's the signal every playback path uses to mean "the music
* hasn't come in yet". Identity (trackTime === videoTime) when nothing is trimmed and
* no marker is placed.
* @param {Object} args
* @param {number} args.audioIn Track's In point, track time (seconds).
* @param {number} args.musicStart Marker position, video time (seconds).
* @param {number} args.videoTime Position to map, video time (seconds).
* @returns {number} Corresponding position, track time (seconds).
*/
function trackTimeFor({ audioIn, musicStart, videoTime }) {
return audioIn + (videoTime - musicStart);
}
/**
* The exact inverse of trackTimeFor: given a moment in the SONG, the video-timeline
* position that plays it. Track-waveform click-to-seek measures where you pointed in
* TRACK time and has to hand doSeek() a VIDEO time — issue #341 was that conversion
* going missing, so a click landed (audioIn − musicStart) seconds off. The scrub bar
* and the OA waveform need no conversion: they already span the video timeline.
* @param {Object} args
* @param {number} args.audioIn Track's In point, track time (seconds).
* @param {number} args.musicStart Marker position, video time (seconds).
* @param {number} args.trackTime Position to map, track time (seconds).
* @returns {number} Corresponding position, video time (seconds).
*/
function videoTimeForTrack({ audioIn, musicStart, trackTime }) {
return musicStart + (trackTime - audioIn);
}
/**
* The furthest a track's Out point may reach, measured on the track's own timeline. The
* track only occupies [musicStart, videoOut] of the video, so pushing the marker right
* SHRINKS this cap and pulls every track's effective Out handle inward.
* @param {Object} args
* @param {number} args.audioIn Track's In point, track time (seconds).
* @param {number} args.musicStart Marker position, video time (seconds).
* @param {number} args.videoOut Video trim Out point, video time (seconds).
* @returns {number} Maximum track Out position, track time (seconds).
*/
function videoCapFor({ audioIn, musicStart, videoOut }) {
return audioIn + (videoOut - musicStart);
}
return { clampMusicStart, rideWindow, trackTimeFor, videoTimeForTrack, videoCapFor };
});