From 8ca7b0990bc5b0d428bffe846d2a128fb2891ba0 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Thu, 29 May 2025 04:15:11 +0900 Subject: [PATCH 01/17] Add CameraFeed support for Web --- doc/classes/CameraFeed.xml | 2 +- doc/classes/CameraServer.xml | 2 +- modules/camera/SCsub | 5 +- modules/camera/camera_web.cpp | 179 ++++++++ modules/camera/camera_web.h | 72 ++++ modules/camera/config.py | 2 +- modules/camera/register_types.cpp | 6 + platform/web/SCsub | 5 + platform/web/camera_driver_web.cpp | 191 +++++++++ platform/web/camera_driver_web.h | 76 ++++ platform/web/godot_camera.h | 60 +++ platform/web/js/libs/library_godot_camera.js | 414 +++++++++++++++++++ 12 files changed, 1010 insertions(+), 4 deletions(-) create mode 100644 modules/camera/camera_web.cpp create mode 100644 modules/camera/camera_web.h create mode 100644 platform/web/camera_driver_web.cpp create mode 100644 platform/web/camera_driver_web.h create mode 100644 platform/web/godot_camera.h create mode 100644 platform/web/js/libs/library_godot_camera.js diff --git a/doc/classes/CameraFeed.xml b/doc/classes/CameraFeed.xml index 7623ae3ed864..77a17cb0d0aa 100644 --- a/doc/classes/CameraFeed.xml +++ b/doc/classes/CameraFeed.xml @@ -6,7 +6,7 @@ A camera feed gives you access to a single physical camera attached to your device. When enabled, Godot will start capturing frames from the camera which can then be used. See also [CameraServer]. [b]Note:[/b] Many cameras will return YCbCr images which are split into two textures and need to be combined in a shader. Godot does this automatically for you if you set the environment to show the camera image in the background. - [b]Note:[/b] This class is currently only implemented on Linux, Android, macOS, and iOS. On other platforms no [CameraFeed]s will be available. To get a [CameraFeed] on iOS, the camera plugin from [url=https://github.com/godotengine/godot-ios-plugins]godot-ios-plugins[/url] is required. + [b]Note:[/b] This class is currently only implemented on Linux, Android, Web, macOS, and iOS. On other platforms no [CameraFeed]s will be available. To get a [CameraFeed] on iOS, the camera plugin from [url=https://github.com/godotengine/godot-ios-plugins]godot-ios-plugins[/url] is required. diff --git a/doc/classes/CameraServer.xml b/doc/classes/CameraServer.xml index 3b16768a3b06..680df69aca38 100644 --- a/doc/classes/CameraServer.xml +++ b/doc/classes/CameraServer.xml @@ -6,7 +6,7 @@ The [CameraServer] keeps track of different cameras accessible in Godot. These are external cameras such as webcams or the cameras on your phone. It is notably used to provide AR modules with a video feed from the camera. - [b]Note:[/b] This class is currently only implemented on Linux, Android, macOS, and iOS. On other platforms no [CameraFeed]s will be available. To get a [CameraFeed] on iOS, the camera plugin from [url=https://github.com/godotengine/godot-ios-plugins]godot-ios-plugins[/url] is required. + [b]Note:[/b] This class is currently only implemented on Linux, Android, Web, macOS, and iOS. On other platforms no [CameraFeed]s will be available. To get a [CameraFeed] on iOS, the camera plugin from [url=https://github.com/godotengine/godot-ios-plugins]godot-ios-plugins[/url] is required. diff --git a/modules/camera/SCsub b/modules/camera/SCsub index 750dc7c368c1..2bba61ec03bf 100644 --- a/modules/camera/SCsub +++ b/modules/camera/SCsub @@ -6,7 +6,7 @@ Import("env_modules") env_camera = env_modules.Clone() -if env["platform"] in ["windows", "macos", "linuxbsd", "android"]: +if env["platform"] in ["windows", "macos", "linuxbsd", "android", "web"]: env_camera.add_source_files(env.modules_sources, "register_types.cpp") if env["platform"] == "windows": @@ -23,3 +23,6 @@ elif env["platform"] == "linuxbsd": env_camera.add_source_files(env.modules_sources, "camera_linux.cpp") env_camera.add_source_files(env.modules_sources, "camera_feed_linux.cpp") env_camera.add_source_files(env.modules_sources, "buffer_decoder.cpp") + +elif env["platform"] == "web": + env_camera.add_source_files(env.modules_sources, "camera_web.cpp") diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp new file mode 100644 index 000000000000..42b5903dfe96 --- /dev/null +++ b/modules/camera/camera_web.cpp @@ -0,0 +1,179 @@ +/**************************************************************************/ +/* camera_web.cpp */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +#include "camera_web.h" + +#include "core/io/json.h" + +void CameraFeedWeb::_on_get_pixeldata(void *context, const uint8_t *rawdata, const int length, const int p_width, const int p_height, const char *error) { + CameraFeedWeb *feed = reinterpret_cast(context); + if (error) { + if (feed->is_active()) { + feed->deactivate_feed(); + }; + String error_str = String::utf8(error); + ERR_PRINT(vformat("Camera feed error from JS: %s", error_str)); + return; + } + + if (context == nullptr || rawdata == nullptr || length < 0 || p_width <= 0 || p_height <= 0) { + if (feed->is_active()) { + feed->deactivate_feed(); + }; + ERR_PRINT("Camera feed error: Invalid pixel data received."); + return; + } + + Vector data = feed->data; + Ref image = feed->image; + + if (length != data.size()) { + int64_t size = Image::get_image_data_size(p_width, p_height, Image::FORMAT_RGBA8, false); + data.resize(length > size ? length : size); + } + memcpy(data.ptrw(), rawdata, length); + + image->initialize_data(p_width, p_height, false, Image::FORMAT_RGBA8, data); + feed->set_rgb_image(image); + feed->emit_signal(SNAME("frame_changed")); +} + +void CameraFeedWeb::_on_denied_callback(void *context) { + CameraFeedWeb *feed = reinterpret_cast(context); + feed->deactivate_feed(); +} + +bool CameraFeedWeb::activate_feed() { + ERR_FAIL_COND_V_MSG(selected_format == -1, false, "CameraFeed format needs to be set before activating."); + + CameraFeed::FeedFormat f = formats[selected_format]; + int width = parameters.get(KEY_WIDTH, 0); + int height = parameters.get(KEY_HEIGHT, 0); + width = width > 0 ? width : f.width; + height = height > 0 ? height : f.height; + CameraDriverWeb::get_singleton()->get_pixel_data(this, device_id, width, height, &_on_get_pixeldata, &_on_denied_callback); + return true; +} + +void CameraFeedWeb::deactivate_feed() { + CameraDriverWeb::get_singleton()->stop_stream(device_id); +} + +bool CameraFeedWeb::set_format(int p_index, const Dictionary &p_parameters) { + ERR_FAIL_COND_V_MSG(active, false, "Feed is active."); + ERR_FAIL_INDEX_V_MSG(p_index, formats.size(), false, "Invalid format index."); + + selected_format = p_index; + parameters = p_parameters; + return true; +} + +Array CameraFeedWeb::get_formats() const { + Array result; + for (const FeedFormat &feed_format : formats) { + Dictionary dictionary; + dictionary["width"] = feed_format.width; + dictionary["height"] = feed_format.height; + dictionary["format"] = feed_format.format; + result.push_back(dictionary); + } + return result; +} + +CameraFeed::FeedFormat CameraFeedWeb::get_format() const { + CameraFeed::FeedFormat feed_format = {}; + return selected_format == -1 ? feed_format : formats[selected_format]; +} + +CameraFeedWeb::CameraFeedWeb(const CameraInfo &info) { + name = info.label; + device_id = info.device_id; + + Vector capabilities; + CameraDriverWeb::get_singleton()->get_capabilities(&capabilities, device_id); + for (int i = 0; i < capabilities.size(); i++) { + CapabilityInfo capability = capabilities[i]; + FeedFormat feed_format; + feed_format.width = capability.width; + feed_format.height = capability.height; + feed_format.format = String("RGBA"); + formats.append(feed_format); + } + + image.instantiate(); +} + +CameraFeedWeb::~CameraFeedWeb() { + if (is_active()) { + deactivate_feed(); + } +} + +void CameraWeb::_update_feeds() { + for (int i = feeds.size() - 1; i >= 0; i--) { + remove_feed(feeds[i]); + } + + Vector camera_info; + camera_driver_web->get_cameras(&camera_info); + for (int i = 0; i < camera_info.size(); i++) { + CameraInfo info = camera_info[i]; + Ref feed = memnew(CameraFeedWeb(info)); + add_feed(feed); + } +} + +void CameraWeb::_cleanup() { + if (camera_driver_web != nullptr) { + camera_driver_web->stop_stream(); + memdelete(camera_driver_web); + camera_driver_web = nullptr; + } +} + +void CameraWeb::set_monitoring_feeds(bool p_monitoring_feeds) { + if (p_monitoring_feeds == monitoring_feeds) { + return; + } + + CameraServer::set_monitoring_feeds(p_monitoring_feeds); + if (p_monitoring_feeds) { + if (camera_driver_web == nullptr) { + camera_driver_web = new CameraDriverWeb(); + } + _update_feeds(); + } else { + _cleanup(); + } +} + +CameraWeb::~CameraWeb() { + _cleanup(); +} diff --git a/modules/camera/camera_web.h b/modules/camera/camera_web.h new file mode 100644 index 000000000000..c8b291b98f03 --- /dev/null +++ b/modules/camera/camera_web.h @@ -0,0 +1,72 @@ +/**************************************************************************/ +/* camera_web.h */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +#pragma once + +#include "platform/web/camera_driver_web.h" +#include "servers/camera/camera_feed.h" +#include "servers/camera_server.h" + +class CameraFeedWeb : public CameraFeed { + GDCLASS(CameraFeedWeb, CameraFeed); + +private: + String device_id; + Ref image; + Vector data; + static void _on_get_pixeldata(void *, const uint8_t *, const int, const int, const int, const char *error); + static void _on_denied_callback(void *context); + +protected: +public: + bool activate_feed() override; + void deactivate_feed() override; + bool set_format(int p_index, const Dictionary &p_parameters) override; + Array get_formats() const override; + FeedFormat get_format() const override; + + CameraFeedWeb(const CameraInfo &info); + ~CameraFeedWeb(); +}; + +class CameraWeb : public CameraServer { + GDCLASS(CameraWeb, CameraServer); + +private: + CameraDriverWeb *camera_driver_web = nullptr; + void _cleanup(); + void _update_feeds(); + +protected: +public: + void set_monitoring_feeds(bool p_monitoring_feeds) override; + + ~CameraWeb(); +}; diff --git a/modules/camera/config.py b/modules/camera/config.py index d699d4e456c8..b97fdd0bcd58 100644 --- a/modules/camera/config.py +++ b/modules/camera/config.py @@ -3,7 +3,7 @@ def can_build(env, platform): if sys.platform.startswith("freebsd") or sys.platform.startswith("openbsd"): return False - return platform == "macos" or platform == "windows" or platform == "linuxbsd" or platform == "android" + return platform in ["macos", "windows", "linuxbsd", "android", "web"] def configure(env): diff --git a/modules/camera/register_types.cpp b/modules/camera/register_types.cpp index c56ddfe862e3..b82b57fe991c 100644 --- a/modules/camera/register_types.cpp +++ b/modules/camera/register_types.cpp @@ -42,6 +42,9 @@ #if defined(ANDROID_ENABLED) #include "camera_android.h" #endif +#if defined(WEB_ENABLED) +#include "camera_web.h" +#endif void initialize_camera_module(ModuleInitializationLevel p_level) { if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) { @@ -60,6 +63,9 @@ void initialize_camera_module(ModuleInitializationLevel p_level) { #if defined(ANDROID_ENABLED) CameraServer::make_default(); #endif +#if defined(WEB_ENABLED) + CameraServer::make_default(); +#endif } void uninitialize_camera_module(ModuleInitializationLevel p_level) { diff --git a/platform/web/SCsub b/platform/web/SCsub index ea974acaa4a3..70ae06247e3a 100644 --- a/platform/web/SCsub +++ b/platform/web/SCsub @@ -22,6 +22,7 @@ if "serve" in COMMAND_LINE_TARGETS or "run" in COMMAND_LINE_TARGETS: web_files = [ "audio_driver_web.cpp", + "camera_driver_web.cpp", "webmidi_driver.cpp", "display_server_web.cpp", "http_client_web.cpp", @@ -37,6 +38,7 @@ sys_env = env.Clone() sys_env.AddJSLibraries( [ "js/libs/library_godot_audio.js", + "js/libs/library_godot_camera.js", "js/libs/library_godot_display.js", "js/libs/library_godot_emscripten.js", "js/libs/library_godot_fetch.js", @@ -108,6 +110,9 @@ else: sys_env.Append(LIBS=["idbfs.js"]) build = sys_env.add_program(build_targets, web_files + ["web_runtime.cpp"]) +sys_env.Append(LINKFLAGS=["-s", "ASYNCIFY=2"]) +sys_env.Append(LINKFLAGS=["-s", "ASYNCIFY_IMPORTS=['godot_js_camera_get_cameras', 'godot_js_camera_get_capabilities']"]) + sys_env.Depends(build[0], sys_env["JS_LIBS"]) sys_env.Depends(build[0], sys_env["JS_PRE"]) sys_env.Depends(build[0], sys_env["JS_POST"]) diff --git a/platform/web/camera_driver_web.cpp b/platform/web/camera_driver_web.cpp new file mode 100644 index 000000000000..43fe101d7154 --- /dev/null +++ b/platform/web/camera_driver_web.cpp @@ -0,0 +1,191 @@ +/**************************************************************************/ +/* camera_driver_web.cpp */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +#include "camera_driver_web.h" + +#include "core/io/json.h" + +#include + +EM_ASYNC_JS(void, godot_js_camera_get_cameras, (void *context, CameraLibrary_OnGetCamerasCallback p_callback_ptr), { + await GodotCamera.api.getCameras(context, p_callback_ptr); +}); + +EM_ASYNC_JS(void, godot_js_camera_get_capabilities, (void *context, const char *p_device_id_ptr, CameraLibrary_OnGetCapabilitiesCallback p_callback_ptr), { + await GodotCamera.api.getCameraCapabilities(p_device_id_ptr, context, p_callback_ptr); +}); + +CameraDriverWeb *CameraDriverWeb::singleton = nullptr; +Array CameraDriverWeb::_camera_info_key; + +CameraDriverWeb *CameraDriverWeb::get_singleton() { + _camera_info_key.clear(); + _camera_info_key.push_back(KEY_INDEX); + _camera_info_key.push_back(KEY_ID); + _camera_info_key.push_back(KEY_LABEL); + return singleton; +} + +void CameraDriverWeb::_on_get_cameras_callback(void *context, const char *json_ptr) { + if (!json_ptr) { + print_error("CameraDriverWeb::_on_get_cameras_callback: json_ptr is null"); + return; + } + String json_string = String::utf8(json_ptr); + Variant json_variant = JSON::parse_string(json_string); + + if (json_variant.get_type() == Variant::DICTIONARY) { + Dictionary json_dict = json_variant; + Variant v_error = json_dict[KEY_ERROR]; + if (v_error.get_type() == Variant::STRING) { + String error_str = v_error; + ERR_PRINT(vformat("Camera error from JS: %s", error_str)); + return; + } + Variant v_devices = json_dict.get(KEY_CAMERAS, Variant()); + if (v_devices.get_type() != Variant::ARRAY) { + ERR_PRINT("Camera error: 'cameras' is not an array or missing."); + return; + } + Array devices_array = v_devices; + Vector *camera_info = reinterpret_cast *>(context); + camera_info->clear(); + for (int i = 0; i < devices_array.size(); i++) { + Variant device_variant = devices_array.get(i); + if (device_variant.get_type() == Variant::DICTIONARY) { + Dictionary device_dict = device_variant; + if (device_dict.has_all(_camera_info_key)) { + CameraInfo info; + info.index = device_dict[KEY_INDEX]; + info.device_id = device_dict[KEY_ID]; + info.label = device_dict[KEY_LABEL]; + camera_info->push_back(info); + } else { + WARN_PRINT("Camera info entry missing required keys (index, id, label)."); + } + } + } + } else { + ERR_PRINT("CameraDriverWeb::_on_get_cameras_callback: Failed to parse JSON response or response is not a Dictionary."); + } +} + +void CameraDriverWeb::_on_get_capabilities_callback(void *context, const char *json_ptr) { + if (!json_ptr) { + ERR_PRINT("CameraDriverWeb::_on_get_capabilities_callback: json_ptr is null"); + return; + } + String json_string = String::utf8(json_ptr); + Variant json_variant = JSON::parse_string(json_string); + + if (json_variant.get_type() == Variant::DICTIONARY) { + Dictionary json_dict = json_variant; + Variant v_error = json_dict[KEY_ERROR]; + if (v_error.get_type() == Variant::STRING) { + String error_str = v_error; + ERR_PRINT(vformat("Camera capabilities error from JS: %s", error_str)); + return; + } + Variant v_caps_data = json_dict.get(KEY_CAPABILITIES, Variant()); + if (v_caps_data.get_type() != Variant::DICTIONARY) { + ERR_PRINT("Camera capabilities error: 'capabilities' data is not a dictionary or missing."); + return; + } + Dictionary caps_dict = v_caps_data; + Vector *capabilities = reinterpret_cast *>(context); + capabilities->clear(); + + if (caps_dict.has(KEY_WIDTH) && caps_dict.has(KEY_HEIGHT)) { + Variant v_width_val = caps_dict.get(KEY_WIDTH, Variant()); + Variant v_height_val = caps_dict.get(KEY_HEIGHT, Variant()); + int width = 0; + int height = 0; + + // Helper to extract 'max' from a capability dictionary or use direct value + auto get_max_or_direct = [](const Variant &p_val) -> int { + if (p_val.get_type() == Variant::DICTIONARY) { + Dictionary d = p_val; + if (d.has(KEY_MAX)) { + return d[KEY_MAX]; + } + } else if (p_val.get_type() == Variant::INT) { + return p_val; + } else if (p_val.get_type() == Variant::FLOAT) { + return static_cast(p_val.operator float()); + } + return 0; + }; + + width = get_max_or_direct(v_width_val); + height = get_max_or_direct(v_height_val); + + if (width > 0 && height > 0) { + CapabilityInfo info; + info.width = width; + info.height = height; + capabilities->push_back(info); + } else { + WARN_PRINT("Could not extract valid width/height from capabilities structure."); + } + } else { + WARN_PRINT("Capabilities object does not directly contain top-level width/height keys."); + } + } else { + ERR_PRINT("CameraDriverWeb::_on_get_capabilities_callback: Failed to parse JSON response or response is not a Dictionary."); + } +} + +void CameraDriverWeb::get_cameras(Vector *r_camera_info) { + godot_js_camera_get_cameras((void *)r_camera_info, &_on_get_cameras_callback); +} + +void CameraDriverWeb::get_capabilities(Vector *r_capabilities, const String &p_device_id) { + godot_js_camera_get_capabilities((void *)r_capabilities, p_device_id.utf8().get_data(), &_on_get_capabilities_callback); +} + +void CameraDriverWeb::get_pixel_data(void *context, const String &p_device_id, const int width, const int height, CameraLibrary_OnGetPixelDataCallback p_callback, CameraLibrary_OnDeniedCallback p_denied_callback) { + godot_js_camera_get_pixel_data(context, p_device_id.utf8().get_data(), width, height, p_callback, p_denied_callback); +} + +void CameraDriverWeb::stop_stream(const String &device_id) { + godot_js_camera_stop_stream(device_id.utf8().get_data()); +} + +CameraDriverWeb::CameraDriverWeb() { + if (singleton == nullptr) { + singleton = this; + } +} + +CameraDriverWeb::~CameraDriverWeb() { + if (singleton == this) { + singleton = nullptr; + } +} diff --git a/platform/web/camera_driver_web.h b/platform/web/camera_driver_web.h new file mode 100644 index 000000000000..4c4e4054f0e8 --- /dev/null +++ b/platform/web/camera_driver_web.h @@ -0,0 +1,76 @@ +/**************************************************************************/ +/* camera_driver_web.h */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +#pragma once + +#include "godot_camera.h" +#include "godot_js.h" + +#include "core/string/ustring.h" +#include "core/templates/vector.h" + +#define KEY_CAMERAS String("cameras") +#define KEY_CAPABILITIES String("capabilities") +#define KEY_ERROR String("error") +#define KEY_HEIGHT String("height") +#define KEY_ID String("id") +#define KEY_INDEX String("index") +#define KEY_LABEL String("label") +#define KEY_MAX String("max") +#define KEY_WIDTH String("width") + +struct CameraInfo { + int index; + String device_id; + String label; +}; + +struct CapabilityInfo { + int width; + int height; +}; + +class CameraDriverWeb { +private: + static CameraDriverWeb *singleton; + static Array _camera_info_key; + WASM_EXPORT static void _on_get_cameras_callback(void *context, const char *json_ptr); + WASM_EXPORT static void _on_get_capabilities_callback(void *context, const char *json_ptr); + +public: + static CameraDriverWeb *get_singleton(); + void get_cameras(Vector *camera_info); + void get_capabilities(Vector *capabilities, const String &p_device_id); + void get_pixel_data(void *context, const String &p_device_id, const int width, const int height, CameraLibrary_OnGetPixelDataCallback p_callback, CameraLibrary_OnDeniedCallback p_denied_callback); + void stop_stream(const String &device_id = String()); + + CameraDriverWeb(); + ~CameraDriverWeb(); +}; diff --git a/platform/web/godot_camera.h b/platform/web/godot_camera.h new file mode 100644 index 000000000000..7a1aa23306f8 --- /dev/null +++ b/platform/web/godot_camera.h @@ -0,0 +1,60 @@ +/**************************************************************************/ +/* godot_camera.h */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +using CameraLibrary_OnGetCamerasCallback = void (*)(void *, const char *); + +using CameraLibrary_OnGetCapabilitiesCallback = void (*)(void *, const char *); + +using CameraLibrary_OnGetPixelDataCallback = void (*)(void *, const uint8_t *, const int, const int, const int, const char *); + +using CameraLibrary_OnDeniedCallback = void (*)(void *); + +extern void godot_js_camera_get_pixel_data( + void *context, + const char *p_device_id_ptr, + const int width, + const int height, + CameraLibrary_OnGetPixelDataCallback p_callback_ptr, + CameraLibrary_OnDeniedCallback p_denied_callback_ptr); + +extern void godot_js_camera_stop_stream(const char *p_device_id_ptr = nullptr); + +#ifdef __cplusplus +} +#endif diff --git a/platform/web/js/libs/library_godot_camera.js b/platform/web/js/libs/library_godot_camera.js new file mode 100644 index 000000000000..85cfbda85622 --- /dev/null +++ b/platform/web/js/libs/library_godot_camera.js @@ -0,0 +1,414 @@ +/**************************************************************************/ +/* library_godot_camera.js */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +/** + * @typedef {{ + * deviceId: string + * label: string + * index: number + * }} CameraInfo + * + * @typedef {{ + * video: HTMLVideoElement? + * canvas: HTMLCanvasElement | OffscreenCanvas? + * canvasContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D? + * stream: MediaStream? + * animationFrameId: number? + * permissionListener: Function? + * permissionStatus: PermissionStatus? + * }} CameraResource + */ + +const GodotCamera = { + $GodotCamera__deps: ['$GodotRuntime', '$GodotConfig', '$GodotOS'], + $GodotCamera__postset: 'GodotOS.atexit(function(resolve, reject) { GodotCamera.cleanup(); resolve(); });', + $GodotCamera: { + /** + * Map to manage resources for each camera. + * @type {Map} + */ + cameras: new Map(), + + /** + * Ensures cameras Map is properly initialized. + * @returns {Map} + */ + ensureCamerasMap: function () { + if (!this.cameras || !(this.cameras instanceof Map)) { + this.cameras = new Map(); + } + return this.cameras; + }, + + /** + * Cleanup all camera resources. + * @returns {void} + */ + cleanup: function () { + this.api.stop(); + }, + + /** + * Sends a JSON result to the callback function. + * @param {Function} callback Callback function pointer + * @param {number} context Context value to pass to callback + * @param {Object} result Result object to stringify + * @returns {void} + */ + sendCallbackResult: function (callback, context, result) { + const jsonStr = JSON.stringify(result); + const strPtr = GodotRuntime.allocString(jsonStr); + callback(context, strPtr); + GodotRuntime.free(strPtr); + }, + + /** + * Sends pixel data or error to the callback function. + * @param {Function} callback Callback function pointer + * @param {number} context Context value to pass to callback + * @param {number} dataPtr Pointer to pixel data + * @param {number} dataLen Length of pixel data + * @param {number} width Image width + * @param {number} height Image height + * @param {string?} errorMsg Error message if any + * @returns {void} + */ + sendErrorCallback: function (callback, context, dataPtr, dataLen, width, height, errorMsg) { + const errorMsgPtr = errorMsg ? GodotRuntime.allocString(errorMsg) : 0; + callback(context, dataPtr, dataLen, width, height, errorMsgPtr); + if (errorMsgPtr) { + GodotRuntime.free(errorMsgPtr); + } + }, + + /** + * Cleans up resources for a specific camera. + * @param {CameraResource} camera Camera resource to cleanup + * @returns {void} + */ + cleanupCamera: function (camera) { + if (camera.animationFrameId) { + cancelAnimationFrame(camera.animationFrameId); + } + + if (camera.stream) { + camera.stream.getTracks().forEach((track) => track.stop()); + } + + if (camera.video && camera.video.parentNode) { + camera.video.parentNode.removeChild(camera.video); + } + + if (camera.canvas && camera.canvas instanceof HTMLCanvasElement && camera.canvas.parentNode) { + camera.canvas.parentNode.removeChild(camera.canvas); + } + + if (camera.permissionListener && camera.permissionStatus) { + camera.permissionStatus.removeEventListener('change', camera.permissionListener); + camera.permissionListener = null; + camera.permissionStatus = null; + } + }, + + api: { + /** + * Gets list of available cameras. + * Calls callback with JSON containing array of camera info. + * @param {number} context Context value to pass to callback + * @param {number} callbackPtr Pointer to callback function + * @returns {Promise} + */ + getCameras: async function (context, callbackPtr) { + const callback = GodotRuntime.get_func(callbackPtr); + const result = { error: null, cameras: null }; + + try { + // request camera access permission + const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); + stream.getTracks().forEach((track) => track.stop()); + const devices = await navigator.mediaDevices.enumerateDevices(); + result.cameras = devices + .filter((device) => device.kind === 'videoinput') + .map((device, index) => ({ + index, + id: device.deviceId, + label: device.label || `Camera ${index}`, + })); + + GodotCamera.api.stop(); + } catch (error) { + result.error = error.message; + } + + GodotCamera.sendCallbackResult(callback, context, result); + }, + + /** + * Gets capabilities of a specific camera. + * Calls callback with JSON containing camera capabilities. + * @param {number} deviceIdPtr Pointer to device ID string + * @param {number} context Context value to pass to callback + * @param {number} callbackPtr Pointer to callback function + * @returns {Promise} + */ + getCameraCapabilities: async function (deviceIdPtr, context, callbackPtr) { + const callback = GodotRuntime.get_func(callbackPtr); + const deviceId = GodotRuntime.parseString(deviceIdPtr); + const result = { error: null, capabilities: null }; + + try { + // request camera access permission + const stream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: { exact: deviceId } }, + audio: false, + }); + + const videoTrack = stream.getVideoTracks()[0]; + result.capabilities = videoTrack.getCapabilities(); + + stream.getTracks().forEach((track) => track.stop()); + } catch (error) { + result.error = error.message; + } + + GodotCamera.sendCallbackResult(callback, context, result); + }, + + /** + * Starts capturing pixel data from camera. + * Continuously calls callback with pixel data. + * @param {number} context Context value to pass to callback + * @param {string?} deviceId Camera device ID + * @param {number} width Desired capture width + * @param {number} height Desired capture height + * @param {number} callbackPtr Pointer to callback function + * @param {number} deniedCallbackPtr Pointer to callback function + * @returns {Promise} + */ + getPixelData: async function (context, deviceId, width, height, callbackPtr, deniedCallbackPtr) { + const callback = GodotRuntime.get_func(callbackPtr); + const deniedCallback = GodotRuntime.get_func(deniedCallbackPtr); + const cameraId = deviceId || 'default'; + + try { + const camerasMap = GodotCamera.ensureCamerasMap(); + let camera = camerasMap.get(cameraId); + if (!camera) { + camera = { + video: null, + canvas: null, + canvasContext: null, + stream: null, + animationFrameId: null, + permissionListener: null, + permissionStatus: null, + }; + camerasMap.set(cameraId, camera); + } + + if (!camera.stream) { + camera.video = document.createElement('video'); + camera.video.style.display = 'none'; + camera.video.autoplay = true; + camera.video.playsInline = true; + document.body.appendChild(camera.video); + + if (typeof OffscreenCanvas !== 'undefined') { + camera.canvas = new OffscreenCanvas(width, height); + } else { + camera.canvas = document.createElement('canvas'); + camera.canvas.style.display = 'none'; + document.body.appendChild(camera.canvas); + } + + const constraints = { + video: { + deviceId: deviceId ? { exact: deviceId } : undefined, + width: { ideal: width }, + height: { ideal: height }, + }, + }; + // eslint-disable-next-line require-atomic-updates + camera.stream = await navigator.mediaDevices.getUserMedia(constraints); + + const videoTrack = camera.stream.getVideoTracks()[0]; + if (videoTrack) { + videoTrack.addEventListener('ended', () => { + GodotRuntime.print('Camera track ended, stopping stream'); + GodotCamera.api.stop(deviceId); + }); + } + + if (navigator.permissions && navigator.permissions.query) { + try { + const permissionStatus = await navigator.permissions.query({ name: 'camera' }); + // eslint-disable-next-line require-atomic-updates + camera.permissionStatus = permissionStatus; + camera.permissionListener = () => { + if (permissionStatus.state === 'denied') { + GodotRuntime.print('Camera permission denied, stopping stream'); + if (camera.permissionListener) { + permissionStatus.removeEventListener('change', camera.permissionListener); + camera.permissionListener = null; + camera.permissionStatus = null; + } + GodotCamera.api.stop(deviceId); + deniedCallback(context); + } + }; + permissionStatus.addEventListener('change', camera.permissionListener); + } catch (e) { + GodotRuntime.error(e); + } + } + + camera.video.srcObject = camera.stream; + await camera.video.play(); + } + + if (camera.canvas.width !== width || camera.canvas.height !== height) { + camera.canvas.width = width; + camera.canvas.height = height; + } + camera.canvasContext = camera.canvas.getContext('2d', { willReadFrequently: true }); + + if (camera.animationFrameId) { + cancelAnimationFrame(camera.animationFrameId); + } + + const captureFrame = () => { + const cameras = GodotCamera.ensureCamerasMap(); + const currentCamera = cameras.get(cameraId); + if (!currentCamera) { + return; + } + + const { video, canvasContext, stream } = currentCamera; + + if (!stream || !stream.active) { + GodotRuntime.print('Stream is not active, stopping'); + GodotCamera.api.stop(deviceId); + return; + } + + if (video.readyState === video.HAVE_ENOUGH_DATA) { + try { + canvasContext.drawImage(video, 0, 0, width, height); + const imageData = canvasContext.getImageData(0, 0, width, height); + const pixelData = imageData.data; + + const dataPtr = GodotRuntime.malloc(pixelData.length); + GodotRuntime.heapCopy(HEAPU8, pixelData, dataPtr); + + GodotCamera.sendErrorCallback( + callback, + context, + dataPtr, + pixelData.length, + video.videoWidth, + video.videoHeight, + null + ); + + GodotRuntime.free(dataPtr); + } catch (error) { + GodotCamera.sendErrorCallback(callback, context, 0, 0, 0, 0, error.message); + + if (error.name === 'SecurityError' || error.name === 'NotAllowedError') { + GodotRuntime.print('Security error, stopping stream:', error); + GodotCamera.api.stop(deviceId); + } + return; + } + } + + currentCamera.animationFrameId = requestAnimationFrame(captureFrame); + }; + + camera.animationFrameId = requestAnimationFrame(captureFrame); + } catch (error) { + GodotCamera.sendErrorCallback(callback, context, 0, 0, 0, 0, error.message); + } + }, + + /** + * Stops camera stream(s). + * @param {string?} deviceId Device ID to stop, or null to stop all + * @returns {void} + */ + stop: function (deviceId) { + const cameras = GodotCamera.ensureCamerasMap(); + + if (deviceId && cameras.has(deviceId)) { + const camera = cameras.get(deviceId); + if (camera) { + GodotCamera.cleanupCamera(camera); + } + cameras.delete(deviceId); + } else { + cameras.forEach((camera) => { + if (camera) { + GodotCamera.cleanupCamera(camera); + } + }); + cameras.clear(); + } + }, + }, + }, + + /** + * Native binding for getting pixel data from camera. + * @param {number} context Context value to pass to callback + * @param {number} deviceIdPtr Pointer to device ID string + * @param {number} width Desired capture width + * @param {number} height Desired capture height + * @param {number} callbackPtr Pointer to callback function + * @param {number} deniedCallbackPtr Pointer to denied callback function + * @returns {*} + */ + godot_js_camera_get_pixel_data: function (context, deviceIdPtr, width, height, callbackPtr, deniedCallbackPtr) { + const deviceId = deviceIdPtr && deviceIdPtr !== 0 ? GodotRuntime.parseString(deviceIdPtr) : undefined; + return GodotCamera.api.getPixelData(context, deviceId, width, height, callbackPtr, deniedCallbackPtr); + }, + + /** + * Native binding for stopping camera stream. + * @param {number} deviceIdPtr Pointer to device ID string + * @returns {void} + */ + godot_js_camera_stop_stream: function (deviceIdPtr) { + const deviceId = deviceIdPtr && deviceIdPtr !== 0 ? GodotRuntime.parseString(deviceIdPtr) : undefined; + GodotCamera.api.stop(deviceId); + }, +}; + +autoAddDeps(GodotCamera, '$GodotCamera'); +mergeInto(LibraryManager.library, GodotCamera); From 8d3cfc843935e3f7af15180a12dc9e9c3374fea4 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Fri, 6 Jun 2025 22:59:37 +0900 Subject: [PATCH 02/17] Remove Asyncify dependency --- modules/camera/camera_web.cpp | 47 ++--- modules/camera/camera_web.h | 8 +- platform/web/SCsub | 3 - platform/web/camera_driver_web.cpp | 190 ++++++++----------- platform/web/camera_driver_web.h | 18 +- platform/web/godot_camera.h | 11 +- platform/web/js/libs/library_godot_camera.js | 155 ++++++++------- 7 files changed, 208 insertions(+), 224 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index 42b5903dfe96..0ccefd902e68 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -73,11 +73,14 @@ void CameraFeedWeb::_on_denied_callback(void *context) { bool CameraFeedWeb::activate_feed() { ERR_FAIL_COND_V_MSG(selected_format == -1, false, "CameraFeed format needs to be set before activating."); - CameraFeed::FeedFormat f = formats[selected_format]; int width = parameters.get(KEY_WIDTH, 0); int height = parameters.get(KEY_HEIGHT, 0); - width = width > 0 ? width : f.width; - height = height > 0 ? height : f.height; + // Firefox ESR (128.11.0esr) does not implement MediaStreamTrack.getCapabilities(), so 'formats' will be empty. + if (formats.size() > selected_format) { + CameraFeed::FeedFormat f = formats[selected_format]; + width = width > 0 ? width : f.width; + height = height > 0 ? height : f.height; + } CameraDriverWeb::get_singleton()->get_pixel_data(this, device_id, width, height, &_on_get_pixeldata, &_on_denied_callback); return true; } @@ -116,16 +119,11 @@ CameraFeedWeb::CameraFeedWeb(const CameraInfo &info) { name = info.label; device_id = info.device_id; - Vector capabilities; - CameraDriverWeb::get_singleton()->get_capabilities(&capabilities, device_id); - for (int i = 0; i < capabilities.size(); i++) { - CapabilityInfo capability = capabilities[i]; - FeedFormat feed_format; - feed_format.width = capability.width; - feed_format.height = capability.height; - feed_format.format = String("RGBA"); - formats.append(feed_format); - } + FeedFormat feed_format; + feed_format.width = info.capability.width; + feed_format.height = info.capability.height; + feed_format.format = String("RGBA"); + formats.append(feed_format); image.instantiate(); } @@ -136,18 +134,23 @@ CameraFeedWeb::~CameraFeedWeb() { } } -void CameraWeb::_update_feeds() { - for (int i = feeds.size() - 1; i >= 0; i--) { - remove_feed(feeds[i]); +void CameraWeb::_on_get_cameras_callback(void *context, const Vector &camera_info) { + CameraWeb *server = static_cast(context); + for (int i = server->feeds.size() - 1; i >= 0; i--) { + server->remove_feed(server->feeds[i]); } - - Vector camera_info; - camera_driver_web->get_cameras(&camera_info); for (int i = 0; i < camera_info.size(); i++) { CameraInfo info = camera_info[i]; Ref feed = memnew(CameraFeedWeb(info)); - add_feed(feed); + server->add_feed(feed); } + server->CameraServer::set_monitoring_feeds(true); + server->activating = false; +} + +void CameraWeb::_update_feeds() { + activating = true; + camera_driver_web->get_cameras((void *)this, &_on_get_cameras_callback); } void CameraWeb::_cleanup() { @@ -159,17 +162,17 @@ void CameraWeb::_cleanup() { } void CameraWeb::set_monitoring_feeds(bool p_monitoring_feeds) { - if (p_monitoring_feeds == monitoring_feeds) { + if (p_monitoring_feeds == monitoring_feeds || activating) { return; } - CameraServer::set_monitoring_feeds(p_monitoring_feeds); if (p_monitoring_feeds) { if (camera_driver_web == nullptr) { camera_driver_web = new CameraDriverWeb(); } _update_feeds(); } else { + CameraServer::set_monitoring_feeds(p_monitoring_feeds); _cleanup(); } } diff --git a/modules/camera/camera_web.h b/modules/camera/camera_web.h index c8b291b98f03..27490536728e 100644 --- a/modules/camera/camera_web.h +++ b/modules/camera/camera_web.h @@ -34,14 +34,16 @@ #include "servers/camera/camera_feed.h" #include "servers/camera_server.h" +#include + class CameraFeedWeb : public CameraFeed { - GDCLASS(CameraFeedWeb, CameraFeed); + GDSOFTCLASS(CameraFeedWeb, CameraFeed); private: String device_id; Ref image; Vector data; - static void _on_get_pixeldata(void *, const uint8_t *, const int, const int, const int, const char *error); + static void _on_get_pixeldata(void *context, const uint8_t *rawdata, const int length, const int p_width, const int p_height, const char *error); static void _on_denied_callback(void *context); protected: @@ -61,8 +63,10 @@ class CameraWeb : public CameraServer { private: CameraDriverWeb *camera_driver_web = nullptr; + std::atomic activating; void _cleanup(); void _update_feeds(); + static void _on_get_cameras_callback(void *context, const Vector &camera_info); protected: public: diff --git a/platform/web/SCsub b/platform/web/SCsub index 70ae06247e3a..f1adb7f6e09b 100644 --- a/platform/web/SCsub +++ b/platform/web/SCsub @@ -110,9 +110,6 @@ else: sys_env.Append(LIBS=["idbfs.js"]) build = sys_env.add_program(build_targets, web_files + ["web_runtime.cpp"]) -sys_env.Append(LINKFLAGS=["-s", "ASYNCIFY=2"]) -sys_env.Append(LINKFLAGS=["-s", "ASYNCIFY_IMPORTS=['godot_js_camera_get_cameras', 'godot_js_camera_get_capabilities']"]) - sys_env.Depends(build[0], sys_env["JS_LIBS"]) sys_env.Depends(build[0], sys_env["JS_PRE"]) sys_env.Depends(build[0], sys_env["JS_POST"]) diff --git a/platform/web/camera_driver_web.cpp b/platform/web/camera_driver_web.cpp index 43fe101d7154..81fd09b13384 100644 --- a/platform/web/camera_driver_web.cpp +++ b/platform/web/camera_driver_web.cpp @@ -34,26 +34,34 @@ #include -EM_ASYNC_JS(void, godot_js_camera_get_cameras, (void *context, CameraLibrary_OnGetCamerasCallback p_callback_ptr), { - await GodotCamera.api.getCameras(context, p_callback_ptr); -}); - -EM_ASYNC_JS(void, godot_js_camera_get_capabilities, (void *context, const char *p_device_id_ptr, CameraLibrary_OnGetCapabilitiesCallback p_callback_ptr), { - await GodotCamera.api.getCameraCapabilities(p_device_id_ptr, context, p_callback_ptr); -}); - CameraDriverWeb *CameraDriverWeb::singleton = nullptr; Array CameraDriverWeb::_camera_info_key; CameraDriverWeb *CameraDriverWeb::get_singleton() { - _camera_info_key.clear(); - _camera_info_key.push_back(KEY_INDEX); - _camera_info_key.push_back(KEY_ID); - _camera_info_key.push_back(KEY_LABEL); + if (_camera_info_key.is_empty()) { + _camera_info_key.push_back(KEY_INDEX); + _camera_info_key.push_back(KEY_ID); + _camera_info_key.push_back(KEY_LABEL); + } return singleton; } -void CameraDriverWeb::_on_get_cameras_callback(void *context, const char *json_ptr) { +// Helper to extract 'max' from a capability dictionary or use direct value +int CameraDriverWeb::_get_max_or_direct(const Variant &p_val) { + if (p_val.get_type() == Variant::DICTIONARY) { + Dictionary d = p_val; + if (d.has(KEY_MAX)) { + return d[KEY_MAX]; + } + } else if (p_val.get_type() == Variant::INT) { + return p_val; + } else if (p_val.get_type() == Variant::FLOAT) { + return static_cast(p_val.operator float()); + } + return 0; +} + +void CameraDriverWeb::_on_get_cameras_callback(void *context, void *callback, const char *json_ptr) { if (!json_ptr) { print_error("CameraDriverWeb::_on_get_cameras_callback: json_ptr is null"); return; @@ -61,113 +69,82 @@ void CameraDriverWeb::_on_get_cameras_callback(void *context, const char *json_p String json_string = String::utf8(json_ptr); Variant json_variant = JSON::parse_string(json_string); - if (json_variant.get_type() == Variant::DICTIONARY) { - Dictionary json_dict = json_variant; - Variant v_error = json_dict[KEY_ERROR]; - if (v_error.get_type() == Variant::STRING) { - String error_str = v_error; - ERR_PRINT(vformat("Camera error from JS: %s", error_str)); - return; - } - Variant v_devices = json_dict.get(KEY_CAMERAS, Variant()); - if (v_devices.get_type() != Variant::ARRAY) { - ERR_PRINT("Camera error: 'cameras' is not an array or missing."); - return; - } - Array devices_array = v_devices; - Vector *camera_info = reinterpret_cast *>(context); - camera_info->clear(); - for (int i = 0; i < devices_array.size(); i++) { - Variant device_variant = devices_array.get(i); - if (device_variant.get_type() == Variant::DICTIONARY) { - Dictionary device_dict = device_variant; - if (device_dict.has_all(_camera_info_key)) { - CameraInfo info; - info.index = device_dict[KEY_INDEX]; - info.device_id = device_dict[KEY_ID]; - info.label = device_dict[KEY_LABEL]; - camera_info->push_back(info); - } else { - WARN_PRINT("Camera info entry missing required keys (index, id, label)."); - } - } - } - } else { + if (json_variant.get_type() != Variant::DICTIONARY) { ERR_PRINT("CameraDriverWeb::_on_get_cameras_callback: Failed to parse JSON response or response is not a Dictionary."); + return; } -} -void CameraDriverWeb::_on_get_capabilities_callback(void *context, const char *json_ptr) { - if (!json_ptr) { - ERR_PRINT("CameraDriverWeb::_on_get_capabilities_callback: json_ptr is null"); + Dictionary json_dict = json_variant; + Variant v_error = json_dict[KEY_ERROR]; + if (v_error.get_type() == Variant::STRING) { + String error_str = v_error; + ERR_PRINT(vformat("Camera error from JS: %s", error_str)); return; } - String json_string = String::utf8(json_ptr); - Variant json_variant = JSON::parse_string(json_string); - if (json_variant.get_type() == Variant::DICTIONARY) { - Dictionary json_dict = json_variant; - Variant v_error = json_dict[KEY_ERROR]; - if (v_error.get_type() == Variant::STRING) { - String error_str = v_error; - ERR_PRINT(vformat("Camera capabilities error from JS: %s", error_str)); - return; + Variant v_devices = json_dict.get(KEY_CAMERAS, Variant()); + if (v_devices.get_type() != Variant::ARRAY) { + ERR_PRINT("Camera error: 'cameras' is not an array or missing."); + return; + } + + Array devices_array = v_devices; + Vector camera_info; + for (int i = 0; i < devices_array.size(); i++) { + Variant device_variant = devices_array.get(i); + if (device_variant.get_type() != Variant::DICTIONARY) { + continue; + } + + Dictionary device_dict = device_variant; + if (!device_dict.has_all(_camera_info_key)) { + WARN_PRINT("Camera info entry missing required keys (index, id, label)."); + continue; } - Variant v_caps_data = json_dict.get(KEY_CAPABILITIES, Variant()); + + CameraInfo info; + info.index = device_dict[KEY_INDEX]; + info.device_id = device_dict[KEY_ID]; + info.label = device_dict[KEY_LABEL]; + + Variant v_caps_data = device_dict.get(KEY_CAPABILITIES, Variant()); if (v_caps_data.get_type() != Variant::DICTIONARY) { - ERR_PRINT("Camera capabilities error: 'capabilities' data is not a dictionary or missing."); - return; + WARN_PRINT("Camera info entry has no capabilities or capabilities are not a dictionary."); + camera_info.push_back(info); + continue; } + Dictionary caps_dict = v_caps_data; - Vector *capabilities = reinterpret_cast *>(context); - capabilities->clear(); - - if (caps_dict.has(KEY_WIDTH) && caps_dict.has(KEY_HEIGHT)) { - Variant v_width_val = caps_dict.get(KEY_WIDTH, Variant()); - Variant v_height_val = caps_dict.get(KEY_HEIGHT, Variant()); - int width = 0; - int height = 0; - - // Helper to extract 'max' from a capability dictionary or use direct value - auto get_max_or_direct = [](const Variant &p_val) -> int { - if (p_val.get_type() == Variant::DICTIONARY) { - Dictionary d = p_val; - if (d.has(KEY_MAX)) { - return d[KEY_MAX]; - } - } else if (p_val.get_type() == Variant::INT) { - return p_val; - } else if (p_val.get_type() == Variant::FLOAT) { - return static_cast(p_val.operator float()); - } - return 0; - }; - - width = get_max_or_direct(v_width_val); - height = get_max_or_direct(v_height_val); - - if (width > 0 && height > 0) { - CapabilityInfo info; - info.width = width; - info.height = height; - capabilities->push_back(info); - } else { - WARN_PRINT("Could not extract valid width/height from capabilities structure."); - } - } else { + if (!caps_dict.has(KEY_WIDTH) || !caps_dict.has(KEY_HEIGHT)) { WARN_PRINT("Capabilities object does not directly contain top-level width/height keys."); + camera_info.push_back(info); + continue; } - } else { - ERR_PRINT("CameraDriverWeb::_on_get_capabilities_callback: Failed to parse JSON response or response is not a Dictionary."); + + Variant v_width_val = caps_dict.get(KEY_WIDTH, Variant()); + Variant v_height_val = caps_dict.get(KEY_HEIGHT, Variant()); + + int width = _get_max_or_direct(v_width_val); + int height = _get_max_or_direct(v_height_val); + + if (width <= 0 || height <= 0) { + WARN_PRINT("Could not extract valid width/height from capabilities structure."); + continue; + } + + CapabilityInfo capability; + capability.width = width; + capability.height = height; + info.capability = capability; + camera_info.push_back(info); } -} -void CameraDriverWeb::get_cameras(Vector *r_camera_info) { - godot_js_camera_get_cameras((void *)r_camera_info, &_on_get_cameras_callback); + CameraDriverWeb_OnGetCamerasCallback on_get_cameras_callback = reinterpret_cast(callback); + on_get_cameras_callback(context, camera_info); } -void CameraDriverWeb::get_capabilities(Vector *r_capabilities, const String &p_device_id) { - godot_js_camera_get_capabilities((void *)r_capabilities, p_device_id.utf8().get_data(), &_on_get_capabilities_callback); +void CameraDriverWeb::get_cameras(void *context, CameraDriverWeb_OnGetCamerasCallback callback) { + godot_js_camera_get_cameras(context, (void *)callback, &_on_get_cameras_callback); } void CameraDriverWeb::get_pixel_data(void *context, const String &p_device_id, const int width, const int height, CameraLibrary_OnGetPixelDataCallback p_callback, CameraLibrary_OnDeniedCallback p_denied_callback) { @@ -179,9 +156,8 @@ void CameraDriverWeb::stop_stream(const String &device_id) { } CameraDriverWeb::CameraDriverWeb() { - if (singleton == nullptr) { - singleton = this; - } + ERR_FAIL_COND_MSG(singleton != nullptr, "CameraDriverWeb singleton already exists."); + singleton = this; } CameraDriverWeb::~CameraDriverWeb() { diff --git a/platform/web/camera_driver_web.h b/platform/web/camera_driver_web.h index 4c4e4054f0e8..f8c7045f3fa2 100644 --- a/platform/web/camera_driver_web.h +++ b/platform/web/camera_driver_web.h @@ -46,28 +46,30 @@ #define KEY_MAX String("max") #define KEY_WIDTH String("width") +struct CapabilityInfo { + int width; + int height; +}; + struct CameraInfo { int index; String device_id; String label; + CapabilityInfo capability; }; -struct CapabilityInfo { - int width; - int height; -}; +using CameraDriverWeb_OnGetCamerasCallback = void (*)(void *context, const Vector &camera_info); class CameraDriverWeb { private: static CameraDriverWeb *singleton; static Array _camera_info_key; - WASM_EXPORT static void _on_get_cameras_callback(void *context, const char *json_ptr); - WASM_EXPORT static void _on_get_capabilities_callback(void *context, const char *json_ptr); + static int _get_max_or_direct(const Variant &p_val); + WASM_EXPORT static void _on_get_cameras_callback(void *context, void *callback, const char *json_ptr); public: static CameraDriverWeb *get_singleton(); - void get_cameras(Vector *camera_info); - void get_capabilities(Vector *capabilities, const String &p_device_id); + void get_cameras(void *context, CameraDriverWeb_OnGetCamerasCallback callback); void get_pixel_data(void *context, const String &p_device_id, const int width, const int height, CameraLibrary_OnGetPixelDataCallback p_callback, CameraLibrary_OnDeniedCallback p_denied_callback); void stop_stream(const String &device_id = String()); diff --git a/platform/web/godot_camera.h b/platform/web/godot_camera.h index 7a1aa23306f8..cb2ce5248b8a 100644 --- a/platform/web/godot_camera.h +++ b/platform/web/godot_camera.h @@ -37,13 +37,16 @@ extern "C" { #endif -using CameraLibrary_OnGetCamerasCallback = void (*)(void *, const char *); +using CameraLibrary_OnGetCamerasCallback = void (*)(void *context, void *callback, const char *result); -using CameraLibrary_OnGetCapabilitiesCallback = void (*)(void *, const char *); +using CameraLibrary_OnGetPixelDataCallback = void (*)(void *context, const uint8_t *, const int size, const int width, const int height, const char *error); -using CameraLibrary_OnGetPixelDataCallback = void (*)(void *, const uint8_t *, const int, const int, const int, const char *); +using CameraLibrary_OnDeniedCallback = void (*)(void *context); -using CameraLibrary_OnDeniedCallback = void (*)(void *); +extern void godot_js_camera_get_cameras( + void *context, + void *callback, + CameraLibrary_OnGetCamerasCallback p_callback_ptr); extern void godot_js_camera_get_pixel_data( void *context, diff --git a/platform/web/js/libs/library_godot_camera.js b/platform/web/js/libs/library_godot_camera.js index 85cfbda85622..6181e200e197 100644 --- a/platform/web/js/libs/library_godot_camera.js +++ b/platform/web/js/libs/library_godot_camera.js @@ -36,13 +36,13 @@ * }} CameraInfo * * @typedef {{ - * video: HTMLVideoElement? - * canvas: HTMLCanvasElement | OffscreenCanvas? - * canvasContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D? - * stream: MediaStream? - * animationFrameId: number? - * permissionListener: Function? - * permissionStatus: PermissionStatus? + * video: HTMLVideoElement|null + * canvas: (HTMLCanvasElement|OffscreenCanvas)|null + * canvasContext: (CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D)|null + * stream: MediaStream|null + * animationFrameId: number|null + * permissionListener: Function|null + * permissionStatus: PermissionStatus|null * }} CameraResource */ @@ -55,6 +55,14 @@ const GodotCamera = { * @type {Map} */ cameras: new Map(), + defaultMinimumCapabilities: { + 'width': { + 'max': 1280, + }, + 'height': { + 'max': 1080, + }, + }, /** * Ensures cameras Map is properly initialized. @@ -78,14 +86,15 @@ const GodotCamera = { /** * Sends a JSON result to the callback function. * @param {Function} callback Callback function pointer + * @param {number} callbackPtr Context value to pass to callback * @param {number} context Context value to pass to callback * @param {Object} result Result object to stringify * @returns {void} */ - sendCallbackResult: function (callback, context, result) { + sendCamerasCallbackResult: function (callback, callbackPtr, context, result) { const jsonStr = JSON.stringify(result); const strPtr = GodotRuntime.allocString(jsonStr); - callback(context, strPtr); + callback(context, callbackPtr, strPtr); GodotRuntime.free(strPtr); }, @@ -97,10 +106,10 @@ const GodotCamera = { * @param {number} dataLen Length of pixel data * @param {number} width Image width * @param {number} height Image height - * @param {string?} errorMsg Error message if any + * @param {string|null} errorMsg Error message if any * @returns {void} */ - sendErrorCallback: function (callback, context, dataPtr, dataLen, width, height, errorMsg) { + sendGetPixelDataCallback: function (callback, context, dataPtr, dataLen, width, height, errorMsg) { const errorMsgPtr = errorMsg ? GodotRuntime.allocString(errorMsg) : 0; callback(context, dataPtr, dataLen, width, height, errorMsgPtr); if (errorMsgPtr) { @@ -142,17 +151,22 @@ const GodotCamera = { * Gets list of available cameras. * Calls callback with JSON containing array of camera info. * @param {number} context Context value to pass to callback - * @param {number} callbackPtr Pointer to callback function + * @param {number} callbackPtr1 Pointer to callback function + * @param {number} callbackPtr2 Pointer to callback function * @returns {Promise} */ - getCameras: async function (context, callbackPtr) { - const callback = GodotRuntime.get_func(callbackPtr); + getCameras: async function (context, callbackPtr1, callbackPtr2) { + const callback = GodotRuntime.get_func(callbackPtr2); const result = { error: null, cameras: null }; try { // request camera access permission const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); - stream.getTracks().forEach((track) => track.stop()); + const getCapabilities = function (deviceId) { + const videoTrack = stream.getVideoTracks() + .find((track) => track.getSettings().deviceId === deviceId); + return videoTrack?.getCapabilities() || GodotCamera.defaultMinimumCapabilities; + }; const devices = await navigator.mediaDevices.enumerateDevices(); result.cameras = devices .filter((device) => device.kind === 'videoinput') @@ -160,52 +174,23 @@ const GodotCamera = { index, id: device.deviceId, label: device.label || `Camera ${index}`, + capabilities: getCapabilities(device.deviceId), })); - GodotCamera.api.stop(); - } catch (error) { - result.error = error.message; - } - - GodotCamera.sendCallbackResult(callback, context, result); - }, - - /** - * Gets capabilities of a specific camera. - * Calls callback with JSON containing camera capabilities. - * @param {number} deviceIdPtr Pointer to device ID string - * @param {number} context Context value to pass to callback - * @param {number} callbackPtr Pointer to callback function - * @returns {Promise} - */ - getCameraCapabilities: async function (deviceIdPtr, context, callbackPtr) { - const callback = GodotRuntime.get_func(callbackPtr); - const deviceId = GodotRuntime.parseString(deviceIdPtr); - const result = { error: null, capabilities: null }; - - try { - // request camera access permission - const stream = await navigator.mediaDevices.getUserMedia({ - video: { deviceId: { exact: deviceId } }, - audio: false, - }); - - const videoTrack = stream.getVideoTracks()[0]; - result.capabilities = videoTrack.getCapabilities(); - stream.getTracks().forEach((track) => track.stop()); + GodotCamera.api.stop(); } catch (error) { result.error = error.message; } - GodotCamera.sendCallbackResult(callback, context, result); + GodotCamera.sendCamerasCallbackResult(callback, callbackPtr1, context, result); }, /** * Starts capturing pixel data from camera. * Continuously calls callback with pixel data. * @param {number} context Context value to pass to callback - * @param {string?} deviceId Camera device ID + * @param {string|null} deviceId Camera device ID * @param {number} width Desired capture width * @param {number} height Desired capture height * @param {number} callbackPtr Pointer to callback function @@ -233,6 +218,7 @@ const GodotCamera = { camerasMap.set(cameraId, camera); } + let _height, _width; if (!camera.stream) { camera.video = document.createElement('video'); camera.video.style.display = 'none'; @@ -240,14 +226,6 @@ const GodotCamera = { camera.video.playsInline = true; document.body.appendChild(camera.video); - if (typeof OffscreenCanvas !== 'undefined') { - camera.canvas = new OffscreenCanvas(width, height); - } else { - camera.canvas = document.createElement('canvas'); - camera.canvas.style.display = 'none'; - document.body.appendChild(camera.canvas); - } - const constraints = { video: { deviceId: deviceId ? { exact: deviceId } : undefined, @@ -258,13 +236,19 @@ const GodotCamera = { // eslint-disable-next-line require-atomic-updates camera.stream = await navigator.mediaDevices.getUserMedia(constraints); - const videoTrack = camera.stream.getVideoTracks()[0]; - if (videoTrack) { - videoTrack.addEventListener('ended', () => { - GodotRuntime.print('Camera track ended, stopping stream'); - GodotCamera.api.stop(deviceId); - }); + const [videoTrack] = camera.stream.getVideoTracks(); + ({ width: _width, height: _height } = videoTrack.getSettings()); + if (typeof OffscreenCanvas !== 'undefined') { + camera.canvas = new OffscreenCanvas(_width, _height); + } else { + camera.canvas = document.createElement('canvas'); + camera.canvas.style.display = 'none'; + document.body.appendChild(camera.canvas); } + videoTrack.addEventListener('ended', () => { + GodotRuntime.print('Camera track ended, stopping stream'); + GodotCamera.api.stop(deviceId); + }); if (navigator.permissions && navigator.permissions.query) { try { @@ -276,8 +260,6 @@ const GodotCamera = { GodotRuntime.print('Camera permission denied, stopping stream'); if (camera.permissionListener) { permissionStatus.removeEventListener('change', camera.permissionListener); - camera.permissionListener = null; - camera.permissionStatus = null; } GodotCamera.api.stop(deviceId); deniedCallback(context); @@ -285,17 +267,23 @@ const GodotCamera = { }; permissionStatus.addEventListener('change', camera.permissionListener); } catch (e) { - GodotRuntime.error(e); + // Some browsers don't support 'camera' permission query + // This is not critical - we can still use the camera + GodotRuntime.print('Camera permission query not supported:', e.message); } } camera.video.srcObject = camera.stream; await camera.video.play(); + } else { + // Use requested dimensions when stream already exists + _width = width; + _height = height; } - if (camera.canvas.width !== width || camera.canvas.height !== height) { - camera.canvas.width = width; - camera.canvas.height = height; + if (camera.canvas.width !== _width || camera.canvas.height !== _height) { + camera.canvas.width = _width; + camera.canvas.height = _height; } camera.canvasContext = camera.canvas.getContext('2d', { willReadFrequently: true }); @@ -320,26 +308,26 @@ const GodotCamera = { if (video.readyState === video.HAVE_ENOUGH_DATA) { try { - canvasContext.drawImage(video, 0, 0, width, height); - const imageData = canvasContext.getImageData(0, 0, width, height); + canvasContext.drawImage(video, 0, 0, _width, _height); + const imageData = canvasContext.getImageData(0, 0, _width, _height); const pixelData = imageData.data; const dataPtr = GodotRuntime.malloc(pixelData.length); GodotRuntime.heapCopy(HEAPU8, pixelData, dataPtr); - GodotCamera.sendErrorCallback( + GodotCamera.sendGetPixelDataCallback( callback, context, dataPtr, pixelData.length, - video.videoWidth, - video.videoHeight, + _width, + _height, null ); GodotRuntime.free(dataPtr); } catch (error) { - GodotCamera.sendErrorCallback(callback, context, 0, 0, 0, 0, error.message); + GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, error.message); if (error.name === 'SecurityError' || error.name === 'NotAllowedError') { GodotRuntime.print('Security error, stopping stream:', error); @@ -354,13 +342,13 @@ const GodotCamera = { camera.animationFrameId = requestAnimationFrame(captureFrame); } catch (error) { - GodotCamera.sendErrorCallback(callback, context, 0, 0, 0, 0, error.message); + GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, error.message); } }, /** * Stops camera stream(s). - * @param {string?} deviceId Device ID to stop, or null to stop all + * @param {string|null} deviceId Device ID to stop, or null to stop all * @returns {void} */ stop: function (deviceId) { @@ -384,6 +372,17 @@ const GodotCamera = { }, }, + /** + * Native binding for getting list of cameras. + * @param {number} context Context value to pass to callback + * @param {number} callbackPtr1 Pointer to callback function + * @param {number} callbackPtr2 Pointer to callback function + * @returns {Promise} + */ + godot_js_camera_get_cameras: function (context, callbackPtr1, callbackPtr2) { + return GodotCamera.api.getCameras(context, callbackPtr1, callbackPtr2); + }, + /** * Native binding for getting pixel data from camera. * @param {number} context Context value to pass to callback @@ -395,7 +394,7 @@ const GodotCamera = { * @returns {*} */ godot_js_camera_get_pixel_data: function (context, deviceIdPtr, width, height, callbackPtr, deniedCallbackPtr) { - const deviceId = deviceIdPtr && deviceIdPtr !== 0 ? GodotRuntime.parseString(deviceIdPtr) : undefined; + const deviceId = deviceIdPtr ? GodotRuntime.parseString(deviceIdPtr) : undefined; return GodotCamera.api.getPixelData(context, deviceId, width, height, callbackPtr, deniedCallbackPtr); }, @@ -405,7 +404,7 @@ const GodotCamera = { * @returns {void} */ godot_js_camera_stop_stream: function (deviceIdPtr) { - const deviceId = deviceIdPtr && deviceIdPtr !== 0 ? GodotRuntime.parseString(deviceIdPtr) : undefined; + const deviceId = deviceIdPtr ? GodotRuntime.parseString(deviceIdPtr) : undefined; GodotCamera.api.stop(deviceId); }, }; From 3b1e7015c8ab49b6b414ad77e59e6c55926fee83 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Mon, 9 Jun 2025 15:27:11 +0900 Subject: [PATCH 03/17] Initialize image and release --- modules/camera/camera_web.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index 0ccefd902e68..e9ced9931bcb 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -73,6 +73,11 @@ void CameraFeedWeb::_on_denied_callback(void *context) { bool CameraFeedWeb::activate_feed() { ERR_FAIL_COND_V_MSG(selected_format == -1, false, "CameraFeed format needs to be set before activating."); + // Initialize image when activating the feed + if (image.is_null()) { + image.instantiate(); + } + int width = parameters.get(KEY_WIDTH, 0); int height = parameters.get(KEY_HEIGHT, 0); // Firefox ESR (128.11.0esr) does not implement MediaStreamTrack.getCapabilities(), so 'formats' will be empty. @@ -87,6 +92,9 @@ bool CameraFeedWeb::activate_feed() { void CameraFeedWeb::deactivate_feed() { CameraDriverWeb::get_singleton()->stop_stream(device_id); + // Release the image when deactivating the feed + image.unref(); + data.clear(); } bool CameraFeedWeb::set_format(int p_index, const Dictionary &p_parameters) { @@ -124,8 +132,6 @@ CameraFeedWeb::CameraFeedWeb(const CameraInfo &info) { feed_format.height = info.capability.height; feed_format.format = String("RGBA"); formats.append(feed_format); - - image.instantiate(); } CameraFeedWeb::~CameraFeedWeb() { From 221ef974a5c0768ebbf8720bf3eb2f8c77678ae5 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Tue, 1 Jul 2025 23:36:27 +0900 Subject: [PATCH 04/17] Use SafeFlag instead of atomic --- modules/camera/camera_web.cpp | 6 +++--- modules/camera/camera_web.h | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index e9ced9931bcb..6241639ac1e2 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -151,11 +151,11 @@ void CameraWeb::_on_get_cameras_callback(void *context, const Vector server->add_feed(feed); } server->CameraServer::set_monitoring_feeds(true); - server->activating = false; + server->activating.clear(); } void CameraWeb::_update_feeds() { - activating = true; + activating.set(); camera_driver_web->get_cameras((void *)this, &_on_get_cameras_callback); } @@ -168,7 +168,7 @@ void CameraWeb::_cleanup() { } void CameraWeb::set_monitoring_feeds(bool p_monitoring_feeds) { - if (p_monitoring_feeds == monitoring_feeds || activating) { + if (p_monitoring_feeds == monitoring_feeds || activating.is_set()) { return; } diff --git a/modules/camera/camera_web.h b/modules/camera/camera_web.h index 27490536728e..a5bcad6047a9 100644 --- a/modules/camera/camera_web.h +++ b/modules/camera/camera_web.h @@ -34,8 +34,6 @@ #include "servers/camera/camera_feed.h" #include "servers/camera_server.h" -#include - class CameraFeedWeb : public CameraFeed { GDSOFTCLASS(CameraFeedWeb, CameraFeed); @@ -63,7 +61,7 @@ class CameraWeb : public CameraServer { private: CameraDriverWeb *camera_driver_web = nullptr; - std::atomic activating; + SafeFlag activating; void _cleanup(); void _update_feeds(); static void _on_get_cameras_callback(void *context, const Vector &camera_info); From 962fff26bc79193a7120ed08253d4ea781f8aa87 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Fri, 4 Jul 2025 02:50:33 +0900 Subject: [PATCH 05/17] Support signals - CameraServer::camera_feeds_updated - CameraFeed::frame_changed --- modules/camera/camera_web.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index 6241639ac1e2..a36516cd43ca 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -62,7 +62,6 @@ void CameraFeedWeb::_on_get_pixeldata(void *context, const uint8_t *rawdata, con image->initialize_data(p_width, p_height, false, Image::FORMAT_RGBA8, data); feed->set_rgb_image(image); - feed->emit_signal(SNAME("frame_changed")); } void CameraFeedWeb::_on_denied_callback(void *context) { @@ -150,8 +149,8 @@ void CameraWeb::_on_get_cameras_callback(void *context, const Vector Ref feed = memnew(CameraFeedWeb(info)); server->add_feed(feed); } - server->CameraServer::set_monitoring_feeds(true); server->activating.clear(); + server->call_deferred("emit_signal", SNAME(CameraServer::feeds_updated_signal_name)); } void CameraWeb::_update_feeds() { @@ -172,13 +171,13 @@ void CameraWeb::set_monitoring_feeds(bool p_monitoring_feeds) { return; } + CameraServer::set_monitoring_feeds(p_monitoring_feeds); if (p_monitoring_feeds) { if (camera_driver_web == nullptr) { camera_driver_web = new CameraDriverWeb(); } _update_feeds(); } else { - CameraServer::set_monitoring_feeds(p_monitoring_feeds); _cleanup(); } } From 57ed05cbd4782e6dce041b1909b9feb66e30342c Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Sun, 7 Sep 2025 00:51:30 +0900 Subject: [PATCH 06/17] Improve more safely --- modules/camera/camera_web.cpp | 34 +++++++++++++------ platform/web/camera_driver_web.cpp | 24 +++++++++----- platform/web/js/libs/library_godot_camera.js | 35 ++++++++++++-------- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index a36516cd43ca..84badcd9fee4 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -30,35 +30,49 @@ #include "camera_web.h" -#include "core/io/json.h" - void CameraFeedWeb::_on_get_pixeldata(void *context, const uint8_t *rawdata, const int length, const int p_width, const int p_height, const char *error) { + // Validate context first to avoid dereferencing null on error paths. + if (context == nullptr) { + ERR_PRINT("Camera feed error: Null context received."); + return; + } + CameraFeedWeb *feed = reinterpret_cast(context); + if (error) { if (feed->is_active()) { feed->deactivate_feed(); - }; + } String error_str = String::utf8(error); ERR_PRINT(vformat("Camera feed error from JS: %s", error_str)); return; } - if (context == nullptr || rawdata == nullptr || length < 0 || p_width <= 0 || p_height <= 0) { + if (rawdata == nullptr || length < 0 || p_width <= 0 || p_height <= 0) { if (feed->is_active()) { feed->deactivate_feed(); - }; + } ERR_PRINT("Camera feed error: Invalid pixel data received."); return; } - Vector data = feed->data; + Vector &data = feed->data; Ref image = feed->image; - if (length != data.size()) { - int64_t size = Image::get_image_data_size(p_width, p_height, Image::FORMAT_RGBA8, false); - data.resize(length > size ? length : size); + const int64_t expected_size = Image::get_image_data_size(p_width, p_height, Image::FORMAT_RGBA8, false); + if (length < expected_size) { + if (feed->is_active()) { + feed->deactivate_feed(); + } + ERR_PRINT("Camera feed error: Received pixel data smaller than expected."); + return; + } + + if (data.size() != expected_size) { + data.resize(expected_size); } - memcpy(data.ptrw(), rawdata, length); + // Copy exactly the expected size (ignore any trailing bytes in 'rawdata'). + memcpy(data.ptrw(), rawdata, expected_size); image->initialize_data(p_width, p_height, false, Image::FORMAT_RGBA8, data); feed->set_rgb_image(image); diff --git a/platform/web/camera_driver_web.cpp b/platform/web/camera_driver_web.cpp index 81fd09b13384..6a5047e08819 100644 --- a/platform/web/camera_driver_web.cpp +++ b/platform/web/camera_driver_web.cpp @@ -63,7 +63,7 @@ int CameraDriverWeb::_get_max_or_direct(const Variant &p_val) { void CameraDriverWeb::_on_get_cameras_callback(void *context, void *callback, const char *json_ptr) { if (!json_ptr) { - print_error("CameraDriverWeb::_on_get_cameras_callback: json_ptr is null"); + ERR_PRINT("CameraDriverWeb::_on_get_cameras_callback: json_ptr is null"); return; } String json_string = String::utf8(json_ptr); @@ -106,6 +106,13 @@ void CameraDriverWeb::_on_get_cameras_callback(void *context, void *callback, co info.index = device_dict[KEY_INDEX]; info.device_id = device_dict[KEY_ID]; info.label = device_dict[KEY_LABEL]; + // Initialize capability with safe defaults to avoid uninitialized usage downstream. + { + CapabilityInfo capability = {}; + capability.width = 0; + capability.height = 0; + info.capability = capability; + } Variant v_caps_data = device_dict.get(KEY_CAPABILITIES, Variant()); if (v_caps_data.get_type() != Variant::DICTIONARY) { @@ -129,14 +136,15 @@ void CameraDriverWeb::_on_get_cameras_callback(void *context, void *callback, co if (width <= 0 || height <= 0) { WARN_PRINT("Could not extract valid width/height from capabilities structure."); - continue; + // Still include the device in the list; keep zeroed capabilities. + camera_info.push_back(info); + } else { + CapabilityInfo capability; + capability.width = width; + capability.height = height; + info.capability = capability; + camera_info.push_back(info); } - - CapabilityInfo capability; - capability.width = width; - capability.height = height; - info.capability = capability; - camera_info.push_back(info); } CameraDriverWeb_OnGetCamerasCallback on_get_cameras_callback = reinterpret_cast(callback); diff --git a/platform/web/js/libs/library_godot_camera.js b/platform/web/js/libs/library_godot_camera.js index 6181e200e197..d9774d435990 100644 --- a/platform/web/js/libs/library_godot_camera.js +++ b/platform/web/js/libs/library_godot_camera.js @@ -144,6 +144,13 @@ const GodotCamera = { camera.permissionListener = null; camera.permissionStatus = null; } + + // Null out references to help GC. + camera.animationFrameId = null; + camera.canvasContext = null; + camera.stream = null; + camera.video = null; + camera.canvas = null; }, api: { @@ -178,7 +185,6 @@ const GodotCamera = { })); stream.getTracks().forEach((track) => track.stop()); - GodotCamera.api.stop(); } catch (error) { result.error = error.message; } @@ -247,7 +253,7 @@ const GodotCamera = { } videoTrack.addEventListener('ended', () => { GodotRuntime.print('Camera track ended, stopping stream'); - GodotCamera.api.stop(deviceId); + GodotCamera.api.stop(cameraId); }); if (navigator.permissions && navigator.permissions.query) { @@ -261,7 +267,7 @@ const GodotCamera = { if (camera.permissionListener) { permissionStatus.removeEventListener('change', camera.permissionListener); } - GodotCamera.api.stop(deviceId); + GodotCamera.api.stop(cameraId); deniedCallback(context); } }; @@ -302,7 +308,7 @@ const GodotCamera = { if (!stream || !stream.active) { GodotRuntime.print('Stream is not active, stopping'); - GodotCamera.api.stop(deviceId); + GodotCamera.api.stop(cameraId); return; } @@ -316,14 +322,13 @@ const GodotCamera = { GodotRuntime.heapCopy(HEAPU8, pixelData, dataPtr); GodotCamera.sendGetPixelDataCallback( - callback, - context, - dataPtr, - pixelData.length, - _width, - _height, - null - ); + callback, + context, + dataPtr, + pixelData.length, + _width, + _height, + null); GodotRuntime.free(dataPtr); } catch (error) { @@ -331,7 +336,8 @@ const GodotCamera = { if (error.name === 'SecurityError' || error.name === 'NotAllowedError') { GodotRuntime.print('Security error, stopping stream:', error); - GodotCamera.api.stop(deviceId); + GodotCamera.api.stop(cameraId); + deniedCallback(context); } return; } @@ -343,6 +349,9 @@ const GodotCamera = { camera.animationFrameId = requestAnimationFrame(captureFrame); } catch (error) { GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, error.message); + if (error && (error.name === 'SecurityError' || error.name === 'NotAllowedError')) { + deniedCallback(context); + } } }, From 94857baf17d815ac3bbe5b4b9fc9f94dd888bce6 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Sun, 7 Sep 2025 02:43:58 +0900 Subject: [PATCH 07/17] Apply formatter --- platform/web/camera_driver_web.cpp | 14 +++++++------- platform/web/js/libs/library_godot_camera.js | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/platform/web/camera_driver_web.cpp b/platform/web/camera_driver_web.cpp index 6a5047e08819..e0b51d43ec58 100644 --- a/platform/web/camera_driver_web.cpp +++ b/platform/web/camera_driver_web.cpp @@ -106,13 +106,13 @@ void CameraDriverWeb::_on_get_cameras_callback(void *context, void *callback, co info.index = device_dict[KEY_INDEX]; info.device_id = device_dict[KEY_ID]; info.label = device_dict[KEY_LABEL]; - // Initialize capability with safe defaults to avoid uninitialized usage downstream. - { - CapabilityInfo capability = {}; - capability.width = 0; - capability.height = 0; - info.capability = capability; - } + // Initialize capability with safe defaults to avoid uninitialized usage downstream. + { + CapabilityInfo capability = {}; + capability.width = 0; + capability.height = 0; + info.capability = capability; + } Variant v_caps_data = device_dict.get(KEY_CAPABILITIES, Variant()); if (v_caps_data.get_type() != Variant::DICTIONARY) { diff --git a/platform/web/js/libs/library_godot_camera.js b/platform/web/js/libs/library_godot_camera.js index d9774d435990..85cd71b21902 100644 --- a/platform/web/js/libs/library_godot_camera.js +++ b/platform/web/js/libs/library_godot_camera.js @@ -322,13 +322,13 @@ const GodotCamera = { GodotRuntime.heapCopy(HEAPU8, pixelData, dataPtr); GodotCamera.sendGetPixelDataCallback( - callback, - context, - dataPtr, - pixelData.length, - _width, - _height, - null); + callback, + context, + dataPtr, + pixelData.length, + _width, + _height, + null); GodotRuntime.free(dataPtr); } catch (error) { From ff9ca835b2788cc4d23f497b627987eae52d12c2 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Tue, 9 Sep 2025 15:37:11 +0900 Subject: [PATCH 08/17] Update code style --- modules/camera/camera_web.cpp | 4 ++-- platform/web/camera_driver_web.cpp | 4 ++-- platform/web/js/libs/library_godot_camera.js | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index 84badcd9fee4..aed3c8ad1be1 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -86,7 +86,7 @@ void CameraFeedWeb::_on_denied_callback(void *context) { bool CameraFeedWeb::activate_feed() { ERR_FAIL_COND_V_MSG(selected_format == -1, false, "CameraFeed format needs to be set before activating."); - // Initialize image when activating the feed + // Initialize image when activating the feed. if (image.is_null()) { image.instantiate(); } @@ -105,7 +105,7 @@ bool CameraFeedWeb::activate_feed() { void CameraFeedWeb::deactivate_feed() { CameraDriverWeb::get_singleton()->stop_stream(device_id); - // Release the image when deactivating the feed + // Release the image when deactivating the feed. image.unref(); data.clear(); } diff --git a/platform/web/camera_driver_web.cpp b/platform/web/camera_driver_web.cpp index e0b51d43ec58..8ef4cbc22336 100644 --- a/platform/web/camera_driver_web.cpp +++ b/platform/web/camera_driver_web.cpp @@ -46,7 +46,7 @@ CameraDriverWeb *CameraDriverWeb::get_singleton() { return singleton; } -// Helper to extract 'max' from a capability dictionary or use direct value +// Helper to extract 'max' from a capability dictionary or use direct value. int CameraDriverWeb::_get_max_or_direct(const Variant &p_val) { if (p_val.get_type() == Variant::DICTIONARY) { Dictionary d = p_val; @@ -63,7 +63,7 @@ int CameraDriverWeb::_get_max_or_direct(const Variant &p_val) { void CameraDriverWeb::_on_get_cameras_callback(void *context, void *callback, const char *json_ptr) { if (!json_ptr) { - ERR_PRINT("CameraDriverWeb::_on_get_cameras_callback: json_ptr is null"); + ERR_PRINT("CameraDriverWeb::_on_get_cameras_callback: json_ptr is null."); return; } String json_string = String::utf8(json_ptr); diff --git a/platform/web/js/libs/library_godot_camera.js b/platform/web/js/libs/library_godot_camera.js index 85cd71b21902..9cbd5257974b 100644 --- a/platform/web/js/libs/library_godot_camera.js +++ b/platform/web/js/libs/library_godot_camera.js @@ -167,7 +167,7 @@ const GodotCamera = { const result = { error: null, cameras: null }; try { - // request camera access permission + // request camera access permission. const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); const getCapabilities = function (deviceId) { const videoTrack = stream.getVideoTracks() @@ -273,8 +273,8 @@ const GodotCamera = { }; permissionStatus.addEventListener('change', camera.permissionListener); } catch (e) { - // Some browsers don't support 'camera' permission query - // This is not critical - we can still use the camera + // Some browsers don't support 'camera' permission query. + // This is not critical - we can still use the camera. GodotRuntime.print('Camera permission query not supported:', e.message); } } @@ -282,7 +282,7 @@ const GodotCamera = { camera.video.srcObject = camera.stream; await camera.video.play(); } else { - // Use requested dimensions when stream already exists + // Use requested dimensions when stream already exists. _width = width; _height = height; } From 136cdba40ca33494e3ea7e663467fe5c68f09313 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Sat, 4 Oct 2025 16:59:38 +0900 Subject: [PATCH 09/17] Fix header path --- modules/camera/camera_web.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/camera/camera_web.h b/modules/camera/camera_web.h index a5bcad6047a9..088753e1b5bd 100644 --- a/modules/camera/camera_web.h +++ b/modules/camera/camera_web.h @@ -32,7 +32,7 @@ #include "platform/web/camera_driver_web.h" #include "servers/camera/camera_feed.h" -#include "servers/camera_server.h" +#include "servers/camera/camera_server.h" class CameraFeedWeb : public CameraFeed { GDSOFTCLASS(CameraFeedWeb, CameraFeed); From 9e6c8194f9f3a789829be76a9a4d3ec2395f9680 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Tue, 7 Oct 2025 08:08:03 +0900 Subject: [PATCH 10/17] Add array.h header --- platform/web/camera_driver_web.h | 1 + 1 file changed, 1 insertion(+) diff --git a/platform/web/camera_driver_web.h b/platform/web/camera_driver_web.h index f8c7045f3fa2..fa3073e38df1 100644 --- a/platform/web/camera_driver_web.h +++ b/platform/web/camera_driver_web.h @@ -35,6 +35,7 @@ #include "core/string/ustring.h" #include "core/templates/vector.h" +#include "core/variant/array.h" #define KEY_CAMERAS String("cameras") #define KEY_CAPABILITIES String("capabilities") From 54a441e5b582504ae91214ec6b87b39e01d39faf Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Sat, 29 Nov 2025 00:26:46 +0900 Subject: [PATCH 11/17] Clean up --- modules/camera/camera_web.cpp | 53 ++++++++++++++++-------------- modules/camera/camera_web.h | 14 +++----- platform/web/camera_driver_web.cpp | 22 ++++++++++--- platform/web/camera_driver_web.h | 16 ++------- platform/web/godot_camera.h | 28 +++++++--------- 5 files changed, 65 insertions(+), 68 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index aed3c8ad1be1..e472322a01a2 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -30,25 +30,30 @@ #include "camera_web.h" -void CameraFeedWeb::_on_get_pixeldata(void *context, const uint8_t *rawdata, const int length, const int p_width, const int p_height, const char *error) { +namespace { +const String KEY_HEIGHT("height"); +const String KEY_WIDTH("width"); +} //namespace + +void CameraFeedWeb::_on_get_pixel_data(void *p_context, const uint8_t *p_data, const int p_length, const int p_width, const int p_height, const char *p_error) { // Validate context first to avoid dereferencing null on error paths. - if (context == nullptr) { + if (p_context == nullptr) { ERR_PRINT("Camera feed error: Null context received."); return; } - CameraFeedWeb *feed = reinterpret_cast(context); + CameraFeedWeb *feed = reinterpret_cast(p_context); - if (error) { + if (p_error) { if (feed->is_active()) { feed->deactivate_feed(); } - String error_str = String::utf8(error); + String error_str = String::utf8(p_error); ERR_PRINT(vformat("Camera feed error from JS: %s", error_str)); return; } - if (rawdata == nullptr || length < 0 || p_width <= 0 || p_height <= 0) { + if (p_data == nullptr || p_length < 0 || p_width <= 0 || p_height <= 0) { if (feed->is_active()) { feed->deactivate_feed(); } @@ -60,7 +65,7 @@ void CameraFeedWeb::_on_get_pixeldata(void *context, const uint8_t *rawdata, con Ref image = feed->image; const int64_t expected_size = Image::get_image_data_size(p_width, p_height, Image::FORMAT_RGBA8, false); - if (length < expected_size) { + if (p_length < expected_size) { if (feed->is_active()) { feed->deactivate_feed(); } @@ -71,15 +76,15 @@ void CameraFeedWeb::_on_get_pixeldata(void *context, const uint8_t *rawdata, con if (data.size() != expected_size) { data.resize(expected_size); } - // Copy exactly the expected size (ignore any trailing bytes in 'rawdata'). - memcpy(data.ptrw(), rawdata, expected_size); + // Copy exactly the expected size (ignore any trailing bytes in 'p_data'). + memcpy(data.ptrw(), p_data, expected_size); image->initialize_data(p_width, p_height, false, Image::FORMAT_RGBA8, data); feed->set_rgb_image(image); } -void CameraFeedWeb::_on_denied_callback(void *context) { - CameraFeedWeb *feed = reinterpret_cast(context); +void CameraFeedWeb::_on_denied_callback(void *p_context) { + CameraFeedWeb *feed = reinterpret_cast(p_context); feed->deactivate_feed(); } @@ -99,7 +104,7 @@ bool CameraFeedWeb::activate_feed() { width = width > 0 ? width : f.width; height = height > 0 ? height : f.height; } - CameraDriverWeb::get_singleton()->get_pixel_data(this, device_id, width, height, &_on_get_pixeldata, &_on_denied_callback); + CameraDriverWeb::get_singleton()->get_pixel_data(this, device_id, width, height, &_on_get_pixel_data, &_on_denied_callback); return true; } @@ -115,7 +120,7 @@ bool CameraFeedWeb::set_format(int p_index, const Dictionary &p_parameters) { ERR_FAIL_INDEX_V_MSG(p_index, formats.size(), false, "Invalid format index."); selected_format = p_index; - parameters = p_parameters; + parameters = p_parameters.duplicate(); return true; } @@ -153,13 +158,13 @@ CameraFeedWeb::~CameraFeedWeb() { } } -void CameraWeb::_on_get_cameras_callback(void *context, const Vector &camera_info) { - CameraWeb *server = static_cast(context); +void CameraWeb::_on_get_cameras_callback(void *p_context, const Vector &p_camera_info) { + CameraWeb *server = static_cast(p_context); for (int i = server->feeds.size() - 1; i >= 0; i--) { server->remove_feed(server->feeds[i]); } - for (int i = 0; i < camera_info.size(); i++) { - CameraInfo info = camera_info[i]; + for (int i = 0; i < p_camera_info.size(); i++) { + CameraInfo info = p_camera_info[i]; Ref feed = memnew(CameraFeedWeb(info)); server->add_feed(feed); } @@ -169,14 +174,14 @@ void CameraWeb::_on_get_cameras_callback(void *context, const Vector void CameraWeb::_update_feeds() { activating.set(); - camera_driver_web->get_cameras((void *)this, &_on_get_cameras_callback); + driver->get_cameras((void *)this, &_on_get_cameras_callback); } void CameraWeb::_cleanup() { - if (camera_driver_web != nullptr) { - camera_driver_web->stop_stream(); - memdelete(camera_driver_web); - camera_driver_web = nullptr; + if (driver != nullptr) { + driver->stop_stream(); + memdelete(driver); + driver = nullptr; } } @@ -187,8 +192,8 @@ void CameraWeb::set_monitoring_feeds(bool p_monitoring_feeds) { CameraServer::set_monitoring_feeds(p_monitoring_feeds); if (p_monitoring_feeds) { - if (camera_driver_web == nullptr) { - camera_driver_web = new CameraDriverWeb(); + if (driver == nullptr) { + driver = new CameraDriverWeb(); } _update_feeds(); } else { diff --git a/modules/camera/camera_web.h b/modules/camera/camera_web.h index 088753e1b5bd..13c8d7b31a84 100644 --- a/modules/camera/camera_web.h +++ b/modules/camera/camera_web.h @@ -37,14 +37,12 @@ class CameraFeedWeb : public CameraFeed { GDSOFTCLASS(CameraFeedWeb, CameraFeed); -private: String device_id; Ref image; Vector data; - static void _on_get_pixeldata(void *context, const uint8_t *rawdata, const int length, const int p_width, const int p_height, const char *error); - static void _on_denied_callback(void *context); + static void _on_get_pixel_data(void *p_context, const uint8_t *p_data, const int p_length, const int p_width, const int p_height, const char *p_error); + static void _on_denied_callback(void *p_context); -protected: public: bool activate_feed() override; void deactivate_feed() override; @@ -57,16 +55,14 @@ class CameraFeedWeb : public CameraFeed { }; class CameraWeb : public CameraServer { - GDCLASS(CameraWeb, CameraServer); + GDSOFTCLASS(CameraWeb, CameraServer); -private: - CameraDriverWeb *camera_driver_web = nullptr; + CameraDriverWeb *driver = nullptr; SafeFlag activating; void _cleanup(); void _update_feeds(); - static void _on_get_cameras_callback(void *context, const Vector &camera_info); + static void _on_get_cameras_callback(void *p_context, const Vector &p_camera_info); -protected: public: void set_monitoring_feeds(bool p_monitoring_feeds) override; diff --git a/platform/web/camera_driver_web.cpp b/platform/web/camera_driver_web.cpp index 8ef4cbc22336..fdacb394d527 100644 --- a/platform/web/camera_driver_web.cpp +++ b/platform/web/camera_driver_web.cpp @@ -34,6 +34,18 @@ #include +namespace { +const String KEY_CAMERAS("cameras"); +const String KEY_CAPABILITIES("capabilities"); +const String KEY_ERROR("error"); +const String KEY_HEIGHT("height"); +const String KEY_ID("id"); +const String KEY_INDEX("index"); +const String KEY_LABEL("label"); +const String KEY_MAX("max"); +const String KEY_WIDTH("width"); +} //namespace + CameraDriverWeb *CameraDriverWeb::singleton = nullptr; Array CameraDriverWeb::_camera_info_key; @@ -147,16 +159,16 @@ void CameraDriverWeb::_on_get_cameras_callback(void *context, void *callback, co } } - CameraDriverWeb_OnGetCamerasCallback on_get_cameras_callback = reinterpret_cast(callback); + CameraDriverWebGetCamerasCallback on_get_cameras_callback = reinterpret_cast(callback); on_get_cameras_callback(context, camera_info); } -void CameraDriverWeb::get_cameras(void *context, CameraDriverWeb_OnGetCamerasCallback callback) { - godot_js_camera_get_cameras(context, (void *)callback, &_on_get_cameras_callback); +void CameraDriverWeb::get_cameras(void *p_context, CameraDriverWebGetCamerasCallback p_callback) { + godot_js_camera_get_cameras(p_context, (void *)p_callback, &_on_get_cameras_callback); } -void CameraDriverWeb::get_pixel_data(void *context, const String &p_device_id, const int width, const int height, CameraLibrary_OnGetPixelDataCallback p_callback, CameraLibrary_OnDeniedCallback p_denied_callback) { - godot_js_camera_get_pixel_data(context, p_device_id.utf8().get_data(), width, height, p_callback, p_denied_callback); +void CameraDriverWeb::get_pixel_data(void *p_context, const String &p_device_id, const int p_width, const int p_height, void (*p_callback)(void *, const uint8_t *, const int, const int, const int, const char *), void (*p_denied_callback)(void *)) { + godot_js_camera_get_pixel_data(p_context, p_device_id.utf8().get_data(), p_width, p_height, p_callback, p_denied_callback); } void CameraDriverWeb::stop_stream(const String &device_id) { diff --git a/platform/web/camera_driver_web.h b/platform/web/camera_driver_web.h index fa3073e38df1..3b1a63214d36 100644 --- a/platform/web/camera_driver_web.h +++ b/platform/web/camera_driver_web.h @@ -37,16 +37,6 @@ #include "core/templates/vector.h" #include "core/variant/array.h" -#define KEY_CAMERAS String("cameras") -#define KEY_CAPABILITIES String("capabilities") -#define KEY_ERROR String("error") -#define KEY_HEIGHT String("height") -#define KEY_ID String("id") -#define KEY_INDEX String("index") -#define KEY_LABEL String("label") -#define KEY_MAX String("max") -#define KEY_WIDTH String("width") - struct CapabilityInfo { int width; int height; @@ -59,7 +49,7 @@ struct CameraInfo { CapabilityInfo capability; }; -using CameraDriverWeb_OnGetCamerasCallback = void (*)(void *context, const Vector &camera_info); +using CameraDriverWebGetCamerasCallback = void (*)(void *p_context, const Vector &p_camera_info); class CameraDriverWeb { private: @@ -70,8 +60,8 @@ class CameraDriverWeb { public: static CameraDriverWeb *get_singleton(); - void get_cameras(void *context, CameraDriverWeb_OnGetCamerasCallback callback); - void get_pixel_data(void *context, const String &p_device_id, const int width, const int height, CameraLibrary_OnGetPixelDataCallback p_callback, CameraLibrary_OnDeniedCallback p_denied_callback); + void get_cameras(void *p_context, CameraDriverWebGetCamerasCallback p_callback); + void get_pixel_data(void *p_context, const String &p_device_id, const int p_width, const int p_height, void (*p_callback)(void *, const uint8_t *, const int, const int, const int, const char *), void (*p_denied_callback)(void *)); void stop_stream(const String &device_id = String()); CameraDriverWeb(); diff --git a/platform/web/godot_camera.h b/platform/web/godot_camera.h index cb2ce5248b8a..39633575eab1 100644 --- a/platform/web/godot_camera.h +++ b/platform/web/godot_camera.h @@ -37,26 +37,20 @@ extern "C" { #endif -using CameraLibrary_OnGetCamerasCallback = void (*)(void *context, void *callback, const char *result); - -using CameraLibrary_OnGetPixelDataCallback = void (*)(void *context, const uint8_t *, const int size, const int width, const int height, const char *error); - -using CameraLibrary_OnDeniedCallback = void (*)(void *context); - extern void godot_js_camera_get_cameras( - void *context, - void *callback, - CameraLibrary_OnGetCamerasCallback p_callback_ptr); + void *p_context, + void *p_callback, + void (*p_callback_ptr)(void *p_context, void *p_callback, const char *p_result)); extern void godot_js_camera_get_pixel_data( - void *context, - const char *p_device_id_ptr, - const int width, - const int height, - CameraLibrary_OnGetPixelDataCallback p_callback_ptr, - CameraLibrary_OnDeniedCallback p_denied_callback_ptr); - -extern void godot_js_camera_stop_stream(const char *p_device_id_ptr = nullptr); + void *p_context, + const char *p_device_id, + const int p_width, + const int p_height, + void (*p_callback)(void *p_context, const uint8_t *p_data, const int p_size, const int p_width, const int p_height, const char *p_error), + void (*p_denied_callback)(void *p_context)); + +extern void godot_js_camera_stop_stream(const char *p_device_id = nullptr); #ifdef __cplusplus } From a16cda5e2d4324eb979d284ae55fecc91daa9122 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Mon, 29 Dec 2025 06:53:51 +0900 Subject: [PATCH 12/17] Web: Add safety checks and fix potential issues in camera implementation - Add null context check in _on_denied_callback using ERR_FAIL_NULL_MSG - Use ERR_FAIL_NULL_MSG macro in _on_get_pixel_data for consistency - Add bounds check in get_format() to prevent out-of-bounds access - Add is_active() check in activate_feed() to prevent duplicate activation - Add CameraDriverWeb singleton null checks in activate/deactivate_feed - Fix JS stream dimension mismatch when reusing existing stream --- modules/camera/camera_web.cpp | 23 ++++++++++++++------ platform/web/js/libs/library_godot_camera.js | 6 ++--- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index e472322a01a2..6ba1eb3742b2 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -37,10 +37,7 @@ const String KEY_WIDTH("width"); void CameraFeedWeb::_on_get_pixel_data(void *p_context, const uint8_t *p_data, const int p_length, const int p_width, const int p_height, const char *p_error) { // Validate context first to avoid dereferencing null on error paths. - if (p_context == nullptr) { - ERR_PRINT("Camera feed error: Null context received."); - return; - } + ERR_FAIL_NULL_MSG(p_context, "Camera feed error: Null context received."); CameraFeedWeb *feed = reinterpret_cast(p_context); @@ -84,11 +81,16 @@ void CameraFeedWeb::_on_get_pixel_data(void *p_context, const uint8_t *p_data, c } void CameraFeedWeb::_on_denied_callback(void *p_context) { + ERR_FAIL_NULL_MSG(p_context, "Camera feed error: Null context received in denied callback."); CameraFeedWeb *feed = reinterpret_cast(p_context); feed->deactivate_feed(); } bool CameraFeedWeb::activate_feed() { + if (is_active()) { + WARN_PRINT("Camera feed is already active."); + return true; + } ERR_FAIL_COND_V_MSG(selected_format == -1, false, "CameraFeed format needs to be set before activating."); // Initialize image when activating the feed. @@ -104,12 +106,16 @@ bool CameraFeedWeb::activate_feed() { width = width > 0 ? width : f.width; height = height > 0 ? height : f.height; } - CameraDriverWeb::get_singleton()->get_pixel_data(this, device_id, width, height, &_on_get_pixel_data, &_on_denied_callback); + CameraDriverWeb *driver = CameraDriverWeb::get_singleton(); + ERR_FAIL_NULL_V_MSG(driver, false, "CameraDriverWeb singleton is not initialized."); + driver->get_pixel_data(this, device_id, width, height, &_on_get_pixel_data, &_on_denied_callback); return true; } void CameraFeedWeb::deactivate_feed() { - CameraDriverWeb::get_singleton()->stop_stream(device_id); + CameraDriverWeb *driver = CameraDriverWeb::get_singleton(); + ERR_FAIL_NULL_MSG(driver, "CameraDriverWeb singleton is not initialized."); + driver->stop_stream(device_id); // Release the image when deactivating the feed. image.unref(); data.clear(); @@ -138,7 +144,10 @@ Array CameraFeedWeb::get_formats() const { CameraFeed::FeedFormat CameraFeedWeb::get_format() const { CameraFeed::FeedFormat feed_format = {}; - return selected_format == -1 ? feed_format : formats[selected_format]; + if (selected_format < 0 || selected_format >= formats.size()) { + return feed_format; + } + return formats[selected_format]; } CameraFeedWeb::CameraFeedWeb(const CameraInfo &info) { diff --git a/platform/web/js/libs/library_godot_camera.js b/platform/web/js/libs/library_godot_camera.js index 9cbd5257974b..2d7a4838908a 100644 --- a/platform/web/js/libs/library_godot_camera.js +++ b/platform/web/js/libs/library_godot_camera.js @@ -282,9 +282,9 @@ const GodotCamera = { camera.video.srcObject = camera.stream; await camera.video.play(); } else { - // Use requested dimensions when stream already exists. - _width = width; - _height = height; + // Get actual dimensions from existing stream. + const [videoTrack] = camera.stream.getVideoTracks(); + ({ width: _width, height: _height } = videoTrack.getSettings()); } if (camera.canvas.width !== _width || camera.canvas.height !== _height) { From 976f8bf61bff7b6b91ba05f8b95f01d67333e2e0 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Thu, 1 Jan 2026 22:59:12 +0900 Subject: [PATCH 13/17] Web: Add screen orientation and facing mode support to camera feed --- modules/camera/camera_web.cpp | 16 ++++++- modules/camera/camera_web.h | 2 +- platform/web/camera_driver_web.cpp | 2 +- platform/web/camera_driver_web.h | 2 +- platform/web/godot_camera.h | 2 +- platform/web/js/libs/library_godot_camera.js | 45 +++++++++++++++++--- 6 files changed, 58 insertions(+), 11 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index 6ba1eb3742b2..dc1bbb3382a0 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -35,7 +35,7 @@ const String KEY_HEIGHT("height"); const String KEY_WIDTH("width"); } //namespace -void CameraFeedWeb::_on_get_pixel_data(void *p_context, const uint8_t *p_data, const int p_length, const int p_width, const int p_height, const char *p_error) { +void CameraFeedWeb::_on_get_pixel_data(void *p_context, const uint8_t *p_data, const int p_length, const int p_width, const int p_height, const int p_orientation, const int p_facing_mode, const char *p_error) { // Validate context first to avoid dereferencing null on error paths. ERR_FAIL_NULL_MSG(p_context, "Camera feed error: Null context received."); @@ -58,6 +58,20 @@ void CameraFeedWeb::_on_get_pixel_data(void *p_context, const uint8_t *p_data, c return; } + // Update feed position based on facing mode. + // p_facing_mode: 0=unknown, 1=user/front, 2=environment/back + if (p_facing_mode == 1) { + feed->position = CameraFeed::FEED_FRONT; + } else if (p_facing_mode == 2) { + feed->position = CameraFeed::FEED_BACK; + } + + // Apply rotation based on screen orientation (convert degrees to radians). + // Also apply vertical flip for all cameras. + feed->transform = Transform2D(); + feed->transform = feed->transform.rotated(Math::deg_to_rad(static_cast(p_orientation))); + feed->transform = feed->transform.scaled(Vector2(1, -1)); + Vector &data = feed->data; Ref image = feed->image; diff --git a/modules/camera/camera_web.h b/modules/camera/camera_web.h index 13c8d7b31a84..fe443fb488c7 100644 --- a/modules/camera/camera_web.h +++ b/modules/camera/camera_web.h @@ -40,7 +40,7 @@ class CameraFeedWeb : public CameraFeed { String device_id; Ref image; Vector data; - static void _on_get_pixel_data(void *p_context, const uint8_t *p_data, const int p_length, const int p_width, const int p_height, const char *p_error); + static void _on_get_pixel_data(void *p_context, const uint8_t *p_data, const int p_length, const int p_width, const int p_height, const int p_orientation, const int p_facing_mode, const char *p_error); static void _on_denied_callback(void *p_context); public: diff --git a/platform/web/camera_driver_web.cpp b/platform/web/camera_driver_web.cpp index fdacb394d527..a2012662d7ce 100644 --- a/platform/web/camera_driver_web.cpp +++ b/platform/web/camera_driver_web.cpp @@ -167,7 +167,7 @@ void CameraDriverWeb::get_cameras(void *p_context, CameraDriverWebGetCamerasCall godot_js_camera_get_cameras(p_context, (void *)p_callback, &_on_get_cameras_callback); } -void CameraDriverWeb::get_pixel_data(void *p_context, const String &p_device_id, const int p_width, const int p_height, void (*p_callback)(void *, const uint8_t *, const int, const int, const int, const char *), void (*p_denied_callback)(void *)) { +void CameraDriverWeb::get_pixel_data(void *p_context, const String &p_device_id, const int p_width, const int p_height, void (*p_callback)(void *, const uint8_t *, const int, const int, const int, const int, const int, const char *), void (*p_denied_callback)(void *)) { godot_js_camera_get_pixel_data(p_context, p_device_id.utf8().get_data(), p_width, p_height, p_callback, p_denied_callback); } diff --git a/platform/web/camera_driver_web.h b/platform/web/camera_driver_web.h index 3b1a63214d36..79b818e4e691 100644 --- a/platform/web/camera_driver_web.h +++ b/platform/web/camera_driver_web.h @@ -61,7 +61,7 @@ class CameraDriverWeb { public: static CameraDriverWeb *get_singleton(); void get_cameras(void *p_context, CameraDriverWebGetCamerasCallback p_callback); - void get_pixel_data(void *p_context, const String &p_device_id, const int p_width, const int p_height, void (*p_callback)(void *, const uint8_t *, const int, const int, const int, const char *), void (*p_denied_callback)(void *)); + void get_pixel_data(void *p_context, const String &p_device_id, const int p_width, const int p_height, void (*p_callback)(void *, const uint8_t *, const int, const int, const int, const int, const int, const char *), void (*p_denied_callback)(void *)); void stop_stream(const String &device_id = String()); CameraDriverWeb(); diff --git a/platform/web/godot_camera.h b/platform/web/godot_camera.h index 39633575eab1..f7006e825241 100644 --- a/platform/web/godot_camera.h +++ b/platform/web/godot_camera.h @@ -47,7 +47,7 @@ extern void godot_js_camera_get_pixel_data( const char *p_device_id, const int p_width, const int p_height, - void (*p_callback)(void *p_context, const uint8_t *p_data, const int p_size, const int p_width, const int p_height, const char *p_error), + void (*p_callback)(void *p_context, const uint8_t *p_data, const int p_size, const int p_width, const int p_height, const int p_orientation, const int p_facing_mode, const char *p_error), void (*p_denied_callback)(void *p_context)); extern void godot_js_camera_stop_stream(const char *p_device_id = nullptr); diff --git a/platform/web/js/libs/library_godot_camera.js b/platform/web/js/libs/library_godot_camera.js index 2d7a4838908a..706d63514925 100644 --- a/platform/web/js/libs/library_godot_camera.js +++ b/platform/web/js/libs/library_godot_camera.js @@ -106,17 +106,43 @@ const GodotCamera = { * @param {number} dataLen Length of pixel data * @param {number} width Image width * @param {number} height Image height + * @param {number} orientation Screen orientation angle (0, 90, 180, 270) + * @param {number} facingMode Camera facing mode (0=unknown, 1=user/front, 2=environment/back) * @param {string|null} errorMsg Error message if any * @returns {void} */ - sendGetPixelDataCallback: function (callback, context, dataPtr, dataLen, width, height, errorMsg) { + sendGetPixelDataCallback: function (callback, context, dataPtr, dataLen, width, height, orientation, facingMode, errorMsg) { const errorMsgPtr = errorMsg ? GodotRuntime.allocString(errorMsg) : 0; - callback(context, dataPtr, dataLen, width, height, errorMsgPtr); + callback(context, dataPtr, dataLen, width, height, orientation, facingMode, errorMsgPtr); if (errorMsgPtr) { GodotRuntime.free(errorMsgPtr); } }, + /** + * Converts facingMode string to numeric value. + * @param {MediaStream|null} stream Media stream to get facing mode from + * @returns {number} 0=unknown, 1=user/front, 2=environment/back + */ + getFacingMode: function (stream) { + if (!stream) { + return 0; + } + const [videoTrack] = stream.getVideoTracks(); + if (!videoTrack) { + return 0; + } + const settings = videoTrack.getSettings(); + switch (settings.facingMode) { + case 'user': + return 1; // Front camera + case 'environment': + return 2; // Back camera + default: + return 0; // Unknown + } + }, + /** * Cleans up resources for a specific camera. * @param {CameraResource} camera Camera resource to cleanup @@ -175,8 +201,8 @@ const GodotCamera = { return videoTrack?.getCapabilities() || GodotCamera.defaultMinimumCapabilities; }; const devices = await navigator.mediaDevices.enumerateDevices(); - result.cameras = devices - .filter((device) => device.kind === 'videoinput') + const videoDevices = devices.filter((device) => device.kind === 'videoinput'); + result.cameras = videoDevices .map((device, index) => ({ index, id: device.deviceId, @@ -321,6 +347,11 @@ const GodotCamera = { const dataPtr = GodotRuntime.malloc(pixelData.length); GodotRuntime.heapCopy(HEAPU8, pixelData, dataPtr); + // Get screen orientation + + const screenOrientation = screen?.orientation?.angle ?? window.orientation ?? 0; + const facingMode = GodotCamera.getFacingMode(stream); + GodotCamera.sendGetPixelDataCallback( callback, context, @@ -328,11 +359,13 @@ const GodotCamera = { pixelData.length, _width, _height, + screenOrientation, + facingMode, null); GodotRuntime.free(dataPtr); } catch (error) { - GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, error.message); + GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, 0, 0, error.message); if (error.name === 'SecurityError' || error.name === 'NotAllowedError') { GodotRuntime.print('Security error, stopping stream:', error); @@ -348,7 +381,7 @@ const GodotCamera = { camera.animationFrameId = requestAnimationFrame(captureFrame); } catch (error) { - GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, error.message); + GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, 0, 0, error.message); if (error && (error.name === 'SecurityError' || error.name === 'NotAllowedError')) { deniedCallback(context); } From f10c8f6757e3c46cd9e16d137bf32a00c1c751d9 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Fri, 2 Jan 2026 19:23:51 +0900 Subject: [PATCH 14/17] Web: Deactivate camera feeds before removal --- modules/camera/camera_web.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index dc1bbb3382a0..aad27ddb29a0 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -183,6 +183,15 @@ CameraFeedWeb::~CameraFeedWeb() { void CameraWeb::_on_get_cameras_callback(void *p_context, const Vector &p_camera_info) { CameraWeb *server = static_cast(p_context); + + // Deactivate all feeds before removing them. + for (int i = 0; i < server->feeds.size(); i++) { + Ref feed = server->feeds[i]; + if (feed.is_valid() && feed->is_active()) { + feed->deactivate_feed(); + } + } + for (int i = server->feeds.size() - 1; i >= 0; i--) { server->remove_feed(server->feeds[i]); } From f266e19f11625ab08fcbb5d11aa27e6b93994d41 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Sat, 3 Jan 2026 01:11:48 +0900 Subject: [PATCH 15/17] Web: Add support for multiple camera formats --- modules/camera/camera_web.cpp | 12 ++-- platform/web/camera_driver_web.cpp | 76 +++++++++----------- platform/web/camera_driver_web.h | 6 +- platform/web/js/libs/library_godot_camera.js | 74 +++++++++++++++---- 4 files changed, 103 insertions(+), 65 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index aad27ddb29a0..cfbf8c968085 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -168,11 +168,13 @@ CameraFeedWeb::CameraFeedWeb(const CameraInfo &info) { name = info.label; device_id = info.device_id; - FeedFormat feed_format; - feed_format.width = info.capability.width; - feed_format.height = info.capability.height; - feed_format.format = String("RGBA"); - formats.append(feed_format); + for (int i = 0; i < info.formats.size(); i++) { + FeedFormat feed_format; + feed_format.width = info.formats[i].width; + feed_format.height = info.formats[i].height; + feed_format.format = String("RGBA"); + formats.append(feed_format); + } } CameraFeedWeb::~CameraFeedWeb() { diff --git a/platform/web/camera_driver_web.cpp b/platform/web/camera_driver_web.cpp index a2012662d7ce..7125e664e2c0 100644 --- a/platform/web/camera_driver_web.cpp +++ b/platform/web/camera_driver_web.cpp @@ -36,13 +36,12 @@ namespace { const String KEY_CAMERAS("cameras"); -const String KEY_CAPABILITIES("capabilities"); const String KEY_ERROR("error"); +const String KEY_FORMATS("formats"); const String KEY_HEIGHT("height"); const String KEY_ID("id"); const String KEY_INDEX("index"); const String KEY_LABEL("label"); -const String KEY_MAX("max"); const String KEY_WIDTH("width"); } //namespace @@ -58,14 +57,9 @@ CameraDriverWeb *CameraDriverWeb::get_singleton() { return singleton; } -// Helper to extract 'max' from a capability dictionary or use direct value. -int CameraDriverWeb::_get_max_or_direct(const Variant &p_val) { - if (p_val.get_type() == Variant::DICTIONARY) { - Dictionary d = p_val; - if (d.has(KEY_MAX)) { - return d[KEY_MAX]; - } - } else if (p_val.get_type() == Variant::INT) { +// Helper to extract integer value from Variant. +int CameraDriverWeb::_get_int_value(const Variant &p_val) { + if (p_val.get_type() == Variant::INT) { return p_val; } else if (p_val.get_type() == Variant::FLOAT) { return static_cast(p_val.operator float()); @@ -118,45 +112,39 @@ void CameraDriverWeb::_on_get_cameras_callback(void *context, void *callback, co info.index = device_dict[KEY_INDEX]; info.device_id = device_dict[KEY_ID]; info.label = device_dict[KEY_LABEL]; - // Initialize capability with safe defaults to avoid uninitialized usage downstream. - { - CapabilityInfo capability = {}; - capability.width = 0; - capability.height = 0; - info.capability = capability; - } - Variant v_caps_data = device_dict.get(KEY_CAPABILITIES, Variant()); - if (v_caps_data.get_type() != Variant::DICTIONARY) { - WARN_PRINT("Camera info entry has no capabilities or capabilities are not a dictionary."); - camera_info.push_back(info); - continue; + // Parse formats array. + Variant v_formats = device_dict.get(KEY_FORMATS, Variant()); + if (v_formats.get_type() == Variant::ARRAY) { + Array formats_array = v_formats; + for (int j = 0; j < formats_array.size(); j++) { + Variant format_variant = formats_array.get(j); + if (format_variant.get_type() != Variant::DICTIONARY) { + continue; + } + + Dictionary format_dict = format_variant; + if (!format_dict.has(KEY_WIDTH) || !format_dict.has(KEY_HEIGHT)) { + continue; + } + + int width = _get_int_value(format_dict.get(KEY_WIDTH, Variant())); + int height = _get_int_value(format_dict.get(KEY_HEIGHT, Variant())); + + if (width > 0 && height > 0) { + FormatInfo format_info; + format_info.width = width; + format_info.height = height; + info.formats.push_back(format_info); + } + } } - Dictionary caps_dict = v_caps_data; - if (!caps_dict.has(KEY_WIDTH) || !caps_dict.has(KEY_HEIGHT)) { - WARN_PRINT("Capabilities object does not directly contain top-level width/height keys."); - camera_info.push_back(info); - continue; + if (info.formats.is_empty()) { + WARN_PRINT("Camera info entry has no valid formats."); } - Variant v_width_val = caps_dict.get(KEY_WIDTH, Variant()); - Variant v_height_val = caps_dict.get(KEY_HEIGHT, Variant()); - - int width = _get_max_or_direct(v_width_val); - int height = _get_max_or_direct(v_height_val); - - if (width <= 0 || height <= 0) { - WARN_PRINT("Could not extract valid width/height from capabilities structure."); - // Still include the device in the list; keep zeroed capabilities. - camera_info.push_back(info); - } else { - CapabilityInfo capability; - capability.width = width; - capability.height = height; - info.capability = capability; - camera_info.push_back(info); - } + camera_info.push_back(info); } CameraDriverWebGetCamerasCallback on_get_cameras_callback = reinterpret_cast(callback); diff --git a/platform/web/camera_driver_web.h b/platform/web/camera_driver_web.h index 79b818e4e691..62fe2e31f111 100644 --- a/platform/web/camera_driver_web.h +++ b/platform/web/camera_driver_web.h @@ -37,7 +37,7 @@ #include "core/templates/vector.h" #include "core/variant/array.h" -struct CapabilityInfo { +struct FormatInfo { int width; int height; }; @@ -46,7 +46,7 @@ struct CameraInfo { int index; String device_id; String label; - CapabilityInfo capability; + Vector formats; }; using CameraDriverWebGetCamerasCallback = void (*)(void *p_context, const Vector &p_camera_info); @@ -55,7 +55,7 @@ class CameraDriverWeb { private: static CameraDriverWeb *singleton; static Array _camera_info_key; - static int _get_max_or_direct(const Variant &p_val); + static int _get_int_value(const Variant &p_val); WASM_EXPORT static void _on_get_cameras_callback(void *context, void *callback, const char *json_ptr); public: diff --git a/platform/web/js/libs/library_godot_camera.js b/platform/web/js/libs/library_godot_camera.js index 706d63514925..e742e9b82845 100644 --- a/platform/web/js/libs/library_godot_camera.js +++ b/platform/web/js/libs/library_godot_camera.js @@ -57,13 +57,46 @@ const GodotCamera = { cameras: new Map(), defaultMinimumCapabilities: { 'width': { + 'min': 1, 'max': 1280, }, 'height': { - 'max': 1080, + 'min': 1, + 'max': 720, }, }, + /** + * Common resolutions to check against camera capabilities. + */ + commonResolutions: [ + { width: 320, height: 240 }, // QVGA (4:3) + { width: 352, height: 288 }, // CIF (4:3) - Video conferencing + { width: 640, height: 480 }, // VGA (4:3) + { width: 1024, height: 768 }, // XGA (4:3) + { width: 1280, height: 720 }, // HD 720p (16:9) + { width: 1280, height: 960 }, // SXGA- (4:3) + { width: 1600, height: 1200 }, // UXGA (4:3) + { width: 1920, height: 1080 }, // Full HD 1080p (16:9) + { width: 2560, height: 1440 }, // QHD 1440p (16:9) + { width: 3840, height: 2160 }, // 4K UHD 2160p (16:9) + ], + + /** + * Gets supported formats based on capabilities. + * @param {Object} capabilities MediaTrackCapabilities object + * @returns {Array<{width: number, height: number}>} Supported resolutions + */ + getSupportedFormats: function (capabilities) { + const widthRange = capabilities.width || this.defaultMinimumCapabilities.width; + const heightRange = capabilities.height || this.defaultMinimumCapabilities.height; + + return this.commonResolutions.filter((res) => res.width >= (widthRange.min || 1) + && res.width <= (widthRange.max || 9999) + && res.height >= (heightRange.min || 1) + && res.height <= (heightRange.max || 9999)); + }, + /** * Ensures cameras Map is properly initialized. * @returns {Map} @@ -193,24 +226,39 @@ const GodotCamera = { const result = { error: null, cameras: null }; try { - // request camera access permission. - const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); - const getCapabilities = function (deviceId) { - const videoTrack = stream.getVideoTracks() - .find((track) => track.getSettings().deviceId === deviceId); - return videoTrack?.getCapabilities() || GodotCamera.defaultMinimumCapabilities; - }; + // Request camera access permission first. + const initialStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); + initialStream.getTracks().forEach((track) => track.stop()); + const devices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = devices.filter((device) => device.kind === 'videoinput'); - result.cameras = videoDevices - .map((device, index) => ({ + + // Get capabilities for each camera device. + const cameraPromises = videoDevices.map(async (device, index) => { + let formats = []; + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: { exact: device.deviceId } }, + audio: false, + }); + const [videoTrack] = stream.getVideoTracks(); + const capabilities = videoTrack?.getCapabilities() || GodotCamera.defaultMinimumCapabilities; + formats = GodotCamera.getSupportedFormats(capabilities); + stream.getTracks().forEach((track) => track.stop()); + } catch (e) { + // If we can't get capabilities, use default formats. + formats = GodotCamera.getSupportedFormats(GodotCamera.defaultMinimumCapabilities); + } + + return { index, id: device.deviceId, label: device.label || `Camera ${index}`, - capabilities: getCapabilities(device.deviceId), - })); + formats, + }; + }); - stream.getTracks().forEach((track) => track.stop()); + result.cameras = await Promise.all(cameraPromises); } catch (error) { result.error = error.message; } From bfef51df079d856c3160a5d2fd56edcfe74e530c Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Mon, 5 Jan 2026 11:56:17 +0900 Subject: [PATCH 16/17] Web: Add frame rate support to camera formats --- modules/camera/camera_web.cpp | 8 ++++++++ platform/web/camera_driver_web.cpp | 3 +++ platform/web/camera_driver_web.h | 1 + platform/web/js/libs/library_godot_camera.js | 20 ++++++++++++++------ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index cfbf8c968085..ae4768f75114 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -151,6 +151,8 @@ Array CameraFeedWeb::get_formats() const { dictionary["width"] = feed_format.width; dictionary["height"] = feed_format.height; dictionary["format"] = feed_format.format; + dictionary["frame_numerator"] = feed_format.frame_numerator; + dictionary["frame_denominator"] = feed_format.frame_denominator; result.push_back(dictionary); } return result; @@ -173,6 +175,12 @@ CameraFeedWeb::CameraFeedWeb(const CameraInfo &info) { feed_format.width = info.formats[i].width; feed_format.height = info.formats[i].height; feed_format.format = String("RGBA"); + // Web API provides frame rate as integer (max fps). + // Use frame_numerator/frame_denominator format consistent with other platforms. + if (info.formats[i].frame_rate > 0) { + feed_format.frame_numerator = info.formats[i].frame_rate; + feed_format.frame_denominator = 1; + } formats.append(feed_format); } } diff --git a/platform/web/camera_driver_web.cpp b/platform/web/camera_driver_web.cpp index 7125e664e2c0..1e06bf4b7da6 100644 --- a/platform/web/camera_driver_web.cpp +++ b/platform/web/camera_driver_web.cpp @@ -38,6 +38,7 @@ namespace { const String KEY_CAMERAS("cameras"); const String KEY_ERROR("error"); const String KEY_FORMATS("formats"); +const String KEY_FRAME_RATE("frameRate"); const String KEY_HEIGHT("height"); const String KEY_ID("id"); const String KEY_INDEX("index"); @@ -130,11 +131,13 @@ void CameraDriverWeb::_on_get_cameras_callback(void *context, void *callback, co int width = _get_int_value(format_dict.get(KEY_WIDTH, Variant())); int height = _get_int_value(format_dict.get(KEY_HEIGHT, Variant())); + int frame_rate = _get_int_value(format_dict.get(KEY_FRAME_RATE, Variant())); if (width > 0 && height > 0) { FormatInfo format_info; format_info.width = width; format_info.height = height; + format_info.frame_rate = frame_rate; info.formats.push_back(format_info); } } diff --git a/platform/web/camera_driver_web.h b/platform/web/camera_driver_web.h index 62fe2e31f111..48c7a5adaffc 100644 --- a/platform/web/camera_driver_web.h +++ b/platform/web/camera_driver_web.h @@ -40,6 +40,7 @@ struct FormatInfo { int width; int height; + int frame_rate; }; struct CameraInfo { diff --git a/platform/web/js/libs/library_godot_camera.js b/platform/web/js/libs/library_godot_camera.js index e742e9b82845..a5a14bc88f39 100644 --- a/platform/web/js/libs/library_godot_camera.js +++ b/platform/web/js/libs/library_godot_camera.js @@ -85,16 +85,24 @@ const GodotCamera = { /** * Gets supported formats based on capabilities. * @param {Object} capabilities MediaTrackCapabilities object - * @returns {Array<{width: number, height: number}>} Supported resolutions + * @returns {Array<{width: number, height: number, frameRate: number}>} Supported resolutions with frame rate */ getSupportedFormats: function (capabilities) { const widthRange = capabilities.width || this.defaultMinimumCapabilities.width; const heightRange = capabilities.height || this.defaultMinimumCapabilities.height; - - return this.commonResolutions.filter((res) => res.width >= (widthRange.min || 1) - && res.width <= (widthRange.max || 9999) - && res.height >= (heightRange.min || 1) - && res.height <= (heightRange.max || 9999)); + const frameRateRange = capabilities.frameRate || { min: 1, max: 30 }; + const maxFrameRate = frameRateRange.max || 30; + + return this.commonResolutions + .filter((res) => res.width >= (widthRange.min || 1) + && res.width <= (widthRange.max || 9999) + && res.height >= (heightRange.min || 1) + && res.height <= (heightRange.max || 9999)) + .map((res) => ({ + width: res.width, + height: res.height, + frameRate: maxFrameRate, + })); }, /** From af6f2321649bbe15511402e1dd116d30b8853709 Mon Sep 17 00:00:00 2001 From: KOGA Mitsuhiro Date: Wed, 7 Jan 2026 17:37:54 +0900 Subject: [PATCH 17/17] Web: Add WebCodecs camera capture with Worker offloading Implement modern camera capture APIs with automatic fallback chain for broad browser compatibility and optimal performance: 1. WebCodecs + Worker (VideoFrame transferred to Worker for copyTo) 2. Worker Canvas 2D (ImageBitmap transferred for drawImage/getImageData) 3. WebCodecs main thread (copyTo on main thread) 4. Canvas 2D main thread (legacy fallback) Features: - Automatic API detection and selection at runtime - VideoFrame transfer to Worker for off-main-thread processing - Web Worker embedded as Blob URL (no external file) - ImageBitmap/VideoFrame transfer for zero-copy communication - Graceful fallback on errors at each level Performance considerations: - WebCodecs + Worker is preferred as it offloads copyTo() to worker - Worker-based methods prevent main thread blocking - WebGPU was evaluated but removed (texture-to-buffer-to-CPU path is less efficient than WebCodecs copyTo for pixel readback) Browser support: - WebCodecs: Chrome 94+, Edge 94+, Firefox 141+, Safari 26+ - Web Worker: All modern browsers - Canvas 2D: Universal fallback --- modules/camera/camera_web.cpp | 6 +- modules/camera/camera_web.h | 1 + platform/web/js/libs/library_godot_camera.js | 885 +++++++++++++++++-- 3 files changed, 807 insertions(+), 85 deletions(-) diff --git a/modules/camera/camera_web.cpp b/modules/camera/camera_web.cpp index ae4768f75114..a9533622df82 100644 --- a/modules/camera/camera_web.cpp +++ b/modules/camera/camera_web.cpp @@ -120,9 +120,12 @@ bool CameraFeedWeb::activate_feed() { width = width > 0 ? width : f.width; height = height > 0 ? height : f.height; } + CameraDriverWeb *driver = CameraDriverWeb::get_singleton(); ERR_FAIL_NULL_V_MSG(driver, false, "CameraDriverWeb singleton is not initialized."); - driver->get_pixel_data(this, device_id, width, height, &_on_get_pixel_data, &_on_denied_callback); + + driver->get_pixel_data(this, device_id, width, height, + &_on_get_pixel_data, &_on_denied_callback); return true; } @@ -130,6 +133,7 @@ void CameraFeedWeb::deactivate_feed() { CameraDriverWeb *driver = CameraDriverWeb::get_singleton(); ERR_FAIL_NULL_MSG(driver, "CameraDriverWeb singleton is not initialized."); driver->stop_stream(device_id); + // Release the image when deactivating the feed. image.unref(); data.clear(); diff --git a/modules/camera/camera_web.h b/modules/camera/camera_web.h index fe443fb488c7..88caab621970 100644 --- a/modules/camera/camera_web.h +++ b/modules/camera/camera_web.h @@ -40,6 +40,7 @@ class CameraFeedWeb : public CameraFeed { String device_id; Ref image; Vector data; + static void _on_get_pixel_data(void *p_context, const uint8_t *p_data, const int p_length, const int p_width, const int p_height, const int p_orientation, const int p_facing_mode, const char *p_error); static void _on_denied_callback(void *p_context); diff --git a/platform/web/js/libs/library_godot_camera.js b/platform/web/js/libs/library_godot_camera.js index a5a14bc88f39..dd36cc909529 100644 --- a/platform/web/js/libs/library_godot_camera.js +++ b/platform/web/js/libs/library_godot_camera.js @@ -43,6 +43,12 @@ * animationFrameId: number|null * permissionListener: Function|null * permissionStatus: PermissionStatus|null + * trackProcessor: MediaStreamTrackProcessor|null + * frameReader: ReadableStreamDefaultReader|null + * worker: Worker|null + * useWebCodecsWorker: boolean + * useWebCodecs: boolean + * useWorker: boolean * }} CameraResource */ @@ -55,6 +61,19 @@ const GodotCamera = { * @type {Map} */ cameras: new Map(), + + /** + * Cached result of Web Worker support check. + * @type {boolean|null} + */ + workerSupported: null, + + /** + * Blob URL for the camera worker script. + * @type {string|null} + */ + workerBlobUrl: null, + defaultMinimumCapabilities: { 'width': { 'min': 1, @@ -105,6 +124,217 @@ const GodotCamera = { })); }, + /** + * Checks if WebCodecs API is supported for camera capture. + * @returns {boolean} True if MediaStreamTrackProcessor and VideoFrame are available + */ + isWebCodecsSupported: function () { + return 'MediaStreamTrackProcessor' in window && 'VideoFrame' in window; + }, + + /** + * Checks if Web Worker for camera frame processing is supported. + * Requires Worker, OffscreenCanvas, and createImageBitmap support. + * @returns {boolean} True if worker-based capture is supported + */ + isWorkerSupported: function () { + if (this.workerSupported !== null) { + return this.workerSupported; + } + + try { + // Check for Worker support + if (typeof Worker === 'undefined') { + this.workerSupported = false; + return false; + } + + // Check for OffscreenCanvas support (required in worker) + if (typeof OffscreenCanvas === 'undefined') { + this.workerSupported = false; + return false; + } + + // Check for createImageBitmap support (required for transferring frames) + if (typeof createImageBitmap === 'undefined') { + this.workerSupported = false; + return false; + } + + this.workerSupported = true; + GodotRuntime.print('Web Worker for camera capture is supported'); + } catch (e) { + GodotRuntime.print('Web Worker support check failed:', e.message); + this.workerSupported = false; + } + + return this.workerSupported; + }, + + /** + * Creates or returns the Blob URL for the camera worker script. + * The worker is embedded as inline code to avoid external file dependencies. + * @returns {string|null} Blob URL for the worker, or null if creation fails + */ + getWorkerBlobUrl: function () { + if (this.workerBlobUrl) { + return this.workerBlobUrl; + } + + // Worker code embedded as a string. + // Supports both VideoFrame (WebCodecs) and ImageBitmap (Canvas 2D) processing. + const workerCode = ` +// Worker state +let canvas = null; +let canvasContext = null; +let width = 0; +let height = 0; +let isCapturing = false; + +// Process ImageBitmap using Canvas 2D (fallback method) +function processImageBitmap(imageBitmap) { + const frameWidth = imageBitmap.width; + const frameHeight = imageBitmap.height; + + if (canvas.width !== frameWidth || canvas.height !== frameHeight) { + canvas.width = frameWidth; + canvas.height = frameHeight; + width = frameWidth; + height = frameHeight; + canvasContext = canvas.getContext('2d', { willReadFrequently: true }); + } + + canvasContext.drawImage(imageBitmap, 0, 0, width, height); + const imageData = canvasContext.getImageData(0, 0, width, height); + imageBitmap.close(); + + return { + pixelData: imageData.data, + width: width, + height: height, + }; +} + +// Process VideoFrame using WebCodecs copyTo (most efficient) +async function processVideoFrame(videoFrame) { + const frameWidth = videoFrame.displayWidth; + const frameHeight = videoFrame.displayHeight; + const bufferSize = frameWidth * frameHeight * 4; + const pixelBuffer = new Uint8Array(bufferSize); + + try { + await videoFrame.copyTo(pixelBuffer, { + rect: { x: 0, y: 0, width: frameWidth, height: frameHeight }, + layout: [{ offset: 0, stride: frameWidth * 4 }], + format: 'RGBA', + }); + + return { + pixelData: pixelBuffer, + width: frameWidth, + height: frameHeight, + }; + } finally { + videoFrame.close(); + } +} + +self.onmessage = async function(event) { + const { type, data } = event.data; + + switch (type) { + case 'init': + canvas = new OffscreenCanvas(data.width || 640, data.height || 480); + width = data.width || 640; + height = data.height || 480; + canvasContext = canvas.getContext('2d', { willReadFrequently: true }); + isCapturing = true; + self.postMessage({ type: 'initialized' }); + break; + + case 'videoFrame': + // WebCodecs VideoFrame processing + if (!isCapturing) { + if (data.videoFrame) { + data.videoFrame.close(); + } + return; + } + + try { + const result = await processVideoFrame(data.videoFrame); + self.postMessage( + { + type: 'frameData', + pixelData: result.pixelData, + width: result.width, + height: result.height, + orientation: data.orientation, + facingMode: data.facingMode, + }, + [result.pixelData.buffer] + ); + } catch (error) { + self.postMessage({ + type: 'error', + message: error.message, + }); + } + break; + + case 'frame': + // Canvas 2D ImageBitmap processing (fallback) + if (!isCapturing || !canvas) { + if (data.imageBitmap) { + data.imageBitmap.close(); + } + return; + } + + try { + const result = processImageBitmap(data.imageBitmap); + self.postMessage( + { + type: 'frameData', + pixelData: result.pixelData, + width: result.width, + height: result.height, + orientation: data.orientation, + facingMode: data.facingMode, + }, + [result.pixelData.buffer] + ); + } catch (error) { + self.postMessage({ + type: 'error', + message: error.message, + }); + } + break; + + case 'stop': + isCapturing = false; + canvas = null; + canvasContext = null; + self.postMessage({ type: 'stopped' }); + break; + + default: + console.warn('Unknown message type:', type); + } +}; +`; + + try { + const blob = new Blob([workerCode], { type: 'application/javascript' }); + this.workerBlobUrl = URL.createObjectURL(blob); + return this.workerBlobUrl; + } catch (e) { + GodotRuntime.print('Failed to create worker blob URL:', e.message); + return null; + } + }, + /** * Ensures cameras Map is properly initialized. * @returns {Map} @@ -122,6 +352,12 @@ const GodotCamera = { */ cleanup: function () { this.api.stop(); + + // Revoke worker blob URL to free memory. + if (this.workerBlobUrl) { + URL.revokeObjectURL(this.workerBlobUrl); + this.workerBlobUrl = null; + } }, /** @@ -184,12 +420,539 @@ const GodotCamera = { } }, + /** + * Sets up WebCodecs-based frame capture using MediaStreamTrackProcessor. + * @param {CameraResource} camera Camera resource + * @param {string} cameraId Camera identifier + * @param {Function} callback Callback function for frame data + * @param {number} context Context value to pass to callback + * @param {Function} deniedCallback Callback for permission denied + * @returns {void} + */ + setupWebCodecsCapture: function (camera, cameraId, callback, context, deniedCallback) { + const [videoTrack] = camera.stream.getVideoTracks(); + + camera.trackProcessor = new MediaStreamTrackProcessor({ track: videoTrack }); + camera.frameReader = camera.trackProcessor.readable.getReader(); + + const processFrames = async () => { + const cameras = GodotCamera.ensureCamerasMap(); + try { + while (true) { + const currentCamera = cameras.get(cameraId); + if (!currentCamera || !currentCamera.useWebCodecs) { + break; + } + + // eslint-disable-next-line no-await-in-loop + const { done, value: videoFrame } = await currentCamera.frameReader.read(); + if (done) { + break; + } + + try { + const width = videoFrame.displayWidth; + const height = videoFrame.displayHeight; + const bufferSize = width * height * 4; + const pixelBuffer = new Uint8Array(bufferSize); + + // eslint-disable-next-line no-await-in-loop + await videoFrame.copyTo(pixelBuffer, { + rect: { x: 0, y: 0, width, height }, + layout: [{ offset: 0, stride: width * 4 }], + format: 'RGBA', + }); + + const dataPtr = GodotRuntime.malloc(pixelBuffer.length); + GodotRuntime.heapCopy(HEAPU8, pixelBuffer, dataPtr); + + const screenOrientation = screen?.orientation?.angle ?? window.orientation ?? 0; + const facingMode = GodotCamera.getFacingMode(currentCamera.stream); + + GodotCamera.sendGetPixelDataCallback( + callback, + context, + dataPtr, + pixelBuffer.length, + width, + height, + screenOrientation, + facingMode, + null + ); + + GodotRuntime.free(dataPtr); + } finally { + videoFrame.close(); + } + } + } catch (error) { + GodotRuntime.print('WebCodecs error, falling back to Canvas 2D:', error.message); + const currentCamera = cameras.get(cameraId); + if (currentCamera) { + // Clean up WebCodecs resources before fallback. + if (currentCamera.frameReader) { + currentCamera.frameReader.cancel().catch(() => {}); + currentCamera.frameReader = null; + } + currentCamera.trackProcessor = null; + currentCamera.useWebCodecs = false; + + // Fall back to Worker-based Canvas 2D if supported, else main thread. + if (GodotCamera.isWorkerSupported()) { + currentCamera.useWorker = true; + GodotCamera.setupCanvas2DWorkerCapture(currentCamera, cameraId, callback, context, deniedCallback); + } else { + GodotCamera.setupCanvas2DCapture(currentCamera, cameraId, callback, context, deniedCallback); + } + } + } + }; + + processFrames(); + }, + + /** + * Sets up WebCodecs + Worker frame capture. + * Uses MediaStreamTrackProcessor to get VideoFrame, transfers to Worker for copyTo(). + * This is the most efficient method as it offloads processing to a worker thread. + * @param {CameraResource} camera Camera resource + * @param {string} cameraId Camera identifier + * @param {Function} callback Callback function for frame data + * @param {number} context Context value to pass to callback + * @param {Function} deniedCallback Callback for permission denied + * @returns {void} + */ + setupWebCodecsWorkerCapture: function (camera, cameraId, callback, context, deniedCallback) { + const workerUrl = this.getWorkerBlobUrl(); + if (!workerUrl) { + GodotRuntime.print('Failed to create worker, falling back to WebCodecs main thread'); + this.setupWebCodecsCapture(camera, cameraId, callback, context, deniedCallback); + return; + } + + const [videoTrack] = camera.stream.getVideoTracks(); + const { width: _width, height: _height } = videoTrack.getSettings(); + + try { + camera.worker = new Worker(workerUrl); + } catch (e) { + GodotRuntime.print('Failed to create worker:', e.message); + this.setupWebCodecsCapture(camera, cameraId, callback, context, deniedCallback); + return; + } + + // Set up MediaStreamTrackProcessor + + camera.trackProcessor = new MediaStreamTrackProcessor({ track: videoTrack }); + camera.frameReader = camera.trackProcessor.readable.getReader(); + + // Handle messages from worker + camera.worker.onmessage = (event) => { + const { type } = event.data; + + switch (type) { + case 'initialized': + GodotRuntime.print('Camera worker initialized (WebCodecs mode)'); + break; + + case 'frameData': { + const { pixelData, width, height, orientation, facingMode } = event.data; + const dataPtr = GodotRuntime.malloc(pixelData.length); + GodotRuntime.heapCopy(HEAPU8, pixelData, dataPtr); + + GodotCamera.sendGetPixelDataCallback( + callback, + context, + dataPtr, + pixelData.length, + width, + height, + orientation, + facingMode, + null + ); + + GodotRuntime.free(dataPtr); + break; + } + + case 'error': + GodotRuntime.print('Worker error:', event.data.message); + // Fall back to Worker Canvas 2D on error + camera.useWebCodecsWorker = false; + if (camera.frameReader) { + camera.frameReader.cancel().catch(() => {}); + camera.frameReader = null; + } + camera.trackProcessor = null; + // Keep the worker for Canvas 2D fallback + camera.useWorker = true; + GodotCamera.setupCanvas2DWorkerCapture(camera, cameraId, callback, context, deniedCallback); + break; + + case 'stopped': + GodotRuntime.print('Camera worker stopped'); + break; + + default: + break; + } + }; + + camera.worker.onerror = (error) => { + GodotRuntime.print('Worker error event:', error.message); + camera.useWebCodecsWorker = false; + if (camera.frameReader) { + camera.frameReader.cancel().catch(() => {}); + camera.frameReader = null; + } + camera.trackProcessor = null; + if (camera.worker) { + camera.worker.terminate(); + camera.worker = null; + } + // Fall back to WebCodecs main thread + camera.useWebCodecs = true; + GodotCamera.setupWebCodecsCapture(camera, cameraId, callback, context, deniedCallback); + }; + + // Initialize the worker + camera.worker.postMessage({ + type: 'init', + data: { width: _width, height: _height }, + }); + + // Start the frame reading loop + const processFrames = async () => { + const cameras = GodotCamera.ensureCamerasMap(); + try { + while (true) { + const currentCamera = cameras.get(cameraId); + if (!currentCamera || !currentCamera.useWebCodecsWorker || !currentCamera.worker) { + break; + } + + // eslint-disable-next-line no-await-in-loop + const { done, value: videoFrame } = await currentCamera.frameReader.read(); + if (done) { + break; + } + + const screenOrientation = screen?.orientation?.angle ?? window.orientation ?? 0; + const facingMode = GodotCamera.getFacingMode(currentCamera.stream); + + // Transfer VideoFrame to worker + currentCamera.worker.postMessage( + { + type: 'videoFrame', + data: { + videoFrame, + orientation: screenOrientation, + facingMode, + }, + }, + [videoFrame] + ); + } + } catch (error) { + GodotRuntime.print('WebCodecs Worker error, falling back:', error.message); + const currentCamera = cameras.get(cameraId); + if (currentCamera && currentCamera.useWebCodecsWorker) { + currentCamera.useWebCodecsWorker = false; + if (currentCamera.frameReader) { + currentCamera.frameReader.cancel().catch(() => {}); + currentCamera.frameReader = null; + } + currentCamera.trackProcessor = null; + + // Fall back to Worker Canvas 2D + if (currentCamera.worker) { + currentCamera.useWorker = true; + GodotCamera.setupCanvas2DWorkerCapture(currentCamera, cameraId, callback, context, deniedCallback); + } else { + // Fall back to WebCodecs main thread + currentCamera.useWebCodecs = true; + GodotCamera.setupWebCodecsCapture(currentCamera, cameraId, callback, context, deniedCallback); + } + } + } + }; + + processFrames(); + }, + + /** + * Sets up Canvas 2D-based frame capture (fallback method). + * @param {CameraResource} camera Camera resource + * @param {string} cameraId Camera identifier + * @param {Function} callback Callback function for frame data + * @param {number} context Context value to pass to callback + * @param {Function} deniedCallback Callback for permission denied + * @returns {void} + */ + setupCanvas2DCapture: function (camera, cameraId, callback, context, deniedCallback) { + const [videoTrack] = camera.stream.getVideoTracks(); + const { width: _width, height: _height } = videoTrack.getSettings(); + + if (!camera.canvas) { + if (typeof OffscreenCanvas !== 'undefined') { + camera.canvas = new OffscreenCanvas(_width, _height); + } else { + camera.canvas = document.createElement('canvas'); + camera.canvas.style.display = 'none'; + document.body.appendChild(camera.canvas); + } + } + + if (camera.canvas.width !== _width || camera.canvas.height !== _height) { + camera.canvas.width = _width; + camera.canvas.height = _height; + } + camera.canvasContext = camera.canvas.getContext('2d', { willReadFrequently: true }); + + if (camera.animationFrameId) { + cancelAnimationFrame(camera.animationFrameId); + } + + const captureFrame = () => { + const cameras = GodotCamera.ensureCamerasMap(); + const currentCamera = cameras.get(cameraId); + if (!currentCamera) { + return; + } + + const { video, canvasContext, stream } = currentCamera; + + if (!stream || !stream.active) { + GodotRuntime.print('Stream is not active, stopping'); + GodotCamera.api.stop(cameraId); + return; + } + + if (video.readyState === video.HAVE_ENOUGH_DATA) { + try { + canvasContext.drawImage(video, 0, 0, _width, _height); + const imageData = canvasContext.getImageData(0, 0, _width, _height); + const pixelData = imageData.data; + + const dataPtr = GodotRuntime.malloc(pixelData.length); + GodotRuntime.heapCopy(HEAPU8, pixelData, dataPtr); + + const screenOrientation = screen?.orientation?.angle ?? window.orientation ?? 0; + const facingMode = GodotCamera.getFacingMode(stream); + + GodotCamera.sendGetPixelDataCallback( + callback, + context, + dataPtr, + pixelData.length, + _width, + _height, + screenOrientation, + facingMode, + null + ); + + GodotRuntime.free(dataPtr); + } catch (error) { + GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, 0, 0, error.message); + + if (error.name === 'SecurityError' || error.name === 'NotAllowedError') { + GodotRuntime.print('Security error, stopping stream:', error); + GodotCamera.api.stop(cameraId); + deniedCallback(context); + } + return; + } + } + + currentCamera.animationFrameId = requestAnimationFrame(captureFrame); + }; + + camera.animationFrameId = requestAnimationFrame(captureFrame); + }, + + /** + * Sets up Web Worker-based Canvas 2D frame capture. + * Offloads drawImage and getImageData to a worker thread. + * @param {CameraResource} camera Camera resource + * @param {string} cameraId Camera identifier + * @param {Function} callback Callback function for frame data + * @param {number} context Context value to pass to callback + * @param {Function} deniedCallback Callback for permission denied + * @returns {void} + */ + setupCanvas2DWorkerCapture: function (camera, cameraId, callback, context, deniedCallback) { + const workerUrl = this.getWorkerBlobUrl(); + if (!workerUrl) { + GodotRuntime.print('Failed to create worker, falling back to main thread Canvas 2D'); + camera.useWorker = false; + this.setupCanvas2DCapture(camera, cameraId, callback, context, deniedCallback); + return; + } + + const [videoTrack] = camera.stream.getVideoTracks(); + const { width: _width, height: _height } = videoTrack.getSettings(); + + try { + camera.worker = new Worker(workerUrl); + } catch (e) { + GodotRuntime.print('Failed to create worker:', e.message); + camera.useWorker = false; + this.setupCanvas2DCapture(camera, cameraId, callback, context, deniedCallback); + return; + } + + // Handle messages from worker. + camera.worker.onmessage = (event) => { + const { type } = event.data; + + switch (type) { + case 'initialized': + GodotRuntime.print('Camera worker initialized'); + break; + + case 'frameData': { + const { pixelData, width, height, orientation, facingMode } = event.data; + const dataPtr = GodotRuntime.malloc(pixelData.length); + GodotRuntime.heapCopy(HEAPU8, pixelData, dataPtr); + + GodotCamera.sendGetPixelDataCallback( + callback, + context, + dataPtr, + pixelData.length, + width, + height, + orientation, + facingMode, + null + ); + + GodotRuntime.free(dataPtr); + break; + } + + case 'error': + GodotRuntime.print('Worker error:', event.data.message); + // Fall back to main thread on error. + camera.useWorker = false; + if (camera.worker) { + camera.worker.terminate(); + camera.worker = null; + } + GodotCamera.setupCanvas2DCapture(camera, cameraId, callback, context, deniedCallback); + break; + + case 'stopped': + GodotRuntime.print('Camera worker stopped'); + break; + + default: + break; + } + }; + + camera.worker.onerror = (error) => { + GodotRuntime.print('Worker error event:', error.message); + camera.useWorker = false; + if (camera.worker) { + camera.worker.terminate(); + camera.worker = null; + } + GodotCamera.setupCanvas2DCapture(camera, cameraId, callback, context, deniedCallback); + }; + + // Initialize the worker. + camera.worker.postMessage({ + type: 'init', + data: { width: _width, height: _height }, + }); + + // Start the frame capture loop. + const captureFrame = () => { + const cameras = GodotCamera.ensureCamerasMap(); + const currentCamera = cameras.get(cameraId); + if (!currentCamera || !currentCamera.useWorker || !currentCamera.worker) { + return; + } + + const { video, stream } = currentCamera; + + if (!stream || !stream.active) { + GodotRuntime.print('Stream is not active, stopping'); + GodotCamera.api.stop(cameraId); + return; + } + + if (video.readyState === video.HAVE_ENOUGH_DATA) { + try { + // Create ImageBitmap from video (can be transferred to worker). + createImageBitmap(video).then((imageBitmap) => { + const cam = cameras.get(cameraId); + if (!cam || !cam.useWorker || !cam.worker) { + imageBitmap.close(); + return; + } + + const screenOrientation = screen?.orientation?.angle ?? window.orientation ?? 0; + const facingMode = GodotCamera.getFacingMode(cam.stream); + + cam.worker.postMessage( + { + type: 'frame', + data: { + imageBitmap, + orientation: screenOrientation, + facingMode, + }, + }, + [imageBitmap] + ); + }).catch((e) => { + GodotRuntime.print('createImageBitmap error:', e.message); + }); + } catch (error) { + GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, 0, 0, error.message); + + if (error.name === 'SecurityError' || error.name === 'NotAllowedError') { + GodotRuntime.print('Security error, stopping stream:', error); + GodotCamera.api.stop(cameraId); + deniedCallback(context); + } + return; + } + } + + currentCamera.animationFrameId = requestAnimationFrame(captureFrame); + }; + + camera.animationFrameId = requestAnimationFrame(captureFrame); + }, + /** * Cleans up resources for a specific camera. * @param {CameraResource} camera Camera resource to cleanup * @returns {void} */ - cleanupCamera: function (camera) { + cleanupCamera: function (camera, cameraId) { + // Clean up Web Worker resources. + if (camera.worker) { + camera.worker.postMessage({ type: 'stop' }); + camera.worker.terminate(); + camera.worker = null; + } + + // Clean up WebCodecs resources. + if (camera.frameReader) { + camera.frameReader.cancel().catch(() => {}); + camera.frameReader = null; + } + if (camera.trackProcessor) { + camera.trackProcessor = null; + } + if (camera.animationFrameId) { cancelAnimationFrame(camera.animationFrameId); } @@ -218,6 +981,9 @@ const GodotCamera = { camera.stream = null; camera.video = null; camera.canvas = null; + camera.useWebCodecsWorker = false; + camera.useWebCodecs = false; + camera.useWorker = false; }, api: { @@ -302,12 +1068,18 @@ const GodotCamera = { animationFrameId: null, permissionListener: null, permissionStatus: null, + trackProcessor: null, + frameReader: null, + worker: null, + useWebCodecsWorker: false, + useWebCodecs: false, + useWorker: false, }; camerasMap.set(cameraId, camera); } - let _height, _width; if (!camera.stream) { + // Create video element (needed for Canvas 2D fallback). camera.video = document.createElement('video'); camera.video.style.display = 'none'; camera.video.autoplay = true; @@ -325,14 +1097,6 @@ const GodotCamera = { camera.stream = await navigator.mediaDevices.getUserMedia(constraints); const [videoTrack] = camera.stream.getVideoTracks(); - ({ width: _width, height: _height } = videoTrack.getSettings()); - if (typeof OffscreenCanvas !== 'undefined') { - camera.canvas = new OffscreenCanvas(_width, _height); - } else { - camera.canvas = document.createElement('canvas'); - camera.canvas.style.display = 'none'; - document.body.appendChild(camera.canvas); - } videoTrack.addEventListener('ended', () => { GodotRuntime.print('Camera track ended, stopping stream'); GodotCamera.api.stop(cameraId); @@ -363,79 +1127,32 @@ const GodotCamera = { camera.video.srcObject = camera.stream; await camera.video.play(); - } else { - // Get actual dimensions from existing stream. - const [videoTrack] = camera.stream.getVideoTracks(); - ({ width: _width, height: _height } = videoTrack.getSettings()); - } - - if (camera.canvas.width !== _width || camera.canvas.height !== _height) { - camera.canvas.width = _width; - camera.canvas.height = _height; - } - camera.canvasContext = camera.canvas.getContext('2d', { willReadFrequently: true }); - - if (camera.animationFrameId) { - cancelAnimationFrame(camera.animationFrameId); - } - - const captureFrame = () => { - const cameras = GodotCamera.ensureCamerasMap(); - const currentCamera = cameras.get(cameraId); - if (!currentCamera) { - return; - } - const { video, canvasContext, stream } = currentCamera; - - if (!stream || !stream.active) { - GodotRuntime.print('Stream is not active, stopping'); - GodotCamera.api.stop(cameraId); - return; - } - - if (video.readyState === video.HAVE_ENOUGH_DATA) { - try { - canvasContext.drawImage(video, 0, 0, _width, _height); - const imageData = canvasContext.getImageData(0, 0, _width, _height); - const pixelData = imageData.data; - - const dataPtr = GodotRuntime.malloc(pixelData.length); - GodotRuntime.heapCopy(HEAPU8, pixelData, dataPtr); - - // Get screen orientation - - const screenOrientation = screen?.orientation?.angle ?? window.orientation ?? 0; - const facingMode = GodotCamera.getFacingMode(stream); - - GodotCamera.sendGetPixelDataCallback( - callback, - context, - dataPtr, - pixelData.length, - _width, - _height, - screenOrientation, - facingMode, - null); - - GodotRuntime.free(dataPtr); - } catch (error) { - GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, 0, 0, error.message); - - if (error.name === 'SecurityError' || error.name === 'NotAllowedError') { - GodotRuntime.print('Security error, stopping stream:', error); - GodotCamera.api.stop(cameraId); - deniedCallback(context); - } - return; - } + // Choose capture method (ordered by efficiency): + // 1. WebCodecs + Worker: VideoFrame transferred to Worker, copyTo() in Worker + // 2. Worker Canvas 2D: ImageBitmap transferred to Worker, drawImage + getImageData in Worker + // 3. WebCodecs (main thread): copyTo() on main thread + // 4. Canvas 2D (main thread): drawImage + getImageData on main thread + if (GodotCamera.isWebCodecsSupported() && GodotCamera.isWorkerSupported()) { + // eslint-disable-next-line require-atomic-updates + camera.useWebCodecsWorker = true; + GodotRuntime.print('Using WebCodecs + Worker for camera capture'); + GodotCamera.setupWebCodecsWorkerCapture(camera, cameraId, callback, context, deniedCallback); + } else if (GodotCamera.isWorkerSupported()) { + // eslint-disable-next-line require-atomic-updates + camera.useWorker = true; + GodotRuntime.print('Using Worker Canvas 2D for camera capture'); + GodotCamera.setupCanvas2DWorkerCapture(camera, cameraId, callback, context, deniedCallback); + } else if (GodotCamera.isWebCodecsSupported()) { + // eslint-disable-next-line require-atomic-updates + camera.useWebCodecs = true; + GodotRuntime.print('Using WebCodecs (main thread) for camera capture'); + GodotCamera.setupWebCodecsCapture(camera, cameraId, callback, context, deniedCallback); + } else { + GodotRuntime.print('Using Canvas 2D (main thread) for camera capture'); + GodotCamera.setupCanvas2DCapture(camera, cameraId, callback, context, deniedCallback); } - - currentCamera.animationFrameId = requestAnimationFrame(captureFrame); - }; - - camera.animationFrameId = requestAnimationFrame(captureFrame); + } } catch (error) { GodotCamera.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, 0, 0, error.message); if (error && (error.name === 'SecurityError' || error.name === 'NotAllowedError')) { @@ -455,13 +1172,13 @@ const GodotCamera = { if (deviceId && cameras.has(deviceId)) { const camera = cameras.get(deviceId); if (camera) { - GodotCamera.cleanupCamera(camera); + GodotCamera.cleanupCamera(camera, deviceId); } cameras.delete(deviceId); } else { - cameras.forEach((camera) => { + cameras.forEach((camera, id) => { if (camera) { - GodotCamera.cleanupCamera(camera); + GodotCamera.cleanupCamera(camera, id); } }); cameras.clear();