project.jsjavascript
/**
 * @file Project file (de)serialization — the pure shape logic behind Save / Open Project.
 *
 * Like automix.js, this owns ONLY data transformation (no DOM, no fs, no IPC) so it can
 * be unit-tested in plain Node (test/project.test.js). The renderer gathers a state
 * snapshot, hands it to build(), and writes the result; on open it reads JSON and runs it
 * through parse() before rehydrating the session.
 *
 * Loaded two ways (UMD-lite, matching automix.js): the Electron renderer loads it with a
 * script tag, which attaches window.ProjectFile; Node tests require('../src/project.js')
 * and use module.exports.
 *
 * The file persists a session, never rendered output: which video + tracks are loaded,
 * their levels and trim windows, the Music Start marker, the Music Level offset, and
 * which track was active. Paths are the identity (session ids are minted fresh each
 * load); transient runtime objects (blob URLs, audio elements, GainNodes, decoded peaks,
 * LUFS) are deliberately omitted — they're rederived on restore.
 * @module project
 */

(function (root, factory) {
  const api = factory();
  if (typeof module === 'object' && module.exports) {
    module.exports = api;
  } else {
    root.ProjectFile = api;
  }
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  /**
   * On-disk format version. Bump only when the shape changes incompatibly; parse()
   * refuses files from a NEWER app (version > this) with a friendly message rather than
   * silently mishandling fields.
   *
   * ADDING an optional field that defaults to null is NOT such a change and must not bump
   * this: new app + old file reads it as undefined → null → the pre-existing behaviour,
   * and old app + new file passes the gate and simply ignores the key. Bumping would
   * instead make every newly saved project unopenable by every already-shipped build,
   * with an alarming "made with a newer version" dialog, in exchange for nothing. Reserve
   * the bump for a change that alters the MEANING of an existing field, or that makes a
   * new field load-bearing. (Precedent: the video.musicStart marker was added additively
   * under version 1.)
   * @type {number}
   */
  const VERSION = 1;

  /**
   * Coerce a finite number, or fall back. Guards against NaN / strings sneaking into
   * trims.
   * @param {*} v Candidate value.
   * @param {*} [fallback=0] Value returned when `v` is not a finite number.
   * @returns {*} `v` when it is a finite number, otherwise `fallback`.
   */
  function num(v, fallback = 0) {
    return (typeof v === 'number' && isFinite(v)) ? v : fallback;
  }

  /**
   * Build the JSON-serializable project object from a plain state snapshot. The snapshot
   * mirrors §6 of the design doc. Null semantics persist the user's INTENT: video.trimEnd
   * null = full extent (no out-trim), preserved as null and never coerced to 0;
   * video.musicStart (seconds on the video timeline, where the music comes in) null = not
   * placed → the video In point; track trimEndTarget null = whole track. Every effective
   * value (track Out caps, export delays) is derived from these on load. oaEnabled
   * defaults to true.
   * @param {Object} state Snapshot of the session.
   * @param {?{path: string, oaEnabled: boolean, trimStart: number, trimEnd: ?number,
   *   musicStart: ?number}} state.video Loaded video, or null.
   * @param {number} state.musicLevel Music Level offset, in dB (defaults to -12).
   * @param {?string} state.activeTrackPath Path of the active track, or null.
   * @param {Array<{path: string, name: ?string, volumeDb: number, trimStart: number,
   *   trimEndTarget: ?number}>} state.tracks Loaded tracks; entries without a path are
   *   dropped.
   * @returns {Object} Project object in the on-disk shape, tagged with VERSION.
   */
  function build(state) {
    const out = {
      version: VERSION,
      video: null,
      musicLevel: num(state.musicLevel, -12),
      activeTrackPath: state.activeTrackPath ?? null,
      tracks: [],
    };

    if (state.video && state.video.path) {
      out.video = {
        path: state.video.path,
        oaEnabled: state.video.oaEnabled !== false,
        trimStart: num(state.video.trimStart, 0),
        trimEnd: state.video.trimEnd == null ? null : num(state.video.trimEnd, null),
        musicStart: state.video.musicStart == null ? null : num(state.video.musicStart, null),
      };
    }

    out.tracks = (state.tracks || [])
      .filter(t => t && t.path)
      .map(t => ({
        path: t.path,
        name: t.name ?? null,
        volumeDb: num(t.volumeDb, 0),
        trimStart: num(t.trimStart, 0),
        trimEndTarget: t.trimEndTarget == null ? null : num(t.trimEndTarget, null),
      }));

    return out;
  }

  /**
   * Validate + normalize a parsed JSON object into the same shape build() produces —
   * re-running it through build() keeps defaults, filtering, and null-handling in one
   * place. Throws an Error tagged with `.code` so the renderer can show the right
   * message: 'invalid' = not a project file (missing/garbage version), 'version' = made
   * by a newer app than we understand.
   * @param {*} obj Parsed JSON from a project file.
   * @returns {Object} Normalized project object, same shape as build()'s output.
   * @throws {Error} With `.code` 'invalid' or 'version' as above.
   */
  function parse(obj) {
    if (!obj || typeof obj !== 'object' || typeof obj.version !== 'number') {
      const e = new Error('This file is not a valid project.');
      e.code = 'invalid';
      throw e;
    }
    if (obj.version > VERSION) {
      const e = new Error('This project was made with a newer version of the app.');
      e.code = 'version';
      throw e;
    }

    return build({
      video: obj.video || null,
      musicLevel: obj.musicLevel,
      activeTrackPath: obj.activeTrackPath ?? null,
      tracks: Array.isArray(obj.tracks) ? obj.tracks : [],
    });
  }

  return { VERSION, build, parse };
});