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
398 lines
11 KiB
JavaScript
398 lines
11 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 } 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,
|
|
* 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
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<Prefs>) => 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
|
|
* }} props
|
|
*/
|
|
export function Home({
|
|
prefs,
|
|
setPref,
|
|
state,
|
|
running,
|
|
onStart,
|
|
onStop,
|
|
onSingleShot,
|
|
singleShotStatus,
|
|
config,
|
|
hasFolder,
|
|
canCapture,
|
|
log,
|
|
voiceSupported,
|
|
onToggleVoice
|
|
}) {
|
|
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<Prefs>} 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)
|
|
)
|
|
),
|
|
|
|
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
|
|
);
|
|
}
|