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

352
examples/preact/home.js Normal file
View File

@@ -0,0 +1,352 @@
/*
* 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
*/
import { h } from 'preact';
import {
secondsUntilNext,
formatDuration,
formatClock
} from './intervalometer.js';
import {
statusReadout,
configValue,
shutterSpeedSeconds
} from './config-utils.js';
/** @typedef {import('./settings.js').Prefs} Prefs */
/** @typedef {import('./intervalometer.js').SequenceState} SequenceState */
/**
* Things worth knowing before you walk away from a running camera for two hours.
*
* @param {Prefs} prefs
* @param {import('web-gphoto2').Config | undefined} config
* @param {boolean} hasFolder
*/
function sequenceWarnings(prefs, config, hasFolder) {
let warnings = [];
let format = configValue(config, 'imageformat');
let isRaw = typeof format === 'string' && /raw/i.test(format);
// Rough per-frame cost on top of the exposure itself: mirror, card write and
// the USB 2.0 transfer. A 450D clears a JPEG in about 2s and a RAW in about 6s.
let overhead = isRaw ? 6 : 2.5;
let exposure = prefs.bulbEnabled
? prefs.bulbSeconds
: shutterSpeedSeconds(configValue(config, 'shutterspeed'));
if (exposure !== undefined && exposure >= prefs.intervalSeconds) {
warnings.push(
`The exposure (${formatDuration(exposure)}) is longer than the ${
prefs.intervalSeconds
}s interval — every frame will run late.`
);
} else if (
exposure !== undefined &&
prefs.intervalSeconds < exposure + overhead
) {
warnings.push(
`A ${prefs.intervalSeconds}s interval is tight: ${
isRaw ? String(format) : 'each frame'
} needs roughly ${formatDuration(
exposure + overhead
)} to shoot and transfer. Expect late frames.`
);
}
if (prefs.saveMode === 'folder' && !hasFolder) {
warnings.push('No output folder chosen yet — pick one in Settings.');
}
if (prefs.saveMode === 'download' && !prefs.unlimited && prefs.shots > 20) {
warnings.push(
`${prefs.shots} separate downloads will be slow and noisy. Saving to a folder is much better for a run this long.`
);
}
if (prefs.bulbEnabled) {
warnings.push(
'Bulb mode is on: frames stay on the camera card and will not be saved by the browser.'
);
}
return warnings;
}
/**
* @param {{ state: SequenceState, prefs: Prefs }} props
*/
function SequenceStatus({ state, prefs }) {
let { phase, taken, total } = state;
let headline;
let detail;
switch (phase) {
case 'delay':
headline = `Starting in ${formatDuration(secondsUntilNext(state))}`;
detail = 'Waiting out the start delay.';
break;
case 'waiting':
headline = `Next frame in ${formatDuration(secondsUntilNext(state))}`;
detail = `${taken} of ${total || '∞'} captured`;
break;
case 'capturing':
headline = prefs.bulbEnabled
? `⏱ Bulb exposure — ${prefs.bulbSeconds}s`
: '📸 Capturing…';
detail = `${taken} of ${total || '∞'} captured`;
break;
case 'done':
headline = '✅ Sequence complete';
detail = `${taken} frame${taken === 1 ? '' : 's'} in ${formatDuration(
(state.finishedAt - state.startedAt) / 1000
)}`;
break;
case 'stopped':
headline = '⏹ Stopped';
detail = `${taken} frame${taken === 1 ? '' : 's'} captured`;
break;
case 'failed':
headline = '❌ Sequence failed';
detail = `${taken} captured, ${state.errors} failed`;
break;
default:
headline = 'Ready';
detail = 'Set an interval and press Start.';
}
let progress = total ? Math.min(1, taken / total) : 0;
return h(
'div',
{ class: `sequence-status phase-${phase}` },
h('div', { class: 'headline' }, headline),
h('div', { class: 'detail' }, detail),
total
? h(
'div',
{ class: 'progress' },
h('div', { class: 'bar', style: `width: ${progress * 100}%` })
)
: undefined,
state.errors > 0 && phase !== 'failed'
? h('div', { class: 'detail warn-text' }, `${state.errors} frame(s) failed`)
: undefined
);
}
/**
* The whole home-screen control column: interval timing, start/stop, progress,
* and a running log. Camera settings deliberately live in the drawer instead.
*
* @param {{
* prefs: Prefs,
* setPref: (patch: Partial<Prefs>) => void,
* state: SequenceState,
* running: boolean,
* onStart: () => void,
* onStop: () => void,
* onSingleShot: () => void,
* singleShotStatus: string,
* config: import('web-gphoto2').Config | undefined,
* hasFolder: boolean,
* canCapture: boolean,
* log: { id: number, message: string, kind: string, at: number }[]
* }} props
*/
export function Home({
prefs,
setPref,
state,
running,
onStart,
onStop,
onSingleShot,
singleShotStatus,
config,
hasFolder,
canCapture,
log
}) {
let warnings = running ? [] : sequenceWarnings(prefs, config, hasFolder);
let plannedSeconds = prefs.unlimited
? Infinity
: prefs.startDelaySeconds + prefs.shots * prefs.intervalSeconds;
let readout = statusReadout(config);
/** @param {(value: number) => Partial<Prefs>} toPatch */
let numberHandler = toPatch => e => {
let value = /** @type {HTMLInputElement} */ (e.currentTarget).valueAsNumber;
if (Number.isFinite(value)) setPref(toPatch(value));
};
return h(
'div',
{ id: 'home' },
h(SequenceStatus, { state, prefs }),
h(
'div',
{ class: 'card' },
h(
'div',
{ class: 'field-grid' },
h(
'label',
{ class: 'field' },
h('span', null, 'Interval'),
h(
'div',
{ class: 'input-with-unit' },
h('input', {
type: 'number',
min: '0.5',
step: '0.5',
value: prefs.intervalSeconds,
disabled: running,
onInput: numberHandler(v => ({
intervalSeconds: Math.max(0.5, v)
}))
}),
h('span', { class: 'unit' }, 'sec')
)
),
h(
'label',
{ class: 'field' },
h('span', null, 'Frames'),
h(
'div',
{ class: 'input-with-unit' },
h('input', {
type: 'number',
min: '1',
step: '1',
value: prefs.shots,
disabled: running || prefs.unlimited,
onInput: numberHandler(v => ({ shots: Math.max(1, Math.round(v)) }))
}),
h(
'label',
{ class: 'inline-check' },
h('input', {
type: 'checkbox',
checked: prefs.unlimited,
disabled: running,
onChange: e =>
setPref({ unlimited: e.currentTarget.checked })
}),
' ∞'
)
)
),
h(
'label',
{ class: 'field' },
h('span', null, 'Start delay'),
h(
'div',
{ class: 'input-with-unit' },
h('input', {
type: 'number',
min: '0',
step: '1',
value: prefs.startDelaySeconds,
disabled: running,
onInput: numberHandler(v => ({
startDelaySeconds: Math.max(0, Math.round(v))
}))
}),
h('span', { class: 'unit' }, 'sec')
)
)
),
h(
'p',
{ class: 'plan' },
prefs.unlimited
? `Runs until you stop it, one frame every ${prefs.intervalSeconds}s.`
: `${prefs.shots} frames over ${formatDuration(
plannedSeconds
)} — finishing around ${formatClock(
Date.now() + plannedSeconds * 1000
)}.`
),
h(
'div',
{ class: 'actions' },
h(
'button',
{
type: 'button',
class: running ? 'danger big' : 'primary big',
disabled: !canCapture,
onclick: running ? onStop : onStart
},
running ? '⏹ Stop' : '▶ Start sequence'
),
h(
'button',
{
type: 'button',
class: 'secondary',
disabled: !canCapture || running || singleShotStatus !== 'idle',
onclick: onSingleShot
},
singleShotStatus === 'busy' ? '⌛ Capturing…' : '📷 Single shot'
)
),
warnings.map(text =>
h('p', { key: text, class: 'notice warn' }, '⚠ ', text)
)
),
readout.length
? h(
'div',
{ class: 'readout' },
readout.map(({ label, value }) =>
h(
'div',
{ key: label, class: 'readout-item' },
h('span', { class: 'readout-label' }, label),
h('span', { class: 'readout-value' }, String(value))
)
)
)
: undefined,
log.length
? h(
'div',
{ class: 'log' },
log.map(entry =>
h(
'div',
{ key: entry.id, class: `log-entry ${entry.kind}` },
h('span', { class: 'log-time' }, formatClock(entry.at)),
h('span', null, entry.message)
)
)
)
: undefined
);
}