diff --git a/doc/classes/CameraFeed.xml b/doc/classes/CameraFeed.xml
index 00aea9a195c9..50a9454aa8d2 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 757c09448c5b..09e16ad81195 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..e9ced9931bcb
--- /dev/null
+++ b/modules/camera/camera_web.cpp
@@ -0,0 +1,188 @@
+/**************************************************************************/
+/* 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.");
+
+ // 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.
+ 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;
+}
+
+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) {
+ 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;
+
+ FeedFormat feed_format;
+ feed_format.width = info.capability.width;
+ feed_format.height = info.capability.height;
+ feed_format.format = String("RGBA");
+ formats.append(feed_format);
+}
+
+CameraFeedWeb::~CameraFeedWeb() {
+ if (is_active()) {
+ deactivate_feed();
+ }
+}
+
+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]);
+ }
+ for (int i = 0; i < camera_info.size(); i++) {
+ CameraInfo info = camera_info[i];
+ Ref feed = memnew(CameraFeedWeb(info));
+ 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() {
+ 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 || activating) {
+ return;
+ }
+
+ 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();
+ }
+}
+
+CameraWeb::~CameraWeb() {
+ _cleanup();
+}
diff --git a/modules/camera/camera_web.h b/modules/camera/camera_web.h
new file mode 100644
index 000000000000..27490536728e
--- /dev/null
+++ b/modules/camera/camera_web.h
@@ -0,0 +1,76 @@
+/**************************************************************************/
+/* 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"
+
+#include
+
+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);
+
+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;
+ std::atomic activating;
+ void _cleanup();
+ void _update_feeds();
+ static void _on_get_cameras_callback(void *context, const Vector &camera_info);
+
+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 b543dc74fa2e..dc7ab0ea5028 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"):
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 14f52dca3373..505df4373fd1 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",
@@ -39,6 +40,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_fetch.js",
"js/libs/library_godot_webmidi.js",
diff --git a/platform/web/camera_driver_web.cpp b/platform/web/camera_driver_web.cpp
new file mode 100644
index 000000000000..81fd09b13384
--- /dev/null
+++ b/platform/web/camera_driver_web.cpp
@@ -0,0 +1,167 @@
+/**************************************************************************/
+/* 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
+
+CameraDriverWeb *CameraDriverWeb::singleton = nullptr;
+Array CameraDriverWeb::_camera_info_key;
+
+CameraDriverWeb *CameraDriverWeb::get_singleton() {
+ 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;
+}
+
+// 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;
+ }
+ String json_string = String::utf8(json_ptr);
+ Variant json_variant = JSON::parse_string(json_string);
+
+ 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;
+ }
+
+ 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;
+ 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;
+ }
+
+ 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) {
+ 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;
+ 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;
+ }
+
+ 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);
+ }
+
+ CameraDriverWeb_OnGetCamerasCallback 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_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() {
+ ERR_FAIL_COND_MSG(singleton != nullptr, "CameraDriverWeb singleton already exists.");
+ 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..f8c7045f3fa2
--- /dev/null
+++ b/platform/web/camera_driver_web.h
@@ -0,0 +1,78 @@
+/**************************************************************************/
+/* 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 CapabilityInfo {
+ int width;
+ int height;
+};
+
+struct CameraInfo {
+ int index;
+ String device_id;
+ String label;
+ CapabilityInfo capability;
+};
+
+using CameraDriverWeb_OnGetCamerasCallback = void (*)(void *context, const Vector &camera_info);
+
+class CameraDriverWeb {
+private:
+ static CameraDriverWeb *singleton;
+ static Array _camera_info_key;
+ 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(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());
+
+ CameraDriverWeb();
+ ~CameraDriverWeb();
+};
diff --git a/platform/web/godot_camera.h b/platform/web/godot_camera.h
new file mode 100644
index 000000000000..cb2ce5248b8a
--- /dev/null
+++ b/platform/web/godot_camera.h
@@ -0,0 +1,63 @@
+/**************************************************************************/
+/* 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 *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);
+
+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..6181e200e197
--- /dev/null
+++ b/platform/web/js/libs/library_godot_camera.js
@@ -0,0 +1,413 @@
+/**************************************************************************/
+/* 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|null
+ * canvas: (HTMLCanvasElement|OffscreenCanvas)|null
+ * canvasContext: (CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D)|null
+ * stream: MediaStream|null
+ * animationFrameId: number|null
+ * permissionListener: Function|null
+ * permissionStatus: PermissionStatus|null
+ * }} 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(),
+ defaultMinimumCapabilities: {
+ 'width': {
+ 'max': 1280,
+ },
+ 'height': {
+ 'max': 1080,
+ },
+ },
+
+ /**
+ * 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} 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}
+ */
+ sendCamerasCallbackResult: function (callback, callbackPtr, context, result) {
+ const jsonStr = JSON.stringify(result);
+ const strPtr = GodotRuntime.allocString(jsonStr);
+ callback(context, callbackPtr, 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|null} errorMsg Error message if any
+ * @returns {void}
+ */
+ 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) {
+ 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} callbackPtr1 Pointer to callback function
+ * @param {number} callbackPtr2 Pointer to callback function
+ * @returns {Promise}
+ */
+ 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 });
+ 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')
+ .map((device, index) => ({
+ index,
+ id: device.deviceId,
+ label: device.label || `Camera ${index}`,
+ capabilities: getCapabilities(device.deviceId),
+ }));
+
+ stream.getTracks().forEach((track) => track.stop());
+ GodotCamera.api.stop();
+ } catch (error) {
+ result.error = error.message;
+ }
+
+ 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|null} 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);
+ }
+
+ let _height, _width;
+ 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);
+
+ 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();
+ ({ 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 {
+ 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);
+ }
+ GodotCamera.api.stop(deviceId);
+ deniedCallback(context);
+ }
+ };
+ 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
+ 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;
+ }
+ 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.sendGetPixelDataCallback(
+ callback,
+ context,
+ dataPtr,
+ pixelData.length,
+ _width,
+ _height,
+ null
+ );
+
+ GodotRuntime.free(dataPtr);
+ } catch (error) {
+ GodotCamera.sendGetPixelDataCallback(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.sendGetPixelDataCallback(callback, context, 0, 0, 0, 0, error.message);
+ }
+ },
+
+ /**
+ * Stops camera stream(s).
+ * @param {string|null} 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 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
+ * @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 ? 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 ? GodotRuntime.parseString(deviceIdPtr) : undefined;
+ GodotCamera.api.stop(deviceId);
+ },
+};
+
+autoAddDeps(GodotCamera, '$GodotCamera');
+mergeInto(LibraryManager.library, GodotCamera);