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