diff --git a/docs/ENGINES.md b/docs/ENGINES.md index b95a12d14..f129306d4 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), the photo of an iPhone LIVP, the merged picture of a Krita KRA and the icon of a Windows program or library (EXE, DLL) 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 (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, DART, HQL, DBML, JAVA → 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 | @@ -30,9 +30,9 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | **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) and OpenStreetMap XML (`.osm`), both 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) | +| **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), 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 | +| 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; renders CMYK PDF/AI pages for CMYK JPG/TIFF | @@ -329,13 +329,6 @@ in it also builds the package (artifact `windows-arm64-package`). 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. -- **EXE / DLL** (a Windows program or library, #1188) is read as its icon, the one Explorer shows: the first icon group - of its resources, put back together as an `.ico` in memory (`ProgramIcon`, Filee's own reader for 32 and 64-bit PE - files; only the headers and the icon are read, at most 64 MB, and the file is never loaded or run). To ICO it is - that icon as it is, every size the program has, unless the preset changes the picture or asks for other icon - sizes; then, as for every other target, the largest size is converted. A file without an icon of its own, or a - 16-bit or damaged one, gives a clear error. Programs are converted only when dropped or named: watch folders and - folders given to the command line skip them, and Recent conversions never opens one. - **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, @@ -1536,7 +1529,7 @@ bundled with the installer), never a 7-Zip installed on the PC. It runs as a sep | 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 | — | @@ -1769,7 +1762,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 | diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index dad011a79..69617fad2 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -269,7 +269,8 @@ .. WhisperLanguages.All new Choice("tr", "Türkçe"), new Choice("ne", "नेपाली"), new Choice("th", "ไทย"), - new Choice("id", "Bahasa Indonesia") + new Choice("id", "Bahasa Indonesia"), + new Choice("uk", "Українська") ]; _targetLanguage = TranslationLanguageChoices.FirstOrDefault(l => string.Equals(l.Value, _edit.Translate.TargetLanguage, StringComparison.OrdinalIgnoreCase)) ?? TranslationLanguageChoices[0]; diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs index dae0079d1..84852bcb5 100644 --- a/src/Filee.Core/Formats/FormatFields.cs +++ b/src/Filee.Core/Formats/FormatFields.cs @@ -69,8 +69,8 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow "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, [ diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 9cfb125fc..c40592595 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -155,12 +155,15 @@ public static class FormatRegistry new("sda", "SDA", FormatCategory.Presentation, ["sda", "sdd"], Writable: false), // Plain text / markup - // Text logs, source code, database schemas (DBML), 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). + // 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", "dart", "hql", "dbml", "java"]), + "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. @@ -266,6 +269,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"]), @@ -330,6 +335,8 @@ public static class FormatRegistry new("dmg", "DMG", FormatCategory.Archive, ["dmg"], 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), diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index a7de18ee7..d28dcdf10 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -132,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 = 78; + public const int CurrentSchemaVersion = 77; public int SchemaVersion { get; set; } = CurrentSchemaVersion; 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 d8d7825a8..548cd8991 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -1081,14 +1081,12 @@ public static bool ApplyToLibrary(int from, List presets, List turnedOff) => "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", "osm", "otf", "pam", "pcd", "pcx", "pdb", "pfm", "plist", "plt", "ply", "pml", "ppm", "prc", "ps", "psb", "psd", + "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", @@ -144,8 +144,8 @@ public void Grade_counts_match_the_published_table() new Dictionary { [FormatGrade.Everyday] = 19, - [FormatGrade.General] = 96, - [FormatGrade.Specialist] = 115, + [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 37bb074fa..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 and HiveQL, DBML database schemas, INI files, RouterOS exports, CSS -// style sheets and C, C++, C#, Dart and Java source files are plain text (issues #388, #368, #442, #607, #536, #630, #661, -// #660, #864, #828, #980, #997, #854, #1176): recognised, and learnt by existing text donuts. +// 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; @@ -34,6 +36,16 @@ public class ScriptTextTests [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] @@ -161,4 +173,35 @@ public void Version_70_text_donuts_learn_java_without_taking_custom_claims() 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)); + } } diff --git a/tests/Filee.Engines.Tests/ArchiveTests.cs b/tests/Filee.Engines.Tests/ArchiveTests.cs index b042433d5..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")] diff --git a/tests/Filee.Engines.Tests/MediaTests.cs b/tests/Filee.Engines.Tests/MediaTests.cs index 5ff65d91b..0ad4b7404 100644 --- a/tests/Filee.Engines.Tests/MediaTests.cs +++ b/tests/Filee.Engines.Tests/MediaTests.cs @@ -1221,6 +1221,15 @@ MediaInfo Source(string codec, string pixelFormat) => 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() { diff --git a/tests/Filee.Engines.Tests/ScriptTextTests.cs b/tests/Filee.Engines.Tests/ScriptTextTests.cs index 586c0ebff..ee14dc93d 100644 --- a/tests/Filee.Engines.Tests/ScriptTextTests.cs +++ b/tests/Filee.Engines.Tests/ScriptTextTests.cs @@ -1,31 +1,208 @@ -// Lua scripts, CSS style sheets, DBML database schemas and Java source files (issues #388, #368, #854, #1176) become a PDF -// of their text, never run, compiled or translated. +// 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)")] - [InlineData("shop.dbml", "Table customers { id integer [primary key] }")] - [InlineData("Hello.java", "class Hello { }")] - 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 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); + 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"] }); - var job = await fx.ConvertAsync([path], new Preset { TargetFormat = "pdf" }); + Assert.True(SettingsMigrations.ApplyToLibrary(33, 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 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() + { + 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)); } [Fact]