diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md index 6595b85e60..c684833caf 100644 --- a/HEIF_IMPLEMENTATION_PLAN.md +++ b/HEIF_IMPLEMENTATION_PLAN.md @@ -847,6 +847,7 @@ Encoder verification contract: - [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. - [x] Finalized fixed-block decisions now set the block-level transform-skip flag only when every retained luma and coded chroma transform has zero EOB, matching current libaom's conjunction of per-plane skip state. The previous always-false flag produced legal but redundant non-skip and zero-coefficient syntax. Monochrome and 4:2:0 regressions prove both branches from actual coefficient state; the focused decision and production-frame set passes 32 of 32 direct net11 VSTest cases. Current-main `aomdec` accepts all 29 regenerated payloads, the recorded decoded-frame MD5s are unchanged, and affected 16x16 constant 8-bit and 10-bit payloads are one byte smaller. The complete HEIF/AV1 namespace passes 8,862 of 8,862 cases with zero failures or skips. - [x] Operation-wide allocation tracking now exercises a real 64x64 12-bit 4:4:4 frame through packed-pixel conversion, both native frame owners, picture and coefficient state, reusable block workspaces, entropy coding, OBU framing, and a non-seekable destination. It proves exactly one 60 KiB tile-output reservation from current libaom's all-intra 2.5x rule and balanced exactly-once returns for every tracked allocation before the operation completes. The focused ownership case passes 1 of 1 and the complete HEIF/AV1 namespace passes 8,863 of 8,863 direct net11 VSTest cases with zero failures or skips. +- [x] HEIF box offsets are now counted from the start of the encoded file instead of reading `Stream.Position`. This preserves ISO BMFF file-relative `iloc` offsets when the destination begins at a nonzero position and permits non-seekable output. Decoder item extents and image-sequence chunk offsets now resolve from that same file origin rather than the backing stream origin. Real legacy-JPEG HEIF round trips cover non-seekable output and a prefixed destination, while current-position AV1 decode covers both a still item and a five-frame sequence. All 96 encoder/decoder cases and all 38 sequence-parser cases pass direct net11 Release VSTest; the Release build remains at the established 1,005-warning baseline with zero errors. ### 7. Write complete AVIF output diff --git a/src/ImageSharp/Formats/Heif/HeifDecoderCore.cs b/src/ImageSharp/Formats/Heif/HeifDecoderCore.cs index c83f5e85f6..f8b8a4a93f 100644 --- a/src/ImageSharp/Formats/Heif/HeifDecoderCore.cs +++ b/src/ImageSharp/Formats/Heif/HeifDecoderCore.cs @@ -79,6 +79,11 @@ internal sealed class HeifDecoderCore : ImageDecoderCore /// private readonly List itemLinks; + /// + /// The absolute stream position at which the current HEIF file begins. + /// + private long fileStartOffset; + /// /// The absolute stream offset of the item-data box payload, or -1 when no item-data box exists. /// @@ -137,6 +142,7 @@ internal sealed class HeifDecoderCore : ImageDecoderCore /// protected override Image Decode(BufferedReadStream stream, CancellationToken cancellationToken) { + this.fileStartOffset = stream.Position; HeifFileType fileType = this.ReadFileTypeBox(stream); if (fileType == HeifFileType.Unsupported) { @@ -184,6 +190,7 @@ internal sealed class HeifDecoderCore : ImageDecoderCore /// protected override ImageInfo Identify(BufferedReadStream stream, CancellationToken cancellationToken) { + this.fileStartOffset = stream.Position; HeifFileType fileType = this.ReadFileTypeBox(stream); if (fileType == HeifFileType.Unsupported) { @@ -270,7 +277,7 @@ internal sealed class HeifDecoderCore : ImageDecoderCore throw new InvalidImageContentException("The HEIF image sequence contains more than one movie box."); } - sequence = this.sequenceParser.Parse(stream, boxLength); + sequence = this.sequenceParser.Parse(stream, boxLength, this.fileStartOffset); } else { @@ -610,7 +617,7 @@ internal sealed class HeifDecoderCore : ImageDecoderCore try { Span sampleData = sampleOwner.GetSpan()[..sample.Length]; - stream.Position = sample.Offset; + stream.Position = this.fileStartOffset + sample.Offset; HeifBoxReader.ReadExactly(stream, sampleData, "The HEIF image-sequence sample is truncated."); codecConfiguration.ValidateSampleData( sampleData, @@ -2195,8 +2202,10 @@ internal sealed class HeifDecoderCore : ImageDecoderCore if (location.Origin == HeifLocationOffsetOrigin.FileOffset) { // Construction method zero resolves base_offset + extent_offset from the start of the file. - sourceOffset = relativeOffset; - sourceBytesRemaining = stream.Length - sourceOffset; + long fileLength = stream.Length - this.fileStartOffset; + HeifBoxReader.EnsureInsideParent(relativeOffset, fileLength); + sourceOffset = this.fileStartOffset + relativeOffset; + sourceBytesRemaining = fileLength - relativeOffset; } else if (location.Origin == HeifLocationOffsetOrigin.ItemDataOffset) { diff --git a/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs b/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs index 25704b2f57..cc3e955492 100644 --- a/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs +++ b/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs @@ -65,8 +65,8 @@ internal sealed class HeifEncoderCore GenerateItems(image, compressedPixels.Length, items); // Write out the generated header and pixels. - this.WriteFileTypeBox(stream); - this.WriteMetadataBox(items, links, stream); + long metadataBoxOffset = this.WriteFileTypeBox(stream); + this.WriteMetadataBox(items, links, metadataBoxOffset, stream); this.WriteMediaDataBox(compressedPixels, stream); stream.Flush(); } @@ -138,7 +138,8 @@ internal sealed class HeifEncoderCore /// Writes the major brand, minor version, and compatible brands for the current HEIF output. /// /// The destination stream. - private void WriteFileTypeBox(Stream stream) + /// The number of bytes written. + private int WriteFileTypeBox(Stream stream) { Span buffer = stackalloc byte[16]; int bytesWritten = WriteBoxHeader(buffer, Heif4CharCode.Ftyp); @@ -149,6 +150,8 @@ internal sealed class HeifEncoderCore BinaryPrimitives.WriteUInt32BigEndian(buffer, (uint)bytesWritten); stream.Write(buffer); + + return bytesWritten; } /// @@ -156,8 +159,9 @@ internal sealed class HeifEncoderCore /// /// The declared image and metadata items. /// The typed relationships between items. + /// The metadata box offset from the start of the encoded file. /// The destination stream positioned after the file-type box. - private void WriteMetadataBox(List items, List links, Stream stream) + private void WriteMetadataBox(List items, List links, long metadataBoxOffset, Stream stream) { using AutoExpandingMemory memory = new(this.configuration, 0x1000); Span buffer = memory.GetSpan(12); @@ -179,7 +183,7 @@ internal sealed class HeifEncoderCore bytesWritten += WriteItemLocationBox(memory, bytesWritten, items, 0); // The mdat payload immediately follows the completed meta box and its own eight-byte header. - long mediaDataOffset = checked(stream.Position + bytesWritten + 8); + long mediaDataOffset = checked(metadataBoxOffset + bytesWritten + 8); WriteItemLocationBox(memory, itemLocationOffset, items, mediaDataOffset); buffer = memory.GetSpan(bytesWritten); diff --git a/src/ImageSharp/Formats/Heif/HeifSequenceParser.cs b/src/ImageSharp/Formats/Heif/HeifSequenceParser.cs index be39e4751e..6fff6edf2f 100644 --- a/src/ImageSharp/Formats/Heif/HeifSequenceParser.cs +++ b/src/ImageSharp/Formats/Heif/HeifSequenceParser.cs @@ -44,6 +44,11 @@ internal sealed class HeifSequenceParser /// private readonly DecoderOptions options; + /// + /// The absolute stream position at which the current HEIF file begins. + /// + private long fileStartOffset; + /// /// Initializes a new instance of the class. /// @@ -62,9 +67,11 @@ internal sealed class HeifSequenceParser /// /// The seekable HEIF stream positioned at the movie payload. /// The validated movie payload length. + /// The absolute stream position at which the HEIF file begins. /// The bounded image-sequence model required by the HEIF decoder. - public HeifSequence Parse(Stream stream, long boxLength) + public HeifSequence Parse(Stream stream, long boxLength, long fileStartOffset = 0) { + this.fileStartOffset = fileStartOffset; long movieStart = stream.Position; long movieEnd = checked(movieStart + boxLength); HeifBoxReader.EnsureInsideParent(boxLength, stream.Length - movieStart); @@ -821,7 +828,7 @@ internal sealed class HeifSequenceParser out int entryCount); stream.Position = chunkOffsets.Offset; - ResolveSampleLocations(stream, chunkOffsets.Length, chunkOffsets.Type, chunkCount, entries.GetSpan()[..entryCount], track, scratch); + this.ResolveSampleLocations(stream, chunkOffsets.Length, chunkOffsets.Type, chunkCount, entries.GetSpan()[..entryCount], track, scratch); if (syncSamples.IsPresent) { stream.Position = syncSamples.Offset; @@ -1524,7 +1531,7 @@ internal sealed class HeifSequenceParser /// The retained sample-to-chunk runs. /// The selected track receiving absolute sample locations. /// The parser-owned reusable scratch span. - private static void ResolveSampleLocations( + private void ResolveSampleLocations( Stream stream, long boxLength, Heif4CharCode boxType, @@ -1538,10 +1545,11 @@ internal sealed class HeifSequenceParser HeifBoxPayloadReader reader = new(stream, checked((long)chunkCount * entrySize), scratch, "chunk offsets"); int retainedSample = 0; int runIndex = 0; + long fileLength = stream.Length - this.fileStartOffset; for (uint chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) { ulong chunkOffset = entrySize == 8 ? reader.ReadUInt64() : reader.ReadUInt32(); - if (chunkOffset > (ulong)stream.Length) + if (chunkOffset > (ulong)fileLength) { throw new InvalidImageContentException("An image-sequence chunk offset extends beyond the file."); } @@ -1558,7 +1566,7 @@ internal sealed class HeifSequenceParser { ref HeifSequenceSample sample = ref track.Samples[retainedSample++]; ulong sampleEnd = checked(sampleOffset + (uint)sample.Length); - if (sampleEnd > (ulong)stream.Length || sampleOffset > long.MaxValue) + if (sampleEnd > (ulong)fileLength || sampleOffset > long.MaxValue) { throw new InvalidImageContentException("An image-sequence sample extends beyond the file."); } diff --git a/tests/ImageSharp.Tests/Formats/Heif/HeifDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Heif/HeifDecoderTests.cs index 9147fdeaf3..189a2cb5e0 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/HeifDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/HeifDecoderTests.cs @@ -93,6 +93,28 @@ public class HeifDecoderTests } } + [Theory] + [InlineData(TestImages.Heif.Orange4x4, 1, 4, 4)] + [InlineData(TestImages.Heif.Animated8Bit, 5, 150, 150)] + public void DecodeFromCurrentStreamPosition( + string imagePath, + int expectedFrameCount, + int expectedWidth, + int expectedHeight) + { + TestFile testFile = TestFile.Create(imagePath); + using MemoryStream stream = new(); + stream.Write([1, 2, 3, 4]); + long fileStart = stream.Position; + stream.Write(testFile.Bytes); + stream.Position = fileStart; + + using Image image = Image.Load(stream); + + Assert.Equal(new Size(expectedWidth, expectedHeight), image.Size); + Assert.Equal(expectedFrameCount, image.Frames.Count); + } + /// /// Verifies that AVIF decoding preserves the exact embedded ICC profile bytes. /// diff --git a/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs index 4d9e20c453..6d6a8766bf 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs @@ -79,6 +79,38 @@ public class HeifEncoderTests Assert.Equal(image.Size, decoded.Size); } + [Fact] + public void LegacyJpegWritesNonSeekableStream() + { + using Image image = new(1, 1); + image[0, 0] = new Rgba32(10, 20, 30); + using MemoryStream storage = new(); + using NonSeekableStream destination = new(storage); + + image.Save(destination, new HeifEncoder()); + + Assert.NotEqual(0, storage.Length); + storage.Position = 0; + using Image decoded = Image.Load(storage); + Assert.Equal(image.Size, decoded.Size); + } + + [Fact] + public void LegacyJpegWritesAtCurrentStreamPosition() + { + using Image image = new(1, 1); + image[0, 0] = new Rgba32(10, 20, 30); + using MemoryStream stream = new(); + stream.Write([1, 2, 3, 4]); + long fileStart = stream.Position; + + image.Save(stream, new HeifEncoder()); + + stream.Position = fileStart; + using Image decoded = Image.Load(stream); + Assert.Equal(image.Size, decoded.Size); + } + [Fact] public void LegacyJpegRejectsLosslessEncoding() {