Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions docs/ENGINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/Filee.App/ViewModels/PresetEditorViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -976,10 +976,10 @@ partial void OnAnimationSpeedChanged(Choice<double>? oldValue, Choice<double> ne
/// <summary>The video of a Live / Motion Photo: MP4 and MOV (<see cref="MediaOptions.MotionPhotoTargets"/>).</summary>
public bool ShowExtractMotionPhoto => !IsTool && MediaOptions.MotionPhotoTargets.Contains(TargetId);
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// 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.
Expand Down
2 changes: 1 addition & 1 deletion src/Filee.Core/Presets/Preset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,7 @@ public void ForgetBackdropPictures()
public static readonly double[] AnimationSpeeds = [0.25, 0.5, 0.75, 1, 1.5, 2, 4];

/// <summary>
/// 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.
/// </summary>
public double AnimationSpeed { get; set; } = 1;
Expand Down
2 changes: 1 addition & 1 deletion src/Filee.Engines/Heic/HeicWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ private static List<Picture> Prepare(ConversionStep step, CancellationToken canc
{
using var images = ImageReader.Read(step.InputPath, step.From, step.Preset);
var pictures = new List<Picture>();
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");
Expand Down
6 changes: 3 additions & 3 deletions src/Filee.Engines/Magick/AnimationTiming.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -20,8 +20,8 @@ internal static class AnimationTiming
/// <summary>The largest delay GIF and MNG store (16 bits of hundredths of a second, about 11 minutes).</summary>
private const int MaxHundredths = ushort.MaxValue;

/// <summary>The ticks per second delays are written in: hundredths for GIF and MNG, milliseconds for WebP and APNG.</summary>
public static int TicksPerSecond(string targetFormat) => targetFormat is "webp" or "apng" ? 1000 : 100;
/// <summary>The ticks per second delays are written in: hundredths for GIF and MNG, milliseconds for WebP, APNG and AVIF.</summary>
public static int TicksPerSecond(string targetFormat) => targetFormat is "webp" or "apng" or "avif" ? 1000 : 100;

/// <summary>
/// The frames to write, playing <paramref name="speed"/> times as fast: every delay divided by
Expand Down
59 changes: 59 additions & 0 deletions src/Filee.Engines/Magick/AvifSequence.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Recognizes AVIF image sequences.</summary>
internal static class AvifSequence
{
/// <summary>The format id frames of an AVIF image sequence are read as, as a .png with an acTL chunk is read as "apng".</summary>
public const string Format = "avis";

/// <summary>The format id to read <paramref name="path"/> of <paramref name="format"/> as: <see cref="Format"/> for a sequence.</summary>
public static string SourceFormat(string format, string path) =>
format == "avif" && IsSequence(path) ? Format : format;

/// <summary>
/// True when <paramref name="path"/> 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.
/// </summary>
public static bool IsSequence(string path)
{
try
{
using var stream = File.OpenRead(path);
Span<byte> 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;
}
}
}
33 changes: 26 additions & 7 deletions src/Filee.Engines/Magick/ImageEncoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ internal static class ImageEncoder
private static readonly HashSet<string> MultiFrame = ["tiff", "gif", "apng", "mng"];

/// <summary>
/// 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 (<see cref="AvifSequence"/>).
/// 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.
/// </summary>
private static readonly HashSet<string> Animated = ["gif", "webp", "apng", "mng", "jxl"];
private static readonly HashSet<string> Animated = ["gif", "webp", "apng", "mng", "jxl", AvifSequence.Format];

/// <summary>
/// The edge an image engine offers from a picture in <paramref name="source"/> to <paramref name="target"/>. GIF
Expand All @@ -61,7 +62,7 @@ public static ConversionEdge Edge(string source, string target) =>

/// <summary>True when frames of <paramref name="sourceFormat"/> are written as one animation in <paramref name="targetFormat"/>.</summary>
public static bool KeepsAnimation(string sourceFormat, string targetFormat) =>
Animated.Contains(sourceFormat) && Animated.Contains(targetFormat);
Animated.Contains(sourceFormat) && (targetFormat == "avif" || Animated.Contains(targetFormat));

/// <summary>
/// Formats written without an alpha channel: transparent pixels are flattened onto the background colour. HEIC
Expand Down Expand Up @@ -157,8 +158,8 @@ public static (MagickFormat Format, string Extension) OutputFormat(string target

/// <summary>
/// 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.
/// </summary>
public static List<IMagickImage<byte>> SelectFrames(MagickImageCollection images, string sourceFormat, string targetFormat)
Expand Down Expand Up @@ -450,7 +451,10 @@ public static void ApplyOptions(IMagickImage<byte> image, ImageOptions options,
/// </summary>
/// <param name="options">Options of the preset (the Netpbm sub-format depends on them).</param>
/// <param name="sourcePath">Input file of the step, see <see cref="OutputFormat"/>.</param>
/// <param name="animation">The frames are an animation: write them into one file even for a WebP target.</param>
/// <param name="animation">
/// 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).
/// </param>
public static List<string> Write(IReadOnlyList<IMagickImage<byte>> frames, string targetFormat, IOutputAllocator output,
ImageOptions? options = null, string? sourcePath = null, bool animation = false)
{
Expand All @@ -476,6 +480,8 @@ public static List<string> Write(IReadOnlyList<IMagickImage<byte>> 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;
Expand All @@ -495,6 +501,19 @@ public static List<string> Write(IReadOnlyList<IMagickImage<byte>> frames, strin
return written;
}

/// <summary>
/// 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.
/// </summary>
private static void MatchAlpha(IEnumerable<IMagickImage<byte>> frames)
{
var action = frames.All(frame => frame.IsOpaque) ? AlphaAction.Off : AlphaAction.Set;
foreach (var frame in frames)
frame.Alpha(action);
}

/// <summary>A fitted picture is never made smaller than this long edge (nor than its own): below it, it's a thumbnail.</summary>
internal const int MinFittedLongEdge = 640;

Expand Down
5 changes: 3 additions & 2 deletions src/Filee.Engines/Magick/MagickImageConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ public Task<IReadOnlyList<string>> ConvertAsync(ConversionStep step, IProgress<d
// FITS → TIFF keeps the file's depth (16 or 32 bits), which the Q8 build can't: Filee writes it.
if (step.From == "fits" && step.To == "tiff")
return FitsRendering.WriteTiff(step, progress, cancellationToken);
// Animated PNGs are often saved as .png: read them as APNG, so they stay animated as GIF or WebP.
var from = step.From == "png" && Apng.IsAnimated(step.InputPath) ? "apng" : step.From;
// Animated PNGs are often saved as .png: read them as APNG, so they stay animated as GIF or WebP. An AVIF is an
// animation only when it is an image sequence.
var from = step.From == "png" && Apng.IsAnimated(step.InputPath) ? "apng" : AvifSequence.SourceFormat(step.From, step.InputPath);
using var images = ImageReader.Read(step.InputPath, from, step.Preset);
return Write(images, from, step, progress, cancellationToken);
}, cancellationToken);
Expand Down
2 changes: 1 addition & 1 deletion src/Filee.Engines/Pdf/PdfSharpConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ private static IReadOnlyList<string> ImageToPdf(ConversionStep step, IProgress<d
var options = step.Preset.Image;
var pdfOptions = step.Preset.Pdf;
using var images = ImageReader.Read(step.InputPath, step.From, step.Preset);
var frames = ImageEncoder.SelectFrames(images, step.From, "pdf");
var frames = ImageEncoder.SelectFrames(images, AvifSequence.SourceFormat(step.From, step.InputPath), "pdf");

using var document = new PdfDocument();
for (var i = 0; i < frames.Count; i++)
Expand Down
2 changes: 1 addition & 1 deletion tests/Filee.App.Tests/RenderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1653,7 +1653,7 @@ public void Preset_editor_shows_playback_speed_only_for_targets_that_keep_animat
Save(window, "preset-animation-speed-ko.png");
window.Close();

foreach (var target in new[] { "webp", "apng", "mng", Filee.Core.Presets.BuiltInData.SameAsSource })
foreach (var target in new[] { "webp", "apng", "mng", "avif", Filee.Core.Presets.BuiltInData.SameAsSource })
{
vm.Target = vm.Targets.Single(t => t.Value == target);
Assert.True(vm.ShowAnimationSpeed, target);
Expand Down
Loading
Loading