lab-js/loader.jsjavascript
/**
* @module loader
* @description
* User-interface adapter for the seekable Video Loader. This page analyses
* existing local videos only; live camera assistance belongs to realtime.html.
* The model pipeline and the serialisable session live in dedicated modules so
* Playwright can drive exactly the same behaviour as a person using the page.
*/
import { applyI18n, initI18n, setupLocaleSelect } from './i18n.js';
import { createVideoAnalysisController } from './video-loader/controller.js';
import {
applyEditorialCommand,
redoEditorialCommand,
undoEditorialCommand,
} from './video-loader/session.js';
const elements = {
file: document.getElementById('videoFile'),
video: document.getElementById('video'),
viewer: document.getElementById('viewer'),
viewerLock: document.getElementById('analysisLockNotice'),
sourceControls: document.querySelector('.loader-source-controls'),
analysisSettings: document.getElementById('analysisSettings'),
sourceFileAction: document.getElementById('sourceFileAction'),
sourceFileName: document.getElementById('sourceFileName'),
sourceFileMeta: document.getElementById('sourceFileMeta'),
placeholder: document.getElementById('placeholder'),
seek: document.getElementById('videoSeek'),
currentTime: document.getElementById('currentTimeLabel'),
duration: document.getElementById('durationLabel'),
statusDot: document.getElementById('statusDot'),
statusText: document.getElementById('statusText'),
start: document.getElementById('startAnalysisBtn'),
cancel: document.getElementById('cancelAnalysisBtn'),
sampleInterval: document.getElementById('sampleInterval'),
selectBase: document.getElementById('selectBaseBtn'),
selectedBaseLabel: document.getElementById('selectedBaseLabel'),
analyzeFrame: document.getElementById('analyzeFrameBtn'),
progress: document.getElementById('analysisProgress'),
progressLabel: document.getElementById('analysisProgressLabel'),
analysisMessage: document.getElementById('analysisMessage'),
analysisMessageTitle: document.getElementById('analysisMessageTitle'),
analysisMessageBody: document.getElementById('analysisMessageBody'),
baselineSummary: document.getElementById('baselineSummary'),
timeline: document.getElementById('analysisTimeline'),
eventList: document.getElementById('eventList'),
emptyReport: document.getElementById('emptyReport'),
reportSummary: document.getElementById('reportSummary'),
downloadReport: document.getElementById('downloadReportBtn'),
downloadLog: document.getElementById('downloadLogBtn'),
undo: document.getElementById('undoEditBtn'),
redo: document.getElementById('redoEditBtn'),
};
const controller = createVideoAnalysisController(elements.video);
let pendingSourceMetadata = {};
let sourceLoaded = false;
let analysisRunning = false;
let selectedBaseTime = null;
let lastProgressState = { scanSampleIndex: 0, scanSampleTotal: null, mediaTime: null };
/**
* Formats a media timestamp as `mm:ss.mmm` or `hh:mm:ss.mmm`.
*
* @param {number} seconds Media timestamp in seconds.
* @returns {string} Human-readable timestamp.
*/
function formatMediaTime(seconds) {
if (!Number.isFinite(seconds)) return '00:00.000';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = seconds % 60;
const prefix = hours ? `${String(hours).padStart(2, '0')}:` : '';
return `${prefix}${String(minutes).padStart(2, '0')}:${rest.toFixed(3).padStart(6, '0')}`;
}
/**
* Formats source byte size for the compact file identity line.
*
* @param {number} bytes Source file size.
* @returns {string} Human-readable binary size.
*/
function formatFileSize(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return 'unknown size';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
/**
* Shows or clears the persistent analysis notice beside the run controls.
*
* @param {'error'|'success'|null} kind Notice kind, or null to clear it.
* @param {string} [title] Short notice heading.
* @param {string} [body] Actionable explanation.
* @returns {void}
*/
function renderAnalysisMessage(kind, title = '', body = '') {
elements.analysisMessage.hidden = kind === null;
if (kind === null) {
elements.analysisMessage.removeAttribute('data-kind');
elements.analysisMessageTitle.textContent = '';
elements.analysisMessageBody.textContent = '';
return;
}
elements.analysisMessage.dataset.kind = kind;
elements.analysisMessageTitle.textContent = title;
elements.analysisMessageBody.textContent = body;
}
/**
* Converts a stable controller failure reason into baseline guidance that can
* be understood without reading the technical log.
*
* @param {object} update Controller failure update.
* @returns {{title: string, body: string}} Visible baseline diagnostic.
*/
function baselineFailureCopy(update) {
const time = formatMediaTime(update.baselineTime);
if (update.reason === 'selected-frame-neighbours-insufficient') {
const windowSeconds = Math.max(0, update.baselineSearchEnd - update.baselineTime).toFixed(1);
return {
title: `Baseline not created at ${time}`,
body: `The selected frame may be usable, but only ${update.acceptedEmbeddings} of ${update.requiredEmbeddings} comparable nearby frames were found within ${windowSeconds} seconds. Rapid pose, hand, crop, or makeup changes prevented stable local calibration. Select a steadier moment and retry. Makeup already being present is not itself an error.`,
};
}
if (update.reason === 'selected-frame-is-not-comparable') {
return {
title: `Baseline frame is not comparable at ${time}`,
body: 'MediaPipe could not produce a usable aligned face crop. Select a frame with the complete face visible, sufficiently large, evenly exposed, and closer to a frontal pose.',
};
}
if (update.reason === 'face-api-could-not-describe-the-selected-frame') {
return {
title: `No face-api reference at ${time}`,
body: 'MediaPipe found usable face geometry, but face-api could not create a descriptor for the selected frame. Select a clearer frame with less occlusion or motion and retry.',
};
}
if (update.reason === 'selected-frame-descriptors-insufficient') {
return {
title: `Not enough face-api references near ${time}`,
body: `MediaPipe calibration succeeded, but only ${update.acceptedDescriptors} of ${update.requiredDescriptors} nearby frames returned a face-api descriptor. The selected frame may be usable; choose an interval with a clearer, less occluded face and retry.`,
};
}
if (update.reason === 'selected-frame-descriptors-are-not-stable') {
return {
title: `Baseline readings are unstable at ${time}`,
body: 'Nearby face-api descriptors varied too much to define one trustworthy reference. Select a steadier interval and retry.',
};
}
return {
title: `Baseline not created at ${time}`,
body: 'The selected point did not provide enough stable, comparable face observations. Choose a clearer and steadier moment; the technical log retains the precise rejection reasons.',
};
}
/**
* Seeks the source player from report evidence when analysis is idle. During
* analysis the controller owns media time, so evidence links remain disabled.
*
* @param {number} mediaTime Source timestamp.
* @returns {void}
*/
function seekToMediaTime(mediaTime) {
if (analysisRunning || !sourceLoaded) return;
elements.video.pause();
elements.video.currentTime = Math.max(0, Math.min(elements.video.duration || mediaTime, mediaTime));
syncVideoTimeline();
}
/**
* Creates a timestamp that acts as a source-video seek control.
*
* @param {number} mediaTime Source timestamp.
* @param {string} [extraClass] Optional presentation class.
* @returns {HTMLButtonElement} Clickable media-time control.
*/
function createMediaTimeButton(mediaTime, extraClass = '') {
const button = document.createElement('button');
button.type = 'button';
button.className = `loader-time-link ${extraClass}`.trim();
button.dataset.mediaTime = String(mediaTime);
button.textContent = formatMediaTime(mediaTime);
button.setAttribute('aria-label', `Show source video at ${formatMediaTime(mediaTime)}`);
button.disabled = analysisRunning;
button.addEventListener('click', () => seekToMediaTime(mediaTime));
return button;
}
/**
* Renders baseline calibration or scan-sample progress without pretending
* that browser media time is a reliable encoded-frame number.
*
* @param {object} update Controller update.
* @returns {void}
*/
function renderAnalysisProgress(update) {
if (update.phase === 'baseline') {
const accepted = update.baselineAcceptedSamples ?? 0;
const required = update.baselineRequiredSamples ?? 0;
const acceptedDescriptors = update.baselineAcceptedDescriptors ?? 0;
const requiredDescriptors = update.baselineRequiredDescriptors ?? 0;
const descriptorStage = required > 0 && accepted >= required;
elements.progress.max = Math.max(1, descriptorStage ? requiredDescriptors : required);
elements.progress.value = descriptorStage ? acceptedDescriptors : Math.min(accepted, required);
elements.progressLabel.textContent = descriptorStage
? `baseline face-api ${acceptedDescriptors} / ${requiredDescriptors}`
: `baseline MediaPipe ${accepted} / ${required}`;
return;
}
const index = update.scanSampleIndex ?? lastProgressState.scanSampleIndex;
const total = update.scanSampleTotal ?? lastProgressState.scanSampleTotal;
const mediaTime = update.mediaTime ?? update.sample?.mediaTime ?? lastProgressState.mediaTime;
lastProgressState = { scanSampleIndex: index, scanSampleTotal: total, mediaTime };
elements.progress.max = Math.max(1, total || 1);
elements.progress.value = Math.min(index, total || index);
const percentage = Math.max(0, Math.min(100, Math.round((update.progress ?? 0) * 100)));
const count = total ? `${index} / ${total} samples` : `${index} samples`;
if (update.type === 'analysis-cancelled') {
elements.progressLabel.textContent = `${count} · paused at ${formatMediaTime(mediaTime)}`;
} else if (update.type === 'analysis-complete') {
elements.progressLabel.textContent = `${count} · complete`;
} else {
elements.progressLabel.textContent = `${count} · ${percentage}%`;
}
}
/**
* Updates the visible application status.
*
* @param {'init'|'ready'|'working'|'error'} kind Status kind.
* @param {string} message Human-readable status.
* @returns {void}
*/
function setStatus(kind, message) {
elements.statusDot.className = 'status-dot';
if (kind === 'ready') elements.statusDot.classList.add('live');
if (kind === 'working') elements.statusDot.classList.add('working');
if (kind === 'error') elements.statusDot.classList.add('error');
elements.statusText.textContent = message;
}
/**
* Dispatches a stable browser event consumed by Playwright and optional tools.
*
* @param {string} type Event suffix.
* @param {object} [detail={}] Serializable detail.
* @returns {void}
*/
function dispatchLoaderEvent(type, detail = {}) {
window.dispatchEvent(new CustomEvent(`gstmxx-video-loader:${type}`, { detail }));
}
/**
* Starts a browser download without coupling the report and technical log.
*
* @param {string} filename Suggested filename.
* @param {string} contents Text contents.
* @param {string} mimeType MIME type.
* @returns {void}
*/
function downloadText(filename, contents, mimeType) {
const url = URL.createObjectURL(new Blob([contents], { type: mimeType }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
/**
* Downloads a selected evidence frame from its data URL.
*
* @param {string} filename Suggested filename.
* @param {string} dataUrl JPEG data URL.
* @returns {void}
*/
function downloadFrame(filename, dataUrl) {
const anchor = document.createElement('a');
anchor.href = dataUrl;
anchor.download = filename;
anchor.click();
}
/**
* Synchronises video labels and the manual seek control.
*
* @returns {void}
*/
function syncVideoTimeline() {
const duration = Number.isFinite(elements.video.duration) ? elements.video.duration : 0;
elements.currentTime.textContent = formatMediaTime(elements.video.currentTime);
elements.duration.textContent = formatMediaTime(duration);
elements.seek.max = String(duration);
if (!elements.seek.matches(':active')) elements.seek.value = String(elements.video.currentTime || 0);
elements.analyzeFrame.textContent = `Analyse frame at ${formatMediaTime(elements.video.currentTime || 0)}`;
renderBaseSelection();
}
/**
* Shows the current scrub position as an explicit baseline action and keeps
* the already selected reference visible while the person reviews the video.
*
* @returns {void}
*/
function renderBaseSelection() {
const currentTime = Number(elements.video.currentTime) || 0;
const currentLabel = formatMediaTime(currentTime);
if (selectedBaseTime === null) {
elements.selectBase.textContent = `Select base at ${currentLabel}`;
elements.selectedBaseLabel.textContent = 'No base selected. Scrub the video, then select the displayed frame.';
return;
}
const selectedLabel = formatMediaTime(selectedBaseTime);
const isAtSelectedFrame = Math.abs(currentTime - selectedBaseTime) < 0.001;
elements.selectBase.textContent = isAtSelectedFrame
? `Base selected at ${selectedLabel}`
: `Replace base with ${currentLabel}`;
elements.selectedBaseLabel.textContent = `Distance reference selected at ${selectedLabel}. It will be the first evidence-story entry.`;
}
/**
* Selects the currently displayed video position as the exact computational
* and visual baseline. Nearby frames may calibrate noise but cannot replace
* the selected source timestamp.
*
* @returns {void}
*/
function selectCurrentBase() {
selectedBaseTime = Number(elements.video.currentTime) || 0;
renderAnalysisMessage(null);
renderBaseSelection();
syncControls();
}
/**
* Draws three separate evidence lanes: MediaPipe presence, ImageEmbedder drift,
* and sparse face-api distances. Values from different models are never
* merged into one scale.
*
* @param {object|null} session Current analysis session.
* @returns {void}
*/
function drawTimeline(session) {
const canvas = elements.timeline;
const context = canvas.getContext('2d');
if (!context || typeof context.setTransform !== 'function') return;
const cssWidth = Math.max(320, canvas.clientWidth || 960);
const cssHeight = 210;
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(cssWidth * ratio);
canvas.height = Math.round(cssHeight * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, cssWidth, cssHeight);
context.fillStyle = '#17130f';
context.fillRect(0, 0, cssWidth, cssHeight);
const left = 150;
const right = 14;
const plotWidth = cssWidth - left - right;
const rangeStart = session?.config?.analysisRange?.start ?? 0;
const rangeEnd = session?.config?.analysisRange?.end ?? session?.source?.duration ?? 1;
const duration = Math.max(0.001, rangeEnd - rangeStart);
const x = (mediaTime) => left + ((mediaTime - rangeStart) / duration) * plotWidth;
const lanes = {
presence: { top: 18, height: 34, label: 'MediaPipe coverage' },
drift: { top: 74, height: 50, label: 'ImageEmbedder drift' },
face: { top: 146, height: 48, label: 'face-api L2' },
};
context.font = '700 11px ui-monospace, monospace';
context.textBaseline = 'middle';
for (const lane of Object.values(lanes)) {
context.fillStyle = '#b8aa96';
context.fillText(lane.label, 10, lane.top + lane.height / 2);
context.strokeStyle = 'rgba(255, 231, 168, 0.12)';
context.strokeRect(left, lane.top, plotWidth, lane.height);
}
if (!session?.samples?.length) return;
const timelineSamples = session.samples
.filter((sample) => sample.phase !== 'baseline')
.sort((leftSample, rightSample) => leftSample.mediaTime - rightSample.mediaTime);
if (!timelineSamples.length) return;
const threshold = session.baseline?.visualTriggerThreshold || 0.1;
const driftMax = Math.max(0.1, threshold * 3, ...timelineSamples.map((sample) => sample.visualDrift || 0));
context.lineWidth = 2;
context.strokeStyle = '#7fe3b0';
context.beginPath();
timelineSamples.forEach((sample, index) => {
const px = x(sample.mediaTime);
const py = lanes.presence.top + (sample.comparable
? 7
: sample.facePresent ? lanes.presence.height / 2 : lanes.presence.height - 7);
if (index === 0) context.moveTo(px, py);
else context.lineTo(px, py);
});
context.stroke();
for (const sample of timelineSamples) {
context.fillStyle = sample.comparable ? '#7fe3b0' : sample.facePresent ? '#f3c747' : '#756b60';
const py = lanes.presence.top + (sample.comparable
? 7
: sample.facePresent ? lanes.presence.height / 2 : lanes.presence.height - 7);
context.beginPath();
context.arc(x(sample.mediaTime), py, 2.25, 0, Math.PI * 2);
context.fill();
}
context.strokeStyle = '#f3c747';
context.beginPath();
let started = false;
for (const sample of timelineSamples) {
if (sample.visualDrift === null) {
started = false;
continue;
}
const px = x(sample.mediaTime);
const py = lanes.drift.top + lanes.drift.height - (sample.visualDrift / driftMax) * lanes.drift.height;
if (!started) context.moveTo(px, py);
else context.lineTo(px, py);
started = true;
}
context.stroke();
if (session.baseline) {
const thresholdY = lanes.drift.top + lanes.drift.height
- (session.baseline.visualTriggerThreshold / driftMax) * lanes.drift.height;
context.strokeStyle = 'rgba(243, 199, 71, 0.65)';
context.setLineDash([4, 4]);
context.beginPath();
context.moveTo(left, thresholdY);
context.lineTo(left + plotWidth, thresholdY);
context.stroke();
context.setLineDash([]);
context.fillStyle = '#d8c9b5';
context.font = '700 9px ui-monospace, monospace';
context.textAlign = 'right';
context.fillText(
`trigger ${session.baseline.visualTriggerThreshold.toFixed(4)}`,
left + plotWidth - 6,
thresholdY - 7,
);
context.textAlign = 'left';
}
const faceApiEntries = session.journal.filter((entry) => entry.type === 'face-api-result');
const numericFaceDistances = faceApiEntries
.map((entry) => entry.faceDistance)
.filter(Number.isFinite);
const faceThreshold = session.config?.faceDistanceThreshold ?? 0.6;
const faceMax = Math.max(0.8, faceThreshold * 1.25, ...numericFaceDistances.map((value) => value * 1.1));
context.fillStyle = '#ff8e72';
for (const entry of faceApiEntries) {
const px = x(entry.mediaTime);
if (Number.isFinite(entry.faceDistance)) {
const py = lanes.face.top + lanes.face.height - (entry.faceDistance / faceMax) * lanes.face.height;
context.beginPath();
context.arc(px, py, 3.5, 0, Math.PI * 2);
context.fill();
} else {
const py = lanes.face.top + 7;
context.strokeStyle = '#ff8e72';
context.lineWidth = 1.6;
context.beginPath();
context.moveTo(px - 3, py - 3);
context.lineTo(px + 3, py + 3);
context.moveTo(px + 3, py - 3);
context.lineTo(px - 3, py + 3);
context.stroke();
}
}
const faceThresholdY = lanes.face.top + lanes.face.height - (faceThreshold / faceMax) * lanes.face.height;
context.strokeStyle = 'rgba(255, 142, 114, 0.7)';
context.setLineDash([4, 4]);
context.beginPath();
context.moveTo(left, faceThresholdY);
context.lineTo(left + plotWidth, faceThresholdY);
context.stroke();
context.setLineDash([]);
context.fillStyle = '#d8c9b5';
context.font = '700 9px ui-monospace, monospace';
context.textAlign = 'right';
context.fillText('× no 2D face', left + plotWidth - 6, faceThresholdY - 7);
context.fillText(`threshold ${faceThreshold.toFixed(4)}`, left + plotWidth - 6, faceThresholdY + 12);
context.textAlign = 'left';
}
/**
* Builds one editable evidence card.
*
* @param {object} event Session evidence event.
* @param {object} session Current session.
* @returns {HTMLElement} Event card.
*/
function createEventCard(event, session) {
const card = document.createElement('article');
card.className = [
'loader-event',
`loader-event--${event.status}`,
event.origin === 'manual' ? 'loader-event--manual' : '',
].filter(Boolean).join(' ');
card.dataset.eventId = event.id;
if (event.selectedFrame) {
const image = document.createElement('img');
image.src = event.selectedFrame.dataUrl;
image.alt = `Selected video frame at ${formatMediaTime(event.selectedFrame.mediaTime)}`;
card.appendChild(image);
}
const body = document.createElement('div');
body.className = 'loader-event__body';
const title = document.createElement('h3');
const eventLabel = event.classification === 'manual-frame-check'
? 'Manual frame check'
: event.classification === 'identity-distance'
? 'Identity-distance transition'
: event.classification === 'detector-divergence'
? 'Detector divergence — review'
: event.status === 'confirmed' ? 'Mixed transition — review' : 'Candidate';
title.append(`${eventLabel} · `, createMediaTimeButton(event.anchorMediaTime));
body.appendChild(title);
const best = event.observations.at(-1);
const metrics = document.createElement('p');
metrics.className = 'loader-event__metrics';
metrics.textContent = [
best?.faceDistance === null ? 'face-api: no face' : `face-api: ${best?.faceDistance?.toFixed(4) ?? 'n/a'}`,
best?.visualDrift === null ? 'ImageEmbedder drift: n/a' : `ImageEmbedder drift: ${best.visualDrift.toFixed(4)}`,
].join(' · ');
body.appendChild(metrics);
const reasons = document.createElement('p');
reasons.className = 'loader-event__reasons';
reasons.textContent = event.origin === 'manual'
? 'Requested manually from the source position. This is one explicit check, not a repeated automatic transition.'
: `Trigger: ${(best?.reasons || []).join(', ') || 'verification'}`;
body.appendChild(reasons);
if (event.origin === 'manual') {
const interpretation = document.createElement('p');
interpretation.className = 'loader-event__warning';
interpretation.textContent = event.interpretation === 'above-threshold'
? `The face-api distance is at or above the ${(best?.faceThreshold ?? 0.6).toFixed(4)} threshold.`
: event.interpretation === 'below-threshold'
? `The face-api distance is below the ${(best?.faceThreshold ?? 0.6).toFixed(4)} threshold.`
: event.interpretation === 'no-2d-face'
? 'MediaPipe retained a comparable crop, but face-api returned no 2D face.'
: 'This frame did not meet MediaPipe comparability limits; any face-api value is shown only as diagnostic context.';
body.appendChild(interpretation);
}
const caption = document.createElement('textarea');
caption.value = event.caption;
caption.placeholder = 'Explain why this before/after moment matters…';
caption.setAttribute('aria-label', 'Event caption');
caption.addEventListener('change', () => {
applyEditorialCommand(session, { type: 'caption', eventId: event.id, value: caption.value });
renderSession(session);
});
body.appendChild(caption);
const actions = document.createElement('div');
actions.className = 'loader-event__actions';
appendStoryOrderButtons(actions, event.id, session);
const promote = document.createElement('button');
promote.type = 'button';
promote.textContent = event.promoted ? 'Selected for story' : 'Select for story';
promote.disabled = event.promoted;
promote.addEventListener('click', () => {
applyEditorialCommand(session, { type: 'promote', eventId: event.id });
renderSession(session);
});
actions.appendChild(promote);
const reject = document.createElement('button');
reject.type = 'button';
reject.textContent = 'Reject';
reject.addEventListener('click', () => {
applyEditorialCommand(session, { type: 'reject', eventId: event.id });
renderSession(session);
});
actions.appendChild(reject);
if (event.selectedFrame) {
const save = document.createElement('button');
save.type = 'button';
save.textContent = 'Download frame';
save.addEventListener('click', () => downloadFrame(
`ghostmaxxing-${formatMediaTime(event.selectedFrame.mediaTime).replaceAll(':', '-')}.jpg`,
event.selectedFrame.dataUrl,
));
actions.appendChild(save);
}
body.appendChild(actions);
card.appendChild(body);
return card;
}
/**
* Returns report items in human-controlled story order. Source timestamps are
* never changed by an editorial move.
*
* @param {object} session Current session.
* @returns {Array<{id: string, kind: 'baseline'|'event', value: object}>} Ordered items.
*/
function orderedStoryItems(session) {
if (!session) return [];
const events = new Map(session.events
.filter((event) => event.status === 'confirmed')
.map((event) => [event.id, event]));
const ordered = [];
for (const id of session.editorial.storyOrder) {
if (id === 'baseline' && session.baseline) {
ordered.push({ id, kind: 'baseline', value: session.baseline });
} else if (events.has(id)) {
ordered.push({ id, kind: 'event', value: events.get(id) });
events.delete(id);
}
}
for (const event of events.values()) ordered.push({ id: event.id, kind: 'event', value: event });
return ordered;
}
/**
* Adds reversible story-order controls to one evidence card.
*
* @param {HTMLElement} actions Action container.
* @param {string} itemId Event identifier.
* @param {object} session Current session.
* @returns {void}
*/
function appendStoryOrderButtons(actions, itemId, session) {
const order = orderedStoryItems(session).map((item) => item.id);
const index = order.indexOf(itemId);
const earlier = document.createElement('button');
earlier.type = 'button';
earlier.textContent = 'Move earlier';
earlier.disabled = index <= 0 || order[index - 1] === 'baseline';
earlier.addEventListener('click', () => {
applyEditorialCommand(session, { type: 'move-earlier', eventId: itemId });
renderSession(session);
});
actions.appendChild(earlier);
const later = document.createElement('button');
later.type = 'button';
later.textContent = 'Move later';
later.disabled = index < 0 || index >= order.length - 1;
later.addEventListener('click', () => {
applyEditorialCommand(session, { type: 'move-later', eventId: itemId });
renderSession(session);
});
actions.appendChild(later);
}
/**
* Builds the pinned computational reference as the first story item. Its
* source timestamp and role in the distance calculation remain unchanged.
*
* @param {object} baseline Session baseline.
* @param {object} session Current session.
* @returns {HTMLElement} Reference card.
*/
function createBaselineCard(baseline, session) {
const card = document.createElement('article');
card.className = 'loader-event loader-event--reference';
card.dataset.eventId = 'baseline';
if (baseline.frame) {
const image = document.createElement('img');
image.src = baseline.frame.dataUrl;
image.alt = `Computational reference frame at ${formatMediaTime(baseline.mediaTime)}`;
card.appendChild(image);
}
const body = document.createElement('div');
body.className = 'loader-event__body';
const title = document.createElement('h3');
title.append('Baseline · distance reference · ', createMediaTimeButton(baseline.mediaTime));
body.appendChild(title);
const metrics = document.createElement('p');
metrics.className = 'loader-event__metrics';
metrics.textContent = [
`ImageEmbedder trigger ${baseline.visualTriggerThreshold.toFixed(4)}`,
`face-api baseline spread ${baseline.faceSpread.toFixed(4)}`,
`face-api decision threshold ${(session.config?.faceDistanceThreshold ?? 0.6).toFixed(4)}`,
].join(' · ');
body.appendChild(metrics);
const explanation = document.createElement('p');
explanation.className = 'loader-event__reasons';
explanation.textContent = 'This frame anchors the distances and remains the first story entry. Its source timestamp and computational role never change.';
body.appendChild(explanation);
if (baseline.faceSpread > (session.config?.maximumBaselineFaceSpread ?? 0.3)) {
const warning = document.createElement('p');
warning.className = 'loader-event__warning';
warning.textContent = 'The selected baseline has elevated face-api variation. Treat distances near the decision threshold as review evidence.';
body.appendChild(warning);
}
const actions = document.createElement('div');
actions.className = 'loader-event__actions';
const save = document.createElement('button');
save.type = 'button';
save.textContent = 'Download frame';
save.addEventListener('click', () => downloadFrame(
`ghostmaxxing-reference-${formatMediaTime(baseline.mediaTime).replaceAll(':', '-')}.jpg`,
baseline.frame.dataUrl,
));
actions.appendChild(save);
body.appendChild(actions);
card.appendChild(body);
return card;
}
/**
* Re-renders report summary, timeline, and evidence cards from session state.
*
* @param {object|null} session Current analysis session.
* @returns {void}
*/
function renderSession(session) {
drawTimeline(session);
elements.eventList.replaceChildren();
const storyItems = orderedStoryItems(session);
elements.emptyReport.hidden = storyItems.length > 0;
for (const item of storyItems) {
elements.eventList.appendChild(item.kind === 'baseline'
? createBaselineCard(item.value, session)
: createEventCard(item.value, session));
}
if (session?.baseline) {
elements.baselineSummary.replaceChildren(
document.createTextNode('Reference '),
createMediaTimeButton(session.baseline.mediaTime, 'loader-time-link--summary'),
document.createTextNode([
` · visual trigger ${session.baseline.visualTriggerThreshold.toFixed(4)}`,
`face noise ${session.baseline.faceSpread.toFixed(4)}`,
].join(' · ')),
);
} else {
elements.baselineSummary.textContent = 'Distance reference not acquired yet.';
}
if (session) {
elements.reportSummary.textContent = [
`${session.summary.sampledFrames} samples`,
`${session.summary.faceApiRuns} face-api checks`,
`${session.summary.confirmedEvents} confirmed events`,
`${session.summary.manualChecks || 0} manual checks`,
].join(' · ');
elements.downloadReport.disabled = session.status !== 'complete';
elements.downloadLog.disabled = session.samples.length === 0;
elements.undo.disabled = session.editorial.undo.length === 0;
elements.redo.disabled = session.editorial.redo.length === 0;
}
}
/**
* Compares paused-run settings with the editable UI values. It prevents a
* changed cadence or reference from being presented as a lossless resume.
*
* @param {object|null} session Current analysis session.
* @param {number} requestedSampleInterval Sampling interval requested by the UI or API.
* @param {number|null} requestedBaselineTime Baseline timestamp requested by the UI or API.
* @returns {{samplingChanged: boolean, baselineChanged: boolean}} Restart reasons.
*/
function getPausedSettingsChanges(session, requestedSampleInterval, requestedBaselineTime) {
if (session?.status !== 'cancelled') return { samplingChanged: false, baselineChanged: false };
const pausedSampleInterval = Number(session.config?.sampleIntervalSeconds);
const pausedBaselineTime = Number(
session.baseline?.selectedMediaTime
?? session.baseline?.mediaTime
?? session.config?.baselineSearchStart,
);
return {
samplingChanged: Number.isFinite(pausedSampleInterval)
&& requestedSampleInterval !== pausedSampleInterval,
baselineChanged: requestedBaselineTime !== null
&& Number.isFinite(pausedBaselineTime)
&& Math.abs(Number(requestedBaselineTime) - pausedBaselineTime) >= 0.001,
};
}
/**
* Updates controls after model, file, and analysis state changes.
*
* @returns {void}
*/
function syncControls() {
const session = controller.getSession();
const resumable = session?.status === 'cancelled';
const changes = getPausedSettingsChanges(
session,
Number(elements.sampleInterval.value),
selectedBaseTime,
);
const restartLabel = changes.samplingChanged && changes.baselineChanged
? 'Restart with new settings'
: changes.samplingChanged
? 'Restart with new sampling'
: 'Restart with new baseline';
const restartRequired = changes.samplingChanged || changes.baselineChanged;
elements.start.disabled = !sourceLoaded
|| !controller.isModelsReady()
|| (!resumable && selectedBaseTime === null)
|| analysisRunning;
elements.start.textContent = resumable
? restartRequired ? restartLabel : 'Resume analysis'
: session?.status === 'complete'
? 'Analyse again'
: session?.status === 'failed' ? 'Retry analysis' : 'Analyse video';
elements.cancel.disabled = !analysisRunning;
elements.start.hidden = analysisRunning;
elements.cancel.hidden = !analysisRunning;
elements.file.disabled = analysisRunning;
elements.sampleInterval.disabled = analysisRunning;
elements.selectBase.disabled = !sourceLoaded || analysisRunning;
elements.analyzeFrame.disabled = !sourceLoaded || !session?.baseline || analysisRunning;
elements.seek.disabled = !sourceLoaded || analysisRunning;
elements.video.inert = analysisRunning;
elements.video.setAttribute('aria-disabled', String(analysisRunning));
elements.viewer.classList.toggle('is-analysis-locked', analysisRunning);
elements.viewerLock.hidden = !analysisRunning;
elements.sourceControls.classList.toggle('is-muted', analysisRunning);
elements.analysisSettings.classList.toggle('is-muted', analysisRunning);
document.querySelectorAll('.loader-time-link').forEach((button) => {
button.disabled = analysisRunning;
});
}
/**
* Loads the file currently selected in the input.
*
* @returns {Promise<object|null>} Loaded session, or null when no file exists.
*/
async function loadSelectedFile() {
const file = elements.file.files?.[0];
if (!file) return null;
setStatus('working', 'loading video');
const session = await controller.loadFile(file, pendingSourceMetadata);
pendingSourceMetadata = {};
sourceLoaded = true;
selectedBaseTime = null;
elements.sourceFileAction.textContent = 'Replace local video';
elements.sourceFileName.textContent = session.source.name;
elements.sourceFileName.title = session.source.name;
elements.sourceFileMeta.textContent = [
session.source.width && session.source.height ? `${session.source.width} × ${session.source.height}` : null,
formatMediaTime(session.source.duration),
formatFileSize(session.source.size),
].filter(Boolean).join(' · ');
renderAnalysisMessage(null);
elements.placeholder.hidden = true;
elements.seek.disabled = false;
syncVideoTimeline();
renderSession(session);
setStatus('ready', 'ready to analyse');
syncControls();
dispatchLoaderEvent('source-loaded', { source: session.source });
return session;
}
/**
* Runs analysis using the currently selected sampling interval.
*
* @param {object} [options] Optional API-provided range and cadence overrides.
* @returns {Promise<object>} Completed session.
*/
async function startAnalysis(options = {}) {
const currentSession = controller.getSession();
const requestedSampleInterval = Number(options.sampleIntervalSeconds ?? elements.sampleInterval.value);
const requestedBaselineValue = options.baselineTime ?? selectedBaseTime;
const changes = getPausedSettingsChanges(currentSession, requestedSampleInterval, requestedBaselineValue);
const restartRequired = changes.samplingChanged || changes.baselineChanged;
const resume = options.resume ?? (currentSession?.status === 'cancelled' && !restartRequired);
const baselineWasExplicitlySelected = options.baselineTime !== undefined && options.baselineTime !== null
|| selectedBaseTime !== null;
analysisRunning = true;
renderAnalysisMessage(null);
syncControls();
setStatus('working', resume ? 'resuming analysis' : 'analysing video');
if (!resume) {
lastProgressState = { scanSampleIndex: 0, scanSampleTotal: null, mediaTime: null };
elements.progress.max = 1;
elements.progress.value = 0;
elements.progressLabel.textContent = 'building baseline';
}
try {
const session = await controller.analyze({
resume,
sampleIntervalSeconds: requestedSampleInterval,
baselineTime: Number(options.baselineTime ?? selectedBaseTime ?? options.start ?? 0),
baselineSelectionMode: options.baselineSelectionMode
?? (baselineWasExplicitlySelected ? 'anchored' : 'search'),
start: options.start,
end: options.end,
});
renderSession(session);
setStatus('ready', session.status === 'complete' ? 'analysis complete' : 'analysis paused');
return session;
} catch (error) {
const failedBaseline = controller.getSession()?.status === 'failed';
setStatus('error', failedBaseline ? 'baseline not created' : error.message || String(error));
if (!failedBaseline) {
renderAnalysisMessage('error', 'Analysis stopped', error.message || String(error));
}
dispatchLoaderEvent('error', { message: error.message || String(error) });
throw error;
} finally {
analysisRunning = false;
syncControls();
}
}
/**
* Runs an explicit comparison for the frame currently shown by the source
* player and adds the result to the editable evidence story.
*
* @param {number} [mediaTime] Optional timestamp supplied by automation.
* @returns {Promise<object>} Created manual evidence event.
*/
async function analyzeCurrentFrame(mediaTime = Number(elements.video.currentTime) || 0) {
analysisRunning = true;
renderAnalysisMessage(null);
syncControls();
setStatus('working', 'analysing selected frame');
try {
const event = await controller.analyzeFrame(mediaTime);
renderSession(controller.getSession());
setStatus('ready', 'manual frame check added');
return event;
} catch (error) {
setStatus('error', 'frame check failed');
renderAnalysisMessage('error', 'Frame check failed', error.message || String(error));
dispatchLoaderEvent('error', { message: error.message || String(error) });
throw error;
} finally {
analysisRunning = false;
syncControls();
}
}
controller.subscribe((update) => {
if (update.type === 'models-ready') {
setStatus(sourceLoaded ? 'ready' : 'init', sourceLoaded ? 'ready to analyse' : 'select a local video');
syncControls();
dispatchLoaderEvent('models-ready');
return;
}
if (update.type === 'sample' || update.type === 'analysis-cancelled' || update.type === 'analysis-complete') {
renderAnalysisProgress(update);
}
if (update.type === 'sample') {
renderSession(update.session);
}
if (update.type === 'analysis-failed') {
const copy = baselineFailureCopy(update);
renderAnalysisMessage('error', copy.title, copy.body);
setStatus('error', 'baseline not created');
}
if (update.type === 'manual-frame-checked') {
const observation = update.event.observations.at(-1);
const distance = Number.isFinite(observation?.faceDistance)
? `face-api distance ${observation.faceDistance.toFixed(4)}`
: 'face-api returned no numeric distance';
renderAnalysisMessage(
'success',
`Frame added at ${formatMediaTime(update.event.anchorMediaTime)}`,
`${distance}. Review the new manual card in the evidence story.`,
);
renderSession(update.session);
}
if (update.type === 'baseline-ready' || update.type === 'event-confirmed') {
renderSession(update.session);
syncControls();
}
dispatchLoaderEvent(update.type, {
progress: update.progress,
eventId: update.event?.id,
});
});
elements.file.addEventListener('change', () => {
loadSelectedFile().catch((error) => setStatus('error', error.message || String(error)));
});
elements.video.addEventListener('timeupdate', syncVideoTimeline);
elements.video.addEventListener('seeked', syncVideoTimeline);
elements.video.addEventListener('loadedmetadata', syncVideoTimeline);
elements.seek.addEventListener('input', () => {
elements.video.currentTime = Number(elements.seek.value) || 0;
syncVideoTimeline();
});
elements.sampleInterval.addEventListener('change', syncControls);
elements.selectBase.addEventListener('click', selectCurrentBase);
elements.start.addEventListener('click', () => startAnalysis().catch(() => {}));
elements.cancel.addEventListener('click', () => controller.cancel());
elements.analyzeFrame.addEventListener('click', () => analyzeCurrentFrame().catch(() => {}));
elements.undo.addEventListener('click', () => {
const session = controller.getSession();
if (session && undoEditorialCommand(session)) renderSession(session);
});
elements.redo.addEventListener('click', () => {
const session = controller.getSession();
if (session && redoEditorialCommand(session)) renderSession(session);
});
elements.downloadReport.addEventListener('click', () => {
const bundle = controller.exportBundle();
downloadText('ghostmaxxing-video-report.json', JSON.stringify(bundle.report, null, 2), 'application/json');
});
elements.downloadLog.addEventListener('click', () => {
const bundle = controller.exportBundle();
downloadText('ghostmaxxing-analysis.ndjson', bundle.technicalLog, 'application/x-ndjson');
});
/**
* Stable automation API shared by the UI and the Playwright CLI.
*
* @type {object}
*/
window.gstmxxVideoLoader = {
/** @returns {boolean} Whether vendored models are ready. */
isModelsReady: () => controller.isModelsReady(),
/** @returns {boolean} Whether a source has loaded metadata. */
isSourceLoaded: () => sourceLoaded,
/** @returns {boolean} Whether an analysis is in progress. */
isAnalysisRunning: () => analysisRunning,
/** @returns {object|null} Current mutable session for diagnostic automation. */
getSession: () => controller.getSession(),
/** @returns {object} Report, technical log, and selected frame artifacts. */
exportBundle: () => controller.exportBundle(),
/** @param {object} metadata Source provenance supplied before setInputFiles(). */
setSourceMetadata(metadata) { pendingSourceMetadata = { ...metadata }; },
/** @param {object} options Analysis range and cadence. */
analyze: (options = {}) => startAnalysis(options),
/** @param {number} mediaTime Source timestamp to compare with the baseline. */
analyzeFrame: (mediaTime) => analyzeCurrentFrame(mediaTime),
/** @returns {Promise<object>} Resumes the current cancelled analysis. */
resume: () => startAnalysis({ resume: true }),
/** @returns {void} Requests a resumable pause at the next sample boundary. */
cancel: () => controller.cancel(),
};
/**
* Initialises localisation and model loading without requesting a camera.
*
* @returns {Promise<void>} Resolves after model loading succeeds or fails.
*/
async function init() {
initI18n();
setupLocaleSelect(document.getElementById('localeSelect'), applyI18n);
applyI18n();
renderSession(null);
syncControls();
setStatus('init', 'loading models');
try {
await controller.loadModels();
} catch (error) {
setStatus('error', `model loading failed: ${error.message || String(error)}`);
dispatchLoaderEvent('error', { message: error.message || String(error) });
}
}
init();