diff --git a/.gitattributes b/.gitattributes index d2f5c59..0f8a342 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1 @@ -build/* binary -build/* linguist-generated=true \ No newline at end of file +build/* linguist-generated=true diff --git a/.gitignore b/.gitignore index 242d092..93e9743 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /.vscode/c_cpp_properties.json -/node_modules +node_modules /deps/* !/deps/libgphoto2 !/deps/libusb diff --git a/Makefile b/Makefile index e9585c9..7a1e2a5 100644 --- a/Makefile +++ b/Makefile @@ -15,15 +15,15 @@ export LDFLAGS += $(COMMON_FLAGS) ## Main API module -build/libapi.mjs: api.o $(SYSROOT)/lib/libltdl.la $(SYSROOT)/lib/libgphoto2.la +build/libapi.mjs: src/api.o $(SYSROOT)/lib/libltdl.la $(SYSROOT)/lib/libgphoto2.la libtool --verbose --mode=link $(LD) $(LDFLAGS) -o $@ $+ \ -fexceptions --bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH \ -dlpreopen $(SYSROOT)/lib/libgphoto2/2.5.28.1/ptp2.la \ -dlpreopen $(SYSROOT)/lib/libgphoto2_port/0.12.0/usb1.la -api.o: deps/libgphoto2/configure.ac -api.o: CPPFLAGS += -Ideps/libgphoto2 -Ideps/libgphoto2/libgphoto2_port -api.o: CXXFLAGS += -std=c++17 -fexceptions -pthread +src/api.o: deps/libgphoto2/configure.ac +src/api.o: CPPFLAGS += -Ideps/libgphoto2 -Ideps/libgphoto2/libgphoto2_port +src/api.o: CXXFLAGS += -std=c++17 -fexceptions -pthread ## Generic rules for deps diff --git a/README.md b/README.md index 3d4e050..5e4d5fa 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,14 @@ A gPhoto2 implementation using WebAssembly to control DSLR cameras from the brow Powered by a [custom fork](https://github.com/RReverser/libgphoto2) of [libgphoto2](https://github.com/gphoto/libgphoto2), the [WebUSB](https://github.com/WICG/webusb) backend of [libusb](https://github.com/libusb/libusb), and WebAssembly via [Emscripten](https://emscripten.org/). -# NPM - ## Installation + ```bash npm install web-gphoto2 -// or -yarn add web-gphoto2 ``` ## Usage + A short example on how to use this package: ```ts @@ -55,7 +53,7 @@ async function captureImageAsFile() { } ``` -# Demo +## Demo This repository also contains a [demo app](https://web.dev/porting-libusb-to-webusb/) running gPhoto2 on the Web: ![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.](https://web-dev.imgix.net/image/9oK23mr86lhFOwKaoYZ4EySNFp02/MR4YGRvl0Z9AWT6vv3sQ.jpg?auto=format&w=1600) @@ -64,20 +62,21 @@ For the detailed technical write-up, see [the official blog post](https://web.de 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, you'll need Docker. Then: +To build the WebAssembly part of the repo, you'll need Docker. Then: ```bash ./build.sh # runs build in Docker npx serve examples/preact # starts a local server with COOP/COEP ``` -Then, navigate to http://localhost:3000/ in Chrome. +Then, navigate to in Chrome. ## Common Issues +
SharedArrayBuffer can not be found @@ -123,6 +122,7 @@ export default defineConfig({ }, }); ``` +
## See also diff --git a/build/camera.d.ts b/build/camera.d.ts new file mode 100644 index 0000000..ff3b55b --- /dev/null +++ b/build/camera.d.ts @@ -0,0 +1,24 @@ +export type { Config, SupportedOps } from '../build/libapi.mjs'; +export declare function rethrowIfCritical(err: any): void; +export declare class Camera { + #private; + static showPicker(): Promise; + connect(): Promise; + disconnect(): Promise; + getConfig(): Promise<{ + name: string; + info: string; + label: string; + readonly: boolean; + } & { + type: "window"; + children: Record; + } & { + type: "window"; + }>; + getSupportedOps(): Promise; + setConfigValue(name: string, value: string | number | boolean): Promise; + capturePreviewAsBlob(): Promise; + captureImageAsFile(): Promise; + consumeEvents(): Promise; +} diff --git a/build/camera.js b/build/camera.js new file mode 100644 index 0000000..45ceee0 --- /dev/null +++ b/build/camera.js @@ -0,0 +1,89 @@ +/* + * Copyright 2023 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 initModule from '../build/libapi.mjs'; +// A helper that allows to distinguish critical errors from library errors. +export function rethrowIfCritical(err) { + // If it's precisely Error, it's a custom error; anything else - SyntaxError, + // WebAssembly.RuntimeError, TypeError, etc. - is treated as critical here. + if (err?.constructor !== Error) { + throw err; + } +} +const INTERFACE_CLASS = 6; // PTP +const INTERFACE_SUBCLASS = 1; // MTP +let ModulePromise; +export class Camera { + #queue = Promise.resolve(); + #context = null; + static async showPicker() { + // @ts-ignore + await navigator.usb.requestDevice({ + filters: [ + { + classCode: INTERFACE_CLASS, + subclassCode: INTERFACE_SUBCLASS + } + ] + }); + } + async connect() { + if (!ModulePromise) { + ModulePromise = initModule(); + } + let Module = await ModulePromise; + this.#context = await new Module.Context(); + } + async #schedule(op) { + let res = this.#queue.then(() => op(this.#context)); + this.#queue = res.catch(rethrowIfCritical); + return res; + } + async disconnect() { + if (this.#context && !this.#context.isDeleted()) { + this.#context.delete(); + } + } + async getConfig() { + return this.#schedule(context => context.configToJS()); + } + async getSupportedOps() { + if (this.#context) { + return await this.#context.supportedOps(); + } + throw new Error('You need to connect to the camera first'); + } + async setConfigValue(name, value) { + let uiTimeout; + await this.#schedule(context => { + // This is terrible, yes... but some configs return too quickly before they're actually updated. + // We want to wait some time before updating the UI in that case, but not block subsequent ops. + uiTimeout = new Promise(resolve => setTimeout(resolve, 800)); + return context.setConfigValue(name, value); + }); + await uiTimeout; + } + async capturePreviewAsBlob() { + return this.#schedule(context => context.capturePreviewAsBlob()); + } + async captureImageAsFile() { + return this.#schedule(context => context.captureImageAsFile()); + } + async consumeEvents() { + return this.#schedule(context => context.consumeEvents()); + } +} diff --git a/src/libapi.mjs.d.ts b/build/libapi.mjs.d.ts similarity index 78% rename from src/libapi.mjs.d.ts rename to build/libapi.mjs.d.ts index 1350577..f12935b 100644 --- a/src/libapi.mjs.d.ts +++ b/build/libapi.mjs.d.ts @@ -1,65 +1,65 @@ -/* - * 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 - */ - -type Config = { - name: string; - info: string; - label: string; - readonly: boolean; -} & ( - | { type: 'range'; value: number; min: number; max: number; step: number } - | { type: 'menu' | 'radio'; value: string; choices: string[] } - | { type: 'toggle'; value: boolean } - | { type: 'text'; value: string } - | { type: 'window'; children: Record } - | { type: 'section'; children: Record } - | { type: 'datetime'; value: number } - ); - -declare interface SupportedOps { - captureImage: boolean; - captureVideo: boolean; - captureAudio: boolean; - capturePreview: boolean; - config: boolean; - triggerCapture: boolean; -} - -declare class Context { - configToJS(): Promise; - setConfigValue( - name: string, - value: number | string | boolean - ): Promise; - capturePreviewAsBlob(): Promise; - captureImageAsFile(): Promise; - consumeEvents(): Promise; - supportedOps(): SupportedOps; - - delete(): void; - isDeleted(): boolean; -} - -export interface Module extends EmscriptenModule { - Context: typeof Context; -} - -export type { Config, Context, SupportedOps }; - -declare const initModule: EmscriptenModuleFactory; -export default initModule; +/* + * 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 + */ + +type Config = { + name: string; + info: string; + label: string; + readonly: boolean; +} & ( + | { type: 'range'; value: number; min: number; max: number; step: number } + | { type: 'menu' | 'radio'; value: string; choices: string[] } + | { type: 'toggle'; value: boolean } + | { type: 'text'; value: string } + | { type: 'window'; children: Record } + | { type: 'section'; children: Record } + | { type: 'datetime'; value: number } +); + +declare interface SupportedOps { + captureImage: boolean; + captureVideo: boolean; + captureAudio: boolean; + capturePreview: boolean; + config: boolean; + triggerCapture: boolean; +} + +declare class Context { + configToJS(): Promise; + setConfigValue( + name: string, + value: number | string | boolean + ): Promise; + capturePreviewAsBlob(): Promise; + captureImageAsFile(): Promise; + consumeEvents(): Promise; + supportedOps(): SupportedOps; + + delete(): void; + isDeleted(): boolean; +} + +export interface Module extends EmscriptenModule { + Context: typeof Context; +} + +export type { Config, Context, SupportedOps }; + +declare const initModule: EmscriptenModuleFactory; +export default initModule; diff --git a/examples/preact/build b/examples/preact/build deleted file mode 120000 index 8fdb6a2..0000000 --- a/examples/preact/build +++ /dev/null @@ -1 +0,0 @@ -../../build \ No newline at end of file diff --git a/examples/preact/index.html b/examples/preact/index.html index 841d2bc..a54626c 100644 --- a/examples/preact/index.html +++ b/examples/preact/index.html @@ -43,6 +43,7 @@