Skip to content

Commit 36aa706

Browse files
committed
fix(pipewire): planar dmabuf block count + reliable gst integration tests
Capture of a planar format (I420/NV12) declared a single buffer block, so the daemon rejected allocation ("alloc buffers: Invalid argument") - the producer carries one spa_data block per plane for host memory and DMA-BUF alike. Declare blocks = plane count. Make the gst integration tests deterministic: pipewiresink needs mode=provide to serve as a source (without it an Audio/Source advertises a node but never feeds samples, so audio capture reached Streaming yet timed out); the A/V shared-clock test collects over a polled window and tolerates the gst dual-sink startup race. Replace the gst-GL dmabuf test with an in-process round-trip: pipewiresink's dmabuf EXPORT support varies by distro/driver/gst build (this box advertises no DRM modifier at all), so drive a libgbm-backed PipeWireVideoOutput dmabuf producer into a PipeWireVideoCapture consumer instead - deterministic wherever a render node + libgbm exist, with zero gst dependency.
1 parent decf073 commit 36aa706

5 files changed

Lines changed: 267 additions & 65 deletions

File tree

src/PipeWire.NET/PipeWireVideoCapture.cs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Runtime.Versioning;
2+
using Microsoft.Extensions.Logging;
23
using PipeWire.NET.Generated;
34
using PipeWire.NET.Spa;
45

@@ -13,7 +14,7 @@ namespace PipeWire.NET;
1314
/// is a <see langword="ref struct"/> whose data is valid only for the duration of the handler.
1415
/// </remarks>
1516
[SupportedOSPlatform("linux")]
16-
public sealed class PipeWireVideoCapture : IAsyncDisposable
17+
public sealed partial class PipeWireVideoCapture : IAsyncDisposable
1718
{
1819
/// <summary>Wildcard node id - let PipeWire auto-select a source.</summary>
1920
public const uint AnyNode = Native.PW_ID_ANY;
@@ -32,6 +33,7 @@ public sealed class PipeWireVideoCapture : IAsyncDisposable
3233

3334
private readonly PipeWireContext _ctx;
3435
private readonly string _name;
36+
private readonly ILogger _logger;
3537
private PipeWireStreamCore? _core;
3638
private ulong _sequence;
3739
private SpaFormat.VideoFormatInfo _fmt = new(PixelFormat.Bgra, 0, 0, VideoColorInfo.Unknown);
@@ -57,6 +59,7 @@ public PipeWireVideoCapture(PipeWireContext context, string name = "PipeWire.NET
5759
ArgumentException.ThrowIfNullOrEmpty(name);
5860
_ctx = context;
5961
_name = name;
62+
_logger = context.LoggerFactory.CreateLogger($"PipeWire.NET.{name}");
6063
}
6164

6265
/// <summary>Connects to a discovered source.</summary>
@@ -167,8 +170,11 @@ private unsafe void OnBuffer(spa_data* d, pw_buffer* buf, in PipeWireStreamCore.
167170
private void OnState(PipeWireStreamState oldState, PipeWireStreamState newState) =>
168171
StateChanged?.Invoke(this, oldState, newState);
169172

170-
private unsafe void OnFormat(spa_pod* param) =>
173+
private unsafe void OnFormat(spa_pod* param)
174+
{
171175
_fmt = SpaFormat.ParseVideoFormat(param, _fmt);
176+
LogNegotiatedFormat(_fmt.Format, _fmt.Width, _fmt.Height, _fmt.Modifier, _fmt.ModifierNeedsFixation);
177+
}
172178

173179
// After the format is negotiated we know the geometry, so declare our buffer needs:
174180
// accept host memory AND DMA-BUF (zero-copy GPU), plus request the PTS header meta.
@@ -199,11 +205,22 @@ private void OnPostFormat(PipeWireStreamCore core)
199205
int size = SpaFormat.VideoImageSize(_fmt.Format, _fmt.Width, _fmt.Height);
200206
if (size <= 0) { core.RequestParams(meta[..ml]); return; } // geometry not known yet
201207

202-
// Canonical buffer param (size/stride correct for packed or planar) advertising that we
203-
// accept DMA-BUF and host memory - a GPU producer can then hand us zero-copy buffers.
208+
// Block count = number of planes. A planar format (I420=3, NV12=2) is carried as one spa_data
209+
// block per plane for BOTH host memory (one MemFd per plane) and DMA-BUF (one fd per plane) -
210+
// gst's pipewiresink splits the planes either way. Declaring a single block for a multi-plane
211+
// format makes the daemon reject buffer allocation ("alloc buffers: Invalid argument"); packed
212+
// formats are a single block. Offer host memory and DMA-BUF so a GPU producer can go zero-copy.
213+
int blocks = SpaFormat.VideoPlaneCount(_fmt.Format);
204214
Span<byte> buffers = stackalloc byte[256];
205-
int bl = SpaFormat.WriteVideoBuffersParam(buffers, size, stride, SpaFormat.VideoCaptureDataTypeMask);
215+
int bl = SpaFormat.WriteVideoBuffersParam(buffers, size, stride, SpaFormat.VideoCaptureDataTypeMask, blocks);
206216

217+
LogRequestedBuffers(blocks, size, stride, SpaFormat.VideoCaptureDataTypeMask);
207218
core.RequestParams(buffers[..bl], meta[..ml]);
208219
}
220+
221+
[LoggerMessage(Level = LogLevel.Debug, Message = "negotiated format {Format} {Width}x{Height} modifier=0x{Modifier:x} needsFixation={NeedsFixation}")]
222+
private partial void LogNegotiatedFormat(PixelFormat format, int width, int height, ulong modifier, bool needsFixation);
223+
224+
[LoggerMessage(Level = LogLevel.Debug, Message = "requesting buffers blocks={Blocks} size={Size} stride={Stride} dataTypeMask=0x{DataTypeMask:x}")]
225+
private partial void LogRequestedBuffers(int blocks, int size, int stride, int dataTypeMask);
209226
}

src/PipeWire.NET/Spa/SpaFormat.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,19 @@ internal static int WriteVideoBuffersParam(Span<byte> buf, int size, int stride,
7373
internal static int VideoCaptureDataTypeMask =>
7474
(1 << (int)SpaType.DataMemPtr) | (1 << (int)SpaType.DataMemFd) | (1 << (int)SpaType.DataDmaBuf);
7575

76+
/// <summary>
77+
/// Number of memory blocks a buffer carries for the format: one block per plane. gst's pipewiresink
78+
/// (and PipeWire's convention) splits a planar format into one <c>spa_data</c> per plane for both
79+
/// host memory and DMA-BUF, so a consumer must declare a block per plane (I420=3, NV12=2). Packed
80+
/// formats are a single block.
81+
/// </summary>
82+
internal static int VideoPlaneCount(PixelFormat fmt) => fmt switch
83+
{
84+
PixelFormat.Yuv420 => 3, // Y + U + V
85+
PixelFormat.Nv12 => 2, // Y + interleaved UV
86+
_ => 1, // packed
87+
};
88+
7689
// - Buffer metadata -
7790

7891
/// <summary>Maps a raw <c>spa_data.type</c> to <see cref="PipeWireBufferType"/>.</summary>
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
using System.Runtime.InteropServices;
2+
using System.Runtime.Versioning;
3+
using System.Threading;
4+
using Microsoft.VisualStudio.TestTools.UnitTesting;
5+
6+
namespace PipeWire.NET.Tests;
7+
8+
/// <summary>
9+
/// End-to-end DMA-BUF zero-copy round-trip that depends on neither GStreamer nor any StreamWeaver/VAAPI
10+
/// code: a <see cref="PipeWireVideoOutput"/> dmabuf producer is backed by a real dmabuf allocated through
11+
/// libgbm on the render node, and a <see cref="PipeWireVideoCapture"/> consumer connects with the matching
12+
/// DRM modifier and must receive frames whose <see cref="PipeWireBufferType"/> is <c>DmaBuf</c> with a valid
13+
/// fd. This is the regression guard for the dmabuf path (modifier negotiation + fixation, plane layout, fd
14+
/// passing) - gst's <c>pipewiresink</c> dmabuf export is unreliable across distro/driver builds (some
15+
/// advertise no modifier at all), so the capability is exercised in-process instead.
16+
/// Skips where there is no render node or libgbm (e.g. GPU-less CI runners).
17+
/// </summary>
18+
[TestClass]
19+
[SupportedOSPlatform("linux")]
20+
public sealed class DmaBufRoundTripTests
21+
{
22+
[TestMethod]
23+
[TestCategory("Integration")]
24+
[TestCategory("RequiresDaemon")]
25+
[TestCategory("RequiresGpu")]
26+
public async Task DmaBufProducer_ToConsumer_DeliversDmaBufFrames()
27+
{
28+
if (!File.Exists("/dev/dri/renderD128"))
29+
Assert.Inconclusive("No GPU render node (/dev/dri/renderD128) - skipping dmabuf round-trip.");
30+
31+
const int width = 320, height = 240, poolCap = 8;
32+
GbmAllocator gbm;
33+
try
34+
{
35+
gbm = new GbmAllocator("/dev/dri/renderD128");
36+
}
37+
catch (Exception ex)
38+
{
39+
Assert.Inconclusive($"libgbm unavailable ({ex.Message}) - skipping dmabuf round-trip.");
40+
return;
41+
}
42+
43+
var buffers = new List<GbmAllocator.Buffer>();
44+
try
45+
{
46+
await using var ctx = new PipeWireContext("test", ConsoleTestLoggerFactory.Instance);
47+
await ctx.StartAsync();
48+
49+
long modifier = (long)GbmAllocator.LinearModifier;
50+
bool streaming = false;
51+
int framesConsumed = 0, dmaBufConsumed = 0;
52+
long firstFd = -1;
53+
54+
await using var output = new PipeWireVideoOutput(ctx, "stx-dmabuf-roundtrip", width, height, PixelFormat.Bgra, 30);
55+
output.AllocateDmaBuf += (_, index, w, h, _, planes) =>
56+
{
57+
if (index >= poolCap) return 0;
58+
while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(width, height));
59+
GbmAllocator.Buffer b = buffers[index];
60+
planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size);
61+
return 1;
62+
};
63+
output.FillDmaBuf += (_, _) => true;
64+
output.StateChanged += (_, _, s) => streaming = s == PipeWireStreamState.Streaming;
65+
output.ConnectDmaBuf([modifier]);
66+
67+
uint nodeId = PipeWireVideoCapture.AnyNode;
68+
for (int i = 0; i < 50 && nodeId == PipeWireVideoCapture.AnyNode; i++)
69+
{
70+
nodeId = output.NodeId;
71+
if (nodeId == PipeWireVideoCapture.AnyNode) await Task.Delay(50);
72+
}
73+
74+
Assert.AreNotEqual(PipeWireVideoCapture.AnyNode, nodeId, "producer node should be assigned an id");
75+
76+
await using var capture = new PipeWireVideoCapture(ctx, "stx-dmabuf-roundtrip-sink");
77+
capture.FrameReady += (_, frame) =>
78+
{
79+
Interlocked.Increment(ref framesConsumed);
80+
if (frame.BufferType == PipeWireBufferType.DmaBuf)
81+
{
82+
Interlocked.Increment(ref dmaBufConsumed);
83+
Interlocked.CompareExchange(ref firstFd, frame.Fd, -1);
84+
}
85+
};
86+
capture.Connect(nodeId, [PixelFormat.Bgra], modifiers: [modifier]);
87+
88+
// DRIVER producer: pace it at ~30fps once streaming so the consumer sees a steady frame flow.
89+
using var driver = new Timer(_ => { if (streaming) output.TriggerFrame(); }, null, 100, 33);
90+
91+
await Task.Delay(TimeSpan.FromSeconds(4));
92+
93+
Assert.IsTrue(dmaBufConsumed >= 10,
94+
$"expected >=10 DMA-BUF frames through the dmabuf path, got {dmaBufConsumed} dmabuf of {framesConsumed} total");
95+
Assert.IsTrue(firstFd >= 0, "a delivered DMA-BUF frame must expose a valid fd for zero-copy import");
96+
}
97+
finally
98+
{
99+
foreach (GbmAllocator.Buffer b in buffers) b.Dispose();
100+
gbm.Dispose();
101+
}
102+
}
103+
104+
/// <summary>
105+
/// Minimal libgbm allocator: opens the render node and hands out LINEAR-modifier BGRA buffers exported
106+
/// as dmabuf fds. Just enough to back a PipeWire dmabuf producer in-process - not a general GBM wrapper.
107+
/// </summary>
108+
[SupportedOSPlatform("linux")]
109+
private sealed class GbmAllocator : IDisposable
110+
{
111+
public const ulong LinearModifier = 0; // DRM_FORMAT_MOD_LINEAR
112+
private const uint GbmFormatArgb8888 = 0x34325241; // fourcc('A','R','2','4') == BGRA byte order (LE)
113+
114+
private readonly int _drmFd;
115+
private readonly IntPtr _device;
116+
117+
public GbmAllocator(string renderNode)
118+
{
119+
_drmFd = open(renderNode, 2 /* O_RDWR */);
120+
if (_drmFd < 0) throw new InvalidOperationException($"open({renderNode}) failed errno={Marshal.GetLastPInvokeError()}");
121+
_device = gbm_create_device(_drmFd);
122+
if (_device == IntPtr.Zero) { close(_drmFd); throw new InvalidOperationException("gbm_create_device failed"); }
123+
}
124+
125+
public Buffer CreateBgra(int width, int height)
126+
{
127+
ulong mod = LinearModifier;
128+
IntPtr bo = gbm_bo_create_with_modifiers(_device, (uint)width, (uint)height, GbmFormatArgb8888, ref mod, 1);
129+
if (bo == IntPtr.Zero) throw new InvalidOperationException("gbm_bo_create_with_modifiers failed (LINEAR BGRA)");
130+
int fd = gbm_bo_get_fd(bo);
131+
uint stride = gbm_bo_get_stride(bo);
132+
uint offset = gbm_bo_get_offset(bo, 0);
133+
return new Buffer(bo, fd, offset, (int)stride, stride * (uint)height);
134+
}
135+
136+
public void Dispose()
137+
{
138+
if (_device != IntPtr.Zero) gbm_device_destroy(_device);
139+
if (_drmFd >= 0) close(_drmFd);
140+
}
141+
142+
public sealed class Buffer(IntPtr bo, int fd, uint offset, int stride, uint size) : IDisposable
143+
{
144+
public long Fd { get; } = fd;
145+
public uint Offset { get; } = offset;
146+
public int Stride { get; } = stride;
147+
public uint Size { get; } = size;
148+
149+
public void Dispose()
150+
{
151+
if (fd >= 0) close(fd);
152+
if (bo != IntPtr.Zero) gbm_bo_destroy(bo);
153+
}
154+
}
155+
156+
[DllImport("libc", SetLastError = true)] private static extern int open(string path, int flags);
157+
[DllImport("libc")] private static extern int close(int fd);
158+
[DllImport("libgbm.so.1")] private static extern IntPtr gbm_create_device(int fd);
159+
[DllImport("libgbm.so.1")] private static extern void gbm_device_destroy(IntPtr dev);
160+
[DllImport("libgbm.so.1")] private static extern IntPtr gbm_bo_create_with_modifiers(IntPtr dev, uint w, uint h, uint format, ref ulong modifiers, uint count);
161+
[DllImport("libgbm.so.1")] private static extern int gbm_bo_get_fd(IntPtr bo);
162+
[DllImport("libgbm.so.1")] private static extern uint gbm_bo_get_stride(IntPtr bo);
163+
[DllImport("libgbm.so.1")] private static extern uint gbm_bo_get_offset(IntPtr bo, int plane);
164+
[DllImport("libgbm.so.1")] private static extern void gbm_bo_destroy(IntPtr bo);
165+
}
166+
}

0 commit comments

Comments
 (0)