/* * 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, Component } from 'preact'; import { secondsUntilNext, formatDuration, formatClock } from './intervalometer.js'; import { statusReadout, configValue, shutterSpeedSeconds, exposureControls, detectFocusControls } from './config-utils.js'; /** @typedef {import('./settings.js').Prefs} Prefs */ /** @typedef {import('./intervalometer.js').SequenceState} SequenceState */ /** * Things worth knowing before you walk away from a running camera for two hours. * * @param {Prefs} prefs * @param {import('web-gphoto2').Config | undefined} config * @param {boolean} hasFolder */ function sequenceWarnings(prefs, config, hasFolder) { let warnings = []; let format = configValue(config, 'imageformat'); let isRaw = typeof format === 'string' && /raw/i.test(format); // Rough per-frame cost on top of the exposure itself: mirror, card write and // the USB 2.0 transfer. A 450D clears a JPEG in about 2s and a RAW in about 6s. let overhead = isRaw ? 6 : 2.5; let exposure = prefs.bulbEnabled ? prefs.bulbSeconds : shutterSpeedSeconds(configValue(config, 'shutterspeed')); if (exposure !== undefined && exposure >= prefs.intervalSeconds) { warnings.push( `The exposure (${formatDuration(exposure)}) is longer than the ${ prefs.intervalSeconds }s interval — every frame will run late.` ); } else if ( exposure !== undefined && prefs.intervalSeconds < exposure + overhead ) { warnings.push( `A ${prefs.intervalSeconds}s interval is tight: ${ isRaw ? String(format) : 'each frame' } needs roughly ${formatDuration( exposure + overhead )} to shoot and transfer. Expect late frames.` ); } if (prefs.saveMode === 'folder' && !hasFolder) { warnings.push('No output folder chosen yet — pick one in Settings.'); } if (prefs.saveMode === 'download' && !prefs.unlimited && prefs.shots > 20) { warnings.push( `${prefs.shots} separate downloads will be slow and noisy. Saving to a folder is much better for a run this long.` ); } if (prefs.bulbEnabled) { warnings.push( 'Bulb mode is on: frames stay on the camera card and will not be saved by the browser.' ); } return warnings; } /** * @param {{ * state: SequenceState, * prefs: Prefs, * voiceSupported: boolean, * onToggleVoice: () => void * }} props */ function SequenceStatus({ state, prefs, voiceSupported, onToggleVoice }) { let { phase, taken, total } = state; let { headline, detail } = sequenceSummary(state, prefs); let progress = total ? Math.min(1, taken / total) : 0; return h( 'div', { class: `sequence-status phase-${phase}` }, h( 'div', { class: 'status-head' }, h( 'div', { class: 'status-text' }, h('div', { class: 'headline' }, headline), h('div', { class: 'detail' }, detail) ), // Sits with the countdown it speaks, rather than crowding the button row. voiceSupported ? h( 'button', { type: 'button', class: `voice-toggle ${prefs.voice ? 'on' : ''}`, onclick: onToggleVoice, title: prefs.voice ? 'Voice countdown on — click to mute' : 'Voice countdown off — click to speak the countdown before each frame' }, prefs.voice ? '🔊' : '🔇' ) : undefined ), total ? h( 'div', { class: 'progress' }, h('div', { class: 'bar', style: `width: ${progress * 100}%` }) ) : undefined, state.errors > 0 && phase !== 'failed' ? h('div', { class: 'detail warn-text' }, `${state.errors} frame(s) failed`) : undefined ); } /** * A camera setting as a dropdown, with the round-trip to the body surfaced. * * Each change is a USB round-trip that takes the best part of a second, so the * control locks and shows a spinner rather than pretending it was instant and * then snapping back to the old value. * * @extends Component<{ * node: import('web-gphoto2').Config, * label: string, * setValue: (name: string, value: any) => Promise * }, { pending: boolean }> */ class ConfigSelect extends Component { state = { pending: false }; handleChange = async e => { let value = e.currentTarget.value; this.setState({ pending: true }); try { await this.props.setValue(this.props.node.name, value); } finally { this.setState({ pending: false }); } }; render( /** @type {ConfigSelect['props']} */ { node, label }, /** @type {ConfigSelect['state']} */ { pending } ) { // Callers only pass menu/radio nodes; the union type doesn't know that. let { choices = [], value } = /** @type {any} */ (node); let locked = node.readonly || pending; return h( 'label', { class: 'field' }, h( 'span', null, label, pending ? h('span', { class: 'mini-spinner' }) : undefined ), h( 'select', { value, disabled: locked, title: node.readonly ? `${label} is fixed by the camera in this mode` : undefined, onChange: this.handleChange }, choices.map(choice => h('option', { key: choice, value: choice }, choice)) ) ); } } /** * Shutter / aperture / ISO, straight on the home screen. * * @param {{ * config: import('web-gphoto2').Config | undefined, * setValue: (name: string, value: any) => Promise * }} props */ function ExposureControls({ config, setValue }) { let controls = exposureControls(config); if (!controls.length) return undefined; let allLocked = controls.every(c => c.node.readonly); let mode = configValue(config, 'autoexposuremode'); return h( 'div', { class: 'card' }, h('h3', { class: 'card-title' }, 'Exposure'), h( 'div', { class: 'field-grid tight' }, controls.map(({ name, label, node }) => h(ConfigSelect, { key: name, node, label, setValue }) ) ), allLocked ? h( 'p', { class: 'notice' }, `The camera is driving exposure itself${ mode ? ` in ${mode}` : '' } — switch the mode dial to M to set these from here.` ) : undefined ); } /** * Focus, where the body supports driving it over PTP. * * @param {{ * config: import('web-gphoto2').Config | undefined, * setValue: (name: string, value: any) => Promise, * livePreview: boolean * }} props */ function FocusControls({ config, setValue, livePreview }) { let { manual, autofocus, mode } = detectFocusControls(config); if (!manual && !autofocus) return undefined; // Three nudge sizes per direction, largest on the outside. let step = (choice, glyph, title) => h( 'button', { key: choice, type: 'button', class: 'focus-step', title, onclick: () => setValue(manual.name, choice) }, glyph ); return h( 'div', { class: 'card' }, h( 'h3', { class: 'card-title' }, 'Focus', mode ? h('span', { class: 'card-title-note' }, String(mode.value)) : undefined ), h( 'div', { class: 'focus-row' }, manual ? h( 'div', { class: 'focus-steps' }, h('span', { class: 'focus-end' }, 'Near'), [...manual.near].reverse().map((choice, i) => step(choice, '◀'.repeat(manual.near.length - i), `Focus nearer — ${choice}`) ), manual.far.map((choice, i) => step(choice, '▶'.repeat(i + 1), `Focus further — ${choice}`) ), h('span', { class: 'focus-end' }, 'Far') ) : undefined, autofocus ? h( 'button', { type: 'button', class: 'secondary', title: 'Trigger autofocus', onclick: () => setValue(autofocus, true) }, 'AF' ) : undefined ), manual && !livePreview ? h( 'p', { class: 'notice warn' }, '⚠ Canon bodies generally only accept focus commands with live view running — turn it back on in Settings.' ) : undefined ); } /** * The one-line description of where the sequence is up to. Shared by the status * card and the fullscreen preview overlay. * * @param {SequenceState} state * @param {Prefs} prefs * @returns {{ headline: string, detail: string }} */ export function sequenceSummary(state, prefs) { let { phase, taken, total } = state; let headline; let detail; switch (phase) { case 'delay': headline = `Starting in ${formatDuration(secondsUntilNext(state))}`; detail = 'Waiting out the start delay.'; break; case 'waiting': headline = `Next frame in ${formatDuration(secondsUntilNext(state))}`; detail = `${taken} of ${total || '∞'} captured`; break; case 'capturing': headline = prefs.bulbEnabled ? `⏱ Bulb exposure — ${prefs.bulbSeconds}s` : '📸 Capturing…'; detail = `${taken} of ${total || '∞'} captured`; break; case 'done': headline = '✅ Sequence complete'; detail = `${taken} frame${taken === 1 ? '' : 's'} in ${formatDuration( (state.finishedAt - state.startedAt) / 1000 )}`; break; case 'stopped': headline = '⏹ Stopped'; detail = `${taken} frame${taken === 1 ? '' : 's'} captured`; break; case 'failed': headline = '❌ Sequence failed'; detail = `${taken} captured, ${state.errors} failed`; break; default: headline = 'Ready'; detail = 'Set an interval and press Start.'; } return { headline, detail }; } /** * The whole home-screen control column: interval timing, start/stop, progress, * and a running log. Camera settings deliberately live in the drawer instead. * * @param {{ * prefs: Prefs, * setPref: (patch: Partial) => void, * state: SequenceState, * running: boolean, * onStart: () => void, * onStop: () => void, * onSingleShot: () => void, * singleShotStatus: string, * config: import('web-gphoto2').Config | undefined, * hasFolder: boolean, * canCapture: boolean, * log: { id: number, message: string, kind: string, at: number }[], * voiceSupported: boolean, * onToggleVoice: () => void, * setValue: (name: string, value: any) => Promise * }} props */ export function Home({ prefs, setPref, state, running, onStart, onStop, onSingleShot, singleShotStatus, config, hasFolder, canCapture, log, voiceSupported, onToggleVoice, setValue }) { let warnings = running ? [] : sequenceWarnings(prefs, config, hasFolder); let plannedSeconds = prefs.unlimited ? Infinity : prefs.startDelaySeconds + prefs.shots * prefs.intervalSeconds; // Whatever is now an editable control doesn't need repeating as a readout. let readout = statusReadout( config, exposureControls(config).map(c => c.name) ); /** @param {(value: number) => Partial} toPatch */ let numberHandler = toPatch => e => { let value = /** @type {HTMLInputElement} */ (e.currentTarget).valueAsNumber; if (Number.isFinite(value)) setPref(toPatch(value)); }; return h( 'div', { id: 'home' }, h(SequenceStatus, { state, prefs, voiceSupported, onToggleVoice }), h( 'div', { class: 'card' }, h( 'div', { class: 'field-grid' }, h( 'label', { class: 'field' }, h('span', null, 'Interval'), h( 'div', { class: 'input-with-unit' }, h('input', { type: 'number', min: '0.5', step: '0.5', value: prefs.intervalSeconds, disabled: running, onInput: numberHandler(v => ({ intervalSeconds: Math.max(0.5, v) })) }), h('span', { class: 'unit' }, 'sec') ) ), h( 'label', { class: 'field' }, h('span', null, 'Frames'), h( 'div', { class: 'input-with-unit' }, h('input', { type: 'number', min: '1', step: '1', value: prefs.shots, disabled: running || prefs.unlimited, onInput: numberHandler(v => ({ shots: Math.max(1, Math.round(v)) })) }), h( 'label', { class: 'inline-check' }, h('input', { type: 'checkbox', checked: prefs.unlimited, disabled: running, onChange: e => setPref({ unlimited: e.currentTarget.checked }) }), ' ∞' ) ) ), h( 'label', { class: 'field' }, h('span', null, 'Start delay'), h( 'div', { class: 'input-with-unit' }, h('input', { type: 'number', min: '0', step: '1', value: prefs.startDelaySeconds, disabled: running, onInput: numberHandler(v => ({ startDelaySeconds: Math.max(0, Math.round(v)) })) }), h('span', { class: 'unit' }, 'sec') ) ) ), h( 'p', { class: 'plan' }, prefs.unlimited ? `Runs until you stop it, one frame every ${prefs.intervalSeconds}s.` : `${prefs.shots} frames over ${formatDuration( plannedSeconds )} — finishing around ${formatClock( Date.now() + plannedSeconds * 1000 )}.` ), h( 'div', { class: 'actions' }, h( 'button', { type: 'button', class: running ? 'danger big' : 'primary big', disabled: !canCapture, onclick: running ? onStop : onStart }, running ? '⏹ Stop' : '▶ Start sequence' ), h( 'button', { type: 'button', class: 'secondary', disabled: !canCapture || running || singleShotStatus !== 'idle', onclick: onSingleShot }, singleShotStatus === 'busy' ? '⌛ Capturing…' : '📷 Single shot' ) ), warnings.map(text => h('p', { key: text, class: 'notice warn' }, '⚠ ', text) ) ), h(ExposureControls, { config, setValue }), h(FocusControls, { config, setValue, livePreview: prefs.livePreview }), readout.length ? h( 'div', { class: 'readout' }, readout.map(({ label, value }) => h( 'div', { key: label, class: 'readout-item' }, h('span', { class: 'readout-label' }, label), h('span', { class: 'readout-value' }, String(value)) ) ) ) : undefined, log.length ? h( 'div', { class: 'log' }, log.map(entry => h( 'div', { key: entry.id, class: `log-entry ${entry.kind}` }, h('span', { class: 'log-time' }, formatClock(entry.at)), h('span', null, entry.message) ) ) ) : undefined ); }