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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-01 18:47:04 +01:00

173 lines
5.2 KiB
JavaScript

/*
* 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, createRef } from 'preact';
import { rethrowIfCritical } from 'web-gphoto2';
export const isDebug = new URLSearchParams(location.search).has('debug');
const Stats = isDebug
? await import('stats.js').then(
res => /** @type {typeof import('stats.js')} */ (res['default'])
)
: null;
/** @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;
return h(
'div',
{ class: 'center-parent', ref: this.canvasHolderRef },
!props.getPreview
? h('div', { class: 'center' }, `Preview is unsupported`)
: h('canvas', { class: 'center', ref: this.canvasRef }),
overlay ? h('div', { class: 'preview-overlay' }, overlay) : undefined
);
}
async componentDidMount() {
if (!this.props.getPreview) return;
let canvas = /** @type {HTMLCanvasElement} */ (this.canvasRef.current);
let canvasHolder = this.canvasHolderRef.current;
if (isDebug) {
canvasHolder.appendChild(this.stats.dom);
}
let canvasCtx = canvas.getContext('bitmaprenderer');
let ratio = 0;
let throttled = 0;
function updateCanvasSize() {
if (throttled) {
cancelAnimationFrame(throttled);
}
throttled = requestAnimationFrame(() => {
throttled = 0;
let width = canvasHolder.offsetWidth - 10;
let height = canvasHolder.offsetHeight;
if (height * ratio > width) {
height = width / ratio;
} else {
width = height * ratio;
}
Object.assign(canvas, { width, height });
});
}
(this.resizeObserver = new ResizeObserver(updateCanvasSize)).observe(
canvasHolder
);
// 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();
// If ratio is known; decode resized image right away - it's a bit faster.
// If it isn't known, retrieve entire image to calculate ratio from its dimensions.
let img = await createImageBitmap(
blob,
ratio
? {
resizeWidth: canvas.width,
resizeHeight: canvas.height
}
: {}
);
if (!ratio) {
ratio = img.width / img.height;
updateCanvasSize();
}
await new Promise(resolve => requestAnimationFrame(resolve));
canvasCtx.transferFromImageBitmap(img);
if (failures) {
failures = 0;
this.setState({ restoring: false });
}
} catch (err) {
rethrowIfCritical(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();
}
}
componentWillUnmount() {
this.resizeObserver?.disconnect();
}
}