From 21bbbbb1dd08cc5628374a56a7fa332699c0b5b1 Mon Sep 17 00:00:00 2001 From: rowdytomato Date: Thu, 11 Dec 2025 04:31:14 -0600 Subject: [PATCH 01/39] Add program-based blanking/mute when source not visible --- data/locale/ca-ES.ini | 4 ++ data/locale/de-DE.ini | 4 ++ data/locale/en-US.ini | 4 ++ data/locale/fr-FR.ini | 4 ++ data/locale/ja-JP.ini | 4 ++ data/locale/ko-KR.ini | 4 ++ data/locale/ro-RO.ini | 4 ++ data/locale/ru-RU.ini | 4 ++ data/locale/uk-UA.ini | 4 ++ data/locale/zh-CN.ini | 4 ++ src/audio/audio-capture.cpp | 24 ++++++--- src/audio/audio-capture.hpp | 5 +- src/plugin-main.cpp | 101 ++++++++++++++++++++++++++++++++++-- src/plugin-main.hpp | 4 ++ src/plugin-ui.cpp | 21 ++++++++ src/utils.cpp | 86 ++++++++++++++++++++++++++++++ src/utils.hpp | 3 ++ 17 files changed, 274 insertions(+), 10 deletions(-) diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index 2bf18c3..0450d80 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -130,6 +130,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 visible" +BlankWhenNotVisibleNote="Manté la transmissió/enregistrament actiu però substitueix el vídeo per negre sempre que la font no sigui visible a la sortida de Programa/Previsualització activa." +MuteAudioWhenBlank="Silencia l'àudio mentre està enfosquit" +MuteAudioWhenBlankNote="Quan l'enfosquiment està actiu, també silencia les fonts d'àudio seleccionades." 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" diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index 4b96805..79706a9 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -130,6 +130,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 sichtbar ist" +BlankWhenNotVisibleNote="Lässt Stream/Aufnahme weiterlaufen, ersetzt das Video aber durch Schwarz, sobald die Quelle im aktiven Programm/Preview nicht sichtbar ist." +MuteAudioWhenBlank="Audio während Schwärzung stummschalten" +MuteAudioWhenBlankNote="Wenn die Schwärzung aktiv ist, auch die ausgewählten Audioquellen stummschalten." 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" diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index fc60982..8550494 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -130,6 +130,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 visible" +BlankWhenNotVisibleNote="Keeps streaming/recording running but replaces video with black whenever the source is not visible in the active Program/Preview output." +MuteAudioWhenBlank="Mute audio while blanked" +MuteAudioWhenBlankNote="When blanking is active, also mute the selected audio sources." 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" diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index 4ad3e1f..a44de55 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -130,6 +130,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 visible" +BlankWhenNotVisibleNote="Continue la diffusion/l’enregistrement mais remplace la vidéo par du noir lorsque la source n’est pas visible dans la sortie Programme/Aperçu active." +MuteAudioWhenBlank="Couper l’audio pendant le noircissement" +MuteAudioWhenBlankNote="Quand le noircissement est actif, couper aussi les sources audio sélectionnées." 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" diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index f6dec5e..953564e 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -130,6 +130,10 @@ UseProfileRecordingPath="プロファイルの録画パスを使用する" NoSpaceFileName="スペースなしのファイル名を生成する" KeepOutputBaseResolution="ソース解像度が変化した時に出力をリセットしない" KeepOutputBaseResolutionNote="有効にした場合、ソースの解像度が変わると、映像が見切れたり余白が出来たりする場合があります" +BlankWhenNotVisible="ソースが表示されていないときに出力を黒画面にする" +BlankWhenNotVisibleNote="配信/録画は継続しつつ、ソースがアクティブなプログラム/プレビュー出力に表示されていない場合は映像を黒に置き換えます。" +MuteAudioWhenBlank="黒画面中に音声をミュートする" +MuteAudioWhenBlankNote="ブランキングが有効なとき、選択した音声ソースもミュートします。" AudioSourceNote="マスター音声: OBSのミックス出力(既知の6トラックがあります)
フィルター音声: Branch Outputフィルターに渡されるフィルターパイプラインの音声。
ソース音声: パイプライン内のすべてのフィルターを通過した後のソースのバンドルされた音声。" SplitRecordingFileHotkey="'%1' の録画ファイルを分割" SplitRecordingAllHotkey="全ての Branch Output 録画ファイルを分割" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index 3a3c19a..4c5e954 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -130,6 +130,10 @@ UseProfileRecordingPath="프로필의 녹음 경로 사용" NoSpaceFileName="ス공백 없이 파일 이름 짓기" KeepOutputBaseResolution="소스 해상도가 변경될 때 출력을 재설정하지 않음" KeepOutputBaseResolutionNote="활성화된 경우, 소스 해상도가 변경되면 비디오 이미지가 잘리거나 빈 공간이 생길 수 있습니다." +BlankWhenNotVisible="소스가 보이지 않을 때 출력 검정 처리" +BlankWhenNotVisibleNote="송출/녹화를 계속하면서, 소스가 활성 프로그램/프리뷰 출력에 보이지 않을 때 영상은 검정으로 대체합니다." +MuteAudioWhenBlank="검정 처리 중 오디오 음소거" +MuteAudioWhenBlankNote="블랭킹이 활성화되면 선택한 오디오 소스도 음소거합니다." AudioSourceNote="마스터 오디오: OBS의 믹스 출력 (알려진 대로 6개의 트랙이 있음)
필터 오디오: Branch Output 필터로 전달되는 필터 파이프라인의 오디오.
소스 오디오: 파이프라인의 모든 필터를 통과한 후의 소스의 번들 오디오." SplitRecordingFileHotkey="'%1' 녹화 파일 분할" SplitRecordingAllHotkey="모든 분기 출력 녹화 파일 분할" diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index b092e95..c8ba61f 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -128,6 +128,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 vizibilă" +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 Program/Previzualizare activă." +MuteAudioWhenBlank="Oprește sunetul în timpul ecranului negru" +MuteAudioWhenBlankNote="Când înnegrirea este activă, oprește și sursele audio selectate." 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ă" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index 95b0504..1d24980 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -130,6 +130,10 @@ UseProfileRecordingPath="Использовать путь записи проф NoSpaceFileName="Генерировать имя файла без пробела" KeepOutputBaseResolution="Не сбрасывать вывод при изменении разрешения источника" KeepOutputBaseResolutionNote="При включении этой функции изображение видео может быть обрезано или иметь пустые пространства при изменении разрешения источника." +BlankWhenNotVisible="Затемнять вывод, если источник не виден" +BlankWhenNotVisibleNote="Продолжает трансляцию/запись, но заменяет видео черным, когда источник не виден в активном выводе Program/Preview." +MuteAudioWhenBlank="При затемнении отключать звук" +MuteAudioWhenBlankNote="Когда затемнение активно, также выключать выбранные аудиоисточники." AudioSourceNote="Мастер-аудио: Микшированный выход OBS (известно, что имеет 6 дорожек)
Аудио фильтра: Аудио из конвейера фильтров, которое передается в фильтр Branch Output.
Аудио источника: Объединенное аудио источника после прохождения через все фильтры в конвейере." SplitRecordingFileHotkey="Разделить файл записи '%1'" SplitRecordingAllHotkey="Разделить все файлы записи Выхода Ветви" diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index 57f37ea..5b1248e 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -130,6 +130,10 @@ UseProfileRecordingPath="Використовувати шлях запису п NoSpaceFileName="Генерувати назву файлу без пробілів" KeepOutputBaseResolution="Не скидати вивід при зміні роздільної здатності джерела" KeepOutputBaseResolutionNote="Якщо увімкнено, зображення відео може бути обрізане або мати пусті простори при зміні роздільної здатності джерела." +BlankWhenNotVisible="Затемнювати вихід, якщо джерело не видно" +BlankWhenNotVisibleNote="Продовжує трансляцію/запис, але замінює відео на чорне, коли джерело не видно в активному виході Program/Preview." +MuteAudioWhenBlank="Вимикати звук під час затемнення" +MuteAudioWhenBlankNote="Коли затемнення активне, також вимикати вибрані аудіоджерела." AudioSourceNote="Майстер-аудіо: Змішаний вихід OBS (як відомо, має 6 доріжок)
Аудіо фільтра: Аудіо з конвеєра фільтрів, яке передається у фільтр Branch Output.
Аудіо джерела: Об'єднане аудіо джерела після проходження через усі фільтри в конвеєрі." SplitRecordingFileHotkey="Розділити файл запису '%1'" SplitRecordingAllHotkey="Розділити всі файли запису Виходу Гілки" diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index c666ff4..808f636 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -130,6 +130,10 @@ UseProfileRecordingPath="使用个人资料的录制路径" NoSpaceFileName="生成没有空格的文件名" KeepOutputBaseResolution="源分辨率变化时不重置输出" KeepOutputBaseResolutionNote="如果启用,当源分辨率改变时,视频图像可能会被裁剪或出现空白区域。" +BlankWhenNotVisible="当源不可见时将输出置为黑屏" +BlankWhenNotVisibleNote="保持推流/录制继续,但当源未在当前 Program/Preview 输出中可见时,用黑色替换视频。" +MuteAudioWhenBlank="黑屏时静音" +MuteAudioWhenBlankNote="启用黑屏时,同时静音所选音频源。" AudioSourceNote="主音频: OBS的混合输出(众所周知有6个轨道)
滤镜音频: 传递给分支输出滤镜的滤镜管道音频。
源音频: 通过管道中所有滤镜后的源捆绑音频。" SplitRecordingFileHotkey="分割 '%1' 录制文件" SplitRecordingAllHotkey="分割所有分支输出录制文件" diff --git a/src/audio/audio-capture.cpp b/src/audio/audio-capture.cpp index 2dd38d7..e15b967 100644 --- a/src/audio/audio-capture.cpp +++ b/src/audio/audio-capture.cpp @@ -18,6 +18,8 @@ with this program. If not, see #include +#include + #include "audio-capture.hpp" #include "../plugin-support.h" @@ -37,7 +39,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 +54,11 @@ AudioCapture::AudioCapture( audio = nullptr; return; } - - active = true; } AudioCapture::~AudioCapture() { - active = false; + active.store(false); if (audio) { audio_output_close(audio); @@ -69,7 +70,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 +142,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 +207,17 @@ 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; + } +} + // 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 diff --git a/src/audio/audio-capture.hpp b/src/audio/audio-capture.hpp index decd2fd..f8c1571 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; } diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 157477d..c5ab6e1 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -57,6 +57,9 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc storedSettingsRev(0), activeSettingsRev(0), intervalTimer(nullptr), + streamingStopping(false), + blankingOutput(false), + audioMutedByBlank(false), recordingOutput(nullptr), videoEncoder(nullptr), videoOutput(nullptr), @@ -303,6 +306,9 @@ void BranchOutputFilter::stopOutput() view = nullptr; } + + blankingOutput = false; + audioMutedByBlank = false; } obs_data_t *BranchOutputFilter::createRecordingSettings(obs_data_t *settings, bool createFolder) @@ -734,6 +740,9 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) 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 @@ -943,6 +952,14 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) obs_encoder_set_audio(audioContext->encoder, audioContext->audio); } + if (blankWhenHidden) { + bool previewMode = obs_frontend_preview_enabled(); + bool visible = previewMode ? sourceVisibleInPreview(parent) : sourceVisibleInProgram(parent); + setBlankingState(!visible, muteWhenHidden, parent); + } else { + setBlankingState(false, muteWhenHidden, parent); + } + //--- Start recording output (if requested) ---// if (isRecordingEnabled(settings)) { recordingPending = (sourceWidth == 0 || sourceHeight == 0) && @@ -1247,6 +1264,10 @@ 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 (!obs_frontend_streaming_active()) { @@ -1295,9 +1316,23 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } + if (blankWhenHidden) { + bool visible = sourceVisibleInProgram(parent); + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); + setBlankingState(!visible, muteWhenHidden, parent); + } + } else if (blankingOutput || audioMutedByBlank) { + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); + setBlankingState(false, muteWhenHidden, parent); + } + } + if (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 +1387,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; } } @@ -1528,6 +1563,66 @@ 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::setBlankingState(bool blank, bool muteAudio, obs_source_t *parent) +{ + if (!parent) { + parent = obs_filter_get_parent(filterSource); + } + + if (!view) { + blankingOutput = false; + if (audioMutedByBlank) { + setAudioCapturesActive(true); + audioMutedByBlank = false; + } + return; + } + + if (blank) { + if (!blankingOutput) { + obs_view_set_source(view, 0, nullptr); + if (muteAudio) { + setAudioCapturesActive(false); + audioMutedByBlank = true; + } else { + audioMutedByBlank = false; + } + blankingOutput = true; + obs_log(LOG_INFO, "%s: Output blanked because source is not visible", qUtf8Printable(name)); + } else { + if (muteAudio && !audioMutedByBlank) { + setAudioCapturesActive(false); + audioMutedByBlank = true; + } else if (!muteAudio && audioMutedByBlank) { + setAudioCapturesActive(true); + audioMutedByBlank = false; + } + } + } else { + if (blankingOutput) { + if (parent) { + obs_view_set_source(view, 0, parent); + } + blankingOutput = false; + obs_log(LOG_INFO, "%s: Output resumed because source became visible", qUtf8Printable(name)); + } + if (audioMutedByBlank) { + setAudioCapturesActive(true); + audioMutedByBlank = false; + } + } +} + bool BranchOutputFilter::onEnableFilterHotkeyPressed(void *data, obs_hotkey_pair_id, obs_hotkey *, bool pressed) { if (!pressed) { diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index de101ac..1a71e41 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -75,6 +75,8 @@ class BranchOutputFilter : public QObject { uint32_t activeSettingsRev; QTimer *intervalTimer; bool streamingStopping; + bool blankingOutput; + bool audioMutedByBlank; // Filter source (Do not use OBSSourceAutoRelease) obs_source_t *filterSource; @@ -142,6 +144,8 @@ class BranchOutputFilter : public QObject { bool pauseRecording(); bool unpauseRecording(); bool addChapterToRecording(QString chapterName = QString()); + void setBlankingState(bool blank, 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"); diff --git a/src/plugin-ui.cpp b/src/plugin-ui.cpp index 99d8ec2..ad1dfb3 100644 --- a/src/plugin-ui.cpp +++ b/src/plugin-ui.cpp @@ -183,6 +183,8 @@ 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, "rec_muxer_custom", mux); auto path = getProfileRecordingPath(config); @@ -434,6 +436,25 @@ void BranchOutputFilter::addStreamGroup(obs_properties_t *props) ); obs_property_set_long_description(keepOutputBaseResolution, obs_module_text("KeepOutputBaseResolutionNote")); + auto blankWhenNotVisible = + obs_properties_add_bool(streamGroup, "blank_when_not_visible", obs_module_text("BlankWhenNotVisible")); + obs_property_set_long_description(blankWhenNotVisible, obs_module_text("BlankWhenNotVisibleNote")); + + auto muteAudioWhenBlank = + obs_properties_add_bool(streamGroup, "mute_audio_when_blank", obs_module_text("MuteAudioWhenBlank")); + obs_property_set_long_description(muteAudioWhenBlank, obs_module_text("MuteAudioWhenBlankNote")); + obs_property_set_visible(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_visible(obs_properties_get(_props, "mute_audio_when_blank"), enabled); + return true; + }, + nullptr + ); + obs_properties_add_group(props, "stream", obs_module_text("Stream"), OBS_GROUP_NORMAL, streamGroup); } diff --git a/src/utils.cpp b/src/utils.cpp index 1c49d9d..ca6a7b7 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -17,7 +17,9 @@ with this program. If not, see */ #include +#include #include +#include #include @@ -119,3 +121,87 @@ 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; + } + + obs_scene_t *scene = obs_scene_from_source(sceneSource); + if (!scene) { + return false; + } + + return sceneHasVisibleSource(scene, target); +} + +bool sourceVisibleInProgram(obs_source_t *source) +{ + OBSSourceAutoRelease program = obs_frontend_get_current_scene(); + return sourceVisibleInSceneSource(program, source); +} + +bool sourceVisibleInPreview(obs_source_t *source) +{ + OBSSourceAutoRelease preview = obs_frontend_get_current_preview_scene(); + return sourceVisibleInSceneSource(preview, source); +} diff --git a/src/utils.hpp b/src/utils.hpp index 00b28ba..4bad0b8 100644 --- a/src/utils.hpp +++ b/src/utils.hpp @@ -123,6 +123,9 @@ inline bool sourceIsPrivate(obs_source_t *source) return finder != nullptr; } +bool sourceVisibleInProgram(obs_source_t *source); +bool sourceVisibleInPreview(obs_source_t *source); + // Return value must be obs_data_release() after use inline obs_data_t *loadHotkeyData(const char *name) { From b6792301cbc9b2837b8d5e74bc2b0a2800c4a080 Mon Sep 17 00:00:00 2001 From: rowdytomato Date: Fri, 12 Dec 2025 05:22:34 -0600 Subject: [PATCH 02/39] blanking stability fix --- src/plugin-main.cpp | 37 +++++++++++++++++++++++++++++++++---- src/plugin-main.hpp | 3 +++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index c5ab6e1..cae3905 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -305,6 +305,7 @@ void BranchOutputFilter::stopOutput() } view = nullptr; + blankSource = nullptr; } blankingOutput = false; @@ -1316,12 +1317,13 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } + bool visibleInProgram = true; if (blankWhenHidden) { - bool visible = sourceVisibleInProgram(parent); + visibleInProgram = sourceVisibleInProgram(parent); pthread_mutex_lock(&outputMutex); { OBSMutexAutoUnlock outputLocked(&outputMutex); - setBlankingState(!visible, muteWhenHidden, parent); + setBlankingState(!visibleInProgram, muteWhenHidden, parent); } } else if (blankingOutput || audioMutedByBlank) { pthread_mutex_lock(&outputMutex); @@ -1331,7 +1333,11 @@ void BranchOutputFilter::onIntervalTimerTimeout() } } - if (width != sourceWidth || height != sourceHeight) { + // 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 if (sourceWidth > 0 && sourceHeight > 0) { if (!obs_data_get_bool(settings, "keep_output_base_resolution")) { @@ -1590,7 +1596,30 @@ void BranchOutputFilter::setBlankingState(bool blank, bool muteAudio, obs_source if (blank) { if (!blankingOutput) { - obs_view_set_source(view, 0, nullptr); + if (!blankSource) { + OBSDataAutoRelease blankSettings = obs_data_create(); + // Solid black, fully opaque. + obs_data_set_int(blankSettings, "color", 0xFF000000); + if (width > 0 && height > 0) { + obs_data_set_int(blankSettings, "width", width); + obs_data_set_int(blankSettings, "height", height); + } + + blankSource = obs_source_create_private( + "color_source", "Branch Output Blank", blankSettings + ); + if (!blankSource) { + obs_log( + LOG_WARNING, + "%s: Failed to create blank color source; leaving original source active", + qUtf8Printable(name) + ); + } + } + + if (blankSource) { + obs_view_set_source(view, 0, blankSource); + } if (muteAudio) { setAudioCapturesActive(false); audioMutedByBlank = true; diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 1a71e41..a8b8232 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -81,6 +81,9 @@ class BranchOutputFilter : public QObject { // Filter source (Do not use OBSSourceAutoRelease) obs_source_t *filterSource; + // Private solid-color source used for blanking + OBSSourceAutoRelease blankSource; + // User choosed encoder OBSEncoderAutoRelease videoEncoder; From 06a7d811278c7dd9c9005103d4a74fc6594716c0 Mon Sep 17 00:00:00 2001 From: rowdytomato Date: Sat, 13 Dec 2025 22:25:45 -0600 Subject: [PATCH 03/39] Fix studio mode and formatting --- src/plugin-main.cpp | 12 ++++-------- src/utils.cpp | 33 +++++++++++++++++++++++++++------ src/utils.hpp | 1 - 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index cae3905..b0f9f65 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -954,9 +954,8 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) } if (blankWhenHidden) { - bool previewMode = obs_frontend_preview_enabled(); - bool visible = previewMode ? sourceVisibleInPreview(parent) : sourceVisibleInProgram(parent); - setBlankingState(!visible, muteWhenHidden, parent); + bool visibleInProgram = sourceVisibleInProgram(parent); + setBlankingState(!visibleInProgram, muteWhenHidden, parent); } else { setBlankingState(false, muteWhenHidden, parent); } @@ -1605,13 +1604,10 @@ void BranchOutputFilter::setBlankingState(bool blank, bool muteAudio, obs_source obs_data_set_int(blankSettings, "height", height); } - blankSource = obs_source_create_private( - "color_source", "Branch Output Blank", blankSettings - ); + blankSource = obs_source_create_private("color_source", "Branch Output Blank", blankSettings); if (!blankSource) { obs_log( - LOG_WARNING, - "%s: Failed to create blank color source; leaving original source active", + LOG_WARNING, "%s: Failed to create blank color source; leaving original source active", qUtf8Printable(name) ); } diff --git a/src/utils.cpp b/src/utils.cpp index ca6a7b7..f662f08 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -196,12 +196,33 @@ static bool sourceVisibleInSceneSource(obs_source_t *sceneSource, obs_source_t * bool sourceVisibleInProgram(obs_source_t *source) { + if (!source) { + return false; + } + + // Prefer using OBS' actual program output source (transition), because in Studio Mode the + // Program display may use duplicated/private scenes that differ from the "current scene". + // + // During transitions, the Program output can contain both Source A and Source B. Consider + // the source visible if it's visible in either one, 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) { + return sourceVisibleInSceneSource(a, source) || sourceVisibleInSceneSource(b, source); + } + + OBSSourceAutoRelease active = obs_transition_get_active_source(output); + if (active) { + return sourceVisibleInSceneSource(active, source); + } + + return sourceVisibleInSceneSource(output, source); + } + + // Fallback: frontend API scene pointer. OBSSourceAutoRelease program = obs_frontend_get_current_scene(); return sourceVisibleInSceneSource(program, source); } - -bool sourceVisibleInPreview(obs_source_t *source) -{ - OBSSourceAutoRelease preview = obs_frontend_get_current_preview_scene(); - return sourceVisibleInSceneSource(preview, source); -} diff --git a/src/utils.hpp b/src/utils.hpp index 4bad0b8..caecd91 100644 --- a/src/utils.hpp +++ b/src/utils.hpp @@ -124,7 +124,6 @@ inline bool sourceIsPrivate(obs_source_t *source) } bool sourceVisibleInProgram(obs_source_t *source); -bool sourceVisibleInPreview(obs_source_t *source); // Return value must be obs_data_release() after use inline obs_data_t *loadHotkeyData(const char *name) From b79ba15de59c1e373d69b9980d9362c474df9a7c Mon Sep 17 00:00:00 2001 From: rowdytomato Date: Sat, 13 Dec 2025 23:22:05 -0600 Subject: [PATCH 04/39] Fix crash on exit when Branch Output Status Dock is open --- src/UI/output-status-dock.cpp | 39 ++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index b4b3b49..8f9a89c 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -203,6 +203,8 @@ BranchOutputStatusDock::BranchOutputStatusDock(QWidget *parent) BranchOutputStatusDock::~BranchOutputStatusDock() { + timer.stop(); + saveSettings(); // Unregister hotkeys @@ -485,16 +487,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) From d8df1b2e08248f77ff79d6ae21df384971aaf0bb Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 18:14:39 +0900 Subject: [PATCH 05/39] fix: Tweak translations for blanking mode settings --- data/locale/ca-ES.ini | 4 ++-- data/locale/de-DE.ini | 4 ++-- data/locale/en-US.ini | 4 ++-- data/locale/fr-FR.ini | 4 ++-- data/locale/ja-JP.ini | 4 ++-- data/locale/ko-KR.ini | 4 ++-- data/locale/ro-RO.ini | 4 ++-- data/locale/ru-RU.ini | 4 ++-- data/locale/uk-UA.ini | 4 ++-- data/locale/zh-CN.ini | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index 0450d80..f7a9696 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -130,8 +130,8 @@ 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 visible" -BlankWhenNotVisibleNote="Manté la transmissió/enregistrament actiu però substitueix el vídeo per negre sempre que la font no sigui visible a la sortida de Programa/Previsualització activa." +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." MuteAudioWhenBlank="Silencia l'àudio mentre està enfosquit" MuteAudioWhenBlankNote="Quan l'enfosquiment està actiu, també silencia les fonts d'àudio seleccionades." 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." diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index 79706a9..9b551ee 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -130,8 +130,8 @@ 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 sichtbar ist" -BlankWhenNotVisibleNote="Lässt Stream/Aufnahme weiterlaufen, ersetzt das Video aber durch Schwarz, sobald die Quelle im aktiven Programm/Preview nicht sichtbar ist." +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." MuteAudioWhenBlank="Audio während Schwärzung stummschalten" MuteAudioWhenBlankNote="Wenn die Schwärzung aktiv ist, auch die ausgewählten Audioquellen stummschalten." 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." diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 8550494..2c072c2 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -130,8 +130,8 @@ 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 visible" -BlankWhenNotVisibleNote="Keeps streaming/recording running but replaces video with black whenever the source is not visible in the active Program/Preview output." +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." MuteAudioWhenBlank="Mute audio while blanked" MuteAudioWhenBlankNote="When blanking is active, also mute the selected audio sources." 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." diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index a44de55..87fb8f3 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -130,8 +130,8 @@ 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 visible" -BlankWhenNotVisibleNote="Continue la diffusion/l’enregistrement mais remplace la vidéo par du noir lorsque la source n’est pas visible dans la sortie Programme/Aperçu active." +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." MuteAudioWhenBlank="Couper l’audio pendant le noircissement" MuteAudioWhenBlankNote="Quand le noircissement est actif, couper aussi les sources audio sélectionnées." 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." diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index 953564e..f1b3d3a 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -130,8 +130,8 @@ UseProfileRecordingPath="プロファイルの録画パスを使用する" NoSpaceFileName="スペースなしのファイル名を生成する" KeepOutputBaseResolution="ソース解像度が変化した時に出力をリセットしない" KeepOutputBaseResolutionNote="有効にした場合、ソースの解像度が変わると、映像が見切れたり余白が出来たりする場合があります" -BlankWhenNotVisible="ソースが表示されていないときに出力を黒画面にする" -BlankWhenNotVisibleNote="配信/録画は継続しつつ、ソースがアクティブなプログラム/プレビュー出力に表示されていない場合は映像を黒に置き換えます。" +BlankWhenNotVisible="ソースがメイン出力に含まれていないときに出力を黒画面にする" +BlankWhenNotVisibleNote="配信/録画は継続しつつ、ソースがアクティブなメイン出力に表示されていない場合は映像を黒に置き換えます。" MuteAudioWhenBlank="黒画面中に音声をミュートする" MuteAudioWhenBlankNote="ブランキングが有効なとき、選択した音声ソースもミュートします。" AudioSourceNote="マスター音声: OBSのミックス出力(既知の6トラックがあります)
フィルター音声: Branch Outputフィルターに渡されるフィルターパイプラインの音声。
ソース音声: パイプライン内のすべてのフィルターを通過した後のソースのバンドルされた音声。" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index 4c5e954..ad984c1 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -130,8 +130,8 @@ UseProfileRecordingPath="프로필의 녹음 경로 사용" NoSpaceFileName="ス공백 없이 파일 이름 짓기" KeepOutputBaseResolution="소스 해상도가 변경될 때 출력을 재설정하지 않음" KeepOutputBaseResolutionNote="활성화된 경우, 소스 해상도가 변경되면 비디오 이미지가 잘리거나 빈 공간이 생길 수 있습니다." -BlankWhenNotVisible="소스가 보이지 않을 때 출력 검정 처리" -BlankWhenNotVisibleNote="송출/녹화를 계속하면서, 소스가 활성 프로그램/프리뷰 출력에 보이지 않을 때 영상은 검정으로 대체합니다." +BlankWhenNotVisible="소스가 메인 출력에 없을 때 출력 검정 처리" +BlankWhenNotVisibleNote="송출/녹화를 계속하면서, 소스가 활성 메인 출력에 보이지 않을 때 영상을 검정으로 대체합니다." MuteAudioWhenBlank="검정 처리 중 오디오 음소거" MuteAudioWhenBlankNote="블랭킹이 활성화되면 선택한 오디오 소스도 음소거합니다." AudioSourceNote="마스터 오디오: OBS의 믹스 출력 (알려진 대로 6개의 트랙이 있음)
필터 오디오: Branch Output 필터로 전달되는 필터 파이프라인의 오디오.
소스 오디오: 파이프라인의 모든 필터를 통과한 후의 소스의 번들 오디오." diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index c8ba61f..4bdea36 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -128,8 +128,8 @@ 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 vizibilă" -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 Program/Previzualizare activă." +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ă." MuteAudioWhenBlank="Oprește sunetul în timpul ecranului negru" MuteAudioWhenBlankNote="Când înnegrirea este activă, oprește și sursele audio selectate." 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ă." diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index 1d24980..5bfd87a 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -130,8 +130,8 @@ UseProfileRecordingPath="Использовать путь записи проф NoSpaceFileName="Генерировать имя файла без пробела" KeepOutputBaseResolution="Не сбрасывать вывод при изменении разрешения источника" KeepOutputBaseResolutionNote="При включении этой функции изображение видео может быть обрезано или иметь пустые пространства при изменении разрешения источника." -BlankWhenNotVisible="Затемнять вывод, если источник не виден" -BlankWhenNotVisibleNote="Продолжает трансляцию/запись, но заменяет видео черным, когда источник не виден в активном выводе Program/Preview." +BlankWhenNotVisible="Затемнять вывод, если источник не в основном выводе" +BlankWhenNotVisibleNote="Продолжает трансляцию/запись, но заменяет видео чёрным, когда источник не виден в активном основном выводе." MuteAudioWhenBlank="При затемнении отключать звук" MuteAudioWhenBlankNote="Когда затемнение активно, также выключать выбранные аудиоисточники." AudioSourceNote="Мастер-аудио: Микшированный выход OBS (известно, что имеет 6 дорожек)
Аудио фильтра: Аудио из конвейера фильтров, которое передается в фильтр Branch Output.
Аудио источника: Объединенное аудио источника после прохождения через все фильтры в конвейере." diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index 5b1248e..a736ebd 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -130,8 +130,8 @@ UseProfileRecordingPath="Використовувати шлях запису п NoSpaceFileName="Генерувати назву файлу без пробілів" KeepOutputBaseResolution="Не скидати вивід при зміні роздільної здатності джерела" KeepOutputBaseResolutionNote="Якщо увімкнено, зображення відео може бути обрізане або мати пусті простори при зміні роздільної здатності джерела." -BlankWhenNotVisible="Затемнювати вихід, якщо джерело не видно" -BlankWhenNotVisibleNote="Продовжує трансляцію/запис, але замінює відео на чорне, коли джерело не видно в активному виході Program/Preview." +BlankWhenNotVisible="Затемнювати вихід, якщо джерело не в основному виході" +BlankWhenNotVisibleNote="Продовжує трансляцію/запис, але замінює відео на чорне, коли джерело не видно в активному основному виході." MuteAudioWhenBlank="Вимикати звук під час затемнення" MuteAudioWhenBlankNote="Коли затемнення активне, також вимикати вибрані аудіоджерела." AudioSourceNote="Майстер-аудіо: Змішаний вихід OBS (як відомо, має 6 доріжок)
Аудіо фільтра: Аудіо з конвеєра фільтрів, яке передається у фільтр Branch Output.
Аудіо джерела: Об'єднане аудіо джерела після проходження через усі фільтри в конвеєрі." diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index 808f636..2e85668 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -130,8 +130,8 @@ UseProfileRecordingPath="使用个人资料的录制路径" NoSpaceFileName="生成没有空格的文件名" KeepOutputBaseResolution="源分辨率变化时不重置输出" KeepOutputBaseResolutionNote="如果启用,当源分辨率改变时,视频图像可能会被裁剪或出现空白区域。" -BlankWhenNotVisible="当源不可见时将输出置为黑屏" -BlankWhenNotVisibleNote="保持推流/录制继续,但当源未在当前 Program/Preview 输出中可见时,用黑色替换视频。" +BlankWhenNotVisible="当源不在主输出中时将输出置为黑屏" +BlankWhenNotVisibleNote="保持推流/录制继续,但当源未在当前主输出中可见时,用黑色替换视频。" MuteAudioWhenBlank="黑屏时静音" MuteAudioWhenBlankNote="启用黑屏时,同时静音所选音频源。" AudioSourceNote="主音频: OBS的混合输出(众所周知有6个轨道)
滤镜音频: 传递给分支输出滤镜的滤镜管道音频。
源音频: 通过管道中所有滤镜后的源捆绑音频。" From 912f53bdf3e85caab5ec12d882260c60e2087f02 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 18:15:18 +0900 Subject: [PATCH 06/39] refactor: Apply coding style --- src/audio/audio-capture.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/audio/audio-capture.cpp b/src/audio/audio-capture.cpp index e15b967..bab6383 100644 --- a/src/audio/audio-capture.cpp +++ b/src/audio/audio-capture.cpp @@ -212,9 +212,12 @@ void AudioCapture::setActive(bool enable) active.store(enable); if (!enable) { QMutexLocker locker(&audioBufferMutex); - deque_free(&audioBuffer); - deque_init(&audioBuffer); - audioBufferFrames = 0; + { + deque_free(&audioBuffer); + deque_init(&audioBuffer); + audioBufferFrames = 0; + } + locker.unlock(); } } From 2a7da18b74e0889f549ab24d8b8c9fe1e9e59c46 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 18:15:38 +0900 Subject: [PATCH 07/39] remove: Dead code. --- src/plugin-main.cpp | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index b0f9f65..0fba1b7 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -1324,12 +1324,6 @@ void BranchOutputFilter::onIntervalTimerTimeout() OBSMutexAutoUnlock outputLocked(&outputMutex); setBlankingState(!visibleInProgram, muteWhenHidden, parent); } - } else if (blankingOutput || audioMutedByBlank) { - pthread_mutex_lock(&outputMutex); - { - OBSMutexAutoUnlock outputLocked(&outputMutex); - setBlankingState(false, muteWhenHidden, parent); - } } // When blanking because the source is not visible, some sources report unstable base sizes. From bb7e112193afbfb1d8307206bd8e087073ff25ed Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 19:30:36 +0900 Subject: [PATCH 08/39] refactor: Rename blanking output variables for clarity improve: Pre-create blank source in startOutput for more robust. --- src/plugin-main.cpp | 88 +++++++++++++++++++++++---------------------- src/plugin-main.hpp | 6 ++-- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 0fba1b7..6a2a6f2 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -58,8 +58,8 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc activeSettingsRev(0), intervalTimer(nullptr), streamingStopping(false), - blankingOutput(false), - audioMutedByBlank(false), + blankingOutputActive(false), + blankingAudioMuted(false), recordingOutput(nullptr), videoEncoder(nullptr), videoOutput(nullptr), @@ -308,8 +308,8 @@ void BranchOutputFilter::stopOutput() blankSource = nullptr; } - blankingOutput = false; - audioMutedByBlank = false; + blankingOutputActive = false; + blankingAudioMuted = false; } obs_data_t *BranchOutputFilter::createRecordingSettings(obs_data_t *settings, bool createFolder) @@ -954,10 +954,24 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) } if (blankWhenHidden) { + // Pre-create blank source so that setBlankingActive() never has to + // allocate during a live stream. The color_source is lightweight and + // cleaned up in stopOutput(). + if (!blankSource) { + OBSDataAutoRelease blankSettings = obs_data_create(); + obs_data_set_int(blankSettings, "color", 0xFF000000); + if (width > 0 && height > 0) { + obs_data_set_int(blankSettings, "width", width); + obs_data_set_int(blankSettings, "height", height); + } + blankSource = obs_source_create_private("color_source", "Branch Output Blank", blankSettings); + if (!blankSource) { + obs_log(LOG_WARNING, "%s: Failed to pre-create blank color source", qUtf8Printable(name)); + } + } + bool visibleInProgram = sourceVisibleInProgram(parent); - setBlankingState(!visibleInProgram, muteWhenHidden, parent); - } else { - setBlankingState(false, muteWhenHidden, parent); + setBlankingActive(!visibleInProgram, muteWhenHidden, parent); } //--- Start recording output (if requested) ---// @@ -1322,7 +1336,7 @@ void BranchOutputFilter::onIntervalTimerTimeout() pthread_mutex_lock(&outputMutex); { OBSMutexAutoUnlock outputLocked(&outputMutex); - setBlankingState(!visibleInProgram, muteWhenHidden, parent); + setBlankingActive(!visibleInProgram, muteWhenHidden, parent); } } @@ -1572,72 +1586,60 @@ void BranchOutputFilter::setAudioCapturesActive(bool active) } } -void BranchOutputFilter::setBlankingState(bool blank, bool muteAudio, obs_source_t *parent) +void BranchOutputFilter::setBlankingActive(bool active, bool muteAudio, obs_source_t *parent) { if (!parent) { parent = obs_filter_get_parent(filterSource); } if (!view) { - blankingOutput = false; - if (audioMutedByBlank) { + blankingOutputActive = false; + if (blankingAudioMuted) { setAudioCapturesActive(true); - audioMutedByBlank = false; + blankingAudioMuted = false; } return; } - if (blank) { - if (!blankingOutput) { - if (!blankSource) { - OBSDataAutoRelease blankSettings = obs_data_create(); - // Solid black, fully opaque. - obs_data_set_int(blankSettings, "color", 0xFF000000); - if (width > 0 && height > 0) { - obs_data_set_int(blankSettings, "width", width); - obs_data_set_int(blankSettings, "height", height); - } - - blankSource = obs_source_create_private("color_source", "Branch Output Blank", blankSettings); - if (!blankSource) { - obs_log( - LOG_WARNING, "%s: Failed to create blank color source; leaving original source active", - qUtf8Printable(name) - ); - } - } - + if (active) { + if (!blankingOutputActive) { + // blankSource is pre-created in startOutput(). if (blankSource) { obs_view_set_source(view, 0, blankSource); + } else { + obs_log( + LOG_WARNING, "%s: Blank source not available; leaving original source active", + qUtf8Printable(name) + ); } if (muteAudio) { setAudioCapturesActive(false); - audioMutedByBlank = true; + blankingAudioMuted = true; } else { - audioMutedByBlank = false; + blankingAudioMuted = false; } - blankingOutput = true; + blankingOutputActive = true; obs_log(LOG_INFO, "%s: Output blanked because source is not visible", qUtf8Printable(name)); } else { - if (muteAudio && !audioMutedByBlank) { + if (muteAudio && !blankingAudioMuted) { setAudioCapturesActive(false); - audioMutedByBlank = true; - } else if (!muteAudio && audioMutedByBlank) { + blankingAudioMuted = true; + } else if (!muteAudio && blankingAudioMuted) { setAudioCapturesActive(true); - audioMutedByBlank = false; + blankingAudioMuted = false; } } } else { - if (blankingOutput) { + if (blankingOutputActive) { if (parent) { obs_view_set_source(view, 0, parent); } - blankingOutput = false; + blankingOutputActive = false; obs_log(LOG_INFO, "%s: Output resumed because source became visible", qUtf8Printable(name)); } - if (audioMutedByBlank) { + if (blankingAudioMuted) { setAudioCapturesActive(true); - audioMutedByBlank = false; + blankingAudioMuted = false; } } } diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index a8b8232..fb4e4ee 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -75,8 +75,8 @@ class BranchOutputFilter : public QObject { uint32_t activeSettingsRev; QTimer *intervalTimer; bool streamingStopping; - bool blankingOutput; - bool audioMutedByBlank; + bool blankingOutputActive; + bool blankingAudioMuted; // Filter source (Do not use OBSSourceAutoRelease) obs_source_t *filterSource; @@ -147,7 +147,7 @@ class BranchOutputFilter : public QObject { bool pauseRecording(); bool unpauseRecording(); bool addChapterToRecording(QString chapterName = QString()); - void setBlankingState(bool blank, bool muteAudio, obs_source_t *parent); + void setBlankingActive(bool active, bool muteAudio, obs_source_t *parent); void setAudioCapturesActive(bool active); // Implemented in plugin-ui.cpp From 62663a00e081a8817ccb2117f33c98d2a0b129a6 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 19:41:32 +0900 Subject: [PATCH 09/39] fix: clang-format error --- src/plugin-main.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 6a2a6f2..93b71e4 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -1608,8 +1608,7 @@ void BranchOutputFilter::setBlankingActive(bool active, bool muteAudio, obs_sour obs_view_set_source(view, 0, blankSource); } else { obs_log( - LOG_WARNING, "%s: Blank source not available; leaving original source active", - qUtf8Printable(name) + LOG_WARNING, "%s: Blank source not available; leaving original source active", qUtf8Printable(name) ); } if (muteAudio) { From 9131dd98af9a1914ccffbf6c32868a485219269b Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 20:31:01 +0900 Subject: [PATCH 10/39] feat: Implement profile-specific settings saving and loading in output status dock --- src/UI/output-status-dock.cpp | 49 ++++++++++++++++++++++++++++++++--- src/UI/output-status-dock.hpp | 3 +++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 8f9a89c..08c8f41 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -198,6 +198,8 @@ BranchOutputStatusDock::BranchOutputStatusDock(QWidget *parent) sort(); + obs_frontend_add_event_callback(onOBSFrontendEvent, this); + obs_log(LOG_DEBUG, "BranchOutputStatusDock created"); } @@ -205,6 +207,8 @@ BranchOutputStatusDock::~BranchOutputStatusDock() { timer.stop(); + obs_frontend_remove_event_callback(onOBSFrontendEvent, this); + saveSettings(); // Unregister hotkeys @@ -220,12 +224,26 @@ BranchOutputStatusDock::~BranchOutputStatusDock() 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); +} + +void BranchOutputStatusDock::applySettings(obs_data_t *settings) +{ for (int i = 0; i < outputTable->columnCount(); i++) { if (i == resetColumnIndex) { // Skip reset button column @@ -271,11 +289,34 @@ 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(); + auto profileSettingsPath = QString("%1/%2").arg(QString(profilePath)).arg(SETTINGS_JSON_NAME); + obs_data_save_json_safe(settings, qUtf8Printable(profileSettingsPath), "tmp", "bak"); +} + +void BranchOutputStatusDock::onOBSFrontendEvent(enum obs_frontend_event event, void *param) +{ + auto *dock = static_cast(param); + + switch (event) { + case OBS_FRONTEND_EVENT_PROFILE_CHANGING: + dock->saveSettings(); + break; + case OBS_FRONTEND_EVENT_PROFILE_CHANGED: + dock->loadSettings(); + dock->sort(); + break; + default: + break; + } } void BranchOutputStatusDock::addRow( diff --git a/src/UI/output-status-dock.hpp b/src/UI/output-status-dock.hpp index c2df1d8..33d246a 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 @@ -223,7 +224,9 @@ class BranchOutputStatusDock : public QFrame { void applyAddChapterToRecordingAllButtonEnabled(); 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); From 3caa2685bafc3b87d8396690e559f630703ef249 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 21:30:13 +0900 Subject: [PATCH 11/39] feat: Defer loading settings on profile change to ensure proper event processing --- src/UI/output-status-dock.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 08c8f41..ee28ad8 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -311,8 +311,15 @@ void BranchOutputStatusDock::onOBSFrontendEvent(enum obs_frontend_event event, v dock->saveSettings(); break; case OBS_FRONTEND_EVENT_PROFILE_CHANGED: - dock->loadSettings(); - dock->sort(); + // 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; From f1ecd06846ed2163a5bed843861e2588836d5555 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 22:21:05 +0900 Subject: [PATCH 12/39] fix: Update notes for blanking settings in multiple language files --- data/locale/ca-ES.ini | 4 ++-- data/locale/de-DE.ini | 4 ++-- data/locale/en-US.ini | 4 ++-- data/locale/fr-FR.ini | 6 +++--- data/locale/ja-JP.ini | 4 ++-- data/locale/ko-KR.ini | 4 ++-- data/locale/ro-RO.ini | 4 ++-- data/locale/ru-RU.ini | 4 ++-- data/locale/uk-UA.ini | 4 ++-- data/locale/zh-CN.ini | 4 ++-- 10 files changed, 21 insertions(+), 21 deletions(-) diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index f7a9696..d60ce0f 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -131,9 +131,9 @@ 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." +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." +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" diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index 9b551ee..0aa92a3 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -131,9 +131,9 @@ 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." +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." +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" diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 2c072c2..0612a72 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -131,9 +131,9 @@ 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." +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." +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" diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index 87fb8f3..9f2d1df 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -131,9 +131,9 @@ 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." -MuteAudioWhenBlank="Couper l’audio pendant le noircissement" -MuteAudioWhenBlankNote="Quand le noircissement est actif, couper aussi les sources audio sélectionnées." +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" diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index f1b3d3a..aa2e8e2 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -131,9 +131,9 @@ NoSpaceFileName="スペースなしのファイル名を生成する" KeepOutputBaseResolution="ソース解像度が変化した時に出力をリセットしない" KeepOutputBaseResolutionNote="有効にした場合、ソースの解像度が変わると、映像が見切れたり余白が出来たりする場合があります" BlankWhenNotVisible="ソースがメイン出力に含まれていないときに出力を黒画面にする" -BlankWhenNotVisibleNote="配信/録画は継続しつつ、ソースがアクティブなメイン出力に表示されていない場合は映像を黒に置き換えます。" +BlankWhenNotVisibleNote="配信/録画は継続しつつ、ソースがアクティブなメイン出力に表示されていない場合は映像を黒に置き換えます。オフシーンを録画・配信したくない時に使用してください。" MuteAudioWhenBlank="黒画面中に音声をミュートする" -MuteAudioWhenBlankNote="ブランキングが有効なとき、選択した音声ソースもミュートします。" +MuteAudioWhenBlankNote="ブランキングが有効なとき、選択した音声ソースもミュートします。オフシーンの音声を録画・配信したくない時に使用してください。" AudioSourceNote="マスター音声: OBSのミックス出力(既知の6トラックがあります)
フィルター音声: Branch Outputフィルターに渡されるフィルターパイプラインの音声。
ソース音声: パイプライン内のすべてのフィルターを通過した後のソースのバンドルされた音声。" SplitRecordingFileHotkey="'%1' の録画ファイルを分割" SplitRecordingAllHotkey="全ての Branch Output 録画ファイルを分割" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index ad984c1..fe9a6fc 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -131,9 +131,9 @@ NoSpaceFileName="ス공백 없이 파일 이름 짓기" KeepOutputBaseResolution="소스 해상도가 변경될 때 출력을 재설정하지 않음" KeepOutputBaseResolutionNote="활성화된 경우, 소스 해상도가 변경되면 비디오 이미지가 잘리거나 빈 공간이 생길 수 있습니다." BlankWhenNotVisible="소스가 메인 출력에 없을 때 출력 검정 처리" -BlankWhenNotVisibleNote="송출/녹화를 계속하면서, 소스가 활성 메인 출력에 보이지 않을 때 영상을 검정으로 대체합니다." +BlankWhenNotVisibleNote="송출/녹화를 계속하면서, 소스가 활성 메인 출력에 보이지 않을 때 영상을 검정으로 대체합니다. 오프씬을 녹화하거나 송출하고 싶지 않을 때 사용하세요." MuteAudioWhenBlank="검정 처리 중 오디오 음소거" -MuteAudioWhenBlankNote="블랭킹이 활성화되면 선택한 오디오 소스도 음소거합니다." +MuteAudioWhenBlankNote="블랭킹이 활성화되면 선택한 오디오 소스도 음소거합니다. 오프씬의 오디오를 녹화하거나 송출하고 싶지 않을 때 사용하세요." AudioSourceNote="마스터 오디오: OBS의 믹스 출력 (알려진 대로 6개의 트랙이 있음)
필터 오디오: Branch Output 필터로 전달되는 필터 파이프라인의 오디오.
소스 오디오: 파이프라인의 모든 필터를 통과한 후의 소스의 번들 오디오." SplitRecordingFileHotkey="'%1' 녹화 파일 분할" SplitRecordingAllHotkey="모든 분기 출력 녹화 파일 분할" diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index 4bdea36..07d063b 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -129,9 +129,9 @@ 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ă." +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." +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ă" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index 5bfd87a..0b175d5 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -131,9 +131,9 @@ NoSpaceFileName="Генерировать имя файла без пробел KeepOutputBaseResolution="Не сбрасывать вывод при изменении разрешения источника" KeepOutputBaseResolutionNote="При включении этой функции изображение видео может быть обрезано или иметь пустые пространства при изменении разрешения источника." BlankWhenNotVisible="Затемнять вывод, если источник не в основном выводе" -BlankWhenNotVisibleNote="Продолжает трансляцию/запись, но заменяет видео чёрным, когда источник не виден в активном основном выводе." +BlankWhenNotVisibleNote="Продолжает трансляцию/запись, но заменяет видео чёрным, когда источник не виден в активном основном выводе. Используйте это, когда не хотите записывать или транслировать неактивные сцены." MuteAudioWhenBlank="При затемнении отключать звук" -MuteAudioWhenBlankNote="Когда затемнение активно, также выключать выбранные аудиоисточники." +MuteAudioWhenBlankNote="Когда затемнение активно, также выключать выбранные аудиоисточники. Используйте это, когда не хотите записывать или транслировать аудио из неактивных сцен." AudioSourceNote="Мастер-аудио: Микшированный выход OBS (известно, что имеет 6 дорожек)
Аудио фильтра: Аудио из конвейера фильтров, которое передается в фильтр Branch Output.
Аудио источника: Объединенное аудио источника после прохождения через все фильтры в конвейере." SplitRecordingFileHotkey="Разделить файл записи '%1'" SplitRecordingAllHotkey="Разделить все файлы записи Выхода Ветви" diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index a736ebd..f2d4402 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -131,9 +131,9 @@ NoSpaceFileName="Генерувати назву файлу без пробіл KeepOutputBaseResolution="Не скидати вивід при зміні роздільної здатності джерела" KeepOutputBaseResolutionNote="Якщо увімкнено, зображення відео може бути обрізане або мати пусті простори при зміні роздільної здатності джерела." BlankWhenNotVisible="Затемнювати вихід, якщо джерело не в основному виході" -BlankWhenNotVisibleNote="Продовжує трансляцію/запис, але замінює відео на чорне, коли джерело не видно в активному основному виході." +BlankWhenNotVisibleNote="Продовжує трансляцію/запис, але замінює відео на чорне, коли джерело не видно в активному основному виході. Використовуйте це, коли не хочете записувати або транслювати неактивні сцени." MuteAudioWhenBlank="Вимикати звук під час затемнення" -MuteAudioWhenBlankNote="Коли затемнення активне, також вимикати вибрані аудіоджерела." +MuteAudioWhenBlankNote="Коли затемнення активне, також вимикати вибрані аудіоджерела. Використовуйте це, коли не хочете записувати або транслювати аудіо з неактивних сцен." AudioSourceNote="Майстер-аудіо: Змішаний вихід OBS (як відомо, має 6 доріжок)
Аудіо фільтра: Аудіо з конвеєра фільтрів, яке передається у фільтр Branch Output.
Аудіо джерела: Об'єднане аудіо джерела після проходження через усі фільтри в конвеєрі." SplitRecordingFileHotkey="Розділити файл запису '%1'" SplitRecordingAllHotkey="Розділити всі файли запису Виходу Гілки" diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index 2e85668..b5655d7 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -131,9 +131,9 @@ NoSpaceFileName="生成没有空格的文件名" KeepOutputBaseResolution="源分辨率变化时不重置输出" KeepOutputBaseResolutionNote="如果启用,当源分辨率改变时,视频图像可能会被裁剪或出现空白区域。" BlankWhenNotVisible="当源不在主输出中时将输出置为黑屏" -BlankWhenNotVisibleNote="保持推流/录制继续,但当源未在当前主输出中可见时,用黑色替换视频。" +BlankWhenNotVisibleNote="保持推流/录制继续,但当源未在当前主输出中可见时,用黑色替换视频。当您不想录制或推流非活动场景时使用此功能。" MuteAudioWhenBlank="黑屏时静音" -MuteAudioWhenBlankNote="启用黑屏时,同时静音所选音频源。" +MuteAudioWhenBlankNote="启用黑屏时,同时静音所选音频源。当您不想录制或推流非活动场景的音频时使用此功能。" AudioSourceNote="主音频: OBS的混合输出(众所周知有6个轨道)
滤镜音频: 传递给分支输出滤镜的滤镜管道音频。
源音频: 通过管道中所有滤镜后的源捆绑音频。" SplitRecordingFileHotkey="分割 '%1' 录制文件" SplitRecordingAllHotkey="分割所有分支输出录制文件" From 11ad82446b892db54c3d0579d1ac2d206a94f1a0 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 22:36:15 +0900 Subject: [PATCH 13/39] fix: Update version number to 1.0.8 in buildspec.json --- buildspec.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 6f3169cf517071f41273049b3b720c42f10345fe Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 22:44:11 +0900 Subject: [PATCH 14/39] fix: Add gap line in stream options and update mute audio property visibility logic --- src/plugin-ui.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/plugin-ui.cpp b/src/plugin-ui.cpp index ad1dfb3..ba97498 100644 --- a/src/plugin-ui.cpp +++ b/src/plugin-ui.cpp @@ -430,6 +430,9 @@ void BranchOutputFilter::addStreamGroup(obs_properties_t *props) suspendRecordingWhenSourceCollapsed, obs_module_text("SuspendRecordingWhenSourceCollapsedNote") ); + // Add gap line + obs_properties_add_text(streamGroup, "stream_options_group", "", OBS_TEXT_INFO); + // Source resolution trackability auto keepOutputBaseResolution = obs_properties_add_bool( streamGroup, "keep_output_base_resolution", obs_module_text("KeepOutputBaseResolution") @@ -443,13 +446,13 @@ void BranchOutputFilter::addStreamGroup(obs_properties_t *props) auto muteAudioWhenBlank = obs_properties_add_bool(streamGroup, "mute_audio_when_blank", obs_module_text("MuteAudioWhenBlank")); obs_property_set_long_description(muteAudioWhenBlank, obs_module_text("MuteAudioWhenBlankNote")); - obs_property_set_visible(muteAudioWhenBlank, false); + 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_visible(obs_properties_get(_props, "mute_audio_when_blank"), enabled); + obs_property_set_enabled(obs_properties_get(_props, "mute_audio_when_blank"), enabled); return true; }, nullptr From cb4e669797e7283809919e5a67cfcc211347cb8a Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 22:58:37 +0900 Subject: [PATCH 15/39] fix: Validate recording path and handle errors for unavailable paths --- src/plugin-main.cpp | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 93b71e4..427cee9 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -335,8 +335,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 @@ -347,6 +359,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"); @@ -659,6 +677,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)); From 8472ee87e43a1e2c2c4a43440f115b4bd8d15219 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 23:37:18 +0900 Subject: [PATCH 16/39] chore: Add draft of AGENTS.md --- .gitignore | 3 + AGENTS.md | 246 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index e3a8413..a16e519 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ !screenshot2.jpg !build-windows-installer.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..c934821 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,246 @@ +# 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) +├── build-windows-installer.ps1 +├── .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`) + +### 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). + +--- + +## 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 `data/locale/en-US.ini` and `data/locale/ja-JP.ini`. +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. From 5f658a76b8eaa3aa87f1bfa9a2954168c65af8ab Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 22 Feb 2026 23:56:12 +0900 Subject: [PATCH 17/39] chore: Update .gitignore to include build.ps1 and remove build-windows-installer.ps1 from tracked files --- .gitignore | 2 +- AGENTS.md | 1 - build-windows-installer.ps1 => build.ps1 | 8 +++++++- 3 files changed, 8 insertions(+), 3 deletions(-) rename build-windows-installer.ps1 => build.ps1 (75%) diff --git a/.gitignore b/.gitignore index a16e519..6626e84 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ # Except additional project files !screenshot1.jpg !screenshot2.jpg -!build-windows-installer.ps1 +!build.ps1 !README_ja.md # Except for AI agent instructions diff --git a/AGENTS.md b/AGENTS.md index c934821..605a913 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,6 @@ branch-output/ ├── 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) -├── build-windows-installer.ps1 ├── .clang-format # C++ code style (clang-format ≥ 16) ├── .cmake-format.json # CMake code style ├── .github/ 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 From e520f6642c887fe13d7649196f5413456d4f5c10 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 23 Feb 2026 00:19:54 +0900 Subject: [PATCH 18/39] fix: Update locale string instructions to include all locale files in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 605a913..b8dfa6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,7 +212,7 @@ Release tags follow semver: `X.Y.Z` for stable, `X.Y.Z-beta`/`X.Y.Z-rc` for pre- 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 `data/locale/en-US.ini` and `data/locale/ja-JP.ini`. +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 From 9f7a6e0bbf9e80fa6287a0751a788bf165c203d3 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 23 Feb 2026 00:22:40 +0900 Subject: [PATCH 19/39] feat: Display blanking status in recording/streaming status on the Status Dock --- data/locale/ca-ES.ini | 2 ++ data/locale/de-DE.ini | 2 ++ data/locale/en-US.ini | 2 ++ data/locale/fr-FR.ini | 2 ++ data/locale/ja-JP.ini | 2 ++ data/locale/ko-KR.ini | 2 ++ data/locale/ro-RO.ini | 2 ++ data/locale/ru-RU.ini | 2 ++ data/locale/uk-UA.ini | 2 ++ data/locale/zh-CN.ini | 2 ++ src/UI/output-status-dock.cpp | 12 +++++++++--- 11 files changed, 29 insertions(+), 3 deletions(-) diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index d60ce0f..1395dfd 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" diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index 0aa92a3..a338ef8 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" diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 0612a72..7d0149a 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" diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index 9f2d1df..8b9075b 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" diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index aa2e8e2..22b1b87 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="全て有効化" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index fe9a6fc..343fbb6 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="모두 활성화" diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index 07d063b..dae35f5 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" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index 0b175d5..eedf7b5 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="Активировать все" diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index f2d4402..ffe22f4 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="Активувати всі" diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index b5655d7..6a68339 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="全部启用" diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 8f9a89c..b11a529 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -727,6 +727,12 @@ void OutputTableRow::update() status->setAddChapterToRecordingButtonShow(false); status->setAddChapterToRecordingButtonShow(false); } else { + // Blanking suffix for status text + auto blankSuffix = filter->blankingOutputActive && filter->blankingAudioMuted + ? QTStr("Status.BlankMutedSuffix") + : filter->blankingOutputActive ? QTStr("Status.BlankSuffix") + : QStringLiteral(""); + switch (outputType) { case ROW_OUTPUT_STREAMING: if (stopping) { @@ -734,7 +740,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); } @@ -746,11 +752,11 @@ void OutputTableRow::update() 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); } From 7b0e0b474ad3c91f3dbb230e0be8029f4d23f63e Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 23 Feb 2026 00:43:46 +0900 Subject: [PATCH 20/39] refactor: Multi-line ternary operator to if/else statements for better readability --- AGENTS.md | 4 ++++ src/UI/output-status-dock.cpp | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b8dfa6d..18f2f6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,10 @@ Format is checked in CI via `.github/workflows/check-format.yaml` using reusable - **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. diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index b11a529..6188c81 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -728,10 +728,12 @@ void OutputTableRow::update() status->setAddChapterToRecordingButtonShow(false); } else { // Blanking suffix for status text - auto blankSuffix = filter->blankingOutputActive && filter->blankingAudioMuted - ? QTStr("Status.BlankMutedSuffix") - : filter->blankingOutputActive ? QTStr("Status.BlankSuffix") - : QStringLiteral(""); + 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: From f18ffc9953309718e643fd02355556d79fdea000 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 23 Feb 2026 13:43:52 +0900 Subject: [PATCH 21/39] feat: Add capturing video of filter functionality --- CMakeLists.txt | 10 +- data/locale/ca-ES.ini | 4 + data/locale/de-DE.ini | 4 + data/locale/en-US.ini | 4 + data/locale/fr-FR.ini | 4 + data/locale/ja-JP.ini | 4 + data/locale/ko-KR.ini | 4 + data/locale/ro-RO.ini | 4 + data/locale/ru-RU.ini | 4 + data/locale/uk-UA.ini | 4 + data/locale/zh-CN.ini | 4 + src/plugin-main.cpp | 137 ++++++++++++++++---- src/plugin-main.hpp | 8 ++ src/plugin-ui.cpp | 10 ++ src/video/filter-video-capture.cpp | 192 +++++++++++++++++++++++++++++ src/video/filter-video-capture.hpp | 81 ++++++++++++ 16 files changed, 452 insertions(+), 26 deletions(-) create mode 100644 src/video/filter-video-capture.cpp create mode 100644 src/video/filter-video-capture.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 791606b..1afd5fd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,7 +35,13 @@ 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-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/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index 1395dfd..3878148 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -153,3 +153,7 @@ 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="Sortida de la font (Per defecte)" +VideoSourceType.FilterInput="Entrada de filtre (Experimental)" diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index a338ef8..898e556 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -153,3 +153,7 @@ 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="Quellenausgabe (Standard)" +VideoSourceType.FilterInput="Filtereingabe (Experimentell)" diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 7d0149a..b90fc3a 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -154,3 +154,7 @@ 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="Source Output (Default)" +VideoSourceType.FilterInput="Filter Input (Experimental)" diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index 8b9075b..1478468 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -153,3 +153,7 @@ 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="Sortie source (Par défaut)" +VideoSourceType.FilterInput="Entrée filtre (Expérimental)" diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index 22b1b87..4e3148b 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -154,3 +154,7 @@ SuspendRecordingWhenSourceCollapsedNote="配信がある場合は録画が停止 AddChapterToRecordingFileHotkey="'%1' の録画ファイルにチャプターを追加" AddChapterToAllRecordings="全録画にチャプター追加" AddChapterToRecordingAllHotkey="全ての Branch Output 録画にチャプターを追加" +VideoSourceType="映像ソース" +VideoSourceType.LongDescription="出力に使用する映像を選択します。\n「ソースの最終出力」はソースの全てのフィルターを通過した最終映像をキャプチャします。\n「フィルター入力」はフィルターチェーン内のこのフィルターの位置で映像をキャプチャします(このフィルターより上のフィルターのみ適用された映像)。" +VideoSourceType.Source="ソースの最終出力 (デフォルト)" +VideoSourceType.FilterInput="フィルター入力 (実験的)" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index 343fbb6..acdbb5a 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -153,3 +153,7 @@ SuspendRecordingWhenSourceCollapsedNote="스트리밍이 있는 경우 녹화가 AddChapterToRecordingFileHotkey="'%1' 녹화 파일에 챕터 추가" AddChapterToAllRecordings="모든 녹화에 챕터 추가" AddChapterToRecordingAllHotkey="모든 분기 출력 녹화에 챕터 추가" +VideoSourceType="비디오 소스" +VideoSourceType.LongDescription="출력에 캡처할 비디오를 선택합니다.\n'소스 출력'은 소스의 최종 렌더링 출력을 캡처합니다(모든 필터 적용 후).\n'필터 입력'은 필터 체인에서 이 필터 위치의 비디오를 캡처합니다(이 필터 이전 및 아래의 필터 이전)." +VideoSourceType.Source="소스 출력 (기본값)" +VideoSourceType.FilterInput="필터 입력 (실험적)" diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index dae35f5..e48bcf2 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -151,3 +151,7 @@ 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="Ieșire sursă (Implicit)" +VideoSourceType.FilterInput="Intrare filtru (Experimental)" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index eedf7b5..3ca3df3 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -153,3 +153,7 @@ SuspendRecordingWhenSourceCollapsedNote="При наличии трансляц AddChapterToRecordingFileHotkey="Добавить главу в файл записи '%1'" AddChapterToAllRecordings="Добавить главу во все записи" AddChapterToRecordingAllHotkey="Добавить главу во все записи Выхода Ветви" +VideoSourceType="Источник видео" +VideoSourceType.LongDescription="Выберите, какое видео захватывать для вывода.\n'Выход источника' захватывает окончательный отрисованный вывод источника (после всех фильтров).\n'Вход фильтра' захватывает видео на позиции этого фильтра в цепочке (до этого фильтра и любых фильтров ниже)." +VideoSourceType.Source="Выход источника (По умолчанию)" +VideoSourceType.FilterInput="Вход фильтра (Экспериментально)" diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index ffe22f4..2283127 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -153,3 +153,7 @@ SuspendRecordingWhenSourceCollapsedNote="При наявності трансл AddChapterToRecordingFileHotkey="Додати розділ до файлу запису '%1'" AddChapterToAllRecordings="Додати розділ до всіх записів" AddChapterToRecordingAllHotkey="Додати розділ до всіх записів Виходу Гілки" +VideoSourceType="Джерело відео" +VideoSourceType.LongDescription="Оберіть, яке відео захоплювати для виводу.\n'Вихід джерела' захоплює остаточний відрендерений вивід джерела (після всіх фільтрів).\n'Вхід фільтра' захоплює відео на позиції цього фільтра в ланцюжку (до цього фільтра та будь-яких фільтрів нижче)." +VideoSourceType.Source="Вихід джерела (За замовчуванням)" +VideoSourceType.FilterInput="Вхід фільтра (Експериментально)" diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index 6a68339..d7acf81 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -153,3 +153,7 @@ SuspendRecordingWhenSourceCollapsedNote="有直播时录制会停止,仅录制 AddChapterToRecordingFileHotkey="为 '%1' 录制文件添加章节" AddChapterToAllRecordings="为所有录制添加章节" AddChapterToRecordingAllHotkey="为所有分支输出录制添加章节" +VideoSourceType="视频源" +VideoSourceType.LongDescription="选择要为输出捕获的视频。\n'源输出'捕获源的最终渲染输出(经过所有滤镜后)。\n'滤镜输入'捕获此滤镜在链中位置的视频(在此滤镜之前和下方的任何滤镜之前)。" +VideoSourceType.Source="源输出(默认)" +VideoSourceType.FilterInput="滤镜输入 (实验性)" diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 427cee9..8ac4714 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" @@ -64,6 +65,8 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc videoEncoder(nullptr), videoOutput(nullptr), view(nullptr), + useFilterInput(false), + filterVideoCapture(nullptr), width(0), height(0), toggleEnableHotkeyPairId(OBS_INVALID_HOTKEY_PAIR_ID), @@ -194,6 +197,12 @@ void BranchOutputFilter::updateCallback(obs_data_t *settings) void BranchOutputFilter::videoRenderCallback(gs_effect_t *) { + // Capture filter input when in filter input mode + if (useFilterInput && filterVideoCapture) { + filterVideoCapture->captureFilterInput(); + } + + // Always pass through the filter chain obs_source_skip_video_filter(filterSource); } @@ -254,11 +263,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); } } @@ -299,12 +309,20 @@ 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; blankSource = nullptr; } @@ -436,6 +454,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"); @@ -633,7 +672,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); @@ -643,7 +685,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); } @@ -722,7 +767,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)); @@ -773,12 +821,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 @@ -804,14 +859,40 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) } //--- 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, 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) ---// @@ -943,6 +1024,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)); @@ -1341,10 +1423,11 @@ void BranchOutputFilter::onIntervalTimerTimeout() if (streamingAlive || recordingAlive || recordingPending) { // 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 @@ -1652,7 +1735,9 @@ void BranchOutputFilter::setBlankingActive(bool active, bool muteAudio, obs_sour } } else { if (blankingOutputActive) { - if (parent) { + if (useFilterInput && filterVideoCapture) { + obs_view_set_source(view, 0, filterVideoCapture->getProxySource()); + } else if (parent) { obs_view_set_source(view, 0, parent); } blankingOutputActive = false; @@ -1870,12 +1955,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 fb4e4ee..3d34849 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 @@ -93,6 +94,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]; @@ -119,6 +126,7 @@ 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); diff --git a/src/plugin-ui.cpp b/src/plugin-ui.cpp index ba97498..87cad4f 100644 --- a/src/plugin-ui.cpp +++ b/src/plugin-ui.cpp @@ -185,6 +185,7 @@ void BranchOutputFilter::getDefaults(obs_data_t *defaults) 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); @@ -694,6 +695,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 diff --git a/src/video/filter-video-capture.cpp b/src/video/filter-video-capture.cpp new file mode 100644 index 0000000..859e427 --- /dev/null +++ b/src/video/filter-video-capture.cpp @@ -0,0 +1,192 @@ +/* +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, uint32_t _width, uint32_t _height) + : filterSource(_filterSource), + proxySource(nullptr), + texrender(nullptr), + captureWidth(_width), + captureHeight(_height), + active(false), + textureReady(false) +{ + // Create texrender on graphics thread + obs_enter_graphics(); + texrender = gs_texrender_create(GS_BGRA, GS_ZS_NONE); + obs_leave_graphics(); + + if (!texrender) { + 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; + } + obs_leave_graphics(); +} + +void FilterVideoCapture::captureFilterInput() +{ + if (!active.load() || !texrender) { + return; + } + + obs_source_t *target = obs_filter_get_target(filterSource); + if (!target) { + return; + } + + uint32_t cx = obs_source_get_base_width(target); + uint32_t cy = obs_source_get_base_height(target); + + if (cx == 0 || cy == 0) { + return; + } + + // Detect resolution change — BranchOutputFilter will restart output + if (cx != captureWidth || cy != captureHeight) { + return; + } + + // Render filter input to texrender + gs_texrender_reset(texrender); + if (!gs_texrender_begin(texrender, cx, cy)) { + return; + } + + 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); +} + +void FilterVideoCapture::renderTexture() +{ + if (!textureReady.load() || !texrender) { + 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..c2b9948 --- /dev/null +++ b/src/video/filter-video-capture.hpp @@ -0,0 +1,81 @@ +/* +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; + + // Private proxy source for obs_view binding + OBSSourceAutoRelease proxySource; + + // GPU texrender for capturing filter input + gs_texrender_t *texrender; + + // Capture dimensions (filter target resolution) + uint32_t captureWidth; + uint32_t captureHeight; + + // State + std::atomic_bool active; + std::atomic_bool textureReady; + +public: + explicit FilterVideoCapture(obs_source_t *_filterSource, uint32_t _width, uint32_t _height); + ~FilterVideoCapture(); + + // Called from video_render callback (graphics thread) to capture filter input + void captureFilterInput(); + + // 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(); } + + // 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(); +}; From f5b7015d8c47494f7e33c00e769185b5a4cf3b01 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 23 Feb 2026 18:46:10 +0900 Subject: [PATCH 22/39] fix: Status Dock settings are not saved correctly when quitting OBS 32 --- src/UI/output-status-dock.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 3b7f2aa..1335caf 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -209,7 +209,11 @@ BranchOutputStatusDock::~BranchOutputStatusDock() obs_frontend_remove_event_callback(onOBSFrontendEvent, this); - saveSettings(); + // 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); @@ -240,6 +244,8 @@ void BranchOutputStatusDock::loadSettings() } applySettings(settings); + + obs_log(LOG_DEBUG, "BranchOutputStatusDock settings loaded."); } void BranchOutputStatusDock::applySettings(obs_data_t *settings) @@ -298,8 +304,12 @@ void BranchOutputStatusDock::saveSettings() // Save to profile-specific settings OBSString profilePath = obs_frontend_get_current_profile_path(); - auto profileSettingsPath = QString("%1/%2").arg(QString(profilePath)).arg(SETTINGS_JSON_NAME); - obs_data_save_json_safe(settings, qUtf8Printable(profileSettingsPath), "tmp", "bak"); + 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) @@ -307,6 +317,9 @@ void BranchOutputStatusDock::onOBSFrontendEvent(enum obs_frontend_event event, v auto *dock = static_cast(param); switch (event) { + case OBS_FRONTEND_EVENT_EXIT: + dock->saveSettings(); + break; case OBS_FRONTEND_EVENT_PROFILE_CHANGING: dock->saveSettings(); break; From f877c1a95f35c458b731f34abcec361ccfc63962 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 23 Feb 2026 21:29:17 +0900 Subject: [PATCH 23/39] feat: Add replay buffer functionality --- CMakeLists.txt | 1 + data/locale/ca-ES.ini | 7 + data/locale/de-DE.ini | 7 + data/locale/en-US.ini | 7 + data/locale/fr-FR.ini | 7 + data/locale/ja-JP.ini | 7 + data/locale/ko-KR.ini | 7 + data/locale/ro-RO.ini | 7 + data/locale/ru-RU.ini | 7 + data/locale/uk-UA.ini | 7 + data/locale/zh-CN.ini | 7 + src/UI/images/replay-buffering.svg | 1 + src/UI/images/replay-save.svg | 1 + src/UI/output-status-dock.cpp | 154 +++++++++++++++++++- src/UI/output-status-dock.hpp | 36 ++++- src/UI/resources.qrc | 2 + src/plugin-main.cpp | 47 ++++-- src/plugin-main.hpp | 14 ++ src/plugin-replay-buffer.cpp | 226 +++++++++++++++++++++++++++++ src/plugin-ui.cpp | 85 +++++++++++ 20 files changed, 624 insertions(+), 13 deletions(-) create mode 100644 src/UI/images/replay-buffering.svg create mode 100644 src/UI/images/replay-save.svg create mode 100644 src/plugin-replay-buffer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1afd5fd..1fd31ee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,7 @@ endif() target_sources( ${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 diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index 3878148..a593913 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -157,3 +157,10 @@ 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="Sortida de la font (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" diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index 898e556..71d3231 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -157,3 +157,10 @@ 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="Quellenausgabe (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" diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index b90fc3a..144d443 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -158,3 +158,10 @@ 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="Source Output (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" diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index 1478468..8a0b946 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -157,3 +157,10 @@ 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="Sortie source (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" diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index 4e3148b..d955cab 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -158,3 +158,10 @@ VideoSourceType="映像ソース" VideoSourceType.LongDescription="出力に使用する映像を選択します。\n「ソースの最終出力」はソースの全てのフィルターを通過した最終映像をキャプチャします。\n「フィルター入力」はフィルターチェーン内のこのフィルターの位置で映像をキャプチャします(このフィルターより上のフィルターのみ適用された映像)。" VideoSourceType.Source="ソースの最終出力 (デフォルト)" VideoSourceType.FilterInput="フィルター入力 (実験的)" +ReplayBuffer="リプレイバッファ" +ReplayBufferDuration="最大リプレイ時間(秒)" +Status.ReplayBuffer="バッファリング中" +SaveReplayBufferHotkey="'%1' のリプレイバッファを保存" +SaveReplayBufferAllHotkey="全てのBranch Outputリプレイバッファを保存" +SaveAllReplayBuffers="全リプレイバッファを保存" +ReplayBuffer.Saved="リプレイ保存完了" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index acdbb5a..5091c0f 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -157,3 +157,10 @@ VideoSourceType="비디오 소스" VideoSourceType.LongDescription="출력에 캡처할 비디오를 선택합니다.\n'소스 출력'은 소스의 최종 렌더링 출력을 캡처합니다(모든 필터 적용 후).\n'필터 입력'은 필터 체인에서 이 필터 위치의 비디오를 캡처합니다(이 필터 이전 및 아래의 필터 이전)." VideoSourceType.Source="소스 출력 (기본값)" VideoSourceType.FilterInput="필터 입력 (실험적)" +ReplayBuffer="리플레이 버퍼" +ReplayBufferDuration="최대 리플레이 시간 (초)" +Status.ReplayBuffer="버퍼링 중" +SaveReplayBufferHotkey="'%1' 리플레이 버퍼 저장" +SaveReplayBufferAllHotkey="모든 Branch Output 리플레이 버퍼 저장" +SaveAllReplayBuffers="모든 리플레이 버퍼 저장" +ReplayBuffer.Saved="리플레이 저장됨" diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index e48bcf2..b64cd63 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -155,3 +155,10 @@ 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="Ieșire sursă (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" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index 3ca3df3..529901b 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -157,3 +157,10 @@ VideoSourceType="Источник видео" VideoSourceType.LongDescription="Выберите, какое видео захватывать для вывода.\n'Выход источника' захватывает окончательный отрисованный вывод источника (после всех фильтров).\n'Вход фильтра' захватывает видео на позиции этого фильтра в цепочке (до этого фильтра и любых фильтров ниже)." VideoSourceType.Source="Выход источника (По умолчанию)" VideoSourceType.FilterInput="Вход фильтра (Экспериментально)" +ReplayBuffer="Буфер воспроизведения" +ReplayBufferDuration="Максимальное время воспроизведения (секунды)" +Status.ReplayBuffer="Буферизация" +SaveReplayBufferHotkey="Сохранить буфер воспроизведения '%1'" +SaveReplayBufferAllHotkey="Сохранить все буферы воспроизведения Branch Output" +SaveAllReplayBuffers="Сохранить все буферы воспроизведения" +ReplayBuffer.Saved="Воспроизведение сохранено" diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index 2283127..f0c44b2 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -157,3 +157,10 @@ VideoSourceType="Джерело відео" VideoSourceType.LongDescription="Оберіть, яке відео захоплювати для виводу.\n'Вихід джерела' захоплює остаточний відрендерений вивід джерела (після всіх фільтрів).\n'Вхід фільтра' захоплює відео на позиції цього фільтра в ланцюжку (до цього фільтра та будь-яких фільтрів нижче)." VideoSourceType.Source="Вихід джерела (За замовчуванням)" VideoSourceType.FilterInput="Вхід фільтра (Експериментально)" +ReplayBuffer="Буфер повтору" +ReplayBufferDuration="Максимальний час повтору (секунди)" +Status.ReplayBuffer="Буферизація" +SaveReplayBufferHotkey="Зберегти буфер повтору '%1'" +SaveReplayBufferAllHotkey="Зберегти всі буфери повтору Branch Output" +SaveAllReplayBuffers="Зберегти всі буфери повтору" +ReplayBuffer.Saved="Повтор збережено" diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index d7acf81..b67796a 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -157,3 +157,10 @@ VideoSourceType="视频源" VideoSourceType.LongDescription="选择要为输出捕获的视频。\n'源输出'捕获源的最终渲染输出(经过所有滤镜后)。\n'滤镜输入'捕获此滤镜在链中位置的视频(在此滤镜之前和下方的任何滤镜之前)。" VideoSourceType.Source="源输出(默认)" VideoSourceType.FilterInput="滤镜输入 (实验性)" +ReplayBuffer="回放缓冲" +ReplayBufferDuration="最大回放时间(秒)" +Status.ReplayBuffer="缓冲中" +SaveReplayBufferHotkey="保存 '%1' 回放缓冲" +SaveReplayBufferAllHotkey="保存所有 Branch Output 回放缓冲" +SaveAllReplayBuffers="保存所有回放缓冲" +ReplayBuffer.Saved="回放已保存" 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 1335caf..0b413b1 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -138,6 +138,12 @@ 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); @@ -155,6 +161,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 +194,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,6 +206,7 @@ BranchOutputStatusDock::BranchOutputStatusDock(QWidget *parent) loadHotkey(pauseRecordingAllHotkey, "PauseRecordingAllBranchOutputsHotkey"); loadHotkey(unpauseRecordingAllHotkey, "UnpauseRecordingAllBranchOutputsHotkey"); loadHotkey(addChapterToRecordingAllHotkey, "AddChapterToRecordingAllBranchOutputsHotkey"); + loadHotkey(saveReplayBufferAllHotkey, "SaveReplayBufferAllBranchOutputsHotkey"); sort(); @@ -222,6 +234,7 @@ BranchOutputStatusDock::~BranchOutputStatusDock() obs_hotkey_unregister(pauseRecordingAllHotkey); obs_hotkey_unregister(unpauseRecordingAllHotkey); obs_hotkey_unregister(addChapterToRecordingAllHotkey); + obs_hotkey_unregister(saveReplayBufferAllHotkey); obs_log(LOG_DEBUG, "BranchOutputStatusDock destroyed"); } @@ -364,6 +377,11 @@ 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++) { @@ -411,6 +429,7 @@ void BranchOutputStatusDock::update() applyPauseRecordingAllButtonEnabled(); applyUnpauseRecordingAllButtonEnabled(); applyAddChapterToRecordingAllButtonEnabled(); + applySaveReplayBufferAllButtonEnabled(); sort(); } @@ -480,6 +499,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); @@ -539,6 +569,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) { @@ -637,6 +676,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) { @@ -679,6 +726,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); } @@ -726,6 +776,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 &) { @@ -755,6 +806,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; @@ -787,6 +841,7 @@ void OutputTableRow::update() status->setUnpauseRecordingButtonShow(false); status->setAddChapterToRecordingButtonShow(false); status->setAddChapterToRecordingButtonShow(false); + status->setSaveReplayBufferButtonShow(false); } else { // Blanking suffix for status text QString blankSuffix; @@ -812,6 +867,7 @@ void OutputTableRow::update() status->setPauseRecordingButtonShow(false); status->setUnpauseRecordingButtonShow(false); status->setAddChapterToRecordingButtonShow(false); + status->setSaveReplayBufferButtonShow(false); break; case ROW_OUTPUT_RECORDING: if (paused) { @@ -827,6 +883,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")); @@ -836,9 +903,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 { @@ -850,6 +929,7 @@ void OutputTableRow::update() status->setPauseRecordingButtonShow(false); status->setUnpauseRecordingButtonShow(false); status->setAddChapterToRecordingButtonShow(false); + status->setSaveReplayBufferButtonShow(false); droppedFrames->setTextValue(""); megabytesSent->setTextValue(""); bitrate->setTextValue(""); @@ -882,7 +962,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; @@ -891,7 +973,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; @@ -935,6 +1021,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; } @@ -1183,6 +1272,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) @@ -1192,11 +1318,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)); @@ -1210,6 +1338,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); @@ -1222,23 +1354,31 @@ 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); @@ -1257,21 +1397,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 33d246a..652ac5a 100644 --- a/src/UI/output-status-dock.hpp +++ b/src/UI/output-status-dock.hpp @@ -138,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 @@ -145,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(); @@ -175,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; } }; @@ -182,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 { @@ -202,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; @@ -211,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; @@ -222,6 +253,7 @@ class BranchOutputStatusDock : public QFrame { void applyPauseRecordingAllButtonEnabled(); void applyUnpauseRecordingAllButtonEnabled(); void applyAddChapterToRecordingAllButtonEnabled(); + void applySaveReplayBufferAllButtonEnabled(); void saveSettings(); void loadSettings(); void applySettings(obs_data_t *settings); @@ -233,6 +265,7 @@ class BranchOutputStatusDock : public QFrame { 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); @@ -254,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/plugin-main.cpp b/src/plugin-main.cpp index 8ac4714..6b8dc94 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -71,7 +71,9 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc 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)); @@ -109,8 +111,9 @@ 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"); + // Fiter activate immediately when "server" or "stream_recording" or "replay_buffer" is exists. + initialized = countEnabledStreamings(settings) > 0 || obs_data_get_bool(settings, "stream_recording") || + obs_data_get_bool(settings, "replay_buffer"); obs_log(LOG_INFO, "%s: BranchOutputFilter created", qUtf8Printable(name)); } @@ -240,6 +243,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)); } @@ -288,6 +295,7 @@ void BranchOutputFilter::stopRecordingOutput() void BranchOutputFilter::stopOutput() { stopRecordingOutput(); + stopReplayBufferOutput(); pthread_mutex_lock(&outputMutex); { @@ -787,7 +795,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; } @@ -1089,6 +1098,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); @@ -1213,12 +1227,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 (countEnabledStreamings(settings) > 0 || isRecordingEnabled(settings) || isReplayBufferEnabled(settings)) { startOutput(settings); } } @@ -1335,7 +1349,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)) { @@ -1420,7 +1434,7 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } - if (streamingAlive || recordingAlive || recordingPending) { + if (streamingAlive || recordingAlive || recordingPending || replayBufferActive) { // Monitoring source auto parent = obs_filter_get_parent(filterSource); @@ -1528,7 +1542,7 @@ void BranchOutputFilter::onIntervalTimerTimeout() } } else { - if (streamingActive || recordingActive || recordingPending) { + if (streamingActive || recordingActive || recordingPending || replayBufferActive) { // Clicked filter's "Eye" icon (Hide) onStopOutputGracefully(); return; @@ -1539,8 +1553,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); @@ -1846,6 +1861,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)); @@ -1888,6 +1907,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 diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 3d34849..797a9ed 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -110,6 +110,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]; @@ -119,6 +124,7 @@ class BranchOutputFilter : public QObject { obs_hotkey_id splitRecordingHotkeyId; obs_hotkey_pair_id togglePauseRecordingHotkeyPairId; obs_hotkey_id addChapterToRecordingHotkeyId; + obs_hotkey_id saveReplayBufferHotkeyId; OBSSignal filterRenamedSignal; @@ -133,6 +139,11 @@ class BranchOutputFilter : public QObject { 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); @@ -168,6 +179,7 @@ class BranchOutputFilter : public QObject { void addAudioGroup(obs_properties_t *props); void addAudioEncoderGroup(obs_properties_t *props); void addVideoEncoderGroup(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); @@ -177,6 +189,8 @@ 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); diff --git a/src/plugin-replay-buffer.cpp b/src/plugin-replay-buffer.cpp new file mode 100644 index 0000000..5fe4731 --- /dev/null +++ b/src/plugin-replay-buffer.cpp @@ -0,0 +1,226 @@ +/* +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 87cad4f..54eb10e 100644 --- a/src/plugin-ui.cpp +++ b/src/plugin-ui.cpp @@ -195,6 +195,17 @@ 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."); } @@ -462,6 +473,77 @@ void BranchOutputFilter::addStreamGroup(obs_properties_t *props) obs_properties_add_group(props, "stream", obs_module_text("Stream"), OBS_GROUP_NORMAL, streamGroup); } +void BranchOutputFilter::addReplayBufferGroup(obs_properties_t *props) +{ + auto replayBufferGroup = obs_properties_create(); + + 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"); + + // 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 _replayBuffer = obs_data_get_bool(settings, "replay_buffer"); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_duration"), _replayBuffer); + obs_property_set_visible( + obs_properties_get(_props, "replay_buffer_use_profile_path"), _replayBuffer + ); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_path"), _replayBuffer); + obs_property_set_visible( + obs_properties_get(_props, "replay_buffer_filename_formatting"), _replayBuffer + ); + obs_property_set_visible( + obs_properties_get(_props, "replay_buffer_no_space_filename"), _replayBuffer + ); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_format"), _replayBuffer); + return true; + }, + nullptr + ); +} + void BranchOutputFilter::createAudioTrackProperties(obs_properties_t *audioGroup, size_t track, bool visible) { auto propNameFormat = getIndexedPropNameFormat(track, 1); @@ -829,6 +911,9 @@ obs_properties_t *BranchOutputFilter::getProperties() addApplyButton(props, "apply1"); + //--- "Replay Buffer" group ---// + addReplayBufferGroup(props); + //--- "Audio" gorup ---// addAudioGroup(props); From e90842539c254b8e5dda9950556996b63008a52a Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 23 Feb 2026 21:34:49 +0900 Subject: [PATCH 24/39] fix: clang-format errors --- src/UI/output-status-dock.cpp | 4 +--- src/plugin-replay-buffer.cpp | 3 +-- src/plugin-ui.cpp | 23 ++++++----------------- 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 0b413b1..6e046f6 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -1363,9 +1363,7 @@ StatusCell::StatusCell(const QString &rowId, const QString &textValue, QWidget * connect(addChapterToRecordingButton, &QToolButton::clicked, this, [this]() { emit addChapterToRecordingButtonClicked(); }); - connect(saveReplayBufferButton, &QToolButton::clicked, this, [this]() { - emit saveReplayBufferButtonClicked(); - }); + connect(saveReplayBufferButton, &QToolButton::clicked, this, [this]() { emit saveReplayBufferButtonClicked(); }); auto layout = new QHBoxLayout(); layout->setContentsMargins(0, 0, 0, 0); diff --git a/src/plugin-replay-buffer.cpp b/src/plugin-replay-buffer.cpp index 5fe4731..de71a15 100644 --- a/src/plugin-replay-buffer.cpp +++ b/src/plugin-replay-buffer.cpp @@ -61,8 +61,7 @@ obs_data_t *BranchOutputFilter::createReplayBufferSettings(obs_data_t *settings) // 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 path = useProfilePath ? getProfileRecordingPath(config) : obs_data_get_string(settings, "replay_buffer_path"); auto rbFormat = obs_data_get_string(settings, "replay_buffer_format"); // Validate path diff --git a/src/plugin-ui.cpp b/src/plugin-ui.cpp index 54eb10e..833606b 100644 --- a/src/plugin-ui.cpp +++ b/src/plugin-ui.cpp @@ -200,9 +200,7 @@ void BranchOutputFilter::getDefaults(obs_data_t *defaults) 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_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); @@ -496,13 +494,10 @@ void BranchOutputFilter::addReplayBufferGroup(obs_properties_t *props) 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 + 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") - ); + 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( @@ -527,16 +522,10 @@ void BranchOutputFilter::addReplayBufferGroup(obs_properties_t *props) [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { auto _replayBuffer = obs_data_get_bool(settings, "replay_buffer"); obs_property_set_visible(obs_properties_get(_props, "replay_buffer_duration"), _replayBuffer); - obs_property_set_visible( - obs_properties_get(_props, "replay_buffer_use_profile_path"), _replayBuffer - ); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_use_profile_path"), _replayBuffer); obs_property_set_visible(obs_properties_get(_props, "replay_buffer_path"), _replayBuffer); - obs_property_set_visible( - obs_properties_get(_props, "replay_buffer_filename_formatting"), _replayBuffer - ); - obs_property_set_visible( - obs_properties_get(_props, "replay_buffer_no_space_filename"), _replayBuffer - ); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_filename_formatting"), _replayBuffer); + obs_property_set_visible(obs_properties_get(_props, "replay_buffer_no_space_filename"), _replayBuffer); obs_property_set_visible(obs_properties_get(_props, "replay_buffer_format"), _replayBuffer); return true; }, From 4cdac2fd252d64eff422e5ed46b514bdb86532fb Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 23 Feb 2026 21:52:49 +0900 Subject: [PATCH 25/39] improve: Japenase translation --- data/locale/ja-JP.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index d955cab..0e93de4 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -160,7 +160,7 @@ VideoSourceType.Source="ソースの最終出力 (デフォルト)" VideoSourceType.FilterInput="フィルター入力 (実験的)" ReplayBuffer="リプレイバッファ" ReplayBufferDuration="最大リプレイ時間(秒)" -Status.ReplayBuffer="バッファリング中" +Status.ReplayBuffer="バッファ中" SaveReplayBufferHotkey="'%1' のリプレイバッファを保存" SaveReplayBufferAllHotkey="全てのBranch Outputリプレイバッファを保存" SaveAllReplayBuffers="全リプレイバッファを保存" From 8165b96579f231f6f5481f0e707d05421ccec493 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 23 Feb 2026 21:53:25 +0900 Subject: [PATCH 26/39] fix: Update output display for replay buffer in OutputTableRow --- src/UI/output-status-dock.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 6e046f6..67a896e 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -1038,8 +1038,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() From 211733cea296fcbcffe559ded95e95ee680a6d37 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 23 Feb 2026 22:11:25 +0900 Subject: [PATCH 27/39] fix: Enhance testing instructions for Branch Output filter stability --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 18f2f6a..9885280 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,11 @@ Format is checked in CI via `.github/workflows/check-format.yaml` using reusable - **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. --- From d80bac30de60ec9d840f74a0560c7d99fbb9c24a Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Fri, 27 Feb 2026 22:56:29 +0900 Subject: [PATCH 28/39] add: Optimize GPU usage of filter input mode. --- src/plugin-main.cpp | 19 +++++++++++----- src/video/filter-video-capture.cpp | 35 +++++++++++++++++++++++++----- src/video/filter-video-capture.hpp | 7 +++++- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 6b8dc94..1d6a1af 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -200,13 +200,22 @@ void BranchOutputFilter::updateCallback(obs_data_t *settings) void BranchOutputFilter::videoRenderCallback(gs_effect_t *) { - // Capture filter input when in filter input mode if (useFilterInput && filterVideoCapture) { - filterVideoCapture->captureFilterInput(); + // 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); } - - // Always pass through the filter chain - obs_source_skip_video_filter(filterSource); } // This method possibly called in different thread from UI thread diff --git a/src/video/filter-video-capture.cpp b/src/video/filter-video-capture.cpp index 859e427..fcd0622 100644 --- a/src/video/filter-video-capture.cpp +++ b/src/video/filter-video-capture.cpp @@ -126,33 +126,33 @@ FilterVideoCapture::~FilterVideoCapture() obs_leave_graphics(); } -void FilterVideoCapture::captureFilterInput() +bool FilterVideoCapture::captureFilterInput() { if (!active.load() || !texrender) { - return; + return false; } obs_source_t *target = obs_filter_get_target(filterSource); if (!target) { - return; + 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; + return false; } // Detect resolution change — BranchOutputFilter will restart output if (cx != captureWidth || cy != captureHeight) { - return; + return false; } // Render filter input to texrender gs_texrender_reset(texrender); if (!gs_texrender_begin(texrender, cx, cy)) { - return; + return false; } struct vec4 clear_color; @@ -169,6 +169,29 @@ void FilterVideoCapture::captureFilterInput() gs_texrender_end(texrender); textureReady.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() diff --git a/src/video/filter-video-capture.hpp b/src/video/filter-video-capture.hpp index c2b9948..3935ec5 100644 --- a/src/video/filter-video-capture.hpp +++ b/src/video/filter-video-capture.hpp @@ -60,7 +60,12 @@ class FilterVideoCapture { ~FilterVideoCapture(); // Called from video_render callback (graphics thread) to capture filter input - void captureFilterInput(); + // 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(); From c38dc2dd756885dcfb73a50dc82bc9576d5747a6 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Fri, 27 Feb 2026 23:42:32 +0900 Subject: [PATCH 29/39] fix: Blanking mode was not exited when branch outputs were in the scene. --- src/utils.cpp | 54 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/src/utils.cpp b/src/utils.cpp index f662f08..494e909 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -24,6 +24,7 @@ with this program. If not, see #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) @@ -186,6 +187,19 @@ static bool sourceVisibleInSceneSource(obs_source_t *sceneSource, obs_source_t * 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; @@ -200,29 +214,41 @@ bool sourceVisibleInProgram(obs_source_t *source) return false; } - // Prefer using OBS' actual program output source (transition), because in Studio Mode the - // Program display may use duplicated/private scenes that differ from the "current scene". - // - // During transitions, the Program output can contain both Source A and Source B. Consider - // the source visible if it's visible in either one, so we don't blank incorrectly mid-transition. + // 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) { - return sourceVisibleInSceneSource(a, source) || sourceVisibleInSceneSource(b, source); - } - - OBSSourceAutoRelease active = obs_transition_get_active_source(output); - if (active) { - return sourceVisibleInSceneSource(active, source); + 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; + } + } } - - return sourceVisibleInSceneSource(output, source); } - // Fallback: frontend API scene pointer. + // 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); } From b2d784c7941a00839e6a3ca3a5f912d39a070afb Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 28 Feb 2026 00:44:40 +0900 Subject: [PATCH 30/39] fix: When adding a branch output to a scene and using filter input mode, the video would freeze when the scene became inactive. --- src/plugin-main.cpp | 17 +++++++++- src/plugin-main.hpp | 1 + src/video/filter-video-capture.cpp | 50 +++++++++++++++++++++++++++--- src/video/filter-video-capture.hpp | 23 +++++++++++++- 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 1d6a1af..4e62807 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -198,6 +198,17 @@ 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 *) { if (useFilterInput && filterVideoCapture) { @@ -882,7 +893,7 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) // 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, width, height); + filterVideoCapture = new FilterVideoCapture(filterSource, parent, width, height); if (!filterVideoCapture->getProxySource()) { obs_log(LOG_ERROR, "%s: Filter video capture creation failed", qUtf8Printable(name)); delete filterVideoCapture; @@ -1968,6 +1979,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(); diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 797a9ed..ac38dba 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -194,6 +194,7 @@ class BranchOutputFilter : public QObject { 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/video/filter-video-capture.cpp b/src/video/filter-video-capture.cpp index fcd0622..aea44bb 100644 --- a/src/video/filter-video-capture.cpp +++ b/src/video/filter-video-capture.cpp @@ -80,21 +80,26 @@ obs_source_info FilterVideoCapture::createProxySourceInfo() //--- FilterVideoCapture implementation ---// -FilterVideoCapture::FilterVideoCapture(obs_source_t *_filterSource, uint32_t _width, uint32_t _height) +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) + 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) { + if (!texrender || !triggerTexrender) { obs_log(LOG_ERROR, "FilterVideoCapture: gs_texrender_create failed"); return; } @@ -123,6 +128,10 @@ FilterVideoCapture::~FilterVideoCapture() gs_texrender_destroy(texrender); texrender = nullptr; } + if (triggerTexrender) { + gs_texrender_destroy(triggerTexrender); + triggerTexrender = nullptr; + } obs_leave_graphics(); } @@ -169,6 +178,7 @@ bool FilterVideoCapture::captureFilterInput() gs_texrender_end(texrender); textureReady.store(true); + capturedThisFrame.store(true); return true; } @@ -196,7 +206,39 @@ void FilterVideoCapture::drawCapturedTexture() void FilterVideoCapture::renderTexture() { - if (!textureReady.load() || !texrender) { + 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; } diff --git a/src/video/filter-video-capture.hpp b/src/video/filter-video-capture.hpp index 3935ec5..84e3077 100644 --- a/src/video/filter-video-capture.hpp +++ b/src/video/filter-video-capture.hpp @@ -41,12 +41,23 @@ 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; @@ -55,8 +66,15 @@ class FilterVideoCapture { 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, uint32_t _width, uint32_t _height); + 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 @@ -77,6 +95,9 @@ class FilterVideoCapture { 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; } From 880859fc69873096073bd394460ca1665bce33e5 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 28 Feb 2026 00:56:28 +0900 Subject: [PATCH 31/39] fix: Improve translations --- data/locale/ca-ES.ini | 2 +- data/locale/de-DE.ini | 2 +- data/locale/en-US.ini | 2 +- data/locale/fr-FR.ini | 2 +- data/locale/ja-JP.ini | 2 +- data/locale/ko-KR.ini | 2 +- data/locale/ro-RO.ini | 2 +- data/locale/ru-RU.ini | 2 +- data/locale/uk-UA.ini | 2 +- data/locale/zh-CN.ini | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index a593913..ed8da91 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -155,7 +155,7 @@ 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="Sortida de la font (Per defecte)" +VideoSourceType.Source="Mescla independent (Per defecte)" VideoSourceType.FilterInput="Entrada de filtre (Experimental)" ReplayBuffer="Replay Buffer" ReplayBufferDuration="Maximum Replay Time (seconds)" diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index 71d3231..f15d5e1 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -155,7 +155,7 @@ 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="Quellenausgabe (Standard)" +VideoSourceType.Source="Unabhängiger Mix (Standard)" VideoSourceType.FilterInput="Filtereingabe (Experimentell)" ReplayBuffer="Wiederholungspuffer" ReplayBufferDuration="Maximale Wiederholungszeit (Sekunden)" diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 144d443..d656ace 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -156,7 +156,7 @@ 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="Source Output (Default)" +VideoSourceType.Source="Independent Mix (Default)" VideoSourceType.FilterInput="Filter Input (Experimental)" ReplayBuffer="Replay Buffer" ReplayBufferDuration="Maximum Replay Time (seconds)" diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index 8a0b946..2093f74 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -155,7 +155,7 @@ 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="Sortie source (Par défaut)" +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)" diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index 0e93de4..2f6d816 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -156,7 +156,7 @@ AddChapterToAllRecordings="全録画にチャプター追加" AddChapterToRecordingAllHotkey="全ての Branch Output 録画にチャプターを追加" VideoSourceType="映像ソース" VideoSourceType.LongDescription="出力に使用する映像を選択します。\n「ソースの最終出力」はソースの全てのフィルターを通過した最終映像をキャプチャします。\n「フィルター入力」はフィルターチェーン内のこのフィルターの位置で映像をキャプチャします(このフィルターより上のフィルターのみ適用された映像)。" -VideoSourceType.Source="ソースの最終出力 (デフォルト)" +VideoSourceType.Source="独立ミックス (デフォルト)" VideoSourceType.FilterInput="フィルター入力 (実験的)" ReplayBuffer="リプレイバッファ" ReplayBufferDuration="最大リプレイ時間(秒)" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index 5091c0f..79555fb 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -155,7 +155,7 @@ AddChapterToAllRecordings="모든 녹화에 챕터 추가" AddChapterToRecordingAllHotkey="모든 분기 출력 녹화에 챕터 추가" VideoSourceType="비디오 소스" VideoSourceType.LongDescription="출력에 캡처할 비디오를 선택합니다.\n'소스 출력'은 소스의 최종 렌더링 출력을 캡처합니다(모든 필터 적용 후).\n'필터 입력'은 필터 체인에서 이 필터 위치의 비디오를 캡처합니다(이 필터 이전 및 아래의 필터 이전)." -VideoSourceType.Source="소스 출력 (기본값)" +VideoSourceType.Source="독립 믹스 (기본값)" VideoSourceType.FilterInput="필터 입력 (실험적)" ReplayBuffer="리플레이 버퍼" ReplayBufferDuration="최대 리플레이 시간 (초)" diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index b64cd63..a0f14b7 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -153,7 +153,7 @@ 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="Ieșire sursă (Implicit)" +VideoSourceType.Source="Mix independent (Implicit)" VideoSourceType.FilterInput="Intrare filtru (Experimental)" ReplayBuffer="Replay Buffer" ReplayBufferDuration="Maximum Replay Time (seconds)" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index 529901b..7d9b8cb 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -155,7 +155,7 @@ AddChapterToAllRecordings="Добавить главу во все записи" AddChapterToRecordingAllHotkey="Добавить главу во все записи Выхода Ветви" VideoSourceType="Источник видео" VideoSourceType.LongDescription="Выберите, какое видео захватывать для вывода.\n'Выход источника' захватывает окончательный отрисованный вывод источника (после всех фильтров).\n'Вход фильтра' захватывает видео на позиции этого фильтра в цепочке (до этого фильтра и любых фильтров ниже)." -VideoSourceType.Source="Выход источника (По умолчанию)" +VideoSourceType.Source="Независимый микс (По умолчанию)" VideoSourceType.FilterInput="Вход фильтра (Экспериментально)" ReplayBuffer="Буфер воспроизведения" ReplayBufferDuration="Максимальное время воспроизведения (секунды)" diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index f0c44b2..919b7cf 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -155,7 +155,7 @@ AddChapterToAllRecordings="Додати розділ до всіх записі AddChapterToRecordingAllHotkey="Додати розділ до всіх записів Виходу Гілки" VideoSourceType="Джерело відео" VideoSourceType.LongDescription="Оберіть, яке відео захоплювати для виводу.\n'Вихід джерела' захоплює остаточний відрендерений вивід джерела (після всіх фільтрів).\n'Вхід фільтра' захоплює відео на позиції цього фільтра в ланцюжку (до цього фільтра та будь-яких фільтрів нижче)." -VideoSourceType.Source="Вихід джерела (За замовчуванням)" +VideoSourceType.Source="Незалежний мікс (За замовчуванням)" VideoSourceType.FilterInput="Вхід фільтра (Експериментально)" ReplayBuffer="Буфер повтору" ReplayBufferDuration="Максимальний час повтору (секунди)" diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index b67796a..6cecee1 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -155,7 +155,7 @@ AddChapterToAllRecordings="为所有录制添加章节" AddChapterToRecordingAllHotkey="为所有分支输出录制添加章节" VideoSourceType="视频源" VideoSourceType.LongDescription="选择要为输出捕获的视频。\n'源输出'捕获源的最终渲染输出(经过所有滤镜后)。\n'滤镜输入'捕获此滤镜在链中位置的视频(在此滤镜之前和下方的任何滤镜之前)。" -VideoSourceType.Source="源输出(默认)" +VideoSourceType.Source="独立混合(默认)" VideoSourceType.FilterInput="滤镜输入 (实验性)" ReplayBuffer="回放缓冲" ReplayBufferDuration="最大回放时间(秒)" From 6cc80719f6d7981aa8ee09069525090472daa028 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 28 Feb 2026 01:24:24 +0900 Subject: [PATCH 32/39] fix: clang-format error --- src/video/filter-video-capture.cpp | 5 +++-- src/video/filter-video-capture.hpp | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/video/filter-video-capture.cpp b/src/video/filter-video-capture.cpp index aea44bb..967899e 100644 --- a/src/video/filter-video-capture.cpp +++ b/src/video/filter-video-capture.cpp @@ -80,8 +80,9 @@ obs_source_info FilterVideoCapture::createProxySourceInfo() //--- FilterVideoCapture implementation ---// -FilterVideoCapture::FilterVideoCapture(obs_source_t *_filterSource, obs_source_t *_parentSource, uint32_t _width, - uint32_t _height) +FilterVideoCapture::FilterVideoCapture( + obs_source_t *_filterSource, obs_source_t *_parentSource, uint32_t _width, uint32_t _height +) : filterSource(_filterSource), parentSource(_parentSource), proxySource(nullptr), diff --git a/src/video/filter-video-capture.hpp b/src/video/filter-video-capture.hpp index 84e3077..aa93a0b 100644 --- a/src/video/filter-video-capture.hpp +++ b/src/video/filter-video-capture.hpp @@ -73,8 +73,9 @@ class FilterVideoCapture { std::atomic_bool capturedThisFrame; public: - explicit FilterVideoCapture(obs_source_t *_filterSource, obs_source_t *_parentSource, uint32_t _width, - uint32_t _height); + 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 From fbdc9907823f39cd70a97b0c573787fac5234076 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 28 Feb 2026 11:54:52 +0900 Subject: [PATCH 33/39] feat: Add new interlock modes, Replay Buffer, Always OFF --- data/locale/ca-ES.ini | 2 ++ data/locale/de-DE.ini | 2 ++ data/locale/en-US.ini | 2 ++ data/locale/fr-FR.ini | 2 ++ data/locale/ja-JP.ini | 2 ++ data/locale/ko-KR.ini | 2 ++ data/locale/ro-RO.ini | 2 ++ data/locale/ru-RU.ini | 2 ++ data/locale/uk-UA.ini | 2 ++ data/locale/zh-CN.ini | 2 ++ src/UI/output-status-dock.cpp | 2 ++ src/plugin-main.cpp | 21 +++++++++++++++++++-- src/plugin-main.hpp | 2 ++ 13 files changed, 43 insertions(+), 2 deletions(-) diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index ed8da91..a420af8 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -35,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" diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index f15d5e1..91be576 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -35,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" diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index d656ace..7819988 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -35,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" diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index 2093f74..b10841e 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -35,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" diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index 2f6d816..de69bd1 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -35,7 +35,9 @@ AlwaysOn="常時ON" Streaming="配信" Recording="録画" StreamingOrRecording="配信 or 録画" +ReplayBuffer="リプレイバッファ" VirtualCam="仮想カメラ" +AlwaysOff="常時OFF" StreamRecording="配信録画" Path="保存先パス" VideoFormat="録画フォーマット" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index 79555fb..4e85333 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -35,7 +35,9 @@ AlwaysOn="항상 켜짐" Streaming="스트리밍" Recording="녹화" StreamingOrRecording="스트리밍 or 녹화" +ReplayBuffer="리플레이 버퍼" VirtualCam="가상 카메라" +AlwaysOff="항상 OFF" StreamRecording="배달 녹화" Path="대상 경로" VideoFormat="녹화 형식" diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index a0f14b7..d5d930f 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -35,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" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index 7d9b8cb..fb8e7e7 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -35,7 +35,9 @@ AlwaysOn="Всегда ВКЛЮЧЕНО" Streaming="Трансляция" Recording="Запись" StreamingOrRecording="Трансляция или Запись" +ReplayBuffer="Буфер воспроизведения" VirtualCam="Виртуальная камера" +AlwaysOff="Всегда ВЫКЛ" StreamRecording="Запись потока" Path="Путь к папке" VideoFormat="Формат контейнера" diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index 919b7cf..ad46887 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -35,7 +35,9 @@ AlwaysOn="Завжди ввімкнено" Streaming="Трансляція" Recording="Записування" StreamingOrRecording="Трансляція або Записування" +ReplayBuffer="Буфер повтору" VirtualCam="Віртуальна камера" +AlwaysOff="Завжди ВИМК" StreamRecording="Запис трансляції" Path="Шлях до папки" VideoFormat="Формат контейнера" diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index 6cecee1..05c4bc0 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -35,7 +35,9 @@ AlwaysOn="始终开启" Streaming="直播" Recording="录制中" StreamingOrRecording="直播或记录" +ReplayBuffer="回放缓存" VirtualCam="虚拟摄像头" +AlwaysOff="始终关闭" StreamRecording="直播录制" Path="保存路径" VideoFormat="录像格式" diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 67a896e..4b30a83 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -150,7 +150,9 @@ BranchOutputStatusDock::BranchOutputStatusDock(QWidget *parent) 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); diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 4e62807..5239bab 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -1380,7 +1380,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; @@ -1400,6 +1402,11 @@ void BranchOutputFilter::onIntervalTimerTimeout() restartOutput(); return; } + } else if (interlockType == INTERLOCK_TYPE_REPLAY_BUFFER) { + if (obs_frontend_replay_buffer_active()) { + restartOutput(); + return; + } } else { restartOutput(); return; @@ -1421,7 +1428,11 @@ void BranchOutputFilter::onIntervalTimerTimeout() 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(); @@ -1445,6 +1456,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) { diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index ac38dba..0a68540 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -45,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 { From a0e6335a74a7d9ae7aef7c7f019f6fbb85a728c7 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 28 Feb 2026 13:03:04 +0900 Subject: [PATCH 34/39] feat: Add improved filter properties UI --- data/locale/ca-ES.ini | 6 + data/locale/de-DE.ini | 6 + data/locale/en-US.ini | 6 + data/locale/fr-FR.ini | 6 + data/locale/ja-JP.ini | 6 + data/locale/ko-KR.ini | 6 + data/locale/ro-RO.ini | 6 + data/locale/ru-RU.ini | 6 + data/locale/uk-UA.ini | 6 + data/locale/zh-CN.ini | 6 + src/UI/output-status-dock.cpp | 10 +- src/plugin-main.cpp | 26 ++- src/plugin-main.hpp | 5 +- src/plugin-ui.cpp | 343 +++++++++++++++++++++++----------- 14 files changed, 325 insertions(+), 119 deletions(-) diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index ed8da91..8185233 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -164,3 +164,9 @@ 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 f15d5e1..57764a4 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -164,3 +164,9 @@ 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 d656ace..d1b210e 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -165,3 +165,9 @@ 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 2093f74..80b4aee 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -164,3 +164,9 @@ 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 2f6d816..7067044 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -165,3 +165,9 @@ 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 79555fb..7d34f3d 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -164,3 +164,9 @@ 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 a0f14b7..d0b5660 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -162,3 +162,9 @@ 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 7d9b8cb..b889691 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -164,3 +164,9 @@ SaveReplayBufferHotkey="Сохранить буфер воспроизведен 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 919b7cf..0576a00 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -164,3 +164,9 @@ 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 6cecee1..6de6637 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -164,3 +164,9 @@ 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/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 67a896e..19d310b 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -383,10 +383,12 @@ void BranchOutputStatusDock::addFilter(BranchOutputFilter *filter) } // 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++); + } } } diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 4e62807..dd37f0c 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -111,8 +111,14 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc obs_data_set_int(settings, "audio_track", trackNo); } + // 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 = countEnabledStreamings(settings) > 0 || obs_data_get_bool(settings, "stream_recording") || + 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)); @@ -835,7 +841,7 @@ 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; } @@ -882,9 +888,11 @@ 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 ---// @@ -1225,6 +1233,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"); @@ -1252,7 +1261,7 @@ void BranchOutputFilter::restartOutput() } OBSDataAutoRelease settings = obs_source_get_settings(filterSource); - if (countEnabledStreamings(settings) > 0 || isRecordingEnabled(settings) || isReplayBufferEnabled(settings)) { + if (isStreamingGroupEnabled(settings) || isRecordingEnabled(settings) || isReplayBufferEnabled(settings)) { startOutput(settings); } } @@ -1285,6 +1294,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; diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index ac38dba..d7f6343 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -155,6 +155,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); @@ -172,13 +173,15 @@ class BranchOutputFilter : public QObject { // 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 diff --git a/src/plugin-ui.cpp b/src/plugin-ui.cpp index 833606b..f2ac5c5 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"); @@ -299,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( @@ -317,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 + ); + + addApplyButton(props, "apply1"); +} - //--- Recording options (initially hidden) ---// - auto useProfilePath = - obs_properties_add_bool(streamGroup, "use_profile_recording_path", obs_module_text("UseProfileRecordingPath")); +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"); @@ -396,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"); @@ -417,44 +426,90 @@ 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") ); - // Add gap line - obs_properties_add_text(streamGroup, "stream_options_group", "", OBS_TEXT_INFO); + // 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")); auto blankWhenNotVisible = - obs_properties_add_bool(streamGroup, "blank_when_not_visible", obs_module_text("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(streamGroup, "mute_audio_when_blank", obs_module_text("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); @@ -468,13 +523,20 @@ void BranchOutputFilter::addStreamGroup(obs_properties_t *props) nullptr ); - obs_properties_add_group(props, "stream", obs_module_text("Stream"), OBS_GROUP_NORMAL, streamGroup); + 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 ); @@ -511,6 +573,12 @@ void BranchOutputFilter::addReplayBufferGroup(obs_properties_t *props) 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 @@ -520,17 +588,22 @@ void BranchOutputFilter::addReplayBufferGroup(obs_properties_t *props) obs_property_set_modified_callback2( replayBufferProp, [](void *, obs_properties_t *_props, obs_property_t *, obs_data_t *settings) { - auto _replayBuffer = obs_data_get_bool(settings, "replay_buffer"); - obs_property_set_visible(obs_properties_get(_props, "replay_buffer_duration"), _replayBuffer); - obs_property_set_visible(obs_properties_get(_props, "replay_buffer_use_profile_path"), _replayBuffer); - obs_property_set_visible(obs_properties_get(_props, "replay_buffer_path"), _replayBuffer); - obs_property_set_visible(obs_properties_get(_props, "replay_buffer_filename_formatting"), _replayBuffer); - obs_property_set_visible(obs_properties_get(_props, "replay_buffer_no_space_filename"), _replayBuffer); - obs_property_set_visible(obs_properties_get(_props, "replay_buffer_format"), _replayBuffer); + 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) @@ -616,9 +689,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")); @@ -630,29 +743,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 @@ -661,6 +754,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) @@ -895,10 +1018,11 @@ 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); @@ -912,7 +1036,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; From 86da803606e6606183c5b01d86c5d055e2f77263 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 28 Feb 2026 14:21:57 +0900 Subject: [PATCH 35/39] fix: clang-format error --- src/plugin-ui.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/plugin-ui.cpp b/src/plugin-ui.cpp index f2ac5c5..3ee54b9 100644 --- a/src/plugin-ui.cpp +++ b/src/plugin-ui.cpp @@ -476,7 +476,9 @@ void BranchOutputFilter::addRecordingGroup(obs_properties_t *props) 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); + 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( @@ -593,8 +595,12 @@ void BranchOutputFilter::addReplayBufferGroup(obs_properties_t *props) 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_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); From d83ba6337b551aa8df7870b979892029a1502748 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 28 Feb 2026 15:29:33 +0900 Subject: [PATCH 36/39] fix: ensure blanking output and audio states are reset in innner of mutex --- src/plugin-main.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index efbfbfd..2cab348 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -358,10 +358,9 @@ void BranchOutputFilter::stopOutput() videoOutput = nullptr; useFilterInput = false; blankSource = nullptr; + blankingOutputActive = false; + blankingAudioMuted = false; } - - blankingOutputActive = false; - blankingAudioMuted = false; } obs_data_t *BranchOutputFilter::createRecordingSettings(obs_data_t *settings, bool createFolder) From 815ef49aa2bd70ae626c58581149b7ca309c93f8 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 28 Feb 2026 16:02:15 +0900 Subject: [PATCH 37/39] fix: Bug where audio was not muted properly in blanking and mute modes when using master audio --- src/audio/audio-capture.cpp | 29 +++++++++++++++++++++++++++++ src/audio/audio-capture.hpp | 15 +++++++++++++++ src/plugin-main.cpp | 15 ++++++++++++--- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/audio/audio-capture.cpp b/src/audio/audio-capture.cpp index bab6383..3d9a32f 100644 --- a/src/audio/audio-capture.cpp +++ b/src/audio/audio-capture.cpp @@ -22,6 +22,7 @@ with this program. If not, see #include "audio-capture.hpp" #include "../plugin-support.h" +#include "../utils.hpp" #define MAX_AUDIO_BUFFER_FRAMES 131071 @@ -270,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 f8c1571..48bd28e 100644 --- a/src/audio/audio-capture.hpp +++ b/src/audio/audio-capture.hpp @@ -108,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 2cab348..24780d5 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -993,9 +993,18 @@ 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 From 872b515856dc93f2135265de34492b347a5e1790 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 28 Feb 2026 16:12:11 +0900 Subject: [PATCH 38/39] fix: Made blanking mode more robust by purging blankSource and using a null source --- src/plugin-main.cpp | 26 +------------------------- src/plugin-main.hpp | 3 --- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 24780d5..fc36f5c 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -357,7 +357,6 @@ void BranchOutputFilter::stopOutput() view = nullptr; videoOutput = nullptr; useFilterInput = false; - blankSource = nullptr; blankingOutputActive = false; blankingAudioMuted = false; } @@ -1103,22 +1102,6 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) } if (blankWhenHidden) { - // Pre-create blank source so that setBlankingActive() never has to - // allocate during a live stream. The color_source is lightweight and - // cleaned up in stopOutput(). - if (!blankSource) { - OBSDataAutoRelease blankSettings = obs_data_create(); - obs_data_set_int(blankSettings, "color", 0xFF000000); - if (width > 0 && height > 0) { - obs_data_set_int(blankSettings, "width", width); - obs_data_set_int(blankSettings, "height", height); - } - blankSource = obs_source_create_private("color_source", "Branch Output Blank", blankSettings); - if (!blankSource) { - obs_log(LOG_WARNING, "%s: Failed to pre-create blank color source", qUtf8Printable(name)); - } - } - bool visibleInProgram = sourceVisibleInProgram(parent); setBlankingActive(!visibleInProgram, muteWhenHidden, parent); } @@ -1782,14 +1765,7 @@ void BranchOutputFilter::setBlankingActive(bool active, bool muteAudio, obs_sour if (active) { if (!blankingOutputActive) { - // blankSource is pre-created in startOutput(). - if (blankSource) { - obs_view_set_source(view, 0, blankSource); - } else { - obs_log( - LOG_WARNING, "%s: Blank source not available; leaving original source active", qUtf8Printable(name) - ); - } + obs_view_set_source(view, 0, nullptr); if (muteAudio) { setAudioCapturesActive(false); blankingAudioMuted = true; diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index bf05278..8f186e7 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -84,9 +84,6 @@ class BranchOutputFilter : public QObject { // Filter source (Do not use OBSSourceAutoRelease) obs_source_t *filterSource; - // Private solid-color source used for blanking - OBSSourceAutoRelease blankSource; - // User choosed encoder OBSEncoderAutoRelease videoEncoder; From 557a8328a18bb4027fe82bb73c53f0c656733ea4 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 28 Feb 2026 16:15:15 +0900 Subject: [PATCH 39/39] fix: clang-format error. --- src/plugin-main.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index fc36f5c..bb48a90 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -993,9 +993,8 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) ); if (blankWhenHidden && muteWhenHidden) { - audioContext->capture = new MasterAudioCapture( - masterTrack - 1, ai.samples_per_sec, ai.speakers, this - ); + 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();