diff options
author | Rémi Verschelde <rverschelde@gmail.com> | 2024-01-18 09:33:52 +0100 |
---|---|---|
committer | Rémi Verschelde <rverschelde@gmail.com> | 2024-01-18 09:33:52 +0100 |
commit | fa81059b9d5c763c4775708d4832bf8bfc1dda80 (patch) | |
tree | 8acc89e6e12558fe003a07fe4acd02a9bba7b3e3 /platform | |
parent | b5dcb5f58aec77f8e282ff134f279c7bc7467e8e (diff) | |
parent | bd70b8e1f643dbf7252be9bc367e0de0f82d722d (diff) | |
download | redot-engine-fa81059b9d5c763c4775708d4832bf8bfc1dda80.tar.gz |
Merge pull request #85939 from adamscott/single-threaded-godot-4
Add `THREADS_ENABLED` macro in order to compile Godot to run on the main thread
Diffstat (limited to 'platform')
-rw-r--r-- | platform/web/.eslintrc.html.js | 2 | ||||
-rw-r--r-- | platform/web/SCsub | 4 | ||||
-rw-r--r-- | platform/web/audio_driver_web.cpp | 52 | ||||
-rw-r--r-- | platform/web/audio_driver_web.h | 51 | ||||
-rw-r--r-- | platform/web/detect.py | 24 | ||||
-rw-r--r-- | platform/web/doc_classes/EditorExportPlatformWeb.xml | 4 | ||||
-rw-r--r-- | platform/web/emscripten_helpers.py | 41 | ||||
-rw-r--r-- | platform/web/export/export_plugin.cpp | 26 | ||||
-rw-r--r-- | platform/web/export/export_plugin.h | 5 | ||||
-rw-r--r-- | platform/web/js/engine/features.js | 22 | ||||
-rw-r--r-- | platform/web/js/libs/audio.worklet.js | 4 |
11 files changed, 195 insertions, 40 deletions
diff --git a/platform/web/.eslintrc.html.js b/platform/web/.eslintrc.html.js index 5cb8de360a..8c9a3d83da 100644 --- a/platform/web/.eslintrc.html.js +++ b/platform/web/.eslintrc.html.js @@ -15,5 +15,7 @@ module.exports = { "Godot": true, "Engine": true, "$GODOT_CONFIG": true, + "$GODOT_THREADS_ENABLED": true, + "___GODOT_THREADS_ENABLED___": true, }, }; diff --git a/platform/web/SCsub b/platform/web/SCsub index 1af0642554..3e0cc9ac4a 100644 --- a/platform/web/SCsub +++ b/platform/web/SCsub @@ -13,7 +13,7 @@ if "serve" in COMMAND_LINE_TARGETS or "run" in COMMAND_LINE_TARGETS: except Exception: print("GODOT_WEB_TEST_PORT must be a valid integer") sys.exit(255) - serve(env.Dir("#bin/.web_zip").abspath, port, "run" in COMMAND_LINE_TARGETS) + serve(env.Dir(env.GetTemplateZipPath()).abspath, port, "run" in COMMAND_LINE_TARGETS) sys.exit(0) web_files = [ @@ -95,7 +95,7 @@ engine = [ "js/engine/engine.js", ] externs = [env.File("#platform/web/js/engine/engine.externs.js")] -js_engine = env.CreateEngineFile("#bin/godot${PROGSUFFIX}.engine.js", engine, externs) +js_engine = env.CreateEngineFile("#bin/godot${PROGSUFFIX}.engine.js", engine, externs, env["threads"]) env.Depends(js_engine, externs) wrap_list = [ diff --git a/platform/web/audio_driver_web.cpp b/platform/web/audio_driver_web.cpp index 1298d28ebf..ec3c22bf7c 100644 --- a/platform/web/audio_driver_web.cpp +++ b/platform/web/audio_driver_web.cpp @@ -30,6 +30,8 @@ #include "audio_driver_web.h" +#include "godot_audio.h" + #include "core/config/project_settings.h" #include <emscripten.h> @@ -184,6 +186,8 @@ Error AudioDriverWeb::input_stop() { return OK; } +#ifdef THREADS_ENABLED + /// AudioWorkletNode implementation (threads) void AudioDriverWorklet::_audio_thread_func(void *p_data) { AudioDriverWorklet *driver = static_cast<AudioDriverWorklet *>(p_data); @@ -245,3 +249,51 @@ void AudioDriverWorklet::finish_driver() { quit = true; // Ask thread to quit. thread.wait_to_finish(); } + +#else // No threads. + +/// AudioWorkletNode implementation (no threads) +AudioDriverWorklet *AudioDriverWorklet::singleton = nullptr; + +Error AudioDriverWorklet::create(int &p_buffer_size, int p_channels) { + if (!godot_audio_has_worklet()) { + return ERR_UNAVAILABLE; + } + return (Error)godot_audio_worklet_create(p_channels); +} + +void AudioDriverWorklet::start(float *p_out_buf, int p_out_buf_size, float *p_in_buf, int p_in_buf_size) { + _audio_driver_process(); + godot_audio_worklet_start_no_threads(p_out_buf, p_out_buf_size, &_process_callback, p_in_buf, p_in_buf_size, &_capture_callback); +} + +void AudioDriverWorklet::_process_callback(int p_pos, int p_samples) { + AudioDriverWorklet *driver = AudioDriverWorklet::get_singleton(); + driver->_audio_driver_process(p_pos, p_samples); +} + +void AudioDriverWorklet::_capture_callback(int p_pos, int p_samples) { + AudioDriverWorklet *driver = AudioDriverWorklet::get_singleton(); + driver->_audio_driver_capture(p_pos, p_samples); +} + +/// ScriptProcessorNode implementation +AudioDriverScriptProcessor *AudioDriverScriptProcessor::singleton = nullptr; + +void AudioDriverScriptProcessor::_process_callback() { + AudioDriverScriptProcessor::get_singleton()->_audio_driver_capture(); + AudioDriverScriptProcessor::get_singleton()->_audio_driver_process(); +} + +Error AudioDriverScriptProcessor::create(int &p_buffer_samples, int p_channels) { + if (!godot_audio_has_script_processor()) { + return ERR_UNAVAILABLE; + } + return (Error)godot_audio_script_create(&p_buffer_samples, p_channels); +} + +void AudioDriverScriptProcessor::start(float *p_out_buf, int p_out_buf_size, float *p_in_buf, int p_in_buf_size) { + godot_audio_script_start(p_in_buf, p_in_buf_size, p_out_buf, p_out_buf_size, &_process_callback); +} + +#endif // THREADS_ENABLED diff --git a/platform/web/audio_driver_web.h b/platform/web/audio_driver_web.h index 12a61746c3..df88d0a94c 100644 --- a/platform/web/audio_driver_web.h +++ b/platform/web/audio_driver_web.h @@ -90,6 +90,7 @@ public: AudioDriverWeb() {} }; +#ifdef THREADS_ENABLED class AudioDriverWorklet : public AudioDriverWeb { private: enum { @@ -120,4 +121,54 @@ public: virtual void unlock() override; }; +#else + +class AudioDriverWorklet : public AudioDriverWeb { +private: + static void _process_callback(int p_pos, int p_samples); + static void _capture_callback(int p_pos, int p_samples); + + static AudioDriverWorklet *singleton; + +protected: + virtual Error create(int &p_buffer_size, int p_output_channels) override; + virtual void start(float *p_out_buf, int p_out_buf_size, float *p_in_buf, int p_in_buf_size) override; + +public: + virtual const char *get_name() const override { + return "AudioWorklet"; + } + + virtual void lock() override {} + virtual void unlock() override {} + + static AudioDriverWorklet *get_singleton() { return singleton; } + + AudioDriverWorklet() { singleton = this; } +}; + +class AudioDriverScriptProcessor : public AudioDriverWeb { +private: + static void _process_callback(); + + static AudioDriverScriptProcessor *singleton; + +protected: + virtual Error create(int &p_buffer_size, int p_output_channels) override; + virtual void start(float *p_out_buf, int p_out_buf_size, float *p_in_buf, int p_in_buf_size) override; + virtual void finish_driver() override; + +public: + virtual const char *get_name() const override { return "ScriptProcessor"; } + + virtual void lock() override {} + virtual void unlock() override {} + + static AudioDriverScriptProcessor *get_singleton() { return singleton; } + + AudioDriverScriptProcessor() { singleton = this; } +}; + +#endif // THREADS_ENABLED + #endif // AUDIO_DRIVER_WEB_H diff --git a/platform/web/detect.py b/platform/web/detect.py index 53fae67f6a..bce03eb79e 100644 --- a/platform/web/detect.py +++ b/platform/web/detect.py @@ -8,6 +8,7 @@ from emscripten_helpers import ( add_js_pre, add_js_externs, create_template_zip, + get_template_zip_path, ) from methods import get_compiler_version from SCons.Util import WhereIs @@ -161,6 +162,9 @@ def configure(env: "Environment"): # Add method that joins/compiles our Engine files. env.AddMethod(create_engine_file, "CreateEngineFile") + # Add method for getting the final zip path + env.AddMethod(get_template_zip_path, "GetTemplateZipPath") + # Add method for creating the final zip file env.AddMethod(create_template_zip, "CreateTemplateZip") @@ -209,13 +213,17 @@ def configure(env: "Environment"): stack_size_opt = "STACK_SIZE" if cc_semver >= (3, 1, 25) else "TOTAL_STACK" env.Append(LINKFLAGS=["-s", "%s=%sKB" % (stack_size_opt, env["stack_size"])]) - # Thread support (via SharedArrayBuffer). - env.Append(CPPDEFINES=["PTHREAD_NO_RENAME"]) - env.Append(CCFLAGS=["-s", "USE_PTHREADS=1"]) - env.Append(LINKFLAGS=["-s", "USE_PTHREADS=1"]) - env.Append(LINKFLAGS=["-s", "DEFAULT_PTHREAD_STACK_SIZE=%sKB" % env["default_pthread_stack_size"]]) - env.Append(LINKFLAGS=["-s", "PTHREAD_POOL_SIZE=8"]) - env.Append(LINKFLAGS=["-s", "WASM_MEM_MAX=2048MB"]) + if env["threads"]: + # Thread support (via SharedArrayBuffer). + env.Append(CPPDEFINES=["PTHREAD_NO_RENAME"]) + env.Append(CCFLAGS=["-s", "USE_PTHREADS=1"]) + env.Append(LINKFLAGS=["-s", "USE_PTHREADS=1"]) + env.Append(LINKFLAGS=["-s", "DEFAULT_PTHREAD_STACK_SIZE=%sKB" % env["default_pthread_stack_size"]]) + env.Append(LINKFLAGS=["-s", "PTHREAD_POOL_SIZE=8"]) + env.Append(LINKFLAGS=["-s", "WASM_MEM_MAX=2048MB"]) + elif env["proxy_to_pthread"]: + print('"threads=no" support requires "proxy_to_pthread=no", disabling proxy to pthread.') + env["proxy_to_pthread"] = False if env["lto"] != "none": # Workaround https://github.com/emscripten-core/emscripten/issues/19781. @@ -224,7 +232,7 @@ def configure(env: "Environment"): if env["dlink_enabled"]: if env["proxy_to_pthread"]: - print("GDExtension support requires proxy_to_pthread=no, disabling") + print("GDExtension support requires proxy_to_pthread=no, disabling proxy to pthread.") env["proxy_to_pthread"] = False if cc_semver < (3, 1, 14): diff --git a/platform/web/doc_classes/EditorExportPlatformWeb.xml b/platform/web/doc_classes/EditorExportPlatformWeb.xml index c4c4fd870b..f07f265b0d 100644 --- a/platform/web/doc_classes/EditorExportPlatformWeb.xml +++ b/platform/web/doc_classes/EditorExportPlatformWeb.xml @@ -47,6 +47,10 @@ </member> <member name="variant/extensions_support" type="bool" setter="" getter=""> </member> + <member name="variant/thread_support" type="bool" setter="" getter=""> + If enabled, the exported game will support threads. It requires [url=https://web.dev/articles/coop-coep]a "cross-origin isolated" website[/url], which can be difficult to setup and brings some limitations (e.g. not being able to communicate with third-party websites). + If disabled, the exported game will not support threads. As a result, it is more prone to performance and audio issues, but will only require to be run on a HTTPS website. + </member> <member name="vram_texture_compression/for_desktop" type="bool" setter="" getter=""> </member> <member name="vram_texture_compression/for_mobile" type="bool" setter="" getter=""> diff --git a/platform/web/emscripten_helpers.py b/platform/web/emscripten_helpers.py index ec33397842..3ba133c9a1 100644 --- a/platform/web/emscripten_helpers.py +++ b/platform/web/emscripten_helpers.py @@ -4,7 +4,12 @@ from SCons.Util import WhereIs def run_closure_compiler(target, source, env, for_signature): - closure_bin = os.path.join(os.path.dirname(WhereIs("emcc")), "node_modules", ".bin", "google-closure-compiler") + closure_bin = os.path.join( + os.path.dirname(WhereIs("emcc")), + "node_modules", + ".bin", + "google-closure-compiler", + ) cmd = [WhereIs("node"), closure_bin] cmd.extend(["--compilation_level", "ADVANCED_OPTIMIZATIONS"]) for f in env["JSEXTERNS"]: @@ -31,27 +36,29 @@ def get_build_version(): return v -def create_engine_file(env, target, source, externs): +def create_engine_file(env, target, source, externs, threads_enabled): if env["use_closure_compiler"]: return env.BuildJS(target, source, JSEXTERNS=externs) - return env.Textfile(target, [env.File(s) for s in source]) + subst_dict = {"___GODOT_THREADS_ENABLED": "true" if threads_enabled else "false"} + return env.Substfile(target=target, source=[env.File(s) for s in source], SUBST_DICT=subst_dict) def create_template_zip(env, js, wasm, worker, side): binary_name = "godot.editor" if env.editor_build else "godot" - zip_dir = env.Dir("#bin/.web_zip") + zip_dir = env.Dir(env.GetTemplateZipPath()) in_files = [ js, wasm, - worker, "#platform/web/js/libs/audio.worklet.js", ] out_files = [ zip_dir.File(binary_name + ".js"), zip_dir.File(binary_name + ".wasm"), - zip_dir.File(binary_name + ".worker.js"), zip_dir.File(binary_name + ".audio.worklet.js"), ] + if env["threads"]: + in_files.append(worker) + out_files.append(zip_dir.File(binary_name + ".worker.js")) # Dynamic linking (extensions) specific. if env["dlink_enabled"]: in_files.append(side) # Side wasm (contains the actual Godot code). @@ -65,18 +72,20 @@ def create_template_zip(env, js, wasm, worker, side): "godot.editor.html", "offline.html", "godot.editor.js", - "godot.editor.worker.js", "godot.editor.audio.worklet.js", "logo.svg", "favicon.png", ] + if env["threads"]: + cache.append("godot.editor.worker.js") opt_cache = ["godot.editor.wasm"] subst_dict = { - "@GODOT_VERSION@": get_build_version(), - "@GODOT_NAME@": "GodotEngine", - "@GODOT_CACHE@": json.dumps(cache), - "@GODOT_OPT_CACHE@": json.dumps(opt_cache), - "@GODOT_OFFLINE_PAGE@": "offline.html", + "___GODOT_VERSION___": get_build_version(), + "___GODOT_NAME___": "GodotEngine", + "___GODOT_CACHE___": json.dumps(cache), + "___GODOT_OPT_CACHE___": json.dumps(opt_cache), + "___GODOT_OFFLINE_PAGE___": "offline.html", + "___GODOT_THREADS_ENABLED___": "true" if env["threads"] else "false", } html = env.Substfile(target="#bin/godot${PROGSUFFIX}.html", source=html, SUBST_DICT=subst_dict) in_files.append(html) @@ -88,7 +97,9 @@ def create_template_zip(env, js, wasm, worker, side): out_files.append(zip_dir.File("favicon.png")) # PWA service_worker = env.Substfile( - target="#bin/godot${PROGSUFFIX}.service.worker.js", source=service_worker, SUBST_DICT=subst_dict + target="#bin/godot${PROGSUFFIX}.service.worker.js", + source=service_worker, + SUBST_DICT=subst_dict, ) in_files.append(service_worker) out_files.append(zip_dir.File("service.worker.js")) @@ -115,6 +126,10 @@ def create_template_zip(env, js, wasm, worker, side): ) +def get_template_zip_path(env): + return "#bin/.web_zip" + + def add_js_libraries(env, libraries): env.Append(JS_LIBS=env.File(libraries)) diff --git a/platform/web/export/export_plugin.cpp b/platform/web/export/export_plugin.cpp index 9ffb776c29..d7e72612b4 100644 --- a/platform/web/export/export_plugin.cpp +++ b/platform/web/export/export_plugin.cpp @@ -169,6 +169,13 @@ void EditorExportPlatformWeb::_fix_html(Vector<uint8_t> &p_html, const Ref<Edito replaces["$GODOT_PROJECT_NAME"] = GLOBAL_GET("application/config/name"); replaces["$GODOT_HEAD_INCLUDE"] = head_include + custom_head_include; replaces["$GODOT_CONFIG"] = str_config; + + if (p_preset->get("variant/thread_support")) { + replaces["$GODOT_THREADS_ENABLED"] = "true"; + } else { + replaces["$GODOT_THREADS_ENABLED"] = "false"; + } + _replace_strings(replaces, p_html); } @@ -216,9 +223,9 @@ Error EditorExportPlatformWeb::_build_pwa(const Ref<EditorExportPreset> &p_prese const String name = p_path.get_file().get_basename(); bool extensions = (bool)p_preset->get("variant/extensions_support"); HashMap<String, String> replaces; - replaces["@GODOT_VERSION@"] = String::num_int64(OS::get_singleton()->get_unix_time()) + "|" + String::num_int64(OS::get_singleton()->get_ticks_usec()); - replaces["@GODOT_NAME@"] = proj_name.substr(0, 16); - replaces["@GODOT_OFFLINE_PAGE@"] = name + ".offline.html"; + replaces["___GODOT_VERSION___"] = String::num_int64(OS::get_singleton()->get_unix_time()) + "|" + String::num_int64(OS::get_singleton()->get_ticks_usec()); + replaces["___GODOT_NAME___"] = proj_name.substr(0, 16); + replaces["___GODOT_OFFLINE_PAGE___"] = name + ".offline.html"; // Files cached during worker install. Array cache_files; @@ -231,7 +238,7 @@ Error EditorExportPlatformWeb::_build_pwa(const Ref<EditorExportPreset> &p_prese } cache_files.push_back(name + ".worker.js"); cache_files.push_back(name + ".audio.worklet.js"); - replaces["@GODOT_CACHE@"] = Variant(cache_files).to_json_string(); + replaces["___GODOT_CACHE___"] = Variant(cache_files).to_json_string(); // Heavy files that are cached on demand. Array opt_cache_files; @@ -243,7 +250,7 @@ Error EditorExportPlatformWeb::_build_pwa(const Ref<EditorExportPreset> &p_prese opt_cache_files.push_back(p_shared_objects[i].path.get_file()); } } - replaces["@GODOT_OPT_CACHE@"] = Variant(opt_cache_files).to_json_string(); + replaces["___GODOT_OPT_CACHE___"] = Variant(opt_cache_files).to_json_string(); const String sw_path = dir.path_join(name + ".service.worker.js"); Vector<uint8_t> sw; @@ -335,6 +342,7 @@ void EditorExportPlatformWeb::get_export_options(List<ExportOption> *r_options) r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "custom_template/release", PROPERTY_HINT_GLOBAL_FILE, "*.zip"), "")); r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "variant/extensions_support"), false)); // Export type. + r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "variant/thread_support"), true)); // Thread support (i.e. run with or without COEP/COOP headers). r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "vram_texture_compression/for_desktop"), true)); // S3TC r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "vram_texture_compression/for_mobile"), false)); // ETC or ETC2, depending on renderer @@ -377,10 +385,11 @@ bool EditorExportPlatformWeb::has_valid_export_configuration(const Ref<EditorExp String err; bool valid = false; bool extensions = (bool)p_preset->get("variant/extensions_support"); + bool thread_support = (bool)p_preset->get("variant/thread_support"); // Look for export templates (first official, and if defined custom templates). - bool dvalid = exists_export_template(_get_template_name(extensions, true), &err); - bool rvalid = exists_export_template(_get_template_name(extensions, false), &err); + bool dvalid = exists_export_template(_get_template_name(extensions, thread_support, true), &err); + bool rvalid = exists_export_template(_get_template_name(extensions, thread_support, false), &err); if (p_preset->get("custom_template/debug") != "") { dvalid = FileAccess::exists(p_preset->get("custom_template/debug")); @@ -454,7 +463,8 @@ Error EditorExportPlatformWeb::export_project(const Ref<EditorExportPreset> &p_p template_path = template_path.strip_edges(); if (template_path.is_empty()) { bool extensions = (bool)p_preset->get("variant/extensions_support"); - template_path = find_export_template(_get_template_name(extensions, p_debug)); + bool thread_support = (bool)p_preset->get("variant/thread_support"); + template_path = find_export_template(_get_template_name(extensions, thread_support, p_debug)); } if (!template_path.is_empty() && !FileAccess::exists(template_path)) { diff --git a/platform/web/export/export_plugin.h b/platform/web/export/export_plugin.h index 9bb82d472e..98e3fe729e 100644 --- a/platform/web/export/export_plugin.h +++ b/platform/web/export/export_plugin.h @@ -56,11 +56,14 @@ class EditorExportPlatformWeb : public EditorExportPlatform { Mutex server_lock; Thread server_thread; - String _get_template_name(bool p_extension, bool p_debug) const { + String _get_template_name(bool p_extension, bool p_thread_support, bool p_debug) const { String name = "web"; if (p_extension) { name += "_dlink"; } + if (!p_thread_support) { + name += "_nothreads"; + } if (p_debug) { name += "_debug.zip"; } else { diff --git a/platform/web/js/engine/features.js b/platform/web/js/engine/features.js index b7c6c9d445..81bc82f3c6 100644 --- a/platform/web/js/engine/features.js +++ b/platform/web/js/engine/features.js @@ -72,8 +72,14 @@ const Features = { // eslint-disable-line no-unused-vars * * @returns {Array<string>} A list of human-readable missing features. * @function Engine.getMissingFeatures + * @typedef {{ threads: boolean }} SupportedFeatures + * @param {SupportedFeatures} supportedFeatures */ - getMissingFeatures: function () { + getMissingFeatures: function (supportedFeatures = {}) { + const { + threads: supportsThreads = true, + } = supportedFeatures; + const missing = []; if (!Features.isWebGLAvailable(2)) { missing.push('WebGL2 - Check web browser configuration and hardware support'); @@ -84,12 +90,16 @@ const Features = { // eslint-disable-line no-unused-vars if (!Features.isSecureContext()) { missing.push('Secure Context - Check web server configuration (use HTTPS)'); } - if (!Features.isCrossOriginIsolated()) { - missing.push('Cross Origin Isolation - Check web server configuration (send correct headers)'); - } - if (!Features.isSharedArrayBufferAvailable()) { - missing.push('SharedArrayBuffer - Check web server configuration (send correct headers)'); + + if (supportsThreads) { + if (!Features.isCrossOriginIsolated()) { + missing.push('Cross-Origin Isolation - Check that the web server configuration sends the correct headers.'); + } + if (!Features.isSharedArrayBufferAvailable()) { + missing.push('SharedArrayBuffer - Check that the web server configuration sends the correct headers.'); + } } + // Audio is normally optional since we have a dummy fallback. return missing; }, diff --git a/platform/web/js/libs/audio.worklet.js b/platform/web/js/libs/audio.worklet.js index 89b581b3d6..3b94cab85c 100644 --- a/platform/web/js/libs/audio.worklet.js +++ b/platform/web/js/libs/audio.worklet.js @@ -167,7 +167,7 @@ class GodotProcessor extends AudioWorkletProcessor { GodotProcessor.write_input(this.input_buffer, input); this.input.write(this.input_buffer); } else { - this.port.postMessage('Input buffer is full! Skipping input frame.'); + // this.port.postMessage('Input buffer is full! Skipping input frame.'); // Uncomment this line to debug input buffer. } } const process_output = GodotProcessor.array_has_data(outputs); @@ -184,7 +184,7 @@ class GodotProcessor extends AudioWorkletProcessor { this.port.postMessage({ 'cmd': 'read', 'data': chunk }); } } else { - this.port.postMessage('Output buffer has not enough frames! Skipping output frame.'); + // this.port.postMessage('Output buffer has not enough frames! Skipping output frame.'); // Uncomment this line to debug output buffer. } } this.process_notify(); |