scripts/state.jsjavascript
/**
* @module state
* @description
* Single source of mutable runtime state for the Ghostmaxxing web app. A plain
* object shared (by reference) across all ES modules via `import { state }`.
* There is exactly one instance; mutating a field here is observed everywhere.
*
* The full shape — including the type, allowed range, and consuming modules of
* every field — is documented on the {@link GhostmaxxingState} typedef below, which
* renders as a properties table.
*
* Note on events: the in-app event bus is `state.gstmxxEvents`. The
* `ghostatiReady` lifecycle event is the one exception — it is dispatched on
* `window`, NOT on this bus.
*/
/**
* A single enrolled face record persisted in the local DB.
* @typedef {Object} FaceRecord
* @property {number} id Stable identifier assigned at enrollment.
* @property {Float32Array|number[]} descriptor 128-D face-api recognition descriptor.
*/
/**
* A single enrolled face record persisted in the 3D DB.
* @typedef {Object} FaceRecord3d
* @property {number} id Same ID as the corresponding FaceRecord (assigned by engine.js).
* @property {number[]} descriptor3d Image embedding produced by MediaPipe ImageEmbedder.
* @property {string} savedAt ISO timestamp.
*/
/**
* A loaded 2D ghostyle plugin entry.
* @typedef {Object} GhostyleEntry
* @property {string} id Identifier derived from the script filename (key in loadedGhostyles).
* @property {string} name Human-readable name parsed from the `@name` header.
* @property {object} module The imported ES module (may export onInit/onClear/draw hooks).
* @property {string} url URL the module was loaded from.
*/
/**
* Shape of the shared runtime state object. Each property documents its type,
* its allowed/observed range, and the modules that read or write it.
*
* @typedef {Object} GhostmaxxingState
*
* @property {{ nextId: number, faces: FaceRecord[] } | null} db
* Local face database (recognition descriptors + id counter).
* **Range:** `null` until `db.loadDb()` runs at startup; afterwards an object
* where `nextId >= 0` and `faces` is a (possibly empty) array.
* **Used in:** db.js (load/save/clear/renderDbStats), engine.js (descriptor
* matching), utils.js (computeMatchState), main.js (init, `Ghostati.getDb`).
*
* @property {{ faces: FaceRecord3d[], modelVersion: string } | null} db3d
* Local 3D face database (ImageEmbedder embeddings, keyed by same IDs as db).
* **Range:** `null` until `db.loadDb3d()` runs at startup.
* **Used in:** db.js (load/save/clear), engine-3d.js (embedding matching), main.js.
*
* @property {undefined} [thumbnailsStore]
* Documentation-only reference to the dedicated thumbnail persistence store
* in `localStorage` (`local-face-lab-thumbnails-v1`). Managed exclusively by
* `face-thumbnails.js` and intentionally not mirrored onto `state`.
*
* @property {string|null} activeEffect
* Identifier of the currently active 2D ghostyle effect.
* **Range:** `null` when no effect is active, otherwise a key present in
* `loadedGhostyles`.
* **Used in:** ghostyles-manager.js (toggleEffect/deactivateEffect), dom.js
* (clearActiveEffect/effectSelected), engine.js (runEffectPass,
* hasActivePlugin), main.js (scan branch, `Ghostati.getActiveEffect`).
*
* @property {boolean} effectInferenceInFlight
* Re-entrancy guard: `true` while an async face-api inference for the effect
* pass is in flight, to prevent overlapping detections.
* **Range:** `true` | `false`.
* **Used in:** camera.js (effect loop), engine.js (runEffectPass guard, reset in finally).
*
* @property {number} lastEffectRun
* Timestamp of the last effect-loop iteration, used to throttle inference to
* the selected frame rate.
* **Range:** milliseconds (`performance.now()` domain), `>= 0`; starts at `0`.
* **Used in:** camera.js only (effect loop throttling).
*
* @property {boolean} isSystemBusy
* Global "system busy" flag. While `true`, interactive controls are disabled
* to prevent concurrent operations (model load, scan, save, find).
* **Range:** `true` | `false`.
* **Used in:** main.js (setBusy toggles it and the control disabled states),
* engine.js (guards in detection passes).
*
* @property {object|null} lastKnownEffectResult
* Most recent face-api detection result from the effect loop, cached so other
* code (e.g. plugins) can read the current landmarks without re-detecting.
* **Range:** `null` when no face is detected or the effect is cleared; otherwise
* a face-api detection result, optionally carrying 68-point landmarks (shape
* depends on whether an effect requiring landmarks is active).
* **Used in:** engine.js (set in runEffectPass / scan / efficacy paths), dom.js
* (reset to `null` in clearActiveEffect), main.js (`Ghostati.getLastResult`).
*
* @property {HTMLCanvasElement|null} lastCompositedCanvas
* The last composited canvas (live frame + applied makeup overlay) produced by
* the efficacy test; reused by the "copy makeup" export.
* **Range:** `null` until an efficacy composite is produced; then a canvas sized
* to the video's native resolution.
* **Used in:** engine.js (set after compositing), dom.js (reset to `null`),
* main.js (copyMakeup export source; gates copyMakeupBtn enabled state in setBusy).
*
* @property {boolean} isMirrored
* Whether the webcam preview (and overlays) are horizontally mirrored.
* **Range:** `true` | `false`. Set to `true` automatically for the front camera
* (`currentFacingMode === 'user'`).
* **Used in:** camera.js (set on camera start), engine.js (compositing transform),
* utils.js (drawing), main.js (mirror toggle, export flip).
*
* @property {'user'|'environment'} currentFacingMode
* Active camera facing mode passed to `getUserMedia`.
* **Range:** exactly `'user'` (front) or `'environment'` (rear); defaults to `'user'`.
* **Used in:** camera.js (getUserMedia constraint, drives isMirrored),
* main.js (switchCamera button toggles it).
*
* @property {HTMLElement[]} logsArchive
* Rolling archive of rendered log line DOM nodes.
* **Range:** 0–100 elements; capped at 100, oldest entries shifted out (FIFO).
* **Used in:** utils.js (setLog pushes, updateLogDisplay renders),
* main.js (`Ghostati.clearVisibleLogs`).
*
* @property {number} visibleLogStartIndex
* Index into `logsArchive` marking where currently-visible logs begin, so the
* user can clear the visible view without dropping the archive.
* **Range:** `0 .. logsArchive.length`; decremented when the archive shifts.
* **Used in:** utils.js (updateLogDisplay), main.js (clearVisibleLogs).
*
* @property {number|null} overlayFadeTimeout
* Handle for the pending overlay fade-out `setTimeout`.
* **Range:** `null` when no fade is scheduled; otherwise a positive timeout id.
* **Used in:** engine.js (schedules the fade-out), dom.js (cleared in clearOverlay
* and effectSelected before starting a new transition).
*
* @property {boolean} isLogExpanded
* Whether the log box UI panel is expanded.
* **Range:** `true` | `false`.
* **Used in:** utils.js (updateLogDisplay layout), main.js (toggled on logBox click).
*
* @property {number} MATCH_THRESHOLD
* face-api recognition distance threshold. A descriptor distance `<=` this value
* is classified as a match ("matched"); above it counts as eluded.
* **Range:** no enforced bounds. Conventionally an L2 distance in ~0.4–0.6 for
* face-api; default `0.58`. Lower = stricter matching.
* **Used in:** utils.js (computeMatchState), engine.js (match classification in
* findFace), db.js (threshold label), main.js (`Ghostati.getMatchThreshold`).
*
* @property {number} MATCH_THRESHOLD_3D
* ImageEmbedder cosine-similarity threshold. A similarity `>=` this value is a match.
* **Range:** [0, 1]; default `0.85`. Higher = stricter (opposite sign from MATCH_THRESHOLD).
* **Used in:** engine-3d.js (match classification in findFace3d/saveFace3d), main.js.
*
* @property {Array|null} lastLandmarks3d
* Most recent MediaPipe FaceLandmarker result (478 normalised landmarks) from
* the current video frame. Cached so engine-3d and plugins can read it without
* re-running inference. `null` if MediaPipe has not yet produced a result.
* **Used in:** mediapipe-loop.js (written), engine-3d.js (read for compositing),
* window.gstmxx.lastLandmarks3d (exposed to plugins).
*
* @property {object|null} imageEmbedder
* Loaded MediaPipe ImageEmbedder instance. `null` until
* `loadMobileNet()` completes in engine-3d.js.
* **Used in:** engine-3d.js (getFaceEmbedding, guards).
*
* @property {EventTarget} gstmxxEvents
* In-app event bus. All cross-module events are dispatched here and exposed to
* plugins as `window.gstmxx.events`. Events on this bus: `ready`, `detection`,
* `effectChanged`, `effectChanged3d`, `landmarks3d`, `matchStateChanged`,
* `beforeEfficacyComposite`, `dbChanged`, `mediapipeReady`. (NB: `ghostatiReady`
* is dispatched on `window`, not here.)
* **Range:** a single EventTarget instance (never reassigned).
* **Used in:** engine.js, ghostyles-manager.js, db.js, main.js (and all add-on
* scripts via `window.gstmxx.events`).
*
* @property {Map<string, GhostyleEntry>} loadedGhostyles
* Registry of loaded 2D ghostyle plugins, keyed by ghostyle id.
* **Range:** empty at startup; one entry per successfully loaded ghostyle.
* **Used in:** dom.js (addGhostyleBtn populates it; effectSelected/clearActiveEffect
* read it), ghostyles-manager.js (toggleEffect/deactivateEffect read it),
* engine.js, main.js.
*
* @property {boolean} isRecording
* True while a 1-second video recording is actively in progress.
* **Range:** `true` | `false`.
* **Used in:** camera.js (recording control), main.js (button disabling).
*/
/**
* The shared runtime state singleton.
* @type {GhostmaxxingState}
*/
export const state = {
db: null, // initialized after loading via db.loadDb()
db3d: null, // initialized after loading via db.loadDb3d()
activeEffect: null,
effectInferenceInFlight: false,
lastEffectRun: 0,
isSystemBusy: false,
lastKnownEffectResult: null,
lastCompositedCanvas: null,
lastLandmarks3d: null, // cached from mediapipe-loop, 478-point normalised array
imageEmbedder: null, // loaded MediaPipe ImageEmbedder instance
isMirrored: false,
currentFacingMode: 'user',
logsArchive: [],
visibleLogStartIndex: 0,
overlayFadeTimeout: null,
isLogExpanded: false,
MATCH_THRESHOLD: 0.58,
MATCH_THRESHOLD_3D: 0.85,
gstmxxEvents: new EventTarget(),
loadedGhostyles: new Map(),
isRecording: false,
};