diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 00f47735..76461519 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -201,14 +201,25 @@ in it also builds the package (artifact `windows-arm64-package`). borders, fully transparent ones and multi-frame animations (each frame alone would shift) come out as they are. Formats that can't be transparent (JPG, HEIC, …) have nothing to trim: the preset is greyed out for them, and a JPG in a mixed selection fails with that reason instead of being re-encoded. -- **Animations.** An animated GIF, WebP, APNG, MNG or JPEG XL keeps every frame and its timing when converted to one of - these (animated GIF ↔ animated WebP ↔ APNG ↔ MNG ↔ JPEG XL, WebP lossy or lossless as the preset says, #988) and - becomes a multi-page TIFF; - other targets get the first frame. Multi-page TIFFs and PDFs stay one image per page in WebP. +- **Animations.** An animated GIF, WebP, APNG, MNG, JPEG XL or AVIF keeps every frame and its timing when converted to + one of these (animated GIF ↔ animated WebP ↔ APNG ↔ MNG ↔ JPEG XL ↔ AVIF, WebP lossy or lossless as the preset says, + #988) and becomes a multi-page TIFF; + other targets get the first frame. Multi-page TIFFs and PDFs stay one image per page in WebP and AVIF. + - **Animated AVIF** (#1200) needs no FFmpeg: the bundled ImageMagick writes several frames as an AVIF image sequence + (`avis`) through libheif, with the first frame also stored as the still picture for viewers that don't play + sequences, and reads such a file back frame by frame, also one FFmpeg wrote. Transparency stays (one alpha track, so + `ImageEncoder.MatchAlpha` gives every frame an alpha channel when any frame is transparent, and none when all are + opaque). An AVIF source is an animation only when it is a sequence (`Magick/AvifSequence.cs`: major brand `avis` and a + `moov` box). The items of any other AVIF (a burst, a visible gain map, pictures of their own sizes) stay separate + pictures, one `_pN` file each. A still picture takes the same one-picture path as ever. The writer stores no loop + count, so an animation that plays a set number of times loops for ever as AVIF. Encoding takes much longer than WebP (about half a second per busy + 480 × 270 frame, and the write can't be interrupted). On the way to video an AVIF counts as a photo + (`ImageEncoder.Edge`): nearly all of them are, so an animated one becomes a video of its first frame rather than + every AVIF going through a 256-colour GIF. - **Playback speed** (preset option *Playback speed*, `ImageOptions.AnimationSpeed`: 0.25×, 0.5×, 0.75×, 1×, 1.5×, - 2×, 4×; shown for GIF, WebP, APNG, MNG, JPEG XL and "same as source") rewrites the frame delays of an animation that stays + 2×, 4×; shown for GIF, WebP, APNG, MNG, JPEG XL, AVIF and "same as source") rewrites the frame delays of an animation that stays animated (`Magick/AnimationTiming.cs`). Delays are divided by the speed in the target's own units (hundredths of a - second for GIF, MNG and JPEG XL, milliseconds for WebP and APNG) and rounded on the running total, so the whole animation + second for GIF, MNG and JPEG XL, milliseconds for WebP, APNG and AVIF) and rounded on the running total, so the whole animation keeps its new length. Players show a frame of 10 ms or less for 100 ms, so no delay goes below 20 ms (50 frames a second): a frame that would start sooner than that after the one before is dropped and its time goes to that one, which leaves frames out evenly. Source delays of 10 ms or less count as the 100 ms players show. Loop count and diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index edec844d..f1e966f3 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -976,10 +976,10 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne /// The video of a Live / Motion Photo: MP4 and MOV (). public bool ShowExtractMotionPhoto => !IsTool && MediaOptions.MotionPhotoTargets.Contains(TargetId); /// - /// Playback speed: for the formats an animation stays animated in (an animated GIF, WebP, APNG or MNG, or a video - /// made into a GIF or APNG). Other targets get one frame. + /// Playback speed: for the formats an animation stays animated in (an animated GIF, WebP, APNG, MNG or AVIF, or a + /// video made into a GIF or APNG). Other targets get one frame. /// - public bool ShowAnimationSpeed => TargetId is "gif" or "webp" or "apng" or "mng" or "jxl" or BuiltInData.SameAsSource; + public bool ShowAnimationSpeed => TargetId is "gif" or "webp" or "apng" or "mng" or "jxl" or "avif" or BuiltInData.SameAsSource; /// /// Tracing options: raster images reach SVG (and DXF / DWG through it) only by tracing. For other sources (a /// drawing, a CAD file) the options are ignored. diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index 3a49a559..78d3163d 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -967,7 +967,7 @@ public void ForgetBackdropPictures() public static readonly double[] AnimationSpeeds = [0.25, 0.5, 0.75, 1, 1.5, 2, 4]; /// - /// Plays an animation (GIF, WebP, APNG, MNG; video → GIF or APNG) this many times as fast: 2 halves every frame + /// Plays an animation (GIF, WebP, APNG, MNG, AVIF; video → GIF or APNG) this many times as fast: 2 halves every frame /// delay, 0.5 doubles it. 1 (the default, also for presets saved before the option) leaves the timing as it is. /// public double AnimationSpeed { get; set; } = 1; diff --git a/src/Filee.Engines/Heic/HeicWriter.cs b/src/Filee.Engines/Heic/HeicWriter.cs index f11f29ff..7f3497e0 100644 --- a/src/Filee.Engines/Heic/HeicWriter.cs +++ b/src/Filee.Engines/Heic/HeicWriter.cs @@ -74,7 +74,7 @@ private static List Prepare(ConversionStep step, CancellationToken canc { using var images = ImageReader.Read(step.InputPath, step.From, step.Preset); var pictures = new List(); - foreach (var frame in ImageEncoder.SelectFrames(images, step.From, "heic")) + foreach (var frame in ImageEncoder.SelectFrames(images, AvifSequence.SourceFormat(step.From, step.InputPath), "heic")) { cancellationToken.ThrowIfCancellationRequested(); ImageEncoder.ApplyOptions(frame, step.Preset.Image, "heic"); diff --git a/src/Filee.Engines/Magick/AnimationTiming.cs b/src/Filee.Engines/Magick/AnimationTiming.cs index 4b18db45..8a4424c0 100644 --- a/src/Filee.Engines/Magick/AnimationTiming.cs +++ b/src/Filee.Engines/Magick/AnimationTiming.cs @@ -1,5 +1,5 @@ // Playback speed of animations (preset option ImageOptions.AnimationSpeed): rewrites the frame delays of an animation -// written as GIF, WebP, APNG or MNG, and drops frames where a delay would get shorter than players show it. +// written as GIF, WebP, APNG, MNG or AVIF, and drops frames where a delay would get shorter than players show it. using ImageMagick; @@ -20,8 +20,8 @@ internal static class AnimationTiming /// The largest delay GIF and MNG store (16 bits of hundredths of a second, about 11 minutes). private const int MaxHundredths = ushort.MaxValue; - /// The ticks per second delays are written in: hundredths for GIF and MNG, milliseconds for WebP and APNG. - public static int TicksPerSecond(string targetFormat) => targetFormat is "webp" or "apng" ? 1000 : 100; + /// The ticks per second delays are written in: hundredths for GIF and MNG, milliseconds for WebP, APNG and AVIF. + public static int TicksPerSecond(string targetFormat) => targetFormat is "webp" or "apng" or "avif" ? 1000 : 100; /// /// The frames to write, playing times as fast: every delay divided by diff --git a/src/Filee.Engines/Magick/AvifSequence.cs b/src/Filee.Engines/Magick/AvifSequence.cs new file mode 100644 index 00000000..f88a0b70 --- /dev/null +++ b/src/Filee.Engines/Magick/AvifSequence.cs @@ -0,0 +1,59 @@ +// Tells an animated AVIF from every other AVIF by the file itself. ImageMagick reads both as a list of frames, but only +// an image sequence is an animation: the items of any other AVIF (a burst, a gain map, pictures of their own sizes) are +// separate pictures. + +using System.Buffers.Binary; + +namespace Filee.Engines.Magick; + +/// Recognizes AVIF image sequences. +internal static class AvifSequence +{ + /// The format id frames of an AVIF image sequence are read as, as a .png with an acTL chunk is read as "apng". + public const string Format = "avis"; + + /// The format id to read of as: for a sequence. + public static string SourceFormat(string format, string path) => + format == "avif" && IsSequence(path) ? Format : format; + + /// + /// True when has the major brand "avis" and the "moov" box with its tracks. The major brand is + /// what decides for players too: browsers show a file with the brand "avif" as one picture, whatever else it holds. + /// + public static bool IsSequence(string path) + { + try + { + using var stream = File.OpenRead(path); + Span box = stackalloc byte[12]; + if (stream.ReadAtLeast(box, 12, throwOnEndOfStream: false) < 12 + || !box[4..8].SequenceEqual("ftyp"u8) || !box[8..12].SequenceEqual("avis"u8)) + return false; + + stream.Position = 0; + while (stream.ReadAtLeast(box[..8], 8, throwOnEndOfStream: false) == 8) + { + if (box[4..8].SequenceEqual("moov"u8)) + return true; + long size = BinaryPrimitives.ReadUInt32BigEndian(box); + var header = 8; + if (size == 1) + { + if (stream.ReadAtLeast(box[..8], 8, throwOnEndOfStream: false) < 8) + return false; + size = BinaryPrimitives.ReadInt64BigEndian(box); + header = 16; + } + // Size 0 runs to the end of the file; anything shorter than its own header is broken. + if (size < header) + return false; + stream.Seek(size - header, SeekOrigin.Current); + } + return false; + } + catch (IOException) + { + return false; + } + } +} diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs index 0523c679..c0501213 100644 --- a/src/Filee.Engines/Magick/ImageEncoder.cs +++ b/src/Filee.Engines/Magick/ImageEncoder.cs @@ -42,10 +42,11 @@ internal static class ImageEncoder private static readonly HashSet MultiFrame = ["tiff", "gif", "apng", "mng"]; /// - /// Formats whose frames are an animation. Between these, all frames are kept with their timing (an animated GIF - /// becomes an animated WebP or JPEG XL and the other way round); any other target gets the first frame. + /// Formats whose frames are an animation; an AVIF only when it is an image sequence (). + /// Between these, all frames are kept with their timing (an animated GIF becomes an animated WebP, JPEG XL or AVIF + /// and the other way round); any other target gets the first frame. /// - private static readonly HashSet Animated = ["gif", "webp", "apng", "mng", "jxl"]; + private static readonly HashSet Animated = ["gif", "webp", "apng", "mng", "jxl", AvifSequence.Format]; /// /// The edge an image engine offers from a picture in to . GIF @@ -61,7 +62,7 @@ public static ConversionEdge Edge(string source, string target) => /// True when frames of are written as one animation in . public static bool KeepsAnimation(string sourceFormat, string targetFormat) => - Animated.Contains(sourceFormat) && Animated.Contains(targetFormat); + Animated.Contains(sourceFormat) && (targetFormat == "avif" || Animated.Contains(targetFormat)); /// /// Formats written without an alpha channel: transparent pixels are flattened onto the background colour. HEIC @@ -157,8 +158,8 @@ public static (MagickFormat Format, string Extension) OutputFormat(string target /// /// Chooses which frames of a (possibly multi-frame) source are converted. - /// ICO, CUR, DDS: the largest image. Animations (GIF, WebP, APNG, MNG, JPEG XL): every frame for an animated or TIFF - /// target, else the first frame. + /// ICO, CUR, DDS: the largest image. Animations (GIF, WebP, APNG, MNG, JPEG XL, AVIF): every frame for an animated or + /// TIFF target, else the first frame. /// PSD/PSB: the composite (frame 0; any further frames are layers). Otherwise: all frames/pages. /// public static List> SelectFrames(MagickImageCollection images, string sourceFormat, string targetFormat) @@ -450,7 +451,10 @@ public static void ApplyOptions(IMagickImage image, ImageOptions options, /// /// Options of the preset (the Netpbm sub-format depends on them). /// Input file of the step, see . - /// The frames are an animation: write them into one file even for a WebP target. + /// + /// The frames are an animation: write them into one file even for a WebP or AVIF target (ImageMagick writes several + /// frames as an AVIF image sequence, whose first frame is also the still picture for viewers that don't play it). + /// public static List Write(IReadOnlyList> frames, string targetFormat, IOutputAllocator output, ImageOptions? options = null, string? sourcePath = null, bool animation = false) { @@ -476,6 +480,8 @@ public static List Write(IReadOnlyList> frames, strin using var collection = new MagickImageCollection(); foreach (var frame in frames) collection.Add(frame.Clone()); + if (targetFormat == "avif") + MatchAlpha(collection); collection.Write(path, format); written.Add(path); return written; @@ -495,6 +501,19 @@ public static List Write(IReadOnlyList> frames, strin return written; } + /// + /// Gives the frames of an AVIF sequence one kind of alpha. The sequence has one alpha track, and ImageMagick stores a + /// frame without an alpha channel as fully transparent in it: an opaque first frame of a GIF that turns transparent + /// later would vanish. So every frame gets the channel when any of them is transparent, and none when all are opaque + /// (an APNG's frames all have one), which saves the track. + /// + private static void MatchAlpha(IEnumerable> frames) + { + var action = frames.All(frame => frame.IsOpaque) ? AlphaAction.Off : AlphaAction.Set; + foreach (var frame in frames) + frame.Alpha(action); + } + /// A fitted picture is never made smaller than this long edge (nor than its own): below it, it's a thumbnail. internal const int MinFittedLongEdge = 640; diff --git a/src/Filee.Engines/Magick/MagickImageConverter.cs b/src/Filee.Engines/Magick/MagickImageConverter.cs index 6c3c5210..436d7baf 100644 --- a/src/Filee.Engines/Magick/MagickImageConverter.cs +++ b/src/Filee.Engines/Magick/MagickImageConverter.cs @@ -81,8 +81,9 @@ public Task> ConvertAsync(ConversionStep step, IProgress ImageToPdf(ConversionStep step, IProgress t.Value == target); Assert.True(vm.ShowAnimationSpeed, target); diff --git a/tests/Filee.Engines.Tests/AnimatedAvifTests.cs b/tests/Filee.Engines.Tests/AnimatedAvifTests.cs new file mode 100644 index 00000000..96c1f5e4 --- /dev/null +++ b/tests/Filee.Engines.Tests/AnimatedAvifTests.cs @@ -0,0 +1,600 @@ +// Animated GIF, WebP, APNG and MNG ↔ animated AVIF (issue #1200). The animations are generated in the tests and every +// result is read back for its frames; with FFmpeg installed, its decoder counts them as well. + +using System.Globalization; +using System.Text; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Magick; +using Filee.Engines.Media; +using ImageMagick; +using ImageMagick.Drawing; +using PdfSharp.Pdf.IO; + +namespace Filee.Engines.Tests; + +[Collection(nameof(FfmpegCollection))] +public class AnimatedAvifTests(EngineFixture fx) : IClassFixture +{ + private const uint Width = 64, Height = 48; + + private static void AssertDone(ConversionJob job) + { + var errors = string.Join("; ", job.Files.Where(f => f.State == FileState.Failed).Select(f => $"{f.ErrorKey} {f.ErrorDetail}")); + Assert.True(job.State == JobState.Completed, $"{job.State}: {errors}"); + } + + private static Preset To(string target, int quality = 60, double speed = 1) => + new() { TargetFormat = target, Image = { Quality = quality, AnimationSpeed = speed } }; + + private static MagickColor Colour(int i) => new((byte)(i * 60 % 256), (byte)(255 - i * 60 % 256), 90); + + /// A frame of its own colour for every delay (hundredths of a second). + private static string MakeAnimation(string folder, string name, MagickFormat format, params uint[] delays) + { + using var frames = new MagickImageCollection(); + for (var i = 0; i < delays.Length; i++) + frames.Add(new MagickImage(Colour(i), Width, Height) { AnimationDelay = delays[i] }); + var path = Path.Combine(folder, name); + frames.Write(path, format); + return path; + } + + /// A square that moves right on a transparent background, a step of 20 px per frame. + private static string MakeMovingSquare(string folder, int count = 3) + { + using var frames = new MagickImageCollection(); + for (var i = 0; i < count; i++) + { + var frame = new MagickImage(MagickColors.Transparent, Width, Height) + { + AnimationDelay = 10, + GifDisposeMethod = GifDisposeMethod.Background, + }; + new Drawables().FillColor(MagickColors.Red).Rectangle(i * 20, 10, i * 20 + 15, 30).Draw(frame); + frames.Add(frame); + } + var path = Path.Combine(folder, "square.gif"); + frames.Write(path, MagickFormat.Gif); + return path; + } + + /// + /// A moving red square, on white in the first frames (not a transparent pixel in them) and on + /// transparency in the others. ImageMagick reads such opaque frames of a GIF, WebP or MNG without an alpha channel. + /// + private static string MakeOpaqueThenTransparent(string folder, string name, MagickFormat format, int opaque, int count = 3) + { + using var frames = new MagickImageCollection(); + for (var i = 0; i < count; i++) + { + var frame = new MagickImage(i < opaque ? MagickColors.White : MagickColors.Transparent, Width, Height) + { + AnimationDelay = 10, + GifDisposeMethod = GifDisposeMethod.Background, + }; + new Drawables().FillColor(MagickColors.Red).Rectangle(i * 20, 10, i * 20 + 15, 30).Draw(frame); + frames.Add(frame); + } + var path = Path.Combine(folder, name); + frames.Write(path, format); + return path; + } + + /// Asserts the frames show what drew, read from their pixels. + private static void AssertOpaqueThenTransparent(MagickImageCollection frames, int opaque, int count = 3) + { + Assert.Equal(count, frames.Count); + for (var i = 0; i < count; i++) + { + var pixels = frames[i].GetPixels(); + var square = pixels.GetPixel(i * 20 + 7, 20).ToColor()!; + Assert.True(square.A > 200 && square.R > 200 && square.G < 60, $"frame {i}: the square is {square}"); + var background = pixels.GetPixel(60, 44).ToColor()!; + if (i < opaque) + Assert.True(background.A > 200 && background.G > 200, $"frame {i}: the opaque background is {background}"); + else + Assert.True(background.A < 50, $"frame {i}: the transparent background is {background}"); + } + } + + /// The major brand of an HEIF file: "avis" for an image sequence, "avif" for one picture. + private static string Brand(string path) => Encoding.ASCII.GetString(File.ReadAllBytes(path), 8, 4); + + /// True when the file has an alpha track (the handler of an auxiliary video track). + private static bool HasAlphaTrack(string path) => Encoding.ASCII.GetString(File.ReadAllBytes(path)).Contains("auxv", StringComparison.Ordinal); + + private static void AssertColours(MagickImageCollection frames, int count) + { + Assert.Equal(count, frames.Count); + for (var i = 0; i < count; i++) + { + var pixel = frames[i].GetPixels().GetPixel((int)frames[i].Width / 2, (int)frames[i].Height / 2).ToColor()!; + var expected = Colour(i); + Assert.True(Math.Abs(pixel.R - expected.R) < 16 && Math.Abs(pixel.G - expected.G) < 16 && Math.Abs(pixel.B - expected.B) < 16, + $"frame {i} is {pixel}, not {expected}"); + } + } + + /// Each frame's delay in milliseconds. + private static List DelaysMs(MagickImageCollection frames) => + [.. frames.Select(f => (int)Math.Round(f.AnimationDelay * 1000.0 / f.AnimationTicksPerSecond))]; + + /// Asserts every frame shows the square at its own place and nothing where it was before. + private static void AssertSquareMoves(MagickImageCollection frames, int count) + { + Assert.Equal(count, frames.Count); + for (var i = 0; i < count; i++) + { + var pixels = frames[i].GetPixels(); + var square = pixels.GetPixel(i * 20 + 7, 20).ToColor()!; + Assert.True(square.A > 200 && square.R > 200, $"frame {i}: the square is {square}"); + if (i > 0) + Assert.True(pixels.GetPixel((i - 1) * 20 + 2, 20).ToColor()!.A < 50, $"frame {i} still shows the square of the frame before"); + } + } + + [Theory] + [InlineData("gif", MagickFormat.Gif)] + [InlineData("webp", MagickFormat.WebP)] + public async Task Animations_stay_animated_with_their_timing(string from, MagickFormat format) + { + var dir = fx.NewFolder(); + var source = MakeAnimation(dir, "anim." + from, format, 10, 20, 30, 40); + + var job = await fx.ConvertAsync([source], To("avif")); + + AssertDone(job); + var output = job.Outputs.Single(); + Assert.Equal("avis", Brand(output)); + using var frames = new MagickImageCollection(output); + AssertColours(frames, 4); + Assert.Equal([100, 200, 300, 400], DelaysMs(frames)); + } + + [Fact] + public async Task Apng_and_mng_stay_animated_too() + { + var dir = fx.NewFolder(); + var gif = MakeAnimation(dir, "anim.gif", MagickFormat.Gif, 10, 10, 10); + foreach (var format in new[] { "apng", "mng" }) + { + var source = await fx.ConvertAsync([gif], To(format)); + AssertDone(source); + + var job = await fx.ConvertAsync([source.Outputs.Single()], To("avif")); + + AssertDone(job); + Assert.Equal("avis", Brand(job.Outputs.Single())); + using var frames = new MagickImageCollection(job.Outputs.Single()); + AssertColours(frames, 3); + } + } + + [Fact] + public async Task Odd_sizes_and_delays_of_zero_are_written() + { + var dir = fx.NewFolder(); + using var frames = new MagickImageCollection(); + for (var i = 0; i < 3; i++) + frames.Add(new MagickImage(Colour(i), 3, 1)); + var gif = Path.Combine(dir, "tiny.gif"); + frames.Write(gif, MagickFormat.Gif); + + var job = await fx.ConvertAsync([gif], To("avif")); + + AssertDone(job); + using var back = new MagickImageCollection(job.Outputs.Single()); + Assert.Equal(3, back.Count); + Assert.Equal((3u, 1u), (back[0].Width, back[0].Height)); + // A delay of 0 has no length of its own: it plays at the 100 ms players give such a GIF frame. + Assert.Equal([100, 100, 100], DelaysMs(back)); + } + + [Fact] + public async Task Resizing_and_turning_apply_to_every_frame() + { + var dir = fx.NewFolder(); + var gif = MakeAnimation(dir, "anim.gif", MagickFormat.Gif, 10, 20, 30); + var preset = To("avif", quality: 90); + preset.Image.Rotation = ImageRotation.Right90; + preset.Image.Resize = ResizeMode.Percent; + preset.Image.ResizePercent = 50; + + var job = await fx.ConvertAsync([gif], preset); + + AssertDone(job); + var output = job.Outputs.Single(); + Assert.Equal("avis", Brand(output)); + using var frames = new MagickImageCollection(output); + AssertColours(frames, 3); + Assert.All(frames, frame => Assert.Equal((24u, 32u), (frame.Width, frame.Height))); + Assert.Equal([100, 200, 300], DelaysMs(frames)); + } + + [Theory] + [InlineData("gif", MagickFormat.Gif, 1)] + [InlineData("gif", MagickFormat.Gif, 2)] + [InlineData("webp", MagickFormat.WebP, 1)] + [InlineData("mng", MagickFormat.Mng, 1)] + public async Task Opaque_frames_before_transparent_ones_stay_visible(string from, MagickFormat format, int opaque) + { + var dir = fx.NewFolder(); + var source = MakeOpaqueThenTransparent(dir, "first." + from, format, opaque); + + var job = await fx.ConvertAsync([source], To("avif", quality: 90)); + + AssertDone(job); + var output = job.Outputs.Single(); + Assert.Equal("avis", Brand(output)); + Assert.True(HasAlphaTrack(output)); + using var frames = new MagickImageCollection(output); + AssertOpaqueThenTransparent(frames, opaque); + } + + [Theory] + [InlineData("webp")] + [InlineData("gif")] + public async Task Opaque_frames_before_transparent_ones_stay_visible_from_avif_too(string target) + { + var dir = fx.NewFolder(); + var avif = await fx.ConvertAsync([MakeOpaqueThenTransparent(dir, "first.gif", MagickFormat.Gif, 1)], To("avif", quality: 90)); + AssertDone(avif); + + var job = await fx.ConvertAsync([avif.Outputs.Single()], To(target, quality: 90)); + + AssertDone(job); + using var frames = new MagickImageCollection(job.Outputs.Single()); + frames.Coalesce(); // the file may store only what changed + AssertOpaqueThenTransparent(frames, 1); + } + + [Theory] + [InlineData("gif", MagickFormat.Gif)] + [InlineData("webp", MagickFormat.WebP)] + [InlineData("apng", MagickFormat.Gif)] // through a GIF: Apng.Read gives every frame an alpha channel + public async Task An_opaque_animation_gets_no_alpha(string from, MagickFormat format) + { + var dir = fx.NewFolder(); + var source = MakeAnimation(dir, from == "apng" ? "anim.gif" : "anim." + from, format, 10, 10, 10); + if (from == "apng") + { + var apng = await fx.ConvertAsync([source], To("apng")); + AssertDone(apng); + source = apng.Outputs.Single(); + } + + var job = await fx.ConvertAsync([source], To("avif")); + + AssertDone(job); + var output = job.Outputs.Single(); + Assert.Equal("avis", Brand(output)); + Assert.False(HasAlphaTrack(output)); + using var frames = new MagickImageCollection(output); + AssertColours(frames, 3); + Assert.All(frames, frame => Assert.False(frame.HasAlpha)); + } + + [Fact] + public async Task Transparency_stays_and_frames_do_not_pile_up() + { + var dir = fx.NewFolder(); + + var job = await fx.ConvertAsync([MakeMovingSquare(dir)], To("avif", quality: 90)); + + AssertDone(job); + using var frames = new MagickImageCollection(job.Outputs.Single()); + AssertSquareMoves(frames, 3); + } + + [Theory] + [InlineData("webp")] + [InlineData("gif")] + public async Task Transparent_animated_avif_does_not_pile_up_either(string target) + { + var dir = fx.NewFolder(); + var avif = await fx.ConvertAsync([MakeMovingSquare(dir)], To("avif", quality: 90)); + AssertDone(avif); + + var job = await fx.ConvertAsync([avif.Outputs.Single()], To(target, quality: 90)); + + AssertDone(job); + using var frames = new MagickImageCollection(job.Outputs.Single()); + frames.Coalesce(); // the file may store only what changed + AssertSquareMoves(frames, 3); + } + + [Fact] + public async Task Quality_applies_to_every_frame() + { + var dir = fx.NewFolder(); + using (var noise = new MagickImageCollection()) + { + for (var i = 0; i < 4; i++) + { + var frame = new MagickImage("plasma:", 160, 120) { AnimationDelay = 10 }; + noise.Add(frame); + } + noise.Write(Path.Combine(dir, "noise.gif"), MagickFormat.Gif); + } + var gif = Path.Combine(dir, "noise.gif"); + + var low = await fx.ConvertAsync([gif], new Preset { TargetFormat = "avif", Image = { Quality = 20 }, Output = { FileNamePattern = "{name}_low" } }); + var high = await fx.ConvertAsync([gif], new Preset { TargetFormat = "avif", Image = { Quality = 90 }, Output = { FileNamePattern = "{name}_high" } }); + + AssertDone(low); + AssertDone(high); + Assert.Equal("avis", Brand(high.Outputs.Single())); + Assert.True(new FileInfo(high.Outputs.Single()).Length > 2 * new FileInfo(low.Outputs.Single()).Length); + } + + [Fact] + public async Task Playback_speed_changes_the_delays() + { + var dir = fx.NewFolder(); + var gif = MakeAnimation(dir, "anim.gif", MagickFormat.Gif, 10, 20, 30, 40); + + var job = await fx.ConvertAsync([gif], To("avif", speed: 2)); + + AssertDone(job); + using var frames = new MagickImageCollection(job.Outputs.Single()); + Assert.Equal([50, 100, 150, 200], DelaysMs(frames)); + } + + // ───────────────────────── Still pictures ───────────────────────── + + [Theory] + [InlineData("png", MagickFormat.Png)] + [InlineData("gif", MagickFormat.Gif)] + public async Task A_still_picture_is_written_as_before(string from, MagickFormat format) + { + var dir = fx.NewFolder(); + var source = MakeAnimation(dir, "still." + from, format, 10); + + var job = await fx.ConvertAsync([source], To("avif")); + + AssertDone(job); + var output = job.Outputs.Single(); + Assert.Equal("avif", Brand(output)); + // The same bytes as writing the one picture at the preset's quality: no sequence, no other encoder settings. + using var image = new MagickImage(source); + image.Strip(); + image.Quality = 60; + Assert.Equal(image.ToByteArray(MagickFormat.Avif), await File.ReadAllBytesAsync(output, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Pages_stay_one_picture_each() + { + var dir = fx.NewFolder(); + + var job = await fx.ConvertAsync([EngineFixture.MakeMultiPageTiff(dir, 2)], To("avif")); + + AssertDone(job); + Assert.Equal(2, job.Outputs.Count()); + Assert.All(job.Outputs, output => Assert.Equal("avif", Brand(output))); + } + + // ───────────────────────── Animated AVIF as the source ───────────────────────── + + [Theory] + [InlineData("gif")] + [InlineData("webp")] + [InlineData("apng")] + [InlineData("avif")] + public async Task Animated_avif_keeps_its_frames_and_timing(string target) + { + var dir = fx.NewFolder(); + var avif = await fx.ConvertAsync([MakeAnimation(dir, "anim.gif", MagickFormat.Gif, 10, 20, 30, 40)], To("avif", quality: 90)); + AssertDone(avif); + + var job = await fx.ConvertAsync([avif.Outputs.Single()], + new Preset { TargetFormat = target, Image = { Quality = 90 }, Output = { FileNamePattern = "{name}_again" } }); + + AssertDone(job); + var output = job.Outputs.Single(); + using var frames = target == "apng" ? Apng.Read(output) : new MagickImageCollection(output); + AssertColours(frames, 4); + Assert.Equal([100, 200, 300, 400], DelaysMs(frames)); + } + + [Fact] + public async Task Animated_avif_becomes_one_picture_for_other_targets() + { + var dir = fx.NewFolder(); + var avif = await fx.ConvertAsync([MakeAnimation(dir, "anim.gif", MagickFormat.Gif, 10, 10, 10)], To("avif")); + AssertDone(avif); + + var job = await fx.ConvertAsync([avif.Outputs.Single()], To("png")); + + AssertDone(job); + using var frames = new MagickImageCollection(job.Outputs.Single()); + AssertColours(frames, 1); + } + + [Fact] + public void Avif_reaches_gif_directly_and_counts_as_a_still_picture_on_the_way_to_video() + { + var planner = fx.Catalog.CreatePlanner(["ffmpeg"]); + Assert.Equal([("magick", "avif", "gif")], planner.Plan("avif", "gif")!.Steps.Select(s => (s.Converter.Id, s.From, s.To))); + Assert.Equal([("magick", "gif", "avif")], planner.Plan("gif", "avif")!.Steps.Select(s => (s.Converter.Id, s.From, s.To))); + Assert.Equal([("magick", "avif", "png"), ("ffmpeg", "png", "mp4")], planner.Plan("avif", "mp4")!.Steps.Select(s => (s.Converter.Id, s.From, s.To))); + } + + [Fact] + public async Task Animated_avif_becomes_one_page_in_a_pdf() + { + var dir = fx.NewFolder(); + var avif = await fx.ConvertAsync([MakeAnimation(dir, "anim.gif", MagickFormat.Gif, 10, 10, 10)], To("avif")); + AssertDone(avif); + + var job = await fx.ConvertAsync([avif.Outputs.Single()], To("pdf")); + + AssertDone(job); + using var pdf = PdfReader.Open(job.Outputs.Single(), PdfDocumentOpenMode.Import); + Assert.Equal(1, pdf.PageCount); + } + + // ───────────────────────── AVIF files of several pictures, not a sequence ───────────────────────── + + private static readonly MagickColor Red = new(255, 0, 0), Green = new(0, 255, 0), Blue = new(0, 0, 255); + + private static string MakeItems(string folder, string brand, params AvifBuilder.Picture[] pictures) + { + var path = Path.Combine(folder, "items.avif"); + File.WriteAllBytes(path, AvifBuilder.Items(brand, pictures)); + return path; + } + + /// Asserts each output is one picture of its own size and colour, named items_p{n}. + private static void AssertSeparate(ConversionJob job, string extension, params AvifBuilder.Picture[] pictures) + { + AssertDone(job); + var outputs = job.Outputs.OrderBy(path => path, StringComparer.Ordinal).ToList(); + Assert.Equal(Enumerable.Range(1, pictures.Length).Select(n => $"items_p{n}.{extension}"), outputs.Select(Path.GetFileName)); + for (var i = 0; i < pictures.Length; i++) + { + using var frames = new MagickImageCollection(outputs[i]); + var frame = Assert.Single(frames); + Assert.Equal((pictures[i].Width, pictures[i].Height), (frame.Width, frame.Height)); + var pixel = frame.GetPixels().GetPixel((int)frame.Width / 2, (int)frame.Height / 2).ToColor()!; + var expected = pictures[i].Colour; + Assert.True(Math.Abs(pixel.R - expected.R) < 24 && Math.Abs(pixel.G - expected.G) < 24 && Math.Abs(pixel.B - expected.B) < 24, + $"picture {i} is {pixel}, not {expected}"); + } + } + + [Theory] + [InlineData("png")] + [InlineData("jpg")] + [InlineData("webp")] + [InlineData("avif")] + public async Task A_burst_stays_separate_pictures(string target) + { + AvifBuilder.Picture[] pictures = [new(Red, 64, 48), new(Green, 64, 48)]; + + var job = await fx.ConvertAsync([MakeItems(fx.NewFolder(), "avif", pictures)], To(target, quality: 90)); + + AssertSeparate(job, target, pictures); + } + + [Fact] + public async Task A_burst_keeps_its_pictures_in_formats_that_hold_several() + { + var dir = fx.NewFolder(); + var source = MakeItems(dir, "avif", new AvifBuilder.Picture(Red, 64, 48), new AvifBuilder.Picture(Green, 64, 48)); + + var gif = await fx.ConvertAsync([source], To("gif")); + var document = await fx.ConvertAsync([source], To("pdf")); + + AssertDone(gif); + AssertDone(document); + using var frames = new MagickImageCollection(gif.Outputs.Single()); + Assert.Equal(2, frames.Count); + using var pdf = PdfReader.Open(document.Outputs.Single(), PdfDocumentOpenMode.Import); + Assert.Equal(2, pdf.PageCount); + } + + [Theory] + [InlineData("webp")] + [InlineData("avif")] + public async Task A_larger_second_item_is_not_cropped(string target) + { + AvifBuilder.Picture[] pictures = [new(Red, 64, 48), new(Blue, 128, 96)]; + + var job = await fx.ConvertAsync([MakeItems(fx.NewFolder(), "avif", pictures)], To(target, quality: 90)); + + AssertSeparate(job, target, pictures); + } + + [Theory] + [InlineData("webp")] + [InlineData("avif")] + public async Task A_smaller_second_item_like_a_visible_gain_map_is_not_laid_over_the_first(string target) + { + AvifBuilder.Picture[] pictures = [new(Red, 64, 48), new(new MagickColor(128, 128, 128), 16, 12)]; + + var job = await fx.ConvertAsync([MakeItems(fx.NewFolder(), "avif", pictures)], To(target, quality: 90)); + + AssertSeparate(job, target, pictures); + } + + [Fact] + public async Task The_avis_brand_alone_does_not_make_a_sequence() + { + AvifBuilder.Picture[] pictures = [new(Red, 64, 48), new(Green, 64, 48)]; + var path = MakeItems(fx.NewFolder(), "avis", pictures); + Assert.False(AvifSequence.IsSequence(path)); + + var job = await fx.ConvertAsync([path], To("png")); + + AssertSeparate(job, "png", pictures); + } + + [Fact] + public async Task Only_a_file_with_the_avis_brand_and_tracks_is_a_sequence() + { + var dir = fx.NewFolder(); + var job = await fx.ConvertAsync([MakeAnimation(dir, "anim.gif", MagickFormat.Gif, 10, 10)], To("avif")); + AssertDone(job); + var sequence = job.Outputs.Single(); + Assert.True(AvifSequence.IsSequence(sequence)); + + // Browsers show a file with the major brand "avif" as one picture, even with tracks in it. + var file = await File.ReadAllBytesAsync(sequence, TestContext.Current.CancellationToken); + Encoding.ASCII.GetBytes("avif").CopyTo(file, 8); + var still = Path.Combine(dir, "still.avif"); + await File.WriteAllBytesAsync(still, file, TestContext.Current.CancellationToken); + Assert.False(AvifSequence.IsSequence(still)); + + var empty = Path.Combine(dir, "empty.avif"); + await File.WriteAllBytesAsync(empty, [], TestContext.Current.CancellationToken); + Assert.False(AvifSequence.IsSequence(empty)); + Assert.False(AvifSequence.IsSequence(Path.Combine(dir, "missing.avif"))); + } + + // ───────────────────────── With FFmpeg, a decoder and encoder of its own ───────────────────────── + + private static string FfmpegOrSkip() + { + Assert.SkipUnless(FfmpegConverter.Locate() is not null, "FFmpeg is not installed (pwsh build/fetch-engines.ps1 -Only ffmpeg)"); + return FfmpegConverter.Locate()!.Value.Ffmpeg; + } + + [Fact] + public async Task Ffmpeg_counts_the_same_frames() + { + var ffmpeg = FfmpegOrSkip(); + var dir = fx.NewFolder(); + var job = await fx.ConvertAsync([MakeAnimation(dir, "anim.gif", MagickFormat.Gif, 10, 20, 30, 40)], To("avif")); + AssertDone(job); + + // The sequence track is the first stream; "-f null" decodes it. Stream 1 is the still picture for other viewers. + var result = await FfmpegRunner.RunAsync(ffmpeg, + ["-hide_banner", "-nostdin", "-i", job.Outputs.Single(), "-map", "0:v:0", "-f", "null", "-"], null, TestContext.Current.CancellationToken); + + Assert.Equal(0, result.ExitCode); + var frames = result.StandardError.Split('\r', '\n').Last(line => line.StartsWith("frame=", StringComparison.Ordinal)); + Assert.Equal(4, int.Parse(frames["frame=".Length..].Trim().Split(' ')[0], CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task An_animated_avif_from_ffmpeg_keeps_its_frames() + { + var ffmpeg = FfmpegOrSkip(); + var dir = fx.NewFolder(); + var gif = MakeAnimation(dir, "anim.gif", MagickFormat.Gif, 10, 10, 10, 10); + var avif = Path.Combine(dir, "ffmpeg.avif"); + var encode = await FfmpegRunner.RunAsync(ffmpeg, + ["-hide_banner", "-nostdin", "-v", "error", "-y", "-i", gif, "-c:v", "libaom-av1", "-crf", "20", "-cpu-used", "8", "-pix_fmt", "yuv420p", "-f", "avif", avif], + null, TestContext.Current.CancellationToken); + Assert.SkipUnless(encode.ExitCode == 0, $"This FFmpeg has no AVIF route: {encode.StandardError}"); + + var job = await fx.ConvertAsync([avif], To("webp", quality: 90)); + + AssertDone(job); + using var frames = new MagickImageCollection(job.Outputs.Single()); + AssertColours(frames, 4); + Assert.Equal([100, 100, 100, 100], DelaysMs(frames)); + } +} diff --git a/tests/Filee.Engines.Tests/AvifBuilder.cs b/tests/Filee.Engines.Tests/AvifBuilder.cs new file mode 100644 index 00000000..4877e82c --- /dev/null +++ b/tests/Filee.Engines.Tests/AvifBuilder.cs @@ -0,0 +1,95 @@ +// Builds AVIF files of several pictures in memory, no image files are checked into the repository. ImageMagick encodes +// each picture as a one-picture AVIF; its coded data and av1C box are put into a new file as items next to each other. + +using System.Buffers.Binary; +using System.Text; +using ImageMagick; + +namespace Filee.Engines.Tests; + +internal static class AvifBuilder +{ + /// One item of the file: a picture of one colour. The first item is the primary one. + public readonly record struct Picture(MagickColor Colour, uint Width, uint Height); + + /// An AVIF with every picture as a visible item, and no tracks: not a sequence, whatever says. + public static byte[] Items(string brand, params Picture[] pictures) + { + var coded = pictures.Select(Encode).ToList(); + var ftyp = Box("ftyp", Ascii(brand), [0, 0, 0, 0], Ascii("mif1"), Ascii("avif"), Ascii("miaf")); + // Offsets depend only on the length of the meta box, which the offsets don't change. + var meta = Meta(coded, 0); + var offset = ftyp.Length + meta.Length + 8; + return [.. ftyp, .. Meta(coded, offset), .. Box("mdat", [.. coded.SelectMany(c => c.Data)])]; + } + + /// The picture's av1C box and coded data, out of a one-picture AVIF written by ImageMagick. + private static (byte[] Av1C, byte[] Data, uint Width, uint Height) Encode(Picture picture) + { + using var image = new MagickImage(picture.Colour, picture.Width, picture.Height); + image.Quality = 90; + image.Strip(); + image.Alpha(AlphaAction.Off); + var file = image.ToByteArray(MagickFormat.Avif); + var meta = Find(file, 0, file.Length, "meta"); + var iprp = Find(file, meta.Start + 4, meta.End, "iprp"); // meta is a full box + var ipco = Find(file, iprp.Start, iprp.End, "ipco"); + var av1C = Find(file, ipco.Start, ipco.End, "av1C"); + var mdat = Find(file, 0, file.Length, "mdat"); + return (file[(av1C.Start - 8)..av1C.End], file[mdat.Start..mdat.End], picture.Width, picture.Height); + } + + private static byte[] Meta(List<(byte[] Av1C, byte[] Data, uint Width, uint Height)> coded, int dataOffset) + { + var count = U16(coded.Count); + var locations = new List(); + var infos = new List(); + var properties = new List(); + var associations = new List(); + for (var i = 0; i < coded.Count; i++) + { + var id = U16(i + 1); + locations.AddRange([.. id, 0, 0, 0, 1, .. U32(dataOffset), .. U32(coded[i].Data.Length)]); + dataOffset += coded[i].Data.Length; + infos.AddRange(Full("infe", 2, [.. id, 0, 0, .. Ascii("av01"), 0])); + properties.AddRange(coded[i].Av1C); + properties.AddRange(Full("ispe", 0, [.. U32((int)coded[i].Width), .. U32((int)coded[i].Height)])); + // The av1C of the item is essential, its size not (property indexes count from 1). + associations.AddRange([.. id, 2, (byte)(0x80 | (i * 2 + 1)), (byte)(i * 2 + 2)]); + } + return Full("meta", 0, + Full("hdlr", 0, [0, 0, 0, 0, .. Ascii("pict"), .. new byte[12], 0]), + Full("pitm", 0, U16(1)), + // Offsets and lengths of 4 bytes, no base offset. + Full("iloc", 0, [0x44, 0x00, .. count, .. locations]), + Full("iinf", 0, [.. count, .. infos]), + Box("iprp", Box("ipco", [.. properties]), Full("ipma", 0, [.. U32(coded.Count), .. associations]))); + } + + /// The contents of the first box of between two positions. + private static (int Start, int End) Find(byte[] file, int start, int end, string type) + { + for (var position = start; position + 8 <= end;) + { + var size = (int)BinaryPrimitives.ReadUInt32BigEndian(file.AsSpan(position)); + if (Encoding.ASCII.GetString(file, position + 4, 4) == type) + return (position + 8, position + size); + position += size; + } + throw new InvalidDataException($"No {type} box."); + } + + private static byte[] Box(string type, params byte[][] parts) + { + var length = 8 + parts.Sum(p => p.Length); + return [.. U32(length), .. Ascii(type), .. parts.SelectMany(p => p)]; + } + + private static byte[] Full(string type, byte version, params byte[][] parts) => Box(type, [[version, 0, 0, 0], .. parts]); + + private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text); + + private static byte[] U16(int value) => [(byte)(value >> 8), (byte)value]; + + private static byte[] U32(int value) => [(byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value]; +}