diff --git a/src/AVIFConfigurationModule.cs b/src/AVIFConfigurationModule.cs new file mode 100644 index 0000000..298f1fc --- /dev/null +++ b/src/AVIFConfigurationModule.cs @@ -0,0 +1,15 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; + + +namespace NeoSolve.ImageSharp.AVIF; + +public sealed class AVIFConfigurationModule : IImageFormatConfigurationModule +{ + public void Configure(Configuration configuration) + { + configuration.ImageFormatsManager.SetEncoder(AVIFFormat.Instance, new AVIFEncoder()); + configuration.ImageFormatsManager.SetDecoder(AVIFFormat.Instance, AVIFDecoder.Instance); + configuration.ImageFormatsManager.AddImageFormatDetector(new AVIFImageFormatDetector()); + } +} diff --git a/src/AVIFDecoder.cs b/src/AVIFDecoder.cs new file mode 100644 index 0000000..85ef673 --- /dev/null +++ b/src/AVIFDecoder.cs @@ -0,0 +1,224 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Metadata; +using SixLabors.ImageSharp.PixelFormats; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + + +namespace NeoSolve.ImageSharp.AVIF; + +public class AVIFDecoder : IImageDecoder +{ + public static IImageDecoder Instance = new AVIFDecoder(); + + public Image Decode(DecoderOptions options, Stream stream) + where TPixel : unmanaged, IPixel + { + return DecodeAsync(options, stream, CancellationToken.None).Result; + } + + public Image Decode(DecoderOptions options, Stream stream) + { + return DecodeAsync(options, stream, CancellationToken.None).Result; + } + + /// + /// Decodes an AVIF image from a stream into an ImageSharp image, + /// piping the data through avifdec. + /// + /// The pixel format. + /// The decoder options. + /// The input stream containing the AVIF data. + /// A cancellation token. + /// A decoded image. + public async Task> DecodeAsync(DecoderOptions options, Stream stream, CancellationToken cancellationToken = default) + where TPixel : unmanaged, IPixel + { + string inputFilePath = Path.GetTempFileName(); + string outputFilePath = Path.GetTempFileName() + ".png"; + + try + { + using (var fileStream = new FileStream(inputFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true)) + { + await stream.CopyToAsync(fileStream, cancellationToken); + } + + var arguments = new List { inputFilePath, outputFilePath }; + + var psi = new ProcessStartInfo + { + FileName = Native.CAVIFDEC, + Arguments = string.Join(' ', arguments), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true + }; + + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start avifdec process."); + + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + string error = await process.StandardError.ReadToEndAsync(); + throw new InvalidOperationException($"AVIF decoding failed with exit code {process.ExitCode}. Error: {error}"); + } + + using (var outputStream = new FileStream(outputFilePath, FileMode.Open, FileAccess.Read, FileShare.None, 4096, true)) + { + return await Image.LoadAsync(outputStream, cancellationToken); + } + } + finally + { + File.Delete(inputFilePath); + File.Delete(outputFilePath); + } + } + + + public async Task DecodeAsync(DecoderOptions options, Stream stream, CancellationToken cancellationToken = default) + { + return await DecodeAsync(options, stream, cancellationToken); + } + + + public ImageInfo Identify(DecoderOptions options, Stream stream) + { + return IdentifyAsync(options, stream, CancellationToken.None).Result; + } + + public async Task IdentifyAsync(DecoderOptions options, Stream stream, CancellationToken cancellationToken = default) + { + string tempFilePath = Path.GetTempFileName(); + + try + { + await using (var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true)) + { + await stream.CopyToAsync(fileStream, cancellationToken); + } + + stream.Position = 0; + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Native.CAVIFDEC, + Arguments = $"--info \"{tempFilePath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode == 0) + { + string output = await process.StandardOutput.ReadToEndAsync(); + var avifInfo = ParseAVIFInfo(output); + var imageMetadata = new ImageMetadata(); + + Size size = new(avifInfo.Width, avifInfo.Height); + return new ImageInfo(new PixelTypeInfo(avifInfo.BitDepth), size, imageMetadata); + } + else + { + string error = await process.StandardError.ReadToEndAsync(); + return null; + } + } + finally + { + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + } + } + + public static AVIFInfo ParseAVIFInfo(string text) + { + var avifInfo = new AVIFInfo(); + var reader = new StringReader(text); + string line; + + while ((line = reader.ReadLine()) != null) + { + if (string.IsNullOrWhiteSpace(line) || !line.Contains(":")) + { + continue; + } + + var parts = line.Split(':', 2, StringSplitOptions.TrimEntries); + var key = parts[0].Trim().Replace("*", "").Trim(); + var value = parts[1].Trim(); + + switch (key) + { + case "Resolution": + var resolutionParts = value.Split('x'); + if (resolutionParts.Length == 2) + { + avifInfo.Width = int.Parse(resolutionParts[0].Trim()); + avifInfo.Height = int.Parse(resolutionParts[1].Trim()); + } + break; + case "Bit Depth": + avifInfo.BitDepth = int.Parse(value); + break; + case "Format": + avifInfo.Format = value; + break; + case "Chroma Sam. Pos": + avifInfo.ChromaSamPos = int.Parse(value); + break; + case "Alpha": + avifInfo.Alpha = value; + break; + case "Range": + avifInfo.Range = value; + break; + case "Color Primaries": + avifInfo.ColorPrimaries = int.Parse(value); + break; + case "Transfer Char.": + avifInfo.TransferChar = int.Parse(value); + break; + case "Matrix Coeffs.": + avifInfo.MatrixCoeffs = int.Parse(value); + break; + case "ICC Profile": + avifInfo.IccProfile = value; + break; + case "XMP Metadata": + avifInfo.XmpMetadata = value; + break; + case "Exif Metadata": + avifInfo.ExifMetadata = value; + break; + case "Transformations": + avifInfo.Transformations = value; + break; + case "Progressive": + avifInfo.Progressive = value; + break; + case "Gain map": + avifInfo.GainMap = value; + break; + } + } + + return avifInfo; + } +} diff --git a/src/AVIFEncoder.cs b/src/AVIFEncoder.cs index 5ac4159..66ad67f 100644 --- a/src/AVIFEncoder.cs +++ b/src/AVIFEncoder.cs @@ -3,9 +3,6 @@ using System.Diagnostics; using System.Globalization; using System.IO; -using System.Linq; -using System.Reflection.Emit; -using System.Text; using System.Threading; using System.Threading.Tasks; using SixLabors.ImageSharp; @@ -58,7 +55,7 @@ public async Task EncodeAsync(Image image, Stream stream, Cancel var psi = new ProcessStartInfo { - FileName = Native.CAVIF, + FileName = Native.CAVIFENC, Arguments = string.Join(' ', arguments), RedirectStandardInput = true, RedirectStandardOutput = true diff --git a/src/AVIFFormat.cs b/src/AVIFFormat.cs index 44e0597..8f261d7 100644 --- a/src/AVIFFormat.cs +++ b/src/AVIFFormat.cs @@ -1,14 +1,19 @@ -using System; +using SixLabors.ImageSharp.Metadata; using System.Collections.Generic; using SixLabors.ImageSharp.Formats; namespace NeoSolve.ImageSharp.AVIF; public class AVIFFormat : IImageFormat { public string Name => "AVIF"; + public static AVIFFormat Instance { get; } = new AVIFFormat(); public string DefaultMimeType => "image/avif"; public IEnumerable MimeTypes => AVIFConstants.MimeTypes; public IEnumerable FileExtensions => AVIFConstants.FileExtensions; + public IImageDecoder Decoder { get; } = new AVIFDecoder(); + public IImageEncoder Encoder { get; } = new AVIFEncoder(); + + public ImageMetadata CreateDefaultFormatMetadata() => new(); } diff --git a/src/AVIFImageFormatDetector.cs b/src/AVIFImageFormatDetector.cs new file mode 100644 index 0000000..d85994c --- /dev/null +++ b/src/AVIFImageFormatDetector.cs @@ -0,0 +1,34 @@ +using SixLabors.ImageSharp.Formats; +using System; +using System.Diagnostics.CodeAnalysis; + +namespace NeoSolve.ImageSharp.AVIF; + +public class AVIFImageFormatDetector : IImageFormatDetector +{ + public int HeaderSize => 12; + + public bool TryDetectFormat(ReadOnlySpan header, [NotNullWhen(true)] out IImageFormat format) + { + bool isAVIF = header.Length <= HeaderSize && IsAvif(header); + format = isAVIF ? AVIFFormat.Instance : null; + return isAVIF; + } + + private static bool IsAvif(ReadOnlySpan header) + { + // Check for the 'ftyp' box type at byte offset 4. + if (header[4] != 'f' || header[5] != 't' || header[6] != 'y' || header[7] != 'p') + { + return false; + } + + // Check for the 'avif' brand at byte offset 8. + if (header[8] == 'a' && header[9] == 'v' && header[10] == 'i' && header[11] == 'f') + { + return true; + } + + return false; + } +} diff --git a/src/AVIFInfo.cs b/src/AVIFInfo.cs new file mode 100644 index 0000000..6688cac --- /dev/null +++ b/src/AVIFInfo.cs @@ -0,0 +1,21 @@ +namespace NeoSolve.ImageSharp.AVIF; + +public class AVIFInfo +{ + public int Width { get; set; } + public int Height { get; set; } + public int BitDepth { get; set; } + public string Format { get; set; } + public int ChromaSamPos { get; set; } + public string Alpha { get; set; } + public string Range { get; set; } + public int ColorPrimaries { get; set; } + public int TransferChar { get; set; } + public int MatrixCoeffs { get; set; } + public string IccProfile { get; set; } + public string XmpMetadata { get; set; } + public string ExifMetadata { get; set; } + public string Transformations { get; set; } + public string Progressive { get; set; } + public string GainMap { get; set; } +} \ No newline at end of file diff --git a/src/Native.cs b/src/Native.cs index 7729c80..2f18733 100644 --- a/src/Native.cs +++ b/src/Native.cs @@ -5,7 +5,8 @@ namespace NeoSolve.ImageSharp.AVIF; public static class Native { - public static string CAVIF => Path.Combine("native", OSFolder, "avifenc") + ExecutableExtension; + public static string CAVIFENC => Path.Combine("native", OSFolder, "avifenc") + ExecutableExtension; + public static string CAVIFDEC => Path.Combine("native", OSFolder, "avifdec") + ExecutableExtension; private static string OSFolder { get { diff --git a/src/native/linux/avifdec b/src/native/linux/avifdec new file mode 100644 index 0000000..3ccd9f9 Binary files /dev/null and b/src/native/linux/avifdec differ diff --git a/src/native/win-x64/avifdec.exe b/src/native/win-x64/avifdec.exe new file mode 100644 index 0000000..1e8268f Binary files /dev/null and b/src/native/win-x64/avifdec.exe differ diff --git a/test/AVIFDecoderTests.cs b/test/AVIFDecoderTests.cs new file mode 100644 index 0000000..4d09582 --- /dev/null +++ b/test/AVIFDecoderTests.cs @@ -0,0 +1,84 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.PixelFormats; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace NeoSolve.ImageSharp.AVIF.Tests; + +public class AVIFDecoderTests +{ + private const string AVIFTestFile = "Resources/test.avif"; + private const string JPGTestFile = "Resources/test.jpg"; + + [Theory] + [InlineData(AVIFTestFile, true)] + [InlineData(JPGTestFile, false)] + public void CanIdentifyAVIFFiles(string file, bool vaildAVIF) + { + var SUT = new AVIFDecoder(); + ImageInfo imageInfo; + + using (Stream stream = File.OpenRead(file)) + { + imageInfo = SUT.Identify(new DecoderOptions(), stream); + } + var success = imageInfo != null; + + Assert.Equal(vaildAVIF, success); + } + + [Theory] + [InlineData(AVIFTestFile, true)] + [InlineData(JPGTestFile, false)] + public async Task CanIdentifyAVIFFilesAsync(string file, bool validAVIF) + { + var SUT = new AVIFDecoder(); + ImageInfo imageInfo; + + using (Stream stream = File.OpenRead(file)) + { + imageInfo = await SUT.IdentifyAsync(new DecoderOptions(), stream); + } + var success = imageInfo != null; + + Assert.Equal(validAVIF, success); + } + + [Fact] + public void Decode_NonGeneric_CreatesRgba32Image() + { + Configuration config = CreateDefaultConfiguration(); + + var decoderOptions = new DecoderOptions + { + Configuration = config + }; + + using Image image = Image.Load(decoderOptions, AVIFTestFile); + Assert.IsType>(image); + } + + [Fact] + public void CanDetectImageFormat() + { + var SUT = new AVIFImageFormatDetector(); + var header = Encoding.UTF8.GetBytes("0123ftypavif"); + + var result = SUT.TryDetectFormat(header, out var format); + + Assert.True(result); + Assert.IsType(format); + } + + + + private static Configuration CreateDefaultConfiguration() + { + Configuration cfg = new(new AVIFConfigurationModule()); + return cfg; + } + +} diff --git a/test/Resources/test.avif b/test/Resources/test.avif new file mode 100644 index 0000000..247effc Binary files /dev/null and b/test/Resources/test.avif differ