Browse Source

Honor HEIF file-relative stream offsets

pull/2633/head
James Jackson-South 1 month ago
parent
commit
c97e2d7693
  1. 1
      HEIF_IMPLEMENTATION_PLAN.md
  2. 17
      src/ImageSharp/Formats/Heif/HeifDecoderCore.cs
  3. 14
      src/ImageSharp/Formats/Heif/HeifEncoderCore.cs
  4. 18
      src/ImageSharp/Formats/Heif/HeifSequenceParser.cs
  5. 22
      tests/ImageSharp.Tests/Formats/Heif/HeifDecoderTests.cs
  6. 32
      tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs

1
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

17
src/ImageSharp/Formats/Heif/HeifDecoderCore.cs

@ -79,6 +79,11 @@ internal sealed class HeifDecoderCore : ImageDecoderCore
/// </summary>
private readonly List<HeifItemLink> itemLinks;
/// <summary>
/// The absolute stream position at which the current HEIF file begins.
/// </summary>
private long fileStartOffset;
/// <summary>
/// The absolute stream offset of the item-data box payload, or <c>-1</c> when no item-data box exists.
/// </summary>
@ -137,6 +142,7 @@ internal sealed class HeifDecoderCore : ImageDecoderCore
/// <inheritdoc/>
protected override Image<TPixel> Decode<TPixel>(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
/// <inheritdoc/>
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<byte> 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)
{

14
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.
/// </summary>
/// <param name="stream">The destination stream.</param>
private void WriteFileTypeBox(Stream stream)
/// <returns>The number of bytes written.</returns>
private int WriteFileTypeBox(Stream stream)
{
Span<byte> 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;
}
/// <summary>
@ -156,8 +159,9 @@ internal sealed class HeifEncoderCore
/// </summary>
/// <param name="items">The declared image and metadata items.</param>
/// <param name="links">The typed relationships between items.</param>
/// <param name="metadataBoxOffset">The metadata box offset from the start of the encoded file.</param>
/// <param name="stream">The destination stream positioned after the file-type box.</param>
private void WriteMetadataBox(List<HeifItem> items, List<HeifItemLink> links, Stream stream)
private void WriteMetadataBox(List<HeifItem> items, List<HeifItemLink> links, long metadataBoxOffset, Stream stream)
{
using AutoExpandingMemory<byte> memory = new(this.configuration, 0x1000);
Span<byte> 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);

18
src/ImageSharp/Formats/Heif/HeifSequenceParser.cs

@ -44,6 +44,11 @@ internal sealed class HeifSequenceParser
/// </summary>
private readonly DecoderOptions options;
/// <summary>
/// The absolute stream position at which the current HEIF file begins.
/// </summary>
private long fileStartOffset;
/// <summary>
/// Initializes a new instance of the <see cref="HeifSequenceParser"/> class.
/// </summary>
@ -62,9 +67,11 @@ internal sealed class HeifSequenceParser
/// </summary>
/// <param name="stream">The seekable HEIF stream positioned at the movie payload.</param>
/// <param name="boxLength">The validated movie payload length.</param>
/// <param name="fileStartOffset">The absolute stream position at which the HEIF file begins.</param>
/// <returns>The bounded image-sequence model required by the HEIF decoder.</returns>
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
/// <param name="entries">The retained sample-to-chunk runs.</param>
/// <param name="track">The selected track receiving absolute sample locations.</param>
/// <param name="scratch">The parser-owned reusable scratch span.</param>
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.");
}

22
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<Rgba32> image = Image.Load<Rgba32>(stream);
Assert.Equal(new Size(expectedWidth, expectedHeight), image.Size);
Assert.Equal(expectedFrameCount, image.Frames.Count);
}
/// <summary>
/// Verifies that AVIF decoding preserves the exact embedded ICC profile bytes.
/// </summary>

32
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<Rgba32> 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<Rgba32> decoded = Image.Load<Rgba32>(storage);
Assert.Equal(image.Size, decoded.Size);
}
[Fact]
public void LegacyJpegWritesAtCurrentStreamPosition()
{
using Image<Rgba32> 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<Rgba32> decoded = Image.Load<Rgba32>(stream);
Assert.Equal(image.Size, decoded.Size);
}
[Fact]
public void LegacyJpegRejectsLosslessEncoding()
{

Loading…
Cancel
Save