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
173 lines
5.2 KiB
JavaScript
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();
|
|
}
|
|
}
|