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
268 lines
8.2 KiB
JavaScript
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'
|
|
});
|
|
}
|