diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 4b03945e..9d7c61e3 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -1570,6 +1570,9 @@ can't hold it. The chunks carry the camera's wall-clock time, so FFmpeg reports the output starts at zero. Bink videos (`.bik`, read only, #751), the cutscene format of many games, are read by FFmpeg's `bink` demuxer and decoder and go to every video and audio target. FFmpeg has no Bink encoder, so nothing writes `.bik`. +FLIC animations (`.flc` and `.fli`, read only, #940), from Autodesk Animator and Animator Pro and the cutscenes of DOS +games, are read by FFmpeg's `flic` demuxer and decoder (8-bit palette pictures, no sound) and go to GIF, every video +target and PNG frames. FFmpeg has no FLIC encoder, so nothing writes them. A Video CD's `MPEGAV/AVSEQ01.DAT` (MPEG-1 in a RIFF/CDXA wrapper) counts as MPEG: `.dat` is an extension of that format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fails instead of making a file. diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs index 209336a9..7ed11ff3 100644 --- a/src/Filee.Core/Formats/FormatFields.cs +++ b/src/Filee.Core/Formats/FormatFields.cs @@ -88,7 +88,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow [ "mng", "pcx", "wbmp", "sgi", "ras", "pcd", "wmf", "dot", "wpd", "lwp", "sdw", "cwk", "mcw", "602", "psw", "xlt", "sdc", "sda", "mobi", "azw", "azw4", "prc", "lit", "lrf", "pdb", "pml", "rb", "snb", "tcr", "oeb", - "flv", "3gp", "3g2", "vob", "mod", "rm", "swf", "wtv", "amr", "au", "voc", "mpc", "spx", "tz", "z", + "flv", "3gp", "3g2", "vob", "mod", "rm", "swf", "wtv", "flc", "amr", "au", "voc", "mpc", "spx", "tz", "z", "lha", "arj", "eot", "fon", "sub" ]), ]); @@ -137,6 +137,8 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow ], ["legacy"]), // Kodak Photo CD. (["pcd"], ["legacy", "design"]), + // FLIC: Autodesk Animator's animations, also the cutscenes of DOS games. + (["flc"], ["legacy", "game"]), // Musepack. (["mpc"], ["legacy", "audio-pro"]), // EOT gave way to WOFF, FON to TrueType. diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 1cd57daa..a09e18f7 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -241,6 +241,9 @@ public static class FormatRegistry new("dav", "DAV", FormatCategory.Video, ["dav"], Writable: false), // Bink Video, the game cutscene format of RAD Game Tools (#751): FFmpeg's bink demuxer reads it. Never a target. new("bik", "BIK", FormatCategory.Video, ["bik"], Writable: false), + // FLIC, the animations of Autodesk Animator (.fli) and Animator Pro (.flc), also in DOS games and pixel-art tools + // (#940): FFmpeg's flic demuxer reads them. FFmpeg has no FLIC encoder, so never a target. + new("flc", "FLIC", FormatCategory.Video, ["flc", "fli"], Writable: false), // Raw H.265 / H.264 streams with no container, as security cameras and VMS software export them (issue #301). new("h265", "H.265", FormatCategory.Video, ["h265", "265", "hevc"], Writable: false), new("h264", "H.264", FormatCategory.Video, ["h264", "264"], Writable: false), diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index fc0c8d05..f26c72da 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -123,7 +123,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 66; + public const int CurrentSchemaVersion = 67; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index e226dbb9..e8b3e515 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -1016,6 +1016,10 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id == "video").Extensions; + video.RemoveAll(e => e is "flc" or "fli"); + profiles.Insert(0, new ToolbarProfile { Id = "my-retro", Name = "Retro", Extensions = ["fli"] }); + + Assert.True(SettingsMigrations.ApplyToLibrary(66, presets, profiles)); + + Assert.Contains("flc", video); + Assert.DoesNotContain("fli", video); // the user's own donut has it + Assert.False(SettingsMigrations.ApplyToLibrary(66, presets, profiles)); + } +} diff --git a/tests/Filee.Core.Tests/FormatFieldsTests.cs b/tests/Filee.Core.Tests/FormatFieldsTests.cs index fcf50c1a..7f0d8dca 100644 --- a/tests/Filee.Core.Tests/FormatFieldsTests.cs +++ b/tests/Filee.Core.Tests/FormatFieldsTests.cs @@ -28,7 +28,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => "3g2", "3gp", "3mf", "602", "ac3", "adoc", "ai", "amr", "ape", "arj", "ass", "au", "azw", "azw4", "bik", "bz2", "cab", "caf", "cavs", "cdr", "cgm", "chm", "cpio", "cur", "cwk", "dav", "dbk", "dcm", "dds", "deb", "dff", "dot", "dsf", "dss", "dst", "dts", "dv", "dwg", "eac3", "eot", "eps", "exr", "fbx", "fit", "fits", - "flv", "fodg", "fodp", "fods", "fodt", "fon", "geojson", "glb", "gltf", "gpx", "gz", "h264", "h265", "har", + "flc", "flv", "fodg", "fodp", "fods", "fodt", "fon", "geojson", "glb", "gltf", "gpx", "gz", "h264", "h265", "har", "hdr", "htmlz", "icns", "ipynb", "jar", "jp2", "jsonl", "kml", "kmz", "kra", "lha", "lit", "lrf", "lwp", "lz", "lzma", "m4s", "man", "mcaddon", "mcpack", "mctemplate", "mcw", "mcworld", "mediawiki", "miff", "mng", "mobi", "mod", "mp2", "mpc", "mrpack", "mxf", "obj", "oeb", @@ -72,7 +72,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "model3d", ["3mf", "fbx", "glb", "gltf", "obj", "ply", "stl"] }, { "geo", ["fit", "geojson", "gpx", "kml", "kmz", "shp"] }, { "font", ["eot", "fon", "otf", "ttf", "woff", "woff2"] }, - { "game", ["bik", "dds", "mcaddon", "mcpack", "mctemplate", "mcworld", "mrpack", "tga", "usm"] }, + { "game", ["bik", "dds", "flc", "mcaddon", "mcpack", "mctemplate", "mcworld", "mrpack", "tga", "usm"] }, { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "jp2", "kra", "pcd", "plt", "ps", "psb", "psd", "raw", "xcf"] }, { "science", ["dcm", "fits", "ipynb", "jp2"] }, { "developer", [ @@ -86,7 +86,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => ] }, { "audio-pro", ["ape", "caf", "dff", "dsf", "dss", "mpc", "tak", "tta", "w64", "wv"] }, { "legacy", [ - "3g2", "3gp", "602", "amr", "arj", "au", "azw", "azw4", "cwk", "dot", "eot", "flv", "fon", "lha", + "3g2", "3gp", "602", "amr", "arj", "au", "azw", "azw4", "cwk", "dot", "eot", "flc", "flv", "fon", "lha", "lit", "lrf", "lwp", "mcw", "mng", "mobi", "mod", "mpc", "oeb", "pcd", "pcx", "pdb", "pml", "prc", "psw", "ras", "rb", "rm", "sda", "sdc", "sdw", "sgi", "snb", "spx", "sub", "swf", "tcr", "tz", "vob", "voc", "wbmp", "wmf", "wpd", "wtv", "xlt", "z" @@ -146,7 +146,7 @@ public void Grade_counts_match_the_published_table() [FormatGrade.Everyday] = 19, [FormatGrade.General] = 94, [FormatGrade.Specialist] = 113, - [FormatGrade.Legacy] = 50, + [FormatGrade.Legacy] = 51, }, counts); } diff --git a/tests/Filee.Engines.Tests/MediaTests.cs b/tests/Filee.Engines.Tests/MediaTests.cs index 8669563a..976460ef 100644 --- a/tests/Filee.Engines.Tests/MediaTests.cs +++ b/tests/Filee.Engines.Tests/MediaTests.cs @@ -157,6 +157,119 @@ private static bool IsKeyFrame(byte[] unit) } } + [Fact] + public void Flic_animations_are_read_but_never_written() + { + // #940: Autodesk Animator's FLIC, read by FFmpeg's flic demuxer; FFmpeg has no FLIC encoder, so never a target. + var route = Planner().Plan("flc", "gif"); + Assert.NotNull(route); + Assert.Equal("ffmpeg", Assert.Single(route.Steps).Converter.Id); + Assert.Null(Planner().Plan("mp4", "flc")); + } + + [Theory] + [InlineData("intro.flc", "gif")] + [InlineData("intro.flc", "mp4")] + [InlineData("intro.fli", "gif")] + public async Task A_flic_animation_converts_whole(string name, string target) + { + // FFmpeg has no FLIC encoder, so the animation is put together here the way Autodesk Animator writes it: ten + // frames at 10 per second. + SkipUnlessInstalled(); + var flic = await MakeFlicAsync(name); + + var probed = await ProbeAsync(await ConvertAsync(flic, target)); + + Assert.Equal((64, 48), (probed.Video!.Width, probed.Video.Height)); + Assert.True(probed.Video.Frames >= 10, $"{probed.Video.Frames} frames"); + Assert.InRange(probed.Duration, 0.9, 1.2); + } + + [Fact] + public async Task A_fli_palette_of_six_bits_keeps_its_colours() + { + // Animator's FLI keeps its palette in 6 bits (0-63): full red and blue must stay full, not come out dark. + SkipUnlessInstalled(); + var still = await ConvertAsync(await MakeFlicAsync("intro.fli"), "png"); + + using var png = new ImageMagick.MagickImage(still); + using var pixels = png.GetPixels(); + Assert.Equal(ImageMagick.MagickColors.Red, pixels.GetPixel(0, 24)!.ToColor()); + Assert.Equal(ImageMagick.MagickColors.Blue, pixels.GetPixel(63, 24)!.ToColor()); + } + + private async Task MakeFlicAsync(string name) + { + var path = Path.Combine(fx.NewFolder(), name); + await File.WriteAllBytesAsync(path, Flic.Animation(name.EndsWith(".fli", StringComparison.Ordinal), 64, 48, frames: 10), + TestContext.Current.CancellationToken); + return path; + } + + /// Autodesk Animator's FLIC, as far as FFmpeg's flic demuxer and decoder read it. + private static class Flic + { + /// + /// The 128-byte header, then one frame chunk per picture: the first holds the palette (1 red, 2 blue), each one + /// its pixels uncompressed (a red bar growing from the left over blue). FLI (Animator) keeps the palette in 6 bits + /// and the speed in 1/70 s, FLC (Animator Pro) in 8 bits and milliseconds; both play at 10 frames a second here. + /// + public static byte[] Animation(bool fli, int width, int height, int frames) + { + var body = new MemoryStream(); + for (var n = 0; n < frames; n++) + { + var chunks = new MemoryStream(); + var count = 1; + if (n == 0) + { + var full = fli ? (byte)63 : (byte)255; + // One packet: skip no colours, change 256 (written as 0); three bytes per colour. + var palette = new byte[4 + 256 * 3]; + BinaryPrimitives.WriteUInt16LittleEndian(palette, 1); + palette[4 + 3] = full; + palette[4 + 6 + 2] = full; + Chunk(chunks, fli ? (ushort)11 : (ushort)4, palette); // COLOR_64 / COLOR_256 + count++; + } + var pixels = new byte[width * height]; + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + pixels[y * width + x] = x < (n + 1) * width / (frames + 1) ? (byte)1 : (byte)2; + Chunk(chunks, 16, pixels); // COPY + + var frame = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(frame, (uint)(16 + chunks.Length)); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(4), 0xF1FA); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(6), (ushort)count); + body.Write(frame); + chunks.WriteTo(body); + } + + var header = new byte[128]; + BinaryPrimitives.WriteUInt32LittleEndian(header, (uint)(header.Length + body.Length)); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(4), fli ? (ushort)0xAF11 : (ushort)0xAF12); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(6), (ushort)frames); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(8), (ushort)width); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(10), (ushort)height); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(12), 8); // bits per pixel + if (fli) + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(16), 7); + else + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(16), 100); + return [.. header, .. body.ToArray()]; + } + + private static void Chunk(MemoryStream output, ushort type, byte[] data) + { + Span head = stackalloc byte[6]; + BinaryPrimitives.WriteUInt32LittleEndian(head, (uint)(6 + data.Length)); + BinaryPrimitives.WriteUInt16LittleEndian(head[4..], type); + output.Write(head); + output.Write(data); + } + } + [Fact] public async Task A_self_contained_m4s_converts_and_a_bare_dash_segment_is_refused() {