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:
352
examples/preact/home.js
Normal file
352
examples/preact/home.js
Normal file
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* 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 } from 'preact';
|
||||
import {
|
||||
secondsUntilNext,
|
||||
formatDuration,
|
||||
formatClock
|
||||
} from './intervalometer.js';
|
||||
import {
|
||||
statusReadout,
|
||||
configValue,
|
||||
shutterSpeedSeconds
|
||||
} from './config-utils.js';
|
||||
|
||||
/** @typedef {import('./settings.js').Prefs} Prefs */
|
||||
/** @typedef {import('./intervalometer.js').SequenceState} SequenceState */
|
||||
|
||||
/**
|
||||
* Things worth knowing before you walk away from a running camera for two hours.
|
||||
*
|
||||
* @param {Prefs} prefs
|
||||
* @param {import('web-gphoto2').Config | undefined} config
|
||||
* @param {boolean} hasFolder
|
||||
*/
|
||||
function sequenceWarnings(prefs, config, hasFolder) {
|
||||
let warnings = [];
|
||||
|
||||
let format = configValue(config, 'imageformat');
|
||||
let isRaw = typeof format === 'string' && /raw/i.test(format);
|
||||
// Rough per-frame cost on top of the exposure itself: mirror, card write and
|
||||
// the USB 2.0 transfer. A 450D clears a JPEG in about 2s and a RAW in about 6s.
|
||||
let overhead = isRaw ? 6 : 2.5;
|
||||
let exposure = prefs.bulbEnabled
|
||||
? prefs.bulbSeconds
|
||||
: shutterSpeedSeconds(configValue(config, 'shutterspeed'));
|
||||
|
||||
if (exposure !== undefined && exposure >= prefs.intervalSeconds) {
|
||||
warnings.push(
|
||||
`The exposure (${formatDuration(exposure)}) is longer than the ${
|
||||
prefs.intervalSeconds
|
||||
}s interval — every frame will run late.`
|
||||
);
|
||||
} else if (
|
||||
exposure !== undefined &&
|
||||
prefs.intervalSeconds < exposure + overhead
|
||||
) {
|
||||
warnings.push(
|
||||
`A ${prefs.intervalSeconds}s interval is tight: ${
|
||||
isRaw ? String(format) : 'each frame'
|
||||
} needs roughly ${formatDuration(
|
||||
exposure + overhead
|
||||
)} to shoot and transfer. Expect late frames.`
|
||||
);
|
||||
}
|
||||
|
||||
if (prefs.saveMode === 'folder' && !hasFolder) {
|
||||
warnings.push('No output folder chosen yet — pick one in Settings.');
|
||||
}
|
||||
|
||||
if (prefs.saveMode === 'download' && !prefs.unlimited && prefs.shots > 20) {
|
||||
warnings.push(
|
||||
`${prefs.shots} separate downloads will be slow and noisy. Saving to a folder is much better for a run this long.`
|
||||
);
|
||||
}
|
||||
|
||||
if (prefs.bulbEnabled) {
|
||||
warnings.push(
|
||||
'Bulb mode is on: frames stay on the camera card and will not be saved by the browser.'
|
||||
);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ state: SequenceState, prefs: Prefs }} props
|
||||
*/
|
||||
function SequenceStatus({ state, prefs }) {
|
||||
let { phase, taken, total } = state;
|
||||
let headline;
|
||||
let detail;
|
||||
|
||||
switch (phase) {
|
||||
case 'delay':
|
||||
headline = `Starting in ${formatDuration(secondsUntilNext(state))}`;
|
||||
detail = 'Waiting out the start delay.';
|
||||
break;
|
||||
case 'waiting':
|
||||
headline = `Next frame in ${formatDuration(secondsUntilNext(state))}`;
|
||||
detail = `${taken} of ${total || '∞'} captured`;
|
||||
break;
|
||||
case 'capturing':
|
||||
headline = prefs.bulbEnabled
|
||||
? `⏱ Bulb exposure — ${prefs.bulbSeconds}s`
|
||||
: '📸 Capturing…';
|
||||
detail = `${taken} of ${total || '∞'} captured`;
|
||||
break;
|
||||
case 'done':
|
||||
headline = '✅ Sequence complete';
|
||||
detail = `${taken} frame${taken === 1 ? '' : 's'} in ${formatDuration(
|
||||
(state.finishedAt - state.startedAt) / 1000
|
||||
)}`;
|
||||
break;
|
||||
case 'stopped':
|
||||
headline = '⏹ Stopped';
|
||||
detail = `${taken} frame${taken === 1 ? '' : 's'} captured`;
|
||||
break;
|
||||
case 'failed':
|
||||
headline = '❌ Sequence failed';
|
||||
detail = `${taken} captured, ${state.errors} failed`;
|
||||
break;
|
||||
default:
|
||||
headline = 'Ready';
|
||||
detail = 'Set an interval and press Start.';
|
||||
}
|
||||
|
||||
let progress = total ? Math.min(1, taken / total) : 0;
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: `sequence-status phase-${phase}` },
|
||||
h('div', { class: 'headline' }, headline),
|
||||
h('div', { class: 'detail' }, detail),
|
||||
total
|
||||
? h(
|
||||
'div',
|
||||
{ class: 'progress' },
|
||||
h('div', { class: 'bar', style: `width: ${progress * 100}%` })
|
||||
)
|
||||
: undefined,
|
||||
state.errors > 0 && phase !== 'failed'
|
||||
? h('div', { class: 'detail warn-text' }, `${state.errors} frame(s) failed`)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole home-screen control column: interval timing, start/stop, progress,
|
||||
* and a running log. Camera settings deliberately live in the drawer instead.
|
||||
*
|
||||
* @param {{
|
||||
* prefs: Prefs,
|
||||
* setPref: (patch: Partial<Prefs>) => void,
|
||||
* state: SequenceState,
|
||||
* running: boolean,
|
||||
* onStart: () => void,
|
||||
* onStop: () => void,
|
||||
* onSingleShot: () => void,
|
||||
* singleShotStatus: string,
|
||||
* config: import('web-gphoto2').Config | undefined,
|
||||
* hasFolder: boolean,
|
||||
* canCapture: boolean,
|
||||
* log: { id: number, message: string, kind: string, at: number }[]
|
||||
* }} props
|
||||
*/
|
||||
export function Home({
|
||||
prefs,
|
||||
setPref,
|
||||
state,
|
||||
running,
|
||||
onStart,
|
||||
onStop,
|
||||
onSingleShot,
|
||||
singleShotStatus,
|
||||
config,
|
||||
hasFolder,
|
||||
canCapture,
|
||||
log
|
||||
}) {
|
||||
let warnings = running ? [] : sequenceWarnings(prefs, config, hasFolder);
|
||||
let plannedSeconds = prefs.unlimited
|
||||
? Infinity
|
||||
: prefs.startDelaySeconds + prefs.shots * prefs.intervalSeconds;
|
||||
let readout = statusReadout(config);
|
||||
|
||||
/** @param {(value: number) => Partial<Prefs>} toPatch */
|
||||
let numberHandler = toPatch => e => {
|
||||
let value = /** @type {HTMLInputElement} */ (e.currentTarget).valueAsNumber;
|
||||
if (Number.isFinite(value)) setPref(toPatch(value));
|
||||
};
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ id: 'home' },
|
||||
h(SequenceStatus, { state, prefs }),
|
||||
|
||||
h(
|
||||
'div',
|
||||
{ class: 'card' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'field-grid' },
|
||||
h(
|
||||
'label',
|
||||
{ class: 'field' },
|
||||
h('span', null, 'Interval'),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'input-with-unit' },
|
||||
h('input', {
|
||||
type: 'number',
|
||||
min: '0.5',
|
||||
step: '0.5',
|
||||
value: prefs.intervalSeconds,
|
||||
disabled: running,
|
||||
onInput: numberHandler(v => ({
|
||||
intervalSeconds: Math.max(0.5, v)
|
||||
}))
|
||||
}),
|
||||
h('span', { class: 'unit' }, 'sec')
|
||||
)
|
||||
),
|
||||
h(
|
||||
'label',
|
||||
{ class: 'field' },
|
||||
h('span', null, 'Frames'),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'input-with-unit' },
|
||||
h('input', {
|
||||
type: 'number',
|
||||
min: '1',
|
||||
step: '1',
|
||||
value: prefs.shots,
|
||||
disabled: running || prefs.unlimited,
|
||||
onInput: numberHandler(v => ({ shots: Math.max(1, Math.round(v)) }))
|
||||
}),
|
||||
h(
|
||||
'label',
|
||||
{ class: 'inline-check' },
|
||||
h('input', {
|
||||
type: 'checkbox',
|
||||
checked: prefs.unlimited,
|
||||
disabled: running,
|
||||
onChange: e =>
|
||||
setPref({ unlimited: e.currentTarget.checked })
|
||||
}),
|
||||
' ∞'
|
||||
)
|
||||
)
|
||||
),
|
||||
h(
|
||||
'label',
|
||||
{ class: 'field' },
|
||||
h('span', null, 'Start delay'),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'input-with-unit' },
|
||||
h('input', {
|
||||
type: 'number',
|
||||
min: '0',
|
||||
step: '1',
|
||||
value: prefs.startDelaySeconds,
|
||||
disabled: running,
|
||||
onInput: numberHandler(v => ({
|
||||
startDelaySeconds: Math.max(0, Math.round(v))
|
||||
}))
|
||||
}),
|
||||
h('span', { class: 'unit' }, 'sec')
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
h(
|
||||
'p',
|
||||
{ class: 'plan' },
|
||||
prefs.unlimited
|
||||
? `Runs until you stop it, one frame every ${prefs.intervalSeconds}s.`
|
||||
: `${prefs.shots} frames over ${formatDuration(
|
||||
plannedSeconds
|
||||
)} — finishing around ${formatClock(
|
||||
Date.now() + plannedSeconds * 1000
|
||||
)}.`
|
||||
),
|
||||
|
||||
h(
|
||||
'div',
|
||||
{ class: 'actions' },
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: running ? 'danger big' : 'primary big',
|
||||
disabled: !canCapture,
|
||||
onclick: running ? onStop : onStart
|
||||
},
|
||||
running ? '⏹ Stop' : '▶ Start sequence'
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'secondary',
|
||||
disabled: !canCapture || running || singleShotStatus !== 'idle',
|
||||
onclick: onSingleShot
|
||||
},
|
||||
singleShotStatus === 'busy' ? '⌛ Capturing…' : '📷 Single shot'
|
||||
)
|
||||
),
|
||||
|
||||
warnings.map(text =>
|
||||
h('p', { key: text, class: 'notice warn' }, '⚠ ', text)
|
||||
)
|
||||
),
|
||||
|
||||
readout.length
|
||||
? h(
|
||||
'div',
|
||||
{ class: 'readout' },
|
||||
readout.map(({ label, value }) =>
|
||||
h(
|
||||
'div',
|
||||
{ key: label, class: 'readout-item' },
|
||||
h('span', { class: 'readout-label' }, label),
|
||||
h('span', { class: 'readout-value' }, String(value))
|
||||
)
|
||||
)
|
||||
)
|
||||
: undefined,
|
||||
|
||||
log.length
|
||||
? h(
|
||||
'div',
|
||||
{ class: 'log' },
|
||||
log.map(entry =>
|
||||
h(
|
||||
'div',
|
||||
{ key: entry.id, class: `log-entry ${entry.kind}` },
|
||||
h('span', { class: 'log-time' }, formatClock(entry.at)),
|
||||
h('span', null, entry.message)
|
||||
)
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user