Files
web-dslr/examples/preact/index.js
Jon 539e3cfb34
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Add a spoken countdown between frames
A 🔊 toggle on the home screen counts you into each frame - "five, four,
three, two, one" - plus start and finish announcements. Useful when you're in
front of the camera rather than at the laptop. Uses the Web Speech API, so
it's local and needs no configuration.

The narrator runs off the intervalometer's state updates rather than a timer
of its own, so it can't drift away from what the sequence is doing; each
second is spoken at most once even though state arrives ~5x/sec.

The count is capped at one second under the interval, so a 3s interval says
"two, one" instead of talking over the previous frame, and a 1s interval stays
silent. A start delay isn't clamped, since that gap is whatever you set.

Countdown length, voice choice and frame-number announcements live in the
settings drawer; only the toggle is on the home screen.

Also:
- The toggle sits in the status card rather than the button row: three buttons
  don't fit a 400px column, and it wrapped onto its own flex line where it
  stretched to the wrong height.
- tsconfig excludes dist/, which build-dist.sh fills with copies of these same
  files and which otherwise gets type-checked twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-01 21:02:00 +01:00

507 lines
15 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
*/
import { h, hydrate, Component, Fragment } from 'preact';
import { Camera, rethrowIfCritical } from 'web-gphoto2';
import { Preview } from './preview.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';
import { Narrator, supportsSpeech } from './voice.js';
export const isDebug = new URLSearchParams(location.search).has('debug');
if (isDebug) {
// @ts-ignore
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();
narrator = new Narrator();
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.
/** @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;
this.syncNarrator(this.state.prefs);
addEventListener('error', ({ 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(
'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 {import('./settings.js').Prefs} prefs */
syncNarrator(prefs) {
Object.assign(this.narrator, {
enabled: prefs.voice,
countFrom: prefs.voiceCountFrom,
voiceURI: prefs.voiceURI,
announceFrames: prefs.voiceAnnounceFrames
});
}
/** @param {Partial<import('./settings.js').Prefs>} patch */
setPref = patch => {
this.setState(({ prefs }) => {
let next = { ...prefs, ...patch };
savePrefs(next);
this.saver.mode = next.saveMode;
this.syncNarrator(next);
if (!next.keepAwake) this.wakeLock.release();
return { prefs: next };
});
};
toggleVoice = () => {
let on = !this.state.prefs.voice;
// Applied straight away rather than waiting for the state update, so a
// countdown can't slip out between the click and the commit.
this.narrator.enabled = on;
this.setPref({ voice: on });
// Speaking here also gets the user gesture Chrome wants before it will
// let a page talk at all.
if (on) this.narrator.say('Voice countdown on');
else this.narrator.cancel();
};
testVoice = () => {
let wasEnabled = this.narrator.enabled;
this.narrator.enabled = true;
this.narrator.say('Three. Two. One.', { interrupt: true });
this.narrator.enabled = wasEnabled;
};
selectDevice = async () => {
// @ts-ignore
await Camera.showPicker();
this.setState({ view: 'status', message: '⌛ Connecting…' });
await this.tryToConnectToCamera();
};
async tryToConnectToCamera() {
/** @type {Camera} */
let camera;
try {
camera = new Camera();
await camera.connect();
} catch (e) {
console.warn(e);
this.setState({ view: 'picker' });
return;
}
this.camera = camera;
let supportedOps = await camera.getSupportedOps();
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);
}
}
/**
* 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) => {
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.narrator.update(seq);
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.narrator.reset();
this.narrator.say('Starting');
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.view) {
case 'picker':
return h(
'div',
{ class: 'center' },
h('h1', null, '⏱ Intervalometer'),
h('p', null, 'Connect your Canon 450D over USB and switch it on.'),
h(
'button',
{ type: 'button', class: 'primary big', onclick: this.selectDevice },
'🔍 Select camera'
),
h(
'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(
'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,
voiceSupported: supportsSpeech,
onToggleVoice: this.toggleVoice
})
),
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,
voices: this.narrator.voices,
testVoice: this.testVoice
})
);
}
default:
return h('div', { class: 'center' }, state.message);
}
}
}
hydrate(h(App, null), document.body);