| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | /* | ||
| 3 | * This file is part of the Falcon Player (FPP) and is Copyright (C) | ||
| 4 | * 2013-2026 by the Falcon Player Developers. | ||
| 5 | * | ||
| 6 | * The Falcon Player (FPP) is free software, and is covered under | ||
| 7 | * multiple Open Source licenses. Please see the included 'LICENSES' | ||
| 8 | * file for descriptions of what files are covered by each license. | ||
| 9 | * | ||
| 10 | * This source file is covered under the LGPL v2.1 as described in the | ||
| 11 | * included LICENSE.LGPL file. | ||
| 12 | */ | ||
| 13 | |||
| 14 | #include <atomic> | ||
| 15 | #include <chrono> | ||
| 16 | #include <condition_variable> | ||
| 17 | #include <map> | ||
| 18 | #include <memory> | ||
| 19 | #include <mutex> | ||
| 20 | #include <set> | ||
| 21 | #include <string> | ||
| 22 | #include <thread> | ||
| 23 | #include <vector> | ||
| 24 | |||
| 25 | #include <arpa/inet.h> | ||
| 26 | |||
| 27 | #include "DrmCards.h" | ||
| 28 | |||
| 29 | #if __has_include(<gst/gst.h>) | ||
| 30 | #define HAS_GSTREAMER_VIDEO_OUTPUT | ||
| 31 | #include <gst/gst.h> | ||
| 32 | #include <gst/app/gstappsink.h> | ||
| 33 | #endif | ||
| 34 | |||
| 35 | class PixelOverlayModel; | ||
| 36 | |||
| 37 | /** | ||
| 38 | * A sub-region of the source frame, as fractions (0.0-1.0) of its width and | ||
| 39 | * height. Fractions rather than pixels so one configuration works for any | ||
| 40 | * source resolution — the pixel crop is computed from the negotiated caps at | ||
| 41 | * runtime, and re-computed if the source resolution changes mid-stream. | ||
| 42 | * | ||
| 43 | * Used to split one decoded video across several displays: give each output a | ||
| 44 | * different region (e.g. left half / right half of a 3840x1080 file) and each | ||
| 45 | * shows its own slice of a single decode. | ||
| 46 | */ | ||
| 47 | struct VideoCropRect { | ||
| 48 | double x = 0.0; | ||
| 49 | double y = 0.0; | ||
| 50 | double width = 1.0; | ||
| 51 | double height = 1.0; | ||
| 52 | |||
| 53 | /// When > 0, the selected region is narrowed further -- centred -- until | ||
| 54 | /// its width:height matches this ratio. That is how "fill" scaling is | ||
| 55 | /// expressed: crop the source to the display's shape so the | ||
| 56 | /// aspect-preserving scale that follows covers the display exactly, with | ||
| 57 | /// nothing letterboxed. Resolved against the negotiated caps alongside | ||
| 58 | /// the fractions above, so it costs no extra element. 0 leaves the | ||
| 59 | /// region exactly as selected ("fit"). | ||
| 60 | double targetAspect = 0.0; | ||
| 61 | |||
| 62 | /// True when this selects the whole frame, i.e. no crop element is needed. | ||
| 63 | /// A target aspect always needs one: whether it actually trims anything | ||
| 64 | /// depends on the source resolution, which is not known until caps arrive. | ||
| 65 | 712 | bool IsFullFrame() const { | |
| 66 | 1423 | return targetAspect <= 0.0 && | |
| 67 |
8/10✓ Branch 0 taken 711 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 710 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 710 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 709 times.
✓ Branch 7 taken 1 times.
✓ Branch 8 taken 709 times.
✗ Branch 9 not taken.
|
712 | x <= 0.0 && y <= 0.0 && width >= 1.0 && height >= 1.0; |
| 68 | } | ||
| 69 | |||
| 70 | /// True when the values describe a usable region. A zero/negative extent | ||
| 71 | /// or one starting past the right/bottom edge would crop everything away. | ||
| 72 | 5 | bool IsValid() const { | |
| 73 |
3/6✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
|
5 | return width > 0.0 && height > 0.0 && x >= 0.0 && y >= 0.0 && |
| 74 |
3/6✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
|
10 | (x + width) <= 1.0001 && (y + height) <= 1.0001; |
| 75 | } | ||
| 76 | }; | ||
| 77 | |||
| 78 | /** | ||
| 79 | * VideoOutputManager — manages PipeWire video consumer pipelines. | ||
| 80 | * | ||
| 81 | * Each video output destination (HDMI display, pixel overlay, network stream) | ||
| 82 | * runs a GStreamer consumer pipeline: | ||
| 83 | * pipewiresrc → videoconvert → videoscale → destination sink | ||
| 84 | * | ||
| 85 | * Consumer pipelines are started on-demand when a video producer appears | ||
| 86 | * (GStreamerOut calls StartConsumers after pipewiresink is attached) and | ||
| 87 | * stopped when playback ends (StopConsumers). The pipewiresrc target-object | ||
| 88 | * is set to the producer's node name so PipeWire links them automatically. | ||
| 89 | * | ||
| 90 | * Config file: $mediaDirectory/config/pipewire-video-consumers.json | ||
| 91 | */ | ||
| 92 | class VideoOutputManager { | ||
| 93 | public: | ||
| 94 | static VideoOutputManager& Instance(); | ||
| 95 | |||
| 96 | /// Load config at startup. Does NOT start consumer pipelines. | ||
| 97 | void Init(); | ||
| 98 | |||
| 99 | /// Reload config (called when Apply is triggered from UI). | ||
| 100 | /// If consumers are currently running, restarts them with new config. | ||
| 101 | void Reload(); | ||
| 102 | |||
| 103 | /// Stop all consumer pipelines and clean up. | ||
| 104 | void Shutdown(); | ||
| 105 | |||
| 106 | /// Info about an HDMI consumer for direct kmssink attachment. | ||
| 107 | struct HdmiConsumerInfo { | ||
| 108 | int connectorId = -1; | ||
| 109 | std::string connector; // e.g. "HDMI-A-2" | ||
| 110 | std::string cardPath; | ||
| 111 | int width = 0; | ||
| 112 | int height = 0; | ||
| 113 | int primaryPlaneId = -1; // DRM primary plane for this connector's CRTC | ||
| 114 | VideoCropRect crop; // region of the source this display shows | ||
| 115 | // "fit", "fill" or "stretch". "fill" needs nothing here -- it is | ||
| 116 | // already folded into `crop` as a target aspect -- but "stretch" has | ||
| 117 | // to defeat the aspect-preserving scale kmssink always does, so the | ||
| 118 | // caller needs to know about it. See CreateScaleElements. | ||
| 119 | std::string scaling = "fit"; | ||
| 120 | }; | ||
| 121 | |||
| 122 | /// True when an overlay or RTP member of a playback group (no fixed | ||
| 123 | /// source) takes stream slot `streamSlot`. Those read the slot's | ||
| 124 | /// intervideo channel, which media playback publishes only when asked. | ||
| 125 | bool HasSlotChannelConsumers(int streamSlot) const; | ||
| 126 | |||
| 127 | /// Return HDMI consumer info for consumers that match the given stream | ||
| 128 | /// slot but whose connectorId is NOT in skipConnectorIds. | ||
| 129 | std::vector<HdmiConsumerInfo> GetHdmiConsumers( | ||
| 130 | int streamSlot, | ||
| 131 | const std::set<int>& skipConnectorIds = {}) const; | ||
| 132 | |||
| 133 | /// Region configured for the display on `connectorId`, whole frame if none. | ||
| 134 | /// Unlike GetHdmiConsumers this does not skip the connector driven by the | ||
| 135 | /// primary pipeline — that display is one half of a split too, so its | ||
| 136 | /// kmssink needs the crop even though no consumer pipeline is started for | ||
| 137 | /// it. Matches the on-demand consumer entries and the stream-slot filter | ||
| 138 | /// exactly as GetHdmiConsumers does. | ||
| 139 | VideoCropRect GetHdmiCropForConnector(int streamSlot, int connectorId) const; | ||
| 140 | |||
| 141 | /// Scaling mode configured for the display on `connectorId`, "fit" if none. | ||
| 142 | /// Matches entries exactly as GetHdmiCropForConnector does, and for the | ||
| 143 | /// same reason: the primary display is a configured output too. | ||
| 144 | std::string GetHdmiScalingForConnector(int streamSlot, int connectorId) const; | ||
| 145 | |||
| 146 | #ifdef HAS_GSTREAMER_VIDEO_OUTPUT | ||
| 147 | /// Build a videocrop element that resolves `crop` against the negotiated | ||
| 148 | /// caps at runtime. Returns nullptr when the whole frame is selected (no | ||
| 149 | /// element needed) or when the crop is invalid/videocrop is unavailable. | ||
| 150 | /// The caller owns the floating ref — add it to a bin as usual. | ||
| 151 | /// | ||
| 152 | /// Place it immediately upstream of kmssink: kmssink advertises | ||
| 153 | /// GstVideoCropMeta, so videocrop then crops by metadata (it becomes the | ||
| 154 | /// DRM plane's source rectangle, applied by the display hardware during | ||
| 155 | /// scanout) instead of copying pixels. With any other element in between | ||
| 156 | /// it silently falls back to a full per-frame copy. | ||
| 157 | static GstElement* CreateCropElement(const VideoCropRect& crop, | ||
| 158 | const std::string& name); | ||
| 159 | |||
| 160 | /// Elements needed upstream of kmssink to honour `scaling`, in link order. | ||
| 161 | /// | ||
| 162 | /// Empty for "fit" and "fill": kmssink always scales its input to the | ||
| 163 | /// display preserving aspect ratio, which IS "fit", and "fill" is reached | ||
| 164 | /// by handing it an already display-shaped region (VideoCropRect:: | ||
| 165 | /// targetAspect) so that same scale covers the display exactly. Both are | ||
| 166 | /// free -- the DRM plane does the scaling during scanout. | ||
| 167 | /// | ||
| 168 | /// "stretch" is the one mode the sink cannot express: kmssink has no | ||
| 169 | /// force-aspect-ratio property, so the frame has to arrive already at the | ||
| 170 | /// display's exact size. That costs a real videoscale pass, and the | ||
| 171 | /// display size has to be known -- with displayWidth/Height <= 0 the mode | ||
| 172 | /// is dropped back to "fit" rather than guessed at. | ||
| 173 | /// | ||
| 174 | /// Place these AFTER any crop element (the region must be selected at | ||
| 175 | /// source resolution, before anything rescales the frame) and before the | ||
| 176 | /// sink. The caller owns the floating refs. | ||
| 177 | static std::vector<GstElement*> CreateScaleElements(const std::string& scaling, | ||
| 178 | int displayWidth, | ||
| 179 | int displayHeight, | ||
| 180 | const std::string& namePrefix); | ||
| 181 | #endif | ||
| 182 | |||
| 183 | /// Start all configured consumers targeting the given producer node. | ||
| 184 | /// Called by GStreamerOut after pipewiresink is attached to PipeWire graph. | ||
| 185 | /// HDMI connectors the producer's own kmssinks drive were claimed with | ||
| 186 | /// ClaimConnectorForMedia() and are skipped. | ||
| 187 | /// producerGeneration: ProducerGeneration() as read when the producer was | ||
| 188 | /// set up; if StopConsumers() has run since, the producer is gone and | ||
| 189 | /// nothing is started. 0 skips the check. | ||
| 190 | void StartConsumers(const std::string& producerNodeName, | ||
| 191 | uint64_t producerGeneration = 0); | ||
| 192 | |||
| 193 | /// Stop all running consumer pipelines. | ||
| 194 | /// Called by GStreamerOut when video playback ends. Overlay and RTP | ||
| 195 | /// members are parked rather than torn down -- PAUSED, showing nothing, | ||
| 196 | /// the model handed back -- and kept for LINGER_MS, so the next video | ||
| 197 | /// needn't build them again. | ||
| 198 | void StopConsumers(); | ||
| 199 | |||
| 200 | /// Media is about to start on this slot (a video was opened, or a | ||
| 201 | /// scheduled playlist is near): build the slot's overlay and RTP members | ||
| 202 | /// now, PAUSED, so that StartConsumers() only has to set them PLAYING, | ||
| 203 | /// and keep them for holdMs if no video comes. Building one takes about | ||
| 204 | /// half a second on a BeagleBone; done here it overlaps the producer's | ||
| 205 | /// own start. Never blocks: the work is done on the warm worker. | ||
| 206 | void Prewarm(int streamSlot, int holdMs); | ||
| 207 | |||
| 208 | /// How long a parked or prewarmed member is kept, as the audio stream | ||
| 209 | /// lingers (PipeWireOutputStream::LINGER_MS). | ||
| 210 | static constexpr int LINGER_MS = 5000; | ||
| 211 | |||
| 212 | /// Bumped by every StopConsumers(). A producer reads it when it is set | ||
| 213 | /// up and hands it back to StartConsumers(), which then refuses to start | ||
| 214 | /// consumers for a producer that has been stopped in between. | ||
| 215 | 3932 | uint64_t ProducerGeneration() const { return m_producerGeneration.load(); } | |
| 216 | |||
| 217 | /// Called by VideoInputManager when a producer source pipeline reaches | ||
| 218 | /// PLAYING and is about to deliver its first video frames. Starts any | ||
| 219 | /// persistent consumers that target the given intervideo channel. | ||
| 220 | void NotifyProducerReady(const std::string& channelName); | ||
| 221 | |||
| 222 | /// Retry the persistent consumers on a source's channel after a | ||
| 223 | /// backoff (2s, doubling to 60s; back to 2s after five quiet minutes): | ||
| 224 | /// one whose pipeline errored or ended while the source kept running, | ||
| 225 | /// or one that could not start (a display not connected yet). Runs on | ||
| 226 | /// its own worker, and only while the source runs -- a dead source | ||
| 227 | /// restarts itself and fires NotifyProducerReady(). Never takes | ||
| 228 | /// m_mutex. | ||
| 229 | void ScheduleConsumerRestart(const std::string& channelName); | ||
| 230 | /// The same retry after a fixed delay, outside the failure backoff. | ||
| 231 | void ScheduleConsumerResume(const std::string& channelName, int delayMs); | ||
| 232 | |||
| 233 | /// Media playback is about to show on this HDMI connector. A persistent | ||
| 234 | /// consumer there (a camera group, say) stops and waits; this returns | ||
| 235 | /// once its kmssink has let go of the connector, so the caller can take | ||
| 236 | /// it. Refcounted: every call needs a ReleaseConnectorFromMedia(). | ||
| 237 | void ClaimConnectorForMedia(int connectorId); | ||
| 238 | |||
| 239 | /// The media on this connector is gone (its kmssink released). Once | ||
| 240 | /// nothing else holds it, what stood aside comes back after a moment -- | ||
| 241 | /// long enough that the next video in a playlist claims the connector | ||
| 242 | /// first, rather than the consumer flashing up between them. | ||
| 243 | void ReleaseConnectorFromMedia(int connectorId); | ||
| 244 | |||
| 245 | /// Check if any consumers are configured. | ||
| 246 | bool HasConsumers() const; | ||
| 247 | |||
| 248 | /// Check if any consumers are currently running. | ||
| 249 | bool HasActiveConsumers() const; | ||
| 250 | |||
| 251 | /// Get count of running consumer pipelines. | ||
| 252 | int ActiveConsumerCount() const; | ||
| 253 | |||
| 254 | private: | ||
| 255 | 1887 | VideoOutputManager() = default; | |
| 256 | ~VideoOutputManager(); | ||
| 257 | |||
| 258 | VideoOutputManager(const VideoOutputManager&) = delete; | ||
| 259 | VideoOutputManager& operator=(const VideoOutputManager&) = delete; | ||
| 260 | |||
| 261 | /// Thread-safe overlay state shared between appsink callback and manager. | ||
| 262 | struct OverlayState { | ||
| 263 | std::mutex mtx; | ||
| 264 | PixelOverlayModel* model = nullptr; | ||
| 265 | int width = 0; | ||
| 266 | int height = 0; | ||
| 267 | bool wasDisabled = false; | ||
| 268 | std::atomic<bool> active{false}; | ||
| 269 | }; | ||
| 270 | |||
| 271 | struct ConsumerInfo { | ||
| 272 | int id = 0; | ||
| 273 | std::string name; | ||
| 274 | std::string type; // "hdmi", "overlay", "rtp" | ||
| 275 | std::string pipeWireNodeName; | ||
| 276 | |||
| 277 | // HDMI-specific | ||
| 278 | std::string connector; | ||
| 279 | std::string cardPath; | ||
| 280 | int connectorId = -1; | ||
| 281 | int width = 0; | ||
| 282 | int height = 0; | ||
| 283 | std::string scaling; // "fit", "fill", "stretch" | ||
| 284 | VideoCropRect crop; // region of the source shown on this output | ||
| 285 | // The DRM lease this consumer's kmssink runs on (fd < 0: none -- | ||
| 286 | // non-HDMI, or it couldn't get one). Released in StopConsumer's | ||
| 287 | // teardown thread, strictly after the pipeline (and its kmssink) has | ||
| 288 | // been unreffed. | ||
| 289 | DrmCards::Lease drmLease; | ||
| 290 | |||
| 291 | // Overlay-specific | ||
| 292 | std::string overlayModel; | ||
| 293 | std::shared_ptr<OverlayState> overlayState; | ||
| 294 | |||
| 295 | // RTP-specific | ||
| 296 | std::string address; | ||
| 297 | int port = 5004; | ||
| 298 | std::string rtpEncoding; // "raw", "h264", "h265", "mjpeg" | ||
| 299 | |||
| 300 | // Source video stream (e.g. "fppd_video_stream_1") | ||
| 301 | std::string sourceNode; | ||
| 302 | |||
| 303 | // Stream slots this consumer should receive (empty = all slots) | ||
| 304 | std::vector<int> streamSlots; | ||
| 305 | |||
| 306 | // When >= 0, this consumer is part of a grouped pipeline and | ||
| 307 | // the GstElement* pipeline is owned by m_consumers[groupPipelineOwnerIdx]. | ||
| 308 | // -1 means standalone (owns its own pipeline or not grouped). | ||
| 309 | int groupPipelineOwnerIdx = -1; | ||
| 310 | |||
| 311 | #ifdef HAS_GSTREAMER_VIDEO_OUTPUT | ||
| 312 | GstElement* pipeline = nullptr; | ||
| 313 | #endif | ||
| 314 | std::thread startThread; | ||
| 315 | // Shared (not raw-pointer-captured) so the bus-monitor thread spawned | ||
| 316 | // in StartConsumer can safely outlive this ConsumerInfo: Reload()/ | ||
| 317 | // Shutdown() clear() m_consumers, which would invalidate a raw | ||
| 318 | // `&consumer.shutdownRequested` pointer while the detached thread may | ||
| 319 | // still be inside gst_bus_timed_pop(). The thread captures this | ||
| 320 | // shared_ptr by value instead. Re-assigned to a fresh instance each | ||
| 321 | // time StartConsumer runs so a still-draining prior thread can never | ||
| 322 | // have its flag reset out from under it by a later restart. | ||
| 323 | std::shared_ptr<std::atomic<bool>> shutdownRequested = std::make_shared<std::atomic<bool>>(false); | ||
| 324 | // Set by the bus-monitor thread when it exits due to | ||
| 325 | // GST_MESSAGE_ERROR/EOS (i.e. the pipeline died on its own, not via | ||
| 326 | // StopConsumer). Without this, `running` stays true forever for | ||
| 327 | // persistent (sourceNode) consumers and nothing ever restarts them — | ||
| 328 | // a zombie that holds its DRM plane and CMA indefinitely. Same | ||
| 329 | // shared_ptr-per-run rationale as shutdownRequested. | ||
| 330 | std::shared_ptr<std::atomic<bool>> pipelineDead = std::make_shared<std::atomic<bool>>(false); | ||
| 331 | bool running = false; | ||
| 332 | // Stood aside for media playback on its connector, to come back when | ||
| 333 | // the media releases it (ReleaseConnectorFromMedia). | ||
| 334 | bool suspendedForMedia = false; | ||
| 335 | // An on-demand overlay/RTP member that is built but PAUSED (Prewarm, | ||
| 336 | // or parked by StopConsumers), on `channel`, until holdUntilMs. | ||
| 337 | // `running` stays true: the pipeline exists. | ||
| 338 | bool parked = false; | ||
| 339 | std::string channel; | ||
| 340 | uint64_t holdUntilMs = 0; | ||
| 341 | // The PLAYING/PAUSED state the pipeline should be in, and the lock | ||
| 342 | // its start thread, ActivateConsumer and ParkConsumer take to set it, | ||
| 343 | // so a start thread can't PAUSE a member that was just activated. | ||
| 344 | // Fresh per run, like shutdownRequested. | ||
| 345 | std::shared_ptr<std::atomic<bool>> playing = std::make_shared<std::atomic<bool>>(false); | ||
| 346 | std::shared_ptr<std::mutex> stateLock = std::make_shared<std::mutex>(); | ||
| 347 | |||
| 348 |
4/8✓ Branch 0 taken 384 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 384 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 384 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 384 times.
✗ Branch 7 not taken.
|
384 | ConsumerInfo() = default; |
| 349 | 633 | ConsumerInfo(ConsumerInfo&& o) noexcept | |
| 350 | 633 | : id(o.id), name(std::move(o.name)), type(std::move(o.type)), | |
| 351 | 633 | pipeWireNodeName(std::move(o.pipeWireNodeName)), | |
| 352 | 633 | connector(std::move(o.connector)), cardPath(std::move(o.cardPath)), | |
| 353 | 633 | connectorId(o.connectorId), width(o.width), height(o.height), | |
| 354 | 633 | scaling(std::move(o.scaling)), crop(o.crop), | |
| 355 | 633 | drmLease(o.drmLease), | |
| 356 | 633 | overlayModel(std::move(o.overlayModel)), | |
| 357 | 633 | overlayState(std::move(o.overlayState)), | |
| 358 | 633 | address(std::move(o.address)), port(o.port), | |
| 359 | 633 | rtpEncoding(std::move(o.rtpEncoding)), | |
| 360 | 633 | sourceNode(std::move(o.sourceNode)), | |
| 361 | 633 | streamSlots(std::move(o.streamSlots)), | |
| 362 | 633 | groupPipelineOwnerIdx(o.groupPipelineOwnerIdx), | |
| 363 | #ifdef HAS_GSTREAMER_VIDEO_OUTPUT | ||
| 364 | 633 | pipeline(o.pipeline), | |
| 365 | #endif | ||
| 366 | 633 | startThread(std::move(o.startThread)), | |
| 367 | 633 | shutdownRequested(std::move(o.shutdownRequested)), | |
| 368 | 633 | pipelineDead(std::move(o.pipelineDead)), | |
| 369 | 633 | running(o.running), suspendedForMedia(o.suspendedForMedia), | |
| 370 | 633 | parked(o.parked), channel(std::move(o.channel)), holdUntilMs(o.holdUntilMs), | |
| 371 | 633 | playing(std::move(o.playing)), stateLock(std::move(o.stateLock)) { | |
| 372 | #ifdef HAS_GSTREAMER_VIDEO_OUTPUT | ||
| 373 | 633 | o.pipeline = nullptr; | |
| 374 | #endif | ||
| 375 | 633 | } | |
| 376 | ConsumerInfo(const ConsumerInfo&) = delete; | ||
| 377 | ConsumerInfo& operator=(const ConsumerInfo&) = delete; | ||
| 378 | }; | ||
| 379 | |||
| 380 | /// Load consumer config from JSON file. | ||
| 381 | bool LoadConfig(); | ||
| 382 | |||
| 383 | /// Start a single consumer pipeline, on `channel` (default: its | ||
| 384 | /// sourceNode, or the active producer), PLAYING or (startPlaying false) | ||
| 385 | /// built and PAUSED. | ||
| 386 | bool StartConsumer(ConsumerInfo& consumer, const std::string& channel = "", | ||
| 387 | bool startPlaying = true); | ||
| 388 | |||
| 389 | /// Set a parked member PLAYING and let it show again. | ||
| 390 | void ActivateConsumer(ConsumerInfo& consumer); | ||
| 391 | /// Pause a member, hand its overlay model back, and keep it until | ||
| 392 | /// holdUntilMs. | ||
| 393 | void ParkConsumer(ConsumerInfo& consumer, uint64_t holdUntilMs); | ||
| 394 | /// An on-demand overlay or RTP member that serves this stream slot. | ||
| 395 | static bool IsSlotChannelMember(const ConsumerInfo& c, int streamSlot); | ||
| 396 | |||
| 397 | /// Start a group of HDMI consumers sharing the same PipeWire source | ||
| 398 | /// as a single combined pipeline with tee. PipeWire mode=provide | ||
| 399 | /// doesn't fan-out buffers to multiple consumers; this works around | ||
| 400 | /// that by using one pipewiresrc with a GStreamer tee. | ||
| 401 | bool StartHdmiConsumerGroup(const std::vector<size_t>& indices); | ||
| 402 | |||
| 403 | /// Stop a single consumer pipeline. | ||
| 404 | void StopConsumer(ConsumerInfo& consumer); | ||
| 405 | |||
| 406 | /// Stop all consumers. | ||
| 407 | void StopAllConsumers(); | ||
| 408 | |||
| 409 | /// Write an SDP file for an RTP consumer so receivers (VLC, ffplay) can connect. | ||
| 410 | void WriteSdpFile(const ConsumerInfo& consumer, const std::string& encoding); | ||
| 411 | |||
| 412 | /// Build the SDP body for an RTP consumer. | ||
| 413 | std::string BuildSdp(const ConsumerInfo& consumer, const std::string& encoding); | ||
| 414 | |||
| 415 | /// Build a raw RFC 2974 SAP packet wrapping the given SDP. | ||
| 416 | static std::vector<uint8_t> BuildSAPPacket(const std::string& sourceIP, | ||
| 417 | uint16_t msgIdHash, | ||
| 418 | const std::string& sdp, | ||
| 419 | bool isDeletion = false); | ||
| 420 | |||
| 421 | /// Start/stop the SAP multicast announcer thread. | ||
| 422 | /// | ||
| 423 | /// Both MUST be called with m_mutex NOT held: they join m_sapThread, and | ||
| 424 | /// SAPAnnounceLoop() takes m_mutex, so joining under it deadlocks. Every | ||
| 425 | /// call site already does this deliberately. | ||
| 426 | void StartSAPAnnouncer(); | ||
| 427 | bool HasRunningRtpLocked() const; // caller holds m_mutex | ||
| 428 | void StopSAPAnnouncer(); | ||
| 429 | |||
| 430 | /// SAP announcer background loop. | ||
| 431 | void SAPAnnounceLoop(); | ||
| 432 | |||
| 433 | std::thread m_sapThread; | ||
| 434 | std::atomic<bool> m_sapRunning{false}; | ||
| 435 | /// Serializes the announcer's lifecycle (m_sapThread + m_sapRunning as a | ||
| 436 | /// pair). Deliberately NOT m_mutex: SAPAnnounceLoop() takes m_mutex, and | ||
| 437 | /// Stop joins that thread, so the two must stay separate -- which is also | ||
| 438 | /// why the call sites all sit outside m_mutex. SAPAnnounceLoop() never | ||
| 439 | /// takes this lock, so there is no cycle. | ||
| 440 | /// | ||
| 441 | /// m_sapRunning alone was not enough: it was tested and set as two steps | ||
| 442 | /// from unsynchronized threads (a detached GStreamer start thread reaching | ||
| 443 | /// StartConsumers(), and the command/API thread reaching Reload()), so two | ||
| 444 | /// callers could both pass the check and both assign m_sapThread -- | ||
| 445 | /// assigning over a joinable std::thread calls std::terminate(). | ||
| 446 | std::mutex m_sapLock; | ||
| 447 | |||
| 448 | #ifdef HAS_GSTREAMER_VIDEO_OUTPUT | ||
| 449 | /// Appsink callback for overlay consumers (runs on GStreamer streaming thread). | ||
| 450 | static GstFlowReturn OnOverlaySample(GstAppSink* appsink, gpointer userData); | ||
| 451 | #endif | ||
| 452 | |||
| 453 | /// Wait for pending pipeline teardown threads to finish (releases DRM/CMA). | ||
| 454 | /// Returns true if all teardowns completed, false on timeout. | ||
| 455 | bool WaitForPendingTeardowns(int timeoutMs); | ||
| 456 | // CmaFree from /proc/meminfo in kB, or -1. | ||
| 457 | static long ReadCmaFreeKB(); | ||
| 458 | static constexpr long CMA_WARN_KB = 20480; | ||
| 459 | // WaitForPendingTeardowns(), but only when CMA is short. Never call it | ||
| 460 | // with m_mutex held. | ||
| 461 | void WaitForCmaIfLow(); | ||
| 462 | |||
| 463 | // ScheduleConsumerRestart()'s worker. m_restartMutex guards these; it | ||
| 464 | // is taken after m_mutex, never before. | ||
| 465 | struct RestartState { | ||
| 466 | std::chrono::steady_clock::time_point due; | ||
| 467 | std::chrono::steady_clock::time_point lastFailure; | ||
| 468 | int delayMs = 0; | ||
| 469 | bool pending = false; | ||
| 470 | }; | ||
| 471 | std::mutex m_restartMutex; | ||
| 472 | std::condition_variable m_restartCv; | ||
| 473 | std::thread m_restartThread; | ||
| 474 | bool m_restartRunning = false; | ||
| 475 | bool m_stopRestarts = false; | ||
| 476 | std::map<std::string, RestartState> m_restarts; | ||
| 477 | static constexpr int RESTART_MIN_DELAY_MS = 2000; | ||
| 478 | static constexpr int RESTART_MAX_DELAY_MS = 60000; | ||
| 479 | static constexpr int RESTART_QUIET_RESET_MS = 5 * 60 * 1000; | ||
| 480 | void RestartWorker(); | ||
| 481 | |||
| 482 | // Prewarm()'s worker: builds the requested slots' members and tears down | ||
| 483 | // parked ones whose hold has run out. m_warmMutex guards these; it is | ||
| 484 | // taken after m_mutex, never before (the worker drops it first). | ||
| 485 | std::mutex m_warmMutex; | ||
| 486 | std::condition_variable m_warmCv; | ||
| 487 | std::thread m_warmThread; | ||
| 488 | bool m_stopWarm = false; | ||
| 489 | std::map<int, uint64_t> m_prewarmRequests; // slot -> hold until (GetTimeMS) | ||
| 490 | void KickWarmWorker(); | ||
| 491 | void WarmWorker(); | ||
| 492 | void ServiceWarm(const std::map<int, uint64_t>& requests); | ||
| 493 | |||
| 494 | mutable std::mutex m_mutex; | ||
| 495 | std::vector<ConsumerInfo> m_consumers; | ||
| 496 | std::string m_activeProducer; // node name of the current producer, empty if none | ||
| 497 | std::atomic<uint64_t> m_producerGeneration{ 1 }; | ||
| 498 | // HDMI connectors media playback holds (ClaimConnectorForMedia), with how | ||
| 499 | // many claims each. A persistent consumer stands aside on these. | ||
| 500 | std::map<int, int> m_mediaConnectors; | ||
| 501 | static constexpr int MEDIA_RESUME_DELAY_MS = 1500; | ||
| 502 | bool m_initialized = false; | ||
| 503 | // Guards Shutdown() so the destructor doesn't re-run it after main() already | ||
| 504 | // did (the "Shutdown complete" was logged twice). Harmless today since it | ||
| 505 | // only locks our own mutex, but keeps teardown single-shot and future-proof. | ||
| 506 | std::atomic<bool> m_shutdownDone{false}; | ||
| 507 | |||
| 508 | // Pipeline teardown tracking — detached threads decrement the counter | ||
| 509 | // and notify the CV when done, so callers can wait for CMA to be freed. | ||
| 510 | std::mutex m_teardownMutex; | ||
| 511 | std::condition_variable m_teardownCV; | ||
| 512 | int m_pendingTeardowns = 0; | ||
| 513 | // Up while a teardown has outlived WaitForPendingTeardowns(); comes down | ||
| 514 | // when the last one finishes. Under m_teardownMutex. | ||
| 515 | bool m_teardownStuckWarned = false; | ||
| 516 | static constexpr const char* TEARDOWN_STUCK_WARNING = | ||
| 517 | "Video output: a pipeline teardown is stuck (its display memory is not freed)"; | ||
| 518 | }; | ||
| 519 |