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 };
});