Files
web-dslr/examples/preact/intervalometer.js
Jon 1af4b215b2
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Turn the demo app into a Canon 450D intervalometer
Replaces the gPhoto2 demo UI (live view beside the raw config tree) with a
timelapse intervalometer. The home screen carries only what changes between
runs - interval, frame count, start delay, start/stop, countdown, progress
and a log - while every camera setting moves into a settings drawer.

- intervalometer.js: schedules frames on an absolute grid (start + n *
  interval) so transfer time doesn't accumulate as drift over a long run. An
  overrun logs and fires as soon as the camera is free rather than dropping a
  frame; three consecutive failures abort.
- storage.js: frames stream into a folder via the File System Access API,
  named for capture time (20260801-172713_00001.JPG) so sorting by name is
  sorting by time. Falls back to downloads. Also holds the screen wake lock,
  since background tabs get their timers throttled.
- config-utils.js: config tree lookups, shutter speed parsing, and bulb
  capability detection (bulb toggle or Canon eosremoterelease).
- home.js: sequence controls plus a read-only exposure readout and pre-flight
  warnings when the interval can't fit the exposure and transfer.
- settings.js / index.js: app prefs and the full config tree behind a drawer.
  Config polling now only runs while that drawer is open, leaving the USB link
  to the captures during a sequence.

Live view stays up between frames and steps aside only while the shutter
fires, which is what the EOS driver requires; it recovers afterwards with
backoff instead of hammering a busy camera.

Deployed as an assets-only Cloudflare Worker. The WASM is built with pthreads
and allocates a shared WebAssembly.Memory, so _headers reproduces the COOP/COEP
pair from serve.json - without cross-origin isolation the app fails to start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-01 18:47:04 +01:00

268 lines
8.2 KiB
JavaScript

/*
* 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<typeof setInterval> | undefined} */
#ticker;
/**
* @param {object} handlers
* @param {(index: number) => Promise<void>} 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<SequenceState>} 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'
});
}