Turn the demo app into a Canon 450D intervalometer
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:
Jon
2026-08-01 18:47:04 +01:00
parent ec3f4462b1
commit 1af4b215b2
15 changed files with 2405 additions and 208 deletions

View File

@@ -27,21 +27,48 @@ const Stats = isDebug
)
: null;
/** @extends Component<{ getPreview?: () => Promise<Blob> }, { error?: string }> */
/** @param {number} ms */
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
/**
* How long to let the camera settle before asking for live view again after a
* pause. A still capture drops the EOS out of live view entirely, and asking
* too soon just earns a "device busy".
*/
const RESUME_DELAY_MS = 500;
/**
* @extends Component<{
* getPreview?: () => Promise<Blob>,
* paused?: boolean,
* pausedMessage?: string
* }, { restoring?: boolean }>
*/
export class Preview extends Component {
canvasHolderRef = createRef();
canvasRef = createRef();
/** @type {ResizeObserver} */
resizeObserver;
stats = isDebug ? new Stats() : null;
state = { restoring: false };
render(
/** @type {Preview['props']} */ props,
/** @type {Preview['state']} */ state
) {
let overlay = props.paused
? props.pausedMessage || '⏸ Live view paused'
: state.restoring
? '⌛ Restoring live view…'
: undefined;
render() {
return h(
'div',
{ class: 'center-parent', ref: this.canvasHolderRef },
!this.props.getPreview
!props.getPreview
? h('div', { class: 'center' }, `Preview is unsupported`)
: h('canvas', { class: 'center', ref: this.canvasRef })
: h('canvas', { class: 'center', ref: this.canvasRef }),
overlay ? h('div', { class: 'preview-overlay' }, overlay) : undefined
);
}
@@ -86,7 +113,21 @@ export class Preview extends Component {
// I have no idea why, but if we connect too soon, it just hangs...
await new Promise(resolve => setTimeout(resolve, 1500));
let failures = 0;
let resuming = false;
while (this.canvasRef.current) {
// Paused while the shutter actually fires - live view and capture share
// one USB link, and the camera drops out of live view to take the shot.
if (this.props.paused) {
resuming = true;
await sleep(200);
continue;
}
if (resuming) {
resuming = false;
await sleep(RESUME_DELAY_MS);
}
try {
let blob = await this.props.getPreview();
@@ -107,9 +148,19 @@ export class Preview extends Component {
}
await new Promise(resolve => requestAnimationFrame(resolve));
canvasCtx.transferFromImageBitmap(img);
if (failures) {
failures = 0;
this.setState({ restoring: false });
}
} catch (err) {
rethrowIfCritical(err);
console.error('Could not refresh preview:', err);
// Right after a capture the camera reports busy for a beat while the
// driver spins live view back up, so back off instead of hammering it -
// retrying flat out here is what keeps the feed down.
if (!failures) console.warn('Could not refresh preview:', err);
failures++;
if (failures === 3) this.setState({ restoring: true });
await sleep(Math.min(1500, 150 * failures));
}
this.stats?.update();
}