From 2bbee73425002588c43dd9ef091e86fdc39a391e Mon Sep 17 00:00:00 2001 From: bitheaven Date: Sat, 18 Jul 2026 08:18:50 +0500 Subject: [PATCH] gpu accelerated --- .gitignore | 4 + CMakeLists.txt | 86 +++++ LICENSE | 15 + README.md | 53 +++ data/locale/en-US.ini | 9 + data/locale/ru-RU.ini | 9 + scripts/install-deps-alpine.sh | 21 ++ src/plugin.c | 607 +++++++++++++++++++++++++++++++ src/protocol.h | 73 ++++ src/renderer.c | 645 +++++++++++++++++++++++++++++++++ tests/module-load-test.c | 70 ++++ tests/protocol-test.c | 14 + tests/renderer-fps-test.c | 177 +++++++++ tests/renderer-smoke-test.c | 192 ++++++++++ 14 files changed, 1975 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 LICENSE create mode 100644 README.md create mode 100644 data/locale/en-US.ini create mode 100644 data/locale/ru-RU.ini create mode 100755 scripts/install-deps-alpine.sh create mode 100644 src/plugin.c create mode 100644 src/protocol.h create mode 100644 src/renderer.c create mode 100644 tests/module-load-test.c create mode 100644 tests/protocol-test.c create mode 100644 tests/renderer-fps-test.c create mode 100644 tests/renderer-smoke-test.c diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f1e1f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/build/ +*.o +*.so +*.swp diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..760b599 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,86 @@ +cmake_minimum_required(VERSION 3.20) + +project(obs-webkit-browser VERSION 0.1.0 LANGUAGES C) + +include(GNUInstallDirs) +find_package(PkgConfig REQUIRED) +find_package(Threads REQUIRED) + +pkg_check_modules(OBS REQUIRED IMPORTED_TARGET libobs) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(WEBKIT REQUIRED IMPORTED_TARGET webkit2gtk-4.1) +pkg_check_modules(X11_CAPTURE REQUIRED IMPORTED_TARGET x11 xcomposite xext) + +add_library(obs-webkit-browser MODULE + src/plugin.c + src/protocol.h +) +target_compile_features(obs-webkit-browser PRIVATE c_std_11) +target_compile_definitions(obs-webkit-browser PRIVATE _GNU_SOURCE) +target_compile_options(obs-webkit-browser PRIVATE -Wall -Wextra -Wpedantic) +target_link_libraries(obs-webkit-browser PRIVATE PkgConfig::OBS Threads::Threads) +set_target_properties(obs-webkit-browser PROPERTIES PREFIX "") + +add_executable(obs-webkit-renderer + src/renderer.c + src/protocol.h +) +target_compile_features(obs-webkit-renderer PRIVATE c_std_11) +target_compile_definitions(obs-webkit-renderer PRIVATE _GNU_SOURCE) +target_compile_options(obs-webkit-renderer PRIVATE -Wall -Wextra -Wpedantic) +target_link_libraries(obs-webkit-renderer PRIVATE + PkgConfig::GLIB + PkgConfig::GTK + PkgConfig::WEBKIT + PkgConfig::X11_CAPTURE + Threads::Threads +) + +set(OBS_PLUGIN_DIR "${CMAKE_INSTALL_LIBDIR}/obs-plugins" CACHE PATH + "Directory for the OBS plugin module") +set(OBS_PLUGIN_DATA_DIR "${CMAKE_INSTALL_DATADIR}/obs/obs-plugins/obs-webkit-browser" + CACHE PATH "Directory for plugin data and the renderer helper") + +install(TARGETS obs-webkit-browser + LIBRARY DESTINATION "${OBS_PLUGIN_DIR}") +install(TARGETS obs-webkit-renderer + RUNTIME DESTINATION "${OBS_PLUGIN_DATA_DIR}") +install(FILES data/locale/en-US.ini data/locale/ru-RU.ini + DESTINATION "${OBS_PLUGIN_DATA_DIR}/locale") + +include(CTest) +if(BUILD_TESTING) + add_executable(protocol-test tests/protocol-test.c) + target_compile_features(protocol-test PRIVATE c_std_11) + target_include_directories(protocol-test PRIVATE src) + add_test(NAME protocol-test COMMAND protocol-test) + + add_executable(renderer-smoke-test tests/renderer-smoke-test.c) + target_compile_features(renderer-smoke-test PRIVATE c_std_11) + target_compile_definitions(renderer-smoke-test PRIVATE _GNU_SOURCE) + target_include_directories(renderer-smoke-test PRIVATE src) + add_test(NAME renderer-smoke-test + COMMAND renderer-smoke-test $) + set_tests_properties(renderer-smoke-test PROPERTIES TIMEOUT 20) + + add_test(NAME renderer-opaque-smoke-test + COMMAND renderer-smoke-test $ opaque) + set_tests_properties(renderer-opaque-smoke-test PROPERTIES TIMEOUT 20) + + add_executable(renderer-fps-test tests/renderer-fps-test.c) + target_compile_features(renderer-fps-test PRIVATE c_std_11) + target_compile_definitions(renderer-fps-test PRIVATE _GNU_SOURCE) + target_include_directories(renderer-fps-test PRIVATE src) + add_test(NAME blur-transition-fps-test + COMMAND renderer-fps-test $) + set_tests_properties(blur-transition-fps-test PROPERTIES TIMEOUT 20) + + add_executable(module-load-test tests/module-load-test.c) + target_compile_features(module-load-test PRIVATE c_std_11) + target_link_libraries(module-load-test PRIVATE PkgConfig::OBS) + add_test(NAME module-load-test COMMAND module-load-test + $ "${CMAKE_CURRENT_SOURCE_DIR}/data" + $) + set_tests_properties(module-load-test PROPERTIES TIMEOUT 20) +endif() diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f61fc50 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +SPDX-License-Identifier: GPL-2.0-or-later + +Copyright (C) 2026 obs-webkit-browser contributors + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, either version 2 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..e67a922 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# OBS WebKit Browser Source (без CEF) + +Нативный input source для OBS Studio на Alpine Linux. Страницы рендерит системный +WebKitGTK 4.1 в отдельном процессе на приватном Xvfb-дисплее; Chromium/CEF в проекте +нет. Кадры передаются в OBS напрямую как BGRA, без FFmpeg и захвата рабочего стола. + +Поддерживается: + +- HTTP/HTTPS, `data:` URL и локальные HTML-файлы; +- прозрачный фон с alpha-каналом; +- пользовательский CSS; +- размер 2–8192 px и 1–60 FPS; +- аппаратный WebKit compositor через DRM/DMA-BUF (Mesa `radeonsi` на AMD), + при этом Xvfb используется только как изолированный невидимый дисплей; +- прямой XShm-захват итогового кадра Xvfb для непрозрачных страниц и + XComposite/XShm-захват с alpha для прозрачных; +- shared-memory транспорт кадров без многомегабайтной записи в pipe; +- мышь, прокрутка, фокус и базовый ввод с клавиатуры через окно Interact OBS; +- перезагрузка страницы и остановка renderer-процесса у скрытого source. + +Звук страницы намеренно выключен: добавьте в OBS отдельный Application Audio Capture. +Renderer всегда использует отдельный приватный Xvfb-дисплей и не создаёт окон на +рабочем столе. WebKit принудительно включает аппаратный compositor и выбирает доступный +DRM render node; для него нужен доступ к `/dev/dri/renderD*`. +Для тяжёлого полноэкранного `filter: blur()` всё ещё разумно выбирать реальный размер +виджета вместо рендера 4K/1080p с последующим уменьшением в сцене. Пользовательские +`will-change` и compositor hints сохраняются, но renderer не внедряет их в страницу сам: +WebKit сам распределяет элементы по GPU-слоям. + +Это рабочий backend, а не замена всех расширенных возможностей штатного obs-browser +(DevTools, cookies API, browser docks и DRM здесь пока отсутствуют). + +## Сборка на Alpine + +```sh +chmod +x scripts/install-deps-alpine.sh +./scripts/install-deps-alpine.sh +/usr/bin/cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +/usr/bin/cmake --build build +ctest --test-dir build --output-on-failure +doas /usr/bin/cmake --install build --prefix /usr +``` + +После установки перезапустите OBS и добавьте источник **Браузер WebKit (без CEF)**. +Если OBS установлен в нестандартный prefix, задайте при конфигурации +`OBS_PLUGIN_DIR` и `OBS_PLUGIN_DATA_DIR`. + +## Почему WebKitGTK, а не WPE + +WPE архитектурно подходит лучше всего, но пакет `wpewebkit` отсутствует в актуальном +Alpine edge/3.24 (остались только `libwpe` и backend). WebKitGTK 4.1 есть в текущем +репозитории Alpine, использует тот же WebKit и не зависит от CEF. Renderer изолирован +от OBS, поэтому падение Web-процесса не должно уронить студию. diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini new file mode 100644 index 0000000..7d14f90 --- /dev/null +++ b/data/locale/en-US.ini @@ -0,0 +1,9 @@ +WebKitBrowserSource="WebKit Browser (no CEF)" +URL="URL or local HTML file" +Width="Width" +Height="Height" +FPS="Frames per second" +Transparent="Transparent background" +CustomCSS="Custom CSS" +ShutdownWhenHidden="Shut down renderer when source is hidden" +Reload="Reload page" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini new file mode 100644 index 0000000..bd08fdf --- /dev/null +++ b/data/locale/ru-RU.ini @@ -0,0 +1,9 @@ +WebKitBrowserSource="Браузер WebKit (без CEF)" +URL="URL или локальный HTML-файл" +Width="Ширина" +Height="Высота" +FPS="Кадров в секунду" +Transparent="Прозрачный фон" +CustomCSS="Пользовательский CSS" +ShutdownWhenHidden="Останавливать рендерер, когда источник скрыт" +Reload="Перезагрузить страницу" diff --git a/scripts/install-deps-alpine.sh b/scripts/install-deps-alpine.sh new file mode 100755 index 0000000..67048ab --- /dev/null +++ b/scripts/install-deps-alpine.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -eq 0 ]; then + ROOT= +elif command -v doas >/dev/null 2>&1; then + ROOT=doas +elif command -v sudo >/dev/null 2>&1; then + ROOT=sudo +else + echo "Run as root or install doas/sudo." >&2 + exit 1 +fi + +$ROOT apk add \ + build-base cmake samurai pkgconf \ + obs-studio obs-studio-dev \ + gtk+3.0 gtk+3.0-dev \ + webkit2gtk-4.1 webkit2gtk-4.1-dev \ + libx11-dev libxcomposite-dev libxext-dev \ + xvfb mesa-dri-gallium diff --git a/src/plugin.c b/src/plugin.c new file mode 100644 index 0000000..e0d7d33 --- /dev/null +++ b/src/plugin.c @@ -0,0 +1,607 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "protocol.h" + +OBS_DECLARE_MODULE() +OBS_MODULE_USE_DEFAULT_LOCALE("obs-webkit-browser", "en-US") +MODULE_EXPORT const char *obs_module_description(void) +{ + return "WebKitGTK browser source for OBS (no CEF)"; +} + +extern char **environ; + +struct browser_source; + +struct renderer_process { + pid_t pid; + int control_fd; + int frame_fd; + int log_fd; + int shared_fd; + struct owb_shared_frames *shared; + size_t shared_size; + pthread_t frame_thread; + pthread_t log_thread; + bool frame_thread_started; + bool log_thread_started; + atomic_bool stopping; + uint32_t width; + uint32_t height; + struct browser_source *owner; +}; + +struct browser_source { + obs_source_t *source; + pthread_mutex_t mutex; + struct renderer_process *renderer; + char *url; + char *css; + uint32_t width; + uint32_t height; + uint32_t fps; + bool transparent; + bool shutdown_when_hidden; + bool visible; +}; + +static bool write_all_socket(int fd, const void *data, size_t size) +{ + const uint8_t *cursor = data; + while (size > 0) { + ssize_t written = send(fd, cursor, size, MSG_NOSIGNAL); + if (written < 0 && errno == EINTR) + continue; + if (written <= 0) + return false; + cursor += (size_t)written; + size -= (size_t)written; + } + return true; +} + +static void set_close_on_exec(int fd) +{ + int flags = fcntl(fd, F_GETFD); + if (flags >= 0) + fcntl(fd, F_SETFD, flags | FD_CLOEXEC); +} + +static uint32_t setting_u32(obs_data_t *settings, const char *name, uint32_t minimum, + uint32_t maximum) +{ + long long value = obs_data_get_int(settings, name); + if (value < (long long)minimum) + return minimum; + if (value > (long long)maximum) + return maximum; + return (uint32_t)value; +} + +static void *frame_reader(void *opaque) +{ + struct renderer_process *renderer = opaque; + size_t stride = (size_t)renderer->width * 4; + size_t frame_size = owb_frame_size(renderer->width, renderer->height); + uint8_t *pixels = malloc(frame_size); + if (!pixels) { + blog(LOG_ERROR, "[obs-webkit-browser] Unable to allocate a %zu-byte frame", frame_size); + return NULL; + } + + while (!atomic_load(&renderer->stopping)) { + struct pollfd notification = {.fd = renderer->frame_fd, .events = POLLIN}; + int ready = poll(¬ification, 1, 200); + if (ready < 0 && errno == EINTR) + continue; + if (ready <= 0) + continue; + uint8_t slot; + ssize_t count = read(renderer->frame_fd, &slot, sizeof(slot)); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + break; + if (slot >= OWB_SHARED_FRAME_SLOTS) + continue; + bool stable = false; + for (unsigned attempt = 0; attempt < 3 && !stable; attempt++) { + uint64_t before = __atomic_load_n(&renderer->shared->sequence[slot], __ATOMIC_ACQUIRE); + if (before & 1u) + continue; + memcpy(pixels, owb_shared_slot(renderer->shared, slot), frame_size); + uint64_t after = __atomic_load_n(&renderer->shared->sequence[slot], __ATOMIC_ACQUIRE); + stable = before == after && !(after & 1u); + } + if (!stable) + continue; + struct obs_source_frame frame = {0}; + frame.data[0] = pixels; + frame.linesize[0] = (uint32_t)stride; + frame.width = renderer->width; + frame.height = renderer->height; + frame.timestamp = os_gettime_ns(); + frame.format = VIDEO_FORMAT_BGRA; + frame.full_range = true; + obs_source_output_video(renderer->owner->source, &frame); + } + + free(pixels); + return NULL; +} + +static void *log_reader(void *opaque) +{ + struct renderer_process *renderer = opaque; + char line[2048]; + size_t used = 0; + while (!atomic_load(&renderer->stopping)) { + struct pollfd item = {.fd = renderer->log_fd, .events = POLLIN}; + int ready = poll(&item, 1, 200); + if (ready < 0 && errno == EINTR) + continue; + if (ready <= 0) + continue; + char chunk[512]; + ssize_t count = read(renderer->log_fd, chunk, sizeof(chunk)); + if (count <= 0) + break; + for (ssize_t index = 0; index < count; index++) { + char byte = chunk[index]; + if (byte == '\n') { + line[used] = '\0'; + if (used) + blog(LOG_INFO, "[obs-webkit-browser renderer] %s", line); + used = 0; + } else if (byte != '\r' && used + 1 < sizeof(line)) { + line[used++] = byte; + } + } + } + if (used) { + line[used] = '\0'; + blog(LOG_INFO, "[obs-webkit-browser renderer] %s", line); + } + close(renderer->log_fd); + renderer->log_fd = -1; + return NULL; +} + +static void terminate_renderer_group(pid_t pid) +{ + if (pid <= 0) + return; + kill(-pid, SIGTERM); + for (unsigned attempt = 0; attempt < 200; attempt++) { + pid_t result = waitpid(pid, NULL, WNOHANG); + if (result == pid || (result < 0 && errno == ECHILD)) + return; + struct timespec pause = {.tv_nsec = 10000000L}; + nanosleep(&pause, NULL); + } + kill(-pid, SIGKILL); + while (waitpid(pid, NULL, 0) < 0 && errno == EINTR) { + } +} + +static void stop_renderer_locked(struct browser_source *browser) +{ + struct renderer_process *renderer = browser->renderer; + if (!renderer) + return; + browser->renderer = NULL; + atomic_store(&renderer->stopping, true); + + if (renderer->control_fd >= 0) { + shutdown(renderer->control_fd, SHUT_RDWR); + close(renderer->control_fd); + renderer->control_fd = -1; + } + terminate_renderer_group(renderer->pid); + if (renderer->frame_fd >= 0) { + close(renderer->frame_fd); + renderer->frame_fd = -1; + } + if (renderer->frame_thread_started) + pthread_join(renderer->frame_thread, NULL); + if (renderer->log_thread_started) + pthread_join(renderer->log_thread, NULL); + else if (renderer->log_fd >= 0) + close(renderer->log_fd); + if (renderer->shared && renderer->shared != MAP_FAILED) + munmap(renderer->shared, renderer->shared_size); + if (renderer->shared_fd >= 0) + close(renderer->shared_fd); + free(renderer); +} + +static bool send_initial_config(struct browser_source *browser, struct renderer_process *renderer) +{ + size_t url_length = strlen(browser->url); + size_t css_length = strlen(browser->css); + if (url_length > OWB_MAX_STRING || css_length > OWB_MAX_STRING) + return false; + struct owb_config_header header = { + .magic = OWB_CONFIG_MAGIC, + .version = OWB_PROTOCOL_VERSION, + .width = browser->width, + .height = browser->height, + .fps = browser->fps, + .transparent = browser->transparent, + .hardware_acceleration = true, + .url_length = (uint32_t)url_length, + .css_length = (uint32_t)css_length, + }; + return write_all_socket(renderer->control_fd, &header, sizeof(header)) && + write_all_socket(renderer->control_fd, browser->url, url_length) && + write_all_socket(renderer->control_fd, browser->css, css_length); +} + +static bool start_renderer_locked(struct browser_source *browser) +{ + if (browser->renderer || (browser->shutdown_when_hidden && !browser->visible)) + return true; + + char *helper = obs_module_file("obs-webkit-renderer"); + if (!helper) { + blog(LOG_ERROR, "[obs-webkit-browser] Renderer helper is missing from the plugin data directory"); + return false; + } + if (access(helper, X_OK) != 0) { + blog(LOG_ERROR, "[obs-webkit-browser] Renderer is not executable: %s", helper); + bfree(helper); + return false; + } + + int control[2] = {-1, -1}; + int frames[2] = {-1, -1}; + int logs[2] = {-1, -1}; + int shared_fd = -1; + struct owb_shared_frames *shared = MAP_FAILED; + size_t shared_size = owb_shared_size(browser->width, browser->height); + shared_fd = memfd_create("obs-webkit-browser-frames", MFD_CLOEXEC); + if (shared_fd < 0 || ftruncate(shared_fd, (off_t)shared_size) != 0) { + blog(LOG_ERROR, "[obs-webkit-browser] Shared frame allocation failed: %s", strerror(errno)); + goto fail; + } + shared = mmap(NULL, shared_size, PROT_READ | PROT_WRITE, MAP_SHARED, shared_fd, 0); + if (shared == MAP_FAILED) { + blog(LOG_ERROR, "[obs-webkit-browser] Shared frame mapping failed: %s", strerror(errno)); + goto fail; + } + memset(shared, 0, sizeof(*shared)); + shared->magic = OWB_SHARED_MAGIC; + shared->version = OWB_PROTOCOL_VERSION; + shared->width = browser->width; + shared->height = browser->height; + shared->stride = browser->width * 4; + shared->slots = OWB_SHARED_FRAME_SLOTS; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, control) != 0 || pipe(frames) != 0 || pipe(logs) != 0) { + blog(LOG_ERROR, "[obs-webkit-browser] IPC setup failed: %s", strerror(errno)); + goto fail; + } + for (size_t i = 0; i < 2; i++) { + set_close_on_exec(control[i]); + set_close_on_exec(frames[i]); + set_close_on_exec(logs[i]); + } + + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + posix_spawn_file_actions_init(&actions); + posix_spawnattr_init(&attributes); + sigset_t defaults; + sigemptyset(&defaults); + sigaddset(&defaults, SIGTERM); + sigaddset(&defaults, SIGINT); + sigaddset(&defaults, SIGPIPE); + posix_spawnattr_setsigdefault(&attributes, &defaults); + posix_spawnattr_setpgroup(&attributes, 0); + posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETPGROUP); + posix_spawn_file_actions_adddup2(&actions, control[1], STDIN_FILENO); + posix_spawn_file_actions_adddup2(&actions, frames[1], STDOUT_FILENO); + posix_spawn_file_actions_adddup2(&actions, logs[1], STDERR_FILENO); + posix_spawn_file_actions_adddup2(&actions, shared_fd, OWB_SHARED_FRAME_FD); + posix_spawn_file_actions_addclose(&actions, control[0]); + posix_spawn_file_actions_addclose(&actions, frames[0]); + posix_spawn_file_actions_addclose(&actions, logs[0]); + char *argv[] = {helper, NULL}; + pid_t pid = 0; + int spawn_error = posix_spawn(&pid, helper, &actions, &attributes, argv, environ); + posix_spawn_file_actions_destroy(&actions); + posix_spawnattr_destroy(&attributes); + if (spawn_error != 0) { + blog(LOG_ERROR, "[obs-webkit-browser] Cannot start renderer: %s", strerror(spawn_error)); + goto fail; + } + + close(control[1]); + close(frames[1]); + close(logs[1]); + control[1] = frames[1] = logs[1] = -1; + struct renderer_process *renderer = calloc(1, sizeof(*renderer)); + if (!renderer) { + terminate_renderer_group(pid); + goto fail; + } + renderer->pid = pid; + renderer->control_fd = control[0]; + renderer->frame_fd = frames[0]; + renderer->log_fd = logs[0]; + renderer->shared_fd = shared_fd; + renderer->shared = shared; + renderer->shared_size = shared_size; + renderer->width = browser->width; + renderer->height = browser->height; + renderer->owner = browser; + atomic_init(&renderer->stopping, false); + control[0] = frames[0] = logs[0] = -1; + shared_fd = -1; + shared = MAP_FAILED; + + if (!send_initial_config(browser, renderer)) { + blog(LOG_ERROR, "[obs-webkit-browser] Cannot send configuration to renderer"); + terminate_renderer_group(renderer->pid); + close(renderer->control_fd); + close(renderer->frame_fd); + close(renderer->log_fd); + munmap(renderer->shared, renderer->shared_size); + close(renderer->shared_fd); + free(renderer); + goto fail; + } + if (pthread_create(&renderer->frame_thread, NULL, frame_reader, renderer) == 0) + renderer->frame_thread_started = true; + if (pthread_create(&renderer->log_thread, NULL, log_reader, renderer) == 0) + renderer->log_thread_started = true; + if (!renderer->frame_thread_started) { + blog(LOG_ERROR, "[obs-webkit-browser] Cannot start frame reader thread"); + browser->renderer = renderer; + stop_renderer_locked(browser); + goto fail; + } + browser->renderer = renderer; + blog(LOG_INFO, "[obs-webkit-browser] Started %ux%u@%u renderer", browser->width, + browser->height, browser->fps); + bfree(helper); + return true; + +fail: + for (size_t i = 0; i < 2; i++) { + if (control[i] >= 0) + close(control[i]); + if (frames[i] >= 0) + close(frames[i]); + if (logs[i] >= 0) + close(logs[i]); + } + bfree(helper); + if (shared != MAP_FAILED) + munmap(shared, shared_size); + if (shared_fd >= 0) + close(shared_fd); + return false; +} + +static void restart_renderer(struct browser_source *browser) +{ + pthread_mutex_lock(&browser->mutex); + stop_renderer_locked(browser); + start_renderer_locked(browser); + pthread_mutex_unlock(&browser->mutex); +} + +static void browser_update(void *data, obs_data_t *settings) +{ + struct browser_source *browser = data; + const char *url = obs_data_get_string(settings, "url"); + const char *css = obs_data_get_string(settings, "css"); + char *new_url = bstrdup(url && *url ? url : "about:blank"); + char *new_css = bstrdup(css ? css : ""); + pthread_mutex_lock(&browser->mutex); + bfree(browser->url); + bfree(browser->css); + browser->url = new_url; + browser->css = new_css; + browser->width = setting_u32(settings, "width", 2, 8192); + browser->height = setting_u32(settings, "height", 2, 8192); + browser->fps = setting_u32(settings, "fps", 1, 60); + browser->transparent = obs_data_get_bool(settings, "transparent"); + browser->shutdown_when_hidden = obs_data_get_bool(settings, "shutdown_when_hidden"); + stop_renderer_locked(browser); + start_renderer_locked(browser); + pthread_mutex_unlock(&browser->mutex); +} + +static void *browser_create(obs_data_t *settings, obs_source_t *source) +{ + struct browser_source *browser = bzalloc(sizeof(*browser)); + browser->source = source; + browser->visible = true; + pthread_mutex_init(&browser->mutex, NULL); + browser_update(browser, settings); + return browser; +} + +static void browser_destroy(void *data) +{ + struct browser_source *browser = data; + pthread_mutex_lock(&browser->mutex); + stop_renderer_locked(browser); + pthread_mutex_unlock(&browser->mutex); + pthread_mutex_destroy(&browser->mutex); + bfree(browser->url); + bfree(browser->css); + bfree(browser); +} + +static uint32_t browser_width(void *data) +{ + return ((struct browser_source *)data)->width; +} + +static uint32_t browser_height(void *data) +{ + return ((struct browser_source *)data)->height; +} + +static void browser_defaults(obs_data_t *settings) +{ + obs_data_set_default_string(settings, "url", "https://example.com"); + obs_data_set_default_int(settings, "width", 1280); + obs_data_set_default_int(settings, "height", 720); + obs_data_set_default_int(settings, "fps", 30); + obs_data_set_default_bool(settings, "transparent", true); + obs_data_set_default_bool(settings, "shutdown_when_hidden", false); +} + +static bool reload_clicked(obs_properties_t *props, obs_property_t *property, void *data) +{ + UNUSED_PARAMETER(props); + UNUSED_PARAMETER(property); + restart_renderer(data); + return false; +} + +static obs_properties_t *browser_properties(void *data) +{ + obs_properties_t *props = obs_properties_create(); + obs_properties_add_text(props, "url", obs_module_text("URL"), OBS_TEXT_DEFAULT); + obs_properties_add_int(props, "width", obs_module_text("Width"), 2, 8192, 1); + obs_properties_add_int(props, "height", obs_module_text("Height"), 2, 8192, 1); + obs_properties_add_int(props, "fps", obs_module_text("FPS"), 1, 60, 1); + obs_properties_add_bool(props, "transparent", obs_module_text("Transparent")); + obs_properties_add_text(props, "css", obs_module_text("CustomCSS"), OBS_TEXT_MULTILINE); + obs_properties_add_bool(props, "shutdown_when_hidden", obs_module_text("ShutdownWhenHidden")); + if (data) + obs_properties_add_button2(props, "reload", obs_module_text("Reload"), reload_clicked, data); + return props; +} + +static void send_command(struct browser_source *browser, struct owb_command *command) +{ + command->magic = OWB_COMMAND_MAGIC; + pthread_mutex_lock(&browser->mutex); + if (browser->renderer && browser->renderer->control_fd >= 0) + write_all_socket(browser->renderer->control_fd, command, sizeof(*command)); + pthread_mutex_unlock(&browser->mutex); +} + +static void browser_mouse_move(void *data, const struct obs_mouse_event *event, bool leave) +{ + struct owb_command command = {.type = OWB_MOUSE_MOVE, .x = event->x, .y = event->y, + .value1 = leave, .modifiers = event->modifiers}; + send_command(data, &command); +} + +static void browser_mouse_click(void *data, const struct obs_mouse_event *event, int32_t type, + bool mouse_up, uint32_t click_count) +{ + struct owb_command command = {.type = OWB_MOUSE_CLICK, .x = event->x, .y = event->y, + .value1 = type, .value2 = mouse_up, + .modifiers = event->modifiers}; + command.text_length = click_count; + send_command(data, &command); +} + +static void browser_mouse_wheel(void *data, const struct obs_mouse_event *event, int x_delta, int y_delta) +{ + struct owb_command command = {.type = OWB_MOUSE_WHEEL, .x = event->x, .y = event->y, + .value1 = x_delta, .value2 = y_delta, + .modifiers = event->modifiers}; + send_command(data, &command); +} + +static void browser_focus(void *data, bool focus) +{ + struct owb_command command = {.type = OWB_FOCUS, .value1 = focus}; + send_command(data, &command); +} + +static void browser_key(void *data, const struct obs_key_event *event, bool key_up) +{ + struct owb_command command = {.type = OWB_KEY, .value1 = key_up, + .value2 = (int32_t)event->native_vkey, + .modifiers = event->modifiers}; + if (event->text) { + size_t length = strlen(event->text); + if (length > sizeof(command.text)) + length = sizeof(command.text); + memcpy(command.text, event->text, length); + command.text_length = (uint32_t)length; + } + send_command(data, &command); +} + +static void browser_show(void *data) +{ + struct browser_source *browser = data; + pthread_mutex_lock(&browser->mutex); + browser->visible = true; + start_renderer_locked(browser); + pthread_mutex_unlock(&browser->mutex); +} + +static void browser_hide(void *data) +{ + struct browser_source *browser = data; + pthread_mutex_lock(&browser->mutex); + browser->visible = false; + if (browser->shutdown_when_hidden) + stop_renderer_locked(browser); + pthread_mutex_unlock(&browser->mutex); +} + +static const char *browser_name(void *unused) +{ + UNUSED_PARAMETER(unused); + return obs_module_text("WebKitBrowserSource"); +} + +static struct obs_source_info browser_info = { + .id = "webkit_browser_source", + .type = OBS_SOURCE_TYPE_INPUT, + .output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_INTERACTION, + .get_name = browser_name, + .create = browser_create, + .destroy = browser_destroy, + .update = browser_update, + .get_defaults = browser_defaults, + .get_properties = browser_properties, + .get_width = browser_width, + .get_height = browser_height, + .show = browser_show, + .hide = browser_hide, + .mouse_click = browser_mouse_click, + .mouse_move = browser_mouse_move, + .mouse_wheel = browser_mouse_wheel, + .focus = browser_focus, + .key_click = browser_key, +}; + +bool obs_module_load(void) +{ + obs_register_source(&browser_info); + blog(LOG_INFO, "[obs-webkit-browser] Loaded WebKitGTK browser source"); + return true; +} diff --git a/src/protocol.h b/src/protocol.h new file mode 100644 index 0000000..dbb6dae --- /dev/null +++ b/src/protocol.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include + +#define OWB_CONFIG_MAGIC 0x4f574243u /* OWBC */ +#define OWB_COMMAND_MAGIC 0x4f574245u /* OWBE */ +#define OWB_PROTOCOL_VERSION 2u +#define OWB_MAX_STRING (16u * 1024u * 1024u) +#define OWB_SHARED_MAGIC 0x4f574246u /* OWBF */ +#define OWB_SHARED_FRAME_FD 198 +#define OWB_SHARED_FRAME_SLOTS 2u + +struct owb_config_header { + uint32_t magic; + uint32_t version; + uint32_t width; + uint32_t height; + uint32_t fps; + uint32_t transparent; + uint32_t hardware_acceleration; + uint32_t url_length; + uint32_t css_length; +}; + +struct owb_shared_frames { + uint32_t magic; + uint32_t version; + uint32_t width; + uint32_t height; + uint32_t stride; + uint32_t slots; + uint64_t sequence[OWB_SHARED_FRAME_SLOTS]; + uint8_t pixels[]; +}; + +static inline size_t owb_frame_size(uint32_t width, uint32_t height) +{ + return (size_t)width * (size_t)height * 4u; +} + +static inline size_t owb_shared_size(uint32_t width, uint32_t height) +{ + return sizeof(struct owb_shared_frames) + + owb_frame_size(width, height) * OWB_SHARED_FRAME_SLOTS; +} + +static inline uint8_t *owb_shared_slot(struct owb_shared_frames *shared, uint32_t slot) +{ + return shared->pixels + owb_frame_size(shared->width, shared->height) * slot; +} + +enum owb_command_type { + OWB_MOUSE_MOVE = 1, + OWB_MOUSE_CLICK = 2, + OWB_MOUSE_WHEEL = 3, + OWB_FOCUS = 4, + OWB_KEY = 5, + OWB_RELOAD = 6, +}; + +/* Fixed-size control messages keep the renderer's nonblocking parser simple. */ +struct owb_command { + uint32_t magic; + uint32_t type; + int32_t x; + int32_t y; + int32_t value1; + int32_t value2; + uint32_t modifiers; + uint32_t text_length; + char text[64]; +}; diff --git a/src/renderer.c b/src/renderer.c new file mode 100644 index 0000000..2d7a704 --- /dev/null +++ b/src/renderer.c @@ -0,0 +1,645 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "protocol.h" + +struct config { + uint32_t width; + uint32_t height; + uint32_t fps; + bool transparent; + char *url; + char *css; +}; + +struct renderer { + struct config config; + GtkWidget *window; + WebKitWebView *web_view; + GPid xvfb_pid; + struct owb_shared_frames *shared; + size_t shared_size; + uint64_t next_sequence; + atomic_bool stopping; + pthread_t capture_thread; + bool capture_thread_started; + uint8_t command_buffer[sizeof(struct owb_command)]; + size_t command_used; + Display *capture_display; + Window capture_window; + Pixmap capture_pixmap; + XImage *capture_image; + XShmSegmentInfo capture_shm; + bool capture_redirected; + bool capture_attached; +}; + +static bool read_exact(int fd, void *data, size_t size) +{ + uint8_t *cursor = data; + while (size > 0) { + ssize_t count = read(fd, cursor, size); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + return false; + cursor += (size_t)count; + size -= (size_t)count; + } + return true; +} + +static bool read_config(struct config *config) +{ + struct owb_config_header header; + if (!read_exact(STDIN_FILENO, &header, sizeof(header)) || header.magic != OWB_CONFIG_MAGIC || + header.version != OWB_PROTOCOL_VERSION || header.width < 2 || header.height < 2 || + header.width > 8192 || header.height > 8192 || header.fps < 1 || header.fps > 60 || + header.url_length > OWB_MAX_STRING || header.css_length > OWB_MAX_STRING) + return false; + config->width = header.width; + config->height = header.height; + config->fps = header.fps; + config->transparent = header.transparent != 0; + /* Protocol v2 used this flag for an experimental host-display mode. It is + * intentionally ignored: an OBS source must never create an input window on + * the user's desktop. */ + (void)header.hardware_acceleration; + config->url = calloc((size_t)header.url_length + 1, 1); + config->css = calloc((size_t)header.css_length + 1, 1); + if (!config->url || !config->css) + return false; + return read_exact(STDIN_FILENO, config->url, header.url_length) && + read_exact(STDIN_FILENO, config->css, header.css_length); +} + +static char *normalize_uri(const char *value) +{ + char *trimmed = g_strdup(value); + g_strstrip(trimmed); + if (strstr(trimmed, "://") || g_str_has_prefix(trimmed, "data:") || + g_str_has_prefix(trimmed, "about:")) + return trimmed; + if (g_file_test(trimmed, G_FILE_TEST_EXISTS)) { + char *absolute = g_canonicalize_filename(trimmed, NULL); + char *uri = g_filename_to_uri(absolute, NULL, NULL); + g_free(absolute); + g_free(trimmed); + return uri; + } + char *uri = g_strdup_printf("https://%s", trimmed); + g_free(trimmed); + return uri; +} + +static bool prepare_display(struct renderer *renderer) +{ + char screen[64]; + g_snprintf(screen, sizeof(screen), "%ux%ux24", renderer->config.width, renderer->config.height); + char *argv[] = {"Xvfb", "-displayfd", "1", "-screen", "0", screen, "+extension", "Composite", + "-nolisten", "tcp", "-noreset", "-dpi", "96", NULL}; + int stdout_fd = -1; + GError *error = NULL; + if (!g_spawn_async_with_pipes(NULL, argv, NULL, + G_SPAWN_SEARCH_PATH | G_SPAWN_DO_NOT_REAP_CHILD, NULL, NULL, + &renderer->xvfb_pid, NULL, &stdout_fd, NULL, &error)) { + fprintf(stderr, "Cannot start Xvfb: %s\n", error->message); + g_error_free(error); + return false; + } + char display_number[32] = {0}; + size_t display_used = 0; + while (display_used + 1 < sizeof(display_number) && + !memchr(display_number, '\n', display_used)) { + struct pollfd display_poll = {.fd = stdout_fd, .events = POLLIN}; + int ready; + do { + ready = poll(&display_poll, 1, 10000); + } while (ready < 0 && errno == EINTR); + if (ready <= 0) + break; + ssize_t count = read(stdout_fd, display_number + display_used, + sizeof(display_number) - display_used - 1); + if (count <= 0) + break; + display_used += (size_t)count; + } + close(stdout_fd); + if (!display_used || !memchr(display_number, '\n', display_used)) { + fprintf(stderr, "Xvfb did not publish a display number within 10 seconds\n"); + kill(renderer->xvfb_pid, SIGTERM); + waitpid(renderer->xvfb_pid, NULL, 0); + g_spawn_close_pid(renderer->xvfb_pid); + renderer->xvfb_pid = 0; + return false; + } + display_number[strcspn(display_number, "\r\n")] = '\0'; + char *display = g_strdup_printf(":%s", display_number); + g_setenv("DISPLAY", display, TRUE); + g_setenv("GDK_BACKEND", "x11", TRUE); + g_free(display); + /* WebKitGTK's accelerated backing store renders into a DRM DMA-BUF and maps + * the completed buffer into this private X display. Xvfb remains useful for + * keeping the Gtk window off the desktop; it must not force WebKit itself to + * use llvmpipe. Override software-rendering flags inherited from OBS too. */ + g_unsetenv("LIBGL_ALWAYS_SOFTWARE"); + g_unsetenv("GALLIUM_DRIVER"); + g_unsetenv("MESA_LOADER_DRIVER_OVERRIDE"); + g_setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "0", TRUE); + g_setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "0", TRUE); + g_setenv("WEBKIT_FORCE_COMPOSITING_MODE", "1", TRUE); + g_setenv("NO_AT_BRIDGE", "1", FALSE); + return true; +} + +static bool map_shared_frames(struct renderer *renderer) +{ + struct stat info; + renderer->shared_size = owb_shared_size(renderer->config.width, renderer->config.height); + if (fstat(OWB_SHARED_FRAME_FD, &info) != 0 || (size_t)info.st_size < renderer->shared_size) + return false; + renderer->shared = mmap(NULL, renderer->shared_size, PROT_READ | PROT_WRITE, MAP_SHARED, + OWB_SHARED_FRAME_FD, 0); + if (renderer->shared == MAP_FAILED) + return false; + return renderer->shared->magic == OWB_SHARED_MAGIC && + renderer->shared->version == OWB_PROTOCOL_VERSION && + renderer->shared->width == renderer->config.width && + renderer->shared->height == renderer->config.height && + renderer->shared->stride == renderer->config.width * 4 && + renderer->shared->slots == OWB_SHARED_FRAME_SLOTS; +} + +static bool initialize_x11_capture(struct renderer *renderer) +{ + renderer->capture_display = XOpenDisplay(NULL); + if (!renderer->capture_display || !XShmQueryExtension(renderer->capture_display)) + return false; + + /* An opaque WebKit view may render into child/compositor surfaces which are + * absent from the top-level window's named backing pixmap. Xvfb's root + * drawable contains the final assembled image, and the renderer window + * covers that private screen exactly. */ + if (!renderer->config.transparent) { + renderer->capture_window = DefaultRootWindow(renderer->capture_display); + renderer->capture_pixmap = renderer->capture_window; + } else { + GdkWindow *gdk_window = gtk_widget_get_window(renderer->window); + if (!gdk_window || !GDK_IS_X11_WINDOW(gdk_window)) + return false; + renderer->capture_window = GDK_WINDOW_XID(gdk_window); + int event_base = 0, error_base = 0; + if (!XCompositeQueryExtension(renderer->capture_display, &event_base, &error_base)) + return false; + XCompositeRedirectWindow(renderer->capture_display, renderer->capture_window, + CompositeRedirectManual); + renderer->capture_redirected = true; + XSync(renderer->capture_display, False); + renderer->capture_pixmap = XCompositeNameWindowPixmap(renderer->capture_display, + renderer->capture_window); + if (!renderer->capture_pixmap) + return false; + } + XWindowAttributes attributes; + if (!XGetWindowAttributes(renderer->capture_display, renderer->capture_window, &attributes)) + return false; + memset(&renderer->capture_shm, 0, sizeof(renderer->capture_shm)); + renderer->capture_shm.shmid = -1; + renderer->capture_image = XShmCreateImage(renderer->capture_display, attributes.visual, + (unsigned)attributes.depth, ZPixmap, NULL, + &renderer->capture_shm, renderer->config.width, + renderer->config.height); + if (!renderer->capture_image || renderer->capture_image->bits_per_pixel != 32) + return false; + size_t image_size = (size_t)renderer->capture_image->bytes_per_line * + (size_t)renderer->capture_image->height; + renderer->capture_shm.shmid = shmget(IPC_PRIVATE, image_size, IPC_CREAT | 0600); + if (renderer->capture_shm.shmid < 0) + return false; + renderer->capture_shm.shmaddr = shmat(renderer->capture_shm.shmid, NULL, 0); + if (renderer->capture_shm.shmaddr == (char *)-1) { + renderer->capture_shm.shmaddr = NULL; + return false; + } + renderer->capture_image->data = renderer->capture_shm.shmaddr; + renderer->capture_shm.readOnly = False; + if (!XShmAttach(renderer->capture_display, &renderer->capture_shm)) + return false; + renderer->capture_attached = true; + XSync(renderer->capture_display, False); + shmctl(renderer->capture_shm.shmid, IPC_RMID, NULL); + return true; +} + +static void destroy_x11_capture(struct renderer *renderer) +{ + if (renderer->capture_attached) { + XShmDetach(renderer->capture_display, &renderer->capture_shm); + XSync(renderer->capture_display, False); + } + if (renderer->capture_image) { + renderer->capture_image->data = NULL; + XDestroyImage(renderer->capture_image); + } + if (renderer->capture_shm.shmaddr) + shmdt(renderer->capture_shm.shmaddr); + if (renderer->capture_shm.shmid >= 0 && !renderer->capture_attached) + shmctl(renderer->capture_shm.shmid, IPC_RMID, NULL); + if (renderer->capture_pixmap && renderer->capture_redirected) + XFreePixmap(renderer->capture_display, renderer->capture_pixmap); + if (renderer->capture_redirected) + XCompositeUnredirectWindow(renderer->capture_display, renderer->capture_window, + CompositeRedirectManual); + if (renderer->capture_display) + XCloseDisplay(renderer->capture_display); +} + +static void publish_frame(struct renderer *renderer, const uint8_t *pixels, int stride, + bool preserve_alpha) +{ + uint32_t slot = (uint32_t)(renderer->next_sequence % OWB_SHARED_FRAME_SLOTS); + uint64_t complete_sequence = (++renderer->next_sequence) << 1; + __atomic_store_n(&renderer->shared->sequence[slot], complete_sequence | 1u, __ATOMIC_RELEASE); + uint8_t *destination = owb_shared_slot(renderer->shared, slot); + for (uint32_t y = 0; y < renderer->config.height; y++) + memcpy(destination + (size_t)y * renderer->config.width * 4, + pixels + (size_t)y * (size_t)stride, (size_t)renderer->config.width * 4); + if (!preserve_alpha) { + for (size_t offset = 3, size = owb_frame_size(renderer->config.width, + renderer->config.height); + offset < size; offset += 4) + destination[offset] = 255; + } + __atomic_store_n(&renderer->shared->sequence[slot], complete_sequence, __ATOMIC_RELEASE); + uint8_t notification = (uint8_t)slot; + ssize_t ignored = write(STDOUT_FILENO, ¬ification, sizeof(notification)); + (void)ignored; +} + +static void publish_x11_frame(struct renderer *renderer) +{ + if (!XShmGetImage(renderer->capture_display, renderer->capture_pixmap, + renderer->capture_image, 0, 0, AllPlanes)) + return; + publish_frame(renderer, (const uint8_t *)renderer->capture_image->data, + renderer->capture_image->bytes_per_line, + renderer->config.transparent && renderer->capture_image->depth == 32); +} + +static void advance_deadline(struct timespec *deadline, long interval_ns) +{ + deadline->tv_nsec += interval_ns; + while (deadline->tv_nsec >= 1000000000L) { + deadline->tv_nsec -= 1000000000L; + deadline->tv_sec++; + } +} + +static void *capture_loop(void *opaque) +{ + struct renderer *renderer = opaque; + long interval_ns = 1000000000L / (long)renderer->config.fps; + struct timespec deadline; + clock_gettime(CLOCK_MONOTONIC, &deadline); + while (!atomic_load_explicit(&renderer->stopping, memory_order_acquire)) { + publish_x11_frame(renderer); + advance_deadline(&deadline, interval_ns); + while (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &deadline, NULL) == EINTR) { + } + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + if (now.tv_sec > deadline.tv_sec + 1) + deadline = now; + } + return NULL; +} + +static GdkModifierType modifiers_from_obs(uint32_t modifiers) +{ + GdkModifierType state = 0; + if (modifiers & (1u << 1)) + state |= GDK_SHIFT_MASK; + if (modifiers & (1u << 2)) + state |= GDK_CONTROL_MASK; + if (modifiers & (1u << 3)) + state |= GDK_MOD1_MASK; + if (modifiers & (1u << 4)) + state |= GDK_BUTTON1_MASK; + if (modifiers & (1u << 5)) + state |= GDK_BUTTON2_MASK; + if (modifiers & (1u << 6)) + state |= GDK_BUTTON3_MASK; + return state; +} + +static void dispatch_command(struct renderer *renderer, const struct owb_command *command) +{ + if (command->magic != OWB_COMMAND_MAGIC) + return; + GdkWindow *window = gtk_widget_get_window(GTK_WIDGET(renderer->web_view)); + if (!window) + return; + if (command->type == OWB_RELOAD) { + webkit_web_view_reload(renderer->web_view); + return; + } + if (command->type == OWB_FOCUS) { + gtk_window_set_focus(GTK_WINDOW(renderer->window), + command->value1 ? GTK_WIDGET(renderer->web_view) : NULL); + return; + } + if (command->type == OWB_MOUSE_MOVE) { + if (command->value1) { + GdkEvent *event = gdk_event_new(GDK_LEAVE_NOTIFY); + event->crossing.window = g_object_ref(window); + event->crossing.send_event = TRUE; + event->crossing.time = GDK_CURRENT_TIME; + event->crossing.x = command->x; + event->crossing.y = command->y; + event->crossing.mode = GDK_CROSSING_NORMAL; + event->crossing.detail = GDK_NOTIFY_NONLINEAR; + event->crossing.state = modifiers_from_obs(command->modifiers); + gtk_widget_event(GTK_WIDGET(renderer->web_view), event); + gdk_event_free(event); + return; + } + GdkEvent *event = gdk_event_new(GDK_MOTION_NOTIFY); + event->motion.window = g_object_ref(window); + event->motion.send_event = TRUE; + event->motion.time = GDK_CURRENT_TIME; + event->motion.x = command->x; + event->motion.y = command->y; + event->motion.state = modifiers_from_obs(command->modifiers); + gtk_widget_event(GTK_WIDGET(renderer->web_view), event); + gdk_event_free(event); + return; + } + if (command->type == OWB_MOUSE_CLICK) { + GdkEventType type = command->value2 ? GDK_BUTTON_RELEASE + : command->text_length == 2 ? GDK_2BUTTON_PRESS + : command->text_length >= 3 ? GDK_3BUTTON_PRESS + : GDK_BUTTON_PRESS; + GdkEvent *event = gdk_event_new(type); + event->button.window = g_object_ref(window); + event->button.send_event = TRUE; + event->button.time = GDK_CURRENT_TIME; + event->button.x = command->x; + event->button.y = command->y; + event->button.button = command->value1 == 1 ? 2 : command->value1 == 2 ? 3 : 1; + event->button.state = modifiers_from_obs(command->modifiers); + gtk_widget_event(GTK_WIDGET(renderer->web_view), event); + gdk_event_free(event); + return; + } + if (command->type == OWB_MOUSE_WHEEL) { + GdkEvent *event = gdk_event_new(GDK_SCROLL); + event->scroll.window = g_object_ref(window); + event->scroll.send_event = TRUE; + event->scroll.time = GDK_CURRENT_TIME; + event->scroll.x = command->x; + event->scroll.y = command->y; + event->scroll.direction = GDK_SCROLL_SMOOTH; + event->scroll.delta_x = -(double)command->value1 / 120.0; + event->scroll.delta_y = -(double)command->value2 / 120.0; + event->scroll.state = modifiers_from_obs(command->modifiers); + gtk_widget_event(GTK_WIDGET(renderer->web_view), event); + gdk_event_free(event); + return; + } + if (command->type == OWB_KEY) { + GdkEvent *event = gdk_event_new(command->value1 ? GDK_KEY_RELEASE : GDK_KEY_PRESS); + event->key.window = g_object_ref(window); + event->key.send_event = TRUE; + event->key.time = GDK_CURRENT_TIME; + event->key.state = modifiers_from_obs(command->modifiers); + event->key.keyval = (guint)command->value2; + if (command->text_length) { + gunichar character = g_utf8_get_char_validated(command->text, command->text_length); + if (character != (gunichar)-1 && character != (gunichar)-2) + event->key.keyval = gdk_unicode_to_keyval(character); + } + event->key.length = (gint)command->text_length; + event->key.string = g_strndup(command->text, command->text_length); + gtk_widget_event(GTK_WIDGET(renderer->web_view), event); + gdk_event_free(event); + } +} + +static gboolean control_ready(gint fd, GIOCondition condition, gpointer opaque) +{ + struct renderer *renderer = opaque; + if (condition & (G_IO_HUP | G_IO_ERR)) { + gtk_main_quit(); + return G_SOURCE_REMOVE; + } + for (;;) { + ssize_t count = read(fd, renderer->command_buffer + renderer->command_used, + sizeof(renderer->command_buffer) - renderer->command_used); + if (count < 0 && errno == EINTR) + continue; + if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return G_SOURCE_CONTINUE; + if (count <= 0) { + gtk_main_quit(); + return G_SOURCE_REMOVE; + } + renderer->command_used += (size_t)count; + if (renderer->command_used == sizeof(struct owb_command)) { + dispatch_command(renderer, (const struct owb_command *)renderer->command_buffer); + renderer->command_used = 0; + } + } +} + +static gboolean clear_window(GtkWidget *widget, cairo_t *context, gpointer unused) +{ + (void)widget; + (void)unused; + cairo_set_operator(context, CAIRO_OPERATOR_SOURCE); + cairo_set_source_rgba(context, 0, 0, 0, 0); + cairo_paint(context); + return FALSE; +} + +static gboolean terminate_renderer(gpointer opaque) +{ + struct renderer *renderer = opaque; + atomic_store_explicit(&renderer->stopping, true, memory_order_release); + gtk_main_quit(); + return G_SOURCE_REMOVE; +} + +static void script_message_received(WebKitUserContentManager *manager, + WebKitJavascriptResult *message, gpointer unused) +{ + (void)manager; + (void)unused; + JSCValue *value = webkit_javascript_result_get_js_value(message); + char *text = jsc_value_to_string(value); + fprintf(stderr, "Page: %s\n", text ? text : "(empty message)"); + g_free(text); +} + +static gboolean load_failed(WebKitWebView *view, WebKitLoadEvent event, const char *uri, + GError *error, gpointer unused) +{ + (void)view; + (void)event; + (void)unused; + fprintf(stderr, "Page load failed for %s: %s\n", uri, error->message); + return FALSE; +} + +int main(void) +{ + struct renderer renderer = {0}; + atomic_init(&renderer.stopping, false); + if (!read_config(&renderer.config)) { + fprintf(stderr, "Invalid renderer configuration\n"); + return 2; + } + if (!prepare_display(&renderer)) + return 3; + if (!map_shared_frames(&renderer)) { + fprintf(stderr, "Cannot map shared frame transport\n"); + return 4; + } + if (!gtk_init_check(NULL, NULL)) { + fprintf(stderr, "GTK could not connect to private Xvfb display\n"); + return 5; + } + + WebKitUserContentManager *manager = webkit_user_content_manager_new(); + g_signal_connect(manager, "script-message-received::owbLog", + G_CALLBACK(script_message_received), NULL); + if (!webkit_user_content_manager_register_script_message_handler(manager, "owbLog")) + fprintf(stderr, "Cannot register page diagnostic message handler\n"); + static const char error_reporter[] = + "(()=>{const p=(k,v)=>{try{window.webkit.messageHandlers.owbLog.postMessage(k+': '+v)}catch{}};" + "addEventListener('error',e=>p('error',e.message+' @ '+e.filename+':'+e.lineno));" + "addEventListener('unhandledrejection',e=>p('rejection',e.reason?.stack||e.reason));" + "const ce=console.error;console.error=(...a)=>{p('console',a.join(' '));ce(...a)}})();"; + WebKitUserScript *reporter = webkit_user_script_new(error_reporter, + WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, + NULL, NULL); + webkit_user_content_manager_add_script(manager, reporter); + webkit_user_script_unref(reporter); + if (*renderer.config.css) { + WebKitUserStyleSheet *sheet = webkit_user_style_sheet_new(renderer.config.css, + WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, WEBKIT_USER_STYLE_LEVEL_USER, NULL, NULL); + webkit_user_content_manager_add_style_sheet(manager, sheet); + webkit_user_style_sheet_unref(sheet); + } + renderer.web_view = WEBKIT_WEB_VIEW(webkit_web_view_new_with_user_content_manager(manager)); + g_signal_connect(renderer.web_view, "load-failed", G_CALLBACK(load_failed), NULL); + g_object_unref(manager); + WebKitSettings *settings = webkit_web_view_get_settings(renderer.web_view); + webkit_settings_set_enable_webgl(settings, TRUE); + webkit_settings_set_enable_page_cache(settings, TRUE); + webkit_settings_set_enable_smooth_scrolling(settings, TRUE); + webkit_settings_set_media_playback_requires_user_gesture(settings, FALSE); + webkit_settings_set_hardware_acceleration_policy(settings, + WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS); + webkit_web_view_set_is_muted(renderer.web_view, TRUE); + GdkRGBA background = renderer.config.transparent ? (GdkRGBA){0, 0, 0, 0} : (GdkRGBA){1, 1, 1, 1}; + webkit_web_view_set_background_color(renderer.web_view, &background); + + renderer.window = gtk_window_new(GTK_WINDOW_POPUP); + GdkScreen *screen = gdk_screen_get_default(); + GdkVisual *visual = renderer.config.transparent ? gdk_screen_get_rgba_visual(screen) + : gdk_screen_get_system_visual(screen); + if (visual) + gtk_widget_set_visual(renderer.window, visual); + gtk_widget_set_app_paintable(renderer.window, renderer.config.transparent); + if (renderer.config.transparent) + g_signal_connect(renderer.window, "draw", G_CALLBACK(clear_window), NULL); + gtk_window_set_decorated(GTK_WINDOW(renderer.window), FALSE); + gtk_window_set_resizable(GTK_WINDOW(renderer.window), FALSE); + gtk_window_set_skip_taskbar_hint(GTK_WINDOW(renderer.window), TRUE); + gtk_window_set_skip_pager_hint(GTK_WINDOW(renderer.window), TRUE); + gtk_window_set_accept_focus(GTK_WINDOW(renderer.window), FALSE); + gtk_window_set_focus_on_map(GTK_WINDOW(renderer.window), FALSE); + gtk_window_move(GTK_WINDOW(renderer.window), 0, 0); + gtk_window_set_default_size(GTK_WINDOW(renderer.window), (int)renderer.config.width, + (int)renderer.config.height); + gtk_widget_set_size_request(GTK_WIDGET(renderer.web_view), (int)renderer.config.width, + (int)renderer.config.height); + gtk_container_add(GTK_CONTAINER(renderer.window), GTK_WIDGET(renderer.web_view)); + gtk_widget_realize(renderer.window); + GdkWindow *renderer_window = gtk_widget_get_window(renderer.window); + if (renderer_window) { + cairo_region_t *empty_input = cairo_region_create(); + gdk_window_input_shape_combine_region(renderer_window, empty_input, 0, 0); + cairo_region_destroy(empty_input); + } + gtk_widget_show_all(renderer.window); + while (gtk_events_pending()) + gtk_main_iteration(); + if (!initialize_x11_capture(&renderer)) { + fprintf(stderr, "Cannot initialize XComposite/XShm capture\n"); + return 6; + } + + int output_flags = fcntl(STDOUT_FILENO, F_GETFL, 0); + if (output_flags >= 0) + fcntl(STDOUT_FILENO, F_SETFL, output_flags | O_NONBLOCK); + + int flags = fcntl(STDIN_FILENO, F_GETFL, 0); + if (flags >= 0) + fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK); + g_unix_fd_add(STDIN_FILENO, G_IO_IN | G_IO_HUP | G_IO_ERR, control_ready, &renderer); + char *uri = normalize_uri(renderer.config.url); + webkit_web_view_load_uri(renderer.web_view, uri); + g_free(uri); + if (pthread_create(&renderer.capture_thread, NULL, capture_loop, &renderer) != 0) { + fprintf(stderr, "Cannot start X11 capture thread\n"); + return 7; + } + renderer.capture_thread_started = true; + fprintf(stderr, "Ready: WebKitGTK %u.%u.%u, %ux%u@%u, GPU compositing, DMA-BUF, X11 capture\n", + webkit_get_major_version(), + webkit_get_minor_version(), webkit_get_micro_version(), renderer.config.width, + renderer.config.height, renderer.config.fps); + fflush(stderr); + g_unix_signal_add(SIGTERM, terminate_renderer, &renderer); + g_unix_signal_add(SIGINT, terminate_renderer, &renderer); + gtk_main(); + + atomic_store_explicit(&renderer.stopping, true, memory_order_release); + if (renderer.capture_thread_started) + pthread_join(renderer.capture_thread, NULL); + destroy_x11_capture(&renderer); + if (renderer.xvfb_pid > 0) { + kill(renderer.xvfb_pid, SIGTERM); + waitpid(renderer.xvfb_pid, NULL, 0); + g_spawn_close_pid(renderer.xvfb_pid); + } + munmap(renderer.shared, renderer.shared_size); + free(renderer.config.url); + free(renderer.config.css); + return 0; +} diff --git a/tests/module-load-test.c b/tests/module-load-test.c new file mode 100644 index 0000000..f14b5e5 --- /dev/null +++ b/tests/module-load-test.c @@ -0,0 +1,70 @@ +#include + +#include +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + if (argc != 4) { + fprintf(stderr, "usage: %s plugin.so data-directory renderer\n", argv[0]); + return 2; + } + char temporary[] = "/tmp/obs-webkit-module-test-XXXXXX"; + if (!mkdtemp(temporary)) { + perror("mkdtemp"); + return 2; + } + char renderer_link[512]; + char locale_link[512]; + char locale_source[512]; + snprintf(renderer_link, sizeof(renderer_link), "%s/obs-webkit-renderer", temporary); + snprintf(locale_link, sizeof(locale_link), "%s/locale", temporary); + snprintf(locale_source, sizeof(locale_source), "%s/locale", argv[2]); + if (symlink(argv[3], renderer_link) || symlink(locale_source, locale_link)) { + perror("symlink"); + return 2; + } + if (!obs_startup("en-US", NULL, NULL)) { + fprintf(stderr, "obs_startup failed\n"); + return 1; + } + obs_module_t *module = NULL; + int result = obs_open_module(&module, argv[1], temporary); + if (result != MODULE_SUCCESS || !obs_init_module(module)) { + fprintf(stderr, "module load failed: %d\n", result); + obs_shutdown(); + return 1; + } + const char *name = obs_source_get_display_name("webkit_browser_source"); + uint32_t flags = obs_get_source_output_flags("webkit_browser_source"); + bool valid = name && strstr(name, "WebKit") && (flags & OBS_SOURCE_ASYNC_VIDEO) && + (flags & OBS_SOURCE_INTERACTION); + if (!valid) + fprintf(stderr, "source registration is incomplete (name=%s, flags=0x%x)\n", + name ? name : "(null)", flags); + obs_data_t *settings = obs_data_create(); + obs_data_set_string(settings, "url", "data:text/html,"); + obs_data_set_int(settings, "width", 64); + obs_data_set_int(settings, "height", 64); + obs_data_set_int(settings, "fps", 10); + obs_data_set_bool(settings, "transparent", true); + obs_data_set_bool(settings, "hardware_acceleration", false); + obs_source_t *source = obs_source_create_private("webkit_browser_source", "smoke", settings); + obs_data_release(settings); + if (!source || obs_source_get_width(source) != 64 || obs_source_get_height(source) != 64) { + fprintf(stderr, "source lifecycle setup failed\n"); + valid = false; + } + if (source) { + usleep(500000); + obs_source_release(source); + } + obs_shutdown(); + unlink(renderer_link); + unlink(locale_link); + rmdir(temporary); + return valid ? 0 : 1; +} diff --git a/tests/protocol-test.c b/tests/protocol-test.c new file mode 100644 index 0000000..4843575 --- /dev/null +++ b/tests/protocol-test.c @@ -0,0 +1,14 @@ +#include "protocol.h" + +#include +#include + +int main(void) +{ + assert(sizeof(struct owb_config_header) == 36); + assert(sizeof(struct owb_command) == 100); + assert(sizeof(struct owb_shared_frames) == 40); + assert(offsetof(struct owb_command, text) == 36); + assert(OWB_CONFIG_MAGIC != OWB_COMMAND_MAGIC); + return 0; +} diff --git a/tests/renderer-fps-test.c b/tests/renderer-fps-test.c new file mode 100644 index 0000000..44cc408 --- /dev/null +++ b/tests/renderer-fps-test.c @@ -0,0 +1,177 @@ +#include "protocol.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +static bool transfer(int fd, void *data, size_t size, bool writing) +{ + uint8_t *cursor = data; + while (size) { + if (!writing) { + struct pollfd item = {.fd = fd, .events = POLLIN}; + if (poll(&item, 1, 5000) <= 0) + return false; + } + ssize_t count = writing ? write(fd, cursor, size) : read(fd, cursor, size); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + return false; + cursor += (size_t)count; + size -= (size_t)count; + } + return true; +} + +static double seconds_since(const struct timespec *start) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (double)(now.tv_sec - start->tv_sec) + (double)(now.tv_nsec - start->tv_nsec) / 1e9; +} + +int main(int argc, char **argv) +{ + if (argc != 2 && argc != 5 && argc != 6) + return 2; + int control[2], frames[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, control) || pipe(frames)) + return 2; + const uint32_t width = argc > 2 ? (uint32_t)strtoul(argv[2], NULL, 10) : 1280; + const uint32_t height = argc > 3 ? (uint32_t)strtoul(argv[3], NULL, 10) : 720; + const uint32_t requested_fps = argc > 4 ? (uint32_t)strtoul(argv[4], NULL, 10) : 30; + size_t shared_size = owb_shared_size(width, height); + int shared_fd = memfd_create("renderer-fps-frames", MFD_CLOEXEC); + if (shared_fd < 0 || ftruncate(shared_fd, (off_t)shared_size) != 0) + return 2; + struct owb_shared_frames *shared = mmap(NULL, shared_size, PROT_READ | PROT_WRITE, + MAP_SHARED, shared_fd, 0); + if (shared == MAP_FAILED) + return 2; + memset(shared, 0, sizeof(*shared)); + shared->magic = OWB_SHARED_MAGIC; + shared->version = OWB_PROTOCOL_VERSION; + shared->width = width; + shared->height = height; + shared->stride = width * 4; + shared->slots = OWB_SHARED_FRAME_SLOTS; + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + posix_spawn_file_actions_adddup2(&actions, control[1], STDIN_FILENO); + posix_spawn_file_actions_adddup2(&actions, frames[1], STDOUT_FILENO); + posix_spawn_file_actions_adddup2(&actions, shared_fd, OWB_SHARED_FRAME_FD); + char *child_argv[] = {argv[1], NULL}; + pid_t child = 0; + int error = posix_spawn(&child, argv[1], &actions, NULL, child_argv, environ); + posix_spawn_file_actions_destroy(&actions); + close(control[1]); + close(frames[1]); + if (error) + return 2; + + const char url[] = "data:text/html,
"; + const char css_blur[] = + "html,body{margin:0;background:transparent;overflow:hidden}" + "body:after{content:'';position:fixed;left:0;top:0;width:32px;height:32px;" + "animation:probe .73s linear infinite alternate}" + "#card{position:absolute;left:15%;top:20%;width:45%;height:55%;" + "background:rgba(30,150,255,.75);will-change:transform,filter;" + "animation:fx 1s ease-in-out infinite alternate}" + "@keyframes fx{from{transform:translateX(0) scale(.92);filter:blur(2px)}" + "to{transform:translateX(45%) scale(1.08);filter:blur(18px)}}" + "@keyframes probe{from{background:#000}to{background:#fff}}"; + const char css_plain[] = + "html,body{margin:0;background:transparent;overflow:hidden}" + "body:after{content:'';position:fixed;left:0;top:0;width:32px;height:32px;" + "animation:probe .73s linear infinite alternate}" + "#card{position:absolute;left:15%;top:20%;width:45%;height:55%;" + "background:rgba(30,150,255,.75);animation:fx 1s ease-in-out infinite alternate}" + "@keyframes fx{from{transform:translateX(0) scale(.92)}" + "to{transform:translateX(45%) scale(1.08)}}" + "@keyframes probe{from{background:#000}to{background:#fff}}"; + const char *css = argc == 6 && strstr(argv[5], "plain") != NULL ? css_plain : css_blur; + size_t css_length = strlen(css); + struct owb_config_header config = { + .magic = OWB_CONFIG_MAGIC, .version = OWB_PROTOCOL_VERSION, + .width = width, .height = height, .fps = requested_fps, .transparent = 1, + .hardware_acceleration = 1, + .url_length = sizeof(url) - 1, .css_length = (uint32_t)css_length, + }; + bool okay = transfer(control[0], &config, sizeof(config), true) && + transfer(control[0], (void *)url, sizeof(url) - 1, true) && + transfer(control[0], (void *)css, css_length, true); + size_t frame_size = (size_t)width * height * 4; + uint8_t *pixels = malloc(frame_size); + unsigned frames_read = 0; + unsigned changed_frames = 0; + uint64_t previous_hash = 0; + struct timespec start = {0}; + while (okay) { + uint8_t slot; + if (!transfer(frames[0], &slot, sizeof(slot), false) || slot >= OWB_SHARED_FRAME_SLOTS) + break; + bool stable = false; + for (unsigned attempt = 0; attempt < 3 && !stable; attempt++) { + uint64_t before = __atomic_load_n(&shared->sequence[slot], __ATOMIC_ACQUIRE); + if (before & 1u) + continue; + memcpy(pixels, owb_shared_slot(shared, slot), frame_size); + uint64_t after = __atomic_load_n(&shared->sequence[slot], __ATOMIC_ACQUIRE); + stable = before == after && !(after & 1u); + } + if (!stable) + continue; + uint64_t hash = 1469598103934665603ull; + for (uint32_t y = 0; y < height; y += 16) { + for (uint32_t x = 0; x < width; x += 16) { + size_t offset = ((size_t)y * width + x) * 4; + uint32_t sample; + memcpy(&sample, pixels + offset, sizeof(sample)); + hash ^= sample; + hash *= 1099511628211ull; + } + } + if (!frames_read || hash != previous_hash) + changed_frames++; + previous_hash = hash; + if (!frames_read) + clock_gettime(CLOCK_MONOTONIC, &start); + frames_read++; + if (seconds_since(&start) >= 4.0) + break; + } + double elapsed = frames_read ? seconds_since(&start) : 0.0; + double fps = elapsed > 0.0 ? (double)(frames_read - 1) / elapsed : 0.0; + double changed_fps = elapsed > 0.0 ? (double)(changed_frames - 1) / elapsed : 0.0; + fprintf(stderr, + "blur-transition: %u frames in %.3f s = %.2f FPS; %u changed = %.2f FPS " + "(requested %u)\n", + frames_read, elapsed, fps, changed_frames, changed_fps, requested_fps); + free(pixels); + munmap(shared, shared_size); + close(shared_fd); + shutdown(control[0], SHUT_RDWR); + close(control[0]); + close(frames[0]); + kill(child, SIGTERM); + while (waitpid(child, NULL, 0) < 0 && errno == EINTR) { + } + return frames_read && fps >= requested_fps * 0.80 && changed_fps >= requested_fps * 0.50 + ? 0 + : 1; +} diff --git a/tests/renderer-smoke-test.c b/tests/renderer-smoke-test.c new file mode 100644 index 0000000..87d5aad --- /dev/null +++ b/tests/renderer-smoke-test.c @@ -0,0 +1,192 @@ +#include "protocol.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +static bool write_exact(int fd, const void *data, size_t size) +{ + const uint8_t *cursor = data; + while (size) { + ssize_t count = write(fd, cursor, size); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + return false; + cursor += (size_t)count; + size -= (size_t)count; + } + return true; +} + +static bool read_frame(int fd, uint8_t *pixels, size_t size, int timeout_ms) +{ + size_t used = 0; + while (used < size) { + struct pollfd pollfd = {.fd = fd, .events = POLLIN}; + int ready = poll(&pollfd, 1, timeout_ms); + if (ready <= 0) + return false; + ssize_t count = read(fd, pixels + used, size - used); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + return false; + used += (size_t)count; + } + return true; +} + +int main(int argc, char **argv) +{ + if (argc < 2 || argc > 4) { + fprintf(stderr, + "usage: %s /path/to/obs-webkit-renderer [opaque | opaque-url URL]\n", + argv[0]); + return 2; + } + bool external_url = argc == 4 && strcmp(argv[2], "opaque-url") == 0; + bool opaque = external_url || (argc == 3 && strcmp(argv[2], "opaque") == 0); + int control[2]; + int frames[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, control) || pipe(frames)) { + perror("IPC"); + return 2; + } + const uint32_t width = external_url ? 1890 : 64; + const uint32_t height = external_url ? 660 : 64; + size_t shared_size = owb_shared_size(width, height); + int shared_fd = memfd_create("renderer-smoke-frames", MFD_CLOEXEC); + if (shared_fd < 0 || ftruncate(shared_fd, (off_t)shared_size) != 0) + return 2; + struct owb_shared_frames *shared = mmap(NULL, shared_size, PROT_READ | PROT_WRITE, + MAP_SHARED, shared_fd, 0); + if (shared == MAP_FAILED) + return 2; + memset(shared, 0, sizeof(*shared)); + shared->magic = OWB_SHARED_MAGIC; + shared->version = OWB_PROTOCOL_VERSION; + shared->width = width; + shared->height = height; + shared->stride = width * 4; + shared->slots = OWB_SHARED_FRAME_SLOTS; + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + posix_spawn_file_actions_adddup2(&actions, control[1], STDIN_FILENO); + posix_spawn_file_actions_adddup2(&actions, frames[1], STDOUT_FILENO); + posix_spawn_file_actions_adddup2(&actions, shared_fd, OWB_SHARED_FRAME_FD); + char *child_argv[] = {argv[1], NULL}; + pid_t child = 0; + int error = posix_spawn(&child, argv[1], &actions, NULL, child_argv, environ); + posix_spawn_file_actions_destroy(&actions); + close(control[1]); + close(frames[1]); + if (error) { + fprintf(stderr, "posix_spawn: %s\n", strerror(error)); + return 2; + } + + const char internal_url[] = "data:text/html,
"; + const char transparent_css[] = "html,body{margin:0;background:transparent!important}" + "#box{position:fixed;inset:0;background:rgba(255,0,0,.5)}"; + const char opaque_css[] = "html,body{margin:0;background:rgb(7,19,31)!important}" + "#box{position:fixed;left:16px;top:16px;width:32px;height:32px;" + "background:rgb(241,23,17);transform:translateZ(0);filter:blur(.1px)}"; + const char *url = external_url ? argv[3] : internal_url; + const char *css = external_url ? "" : opaque ? opaque_css : transparent_css; + struct owb_config_header config = { + .magic = OWB_CONFIG_MAGIC, + .version = OWB_PROTOCOL_VERSION, + .width = width, + .height = height, + .fps = 10, + .transparent = opaque ? 0 : 1, + /* GPU rendering must still use the private Xvfb and produce a frame. */ + .hardware_acceleration = 1, + .url_length = (uint32_t)strlen(url), + .css_length = (uint32_t)strlen(css), + }; + bool sent = write_exact(control[0], &config, sizeof(config)) && + write_exact(control[0], url, strlen(url)) && + write_exact(control[0], css, strlen(css)); + uint8_t last_bgra[4] = {0}; + bool found_red = false; + int max_attempts = external_url ? 120 : 60; + for (int attempt = 0; sent && attempt < max_attempts; attempt++) { + uint8_t slot; + if (!read_frame(frames[0], &slot, sizeof(slot), 1000)) + continue; + if (slot >= OWB_SHARED_FRAME_SLOTS) + continue; + uint8_t *frame = owb_shared_slot(shared, slot); + if (external_url) { + bool nonuniform = false; + uint32_t first; + memcpy(&first, frame, sizeof(first)); + for (uint32_t y = 0; y < height && !nonuniform; y += 8) { + for (uint32_t x = 0; x < width; x += 8) { + uint32_t sample; + memcpy(&sample, frame + ((size_t)y * width + x) * 4, + sizeof(sample)); + if (sample != first) { + nonuniform = true; + break; + } + } + } + found_red = nonuniform; + if ((attempt + 1) % 10 == 0) + fprintf(stderr, "external page at %.1fs: %s\n", + (double)(attempt + 1) / 10.0, + nonuniform ? "content" : "uniform"); + continue; + } + size_t center = ((size_t)(height / 2) * width + width / 2) * 4; + memcpy(last_bgra, frame + center, sizeof(last_bgra)); + bool expected = opaque + ? last_bgra[0] < 32 && last_bgra[1] < 40 && last_bgra[2] > 210 && + last_bgra[3] == 255 + : last_bgra[0] < 8 && last_bgra[1] < 8 && last_bgra[2] > 120 && + last_bgra[2] < 136 && last_bgra[3] > 120 && last_bgra[3] < 136; + if (expected) { + found_red = true; + break; + } + } + shutdown(control[0], SHUT_RDWR); + close(control[0]); + close(frames[0]); + munmap(shared, shared_size); + close(shared_fd); + kill(child, SIGTERM); + while (waitpid(child, NULL, 0) < 0 && errno == EINTR) { + } + if (!found_red) { + if (external_url) { + fprintf(stderr, "renderer kept the external page uniformly colored: %s\n", url); + return 1; + } + fprintf(stderr, + "renderer did not produce the expected %s red BGRA frame " + "(center BGRA=%u,%u,%u,%u)\n", + opaque ? "opaque" : "transparent", last_bgra[0], last_bgra[1], + last_bgra[2], last_bgra[3]); + return 1; + } + return 0; +}