Some checks failed
CI / build-and-deploy (push) Has been cancelled
A 🔊 toggle on the home screen counts you into each frame - "five, four, three, two, one" - plus start and finish announcements. Useful when you're in front of the camera rather than at the laptop. Uses the Web Speech API, so it's local and needs no configuration. The narrator runs off the intervalometer's state updates rather than a timer of its own, so it can't drift away from what the sequence is doing; each second is spoken at most once even though state arrives ~5x/sec. The count is capped at one second under the interval, so a 3s interval says "two, one" instead of talking over the previous frame, and a 1s interval stays silent. A start delay isn't clamped, since that gap is whatever you set. Countdown length, voice choice and frame-number announcements live in the settings drawer; only the toggle is on the home screen. Also: - The toggle sits in the status card rather than the button row: three buttons don't fit a 400px column, and it wrapped onto its own flex line where it stretched to the wrong height. - tsconfig excludes dist/, which build-dist.sh fills with copies of these same files and which otherwise gets type-checked twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
385 lines
11 KiB
JavaScript
385 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;
|
|
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: '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 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
|
|
);
|
|
}
|