From 5adad27ee27928b197bcd169f8ecdd5c43e86ec6 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Tue, 10 Nov 2026 20:09:18 +0000 Subject: [PATCH 1/7] Add files via upload --- src/Filee.Core/Formats/FormatFields.cs | 25 ++++++----- src/Filee.Core/Formats/FormatRegistry.cs | 56 ++++++++++++++++++++---- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs index 9fe519d20..d85a0ec3e 100644 --- a/src/Filee.Core/Formats/FormatFields.cs +++ b/src/Filee.Core/Formats/FormatFields.cs @@ -66,20 +66,21 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow [ "tiff", "bmp", "ico", "avif", "jxl", "tga", "apng", "jxr", "livp", "svg", "svgz", "emf", "vsd", "odg", "docm", "dotx", "dotm", "doc", "odt", "ott", "rtf", "wps", "ofd", "abw", "pages", "pub", "eml", "msg", - "xlsm", "xltx", "xls", "ods", "ots", "tsv", "et", "numbers", "pptm", "potx", "potm", "ppsx", "ppt", + "p7m", "xlsm", "xltx", "xls", "ods", "ots", "tsv", "et", "numbers", "pptm", "potx", "potm", "ppsx", "ppt", "odp", "otp", "key", "dps", "md", "html", "mhtml", "opml", "json", "xml", "vcf", "ics", "epub", "kepub", "azw3", "fb2", "cbz", "cbr", "cb7", "cbt", "cbc", "m4v", "mkv", "webm", "avi", "wmv", "mpg", "ts", - "m2ts", "ogv", "m4b", "aac", "flac", "ogg", "opus", "wma", "aiff", "weba", "mka", "qta", "7z", "rar", - "dmg", "iso", "img", "alz", "egg", "split", "dxf", "srt", "vtt", "smi", "lrc" + "m2ts", "ogv", "m4b", "m4r", "aac", "flac", "ogg", "opus", "wma", "aiff", "weba", "mka", "qta", "7z", "rar", + "dmg", "iso", "img", "udf", "alz", "egg", "split", "dxf", "srt", "vtt", "smi", "lrc" ]), (FormatGrade.Specialist, [ "jp2", "psd", "psb", "ppm", "icns", "qoi", "dds", "cur", "xpm", "xbm", "pam", "pfm", "miff", "dcm", - "exr", "hdr", "fits", "xcf", "raw", "eps", "ps", "ai", "cdr", "cgm", "plt", "dst", "fodg", "fodt", + "exr", "hdr", "fits", "xcf", "kra", "raw", "eps", "ps", "ai", "cdr", "cgm", "plt", "dst", "fodg", "fodt", "fods", "fodp", "rst", "tex", "org", "adoc", "typ", "mediawiki", "dbk", "ipynb", "textile", "man", - "jsonl", "yaml", "toml", "har", "htmlz", "txtz", "chm", "dv", "mxf", "cavs", "usm", "m4s", "dav", "bik", + "jsonl", "yaml", "toml", "har", "plist", "htmlz", "txtz", "chm", "dv", "mxf", "cavs", "usm", "m4s", "dav", "bik", "h265", "h264", "ac3", "caf", "mp2", "dss", "wv", "tta", "w64", "eac3", "ape", "tak", "dsf", "dff", - "dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "cab", + "dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "mcpack", "mcaddon", + "mcworld", "mctemplate", "mrpack", "cab", "cpio", "deb", "rpm", "dwg", "ttf", "otf", "woff", "woff2", "ass", "ssa", "ttml", "scc", "glb", "gltf", "stl", "obj", "3mf", "ply", "fbx", "geojson", "kml", "kmz", "gpx", "shp", "fit" ]), @@ -87,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" ]), ]); @@ -96,16 +97,16 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow private static readonly Dictionary Overlaps = BuildOverlaps( [ // Layout, print, photo post-production, signs and embroidery. - (["psd", "psb", "exr", "hdr", "xcf", "raw", "eps", "ps", "ai", "cdr", "plt", "dst"], ["design"]), - // Game textures and cutscenes. - (["dds", "usm", "bik"], ["game"]), + (["psd", "psb", "exr", "hdr", "xcf", "kra", "raw", "eps", "ps", "ai", "cdr", "plt", "dst"], ["design"]), + // Game textures and cutscenes, Minecraft packs and modpacks. + (["dds", "usm", "bik", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack"], ["game"]), // Medical imaging and astronomy. (["dcm", "fits"], ["science"]), // Software work: raw image formats of graphics tools, app icons and cursors, config and data files, flat XML // ODF for version control, man pages, streaming segments, packages and compressed archives. ([ "ppm", "icns", "qoi", "cur", "xpm", "xbm", "pam", "pfm", "miff", "fodg", "fodt", "fods", "fodp", "man", - "jsonl", "yaml", "toml", "har", "m4s", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", + "jsonl", "yaml", "toml", "har", "plist", "m4s", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "cab", "cpio", "deb", "rpm" ], ["developer"]), // Markup sources, papers and outlines, maintenance-doc drawings (CGM), Calibre's ebook formats. @@ -136,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 d797260a6..b84a59da7 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -7,7 +7,7 @@ namespace Filee.Core.Formats; /// Lookup table of every format Filee knows about. public static class FormatRegistry { - /// Pseudo format of the "Extract" preset: the output is a folder with the archive's files. + /// Pseudo format for archive extraction outputs and folders explicitly classified at input handling. public const string Folder = "folder"; /// All known formats. Order is the order shown in format pickers. @@ -15,7 +15,8 @@ public static class FormatRegistry [ // Raster images new("png", "PNG", FormatCategory.Image, ["png", "png_large"]), - new("jpg", "JPG", FormatCategory.Image, ["jpg", "jpeg", "jpe", "jfif", "jpg_large"]), + // Insta360 .insp photos (#711) are JPEGs of both fisheye circles; stitching them needs Insta360's lens data. + new("jpg", "JPG", FormatCategory.Image, ["jpg", "jpeg", "jpe", "jfif", "jif", "insp", "jpg_large"]), new("webp", "WEBP", FormatCategory.Image, ["webp"]), new("tiff", "TIFF", FormatCategory.Image, ["tiff", "tif"]), new("bmp", "BMP", FormatCategory.Image, ["bmp", "dib"]), @@ -54,6 +55,9 @@ public static class FormatRegistry // Read on Windows only, with its own codec (HDR screenshots of Windows are JPEG XR). new("jxr", "JPEG XR", FormatCategory.Image, ["jxr", "wdp", "hdp"], Writable: false), new("xcf", "XCF", FormatCategory.Image, ["xcf"], Writable: false), + // Krita's own file (#903): a ZIP whose merged picture (mergedimage.png) is read, as Krita shows it. ".kra~" is the + // backup Krita keeps of the previous save, the same file under another name (#1077). + new("kra", "Krita (KRA)", FormatCategory.Image, ["kra", "kra~"], Writable: false), // iPhone Live Photo as one ZIP (iCloud downloads): the photo, or its video with FFmpeg. new("livp", "Live Photo (LIVP)", FormatCategory.Image, ["livp"], Writable: false), new("raw", "RAW", FormatCategory.Image, @@ -106,6 +110,9 @@ public static class FormatRegistry new("pub", "PUB", FormatCategory.Document, ["pub"], Writable: false), new("eml", "EML", FormatCategory.Document, ["eml"]), new("msg", "MSG", FormatCategory.Document, ["msg"], Writable: false), + // A signed envelope (CMS / PKCS #7, CAdES; #1010): Italian e-invoices (invoice.xml.p7m) and signed PDFs. It is + // converted as the file inside it (IEnvelopeOpener), and named after it: invoice.pdf.p7m → invoice.pdf. + new("p7m", "P7M", FormatCategory.Document, ["p7m"], Writable: false), // Korean word processor (Hancom) new("hwpx", "HWPX", FormatCategory.Hwp, ["hwpx"]), @@ -141,11 +148,15 @@ public static class FormatRegistry new("sda", "SDA", FormatCategory.Presentation, ["sda", "sdd"], Writable: false), // Plain text / markup - // Text logs, source code, proxy scripts, style sheets and settings files are read as plain text, never executed or - // compiled (issues #273, #284, #254, #388, #368, #607, #536, #630, #661, #660, #864, #828). + // Text logs, source code, database schemas (DBML), diagrams written as text (Mermaid), proxy scripts, style sheets + // and settings files are read as plain text, never executed, compiled or translated (issues #273, #284, #254, #388, + // #368, #607, #536, #630, #661, #660, #864, #828, #980, #997, #854, #1176, #1309). Rust, Go, Kotlin, Swift, Scala, + // SCSS, Vue and TSX sources join the other code files; ".js", ".php" and shell scripts stay out (programs and web + // pages, see WebFileNames). new("txt", "TXT", FormatCategory.Text, ["txt", "log", "py", "pac", "bat", "cmd", "command", "lua", "css", "ps1", "sql", "ahk", "ini", "rsc", - "c", "h", "cpp", "cc", "cxx", "hpp", "hh", "hxx", "cs"]), + "c", "h", "cpp", "cc", "cxx", "hpp", "hh", "hxx", "cs", "dart", "hql", "dbml", "java", + "mermaid", "mmd", "rs", "go", "kt", "swift", "scala", "scss", "vue", "tsx"]), new("md", "Markdown", FormatCategory.Text, ["md", "markdown"]), new("html", "HTML", FormatCategory.Text, ["html", "htm", "xhtml"]), // A web page saved with its pictures in one MIME file (Chrome, Edge, Word); read as the page it holds. @@ -176,6 +187,8 @@ public static class FormatRegistry // A browser's recorded network requests (HTTP Archive): JSON, read as one table row per request (issue #328). new("har", "HAR", FormatCategory.Data, ["har"], Writable: false), new("ics", "iCalendar", FormatCategory.Data, ["ics", "ical"]), + // The settings and data files of macOS and iOS apps, XML or binary, read into the data tree (issue #1256). + new("plist", "Property list", FormatCategory.Data, ["plist"], Writable: false), // E-books new("epub", "EPUB", FormatCategory.Ebook, ["epub"]), @@ -206,7 +219,8 @@ public static class FormatRegistry // Video new("mp4", "MP4", FormatCategory.Video, ["mp4"]), new("m4v", "M4V", FormatCategory.Video, ["m4v"]), - new("mov", "MOV", FormatCategory.Video, ["mov", "qt"]), + // Some downloaders name video/quicktime files ".quicktime" (#1283); MOV stays the extension Filee writes. + new("mov", "MOV", FormatCategory.Video, ["mov", "qt", "quicktime"]), new("mkv", "MKV", FormatCategory.Video, ["mkv"]), new("webm", "WEBM", FormatCategory.Video, ["webm"]), new("avi", "AVI", FormatCategory.Video, ["avi", "divx"]), @@ -237,6 +251,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), @@ -245,6 +262,8 @@ public static class FormatRegistry new("mp3", "MP3", FormatCategory.Audio, ["mp3"]), new("m4a", "M4A", FormatCategory.Audio, ["m4a"]), new("m4b", "M4B", FormatCategory.Audio, ["m4b"]), + // iPhone ringtones (#1291): AAC in the same MP4 container as M4A, under the extension iTunes and iOS look for. + new("m4r", "M4R", FormatCategory.Audio, ["m4r"]), new("aac", "AAC", FormatCategory.Audio, ["aac"]), new("wav", "WAV", FormatCategory.Audio, ["wav", "wave"]), new("flac", "FLAC", FormatCategory.Audio, ["flac"]), @@ -294,13 +313,23 @@ public static class FormatRegistry new("lzma", "LZMA", FormatCategory.Archive, ["lzma"], Writable: false), new("z", "Z", FormatCategory.Archive, ["z"], Writable: false), new("jar", "JAR", FormatCategory.Archive, ["jar"], Writable: false), + // Minecraft Bedrock packs, add-ons, worlds and world templates, and Modrinth modpacks (#824, #898): ZIP archives + // under their own extensions, written with their marker file (manifest.json, level.dat, modrinth.index.json) at + // the top. + new("mcpack", "Minecraft pack", FormatCategory.Archive, ["mcpack"]), + new("mcaddon", "Minecraft add-on", FormatCategory.Archive, ["mcaddon"]), + new("mcworld", "Minecraft world", FormatCategory.Archive, ["mcworld"]), + new("mctemplate", "Minecraft world template", FormatCategory.Archive, ["mctemplate"]), + new("mrpack", "Modrinth modpack", FormatCategory.Archive, ["mrpack"]), new("cab", "CAB", FormatCategory.Archive, ["cab"], Writable: false), new("cpio", "CPIO", FormatCategory.Archive, ["cpio"], Writable: false), new("deb", "DEB", FormatCategory.Archive, ["deb"], Writable: false), new("rpm", "RPM", FormatCategory.Archive, ["rpm"], Writable: false), new("dmg", "DMG", FormatCategory.Archive, ["dmg"], Writable: false), - new("iso", "ISO", FormatCategory.Archive, ["iso"], Writable: false), + new("iso", "ISO", FormatCategory.Archive, ["iso"]), new("img", "IMG", FormatCategory.Archive, ["img"], Writable: false), + // UDF disc images (#1338): DVD, Blu-ray and data discs, unpacked by 7-Zip like ISO files. Never a target. + new("udf", "UDF", FormatCategory.Archive, ["udf"], Writable: false), new("lha", "LHA", FormatCategory.Archive, ["lha", "lzh"], Writable: false), new("arj", "ARJ", FormatCategory.Archive, ["arj"], Writable: false), new("alz", "ALZ", FormatCategory.Archive, ["alz"], Writable: false), @@ -391,11 +420,20 @@ public static string ExtensionOf(string path) return Path.GetExtension(name).TrimStart('.'); } - /// The file name without its (possibly two-part) extension: "backup.tar.gz" → "backup". + /// + /// The file name without its (possibly two-part) extension: "backup.tar.gz" → "backup". An envelope's name loses + /// the extension of the file inside too, when it has one Filee knows: "invoice.pdf.p7m" → "invoice". + /// public static string NameWithoutExtension(string path) { var name = Path.GetFileName(path); var extension = ExtensionOf(name); - return extension.Length == 0 ? name : name[..^(extension.Length + 1)]; + if (extension.Length == 0) + return name; + var stem = name[..^(extension.Length + 1)]; + return Envelopes.Contains(extension) && Detect(stem) is not null ? NameWithoutExtension(stem) : stem; } + + /// Extensions of envelopes, which are named after the file inside (). + private static readonly HashSet Envelopes = new(["p7m"], StringComparer.OrdinalIgnoreCase); } From 5833fbad5a4f4a5e5bc061d8d4380d85d623be69 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Tue, 10 Nov 2026 20:10:15 +0000 Subject: [PATCH 2/7] Add files via upload --- src/Filee.Core/Settings/AppSettings.cs | 36 +- src/Filee.Core/Settings/ArchiveExtractor.cs | 180 +++ src/Filee.Core/Settings/MediaEncoding.cs | 1162 +++++++++++++++++ src/Filee.Core/Settings/TranslationService.cs | 350 +++++ src/Filee.Core/Settings/UserDataStore.cs | 265 +++- 5 files changed, 1970 insertions(+), 23 deletions(-) create mode 100644 src/Filee.Core/Settings/ArchiveExtractor.cs create mode 100644 src/Filee.Core/Settings/MediaEncoding.cs create mode 100644 src/Filee.Core/Settings/TranslationService.cs diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index f200e3fb0..d28dcdf10 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -72,6 +72,15 @@ public sealed class DonutSettings /// The lowest the Theme and Donut toolbar pages offer. public const double MinOpacity = 0.1; + /// + /// The small line under a slice's name saying what it makes ("SVG" under Trace, the file's own format under a + /// preset that keeps it, "Group of N" under a group). Off, the name stands alone in the middle of its slice; a + /// group is still marked by its chevron. A folder slice keeps its "Move" or "Copy", which says what it does, and + /// two slices of one name keep theirs, the only thing that tells them apart. + /// On as before it existed; a missing value (settings from before it) reads as on, so it needs no schema step. + /// + public bool ShowCaptions { get; set; } = true; + /// The flip key unless the user picks another one (Settings → Shortcuts). public const string DefaultFlipKey = "Tab"; @@ -123,7 +132,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 = 58; + public const int CurrentSchemaVersion = 77; public int SchemaVersion { get; set; } = CurrentSchemaVersion; @@ -231,6 +240,9 @@ public sealed class AppSettings /// public bool LibreOfficeForSlides { get; set; } = true; + /// Active local model for offline translation (e.g. "llama3.2", "qwen2.5"). + public string? PreferredOllamaModel { get; set; } + /// /// With LibreOffice and H2Orestart installed, HWP / HWPX → PDF go through it instead of rhwp: a way around a /// document rhwp lays out wrong (each engine gets different documents right). @@ -252,6 +264,11 @@ public sealed class AppSettings /// Every conversion keeps the created and modified dates of the original, whatever the preset says. public bool KeepFileDates { get; set; } + /// + /// Files dropped or picked on the Home page wait in its list (true), instead of opening the donut for each drop. + /// + public bool CollectDroppedFiles { get; set; } = false; + /// Every geospatial conversion is saved as a ZIP, whatever the preset says (see ). public bool ZipGeoResults { get; set; } @@ -273,6 +290,17 @@ public sealed class AppSettings /// public WindowBounds? MainWindow { get; set; } + /// + /// The fields turned off on the Fields page (ids of ): a format that belongs only to + /// them is left out of the donut and the preset lists. An empty list and a missing value both mean every field is on, so + /// settings from before it read as every field on and need no schema step. A new install gets its defaults from + /// . + /// + public List TurnedOffFields { get; set; } = []; + + /// as a set that ignores case, for . + public IReadOnlySet TurnedOffFieldSet() => TurnedOffFields.ToHashSet(StringComparer.OrdinalIgnoreCase); + /// /// Returns itself, or a copy with the app-wide output settings applied: it saves to /// when the preset's location is , and keeps the @@ -350,4 +378,10 @@ public sealed class BrowserDropSettings /// Keep only the converted file: the original goes into a work folder and is deleted after the conversion. public bool SaveOnlyConverted { get; set; } + + /// + /// Links may lead to this computer and the local network (an intranet, a NAS). Off: a web page could otherwise make + /// Filee send requests to the router or other devices at home or at work. + /// + public bool AllowLocalNetwork { get; set; } } diff --git a/src/Filee.Core/Settings/ArchiveExtractor.cs b/src/Filee.Core/Settings/ArchiveExtractor.cs new file mode 100644 index 000000000..40eec0ea6 --- /dev/null +++ b/src/Filee.Core/Settings/ArchiveExtractor.cs @@ -0,0 +1,180 @@ +// Unpacks every archive format Filee reads into a folder: the bundled 7-Zip for most of them, Filee's own readers for +// ALZ, EGG and lzip. Compressed TARs (.tar.gz, .tar.lz …) and RPM payloads are unpacked stage by stage until the +// files appear. Every stage is checked first (encryption, paths leaving the folder, disk space) and links are removed. +// A single compressed stream (.gz, .bz2 …) is written by Filee as 7-Zip decodes it, so it can't fill the drive. The +// archive's Mark of the Web goes onto the unpacked files. + +using Filee.Core.Formats; +using Filee.Core.Platform; + +namespace Filee.Engines.Archives; + +/// Unpacks archives into scratch folders. +/// +/// The most a single compressed stream may write into a folder; unless a test +/// sets a small one. +/// +internal sealed class ArchiveExtractor(SevenZip? sevenZip, Func? streamLimit = null) +{ + /// Formats read by 7-Zip. + public static readonly IReadOnlyList SevenZipFormats = + [ + "zip", "jar", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", "tz", "gz", "bz2", "xz", "lzma", "z", + "cab", "cpio", "deb", "rpm", "dmg", "iso", "img", "udf", "lha", "arj", "split", + "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack", + ]; + + /// Formats read by Filee's own code (lzip still needs 7-Zip's LZMA decoder). + public static readonly IReadOnlyList BuiltInFormats = ["alz", "egg", "lz", "tlz"]; + + /// Everything that can be unpacked. + public static IEnumerable ReadableFormats => SevenZipFormats.Concat(BuiltInFormats); + + /// A TAR inside a compressed stream (or a 7z): unpacking the outer layer yields one .tar file. + private static readonly HashSet TarWrapped = ["tgz", "tbz2", "txz", "tar7z", "tz", "tlz"]; + + /// + /// One compressed stream, no names or sizes to check beforehand (bzip2 states no size, gzip one modulo 4 GiB that + /// its author chose). + /// + private static readonly HashSet SingleStream = ["gz", "bz2", "xz", "lzma", "z", "zst", "tgz", "tbz2", "txz", "tz"]; + + /// Formats an RPM payload comes in (7-Zip shows it as one compressed CPIO file). + private static readonly HashSet RpmPayload = ["cpio", "gz", "bz2", "xz", "lzma", "z", "zst"]; + + private const int MaxStages = 3; + + /// + /// Unpacks (of format ) into a new folder below + /// and returns that folder. + /// + public async Task ExtractAsync(string archive, string format, string scratchRoot, CancellationToken cancellationToken) + { + var current = archive; + var currentFormat = format; + string? previous = null; + for (var stage = 0; ; stage++) + { + var folder = Path.Combine(scratchRoot, "x" + stage); + Directory.CreateDirectory(folder); + await ExtractOnceAsync(current, currentFormat, folder, scratchRoot, cancellationToken); + ArchiveSafety.RemoveLinks(folder); + if (previous is not null) + ArchiveFiles.TryDeleteFolder(previous); + + if (stage + 1 < MaxStages && InnerArchive(format, folder) is { } inner) + { + previous = folder; + (current, currentFormat) = inner; + continue; + } + return folder; + } + } + + private async Task ExtractOnceAsync(string archive, string format, string folder, string scratchRoot, CancellationToken cancellationToken) + { + var name = Path.GetFileName(archive); + switch (format) + { + case "alz": + await Task.Run(() => AlzReader.Extract(archive, folder, cancellationToken), cancellationToken); + PassOnMark(archive, folder); + break; + case "egg": + await EggReader.ExtractAsync(archive, folder, sevenZip, scratchRoot, cancellationToken); + PassOnMark(archive, folder); + break; + case "lz": + case "tlz": + var output = FormatRegistry.NameWithoutExtension(archive) + (format == "tlz" ? ".tar" : ""); + await LzipReader.DecompressAsync(archive, Path.Combine(folder, output), Require(), scratchRoot, cancellationToken); + PassOnMark(archive, folder); + break; + default: + var tool = Require(); + var entries = await tool.ListAsync(archive, cancellationToken); + ArchiveSafety.CheckListing(entries, name, folder); + // 7-Zip lists no name for some (.bz2, .xz), and never more than one file. + if (SingleStream.Contains(format) && entries.Count(e => !e.IsFolder) <= 1) + { + await DecodeStreamAsync(tool, archive, format, entries.FirstOrDefault(e => !e.IsFolder), folder, cancellationToken); + PassOnMark(archive, folder); + break; + } + var links = entries.Where(e => e.IsSymbolicLink).Select(e => e.Path).ToList(); + await tool.ExtractAsync(archive, folder, links, scratchRoot, cancellationToken); + break; + } + } + + /// + /// Unpacks a single compressed stream into under the name 7-Zip gives it (the one stored + /// in a .gz, else the archive's name without its extension), stopping at . + /// + private async Task DecodeStreamAsync(SevenZip tool, string archive, string format, SevenZipEntry? entry, + string folder, CancellationToken cancellationToken) + { + var name = Path.GetFileName(archive); + var entryName = entry is { Path.Length: > 0 } + ? entry.Path + : FormatRegistry.NameWithoutExtension(archive) + (TarWrapped.Contains(format) ? ".tar" : ""); + var path = ArchiveSafety.ResolveEntryPath(folder, entryName, name); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var limit = (streamLimit ?? ArchiveSafety.StreamLimit)(folder); + var tooLarge = ArchiveSafety.TooLarge(name, limit, folder); // with the free space before writing + try + { + await using var output = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 16); + await tool.DecodeAsync(archive, output, limit, () => tooLarge, cancellationToken); + } + catch + { + ArchiveFiles.TryDeleteFile(path); + throw; + } + File.SetLastWriteTimeUtc(path, entry?.Modified ?? File.GetLastWriteTimeUtc(archive)); + } + + /// + /// Gives the files Filee's own code unpacked into the archive's Mark of the Web, if it has + /// one (7-Zip does this itself for the files it writes). + /// + private static void PassOnMark(string archive, string folder) => + MarkOfTheWeb.Copy(archive, Directory.EnumerateFiles(folder, "*", + new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint })); + + /// The single archive file a stage produced that must be unpacked as well, or null when done. + private static (string Path, string Format)? InnerArchive(string originalFormat, string folder) + { + if (!TarWrapped.Contains(originalFormat) && originalFormat != "rpm") + return null; + var entries = Directory.GetFileSystemEntries(folder); + if (entries.Length != 1 || !File.Exists(entries[0])) + return null; + + var file = entries[0]; + if (IsTar(file)) + return (file, "tar"); + if (originalFormat == "rpm") + { + var detected = FormatRegistry.Detect(file)?.Id ?? Path.GetExtension(file).TrimStart('.').ToLowerInvariant(); + if (RpmPayload.Contains(detected)) + return (file, detected); + } + return null; + } + + /// True for a .tar file or a file with the "ustar" signature at offset 257. + internal static bool IsTar(string path) + { + if (path.EndsWith(".tar", StringComparison.OrdinalIgnoreCase)) + return true; + Span header = stackalloc byte[262]; + using var stream = File.OpenRead(path); + return stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false) == header.Length + && header[257..262].SequenceEqual("ustar"u8); + } + + private SevenZip Require() => sevenZip ?? throw new InvalidOperationException("7-Zip was not found (engines/7zip)."); +} diff --git a/src/Filee.Core/Settings/MediaEncoding.cs b/src/Filee.Core/Settings/MediaEncoding.cs new file mode 100644 index 000000000..bc7529063 --- /dev/null +++ b/src/Filee.Core/Settings/MediaEncoding.cs @@ -0,0 +1,1162 @@ +// Which container and codecs Filee writes for every video and audio format, and the FFmpeg command lines for them. +// Pure (no processes), so the choices are unit tested without FFmpeg. Every encoder and muxer used here was checked +// against the pinned FFmpeg build (engines.json: `ffmpeg -encoders` / `ffmpeg -muxers`). + +using System.Globalization; +using Filee.Core.Presets; + +namespace Filee.Engines.Media; + +/// How one target format is written. +/// FFmpeg container name (-f), always given explicitly: some extensions are ambiguous. +/// FFmpeg video encoder, null for audio-only formats. +/// FFmpeg audio encoder. +internal sealed record MediaTarget(string Muxer, string? VideoCodec, string AudioCodec); + +/// One run of ffmpeg. +/// Command line after the executable. +/// Share of the whole conversion's progress (the passes of one conversion add up to 1). +internal sealed record FfmpegPass(IReadOnlyList Arguments, double Weight); + +/// Encoding choices and command lines. +internal static partial class MediaEncoding +{ + /// + /// Video formats Filee writes. H.264 + AAC wherever the container allows it: it plays on every browser, phone, + /// TV and editor. The other containers get the codecs their players expect. + /// + public static IReadOnlyDictionary VideoTargets { get; } = new Dictionary + { + ["mp4"] = new("mp4", "libx264", "aac"), + // Apple's M4V is MP4 (FFmpeg's "m4v" muxer would write a raw MPEG-4 part 2 stream instead). + ["m4v"] = new("mp4", "libx264", "aac"), + ["mov"] = new("mov", "libx264", "aac"), + ["mkv"] = new("matroska", "libx264", "aac"), + ["flv"] = new("flv", "libx264", "aac"), + ["ts"] = new("mpegts", "libx264", "aac"), + ["m2ts"] = new("mpegts", "libx264", "aac"), // + 192-byte Blu-ray packets, see ContainerArguments + ["3gp"] = new("3gp", "libx264", "aac"), + ["3g2"] = new("3g2", "libx264", "aac"), + // The WebM specification allows only VP8/VP9/AV1 with Vorbis/Opus. + ["webm"] = new("webm", "libvpx-vp9", "libopus"), + // MPEG-4 part 2 tagged XVID + CBR MP3: what DVD players, old TVs and Windows' built-in codecs read in AVI. + ["avi"] = new("avi", "mpeg4", "libmp3lame"), + ["wmv"] = new("asf", "wmv2", "wmav2"), + // MPEG-2 program stream ("vob" is FFmpeg's plain MPEG-2 PS muxer; "mpeg" would write an MPEG-1 system stream). + ["mpg"] = new("vob", "mpeg2video", "mp2"), + // DVD-Video compliant: 720×480 NTSC / 720×576 PAL, AC-3, DVD mux rate and navigation packets. + ["vob"] = new("dvd", "mpeg2video", "ac3"), + ["ogv"] = new("ogg", "libtheora", "libvorbis"), + // DV has fixed frame sizes and rates (see DvdOrDvFilter) and 48 kHz PCM. + ["dv"] = new("dv", "dvvideo", "pcm_s16le"), + // MXF OP1a with long-GOP MPEG-2 (XDCAM style) and 48 kHz PCM: what broadcast and editing tools import. + ["mxf"] = new("mxf", "mpeg2video", "pcm_s16le"), + }; + + /// Audio formats Filee writes (also the targets of audio extraction from video). + public static IReadOnlyDictionary AudioTargets { get; } = new Dictionary + { + ["mp3"] = new("mp3", null, "libmp3lame"), + // The iPod muxer writes the M4A/M4B brands iTunes and phones expect. + ["m4a"] = new("ipod", null, "aac"), + ["m4b"] = new("ipod", null, "aac"), + // iPhone ringtones (#1291): AAC in the iPod muxer; the extension is what iTunes and iOS look for. + ["m4r"] = new("ipod", null, "aac"), + ["aac"] = new("adts", null, "aac"), + ["wav"] = new("wav", null, "pcm_s16le"), + ["flac"] = new("flac", null, "flac"), + ["ogg"] = new("ogg", null, "libvorbis"), + ["opus"] = new("opus", null, "libopus"), + ["wma"] = new("asf", null, "wmav2"), + ["aiff"] = new("aiff", null, "pcm_s16be"), + ["ac3"] = new("ac3", null, "ac3"), + // AMR-NB (OpenCORE): 8 kHz mono speech, what phones record voice memos in. + ["amr"] = new("amr", null, "libopencore_amrnb"), + ["au"] = new("au", null, "pcm_s16be"), + // CAF with big-endian linear PCM, the variant Apple's own tools write by default. + ["caf"] = new("caf", null, "pcm_s16be"), + ["weba"] = new("webm", null, "libopus"), + ["mka"] = new("matroska", null, "libopus"), + ["mp2"] = new("mp2", null, "mp2"), + // 16-bit PCM in an extended block: exact sample rate (8-bit blocks can only approximate it). + ["voc"] = new("voc", null, "pcm_s16le"), + // Lossless: WavPack and TTA (FFmpeg's own encoders); Sony Wave64 is WAV without the 4 GB limit. + ["wv"] = new("wv", null, "wavpack"), + ["tta"] = new("tta", null, "tta"), + ["w64"] = new("w64", null, "pcm_s16le"), + ["eac3"] = new("eac3", null, "eac3"), + }; + + /// Subtitle formats a video's first text subtitle track can be taken out as: (muxer, encoder). + public static IReadOnlyDictionary SubtitleTargets { get; } = new Dictionary + { + ["srt"] = ("srt", "srt"), + ["ass"] = ("ass", "ass"), + ["vtt"] = ("webvtt", "webvtt"), + }; + + /// + /// Subtitle codecs that are text (FFmpeg decodes them to text; CEA-608 captions too). Blu-ray PGS, DVD VobSub and + /// DVB subtitles are pictures. + /// + private static readonly HashSet TextSubtitleCodecs = + [ + "subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text", "microdvd", "sami", "realtext", "subviewer", "subviewer1", + "jacosub", "mpl2", "pjs", "vplayer", "stl", "eia_608", "ttml", + ]; + + /// + /// Video targets that keep the source's subtitle tracks (#789), and the encoder a text track is converted with when + /// the container can't take it as it is: MP4 and MOV hold only timed text (mov_text), WebM only WebVTT. Matroska + /// takes nearly everything as it is; the rest of its text tracks become SRT. Other containers keep none. + /// + private static readonly Dictionary SubtitleEncoders = new() + { + ["mp4"] = "mov_text", + ["m4v"] = "mov_text", + ["mov"] = "mov_text", + ["webm"] = "webvtt", + ["mkv"] = "srt", + }; + + /// Subtitle codecs Matroska takes as they are: SRT, ASS with its styles, WebVTT, and Blu-ray, DVD and DVB pictures. + private static readonly HashSet MatroskaSubtitles = ["subrip", "ass", "ssa", "webvtt", "hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"]; + + /// + /// Text subtitles left out of a converted video, as before #789: FFmpeg has no TTML decoder, and CEA-608 captions (a + /// broadcast caption feed) have no file to prove their re-encoded timing with. A video that fails over a subtitle + /// track would be worse than one without it. + /// + private static readonly HashSet UnconvertedSubtitles = ["ttml", "eia_608"]; + + /// Formats an animated GIF or a still picture (PNG) can be turned into. + public static IReadOnlyList PictureToVideoTargets { get; } = ["mp4", "webm", "mov"]; + + /// How long a video made of one still picture shows it. + public static TimeSpan StillDuration { get; } = TimeSpan.FromSeconds(5); + + /// + /// The largest picture H.264 and VP9 at level 6.2 (their highest) allow: 8192 × 4352, about 35.6 megapixels. + /// Larger photos (48 MP phone cameras) are scaled down to it, or many players refuse the video. + /// + private const long MaxVideoPixels = 8192L * 4352; + + /// The longest side VP9 level 6.2 allows (H.264 allows 16880), for panoramas within the pixel limit. + private const int MaxVideoSide = 16384; + + /// Audio targets that can carry a cover picture (album art) copied from the source. + private static readonly HashSet CoverTargets = ["mp3", "m4a", "m4b", "flac"]; + + /// + /// Frame rates MPEG-2 (and MXF, which only knows broadcast rates) can signal exactly. Other rates are + /// converted to the nearest one by dropping or repeating frames. + /// + private static readonly (int Num, int Den)[] BroadcastRates = + [(24000, 1001), (24, 1), (25, 1), (30000, 1001), (30, 1), (50, 1), (60000, 1001), (60, 1)]; + + /// Container metadata that describes the source file, not the content: not copied. + private static readonly string[] SourceOnlyTags = ["major_brand", "minor_version", "compatible_brands"]; + + /// Global arguments of every run: no banner, no keyboard input, only errors on stderr, overwrite. + internal static readonly string[] Common = ["-hide_banner", "-nostdin", "-nostats", "-v", "error", "-y"]; + + /// + /// The ffmpeg runs that convert into in format + /// . + /// + /// Scratch folder (the GIF palette goes there). + /// The source's cover for targets FFmpeg can't put a picture stream into (). + /// The encoders the FFmpeg in use has (ffmpeg -encoders); null assumes the pinned build's. + /// + /// The hardware encoder that passed its probe for of this file; null encodes with x264 / + /// x265 / SVT-AV1 (also the retry after a hardware encode failed). + /// + /// Playback speed of an animated GIF or APNG (ImageOptions.PlaybackSpeed); 1 keeps the video's. + /// + /// A video target: only this part of the video, cut exactly (the video is always encoded; + /// copies it instead, cut at key frames). + /// + /// + /// A video target: the filters that crop the frame first ("Crop video", ); the video is + /// always encoded then. + /// + /// + /// A video target keeps the source's subtitle tracks and fonts (); false converts without + /// them, the retry after FFmpeg failed with them. + /// + /// The file lacks the stream the target needs (e.g. no audio track). + public static IReadOnlyList Plan(string input, string output, string target, MediaOptions options, MediaInfo info, string workDirectory, + CoverExtras? cover = null, IReadOnlySet? encoders = null, HardwareEncoder? hardware = null, double animationSpeed = 1, + ClipRange? clip = null, string? crop = null, bool subtitles = true) + { + if (clip is { } part && VideoTargets.TryGetValue(target, out var clipTarget)) + return [new FfmpegPass(Video(input, output, target, clipTarget, options, info, encoders, hardware, part), 1)]; + if (target == "gif") + return AnimatedGif(input, output, options, info, workDirectory, animationSpeed); + if (target == "apng") + return [new FfmpegPass(AnimatedPng(input, output, options, info, animationSpeed), 1)]; + if (target == "png") + return [new FfmpegPass(PosterFrame(input, output, info), 1)]; + if (VideoTargets.TryGetValue(target, out var video)) + return [new FfmpegPass(Video(input, output, target, video, options, info, encoders, hardware, crop: crop, keepSubtitles: subtitles), 1)]; + if (AudioTargets.TryGetValue(target, out var audio)) + return [new FfmpegPass(Audio(input, output, target, audio, options, info, cover), 1)]; + if (SubtitleTargets.ContainsKey(target)) + return [new FfmpegPass(Subtitles(input, output, target, info), 1)]; + throw new NotSupportedException($"FFmpeg cannot write '{target}'."); + } + + /// + /// Maps preset image quality (1..100, 100 best) to FFmpeg's mjpeg -q:v parameter (1..31, 1 best, 2..3 visually lossless). + /// + public static int QualityToQv(int quality) + { + var clamped = Math.Clamp(quality, 1, 100); + return Math.Clamp((int)Math.Round(2.0 + (100 - clamped) * 29.0 / 99.0), 1, 31); + } + + /// + /// Every frame (or every th) of the first video stream as numbered pictures in + /// (an image2 pattern such as frame_%06d.png), for focus stacking and + /// picking a shot. + /// + /// + /// Each decoded frame is written once. The image2 muxer would otherwise pace frames to a constant rate and + /// duplicate (or drop) frames of a variable frame rate video (phones), so frames pass through with their own + /// timestamps; with an interval the select filter leaves gaps, which vfr keeps as they are. + /// + public static List FrameSequence( + string input, + string outputPattern, + string imageExt, + VideoStreamInfo? video = null, + int quality = 90, + int frameInterval = 1) + { + var select = frameInterval > 1 ? $"select='not(mod(n\\,{frameInterval}))'" : "null"; + List args = + [ + .. Common, "-i", input, "-map", "0:V:0", "-vf", (video is null ? "" : SquarePixels(video)) + select, + "-fps_mode", frameInterval > 1 ? "vfr" : "passthrough", + ]; + + if (imageExt is "jpg" or "jpeg") + args.AddRange(["-c:v", "mjpeg", "-q:v", QualityToQv(quality).ToString(CultureInfo.InvariantCulture)]); + else + args.AddRange(["-c:v", "png"]); + + args.AddRange(["-f", "image2", "-progress", "pipe:1", outputPattern]); + return args; + } + + // ───────────────────────── Video ───────────────────────── + + /// + /// The codec a hardware encoder would have to write for this file ("h264", "hevc" or "av1"), or null when the preset + /// does not ask for hardware encoding, the target is written with another codec (VP9, MPEG-2, …), or the video is + /// copied anyway. + /// + /// The video is encoded even where it could be copied (an exact clip). + internal static string? HardwareCodec(string target, MediaOptions options, MediaInfo info, bool encode = false) => + options.HardwareEncoding && info.Video is { } video && VideoTargets.ContainsKey(target) + && MediaCodecs.TakesHardwareEncoding(target, options.VideoCodec) && (encode || info.RawStream || !CanCopyVideo(target, video, options)) + ? MediaCodecs.Effective(target, options.VideoCodec) switch + { + VideoCodec.Hevc => "hevc", + VideoCodec.Av1 => "av1", + _ => "h264", + } + : null; + + private static List Video(string input, string output, string target, MediaTarget spec, MediaOptions options, MediaInfo info, + IReadOnlySet? encoders, HardwareEncoder? hardware, ClipRange? clip = null, string? crop = null, bool keepSubtitles = true) + { + var video = info.Video ?? throw new InvalidOperationException("The file has no video stream."); + // An input without sound is fine: the output simply has no audio track. Every track is kept (languages, + // commentary, issue #396), except in containers that hold one and in a clip, which keeps the first one as a + // copied or edited clip does. MediaInfo has already left out tracks FFmpeg cannot decode. + IReadOnlyList tracks = options.RemoveAudio ? [] + : clip is not null || SingleAudioTargets.Contains(target) ? [.. info.AudioTracks.Take(1)] + : info.AudioTracks; + + // A clip: FFmpeg seeks the input and decodes from the key frame before the start, so the first frame is exact. + List args = [.. Common, .. clip is { } part ? ClipInput(part) : [], "-i", input, "-map", "0:V:0"]; + for (var i = 0; i < tracks.Count; i++) + args.AddRange(["-map", $"0:a:{tracks[i].Ordinal}"]); + // Subtitle tracks stay switchable (#789), with their language tags (FFmpeg copies stream tags with each stream). A + // clip keeps none: its input is seeked, and subtitle timing after the seek isn't proven. + var subtitles = clip is null && keepSubtitles ? SubtitleTracks(target, info) : []; + foreach (var (index, _) in subtitles) + args.AddRange(["-map", $"0:{index}"]); + // An MKV's attachments are the fonts its styled (ASS) subtitles are drawn with. + var fonts = clip is null && keepSubtitles && target == "mkv" && info.HasAttachments; + if (fonts) + args.AddRange(["-map", "0:t"]); + + if (clip is null && crop is null && !info.RawStream && CanCopyVideo(target, video, options)) + { + args.AddRange(["-c:v", "copy"]); + // Apple plays HEVC in MP4/MOV only under the "hvc1" tag (FFmpeg keeps "hev1" from some sources). + if (video.Codec == "hevc" && target is "mp4" or "m4v" or "mov") + args.AddRange(["-tag:v", "hvc1"]); + } + else if (hardware is not null && HardwareCodec(target, options, info, encode: clip is not null || crop is not null) == hardware.Codec) + { + // VA-API's device is a global option, so it goes before the input. + args.InsertRange(Common.Length, HardwareVideo.InputArguments(hardware)); + args.AddRange(["-vf", crop + ScaleFilter(options.MaxHeight) + HardwareVideo.FilterSuffix(hardware, HardwareVideo.TenBit(hardware.Codec, video))]); + args.AddRange(HardwareVideo.CodecArguments(hardware, target, options.Quality, video, options.MaxHeight)); + } + else + { + var pal = IsPal(video.FrameRate); + args.AddRange(["-vf", crop + (target is "vob" or "dv" ? DvdOrDvFilter(video, pal) : ScaleFilter(options.MaxHeight))]); + // Only this path keeps alpha: a still picture and an edited clip pass through format=yuv420p first (PlanStill, ClipGraphOf). + args.AddRange(VideoCodecArguments(target, VideoEncoder(target, spec.VideoCodec!, options.VideoCodec, encoders), options.Quality, video, pal, + alpha: video.HasAlpha)); + } + + if (tracks.Count == 0) + args.Add("-an"); + for (var i = 0; i < tracks.Count; i++) + { + // Each track on its own: copied where the container takes it, else encoded for its own channel count. A + // clip's sound is encoded too: copied, it would start with the packets before the seek point (a second early). + var trackArgs = clip is null && CanCopyAudio(target, tracks[i], options) + ? ["-c:a", "copy"] + : AudioCodecArguments(target, spec.AudioCodec, options, tracks[i], inVideo: true); + args.AddRange(tracks.Count == 1 ? trackArgs : ForAudioTrack(trackArgs, i)); + } + for (var i = 0; i < subtitles.Count; i++) + args.AddRange([$"-c:s:{i}", subtitles[i].Codec]); + if (fonts) + args.AddRange(["-c:t", "copy"]); + + args.AddRange(MetadataArguments()); + args.AddRange(ContainerArguments(target)); + // Sparse or late-starting streams (subtitles-like audio) otherwise fail with "too many packets buffered". + args.AddRange(["-max_muxing_queue_size", "4096", "-f", spec.Muxer, "-progress", "pipe:1", output]); + return args; + } + + /// + /// The source's subtitle tracks a video converted to keeps, in file order: each stream's + /// index and its codec in the new file (copy where the container takes it as it is). Picture subtitles go only + /// into MKV. + /// + internal static List<(int Index, string Codec)> SubtitleTracks(string target, MediaInfo info) + { + List<(int, string)> kept = []; + if (!SubtitleEncoders.TryGetValue(target, out var encoder)) + return kept; + foreach (var track in info.Subtitles) + { + if (track.Codec is not { } codec) + continue; + if (target == "mkv" ? MatroskaSubtitles.Contains(codec) : codec == encoder) + kept.Add((track.Index, "copy")); + else if (TextSubtitleCodecs.Contains(codec) && !UnconvertedSubtitles.Contains(codec)) + kept.Add((track.Index, encoder)); + } + return kept; + } + + /// True when the passes map subtitle tracks or attachments (fonts) into the output. + internal static bool MapsSubtitles(IReadOnlyList passes) => + passes.Any(pass => pass.Arguments.Any(arg => arg == "-c:t" || arg.StartsWith("-c:s:", StringComparison.Ordinal))); + + /// + /// True when converting to leaves out picture subtitles (Blu-ray PGS, DVD VobSub, DVB) the + /// source has: MP4, MOV and WebM can't hold them, MKV can. Shown as a notice, since the user may not know. + /// + internal static bool LeavesOutPictureSubtitles(string target, MediaInfo info) => + target != "mkv" && SubtitleEncoders.ContainsKey(target) + && info.Subtitles.Any(s => s.Codec is { } codec && !TextSubtitleCodecs.Contains(codec)); + + /// Containers that hold a single audio track: the first one is kept. + private static readonly HashSet SingleAudioTargets = ["flv", "dv"]; + + /// Audio options addressed to one output track (-b:a → -b:a:2, -ac → -ac:a:2). + internal static IEnumerable ForAudioTrack(IReadOnlyList args, int track) => + args.Select((arg, i) => arg switch + { + // Values are never options: only the names are addressed. + _ when i > 0 && args[i - 1].StartsWith('-') && !arg.StartsWith('-') => arg, + "-c:a" or "-b:a" or "-q:a" => $"{arg}:{track}", + "-ac" or "-ar" => $"{arg}:a:{track}", + _ => arg, + }); + + /// + /// A part of a video copied as it is (-c copy): done in a moment and nothing is lost, but a copy can only + /// start at a key frame, so the clip starts at the last one before (up to a few + /// seconds early). The first video stream and the first audio track are kept (none with + /// ), as in an exact or edited clip; the + /// other options only apply to an exact clip, which is encoded. + /// + /// The file has no video stream. + /// FFmpeg cannot write . + internal static List ClipCopy(string input, string output, string target, ClipRange clip, MediaOptions options, MediaInfo info) + { + var video = info.Video ?? throw new InvalidOperationException("The file has no video stream."); + if (!VideoTargets.TryGetValue(target, out var spec)) + throw new NotSupportedException($"FFmpeg cannot write '{target}'."); + var audio = !options.RemoveAudio && info.Audio is not null; + + List args = [.. Common, .. ClipInput(clip), "-i", input, "-map", "0:V:0"]; + if (audio) + args.AddRange(["-map", $"0:a:{info.Audio!.Ordinal}"]); + args.AddRange(["-c", "copy"]); + // Apple plays HEVC in MP4/MOV only under the "hvc1" tag (FFmpeg keeps "hev1" from some sources). + if (video.Codec == "hevc" && target is "mp4" or "m4v" or "mov") + args.AddRange(["-tag:v", "hvc1"]); + if (!audio) + args.Add("-an"); + // The copied packets keep their timestamps from the key frame on: shifted so the clip starts at 0. + args.AddRange(["-avoid_negative_ts", "make_zero"]); + args.AddRange(MetadataArguments()); + args.AddRange(ContainerArguments(target)); + args.AddRange(["-max_muxing_queue_size", "4096", "-f", spec.Muxer, "-progress", "pipe:1", output]); + return args; + } + + /// + /// Input options that read only of the file: -ss and -to before -i, + /// both positions in the source. + /// + internal static string[] ClipInput(ClipRange clip) => ["-ss", ClipRange.Ffmpeg(clip.Start), "-to", ClipRange.Ffmpeg(clip.End)]; + + /// + /// One frame at as a JPEG at most pixels high (square + /// pixels, turned upright): the timeline and the large picture of the clip window. + /// + internal static List FrameAt(string input, double seconds, string output, int maxHeight, VideoStreamInfo? video) => + [ + .. Common, "-ss", ClipRange.Ffmpeg(seconds), "-i", input, "-map", "0:V:0", + "-vf", (video is null ? "" : SquarePixels(video)) + $"scale=w=-2:h='trunc(min(ih,{Math.Max(16, maxHeight)})/2)*2'", + "-frames:v", "1", "-update", "1", "-c:v", "mjpeg", "-q:v", "3", "-f", "image2", output, + ]; + + /// + /// Codecs a container takes as they are. A video whose streams are listed for the target is only rewrapped + /// (stream copy): no quality loss, HDR and 10-bit kept, seconds instead of minutes. iPhone videos (HEVC or H.264 + /// with AAC in MOV) become MP4 this way. Only codecs the target's usual players handle are listed: MP4/MOV keep to + /// what Apple devices and browsers play, WebM to what the WebM specification allows. + /// + internal static readonly Dictionary Video, HashSet Audio)> Remuxable = new() + { + ["mp4"] = (["h264", "hevc"], ["aac", "alac", "ac3", "eac3", "mp3"]), + ["m4v"] = (["h264", "hevc"], ["aac", "alac", "ac3", "eac3", "mp3"]), + ["mov"] = (["h264", "hevc", "prores"], ["aac", "alac", "ac3", "eac3", "mp3", "pcm_s16le", "pcm_s24le"]), + ["mkv"] = (["h264", "hevc", "vp9", "av1"], ["aac", "alac", "ac3", "eac3", "mp3", "opus", "vorbis", "flac"]), + ["webm"] = (["vp8", "vp9", "av1"], ["opus", "vorbis"]), + ["ts"] = (["h264", "hevc"], ["aac", "ac3", "eac3", "mp3"]), + ["m2ts"] = (["h264", "hevc"], ["aac", "ac3", "eac3"]), + }; + + /// Pixel formats players decode in hardware: 4:2:0 at 8 or 10 bits (phones record HDR video as 10-bit). + private static readonly HashSet PlayablePixelFormats = ["yuv420p", "yuvj420p", "yuv420p10le"]; + + /// + /// True when the video stream can be copied: the target container takes its codec in a pixel format players + /// decode, it needs no scaling down, and the preset does not ask for a small file (Small always re-encodes; High + /// and Balanced only describe how a video that has to be encoded anyway is encoded). A preset that asks for HEVC + /// or AV1 copies only a video in that codec (an AV1 video goes into MP4 then too). + /// + internal static bool CanCopyVideo(string target, VideoStreamInfo video, MediaOptions options) + { + // A size limit encodes the video, so its bitrate can be capped (ForSizeLimit). + if (options.Quality == MediaQuality.Small || options.MaxSizeMb > 0 || options.MaxSizePercent > 0 || options.MaxHeight > 0 && video.DisplayHeight > Math.Max(16, options.MaxHeight) + || video.Codec is not { } codec || !Remuxable.TryGetValue(target, out var codecs)) + return false; + var takes = MediaCodecs.Effective(target, options.VideoCodec) switch + { + VideoCodec.Hevc => codec == "hevc", + VideoCodec.Av1 => codec == "av1", + _ => codecs.Video.Contains(codec), + }; + return takes && (codec == "prores" || video.PixelFormat is { } format && PlayablePixelFormats.Contains(format)); + } + + /// + /// The encoder for a video that has to be encoded: the preset's codec where the target takes it (x265 for HEVC; + /// SVT-AV1 for AV1, libaom where an FFmpeg lacks SVT-AV1), else the target's usual one. + /// + /// The FFmpeg in use has no encoder for the chosen codec. + internal static string VideoEncoder(string target, string defaultEncoder, VideoCodec codec, IReadOnlySet? encoders) + { + bool Has(string encoder) => encoders is null || encoders.Contains(encoder); + switch (MediaCodecs.Effective(target, codec)) + { + case VideoCodec.Hevc when Has("libx265"): + return "libx265"; + case VideoCodec.Hevc: + throw new InvalidOperationException("This FFmpeg has no HEVC encoder (libx265). Filee's FFmpeg download has one."); + case VideoCodec.Av1 when Has("libsvtav1"): + return "libsvtav1"; + case VideoCodec.Av1 when Has("libaom-av1"): + return "libaom-av1"; + case VideoCodec.Av1: + throw new InvalidOperationException("This FFmpeg has no AV1 encoder (libsvtav1 or libaom-av1). Filee's FFmpeg download has one."); + default: + return defaultEncoder; + } + } + + /// + /// True when the audio stream can be copied: the container takes it, no bitrate is set and the channels stay + /// as they are. + /// + internal static bool CanCopyAudio(string target, AudioStreamInfo audio, MediaOptions options) => + options.Quality != MediaQuality.Small && options.AudioBitrateKbps <= 0 + && (options.AudioChannels is not (1 or 2) || options.AudioChannels == audio.Channels) + && Remuxable.TryGetValue(target, out var codecs) && audio.Codec is { } codec && codecs.Audio.Contains(codec); + + /// + /// Scales down to (never up) keeping the aspect ratio, and makes both sides even, + /// which 4:2:0 encoders require (an odd-sized GIF or phone video would otherwise fail). + /// + internal static string ScaleFilter(int maxHeight) => maxHeight > 0 + ? $"scale=w=-2:h='trunc(min(ih,{Math.Max(16, maxHeight)})/2)*2'" + : "scale=w='trunc(iw/2)*2':h='trunc(ih/2)*2'"; + + /// + /// DVD and DV frames are always 720×480 (NTSC) or 720×576 (PAL), shown at 4:3 or 16:9 through the pixel aspect. + /// The picture is fitted into whichever of the two is closer to its own shape, with black bars for the rest. + /// + internal static string DvdOrDvFilter(VideoStreamInfo video, bool pal) + { + var height = pal ? 576 : 480; + var aspect = video.DisplayAspect; + var wide = aspect >= 1.55; // between 4:3 (1.33) and 16:9 (1.78) + var frameAspect = wide ? 16 / 9.0 : 4 / 3.0; + var (w, h) = aspect > frameAspect + ? (720, Even(height * frameAspect / aspect)) + : (Even(720 * aspect / frameAspect), height); + return $"scale={w}:{h},pad=720:{height}:(ow-iw)/2:(oh-ih)/2:black,setdar={(wide ? "16/9" : "4/3")}"; + } + + /// PAL (25 fps) for 25/50 fps sources, NTSC (29.97 fps) for everything else. + internal static bool IsPal(double fps) => Math.Abs(fps - 25) < 0.5 || Math.Abs(fps - 50) < 1 || Math.Abs(fps - 12.5) < 0.3; + + /// + /// The size limit (#553): the video bitrate in kbit/s that fits over + /// , less the sound and a margin for the container; 0 when not even 100 kbit/s fits. + /// + internal static int FitBitrateKbps(long limitBytes, double seconds, int audioKbps) + { + var video = (int)(limitBytes * 8 * 0.95 / 1000 / seconds) - audioKbps; + return video >= 100 ? video : 0; + } + + /// + /// The options a video with a size limit is encoded with, and the video bitrate that fits (0 when the length is + /// unknown or nothing fits): the video is never copied as it is (), the sound is at most + /// 96 kbit/s a track, and no graphics chip encodes, since software rate control keeps to the cap. + /// + internal static (MediaOptions Options, int Kbps) ForSizeLimit(MediaOptions media, MediaInfo info, long limitBytes) + { + var fitting = new Preset { Media = media }.Clone().Media; + fitting.HardwareEncoding = false; + fitting.AudioBitrateKbps = fitting.AudioBitrateKbps is > 0 and < 96 ? fitting.AudioBitrateKbps : 96; + var audio = fitting.RemoveAudio ? 0 : fitting.AudioBitrateKbps * info.AudioTracks.Count; + var kbps = info.Duration is { TotalSeconds: > 0 } length ? FitBitrateKbps(limitBytes, length.TotalSeconds, audio) : 0; + return (fitting, kbps); + } + + /// + /// The passes with the video's peaks capped at next to its constant quality: one encode, + /// never larger than without the cap and usually within the size limit. VP9 and libaom's "-b:v 0" becomes the cap. + /// + internal static IReadOnlyList WithMaxRate(IReadOnlyList passes, int kbps) => + [.. passes.Select(pass => pass with { Arguments = WithMaxRate(pass.Arguments, kbps) })]; + + private static List WithMaxRate(IReadOnlyList args, int kbps) + { + var result = new List(); + for (var i = 0; i < args.Count; i++) + { + if (args[i] == "-b:v" && i + 1 < args.Count && args[i + 1] == "0") + { + result.AddRange(["-b:v", $"{kbps}k"]); + i++; + continue; + } + result.Add(args[i]); + if (args[i] is "-crf" or "-q:v" && i + 1 < args.Count) + { + result.AddRange([args[++i], "-maxrate", $"{kbps}k", "-bufsize", $"{kbps * 2}k"]); + } + } + return result; + } + + /// The passes with the video at instead of a constant quality (CRF or quantizer). + internal static IReadOnlyList AtBitrate(IReadOnlyList passes, int kbps) => + [.. passes.Select(pass => pass with { Arguments = AtBitrate(pass.Arguments, kbps) })]; + + private static List AtBitrate(IReadOnlyList args, int kbps) + { + var result = new List(); + for (var i = 0; i < args.Count; i++) + { + if (args[i] is "-crf" or "-q:v" && i + 1 < args.Count) + { + result.AddRange(["-b:v", $"{kbps}k", "-maxrate", $"{kbps * 3 / 2}k", "-bufsize", $"{kbps * 2}k"]); + i++; + } + else if (args[i] == "-b:v" && i + 1 < args.Count && args[i + 1] == "0") + i++; // VP9 / libaom's "constant quality" switch + else + result.Add(args[i]); + } + return result; + } + + /// Keep the source's transparency where the codec can (VP9 only, #1198). + internal static IEnumerable VideoCodecArguments(string target, string codec, MediaQuality quality, VideoStreamInfo video, bool pal, + bool alpha = false) + { + // Constant quantizer for the MPEG-style codecs: 2 is the best useful value, 4 good, 7 visibly compressed. + var quantizer = Text(Choose(quality, 2, 4, 7)); + switch (codec) + { + case "libx264": + // CRF 18 is visually lossless, 23 is x264's default, 28 is clearly smaller. Preset medium is x264's + // default speed/size balance; yuv420p (8-bit 4:2:0) is the only format every H.264 decoder plays. + return ["-c:v", "libx264", "-preset", "medium", "-crf", Text(Choose(quality, 18, 23, 28)), "-pix_fmt", "yuv420p"]; + case "libx265": + // x265's CRF 28 looks like x264's 23 at about half the size; 22 / 26 / 30 keep High clean and Small + // clearly smaller than H.264 Small. Apple plays HEVC in MP4 / MOV only under the "hvc1" tag. + return ["-c:v", "libx265", "-preset", "medium", "-crf", Text(Choose(quality, 22, 26, 30)), + .. HighBitDepthArguments(video), "-x265-params", "log-level=error", + .. target is "mp4" or "m4v" or "mov" ? ["-tag:v", "hvc1"] : Array.Empty()]; + case "libsvtav1": + // SVT-AV1 preset 8: fast enough on a desktop, a little larger than the slow presets. + return ["-c:v", "libsvtav1", "-preset", "8", "-crf", Text(Choose(quality, 26, 32, 40)), .. HighBitDepthArguments(video)]; + case "libaom-av1": + // Constant quality (-b:v 0 + CRF); cpu-used 6 with row multithreading and tiles keeps libaom bearable. + return ["-c:v", "libaom-av1", "-b:v", "0", "-crf", Text(Choose(quality, 26, 32, 40)), "-cpu-used", "6", "-row-mt", "1", + "-tiles", "2x2", .. HighBitDepthArguments(video)]; + case "libvpx-vp9": + // Constant quality mode (-b:v 0 + CRF). Row multithreading and speed 4 keep VP9 usable on a desktop: + // the slower speeds gain little size for several times the encoding time. A transparent source keeps + // its alpha (yuva420p); older libvpx builds refuse alpha with alt-ref frames on. + return ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", Text(Choose(quality, 24, 32, 38)), "-row-mt", "1", + "-deadline", "good", "-cpu-used", "4", "-pix_fmt", alpha ? "yuva420p" : "yuv420p", + .. alpha ? ["-auto-alt-ref", "0"] : Array.Empty()]; + case "mpeg4": + // GOP 250 because the encoder's default of 12 bloats files. The frame rate is fixed because MPEG-4 + // part 2 cannot store the fine time bases of phone videos. + return ["-c:v", "mpeg4", "-tag:v", "XVID", "-q:v", quantizer, "-g", "250", "-bf", "2", + "-pix_fmt", "yuv420p", .. FrameRate(NearestRate(video.FrameRate))]; + case "wmv2": + return ["-c:v", "wmv2", "-q:v", quantizer, "-g", "250", "-pix_fmt", "yuv420p", + .. FrameRate(NearestRate(video.FrameRate))]; + case "libtheora": + // Theora quality 0-10. + return ["-c:v", "libtheora", "-q:v", Text(Choose(quality, 8, 6, 4)), "-pix_fmt", "yuv420p", + .. FrameRate(NearestRate(video.FrameRate))]; + case "mpeg2video" when target == "vob": + // DVD-Video limits: peak 9.8 Mbit/s for video + audio, 1.8 Mbit VBV buffer, GOP of 15 (PAL) / 18 (NTSC). + return ["-c:v", "mpeg2video", "-b:v", Text(Choose(quality, 8000, 6000, 4000)) + "k", "-maxrate", "9000k", + "-minrate", "0", "-bufsize", "1835k", "-g", pal ? "15" : "18", "-bf", "2", "-pix_fmt", "yuv420p", + "-r", pal ? "25" : "30000/1001"]; + case "mpeg2video": + // mpg and mxf: constant quantizer, broadcast GOP of 15 and a frame rate MPEG-2 can signal. + return ["-c:v", "mpeg2video", "-q:v", quantizer, "-g", "15", "-bf", "2", "-pix_fmt", "yuv420p", + "-r", BroadcastRate(video.FrameRate)]; + case "dvvideo": + // DV is fixed at 25 Mbit/s: NTSC samples colour 4:1:1, PAL 4:2:0. + return ["-c:v", "dvvideo", "-pix_fmt", pal ? "yuv420p" : "yuv411p", "-r", pal ? "25" : "30000/1001"]; + default: + throw new NotSupportedException(codec); + } + } + + /// + /// HEVC and AV1 keep 10 bits for 10-bit and HDR sources (Main 10; 8 bits would band and break HDR) and 8-bit 4:2:0 + /// for everything else, which every player decodes. HDR colour tags (BT.2020 primaries, PQ or HLG transfer) are + /// written as the source has them. + /// + internal static string[] HighBitDepthArguments(VideoStreamInfo video) + { + var tenBit = video.IsHighBitDepth || video.IsHdr; + return ["-pix_fmt", tenBit ? "yuv420p10le" : "yuv420p", .. tenBit ? ColorArguments(video) : []]; + } + + /// The source's colour tags (primaries, transfer, matrix), for 10-bit and HDR encodes. + internal static List ColorArguments(VideoStreamInfo video) + { + List args = []; + if (video.Color is { } color) + { + if (color.Primaries is { } primaries) + args.AddRange(["-color_primaries", primaries]); + if (color.Transfer is { } transfer) + args.AddRange(["-color_trc", transfer]); + if (color.Matrix is { } matrix) + args.AddRange(["-colorspace", matrix]); + } + return args; + } + + /// -r for a rate, nothing when the source rate is unknown (FFmpeg then keeps its own guess). + private static string[] FrameRate(string? rate) => rate is null ? [] : ["-r", rate]; + + /// + /// The source rate as a simple fraction: exact NTSC/film/PAL rates are kept, anything else (variable frame rate + /// phone videos report averages like 29.87) is rounded to whole frames per second. + /// + internal static string? NearestRate(double fps) + { + if (fps <= 0) + return null; + foreach (var (num, den) in BroadcastRates) + { + if (Math.Abs(fps - (double)num / den) < 0.01) + return den == 1 ? num.ToString(CultureInfo.InvariantCulture) : $"{num}/{den}"; + } + return Math.Clamp((int)Math.Round(fps), 1, 120).ToString(CultureInfo.InvariantCulture); + } + + /// The broadcast rate closest to (PAL's 25 when unknown). + internal static string BroadcastRate(double fps) + { + if (fps <= 0) + return "25"; + var (num, den) = BroadcastRates.MinBy(r => Math.Abs(fps - (double)r.Num / r.Den)); + return den == 1 ? num.ToString(CultureInfo.InvariantCulture) : $"{num}/{den}"; + } + + internal static IEnumerable ContainerArguments(string target) => target switch + { + // Moves the index to the front so playback (and web streaming) can start before the file is loaded. + "mp4" or "m4v" or "mov" or "3gp" or "3g2" or "m4a" or "m4b" or "m4r" => ["-movflags", "+faststart"], + // Blu-ray / AVCHD transport stream: 192-byte packets with timestamps. + "m2ts" => ["-mpegts_m2ts_mode", "1"], + // DVD program stream: 10.08 Mbit/s mux rate and 2 KB sectors. + "vob" => ["-muxrate", "10080000", "-packetsize", "2048"], + // ID3v2.3 is what Windows Explorer and most car stereos read (FFmpeg writes 2.4 by default). + "mp3" => ["-id3v2_version", "3"], + // WAV is limited to 4 GB; long recordings switch to RF64 automatically. + "wav" => ["-rf64", "auto"], + _ => [], + }; + + internal static IEnumerable MetadataArguments(int from = 0) + { + // Keep title, artist, dates, … (chapters are kept by default); an empty value removes a tag. + yield return "-map_metadata"; + yield return from.ToString(CultureInfo.InvariantCulture); + foreach (var tag in SourceOnlyTags) + { + yield return "-metadata"; + yield return tag + "="; + } + } + + // ───────────────────────── Audio ───────────────────────── + + private static List Audio(string input, string output, string target, MediaTarget spec, MediaOptions options, MediaInfo info, + CoverExtras? extras) + { + var audio = info.Audio ?? throw new InvalidOperationException("The file has no audio track."); + List args = [.. Common, "-i", input]; + // Ogg: the tags, and the picture as a comment, come from a metadata file (input 1) instead of the source. + if (extras?.MetadataFile is { } metadata) + args.AddRange(["-i", metadata]); + args.AddRange(["-map", $"0:a:{audio.Ordinal}"]); + if (info.CoverStreamIndex is { } cover && CoverTargets.Contains(target)) + args.AddRange(["-map", $"0:{cover}", "-c:v", "copy", "-disposition:v:0", "attached_pic"]); + if (extras is { AttachmentFile: { } picture, AttachmentMime: { } mime }) + args.AddRange(["-attach", picture, "-metadata:s:t", "mimetype=" + mime, "-metadata:s:t", "filename=" + Path.GetFileName(picture)]); + if (CanCopyIntoAudio(target, audio, options)) + { + // "Extract audio": the phone video's AAC goes into the M4A as it is, without a second encode. + args.AddRange(["-c:a", "copy"]); + } + else + { + var codec = AudioEncoder(target, spec.AudioCodec, options); + args.AddRange(AudioCodecArguments(target, codec, options, audio, inVideo: false)); + args.AddRange(ChannelAndRateArguments(target, codec, options, audio.SampleRate)); + } + args.AddRange(MetadataArguments(extras?.MetadataFile is null ? 0 : 1)); + args.AddRange(ContainerArguments(target)); + args.AddRange(["-f", spec.Muxer, "-progress", "pipe:1", output]); + return args; + } + + /// Audio codecs an audio target takes as they are (). + private static readonly Dictionary> CopyableAudio = new() + { + ["m4a"] = ["aac", "alac"], + ["m4b"] = ["aac", "alac"], + ["m4r"] = ["aac"], + ["aac"] = ["aac"], + ["mp3"] = ["mp3"], + ["flac"] = ["flac"], + ["opus"] = ["opus"], + ["ogg"] = ["vorbis"], + }; + + /// + /// True when the preset copies sound it can () and this sound can be + /// copied into : the target takes its codec (only ALAC when the preset asks for Apple + /// Lossless), and no bitrate, channel count or Small quality asks for an encode. + /// + internal static bool CanCopyIntoAudio(string target, AudioStreamInfo audio, MediaOptions options) + { + if (!options.CopyAudioWhenPossible || options.Quality == MediaQuality.Small || options.AudioBitrateKbps > 0 + || options.AudioChannels is 1 or 2 && options.AudioChannels != audio.Channels + || audio.Codec is not { } codec || !CopyableAudio.TryGetValue(target, out var codecs) || !codecs.Contains(codec)) + return false; + return MediaCodecs.Effective(target, options.AudioCodec) != Core.Presets.AudioCodec.Alac || codec == "alac"; + } + + // ───────────────────────── Without metadata ───────────────────────── + + /// + /// "Remove metadata": the file's video, audio and subtitle streams copied as they are into the same format, with no + /// tags (title, artist, dates, location, camera, encoder), per-stream tags or chapters. Data tracks (an iPhone's + /// timed location and camera metadata) are left out; album art stays. + /// + public static List WithoutMetadata(string input, string output, string target, MediaInfo info) + { + var spec = VideoTargets.TryGetValue(target, out var video) ? video + : AudioTargets.TryGetValue(target, out var audio) ? audio + : throw new NotSupportedException($"FFmpeg cannot write '{target}'."); + List args = [.. Common, "-i", input]; + // A video's pictures without cover art (0:V); an audio file's cover art (0:v) stays with it. + args.AddRange(["-map", info.Video is not null ? "0:V?" : "0:v?", "-map", "0:a?", "-map", "0:s?", "-c", "copy"]); + if (info.Video?.Codec == "hevc" && target is "mp4" or "m4v" or "mov") + args.AddRange(["-tag:v", "hvc1"]); + args.AddRange(["-map_metadata", "-1", "-map_metadata:s", "-1", "-map_chapters", "-1", "-fflags", "+bitexact"]); + // Attachments are an MKV's subtitle fonts (without them styled subtitles fall back to another font); each needs + // its own file name and type tags. Only when there are any: FFmpeg refuses a specifier that matches nothing. + if (info.HasAttachments) + args.AddRange(["-map", "0:t", "-map_metadata:s:t", "0:s:t"]); + args.AddRange(ContainerArguments(target)); + args.AddRange(["-max_muxing_queue_size", "4096", "-f", spec.Muxer, "-progress", "pipe:1", output]); + return args; + } + + /// + /// The audio encoder of a target: the PCM format the preset picks for WAV and W64, ALAC for M4A / M4B when the + /// preset asks for Apple Lossless, else the target's usual one. + /// + internal static string AudioEncoder(string target, string defaultEncoder, MediaOptions options) => + target is "wav" or "w64" ? WavCodec(options.WavFormat) + : MediaCodecs.Effective(target, options.AudioCodec) == Core.Presets.AudioCodec.Alac ? "alac" + : defaultEncoder; + + /// The PCM codec of a WAV sample format (little-endian, 8-bit is unsigned as WAV defines it). + internal static string WavCodec(WavSampleFormat format) => format switch + { + WavSampleFormat.Pcm8 => "pcm_u8", + WavSampleFormat.Pcm24 => "pcm_s24le", + WavSampleFormat.Pcm32 => "pcm_s32le", + WavSampleFormat.Float32 => "pcm_f32le", + _ => "pcm_s16le", + }; + + /// + /// Mono or stereo for any audio target (after the codec's own limits, so the user's choice wins), and the sample + /// rate for WAV and W64, whose PCM takes any rate. Codecs with a fixed layout (AMR is mono, 8 kHz) are left alone. + /// + /// + /// The source's rate. DSD (DSF, DFF) decodes at 352.8 kHz or more: lossless and PCM targets get 88.2 kHz (the + /// usual rate for DSD as PCM), Vorbis and WMA 44.1 kHz; the other lossy encoders pick a rate they support. + /// + internal static IEnumerable ChannelAndRateArguments(string target, string codec, MediaOptions options, int sourceSampleRate = 0) + { + if (options.AudioChannels is 1 or 2 && codec != "libopencore_amrnb") + { + yield return "-ac"; + yield return options.AudioChannels.ToString(CultureInfo.InvariantCulture); + } + if (target is "wav" or "w64" && options.AudioSampleRate is >= 8000 and <= 192000) + { + yield return "-ar"; + yield return options.AudioSampleRate.ToString(CultureInfo.InvariantCulture); + } + else if (sourceSampleRate > 192000 && (LosslessEncoders.Contains(codec) || codec.StartsWith("pcm_", StringComparison.Ordinal))) + { + yield return "-ar"; + yield return "88200"; + } + else if (sourceSampleRate > 192000 && codec is "libvorbis" or "wmav2") + { + yield return "-ar"; + yield return "44100"; + } + } + + private static readonly HashSet LosslessEncoders = ["flac", "alac", "wavpack", "tta"]; + + /// + /// Encoder options for one audio codec. wins when set (clamped to + /// what the codec accepts); otherwise the quality picks a bitrate or VBR level. Lossless and PCM codecs ignore + /// both. Channel counts the codec cannot store are mixed down. + /// + private static List AudioCodecArguments(string target, string codec, MediaOptions options, AudioStreamInfo audio, bool inVideo) + { + var quality = options.Quality; + var requested = options.AudioBitrateKbps; + var channels = audio.Channels > 0 ? audio.Channels : 2; + List args = ["-c:a", codec]; + + switch (codec) + { + case "aac": + // Per stereo pair: 256 kbit/s is transparent, 192 excellent, 128 fine for speech and small files. + args.AddRange(Bitrate(requested > 0 ? requested : Scaled(Choose(quality, 256, 192, 128), channels), 16, 256 * channels)); + break; + case "libopus": + // Opus is transparent at about 128 kbit/s for stereo; 64 is still good for music. + args.AddRange(Bitrate(requested > 0 ? requested : Scaled(Choose(quality, 160, 128, 64), channels), 6, 256 * channels)); + break; + case "libmp3lame" when requested > 0 || inVideo: + // Constant bitrate (variable-bitrate MP3 confuses AVI players' audio sync) from the MP3 bitrate table. + args.AddRange(Bitrate(Snap(requested > 0 ? requested : Choose(quality, 256, 192, 128), Mp3Bitrates), 32, 320)); + break; + case "libmp3lame": + // LAME's VBR presets: V0 ≈ 245, V2 ≈ 190, V5 ≈ 130 kbit/s. + args.AddRange(["-q:a", Text(Choose(quality, 0, 2, 5))]); + break; + case "libvorbis" when requested > 0: + args.AddRange(Bitrate(requested, 32, 500)); + break; + case "libvorbis": + // Vorbis quality 6 ≈ 192, 4 ≈ 128, 2 ≈ 96 kbit/s. + args.AddRange(["-q:a", Text(Choose(quality, 6, 4, 2))]); + break; + case "wmav2": + // WMA 2 stores at most two channels. + args.AddRange(Bitrate(requested > 0 ? requested : Choose(quality, 192, 128, 96), 32, 320)); + if (channels > 2) + args.AddRange(["-ac", "2"]); + break; + case "mp2": + // Layer II allows only the bitrates of its table, and those only at 32 kHz or more. + args.AddRange(Bitrate(Snap(requested > 0 ? requested : Choose(quality, 320, 224, 160), Mp2Bitrates), 32, 384)); + if (audio.SampleRate is > 0 and < 32000) + args.AddRange(["-ar", "32000"]); + break; + case "ac3" when target == "vob": + // DVD audio is 48 kHz; 448 kbit/s is the DVD maximum for 5.1, 192 the usual stereo rate. + args.AddRange(Bitrate(Snap(requested > 0 ? requested : channels > 2 ? 448 : 192, Ac3Bitrates), 64, 448)); + args.AddRange(["-ar", "48000"]); + break; + case "ac3": + // Without a bitrate the encoder picks the standard rate for the channel layout (192k stereo, 448k 5.1). + if (requested > 0) + args.AddRange(Bitrate(Snap(requested, Ac3Bitrates), 32, 640)); + break; + case "eac3": + // E-AC-3 takes any bitrate; per stereo pair 256 / 192 / 128 kbit/s like AAC. + args.AddRange(Bitrate(requested > 0 ? requested : Scaled(Choose(quality, 256, 192, 128), channels), 32, 6144)); + break; + case "libopencore_amrnb": + // AMR-NB is defined for 8 kHz mono only; 12.2 kbit/s is its best mode. + args.AddRange(["-ar", "8000", "-ac", "1", "-b:a", "12.2k"]); + break; + case "pcm_s16le" when target is "dv": + args.AddRange(["-ar", "48000", "-ac", "2"]); + break; + case "pcm_s16le" when target is "mxf": + // MXF (SMPTE 377) carries 48 kHz audio only. + args.AddRange(["-ar", "48000"]); + break; + case "pcm_s16le" when target is "voc" && channels > 2: + args.AddRange(["-ac", "2"]); + break; + } + return args; + } + + /// Bitrate for a stereo pair scaled to the channel count (mono gets half, 5.1 three times as much). + private static int Scaled(int stereoKbps, int channels) => stereoKbps * Math.Clamp(channels, 1, 8) / 2; + + private static string[] Bitrate(int kbps, int min, int max) => ["-b:a", Text(Math.Clamp(kbps, min, Math.Max(min, max))) + "k"]; + + /// The largest allowed bitrate not above (the smallest one if all are above). + internal static int Snap(int kbps, int[] allowed) => allowed.Where(a => a <= kbps).DefaultIfEmpty(allowed[0]).Max(); + + private static readonly int[] Mp3Bitrates = [32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]; + private static readonly int[] Mp2Bitrates = [32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]; + private static readonly int[] Ac3Bitrates = [32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 448, 512, 576, 640]; + + // ───────────────────────── Subtitles ───────────────────────── + + /// + /// The first text subtitle track of a video as SRT, ASS or WebVTT (UTF-8). A track already in the target's format is + /// copied as it is (an ASS track keeps its styles); others are converted by FFmpeg. + /// + /// The video has no subtitle track, or only picture subtitles. + private static List Subtitles(string input, string output, string target, MediaInfo info) + { + var track = info.Subtitles.FirstOrDefault(s => s.Codec is { } codec && TextSubtitleCodecs.Contains(codec)) + ?? throw new InvalidOperationException(info.Subtitles.Count == 0 + ? "The video has no subtitle track." + : "The video's subtitles are pictures (Blu-ray PGS, DVD or DVB subtitles), not text. Picture subtitles can't be turned into text without OCR."); + var (muxer, encoder) = SubtitleTargets[target]; + var same = (target, track.Codec) is ("srt", "subrip" or "srt") or ("ass", "ass") or ("vtt", "webvtt"); + return [.. Common, "-i", input, "-map", $"0:{track.Index}", "-c:s", same ? "copy" : encoder, "-f", muxer, "-progress", "pipe:1", output]; + } + + // ───────────────────────── Animated GIF ───────────────────────── + + /// + /// Animated GIF in two passes: the first finds the 256 colours that suit the whole clip best (palettegen), the + /// second maps every frame to them (paletteuse). A single pass with a fixed palette looks banded; generating + /// the palette in the same run would keep every frame in memory until the end. Frame rate and width are limited + /// by the quality setting because GIF stores every frame uncompressed-ish and grows fast. + /// + private static List AnimatedGif(string input, string output, MediaOptions options, MediaInfo info, string workDirectory, double speed) + { + var video = info.Video ?? throw new InvalidOperationException("The file has no video stream."); + var quality = options.Quality; + var chain = GifFrames(video, options.MaxHeight, fps: Choose(quality, 15, 12, 10), maxWidth: Choose(quality, 640, 480, 320), speed); + var palette = Path.Combine(workDirectory, $"palette-{Guid.NewGuid():N}.png"); + var colors = Choose(quality, 256, 256, 128); + // Error diffusion looks best; ordered (Bayer) dithering compresses much better because it does not flicker. + var dither = Choose(quality, "sierra2_4a", "bayer:bayer_scale=4", "bayer:bayer_scale=5"); + + // Pass 1 reports no output time until the palette is written at the very end, so a second, discarded copy + // of the frames goes to a null output whose per-frame stats (frame_time=) serve as progress. + List first = + [ + .. Common, "-i", input, + "-filter_complex", $"[0:V:0]{chain},split[a][b];[a]palettegen=max_colors={colors}[palette]", + "-map", "[palette]", "-frames:v", "1", "-update", "1", "-f", "image2", palette, + "-map", "[b]", "-stats_enc_pre", "pipe:1", "-stats_enc_pre_fmt", FfmpegRunner.FrameTimeKey + "={t}", "-f", "null", "-", + ]; + List second = + [ + .. Common, "-i", input, "-i", palette, + "-filter_complex", $"[0:V:0]{chain}[frames];[frames][1:v]paletteuse=dither={dither}:diff_mode=rectangle[gif]", + "-map", "[gif]", "-loop", "0", "-f", "gif", "-progress", "pipe:1", output, + ]; + return [new FfmpegPass(first, 0.4), new FfmpegPass(second, 0.6)]; + } + + /// + /// Animated PNG with FFmpeg's APNG encoder and muxer: full colour (24-bit RGB, no palette pass), looping, with the + /// frame rate and width limits of GIF, since every frame is stored losslessly. + /// + private static List AnimatedPng(string input, string output, MediaOptions options, MediaInfo info, double speed) + { + var video = info.Video ?? throw new InvalidOperationException("The file has no video stream."); + var quality = options.Quality; + var chain = GifFrames(video, options.MaxHeight, fps: Choose(quality, 15, 12, 10), maxWidth: Choose(quality, 640, 480, 320), speed); + return + [ + .. Common, "-i", input, "-map", "0:V:0", "-vf", chain, "-an", + "-c:v", "apng", "-pix_fmt", "rgb24", "-pred", "mixed", "-plays", "0", "-f", "apng", "-progress", "pipe:1", output, + ]; + } + + /// + /// Playback speed, frame rate, square pixels and size of the GIF frames (never upscaled). The speed changes the + /// timestamps before the frame rate is taken, so a faster clip keeps the frame rate by leaving frames out, and a + /// slower one repeats frames where the video has too few. + /// + private static string GifFrames(VideoStreamInfo video, int maxHeight, int fps, int maxWidth, double speed) + { + var height = maxHeight > 0 ? $"'min(ih,{Math.Max(16, maxHeight)})'" : "ih"; + var timing = double.IsFinite(speed) && speed is > 0 and not 1 + ? $"setpts=PTS/{speed.ToString("0.###", CultureInfo.InvariantCulture)}," + : ""; + return $"{timing}fps={fps},{SquarePixels(video)}scale=w='min(iw,{maxWidth})':h={height}:force_original_aspect_ratio=decrease:flags=lanczos"; + } + + /// Images have no pixel aspect ratio: anamorphic video (DV, DVD) is stretched to square pixels first. + internal static string SquarePixels(VideoStreamInfo video) => + Math.Abs(video.SampleAspect - 1) > 0.01 ? "scale=w='trunc(iw*sar/2)*2':h=ih,setsar=1," : ""; + + // ───────────────────────── Still frame ───────────────────────── + + /// + /// One full-size PNG frame from a tenth into the video (the first frames are often black), the start for video → + /// image conversions (the image engines take it from there). Without this edge the route planner would reach + /// images through an animated GIF of the whole video. + /// + private static List PosterFrame(string input, string output, MediaInfo info) + { + var video = info.Video ?? throw new InvalidOperationException("The file has no video stream."); + var seconds = (info.Duration?.TotalSeconds ?? 0) / 10; + return + [ + .. Common, "-ss", seconds.ToString("0.###", CultureInfo.InvariantCulture), "-i", input, "-map", "0:V:0", + "-vf", SquarePixels(video) + "null", "-frames:v", "1", "-update", "1", "-c:v", "png", "-f", "image2", + "-progress", "pipe:1", output, + ]; + } + + // ───────────────────────── Still picture → video ───────────────────────── + + /// + /// A video that shows the PNG for . The image engines write + /// that PNG at full size and colour, so pictures never pass through a 256-colour GIF on their way to video. + /// + /// + /// The PNG's ICC profile or colour chunks give the primaries and transfer (a Display P3 photo stays P3 in the + /// video); RGB becomes BT.709 YUV in TV range, tagged as such. Transparent areas turn black, like the bars around + /// a video. 30 frames per second play everywhere; identical frames cost x264 (tuned for stills) and VP9 almost + /// nothing, so the quality is one step above the preset's (Balanced encodes like High): the file is about the + /// size of one picture either way. + /// + public static IReadOnlyList PlanStill(string input, string output, string target, MediaOptions options, MediaInfo info) + { + var picture = info.Video ?? throw new InvalidOperationException("The file has no picture."); + if (!PictureToVideoTargets.Contains(target)) + throw new NotSupportedException($"FFmpeg does not turn pictures into '{target}'."); + var spec = VideoTargets[target]; + var quality = options.Quality == MediaQuality.Small ? MediaQuality.Balanced : MediaQuality.High; + + List args = + [ + .. Common, "-flags2", "+icc_profiles", "-loop", "1", "-framerate", "30", "-i", input, "-map", "0:V:0", + "-t", StillDuration.TotalSeconds.ToString(CultureInfo.InvariantCulture), + "-vf", $"premultiply=inplace=1,{StillScale(picture, options.MaxHeight)}:out_color_matrix=bt709:out_range=tv,format=yuv420p", + .. VideoCodecArguments(target, spec.VideoCodec!, quality, picture, pal: false), + ]; + if (spec.VideoCodec == "libx264") + args.AddRange(["-tune", "stillimage"]); + args.AddRange(["-colorspace", "bt709", "-an", .. ContainerArguments(target), "-f", spec.Muxer, "-progress", "pipe:1", output]); + return [new FfmpegPass(args, 1)]; + } + + /// + /// The picture's own size, made even (4:2:0) and scaled down only to or to what video + /// players decode (, ), keeping the aspect ratio. + /// + internal static string StillScale(VideoStreamInfo picture, int maxHeight) + { + double width = picture.Width, height = picture.Height; + if (width <= 0 || height <= 0) + return ScaleFilter(maxHeight) + ":flags=lanczos"; + var factor = Math.Min(1, Math.Min(Math.Sqrt(MaxVideoPixels / (width * height)), MaxVideoSide / Math.Max(width, height))); + if (maxHeight > 0) + factor = Math.Min(factor, Math.Max(16, maxHeight) / height); + return $"scale={EvenDown(width * factor)}:{EvenDown(height * factor)}:flags=lanczos"; + } + + // ───────────────────────── Helpers ───────────────────────── + + internal static T Choose(MediaQuality quality, T high, T balanced, T small) => quality switch + { + MediaQuality.High => high, + MediaQuality.Small => small, + _ => balanced, + }; + + private static string Text(int value) => value.ToString(CultureInfo.InvariantCulture); + + internal static int Even(double value) => Math.Max(2, (int)Math.Round(value / 2) * 2); + + /// The even number at or below (a tiny rounding margin keeps 1080.0000001 at 1080). + private static int EvenDown(double value) => Math.Max(2, (int)Math.Floor(value / 2 + 1e-6) * 2); +} diff --git a/src/Filee.Core/Settings/TranslationService.cs b/src/Filee.Core/Settings/TranslationService.cs new file mode 100644 index 000000000..0d448533f --- /dev/null +++ b/src/Filee.Core/Settings/TranslationService.cs @@ -0,0 +1,350 @@ +using System.Diagnostics; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Infrastructure; +using Microsoft.Extensions.Logging; + +namespace Filee.Engines.Translation; + +/// +/// Offline document and text translation service powered by local AI (Ollama). +/// Strictly runs on the user's computer: no text is ever uploaded or sent to third-party web services. +/// +public sealed class TranslationService : ITranslationService +{ + private static readonly TimeSpan OllamaTimeout = TimeSpan.FromMinutes(2); + private static readonly Uri LocalOllamaBase = new("http://127.0.0.1:11434"); + + private static readonly HttpClient SharedHttpClient = new(new SocketsHttpHandler + { + UseProxy = false, + AllowAutoRedirect = false, + ConnectTimeout = TimeSpan.FromSeconds(5) + }) + { + Timeout = OllamaTimeout + }; + + private static readonly HttpClient SharedPingClient = new(new SocketsHttpHandler + { + UseProxy = false, + AllowAutoRedirect = false, + ConnectTimeout = TimeSpan.FromMilliseconds(500) + }) + { + Timeout = TimeSpan.FromSeconds(2) + }; + + private readonly TranslateOptions _options; + private readonly ILogger? _logger; + private readonly HttpClient? _http; + private string? _resolvedModel; + + public TranslationService(TranslateOptions? options = null, HttpClient? httpClient = null, ILogger? logger = null) + { + _options = options ?? new TranslateOptions(); + _http = httpClient; + _logger = logger; + } + + public static string NormalizeLanguage(string? lang) + { + if (string.IsNullOrWhiteSpace(lang)) + return "en"; + var trimmed = lang.Trim(); + return LanguageMap.TryGetValue(trimmed, out var code) ? code : trimmed.ToLowerInvariant(); + } + + public static string LanguageDisplayName(string code) + { + var norm = NormalizeLanguage(code); + return CodeToNameMap.TryGetValue(norm, out var name) ? name : code; + } + + /// + /// Checks whether an Ollama model forwards prompts to the cloud (e.g. models ending in '-cloud' or containing 'cloud'). + /// Cloud models are strictly prohibited to ensure document text never leaves the computer. + /// + public static bool IsCloudModel(string? modelName) + { + if (string.IsNullOrWhiteSpace(modelName)) + return false; + var name = modelName.Trim(); + return name.EndsWith("-cloud", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(":cloud", StringComparison.OrdinalIgnoreCase) + || name.Contains("-cloud:", StringComparison.OrdinalIgnoreCase) + || name.Contains("/cloud", StringComparison.OrdinalIgnoreCase) + || name.IndexOf("cloud", StringComparison.OrdinalIgnoreCase) >= 0; + } + + public static bool IsCloudModelElement(JsonElement modelElement) + { + if (modelElement.TryGetProperty("name", out var n) && IsCloudModel(n.GetString())) + return true; + if (modelElement.TryGetProperty("remote_host", out var host) && !string.IsNullOrWhiteSpace(host.GetString())) + return true; + if (modelElement.TryGetProperty("remote_model", out var rModel) && !string.IsNullOrWhiteSpace(rModel.GetString())) + return true; + if (modelElement.TryGetProperty("details", out var details) && details.TryGetProperty("parent_model", out var parent) && IsCloudModel(parent.GetString())) + return true; + return false; + } + + public static async Task IsLocalOllamaAvailableAsync(CancellationToken cancellationToken = default) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMilliseconds(500)); + using var res = await SharedPingClient.GetAsync(new Uri(LocalOllamaBase, "/api/tags"), cts.Token); + return res.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + public static async Task GetPreferredLocalOllamaModelAsync(CancellationToken cancellationToken = default) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(2)); + using var res = await SharedPingClient.GetAsync(new Uri(LocalOllamaBase, "/api/tags"), cts.Token); + if (!res.IsSuccessStatusCode) + return null; + + using var doc = await JsonDocument.ParseAsync(await res.Content.ReadAsStreamAsync(cts.Token), cancellationToken: cts.Token); + if (doc.RootElement.TryGetProperty("models", out var models) && models.ValueKind == JsonValueKind.Array) + { + var names = models.EnumerateArray() + .Where(m => !IsCloudModelElement(m)) + .Select(m => m.TryGetProperty("name", out var n) ? n.GetString() : null) + .Where(n => !string.IsNullOrEmpty(n)) + .Select(n => n!) + .ToList(); + + if (names.Count == 0) + return null; + + // Prefer specialized or standard instruction models + string[] preferred = ["translategemma", "qwen2.5", "llama3.2", "llama3", "mistral", "gemma2", "phi3", "phi4"]; + foreach (var p in preferred) + { + var match = names.FirstOrDefault(n => n.StartsWith(p, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + return match; + } + return names[0]; + } + } + catch + { + // Ignore connection errors + } + return null; + } + + private async Task EnsureModelResolvedAsync(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_resolvedModel)) + return _resolvedModel; + + if (!string.IsNullOrWhiteSpace(_options.Model)) + { + if (IsCloudModel(_options.Model) || await CheckModelRemoteCloudAsync(_options.Model, cancellationToken)) + throw new ConversionRefusedException("error.translation_cloud_model_forbidden"); + _resolvedModel = _options.Model; + return _resolvedModel; + } + + var preferred = await GetPreferredLocalOllamaModelAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(preferred)) + throw new ConversionRefusedException("engines.package.translator.no_model"); + + if (IsCloudModel(preferred) || await CheckModelRemoteCloudAsync(preferred, cancellationToken)) + throw new ConversionRefusedException("error.translation_cloud_model_forbidden"); + + _resolvedModel = preferred; + return _resolvedModel; + } + + private async Task CheckModelRemoteCloudAsync(string modelName, CancellationToken cancellationToken) + { + try + { + var client = _http ?? SharedHttpClient; + var body = new { model = modelName }; + using var res = await client.PostAsJsonAsync(new Uri(LocalOllamaBase, "/api/show"), body, cancellationToken); + if (res.StatusCode == System.Net.HttpStatusCode.NotFound) + throw new ConversionRefusedException("error.translation_model_not_found"); + + if (!res.IsSuccessStatusCode) + return true; + + using var doc = await JsonDocument.ParseAsync(await res.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + return IsCloudModelElement(doc.RootElement); + } + catch (ConversionRefusedException) + { + throw; + } + catch + { + return true; + } + } + + public async Task TranslateTextAsync(string text, string toLang, string fromLang = "auto", CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) + return text; + + var targetLang = NormalizeLanguage(toLang); + var sourceLang = string.IsNullOrWhiteSpace(fromLang) || fromLang.Equals("auto", StringComparison.OrdinalIgnoreCase) ? "auto" : NormalizeLanguage(fromLang); + + if (string.Equals(sourceLang, targetLang, StringComparison.OrdinalIgnoreCase)) + return text; + + var model = await EnsureModelResolvedAsync(cancellationToken); + return await TranslateWithLocalOllamaAsync(text, targetLang, sourceLang, model, cancellationToken); + } + + public async Task> TranslateBatchAsync(IReadOnlyList texts, string toLang, string fromLang = "auto", CancellationToken cancellationToken = default) + { + if (texts.Count == 0) + return []; + + await EnsureModelResolvedAsync(cancellationToken); + var results = new List(texts.Count); + foreach (var text in texts) + { + cancellationToken.ThrowIfCancellationRequested(); + results.Add(await TranslateTextAsync(text, toLang, fromLang, cancellationToken)); + } + return results; + } + + private async Task TranslateWithLocalOllamaAsync(string text, string targetLang, string sourceLang, string model, CancellationToken cancellationToken) + { + var targetName = LanguageDisplayName(targetLang); + var system = $"You are a professional book and document translator. Translate the given text into {targetName}.\n" + + "Rules:\n" + + "- Preserve all formatting tags (such as , , ,
, etc.) and slot tags (, , etc.) exactly.\n" + + "- Maintain the exact same paragraph and line structure.\n" + + "- Output ONLY the translated text without commentary, intro, rules, or explanation."; + + var requestBody = new + { + model, + system, + prompt = text, + stream = false, + think = false, + options = new { temperature = 0.1 } + }; + + var client = _http ?? SharedHttpClient; + using var response = await client.PostAsJsonAsync(new Uri(LocalOllamaBase, "/api/generate"), requestBody, cancellationToken); + response.EnsureSuccessStatusCode(); + + using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + if (json.RootElement.TryGetProperty("response", out var resp) && resp.GetString() is { } result && !string.IsNullOrWhiteSpace(result)) + { + return StripThinkTags(result).Trim(); + } + + throw new ConversionRefusedException("error.translation_empty_response"); + } + + private static string StripThinkTags(string text) => + Regex.Replace(text, @".*?", "", RegexOptions.Singleline).Trim(); + + private static readonly Dictionary LanguageMap = new(StringComparer.OrdinalIgnoreCase) + { + ["th"] = "th", + ["thai"] = "th", + ["ไทย"] = "th", + ["id"] = "id", + ["indonesian"] = "id", + ["bahasa indonesia"] = "id", + ["en"] = "en", + ["english"] = "en", + ["es"] = "es", + ["spanish"] = "es", + ["español"] = "es", + ["fr"] = "fr", + ["french"] = "fr", + ["français"] = "fr", + ["de"] = "de", + ["german"] = "de", + ["deutsch"] = "de", + ["zh"] = "zh", + ["chinese"] = "zh", + ["zh-cn"] = "zh", + ["zh-tw"] = "zh", + ["hi"] = "hi", + ["hindi"] = "hi", + ["हिन्दी"] = "hi", + ["ja"] = "ja", + ["japanese"] = "ja", + ["日本語"] = "ja", + ["ko"] = "ko", + ["korean"] = "ko", + ["한국어"] = "ko", + ["ru"] = "ru", + ["russian"] = "ru", + ["русский"] = "ru", + ["pt"] = "pt", + ["portuguese"] = "pt", + ["português"] = "pt", + ["ar"] = "ar", + ["arabic"] = "ar", + ["العربية"] = "ar", + ["ne"] = "ne", + ["nepali"] = "ne", + ["नेपाली"] = "ne", + ["it"] = "it", + ["italian"] = "it", + ["italiano"] = "it", + ["nl"] = "nl", + ["dutch"] = "nl", + ["nederlands"] = "nl", + ["pl"] = "pl", + ["polish"] = "pl", + ["polski"] = "pl", + ["tr"] = "tr", + ["turkish"] = "tr", + ["türkçe"] = "tr", + ["uk"] = "uk", + ["ukrainian"] = "uk", + ["українська"] = "uk" + }; + + private static readonly Dictionary CodeToNameMap = new(StringComparer.OrdinalIgnoreCase) + { + ["en"] = "English", + ["es"] = "Spanish", + ["fr"] = "French", + ["de"] = "German", + ["zh"] = "Chinese", + ["hi"] = "Hindi", + ["ja"] = "Japanese", + ["ko"] = "Korean", + ["ru"] = "Russian", + ["pt"] = "Portuguese", + ["ar"] = "Arabic", + ["ne"] = "Nepali", + ["it"] = "Italian", + ["nl"] = "Dutch", + ["pl"] = "Polish", + ["tr"] = "Turkish", + ["th"] = "Thai", + ["id"] = "Indonesian", + ["uk"] = "Ukrainian" + }; +} diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 6f13384c3..548cd8991 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using Filee.Core.Formats; using Filee.Core.History; using Filee.Core.Presets; using Filee.Core.Profiles; @@ -70,7 +71,13 @@ public UserDataStore(string directory, ILogger? log = null) public void Load() { System.IO.Directory.CreateDirectory(Directory); + // A new install is told by the file missing, not by Read returning null: a settings file that can't be read is an + // existing user's, who keeps every field on. Only a new install gets the fields that are off by default. + var fresh = !File.Exists(SettingsPath); Settings = Read(SettingsPath, FileeJsonContext.Default.AppSettings) ?? new AppSettings(); + Settings.TurnedOffFields ??= []; + if (fresh) + Settings.TurnedOffFields = FormatFields.All.Where(f => f.CanTurnOff && !f.OnByDefault).Select(f => f.Id).ToList(); Presets = Read(PresetsPath, FileeJsonContext.Default.ListPreset) ?? BuiltInData.CreatePresets(); Profiles = Read(ProfilesPath, FileeJsonContext.Default.ListToolbarProfile) ?? BuiltInData.CreateProfiles(); History = Read(HistoryPath, FileeJsonContext.Default.ListHistoryEntry) ?? []; @@ -245,9 +252,11 @@ private void RestoreBuiltInPresets(IEnumerable ids) /// The preset when it converts (a group or tool doesn't), for every place that runs one. public Preset? FindConversion(string id) => Presets.FirstOrDefault(p => p.Id == id && !p.IsGroup && !p.IsTool); - /// Exports presets and profiles to a shareable JSON file, without PDF passwords. + /// + /// Exports presets and profiles to a shareable JSON file, without PDF passwords or paths to one's own pictures. + /// public void Export(string path) => - Write(path, new PresetBundle { Presets = [.. Presets.Select(p => p.WithoutPassword(mark: false))], Profiles = Profiles }, + Write(path, new PresetBundle { Presets = [.. Presets.Select(p => p.ForSharing())], Profiles = Profiles }, FileeJsonContext.Default.PresetBundle); /// @@ -262,6 +271,8 @@ public int Import(string path) foreach (var preset in bundle.Presets) { + // A path to a picture in someone else's bundle is not one of ours to open (a network share, say). + preset.Image.ForgetBackdropPictures(); Presets.RemoveAll(p => p.Id == preset.Id); Presets.Add(preset); } @@ -457,11 +468,15 @@ public static void Apply(AppSettings settings) public static bool ApplyToLibrary(int from, List presets, List profiles) { var changed = false; + // The fronts as read, before the steps below add slots to them: AddBackFaces compares these with the new defaults. + var storedFronts = new Dictionary>(); + foreach (var profile in profiles) + storedFronts.TryAdd(profile.Id, [.. profile.PresetIds]); // v4: Markdown / TXT / HTML files get their own donut instead of the "mixed files" fallback. Skipped when // the user already routes Markdown through a profile of their own. if (from < 4 && !profiles.Any(p => p.Id == "text" || (!p.IsFallback && p.Extensions.Contains("md", StringComparer.OrdinalIgnoreCase)))) { - var text = BuiltInData.TextProfile(); + var text = BuiltInData.ProfilesSchema58().Single(p => p.Id == "text"); var ids = presets.Select(p => p.Id).ToHashSet(); text.PresetIds = text.PresetIds.Where(ids.Contains).ToList(); var fallback = profiles.FindIndex(p => p.IsFallback); @@ -482,7 +497,7 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id == "office"); var insertAt = office is null ? profiles.FindIndex(p => p.IsFallback) : profiles.IndexOf(office) + 1; var ids = presets.Select(p => p.Id).ToHashSet(); - foreach (var split in new[] { BuiltInData.SpreadsheetProfile(), BuiltInData.PresentationProfile() }) + foreach (var split in BuiltInData.ProfilesSchema58().Where(p => p.Id is "spreadsheets" or "presentations")) { if (profiles.Any(p => p.Id == split.Id)) continue; @@ -752,12 +767,14 @@ public static bool ApplyToLibrary(int from, List presets, List p.BackPresetIds is null)) - changed |= AddBackFaces(presets, profiles); + changed |= AddBackFaces(presets, profiles, storedFronts); // v31: "Live Photo (iPhone)" and "Motion Photo (Android)" (a clip of a video as a moving photo), after "Extract // Live Photo video" as in a new library, and on the back of the video donut after "Extract frames" when it // has room and the user hasn't put them elsewhere on it. Present already but saved since by a Filee before @@ -955,6 +972,196 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id != BuiltInData.AnnotateId)) + { + var hide = presets.FindIndex(p => p.Id == BuiltInData.HidePresets().Single().Id); + presets.InsertRange(hide < 0 ? presets.Count : hide + 1, BuiltInData.AnnotatePresets()); + changed = true; + } + // v61: Dart and HiveQL sources (#980, #997) learn the text donut, Krita files (#903) the images donut; custom + // claims stay put. + if (from < 61) + changed |= LearnExtensions(profiles, out _, ["dart", "hql", "kra"]); + // v62: Minecraft packs and Modrinth modpacks (#824, #898): their presets join the library after "TAR" (no donut gets + // a slot, #806), and the archives donut learns their extensions; custom claims stay put. + if (from < 62) + { + changed |= AddGamePacks(presets); + changed |= LearnExtensions(profiles, out _, ["mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack"]); + } + // v63: "Pages per sheet" (#737): its group and presets join the library after the PDF "Edit" group (no donut gets + // a slot, #806). (Schema 63 may be renumbered if another change takes it first.) + if (from < 63) + changed |= AddPdfSheets(presets); + // v64: the "Add background" tool, in the palette only, after "Annotate" as in a new library (at the end + // without it). (Schema 64 may be renumbered if another change takes it first.) + if (from < 64 && presets.All(p => p.Id != BuiltInData.BackgroundId)) + { + var annotate = presets.FindIndex(p => p.Id == BuiltInData.AnnotateId); + presets.InsertRange(annotate < 0 ? presets.Count : annotate + 1, BuiltInData.BackgroundPresets()); + changed = true; + } + // v65: DBML database schemas (#854) learn the text donut; custom claims stay put. (Schema 65 may be renumbered if + // another change takes it first.) + if (from < 65) + changed |= LearnExtensions(profiles, out _, ["dbml"]); + // v66: Krita's backups (.kra~, #1077) learn the images donut; custom claims stay put. (Schema 66 may be renumbered + // 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"]); + // v68: signed files (.p7m, #1010) learn the documents donut; custom claims stay put. (Schema 68 may be + // renumbered if another change takes it first.) + if (from < 68) + changed |= LearnExtensions(profiles, out _, ["p7m"]); + // v69: the Translate preset (#1160), which no step brought to libraries saved before it existed, after the + // built-in preset before it as in a new library; no donut gets a slot (#806). (Schema 69 may be renumbered + // if another change takes it first.) + if (from < 69 && presets.All(p => p.Id != "translate")) + changed |= AddTranslatePreset(presets); + // v70: JPEG .jif files (#1184) learn the images donut without taking custom claims. + if (from < 70) + changed |= LearnExtensions(profiles, out _, ["jif"]); + // v71: Java source files (#1176) learn the text donut; custom claims stay put. + if (from < 71) + changed |= LearnExtensions(profiles, out _, ["java"]); + // v72: the "Metadata" tool, in the palette only, after "Add background" as in a new library (at the end + // without it). (Schema 72 may be renumbered if another change takes it first.) + if (from < 72 && presets.All(p => p.Id != BuiltInData.MetadataId)) + { + var background = presets.FindIndex(p => p.Id == BuiltInData.BackgroundId); + presets.InsertRange(background < 0 ? presets.Count : background + 1, BuiltInData.MetadataPresets()); + changed = true; + } + // v73: the "Upscale" tool, in the palette only, after "Metadata" (or "Add background") as in a new library (at + // the end without either, #806). + if (from < 73 && presets.All(p => p.Id != BuiltInData.UpscaleId)) + { + var after = presets.FindIndex(p => p.Id == BuiltInData.MetadataId); + if (after < 0) + after = presets.FindIndex(p => p.Id == BuiltInData.BackgroundId); + presets.InsertRange(after < 0 ? presets.Count : after + 1, BuiltInData.UpscalePresets()); + changed = true; + } + // v74: folder inputs have their own small ISO donut; existing archive donuts remain untouched. (Schema 74 may + // be renumbered if another change takes it first.) + if (from < 74) + { + var hadIsoPreset = presets.Any(p => p.Id == "to-iso"); + if (presets.All(p => p.Id != "to-iso")) + { + var at = presets.FindIndex(p => p.Id == "zip-all"); + presets.Insert(at >= 0 ? at : presets.Count, BuiltInData.CreatePresets().Single(p => p.Id == "to-iso")); + changed = true; + } + // A library already containing our ISO preset may have deliberately removed its Folders donut. + if (!hadIsoPreset && profiles.All(p => p.Id != "folders")) + { + var at = profiles.FindIndex(p => p.Id == "archives"); + if (at >= 0) at++; + else { at = profiles.FindIndex(p => p.IsFallback); if (at < 0) at = profiles.Count; } + profiles.Insert(at, BuiltInData.CreateProfiles().Single(p => p.Id == "folders")); + changed = true; + } + } + // v75: property lists (#1256) learn the data donut; custom claims stay put. + if (from < 75) + changed |= LearnExtensions(profiles, out _, ["plist"]); + // v76: QuickTime movies saved as .quicktime (#1283) learn the video donut and Insta360 .insp photos (#711) the + // images donut; custom claims stay put. + if (from < 76) + changed |= LearnExtensions(profiles, out _, ["quicktime", "insp"]); + // v77: Mermaid diagrams (#1309) and Rust, Go, Kotlin, Swift, Scala, SCSS, Vue and TSX sources learn the text donut, + // UDF disc images (#1338) the archives donut and iPhone ringtones (#1291) the audio donut; custom claims + // stay put. + if (from < 77) + changed |= LearnExtensions(profiles, out _, + ["mermaid", "mmd", "rs", "go", "kt", "swift", "scala", "scss", "vue", "tsx", "udf", "m4r"]); + return changed; + } + + private static bool AddTranslatePreset(List presets) + { + var defaults = BuiltInData.CreatePresets(); + var at = defaults.FindIndex(p => p.Id == "translate"); + var after = defaults.Take(at).Select(p => presets.FindIndex(existing => existing.Id == p.Id)).LastOrDefault(i => i >= 0, -1); + presets.Insert(after + 1, defaults[at]); + return true; + } + + private static bool AddPdfSheets(List presets) + { + var at = presets.FindIndex(p => p.Id == "pdf-edit"); + at = at < 0 ? presets.Count : at + 1; + var changed = false; + foreach (var preset in BuiltInData.PdfSheetPresets()) + { + if (presets.FirstOrDefault(p => p.Id == preset.Id) is { } existing) + { + // Saved since by an older Filee, which dropped the option and left a plain PDF preset. + if (preset.Pdf.MakesSheets && !existing.Pdf.MakesSheets && existing.TargetFormat == preset.TargetFormat) + { + existing.Pdf.PagesPerSheet = preset.Pdf.PagesPerSheet; + changed = true; + } + at = presets.IndexOf(existing) + 1; + continue; + } + presets.Insert(at++, preset); + changed = true; + } + return changed; + } + + private static bool AddGamePacks(List presets) + { + var at = presets.FindIndex(p => p.Id == "to-tar"); + at = at < 0 ? presets.Count : at + 1; + var changed = false; + foreach (var preset in BuiltInData.GamePackPresets()) + { + if (presets.FirstOrDefault(p => p.Id == preset.Id) is { } existing) + { + at = presets.IndexOf(existing) + 1; + continue; + } + presets.Insert(at++, preset); + changed = true; + } + return changed; + } + + private static bool AddCropVideo(List presets) + { + var at = presets.FindIndex(p => p.Id == "tool-clip"); + at = at < 0 ? presets.Count : at + 1; + var changed = false; + foreach (var preset in BuiltInData.CropVideoPresets()) + { + if (presets.FirstOrDefault(p => p.Id == preset.Id) is { } existing) + { + // Saved since by an older Filee, which dropped the option and left a plain "same format" preset. + if (preset.CropsVideo && !existing.CropsVideo && existing.TargetFormat == preset.TargetFormat) + { + existing.Media.CropFrame = preset.Media.CropFrame; + changed = true; + } + at = presets.IndexOf(existing) + 1; + continue; + } + presets.Insert(at++, preset); + changed = true; + } return changed; } @@ -1086,18 +1293,31 @@ private static bool AddMediaTools(List presets, List pro } /// - /// Gives each built-in donut without a back face ( null) its default one, - /// with only the presets in . Donuts the user made are left for - /// to give an empty one. Returns true when one was added. + /// Gives each built-in donut without a back face ( null) one, with only the + /// presets in : the new default back face when its front is exactly the new default front + /// (same ids, same order) and no existing library can have that front by default, the schema 58 one otherwise. The + /// front is the one in (as read, before migrations added slots), else the current + /// one. Donuts the user made are left for to give an empty one. Returns true when one + /// was added. /// - internal static bool AddBackFaces(List presets, IEnumerable profiles) + internal static bool AddBackFaces(List presets, IEnumerable profiles, + IReadOnlyDictionary>? storedFronts = null) { - var defaults = BuiltInData.CreateProfiles().ToDictionary(p => p.Id); + var frozen = BuiltInData.ProfilesSchema58().ToDictionary(p => p.Id); + var current = BuiltInData.CreateProfiles().ToDictionary(p => p.Id); var ids = presets.Select(p => p.Id).ToHashSet(); var changed = false; - foreach (var profile in profiles.Where(p => p.BackPresetIds is null && defaults.ContainsKey(p.Id))) - { - profile.BackPresetIds = (defaults[profile.Id].BackPresetIds ?? []).Where(ids.Contains).ToList(); + foreach (var profile in profiles.Where(p => p.BackPresetIds is null && frozen.ContainsKey(p.Id))) + { + var front = storedFronts?.GetValueOrDefault(profile.Id) ?? profile.PresetIds; + var fresh = current[profile.Id]; + // Presentations, CAD and mixed have the same front in both layouts, so the front can't tell a new library + // from an existing one. Text: the Filee 1.0.1 front plus HWP from schema 7 is exactly the new text front. + // Their schema 58 back faces hold every slot of the new ones. + var isNew = front.SequenceEqual(fresh.PresetIds) + && !fresh.PresetIds.SequenceEqual(frozen[profile.Id].PresetIds) + && profile.Id != "text"; + profile.BackPresetIds = ((isNew ? fresh : frozen[profile.Id]).BackPresetIds ?? []).Where(ids.Contains).ToList(); changed = true; } return changed; @@ -1232,7 +1452,7 @@ private static bool AddGeo(List presets, List profiles) if (profiles.Any(p => p.Id == "geo")) return changed; - var geo = BuiltInData.GeoProfile(); + var geo = BuiltInData.ProfilesSchema58().Single(p => p.Id == "geo"); // Only the extensions no donut of the user takes yet. geo.Extensions = geo.Extensions.Where(claimed.Add).ToList(); if (geo.Extensions.Count == 0) @@ -1278,7 +1498,7 @@ private static bool AddData(List presets, List profiles) if (profiles.All(p => p.Id != "data")) { - var data = BuiltInData.DataProfile(); + var data = BuiltInData.ProfilesSchema58().Single(p => p.Id == "data"); // Only the extensions no donut of the user takes yet. data.Extensions = data.Extensions.Where(claimed.Add).ToList(); if (data.Extensions.Count > 0) @@ -1308,7 +1528,7 @@ private static bool AddCodecsAndSubtitles(List presets, List p.Id != "subtitles")) { - var subtitles = BuiltInData.SubtitleProfile(); + var subtitles = BuiltInData.ProfilesSchema58().Single(p => p.Id == "subtitles"); subtitles.Extensions = subtitles.Extensions.Where(claimed.Add).ToList(); if (subtitles.Extensions.Count > 0) { @@ -1360,7 +1580,7 @@ private static bool AddModels(List presets, List profile if (profiles.Any(p => p.Id == "models")) return changed; - var models = BuiltInData.ModelProfile(); + var models = BuiltInData.ProfilesSchema58().Single(p => p.Id == "models"); // Only the extensions no donut of the user takes yet. models.Extensions = models.Extensions.Where(claimed.Add).ToList(); if (models.Extensions.Count == 0) @@ -1389,7 +1609,8 @@ private static bool AddCatalog(List presets, List profil var text = profiles.FindIndex(p => p.Id == "text"); var insertAt = text >= 0 ? text + 1 : profiles.FindIndex(p => p.IsFallback); insertAt = insertAt < 0 ? profiles.Count : insertAt; - foreach (var profile in BuiltInData.CatalogProfiles().Where(p => profiles.All(existing => existing.Id != p.Id))) + foreach (var profile in BuiltInData.ProfilesSchema58().Where(p => p.Id is "ebooks" or "video" or "audio" or "vector" or "archives" or "cad" or "fonts") + .Where(p => profiles.All(existing => existing.Id != p.Id))) { // Only the extensions no donut of the user takes yet. profile.Extensions = profile.Extensions.Where(claimed.Add).ToList(); From 146cedd54464a63cd582afae91a40a2b18112652 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Tue, 10 Nov 2026 20:10:48 +0000 Subject: [PATCH 3/7] Add files via upload --- .../ViewModels/PresetEditorViewModel.cs | 540 ++++++++++++++++-- 1 file changed, 499 insertions(+), 41 deletions(-) diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 916946e8f..31f52be07 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -3,11 +3,17 @@ // // Works on a clone; Apply() copies the edits back into the original preset (Cancel = just drop the VM). +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.App.Services; using Filee.Core.Formats; using Filee.Core.Localization; using Filee.Core.Presets; +using Filee.Core.Settings; using Filee.Engines.Ocr; +using Filee.Engines.Translation; +using Filee.Engines.Upscale; namespace Filee.App.ViewModels; @@ -29,12 +35,40 @@ public enum FileSizeUnit MB, } +/// The unit of a video's size limit in the preset editor (#680); the preset itself keeps megabytes or a percentage. +public enum VideoSizeUnit +{ + MB, + Percent, +} + /// A value with a localized label for combo boxes. public sealed record Choice(T Value, string Label, string? Detail = null) : IChoice { public override string ToString() => Label; } +/// +/// A language in the preset editor's OCR lists. Whether it needs a download depends on the other languages chosen +/// (), so the lists keep one object per language and only its changes. +/// +/// The size of its download, or null when its data is there already. +public sealed partial class OcrLanguageChoice(string value, string label, bool visionReads, string? download) : ObservableObject, IChoice +{ + public string Value => value; + public string Label => label; + + /// Apple Vision reads it on this computer. + public bool VisionReads => visionReads; + + [ObservableProperty] private string? _detail; + + /// Tesseract reads the chosen languages, not Apple Vision: it needs the data of each. + public void ShowDownload(bool tesseractReads) => Detail = tesseractReads || !visionReads ? download : null; + + public override string ToString() => Label; +} + /// Selections bound to properties that always hold a value (a choice, the open page). public static class Choices { @@ -68,19 +102,27 @@ public sealed partial class PresetEditorViewModel : ObservableObject private readonly Preset _edit; private readonly ILocalizer _loc; - public PresetEditorViewModel(Preset preset, ILocalizer loc) + public PresetEditorViewModel(Preset preset, ILocalizer loc, IReadOnlySet? turnedOff = null, bool folderInput = false) + : this(preset, loc, turnedOff, null, folderInput) + { + } + + /// Which languages Apple Vision reads, for tests; by default what this computer's does. + internal PresetEditorViewModel(Preset preset, ILocalizer loc, IReadOnlySet? turnedOff, Func? visionReads, + bool folderInput = false) { _original = preset; _edit = preset.Clone(); _loc = loc; + FolderInput = folderInput || preset.TargetFormat == "iso"; // ~150 formats in registry order, which groups them by category; the category is shown next to each name // and typing the first letters of a name jumps to it. Targets = [ - new Choice(BuiltInData.SameAsSource, loc["presets.same_as_source"]), + .. FolderInput ? Array.Empty>() : [new Choice(BuiltInData.SameAsSource, loc["presets.same_as_source"])], .. FormatRegistry.Known - .Where(f => f.Writable) + .Where(f => f.Writable && (!FolderInput || BuiltInData.SupportsFolderTarget(f.Id)) && (turnedOff is null || FormatFields.IsShown(f.Id, turnedOff))) .Select(f => new Choice( f.Id, f.Id == FormatRegistry.Folder ? loc["presets.target_folder"] : f.DisplayName, @@ -94,14 +136,13 @@ .. FormatRegistry.Known PageSizes = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.page_size.{v}"])).ToList(); CompressionLevels = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.pdf_compression.{v}"])).ToList(); // Each language under its own name; one not downloaded yet shows its size (it downloads with the first file). - // On a Mac, Apple Vision reads the common languages without a download. + // On a Mac, Apple Vision reads the common languages without a download (ShowOcrDownloads). var vision = new VisionConverter(); OcrHint = loc[vision.GetStatus().IsAvailable ? "presets.ocr_hint_macos" : "presets.ocr_hint"]; OcrLanguageChoices = [.. OcrLanguages.All .OrderBy(l => l.Code != OcrLanguages.Default).ThenBy(l => l.NativeName, StringComparer.CurrentCulture) - .Select(l => new Choice(l.Code, l.NativeName, - OcrLanguages.IsInstalled(l.Code) || vision.Reads(l.Code) ? null - : loc.Format("presets.ocr_language_size", $"{l.Size / 1_000_000.0:0.#} MB")))]; + .Select(l => new OcrLanguageChoice(l.Code, l.NativeName, (visionReads ?? vision.Reads)(l.Code), + OcrLanguages.IsInstalled(l.Code) ? null : loc.Format("presets.ocr_language_size", $"{l.Size / 1_000_000.0:0.#} MB")))]; // "Ask every time" (#521) is a bool of its own on the preset (OutputRule.AskEveryTime): its choice has no location. Locations = [.. Enum.GetValues().Select(v => new Choice(v, loc[$"presets.location.{v}"])), new Choice(null, loc["presets.location.Ask"])]; @@ -117,6 +158,23 @@ .. FormatRegistry.Known FitsTiffModes = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.fits_tiff.{v}"])).ToList(); MediaQualities = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.media_quality.{v}"])).ToList(); MaxHeights = Steps(MaxHeightSteps, _edit.Media.MaxHeight, h => h == 0 ? loc["presets.max_height.original"] : $"{h}p"); + // A ratio set outside the editor (a hand-edited preset file) is offered too, instead of being lost. + // "Pages per sheet" (#737): a number set outside the editor (a hand-edited preset file) is offered too. + SheetLayouts = [.. PdfOptions.SheetLayouts.Append(_edit.Pdf.PagesPerSheet).Where(n => n is > 1 and <= 16).Distinct().Order() + .Select(n => new Choice(n, $"{n}"))]; + PageOutputChoices = + [ + new(ViewModels.PageOutput.OnePdf, loc["pages.one_pdf"]), + new(ViewModels.PageOutput.EachPage, loc["presets.split"]), + new(ViewModels.PageOutput.Groups, loc["pages.groups"]), + ]; + GroupKinds = PageOutputs.Kinds(loc); + SheetPapers = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.sheet_paper.{v}"])).ToList(); + SheetOrientations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.sheet_orientation.{v}"])).ToList(); + SheetOrders = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.sheet_order.{v}"])).ToList(); + CropFrames = [new(VideoCrop.Auto, loc["presets.crop_frame.auto"]), + .. VideoCrop.Ratios.Append(_edit.Media.CropFrame).Where(r => VideoCrop.TryParseRatio(r, out _, out _)).Distinct() + .Select(r => new Choice(r, r))]; AudioBitrates = Steps(BitrateSteps, _edit.Media.AudioBitrateKbps, k => k == 0 ? loc["presets.audio_bitrate.auto"] : $"{k} kbps"); AudioChannelChoices = new[] { 0, 1, 2 }.Select(c => new Choice(c, loc[$"presets.audio_channels.{c}"])).ToList(); SampleRates = Steps(SampleRateSteps, _edit.Media.AudioSampleRate, @@ -140,7 +198,15 @@ .. WhisperLanguages.All ]; _name = string.IsNullOrWhiteSpace(_edit.Name) ? loc.DisplayName(_edit) : _edit.Name; - _target = Targets.FirstOrDefault(t => t.Value == _edit.TargetFormat) ?? Targets[1]; + if (FormatRegistry.FindById(_edit.TargetFormat) is { Writable: true } hidden + && turnedOff is not null && !FormatFields.IsShown(hidden.Id, turnedOff)) + { + _target = new Choice(hidden.Id, hidden.DisplayName, FormatLabels.Category(loc, hidden.Category)); + _hiddenTarget = hidden.DisplayName; + _hiddenTargetNote = loc.Format("presets.target_hidden", hidden.DisplayName); + } + else + _target = Targets.FirstOrDefault(t => t.Value == _edit.TargetFormat) ?? Targets.First(t => t.Value == (FolderInput ? "iso" : BuiltInData.SameAsSource)); _resize = ResizeModes.First(r => r.Value == (_edit.Image.CustomSize is not null ? ResizeMode.Custom : _edit.Image.ClampedMinShortEdge is not null ? ResizeMode.MinShortEdge : _edit.Image.Resize)); @@ -153,7 +219,11 @@ .. WhisperLanguages.All _pageNumbers = _edit.Pdf.PageNumbers; _pdfPassword = _edit.Pdf.Password; _ocr = _edit.Pdf.Ocr; - _ocrLanguage = OcrLanguageChoices.FirstOrDefault(l => l.Value == _edit.Pdf.OcrLanguage) ?? OcrLanguageChoices[0]; + var ocrLanguages = OcrLanguages.Parse(_edit.Pdf.OcrLanguage).Select(l => OcrLanguageChoices.First(c => c.Value == l.Code)).ToList(); + _ocrLanguage = ocrLanguages[0]; + MoreOcrLanguages = [.. ocrLanguages.Skip(1)]; + MoreOcrLanguages.CollectionChanged += (_, _) => RefreshOcrLanguagesToAdd(); + RefreshOcrLanguagesToAdd(); _location = _edit.Output.AskEveryTime ? Locations[^1] : Locations.First(l => l.Value == _edit.Output.Location); _conflict = Conflicts.First(c => c.Value == _edit.Output.Conflict); _tiffCompression = TiffCompressions.First(t => t.Value == _edit.Image.TiffCompression); @@ -161,6 +231,14 @@ .. WhisperLanguages.All _fitsDebayer = _edit.Image.FitsDebayer; _mediaQuality = MediaQualities.First(q => q.Value == _edit.Media.Quality); _maxHeight = MaxHeights.First(h => h.Value == _edit.Media.MaxHeight); + _cropFrame = CropFrames.FirstOrDefault(c => c.Value == _edit.Media.CropFrame) ?? CropFrames[0]; + _pagesPerSheet = SheetLayouts.FirstOrDefault(c => c.Value == _edit.Pdf.PagesPerSheet) ?? SheetLayouts.First(c => c.Value == 4); + _sheetPaper = SheetPapers.FirstOrDefault(c => c.Value == _edit.Pdf.SheetPaper) ?? SheetPapers[0]; + _sheetOrientation = SheetOrientations.FirstOrDefault(c => c.Value == _edit.Pdf.SheetOrientation) ?? SheetOrientations[0]; + _sheetOrder = SheetOrders.FirstOrDefault(c => c.Value == _edit.Pdf.SheetOrder) ?? SheetOrders[0]; + _sheetMarginMm = _edit.Pdf.SheetMarginMm; + _sheetGapMm = _edit.Pdf.SheetGapMm; + _sheetBorders = _edit.Pdf.SheetBorders; _audioBitrate = AudioBitrates.First(b => b.Value == _edit.Media.AudioBitrateKbps); _removeAudio = _edit.Media.RemoveAudio; _audioChannels = AudioChannelChoices.FirstOrDefault(c => c.Value == _edit.Media.AudioChannels) ?? AudioChannelChoices[0]; @@ -172,6 +250,33 @@ .. WhisperLanguages.All _caption = _edit.Media.Caption; _spokenLanguage = SpokenLanguageChoices.FirstOrDefault(l => string.Equals(l.Value, _edit.Media.SpokenLanguage, StringComparison.OrdinalIgnoreCase)) ?? SpokenLanguageChoices[0]; + TranslationLanguageChoices = + [ + new Choice("en", "English"), + new Choice("es", "Español"), + new Choice("fr", "Français"), + new Choice("de", "Deutsch"), + new Choice("zh", "中文"), + new Choice("hi", "हिन्दी"), + new Choice("ja", "日本語"), + new Choice("ko", "한국어"), + new Choice("ru", "Русский"), + new Choice("pt", "Português"), + new Choice("ar", "العربية"), + new Choice("it", "Italiano"), + new Choice("nl", "Nederlands"), + new Choice("pl", "Polski"), + new Choice("tr", "Türkçe"), + new Choice("ne", "नेपाली"), + new Choice("th", "ไทย"), + new Choice("id", "Bahasa Indonesia"), + new Choice("uk", "Українська") + ]; + _targetLanguage = TranslationLanguageChoices.FirstOrDefault(l => string.Equals(l.Value, _edit.Translate.TargetLanguage, StringComparison.OrdinalIgnoreCase)) + ?? TranslationLanguageChoices[0]; + _upscaleScale = _edit.Upscale.Scale > 0 ? _edit.Upscale.Scale : 4; + _selectedUpscaleModel = UpscaleModels.FirstOrDefault(m => string.Equals(m.Id, _edit.Upscale.Model, StringComparison.OrdinalIgnoreCase)) + ?? UpscaleModels.FirstOrDefault(); _archiveLevel = ArchiveLevels.First(l => l.Value == _edit.Archive.Level); _combineIntoOne = _edit.Archive.CombineIntoOne; _saveEveryFrame = _edit.Media.SaveEveryFrame; @@ -197,13 +302,22 @@ .. WhisperLanguages.All var perMegabyte = _imageSizeUnit.Value == FileSizeUnit.KB ? 1000m : 1m; _imageMaxSize = (decimal)_edit.Image.MaxSizeMb * perMegabyte; _imageMinSize = (decimal)_edit.Image.MinSizeMb * perMegabyte; - _videoMaxSizeMb = (decimal)_edit.Media.MaxSizeMb; + _videoSizeUnitChoice = _edit.Media.MaxSizePercent > 0 ? VideoSizeUnits[1] : VideoSizeUnits[0]; + _videoMaxSizeMb = _edit.Media.MaxSizeMb > 0 ? (decimal)_edit.Media.MaxSizeMb : null; + _videoMaxSizePercent = _edit.Media.MaxSizePercent > 0 ? _edit.Media.MaxSizePercent : null; _resizePercent = _edit.Image.ResizePercent; _longEdge = _edit.Image.LongEdge; _width = _edit.Image.Width; _customWidth = _edit.Image.CustomSize?.Width ?? 1920; _customHeight = _edit.Image.CustomSize?.Height ?? 1080; _lockAspect = _edit.Image.CustomSize?.LockAspect ?? false; + _shrinkOnly = _edit.Image.CustomSize?.ShrinkOnly ?? false; + // A side left empty is stored as the largest one (CustomImageSize.FreeSide) and comes back empty. + if (_lockAspect && _customWidth >= CustomImageSize.FreeSide) + _customWidth = null; + if (_lockAspect && _customHeight >= CustomImageSize.FreeSide) + _customHeight = null; + _maxSizeKeepsPixels = _edit.Image.MaxSizeKeepsPixels; _minShortEdge = _edit.Image.ClampedMinShortEdge ?? 1080; _setDpi = _edit.Image.Dpi.HasValue; _dpi = _edit.Image.Dpi ?? 300; @@ -221,7 +335,10 @@ .. WhisperLanguages.All _marginMm = _edit.Pdf.MarginMm; _renderDpi = _edit.Pdf.RenderDpi; _pageRange = _edit.Pdf.PageRange; - _splitPages = _edit.Pdf.SplitPages; + _askPageRange = _edit.Pdf.AskPageRange; + _pageOutput = PageOutputChoices.First(c => c.Value == PageOutputs.Of(_edit.Pdf)); + _groupKind = GroupKinds.First(k => k.Value == PageOutputs.KindOf(_edit.Pdf)); + _groupSize = PageOutputs.SizeOf(_edit.Pdf); _combinePages = _edit.Pdf.CombinePages; _trimPageMargins = _edit.Pdf.TrimPageMargins; _splitTallImage = _edit.Pdf.SplitTallImage; @@ -234,6 +351,7 @@ .. WhisperLanguages.All _keepDates = _edit.Output.KeepDates; _zipResult = _edit.Output.ZipResult; _replaceOriginal = _edit.Output.ReplaceOriginal; + _moveInsteadOfCopy = _edit.Output.MoveInsteadOfCopy; } public string PresetId => _original.Id; @@ -244,6 +362,9 @@ .. WhisperLanguages.All public bool IsTool => _original.IsTool; public bool CanChangeTarget => !IsTool; + /// Folder actions only offer directory-capable targets and preserve the source directory. + public bool FolderInput { get; } + public bool ShowReplaceOriginal => !FolderInput && !IsCopyToFolder; public IReadOnlyList> Targets { get; } public IReadOnlyList> ResizeModes { get; } @@ -254,10 +375,65 @@ .. WhisperLanguages.All public IReadOnlyList> PageSizes { get; } /// Languages Tesseract reads (PdfOptions.OcrLanguage), English first. - public IReadOnlyList> OcrLanguageChoices { get; } + public IReadOnlyList OcrLanguageChoices { get; } + + /// Languages read next to , for text that mixes them (#1123); PdfOptions.OcrLanguage holds them all. + public ObservableCollection MoreOcrLanguages { get; } + + /// The languages not chosen yet, for the "add a language" list. + public ObservableCollection OcrLanguagesToAdd { get; } = []; + /// The spoken languages Whisper recognizes (MediaOptions.SpokenLanguage), Auto first. public IReadOnlyList> SpokenLanguageChoices { get; } + public IReadOnlyList> TranslationLanguageChoices { get; } + + [ObservableProperty] private Choice _targetLanguage; + + public bool ShowTranslate => _original.Id == "translate" || _edit.Translate.Enabled; + + [ObservableProperty] private string _testTranslationResult = ""; + [ObservableProperty] private bool _isTestingTranslation; + [ObservableProperty] private bool _testTranslationHasError; + + [RelayCommand] + public async Task TestTranslationAsync() + { + if (IsTestingTranslation) return; + IsTestingTranslation = true; + TestTranslationHasError = false; + TestTranslationResult = _loc["engines.ollama.connecting"]; + + try + { + var targetLang = TargetLanguage?.Value ?? "es"; + var preferred = AppHost.Get()?.Settings.PreferredOllamaModel; + var model = !string.IsNullOrWhiteSpace(_edit.Translate.Model) ? _edit.Translate.Model + : !string.IsNullOrWhiteSpace(preferred) ? preferred + : await TranslationService.GetPreferredLocalOllamaModelAsync(); + var service = new TranslationService(new TranslateOptions { TargetLanguage = targetLang, Model = model }); + var sample = "Hello, world!"; + var translated = await service.TranslateTextAsync(sample, targetLang); + TestTranslationResult = $"\"{sample}\" → \"{translated}\" ({model ?? "local model"})"; + TestTranslationHasError = false; + } + catch (Exception ex) + { + TestTranslationResult = $"{_loc["engines.ollama.error"]} {ex.Message}"; + TestTranslationHasError = true; + } + finally + { + IsTestingTranslation = false; + } + } + + public bool ShowUpscale => _original.IsUpscaleTool || _edit.Upscale.Enabled; + public IReadOnlyList UpscaleModels { get; } = Filee.Engines.Upscale.UpscaleModels.All; + [ObservableProperty] private UpscaleModel? _selectedUpscaleModel; + [ObservableProperty] private int _upscaleScale = 4; + + /// What "Recognize text" needs: Tesseract, or on a Mac Apple Vision (Tesseract only for other languages). public string OcrHint { get; } @@ -271,6 +447,16 @@ .. WhisperLanguages.All public IReadOnlyList> FitsTiffModes { get; } public IReadOnlyList> MediaQualities { get; } public IReadOnlyList> MaxHeights { get; } + /// "Crop video": the picture inside the black bars, or a ratio. + public IReadOnlyList> CropFrames { get; } + public IReadOnlyList> SheetLayouts { get; } + + /// PDF → PDF: one PDF, one file per page, or several pages per file (#1259). + public IReadOnlyList> PageOutputChoices { get; } + public IReadOnlyList> GroupKinds { get; } + public IReadOnlyList> SheetPapers { get; } + public IReadOnlyList> SheetOrientations { get; } + public IReadOnlyList> SheetOrders { get; } public IReadOnlyList> AudioBitrates { get; } public IReadOnlyList> AudioChannelChoices { get; } public IReadOnlyList> SampleRates { get; } @@ -293,17 +479,41 @@ .. WhisperLanguages.All [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowImage), nameof(ShowTurnForPdf), nameof(ShowQuality), nameof(ShowTiff), nameof(ShowMultiPageTiff), nameof(ShowWebp), nameof(ShowIco), nameof(ShowFitsTiff), nameof(ShowFitsDebayer), - nameof(ShowZipResult), nameof(ShowPdf), nameof(ShowPdfBuild), nameof(ShowDocument), nameof(ShowLeaveOutPictures), nameof(ShowPdfSplit), nameof(ShowPdfCombine), - nameof(ShowPdfCompression), nameof(ShowPdfCompressionOptions), nameof(ShowOcr), nameof(ShowPageSize), nameof(ShowPdfTablesNote), + nameof(ShowZipResult), nameof(ShowPdf), nameof(ShowPdfBuild), nameof(ShowDocument), nameof(ShowLeaveOutPictures), nameof(ShowPdfSplit), nameof(ShowPageGroups), nameof(ShowPdfCombine), + nameof(ShowAskPages), nameof(ShowAskPagesHint), + nameof(ShowPdfCompression), nameof(ShowPdfCompressionOptions), nameof(ShowSheets), nameof(ShowOcr), nameof(ShowPageSize), nameof(ShowPdfTablesNote), nameof(ShowMedia), nameof(ShowMediaQuality), nameof(ShowMaxHeight), nameof(ShowVideoMaxSize), nameof(ShowAudioBitrate), nameof(ShowRemoveAudio), nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec), nameof(ShowHardwareEncoding), nameof(ShowCaption), nameof(AudioCodecs), nameof(AudioCodecChoice), nameof(ShowAudioCodec), nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote), nameof(ShowSaveEveryFrame), nameof(ShowAnimationSpeed), nameof(ShowTrace), nameof(ShowCad), nameof(ShowCadColors), nameof(ShowExtractMotionPhoto), nameof(ShowSlideshow), nameof(ShowLivePhotoChoice), nameof(LivePhotoKinds), nameof(LivePhotoChoice), nameof(IsLivePhoto), nameof(LivePhotoHint), - nameof(ShowImageSize), nameof(SizeRangeError), nameof(IsValid))] + nameof(ShowImageSize), nameof(SizeRangeError), nameof(CustomSizeError), nameof(IsValid))] private Choice _target; + // A target whose fields are all turned off isn't added to Targets (the list follows the switches), but it stays the + // target: the box shows its name as a placeholder with a note, and Apply saves it unless another one is picked. + [ObservableProperty] private string? _hiddenTarget; + [ObservableProperty] private string? _hiddenTargetNote; + + /// The target box has no selection while the current target is hidden by its field switch. + public Choice? TargetSelection + { + get => HiddenTarget is null ? Target : null; + set + { + if (value is null) + { + if (HiddenTarget is null) + OnPropertyChanged(nameof(TargetSelection)); + return; + } + HiddenTarget = null; + HiddenTargetNote = null; + Target = value; + } + } + private LivePhotoKind _livePhotoValue; /// What a video becomes for a JPG or HEIC target: one still frame, or a Live / Motion Photo it can be written as. @@ -328,7 +538,7 @@ public Choice LivePhotoChoice return; _livePhotoValue = value.Value; OnPropertyChanged(); - foreach (var name in (string[])[nameof(IsLivePhoto), nameof(LivePhotoHint), nameof(ShowImage), nameof(ShowPdf), nameof(ShowSaveEveryFrame), nameof(ShowImageSize), nameof(SizeRangeError), nameof(IsValid)]) + foreach (var name in (string[])[nameof(IsLivePhoto), nameof(LivePhotoHint), nameof(ShowImage), nameof(ShowPdf), nameof(ShowSaveEveryFrame), nameof(ShowImageSize), nameof(SizeRangeError), nameof(CustomSizeError), nameof(IsValid)]) OnPropertyChanged(name); } } @@ -420,6 +630,7 @@ public Choice AudioCodecChoice } [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowPercent), nameof(ShowLongEdge), nameof(ShowWidth), nameof(ShowCustom), nameof(ShowMinShortEdge))] + [NotifyPropertyChangedFor(nameof(CustomSizeError), nameof(IsValid))] private Choice _resize; [ObservableProperty] private Choice _rotation; @@ -442,7 +653,13 @@ public Choice AudioCodecChoice /// A picture's size range (#553, #662) in ; 0 or empty = none. [ObservableProperty, NotifyPropertyChangedFor(nameof(SizeRangeError), nameof(IsValid))] private decimal? _imageMinSize; - [ObservableProperty, NotifyPropertyChangedFor(nameof(SizeRangeError), nameof(IsValid))] private decimal? _imageMaxSize; + [ObservableProperty, NotifyPropertyChangedFor(nameof(SizeRangeError), nameof(IsValid), nameof(HasImageMaxSize))] private decimal? _imageMaxSize; + + /// With a maximum: keep the pixels and lower only the quality (ImageOptions.MaxSizeKeepsPixels, #1270). + [ObservableProperty] private bool _maxSizeKeepsPixels; + + /// A maximum is set, so keeping the pixels has something to do. + public bool HasImageMaxSize => ImageMaxSize > 0; /// KB or MB, for both ends of the range; not saved (the preset keeps megabytes). [ObservableProperty, NotifyPropertyChangedFor(nameof(SizeFormat), nameof(SizeIncrement))] private Choice _imageSizeUnit; @@ -463,15 +680,46 @@ public Choice AudioCodecChoice /// A minimum over the maximum, while the boxes are shown; a maximum of 0 is no maximum. public string? SizeRangeError => ShowImageSize && ImageMaxSize > 0 && ImageMinSize > ImageMaxSize ? _loc["presets.size_range_invalid"] : null; - /// "Size limit (MB)" for a video (0 = none, #553). - [ObservableProperty] private decimal? _videoMaxSizeMb; + /// The unit of the video size limit: MB or % of original (#680). + public IReadOnlyList> VideoSizeUnits { get; } = + [new(VideoSizeUnit.MB, "MB"), new(VideoSizeUnit.Percent, "%")]; + + [ObservableProperty] private Choice _videoSizeUnitChoice; + + /// Video size limit in MB (0 or empty = none, #553). + [ObservableProperty, NotifyPropertyChangedFor(nameof(VideoMaxSize))] private decimal? _videoMaxSizeMb; + + /// Video size limit as percentage of original (1–99, 0 or empty = none, #680). + [ObservableProperty, NotifyPropertyChangedFor(nameof(VideoMaxSize))] private decimal? _videoMaxSizePercent; + + /// The value bound to the video size NumericUpDown. + public decimal? VideoMaxSize + { + get => VideoSizeUnitChoice.Value == VideoSizeUnit.Percent ? VideoMaxSizePercent : VideoMaxSizeMb; + set + { + if (VideoSizeUnitChoice.Value == VideoSizeUnit.Percent) + VideoMaxSizePercent = value; + else + VideoMaxSizeMb = value; + OnPropertyChanged(); + } + } + + /// Maximum value for the video size NumericUpDown: 99 for %, 4096 for MB. + public decimal VideoSizeMaximum => VideoSizeUnitChoice.Value == VideoSizeUnit.Percent ? 99 : 4096; + public decimal VideoSizeIncrement => 1; + public string VideoSizeFormat => VideoSizeUnitChoice.Value == VideoSizeUnit.Percent ? "0" : "0.#"; [ObservableProperty] private int _resizePercent; [ObservableProperty] private int _longEdge; [ObservableProperty] private int _width; - [ObservableProperty] private int _customWidth; - [ObservableProperty] private int _customHeight; + /// With Custom: the box, in px; one of them may be left empty while the aspect is kept (#1270). + [ObservableProperty, NotifyPropertyChangedFor(nameof(CustomSizeError), nameof(IsValid))] private int? _customWidth; + [ObservableProperty, NotifyPropertyChangedFor(nameof(CustomSizeError), nameof(IsValid))] private int? _customHeight; /// With Custom: keep each file's shape and fit inside, instead of stretching (ImageOptions.LockAspect). - [ObservableProperty] private bool _lockAspect; + [ObservableProperty, NotifyPropertyChangedFor(nameof(CustomSizeError), nameof(IsValid))] private bool _lockAspect; + /// With Custom and the aspect kept: never enlarge a picture that already fits (CustomImageSize.ShrinkOnly). + [ObservableProperty] private bool _shrinkOnly; /// With MinShortEdge: the shortest the shorter edge may be (ImageOptions.MinShortEdge). [ObservableProperty] private int _minShortEdge; [ObservableProperty] private bool _setDpi; @@ -506,11 +754,30 @@ public bool ColorProfileChecked [ObservableProperty] private bool _webpLossless; [ObservableProperty] private bool _multiPageTiff; [ObservableProperty] private string _icoSizes; - [ObservableProperty] private bool _merge; + [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowAskPages), nameof(ShowAskPagesHint))] + private bool _merge; [ObservableProperty] private double _marginMm; [ObservableProperty] private int _renderDpi; [ObservableProperty] private string _pageRange; - [ObservableProperty] private bool _splitPages; + + /// "Ask every time" for the pages (PdfOptions.AskPageRange, #981). + [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowAskPagesHint))] + private bool _askPageRange; + + /// One PDF, one file per page or several pages per file (PdfOptions.SplitPages, SplitEvery, SplitParts). + [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowPageGroups))] + private Choice _pageOutput; + + partial void OnPageOutputChanged(Choice? oldValue, Choice newValue) => + Choices.Restore(oldValue, newValue, v => PageOutput = v); + + /// With several pages per file: the number is the pages per file, or the equal parts. + [ObservableProperty] private Choice _groupKind; + + partial void OnGroupKindChanged(Choice? oldValue, Choice newValue) => + Choices.Restore(oldValue, newValue, v => GroupKind = v); + + [ObservableProperty] private int _groupSize; [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowPdfCompressionOptions))] private Choice _compressionLevel; @@ -536,7 +803,7 @@ partial void OnPdfPasswordChanged(string value) /// Recognize the text of scans and pictures (PdfOptions.Ocr), in . [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowPdfSplit), nameof(ShowPdfCompression), nameof(ShowPdfCompressionOptions), nameof(ShowPageSize))] + [NotifyPropertyChangedFor(nameof(ShowPdfSplit), nameof(ShowPageGroups), nameof(ShowPdfCompression), nameof(ShowPdfCompressionOptions), nameof(ShowPageSize))] private bool _ocr; /// @@ -544,7 +811,72 @@ partial void OnPdfPasswordChanged(string value) /// file per page, compression (and with it removing metadata), page size and margin don't apply and are hidden. /// private bool Recognizes => ShowOcr && Ocr; - [ObservableProperty] private Choice _ocrLanguage; + [ObservableProperty] private OcrLanguageChoice _ocrLanguage; + + /// Picked in the "add a language" list, until adds it. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(AddOcrLanguageCommand))] + private OcrLanguageChoice? _ocrLanguageToAdd; + + public bool HasMoreOcrLanguages => MoreOcrLanguages.Count > 0; + + [RelayCommand(CanExecute = nameof(CanAddOcrLanguage))] + private void AddOcrLanguage() + { + if (OcrLanguageToAdd is not { } language) + return; + OcrLanguageToAdd = null; + MoreOcrLanguages.Add(language); + } + + private bool CanAddOcrLanguage() => OcrLanguageToAdd is not null; + + [RelayCommand] + private void RemoveOcrLanguage(OcrLanguageChoice language) => MoreOcrLanguages.Remove(language); + + partial void OnOcrLanguageChanged(OcrLanguageChoice? oldValue, OcrLanguageChoice newValue) + { + if (Choices.Restore(oldValue, newValue, v => OcrLanguage = v)) + return; + // An added language made the main one trades places with it: the text still has the old main language. + if (oldValue is not null && MoreOcrLanguages.IndexOf(newValue) is >= 0 and var index) + MoreOcrLanguages[index] = oldValue; + RefreshOcrLanguagesToAdd(); + } + + partial void OnOcrLanguageToAddChanged(OcrLanguageChoice? value) => ShowOcrDownloads(); + + private void RefreshOcrLanguagesToAdd() + { + // In place, not cleared and filled again: a combo box drops its selection with its items, and a language picked + // but not added yet stays picked while it is still in the list. + var available = OcrLanguageChoices.Where(l => l != OcrLanguage && !MoreOcrLanguages.Contains(l)).ToList(); + for (var i = OcrLanguagesToAdd.Count - 1; i >= 0; i--) + { + if (!available.Contains(OcrLanguagesToAdd[i])) + OcrLanguagesToAdd.RemoveAt(i); + } + for (var i = 0; i < available.Count; i++) + { + if (i == OcrLanguagesToAdd.Count || OcrLanguagesToAdd[i] != available[i]) + OcrLanguagesToAdd.Insert(i, available[i]); + } + if (OcrLanguageToAdd is { } picked && !available.Contains(picked)) + OcrLanguageToAdd = null; + OnPropertyChanged(nameof(HasMoreOcrLanguages)); + ShowOcrDownloads(); + } + + /// + /// One language Apple Vision doesn't read sends all the chosen ones to Tesseract, which needs the data of each + /// (VisionConverter): then the languages Vision reads show their download too. Off a Mac that is always so. + /// + private void ShowOcrDownloads() + { + var tesseractReads = !OcrLanguage.VisionReads || MoreOcrLanguages.Any(l => !l.VisionReads) || OcrLanguageToAdd is { VisionReads: false }; + foreach (var language in OcrLanguageChoices) + language.ShowDownload(tesseractReads); + } /// PDF → image: one tall image of all pages. [ObservableProperty] private bool _combinePages; @@ -566,8 +898,38 @@ partial void OnPdfPasswordChanged(string value) [ObservableProperty] private bool _zipResult; /// The result takes the original's place, which goes to the Recycle Bin / Trash (OutputRule.ReplaceOriginal). [ObservableProperty] private bool _replaceOriginal; + /// Copy to folder: the files are moved instead of copied (OutputRule.MoveInsteadOfCopy, #856). + [ObservableProperty, NotifyPropertyChangedFor(nameof(CopyHint), nameof(CopyAction))] + private bool _moveInsteadOfCopy; + + /// Copy to folder's Action: copy (the default) or move. + public IReadOnlyList> CopyActions => + [new(false, _loc["presets.copy_to_folder"]), new(true, _loc["presets.move_to_folder"])]; + + public Choice CopyAction + { + get => CopyActions[MoveInsteadOfCopy ? 1 : 0]; + set + { + if (value is not null) + MoveInsteadOfCopy = value.Value; + } + } + + /// What Copy to folder does with the originals, as the box below it is set. + public string CopyHint => _loc[MoveInsteadOfCopy ? "presets.move_hint" : "presets.copy_hint"]; [ObservableProperty] private Choice _mediaQuality; [ObservableProperty] private Choice _maxHeight; + [ObservableProperty] private Choice _cropFrame; + + /// "Pages per sheet" (#737), for the presets that make sheets (). + [ObservableProperty] private Choice _pagesPerSheet; + [ObservableProperty] private Choice _sheetPaper; + [ObservableProperty] private Choice _sheetOrientation; + [ObservableProperty] private Choice _sheetOrder; + [ObservableProperty] private double _sheetMarginMm; + [ObservableProperty] private double _sheetGapMm; + [ObservableProperty] private bool _sheetBorders; [ObservableProperty] private Choice _audioBitrate; [ObservableProperty] private Choice _audioChannels; [ObservableProperty] private Choice _sampleRate; @@ -607,7 +969,25 @@ partial void OnPdfPasswordChanged(string value) [ObservableProperty] private Choice _animationSpeed; // A combo box that loses its selection sets null: every choice keeps its value (see Choices.Restore). - partial void OnTargetChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Target = v); + partial void OnTargetChanged(Choice? oldValue, Choice newValue) + { + Choices.Restore(oldValue, newValue, v => Target = v); + OnPropertyChanged(nameof(TargetSelection)); + } + + partial void OnVideoSizeUnitChoiceChanged(Choice? oldValue, Choice newValue) + { + if (Choices.Restore(oldValue, newValue, v => VideoSizeUnitChoice = v) || oldValue is null || oldValue.Value == newValue.Value) + return; + var toPercent = newValue.Value == VideoSizeUnit.Percent; + if (!toPercent) + OnPropertyChanged(nameof(VideoSizeMaximum)); + OnPropertyChanged(nameof(VideoSizeIncrement)); + OnPropertyChanged(nameof(VideoSizeFormat)); + OnPropertyChanged(nameof(VideoMaxSize)); + if (toPercent) + OnPropertyChanged(nameof(VideoSizeMaximum)); + } /// /// Both ends keep their size in the other unit: 500 KB becomes 0.5 MB. The boxes' maximum is raised before the @@ -638,6 +1018,12 @@ partial void OnFitsTiffModeChanged(Choice? oldValue, Choice FitsTiffMode = v); partial void OnMediaQualityChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => MediaQuality = v); partial void OnMaxHeightChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => MaxHeight = v); + partial void OnCropFrameChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => CropFrame = v); + partial void OnPagesPerSheetChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => PagesPerSheet = v); + partial void OnSheetPaperChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => SheetPaper = v); + partial void OnSheetOrientationChanged(Choice? oldValue, Choice newValue) => + Choices.Restore(oldValue, newValue, v => SheetOrientation = v); + partial void OnSheetOrderChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => SheetOrder = v); partial void OnAudioBitrateChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => AudioBitrate = v); partial void OnAudioChannelsChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => AudioChannels = v); partial void OnSampleRateChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => SampleRate = v); @@ -692,6 +1078,14 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne /// Page size and margin of a PDF built from pictures; not with text recognition (). public bool ShowPageSize => ShowPdfBuild && !Recognizes; public bool ShowPdfSplit => TargetId == "pdf" && !Recognizes; + public bool ShowPageGroups => ShowPdfSplit && PageOutput.Value == ViewModels.PageOutput.Groups; + + /// + /// "Ask every time" under Pages: wherever the pages are, except a merge, where one range would go for every file + /// (AskedPages.Asks). + /// + public bool ShowAskPages => ShowPdf && !(TargetId == "pdf" && Merge); + public bool ShowAskPagesHint => ShowAskPages && AskPageRange; /// Compression is for PDF → PDF; Ghostscript and metadata only matter while it is on. public bool ShowPdfCompression => TargetId == "pdf" && !Recognizes; @@ -709,7 +1103,7 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne public bool ShowDocument => TargetId == "pdf"; /// Markdown can't hold pictures: they go into a folder next to it, or are left out. public bool ShowLeaveOutPictures => TargetId == "md"; - public bool ShowMedia => ShowMediaQuality || ShowMaxHeight || ShowRemoveAudio || ShowAudioChannels || ShowVideoCodec || ShowAudioCodec || ShowCaption; + public bool ShowMedia => ShowCropFrame || ShowMediaQuality || ShowMaxHeight || ShowRemoveAudio || ShowAudioChannels || ShowVideoCodec || ShowAudioCodec || ShowCaption; /// Mono / stereo for every audio target but AMR, which is mono by definition. public bool ShowAudioChannels => TargetIsAudio && TargetId != "amr"; /// Sample rate and sample format: the PCM of WAV and W64 takes any. @@ -718,19 +1112,25 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne public bool ShowVideoCodec => TargetIsVideo && VideoCodecs.Count > 1; /// Hardware encoding applies to H.264, HEVC and AV1 only (not VP9, MPEG-2, …). public bool ShowHardwareEncoding => TargetIsVideo && MediaCodecs.TakesHardwareEncoding(TargetId, _videoCodecValue); - /// Generate Whisper AI subtitles for speech. - public bool ShowCaption => TargetId == "srt"; + /// Generate Whisper AI subtitles for speech, or its text alone for TXT. + public bool ShowCaption => MediaOptions.CaptionTargets.Contains(TargetId); /// M4A / M4B can hold Apple Lossless besides AAC. public bool ShowAudioCodec => AudioCodecs.Count > 1; public bool ShowMediaQuality => TargetIsLossyMedia; public bool ShowMaxHeight => TargetIsVideo; + /// "Crop video" presets (#830), which keep the video's own format. + public bool ShowCropFrame => _original.CropsVideo; + + /// "Pages per sheet": shown for the presets that put several PDF pages on a sheet, while they write a PDF. + public bool ShowSheets => ShowPdfBuild && _original.Pdf.MakesSheets; /// A size limit for a converted video; a clip is cut, not fitted. public bool ShowVideoMaxSize => TargetCategory == FormatCategory.Video && !_original.IsClipTool // Not where nothing fits it: a join or slideshow, or VOB and DV, which have a fixed bitrate. && !_original.Media.JoinIntoOne && !_isSlideshow && TargetId is not ("vob" or "dv"); public bool ShowAudioBitrate => TargetIsLossyMedia; public bool ShowRemoveAudio => TargetIsVideo; - public bool ShowArchive => TargetCategory == FormatCategory.Archive; + /// ISO creation has no archive compression settings. + public bool ShowArchive => TargetCategory == FormatCategory.Archive && TargetId != "iso"; public bool ShowArchiveOptions => ShowArchive && TargetId != FormatRegistry.Folder; /// "Extract" has nothing to set; a line says what it does instead. public bool ShowExtractNote => TargetId == FormatRegistry.Folder; @@ -741,10 +1141,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 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. @@ -770,7 +1170,14 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne private static double SizeLimit(decimal? megabytes) => (double)Math.Clamp(megabytes ?? 0, 0, 4096); public string? NameError => string.IsNullOrWhiteSpace(Name) ? _loc["presets.name_required"] : null; - public bool IsValid => NameError is null && SizeRangeError is null && DestinationError is null; + public bool IsValid => NameError is null && SizeRangeError is null && DestinationError is null && CustomSizeError is null; + + /// + /// The width × height boxes, while shown: a stretch needs both sides, a box that keeps the aspect at least one. + /// + public string? CustomSizeError => ShowImage && ShowCustom && (LockAspect ? CustomWidth is null && CustomHeight is null : CustomWidth is null || CustomHeight is null) + ? _loc["presets.custom_size_missing"] + : null; /// A size from the range's boxes in megabytes. private decimal? Megabytes(decimal? size) => ImageSizeUnit.Value == FileSizeUnit.KB ? size / 1000 : size; @@ -791,13 +1198,20 @@ public bool Apply() // Only where the field is shown, as with SaveEveryFrame below: a limit left behind by a JPG target fits nothing. image.MaxSizeMb = ShowQuality ? SizeLimit(Megabytes(ImageMaxSize)) : 0; image.MinSizeMb = ShowImageSize ? SizeLimit(Megabytes(ImageMinSize)) : 0; + image.MaxSizeKeepsPixels = image.MaxSizeMb > 0 && MaxSizeKeepsPixels; if (Resize.Value == ResizeMode.Custom) { // Custom sizing is stored as CustomSize with Resize None, never as a resize name an older version rejects // (it would read presets.json as broken and fall back to the built-in presets, and the next save would // write those over the user's own; issue #576). image.Resize = ResizeMode.None; - image.CustomSize = new CustomImageSize { Width = Math.Clamp(CustomWidth, CustomImageSize.MinSide, CustomImageSize.MaxSide), Height = Math.Clamp(CustomHeight, CustomImageSize.MinSide, CustomImageSize.MaxSide), LockAspect = LockAspect }; + // An empty side is free with the aspect kept (#1270). The card can be hidden for another target, where a box + // nobody can fix falls back to the defaults instead of blocking the save. + var (width, height) = (CustomWidth, CustomHeight); + if (!LockAspect || width is null && height is null) + (width, height) = (width ?? 1920, height ?? 1080); + static int Side(int? side) => side is { } px ? Math.Clamp(px, CustomImageSize.MinSide, CustomImageSize.MaxSide) : CustomImageSize.FreeSide; + image.CustomSize = new CustomImageSize { Width = Side(width), Height = Side(height), LockAspect = LockAspect, ShrinkOnly = LockAspect && ShrinkOnly }; image.MinShortEdge = null; } else if (Resize.Value == ResizeMode.MinShortEdge) @@ -849,12 +1263,27 @@ public bool Apply() pdf.MarginMm = Math.Clamp(MarginMm, 0, 100); pdf.RenderDpi = Math.Clamp(RenderDpi, 36, 1200); pdf.PageRange = PageRange.Trim(); - pdf.SplitPages = SplitPages; + pdf.AskPageRange = ShowAskPages && AskPageRange; + var (split, every, parts) = PageOutputs.Settings(PageOutput.Value, GroupKind.Value, GroupSize); + pdf.SplitPages = split; + // One PDF keeps the numbers of a split, as the pages window does, for when it is chosen again. + if (split) + (pdf.SplitEvery, pdf.SplitParts) = (every, parts); pdf.CombinePages = CombinePages; pdf.TrimPageMargins = TrimPageMargins; pdf.SplitTallImage = SplitTallImage; pdf.Compression = CompressionLevel.Value; pdf.CompressWithGhostscript = CompressWithGhostscript; + if (ShowSheets) + { + pdf.PagesPerSheet = PagesPerSheet.Value; + pdf.SheetPaper = SheetPaper.Value; + pdf.SheetOrientation = SheetOrientation.Value; + pdf.SheetOrder = SheetOrder.Value; + pdf.SheetMarginMm = Math.Clamp(SheetMarginMm, 0, 50); + pdf.SheetGapMm = Math.Clamp(SheetGapMm, 0, 50); + pdf.SheetBorders = SheetBorders; + } // Only for a PDF target, where they are shown: a password left behind by it must not lock nothing. pdf.Watermark = ShowPdfBuild ? Watermark.Trim() : ""; pdf.PageNumbers = ShowPdfBuild && PageNumbers; @@ -863,7 +1292,8 @@ public bool Apply() pdf.PasswordLeftOut = false; // Only where the option is shown, as with SaveEveryFrame below. pdf.Ocr = Recognizes; - pdf.OcrLanguage = OcrLanguage.Value; + AddOcrLanguage(); // picked in the list without pressing Add: it looks chosen, so it counts + pdf.OcrLanguage = OcrLanguages.Join([OcrLanguage.Value, .. MoreOcrLanguages.Select(l => l.Value)]); if (pdf.Ocr) { // Hidden while text is recognized, so nothing is kept behind it that the PDF wouldn't do (Recognizes). @@ -880,7 +1310,26 @@ public bool Apply() var media = _original.Media; media.Quality = MediaQuality.Value; media.MaxHeight = MaxHeight.Value; - media.MaxSizeMb = ShowVideoMaxSize ? SizeLimit(VideoMaxSizeMb) : 0; + if (ShowCropFrame) + media.CropFrame = CropFrame.Value; + if (ShowVideoMaxSize) + { + if (VideoSizeUnitChoice.Value == VideoSizeUnit.Percent) + { + media.MaxSizeMb = 0; + media.MaxSizePercent = VideoMaxSizePercent is > 0 and < 100 ? (int)Math.Clamp(VideoMaxSizePercent.Value, 1, 99) : 0; + } + else + { + media.MaxSizePercent = 0; + media.MaxSizeMb = SizeLimit(VideoMaxSizeMb); + } + } + else + { + media.MaxSizeMb = 0; + media.MaxSizePercent = 0; + } media.AudioBitrateKbps = AudioBitrate.Value; media.RemoveAudio = RemoveAudio; media.AudioChannels = AudioChannels.Value; @@ -910,7 +1359,7 @@ static double Seconds(double value, double fallback, double min, double max) => media.LivePhotoLength = Seconds(LivePhotoLength, 3, MediaOptions.MinLivePhotoLength, MediaOptions.MaxLivePhotoLength); media.LivePhotoCover = Seconds(LivePhotoCover, 1.5, 0, MediaOptions.MaxLivePhotoLength); _original.Archive.Level = ArchiveLevel.Value; - _original.Archive.CombineIntoOne = CombineIntoOne; + _original.Archive.CombineIntoOne = TargetId != "iso" && CombineIntoOne; _original.Trace.Mode = TraceMode.Value; _original.Trace.Colors = Math.Clamp(TraceColors, TracingOptions.MinColors, TracingOptions.MaxColors); _original.Trace.Detail = Math.Clamp(TraceDetail, 0, 100); @@ -918,6 +1367,14 @@ static double Seconds(double value, double fallback, double min, double max) => // black and white chosen for PDF and left behind by a DWG target. _original.Cad.Version = ShowCad ? CadVersion.Value : default; _original.Cad.Colors = ShowCadColors ? CadColors.Value : default; + _original.Translate.TargetLanguage = TargetLanguage.Value; + _original.Translate.Enabled = ShowTranslate; + if (ShowUpscale) + { + _original.Upscale.Enabled = true; + _original.Upscale.Model = SelectedUpscaleModel?.Id; + _original.Upscale.Scale = UpscaleScale; + } var output = _original.Output; // Asking keeps the location it had: a Filee that can't ask (or one from before it) saves there. @@ -933,7 +1390,8 @@ static double Seconds(double value, double fallback, double min, double max) => output.KeepDates = KeepDates; // Only for geospatial targets, where it is offered: a box ticked for Shapefile must not zip a later PNG. output.ZipResult = ShowZipResult && ZipResult; - output.ReplaceOriginal = !IsCopyToFolder && ReplaceOriginal; + output.ReplaceOriginal = ShowReplaceOriginal && ReplaceOriginal; + output.MoveInsteadOfCopy = IsCopyToFolder && MoveInsteadOfCopy; return true; } From ff452ed99d79709cfd2c5010a86332962ddc6274 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Tue, 10 Nov 2026 20:11:47 +0000 Subject: [PATCH 4/7] Add files via upload --- tests/Filee.Core.Tests/FormatFieldsTests.cs | 29 ++--- tests/Filee.Core.Tests/ScriptTextTests.cs | 113 +++++++++++++++++++- 2 files changed, 125 insertions(+), 17 deletions(-) diff --git a/tests/Filee.Core.Tests/FormatFieldsTests.cs b/tests/Filee.Core.Tests/FormatFieldsTests.cs index fd61ce6eb..069c735a9 100644 --- a/tests/Filee.Core.Tests/FormatFieldsTests.cs +++ b/tests/Filee.Core.Tests/FormatFieldsTests.cs @@ -28,10 +28,11 @@ 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", - "hdr", "htmlz", "icns", "ipynb", "jar", "jp2", "jsonl", "kml", "kmz", "lha", "lit", "lrf", "lwp", "lz", - "lzma", "m4s", "man", "mcw", "mediawiki", "miff", "mng", "mobi", "mod", "mp2", "mpc", "mxf", "obj", "oeb", - "org", "otf", "pam", "pcd", "pcx", "pdb", "pfm", "plt", "ply", "pml", "ppm", "prc", "ps", "psb", "psd", + "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", + "org", "otf", "pam", "pcd", "pcx", "pdb", "pfm", "plist", "plt", "ply", "pml", "ppm", "prc", "ps", "psb", "psd", "psw", "qoi", "ras", "raw", "rb", "rm", "rpm", "rst", "scc", "sda", "sdc", "sdw", "sgi", "shp", "snb", "spx", "ssa", "stl", "sub", "swf", "tak", "tar", "tar7z", "tbz2", "tcr", "tex", "textile", "tgz", "tlz", "toml", "tta", "ttf", "ttml", "txtz", "txz", "typ", "tz", "usm", "vob", "voc", "w64", "wbmp", "wmf", "woff", "woff2", @@ -44,12 +45,12 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "model3d", ["fbx", "glb", "gltf", "ply"] }, { "geo", ["fit", "geojson", "gpx", "kml", "kmz", "shp"] }, { "font", ["otf", "ttf", "woff", "woff2"] }, - { "game", ["bik", "dds", "usm"] }, - { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "plt", "ps", "psb", "psd", "raw", "xcf"] }, + { "game", ["bik", "dds", "mcaddon", "mcpack", "mctemplate", "mcworld", "mrpack", "usm"] }, + { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "kra", "plt", "ps", "psb", "psd", "raw", "xcf"] }, { "science", ["dcm", "fits"] }, { "developer", [ "bz2", "cab", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", "icns", "jar", - "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "ppm", "qoi", "rpm", "tar", "tar7z", + "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "plist", "ppm", "qoi", "rpm", "tar", "tar7z", "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml" ] }, { "writing", ["adoc", "cgm", "dbk", "htmlz", "mediawiki", "org", "rst", "tex", "textile", "txtz", "typ"] }, @@ -71,12 +72,12 @@ 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", "tga", "usm"] }, - { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "jp2", "pcd", "plt", "ps", "psb", "psd", "raw", "xcf"] }, + { "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", [ "bz2", "cab", "caf", "chm", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", - "icns", "ipynb", "jar", "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "ppm", "qoi", + "icns", "ipynb", "jar", "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "plist", "ppm", "qoi", "rpm", "tar", "tar7z", "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml" ] }, { "writing", ["adoc", "cgm", "chm", "dbk", "htmlz", "mediawiki", "org", "rst", "tex", "textile", "txtz", "typ"] }, @@ -85,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" @@ -143,9 +144,9 @@ public void Grade_counts_match_the_published_table() new Dictionary { [FormatGrade.Everyday] = 19, - [FormatGrade.General] = 94, - [FormatGrade.Specialist] = 107, - [FormatGrade.Legacy] = 50, + [FormatGrade.General] = 97, + [FormatGrade.Specialist] = 114, + [FormatGrade.Legacy] = 51, }, counts); } diff --git a/tests/Filee.Core.Tests/ScriptTextTests.cs b/tests/Filee.Core.Tests/ScriptTextTests.cs index df50b27c2..0ed4615f0 100644 --- a/tests/Filee.Core.Tests/ScriptTextTests.cs +++ b/tests/Filee.Core.Tests/ScriptTextTests.cs @@ -1,6 +1,8 @@ -// Lua, PowerShell, AutoHotkey and batch scripts, SQL, INI files, RouterOS exports, CSS style sheets and C, C++ and C# -// source files are plain text (issues #388, #368, #442, #607, #536, #630, #661, #660, #864, #828): recognised, and -// learnt by existing text donuts. +// Lua, PowerShell, AutoHotkey and batch scripts, SQL and HiveQL, DBML database schemas, INI files, RouterOS exports, CSS +// style sheets, Mermaid diagrams and C, C++, C#, Dart, Java, Rust, Go, Kotlin, Swift, Scala, SCSS, Vue and TSX source +// files are plain text (issues #388, #368, #442, #607, #536, #630, #661, #660, #864, #828, #980, #997, #854, #1176, +// #1309): recognised, and learnt by existing text donuts. UDF disc images (#1338) and iPhone ringtones (#1291) are +// recognised too. using Filee.Core.Formats; using Filee.Core.Presets; @@ -29,6 +31,21 @@ public class ScriptTextTests [InlineData("vector.hh")] [InlineData("vector.hxx")] [InlineData("MyBot.cs")] + [InlineData("main.dart")] + [InlineData("daily_report.HQL")] + [InlineData("shop.dbml")] + [InlineData("HelloWorld.java")] + [InlineData("Legacy.JAVA")] + [InlineData("flow.mermaid")] + [InlineData("flow.MMD")] + [InlineData("main.rs")] + [InlineData("server.go")] + [InlineData("App.kt")] + [InlineData("View.swift")] + [InlineData("Main.scala")] + [InlineData("theme.scss")] + [InlineData("Home.vue")] + [InlineData("Button.tsx")] public void Scripts_and_style_sheets_are_text(string name) => Assert.Equal("txt", FormatRegistry.Detect(name)?.Id); [Fact] @@ -97,4 +114,94 @@ public void Version_57_text_donuts_learn_c_cpp_and_cs_without_taking_custom_clai Assert.DoesNotContain("cs", text); // the user's own donut has it Assert.False(SettingsMigrations.ApplyToLibrary(57, presets, profiles)); } + + [Fact] + public void Version_60_donuts_learn_dart_hql_and_kra_without_taking_custom_claims() + { + string[] added = ["dart", "hql", "kra"]; + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.RemoveAll(added.Contains); + profiles.Insert(0, new ToolbarProfile { Id = "my-hive", Name = "Hive", Extensions = ["hql"] }); + + Assert.True(SettingsMigrations.ApplyToLibrary(60, presets, profiles)); + + var text = profiles.Single(p => p.Id == "text").Extensions; + Assert.Contains("dart", text); + Assert.DoesNotContain("hql", text); // the user's own donut has it + Assert.Contains("kra", profiles.Single(p => p.Id == "images").Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(60, presets, profiles)); + } + + [Fact] + public void Version_64_text_donuts_learn_dbml_without_taking_custom_claims() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var text = profiles.Single(p => p.Id == "text").Extensions; + text.Remove("dbml"); + + Assert.True(SettingsMigrations.ApplyToLibrary(64, presets, profiles)); + Assert.Contains("dbml", text); + Assert.False(SettingsMigrations.ApplyToLibrary(64, presets, profiles)); + + text.Remove("dbml"); + profiles.Insert(0, new ToolbarProfile { Id = "my-schemas", Name = "Schemas", Extensions = ["dbml"] }); + Assert.False(SettingsMigrations.ApplyToLibrary(64, presets, profiles)); + Assert.DoesNotContain("dbml", text); // the user's own donut has it + } + + [Fact] + public void A_java_file_uses_the_text_donut() => + Assert.Equal("text", ProfileSelector.Select(BuiltInData.CreateProfiles(), ["HelloWorld.java"])?.Id); + + [Fact] + public void Version_70_text_donuts_learn_java_without_taking_custom_claims() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var text = profiles.Single(p => p.Id == "text").Extensions; + text.Remove("java"); + + Assert.True(SettingsMigrations.ApplyToLibrary(70, presets, profiles)); + Assert.Contains("java", text); + Assert.False(SettingsMigrations.ApplyToLibrary(70, presets, profiles)); + + text.Remove("java"); + profiles.Insert(0, new ToolbarProfile { Id = "my-java", Name = "Java", Extensions = ["JAVA"] }); + Assert.False(SettingsMigrations.ApplyToLibrary(70, presets, profiles)); + Assert.DoesNotContain("java", text); // the user's own donut has it + } + + [Theory] + [InlineData("movie.udf", "udf", "archive", false)] + [InlineData("Ringtone.M4R", "m4r", "audio", true)] + public void Disc_images_and_ringtones_are_recognised(string name, string id, string category, bool writable) + { + var format = FormatRegistry.Detect(name); + Assert.Equal(id, format?.Id); + Assert.Equal(category, format?.Category.ToString().ToLowerInvariant()); + Assert.Equal(writable, format?.Writable); + } + + [Fact] + public void Version_76_donuts_learn_the_new_formats_without_taking_custom_claims() + { + string[] text = ["mermaid", "mmd", "rs", "go", "kt", "swift", "scala", "scss", "vue", "tsx"]; + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.RemoveAll(e => e is "udf" or "m4r" || text.Contains(e)); + profiles.Insert(0, new ToolbarProfile { Id = "my-rust", Name = "Rust", Extensions = ["rs"] }); + + Assert.True(SettingsMigrations.ApplyToLibrary(76, presets, profiles)); + + var learnt = profiles.Single(p => p.Id == "text").Extensions; + Assert.All(text.Where(e => e != "rs"), e => Assert.Contains(e, learnt)); + Assert.DoesNotContain("rs", learnt); // the user's own donut has it + Assert.Contains("udf", profiles.Single(p => p.Id == "archives").Extensions); + Assert.Contains("m4r", profiles.Single(p => p.Id == "audio").Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(76, presets, profiles)); + } } From d82fa57136cacfb3980f4772ef7f6200685d1385 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Tue, 10 Nov 2026 20:12:47 +0000 Subject: [PATCH 5/7] Add files via upload --- tests/Filee.Engines.Tests/ScriptTextTests.cs | 212 +++++++++++++++++-- 1 file changed, 196 insertions(+), 16 deletions(-) diff --git a/tests/Filee.Engines.Tests/ScriptTextTests.cs b/tests/Filee.Engines.Tests/ScriptTextTests.cs index 80c651c17..0ed4615f0 100644 --- a/tests/Filee.Engines.Tests/ScriptTextTests.cs +++ b/tests/Filee.Engines.Tests/ScriptTextTests.cs @@ -1,27 +1,207 @@ -// Lua scripts and CSS style sheets (issues #388, #368) become a PDF of their text, never run. +// Lua, PowerShell, AutoHotkey and batch scripts, SQL and HiveQL, DBML database schemas, INI files, RouterOS exports, CSS +// style sheets, Mermaid diagrams and C, C++, C#, Dart, Java, Rust, Go, Kotlin, Swift, Scala, SCSS, Vue and TSX source +// files are plain text (issues #388, #368, #442, #607, #536, #630, #661, #660, #864, #828, #980, #997, #854, #1176, +// #1309): recognised, and learnt by existing text donuts. UDF disc images (#1338) and iPhone ringtones (#1291) are +// recognised too. -using Filee.Core.Conversion; +using Filee.Core.Formats; using Filee.Core.Presets; -using Filee.Engines.Pdf; +using Filee.Core.Profiles; +using Filee.Core.Settings; -namespace Filee.Engines.Tests; +namespace Filee.Core.Tests; -public class ScriptTextTests(EngineFixture fx) : IClassFixture +public class ScriptTextTests { [Theory] - [InlineData("site.css", "body { color: #1d9e75; }")] - [InlineData("init.lua", "local greeting = \"hello\" print(greeting)")] - public async Task The_code_becomes_a_pdf_of_its_text(string name, string code) + [InlineData("init.lua")] + [InlineData("site.css")] + [InlineData("Backup.ps1")] + [InlineData("Hotkeys.AHK")] + [InlineData("desktop.ini")] + [InlineData("router-export.rsc")] + [InlineData("install.cmd")] + [InlineData("query.sql")] + [InlineData("main.c")] + [InlineData("stdio.h")] + [InlineData("Widget.CPP")] + [InlineData("parser.cc")] + [InlineData("parser.cxx")] + [InlineData("vector.hpp")] + [InlineData("vector.hh")] + [InlineData("vector.hxx")] + [InlineData("MyBot.cs")] + [InlineData("main.dart")] + [InlineData("daily_report.HQL")] + [InlineData("shop.dbml")] + [InlineData("HelloWorld.java")] + [InlineData("Legacy.JAVA")] + [InlineData("flow.mermaid")] + [InlineData("flow.MMD")] + [InlineData("main.rs")] + [InlineData("server.go")] + [InlineData("App.kt")] + [InlineData("View.swift")] + [InlineData("Main.scala")] + [InlineData("theme.scss")] + [InlineData("Home.vue")] + [InlineData("Button.tsx")] + public void Scripts_and_style_sheets_are_text(string name) => Assert.Equal("txt", FormatRegistry.Detect(name)?.Id); + + [Fact] + public void Version_33_text_donuts_learn_them_without_taking_custom_claims() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.RemoveAll(e => e is "lua" or "css"); + profiles.Insert(0, new ToolbarProfile { Id = "my-web", Name = "Web", Extensions = ["css"] }); + + Assert.True(SettingsMigrations.ApplyToLibrary(33, presets, profiles)); + + var text = profiles.Single(p => p.Id == "text").Extensions; + Assert.Contains("lua", text); + Assert.DoesNotContain("css", text); // the user's own donut has it + Assert.False(SettingsMigrations.ApplyToLibrary(33, presets, profiles)); + } + + [Fact] + public void Version_38_text_donuts_learn_powershell_scripts() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.Remove("ps1"); + + Assert.True(SettingsMigrations.ApplyToLibrary(38, presets, profiles)); + + Assert.Contains("ps1", profiles.Single(p => p.Id == "text").Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(39, presets, profiles)); + } + + [Fact] + public void Version_48_text_donuts_learn_sql_ahk_ini_rsc_and_cmd_without_taking_custom_claims() + { + string[] added = ["sql", "ahk", "ini", "rsc", "cmd"]; + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.RemoveAll(added.Contains); + profiles.Insert(0, new ToolbarProfile { Id = "my-router", Name = "Router", Extensions = ["rsc"] }); + + Assert.True(SettingsMigrations.ApplyToLibrary(48, presets, profiles)); + + var text = profiles.Single(p => p.Id == "text").Extensions; + Assert.All(added.Where(e => e != "rsc"), e => Assert.Contains(e, text)); + Assert.DoesNotContain("rsc", text); // the user's own donut has it + Assert.False(SettingsMigrations.ApplyToLibrary(48, presets, profiles)); + } + + [Fact] + public void Version_57_text_donuts_learn_c_cpp_and_cs_without_taking_custom_claims() + { + string[] added = ["c", "h", "cpp", "cc", "cxx", "hpp", "hh", "hxx", "cs"]; + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.RemoveAll(added.Contains); + profiles.Insert(0, new ToolbarProfile { Id = "my-trading", Name = "cTrader", Extensions = ["cs"] }); + + Assert.True(SettingsMigrations.ApplyToLibrary(57, presets, profiles)); + + var text = profiles.Single(p => p.Id == "text").Extensions; + Assert.All(added.Where(e => e != "cs"), e => Assert.Contains(e, text)); + Assert.DoesNotContain("cs", text); // the user's own donut has it + Assert.False(SettingsMigrations.ApplyToLibrary(57, presets, profiles)); + } + + [Fact] + public void Version_60_donuts_learn_dart_hql_and_kra_without_taking_custom_claims() + { + string[] added = ["dart", "hql", "kra"]; + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.RemoveAll(added.Contains); + profiles.Insert(0, new ToolbarProfile { Id = "my-hive", Name = "Hive", Extensions = ["hql"] }); + + Assert.True(SettingsMigrations.ApplyToLibrary(60, presets, profiles)); + + var text = profiles.Single(p => p.Id == "text").Extensions; + Assert.Contains("dart", text); + Assert.DoesNotContain("hql", text); // the user's own donut has it + Assert.Contains("kra", profiles.Single(p => p.Id == "images").Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(60, presets, profiles)); + } + + [Fact] + public void Version_64_text_donuts_learn_dbml_without_taking_custom_claims() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var text = profiles.Single(p => p.Id == "text").Extensions; + text.Remove("dbml"); + + Assert.True(SettingsMigrations.ApplyToLibrary(64, presets, profiles)); + Assert.Contains("dbml", text); + Assert.False(SettingsMigrations.ApplyToLibrary(64, presets, profiles)); + + text.Remove("dbml"); + profiles.Insert(0, new ToolbarProfile { Id = "my-schemas", Name = "Schemas", Extensions = ["dbml"] }); + Assert.False(SettingsMigrations.ApplyToLibrary(64, presets, profiles)); + Assert.DoesNotContain("dbml", text); // the user's own donut has it + } + + [Fact] + public void A_java_file_uses_the_text_donut() => + Assert.Equal("text", ProfileSelector.Select(BuiltInData.CreateProfiles(), ["HelloWorld.java"])?.Id); + + [Fact] + public void Version_70_text_donuts_learn_java_without_taking_custom_claims() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var text = profiles.Single(p => p.Id == "text").Extensions; + text.Remove("java"); + + Assert.True(SettingsMigrations.ApplyToLibrary(70, presets, profiles)); + Assert.Contains("java", text); + Assert.False(SettingsMigrations.ApplyToLibrary(70, presets, profiles)); + + text.Remove("java"); + profiles.Insert(0, new ToolbarProfile { Id = "my-java", Name = "Java", Extensions = ["JAVA"] }); + Assert.False(SettingsMigrations.ApplyToLibrary(70, presets, profiles)); + Assert.DoesNotContain("java", text); // the user's own donut has it + } + + [Theory] + [InlineData("movie.udf", "udf", "archive", false)] + [InlineData("Ringtone.M4R", "m4r", "audio", true)] + public void Disc_images_and_ringtones_are_recognised(string name, string id, string category, bool writable) + { + var format = FormatRegistry.Detect(name); + Assert.Equal(id, format?.Id); + Assert.Equal(category, format?.Category.ToString().ToLowerInvariant()); + Assert.Equal(writable, format?.Writable); + } + + [Fact] + public void Version_76_donuts_learn_the_new_formats_without_taking_custom_claims() { - var available = fx.Converters.Where(c => fx.Catalog.StatusOf(c).IsAvailable).Select(c => c.Id).ToHashSet(); - Assert.SkipUnless(available.Contains("rhwp") || available.Contains("libreoffice"), "neither rhwp nor LibreOffice is available"); - var path = Path.Combine(fx.NewFolder(), name); - await File.WriteAllTextAsync(path, code, TestContext.Current.CancellationToken); + string[] text = ["mermaid", "mmd", "rs", "go", "kt", "swift", "scala", "scss", "vue", "tsx"]; + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.RemoveAll(e => e is "udf" or "m4r" || text.Contains(e)); + profiles.Insert(0, new ToolbarProfile { Id = "my-rust", Name = "Rust", Extensions = ["rs"] }); - var job = await fx.ConvertAsync([path], new Preset { TargetFormat = "pdf" }); + Assert.True(SettingsMigrations.ApplyToLibrary(76, presets, profiles)); - Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); - var text = PdfDocumentReader.PlainText(PdfDocumentReader.ReadText(Assert.Single(job.Outputs))); - Assert.Contains(code.Split(' ')[1], text); + var learnt = profiles.Single(p => p.Id == "text").Extensions; + Assert.All(text.Where(e => e != "rs"), e => Assert.Contains(e, learnt)); + Assert.DoesNotContain("rs", learnt); // the user's own donut has it + Assert.Contains("udf", profiles.Single(p => p.Id == "archives").Extensions); + Assert.Contains("m4r", profiles.Single(p => p.Id == "audio").Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(76, presets, profiles)); } } From 0779ee84bc5d4f3b58973468fc40525ba4ce1c2d Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Tue, 10 Nov 2026 20:13:26 +0000 Subject: [PATCH 6/7] Add files via upload --- tests/Filee.Engines.Tests/ArchiveTests.cs | 158 +++++- tests/Filee.Engines.Tests/MediaTests.cs | 574 +++++++++++++++++++++- 2 files changed, 729 insertions(+), 3 deletions(-) diff --git a/tests/Filee.Engines.Tests/ArchiveTests.cs b/tests/Filee.Engines.Tests/ArchiveTests.cs index 4dd7678a1..9a739d214 100644 --- a/tests/Filee.Engines.Tests/ArchiveTests.cs +++ b/tests/Filee.Engines.Tests/ArchiveTests.cs @@ -47,6 +47,7 @@ private async Task ConvertOkAsync(string source, Preset preset) [InlineData("tz")] [InlineData("iso")] [InlineData("dmg")] + [InlineData("udf")] [InlineData("cab")] [InlineData("rpm")] [InlineData("deb")] @@ -111,7 +112,11 @@ public async Task Zip_7z_tgz_tar_zip_round_trip_keeps_names_folders_contents_and ZipFile.ExtractToDirectory(current, check); Assert.Equal(expected, Snapshot(Path.Combine(check, "프로젝트"))); var time = File.GetLastWriteTime(Path.Combine(check, "프로젝트", "readme.txt")); - Assert.True(Math.Abs((time - KnownTime).TotalSeconds) <= 2, $"time {time} instead of {KnownTime}"); + // 7-Zip turns a ZIP's local times into UTC with Windows' current offset, which in a time zone with daylight + // saving time can be an hour off .NET's offset for that date (seen on a Pacific-time CI runner). + var off = Math.Abs((time - KnownTime).TotalSeconds); + var dstHour = TimeZoneInfo.Local.SupportsDaylightSavingTime && Math.Abs(off - 3600) <= 2; + Assert.True(off <= 2 || dstHour, $"time {time} instead of {KnownTime}"); } [Fact] @@ -316,6 +321,154 @@ public async Task Damaged_and_fake_archives_fail_cleanly() Assert.Equal(["fake.zip"], Directory.GetFileSystemEntries(dir).Select(Path.GetFileName)); } + [Fact] + public async Task Extract_into_an_existing_folder_refuses_a_link_inside_it_before_moving_anything() + { + SkipWithout7Zip(); + var dir = fx.NewFolder(); + var tree = MakeTree(dir, "data"); + var zip = Path.Combine(dir, "data.zip"); + ZipFile.CreateFromDirectory(tree, zip, CompressionLevel.Fastest, includeBaseDirectory: false, Encoding.UTF8); + Directory.Delete(tree, true); + var target = Directory.CreateDirectory(Path.Combine(dir, "data")).FullName; + await File.WriteAllTextAsync(Path.Combine(target, "readme.txt"), "old", Ct); + var elsewhere = Directory.CreateDirectory(Path.Combine(dir, "elsewhere")).FullName; + var link = Path.Combine(target, "docs"); // "docs/보고서/data.csv" would land in elsewhere + LinkFolder(link, elsewhere); + try + { + var overwrite = Extract; + overwrite.Output.Conflict = ConflictPolicy.Overwrite; + var job = await fx.ConvertAsync([zip], overwrite); + + Assert.Equal(JobState.Failed, job.State); + Assert.Contains("is a link", job.Files[0].ErrorDetail); + Assert.Empty(Directory.EnumerateFileSystemEntries(elsewhere)); + Assert.Equal("old", await File.ReadAllTextAsync(Path.Combine(target, "readme.txt"), Ct)); + } + finally + { + Directory.Delete(link); // the link only, before the fixture deletes the folders + } + } + + // ───────── Mark of the Web ───────── + + [Fact] + public async Task Unpacked_files_keep_the_mark_of_the_web_of_their_archive() + { + SkipWithout7Zip(); + Assert.SkipUnless(OperatingSystem.IsWindows(), "The Mark of the Web is a Windows (NTFS) stream."); + var dir = fx.NewFolder(); + var tree = MakeTree(dir, "tree"); + var zip = Path.Combine(dir, "tools.zip"); + ZipFile.CreateFromDirectory(tree, zip, CompressionLevel.Fastest, includeBaseDirectory: false, Encoding.UTF8); + var tgz = Path.Combine(dir, "tools.tar.gz"); + await using (var file = File.Create(tgz)) + await using (var gzip = new GZipStream(file, CompressionLevel.Fastest)) + await TarFile.CreateFromDirectoryAsync(tree, gzip, includeBaseDirectory: false, Ct); + var alz = Path.Combine(dir, "tools.alz"); + WriteAlz(alz, [new("setup.txt", Words(1_000), Method: 2)]); + var egg = Path.Combine(dir, "tools.egg"); + WriteEgg(egg, [new("setup.txt", false, new EggBlock(Words(1_000), 1))]); + var lz = Path.Combine(dir, "setup.txt.lz"); + await File.WriteAllBytesAsync(lz, await LzipAsync(Words(1_000), dir), Ct); + + var mark = Filee.Core.Platform.MarkOfTheWeb.Internet(new Uri("https://example.com/tools.zip")); + foreach (var archive in new[] { zip, tgz, alz, egg, lz }) + { + Assert.SkipUnless(Filee.Core.Platform.MarkOfTheWeb.TryWrite(archive, mark), "The temp drive holds no NTFS streams."); + var folder = (await ConvertOkAsync(archive, Extract)).Outputs.Single(); + var files = Directory.GetFiles(folder, "*", SearchOption.AllDirectories); + Assert.NotEmpty(files); + foreach (var file in files) + Assert.Contains("ZoneId=3", Filee.Core.Platform.MarkOfTheWeb.Read(file) ?? $"no mark on {file}"); + } + + // An archive without a mark gives files without one. + var plain = Path.Combine(dir, "plain.zip"); + ZipFile.CreateFromDirectory(tree, plain, CompressionLevel.Fastest, includeBaseDirectory: false, Encoding.UTF8); + var unmarked = (await ConvertOkAsync(plain, Extract)).Outputs.Single(); + Assert.All(Directory.GetFiles(unmarked, "*", SearchOption.AllDirectories), f => Assert.Null(Filee.Core.Platform.MarkOfTheWeb.Read(f))); + } + + // ───────── Disk space ───────── + + [Fact] + public async Task A_compressed_stream_that_unpacks_past_the_limit_is_stopped_and_nothing_is_kept() + { + SkipWithout7Zip(); + var dir = fx.NewFolder(); + var zeros = new byte[16 * 1024 * 1024]; // a few hundred bytes as .bz2 and a few KB as .gz + var bz2 = Path.Combine(dir, "zeros.bin.bz2"); + await File.WriteAllBytesAsync(bz2, await Bzip2Async(zeros, dir), Ct); + var gz = Path.Combine(dir, "zeros.bin.gz"); + await using (var file = File.Create(gz)) + await using (var gzip = new GZipStream(file, CompressionLevel.SmallestSize)) + await gzip.WriteAsync(zeros, Ct); + var tgz = Path.Combine(dir, "zeros.tar.gz"); + await using (var file = File.Create(tgz)) + await using (var gzip = new GZipStream(file, CompressionLevel.SmallestSize)) + await using (var tar = new TarWriter(gzip, TarEntryFormat.Pax)) + await tar.WriteEntryAsync(new PaxTarEntry(TarEntryType.RegularFile, "zeros.bin") { DataStream = new MemoryStream(zeros) }, Ct); + + var extractor = new ArchiveExtractor(SevenZip.Locate(), _ => 1024 * 1024); + foreach (var (archive, format) in new[] { (bz2, "bz2"), (gz, "gz"), (tgz, "tgz") }) + { + var scratch = Directory.CreateDirectory(Path.Combine(dir, "scratch-" + format)).FullName; + var error = await Assert.ThrowsAsync(() => extractor.ExtractAsync(archive, format, scratch, Ct)); + Assert.Contains("unpacks to more than 1.0 MB", error.Message); + Assert.Empty(Directory.EnumerateFiles(scratch, "*", SearchOption.AllDirectories)); + } + + // Under the limit, the same files unpack as before. + var roomy = new ArchiveExtractor(SevenZip.Locate(), _ => 32 * 1024 * 1024); + var unpacked = await roomy.ExtractAsync(bz2, "bz2", Directory.CreateDirectory(Path.Combine(dir, "roomy")).FullName, Ct); + Assert.Equal(zeros.Length, new FileInfo(Path.Combine(unpacked, "zeros.bin")).Length); + } + + [Fact] + public async Task An_lzip_member_longer_than_its_trailer_says_is_stopped_at_that_size() + { + SkipWithout7Zip(); + var dir = fx.NewFolder(); + var member = await LzipAsync(Words(200_000), dir); + BitConverter.GetBytes(1_000L).CopyTo(member, member.Length - 16); // the trailer's data size + var lz = Path.Combine(dir, "log.txt.lz"); + await File.WriteAllBytesAsync(lz, member, Ct); + + var job = await fx.ConvertAsync([lz], Extract); + + Assert.Equal(JobState.Failed, job.State); + Assert.Contains("more than the archive declares", job.Files[0].ErrorDetail); + Assert.Equal(["log.txt.lz"], Directory.GetFileSystemEntries(dir).Where(File.Exists).Select(Path.GetFileName)); + } + + /// A junction on Windows (no rights needed), a symbolic link elsewhere; skips when neither can be made. + private static void LinkFolder(string link, string target) + { + if (!OperatingSystem.IsWindows()) + { + try + { + Directory.CreateSymbolicLink(link, target); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Assert.Skip("This machine doesn't let the test make a symbolic link."); + } + return; + } + using var mklink = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("cmd.exe", ["/c", "mklink", "/J", link, target]) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + })!; + mklink.WaitForExit(); + Assert.SkipUnless(mklink.ExitCode == 0, "This drive can't hold a junction."); + } + // ───────── Compress into one archive ───────── [Fact] @@ -576,6 +729,7 @@ public void Listing_parser_reads_7zip_technical_output() Path = dir\file.txt Folder = - Size = 7 + Modified = 2021-05-06 07:08:10 Mode = -rw-r--r-- Symbolic Link = Hard Link = @@ -600,6 +754,8 @@ public void Listing_parser_reads_7zip_technical_output() Assert.True(entries[2].IsFolder); Assert.True(entries[2].IsEncrypted); Assert.Equal(7, entries[0].Size); + Assert.Equal(KnownTime.ToUniversalTime(), entries[0].Modified); + Assert.Null(entries[1].Modified); } [Fact] diff --git a/tests/Filee.Engines.Tests/MediaTests.cs b/tests/Filee.Engines.Tests/MediaTests.cs index 9fbc757bc..0ad4b7404 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() { @@ -221,6 +334,22 @@ await RunAsync(Tools!.Value.Ffmpeg, ["-hide_banner", "-nostdin", "-v", "error", } } + [Fact] + public async Task A_quicktime_named_movie_converts_like_a_mov() + { + // #1283: FFmpeg probes the content, so the .quicktime name only has to reach the MOV routes; a MOV output is + // written as .mov. + SkipUnlessInstalled(); + var movie = await MakeClipAsync(fx.NewFolder(), "clip.quicktime", size: "160x120", extra: ["-c:v", "libx264", "-c:a", "aac", "-f", "mov"]); + + foreach (var target in new[] { "mp4", "mov", "mp3" }) + { + var probed = await ProbeAsync(await ConvertAsync(movie, target)); + Assert.InRange(probed.Duration, 1.7, 2.4); + Assert.Equal(target != "mp3", probed.Video is not null); + } + } + [Fact] public async Task A_qta_with_more_tracks_converts_its_first_audio_track() { @@ -603,6 +732,300 @@ private static IReadOnlyList Args(string target, MediaOptions? options = private static string After(IReadOnlyList args, string option) => args[args.ToList().IndexOf(option) + 1]; + [Fact] + public void Crop_video_encodes_the_frame_cut_first_and_copies_the_sound() + { + // #830: the box before the scale filter; the video can't be copied then. + var crop = MediaEncoding.CropFilter(Hd, new CropBox(1920, 800, 0, 140)); + Assert.Equal("crop=1920:800:0:140,", crop); + var info = new MediaInfo(TimeSpan.FromSeconds(10), Hd with { Codec = "h264", PixelFormat = "yuv420p" }, new AudioStreamInfo(2, 48000, "aac"), null); + var copied = MediaEncoding.Plan("in.mp4", "out.mp4", "mp4", new MediaOptions(), info, "work").Single().Arguments; + Assert.Equal("copy", After(copied, "-c:v")); + var cropped = MediaEncoding.Plan("in.mp4", "out.mp4", "mp4", new MediaOptions(), info, "work", crop: crop).Single().Arguments; + Assert.Equal("libx264", After(cropped, "-c:v")); + Assert.StartsWith(crop + "scale=", After(cropped, "-vf")); + Assert.Equal("copy", After(cropped, "-c:a")); + + // An MKV keeps its subtitles and fonts. + var episode = info with { SubtitleStreams = [new SubtitleStreamInfo(2, "ass", "kor")], HasAttachments = true }; + var mkv = MediaEncoding.Plan("in.mkv", "out.mkv", "mkv", new MediaOptions(), episode, "work", crop: crop).Single().Arguments; + Assert.Contains("0:2", mkv); + Assert.Contains("0:t", mkv); + Assert.Equal(("copy", "copy"), (After(mkv, "-c:s:0"), After(mkv, "-c:t"))); + + // Anamorphic pixels are made square first, as cropdetect saw them. + var dv = new VideoStreamInfo(720, 480, 8 / 9.0, 0, 29.97); + Assert.StartsWith(MediaEncoding.SquarePixels(dv) + "crop=", MediaEncoding.CropFilter(dv, new CropBox(640, 360, 0, 60))); + } + + /// A Blu-ray rip: Korean SRT, English ASS, a Korean PGS track and the fonts of the ASS track. + private static readonly MediaInfo Episode = new(TimeSpan.FromSeconds(10), Hd with { Codec = "h264", PixelFormat = "yuv420p" }, + new AudioStreamInfo(2, 48000, "aac"), null, + [new SubtitleStreamInfo(2, "subrip", "kor"), new SubtitleStreamInfo(3, "ass", "eng"), new SubtitleStreamInfo(4, "hdmv_pgs_subtitle", "kor")], + HasAttachments: true); + + /// The streams mapped from the input, in order. + private static List Mapped(IReadOnlyList args) => + [.. args.Select((arg, i) => (arg, i)).Where(a => a.arg == "-map").Select(a => args[a.i + 1])]; + + [Fact] + public void An_mkv_keeps_every_subtitle_track_and_its_fonts() + { + // #789: for every conversion, not only a crop; the video is copied here. + var args = Args("mkv", info: Episode); + Assert.Equal(["0:V:0", "0:a:0", "0:2", "0:3", "0:4", "0:t"], Mapped(args)); + Assert.Equal(("copy", "copy", "copy", "copy"), (After(args, "-c:s:0"), After(args, "-c:s:1"), After(args, "-c:s:2"), After(args, "-c:t"))); + Assert.Equal("copy", After(args, "-c:v")); + + // Timed text from an MP4 isn't a Matroska codec: it becomes SRT. A file without attachments maps none (FFmpeg + // refuses a specifier that matches nothing). + var phone = Episode with { SubtitleStreams = [new SubtitleStreamInfo(2, "mov_text", "eng")], HasAttachments = false }; + var fromMp4 = Args("mkv", info: phone); + Assert.Equal("srt", After(fromMp4, "-c:s:0")); + Assert.DoesNotContain("0:t", fromMp4); + } + + [Fact] + public void Mp4_and_mov_keep_the_text_subtitles_as_timed_text() + { + foreach (var target in new[] { "mp4", "m4v", "mov" }) + { + var args = Args(target, info: Episode); + // The PGS track (0:4) and the fonts can't go in. + Assert.Equal(["0:V:0", "0:a:0", "0:2", "0:3"], Mapped(args)); + Assert.Equal(("mov_text", "mov_text"), (After(args, "-c:s:0"), After(args, "-c:s:1"))); + Assert.DoesNotContain("-c:s:2", args); + Assert.DoesNotContain("-c:t", args); + Assert.True(MediaEncoding.LeavesOutPictureSubtitles(target, Episode)); + } + + // Timed text already is timed text. + var phone = Episode with { SubtitleStreams = [new SubtitleStreamInfo(2, "mov_text", "eng")] }; + Assert.Equal("copy", After(Args("mp4", info: phone), "-c:s:0")); + Assert.False(MediaEncoding.LeavesOutPictureSubtitles("mp4", phone)); + } + + [Fact] + public void Webm_keeps_the_text_subtitles_as_webvtt() + { + var args = Args("webm", info: Episode); + Assert.Equal(["0:V:0", "0:a:0", "0:2", "0:3"], Mapped(args)); + Assert.Equal(("webvtt", "webvtt"), (After(args, "-c:s:0"), After(args, "-c:s:1"))); + Assert.True(MediaEncoding.LeavesOutPictureSubtitles("webm", Episode)); + var web = Episode with { SubtitleStreams = [new SubtitleStreamInfo(2, "webvtt", "eng")] }; + Assert.Equal("copy", After(Args("webm", info: web), "-c:s:0")); + } + + [Fact] + public void Other_targets_and_clips_keep_no_subtitles() + { + foreach (var target in new[] { "avi", "flv", "ts", "wmv", "mpg", "ogv" }) + { + var args = Args(target, info: Episode); + Assert.Equal(["0:V:0", "0:a:0"], Mapped(args)); + Assert.DoesNotContain(args, a => a.StartsWith("-c:s", StringComparison.Ordinal)); + // Nothing to say either: these never kept subtitles. + Assert.False(MediaEncoding.LeavesOutPictureSubtitles(target, Episode)); + } + Assert.False(MediaEncoding.LeavesOutPictureSubtitles("mkv", Episode)); + + // A clip: the input is seeked, and subtitle timing after the seek isn't proven. + var clip = MediaEncoding.Plan("in.mkv", "out.mkv", "mkv", new MediaOptions(), Episode, "work", clip: new ClipRange(2, 5)).Single().Arguments; + Assert.Equal(["0:V:0", "0:a:0"], Mapped(clip)); + + // Captions and TTML are left out as before: no decoder, or no proof of their timing. + var captions = Episode with { SubtitleStreams = [new SubtitleStreamInfo(2, "eia_608", null), new SubtitleStreamInfo(3, "ttml", "eng")] }; + Assert.Equal(["0:V:0", "0:a:0"], Mapped(Args("mp4", info: captions))); + Assert.False(MediaEncoding.LeavesOutPictureSubtitles("mp4", captions)); + } + + [Fact] + public void A_retry_without_subtitles_maps_none() + { + var mapped = MediaEncoding.Plan("in.mkv", "out.mkv", "mkv", new MediaOptions(), Episode, "work"); + Assert.True(MediaEncoding.MapsSubtitles(mapped)); + var without = MediaEncoding.Plan("in.mkv", "out.mkv", "mkv", new MediaOptions(), Episode, "work", subtitles: false); + Assert.False(MediaEncoding.MapsSubtitles(without)); + Assert.Equal(["0:V:0", "0:a:0"], Mapped(without.Single().Arguments)); + Assert.False(MediaEncoding.MapsSubtitles(MediaEncoding.Plan("in.mkv", "out.avi", "avi", new MediaOptions(), Episode, "work"))); + } + + [Fact] + public void Only_ffmpeg_failures_that_may_be_the_subtitles_are_retried_without_them() + { + var none = CancellationToken.None; + Assert.True(FfmpegConverter.RetriesWithoutSubtitles( + new InvalidOperationException("FFmpeg failed (exit code -22): Could not find tag for codec ttf in stream #2"), none)); + Assert.True(FfmpegConverter.RetriesWithoutSubtitles(new InvalidOperationException("FFmpeg did not write any output."), none)); + + // The output can't be written: the same again without subtitles. + Assert.False(FfmpegConverter.RetriesWithoutSubtitles( + new InvalidOperationException("FFmpeg failed (exit code -28): Error writing trailer: No space left on device"), none)); + Assert.False(FfmpegConverter.RetriesWithoutSubtitles( + new InvalidOperationException("FFmpeg failed (exit code -13): out.mp4: Permission denied"), none)); + // A stall, a cancellation or a refusal isn't FFmpeg failing over a track. + Assert.False(FfmpegConverter.RetriesWithoutSubtitles(new TimeoutException("FFmpeg stopped responding"), none)); + Assert.False(FfmpegConverter.RetriesWithoutSubtitles(new OperationCanceledException(), none)); + Assert.False(FfmpegConverter.RetriesWithoutSubtitles(new ConversionRefusedException("error.over_size_limit"), none)); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + Assert.False(FfmpegConverter.RetriesWithoutSubtitles(new InvalidOperationException("FFmpeg failed"), cancelled.Token)); + } + + [Fact] + public async Task A_conversion_that_fails_with_subtitles_runs_once_more_without_them() + { + var output = Path.Combine(fx.NewFolder(), "out.mp4"); + var logger = new HardwareEncodingTests.ListLogger(); + List runs = []; + Task FailWithSubtitles(bool subtitles) + { + runs.Add(subtitles); + if (!subtitles) + return Task.CompletedTask; + File.WriteAllText(output, "half-written"); + throw new InvalidOperationException("FFmpeg failed (exit code -22): Subtitle codec 94213 is not supported"); + } + + Assert.True(await FfmpegConverter.RunWithoutSubtitlesOnFailureAsync(true, FailWithSubtitles, output, logger, CancellationToken.None)); + Assert.Equal([true, false], runs); + Assert.False(File.Exists(output)); // the partial output is gone before the retry + Assert.Contains("Subtitle codec 94213", Assert.Single(logger.Entries).Message); + + // Nothing mapped: one run, its error as it is. + runs.Clear(); + var error = await Assert.ThrowsAsync(() => + FfmpegConverter.RunWithoutSubtitlesOnFailureAsync(false, FailWithSubtitles, output, logger, CancellationToken.None)); + Assert.Contains("94213", error.Message); + Assert.Equal([true], runs); + + // Success the first time, and a retry that fails too (its error is the one reported). + Assert.False(await FfmpegConverter.RunWithoutSubtitlesOnFailureAsync(true, _ => Task.CompletedTask, output, logger, CancellationToken.None)); + error = await Assert.ThrowsAsync(() => FfmpegConverter.RunWithoutSubtitlesOnFailureAsync(true, + subtitles => throw new InvalidOperationException(subtitles ? "with" : "without"), output, logger, CancellationToken.None)); + Assert.Equal("without", error.Message); + + // A full disk isn't retried. + runs.Clear(); + await Assert.ThrowsAsync(() => FfmpegConverter.RunWithoutSubtitlesOnFailureAsync(true, subtitles => + { + runs.Add(subtitles); + throw new InvalidOperationException("FFmpeg failed (exit code -28): No space left on device"); + }, output, logger, CancellationToken.None)); + Assert.Equal([true], runs); + } + + [Fact] + public async Task A_subtitle_track_ffmpeg_refuses_is_left_out_on_the_retry() + { + SkipUnlessInstalled(); + // A track the container can't take, forced: stream 2 of this MKV is a font, planned as if it were an SRT track, + // so the MP4 muxer refuses it as a subtitle the way it would refuse a track it has no tag for. + var folder = fx.NewFolder(); + var font = Path.Combine(folder, "Sub Font.ttf"); + await File.WriteAllBytesAsync(font, Enumerable.Range(0, 2048).Select(i => (byte)i).ToArray(), TestContext.Current.CancellationToken); + var source = await MakeClipAsync(folder, "episode.mkv", seconds: 1, size: "160x120", extra: + ["-c:v", "libx264", "-c:a", "aac", "-attach", font, "-metadata:s:t", "mimetype=application/x-truetype-font"]); + var info = await MediaInfo.ProbeAsync(Tools!.Value.Ffprobe, source, CancellationToken.None) with + { + SubtitleStreams = [new SubtitleStreamInfo(2, "subrip", "kor")], + }; + var output = Path.Combine(folder, "episode.mp4"); + var logger = new HardwareEncodingTests.ListLogger(); + + var left = await FfmpegConverter.RunWithoutSubtitlesOnFailureAsync(true, subtitles => FfmpegConverter.RunPassAsync(Tools!.Value.Ffmpeg, + MediaEncoding.Plan(source, output, "mp4", new MediaOptions(), info, folder, subtitles: subtitles).Single(), info.Duration, null, 0, + CancellationToken.None), output, logger, CancellationToken.None); + + Assert.True(left); + Assert.Single(logger.Entries); + var probed = await ProbeAsync(output); + Assert.Equal(["video", "audio"], probed.Streams.Select(s => s.Type)); + } + + [Fact] + public void A_ratio_is_the_largest_centred_box_that_fits() + { + Assert.Equal(new CropBox(1080, 1080, 420, 0), MediaEncoding.RatioBox(Hd, 1, 1)); + Assert.Equal(new CropBox(606, 1080, 656, 0), MediaEncoding.RatioBox(Hd, 9, 16)); + Assert.Equal(new CropBox(1920, 822, 0, 128), MediaEncoding.RatioBox(Hd, 21, 9)); + // Already 16:9: nothing to crop. + Assert.True(MediaEncoding.IsWholeFrame(MediaEncoding.RatioBox(Hd, 16, 9), Hd)); + // Upright and with square pixels: a portrait phone video, an anamorphic DV one (shown 640 × 480). + Assert.Equal(new CropBox(1080, 1080, 0, 420), MediaEncoding.RatioBox(Hd with { Rotation = 90 }, 1, 1)); + var dv = new VideoStreamInfo(720, 480, 8 / 9.0, 0, 29.97); + Assert.True(MediaEncoding.IsWholeFrame(MediaEncoding.RatioBox(dv, 4, 3), dv)); + } + + [Fact] + public void Cropdetect_ends_each_stretch_with_its_largest_box_and_the_largest_stretch_wins() + { + string Line(string crop) => $"[Parsed_cropdetect_1 @ 0000] x1:0 x2:1919 y1:140 y2:939 w:1920 h:800 x:0 y:140 pts:1 t:0.04 limit:0.09 crop={crop}"; + string[] lines = + [ + "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'in.mp4':", + Line("1280:540:320:270"), Line("1921:801:1:141"), + // A black frame: no size. + Line("-1904:-1072:1912:1080"), + "[out#0/null @ 0000] video:1kB audio:0kB", + ]; + Assert.Equal(new CropBox(1920, 800, 0, 140), MediaEncoding.FinalCrop(lines)); + Assert.Null(MediaEncoding.FinalCrop(["no crop here", Line("-16:-16:8:8")])); + Assert.True(MediaEncoding.HasCropLine(["no crop here", Line("-16:-16:8:8")])); + Assert.False(MediaEncoding.HasCropLine(["no crop here"])); + + // A dark stretch finds a smaller box: the largest one is the picture. + Assert.Equal(new CropBox(1920, 800, 0, 140), + MediaEncoding.LargestCrop([new CropBox(1280, 540, 320, 270), null, new CropBox(1920, 800, 0, 140), new CropBox(1600, 700, 160, 190)])); + Assert.Null(MediaEncoding.LargestCrop([null, null])); + // A video dark all through: a box too small to be the picture. + Assert.True(MediaEncoding.IsTooSmall(new CropBox(400, 800, 760, 140), Hd)); + Assert.False(MediaEncoding.IsTooSmall(new CropBox(1440, 800, 240, 140), Hd)); + } + + [Fact] + public void A_crop_keeps_an_hevc_or_av1_source_in_its_codec_where_it_can() + { + var hevc = Hd with { Codec = "hevc" }; + HashSet all = ["libx264", "libx265", "libsvtav1"]; + Assert.Equal(VideoCodec.Hevc, MediaEncoding.CropOptions("mp4", new MediaOptions(), hevc, all).VideoCodec); + Assert.Equal(VideoCodec.Av1, MediaEncoding.CropOptions("mkv", new MediaOptions(), Hd with { Codec = "av1" }, all).VideoCodec); + // WebM holds no HEVC; an FFmpeg without x265; a codec the preset chose; an H.264 source. + Assert.Equal(VideoCodec.Default, MediaEncoding.CropOptions("webm", new MediaOptions(), hevc, all).VideoCodec); + Assert.Equal(VideoCodec.Default, MediaEncoding.CropOptions("mp4", new MediaOptions(), hevc, new HashSet { "libx264" }).VideoCodec); + Assert.Equal(VideoCodec.Av1, MediaEncoding.CropOptions("mp4", new MediaOptions { VideoCodec = VideoCodec.Av1 }, hevc, all).VideoCodec); + Assert.Equal(VideoCodec.Default, MediaEncoding.CropOptions("mp4", new MediaOptions(), Hd with { Codec = "h264" }, all).VideoCodec); + // The preset itself is left as it is. + var options = new MediaOptions(); + MediaEncoding.CropOptions("mp4", options, hevc, all); + Assert.Equal(VideoCodec.Default, options.VideoCodec); + } + + [Fact] + public void Cropdetect_reads_a_few_stretches_of_a_long_video_and_the_start_of_a_short_one() + { + Assert.Equal([(6.0, 1.0), (18.0, 1.0), (30.0, 1.0), (42.0, 1.0), (54.0, 1.0)], MediaEncoding.CropDetectStretches(60).ToList()); + Assert.Equal([(0.0, 5.0)], MediaEncoding.CropDetectStretches(4).ToList()); + Assert.Equal([(0.0, 5.0)], MediaEncoding.CropDetectStretches(null).ToList()); + var args = MediaEncoding.CropDetect("in.mp4", 6, 1, Hd); + Assert.Equal(("info", "6.000", "1.000"), (After(args, "-v"), After(args, "-ss"), After(args, "-t"))); + // A part of the range, not 24 of 255: the same black at 10 bits (#830 review). + Assert.Contains("cropdetect=limit=0.094:", After(args, "-vf")); + } + + [Fact] + public void A_box_as_large_as_the_picture_is_no_black_bars() + { + Assert.True(MediaEncoding.IsWholeFrame(new CropBox(1920, 1080, 0, 0), Hd)); + Assert.True(MediaEncoding.IsWholeFrame(new CropBox(1918, 1080, 0, 0), Hd)); + Assert.False(MediaEncoding.IsWholeFrame(new CropBox(1920, 800, 0, 140), Hd)); + // A portrait phone video: upright, the width and height change places. + Assert.True(MediaEncoding.IsWholeFrame(new CropBox(1080, 1920, 0, 0), Hd with { Rotation = 90 })); + Assert.False(MediaEncoding.IsWholeFrame(new CropBox(1080, 1920, 0, 0), Hd)); + // Anamorphic DV: 720 × 480 shown 4:3, as 640 × 480 with square pixels. + Assert.True(MediaEncoding.IsWholeFrame(new CropBox(640, 480, 0, 0), new VideoStreamInfo(720, 480, 8 / 9.0, 0, 29.97))); + } + [Fact] public void Audio_targets_map_the_track_the_probe_chose() { @@ -763,6 +1186,50 @@ public void Ten_bit_and_hdr_sources_stay_ten_bit_and_matching_codecs_are_copied( Assert.False(new VideoStreamInfo(1, 1, 1, 0, 0, PixelFormat: "yuvj420p").IsHighBitDepth); } + [Fact] + public void Transparent_sources_keep_their_alpha_in_webm() + { + MediaInfo Source(string codec, string pixelFormat) => + new(TimeSpan.FromSeconds(10), new VideoStreamInfo(640, 360, 1, 0, 30, codec, pixelFormat), null, null); + + // ProRes 4444, QuickTime Animation, APNG and GIF (#1198): VP9 writes the alpha plane, alt-ref frames off. + foreach (var (codec, format) in new[] { ("prores", "yuva444p12le"), ("qtrle", "argb"), ("apng", "rgba"), ("gif", "bgra") }) + { + var webm = Args("webm", info: Source(codec, format)); + Assert.Equal(("libvpx-vp9", "yuva420p", "0"), (After(webm, "-c:v"), After(webm, "-pix_fmt"), After(webm, "-auto-alt-ref"))); + } + + // Opaque sources are encoded as before. + foreach (var format in new[] { "yuv420p", "yuv444p10le", "rgb24", "bgr0", "pal8" }) + { + var webm = Args("webm", info: Source("h264", format)); + Assert.Equal("yuv420p", After(webm, "-pix_fmt")); + Assert.DoesNotContain("-auto-alt-ref", webm); + } + + // Only VP9 is asked to keep it: AV1 in WebM (10 bits for 12-bit ProRes) and H.264 in MP4 stay without alpha. + var prores = Source("prores", "yuva444p12le"); + var av1 = Args("webm", new MediaOptions { VideoCodec = VideoCodec.Av1 }, prores); + Assert.Equal(("libsvtav1", "yuv420p10le"), (After(av1, "-c:v"), After(av1, "-pix_fmt"))); + Assert.DoesNotContain("-auto-alt-ref", av1); + Assert.Equal("yuv420p", After(Args("mp4", info: prores), "-pix_fmt")); + + // A still picture is still shown on black. + var picture = new MediaInfo(null, new VideoStreamInfo(640, 360, 1, 0, 25, "png", "rgba"), null, null); + var still = MediaEncoding.PlanStill("in.png", "out.webm", "webm", new MediaOptions(), picture).Single().Arguments; + Assert.Equal("yuv420p", After(still, "-pix_fmt")); + Assert.DoesNotContain("-auto-alt-ref", still); + } + + [Fact] + public void An_iphone_ringtone_is_aac_in_the_ipod_muxer_even_when_alac_is_asked_for() + { + // #1291: iOS plays AAC ringtones; ALAC is only offered for M4A and M4B. + var ringtone = Args("m4r", new MediaOptions { AudioCodec = AudioCodec.Alac }); + Assert.Equal(("aac", "ipod"), (After(ringtone, "-c:a"), After(ringtone, "-f"))); + Assert.EndsWith(".m4r", ringtone[^1]); + } + [Fact] public void Lossless_and_new_audio_targets_get_their_encoders() { @@ -964,6 +1431,77 @@ public void Ffmpeg_progress_and_errors_are_read() // ───────────────────────── Real conversions ───────────────────────── + /// A 2-second video of one colour with a tone, (a pad filter) adding black bars. + private static async Task MakeBarsAsync(string folder, string name, string size, string? pad, + string encoder = "libx264", string pixelFormat = "yuv420p") + { + var path = Path.Combine(folder, name); + await RunAsync(Tools!.Value.Ffmpeg, ["-hide_banner", "-nostdin", "-v", "error", "-y", + "-f", "lavfi", "-i", $"color=c=0x3366cc:size={size}:rate=25:duration=2" + (pad is null ? "" : "," + pad) + $",format={pixelFormat}", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=44100:duration=2", + "-c:v", encoder, "-pix_fmt", pixelFormat, "-c:a", "aac", "-shortest", path]); + return path; + } + + private async Task CropAsync(string input, string frame) => + await fx.ConvertAsync([input], new Preset { TargetFormat = BuiltInData.SameAsSource, Media = { CropFrame = frame } }); + + [Fact] + public async Task Crop_video_auto_removes_the_black_bars_and_keeps_the_sound() + { + SkipUnlessInstalled(); + // #830: a 4:3 picture letterboxed into a 3:4 frame. + var video = await MakeBarsAsync(fx.NewFolder(), "boxed.mp4", "240x180", "pad=240:320:0:70:black"); + var job = await CropAsync(video, VideoCrop.Auto); + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var output = Assert.Single(job.Outputs); + Assert.Equal(".mp4", Path.GetExtension(output)); + var probed = await ProbeAsync(output); + Assert.Equal((240, 180), (probed.Video!.Width, probed.Video.Height)); + Assert.Equal("aac", probed.Audio?.Codec); + Assert.InRange(probed.Duration, 1.7, 2.4); + } + + [Fact] + public async Task Crop_video_to_a_ratio_keeps_the_middle_as_large_as_fits() + { + SkipUnlessInstalled(); + var video = await MakeBarsAsync(fx.NewFolder(), "wide.mp4", "320x240", null); + var square = await ProbeAsync(Assert.Single((await CropAsync(video, "1:1")).Outputs)); + Assert.Equal((240, 240), (square.Video!.Width, square.Video.Height)); + var portrait = await ProbeAsync(Assert.Single((await CropAsync(video, "9:16")).Outputs)); + Assert.Equal((134, 240), (portrait.Video!.Width, portrait.Video.Height)); + + // Already 4:3: refused, rather than encoded again as it was. + var same = Assert.Single((await CropAsync(video, "4:3")).Files); + Assert.Equal((FileState.Failed, "error.crop_same_frame"), (same.State, same.ErrorKey)); + } + + [Fact] + public async Task Crop_video_auto_finds_the_bars_of_a_10_bit_hevc_video_and_keeps_its_codec() + { + SkipUnlessInstalled(); + // #830 review: cropdetect's limit at 10 bits, and the crop stays HEVC with its 10 bits. + var video = await MakeBarsAsync(fx.NewFolder(), "deep.mkv", "240x180", "pad=240:320:0:70:black", "libx265", "yuv420p10le"); + var job = await CropAsync(video, VideoCrop.Auto); + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var probed = await ProbeAsync(Assert.Single(job.Outputs)); + Assert.Equal((240, 180), (probed.Video!.Width, probed.Video.Height)); + Assert.Equal(("hevc", "yuv420p10le"), (probed.Video.Codec, probed.Video.PixelFormat)); + } + + [Fact] + public async Task Crop_video_auto_refuses_a_video_without_black_bars() + { + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var video = await MakeBarsAsync(folder, "plain.mp4", "320x240", null); + var job = await CropAsync(video, VideoCrop.Auto); + var file = Assert.Single(job.Files); + Assert.Equal((FileState.Failed, "error.no_black_bars"), (file.State, file.ErrorKey)); + Assert.Equal(["plain.mp4"], Directory.GetFiles(folder).Select(Path.GetFileName)); + } + /// Writes a test clip with FFmpeg: moving test pattern (+ a 440 Hz tone unless is false). private static async Task MakeClipAsync(string folder, string name, double seconds = 2, string size = "320x240", int fps = 25, bool audio = true, int channels = 2, params string[] extra) @@ -1005,7 +1543,7 @@ private sealed record Probed(double Duration, List Streams) public Track? Audio => Streams.FirstOrDefault(s => s.Type == "audio"); } - private sealed record Track(string Type, string Codec, int Width, int Height, int Channels, int SampleRate, long BitRate, bool Cover, int Frames); + private sealed record Track(string Type, string Codec, int Width, int Height, int Channels, int SampleRate, long BitRate, bool Cover, int Frames, string PixelFormat = ""); private static async Task ProbeAsync(string path) { @@ -1020,7 +1558,7 @@ private static async Task ProbeAsync(string path) S(s, "codec_type"), S(s, "codec_name"), (int)N(s, "width"), (int)N(s, "height"), (int)N(s, "channels"), (int)N(s, "sample_rate"), N(s, "bit_rate"), s.TryGetProperty("disposition", out var d) && d.TryGetProperty("attached_pic", out var a) && a.GetInt32() == 1, - (int)N(s, "nb_read_frames"))).ToList(); + (int)N(s, "nb_read_frames"), S(s, "pix_fmt"))).ToList(); var duration = double.TryParse(S(json.RootElement.GetProperty("format"), "duration"), NumberStyles.Float, CultureInfo.InvariantCulture, out var d2) ? d2 : 0; return new Probed(duration, streams); } @@ -1067,6 +1605,38 @@ public async Task Mp4_converts_to_other_video_formats(string target) Assert.InRange(probed.Duration, 1.7, 2.4); } + [Theory] + [InlineData("mp4", "mov_text")] + [InlineData("mov", "mov_text")] + [InlineData("webm", "webvtt")] + public async Task An_mkv_with_srt_subtitles_keeps_them_as_a_switchable_track(string target, string codec) + { + SkipUnlessInstalled(); + // #789: an MKV with a Korean SRT track, made here. + var folder = fx.NewFolder(); + var srt = Path.Combine(folder, "episode.srt"); + await File.WriteAllTextAsync(srt, "1\r\n00:00:00,500 --> 00:00:01,500\r\n안녕하세요\r\n\r\n", TestContext.Current.CancellationToken); + var source = Path.Combine(folder, "episode.mkv"); + await RunAsync(Tools!.Value.Ffmpeg, ["-hide_banner", "-nostdin", "-v", "error", "-y", + "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25:duration=2", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2", "-i", srt, + "-map", "0", "-map", "1", "-map", "2", "-c:v", "libx264", "-c:a", "aac", "-c:s", "srt", "-metadata:s:s:0", "language=kor", source]); + + var output = await ConvertAsync(source, target); + + var info = await MediaInfo.ProbeAsync(Tools!.Value.Ffprobe, output, CancellationToken.None); + var track = Assert.Single(info.Subtitles); + Assert.Equal((codec, "kor"), (track.Codec, track.Language)); + // The cue still starts half a second in. Timed text fills the gap before it with an empty 2-byte sample. + var packets = await ProcessRunner.RunAsync(Tools!.Value.Ffprobe, + ["-v", "error", "-select_streams", "s:0", "-show_entries", "packet=pts_time,size", "-of", "csv=p=0", output], + TimeSpan.FromMinutes(1), CancellationToken.None); + var cue = packets.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(line => line.Split(',')) + .First(p => int.Parse(p[1], CultureInfo.InvariantCulture) > 2); + Assert.InRange(double.Parse(cue[0], CultureInfo.InvariantCulture), 0.45, 0.55); + } + [Fact] public async Task Every_target_format_is_written_with_the_planned_codecs() { From 12585fbb1e31210bd291c2ac9f07d47e321fc150 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Tue, 10 Nov 2026 20:14:01 +0000 Subject: [PATCH 7/7] Add files via upload --- docs/ENGINES.md | 302 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 266 insertions(+), 36 deletions(-) diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 57dd00fe4..ff1006ee7 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -5,14 +5,14 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | Engine | How it gets there | Handles | |---|---|---| -| ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), **FITS** (Filee's own reader; TIFF at the file's own depth by Filee's own writer), Photo CD, OpenEXR, Radiance HDR, EMF/WMF (Windows) and the photo of an iPhone LIVP read | +| ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), **FITS** (Filee's own reader; TIFF at the file's own depth by Filee's own writer), Photo CD, OpenEXR, Radiance HDR, EMF/WMF (Windows), the photo of an iPhone LIVP and the merged picture of a Krita KRA read | | Windows Imaging Component | part of Windows (Windows only) | **JPEG XR** (JXR, WDP, HDP; HDR screenshots of Windows) → the images ImageMagick writes, and PDF, ICNS, HEIC and SVG through PNG; HDR tone-mapped to SDR | | Vector graphics | built in (Svg.Skia, SkiaSharp, ACadSharp) | **SVG/SVGZ → PDF (vector) and images**, SVG ↔ SVGZ, **SVG → DXF** (→ DWG with the CAD engine), **raster images → SVG (tracing)**, PDF-compatible AI → PDF, **HP-GL / HP-GL/2 plot files (PLT) → SVG**, PDF and images (pen moves, packed PE polylines, arcs and filled polygons; text labels are not drawn), **Tajima DST embroidery → SVG** (stitches as lines, a palette colour per thread; → PNG, JPG, PDF from there) | | ICNS | built in | images → ICNS (16–1024 px), ICNS → images | -| PDFsharp | built in (library) | images → PDF, merge, split, page ranges, page organising, lossless rotate and crop-box edits, spreadsheet print-studio assembly, **compression** (smaller PDFs, see below), PDFs without their metadata | -| PDFium | built in (library) | PDF → images (multi-page TIFF/GIF supported) | +| PDFsharp | built in (library) | images → PDF (CMYK pictures stay CMYK), merge, split, page ranges, page organising, lossless rotate and crop-box edits, pages per sheet (N-up), spreadsheet print-studio assembly, **compression** (smaller PDFs, see below), PDFs without their metadata | +| PDFium | built in (library) | PDF → images (multi-page TIFF/GIF supported); CMYK pages → CMYK JPG/TIFF through Ghostscript when it is installed | | Unhwp | built in (library) | HWP/HWPX → TXT, Markdown, HTML (pictures in a `_files` folder next to the Markdown, embedded in the HTML) | -| Text & Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT**; **TXT, LOG, PY, PAC, BAT, CMD, COMMAND, LUA, CSS, PS1, SQL, AHK, INI, RSC, C, H, CPP, CC, CXX, HPP, HH, HXX, CS → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | +| Text & Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT**; **TXT, LOG, PY, PAC, BAT, CMD, COMMAND, LUA, CSS, PS1, SQL, AHK, INI, RSC, C, H, CPP, CC, CXX, HPP, HH, HXX, CS, DART, HQL, DBML, JAVA, MERMAID, MMD, RS, GO, KT, SWIFT, SCALA, SCSS, VUE, TSX → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | | Spreadsheets (ExcelDataReader for XLS) | built in (library) | **XLSX, XLS, ODS, CSV, TSV → XLSX, ODS, CSV, TSV** (one CSV / TSV per sheet) | | Office Open XML | built in | **DOCM / DOTX / DOTM ↔ DOCX, XLSM / XLTX ↔ XLSX, PPTM / POTX / POTM / PPSX ↔ PPTX** (macros removed for macro-free types) | | Fonts | built in | **TTF, OTF, WOFF, WOFF2, EOT ↔ each other**; **FON (NE monochrome FNT v2/v3, supported single-byte charsets) → TTF, OTF, WOFF, WOFF2, EOT only**; CFF (PostScript) outlines become TrueType for TTF and EOT | @@ -22,22 +22,25 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | PDF slides | built in (PDFium, PdfPig) | **PDF → PPTX**: a picture of each page on its own slide (looks like the PDF, not editable), the page's text in the speaker notes | | PDF tables | built in (PdfPig) | **PDF → XLSX, ODS, CSV** (#267): each table drawn with lines on its own sheet, numbers and dates as real values, merged cells and column widths kept | | E-mail | built in (MimeKit, MsgReader) | **EML, Outlook MSG → HTML** (headers + body, inline pictures), **TXT**, **ZIP** (the attachments); **MSG → EML**; **MHTML → HTML** (the saved page in one file, pictures inlined; on to PDF, DOCX, HWPX, TXT, Markdown, EPUB from there) | -| Data | built in (System.Text.Json, YamlDotNet, Tomlyn, System.Xml) | **JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other** (CSV for tables); **vCard, iCalendar ↔ CSV**; FreeMASTER oscilloscope TXT → CSV; spreadsheets, contacts and events reach JSON / YAML / TOML / XML through CSV | +| Signed files | built in (System.Security.Cryptography.Pkcs) | **P7M → the signed file inside** (CMS / PKCS #7, CAdES: Italian e-invoices `*.xml.p7m`, signed PDFs `*.pdf.p7m`), then on from its format (P7M → PDF → JPG); the signature isn't checked | +| Data | built in (System.Text.Json, YamlDotNet, Tomlyn, System.Xml) | **JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other** (CSV for tables); **vCard, iCalendar ↔ CSV**; **property lists (.plist, XML and binary) → each of them and TXT**; FreeMASTER oscilloscope TXT → CSV; spreadsheets, contacts and events reach JSON / YAML / TOML / XML through CSV | | **E-books** | built in | **EPUB, MOBI/AZW/AZW3/PRC, FB2, HTMLZ, TXTZ → EPUB, HWPX (→ PDF), TXT, HTML, Markdown, FB2, HTMLZ, TXTZ**; HTML, Markdown, TXT, DOCX → EPUB/FB2/HTMLZ/TXTZ; HTML → TXT; comics CBZ/CBR/CB7/CBT/CBC → PDF, EPUB, CBZ; PDF → CBZ; AZW4 → PDF; **EPUB ↔ KEPUB** (Kobo) | | CAD (ACadSharp + built-in renderer) | built in (library) | **DWG ↔ DXF**; DWG/DXF → **PDF** (vector), **SVG**, PNG, JPG, WEBP, TIFF, BMP, GIF, ICO, AVIF | -| **Subtitles** | built in | **SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC → SRT, ASS, WebVTT, SAMI, LRC, TTML, MicroDVD**, always UTF-8 (Korean CP949 / EUC-KR files read correctly) | +| **Subtitles** | built in | **SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC → SRT, ASS, WebVTT, SAMI, LRC, TTML, MicroDVD, TXT**, always UTF-8 (Korean CP949 / EUC-KR files read correctly) | | 3D models (SharpGLTF for glTF) | built in (library) | **GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY ↔ each other**; FBX → each of them; → PNG, JPG, … (an isometric preview) | +| Pictures to 3D relief | built in | **PNG, JPG, WEBP, … (every picture ImageMagick reads) → GLB, glTF, STL, OBJ, 3MF, PLY**: a relief, brighter areas higher | | **Geospatial vector data** | built in | **GeoJSON, KML, KMZ, GPX, Shapefile ↔ each other and CSV** (longitude / latitude or WKT); Garmin FIT (activities, read only) → each of them and CSV; projected Shapefiles (UTM, Web Mercator, Lambert, Albers) are converted to WGS 84; through CSV to XLSX, JSON, … (see below) | | rhwp | bundled with the installer (`engines/rhwp`, with the Microsoft C++ runtime, see below) | HWP/HWPX → PDF, **HWP → HWPX, HWPX → HWP** | -| Archives (7-Zip) | bundled with the installer (`engines/7zip`, ~2.5 MB) + built-in readers | ZIP (including ANSYS Workbench WBPZ), 7Z, RAR, TAR (+ GZ/BZ2/XZ/Z/7Z/LZ), CAB, ISO, DMG, split (.001), … **→ folder, ZIP, 7Z, TAR, TAR.GZ/BZ2/XZ**; ALZ, EGG, lzip read in-process; "Compress into one archive" for any files | +| Archives (7-Zip) | bundled with the installer (`engines/7zip`, ~2.5 MB) + built-in readers | ZIP (including ANSYS Workbench WBPZ), 7Z, RAR, TAR (+ GZ/BZ2/XZ/Z/7Z/LZ), CAB, ISO, UDF, DMG, split (.001), Minecraft and Modrinth packs, … **→ folder, ZIP, 7Z, TAR, TAR.GZ/BZ2/XZ, the packs**; ALZ, EGG, lzip read in-process; "Compress into one archive" for any files | | LibreOffice + H2Orestart + Java | **optional download** (~420 MB, 1.3 GB on disk) | older and rare formats only: DOC, XLS, PPT, RTF and OpenDocument → PDF and to each other, output as DOCX/ODT/ODS/ODP; HWP/HWPX → DOCX/ODT; read-only formats (see below) → PDF and their kind's editable formats; FODT, OpenDocument templates (OTT, OTS, OTP) and XLT out; SVG → EMF/WMF/ODG, a PDF page → SVG | | Pandoc | **optional download** (~42 MB, 240 MB on disk) | Markdown ↔ DOCX/ODT/RTF, DOCX/ODT/HTML/RTF → Markdown, HTML ↔ DOCX/ODT, markup languages (reStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter, Textile, man, OPML) ↔ Markdown/HTML/DOCX/ODT and each other (and → RTF, EPUB, TXT), Markdown and markup → PPTX slides | -| Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium); smaller PDFs when a preset asks for it | +| Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium); smaller PDFs when a preset asks for it; renders CMYK PDF/AI pages for CMYK JPG/TIFF | | Tesseract | **optional download** (~50 MB, 160 MB on disk; each language 0.4–10 MB when first used) | **text recognition (OCR)**: scans and pictures → searchable PDF, and their text into DOCX, TXT, Markdown and HWPX ("Recognize text" in the preset, see below) | | Apple Vision | part of macOS (Mac only) | **text recognition (OCR) on a Mac**, the same step as Tesseract without a download; Tesseract reads the languages Vision doesn't (see "Apple Vision on macOS") | | FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG, a still frame or every frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, Live / Motion Photos → their video, a clip of a video → an iPhone Live Photo or Android Motion Photo (experimental), images → HEIC, clips or audio files joined into one, pictures → a slideshow MP4, copies without metadata (see "Media tools") | | Calibre | **optional download** (~216 MB, 660 MB on disk) | rare e-book formats: LIT, LRF, CHM, PDB, PML, RB, SNB, TCR, OEB → EPUB; EPUB → MOBI, AZW3, LIT, LRF, PDB, PML, RB, SNB, TCR; e-books → PDF (ranked above the HWPX writer, see below) | -| Whisper | **optional download** (Windows only; ~157 MB with model, ~160 MB on disk) | **Speech-to-text transcription**: video and audio → SRT subtitles in ~100 languages using OpenAI Whisper (whisper.cpp); runs locally and offline; needs FFmpeg | +| Whisper | **optional download** (Windows only; ~157 MB with model, ~160 MB on disk) | **Speech-to-text transcription**: video and audio → SRT subtitles or plain TXT in ~100 languages using OpenAI Whisper (whisper.cpp); runs locally and offline; needs FFmpeg | +| ExifTool | **optional download** (~11 MB on Windows, ~8 MB elsewhere; 30 MB on disk) | **the Metadata tool**: reads every metadata field of pictures, PDFs, video and sound, and writes changes into a copy in the file's own format without re-encoding it (JPG, PNG, TIFF, WEBP, HEIC, AVIF, JPEG XL, GIF, PSD, camera RAW, PDF, MP4, MOV, M4A, 3GP, …; not MP3, FLAC, WAV, MKV, AVI or BMP, which it shows read-only). Without it the Metadata tool shows nothing and offers it | DOCX, XLSX, XLS, ODS and PPTX → PDF need neither Microsoft Office nor LibreOffice: they are read in-process, written as HWPX and rendered by rhwp. Anything the built-in readers understand (including PDF) is written as DOCX by the DOCX writer. @@ -56,8 +59,10 @@ when Settings → *Engines* doesn't choose it for documents. Without LibreOffice LibreOffice to convert correctly" and the script's name instead of becoming a broken PDF. The same scan also finds text rhwp has no widths for: its width tables cover only ASCII and Latin-1, general punctuation, math, the CJK forms and Hangul (rhwp#8143), so a letter of any other script — Polish, Turkish, Vietnamese, Greek, Cyrillic, ... — or a -combining mark is drawn but laid out at a fixed half-em. Such a document takes a route without unshaped edges when -one exists and is never refused when none does: the overlapping text stays readable. Only letters inside words +combining mark is laid out at a fixed half-em. Such a document takes a route without unshaped edges when one exists +and is never refused when none does: Filee then gives those letters a letter spacing that takes rhwp's half-em to the +real width in the font rhwp draws them with (LATIN for Latin Extended, HANGUL for other scripts; issue #1262), so +they don't overlap. Combining marks keep the half-em. Only letters inside words count (an extended Latin letter next to any letter, a letter of another script next to one of its own script), so a symbol or unit on its own (α, Ω, μm in a Korean report) keeps rhwp, and an HWPX stays with rhwp for this (only text that needs shaping sends it elsewhere). Documents in French, German or Spanish (Latin-1 letters only), Chinese, @@ -108,6 +113,9 @@ they can be installed or removed later in Settings → *Engines*. A conversion t installer files and bundled language data are removed (`TrimTesseract`, ~80 MB). The languages come from `tessdata_fast` 4.1.0, pinned like the engines in `Infrastructure/ocr-languages.json` (`traineddata` kind, one folder per language under `engines/ocr-languages`), and are downloaded the first time a preset uses one. +- ExifTool is Phil Harvey's Windows build (a Perl bundled with it), whose `exiftool(-k).exe` is renamed `exiftool.exe`: + the "-k" makes it wait for a key before it exits. On macOS and Linux it is the Perl distribution + (`Image-ExifTool-*.tar.gz`), run by the Perl both come with. ### Microsoft C++ runtime (Windows) @@ -195,13 +203,33 @@ 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 or MNG keeps every frame and its timing when converted to one of these - (animated GIF ↔ animated WebP ↔ APNG ↔ MNG, WebP lossy or lossless as the preset says) 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, but goes much faster with it. ImageMagick reads the frames and applies + the preset either way. With FFmpeg and its libaom-av1 encoder (Filee's FFmpeg download has it), + `Media/AvifSequenceWriter.cs` packs the frames into an APNG in the work folder (`Magick/Apng.cs`, exact delays) and + FFmpeg's avif muxer writes the image sequence (`avis`): FFmpeg encodes 60 busy 480 × 270 frames in about 3 s, where + ImageMagick's writer takes 50 s or more. Cancelling stops it, and the loop count stays (the muxer's `-loop`, the edit list's repeat). Only + libaom-av1 is used: the avif muxer takes the alpha track as one grey plane, which SVT-AV1 doesn't encode, and + FFmpeg's SVT-AV1 wrapper passes no packet durations, so the last frame's length would be a guess (a GIF's long + pause at the end cut short). Colour is full-range BT.709 4:2:0 tagged as sRGB, the preset's quality the quantizer + libheif would pick. An FFmpeg without libaom-av1, or one whose run fails (an old avif muxer), leaves the file to + ImageMagick, which writes the sequence through libheif. Its writer stores no loop count (an animation that plays a + set number of times loops for ever) and can't be interrupted. Both ways, the first frame is also stored as the + still picture for viewers that don't play sequences, and ImageMagick 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, which leaves out the alpha track). 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. 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 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 and MNG, 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 @@ -290,6 +318,16 @@ in it also builds the package (artifact `windows-arm64-package`). - **LIVP** (an iPhone Live Photo as iCloud downloads it: a ZIP of the photo, HEIC or JPEG, and its MOV) is read as its photo, taken out of the package in memory (`LivePhotoSource.ReadStill`, at most 64 MB); its video is FFmpeg's (see Live / Motion Photos there). +- **CMYK pictures → PDF** (#1027, #1055): print work keeps its colours. PDFsharp's image import takes grey and RGB + only, so `Pdf/CmykPictures.cs` writes the image itself: CMYK, with the picture's own ICC profile as an ICCBased + colour space (else DeviceCMYK); a JPEG source as JPEG data (ImageMagick writes CMYK JPEGs inverted with Adobe's + APP14 marker, undone with a Decode array), others as their samples compressed losslessly; transparency as a soft + mask. Grey and RGB pictures go in through PDFsharp as before. +- **KRA** (Krita's own file, a ZIP of the layers) is read as the merged picture Krita stores at its top for other + programs (`mergedimage.png`, `Krita.ReadMerged`, at most 256 MB, in memory), so it looks as it does in Krita. A file + without one gives a clear error. Krita's backup of the previous save, `name.kra~`, is the same file and is read the + same way; its output is named `name.png`. Watch folders skip it (and any name ending in `~`), as it is written next + to the drawing on every save. - **Live / Motion Photos from a video** (JPG and HEIC targets) are FFmpeg's: the clip and the photo's frame come from it, ImageMagick or the HEIC writer only encode that frame (see *Making Live / Motion Photos* there). - **Camera RAW** (DNG, CR2, CR3, NEF, ARW, …, and `.raw`) is decoded by LibRaw with the camera's white balance, @@ -305,6 +343,19 @@ in it also builds the package (artifact `windows-arm64-package`). square; reading takes the largest image (PNG, JPEG 2000 or the old RLE + mask entries). - **Illustrator** files saved with "Create PDF compatible file" (the default since Illustrator 9) are PDFs and need nothing extra; PostScript-based ones need Ghostscript. +- **CMYK pages** (#1027). PDFium renders RGB only, so a print file (a CMYK PDF, or an Illustrator file through its + PDF) → JPG or TIFF is rendered by Ghostscript instead when it is installed: `tiff32nc` (8-bit CMYK) at the preset's + render DPI, cut to the crop box, anti-aliased, then written like PDFium's pages (quality, resize, `_p{n}` names, + multi-page TIFF, long images, size limits). `Pdf/PdfCmykContent.cs` decides per document with PdfPig, from the + first 20 selected pages: DeviceCMYK colours (`k` / `K`, also inside forms), four-channel ICC colour spaces, + Separation / DeviceN (spot colours), CMYK pictures and gradients. DeviceCMYK values come out as the document gives + them (Ghostscript's default CMYK profile is both their source and the output). When the PDF has an output intent + with a CMYK profile (PDF/X), Ghostscript renders into that profile and the picture carries it; otherwise the picture + is untagged CMYK, which keeps the numbers and leaves the profile to the print shop (Ghostscript's own default + profile is removed). RGB documents, a grayscale preset, other targets (PNG, WebP, …), a PDF PdfPig can't read and + PCs without Ghostscript keep PDFium's RGB rendering. When a CMYK file comes out in RGB only because Ghostscript is + missing or failed on it, the result says so (`notice.cmyk_needs_ghostscript`, `notice.cmyk_render_failed`): print + work shouldn't change colour unseen. - EPS and PS → PNG go straight through Ghostscript's `pngalpha` device, so the background stays transparent; JPG, WebP, … and AI reach images through PDF (Ghostscript → PDFium), and SVG reaches EPS through PDF. @@ -418,8 +469,21 @@ Readers turn the source into a small document model (`Hwp/Hwpx/HwpxModel.cs`) an - **MHTML** (`.mhtml`, `.mht`) is turned into its standalone page first (see *E-mail* below) and that page read like HTML; it refers to nothing but data: URIs, so no file outside the job's scratch folder is looked at. - **ODT, RTF** are parsed by Pandoc into its JSON AST (`PandocAstReader.cs`, following - [pypandoc-hwpx](https://github.com/msjang/pypandoc-hwpx)). + [pypandoc-hwpx](https://github.com/msjang/pypandoc-hwpx)), in Pandoc's `--sandbox`: Pandoc reads nothing but the + file, so include directives stay unread and pictures the document links to are found by Filee. - Markdown, HTML and Pandoc keep structure only, so page setup comes from the built-in template (A4). +- Pictures (and HTML style sheets) a Markdown, HTML or Pandoc document links to are read only from the document's + own folder and its subfolders (`DocumentFiles.cs`, also for e-books, MHTML and translation): `../`, absolute paths + and `file:` URLs outside are left out like missing pictures. A path or `file:` URL naming another computer + (`\\host\share`, `file://host/`, `//host/`) is refused by its text alone, so no network connection is made. +- **Limits** (`Hwp/Hwpx/Nesting.cs`, `Infrastructure/BoundedXml.cs`): the XML parts of DOCX, PPTX, XLSX, HWPX and + ODS packages (and FB2 and OPF files) are read without DTDs, each up to 256 MB unpacked, 1 GB per package, with + elements nested at most 1,000 levels; larger or deeper files are refused (`error.document_too_large`). Tables in + table cells, text boxes, notes and links nested more than 32 deep become plain paragraphs of their text, in the + DOCX and HWPX readers and again in every writer (whatever reader made the model); an equation deeper than 200 + levels becomes its text, notes inside notes are not followed, and PPTX groups are opened without recursion. The + readers run on a thread with a 16 MB stack. The scan for text rhwp can't draw (`ComplexScripts.cs`) stops at the + same sizes: it runs before any reader, finds nothing in a file over them, and the reader then refuses that file. Element order and attribute values follow files saved by 한글 where the schema and 한글 disagree, for example: @@ -771,7 +835,7 @@ Password-protected PDFs fail with the same message as PDF → DOCX. ## PDF compression -"Compress PDF" (and any PDF → PDF preset with *Compress* set, also with a page range or one file per page) makes a +"Compress PDF" (and any PDF → PDF preset with *Compress* set, also with a page range or split into files) makes a PDF smaller while it stays a PDF, for e-mail attachments and upload limits. `Pdf/PdfCompressor.cs` does it with PDFsharp and Magick.NET; nothing has to be installed. @@ -841,6 +905,21 @@ in the preset library only). Video and audio need FFmpeg. XMP packet (`PdfSharpConverter.StripMetadata`), and PDFsharp writes a new one holding only its own name and the save date; pages, links, bookmarks and form fields stay. +## Pages per sheet (N-up) + +"Pages per sheet" (#737, `pdf-nup` group in the palette, `PdfOptions.PagesPerSheet` 2, 4, 6, 8 or 9; +`Pdf/PdfSheets.cs`, PDFsharp only) puts several pages of a PDF on each sheet of a new PDF. + +- Each page goes in as a vector form (`XPdfForm`): text stays text, nothing is rasterised. It is placed by its crop + box and its `/Rotate`, as a viewer shows it, clipped to the crop box, as large as fits in its cell and centred; a + page that fits larger turned (a landscape page in a portrait cell) is turned a quarter to the left. +- The sheet: A4, Letter, A3 or the size of the first page; portrait, landscape or (auto) whichever fits the first page + larger. The long side of the sheet takes more pages (4-up 2 × 2, 6-up 2 × 3, 8-up 2 × 4, 9-up 3 × 3). +- Options: order (across then down, or down then across), margin and gap in mm (cut down when too large for the + sheet), a thin grey border around each page. The page range applies. +- Only the page content comes along (as in any N-up): the values of filled form fields, link and comment + annotations and bookmarks of the source are not on the sheets. + ## PDF watermark, page numbers and password A preset that writes PDF can finish every PDF it makes (`PdfOptions.Watermark`, `PageNumbers`, `Password`; @@ -923,7 +1002,32 @@ saved as octet-stream, and scripts, frames, remote pictures, fonts and style she (windows-1252, `file:///C:/` locations, VML fallbacks), a `cid:`-only page in EUC-KR, a picture on disk named by a `file:` URI that must not be read, and the routes to HTML, TXT, DOCX, HWPX and PDF. -## Data (JSON, YAML, TOML, XML, CSV, vCard, iCalendar) +## Signed files (.p7m) + +A `.p7m` (#1010) is a CMS / PKCS #7 SignedData envelope (RFC 5652; CAdES as Italian e-invoices and signed PDFs are +made): the signed file with its signature around it. `Signed/P7mConverter.cs` takes the file out with .NET's +`SignedCms` (System.Security.Cryptography.Pkcs): DER or BER, or the same saved as Base64 or PEM text. An envelope +signed again is opened again, up to 8 deep. Envelopes over 512 MB are refused. + +- **The signature is not checked**, and nothing in it is trusted: no certificate chain, no revocation list, no + time-stamp server, no network. The result is the plain file without the proof the signature gives; the `.p7m` is + left as it was. +- **The format inside** comes from the envelope's name (`invoice.xml.p7m` → XML), else from the first bytes: PDF and + pictures (as for files from a browser), XML, HTML, RTF, a MIME message (a signed e-mail, `smime.p7m`), ZIP and the + Word, Excel, PowerPoint, OpenDocument and EPUB files in one, plain UTF-8 text. Anything else is refused + (`error.p7m_unknown_content`). +- **The route** goes on from that format. The converter is an `IEnvelopeOpener`: `JobQueue` asks it for the format + inside before planning, and `RoutePlanner.PlanThrough` puts the step to it first (P7M → PDF → PNG); "same format", + OCR and the other options apply to the file inside. The converter has an edge from P7M to every format, so the donut + offers what any file inside could become, and a file inside that can't become the target fails with its own + format's "no route" (`xml → mp4`). +- **Names**: outputs are named after the file inside (`FormatRegistry.NameWithoutExtension`: `invoice.pdf.p7m` → + `invoice.pdf`, `invoice.png`). +- **Refused**: a detached signature (`error.p7m_detached`: the signed file is kept elsewhere), an encrypted S/MIME + envelope (`error.p7m_encrypted`: EnvelopedData, only the recipient's key opens it) and anything that isn't a + SignedData (`error.p7m_unreadable`). + +## Data (JSON, YAML, TOML, XML, CSV, vCard, iCalendar, property lists) `Data/DataConverter.cs` (engine id `data`) reads every format into one tree (`Data/DataTree.cs`: objects with their keys in file order, arrays, text, true / false, integers of any size, floats, TOML dates and null) and writes it @@ -935,6 +1039,7 @@ options: the output is always pretty-printed. |---|---|---|---| | JSON (`.json`) | System.Text.Json | comments and trailing commas allowed (JSONC) | non-ASCII text as it is; NaN / infinities as text ("NaN") | | HAR (`.har`, read only, #328) | System.Text.Json (`Data/HarData.cs`) | the HTTP Archive a browser's developer tools save: as JSON for JSON, YAML, TOML, XML and JSON Lines; as a table for CSV (and spreadsheets, Markdown, PDF through it), one row per request of `log.entries`: started, method, URL, status, time (ms), size (bytes), type. Pages, timings and headers stay in the JSON. A file without `log.entries` fails as no HAR | — (not written) | +| Property list (`.plist`, read only, #1256) | Filee (`Data/PlistData.cs`) | both kinds. XML plists are read by what their elements say (`dict`, `array`, `string`, `integer`, `real`, `true` / `false`, `date`, `data`); the DOCTYPE naming Apple's DTD is ignored and never fetched. A `` must have the shape Apple writes (`2020-05-17T10:00:00Z`) or one of its shortened forms that plistlib reads (`2020Z`, `2020-05Z`, …), which is written out in full; anything else is refused, so a date can't carry other text into a TOML file. An `` is decimal or hexadecimal (`0x10`) within Apple's 128 bits. Old-style OpenStep / NeXTSTEP text plists (`{ a = b; }`) aren't read: they are refused as neither binary nor XML. Binary ones (`bplist00`) are read by the layout in Apple's CFBinaryPList: integers of 1 to 16 bytes, floats, dates (seconds from 2001-01-01 UTC, converted), data, ASCII / UTF-16 / UTF-8 text, arrays, sets, dictionaries, UIDs. Values: dict → object (keys in file order), array and set → array, integers of any size exact, NaN and the infinities as in XML plists, a date → a date (a TOML date in TOML, text in the others), data → Base64 text, a UID → `{"CF$UID": n}` as `plutil` writes it; NSKeyedArchiver files come out as their raw `$objects` table. Every offset, length and reference of a binary plist is checked; a value that contains itself, a nesting deeper than 256 levels (also through a value shared by several others) and a file whose shared values would expand to more than 5 million values (a binary plist stores equal values once, so a few hundred bytes could otherwise name billions) are refused with a message, as are a dictionary key that isn't text, a version other than `bplist00` and a file cut short. Converts to JSON, JSON Lines, YAML, TOML, XML and CSV (a list of dictionaries) like any data file, and to **TXT**: the indented `key: value` listing of YAML, UTF-8 with a BOM | — (not written); null exists in binary plists only (TOML leaves it out, as always) | | JSON Lines (`.jsonl`, `.ndjson`) | System.Text.Json | one value per line, blank lines skipped, read as an array | one compact line per record (the array, or the array inside wrappers, see CSV) | | YAML (`.yaml`, `.yml`) | YamlDotNet (MIT) | YAML 1.2 core schema: unquoted `true`, `null` / `~`, numbers (`0x`, `0o`, `.inf`, `.nan`) are typed, the rest and anything quoted is text (`yes` / `no` stay text); explicit `!!str` / `!!int` … tags; anchors and aliases resolved (copied), merge keys (`<<`) applied; several documents (`---`) become an array; comments dropped; alias "bombs" (more than 5 million values) and nesting deeper than 256 refused | block style; text that would read back as another type (`"true"`, `"007"`, `""`, YAML 1.1's `yes` / `on`, dates) is quoted; multi-line text as a literal block (`\|`) | | TOML (`.toml`) | Tomlyn (BSD-2-Clause, TOML 1.1) | dates and times keep their TOML spelling (text in JSON / YAML) | written by Filee: plain keys, then `[tables]` and `[[arrays of tables]]`; **null values are left out** (TOML has none); a root that is not an object goes under `items` (`[[items]]`); integers beyond 64 bits as text | @@ -1102,13 +1207,26 @@ with one light and flat shading per triangle, vertex colours or the texture inte at 2048 px and scaled down to 1024 px for smooth edges, on a transparent background. The preset's image options (resize, background for JPG, quality) apply as for other pictures. +**Pictures → 3D relief** (`ImageTo3DConverter`, engine `image3d`): every picture ImageMagick reads (PNG, JPG, WebP, +HEIC, camera RAW, …; not EMF/WMF) becomes a relief in any of the six formats, only as a direct conversion (a picture +never reaches a model through another format). The picture is turned upright and scaled down to at most 200 × 200 +grid points, one per pixel; its longer side is **100 mm** (from the first pixel's centre to the last one's), the other in +proportion. Each point stands on a **2 mm base** plus **8 mm × its brightness** (Rec. 601 luma) × its opacity, so white +is 10 mm high and black or transparent pixels 2 mm. The mesh is one closed solid (the top grid, the same grid flat +underneath and four walls; every edge is shared by two triangles), so slicers take it as it is, and the picture reads +upright seen from above. GLB, glTF and OBJ get the picture as the material's texture (a PNG at the grid's size), 3MF and +PLY vertex colours, STL none; transparent pixels are light grey (#D9D9D9) and the walls take their edge pixels' colour. +A 200 × 200 grid is about 160,000 triangles, some 8 MB as STL. + Tests (`tests/Filee.Engines.Tests/ModelTests.cs`) build a cube, a sphere and a fan of coloured triangles in code and convert each through every format pair, checking triangle counts, bounds, signed volume (winding), normals, UVs, colours and materials where both formats keep them; hand-written STL, OBJ, PLY, 3MF and glTF files cover the reading details, and a generated 1,000,000-triangle STL the speed. `FbxTests.cs` writes FBX files in code (`FbxBuilder`: binary 7.4 and 7.5, plain and compressed arrays, ASCII): a cube under a moved parent, turned and scaled, with a templated material and an embedded or neighbouring texture, Z-up files in metres, rotation orders, concave polygons and -materials by polygon, and every cut or flipped byte of a file, which must read or fail with a clear message. +materials by polygon, and every cut or flipped byte of a file, which must read or fail with a clear message. The relief +tests draw pictures in code and check that the mesh is closed, the sizes and heights above, transparency, the 200-point +grid and every output format. ## Geospatial data (GeoJSON, KML / KMZ, GPX, Shapefile, CSV, FIT) @@ -1268,7 +1386,8 @@ writer, so every e-book also reaches HWPX, PDF and images. The document model is - **EPUB 2 / 3**: container.xml → OPF → spine; each chapter is read by the HTML reader and starts a new page, links between chapters become links to bookmarks, pictures come from the package, title / authors / language / cover - from the metadata. + from the metadata. A book (EPUB, HTMLZ, TXTZ) is unpacked with at most 100,000 entries, 1 GB per entry and 2 GB + in all, checked while unpacking; past that it is refused. - **EPUB output** is EPUB 3 with an NCX for older readers: `mimetype` first and stored, a navigation document from the headings, chapters split at level-1 headings and page breaks, one style sheet, pictures in formats every reader shows (others become JPEG / PNG), a cover page for covers the content does not show. @@ -1356,15 +1475,16 @@ bundled with the installer), never a 7-Zip installed on the PC. It runs as a sep | Format | Read | Write | |---|---|---| | ZIP, JAR, WBPZ | 7-Zip | ZIP in-process (System.IO.Compression): UTF-8 names with the language-encoding flag, times, empty folders | +| Minecraft MCPACK, MCADDON, MCWORLD, MCTEMPLATE; Modrinth MRPACK | 7-Zip (they are ZIPs) | ZIP in-process, with the marker file at the top (see below) | | 7Z | 7-Zip | 7-Zip | | TAR | 7-Zip | in-process (System.Formats.Tar, PAX: UTF-8 names, times) | | TAR.GZ | 7-Zip, in two stages | in-process (PAX TAR + GZipStream) | | TAR.BZ2, TAR.XZ | 7-Zip, in two stages | TAR in-process, compressed by 7-Zip | | TAR.7Z, TAR.Z | 7-Zip, in two stages | — | -| LZ, TAR.LZ (lzip) | Filee: each member rewritten as a .lzma file for 7-Zip's LZMA decoder, CRC and size checked | — | +| LZ, TAR.LZ (lzip) | Filee: each member rewritten as a .lzma file for 7-Zip's LZMA decoder, stopped at the size its trailer states, CRC checked | — | | GZ, BZ2, XZ | 7-Zip | 7-Zip; one file only (from another single-file format, or "Compress into one archive" with one file) | | LZMA, Z | 7-Zip | — | -| RAR 4/5, CAB, CPIO, DEB, RPM, DMG, ISO, IMG, LHA/LZH, ARJ | 7-Zip (an RPM's payload is unpacked until the files appear) | — | +| RAR 4/5, CAB, CPIO, DEB, RPM, DMG, ISO, UDF, IMG, LHA/LZH, ARJ | 7-Zip (an RPM's payload is unpacked until the files appear) | — | | Split archives (.001, .7z.001, .zip.001, .tar.001) | 7-Zip (combining .002, .003 … automatically) | — | | ALZ (ALZip) | Filee: store, deflate and ALZip's bzip2 variant | — | | EGG (ALZip) | Filee: store, deflate, bzip2, LZMA (decoded by 7-Zip); several blocks per file | — | @@ -1372,17 +1492,31 @@ bundled with the installer), never a 7-Zip installed on the PC. It runs as a sep - **Extract** (target `folder`): the archive is unpacked into a hidden folder next to the result and moved into place when complete. When the archive holds exactly one top folder, its content becomes the result folder (`photos.zip` with `photos/…` gives `photos/…`, not `photos/photos/…`). Name conflicts follow the preset: `name (2)`, overwrite - (files are merged into the existing folder) or skip. + (files are merged into the existing folder; refused before anything moves when that folder, or any folder in it that + would receive files, is a link or junction) or skip. - **Archive → archive** unpacks into the job's temp folder and packs everything into the target. `Preset.Archive.Level` maps to Store / Fastest / Optimal / SmallestSize in-process and to 7-Zip's `-mx0/1/5/9`. +- **Game packs** (#824, #898, the Games field): Minecraft and Modrinth read a marker file at the top of the ZIP + (`manifest.json` in a pack, `level.dat` in a world or template, `modrinth.index.json` in a modpack). A pack made from + an archive or a folder whose only top folder holds the marker gets that folder's content, not the folder + (`GamePacks.Root`); an add-on (MCADDON, several packs side by side) is packed as it is. The presets (MCPACK … + MRPACK) are in the palette only. - **Compress into one archive** (`Archive.CombineIntoOne`, the "One ZIP" preset): `JobQueue` hands all dropped files, of any type and unconverted, to `IFileCombiner` (implemented by this engine). The archive is named after the first file with the output pattern; files with the same name become `name (2).ext`. ZIP, TAR and TAR.GZ need no 7-Zip. - **Safety**: every archive is listed first. Encrypted entries or archives stop with "password-protected"; entries (or hard links) with `..`, a drive or a leading slash stop the job ("points outside the target folder", nothing is written); symbolic links are never unpacked and any link left afterwards is deleted; archives with more than - 1,000,000 entries or more data than the drive has free are refused; Filee's own readers also stop when data unpacks - to more than the entry declares, and check CRCs. + 1,000,000 entries or more data than the drive has free are refused (a listing without sizes still needs the 64 MB + reserve); Filee's own readers also stop when data unpacks to more than the entry declares, and check CRCs. +- **Single compressed streams** (GZ, BZ2, XZ, LZMA, Z, and the first stage of TAR.GZ, TAR.BZ2, TAR.XZ, TAR.Z) state + no size (bzip2) or one their author chose (gzip, modulo 4 GiB), so the listing can't be trusted: 7-Zip writes them + to its standard output (`x -so`) and Filee writes the file, stopping 7-Zip and deleting what was written once it + passes the room on the drive less a tenth of the free space ("unpacks to more than …, but only … is free"). +- **Mark of the Web** (Windows): unpacked files get the archive's `Zone.Identifier` stream, so a ZIP a browser saved + still gives SmartScreen prompts and Office's macro block once unpacked. 7-Zip copies it (`-snz`); for files Filee + writes itself (ALZ, EGG, lzip, single streams) it is copied after unpacking (`Core/Platform/MarkOfTheWeb`). A drive + without NTFS streams (FAT32, exFAT) gets none, and that never fails the job. - **Not supported**: encrypted archives, split (multi-volume) ALZ and EGG, solid EGG, EGG entries compressed with ESTsoft's own AZO method, LZO / TAR.LZO (lzop; 7-Zip has no reader either), and writing RAR, ALZ or EGG. - The ALZ and EGG readers are Filee's own code, written from the format descriptions of unalz (zlib licence), the @@ -1391,10 +1525,47 @@ bundled with the installer), never a 7-Zip installed on the PC. It runs as a sep - Tests (`ArchiveTests.cs`) build ALZ, EGG and lzip files in code (`ArchiveBuilders.cs`, with bzip2 and LZMA streams written by 7-Zip) and skip the 7-Zip parts when `engines/7zip` is missing. +## ISO images + +A dropped folder opens a small **Folders** donut with **ISO image**. Each selected folder creates a separate ISO, +with its contents at the disc root. The ISO preset is available in Settings > Toolbar for users to add to the +Archives donut; it is not on that donut by default. Supported archives can also be converted to ISO: Filee +safely extracts them into its temporary work folder, then writes their contents into the image. +Password-protected archives fail with a clear message. + +CLI: `filee convert "path/to/folder" --to iso` or `filee convert "backup.zip" --to iso`. +DiscUtils.Iso9660 writes ISO 9660 with Joliet in-process, preserving Unicode BMP names and empty directories. +There are no filesystem or compression options. ISO writing needs no external program; archive reading uses +the existing extraction engine. Images are data discs, not bootable clones. UDF is not included. + +Individual names must fit 62 UTF-16 characters, leaving room for DiscUtils' version suffix; relative disc paths +must fit 240 characters. Control characters, semicolons, backslashes, `*`, `:`, `?` and supplementary Unicode +characters are rejected clearly. Individual files must be smaller than 4 GiB. Drive roots and symbolic links/junctions +are refused. Timestamps are clamped to 1900-2155 in the image; +source timestamps are unchanged. On Windows, a marked input file transfers Mark of the Web to the image. + +File contents are streamed, progress reports bytes written, and cancellation is checked during enumeration and +streaming. A previous destination inside the source is excluded. A new image replaces its destination only after +all expected bytes have been written; failures and cancellation preserve any previous image. + ## LibreOffice - Each parallel worker gets its own profile under `%APPDATA%\Filee\libreoffice-profiles` (LibreOffice silently fails when two conversions share a profile). +- Before every start the profile's `user/registrymodifications.xcu` gets Filee's settings (`LibreOfficeProfile`): no + macros (`MacroSecurityLevel` 3, `DisableMacrosExecution`, no trusted locations), Word and Excel VBA not executable, + links never updated (Writer and Calc), and `BlockUntrustedRefererLinks`, which keeps LibreOffice from loading + anything a document links to: linked pictures, files and addresses, in every application. A document with macros + converts with the notice that they weren't run (`OfficeMacros`). +- LibreOffice gets a copy without links to content outside the document (`LinkedContent`): external relationships + (not hyperlinks) of Office Open XML, `xlink:href` outside an OpenDocument package, picture and style sheet addresses + of HTML other than `data:`, and RTF `INCLUDEPICTURE` fields, whose file the RTF reader loads whatever the profile + says. A local picture outside the document never ends up in the result, and no share or address is contacted. + An HTML page's pictures inside its own folder (the rule of `DocumentFiles.cs`) go into the copy as `data:` URIs, + except SVG, which can link to further files. XML parts are read with the limits of `BoundedXml.cs`. Binary Office + files (DOC, XLS, PPT) are left to the profile. Later changes to the copy (`DocxNoteMarks`) start from this copy. + Every copy of a package is written by `PackageRepacker`, which refuses one that unpacks to over 2 GB, or to over + twice its own size when that is more (`error.document_too_large`). - HWP/HWPX import comes from the H2Orestart extension, which is written in Java; Filee points LibreOffice at the downloaded JRE (`-env:UNO_JAVA_JFW_JREHOME`). - When LibreOffice can't load an Office Open XML or OpenDocument file ("source file could not be loaded"), Filee @@ -1442,10 +1613,13 @@ DocBook (`.dbk`, `.docbook`; written as DocBook 5), Jupyter notebooks (`.ipynb`) - DocBook, man pages, OPML, Typst, HTML and LaTeX are written with `--standalone` (a root element, a `.TH` header, a preamble), the text formats extract pictures into `_files` next to the result (Markdown with *Leave out pictures* gets none: a Lua filter drops them). -- Pictures are linked relative to the result (`_files/media/…`; Pandoc runs in the result's folder, with - `--resource-path` pointing at the source's for pictures the source links to), so both can be moved together. - EMF and WMF pictures, common in Word documents, become PNG (ImageMagick draws metafiles on Windows only; elsewhere - they stay). +- Pictures are linked relative to the result (`_files/media/…`; Pandoc runs in the result's folder), so both can + be moved together. EMF and WMF pictures, common in Word documents, become PNG (ImageMagick draws metafiles on + Windows only; elsewhere they stay). +- Pandoc runs twice, both times in its `--sandbox` (it reads nothing but the file it is given, so `\input` and + `.. include::` stay unread): the source → JSON AST, whose pictures Filee checks (only from the source's folder, + see *HWPX writer*), then the AST → the result, with those pictures handed over by a Lua filter. Pictures on the + web aren't fetched. - Markdown is GitHub-flavoured and kept free of HTML, which many Markdown editors show as code (#391). A Lua filter lays every table out as a pipe table: a merged cell keeps its text in its first position and leaves the cells it covered empty, the paragraphs and list items of a cell are joined with `
`, and a table without a header row @@ -1468,6 +1642,7 @@ SRT or SAMI file in CP949 comes out as UTF-8). | TTML / DFXP | TTML 1 and the 2006 DFXP namespace; times relative to their parents; clock, frames (`ttp:frameRate`), ticks and offset times; spans and referenced styles | TTML 1 with a bottom region and tts: spans | | MicroDVD (.sub) | `{frame}{frame}text` with the rate of a `{1}{1}25` first line (else 23.976), `\|` between lines, `{y:i}` / `{c:$BBGGRR}`; VobSub `.sub` (pictures) and SubViewer fail with a clear message | 23.976 fps, first line states it, plain text, `{Y:i}` for all-italic cues | | SCC | CEA-608 caption channel 1: pop-on, paint-on and roll-up captions, row preambles, italics, special and extended characters, doubled control codes, drop-frame time codes | — | +| TXT | — | the spoken text only (#1274): a line per cue with its lines joined, no times or styles, a line the same as the one before left out; UTF-8 with BOM, CRLF. Only straight from a subtitle file, never from a video's subtitle track | - **Encodings**: a byte order mark (UTF-8, UTF-16) or valid UTF-8 decide first, then a `charset=` / `encoding=` the file declares. Otherwise CP949 when the bytes are CP949 pairs, most of them Hangul syllables of KS X 1001 and hardly @@ -1511,6 +1686,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. @@ -1520,6 +1698,8 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa and Vulkan libraries are loaded only if present. Being over 60 MB, it is offered but not ticked on first run. - The Cut a clip window's preview plays its sound through `ffplay` when it sits next to Filee's FFmpeg (the gyan.dev build has it); without it, the preview plays silently. Nothing else uses ffplay. +- Crop video (#830) cuts the frame with the `crop` filter, to a ratio or to the box `cropdetect` finds inside the black + bars (`Media/MediaEncoding.Crop.cs`); the video is encoded again in its own format and the sound copied. - Container and codecs per target (`Media/MediaEncoding.cs`, every encoder checked with `ffmpeg -encoders`): | Target | Written as | @@ -1537,7 +1717,7 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa | APNG | one pass with the `apng` encoder (24-bit RGB, mixed prediction) and muxer, looping; the same frame rates, widths and playback speed as GIF | | MP4, M4V, MKV with *Video codec* HEVC · MOV with HEVC | x265 (preset medium, `hvc1` tag in MP4/M4V/MOV) + AAC | | MP4, M4V, MKV, WEBM with *Video codec* AV1 | SVT-AV1 (preset 8), or libaom-av1 (cpu-used 6, row multithreading) with an FFmpeg that lacks SVT-AV1 | - | MP3 · M4A/M4B · AAC · OGG · OPUS · WEBA · MKA · WMA · MP2 · AC3 · E-AC3 · AMR | LAME VBR (V0/V2/V5) or CBR · AAC, or ALAC with *Audio codec* ALAC · AAC (ADTS) · Vorbis · Opus · Opus · Opus · WMA 2 · MP2 · AC-3 · E-AC-3 · AMR-NB 12.2k (8 kHz mono) | + | MP3 · M4A/M4B/M4R (iPhone ringtone, always AAC) · AAC · OGG · OPUS · WEBA · MKA · WMA · MP2 · AC3 · E-AC3 · AMR | LAME VBR (V0/V2/V5) or CBR · AAC, or ALAC with *Audio codec* ALAC · AAC (ADTS) · Vorbis · Opus · Opus · Opus · WMA 2 · MP2 · AC-3 · E-AC-3 · AMR-NB 12.2k (8 kHz mono) | | WAV · W64 · AIFF · AU · CAF · VOC · FLAC · WV · TTA | 16-bit PCM (WAV and W64: 8/16/24/32-bit or 32-bit float; WAV switches to RF64 over 4 GB, Sony Wave64 has no limit) · FLAC · WavPack (lossless) · TTA | | SRT · ASS · VTT (from video) | the video's first text subtitle track, copied when it already is that format, else converted by FFmpeg | @@ -1559,6 +1739,8 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa - Preset options (`MediaOptions`): *Quality* picks CRF 18/23/28 (x264), 22/26/30 (x265), 26/32/40 (SVT-AV1 and libaom), 24/32/38 (VP9), quantizer 2/4/7 (MPEG-4, WMV, MPEG-2), Theora 8/6/4 and the audio bitrates (e.g. AAC and E-AC-3 256/192/128 kbit/s per stereo pair); *MaxHeight* scales down only, keeps the aspect ratio and even sizes; + *MaxSizeMb* and *MaxSizePercent* (a size limit in MB or as 1–99% of the source file, #553, #680) cap the bitrate at + what the video's length allows, re-encoding once more if it missed; *AudioBitrateKbps* overrides the audio bitrate (snapped to the MP3/MP2/AC-3 bitrate tables; the editor offers 32 to 320 kbit/s); *RemoveAudio* drops the sound; *AudioChannels* (0 source, 1 mono, 2 stereo) applies to every audio target but AMR; *AudioSampleRate* (8 to 48 kHz) and *WavFormat* (bit depth) apply to WAV and W64. @@ -1608,6 +1790,14 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa A video without subtitles fails with "The video has no subtitle track."; one with only picture subtitles (Blu-ray PGS, DVD, DVB) says they can't be turned into text without OCR. Video → SAMI, LRC, TTML, … goes through SRT and the subtitle engine (ASS and WebVTT extraction cost 11 so SRT is the hub). +- Video to video keeps the subtitle tracks as switchable tracks, with their language tags (#789, + `MediaEncoding.SubtitleTracks`). MKV keeps every SRT, ASS, WebVTT, PGS, DVD and DVB track as it is (other text + tracks become SRT) and its attachments (the fonts ASS subtitles are drawn with). MP4, M4V and MOV get the text tracks + as timed text (`mov_text`), WebM as WebVTT. Picture subtitles (PGS, DVD, DVB) can't go into those three: they are + left out with a notice that MKV keeps them. TTML and CEA-608 tracks are left out, and other targets (AVI, FLV, TS, + …) and clips keep no subtitles. When FFmpeg fails with the subtitle tracks, the video is converted once more without + them (and without the fonts), the first error is logged and the result gets a notice; a cancellation, a stall or an + output FFmpeg can't write (a full disk, no permission) is not retried. - The first video stream (cover pictures excluded) is converted. Video to video keeps every audio track (languages, commentary), each copied where the target takes its codec or encoded on its own, with its language tag; FLV and DV hold one and keep the first. Video to audio takes the first. Tracks FFmpeg can't decode (no decoder, like the spatial @@ -1741,6 +1931,14 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa scans and pictures with Tesseract (`Ocr/TesseractConverter.cs`), in the language the preset names (`PdfOptions.OcrLanguage`, a Tesseract code; English by default). The built-in "Searchable PDF" preset turns it on. +Text that mixes languages (issue #1123) has several codes in that one setting, the main language first, joined with +`+` as Tesseract's `-l` takes them (`chi_sim+eng`). `OcrLanguages.Parse` is the only reader: a code not in the list is +left out, repeats count once, and with none left English is read. Settings from before this hold one code and read as +they always did; an older Filee reads a joined value as a code it doesn't know, so English. Each language is its own +download and is fetched before the file is read; a download that fails refuses the file and names that language. Every +language makes Tesseract slower (it tries the others on words the first one reads badly), so the editor's "Other +languages in the text" says to add only what the text has. + - The route planner puts an OCR edge first (`ConversionEdge.Ocr`, `RoutePlanner.Plan(ocr: true)`): picture or PDF → searchable PDF, then the cheapest ordinary route from that PDF. A picture Tesseract can't open (HEIC, PSD, …) becomes a PNG first. OCR edges are never part of an ordinary route. @@ -1769,12 +1967,14 @@ level and the newest revision the Mac has), part of macOS, so nothing is downloa Tesseract and is registered before it, so the route planner takes it; on Windows and Linux it is unavailable ("Not available on this platform"). -- The preset's language (a Tesseract code) is mapped to Vision's identifiers (`VisionLanguages`) and asked for with - English next to it; if Vision refuses the pair it reads with the language alone. Which languages Vision reads comes +- The preset's languages (Tesseract codes) are mapped to Vision's identifiers (`VisionLanguages`) and asked for in their + order. English is added when it wasn't chosen and goes last unless it is the main language; if Vision refuses the + combination it reads with one language fewer, down to the main language alone. Which languages Vision reads comes from the Mac itself (`supportedRecognitionLanguagesAndReturnError:`, more with newer macOS versions); a language it - doesn't read (Hindi, Nepali, Greek, …) goes to Tesseract when that is installed, and is refused otherwise with - "Apple Vision can't read this language. Install Tesseract …". The preset editor shows no download size for the - languages Vision reads, and its hint names Apple Vision. + doesn't read (Hindi, Nepali, Greek, …) goes to Tesseract when that is installed, together with the preset's other + languages, and is refused otherwise with "Apple Vision can't read this language. Install Tesseract …" and its name. + The preset editor shows no download size for the languages Vision reads, unless such a language among the chosen ones + sends them all to Tesseract, and its hint names Apple Vision. - PDFs go page by page exactly like Tesseract's (`TesseractConverter.RecognizePdfAsync`, at most two pages at a time). A JPEG picture goes into the PDF as it is, other pictures (and a JPEG that EXIF turns) as an upright PNG; every page of a TIFF becomes a page. The page size comes from the picture's resolution, 300 dpi when it has none. @@ -1794,6 +1994,8 @@ Tesseract and is registered before it, so the route planner takes it; on Windows ## Whisper Filee transcribes speech in video and audio files into SRT subtitle files using OpenAI Whisper (`whisper.cpp`). +A preset converting to TXT with *Generate subtitles* on gets the text alone (#1274): Whisper's SRT goes through the +subtitle engine's TXT writer (see *Subtitles*). Transcription runs entirely locally and offline on your computer; no audio or data is sent to external servers. - **Platform**: Windows only (x64). On macOS and Linux, the Whisper package is not offered and the engine reports @@ -1805,3 +2007,31 @@ Transcription runs entirely locally and offline on your computer; no audio or da formats requires FFmpeg. FFmpeg must be installed (via Settings → *Engines*, bundled, or found on `PATH`). - **Languages**: Whisper supports auto-detection (`auto`) as well as specific language codes (`-l `, e.g. `hi` for Hindi, `en` for English, `es` for Spanish, `fr` for French, `ja` for Japanese, `zh` for Chinese). + +## Document & Book Translator (Offline AI) + +Filee translates documents, books, and text files locally using a local Ollama instance over loopback (`http://127.0.0.1:11434`). Translation runs entirely on the user's computer: no text or document data is ever uploaded or sent to third-party web services. + +- **Offline privacy**: Adheres strictly to Filee's offline promise: "Filee converts files on your own computer and never uploads them." Translation exclusively uses local Ollama over loopback (`127.0.0.1:11434`). Cloud-forwarded models (`*-cloud` or models reporting remote endpoints) are refused with a security block to ensure privacy. No external web services or network calls outside loopback are ever made. +- **Where it lives**: + - Available as the **Translate** preset in Settings → *Presets*, where the target language (e.g. English, Spanish, Hindi, French, German, Chinese, Japanese, etc.) is configured as the single source of truth. + - Sits cleanly on the back face (`BackPresetIds`) of document and text profiles for fresh installs, keeping front faces clean and untouched (#806 compliance). + - Manageable from the Engines page via **Manage Ollama**, providing status, local model selection, pulling offline models, and starting/stopping the local Ollama server. +- **Supported formats and layout preservation**: + - **Documents & E-books**: DOCX, HWPX, EPUB, KEPUB, FB2, HTMLZ, TXTZ, MD, TXT, and HTML are translated in place, preserving document structure, headings, bold/italic runs, tables, lists, images, and notes. + - **Kindle e-books**: MOBI, AZW, AZW3, and PRC formats are translated via EPUB, and can then be routed to any supported target format. + - **PDF target**: When a document is translated with PDF as the target format, the route planner translates the document model first and then routes the translated document through Filee's standard document-to-PDF export pipeline. + +## Image Quality Models (Offline AI Upscaler) + +Filee upscales and restores images locally using an offline AI super-resolution engine based on Vulkan/NCNN (`upscayl-bin` / `upscayl-ncnn`) and curated open-source neural network models (`upscayl/custom-models`). All processing runs 100% offline on your computer; no image data is ever uploaded or sent to external servers. + +- **Offline privacy & local execution**: Like all Filee features, image upscaling processes every pixel locally on your machine via direct Vulkan GPU acceleration. No network connection is used during upscaling. Requires a Vulkan-compatible graphics device. +- **Models catalog**: Curated selection of high-quality image models for photos, digital art, illustration, and restoration: + - Fast & lightweight models (integrated and entry-level GPUs): `4xLSDIRCompactC3`, `RealESRGAN_General_x4_v3`, `RealESRGAN_General_WDN_x4_v3`. + - Detailed & high-fidelity models (recommended for dedicated GPUs): `4x_NMKD-Siax_200k`, `4x_NMKD-Superscale-SP_178000_G`, `4xLSDIR`, `4xLSDIRplusC`, `4xNomos8kSC`, `4xHFA2k`. +- **Where it lives**: + - **Palette**: The **Upscale** preset opens the interactive Upscale dialog to select models, scale factors (2× to 4×), preview output resolution, and upscale directly. + - **Settings → Presets**: The "Upscale Image" tool preset can be configured with preferred default models and scale factors. + - **Settings → Engines**: "Upscayl (AI image upscaler)" engine package provides the local `upscayl-bin` binary, with models downloaded on demand with live progress and uninstall support in the Upscale dialog. +- **Supported formats**: PNG, JPG, WEBP, ICO, AVIF, BMP, and TIFF.