Turn the demo app into a Canon 450D intervalometer
Some checks failed
CI / build-and-deploy (push) Has been cancelled
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:
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>
|
||||
<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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/** @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
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: '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';
|
||||
};
|
||||
|
||||
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