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

@@ -16,11 +16,14 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
import { h, hydrate, Component } from 'preact';
import { CaptureButton } from './capture-button.js';
import { h, hydrate, Component, Fragment } from 'preact';
import { Camera, rethrowIfCritical } from 'web-gphoto2';
import { Preview } from './preview.js';
import { Widget } from './widget.js';
import { Home } from './home.js';
import { SettingsDrawer, loadPrefs, savePrefs } from './settings.js';
import { Intervalometer, formatDuration } from './intervalometer.js';
import { FrameSaver, WakeLock } from './storage.js';
import { detectBulbSupport, configValue } from './config-utils.js';
export const isDebug = new URLSearchParams(location.search).has('debug');
@@ -29,39 +32,97 @@ if (isDebug) {
await import('preact/debug');
}
/** @param {number} ms */
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
/** @extends Component<{}, AppState> */
class App extends Component {
/** @type {Camera | undefined} */
camera;
saver = new FrameSaver();
wakeLock = new WakeLock();
intervalometer = new Intervalometer({
capture: index => this.captureFrame(index),
onChange: seq => this.handleSequenceChange(seq),
log: (message, kind) => this.log(message, kind)
});
// Make sure that first render hydrates the existing HTML smoothly.
state = { type: 'Status', message: '⌛ Loading...' };
/** @type {AppState} */
state = {
view: 'status',
message: '⌛ Loading…',
prefs: loadPrefs(),
seq: this.intervalometer.state,
log: [],
settingsOpen: false,
singleShotStatus: 'idle'
};
#logId = 0;
#configWatchToken = 0;
#wasRunning = false;
componentDidMount() {
this.saver.mode = this.state.prefs.saveMode;
addEventListener('error', ({ message }) =>
this.setState({
type: 'Status',
message: `${message}`
})
this.log(`Uncaught error: ${message}`, 'error')
);
// Closing the tab halfway through a long sequence is an expensive mistake.
addEventListener('beforeunload', e => {
if (this.intervalometer.running) {
e.preventDefault();
e.returnValue = '';
}
});
addEventListener(
'beforeunload',
'pagehide',
() => {
if (!this.camera) return;
this.intervalometer.stop();
this.camera.disconnect();
this.camera = undefined;
},
{ once: true }
);
// Try to connect to camera at startup.
// If none is found among saved connections, it will fallback to a picker.
this.tryToConnectToCamera();
}
/**
* @param {string} message
* @param {'info' | 'warn' | 'error'} [kind]
*/
log(message, kind = 'info') {
if (kind === 'error') console.error(message);
this.setState(({ log }) => ({
log: [
{ id: ++this.#logId, message, kind, at: Date.now() },
...log
].slice(0, 8)
}));
}
/** @param {Partial<import('./settings.js').Prefs>} patch */
setPref = patch => {
this.setState(({ prefs }) => {
let next = { ...prefs, ...patch };
savePrefs(next);
this.saver.mode = next.saveMode;
if (!next.keepAwake) this.wakeLock.release();
return { prefs: next };
});
};
selectDevice = async () => {
// @ts-ignore
await Camera.showPicker();
this.setState({ type: 'Status', message: '⌛ Connecting...' });
this.setState({ view: 'status', message: '⌛ Connecting' });
await this.tryToConnectToCamera();
};
@@ -73,132 +134,331 @@ class App extends Component {
await camera.connect();
} catch (e) {
console.warn(e);
this.setState({ type: 'CameraPicker' });
this.setState({ view: 'picker' });
return;
}
this.camera = camera;
let supportedOps = await camera.getSupportedOps();
let capturePreview;
if (supportedOps.capturePreview) {
capturePreview = () => camera.capturePreviewAsBlob();
}
let triggerCapture;
if (supportedOps.captureImage) {
triggerCapture = () => camera.captureImageAsFile();
}
// We should reach this only once.
while (this.camera) {
try {
let config = await this.camera.getConfig();
if (!isDebug) {
delete config.children.actions;
delete config.children.other;
}
this.setState({
type: 'Config',
config,
capturePreview,
triggerCapture
});
} catch (err) {
rethrowIfCritical(err);
console.error('Could not refresh config:', err);
}
while (true) {
await new Promise(resolve =>
requestIdleCallback(resolve, { timeout: 500 })
);
try {
let hadEvents = await this.camera.consumeEvents();
if (hadEvents) {
break;
}
} catch (err) {
rethrowIfCritical(err);
console.error('Could not consume events:', err);
}
}
this.setState({ view: 'ready', supportedOps });
await this.refreshConfig();
this.log(
`Connected to ${configValue(this.state.config, 'cameramodel') ||
configValue(this.state.config, 'model') ||
'camera'}.`
);
}
async refreshConfig() {
if (!this.camera) return;
try {
this.setState({ config: await this.camera.getConfig() });
} catch (err) {
rethrowIfCritical(err);
console.error('Could not refresh config:', err);
}
}
/**
* Set the specified config value.
* Poll the camera for changes made on the body itself (dial turns, etc).
*
* Unlike the original demo this only runs while the settings drawer is open:
* an unattended timelapse doesn't benefit from a constant stream of config
* reads, and every one of them is a USB round-trip competing with captures.
*/
async watchConfig() {
let token = ++this.#configWatchToken;
while (this.camera && token === this.#configWatchToken) {
await new Promise(resolve =>
requestIdleCallback(resolve, { timeout: 1000 })
);
if (!this.state.settingsOpen) break;
if (this.intervalometer.running) continue;
try {
if (await this.camera.consumeEvents()) {
await this.refreshConfig();
}
} catch (err) {
rethrowIfCritical(err);
console.error('Could not consume events:', err);
}
}
}
toggleSettings = async () => {
let settingsOpen = !this.state.settingsOpen;
this.setState({ settingsOpen });
if (settingsOpen) {
await this.refreshConfig();
this.watchConfig();
} else {
this.#configWatchToken++;
}
};
/**
* Set the specified config value, then re-read the tree.
*
* Setting one value often changes others (and the camera may round or reject
* what you asked for), so don't wait for the event loop to notice - it only
* runs while this drawer is open, and not at all mid-sequence.
*
* @param {string} name
* @param {*} value
*/
setValue = async (name, value) => this.camera?.setConfigValue(name, value);
setValue = async (name, value) => {
if (!this.camera) return;
await this.camera.setConfigValue(name, value);
await this.refreshConfig();
};
get bulbSupport() {
return detectBulbSupport(this.state.config);
}
/**
* One frame of the sequence.
* @param {number} index
*/
async captureFrame(index) {
if (!this.camera) throw new Error('Camera is not connected');
if (this.state.prefs.bulbEnabled && this.bulbSupport) {
return this.bulbExposure();
}
let file = await this.camera.captureImageAsFile();
await this.saver.save(file, index);
}
/**
* Hold the shutter open for the configured duration.
*
* The resulting frame is written by the camera to its own storage - the WASM
* API only hands back files produced by an explicit `captureImageAsFile`, so
* there's nothing for us to download here.
*/
async bulbExposure() {
let support = this.bulbSupport;
if (!support) throw new Error('Bulb is not supported by this camera');
let { bulbSeconds } = this.state.prefs;
let open = () =>
support.kind === 'bulb'
? this.setValue('bulb', true)
: this.setValue('eosremoterelease', support.press);
let close = () =>
support.kind === 'bulb'
? this.setValue('bulb', false)
: this.setValue('eosremoterelease', support.release);
await open();
try {
await wait(bulbSeconds * 1000);
} finally {
await close();
}
}
/** @param {import('./intervalometer.js').SequenceState} seq */
handleSequenceChange(seq) {
let running = this.intervalometer.running;
this.setState({ seq });
if (this.#wasRunning && !running) {
this.wakeLock.release();
this.refreshConfig();
}
this.#wasRunning = running;
}
chooseFolder = async () => {
try {
let name = await this.saver.chooseFolder();
this.log(`Saving frames to “${name}”.`);
this.forceUpdate();
} catch (err) {
if (/** @type {Error} */ (err).name !== 'AbortError') {
this.log(`Could not open that folder: ${err}`, 'error');
}
}
};
startSequence = async () => {
let { prefs } = this.state;
if (prefs.saveMode === 'folder') {
if (!this.saver.hasFolder) {
await this.chooseFolder();
if (!this.saver.hasFolder) return;
}
if (!(await this.saver.ensureWritable())) {
this.log('Write permission for the output folder was denied.', 'error');
return;
}
}
if (prefs.keepAwake) await this.wakeLock.acquire();
let count = prefs.unlimited ? 0 : prefs.shots;
this.log(
`${count || '∞'} frames, one every ${formatDuration(
prefs.intervalSeconds
)}${
prefs.startDelaySeconds
? `, starting in ${formatDuration(prefs.startDelaySeconds)}`
: ''
}.`
);
this.intervalometer.start({
intervalMs: prefs.intervalSeconds * 1000,
count,
startDelayMs: prefs.startDelaySeconds * 1000
});
};
stopSequence = () => {
this.intervalometer.stop();
};
singleShot = async () => {
if (!this.camera) return;
this.setState({ singleShotStatus: 'busy' });
try {
let file = await this.camera.captureImageAsFile();
let saved = await this.saver.save(file);
this.log(
saved ? `📷 Saved ${saved}.` : `📷 Captured ${file.name} (not saved).`
);
} catch (err) {
rethrowIfCritical(err);
this.log(`Capture failed: ${/** @type {Error} */ (err).message}`, 'error');
} finally {
this.setState({ singleShotStatus: 'idle' });
this.refreshConfig();
}
};
renderHeader() {
let model =
configValue(this.state.config, 'cameramodel') ||
configValue(this.state.config, 'model');
return h(
'header',
{ id: 'app-header' },
h(
'div',
{ class: 'brand' },
h('span', { class: 'logo' }, '⏱'),
h(
'div',
null,
h('h1', null, 'Intervalometer'),
h('small', null, model ? String(model) : 'DSLR over WebUSB')
)
),
h(
'button',
{
type: 'button',
class: 'icon-button',
onclick: this.toggleSettings,
title: 'Settings'
},
'⚙'
)
);
}
render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) {
switch (state.type) {
case 'CameraPicker':
switch (state.view) {
case 'picker':
return h(
'div',
{
class: 'center'
},
h('input', {
type: 'button',
onclick: this.selectDevice,
value: '🔍 Select camera'
}),
h(
'p',
null,
"Don't know how you got here? Check out the ",
h(
'a',
{ href: 'https://web.dev/porting-libusb-to-webusb/' },
'blog post'
),
' or the ',
h(
'a',
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
'repo'
),
'!'
)
);
case 'Status':
return h('div', { class: 'center' }, state.message);
case 'Config':
return h(
'div',
{ class: 'pure-g' },
{ class: 'center' },
h('h1', null, '⏱ Intervalometer'),
h('p', null, 'Connect your Canon 450D over USB and switch it on.'),
h(
'div',
{ class: 'pure-u-2-3' },
h(Preview, {
getPreview: state.capturePreview
})
'button',
{ type: 'button', class: 'primary big', onclick: this.selectDevice },
'🔍 Select camera'
),
h(
'div',
{ id: 'config', class: 'pure-u-1-3' },
'p',
{ class: 'fine-print' },
'Requires Chrome with WebUSB. On Linux you may need a udev rule, and on macOS you may need to quit Photos/Image Capture first. Built on ',
h(
'form',
{ class: 'pure-form pure-form-aligned' },
h(
'fieldset',
null,
state.triggerCapture
? h(CaptureButton, { getFile: state.triggerCapture })
: undefined,
' ',
h(
'a',
{
class: 'pure-button',
href: 'https://github.com/GoogleChromeLabs/web-gphoto2',
target: '_blank'
},
'⭐ Star on Github'
)
),
h(Widget, { config: state.config, setValue: this.setValue })
)
'a',
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
'web-gphoto2'
),
'.'
)
);
case 'ready': {
let running = this.intervalometer.running;
let previewSupported = state.supportedOps?.capturePreview;
let showPreview = state.prefs.livePreview && previewSupported;
return h(
Fragment,
null,
this.renderHeader(),
h(
'main',
null,
h(
'div',
{ id: 'viewer' },
showPreview
? h(Preview, {
getPreview: () => this.camera.capturePreviewAsBlob(),
// Between frames the feed stays up; it only steps aside for
// the shutter itself, which the camera needs it to anyway.
paused:
running &&
(state.prefs.liveViewBetweenFrames
? state.seq.phase === 'capturing'
: true),
pausedMessage: state.prefs.liveViewBetweenFrames
? '📸 Taking the shot…'
: 'Live view paused for the whole sequence'
})
: h(
'div',
{ class: 'center muted' },
previewSupported
? 'Live view is turned off in Settings.'
: 'This camera does not support live preview.'
)
),
h(Home, {
prefs: state.prefs,
setPref: this.setPref,
state: state.seq,
running,
onStart: this.startSequence,
onStop: this.stopSequence,
onSingleShot: this.singleShot,
singleShotStatus: state.singleShotStatus,
config: state.config,
hasFolder: this.saver.hasFolder,
canCapture: !!state.supportedOps?.captureImage,
log: state.log
})
),
h(SettingsDrawer, {
open: state.settingsOpen,
onClose: this.toggleSettings,
prefs: state.prefs,
setPref: this.setPref,
config: state.config,
setValue: this.setValue,
folderName: this.saver.folderName,
chooseFolder: this.chooseFolder,
bulbSupport: this.bulbSupport,
locked: running
})
);
}
default:
return h('div', { class: 'center' }, state.message);
}
}
}