4 Commits

Author SHA1 Message Date
3d7efde9e9 Update README.md 2026-07-20 12:00:56 +05:00
b9309e2326 Fix transparency? 2026-07-20 11:40:45 +05:00
eb13c87dd3 Add sound capture 2026-07-20 10:57:34 +05:00
07bd12ee9a Add scale support 2026-07-20 10:23:57 +05:00
16 changed files with 808 additions and 97 deletions

View File

@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(obs-webkit-browser VERSION 1.1.0 LANGUAGES C CXX) project(obs-webkit-browser VERSION 1.2.1 LANGUAGES C CXX)
include(GNUInstallDirs) include(GNUInstallDirs)
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
@@ -14,11 +14,14 @@ pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(WEBKIT REQUIRED IMPORTED_TARGET webkit2gtk-4.1) pkg_check_modules(WEBKIT REQUIRED IMPORTED_TARGET webkit2gtk-4.1)
pkg_check_modules(X11_CAPTURE REQUIRED IMPORTED_TARGET x11 xcomposite xdamage xext) pkg_check_modules(X11_CAPTURE REQUIRED IMPORTED_TARGET x11 xcomposite xdamage xext)
pkg_check_modules(PULSE REQUIRED IMPORTED_TARGET libpulse libpulse-simple)
add_library(obs-webkit-browser MODULE add_library(obs-webkit-browser MODULE
src/plugin.c src/plugin.c
src/docks.cpp src/docks.cpp
src/protocol.h src/protocol.h
src/pulse-capture.c
src/pulse-capture.h
) )
target_compile_features(obs-webkit-browser PRIVATE c_std_11) target_compile_features(obs-webkit-browser PRIVATE c_std_11)
target_compile_features(obs-webkit-browser PRIVATE cxx_std_17) target_compile_features(obs-webkit-browser PRIVATE cxx_std_17)
@@ -29,6 +32,7 @@ target_link_libraries(obs-webkit-browser PRIVATE
obs obs
obs-frontend-api obs-frontend-api
PkgConfig::QT_WIDGETS PkgConfig::QT_WIDGETS
PkgConfig::PULSE
Threads::Threads Threads::Threads
) )
set_target_properties(obs-webkit-browser PROPERTIES PREFIX "") set_target_properties(obs-webkit-browser PROPERTIES PREFIX "")
@@ -79,6 +83,10 @@ if(BUILD_TESTING)
COMMAND renderer-smoke-test $<TARGET_FILE:obs-webkit-renderer> opaque) COMMAND renderer-smoke-test $<TARGET_FILE:obs-webkit-renderer> opaque)
set_tests_properties(renderer-opaque-smoke-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77) set_tests_properties(renderer-opaque-smoke-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
add_test(NAME renderer-zoom-smoke-test
COMMAND renderer-smoke-test $<TARGET_FILE:obs-webkit-renderer> zoom)
set_tests_properties(renderer-zoom-smoke-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
add_executable(renderer-fps-test tests/renderer-fps-test.c) add_executable(renderer-fps-test tests/renderer-fps-test.c)
target_compile_features(renderer-fps-test PRIVATE c_std_11) target_compile_features(renderer-fps-test PRIVATE c_std_11)
target_compile_definitions(renderer-fps-test PRIVATE _GNU_SOURCE) target_compile_definitions(renderer-fps-test PRIVATE _GNU_SOURCE)
@@ -102,4 +110,14 @@ if(BUILD_TESTING)
$<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data" $<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data"
$<TARGET_FILE:obs-webkit-renderer>) $<TARGET_FILE:obs-webkit-renderer>)
set_tests_properties(module-load-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77) set_tests_properties(module-load-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
add_test(NAME module-audio-test COMMAND module-load-test
$<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data"
$<TARGET_FILE:obs-webkit-renderer> audio-test)
set_tests_properties(module-audio-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
add_test(NAME module-alpha-test COMMAND module-load-test
$<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data"
$<TARGET_FILE:obs-webkit-renderer> alpha-test)
set_tests_properties(module-alpha-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
endif() endif()

139
README.md
View File

@@ -1,49 +1,62 @@
# OBS WebKit Browser Source (без CEF) # OBS WebKit Browser Source (No CEF)
Нативный input source для OBS Studio на Alpine Linux. Страницы рендерит системный A native browser input source for OBS Studio on Alpine Linux. Pages are rendered
WebKitGTK 4.1 в отдельном процессе; Chromium/CEF и Xvfb в проекте нет. Renderer by the system WebKitGTK 4.1 in a separate process; the project does not use
использует X11/XWayland-сессию OBS и держит WebView в полностью прозрачном Chromium/CEF or Xvfb. The renderer uses the X11/XWayland display inherited from
неинтерактивном нативном окне. Нативная surface нужна WebKitGTK для импорта GPU OBS and keeps its WebView in a fully transparent, non-interactive native window.
DMA-BUF. Кадры передаются в OBS напрямую как BGRA, без FFmpeg и захвата рабочего WebKitGTK needs this native surface to import GPU DMA-BUF buffers. Frames are
стола. sent directly to OBS as BGRA without FFmpeg or desktop capture.
Поддерживается: Features:
- HTTP/HTTPS, `data:` URL и локальные HTML-файлы; - HTTP/HTTPS and `data:` URLs, plus local HTML files;
- прозрачный фон с alpha-каналом; - transparent backgrounds with an alpha channel;
- пользовательский CSS; - custom CSS;
- размер 28192 px и 160 FPS; - 25500% page zoom saved in source or web panel settings;
- аппаратный WebKit compositor через DRM/DMA-BUF (Mesa `radeonsi` на AMD); - optional per-source audio capture as a separate OBS Audio Mixer channel;
- прямой XComposite/XShm-захват скомпозированного GPU-кадра с alpha; - dimensions from 2 to 8192 px and frame rates from 1 to 60 FPS;
- XDamage-пропуск чтения backing pixmap, пока содержимое страницы не меняется; - hardware-accelerated WebKit compositing through DRM/DMA-BUF (Mesa `radeonsi`
- shared-memory транспорт кадров без многомегабайтной записи в pipe; on AMD);
- мышь, прокрутка, фокус и базовый ввод с клавиатуры через окно Interact OBS; - direct XComposite/XShm capture of composited GPU frames with alpha;
- перезагрузка страницы и остановка renderer-процесса у скрытого source. - XDamage-based skipping of backing pixmap reads while page content is unchanged;
- пользовательские web-панели в меню «Док-панели» OBS с сохранением URL в - shared-memory frame transport without sending multi-megabyte frames through a pipe;
коллекции сцен, восстановлением после запуска и интерактивным вводом. - mouse, scrolling, focus, and basic keyboard input through the OBS Interact window;
- единый постоянный WebKit-профиль для источников на сцене и web-панелей: общие - page reload and optional renderer shutdown while a source is hidden;
cookies, HTTP cache, localStorage, IndexedDB, service workers и credentials; - custom web panels in the OBS Docks menu, with URLs stored in the scene collection,
страницы одного origin взаимодействуют штатными `storage`-событиями и restored on startup, and full interactive input;
`BroadcastChannel`, как вкладки одного браузера. - one shared persistent WebKit profile for scene sources and web panels, including
cookies, HTTP cache, localStorage, IndexedDB, service workers, and credentials;
same-origin pages communicate through standard `storage` events and
`BroadcastChannel`, just like browser tabs.
Звук страницы намеренно выключен: добавьте в OBS отдельный Application Audio Capture. Audio capture is disabled by default. Enable **Capture browser audio** in the
Все WebView одной сессии OBS работают в общем renderer-процессе и одном source settings to add its audio as a separate OBS Audio Mixer channel. Capture
`WebKitWebContext`; маленькие процессы-посредники сохраняют прежнюю независимую requires a running PulseAudio-compatible server (including PipeWire Pulse) and
жизнь каждого source/dock и изоляцию от OBS. Профиль хранится в стандартных каталогах the GStreamer `pulsesink`; the dependency installation script installs the
данных и cache пользователя (`obs-webkit-browser/profile`) и переживает перезапуск OBS. required `gst-plugins-good` package.
Renderer наследует X11/XWayland-дисплей OBS; его нативное окно имеет нулевую opacity,
пустую input shape и не появляется на рабочем столе. Дополнительный display server не
запускается. WebKit принудительно включает аппаратный compositor и выбирает доступный
DRM render node; для него нужен доступ к `/dev/dri/renderD*`.
Для тяжёлого полноэкранного `filter: blur()` всё ещё разумно выбирать реальный размер
виджета вместо рендера 4K/1080p с последующим уменьшением в сцене. Пользовательские
`will-change` и compositor hints сохраняются, но renderer не внедряет их в страницу сам:
WebKit сам распределяет элементы по GPU-слоям.
Это рабочий backend, а не замена всех расширенных возможностей штатного obs-browser All WebViews in one OBS session share a renderer process and a single
(DevTools, cookies API и DRM здесь пока отсутствуют). `WebKitWebContext`. Small proxy processes preserve the independent lifetime of
each source or dock and isolate them from OBS. The profile is stored in the
standard user data and cache directories under `obs-webkit-browser/profile` and
survives OBS restarts.
## Сборка на Alpine The renderer inherits the X11/XWayland display from OBS. Its native window has
zero opacity and an empty input shape, so it does not appear on the desktop. No
additional display server is started. WebKit is forced to use its hardware
compositor and selects an available DRM render node, which requires access to
`/dev/dri/renderD*`.
For expensive full-screen `filter: blur()` effects, use the actual widget size
instead of rendering at 4K/1080p and scaling the source down in the scene. Custom
`will-change` declarations and compositor hints are preserved, but the renderer
does not inject them itself; WebKit assigns elements to GPU layers.
This is a working backend, not a replacement for every advanced feature in the
standard obs-browser plugin. DevTools, the cookies API, and DRM are not currently
available.
## Building on Alpine
```sh ```sh
chmod +x scripts/install-deps-alpine.sh chmod +x scripts/install-deps-alpine.sh
@@ -54,16 +67,15 @@ ctest --test-dir build --output-on-failure
doas /usr/bin/cmake --install build --prefix /usr doas /usr/bin/cmake --install build --prefix /usr
``` ```
После установки перезапустите OBS и добавьте источник **Браузер WebKit**. After installation, restart OBS and add a **WebKit Browser** source. To add a
Для панели откройте **Док-панели → Пользовательские web-панели...**, добавьте panel, open **Docks → Custom Web Panels...**, enter its name and URL, and click
название и URL и нажмите **Применить**. Панель можно скрывать и показывать через **Apply**. Panels can be shown or hidden through the regular Docks menu; right-click
обычное меню доков OBS; правый клик по странице открывает команду перезагрузки. a page to reload it. If OBS uses a non-standard prefix, set `OBS_PLUGIN_DIR` and
Если OBS установлен в нестандартный prefix, задайте при конфигурации `OBS_PLUGIN_DATA_DIR` during configuration.
`OBS_PLUGIN_DIR` и `OBS_PLUGIN_DATA_DIR`.
## Архив для публикации ## Release Archive
После установки зависимостей используйте отдельные цели `Makefile`: After installing the dependencies, use the dedicated `Makefile` targets:
```sh ```sh
make build make build
@@ -72,14 +84,13 @@ doas make install
make archive make archive
``` ```
`make build` собирает Release-версию, `make test` запускает тесты, `make build` creates a Release build, `make test` runs the tests,
`doas make install` устанавливает плагин в `/usr`, а `make archive` запускает `doas make install` installs the plugin into `/usr`, and `make archive` builds and
сборку и тесты, после чего создаёт пакет без установки файлов в систему. tests the project before creating a package without installing it. The archive
Готовый архив `obs-webkit-browser-<version>-linux-<arch>.tar.gz` и файл с его `obs-webkit-browser-<version>-linux-<arch>.tar.gz` and its SHA-256 file are written
SHA-256 будут записаны в каталог `dist/`. Внутри нет абсолютных путей: архив to `dist/`. The archive contains no absolute paths, only the user plugin layout
содержит только пользовательскую структуру плагина `obs-webkit-browser/{bin/64bit,data}`. Install it directly into the OBS user plugin
`obs-webkit-browser/{bin/64bit,data}`. Установить её можно напрямую в каталог directory:
пользовательских плагинов OBS:
```sh ```sh
mkdir -p ~/.config/obs-studio/plugins mkdir -p ~/.config/obs-studio/plugins
@@ -87,9 +98,15 @@ tar -xzf dist/obs-webkit-browser-<version>-linux-<arch>.tar.gz \
-C ~/.config/obs-studio/plugins -C ~/.config/obs-studio/plugins
``` ```
## Почему WebKitGTK, а не WPE ## Why WebKitGTK Instead of WPE?
WPE архитектурно подходит лучше всего, но пакет `wpewebkit` отсутствует в актуальном WPE would be the best architectural fit, but the `wpewebkit` package is not
Alpine edge/3.24 (остались только `libwpe` и backend). WebKitGTK 4.1 есть в текущем available in current Alpine edge/3.24; only `libwpe` and the backend remain.
репозитории Alpine, использует тот же WebKit и не зависит от CEF. Renderer изолирован WebKitGTK 4.1 is available in the current Alpine repository, uses the same WebKit,
от OBS, поэтому падение Web-процесса не должно уронить студию. and does not depend on CEF. The renderer is isolated from OBS, so a Web process
crash should not bring down the studio.
## Donations
- TRC20: `TYxUB8Vey9xh7jS9cPU2R34eT97pAN851v`
- TON: `UQDBYC5TfbQLsgf50xtBaq9jtyhMl4klwJIeWh6HY_fWim42`

View File

@@ -13,6 +13,8 @@ URL="URL or local HTML file"
Width="Width" Width="Width"
Height="Height" Height="Height"
FPS="Frames per second" FPS="Frames per second"
PageZoom="Page zoom (%)"
CaptureAudio="Capture browser audio"
Transparent="Transparent background" Transparent="Transparent background"
CustomCSS="Custom CSS" CustomCSS="Custom CSS"
ShutdownWhenHidden="Shut down renderer when source is hidden" ShutdownWhenHidden="Shut down renderer when source is hidden"

View File

@@ -13,6 +13,8 @@ URL="URL или локальный HTML-файл"
Width="Ширина" Width="Ширина"
Height="Высота" Height="Высота"
FPS="Кадров в секунду" FPS="Кадров в секунду"
PageZoom="Масштаб страницы (%)"
CaptureAudio="Захватывать звук браузера"
Transparent="Прозрачный фон" Transparent="Прозрачный фон"
CustomCSS="Пользовательский CSS" CustomCSS="Пользовательский CSS"
ShutdownWhenHidden="Останавливать рендерер, когда источник скрыт" ShutdownWhenHidden="Останавливать рендерер, когда источник скрыт"

View File

@@ -18,5 +18,7 @@ $ROOT apk add \
qt6-qtbase-dev \ qt6-qtbase-dev \
gtk+3.0 gtk+3.0-dev \ gtk+3.0 gtk+3.0-dev \
webkit2gtk-4.1 webkit2gtk-4.1-dev \ webkit2gtk-4.1 webkit2gtk-4.1-dev \
gst-plugins-good \
pulseaudio-dev \
libx11-dev libxcomposite-dev libxdamage-dev libxext-dev \ libx11-dev libxcomposite-dev libxdamage-dev libxext-dev \
mesa-dri-gallium mesa-dri-gallium

View File

@@ -29,6 +29,7 @@
#include <QSet> #include <QSet>
#include <QShowEvent> #include <QShowEvent>
#include <QSocketNotifier> #include <QSocketNotifier>
#include <QSpinBox>
#include <QTableWidget> #include <QTableWidget>
#include <QTimer> #include <QTimer>
#include <QUuid> #include <QUuid>
@@ -108,11 +109,13 @@ struct DockDefinition {
QString id; QString id;
QString title; QString title;
QString url; QString url;
int zoomPercent = 100;
}; };
class WebPanel final : public QWidget { class WebPanel final : public QWidget {
public: public:
explicit WebPanel(QString url, QWidget *parent = nullptr) : QWidget(parent), url_(std::move(url)) explicit WebPanel(QString url, int zoomPercent, QWidget *parent = nullptr)
: QWidget(parent), url_(std::move(url)), zoomPercent_(zoomPercent)
{ {
setFocusPolicy(Qt::StrongFocus); setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true); setMouseTracking(true);
@@ -127,11 +130,13 @@ public:
~WebPanel() override { stopRenderer(); } ~WebPanel() override { stopRenderer(); }
void setUrl(const QString &url) void setConfiguration(const QString &url, int zoomPercent)
{ {
if (url_ == url) zoomPercent = std::clamp(zoomPercent, 25, 500);
if (url_ == url && zoomPercent_ == zoomPercent)
return; return;
url_ = url; url_ = url;
zoomPercent_ = zoomPercent;
if (isVisible()) if (isVisible())
restartRenderer(); restartRenderer();
} }
@@ -343,6 +348,8 @@ private:
header.width = static_cast<uint32_t>(renderWidth_); header.width = static_cast<uint32_t>(renderWidth_);
header.height = static_cast<uint32_t>(renderHeight_); header.height = static_cast<uint32_t>(renderHeight_);
header.fps = 30; header.fps = 30;
header.zoom_percent = static_cast<uint32_t>(zoomPercent_);
header.capture_audio = 0;
header.transparent = 0; header.transparent = 0;
header.hardware_acceleration = 1; header.hardware_acceleration = 1;
header.url_length = static_cast<uint32_t>(url.size()); header.url_length = static_cast<uint32_t>(url.size());
@@ -569,6 +576,7 @@ private:
} }
QString url_; QString url_;
int zoomPercent_ = 100;
QString status_; QString status_;
QImage frame_; QImage frame_;
QTimer resizeTimer_; QTimer resizeTimer_;
@@ -732,6 +740,7 @@ private:
obs_data_set_string(item, "id", definition.id.toUtf8().constData()); obs_data_set_string(item, "id", definition.id.toUtf8().constData());
obs_data_set_string(item, "title", definition.title.toUtf8().constData()); obs_data_set_string(item, "title", definition.title.toUtf8().constData());
obs_data_set_string(item, "url", definition.url.toUtf8().constData()); obs_data_set_string(item, "url", definition.url.toUtf8().constData());
obs_data_set_int(item, "zoom", definition.zoomPercent);
obs_data_array_push_back(array, item); obs_data_array_push_back(array, item);
obs_data_release(item); obs_data_release(item);
} }
@@ -750,7 +759,11 @@ private:
QString::fromUtf8(obs_data_get_string(item, "id")), QString::fromUtf8(obs_data_get_string(item, "id")),
QString::fromUtf8(obs_data_get_string(item, "title")), QString::fromUtf8(obs_data_get_string(item, "title")),
QString::fromUtf8(obs_data_get_string(item, "url")), QString::fromUtf8(obs_data_get_string(item, "url")),
static_cast<int>(obs_data_get_int(item, "zoom")),
}; };
if (definition.zoomPercent == 0)
definition.zoomPercent = 100;
definition.zoomPercent = std::clamp(definition.zoomPercent, 25, 500);
if (definition.id.isEmpty()) if (definition.id.isEmpty())
definition.id = QUuid::createUuid().toString(QUuid::WithoutBraces); definition.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
if (!definition.title.trimmed().isEmpty() && !definition.url.trimmed().isEmpty()) if (!definition.title.trimmed().isEmpty() && !definition.url.trimmed().isEmpty())
@@ -780,11 +793,12 @@ private:
} }
for (const DockDefinition &definition : definitions_) { for (const DockDefinition &definition : definitions_) {
if (panels_.contains(definition.id) && panels_[definition.id]) { if (panels_.contains(definition.id) && panels_[definition.id]) {
panels_[definition.id]->setUrl(definition.url); panels_[definition.id]->setConfiguration(definition.url,
definition.zoomPercent);
panels_[definition.id]->setDockTitle(definition.title); panels_[definition.id]->setDockTitle(definition.title);
continue; continue;
} }
auto *panel = new WebPanel(definition.url); auto *panel = new WebPanel(definition.url, definition.zoomPercent);
QByteArray id = dockId(definition.id).toUtf8(); QByteArray id = dockId(definition.id).toUtf8();
QByteArray title = definition.title.toUtf8(); QByteArray title = definition.title.toUtf8();
if (obs_frontend_add_dock_by_id(id.constData(), title.constData(), panel)) if (obs_frontend_add_dock_by_id(id.constData(), title.constData(), panel))
@@ -802,10 +816,11 @@ private:
dialog.resize(720, 360); dialog.resize(720, 360);
auto *layout = new QVBoxLayout(&dialog); auto *layout = new QVBoxLayout(&dialog);
layout->addWidget(new QLabel(text("CustomBrowserDocksDescription"), &dialog)); layout->addWidget(new QLabel(text("CustomBrowserDocksDescription"), &dialog));
auto *table = new QTableWidget(0, 2, &dialog); auto *table = new QTableWidget(0, 3, &dialog);
table->setHorizontalHeaderLabels({text("PanelName"), text("URL")}); table->setHorizontalHeaderLabels({text("PanelName"), text("URL"), text("PageZoom")});
table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents);
table->setSelectionBehavior(QAbstractItemView::SelectRows); table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::ExtendedSelection); table->setSelectionMode(QAbstractItemView::ExtendedSelection);
layout->addWidget(table); layout->addWidget(table);
@@ -817,6 +832,12 @@ private:
title->setData(Qt::UserRole, definition.id); title->setData(Qt::UserRole, definition.id);
table->setItem(row, 0, title); table->setItem(row, 0, title);
table->setItem(row, 1, new QTableWidgetItem(definition.url)); table->setItem(row, 1, new QTableWidgetItem(definition.url));
auto *zoom = new QSpinBox(table);
zoom->setRange(25, 500);
zoom->setSingleStep(5);
zoom->setSuffix("%");
zoom->setValue(definition.zoomPercent);
table->setCellWidget(row, 2, zoom);
}; };
for (const DockDefinition &definition : definitions_) for (const DockDefinition &definition : definitions_)
appendRow(definition); appendRow(definition);
@@ -829,7 +850,7 @@ private:
rowButtons->addStretch(); rowButtons->addStretch();
layout->addLayout(rowButtons); layout->addLayout(rowButtons);
QObject::connect(add, &QPushButton::clicked, table, [table, appendRow] { QObject::connect(add, &QPushButton::clicked, table, [table, appendRow] {
appendRow({QUuid::createUuid().toString(QUuid::WithoutBraces), QString(), "https://"}); appendRow({QUuid::createUuid().toString(QUuid::WithoutBraces), QString(), "https://", 100});
int row = table->rowCount() - 1; int row = table->rowCount() - 1;
table->setCurrentCell(row, 0); table->setCurrentCell(row, 0);
table->editItem(table->item(row, 0)); table->editItem(table->item(row, 0));
@@ -856,6 +877,7 @@ private:
for (int row = 0; row < table->rowCount(); ++row) { for (int row = 0; row < table->rowCount(); ++row) {
QString title = table->item(row, 0) ? table->item(row, 0)->text().trimmed() : QString(); QString title = table->item(row, 0) ? table->item(row, 0)->text().trimmed() : QString();
QString url = table->item(row, 1) ? table->item(row, 1)->text().trimmed() : QString(); QString url = table->item(row, 1) ? table->item(row, 1)->text().trimmed() : QString();
auto *zoom = qobject_cast<QSpinBox *>(table->cellWidget(row, 2));
if (title.isEmpty() || url.isEmpty()) { if (title.isEmpty() || url.isEmpty()) {
QMessageBox::warning(&dialog, text("CustomBrowserDocks"), text("PanelFieldsRequired")); QMessageBox::warning(&dialog, text("CustomBrowserDocks"), text("PanelFieldsRequired"));
return; return;
@@ -868,7 +890,7 @@ private:
QString id = table->item(row, 0)->data(Qt::UserRole).toString(); QString id = table->item(row, 0)->data(Qt::UserRole).toString();
if (id.isEmpty()) if (id.isEmpty())
id = QUuid::createUuid().toString(QUuid::WithoutBraces); id = QUuid::createUuid().toString(QUuid::WithoutBraces);
updated.push_back({id, title, url}); updated.push_back({id, title, url, zoom ? zoom->value() : 100});
} }
definitions_ = std::move(updated); definitions_ = std::move(updated);
reconcile(); reconcile();

View File

@@ -1,6 +1,8 @@
#include <obs/obs-module.h> #include <obs/obs-module.h>
#include <obs/obs-interaction.h> #include <obs/obs-interaction.h>
#include <obs/util/platform.h> #include <obs/util/platform.h>
#include <pulse/error.h>
#include <pulse/simple.h>
#include <errno.h> #include <errno.h>
#include <fcntl.h> #include <fcntl.h>
@@ -21,6 +23,7 @@
#include <unistd.h> #include <unistd.h>
#include "protocol.h" #include "protocol.h"
#include "pulse-capture.h"
OBS_DECLARE_MODULE() OBS_DECLARE_MODULE()
OBS_MODULE_USE_DEFAULT_LOCALE("obs-webkit-browser", "en-US") OBS_MODULE_USE_DEFAULT_LOCALE("obs-webkit-browser", "en-US")
@@ -51,8 +54,11 @@ struct renderer_process {
size_t shared_size; size_t shared_size;
pthread_t frame_thread; pthread_t frame_thread;
pthread_t log_thread; pthread_t log_thread;
pthread_t audio_thread;
bool frame_thread_started; bool frame_thread_started;
bool log_thread_started; bool log_thread_started;
bool audio_thread_started;
struct owb_pulse_sink pulse_sink;
atomic_bool stopping; atomic_bool stopping;
uint32_t width; uint32_t width;
uint32_t height; uint32_t height;
@@ -68,6 +74,8 @@ struct browser_source {
uint32_t width; uint32_t width;
uint32_t height; uint32_t height;
uint32_t fps; uint32_t fps;
uint32_t zoom_percent;
bool capture_audio;
bool transparent; bool transparent;
bool shutdown_when_hidden; bool shutdown_when_hidden;
bool visible; bool visible;
@@ -195,6 +203,54 @@ static void *log_reader(void *opaque)
return NULL; return NULL;
} }
static void *audio_reader(void *opaque)
{
struct renderer_process *renderer = opaque;
char monitor[OWB_AUDIO_SINK_NAME_SIZE + 16];
snprintf(monitor, sizeof(monitor), "%s.monitor", renderer->pulse_sink.name);
pa_sample_spec specification = {
.format = PA_SAMPLE_FLOAT32NE,
.rate = OWB_AUDIO_SAMPLE_RATE,
.channels = OWB_AUDIO_CHANNELS,
};
pa_buffer_attr buffering = {
.maxlength = (uint32_t)-1,
.tlength = (uint32_t)-1,
.prebuf = (uint32_t)-1,
.minreq = (uint32_t)-1,
.fragsize = OWB_AUDIO_BLOCK_FRAMES * OWB_AUDIO_CHANNELS * sizeof(float),
};
int pulse_error = 0;
pa_simple *stream = pa_simple_new(NULL, "OBS WebKit Browser", PA_STREAM_RECORD,
monitor, "Browser source audio", &specification, NULL,
&buffering, &pulse_error);
if (!stream) {
blog(LOG_ERROR, "[obs-webkit-browser] Cannot capture browser audio: %s",
pa_strerror(pulse_error));
return NULL;
}
float samples[OWB_AUDIO_BLOCK_FRAMES * OWB_AUDIO_CHANNELS];
while (!atomic_load(&renderer->stopping)) {
if (pa_simple_read(stream, samples, sizeof(samples), &pulse_error) < 0)
break;
uint64_t duration = (uint64_t)OWB_AUDIO_BLOCK_FRAMES * 1000000000u /
OWB_AUDIO_SAMPLE_RATE;
uint64_t now = os_gettime_ns();
struct obs_source_audio audio = {
.data = {(const uint8_t *)samples},
.frames = OWB_AUDIO_BLOCK_FRAMES,
.speakers = SPEAKERS_STEREO,
.format = AUDIO_FORMAT_FLOAT,
.samples_per_sec = OWB_AUDIO_SAMPLE_RATE,
.timestamp = now > duration ? now - duration : now,
};
obs_source_output_audio(renderer->owner->source, &audio);
}
pa_simple_free(stream);
return NULL;
}
static void terminate_renderer_group(pid_t pid) static void terminate_renderer_group(pid_t pid)
{ {
if (pid <= 0) if (pid <= 0)
@@ -226,6 +282,8 @@ static void stop_renderer_locked(struct browser_source *browser)
renderer->control_fd = -1; renderer->control_fd = -1;
} }
terminate_renderer_group(renderer->pid); terminate_renderer_group(renderer->pid);
if (renderer->pulse_sink.module_index != UINT32_MAX)
owb_pulse_sink_destroy(&renderer->pulse_sink);
if (renderer->frame_fd >= 0) { if (renderer->frame_fd >= 0) {
close(renderer->frame_fd); close(renderer->frame_fd);
renderer->frame_fd = -1; renderer->frame_fd = -1;
@@ -236,6 +294,8 @@ static void stop_renderer_locked(struct browser_source *browser)
pthread_join(renderer->log_thread, NULL); pthread_join(renderer->log_thread, NULL);
else if (renderer->log_fd >= 0) else if (renderer->log_fd >= 0)
close(renderer->log_fd); close(renderer->log_fd);
if (renderer->audio_thread_started)
pthread_join(renderer->audio_thread, NULL);
if (renderer->shared && renderer->shared != MAP_FAILED) if (renderer->shared && renderer->shared != MAP_FAILED)
munmap(renderer->shared, renderer->shared_size); munmap(renderer->shared, renderer->shared_size);
if (renderer->shared_fd >= 0) if (renderer->shared_fd >= 0)
@@ -255,11 +315,15 @@ static bool send_initial_config(struct browser_source *browser, struct renderer_
.width = browser->width, .width = browser->width,
.height = browser->height, .height = browser->height,
.fps = browser->fps, .fps = browser->fps,
.zoom_percent = browser->zoom_percent,
.capture_audio = browser->capture_audio,
.transparent = browser->transparent, .transparent = browser->transparent,
.hardware_acceleration = true, .hardware_acceleration = true,
.url_length = (uint32_t)url_length, .url_length = (uint32_t)url_length,
.css_length = (uint32_t)css_length, .css_length = (uint32_t)css_length,
}; };
if (browser->capture_audio)
snprintf(header.audio_sink, sizeof(header.audio_sink), "%s", renderer->pulse_sink.name);
return write_all_socket(renderer->control_fd, &header, sizeof(header)) && 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->url, url_length) &&
write_all_socket(renderer->control_fd, browser->css, css_length); write_all_socket(renderer->control_fd, browser->css, css_length);
@@ -285,6 +349,16 @@ static bool start_renderer_locked(struct browser_source *browser)
int frames[2] = {-1, -1}; int frames[2] = {-1, -1};
int logs[2] = {-1, -1}; int logs[2] = {-1, -1};
int shared_fd = -1; int shared_fd = -1;
struct owb_pulse_sink pulse_sink = {.module_index = UINT32_MAX};
if (browser->capture_audio) {
char pulse_error[256];
if (!owb_pulse_sink_create(&pulse_sink, pulse_error, sizeof(pulse_error))) {
blog(LOG_ERROR, "[obs-webkit-browser] Cannot create browser audio sink: %s",
pulse_error);
bfree(helper);
return false;
}
}
struct owb_shared_frames *shared = MAP_FAILED; struct owb_shared_frames *shared = MAP_FAILED;
size_t shared_size = owb_shared_size(browser->width, browser->height); size_t shared_size = owb_shared_size(browser->width, browser->height);
shared_fd = memfd_create("obs-webkit-browser-frames", MFD_CLOEXEC); shared_fd = memfd_create("obs-webkit-browser-frames", MFD_CLOEXEC);
@@ -359,6 +433,8 @@ static bool start_renderer_locked(struct browser_source *browser)
renderer->shared_fd = shared_fd; renderer->shared_fd = shared_fd;
renderer->shared = shared; renderer->shared = shared;
renderer->shared_size = shared_size; renderer->shared_size = shared_size;
renderer->pulse_sink = pulse_sink;
pulse_sink.module_index = UINT32_MAX;
renderer->width = browser->width; renderer->width = browser->width;
renderer->height = browser->height; renderer->height = browser->height;
renderer->owner = browser; renderer->owner = browser;
@@ -375,6 +451,7 @@ static bool start_renderer_locked(struct browser_source *browser)
close(renderer->log_fd); close(renderer->log_fd);
munmap(renderer->shared, renderer->shared_size); munmap(renderer->shared, renderer->shared_size);
close(renderer->shared_fd); close(renderer->shared_fd);
owb_pulse_sink_destroy(&renderer->pulse_sink);
free(renderer); free(renderer);
goto fail; goto fail;
} }
@@ -382,12 +459,21 @@ static bool start_renderer_locked(struct browser_source *browser)
renderer->frame_thread_started = true; renderer->frame_thread_started = true;
if (pthread_create(&renderer->log_thread, NULL, log_reader, renderer) == 0) if (pthread_create(&renderer->log_thread, NULL, log_reader, renderer) == 0)
renderer->log_thread_started = true; renderer->log_thread_started = true;
if (browser->capture_audio &&
pthread_create(&renderer->audio_thread, NULL, audio_reader, renderer) == 0)
renderer->audio_thread_started = true;
if (!renderer->frame_thread_started) { if (!renderer->frame_thread_started) {
blog(LOG_ERROR, "[obs-webkit-browser] Cannot start frame reader thread"); blog(LOG_ERROR, "[obs-webkit-browser] Cannot start frame reader thread");
browser->renderer = renderer; browser->renderer = renderer;
stop_renderer_locked(browser); stop_renderer_locked(browser);
goto fail; goto fail;
} }
if (browser->capture_audio && !renderer->audio_thread_started) {
blog(LOG_ERROR, "[obs-webkit-browser] Cannot start browser audio reader thread");
browser->renderer = renderer;
stop_renderer_locked(browser);
goto fail;
}
browser->renderer = renderer; browser->renderer = renderer;
blog(LOG_INFO, "[obs-webkit-browser] Started %ux%u@%u renderer", browser->width, blog(LOG_INFO, "[obs-webkit-browser] Started %ux%u@%u renderer", browser->width,
browser->height, browser->fps); browser->height, browser->fps);
@@ -408,6 +494,7 @@ fail:
munmap(shared, shared_size); munmap(shared, shared_size);
if (shared_fd >= 0) if (shared_fd >= 0)
close(shared_fd); close(shared_fd);
owb_pulse_sink_destroy(&pulse_sink);
return false; return false;
} }
@@ -434,11 +521,14 @@ static void browser_update(void *data, obs_data_t *settings)
browser->width = setting_u32(settings, "width", 2, 8192); browser->width = setting_u32(settings, "width", 2, 8192);
browser->height = setting_u32(settings, "height", 2, 8192); browser->height = setting_u32(settings, "height", 2, 8192);
browser->fps = setting_u32(settings, "fps", 1, 60); browser->fps = setting_u32(settings, "fps", 1, 60);
browser->zoom_percent = setting_u32(settings, "zoom", 25, 500);
browser->capture_audio = obs_data_get_bool(settings, "capture_audio");
browser->transparent = obs_data_get_bool(settings, "transparent"); browser->transparent = obs_data_get_bool(settings, "transparent");
browser->shutdown_when_hidden = obs_data_get_bool(settings, "shutdown_when_hidden"); browser->shutdown_when_hidden = obs_data_get_bool(settings, "shutdown_when_hidden");
stop_renderer_locked(browser); stop_renderer_locked(browser);
start_renderer_locked(browser); start_renderer_locked(browser);
pthread_mutex_unlock(&browser->mutex); pthread_mutex_unlock(&browser->mutex);
obs_source_set_audio_active(browser->source, browser->capture_audio);
} }
static void *browser_create(obs_data_t *settings, obs_source_t *source) static void *browser_create(obs_data_t *settings, obs_source_t *source)
@@ -479,6 +569,8 @@ static void browser_defaults(obs_data_t *settings)
obs_data_set_default_int(settings, "width", 1280); obs_data_set_default_int(settings, "width", 1280);
obs_data_set_default_int(settings, "height", 720); obs_data_set_default_int(settings, "height", 720);
obs_data_set_default_int(settings, "fps", 30); obs_data_set_default_int(settings, "fps", 30);
obs_data_set_default_int(settings, "zoom", 100);
obs_data_set_default_bool(settings, "capture_audio", false);
obs_data_set_default_bool(settings, "transparent", true); obs_data_set_default_bool(settings, "transparent", true);
obs_data_set_default_bool(settings, "shutdown_when_hidden", false); obs_data_set_default_bool(settings, "shutdown_when_hidden", false);
} }
@@ -498,6 +590,8 @@ static obs_properties_t *browser_properties(void *data)
obs_properties_add_int(props, "width", obs_module_text("Width"), 2, 8192, 1); 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, "height", obs_module_text("Height"), 2, 8192, 1);
obs_properties_add_int(props, "fps", obs_module_text("FPS"), 1, 60, 1); obs_properties_add_int(props, "fps", obs_module_text("FPS"), 1, 60, 1);
obs_properties_add_int_slider(props, "zoom", obs_module_text("PageZoom"), 25, 500, 5);
obs_properties_add_bool(props, "capture_audio", obs_module_text("CaptureAudio"));
obs_properties_add_bool(props, "transparent", obs_module_text("Transparent")); 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_text(props, "css", obs_module_text("CustomCSS"), OBS_TEXT_MULTILINE);
obs_properties_add_bool(props, "shutdown_when_hidden", obs_module_text("ShutdownWhenHidden")); obs_properties_add_bool(props, "shutdown_when_hidden", obs_module_text("ShutdownWhenHidden"));
@@ -590,7 +684,8 @@ static struct obs_source_info browser_info = {
.id = "webkit_browser_source", .id = "webkit_browser_source",
.type = OBS_SOURCE_TYPE_INPUT, .type = OBS_SOURCE_TYPE_INPUT,
.icon_type = OBS_ICON_TYPE_BROWSER, .icon_type = OBS_ICON_TYPE_BROWSER,
.output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_INTERACTION, .output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO | OBS_SOURCE_INTERACTION |
OBS_SOURCE_SRGB,
.get_name = browser_name, .get_name = browser_name,
.create = browser_create, .create = browser_create,
.destroy = browser_destroy, .destroy = browser_destroy,

View File

@@ -5,8 +5,9 @@
#define OWB_CONFIG_MAGIC 0x4f574243u /* OWBC */ #define OWB_CONFIG_MAGIC 0x4f574243u /* OWBC */
#define OWB_COMMAND_MAGIC 0x4f574245u /* OWBE */ #define OWB_COMMAND_MAGIC 0x4f574245u /* OWBE */
#define OWB_PROTOCOL_VERSION 2u #define OWB_PROTOCOL_VERSION 4u
#define OWB_MAX_STRING (16u * 1024u * 1024u) #define OWB_MAX_STRING (16u * 1024u * 1024u)
#define OWB_AUDIO_SINK_NAME_SIZE 64u
#define OWB_SHARED_MAGIC 0x4f574246u /* OWBF */ #define OWB_SHARED_MAGIC 0x4f574246u /* OWBF */
#define OWB_SHARED_FRAME_FD 198 #define OWB_SHARED_FRAME_FD 198
#define OWB_SHARED_FRAME_SLOTS 2u #define OWB_SHARED_FRAME_SLOTS 2u
@@ -17,10 +18,13 @@ struct owb_config_header {
uint32_t width; uint32_t width;
uint32_t height; uint32_t height;
uint32_t fps; uint32_t fps;
uint32_t zoom_percent;
uint32_t capture_audio;
uint32_t transparent; uint32_t transparent;
uint32_t hardware_acceleration; uint32_t hardware_acceleration;
uint32_t url_length; uint32_t url_length;
uint32_t css_length; uint32_t css_length;
char audio_sink[OWB_AUDIO_SINK_NAME_SIZE];
}; };
struct owb_shared_frames { struct owb_shared_frames {

174
src/pulse-capture.c Normal file
View File

@@ -0,0 +1,174 @@
#include "pulse-capture.h"
#include <pulse/error.h>
#include <pulse/introspect.h>
#include <pulse/mainloop.h>
#include <pulse/pulseaudio.h>
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
struct pulse_connection {
pa_mainloop *mainloop;
pa_context *context;
};
struct index_result {
bool complete;
uint32_t index;
};
struct success_result {
bool complete;
bool success;
};
static atomic_uint sink_serial = 1;
static uint64_t monotonic_milliseconds(void)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (uint64_t)now.tv_sec * 1000u + (uint64_t)now.tv_nsec / 1000000u;
}
static bool pulse_iterate_until(struct pulse_connection *connection, bool *complete,
uint64_t deadline)
{
while (!*complete && monotonic_milliseconds() < deadline) {
int result = 0;
if (pa_mainloop_iterate(connection->mainloop, 0, &result) < 0)
return false;
pa_context_state_t state = pa_context_get_state(connection->context);
if (!PA_CONTEXT_IS_GOOD(state))
return false;
struct timespec pause = {.tv_nsec = 10000000L};
nanosleep(&pause, NULL);
}
return *complete;
}
static bool pulse_connection_open(struct pulse_connection *connection, char *error,
size_t error_size)
{
memset(connection, 0, sizeof(*connection));
connection->mainloop = pa_mainloop_new();
if (!connection->mainloop)
goto fail;
connection->context = pa_context_new(pa_mainloop_get_api(connection->mainloop),
"OBS WebKit Browser");
if (!connection->context || pa_context_connect(connection->context, NULL,
PA_CONTEXT_NOFLAGS, NULL) < 0)
goto fail;
uint64_t deadline = monotonic_milliseconds() + 3000;
while (monotonic_milliseconds() < deadline) {
pa_context_state_t state = pa_context_get_state(connection->context);
if (state == PA_CONTEXT_READY)
return true;
if (!PA_CONTEXT_IS_GOOD(state))
break;
int result = 0;
if (pa_mainloop_iterate(connection->mainloop, 0, &result) < 0)
break;
struct timespec pause = {.tv_nsec = 10000000L};
nanosleep(&pause, NULL);
}
fail:
if (error && error_size) {
int code = connection->context ? pa_context_errno(connection->context) : PA_ERR_INTERNAL;
snprintf(error, error_size, "%s", pa_strerror(code));
}
if (connection->context) {
pa_context_disconnect(connection->context);
pa_context_unref(connection->context);
}
if (connection->mainloop)
pa_mainloop_free(connection->mainloop);
memset(connection, 0, sizeof(*connection));
return false;
}
static void pulse_connection_close(struct pulse_connection *connection)
{
if (connection->context) {
pa_context_disconnect(connection->context);
pa_context_unref(connection->context);
}
if (connection->mainloop)
pa_mainloop_free(connection->mainloop);
}
static void module_loaded(pa_context *context, uint32_t index, void *opaque)
{
(void)context;
struct index_result *result = opaque;
result->index = index;
result->complete = true;
}
static void module_unloaded(pa_context *context, int success, void *opaque)
{
(void)context;
struct success_result *result = opaque;
result->success = success != 0;
result->complete = true;
}
bool owb_pulse_sink_create(struct owb_pulse_sink *sink, char *error, size_t error_size)
{
memset(sink, 0, sizeof(*sink));
sink->module_index = PA_INVALID_INDEX;
unsigned serial = atomic_fetch_add(&sink_serial, 1);
snprintf(sink->name, sizeof(sink->name), "owb_%ld_%u", (long)getpid(), serial);
struct pulse_connection connection;
if (!pulse_connection_open(&connection, error, error_size))
return false;
char arguments[256];
snprintf(arguments, sizeof(arguments),
"sink_name=%s rate=%u channels=%u sink_properties=device.description=OBS_WebKit_Browser",
sink->name, OWB_AUDIO_SAMPLE_RATE, OWB_AUDIO_CHANNELS);
struct index_result result = {.index = PA_INVALID_INDEX};
pa_operation *operation = pa_context_load_module(connection.context, "module-null-sink",
arguments, module_loaded, &result);
bool okay = operation && pulse_iterate_until(&connection, &result.complete,
monotonic_milliseconds() + 3000) &&
result.index != PA_INVALID_INDEX;
if (operation)
pa_operation_unref(operation);
if (!okay && error && error_size)
snprintf(error, error_size, "%s", pa_strerror(pa_context_errno(connection.context)));
pulse_connection_close(&connection);
if (!okay) {
sink->name[0] = '\0';
return false;
}
sink->module_index = result.index;
return true;
}
void owb_pulse_sink_destroy(struct owb_pulse_sink *sink)
{
if (!sink || sink->module_index == PA_INVALID_INDEX)
return;
struct pulse_connection connection;
if (pulse_connection_open(&connection, NULL, 0)) {
struct success_result result = {0};
pa_operation *operation = pa_context_unload_module(connection.context,
sink->module_index,
module_unloaded, &result);
if (operation) {
pulse_iterate_until(&connection, &result.complete,
monotonic_milliseconds() + 3000);
pa_operation_unref(operation);
}
pulse_connection_close(&connection);
}
sink->module_index = PA_INVALID_INDEX;
sink->name[0] = '\0';
}

19
src/pulse-capture.h Normal file
View File

@@ -0,0 +1,19 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "protocol.h"
#define OWB_AUDIO_SAMPLE_RATE 48000u
#define OWB_AUDIO_CHANNELS 2u
#define OWB_AUDIO_BLOCK_FRAMES 480u
struct owb_pulse_sink {
uint32_t module_index;
char name[OWB_AUDIO_SINK_NAME_SIZE];
};
bool owb_pulse_sink_create(struct owb_pulse_sink *sink, char *error, size_t error_size);
void owb_pulse_sink_destroy(struct owb_pulse_sink *sink);

View File

@@ -35,7 +35,10 @@ struct config {
uint32_t width; uint32_t width;
uint32_t height; uint32_t height;
uint32_t fps; uint32_t fps;
uint32_t zoom_percent;
bool capture_audio;
bool transparent; bool transparent;
char audio_sink[OWB_AUDIO_SINK_NAME_SIZE];
char *url; char *url;
char *css; char *css;
}; };
@@ -107,13 +110,21 @@ static bool read_config(int fd, struct config *config)
if (!read_exact(fd, &header, sizeof(header)) || header.magic != OWB_CONFIG_MAGIC || if (!read_exact(fd, &header, sizeof(header)) || header.magic != OWB_CONFIG_MAGIC ||
header.version != OWB_PROTOCOL_VERSION || header.width < 2 || header.height < 2 || header.version != OWB_PROTOCOL_VERSION || header.width < 2 || header.height < 2 ||
header.width > 8192 || header.height > 8192 || header.fps < 1 || header.fps > 60 || header.width > 8192 || header.height > 8192 || header.fps < 1 || header.fps > 60 ||
header.zoom_percent < 25 || header.zoom_percent > 500 ||
header.capture_audio > 1 ||
(header.capture_audio &&
(!header.audio_sink[0] ||
!memchr(header.audio_sink, '\0', sizeof(header.audio_sink)))) ||
header.url_length > OWB_MAX_STRING || header.css_length > OWB_MAX_STRING) header.url_length > OWB_MAX_STRING || header.css_length > OWB_MAX_STRING)
return false; return false;
config->width = header.width; config->width = header.width;
config->height = header.height; config->height = header.height;
config->fps = header.fps; config->fps = header.fps;
config->zoom_percent = header.zoom_percent;
config->capture_audio = header.capture_audio != 0;
config->transparent = header.transparent != 0; config->transparent = header.transparent != 0;
/* Protocol v2 retains this legacy flag, but this renderer always enables the memcpy(config->audio_sink, header.audio_sink, sizeof(config->audio_sink));
/* The protocol retains this legacy flag, but this renderer always enables the
* WebKit compositor. */ * WebKit compositor. */
(void)header.hardware_acceleration; (void)header.hardware_acceleration;
config->url = calloc((size_t)header.url_length + 1, 1); config->url = calloc((size_t)header.url_length + 1, 1);
@@ -696,9 +707,12 @@ static struct renderer *create_renderer(int client_fd, const int descriptors[4])
webkit_user_content_manager_add_style_sheet(manager, sheet); webkit_user_content_manager_add_style_sheet(manager, sheet);
webkit_user_style_sheet_unref(sheet); webkit_user_style_sheet_unref(sheet);
} }
WebKitWebsitePolicies *policies = webkit_website_policies_new_with_policies(
"autoplay", WEBKIT_AUTOPLAY_ALLOW, NULL);
renderer->web_view = WEBKIT_WEB_VIEW(g_object_new( renderer->web_view = WEBKIT_WEB_VIEW(g_object_new(
WEBKIT_TYPE_WEB_VIEW, "web-context", shared_context, WEBKIT_TYPE_WEB_VIEW, "web-context", shared_context,
"user-content-manager", manager, NULL)); "user-content-manager", manager, "website-policies", policies, NULL));
g_object_unref(policies);
g_signal_connect(renderer->web_view, "load-failed", G_CALLBACK(load_failed), renderer); g_signal_connect(renderer->web_view, "load-failed", G_CALLBACK(load_failed), renderer);
g_object_unref(manager); g_object_unref(manager);
WebKitSettings *settings = webkit_web_view_get_settings(renderer->web_view); WebKitSettings *settings = webkit_web_view_get_settings(renderer->web_view);
@@ -709,7 +723,9 @@ static struct renderer *create_renderer(int client_fd, const int descriptors[4])
webkit_settings_set_media_playback_requires_user_gesture(settings, FALSE); webkit_settings_set_media_playback_requires_user_gesture(settings, FALSE);
webkit_settings_set_hardware_acceleration_policy(settings, webkit_settings_set_hardware_acceleration_policy(settings,
WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS); WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS);
webkit_web_view_set_is_muted(renderer->web_view, TRUE); webkit_web_view_set_zoom_level(renderer->web_view,
(double)renderer->config.zoom_percent / 100.0);
webkit_web_view_set_is_muted(renderer->web_view, !renderer->config.capture_audio);
GdkRGBA background = renderer->config.transparent ? (GdkRGBA){0, 0, 0, 0} : (GdkRGBA){1, 1, 1, 1}; 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); webkit_web_view_set_background_color(renderer->web_view, &background);
@@ -764,7 +780,29 @@ static struct renderer *create_renderer(int client_fd, const int descriptors[4])
renderer->client_source = g_unix_fd_add(renderer->client_fd, renderer->client_source = g_unix_fd_add(renderer->client_fd,
G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL, client_ready, renderer); G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL, client_ready, renderer);
char *uri = normalize_uri(renderer->config.url); char *uri = normalize_uri(renderer->config.url);
char *previous_pulse_sink = g_strdup(g_getenv("PULSE_SINK"));
char *previous_feature_rank = g_strdup(g_getenv("GST_PLUGIN_FEATURE_RANK"));
if (renderer->config.capture_audio) {
g_setenv("PULSE_SINK", renderer->config.audio_sink, TRUE);
char *audio_feature_rank = previous_feature_rank && *previous_feature_rank
? g_strdup_printf("pipewiresink:NONE,pulsesink:MAX,%s", previous_feature_rank)
: g_strdup("pipewiresink:NONE,pulsesink:MAX");
g_setenv("GST_PLUGIN_FEATURE_RANK", audio_feature_rank, TRUE);
g_free(audio_feature_rank);
}
webkit_web_view_load_uri(renderer->web_view, uri); webkit_web_view_load_uri(renderer->web_view, uri);
if (previous_pulse_sink) {
g_setenv("PULSE_SINK", previous_pulse_sink, TRUE);
g_free(previous_pulse_sink);
} else {
g_unsetenv("PULSE_SINK");
}
if (previous_feature_rank) {
g_setenv("GST_PLUGIN_FEATURE_RANK", previous_feature_rank, TRUE);
g_free(previous_feature_rank);
} else {
g_unsetenv("GST_PLUGIN_FEATURE_RANK");
}
g_free(uri); g_free(uri);
if (pthread_create(&renderer->capture_thread, NULL, capture_loop, renderer) != 0) { if (pthread_create(&renderer->capture_thread, NULL, capture_loop, renderer) != 0) {
renderer_log(renderer, "Cannot start GPU capture thread\n"); renderer_log(renderer, "Cannot start GPU capture thread\n");
@@ -776,10 +814,10 @@ static struct renderer *create_renderer(int client_fd, const int descriptors[4])
g_source_remove(idle_source); g_source_remove(idle_source);
idle_source = 0; idle_source = 0;
} }
renderer_log(renderer, "Ready: WebKitGTK %u.%u.%u, %ux%u@%u, shared profile, GPU DMA-BUF, XComposite/XShm capture\n", renderer_log(renderer, "Ready: WebKitGTK %u.%u.%u, %ux%u@%u, zoom %u%%, shared profile, GPU DMA-BUF, XComposite/XShm capture\n",
webkit_get_major_version(), webkit_get_major_version(),
webkit_get_minor_version(), webkit_get_micro_version(), renderer->config.width, webkit_get_minor_version(), webkit_get_micro_version(), renderer->config.width,
renderer->config.height, renderer->config.fps); renderer->config.height, renderer->config.fps, renderer->config.zoom_percent);
return renderer; return renderer;
fail: fail:

View File

@@ -1,17 +1,211 @@
#include <obs/obs.h> #include <obs/obs.h>
#include <arpa/inet.h>
#include <errno.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h> #include <unistd.h>
struct audio_probe {
atomic_uint blocks;
atomic_bool signal;
};
static bool source_alpha_is_correct(obs_source_t *source)
{
bool correct = false;
uint8_t solid[4] = {0};
uint8_t half[4] = {0};
uint8_t background[4] = {0};
obs_set_output_source(0, source);
for (unsigned attempt = 0; attempt < 60 && !correct; attempt++) {
usleep(100000);
obs_enter_graphics();
gs_texrender_t *target = gs_texrender_create(GS_BGRA, GS_ZS_NONE);
gs_stagesurf_t *stage = gs_stagesurface_create(64, 64, GS_BGRA);
if (target && stage && gs_texrender_begin(target, 64, 64)) {
struct vec4 clear = {0};
gs_clear(GS_CLEAR_COLOR, &clear, 0.0f, 0);
gs_ortho(0.0f, 64.0f, 0.0f, 64.0f, -100.0f, 100.0f);
bool previous_srgb = gs_set_linear_srgb(true);
obs_source_video_render(source);
gs_set_linear_srgb(previous_srgb);
gs_texrender_end(target);
gs_stage_texture(stage, gs_texrender_get_texture(target));
uint8_t *pixels = NULL;
uint32_t stride = 0;
if (gs_stagesurface_map(stage, &pixels, &stride)) {
memcpy(background, pixels, sizeof(background));
memcpy(solid, pixels + (size_t)24 * stride + 24 * 4, sizeof(solid));
memcpy(half, pixels + (size_t)40 * stride + 40 * 4, sizeof(half));
gs_stagesurface_unmap(stage);
correct = solid[0] < 32 && solid[1] < 40 && solid[2] > 225 &&
solid[3] == 255 && half[0] < 8 && half[1] < 8 &&
half[2] > 120 && half[2] < 136 && half[3] > 120 &&
half[3] < 136 && background[0] == 0 &&
background[1] == 0 && background[2] == 0 &&
background[3] == 0;
}
}
gs_stagesurface_destroy(stage);
gs_texrender_destroy(target);
obs_leave_graphics();
}
obs_set_output_source(0, NULL);
if (!correct)
fprintf(stderr,
"OBS compositing changed browser alpha (solid BGRA=%u,%u,%u,%u; "
"half BGRA=%u,%u,%u,%u; background BGRA=%u,%u,%u,%u)\n",
solid[0], solid[1], solid[2], solid[3], half[0], half[1], half[2],
half[3], background[0], background[1], background[2], background[3]);
return correct;
}
static bool write_all(int fd, const void *data, size_t size)
{
const uint8_t *cursor = data;
while (size) {
ssize_t written = write(fd, cursor, size);
if (written < 0 && errno == EINTR)
continue;
if (written <= 0)
return false;
cursor += (size_t)written;
size -= (size_t)written;
}
return true;
}
static void write_u32_le(uint8_t *destination, uint32_t value)
{
for (unsigned index = 0; index < 4; index++)
destination[index] = (uint8_t)(value >> (index * 8));
}
static void write_u16_le(uint8_t *destination, uint16_t value)
{
destination[0] = (uint8_t)value;
destination[1] = (uint8_t)(value >> 8);
}
static void serve_audio(int server)
{
static const char page[] =
"<!doctype html><style>html,body{margin:0;width:100%;height:100%;background:#123}</style>"
"<audio id='tone' autoplay loop src='/tone.wav'></audio>"
"<script>tone.play().catch(e=>console.error('audio play failed',e))</script>";
const uint32_t frames = 48000;
const uint32_t data_size = frames * 2 * sizeof(int16_t);
uint8_t wav_header[44] = {0};
memcpy(wav_header, "RIFF", 4);
write_u32_le(wav_header + 4, 36 + data_size);
memcpy(wav_header + 8, "WAVEfmt ", 8);
write_u32_le(wav_header + 16, 16);
write_u16_le(wav_header + 20, 1);
write_u16_le(wav_header + 22, 2);
write_u32_le(wav_header + 24, 48000);
write_u32_le(wav_header + 28, 48000 * 2 * sizeof(int16_t));
write_u16_le(wav_header + 32, 2 * sizeof(int16_t));
write_u16_le(wav_header + 34, 16);
memcpy(wav_header + 36, "data", 4);
write_u32_le(wav_header + 40, data_size);
int16_t *samples = malloc(data_size);
if (!samples)
_exit(2);
for (uint32_t frame = 0; frame < frames; frame++) {
int16_t value = (frame / 55) % 2 ? 10000 : -10000;
samples[frame * 2] = value;
samples[frame * 2 + 1] = value;
}
for (;;) {
int client = accept(server, NULL, NULL);
if (client < 0 && errno == EINTR)
continue;
if (client < 0)
_exit(0);
char request[2048] = {0};
ssize_t count = read(client, request, sizeof(request) - 1);
bool wav = count > 0 && strstr(request, "GET /tone.wav ");
char response[512];
size_t body_size = wav ? sizeof(wav_header) + data_size : sizeof(page) - 1;
int length = snprintf(response, sizeof(response),
"HTTP/1.1 200 OK\r\nContent-Type: %s\r\nCache-Control: no-store\r\n"
"Connection: close\r\nContent-Length: %zu\r\n\r\n",
wav ? "audio/wav" : "text/html", body_size);
write_all(client, response, (size_t)length);
if (wav) {
write_all(client, wav_header, sizeof(wav_header));
write_all(client, samples, data_size);
} else {
write_all(client, page, sizeof(page) - 1);
}
close(client);
}
}
static pid_t start_audio_server(char *url, size_t url_size)
{
int server = socket(AF_INET, SOCK_STREAM, 0);
if (server < 0)
return -1;
struct sockaddr_in address = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
if (bind(server, (struct sockaddr *)&address, sizeof(address)) != 0 || listen(server, 8) != 0) {
close(server);
return -1;
}
socklen_t size = sizeof(address);
if (getsockname(server, (struct sockaddr *)&address, &size) != 0) {
close(server);
return -1;
}
pid_t child = fork();
if (child == 0)
serve_audio(server);
close(server);
if (child < 0)
return -1;
snprintf(url, url_size, "http://127.0.0.1:%u/", ntohs(address.sin_port));
return child;
}
static void audio_captured(void *opaque, obs_source_t *source,
const struct audio_data *audio, bool muted)
{
(void)source;
(void)muted;
struct audio_probe *probe = opaque;
atomic_fetch_add(&probe->blocks, 1);
if (!audio->data[0])
return;
const float *samples = (const float *)audio->data[0];
for (uint32_t index = 0; index < audio->frames; index++) {
float value = samples[index];
if (value > 0.01f || value < -0.01f) {
atomic_store(&probe->signal, true);
break;
}
}
}
int main(int argc, char **argv) int main(int argc, char **argv)
{ {
if (argc != 4) { if (argc < 4 || argc > 5) {
fprintf(stderr, "usage: %s plugin.so data-directory renderer\n", argv[0]); fprintf(stderr,
"usage: %s plugin.so data-directory renderer [audio-test | alpha-test]\n",
argv[0]);
return 2; return 2;
} }
bool audio_test = argc == 5 && strcmp(argv[4], "audio-test") == 0;
bool alpha_test = argc == 5 && strcmp(argv[4], "alpha-test") == 0;
char temporary[] = "/tmp/obs-webkit-module-test-XXXXXX"; char temporary[] = "/tmp/obs-webkit-module-test-XXXXXX";
if (!mkdtemp(temporary)) { if (!mkdtemp(temporary)) {
perror("mkdtemp"); perror("mkdtemp");
@@ -34,25 +228,74 @@ int main(int argc, char **argv)
rmdir(temporary); rmdir(temporary);
return 77; return 77;
} }
struct obs_audio_info audio_info = {.samples_per_sec = 48000, .speakers = SPEAKERS_STEREO};
if (!obs_reset_audio(&audio_info)) {
obs_shutdown();
return 77;
}
if (alpha_test) {
struct obs_video_info video_info = {
.graphics_module = "libobs-opengl",
.fps_num = 30,
.fps_den = 1,
.base_width = 64,
.base_height = 64,
.output_width = 64,
.output_height = 64,
.output_format = VIDEO_FORMAT_BGRA,
.gpu_conversion = false,
.colorspace = VIDEO_CS_DEFAULT,
.range = VIDEO_RANGE_FULL,
.scale_type = OBS_SCALE_DISABLE,
};
if (obs_reset_video(&video_info) != OBS_VIDEO_SUCCESS) {
obs_shutdown();
return 77;
}
}
char audio_url[128] = {0};
pid_t audio_server = audio_test ? start_audio_server(audio_url, sizeof(audio_url)) : -1;
if (audio_test && audio_server < 0) {
obs_shutdown();
return 77;
}
obs_module_t *module = NULL; obs_module_t *module = NULL;
int result = obs_open_module(&module, argv[1], temporary); int result = obs_open_module(&module, argv[1], temporary);
if (result != MODULE_SUCCESS || !obs_init_module(module)) { if (result != MODULE_SUCCESS || !obs_init_module(module)) {
fprintf(stderr, "module load failed: %d\n", result); fprintf(stderr, "module load failed: %d\n", result);
if (audio_server > 0) {
kill(audio_server, SIGTERM);
waitpid(audio_server, NULL, 0);
}
obs_shutdown(); obs_shutdown();
return 1; return 1;
} }
const char *name = obs_source_get_display_name("webkit_browser_source"); const char *name = obs_source_get_display_name("webkit_browser_source");
uint32_t flags = obs_get_source_output_flags("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) && bool valid = name && strstr(name, "WebKit") && (flags & OBS_SOURCE_ASYNC_VIDEO) &&
(flags & OBS_SOURCE_INTERACTION); (flags & OBS_SOURCE_AUDIO) && (flags & OBS_SOURCE_INTERACTION) &&
(flags & OBS_SOURCE_SRGB);
if (!valid) if (!valid)
fprintf(stderr, "source registration is incomplete (name=%s, flags=0x%x)\n", fprintf(stderr, "source registration is incomplete (name=%s, flags=0x%x)\n",
name ? name : "(null)", flags); name ? name : "(null)", flags);
obs_data_t *settings = obs_data_create(); obs_data_t *settings = obs_data_create();
obs_data_set_string(settings, "url", "data:text/html,<body style='background:red'></body>"); const char *test_url = "data:text/html,<body style='background:red'></body>";
if (audio_test)
test_url = audio_url;
else if (alpha_test)
test_url = "data:text/html,<body><div id='box'></div></body>";
obs_data_set_string(settings, "url", test_url);
if (alpha_test)
obs_data_set_string(settings, "css",
"html,body{margin:0}#box{position:fixed;left:16px;top:16px;"
"width:16px;height:16px;background:rgb(241,23,17)}"
"#box:after{content:'';position:fixed;left:32px;top:32px;"
"width:16px;height:16px;background:rgba(255,0,0,.5)}");
obs_data_set_int(settings, "width", 64); obs_data_set_int(settings, "width", 64);
obs_data_set_int(settings, "height", 64); obs_data_set_int(settings, "height", 64);
obs_data_set_int(settings, "fps", 10); obs_data_set_int(settings, "fps", 10);
obs_data_set_int(settings, "zoom", alpha_test ? 100 : 175);
obs_data_set_bool(settings, "capture_audio", audio_test);
obs_data_set_bool(settings, "transparent", true); obs_data_set_bool(settings, "transparent", true);
obs_data_set_bool(settings, "hardware_acceleration", false); obs_data_set_bool(settings, "hardware_acceleration", false);
obs_source_t *source = obs_source_create_private("webkit_browser_source", "smoke", settings); obs_source_t *source = obs_source_create_private("webkit_browser_source", "smoke", settings);
@@ -61,13 +304,53 @@ int main(int argc, char **argv)
fprintf(stderr, "source lifecycle setup failed\n"); fprintf(stderr, "source lifecycle setup failed\n");
valid = false; valid = false;
} }
obs_properties_t *properties = source ? obs_source_properties(source) : NULL;
obs_property_t *zoom = properties ? obs_properties_get(properties, "zoom") : NULL;
obs_property_t *capture_audio_property =
properties ? obs_properties_get(properties, "capture_audio") : NULL;
if (!zoom || obs_property_get_type(zoom) != OBS_PROPERTY_INT ||
obs_property_int_type(zoom) != OBS_NUMBER_SLIDER || obs_property_int_min(zoom) != 25 ||
obs_property_int_max(zoom) != 500 || obs_property_int_step(zoom) != 5) {
fprintf(stderr, "page zoom property is missing or invalid\n");
valid = false;
}
if (!capture_audio_property || obs_property_get_type(capture_audio_property) != OBS_PROPERTY_BOOL ||
!source ||
obs_source_audio_active(source) != audio_test) {
fprintf(stderr, "browser audio capture property or inactive mixer state is invalid\n");
valid = false;
}
if (properties)
obs_properties_destroy(properties);
struct audio_probe probe;
atomic_init(&probe.blocks, 0);
atomic_init(&probe.signal, false);
if (source && audio_test)
obs_source_add_audio_capture_callback(source, audio_captured, &probe);
if (source) { if (source) {
usleep(500000); if (alpha_test && !source_alpha_is_correct(source))
valid = false;
unsigned attempts = audio_test ? 100 : 5;
for (unsigned attempt = 0; attempt < attempts &&
(!audio_test || !atomic_load(&probe.signal)); attempt++)
usleep(100000);
if (audio_test) {
obs_source_remove_audio_capture_callback(source, audio_captured, &probe);
if (!atomic_load(&probe.signal)) {
fprintf(stderr, "browser audio did not reach the OBS source (blocks=%u)\n",
atomic_load(&probe.blocks));
valid = false;
}
}
obs_source_release(source); obs_source_release(source);
} }
obs_shutdown(); obs_shutdown();
unlink(renderer_link); unlink(renderer_link);
unlink(locale_link); unlink(locale_link);
rmdir(temporary); rmdir(temporary);
if (audio_server > 0) {
kill(audio_server, SIGTERM);
waitpid(audio_server, NULL, 0);
}
return valid ? 0 : 1; return valid ? 0 : 1;
} }

View File

@@ -5,7 +5,9 @@
int main(void) int main(void)
{ {
assert(sizeof(struct owb_config_header) == 36); assert(sizeof(struct owb_config_header) == 108);
assert(offsetof(struct owb_config_header, zoom_percent) == 20);
assert(offsetof(struct owb_config_header, audio_sink) == 44);
assert(sizeof(struct owb_command) == 100); assert(sizeof(struct owb_command) == 100);
assert(offsetof(struct owb_shared_frames, pixels) == 40); assert(offsetof(struct owb_shared_frames, pixels) == 40);
assert(owb_shared_size(2, 2) == 72); assert(owb_shared_size(2, 2) == 72);

View File

@@ -109,6 +109,7 @@ int main(int argc, char **argv)
struct owb_config_header config = { struct owb_config_header config = {
.magic = OWB_CONFIG_MAGIC, .version = OWB_PROTOCOL_VERSION, .magic = OWB_CONFIG_MAGIC, .version = OWB_PROTOCOL_VERSION,
.width = width, .height = height, .fps = requested_fps, .transparent = 1, .width = width, .height = height, .fps = requested_fps, .transparent = 1,
.zoom_percent = 100,
.hardware_acceleration = 1, .hardware_acceleration = 1,
.url_length = sizeof(url) - 1, .css_length = (uint32_t)css_length, .url_length = sizeof(url) - 1, .css_length = (uint32_t)css_length,
}; };

View File

@@ -56,12 +56,13 @@ int main(int argc, char **argv)
{ {
if (argc < 2 || argc > 4) { if (argc < 2 || argc > 4) {
fprintf(stderr, fprintf(stderr,
"usage: %s /path/to/obs-webkit-renderer [opaque | opaque-url URL]\n", "usage: %s /path/to/obs-webkit-renderer [opaque | zoom | opaque-url URL]\n",
argv[0]); argv[0]);
return 2; return 2;
} }
bool external_url = argc == 4 && strcmp(argv[2], "opaque-url") == 0; bool external_url = argc == 4 && strcmp(argv[2], "opaque-url") == 0;
bool opaque = external_url || (argc == 3 && strcmp(argv[2], "opaque") == 0); bool zoom = argc == 3 && strcmp(argv[2], "zoom") == 0;
bool opaque = external_url || zoom || (argc == 3 && strcmp(argv[2], "opaque") == 0);
int control[2]; int control[2];
int frames[2]; int frames[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, control) || pipe(frames)) { if (socketpair(AF_UNIX, SOCK_STREAM, 0, control) || pipe(frames)) {
@@ -103,30 +104,43 @@ int main(int argc, char **argv)
const char internal_url[] = const char internal_url[] =
"data:text/html,<body style='background:rgb(3,5,7)'><div id='box'></div></body>"; "data:text/html,<body style='background:rgb(3,5,7)'><div id='box'></div></body>";
const char transparent_css[] = "html,body{margin:0}" const char transparent_css[] =
"#box{position:fixed;inset:16px;background:rgba(255,0,0,.5)}"; "html,body{margin:0}"
"#box{position:fixed;left:16px;top:16px;width:16px;height:16px;"
"background:rgb(241,23,17)}"
"#box:after{content:'';position:fixed;left:32px;top:32px;width:16px;"
"height:16px;background:rgba(255,0,0,.5)}";
const char opaque_css[] = "html,body{margin:0;background:rgb(7,19,31)!important}" 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;" "#box{position:fixed;left:16px;top:16px;width:32px;height:32px;"
"background:rgb(241,23,17);transform:translateZ(0);filter:blur(.1px)}"; "background:rgb(241,23,17);transform:translateZ(0);filter:blur(.1px)}";
const char zoom_css[] = "html,body{margin:0;background:rgb(7,19,31)!important}"
"#box{position:fixed;left:0;top:0;width:10px;height:10px;"
"background:rgb(241,23,17)}";
const char *url = external_url ? argv[3] : internal_url; const char *url = external_url ? argv[3] : internal_url;
const char *css = external_url ? "" : opaque ? opaque_css : transparent_css; const char *css = external_url ? "" : zoom ? zoom_css : opaque ? opaque_css : transparent_css;
const char *audio_sink = getenv("OWB_TEST_AUDIO_SINK");
struct owb_config_header config = { struct owb_config_header config = {
.magic = OWB_CONFIG_MAGIC, .magic = OWB_CONFIG_MAGIC,
.version = OWB_PROTOCOL_VERSION, .version = OWB_PROTOCOL_VERSION,
.width = width, .width = width,
.height = height, .height = height,
.fps = 10, .fps = 10,
.zoom_percent = zoom ? 200 : 100,
.capture_audio = audio_sink && *audio_sink,
.transparent = opaque ? 0 : 1, .transparent = opaque ? 0 : 1,
/* GPU rendering must still produce an invisible captured frame. */ /* GPU rendering must still produce an invisible captured frame. */
.hardware_acceleration = 1, .hardware_acceleration = 1,
.url_length = (uint32_t)strlen(url), .url_length = (uint32_t)strlen(url),
.css_length = (uint32_t)strlen(css), .css_length = (uint32_t)strlen(css),
}; };
if (audio_sink)
snprintf(config.audio_sink, sizeof(config.audio_sink), "%s", audio_sink);
bool sent = write_exact(control[0], &config, sizeof(config)) && bool sent = write_exact(control[0], &config, sizeof(config)) &&
write_exact(control[0], url, strlen(url)) && write_exact(control[0], url, strlen(url)) &&
write_exact(control[0], css, strlen(css)); write_exact(control[0], css, strlen(css));
uint8_t last_bgra[4] = {0}; uint8_t last_bgra[4] = {0};
uint8_t last_corner_bgra[4] = {0}; uint8_t last_corner_bgra[4] = {0};
uint8_t last_half_bgra[4] = {0};
bool found_red = false; bool found_red = false;
int max_attempts = external_url ? 120 : 60; int max_attempts = external_url ? 120 : 60;
for (int attempt = 0; sent && attempt < max_attempts; attempt++) { for (int attempt = 0; sent && attempt < max_attempts; attempt++) {
@@ -158,14 +172,28 @@ int main(int argc, char **argv)
nonuniform ? "content" : "uniform"); nonuniform ? "content" : "uniform");
continue; continue;
} }
size_t center = ((size_t)(height / 2) * width + width / 2) * 4; size_t center = zoom ? ((size_t)5 * width + 15) * 4
: opaque ? ((size_t)(height / 2) * width + width / 2) * 4
: ((size_t)24 * width + 24) * 4;
memcpy(last_bgra, frame + center, sizeof(last_bgra)); memcpy(last_bgra, frame + center, sizeof(last_bgra));
memcpy(last_corner_bgra, frame, sizeof(last_corner_bgra)); size_t corner = zoom ? ((size_t)5 * width + 25) * 4 : 0;
bool expected = opaque memcpy(last_corner_bgra, frame + corner, sizeof(last_corner_bgra));
if (!zoom && !opaque) {
size_t half = ((size_t)40 * width + 40) * 4;
memcpy(last_half_bgra, frame + half, sizeof(last_half_bgra));
}
bool expected = zoom
? last_bgra[0] < 32 && last_bgra[1] < 40 && last_bgra[2] > 210 &&
last_bgra[3] == 255 && last_corner_bgra[0] > 20 &&
last_corner_bgra[1] < 32 && last_corner_bgra[2] < 32 &&
last_corner_bgra[3] == 255
: opaque
? last_bgra[0] < 32 && last_bgra[1] < 40 && last_bgra[2] > 210 && ? last_bgra[0] < 32 && last_bgra[1] < 40 && last_bgra[2] > 210 &&
last_bgra[3] == 255 last_bgra[3] == 255
: last_bgra[0] < 8 && last_bgra[1] < 8 && last_bgra[2] > 248 && : last_bgra[0] < 32 && last_bgra[1] < 40 && last_bgra[2] > 225 &&
last_bgra[3] > 120 && last_bgra[3] < 136 && last_bgra[3] == 255 && last_half_bgra[0] < 8 &&
last_half_bgra[1] < 8 && last_half_bgra[2] > 248 &&
last_half_bgra[3] > 120 && last_half_bgra[3] < 136 &&
last_corner_bgra[0] == 0 && last_corner_bgra[1] == 0 && last_corner_bgra[0] == 0 && last_corner_bgra[1] == 0 &&
last_corner_bgra[2] == 0 && last_corner_bgra[3] == 0; last_corner_bgra[2] == 0 && last_corner_bgra[3] == 0;
if (expected) { if (expected) {
@@ -191,9 +219,12 @@ int main(int argc, char **argv)
} }
fprintf(stderr, fprintf(stderr,
"renderer did not produce the expected %s red BGRA frame " "renderer did not produce the expected %s red BGRA frame "
"(center BGRA=%u,%u,%u,%u; corner BGRA=%u,%u,%u,%u)\n", "(center BGRA=%u,%u,%u,%u; half BGRA=%u,%u,%u,%u; "
opaque ? "opaque" : "transparent", last_bgra[0], last_bgra[1], "corner BGRA=%u,%u,%u,%u)\n",
last_bgra[2], last_bgra[3], last_corner_bgra[0], last_corner_bgra[1], zoom ? "200%-zoomed" : opaque ? "opaque" : "transparent",
last_bgra[0], last_bgra[1],
last_bgra[2], last_bgra[3], last_half_bgra[0], last_half_bgra[1],
last_half_bgra[2], last_half_bgra[3], last_corner_bgra[0], last_corner_bgra[1],
last_corner_bgra[2], last_corner_bgra[3]); last_corner_bgra[2], last_corner_bgra[3]);
return 1; return 1;
} }

View File

@@ -114,6 +114,7 @@ static bool start_view(struct view *view, const char *renderer, const char *url)
struct owb_config_header config = { struct owb_config_header config = {
.magic = OWB_CONFIG_MAGIC, .version = OWB_PROTOCOL_VERSION, .magic = OWB_CONFIG_MAGIC, .version = OWB_PROTOCOL_VERSION,
.width = width, .height = height, .fps = 20, .transparent = 0, .width = width, .height = height, .fps = 20, .transparent = 0,
.zoom_percent = 100,
.hardware_acceleration = 1, .url_length = (uint32_t)strlen(url), .css_length = 0, .hardware_acceleration = 1, .url_length = (uint32_t)strlen(url), .css_length = 0,
}; };
return write_all(view->control, &config, sizeof(config)) && return write_all(view->control, &config, sizeof(config)) &&