From 04684412202de8bf5c157749b61dd2a2037e69a9 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 25 Aug 2026 13:49:25 +1000 Subject: [PATCH] Define HEIF encoder options --- HEIF_IMPLEMENTATION_PLAN.md | 12 +- src/ImageSharp/Formats/Heif/HeifBitDepth.cs | 25 +++++ .../Formats/Heif/HeifChromaSubsampling.cs | 30 +++++ .../Formats/Heif/HeifCompressionMethod.cs | 20 ---- src/ImageSharp/Formats/Heif/HeifEncoder.cs | 105 +++++++++++++++++- .../Formats/Heif/HeifEncoderCore.cs | 42 ++++++- .../Formats/Heif/IHeifEncoderOptions.cs | 12 -- .../Formats/Heif/HeifEncoderTests.cs | 81 ++++++++++++++ 8 files changed, 283 insertions(+), 44 deletions(-) create mode 100644 src/ImageSharp/Formats/Heif/HeifBitDepth.cs create mode 100644 src/ImageSharp/Formats/Heif/HeifChromaSubsampling.cs delete mode 100644 src/ImageSharp/Formats/Heif/IHeifEncoderOptions.cs diff --git a/HEIF_IMPLEMENTATION_PLAN.md b/HEIF_IMPLEMENTATION_PLAN.md index acbce5ece..c450c8869 100644 --- a/HEIF_IMPLEMENTATION_PLAN.md +++ b/HEIF_IMPLEMENTATION_PLAN.md @@ -56,11 +56,11 @@ Checkboxes may be marked complete only when the implementation and the verificat - [ ] Connect both payload encoders to the bounded HEIF writer with the selected bit depth, chroma layout, range, color signaling, alpha, metadata, and animation state. - [ ] Replace each `NotSupportedException` branch only when the corresponding payload is accepted by the pinned independent decoder and the ImageSharp decoder. - [ ] Verify that every public quality, effort, lossless, bit-depth, chroma, alpha, and metadata option changes or constrains the encoded output exactly as documented. -- [ ] **Queued:** complete `IHeifEncoderOptions` documentation, including all observable limits, and use `` consistently from `HeifEncoder` if the interface remains justified. - - [ ] Confirm that the interface is required by multiple concrete HEIF-family encoders and remove it if it does not represent a genuine shared public contract. - - [ ] Document the default, valid range, special values, invalid-value behavior, and format-dependent restrictions of every retained option using observable API behavior only. - - [ ] Keep the complete contract on `IHeifEncoderOptions` and use `` on matching `HeifEncoder` members instead of maintaining duplicate documentation. - - [ ] Verify option validation and API shape against the established ImageSharp encoder patterns before the Phase 1 API-review gate is marked complete. +- [x] **Completed:** define and document the HEIF encoder option contract using the established ImageSharp encoder pattern. + - [x] Confirm that `IHeifEncoderOptions` has only one concrete implementation and remove the unnecessary interface. + - [x] Document the default, valid range, special values, invalid-value behavior, and format-dependent restrictions of every retained option using observable API behavior only. + - [x] Pass `HeifEncoder` directly to `HeifEncoderCore`, matching the JPEG, PNG, and WebP encoder-core contracts and avoiding interface dispatch. + - [x] Verify construction-time range validation and legacy-JPEG codec-boundary restrictions with focused tests before the Phase 1 API-review gate is marked complete. Gain maps, progressive/layered images, sample transforms, and experimental extension brands require explicit conformance and API decisions. They do not create permission to omit any valid color, compression, or bit-depth path from the PR. The container reader must skip unsupported optional extensions safely and reject an unsupported essential property with a useful error. @@ -176,7 +176,7 @@ This assessment is based on the current source after the upstream ImageSharp mer - `HeifFormat` combines the HEIF, HEIC, HIF, and AVIF identities and extensions, but the implementation does not yet decode all payloads that contract implies. - `HeifDecoder` now defaults to `Rgba32`, preserving decoded auxiliary alpha for non-generic loads. - `HeifMetadata` now reports alpha presence and the corresponding 24/32-bit RGB pixel shape, but complete decoded HEVC/AV1 bit depth, monochrome/chroma layout, color signaling, and profiles remain absent. -- `IHeifEncoderOptions` is empty, and the encoder exposes no meaningful quality, speed, lossless, subsampling, bit-depth, or alpha policy. +- `HeifEncoder` defines quality, alpha quality, effort, lossless, chroma-subsampling, and bit-depth contracts directly, without a single-implementation options interface. The legacy JPEG path applies its supported quality, bit-depth, and chroma options and rejects unsupported combinations; AV1 and HEVC must implement the same public contracts before the Phase 1 API-review gate can pass. - HEIF/HEIC/AVIF is absent from the format source-generation list in `_Formats.ttinclude`, so the standard ImageSharp save extensions are not generated. - Configuration registration exists, but it currently registers capabilities broader than the implementation provides. diff --git a/src/ImageSharp/Formats/Heif/HeifBitDepth.cs b/src/ImageSharp/Formats/Heif/HeifBitDepth.cs new file mode 100644 index 000000000..be11bd6ae --- /dev/null +++ b/src/ImageSharp/Formats/Heif/HeifBitDepth.cs @@ -0,0 +1,25 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Heif; + +/// +/// Enumerates the component bit depths supported for HEIF image encoding. +/// +public enum HeifBitDepth : byte +{ + /// + /// Eight bits per image component. + /// + Bit8 = 8, + + /// + /// Ten bits per image component. + /// + Bit10 = 10, + + /// + /// Twelve bits per image component. + /// + Bit12 = 12 +} diff --git a/src/ImageSharp/Formats/Heif/HeifChromaSubsampling.cs b/src/ImageSharp/Formats/Heif/HeifChromaSubsampling.cs new file mode 100644 index 000000000..099430c7c --- /dev/null +++ b/src/ImageSharp/Formats/Heif/HeifChromaSubsampling.cs @@ -0,0 +1,30 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Heif; + +/// +/// Enumerates the chroma sampling layouts supported for HEIF image encoding. +/// +public enum HeifChromaSubsampling : byte +{ + /// + /// A single luminance plane without chroma planes. + /// + Monochrome, + + /// + /// Chroma sampled at half the luma resolution horizontally and vertically. + /// + Yuv420, + + /// + /// Chroma sampled at half the luma resolution horizontally and full resolution vertically. + /// + Yuv422, + + /// + /// Chroma sampled at full luma resolution horizontally and vertically. + /// + Yuv444 +} diff --git a/src/ImageSharp/Formats/Heif/HeifCompressionMethod.cs b/src/ImageSharp/Formats/Heif/HeifCompressionMethod.cs index 6789f94c9..f317301b4 100644 --- a/src/ImageSharp/Formats/Heif/HeifCompressionMethod.cs +++ b/src/ImageSharp/Formats/Heif/HeifCompressionMethod.cs @@ -18,28 +18,8 @@ public enum HeifCompressionMethod /// LegacyJpeg, - /// - /// JPEG 2000 coding. - /// - Jpeg2000, - - /// - /// JPEG XR coding. - /// - JpegXR, - - /// - /// JPEG XS coding. - /// - JpegXS, - /// /// AOMedia Video 1 (AV1) coding. /// Av1, - - /// - /// Advanced Video Coding (AVC). - /// - Avc, } diff --git a/src/ImageSharp/Formats/Heif/HeifEncoder.cs b/src/ImageSharp/Formats/Heif/HeifEncoder.cs index 170dc7bd7..b440fc393 100644 --- a/src/ImageSharp/Formats/Heif/HeifEncoder.cs +++ b/src/ImageSharp/Formats/Heif/HeifEncoder.cs @@ -4,10 +4,111 @@ namespace SixLabors.ImageSharp.Formats.Heif; /// -/// Image encoder for writing an image to a stream as HEIF images. +/// Image encoder for writing image data to a stream in a HEIF container. /// -public sealed class HeifEncoder : ImageEncoder +public sealed class HeifEncoder : AlphaAwareImageEncoder { + /// + /// Backing field for . + /// + private int? quality; + + /// + /// Backing field for . + /// + private int? alphaQuality; + + /// + /// Backing field for . + /// + private int effort = 5; + + /// + /// Gets the compression method used for the primary image item. + /// The default is . + /// + public HeifCompressionMethod CompressionMethod { get; init; } = HeifCompressionMethod.LegacyJpeg; + + /// + /// Gets the lossy compression quality, or to use the compression method's default quality. + /// Valid values range from 0 for the lowest quality to 100 for the highest quality. Legacy JPEG image items + /// support values from 1 through 100. A value of 100 does not enable encoding. + /// + /// The quality is outside the range 0 to 100. + public int? Quality + { + get => this.quality; + init + { + if (value is < 0 or > 100) + { + throw new ArgumentException("Quality must be in the range [0..100]."); + } + + this.quality = value; + } + } + + /// + /// Gets the lossy compression quality for the auxiliary alpha image, or to use the + /// effective . Valid values range from 0 for the lowest quality to 100 for the highest + /// quality. This option has no effect when the encoded image does not require an auxiliary alpha image. + /// + /// The alpha quality is outside the range 0 to 100. + public int? AlphaQuality + { + get => this.alphaQuality; + init + { + if (value is < 0 or > 100) + { + throw new ArgumentException("Alpha quality must be in the range [0..100]."); + } + + this.alphaQuality = value; + } + } + + /// + /// Gets the encoding effort in the range 0 to 10. A value of 0 selects the fastest encoding and 10 selects the + /// slowest encoding with the greatest compression effort. The default is 5. Legacy JPEG image items use a fixed + /// encoding effort, so this option does not affect them. + /// + /// The effort is outside the range 0 to 10. + public int Effort + { + get => this.effort; + init + { + if (value is < 0 or > 10) + { + throw new ArgumentException("Effort must be in the range [0..10]."); + } + + this.effort = value; + } + } + + /// + /// Gets a value indicating whether the primary and auxiliary alpha images are encoded without loss. When + /// , and do not affect the encoded image. + /// Legacy JPEG image items do not support lossless encoding. The default is . + /// + public bool Lossless { get; init; } + + /// + /// Gets the encoded precision of each image component, or to use the HEIF metadata bit + /// depth. Metadata that does not specify a bit depth defaults to . Legacy JPEG + /// image items support only . + /// + public HeifBitDepth? BitDepth { get; init; } + + /// + /// Gets the encoded chroma sampling, or to use + /// for lossy encoding and for lossless encoding. + /// + public HeifChromaSubsampling? ChromaSubsampling { get; init; } + /// protected override void Encode(Image image, Stream stream, CancellationToken cancellationToken) { diff --git a/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs b/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs index 7d4684c5e..8a28213fe 100644 --- a/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs +++ b/src/ImageSharp/Formats/Heif/HeifEncoderCore.cs @@ -47,7 +47,14 @@ internal sealed class HeifEncoderCore Guard.NotNull(image, nameof(image)); Guard.NotNull(stream, nameof(stream)); - byte[] pixels = CompressPixels(image, cancellationToken); + byte[] pixels = this.encoder.CompressionMethod switch + { + HeifCompressionMethod.LegacyJpeg => this.CompressPixels(image, cancellationToken), + HeifCompressionMethod.Av1 => throw new NotSupportedException("AV1 encoding is not implemented."), + HeifCompressionMethod.Hevc => throw new NotSupportedException("HEVC encoding is not implemented."), + _ => throw new NotSupportedException($"HEIF compression method '{this.encoder.CompressionMethod}' is not supported.") + }; + List items = new(); List links = new(); GenerateItems(image, pixels, items); @@ -59,7 +66,7 @@ internal sealed class HeifEncoderCore stream.Flush(); HeifMetadata meta = image.Metadata.GetHeifMetadata(); - meta.CompressionMethod = HeifCompressionMethod.LegacyJpeg; + meta.CompressionMethod = this.encoder.CompressionMethod; } /// @@ -432,13 +439,40 @@ internal sealed class HeifEncoderCore /// The source image. /// The token used to cancel payload encoding. /// The encoded JPEG item bytes. - private static byte[] CompressPixels(Image image, CancellationToken cancellationToken) + private byte[] CompressPixels(Image image, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { + if (this.encoder.Lossless) + { + throw new NotSupportedException("Legacy JPEG image items do not support lossless encoding."); + } + + if (this.encoder.BitDepth is not null && this.encoder.BitDepth != HeifBitDepth.Bit8) + { + throw new NotSupportedException("Legacy JPEG image items support only 8-bit component encoding."); + } + + if (this.encoder.Quality == 0) + { + // Zero is meaningful to the AV1 and HEVC quality scales, but ImageSharp's JPEG encoder deliberately + // exposes the JPEG quality scale as 1 through 100. Reject the codec-specific mismatch at this boundary. + throw new NotSupportedException("Legacy JPEG image items support quality values in the range [1..100]."); + } + + JpegColorType colorType = this.encoder.ChromaSubsampling switch + { + null or HeifChromaSubsampling.Yuv420 => JpegColorType.YCbCrRatio420, + HeifChromaSubsampling.Yuv422 => JpegColorType.YCbCrRatio422, + HeifChromaSubsampling.Yuv444 => JpegColorType.YCbCrRatio444, + HeifChromaSubsampling.Monochrome => JpegColorType.Luminance, + _ => throw new NotSupportedException($"HEIF chroma sampling '{this.encoder.ChromaSubsampling}' is not supported.") + }; + using MemoryStream stream = new(); JpegEncoder encoder = new() { - ColorType = JpegColorType.YCbCrRatio420 + Quality = this.encoder.Quality, + ColorType = colorType }; // ImageEncoder is a synchronous contract. Wait for the cancellable JPEG operation diff --git a/src/ImageSharp/Formats/Heif/IHeifEncoderOptions.cs b/src/ImageSharp/Formats/Heif/IHeifEncoderOptions.cs deleted file mode 100644 index 700ccc1e0..000000000 --- a/src/ImageSharp/Formats/Heif/IHeifEncoderOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -namespace SixLabors.ImageSharp.Formats.Heif; - -/// -/// Configuration options for use during HEIF encoding. -/// -internal interface IHeifEncoderOptions -{ - // None defined yet. -} diff --git a/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs index 61ceb6b14..8b1f2d804 100644 --- a/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Heif/HeifEncoderTests.cs @@ -12,6 +12,87 @@ namespace SixLabors.ImageSharp.Tests.Formats.Heif; [ValidateDisposedMemoryAllocations] public class HeifEncoderTests { + [Fact] + public void OptionsHaveExpectedDefaults() + { + HeifEncoder encoder = new(); + + Assert.Equal(HeifCompressionMethod.LegacyJpeg, encoder.CompressionMethod); + Assert.Null(encoder.Quality); + Assert.Null(encoder.AlphaQuality); + Assert.Equal(5, encoder.Effort); + Assert.False(encoder.Lossless); + Assert.Null(encoder.BitDepth); + Assert.Null(encoder.ChromaSubsampling); + } + + [Theory] + [InlineData(-1)] + [InlineData(101)] + public void QualityOutsideRangeThrows(int quality) + => Assert.Throws(() => new HeifEncoder { Quality = quality }); + + [Theory] + [InlineData(-1)] + [InlineData(101)] + public void AlphaQualityOutsideRangeThrows(int quality) + => Assert.Throws(() => new HeifEncoder { AlphaQuality = quality }); + + [Theory] + [InlineData(-1)] + [InlineData(11)] + public void EffortOutsideRangeThrows(int effort) + => Assert.Throws(() => new HeifEncoder { Effort = effort }); + + [Theory] + [InlineData(0, 0, 0)] + [InlineData(100, 100, 10)] + public void OptionRangeBoundariesAreAccepted(int quality, int alphaQuality, int effort) + { + HeifEncoder encoder = new() + { + Quality = quality, + AlphaQuality = alphaQuality, + Effort = effort + }; + + Assert.Equal(quality, encoder.Quality); + Assert.Equal(alphaQuality, encoder.AlphaQuality); + Assert.Equal(effort, encoder.Effort); + } + + [Fact] + public void LegacyJpegRejectsZeroQuality() + { + using Image image = new(1, 1); + using MemoryStream stream = new(); + HeifEncoder encoder = new() { Quality = 0 }; + + Assert.Throws(() => image.Save(stream, encoder)); + } + + [Fact] + public void LegacyJpegRejectsLosslessEncoding() + { + using Image image = new(1, 1); + using MemoryStream stream = new(); + HeifEncoder encoder = new() { Lossless = true }; + + Assert.Throws(() => image.Save(stream, encoder)); + } + + [Theory] + [InlineData(HeifBitDepth.Bit10)] + [InlineData(HeifBitDepth.Bit12)] + public void LegacyJpegRejectsHighBitDepth(HeifBitDepth bitDepth) + { + using Image image = new(1, 1); + using MemoryStream stream = new(); + HeifEncoder encoder = new() { BitDepth = bitDepth }; + + Assert.Throws(() => image.Save(stream, encoder)); + } + [Theory] [WithFile(TestImages.Heif.Sample640x427, PixelTypes.Rgba32, HeifCompressionMethod.LegacyJpeg)] public static void Encode(TestImageProvider provider, HeifCompressionMethod compressionMethod)