Turn the demo app into a Canon 450D intervalometer
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Some checks failed
CI / build-and-deploy (push) Has been cancelled
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
This commit is contained in:
267
examples/preact/intervalometer.js
Normal file
267
examples/preact/intervalometer.js
Normal file
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* 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'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user