lab-js/video-loader/session.jsjavascript
/**
 * @module video-loader/session
 * @description
 * Versioned analysis session, technical journal, and reversible editorial
 * commands for the Video Loader. The technical journal is intentionally
 * independent from the report so it can later drive clip extraction or other
 * post-processing without rendering the editorial output again.
 */

/** Current machine-readable session and log schema version. */
export const VIDEO_ANALYSIS_SCHEMA_VERSION = 4;

/**
 * Creates a stable random identifier with a deterministic fallback for older
 * browsers and constrained test environments.
 *
 * @param {string} prefix Identifier prefix.
 * @returns {string} Unique identifier.
 */
function createId(prefix) {
   const random = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
   return `${prefix}-${random}`;
}

/**
 * Creates an empty video-analysis session.
 *
 * @param {object} source Source metadata.
 * @param {string} source.name Source filename.
 * @param {number} source.size Source byte size.
 * @param {string} source.type Source media type.
 * @param {number} [source.lastModified] Source modification time.
 * @param {number} [source.duration] Decoded duration.
 * @param {number} [source.width] Decoded width.
 * @param {number} [source.height] Decoded height.
 * @param {string|null} [source.fingerprint] Bounded source fingerprint.
 * @param {object} config Effective analyser configuration.
 * @returns {object} Mutable session owned by one analyser controller.
 */
export function createAnalysisSession(source, config) {
   const createdAt = new Date().toISOString();
   return {
      schemaVersion: VIDEO_ANALYSIS_SCHEMA_VERSION,
      sessionId: createId('session'),
      createdAt,
      updatedAt: createdAt,
      source: { ...source },
      config: structuredClone(config),
      models: {},
      baseline: null,
      samples: [],
      events: [],
      journal: [],
      editorial: {
         storyOrder: ['baseline'],
         undo: [],
         redo: [],
      },
      status: 'idle',
      summary: {
         sampledFrames: 0,
         mediaPipeFaces: 0,
         faceApiRuns: 0,
         confirmedEvents: 0,
         manualChecks: 0,
      },
   };
}

/**
 * Appends one structured technical-journal entry.
 *
 * @param {object} session Analysis session.
 * @param {string} type Stable entry type.
 * @param {object} [payload={}] Serializable entry payload.
 * @returns {object} Appended journal entry.
 */
export function appendJournal(session, type, payload = {}) {
   const entry = {
      sequence: session.journal.length + 1,
      recordedAt: new Date().toISOString(),
      type,
      ...structuredClone(payload),
   };
   session.journal.push(entry);
   session.updatedAt = entry.recordedAt;
   return entry;
}

/**
 * Adds one lightweight sample without image pixels or model vectors.
 *
 * @param {object} session Analysis session.
 * @param {object} sample Serializable sample metrics.
 * @returns {object} Stored sample.
 */
export function appendSample(session, sample) {
   const stored = {
      sequence: session.samples.length + 1,
      ...structuredClone(sample),
   };
   session.samples.push(stored);
   session.summary.sampledFrames += 1;
   if (stored.facePresent) session.summary.mediaPipeFaces += 1;
   return stored;
}

/**
 * Adds an evidence event or merges a nearby verification into the latest open
 * event. Images may be attached to the session for report rendering, but they
 * are excluded from the separately downloadable technical log.
 *
 * @param {object} session Analysis session.
 * @param {object} observation Face-api observation and trigger context.
 * @param {number} [mergeWindowSeconds=3] Maximum distance from the previous observation.
 * @returns {object} Created or updated event.
 */
export function recordEvidenceObservation(session, observation, mergeWindowSeconds = 3) {
   const latest = session.events.at(-1);
   const mayMerge = latest
      && latest.origin !== 'manual'
      && latest.status !== 'rejected'
      && observation.mediaTime - latest.endMediaTime <= mergeWindowSeconds;
   const event = mayMerge ? latest : {
      id: createId('event'),
      origin: 'automatic',
      startMediaTime: observation.mediaTime,
      endMediaTime: observation.mediaTime,
      anchorMediaTime: observation.mediaTime,
      status: 'candidate',
      classification: 'unclassified',
      reviewRequired: true,
      promoted: false,
      caption: '',
      observations: [],
      selectedFrame: null,
      recommendedClip: {
         start: Math.max(0, observation.mediaTime - 2),
         end: observation.mediaTime + 3,
      },
   };

   if (!mayMerge) {
      session.events.push(event);
   }
   event.observations.push(structuredClone(observation));
   event.endMediaTime = observation.mediaTime;
   event.recommendedClip.end = observation.mediaTime + 3;

   const divergent = event.observations.filter((item) => (
      item.outcome === 'divergent'
      && (Number.isFinite(item.faceDistance) || item.mediaPipeComparable === true)
   ));
   if (divergent.length >= 2 && event.status !== 'confirmed') {
      event.status = 'confirmed';
      event.anchorMediaTime = divergent.reduce((best, item) => (
         (item.quality?.sharpness || 0) > (best.quality?.sharpness || 0) ? item : best
      )).mediaTime;
      session.editorial.storyOrder.push(event.id);
      const chronologicalEvents = session.editorial.storyOrder
         .filter((itemId) => itemId !== 'baseline')
         .sort((leftId, rightId) => {
            const leftTime = session.events.find((candidate) => candidate.id === leftId)?.anchorMediaTime
               ?? Number.POSITIVE_INFINITY;
            const rightTime = session.events.find((candidate) => candidate.id === rightId)?.anchorMediaTime
               ?? Number.POSITIVE_INFINITY;
            return leftTime - rightTime;
         });
      session.editorial.storyOrder = ['baseline', ...chronologicalEvents];
      session.summary.confirmedEvents += 1;
   }
   const distanceDivergences = divergent.filter((item) => (
      Number.isFinite(item.faceDistance)
      && item.faceDistance >= (item.faceThreshold ?? session.config.faceDistanceThreshold)
   ));
   const missingDetections = divergent.filter((item) => (
      item.mediaPipeComparable === true && item.faceApiDetected === false
   ));
   if (distanceDivergences.length >= 2) {
      event.classification = 'identity-distance';
      event.reviewRequired = false;
   } else if (missingDetections.length >= 2) {
      event.classification = 'detector-divergence';
      event.reviewRequired = true;
   } else if (divergent.length >= 2) {
      event.classification = 'mixed-divergence';
      event.reviewRequired = true;
   }
   return event;
}

/**
 * Adds one person-requested source-frame comparison to the evidence story.
 * Unlike automatic events, a manual check needs no repeated divergence vote:
 * its meaning is that a person explicitly asked to inspect this timestamp.
 * Image bytes remain on the session event and are omitted from the log.
 *
 * @param {object} session Analysis session with an acquired baseline.
 * @param {object} check Manual comparison result.
 * @param {number} check.mediaTime Exact source timestamp.
 * @param {'below-threshold'|'above-threshold'|'no-2d-face'|'not-comparable'} check.interpretation Human-facing result category.
 * @param {object} check.observation Model result stored with the event.
 * @param {{mediaTime: number, dataUrl: string, width: number, height: number, sharpness: number, reason: string}} check.frame Native source frame.
 * @returns {object} Created manual evidence event.
 */
export function recordManualFrameCheck(session, check) {
   if (!session.baseline) throw new Error('A baseline is required before a frame can be compared.');
   const event = {
      id: createId('event'),
      origin: 'manual',
      startMediaTime: check.mediaTime,
      endMediaTime: check.mediaTime,
      anchorMediaTime: check.mediaTime,
      status: 'confirmed',
      classification: 'manual-frame-check',
      interpretation: check.interpretation,
      reviewRequired: true,
      promoted: false,
      caption: '',
      observations: [structuredClone(check.observation)],
      selectedFrame: { ...check.frame },
      recommendedClip: {
         start: Math.max(0, check.mediaTime - 2),
         end: check.mediaTime + 3,
      },
   };
   session.events.push(event);
   session.editorial.storyOrder.push(event.id);
   session.summary.manualChecks = (session.summary.manualChecks || 0) + 1;
   appendJournal(session, 'manual-frame-checked', {
      eventId: event.id,
      mediaTime: check.mediaTime,
      interpretation: check.interpretation,
      mediaPipeComparable: check.observation.mediaPipeComparable,
      faceApiDetected: check.observation.faceApiDetected,
      faceDistance: check.observation.faceDistance,
      faceThreshold: check.observation.faceThreshold,
      visualDrift: check.observation.visualDrift,
      frame: {
         mediaTime: check.frame.mediaTime,
         width: check.frame.width,
         height: check.frame.height,
         sharpness: check.frame.sharpness,
         reason: check.frame.reason,
      },
   });
   return event;
}

/**
 * Attaches or replaces the selected high-resolution frame for an event.
 *
 * @param {object} event Evidence event.
 * @param {{mediaTime: number, dataUrl: string, width: number, height: number, sharpness: number, reason: string}} frame Frame payload.
 * @returns {boolean} Whether the frame replaced the prior selection.
 */
export function selectEventFrame(event, frame) {
   if (event.selectedFrame && event.selectedFrame.sharpness > frame.sharpness) return false;
   event.selectedFrame = { ...frame };
   return true;
}

/**
 * Applies one reversible editorial command.
 *
 * @param {object} session Analysis session.
 * @param {object} command Editorial command.
 * @param {'promote'|'reject'|'caption'|'move-earlier'|'move-later'} command.type Command type.
 * @param {string} command.eventId Event identifier. The baseline is pinned and cannot receive editorial commands.
 * @param {string} [command.value] Caption value.
 * @returns {boolean} Whether the command changed an event.
 */
export function applyEditorialCommand(session, command) {
   const event = session.events.find((candidate) => candidate.id === command.eventId);
   const isMovement = command.type === 'move-earlier' || command.type === 'move-later';
   if (!event) return false;
   const before = {
      event: event ? {
         status: event.status,
         promoted: event.promoted,
         caption: event.caption,
      } : null,
      storyOrder: [...session.editorial.storyOrder],
   };
   if (command.type === 'promote') event.promoted = true;
   if (command.type === 'reject') {
      event.status = 'rejected';
      session.editorial.storyOrder = session.editorial.storyOrder.filter((id) => id !== event.id);
   }
   if (command.type === 'caption') event.caption = command.value || '';
   if (isMovement) {
      const index = session.editorial.storyOrder.indexOf(command.eventId);
      const offset = command.type === 'move-earlier' ? -1 : 1;
      const target = index + offset;
      if (index < 0 || target < 0 || target >= session.editorial.storyOrder.length) return false;
      if (session.editorial.storyOrder[target] === 'baseline') return false;
      [session.editorial.storyOrder[index], session.editorial.storyOrder[target]] = [
         session.editorial.storyOrder[target],
         session.editorial.storyOrder[index],
      ];
   }
   const after = {
      event: event ? {
         status: event.status,
         promoted: event.promoted,
         caption: event.caption,
      } : null,
      storyOrder: [...session.editorial.storyOrder],
   };
   session.editorial.undo.push({ command: structuredClone(command), before, after });
   session.editorial.redo = [];
   appendJournal(session, 'editorial-command', { command, before, after });
   return true;
}

/**
 * Restores the state before the latest editorial command.
 *
 * @param {object} session Analysis session.
 * @returns {boolean} Whether a command was undone.
 */
export function undoEditorialCommand(session) {
   const change = session.editorial.undo.pop();
   if (!change) return false;
   const event = session.events.find((candidate) => candidate.id === change.command.eventId);
   if (change.before.event && !event) return false;
   if (event && change.before.event) Object.assign(event, change.before.event);
   session.editorial.storyOrder = [...change.before.storyOrder];
   session.editorial.redo.push(change);
   appendJournal(session, 'editorial-undo', { command: change.command });
   return true;
}

/**
 * Reapplies the latest undone editorial command.
 *
 * @param {object} session Analysis session.
 * @returns {boolean} Whether a command was redone.
 */
export function redoEditorialCommand(session) {
   const change = session.editorial.redo.pop();
   if (!change) return false;
   const event = session.events.find((candidate) => candidate.id === change.command.eventId);
   if (change.after.event && !event) return false;
   if (event && change.after.event) Object.assign(event, change.after.event);
   session.editorial.storyOrder = [...change.after.storyOrder];
   session.editorial.undo.push(change);
   appendJournal(session, 'editorial-redo', { command: change.command });
   return true;
}

/**
 * Produces the user-facing report without technical journal internals or image
 * byte duplication. Selected frame data URLs remain available to the caller as
 * separate frame artifacts.
 *
 * @param {object} session Analysis session.
 * @returns {object} Serializable report model.
 */
export function buildReport(session) {
   const storyIndex = new Map(session.editorial.storyOrder.map((id, index) => [id, index]));
   const events = session.events
      .filter((event) => event.status === 'confirmed' && event.status !== 'rejected')
      .map((event) => ({
         id: event.id,
         startMediaTime: event.startMediaTime,
         endMediaTime: event.endMediaTime,
         anchorMediaTime: event.anchorMediaTime,
         classification: event.classification,
         origin: event.origin || 'automatic',
         interpretation: event.interpretation || null,
         reviewRequired: event.reviewRequired,
         promoted: event.promoted,
         caption: event.caption,
         recommendedClip: event.recommendedClip,
         selectedFrame: event.selectedFrame ? {
            mediaTime: event.selectedFrame.mediaTime,
            width: event.selectedFrame.width,
            height: event.selectedFrame.height,
            sharpness: event.selectedFrame.sharpness,
            reason: event.selectedFrame.reason,
         } : null,
         evidence: event.observations.map((observation) => ({
            mediaTime: observation.mediaTime,
            outcome: observation.outcome,
            faceDistance: observation.faceDistance,
            visualDrift: observation.visualDrift,
            reasons: observation.reasons,
         })),
      }))
      .sort((left, right) => (
         (storyIndex.get(left.id) ?? Number.POSITIVE_INFINITY)
         - (storyIndex.get(right.id) ?? Number.POSITIVE_INFINITY)
      ));
   const includedIds = new Set(events.map((event) => event.id));
   const storyOrder = session.editorial.storyOrder.filter((id) => id === 'baseline' || includedIds.has(id));

   return {
      schemaVersion: session.schemaVersion,
      sessionId: session.sessionId,
      createdAt: session.createdAt,
      updatedAt: session.updatedAt,
      source: structuredClone(session.source),
      config: structuredClone(session.config),
      models: structuredClone(session.models),
      storyOrder,
      baseline: session.baseline ? {
         mediaTime: session.baseline.mediaTime,
         selectionMode: session.baseline.selectionMode,
         selectedMediaTime: session.baseline.selectedMediaTime,
         faceSpread: session.baseline.faceSpread,
         embeddingMedianDrift: session.baseline.embeddingMedianDrift,
         embeddingMad: session.baseline.embeddingMad,
         visualTriggerThreshold: session.baseline.visualTriggerThreshold,
         frame: session.baseline.frame ? {
            mediaTime: session.baseline.frame.mediaTime,
            width: session.baseline.frame.width,
            height: session.baseline.frame.height,
         } : null,
      } : null,
      summary: structuredClone(session.summary),
      events,
   };
}

/**
 * Serialises the technical log as newline-delimited JSON. The first record is
 * a self-contained header; later records can be streamed or processed without
 * loading report images into memory.
 *
 * @param {object} session Analysis session.
 * @returns {string} NDJSON document ending with a newline.
 */
export function serializeTechnicalLog(session) {
   const header = {
      type: 'analysis-header',
      schemaVersion: session.schemaVersion,
      sessionId: session.sessionId,
      createdAt: session.createdAt,
      source: session.source,
      config: session.config,
      models: session.models,
      baseline: session.baseline ? {
         mediaTime: session.baseline.mediaTime,
         selectionMode: session.baseline.selectionMode,
         selectedMediaTime: session.baseline.selectedMediaTime,
         faceSpread: session.baseline.faceSpread,
         embeddingMedianDrift: session.baseline.embeddingMedianDrift,
         embeddingMad: session.baseline.embeddingMad,
         visualTriggerThreshold: session.baseline.visualTriggerThreshold,
      } : null,
   };
   const samples = session.samples.map((sample) => ({ type: 'sample', ...sample }));
   const events = session.events.map((event) => ({
      type: 'evidence-event',
      id: event.id,
      startMediaTime: event.startMediaTime,
      endMediaTime: event.endMediaTime,
      anchorMediaTime: event.anchorMediaTime,
      status: event.status,
      classification: event.classification,
      origin: event.origin || 'automatic',
      interpretation: event.interpretation || null,
      reviewRequired: event.reviewRequired,
      promoted: event.promoted,
      caption: event.caption,
      recommendedClip: event.recommendedClip,
      selectedFrame: event.selectedFrame ? {
         mediaTime: event.selectedFrame.mediaTime,
         width: event.selectedFrame.width,
         height: event.selectedFrame.height,
         sharpness: event.selectedFrame.sharpness,
         reason: event.selectedFrame.reason,
      } : null,
      observations: event.observations,
   }));
   const footer = {
      type: 'analysis-footer',
      status: session.status,
      updatedAt: session.updatedAt,
      summary: session.summary,
      storyOrder: session.editorial.storyOrder,
   };
   return [header, ...samples, ...session.journal, ...events, footer]
      .map((entry) => JSON.stringify(entry))
      .join('\n') + '\n';
}