diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md index 28c5cc174b..72fd7fe1f3 100644 --- a/HEIF_IMPLEMENTATION_PLAN.md +++ b/HEIF_IMPLEMENTATION_PLAN.md @@ -806,7 +806,7 @@ Encoder data-flow contract: 2. Convert each generic `ImageFrame` once through `PixelOperations` and the SIMD-first HEIF planar converter into native 8, 10, or 12-bit planes. Alpha is encoded as an auxiliary image when requested by the resolved output contract; it is not discarded through a source scan. 3. Reuse allocator-owned plane, row, block, transform, quantization, entropy, and reconstruction workspaces for the complete frame. No active path may allocate per row, block, transform, scanline, or SIMD tail. 4. Analyze and encode tiles directly from those planes, retaining reconstructed reference frames only for the bounded sequence lifetime. -5. Stream OBUs and container extents through allocator-backed chunked storage. Every ownership transfer is explicit, every owner is disposed exactly once, and no `ToArray` or file-sized copy crosses a layer boundary. +5. Build OBU headers in bounded allocator-backed scratch and stream entropy-coded tile owners and container extents directly. Every ownership transfer is explicit, every owner is disposed exactly once, and no `ToArray` or file-sized copy crosses a layer boundary. 6. Iterate image frames using ImageSharp frame metadata and format-connecting metadata. Root-frame-only behavior is permitted only for an explicitly static output contract. Encoder verification contract: @@ -844,6 +844,7 @@ Encoder verification contract: - [x] The exact net11 Release rebuild completed at the established 1,005-warning repository baseline with zero errors. The complete HEIF/AV1 namespace passes 8,838 of 8,838 direct VSTest cases with zero failures or skips. Roslynk reports zero compiler errors and no diagnostics in the five changed C# files; `git diff --check` passes and `.gitattributes` is unchanged. - [~] The same internal frame operation now produces 4:2:0, 4:2:2, and 4:4:4 payloads at 8, 10, and 12 bits. Twenty-one color cases cover constant and spatially varying input at aligned dimensions plus odd 13x11 visible dimensions for every chroma geometry. The production decoder consumes every payload, the decoded output retains non-neutral chroma, and current-main `aomdec` accepts all 29 monochrome and color outputs. The odd-dimension decoded-frame MD5 values are `d025030a44750e703bcca9a6d3e131e8` (4:2:0), `75936a76104ec678c7a1f10b4c560172` (4:2:2), and `044026b1518aa94f3c2db82c7247c0d6` (4:4:4). This proves legal current-libaom payload syntax across native plane geometries; it does not yet prove target quality or native-plane equality with an independently encoded reference. - [x] The expanded checkpoint exposed a pre-existing transform-block test that asserted uninitialized pooled padding was zero. The test now initializes the complete physical luma plane with a sentinel and proves the block operation leaves both adjacent padding samples unchanged. The exact net11 Release rebuild remains at 1,005 baseline warnings and zero errors, the focused allocator-order set passes 30 of 30 cases, and the complete HEIF/AV1 namespace passes 8,859 of 8,859 direct VSTest cases with zero failures or skips. +- [x] Combined-frame OBU output now counts the byte-aligned frame and tile-group headers, non-final tile-size fields, and owned tile payloads before emitting the OBU size. It retains only the small allocator-owned header scratch and writes each entropy-coded tile span directly from its detached owner, removing the second file-sized allocator rent and complete-payload copy. A 64 KiB regression proves exactly one sub-payload-sized byte rent with a balanced return and verifies the exact streamed tile tail; the existing two-tile round trip proves size-prefix and ordering parity. The focused writer and production-frame set passes 32 of 32 direct net11 VSTest cases, current-main `aomdec` accepts all 29 generated native-format payloads, and the complete HEIF/AV1 namespace passes 8,860 of 8,860 cases with zero failures or skips. ### 7. Write complete AVIF output diff --git a/src/ImageSharp/Formats/Heif/Av1/OpenBitstreamUnit/ObuWriter.cs b/src/ImageSharp/Formats/Heif/Av1/OpenBitstreamUnit/ObuWriter.cs index 00244d3216..5d73612cdb 100644 --- a/src/ImageSharp/Formats/Heif/Av1/OpenBitstreamUnit/ObuWriter.cs +++ b/src/ImageSharp/Formats/Heif/Av1/OpenBitstreamUnit/ObuWriter.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Buffers.Binary; using SixLabors.ImageSharp.Formats.Heif.Av1.Pipeline.Quantizers; using SixLabors.ImageSharp.Formats.Heif.Av1.Transform; using SixLabors.ImageSharp.Memory; @@ -26,8 +27,8 @@ internal class ObuWriter Justification = "Preserves the existing writer instance contract.")] public void WriteAll(Configuration configuration, Stream stream, ObuSequenceHeader sequenceHeader, ObuFrameHeader frameHeader, IAv1TileWriter tileWriter) { - // The allocation expands when necessary; this initial size avoids repeated growth for - // the small headers and tiles produced by the current still-image encoder. + // The reusable scratch only contains headers. Entropy-coded tiles remain in their owning + // buffers and are streamed directly so the complete compressed frame is never duplicated. int initialBufferSize = 2000; using AutoExpandingMemory buffer = new(configuration, initialBufferSize); Av1BitStreamWriter writer = new(buffer); @@ -44,14 +45,34 @@ internal class ObuWriter if (frameHeader != null && sequenceHeader != null) { WriteFrameHeader(ref writer, sequenceHeader, frameHeader); - if (frameHeader.TilesInfo != null) + ObuTileGroupHeader tileInfo = frameHeader.TilesInfo; + if (tileInfo != null) { - WriteTileGroup(ref writer, frameHeader.TilesInfo, tileWriter); + WriteTileGroupHeader(ref writer, tileInfo); } - int bytesWritten = (writer.BitPosition + 7) >> 3; + int frameHeaderBytes = (writer.BitPosition + 7) >> 3; writer.Flush(); - WriteObuHeaderAndSize(stream, ObuType.Frame, buffer.GetSpan(bytesWritten)); + + uint framePayloadSize = (uint)frameHeaderBytes; + if (tileInfo != null) + { + int tileCount = tileInfo.TileColumnCount * tileInfo.TileRowCount; + framePayloadSize += (uint)((tileCount - 1) * tileInfo.TileSizeBytes); + + for (int tileNum = 0; tileNum < tileCount; tileNum++) + { + framePayloadSize += (uint)tileWriter.GetTileData(tileNum).Length; + } + } + + WriteObuHeaderAndSize(stream, ObuType.Frame, framePayloadSize); + stream.Write(buffer.GetSpan(frameHeaderBytes)); + + if (tileInfo != null) + { + WriteTileData(stream, tileInfo, tileWriter); + } } } @@ -74,14 +95,25 @@ internal class ObuWriter /// The OBU payload type. /// The complete OBU payload. private static void WriteObuHeaderAndSize(Stream stream, ObuType type, ReadOnlySpan payload) + { + WriteObuHeaderAndSize(stream, type, (uint)payload.Length); + stream.Write(payload); + } + + /// + /// Writes a byte-aligned OBU header and its little-endian base-128 payload size. + /// + /// The destination stream. + /// The OBU payload type. + /// The number of payload bytes that follow the header. + private static void WriteObuHeaderAndSize(Stream stream, ObuType type, uint payloadSize) { stream.WriteByte(WriteObuHeader(type)); // A 32-bit OBU payload length requires at most five base-128 bytes. Span lengthBytes = stackalloc byte[5]; - int lengthLength = Av1BitStreamWriter.GetLittleEndianBytes128((uint)payload.Length, lengthBytes); + int lengthLength = Av1BitStreamWriter.GetLittleEndianBytes128(payloadSize, lengthBytes); stream.Write(lengthBytes, 0, lengthLength); - stream.Write(payload); } /// @@ -520,12 +552,11 @@ internal class ObuWriter } /// - /// Writes a tile-group header and all tile payloads for a combined frame OBU. + /// Writes the byte-aligned tile-group header for a combined frame OBU. /// /// The bit writer receiving the tile group. /// The frame tile layout. - /// The writer that produces each entropy-coded tile payload. - private static void WriteTileGroup(ref Av1BitStreamWriter writer, ObuTileGroupHeader tileInfo, IAv1TileWriter tileWriter) + private static void WriteTileGroupHeader(ref Av1BitStreamWriter writer, ObuTileGroupHeader tileInfo) { int tileCount = tileInfo.TileColumnCount * tileInfo.TileRowCount; @@ -541,28 +572,30 @@ internal class ObuWriter } AlignToByteBoundary(ref writer); - - WriteTileData(ref writer, tileInfo, tileWriter); } /// /// Writes the size-prefixed tile payloads in raster order. /// - /// The byte-aligned bit writer receiving tile data. + /// The destination stream receiving tile data. /// The frame tile layout and tile-size field width. /// The writer that produces each tile payload. - private static void WriteTileData(ref Av1BitStreamWriter writer, ObuTileGroupHeader tileInfo, IAv1TileWriter tileWriter) + private static void WriteTileData(Stream stream, ObuTileGroupHeader tileInfo, IAv1TileWriter tileWriter) { int tileCount = tileInfo.TileColumnCount * tileInfo.TileRowCount; + Span tileSizeBuffer = stackalloc byte[sizeof(uint)]; + for (int tileNum = 0; tileNum < tileCount; tileNum++) { ReadOnlySpan tileData = tileWriter.GetTileData(tileNum); if (tileNum != tileCount - 1 && tileCount > 1) { - writer.WriteLittleEndian((uint)tileData.Length - 1U, tileInfo.TileSizeBytes); + // AV1 stores each non-final tile size minus one with the least-significant byte first. + BinaryPrimitives.WriteUInt32LittleEndian(tileSizeBuffer, (uint)tileData.Length - 1U); + stream.Write(tileSizeBuffer[..tileInfo.TileSizeBytes]); } - writer.WriteBlob(tileData); + stream.Write(tileData); } } diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/ObuFrameHeaderTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/ObuFrameHeaderTests.cs index 9437fec2af..a0251b8b23 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/Av1/ObuFrameHeaderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/ObuFrameHeaderTests.cs @@ -5,6 +5,7 @@ using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Heif.Av1; using SixLabors.ImageSharp.Formats.Heif.Av1.OpenBitstreamUnit; using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.Tests.Memory; namespace SixLabors.ImageSharp.Tests.Formats.Heif.Av1; @@ -704,6 +705,37 @@ public class ObuFrameHeaderTests Assert.Equal(bitStream.Length * 8, reader.BitPosition); } + /// + /// Verifies that the OBU writer streams an encoded tile from its owning buffer without renting a second payload-sized buffer. + /// + [Fact] + public void WriteFrameStreamsTilePayloadWithoutRentingPayloadCopy() + { + const int TilePayloadLength = 64 * 1024; + + TestMemoryAllocator allocator = new(); + allocator.EnableNonThreadSafeLogging(); + Configuration configuration = new(); + configuration.MemoryAllocator = allocator; + ObuSequenceHeader sequenceHeader = GetDefaultSequenceHeader(); + ObuFrameHeader frameHeader = GetKeyFrameHeader(); + byte[] tileData = new byte[TilePayloadLength]; + tileData.AsSpan().Fill(0x80); + Av1TileDecoderStub tileStub = new(); + tileStub.ReadTile(tileData, 0); + + using MemoryStream stream = new(); + ObuWriter writer = new(); + writer.WriteAll(configuration, stream, sequenceHeader, frameHeader, tileStub); + + TestMemoryAllocator.AllocationRequest headerScratch = Assert.Single(allocator.AllocationLog); + Assert.Equal(typeof(byte), headerScratch.ElementType); + Assert.InRange(headerScratch.Length, 1, TilePayloadLength - 1); + TestMemoryAllocator.ReturnRequest returned = Assert.Single(allocator.ReturnLog); + Assert.Equal(headerScratch.AllocationId, returned.AllocationId); + Assert.True(stream.GetBuffer().AsSpan((int)stream.Length - TilePayloadLength, TilePayloadLength).SequenceEqual(tileData)); + } + /// /// Verifies non-uniform tile boundaries use the next stored boundary and retain the clipped final mode-info edge. ///