diff --git a/.gitignore b/.gitignore index edd5b75..20a8215 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ wheels/ .venv kwin_capture_screen +portal_capture_frame +*.moc diff --git a/Makefile b/Makefile index c1521d5..bd78e70 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,39 @@ CXX ?= g++ PKG_CONFIG ?= pkg-config +PREFIX ?= /usr/local +BIN_DIR ?= $(PREFIX)/bin +APPLICATIONS_DIR ?= $(PREFIX)/share/applications +SYSTEM_HELPER := $(BIN_DIR)/reforger-kwin-capture +DESKTOP_FILE := org.codex.reforger-kwin-capture.desktop QT_CFLAGS := $(shell $(PKG_CONFIG) --cflags Qt6Core Qt6DBus) QT_LIBS := $(shell $(PKG_CONFIG) --libs Qt6Core Qt6DBus) +PORTAL_CFLAGS := $(shell $(PKG_CONFIG) --cflags Qt6Core Qt6DBus Qt6Gui gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0) +PORTAL_LIBS := $(shell $(PKG_CONFIG) --libs Qt6Core Qt6DBus Qt6Gui gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0) +MOC ?= /usr/lib/qt6/moc -.PHONY: all clean +.PHONY: all clean install-kwin-auth uninstall-kwin-auth -all: kwin_capture_screen +all: kwin_capture_screen portal_capture_frame -kwin_capture_screen: kwin_capture_screen.cpp - $(CXX) -std=c++17 -O2 -Wall -Wextra $(QT_CFLAGS) $< -o $@ $(QT_LIBS) +kwin_capture_screen: kwin_capture_screen.cpp kwin_capture_screen.moc + $(CXX) -std=c++17 -O2 -Wall -Wextra -fPIC $(QT_CFLAGS) $< -o $@ $(QT_LIBS) + +kwin_capture_screen.moc: kwin_capture_screen.cpp + $(MOC) $< -o $@ + +portal_capture_frame: portal_capture_frame.cpp portal_capture_frame.moc + $(CXX) -std=c++17 -O2 -Wall -Wextra -fPIC $(PORTAL_CFLAGS) $< -o $@ $(PORTAL_LIBS) + +portal_capture_frame.moc: portal_capture_frame.cpp + $(MOC) $< -o $@ + +install-kwin-auth: kwin_capture_screen $(DESKTOP_FILE) + install -Dm755 kwin_capture_screen "$(DESTDIR)$(SYSTEM_HELPER)" + install -Dm644 $(DESKTOP_FILE) "$(DESTDIR)$(APPLICATIONS_DIR)/$(DESKTOP_FILE)" + +uninstall-kwin-auth: + rm -f "$(DESTDIR)$(SYSTEM_HELPER)" "$(DESTDIR)$(APPLICATIONS_DIR)/$(DESKTOP_FILE)" clean: - rm -f kwin_capture_screen + rm -f kwin_capture_screen kwin_capture_screen.moc portal_capture_frame portal_capture_frame.moc diff --git a/README.md b/README.md index 744cb38..3692925 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,127 @@ # Anti Prestige Tool -Prototype queue-position reader for Arma Reforger. +Queue-position reader for Arma Reforger on Linux. -## Current State +The tool captures the Reforger queue screen, crops the queue-number region, and reads the orange UI digits with a small built-in matcher. It does not use Tesseract, OpenCV, or Pillow. -The working script is: +## Install + +Install system dependencies: ```bash -python3 reforger_queue_read.py +# Arch example +sudo pacman -S --needed base-devel uv qt6-base gstreamer gst-plugins-base gst-plugin-pipewire imagemagick xdg-desktop-portal ``` -It reads the queue number by: - -1. Cropping the queue-number region. -2. Thresholding orange UI pixels. -3. Splitting connected digit components. -4. Matching digits against synthetic `Roboto-Condensed` templates. - -No Tesseract/OpenCV/Pillow dependency is currently required. - -## Dataset Validation - -Dataset: +You also need a portal backend for your desktop: ```text -/home/scootz/Pictures/codex-dataset +KDE: xdg-desktop-portal-kde +GNOME: xdg-desktop-portal-gnome +wlr: xdg-desktop-portal-wlr ``` -Validation command: +Build the local helpers: ```bash -python3 reforger_queue_read.py --dataset /home/scootz/Pictures/codex-dataset --expect-filenames +make ``` -Current result: +No sudo install is required for the default Portal/PipeWire capture path. -```text -summary: 14 passed, 0 failed -``` +## Use -The default crop is derived from this 2560x1440 reference: - -```text -reference-size: 2560x1440 -reference-crop: x=1050 y=620 width=100 height=60 -scale-mode: width -``` - -`scale-mode=width` means the crop scales uniformly from the screenshot width. This handles the current dataset better than independent width/height scaling because the dataset images are slightly shorter than 1440 pixels, while the UI coordinates still match a 1440p layout. - -## Real Screenshot Calibration - -Dataset: - -```text -datasets/scootz-dataset -``` - -Files: - -```text -1920x1080.png -2160x1440.png # actual image size is 2560x1440 -``` - -Validation command: +Preferred command: ```bash -python3 reforger_queue_read.py --dataset datasets/scootz-dataset --show-crop --debug +uv run reforger_queue_read.py --portal-window --show-crop --debug ``` -Current result: +On first run, your desktop should show a window-sharing picker. Select the Arma Reforger window. If the portal grants persistence, the tool stores a restore token here: + +```text +~/.local/state/anti-prestige-tool/portal-window-restore-token +``` + +Later runs reuse that token: + +```bash +uv run reforger_queue_read.py --portal-window --show-crop --debug +``` + +If the saved window selection is wrong or stale: + +```bash +uv run reforger_queue_read.py --portal-window --portal-reselect --show-crop --debug +``` + +Save the captured input image while debugging: + +```bash +uv run reforger_queue_read.py --portal-window --save-input /tmp/apt-portal-window.png --show-crop --debug +``` + +## Optional KDE/KWin Mode + +KWin mode can automatically find the Arma window by KWin metadata and can rebind after the game restarts. It is KDE-specific and requires installing an authorized helper. + +Install the helper: + +```bash +sudo make install-kwin-auth +kbuildsycoca6 +``` + +Run: + +```bash +uv run reforger_queue_read.py --kwin-window auto --show-crop --debug +``` + +If auto matching is ambiguous, list windows and pass the PID or internal id: + +```bash +uv run reforger_queue_read.py --list-kwin-windows +uv run reforger_queue_read.py --kwin-window 53561 --show-crop --debug +uv run reforger_queue_read.py --kwin-window-id '{77a38955-a827-479c-971b-af8de226ac7b}' --show-crop --debug +``` + +The auth install places: + +```text +/usr/local/bin/reforger-kwin-capture +/usr/local/share/applications/org.codex.reforger-kwin-capture.desktop +``` + +The desktop file must be in a root-owned application directory because KWin does not trust user-writable desktop entries for restricted screenshot interfaces. + +## Other Inputs + +Read an existing screenshot: + +```bash +uv run reforger_queue_read.py --image /path/to/screenshot.png --show-crop --debug +``` + +Use Spectacle/KDE screen capture: + +```bash +uv run reforger_queue_read.py --capture --screen DP-3 --show-crop --debug +``` + +Use an OBS scene screenshot: + +```bash +uv run reforger_queue_read.py --obs-scene APT --show-crop --debug +``` + +Validate the included dataset: + +```bash +uv run reforger_queue_read.py --dataset datasets/scootz-dataset --show-crop +``` + +Expected current output: ```text datasets/scootz-dataset/1920x1080.png crop=788,465,75,45 @@ -79,124 +130,16 @@ datasets/scootz-dataset/2160x1440.png crop=1050,620,100,60 datasets/scootz-dataset/2160x1440.png 24 ``` -Measured digit box positions: +## Notes + +Default crop reference: ```text -1440p digit bbox abs=(1080,636)-(1118,666) -1440p digit bbox rel=(0.421875,0.441667)-(0.436719,0.462500) - -1080p digit bbox abs=(810,477)-(838,500) -1080p digit bbox rel=(0.421875,0.441667)-(0.436458,0.462963) +reference-size: 2560x1440 +reference-crop: 1050,620,100,60 +scale-mode: width ``` -The match is exact enough to use width-scaled coordinates for normal 16:9 1080p and 1440p users. +Portal/PipeWire is easiest to install, but restore behavior depends on the desktop portal. If the selected window disappears after an Arma restart, the portal may show the picker again. -Default search crop relative to a 2560x1440 screen: - -```text -x=1050 / 2560 = 0.410156 -y=620 / 1440 = 0.430556 -w=100 / 2560 = 0.039063 -h=60 / 1440 = 0.041667 -``` - -Resolved default crop examples: - -```text -2560x1440 -> x=1050 y=620 width=100 height=60 -1920x1080 -> x=788 y=465 width=75 height=45 -``` - -## Single-Monitor Capture Direction - -The old approach captured the whole KDE desktop across all screens. That is not ideal. - -List available KWin outputs: - -```bash -python3 reforger_queue_read.py --list-screens -``` - -Example output: - -```text -DP-2 enabled 0,360,1920x1080 -DP-3 enabled 1920,0,2560x1440 -``` - -Preferred live usage is to pass the screen containing Reforger explicitly: - -```bash -python3 reforger_queue_read.py --capture --screen DP-3 --show-crop --debug -``` - -The tool first tries KWin's screen-specific screenshot API. On this machine KWin currently rejects that direct API for an untrusted process, so the tool falls back to: - -1. Capturing the full Spectacle desktop. -2. Cropping to the requested KWin output geometry. -3. Applying the queue-number crop inside that monitor image. - -This still avoids the "wrong current monitor" problem. It does not yet avoid the temporary full-desktop screenshot in the fallback path. - -The script also supports Spectacle's current-monitor capture: - -```bash -python3 reforger_queue_read.py --capture -``` - -This uses: - -```bash -spectacle --background --current --nonotify --output -``` - -That should capture only one monitor. For this to work reliably, Reforger should be focused or the relevant monitor should be the current KDE monitor. - -Whole-desktop capture is still available for fallback: - -```bash -python3 reforger_queue_read.py --capture --capture-mode fullscreen -``` - -Live DP-3 test result from 2026-05-01: - -```text -crop=1050,620,100,60 -19 -``` - -## Useful Commands - -Read a full single-monitor screenshot: - -```bash -python3 reforger_queue_read.py --image /path/to/screenshot.png --debug -``` - -Show the crop selected for a screenshot: - -```bash -python3 reforger_queue_read.py --image /path/to/screenshot.png --show-crop --debug -``` - -Read a full screenshot with an explicit crop: - -```bash -python3 reforger_queue_read.py --image /path/to/screenshot.png --crop 1050,620,100,60 --debug -``` - -Read already-cropped number images: - -```bash -python3 reforger_queue_read.py --dataset /path/to/cropped-digits --cropped --expect-filenames -``` - -## Next Step - -Get real full screenshots from the game on the actual monitor setup and validate: - -```bash -python3 reforger_queue_read.py --image /path/to/real-full-screenshot.png --debug -``` - -If the default crop misses, use ImageMagick to crop around the queue value, then update `--reference-crop`. +KWin mode is the best KDE automation path, but it has the extra authorization install step. diff --git a/datasets/scootz-dataset/1920x1080.png b/datasets/scootz-dataset/1920x1080.png new file mode 100644 index 0000000..108d795 Binary files /dev/null and b/datasets/scootz-dataset/1920x1080.png differ diff --git a/datasets/scootz-dataset/2160x1440.png b/datasets/scootz-dataset/2160x1440.png new file mode 100644 index 0000000..0b1be8e Binary files /dev/null and b/datasets/scootz-dataset/2160x1440.png differ diff --git a/kwin_capture_screen.cpp b/kwin_capture_screen.cpp index deb80ff..b2b3f3d 100644 --- a/kwin_capture_screen.cpp +++ b/kwin_capture_screen.cpp @@ -4,34 +4,90 @@ #include #include #include +#include #include +#include +#include +#include +#include #include +#include #include -int main(int argc, char *argv[]) -{ - QCoreApplication app(argc, argv); - QTextStream err(stderr); +#include - if (argc != 3) { - err << "usage: " << argv[0] << " OUTPUT_NAME OUTPUT_FILE\n"; - return 64; +class WindowReceiver : public QObject +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.codex.ReforgerQueueKWinProbe") + +public: + QString json; + QString error; + bool done = false; + +public slots: + void WindowsJson(const QString &payload) + { + json = payload; + done = true; + QCoreApplication::quit(); } - const QString outputName = QString::fromLocal8Bit(argv[1]); - const QString outputPath = QString::fromLocal8Bit(argv[2]); + void Error(const QString &message) + { + error = message; + done = true; + QCoreApplication::quit(); + } +}; +QDBusConnection sessionBus() +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (bus.isConnected()) { + return bus; + } + + QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + if (runtimeDir.isEmpty()) { + runtimeDir = QStringLiteral("/run/user/%1").arg(getuid()); + } + + return QDBusConnection::connectToBus( + QStringLiteral("unix:path=%1/bus").arg(runtimeDir), + QStringLiteral("reforger-queue-session")); +} + +QString jsString(const QString &value) +{ + QJsonArray array; + array.append(value); + QByteArray encoded = QJsonDocument(array).toJson(QJsonDocument::Compact); + encoded.chop(1); + encoded.remove(0, 1); + return QString::fromUtf8(encoded); +} + +int capture(const QString &method, const QString &target, const QString &outputPath, QTextStream &out, QTextStream &err) +{ QFile file(outputPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { err << "failed to open output file: " << file.errorString() << "\n"; return 1; } + QDBusConnection bus = sessionBus(); + if (!bus.isConnected()) { + err << "failed to connect to the session D-Bus: " << bus.lastError().message() << "\n"; + return 1; + } + QDBusInterface iface( QStringLiteral("org.kde.KWin"), QStringLiteral("/org/kde/KWin/ScreenShot2"), QStringLiteral("org.kde.KWin.ScreenShot2"), - QDBusConnection::sessionBus()); + bus); if (!iface.isValid()) { err << "failed to connect to KWin ScreenShot2: " @@ -41,19 +97,238 @@ int main(int argc, char *argv[]) QVariantMap options; QDBusUnixFileDescriptor fd(file.handle()); - QDBusReply reply = iface.call( - QStringLiteral("CaptureScreen"), - outputName, - options, - QVariant::fromValue(fd)); + QDBusReply reply = iface.call(method, target, options, QVariant::fromValue(fd)); file.close(); if (!reply.isValid()) { - err << "CaptureScreen failed for " << outputName << ": " + err << method << " failed for " << target << ": " << reply.error().name() << ": " << reply.error().message() << "\n"; return 1; } + QJsonObject resultJson; + const QVariantMap resultMap = reply.value(); + for (auto it = resultMap.constBegin(); it != resultMap.constEnd(); ++it) { + resultJson.insert(it.key(), QJsonValue::fromVariant(it.value())); + } + out << QJsonDocument(resultJson).toJson(QJsonDocument::Compact) << "\n"; + return 0; } + +QString windowProbeScript(const QString &service, const QString &path, const QString &interface) +{ + return QStringLiteral(R"JS( +function safeString(value) { + if (value === undefined || value === null) { + return ""; + } + return String(value); +} + +function safeNumber(value) { + var number = Number(value); + return isNaN(number) ? 0 : number; +} + +function safeBool(value) { + return Boolean(value); +} + +function prop(object, name, fallback) { + try { + if (object[name] === undefined || object[name] === null) { + return fallback; + } + return object[name]; + } catch (error) { + return fallback; + } +} + +function rect(value) { + if (!value) { + return {"x": 0, "y": 0, "width": 0, "height": 0}; + } + return { + "x": Math.round(safeNumber(prop(value, "x", 0))), + "y": Math.round(safeNumber(prop(value, "y", 0))), + "width": Math.round(safeNumber(prop(value, "width", 0))), + "height": Math.round(safeNumber(prop(value, "height", 0))) + }; +} + +function outputName(window) { + try { + if (window.output && window.output.name) { + return String(window.output.name); + } + } catch (error) { + } + return ""; +} + +function windowInfo(window) { + var geometry = rect(prop(window, "frameGeometry", prop(window, "bufferGeometry", null))); + return { + "internalId": safeString(prop(window, "internalId", "")), + "pid": safeNumber(prop(window, "pid", 0)), + "caption": safeString(prop(window, "caption", "")), + "resourceClass": safeString(prop(window, "resourceClass", "")), + "resourceName": safeString(prop(window, "resourceName", "")), + "windowRole": safeString(prop(window, "windowRole", "")), + "desktopFileName": safeString(prop(window, "desktopFileName", "")), + "output": outputName(window), + "frameGeometry": geometry, + "normalWindow": safeBool(prop(window, "normalWindow", false)), + "fullScreen": safeBool(prop(window, "fullScreen", false)), + "minimized": safeBool(prop(window, "minimized", false)), + "hidden": safeBool(prop(window, "hidden", false)), + "keepAbove": safeBool(prop(window, "keepAbove", false)), + "skipTaskbar": safeBool(prop(window, "skipTaskbar", false)) + }; +} + +try { + var windows = workspace.windowList().map(windowInfo); + callDBus(%1, %2, %3, "WindowsJson", JSON.stringify(windows)); +} catch (error) { + callDBus(%1, %2, %3, "Error", safeString(error && error.stack ? error.stack : error)); +} +)JS") + .arg(jsString(service), jsString(path), jsString(interface)); +} + +int listWindows(QCoreApplication &app, QTextStream &out, QTextStream &err) +{ + QDBusConnection bus = sessionBus(); + if (!bus.isConnected()) { + err << "failed to connect to the session D-Bus: " << bus.lastError().message() << "\n"; + return 1; + } + + const QString service = QStringLiteral("org.codex.ReforgerQueueKWinProbe%1").arg(QCoreApplication::applicationPid()); + const QString path = QStringLiteral("/Probe"); + const QString interface = QStringLiteral("org.codex.ReforgerQueueKWinProbe"); + + WindowReceiver receiver; + if (!bus.registerService(service)) { + err << "failed to register D-Bus service " << service << ": " << bus.lastError().message() << "\n"; + return 1; + } + if (!bus.registerObject(path, &receiver, QDBusConnection::ExportAllSlots)) { + err << "failed to register D-Bus object " << path << ": " << bus.lastError().message() << "\n"; + bus.unregisterService(service); + return 1; + } + + QTemporaryFile scriptFile; + scriptFile.setFileTemplate(QDir::tempPath() + QStringLiteral("/reforger-kwin-window-probe-XXXXXX.js")); + if (!scriptFile.open()) { + err << "failed to create temporary KWin script: " << scriptFile.errorString() << "\n"; + bus.unregisterObject(path); + bus.unregisterService(service); + return 1; + } + scriptFile.write(windowProbeScript(service, path, interface).toUtf8()); + scriptFile.close(); + + const QString pluginName = QStringLiteral("reforger-queue-window-probe-%1").arg(QCoreApplication::applicationPid()); + QDBusInterface scripting( + QStringLiteral("org.kde.KWin"), + QStringLiteral("/Scripting"), + QStringLiteral("org.kde.kwin.Scripting"), + bus); + + if (!scripting.isValid()) { + err << "failed to connect to KWin scripting: " << scripting.lastError().message() << "\n"; + bus.unregisterObject(path); + bus.unregisterService(service); + return 1; + } + + QDBusReply loadReply = scripting.call(QStringLiteral("loadScript"), scriptFile.fileName(), pluginName); + if (!loadReply.isValid() || loadReply.value() < 0) { + err << "failed to load KWin window probe script: " + << loadReply.error().name() << ": " << loadReply.error().message() << "\n"; + bus.unregisterObject(path); + bus.unregisterService(service); + return 1; + } + + const int scriptId = loadReply.value(); + QDBusInterface script( + QStringLiteral("org.kde.KWin"), + QStringLiteral("/Scripting/Script%1").arg(scriptId), + QStringLiteral("org.kde.kwin.Script"), + bus); + QDBusMessage runReply = script.call(QStringLiteral("run")); + if (runReply.type() == QDBusMessage::ErrorMessage) { + err << "failed to run KWin window probe script: " + << runReply.errorName() << ": " << runReply.errorMessage() << "\n"; + scripting.call(QStringLiteral("unloadScript"), pluginName); + bus.unregisterObject(path); + bus.unregisterService(service); + return 1; + } + + QTimer::singleShot(5000, &app, [&receiver]() { + if (!receiver.done) { + receiver.error = QStringLiteral("timed out waiting for KWin window probe results"); + receiver.done = true; + QCoreApplication::quit(); + } + }); + app.exec(); + + scripting.call(QStringLiteral("unloadScript"), pluginName); + bus.unregisterObject(path); + bus.unregisterService(service); + + if (!receiver.error.isEmpty()) { + err << receiver.error << "\n"; + return 1; + } + if (receiver.json.isEmpty()) { + err << "KWin window probe returned no data\n"; + return 1; + } + + out << receiver.json << "\n"; + return 0; +} + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + QTextStream out(stdout); + QTextStream err(stderr); + + const QStringList args = QCoreApplication::arguments(); + + if (args.size() == 3 && !args.at(1).startsWith(QLatin1String("--"))) { + return capture(QStringLiteral("CaptureScreen"), args.at(1), args.at(2), out, err); + } + + if (args.size() == 2 && args.at(1) == QLatin1String("--list-windows")) { + return listWindows(app, out, err); + } + + if (args.size() == 4 && args.at(1) == QLatin1String("--capture-screen")) { + return capture(QStringLiteral("CaptureScreen"), args.at(2), args.at(3), out, err); + } + + if (args.size() == 4 && args.at(1) == QLatin1String("--capture-window")) { + return capture(QStringLiteral("CaptureWindow"), args.at(2), args.at(3), out, err); + } + + err << "usage:\n" + << " " << args.value(0) << " OUTPUT_NAME OUTPUT_FILE\n" + << " " << args.value(0) << " --capture-screen OUTPUT_NAME OUTPUT_FILE\n" + << " " << args.value(0) << " --capture-window INTERNAL_ID OUTPUT_FILE\n" + << " " << args.value(0) << " --list-windows\n"; + return 64; +} + +#include "kwin_capture_screen.moc" diff --git a/org.codex.reforger-kwin-capture.desktop b/org.codex.reforger-kwin-capture.desktop new file mode 100644 index 0000000..2afa608 --- /dev/null +++ b/org.codex.reforger-kwin-capture.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Reforger KWin Capture Helper +Comment=Authorized KWin screenshot helper for the Reforger queue reader +Exec=/usr/local/bin/reforger-kwin-capture +NoDisplay=true +Terminal=false +Categories=Utility; +X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2 diff --git a/portal_capture_frame.cpp b/portal_capture_frame.cpp new file mode 100644 index 0000000..d9bed7c --- /dev/null +++ b/portal_capture_frame.cpp @@ -0,0 +1,518 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +static const char *PORTAL_SERVICE = "org.freedesktop.portal.Desktop"; +static const char *PORTAL_PATH = "/org/freedesktop/portal/desktop"; +static const char *SCREENCAST_IFACE = "org.freedesktop.portal.ScreenCast"; +static const uint SOURCE_TYPE_MONITOR = 1; +static const uint SOURCE_TYPE_WINDOW = 2; +static const uint CURSOR_MODE_HIDDEN = 1; + +struct PipeWireStream +{ + uint nodeId = 0; + QVariantMap properties; +}; + +Q_DECLARE_METATYPE(PipeWireStream) +Q_DECLARE_METATYPE(QList) + +const QDBusArgument &operator>>(const QDBusArgument &argument, PipeWireStream &stream) +{ + argument.beginStructure(); + argument >> stream.nodeId >> stream.properties; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, const PipeWireStream &stream) +{ + argument.beginStructure(); + argument << stream.nodeId << stream.properties; + argument.endStructure(); + return argument; +} + +class RequestWatcher : public QObject +{ + Q_OBJECT + +public: + uint response = 2; + QVariantMap results; + bool done = false; + QString error; + +public slots: + void Response(uint responseCode, const QVariantMap &responseResults) + { + response = responseCode; + results = responseResults; + done = true; + QCoreApplication::quit(); + } +}; + +QDBusConnection sessionBus() +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (bus.isConnected()) { + return bus; + } + + QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + if (runtimeDir.isEmpty()) { + runtimeDir = QStringLiteral("/run/user/%1").arg(getuid()); + } + + return QDBusConnection::connectToBus( + QStringLiteral("unix:path=%1/bus").arg(runtimeDir), + QStringLiteral("reforger-portal-session")); +} + +QString token() +{ + return QStringLiteral("reforger_%1_%2") + .arg(QCoreApplication::applicationPid()) + .arg(QString::number(QRandomGenerator::global()->generate(), 16)); +} + +bool waitForRequest(QCoreApplication &app, QDBusConnection &bus, const QDBusObjectPath &handle, int timeoutMs, RequestWatcher &watcher, QTextStream &err) +{ + const bool connected = bus.connect( + QString::fromLatin1(PORTAL_SERVICE), + handle.path(), + QStringLiteral("org.freedesktop.portal.Request"), + QStringLiteral("Response"), + &watcher, + SLOT(Response(uint,QVariantMap))); + if (!connected) { + err << "failed to listen for portal request response on " << handle.path() << ": " + << bus.lastError().message() << "\n"; + return false; + } + + QTimer::singleShot(timeoutMs, &app, [&watcher]() { + if (!watcher.done) { + watcher.error = QStringLiteral("timed out waiting for portal response"); + watcher.done = true; + QCoreApplication::quit(); + } + }); + app.exec(); + bus.disconnect( + QString::fromLatin1(PORTAL_SERVICE), + handle.path(), + QStringLiteral("org.freedesktop.portal.Request"), + QStringLiteral("Response"), + &watcher, + SLOT(Response(uint,QVariantMap))); + + if (!watcher.error.isEmpty()) { + err << watcher.error << "\n"; + return false; + } + if (watcher.response == 1) { + err << "portal request was cancelled\n"; + return false; + } + if (watcher.response != 0) { + err << "portal request failed with response code " << watcher.response << "\n"; + return false; + } + return true; +} + +QVariantMap callRequest( + QCoreApplication &app, + QDBusConnection &bus, + QDBusInterface &portal, + const QString &method, + const QList &args, + int timeoutMs, + QTextStream &err, + bool *ok) +{ + *ok = false; + QDBusMessage reply = portal.callWithArgumentList(QDBus::Block, method, args); + if (reply.type() == QDBusMessage::ErrorMessage) { + err << method << " failed: " << reply.errorName() << ": " << reply.errorMessage() << "\n"; + return {}; + } + if (reply.arguments().isEmpty()) { + err << method << " returned no request handle\n"; + return {}; + } + + QDBusObjectPath handle = qdbus_cast(reply.arguments().at(0)); + if (handle.path().isEmpty()) { + err << method << " returned an invalid request handle\n"; + return {}; + } + + RequestWatcher watcher; + if (!waitForRequest(app, bus, handle, timeoutMs, watcher, err)) { + return {}; + } + *ok = true; + return watcher.results; +} + +uint portalUIntProperty(QDBusConnection &bus, const QString &name) +{ + QDBusInterface props( + QString::fromLatin1(PORTAL_SERVICE), + QString::fromLatin1(PORTAL_PATH), + QStringLiteral("org.freedesktop.DBus.Properties"), + bus); + QDBusReply reply = props.call(QStringLiteral("Get"), QString::fromLatin1(SCREENCAST_IFACE), name); + if (!reply.isValid()) { + return 0; + } + return reply.value().variant().toUInt(); +} + +QList parseStreams(const QVariant &value) +{ + QList streams = qdbus_cast>(value); + if (!streams.isEmpty()) { + return streams; + } + + if (value.canConvert()) { + return qdbus_cast>(value.value()); + } + + return {}; +} + +QString variantString(const QVariantMap &map, const QString &key) +{ + const QVariant value = map.value(key); + if (value.canConvert()) { + return value.value().path(); + } + return value.toString(); +} + +void closePortalSession(QDBusConnection &bus, const QString &sessionHandle) +{ + if (sessionHandle.isEmpty()) { + return; + } + + QDBusInterface session( + QString::fromLatin1(PORTAL_SERVICE), + sessionHandle, + QStringLiteral("org.freedesktop.portal.Session"), + bus); + if (session.isValid()) { + session.call(QStringLiteral("Close")); + } +} + +int writePipeWireFrame(int fd, uint nodeId, const QString &outputPath, int timeoutMs, QTextStream &err) +{ + GstElement *pipeline = gst_pipeline_new("reforger-portal-capture"); + GstElement *source = gst_element_factory_make("pipewiresrc", "source"); + GstElement *convert = gst_element_factory_make("videoconvert", "convert"); + GstElement *filter = gst_element_factory_make("capsfilter", "filter"); + GstElement *sink = gst_element_factory_make("appsink", "sink"); + + if (!pipeline || !source || !convert || !filter || !sink) { + err << "failed to create GStreamer PipeWire capture pipeline\n"; + if (pipeline) { + gst_object_unref(pipeline); + } + return 1; + } + + const QByteArray node = QByteArray::number(nodeId); + g_object_set(source, "fd", fd, "path", node.constData(), "num-buffers", 1, nullptr); + + GstCaps *caps = gst_caps_new_simple("video/x-raw", "format", G_TYPE_STRING, "BGRA", nullptr); + g_object_set(filter, "caps", caps, nullptr); + gst_caps_unref(caps); + + g_object_set(sink, "emit-signals", FALSE, "sync", FALSE, "max-buffers", 1, "drop", TRUE, nullptr); + + gst_bin_add_many(GST_BIN(pipeline), source, convert, filter, sink, nullptr); + if (!gst_element_link_many(source, convert, filter, sink, nullptr)) { + err << "failed to link GStreamer PipeWire capture pipeline\n"; + gst_object_unref(pipeline); + return 1; + } + + if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { + err << "failed to start GStreamer PipeWire capture pipeline\n"; + gst_object_unref(pipeline); + return 1; + } + + GstSample *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(sink), timeoutMs * GST_MSECOND); + if (!sample) { + GstBus *bus = gst_element_get_bus(pipeline); + GstMessage *message = gst_bus_pop_filtered(bus, static_cast(GST_MESSAGE_ERROR | GST_MESSAGE_EOS)); + if (message && GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) { + GError *error = nullptr; + gchar *debug = nullptr; + gst_message_parse_error(message, &error, &debug); + err << "GStreamer PipeWire capture failed: " << (error ? error->message : "unknown error") << "\n"; + if (debug) { + err << debug << "\n"; + } + if (error) { + g_error_free(error); + } + g_free(debug); + } else { + err << "timed out waiting for a PipeWire video frame\n"; + } + if (message) { + gst_message_unref(message); + } + gst_object_unref(bus); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + return 1; + } + + GstCaps *sampleCaps = gst_sample_get_caps(sample); + GstVideoInfo info; + if (!sampleCaps || !gst_video_info_from_caps(&info, sampleCaps)) { + err << "failed to read video frame format from PipeWire sample\n"; + gst_sample_unref(sample); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + return 1; + } + + GstBuffer *buffer = gst_sample_get_buffer(sample); + GstMapInfo map; + if (!buffer || !gst_buffer_map(buffer, &map, GST_MAP_READ)) { + err << "failed to map PipeWire video frame\n"; + gst_sample_unref(sample); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + return 1; + } + + const int width = GST_VIDEO_INFO_WIDTH(&info); + const int height = GST_VIDEO_INFO_HEIGHT(&info); + const int stride = GST_VIDEO_INFO_PLANE_STRIDE(&info, 0); + QImage image(map.data, width, height, stride, QImage::Format_ARGB32); + const bool saved = image.copy().save(outputPath, "PNG"); + + gst_buffer_unmap(buffer, &map); + gst_sample_unref(sample); + gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + + if (!saved) { + err << "failed to write PNG output: " << outputPath << "\n"; + return 1; + } + + return 0; +} + +int captureWindow(QCoreApplication &app, const QString &outputPath, const QString &restoreToken, bool persist, int timeoutMs, QTextStream &out, QTextStream &err) +{ + QDBusConnection bus = sessionBus(); + if (!bus.isConnected()) { + err << "failed to connect to the session D-Bus: " << bus.lastError().message() << "\n"; + return 1; + } + + const uint sourceTypes = portalUIntProperty(bus, QStringLiteral("AvailableSourceTypes")); + if (!(sourceTypes & SOURCE_TYPE_WINDOW)) { + err << "desktop portal does not advertise window capture support"; + if (sourceTypes) { + err << " (AvailableSourceTypes=" << sourceTypes << ")"; + } + err << "\n"; + return 1; + } + + QDBusInterface portal( + QString::fromLatin1(PORTAL_SERVICE), + QString::fromLatin1(PORTAL_PATH), + QString::fromLatin1(SCREENCAST_IFACE), + bus); + if (!portal.isValid()) { + err << "failed to connect to desktop ScreenCast portal: " << portal.lastError().message() << "\n"; + return 1; + } + + QVariantMap createOptions; + createOptions.insert(QStringLiteral("handle_token"), token()); + createOptions.insert(QStringLiteral("session_handle_token"), token()); + bool ok = false; + QVariantMap createResults = callRequest(app, bus, portal, QStringLiteral("CreateSession"), {createOptions}, timeoutMs, err, &ok); + if (!ok) { + return 1; + } + const QString sessionHandle = variantString(createResults, QStringLiteral("session_handle")); + if (sessionHandle.isEmpty()) { + err << "portal CreateSession did not return a session handle\n"; + return 1; + } + + QVariantMap selectOptions; + selectOptions.insert(QStringLiteral("handle_token"), token()); + selectOptions.insert(QStringLiteral("types"), SOURCE_TYPE_WINDOW); + selectOptions.insert(QStringLiteral("multiple"), false); + selectOptions.insert(QStringLiteral("cursor_mode"), CURSOR_MODE_HIDDEN); + if (!restoreToken.isEmpty()) { + selectOptions.insert(QStringLiteral("restore_token"), restoreToken); + } + if (persist) { + selectOptions.insert(QStringLiteral("persist_mode"), 2u); + } + callRequest(app, bus, portal, QStringLiteral("SelectSources"), {QVariant::fromValue(QDBusObjectPath(sessionHandle)), selectOptions}, timeoutMs, err, &ok); + if (!ok) { + closePortalSession(bus, sessionHandle); + return 1; + } + + QVariantMap startOptions; + startOptions.insert(QStringLiteral("handle_token"), token()); + QVariantMap startResults = callRequest(app, bus, portal, QStringLiteral("Start"), {QVariant::fromValue(QDBusObjectPath(sessionHandle)), QString(), startOptions}, timeoutMs, err, &ok); + if (!ok) { + closePortalSession(bus, sessionHandle); + return 1; + } + const QList streams = parseStreams(startResults.value(QStringLiteral("streams"))); + if (streams.isEmpty()) { + err << "portal Start did not return any PipeWire streams\n"; + closePortalSession(bus, sessionHandle); + return 1; + } + + const PipeWireStream stream = streams.first(); + if (stream.nodeId == 0) { + err << "portal returned an invalid PipeWire node id\n"; + closePortalSession(bus, sessionHandle); + return 1; + } + + QDBusReply fdReply = portal.call( + QStringLiteral("OpenPipeWireRemote"), + QVariant::fromValue(QDBusObjectPath(sessionHandle)), + QVariantMap()); + if (!fdReply.isValid()) { + err << "OpenPipeWireRemote failed: " << fdReply.error().name() << ": " << fdReply.error().message() << "\n"; + closePortalSession(bus, sessionHandle); + return 1; + } + + const QDBusUnixFileDescriptor remoteFd = fdReply.value(); + if (!remoteFd.isValid()) { + err << "OpenPipeWireRemote returned an invalid file descriptor\n"; + closePortalSession(bus, sessionHandle); + return 1; + } + + const int status = writePipeWireFrame(remoteFd.fileDescriptor(), stream.nodeId, outputPath, timeoutMs, err); + + closePortalSession(bus, sessionHandle); + + if (status != 0) { + return status; + } + + QJsonObject info; + info.insert(QStringLiteral("node_id"), static_cast(stream.nodeId)); + info.insert(QStringLiteral("session_handle"), sessionHandle); + const QString newRestoreToken = variantString(startResults, QStringLiteral("restore_token")); + if (!newRestoreToken.isEmpty()) { + info.insert(QStringLiteral("restore_token"), newRestoreToken); + } + QJsonArray streamArray; + for (const PipeWireStream &pipeWireStream : streams) { + QJsonObject streamObject; + streamObject.insert(QStringLiteral("node_id"), static_cast(pipeWireStream.nodeId)); + QJsonObject properties; + for (auto it = pipeWireStream.properties.constBegin(); it != pipeWireStream.properties.constEnd(); ++it) { + properties.insert(it.key(), QJsonValue::fromVariant(it.value())); + } + streamObject.insert(QStringLiteral("properties"), properties); + streamArray.append(streamObject); + } + info.insert(QStringLiteral("streams"), streamArray); + out << QJsonDocument(info).toJson(QJsonDocument::Compact) << "\n"; + + return 0; +} + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + gst_init(&argc, &argv); + qDBusRegisterMetaType(); + qDBusRegisterMetaType>(); + + QTextStream out(stdout); + QTextStream err(stderr); + const QStringList args = QCoreApplication::arguments(); + + QString outputPath; + QString restoreToken; + bool persist = false; + int timeoutMs = 60000; + + for (int i = 1; i < args.size(); ++i) { + const QString arg = args.at(i); + if (arg == QLatin1String("--window") && i + 1 < args.size()) { + outputPath = args.at(++i); + } else if (arg == QLatin1String("--restore-token") && i + 1 < args.size()) { + restoreToken = args.at(++i); + } else if (arg == QLatin1String("--persist")) { + persist = true; + } else if (arg == QLatin1String("--timeout") && i + 1 < args.size()) { + bool ok = false; + const double seconds = args.at(++i).toDouble(&ok); + if (!ok || seconds <= 0) { + err << "invalid --timeout value\n"; + return 64; + } + timeoutMs = qRound(seconds * 1000.0); + } else { + err << "unknown or incomplete argument: " << arg << "\n"; + return 64; + } + } + + if (outputPath.isEmpty()) { + err << "usage: " << args.value(0) + << " --window OUTPUT.png [--restore-token TOKEN] [--persist] [--timeout SECONDS]\n"; + return 64; + } + + return captureWindow(app, outputPath, restoreToken, persist, timeoutMs, out, err); +} + +#include "portal_capture_frame.moc" diff --git a/reforger_queue_read.py b/reforger_queue_read.py index c9a2266..fd5128b 100644 --- a/reforger_queue_read.py +++ b/reforger_queue_read.py @@ -1,10 +1,17 @@ #!/usr/bin/env python3 import argparse +import base64 +import hashlib +import json import os import re +import socket +import struct import subprocess import sys import tempfile +import time +import uuid DEFAULT_REFERENCE_SIZE = (2560, 1440) @@ -12,6 +19,18 @@ DEFAULT_REFERENCE_CROP = (1050, 620, 100, 60) DEFAULT_FONT = "Roboto-Condensed" DEFAULT_POINTSIZE = 43 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +SYSTEM_KWIN_HELPER = "/usr/local/bin/reforger-kwin-capture" +DEFAULT_PORTAL_RESTORE_TOKEN = os.path.expanduser( + "~/.local/state/anti-prestige-tool/portal-window-restore-token" +) +OBS_WEBSOCKET_CONFIG = os.path.expanduser("~/.config/obs-studio/plugin_config/obs-websocket/config.json") +OBS_DEFAULT_HOST = "127.0.0.1" +OBS_DEFAULT_PORT = 4455 +OBS_DEFAULT_SCENE = "APT" +OBS_DEFAULT_SOURCE = "APT Capture" +OBS_CANVAS_SIZE = (1920, 1080) +OBS_PIPEWIRE_INPUT_KIND = "pipewire-screen-capture-source" +OBS_RESELECT_TIMEOUT = 30.0 def run_bytes(args): @@ -23,6 +42,471 @@ def run_bytes(args): sys.exit(f"command failed ({exc.returncode}): {' '.join(args)}") +def run_text(args): + return run_bytes(args).decode("utf-8", "replace") + + +def obs_is_running(): + return subprocess.run( + ["pgrep", "-x", "obs"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode == 0 + + +def tcp_port_open(host, port, timeout=0.25): + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def read_obs_websocket_config(path=OBS_WEBSOCKET_CONFIG): + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except FileNotFoundError: + return {} + except json.JSONDecodeError as exc: + sys.exit(f"failed to parse OBS WebSocket config {path}: {exc}") + + +def write_obs_websocket_config(config, path=OBS_WEBSOCKET_CONFIG): + directory = os.path.dirname(path) + os.makedirs(directory, exist_ok=True) + temp_path = f"{path}.tmp" + with open(temp_path, "w", encoding="utf-8") as handle: + json.dump(config, handle, indent=2) + handle.write("\n") + os.replace(temp_path, path) + + +def ensure_obs_websocket_config_enabled(path=OBS_WEBSOCKET_CONFIG): + config = read_obs_websocket_config(path) + changed = not config.get("server_enabled", False) + + config.setdefault("alerts_enabled", False) + config.setdefault("auth_required", True) + config.setdefault("first_load", False) + config.setdefault("server_port", OBS_DEFAULT_PORT) + config["server_enabled"] = True + + if config.get("auth_required", True) and not config.get("server_password"): + sys.exit(f"OBS WebSocket auth is enabled but {path} has no server_password") + + if changed: + write_obs_websocket_config(config, path) + + return config, changed + + +def launch_obs(scene): + subprocess.Popen( + ["obs", "--scene", scene, "--minimize-to-tray"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + +def wait_for_port(host, port, timeout=15.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if tcp_port_open(host, port, timeout=0.25): + return True + time.sleep(0.25) + return False + + +def prepare_obs(host, port, scene, config_path=OBS_WEBSOCKET_CONFIG): + running = obs_is_running() + config, config_changed = ensure_obs_websocket_config_enabled(config_path) + effective_port = int(config.get("server_port", port)) + + if running and not tcp_port_open(host, effective_port): + if config_changed: + sys.exit( + "Enabled OBS WebSocket in the OBS config, but OBS is already running. " + "Restart OBS, then rerun this command." + ) + sys.exit( + f"OBS is running, but OBS WebSocket is not listening on {host}:{effective_port}. " + "Enable OBS WebSocket or restart OBS." + ) + + if not running: + launch_obs(scene) + if not wait_for_port(host, effective_port): + sys.exit(f"started OBS, but OBS WebSocket did not become available on {host}:{effective_port}") + + return config, effective_port + + +class ObsWebSocketClient: + def __init__(self, host, port, password=None, timeout=5.0): + self.host = host + self.port = port + self.password = password or "" + self.timeout = timeout + self.sock = None + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc, traceback): + self.close() + + def connect(self): + self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout) + self._http_upgrade() + hello = self.recv_json() + if hello.get("op") != 0: + raise RuntimeError(f"expected OBS Hello, got: {hello}") + + identify = {"rpcVersion": 1, "eventSubscriptions": 0} + auth = hello.get("d", {}).get("authentication") + if auth: + identify["authentication"] = self.auth_response(auth) + + self.send_json({"op": 1, "d": identify}) + identified = self.recv_json() + if identified.get("op") != 2: + raise RuntimeError(f"OBS identification failed: {identified}") + + def close(self): + if self.sock: + try: + self.sock.close() + finally: + self.sock = None + + def _http_upgrade(self): + key = base64.b64encode(os.urandom(16)).decode("ascii") + request = ( + f"GET / HTTP/1.1\r\n" + f"Host: {self.host}:{self.port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: obswebsocket.json\r\n" + "\r\n" + ) + self.sock.sendall(request.encode("ascii")) + response = b"" + while b"\r\n\r\n" not in response: + chunk = self.sock.recv(4096) + if not chunk: + raise RuntimeError("OBS WebSocket closed during HTTP upgrade") + response += chunk + status_line = response.split(b"\r\n", 1)[0] + if b" 101 " not in status_line: + raise RuntimeError(f"OBS WebSocket upgrade failed: {status_line.decode('ascii', 'replace')}") + + def auth_response(self, auth): + salt = auth["salt"] + challenge = auth["challenge"] + secret = base64.b64encode(hashlib.sha256((self.password + salt).encode("utf-8")).digest()).decode("ascii") + return base64.b64encode(hashlib.sha256((secret + challenge).encode("utf-8")).digest()).decode("ascii") + + def read_exact(self, length): + chunks = [] + remaining = length + while remaining: + chunk = self.sock.recv(remaining) + if not chunk: + raise RuntimeError("OBS WebSocket closed unexpectedly") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + def recv_frame(self): + header = self.read_exact(2) + first, second = header + opcode = first & 0x0F + masked = bool(second & 0x80) + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", self.read_exact(2))[0] + elif length == 127: + length = struct.unpack("!Q", self.read_exact(8))[0] + mask = self.read_exact(4) if masked else None + payload = self.read_exact(length) if length else b"" + if mask: + payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) + return opcode, payload + + def send_frame(self, opcode, payload): + if isinstance(payload, str): + payload = payload.encode("utf-8") + mask = os.urandom(4) + length = len(payload) + if length < 126: + header = struct.pack("!BB", 0x80 | opcode, 0x80 | length) + elif length <= 0xFFFF: + header = struct.pack("!BBH", 0x80 | opcode, 0x80 | 126, length) + else: + header = struct.pack("!BBQ", 0x80 | opcode, 0x80 | 127, length) + masked_payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) + self.sock.sendall(header + mask + masked_payload) + + def recv_json(self): + while True: + opcode, payload = self.recv_frame() + if opcode == 1: + return json.loads(payload.decode("utf-8")) + if opcode == 8: + raise RuntimeError("OBS WebSocket closed the connection") + if opcode == 9: + self.send_frame(10, payload) + + def send_json(self, message): + self.send_frame(1, json.dumps(message, separators=(",", ":"))) + + def request(self, request_type, request_data=None): + request_id = str(uuid.uuid4()) + message = { + "op": 6, + "d": { + "requestType": request_type, + "requestId": request_id, + }, + } + if request_data is not None: + message["d"]["requestData"] = request_data + self.send_json(message) + + while True: + response = self.recv_json() + if response.get("op") != 7: + continue + data = response.get("d", {}) + if data.get("requestId") != request_id: + continue + status = data.get("requestStatus", {}) + if not status.get("result"): + comment = status.get("comment", "no error details") + raise RuntimeError(f"OBS request {request_type} failed: {comment}") + return data.get("responseData", {}) + + +def decode_obs_image_data(image_data): + if "," in image_data: + image_data = image_data.split(",", 1)[1] + return base64.b64decode(image_data) + + +def save_obs_scene_screenshot(output, scene, host, port, password): + with ObsWebSocketClient(host, port, password=password) as client: + scenes = client.request("GetSceneList").get("scenes", []) + scene_names = {scene_info.get("sceneName") for scene_info in scenes} + if scene not in scene_names: + available = ", ".join(sorted(name for name in scene_names if name)) + sys.exit(f"OBS scene {scene!r} was not found. Available scenes: {available}") + + data = client.request( + "GetSourceScreenshot", + { + "sourceName": scene, + "imageFormat": "png", + }, + ) + image_bytes = decode_obs_image_data(data["imageData"]) + with open(output, "wb") as handle: + handle.write(image_bytes) + + +def scene_names(client): + return {scene.get("sceneName") for scene in client.request("GetSceneList").get("scenes", [])} + + +def ensure_obs_scene_exists(client, scene): + if scene not in scene_names(client): + client.request("CreateScene", {"sceneName": scene}) + + +def ensure_obs_video_settings(client): + settings = client.request("GetVideoSettings") + width, height = OBS_CANVAS_SIZE + if ( + settings.get("baseWidth") == width + and settings.get("baseHeight") == height + and settings.get("outputWidth") == width + and settings.get("outputHeight") == height + ): + return + + client.request( + "SetVideoSettings", + { + "baseWidth": width, + "baseHeight": height, + "outputWidth": width, + "outputHeight": height, + "fpsNumerator": settings.get("fpsNumerator", 60), + "fpsDenominator": settings.get("fpsDenominator", 1), + }, + ) + + +def get_scene_items(client, scene): + return client.request("GetSceneItemList", {"sceneName": scene}).get("sceneItems", []) + + +def find_pipewire_scene_item(client, scene): + fallback = None + for item in get_scene_items(client, scene): + if item.get("inputKind") != OBS_PIPEWIRE_INPUT_KIND: + continue + if fallback is None: + fallback = item + if item.get("sceneItemEnabled", True): + return item + return fallback + + +def disable_pipewire_scene_items(client, scene): + for item in get_scene_items(client, scene): + if item.get("inputKind") == OBS_PIPEWIRE_INPUT_KIND: + client.request( + "SetSceneItemEnabled", + { + "sceneName": scene, + "sceneItemId": item["sceneItemId"], + "sceneItemEnabled": False, + }, + ) + + +def existing_input_names(client): + return {item.get("inputName") for item in client.request("GetInputList").get("inputs", [])} + + +def unique_input_name(client, preferred): + names = existing_input_names(client) + if preferred not in names: + return preferred + + for index in range(2, 100): + candidate = f"{preferred} {index}" + if candidate not in names: + return candidate + + return f"{preferred} {int(time.time())}" + + +def create_pipewire_scene_item(client, scene, source_name): + source_name = unique_input_name(client, source_name) + response = client.request( + "CreateInput", + { + "sceneName": scene, + "inputName": source_name, + "inputKind": OBS_PIPEWIRE_INPUT_KIND, + "inputSettings": {"ShowCursor": False}, + "sceneItemEnabled": True, + }, + ) + item_id = response.get("sceneItemId") + if item_id is None: + item_id = client.request("GetSceneItemId", {"sceneName": scene, "sourceName": source_name})["sceneItemId"] + return {"sceneItemId": item_id, "sourceName": source_name} + + +def normalize_obs_scene_item_transform(client, scene, item_id): + width, height = OBS_CANVAS_SIZE + client.request( + "SetSceneItemTransform", + { + "sceneName": scene, + "sceneItemId": item_id, + "sceneItemTransform": { + "alignment": 5, + "positionX": 0.0, + "positionY": 0.0, + "rotation": 0.0, + "scaleX": 1.0, + "scaleY": 1.0, + "boundsType": "OBS_BOUNDS_SCALE_INNER", + "boundsAlignment": 0, + "boundsWidth": float(width), + "boundsHeight": float(height), + "cropToBounds": False, + "cropLeft": 0, + "cropTop": 0, + "cropRight": 0, + "cropBottom": 0, + }, + }, + ) + + +def ensure_obs_scene_ready(client, scene, source_name, force_reselect=False): + ensure_obs_scene_exists(client, scene) + ensure_obs_video_settings(client) + client.request("SetCurrentProgramScene", {"sceneName": scene}) + + if force_reselect: + disable_pipewire_scene_items(client, scene) + item = create_pipewire_scene_item(client, scene, source_name) + else: + item = find_pipewire_scene_item(client, scene) + if item is None: + item = create_pipewire_scene_item(client, scene, source_name) + + item_id = item["sceneItemId"] + source = item["sourceName"] + client.request("SetSceneItemEnabled", {"sceneName": scene, "sceneItemId": item_id, "sceneItemEnabled": True}) + normalize_obs_scene_item_transform(client, scene, item_id) + + active = client.request("GetSourceActive", {"sourceName": source}) + if not active.get("videoActive") or not active.get("videoShowing"): + sys.exit( + f"OBS source {source!r} is not active. Open OBS, select scene {scene!r}, " + "and reselect the Arma Reforger window for the PipeWire source." + ) + + return source + + +def write_obs_scene_screenshot(client, output, scene): + data = client.request( + "GetSourceScreenshot", + { + "sourceName": scene, + "imageFormat": "png", + }, + ) + image_bytes = decode_obs_image_data(data["imageData"]) + with open(output, "wb") as handle: + handle.write(image_bytes) + + +def save_obs_scene_screenshot_with_setup( + output, + scene, + source_name, + host, + port, + password, + force_reselect=False, + wait_for_nonblank=False, + timeout=OBS_RESELECT_TIMEOUT, +): + with ObsWebSocketClient(host, port, password=password) as client: + ensure_obs_scene_ready(client, scene, source_name, force_reselect=force_reselect) + deadline = time.monotonic() + timeout + while True: + write_obs_scene_screenshot(client, output, scene) + if not wait_for_nonblank or not image_is_blank(output): + return + if time.monotonic() >= deadline: + return + time.sleep(0.5) + + def ppm_from_magick(args): data = run_bytes(["magick", *args, "-alpha", "off", "-depth", "8", "ppm:-"]) idx = 0 @@ -62,6 +546,20 @@ def image_size(image_path): return int(width), int(height) +def image_is_blank(image_path, bright_threshold=12, min_nonblank_fraction=0.001): + width, height, raw = ppm_from_magick([image_path, "+repage", "-resize", "160x90!"]) + if not width or not height: + return True + + nonblank = 0 + total = width * height + for offset in range(0, len(raw), 3): + if max(raw[offset], raw[offset + 1], raw[offset + 2]) > bright_threshold: + nonblank += 1 + + return (nonblank / total) < min_nonblank_fraction + + def orange_digit_mask(width, height, raw): mask = [[False] * width for _ in range(height)] for y in range(height): @@ -191,21 +689,340 @@ def classify(component, templates): def helper_path(): + if os.path.exists(SYSTEM_KWIN_HELPER) and os.access(SYSTEM_KWIN_HELPER, os.X_OK): + return SYSTEM_KWIN_HELPER return os.path.join(SCRIPT_DIR, "kwin_capture_screen") -def capture_named_screen(output, screen): +def require_helper(): helper = helper_path() if not os.path.exists(helper): sys.exit(f"missing {helper}; run `make` in {SCRIPT_DIR}") + return helper + + +def run_helper(args, stdout=None, stderr=None): + helper = require_helper() + try: + return subprocess.run([helper, *args], check=True, stdout=stdout, stderr=stderr, text=True) + except subprocess.CalledProcessError as exc: + message = exc.stderr.strip() if exc.stderr else str(exc) + sys.exit(message) + + +def capture_named_screen(output, screen): + helper = require_helper() subprocess.run( - [helper, screen, output], + [helper, "--capture-screen", screen, output], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + text=True, ) +def kwin_windows(): + result = run_helper(["--list-windows"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + windows = json.loads(result.stdout) + except json.JSONDecodeError as exc: + sys.exit(f"failed to parse KWin window list JSON: {exc}: {result.stdout!r}") + if not isinstance(windows, list): + sys.exit("KWin window probe returned non-list JSON") + return windows + + +def rect_area(rect): + if not isinstance(rect, dict): + return 0 + return max(0, int(rect.get("width", 0) or 0)) * max(0, int(rect.get("height", 0) or 0)) + + +def window_geometry_text(window): + geometry = window.get("frameGeometry") or {} + x = int(geometry.get("x", 0) or 0) + y = int(geometry.get("y", 0) or 0) + width = int(geometry.get("width", 0) or 0) + height = int(geometry.get("height", 0) or 0) + return f"{x},{y},{width}x{height}" + + +def print_kwin_windows(windows): + print("internalId\tpid\tclass\toutput\tgeometry\tflags\tcaption") + for window in windows: + flags = [] + if window.get("normalWindow"): + flags.append("normal") + if window.get("fullScreen"): + flags.append("fullscreen") + if window.get("minimized"): + flags.append("minimized") + if window.get("hidden"): + flags.append("hidden") + if window.get("skipTaskbar"): + flags.append("skip-taskbar") + print( + f"{window.get('internalId', '')}\t" + f"{window.get('pid', '')}\t" + f"{window.get('resourceClass', '')}\t" + f"{window.get('output', '')}\t" + f"{window_geometry_text(window)}\t" + f"{','.join(flags)}\t" + f"{window.get('caption', '')}" + ) + + +def format_window_candidate(window): + return ( + f"internalId={window.get('internalId', '')} " + f"pid={window.get('pid', '')} " + f"class={window.get('resourceClass', '')!r} " + f"output={window.get('output', '')!r} " + f"geometry={window_geometry_text(window)} " + f"caption={window.get('caption', '')!r}" + ) + + +def print_window_candidates(windows, heading): + print(heading, file=sys.stderr) + candidates = sorted( + windows, + key=lambda window: ( + bool(window.get("normalWindow") or window.get("fullScreen")), + rect_area(window.get("frameGeometry")), + ), + reverse=True, + ) + for window in candidates[:20]: + print(f" {format_window_candidate(window)}", file=sys.stderr) + + +def kwin_auto_score(window): + caption = str(window.get("caption", "") or "") + resource_class = str(window.get("resourceClass", "") or "") + resource_name = str(window.get("resourceName", "") or "") + desktop_file_name = str(window.get("desktopFileName", "") or "") + window_role = str(window.get("windowRole", "") or "") + identity_text = " ".join((resource_class, resource_name, desktop_file_name, window_role)).lower() + caption_text = caption.lower() + + score = 0 + if "steam_app_1874880" in identity_text: + score += 200 + if re.search(r"\b(arma|reforger)\b", identity_text): + score += 120 + if caption_text == "arma reforger": + score += 100 + elif re.search(r"\barma reforger\b", caption_text): + score += 80 + elif re.search(r"\b(arma|reforger)\b", caption_text): + score += 20 + + if window.get("fullScreen"): + score += 15 + if window.get("normalWindow"): + score += 10 + if not window.get("minimized") and not window.get("hidden"): + score += 5 + + return score + + +def auto_kwin_window(windows): + scored = [(kwin_auto_score(window), window) for window in windows] + matches = [(score, window) for score, window in scored if score > 0] + if not matches: + print_window_candidates(windows, "no KWin window matched Arma/Reforger; current candidates:") + sys.exit("no Arma/Reforger KWin window found") + + ranked = sorted( + matches, + key=lambda item: ( + item[0], + bool(item[1].get("normalWindow") or item[1].get("fullScreen")), + not bool(item[1].get("minimized") or item[1].get("hidden")), + rect_area(item[1].get("frameGeometry")), + ), + reverse=True, + ) + top_score_value, top_window = ranked[0] + top_score = ( + top_score_value, + bool(top_window.get("normalWindow") or top_window.get("fullScreen")), + not bool(top_window.get("minimized") or top_window.get("hidden")), + rect_area(top_window.get("frameGeometry")), + ) + ambiguous = [ + window + for score, window in ranked + if ( + score, + bool(window.get("normalWindow") or window.get("fullScreen")), + not bool(window.get("minimized") or window.get("hidden")), + rect_area(window.get("frameGeometry")), + ) + == top_score + ] + if len(ambiguous) > 1: + print_window_candidates(ambiguous, "multiple equally ranked Arma/Reforger KWin windows matched:") + sys.exit("ambiguous Arma/Reforger KWin window match; pass --kwin-window-id or --kwin-window PID") + + return top_window + + +def kwin_window_by_id(windows, internal_id): + matches = [window for window in windows if str(window.get("internalId", "")) == str(internal_id)] + if not matches: + print_window_candidates(windows, f"KWin window id {internal_id!r} was not found; current candidates:") + sys.exit(f"KWin window id {internal_id!r} not found") + if len(matches) > 1: + print_window_candidates(matches, f"KWin window id {internal_id!r} matched more than one window:") + sys.exit(f"KWin window id {internal_id!r} is ambiguous") + return matches[0] + + +def kwin_window_by_pid(windows, pid): + matches = [window for window in windows if str(window.get("pid", "")) == str(pid)] + if not matches: + print_window_candidates(windows, f"KWin window PID {pid!r} was not found; current candidates:") + sys.exit(f"KWin window PID {pid!r} not found") + if len(matches) > 1: + print_window_candidates(matches, f"KWin window PID {pid!r} matched more than one window:") + sys.exit(f"KWin window PID {pid!r} is ambiguous; pass --kwin-window-id") + return matches[0] + + +def resolve_kwin_window_selector(selector): + windows = kwin_windows() + if selector == "auto": + return auto_kwin_window(windows) + if any(str(window.get("internalId", "")) == str(selector) for window in windows): + return kwin_window_by_id(windows, selector) + if str(selector).isdigit(): + return kwin_window_by_pid(windows, selector) + return kwin_window_by_id(windows, selector) + + +def capture_kwin_window(output, selector): + window = resolve_kwin_window_selector(selector) + internal_id = str(window.get("internalId", "")) + if not internal_id: + sys.exit(f"matched KWin window has no internalId: {format_window_candidate(window)}") + geometry = window.get("frameGeometry") or {} + width = int(geometry.get("width", 0) or 0) + height = int(geometry.get("height", 0) or 0) + if width <= 0 or height <= 0: + sys.exit(f"matched KWin window has invalid geometry: {format_window_candidate(window)}") + + fd, raw_path = tempfile.mkstemp(prefix="reforger-kwin-window-", suffix=".raw") + os.close(fd) + try: + result = run_helper(["--capture-window", internal_id, raw_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + capture_info = {} + if result.stdout.strip(): + try: + capture_info = json.loads(result.stdout) + except json.JSONDecodeError as exc: + sys.exit(f"failed to parse KWin CaptureWindow metadata: {exc}: {result.stdout!r}") + width = int(capture_info.get("width", width) or width) + height = int(capture_info.get("height", height) or height) + expected_size = width * height * 4 + raw_size = os.path.getsize(raw_path) + deadline = time.monotonic() + 2.0 + while raw_size != expected_size and time.monotonic() < deadline: + time.sleep(0.05) + raw_size = os.path.getsize(raw_path) + if raw_size == width * height * 4: + run_bytes(["magick", "-size", f"{width}x{height}", "-depth", "8", f"bgra:{raw_path}", output]) + else: + with open(raw_path, "rb") as handle: + header = handle.read(8) + if header.startswith(b"\x89PNG\r\n\x1a\n"): + run_bytes(["magick", raw_path, "+repage", output]) + else: + sys.exit( + f"KWin CaptureWindow returned {raw_size} raw bytes, but window geometry " + f"is {width}x{height} ({expected_size} BGRA bytes expected); " + f"metadata={capture_info}" + ) + finally: + try: + os.unlink(raw_path) + except OSError: + pass + return window + + +def portal_helper_path(): + return os.path.join(SCRIPT_DIR, "portal_capture_frame") + + +def require_portal_helper(): + helper = portal_helper_path() + if not os.path.exists(helper): + sys.exit(f"missing {helper}; run `make` in {SCRIPT_DIR}") + if not os.access(helper, os.X_OK): + sys.exit(f"portal helper is not executable: {helper}") + return helper + + +def read_portal_restore_token(path): + try: + with open(path, "r", encoding="utf-8") as handle: + return handle.read().strip() + except FileNotFoundError: + return "" + except OSError as exc: + sys.exit(f"failed to read portal restore token {path}: {exc}") + + +def write_portal_restore_token(path, token): + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + temp_path = f"{path}.tmp" + try: + with open(temp_path, "w", encoding="utf-8") as handle: + handle.write(token) + handle.write("\n") + os.replace(temp_path, path) + except OSError as exc: + sys.exit(f"failed to write portal restore token {path}: {exc}") + + +def capture_portal_window(output, restore_token_path, reselect, timeout): + helper = require_portal_helper() + restore_token = "" + if restore_token_path and not reselect: + restore_token = read_portal_restore_token(restore_token_path) + + command = [helper, "--window", output, "--timeout", str(timeout)] + if restore_token_path: + command.append("--persist") + if restore_token: + command.extend(["--restore-token", restore_token]) + + try: + result = subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + except subprocess.CalledProcessError as exc: + message = exc.stderr.strip() if exc.stderr else str(exc) + sys.exit(message) + + capture_info = {} + if result.stdout.strip(): + try: + capture_info = json.loads(result.stdout) + except json.JSONDecodeError as exc: + sys.exit(f"failed to parse portal capture metadata: {exc}: {result.stdout!r}") + + new_restore_token = str(capture_info.get("restore_token", "") or "") + if restore_token_path and new_restore_token: + write_portal_restore_token(restore_token_path, new_restore_token) + + return capture_info + + + def parse_geometry(geometry): match = re.match(r"(-?\d+),(-?\d+),(\d+)x(\d+)", geometry) if not match: @@ -395,6 +1212,68 @@ def main(): parser.add_argument("--image", help="Read from an existing image.") parser.add_argument("--dataset", help="Read every image in a dataset directory.") parser.add_argument("--list-screens", action="store_true", help="List KWin output names for use with --screen.") + parser.add_argument( + "--list-kwin-windows", + action="store_true", + help="List KWin-managed windows with internal ids for --kwin-window-id.", + ) + parser.add_argument( + "--kwin-window", + metavar="auto|INTERNAL_ID|PID", + help="Capture a KWin window by internal id or PID, or use 'auto' to match Arma/Reforger.", + ) + parser.add_argument("--kwin-window-id", help="Capture a KWin window by explicit internal id.") + parser.add_argument( + "--obs-scene", + nargs="?", + const=OBS_DEFAULT_SCENE, + help=f"Read from an OBS scene screenshot via obs-websocket. Defaults to {OBS_DEFAULT_SCENE!r}.", + ) + parser.add_argument("--obs-host", default=OBS_DEFAULT_HOST, help="OBS WebSocket host.") + parser.add_argument("--obs-port", type=int, default=OBS_DEFAULT_PORT, help="OBS WebSocket port.") + parser.add_argument("--obs-password", help="OBS WebSocket password. Defaults to the local OBS config password.") + parser.add_argument( + "--obs-source", + default=OBS_DEFAULT_SOURCE, + help="PipeWire capture source name to create if the OBS scene has no PipeWire source.", + ) + parser.add_argument( + "--obs-reselect", + action="store_true", + help="Force a fresh OBS PipeWire source before reading the scene. This cannot choose a Wayland window automatically.", + ) + parser.add_argument( + "--obs-reselect-timeout", + type=float, + default=OBS_RESELECT_TIMEOUT, + help="Seconds to wait for a non-blank OBS frame after forcing source reselection.", + ) + parser.add_argument( + "--obs-config", + default=OBS_WEBSOCKET_CONFIG, + help="Path to OBS WebSocket config. Used for password lookup and enabling the server.", + ) + parser.add_argument( + "--portal-window", + action="store_true", + help="Capture a user-selected Wayland window through xdg-desktop-portal/PipeWire.", + ) + parser.add_argument( + "--portal-restore-token", + default=DEFAULT_PORTAL_RESTORE_TOKEN, + help="Path used to store the portal restore token for later runs.", + ) + parser.add_argument( + "--portal-reselect", + action="store_true", + help="Ignore the saved portal restore token and show the portal picker again.", + ) + parser.add_argument( + "--portal-timeout", + type=float, + default=60.0, + help="Seconds to wait for portal selection and the first PipeWire frame.", + ) parser.add_argument("--cropped", action="store_true", help="Treat input image(s) as already cropped to the queue number.") parser.add_argument("--capture", action="store_true", help="Capture the current monitor with Spectacle.") parser.add_argument("--screen", help="KWin output name to capture explicitly, for example DP-3.") @@ -427,6 +1306,7 @@ def main(): parser.add_argument("--pointsize", type=int, default=DEFAULT_POINTSIZE, help="Point size used for templates.") parser.add_argument("--debug", action="store_true", help="Print component and match details.") parser.add_argument("--show-crop", action="store_true", help="Print the resolved crop used for each full-size image.") + parser.add_argument("--save-input", help="Save the live captured/KWin/OBS input image for debugging.") parser.add_argument( "--expect-filenames", action="store_true", @@ -437,13 +1317,45 @@ def main(): if args.screen and not args.capture: parser.error("--screen can only be used with --capture") - if sum(bool(value) for value in (args.image, args.dataset, args.capture, args.list_screens)) != 1: - parser.error("choose exactly one of --image, --dataset, --capture, or --list-screens") + if args.kwin_window_id and args.kwin_window: + parser.error("use only one of --kwin-window or --kwin-window-id") + + if args.obs_password and not args.obs_scene: + parser.error("--obs-password can only be used with --obs-scene") + + if args.portal_reselect and not args.portal_window: + parser.error("--portal-reselect can only be used with --portal-window") + + if args.portal_timeout <= 0: + parser.error("--portal-timeout must be positive") + + kwin_window_selector = args.kwin_window_id or args.kwin_window + if sum( + bool(value) + for value in ( + args.image, + args.dataset, + args.capture, + args.list_screens, + args.list_kwin_windows, + args.obs_scene, + kwin_window_selector, + args.portal_window, + ) + ) != 1: + parser.error( + "choose exactly one of --image, --dataset, --capture, --obs-scene, --portal-window, " + "--kwin-window/--kwin-window-id, --list-screens, or --list-kwin-windows" + ) if args.list_screens: print_screens() return 0 + if args.list_kwin_windows: + print_kwin_windows(kwin_windows()) + return 0 + if args.dataset: extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} paths = [ @@ -494,12 +1406,54 @@ def main(): temp_path = None image_path = args.image - if args.capture: + kwin_window = None + portal_info = None + if args.capture or args.obs_scene or kwin_window_selector or args.portal_window: fd, temp_path = tempfile.mkstemp(prefix="reforger-queue-", suffix=".png") os.close(fd) image_path = temp_path + + if args.capture: capture_screenshot(image_path, args.capture_mode, args.screen) + if kwin_window_selector: + kwin_window = capture_kwin_window(image_path, kwin_window_selector) + + if args.portal_window: + portal_info = capture_portal_window( + image_path, + args.portal_restore_token, + args.portal_reselect, + args.portal_timeout, + ) + + if args.obs_scene: + config, effective_port = prepare_obs(args.obs_host, args.obs_port, args.obs_scene, args.obs_config) + password = args.obs_password + if password is None and config.get("auth_required", True): + password = config.get("server_password", "") + save_obs_scene_screenshot_with_setup( + image_path, + args.obs_scene, + args.obs_source, + args.obs_host, + effective_port, + password, + force_reselect=args.obs_reselect, + wait_for_nonblank=args.obs_reselect, + timeout=args.obs_reselect_timeout, + ) + if image_is_blank(image_path): + sys.exit( + "OBS scene screenshot is blank. On this OBS/Wayland setup, PipeWire sources " + "only expose RestoreToken/ShowCursor settings over WebSocket, so the CLI cannot " + "select the Arma Reforger process automatically. Use a persistent monitor source " + "or reselect the Arma source in OBS." + ) + + if args.save_input and image_path and (args.capture or args.obs_scene or kwin_window_selector or args.portal_window): + run_bytes(["magick", image_path, "+repage", args.save_input]) + try: crop = resolve_crop(image_path, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped) if args.show_crop and crop: @@ -518,6 +1472,12 @@ def main(): print(number) if args.debug: + if kwin_window: + print(f"kwin-window {format_window_candidate(kwin_window)}") + if portal_info: + print(f"portal-window node_id={portal_info.get('node_id', '')} streams={len(portal_info.get('streams', []))}") + if portal_info.get("restore_token") and args.portal_restore_token: + print(f"portal-restore-token {args.portal_restore_token}") for digit, bbox, area, hamming, iou in details: print(f"digit={digit} bbox={bbox} area={area} hamming={hamming:.3f} iou={iou:.3f}")