GCC Code Coverage Report


Directory: ./
File: mediaoutput/VideoOutputManager.h
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 32 32 100.0%
Functions: 6 6 100.0%
Branches: 18 30 60.0%

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