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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user