Compare commits

..

5 Commits

Author SHA1 Message Date
Jon
55958ae206 Add a theme toggle, and fix the voice picker showing no voices
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Theme toggle sits next to the settings icon. The choice starts as 'system' and
follows the OS until you press it, after which it's explicit and persisted.

An inline script in <head> stamps the resolved theme on <html> before the
first paint - resolving it from JS after load means a visible flash of dark on
the way to light. Because that attribute is always present, the stylesheet
drops its prefers-color-scheme query entirely rather than having a media query
and an explicit override fighting over the same tokens.

The voice picker was effectively empty: Chrome reports zero voices
synchronously and only fills the list when voiceschanged fires, and while the
narrator did reload them, nothing told preact to re-render - so the dropdown
kept whatever existed at construction, which was nothing. It now notifies, and
the list arrives (181 voices here).

That many voices needs shape, so they're sorted with your own language first
and offline voices ahead of network ones, then grouped into optgroups by
language. Network voices are marked as such since they're useless offline,
which for an app built to work in a field matters.

Speed and pitch are adjustable too, both fed through to every utterance.

The settings button gains a class of its own: the header now has two icon
buttons, so identifying it by .icon-button alone hits the theme toggle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-02 20:17:32 +01:00
Jon
c3cf5ddfbf Split settings into tabs; exposure and focus on the home screen
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
2026-08-02 19:36:06 +01:00
Jon
baaa0b5472 Add fullscreen preview, PWA support, and connect without a button
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
2026-08-02 09:53:24 +01:00
Jon
c293ee47d9 Fail the deploy build when a module is left off the publish list
Some checks failed
CI / build-and-deploy (push) Has been cancelled
voice.js was missing from build-dist.sh, which would have shipped a Worker
whose index.js imports a 404 - the app wouldn't have loaded at all. That's the
failure mode of an allowlist, so check it: any top-level .js not in MODULES now
fails the build instead of silently vanishing from the deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-01 21:10:59 +01:00
Jon
539e3cfb34 Add a spoken countdown between frames
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
2026-08-01 21:02:00 +01:00
15 changed files with 1411 additions and 80 deletions

View File

@@ -58,14 +58,21 @@ npm install
npx serve . # serve.json sets the COOP/COEP headers the WASM module needs npx serve . # serve.json sets the COOP/COEP headers the WASM module needs
``` ```
Open the printed URL in Chrome, plug the camera in over USB, switch it on and pick it from the device picker. Open the printed URL in Chrome, plug the camera in over USB, switch it on and pick it from the device picker. It tries once on load to reattach to a camera the browser already knows about, and otherwise shows a "Select camera" button.
Note that WebUSB permissions are per-origin, so the copy on `localhost` and the deployed copy each need granting separately.
It's also an installable PWA: install it from Chrome's address bar to get a standalone window, and it works with no network at all — the app shell and the pinned unpkg dependencies (including the ~2 MB WASM) are precached by a service worker. That matters here because the camera is on a USB cable, so the app is perfectly useful in a field with no signal.
**Home screen** — only the things you change between runs: **Home screen** — only the things you change between runs:
- **Interval**, **frames** (or ∞ for open-ended) and **start delay** - **Interval**, **frames** (or ∞ for open-ended) and **start delay**
- Start/stop, plus a single-shot button for framing - Start/stop, plus a single-shot button for framing
- Live countdown to the next frame, progress, estimated finish time, and a log of the last few events - Live countdown to the next frame, progress, estimated finish time, and a log of the last few events
- A read-only strip showing shutter / aperture / ISO / format / battery / shots remaining, so you can sanity-check exposure against the interval without opening anything - A 🔊 toggle that speaks the countdown into each frame ("five, four, three, two, one") along with start and finish, for when you're in front of the camera rather than at the laptop
- A ⛶ button on the preview for a fullscreen live view, with the countdown carried over as an overlay so you don't lose it
- Shutter, aperture and ISO as dropdowns, and a focus stepper (three nudge sizes each way, plus AF) where the body drives focus over PTP
- A read-only strip for the rest — mode, format, battery, shots remaining — so you can sanity-check the run without opening anything
- Pre-flight warnings when the interval is too tight for the current exposure and transfer time, when you're shooting RAW on a short interval, or when no output folder is set - Pre-flight warnings when the interval is too tight for the current exposure and transfer time, when you're shooting RAW on a short interval, or when no output folder is set
**Settings drawer** (⚙, top right) — everything else, including the full gPhoto2 config tree, so the home screen stays uncluttered: **Settings drawer** (⚙, top right) — everything else, including the full gPhoto2 config tree, so the home screen stays uncluttered:
@@ -73,6 +80,7 @@ Open the printed URL in Chrome, plug the camera in over USB, switch it on and pi
- Where frames go: a folder on disk via the File System Access API, one-at-a-time downloads, or nothing at all if you're keeping them on the card. Frames are named after the moment they were taken — `20260801-172713_00007.JPG` in a sequence, `20260801-172713.JPG` for a single shot — so sorting by name is sorting by capture time. The camera's own `IMG_1234` is dropped: it wraps at 9999 and resets on a card format, so it can't order a long run. - Where frames go: a folder on disk via the File System Access API, one-at-a-time downloads, or nothing at all if you're keeping them on the card. Frames are named after the moment they were taken — `20260801-172713_00007.JPG` in a sequence, `20260801-172713.JPG` for a single shot — so sorting by name is sorting by capture time. The camera's own `IMG_1234` is dropped: it wraps at 9999 and resets on a card format, so it can't order a long run.
- Live view on/off, and whether it stays up between frames during a sequence (on by default — the feed drops only while each shot fires, since the camera needs live view down to take it, then recovers on its own) - Live view on/off, and whether it stays up between frames during a sequence (on by default — the feed drops only while each shot fires, since the camera needs live view down to take it, then recovers on its own)
- Screen wake lock, so a backgrounded tab doesn't get its timers throttled mid-run - Screen wake lock, so a backgrounded tab doesn't get its timers throttled mid-run
- Voice countdown detail: how many seconds out to start counting (capped at one second under the interval so it never talks over the previous frame), which system voice to use, and whether to announce frame numbers
- Bulb exposures, if the camera exposes `bulb` or `eosremoterelease` - Bulb exposures, if the camera exposes `bulb` or `eosremoterelease`
- The camera's own settings: shutter, aperture, ISO, image format, capture target, drive mode, and so on - The camera's own settings: shutter, aperture, ISO, image format, capture target, drive mode, and so on
@@ -92,6 +100,13 @@ Live at <https://dslr-intervalometer.bournemouthtech.workers.dev>.
The one thing a static host has to get right here: the WASM module is built with pthreads and allocates a **shared** `WebAssembly.Memory`, so the page must be [cross-origin isolated](https://web.dev/coop-coep/) or it fails to start outright. `_headers` sets the same COOP/COEP pair `serve.json` uses locally. If you host this anywhere else, set those two headers or nothing will work. Verify with `crossOriginIsolated === true` in the console. The one thing a static host has to get right here: the WASM module is built with pthreads and allocates a **shared** `WebAssembly.Memory`, so the page must be [cross-origin isolated](https://web.dev/coop-coep/) or it fails to start outright. `_headers` sets the same COOP/COEP pair `serve.json` uses locally. If you host this anywhere else, set those two headers or nothing will work. Verify with `crossOriginIsolated === true` in the console.
Notes on the exposure and focus controls:
- They're built from whatever the camera actually reports, so a body that doesn't expose one of them simply doesn't get that control rather than showing something that silently fails.
- The 450D marks shutter and aperture read-only unless the mode dial is somewhere they apply — both are fixed in the green square, shutter in Av, aperture in Tv. When that happens the dropdowns disable themselves and say so rather than appearing to work.
- Focus uses the EOS `manualfocusdrive` steps ("Near 1".."Far 3") and `autofocusdrive` for AF. Canon bodies generally only accept focus commands with live view running, so the panel warns if you've turned live view off.
- Every change is a USB round-trip taking the best part of a second, and it shares the link with captures — changing settings mid-sequence works but can delay a frame.
Notes on the timing and its limits: Notes on the timing and its limits:
- Frames are scheduled on an absolute grid (`start + n × interval`), so transfer time doesn't accumulate as drift over a long run. If a capture overruns its slot the next frame fires as soon as the camera is free and the overrun is logged, rather than a frame being dropped. - Frames are scheduled on an absolute grid (`start + n × interval`), so transfer time doesn't accumulate as drift over a long run. If a capture overruns its slot the next frame fires as soon as the camera is free and the overrun is logged, rather than a frame being dropped.

View File

@@ -4,14 +4,38 @@
# An allowlist rather than an .assetsignore denylist: everything that lands in # An allowlist rather than an .assetsignore denylist: everything that lands in
# dist/ becomes publicly readable, so it should be a deliberate list, not # dist/ becomes publicly readable, so it should be a deliberate list, not
# whatever happens to be sitting in the working directory. # whatever happens to be sitting in the working directory.
#
# The catch with an allowlist is forgetting to add a new module, which breaks
# the deploy outright (the import 404s and nothing loads), so the check at the
# bottom fails the build if any top-level .js file was left out.
set -eu set -eu
cd "$(dirname "$0")" cd "$(dirname "$0")"
MODULES="index.js index-fallback.js home.js settings.js intervalometer.js
storage.js config-utils.js preview.js voice.js widget.js sw.js"
ASSETS="index.html _headers manifest.webmanifest
icon-192.png icon-512.png icon-maskable-512.png"
# Collapse the line break to single spaces so the membership test below works.
# shellcheck disable=SC2116,SC2086
MODULES=$(echo $MODULES)
rm -rf dist rm -rf dist
mkdir -p dist mkdir -p dist
cp index.html _headers dist/ # shellcheck disable=SC2086
cp index.js index-fallback.js home.js settings.js intervalometer.js \ cp $ASSETS $MODULES dist/
storage.js config-utils.js preview.js widget.js dist/
missing=""
for f in *.js; do
case " $MODULES " in
*" $f "*) ;;
*) missing="$missing $f" ;;
esac
done
if [ -n "$missing" ]; then
echo "error: top-level modules missing from the publish list:$missing" >&2
echo " add them to MODULES in $0, or delete them." >&2
exit 1
fi
echo "dist/ contains:" echo "dist/ contains:"
ls -1 dist ls -1 dist

View File

@@ -51,7 +51,7 @@ export function configValue(config, name) {
* simply skipped, so this stays useful on other bodies too. * simply skipped, so this stays useful on other bodies too.
* @param {Config | undefined} config * @param {Config | undefined} config
*/ */
export function statusReadout(config) { export function statusReadout(config, exclude = []) {
return [ return [
['Mode', 'autoexposuremode'], ['Mode', 'autoexposuremode'],
['Shutter', 'shutterspeed'], ['Shutter', 'shutterspeed'],
@@ -62,10 +62,70 @@ export function statusReadout(config) {
['Shots left', 'availableshots'], ['Shots left', 'availableshots'],
['Target', 'capturetarget'] ['Target', 'capturetarget']
] ]
.filter(([, name]) => !exclude.includes(name))
.map(([label, name]) => ({ label, value: configValue(config, name) })) .map(([label, name]) => ({ label, value: configValue(config, name) }))
.filter(({ value }) => value !== undefined && value !== ''); .filter(({ value }) => value !== undefined && value !== '');
} }
/**
* The exposure controls worth putting on the home screen, in the order a
* photographer expects them. Only settable menus qualify - on a 450D these are
* readonly unless the mode dial is somewhere they apply (shutter speed is fixed
* in Av, aperture in Tv, both in the green square).
*
* @param {Config | undefined} config
*/
export function exposureControls(config) {
return [
{ name: 'shutterspeed', label: 'Shutter' },
{ name: 'aperture', label: 'Aperture' },
{ name: 'iso', label: 'ISO' }
]
.map(entry => ({ ...entry, node: findConfig(config, entry.name) }))
.filter(
entry =>
entry.node &&
(entry.node.type === 'menu' || entry.node.type === 'radio')
);
}
/**
* What focus control this body exposes over PTP.
*
* Canon EOS bodies drive the lens through `manualfocusdrive`, a menu whose
* choices are step sizes in each direction ("Near 1".."Far 3"); setting one
* nudges focus and the value falls back to None. `autofocusdrive` is a
* momentary toggle that triggers AF. Neither is guaranteed to exist, and on
* EOS both generally need live view running.
*
* @param {Config | undefined} config
*/
export function detectFocusControls(config) {
let drive = findConfig(config, 'manualfocusdrive');
/** @type {{ name: string, near: string[], far: string[] } | null} */
let manual = null;
if (
drive &&
(drive.type === 'menu' || drive.type === 'radio') &&
!drive.readonly
) {
// Sorted so index 0 is the smallest nudge in each direction.
let byStep = (a, b) => (parseInt(a, 10) || 0) - (parseInt(b, 10) || 0);
let near = drive.choices.filter(c => /near/i.test(c)).sort(byStep);
let far = drive.choices.filter(c => /far/i.test(c)).sort(byStep);
if (near.length && far.length) manual = { name: drive.name, near, far };
}
let auto = findConfig(config, 'autofocusdrive');
let mode = findConfig(config, 'focusmode');
return {
manual,
autofocus: auto && auto.type === 'toggle' && !auto.readonly ? auto.name : null,
mode: mode && 'value' in mode ? mode : null
};
}
/** /**
* Turn a gphoto2 shutter speed string ("1/250", "30", "0.3", "bulb") into * Turn a gphoto2 shutter speed string ("1/250", "30", "0.3", "bulb") into
* seconds. Returns undefined when it can't be parsed. * seconds. Returns undefined when it can't be parsed.

View File

@@ -16,7 +16,7 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/ */
import { h } from 'preact'; import { h, Component } from 'preact';
import { import {
secondsUntilNext, secondsUntilNext,
formatDuration, formatDuration,
@@ -25,7 +25,9 @@ import {
import { import {
statusReadout, statusReadout,
configValue, configValue,
shutterSpeedSeconds shutterSpeedSeconds,
exposureControls,
detectFocusControls
} from './config-utils.js'; } from './config-utils.js';
/** @typedef {import('./settings.js').Prefs} Prefs */ /** @typedef {import('./settings.js').Prefs} Prefs */
@@ -89,9 +91,238 @@ function sequenceWarnings(prefs, config, hasFolder) {
} }
/** /**
* @param {{ state: SequenceState, prefs: Prefs }} props * @param {{
* state: SequenceState,
* prefs: Prefs,
* voiceSupported: boolean,
* onToggleVoice: () => void
* }} props
*/ */
function SequenceStatus({ state, prefs }) { 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 { phase, taken, total } = state;
let headline; let headline;
let detail; let detail;
@@ -130,24 +361,7 @@ function SequenceStatus({ state, prefs }) {
detail = 'Set an interval and press Start.'; detail = 'Set an interval and press Start.';
} }
let progress = total ? Math.min(1, taken / total) : 0; return { headline, detail };
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
);
} }
/** /**
@@ -166,7 +380,10 @@ function SequenceStatus({ state, prefs }) {
* config: import('web-gphoto2').Config | undefined, * config: import('web-gphoto2').Config | undefined,
* hasFolder: boolean, * hasFolder: boolean,
* canCapture: boolean, * canCapture: boolean,
* log: { id: number, message: string, kind: string, at: number }[] * log: { id: number, message: string, kind: string, at: number }[],
* voiceSupported: boolean,
* onToggleVoice: () => void,
* setValue: (name: string, value: any) => Promise<void>
* }} props * }} props
*/ */
export function Home({ export function Home({
@@ -181,13 +398,20 @@ export function Home({
config, config,
hasFolder, hasFolder,
canCapture, canCapture,
log log,
voiceSupported,
onToggleVoice,
setValue
}) { }) {
let warnings = running ? [] : sequenceWarnings(prefs, config, hasFolder); let warnings = running ? [] : sequenceWarnings(prefs, config, hasFolder);
let plannedSeconds = prefs.unlimited let plannedSeconds = prefs.unlimited
? Infinity ? Infinity
: prefs.startDelaySeconds + prefs.shots * prefs.intervalSeconds; : prefs.startDelaySeconds + prefs.shots * prefs.intervalSeconds;
let readout = statusReadout(config); // 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 */ /** @param {(value: number) => Partial<Prefs>} toPatch */
let numberHandler = toPatch => e => { let numberHandler = toPatch => e => {
@@ -198,7 +422,7 @@ export function Home({
return h( return h(
'div', 'div',
{ id: 'home' }, { id: 'home' },
h(SequenceStatus, { state, prefs }), h(SequenceStatus, { state, prefs, voiceSupported, onToggleVoice }),
h( h(
'div', 'div',
@@ -319,6 +543,9 @@ export function Home({
) )
), ),
h(ExposureControls, { config, setValue }),
h(FocusControls, { config, setValue, livePreview: prefs.livePreview }),
readout.length readout.length
? h( ? h(
'div', 'div',

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -4,14 +4,40 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Intervalometer — Canon over WebUSB</title> <title>Intervalometer — Canon over WebUSB</title>
<link rel="manifest" href="./manifest.webmanifest" />
<meta name="theme-color" content="#14161a" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="./icon-192.png" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://unpkg.com/purecss@2.0.6/build/pure-min.css" href="https://unpkg.com/purecss@2.0.6/build/pure-min.css"
crossorigin="anonymous" crossorigin="anonymous"
/> />
<script>
// Resolve the theme before the first paint, or the page flashes dark on
// its way to light. Kept inline and dependency-free for that reason.
(() => {
let choice = 'system';
try {
choice =
JSON.parse(localStorage.getItem('web-dslr.prefs') || '{}').theme ||
'system';
} catch {}
document.documentElement.dataset.theme =
choice === 'light' || choice === 'dark'
? choice
: matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
})();
</script>
<style> <style>
:root { /* Dark is the base. The script above always stamps an explicit
color-scheme: dark light; data-theme, so there's no prefers-color-scheme query here to fight
with an override the user has chosen. */
:root,
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #14161a; --bg: #14161a;
--panel: #1c1f26; --panel: #1c1f26;
--panel-2: #242832; --panel-2: #242832;
@@ -26,17 +52,16 @@
--radius: 10px; --radius: 10px;
} }
@media (prefers-color-scheme: light) { :root[data-theme='light'] {
:root { color-scheme: light;
--bg: #f2f4f7; --bg: #f2f4f7;
--panel: #ffffff; --panel: #ffffff;
--panel-2: #f7f8fa; --panel-2: #f7f8fa;
--line: #d9dee6; --line: #d9dee6;
--text: #1a1d23; --text: #1a1d23;
--muted: #5c6675; --muted: #5c6675;
--accent: #1f6fd0; --accent: #1f6fd0;
--accent-text: #ffffff; --accent-text: #ffffff;
}
} }
* { * {
@@ -87,6 +112,12 @@
gap: 12px; gap: 12px;
} }
#app-header .header-actions {
display: flex;
align-items: center;
gap: 2px;
}
#app-header .logo { #app-header .logo {
font-size: 24px; font-size: 24px;
} }
@@ -139,6 +170,57 @@
font-size: 13px; font-size: 13px;
} }
/* Fullscreen preview */
.viewer-button {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
padding: 6px 10px;
font-size: 16px;
line-height: 1.2;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-color: rgba(255, 255, 255, 0.25);
opacity: 0.55;
transition: opacity 0.15s;
}
#viewer:hover .viewer-button,
.viewer-button:focus-visible {
opacity: 1;
}
#viewer:fullscreen {
background: #000;
}
.viewer-status {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 14px;
border-radius: var(--radius);
background: rgba(0, 0, 0, 0.55);
color: #fff;
}
.viewer-status-headline {
font-size: 24px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.viewer-status-detail {
font-size: 13px;
opacity: 0.8;
}
#home { #home {
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@@ -230,6 +312,11 @@
gap: 10px; gap: 10px;
} }
/* Shutter / aperture / ISO belong on one line, so allow narrower columns. */
.field-grid.tight {
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
}
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -298,6 +385,67 @@
color: var(--muted); color: var(--muted);
} }
.card-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
margin-bottom: 10px;
display: flex;
align-items: baseline;
gap: 8px;
}
.card-title-note {
text-transform: none;
letter-spacing: 0;
font-size: 12px;
opacity: 0.8;
}
.mini-spinner {
display: inline-block;
width: 9px;
height: 9px;
margin-left: 6px;
border: 1.5px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
}
/* Focus stepper */
.focus-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.focus-steps {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
}
.focus-end {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.focus-step {
flex: 1;
padding: 7px 4px;
font-size: 11px;
line-height: 1;
letter-spacing: -1px;
}
.plan { .plan {
margin: 12px 0 0; margin: 12px 0 0;
font-size: 13px; font-size: 13px;
@@ -353,14 +501,44 @@
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 12px;
} }
/* Labels shouldn't fold onto a second line to make room for a sibling -
the row wraps instead. */
.actions button {
white-space: nowrap;
}
.actions .big { .actions .big {
flex: 1; flex: 1;
} }
.status-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.status-text {
min-width: 0;
}
.voice-toggle {
flex: none;
padding: 6px 10px;
font-size: 18px;
line-height: 1.2;
}
.voice-toggle.on {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
/* ---------- readout & log ---------- */ /* ---------- readout & log ---------- */
.readout { .readout {
@@ -513,6 +691,39 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
/* Wraps rather than scrolls: a scrolled-off tab is an undiscoverable
one, and two short rows cost less than a hidden overflow. */
.tab-bar {
display: flex;
flex-wrap: wrap;
gap: 2px 4px;
padding: 4px 8px 0;
border-bottom: 1px solid var(--line);
flex: none;
}
.tab {
flex: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
background: none;
color: var(--muted);
font-size: 13px;
padding: 9px 10px;
white-space: nowrap;
}
.tab:hover:not(.active) {
color: var(--text);
border-color: var(--line);
}
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.drawer-body { .drawer-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -625,6 +836,18 @@
color: var(--muted); color: var(--muted);
} }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.mini-spinner {
animation-duration: 2.4s;
}
}
.fine-print { .fine-print {
max-width: 46ch; max-width: 46ch;
margin: 16px auto 0; margin: 16px auto 0;
@@ -671,6 +894,17 @@
rel="icon" rel="icon"
href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 110 100"><text y=".9em" font-size="90">⏱</text></svg>' href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 110 100"><text y=".9em" font-size="90">⏱</text></svg>'
/> />
<script>
// Registered outside the module graph so a service worker failure can
// never stop the app itself from loading.
if ('serviceWorker' in navigator) {
addEventListener('load', () => {
navigator.serviceWorker
.register('./sw.js')
.catch(err => console.warn('Service worker registration failed:', err));
});
}
</script>
</head> </head>
<body> <body>
<div class="center">⌛ Loading…</div> <div class="center">⌛ Loading…</div>

View File

@@ -16,14 +16,15 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/ */
import { h, hydrate, Component, Fragment } from 'preact'; import { h, hydrate, Component, Fragment, createRef } from 'preact';
import { Camera, rethrowIfCritical } from 'web-gphoto2'; import { Camera, rethrowIfCritical } from 'web-gphoto2';
import { Preview } from './preview.js'; import { Preview } from './preview.js';
import { Home } from './home.js'; import { Home, sequenceSummary } from './home.js';
import { SettingsDrawer, loadPrefs, savePrefs } from './settings.js'; import { SettingsDrawer, loadPrefs, savePrefs } from './settings.js';
import { Intervalometer, formatDuration } from './intervalometer.js'; import { Intervalometer, formatDuration } from './intervalometer.js';
import { FrameSaver, WakeLock } from './storage.js'; import { FrameSaver, WakeLock } from './storage.js';
import { detectBulbSupport, configValue } from './config-utils.js'; import { detectBulbSupport, configValue } from './config-utils.js';
import { Narrator, supportsSpeech } from './voice.js';
export const isDebug = new URLSearchParams(location.search).has('debug'); export const isDebug = new URLSearchParams(location.search).has('debug');
@@ -35,6 +36,19 @@ if (isDebug) {
/** @param {number} ms */ /** @param {number} ms */
const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const systemPrefersLight = () => matchMedia('(prefers-color-scheme: light)');
/**
* Turn the stored preference into the theme actually in force.
* @param {'system' | 'light' | 'dark'} choice
*/
const resolveTheme = choice =>
choice === 'light' || choice === 'dark'
? choice
: systemPrefersLight().matches
? 'light'
: 'dark';
/** @extends Component<{}, AppState> */ /** @extends Component<{}, AppState> */
class App extends Component { class App extends Component {
/** @type {Camera | undefined} */ /** @type {Camera | undefined} */
@@ -42,6 +56,7 @@ class App extends Component {
saver = new FrameSaver(); saver = new FrameSaver();
wakeLock = new WakeLock(); wakeLock = new WakeLock();
narrator = new Narrator();
intervalometer = new Intervalometer({ intervalometer = new Intervalometer({
capture: index => this.captureFrame(index), capture: index => this.captureFrame(index),
@@ -64,9 +79,24 @@ class App extends Component {
#logId = 0; #logId = 0;
#configWatchToken = 0; #configWatchToken = 0;
#wasRunning = false; #wasRunning = false;
viewerRef = createRef();
componentDidMount() { componentDidMount() {
this.saver.mode = this.state.prefs.saveMode; this.saver.mode = this.state.prefs.saveMode;
this.syncNarrator(this.state.prefs);
// The voice list arrives after construction; re-render when it does.
this.narrator.onVoicesChanged = () => this.forceUpdate();
// Held for as long as the app is open, not just while a sequence runs -
// you're usually mid-setup when the display would otherwise sleep.
if (this.state.prefs.keepAwake) this.wakeLock.acquire();
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
this.applyTheme(this.state.prefs.theme);
// While the choice is 'system', follow the OS if it changes underneath us.
systemPrefersLight().addEventListener('change', () => {
if (this.state.prefs.theme === 'system') this.applyTheme('system');
});
addEventListener('error', ({ message }) => addEventListener('error', ({ message }) =>
this.log(`Uncaught error: ${message}`, 'error') this.log(`Uncaught error: ${message}`, 'error')
@@ -89,8 +119,8 @@ class App extends Component {
{ once: true } { once: true }
); );
// Try to connect to camera at startup. // Try the camera once at startup; if it isn't among the connections the
// If none is found among saved connections, it will fallback to a picker. // browser already knows about, fall back to the picker.
this.tryToConnectToCamera(); this.tryToConnectToCamera();
} }
@@ -108,17 +138,69 @@ class App extends Component {
})); }));
} }
/** @param {'system' | 'light' | 'dark'} choice */
applyTheme(choice) {
let theme = resolveTheme(choice);
document.documentElement.dataset.theme = theme;
// Keeps the PWA title bar and mobile browser chrome in step.
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', theme === 'light' ? '#ffffff' : '#14161a');
this.setState({ theme });
}
toggleTheme = () => {
let next = /** @type {'light' | 'dark'} */ (
resolveTheme(this.state.prefs.theme) === 'dark' ? 'light' : 'dark'
);
this.applyTheme(next);
this.setPref({ theme: next });
};
/** @param {import('./settings.js').Prefs} prefs */
syncNarrator(prefs) {
Object.assign(this.narrator, {
enabled: prefs.voice,
countFrom: prefs.voiceCountFrom,
voiceURI: prefs.voiceURI,
announceFrames: prefs.voiceAnnounceFrames,
rate: prefs.voiceRate,
pitch: prefs.voicePitch
});
}
/** @param {Partial<import('./settings.js').Prefs>} patch */ /** @param {Partial<import('./settings.js').Prefs>} patch */
setPref = patch => { setPref = patch => {
this.setState(({ prefs }) => { this.setState(({ prefs }) => {
let next = { ...prefs, ...patch }; let next = { ...prefs, ...patch };
savePrefs(next); savePrefs(next);
this.saver.mode = next.saveMode; this.saver.mode = next.saveMode;
if (!next.keepAwake) this.wakeLock.release(); this.syncNarrator(next);
if (next.keepAwake) this.wakeLock.acquire();
else this.wakeLock.release();
return { prefs: next }; return { prefs: next };
}); });
}; };
toggleVoice = () => {
let on = !this.state.prefs.voice;
// Applied straight away rather than waiting for the state update, so a
// countdown can't slip out between the click and the commit.
this.narrator.enabled = on;
this.setPref({ voice: on });
// Speaking here also gets the user gesture Chrome wants before it will
// let a page talk at all.
if (on) this.narrator.say('Voice countdown on');
else this.narrator.cancel();
};
testVoice = () => {
let wasEnabled = this.narrator.enabled;
this.narrator.enabled = true;
this.narrator.say('Three. Two. One.', { interrupt: true });
this.narrator.enabled = wasEnabled;
};
selectDevice = async () => { selectDevice = async () => {
// @ts-ignore // @ts-ignore
await Camera.showPicker(); await Camera.showPicker();
@@ -259,14 +341,31 @@ class App extends Component {
/** @param {import('./intervalometer.js').SequenceState} seq */ /** @param {import('./intervalometer.js').SequenceState} seq */
handleSequenceChange(seq) { handleSequenceChange(seq) {
let running = this.intervalometer.running; let running = this.intervalometer.running;
this.narrator.update(seq);
this.setState({ seq }); this.setState({ seq });
if (this.#wasRunning && !running) { if (this.#wasRunning && !running) {
this.wakeLock.release(); // The wake lock stays - it's app-wide now, not sequence-scoped.
this.refreshConfig(); this.refreshConfig();
} }
this.#wasRunning = running; this.#wasRunning = running;
} }
handleFullscreenChange = () => {
this.setState({ fullscreen: !!document.fullscreenElement });
};
toggleFullscreen = async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await this.viewerRef.current?.requestFullscreen();
}
} catch (err) {
this.log(`Could not toggle fullscreen: ${err}`, 'warn');
}
};
chooseFolder = async () => { chooseFolder = async () => {
try { try {
let name = await this.saver.chooseFolder(); let name = await this.saver.chooseFolder();
@@ -305,6 +404,8 @@ class App extends Component {
: '' : ''
}.` }.`
); );
this.narrator.reset();
this.narrator.say('Starting');
this.intervalometer.start({ this.intervalometer.start({
intervalMs: prefs.intervalSeconds * 1000, intervalMs: prefs.intervalSeconds * 1000,
count, count,
@@ -353,14 +454,31 @@ class App extends Component {
) )
), ),
h( h(
'button', 'div',
{ { class: 'header-actions' },
type: 'button', h(
class: 'icon-button', 'button',
onclick: this.toggleSettings, {
title: 'Settings' type: 'button',
}, class: 'icon-button',
'⚙' onclick: this.toggleTheme,
title:
this.state.theme === 'dark'
? 'Switch to light mode'
: 'Switch to dark mode'
},
this.state.theme === 'dark' ? '☀' : '☾'
),
h(
'button',
{
type: 'button',
class: 'icon-button settings-button',
onclick: this.toggleSettings,
title: 'Settings'
},
'⚙'
)
) )
); );
} }
@@ -381,7 +499,7 @@ class App extends Component {
h( h(
'p', 'p',
{ class: 'fine-print' }, { class: 'fine-print' },
'Requires Chrome with WebUSB. On Linux you may need a udev rule, and on macOS you may need to quit Photos/Image Capture first. Built on ', 'Requires Chrome with WebUSB. On macOS, quit Photos and Image Capture if they grabbed the camera first; on Linux you may need a udev rule. Built on ',
h( h(
'a', 'a',
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' }, { href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
@@ -404,7 +522,11 @@ class App extends Component {
null, null,
h( h(
'div', 'div',
{ id: 'viewer' }, {
id: 'viewer',
ref: this.viewerRef,
class: state.fullscreen ? 'is-fullscreen' : ''
},
showPreview showPreview
? h(Preview, { ? h(Preview, {
getPreview: () => this.camera.capturePreviewAsBlob(), getPreview: () => this.camera.capturePreviewAsBlob(),
@@ -425,7 +547,37 @@ class App extends Component {
previewSupported previewSupported
? 'Live view is turned off in Settings.' ? 'Live view is turned off in Settings.'
: 'This camera does not support live preview.' : 'This camera does not support live preview.'
),
h(
'button',
{
type: 'button',
class: 'viewer-button',
onclick: this.toggleFullscreen,
title: state.fullscreen
? 'Exit fullscreen (Esc)'
: 'Fullscreen preview'
},
state.fullscreen ? '✕' : '⛶'
),
// Fullscreen hides the whole control column, so carry the
// countdown across rather than leaving you staring at a picture.
state.fullscreen
? h(
'div',
{ class: 'viewer-status' },
h(
'span',
{ class: 'viewer-status-headline' },
sequenceSummary(state.seq, state.prefs).headline
),
h(
'span',
{ class: 'viewer-status-detail' },
sequenceSummary(state.seq, state.prefs).detail
)
) )
: undefined
), ),
h(Home, { h(Home, {
prefs: state.prefs, prefs: state.prefs,
@@ -439,7 +591,10 @@ class App extends Component {
config: state.config, config: state.config,
hasFolder: this.saver.hasFolder, hasFolder: this.saver.hasFolder,
canCapture: !!state.supportedOps?.captureImage, canCapture: !!state.supportedOps?.captureImage,
log: state.log log: state.log,
voiceSupported: supportsSpeech,
onToggleVoice: this.toggleVoice,
setValue: this.setValue
}) })
), ),
h(SettingsDrawer, { h(SettingsDrawer, {
@@ -452,7 +607,9 @@ class App extends Component {
folderName: this.saver.folderName, folderName: this.saver.folderName,
chooseFolder: this.chooseFolder, chooseFolder: this.chooseFolder,
bulbSupport: this.bulbSupport, bulbSupport: this.bulbSupport,
locked: running locked: running,
voices: this.narrator.voices,
testVoice: this.testVoice
}) })
); );
} }

View File

@@ -0,0 +1,22 @@
{
"name": "DSLR Intervalometer",
"short_name": "Intervalometer",
"description": "Shoot timelapses on a USB-connected DSLR from the browser.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "any",
"background_color": "#14161a",
"theme_color": "#14161a",
"categories": ["photo", "utilities"],
"icons": [
{ "src": "./icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "./icon-512.png", "sizes": "512x512", "type": "image/png" },
{
"src": "./icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -19,6 +19,7 @@
import { h, Component, Fragment } from 'preact'; import { h, Component, Fragment } from 'preact';
import { Widget } from './widget.js'; import { Widget } from './widget.js';
import { supportsFolderSaving } from './storage.js'; import { supportsFolderSaving } from './storage.js';
import { supportsSpeech } from './voice.js';
const PREFS_KEY = 'web-dslr.prefs'; const PREFS_KEY = 'web-dslr.prefs';
@@ -38,7 +39,16 @@ export const DEFAULT_PREFS = {
liveViewBetweenFrames: true, liveViewBetweenFrames: true,
keepAwake: true, keepAwake: true,
bulbEnabled: false, bulbEnabled: false,
bulbSeconds: 30 bulbSeconds: 30,
/** 'system' follows the OS until the header toggle is used. */
theme: /** @type {'system' | 'light' | 'dark'} */ ('system'),
// Toggled from the home screen; the rest live in this drawer.
voice: false,
voiceCountFrom: 5,
voiceURI: '',
voiceAnnounceFrames: false,
voiceRate: 1.1,
voicePitch: 1
}; };
/** @typedef {typeof DEFAULT_PREFS} Prefs */ /** @typedef {typeof DEFAULT_PREFS} Prefs */
@@ -64,6 +74,64 @@ export function savePrefs(prefs) {
} }
} }
/**
* The camera's top-level config sections, each of which becomes a tab.
* Empty ones are dropped so we don't offer a tab onto nothing.
*
* @param {import('web-gphoto2').Config | undefined} config
* @returns {(import('web-gphoto2').Config & { type: 'section', children: any })[]}
*/
function cameraSections(config) {
if (!config || config.type !== 'window') return [];
return /** @type {any} */ (Object.values(config.children).filter(
child =>
(child.type === 'section' || child.type === 'window') &&
Object.keys(child.children).length > 0
));
}
/**
* Section labels are all "Camera Actions", "Camera Settings", … - the prefix
* is dead weight in a tab that's already inside the camera's settings.
* @param {string} label
*/
function tabLabel(label) {
return label.replace(/^camera\s+/i, '') || label;
}
/**
* Bucket voices into <optgroup>s by language, keeping the order the narrator
* sorted them into (your own language first, offline voices before network).
*
* @param {SpeechSynthesisVoice[]} voices
*/
function groupVoices(voices) {
/** @type {{ lang: string, voices: SpeechSynthesisVoice[] }[]} */
let groups = [];
let byLang = new Map();
for (let voice of voices) {
let group = byLang.get(voice.lang);
if (!group) {
group = { lang: voice.lang, voices: [] };
byLang.set(voice.lang, group);
groups.push(group);
}
group.voices.push(voice);
}
return groups;
}
/**
* @param {number} value
* @param {number} min
* @param {number} max
* @param {number} fallback Used when the field is left empty (value is NaN).
*/
function clamp(value, min, max, fallback) {
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 10) / 10));
}
/** /**
* @param {{ label: string, hint?: string, children?: any }} props * @param {{ label: string, hint?: string, children?: any }} props
*/ */
@@ -95,10 +163,15 @@ function Row({ label, hint, children }) {
* folderName: string | undefined, * folderName: string | undefined,
* chooseFolder: () => void, * chooseFolder: () => void,
* bulbSupport: ReturnType<typeof import('./config-utils.js').detectBulbSupport>, * bulbSupport: ReturnType<typeof import('./config-utils.js').detectBulbSupport>,
* locked: boolean * locked: boolean,
* voices: SpeechSynthesisVoice[],
* testVoice: () => void
* }> * }>
*/ */
export class SettingsDrawer extends Component { export class SettingsDrawer extends Component {
/** Selected tab: 'app', or the gphoto2 name of a config section. */
state = { tab: 'app' };
#onKeyDown = (/** @type {KeyboardEvent} */ e) => { #onKeyDown = (/** @type {KeyboardEvent} */ e) => {
if (e.key === 'Escape' && this.props.open) this.props.onClose(); if (e.key === 'Escape' && this.props.open) this.props.onClose();
}; };
@@ -122,9 +195,22 @@ export class SettingsDrawer extends Component {
folderName, folderName,
chooseFolder, chooseFolder,
bulbSupport, bulbSupport,
locked locked,
voices,
testVoice
} = props; } = props;
// One tab per top-level section the camera reports, so the config tree
// stops being one enormous scroll.
let sections = cameraSections(config);
// The chosen section can vanish if the camera is swapped or reports
// differently after a change; fall back rather than render nothing.
let tab =
this.state.tab !== 'app' && !sections.some(s => s.name === this.state.tab)
? 'app'
: this.state.tab;
let selected = sections.find(s => s.name === tab);
return h( return h(
Fragment, Fragment,
null, null,
@@ -150,6 +236,32 @@ export class SettingsDrawer extends Component {
'✕' '✕'
) )
), ),
h(
'div',
{ class: 'tab-bar' },
h(
'button',
{
type: 'button',
class: `tab ${tab === 'app' ? 'active' : ''}`,
onclick: () => this.setState({ tab: 'app' })
},
'App'
),
sections.map(section =>
h(
'button',
{
key: section.name,
type: 'button',
class: `tab ${tab === section.name ? 'active' : ''}`,
title: section.label,
onclick: () => this.setState({ tab: section.name })
},
tabLabel(section.label)
)
)
),
h( h(
'div', 'div',
{ class: 'drawer-body' }, { class: 'drawer-body' },
@@ -161,6 +273,27 @@ export class SettingsDrawer extends Component {
) )
: undefined, : undefined,
selected
? h(
'form',
{
class: 'pure-form pure-form-aligned',
onSubmit: e => e.preventDefault()
},
// The section's own children, not the section node itself -
// the tab already names it, so a fieldset around it is noise.
Object.values(selected.children).map(child =>
h(Widget, { key: child.name, config: child, setValue })
)
)
: undefined,
tab !== 'app'
? undefined
: h(
Fragment,
null,
h( h(
'section', 'section',
null, null,
@@ -244,7 +377,7 @@ export class SettingsDrawer extends Component {
Row, Row,
{ {
label: 'Keep screen awake', label: 'Keep screen awake',
hint: 'Background tabs get their timers throttled, which ruins interval timing.' hint: 'Held the whole time the app is open, not just during a sequence. Background tabs get their timers throttled, which ruins interval timing.'
}, },
h('input', { h('input', {
type: 'checkbox', type: 'checkbox',
@@ -255,6 +388,133 @@ export class SettingsDrawer extends Component {
) )
), ),
h(
'section',
null,
h('h3', null, 'Voice countdown'),
supportsSpeech
? h(
Fragment,
null,
h(
'p',
{ class: 'notice' },
'Switched on and off with the 🔊 button on the home screen.'
),
h(
Row,
{
label: 'Start counting at',
hint: 'Capped at one second less than the interval, so it never talks over the previous frame.'
},
h('input', {
type: 'number',
min: '1',
max: '30',
step: '1',
value: prefs.voiceCountFrom,
onChange: e =>
setPref({
voiceCountFrom: Math.min(
30,
Math.max(1, e.currentTarget.valueAsNumber || 1)
)
})
}),
h('span', { class: 'unit' }, 'sec')
),
h(
Row,
{ label: 'Announce frame number' },
h('input', {
type: 'checkbox',
checked: prefs.voiceAnnounceFrames,
onChange: e =>
setPref({ voiceAnnounceFrames: e.currentTarget.checked })
})
),
h(
Row,
{
label: 'Voice',
hint: voices.length
? `${voices.length} available. Network voices won't work offline.`
: 'Loading the system voice list…'
},
h(
'select',
{
value: prefs.voiceURI,
onChange: e => setPref({ voiceURI: e.currentTarget.value })
},
h('option', { value: '' }, 'Browser default'),
// Grouped by language: a flat list of ~180 is unusable.
groupVoices(voices).map(group =>
h(
'optgroup',
{ key: group.lang, label: group.lang },
group.voices.map(v =>
h(
'option',
{ key: v.voiceURI, value: v.voiceURI },
v.localService ? v.name : `${v.name} (network)`
)
)
)
)
)
),
h(
Row,
{
label: 'Speed',
hint: 'Quicker means a spoken number lands nearer the second it names.'
},
h('input', {
type: 'number',
min: '0.5',
max: '2',
step: '0.1',
value: prefs.voiceRate,
onChange: e =>
setPref({
voiceRate: clamp(e.currentTarget.valueAsNumber, 0.5, 2, 1.1)
})
}),
h('span', { class: 'unit' }, '×')
),
h(
Row,
{ label: 'Pitch' },
h('input', {
type: 'number',
min: '0',
max: '2',
step: '0.1',
value: prefs.voicePitch,
onChange: e =>
setPref({
voicePitch: clamp(e.currentTarget.valueAsNumber, 0, 2, 1)
})
})
),
h(
Row,
{ label: 'Test' },
h(
'button',
{ type: 'button', class: 'secondary', onclick: testVoice },
'🔊 Say “three, two, one”'
)
)
)
: h(
'p',
{ class: 'notice' },
'This browser has no speech synthesis, so the voice countdown is unavailable.'
)
),
h( h(
'section', 'section',
null, null,
@@ -305,22 +565,14 @@ export class SettingsDrawer extends Component {
: h( : h(
'p', 'p',
{ class: 'notice' }, { class: 'notice' },
'This camera does not expose a bulb or eosremoterelease control, so timed long exposures are unavailable. Use the shutter speed setting below instead (up to 30s on the 450D).' 'This camera does not expose a bulb or eosremoterelease control, so timed long exposures are unavailable. Use the shutter speed setting instead (up to 30s on the 450D).'
) )
), ),
h( !config
'section', ? h('p', { class: 'notice' }, 'Reading camera configuration')
null, : undefined
h('h3', null, 'Camera'), )
config
? h(
'form',
{ class: 'pure-form pure-form-aligned', onSubmit: e => e.preventDefault() },
h(Widget, { config, setValue })
)
: h('p', { class: 'notice' }, 'Reading camera configuration…')
)
) )
) )
); );

147
examples/preact/sw.js Normal file
View File

@@ -0,0 +1,147 @@
/*
* Service worker: makes the app installable and usable with no network.
*
* Offline matters more here than for most web apps - the camera is on the end
* of a USB cable, so the app is fully functional in a field with no signal, as
* long as it can load at all.
*
* Two caches with deliberately different strategies:
*
* - Our own files: network-first. They change whenever the app is redeployed,
* and a stale module paired with fresh HTML is a miserable bug to chase.
* Cache is the fallback, which is what makes offline work.
* - The unpkg dependencies (preact, web-gphoto2, the ~2MB WASM): cache-first.
* Those URLs are pinned to exact versions and are immutable, so re-checking
* them over the network buys nothing.
*/
const VERSION = 'v1';
const SHELL_CACHE = `intervalometer-shell-${VERSION}`;
const DEPS_CACHE = `intervalometer-deps-${VERSION}`;
const CACHES = [SHELL_CACHE, DEPS_CACHE];
const SHELL = [
'./',
'./index.html',
'./manifest.webmanifest',
'./index.js',
'./index-fallback.js',
'./home.js',
'./settings.js',
'./intervalometer.js',
'./storage.js',
'./config-utils.js',
'./preview.js',
'./voice.js',
'./widget.js',
'./icon-192.png',
'./icon-512.png'
];
/** Cross-origin hosts whose responses are safe to keep indefinitely. */
const IMMUTABLE_HOSTS = ['unpkg.com'];
/*
* These have to be fetched at install time, not left 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 - which
* means without this the app looks cached but dies offline on its imports.
*
* Every URL is version-pinned, so they're safe to hold forever.
*/
const DEPS = [
'https://unpkg.com/web-gphoto2@0.4.1/build/camera.js',
'https://unpkg.com/web-gphoto2@0.4.1/build/libapi.mjs',
'https://unpkg.com/web-gphoto2@0.4.1/build/libapi.wasm',
'https://unpkg.com/preact@10.6.4/dist/preact.module.js',
'https://unpkg.com/purecss@2.0.6/build/pure-min.css'
];
self.addEventListener('install', event => {
event.waitUntil(
Promise.all([
caches.open(SHELL_CACHE).then(cache => cache.addAll(SHELL)),
precacheDeps()
]).then(() => self.skipWaiting())
);
});
/**
* Fetched individually and tolerantly: a flaky CDN shouldn't fail the whole
* install and leave the app with no worker at all. Anything that misses here
* gets picked up by the runtime cache on a later online visit.
*/
async function precacheDeps() {
let cache = await caches.open(DEPS_CACHE);
await Promise.allSettled(
DEPS.map(async url => {
let response = await fetch(url, { mode: 'cors', credentials: 'omit' });
if (!response.ok) throw new Error(`${response.status} for ${url}`);
await cache.put(url, response);
})
);
}
self.addEventListener('activate', event => {
event.waitUntil(
caches
.keys()
.then(keys =>
Promise.all(keys.filter(k => !CACHES.includes(k)).map(k => caches.delete(k)))
)
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
let { request } = event;
if (request.method !== 'GET') return;
let url = new URL(request.url);
if (IMMUTABLE_HOSTS.includes(url.hostname)) {
event.respondWith(cacheFirst(request));
return;
}
if (url.origin === self.location.origin) {
event.respondWith(networkFirst(request));
}
});
/**
* @param {Request} request
*/
async function cacheFirst(request) {
let cache = await caches.open(DEPS_CACHE);
let hit = await cache.match(request);
if (hit) return hit;
let response = await fetch(request);
// An opaque response would break the page's cross-origin isolation on
// replay, so only keep ones that actually passed CORS.
if (response.ok && response.type !== 'opaque') {
cache.put(request, response.clone());
}
return response;
}
/**
* @param {Request} request
*/
async function networkFirst(request) {
let cache = await caches.open(SHELL_CACHE);
try {
let response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch (err) {
let hit = await cache.match(request);
if (hit) return hit;
// A navigation with nothing cached for this exact URL still wants the shell.
if (request.mode === 'navigate') {
let shell = await cache.match('./');
if (shell) return shell;
}
throw err;
}
}

View File

@@ -1,4 +1,9 @@
{ {
// dist/ is a copy of these same files assembled for deploy; checking it too
// just reports every error twice, against stale copies.
// sw.js runs in ServiceWorkerGlobalScope, not the DOM, so checking it in this
// program reports every worker global as undefined.
"exclude": ["dist", "node_modules", "sw.js"],
"compilerOptions": { "compilerOptions": {
"checkJs": true, "checkJs": true,
"target": "ESNext", "target": "ESNext",

View File

@@ -17,4 +17,8 @@ type AppState = {
log: LogEntry[]; log: LogEntry[];
settingsOpen: boolean; settingsOpen: boolean;
singleShotStatus: 'idle' | 'busy'; singleShotStatus: 'idle' | 'busy';
/** Whether the preview pane is currently filling the screen. */
fullscreen?: boolean;
/** The theme actually in force, once 'system' has been resolved. */
theme?: 'light' | 'dark';
}; };

184
examples/preact/voice.js Normal file
View File

@@ -0,0 +1,184 @@
/*
* 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
*/
/** @typedef {import('./intervalometer.js').SequenceState} SequenceState */
export const supportsSpeech = 'speechSynthesis' in globalThis;
/**
* Speaks the run out loud: a countdown into each frame, and the odd status
* announcement. Useful when you're in front of the camera rather than at the
* laptop, or holding still for a long exposure.
*
* Driven from the intervalometer's state updates (~5/s) rather than its own
* timer, so it can't drift away from what the sequence is actually doing. Each
* second is spoken at most once, tracked by the second it belongs to.
*/
export class Narrator {
enabled = false;
/** Seconds before a frame to start counting. */
countFrom = 5;
announceFrames = false;
/** Empty means the browser's default voice. */
voiceURI = '';
rate = 1.1;
pitch = 1;
/**
* Called when the voice list changes, so the UI can re-render. Chrome
* reports zero voices synchronously and only fills the list later, so
* without this the picker is stuck on whatever existed at construction.
* @type {(() => void) | undefined}
*/
onVoicesChanged;
/** @type {number | null} */
#lastSecond = null;
#lastNextAt = 0;
/** @type {string} */
#lastPhase = 'idle';
/** @type {SpeechSynthesisVoice[]} */
#voices = [];
constructor() {
if (!supportsSpeech) return;
this.#loadVoices();
// Voices arrive asynchronously, and on some platforms only after this fires.
speechSynthesis.onvoiceschanged = () => {
this.#loadVoices();
this.onVoicesChanged?.();
};
}
/**
* Chrome hands back ~180 voices in no useful order. Sort them so the ones
* you'd actually pick are near the top: your own language first, then
* offline voices ahead of network ones - the latter are useless in the field.
*/
#loadVoices() {
let primary = (navigator.language || 'en').split('-')[0].toLowerCase();
let rank = v => (v.lang.toLowerCase().startsWith(primary) ? 0 : 1);
this.#voices = speechSynthesis.getVoices().slice().sort(
(a, b) =>
rank(a) - rank(b) ||
a.lang.localeCompare(b.lang) ||
Number(b.localService) - Number(a.localService) ||
a.name.localeCompare(b.name)
);
}
get voices() {
return this.#voices;
}
/**
* @param {string} text
* @param {{ interrupt?: boolean }} [options]
*/
say(text, { interrupt = false } = {}) {
if (!supportsSpeech || !this.enabled) return;
// A countdown that queues up behind a stale announcement is worse than
// silence, so anything time-critical clears the queue first.
if (interrupt) speechSynthesis.cancel();
let utterance = new SpeechSynthesisUtterance(text);
let voice = this.#voices.find(v => v.voiceURI === this.voiceURI);
if (voice) utterance.voice = voice;
// Default is slightly quick, so a spoken "three" lands nearer the second
// it names.
utterance.rate = this.rate;
utterance.pitch = this.pitch;
speechSynthesis.speak(utterance);
}
cancel() {
if (supportsSpeech) speechSynthesis.cancel();
}
/** Forget what's been spoken, e.g. when a new sequence starts. */
reset() {
this.#lastSecond = null;
this.#lastNextAt = 0;
this.#lastPhase = 'idle';
this.cancel();
}
/**
* Called on every sequence state update.
* @param {SequenceState} state
*/
update(state) {
if (!this.enabled || !supportsSpeech) {
this.#lastPhase = state.phase;
return;
}
// A new target time means a new frame to count into.
if (state.nextAt !== this.#lastNextAt) {
this.#lastNextAt = state.nextAt;
this.#lastSecond = null;
}
if (state.phase === 'waiting' || state.phase === 'delay') {
// Never start counting from further out than the gap actually is - with a
// 3s interval and a count of 5 you'd otherwise talk over the last frame.
let gapSeconds =
state.phase === 'delay'
? Infinity
: Math.round(state.intervalMs / 1000) - 1;
let limit = Math.max(0, Math.min(this.countFrom, gapSeconds));
let remaining = Math.ceil((state.nextAt - Date.now()) / 1000);
if (remaining >= 1 && remaining <= limit && remaining !== this.#lastSecond) {
this.#lastSecond = remaining;
this.say(String(remaining));
}
}
if (state.phase !== this.#lastPhase) {
this.#announcePhase(state);
this.#lastPhase = state.phase;
}
}
/** @param {SequenceState} state */
#announcePhase(state) {
switch (state.phase) {
case 'capturing':
if (this.announceFrames) {
this.say(
state.total
? `Frame ${state.taken + 1} of ${state.total}`
: `Frame ${state.taken + 1}`
);
}
break;
case 'done':
this.say(
`Sequence complete. ${state.taken} frame${
state.taken === 1 ? '' : 's'
}.`,
{ interrupt: true }
);
break;
case 'stopped':
this.say('Sequence stopped.', { interrupt: true });
break;
case 'failed':
this.say('Sequence failed.', { interrupt: true });
break;
}
}
}