From 74502568afca281ada3d43805c6b965ba5c9b4d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 11:04:20 +0000 Subject: [PATCH 1/8] feat(presets): ask every time for the compression level (#1353) Preset.AskCompression, off by default: no settings migration. On for the four "Compress" presets in a new install only, as Split PDF's pages (#981). AskedCompression decides which drops ask (not a merge, group or tool) and what each file gets: the quality of a picture in a lossy format, the quality of a video, the bitrate of sound, the level of a PDF. A PNG or a document converts as the preset says. Apply gives the preset as it runs, and the arithmetic the window's estimate uses (bitrate x length, the size limit and "only keep if smaller" as caps). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BPxh5siZR4PzK81twSxnAb --- src/Filee.Core/Presets/AskedCompression.cs | 115 ++++++++++++++++++ src/Filee.Core/Presets/BuiltInData.cs | 12 +- src/Filee.Core/Presets/Preset.cs | 9 ++ .../Filee.Core.Tests/AskedCompressionTests.cs | 109 +++++++++++++++++ 4 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 src/Filee.Core/Presets/AskedCompression.cs create mode 100644 tests/Filee.Core.Tests/AskedCompressionTests.cs diff --git a/src/Filee.Core/Presets/AskedCompression.cs b/src/Filee.Core/Presets/AskedCompression.cs new file mode 100644 index 000000000..ea7ced31c --- /dev/null +++ b/src/Filee.Core/Presets/AskedCompression.cs @@ -0,0 +1,115 @@ +// "Ask every time" for the compression (#1353): which drops ask, what the window offers for each kind of file, the +// preset as it runs once the user has chosen, and the arithmetic behind the size it shows. The estimate itself (reading +// the files) is Filee.Engines' CompressionEstimator. + +using System.Globalization; +using Filee.Core.Formats; + +namespace Filee.Core.Presets; + +/// What the window sets for a dropped file: the setting that decides how small it gets. +public enum CompressionKind +{ + /// A picture saved in a lossy format: . + Image, + /// A video encoded again: . + Video, + /// Sound encoded again, from an audio or a video file: . + Audio, + /// A PDF that stays a PDF: . + Pdf, +} + +/// The settings the window chose; only the ones of the kinds dropped matter. +/// 0 for the bitrate the quality picks, as in the preset editor. +public sealed record CompressionChoice(int ImageQuality, MediaQuality VideoQuality, int AudioBitrateKbps, PdfCompression Pdf) +{ + /// The preset's own settings: what the window starts with. + public static CompressionChoice Of(Preset preset) => + new(preset.Image.Quality, preset.Media.Quality, preset.Media.AudioBitrateKbps, preset.Pdf.Compression); +} + +public static class AskedCompression +{ + /// Picture formats with a quality to choose (the ones the preset editor shows its slider for). + public static readonly IReadOnlySet LossyImageTargets = new HashSet(["jpg", "webp", "avif", "heic"]); + + /// + /// The preset asks for the compression of the files dropped on it (). Not a + /// merge: the window shows what each file comes to, which a joined result isn't; a merge keeps the preset's values. + /// + public static bool Asks(Preset preset) => + preset.AskCompression && !preset.IsGroup && !preset.IsTool + && !preset.JoinsIntoOneMedia && !preset.JoinsImages && !(preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle); + + /// + /// What the window sets for on , or null when the preset converts + /// it without a level to choose (a PNG stays lossless, a document isn't compressed): such a file converts as the + /// preset says. Also a browser file, by its name. + /// + public static CompressionKind? KindOf(Preset preset, string path) + { + if (FormatRegistry.Detect(path) is not { } source) + return null; + var target = preset.TargetFormat == BuiltInData.SameAsSource ? source.Id : preset.TargetFormat; + var category = FormatRegistry.FindById(target)?.Category; + return (source.Category, category) switch + { + (FormatCategory.Pdf, FormatCategory.Pdf) when !preset.RecognizesTextOf("pdf") && !preset.Pdf.MakesSheets => CompressionKind.Pdf, + (FormatCategory.Image, FormatCategory.Image) when LossyImageTargets.Contains(target) => CompressionKind.Image, + (FormatCategory.Video, FormatCategory.Video) => CompressionKind.Video, + (FormatCategory.Audio or FormatCategory.Video, FormatCategory.Audio) => CompressionKind.Audio, + _ => null, + }; + } + + /// The kinds among , in the order of . + public static IReadOnlyList KindsOf(Preset preset, IEnumerable paths) => + [.. paths.Select(path => KindOf(preset, path)).OfType().Distinct().Order()]; + + /// + /// The preset as it runs: the , and no asking again (a retry from history converts with + /// the same settings). A copy; the saved preset is left alone. + /// + public static Preset Apply(Preset preset, CompressionChoice choice) + { + var copy = preset.Clone(); + copy.Image.Quality = Math.Clamp(choice.ImageQuality, 1, 100); + copy.Media.Quality = choice.VideoQuality; + copy.Media.AudioBitrateKbps = Math.Max(0, choice.AudioBitrateKbps); + copy.Pdf.Compression = choice.Pdf; + copy.AskCompression = false; + return copy; + } + + /// + /// Bytes of a media file of at (video and sound together), with + /// 2 % for the container (MP4's index and headers). + /// + public static long MediaBytes(double seconds, double kbps) => + seconds > 0 && kbps > 0 ? (long)Math.Round(seconds * kbps * 1000 / 8 * 1.02) : 0; + + /// + /// What a file of estimated at ends up as: at most the + /// preset's size limit (, 0 for none; the encoder keeps to it) and, with "Only keep if + /// smaller" (), at most the original, which is kept then. + /// + public static long Result(long sourceBytes, long encodedBytes, long limitBytes, bool onlyIfSmaller) + { + var result = limitBytes > 0 ? Math.Min(encodedBytes, limitBytes) : encodedBytes; + return onlyIfSmaller && sourceBytes > 0 ? Math.Min(result, sourceBytes) : result; + } + + /// How much smaller is than , in whole percent (0 when it isn't). + public static int PercentSmaller(long source, long estimated) => + source > 0 && estimated < source ? (int)Math.Round((source - estimated) * 100.0 / source) : 0; + + /// "850 KB", "12.3 MB", "123 MB", "1.2 GB", in megabytes of 1,000,000 bytes as Filee shows sizes. + public static string SizeText(long bytes) => bytes switch + { + >= 1_000_000_000 => string.Create(CultureInfo.InvariantCulture, $"{bytes / 1e9:0.0} GB"), + >= 100_000_000 => string.Create(CultureInfo.InvariantCulture, $"{bytes / 1e6:0} MB"), + >= 1_000_000 => string.Create(CultureInfo.InvariantCulture, $"{bytes / 1e6:0.0} MB"), + _ => string.Create(CultureInfo.InvariantCulture, $"{Math.Max(1, Math.Round(bytes / 1e3)):0} KB"), + }; +} diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs index ae1b7b038..06aaae9bf 100644 --- a/src/Filee.Core/Presets/BuiltInData.cs +++ b/src/Filee.Core/Presets/BuiltInData.cs @@ -393,15 +393,17 @@ public static List MediaToolPresets() => [ new() { - // Quality 75 like most photo apps' "smaller"; a JPEG saved below it stays at its own quality. - Id = "compress-image", NameKey = "preset.compress_image", TargetFormat = SameAsSource, + // Quality 75 like most photo apps' "smaller"; a JPEG saved below it stays at its own quality. The four + // "Compress" presets ask for the level on each drop (#1353): in a new install only, an existing library + // keeps them as they are. + Id = "compress-image", NameKey = "preset.compress_image", TargetFormat = SameAsSource, AskCompression = true, Image = { Quality = 75, MaxCompression = true, TiffCompression = TiffCompression.Zip }, Output = { FileNamePattern = "{name}_compressed", OnlyIfSmaller = true }, }, new() { // H.264 at the Small quality, at most 1080p: plays everywhere, a fraction of a phone video's size. - Id = "compress-video", NameKey = "preset.compress_video", TargetFormat = "mp4", + Id = "compress-video", NameKey = "preset.compress_video", TargetFormat = "mp4", AskCompression = true, Media = { Quality = MediaQuality.Small, MaxHeight = 1080 }, Output = { FileNamePattern = "{name}_compressed", OnlyIfSmaller = true }, }, new() @@ -422,7 +424,7 @@ public static List MediaToolPresets() => new() { // AAC at the Small quality: 128 kbit/s for stereo. - Id = "compress-audio", NameKey = "preset.compress_audio", TargetFormat = "m4a", + Id = "compress-audio", NameKey = "preset.compress_audio", TargetFormat = "m4a", AskCompression = true, Media = { Quality = MediaQuality.Small }, Output = { FileNamePattern = "{name}_compressed", OnlyIfSmaller = true }, }, new() @@ -462,7 +464,7 @@ public static List PdfCompressionPresets() => [ new() { - Id = "compress-pdf", NameKey = "preset.compress_pdf", TargetFormat = "pdf", + Id = "compress-pdf", NameKey = "preset.compress_pdf", TargetFormat = "pdf", AskCompression = true, Pdf = { Compression = PdfCompression.Ebook }, Output = { FileNamePattern = "{name}_compressed" }, }, ]; diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index 76d16fdf0..4d95ff496 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -146,6 +146,14 @@ public sealed class Preset /// Where and how output files are written. public OutputRule Output { get; set; } = new(); + /// + /// "Ask every time" for the compression (#1353): files dropped on the donut open a small window first, where the + /// picture quality, the video quality, the audio bitrate or the PDF level is chosen with the size it will roughly + /// come to (). Off by default; watch folders and the command line use the preset's + /// own values. + /// + public bool AskCompression { get; set; } + /// A conversion or a group. A kind this version doesn't know (from a newer Filee) is read as a group. [JsonConverter(typeof(PresetKindJsonConverter))] public PresetKind Kind { get; set; } = PresetKind.Convert; @@ -404,6 +412,7 @@ public Preset ForSharing() Upscale = Upscale.Clone(), Metadata = Metadata?.Clone(), Output = Output.Clone(), + AskCompression = AskCompression, Kind = Kind, Tool = Tool, SubPresetIds = [.. SubPresetIds], diff --git a/tests/Filee.Core.Tests/AskedCompressionTests.cs b/tests/Filee.Core.Tests/AskedCompressionTests.cs new file mode 100644 index 000000000..4601506d6 --- /dev/null +++ b/tests/Filee.Core.Tests/AskedCompressionTests.cs @@ -0,0 +1,109 @@ +// "Ask every time" for the compression (#1353): which presets and files ask, the preset as it runs once the level is +// chosen, and the arithmetic behind the size the window shows. + +using Filee.Core.Presets; + +namespace Filee.Core.Tests; + +public class AskedCompressionTests +{ + private static Preset BuiltIn(string id) => BuiltInData.CreatePresets().Single(p => p.Id == id); + + [Theory] + [InlineData("compress-image")] + [InlineData("compress-video")] + [InlineData("compress-audio")] + [InlineData("compress-pdf")] + public void The_compress_presets_ask_in_a_new_library(string id) => Assert.True(AskedCompression.Asks(BuiltIn(id))); + + [Fact] + public void Other_presets_and_a_new_preset_do_not_ask() + { + Assert.False(new Preset().AskCompression); + Assert.All(BuiltInData.CreatePresets().Where(p => !p.Id.StartsWith("compress-", StringComparison.Ordinal)), + p => Assert.False(p.AskCompression, p.Id)); + } + + [Fact] + public void A_merge_a_group_or_a_tool_never_asks() + { + Assert.False(AskedCompression.Asks(new Preset { TargetFormat = "pdf", AskCompression = true, Pdf = { MergeIntoSingle = true } })); + Assert.False(AskedCompression.Asks(new Preset { TargetFormat = "mp4", AskCompression = true, Media = { JoinIntoOne = true } })); + Assert.False(AskedCompression.Asks(new Preset { Kind = PresetKind.Group, AskCompression = true })); + Assert.False(AskedCompression.Asks(new Preset { Kind = PresetKind.Tool, AskCompression = true })); + Assert.True(AskedCompression.Asks(new Preset { TargetFormat = "jpg", AskCompression = true })); + } + + [Theory] + [InlineData("compress-image", "photo.jpg", CompressionKind.Image)] + [InlineData("compress-image", "photo.webp", CompressionKind.Image)] + [InlineData("compress-image", "screen.png", null)] + [InlineData("compress-image", "scan.tiff", null)] + [InlineData("compress-video", "clip.mov", CompressionKind.Video)] + [InlineData("compress-video", "photo.jpg", null)] + [InlineData("compress-audio", "song.wav", CompressionKind.Audio)] + [InlineData("compress-audio", "clip.mp4", CompressionKind.Audio)] + [InlineData("compress-pdf", "report.pdf", CompressionKind.Pdf)] + [InlineData("compress-pdf", "letter.docx", null)] + public void Each_file_gets_the_setting_that_makes_it_smaller(string preset, string file, CompressionKind? kind) => + Assert.Equal(kind, AskedCompression.KindOf(BuiltIn(preset), file)); + + [Fact] + public void A_png_converted_to_jpg_gets_a_quality() + { + var toJpg = new Preset { TargetFormat = "jpg", AskCompression = true }; + + Assert.Equal(CompressionKind.Image, AskedCompression.KindOf(toJpg, "screen.png")); + Assert.Equal([CompressionKind.Image, CompressionKind.Pdf], + AskedCompression.KindsOf(new Preset { TargetFormat = BuiltInData.SameAsSource }, ["b.pdf", "a.jpg", "c.jpg", "notes.txt"])); + } + + [Fact] + public void The_choice_is_applied_to_a_copy_that_asks_no_more() + { + var preset = BuiltIn("compress-video"); + + var asked = AskedCompression.Apply(preset, new CompressionChoice(55, MediaQuality.High, 96, PdfCompression.Screen)); + + Assert.NotSame(preset, asked); + Assert.Equal((55, MediaQuality.High, 96, PdfCompression.Screen, false), + (asked.Image.Quality, asked.Media.Quality, asked.Media.AudioBitrateKbps, asked.Pdf.Compression, asked.AskCompression)); + // The saved preset is left as it was, and the rest of the copy is the preset's. + Assert.Equal((MediaQuality.Small, true), (preset.Media.Quality, preset.AskCompression)); + Assert.Equal(1080, asked.Media.MaxHeight); + Assert.Equal(CompressionChoice.Of(preset) with { VideoQuality = MediaQuality.High }, + CompressionChoice.Of(AskedCompression.Apply(preset, CompressionChoice.Of(preset) with { VideoQuality = MediaQuality.High }))); + } + + [Fact] + public void Asking_survives_a_clone() => Assert.True(BuiltIn("compress-pdf").Clone().AskCompression); + + [Fact] + public void Media_bytes_are_bitrate_times_length_with_room_for_the_container() + { + // 100 s at 128 kbit/s: 1.6 MB of sound, + 2 %. + Assert.Equal(1_632_000, AskedCompression.MediaBytes(100, 128)); + Assert.Equal(0, AskedCompression.MediaBytes(0, 128)); + } + + [Fact] + public void The_result_keeps_to_the_limit_and_to_the_original() + { + Assert.Equal(30_000_000, AskedCompression.Result(100_000_000, 30_000_000, 0, onlyIfSmaller: true)); + Assert.Equal(10_000_000, AskedCompression.Result(100_000_000, 30_000_000, 10_000_000, onlyIfSmaller: true)); + // Bigger than the original: kept as it is with "only keep if smaller", written as it is without. + Assert.Equal(100_000_000, AskedCompression.Result(100_000_000, 120_000_000, 0, onlyIfSmaller: true)); + Assert.Equal(120_000_000, AskedCompression.Result(100_000_000, 120_000_000, 0, onlyIfSmaller: false)); + } + + [Fact] + public void Sizes_and_savings_read_as_filee_shows_them() + { + Assert.Equal(70, AskedCompression.PercentSmaller(100_000_000, 30_000_000)); + Assert.Equal(0, AskedCompression.PercentSmaller(100_000_000, 120_000_000)); + Assert.Equal("850 KB", AskedCompression.SizeText(850_000)); + Assert.Equal("30.0 MB", AskedCompression.SizeText(30_000_000)); + Assert.Equal("123 MB", AskedCompression.SizeText(123_400_000)); + Assert.Equal("1.2 GB", AskedCompression.SizeText(1_200_000_000)); + } +} From 91b2befa2cacf38260b4fa11f79bd3487a35517b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 11:04:20 +0000 Subject: [PATCH 2/8] feat(engines): estimate the size a compression level comes to (#1353) CompressionEstimator reads the dropped files once and estimates their total at each setting the window offers: - video and audio: the bitrate the encoder is given (for a constant quality video, the table Filee already uses to give a graphics chip the same quality) times the length ffprobe reads; a copied video keeps its size; - pictures: a full-resolution tile from the middle is really encoded at the quality and scaled up to the whole picture; - PDF: the built-in compressor really runs, into a stream that only counts bytes, once per level. The preset's size limit and "only keep if smaller" cap the result. Only the first files of each kind are read; the others count at their ratio. Tests hold the estimate against real conversions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BPxh5siZR4PzK81twSxnAb --- src/Filee.Engines/CompressionEstimator.cs | 322 ++++++++++++++++++ src/Filee.Engines/Media/MediaEncoding.cs | 25 ++ .../CompressionEstimatorTests.cs | 229 +++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 src/Filee.Engines/CompressionEstimator.cs create mode 100644 tests/Filee.Engines.Tests/CompressionEstimatorTests.cs diff --git a/src/Filee.Engines/CompressionEstimator.cs b/src/Filee.Engines/CompressionEstimator.cs new file mode 100644 index 000000000..7b17a48de --- /dev/null +++ b/src/Filee.Engines/CompressionEstimator.cs @@ -0,0 +1,322 @@ +// The size the compression window shows (#1353): about how large the files dropped on a "Compress" preset come out +// at the setting being chosen. It is an estimate, and the window says so; each kind is guessed the most honest cheap way: +// +// - Video and audio: the bitrate the encoder is given (or, for a constant-quality video, the bitrate Filee already uses +// to give a graphics chip the same quality, HardwareVideo.BitrateKbps) times the length ffprobe reads. Sound is close; +// a video's real size depends on its picture (a still screen recording comes out far smaller, confetti larger). +// - Pictures: a tile from the middle of the picture, at full resolution, is really encoded at the quality and the bytes +// scaled up to the whole picture. Encoding all of it on every slider step would be too slow for a phone photo, and a +// scaled-down copy has more detail per pixel and would guess too high. +// - PDF: the built-in compressor really runs, into a stream that only counts bytes; a level is done once. Nothing +// cheaper can tell how much of a PDF is pictures drawn above the level's resolution. Ghostscript, when the preset +// also tries it, is not run: it can only make the result smaller. +// +// The preset's size limit and "only keep if smaller" cap the result, as they do when it converts. Only the first files +// of each kind are read; the others count at the same ratio. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Engines.Magick; +using Filee.Engines.Media; +using Filee.Engines.Pdf; +using ImageMagick; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.Engines; + +/// Estimates what the files dropped on a preset that asks for its compression come to (). +public sealed class CompressionEstimator : IDisposable +{ + /// Pixels of the tile a picture is estimated from (about 0.4 MP: a few milliseconds per JPEG step). + internal const int TileSide = 640; + + /// Files of each kind read for the estimate; the others count at the same ratio. + internal static int SampledFiles(CompressionKind kind) => kind switch + { + CompressionKind.Image => 12, + CompressionKind.Pdf => 4, + _ => 40, + }; + + private readonly Preset _preset; + private readonly List _entries; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + + private CompressionEstimator(Preset preset, List entries, ILogger logger) + { + _preset = preset; + _entries = entries; + _logger = logger; + } + + /// The files a level is chosen for (), in the order dropped. + public int FileCount => _entries.Count; + + /// Their size now, in bytes. + public long SourceBytes => _entries.Sum(e => e.Bytes); + + /// + /// Reads the files of that asks a level for: their sizes, a tile of + /// each picture, the length and streams of each video or audio file. On a worker thread. + /// + public static Task ReadAsync(IReadOnlyList files, Preset preset, ILogger? logger = null, + CancellationToken cancellationToken = default) => Task.Run(async () => + { + logger ??= NullLogger.Instance; + var entries = new List(); + var sampled = new Dictionary(); + string? ffprobe = null; + var ffprobeLooked = false; + foreach (var path in files) + { + cancellationToken.ThrowIfCancellationRequested(); + if (AskedCompression.KindOf(preset, path) is not { } kind) + continue; + var entry = new Entry(path, kind, File.Exists(path) ? new FileInfo(path).Length : 0); + entries.Add(entry); + if (sampled.GetValueOrDefault(kind) >= SampledFiles(kind) || entry.Bytes == 0) + continue; + sampled[kind] = sampled.GetValueOrDefault(kind) + 1; + entry.Sampled = true; + try + { + switch (kind) + { + case CompressionKind.Image: + entry.Tile = ReadTile(path, preset.Image, out entry.PixelFactor); + break; + case CompressionKind.Video or CompressionKind.Audio: + if (!ffprobeLooked) + (ffprobe, ffprobeLooked) = (FfmpegConverter.Locate()?.Ffprobe, true); + if (ffprobe is not null) + entry.Media = await MediaInfo.ProbeAsync(ffprobe, path, cancellationToken); + break; + } + } + catch (Exception ex) when (ex is not (OperationCanceledException or OutOfMemoryException)) + { + // That file can't be estimated; the others still can, and the window says when none could. + logger.LogInformation(ex, "Could not read {File} to estimate its compressed size", Path.GetFileName(path)); + entry.Sampled = false; + } + } + return new CompressionEstimator(preset, entries, logger); + }, cancellationToken); + + /// + /// About how many bytes the files come to with , or null when none of them could be + /// estimated (no FFmpeg, a picture format ImageMagick doesn't write, a damaged file). On a worker thread; a call + /// waits for the one before it. + /// + public async Task EstimateAsync(CompressionChoice choice, CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken); + try + { + return await Task.Run(() => Estimate(choice, cancellationToken), cancellationToken); + } + finally + { + _gate.Release(); + } + } + + private long? Estimate(CompressionChoice choice, CancellationToken cancellationToken) + { + var preset = AskedCompression.Apply(_preset, choice); + long total = 0; + var any = false; + foreach (var group in _entries.GroupBy(e => e.Kind)) + { + long sourceKnown = 0, estimated = 0; + foreach (var entry in group.Where(e => e.Sampled)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (EstimateOne(entry, preset, cancellationToken) is not { } bytes) + continue; + sourceKnown += entry.Bytes; + estimated += bytes; + } + if (sourceKnown == 0) + return null; // a kind none of whose files could be read: any total would leave them out + any = true; + var ratio = (double)estimated / sourceKnown; + total += estimated + (long)Math.Round(group.Where(e => !e.Sampled).Sum(e => e.Bytes) * ratio); + } + return any ? total : null; + } + + private long? EstimateOne(Entry entry, Preset preset, CancellationToken cancellationToken) + { + try + { + return entry.Kind switch + { + CompressionKind.Image => ImageBytes(entry, preset), + CompressionKind.Video => VideoBytes(entry, preset), + CompressionKind.Audio => AudioBytes(entry, preset), + _ => PdfBytes(entry, preset.Pdf.Compression, cancellationToken), + }; + } + catch (Exception ex) when (ex is not (OperationCanceledException or OutOfMemoryException)) + { + _logger.LogInformation(ex, "Could not estimate the compressed size of {File}", Path.GetFileName(entry.Path)); + return null; + } + } + + /// + /// The middle of the picture, at most square at full resolution, and how many times its pixels + /// the whole picture has once the preset has resized it (). + /// + internal static MagickImage ReadTile(string path, ImageOptions options, out double pixelFactor) + { + var image = new MagickImage(path, new MagickReadSettings { FrameIndex = 0, FrameCount = 1 }); + try + { + double width = image.Width, height = image.Height; + var scale = ImageEncoder.ResizeScale(width, height, options); + var quality = image.Quality; + var format = image.Format; + if (width > TileSide || height > TileSide) + { + var (tileWidth, tileHeight) = ((uint)Math.Min(width, TileSide), (uint)Math.Min(height, TileSide)); + image.Crop(new MagickGeometry((int)((width - tileWidth) / 2), (int)((height - tileHeight) / 2), tileWidth, tileHeight)); + image.ResetPage(); + // Crop keeps what was read, but the encoder's "never raise a JPEG's quality" reads both. + (image.Quality, image.Format) = (quality, format); + } + pixelFactor = width * height * scale * scale / ((double)image.Width * image.Height); + return image; + } + catch + { + image.Dispose(); + throw; + } + } + + private long? ImageBytes(Entry entry, Preset preset) + { + if (entry.Tile is not { } tile) + return null; + var target = preset.TargetFormat == BuiltInData.SameAsSource ? FormatRegistry.Detect(entry.Path)?.Id ?? "" : preset.TargetFormat; + var quality = preset.Image.Quality; + if (!entry.ImageCache.TryGetValue(quality, out var encoded)) + { + // The preset's options on the tile, without its resizing: the tile stands for the resized picture already. + var options = preset.Image.Clone(); + (options.Resize, options.CustomSize, options.MinShortEdge) = (ResizeMode.None, null, null); + using var copy = (MagickImage)tile.Clone(); + (copy.Quality, copy.Format) = (tile.Quality, tile.Format); + ImageEncoder.ApplyOptions(copy, options, target); + var tileBytes = copy.ToByteArray(ImageEncoder.OutputFormat(target, options, entry.Path).Format).LongLength; + var tilePixels = (double)copy.Width * copy.Height; + encoded = (long)Math.Round(tileBytes * entry.PixelFactor * (tile.Width * (double)tile.Height) / tilePixels); + entry.ImageCache[quality] = encoded; + } + var limit = preset.Image.MaxSizeMb > 0 ? SizeLimit.Bytes(preset.Image.MaxSizeMb) : 0; + return AskedCompression.Result(entry.Bytes, encoded, limit, preset.Output.OnlyIfSmaller); + } + + private static long? VideoBytes(Entry entry, Preset preset) + { + var media = preset.Media; + var target = preset.TargetFormat; + if (entry.Media is not { Video: { } video, Duration: { TotalSeconds: > 0 } length } info + || !MediaEncoding.VideoTargets.TryGetValue(target, out var spec)) + return null; + long encoded; + if (MediaEncoding.CanCopyVideo(target, video, media)) + encoded = entry.Bytes; // copied as it is: the size stays + else + { + var codec = MediaCodecs.Effective(target, media.VideoCodec) is VideoCodec.Hevc or VideoCodec.Av1 ? "hevc" : "h264"; + var audio = media.RemoveAudio ? 0 : info.AudioTracks.Sum(track => + MediaEncoding.AudioKbps(MediaEncoding.AudioEncoder(target, spec.AudioCodec, media), media, track.Channels) ?? 0); + encoded = AskedCompression.MediaBytes(length.TotalSeconds, HardwareVideo.BitrateKbps(codec, media.Quality, video, media.MaxHeight) + audio); + } + return AskedCompression.Result(entry.Bytes, encoded, SizeLimit.ForVideo(media, entry.Bytes), preset.Output.OnlyIfSmaller); + } + + private static long? AudioBytes(Entry entry, Preset preset) + { + var media = preset.Media; + if (entry.Media is not { Audio: { } audio, Duration: { TotalSeconds: > 0 } length } + || !MediaEncoding.AudioTargets.TryGetValue(preset.TargetFormat, out var spec) + || MediaEncoding.AudioKbps(MediaEncoding.AudioEncoder(preset.TargetFormat, spec.AudioCodec, media), media, audio.Channels) is not { } kbps) + return null; + return AskedCompression.Result(entry.Bytes, AskedCompression.MediaBytes(length.TotalSeconds, kbps), 0, preset.Output.OnlyIfSmaller); + } + + private static long? PdfBytes(Entry entry, PdfCompression level, CancellationToken cancellationToken) + { + if (level == PdfCompression.None) + return entry.Bytes; + if (!entry.PdfCache.TryGetValue(level, out var compressed)) + { + using var document = PdfSharpConverter.OpenForChanges(entry.Path, cancellationToken); + PdfCompressor.Compress(document, level, keepMetadata: true, cancellationToken); + using var counter = new CountingStream(); + document.Save(counter, closeStream: false); + compressed = counter.Length; + entry.PdfCache[level] = compressed; + } + // The smaller of the two is kept, as when it converts. + return AskedCompression.Result(entry.Bytes, compressed, 0, onlyIfSmaller: true); + } + + public void Dispose() + { + foreach (var entry in _entries) + entry.Tile?.Dispose(); + _gate.Dispose(); + } + + private sealed class Entry(string path, CompressionKind kind, long bytes) + { + public string Path { get; } = path; + public CompressionKind Kind { get; } = kind; + public long Bytes { get; } = bytes; + + /// Read for the estimate; the others count at the ratio of the ones that were. + public bool Sampled; + + public MagickImage? Tile; + public double PixelFactor = 1; + public MediaInfo? Media; + public Dictionary ImageCache { get; } = []; + public Dictionary PdfCache { get; } = []; + } + + /// A stream that only counts what is written to it: the compressed PDF is measured, never kept. + private sealed class CountingStream : Stream + { + private long _length; + + public override bool CanRead => false; + public override bool CanSeek => true; + public override bool CanWrite => true; + public override long Length => _length; + public override long Position { get; set; } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => Position = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => Position + offset, + _ => _length + offset, + }; + + public override void SetLength(long value) => _length = value; + + public override void Write(byte[] buffer, int offset, int count) + { + Position += count; + _length = Math.Max(_length, Position); + } + } +} diff --git a/src/Filee.Engines/Media/MediaEncoding.cs b/src/Filee.Engines/Media/MediaEncoding.cs index 6beb1cc13..21bf3ced7 100644 --- a/src/Filee.Engines/Media/MediaEncoding.cs +++ b/src/Filee.Engines/Media/MediaEncoding.cs @@ -976,6 +976,31 @@ private static List AudioCodecArguments(string target, string codec, Med /// 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; + /// + /// The bitrate in kbit/s gives one track of , for the + /// size the compression window estimates (#1353); a VBR level counts at its usual average. Null for lossless and + /// PCM codecs, whose size follows the sound itself. + /// + internal static int? AudioKbps(string codec, MediaOptions options, int channels) + { + var quality = options.Quality; + var requested = options.AudioBitrateKbps; + channels = channels > 0 ? channels : 2; + return codec switch + { + "aac" => Math.Clamp(requested > 0 ? requested : Scaled(Choose(quality, 256, 192, 128), channels), 16, 256 * channels), + "libopus" => Math.Clamp(requested > 0 ? requested : Scaled(Choose(quality, 160, 128, 64), channels), 6, 256 * channels), + "libmp3lame" => requested > 0 ? Snap(requested, Mp3Bitrates) : Choose(quality, 245, 190, 130), + "libvorbis" => requested > 0 ? requested : Choose(quality, 192, 128, 96), + "wmav2" => requested > 0 ? requested : Choose(quality, 192, 128, 96), + "mp2" => Snap(requested > 0 ? requested : Choose(quality, 320, 224, 160), Mp2Bitrates), + "ac3" => requested > 0 ? Snap(requested, Ac3Bitrates) : channels > 2 ? 448 : 192, + "eac3" => requested > 0 ? requested : Scaled(Choose(quality, 256, 192, 128), channels), + "libopencore_amrnb" => 12, + _ => null, + }; + } + 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). diff --git a/tests/Filee.Engines.Tests/CompressionEstimatorTests.cs b/tests/Filee.Engines.Tests/CompressionEstimatorTests.cs new file mode 100644 index 000000000..cb8f3d265 --- /dev/null +++ b/tests/Filee.Engines.Tests/CompressionEstimatorTests.cs @@ -0,0 +1,229 @@ +// The size the compression window estimates (#1353), held against what the conversion really writes: pictures from a +// tile encoded at the chosen quality, PDFs from the compressor itself, audio and video from bitrate × length. The +// picture and PDF tests run in-process; the audio and video ones only when FFmpeg is installed +// (`pwsh build/fetch-engines.ps1 -Only ffmpeg`), on inputs FFmpeg generates itself. + +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Infrastructure; +using Filee.Engines.Media; +using ImageMagick; + +namespace Filee.Engines.Tests; + +[Collection(nameof(FfmpegCollection))] +public class CompressionEstimatorTests(EngineFixture fx) : IClassFixture +{ + private static readonly (string Ffmpeg, string Ffprobe)? Tools = FfmpegConverter.Locate(); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static Preset BuiltIn(string id) => BuiltInData.CreatePresets().Single(p => p.Id == id); + + private static CompressionChoice With(Preset preset, int? quality = null, MediaQuality? video = null, int? audioKbps = null, + PdfCompression? pdf = null) + { + var choice = CompressionChoice.Of(preset); + return choice with + { + ImageQuality = quality ?? choice.ImageQuality, + VideoQuality = video ?? choice.VideoQuality, + AudioBitrateKbps = audioKbps ?? choice.AudioBitrateKbps, + Pdf = pdf ?? choice.Pdf, + }; + } + + /// A photo-like JPEG: a gradient with noise, so the quality changes its size a lot. + private string Photo(uint width = 3000, uint height = 2000, uint quality = 95, string name = "photo.jpg") + { + using var image = new MagickImage("gradient:#C04020-#2060C0", width, height); + image.AddNoise(NoiseType.Gaussian, 0.4); + image.Quality = quality; + var path = Path.Combine(fx.NewFolder(), name); + image.Write(path, MagickFormat.Jpeg); + return path; + } + + private static long Written(ConversionJob job) + { + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + return new FileInfo(Assert.Single(job.Outputs)).Length; + } + + [Fact] + public async Task A_photo_is_estimated_close_to_what_compress_image_writes() + { + var source = Photo(); + var preset = BuiltIn("compress-image"); + using var estimator = await CompressionEstimator.ReadAsync([source], preset, cancellationToken: Ct); + + Assert.Equal(1, estimator.FileCount); + Assert.Equal(new FileInfo(source).Length, estimator.SourceBytes); + foreach (var quality in new[] { 40, 75 }) + { + var estimate = Assert.NotNull(await estimator.EstimateAsync(With(preset, quality), Ct)); + var written = Written(await fx.ConvertAsync([source], AskedCompression.Apply(preset, With(preset, quality)))); + // An estimate: within a quarter of the real size for an even photo like this one. + Assert.InRange(estimate, written * 0.75, written * 1.25); + } + } + + [Fact] + public async Task A_lower_quality_is_estimated_smaller_and_never_above_the_original() + { + var source = Photo(1800, 1200); + var preset = BuiltIn("compress-image"); + using var estimator = await CompressionEstimator.ReadAsync([source], preset, cancellationToken: Ct); + + var low = await estimator.EstimateAsync(With(preset, 30), Ct); + var middle = await estimator.EstimateAsync(With(preset, 70), Ct); + var high = await estimator.EstimateAsync(With(preset, 100), Ct); + + Assert.True(low < middle, $"{low} vs {middle}"); + // Quality 100 would be larger than the photo saved at 95: "Compress image" never raises a JPEG's quality, and + // only keeps a smaller result. + Assert.InRange(high!.Value, middle!.Value, estimator.SourceBytes); + } + + [Fact] + public async Task The_size_limit_of_the_preset_caps_the_estimate() + { + var source = Photo(); + var preset = BuiltIn("compress-image"); + preset.Image.MaxSizeMb = 0.2; + using var estimator = await CompressionEstimator.ReadAsync([source], preset, cancellationToken: Ct); + + Assert.Equal(200_000, await estimator.EstimateAsync(With(preset, 95), Ct)); + } + + [Fact] + public async Task Pictures_past_the_first_ones_count_at_their_ratio() + { + var preset = BuiltIn("compress-image"); + var files = Enumerable.Range(0, CompressionEstimator.SampledFiles(CompressionKind.Image) + 3) + .Select(i => Photo(800, 600, name: $"photo{i}.jpg")).ToList(); + using var all = await CompressionEstimator.ReadAsync(files, preset, cancellationToken: Ct); + using var one = await CompressionEstimator.ReadAsync([files[0]], preset, cancellationToken: Ct); + + var each = (double)(await one.EstimateAsync(With(preset, 60), Ct))!.Value / one.SourceBytes; + var total = (double)(await all.EstimateAsync(With(preset, 60), Ct))!.Value / all.SourceBytes; + + Assert.Equal(files.Count, all.FileCount); + Assert.Equal(each, total, 0.05); + } + + [Fact] + public async Task Files_without_a_level_to_choose_are_left_out() + { + var png = Path.Combine(fx.NewFolder(), "flat.png"); + using (var image = new MagickImage(MagickColors.Teal, 200, 200)) + image.Write(png, MagickFormat.Png); + var text = Path.Combine(fx.NewFolder(), "notes.txt"); + await File.WriteAllTextAsync(text, "nothing to compress", Ct); + var preset = BuiltIn("compress-image"); + + using var estimator = await CompressionEstimator.ReadAsync([png, text], preset, cancellationToken: Ct); + + Assert.Equal(0, estimator.FileCount); + Assert.Null(await estimator.EstimateAsync(CompressionChoice.Of(preset), Ct)); + } + + [Fact] + public async Task A_damaged_picture_gives_no_estimate() + { + var broken = Path.Combine(fx.NewFolder(), "broken.jpg"); + await File.WriteAllBytesAsync(broken, [0xFF, 0xD8, 0xFF, 0x00, 0x01, 0x02], Ct); + var preset = BuiltIn("compress-image"); + + using var estimator = await CompressionEstimator.ReadAsync([broken], preset, cancellationToken: Ct); + + Assert.Equal(1, estimator.FileCount); + Assert.Null(await estimator.EstimateAsync(CompressionChoice.Of(preset), Ct)); + } + + [Fact] + public async Task A_pdf_is_estimated_by_its_compression_itself() + { + var folder = fx.NewFolder(); + var picture = Photo(2400, 1600); + var pdf = new PdfBuilder(); + pdf.NewPage(); + pdf.Line("Quarterly report with photos", 14, pdf.Font(), 72, 72); + pdf.Picture(picture, 72, 100, 216, 144); + var source = pdf.Save(Path.Combine(folder, "report.pdf")); + var preset = BuiltIn("compress-pdf"); + preset.Pdf.CompressWithGhostscript = false; + using var estimator = await CompressionEstimator.ReadAsync([source], preset, cancellationToken: Ct); + + var screen = (await estimator.EstimateAsync(With(preset, pdf: PdfCompression.Screen), Ct))!.Value; + var print = (await estimator.EstimateAsync(With(preset, pdf: PdfCompression.Print), Ct))!.Value; + var written = Written(await fx.ConvertAsync([source], AskedCompression.Apply(preset, With(preset, pdf: PdfCompression.Screen)))); + + Assert.True(screen < print, $"{screen} vs {print}"); + Assert.InRange(screen, written * 0.95, written * 1.05); + Assert.Equal(estimator.SourceBytes, await estimator.EstimateAsync(With(preset, pdf: PdfCompression.None), Ct)); + } + + private async Task MakeAsync(string name, params string[] arguments) + { + var path = Path.Combine(fx.NewFolder(), name); + var result = await ProcessRunner.RunAsync(Tools!.Value.Ffmpeg, ["-hide_banner", "-nostdin", "-v", "error", "-y", .. arguments, path], + TimeSpan.FromMinutes(2), Ct); + Assert.True(result.ExitCode == 0, result.StandardError); + return path; + } + + [Fact] + public async Task Audio_is_estimated_from_its_bitrate_and_length() + { + Assert.SkipUnless(Tools is not null, "FFmpeg is not installed (pwsh build/fetch-engines.ps1 -Only ffmpeg)"); + var source = await MakeAsync("song.wav", "-f", "lavfi", "-i", "anoisesrc=d=20:c=pink:r=44100:a=0.3", "-ac", "2"); + var preset = BuiltIn("compress-audio"); + using var estimator = await CompressionEstimator.ReadAsync([source], preset, cancellationToken: Ct); + + foreach (var kbps in new[] { 96, 192 }) + { + var estimate = (await estimator.EstimateAsync(With(preset, audioKbps: kbps), Ct))!.Value; + var written = Written(await fx.ConvertAsync([source], AskedCompression.Apply(preset, With(preset, audioKbps: kbps)))); + Assert.Equal(AskedCompression.MediaBytes(20, kbps), estimate); + Assert.InRange(estimate, written * 0.85, written * 1.15); + } + } + + [Fact] + public async Task A_video_is_estimated_smaller_at_a_smaller_quality_and_never_above_the_original() + { + Assert.SkipUnless(Tools is not null, "FFmpeg is not installed (pwsh build/fetch-engines.ps1 -Only ffmpeg)"); + var source = await MakeAsync("clip.mp4", + "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30:duration=6,noise=alls=30:allf=t", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6", "-ac", "2", + "-c:v", "libx264", "-crf", "14", "-pix_fmt", "yuv420p", "-c:a", "aac"); + var preset = BuiltIn("compress-video"); + using var estimator = await CompressionEstimator.ReadAsync([source], preset, cancellationToken: Ct); + + var small = (await estimator.EstimateAsync(With(preset, video: MediaQuality.Small), Ct))!.Value; + var balanced = (await estimator.EstimateAsync(With(preset, video: MediaQuality.Balanced), Ct))!.Value; + var high = (await estimator.EstimateAsync(With(preset, video: MediaQuality.High), Ct))!.Value; + + Assert.True(small < estimator.SourceBytes, $"{small} vs {estimator.SourceBytes}"); + // Balanced and High copy an H.264 video that needs no scaling: it keeps its size. + Assert.Equal(estimator.SourceBytes, balanced); + Assert.Equal(estimator.SourceBytes, high); + // 720p30 at Small: 1280 × 720 × 30 × 0.05 bits per pixel ≈ 1382 kbit/s, and 128 kbit/s of sound. + Assert.Equal((double)AskedCompression.MediaBytes(6, 1382 + 128), small, 0.01 * small); + } + + [Fact] + public async Task A_video_with_a_percentage_limit_is_estimated_at_most_at_it() + { + Assert.SkipUnless(Tools is not null, "FFmpeg is not installed (pwsh build/fetch-engines.ps1 -Only ffmpeg)"); + var source = await MakeAsync("clip.mp4", + "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30:duration=4,noise=alls=30:allf=t", + "-c:v", "libx264", "-crf", "12", "-pix_fmt", "yuv420p"); + var preset = BuiltIn("compress-video"); + preset.Media.MaxSizePercent = 1; + using var estimator = await CompressionEstimator.ReadAsync([source], preset, cancellationToken: Ct); + + Assert.Equal(SizeLimit.Bytes(estimator.SourceBytes, 1), await estimator.EstimateAsync(With(preset, video: MediaQuality.High), Ct)); + } +} From 033ef9c32b657913293cca9d50b95c4987df0871 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 11:17:43 +0000 Subject: [PATCH 3/8] feat(app): choose the compression level with its estimated size (#1353) Files dropped on a preset that asks for its compression open a small "Choose the compression" window first, like "Choose pages" (#981): the preset editor's own setting for each kind of file dropped (picture quality, video quality, audio bitrate, PDF level) and about what the files come to ("About 30.0 MB (70% smaller)"), estimated again a moment after each change and said to be an estimate. Enter compresses at that setting, which the preset keeps for the next time; Esc starts nothing. Files with nothing to choose (a PNG) convert without the window. - Preset editor: "Ask every time" under the setting it asks for, with a hint; not for a merge. - Strings in all 22 languages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BPxh5siZR4PzK81twSxnAb --- src/Filee.App/Assets/i18n/ar.json | 13 + src/Filee.App/Assets/i18n/bn-BD.json | 13 + src/Filee.App/Assets/i18n/de.json | 13 + src/Filee.App/Assets/i18n/en.json | 13 + src/Filee.App/Assets/i18n/es.json | 13 + src/Filee.App/Assets/i18n/fa.json | 13 + src/Filee.App/Assets/i18n/fr.json | 13 + src/Filee.App/Assets/i18n/hi.json | 13 + src/Filee.App/Assets/i18n/id.json | 13 + src/Filee.App/Assets/i18n/it.json | 13 + src/Filee.App/Assets/i18n/ja.json | 13 + src/Filee.App/Assets/i18n/ko.json | 13 + src/Filee.App/Assets/i18n/ne.json | 13 + src/Filee.App/Assets/i18n/pl.json | 13 + src/Filee.App/Assets/i18n/pt-BR.json | 13 + src/Filee.App/Assets/i18n/pt-PT.json | 13 + src/Filee.App/Assets/i18n/ru.json | 13 + src/Filee.App/Assets/i18n/th.json | 13 + src/Filee.App/Assets/i18n/tr.json | 13 + src/Filee.App/Assets/i18n/vi.json | 13 + src/Filee.App/Assets/i18n/zh-CN.json | 13 + src/Filee.App/Assets/i18n/zh-TW.json | 13 + src/Filee.App/Services/RadialController.cs | 94 +++++ src/Filee.App/Services/WindowService.cs | 48 +++ .../ViewModels/AskCompressionViewModel.cs | 179 ++++++++ .../ViewModels/PresetEditorViewModel.cs | 24 +- .../Views/AskCompressionWindow.axaml | 80 ++++ .../Views/AskCompressionWindow.axaml.cs | 39 ++ src/Filee.App/Views/PresetEditorView.axaml | 15 + tests/Filee.App.Tests/AskCompressionTests.cs | 381 ++++++++++++++++++ 30 files changed, 1142 insertions(+), 4 deletions(-) create mode 100644 src/Filee.App/ViewModels/AskCompressionViewModel.cs create mode 100644 src/Filee.App/Views/AskCompressionWindow.axaml create mode 100644 src/Filee.App/Views/AskCompressionWindow.axaml.cs create mode 100644 tests/Filee.App.Tests/AskCompressionTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index e5a254163..4f7edf263 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -907,6 +907,19 @@ "presets.split": "ملف لكل صفحة", "presets.ask_pages": "السؤال في كل مرة", "presets.ask_pages_hint": "عند إفلات ملف PDF على شريط الأدوات الدائري، تُفتح نافذة صغيرة لاختيار صفحاته. تبدأ النافذة بالنطاق أعلاه، وهو أيضًا ما تستخدمه مجلدات المراقبة وسطر الأوامر.", + "presets.ask_compression": "السؤال في كل مرة", + "presets.ask_compression_hint": "عند إفلات الملفات على شريط الأدوات الدائري تُفتح نافذة صغيرة لاختيار مقدار ضغطها، مع الحجم التقريبي الذي ستصل إليه. الإعداد أعلاه هو ما تبدأ به النافذة، وهو ما تستخدمه المجلدات المراقبة وسطر الأوامر.", + "compression.title": "اختيار الضغط", + "compression.files": "{0} ملفات", + "compression.size_now": "الحجم الحالي: {0}", + "compression.estimate_label": "الحجم المقدّر", + "compression.estimating": "جارٍ تقدير الحجم…", + "compression.estimate": "نحو {0} (أصغر بنسبة {1}%)", + "compression.not_smaller": "نحو {0}، ليس أصغر من الآن.", + "compression.not_smaller_kept": "على الأرجح لن يصغر: يُحتفظ بالأصل كما هو.", + "compression.unknown": "لا يستطيع Filee تقدير حجم هذه الملفات.", + "compression.estimate_hint": "هذا تقدير: الحجم الفعلي يعتمد على محتوى الملفات.", + "compression.estimate_hint_video": "هذا تقدير: يعتمد الحجم الفعلي للفيديو كثيرًا على صورته (تسجيل شاشة ثابت يكون أصغر بكثير).", "pages.title": "اختيار الصفحات", "pages.file_of": "الملف {0} من {1}", "pages.count": "عدد صفحات ملف PDF هذا: {0}", diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index be1efff6e..56cf35e97 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -906,6 +906,19 @@ "presets.split": "পৃষ্ঠা প্রতি একটি ফাইল", "presets.ask_pages": "প্রতিবার জিজ্ঞাসা করুন", "presets.ask_pages_hint": "ডোনাটে একটি পিডিএফ ড্রপ করলে এর পৃষ্ঠা নির্বাচন করার জন্য একটি ছোট উইন্ডো খোলে। ওপরের পরিসরটি দিয়ে উইন্ডোটি শুরু হয়, এবং ওয়াচ ফোল্ডার ও কমান্ড লাইন সেটিই ব্যবহার করে।", + "presets.ask_compression": "প্রতিবার জিজ্ঞাসা করুন", + "presets.ask_compression_hint": "ডোনাটে ফাইল ছাড়লে একটি ছোট উইন্ডো খোলে, যেখানে কতটা সংকুচিত হবে তা বেছে নিতে ও আনুমানিক আকার দেখতে পারেন। উপরের সেটিং উইন্ডোর শুরুর মান, আর নজরে রাখা ফোল্ডার ও কমান্ড লাইন এটিই ব্যবহার করে।", + "compression.title": "সংকোচন বেছে নিন", + "compression.files": "{0}টি ফাইল", + "compression.size_now": "এখনকার আকার: {0}", + "compression.estimate_label": "আনুমানিক আকার", + "compression.estimating": "আকার অনুমান করা হচ্ছে…", + "compression.estimate": "প্রায় {0} ({1}% ছোট)", + "compression.not_smaller": "প্রায় {0}, এখনকার চেয়ে ছোট নয়।", + "compression.not_smaller_kept": "সম্ভবত ছোট হবে না: মূল ফাইল যেমন আছে তেমনই রাখা হবে।", + "compression.unknown": "Filee এই ফাইলগুলোর আকার অনুমান করতে পারে না।", + "compression.estimate_hint": "এটি একটি অনুমান: আসল আকার ফাইলের বিষয়বস্তুর ওপর নির্ভর করে।", + "compression.estimate_hint_video": "এটি একটি অনুমান: ভিডিওর আসল আকার তার ছবির ওপর অনেকটা নির্ভর করে (স্থির স্ক্রিন রেকর্ডিং অনেক ছোট হয়)।", "pages.title": "পৃষ্ঠা নির্বাচন করুন", "pages.file_of": "ফাইল {1}-এর মধ্যে {0}", "pages.count": "এই পিডিএফ-এ পৃষ্ঠা সংখ্যা: {0}", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 6424db929..2e74a11ea 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -907,6 +907,19 @@ "presets.split": "Eine Datei pro Seite", "presets.ask_pages": "Jedes Mal fragen", "presets.ask_pages_hint": "Ziehst du eine PDF auf das Ringmenü, öffnet sich ein kleines Fenster, in dem du die Seiten auswählst. Es beginnt mit dem Bereich oben, den auch überwachte Ordner und die Befehlszeile verwenden.", + "presets.ask_compression": "Jedes Mal fragen", + "presets.ask_compression_hint": "Ziehst du Dateien auf das Ringmenü, öffnet sich ein kleines Fenster, in dem du die Komprimierung wählst und die ungefähre Größe siehst. Die Einstellung oben ist der Startwert des Fensters und gilt für überwachte Ordner und die Befehlszeile.", + "compression.title": "Komprimierung wählen", + "compression.files": "{0} Dateien", + "compression.size_now": "Aktuelle Größe: {0}", + "compression.estimate_label": "Geschätzte Größe", + "compression.estimating": "Größe wird geschätzt…", + "compression.estimate": "Etwa {0} ({1} % kleiner)", + "compression.not_smaller": "Etwa {0}, nicht kleiner als jetzt.", + "compression.not_smaller_kept": "Wahrscheinlich nicht kleiner: Das Original bleibt, wie es ist.", + "compression.unknown": "Filee kann die Größe dieser Dateien nicht schätzen.", + "compression.estimate_hint": "Eine Schätzung: Die tatsächliche Größe hängt vom Inhalt der Dateien ab.", + "compression.estimate_hint_video": "Eine Schätzung: Die tatsächliche Größe eines Videos hängt stark vom Bild ab (eine ruhige Bildschirmaufnahme wird viel kleiner).", "pages.title": "Seiten auswählen", "pages.file_of": "Datei {0} von {1}", "pages.count": "Seiten in dieser PDF: {0}", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index d06817209..6f7b70c60 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -922,6 +922,19 @@ "presets.split": "One file per page", "presets.ask_pages": "Ask every time", "presets.ask_pages_hint": "Dropping a PDF on the donut opens a small window to choose its pages. The range above is what the window starts with, and what watch folders and the command line use.", + "presets.ask_compression": "Ask every time", + "presets.ask_compression_hint": "Dropping files on the donut opens a small window to choose how much they are compressed, with the size they will roughly come to. The setting above is what the window starts with, and what watch folders and the command line use.", + "compression.title": "Choose the compression", + "compression.files": "{0} files", + "compression.size_now": "Size now: {0}", + "compression.estimate_label": "Estimated size", + "compression.estimating": "Estimating the size…", + "compression.estimate": "About {0} ({1}% smaller)", + "compression.not_smaller": "About {0}, no smaller than now.", + "compression.not_smaller_kept": "Probably no smaller: the original is kept as it is.", + "compression.unknown": "Filee can't estimate the size of these files.", + "compression.estimate_hint": "An estimate: the real size depends on what the files hold.", + "compression.estimate_hint_video": "An estimate: a video's real size depends a lot on its picture (a still screen recording comes out far smaller).", "pages.title": "Choose pages", "pages.file_of": "File {0} of {1}", "pages.count": "Pages in this PDF: {0}", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 0f0969b65..bf6835f70 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -907,6 +907,19 @@ "presets.split": "Un archivo por página", "presets.ask_pages": "Preguntar cada vez", "presets.ask_pages_hint": "Al soltar un PDF en el menú circular se abre una ventana pequeña para elegir sus páginas. Empieza con el rango de arriba, que también usan las carpetas supervisadas y la línea de comandos.", + "presets.ask_compression": "Preguntar cada vez", + "presets.ask_compression_hint": "Al soltar archivos en el menú circular se abre una ventana pequeña para elegir cuánto se comprimen, con el tamaño aproximado que tendrán. El ajuste de arriba es con el que empieza la ventana, y el que usan las carpetas vigiladas y la línea de comandos.", + "compression.title": "Elegir la compresión", + "compression.files": "{0} archivos", + "compression.size_now": "Tamaño actual: {0}", + "compression.estimate_label": "Tamaño estimado", + "compression.estimating": "Estimando el tamaño…", + "compression.estimate": "Unos {0} ({1} % menos)", + "compression.not_smaller": "Unos {0}, no menos que ahora.", + "compression.not_smaller_kept": "Probablemente no será menor: se conserva el original tal cual.", + "compression.unknown": "Filee no puede estimar el tamaño de estos archivos.", + "compression.estimate_hint": "Es una estimación: el tamaño real depende del contenido de los archivos.", + "compression.estimate_hint_video": "Es una estimación: el tamaño real de un vídeo depende mucho de su imagen (una grabación de pantalla estática queda mucho menor).", "pages.title": "Elegir páginas", "pages.file_of": "Archivo {0} de {1}", "pages.count": "Páginas de este PDF: {0}", diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index 501a5eb00..3cb030344 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -906,6 +906,19 @@ "presets.split": "یک فایل به ازای هر صفحه", "presets.ask_pages": "پرسش در هر بار", "presets.ask_pages_hint": "رها کردن یک فایل PDF روی دونات، پنجره کوچکی را برای انتخاب صفحات آن باز می‌کند. محدوده بالا چیزی است که پنجره با آن شروع می‌شود و پوشه‌های نظارتی و خط فرمان از آن استفاده می‌کنند.", + "presets.ask_compression": "پرسش در هر بار", + "presets.ask_compression_hint": "رها کردن فایل‌ها روی حلقه پنجره‌ای کوچک باز می‌کند تا میزان فشرده‌سازی را انتخاب کنید و اندازهٔ تقریبی را ببینید. تنظیم بالا مقدار آغازین پنجره است و پوشه‌های پایش‌شده و خط فرمان هم از آن استفاده می‌کنند.", + "compression.title": "انتخاب فشرده‌سازی", + "compression.files": "{0} فایل", + "compression.size_now": "اندازهٔ کنونی: {0}", + "compression.estimate_label": "اندازهٔ تخمینی", + "compression.estimating": "در حال تخمین اندازه…", + "compression.estimate": "حدود {0} ({1}٪ کوچک‌تر)", + "compression.not_smaller": "حدود {0}، کوچک‌تر از اکنون نیست.", + "compression.not_smaller_kept": "احتمالاً کوچک‌تر نمی‌شود: فایل اصلی همان‌طور که هست می‌ماند.", + "compression.unknown": "Filee نمی‌تواند اندازهٔ این فایل‌ها را تخمین بزند.", + "compression.estimate_hint": "این یک تخمین است: اندازهٔ واقعی به محتوای فایل‌ها بستگی دارد.", + "compression.estimate_hint_video": "این یک تخمین است: اندازهٔ واقعی ویدیو بسیار به تصویر آن بستگی دارد (ضبط صفحهٔ ثابت بسیار کوچک‌تر می‌شود).", "pages.title": "انتخاب صفحات", "pages.file_of": "فایل {0} از {1}", "pages.count": "صفحات موجود در این PDF: {0}", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index c74e2ac06..099259d7b 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -922,6 +922,19 @@ "presets.split": "Un fichier par page", "presets.ask_pages": "Demander à chaque fois", "presets.ask_pages_hint": "Déposer un PDF sur le menu circulaire ouvre une petite fenêtre pour choisir ses pages. Elle s'ouvre avec la plage ci-dessus, qui sert aussi aux dossiers surveillés et à la ligne de commande.", + "presets.ask_compression": "Demander à chaque fois", + "presets.ask_compression_hint": "Déposer des fichiers sur le menu circulaire ouvre une petite fenêtre pour choisir leur compression, avec la taille qu'ils auront à peu près. Le réglage ci-dessus est celui de départ de la fenêtre, et celui qu'utilisent les dossiers surveillés et la ligne de commande.", + "compression.title": "Choisir la compression", + "compression.files": "{0} fichiers", + "compression.size_now": "Taille actuelle : {0}", + "compression.estimate_label": "Taille estimée", + "compression.estimating": "Estimation de la taille…", + "compression.estimate": "Environ {0} ({1} % plus petit)", + "compression.not_smaller": "Environ {0}, pas plus petit qu'avant.", + "compression.not_smaller_kept": "Sans doute pas plus petit : l'original est gardé tel quel.", + "compression.unknown": "Filee ne peut pas estimer la taille de ces fichiers.", + "compression.estimate_hint": "Une estimation : la taille réelle dépend du contenu des fichiers.", + "compression.estimate_hint_video": "Une estimation : la taille réelle d'une vidéo dépend beaucoup de son image (un enregistrement d'écran fixe sera bien plus petit).", "pages.title": "Choisir les pages", "pages.file_of": "Fichier {0} sur {1}", "pages.count": "Pages de ce PDF : {0}", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 4078425ab..81ae59d3a 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -907,6 +907,19 @@ "presets.split": "प्रति पेज एक फ़ाइल", "presets.ask_pages": "हर बार पूछें", "presets.ask_pages_hint": "डोनट टूलबार पर PDF छोड़ने पर पेज चुनने के लिए एक छोटी विंडो खुलती है। विंडो ऊपर दी गई रेंज से शुरू होती है, और वॉच फ़ोल्डर और कमांड लाइन भी यही रेंज इस्तेमाल करते हैं।", + "presets.ask_compression": "हर बार पूछें", + "presets.ask_compression_hint": "डोनट टूलबार पर फ़ाइलें छोड़ने से एक छोटी विंडो खुलती है, जिसमें आप चुनते हैं कि उन्हें कितना कंप्रेस करना है और उनका लगभग आकार देखते हैं। ऊपर की सेटिंग विंडो का शुरुआती मान है, और वॉच फ़ोल्डर व कमांड लाइन यही इस्तेमाल करते हैं।", + "compression.title": "कंप्रेशन चुनें", + "compression.files": "{0} फ़ाइलें", + "compression.size_now": "अभी का आकार: {0}", + "compression.estimate_label": "अनुमानित आकार", + "compression.estimating": "आकार का अनुमान लगाया जा रहा है…", + "compression.estimate": "लगभग {0} ({1}% छोटा)", + "compression.not_smaller": "लगभग {0}, अभी से छोटा नहीं।", + "compression.not_smaller_kept": "शायद छोटा नहीं होगा: मूल फ़ाइल जैसी है वैसी रखी जाएगी।", + "compression.unknown": "Filee इन फ़ाइलों के आकार का अनुमान नहीं लगा सकता।", + "compression.estimate_hint": "यह एक अनुमान है: असली आकार फ़ाइलों की सामग्री पर निर्भर करता है।", + "compression.estimate_hint_video": "यह एक अनुमान है: वीडियो का असली आकार उसकी तस्वीर पर बहुत निर्भर करता है (स्थिर स्क्रीन रिकॉर्डिंग काफ़ी छोटी बनती है)।", "pages.title": "पेज चुनें", "pages.file_of": "फ़ाइल {0} / {1}", "pages.count": "इस PDF में पेज: {0}", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 0bf6a4285..f3ebb54bd 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -907,6 +907,19 @@ "presets.split": "Satu file per halaman", "presets.ask_pages": "Tanyakan setiap kali", "presets.ask_pages_hint": "Saat PDF dijatuhkan ke toolbar donut, jendela kecil terbuka untuk memilih halamannya. Jendela dimulai dengan rentang di atas, yang juga dipakai folder pantau dan baris perintah.", + "presets.ask_compression": "Tanyakan setiap kali", + "presets.ask_compression_hint": "Menjatuhkan file ke toolbar donut membuka jendela kecil untuk memilih seberapa besar file dikompres, beserta perkiraan ukurannya. Pengaturan di atas adalah nilai awal jendela, dan yang dipakai folder pantauan serta baris perintah.", + "compression.title": "Pilih kompresi", + "compression.files": "{0} file", + "compression.size_now": "Ukuran sekarang: {0}", + "compression.estimate_label": "Perkiraan ukuran", + "compression.estimating": "Memperkirakan ukuran…", + "compression.estimate": "Sekitar {0} ({1}% lebih kecil)", + "compression.not_smaller": "Sekitar {0}, tidak lebih kecil dari sekarang.", + "compression.not_smaller_kept": "Kemungkinan tidak lebih kecil: file asli dibiarkan apa adanya.", + "compression.unknown": "Filee tidak dapat memperkirakan ukuran file ini.", + "compression.estimate_hint": "Ini perkiraan: ukuran sebenarnya bergantung pada isi file.", + "compression.estimate_hint_video": "Ini perkiraan: ukuran sebenarnya sebuah video sangat bergantung pada gambarnya (rekaman layar yang diam jauh lebih kecil).", "pages.title": "Pilih halaman", "pages.file_of": "File {0} dari {1}", "pages.count": "Jumlah halaman PDF ini: {0}", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index c53dfa126..453e29a9f 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -906,6 +906,19 @@ "presets.split": "Un file per pagina", "presets.ask_pages": "Chiedi ogni volta", "presets.ask_pages_hint": "Trascinando un PDF sulla barra donut si apre una piccola finestra per sceglierne le pagine. Parte dall'intervallo qui sopra, che usano anche le cartelle monitorate e la riga di comando.", + "presets.ask_compression": "Chiedi ogni volta", + "presets.ask_compression_hint": "Trascinando dei file sulla barra donut si apre una piccola finestra per scegliere quanto comprimerli, con la dimensione che avranno all'incirca. L'impostazione qui sopra è quella da cui parte la finestra, e quella usata dalle cartelle monitorate e dalla riga di comando.", + "compression.title": "Scegli la compressione", + "compression.files": "{0} file", + "compression.size_now": "Dimensione attuale: {0}", + "compression.estimate_label": "Dimensione stimata", + "compression.estimating": "Stima della dimensione…", + "compression.estimate": "Circa {0} ({1}% in meno)", + "compression.not_smaller": "Circa {0}, non più piccolo di ora.", + "compression.not_smaller_kept": "Probabilmente non più piccolo: l'originale resta com'è.", + "compression.unknown": "Filee non riesce a stimare la dimensione di questi file.", + "compression.estimate_hint": "È una stima: la dimensione reale dipende dal contenuto dei file.", + "compression.estimate_hint_video": "È una stima: la dimensione reale di un video dipende molto dalle immagini (una registrazione dello schermo statica risulta molto più piccola).", "pages.title": "Scegli le pagine", "pages.file_of": "File {0} di {1}", "pages.count": "Pagine di questo PDF: {0}", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 003c8f719..d0dc3655f 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -907,6 +907,19 @@ "presets.split": "1ページずつ別ファイルにする", "presets.ask_pages": "毎回確認する", "presets.ask_pages_hint": "ドーナツメニューにPDFをドロップすると、小さなウィンドウでページを選べます。上の範囲がウィンドウの初期値になり、監視フォルダーとコマンドラインではこの範囲が使われます。", + "presets.ask_compression": "毎回確認する", + "presets.ask_compression_hint": "ドーナツメニューにファイルをドロップすると小さなウィンドウが開き、圧縮の度合いとおおよそのサイズを確認して選べます。上の設定はウィンドウの初期値で、監視フォルダーとコマンドラインでも使われます。", + "compression.title": "圧縮を選択", + "compression.files": "{0} 個のファイル", + "compression.size_now": "現在のサイズ: {0}", + "compression.estimate_label": "推定サイズ", + "compression.estimating": "サイズを推定中…", + "compression.estimate": "約 {0}({1}% 小さく)", + "compression.not_smaller": "約 {0}、今より小さくなりません。", + "compression.not_smaller_kept": "おそらく小さくなりません。元のファイルはそのまま残ります。", + "compression.unknown": "これらのファイルのサイズは推定できません。", + "compression.estimate_hint": "推定値です。実際のサイズはファイルの内容によって変わります。", + "compression.estimate_hint_video": "推定値です。動画の実際のサイズは映像によって大きく変わります(動きの少ない画面録画はずっと小さくなります)。", "pages.title": "ページの選択", "pages.file_of": "ファイル {0}/{1}", "pages.count": "このPDFのページ数: {0}", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 9e531eb1a..82b053bce 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -922,6 +922,19 @@ "presets.split": "페이지마다 파일 하나", "presets.ask_pages": "매번 묻기", "presets.ask_pages_hint": "원형 툴바에 PDF를 놓으면 작은 창이 열려 페이지를 고를 수 있어요. 창은 위의 범위로 시작하고, 자동 변환 폴더와 명령줄은 이 범위를 그대로 써요.", + "presets.ask_compression": "매번 묻기", + "presets.ask_compression_hint": "원형 툴바에 파일을 놓으면 작은 창이 열려 압축 정도를 고르고 대략적인 크기를 확인할 수 있어요. 창은 위의 설정으로 시작하고, 자동 변환 폴더와 명령줄은 이 설정을 그대로 써요.", + "compression.title": "압축 선택", + "compression.files": "파일 {0}개", + "compression.size_now": "현재 크기: {0}", + "compression.estimate_label": "예상 크기", + "compression.estimating": "크기 예상 중…", + "compression.estimate": "약 {0} ({1}% 작음)", + "compression.not_smaller": "약 {0}, 지금보다 작지 않습니다.", + "compression.not_smaller_kept": "작아지지 않을 것 같습니다. 원본은 그대로 유지됩니다.", + "compression.unknown": "이 파일들의 크기는 예상할 수 없습니다.", + "compression.estimate_hint": "예상치입니다. 실제 크기는 파일 내용에 따라 달라집니다.", + "compression.estimate_hint_video": "예상치입니다. 동영상의 실제 크기는 화면에 따라 크게 달라집니다(움직임 없는 화면 녹화는 훨씬 작아집니다).", "pages.title": "페이지 선택", "pages.file_of": "파일 {0}/{1}", "pages.count": "이 PDF의 페이지 수: {0}", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 2cdf57c39..8e35feeda 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -907,6 +907,19 @@ "presets.split": "प्रति पृष्ठ एउटा फाइल", "presets.ask_pages": "हरेक पटक सोध्नुहोस्", "presets.ask_pages_hint": "डोनट टुलबारमा PDF छोड्दा पृष्ठ छान्न एउटा सानो विन्डो खुल्छ। विन्डो माथिको दायराबाट सुरु हुन्छ, र वाच फोल्डरहरू र कमान्ड लाइनले पनि यही दायरा प्रयोग गर्छन्।", + "presets.ask_compression": "हरेक पटक सोध्नुहोस्", + "presets.ask_compression_hint": "डोनट टुलबारमा फाइलहरू छोड्दा एउटा सानो विन्डो खुल्छ, जहाँ कति कम्प्रेस गर्ने छान्न र लगभग आकार हेर्न सकिन्छ। माथिको सेटिङ विन्डोको सुरुको मान हो, र हेरिने फोल्डर तथा कमान्ड लाइनले यही प्रयोग गर्छन्।", + "compression.title": "कम्प्रेसन छान्नुहोस्", + "compression.files": "{0} फाइलहरू", + "compression.size_now": "अहिलेको आकार: {0}", + "compression.estimate_label": "अनुमानित आकार", + "compression.estimating": "आकार अनुमान गर्दै…", + "compression.estimate": "लगभग {0} ({1}% सानो)", + "compression.not_smaller": "लगभग {0}, अहिलेभन्दा सानो छैन।", + "compression.not_smaller_kept": "सायद सानो हुँदैन: मूल फाइल जस्तो छ त्यस्तै राखिन्छ।", + "compression.unknown": "Filee ले यी फाइलहरूको आकार अनुमान गर्न सक्दैन।", + "compression.estimate_hint": "यो अनुमान हो: वास्तविक आकार फाइलहरूको सामग्रीमा भर पर्छ।", + "compression.estimate_hint_video": "यो अनुमान हो: भिडियोको वास्तविक आकार यसको दृश्यमा धेरै भर पर्छ (स्थिर स्क्रिन रेकर्डिङ धेरै सानो हुन्छ)।", "pages.title": "पृष्ठ छान्नुहोस्", "pages.file_of": "फाइल {0} / {1}", "pages.count": "यो PDF मा पृष्ठ: {0}", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index b1db6c055..5b6108900 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -922,6 +922,19 @@ "presets.split": "Jeden plik na stronę", "presets.ask_pages": "Pytaj za każdym razem", "presets.ask_pages_hint": "Upuszczenie pliku PDF na pasek-pierścień otwiera małe okno wyboru stron. Startuje ono z zakresem podanym wyżej, którego używają też obserwowane foldery i wiersz poleceń.", + "presets.ask_compression": "Pytaj za każdym razem", + "presets.ask_compression_hint": "Upuszczenie plików na pasek-pierścień otwiera małe okno, w którym wybierasz stopień kompresji i widzisz przybliżony rozmiar. Ustawienie powyżej jest wartością początkową okna i tą, której używają obserwowane foldery oraz wiersz poleceń.", + "compression.title": "Wybierz kompresję", + "compression.files": "Pliki: {0}", + "compression.size_now": "Obecny rozmiar: {0}", + "compression.estimate_label": "Szacowany rozmiar", + "compression.estimating": "Szacowanie rozmiaru…", + "compression.estimate": "Około {0} (mniej o {1}%)", + "compression.not_smaller": "Około {0}, nie mniej niż teraz.", + "compression.not_smaller_kept": "Prawdopodobnie nie będzie mniejszy: oryginał zostaje bez zmian.", + "compression.unknown": "Filee nie może oszacować rozmiaru tych plików.", + "compression.estimate_hint": "To szacunek: rzeczywisty rozmiar zależy od zawartości plików.", + "compression.estimate_hint_video": "To szacunek: rzeczywisty rozmiar wideo mocno zależy od obrazu (nieruchome nagranie ekranu będzie znacznie mniejsze).", "pages.title": "Wybierz strony", "pages.file_of": "Plik {0} z {1}", "pages.count": "Liczba stron w tym PDF: {0}", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 3616b7b17..bf173af63 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -922,6 +922,19 @@ "presets.split": "Um arquivo por página", "presets.ask_pages": "Perguntar sempre", "presets.ask_pages_hint": "Ao soltar um PDF no menu circular, abre uma janela pequena para escolher as páginas. Ela começa com o intervalo acima, que também é usado pelas pastas monitoradas e pela linha de comando.", + "presets.ask_compression": "Perguntar sempre", + "presets.ask_compression_hint": "Soltar arquivos no menu circular abre uma janela pequena para escolher quanto eles são compactados, com o tamanho aproximado que terão. A configuração acima é a inicial da janela, e a que as pastas monitoradas e a linha de comando usam.", + "compression.title": "Escolher a compressão", + "compression.files": "{0} arquivos", + "compression.size_now": "Tamanho atual: {0}", + "compression.estimate_label": "Tamanho estimado", + "compression.estimating": "Estimando o tamanho…", + "compression.estimate": "Cerca de {0} ({1}% menor)", + "compression.not_smaller": "Cerca de {0}, não menor que agora.", + "compression.not_smaller_kept": "Provavelmente não fica menor: o original é mantido como está.", + "compression.unknown": "O Filee não consegue estimar o tamanho destes arquivos.", + "compression.estimate_hint": "É uma estimativa: o tamanho real depende do conteúdo dos arquivos.", + "compression.estimate_hint_video": "É uma estimativa: o tamanho real de um vídeo depende muito da imagem (uma gravação de tela parada fica bem menor).", "pages.title": "Escolher páginas", "pages.file_of": "Arquivo {0} de {1}", "pages.count": "Páginas deste PDF: {0}", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index cd39fea8d..6b8441481 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -922,6 +922,19 @@ "presets.split": "Um ficheiro por página", "presets.ask_pages": "Perguntar sempre", "presets.ask_pages_hint": "Ao largar um PDF na barra circular, abre-se uma pequena janela para escolher as páginas. Começa com o intervalo acima, que também é usado pelas pastas monitorizadas e pela linha de comandos.", + "presets.ask_compression": "Perguntar sempre", + "presets.ask_compression_hint": "Largar ficheiros na barra circular abre uma pequena janela para escolher quanto são comprimidos, com o tamanho aproximado que vão ter. A definição acima é a inicial da janela, e a que as pastas monitorizadas e a linha de comandos usam.", + "compression.title": "Escolher a compressão", + "compression.files": "{0} ficheiros", + "compression.size_now": "Tamanho atual: {0}", + "compression.estimate_label": "Tamanho estimado", + "compression.estimating": "A estimar o tamanho…", + "compression.estimate": "Cerca de {0} ({1}% mais pequeno)", + "compression.not_smaller": "Cerca de {0}, não mais pequeno do que agora.", + "compression.not_smaller_kept": "Provavelmente não fica mais pequeno: o original é mantido tal como está.", + "compression.unknown": "O Filee não consegue estimar o tamanho destes ficheiros.", + "compression.estimate_hint": "É uma estimativa: o tamanho real depende do conteúdo dos ficheiros.", + "compression.estimate_hint_video": "É uma estimativa: o tamanho real de um vídeo depende muito da imagem (uma gravação de ecrã parada fica muito mais pequena).", "pages.title": "Escolher páginas", "pages.file_of": "Ficheiro {0} de {1}", "pages.count": "Páginas deste PDF: {0}", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 102d2a626..5f64c6abc 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -922,6 +922,19 @@ "presets.split": "По файлу на страницу", "presets.ask_pages": "Спрашивать каждый раз", "presets.ask_pages_hint": "Если перетащить PDF на круговое меню, откроется небольшое окно для выбора страниц. В нём сразу стоит диапазон, указанный выше; его же используют папки наблюдения и командная строка.", + "presets.ask_compression": "Спрашивать каждый раз", + "presets.ask_compression_hint": "Если перетащить файлы на круговое меню, откроется небольшое окно: в нём выбирается степень сжатия и видно примерный размер. Значение выше — начальное для окна; его же используют отслеживаемые папки и командная строка.", + "compression.title": "Выбор сжатия", + "compression.files": "Файлов: {0}", + "compression.size_now": "Сейчас: {0}", + "compression.estimate_label": "Примерный размер", + "compression.estimating": "Оценка размера…", + "compression.estimate": "Около {0} (меньше на {1}%)", + "compression.not_smaller": "Около {0}, не меньше, чем сейчас.", + "compression.not_smaller_kept": "Скорее всего, не меньше: оригинал останется как есть.", + "compression.unknown": "Filee не может оценить размер этих файлов.", + "compression.estimate_hint": "Это оценка: реальный размер зависит от содержимого файлов.", + "compression.estimate_hint_video": "Это оценка: реальный размер видео сильно зависит от изображения (статичная запись экрана получится гораздо меньше).", "pages.title": "Выбор страниц", "pages.file_of": "Файл {0} из {1}", "pages.count": "Страниц в этом PDF: {0}", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index e8ea948c7..e11678dfe 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -907,6 +907,19 @@ "presets.split": "แยกหนึ่งไฟล์ต่อหนึ่งหน้า", "presets.ask_pages": "ถามทุกครั้ง", "presets.ask_pages_hint": "เมื่อวาง PDF บนแถบโดนัท หน้าต่างเล็ก ๆ จะเปิดขึ้นให้เลือกหน้า โดยเริ่มจากช่วงหน้าด้านบน ซึ่งโฟลเดอร์เฝ้าติดตามและบรรทัดคำสั่งก็ใช้ช่วงนี้เช่นกัน", + "presets.ask_compression": "ถามทุกครั้ง", + "presets.ask_compression_hint": "เมื่อวางไฟล์บนแถบโดนัท จะมีหน้าต่างเล็กๆ เปิดขึ้นให้เลือกระดับการบีบอัด พร้อมขนาดโดยประมาณ ค่าด้านบนคือค่าเริ่มต้นของหน้าต่าง และเป็นค่าที่โฟลเดอร์เฝ้าดูและบรรทัดคำสั่งใช้", + "compression.title": "เลือกการบีบอัด", + "compression.files": "{0} ไฟล์", + "compression.size_now": "ขนาดตอนนี้: {0}", + "compression.estimate_label": "ขนาดโดยประมาณ", + "compression.estimating": "กำลังประมาณขนาด…", + "compression.estimate": "ประมาณ {0} (เล็กลง {1}%)", + "compression.not_smaller": "ประมาณ {0} ไม่เล็กกว่าตอนนี้", + "compression.not_smaller_kept": "อาจไม่เล็กลง จึงเก็บไฟล์ต้นฉบับไว้ตามเดิม", + "compression.unknown": "Filee ประมาณขนาดของไฟล์เหล่านี้ไม่ได้", + "compression.estimate_hint": "เป็นค่าประมาณ ขนาดจริงขึ้นอยู่กับเนื้อหาของไฟล์", + "compression.estimate_hint_video": "เป็นค่าประมาณ ขนาดจริงของวิดีโอขึ้นอยู่กับภาพเป็นอย่างมาก (การบันทึกหน้าจอที่ภาพนิ่งจะเล็กกว่ามาก)", "pages.title": "เลือกหน้า", "pages.file_of": "ไฟล์ {0} จาก {1}", "pages.count": "จำนวนหน้าใน PDF นี้: {0}", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 07750e5dd..b6f263fff 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -907,6 +907,19 @@ "presets.split": "Sayfa başına bir dosya", "presets.ask_pages": "Her seferinde sor", "presets.ask_pages_hint": "Halka araç çubuğuna bir PDF bıraktığınızda sayfaları seçmek için küçük bir pencere açılır. Pencere yukarıdaki aralıkla açılır; izlenen klasörler ve komut satırı da bu aralığı kullanır.", + "presets.ask_compression": "Her seferinde sor", + "presets.ask_compression_hint": "Dosyaları halka araç çubuğuna bıraktığınızda, ne kadar sıkıştırılacaklarını seçtiğiniz ve yaklaşık boyutlarını gördüğünüz küçük bir pencere açılır. Yukarıdaki ayar pencerenin başlangıç değeridir; izlenen klasörler ve komut satırı da bunu kullanır.", + "compression.title": "Sıkıştırmayı seçin", + "compression.files": "{0} dosya", + "compression.size_now": "Şu anki boyut: {0}", + "compression.estimate_label": "Tahmini boyut", + "compression.estimating": "Boyut tahmin ediliyor…", + "compression.estimate": "Yaklaşık {0} (%{1} daha küçük)", + "compression.not_smaller": "Yaklaşık {0}, şimdikinden küçük değil.", + "compression.not_smaller_kept": "Büyük olasılıkla küçülmez: orijinal olduğu gibi tutulur.", + "compression.unknown": "Filee bu dosyaların boyutunu tahmin edemiyor.", + "compression.estimate_hint": "Bu bir tahmindir: gerçek boyut dosyaların içeriğine bağlıdır.", + "compression.estimate_hint_video": "Bu bir tahmindir: bir videonun gerçek boyutu büyük ölçüde görüntüsüne bağlıdır (hareketsiz bir ekran kaydı çok daha küçük olur).", "pages.title": "Sayfaları seçin", "pages.file_of": "Dosya {0}/{1}", "pages.count": "Bu PDF'deki sayfa sayısı: {0}", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 403e026c8..a7ac11f93 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -922,6 +922,19 @@ "presets.split": "Mỗi trang một tệp", "presets.ask_pages": "Luôn hỏi", "presets.ask_pages_hint": "Khi thả một tệp PDF lên thanh công cụ vòng tròn, một cửa sổ nhỏ sẽ mở ra để chọn trang. Cửa sổ bắt đầu với phạm vi ở trên, và thư mục theo dõi cùng dòng lệnh cũng dùng phạm vi này.", + "presets.ask_compression": "Luôn hỏi", + "presets.ask_compression_hint": "Thả tệp lên thanh công cụ vòng tròn sẽ mở một cửa sổ nhỏ để chọn mức nén, kèm kích thước ước chừng. Thiết lập ở trên là giá trị ban đầu của cửa sổ, và là giá trị thư mục theo dõi cùng dòng lệnh sử dụng.", + "compression.title": "Chọn mức nén", + "compression.files": "{0} tệp", + "compression.size_now": "Kích thước hiện tại: {0}", + "compression.estimate_label": "Kích thước ước tính", + "compression.estimating": "Đang ước tính kích thước…", + "compression.estimate": "Khoảng {0} (nhỏ hơn {1}%)", + "compression.not_smaller": "Khoảng {0}, không nhỏ hơn hiện tại.", + "compression.not_smaller_kept": "Có lẽ không nhỏ hơn: tệp gốc được giữ nguyên.", + "compression.unknown": "Filee không thể ước tính kích thước của các tệp này.", + "compression.estimate_hint": "Đây là ước tính: kích thước thật phụ thuộc vào nội dung tệp.", + "compression.estimate_hint_video": "Đây là ước tính: kích thước thật của video phụ thuộc nhiều vào hình ảnh (bản quay màn hình tĩnh sẽ nhỏ hơn nhiều).", "pages.title": "Chọn trang", "pages.file_of": "Tệp {0}/{1}", "pages.count": "Số trang của PDF này: {0}", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 370fee2fd..a6a38bdb6 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -922,6 +922,19 @@ "presets.split": "每页一个文件", "presets.ask_pages": "每次询问", "presets.ask_pages_hint": "将 PDF 拖放到环形工具栏上时,会打开一个小窗口来选择页面。窗口以上方的范围开始,自动转换文件夹和命令行也使用这个范围。", + "presets.ask_compression": "每次询问", + "presets.ask_compression_hint": "将文件拖放到环形工具栏上会打开一个小窗口,用于选择压缩程度并查看大致大小。上面的设置是窗口的初始值,也是自动转换文件夹和命令行使用的值。", + "compression.title": "选择压缩", + "compression.files": "{0} 个文件", + "compression.size_now": "当前大小: {0}", + "compression.estimate_label": "估计大小", + "compression.estimating": "正在估算大小…", + "compression.estimate": "约 {0}(减小 {1}%)", + "compression.not_smaller": "约 {0},不比现在小。", + "compression.not_smaller_kept": "可能不会变小,原文件保持不变。", + "compression.unknown": "Filee 无法估算这些文件的大小。", + "compression.estimate_hint": "这是估计值,实际大小取决于文件内容。", + "compression.estimate_hint_video": "这是估计值,视频的实际大小很大程度上取决于画面(静止的屏幕录制会小得多)。", "pages.title": "选择页面", "pages.file_of": "第 {0} 个文件,共 {1} 个", "pages.count": "此 PDF 的页数:{0}", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 4595e18d9..9f2653a39 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -922,6 +922,19 @@ "presets.split": "每頁一個檔案", "presets.ask_pages": "每次詢問", "presets.ask_pages_hint": "將 PDF 拖放到環形工具列上時,會開啟一個小視窗來選擇頁面。視窗以上方的範圍開始,自動轉換資料夾和命令列也使用這個範圍。", + "presets.ask_compression": "每次詢問", + "presets.ask_compression_hint": "將檔案拖放到環形工具列上會開啟一個小視窗,用來選擇壓縮程度並查看大約大小。上方的設定是視窗的初始值,也是自動轉換資料夾和命令列使用的值。", + "compression.title": "選擇壓縮", + "compression.files": "{0} 個檔案", + "compression.size_now": "目前大小: {0}", + "compression.estimate_label": "預估大小", + "compression.estimating": "正在預估大小…", + "compression.estimate": "約 {0}(縮小 {1}%)", + "compression.not_smaller": "約 {0},不比現在小。", + "compression.not_smaller_kept": "可能不會變小,原始檔案維持不變。", + "compression.unknown": "Filee 無法預估這些檔案的大小。", + "compression.estimate_hint": "這是預估值,實際大小取決於檔案內容。", + "compression.estimate_hint_video": "這是預估值,影片的實際大小很大程度取決於畫面(靜止的螢幕錄影會小得多)。", "pages.title": "選擇頁面", "pages.file_of": "第 {0} 個檔案,共 {1} 個", "pages.count": "此 PDF 的頁數:{0}", diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 8485eeb00..e77cce56b 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -433,6 +433,16 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w return; } + // "Ask every time" for the compression (#1353): a small window for the files with a level to choose first, then + // the save dialog when that asks too; the toast shows the job, the donut has closed for the window. + if (AskedCompression.Asks(preset) && sources.Any(s => AskedCompression.KindOf(preset, s) is not null)) + { + KeepKeyboard(); // for the compression window + Close(animate: false); + _ = AskCompressionThenConvertAsync(sources, preset, web, _toClipboard, _onJob); + return; + } + // "Ask every time" (#521): the save dialog first; the toast shows the job, the donut has closed for the dialog. // shortcut: files from a browser aren't asked about, they save into the download folder as "next to the source". if (preset.Output.AskEveryTime && (web is null || preset.IsCopyToFolder)) @@ -603,6 +613,90 @@ private void RememberSplitChoice(Preset preset, PageChoice chosen) } } + /// + /// "Choose the compression" (#1353): one window for all the files dropped, since one setting goes for every file of a + /// kind. Cancelling it starts nothing. The files then convert as one job with the setting chosen; ones without a + /// level to choose (a PNG on "Compress image") convert as the preset says. Files from a browser are saved first, + /// as for a tool: the estimate reads the files themselves. + /// + private async Task AskCompressionThenConvertAsync(IReadOnlyList files, Preset preset, WebDrop? web, bool toClipboard, + Action? onJob) + { + try + { + if (web is not null) + { + var drop = web; + web = null; // saving it disposes it + files = await conversions.SaveBrowserFilesAsync(drop, (placeholder, ex) => + log.LogWarning(ex, "Saving {File} from a browser for {Preset} failed", Path.GetFileName(placeholder), preset.Id)); + } + if (await windows.AskCompressionAsync(files, preset) is not { } choice) + { + log.LogInformation("Choosing the compression for {Preset} was cancelled; nothing converts", preset.Id); + return; + } + RememberCompression(preset, choice, files); + var asked = AskedCompression.Apply(preset, choice); + log.LogInformation("Converting {Count} file(s) with preset {Preset} at the compression chosen", files.Count, preset.Id); + if (asked.Output.AskEveryTime) + await AskThenConvertAsync(files, asked, toClipboard, null, onJob); + else if (conversions.Start(files, asked, toClipboard: toClipboard) is { } job) + onJob?.Invoke(job.Job); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + log.LogError(ex, "Could not convert at the compression chosen"); + } + finally + { + web?.Dispose(); + } + } + + /// + /// "Choose the compression": the settings chosen for the kinds of file dropped () go into + /// the preset as saved, so the window starts with them next time (as "Choose pages" keeps its split). + /// + private void RememberCompression(Preset preset, CompressionChoice chosen, IReadOnlyList files) + { + if (store.FindPreset(preset.Id) is not { } saved) + return; + var changed = false; + foreach (var kind in AskedCompression.KindsOf(preset, files)) + { + switch (kind) + { + case CompressionKind.Image when saved.Image.Quality != chosen.ImageQuality: + saved.Image.Quality = chosen.ImageQuality; + changed = true; + break; + case CompressionKind.Video when saved.Media.Quality != chosen.VideoQuality: + saved.Media.Quality = chosen.VideoQuality; + changed = true; + break; + case CompressionKind.Audio when saved.Media.AudioBitrateKbps != chosen.AudioBitrateKbps: + saved.Media.AudioBitrateKbps = chosen.AudioBitrateKbps; + changed = true; + break; + case CompressionKind.Pdf when saved.Pdf.Compression != chosen.Pdf: + saved.Pdf.Compression = chosen.Pdf; + changed = true; + break; + } + } + if (!changed) + return; + try + { + store.SaveLibrary(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log.LogWarning(ex, "Could not remember the compression chosen"); + } + } + private async Task AskThenConvertAsync(IReadOnlyList sources, Preset preset, bool toClipboard, WebDrop? web = null, Action? onJob = null) { diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 26dd8f2cf..6b799c917 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -127,6 +127,54 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem return dialog.Result; } + private AskCompressionWindow? _askCompression; + + internal AskCompressionWindow? AskCompression => _askCompression; + + /// + /// "Choose the compression" (#1353) for the files dropped on : the setting of each kind + /// among them and the size they will roughly come to, read from the files while the window is open. Null when + /// cancelled. + /// + public async Task AskCompressionAsync(IReadOnlyList files, Preset preset) + { + _askCompression?.Close(); + var asked = files.Where(f => AskedCompression.KindOf(preset, f) is not null).ToList(); + var sourceBytes = asked.Sum(f => File.Exists(f) ? new FileInfo(f).Length : 0); + var logger = services.GetService>(); + using var closing = new CancellationTokenSource(); + var reading = Filee.Engines.CompressionEstimator.ReadAsync(asked, preset, logger, closing.Token); + using var vm = new AskCompressionViewModel(preset, asked, sourceBytes, + async (choice, ct) => await (await reading).EstimateAsync(choice, ct), loc); + var dialog = new AskCompressionWindow { DataContext = vm }; + dialog.StartCompression.Content = loc.DisplayName(preset); + PrepareWindow(dialog); + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + dialog.Closed += (_, _) => + { + if (_askCompression == dialog) _askCompression = null; + closed.TrySetResult(); + }; + _askCompression = dialog; + dialog.Show(); + dialog.Activate(); + await closed.Task; + // The pictures read for the estimate are let go once it is no longer shown, after the estimate under way ends. + vm.Dispose(); + await closing.CancelAsync(); + try + { + var estimator = await reading; + await Task.Run(estimator.Dispose); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + if (ex is not OperationCanceledException) + logger?.LogInformation(ex, "Reading the files for the compression estimate failed"); + } + return dialog.Result; + } + /// The pages of a PDF, or null when it can't be read (damaged, locked with a password, gone). internal int? CountPages(string path) { diff --git a/src/Filee.App/ViewModels/AskCompressionViewModel.cs b/src/Filee.App/ViewModels/AskCompressionViewModel.cs new file mode 100644 index 000000000..75db9081d --- /dev/null +++ b/src/Filee.App/ViewModels/AskCompressionViewModel.cs @@ -0,0 +1,179 @@ +// "Choose the compression" (#1353): the files dropped on a preset that asks for its compression (Preset.AskCompression), +// the setting of each kind among them (the preset editor's own controls) and the size they will roughly come to, +// estimated again a moment after each change. + +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Localization; +using Filee.Core.Presets; + +namespace Filee.App.ViewModels; + +public sealed partial class AskCompressionViewModel : ObservableObject, IDisposable +{ + /// How long the slider rests before the size is estimated again: one encode per stop, not per step. + internal static readonly TimeSpan EstimateDelay = TimeSpan.FromMilliseconds(250); + + private readonly ILocalizer _loc; + private readonly Func> _estimate; + private readonly TimeSpan _delay; + private CancellationTokenSource? _pending; + + /// The dropped files a level is chosen for (AskedCompression.KindOf), in the order dropped. + /// Their size now. + /// About what they come to with a choice (CompressionEstimator.EstimateAsync); null when unknown. + /// The rest before estimating again; unless a test sets it. + public AskCompressionViewModel(Preset preset, IReadOnlyList files, long sourceBytes, + Func> estimate, ILocalizer loc, TimeSpan? delay = null) + { + _loc = loc; + _estimate = estimate; + _delay = delay ?? EstimateDelay; + var kinds = AskedCompression.KindsOf(preset, files); + ShowImage = kinds.Contains(CompressionKind.Image); + ShowVideo = kinds.Contains(CompressionKind.Video); + ShowAudio = kinds.Contains(CompressionKind.Audio); + ShowPdf = kinds.Contains(CompressionKind.Pdf); + OnlyIfSmaller = preset.Output.OnlyIfSmaller; + SourceBytes = sourceBytes; + FilesText = files.Count == 1 ? Path.GetFileName(files[0]) : loc.Format("compression.files", files.Count); + SizeNowText = loc.Format("compression.size_now", AskedCompression.SizeText(sourceBytes)); + HintText = loc[ShowVideo ? "compression.estimate_hint_video" : "compression.estimate_hint"]; + + var start = CompressionChoice.Of(preset); + _quality = Math.Clamp(start.ImageQuality, 1, 100); + VideoQualities = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.media_quality.{v}"])).ToList(); + _videoQuality = VideoQualities.First(q => q.Value == start.VideoQuality); + AudioBitrates = [.. PresetEditorViewModel.BitrateSteps.Append(start.AudioBitrateKbps).Distinct() + .Select(k => new Choice(k, k == 0 ? loc["presets.audio_bitrate.auto"] : $"{k} kbps"))]; + _audioBitrate = AudioBitrates.First(b => b.Value == start.AudioBitrateKbps); + // "Don't compress" is no level for a compression: a preset saved with it starts at Balanced. + PdfLevels = Enum.GetValues().Where(v => v != PdfCompression.None) + .Select(v => new Choice(v, loc[$"presets.pdf_compression.{v}"])).ToList(); + _pdfLevel = PdfLevels.FirstOrDefault(l => l.Value == start.Pdf) ?? PdfLevels.First(l => l.Value == PdfCompression.Ebook); + _ = EstimateAsync(TimeSpan.Zero); + } + + /// The file's name, or "3 files". + public string FilesText { get; } + + /// "Size now: 100 MB". + public string SizeNowText { get; } + + public long SourceBytes { get; } + + /// The preset keeps the original when the result isn't smaller (OutputRule.OnlyIfSmaller). + public bool OnlyIfSmaller { get; } + + public bool ShowImage { get; } + public bool ShowVideo { get; } + public bool ShowAudio { get; } + public bool ShowPdf { get; } + + /// Under the estimate: what it rests on (a video's picture changes it the most). + public string HintText { get; } + + [ObservableProperty] + private int _quality; + + public IReadOnlyList> VideoQualities { get; } + + [ObservableProperty] + private Choice _videoQuality; + + public IReadOnlyList> AudioBitrates { get; } + + [ObservableProperty] + private Choice _audioBitrate; + + public IReadOnlyList> PdfLevels { get; } + + [ObservableProperty] + private Choice _pdfLevel; + + /// "About 30.0 MB (70% smaller)", "Estimating the size…", or why there is none. + [ObservableProperty] + private string _estimateText = ""; + + /// The estimate in bytes, null while it is worked out or when there is none. + [ObservableProperty] + private long? _estimatedBytes; + + [ObservableProperty] + private bool _isEstimating; + + partial void OnQualityChanged(int value) => _ = EstimateAsync(_delay); + + partial void OnVideoQualityChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => VideoQuality = v)) + _ = EstimateAsync(TimeSpan.Zero); + } + + partial void OnAudioBitrateChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => AudioBitrate = v)) + _ = EstimateAsync(TimeSpan.Zero); + } + + partial void OnPdfLevelChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => PdfLevel = v)) + _ = EstimateAsync(TimeSpan.Zero); + } + + /// The settings chosen. + public CompressionChoice Result() => new(Quality, VideoQuality.Value, AudioBitrate.Value, PdfLevel.Value); + + /// The estimate for the settings as they are now; one started before it is dropped. + internal async Task EstimateAsync(TimeSpan delay) + { + _pending?.Cancel(); + _pending?.Dispose(); + var pending = _pending = new CancellationTokenSource(); + var choice = Result(); + IsEstimating = true; + EstimatedBytes = null; + EstimateText = _loc["compression.estimating"]; + try + { + if (delay > TimeSpan.Zero) + await Task.Delay(delay, pending.Token); + var estimate = await _estimate(choice, pending.Token); + if (pending.IsCancellationRequested) + return; + EstimatedBytes = estimate; + EstimateText = Describe(estimate); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + if (!pending.IsCancellationRequested) + EstimateText = _loc["compression.unknown"]; + } + finally + { + if (pending == _pending) + IsEstimating = false; + } + } + + private string Describe(long? estimate) + { + if (estimate is not { } bytes || SourceBytes <= 0) + return _loc["compression.unknown"]; + var smaller = AskedCompression.PercentSmaller(SourceBytes, bytes); + if (smaller > 0) + return _loc.Format("compression.estimate", AskedCompression.SizeText(bytes), smaller); + return OnlyIfSmaller ? _loc["compression.not_smaller_kept"] : _loc.Format("compression.not_smaller", AskedCompression.SizeText(bytes)); + } + + public void Dispose() + { + _pending?.Cancel(); + _pending?.Dispose(); + _pending = null; + } +} diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index dad011a79..11a809170 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -94,8 +94,11 @@ public sealed partial class PresetEditorViewModel : ObservableObject private static readonly HashSet LosslessAudioTargets = ["wav", "flac", "aiff", "wv", "tta", "w64"]; private static readonly int[] MaxHeightSteps = [0, 2160, 1440, 1080, 720, 480]; - // 32-64 kbit/s: speech (Opus is still clear at 32 kbit/s mono), small files. - private static readonly int[] BitrateSteps = [0, 32, 48, 64, 96, 128, 192, 256, 320]; + /// + /// The audio bitrates offered, 0 for the one the quality picks (also in the compression window, #1353). 32-64 kbit/s: + /// speech (Opus is still clear at 32 kbit/s mono), small files. + /// + internal static readonly int[] BitrateSteps = [0, 32, 48, 64, 96, 128, 192, 256, 320]; private static readonly int[] SampleRateSteps = [0, 8000, 16000, 22050, 44100, 48000]; private readonly Preset _original; @@ -335,6 +338,7 @@ .. WhisperLanguages.All _renderDpi = _edit.Pdf.RenderDpi; _pageRange = _edit.Pdf.PageRange; _askPageRange = _edit.Pdf.AskPageRange; + _askCompression = _edit.AskCompression; _pageOutput = PageOutputChoices.First(c => c.Value == PageOutputs.Of(_edit.Pdf)); _groupKind = GroupKinds.First(k => k.Value == PageOutputs.KindOf(_edit.Pdf)); _groupSize = PageOutputs.SizeOf(_edit.Pdf); @@ -479,7 +483,7 @@ public async Task TestTranslationAsync() [NotifyPropertyChangedFor(nameof(ShowImage), nameof(ShowTurnForPdf), nameof(ShowQuality), nameof(ShowTiff), nameof(ShowMultiPageTiff), nameof(ShowWebp), nameof(ShowIco), nameof(ShowFitsTiff), nameof(ShowFitsDebayer), nameof(ShowZipResult), nameof(ShowPdf), nameof(ShowPdfBuild), nameof(ShowDocument), nameof(ShowLeaveOutPictures), nameof(ShowPdfSplit), nameof(ShowPageGroups), nameof(ShowPdfCombine), - nameof(ShowAskPages), nameof(ShowAskPagesHint), + nameof(ShowAskPages), nameof(ShowAskPagesHint), nameof(ShowAskCompressionImage), nameof(ShowAskCompressionMedia), nameof(ShowAskCompressionPdf), nameof(ShowPdfCompression), nameof(ShowPdfCompressionOptions), nameof(ShowSheets), nameof(ShowOcr), nameof(ShowPageSize), nameof(ShowPdfTablesNote), nameof(ShowMedia), nameof(ShowMediaQuality), nameof(ShowMaxHeight), nameof(ShowVideoMaxSize), nameof(ShowAudioBitrate), nameof(ShowRemoveAudio), nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec), nameof(ShowHardwareEncoding), nameof(ShowCaption), @@ -753,7 +757,7 @@ public bool ColorProfileChecked [ObservableProperty] private bool _webpLossless; [ObservableProperty] private bool _multiPageTiff; [ObservableProperty] private string _icoSizes; - [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowAskPages), nameof(ShowAskPagesHint))] + [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowAskPages), nameof(ShowAskPagesHint), nameof(ShowAskCompressionPdf))] private bool _merge; [ObservableProperty] private double _marginMm; [ObservableProperty] private int _renderDpi; @@ -1086,6 +1090,17 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne public bool ShowAskPages => ShowPdf && !(TargetId == "pdf" && Merge); public bool ShowAskPagesHint => ShowAskPages && AskPageRange; + /// + /// "Ask every time" for the compression (Preset.AskCompression, #1353), under the setting it asks for: the picture + /// quality, the video quality and audio bitrate, or the PDF level. Not for a merge (AskedCompression.Asks). + /// + [ObservableProperty] + private bool _askCompression; + + public bool ShowAskCompressionImage => ShowImage && ShowQuality && !_original.JoinsImages; + public bool ShowAskCompressionMedia => !IsTool && ShowMediaQuality && !_original.JoinsIntoOneMedia; + public bool ShowAskCompressionPdf => ShowPdfCompression && !Merge; + /// Compression is for PDF → PDF; Ghostscript and metadata only matter while it is on. public bool ShowPdfCompression => TargetId == "pdf" && !Recognizes; @@ -1263,6 +1278,7 @@ public bool Apply() pdf.RenderDpi = Math.Clamp(RenderDpi, 36, 1200); pdf.PageRange = PageRange.Trim(); pdf.AskPageRange = ShowAskPages && AskPageRange; + _original.AskCompression = AskCompression && (ShowAskCompressionImage || ShowAskCompressionMedia || ShowAskCompressionPdf); var (split, every, parts) = PageOutputs.Settings(PageOutput.Value, GroupKind.Value, GroupSize); pdf.SplitPages = split; // One PDF keeps the numbers of a split, as the pages window does, for when it is chosen again. diff --git a/src/Filee.App/Views/AskCompressionWindow.axaml b/src/Filee.App/Views/AskCompressionWindow.axaml new file mode 100644 index 000000000..3185db81f --- /dev/null +++ b/src/Filee.App/Views/AskCompressionWindow.axaml @@ -0,0 +1,80 @@ + + + + + +