Compare commits

...

15 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
Jon
1af4b215b2 Turn the demo app into a Canon 450D intervalometer
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Replaces the gPhoto2 demo UI (live view beside the raw config tree) with a
timelapse intervalometer. The home screen carries only what changes between
runs - interval, frame count, start delay, start/stop, countdown, progress
and a log - while every camera setting moves into a settings drawer.

- intervalometer.js: schedules frames on an absolute grid (start + n *
  interval) so transfer time doesn't accumulate as drift over a long run. An
  overrun logs and fires as soon as the camera is free rather than dropping a
  frame; three consecutive failures abort.
- storage.js: frames stream into a folder via the File System Access API,
  named for capture time (20260801-172713_00001.JPG) so sorting by name is
  sorting by time. Falls back to downloads. Also holds the screen wake lock,
  since background tabs get their timers throttled.
- config-utils.js: config tree lookups, shutter speed parsing, and bulb
  capability detection (bulb toggle or Canon eosremoterelease).
- home.js: sequence controls plus a read-only exposure readout and pre-flight
  warnings when the interval can't fit the exposure and transfer.
- settings.js / index.js: app prefs and the full config tree behind a drawer.
  Config polling now only runs while that drawer is open, leaving the USB link
  to the captures during a sequence.

Live view stays up between frames and steps aside only while the shutter
fires, which is what the EOS driver requires; it recovers afterwards with
backoff instead of hammering a busy camera.

Deployed as an assets-only Cloudflare Worker. The WASM is built with pthreads
and allocates a shared WebAssembly.Memory, so _headers reproduces the COOP/COEP
pair from serve.json - without cross-origin isolation the app fails to start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-01 18:47:04 +01:00
Ingvar Stepanyan
ec3f4462b1 Provide Loading message sooner instead of blank page 2024-12-06 01:07:58 +00:00
Ingvar Stepanyan
ca9e5b11e7 Get rid of center-parent 2024-12-06 01:05:30 +00:00
Ingvar Stepanyan
b4cd4a1adf Fix dlpreopen versions 2024-12-05 19:08:00 +00:00
Ingvar Stepanyan
d9741c4c8d Rebuild 2024-12-05 16:50:07 +00:00
Ingvar Stepanyan
2beb7fda58 Upgrade libgphoto2 2024-12-05 15:44:26 +00:00
Ingvar Stepanyan
8f7524219b Use libusb from a tarball
Now that Wasm support is included in release tarballs, we don't need to include libusb as a submodule anymore.
2024-12-05 15:31:53 +00:00
Ingvar Stepanyan
9f261eda0c Upgrade Emscripten 2024-12-05 14:55:04 +00:00
Ingvar Stepanyan
0d17b35897 Use em-config to calculate the path 2023-11-15 00:51:18 +00:00
Ingvar Stepanyan
758b5bb95b Upgrade Emscripten 2023-11-15 00:36:38 +00:00
30 changed files with 3762 additions and 242 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
/.vscode/c_cpp_properties.json /.vscode/c_cpp_properties.json
node_modules node_modules
/examples/preact/dist
/examples/preact/.wrangler
/deps/* /deps/*
!/deps/libgphoto2 !/deps/libgphoto2
!/deps/libusb
/src/api.o /src/api.o

3
.gitmodules vendored
View File

@@ -2,6 +2,3 @@
path = deps/libgphoto2 path = deps/libgphoto2
url = https://github.com/RReverser/libgphoto2 url = https://github.com/RReverser/libgphoto2
branch = em branch = em
[submodule "libusb"]
path = deps/libusb
url = https://github.com/libusb/libusb

View File

@@ -1,7 +1,4 @@
FROM emscripten/emsdk:3.1.47 FROM emscripten/emsdk:3.1.73
RUN apt-get update && apt-get install -qqy autoconf autopoint pkg-config libtool libtool-bin RUN apt-get update && apt-get install -qqy autoconf autopoint pkg-config libtool libtool-bin
# Include fix from https://github.com/emscripten-core/emscripten/pull/20452.
# Remove when 3.1.48 is released.
RUN emsdk install releases-e7f0e42ac993b2a9e0416cd35e6f4bc57519a0db-64bit
WORKDIR /src WORKDIR /src
CMD ["sh", "-c", "emmake make -j`nproc`"] CMD ["sh", "-c", "emmake make -j$(nproc)"]

View File

@@ -1,13 +1,10 @@
SYSROOT = /emsdk/upstream/emscripten/cache/sysroot SYSROOT := $(shell em-config CACHE)/sysroot
# Add custom sysroot to library & macro search paths. # Add custom sysroot to library & macro search paths.
export LDFLAGS += -L$(SYSROOT)/lib export LDFLAGS += -L$(SYSROOT)/lib
export ACLOCAL_PATH := $(SYSROOT)/share/aclocal:$(ACLOCAL_PATH)
# Common linking flags for all targets. # Common linking flags for all targets.
export LDFLAGS += -s DYNAMIC_EXECUTION=0 -s AUTO_JS_LIBRARIES=0 -s AUTO_NATIVE_LIBRARIES=0 export LDFLAGS += -s DYNAMIC_EXECUTION=0 -s AUTO_JS_LIBRARIES=0 -s AUTO_NATIVE_LIBRARIES=0
# Temporary workaround for https://github.com/emscripten-core/emscripten/issues/16836.
export LDFLAGS += -Wl,-u,ntohs
# Common compilation & linking flags for all langs and targets. # Common compilation & linking flags for all langs and targets.
COMMON_FLAGS = -Os -flto COMMON_FLAGS = -Os -flto
@@ -20,8 +17,8 @@ export LDFLAGS += $(COMMON_FLAGS)
build/libapi.mjs: src/api.o $(SYSROOT)/lib/libltdl.la $(SYSROOT)/lib/libgphoto2.la build/libapi.mjs: src/api.o $(SYSROOT)/lib/libltdl.la $(SYSROOT)/lib/libgphoto2.la
libtool --verbose --mode=link $(LD) $(LDFLAGS) -o $@ $+ \ libtool --verbose --mode=link $(LD) $(LDFLAGS) -o $@ $+ \
-fexceptions --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH -s ENVIRONMENT=web,worker \ -fexceptions --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH -s ENVIRONMENT=web,worker \
-dlpreopen $(SYSROOT)/lib/libgphoto2/2.5.28.1/ptp2.la \ -dlpreopen $(SYSROOT)/lib/libgphoto2/2.5.31.1/ptp2.la \
-dlpreopen $(SYSROOT)/lib/libgphoto2_port/0.12.0/usb1.la -dlpreopen $(SYSROOT)/lib/libgphoto2_port/0.12.2/usb1.la
src/api.o: deps/libgphoto2/configure.ac src/api.o: deps/libgphoto2/configure.ac
src/api.o: CPPFLAGS += -Ideps/libgphoto2 -Ideps/libgphoto2/libgphoto2_port src/api.o: CPPFLAGS += -Ideps/libgphoto2 -Ideps/libgphoto2/libgphoto2_port
@@ -52,7 +49,11 @@ $(SYSROOT)/lib/libltdl.la: deps/libtool/Makefile | $(SYSROOT)
## libusb ## libusb
deps/libusb/Makefile: CONFIGURE_ARGS = --host=wasm32 deps/libusb/configure:
mkdir -p deps/libusb
curl -L https://github.com/libusb/libusb/releases/download/v1.0.27/libusb-1.0.27.tar.bz2 | tar jx --strip 1 -C deps/libusb
deps/libusb/Makefile: CONFIGURE_ARGS = --host=wasm32-emscripten
$(SYSROOT)/lib/libusb-1.0.la: deps/libusb/Makefile $(SYSROOT)/lib/libusb-1.0.la: deps/libusb/Makefile
$(MAKE) -C deps/libusb install $(MAKE) -C deps/libusb install
@@ -60,7 +61,7 @@ $(SYSROOT)/lib/libusb-1.0.la: deps/libusb/Makefile
## libgphoto2 ## libgphoto2
deps/libgphoto2/Makefile: | $(SYSROOT)/lib/libusb-1.0.la deps/libgphoto2/Makefile: | $(SYSROOT)/lib/libusb-1.0.la
deps/libgphoto2/Makefile: CONFIGURE_ARGS = --host=wasm32 \ deps/libgphoto2/Makefile: CONFIGURE_ARGS = --host=wasm32-emscripten \
--without-libxml-2.0 --disable-nls --disable-ptpip --disable-disk \ --without-libxml-2.0 --disable-nls --disable-ptpip --disable-disk \
--with-camlibs=ptp2 --with-camlibs=ptp2

View File

@@ -48,9 +48,76 @@ a.href = URL.createObjectURL(file);
a.download = file.name; a.download = file.name;
``` ```
## Demo ## Intervalometer app
This repository also contains a [demo app](https://web.dev/porting-libusb-to-webusb/) running gPhoto2 on the Web: `examples/preact` is an intervalometer for shooting timelapses, built around a Canon EOS 450D but not specific to it — anything the PTP driver supports will work.
```bash
cd examples/preact
npm install
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. 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:
- **Interval**, **frames** (or ∞ for open-ended) and **start delay**
- 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
- 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
**Settings drawer** (⚙, top right) — everything else, including the full gPhoto2 config tree, so the home screen stays uncluttered:
- 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)
- 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`
- The camera's own settings: shutter, aperture, ISO, image format, capture target, drive mode, and so on
### Deploying
Deployed as an assets-only Cloudflare Worker:
```bash
cd examples/preact
./build-dist.sh # copies just the browser files into dist/
npx wrangler deploy
```
Live at <https://dslr-intervalometer.bournemouthtech.workers.dev>.
`build-dist.sh` is an explicit allowlist rather than an ignore file, because everything in the assets directory becomes publicly readable and `.assetsignore` was not excluding `node_modules`.
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:
- 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.
- Three failed captures in a row abort the sequence.
- Config polling only runs while the settings drawer is open; during a sequence the USB link is left to the captures.
- Keep the tab in the foreground. Chrome throttles timers hard in hidden tabs, which will stretch your intervals.
- **Bulb is experimental.** Bulb frames are written to the camera's card and are *not* downloaded — the WASM API only returns files produced by an explicit `captureImageAsFile`, and there's no hook for images arriving any other way. Set the capture target to the memory card and pull the card afterwards. For exposures of 30s or less, just set the shutter speed normally and leave bulb off.
## Original demo
`examples/preact` started out as the upstream gPhoto2-on-the-Web demo — a live view next to the raw camera config tree — and the intervalometer above replaced it. The original is still hosted and documented upstream:
![A picture of DSLR camera connected via a USB cable to a laptop. The laptop is running the Web demo mentioned in the article, which mirrors a live video feed from the camera as well as allows to tweak its settings via form controls.](https://web-dev.imgix.net/image/9oK23mr86lhFOwKaoYZ4EySNFp02/MR4YGRvl0Z9AWT6vv3sQ.jpg?auto=format&w=1600) ![A picture of DSLR camera connected via a USB cable to a laptop. The laptop is running the Web demo mentioned in the article, which mirrors a live video feed from the camera as well as allows to tweak its settings via form controls.](https://web-dev.imgix.net/image/9oK23mr86lhFOwKaoYZ4EySNFp02/MR4YGRvl0Z9AWT6vv3sQ.jpg?auto=format&w=1600)
For the detailed technical write-up, see [the official blog post](https://web.dev/porting-libusb-to-webusb/). To see the demo in action, visit the hosted version [here](https://web-gphoto2.rreverser.com/) (but make sure to read the [cross-platform compatibility notes](https://web.dev/porting-libusb-to-webusb/#important-cross-platform-compatibility-notes) first). For the detailed technical write-up, see [the official blog post](https://web.dev/porting-libusb-to-webusb/). To see the demo in action, visit the hosted version [here](https://web-gphoto2.rreverser.com/) (but make sure to read the [cross-platform compatibility notes](https://web.dev/porting-libusb-to-webusb/#important-cross-platform-compatibility-notes) first).

11
build/libapi.mjs generated

File diff suppressed because one or more lines are too long

BIN
build/libapi.wasm generated

Binary file not shown.

View File

@@ -1 +0,0 @@
"use strict";var Module={};var initializedJS=false;function threadPrintErr(){var text=Array.prototype.slice.call(arguments).join(" ");console.error(text)}function threadAlert(){var text=Array.prototype.slice.call(arguments).join(" ");postMessage({cmd:"alert",text:text,threadId:Module["_pthread_self"]()})}var err=threadPrintErr;self.alert=threadAlert;Module["instantiateWasm"]=(info,receiveInstance)=>{var module=Module["wasmModule"];Module["wasmModule"]=null;var instance=new WebAssembly.Instance(module,info);return receiveInstance(instance)};self.onunhandledrejection=e=>{throw e.reason||e};function handleMessage(e){try{if(e.data.cmd==="load"){let messageQueue=[];self.onmessage=e=>messageQueue.push(e);self.startWorker=instance=>{Module=instance;postMessage({"cmd":"loaded"});for(let msg of messageQueue){handleMessage(msg)}self.onmessage=handleMessage};Module["wasmModule"]=e.data.wasmModule;for(const handler of e.data.handlers){Module[handler]=(...args)=>{postMessage({cmd:"callHandler",handler:handler,args:args})}}Module["wasmMemory"]=e.data.wasmMemory;Module["buffer"]=Module["wasmMemory"].buffer;Module["ENVIRONMENT_IS_PTHREAD"]=true;(e.data.urlOrBlob?import(e.data.urlOrBlob):import("./libapi.mjs")).then(exports=>exports.default(Module))}else if(e.data.cmd==="run"){Module["__emscripten_thread_init"](e.data.pthread_ptr,0,0,1);Module["__emscripten_thread_mailbox_await"](e.data.pthread_ptr);Module["establishStackSpace"]();Module["PThread"].receiveObjectTransfer(e.data);Module["PThread"].threadInitTLS();if(!initializedJS){Module["__embind_initialize_bindings"]();initializedJS=true}try{Module["invokeEntryPoint"](e.data.start_routine,e.data.arg)}catch(ex){if(ex!="unwind"){throw ex}}}else if(e.data.cmd==="cancel"){if(Module["_pthread_self"]()){Module["__emscripten_thread_exit"](-1)}}else if(e.data.target==="setimmediate"){}else if(e.data.cmd==="checkMailbox"){if(initializedJS){Module["checkMailbox"]()}}else if(e.data.cmd){err(`worker.js received unknown command ${e.data.cmd}`);err(e.data)}}catch(ex){if(Module["__emscripten_thread_crashed"]){Module["__emscripten_thread_crashed"]()}throw ex}}self.onmessage=handleMessage;

1
deps/libusb vendored

Submodule deps/libusb deleted from 2db3897d7e

7
examples/preact/_headers Normal file
View File

@@ -0,0 +1,7 @@
# The gphoto2 WASM module is built with pthreads and allocates a *shared*
# WebAssembly.Memory, which needs SharedArrayBuffer, which browsers only hand
# to a cross-origin isolated page. Without these two headers the app doesn't
# degrade - it fails to start. Same pair as serve.json uses locally.
/*
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

41
examples/preact/build-dist.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/sh
# Assemble exactly what gets published to Cloudflare.
#
# An allowlist rather than an .assetsignore denylist: everything that lands in
# dist/ becomes publicly readable, so it should be a deliberate list, not
# 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
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
mkdir -p dist
# shellcheck disable=SC2086
cp $ASSETS $MODULES 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:"
ls -1 dist

View File

@@ -1,56 +0,0 @@
/*
* 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, Fragment } from 'preact';
/** @extends Component<{ getFile: () => Promise<File> }, { status: string }> */
export class CaptureButton extends Component {
state = { status: '📷' };
handleCapture = async () => {
this.setState({ status: '⌛' });
try {
let file = await this.props.getFile();
let url = URL.createObjectURL(file);
Object.assign(document.createElement('a'), {
download: file.name,
href: url
}).click();
URL.revokeObjectURL(url);
this.setState({ status: '📷' });
} catch (err) {
console.error(err);
this.setState({ status: '❌' });
}
};
render() {
return h(
Fragment,
null,
h('input', {
type: 'button',
id: 'capture',
disabled: this.state.status !== '📷',
class: 'pure-button',
onclick: this.handleCapture,
value: `${this.state.status} Capture image`
})
);
}
}

View File

@@ -0,0 +1,169 @@
/*
* 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('web-gphoto2').Config} Config */
/**
* Depth-first lookup of a config node by its gphoto2 name.
* @param {Config | undefined} config
* @param {string} name
* @returns {Config | undefined}
*/
export function findConfig(config, name) {
if (!config) return undefined;
if (config.name === name) return config;
if (config.type !== 'window' && config.type !== 'section') return undefined;
for (let child of Object.values(config.children)) {
let found = findConfig(child, name);
if (found) return found;
}
return undefined;
}
/**
* Read the current value of a config node, if it exists.
* @param {Config | undefined} config
* @param {string} name
*/
export function configValue(config, name) {
let node = findConfig(config, name);
return node && 'value' in node ? node.value : undefined;
}
/**
* The read-only summary shown at the bottom of the home screen. These are the
* names the Canon EOS driver (450D included) exposes; anything missing is
* simply skipped, so this stays useful on other bodies too.
* @param {Config | undefined} config
*/
export function statusReadout(config, exclude = []) {
return [
['Mode', 'autoexposuremode'],
['Shutter', 'shutterspeed'],
['Aperture', 'aperture'],
['ISO', 'iso'],
['Format', 'imageformat'],
['Battery', 'batterylevel'],
['Shots left', 'availableshots'],
['Target', 'capturetarget']
]
.filter(([, name]) => !exclude.includes(name))
.map(([label, name]) => ({ label, value: configValue(config, name) }))
.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
* seconds. Returns undefined when it can't be parsed.
* @param {unknown} value
*/
export function shutterSpeedSeconds(value) {
if (typeof value !== 'string') return undefined;
let str = value.trim().toLowerCase();
if (str === 'bulb' || str === '') return undefined;
let fraction = /^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/.exec(str);
if (fraction) {
let denominator = Number(fraction[2]);
return denominator ? Number(fraction[1]) / denominator : undefined;
}
let seconds = Number(str.replace(/s$/, ''));
return Number.isFinite(seconds) ? seconds : undefined;
}
/**
* How the camera can be held open for a long exposure, if at all.
*
* `bulb` is a plain toggle on many bodies; Canon EOS bodies instead drive the
* shutter through `eosremoterelease`. Returns null when neither is available.
* @param {Config | undefined} config
* @returns {{ kind: 'bulb' } | { kind: 'eosremoterelease', press: string, release: string } | null}
*/
export function detectBulbSupport(config) {
let bulb = findConfig(config, 'bulb');
if (bulb && bulb.type === 'toggle' && !bulb.readonly) {
return { kind: 'bulb' };
}
let remote = findConfig(config, 'eosremoterelease');
if (remote && remote.type === 'menu' && !remote.readonly) {
let press = remote.choices.find(c => /^press full/i.test(c));
let release = remote.choices.find(c => /^release full/i.test(c));
if (press && release) {
return { kind: 'eosremoterelease', press, release };
}
}
return null;
}

579
examples/preact/home.js Normal file
View File

@@ -0,0 +1,579 @@
/*
* 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
);
}

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

@@ -1,43 +1,871 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>gphoto2 on the Web</title> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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>
.center-parent { /* Dark is the base. The script above always stamps an explicit
display: flex; data-theme, so there's no prefers-color-scheme query here to fight
height: 100vh; with an override the user has chosen. */
:root,
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #14161a;
--panel: #1c1f26;
--panel-2: #242832;
--line: #333846;
--text: #e8eaee;
--muted: #9aa3b2;
--accent: #4f9cf9;
--accent-text: #06121f;
--danger: #e05252;
--warn: #e8b64c;
--ok: #4fbf7b;
--radius: 10px;
} }
:root[data-theme='light'] {
color-scheme: light;
--bg: #f2f4f7;
--panel: #ffffff;
--panel-2: #f7f8fa;
--line: #d9dee6;
--text: #1a1d23;
--muted: #5c6675;
--accent: #1f6fd0;
--accent-text: #ffffff;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif;
overflow: hidden;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
}
a {
color: var(--accent);
}
/* ---------- header ---------- */
#app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
#app-header .brand {
display: flex;
align-items: center;
gap: 12px;
}
#app-header .header-actions {
display: flex;
align-items: center;
gap: 2px;
}
#app-header .logo {
font-size: 24px;
}
#app-header h1 {
font-size: 17px;
}
#app-header small {
color: var(--muted);
font-size: 12px;
}
/* ---------- layout ---------- */
main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 400px;
}
#viewer {
position: relative;
min-height: 0;
background: #000;
display: flex;
}
#viewer .center-parent {
position: relative;
flex: 1;
display: flex;
min-height: 0;
}
#viewer canvas {
margin: auto;
max-width: 100%;
max-height: 100%;
}
.preview-overlay {
position: absolute;
inset: auto 0 0 0;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
text-align: center;
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 {
min-height: 0;
overflow-y: auto;
padding: 14px;
border-left: 1px solid var(--line);
background: var(--bg);
display: flex;
flex-direction: column;
gap: 12px;
}
/* Nothing in this column may be squashed to fit - it scrolls instead.
(`overflow: hidden` on .readout would otherwise let flex shrink it to
zero height, since that zeroes the automatic minimum size.) */
#home > * {
flex: none;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px;
}
/* ---------- sequence status ---------- */
.sequence-status {
background: var(--panel);
border: 1px solid var(--line);
border-left: 4px solid var(--line);
border-radius: var(--radius);
padding: 14px;
}
.sequence-status.phase-waiting,
.sequence-status.phase-delay,
.sequence-status.phase-capturing {
border-left-color: var(--accent);
}
.sequence-status.phase-done {
border-left-color: var(--ok);
}
.sequence-status.phase-failed {
border-left-color: var(--danger);
}
.sequence-status.phase-stopped {
border-left-color: var(--warn);
}
.sequence-status .headline {
font-size: 22px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sequence-status .detail {
color: var(--muted);
font-size: 13px;
margin-top: 2px;
}
.warn-text {
color: var(--warn) !important;
}
.progress {
margin-top: 10px;
height: 6px;
border-radius: 3px;
background: var(--panel-2);
overflow: hidden;
}
.progress .bar {
height: 100%;
background: var(--accent);
transition: width 0.2s linear;
}
/* ---------- fields ---------- */
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
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 {
display: flex;
flex-direction: column;
gap: 4px;
}
.field > span {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.input-with-unit {
display: flex;
align-items: center;
gap: 6px;
}
.unit {
color: var(--muted);
font-size: 12px;
}
.inline-check {
display: flex;
align-items: center;
gap: 2px;
color: var(--muted);
font-size: 14px;
white-space: nowrap;
}
/* The config tree renders text widgets as a bare <input> with no type
attribute, so match on what it isn't - a typed selector list silently
misses those and leaves them with the browser's default light chrome. */
input:not([type='checkbox']):not([type='radio']):not([type='button']),
select {
min-width: 0;
width: 100%;
padding: 6px 8px;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
font: inherit;
font-variant-numeric: tabular-nums;
}
/* Dropdown lists are painted by the OS, not the page. */
option {
background: var(--panel-2);
color: var(--text);
}
input:disabled,
select:disabled {
opacity: 0.55;
}
/* Values the camera reports but won't let you change. */
input[readonly],
select[readonly] {
background: transparent;
border-color: transparent;
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 {
margin: 12px 0 0;
font-size: 13px;
color: var(--muted);
}
/* ---------- buttons ---------- */
button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
padding: 8px 14px;
cursor: pointer;
}
button:hover:not(:disabled) {
border-color: var(--accent);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
button.danger {
background: var(--danger);
border-color: var(--danger);
color: #fff;
font-weight: 600;
}
button.big {
padding: 12px 18px;
font-size: 16px;
}
button.icon-button {
background: none;
border: none;
font-size: 20px;
padding: 4px 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
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 {
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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
/* Cell borders rather than a gap over a coloured backdrop, so a partly
filled last row doesn't leave a stray block of grid line. */
.readout-item {
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 8px 10px;
display: flex;
flex-direction: column;
min-width: 0;
}
.readout-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.readout-value {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log {
font-size: 12px;
border-top: 1px solid var(--line);
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.log-entry {
display: flex;
gap: 8px;
color: var(--muted);
}
.log-entry.warn {
color: var(--warn);
}
.log-entry.error {
color: var(--danger);
}
.log-time {
font-variant-numeric: tabular-nums;
opacity: 0.7;
flex: none;
}
.notice {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 8px;
background: var(--panel-2);
color: var(--muted);
font-size: 12.5px;
}
.notice.warn {
color: var(--warn);
background: rgba(232, 182, 76, 0.1);
}
.tag {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--panel-2);
color: var(--muted);
border-radius: 4px;
padding: 2px 6px;
vertical-align: middle;
}
/* ---------- settings drawer ---------- */
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s, visibility 0.2s;
z-index: 5;
}
.scrim.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
#settings {
position: fixed;
top: 0;
right: 0;
height: 100%;
width: min(440px, 100%);
background: var(--panel);
border-left: 1px solid var(--line);
transform: translateX(100%);
/* `visibility` keeps the closed drawer out of the tab order and the
accessibility tree; it flips only once the slide-out finishes. */
visibility: hidden;
transition: transform 0.2s ease, visibility 0.2s;
display: flex;
flex-direction: column;
z-index: 6;
}
#settings.open {
transform: none;
visibility: visible;
}
#settings header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
#settings h2 {
font-size: 16px;
}
#settings h3 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
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 {
flex: 1;
overflow-y: auto;
padding: 14px;
}
.drawer-body section {
margin-bottom: 22px;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.setting-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.setting-label small {
color: var(--muted);
font-size: 11.5px;
}
.setting-control {
flex: none;
display: flex;
align-items: center;
gap: 6px;
min-width: 140px;
justify-content: flex-end;
}
/* Needs the #settings prefix to outrank the generic input width above. */
#settings .setting-control input[type='number'] {
width: 80px;
}
/* camera config tree (rendered with purecss classes) */
#settings fieldset {
border: 1px solid var(--line);
border-radius: 8px;
margin: 0 0 10px;
padding: 6px 10px 10px;
}
#settings legend {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
#settings .pure-control-group {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
}
#settings .pure-control-group label {
flex: 1;
font-size: 13px;
margin: 0;
}
/* Specific enough to beat purecss's own .pure-form control styling,
which is built for a light page. */
#settings .pure-control-group input:not([type='checkbox']),
#settings .pure-control-group select {
flex: none;
width: 48%;
/* purecss pins selects to 2.25em, which clips descenders once our own
padding is added. */
height: auto;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
box-shadow: none;
}
#settings .pure-control-group input[readonly],
#settings .pure-control-group select[readonly] {
background: transparent;
border-color: transparent;
color: var(--muted);
}
#settings .pure-control-group input[type='checkbox'] {
flex: none;
width: auto;
}
/* ---------- misc ---------- */
.center { .center {
margin: auto; margin: auto;
text-align: center; text-align: center;
padding: 20px;
} }
#config { .muted {
overflow-y: auto; color: var(--muted);
max-height: 100vh;
padding-left: 5px;
box-sizing: border-box;
border-left: 3px solid #777;
} }
#config .pure-button { @keyframes spin {
margin: 1px; to {
width: 20ch; transform: rotate(360deg);
}
} }
#config label { @media (prefers-reduced-motion: reduce) {
width: 40%; .mini-spinner {
animation-duration: 2.4s;
}
} }
#config input, .fine-print {
#config select { max-width: 46ch;
width: 50%; margin: 16px auto 0;
font-size: 12.5px;
color: var(--muted);
}
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 38vh) minmax(0, 1fr);
overflow: hidden;
}
#home {
border-left: none;
border-top: 1px solid var(--line);
}
} }
</style> </style>
<script type="importmap"> <script type="importmap">
@@ -64,7 +892,21 @@
</script> </script>
<link <link
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>
<div class="center">⌛ Loading…</div>
</body>
</html> </html>

View File

@@ -16,11 +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, render, Component } from 'preact'; import { h, hydrate, Component, Fragment, createRef } from 'preact';
import { CaptureButton } from './capture-button.js';
import { Camera, rethrowIfCritical } from 'web-gphoto2'; import { Camera, rethrowIfCritical } from 'web-gphoto2';
import { Preview } from './preview.js'; import { Preview } from './preview.js';
import { Widget } from './widget.js'; import { Home, sequenceSummary } from './home.js';
import { SettingsDrawer, loadPrefs, savePrefs } from './settings.js';
import { Intervalometer, formatDuration } from './intervalometer.js';
import { FrameSaver, WakeLock } from './storage.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');
@@ -29,37 +33,178 @@ if (isDebug) {
await import('preact/debug'); await import('preact/debug');
} }
/** @param {number} 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} */
camera; camera;
saver = new FrameSaver();
wakeLock = new WakeLock();
narrator = new Narrator();
intervalometer = new Intervalometer({
capture: index => this.captureFrame(index),
onChange: seq => this.handleSequenceChange(seq),
log: (message, kind) => this.log(message, kind)
});
// Make sure that first render hydrates the existing HTML smoothly.
/** @type {AppState} */
state = {
view: 'status',
message: '⌛ Loading…',
prefs: loadPrefs(),
seq: this.intervalometer.state,
log: [],
settingsOpen: false,
singleShotStatus: 'idle'
};
#logId = 0;
#configWatchToken = 0;
#wasRunning = false;
viewerRef = createRef();
componentDidMount() { componentDidMount() {
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.setState({ this.log(`Uncaught error: ${message}`, 'error')
type: 'Status',
message: `${message}`
})
); );
// Closing the tab halfway through a long sequence is an expensive mistake.
addEventListener('beforeunload', e => {
if (this.intervalometer.running) {
e.preventDefault();
e.returnValue = '';
}
});
addEventListener( addEventListener(
'beforeunload', 'pagehide',
() => { () => {
if (!this.camera) return; if (!this.camera) return;
this.intervalometer.stop();
this.camera.disconnect(); this.camera.disconnect();
this.camera = undefined; this.camera = undefined;
}, },
{ once: true } { once: true }
); );
// Try to connect to camera at startup.
// If none is found among saved connections, it will fallback to a picker. // Try the camera once at startup; if it isn't among the connections the
this.setState({ type: 'Status', message: '⌛ Loading...' }); // browser already knows about, fall back to the picker.
this.tryToConnectToCamera(); this.tryToConnectToCamera();
} }
/**
* @param {string} message
* @param {'info' | 'warn' | 'error'} [kind]
*/
log(message, kind = 'info') {
if (kind === 'error') console.error(message);
this.setState(({ log }) => ({
log: [
{ id: ++this.#logId, message, kind, at: Date.now() },
...log
].slice(0, 8)
}));
}
/** @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 */
setPref = patch => {
this.setState(({ prefs }) => {
let next = { ...prefs, ...patch };
savePrefs(next);
this.saver.mode = next.saveMode;
this.syncNarrator(next);
if (next.keepAwake) this.wakeLock.acquire();
else this.wakeLock.release();
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();
this.setState({ type: 'Status', message: '⌛ Connecting...' }); this.setState({ view: 'status', message: '⌛ Connecting' });
await this.tryToConnectToCamera(); await this.tryToConnectToCamera();
}; };
@@ -71,45 +216,48 @@ class App extends Component {
await camera.connect(); await camera.connect();
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
this.setState({ type: 'CameraPicker' }); this.setState({ view: 'picker' });
return; return;
} }
this.camera = camera; this.camera = camera;
let supportedOps = await camera.getSupportedOps(); let supportedOps = await camera.getSupportedOps();
let capturePreview; this.setState({ view: 'ready', supportedOps });
if (supportedOps.capturePreview) { await this.refreshConfig();
capturePreview = () => camera.capturePreviewAsBlob(); this.log(
`Connected to ${configValue(this.state.config, 'cameramodel') ||
configValue(this.state.config, 'model') ||
'camera'}.`
);
} }
let triggerCapture;
if (supportedOps.captureImage) { async refreshConfig() {
triggerCapture = () => camera.captureImageAsFile(); if (!this.camera) return;
}
// We should reach this only once.
while (this.camera) {
try { try {
let config = await this.camera.getConfig(); this.setState({ config: await this.camera.getConfig() });
if (!isDebug) {
delete config.children.actions;
delete config.children.other;
}
this.setState({
type: 'Config',
config,
capturePreview,
triggerCapture
});
} catch (err) { } catch (err) {
rethrowIfCritical(err); rethrowIfCritical(err);
console.error('Could not refresh config:', err); console.error('Could not refresh config:', err);
} }
while (true) { }
/**
* Poll the camera for changes made on the body itself (dial turns, etc).
*
* Unlike the original demo this only runs while the settings drawer is open:
* an unattended timelapse doesn't benefit from a constant stream of config
* reads, and every one of them is a USB round-trip competing with captures.
*/
async watchConfig() {
let token = ++this.#configWatchToken;
while (this.camera && token === this.#configWatchToken) {
await new Promise(resolve => await new Promise(resolve =>
requestIdleCallback(resolve, { timeout: 500 }) requestIdleCallback(resolve, { timeout: 1000 })
); );
if (!this.state.settingsOpen) break;
if (this.intervalometer.running) continue;
try { try {
let hadEvents = await this.camera.consumeEvents(); if (await this.camera.consumeEvents()) {
if (hadEvents) { await this.refreshConfig();
break;
} }
} catch (err) { } catch (err) {
rethrowIfCritical(err); rethrowIfCritical(err);
@@ -117,96 +265,359 @@ class App extends Component {
} }
} }
} }
toggleSettings = async () => {
let settingsOpen = !this.state.settingsOpen;
this.setState({ settingsOpen });
if (settingsOpen) {
await this.refreshConfig();
this.watchConfig();
} else {
this.#configWatchToken++;
} }
};
/** /**
* Set the specified config value. * Set the specified config value, then re-read the tree.
*
* Setting one value often changes others (and the camera may round or reject
* what you asked for), so don't wait for the event loop to notice - it only
* runs while this drawer is open, and not at all mid-sequence.
*
* @param {string} name * @param {string} name
* @param {*} value * @param {*} value
*/ */
setValue = async (name, value) => this.camera?.setConfigValue(name, value); setValue = async (name, value) => {
if (!this.camera) return;
await this.camera.setConfigValue(name, value);
await this.refreshConfig();
};
get bulbSupport() {
return detectBulbSupport(this.state.config);
}
/**
* One frame of the sequence.
* @param {number} index
*/
async captureFrame(index) {
if (!this.camera) throw new Error('Camera is not connected');
if (this.state.prefs.bulbEnabled && this.bulbSupport) {
return this.bulbExposure();
}
let file = await this.camera.captureImageAsFile();
await this.saver.save(file, index);
}
/**
* Hold the shutter open for the configured duration.
*
* The resulting frame is written by the camera to its own storage - the WASM
* API only hands back files produced by an explicit `captureImageAsFile`, so
* there's nothing for us to download here.
*/
async bulbExposure() {
let support = this.bulbSupport;
if (!support) throw new Error('Bulb is not supported by this camera');
let { bulbSeconds } = this.state.prefs;
let open = () =>
support.kind === 'bulb'
? this.setValue('bulb', true)
: this.setValue('eosremoterelease', support.press);
let close = () =>
support.kind === 'bulb'
? this.setValue('bulb', false)
: this.setValue('eosremoterelease', support.release);
await open();
try {
await wait(bulbSeconds * 1000);
} finally {
await close();
}
}
/** @param {import('./intervalometer.js').SequenceState} seq */
handleSequenceChange(seq) {
let running = this.intervalometer.running;
this.narrator.update(seq);
this.setState({ seq });
if (this.#wasRunning && !running) {
// The wake lock stays - it's app-wide now, not sequence-scoped.
this.refreshConfig();
}
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 () => {
try {
let name = await this.saver.chooseFolder();
this.log(`Saving frames to “${name}”.`);
this.forceUpdate();
} catch (err) {
if (/** @type {Error} */ (err).name !== 'AbortError') {
this.log(`Could not open that folder: ${err}`, 'error');
}
}
};
startSequence = async () => {
let { prefs } = this.state;
if (prefs.saveMode === 'folder') {
if (!this.saver.hasFolder) {
await this.chooseFolder();
if (!this.saver.hasFolder) return;
}
if (!(await this.saver.ensureWritable())) {
this.log('Write permission for the output folder was denied.', 'error');
return;
}
}
if (prefs.keepAwake) await this.wakeLock.acquire();
let count = prefs.unlimited ? 0 : prefs.shots;
this.log(
`${count || '∞'} frames, one every ${formatDuration(
prefs.intervalSeconds
)}${
prefs.startDelaySeconds
? `, starting in ${formatDuration(prefs.startDelaySeconds)}`
: ''
}.`
);
this.narrator.reset();
this.narrator.say('Starting');
this.intervalometer.start({
intervalMs: prefs.intervalSeconds * 1000,
count,
startDelayMs: prefs.startDelaySeconds * 1000
});
};
stopSequence = () => {
this.intervalometer.stop();
};
singleShot = async () => {
if (!this.camera) return;
this.setState({ singleShotStatus: 'busy' });
try {
let file = await this.camera.captureImageAsFile();
let saved = await this.saver.save(file);
this.log(
saved ? `📷 Saved ${saved}.` : `📷 Captured ${file.name} (not saved).`
);
} catch (err) {
rethrowIfCritical(err);
this.log(`Capture failed: ${/** @type {Error} */ (err).message}`, 'error');
} finally {
this.setState({ singleShotStatus: 'idle' });
this.refreshConfig();
}
};
renderHeader() {
let model =
configValue(this.state.config, 'cameramodel') ||
configValue(this.state.config, 'model');
return h(
'header',
{ id: 'app-header' },
h(
'div',
{ class: 'brand' },
h('span', { class: 'logo' }, '⏱'),
h(
'div',
null,
h('h1', null, 'Intervalometer'),
h('small', null, model ? String(model) : 'DSLR over WebUSB')
)
),
h(
'div',
{ class: 'header-actions' },
h(
'button',
{
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'
},
'⚙'
)
)
);
}
render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) { render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) {
switch (state.type) { switch (state.view) {
case 'CameraPicker': case 'picker':
return h( return h(
'div', 'div',
{ class: 'center-parent' }, { class: 'center' },
h('h1', null, '⏱ Intervalometer'),
h('p', null, 'Connect your Canon 450D over USB and switch it on.'),
h( h(
'div', 'button',
{ { type: 'button', class: 'primary big', onclick: this.selectDevice },
class: 'center' '🔍 Select camera'
}, ),
h('input', {
type: 'button',
onclick: this.selectDevice,
value: '🔍 Select camera'
}),
h( h(
'p', 'p',
null, { class: 'fine-print' },
"Don't know how you got here? Check out the ", '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(
'a',
{ href: 'https://web.dev/porting-libusb-to-webusb/' },
'blog post'
),
' or the ',
h( h(
'a', 'a',
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' }, { href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
'repo' 'web-gphoto2'
), ),
'!' '.'
)
) )
); );
case 'Status':
case 'ready': {
let running = this.intervalometer.running;
let previewSupported = state.supportedOps?.capturePreview;
let showPreview = state.prefs.livePreview && previewSupported;
return h( return h(
'div', Fragment,
{ class: 'center-parent' }, null,
h('div', { class: 'center' }, state.message) this.renderHeader(),
); h(
case 'Config': 'main',
return h( null,
'div',
{ class: 'pure-g' },
h( h(
'div', 'div',
{ class: 'pure-u-2-3' }, {
h(Preview, { id: 'viewer',
getPreview: state.capturePreview ref: this.viewerRef,
class: state.fullscreen ? 'is-fullscreen' : ''
},
showPreview
? h(Preview, {
getPreview: () => this.camera.capturePreviewAsBlob(),
// Between frames the feed stays up; it only steps aside for
// the shutter itself, which the camera needs it to anyway.
paused:
running &&
(state.prefs.liveViewBetweenFrames
? state.seq.phase === 'capturing'
: true),
pausedMessage: state.prefs.liveViewBetweenFrames
? '📸 Taking the shot…'
: 'Live view paused for the whole sequence'
})
: h(
'div',
{ class: 'center muted' },
previewSupported
? 'Live view is turned off in Settings.'
: '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, {
prefs: state.prefs,
setPref: this.setPref,
state: state.seq,
running,
onStart: this.startSequence,
onStop: this.stopSequence,
onSingleShot: this.singleShot,
singleShotStatus: state.singleShotStatus,
config: state.config,
hasFolder: this.saver.hasFolder,
canCapture: !!state.supportedOps?.captureImage,
log: state.log,
voiceSupported: supportsSpeech,
onToggleVoice: this.toggleVoice,
setValue: this.setValue
}) })
), ),
h( h(SettingsDrawer, {
'div', open: state.settingsOpen,
{ id: 'config', class: 'pure-u-1-3' }, onClose: this.toggleSettings,
h( prefs: state.prefs,
'form', setPref: this.setPref,
{ class: 'pure-form pure-form-aligned' }, config: state.config,
h( setValue: this.setValue,
'fieldset', folderName: this.saver.folderName,
null, chooseFolder: this.chooseFolder,
state.triggerCapture bulbSupport: this.bulbSupport,
? h(CaptureButton, { getFile: state.triggerCapture }) locked: running,
: undefined, voices: this.narrator.voices,
' ', testVoice: this.testVoice
h( })
'a',
{
class: 'pure-button',
href: 'https://github.com/GoogleChromeLabs/web-gphoto2',
target: '_blank'
},
'⭐ Star on Github'
)
),
h(Widget, { config: state.config, setValue: this.setValue })
)
)
); );
} }
default:
return h('div', { class: 'center' }, state.message);
}
} }
} }
render(h(App, null), document.body); hydrate(h(App, null), document.body);

View File

@@ -0,0 +1,267 @@
/*
* 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 {'idle' | 'delay' | 'waiting' | 'capturing' | 'done' | 'stopped' | 'failed'} Phase
*
* @typedef {object} SequenceState
* @property {Phase} phase
* @property {number} taken Frames successfully captured so far.
* @property {number} total Requested frame count, 0 for unlimited.
* @property {number} errors Failed captures so far.
* @property {number} nextAt Timestamp of the next scheduled frame (0 if none).
* @property {number} startedAt Timestamp the sequence was started (0 if idle).
* @property {number} finishedAt Timestamp the sequence ended (0 while running).
* @property {number} intervalMs
*/
/** @type {SequenceState} */
const INITIAL_STATE = {
phase: 'idle',
taken: 0,
total: 0,
errors: 0,
nextAt: 0,
startedAt: 0,
finishedAt: 0,
intervalMs: 0
};
/** Abort three failures in a row - at that point something is properly wrong. */
const MAX_CONSECUTIVE_ERRORS = 3;
class Aborted extends Error {}
/**
* Resolve at an absolute timestamp, or reject with `Aborted` if the signal
* fires first. Resolves immediately if the deadline has already passed.
* @param {number} timestamp
* @param {AbortSignal} signal
*/
function sleepUntil(timestamp, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) return reject(new Aborted());
let remaining = timestamp - Date.now();
if (remaining <= 0) return resolve(undefined);
let onAbort = () => {
clearTimeout(timer);
reject(new Aborted());
};
let timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve(undefined);
}, remaining);
signal.addEventListener('abort', onAbort, { once: true });
});
}
/**
* Drives a timed sequence of captures.
*
* Frames are scheduled on an absolute grid (`start + n * interval`) rather than
* by sleeping for `interval` after each frame, so download 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 reported, rather
* than silently dropping a frame.
*/
export class Intervalometer {
/** @type {SequenceState} */
state = INITIAL_STATE;
/** @type {AbortController | null} */
#abort = null;
/** @type {ReturnType<typeof setInterval> | undefined} */
#ticker;
/**
* @param {object} handlers
* @param {(index: number) => Promise<void>} handlers.capture
* @param {(state: SequenceState) => void} handlers.onChange Called on every state change and ~5x/s while running, for the countdown.
* @param {(message: string, kind?: 'info' | 'warn' | 'error') => void} handlers.log
*/
constructor({ capture, onChange, log }) {
this.#capture = capture;
this.#onChange = onChange;
this.#log = log;
}
get running() {
return this.#abort !== null;
}
/**
* @param {object} params
* @param {number} params.intervalMs Time between the start of consecutive frames.
* @param {number} params.count Number of frames, or 0 for unlimited.
* @param {number} params.startDelayMs Delay before the first frame.
*/
start({ intervalMs, count, startDelayMs }) {
if (this.running) return;
this.#abort = new AbortController();
this.#update({
...INITIAL_STATE,
phase: startDelayMs > 0 ? 'delay' : 'waiting',
total: count,
intervalMs,
startedAt: Date.now()
});
// Keep pushing state while running so the UI countdown stays live.
this.#ticker = setInterval(() => this.#onChange(this.state), 200);
this.#run({ intervalMs, count, startDelayMs }, this.#abort.signal);
}
stop() {
this.#abort?.abort();
}
reset() {
if (this.running) return;
this.#update(INITIAL_STATE);
}
/**
* @param {{ intervalMs: number, count: number, startDelayMs: number }} params
* @param {AbortSignal} signal
*/
async #run({ intervalMs, count, startDelayMs }, signal) {
let firstAt = Date.now() + startDelayMs;
let consecutiveErrors = 0;
/** @type {Phase} */
let finalPhase = 'done';
try {
for (let index = 0; count === 0 || index < count; index++) {
let scheduledAt = firstAt + index * intervalMs;
this.#update({
phase: index === 0 && startDelayMs > 0 ? 'delay' : 'waiting',
nextAt: scheduledAt
});
await sleepUntil(scheduledAt, signal);
let lateBy = Date.now() - scheduledAt;
if (lateBy > 250 && index > 0) {
this.#log(
`Frame ${index + 1} is ${(lateBy / 1000).toFixed(
1
)}s late - the interval is shorter than capture + download takes.`,
'warn'
);
}
this.#update({ phase: 'capturing' });
try {
await this.#capture(index);
consecutiveErrors = 0;
this.#update({ taken: this.state.taken + 1 });
} catch (err) {
if (signal.aborted) throw new Aborted();
consecutiveErrors++;
this.#update({ errors: this.state.errors + 1 });
this.#log(
`Frame ${index + 1} failed: ${
/** @type {Error} */ (err).message || err
}`,
'error'
);
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
this.#log(
`Stopped after ${consecutiveErrors} failures in a row.`,
'error'
);
finalPhase = 'failed';
break;
}
}
}
} catch (err) {
if (err instanceof Aborted) {
finalPhase = 'stopped';
} else {
finalPhase = 'failed';
this.#log(
`Sequence aborted: ${/** @type {Error} */ (err).message || err}`,
'error'
);
// Anything that isn't a graceful abort is worth surfacing in the console too.
console.error(err);
}
} finally {
clearInterval(this.#ticker);
this.#ticker = undefined;
this.#abort = null;
this.#update({
phase: finalPhase,
nextAt: 0,
finishedAt: Date.now()
});
let summary = `${this.state.taken} frame${
this.state.taken === 1 ? '' : 's'
} captured`;
if (finalPhase === 'done') this.#log(`✅ Sequence complete - ${summary}.`);
if (finalPhase === 'stopped') this.#log(`⏹ Stopped - ${summary}.`);
if (finalPhase === 'failed') this.#log(`❌ Sequence failed - ${summary}.`, 'error');
}
}
/** @param {Partial<SequenceState>} patch */
#update(patch) {
this.state = { ...this.state, ...patch };
this.#onChange(this.state);
}
#capture;
#onChange;
#log;
}
/**
* @param {SequenceState} state
* @returns {number} Seconds until the next frame, floored at 0.
*/
export function secondsUntilNext(state) {
if (!state.nextAt) return 0;
return Math.max(0, (state.nextAt - Date.now()) / 1000);
}
/**
* Human-readable duration, e.g. "1h 04m 30s".
* @param {number} seconds
*/
export function formatDuration(seconds) {
if (!Number.isFinite(seconds)) return '∞';
seconds = Math.max(0, Math.round(seconds));
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds % 3600) / 60);
let secs = seconds % 60;
if (hours) {
return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(
secs
).padStart(2, '0')}s`;
}
if (minutes) return `${minutes}m ${String(secs).padStart(2, '0')}s`;
return `${secs}s`;
}
/** @param {number} timestamp */
export function formatClock(timestamp) {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}

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

@@ -27,21 +27,48 @@ const Stats = isDebug
) )
: null; : null;
/** @extends Component<{ getPreview?: () => Promise<Blob> }, { error?: string }> */ /** @param {number} ms */
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
/**
* How long to let the camera settle before asking for live view again after a
* pause. A still capture drops the EOS out of live view entirely, and asking
* too soon just earns a "device busy".
*/
const RESUME_DELAY_MS = 500;
/**
* @extends Component<{
* getPreview?: () => Promise<Blob>,
* paused?: boolean,
* pausedMessage?: string
* }, { restoring?: boolean }>
*/
export class Preview extends Component { export class Preview extends Component {
canvasHolderRef = createRef(); canvasHolderRef = createRef();
canvasRef = createRef(); canvasRef = createRef();
/** @type {ResizeObserver} */ /** @type {ResizeObserver} */
resizeObserver; resizeObserver;
stats = isDebug ? new Stats() : null; stats = isDebug ? new Stats() : null;
state = { restoring: false };
render(
/** @type {Preview['props']} */ props,
/** @type {Preview['state']} */ state
) {
let overlay = props.paused
? props.pausedMessage || '⏸ Live view paused'
: state.restoring
? '⌛ Restoring live view…'
: undefined;
render() {
return h( return h(
'div', 'div',
{ class: 'center-parent', ref: this.canvasHolderRef }, { class: 'center-parent', ref: this.canvasHolderRef },
!this.props.getPreview !props.getPreview
? h('div', { class: 'center' }, `Preview is unsupported`) ? h('div', { class: 'center' }, `Preview is unsupported`)
: h('canvas', { class: 'center', ref: this.canvasRef }) : h('canvas', { class: 'center', ref: this.canvasRef }),
overlay ? h('div', { class: 'preview-overlay' }, overlay) : undefined
); );
} }
@@ -86,7 +113,21 @@ export class Preview extends Component {
// I have no idea why, but if we connect too soon, it just hangs... // I have no idea why, but if we connect too soon, it just hangs...
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise(resolve => setTimeout(resolve, 1500));
let failures = 0;
let resuming = false;
while (this.canvasRef.current) { while (this.canvasRef.current) {
// Paused while the shutter actually fires - live view and capture share
// one USB link, and the camera drops out of live view to take the shot.
if (this.props.paused) {
resuming = true;
await sleep(200);
continue;
}
if (resuming) {
resuming = false;
await sleep(RESUME_DELAY_MS);
}
try { try {
let blob = await this.props.getPreview(); let blob = await this.props.getPreview();
@@ -107,9 +148,19 @@ export class Preview extends Component {
} }
await new Promise(resolve => requestAnimationFrame(resolve)); await new Promise(resolve => requestAnimationFrame(resolve));
canvasCtx.transferFromImageBitmap(img); canvasCtx.transferFromImageBitmap(img);
if (failures) {
failures = 0;
this.setState({ restoring: false });
}
} catch (err) { } catch (err) {
rethrowIfCritical(err); rethrowIfCritical(err);
console.error('Could not refresh preview:', err); // Right after a capture the camera reports busy for a beat while the
// driver spins live view back up, so back off instead of hammering it -
// retrying flat out here is what keeps the feed down.
if (!failures) console.warn('Could not refresh preview:', err);
failures++;
if (failures === 3) this.setState({ restoring: true });
await sleep(Math.min(1500, 150 * failures));
} }
this.stats?.update(); this.stats?.update();
} }

580
examples/preact/settings.js Normal file
View File

@@ -0,0 +1,580 @@
/*
* 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, Fragment } from 'preact';
import { Widget } from './widget.js';
import { supportsFolderSaving } from './storage.js';
import { supportsSpeech } from './voice.js';
const PREFS_KEY = 'web-dslr.prefs';
export const DEFAULT_PREFS = {
// Home screen sequence parameters.
intervalSeconds: 10,
shots: 120,
unlimited: false,
startDelaySeconds: 0,
// Everything below lives in this drawer.
saveMode: /** @type {'folder' | 'download' | 'none'} */ (
supportsFolderSaving ? 'folder' : 'download'
),
livePreview: true,
// Renamed from `previewDuringSequence` (which defaulted to off) so saved
// prefs from before pick up the new default rather than the old behaviour.
liveViewBetweenFrames: true,
keepAwake: true,
bulbEnabled: false,
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 */
/** @returns {Prefs} */
export function loadPrefs() {
try {
let stored = localStorage.getItem(PREFS_KEY);
// Spread over the defaults so prefs added in a later version fill in.
return stored ? { ...DEFAULT_PREFS, ...JSON.parse(stored) } : { ...DEFAULT_PREFS };
} catch (err) {
console.warn('Could not read saved settings:', err);
return { ...DEFAULT_PREFS };
}
}
/** @param {Prefs} prefs */
export function savePrefs(prefs) {
try {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
} catch (err) {
console.warn('Could not persist settings:', err);
}
}
/**
* 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
*/
function Row({ label, hint, children }) {
return h(
'div',
{ class: 'setting-row' },
h(
'div',
{ class: 'setting-label' },
h('span', null, label),
hint ? h('small', null, hint) : undefined
),
h('div', { class: 'setting-control' }, children)
);
}
/**
* Slide-over panel holding everything that isn't interval timing: where frames
* go, preview behaviour, bulb, and the camera's own config tree.
*
* @extends Component<{
* open: boolean,
* onClose: () => void,
* prefs: Prefs,
* setPref: (patch: Partial<Prefs>) => void,
* config: import('web-gphoto2').Config | undefined,
* setValue: (name: string, value: any) => Promise<void>,
* folderName: string | undefined,
* chooseFolder: () => void,
* bulbSupport: ReturnType<typeof import('./config-utils.js').detectBulbSupport>,
* locked: boolean,
* voices: SpeechSynthesisVoice[],
* testVoice: () => void
* }>
*/
export class SettingsDrawer extends Component {
/** Selected tab: 'app', or the gphoto2 name of a config section. */
state = { tab: 'app' };
#onKeyDown = (/** @type {KeyboardEvent} */ e) => {
if (e.key === 'Escape' && this.props.open) this.props.onClose();
};
componentDidMount() {
addEventListener('keydown', this.#onKeyDown);
}
componentWillUnmount() {
removeEventListener('keydown', this.#onKeyDown);
}
render(/** @type {SettingsDrawer['props']} */ props) {
let {
open,
onClose,
prefs,
setPref,
config,
setValue,
folderName,
chooseFolder,
bulbSupport,
locked,
voices,
testVoice
} = 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(
Fragment,
null,
h('div', {
class: `scrim ${open ? 'open' : ''}`,
onclick: onClose
}),
// Visibility (and therefore focus order / screen reader exposure) is
// driven by the `open` class in CSS rather than aria-hidden.
h(
'aside',
{
id: 'settings',
class: open ? 'open' : ''
},
h(
'header',
null,
h('h2', null, 'Settings'),
h(
'button',
{ type: 'button', class: 'icon-button', onclick: onClose, title: 'Close settings' },
'✕'
)
),
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(
'div',
{ class: 'drawer-body' },
locked
? h(
'p',
{ class: 'notice warn' },
'A sequence is running. Changing camera settings mid-run is allowed, but each change costs a USB round-trip and may delay a frame.'
)
: 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(
'section',
null,
h('h3', null, 'Where frames go'),
h(
Row,
{
label: 'Save frames to',
hint:
prefs.saveMode === 'none'
? 'Frames are still transferred off the camera, just not written to disk.'
: undefined
},
h(
'select',
{
value: prefs.saveMode,
onChange: e => setPref({ saveMode: e.currentTarget.value })
},
supportsFolderSaving
? h('option', { value: 'folder' }, 'A folder on this computer')
: undefined,
h('option', { value: 'download' }, 'Downloads (one file at a time)'),
h('option', { value: 'none' }, "Don't save in the browser")
)
),
prefs.saveMode === 'folder'
? h(
Row,
{
label: 'Output folder',
hint: 'Frames are prefixed with a zero-padded index so they sort in order.'
},
h(
'button',
{ type: 'button', class: 'secondary', onclick: chooseFolder },
folderName ? `📁 ${folderName}` : '📁 Choose folder…'
)
)
: undefined,
prefs.saveMode === 'none'
? h(
'p',
{ class: 'notice' },
'Set the camera\'s capture target to the memory card below if you want to keep the frames at all.'
)
: undefined
),
h(
'section',
null,
h('h3', null, 'Live view'),
h(
Row,
{
label: 'Show live preview',
hint: 'Live view on the 450D warms the sensor and drains the battery.'
},
h('input', {
type: 'checkbox',
checked: prefs.livePreview,
onChange: e => setPref({ livePreview: e.currentTarget.checked })
})
),
h(
Row,
{
label: 'Live view between frames',
hint: 'Keeps the feed up during a sequence, dropping it only while each shot fires. Turn off to leave the USB link entirely to the captures.'
},
h('input', {
type: 'checkbox',
checked: prefs.liveViewBetweenFrames,
disabled: !prefs.livePreview,
onChange: e =>
setPref({ liveViewBetweenFrames: e.currentTarget.checked })
})
),
h(
Row,
{
label: 'Keep screen awake',
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', {
type: 'checkbox',
checked: prefs.keepAwake,
disabled: !('wakeLock' in navigator),
onChange: e => setPref({ keepAwake: e.currentTarget.checked })
})
)
),
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(
'section',
null,
h('h3', null, 'Bulb exposures ', h('span', { class: 'tag' }, 'experimental')),
bulbSupport
? h(
Fragment,
null,
h(
Row,
{
label: 'Use bulb for each frame',
hint: `Driven via ${
bulbSupport.kind === 'bulb' ? 'the bulb toggle' : 'eosremoterelease'
}. Put the mode dial on B first.`
},
h('input', {
type: 'checkbox',
checked: prefs.bulbEnabled,
onChange: e => setPref({ bulbEnabled: e.currentTarget.checked })
})
),
h(
Row,
{ label: 'Exposure length' },
h('input', {
type: 'number',
min: '1',
step: '1',
value: prefs.bulbSeconds,
onChange: e =>
setPref({
bulbSeconds: Math.max(1, e.currentTarget.valueAsNumber || 1)
})
}),
h('span', { class: 'unit' }, 'sec')
),
prefs.bulbEnabled
? h(
'p',
{ class: 'notice warn' },
'Bulb frames are written to the camera card and are ',
h('strong', null, 'not'),
' downloaded to the browser — the WASM API has no hook for files that arrive outside of a normal capture. Set the capture target to the memory card and pull the card afterwards.'
)
: undefined
)
: h(
'p',
{ class: 'notice' },
'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).'
)
),
!config
? h('p', { class: 'notice' }, 'Reading camera configuration…')
: undefined
)
)
)
);
}
}

182
examples/preact/storage.js Normal file
View File

@@ -0,0 +1,182 @@
/*
* 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
*/
export const supportsFolderSaving = 'showDirectoryPicker' in globalThis;
/** @param {number} n */
const pad2 = n => String(n).padStart(2, '0');
/**
* Name a frame after the moment it was taken: `20260801-172713.JPG`, or
* `20260801-172713_00007.JPG` within a sequence. Sorting by name is then the
* same as sorting by capture time, which is what every timelapse tool wants,
* and the index keeps sub-second intervals from colliding.
*
* The camera's own name (IMG_1234) is dropped - it wraps around at 9999 and
* restarts on a card format, so it's no basis for ordering a long run.
*
* @param {string} originalName Used only for its extension (.JPG, .CR2, …).
* @param {number} [index] Frame index within a sequence.
* @param {Date} [at]
*/
export function frameName(originalName, index, at = new Date()) {
let stamp =
`${at.getFullYear()}${pad2(at.getMonth() + 1)}${pad2(at.getDate())}` +
`-${pad2(at.getHours())}${pad2(at.getMinutes())}${pad2(at.getSeconds())}`;
let ext = /\.[a-z0-9]+$/i.exec(originalName)?.[0] ?? '.jpg';
return index === undefined
? `${stamp}${ext}`
: `${stamp}_${String(index + 1).padStart(5, '0')}${ext}`;
}
/**
* Writes captured frames somewhere useful.
*
* A few hundred `<a download>` clicks is a miserable way to land a timelapse on
* disk (Chrome prompts for "download multiple files" and every frame goes to
* the same Downloads folder), so the default is the File System Access API:
* pick a folder once, then frames stream straight into it with a zero-padded
* index prefix so they sort in shooting order.
*/
export class FrameSaver {
/** @type {'folder' | 'download' | 'none'} */
mode = 'download';
/** @type {FileSystemDirectoryHandle | null} */
#dir = null;
get folderName() {
return this.#dir?.name;
}
get hasFolder() {
return this.#dir !== null;
}
/**
* Prompt for an output folder. Returns the folder name, or undefined if the
* user dismissed the picker.
*/
async chooseFolder() {
// @ts-ignore - not in the default DOM lib yet.
let dir = await globalThis.showDirectoryPicker({ mode: 'readwrite' });
this.#dir = dir;
return dir.name;
}
forgetFolder() {
this.#dir = null;
}
/**
* Make sure we still hold write permission - Chrome can drop it between
* sessions or after a long idle period, and we'd rather find out before the
* sequence starts than 200 frames in.
*/
async ensureWritable() {
if (this.mode !== 'folder') return true;
if (!this.#dir) return false;
let dir = /** @type {any} */ (this.#dir);
if ((await dir.queryPermission({ mode: 'readwrite' })) === 'granted') {
return true;
}
return (await dir.requestPermission({ mode: 'readwrite' })) === 'granted';
}
/**
* @param {File} file
* @param {number} [index] Frame index within a sequence.
* @returns {Promise<string | undefined>} The name it was saved under.
*/
async save(file, index) {
if (this.mode === 'none') return;
let name = frameName(file.name, index);
if (this.mode === 'folder') {
await this.#saveToFolder(file, name);
} else {
this.#download(file, name);
}
return name;
}
/**
* @param {File} file
* @param {string} name
*/
async #saveToFolder(file, name) {
if (!this.#dir) throw new Error('No output folder selected');
let handle = await this.#dir.getFileHandle(name, { create: true });
let writable = await handle.createWritable();
try {
await writable.write(file);
} finally {
await writable.close();
}
}
/**
* @param {File} file
* @param {string} name
*/
#download(file, name) {
let url = URL.createObjectURL(file);
Object.assign(document.createElement('a'), {
download: name,
href: url
}).click();
// Revoking synchronously can race the download in some Chrome versions.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
}
/**
* Holds a screen wake lock for the duration of a sequence. Chrome throttles
* timers hard in backgrounded tabs, which would wreck the interval timing, so
* keeping the display (and tab) alive matters here.
*/
export class WakeLock {
/** @type {any} */
#lock = null;
async acquire() {
if (this.#lock || !('wakeLock' in navigator)) return;
try {
// @ts-ignore
this.#lock = await navigator.wakeLock.request('screen');
this.#lock.addEventListener('release', () => {
this.#lock = null;
});
// Chrome drops the lock whenever the tab is hidden; re-take it on return.
document.addEventListener('visibilitychange', this.#reacquire);
} catch (err) {
console.warn('Could not acquire wake lock:', err);
}
}
#reacquire = () => {
if (document.visibilityState === 'visible' && !this.#lock) {
this.acquire();
}
};
async release() {
document.removeEventListener('visibilitychange', this.#reacquire);
let lock = this.#lock;
this.#lock = null;
await lock?.release?.();
}
}

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

@@ -1,9 +1,24 @@
type AppState = type LogEntry = {
| { type: 'CameraPicker' } id: number;
| { type: 'Status'; message: string } message: string;
| { kind: 'info' | 'warn' | 'error';
type: 'Config'; at: number;
config: import('web-gphoto2').Config; };
capturePreview: (() => Promise<Blob>) | undefined;
triggerCapture: (() => Promise<File>) | undefined; type AppState = {
/** Which top-level screen is showing. */
view: 'status' | 'picker' | 'ready';
/** Shown while `view` is 'status'. */
message?: string;
config?: import('web-gphoto2').Config;
supportedOps?: import('web-gphoto2').SupportedOps;
prefs: import('./settings.js').Prefs;
seq: import('./intervalometer.js').SequenceState;
log: LogEntry[];
settingsOpen: boolean;
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;
}
}
}

View File

@@ -0,0 +1,10 @@
{
// Assets-only Worker: no script, Cloudflare just serves these files.
// Run ./build-dist.sh first, then `npx wrangler deploy` from examples/preact.
"$schema": "node_modules/wrangler/config-schema.json",
"name": "dslr-intervalometer",
"compatibility_date": "2026-08-01",
"assets": {
"directory": "./dist"
}
}