scripts/camera.jsjavascript
/**
* @module camera
* @description
* Webcam lifecycle and the per-frame effect loop driver. Three areas of
* responsibility, kept in one module because they share the `els.video`
* element and the same lifecycle assumption ("the camera is started once
* and stays running"):
*
* 1. Webcam acquisition: requesting `getUserMedia`, attaching the stream,
* applying the mirror transform based on facing mode, and resizing the
* overlay canvas to match the video's native dimensions.
* 2. Effect render loop: throttled `requestAnimationFrame` driver that
* calls `runEffectPass()` from `engine.js` at the rate selected by the
* FPS dropdown.
* 3. One-second recording: a small MediaRecorder wrapper used by the
* workshop's "capture and share" button.
*
* No module-level mutable state lives here except `effectLoopHandle` (the
* rAF id used to cancel the loop on stop). All other state goes through
* `state.js`.
*/
import { state } from './state.js';
import { setStatus, els, clearOverlay } from './dom.js';
import { setLog } from './utils.js';
import { runEffectPass } from './engine.js';
import { RECORDING_CONFIG } from './config.js';
import { t } from './i18n.js';
/**
* Acquire the webcam stream, attach it to the `<video>` element, configure
* mirroring based on the current facing mode, and start the per-frame
* effect loop. Resolves once the video has loaded its metadata and is
* actually playing — callers can treat resolution as "the live feed is
* visible on screen".
*
* Failure modes:
* - `navigator.mediaDevices` missing (insecure context, e.g. HTTP on a LAN
* IP instead of localhost or HTTPS): logs an actionable message and
* throws.
* - `getUserMedia` rejection: propagates the underlying error.
*
* @returns {Promise<void>}
* @throws {Error} If the page is in an insecure context, or the user denies
* camera permission.
* @see scripts/main.js – called once at the end of `init()` after every
* model has been loaded.
* @see startEffectLoop – kicked off here.
*/
export async function startCamera() {
if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') {
const httpsHint = !window.isSecureContext ? t('webcam_https_hint') : '';
setLog(t('webcam_unavailable_log', { hint: httpsHint }));
throw new Error(t('media_devices_unavailable_error'));
}
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
facingMode: state.currentFacingMode
},
audio: false
});
els.video.srcObject = stream;
// Auto-mirror when using the front camera: by convention a selfie view
// is mirrored, a rear-camera (environment) view is not.
state.isMirrored = state.currentFacingMode === 'user';
els.video.style.transform = state.isMirrored ? 'scaleX(-1)' : 'scaleX(1)';
els.overlay.style.transform = state.isMirrored ? 'scaleX(-1)' : 'scaleX(1)';
if (els.mirrorToggle) {
els.mirrorToggle.classList.toggle('mirrored', state.isMirrored);
els.mirrorToggle.textContent = state.isMirrored ? t('mirror_webcam_on_status') : t('mirror_webcam_button');
}
await new Promise(resolve => {
els.video.onloadedmetadata = () => resolve();
});
await els.video.play();
els.placeholder.style.display = 'none';
setStatus('live', t('webcam_live_status'));
setLog(t('webcam_active_log'));
resizeCanvas();
startEffectLoop();
}
/**
* Align the overlay canvas's intrinsic dimensions to the video's native
* resolution. CSS `object-fit: cover` then handles the visual crop to the
* container, which means coordinates returned by face-api / MediaPipe (in
* video-pixel space) project 1:1 onto the canvas without stretching, even
* on screen aspect ratios different from the camera's. Falls back to the
* container's bounding box before the video has known dimensions (during
* boot, before camera permissions are granted).
*
* @see startCamera – called once after the stream starts.
* @see scripts/engine.js – `drawGhostyleOverlay()` calls this before
* drawing, in case the window has been resized since the last frame.
*/
export function resizeCanvas() {
const rect = els.viewer.getBoundingClientRect();
const w = els.video.videoWidth || Math.max(1, Math.floor(rect.width));
const h = els.video.videoHeight || Math.max(1, Math.floor(rect.height));
els.overlay.width = w;
els.overlay.height = h;
}
/**
* Handle returned by the most recent `requestAnimationFrame` for the effect
* render loop. Exported so tests can inspect the start / stop transitions.
* `null` when the loop is not running.
*/
export let effectLoopHandle = null;
/**
* One iteration of the effect render loop. Runs `runEffectPass()` from
* `engine.js` if at least `currentDelay` milliseconds have passed since the
* last execution (so the FPS dropdown actually throttles the inference,
* even though `requestAnimationFrame` itself fires every 16–17 ms).
* Schedules the next iteration unconditionally — to stop the loop, call
* `stopEffectLoop()`.
*
* @param {number} [ts=0] Timestamp from rAF; defaults to 0 for the very
* first frame so the first `runEffectPass` runs immediately.
* @returns {Promise<void>}
* @see runEffectPass – the actual face-api inference call.
* @see startEffectLoop – schedules the first iteration.
*/
export async function effectLoop(ts = 0) {
const currentDelay = parseInt(els.fpsSelect.value, 10) || 120;
if (ts - state.lastEffectRun > currentDelay) {
state.lastEffectRun = ts;
let result = await runEffectPass();
if (result) {
clearOverlay();
}
}
effectLoopHandle = requestAnimationFrame(effectLoop);
}
/**
* Start (or restart) the effect render loop. Cancels any previously
* scheduled rAF first so successive calls don't stack up parallel loops —
* useful when the camera is restarted after a facingMode switch.
*
* @see effectLoop – the function actually scheduled.
* @see startCamera – calls this once the stream is live.
*/
export function startEffectLoop() {
if (effectLoopHandle) cancelAnimationFrame(effectLoopHandle);
effectLoopHandle = requestAnimationFrame(effectLoop);
}
/**
* Stop the effect render loop and reset the inference-in-flight guard.
* Currently called only from unit tests; no production code path teardown
* the loop because the page lifecycle does it implicitly. Kept for
* completeness and as a test-only handle.
*
* @see startEffectLoop – the symmetric counterpart.
* @see tests/unit/camera.test.js – asserts the cancel + null-out behaviour.
*/
export function stopEffectLoop() {
if (effectLoopHandle) cancelAnimationFrame(effectLoopHandle);
effectLoopHandle = null;
state.effectInferenceInFlight = false;
}
/**
* Capture a short video clip from the live webcam stream and emit it as a
* browser-managed Blob for the upload/consent panel. Honours `state.isRecording` and
* `state.isSystemBusy` to refuse overlapping captures, and updates the
* record button visual state for the duration of the recording.
*
* MIME-type selection is best-effort: prefers H.264 MP4 (broadest player
* support), falls back to MP4 generic, then VP9 WebM, then VP8 WebM. If
* none of these are supported, `MediaRecorder` will throw and the error is
* surfaced into the log.
*
* @returns {Promise<void>}
* @see RECORDING_CONFIG – controls mode, endpoint, and duration.
* @see scripts/main.js – wires the `recordBtn` click handler to this.
*/
export async function recordOneSecond() {
if (state.isRecording || state.isSystemBusy) return;
const stream = els.video.srcObject;
if (!stream) {
setLog(t('webcam_stream_inactive_log'));
return;
}
// 1. Mark recording state
state.isRecording = true;
if (els.recordBtn) {
els.recordBtn.classList.add('recording');
els.recordBtn.disabled = true;
}
setLog(t('recording_started_log'));
// 2. Select format
let mimeType = 'video/webm';
let extension = 'webm';
if (MediaRecorder.isTypeSupported('video/mp4;codecs=h264')) {
mimeType = 'video/mp4;codecs=h264';
extension = 'mp4';
} else if (MediaRecorder.isTypeSupported('video/mp4')) {
mimeType = 'video/mp4';
extension = 'mp4';
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) {
mimeType = 'video/webm;codecs=vp9';
extension = 'webm';
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8')) {
mimeType = 'video/webm;codecs=vp8';
extension = 'webm';
}
try {
const recorder = new MediaRecorder(stream, { mimeType });
const chunks = [];
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) {
chunks.push(e.data);
}
};
recorder.onstop = async () => {
const blob = new Blob(chunks, { type: mimeType });
const filename = `ghostati-recording-${Date.now()}.${extension}`;
const clipId = globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const detail = {
id: clipId,
blob,
filename,
mimeType,
extension,
size: blob.size,
durationMs: RECORDING_CONFIG.durationMs,
recordedAt: new Date().toISOString(),
ghostyleId: state.activeEffect || null
};
state.gstmxxEvents.dispatchEvent(new CustomEvent('clipRecorded', { detail }));
setLog(t('recording_queued_log', { filename }));
// Reset visual styles and state
state.isRecording = false;
if (els.recordBtn) {
els.recordBtn.classList.remove('recording');
// Re-enable if system not busy
els.recordBtn.disabled = state.isSystemBusy;
}
};
// Start recording
recorder.start();
// Stop recording after RECORDING_CONFIG.durationMs
setTimeout(() => {
if (recorder.state !== 'inactive') {
recorder.stop();
}
}, RECORDING_CONFIG.durationMs);
} catch (err) {
setLog(t('media_recorder_error_log', { message: err.message }));
state.isRecording = false;
if (els.recordBtn) {
els.recordBtn.classList.remove('recording');
els.recordBtn.disabled = state.isSystemBusy;
}
}
}