/* * 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} 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);