diff --git a/.gitignore b/.gitignore index e3a8413..6626e84 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,8 @@ # Except additional project files !screenshot1.jpg !screenshot2.jpg -!build-windows-installer.ps1 +!build.ps1 !README_ja.md + +# Except for AI agent instructions +!AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9885280 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,254 @@ +# AGENTS.md — Branch Output Plugin for OBS Studio + +## Project Overview + +**Branch Output** is an OBS Studio plugin (shared library / module) that adds an effect filter allowing +individual sources or scenes to stream and/or record independently from the main OBS output. +It is developed and maintained by **OPENSPHERE Inc.** under the GPLv2+ license. + +- Repository language: **C++ 17** with **Qt 6** for UI +- Build system: **CMake** (≥ 3.16) +- Based on the official [obs-plugintemplate](https://github.com/obsproject/obs-plugintemplate) +- Target OBS Studio version: **≥ 30.1.0** (Qt6, x64 / ARM64 / Apple Silicon) + +--- + +## Repository Layout + +``` +branch-output/ +├── CMakeLists.txt # Top-level CMake project definition +├── CMakePresets.json # CMake presets (windows-x64, macos, linux-x86_64, CI variants) +├── buildspec.json # Build specification (name, version, dependencies, UUIDs) +├── build.ps1 # Local Windows build script (RelWithDebInfo) +├── .clang-format # C++ code style (clang-format ≥ 16) +├── .cmake-format.json # CMake code style +├── .github/ +│ ├── actions/ # Reusable format-check actions +│ └── workflows/ # CI workflows (build, format check, release) +├── cmake/ # CMake helpers & platform-specific modules +├── build-aux/ # Auxiliary build scripts (format runners) +├── data/ +│ └── locale/ # Translation files (en-US, ja-JP, zh-CN, ko-KR, etc.) +├── src/ +│ ├── plugin-main.cpp # Plugin entry point, BranchOutputFilter implementation +│ ├── plugin-main.hpp # BranchOutputFilter class declaration +│ ├── plugin-ui.cpp # OBS properties UI, filter settings UI +│ ├── plugin-support.h # Auto-generated plugin support header +│ ├── plugin-support.c.in # Template for plugin-support.c +│ ├── utils.cpp / .hpp # Utility functions, encoder helpers, profile helpers +│ ├── audio/ +│ │ ├── audio-capture.cpp / .hpp # Audio capture abstraction +│ └── UI/ +│ ├── output-status-dock.cpp / .hpp # Status dock widget +│ ├── resources.qrc # Qt resource file +│ └── images/ # Icons and images +└── release/ # Build output directory +``` + +--- + +## Build Instructions + +### Prerequisites + +| Platform | Toolchain | +|----------|-----------| +| Windows | Visual Studio 17 2022, CMake ≥ 3.16, Qt 6 | +| macOS | Xcode 15+, CMake ≥ 3.16, Qt 6 | +| Linux | GCC / Clang, CMake ≥ 3.16, Qt 6 | + +OBS Studio sources and pre-built dependencies are fetched automatically via `buildspec.json` +(obs-studio 30.1.2, obs-deps, Qt6). + +### Windows (local) + +```powershell +# Configure + Build + Install +.\build.ps1 + +# With Inno Setup installer +.\build.ps1 -installer +``` + +Or manually: + +```powershell +cmake --fresh -S . -B build_x64 -G "Visual Studio 17 2022" -A x64 +cmake --build build_x64 --config RelWithDebInfo +cmake --install build_x64 --prefix release/Package --config RelWithDebInfo +``` + +### CMake Presets + +Use `cmake --preset ` with one of: +- `windows-x64` / `windows-ci-x64` +- `macos` / `macos-ci` +- `linux-x86_64` / `linux-ci-x86_64` + +--- + +## Architecture & Key Concepts + +### Plugin Entry Point + +The plugin registers an OBS **effect filter** via `obs_source_info` (filter ID: `osi_branch_output`). +The main class is `BranchOutputFilter` (declared in `plugin-main.hpp`), which is a `QObject` subclass. + +### Core Responsibilities of BranchOutputFilter + +| Area | Description | +|------|-------------| +| **Streaming** | Creates up to `MAX_SERVICES` (8) independent streaming outputs with dedicated services, encoders, and reconnect logic. | +| **Recording** | Supports file recording with various container formats, time/size-based splitting, pause/unpause, and chapter markers. | +| **Audio** | Manages up to `MAX_AUDIO_MIXES` audio contexts via `AudioCapture` class. Supports filter audio, per-source audio, and audio track selection. | +| **Video** | Creates an OBS view (`obs_view_t`) with a private video output for per-filter encoding and resolution control. | +| **UI** | Properties panel built via OBS properties API (`plugin-ui.cpp`). Status dock (`BranchOutputStatusDock`) shows live statistics for all filters. | +| **Hotkeys** | Registers hotkey pairs for enable/disable, split recording, pause/unpause, and chapter markers. | +| **Interlock** | Can link filter activation to OBS streaming, recording, or virtual camera states. | +| **Blanking** | Uses a private solid-color source to blank output when the parent source is inactive. | + +### Threading Model + +- OBS callbacks (video render, audio filter, video tick) may run on **different threads** from the UI thread. +- `pthread_mutex_t outputMutex` protects streaming/recording output state. +- `QMutex` protects audio buffers in `AudioCapture`. +- UI updates use `QMetaObject::invokeMethod` with `Qt::QueuedConnection` for thread safety. +- Settings changes are tracked via revision counters (`storedSettingsRev` / `activeSettingsRev`) to defer restarts. + +### OBS API Usage + +The plugin heavily uses: +- `obs-module.h` — Module registration, locale, config paths +- `obs-frontend-api.h` — Profile config, scene enumeration, frontend events +- `obs.hpp` — OBS RAII wrappers (`OBSSourceAutoRelease`, `OBSEncoderAutoRelease`, `OBSOutputAutoRelease`, etc.) +- `util/deque.h`, `util/threading.h`, `util/platform.h` — Low-level utilities + +--- + +## Code Style & Formatting + +### C++ (clang-format) + +- **Standard**: C++17 +- **Column limit**: 120 +- **Indent**: 4 spaces (tabs not used) +- **Brace style**: Custom — functions use next-line braces; control statements use same-line braces +- **clang-format version**: ≥ 16 required +- Run: `clang-format -i src/**/*.cpp src/**/*.hpp` + +### CMake (cmake-format) + +- **Line width**: 120 +- **Tab size**: 2 +- Config: `.cmake-format.json` + +### CI Enforcement + +Format is checked in CI via `.github/workflows/check-format.yaml` using reusable actions +(`run-clang-format`, `run-cmake-format`). PRs and pushes to `master` are validated. + +--- + +## Coding Guidelines + +### General Rules + +1. **Follow OBS plugin conventions** — Use `obs_log()` for logging, `OBS_DECLARE_MODULE()` for entry, locale via `obs_module_text()`. +2. **RAII wrappers** — Always use `OBSSourceAutoRelease`, `OBSDataAutoRelease`, etc. instead of manual `obs_*_release()`. +3. **Thread safety** — Any data shared between OBS callbacks and UI must be guarded by mutex. Use `QMetaObject::invokeMethod` with `Qt::QueuedConnection` when calling UI methods from non-UI threads. +4. **Settings migration** — When changing settings schema, add backward-compatible migration code in the constructor (see `audio_source` migration example in `BranchOutputFilter` constructor). +5. **Qt MOC** — Classes using `Q_OBJECT` must be in headers. `AUTOMOC`, `AUTOUIC`, `AUTORCC` are enabled. + +### Naming Conventions + +- **Classes**: PascalCase (`BranchOutputFilter`, `AudioCapture`, `OutputTableRow`) +- **Methods**: camelCase (`startOutput`, `stopRecordingOutput`, `onIntervalTimerTimeout`) +- **Constants/Macros**: UPPER_SNAKE_CASE (`MAX_SERVICES`, `FILTER_ID`, `OUTPUT_MAX_RETRIES`) +- **Member variables**: camelCase, no prefix (`filterSource`, `videoEncoder`, `recordingActive`) +- **Static callbacks**: camelCase with descriptive prefix (`onEnableFilterHotkeyPressed`, `audioFilterCallback`) + +### Ternary Operators + +- **Avoid multi-line ternary expressions** — clang-format cannot reliably format nested or multi-line ternary operators (`a ? b : c`) in a readable way. Use `if`/`else if`/`else` statements instead when the expression would span multiple lines. + +### Error Handling + +- Use `obs_log(LOG_ERROR, ...)` for errors, `LOG_WARNING` for warnings, `LOG_INFO` for lifecycle events, `LOG_DEBUG` for verbose tracing. +- Check return values from OBS API calls; handle gracefully (do not crash OBS). +- Never throw C++ exceptions across OBS API boundaries. + +### Localization + +- All user-facing strings must use `obs_module_text("Key")` or the `QTStr("Key")` helper. +- Add new keys to `data/locale/en-US.ini` (primary) and ideally to `ja-JP.ini`. +- Locale files use INI format: `Key="Value"`. + +--- + +## Testing & Validation + +- There are no automated unit tests in this repository currently. +- **Manual testing** is required: load the plugin in OBS Studio, add the "Branch Output" filter, and verify streaming/recording behavior. +- Test on all supported platforms when possible (Windows x64, macOS, Linux). +- Verify Studio Mode compatibility (Branch Output ignores studio mode's program and outputs from preview). +- Verify that enabling and then disabling the Branch Output filter does not cause a crash. +- Verify that shutting down OBS does not crash when the Branch Output filter is inactive or active, respectively. +- Verify that switching scene collections does not crash when the Branch Output filter is inactive or active, respectively. +- Verify that no memory leaks are logged on OBS shutdown. +- Verify that mutex does not cause deadlocks. + +--- + +## CI / CD + +| Workflow | Trigger | Purpose | +|----------|---------|---------| +| `push.yaml` | Push to `master`/`main`/`release/**`, tags | Format check + build + release creation | +| `build-project.yaml` | Called by push/PR workflows | Multi-platform build (Windows, macOS, Linux) | +| `check-format.yaml` | Called by push workflow | clang-format and cmake-format validation | +| `pr-pull.yaml` | Pull requests | Build validation | +| `dispatch.yaml` | Manual dispatch | On-demand builds | + +Release tags follow semver: `X.Y.Z` for stable, `X.Y.Z-beta`/`X.Y.Z-rc` for pre-releases. + +--- + +## Common Tasks for AI Agents + +### Adding a New Setting / Feature + +1. Add the setting key to `BranchOutputFilter::getDefaults()` in `plugin-main.cpp`. +2. Add UI controls in `plugin-ui.cpp` (use OBS properties API: `obs_properties_add_*`). +3. Handle the setting in `startOutput()` / `stopOutput()` / `updateCallback()` as needed. +4. Add locale strings to **all** locale files under `data/locale/` (`en-US.ini`, `ja-JP.ini`, `zh-CN.ini`, `ko-KR.ini`, `de-DE.ini`, `fr-FR.ini`, `ca-ES.ini`, `ro-RO.ini`, `ru-RU.ini`, `uk-UA.ini`). `en-US.ini` is the primary (required); all others should also be updated. +5. Ensure backward compatibility — existing saved settings must still load correctly. + +### Adding a New Streaming Service Slot + +- The plugin supports up to `MAX_SERVICES` (8) service slots. +- Each slot has its own `BranchOutputStreamingContext` with output, service, and signals. +- Use `getIndexedPropNameFormat()` for indexed property names. + +### Modifying Audio Handling + +- Audio capture is abstracted through `AudioCapture` class in `src/audio/`. +- The class manages an audio buffer, supports push/pop patterns, and handles format conversion. +- Audio mixers and encoder binding are managed in `BranchOutputFilter::startOutput()`. + +### Modifying the Status Dock + +- `BranchOutputStatusDock` in `src/UI/` is a `QFrame`-based dock widget. +- It uses a `QTableWidget` with custom cell classes (`OutputTableCellItem`, `LabelCell`, `FilterCell`). +- Thread-safe updates via `QMetaObject::invokeMethod`. + +--- + +## Important Warnings + +- **Do NOT call `obs_filter_get_parent()` in the `BranchOutputFilter` constructor** — it returns `nullptr` at that point. Use `addCallback()` instead. +- **Private sources** (not visible in frontend) are intentionally excluded from status dock and timer registration. +- **Settings revisions** (`storedSettingsRev` / `activeSettingsRev`) exist to avoid stopping output during reconnect attempts. Do not bypass this mechanism. +- **Encoder compatibility** — The plugin maps "simple" encoder names to actual encoder IDs, with version-specific fallbacks (OBS 30 vs OBS 31). See `getSimpleVideoEncoder()` in `utils.hpp`. +- **Memory management** — Use OBS RAII wrappers. Raw `bfree()` / `obs_data_release()` calls are error-prone. +- **`.gitignore` uses allowlist pattern** — New top-level files/directories must be explicitly un-ignored with `!` prefix. diff --git a/CMakeLists.txt b/CMakeLists.txt index 791606b..1fd31ee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,7 +35,14 @@ if(ENABLE_QT) endif() target_sources( - ${CMAKE_PROJECT_NAME} PRIVATE src/plugin-main.cpp src/plugin-ui.cpp src/utils.cpp src/audio/audio-capture.cpp - src/UI/output-status-dock.cpp src/UI/resources.qrc) + ${CMAKE_PROJECT_NAME} + PRIVATE src/plugin-main.cpp + src/plugin-replay-buffer.cpp + src/plugin-ui.cpp + src/utils.cpp + src/audio/audio-capture.cpp + src/video/filter-video-capture.cpp + src/UI/output-status-dock.cpp + src/UI/resources.qrc) set_target_properties_plugin(${CMAKE_PROJECT_NAME} PROPERTIES OUTPUT_NAME ${_name}) diff --git a/build-windows-installer.ps1 b/build.ps1 similarity index 75% rename from build-windows-installer.ps1 rename to build.ps1 index f6f9315..bd82138 100644 --- a/build-windows-installer.ps1 +++ b/build.ps1 @@ -1,3 +1,7 @@ +Param ( + [switch]$installer +) + $BuildSpec = Get-Content -Path ./buildspec.json -Raw | ConvertFrom-Json $ProductName = $BuildSpec.name $ProductVersion = $BuildSpec.version @@ -8,4 +12,6 @@ cmake --fresh -S . -B build_x64 -Wdev -Wdeprecated -DCMAKE_SYSTEM_VERSION="10.0. cmake --build build_x64 --config RelWithDebInfo --target ALL_BUILD -- cmake --install build_x64 --prefix release/Package --config RelWithDebInfo -iscc build_x64/installer-Windows.generated.iss /O"release" /F"${OutputName}-Installer-signed" +if ($installer) { + iscc build_x64/installer-Windows.generated.iss /O"release" /F"${OutputName}-Installer-signed" +} \ No newline at end of file diff --git a/buildspec.json b/buildspec.json index 513ba2e..4a544c0 100644 --- a/buildspec.json +++ b/buildspec.json @@ -38,7 +38,7 @@ }, "name": "osi-branch-output", "displayName": "Branch Output Plugin", - "version": "1.0.7", + "version": "1.0.8", "author": "OPENSPHERE Inc.", "website": "https://opensphere.co.jp/", "email": "info@opensphere.co.jp", diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index 2bf18c3..7df5701 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -23,6 +23,8 @@ Status.Active="Actiu" Status.Paused="En pausa" Status.Pending="Pendent" Status.Stopping="Aturant" +Status.BlankSuffix=" (Negre)" +Status.BlankMutedSuffix=" (Negre+Mut)" Reset="Restableix" ResetAll="Restableix-ho tot" EnableAll="Activa-ho tot" @@ -33,7 +35,9 @@ AlwaysOn="Sempre ON" Streaming="Transmissió" Recording="Gravació" StreamingOrRecording="Transmissió o gravació" +ReplayBuffer="Memòria de repetició" VirtualCam="Càmera virtual" +AlwaysOff="Sempre OFF" StreamRecording="Gravació en flux" Path="Ruta del directori" VideoFormat="Format de contenidor" @@ -130,6 +134,10 @@ UseProfileRecordingPath="Utilitzeu la ruta de gravació del perfil" NoSpaceFileName="Genera noms de fitxer sense espai" KeepOutputBaseResolution="No reinicieu la sortida quan canviï la resolució de la font" KeepOutputBaseResolutionNote="Si s'activa, la imatge de vídeo pot quedar retallada o tenir espais en blanc quan es canvia la resolució de la font." +BlankWhenNotVisible="Enfosqueix la sortida quan la font no és a la sortida principal" +BlankWhenNotVisibleNote="Manté la transmissió/enregistrament actiu però substitueix el vídeo per negre sempre que la font no sigui visible a la sortida principal activa. Utilitzeu-ho quan no vulgueu enregistrar ni transmetre escenes fora d'antena." +MuteAudioWhenBlank="Silencia l'àudio mentre està enfosquit" +MuteAudioWhenBlankNote="Quan l'enfosquiment està actiu, també silencia les fonts d'àudio seleccionades. Utilitzeu-ho quan no vulgueu enregistrar ni transmetre l'àudio de les escenes fora d'antena." AudioSourceNote="Àudio mestre: Sortida mixta d'OBS (té 6 pistes conegudes)
Àudio de font: Àudio del conducte de filtre que es passa al filtre Branch Output.
Àudio de font: Àudio agrupat de la font després de passar per tots els filtres del conducte." SplitRecordingFileHotkey="Divideix el fitxer d'enregistrament de '%1'" SplitRecordingAllHotkey="Divideix tots els fitxers d'enregistrament de Sortida de Branca" @@ -147,3 +155,20 @@ SuspendRecordingWhenSourceCollapsedNote="Si hi ha emissió, l'enregistrament s'a AddChapterToRecordingFileHotkey="Afegeix capítol al fitxer d'enregistrament de '%1'" AddChapterToAllRecordings="Afegeix capítol a tots els enregistraments" AddChapterToRecordingAllHotkey="Afegeix capítol a tots els enregistraments de Sortida de Branca" +VideoSourceType="Font de vídeo" +VideoSourceType.LongDescription="Seleccioneu quin vídeo capturar per a la sortida.\n'Sortida de la font' captura la sortida renderitzada final de la font (després de tots els filtres).\n'Entrada de filtre' captura el vídeo a la posició d'aquest filtre a la cadena (abans d'aquest filtre i qualsevol filtre a sota)." +VideoSourceType.Source="Mescla independent (Per defecte)" +VideoSourceType.FilterInput="Entrada de filtre (Experimental)" +ReplayBuffer="Replay Buffer" +ReplayBufferDuration="Maximum Replay Time (seconds)" +Status.ReplayBuffer="Buffering" +SaveReplayBufferHotkey="Save '%1' Replay Buffer" +SaveReplayBufferAllHotkey="Save all Branch Output replay buffers" +SaveAllReplayBuffers="Save All Replay Buffers" +ReplayBuffer.Saved="Replay Saved" +AdvancedSettings="Advanced Settings" +StreamingDescription="Configureu sortides de transmissió independents a servidors RTMP/SRT (fins a 8 transmissions simultànies)." +RecordingDescription="Enregistreu la sortida de la font en un fitxer independentment de l'enregistrament principal d'OBS." +ReplayBufferDescription="Manteniu les imatges recents en un buffer de reproducció i deseu-les en qualsevol moment mitjançant una drecera de teclat." +ReplayBufferHotkeyNote="Per desar el buffer de reproducció, utilitzeu la drecera de teclat registrada a Configuració d'OBS Studio → Dreceres de teclat." +CustomAudioSourceDescription="Seleccioneu fonts d'àudio específiques en lloc de l'àudio per defecte. Admet àudio multipista amb fins a 6 pistes." diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index 4b96805..7a76744 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -23,6 +23,8 @@ Status.Active="Aktiv" Status.Paused="Pausiert" Status.Pending="Ausstehend" Status.Stopping="Stoppe" +Status.BlankSuffix=" (Schwarzbild)" +Status.BlankMutedSuffix=" (Schwarzbild+Stumm)" Reset="Zurücksetzen" ResetAll="Alle zurücksetzen" EnableAll="Alle aktivieren" @@ -33,7 +35,9 @@ AlwaysOn="Immer EIN" Streaming="Streaming" Recording="Aufnahme" StreamingOrRecording="Streaming oder Aufnahme" +ReplayBuffer="Wiederholungspuffer" VirtualCam="Virtuelle Kamera" +AlwaysOff="Immer AUS" StreamRecording="Stream-Aufzeichnung" Path="Pfad speichern" VideoFormat="Containerformat" @@ -130,6 +134,10 @@ UseProfileRecordingPath="Aufzeichnungspfad des Profils verwenden" NoSpaceFileName="Dateinamen ohne Leerzeichen generieren" KeepOutputBaseResolution="Ausgabe nicht zurücksetzen, wenn sich die Quellauflösung ändert" KeepOutputBaseResolutionNote="Wenn aktiviert, kann das Videobild beschnitten werden oder leere Bereiche aufweisen, wenn sich die Quellauflösung ändert." +BlankWhenNotVisible="Ausgabe schwärzen, wenn Quelle nicht in der Hauptausgabe ist" +BlankWhenNotVisibleNote="Lässt Stream/Aufnahme weiterlaufen, ersetzt das Video aber durch Schwarz, wenn die Quelle in der aktiven Hauptausgabe nicht sichtbar ist. Verwenden Sie dies, wenn Sie Off-Szenen nicht aufnehmen oder streamen möchten." +MuteAudioWhenBlank="Audio während Schwärzung stummschalten" +MuteAudioWhenBlankNote="Wenn die Schwärzung aktiv ist, auch die ausgewählten Audioquellen stummschalten. Verwenden Sie dies, wenn Sie Audio von Off-Szenen nicht aufnehmen oder streamen möchten." AudioSourceNote="Master-Audio: OBS-Mischausgang (hat bekanntlich 6 Spuren)
Filter-Audio: Audio der Filterpipeline, das an den Branch Output-Filter übergeben wird.
Quell-Audio: Gebündeltes Audio der Quelle, nachdem es alle Filter in der Pipeline durchlaufen hat." SplitRecordingFileHotkey="'%1' Aufnahmedatei aufteilen" SplitRecordingAllHotkey="Alle Zweigausgang-Aufnahmedateien aufteilen" @@ -147,3 +155,20 @@ SuspendRecordingWhenSourceCollapsedNote="Bei Streaming wird die Aufnahme gestopp AddChapterToRecordingFileHotkey="Kapitel zu '%1' Aufnahmedatei hinzufügen" AddChapterToAllRecordings="Kapitel zu allen Aufnahmen hinzufügen" AddChapterToRecordingAllHotkey="Kapitel zu allen Zweigausgang-Aufnahmen hinzufügen" +VideoSourceType="Videoquelle" +VideoSourceType.LongDescription="Wählen Sie aus, welches Video für die Ausgabe aufgenommen werden soll.\n'Quellenausgabe' nimmt die endgültige gerenderte Ausgabe der Quelle auf (nach allen Filtern).\n'Filtereingabe' nimmt das Video an der Position dieses Filters in der Kette auf (vor diesem Filter und allen darunterliegenden Filtern)." +VideoSourceType.Source="Unabhängiger Mix (Standard)" +VideoSourceType.FilterInput="Filtereingabe (Experimentell)" +ReplayBuffer="Wiederholungspuffer" +ReplayBufferDuration="Maximale Wiederholungszeit (Sekunden)" +Status.ReplayBuffer="Pufferung" +SaveReplayBufferHotkey="'%1' Wiederholungspuffer speichern" +SaveReplayBufferAllHotkey="Alle Branch Output Wiederholungspuffer speichern" +SaveAllReplayBuffers="Alle Wiederholungspuffer speichern" +ReplayBuffer.Saved="Wiederholung gespeichert" +AdvancedSettings="Erweiterte Einstellungen" +StreamingDescription="Konfigurieren Sie unabhängige Streaming-Ausgaben zu RTMP/SRT-Servern (bis zu 8 gleichzeitige Streams)." +RecordingDescription="Nehmen Sie die Quellenausgabe unabhängig von der OBS-Hauptaufnahme in eine Datei auf." +ReplayBufferDescription="Halten Sie aktuelle Aufnahmen im Wiederholungspuffer bereit, die per Hotkey jederzeit gespeichert werden können." +ReplayBufferHotkeyNote="Um den Wiederholungspuffer zu speichern, verwenden Sie den in OBS Studio Einstellungen → Hotkeys registrierten Hotkey." +CustomAudioSourceDescription="Wählen Sie bestimmte Audioquellen anstelle des Standardaudios. Unterstützt Mehrspuraudio mit bis zu 6 Spuren." diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index fc60982..9af5a7c 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -23,6 +23,8 @@ Status.Active="Active" Status.Paused="Paused" Status.Pending="Pending" Status.Stopping="Stopping" +Status.BlankSuffix=" (Blank)" +Status.BlankMutedSuffix=" (Blank+Mute)" Reset="Reset" ResetAll="Reset All" EnableAll="Activate All" @@ -33,7 +35,9 @@ AlwaysOn="Always ON" Streaming="Streaming" Recording="Recording" StreamingOrRecording="Streaming or Recording" +ReplayBuffer="Replay Buffer" VirtualCam="Virtual Cam" +AlwaysOff="Always OFF" StreamRecording="Stream Recording" Path="Save Path" VideoFormat="Container Format" @@ -130,6 +134,10 @@ UseProfileRecordingPath="Use profile's recording path" NoSpaceFileName="Generate File Name without Space" KeepOutputBaseResolution="Don't reset output when source resolution changes" KeepOutputBaseResolutionNote="If enabled, the video image may be cropped or have blank spaces when the source resolution is changed." +BlankWhenNotVisible="Blank output when source is not in Main Output" +BlankWhenNotVisibleNote="Keeps streaming/recording running but replaces video with black whenever the source is not visible in the active Main Output. Use this when you don't want to record or stream off-scenes." +MuteAudioWhenBlank="Mute audio while blanked" +MuteAudioWhenBlankNote="When blanking is active, also mute the selected audio sources. Use this when you don't want to record or stream audio from off-scenes." AudioSourceNote="Master Audio: OBS's mix output (It has 6 tracks as known)
Filter Audio: Filter pipeline's audio which is passed to Branch Output filter.
Source Audio: Source's bundled audio which after passed through the all filters in pipeline." SplitRecordingFileHotkey="Split '%1' Recording File" SplitRecordingAllHotkey="Split all Branch Output recording files" @@ -148,3 +156,20 @@ SuspendRecordingWhenSourceCollapsedNote="When streaming is active, recording is AddChapterToRecordingFileHotkey="Add chapter to '%1' recording file" AddChapterToAllRecordings="Add chapter to all recordings" AddChapterToRecordingAllHotkey="Add chapter to all Branch Output recordings" +VideoSourceType="Video Source" +VideoSourceType.LongDescription="Select which video to capture for the output.\n'Source Output' captures the final rendered output of the source (after all filters).\n'Filter Input' captures the video at this filter's position in the chain (before this filter and any filters below)." +VideoSourceType.Source="Independent Mix (Default)" +VideoSourceType.FilterInput="Filter Input (Experimental)" +ReplayBuffer="Replay Buffer" +ReplayBufferDuration="Maximum Replay Time (seconds)" +Status.ReplayBuffer="Buffering" +SaveReplayBufferHotkey="Save '%1' Replay Buffer" +SaveReplayBufferAllHotkey="Save all Branch Output replay buffers" +SaveAllReplayBuffers="Save All Replay Buffers" +ReplayBuffer.Saved="Replay Saved" +AdvancedSettings="Advanced Settings" +StreamingDescription="Configure independent streaming outputs to RTMP/SRT servers (up to 8 simultaneous streams)." +RecordingDescription="Record the source output to a file independently from OBS's main recording." +ReplayBufferDescription="Keep a rolling replay buffer of recent footage that can be saved on demand via hotkey." +ReplayBufferHotkeyNote="To save the replay buffer, use the hotkey registered in OBS Studio Settings → Hotkeys." +CustomAudioSourceDescription="Select specific audio sources instead of using the default audio. Supports multitrack audio with up to 6 tracks." diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index 4ad3e1f..99b643a 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -23,6 +23,8 @@ Status.Active="Actif" Status.Paused="En pause" Status.Pending="En attente" Status.Stopping="Arrêt en cours" +Status.BlankSuffix=" (Écran noir)" +Status.BlankMutedSuffix=" (Écran noir+Muet)" Reset="Réinitialiser" ResetAll="Tout réinitialiser" EnableAll="Activer tout" @@ -33,7 +35,9 @@ AlwaysOn="Toujours allumé" Streaming="Streaming" Recording="Enregistrement" StreamingOrRecording="Streaming ou enregistrement" +ReplayBuffer="Tampon de relecture" VirtualCam="Caméra virtuelle" +AlwaysOff="Toujours OFF" StreamRecording="Enregistrement de stream en direct" Path="Enregistrer le chemin" VideoFormat="Format du conteneur" @@ -130,6 +134,10 @@ UseProfileRecordingPath="Utiliser le parcours d'enregistrement du profil" NoSpaceFileName="Générer le nom du fichier sans espace" KeepOutputBaseResolution="Ne pas réinitialiser la sortie lorsque la résolution source change" KeepOutputBaseResolutionNote="Si activé, l'image vidéo peut être recadrée ou présenter des espaces vides lorsque la résolution de la source est modifiée." +BlankWhenNotVisible="Noircir la sortie quand la source n'est pas dans la sortie principale" +BlankWhenNotVisibleNote="Continue la diffusion/l'enregistrement mais remplace la vidéo par du noir lorsque la source n'est pas visible dans la sortie principale active. Utilisez cette option lorsque vous ne souhaitez pas enregistrer ou diffuser les scènes hors antenne." +MuteAudioWhenBlank="Couper l'audio pendant le noircissement" +MuteAudioWhenBlankNote="Quand le noircissement est actif, couper aussi les sources audio sélectionnées. Utilisez cette option lorsque vous ne souhaitez pas enregistrer ou diffuser l'audio des scènes hors antenne." AudioSourceNote="Audio principal: Sortie mixée d'OBS (elle possède 6 pistes comme connu)
Audio du filtre: Audio du pipeline de filtrage qui est transmis au filtre Branch Output.
Audio de la source: Audio groupé de la source après avoir traversé tous les filtres du pipeline." SplitRecordingFileHotkey="Diviser le fichier d'enregistrement de '%1'" SplitRecordingAllHotkey="Diviser tous les fichiers d'enregistrement de Sortie de Branche" @@ -147,3 +155,20 @@ SuspendRecordingWhenSourceCollapsedNote="Lors de la diffusion, l'enregistrement AddChapterToRecordingFileHotkey="Ajouter un chapitre au fichier d'enregistrement de '%1'" AddChapterToAllRecordings="Ajouter un chapitre à tous les enregistrements" AddChapterToRecordingAllHotkey="Ajouter un chapitre à tous les enregistrements de Sortie de Branche" +VideoSourceType="Source vidéo" +VideoSourceType.LongDescription="Sélectionnez la vidéo à capturer pour la sortie.\n'Sortie source' capture la sortie rendue finale de la source (après tous les filtres).\n'Entrée filtre' capture la vidéo à la position de ce filtre dans la chaîne (avant ce filtre et tous les filtres en dessous)." +VideoSourceType.Source="Mix indépendant (Par défaut)" +VideoSourceType.FilterInput="Entrée filtre (Expérimental)" +ReplayBuffer="Tampon de relecture" +ReplayBufferDuration="Durée maximale de relecture (secondes)" +Status.ReplayBuffer="Mise en tampon" +SaveReplayBufferHotkey="Sauvegarder le tampon de relecture '%1'" +SaveReplayBufferAllHotkey="Sauvegarder tous les tampons de relecture Branch Output" +SaveAllReplayBuffers="Sauvegarder tous les tampons de relecture" +ReplayBuffer.Saved="Relecture sauvegardée" +AdvancedSettings="Paramètres avancés" +StreamingDescription="Configurez des sorties de streaming indépendantes vers des serveurs RTMP/SRT (jusqu'à 8 flux simultanés)." +RecordingDescription="Enregistrez la sortie de la source dans un fichier indépendamment de l'enregistrement principal d'OBS." +ReplayBufferDescription="Conservez les images récentes dans un tampon de relecture et sauvegardez-les à la demande via un raccourci clavier." +ReplayBufferHotkeyNote="Pour sauvegarder le tampon de relecture, utilisez le raccourci clavier enregistré dans Paramètres OBS Studio → Raccourcis clavier." +CustomAudioSourceDescription="Sélectionnez des sources audio spécifiques au lieu de l'audio par défaut. Prend en charge l'audio multipiste avec jusqu'à 6 pistes." diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index f6dec5e..ac5165a 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -23,6 +23,8 @@ Status.Active="アクティブ" Status.Paused="一時停止中" Status.Pending="保留中" Status.Stopping="停止中" +Status.BlankSuffix=" (黒画面)" +Status.BlankMutedSuffix=" (黒画面+無音)" Reset="リセット" ResetAll="全てリセット" EnableAll="全て有効化" @@ -33,7 +35,9 @@ AlwaysOn="常時ON" Streaming="配信" Recording="録画" StreamingOrRecording="配信 or 録画" +ReplayBuffer="リプレイバッファ" VirtualCam="仮想カメラ" +AlwaysOff="常時OFF" StreamRecording="配信録画" Path="保存先パス" VideoFormat="録画フォーマット" @@ -130,6 +134,10 @@ UseProfileRecordingPath="プロファイルの録画パスを使用する" NoSpaceFileName="スペースなしのファイル名を生成する" KeepOutputBaseResolution="ソース解像度が変化した時に出力をリセットしない" KeepOutputBaseResolutionNote="有効にした場合、ソースの解像度が変わると、映像が見切れたり余白が出来たりする場合があります" +BlankWhenNotVisible="ソースがメイン出力に含まれていないときに出力を黒画面にする" +BlankWhenNotVisibleNote="配信/録画は継続しつつ、ソースがアクティブなメイン出力に表示されていない場合は映像を黒に置き換えます。オフシーンを録画・配信したくない時に使用してください。" +MuteAudioWhenBlank="黒画面中に音声をミュートする" +MuteAudioWhenBlankNote="ブランキングが有効なとき、選択した音声ソースもミュートします。オフシーンの音声を録画・配信したくない時に使用してください。" AudioSourceNote="マスター音声: OBSのミックス出力(既知の6トラックがあります)
フィルター音声: Branch Outputフィルターに渡されるフィルターパイプラインの音声。
ソース音声: パイプライン内のすべてのフィルターを通過した後のソースのバンドルされた音声。" SplitRecordingFileHotkey="'%1' の録画ファイルを分割" SplitRecordingAllHotkey="全ての Branch Output 録画ファイルを分割" @@ -148,3 +156,20 @@ SuspendRecordingWhenSourceCollapsedNote="配信がある場合は録画が停止 AddChapterToRecordingFileHotkey="'%1' の録画ファイルにチャプターを追加" AddChapterToAllRecordings="全録画にチャプター追加" AddChapterToRecordingAllHotkey="全ての Branch Output 録画にチャプターを追加" +VideoSourceType="映像ソース" +VideoSourceType.LongDescription="出力に使用する映像を選択します。\n「ソースの最終出力」はソースの全てのフィルターを通過した最終映像をキャプチャします。\n「フィルター入力」はフィルターチェーン内のこのフィルターの位置で映像をキャプチャします(このフィルターより上のフィルターのみ適用された映像)。" +VideoSourceType.Source="独立ミックス (デフォルト)" +VideoSourceType.FilterInput="フィルター入力 (実験的)" +ReplayBuffer="リプレイバッファ" +ReplayBufferDuration="最大リプレイ時間(秒)" +Status.ReplayBuffer="バッファ中" +SaveReplayBufferHotkey="'%1' のリプレイバッファを保存" +SaveReplayBufferAllHotkey="全てのBranch Outputリプレイバッファを保存" +SaveAllReplayBuffers="全リプレイバッファを保存" +ReplayBuffer.Saved="リプレイ保存完了" +AdvancedSettings="詳細設定" +StreamingDescription="RTMP/SRTサーバーへの独立した配信出力を設定します(最大8つの同時配信に対応)。" +RecordingDescription="OBSのメイン録画とは独立して、ソースの出力をファイルに録画します。" +ReplayBufferDescription="直近の映像をリプレイバッファに保持し、ホットキーで任意のタイミングで保存できます。" +ReplayBufferHotkeyNote="リプレイバッファを保存するには、OBS Studio の設定→ホットキーで登録したホットキーを使用します。" +CustomAudioSourceDescription="デフォルト音声の代わりに、特定の音声ソースを選択します。最大6トラックのマルチトラック音声に対応しています。" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index 3a3c19a..96d5846 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -23,6 +23,8 @@ Status.Active="활동적인" Status.Paused="일시정지됨" Status.Pending="대기 중" Status.Stopping="중지 중" +Status.BlankSuffix=" (블랭크)" +Status.BlankMutedSuffix=" (블랭크+무음)" Reset="리셋" ResetAll="모두 리셋" EnableAll="모두 활성화" @@ -33,7 +35,9 @@ AlwaysOn="항상 켜짐" Streaming="스트리밍" Recording="녹화" StreamingOrRecording="스트리밍 or 녹화" +ReplayBuffer="리플레이 버퍼" VirtualCam="가상 카메라" +AlwaysOff="항상 OFF" StreamRecording="배달 녹화" Path="대상 경로" VideoFormat="녹화 형식" @@ -130,6 +134,10 @@ UseProfileRecordingPath="프로필의 녹음 경로 사용" NoSpaceFileName="ス공백 없이 파일 이름 짓기" KeepOutputBaseResolution="소스 해상도가 변경될 때 출력을 재설정하지 않음" KeepOutputBaseResolutionNote="활성화된 경우, 소스 해상도가 변경되면 비디오 이미지가 잘리거나 빈 공간이 생길 수 있습니다." +BlankWhenNotVisible="소스가 메인 출력에 없을 때 출력 검정 처리" +BlankWhenNotVisibleNote="송출/녹화를 계속하면서, 소스가 활성 메인 출력에 보이지 않을 때 영상을 검정으로 대체합니다. 오프씬을 녹화하거나 송출하고 싶지 않을 때 사용하세요." +MuteAudioWhenBlank="검정 처리 중 오디오 음소거" +MuteAudioWhenBlankNote="블랭킹이 활성화되면 선택한 오디오 소스도 음소거합니다. 오프씬의 오디오를 녹화하거나 송출하고 싶지 않을 때 사용하세요." AudioSourceNote="마스터 오디오: OBS의 믹스 출력 (알려진 대로 6개의 트랙이 있음)
필터 오디오: Branch Output 필터로 전달되는 필터 파이프라인의 오디오.
소스 오디오: 파이프라인의 모든 필터를 통과한 후의 소스의 번들 오디오." SplitRecordingFileHotkey="'%1' 녹화 파일 분할" SplitRecordingAllHotkey="모든 분기 출력 녹화 파일 분할" @@ -147,3 +155,20 @@ SuspendRecordingWhenSourceCollapsedNote="스트리밍이 있는 경우 녹화가 AddChapterToRecordingFileHotkey="'%1' 녹화 파일에 챕터 추가" AddChapterToAllRecordings="모든 녹화에 챕터 추가" AddChapterToRecordingAllHotkey="모든 분기 출력 녹화에 챕터 추가" +VideoSourceType="비디오 소스" +VideoSourceType.LongDescription="출력에 캡처할 비디오를 선택합니다.\n'소스 출력'은 소스의 최종 렌더링 출력을 캡처합니다(모든 필터 적용 후).\n'필터 입력'은 필터 체인에서 이 필터 위치의 비디오를 캡처합니다(이 필터 이전 및 아래의 필터 이전)." +VideoSourceType.Source="독립 믹스 (기본값)" +VideoSourceType.FilterInput="필터 입력 (실험적)" +ReplayBuffer="리플레이 버퍼" +ReplayBufferDuration="최대 리플레이 시간 (초)" +Status.ReplayBuffer="버퍼링 중" +SaveReplayBufferHotkey="'%1' 리플레이 버퍼 저장" +SaveReplayBufferAllHotkey="모든 Branch Output 리플레이 버퍼 저장" +SaveAllReplayBuffers="모든 리플레이 버퍼 저장" +ReplayBuffer.Saved="리플레이 저장됨" +AdvancedSettings="고급 설정" +StreamingDescription="RTMP/SRT 서버로의 독립적인 스트리밍 출력을 구성합니다 (최대 8개 동시 스트리밍 지원)." +RecordingDescription="OBS 메인 녹화와 독립적으로 소스 출력을 파일로 녹화합니다." +ReplayBufferDescription="최근 영상을 리플레이 버퍼에 유지하고 단축키로 원하는 시점에 저장할 수 있습니다." +ReplayBufferHotkeyNote="리플레이 버퍼를 저장하려면 OBS Studio 설정 → 단축키에서 등록한 단축키를 사용하세요." +CustomAudioSourceDescription="기본 오디오 대신 특정 오디오 소스를 선택합니다. 최대 6트랙의 멀티트랙 오디오를 지원합니다." diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index b092e95..4e3db81 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -23,6 +23,8 @@ Status.Active="Activ" Status.Paused="În pauză" Status.Pending="În așteptare" Status.Stopping="Se oprește" +Status.BlankSuffix=" (Ecran negru)" +Status.BlankMutedSuffix=" (Ecran negru+Mut)" Reset="Resetați" ResetAll="Resetați tot" EnableAll="Activați toate" @@ -33,7 +35,9 @@ AlwaysOn="Întotdeauna PORNIT" Streaming="Streaming" Recording="Înregistrare" StreamingOrRecording="Streaming sau înregistrare" +ReplayBuffer="Buffer de reluare" VirtualCam="Cameră virtuală" +AlwaysOff="Întotdeauna OFF" StreamRecording="Înregistrare în stream" Path="Calea folderului" VideoFormat="Format container" @@ -128,6 +132,10 @@ UseProfileRecordingPath="Utilizați calea de înregistrare a profilului" NoSpaceFileName="Generează nume de fișiere fără spațiu" KeepOutputBaseResolution="Nu resetați ieșirea când se schimbă rezoluția sursei" KeepOutputBaseResolutionNote="Dacă este activat, imaginea video poate fi decupată sau poate avea spații goale atunci când rezoluția sursei este modificată." +BlankWhenNotVisible="Afișează negru la ieșire când sursa nu este în ieșirea principală" +BlankWhenNotVisibleNote="Menține streaming-ul/înregistrarea activă, dar înlocuiește video cu negru ori de câte ori sursa nu este vizibilă în ieșirea principală activă. Folosiți aceasta când nu doriți să înregistrați sau să transmiteți scenele inactive." +MuteAudioWhenBlank="Oprește sunetul în timpul ecranului negru" +MuteAudioWhenBlankNote="Când înnegrirea este activă, oprește și sursele audio selectate. Folosiți aceasta când nu doriți să înregistrați sau să transmiteți audio din scenele inactive." AudioSourceNote="Audio master: Ieșirea mixată a OBS (are 6 piste după cum se știe)
Audio filtru: Audioul conductei de filtre care este transmis către filtrul Branch Output.
Audio sursă: Audioul grupat al sursei după ce a trecut prin toate filtrele din conductă." SplitRecordingFileHotkey="Separă fișierul înregistrării '%1'" SplitRecordingAllHotkey="Împarte toate fișierele de înregistrare Ieșire de Ramură" @@ -145,3 +153,20 @@ SuspendRecordingWhenSourceCollapsedNote="Când există streaming, înregistrarea AddChapterToRecordingFileHotkey="Adaugă capitol la fișierul de înregistrare '%1'" AddChapterToAllRecordings="Adaugă capitol la toate înregistrările" AddChapterToRecordingAllHotkey="Adaugă capitol la toate înregistrările Ieșire de Ramură" +VideoSourceType="Sursă video" +VideoSourceType.LongDescription="Selectați ce video să capturați pentru ieșire.\n'Ieșire sursă' capturează ieșirea randată finală a sursei (după toate filtrele).\n'Intrare filtru' capturează videoul la poziția acestui filtru în lanț (înainte de acest filtru și orice filtre de mai jos)." +VideoSourceType.Source="Mix independent (Implicit)" +VideoSourceType.FilterInput="Intrare filtru (Experimental)" +ReplayBuffer="Replay Buffer" +ReplayBufferDuration="Maximum Replay Time (seconds)" +Status.ReplayBuffer="Buffering" +SaveReplayBufferHotkey="Save '%1' Replay Buffer" +SaveReplayBufferAllHotkey="Save all Branch Output replay buffers" +SaveAllReplayBuffers="Save All Replay Buffers" +ReplayBuffer.Saved="Replay Saved" +AdvancedSettings="Advanced Settings" +StreamingDescription="Configurați ieșiri de streaming independente către servere RTMP/SRT (până la 8 fluxuri simultane)." +RecordingDescription="Înregistrați ieșirea sursei într-un fișier independent de înregistrarea principală OBS." +ReplayBufferDescription="Păstrați imaginile recente într-un buffer de reluare și salvați-le la cerere prin intermediul unei taste rapide." +ReplayBufferHotkeyNote="Pentru a salva bufferul de reluare, utilizați tasta rapidă înregistrată în Setări OBS Studio → Taste rapide." +CustomAudioSourceDescription="Selectați surse audio specifice în locul audio-ului implicit. Suportă audio multi-pistă cu până la 6 piste." diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index 95b0504..2f174b0 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -23,6 +23,8 @@ Status.Active="Активный" Status.Paused="Приостановлено" Status.Pending="Ожидание" Status.Stopping="Остановка" +Status.BlankSuffix=" (Чёрный экран)" +Status.BlankMutedSuffix=" (Чёрный экран+Без звука)" Reset="Перезагрузить" ResetAll="Перезагрузить все" EnableAll="Активировать все" @@ -33,7 +35,9 @@ AlwaysOn="Всегда ВКЛЮЧЕНО" Streaming="Трансляция" Recording="Запись" StreamingOrRecording="Трансляция или Запись" +ReplayBuffer="Буфер воспроизведения" VirtualCam="Виртуальная камера" +AlwaysOff="Всегда ВЫКЛ" StreamRecording="Запись потока" Path="Путь к папке" VideoFormat="Формат контейнера" @@ -130,6 +134,10 @@ UseProfileRecordingPath="Использовать путь записи проф NoSpaceFileName="Генерировать имя файла без пробела" KeepOutputBaseResolution="Не сбрасывать вывод при изменении разрешения источника" KeepOutputBaseResolutionNote="При включении этой функции изображение видео может быть обрезано или иметь пустые пространства при изменении разрешения источника." +BlankWhenNotVisible="Затемнять вывод, если источник не в основном выводе" +BlankWhenNotVisibleNote="Продолжает трансляцию/запись, но заменяет видео чёрным, когда источник не виден в активном основном выводе. Используйте это, когда не хотите записывать или транслировать неактивные сцены." +MuteAudioWhenBlank="При затемнении отключать звук" +MuteAudioWhenBlankNote="Когда затемнение активно, также выключать выбранные аудиоисточники. Используйте это, когда не хотите записывать или транслировать аудио из неактивных сцен." AudioSourceNote="Мастер-аудио: Микшированный выход OBS (известно, что имеет 6 дорожек)
Аудио фильтра: Аудио из конвейера фильтров, которое передается в фильтр Branch Output.
Аудио источника: Объединенное аудио источника после прохождения через все фильтры в конвейере." SplitRecordingFileHotkey="Разделить файл записи '%1'" SplitRecordingAllHotkey="Разделить все файлы записи Выхода Ветви" @@ -147,3 +155,20 @@ SuspendRecordingWhenSourceCollapsedNote="При наличии трансляц AddChapterToRecordingFileHotkey="Добавить главу в файл записи '%1'" AddChapterToAllRecordings="Добавить главу во все записи" AddChapterToRecordingAllHotkey="Добавить главу во все записи Выхода Ветви" +VideoSourceType="Источник видео" +VideoSourceType.LongDescription="Выберите, какое видео захватывать для вывода.\n'Выход источника' захватывает окончательный отрисованный вывод источника (после всех фильтров).\n'Вход фильтра' захватывает видео на позиции этого фильтра в цепочке (до этого фильтра и любых фильтров ниже)." +VideoSourceType.Source="Независимый микс (По умолчанию)" +VideoSourceType.FilterInput="Вход фильтра (Экспериментально)" +ReplayBuffer="Буфер воспроизведения" +ReplayBufferDuration="Максимальное время воспроизведения (секунды)" +Status.ReplayBuffer="Буферизация" +SaveReplayBufferHotkey="Сохранить буфер воспроизведения '%1'" +SaveReplayBufferAllHotkey="Сохранить все буферы воспроизведения Branch Output" +SaveAllReplayBuffers="Сохранить все буферы воспроизведения" +ReplayBuffer.Saved="Воспроизведение сохранено" +AdvancedSettings="Расширенные настройки" +StreamingDescription="Настройте независимые потоковые выходы на серверы RTMP/SRT (до 8 одновременных потоков)." +RecordingDescription="Записывайте выходной сигнал источника в файл независимо от основной записи OBS." +ReplayBufferDescription="Сохраняйте последние кадры в буфере воспроизведения и сохраняйте их по требованию с помощью горячей клавиши." +ReplayBufferHotkeyNote="Чтобы сохранить буфер воспроизведения, используйте горячую клавишу, зарегистрированную в Настройки OBS Studio → Горячие клавиши." +CustomAudioSourceDescription="Выберите определённые источники звука вместо звука по умолчанию. Поддерживает многодорожечное аудио до 6 дорожек." diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index 57f37ea..ac9d39a 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -23,6 +23,8 @@ Status.Active="Активний" Status.Paused="Призупинено" Status.Pending="Очікування" Status.Stopping="Зупинка" +Status.BlankSuffix=" (Чорний екран)" +Status.BlankMutedSuffix=" (Чорний екран+Без звуку)" Reset="Скидання" ResetAll="Скинути все" EnableAll="Активувати всі" @@ -33,7 +35,9 @@ AlwaysOn="Завжди ввімкнено" Streaming="Трансляція" Recording="Записування" StreamingOrRecording="Трансляція або Записування" +ReplayBuffer="Буфер повтору" VirtualCam="Віртуальна камера" +AlwaysOff="Завжди ВИМК" StreamRecording="Запис трансляції" Path="Шлях до папки" VideoFormat="Формат контейнера" @@ -130,6 +134,10 @@ UseProfileRecordingPath="Використовувати шлях запису п NoSpaceFileName="Генерувати назву файлу без пробілів" KeepOutputBaseResolution="Не скидати вивід при зміні роздільної здатності джерела" KeepOutputBaseResolutionNote="Якщо увімкнено, зображення відео може бути обрізане або мати пусті простори при зміні роздільної здатності джерела." +BlankWhenNotVisible="Затемнювати вихід, якщо джерело не в основному виході" +BlankWhenNotVisibleNote="Продовжує трансляцію/запис, але замінює відео на чорне, коли джерело не видно в активному основному виході. Використовуйте це, коли не хочете записувати або транслювати неактивні сцени." +MuteAudioWhenBlank="Вимикати звук під час затемнення" +MuteAudioWhenBlankNote="Коли затемнення активне, також вимикати вибрані аудіоджерела. Використовуйте це, коли не хочете записувати або транслювати аудіо з неактивних сцен." AudioSourceNote="Майстер-аудіо: Змішаний вихід OBS (як відомо, має 6 доріжок)
Аудіо фільтра: Аудіо з конвеєра фільтрів, яке передається у фільтр Branch Output.
Аудіо джерела: Об'єднане аудіо джерела після проходження через усі фільтри в конвеєрі." SplitRecordingFileHotkey="Розділити файл запису '%1'" SplitRecordingAllHotkey="Розділити всі файли запису Виходу Гілки" @@ -147,3 +155,20 @@ SuspendRecordingWhenSourceCollapsedNote="При наявності трансл AddChapterToRecordingFileHotkey="Додати розділ до файлу запису '%1'" AddChapterToAllRecordings="Додати розділ до всіх записів" AddChapterToRecordingAllHotkey="Додати розділ до всіх записів Виходу Гілки" +VideoSourceType="Джерело відео" +VideoSourceType.LongDescription="Оберіть, яке відео захоплювати для виводу.\n'Вихід джерела' захоплює остаточний відрендерений вивід джерела (після всіх фільтрів).\n'Вхід фільтра' захоплює відео на позиції цього фільтра в ланцюжку (до цього фільтра та будь-яких фільтрів нижче)." +VideoSourceType.Source="Незалежний мікс (За замовчуванням)" +VideoSourceType.FilterInput="Вхід фільтра (Експериментально)" +ReplayBuffer="Буфер повтору" +ReplayBufferDuration="Максимальний час повтору (секунди)" +Status.ReplayBuffer="Буферизація" +SaveReplayBufferHotkey="Зберегти буфер повтору '%1'" +SaveReplayBufferAllHotkey="Зберегти всі буфери повтору Branch Output" +SaveAllReplayBuffers="Зберегти всі буфери повтору" +ReplayBuffer.Saved="Повтор збережено" +AdvancedSettings="Розширені налаштування" +StreamingDescription="Налаштуйте незалежні потокові виходи на сервери RTMP/SRT (до 8 одночасних потоків)." +RecordingDescription="Записуйте вихідний сигнал джерела у файл незалежно від основного запису OBS." +ReplayBufferDescription="Зберігайте останні кадри у буфері повтору та зберігайте їх за потреби за допомогою гарячої клавіші." +ReplayBufferHotkeyNote="Щоб зберегти буфер повтору, використовуйте гарячу клавішу, зареєстровану в Налаштування OBS Studio → Гарячі клавіші." +CustomAudioSourceDescription="Виберіть певні джерела звуку замість звуку за замовчуванням. Підтримує багатодоріжкове аудіо до 6 доріжок." diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index c666ff4..20f95c0 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -23,6 +23,8 @@ Status.Active="活动" Status.Paused="已暂停" Status.Pending="待定" Status.Stopping="停止中" +Status.BlankSuffix=" (黑屏)" +Status.BlankMutedSuffix=" (黑屏+静音)" Reset="重置" ResetAll="全部重置" EnableAll="全部启用" @@ -33,7 +35,9 @@ AlwaysOn="始终开启" Streaming="直播" Recording="录制中" StreamingOrRecording="直播或记录" +ReplayBuffer="回放缓存" VirtualCam="虚拟摄像头" +AlwaysOff="始终关闭" StreamRecording="直播录制" Path="保存路径" VideoFormat="录像格式" @@ -130,6 +134,10 @@ UseProfileRecordingPath="使用个人资料的录制路径" NoSpaceFileName="生成没有空格的文件名" KeepOutputBaseResolution="源分辨率变化时不重置输出" KeepOutputBaseResolutionNote="如果启用,当源分辨率改变时,视频图像可能会被裁剪或出现空白区域。" +BlankWhenNotVisible="当源不在主输出中时将输出置为黑屏" +BlankWhenNotVisibleNote="保持推流/录制继续,但当源未在当前主输出中可见时,用黑色替换视频。当您不想录制或推流非活动场景时使用此功能。" +MuteAudioWhenBlank="黑屏时静音" +MuteAudioWhenBlankNote="启用黑屏时,同时静音所选音频源。当您不想录制或推流非活动场景的音频时使用此功能。" AudioSourceNote="主音频: OBS的混合输出(众所周知有6个轨道)
滤镜音频: 传递给分支输出滤镜的滤镜管道音频。
源音频: 通过管道中所有滤镜后的源捆绑音频。" SplitRecordingFileHotkey="分割 '%1' 录制文件" SplitRecordingAllHotkey="分割所有分支输出录制文件" @@ -147,3 +155,20 @@ SuspendRecordingWhenSourceCollapsedNote="有直播时录制会停止,仅录制 AddChapterToRecordingFileHotkey="为 '%1' 录制文件添加章节" AddChapterToAllRecordings="为所有录制添加章节" AddChapterToRecordingAllHotkey="为所有分支输出录制添加章节" +VideoSourceType="视频源" +VideoSourceType.LongDescription="选择要为输出捕获的视频。\n'源输出'捕获源的最终渲染输出(经过所有滤镜后)。\n'滤镜输入'捕获此滤镜在链中位置的视频(在此滤镜之前和下方的任何滤镜之前)。" +VideoSourceType.Source="独立混合(默认)" +VideoSourceType.FilterInput="滤镜输入 (实验性)" +ReplayBuffer="回放缓冲" +ReplayBufferDuration="最大回放时间(秒)" +Status.ReplayBuffer="缓冲中" +SaveReplayBufferHotkey="保存 '%1' 回放缓冲" +SaveReplayBufferAllHotkey="保存所有 Branch Output 回放缓冲" +SaveAllReplayBuffers="保存所有回放缓冲" +ReplayBuffer.Saved="回放已保存" +AdvancedSettings="高级设置" +StreamingDescription="配置独立的RTMP/SRT服务器推流输出(最多支持8路同时推流)。" +RecordingDescription="独立于OBS主录像,将源输出录制到文件。" +ReplayBufferDescription="将最近的画面保存在回放缓冲中,可通过热键随时保存。" +ReplayBufferHotkeyNote="要保存回放缓冲,请使用在 OBS Studio 设置→热键中注册的热键。" +CustomAudioSourceDescription="选择特定的音频源替代默认音频。支持最多6轨的多轨音频。" diff --git a/src/UI/images/replay-buffering.svg b/src/UI/images/replay-buffering.svg new file mode 100644 index 0000000..d7de65a --- /dev/null +++ b/src/UI/images/replay-buffering.svg @@ -0,0 +1 @@ + diff --git a/src/UI/images/replay-save.svg b/src/UI/images/replay-save.svg new file mode 100644 index 0000000..3a134a1 --- /dev/null +++ b/src/UI/images/replay-save.svg @@ -0,0 +1 @@ + diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index b4b3b49..6a73b1c 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -138,13 +138,21 @@ BranchOutputStatusDock::BranchOutputStatusDock(QWidget *parent) addChapterToRecordingAllButton->setEnabled(false); connect(addChapterToRecordingAllButton, &QToolButton::clicked, this, [this]() { addChapterToRecordingAll(); }); + saveReplayBufferAllButton = new QToolButton(this); + saveReplayBufferAllButton->setToolTip(QTStr("SaveAllReplayBuffers")); + saveReplayBufferAllButton->setIcon(QIcon(":/branch-output/images/replay-save.svg")); + saveReplayBufferAllButton->setEnabled(false); + connect(saveReplayBufferAllButton, &QToolButton::clicked, this, [this]() { saveReplayBufferAll(); }); + interlockLabel = new QLabel(QTStr("Interlock"), this); interlockComboBox = new QComboBox(this); interlockComboBox->addItem(QTStr("AlwaysOn"), BranchOutputFilter::INTERLOCK_TYPE_ALWAYS_ON); interlockComboBox->addItem(QTStr("Streaming"), BranchOutputFilter::INTERLOCK_TYPE_STREAMING); interlockComboBox->addItem(QTStr("Recording"), BranchOutputFilter::INTERLOCK_TYPE_RECORDING); interlockComboBox->addItem(QTStr("StreamingOrRecording"), BranchOutputFilter::INTERLOCK_TYPE_STREAMING_RECORDING); + interlockComboBox->addItem(QTStr("ReplayBuffer"), BranchOutputFilter::INTERLOCK_TYPE_REPLAY_BUFFER); interlockComboBox->addItem(QTStr("VirtualCam"), BranchOutputFilter::INTERLOCK_TYPE_VIRTUAL_CAM); + interlockComboBox->addItem(QTStr("AlwaysOff"), BranchOutputFilter::INTERLOCK_TYPE_ALWAYS_OFF); auto buttonsContainerLayout = new QHBoxLayout(); buttonsContainerLayout->addWidget(applyToAllLabel); @@ -155,6 +163,7 @@ BranchOutputStatusDock::BranchOutputStatusDock(QWidget *parent) buttonsContainerLayout->addWidget(pauseRecordingAllButton); buttonsContainerLayout->addWidget(unpauseRecordingAllButton); buttonsContainerLayout->addWidget(addChapterToRecordingAllButton); + buttonsContainerLayout->addWidget(saveReplayBufferAllButton); buttonsContainerLayout->addStretch(); buttonsContainerLayout->addWidget(interlockLabel); buttonsContainerLayout->addWidget(interlockComboBox); @@ -187,6 +196,10 @@ BranchOutputStatusDock::BranchOutputStatusDock(QWidget *parent) "AddChapterToRecordingAllBranchOutputsHotkey", obs_module_text("AddChapterToRecordingAllHotkey"), onAddChapterToRecordingAllHotkeyPressed, this ); + saveReplayBufferAllHotkey = obs_hotkey_register_frontend( + "SaveReplayBufferAllBranchOutputsHotkey", obs_module_text("SaveReplayBufferAllHotkey"), + onSaveReplayBufferAllHotkeyPressed, this + ); loadSettings(); loadHotkey(enableAllHotkey, "EnableAllBranchOutputsHotkey"); @@ -195,15 +208,26 @@ BranchOutputStatusDock::BranchOutputStatusDock(QWidget *parent) loadHotkey(pauseRecordingAllHotkey, "PauseRecordingAllBranchOutputsHotkey"); loadHotkey(unpauseRecordingAllHotkey, "UnpauseRecordingAllBranchOutputsHotkey"); loadHotkey(addChapterToRecordingAllHotkey, "AddChapterToRecordingAllBranchOutputsHotkey"); + loadHotkey(saveReplayBufferAllHotkey, "SaveReplayBufferAllBranchOutputsHotkey"); sort(); + obs_frontend_add_event_callback(onOBSFrontendEvent, this); + obs_log(LOG_DEBUG, "BranchOutputStatusDock created"); } BranchOutputStatusDock::~BranchOutputStatusDock() { - saveSettings(); + timer.stop(); + + obs_frontend_remove_event_callback(onOBSFrontendEvent, this); + + // Note: saveSettings() is intentionally NOT called here. + // On OBS 32+, the frontend API is already torn down by the time + // the destructor runs (via obs_module_unload), so + // obs_frontend_get_current_profile_path() returns nullptr. + // Settings are saved in onOBSFrontendEvent(OBS_FRONTEND_EVENT_EXIT) instead. // Unregister hotkeys obs_hotkey_unregister(enableAllHotkey); @@ -212,18 +236,35 @@ BranchOutputStatusDock::~BranchOutputStatusDock() obs_hotkey_unregister(pauseRecordingAllHotkey); obs_hotkey_unregister(unpauseRecordingAllHotkey); obs_hotkey_unregister(addChapterToRecordingAllHotkey); + obs_hotkey_unregister(saveReplayBufferAllHotkey); obs_log(LOG_DEBUG, "BranchOutputStatusDock destroyed"); } void BranchOutputStatusDock::loadSettings() { - OBSString path = obs_module_get_config_path(obs_current_module(), SETTINGS_JSON_NAME); - OBSDataAutoRelease settings = obs_data_create_from_json_file(path); + // Try profile-specific settings first + OBSString profilePath = obs_frontend_get_current_profile_path(); + auto profileSettingsPath = QString("%1/%2").arg(QString(profilePath)).arg(SETTINGS_JSON_NAME); + OBSDataAutoRelease settings = obs_data_create_from_json_file(qUtf8Printable(profileSettingsPath)); + + if (!settings) { + // Fallback to global plugin settings (backward compatibility) + OBSString globalPath = obs_module_get_config_path(obs_current_module(), SETTINGS_JSON_NAME); + settings = obs_data_create_from_json_file(globalPath); + } + if (!settings) { return; } + applySettings(settings); + + obs_log(LOG_DEBUG, "BranchOutputStatusDock settings loaded."); +} + +void BranchOutputStatusDock::applySettings(obs_data_t *settings) +{ for (int i = 0; i < outputTable->columnCount(); i++) { if (i == resetColumnIndex) { // Skip reset button column @@ -269,11 +310,48 @@ void BranchOutputStatusDock::saveSettings() ); obs_data_set_int(settings, "sortingOrder", sortingOrder); + // Save to global plugin settings (backward compatibility) OBSString config_dir_path = obs_module_get_config_path(obs_current_module(), ""); os_mkdirs(config_dir_path); - OBSString path = obs_module_get_config_path(obs_current_module(), SETTINGS_JSON_NAME); - obs_data_save_json_safe(settings, path, "tmp", "bak"); + OBSString globalPath = obs_module_get_config_path(obs_current_module(), SETTINGS_JSON_NAME); + obs_data_save_json_safe(settings, globalPath, "tmp", "bak"); + + // Save to profile-specific settings + OBSString profilePath = obs_frontend_get_current_profile_path(); + if (profilePath) { + auto profileSettingsPath = QString("%1/%2").arg(QString(profilePath)).arg(SETTINGS_JSON_NAME); + obs_data_save_json_safe(settings, qUtf8Printable(profileSettingsPath), "tmp", "bak"); + } + + obs_log(LOG_DEBUG, "BranchOutputStatusDock settings saved."); +} + +void BranchOutputStatusDock::onOBSFrontendEvent(enum obs_frontend_event event, void *param) +{ + auto *dock = static_cast(param); + + switch (event) { + case OBS_FRONTEND_EVENT_EXIT: + dock->saveSettings(); + break; + case OBS_FRONTEND_EVENT_PROFILE_CHANGING: + dock->saveSettings(); + break; + case OBS_FRONTEND_EVENT_PROFILE_CHANGED: + // Defer to ensure Qt event loop has finished processing the profile change + QMetaObject::invokeMethod( + dock, + [dock]() { + dock->loadSettings(); + dock->sort(); + }, + Qt::QueuedConnection + ); + break; + default: + break; + } } void BranchOutputStatusDock::addRow( @@ -301,11 +379,18 @@ void BranchOutputStatusDock::addFilter(BranchOutputFilter *filter) addRow(filter, 0, ROW_OUTPUT_RECORDING, groupIndex++); } + // Replay buffer row + if (filter->isReplayBufferEnabled(settings)) { + addRow(filter, 0, ROW_OUTPUT_REPLAY_BUFFER, groupIndex++); + } + // Streaming rows - auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); - for (size_t i = 0; i < MAX_SERVICES && i < serviceCount; i++) { - if (filter->isStreamingEnabled(settings, i)) { - addRow(filter, i, ROW_OUTPUT_STREAMING, groupIndex++); + if (filter->isStreamingGroupEnabled(settings)) { + auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); + for (size_t i = 0; i < MAX_SERVICES && i < serviceCount; i++) { + if (filter->isStreamingEnabled(settings, i)) { + addRow(filter, i, ROW_OUTPUT_STREAMING, groupIndex++); + } } } @@ -348,6 +433,7 @@ void BranchOutputStatusDock::update() applyPauseRecordingAllButtonEnabled(); applyUnpauseRecordingAllButtonEnabled(); applyAddChapterToRecordingAllButtonEnabled(); + applySaveReplayBufferAllButtonEnabled(); sort(); } @@ -417,6 +503,17 @@ void BranchOutputStatusDock::applyAddChapterToRecordingAllButtonEnabled() addChapterToRecordingAllButton->setEnabled(false); } +void BranchOutputStatusDock::applySaveReplayBufferAllButtonEnabled() +{ + foreach (auto row, outputTableRows) { + if (row->status->isSaveReplayBufferButtonShow()) { + saveReplayBufferAllButton->setEnabled(true); + return; + } + } + saveReplayBufferAllButton->setEnabled(false); +} + void BranchOutputStatusDock::showEvent(QShowEvent *) { timer.start(TIMER_INTERVAL); @@ -476,6 +573,15 @@ void BranchOutputStatusDock::addChapterToRecordingAll() } } +void BranchOutputStatusDock::saveReplayBufferAll() +{ + foreach (auto row, outputTableRows) { + if (row->outputType == ROW_OUTPUT_REPLAY_BUFFER && row->status->isSaveReplayBufferButtonShow()) { + row->filter->saveReplayBuffer(); + } + } +} + void BranchOutputStatusDock::resetStatsAll() { foreach (auto row, outputTableRows) { @@ -485,16 +591,43 @@ void BranchOutputStatusDock::resetStatsAll() void BranchOutputStatusDock::sort() { + if (!outputTable) { + return; + } + + QHeaderView *header = outputTable->horizontalHeader(); + if (!header) { + return; + } + + const int headerCount = header->count(); + if (headerCount <= 0) { + return; + } + + if (sortingColumnIndex < 0 || sortingColumnIndex >= headerCount) { + sortingColumnIndex = 0; + } + outputTable->sortItems(sortingColumnIndex, sortingOrder); - for (int i = 0; i < outputTable->horizontalHeader()->count(); i++) { - if (i != sortingColumnIndex && i != resetColumnIndex) { - outputTable->horizontalHeaderItem(i)->setIcon(QIcon()); + for (int i = 0; i < headerCount; i++) { + if (i == sortingColumnIndex || i == resetColumnIndex) { + continue; } + + QTableWidgetItem *item = outputTable->horizontalHeaderItem(i); + if (!item) { + continue; + } + item->setIcon(QIcon()); } - outputTable->horizontalHeaderItem(sortingColumnIndex) - ->setIcon((sortingOrder == Qt::AscendingOrder) ? ascendingIcon : descendingIcon); + QTableWidgetItem *sortHeaderItem = outputTable->horizontalHeaderItem(sortingColumnIndex); + if (!sortHeaderItem) { + return; + } + sortHeaderItem->setIcon((sortingOrder == Qt::AscendingOrder) ? ascendingIcon : descendingIcon); } void BranchOutputStatusDock::onEanbleAllHotkeyPressed(void *data, obs_hotkey_id, obs_hotkey *, bool pressed) @@ -547,6 +680,14 @@ void BranchOutputStatusDock::onAddChapterToRecordingAllHotkeyPressed( } } +void BranchOutputStatusDock::onSaveReplayBufferAllHotkeyPressed(void *data, obs_hotkey_id, obs_hotkey *, bool pressed) +{ + auto dock = static_cast(data); + if (pressed) { + dock->saveReplayBufferAll(); + } +} + void BranchOutputStatusDock::onHeaderPressed(int index) { if (index == resetColumnIndex) { @@ -589,6 +730,9 @@ OutputTableRow::OutputTableRow( case ROW_OUTPUT_RECORDING: outputName = new RecordingOutputCell(rowId, QTStr("Recording"), filter->filterSource, parent); break; + case ROW_OUTPUT_REPLAY_BUFFER: + outputName = new ReplayBufferOutputCell(rowId, QTStr("ReplayBuffer"), filter->filterSource, parent); + break; default: outputName = new LabelCell(rowId, QTStr("None"), parent); } @@ -636,6 +780,7 @@ OutputTableRow::OutputTableRow( connect(status, &StatusCell::pauseRecordingButtonClicked, this, [this]() { pauseRecording(); }); connect(status, &StatusCell::unpauseRecordingButtonClicked, this, [this]() { unpauseRecording(); }); connect(status, &StatusCell::addChapterToRecordingButtonClicked, this, [this]() { addChapterToRecording(); }); + connect(status, &StatusCell::saveReplayBufferButtonClicked, this, [this]() { filter->saveReplayBuffer(); }); // Setup rename event connect(filterCell, &FilterCell::renamed, this, [this, parent](const QString &) { @@ -665,6 +810,9 @@ void OutputTableRow::update() case ROW_OUTPUT_RECORDING: output = filter->recordingOutput.Get(); break; + case ROW_OUTPUT_REPLAY_BUFFER: + output = filter->replayBufferOutput.Get(); + break; default: output = nullptr; break; @@ -697,7 +845,16 @@ void OutputTableRow::update() status->setUnpauseRecordingButtonShow(false); status->setAddChapterToRecordingButtonShow(false); status->setAddChapterToRecordingButtonShow(false); + status->setSaveReplayBufferButtonShow(false); } else { + // Blanking suffix for status text + QString blankSuffix; + if (filter->blankingOutputActive && filter->blankingAudioMuted) { + blankSuffix = QTStr("Status.BlankMutedSuffix"); + } else if (filter->blankingOutputActive) { + blankSuffix = QTStr("Status.BlankSuffix"); + } + switch (outputType) { case ROW_OUTPUT_STREAMING: if (stopping) { @@ -705,7 +862,7 @@ void OutputTableRow::update() status->setTheme("", ""); status->setIconShow(StatusCell::StatusIcon::STATUS_ICON_NONE); } else { - status->setTextValue(QTStr("Status.Streaming")); + status->setTextValue(QTStr("Status.Streaming") + blankSuffix); status->setTheme("good", "text-success"); status->setIconShow(StatusCell::StatusIcon::STATUS_ICON_STREAMING); } @@ -714,14 +871,15 @@ void OutputTableRow::update() status->setPauseRecordingButtonShow(false); status->setUnpauseRecordingButtonShow(false); status->setAddChapterToRecordingButtonShow(false); + status->setSaveReplayBufferButtonShow(false); break; case ROW_OUTPUT_RECORDING: if (paused) { - status->setTextValue(QTStr("Status.Paused")); + status->setTextValue(QTStr("Status.Paused") + blankSuffix); status->setTheme("", ""); status->setIconShow(StatusCell::StatusIcon::STATUS_ICON_RECORDING_PAUSED); } else { - status->setTextValue(QTStr("Status.Recording")); + status->setTextValue(QTStr("Status.Recording") + blankSuffix); status->setTheme("good", "text-success"); status->setIconShow(StatusCell::StatusIcon::STATUS_ICON_RECORDING); } @@ -729,6 +887,17 @@ void OutputTableRow::update() status->setPauseRecordingButtonShow(!paused && filter->canPauseRecording()); status->setUnpauseRecordingButtonShow(paused && filter->canPauseRecording()); status->setAddChapterToRecordingButtonShow(filter->canAddChapterToRecording()); + status->setSaveReplayBufferButtonShow(false); + break; + case ROW_OUTPUT_REPLAY_BUFFER: + status->setTextValue(QTStr("Status.ReplayBuffer") + blankSuffix); + status->setTheme("good", "text-success"); + status->setIconShow(StatusCell::StatusIcon::STATUS_ICON_REPLAY_BUFFER); + status->setSplitRecordingButtonShow(false); + status->setPauseRecordingButtonShow(false); + status->setUnpauseRecordingButtonShow(false); + status->setAddChapterToRecordingButtonShow(false); + status->setSaveReplayBufferButtonShow(filter->replayBufferActive); break; default: status->setTextValue(QTStr("Status.Inactive")); @@ -738,9 +907,21 @@ void OutputTableRow::update() status->setPauseRecordingButtonShow(false); status->setUnpauseRecordingButtonShow(false); status->setAddChapterToRecordingButtonShow(false); + status->setSaveReplayBufferButtonShow(false); } } } else { + if (outputType == ROW_OUTPUT_REPLAY_BUFFER && filter->replayBufferActive) { + status->setTextValue(QTStr("Status.ReplayBuffer")); + status->setTheme("good", "text-success"); + status->setIconShow(StatusCell::StatusIcon::STATUS_ICON_REPLAY_BUFFER); + status->setSaveReplayBufferButtonShow(true); + status->setSplitRecordingButtonShow(false); + status->setPauseRecordingButtonShow(false); + status->setUnpauseRecordingButtonShow(false); + status->setAddChapterToRecordingButtonShow(false); + return; + } if (outputType == ROW_OUTPUT_RECORDING && filter->recordingPending) { status->setTextValue(QTStr("Status.Pending")); } else { @@ -752,6 +933,7 @@ void OutputTableRow::update() status->setPauseRecordingButtonShow(false); status->setUnpauseRecordingButtonShow(false); status->setAddChapterToRecordingButtonShow(false); + status->setSaveReplayBufferButtonShow(false); droppedFrames->setTextValue(""); megabytesSent->setTextValue(""); bitrate->setTextValue(""); @@ -784,7 +966,9 @@ void OutputTableRow::update() unit = "GiB"; } megabytesSent->setTextValue( - outputType != ROW_OUTPUT_NONE ? QString("%1 %2").arg((double)num, 0, 'f', 1).arg(unit) : "" + outputType != ROW_OUTPUT_NONE && outputType != ROW_OUTPUT_REPLAY_BUFFER + ? QString("%1 %2").arg((double)num, 0, 'f', 1).arg(unit) + : "" ); num = kbps; @@ -793,7 +977,11 @@ void OutputTableRow::update() num /= 1000; unit = "Mb/s"; } - bitrate->setTextValue(outputType != ROW_OUTPUT_NONE ? QString("%1 %2").arg((double)num, 0, 'f', 0).arg(unit) : ""); + bitrate->setTextValue( + outputType != ROW_OUTPUT_NONE && outputType != ROW_OUTPUT_REPLAY_BUFFER + ? QString("%1 %2").arg((double)num, 0, 'f', 0).arg(unit) + : "" + ); // Calculate statistics int total = output ? obs_output_get_total_frames(output) : 0; @@ -837,6 +1025,9 @@ void OutputTableRow::reset() case ROW_OUTPUT_RECORDING: output = filter->recordingOutput.Get(); break; + case ROW_OUTPUT_REPLAY_BUFFER: + output = filter->replayBufferOutput.Get(); + break; default: output = nullptr; } @@ -851,8 +1042,13 @@ void OutputTableRow::reset() first_total = obs_output_get_total_frames(output); first_dropped = obs_output_get_frames_dropped(output); droppedFrames->setTextValue(QString("0 / 0 (0)")); - megabytesSent->setTextValue(QString("0 MiB")); - bitrate->setTextValue(QString("0 kb/s")); + if (outputType != ROW_OUTPUT_REPLAY_BUFFER) { + megabytesSent->setTextValue(QString("0 MiB")); + bitrate->setTextValue(QString("0 kb/s")); + } else { + megabytesSent->setTextValue(""); + bitrate->setTextValue(""); + } } void OutputTableRow::splitRecording() @@ -1085,6 +1281,43 @@ void RecordingOutputCell::mousePressEvent(QMouseEvent *event) } } +//--- ReplayBufferOutputCell class ---// + +ReplayBufferOutputCell::ReplayBufferOutputCell( + const QString &rowId, const QString &textValue, obs_source_t *_source, QWidget *parent +) + : LabelCell(rowId, parent), + source(_source) +{ + // Markup as link + setTextFormat(Qt::RichText); + setCursor(Qt::PointingHandCursor); + + setTextValue(textValue); +} + +ReplayBufferOutputCell::~ReplayBufferOutputCell() {} + +void ReplayBufferOutputCell::setTextValue(const QString &textValue) +{ + // Markup as link + LabelCell::setValue(textValue); + LabelCell::setText(QString("%1").arg(textValue)); +} + +void ReplayBufferOutputCell::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + // Open OS file browser + OBSDataAutoRelease settings = obs_source_get_settings(source); + auto path = obs_data_get_bool(settings, "replay_buffer_use_profile_path") + ? getProfileRecordingPath(obs_frontend_get_profile_config()) + : obs_data_get_string(settings, "replay_buffer_path"); + obs_log(LOG_DEBUG, "path=%s", path); + QDesktopServices::openUrl(QUrl::fromLocalFile(path)); + } +} + //--- StatusCell class ---// StatusCell::StatusCell(const QString &rowId, const QString &textValue, QWidget *parent) @@ -1094,11 +1327,13 @@ StatusCell::StatusCell(const QString &rowId, const QString &textValue, QWidget * streamingIcon = new QLabel(this); recordingIcon = new QLabel(this); recordingPausedIcon = new QLabel(this); + replayBufferIcon = new QLabel(this); statusText = new QLabel(this); splitRecordingButton = new QToolButton(this); pauseRecordingButton = new QToolButton(this); unpauseRecordingButton = new QToolButton(this); addChapterToRecordingButton = new QToolButton(this); + saveReplayBufferButton = new QToolButton(this); streamingIcon->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); streamingIcon->setPixmap(QPixmap(":/branch-output/images/streaming.svg").scaled(16, 16)); @@ -1112,6 +1347,10 @@ StatusCell::StatusCell(const QString &rowId, const QString &textValue, QWidget * recordingPausedIcon->setPixmap(QPixmap(":/branch-output/images/recording-paused.svg").scaled(16, 16)); recordingPausedIcon->setVisible(false); + replayBufferIcon->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + replayBufferIcon->setPixmap(QPixmap(":/branch-output/images/replay-buffering.svg").scaled(16, 16)); + replayBufferIcon->setVisible(false); + splitRecordingButton->setIcon(QIcon(":/branch-output/images/scissors.svg")); splitRecordingButton->setVisible(false); @@ -1124,23 +1363,29 @@ StatusCell::StatusCell(const QString &rowId, const QString &textValue, QWidget * addChapterToRecordingButton->setIcon(QIcon(":/branch-output/images/chapter.svg")); addChapterToRecordingButton->setVisible(false); + saveReplayBufferButton->setIcon(QIcon(":/branch-output/images/replay-save.svg")); + saveReplayBufferButton->setVisible(false); + connect(splitRecordingButton, &QToolButton::clicked, this, [this]() { emit splitRecordingButtonClicked(); }); connect(pauseRecordingButton, &QToolButton::clicked, this, [this]() { emit pauseRecordingButtonClicked(); }); connect(unpauseRecordingButton, &QToolButton::clicked, this, [this]() { emit unpauseRecordingButtonClicked(); }); connect(addChapterToRecordingButton, &QToolButton::clicked, this, [this]() { emit addChapterToRecordingButtonClicked(); }); + connect(saveReplayBufferButton, &QToolButton::clicked, this, [this]() { emit saveReplayBufferButtonClicked(); }); auto layout = new QHBoxLayout(); layout->setContentsMargins(0, 0, 0, 0); layout->addWidget(streamingIcon); layout->addWidget(recordingIcon); layout->addWidget(recordingPausedIcon); + layout->addWidget(replayBufferIcon); layout->addWidget(statusText); layout->addWidget(pauseRecordingButton); layout->addWidget(unpauseRecordingButton); layout->addWidget(splitRecordingButton); layout->addWidget(addChapterToRecordingButton); + layout->addWidget(saveReplayBufferButton); layout->addSpacing(5); setLayout(layout); @@ -1159,21 +1404,31 @@ void StatusCell::setIconShow(StatusIcon show) streamingIcon->setVisible(false); recordingIcon->setVisible(false); recordingPausedIcon->setVisible(false); + replayBufferIcon->setVisible(false); break; case STATUS_ICON_STREAMING: streamingIcon->setVisible(true); recordingIcon->setVisible(false); recordingPausedIcon->setVisible(false); + replayBufferIcon->setVisible(false); break; case STATUS_ICON_RECORDING: streamingIcon->setVisible(false); recordingIcon->setVisible(true); recordingPausedIcon->setVisible(false); + replayBufferIcon->setVisible(false); break; case STATUS_ICON_RECORDING_PAUSED: streamingIcon->setVisible(false); recordingIcon->setVisible(false); recordingPausedIcon->setVisible(true); + replayBufferIcon->setVisible(false); + break; + case STATUS_ICON_REPLAY_BUFFER: + streamingIcon->setVisible(false); + recordingIcon->setVisible(false); + recordingPausedIcon->setVisible(false); + replayBufferIcon->setVisible(true); break; } } diff --git a/src/UI/output-status-dock.hpp b/src/UI/output-status-dock.hpp index c2df1d8..652ac5a 100644 --- a/src/UI/output-status-dock.hpp +++ b/src/UI/output-status-dock.hpp @@ -19,6 +19,7 @@ with this program. If not, see #pragma once #include +#include #include #include @@ -137,6 +138,23 @@ class RecordingOutputCell : public LabelCell { void setTextValue(const QString &value); }; +class ReplayBufferOutputCell : public LabelCell { + Q_OBJECT + + obs_source_t *source; + +protected: + void mousePressEvent(QMouseEvent *event) override; + +public: + explicit ReplayBufferOutputCell( + const QString &rowId, const QString &textValue, obs_source_t *source, QWidget *parent = (QWidget *)nullptr + ); + ~ReplayBufferOutputCell(); + + void setTextValue(const QString &value); +}; + class StatusCell : public QWidget { Q_OBJECT @@ -144,20 +162,29 @@ class StatusCell : public QWidget { QLabel *streamingIcon; QLabel *recordingIcon; QLabel *recordingPausedIcon; + QLabel *replayBufferIcon; QLabel *statusText; QToolButton *splitRecordingButton; QToolButton *pauseRecordingButton; QToolButton *unpauseRecordingButton; QToolButton *addChapterToRecordingButton; + QToolButton *saveReplayBufferButton; signals: void splitRecordingButtonClicked(); void pauseRecordingButtonClicked(); void unpauseRecordingButtonClicked(); void addChapterToRecordingButtonClicked(); + void saveReplayBufferButtonClicked(); public: - enum StatusIcon { STATUS_ICON_NONE, STATUS_ICON_STREAMING, STATUS_ICON_RECORDING, STATUS_ICON_RECORDING_PAUSED }; + enum StatusIcon { + STATUS_ICON_NONE, + STATUS_ICON_STREAMING, + STATUS_ICON_RECORDING, + STATUS_ICON_RECORDING_PAUSED, + STATUS_ICON_REPLAY_BUFFER, + }; explicit StatusCell(const QString &rowId, const QString &textValue, QWidget *parent = (QWidget *)nullptr); ~StatusCell(); @@ -174,6 +201,8 @@ class StatusCell : public QWidget { inline bool isUnpauseRecordingButtonShow() const { return unpauseRecordingButton->isVisible(); }; inline void setAddChapterToRecordingButtonShow(bool show) { addChapterToRecordingButton->setVisible(show); }; inline bool isAddChapterToRecordingButtonShow() const { return addChapterToRecordingButton->isVisible(); }; + inline void setSaveReplayBufferButtonShow(bool show) { saveReplayBufferButton->setVisible(show); }; + inline bool isSaveReplayBufferButtonShow() const { return saveReplayBufferButton->isVisible(); }; inline OutputTableCellItem *item() const { return _item; } }; @@ -181,6 +210,7 @@ enum RowOutputType { ROW_OUTPUT_NONE = 0, ROW_OUTPUT_STREAMING = 1, ROW_OUTPUT_RECORDING = 2, + ROW_OUTPUT_REPLAY_BUFFER = 3, }; class BranchOutputStatusDock : public QFrame { @@ -201,6 +231,7 @@ class BranchOutputStatusDock : public QFrame { QToolButton *pauseRecordingAllButton = nullptr; QToolButton *unpauseRecordingAllButton = nullptr; QToolButton *addChapterToRecordingAllButton = nullptr; + QToolButton *saveReplayBufferAllButton = nullptr; QLabel *interlockLabel = nullptr; QComboBox *interlockComboBox = nullptr; OBSSignal sourceAddedSignal; @@ -210,6 +241,7 @@ class BranchOutputStatusDock : public QFrame { obs_hotkey_id pauseRecordingAllHotkey; obs_hotkey_id unpauseRecordingAllHotkey; obs_hotkey_id addChapterToRecordingAllHotkey; + obs_hotkey_id saveReplayBufferAllHotkey; int resetColumnIndex; int sortingColumnIndex; Qt::SortOrder sortingOrder; @@ -221,15 +253,19 @@ class BranchOutputStatusDock : public QFrame { void applyPauseRecordingAllButtonEnabled(); void applyUnpauseRecordingAllButtonEnabled(); void applyAddChapterToRecordingAllButtonEnabled(); + void applySaveReplayBufferAllButtonEnabled(); void saveSettings(); void loadSettings(); + void applySettings(obs_data_t *settings); + static void onOBSFrontendEvent(enum obs_frontend_event event, void *param); static void onEanbleAllHotkeyPressed(void *data, obs_hotkey_id id, obs_hotkey *hotkey, bool pressed); static void onDisableAllHotkeyPressed(void *data, obs_hotkey_id id, obs_hotkey *hotkey, bool pressed); static void onSplitRecordingAllHotkeyPressed(void *data, obs_hotkey_id id, obs_hotkey *hotkey, bool pressed); static void onPauseRecordingAllHotkeyPressed(void *data, obs_hotkey_id id, obs_hotkey *hotkey, bool pressed); static void onUnpauseRecordingAllHotkeyPressed(void *data, obs_hotkey_id id, obs_hotkey *hotkey, bool pressed); static void onAddChapterToRecordingAllHotkeyPressed(void *data, obs_hotkey_id id, obs_hotkey *hotkey, bool pressed); + static void onSaveReplayBufferAllHotkeyPressed(void *data, obs_hotkey_id id, obs_hotkey *hotkey, bool pressed); private slots: void onHeaderPressed(int index); @@ -251,6 +287,7 @@ public slots: void pauseRecordingAll(); void unpauseRecordingAll(); void addChapterToRecordingAll(); + void saveReplayBufferAll(); void resetStatsAll(); void sort(); diff --git a/src/UI/resources.qrc b/src/UI/resources.qrc index 4e171e9..009b20b 100644 --- a/src/UI/resources.qrc +++ b/src/UI/resources.qrc @@ -12,5 +12,7 @@ images/sort-ascending.svg images/sort-descending.svg images/reset.svg + images/replay-save.svg + images/replay-buffering.svg diff --git a/src/audio/audio-capture.cpp b/src/audio/audio-capture.cpp index 2dd38d7..3d9a32f 100644 --- a/src/audio/audio-capture.cpp +++ b/src/audio/audio-capture.cpp @@ -18,8 +18,11 @@ with this program. If not, see #include +#include + #include "audio-capture.hpp" #include "../plugin-support.h" +#include "../utils.hpp" #define MAX_AUDIO_BUFFER_FRAMES 131071 @@ -37,7 +40,8 @@ AudioCapture::AudioCapture( audioBuffer({0}), audioBufferFrames(0), audioConvBuffer(nullptr), - audioConvBufferSize(0) + audioConvBufferSize(0), + active(true) { audio_output_info aoi = {0}; aoi.name = _name; @@ -51,13 +55,11 @@ AudioCapture::AudioCapture( audio = nullptr; return; } - - active = true; } AudioCapture::~AudioCapture() { - active = false; + active.store(false); if (audio) { audio_output_close(audio); @@ -69,7 +71,7 @@ AudioCapture::~AudioCapture() uint64_t AudioCapture::popAudio(uint64_t startTsIn, uint32_t mixers, audio_output_data *audioData) { - if (!active) { + if (!active.load()) { return startTsIn; } @@ -141,7 +143,7 @@ uint64_t AudioCapture::popAudio(uint64_t startTsIn, uint32_t mixers, audio_outpu void AudioCapture::pushAudio(const audio_data *audioData) { - if (!active) { + if (!active.load()) { return; } @@ -206,6 +208,20 @@ void AudioCapture::pushAudio(const obs_audio_data *audioData) pushAudio(&ad); } +void AudioCapture::setActive(bool enable) +{ + active.store(enable); + if (!enable) { + QMutexLocker locker(&audioBufferMutex); + { + deque_free(&audioBuffer); + deque_init(&audioBuffer); + audioBufferFrames = 0; + } + locker.unlock(); + } +} + // Callback from audio_output_open bool AudioCapture::audioCapture( void *param, uint64_t start_ts_in, uint64_t, uint64_t *out_ts, uint32_t mixers, audio_output_data *mixes @@ -255,3 +271,31 @@ void SourceAudioCapture::sourceAudioCallback(void *param, obs_source_t *, const auto sourceCapture = (SourceAudioCapture *)param; sourceCapture->pushAudio(audioData); } + +//--- MasterAudioCapture class ---// + +MasterAudioCapture::MasterAudioCapture( + size_t _mixIndex, uint32_t _samplesPerSec, speaker_layout _speakers, QObject *parent +) + : AudioCapture( + qUtf8Printable(QTStr("MasterTrack%1").arg(_mixIndex + 1)), _samplesPerSec, _speakers, + AudioCapture::audioCapture, parent + ), + masterMixIndex(_mixIndex) +{ + obs_add_raw_audio_callback(masterMixIndex, nullptr, masterAudioCallback, this); + obs_log(LOG_INFO, "%s: Master audio capture created (mix %zu)", qUtf8Printable(getName()), masterMixIndex); +} + +MasterAudioCapture::~MasterAudioCapture() +{ + obs_remove_raw_audio_callback(masterMixIndex, masterAudioCallback, this); + obs_log(LOG_DEBUG, "%s: Master audio capture destroyed.", qUtf8Printable(getName())); +} + +// Callback from obs_add_raw_audio_callback +void MasterAudioCapture::masterAudioCallback(void *param, size_t, struct audio_data *audioData) +{ + auto *masterCapture = static_cast(param); + masterCapture->pushAudio(audioData); +} diff --git a/src/audio/audio-capture.hpp b/src/audio/audio-capture.hpp index decd2fd..48bd28e 100644 --- a/src/audio/audio-capture.hpp +++ b/src/audio/audio-capture.hpp @@ -22,6 +22,7 @@ with this program. If not, see #include #include +#include #include #include @@ -41,7 +42,7 @@ class AudioCapture : public QObject { uint8_t *audioConvBuffer; size_t audioConvBufferSize; QMutex audioBufferMutex; - bool active; + std::atomic_bool active; public: struct AudioBufferHeader { @@ -64,6 +65,8 @@ class AudioCapture : public QObject { uint64_t popAudio(uint64_t startTsIn, uint32_t mixers, audio_output_data *audioData); void pushAudio(const audio_data *audioData); void pushAudio(const obs_audio_data *audioData); + void setActive(bool enable); + inline bool isActive() const { return active.load(); } virtual bool hasSource() { return true; } inline QString getName() { return name; } @@ -105,3 +108,18 @@ class FilterAudioCapture : public AudioCapture { bool hasSource() { return false; } }; + +// Audio capture from OBS master audio track via raw audio callback +class MasterAudioCapture : public AudioCapture { + Q_OBJECT + + size_t masterMixIndex; + + static void masterAudioCallback(void *param, size_t mix_idx, struct audio_data *audioData); + +public: + explicit MasterAudioCapture( + size_t _mixIndex, uint32_t _samplesPerSec, speaker_layout _speakers, QObject *parent = nullptr + ); + ~MasterAudioCapture(); +}; diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 157477d..bb48a90 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -27,6 +27,7 @@ with this program. If not, see #include #include "audio/audio-capture.hpp" +#include "video/filter-video-capture.hpp" #include "plugin-support.h" #include "plugin-main.hpp" #include "utils.hpp" @@ -57,15 +58,22 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc storedSettingsRev(0), activeSettingsRev(0), intervalTimer(nullptr), + streamingStopping(false), + blankingOutputActive(false), + blankingAudioMuted(false), recordingOutput(nullptr), videoEncoder(nullptr), videoOutput(nullptr), view(nullptr), + useFilterInput(false), + filterVideoCapture(nullptr), width(0), height(0), toggleEnableHotkeyPairId(OBS_INVALID_HOTKEY_PAIR_ID), splitRecordingHotkeyId(OBS_INVALID_HOTKEY_ID), - splitRecordingEnabled(false) + splitRecordingEnabled(false), + replayBufferActive(false), + saveReplayBufferHotkeyId(OBS_INVALID_HOTKEY_ID) { // DO NOT use obs_filter_get_parent() in this function (It'll return nullptr) obs_log(LOG_DEBUG, "%s: BranchOutputFilter creating", qUtf8Printable(name)); @@ -103,8 +111,15 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc obs_data_set_int(settings, "audio_track", trackNo); } - // Fiter activate immediately when "server" or "stream_recording" is exists. - initialized = countEnabledStreamings(settings) > 0 || obs_data_get_bool(settings, "stream_recording"); + // Migrate streaming_enabled (for pre-existing filters without this key) + if (!obs_data_has_user_value(settings, "streaming_enabled")) { + bool hasAnyServer = countEnabledStreamings(settings) > 0; + obs_data_set_bool(settings, "streaming_enabled", hasAnyServer); + } + + // Fiter activate immediately when "server" or "stream_recording" or "replay_buffer" is exists. + initialized = isStreamingGroupEnabled(settings) || obs_data_get_bool(settings, "stream_recording") || + obs_data_get_bool(settings, "replay_buffer"); obs_log(LOG_INFO, "%s: BranchOutputFilter created", qUtf8Printable(name)); } @@ -189,9 +204,35 @@ void BranchOutputFilter::updateCallback(obs_data_t *settings) obs_log(LOG_INFO, "%s: Filter updated", qUtf8Printable(name)); } +void BranchOutputFilter::videoTickCallback(float) +{ + // Reset capture flag at the start of each frame so that renderTexture() + // can detect whether captureFilterInput() was already called by the + // normal rendering path (scene active). video_tick runs before + // output_frames in the graphics thread loop, guaranteeing correct ordering. + if (useFilterInput && filterVideoCapture) { + filterVideoCapture->resetCapturedFlag(); + } +} + void BranchOutputFilter::videoRenderCallback(gs_effect_t *) { - obs_source_skip_video_filter(filterSource); + if (useFilterInput && filterVideoCapture) { + // Optimized filter input mode: + // 1. Capture upstream rendering to texrender (one render of the source tree) + // 2. Draw captured texture to current render target (main output passthrough) + // This replaces obs_source_skip_video_filter to avoid rendering the + // source tree a second time. + if (filterVideoCapture->captureFilterInput()) { + filterVideoCapture->drawCapturedTexture(); + } else { + // Fallback: capture failed, pass through normally + obs_source_skip_video_filter(filterSource); + } + } else { + // Source output mode: pass through the filter chain as usual + obs_source_skip_video_filter(filterSource); + } } // This method possibly called in different thread from UI thread @@ -228,6 +269,10 @@ void BranchOutputFilter::removeCallback() obs_hotkey_unregister(addChapterToRecordingHotkeyId); addChapterToRecordingHotkeyId = OBS_INVALID_HOTKEY_ID; } + if (saveReplayBufferHotkeyId != OBS_INVALID_HOTKEY_ID) { + obs_hotkey_unregister(saveReplayBufferHotkeyId); + saveReplayBufferHotkeyId = OBS_INVALID_HOTKEY_ID; + } obs_log(LOG_INFO, "%s: Filter removed", qUtf8Printable(name)); } @@ -251,11 +296,12 @@ void BranchOutputFilter::stopRecordingOutput() { OBSMutexAutoUnlock locked(&outputMutex); - obs_source_t *parent = obs_filter_get_parent(filterSource); - if (recordingOutput) { if (recordingActive) { - obs_source_dec_showing(parent); + obs_source_t *parent = obs_filter_get_parent(filterSource); + if (parent) { + obs_source_dec_showing(parent); + } obs_output_stop(recordingOutput); } } @@ -275,6 +321,7 @@ void BranchOutputFilter::stopRecordingOutput() void BranchOutputFilter::stopOutput() { stopRecordingOutput(); + stopReplayBufferOutput(); pthread_mutex_lock(&outputMutex); { @@ -296,12 +343,22 @@ void BranchOutputFilter::stopOutput() videoEncoder = nullptr; + if (filterVideoCapture) { + filterVideoCapture->setActive(false); + delete filterVideoCapture; + filterVideoCapture = nullptr; + } + if (view) { obs_view_set_source(view, 0, nullptr); obs_view_remove(view); } view = nullptr; + videoOutput = nullptr; + useFilterInput = false; + blankingOutputActive = false; + blankingAudioMuted = false; } } @@ -328,8 +385,20 @@ obs_data_t *BranchOutputFilter::createRecordingSettings(obs_data_t *settings, bo auto path = useProfileRecordingPath ? getProfileRecordingPath(config) : obs_data_get_string(settings, "path"); auto recFormat = obs_data_get_string(settings, "rec_format"); + // Validate recording path + if (!path || !path[0]) { + obs_log(LOG_ERROR, "%s: Recording path is not set", qUtf8Printable(name)); + obs_data_release(recordingSettings); + return nullptr; + } + if (createFolder) { - os_mkdirs(path); + int ret = os_mkdirs(path); + if (ret == MKDIR_ERROR) { + obs_log(LOG_ERROR, "%s: Failed to create recording directory: %s", qUtf8Printable(name), path); + obs_data_release(recordingSettings); + return nullptr; + } } // Add filter name to filename format @@ -340,6 +409,12 @@ obs_data_t *BranchOutputFilter::createRecordingSettings(obs_data_t *settings, bo filenameFormat = filenameFormat.arg(sourceName.replace(re, "-")).arg(filterName.replace(re, "-")); auto compositePath = getOutputFilename(path, recFormat, noSpace, false, qUtf8Printable(filenameFormat)); + if (compositePath.isEmpty()) { + obs_log(LOG_ERROR, "%s: Recording path is not accessible: %s", qUtf8Printable(name), path); + obs_data_release(recordingSettings); + return nullptr; + } + obs_data_set_string(recordingSettings, "path", qUtf8Printable(compositePath)); auto splitFile = obs_data_get_string(settings, "split_file"); @@ -411,6 +486,27 @@ obs_data_t *BranchOutputFilter::createStreamingSettings(obs_data_t *settings, si return streamingSettings; } +void BranchOutputFilter::getSourceResolution(uint32_t &outWidth, uint32_t &outHeight) +{ + if (useFilterInput) { + obs_source_t *target = obs_filter_get_target(filterSource); + if (target) { + outWidth = obs_source_get_base_width(target); + outHeight = obs_source_get_base_height(target); + } else { + outWidth = 0; + outHeight = 0; + } + } else { + obs_source_t *parent = obs_filter_get_parent(filterSource); + outWidth = obs_source_get_width(parent); + outHeight = obs_source_get_height(parent); + } + // Round up to a multiple of 2 + outWidth += (outWidth & 1); + outHeight += (outHeight & 1); +} + void BranchOutputFilter::determineOutputResolution(obs_data_t *settings, obs_video_info *ovi) { auto resolution = obs_data_get_string(settings, "resolution"); @@ -608,7 +704,10 @@ void BranchOutputFilter::startStreamingOutput(size_t index) // Start streaming output if (obs_output_start(streamings[index].output)) { streamings[index].active = true; - obs_source_inc_showing(obs_filter_get_parent(filterSource)); + auto parent = obs_filter_get_parent(filterSource); + if (parent) { + obs_source_inc_showing(parent); + } obs_log(LOG_INFO, "%s (%zu): Starting streaming output succeeded", qUtf8Printable(name), index); } else { obs_log(LOG_ERROR, "%s (%zu): Starting streaming output failed", qUtf8Printable(name), index); @@ -618,7 +717,10 @@ void BranchOutputFilter::startStreamingOutput(size_t index) void BranchOutputFilter::stopStreamingOutput(size_t index) { if (streamings[index].output && streamings[index].active) { - obs_source_dec_showing(obs_filter_get_parent(filterSource)); + obs_source_t *parent = obs_filter_get_parent(filterSource); + if (parent) { + obs_source_dec_showing(parent); + } obs_output_stop(streamings[index].output); obs_log(LOG_INFO, "%s (%zu): Stopping streaming output succeeded", qUtf8Printable(name), index); } @@ -652,6 +754,10 @@ void BranchOutputFilter::createAndStartRecordingOutput(obs_data_t *settings) // Ensure base path exists OBSDataAutoRelease recordingSettings = createRecordingSettings(settings, true); + if (!recordingSettings) { + obs_log(LOG_ERROR, "%s: Recording settings creation failed (path unavailable?)", qUtf8Printable(name)); + return; + } recordingOutput = obs_output_create(outputId, qUtf8Printable(name), recordingSettings, nullptr); if (!recordingOutput) { obs_log(LOG_ERROR, "%s: Recording output creation failed", qUtf8Printable(name)); @@ -693,7 +799,10 @@ void BranchOutputFilter::createAndStartRecordingOutput(obs_data_t *settings) if (obs_output_start(recordingOutput)) { recordingActive = true; recordingPending = false; - obs_source_inc_showing(obs_filter_get_parent(filterSource)); + auto parent = obs_filter_get_parent(filterSource); + if (parent) { + obs_source_inc_showing(parent); + } obs_log(LOG_INFO, "%s: Starting recording output succeeded", qUtf8Printable(name)); } else { obs_log(LOG_ERROR, "%s: Starting recording output failed", qUtf8Printable(name)); @@ -710,7 +819,8 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) OBSMutexAutoUnlock locked(&outputMutex); // Abort when obs initializing or filter disabled. - if (!obs_initialized() || !obs_source_enabled(filterSource) || countActiveStreamings() > 0 || recordingActive) { + if (!obs_initialized() || !obs_source_enabled(filterSource) || countActiveStreamings() > 0 || recordingActive || + replayBufferActive) { obs_log(LOG_ERROR, "%s: Ignore unavailable filter", qUtf8Printable(name)); return; } @@ -729,11 +839,14 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) } // Mandatory paramters - if (countEnabledStreamings(settings) == 0 && !obs_data_get_string(settings, "stream_recording")) { + if (!isStreamingGroupEnabled(settings) && !isRecordingEnabled(settings) && !isReplayBufferEnabled(settings)) { obs_log(LOG_ERROR, "%s: Nothing to do", qUtf8Printable(name)); return; } + bool blankWhenHidden = obs_data_get_bool(settings, "blank_when_not_visible"); + bool muteWhenHidden = obs_data_get_bool(settings, "mute_audio_when_blank"); + obs_video_info ovi = {0}; if (!obs_get_video_info(&ovi)) { // Abort when no video situation @@ -741,12 +854,19 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) return; } - // Round up to a multiple of 2 - uint32_t sourceWidth = obs_source_get_width(parent); - width = sourceWidth += (sourceWidth & 1); - // Round up to a multiple of 2 - uint32_t sourceHeight = obs_source_get_height(parent); - height = sourceHeight += (sourceHeight & 1); + // Determine video source type first to choose correct resolution source + auto videoSourceType = obs_data_get_string(settings, "video_source_type"); + useFilterInput = videoSourceType && !strcmp(videoSourceType, "filter_input"); + + // Resolve input resolution based on video source type + // sourceWidth/sourceHeight represent the actual input resolution for this filter, + // used both for video capture and for collapsed-source detection (recording pending). + uint32_t sourceWidth; + uint32_t sourceHeight; + getSourceResolution(sourceWidth, sourceHeight); + + width = sourceWidth; + height = sourceHeight; if (width == 0 || height == 0) { // Default to canvas size @@ -766,20 +886,48 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) activeSettingsRev = storedSettingsRev; //--- Create service and open streaming output ---// - auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); - for (size_t i = 0; i < MAX_SERVICES && i < serviceCount; i++) { - streamings[i] = createSreamingOutput(settings, i); + if (isStreamingGroupEnabled(settings)) { + auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); + for (size_t i = 0; i < MAX_SERVICES && i < serviceCount; i++) { + streamings[i] = createSreamingOutput(settings, i); + } } //--- Open video output ---// - // Create view and associate it with filter source - view = obs_view_create(); + if (useFilterInput) { + // Filter input mode: capture via texrender + proxy source + obs_view. + // The proxy source renders the captured texrender texture on the GPU. + // obs_view creates a video_t* registered in OBS's mix list, allowing + // GPU encoders (NVENC, QSV, AMF, etc.) to work directly. + filterVideoCapture = new FilterVideoCapture(filterSource, parent, width, height); + if (!filterVideoCapture->getProxySource()) { + obs_log(LOG_ERROR, "%s: Filter video capture creation failed", qUtf8Printable(name)); + delete filterVideoCapture; + filterVideoCapture = nullptr; + return; + } - obs_view_set_source(view, 0, parent); - videoOutput = obs_view_add2(view, &ovi); - if (!videoOutput) { - obs_log(LOG_ERROR, "%s: Video output association failed", qUtf8Printable(name)); - return; + view = obs_view_create(); + obs_view_set_source(view, 0, filterVideoCapture->getProxySource()); + + videoOutput = obs_view_add2(view, &ovi); + if (!videoOutput) { + obs_log(LOG_ERROR, "%s: Video output association failed", qUtf8Printable(name)); + delete filterVideoCapture; + filterVideoCapture = nullptr; + return; + } + filterVideoCapture->setActive(true); + } else { + // Source output mode (default): use obs_view for the parent source + view = obs_view_create(); + obs_view_set_source(view, 0, parent); + + videoOutput = obs_view_add2(view, &ovi); + if (!videoOutput) { + obs_log(LOG_ERROR, "%s: Video output association failed", qUtf8Printable(name)); + return; + } } //--- Open audio output(s) ---// @@ -844,9 +992,17 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) masterTrack, track, audioDest ); - audioContext->mixIndex = masterTrack - 1; - audioContext->audio = obs_get_audio(); - audioContext->name = QTStr("MasterTrack%1").arg(masterTrack); + if (blankWhenHidden && muteWhenHidden) { + audioContext->capture = + new MasterAudioCapture(masterTrack - 1, ai.samples_per_sec, ai.speakers, this); + audioContext->audio = audioContext->capture->getAudio(); + audioContext->mixIndex = 0; + audioContext->name = audioContext->capture->getName(); + } else { + audioContext->mixIndex = masterTrack - 1; + audioContext->audio = obs_get_audio(); + audioContext->name = QTStr("MasterTrack%1").arg(masterTrack); + } } else if (!strcmp(audioSourceUuid, "filter")) { // Filter pipline's audio @@ -911,6 +1067,7 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) //--- Setup video encoder ---// auto video_encoder_id = obs_data_get_string(settings, "video_encoder"); + videoEncoder = obs_video_encoder_create(video_encoder_id, qUtf8Printable(name), settings, nullptr); if (!videoEncoder) { obs_log(LOG_ERROR, "%s: Video encoder creation failed", qUtf8Printable(name)); @@ -943,6 +1100,11 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) obs_encoder_set_audio(audioContext->encoder, audioContext->audio); } + if (blankWhenHidden) { + bool visibleInProgram = sourceVisibleInProgram(parent); + setBlankingActive(!visibleInProgram, muteWhenHidden, parent); + } + //--- Start recording output (if requested) ---// if (isRecordingEnabled(settings)) { recordingPending = (sourceWidth == 0 || sourceHeight == 0) && @@ -954,6 +1116,11 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) } } + //--- Start replay buffer (if requested) ---// + if (isReplayBufferEnabled(settings)) { + createAndStartReplayBuffer(settings); + } + //--- Start streaming output (if requested) ---// for (size_t i = 0; i < MAX_SERVICES; i++) { startStreamingOutput(i); @@ -1056,6 +1223,7 @@ void BranchOutputFilter::loadRecently(obs_data_t *settings) } obs_data_erase(recently_settings, "stream_recording"); + obs_data_erase(recently_settings, "streaming_enabled"); obs_data_erase(recently_settings, "custom_audio_source"); obs_data_erase(recently_settings, "multitrack_audio"); @@ -1078,12 +1246,12 @@ void BranchOutputFilter::loadRecently(obs_data_t *settings) void BranchOutputFilter::restartOutput() { - if (countActiveStreamings() > 0 || recordingActive) { + if (countActiveStreamings() > 0 || recordingActive || replayBufferActive) { stopOutput(); } OBSDataAutoRelease settings = obs_source_get_settings(filterSource); - if (countEnabledStreamings(settings) > 0 || isRecordingEnabled(settings)) { + if (isStreamingGroupEnabled(settings) || isRecordingEnabled(settings) || isReplayBufferEnabled(settings)) { startOutput(settings); } } @@ -1116,6 +1284,11 @@ int BranchOutputFilter::countEnabledStreamings(obs_data_t *settings) return count; } +bool BranchOutputFilter::isStreamingGroupEnabled(obs_data_t *settings) +{ + return obs_data_get_bool(settings, "streaming_enabled") && countEnabledStreamings(settings) > 0; +} + int BranchOutputFilter::countAliveStreamings() { int count = 0; @@ -1200,7 +1373,7 @@ void BranchOutputFilter::onIntervalTimerTimeout() auto sourceEnabled = obs_source_enabled(filterSource); auto streamingActive = countActiveStreamings() > 0; - if (!streamingActive && !recordingActive && !recordingPending) { + if (!streamingActive && !recordingActive && !recordingPending && !replayBufferActive) { // Evaluate start condition auto parent = obs_filter_get_parent(filterSource); if (!parent || !sourceInFrontend(parent)) { @@ -1211,7 +1384,9 @@ void BranchOutputFilter::onIntervalTimerTimeout() if (sourceEnabled) { // Clicked filter's "Eye" icon (Show) // Check interlock condition - if (interlockType == INTERLOCK_TYPE_STREAMING) { + if (interlockType == INTERLOCK_TYPE_ALWAYS_OFF) { + // Never start output + } else if (interlockType == INTERLOCK_TYPE_STREAMING) { if (obs_frontend_streaming_active()) { restartOutput(); return; @@ -1231,6 +1406,11 @@ void BranchOutputFilter::onIntervalTimerTimeout() restartOutput(); return; } + } else if (interlockType == INTERLOCK_TYPE_REPLAY_BUFFER) { + if (obs_frontend_replay_buffer_active()) { + restartOutput(); + return; + } } else { restartOutput(); return; @@ -1247,8 +1427,16 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } + OBSDataAutoRelease settings = obs_source_get_settings(filterSource); + bool blankWhenHidden = obs_data_get_bool(settings, "blank_when_not_visible"); + bool muteWhenHidden = obs_data_get_bool(settings, "mute_audio_when_blank"); + // Check interlock condition - if (interlockType == INTERLOCK_TYPE_STREAMING) { + if (interlockType == INTERLOCK_TYPE_ALWAYS_OFF) { + // Always OFF: Stop output immediately + onStopOutputGracefully(); + return; + } else if (interlockType == INTERLOCK_TYPE_STREAMING) { if (!obs_frontend_streaming_active()) { // Stop output when streaming is not active onStopOutputGracefully(); @@ -1272,6 +1460,12 @@ void BranchOutputFilter::onIntervalTimerTimeout() onStopOutputGracefully(); return; } + } else if (interlockType == INTERLOCK_TYPE_REPLAY_BUFFER) { + if (!obs_frontend_replay_buffer_active()) { + // Stop output when replay buffer is not active + onStopOutputGracefully(); + return; + } } if (activeSettingsRev < storedSettingsRev) { @@ -1281,13 +1475,14 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } - if (streamingAlive || recordingAlive || recordingPending) { + if (streamingAlive || recordingAlive || recordingPending || replayBufferActive) { // Monitoring source auto parent = obs_filter_get_parent(filterSource); - auto sourceWidth = obs_source_get_width(parent); - sourceWidth += (sourceWidth & 1); - uint32_t sourceHeight = obs_source_get_height(parent); - sourceHeight += (sourceHeight & 1); + + // Resolve input resolution based on video source type + uint32_t sourceWidth; + uint32_t sourceHeight; + getSourceResolution(sourceWidth, sourceHeight); if (!sourceInFrontend(parent)) { // Stop output when source had been removed @@ -1295,9 +1490,22 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } - if (width != sourceWidth || height != sourceHeight) { + bool visibleInProgram = true; + if (blankWhenHidden) { + visibleInProgram = sourceVisibleInProgram(parent); + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); + setBlankingActive(!visibleInProgram, muteWhenHidden, parent); + } + } + + // When blanking because the source is not visible, some sources report unstable base sizes. + // Avoid restart storms while hidden; resolution will be re-evaluated when visible again. + bool skipResolutionRestart = blankWhenHidden && !visibleInProgram; + + if (!skipResolutionRestart && (width != sourceWidth || height != sourceHeight)) { // Source resolution was changed - OBSDataAutoRelease settings = obs_source_get_settings(filterSource); if (sourceWidth > 0 && sourceHeight > 0) { if (!obs_data_get_bool(settings, "keep_output_base_resolution")) { // Restart output when source resolution was changed. @@ -1352,8 +1560,8 @@ void BranchOutputFilter::onIntervalTimerTimeout() // If the output has not yet been created. // Create and start recording when recording output was pending. obs_log(LOG_INFO, "%s: Attempting resume the recording output", qUtf8Printable(name)); - OBSDataAutoRelease settings = obs_source_get_settings(filterSource); - createAndStartRecordingOutput(settings); + OBSDataAutoRelease pendingSettings = obs_source_get_settings(filterSource); + createAndStartRecordingOutput(pendingSettings); return; } } @@ -1375,7 +1583,7 @@ void BranchOutputFilter::onIntervalTimerTimeout() } } else { - if (streamingActive || recordingActive || recordingPending) { + if (streamingActive || recordingActive || recordingPending || replayBufferActive) { // Clicked filter's "Eye" icon (Hide) onStopOutputGracefully(); return; @@ -1386,8 +1594,9 @@ void BranchOutputFilter::onIntervalTimerTimeout() void BranchOutputFilter::onStopOutputGracefully() { - // Stop recording immediately first + // Stop recording and replay buffer immediately first stopRecordingOutput(); + stopReplayBufferOutput(); // Lock out other output thread to prevent crash pthread_mutex_lock(&pluginMutex); @@ -1528,6 +1737,68 @@ bool BranchOutputFilter::addChapterToRecording(QString chapterName) } } +void BranchOutputFilter::setAudioCapturesActive(bool active) +{ + for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { + auto audioContext = &audios[i]; + if (audioContext->capture) { + audioContext->capture->setActive(active); + } + } +} + +void BranchOutputFilter::setBlankingActive(bool active, bool muteAudio, obs_source_t *parent) +{ + if (!parent) { + parent = obs_filter_get_parent(filterSource); + } + + if (!view) { + blankingOutputActive = false; + if (blankingAudioMuted) { + setAudioCapturesActive(true); + blankingAudioMuted = false; + } + return; + } + + if (active) { + if (!blankingOutputActive) { + obs_view_set_source(view, 0, nullptr); + if (muteAudio) { + setAudioCapturesActive(false); + blankingAudioMuted = true; + } else { + blankingAudioMuted = false; + } + blankingOutputActive = true; + obs_log(LOG_INFO, "%s: Output blanked because source is not visible", qUtf8Printable(name)); + } else { + if (muteAudio && !blankingAudioMuted) { + setAudioCapturesActive(false); + blankingAudioMuted = true; + } else if (!muteAudio && blankingAudioMuted) { + setAudioCapturesActive(true); + blankingAudioMuted = false; + } + } + } else { + if (blankingOutputActive) { + if (useFilterInput && filterVideoCapture) { + obs_view_set_source(view, 0, filterVideoCapture->getProxySource()); + } else if (parent) { + obs_view_set_source(view, 0, parent); + } + blankingOutputActive = false; + obs_log(LOG_INFO, "%s: Output resumed because source became visible", qUtf8Printable(name)); + } + if (blankingAudioMuted) { + setAudioCapturesActive(true); + blankingAudioMuted = false; + } + } +} + bool BranchOutputFilter::onEnableFilterHotkeyPressed(void *data, obs_hotkey_pair_id, obs_hotkey *, bool pressed) { if (!pressed) { @@ -1624,6 +1895,10 @@ void BranchOutputFilter::registerHotkey() // Unregsiter previous obs_hotkey_unregister(addChapterToRecordingHotkeyId); } + if (saveReplayBufferHotkeyId != OBS_INVALID_HOTKEY_ID) { + // Unregsiter previous + obs_hotkey_unregister(saveReplayBufferHotkeyId); + } // Register enable/disable hotkeys auto enableFilterName = QString("EnableFilter.%1").arg(obs_source_get_uuid(filterSource)); @@ -1666,6 +1941,14 @@ void BranchOutputFilter::registerHotkey() obs_filter_get_parent(filterSource), qUtf8Printable(addChapterName), qUtf8Printable(addChapterDescription), onAddChapterToRecordingFileHotkeyPressed, this ); + + // Register save replay buffer hotkey + auto saveReplayName = QString("SaveReplayBuffer.%1").arg(obs_source_get_uuid(filterSource)); + auto saveReplayDescription = QString(obs_module_text("SaveReplayBufferHotkey")).arg(name); + saveReplayBufferHotkeyId = obs_hotkey_register_source( + obs_filter_get_parent(filterSource), qUtf8Printable(saveReplayName), qUtf8Printable(saveReplayDescription), + onSaveReplayBufferHotkeyPressed, this + ); } // Callback from filter audio @@ -1710,6 +1993,10 @@ obs_source_info BranchOutputFilter::createFilterInfo() auto filter = static_cast(data); filter->videoRenderCallback(effect); }; + info.video_tick = [](void *data, float seconds) { + auto filter = static_cast(data); + filter->videoTickCallback(seconds); + }; info.filter_remove = [](void *data, obs_source_t *) { auto filter = static_cast(data); filter->removeCallback(); @@ -1733,12 +2020,16 @@ obs_source_info BranchOutputFilter::createFilterInfo() //--- OBS Plugin Callbacks ---// obs_source_info filterInfo; +obs_source_info proxySourceInfo; bool obs_module_load() { filterInfo = BranchOutputFilter::createFilterInfo(); obs_register_source(&filterInfo); + proxySourceInfo = FilterVideoCapture::createProxySourceInfo(); + obs_register_source(&proxySourceInfo); + pthread_mutex_init(&pluginMutex, nullptr); obs_log(LOG_INFO, "Plugin loaded successfully (version %s)", PLUGIN_VERSION); diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index de101ac..8f186e7 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -29,6 +29,7 @@ with this program. If not, see #include "UI/output-status-dock.hpp" #include "audio/audio-capture.hpp" +#include "video/filter-video-capture.hpp" #define MAX_SERVICES 8 @@ -44,6 +45,8 @@ class BranchOutputFilter : public QObject { INTERLOCK_TYPE_RECORDING, INTERLOCK_TYPE_STREAMING_RECORDING, INTERLOCK_TYPE_VIRTUAL_CAM, + INTERLOCK_TYPE_REPLAY_BUFFER, + INTERLOCK_TYPE_ALWAYS_OFF = 9999, }; struct BranchOutputAudioContext { @@ -75,6 +78,8 @@ class BranchOutputFilter : public QObject { uint32_t activeSettingsRev; QTimer *intervalTimer; bool streamingStopping; + bool blankingOutputActive; + bool blankingAudioMuted; // Filter source (Do not use OBSSourceAutoRelease) obs_source_t *filterSource; @@ -88,6 +93,12 @@ class BranchOutputFilter : public QObject { uint32_t width; uint32_t height; + // Filter input mode flag + bool useFilterInput; + + // Filter input video capture (captures filter input and provides proxy source for obs_view) + FilterVideoCapture *filterVideoCapture; + // Audio context BranchOutputAudioContext audios[MAX_AUDIO_MIXES]; @@ -98,6 +109,11 @@ class BranchOutputFilter : public QObject { bool splitRecordingEnabled; bool addChapterToRecordingEnabled; + // Replay buffer context + bool replayBufferActive; + OBSOutputAutoRelease replayBufferOutput; + OBSSignal replayBufferSavedSignal; + // Streaming context pthread_mutex_t outputMutex; BranchOutputStreamingContext streamings[MAX_SERVICES]; @@ -107,6 +123,7 @@ class BranchOutputFilter : public QObject { obs_hotkey_id splitRecordingHotkeyId; obs_hotkey_pair_id togglePauseRecordingHotkeyPairId; obs_hotkey_id addChapterToRecordingHotkeyId; + obs_hotkey_id saveReplayBufferHotkeyId; OBSSignal filterRenamedSignal; @@ -114,12 +131,18 @@ class BranchOutputFilter : public QObject { void stopOutput(); obs_data_t *createRecordingSettings(obs_data_t *settings, bool createFolder = false); obs_data_t *createStreamingSettings(obs_data_t *settings, size_t index = 0); + void getSourceResolution(uint32_t &outWidth, uint32_t &outHeight); void determineOutputResolution(obs_data_t *settings, obs_video_info *ovi); BranchOutputStreamingContext createSreamingOutput(obs_data_t *settings, size_t index = 0); void startStreamingOutput(size_t index = 0); void stopStreamingOutput(size_t index = 0); void createAndStartRecordingOutput(obs_data_t *settings); void stopRecordingOutput(); + void createAndStartReplayBuffer(obs_data_t *settings); + void stopReplayBufferOutput(); + obs_data_t *createReplayBufferSettings(obs_data_t *settings); + bool isReplayBufferEnabled(obs_data_t *settings); + bool saveReplayBuffer(); void reconnectStreamingOutput(size_t index = 0); void restartRecordingOutput(); void loadProfile(obs_data_t *settings); @@ -131,6 +154,7 @@ class BranchOutputFilter : public QObject { int countAliveStreamings(); int countActiveStreamings(); bool hasEnabledStreamings(obs_data_t *settings); + bool isStreamingGroupEnabled(obs_data_t *settings); bool isStreamingEnabled(obs_data_t *settings, size_t index = 0); bool isRecordingEnabled(obs_data_t *settings); bool isSplitRecordingEnabled(obs_data_t *settings); @@ -142,17 +166,22 @@ class BranchOutputFilter : public QObject { bool pauseRecording(); bool unpauseRecording(); bool addChapterToRecording(QString chapterName = QString()); + void setBlankingActive(bool active, bool muteAudio, obs_source_t *parent); + void setAudioCapturesActive(bool active); // Implemented in plugin-ui.cpp void addApplyButton(obs_properties_t *props, const char *propName = "apply"); void addPluginInfo(obs_properties_t *props); - void addStreamGroup(obs_properties_t *props); + void addStreamingGroup(obs_properties_t *props); + void addRecordingGroup(obs_properties_t *props); void addServices(obs_properties_t *props); void createServiceProperties(obs_properties_t *props, size_t index, bool visible = true); void createAudioTrackProperties(obs_properties_t *audioGroup, size_t track, bool visible = true); void addAudioGroup(obs_properties_t *props); void addAudioEncoderGroup(obs_properties_t *props); void addVideoEncoderGroup(obs_properties_t *props); + void addAdvancedSettingsGroup(obs_properties_t *props); + void addReplayBufferGroup(obs_properties_t *props); // Callbacks from obs core static bool onEnableFilterHotkeyPressed(void *data, obs_hotkey_pair_id id, obs_hotkey *hotkey, bool pressed); @@ -162,9 +191,12 @@ class BranchOutputFilter : public QObject { static bool onUnpauseRecordingHotkeyPressed(void *data, obs_hotkey_pair_id id, obs_hotkey *hotkey, bool pressed); static void onAddChapterToRecordingFileHotkeyPressed(void *data, obs_hotkey_id id, obs_hotkey *hotkey, bool pressed); + static void onSaveReplayBufferHotkeyPressed(void *data, obs_hotkey_id id, obs_hotkey *hotkey, bool pressed); + static void onReplayBufferSaved(void *data, calldata_t *cd); void addCallback(obs_source_t *source); void updateCallback(obs_data_t *settings); + void videoTickCallback(float seconds); void videoRenderCallback(gs_effect_t *effect); void destroyCallback(); obs_properties_t *getProperties(); diff --git a/src/plugin-replay-buffer.cpp b/src/plugin-replay-buffer.cpp new file mode 100644 index 0000000..de71a15 --- /dev/null +++ b/src/plugin-replay-buffer.cpp @@ -0,0 +1,225 @@ +/* +Branch Output Plugin +Copyright (C) 2024 OPENSPHERE Inc. info@opensphere.co.jp + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include +#include +#include +#include +#include +#include + +#include + +#include "plugin-support.h" +#include "plugin-main.hpp" +#include "utils.hpp" + +void BranchOutputFilter::stopReplayBufferOutput() +{ + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + + if (replayBufferOutput) { + if (replayBufferActive) { + obs_source_t *parent = obs_filter_get_parent(filterSource); + if (parent) { + obs_source_dec_showing(parent); + } + obs_output_stop(replayBufferOutput); + } + } + replayBufferSavedSignal.Disconnect(); + replayBufferOutput = nullptr; + + if (replayBufferActive) { + replayBufferActive = false; + obs_log(LOG_INFO, "%s: Stopping replay buffer succeeded", qUtf8Printable(name)); + } + } +} + +obs_data_t *BranchOutputFilter::createReplayBufferSettings(obs_data_t *settings) +{ + auto replaySettings = obs_data_create(); + auto config = obs_frontend_get_profile_config(); + + // Use replay buffer specific path settings + auto useProfilePath = obs_data_get_bool(settings, "replay_buffer_use_profile_path"); + auto path = useProfilePath ? getProfileRecordingPath(config) : obs_data_get_string(settings, "replay_buffer_path"); + auto rbFormat = obs_data_get_string(settings, "replay_buffer_format"); + + // Validate path + if (!path || !path[0]) { + obs_log(LOG_ERROR, "%s: Replay buffer path is not set", qUtf8Printable(name)); + obs_data_release(replaySettings); + return nullptr; + } + + // Create directory + int ret = os_mkdirs(path); + if (ret == MKDIR_ERROR) { + obs_log(LOG_ERROR, "%s: Failed to create replay buffer directory: %s", qUtf8Printable(name), path); + obs_data_release(replaySettings); + return nullptr; + } + + // Replay buffer specific filename format + QString filenameFormat = obs_data_get_string(settings, "replay_buffer_filename_formatting"); + if (filenameFormat.isEmpty()) { + filenameFormat = config_get_string(config, "Output", "FilenameFormatting"); + } + + // Sanitize filename +#ifdef __APPLE__ + filenameFormat.replace(QRegularExpression("[:]"), ""); +#elif defined(_WIN32) + filenameFormat.replace(QRegularExpression("[<>:\"\\|\\?\\*]"), ""); +#else + // TODO: Add filtering for other platforms +#endif + + QString sourceName = obs_source_get_name(obs_filter_get_parent(filterSource)); + QString filterName = qUtf8Printable(name); + bool noSpace = obs_data_get_bool(settings, "replay_buffer_no_space_filename"); + auto re = noSpace ? QRegularExpression("[\\s/\\\\.:;*?\"<>|&$,]") : QRegularExpression("[/\\\\.:;*?\"<>|&$,]"); + filenameFormat = filenameFormat.arg(sourceName.replace(re, "-")).arg(filterName.replace(re, "-")); + + obs_data_set_string(replaySettings, "directory", path); + obs_data_set_string(replaySettings, "format", qUtf8Printable(filenameFormat)); + obs_data_set_string(replaySettings, "extension", qUtf8Printable(getFormatExt(rbFormat))); + obs_data_set_bool(replaySettings, "allow_spaces", !noSpace); + obs_data_set_int(replaySettings, "max_time_sec", obs_data_get_int(settings, "replay_buffer_duration")); + obs_data_set_int(replaySettings, "max_size_mb", 512); + + // Fragmented MP4/MOV support + bool isFragmented = strncmp(rbFormat, "fragmented", 10) == 0; + if (isFragmented) { + obs_data_set_string(replaySettings, "muxer_settings", "movflags=frag_keyframe+empty_moov+delay_moov"); + } + + return replaySettings; +} + +void BranchOutputFilter::createAndStartReplayBuffer(obs_data_t *settings) +{ + if (!videoEncoder) { + return; + } + + OBSDataAutoRelease rbSettings = createReplayBufferSettings(settings); + if (!rbSettings) { + obs_log(LOG_ERROR, "%s: Replay buffer settings creation failed", qUtf8Printable(name)); + return; + } + + replayBufferOutput = obs_output_create("replay_buffer", qUtf8Printable(name), rbSettings, nullptr); + if (!replayBufferOutput) { + obs_log(LOG_ERROR, "%s: Replay buffer output creation failed", qUtf8Printable(name)); + return; + } + + // Bind audio encoders (same logic as recording) + size_t encIndex = 0; + for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { + auto audioContext = &audios[i]; + if (!audioContext->encoder || !audioContext->recording) { + continue; + } + + obs_output_set_audio_encoder(replayBufferOutput, audioContext->encoder, encIndex++); + } + + if (!encIndex) { + // No audio encoder -> fallback first available encoder + for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { + auto audioContext = &audios[i]; + if (audioContext->encoder) { + obs_log( + LOG_WARNING, "%s: No audio encoder selected for replay buffer, using track %d", + qUtf8Printable(name), i + 1 + ); + obs_output_set_audio_encoder(replayBufferOutput, audioContext->encoder, encIndex++); + break; + } + } + if (!encIndex) { + obs_log(LOG_ERROR, "%s: No audio encoder for replay buffer", qUtf8Printable(name)); + return; + } + } + + obs_output_set_video_encoder(replayBufferOutput, videoEncoder); + + // Connect "saved" signal + auto handler = obs_output_get_signal_handler(replayBufferOutput); + replayBufferSavedSignal.Connect(handler, "saved", onReplayBufferSaved, this); + + // Start replay buffer output + if (obs_output_start(replayBufferOutput)) { + replayBufferActive = true; + auto parent = obs_filter_get_parent(filterSource); + if (parent) { + obs_source_inc_showing(parent); + } + obs_log(LOG_INFO, "%s: Starting replay buffer succeeded", qUtf8Printable(name)); + } else { + obs_log(LOG_ERROR, "%s: Starting replay buffer failed", qUtf8Printable(name)); + } +} + +bool BranchOutputFilter::isReplayBufferEnabled(obs_data_t *settings) +{ + return obs_data_get_bool(settings, "replay_buffer"); +} + +bool BranchOutputFilter::saveReplayBuffer() +{ + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + + if (!replayBufferActive || !replayBufferOutput) { + return false; + } + + auto ph = obs_output_get_proc_handler(replayBufferOutput); + calldata_t cd = {0}; + proc_handler_call(ph, "save", &cd); + calldata_free(&cd); + + obs_log(LOG_INFO, "%s: Replay buffer save triggered", qUtf8Printable(name)); + return true; + } +} + +void BranchOutputFilter::onReplayBufferSaved(void *data, calldata_t *) +{ + auto filter = static_cast(data); + obs_log(LOG_INFO, "%s: Replay buffer saved", qUtf8Printable(filter->name)); +} + +void BranchOutputFilter::onSaveReplayBufferHotkeyPressed(void *data, obs_hotkey_id, obs_hotkey *, bool pressed) +{ + if (!pressed) { + return; + } + + auto filter = static_cast(data); + filter->saveReplayBuffer(); +} diff --git a/src/plugin-ui.cpp b/src/plugin-ui.cpp index 99d8ec2..3ee54b9 100644 --- a/src/plugin-ui.cpp +++ b/src/plugin-ui.cpp @@ -155,6 +155,7 @@ void BranchOutputFilter::getDefaults(obs_data_t *defaults) obs_data_set_default_string(defaults, "audio_encoder", audioEncoderId); obs_data_set_default_string(defaults, "video_encoder", videoEncoderId); obs_data_set_default_int(defaults, "audio_bitrate", audioBitrate); + obs_data_set_default_bool(defaults, "streaming_enabled", false); obs_data_set_default_bool(defaults, "stream_recording", false); obs_data_set_default_bool(defaults, "use_profile_recording_path", false); obs_data_set_default_string(defaults, "audio_source", "master_track"); @@ -183,6 +184,9 @@ void BranchOutputFilter::getDefaults(obs_data_t *defaults) obs_data_set_default_int(defaults, "split_file_size_mb", recSplitFileSizeMb); obs_data_set_default_bool(defaults, "keep_output_base_resolution", false); obs_data_set_default_bool(defaults, "suspend_recording_when_source_collapsed", false); + obs_data_set_default_bool(defaults, "blank_when_not_visible", false); + obs_data_set_default_bool(defaults, "mute_audio_when_blank", false); + obs_data_set_default_string(defaults, "video_source_type", "source"); obs_data_set_default_string(defaults, "rec_muxer_custom", mux); auto path = getProfileRecordingPath(config); @@ -192,6 +196,15 @@ void BranchOutputFilter::getDefaults(obs_data_t *defaults) auto filenameFormatting = QString("%1 %2 ") + QString(config_get_string(config, "Output", "FilenameFormatting")); obs_data_set_default_string(defaults, "filename_formatting", qUtf8Printable(filenameFormatting)); + // Replay buffer defaults + obs_data_set_default_bool(defaults, "replay_buffer", false); + obs_data_set_default_int(defaults, "replay_buffer_duration", 20); + obs_data_set_default_bool(defaults, "replay_buffer_use_profile_path", false); + obs_data_set_default_string(defaults, "replay_buffer_path", path); + obs_data_set_default_string(defaults, "replay_buffer_filename_formatting", qUtf8Printable(filenameFormatting)); + obs_data_set_default_bool(defaults, "replay_buffer_no_space_filename", fileNameWithoutSpace); + obs_data_set_default_string(defaults, "replay_buffer_format", recFormat); + obs_log(LOG_INFO, "Default settings applied."); } @@ -287,6 +300,31 @@ void BranchOutputFilter::createServiceProperties(obs_properties_t *props, size_t ); } +static void updateServiceVisibility(obs_properties_t *props, obs_data_t *settings) +{ + auto streamingEnabled = obs_data_get_bool(settings, "streaming_enabled"); + auto count = obs_data_get_int(settings, "service_count"); + + for (int i = 0; i < MAX_SERVICES; i++) { + QString propNameFormat = getIndexedPropNameFormat(i); + bool visible = streamingEnabled && i < count; + auto useAuth = obs_data_get_bool(settings, qUtf8Printable(propNameFormat.arg("use_auth"))); + + obs_property_set_visible( + obs_properties_get(props, qUtf8Printable(propNameFormat.arg("service_group"))), visible + ); + obs_property_set_visible(obs_properties_get(props, qUtf8Printable(propNameFormat.arg("server"))), visible); + obs_property_set_visible(obs_properties_get(props, qUtf8Printable(propNameFormat.arg("key"))), visible); + obs_property_set_visible(obs_properties_get(props, qUtf8Printable(propNameFormat.arg("use_auth"))), visible); + obs_property_set_visible( + obs_properties_get(props, qUtf8Printable(propNameFormat.arg("username"))), visible && useAuth + ); + obs_property_set_visible( + obs_properties_get(props, qUtf8Printable(propNameFormat.arg("password"))), visible && useAuth + ); + } +} + void BranchOutputFilter::addServices(obs_properties_t *props) { auto serviceCountList = obs_properties_add_list( @@ -305,77 +343,60 @@ void BranchOutputFilter::addServices(obs_properties_t *props) obs_property_set_modified_callback2( serviceCountList, [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { - auto count = obs_data_get_int(settings, "service_count"); - - for (int i = 0; i < MAX_SERVICES; i++) { - QString propNameFormat = getIndexedPropNameFormat(i); - auto useAuth = obs_data_get_bool(settings, qUtf8Printable(propNameFormat.arg("use_auth"))); - - obs_property_set_visible( - obs_properties_get(_props, qUtf8Printable(propNameFormat.arg("service_group"))), i < count - ); - obs_property_set_visible( - obs_properties_get(_props, qUtf8Printable(propNameFormat.arg("server"))), i < count - ); - obs_property_set_visible( - obs_properties_get(_props, qUtf8Printable(propNameFormat.arg("key"))), i < count - ); - obs_property_set_visible( - obs_properties_get(_props, qUtf8Printable(propNameFormat.arg("use_auth"))), i < count - ); - obs_property_set_visible( - obs_properties_get(_props, qUtf8Printable(propNameFormat.arg("username"))), useAuth && i < count - ); - obs_property_set_visible( - obs_properties_get(_props, qUtf8Printable(propNameFormat.arg("password"))), useAuth && i < count - ); - } - + updateServiceVisibility(_props, settings); return true; }, nullptr ); } -void BranchOutputFilter::addStreamGroup(obs_properties_t *props) +void BranchOutputFilter::addStreamingGroup(obs_properties_t *props) { - auto streamGroup = obs_properties_create(); + auto streamingGroup = obs_properties_create(); + + // Description text shown when group is unchecked + obs_properties_add_text( + streamingGroup, "streaming_description", obs_module_text("StreamingDescription"), OBS_TEXT_INFO + ); // Add multi services properties - addServices(streamGroup); + addServices(streamingGroup); - // Add gap line - obs_properties_add_text(streamGroup, "stream_recording_group", "", OBS_TEXT_INFO); - - auto streamRecording = obs_properties_add_bool(streamGroup, "stream_recording", obs_module_text("StreamRecording")); - - auto streamRecordingChangeHandler = [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { - auto _streamRecording = obs_data_get_bool(settings, "stream_recording"); - obs_property_set_visible(obs_properties_get(_props, "use_profile_recording_path"), _streamRecording); - obs_property_set_visible(obs_properties_get(_props, "path"), _streamRecording); - obs_property_set_visible(obs_properties_get(_props, "no_space_filename"), _streamRecording); - obs_property_set_visible(obs_properties_get(_props, "filename_formatting"), _streamRecording); - obs_property_set_visible(obs_properties_get(_props, "rec_format"), _streamRecording); - obs_property_set_visible(obs_properties_get(_props, "split_file"), _streamRecording); - obs_property_set_visible(obs_properties_get(_props, "rec_muxer_custom"), _streamRecording); - obs_property_set_visible( - obs_properties_get(_props, "suspend_recording_when_source_collapsed"), _streamRecording - ); + // Group with checkable toggle + auto streamingProp = obs_properties_add_group( + props, "streaming_enabled", obs_module_text("Streaming"), OBS_GROUP_CHECKABLE, streamingGroup + ); - auto splitFile = obs_data_get_string(settings, "split_file"); - obs_property_set_visible( - obs_properties_get(_props, "split_file_time_mins"), _streamRecording && !strcmp(splitFile, "by_time") - ); - obs_property_set_visible( - obs_properties_get(_props, "split_file_size_mb"), _streamRecording && !strcmp(splitFile, "by_size") - ); - return true; - }; - obs_property_set_modified_callback2(streamRecording, streamRecordingChangeHandler, nullptr); + // Hide group contents when unchecked + obs_property_set_modified_callback2( + streamingProp, + [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { + auto _streamingEnabled = obs_data_get_bool(settings, "streaming_enabled"); + obs_property_set_visible(obs_properties_get(_props, "streaming_description"), !_streamingEnabled); + obs_property_set_visible(obs_properties_get(_props, "service_count"), _streamingEnabled); + updateServiceVisibility(_props, settings); + obs_property_set_visible(obs_properties_get(_props, "apply1"), _streamingEnabled); + return true; + }, + nullptr + ); - //--- Recording options (initially hidden) ---// - auto useProfilePath = - obs_properties_add_bool(streamGroup, "use_profile_recording_path", obs_module_text("UseProfileRecordingPath")); + addApplyButton(props, "apply1"); +} + +void BranchOutputFilter::addRecordingGroup(obs_properties_t *props) +{ + auto recordingGroup = obs_properties_create(); + + // Description text shown when group is unchecked + obs_properties_add_text( + recordingGroup, "recording_description", obs_module_text("RecordingDescription"), OBS_TEXT_INFO + ); + + //--- Recording options ---// + auto useProfilePath = obs_properties_add_bool( + recordingGroup, "use_profile_recording_path", obs_module_text("UseProfileRecordingPath") + ); auto useProfilePathChangeHandler = [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { auto _useProfilePath = obs_data_get_bool(settings, "use_profile_recording_path"); @@ -384,16 +405,16 @@ void BranchOutputFilter::addStreamGroup(obs_properties_t *props) }; obs_property_set_modified_callback2(useProfilePath, useProfilePathChangeHandler, nullptr); - obs_properties_add_path(streamGroup, "path", obs_module_text("Path"), OBS_PATH_DIRECTORY, nullptr, nullptr); + obs_properties_add_path(recordingGroup, "path", obs_module_text("Path"), OBS_PATH_DIRECTORY, nullptr, nullptr); auto filenameFormatting = obs_properties_add_text( - streamGroup, "filename_formatting", obs_module_text("FilenameFormatting"), OBS_TEXT_DEFAULT + recordingGroup, "filename_formatting", obs_module_text("FilenameFormatting"), OBS_TEXT_DEFAULT ); obs_property_set_long_description(filenameFormatting, qUtf8Printable(makeFormatToolTip())); - obs_properties_add_bool(streamGroup, "no_space_filename", obs_module_text("NoSpaceFileName")); + obs_properties_add_bool(recordingGroup, "no_space_filename", obs_module_text("NoSpaceFileName")); // Only support limited formats auto fileFormatList = obs_properties_add_list( - streamGroup, "rec_format", obs_module_text("VideoFormat"), OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING + recordingGroup, "rec_format", obs_module_text("VideoFormat"), OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING ); obs_property_list_add_string(fileFormatList, obs_module_text("MKV"), "mkv"); obs_property_list_add_string(fileFormatList, obs_module_text("MP4"), "mp4"); @@ -405,36 +426,190 @@ void BranchOutputFilter::addStreamGroup(obs_properties_t *props) obs_property_set_long_description(fileFormatList, obs_module_text("VideoFormatNote")); auto splitFileList = obs_properties_add_list( - streamGroup, "split_file", obs_module_text("SplitFile"), OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING + recordingGroup, "split_file", obs_module_text("SplitFile"), OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING ); obs_property_list_add_string(splitFileList, obs_module_text("SplitFile.NoSplit"), ""); obs_property_list_add_string(splitFileList, obs_module_text("SplitFile.ByTime"), "by_time"); obs_property_list_add_string(splitFileList, obs_module_text("SplitFile.BySize"), "by_size"); obs_property_list_add_string(splitFileList, obs_module_text("SplitFile.Manual"), "manual"); - obs_property_set_modified_callback2(splitFileList, streamRecordingChangeHandler, nullptr); + auto splitFileChangeHandler = [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { + auto splitFile = obs_data_get_string(settings, "split_file"); + obs_property_set_visible(obs_properties_get(_props, "split_file_time_mins"), !strcmp(splitFile, "by_time")); + obs_property_set_visible(obs_properties_get(_props, "split_file_size_mb"), !strcmp(splitFile, "by_size")); + return true; + }; + obs_property_set_modified_callback2(splitFileList, splitFileChangeHandler, nullptr); - obs_properties_add_int(streamGroup, "split_file_time_mins", obs_module_text("SplitFile.Time"), 1, 525600, 1); - obs_properties_add_int(streamGroup, "split_file_size_mb", obs_module_text("SplitFile.Size"), 1, 1073741824, 1); + obs_properties_add_int(recordingGroup, "split_file_time_mins", obs_module_text("SplitFile.Time"), 1, 525600, 1); + obs_properties_add_int(recordingGroup, "split_file_size_mb", obs_module_text("SplitFile.Size"), 1, 1073741824, 1); // Mux custom setting - obs_properties_add_text(streamGroup, "rec_muxer_custom", obs_module_text("CustomMuxerSettings"), OBS_TEXT_DEFAULT); + obs_properties_add_text( + recordingGroup, "rec_muxer_custom", obs_module_text("CustomMuxerSettings"), OBS_TEXT_DEFAULT + ); // Pausing settings auto suspendRecordingWhenSourceCollapsed = obs_properties_add_bool( - streamGroup, "suspend_recording_when_source_collapsed", obs_module_text("SuspendRecordingWhenSourceCollapsed") + recordingGroup, "suspend_recording_when_source_collapsed", + obs_module_text("SuspendRecordingWhenSourceCollapsed") ); obs_property_set_long_description( suspendRecordingWhenSourceCollapsed, obs_module_text("SuspendRecordingWhenSourceCollapsedNote") ); + // Group with checkable toggle (reuse stream_recording key for backward compat) + auto recordingProp = obs_properties_add_group( + props, "stream_recording", obs_module_text("StreamRecording"), OBS_GROUP_CHECKABLE, recordingGroup + ); + + // Hide group contents when unchecked + obs_property_set_modified_callback2( + recordingProp, + [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { + auto _recordingEnabled = obs_data_get_bool(settings, "stream_recording"); + obs_property_set_visible(obs_properties_get(_props, "recording_description"), !_recordingEnabled); + obs_property_set_visible(obs_properties_get(_props, "use_profile_recording_path"), _recordingEnabled); + obs_property_set_visible(obs_properties_get(_props, "path"), _recordingEnabled); + obs_property_set_visible(obs_properties_get(_props, "filename_formatting"), _recordingEnabled); + obs_property_set_visible(obs_properties_get(_props, "no_space_filename"), _recordingEnabled); + obs_property_set_visible(obs_properties_get(_props, "rec_format"), _recordingEnabled); + obs_property_set_visible(obs_properties_get(_props, "split_file"), _recordingEnabled); + obs_property_set_visible(obs_properties_get(_props, "rec_muxer_custom"), _recordingEnabled); + obs_property_set_visible( + obs_properties_get(_props, "suspend_recording_when_source_collapsed"), _recordingEnabled + ); + + auto splitFile = obs_data_get_string(settings, "split_file"); + obs_property_set_visible( + obs_properties_get(_props, "split_file_time_mins"), _recordingEnabled && !strcmp(splitFile, "by_time") + ); + obs_property_set_visible( + obs_properties_get(_props, "split_file_size_mb"), _recordingEnabled && !strcmp(splitFile, "by_size") + ); + obs_property_set_visible(obs_properties_get(_props, "apply2"), _recordingEnabled); + return true; + }, + nullptr + ); + + addApplyButton(props, "apply2"); +} + +void BranchOutputFilter::addAdvancedSettingsGroup(obs_properties_t *props) +{ + auto advancedGroup = obs_properties_create(); + // Source resolution trackability auto keepOutputBaseResolution = obs_properties_add_bool( - streamGroup, "keep_output_base_resolution", obs_module_text("KeepOutputBaseResolution") + advancedGroup, "keep_output_base_resolution", obs_module_text("KeepOutputBaseResolution") ); obs_property_set_long_description(keepOutputBaseResolution, obs_module_text("KeepOutputBaseResolutionNote")); - obs_properties_add_group(props, "stream", obs_module_text("Stream"), OBS_GROUP_NORMAL, streamGroup); + auto blankWhenNotVisible = + obs_properties_add_bool(advancedGroup, "blank_when_not_visible", obs_module_text("BlankWhenNotVisible")); + obs_property_set_long_description(blankWhenNotVisible, obs_module_text("BlankWhenNotVisibleNote")); + + auto muteAudioWhenBlank = + obs_properties_add_bool(advancedGroup, "mute_audio_when_blank", obs_module_text("MuteAudioWhenBlank")); + obs_property_set_long_description(muteAudioWhenBlank, obs_module_text("MuteAudioWhenBlankNote")); + obs_property_set_enabled(muteAudioWhenBlank, false); + + obs_property_set_modified_callback2( + blankWhenNotVisible, + [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { + bool enabled = obs_data_get_bool(settings, "blank_when_not_visible"); + obs_property_set_enabled(obs_properties_get(_props, "mute_audio_when_blank"), enabled); + return true; + }, + nullptr + ); + + obs_properties_add_group( + props, "advanced_settings", obs_module_text("AdvancedSettings"), OBS_GROUP_NORMAL, advancedGroup + ); +} + +void BranchOutputFilter::addReplayBufferGroup(obs_properties_t *props) +{ + auto replayBufferGroup = obs_properties_create(); + + // Description text shown when group is unchecked + obs_properties_add_text( + replayBufferGroup, "replay_buffer_description", obs_module_text("ReplayBufferDescription"), OBS_TEXT_INFO + ); + + obs_properties_add_int( + replayBufferGroup, "replay_buffer_duration", obs_module_text("ReplayBufferDuration"), 1, 21600, 1 + ); + + //--- Replay buffer path settings ---// + auto rbUseProfilePath = obs_properties_add_bool( + replayBufferGroup, "replay_buffer_use_profile_path", obs_module_text("UseProfileRecordingPath") + ); + auto rbUseProfilePathChangeHandler = [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { + auto _useProfilePath = obs_data_get_bool(settings, "replay_buffer_use_profile_path"); + obs_property_set_enabled(obs_properties_get(_props, "replay_buffer_path"), !_useProfilePath); + return true; + }; + obs_property_set_modified_callback2(rbUseProfilePath, rbUseProfilePathChangeHandler, nullptr); + + obs_properties_add_path( + replayBufferGroup, "replay_buffer_path", obs_module_text("Path"), OBS_PATH_DIRECTORY, nullptr, nullptr + ); + auto rbFilenameFormatting = obs_properties_add_text( + replayBufferGroup, "replay_buffer_filename_formatting", obs_module_text("FilenameFormatting"), OBS_TEXT_DEFAULT + ); + obs_property_set_long_description(rbFilenameFormatting, qUtf8Printable(makeFormatToolTip())); + obs_properties_add_bool(replayBufferGroup, "replay_buffer_no_space_filename", obs_module_text("NoSpaceFileName")); + + //--- Replay buffer format (replay_buffer output compatible formats only) ---// + auto rbFormatList = obs_properties_add_list( + replayBufferGroup, "replay_buffer_format", obs_module_text("VideoFormat"), OBS_COMBO_TYPE_LIST, + OBS_COMBO_FORMAT_STRING + ); + obs_property_list_add_string(rbFormatList, obs_module_text("MKV"), "mkv"); + obs_property_list_add_string(rbFormatList, obs_module_text("MP4"), "mp4"); + obs_property_list_add_string(rbFormatList, obs_module_text("fMP4"), "fragmented_mp4"); + obs_property_list_add_string(rbFormatList, obs_module_text("MOV"), "mov"); + obs_property_list_add_string(rbFormatList, obs_module_text("fMOV"), "fragmented_mov"); + obs_property_list_add_string(rbFormatList, obs_module_text("TS"), "mpegts"); + + // Hotkey registration note (shown when replay buffer is enabled) + auto rbHotkeyNote = obs_properties_add_text( + replayBufferGroup, "replay_buffer_hotkey_note", obs_module_text("ReplayBufferHotkeyNote"), OBS_TEXT_INFO + ); + obs_property_set_visible(rbHotkeyNote, false); + + // Group with checkable toggle + auto replayBufferProp = obs_properties_add_group( + props, "replay_buffer", obs_module_text("ReplayBuffer"), OBS_GROUP_CHECKABLE, replayBufferGroup + ); + + // Hide group contents when unchecked + obs_property_set_modified_callback2( + replayBufferProp, + [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { + auto _replayBufferEnabled = obs_data_get_bool(settings, "replay_buffer"); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_description"), !_replayBufferEnabled); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_duration"), _replayBufferEnabled); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_use_profile_path"), _replayBufferEnabled); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_path"), _replayBufferEnabled); + obs_property_set_visible( + obs_properties_get(_props, "replay_buffer_filename_formatting"), _replayBufferEnabled + ); + obs_property_set_visible( + obs_properties_get(_props, "replay_buffer_no_space_filename"), _replayBufferEnabled + ); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_format"), _replayBufferEnabled); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_hotkey_note"), _replayBufferEnabled); + obs_property_set_visible(obs_properties_get(_props, "apply3"), _replayBufferEnabled); + return true; + }, + nullptr + ); + + addApplyButton(props, "apply3"); } void BranchOutputFilter::createAudioTrackProperties(obs_properties_t *audioGroup, size_t track, bool visible) @@ -520,9 +695,49 @@ void BranchOutputFilter::createAudioTrackProperties(obs_properties_t *audioGroup obs_property_set_visible(audioDestList, visible); } +static void updateAudioTrackVisibility(obs_properties_t *audioProps, bool customAudio, bool multitrackAudio) +{ + for (size_t track = 1; track <= MAX_AUDIO_MIXES; track++) { + auto propNameFormat = getIndexedPropNameFormat(track, 1); + + if (track > 1) { + obs_property_set_visible( + obs_properties_get(audioProps, qUtf8Printable(propNameFormat.arg("multitrack_audio_group"))), + customAudio && multitrackAudio + ); + obs_property_set_visible( + obs_properties_get(audioProps, qUtf8Printable(propNameFormat.arg("audio_source"))), + customAudio && multitrackAudio + ); + obs_property_set_visible( + obs_properties_get(audioProps, qUtf8Printable(propNameFormat.arg("audio_track"))), + customAudio && multitrackAudio + ); + } else { + obs_property_set_visible( + obs_properties_get(audioProps, qUtf8Printable(propNameFormat.arg("audio_source"))), customAudio + ); + obs_property_set_visible( + obs_properties_get(audioProps, qUtf8Printable(propNameFormat.arg("audio_track"))), customAudio + ); + } + + obs_property_set_visible( + obs_properties_get(audioProps, qUtf8Printable(propNameFormat.arg("audio_dest"))), + customAudio && multitrackAudio + ); + } +} + void BranchOutputFilter::addAudioGroup(obs_properties_t *props) { auto audioGroup = obs_properties_create(); + + // Description text shown when group is unchecked + obs_properties_add_text( + audioGroup, "custom_audio_description", obs_module_text("CustomAudioSourceDescription"), OBS_TEXT_INFO + ); + createAudioTrackProperties(audioGroup, 1); auto multitrackAudio = obs_properties_add_bool(audioGroup, "multitrack_audio", obs_module_text("MultitrackAudio")); @@ -534,29 +749,9 @@ void BranchOutputFilter::addAudioGroup(obs_properties_t *props) obs_property_set_modified_callback2( multitrackAudio, [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { + auto customAudio = obs_data_get_bool(settings, "custom_audio_source"); auto _multitrackAudio = obs_data_get_bool(settings, "multitrack_audio"); - - for (size_t track = 1; track <= MAX_AUDIO_MIXES; track++) { - auto propNameFormat = getIndexedPropNameFormat(track, 1); - - if (track > 1) { - obs_property_set_visible( - obs_properties_get(_props, qUtf8Printable(propNameFormat.arg("multitrack_audio_group"))), - _multitrackAudio - ); - obs_property_set_visible( - obs_properties_get(_props, qUtf8Printable(propNameFormat.arg("audio_source"))), _multitrackAudio - ); - obs_property_set_visible( - obs_properties_get(_props, qUtf8Printable(propNameFormat.arg("audio_track"))), _multitrackAudio - ); - } - - obs_property_set_visible( - obs_properties_get(_props, qUtf8Printable(propNameFormat.arg("audio_dest"))), _multitrackAudio - ); - } - + updateAudioTrackVisibility(_props, customAudio, _multitrackAudio); return true; }, nullptr @@ -565,6 +760,36 @@ void BranchOutputFilter::addAudioGroup(obs_properties_t *props) obs_properties_add_group( props, "custom_audio_source", obs_module_text("CustomAudioSource"), OBS_GROUP_CHECKABLE, audioGroup ); + + addApplyButton(props, "apply4"); + + // Control visibility of AudioGroup contents and apply4 based on custom_audio_source + auto customAudioSourceProp = obs_properties_get(props, "custom_audio_source"); + obs_property_set_modified_callback2( + customAudioSourceProp, + [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { + auto customAudio = obs_data_get_bool(settings, "custom_audio_source"); + auto _multitrackAudio = obs_data_get_bool(settings, "multitrack_audio"); + + // Get group content properties + auto groupProp = obs_properties_get(_props, "custom_audio_source"); + auto groupContent = obs_property_group_content(groupProp); + + // Show/hide description text (inverse of customAudio) + obs_property_set_visible(obs_properties_get(groupContent, "custom_audio_description"), !customAudio); + + // Show/hide multitrack_audio checkbox + obs_property_set_visible(obs_properties_get(groupContent, "multitrack_audio"), customAudio); + + updateAudioTrackVisibility(groupContent, customAudio, _multitrackAudio); + + // Show/hide apply4 + obs_property_set_visible(obs_properties_get(_props, "apply4"), customAudio); + + return true; + }, + nullptr + ); } void BranchOutputFilter::addAudioEncoderGroup(obs_properties_t *props) @@ -670,6 +895,15 @@ void BranchOutputFilter::addVideoEncoderGroup(obs_properties_t *props) { auto videoEncoderGroup = obs_properties_create(); + // Video source type selection + auto videoSourceType = obs_properties_add_list( + videoEncoderGroup, "video_source_type", obs_module_text("VideoSourceType"), OBS_COMBO_TYPE_LIST, + OBS_COMBO_FORMAT_STRING + ); + obs_property_set_long_description(videoSourceType, obs_module_text("VideoSourceType.LongDescription")); + obs_property_list_add_string(videoSourceType, obs_module_text("VideoSourceType.Source"), "source"); + obs_property_list_add_string(videoSourceType, obs_module_text("VideoSourceType.FilterInput"), "filter_input"); + // Resolution prop auto resolutionList = obs_properties_add_list( videoEncoderGroup, "resolution", obs_module_text("Resolution"), OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING @@ -790,10 +1024,14 @@ obs_properties_t *BranchOutputFilter::getProperties() auto props = obs_properties_create(); obs_properties_set_flags(props, OBS_PROPERTIES_DEFER_UPDATE); - //--- "Stream" group ---// - addStreamGroup(props); + //--- "Streaming" group ---// + addStreamingGroup(props); - addApplyButton(props, "apply1"); + //--- "Recording" group ---// + addRecordingGroup(props); + + //--- "Replay Buffer" group ---// + addReplayBufferGroup(props); //--- "Audio" gorup ---// addAudioGroup(props); @@ -804,7 +1042,10 @@ obs_properties_t *BranchOutputFilter::getProperties() //--- "Video Encoder" group ---// addVideoEncoderGroup(props); - addApplyButton(props, "apply2"); + //--- "Advanced Settings" group ---// + addAdvancedSettingsGroup(props); + + addApplyButton(props, "applyLast"); addPluginInfo(props); return props; diff --git a/src/utils.cpp b/src/utils.cpp index 1c49d9d..494e909 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -17,11 +17,14 @@ with this program. If not, see */ #include +#include #include +#include #include #include "utils.hpp" +#include "plugin-support.h" // Origin: https://github.com/obsproject/obs-studio/blob/06642fdee48477ab85f89ff670f105affe402df7/UI/obs-app.cpp#L1871 QString getFormatExt(const char *container) @@ -119,3 +122,133 @@ QString getOutputFilename(const char *path, const char *container, bool noSpace, return strPath; } + +// Recursively check whether a source is visible inside a scene (including nested scenes and groups) +static bool sceneHasVisibleSource(obs_scene_t *scene, obs_source_t *target) +{ + if (!scene || !target) { + return false; + } + + struct FindContext { + obs_source_t *target; + bool found; + } context = {target, false}; + + obs_scene_enum_items( + scene, + [](obs_scene_t *, obs_sceneitem_t *item, void *param) { + auto ctx = static_cast(param); + if (ctx->found) { + return false; + } + + if (!obs_sceneitem_visible(item)) { + return true; + } + + obs_source_t *itemSource = obs_sceneitem_get_source(item); + if (itemSource == ctx->target) { + ctx->found = true; + return false; + } + + if (obs_sceneitem_is_group(item)) { + obs_scene_t *groupScene = obs_sceneitem_group_get_scene(item); + if (sceneHasVisibleSource(groupScene, ctx->target)) { + ctx->found = true; + return false; + } + } + + if (obs_source_get_type(itemSource) == OBS_SOURCE_TYPE_SCENE) { + obs_scene_t *subScene = obs_scene_from_source(itemSource); + if (sceneHasVisibleSource(subScene, ctx->target)) { + ctx->found = true; + return false; + } + } + + return true; + }, + &context + ); + + return context.found; +} + +static bool sourceVisibleInSceneSource(obs_source_t *sceneSource, obs_source_t *target) +{ + if (!sceneSource || !target) { + return false; + } + + if (sceneSource == target) { + return true; + } + + // In Studio Mode with scene duplication enabled (the default), the Program + // output uses a duplicated (private) scene whose obs_source_t pointer + // differs from the original. When the target itself is a scene, compare + // by name so that we correctly detect the duplicate as a match. + if (obs_source_get_type(sceneSource) == OBS_SOURCE_TYPE_SCENE && + obs_source_get_type(target) == OBS_SOURCE_TYPE_SCENE) { + const char *sceneName = obs_source_get_name(sceneSource); + const char *targetName = obs_source_get_name(target); + if (sceneName && targetName && strcmp(sceneName, targetName) == 0) { + return true; + } + } + + obs_scene_t *scene = obs_scene_from_source(sceneSource); + if (!scene) { + return false; + } + + return sceneHasVisibleSource(scene, target); +} + +bool sourceVisibleInProgram(obs_source_t *source) +{ + if (!source) { + return false; + } + + // Check via the actual program output transition first. During transitions the + // program output can contain both Source A and Source B — consider the source + // visible if it appears in either, so we don't blank incorrectly mid-transition. + OBSSourceAutoRelease output = obs_get_output_source(0); + if (output) { + OBSSourceAutoRelease a = obs_transition_get_source(output, OBS_TRANSITION_SOURCE_A); + OBSSourceAutoRelease b = obs_transition_get_source(output, OBS_TRANSITION_SOURCE_B); + + if (a || b) { + if (sourceVisibleInSceneSource(a, source) || sourceVisibleInSceneSource(b, source)) { + return true; + } + } else { + OBSSourceAutoRelease active = obs_transition_get_active_source(output); + if (active) { + if (sourceVisibleInSceneSource(active, source)) { + return true; + } + } else { + if (sourceVisibleInSceneSource(output, source)) { + return true; + } + } + } + } + + // Secondary check via the frontend API. + // + // In Studio Mode with scene-duplication enabled (the default), the Program + // output contains a *private clone* of the scene whose obs_source_t pointer + // differs from the original. The transition-based check above therefore + // fails for filters attached directly to a scene source. + // + // obs_frontend_get_current_scene() returns the *original* scene source even + // in Studio Mode, so it correctly matches the filter's parent pointer. + OBSSourceAutoRelease program = obs_frontend_get_current_scene(); + return sourceVisibleInSceneSource(program, source); +} diff --git a/src/utils.hpp b/src/utils.hpp index 00b28ba..caecd91 100644 --- a/src/utils.hpp +++ b/src/utils.hpp @@ -123,6 +123,8 @@ inline bool sourceIsPrivate(obs_source_t *source) return finder != nullptr; } +bool sourceVisibleInProgram(obs_source_t *source); + // Return value must be obs_data_release() after use inline obs_data_t *loadHotkeyData(const char *name) { diff --git a/src/video/filter-video-capture.cpp b/src/video/filter-video-capture.cpp new file mode 100644 index 0000000..967899e --- /dev/null +++ b/src/video/filter-video-capture.cpp @@ -0,0 +1,258 @@ +/* +Branch Output Plugin +Copyright (C) 2024 OPENSPHERE Inc. info@opensphere.co.jp + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include "filter-video-capture.hpp" + +#include "plugin-support.h" + +//--- Proxy source callbacks ---// + +struct ProxySourceContext { + FilterVideoCapture *owner; +}; + +static const char *proxy_source_get_name(void *) +{ + return "Branch Output Proxy"; +} + +static void *proxy_source_create(obs_data_t *settings, obs_source_t *) +{ + auto ctx = new ProxySourceContext(); + ctx->owner = reinterpret_cast(obs_data_get_int(settings, "owner_ptr")); + return ctx; +} + +static void proxy_source_destroy(void *data) +{ + delete static_cast(data); +} + +static uint32_t proxy_source_get_width(void *data) +{ + auto ctx = static_cast(data); + return (ctx && ctx->owner) ? ctx->owner->getCaptureWidth() : 0; +} + +static uint32_t proxy_source_get_height(void *data) +{ + auto ctx = static_cast(data); + return (ctx && ctx->owner) ? ctx->owner->getCaptureHeight() : 0; +} + +static void proxy_source_video_render(void *data, gs_effect_t *) +{ + auto ctx = static_cast(data); + if (ctx && ctx->owner) { + ctx->owner->renderTexture(); + } +} + +obs_source_info FilterVideoCapture::createProxySourceInfo() +{ + obs_source_info info = {}; + info.id = PROXY_SOURCE_ID; + info.type = OBS_SOURCE_TYPE_INPUT; + info.output_flags = OBS_SOURCE_VIDEO | OBS_SOURCE_CUSTOM_DRAW; + info.get_name = proxy_source_get_name; + info.create = proxy_source_create; + info.destroy = proxy_source_destroy; + info.get_width = proxy_source_get_width; + info.get_height = proxy_source_get_height; + info.video_render = proxy_source_video_render; + return info; +} + +//--- FilterVideoCapture implementation ---// + +FilterVideoCapture::FilterVideoCapture( + obs_source_t *_filterSource, obs_source_t *_parentSource, uint32_t _width, uint32_t _height +) + : filterSource(_filterSource), + parentSource(_parentSource), + proxySource(nullptr), + texrender(nullptr), + triggerTexrender(nullptr), + captureWidth(_width), + captureHeight(_height), + active(false), + textureReady(false), + capturedThisFrame(false) +{ + // Create texrender on graphics thread + obs_enter_graphics(); + texrender = gs_texrender_create(GS_BGRA, GS_ZS_NONE); + triggerTexrender = gs_texrender_create(GS_BGRA, GS_ZS_NONE); + obs_leave_graphics(); + + if (!texrender || !triggerTexrender) { + obs_log(LOG_ERROR, "FilterVideoCapture: gs_texrender_create failed"); + return; + } + + // Create private proxy source + OBSDataAutoRelease settings = obs_data_create(); + obs_data_set_int(settings, "owner_ptr", reinterpret_cast(this)); + proxySource = obs_source_create_private(PROXY_SOURCE_ID, "BranchOutputProxy", settings); + + if (!proxySource) { + obs_log(LOG_ERROR, "FilterVideoCapture: Failed to create proxy source"); + } +} + +FilterVideoCapture::~FilterVideoCapture() +{ + active.store(false); + textureReady.store(false); + + // Release proxy source first (stops any rendering references) + proxySource = nullptr; + + // Destroy GPU resources + obs_enter_graphics(); + if (texrender) { + gs_texrender_destroy(texrender); + texrender = nullptr; + } + if (triggerTexrender) { + gs_texrender_destroy(triggerTexrender); + triggerTexrender = nullptr; + } + obs_leave_graphics(); +} + +bool FilterVideoCapture::captureFilterInput() +{ + if (!active.load() || !texrender) { + return false; + } + + obs_source_t *target = obs_filter_get_target(filterSource); + if (!target) { + return false; + } + + uint32_t cx = obs_source_get_base_width(target); + uint32_t cy = obs_source_get_base_height(target); + + if (cx == 0 || cy == 0) { + return false; + } + + // Detect resolution change — BranchOutputFilter will restart output + if (cx != captureWidth || cy != captureHeight) { + return false; + } + + // Render filter input to texrender + gs_texrender_reset(texrender); + if (!gs_texrender_begin(texrender, cx, cy)) { + return false; + } + + struct vec4 clear_color; + vec4_zero(&clear_color); + gs_clear(GS_CLEAR_COLOR, &clear_color, 0.0f, 0); + gs_ortho(0.0f, (float)cx, 0.0f, (float)cy, -100.0f, 100.0f); + + gs_blend_state_push(); + gs_blend_function_separate(GS_BLEND_SRCALPHA, GS_BLEND_INVSRCALPHA, GS_BLEND_ONE, GS_BLEND_INVSRCALPHA); + + obs_source_video_render(target); + + gs_blend_state_pop(); + gs_texrender_end(texrender); + + textureReady.store(true); + capturedThisFrame.store(true); + return true; +} + +void FilterVideoCapture::drawCapturedTexture() +{ + if (!textureReady.load() || !texrender) { + return; + } + + gs_texture_t *tex = gs_texrender_get_texture(texrender); + if (!tex) { + return; + } + + // Draw the captured texrender content to the current render target + // using OBS default effect (replaces obs_source_skip_video_filter) + gs_effect_t *effect = obs_get_base_effect(OBS_EFFECT_DEFAULT); + gs_eparam_t *image = gs_effect_get_param_by_name(effect, "image"); + gs_effect_set_texture(image, tex); + + while (gs_effect_loop(effect, "Draw")) { + gs_draw_sprite(tex, 0, captureWidth, captureHeight); + } +} + +void FilterVideoCapture::renderTexture() +{ + if (!active.load() || !texrender) { + return; + } + + // When the scene is not being rendered by the main mix (scene inactive), + // the filter's video_render callback is never called, so captureFilterInput() + // is never invoked and the texrender is never updated. We detect this via + // capturedThisFrame (reset each frame from video_tick) and trigger-render + // the parent scene ourselves to drive the filter chain. + if (!capturedThisFrame.load() && parentSource && triggerTexrender) { + // Use triggerTexrender as a disposable render target. + // Rendering the parent scene drives its filter chain, which calls + // BranchOutputFilter::videoRenderCallback() → captureFilterInput(), + // updating `texrender` with fresh content. The output written to + // triggerTexrender (via drawCapturedTexture()) is discarded. + gs_texrender_reset(triggerTexrender); + if (gs_texrender_begin(triggerTexrender, captureWidth, captureHeight)) { + struct vec4 clear_color; + vec4_zero(&clear_color); + gs_clear(GS_CLEAR_COLOR, &clear_color, 0.0f, 0); + gs_ortho(0.0f, (float)captureWidth, 0.0f, (float)captureHeight, -100.0f, 100.0f); + + gs_blend_state_push(); + gs_blend_function_separate(GS_BLEND_SRCALPHA, GS_BLEND_INVSRCALPHA, GS_BLEND_ONE, GS_BLEND_INVSRCALPHA); + + obs_source_video_render(parentSource); + + gs_blend_state_pop(); + gs_texrender_end(triggerTexrender); + } + } + + if (!textureReady.load()) { + return; + } + + gs_texture_t *tex = gs_texrender_get_texture(texrender); + if (!tex) { + return; + } + + gs_effect_t *effect = obs_get_base_effect(OBS_EFFECT_DEFAULT); + gs_eparam_t *image = gs_effect_get_param_by_name(effect, "image"); + gs_effect_set_texture(image, tex); + + while (gs_effect_loop(effect, "Draw")) { + gs_draw_sprite(tex, 0, captureWidth, captureHeight); + } +} diff --git a/src/video/filter-video-capture.hpp b/src/video/filter-video-capture.hpp new file mode 100644 index 0000000..aa93a0b --- /dev/null +++ b/src/video/filter-video-capture.hpp @@ -0,0 +1,108 @@ +/* +Branch Output Plugin +Copyright (C) 2024 OPENSPHERE Inc. info@opensphere.co.jp + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#pragma once + +#include +#include + +#include + +#define PROXY_SOURCE_ID "osi_branch_output_proxy" + +// FilterVideoCapture: Captures filter input via gs_texrender and provides +// a private proxy source for obs_view binding. +// +// The proxy source renders the captured texrender texture directly on the GPU, +// avoiding any CPU roundtrip. When added to an obs_view, the view's video_t* +// is registered in OBS's internal video mix list, allowing GPU encoders +// (NVENC, QSV, AMF, etc.) to work without the obs_encoder_video_tex_active crash. +// +// Flow: +// 1. Filter's video_render → captureFilterInput() → texrender captures filter input +// 2. obs_view renders proxy source → renderTexture() → draws texrender texture +// 3. obs_view's video_t* → encoder → output +class FilterVideoCapture { + // Filter source reference (non-owning) + obs_source_t *filterSource; + + // Parent source reference for trigger rendering (non-owning) + // When the scene is inactive, renderTexture() uses this to drive + // the scene's render pipeline and update the texrender. + obs_source_t *parentSource; + + // Private proxy source for obs_view binding + OBSSourceAutoRelease proxySource; + + // GPU texrender for capturing filter input + gs_texrender_t *texrender; + + // Disposable texrender used as a render target when trigger-rendering + // the parent scene from within renderTexture(). The filter chain writes + // into this texrender (via drawCapturedTexture()), but the result is + // discarded — the real capture goes into `texrender` above. + gs_texrender_t *triggerTexrender; + + // Capture dimensions (filter target resolution) + uint32_t captureWidth; + uint32_t captureHeight; + + // State + std::atomic_bool active; + std::atomic_bool textureReady; + + // Per-frame flag: set to true by captureFilterInput() when the filter's + // video_render is called from the normal rendering path (scene active). + // renderTexture() checks this to decide whether trigger rendering is needed. + // Reset each frame via resetCapturedFlag() from video_tick. + std::atomic_bool capturedThisFrame; + +public: + explicit FilterVideoCapture( + obs_source_t *_filterSource, obs_source_t *_parentSource, uint32_t _width, uint32_t _height + ); + ~FilterVideoCapture(); + + // Called from video_render callback (graphics thread) to capture filter input + // Returns true if capture succeeded, false if capture was skipped/failed + bool captureFilterInput(); + + // Called from video_render callback to draw captured texture to current render target + // (replaces obs_source_skip_video_filter for the main output passthrough) + void drawCapturedTexture(); + + // Called from proxy source's video_render (graphics thread) to render the captured texture + void renderTexture(); + + // Get the proxy source for obs_view binding + inline obs_source_t *getProxySource() const { return proxySource; } + + // Enable/disable capture + inline void setActive(bool enable) { active.store(enable); } + inline bool isActive() const { return active.load(); } + + // Reset the per-frame capture flag (called from video_tick before rendering) + inline void resetCapturedFlag() { capturedThisFrame.store(false); } + + // Get capture dimensions + inline uint32_t getCaptureWidth() const { return captureWidth; } + inline uint32_t getCaptureHeight() const { return captureHeight; } + + // Create obs_source_info for the proxy source type (register at module load) + static obs_source_info createProxySourceInfo(); +};