Compare commits
10 Commits
223718d8a8
...
1af4b215b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1af4b215b2 | ||
|
|
ec3f4462b1 | ||
|
|
ca9e5b11e7 | ||
|
|
b4cd4a1adf | ||
|
|
d9741c4c8d | ||
|
|
2beb7fda58 | ||
|
|
8f7524219b | ||
|
|
9f261eda0c | ||
|
|
0d17b35897 | ||
|
|
758b5bb95b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
3
.gitmodules
vendored
@@ -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
|
|
||||||
|
|||||||
@@ -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)"]
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -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
|
||||||
|
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -48,9 +48,61 @@ 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.
|
||||||
|
|
||||||
|
**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 read-only strip showing shutter / aperture / ISO / format / battery / shots remaining, so you can sanity-check exposure against the interval 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
|
||||||
|
- 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 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:
|
||||||

|

|
||||||
|
|
||||||
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).
|
||||||
|
|||||||
13
build/libapi.mjs
generated
13
build/libapi.mjs
generated
File diff suppressed because one or more lines are too long
BIN
build/libapi.wasm
generated
BIN
build/libapi.wasm
generated
Binary file not shown.
1
build/libapi.worker.js
generated
1
build/libapi.worker.js
generated
@@ -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;
|
|
||||||
2
deps/libgphoto2
vendored
2
deps/libgphoto2
vendored
Submodule deps/libgphoto2 updated: 5f5f3311ec...90bf9143d7
1
deps/libusb
vendored
1
deps/libusb
vendored
Submodule deps/libusb deleted from 2db3897d7e
7
examples/preact/_headers
Normal file
7
examples/preact/_headers
Normal 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
|
||||||
17
examples/preact/build-dist.sh
Executable file
17
examples/preact/build-dist.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/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.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
rm -rf dist
|
||||||
|
mkdir -p dist
|
||||||
|
cp index.html _headers dist/
|
||||||
|
cp index.js index-fallback.js home.js settings.js intervalometer.js \
|
||||||
|
storage.js config-utils.js preview.js widget.js dist/
|
||||||
|
|
||||||
|
echo "dist/ contains:"
|
||||||
|
ls -1 dist
|
||||||
@@ -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`
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
109
examples/preact/config-utils.js
Normal file
109
examples/preact/config-utils.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* 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) {
|
||||||
|
return [
|
||||||
|
['Mode', 'autoexposuremode'],
|
||||||
|
['Shutter', 'shutterspeed'],
|
||||||
|
['Aperture', 'aperture'],
|
||||||
|
['ISO', 'iso'],
|
||||||
|
['Format', 'imageformat'],
|
||||||
|
['Battery', 'batterylevel'],
|
||||||
|
['Shots left', 'availableshots'],
|
||||||
|
['Target', 'capturetarget']
|
||||||
|
]
|
||||||
|
.map(([label, name]) => ({ label, value: configValue(config, name) }))
|
||||||
|
.filter(({ value }) => value !== undefined && value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
352
examples/preact/home.js
Normal file
352
examples/preact/home.js
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2021 Google LLC
|
||||||
|
*
|
||||||
|
* This library is free software; you can redistribute it and/or
|
||||||
|
* modify it under the terms of the GNU Lesser General Public
|
||||||
|
* License as published by the Free Software Foundation; either
|
||||||
|
* version 2.1 of the License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This library is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
* Lesser General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Lesser General Public
|
||||||
|
* License along with this library; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { h } from 'preact';
|
||||||
|
import {
|
||||||
|
secondsUntilNext,
|
||||||
|
formatDuration,
|
||||||
|
formatClock
|
||||||
|
} from './intervalometer.js';
|
||||||
|
import {
|
||||||
|
statusReadout,
|
||||||
|
configValue,
|
||||||
|
shutterSpeedSeconds
|
||||||
|
} from './config-utils.js';
|
||||||
|
|
||||||
|
/** @typedef {import('./settings.js').Prefs} Prefs */
|
||||||
|
/** @typedef {import('./intervalometer.js').SequenceState} SequenceState */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Things worth knowing before you walk away from a running camera for two hours.
|
||||||
|
*
|
||||||
|
* @param {Prefs} prefs
|
||||||
|
* @param {import('web-gphoto2').Config | undefined} config
|
||||||
|
* @param {boolean} hasFolder
|
||||||
|
*/
|
||||||
|
function sequenceWarnings(prefs, config, hasFolder) {
|
||||||
|
let warnings = [];
|
||||||
|
|
||||||
|
let format = configValue(config, 'imageformat');
|
||||||
|
let isRaw = typeof format === 'string' && /raw/i.test(format);
|
||||||
|
// Rough per-frame cost on top of the exposure itself: mirror, card write and
|
||||||
|
// the USB 2.0 transfer. A 450D clears a JPEG in about 2s and a RAW in about 6s.
|
||||||
|
let overhead = isRaw ? 6 : 2.5;
|
||||||
|
let exposure = prefs.bulbEnabled
|
||||||
|
? prefs.bulbSeconds
|
||||||
|
: shutterSpeedSeconds(configValue(config, 'shutterspeed'));
|
||||||
|
|
||||||
|
if (exposure !== undefined && exposure >= prefs.intervalSeconds) {
|
||||||
|
warnings.push(
|
||||||
|
`The exposure (${formatDuration(exposure)}) is longer than the ${
|
||||||
|
prefs.intervalSeconds
|
||||||
|
}s interval — every frame will run late.`
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
exposure !== undefined &&
|
||||||
|
prefs.intervalSeconds < exposure + overhead
|
||||||
|
) {
|
||||||
|
warnings.push(
|
||||||
|
`A ${prefs.intervalSeconds}s interval is tight: ${
|
||||||
|
isRaw ? String(format) : 'each frame'
|
||||||
|
} needs roughly ${formatDuration(
|
||||||
|
exposure + overhead
|
||||||
|
)} to shoot and transfer. Expect late frames.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefs.saveMode === 'folder' && !hasFolder) {
|
||||||
|
warnings.push('No output folder chosen yet — pick one in Settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefs.saveMode === 'download' && !prefs.unlimited && prefs.shots > 20) {
|
||||||
|
warnings.push(
|
||||||
|
`${prefs.shots} separate downloads will be slow and noisy. Saving to a folder is much better for a run this long.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefs.bulbEnabled) {
|
||||||
|
warnings.push(
|
||||||
|
'Bulb mode is on: frames stay on the camera card and will not be saved by the browser.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ state: SequenceState, prefs: Prefs }} props
|
||||||
|
*/
|
||||||
|
function SequenceStatus({ 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.';
|
||||||
|
}
|
||||||
|
|
||||||
|
let progress = total ? Math.min(1, taken / total) : 0;
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ class: `sequence-status phase-${phase}` },
|
||||||
|
h('div', { class: 'headline' }, headline),
|
||||||
|
h('div', { class: 'detail' }, detail),
|
||||||
|
total
|
||||||
|
? h(
|
||||||
|
'div',
|
||||||
|
{ class: 'progress' },
|
||||||
|
h('div', { class: 'bar', style: `width: ${progress * 100}%` })
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
state.errors > 0 && phase !== 'failed'
|
||||||
|
? h('div', { class: 'detail warn-text' }, `${state.errors} frame(s) failed`)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }[]
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function Home({
|
||||||
|
prefs,
|
||||||
|
setPref,
|
||||||
|
state,
|
||||||
|
running,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onSingleShot,
|
||||||
|
singleShotStatus,
|
||||||
|
config,
|
||||||
|
hasFolder,
|
||||||
|
canCapture,
|
||||||
|
log
|
||||||
|
}) {
|
||||||
|
let warnings = running ? [] : sequenceWarnings(prefs, config, hasFolder);
|
||||||
|
let plannedSeconds = prefs.unlimited
|
||||||
|
? Infinity
|
||||||
|
: prefs.startDelaySeconds + prefs.shots * prefs.intervalSeconds;
|
||||||
|
let readout = statusReadout(config);
|
||||||
|
|
||||||
|
/** @param {(value: number) => Partial<Prefs>} toPatch */
|
||||||
|
let numberHandler = toPatch => e => {
|
||||||
|
let value = /** @type {HTMLInputElement} */ (e.currentTarget).valueAsNumber;
|
||||||
|
if (Number.isFinite(value)) setPref(toPatch(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ id: 'home' },
|
||||||
|
h(SequenceStatus, { state, prefs }),
|
||||||
|
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'card' },
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'field-grid' },
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'field' },
|
||||||
|
h('span', null, 'Interval'),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'input-with-unit' },
|
||||||
|
h('input', {
|
||||||
|
type: 'number',
|
||||||
|
min: '0.5',
|
||||||
|
step: '0.5',
|
||||||
|
value: prefs.intervalSeconds,
|
||||||
|
disabled: running,
|
||||||
|
onInput: numberHandler(v => ({
|
||||||
|
intervalSeconds: Math.max(0.5, v)
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
h('span', { class: 'unit' }, 'sec')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'field' },
|
||||||
|
h('span', null, 'Frames'),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'input-with-unit' },
|
||||||
|
h('input', {
|
||||||
|
type: 'number',
|
||||||
|
min: '1',
|
||||||
|
step: '1',
|
||||||
|
value: prefs.shots,
|
||||||
|
disabled: running || prefs.unlimited,
|
||||||
|
onInput: numberHandler(v => ({ shots: Math.max(1, Math.round(v)) }))
|
||||||
|
}),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'inline-check' },
|
||||||
|
h('input', {
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: prefs.unlimited,
|
||||||
|
disabled: running,
|
||||||
|
onChange: e =>
|
||||||
|
setPref({ unlimited: e.currentTarget.checked })
|
||||||
|
}),
|
||||||
|
' ∞'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'field' },
|
||||||
|
h('span', null, 'Start delay'),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'input-with-unit' },
|
||||||
|
h('input', {
|
||||||
|
type: 'number',
|
||||||
|
min: '0',
|
||||||
|
step: '1',
|
||||||
|
value: prefs.startDelaySeconds,
|
||||||
|
disabled: running,
|
||||||
|
onInput: numberHandler(v => ({
|
||||||
|
startDelaySeconds: Math.max(0, Math.round(v))
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
h('span', { class: 'unit' }, 'sec')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{ class: 'plan' },
|
||||||
|
prefs.unlimited
|
||||||
|
? `Runs until you stop it, one frame every ${prefs.intervalSeconds}s.`
|
||||||
|
: `${prefs.shots} frames over ${formatDuration(
|
||||||
|
plannedSeconds
|
||||||
|
)} — finishing around ${formatClock(
|
||||||
|
Date.now() + plannedSeconds * 1000
|
||||||
|
)}.`
|
||||||
|
),
|
||||||
|
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'actions' },
|
||||||
|
h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
class: running ? 'danger big' : 'primary big',
|
||||||
|
disabled: !canCapture,
|
||||||
|
onclick: running ? onStop : onStart
|
||||||
|
},
|
||||||
|
running ? '⏹ Stop' : '▶ Start sequence'
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
class: 'secondary',
|
||||||
|
disabled: !canCapture || running || singleShotStatus !== 'idle',
|
||||||
|
onclick: onSingleShot
|
||||||
|
},
|
||||||
|
singleShotStatus === 'busy' ? '⌛ Capturing…' : '📷 Single shot'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
warnings.map(text =>
|
||||||
|
h('p', { key: text, class: 'notice warn' }, '⚠ ', text)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
readout.length
|
||||||
|
? h(
|
||||||
|
'div',
|
||||||
|
{ class: 'readout' },
|
||||||
|
readout.map(({ label, value }) =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ key: label, class: 'readout-item' },
|
||||||
|
h('span', { class: 'readout-label' }, label),
|
||||||
|
h('span', { class: 'readout-value' }, String(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
log.length
|
||||||
|
? h(
|
||||||
|
'div',
|
||||||
|
{ class: 'log' },
|
||||||
|
log.map(entry =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ key: entry.id, class: `log-entry ${entry.kind}` },
|
||||||
|
h('span', { class: 'log-time' }, formatClock(entry.at)),
|
||||||
|
h('span', null, entry.message)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +1,648 @@
|
|||||||
<!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
|
<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"
|
||||||
/>
|
/>
|
||||||
<style>
|
<style>
|
||||||
.center-parent {
|
:root {
|
||||||
display: flex;
|
color-scheme: dark light;
|
||||||
height: 100vh;
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--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 .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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions .big {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.fine-print {
|
||||||
margin: 1px;
|
max-width: 46ch;
|
||||||
width: 20ch;
|
margin: 16px auto 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
#config label {
|
@media (max-width: 900px) {
|
||||||
width: 40%;
|
main {
|
||||||
}
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(180px, 38vh) minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
#config input,
|
#home {
|
||||||
#config select {
|
border-left: none;
|
||||||
width: 50%;
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
@@ -64,7 +669,10 @@
|
|||||||
</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>'
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="center">⌛ Loading…</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
* 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 } 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 } 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';
|
||||||
|
|
||||||
export const isDebug = new URLSearchParams(location.search).has('debug');
|
export const isDebug = new URLSearchParams(location.search).has('debug');
|
||||||
|
|
||||||
@@ -29,37 +32,97 @@ if (isDebug) {
|
|||||||
await import('preact/debug');
|
await import('preact/debug');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {number} ms */
|
||||||
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
/** @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();
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
this.saver.mode = this.state.prefs.saveMode;
|
||||||
|
|
||||||
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.
|
// Try to connect to camera at startup.
|
||||||
// If none is found among saved connections, it will fallback to a picker.
|
// If none is found among saved connections, it will fallback to a picker.
|
||||||
this.setState({ type: 'Status', message: '⌛ Loading...' });
|
|
||||||
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 {Partial<import('./settings.js').Prefs>} patch */
|
||||||
|
setPref = patch => {
|
||||||
|
this.setState(({ prefs }) => {
|
||||||
|
let next = { ...prefs, ...patch };
|
||||||
|
savePrefs(next);
|
||||||
|
this.saver.mode = next.saveMode;
|
||||||
|
if (!next.keepAwake) this.wakeLock.release();
|
||||||
|
return { prefs: next };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
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,142 +134,333 @@ 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') ||
|
||||||
let triggerCapture;
|
configValue(this.state.config, 'model') ||
|
||||||
if (supportedOps.captureImage) {
|
'camera'}.`
|
||||||
triggerCapture = () => camera.captureImageAsFile();
|
);
|
||||||
}
|
}
|
||||||
// We should reach this only once.
|
|
||||||
while (this.camera) {
|
async refreshConfig() {
|
||||||
try {
|
if (!this.camera) return;
|
||||||
let config = await this.camera.getConfig();
|
try {
|
||||||
if (!isDebug) {
|
this.setState({ config: await this.camera.getConfig() });
|
||||||
delete config.children.actions;
|
} catch (err) {
|
||||||
delete config.children.other;
|
rethrowIfCritical(err);
|
||||||
}
|
console.error('Could not refresh config:', err);
|
||||||
this.setState({
|
|
||||||
type: 'Config',
|
|
||||||
config,
|
|
||||||
capturePreview,
|
|
||||||
triggerCapture
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
rethrowIfCritical(err);
|
|
||||||
console.error('Could not refresh config:', err);
|
|
||||||
}
|
|
||||||
while (true) {
|
|
||||||
await new Promise(resolve =>
|
|
||||||
requestIdleCallback(resolve, { timeout: 500 })
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
let hadEvents = await this.camera.consumeEvents();
|
|
||||||
if (hadEvents) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
rethrowIfCritical(err);
|
|
||||||
console.error('Could not consume events:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the specified config value.
|
* 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 =>
|
||||||
|
requestIdleCallback(resolve, { timeout: 1000 })
|
||||||
|
);
|
||||||
|
if (!this.state.settingsOpen) break;
|
||||||
|
if (this.intervalometer.running) continue;
|
||||||
|
try {
|
||||||
|
if (await this.camera.consumeEvents()) {
|
||||||
|
await this.refreshConfig();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
rethrowIfCritical(err);
|
||||||
|
console.error('Could not consume events:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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.setState({ seq });
|
||||||
|
if (this.#wasRunning && !running) {
|
||||||
|
this.wakeLock.release();
|
||||||
|
this.refreshConfig();
|
||||||
|
}
|
||||||
|
this.#wasRunning = running;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
class: 'icon-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(
|
|
||||||
'p',
|
|
||||||
null,
|
|
||||||
"Don't know how you got here? Check out the ",
|
|
||||||
h(
|
|
||||||
'a',
|
|
||||||
{ href: 'https://web.dev/porting-libusb-to-webusb/' },
|
|
||||||
'blog post'
|
|
||||||
),
|
|
||||||
' or the ',
|
|
||||||
h(
|
|
||||||
'a',
|
|
||||||
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
|
|
||||||
'repo'
|
|
||||||
),
|
|
||||||
'!'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
case 'Status':
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{ class: 'center-parent' },
|
|
||||||
h('div', { class: 'center' }, state.message)
|
|
||||||
);
|
|
||||||
case 'Config':
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{ class: 'pure-g' },
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'pure-u-2-3' },
|
|
||||||
h(Preview, {
|
|
||||||
getPreview: state.capturePreview
|
|
||||||
})
|
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'div',
|
'p',
|
||||||
{ id: 'config', class: 'pure-u-1-3' },
|
{ class: 'fine-print' },
|
||||||
|
'Requires Chrome with WebUSB. On Linux you may need a udev rule, and on macOS you may need to quit Photos/Image Capture first. Built on ',
|
||||||
h(
|
h(
|
||||||
'form',
|
'a',
|
||||||
{ class: 'pure-form pure-form-aligned' },
|
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
|
||||||
h(
|
'web-gphoto2'
|
||||||
'fieldset',
|
),
|
||||||
null,
|
'.'
|
||||||
state.triggerCapture
|
|
||||||
? h(CaptureButton, { getFile: state.triggerCapture })
|
|
||||||
: undefined,
|
|
||||||
' ',
|
|
||||||
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 })
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'ready': {
|
||||||
|
let running = this.intervalometer.running;
|
||||||
|
let previewSupported = state.supportedOps?.capturePreview;
|
||||||
|
let showPreview = state.prefs.livePreview && previewSupported;
|
||||||
|
return h(
|
||||||
|
Fragment,
|
||||||
|
null,
|
||||||
|
this.renderHeader(),
|
||||||
|
h(
|
||||||
|
'main',
|
||||||
|
null,
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ id: 'viewer' },
|
||||||
|
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(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
|
||||||
|
})
|
||||||
|
),
|
||||||
|
h(SettingsDrawer, {
|
||||||
|
open: state.settingsOpen,
|
||||||
|
onClose: this.toggleSettings,
|
||||||
|
prefs: state.prefs,
|
||||||
|
setPref: this.setPref,
|
||||||
|
config: state.config,
|
||||||
|
setValue: this.setValue,
|
||||||
|
folderName: this.saver.folderName,
|
||||||
|
chooseFolder: this.chooseFolder,
|
||||||
|
bulbSupport: this.bulbSupport,
|
||||||
|
locked: running
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return h('div', { class: 'center' }, state.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render(h(App, null), document.body);
|
hydrate(h(App, null), document.body);
|
||||||
|
|||||||
267
examples/preact/intervalometer.js
Normal file
267
examples/preact/intervalometer.js
Normal 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
328
examples/preact/settings.js
Normal file
328
examples/preact/settings.js
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
/*
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
export class SettingsDrawer extends Component {
|
||||||
|
#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
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
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: '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,
|
||||||
|
|
||||||
|
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: '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, '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 below instead (up to 30s on the 450D).'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
h(
|
||||||
|
'section',
|
||||||
|
null,
|
||||||
|
h('h3', null, 'Camera'),
|
||||||
|
config
|
||||||
|
? h(
|
||||||
|
'form',
|
||||||
|
{ class: 'pure-form pure-form-aligned', onSubmit: e => e.preventDefault() },
|
||||||
|
h(Widget, { config, setValue })
|
||||||
|
)
|
||||||
|
: h('p', { class: 'notice' }, 'Reading camera configuration…')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
examples/preact/storage.js
Normal file
182
examples/preact/storage.js
Normal 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?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
examples/preact/types.d.ts
vendored
29
examples/preact/types.d.ts
vendored
@@ -1,9 +1,20 @@
|
|||||||
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';
|
||||||
|
};
|
||||||
|
|||||||
10
examples/preact/wrangler.jsonc
Normal file
10
examples/preact/wrangler.jsonc
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user