diff --git a/dotnet/Devolutions.Cadeau.Test/Program.cs b/dotnet/Devolutions.Cadeau.Test/Program.cs index 295059b..7d33e6f 100644 --- a/dotnet/Devolutions.Cadeau.Test/Program.cs +++ b/dotnet/Devolutions.Cadeau.Test/Program.cs @@ -134,7 +134,7 @@ static void TestMkvStream() string mediaFile = Path.Combine(mediaDir, "window_dragging.rgb"); Console.WriteLine("media dir: {0}", mediaDir); string outputFile = mediaFile.Replace(".rgb", ".webm"); - recorder.SetBipBuffer(mkvStream.bb.Handle); + recorder.SetBipBuffer(mkvStream.bb); recorder.SetFrameSize(frameWidth, frameHeight); recorder.SetFrameRate(frameRate); @@ -307,7 +307,7 @@ static void TestWebSocket() XmfMkvStream mkvStream = new XmfMkvStream(); XmfRecorder recorder = new XmfRecorder(); - recorder.SetBipBuffer(mkvStream.bb.Handle); + recorder.SetBipBuffer(mkvStream.bb); recorder.SetFrameSize(frameWidth, frameHeight); recorder.SetFrameRate(frameRate); recorder.SetCurrentTime(baseTime); @@ -418,7 +418,7 @@ static void TestStreaming() if (streamer.IsRawData) { - recorder.SetBipBuffer(mkvStream.bb.Handle); + recorder.SetBipBuffer(mkvStream.bb); recorder.SetFrameSize(frameWidth, frameHeight); recorder.SetFrameRate(frameRate); recorder.SetCurrentTime(baseTime); @@ -507,7 +507,7 @@ static void TestStreamingV3() XmfMkvStream mkvStream = new XmfMkvStream(); XmfRecorder recorder = new XmfRecorder(); - recorder.SetBipBuffer(mkvStream.bb.Handle); + recorder.SetBipBuffer(mkvStream.bb); recorder.SetFrameSize(frameWidth, frameHeight); recorder.SetFrameRate(frameRate); recorder.SetCurrentTime(baseTime); @@ -571,6 +571,12 @@ static void TestMuxer() { } static void Main(string[] args) { + if (args.Length > 0 && args[0] == "vpx") + { + VpxDecoderTests.Run(); + return; + } + //TestRecorder(); //TestBipBuffer(); //TestMkvStream(); diff --git a/dotnet/Devolutions.Cadeau.Test/VpxDecoderTests.cs b/dotnet/Devolutions.Cadeau.Test/VpxDecoderTests.cs new file mode 100644 index 0000000..a993841 --- /dev/null +++ b/dotnet/Devolutions.Cadeau.Test/VpxDecoderTests.cs @@ -0,0 +1,227 @@ +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Devolutions.Cadeau.Test +{ + // Checks for XmfVpxDecoder and XmfVpxImage. Run with `Devolutions.Cadeau.Test vpx`, with xmf next to the executable. + internal static class VpxDecoderTests + { + // A 321x241 VP8 key frame filled with one color: Y 81, U 90, V 240. + private static readonly byte[] RedFrame = Convert.FromBase64String( + "8BQAnQEqQQHxAABHCIWFiIWEiAICAnWqA/gD+gIGtqT3BoFkn2vbmyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyc4eyKA/v1u8//jmTcwxP+Obf/xYTwOKMj/8VEA"); + + public static void Run() + { + foreach (Action test in new Action[] + { + CopyOutlivesDecoder, + StaleImagesThrow, + ImagesThrowAfterDecoderDispose, + DecoderThrowsAfterHandleDispose, + DisposeWaitsForImageReads, + CopyRacesWithDisposal, + OrphanedImageSurvivesGc, + EachDecodedFrameIsReturnedOnce, + EmptyFramesAreRejected, + }) + { + test(); + Console.WriteLine($"PASS {test.Method.Name}"); + } + } + + private static void CopyOutlivesDecoder() + { + using XmfVpxDecoder decoder = new XmfVpxDecoder(new XmfVpxDecoderConfig { Threads = 1 }); + using XmfVpxImage image = DecodeImage(decoder); + XmfVpxImageCopy copy = image.Copy(); + AssertCopy(copy); + Assert(decoder.Decode(RedFrame), "Decode failed"); + decoder.Dispose(); + image.Dispose(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + AssertCopy(copy); + } + + private static void StaleImagesThrow() + { + using XmfVpxDecoder decoder = new XmfVpxDecoder(new XmfVpxDecoderConfig { Threads = 1 }); + using XmfVpxImage stale = DecodeImage(decoder); + using XmfVpxImage current = DecodeImage(decoder); + AssertThrows(() => stale.Copy()); + AssertThrows(() => stale.GetPlane(XmfVpxPlane.Y)); + AssertCopy(current.Copy()); + } + + private static void ImagesThrowAfterDecoderDispose() + { + XmfVpxDecoder decoder = new XmfVpxDecoder(new XmfVpxDecoderConfig { Threads = 1 }); + using XmfVpxImage image = DecodeImage(decoder); + decoder.Dispose(); + AssertThrows(() => image.Copy()); + AssertThrows(() => image.GetPlane(XmfVpxPlane.Y)); + AssertThrows(() => image.GetStride(XmfVpxPlane.Y)); + AssertThrows(() => decoder.Decode(RedFrame)); + } + + private static void DecoderThrowsAfterHandleDispose() + { + using XmfVpxDecoder decoder = new XmfVpxDecoder(new XmfVpxDecoderConfig { Threads = 1 }); + using XmfVpxImage image = DecodeImage(decoder); + decoder.Handle.Dispose(); + + // The image keeps the native decoder alive, but the decoder must stop working right away, not after GC. + Assert(!decoder.Handle.IsClosed, "Decoder memory was released while an image still references it"); + AssertThrows(() => decoder.Decode(RedFrame)); + AssertThrows(() => decoder.GetNextFrame()); + AssertThrows(() => decoder.GetLastError()); + AssertThrows(() => image.Copy()); + AssertThrows(() => image.GetPlane(XmfVpxPlane.Y)); + } + + private static void DisposeWaitsForImageReads() + { + using XmfVpxDecoder decoder = new XmfVpxDecoder(new XmfVpxDecoderConfig { Threads = 1 }); + using XmfVpxImage image = DecodeImage(decoder); + using ManualResetEventSlim started = new ManualResetEventSlim(); + Task disposal; + + // Holding SyncRoot stands in for an image read in progress on another thread. + lock (decoder.SyncRoot) + { + disposal = Task.Run(() => + { + started.Set(); + image.Dispose(); + }); + Assert(started.Wait(TimeSpan.FromSeconds(5)), "Disposal did not start"); + Assert(!disposal.Wait(TimeSpan.FromMilliseconds(250)), "Dispose bypassed the image read lock"); + AssertCopy(image.Copy()); + decoder.Handle.Dispose(); + Assert(!decoder.Handle.IsClosed, "Decoder memory was released during an image read"); + } + + Assert(disposal.Wait(TimeSpan.FromSeconds(5)), "Disposal did not finish"); + Assert(decoder.Handle.IsClosed, "Decoder memory was not released with the last image"); + AssertThrows(() => image.Copy()); + } + + private static void CopyRacesWithDisposal() + { + for (int iteration = 0; iteration < 256; iteration++) + { + using XmfVpxDecoder decoder = new XmfVpxDecoder(new XmfVpxDecoderConfig { Threads = 1 }); + using XmfVpxImage image = DecodeImage(decoder); + using ManualResetEventSlim start = new ManualResetEventSlim(); + Task copy = Task.Run(() => + { + start.Wait(); + try + { + AssertCopy(image.Copy()); + } + catch (ObjectDisposedException) + { + } + catch (InvalidOperationException error) when (error.Message.StartsWith("The decoded image is no longer valid:", StringComparison.Ordinal)) + { + } + }); + Task disposal = Task.Run(() => + { + start.Wait(); + decoder.Handle.Dispose(); + image.Dispose(); + }); + start.Set(); + Assert(Task.WaitAll(new[] { copy, disposal }, TimeSpan.FromSeconds(5)), "Copy/disposal race timed out"); + } + } + + private static void OrphanedImageSurvivesGc() + { + using XmfVpxImage image = CreateOrphanedImage(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + AssertCopy(image.Copy()); + } + + private static void EachDecodedFrameIsReturnedOnce() + { + using XmfVpxDecoder decoder = new XmfVpxDecoder(new XmfVpxDecoderConfig { Threads = 1 }); + for (int iteration = 0; iteration < 2; iteration++) + { + using XmfVpxImage image = DecodeImage(decoder); + using XmfVpxImage repeated = decoder.GetNextFrame(); + Assert(repeated == null, "GetNextFrame returned the same decoded frame twice"); + } + } + + private static void EmptyFramesAreRejected() + { + using XmfVpxDecoder decoder = new XmfVpxDecoder(new XmfVpxDecoderConfig { Threads = 1 }); + using XmfVpxImage image = DecodeImage(decoder); + AssertThrows(() => decoder.Decode(Array.Empty())); + AssertThrows(() => decoder.Decode(IntPtr.Zero, 1)); + + // A rejected frame never reaches the decoder, so the last image stays usable. + AssertCopy(image.Copy()); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static XmfVpxImage CreateOrphanedImage() + { + XmfVpxDecoder decoder = new XmfVpxDecoder(new XmfVpxDecoderConfig { Threads = 1 }); + return DecodeImage(decoder); + } + + private static XmfVpxImage DecodeImage(XmfVpxDecoder decoder) + { + Assert(decoder.Decode(RedFrame), "Decode failed"); + return decoder.GetNextFrame() ?? throw new InvalidOperationException("No decoded image"); + } + + private static void AssertCopy(XmfVpxImageCopy copy) + { + Assert(copy.Width == 321 && copy.Height == 241, "Wrong image dimensions"); + Assert(copy.Format == XmfVpxImageFormat.I420, "Wrong image format"); + Assert(copy.ColorSpace == XmfVpxColorSpace.Unknown && copy.ColorRange == XmfVpxColorRange.Studio, "Wrong color metadata"); + foreach ((XmfVpxImagePlane plane, int width, int height, byte value) in new[] + { + (copy.Y, 321, 241, (byte)81), + (copy.U, 161, 121, (byte)90), + (copy.V, 161, 121, (byte)240), + }) + { + Assert(plane.Width == width && plane.Height == height && plane.Stride == width, "Wrong plane dimensions"); + Assert(plane.Data.Length == width * height && plane.Data.All(pixel => pixel == value), "Wrong plane pixels"); + } + } + + private static void AssertThrows(Action action) where T : Exception + { + try + { + action(); + } + catch (T) + { + return; + } + + throw new InvalidOperationException($"Expected {typeof(T).Name}"); + } + + private static void Assert(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } + } +} diff --git a/dotnet/Devolutions.Cadeau/Devolutions.Cadeau.csproj b/dotnet/Devolutions.Cadeau/Devolutions.Cadeau.csproj index bdd210a..a9ac2b6 100644 --- a/dotnet/Devolutions.Cadeau/Devolutions.Cadeau.csproj +++ b/dotnet/Devolutions.Cadeau/Devolutions.Cadeau.csproj @@ -93,4 +93,8 @@ + + + + diff --git a/dotnet/Devolutions.Cadeau/XmfVpxDecoder.cs b/dotnet/Devolutions.Cadeau/XmfVpxDecoder.cs new file mode 100644 index 0000000..bb800d2 --- /dev/null +++ b/dotnet/Devolutions.Cadeau/XmfVpxDecoder.cs @@ -0,0 +1,245 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Devolutions.Cadeau +{ + public enum XmfVpxCodec + { + VP8 = 0, + VP9 = 1, + } + + [StructLayout(LayoutKind.Sequential)] + public struct XmfVpxDecoderConfig + { + /// Maximum number of decoding threads; 0 or 1 decodes on one thread. + public uint Threads; + + /// Width, or 0 when unknown. + public uint Width; + + /// Height, or 0 when unknown. + public uint Height; + + public XmfVpxCodec Codec; + } + + public enum XmfVpxDecoderErrorCode + { + NoError = 0, + MemoryError = 1, + InitError = 2, + DecodeError = 3, + NoFrameAvailable = 4, + VpxError = 5, + } + + [StructLayout(LayoutKind.Sequential)] + public struct XmfVpxDecoderError + { + public XmfVpxDecoderErrorCode Code; + + /// The libvpx vpx_codec_err_t when is . + public int VpxErrorCode; + } + + public sealed class XmfVpxDecoderHandle : SafeHandleZeroOrMinusOneIsInvalid + { + private int disposeRequested; + + private XmfVpxDecoderHandle() : base(ownsHandle: true) { } + + internal bool IsDisposeRequested => Volatile.Read(ref this.disposeRequested) != 0; + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Volatile.Write(ref this.disposeRequested, 1); + } + + base.Dispose(disposing); + } + + protected override bool ReleaseHandle() + { + Ffi.Destroy(handle); + return true; + } + + internal static class Ffi + { + private const string Lib = "xmf"; + + [DllImport(Lib, EntryPoint = "XmfVpxDecoder_Create")] + internal static extern XmfVpxDecoderHandle Create(XmfVpxDecoderConfig config); + + [DllImport(Lib, EntryPoint = "XmfVpxDecoder_Destroy")] + internal static extern void Destroy(IntPtr decoder); + } + } + + public class XmfVpxDecoder : IDisposable + { + private readonly XmfVpxDecoderHandle h; + + // Serializes decoding with reads of the decoder's buffers through XmfVpxImage. + internal readonly object SyncRoot = new object(); + + private int generation; + + private bool disposed; + + private class Ffi + { + private const string Lib = "xmf"; + + [DllImport(Lib, EntryPoint = "XmfVpxDecoder_Decode")] + public static extern int Decode(XmfVpxDecoderHandle decoder, IntPtr data, uint size); + + [DllImport(Lib, EntryPoint = "XmfVpxDecoder_GetNextFrame")] + public static extern XmfVpxImageHandle GetNextFrame(XmfVpxDecoderHandle decoder); + + [DllImport(Lib, EntryPoint = "XmfVpxDecoder_GetLastError")] + public static extern XmfVpxDecoderError GetLastError(XmfVpxDecoderHandle decoder); + } + + public XmfVpxDecoderHandle Handle => this.h; + + public XmfVpxDecoder(XmfVpxDecoderConfig config) + { + this.h = XmfVpxDecoderHandle.Ffi.Create(config) ?? throw new InvalidOperationException("XmfVpxDecoder_Create failed"); + + if (this.h.IsInvalid) + { + throw new InvalidOperationException("XmfVpxDecoder_Create failed"); + } + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + lock (this.SyncRoot) + { + this.disposed = true; + } + + this.h?.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Decodes one compressed frame. Returns false when libvpx rejects it; see . Images + /// returned earlier become unusable, because the decoder reuses their buffers. + /// + /// The frame is null or empty, which native code rejects without setting an error. + public bool Decode(IntPtr data, uint size) + { + if (data == IntPtr.Zero) + { + throw new ArgumentNullException(nameof(data)); + } + + if (size == 0) + { + throw new ArgumentException("An empty frame cannot be decoded", nameof(size)); + } + + lock (this.SyncRoot) + { + this.CheckDisposed(); + + this.generation++; + return Ffi.Decode(this.h, data, size) == 0; + } + } + + public bool Decode(byte[] data) + { + if (data == null) + { + throw new ArgumentNullException(nameof(data)); + } + + if (data.Length == 0) + { + throw new ArgumentException("An empty frame cannot be decoded", nameof(data)); + } + + GCHandle pinned = GCHandle.Alloc(data, GCHandleType.Pinned); + try + { + return this.Decode(pinned.AddrOfPinnedObject(), (uint)data.Length); + } + finally + { + pinned.Free(); + } + } + + /// + /// Returns the next decoded frame without copying it, or null when none is left. libvpx yields at most one + /// frame per , so a second call returns null. The image reads this + /// decoder's buffers and is usable only until the next call or until the + /// decoder is disposed; call to keep the pixels longer. + /// + public XmfVpxImage GetNextFrame() + { + lock (this.SyncRoot) + { + this.CheckDisposed(); + + XmfVpxImageHandle image = Ffi.GetNextFrame(this.h); + if (image == null || image.IsInvalid) + { + image?.Dispose(); + return null; + } + + try + { + image.KeepDecoderAlive(this.h); + return new XmfVpxImage(image, this, this.generation); + } + catch + { + image.Dispose(); + throw; + } + } + } + + // Callers hold SyncRoot. Disposing Handle directly also invalidates images: they keep its native memory alive, + // so the handle is only released, and IsClosed only set, once they are gone. + internal bool IsCurrentGeneration(int imageGeneration) + { + return !this.disposed && !this.h.IsDisposeRequested && imageGeneration == this.generation; + } + + public XmfVpxDecoderError GetLastError() + { + lock (this.SyncRoot) + { + this.CheckDisposed(); + + return Ffi.GetLastError(this.h); + } + } + + // Handle can be disposed directly, bypassing Dispose; images may still keep its native memory alive, but the + // decoder must stop working as soon as either one is disposed. + private void CheckDisposed() + { + if (this.disposed || this.h.IsDisposeRequested) + { + throw new ObjectDisposedException(nameof(XmfVpxDecoder)); + } + } + } +} diff --git a/dotnet/Devolutions.Cadeau/XmfVpxImage.cs b/dotnet/Devolutions.Cadeau/XmfVpxImage.cs new file mode 100644 index 0000000..7a1e206 --- /dev/null +++ b/dotnet/Devolutions.Cadeau/XmfVpxImage.cs @@ -0,0 +1,339 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.Runtime.InteropServices; + +namespace Devolutions.Cadeau +{ + public enum XmfVpxImageFormat + { + None = 0, + Yv12 = 0x301, + I420 = 0x102, + I422 = 0x105, + I444 = 0x106, + I440 = 0x107, + Nv12 = 0x109, + I42016 = 0x902, + I42216 = 0x905, + I44416 = 0x906, + I44016 = 0x907, + } + + public enum XmfVpxColorSpace + { + Unknown = 0, + Bt601 = 1, + Bt709 = 2, + Smpte170 = 3, + Smpte240 = 4, + Bt2020 = 5, + Reserved = 6, + Srgb = 7, + } + + public enum XmfVpxColorRange + { + Studio = 0, + Full = 1, + } + + public enum XmfVpxPlane + { + Y = 0, + U = 1, + V = 2, + Alpha = 3, + } + + /// + /// A decoded frame as returned by . Like the native API it reads the + /// decoder's own buffers without copying, so it is usable only until the next + /// call or until the decoder is disposed; after that, + /// , and throw. Call to keep + /// the pixels longer. The image keeps the decoder's native memory alive until the image itself is released, so a + /// stale image never reads freed memory. + /// + public sealed class XmfVpxImage : IDisposable + { + private readonly XmfVpxImageHandle h; + + private readonly XmfVpxDecoder owner; + + private readonly int generation; + + private bool disposed; + + internal XmfVpxImage(XmfVpxImageHandle h, XmfVpxDecoder owner, int generation) + { + this.h = h; + this.owner = owner; + this.generation = generation; + this.Width = Ffi.GetWidth(h); + this.Height = Ffi.GetHeight(h); + this.Format = (XmfVpxImageFormat)Ffi.GetFormat(h); + this.ColorSpace = (XmfVpxColorSpace)Ffi.GetColorSpace(h); + this.ColorRange = (XmfVpxColorRange)Ffi.GetColorRange(h); + } + + public uint Width { get; } + + public uint Height { get; } + + public XmfVpxImageFormat Format { get; } + + /// + /// Color space reported by the decoder. VP8 carries no color metadata and reports + /// . + /// + public XmfVpxColorSpace ColorSpace { get; } + + /// Color range reported by the decoder. VP8 reports . + public XmfVpxColorRange ColorRange { get; } + + /// + /// Returns the start of a plane in decoder memory, or when the plane is unavailable. + /// The pointer is valid only until the next decode call on the decoder, and only while this image is alive: + /// keep a reference to the image (for example with ) while reading through it. + /// + public IntPtr GetPlane(XmfVpxPlane plane) + { + lock (this.owner.SyncRoot) + { + this.CheckUsable(); + + return Ffi.GetPlane(this.h, (int)plane); + } + } + + /// + /// Returns the distance in bytes between rows of a plane, or 0 when the plane is unavailable. + /// + public int GetStride(XmfVpxPlane plane) + { + lock (this.owner.SyncRoot) + { + this.CheckUsable(); + + return Ffi.GetStride(this.h, (int)plane); + } + } + + /// + /// Copies an 8-bit I420 image into managed memory. The copy stays valid after later decode calls and after the + /// decoder is disposed. Other formats throw ; read them with + /// and instead. + /// + public XmfVpxImageCopy Copy() + { + lock (this.owner.SyncRoot) + { + this.CheckUsable(); + + if (this.Format != XmfVpxImageFormat.I420) + { + throw new NotSupportedException($"XmfVpxImage.Copy supports I420 images, not {this.Format}"); + } + + if (this.Width == 0 || this.Height == 0 || this.Width > int.MaxValue / 4 || this.Height > int.MaxValue) + { + throw new InvalidOperationException($"XMF returned an invalid VPX image size {this.Width}x{this.Height}"); + } + + int width = (int)this.Width; + int height = (int)this.Height; + int chromaWidth = (width + 1) / 2; + int chromaHeight = (height + 1) / 2; + + XmfVpxImageCopy copy = new XmfVpxImageCopy( + this.Width, + this.Height, + this.Format, + this.ColorSpace, + this.ColorRange, + this.CopyPlane(XmfVpxPlane.Y, width, height), + this.CopyPlane(XmfVpxPlane.U, chromaWidth, chromaHeight), + this.CopyPlane(XmfVpxPlane.V, chromaWidth, chromaHeight)); + + // The plane pointers outlive the P/Invoke calls that keep their SafeHandle alive. + GC.KeepAlive(this); + return copy; + } + } + + public void Dispose() + { + lock (this.owner.SyncRoot) + { + if (this.disposed) + { + return; + } + + this.disposed = true; + + this.h.Dispose(); + } + + GC.SuppressFinalize(this); + } + + private XmfVpxImagePlane CopyPlane(XmfVpxPlane plane, int width, int height) + { + IntPtr source = Ffi.GetPlane(this.h, (int)plane); + int sourceStride = Ffi.GetStride(this.h, (int)plane); + if (source == IntPtr.Zero || sourceStride < width) + { + throw new InvalidOperationException($"XMF returned an incomplete VPX image plane {plane}"); + } + + byte[] data = new byte[checked(width * height)]; + for (int row = 0; row < height; row++) + { + Marshal.Copy(IntPtr.Add(source, checked(row * sourceStride)), data, row * width, width); + } + + return new XmfVpxImagePlane(data, width, height, width); + } + + private void CheckUsable() + { + if (this.disposed) + { + throw new ObjectDisposedException(nameof(XmfVpxImage)); + } + + if (!this.owner.IsCurrentGeneration(this.generation)) + { + throw new InvalidOperationException( + "The decoded image is no longer valid: its decoder has decoded again or was disposed. Copy it first to keep the pixels."); + } + } + + private static class Ffi + { + private const string Lib = "xmf"; + + [DllImport(Lib, EntryPoint = "XmfVpxImage_GetWidth")] + public static extern uint GetWidth(XmfVpxImageHandle image); + + [DllImport(Lib, EntryPoint = "XmfVpxImage_GetHeight")] + public static extern uint GetHeight(XmfVpxImageHandle image); + + [DllImport(Lib, EntryPoint = "XmfVpxImage_GetFormat")] + public static extern int GetFormat(XmfVpxImageHandle image); + + [DllImport(Lib, EntryPoint = "XmfVpxImage_GetPlane")] + public static extern IntPtr GetPlane(XmfVpxImageHandle image, int plane); + + [DllImport(Lib, EntryPoint = "XmfVpxImage_GetStride")] + public static extern int GetStride(XmfVpxImageHandle image, int plane); + + [DllImport(Lib, EntryPoint = "XmfVpxImage_GetColorSpace")] + public static extern int GetColorSpace(XmfVpxImageHandle image); + + [DllImport(Lib, EntryPoint = "XmfVpxImage_GetColorRange")] + public static extern int GetColorRange(XmfVpxImageHandle image); + } + } + + /// + /// One plane of an . Rows are tightly packed: row r starts at r * . + /// + public sealed class XmfVpxImagePlane + { + internal XmfVpxImagePlane(byte[] data, int width, int height, int stride) + { + this.Data = data; + this.Width = width; + this.Height = height; + this.Stride = stride; + } + + public byte[] Data { get; } + + /// Samples per row. + public int Width { get; } + + /// Number of rows. + public int Height { get; } + + /// Bytes per row. + public int Stride { get; } + } + + /// + /// An I420 frame copied into managed memory by . It does not depend on the decoder. + /// + public sealed class XmfVpxImageCopy + { + internal XmfVpxImageCopy( + uint width, + uint height, + XmfVpxImageFormat format, + XmfVpxColorSpace colorSpace, + XmfVpxColorRange colorRange, + XmfVpxImagePlane y, + XmfVpxImagePlane u, + XmfVpxImagePlane v) + { + this.Width = width; + this.Height = height; + this.Format = format; + this.ColorSpace = colorSpace; + this.ColorRange = colorRange; + this.Y = y; + this.U = u; + this.V = v; + } + + public uint Width { get; } + + public uint Height { get; } + + public XmfVpxImageFormat Format { get; } + + public XmfVpxColorSpace ColorSpace { get; } + + public XmfVpxColorRange ColorRange { get; } + + public XmfVpxImagePlane Y { get; } + + public XmfVpxImagePlane U { get; } + + public XmfVpxImagePlane V { get; } + } + + internal sealed class XmfVpxImageHandle : SafeHandleZeroOrMinusOneIsInvalid + { + // The native image points into memory owned by the decoder, so this handle holds a reference on the decoder + // handle and XmfVpxDecoder_Destroy is deferred until the image is released. + private XmfVpxDecoderHandle decoder; + + private XmfVpxImageHandle() : base(ownsHandle: true) { } + + internal void KeepDecoderAlive(XmfVpxDecoderHandle owner) + { + bool added = false; + owner.DangerousAddRef(ref added); + if (added) + { + this.decoder = owner; + } + } + + protected override bool ReleaseHandle() + { + Ffi.Destroy(handle); + this.decoder?.DangerousRelease(); + return true; + } + + private static class Ffi + { + private const string Lib = "xmf"; + + [DllImport(Lib, EntryPoint = "XmfVpxImage_Destroy")] + public static extern void Destroy(IntPtr image); + } + } +} diff --git a/libxmf/XmfVpxImage.c b/libxmf/XmfVpxImage.c index 47058db..aded93b 100644 --- a/libxmf/XmfVpxImage.c +++ b/libxmf/XmfVpxImage.c @@ -7,6 +7,11 @@ struct xmf_vpx_image vpx_image_t *img; }; +static int xmf_vpx_image_can_index_plane(const XmfVpxImage *image, int plane) +{ + return image && image->img && plane >= VPX_PLANE_Y && plane <= VPX_PLANE_ALPHA; +} + XmfVpxImage *XmfVpxImage_Create(vpx_image_t *data) { XmfVpxImage *image = (XmfVpxImage *)malloc(sizeof(XmfVpxImage)); @@ -47,3 +52,28 @@ unsigned int XmfVpxImage_GetHeight(const XmfVpxImage *image) { return image && image->img ? image->img->d_h : 0; } + +int XmfVpxImage_GetFormat(const XmfVpxImage *image) +{ + return image && image->img ? (int)image->img->fmt : VPX_IMG_FMT_NONE; +} + +const uint8_t *XmfVpxImage_GetPlane(const XmfVpxImage *image, int plane) +{ + return xmf_vpx_image_can_index_plane(image, plane) ? image->img->planes[plane] : NULL; +} + +int XmfVpxImage_GetStride(const XmfVpxImage *image, int plane) +{ + return xmf_vpx_image_can_index_plane(image, plane) ? image->img->stride[plane] : 0; +} + +int XmfVpxImage_GetColorSpace(const XmfVpxImage *image) +{ + return image && image->img ? (int)image->img->cs : VPX_CS_UNKNOWN; +} + +int XmfVpxImage_GetColorRange(const XmfVpxImage *image) +{ + return image && image->img ? (int)image->img->range : -1; +} diff --git a/libxmf/XmfVpxImage.h b/libxmf/XmfVpxImage.h index 1323afe..3e6a812 100644 --- a/libxmf/XmfVpxImage.h +++ b/libxmf/XmfVpxImage.h @@ -1,6 +1,7 @@ #ifndef XMF_VPX_IMAGE_H #define XMF_VPX_IMAGE_H +#include #include #include @@ -20,6 +21,58 @@ extern "C" XMF_EXPORT unsigned int XmfVpxImage_GetWidth(const XmfVpxImage *image); XMF_EXPORT unsigned int XmfVpxImage_GetHeight(const XmfVpxImage *image); + /** + * @brief Retrieves the pixel format of a decoded image. + * + * 8-bit I420 is VPX_IMG_FMT_I420. High bit depth formats carry the VPX_IMG_FMT_HIGHBITDEPTH flag. + * + * @param image Pointer to the decoded image. + * @return The libvpx vpx_img_fmt_t value, or VPX_IMG_FMT_NONE if the image is unavailable. + */ + XMF_EXPORT int XmfVpxImage_GetFormat(const XmfVpxImage *image); + + /** + * @brief Retrieves the start of a plane of a decoded image. + * + * The memory belongs to the decoder and stays valid only until the next XmfVpxDecoder_Decode call + * or until the decoder is destroyed. + * For I420, the chroma planes have (width + 1) / 2 columns and (height + 1) / 2 rows. + * + * @param image Pointer to the decoded image. + * @param plane Plane index: VPX_PLANE_Y, VPX_PLANE_U, VPX_PLANE_V or VPX_PLANE_ALPHA. + * @return Pointer to the first byte of the plane, or NULL if the image or plane is unavailable. + */ + XMF_EXPORT const uint8_t *XmfVpxImage_GetPlane(const XmfVpxImage *image, int plane); + + /** + * @brief Retrieves the row stride of a plane of a decoded image. + * + * @param image Pointer to the decoded image. + * @param plane Plane index: VPX_PLANE_Y, VPX_PLANE_U, VPX_PLANE_V or VPX_PLANE_ALPHA. + * @return Distance in bytes between the starts of two rows, or 0 if the image or plane is unavailable. + */ + XMF_EXPORT int XmfVpxImage_GetStride(const XmfVpxImage *image, int plane); + + /** + * @brief Retrieves the color space reported by the decoder. + * + * VP8 carries no color metadata, so VP8 images report VPX_CS_UNKNOWN. + * + * @param image Pointer to the decoded image. + * @return The libvpx vpx_color_space_t value, or VPX_CS_UNKNOWN if the image is unavailable. + */ + XMF_EXPORT int XmfVpxImage_GetColorSpace(const XmfVpxImage *image); + + /** + * @brief Retrieves the color range reported by the decoder. + * + * VP8 images report VPX_CR_STUDIO_RANGE. + * + * @param image Pointer to the decoded image. + * @return The libvpx vpx_color_range_t value, or -1 if the image is unavailable. + */ + XMF_EXPORT int XmfVpxImage_GetColorRange(const XmfVpxImage *image); + #ifdef __cplusplus } #endif diff --git a/rust/cadeau/README.md b/rust/cadeau/README.md index 3501fe2..c91b105 100644 --- a/rust/cadeau/README.md +++ b/rust/cadeau/README.md @@ -5,6 +5,15 @@ Idiomatic wrapper around the Cadeau library: performance primitives and media fo It’s possible to choose between dynamically loading the library and regular static / dynamic linking at build-time for a total of three options. See [xmf-sys](https://crates.io/crates/xmf-sys) to learn more about this. +## Read decoded VPX pixels + +`VpxImage::i420_planes()` borrows the Y, U and V planes of an 8-bit I420 image without copying. +Use each plane's `rows()` iterator to read its visible pixels safely; the row slices keep the image borrowed, so the decoder cannot decode again while they are in use. +The plane does not expose a single byte slice, because libvpx may leave the padding between rows uninitialized. + +Code that takes a base pointer and a stride, such as a C or SIMD color converter, can use the unsafe `as_ptr()` with `stride()`, `width()` and `height()`. +Its safety section lists what the caller must uphold: read only the `width()` pixel bytes of each row, and stop using the pointer before the image is dropped. + ## Example: generate a WebM file from a PNG image ```rust,no_run diff --git a/rust/cadeau/src/xmf/vpx/mod.rs b/rust/cadeau/src/xmf/vpx/mod.rs index 412f1e7..0e61f69 100644 --- a/rust/cadeau/src/xmf/vpx/mod.rs +++ b/rust/cadeau/src/xmf/vpx/mod.rs @@ -1,16 +1,25 @@ use core::fmt; +use core::mem::MaybeUninit; +use std::ffi::c_int; use xmf_sys::{ XmfVpxCodecType, XmfVpxDecoderError, XmfVpxEncoder, XmfVpxEncoderError, XmfVpxFrame, XmfVpxFrame_Destroy, XmfVpxFrame_GetBuffer, XmfVpxFrame_GetDuration, XmfVpxFrame_GetFlags, XmfVpxFrame_GetHeight, XmfVpxFrame_GetPartitionId, XmfVpxFrame_GetPts, XmfVpxFrame_GetSize, XmfVpxFrame_GetSpatialLayerEncoded, - XmfVpxFrame_GetWidth, XmfVpxImage, XmfVpxImage_Destroy, XmfVpxImage_GetHeight, XmfVpxImage_GetWidth, XmfVpxPacket, - XmfVpxPacketKind, XmfVpxPacket_Destroy, XmfVpxPacket_GetFrame, XmfVpxPacket_GetKind, XmfVpxPacket_IsEmpty, + XmfVpxFrame_GetWidth, XmfVpxImage, XmfVpxImage_Destroy, XmfVpxImage_GetColorRange, XmfVpxImage_GetColorSpace, + XmfVpxImage_GetFormat, XmfVpxImage_GetHeight, XmfVpxImage_GetPlane, XmfVpxImage_GetStride, XmfVpxImage_GetWidth, + XmfVpxPacket, XmfVpxPacketKind, XmfVpxPacket_Destroy, XmfVpxPacket_GetFrame, XmfVpxPacket_GetKind, + XmfVpxPacket_IsEmpty, VPX_CR_FULL_RANGE, VPX_CR_STUDIO_RANGE, VPX_CS_BT_2020, VPX_CS_BT_601, VPX_CS_BT_709, + VPX_CS_RESERVED, VPX_CS_SMPTE_170, VPX_CS_SMPTE_240, VPX_CS_SRGB, VPX_CS_UNKNOWN, VPX_IMG_FMT_I420, VPX_PLANE_U, + VPX_PLANE_V, VPX_PLANE_Y, }; mod decoder; mod encoder; +#[cfg(test)] +mod tests; + pub use decoder::{VpxDecoder, VpxDecoderBuilder}; pub use encoder::{PacketIterator, VpxEncoder, VpxEncoderBuilder, VpxEncoderPreset}; @@ -20,6 +29,134 @@ pub enum VpxCodec { VP9, } +/// libvpx `vpx_img_fmt_t` of a decoded image. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct VpxImageFormat(pub i32); + +impl VpxImageFormat { + /// 8-bit planar YUV 4:2:0. + pub const I420: Self = Self(VPX_IMG_FMT_I420); +} + +/// libvpx `vpx_color_space_t` reported by the decoder. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct VpxColorSpace(pub i32); + +impl VpxColorSpace { + pub const UNKNOWN: Self = Self(VPX_CS_UNKNOWN); + pub const BT_601: Self = Self(VPX_CS_BT_601); + pub const BT_709: Self = Self(VPX_CS_BT_709); + pub const SMPTE_170: Self = Self(VPX_CS_SMPTE_170); + pub const SMPTE_240: Self = Self(VPX_CS_SMPTE_240); + pub const BT_2020: Self = Self(VPX_CS_BT_2020); + pub const RESERVED: Self = Self(VPX_CS_RESERVED); + pub const SRGB: Self = Self(VPX_CS_SRGB); +} + +/// libvpx `vpx_color_range_t` reported by the decoder. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct VpxColorRange(pub i32); + +impl VpxColorRange { + /// Studio (limited) range: Y in 16..=235, U and V in 16..=240. + pub const STUDIO: Self = Self(VPX_CR_STUDIO_RANGE); + /// Full range: Y, U and V in 0..=255. + pub const FULL: Self = Self(VPX_CR_FULL_RANGE); +} + +/// One plane of a decoded image, borrowed until the next decoder call. +#[derive(Clone, Copy)] +pub struct VpxPlane<'image> { + data: &'image [MaybeUninit], + columns: usize, + stride: usize, +} + +impl<'image> VpxPlane<'image> { + /// Borrows each row's visible bytes without exposing padding that libvpx may leave uninitialized. + pub fn rows(&self) -> impl ExactSizeIterator { + let columns = self.columns; + self.data.chunks(self.stride).map(move |row| { + // SAFETY: Each chunk starts at a row whose first `columns` bytes are initialized pixels. + unsafe { core::slice::from_raw_parts(row.as_ptr().cast(), columns) } + }) + } + + /// Number of pixel bytes in each row. + pub fn width(&self) -> usize { + self.columns + } + + /// Number of rows. + pub fn height(&self) -> usize { + self.data.len().div_ceil(self.stride) + } + + /// Distance in bytes between the starts of two rows. At least [`Self::width`]. + pub fn stride(&self) -> usize { + self.stride + } + + /// Returns a pointer to the first pixel of the plane, for code that takes a base pointer and a stride, such as a + /// C or SIMD color converter. + /// + /// Row `r` starts at `as_ptr().add(r * stride())`, and its first [`width()`](Self::width) bytes are pixels. + /// Use [`Self::rows`] instead when a slice per row is enough: it needs no `unsafe`. + /// + /// # Safety + /// + /// The returned pointer carries neither the image's lifetime nor any promise that every byte is initialized, so + /// the caller must uphold all of the following: + /// + /// - Only read through it. The buffer belongs to the decoder. + /// - Only read the first [`width()`](Self::width) bytes of each of the [`height()`](Self::height) rows. The + /// bytes between rows are padding that libvpx may leave uninitialized; reading them is undefined behavior. + /// - Stop using it before the image is dropped. Until then the decoder stays mutably borrowed, so it cannot + /// decode again or be dropped; keep the image (or this plane) alive for as long as the pointer is in use. + /// + /// # Example + /// + /// ```rust,no_run + /// use cadeau::xmf::vpx::VpxPlane; + /// + /// fn luma_sum(plane: &VpxPlane<'_>) -> u64 { + /// // SAFETY: Only pixel bytes are read below, while `plane` keeps the image borrowed. + /// let base = unsafe { plane.as_ptr() }; + /// let mut sum = 0; + /// for row in 0..plane.height() { + /// // SAFETY: Row `row` starts `row * stride` bytes after `base`, inside the plane. + /// let start = unsafe { base.add(row * plane.stride()) }; + /// // SAFETY: The row has `width` initialized pixels. + /// let pixels = unsafe { core::slice::from_raw_parts(start, plane.width()) }; + /// sum += pixels.iter().map(|&pixel| u64::from(pixel)).sum::(); + /// } + /// sum + /// } + /// ``` + pub unsafe fn as_ptr(&self) -> *const u8 { + self.data.as_ptr().cast() + } +} + +impl fmt::Debug for VpxPlane<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // The pixels are not printed: a 1080p luma plane alone is two million bytes, some of them uninitialized. + f.debug_struct("VpxPlane") + .field("width", &self.width()) + .field("height", &self.height()) + .field("stride", &self.stride) + .finish_non_exhaustive() + } +} + +/// The three planes of an 8-bit I420 image, borrowed until the next decoder call. +#[derive(Debug, Clone, Copy)] +pub struct VpxI420Planes<'image> { + pub y: VpxPlane<'image>, + pub u: VpxPlane<'image>, + pub v: VpxPlane<'image>, +} + pub struct VpxImage<'decoder> { // INVARIANT: A valid pointer to a properly initialized XmfVpxImage. // INVARIANT: The pointer is owned. @@ -48,6 +185,76 @@ impl VpxImage<'_> { // SAFETY: Pointer is valid as the lifetime is bound to the associated decoder. unsafe { XmfVpxImage_GetHeight(self.ptr) } } + + /// libvpx `vpx_img_fmt_t` of the decoded image. + pub fn format(&self) -> VpxImageFormat { + // SAFETY: Pointer is valid as the lifetime is bound to the associated decoder. + VpxImageFormat(unsafe { XmfVpxImage_GetFormat(self.ptr) }) + } + + /// Color space reported by the decoder. VP8 always reports [`VpxColorSpace::UNKNOWN`]. + pub fn color_space(&self) -> VpxColorSpace { + // SAFETY: Pointer is valid as the lifetime is bound to the associated decoder. + VpxColorSpace(unsafe { XmfVpxImage_GetColorSpace(self.ptr) }) + } + + /// Color range reported by the decoder. VP8 always reports [`VpxColorRange::STUDIO`]. + pub fn color_range(&self) -> VpxColorRange { + // SAFETY: Pointer is valid as the lifetime is bound to the associated decoder. + VpxColorRange(unsafe { XmfVpxImage_GetColorRange(self.ptr) }) + } + + /// Borrows the Y, U and V planes, or returns `None` when the image is not 8-bit I420. + pub fn i420_planes(&self) -> Option> { + if self.format() != VpxImageFormat::I420 { + return None; + } + + let width = usize::try_from(self.width()).ok()?; + let height = usize::try_from(self.height()).ok()?; + if width == 0 || height == 0 { + return None; + } + + let chroma_width = width.div_ceil(2); + let chroma_height = height.div_ceil(2); + + // SAFETY: The image is 8-bit I420, so libvpx allocated `height` luma rows of at least `width` bytes. + let y = unsafe { self.plane(VPX_PLANE_Y, width, height) }?; + // SAFETY: The image is 8-bit I420, so libvpx allocated `(height + 1) / 2` rows of at least + // `(width + 1) / 2` bytes for each chroma plane. + let u = unsafe { self.plane(VPX_PLANE_U, chroma_width, chroma_height) }?; + // SAFETY: Same as for the U plane. + let v = unsafe { self.plane(VPX_PLANE_V, chroma_width, chroma_height) }?; + + Some(VpxI420Planes { y, u, v }) + } + + /// Borrows `rows` rows of `columns` bytes from a plane, or returns `None` when the plane is unavailable. + /// + /// # Safety + /// + /// Plane `index` must be one allocation holding `rows` rows at the reported stride, with at least `columns` + /// initialized pixel bytes per row. The decoder must not change the buffer while the image is borrowed. + unsafe fn plane(&self, index: c_int, columns: usize, rows: usize) -> Option> { + // SAFETY: Pointer is valid as the lifetime is bound to the associated decoder. + let data = unsafe { XmfVpxImage_GetPlane(self.ptr, index) }; + // SAFETY: Pointer is valid as the lifetime is bound to the associated decoder. + let stride = unsafe { XmfVpxImage_GetStride(self.ptr, index) }; + let stride = usize::try_from(stride).ok().filter(|&stride| stride >= columns)?; + if data.is_null() { + return None; + } + + let length = rows.checked_sub(1)?.checked_mul(stride)?.checked_add(columns)?; + isize::try_from(length).ok()?; + + // SAFETY: The caller guarantees this allocation and borrow. MaybeUninit allows uninitialized row padding; + // only the initialized pixel bytes are exposed by `rows()`. + let data = unsafe { core::slice::from_raw_parts(data.cast::>(), length) }; + + Some(VpxPlane { data, columns, stride }) + } } impl Drop for VpxImage<'_> { diff --git a/rust/cadeau/src/xmf/vpx/tests.rs b/rust/cadeau/src/xmf/vpx/tests.rs new file mode 100644 index 0000000..64b6768 --- /dev/null +++ b/rust/cadeau/src/xmf/vpx/tests.rs @@ -0,0 +1,142 @@ +use core::mem::MaybeUninit; + +use super::VpxPlane; + +#[test] +fn rows_skip_uninitialized_padding() { + let mut data = [MaybeUninit::uninit(); 11]; + for (start, pixels) in [(0, [1, 2, 3]), (4, [4, 5, 6]), (8, [7, 8, 9])] { + for (offset, value) in pixels.into_iter().enumerate() { + data[start + offset].write(value); + } + } + let plane = VpxPlane { + data: &data, + columns: 3, + stride: 4, + }; + + assert_eq!(plane.stride(), 4); + assert_eq!((plane.width(), plane.height()), (3, 3)); + assert_eq!(plane.rows().len(), 3); + let rows: Vec<_> = plane.rows().collect(); + assert_eq!(rows, [&[1, 2, 3][..], &[4, 5, 6][..], &[7, 8, 9][..]]); + assert_eq!(rows[0].as_ptr(), data.as_ptr().cast()); + assert_eq!(rows[1].as_ptr(), data[4..].as_ptr().cast()); +} + +#[test] +fn as_ptr_reaches_every_row_at_the_stride() { + let mut data = [MaybeUninit::uninit(); 11]; + for (start, pixels) in [(0, [1, 2, 3]), (4, [4, 5, 6]), (8, [7, 8, 9])] { + for (offset, value) in pixels.into_iter().enumerate() { + data[start + offset].write(value); + } + } + let plane = VpxPlane { + data: &data, + columns: 3, + stride: 4, + }; + + // SAFETY: Only the initialized pixels of each row are read, while `data` is alive. + let base = unsafe { plane.as_ptr() }; + for (index, row) in plane.rows().enumerate() { + // SAFETY: Row `index` starts `index * stride` bytes after `base`, inside the plane. + let start = unsafe { base.add(index * plane.stride()) }; + // SAFETY: The row has `width` initialized pixels. + let pixels = unsafe { core::slice::from_raw_parts(start, plane.width()) }; + assert_eq!(pixels, row); + } +} + +#[test] +fn debug_prints_geometry_not_pixels() { + let data = [MaybeUninit::new(7); 11]; + let plane = VpxPlane { + data: &data, + columns: 3, + stride: 4, + }; + + assert_eq!(format!("{plane:?}"), "VpxPlane { width: 3, height: 3, stride: 4, .. }"); +} + +#[test] +fn rows_allow_a_single_row_without_trailing_padding() { + let data = [MaybeUninit::new(42); 3]; + let plane = VpxPlane { + data: &data, + columns: 3, + stride: 8, + }; + + assert_eq!(plane.rows().len(), 1); + assert_eq!(plane.rows().next(), Some(&[42, 42, 42][..])); +} + +#[test] +fn rows_allow_tightly_packed_pixels() { + let data = [MaybeUninit::new(42); 6]; + let plane = VpxPlane { + data: &data, + columns: 3, + stride: 3, + }; + + assert_eq!(plane.rows().len(), 2); + assert!(plane.rows().all(|row| row == [42, 42, 42])); +} + +#[cfg(not(feature = "dlopen"))] +#[test] +fn decoded_odd_sized_vp8_rows_contain_only_pixels() { + use super::{VpxColorRange, VpxColorSpace, VpxDecoder, VpxImageFormat}; + + let frame = [ + 0xf0, 0x14, 0x00, 0x9d, 0x01, 0x2a, 0x41, 0x01, 0xf1, 0x00, 0x00, 0x47, 0x08, 0x85, 0x85, 0x88, 0x85, 0x84, + 0x88, 0x02, 0x02, 0x02, 0x75, 0xaa, 0x03, 0xf8, 0x03, 0xfa, 0x02, 0x06, 0xb6, 0xa4, 0xf7, 0x06, 0x81, 0x64, + 0x9f, 0x6b, 0xdb, 0x9b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, + 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, + 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, + 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, + 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, + 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, + 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, + 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x27, 0x38, 0x7b, 0x22, 0x80, 0xfe, 0xfd, 0x6e, + 0xf3, 0xff, 0xe3, 0x99, 0x37, 0x30, 0xc4, 0xff, 0x8e, 0x6d, 0xff, 0xf1, 0x61, 0x3c, 0x0e, 0x28, 0xc8, 0xff, + 0xf1, 0x51, 0x00, + ]; + let mut decoder = VpxDecoder::builder().threads(1).build().expect("create decoder"); + decoder.decode(&frame).expect("decode synthetic red frame"); + let image = decoder.next_frame().expect("decoded image"); + assert_eq!((image.width(), image.height()), (321, 241)); + assert_eq!(image.format(), VpxImageFormat::I420); + assert_eq!(image.color_space(), VpxColorSpace::UNKNOWN); + assert_eq!(image.color_range(), VpxColorRange::STUDIO); + + let planes = image.i420_planes().expect("I420 planes"); + assert!(planes.y.stride() > 321); + for (plane, width, height, value) in [ + (planes.y, 321, 241, 81), + (planes.u, 161, 121, 90), + (planes.v, 161, 121, 240), + ] { + assert_eq!((plane.width(), plane.height()), (width, height)); + assert_eq!(plane.rows().len(), height); + for row in plane.rows() { + assert_eq!(row.len(), width); + assert!(row.iter().all(|&pixel| pixel == value)); + } + + // SAFETY: Only pixel bytes are read, while `image` keeps the decoder borrowed. + let base = unsafe { plane.as_ptr() }; + for (index, row) in plane.rows().enumerate() { + // SAFETY: Row `index` starts `index * stride` bytes after `base`, inside the plane. + let start = unsafe { base.add(index * plane.stride()) }; + // SAFETY: The row has `width` initialized pixels. + let pixels = unsafe { core::slice::from_raw_parts(start, plane.width()) }; + assert_eq!(pixels, row); + } + } +} diff --git a/rust/xmf-sys/src/lib.rs b/rust/xmf-sys/src/lib.rs index 24a6a47..a280b58 100644 --- a/rust/xmf-sys/src/lib.rs +++ b/rust/xmf-sys/src/lib.rs @@ -89,6 +89,11 @@ pub mod raw { fn XmfVpxImage_Destroy(ctx: *mut XmfVpxImage) -> (), fn XmfVpxImage_GetWidth(image: *const XmfVpxImage) -> u32, fn XmfVpxImage_GetHeight(image: *const XmfVpxImage) -> u32, + fn XmfVpxImage_GetFormat(image: *const XmfVpxImage) -> c_int, + fn XmfVpxImage_GetPlane(image: *const XmfVpxImage, plane: c_int) -> *const u8, + fn XmfVpxImage_GetStride(image: *const XmfVpxImage, plane: c_int) -> c_int, + fn XmfVpxImage_GetColorSpace(image: *const XmfVpxImage) -> c_int, + fn XmfVpxImage_GetColorRange(image: *const XmfVpxImage) -> c_int, // VPX Packet fn XmfVpxPacket_Destroy(packet: *mut XmfVpxPacket) -> (), diff --git a/rust/xmf-sys/src/vpx.rs b/rust/xmf-sys/src/vpx.rs index b31bf62..3c27746 100644 --- a/rust/xmf-sys/src/vpx.rs +++ b/rust/xmf-sys/src/vpx.rs @@ -1,9 +1,31 @@ use core::fmt; -use std::ffi::{c_uint, c_void}; +use std::ffi::{c_int, c_uint, c_void}; use std::fmt::Debug; pub const VPX_EFLAG_FORCE_KF: u32 = 0x00000001; +/// libvpx `vpx_img_fmt_t` value for 8-bit planar YUV 4:2:0. +pub const VPX_IMG_FMT_I420: c_int = 0x102; + +// libvpx plane indices. +pub const VPX_PLANE_Y: c_int = 0; +pub const VPX_PLANE_U: c_int = 1; +pub const VPX_PLANE_V: c_int = 2; + +// libvpx `vpx_color_space_t` values. +pub const VPX_CS_UNKNOWN: c_int = 0; +pub const VPX_CS_BT_601: c_int = 1; +pub const VPX_CS_BT_709: c_int = 2; +pub const VPX_CS_SMPTE_170: c_int = 3; +pub const VPX_CS_SMPTE_240: c_int = 4; +pub const VPX_CS_BT_2020: c_int = 5; +pub const VPX_CS_RESERVED: c_int = 6; +pub const VPX_CS_SRGB: c_int = 7; + +// libvpx `vpx_color_range_t` values. +pub const VPX_CR_STUDIO_RANGE: c_int = 0; +pub const VPX_CR_FULL_RANGE: c_int = 1; + pub type XmfVpxEncoder = c_void; pub type XmfVpxDecoder = c_void; pub type XmfVpxImage = c_void;