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);