Some checks failed
CI / build-and-deploy (push) Has been cancelled
Fullscreen: a button on the preview pane fullscreens the live view, carrying the countdown over as an overlay - fullscreen hides the entire control column, so without it you're left staring at a picture with no idea when the next frame lands. Wake lock: held from app start until you switch it off, rather than only for the duration of a sequence. You're usually mid-setup when the display would otherwise sleep. PWA: manifest, icons and a service worker, so it installs to a standalone window and runs with no network. That matters more here than for most web apps - the camera is on a USB cable, so this is fully functional in a field with no signal. The worker precaches the pinned unpkg dependencies at install rather than leaving them to the runtime cache. On a first visit the worker isn't controlling the page yet, so the app's own imports go straight to the network and never reach the fetch handler; without the precache it looked cached but died offline on its imports. Our own files are network-first so a redeploy always wins, and the version-pinned CDN files are cache-first. Connecting: no connect button. The app reaches for the camera on load, retries every 1.5s, and listens for USB connect events, so switching the camera on mid-wait attaches it with no click or reload. The exception is a browser that has never been granted access to the device: Chrome will not open its WebUSB chooser outside a user gesture, so that case still shows a one-off prompt. After that the permission is remembered and getDevices() finds the camera with no interaction. tsconfig excludes sw.js, which runs in ServiceWorkerGlobalScope and reports every worker global as undefined when checked against the DOM lib. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
623 lines
19 KiB
JavaScript
623 lines
19 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 }
|
|
);
|
|
|
|
// No connect button: just keep reaching for the camera from the moment
|
|
// the page loads.
|
|
this.autoConnect();
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
};
|
|
|
|
grantAccess = async () => {
|
|
try {
|
|
// @ts-ignore
|
|
await Camera.showPicker();
|
|
} catch (err) {
|
|
// Dismissing the chooser is not an error worth shouting about.
|
|
return;
|
|
}
|
|
await this.connectOnce();
|
|
};
|
|
|
|
/**
|
|
* Keep trying to attach to a camera the browser has already been given
|
|
* permission for, so landing on the page is all it takes.
|
|
*
|
|
* WebUSB will only show its device chooser from a user gesture, so the one
|
|
* case that genuinely can't be automated is the very first time - after that
|
|
* the permission is remembered and `getDevices()` sees the camera with no
|
|
* interaction at all.
|
|
*/
|
|
async autoConnect() {
|
|
// Reconnect the moment a known camera is plugged in or switched on.
|
|
// @ts-ignore
|
|
navigator.usb?.addEventListener?.('connect', () => this.connectOnce());
|
|
|
|
while (!this.camera) {
|
|
let permitted = await this.permittedDevices();
|
|
if (permitted.length) {
|
|
this.setState({ view: 'searching' });
|
|
if (await this.connectOnce()) return;
|
|
} else {
|
|
this.setState({ view: 'permission' });
|
|
}
|
|
await wait(1500);
|
|
}
|
|
}
|
|
|
|
async permittedDevices() {
|
|
try {
|
|
// @ts-ignore
|
|
return await navigator.usb.getDevices();
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** @returns {Promise<boolean>} whether we're now connected. */
|
|
async connectOnce() {
|
|
if (this.camera) return true;
|
|
/** @type {Camera} */
|
|
let camera;
|
|
try {
|
|
camera = new Camera();
|
|
await camera.connect();
|
|
} catch (err) {
|
|
// Expected while the camera is off, asleep, or claimed by another app.
|
|
console.debug('Camera not ready yet:', err);
|
|
return false;
|
|
}
|
|
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'}.`
|
|
);
|
|
return true;
|
|
}
|
|
|
|
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 'searching':
|
|
return h(
|
|
'div',
|
|
{ class: 'center' },
|
|
h('h1', null, '⏱ Intervalometer'),
|
|
h('p', { class: 'searching' }, h('span', { class: 'spinner' }), 'Looking for your camera…'),
|
|
h(
|
|
'p',
|
|
{ class: 'fine-print' },
|
|
'Switch the camera on and set the mode dial off Auto. It will connect on its own — no need to reload. On macOS, quit Photos and Image Capture if they grabbed it first.'
|
|
)
|
|
);
|
|
|
|
case 'permission':
|
|
return h(
|
|
'div',
|
|
{ class: 'center' },
|
|
h('h1', null, '⏱ Intervalometer'),
|
|
h(
|
|
'p',
|
|
null,
|
|
'One-off setup: the browser needs your permission to talk to the camera.'
|
|
),
|
|
h(
|
|
'button',
|
|
{ type: 'button', class: 'primary big', onclick: this.grantAccess },
|
|
'🔍 Choose camera'
|
|
),
|
|
h(
|
|
'p',
|
|
{ class: 'fine-print' },
|
|
'Only needed once — after this it connects by itself whenever you open the page. Chrome will not open its device chooser without a click, which is the one part of this that cannot be automatic. 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
|
|
})
|
|
),
|
|
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);
|