From fe7cd7364669e18dff5db4064474eabdc1caefc3 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 21:14:50 +0100 Subject: [PATCH] Document asynchronous desktop stream publication and settings --- tools/chatgui/src/stream_publish_engine.h | 120 ++++++++++++---------- tools/chatgui/src/stream_publish_window.h | 56 +++++----- 2 files changed, 94 insertions(+), 82 deletions(-) diff --git a/tools/chatgui/src/stream_publish_engine.h b/tools/chatgui/src/stream_publish_engine.h index 2dc17cd8..3f1740d0 100644 --- a/tools/chatgui/src/stream_publish_engine.h +++ b/tools/chatgui/src/stream_publish_engine.h @@ -1,6 +1,10 @@ -/* Desktop publisher: GUI управляет источниками и AudioDevice, workers кодируют видео/Opus. - * Окно не владеет трансляцией. Stop закрывает callbacks; GUI ждёт workers асинхронно, затем отправляет FIN. - * Captured video/preview хранят только последний кадр; публикация использует общий C actor. */ +/* Desktop publisher принадлежит MainWindow и переживает закрытие окна управления. + * Публичные методы — GUI-поток: start открывает actor/capture, но первый KEY и Live приходят асинхронно. + * reconfigure публикует желаемую конфигурацию; ошибка видео откатывает к последней рабочей, повторная ошибка останавливает. + * Audio callback только копирует PCM; Opus/video кодируются в отдельных workers, QVideoFrame преобразует долгоживущий image worker. + * Capture/preview хранят последний кадр, данные уходят через общий C publisher actor. + * stop закрывает capture, оставляя GUI доступным; отдельный join ждёт завершения workers/TLS, затем finishStop отправляет FIN. + * active остаётся true до окончания actor; новый start разрешён после active=false. Деструктор ждёт также image worker, обрабатывая Qt events. */ #pragma once #include #include @@ -17,25 +21,25 @@ #include "../../../src/stream/stream_publisher.h" struct StreamVideoSource { - enum Kind { Camera, Screen, Window }; - Kind kind = Camera; - QString name; - QByteArray id; - QVariant window; - QSize size; - QList modes; + enum Kind { Camera, Screen, Window }; // Window поддерживается openSource, но sources() перечисляет только camera/screen. + Kind kind = Camera; // Backend захвата. + QString name; // Отображаемое имя; не часть идентичности источника. + QByteArray id; // Camera device-id, дескриптор screen либо "portal" для системного выбора Wayland. + QVariant window; // QCapturableWindow для Kind::Window; пустой у camera/screen. + QSize size; // Физический размер screen; может быть неизвестен до первого кадра portal. + QList modes; // Режимы камеры из объявленных возможностей; screen-режимы строятся отдельно. QPointer screen; // Живой монитор; удаление устройства не должно переключать захват на его одноимённого соседа. - bool sameSource(const StreamVideoSource& other) const { + bool sameSource(const StreamVideoSource& other) const { // Сравнивает backend/handles, без имени, разрешения и списка режимов. return kind == other.kind && id == other.id && window == other.window && screen == other.screen; } }; struct StreamPublishConfig { - StreamVideoSource source; - LiveVideoConfig video; - QString microphone; - bool audio = true; - bool showCursor = true; - bool nativeSize = false, automaticBitrate = false; + StreamVideoSource source; // Выбранный capture; camera-режим должен поддерживать video.width/height/fps. + LiveVideoConfig video; // Параметры encoder; поддерживаемый codec, fps 1..60, размеры в допустимом диапазоне encoder. + QString microphone; // Стабильный id устройства SoundManager; пустой — default. Меняется только до start. + bool audio = true; // Открыть микрофон и stereo Opus 48кГц/128кбит/с; во время публикации меняется только mute. + bool showCursor = true; // Учитывается поддерживаемым screen backend; portal разрешает выбор только до открытия сессии. + bool nativeSize = false, automaticBitrate = false; // Screen: исходные размеры до 4096 и пересчёт bitrate при уточнении размера. bool adaptiveQp = false; // При перерасходе битрейта плавно повышать потолок выше video.maxQp, затем возвращать. }; class StreamPublishEngine : public QObject { @@ -43,51 +47,53 @@ class StreamPublishEngine : public QObject { public: explicit StreamPublishEngine(QObject* parent = nullptr); ~StreamPublishEngine() override; - bool start(uint64_t group, const StreamPublishConfig& config); - bool reconfigure(const StreamPublishConfig& config); - void stop(); - bool active() const; - bool running() const; - uint64_t group() const; - StreamPublishConfig config() const; + bool start(uint64_t group, const StreamPublishConfig& config); // true — запуск принят; результат/ошибки ещё асинхронны. false может начать stop. + bool reconfigure(const StreamPublishConfig& config); // true — принято, не подтверждение KEY; микрофон/audio и cursor сессии portal менять нельзя. + void stop(); // Идемпотентный запрос остановки; освобождение workers/microphone и доотправка actor завершаются через tick. + bool active() const; // Actor-id ещё принадлежит engine, включая запуск и остановку/дренирование. + bool running() const; // Capture/encoder ещё работают либо ждут выбора portal/разрешения CPU; stop сразу даёт false. + uint64_t group() const; // Группа текущего actor, 0 после его окончательного завершения. + StreamPublishConfig config() const; // Копия wanted под lock; может ещё ожидать применения/отката. uint64_t videoBitrate() const; // Фактический выход video encoder за последний интервал около секунды, бит/с; читать в GUI-потоке. int videoMaxQp() const; // Текущий потолок QP с учётом адаптации; -1 — настройки encoder. - QVariant previewFrame() const; - QList codecs() const; - static QList sources(); - static bool supported(); - static bool cursorSupported(const StreamVideoSource& source); - void setMuted(bool muted); - bool muted() const; - uint64_t softwareEncodingRequest() const; + QVariant previewFrame() const; // Shared QVideoFrame последнего capture, пустой/невалидный без кадра; сетевое кодирование не требуется. + QList codecs() const; // Кодеки LiveVideoEncoder, найденные при создании; пусто без поддержки capture. + static QList sources(); // Текущие камеры и screen: Wayland даёт один portal, остальные платформы — QScreen handles. + static bool supported(); // Возможности сборки HAVE_STREAM_CAPTURE; не гарантия наличия устройства или рабочего encoder. + static bool cursorSupported(const StreamVideoSource& source); // Наличие cursor backend для выбранного screen/платформы. + void setMuted(bool muted); // Только running+audio: меняет флаг actor и обнуляет исходящий PCM; ошибку смены трактует как отказ публикации. + bool muted() const; // Текущее mute; сбрасывается при start. + uint64_t softwareEncodingRequest() const; // Поколение ожидающего запроса CPU либо 0. + // Ответ только текущему запросу; устаревший игнорируется, отказ вызывает rollback/stop. void answerSoftwareEncoding(uint64_t generation, bool allow); signals: - void codecsChanged(); - void sourcesChanged(); - void stateChanged(const QString& state); - void configurationChanged(); - void previewVideoChanged(const QVariant& frame); - void telemetryChanged(const stream_publisher_status& status); - void errorOccurred(const QString& error); - void audioChannelsChanged(int channels); + void codecsChanged(); // Сигнал перечня codec; текущая реализация определяет список один раз в конструкторе. + void sourcesChanged(); // Камеры/мониторы изменились либо уточнён размер захваченного screen; GUI перечитывает sources/config. + void stateChanged(const QString& state); // Пользовательский текст запуска, Live, применения, остановки или ожидания CPU/portal. + void configurationChanged(); // Настройки подтверждены либо изменилось состояние start/stop/mute; перечитать config/active/running. + void previewVideoChanged(const QVariant& frame); // GUI публикует новый QVideoFrame до ~30fps; пустой QVariant очищает preview при closeSource. + void telemetryChanged(const stream_publisher_status& status); // Снимок actor, примерно каждые 100мс вне ожидания workers. + void errorOccurred(const QString& error); // Ошибка может привести к rollback либо stop; сама не означает active=false. + void audioChannelsChanged(int channels); // Фактический mono/stereo вход; mono дублируется в stereo-транспорт. + // HW не открылся; требуется явный ответ для CPU. void softwareEncodingRequested(uint64_t generation, const QString& codec, const QString& details); private: struct Impl; - std::unique_ptr m; - AudioCapture m_capture; - AudioCaptureLease m_microphone{AudioCaptureLease::Mode::Exclusive}; - QTimer m_timer; - QTimer m_previewTimer; - bool openSource(const StreamPublishConfig& config); - void closeSource(); - bool openMicrophone(); - void sourceFailed(uint64_t generation, const QString& error); - void publicationFailed(uint64_t id, const QString& error); - void tick(); - bool finishStop(); - bool finishImageStop(); - void imageLoop(); - void videoLoop(StreamPublishConfig initial); - void audioLoop(); - void captureAudio(const AudioCapture::Frame& frame); + std::unique_ptr m; // Workers, Qt capture backend, wanted/good generations, очередь PCM и последний video/preview. + AudioCapture m_capture; // Микрофон 48кГц, callback копирует PCM без DSP/codec. + AudioCaptureLease m_microphone{AudioCaptureLease::Mode::Exclusive}; // Удерживается до закрытия устройства и завершения workers. + QTimer m_timer; // GUI 100мс: finishStop, состояние actor, telemetry, bitrate и таймауты capture. + QTimer m_previewTimer; // GUI 33мс: отправляет только изменившийся previewFrame. + bool openSource(const StreamPublishConfig& config); // Пересоздаёт capture backend в GUI, true — принят запуск; ошибки возможны позже. + void closeSource(); // Меняет sourceGeneration, очищает кадры, отключает callbacks и закрывает backend. + bool openMicrophone(); // true — открыт вход; нормализует каналы и подключает captureAudio. + void sourceFailed(uint64_t generation, const QString& error); // Только текущее поколение: однократный rollback к good либо stop. + void publicationFailed(uint64_t id, const QString& error); // Ошибка всей текущей публикации, без video rollback: errorOccurred и stop. + void tick(); // GUI-проверка состояния actor/остановки, capture/audio timeout и статистики. + bool finishStop(); // true — workers join и закрытие микрофона завершены, FIN уже отправлен; actor ещё может дренироваться. + bool finishImageStop(); // Неблокирующий wait(0) image QThread при уничтожении; true — native TLS уже освобождён. + void imageLoop(); // Долгоживущий QThread: последний QVideoFrame → QImage, rotation/mirror и cursor Windows. + void videoLoop(StreamPublishConfig initial); // Video worker: encoder/revision, HW→CPU consent, KEY, bitrate/QP и feed actor. + void audioLoop(); // Audio worker: 20мс stereo, compressor/Opus, пропуск устаревших blocks, feed actor. + void captureAudio(const AudioCapture::Frame& frame); // Hardware callback: до 960 frames на элемент ограниченной очереди, без allocations/codec. }; diff --git a/tools/chatgui/src/stream_publish_window.h b/tools/chatgui/src/stream_publish_window.h index 2940153c..bf158e70 100644 --- a/tools/chatgui/src/stream_publish_window.h +++ b/tools/chatgui/src/stream_publish_window.h @@ -1,4 +1,8 @@ -/* Управление publisher: окно можно закрыть и открыть повторно, engine живёт у MainWindow. */ +/* GUI управления StreamPublishEngine: окно не владеет engine, он должен жить дольше окна (в MainWindow это отдельный объект). + * Закрытие/Esc скрывает окно и сообщает closed, публикация продолжает работать. Повторное открытие MainWindow создаёт новое окно. + * settings — компактный JSON пользовательского выбора; здесь нет БД, сохранение выполняет владелец по settingsChanged. + * Пока engine активен, UI синхронизируется с wanted config; start/stop/reconfigure подтверждаются асинхронно. + * Отсутствие HW encoder открывает немодальный диалог согласия на CPU для поколения запроса; отказ/удаление диалога отвечает false. */ #pragma once #include #include @@ -14,34 +18,36 @@ class StreamBufferIndicator; class StreamPublishWindow : public QWidget { Q_OBJECT public: + // engine обязателен; group используется при start, действующий actor остаётся в своей группе. settings восстанавливаются только до start. StreamPublishWindow(StreamPublishEngine* engine, uint64_t group, QWidget* parent = nullptr, const QString& settings = {}); signals: - void settingsChanged(const QString& settings); - void closed(); // Возвращает фокус окну, из которого открыто управление; публикация продолжает работать. + void settingsChanged(const QString& settings); // Новый компактный JSON, только при изменении выбора; runtime handles не сохраняются. + void closed(); // Принят closeEvent; MainWindow использует сигнал для возврата фокуса в grid, без остановки публикации. protected: void closeEvent(QCloseEvent* event) override; private: - StreamPublishEngine* m_engine; - uint64_t m_group; - QList m_sources; - QList m_modes; - QComboBox *m_source, *m_microphone, *m_mode, *m_codec, *m_bitrate; - QCheckBox *m_audio, *m_mute, *m_cursor; - QSpinBox* m_maxQp; - QCheckBox* m_adaptiveQp; - QLabel *m_status, *m_stats, *m_inputChannels; - QPushButton* m_start; - StreamBufferIndicator* m_buffer; - QButtonGroup* m_rotation; - QWidget* m_preview; - bool m_updating = false; - QJsonObject m_settings; - StreamPublishConfig selection() const; - void refreshSources(); + StreamPublishEngine* m_engine; // Заимствован; QWidget-дерево владеет только дочерними controls. + uint64_t m_group; // Группа для следующего запуска из этого окна. + QList m_sources; // Текущий список camera/screen, индексы совпадают с m_source. + QList m_modes; // Режимы выбранного источника, индексы совпадают с m_mode. + QComboBox *m_source, *m_microphone, *m_mode, *m_codec, *m_bitrate; // Выбор source/device/mode/codec и ручного либо auto bitrate. + QCheckBox *m_audio, *m_mute, *m_cursor; // Audio/device фиксируются на start; mute меняется отдельно, cursor portal — только до start. + QSpinBox* m_maxQp; // UI 0 означает Automatic → config.maxQp=-1; явные значения 1..51 только H.264/H.265. + QCheckBox* m_adaptiveQp; // Доступен при явном maxQp; engine проверяет поддержку live QP у encoder. + QLabel *m_status, *m_stats, *m_inputChannels; // Статус/ошибка, bitrate/QP/очереди/ACK и фактические входные каналы. + QPushButton* m_start; // Start/Stop; отключена во время завершения actor либо при отсутствии доступных source/mode/codec. + StreamBufferIndicator* m_buffer; // Диапазоны получателей по истории source: oldest → live; tooltip содержит peer/subflows. + QButtonGroup* m_rotation; // Варианты 0/90/270°, задают метаданные rotation для зрителя. + QWidget* m_preview; // QVideoWidget при HAVE_STREAM_CAPTURE, иначе текст о недоступности capture. + bool m_updating = false; // Защита от применения/сохранения промежуточного состояния при перестроении controls. + QJsonObject m_settings; // Последний сохранённый выбор; source id в base64, окно по имени, режим/codec/audio/cursor/QP отдельно. + StreamPublishConfig selection() const; // Снимок controls: auto bitrate рассчитан, screen nativeSize и допустимость QP учтены. + void refreshSources(); // Сохраняет выбранный handle; пропавший active-source не подменяется другим, до start возможен первый доступный. + // Режимы camera/screen; выбирает близкий previous либо native screen. void refreshModes(const StreamPublishConfig& previous, bool maximum = false); - void refreshCodecs(); - void synchronize(); - void apply(); - void restoreSettings(); - void saveSettings(); + void refreshCodecs(); // Перечитывает engine.codecs(), сохраняет текущий/saved codec, недоступный заменяет первым. + void synchronize(); // Читает engine config/state без apply; настраивает controls и ограничения изменения audio/cursor/QP. + void apply(); // Для выбранного режима: reconfigure только active-engine, затем сохранение выбора; не во время m_updating. + void restoreSettings(); // До start восстанавливает доступные значения JSON; некорректные/отсутствующие диагностируются и заменяются. + void saveSettings(); // Сериализует пользовательские настройки без runtime handles/вычисленного auto bitrate и эмитит лишь изменение JSON. };