Some checks failed
CI / build-and-deploy (push) Has been cancelled
Home screen gains shutter, aperture and ISO as dropdowns plus a focus stepper (three nudge sizes each way, and AF) where the body drives focus over PTP. Those three drop out of the read-only strip rather than being shown twice. Everything is built from what the camera actually reports, so a body without one of these controls doesn't get it rather than showing something that silently fails. The 450D marks shutter and aperture readonly unless the mode dial is somewhere they apply, so the dropdowns disable themselves and say why. Focus uses the EOS manualfocusdrive steps and autofocusdrive, and warns when live view is off, which Canon bodies generally require for focus commands. The settings drawer is now tabbed: app settings stay on the first tab, and each config section the camera reports gets its own. Empty sections are dropped, and a selected section that disappears falls back to the first tab. Tabs wrap rather than scroll - a scrolled-off tab is an undiscoverable one. Reverts the automatic camera search added in the previous commit, restoring the Select camera button. Auto-connect could park with no way to reach the device chooser: WebUSB permissions are per-origin, so a grant on localhost doesn't carry to the deployed copy, and a camera that's asleep or held by another app never arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
580 lines
16 KiB
JavaScript
580 lines
16 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, 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<void>
|
|
* }, { 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<void>
|
|
* }} 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<void>,
|
|
* 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<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,
|
|
* setValue: (name: string, value: any) => Promise<void>
|
|
* }} 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<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)
|
|
)
|
|
),
|
|
|
|
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
|
|
);
|
|
}
|