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

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

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

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

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

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

182
examples/preact/storage.js Normal file
View File

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