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
329 lines
10 KiB
JavaScript
329 lines
10 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, Component, Fragment } from 'preact';
|
|
import { Widget } from './widget.js';
|
|
import { supportsFolderSaving } from './storage.js';
|
|
|
|
const PREFS_KEY = 'web-dslr.prefs';
|
|
|
|
export const DEFAULT_PREFS = {
|
|
// Home screen sequence parameters.
|
|
intervalSeconds: 10,
|
|
shots: 120,
|
|
unlimited: false,
|
|
startDelaySeconds: 0,
|
|
// Everything below lives in this drawer.
|
|
saveMode: /** @type {'folder' | 'download' | 'none'} */ (
|
|
supportsFolderSaving ? 'folder' : 'download'
|
|
),
|
|
livePreview: true,
|
|
// Renamed from `previewDuringSequence` (which defaulted to off) so saved
|
|
// prefs from before pick up the new default rather than the old behaviour.
|
|
liveViewBetweenFrames: true,
|
|
keepAwake: true,
|
|
bulbEnabled: false,
|
|
bulbSeconds: 30
|
|
};
|
|
|
|
/** @typedef {typeof DEFAULT_PREFS} Prefs */
|
|
|
|
/** @returns {Prefs} */
|
|
export function loadPrefs() {
|
|
try {
|
|
let stored = localStorage.getItem(PREFS_KEY);
|
|
// Spread over the defaults so prefs added in a later version fill in.
|
|
return stored ? { ...DEFAULT_PREFS, ...JSON.parse(stored) } : { ...DEFAULT_PREFS };
|
|
} catch (err) {
|
|
console.warn('Could not read saved settings:', err);
|
|
return { ...DEFAULT_PREFS };
|
|
}
|
|
}
|
|
|
|
/** @param {Prefs} prefs */
|
|
export function savePrefs(prefs) {
|
|
try {
|
|
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
|
} catch (err) {
|
|
console.warn('Could not persist settings:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ label: string, hint?: string, children?: any }} props
|
|
*/
|
|
function Row({ label, hint, children }) {
|
|
return h(
|
|
'div',
|
|
{ class: 'setting-row' },
|
|
h(
|
|
'div',
|
|
{ class: 'setting-label' },
|
|
h('span', null, label),
|
|
hint ? h('small', null, hint) : undefined
|
|
),
|
|
h('div', { class: 'setting-control' }, children)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Slide-over panel holding everything that isn't interval timing: where frames
|
|
* go, preview behaviour, bulb, and the camera's own config tree.
|
|
*
|
|
* @extends Component<{
|
|
* open: boolean,
|
|
* onClose: () => void,
|
|
* prefs: Prefs,
|
|
* setPref: (patch: Partial<Prefs>) => void,
|
|
* config: import('web-gphoto2').Config | undefined,
|
|
* setValue: (name: string, value: any) => Promise<void>,
|
|
* folderName: string | undefined,
|
|
* chooseFolder: () => void,
|
|
* bulbSupport: ReturnType<typeof import('./config-utils.js').detectBulbSupport>,
|
|
* locked: boolean
|
|
* }>
|
|
*/
|
|
export class SettingsDrawer extends Component {
|
|
#onKeyDown = (/** @type {KeyboardEvent} */ e) => {
|
|
if (e.key === 'Escape' && this.props.open) this.props.onClose();
|
|
};
|
|
|
|
componentDidMount() {
|
|
addEventListener('keydown', this.#onKeyDown);
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
removeEventListener('keydown', this.#onKeyDown);
|
|
}
|
|
|
|
render(/** @type {SettingsDrawer['props']} */ props) {
|
|
let {
|
|
open,
|
|
onClose,
|
|
prefs,
|
|
setPref,
|
|
config,
|
|
setValue,
|
|
folderName,
|
|
chooseFolder,
|
|
bulbSupport,
|
|
locked
|
|
} = props;
|
|
|
|
return h(
|
|
Fragment,
|
|
null,
|
|
h('div', {
|
|
class: `scrim ${open ? 'open' : ''}`,
|
|
onclick: onClose
|
|
}),
|
|
// Visibility (and therefore focus order / screen reader exposure) is
|
|
// driven by the `open` class in CSS rather than aria-hidden.
|
|
h(
|
|
'aside',
|
|
{
|
|
id: 'settings',
|
|
class: open ? 'open' : ''
|
|
},
|
|
h(
|
|
'header',
|
|
null,
|
|
h('h2', null, 'Settings'),
|
|
h(
|
|
'button',
|
|
{ type: 'button', class: 'icon-button', onclick: onClose, title: 'Close settings' },
|
|
'✕'
|
|
)
|
|
),
|
|
h(
|
|
'div',
|
|
{ class: 'drawer-body' },
|
|
locked
|
|
? h(
|
|
'p',
|
|
{ class: 'notice warn' },
|
|
'A sequence is running. Changing camera settings mid-run is allowed, but each change costs a USB round-trip and may delay a frame.'
|
|
)
|
|
: undefined,
|
|
|
|
h(
|
|
'section',
|
|
null,
|
|
h('h3', null, 'Where frames go'),
|
|
h(
|
|
Row,
|
|
{
|
|
label: 'Save frames to',
|
|
hint:
|
|
prefs.saveMode === 'none'
|
|
? 'Frames are still transferred off the camera, just not written to disk.'
|
|
: undefined
|
|
},
|
|
h(
|
|
'select',
|
|
{
|
|
value: prefs.saveMode,
|
|
onChange: e => setPref({ saveMode: e.currentTarget.value })
|
|
},
|
|
supportsFolderSaving
|
|
? h('option', { value: 'folder' }, 'A folder on this computer')
|
|
: undefined,
|
|
h('option', { value: 'download' }, 'Downloads (one file at a time)'),
|
|
h('option', { value: 'none' }, "Don't save in the browser")
|
|
)
|
|
),
|
|
prefs.saveMode === 'folder'
|
|
? h(
|
|
Row,
|
|
{
|
|
label: 'Output folder',
|
|
hint: 'Frames are prefixed with a zero-padded index so they sort in order.'
|
|
},
|
|
h(
|
|
'button',
|
|
{ type: 'button', class: 'secondary', onclick: chooseFolder },
|
|
folderName ? `📁 ${folderName}` : '📁 Choose folder…'
|
|
)
|
|
)
|
|
: undefined,
|
|
prefs.saveMode === 'none'
|
|
? h(
|
|
'p',
|
|
{ class: 'notice' },
|
|
'Set the camera\'s capture target to the memory card below if you want to keep the frames at all.'
|
|
)
|
|
: undefined
|
|
),
|
|
|
|
h(
|
|
'section',
|
|
null,
|
|
h('h3', null, 'Live view'),
|
|
h(
|
|
Row,
|
|
{
|
|
label: 'Show live preview',
|
|
hint: 'Live view on the 450D warms the sensor and drains the battery.'
|
|
},
|
|
h('input', {
|
|
type: 'checkbox',
|
|
checked: prefs.livePreview,
|
|
onChange: e => setPref({ livePreview: e.currentTarget.checked })
|
|
})
|
|
),
|
|
h(
|
|
Row,
|
|
{
|
|
label: 'Live view between frames',
|
|
hint: 'Keeps the feed up during a sequence, dropping it only while each shot fires. Turn off to leave the USB link entirely to the captures.'
|
|
},
|
|
h('input', {
|
|
type: 'checkbox',
|
|
checked: prefs.liveViewBetweenFrames,
|
|
disabled: !prefs.livePreview,
|
|
onChange: e =>
|
|
setPref({ liveViewBetweenFrames: e.currentTarget.checked })
|
|
})
|
|
),
|
|
h(
|
|
Row,
|
|
{
|
|
label: 'Keep screen awake',
|
|
hint: 'Background tabs get their timers throttled, which ruins interval timing.'
|
|
},
|
|
h('input', {
|
|
type: 'checkbox',
|
|
checked: prefs.keepAwake,
|
|
disabled: !('wakeLock' in navigator),
|
|
onChange: e => setPref({ keepAwake: e.currentTarget.checked })
|
|
})
|
|
)
|
|
),
|
|
|
|
h(
|
|
'section',
|
|
null,
|
|
h('h3', null, 'Bulb exposures ', h('span', { class: 'tag' }, 'experimental')),
|
|
bulbSupport
|
|
? h(
|
|
Fragment,
|
|
null,
|
|
h(
|
|
Row,
|
|
{
|
|
label: 'Use bulb for each frame',
|
|
hint: `Driven via ${
|
|
bulbSupport.kind === 'bulb' ? 'the bulb toggle' : 'eosremoterelease'
|
|
}. Put the mode dial on B first.`
|
|
},
|
|
h('input', {
|
|
type: 'checkbox',
|
|
checked: prefs.bulbEnabled,
|
|
onChange: e => setPref({ bulbEnabled: e.currentTarget.checked })
|
|
})
|
|
),
|
|
h(
|
|
Row,
|
|
{ label: 'Exposure length' },
|
|
h('input', {
|
|
type: 'number',
|
|
min: '1',
|
|
step: '1',
|
|
value: prefs.bulbSeconds,
|
|
onChange: e =>
|
|
setPref({
|
|
bulbSeconds: Math.max(1, e.currentTarget.valueAsNumber || 1)
|
|
})
|
|
}),
|
|
h('span', { class: 'unit' }, 'sec')
|
|
),
|
|
prefs.bulbEnabled
|
|
? h(
|
|
'p',
|
|
{ class: 'notice warn' },
|
|
'Bulb frames are written to the camera card and are ',
|
|
h('strong', null, 'not'),
|
|
' downloaded to the browser — the WASM API has no hook for files that arrive outside of a normal capture. Set the capture target to the memory card and pull the card afterwards.'
|
|
)
|
|
: undefined
|
|
)
|
|
: h(
|
|
'p',
|
|
{ class: 'notice' },
|
|
'This camera does not expose a bulb or eosremoterelease control, so timed long exposures are unavailable. Use the shutter speed setting below instead (up to 30s on the 450D).'
|
|
)
|
|
),
|
|
|
|
h(
|
|
'section',
|
|
null,
|
|
h('h3', null, 'Camera'),
|
|
config
|
|
? h(
|
|
'form',
|
|
{ class: 'pure-form pure-form-aligned', onSubmit: e => e.preventDefault() },
|
|
h(Widget, { config, setValue })
|
|
)
|
|
: h('p', { class: 'notice' }, 'Reading camera configuration…')
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|