/* * 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 } from 'preact'; import { secondsUntilNext, formatDuration, formatClock } from './intervalometer.js'; import { statusReadout, configValue, shutterSpeedSeconds } 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 }} props */ function SequenceStatus({ 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.'; } let progress = total ? Math.min(1, taken / total) : 0; return h( 'div', { class: `sequence-status phase-${phase}` }, h('div', { class: 'headline' }, headline), h('div', { class: 'detail' }, detail), 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 ); } /** * 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 }[] * }} props */ export function Home({ prefs, setPref, state, running, onStart, onStop, onSingleShot, singleShotStatus, config, hasFolder, canCapture, log }) { let warnings = running ? [] : sequenceWarnings(prefs, config, hasFolder); let plannedSeconds = prefs.unlimited ? Infinity : prefs.startDelaySeconds + prefs.shots * prefs.intervalSeconds; let readout = statusReadout(config); /** @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 }), 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) ) ), 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 ); }