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
353 lines
9.9 KiB
JavaScript
353 lines
9.9 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 } 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
|
|
);
|
|
}
|