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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user