From 5689ef4d51d67b3c59ad3e78b2fce1c684eb48ed Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 30 Mar 2026 03:05:43 +0900 Subject: [PATCH 01/18] feat: Add individual start/stop control for streaming, recording, and replay buffer - Refactor startOutput/stopOutput into ensureInfrastructure/releaseInfrastructureIfIdle to support shared encoder lifecycle across independently managed outputs - Add per-output start/stop methods (startStreamingIndividual, stopRecordingIndividual, etc.) with internal helpers to eliminate code duplication - Add INTERLOCK_TYPE_INDIVIDUAL mode that maps OBS streaming/recording/replay buffer states to their Branch Output counterparts independently - Add OutputCell widget to status dock with per-output enable/disable checkbox (works in all interlock modes), preserving folder-open click for recording/replay - Persist per-output enable/disable state in filter settings via save callback - Add audioMutex to protect audio capture pointers in audioFilterCallback against concurrent release in releaseInfrastructureIfIdle - Add "Individual" locale key to all 10 locale files Co-Authored-By: Claude Opus 4.6 (1M context) --- data/locale/ca-ES.ini | 1 + data/locale/de-DE.ini | 1 + data/locale/en-US.ini | 1 + data/locale/fr-FR.ini | 1 + data/locale/ja-JP.ini | 1 + data/locale/ko-KR.ini | 1 + data/locale/ro-RO.ini | 1 + data/locale/ru-RU.ini | 1 + data/locale/uk-UA.ini | 1 + data/locale/zh-CN.ini | 1 + src/UI/output-status-dock.cpp | 143 +++++- src/UI/output-status-dock.hpp | 46 +- src/plugin-main.cpp | 788 ++++++++++++++++++-------------- src/plugin-main.hpp | 32 ++ src/plugin-replay-buffer.cpp | 37 ++ src/plugin-stream-recording.cpp | 47 ++ src/plugin-streaming.cpp | 80 ++++ src/plugin-ui.cpp | 5 + 18 files changed, 825 insertions(+), 363 deletions(-) diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index 2b86d1b..6e4926a 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -37,6 +37,7 @@ Recording="Gravació" StreamingOrRecording="Transmissió o gravació" ReplayBuffer="Memòria de repetició" VirtualCam="Càmera virtual" +Individual="Individual" AlwaysOff="Sempre OFF" StreamRecording="Gravació en flux" Path="Ruta del directori" diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index 0e590fd..d9c5975 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -37,6 +37,7 @@ Recording="Aufnahme" StreamingOrRecording="Streaming oder Aufnahme" ReplayBuffer="Wiederholungspuffer" VirtualCam="Virtuelle Kamera" +Individual="Individuell" AlwaysOff="Immer AUS" StreamRecording="Stream-Aufzeichnung" Path="Pfad speichern" diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 6d1af0e..ad78487 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -37,6 +37,7 @@ Recording="Recording" StreamingOrRecording="Streaming or Recording" ReplayBuffer="Replay Buffer" VirtualCam="Virtual Cam" +Individual="Individual" AlwaysOff="Always OFF" StreamRecording="Stream Recording" Path="Save Path" diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index e2a5e03..a5955ca 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -37,6 +37,7 @@ Recording="Enregistrement" StreamingOrRecording="Streaming ou enregistrement" ReplayBuffer="Tampon de relecture" VirtualCam="Caméra virtuelle" +Individual="Individuel" AlwaysOff="Toujours OFF" StreamRecording="Enregistrement de stream en direct" Path="Enregistrer le chemin" diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index 223155e..2b04611 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -37,6 +37,7 @@ Recording="録画" StreamingOrRecording="配信 or 録画" ReplayBuffer="リプレイバッファ" VirtualCam="仮想カメラ" +Individual="インディビデュアル" AlwaysOff="常時OFF" StreamRecording="配信録画" Path="保存先パス" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index 9b7a6d7..74cdb53 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -37,6 +37,7 @@ Recording="녹화" StreamingOrRecording="스트리밍 or 녹화" ReplayBuffer="리플레이 버퍼" VirtualCam="가상 카메라" +Individual="개별" AlwaysOff="항상 OFF" StreamRecording="배달 녹화" Path="대상 경로" diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index 1111926..dba0adb 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -37,6 +37,7 @@ Recording="Înregistrare" StreamingOrRecording="Streaming sau înregistrare" ReplayBuffer="Buffer de reluare" VirtualCam="Cameră virtuală" +Individual="Individual" AlwaysOff="Întotdeauna OFF" StreamRecording="Înregistrare în stream" Path="Calea folderului" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index b579f59..5973f02 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -37,6 +37,7 @@ Recording="Запись" StreamingOrRecording="Трансляция или Запись" ReplayBuffer="Буфер воспроизведения" VirtualCam="Виртуальная камера" +Individual="Индивидуальный" AlwaysOff="Всегда ВЫКЛ" StreamRecording="Запись потока" Path="Путь к папке" diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index 35a2531..2167290 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -37,6 +37,7 @@ Recording="Записування" StreamingOrRecording="Трансляція або Записування" ReplayBuffer="Буфер повтору" VirtualCam="Віртуальна камера" +Individual="Індивідуальний" AlwaysOff="Завжди ВИМК" StreamRecording="Запис трансляції" Path="Шлях до папки" diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index 272de59..938ec2e 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -37,6 +37,7 @@ Recording="录制中" StreamingOrRecording="直播或记录" ReplayBuffer="回放缓存" VirtualCam="虚拟摄像头" +Individual="单独联动" AlwaysOff="始终关闭" StreamRecording="直播录制" Path="保存路径" diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 95933f0..a4d777b 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -153,6 +153,7 @@ BranchOutputStatusDock::BranchOutputStatusDock(QWidget *parent) 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("Individual"), BranchOutputFilter::INTERLOCK_TYPE_INDIVIDUAL); interlockComboBox->addItem(QTStr("AlwaysOff"), BranchOutputFilter::INTERLOCK_TYPE_ALWAYS_OFF); auto buttonsContainerLayout = new QHBoxLayout(); @@ -726,18 +727,44 @@ OutputTableRow::OutputTableRow( switch (outputType) { case ROW_OUTPUT_STREAMING: - outputName = new LabelCell(rowId, QTStr("Streaming%1").arg(streamingIndex + 1), parent); + outputName = new OutputCell( + rowId, QTStr("Streaming%1").arg(streamingIndex + 1), filter->isStreamingUserEnabled(), ROW_OUTPUT_STREAMING, + nullptr, parent + ); break; case ROW_OUTPUT_RECORDING: - outputName = new RecordingOutputCell(rowId, QTStr("Recording"), filter->filterSource, parent); + outputName = new OutputCell( + rowId, QTStr("Recording"), filter->isRecordingUserEnabled(), ROW_OUTPUT_RECORDING, filter->filterSource, + parent + ); break; case ROW_OUTPUT_REPLAY_BUFFER: - outputName = new ReplayBufferOutputCell(rowId, QTStr("ReplayBuffer"), filter->filterSource, parent); + outputName = new OutputCell( + rowId, QTStr("ReplayBuffer"), filter->isReplayBufferUserEnabled(), ROW_OUTPUT_REPLAY_BUFFER, + filter->filterSource, parent + ); break; default: - outputName = new LabelCell(rowId, QTStr("None"), parent); + outputName = new OutputCell(rowId, QTStr("None"), true, ROW_OUTPUT_NONE, nullptr, parent); } + // Connect per-output toggle to filter's user-enabled flags + connect(outputName, &OutputCell::toggled, this, [this](bool checked) { + switch (outputType) { + case ROW_OUTPUT_STREAMING: + filter->setStreamingUserEnabled(checked); + break; + case ROW_OUTPUT_RECORDING: + filter->setRecordingUserEnabled(checked); + break; + case ROW_OUTPUT_REPLAY_BUFFER: + filter->setReplayBufferUserEnabled(checked); + break; + default: + break; + } + }); + droppedFrames = new LabelCell(rowId, "", parent); megabytesSent = new LabelCell(rowId, "", parent); bitrate = new LabelCell(rowId, "", parent); @@ -802,6 +829,21 @@ OutputTableRow::~OutputTableRow() // Imitate UI/window-basic-stats.cpp void OutputTableRow::update() { + // Sync per-output toggle checkbox with filter's user-enabled flags + switch (outputType) { + case ROW_OUTPUT_STREAMING: + outputName->setChecked(filter->isStreamingUserEnabled()); + break; + case ROW_OUTPUT_RECORDING: + outputName->setChecked(filter->isRecordingUserEnabled()); + break; + case ROW_OUTPUT_REPLAY_BUFFER: + outputName->setChecked(filter->isReplayBufferUserEnabled()); + break; + default: + break; + } + obs_output_t *output; switch (outputType) { @@ -1245,6 +1287,99 @@ void ParentCell::mousePressEvent(QMouseEvent *event) } } +//--- OutputCell class ---// + +OutputCell::OutputCell( + const QString &rowId, const QString &textValue, bool checked, RowOutputType _outputType, obs_source_t *_source, + QWidget *parent +) + : QWidget(parent), + _item(new OutputTableCellItem(rowId, "")), + outputType(_outputType), + source(_source) +{ + setMinimumHeight(27); + + outputToggleCheckbox = new QCheckBox(this); + outputToggleCheckbox->setProperty("visibilityCheckBox", true); // Until OBS 30 + outputToggleCheckbox->setProperty("class", "indicator-visibility"); // Since OBS 31 + outputToggleCheckbox->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + outputToggleCheckbox->setChecked(checked); + outputToggleCheckbox->setCursor(Qt::PointingHandCursor); + + connect(outputToggleCheckbox, &QCheckBox::clicked, this, [this](bool checked) { emit toggled(checked); }); + + name = new QLabel(this); + + // Recording and Replay Buffer labels are clickable links to open the output folder + if (outputType == ROW_OUTPUT_RECORDING || outputType == ROW_OUTPUT_REPLAY_BUFFER) { + name->setTextFormat(Qt::RichText); + name->setCursor(Qt::PointingHandCursor); + name->installEventFilter(this); + } + + auto checkboxLayout = new QHBoxLayout(); + checkboxLayout->setContentsMargins(0, 0, 0, 0); + checkboxLayout->addWidget(outputToggleCheckbox); + checkboxLayout->addWidget(name); + setLayout(checkboxLayout); + + setTextValue(textValue); +} + +OutputCell::~OutputCell() {} + +void OutputCell::setTextValue(const QString &value) +{ + if (outputType == ROW_OUTPUT_RECORDING || outputType == ROW_OUTPUT_REPLAY_BUFFER) { + name->setText(QString("%1").arg(value)); + } else { + name->setText(value); + } + _item->setData(Qt::UserRole, value); +} + +void OutputCell::setChecked(bool checked) +{ + outputToggleCheckbox->setChecked(checked); +} + +void OutputCell::openOutputFolder() +{ + if (!source) { + return; + } + OBSDataAutoRelease settings = obs_source_get_settings(source); + const char *path = nullptr; + + if (outputType == ROW_OUTPUT_RECORDING) { + path = obs_data_get_bool(settings, "use_profile_recording_path") + ? getProfileRecordingPath(obs_frontend_get_profile_config()) + : obs_data_get_string(settings, "path"); + } else if (outputType == ROW_OUTPUT_REPLAY_BUFFER) { + 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"); + } + + if (path) { + obs_log(LOG_DEBUG, "path=%s", path); + QDesktopServices::openUrl(QUrl::fromLocalFile(path)); + } +} + +bool OutputCell::eventFilter(QObject *obj, QEvent *event) +{ + if (obj == name && event->type() == QEvent::MouseButtonPress) { + auto mouseEvent = static_cast(event); + if (mouseEvent->button() == Qt::LeftButton) { + openOutputFolder(); + return true; + } + } + return QWidget::eventFilter(obj, event); +} + //--- RecordingOutputCell class ---// RecordingOutputCell::RecordingOutputCell( diff --git a/src/UI/output-status-dock.hpp b/src/UI/output-status-dock.hpp index 969cf72..50f593d 100644 --- a/src/UI/output-status-dock.hpp +++ b/src/UI/output-status-dock.hpp @@ -40,6 +40,13 @@ class QPushButton; class BranchOutputFilter; class OutputTableRow; +enum RowOutputType { + ROW_OUTPUT_NONE = 0, + ROW_OUTPUT_STREAMING = 1, + ROW_OUTPUT_RECORDING = 2, + ROW_OUTPUT_REPLAY_BUFFER = 3, +}; + struct BranchOutputFilterInfo { QString sourceName; QString sourceUuid; @@ -162,6 +169,36 @@ class ReplayBufferOutputCell : public LabelCell { void setTextValue(const QString &value); }; +class OutputCell : public QWidget { + Q_OBJECT + + OutputTableCellItem *_item; + QCheckBox *outputToggleCheckbox; + QLabel *name; + RowOutputType outputType; + obs_source_t *source; // filter source for folder opening (recording/replay buffer) + + void openOutputFolder(); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +signals: + void toggled(bool checked); + +public: + explicit OutputCell( + const QString &rowId, const QString &textValue, bool checked, RowOutputType outputType, + obs_source_t *source = nullptr, QWidget *parent = nullptr + ); + ~OutputCell(); + + void setTextValue(const QString &value); + void setChecked(bool checked); + inline bool isChecked() const { return outputToggleCheckbox->isChecked(); } + inline OutputTableCellItem *item() const { return _item; } +}; + class StatusCell : public QWidget { Q_OBJECT @@ -213,13 +250,6 @@ class StatusCell : public QWidget { inline OutputTableCellItem *item() const { return _item; } }; -enum RowOutputType { - ROW_OUTPUT_NONE = 0, - ROW_OUTPUT_STREAMING = 1, - ROW_OUTPUT_RECORDING = 2, - ROW_OUTPUT_REPLAY_BUFFER = 3, -}; - class BranchOutputStatusDock : public QFrame { Q_OBJECT @@ -310,11 +340,11 @@ class OutputTableRow : public QObject { BranchOutputFilter *filter; FilterCell *filterCell; ParentCell *parentCell; + OutputCell *outputName; StatusCell *status; LabelCell *droppedFrames; LabelCell *megabytesSent; LabelCell *bitrate; - LabelCell *outputName; RowOutputType outputType; size_t streamingIndex; diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index c2f797f..b3e127b 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -56,8 +56,12 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc activeSettingsRev(0), intervalTimer(nullptr), streamingStopping(false), + streamingIndividualStopping(false), blankingOutputActive(false), blankingAudioMuted(false), + streamingUserEnabled(obs_data_get_bool(settings, "streaming_output_enabled")), + recordingUserEnabled(obs_data_get_bool(settings, "recording_output_enabled")), + replayBufferUserEnabled(obs_data_get_bool(settings, "replay_buffer_output_enabled")), recordingOutput(nullptr), videoEncoder(nullptr), videoOutput(nullptr), @@ -89,6 +93,7 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc } pthread_mutex_init(&outputMutex, nullptr); + pthread_mutex_init(&audioMutex, nullptr); if (!strcmp(obs_data_get_last_json(settings), "{}")) { // Maybe initial creation @@ -135,6 +140,7 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc BranchOutputFilter::~BranchOutputFilter() { pthread_mutex_destroy(&outputMutex); + pthread_mutex_destroy(&audioMutex); } void BranchOutputFilter::registerHotkey() @@ -211,382 +217,378 @@ void BranchOutputFilter::registerHotkey() ); } -void BranchOutputFilter::startOutput(obs_data_t *settings) +bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) { - // Force release references - stopOutput(); - - pthread_mutex_lock(&outputMutex); - { - OBSMutexAutoUnlock locked(&outputMutex); + // Idempotent: if infrastructure already exists, return true + if (view) { + return true; + } - // Abort when obs initializing or filter disabled. - if (!obs_initialized() || !obs_source_enabled(filterSource) || countActiveStreamings() > 0 || recordingActive || - replayBufferActive) { - obs_log(LOG_ERROR, "%s: Ignore unavailable filter", qUtf8Printable(name)); - return; - } + // Abort when obs initializing or filter disabled. + if (!obs_initialized() || !obs_source_enabled(filterSource)) { + obs_log(LOG_ERROR, "%s: Ignore unavailable filter", qUtf8Printable(name)); + return false; + } - // Retrieve filter source - auto parent = obs_filter_get_parent(filterSource); - if (!parent) { - obs_log(LOG_ERROR, "%s: Filter source not found", qUtf8Printable(name)); - return; - } + // Retrieve filter source + auto parent = obs_filter_get_parent(filterSource); + if (!parent) { + obs_log(LOG_ERROR, "%s: Filter source not found", qUtf8Printable(name)); + return false; + } - // Ignore private sources - if (sourceIsPrivate(parent)) { - obs_log(LOG_ERROR, "%s: Ignore private source", qUtf8Printable(name)); - return; - } + // Ignore private sources + if (sourceIsPrivate(parent)) { + obs_log(LOG_ERROR, "%s: Ignore private source", qUtf8Printable(name)); + return false; + } - // Mandatory paramters - if (!isStreamingGroupEnabled(settings) && !isRecordingEnabled(settings) && !isReplayBufferEnabled(settings)) { - obs_log(LOG_ERROR, "%s: Nothing to do", qUtf8Printable(name)); - return; - } + // Mandatory paramters + if (!isStreamingGroupEnabled(settings) && !isRecordingEnabled(settings) && !isReplayBufferEnabled(settings)) { + obs_log(LOG_ERROR, "%s: Nothing to do", qUtf8Printable(name)); + return false; + } - bool blankWhenHidden = obs_data_get_bool(settings, "blank_when_not_visible"); - bool muteWhenHidden = obs_data_get_bool(settings, "mute_audio_when_blank"); + 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 - obs_log(LOG_ERROR, "%s: No video", qUtf8Printable(name)); - return; - } + obs_video_info ovi = {0}; + if (!obs_get_video_info(&ovi)) { + // Abort when no video situation + obs_log(LOG_ERROR, "%s: No video", qUtf8Printable(name)); + return false; + } - // 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"); + // 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); + // 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; + width = sourceWidth; + height = sourceHeight; - if (width == 0 || height == 0) { - // Default to canvas size - width = ovi.base_width; - height = ovi.base_height; - } + if (width == 0 || height == 0) { + // Default to canvas size + width = ovi.base_width; + height = ovi.base_height; + } - // Treat 0x0 crop result as source collapse — abort silently so that - // the interval timer can retry when the resolution becomes compatible. - auto crop = calculateCrop(width, height, settings); - if (!crop) { - // Abort when crop produces invalid resolution (0x0) - obs_log(LOG_DEBUG, "%s: Crop produces invalid resolution, treat as collapsed source", qUtf8Printable(name)); - return; - } + // Treat 0x0 crop result as source collapse — abort silently so that + // the interval timer can retry when the resolution becomes compatible. + auto crop = calculateCrop(width, height, settings); + if (!crop) { + // Abort when crop produces invalid resolution (0x0) + obs_log(LOG_DEBUG, "%s: Crop produces invalid resolution, treat as collapsed source", qUtf8Printable(name)); + return false; + } - determineOutputResolution(settings, &ovi, *crop); + determineOutputResolution(settings, &ovi, *crop); - if (ovi.output_width == 0 || ovi.output_height == 0 || ovi.fps_den == 0 || ovi.fps_num == 0) { - // Abort when invalid video parameters situation - obs_log(LOG_DEBUG, "%s: Invalid video spec", qUtf8Printable(name)); - return; - } + if (ovi.output_width == 0 || ovi.output_height == 0 || ovi.fps_den == 0 || ovi.fps_num == 0) { + // Abort when invalid video parameters situation + obs_log(LOG_DEBUG, "%s: Invalid video spec", qUtf8Printable(name)); + return false; + } - // Update active revision with stored settings. - activeSettingsRev = storedSettingsRev; + // Update active revision with stored settings. + activeSettingsRev = storedSettingsRev; - //--- Create service and open streaming output ---// - 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 ---// + if (useFilterInput) { + // Filter input mode: capture via texrender + proxy source + obs_view. + // The proxy source renders the captured texrender texture on the GPU. + // obs_view creates a video_t* registered in OBS's mix list, allowing + // GPU encoders (NVENC, QSV, AMF, etc.) to work directly. + filterVideoCapture = new FilterVideoCapture(filterSource, parent, width, height); + if (!filterVideoCapture->getProxySource()) { + obs_log(LOG_ERROR, "%s: Filter video capture creation failed", qUtf8Printable(name)); + delete filterVideoCapture; + filterVideoCapture = nullptr; + return false; } - //--- Open video output ---// - if (useFilterInput) { - // Filter input mode: capture via texrender + proxy source + obs_view. - // The proxy source renders the captured texrender texture on the GPU. - // obs_view creates a video_t* registered in OBS's mix list, allowing - // GPU encoders (NVENC, QSV, AMF, etc.) to work directly. - filterVideoCapture = new FilterVideoCapture(filterSource, parent, width, height); - if (!filterVideoCapture->getProxySource()) { - obs_log(LOG_ERROR, "%s: Filter video capture creation failed", qUtf8Printable(name)); - delete filterVideoCapture; - filterVideoCapture = nullptr; - return; - } - - if (crop->width != width || crop->height != height) { - filterVideoCapture->setCrop(*crop); - } - - view = obs_view_create(); - obs_view_set_source(view, 0, filterVideoCapture->getProxySource()); + if (crop->width != width || crop->height != height) { + filterVideoCapture->setCrop(*crop); + } - 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(); + view = obs_view_create(); + obs_view_set_source(view, 0, filterVideoCapture->getProxySource()); - if (crop->width != width || crop->height != height) { - cropScene = obs_scene_create_private("branch_output_crop"); - obs_sceneitem_t *item = obs_scene_add(cropScene, parent); + videoOutput = obs_view_add2(view, &ovi); + if (!videoOutput) { + obs_log(LOG_ERROR, "%s: Video output association failed", qUtf8Printable(name)); + delete filterVideoCapture; + filterVideoCapture = nullptr; + return false; + } + filterVideoCapture->setActive(true); + } else { + // Source output mode (default): use obs_view for the parent source + view = obs_view_create(); - struct obs_sceneitem_crop itemCrop; - itemCrop.left = (int)crop->left; - itemCrop.top = (int)crop->top; - itemCrop.right = (int)(width - crop->left - crop->width); - itemCrop.bottom = (int)(height - crop->top - crop->height); - obs_sceneitem_set_crop(item, &itemCrop); + if (crop->width != width || crop->height != height) { + cropScene = obs_scene_create_private("branch_output_crop"); + obs_sceneitem_t *item = obs_scene_add(cropScene, parent); - obs_view_set_source(view, 0, obs_scene_get_source(cropScene)); - } else { - obs_view_set_source(view, 0, parent); - } + struct obs_sceneitem_crop itemCrop; + itemCrop.left = (int)crop->left; + itemCrop.top = (int)crop->top; + itemCrop.right = (int)(width - crop->left - crop->width); + itemCrop.bottom = (int)(height - crop->top - crop->height); + obs_sceneitem_set_crop(item, &itemCrop); - videoOutput = obs_view_add2(view, &ovi); - if (!videoOutput) { - obs_log(LOG_ERROR, "%s: Video output association failed", qUtf8Printable(name)); - return; - } + obs_view_set_source(view, 0, obs_scene_get_source(cropScene)); + } else { + obs_view_set_source(view, 0, parent); } - //--- Open audio output(s) ---// - // Do not use memset - for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { - audios[i] = {0}; + videoOutput = obs_view_add2(view, &ovi); + if (!videoOutput) { + obs_log(LOG_ERROR, "%s: Video output association failed", qUtf8Printable(name)); + return false; } + } - obs_audio_info ai = {0}; - if (!obs_get_audio_info(&ai)) { - obs_log(LOG_ERROR, "%s: Failed to get audio info", qUtf8Printable(name)); - return; - } + //--- Open audio output(s) ---// + // Do not use memset + for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { + audios[i] = {0}; + } + + obs_audio_info ai = {0}; + if (!obs_get_audio_info(&ai)) { + obs_log(LOG_ERROR, "%s: Failed to get audio info", qUtf8Printable(name)); + return false; + } - if (obs_data_get_bool(settings, "custom_audio_source")) { - // Apply custom audio source - bool multitrack = obs_data_get_bool(settings, "multitrack_audio"); + if (obs_data_get_bool(settings, "custom_audio_source")) { + // Apply custom audio source + bool multitrack = obs_data_get_bool(settings, "multitrack_audio"); - for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { - auto audioContext = &audios[i]; - if (!multitrack && i > 0) { - // Signle track mode - break; - } + for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { + auto audioContext = &audios[i]; + if (!multitrack && i > 0) { + // Signle track mode + break; + } - size_t track = i + 1; - auto propNameFormat = getIndexedPropNameFormat(track, 1); + size_t track = i + 1; + auto propNameFormat = getIndexedPropNameFormat(track, 1); - auto audioDest = obs_data_get_string(settings, qUtf8Printable(propNameFormat.arg("audio_dest"))); - audioContext->streaming = !strcmp(audioDest, "streaming") || !strcmp(audioDest, "both"); - audioContext->recording = !strcmp(audioDest, "recording") || !strcmp(audioDest, "both"); + auto audioDest = obs_data_get_string(settings, qUtf8Printable(propNameFormat.arg("audio_dest"))); + audioContext->streaming = !strcmp(audioDest, "streaming") || !strcmp(audioDest, "both"); + audioContext->recording = !strcmp(audioDest, "recording") || !strcmp(audioDest, "both"); - auto audioSourceUuid = - obs_data_get_string(settings, qUtf8Printable(propNameFormat.arg("audio_source"))); - if (!strcmp(audioSourceUuid, "disabled")) { - // Disabled track - obs_log(LOG_INFO, "%s: Track %d is disabled", qUtf8Printable(name), track); - continue; + auto audioSourceUuid = obs_data_get_string(settings, qUtf8Printable(propNameFormat.arg("audio_source"))); + if (!strcmp(audioSourceUuid, "disabled")) { + // Disabled track + obs_log(LOG_INFO, "%s: Track %d is disabled", qUtf8Printable(name), track); + continue; - } else if (!strcmp(audioSourceUuid, "no_audio")) { - // Silence audio - obs_log(LOG_INFO, "%s: Use silence for track %d (%s)", qUtf8Printable(name), track, audioDest); + } else if (!strcmp(audioSourceUuid, "no_audio")) { + // Silence audio + obs_log(LOG_INFO, "%s: Use silence for track %d (%s)", qUtf8Printable(name), track, audioDest); - audioContext->capture = new AudioCapture( - "Silence", ai.samples_per_sec, ai.speakers, AudioCapture::silenceCapture, this - ); - audioContext->audio = audioContext->capture->getAudio(); - audioContext->name = audioContext->capture->getName(); + audioContext->capture = + new AudioCapture("Silence", ai.samples_per_sec, ai.speakers, AudioCapture::silenceCapture, this); + audioContext->audio = audioContext->capture->getAudio(); + audioContext->name = audioContext->capture->getName(); - } else if (!strcmp(audioSourceUuid, "master_track")) { - // Master audio - auto masterTrack = obs_data_get_int(settings, qUtf8Printable(propNameFormat.arg("audio_track"))); - if (masterTrack < 1 || masterTrack > MAX_AUDIO_MIXES) { - obs_log( - LOG_ERROR, "%s: Invalid master audio track No.%d for track %d", qUtf8Printable(name), - masterTrack, track - ); - return; - } + } else if (!strcmp(audioSourceUuid, "master_track")) { + // Master audio + auto masterTrack = obs_data_get_int(settings, qUtf8Printable(propNameFormat.arg("audio_track"))); + if (masterTrack < 1 || masterTrack > MAX_AUDIO_MIXES) { obs_log( - LOG_INFO, "%s: Use master audio track No.%d for track %d (%s)", qUtf8Printable(name), - masterTrack, track, audioDest + LOG_ERROR, "%s: Invalid master audio track No.%d for track %d", qUtf8Printable(name), + masterTrack, track ); + return false; + } + obs_log( + LOG_INFO, "%s: Use master audio track No.%d for track %d (%s)", qUtf8Printable(name), masterTrack, + track, audioDest + ); - 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 - obs_log(LOG_INFO, "%s: Use filter audio for track %d (%s)", qUtf8Printable(name), track, audioDest); - + if (blankWhenHidden && muteWhenHidden) { audioContext->capture = - new FilterAudioCapture(qUtf8Printable(name), ai.samples_per_sec, ai.speakers, this); + 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 { - // Specific source's audio - OBSSourceAutoRelease source = obs_get_source_by_uuid(audioSourceUuid); - if (!source) { - // Non-stopping error - obs_log( - LOG_WARNING, "%s: Ignore audio source for track %d (%s)", qUtf8Printable(name), track, - audioDest - ); - continue; - } + audioContext->mixIndex = masterTrack - 1; + audioContext->audio = obs_get_audio(); + audioContext->name = QTStr("MasterTrack%1").arg(masterTrack); + } - // Use custom audio source - obs_log( - LOG_INFO, "%s: Use %s audio for track %d", qUtf8Printable(name), obs_source_get_name(source), - track - ); + } else if (!strcmp(audioSourceUuid, "filter")) { + // Filter pipline's audio + obs_log(LOG_INFO, "%s: Use filter audio for track %d (%s)", qUtf8Printable(name), track, audioDest); - audioContext->capture = new SourceAudioCapture(source, ai.samples_per_sec, ai.speakers, this); - audioContext->audio = audioContext->capture->getAudio(); - audioContext->name = audioContext->capture->getName(); - } + audioContext->capture = + new FilterAudioCapture(qUtf8Printable(name), ai.samples_per_sec, ai.speakers, this); + audioContext->audio = audioContext->capture->getAudio(); + audioContext->name = audioContext->capture->getName(); - if (!audioContext->audio) { + } else { + // Specific source's audio + OBSSourceAutoRelease source = obs_get_source_by_uuid(audioSourceUuid); + if (!source) { + // Non-stopping error obs_log( - LOG_ERROR, "%s: Audio creation failed for track %d (%s)", qUtf8Printable(name), track, audioDest + LOG_WARNING, "%s: Ignore audio source for track %d (%s)", qUtf8Printable(name), track, audioDest ); - if (audioContext->capture) { - delete audioContext->capture; - audioContext->capture = nullptr; - } - return; + continue; } + + // Use custom audio source + obs_log( + LOG_INFO, "%s: Use %s audio for track %d", qUtf8Printable(name), obs_source_get_name(source), track + ); + + audioContext->capture = new SourceAudioCapture(source, ai.samples_per_sec, ai.speakers, this); + audioContext->audio = audioContext->capture->getAudio(); + audioContext->name = audioContext->capture->getName(); } - } else { - // Filter pipeline's audio - obs_log(LOG_INFO, "%s: Use filter audio for track 1", qUtf8Printable(name)); - auto audioContext = &audios[0]; - audioContext->capture = new FilterAudioCapture(qUtf8Printable(name), ai.samples_per_sec, ai.speakers, this); - audioContext->audio = audioContext->capture->getAudio(); - audioContext->streaming = true; - audioContext->recording = true; - audioContext->name = audioContext->capture->getName(); if (!audioContext->audio) { - obs_log(LOG_ERROR, "%s: Audio creation failed", qUtf8Printable(name)); - delete audioContext->capture; - audioContext->capture = nullptr; - return; + obs_log( + LOG_ERROR, "%s: Audio creation failed for track %d (%s)", qUtf8Printable(name), track, audioDest + ); + if (audioContext->capture) { + delete audioContext->capture; + audioContext->capture = nullptr; + } + return false; } } + } else { + // Filter pipeline's audio + obs_log(LOG_INFO, "%s: Use filter audio for track 1", qUtf8Printable(name)); + auto audioContext = &audios[0]; + audioContext->capture = new FilterAudioCapture(qUtf8Printable(name), ai.samples_per_sec, ai.speakers, this); + audioContext->audio = audioContext->capture->getAudio(); + audioContext->streaming = true; + audioContext->recording = true; + audioContext->name = audioContext->capture->getName(); + + if (!audioContext->audio) { + obs_log(LOG_ERROR, "%s: Audio creation failed", qUtf8Printable(name)); + delete audioContext->capture; + audioContext->capture = nullptr; + return false; + } + } - //--- Setup video encoder ---// - auto video_encoder_id = obs_data_get_string(settings, "video_encoder"); + //--- 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)); - return; - } + 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)); + return false; + } - // Apply frame rate divisor - auto fpsDivider = (uint32_t)obs_data_get_int(settings, "fps_divider"); - if (fpsDivider > 1) { - // Validate: fps_num must be evenly divisible by the divisor - // This ensures clean frame rate for both integer (60, 30, 24) and - // NTSC (60000/1001, 30000/1001) source rates. - bool valid = (ovi.fps_num % fpsDivider) == 0; - - if (!valid) { - // Fallback: search downward for nearest valid divisor - uint32_t originalDivider = fpsDivider; - fpsDivider = 1; - for (uint32_t d = originalDivider - 1; d > 1; d--) { - if ((ovi.fps_num % d) == 0) { - fpsDivider = d; - break; - } + // Apply frame rate divisor + auto fpsDivider = (uint32_t)obs_data_get_int(settings, "fps_divider"); + if (fpsDivider > 1) { + // Validate: fps_num must be evenly divisible by the divisor + // This ensures clean frame rate for both integer (60, 30, 24) and + // NTSC (60000/1001, 30000/1001) source rates. + bool valid = (ovi.fps_num % fpsDivider) == 0; + + if (!valid) { + // Fallback: search downward for nearest valid divisor + uint32_t originalDivider = fpsDivider; + fpsDivider = 1; + for (uint32_t d = originalDivider - 1; d > 1; d--) { + if ((ovi.fps_num % d) == 0) { + fpsDivider = d; + break; } - obs_log( - LOG_WARNING, "%s: Frame rate divider 1/%u invalid for %u/%u fps, falling back to 1/%u", - qUtf8Printable(name), originalDivider, ovi.fps_num, ovi.fps_den, fpsDivider - ); } + obs_log( + LOG_WARNING, "%s: Frame rate divider 1/%u invalid for %u/%u fps, falling back to 1/%u", + qUtf8Printable(name), originalDivider, ovi.fps_num, ovi.fps_den, fpsDivider + ); + } - if (fpsDivider > 1) { - if (!obs_encoder_set_frame_rate_divisor(videoEncoder, fpsDivider)) { - obs_log(LOG_WARNING, "%s: Failed to set frame rate divisor to %u", qUtf8Printable(name), fpsDivider); - } else { - obs_log(LOG_INFO, "%s: Frame rate divisor set to 1/%u", qUtf8Printable(name), fpsDivider); - } + if (fpsDivider > 1) { + if (!obs_encoder_set_frame_rate_divisor(videoEncoder, fpsDivider)) { + obs_log(LOG_WARNING, "%s: Failed to set frame rate divisor to %u", qUtf8Printable(name), fpsDivider); + } else { + obs_log(LOG_INFO, "%s: Frame rate divisor set to 1/%u", qUtf8Printable(name), fpsDivider); } } + } - obs_encoder_set_scaled_size(videoEncoder, 0, 0); // No scaling - obs_encoder_set_video(videoEncoder, videoOutput); - - //--- Setup audio encoder ---// - auto audio_encoder_id = obs_data_get_string(settings, "audio_encoder"); - auto audio_bitrate = obs_data_get_int(settings, "audio_bitrate"); - OBSDataAutoRelease audio_encoder_settings = obs_encoder_defaults(audio_encoder_id); - obs_data_set_int(audio_encoder_settings, "bitrate", audio_bitrate); + obs_encoder_set_scaled_size(videoEncoder, 0, 0); // No scaling + obs_encoder_set_video(videoEncoder, videoOutput); - for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { - auto audioContext = &audios[i]; - if (!audioContext->audio) { - continue; - } + //--- Setup audio encoder ---// + auto audio_encoder_id = obs_data_get_string(settings, "audio_encoder"); + auto audio_bitrate = obs_data_get_int(settings, "audio_bitrate"); + OBSDataAutoRelease audio_encoder_settings = obs_encoder_defaults(audio_encoder_id); + obs_data_set_int(audio_encoder_settings, "bitrate", audio_bitrate); - audioContext->encoder = obs_audio_encoder_create( - audio_encoder_id, qUtf8Printable(audioContext->name), audio_encoder_settings, audioContext->mixIndex, - nullptr - ); - if (!audioContext->encoder) { - obs_log(LOG_ERROR, "%s: Audio encoder creation failed for track %d", qUtf8Printable(name), i + 1); - return; - } - obs_encoder_set_audio(audioContext->encoder, audioContext->audio); + for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { + auto audioContext = &audios[i]; + if (!audioContext->audio) { + continue; } - if (blankWhenHidden) { - bool visibleInProgram = sourceVisibleInProgram(parent); - setBlankingActive(!visibleInProgram, muteWhenHidden, parent); + audioContext->encoder = obs_audio_encoder_create( + audio_encoder_id, qUtf8Printable(audioContext->name), audio_encoder_settings, audioContext->mixIndex, + nullptr + ); + if (!audioContext->encoder) { + obs_log(LOG_ERROR, "%s: Audio encoder creation failed for track %d", qUtf8Printable(name), i + 1); + return false; } + obs_encoder_set_audio(audioContext->encoder, audioContext->audio); + } - //--- Start recording output (if requested) ---// - if (isRecordingEnabled(settings)) { - recordingPending = (sourceWidth == 0 || sourceHeight == 0) && - obs_data_get_bool(settings, "suspend_recording_when_source_collapsed"); - if (!recordingPending) { - createAndStartRecordingOutput(settings); - } else { - obs_log(LOG_INFO, "%s: The recording output pending until source is uncollapsed", qUtf8Printable(name)); - } + if (blankWhenHidden) { + bool visibleInProgram = sourceVisibleInProgram(parent); + setBlankingActive(!visibleInProgram, muteWhenHidden, parent); + } + + return true; +} + +void BranchOutputFilter::startOutput(obs_data_t *settings) +{ + // Force release references + stopOutput(); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + + // Abort if outputs already active + if (countActiveStreamings() > 0 || recordingActive || replayBufferActive) { + obs_log(LOG_ERROR, "%s: Ignore unavailable filter", qUtf8Printable(name)); + return; } - //--- Start replay buffer (if requested) ---// - if (isReplayBufferEnabled(settings)) { - createAndStartReplayBuffer(settings); + if (!ensureInfrastructure(settings)) { + return; } - //--- Start streaming output (if requested) ---// - for (size_t i = 0; i < MAX_SERVICES; i++) { - startStreamingOutput(i); + if (isRecordingUserEnabled()) { + createAndStartRecordingOutputChecked(settings); + } + if (isReplayBufferUserEnabled()) { + createAndStartReplayBufferChecked(settings); + } + if (isStreamingUserEnabled()) { + createAndStartStreamingOutputs(settings); } } } @@ -676,18 +678,16 @@ void BranchOutputFilter::loadRecently(obs_data_t *settings) obs_log(LOG_INFO, "Recently settings loaded"); } -void BranchOutputFilter::stopOutput() +void BranchOutputFilter::releaseInfrastructureIfIdle() { - stopRecordingOutput(); - stopReplayBufferOutput(); + // Only release if all outputs are stopped + if (countActiveStreamings() > 0 || recordingActive || recordingPending || replayBufferActive) { + return; + } - pthread_mutex_lock(&outputMutex); + pthread_mutex_lock(&audioMutex); { - OBSMutexAutoUnlock locked(&outputMutex); - - for (size_t i = 0; i < MAX_SERVICES; i++) { - stopStreamingOutput(i); - } + OBSMutexAutoUnlock audioLocked(&audioMutex); for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { auto audioContext = &audios[i]; @@ -698,27 +698,44 @@ void BranchOutputFilter::stopOutput() audioContext->capture = nullptr; } } + } - videoEncoder = nullptr; + videoEncoder = nullptr; - if (filterVideoCapture) { - filterVideoCapture->setActive(false); - delete filterVideoCapture; - filterVideoCapture = nullptr; - } + if (filterVideoCapture) { + filterVideoCapture->setActive(false); + delete filterVideoCapture; + filterVideoCapture = nullptr; + } - cropScene = nullptr; + cropScene = nullptr; - if (view) { - obs_view_set_source(view, 0, nullptr); - obs_view_remove(view); + if (view) { + obs_view_set_source(view, 0, nullptr); + obs_view_remove(view); + } + + view = nullptr; + videoOutput = nullptr; + useFilterInput = false; + blankingOutputActive = false; + blankingAudioMuted = false; +} + +void BranchOutputFilter::stopOutput() +{ + stopRecordingOutput(); + stopReplayBufferOutput(); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + + for (size_t i = 0; i < MAX_SERVICES; i++) { + stopStreamingOutput(i); } - view = nullptr; - videoOutput = nullptr; - useFilterInput = false; - blankingOutputActive = false; - blankingAudioMuted = false; + releaseInfrastructureIfIdle(); } } @@ -744,6 +761,13 @@ void BranchOutputFilter::setAudioCapturesActive(bool active) } } +void BranchOutputFilter::saveCallback(obs_data_t *settings) +{ + obs_data_set_bool(settings, "streaming_output_enabled", streamingUserEnabled); + obs_data_set_bool(settings, "recording_output_enabled", recordingUserEnabled); + obs_data_set_bool(settings, "replay_buffer_output_enabled", replayBufferUserEnabled); +} + void BranchOutputFilter::setBlankingActive(bool active, bool muteAudio, obs_source_t *parent) { if (!parent) { @@ -813,6 +837,12 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } + // Handle streamingIndividualStopping (per-output graceful streaming stop) + if (streamingIndividualStopping) { + stopStreamingIndividual(); + return; + } + auto interlockType = statusDock ? statusDock->getInterlockType() : INTERLOCK_TYPE_ALWAYS_ON; auto sourceEnabled = obs_source_enabled(filterSource); auto streamingActive = countActiveStreamings() > 0; @@ -826,7 +856,6 @@ void BranchOutputFilter::onIntervalTimerTimeout() } if (sourceEnabled) { - // Clicked filter's "Eye" icon (Show) // Check interlock condition if (interlockType == INTERLOCK_TYPE_ALWAYS_OFF) { // Never start output @@ -855,6 +884,21 @@ void BranchOutputFilter::onIntervalTimerTimeout() restartOutput(); return; } + } else if (interlockType == INTERLOCK_TYPE_INDIVIDUAL) { + // Individual start: follow OBS frontend state per output type + // Only one start per tick to avoid crash from rapid state transitions + if (isStreamingUserEnabled() && obs_frontend_streaming_active()) { + startStreamingIndividual(); + return; + } + if (isRecordingUserEnabled() && obs_frontend_recording_active()) { + startRecordingIndividual(); + return; + } + if (isReplayBufferUserEnabled() && obs_frontend_replay_buffer_active()) { + startReplayBufferIndividual(); + return; + } } else { restartOutput(); return; @@ -910,6 +954,56 @@ void BranchOutputFilter::onIntervalTimerTimeout() onStopOutputGracefully(); return; } + } else if (interlockType == INTERLOCK_TYPE_INDIVIDUAL) { + // Individual stop: follow OBS frontend state per output type + // Only one stop per tick to avoid crash from rapid state transitions + if ((!obs_frontend_streaming_active() || !isStreamingUserEnabled()) && streamingActive) { + stopStreamingIndividual(); + return; + } + if ((!obs_frontend_recording_active() || !isRecordingUserEnabled()) && + (recordingActive || recordingPending)) { + stopRecordingIndividual(); + return; + } + if ((!obs_frontend_replay_buffer_active() || !isReplayBufferUserEnabled()) && replayBufferActive) { + stopReplayBufferIndividual(); + return; + } + // No stop needed — fall through to per-output toggle + monitoring + } + + // Per-output toggle check (all interlock modes including non-Individual) + // Users can explicitly enable/disable each output type (streaming, recording, + // replay buffer) via the status dock's output column checkbox, regardless of + // the current interlock mode. This allows, for example, stopping recording + // while keeping streaming active even in "Always ON" mode. + // Only one start/stop per tick to avoid crash from rapid state transitions. + if (!isStreamingUserEnabled() && streamingActive) { + stopStreamingIndividual(); + return; + } + if (!isRecordingUserEnabled() && (recordingActive || recordingPending)) { + stopRecordingIndividual(); + return; + } + if (!isReplayBufferUserEnabled() && replayBufferActive) { + stopReplayBufferIndividual(); + return; + } + + // Per-output toggle re-enable check (all interlock modes) + if (isStreamingUserEnabled() && !streamingActive && isStreamingGroupEnabled(settings)) { + startStreamingIndividual(); + return; + } + if (isRecordingUserEnabled() && !recordingActive && !recordingPending && isRecordingEnabled(settings)) { + startRecordingIndividual(); + return; + } + if (isReplayBufferUserEnabled() && !replayBufferActive && isReplayBufferEnabled(settings)) { + startReplayBufferIndividual(); + return; } if (activeSettingsRev < storedSettingsRev) { @@ -1078,24 +1172,7 @@ void BranchOutputFilter::onStopOutputGracefully() streamingStopping = true; - for (size_t i = 0; i < MAX_SERVICES; i++) { - if (streamings[i].output && streamings[i].active) { - if (streamings[i].stopping) { - if (reconnectAttemptingTimedOut(i)) { - // stop streaming safely - stopStreamingOutput(i); - } - } else if (obs_output_reconnecting(streamings[i].output)) { - // Waiting few seconds to avoid crash due to stoppoing during reconnecting - streamings[i].stopping = true; - } else { - // Stop immediately - stopStreamingOutput(i); - } - } - } - - if (countActiveStreamings() > 0) { + if (!stopStreamingOutputsGracefully()) { return; } @@ -1512,10 +1589,15 @@ obs_audio_data *BranchOutputFilter::audioFilterCallback(void *param, obs_audio_d { auto filter = static_cast(param); - for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { - auto audioContext = &filter->audios[i]; - if (audioContext->capture && !audioContext->capture->hasSource()) { - audioContext->capture->pushAudio(audioData); + pthread_mutex_lock(&filter->audioMutex); + { + OBSMutexAutoUnlock locked(&filter->audioMutex); + + for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { + auto audioContext = &filter->audios[i]; + if (audioContext->capture && !audioContext->capture->hasSource()) { + audioContext->capture->pushAudio(audioData); + } } } @@ -1562,6 +1644,10 @@ obs_source_info BranchOutputFilter::createFilterInfo() filter->destroyCallback(); }; + info.save = [](void *data, obs_data_t *settings) { + auto filter = static_cast(data); + filter->saveCallback(settings); + }; info.get_properties = [](void *data) -> obs_properties_t * { auto filter = static_cast(data); return filter->getProperties(); diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 03a3560..35863a0 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -48,6 +48,7 @@ class BranchOutputFilter : public QObject { INTERLOCK_TYPE_STREAMING_RECORDING, INTERLOCK_TYPE_VIRTUAL_CAM, INTERLOCK_TYPE_REPLAY_BUFFER, + INTERLOCK_TYPE_INDIVIDUAL, INTERLOCK_TYPE_ALWAYS_OFF = 9999, }; @@ -80,9 +81,15 @@ class BranchOutputFilter : public QObject { uint32_t activeSettingsRev; QTimer *intervalTimer; bool streamingStopping; + bool streamingIndividualStopping; bool blankingOutputActive; bool blankingAudioMuted; + // Per-output user intent flags (persisted via save callback) + bool streamingUserEnabled; + bool recordingUserEnabled; + bool replayBufferUserEnabled; + // Filter source (Do not use OBSSourceAutoRelease) obs_source_t *filterSource; @@ -106,6 +113,7 @@ class BranchOutputFilter : public QObject { FilterVideoCapture *filterVideoCapture; // Audio context + pthread_mutex_t audioMutex; // Protects audios[] capture pointers against audioFilterCallback BranchOutputAudioContext audios[MAX_AUDIO_MIXES]; // Recording context @@ -138,6 +146,21 @@ class BranchOutputFilter : public QObject { void startOutput(obs_data_t *settings); void stopOutput(); + bool ensureInfrastructure(obs_data_t *settings); + void releaseInfrastructureIfIdle(); + + // Internal helpers (caller must hold outputMutex) + void createAndStartStreamingOutputs(obs_data_t *settings); + void createAndStartRecordingOutputChecked(obs_data_t *settings); + void createAndStartReplayBufferChecked(obs_data_t *settings); + bool stopStreamingOutputsGracefully(); + + void startStreamingIndividual(); + void stopStreamingIndividual(); + void startRecordingIndividual(); + void stopRecordingIndividual(); + void startReplayBufferIndividual(); + void stopReplayBufferIndividual(); void getSourceResolution(uint32_t &outWidth, uint32_t &outHeight); void determineOutputResolution(obs_data_t *settings, obs_video_info *ovi, const CropRect &crop); void loadProfile(obs_data_t *settings); @@ -146,6 +169,15 @@ class BranchOutputFilter : public QObject { void registerHotkey(); void setBlankingActive(bool active, bool muteAudio, obs_source_t *parent); void setAudioCapturesActive(bool active); + void saveCallback(obs_data_t *settings); + + // Per-output user intent setters/getters + void setStreamingUserEnabled(bool enabled) { streamingUserEnabled = enabled; } + void setRecordingUserEnabled(bool enabled) { recordingUserEnabled = enabled; } + void setReplayBufferUserEnabled(bool enabled) { replayBufferUserEnabled = enabled; } + bool isStreamingUserEnabled() const { return streamingUserEnabled; } + bool isRecordingUserEnabled() const { return recordingUserEnabled; } + bool isReplayBufferUserEnabled() const { return replayBufferUserEnabled; } std::optional calculateCrop(uint32_t srcWidth, uint32_t srcHeight, obs_data_t *settings); QString applyFilenameFormatArgs(const QString &format, bool noSpace); diff --git a/src/plugin-replay-buffer.cpp b/src/plugin-replay-buffer.cpp index 0b0fd99..7bb0c92 100644 --- a/src/plugin-replay-buffer.cpp +++ b/src/plugin-replay-buffer.cpp @@ -278,3 +278,40 @@ void BranchOutputFilter::onSaveReplayBufferHotkeyPressed(void *data, obs_hotkey_ auto filter = static_cast(data); filter->saveReplayBuffer(); } + +// Internal helper: caller must hold outputMutex +void BranchOutputFilter::createAndStartReplayBufferChecked(obs_data_t *settings) +{ + if (!isReplayBufferEnabled(settings) || replayBufferActive) { + return; + } + + createAndStartReplayBuffer(settings); +} + +void BranchOutputFilter::startReplayBufferIndividual() +{ + OBSDataAutoRelease settings = obs_source_get_settings(filterSource); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + + if (!ensureInfrastructure(settings)) { + return; + } + + createAndStartReplayBufferChecked(settings); + } +} + +void BranchOutputFilter::stopReplayBufferIndividual() +{ + stopReplayBufferOutput(); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + releaseInfrastructureIfIdle(); + } +} diff --git a/src/plugin-stream-recording.cpp b/src/plugin-stream-recording.cpp index d71b7ba..91e255a 100644 --- a/src/plugin-stream-recording.cpp +++ b/src/plugin-stream-recording.cpp @@ -426,3 +426,50 @@ void BranchOutputFilter::onOverrideRecordingFilenameFormat(void *data, calldata_ filter->splitRecording(); } } + +// Internal helper: caller must hold outputMutex +void BranchOutputFilter::createAndStartRecordingOutputChecked(obs_data_t *settings) +{ + if (!isRecordingEnabled(settings) || recordingActive || recordingPending) { + return; + } + + uint32_t sourceWidth; + uint32_t sourceHeight; + getSourceResolution(sourceWidth, sourceHeight); + + recordingPending = (sourceWidth == 0 || sourceHeight == 0) && + obs_data_get_bool(settings, "suspend_recording_when_source_collapsed"); + if (!recordingPending) { + createAndStartRecordingOutput(settings); + } else { + obs_log(LOG_INFO, "%s: The recording output pending until source is uncollapsed", qUtf8Printable(name)); + } +} + +void BranchOutputFilter::startRecordingIndividual() +{ + OBSDataAutoRelease settings = obs_source_get_settings(filterSource); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + + if (!ensureInfrastructure(settings)) { + return; + } + + createAndStartRecordingOutputChecked(settings); + } +} + +void BranchOutputFilter::stopRecordingIndividual() +{ + stopRecordingOutput(); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + releaseInfrastructureIfIdle(); + } +} diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index 7508cd9..eb6b460 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -318,3 +318,83 @@ bool BranchOutputFilter::isStreamingEnabled(obs_data_t *settings, size_t index) auto propNameFormat = getIndexedPropNameFormat(index); return !!strlen(obs_data_get_string(settings, qUtf8Printable(propNameFormat.arg("server")))); } + +// Internal helper: caller must hold outputMutex +void BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) +{ + if (!isStreamingGroupEnabled(settings) || countActiveStreamings() > 0) { + return; + } + + auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); + for (size_t i = 0; i < MAX_SERVICES && i < serviceCount; i++) { + if (!streamings[i].output) { + streamings[i] = createSreamingOutput(settings, i); + } + } + + for (size_t i = 0; i < MAX_SERVICES; i++) { + startStreamingOutput(i); + } +} + +// Internal helper: caller must hold pluginMutex + outputMutex. +// Returns true if all streamings have stopped. +bool BranchOutputFilter::stopStreamingOutputsGracefully() +{ + for (size_t i = 0; i < MAX_SERVICES; i++) { + if (streamings[i].output && streamings[i].active) { + if (streamings[i].stopping) { + if (reconnectAttemptingTimedOut(i)) { + stopStreamingOutput(i); + } + } else if (obs_output_reconnecting(streamings[i].output)) { + streamings[i].stopping = true; + } else { + stopStreamingOutput(i); + } + } + } + return countActiveStreamings() == 0; +} + +void BranchOutputFilter::startStreamingIndividual() +{ + OBSDataAutoRelease settings = obs_source_get_settings(filterSource); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + + if (!ensureInfrastructure(settings)) { + return; + } + + createAndStartStreamingOutputs(settings); + } +} + +extern pthread_mutex_t pluginMutex; + +void BranchOutputFilter::stopStreamingIndividual() +{ + pthread_mutex_lock(&pluginMutex); + { + OBSMutexAutoUnlock pluginLocked(&pluginMutex); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); + + streamingIndividualStopping = true; + + if (!stopStreamingOutputsGracefully()) { + return; + } + + streamingIndividualStopping = false; + + releaseInfrastructureIfIdle(); + } + } +} diff --git a/src/plugin-ui.cpp b/src/plugin-ui.cpp index d3ceb8b..db7f808 100644 --- a/src/plugin-ui.cpp +++ b/src/plugin-ui.cpp @@ -215,6 +215,11 @@ void BranchOutputFilter::getDefaults(obs_data_t *defaults) obs_data_set_default_bool(defaults, "replay_buffer_no_space_filename", fileNameWithoutSpace); obs_data_set_default_string(defaults, "replay_buffer_format", recFormat); + // Per-output user intent defaults (not shown in UI, persisted in settings) + obs_data_set_default_bool(defaults, "streaming_output_enabled", true); + obs_data_set_default_bool(defaults, "recording_output_enabled", true); + obs_data_set_default_bool(defaults, "replay_buffer_output_enabled", true); + obs_log(LOG_INFO, "Default settings applied."); } From 05886cf940c345dd7dd7bdecbd2371c84ef85e9c Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 30 Mar 2026 12:34:59 +0900 Subject: [PATCH 02/18] fix: Address code review findings for individual start/stop feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix critical resource leak in ensureInfrastructure() on partial failure, add Individual mode additional output start logic when some outputs are already active, reset streamingIndividualStopping flag in stopOutput(), make user-enabled flags thread-safe with std::atomic, remove unused RecordingOutputCell/ReplayBufferOutputCell classes, hide checkbox for ROW_OUTPUT_NONE, improve lock contract documentation, and update Japanese translation from "インディビデュアル" to "個別連動". Co-Authored-By: Claude Opus 4.6 (1M context) --- data/locale/ja-JP.ini | 2 +- src/UI/output-status-dock.cpp | 81 +++------------------------ src/UI/output-status-dock.hpp | 34 ----------- src/plugin-main.cpp | 99 ++++++++++++++++++++++++++------- src/plugin-main.hpp | 26 +++++---- src/plugin-stream-recording.cpp | 8 +++ src/plugin-streaming.cpp | 6 +- 7 files changed, 115 insertions(+), 141 deletions(-) diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index 2b04611..eef6aac 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -37,7 +37,7 @@ Recording="録画" StreamingOrRecording="配信 or 録画" ReplayBuffer="リプレイバッファ" VirtualCam="仮想カメラ" -Individual="インディビデュアル" +Individual="個別連動" AlwaysOff="常時OFF" StreamRecording="配信録画" Path="保存先パス" diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index a4d777b..5fb4681 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -1294,6 +1294,8 @@ OutputCell::OutputCell( QWidget *parent ) : QWidget(parent), + // _item ownership is transferred to QTableWidget via setItem(); QTableWidget + // deletes it when the table is destroyed. Do not delete _item in ~OutputCell(). _item(new OutputTableCellItem(rowId, "")), outputType(_outputType), source(_source) @@ -1309,6 +1311,11 @@ OutputCell::OutputCell( connect(outputToggleCheckbox, &QCheckBox::clicked, this, [this](bool checked) { emit toggled(checked); }); + // ROW_OUTPUT_NONE has no associated output to toggle + if (outputType == ROW_OUTPUT_NONE) { + outputToggleCheckbox->setVisible(false); + } + name = new QLabel(this); // Recording and Replay Buffer labels are clickable links to open the output folder @@ -1380,80 +1387,6 @@ bool OutputCell::eventFilter(QObject *obj, QEvent *event) return QWidget::eventFilter(obj, event); } -//--- RecordingOutputCell class ---// - -RecordingOutputCell::RecordingOutputCell( - 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); -} - -RecordingOutputCell::~RecordingOutputCell() {} - -void RecordingOutputCell::setTextValue(const QString &textValue) -{ - // Markup as link - LabelCell::setValue(textValue); - LabelCell::setText(QString("%1").arg(textValue)); -} - -void RecordingOutputCell::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, "use_profile_recording_path") - ? getProfileRecordingPath(obs_frontend_get_profile_config()) - : obs_data_get_string(settings, "path"); - obs_log(LOG_DEBUG, "path=%s", path); - QDesktopServices::openUrl(QUrl::fromLocalFile(path)); - } -} - -//--- 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) diff --git a/src/UI/output-status-dock.hpp b/src/UI/output-status-dock.hpp index 50f593d..976acbe 100644 --- a/src/UI/output-status-dock.hpp +++ b/src/UI/output-status-dock.hpp @@ -135,40 +135,6 @@ class ParentCell : public LabelCell { void setTextValue(const QString &value); }; -class RecordingOutputCell : public LabelCell { - Q_OBJECT - - obs_source_t *source; - -protected: - void mousePressEvent(QMouseEvent *event) override; - -public: - explicit RecordingOutputCell( - const QString &rowId, const QString &textValue, obs_source_t *source, QWidget *parent = (QWidget *)nullptr - ); - ~RecordingOutputCell(); - - 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 OutputCell : public QWidget { Q_OBJECT diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index b3e127b..84300eb 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -217,9 +217,12 @@ void BranchOutputFilter::registerHotkey() ); } +// Caller must hold outputMutex. +// Idempotent: if infrastructure already exists, return true. +// On failure after partial resource creation, all resources are cleaned up +// so that the next call can retry from a clean state. bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) { - // Idempotent: if infrastructure already exists, return true if (view) { return true; } @@ -323,8 +326,12 @@ bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) videoOutput = obs_view_add2(view, &ovi); if (!videoOutput) { obs_log(LOG_ERROR, "%s: Video output association failed", qUtf8Printable(name)); - delete filterVideoCapture; - filterVideoCapture = nullptr; + // releaseInfrastructureIfIdle() safely handles partially-initialized state: + // - OBS RAII wrappers accept nullptr assignment + // - AudioCapture/filterVideoCapture pointers are nullptr-checked before delete + // - FilterVideoCapture::setActive(false) on a never-activated instance is safe + // (simply stores false to atomic bool) + releaseInfrastructureIfIdle(); return false; } filterVideoCapture->setActive(true); @@ -351,6 +358,7 @@ bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) videoOutput = obs_view_add2(view, &ovi); if (!videoOutput) { obs_log(LOG_ERROR, "%s: Video output association failed", qUtf8Printable(name)); + releaseInfrastructureIfIdle(); return false; } } @@ -364,6 +372,7 @@ bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) obs_audio_info ai = {0}; if (!obs_get_audio_info(&ai)) { obs_log(LOG_ERROR, "%s: Failed to get audio info", qUtf8Printable(name)); + releaseInfrastructureIfIdle(); return false; } @@ -408,6 +417,7 @@ bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) LOG_ERROR, "%s: Invalid master audio track No.%d for track %d", qUtf8Printable(name), masterTrack, track ); + releaseInfrastructureIfIdle(); return false; } obs_log( @@ -465,6 +475,7 @@ bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) delete audioContext->capture; audioContext->capture = nullptr; } + releaseInfrastructureIfIdle(); return false; } } @@ -482,6 +493,7 @@ bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) obs_log(LOG_ERROR, "%s: Audio creation failed", qUtf8Printable(name)); delete audioContext->capture; audioContext->capture = nullptr; + releaseInfrastructureIfIdle(); return false; } } @@ -492,6 +504,7 @@ bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) 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)); + releaseInfrastructureIfIdle(); return false; } @@ -549,6 +562,7 @@ bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) ); if (!audioContext->encoder) { obs_log(LOG_ERROR, "%s: Audio encoder creation failed for track %d", qUtf8Printable(name), i + 1); + releaseInfrastructureIfIdle(); return false; } obs_encoder_set_audio(audioContext->encoder, audioContext->audio); @@ -562,6 +576,10 @@ bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) return true; } +// Start all enabled outputs. User intent flags (streamingUserEnabled, etc.) +// are intentionally respected: if the user has disabled a specific output type +// via the status dock checkbox, it stays disabled even after a restartOutput() +// triggered by settings changes. void BranchOutputFilter::startOutput(obs_data_t *settings) { // Force release references @@ -678,6 +696,8 @@ void BranchOutputFilter::loadRecently(obs_data_t *settings) obs_log(LOG_INFO, "Recently settings loaded"); } +// Caller must hold outputMutex. +// Releases shared infrastructure (view, encoders, audio) if all outputs are idle. void BranchOutputFilter::releaseInfrastructureIfIdle() { // Only release if all outputs are stopped @@ -735,6 +755,11 @@ void BranchOutputFilter::stopOutput() stopStreamingOutput(i); } + // Reset individual stopping flag so it does not persist across + // full stop/restart cycles (e.g., filter eye-icon toggle while + // streamingIndividualStopping is still true). + streamingIndividualStopping = false; + releaseInfrastructureIfIdle(); } } @@ -885,17 +910,23 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } } else if (interlockType == INTERLOCK_TYPE_INDIVIDUAL) { - // Individual start: follow OBS frontend state per output type - // Only one start per tick to avoid crash from rapid state transitions - if (isStreamingUserEnabled() && obs_frontend_streaming_active()) { + // Individual start: follow OBS frontend state per output type. + // Check both the user toggle (dock checkbox) and the filter setting + // (whether the output type is configured) to avoid blocking subsequent + // outputs when an unconfigured type matches first. + OBSDataAutoRelease idleSettings = obs_source_get_settings(filterSource); + if (isStreamingUserEnabled() && obs_frontend_streaming_active() && + isStreamingGroupEnabled(idleSettings)) { startStreamingIndividual(); return; } - if (isRecordingUserEnabled() && obs_frontend_recording_active()) { + if (isRecordingUserEnabled() && obs_frontend_recording_active() && + isRecordingEnabled(idleSettings)) { startRecordingIndividual(); return; } - if (isReplayBufferUserEnabled() && obs_frontend_replay_buffer_active()) { + if (isReplayBufferUserEnabled() && obs_frontend_replay_buffer_active() && + isReplayBufferEnabled(idleSettings)) { startReplayBufferIndividual(); return; } @@ -971,6 +1002,23 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } // No stop needed — fall through to per-output toggle + monitoring + + // Individual start for additional outputs while some are already active + if (isStreamingUserEnabled() && obs_frontend_streaming_active() && !streamingActive && + isStreamingGroupEnabled(settings)) { + startStreamingIndividual(); + return; + } + if (isRecordingUserEnabled() && obs_frontend_recording_active() && !recordingActive && + !recordingPending && isRecordingEnabled(settings)) { + startRecordingIndividual(); + return; + } + if (isReplayBufferUserEnabled() && obs_frontend_replay_buffer_active() && !replayBufferActive && + isReplayBufferEnabled(settings)) { + startReplayBufferIndividual(); + return; + } } // Per-output toggle check (all interlock modes including non-Individual) @@ -992,18 +1040,29 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } - // Per-output toggle re-enable check (all interlock modes) - if (isStreamingUserEnabled() && !streamingActive && isStreamingGroupEnabled(settings)) { - startStreamingIndividual(); - return; - } - if (isRecordingUserEnabled() && !recordingActive && !recordingPending && isRecordingEnabled(settings)) { - startRecordingIndividual(); - return; - } - if (isReplayBufferUserEnabled() && !replayBufferActive && isReplayBufferEnabled(settings)) { - startReplayBufferIndividual(); - return; + // Per-output toggle re-enable check. + // Only re-enable an output if the current interlock condition is met. + // For ALWAYS_OFF, we never reach here (returned above). + // For INDIVIDUAL, per-output start is handled in the Individual block above. + // For other modes, re-enable only when the interlock condition is currently satisfied. + // Note: No explicit interlock recheck is needed here because this code is only + // reachable within the "outputs active" block, which means the interlock condition + // was already satisfied when outputs were started and has not been violated (the + // interlock stop checks above would have returned before reaching this point). + if (interlockType != INTERLOCK_TYPE_INDIVIDUAL) { + if (isStreamingUserEnabled() && !streamingActive && isStreamingGroupEnabled(settings)) { + startStreamingIndividual(); + return; + } + if (isRecordingUserEnabled() && !recordingActive && !recordingPending && + isRecordingEnabled(settings)) { + startRecordingIndividual(); + return; + } + if (isReplayBufferUserEnabled() && !replayBufferActive && isReplayBufferEnabled(settings)) { + startReplayBufferIndividual(); + return; + } } if (activeSettingsRev < storedSettingsRev) { diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 35863a0..4fc371e 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -25,6 +25,8 @@ with this program. If not, see #include #include +#include + #include #include "UI/output-status-dock.hpp" @@ -85,10 +87,12 @@ class BranchOutputFilter : public QObject { bool blankingOutputActive; bool blankingAudioMuted; - // Per-output user intent flags (persisted via save callback) - bool streamingUserEnabled; - bool recordingUserEnabled; - bool replayBufferUserEnabled; + // Per-output user intent flags (persisted via save callback). + // Atomic because setters are called from UI thread while saveCallback() + // may be called from OBS core on a different thread. + std::atomic streamingUserEnabled; + std::atomic recordingUserEnabled; + std::atomic replayBufferUserEnabled; // Filter source (Do not use OBSSourceAutoRelease) obs_source_t *filterSource; @@ -171,13 +175,13 @@ class BranchOutputFilter : public QObject { void setAudioCapturesActive(bool active); void saveCallback(obs_data_t *settings); - // Per-output user intent setters/getters - void setStreamingUserEnabled(bool enabled) { streamingUserEnabled = enabled; } - void setRecordingUserEnabled(bool enabled) { recordingUserEnabled = enabled; } - void setReplayBufferUserEnabled(bool enabled) { replayBufferUserEnabled = enabled; } - bool isStreamingUserEnabled() const { return streamingUserEnabled; } - bool isRecordingUserEnabled() const { return recordingUserEnabled; } - bool isReplayBufferUserEnabled() const { return replayBufferUserEnabled; } + // Per-output user intent setters/getters (thread-safe via std::atomic) + void setStreamingUserEnabled(bool enabled) { streamingUserEnabled.store(enabled, std::memory_order_relaxed); } + void setRecordingUserEnabled(bool enabled) { recordingUserEnabled.store(enabled, std::memory_order_relaxed); } + void setReplayBufferUserEnabled(bool enabled) { replayBufferUserEnabled.store(enabled, std::memory_order_relaxed); } + bool isStreamingUserEnabled() const { return streamingUserEnabled.load(std::memory_order_relaxed); } + bool isRecordingUserEnabled() const { return recordingUserEnabled.load(std::memory_order_relaxed); } + bool isReplayBufferUserEnabled() const { return replayBufferUserEnabled.load(std::memory_order_relaxed); } std::optional calculateCrop(uint32_t srcWidth, uint32_t srcHeight, obs_data_t *settings); QString applyFilenameFormatArgs(const QString &format, bool noSpace); diff --git a/src/plugin-stream-recording.cpp b/src/plugin-stream-recording.cpp index 91e255a..2225483 100644 --- a/src/plugin-stream-recording.cpp +++ b/src/plugin-stream-recording.cpp @@ -455,6 +455,9 @@ void BranchOutputFilter::startRecordingIndividual() { OBSMutexAutoUnlock locked(&outputMutex); + // ensureInfrastructure() may fail gracefully if the source is collapsed + // (calculateCrop returns nullopt). This is acceptable — the interval timer + // will retry on the next tick when the source becomes available. if (!ensureInfrastructure(settings)) { return; } @@ -465,6 +468,11 @@ void BranchOutputFilter::startRecordingIndividual() void BranchOutputFilter::stopRecordingIndividual() { + // stopRecordingOutput() clears recordingPending and recordingActive. + // If recording was in pending state (source collapsed), this simply clears the + // pending flag. releaseInfrastructureIfIdle() will then release shared resources + // if no other outputs are active. Re-enabling recording later will go through + // ensureInfrastructure() which will retry from a clean state. stopRecordingOutput(); pthread_mutex_lock(&outputMutex); diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index eb6b460..52c719c 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -319,7 +319,9 @@ bool BranchOutputFilter::isStreamingEnabled(obs_data_t *settings, size_t index) return !!strlen(obs_data_get_string(settings, qUtf8Printable(propNameFormat.arg("server")))); } -// Internal helper: caller must hold outputMutex +// Internal helper: caller must hold outputMutex. +// Note: stopStreamingOutput() sets streamings[i].output to nullptr, so stopped slots +// are always recreated with fresh settings via createSreamingOutput(). void BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) { if (!isStreamingGroupEnabled(settings) || countActiveStreamings() > 0) { @@ -339,7 +341,9 @@ void BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) } // Internal helper: caller must hold pluginMutex + outputMutex. +// Lock order: pluginMutex -> outputMutex (must be consistent with all callers). // Returns true if all streamings have stopped. +// Note: stopStreamingOutput() called within assumes outputMutex is already held. bool BranchOutputFilter::stopStreamingOutputsGracefully() { for (size_t i = 0; i < MAX_SERVICES; i++) { From 807478d1c8670a08c2c54a6fa863a1df3204fbdd Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 30 Mar 2026 12:56:14 +0900 Subject: [PATCH 03/18] fix: Prevent orphaned infrastructure when individual output fails to start - Change createAndStartStreamingOutputs, createAndStartRecordingOutputChecked, createAndStartReplayBufferChecked to return bool (true if output is active) - Release infrastructure in start*Individual() only when no output started - Return true when output is already active to avoid false release - Add filter setting check (isStreamingGroupEnabled, isRecordingEnabled, isReplayBufferEnabled) to Individual mode idle path to prevent unconfigured output types from blocking subsequent outputs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugin-main.hpp | 7 ++++--- src/plugin-replay-buffer.cpp | 15 +++++++++++---- src/plugin-stream-recording.cpp | 15 +++++++++++---- src/plugin-streaming.cpp | 15 +++++++++++---- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 4fc371e..d3559d0 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -154,9 +154,10 @@ class BranchOutputFilter : public QObject { void releaseInfrastructureIfIdle(); // Internal helpers (caller must hold outputMutex) - void createAndStartStreamingOutputs(obs_data_t *settings); - void createAndStartRecordingOutputChecked(obs_data_t *settings); - void createAndStartReplayBufferChecked(obs_data_t *settings); + // Returns true if any output was actually started. + bool createAndStartStreamingOutputs(obs_data_t *settings); + bool createAndStartRecordingOutputChecked(obs_data_t *settings); + bool createAndStartReplayBufferChecked(obs_data_t *settings); bool stopStreamingOutputsGracefully(); void startStreamingIndividual(); diff --git a/src/plugin-replay-buffer.cpp b/src/plugin-replay-buffer.cpp index 7bb0c92..553a1cb 100644 --- a/src/plugin-replay-buffer.cpp +++ b/src/plugin-replay-buffer.cpp @@ -280,13 +280,18 @@ void BranchOutputFilter::onSaveReplayBufferHotkeyPressed(void *data, obs_hotkey_ } // Internal helper: caller must hold outputMutex -void BranchOutputFilter::createAndStartReplayBufferChecked(obs_data_t *settings) +bool BranchOutputFilter::createAndStartReplayBufferChecked(obs_data_t *settings) { - if (!isReplayBufferEnabled(settings) || replayBufferActive) { - return; + if (!isReplayBufferEnabled(settings)) { + return false; + } + if (replayBufferActive) { + return true; } createAndStartReplayBuffer(settings); + + return replayBufferActive; } void BranchOutputFilter::startReplayBufferIndividual() @@ -301,7 +306,9 @@ void BranchOutputFilter::startReplayBufferIndividual() return; } - createAndStartReplayBufferChecked(settings); + if (!createAndStartReplayBufferChecked(settings)) { + releaseInfrastructureIfIdle(); + } } } diff --git a/src/plugin-stream-recording.cpp b/src/plugin-stream-recording.cpp index 2225483..b139e30 100644 --- a/src/plugin-stream-recording.cpp +++ b/src/plugin-stream-recording.cpp @@ -428,10 +428,13 @@ void BranchOutputFilter::onOverrideRecordingFilenameFormat(void *data, calldata_ } // Internal helper: caller must hold outputMutex -void BranchOutputFilter::createAndStartRecordingOutputChecked(obs_data_t *settings) +bool BranchOutputFilter::createAndStartRecordingOutputChecked(obs_data_t *settings) { - if (!isRecordingEnabled(settings) || recordingActive || recordingPending) { - return; + if (!isRecordingEnabled(settings)) { + return false; + } + if (recordingActive || recordingPending) { + return true; } uint32_t sourceWidth; @@ -445,6 +448,8 @@ void BranchOutputFilter::createAndStartRecordingOutputChecked(obs_data_t *settin } else { obs_log(LOG_INFO, "%s: The recording output pending until source is uncollapsed", qUtf8Printable(name)); } + + return recordingActive || recordingPending; } void BranchOutputFilter::startRecordingIndividual() @@ -462,7 +467,9 @@ void BranchOutputFilter::startRecordingIndividual() return; } - createAndStartRecordingOutputChecked(settings); + if (!createAndStartRecordingOutputChecked(settings)) { + releaseInfrastructureIfIdle(); + } } } diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index 52c719c..d9a6ff8 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -322,10 +322,13 @@ bool BranchOutputFilter::isStreamingEnabled(obs_data_t *settings, size_t index) // Internal helper: caller must hold outputMutex. // Note: stopStreamingOutput() sets streamings[i].output to nullptr, so stopped slots // are always recreated with fresh settings via createSreamingOutput(). -void BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) +bool BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) { - if (!isStreamingGroupEnabled(settings) || countActiveStreamings() > 0) { - return; + if (!isStreamingGroupEnabled(settings)) { + return false; + } + if (countActiveStreamings() > 0) { + return true; } auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); @@ -338,6 +341,8 @@ void BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) for (size_t i = 0; i < MAX_SERVICES; i++) { startStreamingOutput(i); } + + return countActiveStreamings() > 0; } // Internal helper: caller must hold pluginMutex + outputMutex. @@ -374,7 +379,9 @@ void BranchOutputFilter::startStreamingIndividual() return; } - createAndStartStreamingOutputs(settings); + if (!createAndStartStreamingOutputs(settings)) { + releaseInfrastructureIfIdle(); + } } } From 190af5faeaa21d3b742a1685145d530a09cc1709 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 30 Mar 2026 13:50:57 +0900 Subject: [PATCH 04/18] fix: clang-format error. --- src/plugin-main.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 84300eb..e10f496 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -920,8 +920,7 @@ void BranchOutputFilter::onIntervalTimerTimeout() startStreamingIndividual(); return; } - if (isRecordingUserEnabled() && obs_frontend_recording_active() && - isRecordingEnabled(idleSettings)) { + if (isRecordingUserEnabled() && obs_frontend_recording_active() && isRecordingEnabled(idleSettings)) { startRecordingIndividual(); return; } @@ -1054,8 +1053,7 @@ void BranchOutputFilter::onIntervalTimerTimeout() startStreamingIndividual(); return; } - if (isRecordingUserEnabled() && !recordingActive && !recordingPending && - isRecordingEnabled(settings)) { + if (isRecordingUserEnabled() && !recordingActive && !recordingPending && isRecordingEnabled(settings)) { startRecordingIndividual(); return; } From 35ce92e4784a3be906faa30f6489b2f8a423164c Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Mon, 30 Mar 2026 13:51:16 +0900 Subject: [PATCH 05/18] fix: Rename lambda parameter to avoid shadowing constructor argument Co-Authored-By: Claude Opus 4.6 (1M context) --- src/UI/output-status-dock.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 5fb4681..44adfa1 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -1309,7 +1309,7 @@ OutputCell::OutputCell( outputToggleCheckbox->setChecked(checked); outputToggleCheckbox->setCursor(Qt::PointingHandCursor); - connect(outputToggleCheckbox, &QCheckBox::clicked, this, [this](bool checked) { emit toggled(checked); }); + connect(outputToggleCheckbox, &QCheckBox::clicked, this, [this](bool value) { emit toggled(value); }); // ROW_OUTPUT_NONE has no associated output to toggle if (outputType == ROW_OUTPUT_NONE) { From 150d7fb4843a78a8f40f989afe848ea6ac3a7c36 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 4 Apr 2026 15:28:58 +0900 Subject: [PATCH 06/18] fix: Add tooltips for recording and replay buffer checkboxes in status dock Add RecordingToggleTooltip and ReplayBufferToggleTooltip locale keys to all 10 locale files and set tooltips on the corresponding checkboxes in the output status dock for consistency with streaming. Co-Authored-By: Claude Opus 4.6 (1M context) --- data/locale/ca-ES.ini | 3 +++ data/locale/de-DE.ini | 3 +++ data/locale/en-US.ini | 3 +++ data/locale/fr-FR.ini | 3 +++ data/locale/ja-JP.ini | 3 +++ data/locale/ko-KR.ini | 3 +++ data/locale/ro-RO.ini | 3 +++ data/locale/ru-RU.ini | 3 +++ data/locale/uk-UA.ini | 3 +++ data/locale/zh-CN.ini | 3 +++ src/UI/output-status-dock.cpp | 9 +++++++++ 11 files changed, 39 insertions(+) diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index 6e4926a..7cbc747 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -201,3 +201,6 @@ CropAbsolute.Width="Amplada" CropAbsolute.Height="Alçada" PreviewCropRect="Previsualitza el retall" PreviewCropRect.LongDescription="La previsualització s'amaga automàticament en tancar el diàleg de propietats. Aquesta configuració no es desa." +StreamingToggleTooltip="En activar o desactivar aquesta casella s'activen o desactiven totes les sortides de transmissió per a aquest filtre." +RecordingToggleTooltip="En activar o desactivar aquesta casella s'activa o desactiva la sortida de gravació per a aquest filtre." +ReplayBufferToggleTooltip="En activar o desactivar aquesta casella s'activa o desactiva la sortida de memòria intermèdia de reproducció per a aquest filtre." diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index d9c5975..112f58c 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -201,3 +201,6 @@ CropAbsolute.Width="Breite" CropAbsolute.Height="Höhe" PreviewCropRect="Zuschneidebereich Vorschau" PreviewCropRect.LongDescription="Die Vorschau wird beim Schließen des Eigenschaftendialogs automatisch ausgeblendet. Diese Einstellung wird nicht gespeichert." +StreamingToggleTooltip="Durch Umschalten dieses Kontrollkästchens werden alle Streaming-Ausgaben für diesen Filter aktiviert oder deaktiviert." +RecordingToggleTooltip="Durch Umschalten dieses Kontrollkästchens wird die Aufnahmeausgabe für diesen Filter aktiviert oder deaktiviert." +ReplayBufferToggleTooltip="Durch Umschalten dieses Kontrollkästchens wird die Wiedergabepuffer-Ausgabe für diesen Filter aktiviert oder deaktiviert." diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index ad78487..23e91eb 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -202,3 +202,6 @@ CropAbsolute.Width="Width" CropAbsolute.Height="Height" PreviewCropRect="Preview Cropping Rect" PreviewCropRect.LongDescription="The preview is automatically hidden when the properties dialog is closed. This setting is not saved." +StreamingToggleTooltip="Toggling this checkbox enables or disables all streaming outputs for this filter." +RecordingToggleTooltip="Toggling this checkbox enables or disables the recording output for this filter." +ReplayBufferToggleTooltip="Toggling this checkbox enables or disables the replay buffer output for this filter." diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index a5955ca..01aed61 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -201,3 +201,6 @@ CropAbsolute.Width="Largeur" CropAbsolute.Height="Hauteur" PreviewCropRect="Aperçu du recadrage" PreviewCropRect.LongDescription="L'aperçu est automatiquement masqué à la fermeture de la boîte de dialogue des propriétés. Ce paramètre n'est pas sauvegardé." +StreamingToggleTooltip="Cocher ou décocher cette case active ou désactive toutes les sorties de streaming pour ce filtre." +RecordingToggleTooltip="Cocher ou décocher cette case active ou désactive la sortie d'enregistrement pour ce filtre." +ReplayBufferToggleTooltip="Cocher ou décocher cette case active ou désactive la sortie du tampon de relecture pour ce filtre." diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index eef6aac..d3ebe1a 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -202,3 +202,6 @@ CropAbsolute.Width="幅" CropAbsolute.Height="高さ" PreviewCropRect="クロップ範囲をプレビュー" PreviewCropRect.LongDescription="プロパティを閉じると自動的に非表示になります。この設定は保存されません。" +StreamingToggleTooltip="このチェックボックスを切り替えると、このフィルターのすべての配信出力が有効または無効になります。" +RecordingToggleTooltip="このチェックボックスを切り替えると、このフィルターの録画出力が有効または無効になります。" +ReplayBufferToggleTooltip="このチェックボックスを切り替えると、このフィルターのリプレイバッファ出力が有効または無効になります。" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index 74cdb53..a5fb9d4 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -201,3 +201,6 @@ CropAbsolute.Width="너비" CropAbsolute.Height="높이" PreviewCropRect="자르기 범위 미리보기" PreviewCropRect.LongDescription="속성 대화 상자를 닫으면 미리보기가 자동으로 숨겨집니다. 이 설정은 저장되지 않습니다." +StreamingToggleTooltip="이 체크박스를 전환하면 이 필터의 모든 스트리밍 출력이 활성화 또는 비활성화됩니다." +RecordingToggleTooltip="이 체크박스를 전환하면 이 필터의 녹화 출력이 활성화 또는 비활성화됩니다." +ReplayBufferToggleTooltip="이 체크박스를 전환하면 이 필터의 다시보기 버퍼 출력이 활성화 또는 비활성화됩니다." diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index dba0adb..eea34ad 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -199,3 +199,6 @@ CropAbsolute.Width="Lățime" CropAbsolute.Height="Înălțime" PreviewCropRect="Previzualizare decupare" PreviewCropRect.LongDescription="Previzualizarea este ascunsă automat la închiderea dialogului de proprietăți. Această setare nu este salvată." +StreamingToggleTooltip="Activarea sau dezactivarea acestei casete activează sau dezactivează toate ieșirile de streaming pentru acest filtru." +RecordingToggleTooltip="Activarea sau dezactivarea acestei casete activează sau dezactivează ieșirea de înregistrare pentru acest filtru." +ReplayBufferToggleTooltip="Activarea sau dezactivarea acestei casete activează sau dezactivează ieșirea bufferului de reluare pentru acest filtru." diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index 5973f02..a921836 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -201,3 +201,6 @@ CropAbsolute.Width="Ширина" CropAbsolute.Height="Высота" PreviewCropRect="Предпросмотр обрезки" PreviewCropRect.LongDescription="Предпросмотр автоматически скрывается при закрытии диалога свойств. Эта настройка не сохраняется." +StreamingToggleTooltip="Переключение этого флажка включает или отключает все потоковые выходы для этого фильтра." +RecordingToggleTooltip="Переключение этого флажка включает или отключает выход записи для этого фильтра." +ReplayBufferToggleTooltip="Переключение этого флажка включает или отключает выход буфера воспроизведения для этого фильтра." diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index 2167290..d96bde9 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -201,3 +201,6 @@ CropAbsolute.Width="Ширина" CropAbsolute.Height="Висота" PreviewCropRect="Попередній перегляд обрізки" PreviewCropRect.LongDescription="Попередній перегляд автоматично приховується при закритті діалогу властивостей. Це налаштування не зберігається." +StreamingToggleTooltip="Перемикання цього прапорця вмикає або вимикає всі потокові виходи для цього фільтра." +RecordingToggleTooltip="Перемикання цього прапорця вмикає або вимикає вихід запису для цього фільтра." +ReplayBufferToggleTooltip="Перемикання цього прапорця вмикає або вимикає вихід буфера повтору для цього фільтра." diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index 938ec2e..931bf81 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -201,3 +201,6 @@ CropAbsolute.Width="宽度" CropAbsolute.Height="高度" PreviewCropRect="预览裁剪范围" PreviewCropRect.LongDescription="关闭属性对话框时预览会自动隐藏。此设置不会被保存。" +StreamingToggleTooltip="切换此复选框可启用或禁用此滤镜的所有推流输出。" +RecordingToggleTooltip="切换此复选框可启用或禁用此滤镜的录制输出。" +ReplayBufferToggleTooltip="切换此复选框可启用或禁用此滤镜的回放缓冲区输出。" diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 44adfa1..7f7fce9 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -731,18 +731,22 @@ OutputTableRow::OutputTableRow( rowId, QTStr("Streaming%1").arg(streamingIndex + 1), filter->isStreamingUserEnabled(), ROW_OUTPUT_STREAMING, nullptr, parent ); + // All streaming slots share a single toggle; toggling one affects all streaming outputs + outputName->setToolTip(QTStr("StreamingToggleTooltip")); break; case ROW_OUTPUT_RECORDING: outputName = new OutputCell( rowId, QTStr("Recording"), filter->isRecordingUserEnabled(), ROW_OUTPUT_RECORDING, filter->filterSource, parent ); + outputName->setToolTip(QTStr("RecordingToggleTooltip")); break; case ROW_OUTPUT_REPLAY_BUFFER: outputName = new OutputCell( rowId, QTStr("ReplayBuffer"), filter->isReplayBufferUserEnabled(), ROW_OUTPUT_REPLAY_BUFFER, filter->filterSource, parent ); + outputName->setToolTip(QTStr("ReplayBufferToggleTooltip")); break; default: outputName = new OutputCell(rowId, QTStr("None"), true, ROW_OUTPUT_NONE, nullptr, parent); @@ -1348,7 +1352,12 @@ void OutputCell::setTextValue(const QString &value) void OutputCell::setChecked(bool checked) { + // Block signals to prevent re-entrant toggled() emission from programmatic updates. + // Currently OutputCell::toggled is connected to QCheckBox::clicked (which is not emitted + // by setChecked), but blockSignals provides defense against future signal changes. + outputToggleCheckbox->blockSignals(true); outputToggleCheckbox->setChecked(checked); + outputToggleCheckbox->blockSignals(false); } void OutputCell::openOutputFolder() From 511c8e8e3b64d6a773fdc56a580b790d281950ef Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 4 Apr 2026 15:37:30 +0900 Subject: [PATCH 07/18] fix: Address code review findings for individual start/stop feature - Separate INDIVIDUAL stop conditions from user toggle (per-output checkbox) to clarify responsibility boundaries - Move extern pluginMutex declaration to plugin-main.hpp to eliminate duplicate declarations and type mismatch risk - Use getter methods in saveCallback for consistent memory_order_relaxed access on atomic user-intent flags - Fix stopStreamingOutputsGracefully() comment to accurately reflect that only outputMutex is required - Add audioMutex lock in setAudioCapturesActive() to prevent race condition with releaseInfrastructureIfIdle() - Release infrastructure only when all outputs fail to start in startOutput(), making the cleanup intent explicit - Add ensureInfrastructure() precondition to helper function doc comments Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugin-main.cpp | 44 +++++++++++++++++++++------------ src/plugin-main.hpp | 5 +++- src/plugin-replay-buffer.cpp | 4 ++- src/plugin-stream-recording.cpp | 4 ++- src/plugin-streaming.cpp | 7 +++--- 5 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index e10f496..95ecd2d 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -599,14 +599,20 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) return; } + bool anyStarted = false; if (isRecordingUserEnabled()) { - createAndStartRecordingOutputChecked(settings); + anyStarted |= createAndStartRecordingOutputChecked(settings); } if (isReplayBufferUserEnabled()) { - createAndStartReplayBufferChecked(settings); + anyStarted |= createAndStartReplayBufferChecked(settings); } if (isStreamingUserEnabled()) { - createAndStartStreamingOutputs(settings); + anyStarted |= createAndStartStreamingOutputs(settings); + } + + // Release infrastructure if all outputs failed to start + if (!anyStarted) { + releaseInfrastructureIfIdle(); } } } @@ -778,19 +784,24 @@ void BranchOutputFilter::restartOutput() 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); + pthread_mutex_lock(&audioMutex); + { + OBSMutexAutoUnlock locked(&audioMutex); + + for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { + auto audioContext = &audios[i]; + if (audioContext->capture) { + audioContext->capture->setActive(active); + } } } } void BranchOutputFilter::saveCallback(obs_data_t *settings) { - obs_data_set_bool(settings, "streaming_output_enabled", streamingUserEnabled); - obs_data_set_bool(settings, "recording_output_enabled", recordingUserEnabled); - obs_data_set_bool(settings, "replay_buffer_output_enabled", replayBufferUserEnabled); + obs_data_set_bool(settings, "streaming_output_enabled", isStreamingUserEnabled()); + obs_data_set_bool(settings, "recording_output_enabled", isRecordingUserEnabled()); + obs_data_set_bool(settings, "replay_buffer_output_enabled", isReplayBufferUserEnabled()); } void BranchOutputFilter::setBlankingActive(bool active, bool muteAudio, obs_source_t *parent) @@ -985,18 +996,19 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } } else if (interlockType == INTERLOCK_TYPE_INDIVIDUAL) { - // Individual stop: follow OBS frontend state per output type - // Only one stop per tick to avoid crash from rapid state transitions - if ((!obs_frontend_streaming_active() || !isStreamingUserEnabled()) && streamingActive) { + // Individual stop: follow OBS frontend state per output type. + // Only checks the OBS frontend state here; user toggle (per-output checkbox) + // is handled separately in the common per-output toggle block below. + // Only one stop per tick to avoid crash from rapid state transitions. + if (!obs_frontend_streaming_active() && streamingActive) { stopStreamingIndividual(); return; } - if ((!obs_frontend_recording_active() || !isRecordingUserEnabled()) && - (recordingActive || recordingPending)) { + if (!obs_frontend_recording_active() && (recordingActive || recordingPending)) { stopRecordingIndividual(); return; } - if ((!obs_frontend_replay_buffer_active() || !isReplayBufferUserEnabled()) && replayBufferActive) { + if (!obs_frontend_replay_buffer_active() && replayBufferActive) { stopReplayBufferIndividual(); return; } diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index d3559d0..7335b2a 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -37,6 +37,9 @@ with this program. If not, see #define MAX_SERVICES 8 +// Defined in plugin-main.cpp. Guards plugin-wide state shared across filter instances. +extern pthread_mutex_t pluginMutex; + class BranchOutputFilter : public QObject { Q_OBJECT @@ -153,7 +156,7 @@ class BranchOutputFilter : public QObject { bool ensureInfrastructure(obs_data_t *settings); void releaseInfrastructureIfIdle(); - // Internal helpers (caller must hold outputMutex) + // Internal helpers (caller must hold outputMutex, and must call ensureInfrastructure() first) // Returns true if any output was actually started. bool createAndStartStreamingOutputs(obs_data_t *settings); bool createAndStartRecordingOutputChecked(obs_data_t *settings); diff --git a/src/plugin-replay-buffer.cpp b/src/plugin-replay-buffer.cpp index 553a1cb..e226f22 100644 --- a/src/plugin-replay-buffer.cpp +++ b/src/plugin-replay-buffer.cpp @@ -279,7 +279,9 @@ void BranchOutputFilter::onSaveReplayBufferHotkeyPressed(void *data, obs_hotkey_ filter->saveReplayBuffer(); } -// Internal helper: caller must hold outputMutex +// Internal helper: caller must hold outputMutex. +// Caller must call ensureInfrastructure() before this function to set up +// the view, video/audio encoders, and related infrastructure. bool BranchOutputFilter::createAndStartReplayBufferChecked(obs_data_t *settings) { if (!isReplayBufferEnabled(settings)) { diff --git a/src/plugin-stream-recording.cpp b/src/plugin-stream-recording.cpp index b139e30..6013eb4 100644 --- a/src/plugin-stream-recording.cpp +++ b/src/plugin-stream-recording.cpp @@ -427,7 +427,9 @@ void BranchOutputFilter::onOverrideRecordingFilenameFormat(void *data, calldata_ } } -// Internal helper: caller must hold outputMutex +// Internal helper: caller must hold outputMutex. +// Caller must call ensureInfrastructure() before this function to set up +// the view, video/audio encoders, and related infrastructure. bool BranchOutputFilter::createAndStartRecordingOutputChecked(obs_data_t *settings) { if (!isRecordingEnabled(settings)) { diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index d9a6ff8..546b924 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -320,6 +320,8 @@ bool BranchOutputFilter::isStreamingEnabled(obs_data_t *settings, size_t index) } // Internal helper: caller must hold outputMutex. +// Caller must call ensureInfrastructure() before this function to set up +// the view, video/audio encoders, and related infrastructure. // Note: stopStreamingOutput() sets streamings[i].output to nullptr, so stopped slots // are always recreated with fresh settings via createSreamingOutput(). bool BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) @@ -345,8 +347,7 @@ bool BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) return countActiveStreamings() > 0; } -// Internal helper: caller must hold pluginMutex + outputMutex. -// Lock order: pluginMutex -> outputMutex (must be consistent with all callers). +// Internal helper: caller must hold outputMutex. // Returns true if all streamings have stopped. // Note: stopStreamingOutput() called within assumes outputMutex is already held. bool BranchOutputFilter::stopStreamingOutputsGracefully() @@ -385,8 +386,6 @@ void BranchOutputFilter::startStreamingIndividual() } } -extern pthread_mutex_t pluginMutex; - void BranchOutputFilter::stopStreamingIndividual() { pthread_mutex_lock(&pluginMutex); From d39370bfea9e6be5d4234552328cd14630e5b9e5 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sat, 4 Apr 2026 16:23:39 +0900 Subject: [PATCH 08/18] feat: Add per-stream individual start/stop control for streaming outputs Replace single streamingUserEnabled flag with per-stream array so each streaming slot can be independently toggled ON/OFF from the status dock. Previously all streaming checkboxes controlled a shared flag; now each checkbox controls its own slot with graceful stop support for reconnecting streams. Includes backward-compatible settings migration. Co-Authored-By: Claude Opus 4.6 (1M context) --- 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 | 9 ++-- src/plugin-main.cpp | 49 ++++++++++++++------ src/plugin-main.hpp | 29 ++++++++++-- src/plugin-streaming.cpp | 85 ++++++++++++++++++++++++++++++++++- src/plugin-ui.cpp | 4 ++ 15 files changed, 162 insertions(+), 34 deletions(-) diff --git a/data/locale/ca-ES.ini b/data/locale/ca-ES.ini index 7cbc747..7174c86 100644 --- a/data/locale/ca-ES.ini +++ b/data/locale/ca-ES.ini @@ -201,6 +201,6 @@ CropAbsolute.Width="Amplada" CropAbsolute.Height="Alçada" PreviewCropRect="Previsualitza el retall" PreviewCropRect.LongDescription="La previsualització s'amaga automàticament en tancar el diàleg de propietats. Aquesta configuració no es desa." -StreamingToggleTooltip="En activar o desactivar aquesta casella s'activen o desactiven totes les sortides de transmissió per a aquest filtre." +StreamingToggleTooltip="En activar o desactivar aquesta casella s'activa o desactiva aquesta sortida de transmissió." RecordingToggleTooltip="En activar o desactivar aquesta casella s'activa o desactiva la sortida de gravació per a aquest filtre." ReplayBufferToggleTooltip="En activar o desactivar aquesta casella s'activa o desactiva la sortida de memòria intermèdia de reproducció per a aquest filtre." diff --git a/data/locale/de-DE.ini b/data/locale/de-DE.ini index 112f58c..e347539 100644 --- a/data/locale/de-DE.ini +++ b/data/locale/de-DE.ini @@ -201,6 +201,6 @@ CropAbsolute.Width="Breite" CropAbsolute.Height="Höhe" PreviewCropRect="Zuschneidebereich Vorschau" PreviewCropRect.LongDescription="Die Vorschau wird beim Schließen des Eigenschaftendialogs automatisch ausgeblendet. Diese Einstellung wird nicht gespeichert." -StreamingToggleTooltip="Durch Umschalten dieses Kontrollkästchens werden alle Streaming-Ausgaben für diesen Filter aktiviert oder deaktiviert." +StreamingToggleTooltip="Durch Umschalten dieses Kontrollkästchens wird diese Streaming-Ausgabe aktiviert oder deaktiviert." RecordingToggleTooltip="Durch Umschalten dieses Kontrollkästchens wird die Aufnahmeausgabe für diesen Filter aktiviert oder deaktiviert." ReplayBufferToggleTooltip="Durch Umschalten dieses Kontrollkästchens wird die Wiedergabepuffer-Ausgabe für diesen Filter aktiviert oder deaktiviert." diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 23e91eb..cbf5cb4 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -202,6 +202,6 @@ CropAbsolute.Width="Width" CropAbsolute.Height="Height" PreviewCropRect="Preview Cropping Rect" PreviewCropRect.LongDescription="The preview is automatically hidden when the properties dialog is closed. This setting is not saved." -StreamingToggleTooltip="Toggling this checkbox enables or disables all streaming outputs for this filter." +StreamingToggleTooltip="Toggling this checkbox enables or disables this streaming output." RecordingToggleTooltip="Toggling this checkbox enables or disables the recording output for this filter." ReplayBufferToggleTooltip="Toggling this checkbox enables or disables the replay buffer output for this filter." diff --git a/data/locale/fr-FR.ini b/data/locale/fr-FR.ini index 01aed61..e63d8b7 100644 --- a/data/locale/fr-FR.ini +++ b/data/locale/fr-FR.ini @@ -201,6 +201,6 @@ CropAbsolute.Width="Largeur" CropAbsolute.Height="Hauteur" PreviewCropRect="Aperçu du recadrage" PreviewCropRect.LongDescription="L'aperçu est automatiquement masqué à la fermeture de la boîte de dialogue des propriétés. Ce paramètre n'est pas sauvegardé." -StreamingToggleTooltip="Cocher ou décocher cette case active ou désactive toutes les sorties de streaming pour ce filtre." +StreamingToggleTooltip="Cocher ou décocher cette case active ou désactive cette sortie de streaming." RecordingToggleTooltip="Cocher ou décocher cette case active ou désactive la sortie d'enregistrement pour ce filtre." ReplayBufferToggleTooltip="Cocher ou décocher cette case active ou désactive la sortie du tampon de relecture pour ce filtre." diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index d3ebe1a..8a0459d 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -202,6 +202,6 @@ CropAbsolute.Width="幅" CropAbsolute.Height="高さ" PreviewCropRect="クロップ範囲をプレビュー" PreviewCropRect.LongDescription="プロパティを閉じると自動的に非表示になります。この設定は保存されません。" -StreamingToggleTooltip="このチェックボックスを切り替えると、このフィルターのすべての配信出力が有効または無効になります。" +StreamingToggleTooltip="このチェックボックスを切り替えると、この配信出力が有効または無効になります。" RecordingToggleTooltip="このチェックボックスを切り替えると、このフィルターの録画出力が有効または無効になります。" ReplayBufferToggleTooltip="このチェックボックスを切り替えると、このフィルターのリプレイバッファ出力が有効または無効になります。" diff --git a/data/locale/ko-KR.ini b/data/locale/ko-KR.ini index a5fb9d4..71b9a0e 100644 --- a/data/locale/ko-KR.ini +++ b/data/locale/ko-KR.ini @@ -201,6 +201,6 @@ CropAbsolute.Width="너비" CropAbsolute.Height="높이" PreviewCropRect="자르기 범위 미리보기" PreviewCropRect.LongDescription="속성 대화 상자를 닫으면 미리보기가 자동으로 숨겨집니다. 이 설정은 저장되지 않습니다." -StreamingToggleTooltip="이 체크박스를 전환하면 이 필터의 모든 스트리밍 출력이 활성화 또는 비활성화됩니다." +StreamingToggleTooltip="이 체크박스를 전환하면 이 스트리밍 출력이 활성화 또는 비활성화됩니다." RecordingToggleTooltip="이 체크박스를 전환하면 이 필터의 녹화 출력이 활성화 또는 비활성화됩니다." ReplayBufferToggleTooltip="이 체크박스를 전환하면 이 필터의 다시보기 버퍼 출력이 활성화 또는 비활성화됩니다." diff --git a/data/locale/ro-RO.ini b/data/locale/ro-RO.ini index eea34ad..25f63d7 100644 --- a/data/locale/ro-RO.ini +++ b/data/locale/ro-RO.ini @@ -199,6 +199,6 @@ CropAbsolute.Width="Lățime" CropAbsolute.Height="Înălțime" PreviewCropRect="Previzualizare decupare" PreviewCropRect.LongDescription="Previzualizarea este ascunsă automat la închiderea dialogului de proprietăți. Această setare nu este salvată." -StreamingToggleTooltip="Activarea sau dezactivarea acestei casete activează sau dezactivează toate ieșirile de streaming pentru acest filtru." +StreamingToggleTooltip="Activarea sau dezactivarea acestei casete activează sau dezactivează această ieșire de streaming." RecordingToggleTooltip="Activarea sau dezactivarea acestei casete activează sau dezactivează ieșirea de înregistrare pentru acest filtru." ReplayBufferToggleTooltip="Activarea sau dezactivarea acestei casete activează sau dezactivează ieșirea bufferului de reluare pentru acest filtru." diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index a921836..9a786c2 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -201,6 +201,6 @@ CropAbsolute.Width="Ширина" CropAbsolute.Height="Высота" PreviewCropRect="Предпросмотр обрезки" PreviewCropRect.LongDescription="Предпросмотр автоматически скрывается при закрытии диалога свойств. Эта настройка не сохраняется." -StreamingToggleTooltip="Переключение этого флажка включает или отключает все потоковые выходы для этого фильтра." +StreamingToggleTooltip="Переключение этого флажка включает или отключает этот потоковый выход." RecordingToggleTooltip="Переключение этого флажка включает или отключает выход записи для этого фильтра." ReplayBufferToggleTooltip="Переключение этого флажка включает или отключает выход буфера воспроизведения для этого фильтра." diff --git a/data/locale/uk-UA.ini b/data/locale/uk-UA.ini index d96bde9..062268c 100644 --- a/data/locale/uk-UA.ini +++ b/data/locale/uk-UA.ini @@ -201,6 +201,6 @@ CropAbsolute.Width="Ширина" CropAbsolute.Height="Висота" PreviewCropRect="Попередній перегляд обрізки" PreviewCropRect.LongDescription="Попередній перегляд автоматично приховується при закритті діалогу властивостей. Це налаштування не зберігається." -StreamingToggleTooltip="Перемикання цього прапорця вмикає або вимикає всі потокові виходи для цього фільтра." +StreamingToggleTooltip="Перемикання цього прапорця вмикає або вимикає цей потоковий вихід." RecordingToggleTooltip="Перемикання цього прапорця вмикає або вимикає вихід запису для цього фільтра." ReplayBufferToggleTooltip="Перемикання цього прапорця вмикає або вимикає вихід буфера повтору для цього фільтра." diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index 931bf81..0a5cbca 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -201,6 +201,6 @@ CropAbsolute.Width="宽度" CropAbsolute.Height="高度" PreviewCropRect="预览裁剪范围" PreviewCropRect.LongDescription="关闭属性对话框时预览会自动隐藏。此设置不会被保存。" -StreamingToggleTooltip="切换此复选框可启用或禁用此滤镜的所有推流输出。" +StreamingToggleTooltip="切换此复选框可启用或禁用此推流输出。" RecordingToggleTooltip="切换此复选框可启用或禁用此滤镜的录制输出。" ReplayBufferToggleTooltip="切换此复选框可启用或禁用此滤镜的回放缓冲区输出。" diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index 7f7fce9..e04a6c1 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -728,10 +728,9 @@ OutputTableRow::OutputTableRow( switch (outputType) { case ROW_OUTPUT_STREAMING: outputName = new OutputCell( - rowId, QTStr("Streaming%1").arg(streamingIndex + 1), filter->isStreamingUserEnabled(), ROW_OUTPUT_STREAMING, - nullptr, parent + rowId, QTStr("Streaming%1").arg(streamingIndex + 1), filter->isStreamingUserEnabled(streamingIndex), + ROW_OUTPUT_STREAMING, nullptr, parent ); - // All streaming slots share a single toggle; toggling one affects all streaming outputs outputName->setToolTip(QTStr("StreamingToggleTooltip")); break; case ROW_OUTPUT_RECORDING: @@ -756,7 +755,7 @@ OutputTableRow::OutputTableRow( connect(outputName, &OutputCell::toggled, this, [this](bool checked) { switch (outputType) { case ROW_OUTPUT_STREAMING: - filter->setStreamingUserEnabled(checked); + filter->setStreamingUserEnabled(streamingIndex, checked); break; case ROW_OUTPUT_RECORDING: filter->setRecordingUserEnabled(checked); @@ -836,7 +835,7 @@ void OutputTableRow::update() // Sync per-output toggle checkbox with filter's user-enabled flags switch (outputType) { case ROW_OUTPUT_STREAMING: - outputName->setChecked(filter->isStreamingUserEnabled()); + outputName->setChecked(filter->isStreamingUserEnabled(streamingIndex)); break; case ROW_OUTPUT_RECORDING: outputName->setChecked(filter->isRecordingUserEnabled()); diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 95ecd2d..35e2398 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -59,7 +59,6 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc streamingIndividualStopping(false), blankingOutputActive(false), blankingAudioMuted(false), - streamingUserEnabled(obs_data_get_bool(settings, "streaming_output_enabled")), recordingUserEnabled(obs_data_get_bool(settings, "recording_output_enabled")), replayBufferUserEnabled(obs_data_get_bool(settings, "replay_buffer_output_enabled")), recordingOutput(nullptr), @@ -87,6 +86,17 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc streamings[i] = {0}; } + // Per-stream user-enabled flags (with backward compatibility migration) + bool legacyStreamingEnabled = obs_data_get_bool(settings, "streaming_output_enabled"); + for (size_t i = 0; i < MAX_SERVICES; i++) { + auto key = QString("streaming_output_enabled_%1").arg(i); + if (obs_data_has_user_value(settings, qUtf8Printable(key))) { + streamingUserEnabled[i].store(obs_data_get_bool(settings, qUtf8Printable(key)), std::memory_order_relaxed); + } else { + streamingUserEnabled[i].store(legacyStreamingEnabled, std::memory_order_relaxed); + } + } + // Do not use memset for (size_t i = 0; i < MAX_AUDIO_MIXES; i++) { audios[i] = {0}; @@ -606,7 +616,7 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) if (isReplayBufferUserEnabled()) { anyStarted |= createAndStartReplayBufferChecked(settings); } - if (isStreamingUserEnabled()) { + if (isAnyStreamingUserEnabled()) { anyStarted |= createAndStartStreamingOutputs(settings); } @@ -799,7 +809,11 @@ void BranchOutputFilter::setAudioCapturesActive(bool active) void BranchOutputFilter::saveCallback(obs_data_t *settings) { - obs_data_set_bool(settings, "streaming_output_enabled", isStreamingUserEnabled()); + for (size_t i = 0; i < MAX_SERVICES; i++) { + auto key = QString("streaming_output_enabled_%1").arg(i); + obs_data_set_bool(settings, qUtf8Printable(key), isStreamingUserEnabled(i)); + } + obs_data_set_bool(settings, "streaming_output_enabled", isAnyStreamingUserEnabled()); obs_data_set_bool(settings, "recording_output_enabled", isRecordingUserEnabled()); obs_data_set_bool(settings, "replay_buffer_output_enabled", isReplayBufferUserEnabled()); } @@ -926,7 +940,7 @@ void BranchOutputFilter::onIntervalTimerTimeout() // (whether the output type is configured) to avoid blocking subsequent // outputs when an unconfigured type matches first. OBSDataAutoRelease idleSettings = obs_source_get_settings(filterSource); - if (isStreamingUserEnabled() && obs_frontend_streaming_active() && + if (isAnyStreamingUserEnabled() && obs_frontend_streaming_active() && isStreamingGroupEnabled(idleSettings)) { startStreamingIndividual(); return; @@ -1015,10 +1029,12 @@ void BranchOutputFilter::onIntervalTimerTimeout() // No stop needed — fall through to per-output toggle + monitoring // Individual start for additional outputs while some are already active - if (isStreamingUserEnabled() && obs_frontend_streaming_active() && !streamingActive && - isStreamingGroupEnabled(settings)) { - startStreamingIndividual(); - return; + for (size_t i = 0; i < MAX_SERVICES; i++) { + if (isStreamingUserEnabled(i) && obs_frontend_streaming_active() && !streamings[i].active && + isStreamingEnabled(settings, i) && isStreamingGroupEnabled(settings)) { + startSingleStreamingIndividual(i); + return; // one start per tick + } } if (isRecordingUserEnabled() && obs_frontend_recording_active() && !recordingActive && !recordingPending && isRecordingEnabled(settings)) { @@ -1038,9 +1054,11 @@ void BranchOutputFilter::onIntervalTimerTimeout() // the current interlock mode. This allows, for example, stopping recording // while keeping streaming active even in "Always ON" mode. // Only one start/stop per tick to avoid crash from rapid state transitions. - if (!isStreamingUserEnabled() && streamingActive) { - stopStreamingIndividual(); - return; + for (size_t i = 0; i < MAX_SERVICES; i++) { + if (!isStreamingUserEnabled(i) && streamings[i].active) { + stopSingleStreamingIndividual(i); + return; // one stop per tick + } } if (!isRecordingUserEnabled() && (recordingActive || recordingPending)) { stopRecordingIndividual(); @@ -1061,9 +1079,12 @@ void BranchOutputFilter::onIntervalTimerTimeout() // was already satisfied when outputs were started and has not been violated (the // interlock stop checks above would have returned before reaching this point). if (interlockType != INTERLOCK_TYPE_INDIVIDUAL) { - if (isStreamingUserEnabled() && !streamingActive && isStreamingGroupEnabled(settings)) { - startStreamingIndividual(); - return; + for (size_t i = 0; i < MAX_SERVICES; i++) { + if (isStreamingUserEnabled(i) && !streamings[i].active && isStreamingEnabled(settings, i) && + isStreamingGroupEnabled(settings)) { + startSingleStreamingIndividual(i); + return; // one start per tick + } } if (isRecordingUserEnabled() && !recordingActive && !recordingPending && isRecordingEnabled(settings)) { startRecordingIndividual(); diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 7335b2a..859e8d1 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -93,7 +93,7 @@ class BranchOutputFilter : public QObject { // Per-output user intent flags (persisted via save callback). // Atomic because setters are called from UI thread while saveCallback() // may be called from OBS core on a different thread. - std::atomic streamingUserEnabled; + std::atomic streamingUserEnabled[MAX_SERVICES]; std::atomic recordingUserEnabled; std::atomic replayBufferUserEnabled; @@ -165,6 +165,8 @@ class BranchOutputFilter : public QObject { void startStreamingIndividual(); void stopStreamingIndividual(); + void startSingleStreamingIndividual(size_t index); + void stopSingleStreamingIndividual(size_t index); void startRecordingIndividual(); void stopRecordingIndividual(); void startReplayBufferIndividual(); @@ -180,10 +182,30 @@ class BranchOutputFilter : public QObject { void saveCallback(obs_data_t *settings); // Per-output user intent setters/getters (thread-safe via std::atomic) - void setStreamingUserEnabled(bool enabled) { streamingUserEnabled.store(enabled, std::memory_order_relaxed); } + void setStreamingUserEnabled(size_t index, bool enabled) + { + if (index < MAX_SERVICES) + streamingUserEnabled[index].store(enabled, std::memory_order_relaxed); + } + bool isStreamingUserEnabled(size_t index) const + { + return index < MAX_SERVICES ? streamingUserEnabled[index].load(std::memory_order_relaxed) : false; + } + bool isAnyStreamingUserEnabled() const + { + for (size_t i = 0; i < MAX_SERVICES; i++) { + if (streamingUserEnabled[i].load(std::memory_order_relaxed)) + return true; + } + return false; + } + void setAllStreamingUserEnabled(bool enabled) + { + for (size_t i = 0; i < MAX_SERVICES; i++) + streamingUserEnabled[i].store(enabled, std::memory_order_relaxed); + } void setRecordingUserEnabled(bool enabled) { recordingUserEnabled.store(enabled, std::memory_order_relaxed); } void setReplayBufferUserEnabled(bool enabled) { replayBufferUserEnabled.store(enabled, std::memory_order_relaxed); } - bool isStreamingUserEnabled() const { return streamingUserEnabled.load(std::memory_order_relaxed); } bool isRecordingUserEnabled() const { return recordingUserEnabled.load(std::memory_order_relaxed); } bool isReplayBufferUserEnabled() const { return replayBufferUserEnabled.load(std::memory_order_relaxed); } std::optional calculateCrop(uint32_t srcWidth, uint32_t srcHeight, obs_data_t *settings); @@ -203,6 +225,7 @@ class BranchOutputFilter : public QObject { bool hasEnabledStreamings(obs_data_t *settings); bool isStreamingGroupEnabled(obs_data_t *settings); bool isStreamingEnabled(obs_data_t *settings, size_t index = 0); + bool stopSingleStreamingOutputGracefully(size_t index); // Implemented in plugin-stream-recording.cpp obs_data_t *createRecordingSettings(obs_data_t *settings, bool createFolder = false); diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index 546b924..7ec1d37 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -335,13 +335,15 @@ bool BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); for (size_t i = 0; i < MAX_SERVICES && i < serviceCount; i++) { - if (!streamings[i].output) { + if (!streamings[i].output && isStreamingUserEnabled(i)) { streamings[i] = createSreamingOutput(settings, i); } } for (size_t i = 0; i < MAX_SERVICES; i++) { - startStreamingOutput(i); + if (isStreamingUserEnabled(i)) { + startStreamingOutput(i); + } } return countActiveStreamings() > 0; @@ -408,3 +410,82 @@ void BranchOutputFilter::stopStreamingIndividual() } } } + +void BranchOutputFilter::startSingleStreamingIndividual(size_t index) +{ + OBSDataAutoRelease settings = obs_source_get_settings(filterSource); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + + if (!ensureInfrastructure(settings)) { + return; + } + + if (!isStreamingGroupEnabled(settings) || !isStreamingEnabled(settings, index)) { + releaseInfrastructureIfIdle(); + return; + } + + if (streamings[index].active) { + return; + } + + if (!streamings[index].output) { + streamings[index] = createSreamingOutput(settings, index); + } + + startStreamingOutput(index); + + if (!streamings[index].active) { + releaseInfrastructureIfIdle(); + } + } +} + +void BranchOutputFilter::stopSingleStreamingIndividual(size_t index) +{ + pthread_mutex_lock(&pluginMutex); + { + OBSMutexAutoUnlock pluginLocked(&pluginMutex); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); + + if (!stopSingleStreamingOutputGracefully(index)) { + return; // reconnecting, will retry next tick + } + + releaseInfrastructureIfIdle(); + } + } +} + +// Internal helper: caller must hold outputMutex. +// Returns true if the stream at the given index has stopped. +// Returns false if the stream is still waiting for reconnect timeout. +bool BranchOutputFilter::stopSingleStreamingOutputGracefully(size_t index) +{ + if (index >= MAX_SERVICES) { + return true; + } + + if (streamings[index].output && streamings[index].active) { + if (streamings[index].stopping) { + if (reconnectAttemptingTimedOut(index)) { + stopStreamingOutput(index); + } else { + return false; + } + } else if (obs_output_reconnecting(streamings[index].output)) { + streamings[index].stopping = true; + return false; + } else { + stopStreamingOutput(index); + } + } + + return true; +} diff --git a/src/plugin-ui.cpp b/src/plugin-ui.cpp index db7f808..e027a4a 100644 --- a/src/plugin-ui.cpp +++ b/src/plugin-ui.cpp @@ -217,6 +217,10 @@ void BranchOutputFilter::getDefaults(obs_data_t *defaults) // Per-output user intent defaults (not shown in UI, persisted in settings) obs_data_set_default_bool(defaults, "streaming_output_enabled", true); + for (size_t i = 0; i < MAX_SERVICES; i++) { + auto key = QString("streaming_output_enabled_%1").arg(i); + obs_data_set_default_bool(defaults, qUtf8Printable(key), true); + } obs_data_set_default_bool(defaults, "recording_output_enabled", true); obs_data_set_default_bool(defaults, "replay_buffer_output_enabled", true); From e64b0fbbff980c9b1759330259c6dfc12e992039 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 5 Apr 2026 01:25:47 +0900 Subject: [PATCH 09/18] refactor: Improve individual start/stop with consistent locking, batching, and safety - Make locking consistent across all individual start/stop functions (pluginMutex -> outputMutex order) for streaming, recording, and replay buffer - Document lock ordering (pluginMutex -> outputMutex -> audioMutex) in plugin-main.hpp - Batch individual start/stop operations per tick instead of one-output-per-tick serialization, treating streaming slots as a single output group - Change individual start/stop functions to return bool indicating whether an operation was performed - Extract startEligibleStreamings lambda to deduplicate streaming start logic with streamingIndividualStopping guard - Move streamingIndividualStopping retry after per-output toggle stops so recording/replay buffer stops are not blocked during streaming graceful stop - Add checkbox update guard in OutputTableRow::update() to skip unnecessary setChecked() calls - Add null-check in createAndStartStreamingOutputs() start loop - Use OBSWeakSourceAutoRelease in OutputCell for lifetime safety Co-Authored-By: Claude Opus 4.6 (1M context) --- src/UI/output-status-dock.cpp | 17 ++- src/UI/output-status-dock.hpp | 2 +- src/plugin-main.cpp | 188 ++++++++++++++++++-------------- src/plugin-main.hpp | 26 +++-- src/plugin-replay-buffer.cpp | 41 ++++--- src/plugin-stream-recording.cpp | 57 ++++++---- src/plugin-streaming.cpp | 96 ++++++++-------- 7 files changed, 248 insertions(+), 179 deletions(-) diff --git a/src/UI/output-status-dock.cpp b/src/UI/output-status-dock.cpp index e04a6c1..8550c6a 100644 --- a/src/UI/output-status-dock.cpp +++ b/src/UI/output-status-dock.cpp @@ -832,20 +832,26 @@ OutputTableRow::~OutputTableRow() // Imitate UI/window-basic-stats.cpp void OutputTableRow::update() { - // Sync per-output toggle checkbox with filter's user-enabled flags + // Sync per-output toggle checkbox with filter's user-enabled flags. + // Only update when the state has actually changed to avoid unnecessary work + // and prevent potential UI glitch if the user is clicking the checkbox. + bool desiredChecked = false; switch (outputType) { case ROW_OUTPUT_STREAMING: - outputName->setChecked(filter->isStreamingUserEnabled(streamingIndex)); + desiredChecked = filter->isStreamingUserEnabled(streamingIndex); break; case ROW_OUTPUT_RECORDING: - outputName->setChecked(filter->isRecordingUserEnabled()); + desiredChecked = filter->isRecordingUserEnabled(); break; case ROW_OUTPUT_REPLAY_BUFFER: - outputName->setChecked(filter->isReplayBufferUserEnabled()); + desiredChecked = filter->isReplayBufferUserEnabled(); break; default: break; } + if (outputName->isChecked() != desiredChecked) { + outputName->setChecked(desiredChecked); + } obs_output_t *output; @@ -1301,7 +1307,7 @@ OutputCell::OutputCell( // deletes it when the table is destroyed. Do not delete _item in ~OutputCell(). _item(new OutputTableCellItem(rowId, "")), outputType(_outputType), - source(_source) + weakSource(obs_source_get_weak_source(_source)) { setMinimumHeight(27); @@ -1361,6 +1367,7 @@ void OutputCell::setChecked(bool checked) void OutputCell::openOutputFolder() { + OBSSourceAutoRelease source = obs_weak_source_get_source(weakSource); if (!source) { return; } diff --git a/src/UI/output-status-dock.hpp b/src/UI/output-status-dock.hpp index 976acbe..1d15092 100644 --- a/src/UI/output-status-dock.hpp +++ b/src/UI/output-status-dock.hpp @@ -142,7 +142,7 @@ class OutputCell : public QWidget { QCheckBox *outputToggleCheckbox; QLabel *name; RowOutputType outputType; - obs_source_t *source; // filter source for folder opening (recording/replay buffer) + OBSWeakSourceAutoRelease weakSource; // filter source for folder opening (recording/replay buffer) void openOutputFolder(); diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 35e2398..2c421bf 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -55,7 +55,7 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc storedSettingsRev(0), activeSettingsRev(0), intervalTimer(nullptr), - streamingStopping(false), + outputGracefullyStopping(false), streamingIndividualStopping(false), blankingOutputActive(false), blankingAudioMuted(false), @@ -882,14 +882,8 @@ void BranchOutputFilter::onIntervalTimerTimeout() return; } - if (streamingStopping) { - onStopOutputGracefully(); - return; - } - - // Handle streamingIndividualStopping (per-output graceful streaming stop) - if (streamingIndividualStopping) { - stopStreamingIndividual(); + if (outputGracefullyStopping) { + stopOutputGracefully(); return; } @@ -939,19 +933,20 @@ void BranchOutputFilter::onIntervalTimerTimeout() // Check both the user toggle (dock checkbox) and the filter setting // (whether the output type is configured) to avoid blocking subsequent // outputs when an unconfigured type matches first. - OBSDataAutoRelease idleSettings = obs_source_get_settings(filterSource); + OBSDataAutoRelease settings = obs_source_get_settings(filterSource); + bool anyStarted = false; if (isAnyStreamingUserEnabled() && obs_frontend_streaming_active() && - isStreamingGroupEnabled(idleSettings)) { - startStreamingIndividual(); - return; + isStreamingGroupEnabled(settings)) { + anyStarted |= startStreamingIndividual(); } - if (isRecordingUserEnabled() && obs_frontend_recording_active() && isRecordingEnabled(idleSettings)) { - startRecordingIndividual(); - return; + if (isRecordingUserEnabled() && obs_frontend_recording_active() && isRecordingEnabled(settings)) { + anyStarted |= startRecordingIndividual(); } if (isReplayBufferUserEnabled() && obs_frontend_replay_buffer_active() && - isReplayBufferEnabled(idleSettings)) { - startReplayBufferIndividual(); + isReplayBufferEnabled(settings)) { + anyStarted |= startReplayBufferIndividual(); + } + if (anyStarted) { return; } } else { @@ -971,80 +966,101 @@ void BranchOutputFilter::onIntervalTimerTimeout() } OBSDataAutoRelease settings = obs_source_get_settings(filterSource); + + // Start all eligible streaming slots as a single output group. + // Returns true if any slot was started. + // Guarded by streamingIndividualStopping to prevent starting slots while + // a graceful stop is still in progress. + auto startEligibleStreamings = [&]() -> bool { + if (streamingIndividualStopping) { + return false; + } + bool anyStarted = false; + for (size_t i = 0; i < MAX_SERVICES; i++) { + if (isStreamingUserEnabled(i) && !streamings[i].active && + isStreamingEnabled(settings, i) && isStreamingGroupEnabled(settings)) { + if (startSingleStreamingIndividual(i)) { + anyStarted = true; + } + } + } + return anyStarted; + }; 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_ALWAYS_OFF) { // Always OFF: Stop output immediately - onStopOutputGracefully(); + stopOutputGracefully(); return; } else if (interlockType == INTERLOCK_TYPE_STREAMING) { if (!obs_frontend_streaming_active()) { // Stop output when streaming is not active - onStopOutputGracefully(); + stopOutputGracefully(); return; } } else if (interlockType == INTERLOCK_TYPE_RECORDING) { if (!obs_frontend_recording_active()) { // Stop output when recording is not active - onStopOutputGracefully(); + stopOutputGracefully(); return; } } else if (interlockType == INTERLOCK_TYPE_STREAMING_RECORDING) { if (!obs_frontend_streaming_active() && !obs_frontend_recording_active()) { // Stop output when streaming and recording are not active - onStopOutputGracefully(); + stopOutputGracefully(); return; } } else if (interlockType == INTERLOCK_TYPE_VIRTUAL_CAM) { if (!obs_frontend_virtualcam_active()) { // Stop output when virtual cam is not active - onStopOutputGracefully(); + stopOutputGracefully(); return; } } else if (interlockType == INTERLOCK_TYPE_REPLAY_BUFFER) { if (!obs_frontend_replay_buffer_active()) { // Stop output when replay buffer is not active - onStopOutputGracefully(); + stopOutputGracefully(); return; } } else if (interlockType == INTERLOCK_TYPE_INDIVIDUAL) { // Individual stop: follow OBS frontend state per output type. // Only checks the OBS frontend state here; user toggle (per-output checkbox) // is handled separately in the common per-output toggle block below. - // Only one stop per tick to avoid crash from rapid state transitions. - if (!obs_frontend_streaming_active() && streamingActive) { - stopStreamingIndividual(); - return; - } - if (!obs_frontend_recording_active() && (recordingActive || recordingPending)) { - stopRecordingIndividual(); - return; - } - if (!obs_frontend_replay_buffer_active() && replayBufferActive) { - stopReplayBufferIndividual(); - return; + { + bool anyStopped = false; + if (!obs_frontend_streaming_active() && streamingActive) { + anyStopped |= stopStreamingIndividual(); + } + if (!obs_frontend_recording_active() && (recordingActive || recordingPending)) { + anyStopped |= stopRecordingIndividual(); + } + if (!obs_frontend_replay_buffer_active() && replayBufferActive) { + anyStopped |= stopReplayBufferIndividual(); + } + if (anyStopped) { + return; + } } - // No stop needed — fall through to per-output toggle + monitoring - // Individual start for additional outputs while some are already active - for (size_t i = 0; i < MAX_SERVICES; i++) { - if (isStreamingUserEnabled(i) && obs_frontend_streaming_active() && !streamings[i].active && - isStreamingEnabled(settings, i) && isStreamingGroupEnabled(settings)) { - startSingleStreamingIndividual(i); - return; // one start per tick + // Individual start for additional outputs while some are already active. + { + bool anyStarted = false; + if (obs_frontend_streaming_active()) { + anyStarted |= startEligibleStreamings(); + } + if (isRecordingUserEnabled() && obs_frontend_recording_active() && !recordingActive && + !recordingPending && isRecordingEnabled(settings)) { + anyStarted |= startRecordingIndividual(); + } + if (isReplayBufferUserEnabled() && obs_frontend_replay_buffer_active() && !replayBufferActive && + isReplayBufferEnabled(settings)) { + anyStarted |= startReplayBufferIndividual(); + } + if (anyStarted) { + return; } - } - if (isRecordingUserEnabled() && obs_frontend_recording_active() && !recordingActive && - !recordingPending && isRecordingEnabled(settings)) { - startRecordingIndividual(); - return; - } - if (isReplayBufferUserEnabled() && obs_frontend_replay_buffer_active() && !replayBufferActive && - isReplayBufferEnabled(settings)) { - startReplayBufferIndividual(); - return; } } @@ -1054,18 +1070,30 @@ void BranchOutputFilter::onIntervalTimerTimeout() // the current interlock mode. This allows, for example, stopping recording // while keeping streaming active even in "Always ON" mode. // Only one start/stop per tick to avoid crash from rapid state transitions. - for (size_t i = 0; i < MAX_SERVICES; i++) { - if (!isStreamingUserEnabled(i) && streamings[i].active) { - stopSingleStreamingIndividual(i); - return; // one stop per tick + // Streaming slots are treated as a single output group. + { + bool anyStopped = false; + for (size_t i = 0; i < MAX_SERVICES; i++) { + if (!isStreamingUserEnabled(i) && streamings[i].active) { + anyStopped |= stopSingleStreamingIndividual(i); + } + } + if (!isRecordingUserEnabled() && (recordingActive || recordingPending)) { + anyStopped |= stopRecordingIndividual(); + } + if (!isReplayBufferUserEnabled() && replayBufferActive) { + anyStopped |= stopReplayBufferIndividual(); + } + if (anyStopped) { + return; } } - if (!isRecordingUserEnabled() && (recordingActive || recordingPending)) { - stopRecordingIndividual(); - return; - } - if (!isReplayBufferUserEnabled() && replayBufferActive) { - stopReplayBufferIndividual(); + + // Retry graceful streaming stop if still in progress. + // Placed after per-output toggle stops so recording/replay buffer stops + // are not blocked during streaming graceful stop. + if (streamingIndividualStopping) { + stopStreamingIndividual(); return; } @@ -1079,19 +1107,15 @@ void BranchOutputFilter::onIntervalTimerTimeout() // was already satisfied when outputs were started and has not been violated (the // interlock stop checks above would have returned before reaching this point). if (interlockType != INTERLOCK_TYPE_INDIVIDUAL) { - for (size_t i = 0; i < MAX_SERVICES; i++) { - if (isStreamingUserEnabled(i) && !streamings[i].active && isStreamingEnabled(settings, i) && - isStreamingGroupEnabled(settings)) { - startSingleStreamingIndividual(i); - return; // one start per tick - } - } + bool anyStarted = false; + anyStarted |= startEligibleStreamings(); if (isRecordingUserEnabled() && !recordingActive && !recordingPending && isRecordingEnabled(settings)) { - startRecordingIndividual(); - return; + anyStarted |= startRecordingIndividual(); } if (isReplayBufferUserEnabled() && !replayBufferActive && isReplayBufferEnabled(settings)) { - startReplayBufferIndividual(); + anyStarted |= startReplayBufferIndividual(); + } + if (anyStarted) { return; } } @@ -1114,7 +1138,7 @@ void BranchOutputFilter::onIntervalTimerTimeout() if (!sourceInFrontend(parent)) { // Stop output when source had been removed - onStopOutputGracefully(); + stopOutputGracefully(); return; } @@ -1238,15 +1262,17 @@ void BranchOutputFilter::onIntervalTimerTimeout() } else { if (streamingActive || recordingActive || recordingPending || replayBufferActive) { // Clicked filter's "Eye" icon (Hide) - onStopOutputGracefully(); + stopOutputGracefully(); return; } } } } -void BranchOutputFilter::onStopOutputGracefully() +void BranchOutputFilter::stopOutputGracefully() { + outputGracefullyStopping = true; + // Stop recording and replay buffer immediately first stopRecordingOutput(); stopReplayBufferOutput(); @@ -1260,17 +1286,15 @@ void BranchOutputFilter::onStopOutputGracefully() { OBSMutexAutoUnlock outputLocked(&outputMutex); - streamingStopping = true; - - if (!stopStreamingOutputsGracefully()) { + if (!stopAllStreamingOutputsGracefully()) { return; } - - // All streaming has been stopped - streamingStopping = false; } } + // All streaming has been stopped + outputGracefullyStopping = false; + // Finalize termination stopOutput(); } diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 859e8d1..43fdd05 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -85,7 +85,7 @@ class BranchOutputFilter : public QObject { uint32_t storedSettingsRev; uint32_t activeSettingsRev; QTimer *intervalTimer; - bool streamingStopping; + bool outputGracefullyStopping; bool streamingIndividualStopping; bool blankingOutputActive; bool blankingAudioMuted; @@ -120,6 +120,8 @@ class BranchOutputFilter : public QObject { FilterVideoCapture *filterVideoCapture; // Audio context + // Lock ordering: always acquire in order pluginMutex -> outputMutex -> audioMutex. + // Never acquire a higher-order lock while holding a lower-order one. pthread_mutex_t audioMutex; // Protects audios[] capture pointers against audioFilterCallback BranchOutputAudioContext audios[MAX_AUDIO_MIXES]; @@ -161,21 +163,22 @@ class BranchOutputFilter : public QObject { bool createAndStartStreamingOutputs(obs_data_t *settings); bool createAndStartRecordingOutputChecked(obs_data_t *settings); bool createAndStartReplayBufferChecked(obs_data_t *settings); - bool stopStreamingOutputsGracefully(); - - void startStreamingIndividual(); - void stopStreamingIndividual(); - void startSingleStreamingIndividual(size_t index); - void stopSingleStreamingIndividual(size_t index); - void startRecordingIndividual(); - void stopRecordingIndividual(); - void startReplayBufferIndividual(); - void stopReplayBufferIndividual(); + bool stopAllStreamingOutputsGracefully(); + + bool startStreamingIndividual(); + bool stopStreamingIndividual(); + bool startSingleStreamingIndividual(size_t index); + bool stopSingleStreamingIndividual(size_t index); + bool startRecordingIndividual(); + bool stopRecordingIndividual(); + bool startReplayBufferIndividual(); + bool stopReplayBufferIndividual(); void getSourceResolution(uint32_t &outWidth, uint32_t &outHeight); void determineOutputResolution(obs_data_t *settings, obs_video_info *ovi, const CropRect &crop); void loadProfile(obs_data_t *settings); void loadRecently(obs_data_t *settings); void restartOutput(); + void stopOutputGracefully(); void registerHotkey(); void setBlankingActive(bool active, bool muteAudio, obs_source_t *parent); void setAudioCapturesActive(bool active); @@ -289,7 +292,6 @@ class BranchOutputFilter : public QObject { private slots: void onIntervalTimerTimeout(); void removeCallback(); - void onStopOutputGracefully(); public: explicit BranchOutputFilter(obs_data_t *settings, obs_source_t *source, QObject *parent = nullptr); diff --git a/src/plugin-replay-buffer.cpp b/src/plugin-replay-buffer.cpp index e226f22..0a8b70c 100644 --- a/src/plugin-replay-buffer.cpp +++ b/src/plugin-replay-buffer.cpp @@ -296,31 +296,46 @@ bool BranchOutputFilter::createAndStartReplayBufferChecked(obs_data_t *settings) return replayBufferActive; } -void BranchOutputFilter::startReplayBufferIndividual() +bool BranchOutputFilter::startReplayBufferIndividual() { OBSDataAutoRelease settings = obs_source_get_settings(filterSource); - pthread_mutex_lock(&outputMutex); + pthread_mutex_lock(&pluginMutex); { - OBSMutexAutoUnlock locked(&outputMutex); + OBSMutexAutoUnlock pluginLocked(&pluginMutex); - if (!ensureInfrastructure(settings)) { - return; - } + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); - if (!createAndStartReplayBufferChecked(settings)) { - releaseInfrastructureIfIdle(); + if (!ensureInfrastructure(settings)) { + return false; + } + + bool started = createAndStartReplayBufferChecked(settings); + if (!started) { + releaseInfrastructureIfIdle(); + } + return started; } } } -void BranchOutputFilter::stopReplayBufferIndividual() +bool BranchOutputFilter::stopReplayBufferIndividual() { - stopReplayBufferOutput(); + bool wasActive = replayBufferActive; - pthread_mutex_lock(&outputMutex); + pthread_mutex_lock(&pluginMutex); { - OBSMutexAutoUnlock locked(&outputMutex); - releaseInfrastructureIfIdle(); + OBSMutexAutoUnlock pluginLocked(&pluginMutex); + + stopReplayBufferOutput(); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); + releaseInfrastructureIfIdle(); + } } + return wasActive; } diff --git a/src/plugin-stream-recording.cpp b/src/plugin-stream-recording.cpp index 6013eb4..a99cbf2 100644 --- a/src/plugin-stream-recording.cpp +++ b/src/plugin-stream-recording.cpp @@ -454,39 +454,54 @@ bool BranchOutputFilter::createAndStartRecordingOutputChecked(obs_data_t *settin return recordingActive || recordingPending; } -void BranchOutputFilter::startRecordingIndividual() +bool BranchOutputFilter::startRecordingIndividual() { OBSDataAutoRelease settings = obs_source_get_settings(filterSource); - pthread_mutex_lock(&outputMutex); + pthread_mutex_lock(&pluginMutex); { - OBSMutexAutoUnlock locked(&outputMutex); + OBSMutexAutoUnlock pluginLocked(&pluginMutex); - // ensureInfrastructure() may fail gracefully if the source is collapsed - // (calculateCrop returns nullopt). This is acceptable — the interval timer - // will retry on the next tick when the source becomes available. - if (!ensureInfrastructure(settings)) { - return; - } + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); - if (!createAndStartRecordingOutputChecked(settings)) { - releaseInfrastructureIfIdle(); + // ensureInfrastructure() may fail gracefully if the source is collapsed + // (calculateCrop returns nullopt). This is acceptable — the interval timer + // will retry on the next tick when the source becomes available. + if (!ensureInfrastructure(settings)) { + return false; + } + + bool started = createAndStartRecordingOutputChecked(settings); + if (!started) { + releaseInfrastructureIfIdle(); + } + return started; } } } -void BranchOutputFilter::stopRecordingIndividual() +bool BranchOutputFilter::stopRecordingIndividual() { - // stopRecordingOutput() clears recordingPending and recordingActive. - // If recording was in pending state (source collapsed), this simply clears the - // pending flag. releaseInfrastructureIfIdle() will then release shared resources - // if no other outputs are active. Re-enabling recording later will go through - // ensureInfrastructure() which will retry from a clean state. - stopRecordingOutput(); + bool wasActive = recordingActive || recordingPending; - pthread_mutex_lock(&outputMutex); + pthread_mutex_lock(&pluginMutex); { - OBSMutexAutoUnlock locked(&outputMutex); - releaseInfrastructureIfIdle(); + OBSMutexAutoUnlock pluginLocked(&pluginMutex); + + // stopRecordingOutput() clears recordingPending and recordingActive. + // If recording was in pending state (source collapsed), this simply clears the + // pending flag. releaseInfrastructureIfIdle() will then release shared resources + // if no other outputs are active. Re-enabling recording later will go through + // ensureInfrastructure() which will retry from a clean state. + stopRecordingOutput(); + + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); + releaseInfrastructureIfIdle(); + } } + return wasActive; } diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index 7ec1d37..d199488 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -341,7 +341,7 @@ bool BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) } for (size_t i = 0; i < MAX_SERVICES; i++) { - if (isStreamingUserEnabled(i)) { + if (isStreamingUserEnabled(i) && streamings[i].output) { startStreamingOutput(i); } } @@ -352,43 +352,40 @@ bool BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) // Internal helper: caller must hold outputMutex. // Returns true if all streamings have stopped. // Note: stopStreamingOutput() called within assumes outputMutex is already held. -bool BranchOutputFilter::stopStreamingOutputsGracefully() +bool BranchOutputFilter::stopAllStreamingOutputsGracefully() { for (size_t i = 0; i < MAX_SERVICES; i++) { - if (streamings[i].output && streamings[i].active) { - if (streamings[i].stopping) { - if (reconnectAttemptingTimedOut(i)) { - stopStreamingOutput(i); - } - } else if (obs_output_reconnecting(streamings[i].output)) { - streamings[i].stopping = true; - } else { - stopStreamingOutput(i); - } - } + stopSingleStreamingOutputGracefully(i); } return countActiveStreamings() == 0; } -void BranchOutputFilter::startStreamingIndividual() +bool BranchOutputFilter::startStreamingIndividual() { OBSDataAutoRelease settings = obs_source_get_settings(filterSource); - pthread_mutex_lock(&outputMutex); + pthread_mutex_lock(&pluginMutex); { - OBSMutexAutoUnlock locked(&outputMutex); + OBSMutexAutoUnlock pluginLocked(&pluginMutex); - if (!ensureInfrastructure(settings)) { - return; - } + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); - if (!createAndStartStreamingOutputs(settings)) { - releaseInfrastructureIfIdle(); + if (!ensureInfrastructure(settings)) { + return false; + } + + if (!createAndStartStreamingOutputs(settings)) { + releaseInfrastructureIfIdle(); + return false; + } } } + return true; } -void BranchOutputFilter::stopStreamingIndividual() +bool BranchOutputFilter::stopStreamingIndividual() { pthread_mutex_lock(&pluginMutex); { @@ -400,8 +397,8 @@ void BranchOutputFilter::stopStreamingIndividual() streamingIndividualStopping = true; - if (!stopStreamingOutputsGracefully()) { - return; + if (!stopAllStreamingOutputsGracefully()) { + return true; } streamingIndividualStopping = false; @@ -409,42 +406,50 @@ void BranchOutputFilter::stopStreamingIndividual() releaseInfrastructureIfIdle(); } } + return true; } -void BranchOutputFilter::startSingleStreamingIndividual(size_t index) +bool BranchOutputFilter::startSingleStreamingIndividual(size_t index) { OBSDataAutoRelease settings = obs_source_get_settings(filterSource); - pthread_mutex_lock(&outputMutex); + pthread_mutex_lock(&pluginMutex); { - OBSMutexAutoUnlock locked(&outputMutex); + OBSMutexAutoUnlock pluginLocked(&pluginMutex); - if (!ensureInfrastructure(settings)) { - return; - } + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock outputLocked(&outputMutex); - if (!isStreamingGroupEnabled(settings) || !isStreamingEnabled(settings, index)) { - releaseInfrastructureIfIdle(); - return; - } + if (!ensureInfrastructure(settings)) { + return false; + } - if (streamings[index].active) { - return; - } + if (!isStreamingGroupEnabled(settings) || !isStreamingEnabled(settings, index)) { + releaseInfrastructureIfIdle(); + return false; + } - if (!streamings[index].output) { - streamings[index] = createSreamingOutput(settings, index); - } + if (streamings[index].active) { + return false; + } + + if (!streamings[index].output) { + streamings[index] = createSreamingOutput(settings, index); + } - startStreamingOutput(index); + startStreamingOutput(index); - if (!streamings[index].active) { - releaseInfrastructureIfIdle(); + if (!streamings[index].active) { + releaseInfrastructureIfIdle(); + return false; + } } } + return true; } -void BranchOutputFilter::stopSingleStreamingIndividual(size_t index) +bool BranchOutputFilter::stopSingleStreamingIndividual(size_t index) { pthread_mutex_lock(&pluginMutex); { @@ -455,12 +460,13 @@ void BranchOutputFilter::stopSingleStreamingIndividual(size_t index) OBSMutexAutoUnlock outputLocked(&outputMutex); if (!stopSingleStreamingOutputGracefully(index)) { - return; // reconnecting, will retry next tick + return true; // reconnecting, will retry next tick } releaseInfrastructureIfIdle(); } } + return true; } // Internal helper: caller must hold outputMutex. From 449c379f04afe3cc7f47831ebcc2ff4a7388ead4 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 5 Apr 2026 01:27:41 +0900 Subject: [PATCH 10/18] fix: clang-format errors. --- src/plugin-main.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 2c421bf..1f99aeb 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -977,8 +977,8 @@ void BranchOutputFilter::onIntervalTimerTimeout() } bool anyStarted = false; for (size_t i = 0; i < MAX_SERVICES; i++) { - if (isStreamingUserEnabled(i) && !streamings[i].active && - isStreamingEnabled(settings, i) && isStreamingGroupEnabled(settings)) { + if (isStreamingUserEnabled(i) && !streamings[i].active && isStreamingEnabled(settings, i) && + isStreamingGroupEnabled(settings)) { if (startSingleStreamingIndividual(i)) { anyStarted = true; } From 4d2091fc3f9e0cca134c2ab0b9be9624c0a78666 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 5 Apr 2026 02:17:22 +0900 Subject: [PATCH 11/18] fix: Move wasActive reads inside mutex-protected region Read recordingActive/recordingPending and replayBufferActive under pluginMutex+outputMutex to eliminate data race on non-atomic booleans in stopRecordingIndividual() and stopReplayBufferIndividual(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugin-replay-buffer.cpp | 48 ++++++++++++++---------- src/plugin-stream-recording.cpp | 66 ++++++++++++++++++--------------- 2 files changed, 65 insertions(+), 49 deletions(-) diff --git a/src/plugin-replay-buffer.cpp b/src/plugin-replay-buffer.cpp index 0a8b70c..f27ff0b 100644 --- a/src/plugin-replay-buffer.cpp +++ b/src/plugin-replay-buffer.cpp @@ -160,28 +160,33 @@ void BranchOutputFilter::createAndStartReplayBuffer(obs_data_t *settings) } } +// Caller must hold outputMutex. +void BranchOutputFilter::stopReplayBufferOutputLocked() +{ + 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)); + } +} + 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)); - } + stopReplayBufferOutputLocked(); } } @@ -323,17 +328,20 @@ bool BranchOutputFilter::startReplayBufferIndividual() bool BranchOutputFilter::stopReplayBufferIndividual() { - bool wasActive = replayBufferActive; + bool wasActive = false; pthread_mutex_lock(&pluginMutex); { OBSMutexAutoUnlock pluginLocked(&pluginMutex); - stopReplayBufferOutput(); - pthread_mutex_lock(&outputMutex); { OBSMutexAutoUnlock outputLocked(&outputMutex); + + // Read shared state under lock to avoid data race on non-atomic booleans. + wasActive = replayBufferActive; + + stopReplayBufferOutputLocked(); releaseInfrastructureIfIdle(); } } diff --git a/src/plugin-stream-recording.cpp b/src/plugin-stream-recording.cpp index a99cbf2..4485a7f 100644 --- a/src/plugin-stream-recording.cpp +++ b/src/plugin-stream-recording.cpp @@ -197,32 +197,37 @@ void BranchOutputFilter::createAndStartRecordingOutput(obs_data_t *settings) } } -void BranchOutputFilter::stopRecordingOutput(bool pending) +// Caller must hold outputMutex. +void BranchOutputFilter::stopRecordingOutputLocked(bool pending) { - pthread_mutex_lock(&outputMutex); - { - OBSMutexAutoUnlock locked(&outputMutex); - - if (recordingOutput) { - if (recordingActive) { - obs_source_t *parent = obs_filter_get_parent(filterSource); - if (parent) { - obs_source_dec_showing(parent); - } - obs_output_stop(recordingOutput); + if (recordingOutput) { + if (recordingActive) { + obs_source_t *parent = obs_filter_get_parent(filterSource); + if (parent) { + obs_source_dec_showing(parent); } + obs_output_stop(recordingOutput); } - recordingOutput = nullptr; + } + recordingOutput = nullptr; - if (recordingActive) { - recordingActive = false; - obs_log(LOG_INFO, "%s: Stopping recording output succeeded", qUtf8Printable(name)); - } + if (recordingActive) { + recordingActive = false; + obs_log(LOG_INFO, "%s: Stopping recording output succeeded", qUtf8Printable(name)); + } - recordingPending = pending; - recordingSettingsOverridden = false; - addChapterToRecordingEnabled = false; - splitRecordingEnabled = false; + recordingPending = pending; + recordingSettingsOverridden = false; + addChapterToRecordingEnabled = false; + splitRecordingEnabled = false; +} + +void BranchOutputFilter::stopRecordingOutput(bool pending) +{ + pthread_mutex_lock(&outputMutex); + { + OBSMutexAutoUnlock locked(&outputMutex); + stopRecordingOutputLocked(pending); } } @@ -484,22 +489,25 @@ bool BranchOutputFilter::startRecordingIndividual() bool BranchOutputFilter::stopRecordingIndividual() { - bool wasActive = recordingActive || recordingPending; + bool wasActive = false; pthread_mutex_lock(&pluginMutex); { OBSMutexAutoUnlock pluginLocked(&pluginMutex); - // stopRecordingOutput() clears recordingPending and recordingActive. - // If recording was in pending state (source collapsed), this simply clears the - // pending flag. releaseInfrastructureIfIdle() will then release shared resources - // if no other outputs are active. Re-enabling recording later will go through - // ensureInfrastructure() which will retry from a clean state. - stopRecordingOutput(); - pthread_mutex_lock(&outputMutex); { OBSMutexAutoUnlock outputLocked(&outputMutex); + + // Read shared state under lock to avoid data race on non-atomic booleans. + wasActive = recordingActive || recordingPending; + + // stopRecordingOutputLocked() clears recordingPending and recordingActive. + // If recording was in pending state (source collapsed), this simply clears the + // pending flag. releaseInfrastructureIfIdle() will then release shared resources + // if no other outputs are active. Re-enabling recording later will go through + // ensureInfrastructure() which will retry from a clean state. + stopRecordingOutputLocked(); releaseInfrastructureIfIdle(); } } From 4dbefecb03860bf0017bf440624cbb56735c5b67 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 5 Apr 2026 02:44:58 +0900 Subject: [PATCH 12/18] refactor: Use recursive mutexes to eliminate *Locked() method variants Switch all three mutexes (pluginMutex, outputMutex, audioMutex) to PTHREAD_MUTEX_RECURSIVE via OBS's pthread_mutex_init_recursive() helper. This allows the same thread to re-lock a mutex it already holds, removing the need for separate stopRecordingOutputLocked() and stopReplayBufferOutputLocked() methods. Callers no longer need to track whether they already hold outputMutex before calling stop functions. Also includes review-driven fixes: typo correction, unused function removal, return-value comment, user-flag guard in startOutput(), and lock ordering unification in stopOutputGracefully(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugin-main.cpp | 32 +++++++++++++-------- src/plugin-main.hpp | 16 +++++------ src/plugin-replay-buffer.cpp | 41 ++++++++++++-------------- src/plugin-stream-recording.cpp | 51 +++++++++++++++------------------ src/plugin-streaming.cpp | 3 ++ 5 files changed, 72 insertions(+), 71 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 1f99aeb..1560ada 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -102,8 +102,8 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc audios[i] = {0}; } - pthread_mutex_init(&outputMutex, nullptr); - pthread_mutex_init(&audioMutex, nullptr); + pthread_mutex_init_recursive(&outputMutex); + pthread_mutex_init_recursive(&audioMutex); if (!strcmp(obs_data_get_last_json(settings), "{}")) { // Maybe initial creation @@ -256,7 +256,7 @@ bool BranchOutputFilter::ensureInfrastructure(obs_data_t *settings) return false; } - // Mandatory paramters + // Mandatory parameters if (!isStreamingGroupEnabled(settings) && !isRecordingEnabled(settings) && !isReplayBufferEnabled(settings)) { obs_log(LOG_ERROR, "%s: Nothing to do", qUtf8Printable(name)); return false; @@ -605,6 +605,13 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) return; } + // Skip infrastructure setup if the user has disabled all output types + // via the status dock checkboxes. Without this check, interlock modes like + // ALWAYS_ON would rebuild and immediately tear down infrastructure every tick. + if (!isAnyStreamingUserEnabled() && !isRecordingUserEnabled() && !isReplayBufferUserEnabled()) { + return; + } + if (!ensureInfrastructure(settings)) { return; } @@ -760,13 +767,13 @@ void BranchOutputFilter::releaseInfrastructureIfIdle() void BranchOutputFilter::stopOutput() { - stopRecordingOutput(); - stopReplayBufferOutput(); - pthread_mutex_lock(&outputMutex); { OBSMutexAutoUnlock locked(&outputMutex); + stopRecordingOutput(); + stopReplayBufferOutput(); + for (size_t i = 0; i < MAX_SERVICES; i++) { stopStreamingOutput(i); } @@ -1273,11 +1280,8 @@ void BranchOutputFilter::stopOutputGracefully() { outputGracefullyStopping = true; - // Stop recording and replay buffer immediately first - stopRecordingOutput(); - stopReplayBufferOutput(); - - // Lock out other output thread to prevent crash + // Lock out other output thread to prevent crash. + // Lock order: pluginMutex -> outputMutex (consistent with stopOutput() and Individual stop functions). pthread_mutex_lock(&pluginMutex); { OBSMutexAutoUnlock pluginLocked(&pluginMutex); @@ -1286,6 +1290,10 @@ void BranchOutputFilter::stopOutputGracefully() { OBSMutexAutoUnlock outputLocked(&outputMutex); + // Stop recording and replay buffer immediately first (under outputMutex) + stopRecordingOutput(); + stopReplayBufferOutput(); + if (!stopAllStreamingOutputsGracefully()) { return; } @@ -1786,7 +1794,7 @@ bool obs_module_load() proxySourceInfo = FilterVideoCapture::createProxySourceInfo(); obs_register_source(&proxySourceInfo); - pthread_mutex_init(&pluginMutex, nullptr); + pthread_mutex_init_recursive(&pluginMutex); obs_log(LOG_INFO, "Plugin loaded successfully (version %s)", PLUGIN_VERSION); return true; diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 43fdd05..b8386d5 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -37,7 +37,12 @@ with this program. If not, see #define MAX_SERVICES 8 -// Defined in plugin-main.cpp. Guards plugin-wide state shared across filter instances. +// Defined in plugin-main.cpp. Guards plugin-wide state shared across filter instances: +// - Serializes OBS global API calls (obs_view, obs_encoder, obs_output creation/destruction) +// - Protects BranchOutputFilter instance lists and cross-instance coordination +// - Prevents concurrent infrastructure setup/teardown across multiple filter instances +// Lock ordering: pluginMutex -> outputMutex -> audioMutex +// All three mutexes are recursive — safe to re-lock from the same thread. extern pthread_mutex_t pluginMutex; class BranchOutputFilter : public QObject { @@ -122,7 +127,7 @@ class BranchOutputFilter : public QObject { // Audio context // Lock ordering: always acquire in order pluginMutex -> outputMutex -> audioMutex. // Never acquire a higher-order lock while holding a lower-order one. - pthread_mutex_t audioMutex; // Protects audios[] capture pointers against audioFilterCallback + pthread_mutex_t audioMutex; // Recursive mutex — protects audios[] capture pointers against audioFilterCallback BranchOutputAudioContext audios[MAX_AUDIO_MIXES]; // Recording context @@ -141,7 +146,7 @@ class BranchOutputFilter : public QObject { QString replayBufferFilenameFormatOverride; // Streaming context - pthread_mutex_t outputMutex; + pthread_mutex_t outputMutex; // Recursive mutex — safe to re-lock from same thread BranchOutputStreamingContext streamings[MAX_SERVICES]; // Hotkey context @@ -202,11 +207,6 @@ class BranchOutputFilter : public QObject { } return false; } - void setAllStreamingUserEnabled(bool enabled) - { - for (size_t i = 0; i < MAX_SERVICES; i++) - streamingUserEnabled[i].store(enabled, std::memory_order_relaxed); - } void setRecordingUserEnabled(bool enabled) { recordingUserEnabled.store(enabled, std::memory_order_relaxed); } void setReplayBufferUserEnabled(bool enabled) { replayBufferUserEnabled.store(enabled, std::memory_order_relaxed); } bool isRecordingUserEnabled() const { return recordingUserEnabled.load(std::memory_order_relaxed); } diff --git a/src/plugin-replay-buffer.cpp b/src/plugin-replay-buffer.cpp index f27ff0b..e4db167 100644 --- a/src/plugin-replay-buffer.cpp +++ b/src/plugin-replay-buffer.cpp @@ -160,33 +160,28 @@ void BranchOutputFilter::createAndStartReplayBuffer(obs_data_t *settings) } } -// Caller must hold outputMutex. -void BranchOutputFilter::stopReplayBufferOutputLocked() -{ - 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)); - } -} - void BranchOutputFilter::stopReplayBufferOutput() { pthread_mutex_lock(&outputMutex); { OBSMutexAutoUnlock locked(&outputMutex); - stopReplayBufferOutputLocked(); + + 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)); + } } } @@ -341,7 +336,7 @@ bool BranchOutputFilter::stopReplayBufferIndividual() // Read shared state under lock to avoid data race on non-atomic booleans. wasActive = replayBufferActive; - stopReplayBufferOutputLocked(); + stopReplayBufferOutput(); releaseInfrastructureIfIdle(); } } diff --git a/src/plugin-stream-recording.cpp b/src/plugin-stream-recording.cpp index 4485a7f..95d0351 100644 --- a/src/plugin-stream-recording.cpp +++ b/src/plugin-stream-recording.cpp @@ -197,37 +197,32 @@ void BranchOutputFilter::createAndStartRecordingOutput(obs_data_t *settings) } } -// Caller must hold outputMutex. -void BranchOutputFilter::stopRecordingOutputLocked(bool pending) -{ - if (recordingOutput) { - if (recordingActive) { - obs_source_t *parent = obs_filter_get_parent(filterSource); - if (parent) { - obs_source_dec_showing(parent); - } - obs_output_stop(recordingOutput); - } - } - recordingOutput = nullptr; - - if (recordingActive) { - recordingActive = false; - obs_log(LOG_INFO, "%s: Stopping recording output succeeded", qUtf8Printable(name)); - } - - recordingPending = pending; - recordingSettingsOverridden = false; - addChapterToRecordingEnabled = false; - splitRecordingEnabled = false; -} - void BranchOutputFilter::stopRecordingOutput(bool pending) { pthread_mutex_lock(&outputMutex); { OBSMutexAutoUnlock locked(&outputMutex); - stopRecordingOutputLocked(pending); + + if (recordingOutput) { + if (recordingActive) { + obs_source_t *parent = obs_filter_get_parent(filterSource); + if (parent) { + obs_source_dec_showing(parent); + } + obs_output_stop(recordingOutput); + } + } + recordingOutput = nullptr; + + if (recordingActive) { + recordingActive = false; + obs_log(LOG_INFO, "%s: Stopping recording output succeeded", qUtf8Printable(name)); + } + + recordingPending = pending; + recordingSettingsOverridden = false; + addChapterToRecordingEnabled = false; + splitRecordingEnabled = false; } } @@ -502,12 +497,12 @@ bool BranchOutputFilter::stopRecordingIndividual() // Read shared state under lock to avoid data race on non-atomic booleans. wasActive = recordingActive || recordingPending; - // stopRecordingOutputLocked() clears recordingPending and recordingActive. + // stopRecordingOutput() clears recordingPending and recordingActive. // If recording was in pending state (source collapsed), this simply clears the // pending flag. releaseInfrastructureIfIdle() will then release shared resources // if no other outputs are active. Re-enabling recording later will go through // ensureInfrastructure() which will retry from a clean state. - stopRecordingOutputLocked(); + stopRecordingOutput(); releaseInfrastructureIfIdle(); } } diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index d199488..942ae4e 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -449,6 +449,9 @@ bool BranchOutputFilter::startSingleStreamingIndividual(size_t index) return true; } +// Returns true to indicate that a stop operation was attempted (regardless of whether +// the stream has fully stopped yet). The caller uses this to track whether any action +// was taken during the current tick via `anyStopped |= stopSingleStreamingIndividual(i)`. bool BranchOutputFilter::stopSingleStreamingIndividual(size_t index) { pthread_mutex_lock(&pluginMutex); From 403bdb610147e4fcb62b7638f3c74bce2af24e8b Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 5 Apr 2026 03:49:44 +0900 Subject: [PATCH 13/18] fix: Make signal callback fields atomic and simplify streaming context - Change outputStarting to std::atomic and reconnectAttemptingAt to std::atomic to eliminate data races from OBS signal callbacks (starting, activate, reconnect, stop) that write these fields on OBS core threads. - Add default member initializers to BranchOutputStreamingContext, removing the explicit zero-init loop in the constructor. - Change createSreamingOutput() to populate streamings[index] in-place and return bool, eliminating the need for move constructor/assignment operator on the context struct. - Cache atomic load in reconnectAttemptingTimedOut() to prevent inconsistent reads between the two uses of the value. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugin-main.cpp | 5 ----- src/plugin-main.hpp | 11 ++++++----- src/plugin-streaming.cpp | 41 ++++++++++++++++++++-------------------- 3 files changed, 26 insertions(+), 31 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 1560ada..4d56dfd 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -81,11 +81,6 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc obs_log(LOG_DEBUG, "%s: BranchOutputFilter creating", qUtf8Printable(name)); obs_log(LOG_DEBUG, "filter_settings_json=%s", obs_data_get_json(settings)); - // Do not use memset - for (size_t i = 0; i < MAX_SERVICES; i++) { - streamings[i] = {0}; - } - // Per-stream user-enabled flags (with backward compatibility migration) bool legacyStreamingEnabled = obs_data_get_bool(settings, "streaming_output_enabled"); for (size_t i = 0; i < MAX_SERVICES; i++) { diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index b8386d5..0be98f8 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -75,14 +75,15 @@ class BranchOutputFilter : public QObject { struct BranchOutputStreamingContext { OBSOutputAutoRelease output; OBSServiceAutoRelease service; - uint64_t reconnectAttemptingAt; - bool outputStarting; - bool active; - bool stopping; + std::atomic reconnectAttemptingAt{0}; + std::atomic outputStarting{false}; + bool active = false; + bool stopping = false; OBSSignal outputStartingSignal; OBSSignal outputActivateSignal; OBSSignal outputReconnectSignal; OBSSignal outputStopSignal; + }; QString name; @@ -216,7 +217,7 @@ class BranchOutputFilter : public QObject { // Implemented in plugin-streaming.cpp obs_data_t *createStreamingSettings(obs_data_t *settings, size_t index = 0); - BranchOutputStreamingContext createSreamingOutput(obs_data_t *settings, size_t index = 0); + bool createSreamingOutput(obs_data_t *settings, size_t index = 0); void startStreamingOutput(size_t index = 0); void stopStreamingOutput(size_t index = 0); void reconnectStreamingOutput(size_t index = 0); diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index 942ae4e..29178fe 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -61,33 +61,32 @@ obs_data_t *BranchOutputFilter::createStreamingSettings(obs_data_t *settings, si return streamingSettings; } -BranchOutputFilter::BranchOutputStreamingContext -BranchOutputFilter::createSreamingOutput(obs_data_t *settings, size_t index) +bool BranchOutputFilter::createSreamingOutput(obs_data_t *settings, size_t index) { auto count = (size_t)obs_data_get_int(settings, "service_count"); if (index >= count || index >= MAX_SERVICES) { - return {0}; + return false; } if (!isStreamingEnabled(settings, index)) { - return {0}; + return false; } OBSDataAutoRelease streamingSettings = createStreamingSettings(settings, index); - BranchOutputStreamingContext context = {0}; + auto &ctx = streamings[index]; // Create service - We always use "rtmp_custom" as service - context.service = obs_service_create("rtmp_custom", qUtf8Printable(name), streamingSettings, nullptr); - if (!context.service) { + ctx.service = obs_service_create("rtmp_custom", qUtf8Printable(name), streamingSettings, nullptr); + if (!ctx.service) { obs_log(LOG_ERROR, "%s: Streaming %zu service creation failed", qUtf8Printable(name), index); - return {0}; + return false; } - obs_service_apply_encoder_settings(context.service, streamingSettings, nullptr); + obs_service_apply_encoder_settings(ctx.service, streamingSettings, nullptr); // Determine output type - auto type = obs_service_get_preferred_output_type(context.service); + auto type = obs_service_get_preferred_output_type(ctx.service); if (!type) { type = "rtmp_output"; - auto url = obs_service_get_connect_info(context.service, OBS_SERVICE_CONNECT_INFO_SERVER_URL); + auto url = obs_service_get_connect_info(ctx.service, OBS_SERVICE_CONNECT_INFO_SERVER_URL); if (url != nullptr && !strncmp(url, FTL_PROTOCOL, strlen(FTL_PROTOCOL))) { type = "ftl_output"; } else if (url != nullptr && strncmp(url, RTMP_PROTOCOL, strlen(RTMP_PROTOCOL))) { @@ -96,16 +95,16 @@ BranchOutputFilter::createSreamingOutput(obs_data_t *settings, size_t index) } // Create streaming output - context.output = + ctx.output = obs_output_create(type, qUtf8Printable(QString("%1 (%2)").arg(name).arg(index)), streamingSettings, nullptr); - if (!context.output) { + if (!ctx.output) { obs_log(LOG_ERROR, "%s (%zu): Streaming output creation failed", qUtf8Printable(name), index); - return {0}; + return false; } - obs_output_set_reconnect_settings(context.output, OUTPUT_MAX_RETRIES, OUTPUT_RETRY_DELAY_SECS); - obs_output_set_service(context.output, context.service); + obs_output_set_reconnect_settings(ctx.output, OUTPUT_MAX_RETRIES, OUTPUT_RETRY_DELAY_SECS); + obs_output_set_service(ctx.output, ctx.service); - return context; + return true; } void BranchOutputFilter::startStreamingOutput(size_t index) @@ -250,8 +249,8 @@ void BranchOutputFilter::reconnectStreamingOutput(size_t index) bool BranchOutputFilter::reconnectAttemptingTimedOut(size_t index) { - return streamings[index].reconnectAttemptingAt && - os_gettime_ns() - streamings[index].reconnectAttemptingAt > RECONNECT_ATTEMPTING_TIMEOUT_NS; + auto attemptingAt = streamings[index].reconnectAttemptingAt.load(); + return attemptingAt && os_gettime_ns() - attemptingAt > RECONNECT_ATTEMPTING_TIMEOUT_NS; } bool BranchOutputFilter::someStreamingsStarting() @@ -336,7 +335,7 @@ bool BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); for (size_t i = 0; i < MAX_SERVICES && i < serviceCount; i++) { if (!streamings[i].output && isStreamingUserEnabled(i)) { - streamings[i] = createSreamingOutput(settings, i); + createSreamingOutput(settings, i); } } @@ -435,7 +434,7 @@ bool BranchOutputFilter::startSingleStreamingIndividual(size_t index) } if (!streamings[index].output) { - streamings[index] = createSreamingOutput(settings, index); + createSreamingOutput(settings, index); } startStreamingOutput(index); From 283dfedd8f89a3b55c24f712a7c9f304125b8f47 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 5 Apr 2026 03:50:50 +0900 Subject: [PATCH 14/18] fix: clang-format errors. --- src/plugin-main.hpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 0be98f8..133eae7 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -83,7 +83,6 @@ class BranchOutputFilter : public QObject { OBSSignal outputActivateSignal; OBSSignal outputReconnectSignal; OBSSignal outputStopSignal; - }; QString name; From 84dd662a268b8aa588d4e94c268eae994b9f0fe4 Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 5 Apr 2026 11:32:38 +0900 Subject: [PATCH 15/18] fix: Limit streaming user-enabled check to configured service slots isAnyStreamingUserEnabled() checked all MAX_SERVICES slots, but unconfigured slots default to enabled and are not visible in the status dock, so users cannot toggle them off. This caused ALWAYS_ON mode to rebuild infrastructure every tick. Add isAnyStreamingUserEnabled(settings) overload that only checks slots within service_count that are actually configured. Move all streaming user-enabled accessors from header inline to plugin-streaming.cpp. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugin-main.cpp | 9 ++++++--- src/plugin-main.hpp | 22 +++++----------------- src/plugin-streaming.cpp | 30 ++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 4d56dfd..2eb8f7d 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -603,7 +603,10 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) // Skip infrastructure setup if the user has disabled all output types // via the status dock checkboxes. Without this check, interlock modes like // ALWAYS_ON would rebuild and immediately tear down infrastructure every tick. - if (!isAnyStreamingUserEnabled() && !isRecordingUserEnabled() && !isReplayBufferUserEnabled()) { + // Note: isAnyStreamingUserEnabled(settings) only checks configured service + // slots (service_count), not all MAX_SERVICES, because unconfigured slots + // are not visible in the status dock and their checkboxes cannot be toggled. + if (!isAnyStreamingUserEnabled(settings) && !isRecordingUserEnabled() && !isReplayBufferUserEnabled()) { return; } @@ -618,7 +621,7 @@ void BranchOutputFilter::startOutput(obs_data_t *settings) if (isReplayBufferUserEnabled()) { anyStarted |= createAndStartReplayBufferChecked(settings); } - if (isAnyStreamingUserEnabled()) { + if (isAnyStreamingUserEnabled(settings)) { anyStarted |= createAndStartStreamingOutputs(settings); } @@ -937,7 +940,7 @@ void BranchOutputFilter::onIntervalTimerTimeout() // outputs when an unconfigured type matches first. OBSDataAutoRelease settings = obs_source_get_settings(filterSource); bool anyStarted = false; - if (isAnyStreamingUserEnabled() && obs_frontend_streaming_active() && + if (isAnyStreamingUserEnabled(settings) && obs_frontend_streaming_active() && isStreamingGroupEnabled(settings)) { anyStarted |= startStreamingIndividual(); } diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 133eae7..9300fb5 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -190,23 +190,11 @@ class BranchOutputFilter : public QObject { void saveCallback(obs_data_t *settings); // Per-output user intent setters/getters (thread-safe via std::atomic) - void setStreamingUserEnabled(size_t index, bool enabled) - { - if (index < MAX_SERVICES) - streamingUserEnabled[index].store(enabled, std::memory_order_relaxed); - } - bool isStreamingUserEnabled(size_t index) const - { - return index < MAX_SERVICES ? streamingUserEnabled[index].load(std::memory_order_relaxed) : false; - } - bool isAnyStreamingUserEnabled() const - { - for (size_t i = 0; i < MAX_SERVICES; i++) { - if (streamingUserEnabled[i].load(std::memory_order_relaxed)) - return true; - } - return false; - } + // Streaming variants are implemented in plugin-streaming.cpp + void setStreamingUserEnabled(size_t index, bool enabled); + bool isStreamingUserEnabled(size_t index) const; + bool isAnyStreamingUserEnabled() const; + bool isAnyStreamingUserEnabled(obs_data_t *settings); void setRecordingUserEnabled(bool enabled) { recordingUserEnabled.store(enabled, std::memory_order_relaxed); } void setReplayBufferUserEnabled(bool enabled) { replayBufferUserEnabled.store(enabled, std::memory_order_relaxed); } bool isRecordingUserEnabled() const { return recordingUserEnabled.load(std::memory_order_relaxed); } diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index 29178fe..e24b3a2 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -253,6 +253,36 @@ bool BranchOutputFilter::reconnectAttemptingTimedOut(size_t index) return attemptingAt && os_gettime_ns() - attemptingAt > RECONNECT_ATTEMPTING_TIMEOUT_NS; } +void BranchOutputFilter::setStreamingUserEnabled(size_t index, bool enabled) +{ + if (index < MAX_SERVICES) + streamingUserEnabled[index].store(enabled, std::memory_order_relaxed); +} + +bool BranchOutputFilter::isStreamingUserEnabled(size_t index) const +{ + return index < MAX_SERVICES ? streamingUserEnabled[index].load(std::memory_order_relaxed) : false; +} + +bool BranchOutputFilter::isAnyStreamingUserEnabled() const +{ + for (size_t i = 0; i < MAX_SERVICES; i++) { + if (streamingUserEnabled[i].load(std::memory_order_relaxed)) + return true; + } + return false; +} + +bool BranchOutputFilter::isAnyStreamingUserEnabled(obs_data_t *settings) +{ + auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); + for (size_t i = 0; i < MAX_SERVICES && i < serviceCount; i++) { + if (isStreamingEnabled(settings, i) && streamingUserEnabled[i].load(std::memory_order_relaxed)) + return true; + } + return false; +} + bool BranchOutputFilter::someStreamingsStarting() { for (size_t i = 0; i < MAX_SERVICES; i++) { From 7d38dcbc43249361c88fe20496b7d948267cf44f Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 5 Apr 2026 11:51:41 +0900 Subject: [PATCH 16/18] fix: Remove legacy streaming_output_enabled migration and erase replay_buffer in loadRecently The legacy streaming_output_enabled key (without index) was for backward compatibility with an unreleased version, so migration is unnecessary. Remove it from constructor loading, saveCallback, and getDefaults. Only indexed streaming_output_enabled_N keys are used. Also erase replay_buffer from recently settings so new filters do not inherit the replay buffer enabled state from previous filters. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugin-main.cpp | 11 +++-------- src/plugin-ui.cpp | 1 - 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 2eb8f7d..1fd8a8b 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -81,15 +81,10 @@ BranchOutputFilter::BranchOutputFilter(obs_data_t *settings, obs_source_t *sourc obs_log(LOG_DEBUG, "%s: BranchOutputFilter creating", qUtf8Printable(name)); obs_log(LOG_DEBUG, "filter_settings_json=%s", obs_data_get_json(settings)); - // Per-stream user-enabled flags (with backward compatibility migration) - bool legacyStreamingEnabled = obs_data_get_bool(settings, "streaming_output_enabled"); + // Per-stream user-enabled flags for (size_t i = 0; i < MAX_SERVICES; i++) { auto key = QString("streaming_output_enabled_%1").arg(i); - if (obs_data_has_user_value(settings, qUtf8Printable(key))) { - streamingUserEnabled[i].store(obs_data_get_bool(settings, qUtf8Printable(key)), std::memory_order_relaxed); - } else { - streamingUserEnabled[i].store(legacyStreamingEnabled, std::memory_order_relaxed); - } + streamingUserEnabled[i].store(obs_data_get_bool(settings, qUtf8Printable(key)), std::memory_order_relaxed); } // Do not use memset @@ -696,6 +691,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, "replay_buffer"); obs_data_erase(recently_settings, "custom_audio_source"); obs_data_erase(recently_settings, "multitrack_audio"); @@ -818,7 +814,6 @@ void BranchOutputFilter::saveCallback(obs_data_t *settings) auto key = QString("streaming_output_enabled_%1").arg(i); obs_data_set_bool(settings, qUtf8Printable(key), isStreamingUserEnabled(i)); } - obs_data_set_bool(settings, "streaming_output_enabled", isAnyStreamingUserEnabled()); obs_data_set_bool(settings, "recording_output_enabled", isRecordingUserEnabled()); obs_data_set_bool(settings, "replay_buffer_output_enabled", isReplayBufferUserEnabled()); } diff --git a/src/plugin-ui.cpp b/src/plugin-ui.cpp index e027a4a..51e4f1a 100644 --- a/src/plugin-ui.cpp +++ b/src/plugin-ui.cpp @@ -216,7 +216,6 @@ void BranchOutputFilter::getDefaults(obs_data_t *defaults) obs_data_set_default_string(defaults, "replay_buffer_format", recFormat); // Per-output user intent defaults (not shown in UI, persisted in settings) - obs_data_set_default_bool(defaults, "streaming_output_enabled", true); for (size_t i = 0; i < MAX_SERVICES; i++) { auto key = QString("streaming_output_enabled_%1").arg(i); obs_data_set_default_bool(defaults, qUtf8Printable(key), true); From 6ffc97de5f3281b8f00e645dabd8f094bba3a5fd Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 5 Apr 2026 12:07:30 +0900 Subject: [PATCH 17/18] fix: Add missing braces to if statements in streaming user-enabled functions Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugin-streaming.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index e24b3a2..8a7c9f2 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -255,8 +255,9 @@ bool BranchOutputFilter::reconnectAttemptingTimedOut(size_t index) void BranchOutputFilter::setStreamingUserEnabled(size_t index, bool enabled) { - if (index < MAX_SERVICES) + if (index < MAX_SERVICES) { streamingUserEnabled[index].store(enabled, std::memory_order_relaxed); + } } bool BranchOutputFilter::isStreamingUserEnabled(size_t index) const @@ -267,8 +268,9 @@ bool BranchOutputFilter::isStreamingUserEnabled(size_t index) const bool BranchOutputFilter::isAnyStreamingUserEnabled() const { for (size_t i = 0; i < MAX_SERVICES; i++) { - if (streamingUserEnabled[i].load(std::memory_order_relaxed)) + if (streamingUserEnabled[i].load(std::memory_order_relaxed)) { return true; + } } return false; } @@ -277,8 +279,9 @@ bool BranchOutputFilter::isAnyStreamingUserEnabled(obs_data_t *settings) { auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); for (size_t i = 0; i < MAX_SERVICES && i < serviceCount; i++) { - if (isStreamingEnabled(settings, i) && streamingUserEnabled[i].load(std::memory_order_relaxed)) + if (isStreamingEnabled(settings, i) && streamingUserEnabled[i].load(std::memory_order_relaxed)) { return true; + } } return false; } From 103015dd78ae7ba89781360f1ed4397d1234d48a Mon Sep 17 00:00:00 2001 From: Shinichi Hanayama Date: Sun, 5 Apr 2026 12:17:01 +0900 Subject: [PATCH 18/18] fix: Rename createSreamingOutput to createStreamingOutput (typo fix) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugin-main.hpp | 2 +- src/plugin-streaming.cpp | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/plugin-main.hpp b/src/plugin-main.hpp index 9300fb5..90b9e1c 100644 --- a/src/plugin-main.hpp +++ b/src/plugin-main.hpp @@ -204,7 +204,7 @@ class BranchOutputFilter : public QObject { // Implemented in plugin-streaming.cpp obs_data_t *createStreamingSettings(obs_data_t *settings, size_t index = 0); - bool createSreamingOutput(obs_data_t *settings, size_t index = 0); + bool createStreamingOutput(obs_data_t *settings, size_t index = 0); void startStreamingOutput(size_t index = 0); void stopStreamingOutput(size_t index = 0); void reconnectStreamingOutput(size_t index = 0); diff --git a/src/plugin-streaming.cpp b/src/plugin-streaming.cpp index 8a7c9f2..bf3ab4b 100644 --- a/src/plugin-streaming.cpp +++ b/src/plugin-streaming.cpp @@ -61,7 +61,7 @@ obs_data_t *BranchOutputFilter::createStreamingSettings(obs_data_t *settings, si return streamingSettings; } -bool BranchOutputFilter::createSreamingOutput(obs_data_t *settings, size_t index) +bool BranchOutputFilter::createStreamingOutput(obs_data_t *settings, size_t index) { auto count = (size_t)obs_data_get_int(settings, "service_count"); if (index >= count || index >= MAX_SERVICES) { @@ -355,7 +355,7 @@ bool BranchOutputFilter::isStreamingEnabled(obs_data_t *settings, size_t index) // Caller must call ensureInfrastructure() before this function to set up // the view, video/audio encoders, and related infrastructure. // Note: stopStreamingOutput() sets streamings[i].output to nullptr, so stopped slots -// are always recreated with fresh settings via createSreamingOutput(). +// are always recreated with fresh settings via createStreamingOutput(). bool BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) { if (!isStreamingGroupEnabled(settings)) { @@ -368,7 +368,7 @@ bool BranchOutputFilter::createAndStartStreamingOutputs(obs_data_t *settings) auto serviceCount = (size_t)obs_data_get_int(settings, "service_count"); for (size_t i = 0; i < MAX_SERVICES && i < serviceCount; i++) { if (!streamings[i].output && isStreamingUserEnabled(i)) { - createSreamingOutput(settings, i); + createStreamingOutput(settings, i); } } @@ -467,7 +467,7 @@ bool BranchOutputFilter::startSingleStreamingIndividual(size_t index) } if (!streamings[index].output) { - createSreamingOutput(settings, index); + createStreamingOutput(settings, index); } startStreamingOutput(index);