diff --git a/src/ImageSharp/Configuration.cs b/src/ImageSharp/Configuration.cs index 47a54e7480..9a2243b4be 100644 --- a/src/ImageSharp/Configuration.cs +++ b/src/ImageSharp/Configuration.cs @@ -13,6 +13,7 @@ using SixLabors.ImageSharp.Formats.Heif; using SixLabors.ImageSharp.Formats.Ico; using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Jxl; using SixLabors.ImageSharp.Formats.Pbm; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Qoi; @@ -226,6 +227,7 @@ public void Configure(IImageFormatConfigurationModule configuration) /// . /// . /// . + /// . /// . /// . /// @@ -241,6 +243,7 @@ public void Configure(IImageFormatConfigurationModule configuration) new WebpConfigurationModule(), new ExrConfigurationModule(), new QoiConfigurationModule(), + new JxlConfigurationModule(), new HeifConfigurationModule(), new IcoConfigurationModule(), new CurConfigurationModule(), diff --git a/src/ImageSharp/Formats/Jxl/JxlConfigurationModule.cs b/src/ImageSharp/Formats/Jxl/JxlConfigurationModule.cs new file mode 100644 index 0000000000..03f94750f0 --- /dev/null +++ b/src/ImageSharp/Formats/Jxl/JxlConfigurationModule.cs @@ -0,0 +1,18 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Jxl; + +/// +/// Registers the JPEG XL encoders, decoders and mime type detectors. +/// +public sealed class JxlConfigurationModule : IImageFormatConfigurationModule +{ + /// + public void Configure(Configuration configuration) + { + configuration.ImageFormatsManager.SetDecoder(JxlFormat.Instance, JxlDecoder.Instance); + configuration.ImageFormatsManager.SetEncoder(JxlFormat.Instance, new JxlEncoder()); + configuration.ImageFormatsManager.AddImageFormatDetector(new JxlImageFormatDetector()); + } +} diff --git a/src/ImageSharp/Formats/Jxl/JxlConstants.cs b/src/ImageSharp/Formats/Jxl/JxlConstants.cs new file mode 100644 index 0000000000..20e3ca5a28 --- /dev/null +++ b/src/ImageSharp/Formats/Jxl/JxlConstants.cs @@ -0,0 +1,25 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Text; + +namespace SixLabors.ImageSharp.Formats.Jxl; + +/// +/// Constants for the JPEG XL (JXL) format. +/// +internal static class JxlConstants +{ + /// Gets the JPEG XL codestream signature (FF 0A). + public static ReadOnlySpan CodestreamMagic => new byte[] { 0xFF, 0x0A }; + + /// Gets the JPEG XL container signature. + public static ReadOnlySpan ContainerMagic => new byte[] + { 0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A }; + + /// Gets the list of mimetypes that equate to a JXL. + public static string[] MimeTypes { get; } = ["image/jxl"]; + + /// Gets the list of file extensions that equate to a JXL. + public static string[] FileExtensions { get; } = ["jxl"]; +} diff --git a/src/ImageSharp/Formats/Jxl/JxlDecoder.cs b/src/ImageSharp/Formats/Jxl/JxlDecoder.cs new file mode 100644 index 0000000000..8d333540e2 --- /dev/null +++ b/src/ImageSharp/Formats/Jxl/JxlDecoder.cs @@ -0,0 +1,60 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Formats.Jxl; + +/// +/// Image decoder for JPEG XL images, decoded via the external libjxl +/// decoder (djxl) to PNG and then with the managed PNG decoder. +/// +internal class JxlDecoder : ImageDecoder +{ + private JxlDecoder() + { + } + + public static JxlDecoder Instance { get; } = new(); + + /// + protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken) + { + Guard.NotNull(options, nameof(options)); + Guard.NotNull(stream, nameof(stream)); + + string dir = JxlTool.NewTempDir(); + try + { + string jxlPath = Path.Combine(dir, "image.jxl"); + string pngPath = Path.Combine(dir, "image.png"); + using (var file = new FileStream(jxlPath, FileMode.Create, FileAccess.Write)) + { + stream.CopyTo(file); + } + + JxlTool.Run("djxl", jxlPath, pngPath); + Image image = Image.Load(pngPath); + ScaleToTargetSize(options, image); + return image; + } + catch (System.ComponentModel.Win32Exception e) + { + throw new ImageFormatException("JPEG XL decoding requires the libjxl 'djxl' tool on PATH.", e); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + /// + protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken) + => this.Decode(options, stream, cancellationToken); + + protected override ImageInfo Identify(DecoderOptions options, Stream stream, CancellationToken cancellationToken) + { + using Image image = this.Decode(options, stream, cancellationToken); + return new ImageInfo(new Size(image.Width, image.Height), image.Metadata); + } +} diff --git a/src/ImageSharp/Formats/Jxl/JxlEncoder.cs b/src/ImageSharp/Formats/Jxl/JxlEncoder.cs new file mode 100644 index 0000000000..ab1549468d --- /dev/null +++ b/src/ImageSharp/Formats/Jxl/JxlEncoder.cs @@ -0,0 +1,58 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Jxl; + +/// +/// Image encoder for writing an image as JPEG XL, encoded via the +/// external libjxl encoder (cjxl) from a managed PNG intermediate. +/// +public class JxlEncoder : ImageEncoder +{ + /// + /// Gets the encoder effort (1-10, default 7) passed to cjxl. + /// + public int Effort { get; init; } = 7; + + /// + /// Gets the Butteraugli distance (default 1.0, ignored when lossless). + /// + public float Distance { get; init; } = 1.0f; + + /// + /// Gets a value indicating whether to encode losslessly (Modular mode). + /// + public bool Lossless { get; init; } + + /// + protected override void Encode(Image image, Stream stream, CancellationToken cancellationToken) + { + string dir = JxlTool.NewTempDir(); + try + { + string pngPath = Path.Combine(dir, "image.png"); + string jxlPath = Path.Combine(dir, "image.jxl"); + image.Save(pngPath); + try + { + JxlTool.Run( + "cjxl", + pngPath, + jxlPath, + $"--effort={this.Effort}", + this.Lossless ? "--lossless=1" : $"--distance={this.Distance.ToString(System.Globalization.CultureInfo.InvariantCulture)}"); + } + catch (System.ComponentModel.Win32Exception e) + { + throw new ImageFormatException("JPEG XL encoding requires the libjxl 'cjxl' tool on PATH.", e); + } + + using var file = new FileStream(jxlPath, FileMode.Open, FileAccess.Read); + file.CopyTo(stream); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/src/ImageSharp/Formats/Jxl/JxlFormat.cs b/src/ImageSharp/Formats/Jxl/JxlFormat.cs new file mode 100644 index 0000000000..04bfcdb117 --- /dev/null +++ b/src/ImageSharp/Formats/Jxl/JxlFormat.cs @@ -0,0 +1,32 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Jxl; + +/// +/// Registers the JPEG XL format. +/// +public sealed class JxlFormat : IImageFormat +{ + private JxlFormat() + { + } + + /// Gets the shared instance. + public static JxlFormat Instance { get; } = new(); + + /// + public string DefaultMimeType => "image/jxl"; + + /// + public string Name => "JXL"; + + /// + public IEnumerable MimeTypes => JxlConstants.MimeTypes; + + /// + public IEnumerable FileExtensions => JxlConstants.FileExtensions; + + /// + public JxlMetadata CreateDefaultFormatMetadata() => new(); +} diff --git a/src/ImageSharp/Formats/Jxl/JxlImageFormatDetector.cs b/src/ImageSharp/Formats/Jxl/JxlImageFormatDetector.cs new file mode 100644 index 0000000000..1c15d05b15 --- /dev/null +++ b/src/ImageSharp/Formats/Jxl/JxlImageFormatDetector.cs @@ -0,0 +1,29 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Diagnostics.CodeAnalysis; + +namespace SixLabors.ImageSharp.Formats.Jxl; + +/// +/// Detects JPEG XL file headers: the codestream signature (FF 0A) or the +/// 12-byte container signature. +/// +public class JxlImageFormatDetector : IImageFormatDetector +{ + /// + public int HeaderSize => 12; + + /// + public bool TryDetectFormat(ReadOnlySpan header, [NotNullWhen(true)] out IImageFormat? format) + { + format = this.IsSupportedFileFormat(header) ? JxlFormat.Instance : null; + return format != null; + } + + private bool IsSupportedFileFormat(ReadOnlySpan header) + => header.Length >= 2 + && (JxlConstants.CodestreamMagic.SequenceEqual(header[..2]) + || (header.Length >= this.HeaderSize + && JxlConstants.ContainerMagic.SequenceEqual(header[..12]))); +} diff --git a/src/ImageSharp/Formats/Jxl/JxlMetadata.cs b/src/ImageSharp/Formats/Jxl/JxlMetadata.cs new file mode 100644 index 0000000000..f1b83683bf --- /dev/null +++ b/src/ImageSharp/Formats/Jxl/JxlMetadata.cs @@ -0,0 +1,63 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Formats.Jxl; + +/// +/// Provides JPEG XL specific metadata information for the image. +/// +public class JxlMetadata : IFormatMetadata +{ + /// Initializes a new instance of the class. + public JxlMetadata() + { + } + + private JxlMetadata(JxlMetadata other) => this.HasAlpha = other.HasAlpha; + + /// Gets or sets a value indicating whether the image has an alpha channel. + public bool HasAlpha { get; set; } + + /// + public static JxlMetadata FromFormatConnectingMetadata(FormatConnectingMetadata metadata) + => new() { HasAlpha = metadata.PixelTypeInfo.ColorType.HasFlag(PixelColorType.Alpha) }; + + /// + public PixelTypeInfo GetPixelTypeInfo() + { + if (this.HasAlpha) + { + return new PixelTypeInfo(32) + { + AlphaRepresentation = PixelAlphaRepresentation.Unassociated, + ColorType = PixelColorType.RGB | PixelColorType.Alpha, + ComponentInfo = PixelComponentInfo.Create(4, 32, 8, 8, 8, 8), + }; + } + + return new PixelTypeInfo(24) + { + ColorType = PixelColorType.RGB, + ComponentInfo = PixelComponentInfo.Create(3, 24, 8, 8, 8), + }; + } + + /// + public FormatConnectingMetadata ToFormatConnectingMetadata() + => new() { PixelTypeInfo = this.GetPixelTypeInfo() }; + + /// + public void AfterImageApply(Image destination, Matrix4x4 matrix) + where TPixel : unmanaged, IPixel + { + } + + /// + IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone(); + + /// + public JxlMetadata DeepClone() => new(this); +} diff --git a/src/ImageSharp/Formats/Jxl/JxlTool.cs b/src/ImageSharp/Formats/Jxl/JxlTool.cs new file mode 100644 index 0000000000..035417c90b --- /dev/null +++ b/src/ImageSharp/Formats/Jxl/JxlTool.cs @@ -0,0 +1,46 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Diagnostics; + +namespace SixLabors.ImageSharp.Formats.Jxl; + +/// +/// Runs an external libjxl tool (cjxl/djxl). JPEG XL encoding in this +/// format is delegated to libjxl because ImageSharp has no managed JXL +/// codec yet; only cjxl can also transcode legacy JPEG bit-exactly. +/// +internal static class JxlTool +{ + public static void Run(string tool, params string[] args) + { + var startInfo = new ProcessStartInfo(tool) + { + CreateNoWindow = true, + RedirectStandardError = true, + }; + foreach (string arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + using Process? process = Process.Start(startInfo); + if (process is null) + { + throw new ImageFormatException($"Failed to start '{tool}'. Install libjxl (cjxl/djxl) to use JPEG XL."); + } + + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new ImageFormatException($"'{tool}' failed with exit code {process.ExitCode}: {process.StandardError.ReadToEnd()}"); + } + } + + public static string NewTempDir() + { + string dir = Path.Combine(Path.GetTempPath(), "imagesharp-jxl-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } +} diff --git a/tests/ImageSharp.Tests/Formats/Jxl/JxlImageFormatDetectorTests.cs b/tests/ImageSharp.Tests/Formats/Jxl/JxlImageFormatDetectorTests.cs new file mode 100644 index 0000000000..10ee980573 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Jxl/JxlImageFormatDetectorTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Jxl; + +namespace SixLabors.ImageSharp.Tests.Formats.Jxl; + +public class JxlImageFormatDetectorTests +{ + [Fact] + public void DetectCodestreamSignature() + { + var detector = new JxlImageFormatDetector(); + byte[] header = [0xFF, 0x0A, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + Assert.True(detector.TryDetectFormat(header.AsSpan(0, detector.HeaderSize), out IImageFormat format)); + Assert.Equal(JxlFormat.Instance, format); + } + + [Fact] + public void DetectContainerSignature() + { + var detector = new JxlImageFormatDetector(); + byte[] header = [0x00, 0x00, 0x00, 0x0C, (byte)'J', (byte)'X', (byte)'L', (byte)' ', 0x0D, 0x0A, 0x87, 0x0A]; + Assert.True(detector.TryDetectFormat(header.AsSpan(0, detector.HeaderSize), out IImageFormat format)); + Assert.Equal(JxlFormat.Instance, format); + } + + [Fact] + public void RejectJpegSignature() + { + var detector = new JxlImageFormatDetector(); + byte[] header = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0, 0, 0, 0, 0]; + Assert.False(detector.TryDetectFormat(header.AsSpan(0, detector.HeaderSize), out _)); + } +}