Files
web-dslr/README.md
Jon c3cf5ddfbf
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Split settings into tabs; exposure and focus on the home screen
Home screen gains shutter, aperture and ISO as dropdowns plus a focus stepper
(three nudge sizes each way, and AF) where the body drives focus over PTP.
Those three drop out of the read-only strip rather than being shown twice.

Everything is built from what the camera actually reports, so a body without
one of these controls doesn't get it rather than showing something that
silently fails. The 450D marks shutter and aperture readonly unless the mode
dial is somewhere they apply, so the dropdowns disable themselves and say why.
Focus uses the EOS manualfocusdrive steps and autofocusdrive, and warns when
live view is off, which Canon bodies generally require for focus commands.

The settings drawer is now tabbed: app settings stay on the first tab, and
each config section the camera reports gets its own. Empty sections are
dropped, and a selected section that disappears falls back to the first tab.
Tabs wrap rather than scroll - a scrolled-off tab is an undiscoverable one.

Reverts the automatic camera search added in the previous commit, restoring
the Select camera button. Auto-connect could park with no way to reach the
device chooser: WebUSB permissions are per-origin, so a grant on localhost
doesn't carry to the deployed copy, and a camera that's asleep or held by
another app never arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-02 19:36:06 +01:00

11 KiB
Raw Permalink Blame History

Web-gPhoto2

A gPhoto2 implementation using WebAssembly to control DSLR cameras from the browser.

Powered by a custom fork of libgphoto2, the WebUSB backend of libusb, and WebAssembly via Emscripten.

Installation

npm install web-gphoto2

Usage

A short example on how to use this package:

import { Camera } from "web-gphoto2";

let camera = new Camera();

// Triggers the browser's native USB picker listing all connected cameras.
await Camera.showPicker();

// Connects to the camera exposed in the previous step.
// In the future we might allow to connect to multiple cameras by passing a specific instance.
await camera.connect();

console.log("Operations supported by the camera:", await camera.getSupportedOps());

console.log("Current configuration tree:", await camera.getConfig());

// Update camera configuration by the setting's name.
await camera.setConfigValue("iso", "800");

// Capture a lower-quality preview frame, useful for high-FPS live view stream.
// Returns a Blob with image mime type and contents.
const blob = await camera.capturePreviewAsBlob();
// Use `URL.createObjectURL` to create an image URL from the blob or `createImageBitmap` to decode it directly.
const img = new Image();
img.src = URL.createObjectURL(blob);

// Capture a full-resolution image in format currently selected on the camera (JPEG or RAW).
// This can be used in the same way as Blob above, but also has extra information such as filename useful for download.
const file = await camera.captureImageAsFile();
const a = document.createElement("a");
a.href = URL.createObjectURL(file);
a.download = file.name;

Intervalometer app

examples/preact is an intervalometer for shooting timelapses, built around a Canon EOS 450D but not specific to it — anything the PTP driver supports will work.

cd examples/preact
npm install
npx serve .   # serve.json sets the COOP/COEP headers the WASM module needs

Open the printed URL in Chrome, plug the camera in over USB, switch it on and pick it from the device picker. It tries once on load to reattach to a camera the browser already knows about, and otherwise shows a "Select camera" button.

Note that WebUSB permissions are per-origin, so the copy on localhost and the deployed copy each need granting separately.

It's also an installable PWA: install it from Chrome's address bar to get a standalone window, and it works with no network at all — the app shell and the pinned unpkg dependencies (including the ~2 MB WASM) are precached by a service worker. That matters here because the camera is on a USB cable, so the app is perfectly useful in a field with no signal.

Home screen — only the things you change between runs:

  • Interval, frames (or ∞ for open-ended) and start delay
  • Start/stop, plus a single-shot button for framing
  • Live countdown to the next frame, progress, estimated finish time, and a log of the last few events
  • A 🔊 toggle that speaks the countdown into each frame ("five, four, three, two, one") along with start and finish, for when you're in front of the camera rather than at the laptop
  • A ⛶ button on the preview for a fullscreen live view, with the countdown carried over as an overlay so you don't lose it
  • Shutter, aperture and ISO as dropdowns, and a focus stepper (three nudge sizes each way, plus AF) where the body drives focus over PTP
  • A read-only strip for the rest — mode, format, battery, shots remaining — so you can sanity-check the run without opening anything
  • Pre-flight warnings when the interval is too tight for the current exposure and transfer time, when you're shooting RAW on a short interval, or when no output folder is set

Settings drawer (⚙, top right) — everything else, including the full gPhoto2 config tree, so the home screen stays uncluttered:

  • Where frames go: a folder on disk via the File System Access API, one-at-a-time downloads, or nothing at all if you're keeping them on the card. Frames are named after the moment they were taken — 20260801-172713_00007.JPG in a sequence, 20260801-172713.JPG for a single shot — so sorting by name is sorting by capture time. The camera's own IMG_1234 is dropped: it wraps at 9999 and resets on a card format, so it can't order a long run.
  • Live view on/off, and whether it stays up between frames during a sequence (on by default — the feed drops only while each shot fires, since the camera needs live view down to take it, then recovers on its own)
  • Screen wake lock, so a backgrounded tab doesn't get its timers throttled mid-run
  • Voice countdown detail: how many seconds out to start counting (capped at one second under the interval so it never talks over the previous frame), which system voice to use, and whether to announce frame numbers
  • Bulb exposures, if the camera exposes bulb or eosremoterelease
  • The camera's own settings: shutter, aperture, ISO, image format, capture target, drive mode, and so on

Deploying

Deployed as an assets-only Cloudflare Worker:

cd examples/preact
./build-dist.sh            # copies just the browser files into dist/
npx wrangler deploy

Live at https://dslr-intervalometer.bournemouthtech.workers.dev.

build-dist.sh is an explicit allowlist rather than an ignore file, because everything in the assets directory becomes publicly readable and .assetsignore was not excluding node_modules.

The one thing a static host has to get right here: the WASM module is built with pthreads and allocates a shared WebAssembly.Memory, so the page must be cross-origin isolated or it fails to start outright. _headers sets the same COOP/COEP pair serve.json uses locally. If you host this anywhere else, set those two headers or nothing will work. Verify with crossOriginIsolated === true in the console.

Notes on the exposure and focus controls:

  • They're built from whatever the camera actually reports, so a body that doesn't expose one of them simply doesn't get that control rather than showing something that silently fails.
  • The 450D marks shutter and aperture read-only unless the mode dial is somewhere they apply — both are fixed in the green square, shutter in Av, aperture in Tv. When that happens the dropdowns disable themselves and say so rather than appearing to work.
  • Focus uses the EOS manualfocusdrive steps ("Near 1".."Far 3") and autofocusdrive for AF. Canon bodies generally only accept focus commands with live view running, so the panel warns if you've turned live view off.
  • Every change is a USB round-trip taking the best part of a second, and it shares the link with captures — changing settings mid-sequence works but can delay a frame.

Notes on the timing and its limits:

  • Frames are scheduled on an absolute grid (start + n × interval), so transfer time doesn't accumulate as drift over a long run. If a capture overruns its slot the next frame fires as soon as the camera is free and the overrun is logged, rather than a frame being dropped.
  • Three failed captures in a row abort the sequence.
  • Config polling only runs while the settings drawer is open; during a sequence the USB link is left to the captures.
  • Keep the tab in the foreground. Chrome throttles timers hard in hidden tabs, which will stretch your intervals.
  • Bulb is experimental. Bulb frames are written to the camera's card and are not downloaded — the WASM API only returns files produced by an explicit captureImageAsFile, and there's no hook for images arriving any other way. Set the capture target to the memory card and pull the card afterwards. For exposures of 30s or less, just set the shutter speed normally and leave bulb off.

Original demo

examples/preact started out as the upstream gPhoto2-on-the-Web demo — a live view next to the raw camera config tree — and the intervalometer above replaced it. The original is still hosted and documented upstream: A picture of DSLR camera connected via a USB cable to a laptop. The laptop is running the Web demo mentioned in the article, which mirrors a live video feed from the camera as well as allows to tweak its settings via form controls.

For the detailed technical write-up, see the official blog post. To see the demo in action, visit the hosted version here (but make sure to read the cross-platform compatibility notes first).

If you don't have a DSLR, you can check out a recording of the demo below:

https://user-images.githubusercontent.com/557590/152155035-a1664656-a7d9-411f-8cb3-5f04320f1391.mp4

Building

To build the WebAssembly part of the repo, you'll need Docker on Linux (WSL works too) or macOS machine. Then:

npm run build:wasm # runs build in Docker

If you are just updating the JS library (src/camera.ts), then it's enough to do

npm run build:ts

on any system as Wasm parts are committed to this repo.

To serve the demo, run:

npx serve examples/preact # starts a local server with COOP/COEP

Then, navigate to http://localhost:3000/ in Chrome.

Common Issues

SharedArrayBuffer can not be found SharedArrayBuffer has been disabled across all browsers due to the Spectre vulnerability. This package uses SharedArrayBuffer to communicate with the WebAssembly module. To work around this issue, you need to set two response headers for your document:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Information from Stackoverflow

Error: Not found: /node_modules/.vite/deps/libapi.wasm Vite tries to optimize the dependencies by default. This causes the WebAssembly module to be moved to a different location. To prevent this, you need to exclude the web-gphoto2 package from the optimization.

In vite, both of the above mentioned issues are solved by adding the following to your vite.config.js:

import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

/** @type {import('vite').Plugin} */
const viteServerConfig = {
  name: "add headers",
  configureServer: (server) => {
    server.middlewares.use((req, res, next) => {
      res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
      res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
      next();
    });
  },
};

export default defineConfig({
  plugins: [sveltekit(), viteServerConfig],
  optimizeDeps: {
    exclude: ["web-gphoto2"],
  },
});

See also

RReverser/eos-remote-web - my other project for controlling Canon cameras over Web Bluetooth.

License

Like the dependencies, this demo is licensed under LGPL v2.1.