Files
web-dslr/examples/preact/index.js
Jon c3cf5ddfbf
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Split settings into tabs; exposure and focus on the home screen
Home screen gains shutter, aperture and ISO as dropdowns plus a focus stepper
(three nudge sizes each way, and AF) where the body drives focus over PTP.
Those three drop out of the read-only strip rather than being shown twice.

Everything is built from what the camera actually reports, so a body without
one of these controls doesn't get it rather than showing something that
silently fails. The 450D marks shutter and aperture readonly unless the mode
dial is somewhere they apply, so the dropdowns disable themselves and say why.
Focus uses the EOS manualfocusdrive steps and autofocusdrive, and warns when
live view is off, which Canon bodies generally require for focus commands.

The settings drawer is now tabbed: app settings stay on the first tab, and
each config section the camera reports gets its own. Empty sections are
dropped, and a selected section that disappears falls back to the first tab.
Tabs wrap rather than scroll - a scrolled-off tab is an undiscoverable one.

Reverts the automatic camera search added in the previous commit, restoring
the Select camera button. Auto-connect could park with no way to reach the
device chooser: WebUSB permissions are per-origin, so a grant on localhost
doesn't carry to the deployed copy, and a camera that's asleep or held by
another app never arrives.

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

565 lines
17 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, createRef } from 'preact';
import { Camera, rethrowIfCritical } from 'web-gphoto2';
import { Preview } from './preview.js';
import { Home, sequenceSummary } 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;
viewerRef = createRef();
componentDidMount() {
this.saver.mode = this.state.prefs.saveMode;
this.syncNarrator(this.state.prefs);
// Held for as long as the app is open, not just while a sequence runs -
// you're usually mid-setup when the display would otherwise sleep.
if (this.state.prefs.keepAwake) this.wakeLock.acquire();
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
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 the camera once at startup; if it isn't among the connections the
// browser already knows about, fall back to the 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.acquire();
else 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) {
// The wake lock stays - it's app-wide now, not sequence-scoped.
this.refreshConfig();
}
this.#wasRunning = running;
}
handleFullscreenChange = () => {
this.setState({ fullscreen: !!document.fullscreenElement });
};
toggleFullscreen = async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await this.viewerRef.current?.requestFullscreen();
}
} catch (err) {
this.log(`Could not toggle fullscreen: ${err}`, 'warn');
}
};
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 macOS, quit Photos and Image Capture if they grabbed the camera first; on Linux you may need a udev rule. 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',
ref: this.viewerRef,
class: state.fullscreen ? 'is-fullscreen' : ''
},
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(
'button',
{
type: 'button',
class: 'viewer-button',
onclick: this.toggleFullscreen,
title: state.fullscreen
? 'Exit fullscreen (Esc)'
: 'Fullscreen preview'
},
state.fullscreen ? '✕' : '⛶'
),
// Fullscreen hides the whole control column, so carry the
// countdown across rather than leaving you staring at a picture.
state.fullscreen
? h(
'div',
{ class: 'viewer-status' },
h(
'span',
{ class: 'viewer-status-headline' },
sequenceSummary(state.seq, state.prefs).headline
),
h(
'span',
{ class: 'viewer-status-detail' },
sequenceSummary(state.seq, state.prefs).detail
)
)
: undefined
),
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,
setValue: this.setValue
})
),
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);