diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md
index 126aec4550..322d99c6b4 100644
--- a/HEIF_IMPLEMENTATION_PLAN.md
+++ b/HEIF_IMPLEMENTATION_PLAN.md
@@ -283,6 +283,32 @@ Frame/block RD and decoder filter follow-up after `aa2ecf690`:
superblock modifier from the range of subblock variances (`partition_search.c:5721-5734`).
`Av1IntraSuperblockEncoder.ModeDecision.cs:172-176` uses only the segment-zero base quantizer and intra flag.
These missing policies remain architectural deviations; no isolated multiplier adjustment was introduced.
+- The default `disable_trellis_quant = 3` (`av1/av1_cx_iface.c:291`) means
+ `NO_ESTIMATE_YRD_TRELLIS_OPT`, not final-pass-only optimization (`speed_features.c:2493-2513`).
+ `encodemb.h:157-162` and `tx_search.c:2084-2085,2216-2229` still allow optimization during transform
+ candidate evaluation under that default. Control value 2 selects final-pass-only behavior. A winner-only
+ optimizer would therefore leave the default production path incomplete.
+- The complete optimizer traversal at `av1/encoder/txb_rdopt.c:18-560` and its cost/dequantization helpers
+ (`txb_rdopt_utils.h:39-204`) were followed through level lowering, EOB replacement, empty-transform selection,
+ signed DC handling, plane/precision/tuning scaling, and joint rate/EOB/context publication. It retains only
+ three nonzero positions for the EOB phase, then changes traversal after that phase's bound is exceeded.
+ That bound belongs to this complete traversal; it is not a general candidate-pruning threshold.
+ Managed `Av1SymbolEncoder.cs:1183-1334` evaluates an unchanged coefficient vector and
+ `Av1TransformBlockEncoder.cs:1175-1225` stops at fast quantization. Existing scratch can support parts of the
+ arithmetic, but cost-state policy, evaluation stages, mutation, and reconstruction must be integrated together.
+- The caller policy also changes quantization, not only the decision to invoke trellis:
+ `tx_search.c:1964-2000,2147-2156,2216-2229` derives the MSE/SATD gates from the active evaluation stage,
+ switches between fast and regular quantization, then optimizes before distortion/reconstruction.
+ Threshold tables and default/mode/winner selection live in `speed_features.c:76-100` and `rd.h:353-381`.
+ Managed candidate reconstruction currently happens before `GetCoefficientCost`
+ (`Av1TransformBlockEncoder.cs:187-251`, `Av1IntraSuperblockEncoder.ModeDecision.cs:2494-2550`).
+ Adding optimization to that later cost call would reconstruct twice or leave candidate pixels stale.
+- The optional residual border policy is selected in `encoder.c:4559-4568`: GOOD mode, objective delta-Q,
+ TPL enabled, no AQ/segmentation/ROI/QP sweep/ducky path, and sharpness other than three.
+ `encodemb.c:80-173` fills outside-visible residuals using a whole-block mean, per-axis mean, or zero,
+ depending on transform type. Thus border residual reuse across transform candidates is conditional.
+ This policy is not an unconditional replacement for coded-edge replication, and is not enabled for ALLINTRA.
+ No isolated border-padding rule was added; its configuration and candidate integration remain open.
- Transform pixel-error normalization and the modeled-rate skip comparison were also traced through
`Av1TransformBlockEncoder.cs:577-590`, `Av1RateDistortion.cs:259-307`, native
`av1/encoder/model_rd.h:70-106,162-199`, and `av1/encoder/tx_search.c:979-1051`.
@@ -294,6 +320,32 @@ Frame/block RD and decoder filter follow-up after `aa2ecf690`:
retains bottom lines for its worker-capable traversal. This inspection establishes no decoder-wide or SIMD
completeness claim. Decoder CDEF storage remains an operation-scoped owner, not native reusable worker state.
+Range-writer output-capacity correction, verified after `93aba785f`:
+
+- `Av1SymbolWriter.cs:213-214,339` before correction sliced a fixed initial allocation for finalization
+ and eight-byte flushes. Reference `aom_dsp/entenc.c:78-91,270-285` grows capacity when either needs
+ more room. The packet estimate in `Av1FrameEncoder.cs:544-563` does not replace that range-coder policy.
+ This is a demonstrated writer-capacity deviation; no production frame overflowing that estimate was established.
+- The existing owner now grows at those two boundaries. Word flushes double the current tile capacity and add
+ eight bytes; finalization reserves its exact terminating length. Reallocation preserves finalized preceding
+ tiles and the current completed prefix, including bytes that can receive a backward carry. Pending bits stay
+ in the range state. The old owner is returned only after successful allocation/copy, and reset reuses capacity.
+ Existing frame aggregation remains; this does not complete native deferred-packing or worker ownership parity.
+- Before correction, the zero-capacity consecutive-tile regression failed in `Normalize` with
+ `ArgumentOutOfRangeException` (`writer-growth-red.trx`); VSTest stopped on that first failure.
+ Five small initial capacities now preserve three consecutive tiles byte-for-byte against sufficient-capacity
+ encoding, with CDF adaptation enabled/disabled. Existing native carry assertions cover two additional
+ finalization-growth capacities. Allocation limits independently exercise failure at both growth boundaries,
+ and allocation identities verify every successful owner is returned exactly once.
+- Final Release .NET 11 incremental build: zero errors and warnings; preceding test compilation:
+ 1,009 existing warnings. Roslynk reports zero compiler errors. Serialized Visual Studio VSTest passes
+ 2,292/2,292 entropy, intra-superblock, encoder-frame, and HEIF encoder cases in 26.9726 seconds
+ (`writer-growth-final.trx`), including the twelve focused writer cases.
+- Current optimized libaom decoding of the regenerated 23 palette, eight partition, and twelve color-sequence
+ streams matches all 38,973 samples exactly: maximum error 0, zero samples exceeding one.
+ These are bounded same-bitstream checks, not separate-encoder parity or a timing/quality improvement.
+ No benchmark was run. All temporary native comparison output and test reports remain excluded from commits.
+
Restoration processing-unit correction:
- Before correction, `Av1LoopRestorationDecoder.cs:147-166,309-358` sized its bordered source, Wiener
diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs
index f706004430..1720c5898e 100644
--- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs
+++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs
@@ -222,7 +222,7 @@ internal sealed class Av1SymbolEncoder : IDisposable
/// Initializes a new instance of the class with reusable tile state.
///
/// The configuration providing output and temporary memory.
- /// The complete fixed output allocation length in bytes.
+ /// The initial output capacity in bytes.
/// The frame base quantizer index.
/// A value indicating whether encoded symbols adapt their tile distributions.
public Av1SymbolEncoder(Configuration configuration, int bufferLength, int qIndex, bool updateCdf)
@@ -1527,7 +1527,7 @@ internal sealed class Av1SymbolEncoder : IDisposable
/// Exposes a prefix containing every consecutively encoded tile without copying their bytes.
///
/// The number of bytes in the prefix.
- /// The encoded prefix, valid until this encoder is reset to offset zero or disposed.
+ /// The encoded prefix, valid until this encoder is reset or disposed.
public ReadOnlyMemory GetOutput(int length)
=> this.writer.GetOutput(length);
diff --git a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolWriter.cs b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolWriter.cs
index a8110cd755..6b26d84d2e 100644
--- a/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolWriter.cs
+++ b/src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolWriter.cs
@@ -46,14 +46,14 @@ internal sealed class Av1SymbolWriter : IDisposable
private readonly Configuration configuration;
///
- /// The owner of the fixed output buffer shared by consecutively encoded tiles.
+ /// The owner of the output buffer shared by consecutively encoded tiles.
///
- private readonly IMemoryOwner bufferOwner;
+ private IMemoryOwner bufferOwner;
///
/// The complete requested output allocation, including every consecutively encoded tile.
///
- private readonly Memory outputBuffer;
+ private Memory outputBuffer;
///
/// The requested output range, excluding any excess capacity returned by a pooling allocator.
@@ -71,10 +71,10 @@ internal sealed class Av1SymbolWriter : IDisposable
private int position;
///
- /// Initializes a new instance of the class with a bounded output size.
+ /// Initializes a new instance of the class.
///
/// The configuration that supplies output allocation.
- /// The complete fixed output allocation length in bytes.
+ /// The initial output capacity in bytes.
/// A value indicating whether encoded symbols adapt their distributions.
public Av1SymbolWriter(Configuration configuration, int bufferLength, bool updateCdf)
{
@@ -86,7 +86,7 @@ internal sealed class Av1SymbolWriter : IDisposable
}
///
- /// Restores the initial range-coder state while retaining the bounded output allocation.
+ /// Restores the initial range-coder state and begins a new output sequence.
///
public void Reset() => this.Reset(0);
@@ -192,7 +192,7 @@ internal sealed class Av1SymbolWriter : IDisposable
/// Exposes a prefix containing consecutively encoded tiles without copying their bytes.
///
/// The number of bytes in the prefix.
- /// The encoded prefix, valid until this writer is reset to offset zero or disposed.
+ /// The encoded prefix, valid until this writer is reset or disposed.
public ReadOnlyMemory GetOutput(int length) => this.outputBuffer[..length];
///
@@ -211,6 +211,12 @@ internal sealed class Av1SymbolWriter : IDisposable
ulong e = ((l + m) & ~m) | (m + 1);
s += c;
int pendingByteCount = Math.Max((s + 7) >> 3, 0);
+ if (pos + pendingByteCount > this.buffer.Length)
+ {
+ // Finalization needs only the terminating bytes; ordinary word flushes reserve their own headroom.
+ this.ResizeBuffer(pos + pendingByteCount);
+ }
+
Span buffer = this.buffer.Span[..(pos + pendingByteCount)];
if (s > 0)
{
@@ -336,6 +342,13 @@ internal sealed class Av1SymbolWriter : IDisposable
// bytes together while preserving one carry bit.
if (s >= 40)
{
+ if (this.position + sizeof(ulong) > this.buffer.Length)
+ {
+ // A word store touches eight bytes even when fewer become logical output. Double the current
+ // tile capacity and add one word, matching the range coder's amortized growth from an empty buffer.
+ this.ResizeBuffer(checked((2 * this.buffer.Length) + sizeof(ulong)));
+ }
+
Span buffer = this.buffer.Span[..(this.position + sizeof(ulong))];
int readyByteCount = (s >> 3) + 1;
c += 24 - (readyByteCount << 3);
@@ -365,6 +378,27 @@ internal sealed class Av1SymbolWriter : IDisposable
this.cnt = s;
}
+ ///
+ /// Replaces the output owner while retaining finalized tiles and the current tile's completed bytes.
+ ///
+ /// The required capacity starting at the current tile's output offset.
+ private void ResizeBuffer(int tileCapacity)
+ {
+ int outputOffset = this.outputBuffer.Length - this.buffer.Length;
+ int capacity = checked(outputOffset + tileCapacity);
+ IMemoryOwner replacement = this.configuration.MemoryAllocator.Allocate(capacity);
+ Memory replacementBuffer = replacement.Memory[..capacity];
+
+ // Previous tile bytes remain part of the frame payload. The current tile's completed prefix also carries
+ // backward into earlier bytes, so preserve that prefix before returning the old owner. Pending bits stay
+ // in low/cnt and need no copy. If allocation fails, the original owner remains available for disposal.
+ this.outputBuffer.Span[..(outputOffset + this.position)].CopyTo(replacementBuffer.Span);
+ this.bufferOwner.Dispose();
+ this.bufferOwner = replacement;
+ this.outputBuffer = replacementBuffer;
+ this.buffer = replacementBuffer[outputOffset..];
+ }
+
///
/// Adds a carry to the completed output prefix.
///
diff --git a/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs b/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs
index 0dd214715f..69ef8c6f70 100644
--- a/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs
+++ b/src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs
@@ -545,6 +545,7 @@ internal static class Av1FrameEncoder
{
// Libaom reserves 2.5 times the 32-sample-aligned native input for an all-intra output packet.
// Counting the active planes directly retains that headroom without charging monochrome for unused chroma.
+ // This is an initial estimate: the range writer grows if encoded syntax exceeds its remaining capacity.
int alignedWidth = Av1Math.AlignPowerOf2(width, OutputAlignmentLog2);
int alignedHeight = Av1Math.AlignPowerOf2(height, OutputAlignmentLog2);
int subsamplingX = colorConfig.SubSamplingX ? 1 : 0;
diff --git a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs
index ce9a817545..bbaddfb107 100644
--- a/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs
@@ -931,10 +931,13 @@ public class Av1EntropyTests
int expected)
=> Assert.Equal(expected, Av1RateDistortion.GetInterFrameRateMultiplier(qIndex, (Av1BitDepth)bitDepth));
- [Fact]
- public void SymbolWriterMatchesCurrentLibaomCarryRegression()
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(ShortSyntaxBufferLength)]
+ public void SymbolWriterMatchesCurrentLibaomCarryRegression(int initialCapacity)
{
- using Av1SymbolWriter writer = new(Configuration.Default, ShortSyntaxBufferLength, updateCdf: false);
+ using Av1SymbolWriter writer = new(Configuration.Default, initialCapacity, updateCdf: false);
writer.WriteBoolean(false, 16_384);
writer.WriteBoolean(false, 16_384);
writer.WriteBoolean(true, 512);
@@ -968,6 +971,105 @@ public class Av1EntropyTests
Assert.Equal(allocation.HashCodeOfBuffer, returned.HashCodeOfBuffer);
}
+ [Theory]
+ [InlineData(0, false)]
+ [InlineData(1, true)]
+ [InlineData(7, false)]
+ [InlineData(8, true)]
+ [InlineData(17, true)]
+ public void SymbolWriterGrowthPreservesConsecutiveTiles(int initialCapacity, bool updateCdf)
+ {
+ TestMemoryAllocator allocator = new();
+ allocator.EnableNonThreadSafeLogging();
+ Configuration configuration = Configuration.Default.Clone();
+ configuration.MemoryAllocator = allocator;
+
+ using (Av1SymbolWriter writer = new(configuration, initialCapacity, updateCdf))
+ using (Av1SymbolWriter expected = new(Configuration.Default, 8192, updateCdf))
+ {
+ int outputLength = 0;
+ for (int tile = 0; tile < 3; tile++)
+ {
+ writer.Reset(outputLength);
+ expected.Reset(outputLength);
+ Av1Distribution distribution = new(100, 16000, 32000);
+ Av1Distribution expectedDistribution = new(100, 16000, 32000);
+ for (int index = 0; index < 257; index++)
+ {
+ // Small intervals provoke carries while literals cross repeated word-flush boundaries.
+ int symbol = (index + tile) & 3;
+ uint literal = (uint)((index * 73) + tile);
+ writer.WriteSymbol(symbol, distribution);
+ writer.WriteLiteral(literal, 8);
+ expected.WriteSymbol(symbol, expectedDistribution);
+ expected.WriteLiteral(literal, 8);
+ }
+
+ ReadOnlyMemory actualTile = writer.Exit(out int length);
+ ReadOnlyMemory expectedTile = expected.Exit(out int expectedLength);
+ Assert.Equal(expectedLength, length);
+ Assert.True(expectedTile.Span.SequenceEqual(actualTile.Span));
+ outputLength += length;
+
+ // Growth in a later tile must preserve all earlier finalized tile bytes too.
+ Assert.True(expected.GetOutput(outputLength).Span.SequenceEqual(writer.GetOutput(outputLength).Span));
+ }
+
+ Assert.True(allocator.AllocationLog.Count > 1);
+ Assert.Equal(allocator.AllocationLog.Count - 1, allocator.ReturnLog.Count);
+ int allocations = allocator.AllocationLog.Count;
+ writer.Reset();
+ writer.WriteLiteral(false);
+ _ = writer.Exit(out _);
+ Assert.Equal(allocations, allocator.AllocationLog.Count);
+ }
+
+ Assert.Equal(
+ allocator.AllocationLog.Select(x => x.AllocationId).Order(),
+ allocator.ReturnLog.Select(x => x.AllocationId).Order());
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void SymbolWriterGrowthFailureRetainsItsOwner(bool failDuringFinalization)
+ {
+ int initialCapacity = failDuringFinalization ? 1 : 8;
+ OutputLimitedAllocator allocator = new(initialCapacity);
+ allocator.EnableNonThreadSafeLogging();
+ Configuration configuration = Configuration.Default.Clone();
+ configuration.MemoryAllocator = allocator;
+
+ using (Av1SymbolWriter writer = new(configuration, initialCapacity, updateCdf: false))
+ {
+ Assert.Throws(() =>
+ {
+ if (failDuringFinalization)
+ {
+ writer.WriteBoolean(false, 16_384);
+ writer.WriteBoolean(false, 16_384);
+ writer.WriteBoolean(true, 512);
+ writer.WriteBoolean(false, 8_192);
+ _ = writer.Exit(out _);
+ }
+ else
+ {
+ for (int index = 0; index < 32; index++)
+ {
+ writer.WriteLiteral((uint)index, 8);
+ }
+ }
+ });
+
+ Assert.Single(allocator.AllocationLog);
+ Assert.Empty(allocator.ReturnLog);
+ }
+
+ Assert.Equal(
+ Assert.Single(allocator.AllocationLog).AllocationId,
+ Assert.Single(allocator.ReturnLog).AllocationId);
+ }
+
[Fact]
public void SymbolWriterResetReusesExistingOutputAllocation()
{
@@ -2268,6 +2370,12 @@ public class Av1EntropyTests
return result;
}
+ // Exercise the allocator's actual contiguous-buffer boundary while retaining the existing owner log.
+ private sealed class OutputLimitedAllocator : TestMemoryAllocator
+ {
+ public OutputLimitedAllocator(int limit) => this.SingleBufferAllocationLimitBytes = limit;
+ }
+
public static TheoryData GetInterTransformTypeData()
{
TheoryData result = [];