/* * Copyright 2021 Google LLC * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA */ /** * @typedef {'idle' | 'delay' | 'waiting' | 'capturing' | 'done' | 'stopped' | 'failed'} Phase * * @typedef {object} SequenceState * @property {Phase} phase * @property {number} taken Frames successfully captured so far. * @property {number} total Requested frame count, 0 for unlimited. * @property {number} errors Failed captures so far. * @property {number} nextAt Timestamp of the next scheduled frame (0 if none). * @property {number} startedAt Timestamp the sequence was started (0 if idle). * @property {number} finishedAt Timestamp the sequence ended (0 while running). * @property {number} intervalMs */ /** @type {SequenceState} */ const INITIAL_STATE = { phase: 'idle', taken: 0, total: 0, errors: 0, nextAt: 0, startedAt: 0, finishedAt: 0, intervalMs: 0 }; /** Abort three failures in a row - at that point something is properly wrong. */ const MAX_CONSECUTIVE_ERRORS = 3; class Aborted extends Error {} /** * Resolve at an absolute timestamp, or reject with `Aborted` if the signal * fires first. Resolves immediately if the deadline has already passed. * @param {number} timestamp * @param {AbortSignal} signal */ function sleepUntil(timestamp, signal) { return new Promise((resolve, reject) => { if (signal.aborted) return reject(new Aborted()); let remaining = timestamp - Date.now(); if (remaining <= 0) return resolve(undefined); let onAbort = () => { clearTimeout(timer); reject(new Aborted()); }; let timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(undefined); }, remaining); signal.addEventListener('abort', onAbort, { once: true }); }); } /** * Drives a timed sequence of captures. * * Frames are scheduled on an absolute grid (`start + n * interval`) rather than * by sleeping for `interval` after each frame, so download time doesn't * accumulate as drift over a long run. If a capture overruns its slot the next * frame fires as soon as the camera is free and the overrun is reported, rather * than silently dropping a frame. */ export class Intervalometer { /** @type {SequenceState} */ state = INITIAL_STATE; /** @type {AbortController | null} */ #abort = null; /** @type {ReturnType | undefined} */ #ticker; /** * @param {object} handlers * @param {(index: number) => Promise} handlers.capture * @param {(state: SequenceState) => void} handlers.onChange Called on every state change and ~5x/s while running, for the countdown. * @param {(message: string, kind?: 'info' | 'warn' | 'error') => void} handlers.log */ constructor({ capture, onChange, log }) { this.#capture = capture; this.#onChange = onChange; this.#log = log; } get running() { return this.#abort !== null; } /** * @param {object} params * @param {number} params.intervalMs Time between the start of consecutive frames. * @param {number} params.count Number of frames, or 0 for unlimited. * @param {number} params.startDelayMs Delay before the first frame. */ start({ intervalMs, count, startDelayMs }) { if (this.running) return; this.#abort = new AbortController(); this.#update({ ...INITIAL_STATE, phase: startDelayMs > 0 ? 'delay' : 'waiting', total: count, intervalMs, startedAt: Date.now() }); // Keep pushing state while running so the UI countdown stays live. this.#ticker = setInterval(() => this.#onChange(this.state), 200); this.#run({ intervalMs, count, startDelayMs }, this.#abort.signal); } stop() { this.#abort?.abort(); } reset() { if (this.running) return; this.#update(INITIAL_STATE); } /** * @param {{ intervalMs: number, count: number, startDelayMs: number }} params * @param {AbortSignal} signal */ async #run({ intervalMs, count, startDelayMs }, signal) { let firstAt = Date.now() + startDelayMs; let consecutiveErrors = 0; /** @type {Phase} */ let finalPhase = 'done'; try { for (let index = 0; count === 0 || index < count; index++) { let scheduledAt = firstAt + index * intervalMs; this.#update({ phase: index === 0 && startDelayMs > 0 ? 'delay' : 'waiting', nextAt: scheduledAt }); await sleepUntil(scheduledAt, signal); let lateBy = Date.now() - scheduledAt; if (lateBy > 250 && index > 0) { this.#log( `Frame ${index + 1} is ${(lateBy / 1000).toFixed( 1 )}s late - the interval is shorter than capture + download takes.`, 'warn' ); } this.#update({ phase: 'capturing' }); try { await this.#capture(index); consecutiveErrors = 0; this.#update({ taken: this.state.taken + 1 }); } catch (err) { if (signal.aborted) throw new Aborted(); consecutiveErrors++; this.#update({ errors: this.state.errors + 1 }); this.#log( `Frame ${index + 1} failed: ${ /** @type {Error} */ (err).message || err }`, 'error' ); if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { this.#log( `Stopped after ${consecutiveErrors} failures in a row.`, 'error' ); finalPhase = 'failed'; break; } } } } catch (err) { if (err instanceof Aborted) { finalPhase = 'stopped'; } else { finalPhase = 'failed'; this.#log( `Sequence aborted: ${/** @type {Error} */ (err).message || err}`, 'error' ); // Anything that isn't a graceful abort is worth surfacing in the console too. console.error(err); } } finally { clearInterval(this.#ticker); this.#ticker = undefined; this.#abort = null; this.#update({ phase: finalPhase, nextAt: 0, finishedAt: Date.now() }); let summary = `${this.state.taken} frame${ this.state.taken === 1 ? '' : 's' } captured`; if (finalPhase === 'done') this.#log(`✅ Sequence complete - ${summary}.`); if (finalPhase === 'stopped') this.#log(`⏹ Stopped - ${summary}.`); if (finalPhase === 'failed') this.#log(`❌ Sequence failed - ${summary}.`, 'error'); } } /** @param {Partial} patch */ #update(patch) { this.state = { ...this.state, ...patch }; this.#onChange(this.state); } #capture; #onChange; #log; } /** * @param {SequenceState} state * @returns {number} Seconds until the next frame, floored at 0. */ export function secondsUntilNext(state) { if (!state.nextAt) return 0; return Math.max(0, (state.nextAt - Date.now()) / 1000); } /** * Human-readable duration, e.g. "1h 04m 30s". * @param {number} seconds */ export function formatDuration(seconds) { if (!Number.isFinite(seconds)) return '∞'; seconds = Math.max(0, Math.round(seconds)); let hours = Math.floor(seconds / 3600); let minutes = Math.floor((seconds % 3600) / 60); let secs = seconds % 60; if (hours) { return `${hours}h ${String(minutes).padStart(2, '0')}m ${String( secs ).padStart(2, '0')}s`; } if (minutes) return `${minutes}m ${String(secs).padStart(2, '0')}s`; return `${secs}s`; } /** @param {number} timestamp */ export function formatClock(timestamp) { return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }