Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/ENGINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion src/Filee.Core/Formats/FormatFields.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ .. Enum.GetValues<FormatCategory>().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"
]),
]);
Expand Down Expand Up @@ -137,6 +137,8 @@ .. Enum.GetValues<FormatCategory>().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.
Expand Down
3 changes: 3 additions & 0 deletions src/Filee.Core/Formats/FormatRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/Filee.Core/Settings/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable<TriggerGesture> gest
public sealed class AppSettings
{
/// <summary>Current schema version. Increase when the format changes and add a migration.</summary>
public const int CurrentSchemaVersion = 66;
public const int CurrentSchemaVersion = 67;

public int SchemaVersion { get; set; } = CurrentSchemaVersion;

Expand Down
4 changes: 4 additions & 0 deletions src/Filee.Core/Settings/UserDataStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,10 @@ public static bool ApplyToLibrary(int from, List<Preset> presets, List<ToolbarPr
// if another change takes it first.)
if (from < 66)
changed |= LearnExtensions(profiles, out _, ["kra~"]);
// v67: FLIC animations (.flc, .fli, #940) learn the video donut; custom claims stay put. (Schema 67 may be
// renumbered if another change takes it first.)
if (from < 67)
changed |= LearnExtensions(profiles, out _, ["flc", "fli"]);
return changed;
}

Expand Down
39 changes: 39 additions & 0 deletions tests/Filee.Core.Tests/FlicFormatTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// FLIC animations (.flc, .fli, #940): a read-only video source on the video donut, learned by existing libraries.

using Filee.Core.Formats;
using Filee.Core.Presets;
using Filee.Core.Profiles;
using Filee.Core.Settings;

namespace Filee.Core.Tests;

public class FlicFormatTests
{
[Theory]
[InlineData("intro.flc")]
[InlineData("INTRO.FLI")]
public void Flic_is_a_video_filee_reads_but_never_writes(string name)
{
var flic = FormatRegistry.Detect(name);
Assert.NotNull(flic);
Assert.Equal(("flc", FormatCategory.Video, false), (flic.Id, flic.Category, flic.Writable));
Assert.Equal("intro", FormatRegistry.NameWithoutExtension(name).ToLowerInvariant());
Assert.Equal("video", ProfileSelector.Select(BuiltInData.CreateProfiles(), [name])?.Id);
}

[Fact]
public void Version_66_libraries_learn_flc_and_fli_once()
{
var presets = BuiltInData.CreatePresets();
var profiles = BuiltInData.CreateProfiles();
var video = profiles.Single(p => 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));
}
}
8 changes: 4 additions & 4 deletions tests/Filee.Core.Tests/FormatFieldsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ private static string[] Hidden(IReadOnlySet<string> 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",
Expand Down Expand Up @@ -72,7 +72,7 @@ private static string[] Hidden(IReadOnlySet<string> 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", [
Expand All @@ -86,7 +86,7 @@ private static string[] Hidden(IReadOnlySet<string> 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"
Expand Down Expand Up @@ -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);
}
Expand Down
113 changes: 113 additions & 0 deletions tests/Filee.Engines.Tests/MediaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,119 @@
}
}

[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<string> 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;
}

/// <summary>Autodesk Animator's FLIC, as far as FFmpeg's flic demuxer and decoder read it.</summary>
private static class Flic
{
/// <summary>
/// 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.
/// </summary>
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<byte> 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()
{
Expand Down Expand Up @@ -1357,7 +1470,7 @@
// The coded pictures are the source's, byte for byte.
async Task<string> Hash(string file)
{
var hash = await ProcessRunner.RunAsync(Tools.Value.Ffmpeg,

Check warning on line 1473 in tests/Filee.Engines.Tests/MediaTests.cs

View workflow job for this annotation

GitHub Actions / build

Nullable value type may be null.
["-hide_banner", "-v", "error", "-i", file, "-map", "0:v", "-c", "copy", "-f", "hash", "-hash", "md5", "-"],
TimeSpan.FromMinutes(1), CancellationToken.None);
return hash.StandardOutput.Trim();
Expand Down
Loading