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