Turn the demo app into a Canon 450D intervalometer
Some checks failed
CI / build-and-deploy (push) Has been cancelled

Replaces the gPhoto2 demo UI (live view beside the raw config tree) with a
timelapse intervalometer. The home screen carries only what changes between
runs - interval, frame count, start delay, start/stop, countdown, progress
and a log - while every camera setting moves into a settings drawer.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
This commit is contained in:
Jon
2026-08-01 18:47:04 +01:00
parent ec3f4462b1
commit 1af4b215b2
15 changed files with 2405 additions and 208 deletions

7
examples/preact/_headers Normal file
View File

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

17
examples/preact/build-dist.sh Executable file
View 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

View File

@@ -1,56 +0,0 @@
/*
* Copyright 2021 Google LLC
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
import { h, Component, Fragment } from 'preact';
/** @extends Component<{ getFile: () => Promise<File> }, { status: string }> */
export class CaptureButton extends Component {
state = { status: '📷' };
handleCapture = async () => {
this.setState({ status: '⌛' });
try {
let file = await this.props.getFile();
let url = URL.createObjectURL(file);
Object.assign(document.createElement('a'), {
download: file.name,
href: url
}).click();
URL.revokeObjectURL(url);
this.setState({ status: '📷' });
} catch (err) {
console.error(err);
this.setState({ status: '❌' });
}
};
render() {
return h(
Fragment,
null,
h('input', {
type: 'button',
id: 'capture',
disabled: this.state.status !== '📷',
class: 'pure-button',
onclick: this.handleCapture,
value: `${this.state.status} Capture image`
})
);
}
}

View File

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

View File

@@ -1,43 +1,648 @@
<!DOCTYPE html>
<html>
<head>
<title>gphoto2 on the Web</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Intervalometer — Canon over WebUSB</title>
<link
rel="stylesheet"
href="https://unpkg.com/purecss@2.0.6/build/pure-min.css"
crossorigin="anonymous"
/>
<style>
body {
display: flex;
height: 100vh;
:root {
color-scheme: dark light;
--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 {
margin: auto;
text-align: center;
padding: 20px;
}
#config {
overflow-y: auto;
max-height: 100vh;
padding-left: 5px;
box-sizing: border-box;
border-left: 3px solid #777;
.muted {
color: var(--muted);
}
#config .pure-button {
margin: 1px;
width: 20ch;
.fine-print {
max-width: 46ch;
margin: 16px auto 0;
font-size: 12.5px;
color: var(--muted);
}
#config label {
width: 40%;
}
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 38vh) minmax(0, 1fr);
overflow: hidden;
}
#config input,
#config select {
width: 50%;
#home {
border-left: none;
border-top: 1px solid var(--line);
}
}
</style>
<script type="importmap">
@@ -64,10 +669,10 @@
</script>
<link
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>
<body>
<div class="center">⌛ Loading...</div>
<div class="center">⌛ Loading</div>
</body>
</html>

View File

@@ -16,11 +16,14 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
import { h, hydrate, Component } from 'preact';
import { CaptureButton } from './capture-button.js';
import { h, hydrate, Component, Fragment } from 'preact';
import { Camera, rethrowIfCritical } from 'web-gphoto2';
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');
@@ -29,39 +32,97 @@ if (isDebug) {
await import('preact/debug');
}
/** @param {number} ms */
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
/** @extends Component<{}, AppState> */
class App extends Component {
/** @type {Camera | undefined} */
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.
state = { type: 'Status', message: '⌛ Loading...' };
/** @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() {
this.saver.mode = this.state.prefs.saveMode;
addEventListener('error', ({ message }) =>
this.setState({
type: 'Status',
message: `${message}`
})
this.log(`Uncaught error: ${message}`, 'error')
);
// Closing the tab halfway through a long sequence is an expensive mistake.
addEventListener('beforeunload', e => {
if (this.intervalometer.running) {
e.preventDefault();
e.returnValue = '';
}
});
addEventListener(
'beforeunload',
'pagehide',
() => {
if (!this.camera) return;
this.intervalometer.stop();
this.camera.disconnect();
this.camera = undefined;
},
{ once: true }
);
// Try to connect to camera at startup.
// If none is found among saved connections, it will fallback to a picker.
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 () => {
// @ts-ignore
await Camera.showPicker();
this.setState({ type: 'Status', message: '⌛ Connecting...' });
this.setState({ view: 'status', message: '⌛ Connecting' });
await this.tryToConnectToCamera();
};
@@ -73,132 +134,331 @@ class App extends Component {
await camera.connect();
} catch (e) {
console.warn(e);
this.setState({ type: 'CameraPicker' });
this.setState({ view: 'picker' });
return;
}
this.camera = camera;
let supportedOps = await camera.getSupportedOps();
let capturePreview;
if (supportedOps.capturePreview) {
capturePreview = () => camera.capturePreviewAsBlob();
}
let triggerCapture;
if (supportedOps.captureImage) {
triggerCapture = () => camera.captureImageAsFile();
}
// We should reach this only once.
while (this.camera) {
try {
let config = await this.camera.getConfig();
if (!isDebug) {
delete config.children.actions;
delete config.children.other;
}
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);
}
}
this.setState({ view: 'ready', supportedOps });
await this.refreshConfig();
this.log(
`Connected to ${configValue(this.state.config, 'cameramodel') ||
configValue(this.state.config, 'model') ||
'camera'}.`
);
}
async refreshConfig() {
if (!this.camera) return;
try {
this.setState({ config: await this.camera.getConfig() });
} catch (err) {
rethrowIfCritical(err);
console.error('Could not refresh config:', 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 {*} 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) {
switch (state.type) {
case 'CameraPicker':
switch (state.view) {
case 'picker':
return h(
'div',
{
class: 'center'
},
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' }, state.message);
case 'Config':
return h(
'div',
{ class: 'pure-g' },
{ class: 'center' },
h('h1', null, '⏱ Intervalometer'),
h('p', null, 'Connect your Canon 450D over USB and switch it on.'),
h(
'div',
{ class: 'pure-u-2-3' },
h(Preview, {
getPreview: state.capturePreview
})
'button',
{ type: 'button', class: 'primary big', onclick: this.selectDevice },
'🔍 Select camera'
),
h(
'div',
{ id: 'config', class: 'pure-u-1-3' },
'p',
{ 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(
'form',
{ class: 'pure-form pure-form-aligned' },
h(
'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 })
)
'a',
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
'web-gphoto2'
),
'.'
)
);
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);
}
}
}

View File

@@ -0,0 +1,267 @@
/*
* Copyright 2021 Google LLC
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
/**
* @typedef {'idle' | 'delay' | 'waiting' | 'capturing' | 'done' | 'stopped' | 'failed'} Phase
*
* @typedef {object} SequenceState
* @property {Phase} phase
* @property {number} taken Frames successfully captured so far.
* @property {number} total Requested frame count, 0 for unlimited.
* @property {number} errors Failed captures so far.
* @property {number} nextAt Timestamp of the next scheduled frame (0 if none).
* @property {number} startedAt Timestamp the sequence was started (0 if idle).
* @property {number} finishedAt Timestamp the sequence ended (0 while running).
* @property {number} intervalMs
*/
/** @type {SequenceState} */
const INITIAL_STATE = {
phase: 'idle',
taken: 0,
total: 0,
errors: 0,
nextAt: 0,
startedAt: 0,
finishedAt: 0,
intervalMs: 0
};
/** Abort three failures in a row - at that point something is properly wrong. */
const MAX_CONSECUTIVE_ERRORS = 3;
class Aborted extends Error {}
/**
* Resolve at an absolute timestamp, or reject with `Aborted` if the signal
* fires first. Resolves immediately if the deadline has already passed.
* @param {number} timestamp
* @param {AbortSignal} signal
*/
function sleepUntil(timestamp, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) return reject(new Aborted());
let remaining = timestamp - Date.now();
if (remaining <= 0) return resolve(undefined);
let onAbort = () => {
clearTimeout(timer);
reject(new Aborted());
};
let timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve(undefined);
}, remaining);
signal.addEventListener('abort', onAbort, { once: true });
});
}
/**
* Drives a timed sequence of captures.
*
* Frames are scheduled on an absolute grid (`start + n * interval`) rather than
* by sleeping for `interval` after each frame, so download time doesn't
* accumulate as drift over a long run. If a capture overruns its slot the next
* frame fires as soon as the camera is free and the overrun is reported, rather
* than silently dropping a frame.
*/
export class Intervalometer {
/** @type {SequenceState} */
state = INITIAL_STATE;
/** @type {AbortController | null} */
#abort = null;
/** @type {ReturnType<typeof setInterval> | undefined} */
#ticker;
/**
* @param {object} handlers
* @param {(index: number) => Promise<void>} handlers.capture
* @param {(state: SequenceState) => void} handlers.onChange Called on every state change and ~5x/s while running, for the countdown.
* @param {(message: string, kind?: 'info' | 'warn' | 'error') => void} handlers.log
*/
constructor({ capture, onChange, log }) {
this.#capture = capture;
this.#onChange = onChange;
this.#log = log;
}
get running() {
return this.#abort !== null;
}
/**
* @param {object} params
* @param {number} params.intervalMs Time between the start of consecutive frames.
* @param {number} params.count Number of frames, or 0 for unlimited.
* @param {number} params.startDelayMs Delay before the first frame.
*/
start({ intervalMs, count, startDelayMs }) {
if (this.running) return;
this.#abort = new AbortController();
this.#update({
...INITIAL_STATE,
phase: startDelayMs > 0 ? 'delay' : 'waiting',
total: count,
intervalMs,
startedAt: Date.now()
});
// Keep pushing state while running so the UI countdown stays live.
this.#ticker = setInterval(() => this.#onChange(this.state), 200);
this.#run({ intervalMs, count, startDelayMs }, this.#abort.signal);
}
stop() {
this.#abort?.abort();
}
reset() {
if (this.running) return;
this.#update(INITIAL_STATE);
}
/**
* @param {{ intervalMs: number, count: number, startDelayMs: number }} params
* @param {AbortSignal} signal
*/
async #run({ intervalMs, count, startDelayMs }, signal) {
let firstAt = Date.now() + startDelayMs;
let consecutiveErrors = 0;
/** @type {Phase} */
let finalPhase = 'done';
try {
for (let index = 0; count === 0 || index < count; index++) {
let scheduledAt = firstAt + index * intervalMs;
this.#update({
phase: index === 0 && startDelayMs > 0 ? 'delay' : 'waiting',
nextAt: scheduledAt
});
await sleepUntil(scheduledAt, signal);
let lateBy = Date.now() - scheduledAt;
if (lateBy > 250 && index > 0) {
this.#log(
`Frame ${index + 1} is ${(lateBy / 1000).toFixed(
1
)}s late - the interval is shorter than capture + download takes.`,
'warn'
);
}
this.#update({ phase: 'capturing' });
try {
await this.#capture(index);
consecutiveErrors = 0;
this.#update({ taken: this.state.taken + 1 });
} catch (err) {
if (signal.aborted) throw new Aborted();
consecutiveErrors++;
this.#update({ errors: this.state.errors + 1 });
this.#log(
`Frame ${index + 1} failed: ${
/** @type {Error} */ (err).message || err
}`,
'error'
);
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
this.#log(
`Stopped after ${consecutiveErrors} failures in a row.`,
'error'
);
finalPhase = 'failed';
break;
}
}
}
} catch (err) {
if (err instanceof Aborted) {
finalPhase = 'stopped';
} else {
finalPhase = 'failed';
this.#log(
`Sequence aborted: ${/** @type {Error} */ (err).message || err}`,
'error'
);
// Anything that isn't a graceful abort is worth surfacing in the console too.
console.error(err);
}
} finally {
clearInterval(this.#ticker);
this.#ticker = undefined;
this.#abort = null;
this.#update({
phase: finalPhase,
nextAt: 0,
finishedAt: Date.now()
});
let summary = `${this.state.taken} frame${
this.state.taken === 1 ? '' : 's'
} captured`;
if (finalPhase === 'done') this.#log(`✅ Sequence complete - ${summary}.`);
if (finalPhase === 'stopped') this.#log(`⏹ Stopped - ${summary}.`);
if (finalPhase === 'failed') this.#log(`❌ Sequence failed - ${summary}.`, 'error');
}
}
/** @param {Partial<SequenceState>} patch */
#update(patch) {
this.state = { ...this.state, ...patch };
this.#onChange(this.state);
}
#capture;
#onChange;
#log;
}
/**
* @param {SequenceState} state
* @returns {number} Seconds until the next frame, floored at 0.
*/
export function secondsUntilNext(state) {
if (!state.nextAt) return 0;
return Math.max(0, (state.nextAt - Date.now()) / 1000);
}
/**
* Human-readable duration, e.g. "1h 04m 30s".
* @param {number} seconds
*/
export function formatDuration(seconds) {
if (!Number.isFinite(seconds)) return '∞';
seconds = Math.max(0, Math.round(seconds));
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds % 3600) / 60);
let secs = seconds % 60;
if (hours) {
return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(
secs
).padStart(2, '0')}s`;
}
if (minutes) return `${minutes}m ${String(secs).padStart(2, '0')}s`;
return `${secs}s`;
}
/** @param {number} timestamp */
export function formatClock(timestamp) {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}

View File

@@ -27,21 +27,48 @@ const Stats = isDebug
)
: 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 {
canvasHolderRef = createRef();
canvasRef = createRef();
/** @type {ResizeObserver} */
resizeObserver;
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(
'div',
{ class: 'center-parent', ref: this.canvasHolderRef },
!this.props.getPreview
!props.getPreview
? 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...
await new Promise(resolve => setTimeout(resolve, 1500));
let failures = 0;
let resuming = false;
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 {
let blob = await this.props.getPreview();
@@ -107,9 +148,19 @@ export class Preview extends Component {
}
await new Promise(resolve => requestAnimationFrame(resolve));
canvasCtx.transferFromImageBitmap(img);
if (failures) {
failures = 0;
this.setState({ restoring: false });
}
} catch (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();
}

328
examples/preact/settings.js Normal file
View 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
View File

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

View File

@@ -1,9 +1,20 @@
type AppState =
| { type: 'CameraPicker' }
| { type: 'Status'; message: string }
| {
type: 'Config';
config: import('web-gphoto2').Config;
capturePreview: (() => Promise<Blob>) | undefined;
triggerCapture: (() => Promise<File>) | undefined;
};
type LogEntry = {
id: number;
message: string;
kind: 'info' | 'warn' | 'error';
at: number;
};
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';
};

View File

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