Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qsyntheticpipewirevideosource.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
3
5
6#include <QtCore/qdebug.h>
7#include <QtCore/qhash.h>
8#include <QtCore/qmutex.h>
9
10#include <spa/param/video/format-utils.h>
11#include <spa/pod/builder.h>
12#include <spa/utils/result.h>
13#include <pipewire/pipewire.h>
14
15#include <algorithm>
16#include <array>
17#include <cstring>
18#include <mutex>
19#include <optional>
20#include <string_view>
21#include <unordered_map>
22#include <unordered_set>
23#include <vector>
24
25QT_BEGIN_NAMESPACE
26
27using namespace Qt::StringLiterals;
28using namespace std::chrono_literals;
29
30namespace {
31
32// Deliberately generous: the consumer picks its own pacing, we just need to be
33// at least as fast as the fastest frame rate a test asks for.
34constexpr spa_fraction FrameRate = SPA_FRACTION(60, 1);
35
36constexpr auto NodeIdTimeout = 5s;
37
38int strideForWidth(int width)
39{
40 return SPA_ROUND_UP_N(width * 4, 4);
41}
42
43} // namespace
44
45QSyntheticPipeWireVideoSourceConfig QSyntheticPipeWireVideoSourceConfig::screenCast()
46{
47 return {};
48}
49
50QSyntheticPipeWireVideoSourceConfig QSyntheticPipeWireVideoSourceConfig::camera(int index)
51{
52 const QByteArray suffix = QByteArray::number(index);
53 QSyntheticPipeWireVideoSourceConfig config;
54 config.loopName = "qt-fake-cam"_ba + suffix;
55 config.nodeName = "qt-fake-camera-"_ba + suffix;
56 config.nodeDescription = "Qt Multimedia synthetic camera "_ba + suffix;
57 config.mediaClass = "Video/Source"_ba;
58 config.mediaRole = "Camera"_ba;
59 return config;
60}
61
63{
64public:
66
67 // --- pipewire thread-loop callbacks -------------------------------------
68 static void onStateChanged(void *userData, pw_stream_state, pw_stream_state state,
69 const char *error);
70 static void onParamChanged(void *userData, uint32_t id, const spa_pod *param);
71 static void onProcess(void *userData);
72 static void onRegistryGlobal(void *userData, uint32_t id, uint32_t permissions,
73 const char *type, uint32_t version, const spa_dict *props);
74
75 void linkTo(ObjectId inputPort);
76 void considerPort(ObjectId port, uint32_t consumerNode);
77
78 QSyntheticPipeWireVideoSourceConfig config;
79
84 spa_hook registryListener = {};
86 spa_hook streamListener = {};
87
88 ObjectId nodeId{ SPA_ID_INVALID };
89 ObjectId outputPort{ SPA_ID_INVALID };
90 // Input ports seen before their node's properties arrived; globals can come in
91 // either order. Keyed by the node the port belongs to.
93 // QHash::value() gives a default-constructed (empty) QByteArray for an unknown
94 // node without a separate lookup, which is exactly what considerPort() wants.
98
101 QImage frame; // guarded by sceneMutex
102 unsigned tick = 0; // guarded by sceneMutex
103
104 bool started = false;
105};
106
107void QSyntheticPipeWireVideoSource::Private::onStateChanged(void *userData, pw_stream_state,
108 pw_stream_state state,
109 const char *error)
110{
111 auto *self = static_cast<Private *>(userData);
112 if (error)
113 qWarning() << "QSyntheticPipeWireVideoSource: stream error:" << error;
114
115 if (state == PW_STREAM_STATE_PAUSED || state == PW_STREAM_STATE_STREAMING) {
116 // The node exists on the server from PAUSED onwards, so this is the
117 // earliest point the id can be reported to a portal client.
118 const ObjectId id{ pw_stream_get_node_id(self->stream.get()) };
119 if (id.value != SPA_ID_INVALID && self->nodeId != id) {
120 self->nodeId = id;
121 self->threadLoop->signal(false);
122 }
123 }
124}
125
126void QSyntheticPipeWireVideoSource::Private::onParamChanged(void *userData, uint32_t id,
127 const spa_pod *param)
128{
129 auto *self = static_cast<Private *>(userData);
130 if (!param || id != SPA_PARAM_Format)
131 return;
132
133 spa_video_info info = {};
134 if (spa_format_parse(param, &info.media_type, &info.media_subtype) < 0)
135 return;
136 if (info.media_type != SPA_MEDIA_TYPE_video || info.media_subtype != SPA_MEDIA_SUBTYPE_raw)
137 return;
138 if (spa_format_video_raw_parse(param, &info.info.raw) < 0)
139 return;
140
141 const int stride = strideForWidth(int(info.info.raw.size.width));
142 const int size = stride * int(info.info.raw.size.height);
143
144 std::array<uint8_t, 1024> buffer;
145 spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size());
146 const spa_pod *params[1];
147 params[0] = static_cast<const spa_pod *>(spa_pod_builder_add_object(
148 &builder, SPA_TYPE_OBJECT_ParamBuffers, SPA_PARAM_Buffers, SPA_PARAM_BUFFERS_buffers,
149 SPA_POD_CHOICE_RANGE_Int(4, 2, 8), SPA_PARAM_BUFFERS_blocks, SPA_POD_Int(1),
150 SPA_PARAM_BUFFERS_size, SPA_POD_Int(size), SPA_PARAM_BUFFERS_stride,
151 SPA_POD_Int(stride), SPA_PARAM_BUFFERS_dataType,
152 SPA_POD_CHOICE_FLAGS_Int((1 << SPA_DATA_MemFd) | (1 << SPA_DATA_MemPtr))));
153
154 pw_stream_update_params(self->stream.get(), params, 1);
155}
156
158{
159 auto *self = static_cast<Private *>(userData);
160
161 pw_buffer *pwBuffer = pw_stream_dequeue_buffer(self->stream.get());
162 if (!pwBuffer)
163 return; // consumer is not keeping up; simply skip this cycle
164
165 spa_buffer *buffer = pwBuffer->buffer;
166 auto *destination = static_cast<uint8_t *>(buffer->datas[0].data);
167 if (!destination) {
168 pw_stream_queue_buffer(self->stream.get(), pwBuffer);
169 return;
170 }
171
172 int height = 0;
173 int stride = 0;
174 {
175 QMutexLocker locker(&self->sceneMutex);
176 if (self->frame.isNull()) {
177 pw_stream_queue_buffer(self->stream.get(), pwBuffer);
178 return;
179 }
180 height = self->frame.height();
181 stride = strideForWidth(self->frame.width());
182
183 const qsizetype copyBytes = std::min<qsizetype>(stride, self->frame.bytesPerLine());
184 for (int y = 0; y < height; ++y)
185 memcpy(destination + qsizetype(y) * stride, self->frame.constScanLine(y), copyBytes);
186 }
187
188 buffer->datas[0].chunk->offset = 0;
189 buffer->datas[0].chunk->stride = stride;
190 buffer->datas[0].chunk->size = uint32_t(stride * height);
191
192 pw_stream_queue_buffer(self->stream.get(), pwBuffer);
193}
194
196{
197 if (outputPort.value == SPA_ID_INVALID)
198 return;
199
200 const QByteArray out = QByteArray::number(outputPort.value);
201 const QByteArray in = QByteArray::number(inputPort.value);
202
203 QtPipeWire::PwPropertiesHandle props{
204 pw_properties_new(PW_KEY_LINK_OUTPUT_PORT, out.constData(), PW_KEY_LINK_INPUT_PORT,
205 in.constData(), PW_KEY_OBJECT_LINGER, "false", nullptr),
206 };
207 // pw_core_create_object() is a macro yielding void *.
208 QtPipeWire::PwProxyHandle link{
209 static_cast<pw_proxy *>(pw_core_create_object(core.get(), "link-factory",
210 PW_TYPE_INTERFACE_Link, PW_VERSION_LINK,
211 &props->dict, 0)),
212 };
213
214 if (!link) {
215 qWarning() << "QSyntheticPipeWireVideoSource: failed to link port" << outputPort.value
216 << "->" << inputPort.value;
217 return;
218 }
219 links.push_back(std::move(link));
220}
221
222/*!
223 Decides whether \a port, belonging to \a consumerNode, is meant for us, and
224 links it if so. Called once the node's properties and the port are both known.
225*/
226void QSyntheticPipeWireVideoSource::Private::considerPort(ObjectId port, uint32_t consumerNode)
227{
228 const QByteArray role = consumerRoles.value(consumerNode);
229 if (role == config.mediaRole) {
230 linkTo(port);
231 return;
232 }
233
234 // Not ours. Warn once per node: a consumer that never gets linked simply sits
235 // in "paused" forever, which is otherwise indistinguishable from a hang.
236 if (mismatchWarned.insert(consumerNode).second) {
237 qWarning() << "QSyntheticPipeWireVideoSource(" << config.mediaRole
238 << "): not linking consumer node" << consumerNode << "with media.role"
239 << (role.isEmpty() ? "<unset>"_ba : role);
240 }
241}
242
243void QSyntheticPipeWireVideoSource::Private::onRegistryGlobal(void *userData, uint32_t id, uint32_t,
244 const char *type, uint32_t,
245 const spa_dict *props)
246{
247 auto *self = static_cast<Private *>(userData);
248 if (!props)
249 return;
250
251 if (std::string_view(type) == PW_TYPE_INTERFACE_Node) {
252 if (ObjectId{ id } == self->nodeId)
253 return;
254
255 const char *role = spa_dict_lookup(props, PW_KEY_MEDIA_ROLE);
256 self->consumerRoles.insert(id, role ? QByteArray(role) : QByteArray());
257
258 // Drain ports that arrived before we knew what this node was.
259 for (ObjectId port : std::exchange(self->pendingInputPorts[id], {}))
260 self->considerPort(port, id);
261 self->pendingInputPorts.erase(id);
262 return;
263 }
264
265 if (std::string_view(type) != PW_TYPE_INTERFACE_Port)
266 return;
267
268 const char *direction = spa_dict_lookup(props, PW_KEY_PORT_DIRECTION);
269 const char *portNodeId = spa_dict_lookup(props, PW_KEY_NODE_ID);
270 if (!direction || !portNodeId)
271 return;
272
273 const ObjectId portId{ id };
274 const ObjectId nodeOfPort{ uint32_t(QByteArray(portNodeId).toUInt()) };
275
276 if (std::string_view(direction) == "out" && nodeOfPort == self->nodeId) {
277 self->outputPort = portId;
278 // Our own output port may show up after some consumers; reconsider them.
279 for (const auto &[node, ports] : std::exchange(self->pendingInputPorts, {})) {
280 for (ObjectId port : ports)
281 self->considerPort(port, node);
282 }
283 return;
284 }
285
286 if (std::string_view(direction) == "in" && nodeOfPort != self->nodeId) {
287 // Wait until both our output port and the consumer's identity are known.
288 if (self->outputPort.value == SPA_ID_INVALID
289 || !self->consumerRoles.contains(nodeOfPort.value)) {
290 self->pendingInputPorts[nodeOfPort.value].push_back(portId);
291 return;
292 }
293 self->considerPort(portId, nodeOfPort.value);
294 }
295}
296
297// ---------------------------------------------------------------------------
298
300
305
306q23::expected<void, QString>
307QSyntheticPipeWireVideoSource::start(const SyntheticVideoScene &scene,
308 const QSyntheticPipeWireVideoSourceConfig &config)
309{
310 using namespace QtPipeWire;
311 if (d->started)
312 return q23::unexpected(u"already started"_s);
313
314 d->config = config;
315 setScene(scene);
316
317 pw_init(nullptr, nullptr);
318
319 d->threadLoop.emplace(config.loopName.constData());
320 if (!*d->threadLoop)
321 return q23::unexpected(u"pw_thread_loop_new() failed"_s);
322
323 if (d->threadLoop->start() != 0)
324 return q23::unexpected(u"pw_thread_loop_start() failed"_s);
325
326 std::lock_guard threadLoopLock{ *d->threadLoop };
327 return [&]() -> q23::expected<void, QString> {
328 d->context = PwContextHandle{
329 pw_context_new(d->threadLoop->loop(), nullptr, 0),
330 };
331 if (!d->context)
332 return q23::unexpected(u"pw_context_new() failed"_s);
333
334 d->core = PwCoreConnectionHandle{
335 pw_context_connect(d->context.get(), nullptr, 0),
336 };
337 if (!d->core) {
338 return q23::unexpected(u"pw_context_connect() failed: "_s
339 + QString::fromLocal8Bit(strerror(errno)));
340 }
341
342 static constexpr pw_registry_events registryEvents = {
343 .version = PW_VERSION_REGISTRY_EVENTS,
344 .global = &Private::onRegistryGlobal,
345 .global_remove = nullptr,
346 };
347 d->registry = PwRegistryHandle{
348 pw_core_get_registry(d->core.get(), PW_VERSION_REGISTRY, 0),
349 };
350 pw_registry_add_listener(d->registry.get(), &d->registryListener, &registryEvents, d.get());
351
352 // An output stream that sets media.class implements a Source rather than a
353 // plain playback stream; see the pw_stream documentation.
354 d->stream = PwStreamHandle{
355 pw_stream_new(d->core.get(), config.nodeName.constData(),
356 pw_properties_new(PW_KEY_MEDIA_CLASS, config.mediaClass.constData(),
357 PW_KEY_MEDIA_TYPE, "Video", PW_KEY_MEDIA_CATEGORY,
358 config.mediaCategory.constData(), PW_KEY_MEDIA_ROLE,
359 config.mediaRole.constData(), PW_KEY_NODE_NAME,
360 config.nodeName.constData(), PW_KEY_NODE_DESCRIPTION,
361 config.nodeDescription.constData(), nullptr)),
362 };
363 if (!d->stream)
364 return q23::unexpected(u"pw_stream_new() failed"_s);
365
366 static constexpr pw_stream_events streamEvents = {
367 .version = PW_VERSION_STREAM_EVENTS,
368 .destroy = nullptr,
369 .state_changed = &Private::onStateChanged,
370 .control_info = nullptr,
371 .io_changed = nullptr,
372 .param_changed = &Private::onParamChanged,
373 .add_buffer = nullptr,
374 .remove_buffer = nullptr,
375 .process = &Private::onProcess,
376 .drained = nullptr,
377 .command = nullptr,
378 .trigger_done = nullptr,
379 };
380 pw_stream_add_listener(d->stream.get(), &d->streamListener, &streamEvents, d.get());
381
382 std::array<uint8_t, 1024> buffer;
383 spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size());
384 spa_rectangle size = SPA_RECTANGLE(uint32_t(scene.frameSize.width()),
385 uint32_t(scene.frameSize.height()));
386 spa_fraction frameRate = FrameRate;
387 const spa_pod *params[1];
388 params[0] = static_cast<const spa_pod *>(spa_pod_builder_add_object(
389 &builder, SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat, SPA_FORMAT_mediaType,
390 SPA_POD_Id(SPA_MEDIA_TYPE_video), SPA_FORMAT_mediaSubtype,
391 SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw), SPA_FORMAT_VIDEO_format,
392 SPA_POD_Id(SPA_VIDEO_FORMAT_BGRx), SPA_FORMAT_VIDEO_size, SPA_POD_Rectangle(&size),
393 SPA_FORMAT_VIDEO_framerate, SPA_POD_Fraction(&frameRate)));
394
395 // DRIVER: with no session manager in the graph nothing else would drive it,
396 // so we clock it ourselves from renderNextFrame().
397 const int error = pw_stream_connect(
398 d->stream.get(), PW_DIRECTION_OUTPUT, PW_ID_ANY,
399 pw_stream_flags(PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_DRIVER), params, 1);
400 if (error < 0) {
401 return q23::unexpected(u"pw_stream_connect() failed: "_s
402 + QString::fromLocal8Bit(spa_strerror(error)));
403 }
404
405 // Wait for the node id: a portal must not answer Start() without it.
406 while (d->nodeId.value == SPA_ID_INVALID) {
407 if (d->threadLoop->wait_for(NodeIdTimeout) != 0)
408 return q23::unexpected(u"timed out waiting for the pipewire node id"_s);
409 }
410
411 d->started = true;
412 return {};
413 }();
414}
415
417{
418 if (!d->threadLoop)
419 return;
420
421 {
422 std::lock_guard threadLoopLock{ *d->threadLoop };
423 d->links.clear();
424 d->stream.reset();
425 d->registry.reset();
426 d->core.reset();
427 }
428
429 d->threadLoop->stop();
430 d->context.reset();
431 d->threadLoop.reset();
432
433 d->nodeId = QtPipeWire::ObjectId{ SPA_ID_INVALID };
434 d->outputPort = QtPipeWire::ObjectId{ SPA_ID_INVALID };
435 d->started = false;
436}
437
439{
440 return d->nodeId;
441}
442
443void QSyntheticPipeWireVideoSource::setScene(const SyntheticVideoScene &scene)
444{
445 QMutexLocker locker(&d->sceneMutex);
446 d->scene = scene;
447 d->tick = 0;
448 d->frame = renderSyntheticVideoScene(scene, 0);
449}
450
452{
453 {
454 QMutexLocker locker(&d->sceneMutex);
455 if (d->scene.animated)
456 d->frame = renderSyntheticVideoScene(d->scene, ++d->tick);
457 }
458
459 if (!d->stream)
460 return;
461
462 std::lock_guard threadLoopLock{ *d->threadLoop };
463 pw_stream_trigger_process(d->stream.get());
464}
465
466QT_END_NAMESPACE
std::vector< QtPipeWire::PwProxyHandle > links
static void onStateChanged(void *userData, pw_stream_state, pw_stream_state state, const char *error)
static void onParamChanged(void *userData, uint32_t id, const spa_pod *param)
void considerPort(ObjectId port, uint32_t consumerNode)
Decides whether port, belonging to consumerNode, is meant for us, and links it if so.
static void onRegistryGlobal(void *userData, uint32_t id, uint32_t permissions, const char *type, uint32_t version, const spa_dict *props)
std::optional< QtPipeWire::PWThreadedEventLoop > threadLoop
QtPipeWire::ObjectId nodeId() const
The PipeWire node id to hand to a consumer. Valid only after a successful start().
void setScene(const SyntheticVideoScene &scene)
Replaces the published content. Safe to call from any thread.
void renderNextFrame()
Renders and publishes the next frame. Safe to call from any thread.