Bunch of updates in preparation for publish

This commit is contained in:
Ingvar Stepanyan
2023-08-05 18:46:26 +01:00
parent a4052aef92
commit c80c04f006
23 changed files with 486 additions and 358 deletions

354
src/api.cpp Normal file
View File

@@ -0,0 +1,354 @@
/*
* 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
*/
#include <emscripten.h>
#include <emscripten/bind.h>
#include <emscripten/val.h>
#include <gphoto2/gphoto2.h>
#include <cstring>
#include <iostream>
using emscripten::val;
template <typename T, auto Deleter>
using gpp_unique_ptr =
std::unique_ptr<T, std::integral_constant<decltype(Deleter), Deleter>>;
using GPPWidget = gpp_unique_ptr<CameraWidget, gp_widget_unref>;
void gpp_try(int status) {
if (status != GP_OK) {
throw std::runtime_error(gp_result_as_string(status));
}
}
void gpp_log_error(GPContext *context, const char *text, void *data) {
std::cerr << text << std::endl;
}
#define GPP_CALL(RET, EXPR) \
({ \
RET OUT; \
RET *_ = &OUT; \
gpp_try(EXPR); \
OUT; \
})
EM_JS([[noreturn]] void, throw_msg, (const char *msg),
{ throw new Error(UTF8ToString(msg)); });
// This is workaround for Emscripten throwing C++ exceptions as meaningless
// numbers (pointers) instead of actual messages.
// To rethrow actual message, we manually wrap all Embind exports into
// callbacks, those callbacks are passed to this function,
// it ensures that all destructors are executed correctly, catches C++ exception
// if any, extracts the message and rethrows it as a JS one. Easy-peasy...
template <typename Func>
auto gpp_rethrow(Func func) {
try {
return func();
} catch (std::exception &e) {
throw_msg(e.what());
}
}
const thread_local val Uint8Array = val::global("Uint8Array");
const thread_local val Blob = val::global("Blob");
const thread_local val File = val::global("File");
const thread_local val arrayOf = val::global("Array")["of"];
class Context {
public:
Context() : camera(nullptr), context(nullptr) {
gpp_rethrow([=]() {
camera.reset(GPP_CALL(Camera *, gp_camera_new(_)));
context.reset(gp_context_new());
gp_context_set_error_func(context.get(), gpp_log_error, nullptr);
gpp_try(gp_camera_init(camera.get(), context.get()));
});
}
val supportedOps() {
return gpp_rethrow([=]() {
auto ops =
GPP_CALL(CameraAbilities, gp_camera_get_abilities(camera.get(), _))
.operations;
val result = val::object();
result.set("captureImage", (ops & GP_OPERATION_CAPTURE_IMAGE) != 0);
result.set("captureVideo", (ops & GP_OPERATION_CAPTURE_VIDEO) != 0);
result.set("captureAudio", (ops & GP_OPERATION_CAPTURE_AUDIO) != 0);
result.set("capturePreview", (ops & GP_OPERATION_CAPTURE_PREVIEW) != 0);
result.set("config", (ops & GP_OPERATION_CONFIG) != 0);
result.set("triggerCapture", (ops & GP_OPERATION_TRIGGER_CAPTURE) != 0);
return result;
});
}
bool consumeEvents() {
return gpp_rethrow([=]() {
bool had_events = false;
for (;;) {
CameraEventType event_type = GP_EVENT_UNKNOWN;
gpp_unique_ptr<void, free> event_data(GPP_CALL(
void *, gp_camera_wait_for_event(camera.get(), 0, &event_type, _,
context.get())));
if (event_type == GP_EVENT_TIMEOUT) {
break;
}
if (event_type == GP_EVENT_UNKNOWN) {
// EM_ASM({ console.log(UTF8ToString($0)); }, event_data.get());
}
had_events = true;
}
return had_events;
});
}
val configToJS() {
return gpp_rethrow([=]() {
GPPWidget config(
GPP_CALL(CameraWidget *,
gp_camera_get_config(camera.get(), _, context.get())));
return walk_config(config.get()).second;
});
}
void setConfigValue(std::string name, val value) {
gpp_rethrow([=]() {
GPPWidget widget(GPP_CALL(
CameraWidget *, gp_camera_get_single_config(
camera.get(), name.c_str(), _, context.get())));
auto type =
GPP_CALL(CameraWidgetType, gp_widget_get_type(widget.get(), _));
switch (type) {
case GP_WIDGET_RANGE: {
float number = value.as<float>();
gpp_try(gp_widget_set_value(widget.get(), &number));
break;
}
case GP_WIDGET_MENU:
case GP_WIDGET_RADIO:
case GP_WIDGET_TEXT: {
auto str = value.as<std::string>();
gpp_try(gp_widget_set_value(widget.get(), str.c_str()));
break;
}
case GP_WIDGET_TOGGLE: {
int on = value.as<bool>() ? 1 : 0;
gpp_try(gp_widget_set_value(widget.get(), &on));
break;
}
case GP_WIDGET_DATE: {
auto seconds = static_cast<int>(value.as<double>() / 1000);
gpp_try(gp_widget_set_value(widget.get(), &seconds));
break;
}
default: {
throw std::logic_error("unimplemented");
}
}
gpp_try(gp_camera_set_single_config(camera.get(), name.c_str(),
widget.get(), context.get()));
});
}
val capturePreviewAsBlob() {
return gpp_rethrow([=]() {
auto &file = get_file();
gpp_try(gp_camera_capture_preview(camera.get(), &file, context.get()));
auto params = blob_chunks_and_opts(file);
return Blob.new_(std::move(params.first), std::move(params.second));
});
}
val captureImageAsFile() {
return gpp_rethrow([=]() {
auto &file = get_file();
{
struct TempCameraFile {
Camera &camera;
GPContext &context;
CameraFilePath path;
~TempCameraFile() {
gp_camera_file_delete(&camera, path.folder, path.name, &context);
}
};
TempCameraFile camera_file{
.camera = *camera,
.context = *context,
};
strcpy(camera_file.path.folder, "/");
strcpy(camera_file.path.name, "web-gphoto2");
gpp_try(gp_camera_capture(camera.get(), GP_CAPTURE_IMAGE,
&camera_file.path, context.get()));
gpp_try(gp_camera_file_get(camera.get(), camera_file.path.folder,
camera_file.path.name, GP_FILE_TYPE_NORMAL,
&file, context.get()));
}
gpp_try(gp_file_set_name(&file, "image."));
gpp_try(gp_file_adjust_name_for_mime_type(&file));
auto name = val(GPP_CALL(const char *, gp_file_get_name(&file, _)));
auto params = blob_chunks_and_opts(file);
return File.new_(std::move(params.first), std::move(name),
std::move(params.second));
});
}
private:
gpp_unique_ptr<Camera, gp_camera_unref> camera;
gpp_unique_ptr<GPContext, gp_context_unref> context;
gpp_unique_ptr<CameraFile, gp_file_unref> file;
static std::pair<val, val> walk_config(CameraWidget *widget) {
val result = val::object();
val name(GPP_CALL(const char *, gp_widget_get_name(widget, _)));
result.set("name", name);
result.set("info", GPP_CALL(const char *, gp_widget_get_info(widget, _)));
result.set("label", GPP_CALL(const char *, gp_widget_get_label(widget, _)));
result.set("readonly",
GPP_CALL(int, gp_widget_get_readonly(widget, _)) != 0);
auto type = GPP_CALL(CameraWidgetType, gp_widget_get_type(widget, _));
switch (type) {
case GP_WIDGET_RANGE: {
result.set("type", "range");
result.set("value", GPP_CALL(float, gp_widget_get_value(widget, _)));
float min, max, step;
gpp_try(gp_widget_get_range(widget, &min, &max, &step));
result.set("min", min);
result.set("max", max);
result.set("step", step);
break;
}
case GP_WIDGET_MENU:
case GP_WIDGET_RADIO: {
result.set("type", type == GP_WIDGET_MENU ? "menu" : "radio");
result.set("value",
GPP_CALL(const char *, gp_widget_get_value(widget, _)));
val choices = val::array();
for (int i = 0, n = gp_widget_count_choices(widget); i < n; i++) {
choices.call<void>(
"push",
val(GPP_CALL(const char *, gp_widget_get_choice(widget, i, _))));
}
result.set("choices", choices);
break;
}
case GP_WIDGET_TOGGLE: {
result.set("type", "toggle");
int value = GPP_CALL(int, gp_widget_get_value(widget, _));
// Note: explicitly not adding `value` for any other values
// (e.g. camera actions often would return 2 here...)
switch (value) {
case 0:
result.set("value", false);
break;
case 1:
result.set("value", true);
break;
}
break;
}
case GP_WIDGET_TEXT: {
result.set("type", "text");
result.set("value",
GPP_CALL(const char *, gp_widget_get_value(widget, _)));
break;
}
case GP_WIDGET_WINDOW:
case GP_WIDGET_SECTION: {
result.set("type", type == GP_WIDGET_WINDOW ? "window" : "section");
val children = val::object();
for (int i = 0, n = gp_widget_count_children(widget); i < n; i++) {
auto child =
GPP_CALL(CameraWidget *, gp_widget_get_child(widget, i, _));
auto kv = walk_config(child);
children.set(kv.first, kv.second);
}
result.set("children", children);
break;
}
case GP_WIDGET_DATE: {
auto seconds = GPP_CALL(int, gp_widget_get_value(widget, _));
result.set("type", "datetime");
result.set("value", static_cast<double>(seconds) * 1000);
break;
}
default: {
throw std::logic_error("unimplemented");
}
}
return {name, result};
}
CameraFile &get_file() {
if (file.get() == nullptr) {
file.reset(GPP_CALL(CameraFile *, gp_file_new(_)));
}
return *file;
}
std::pair<val, val> blob_chunks_and_opts(CameraFile &file) {
auto mime_type = GPP_CALL(const char *, gp_file_get_mime_type(&file, _));
const char *data;
unsigned long size;
gpp_try(gp_file_get_data_and_size(&file, &data, &size));
val blob_opts = val::object();
blob_opts.set("type", mime_type);
return {arrayOf(Uint8Array.new_(emscripten::typed_memory_view(size, data))),
std::move(blob_opts)};
}
};
EMSCRIPTEN_BINDINGS(gphoto2_js_api) {
emscripten::class_<Context>("Context")
.constructor<>()
.function("configToJS", &Context::configToJS)
.function("setConfigValue", &Context::setConfigValue)
.function("capturePreviewAsBlob", &Context::capturePreviewAsBlob)
.function("captureImageAsFile", &Context::captureImageAsFile)
.function("consumeEvents", &Context::consumeEvents)
.function("supportedOps", &Context::supportedOps);
}

View File

@@ -1,103 +0,0 @@
/*
* 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";
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 = null;
export class Camera {
constructor() {
this.queue = Promise.resolve();
this.Module = null;
this.context = null;
}
static async showPicker() {
// @ts-ignore
await navigator.usb.requestDevice({
filters: [
{
classCode: INTERFACE_CLASS,
subclassCode: INTERFACE_SUBCLASS,
},
],
});
}
async connect() {
if (!ModulePromise) {
ModulePromise = initModule()
}
this.Module = await ModulePromise;
this.context = await new this.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.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) {
const uiTimeout = new Promise((resolve) => setTimeout(resolve, 800));
const setResult = this.schedule((context) =>
context.setConfigValue(name, value)
);
return Promise.all([setResult, 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());
}
}

106
src/camera.ts Normal file
View File

@@ -0,0 +1,106 @@
/*
* 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, { Context, Module } from '../build/libapi.mjs';
export type { Config, SupportedOps } from '../build/libapi.mjs';
// A helper that allows to distinguish critical errors from library errors.
export function rethrowIfCritical(err: any) {
// 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: Promise<Module>;
export class Camera {
#queue: Promise<any> = Promise.resolve();
#context: Context | null = 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<T>(op: (context: Context) => Promise<T>): Promise<T> {
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: string, value: string | number | boolean) {
let uiTimeout: Promise<void> | undefined;
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());
}
}

65
src/libapi.mjs.d.ts vendored
View File

@@ -1,65 +0,0 @@
/*
* 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<string, Config> }
| { type: 'section'; children: Record<string, Config> }
| { type: 'datetime'; value: number }
);
declare interface SupportedOps {
captureImage: boolean;
captureVideo: boolean;
captureAudio: boolean;
capturePreview: boolean;
config: boolean;
triggerCapture: boolean;
}
declare class Context {
configToJS(): Promise<Config & { type: 'window' }>;
setConfigValue(
name: string,
value: number | string | boolean
): Promise<undefined>;
capturePreviewAsBlob(): Promise<Blob>;
captureImageAsFile(): Promise<File>;
consumeEvents(): Promise<boolean>;
supportedOps(): SupportedOps;
delete(): void;
isDeleted(): boolean;
}
export interface Module extends EmscriptenModule {
Context: typeof Context;
}
export type { Config, Context, SupportedOps };
declare const initModule: EmscriptenModuleFactory<Module>;
export default initModule;

25
src/types.d.ts vendored
View File

@@ -1,25 +0,0 @@
// Import types Context and SupportedOps ./libapi.mjs.d.ts
import type { Config, Context, SupportedOps } from './libapi.mjs.d.ts';
export declare class Camera {
constructor();
static showPicker(): Promise<void>;
connect(): Promise<void>;
disconnect(): Promise<void>;
getConfig(): Promise<Config>;
getSupportedOps(): SupportedOps;
setConfigValue(name: string, value: number | string | boolean): Promise<void>;
capturePreviewAsBlob(): Promise<Blob>;
captureImageAsFile(): Promise<File>;
consumeEvents(): Promise<boolean>;
private rethrowIfCritical(err: any): void; // any error type can be replaced by more specific if available
private schedule<T>(op: (ctx: any) => Promise<T>): Promise<T>; // ctx and T types can be replaced by more specific if available
}
export type { Config, Context, SupportedOps };