diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index b6c6ca01e..fb18909ce 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -807,6 +807,29 @@ Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on
for the rest. The encoders are looked up with `ffmpeg -encoders` once per FFmpeg, so a copy the user picked without
x265 or AV1 fails with a clear message instead of an FFmpeg error. x265 and SVT-AV1 log only errors
(`-x265-params log-level=error`, `SVT_LOG=1`).
+- **Hardware encoding** (*Hardware acceleration* in the video card, `MediaOptions.HardwareEncoding`; off by default and
+ for older presets, so nobody's results change without asking). H.264 and HEVC encodes (not VP9, AV1 and the
+ MPEG-style codecs, not stream copies, not pictures → video) go to the graphics chip (`Media/HardwareVideo.cs`,
+ `Media/HardwareProbe.cs`):
+
+ | System | Tried in this order |
+ |---|---|
+ | Windows | `*_nvenc` (NVIDIA), `*_qsv` (Intel Quick Sync), `*_amf` (AMD) |
+ | macOS | `*_videotoolbox` with constant quality (Apple Silicon), then with a bitrate (Intel Macs reject `-q:v`) |
+ | Linux | `*_nvenc`, then `*_vaapi` on `/dev/dri/renderD128` (`-vaapi_device`, frames uploaded with `format=nv12,hwupload`) |
+
+ FFmpeg lists encoders the computer can't run (NVENC without an NVIDIA card), so only the ones `ffmpeg -encoders`
+ lists are tried, each first with a test encode with the real options (0.1 s of `testsrc` at 256 × 144 to the null
+ muxer, 15 s limit). The first that succeeds is used; results are kept per FFmpeg executable, encoder, rate control
+ and bit depth until Filee restarts, and written to the log. Quality: *High/Balanced/Small* become quantizer 19/24/29
+ for H.264 and 23/27/31 for HEVC (one above x264's and x265's CRF: hardware encoders spend bits less wisely) as NVENC
+ `-rc vbr -cq N -b:v 0 -preset p5`, Quick Sync `-global_quality N -preset medium` (ICQ), AMF `-rc cqp -qp_p N -qp_i N−2`
+ (`-quality balanced`) and VA-API `-rc_mode CQP -qp N`; VideoToolbox gets `-q:v` 70/60/50, or on Intel Macs
+ `-b:v` from 0.14/0.09/0.05 bits per pixel per frame (× 0.65 for HEVC). 8-bit video is NV12 (H.264 High, HEVC Main);
+ 10-bit and HDR sources stay P010 HEVC Main 10 with their colour tags when the encoder passes a 10-bit test encode,
+ otherwise x265 encodes them. HEVC keeps the `hvc1` tag in MP4/M4V/MOV. When a hardware encode fails anyway (a
+ picture too large for the chip, a driver error), the partial output is deleted and the file is encoded again with
+ x264/x265; that is logged, not shown as a failure.
- DSD (DSF, DFF) decodes at 2.8 MHz or more: lossless and PCM targets get 88.2 kHz, Vorbis and WMA 44.1 kHz, the
other lossy encoders their nearest supported rate.
- Subtitle extraction takes the first text subtitle track (SubRip, ASS/SSA, WebVTT, MP4 timed text, CEA-608, …).
diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs
index 9ad8ebecc..ea70f0a1f 100644
--- a/src/Filee.App/App.axaml.cs
+++ b/src/Filee.App/App.axaml.cs
@@ -5,6 +5,7 @@
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
+using Filee.App.Controls;
using Filee.App.Services;
using Filee.App.Services.Triggers;
using Filee.Core.Conversion;
@@ -22,7 +23,14 @@ public partial class App : Application
private IDisposable? _convertDebounce;
private bool _selectionPending;
- public override void Initialize() => AvaloniaXamlLoader.Load(this);
+ /// Forwarded launches and empty selections can come in bursts: a few lines a minute are enough.
+ private static readonly LogThrottle Throttle = new(TimeSpan.FromSeconds(30));
+
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ SingleSelectionGuard.Register();
+ }
public override void OnFrameworkInitializationCompleted()
{
@@ -34,6 +42,9 @@ public override void OnFrameworkInitializationCompleted()
base.OnFrameworkInitializationCompleted();
}
+ /// Ends Filee as the tray's Quit does.
+ public static void Quit() => (Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown();
+
private void Start(IClassicDesktopStyleApplicationLifetime desktop)
{
// macOS: "Convert with Filee" in Finder's right-click menu. Registered first, so a service picked while
@@ -42,12 +53,20 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
Filee.Platform.MacOS.MacOSServicesProvider.Register(files =>
Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(["--convert", .. files]))));
var log = AppHost.Get>();
+ // Exceptions in UI event handlers end Filee; write them down first (they are not marked handled).
+ Dispatcher.UIThread.UnhandledException += (_, e) =>
+ log.LogCritical(e.Exception, "Unhandled exception on the UI thread");
+ var watchdog = new UiWatchdog(action => Dispatcher.UIThread.Post(action), AppHost.Get>());
+ watchdog.Start();
var store = AppHost.Get();
store.Load();
var loc = AppHost.Get();
loc.Attach(this);
ApplySettings(store.Settings);
+ // Every engine's availability, checked once off the UI thread: the first donut or drop otherwise starts
+ // every engine's check (FFmpeg -version, folder searches) on the UI thread and freezes it meanwhile.
+ _ = AppHost.Get().CheckInBackgroundAsync();
// Global gestures → donut toolbar
var triggers = AppHost.Get();
@@ -66,6 +85,11 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
? await Task.Run(platform.GetFileManagerSelection) : platform.GetFileManagerSelection();
if (files.Count > 0 && !store.Settings.Paused)
radial.ShowForFiles(e.X, e.Y, files);
+ else if (files.Count == 0 && Throttle.TryEnter("empty-selection", out var held))
+ log.LogInformation("The selection shortcut found no selected files in {Window}{Elevation}{Held}",
+ platform.DescribeForegroundWindow() is { Length: > 0 } window ? window : "the foreground window",
+ Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator and can't read Explorer)" : "",
+ LogThrottle.HeldText(held));
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
@@ -104,13 +128,12 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
// Later launches (context menu, Send To, double-clicking the exe) forward their arguments here.
if (Program.Instance is { } instance)
- {
- instance.ArgumentsReceived += args => Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args)));
- instance.StartListening();
- }
+ ListenForLaterLaunches(instance, log);
+ Program.InstanceAcquired += again => ListenForLaterLaunches(again, log);
desktop.Exit += (_, _) =>
{
+ watchdog.Dispose();
AppHost.Get().Dispose();
AppHost.Get().Dispose();
AppHost.Get().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5));
@@ -118,6 +141,21 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
HandleCommandLine(Program.Options, firstLaunch: true);
WatchForUpdates(store);
+ // Filee runs as administrator while Explorer doesn't (an installer started "as administrator" started it):
+ // say so once, next to the tray, and on the Home page for as long as it lasts.
+ if (AppHost.Get().ShellCannotReachFilee)
+ DispatcherTimer.RunOnce(() => AppHost.Get().ShowElevationNotice(), TimeSpan.FromSeconds(2));
+ }
+
+ private void ListenForLaterLaunches(SingleInstance instance, ILogger log)
+ {
+ instance.ArgumentsReceived += args =>
+ {
+ if (Throttle.TryEnter("forwarded", out var held))
+ log.LogInformation("A later launch handed over {Count} argument(s){Held}", args.Length, LogThrottle.HeldText(held));
+ Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args), forwarded: true));
+ };
+ instance.StartListening();
}
///
@@ -193,8 +231,13 @@ private static void ApplySettings(AppSettings settings)
AppHost.Get().Apply(settings);
}
- private void HandleCommandLine(CommandLine options, bool firstLaunch = false)
+ private void HandleCommandLine(CommandLine options, bool firstLaunch = false, bool forwarded = false)
{
+ // Pairs with "A later launch handed over …": the request reached the UI thread too.
+ if (forwarded && Throttle.TryEnter("forwarded-handled", out var held))
+ AppHost.Get>().LogInformation("Handling a later launch: {Request}{Held}",
+ options.Quit ? "quit" : options.ConvertFiles.Count > 0 ? $"convert {options.ConvertFiles.Count} file(s)" : "show Filee",
+ LogThrottle.HeldText(held));
if (options.Quit)
{
// The installer is about to replace or remove Filee's files (Program.RunInstallerCommand waits for this).
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index df5148162..65f9d6894 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -25,6 +25,13 @@
"home.drop_hint_macos": "Or hold {0} while dragging files in Finder. The donut toolbar opens at your cursor.",
"home.drop_hint_linux": "Or hold {0} while dragging files in your file manager. The donut toolbar opens at your cursor.",
"home.accessibility_needed": "To open the donut by dragging with ⌥ Option, allow Filee under Accessibility.\nIt works as soon as you switch it on.",
+ "home.elevated": "Filee is running as administrator, so Explorer can't drop files on it or use its menu.\nThe shortcut can't read Explorer's selection either.\nRestart Filee without administrator rights to fix this.",
+ "home.elevated_restart": "Restart without administrator rights",
+ "home.elevated_restart_failed": "Filee couldn't restart itself.\nQuit Filee from the tray, then start it again.",
+ "elevation.notice_title": "Filee is running as administrator",
+ "elevation.notice_text": "Explorer can't drop files on Filee or use its menu while it runs this way.\nRestart it without administrator rights.",
+ "startup.elevated_other": "Another Filee is running as administrator.\nExplorer can't hand files to it.\nQuit Filee from the tray, then start it again.",
+ "startup.no_answer": "Filee is running but didn't answer.\nIf it runs as administrator, quit it from the tray and start it again.",
"home.recent": "Recent conversions",
"home.recent_empty": "Your converted files will show up here.",
"home.open_folder": "Open folder",
@@ -410,6 +417,8 @@
"presets.video_codec.vp9": "VP9",
"presets.video_codec.hevc": "HEVC / H.265 (smaller)",
"presets.video_codec.av1": "AV1 (smallest, slow)",
+ "presets.hardware_encoding": "Use hardware acceleration (graphics chip)",
+ "presets.hardware_encoding_hint": "Encodes H.264 and HEVC on the graphics chip.\nMuch faster and uses less power.\nSlightly lower quality at the same file size.\nWithout a usable chip, the video is encoded as usual.",
"presets.audio_codec": "Audio codec",
"presets.audio_codec.aac": "AAC",
"presets.audio_codec.alac": "ALAC (lossless)",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 911d0f3ba..d6289a771 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -25,6 +25,13 @@
"home.drop_hint_macos": "또는 Finder에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
"home.drop_hint_linux": "또는 파일 관리자에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
"home.accessibility_needed": "⌥ Option 드래그로 원형 툴바를 열려면 손쉬운 사용에서 Filee를 허용해 주세요.\n켜는 즉시 동작해요.",
+ "home.elevated": "Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n단축키도 탐색기에서 선택한 파일을 읽지 못해요.\n관리자 권한 없이 다시 시작해 주세요.",
+ "home.elevated_restart": "관리자 권한 없이 다시 시작",
+ "home.elevated_restart_failed": "Filee를 다시 시작하지 못했어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.",
+ "elevation.notice_title": "Filee가 관리자 권한으로 실행 중이에요",
+ "elevation.notice_text": "이 상태에서는 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n관리자 권한 없이 다시 시작해 주세요.",
+ "startup.elevated_other": "다른 Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기가 파일을 넘겨줄 수 없어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.",
+ "startup.no_answer": "Filee가 실행 중이지만 응답하지 않아요.\n관리자 권한으로 실행 중이라면 트레이에서 종료한 뒤 다시 실행해 주세요.",
"home.recent": "최근 변환",
"home.recent_empty": "변환한 파일이 여기에 표시됩니다.",
"home.open_folder": "폴더 열기",
@@ -410,6 +417,8 @@
"presets.video_codec.vp9": "VP9",
"presets.video_codec.hevc": "HEVC / H.265 (더 작게)",
"presets.video_codec.av1": "AV1 (가장 작게, 느림)",
+ "presets.hardware_encoding": "하드웨어 가속 사용 (그래픽 칩)",
+ "presets.hardware_encoding_hint": "H.264와 HEVC를 그래픽 칩으로 인코딩해요.\n훨씬 빠르고 전력도 덜 써요.\n같은 용량이면 화질이 조금 낮아요.\n쓸 수 있는 칩이 없으면 평소처럼 인코딩해요.",
"presets.audio_codec": "오디오 코덱",
"presets.audio_codec.aac": "AAC",
"presets.audio_codec.alac": "ALAC (무손실)",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index d6f4f5b8d..faa811a48 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -25,6 +25,13 @@
"home.drop_hint_macos": "或在访达中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
"home.drop_hint_linux": "或在文件管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
"home.accessibility_needed": "要按住 ⌥ Option 拖动打开环形工具栏,请在“辅助功能”中允许 Filee。\n打开后立即生效。",
+ "home.elevated": "Filee 正以管理员身份运行。\n因此无法从资源管理器拖放文件,也无法使用右键菜单。\n快捷键也无法读取资源管理器中选中的文件。\n请以非管理员身份重新启动 Filee。",
+ "home.elevated_restart": "以非管理员身份重新启动",
+ "home.elevated_restart_failed": "Filee 无法自行重新启动。\n请从托盘退出 Filee,然后再次启动。",
+ "elevation.notice_title": "Filee 正以管理员身份运行",
+ "elevation.notice_text": "此时无法从资源管理器拖放文件,也无法使用右键菜单。\n请以非管理员身份重新启动。",
+ "startup.elevated_other": "另一个 Filee 正以管理员身份运行。\n资源管理器无法把文件交给它。\n请从托盘退出 Filee,然后再次启动。",
+ "startup.no_answer": "Filee 正在运行,但没有响应。\n如果它以管理员身份运行,请从托盘退出,然后再次启动。",
"home.recent": "最近的转换",
"home.recent_empty": "转换后的文件会显示在这里。",
"home.open_folder": "打开文件夹",
@@ -410,6 +417,8 @@
"presets.video_codec.vp9": "VP9",
"presets.video_codec.hevc": "HEVC / H.265(更小)",
"presets.video_codec.av1": "AV1(最小,较慢)",
+ "presets.hardware_encoding": "使用硬件加速(显卡)",
+ "presets.hardware_encoding_hint": "用显卡编码 H.264 和 HEVC。\n速度快得多,也更省电。\n相同文件大小时画质略低。\n没有可用的显卡时照常编码。",
"presets.audio_codec": "音频编码",
"presets.audio_codec.aac": "AAC",
"presets.audio_codec.alac": "ALAC(无损)",
diff --git a/src/Filee.App/Controls/SingleSelectionGuard.cs b/src/Filee.App/Controls/SingleSelectionGuard.cs
new file mode 100644
index 000000000..6b48ef51b
--- /dev/null
+++ b/src/Filee.App/Controls/SingleSelectionGuard.cs
@@ -0,0 +1,76 @@
+// Keeps combo boxes and single-selection lists from losing their selection to the toggle modifier. Avalonia treats
+// Ctrl+Space, Ctrl+Enter and Ctrl+click (Cmd on macOS) on the selected item as "toggle", which leaves a combo box
+// empty and pushes null into a setting that always has a value (#107). Multi-selection lists keep the toggle.
+//
+// A selection cleared some other way is refused by the view models (Choices.Restore); the control then shows the
+// bound value again.
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+
+namespace Filee.App.Controls;
+
+public static class SingleSelectionGuard
+{
+ private static bool _registered;
+
+ /// Installs the class handlers once for every combo box and list box of the application.
+ public static void Register()
+ {
+ if (_registered)
+ return;
+ _registered = true;
+ // Tunnel: the item's own handlers (which toggle) run in the bubble phase.
+ InputElement.KeyDownEvent.AddClassHandler(OnKeyDown, RoutingStrategies.Tunnel);
+ InputElement.PointerPressedEvent.AddClassHandler(OnPointer, RoutingStrategies.Tunnel);
+ InputElement.PointerReleasedEvent.AddClassHandler(OnPointer, RoutingStrategies.Tunnel);
+ SelectingItemsControl.SelectedItemProperty.Changed.AddClassHandler(OnSelectedItemChanged);
+ }
+
+ ///
+ /// The view model puts its value back while the control is still clearing its selection, which the control misses:
+ /// once it is done, it reads the bound value again (still null when the view model took the null).
+ ///
+ private static void OnSelectedItemChanged(SelectingItemsControl control, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.NewValue is null && e.OldValue is not null && control is ComboBox or ListBox
+ && BindingOperations.GetBindingExpressionBase(control, SelectingItemsControl.SelectedItemProperty) is { } binding)
+ Dispatcher.UIThread.Post(binding.UpdateTarget);
+ }
+
+ private static void OnKeyDown(ListBoxItem item, KeyEventArgs e)
+ {
+ if (ItemSelectionEventTriggers.ShouldTriggerSelection(item, e) && WouldDeselect(item, e))
+ Keep(item, e);
+ }
+
+ private static void OnPointer(ListBoxItem item, PointerEventArgs e)
+ {
+ if (e.GetCurrentPoint(item).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed or PointerUpdateKind.LeftButtonReleased
+ && WouldDeselect(item, e))
+ Keep(item, e);
+ }
+
+ /// The selected item of a single-selection control, picked again with the toggle modifier held.
+ private static bool WouldDeselect(ListBoxItem item, RoutedEventArgs e) =>
+ !e.Handled && item.IsSelected && ItemSelectionEventTriggers.HasToggleSelectionModifier(item, e)
+ && ItemsControl.ItemsControlFromItemContainer(item) switch
+ {
+ ComboBox => true,
+ ListBox list => (list.SelectionMode & (SelectionMode.Multiple | SelectionMode.Toggle)) == 0,
+ _ => false,
+ };
+
+ /// Picking the item that is already selected: nothing changes, an open combo box closes as after any pick.
+ private static void Keep(ListBoxItem item, RoutedEventArgs e)
+ {
+ e.Handled = true;
+ if (e.RoutedEvent != InputElement.PointerPressedEvent && ItemsControl.ItemsControlFromItemContainer(item) is ComboBox combo)
+ combo.SetCurrentValue(ComboBox.IsDropDownOpenProperty, false);
+ }
+}
diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs
index 45d9e23ce..bcd7fa63e 100644
--- a/src/Filee.App/Program.cs
+++ b/src/Filee.App/Program.cs
@@ -1,6 +1,8 @@
// Entry point. Order matters:
+// 0. The log and the handlers for unhandled exceptions, so even a start that fails leaves a line; then one line
+// describing this launch (version, path, rights, OS).
// 1. Single instance: a second launch (e.g. from the Explorer context menu) forwards its arguments
-// to the running instance and exits.
+// to the running instance and exits. When it can't (the running Filee is elevated, or hung) it says so.
// 2. Installer commands (--quit, --uninstall-cleanup) run without any window.
// 3. Start Avalonia. The app lives in the tray, so it only exits via the tray "Quit" command (or --quit).
@@ -9,6 +11,10 @@
using Avalonia.Controls;
using Avalonia.Media;
using Filee.App.Services;
+using Filee.Core.Platform;
+using Filee.Core.Settings;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
namespace Filee.App;
@@ -26,20 +32,67 @@ internal static class Program
/// How long a later launch waits for a Filee that is still starting to take its arguments.
private static readonly TimeSpan ForwardTimeout = TimeSpan.FromSeconds(30);
+ /// Integrity levels of this process and Explorer, read at start-up (Unknown off Windows).
+ public static ElevationState Elevation { get; private set; } = ElevationState.Unknown;
+
+ /// Program's own log, set up before anything else so even a failed start leaves a line.
+ internal static ILogger Log { get; private set; } = NullLogger.Instance;
+
+ /// Raised on the UI thread when the single-instance lock was taken again after a failed restart.
+ public static event Action? InstanceAcquired;
+
[STAThread]
public static int Main(string[] args)
{
Options = CommandLine.Parse(args);
- Instance = SingleInstance.Acquire();
- if (Options.Quit || Options.UninstallCleanup)
- return RunInstallerCommand(Instance);
- if (!Instance.IsFirst)
+ Log = StartupDiagnostics.CreateEarlyLogger(Path.Combine(UserDataStore.DefaultDirectory, "logs"));
+ StartupDiagnostics.LogUnhandledExceptions(Log);
+ Elevation = Filee.Platform.Windows.Elevation.Probe();
+ var startup = StartupDiagnostics.Describe(UpdateService.CurrentVersion, Environment.ProcessPath,
+ StartupDiagnostics.InstallKind(UpdateService.IsInstalled, UpdateService.IsReleaseBuild), Elevation,
+ StartupDiagnostics.OperatingSystemText(), args.Length);
+ var installerCommand = Options.Quit || Options.UninstallCleanup;
+
+ SingleInstance instance;
+ try
{
- Instance.ForwardToFirst(args, ForwardTimeout);
- Instance.Dispose();
- return 0;
+ instance = SingleInstance.Acquire();
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ // The lock belongs to a Filee running as administrator: this process may not even open it. Before this
+ // was caught, Filee ended here without a word (Explorer's menu showed a busy cursor, then nothing).
+ Log.LogWarning("{Startup}", startup);
+ Log.LogWarning(ex, "Another Filee is running with administrator rights; this launch can't reach it");
+ if (!installerCommand)
+ TellUserOnce("startup.elevated_other");
+ return 1;
+ }
+ Instance = instance;
+ if (installerCommand)
+ {
+ Log.LogInformation("{Startup}", startup);
+ return RunInstallerCommand(instance);
+ }
+ if (!instance.IsFirst)
+ {
+ var result = instance.Forward(args, ForwardTimeout);
+ if (result != ForwardResult.Delivered)
+ {
+ // Logged only when it failed: Explorer starts one Filee per selected file.
+ Log.LogWarning("{Startup}", startup);
+ Log.LogWarning("Could not hand {Count} argument(s) to the running Filee: {Result}", args.Length, result);
+ TellUserOnce(result == ForwardResult.AccessDenied ? "startup.elevated_other" : "startup.no_answer");
+ }
+ ReleaseInstance();
+ return result == ForwardResult.Delivered ? 0 : 1;
}
+ Log.LogInformation("{Startup}", startup);
+ if (Elevation.ShellCannotReachFilee)
+ Log.LogWarning("Filee runs as administrator ({Elevation}) while Explorer doesn't: Explorer can't drop files on it, " +
+ "use its menu or have its selection read", Elevation);
+
try
{
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown);
@@ -55,7 +108,66 @@ public static int Main(string[] args)
}
finally
{
- Instance.Dispose();
+ ReleaseInstance();
+ }
+ }
+
+ /// Lets go of the single-instance lock (at exit, or before restarting without administrator rights).
+ public static void ReleaseInstance()
+ {
+ Instance?.Dispose();
+ Instance = null;
+ }
+
+ /// Takes the lock back after a restart that didn't happen, so later launches find this Filee again.
+ public static void ReacquireInstance()
+ {
+ if (Instance is not null)
+ return;
+ try
+ {
+ var instance = SingleInstance.Acquire();
+ if (!instance.IsFirst)
+ {
+ // Another Filee started meanwhile (perhaps the restart did happen after all): it is the running one.
+ Log.LogWarning("Another Filee took the single-instance lock during the restart");
+ instance.Dispose();
+ return;
+ }
+ Instance = instance;
+ InstanceAcquired?.Invoke(instance);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Log.LogWarning(ex, "Could not take the single-instance lock back");
+ }
+ }
+
+ ///
+ /// Shows a launch problem in a system message box (Filee's own windows aren't available in this process). Explorer
+ /// starts one Filee per selected file: a lock shared between them lets only the first one show it.
+ ///
+ private static void TellUserOnce(string key)
+ {
+ if (!OperatingSystem.IsWindows())
+ return;
+ try
+ {
+ using var shown = new Mutex(initiallyOwned: true, @"Local\" + SingleInstance.DefaultName + "-notice", out var createdNew);
+ if (!createdNew)
+ return;
+ try
+ {
+ Filee.Platform.Windows.Elevation.ShowWarning(LocalizationService.EarlyText(key));
+ }
+ finally
+ {
+ shown.ReleaseMutex();
+ }
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or WaitHandleCannotBeOpenedException)
+ {
+ Log.LogWarning(ex, "Could not show the launch problem");
}
}
diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs
index 5b03168fe..b88787309 100644
--- a/src/Filee.App/Services/AppHost.cs
+++ b/src/Filee.App/Services/AppHost.cs
@@ -44,10 +44,11 @@ public static IServiceProvider Build(string? dataDirectory = null)
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// Conversion engines
services.AddSingleton(_ => new EngineEnvironment(dataDir));
- services.AddSingleton(sp => EngineRegistry.CreateAll(sp.GetRequiredService()));
+ services.AddSingleton(sp => EngineRegistry.CreateAll(sp.GetRequiredService(), sp.GetRequiredService()));
services.AddSingleton(sp => new ConverterCatalog(sp.GetRequiredService>()));
services.AddSingleton(sp => new JobQueue(
sp.GetRequiredService(),
diff --git a/src/Filee.App/Services/ElevationService.cs b/src/Filee.App/Services/ElevationService.cs
new file mode 100644
index 000000000..33291e8cb
--- /dev/null
+++ b/src/Filee.App/Services/ElevationService.cs
@@ -0,0 +1,81 @@
+// Filee running as administrator while Explorer doesn't (see ElevationState): the Home page shows why gestures, drops
+// and the Explorer menu do nothing, and offers to restart Filee with Explorer's rights. The restart lets go of the
+// single-instance lock first, so the new Filee becomes the running one instead of handing its arguments to this one
+// (which it couldn't, being refused by an elevated process anyway), then quits this Filee.
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using Filee.Core.Platform;
+using Microsoft.Extensions.Logging;
+
+namespace Filee.App.Services;
+
+/// Elevation state of this run and the restart without administrator rights.
+public sealed partial class ElevationService : ObservableObject
+{
+ private readonly ILogger _log;
+ private readonly Func _relaunch;
+ private readonly Action _releaseInstance;
+ private readonly Action _reacquireInstance;
+ private readonly Action _shutdown;
+
+ /// The app's service: the state Program read at start-up, the real relaunch and shutdown.
+ public ElevationService(ILogger log) : this(log, Program.Elevation, RelaunchThroughShell,
+ Program.ReleaseInstance, Program.ReacquireInstance, App.Quit)
+ {
+ }
+
+ /// Starts the executable with Explorer's rights; returns null when started, else why not.
+ /// Lets go of the single-instance lock.
+ /// Takes the lock back when the restart failed.
+ /// Ends this Filee.
+ internal ElevationService(ILogger log, ElevationState state, Func relaunch,
+ Action releaseInstance, Action reacquireInstance, Action shutdown)
+ {
+ _log = log;
+ State = state;
+ _relaunch = relaunch;
+ _releaseInstance = releaseInstance;
+ _reacquireInstance = reacquireInstance;
+ _shutdown = shutdown;
+ }
+
+ public ElevationState State { get; }
+
+ /// Explorer can't drop files on Filee, use its menu, or have its selection read: show the warning.
+ public bool ShellCannotReachFilee => State.ShellCannotReachFilee;
+
+ /// The last restart attempt failed (the Home page says so).
+ [ObservableProperty] private bool _restartFailed;
+
+ ///
+ /// Starts Filee again through Explorer and quits this one. Returns false (and sets )
+ /// when Explorer couldn't start it; this Filee then keeps running as before.
+ ///
+ public bool RestartWithoutAdministratorRights(string? executable = null)
+ {
+ executable ??= Environment.ProcessPath;
+ if (string.IsNullOrEmpty(executable))
+ {
+ _log.LogWarning("Restart without administrator rights: the path of Filee.exe is unknown");
+ RestartFailed = true;
+ return false;
+ }
+
+ _log.LogInformation("Restarting without administrator rights through Explorer");
+ _releaseInstance();
+ var error = _relaunch(executable);
+ if (error is null)
+ {
+ RestartFailed = false;
+ _shutdown();
+ return true;
+ }
+ _log.LogWarning("Restart without administrator rights failed: {Error}", error);
+ _reacquireInstance();
+ RestartFailed = true;
+ return false;
+ }
+
+ private static string? RelaunchThroughShell(string executable) =>
+ Filee.Platform.Windows.Elevation.RelaunchThroughShell(executable, "", out var error) ? null : error ?? "unknown error";
+}
diff --git a/src/Filee.App/Services/FileLogger.cs b/src/Filee.App/Services/FileLogger.cs
index 4de17498a..6a26a98e1 100644
--- a/src/Filee.App/Services/FileLogger.cs
+++ b/src/Filee.App/Services/FileLogger.cs
@@ -6,8 +6,9 @@ namespace Filee.App.Services;
public sealed class FileLoggerProvider : ILoggerProvider
{
+ // Shared by every provider: Program's early logger and AppHost's write to the same file.
+ private static readonly Lock WriteLock = new();
private readonly string _directory;
- private readonly object _lock = new();
public FileLoggerProvider(string directory)
{
@@ -16,7 +17,7 @@ public FileLoggerProvider(string directory)
foreach (var old in Directory.EnumerateFiles(directory, "filee-*.log")
.Where(f => File.GetLastWriteTime(f) < DateTime.Now.AddDays(-7)))
{
- try { File.Delete(old); } catch (IOException) { }
+ try { File.Delete(old); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
}
}
@@ -26,13 +27,13 @@ public void Dispose() { }
private void Write(string line)
{
- lock (_lock)
+ lock (WriteLock)
{
try
{
File.AppendAllText(Path.Combine(_directory, $"filee-{DateTime.Now:yyyyMMdd}.log"), line + Environment.NewLine);
}
- catch (IOException) { }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
}
}
diff --git a/src/Filee.App/Services/LocalizationService.cs b/src/Filee.App/Services/LocalizationService.cs
index f09685a04..9d1dbf831 100644
--- a/src/Filee.App/Services/LocalizationService.cs
+++ b/src/Filee.App/Services/LocalizationService.cs
@@ -100,13 +100,35 @@ public static string Resolve(string setting)
return "en";
}
- /// Reads a translation file. Missing files yield an empty dictionary (English is used instead).
- internal static Dictionary LoadStrings(string code)
+ ///
+ /// Reads a translation file. Missing files yield an empty dictionary (English is used instead).
+ /// reads assets before Avalonia is set up (a message shown by Program).
+ ///
+ internal static Dictionary LoadStrings(string code, IAssetLoader? loader = null)
{
var uri = new Uri($"avares://Filee/Assets/i18n/{code}.json");
- if (!AssetLoader.Exists(uri))
+ if (!(loader?.Exists(uri) ?? AssetLoader.Exists(uri)))
return [];
- using var stream = AssetLoader.Open(uri);
+ using var stream = loader?.Open(uri) ?? AssetLoader.Open(uri);
return JsonSerializer.Deserialize(stream, AppJsonContext.Default.DictionaryStringString) ?? [];
}
+
+ ///
+ /// One string in the system's language, before Avalonia (and the settings) are loaded: for the messages a launch
+ /// shows when it can't reach the running Filee. Falls back to English, then to the key.
+ ///
+ internal static string EarlyText(string key)
+ {
+ try
+ {
+ var loader = new StandardAssetLoader(typeof(LocalizationService).Assembly);
+ var code = Resolve("auto");
+ if (LoadStrings(code, loader).TryGetValue(key, out var text) || LoadStrings("en", loader).TryGetValue(key, out text))
+ return text;
+ }
+ catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException or ArgumentException)
+ {
+ }
+ return key;
+ }
}
diff --git a/src/Filee.App/Services/LogThrottle.cs b/src/Filee.App/Services/LogThrottle.cs
new file mode 100644
index 000000000..d539fecbf
--- /dev/null
+++ b/src/Filee.App/Services/LogThrottle.cs
@@ -0,0 +1,37 @@
+// Keeps diagnostics that can come in bursts to a few log lines: Explorer starts one Filee per selected file, and a
+// gesture or shortcut can repeat many times a minute. The first event of a kind is logged, then at most one per
+// interval, saying how many were held back in between.
+
+namespace Filee.App.Services;
+
+/// Rate limit for log lines, per kind of event. Thread-safe.
+internal sealed class LogThrottle(TimeSpan interval, TimeProvider? time = null)
+{
+ private readonly TimeProvider _time = time ?? TimeProvider.System;
+ private readonly Dictionary _kinds = new(StringComparer.Ordinal);
+ private readonly Lock _lock = new();
+
+ ///
+ /// True when an event of kind may be logged now. is how many of
+ /// that kind were held back since the last one logged (0 when it returns false).
+ ///
+ public bool TryEnter(string kind, out int held)
+ {
+ lock (_lock)
+ {
+ var now = _time.GetUtcNow();
+ if (_kinds.TryGetValue(kind, out var entry) && now - entry.Last < interval)
+ {
+ _kinds[kind] = (entry.Last, entry.Held + 1);
+ held = 0;
+ return false;
+ }
+ held = entry.Held;
+ _kinds[kind] = (now, 0);
+ return true;
+ }
+ }
+
+ /// " (3 more since the last one)", or "" when none were held back.
+ public static string HeldText(int held) => held > 0 ? $" ({held} more since the last one)" : "";
+}
diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs
index 202d9bc4a..789759aa9 100644
--- a/src/Filee.App/Services/RadialController.cs
+++ b/src/Filee.App/Services/RadialController.cs
@@ -46,6 +46,10 @@ public sealed class RadialController(
private JobViewModel? _lastRingJob; // for "click the ✓ to open the folder"
private IDisposable? _handOffTimer;
private int _dragGeneration;
+ private bool _dragReachedDonut;
+
+ /// A gesture over something that isn't a file drag (a rubber-band selection) is common: a line now and then.
+ private static readonly LogThrottle Throttle = new(TimeSpan.FromMinutes(1));
///
/// How long a drag that never brought files may keep the invisible window up, in case the button release is
@@ -69,6 +73,7 @@ public void ShowForDrag(int x, int y)
// A ring from an earlier conversion (finished or handed to the toast) must not keep this gesture's window up.
window.DonutControl.LeaveProgressMode();
_dropped = false;
+ _dragReachedDonut = false;
viewModel.ResetForDrag();
window.DonutControl.AllowContextEdit = false;
PlaceAndShow(window, x, y, activate: false);
@@ -101,6 +106,12 @@ public void OnDragGestureEnded()
{
if (!_visible || viewModel.IsClickMode)
return;
+ // No drag ever entered the donut's window: nothing was dragged (a rubber-band selection), or Windows kept
+ // Explorer's drag out (Filee runs as administrator). Tells "the donut didn't react" reports apart.
+ if (!_dragReachedDonut && Throttle.TryEnter("no-drag-enter", out var held))
+ log.LogInformation("Drag gesture ended without a drag entering the donut{Elevation}{Held}",
+ Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator: Windows blocks drops from Explorer)" : "",
+ LogThrottle.HeldText(held));
// Nothing was brought to the donut: the window is invisible but catches clicks, so go right away.
if (!viewModel.HasFiles && !_window!.DonutControl.IsInProgressMode)
{
@@ -154,6 +165,7 @@ private RadialWindow EnsureWindow()
var window = new RadialWindow { DataContext = viewModel };
var donut = window.DonutControl;
+ window.DragEntered += () => _dragReachedDonut = true;
window.FilesEntered += files =>
{
if (viewModel.IsClickMode || SameFiles(files, viewModel.Files))
diff --git a/src/Filee.App/Services/SingleInstance.cs b/src/Filee.App/Services/SingleInstance.cs
index 64285de37..4f1c12479 100644
--- a/src/Filee.App/Services/SingleInstance.cs
+++ b/src/Filee.App/Services/SingleInstance.cs
@@ -10,7 +10,7 @@ namespace Filee.App.Services;
public sealed class SingleInstance : IDisposable
{
// string.GetHashCode() is randomized per process, so a stable hash is required for both processes to agree.
- private static readonly string DefaultName = "Filee-" + Convert.ToHexString(
+ internal static readonly string DefaultName = "Filee-" + Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Environment.UserName)))[..12];
private readonly Mutex _mutex;
private readonly string _name;
@@ -67,6 +67,19 @@ public void StartListening()
{
// A client disconnected half-way; keep serving.
}
+ catch (UnauthorizedAccessException)
+ {
+ // The pipe name is still held by a Filee with other rights (one that just restarted this one
+ // without administrator rights and is closing): try again once it is gone.
+ try
+ {
+ await Task.Delay(500, _cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ }
}
});
}
@@ -75,11 +88,19 @@ public void StartListening()
/// Sends this process's arguments to the first instance. It only listens once its services are up, which can take
/// over ten seconds on a slow or busy computer: a second launch (or the installer's --quit) right after the first
/// lost its arguments after one 3-second try. Tries again until has passed; false when the
- /// first instance never answered (it is shutting down, or hung).
+ /// first instance never answered (it is shutting down, or hung) or refused this process.
+ ///
+ public bool ForwardToFirst(string[] args, TimeSpan wait) => Forward(args, wait) == ForwardResult.Delivered;
+
+ ///
+ /// Like , telling why it failed. A pipe that keeps refusing access belongs to a Filee
+ /// running with other rights (as administrator, while this one isn't): that never changes, so it gives up after a
+ /// few tries instead of waiting the whole time.
///
- public bool ForwardToFirst(string[] args, TimeSpan wait)
+ public ForwardResult Forward(string[] args, TimeSpan wait)
{
var deadline = DateTime.UtcNow + wait;
+ var refusals = 0;
while (true)
{
var left = deadline - DateTime.UtcNow;
@@ -89,17 +110,29 @@ public bool ForwardToFirst(string[] args, TimeSpan wait)
client.Connect((int)Math.Clamp(left.TotalMilliseconds, 100, 1000));
Filee.Platform.Windows.Foreground.AllowOtherProcess(); // its donut takes the focus (Esc, click away)
JsonSerializer.Serialize(client, args, AppJsonContext.Default.StringArray);
- return true;
+ return ForwardResult.Delivered;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Also thrown for a moment while the pipe is being recreated between two launches: try a few times.
+ if (++refusals >= RefusalsBeforeGivingUp)
+ return ForwardResult.AccessDenied;
+ if (DateTime.UtcNow >= deadline)
+ return ForwardResult.NoAnswer;
+ Thread.Sleep(100);
}
- catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException)
+ catch (Exception ex) when (ex is IOException or TimeoutException)
{
if (DateTime.UtcNow >= deadline)
- return false;
+ return ForwardResult.NoAnswer;
Thread.Sleep(100); // the pipe is busy with another launch, or not there yet
}
}
}
+ /// Access-denied answers in a row after which stops trying.
+ private const int RefusalsBeforeGivingUp = 5;
+
///
/// Waits until the first instance has exited (it releases the mutex then), e.g. after forwarding --quit.
/// Returns false on timeout.
@@ -121,8 +154,12 @@ public bool WaitForFirstToExit(TimeSpan timeout)
return true;
}
+ /// Stops listening and releases the mutex. Safe to call twice (a restart releases it before shutting down).
public void Dispose()
{
+ if (_disposed)
+ return;
+ _disposed = true;
_cts.Cancel();
if (IsFirst)
{
@@ -134,4 +171,19 @@ public void Dispose()
}
_mutex.Dispose();
}
+
+ private bool _disposed;
+}
+
+/// Outcome of .
+public enum ForwardResult
+{
+ /// The running Filee has the arguments.
+ Delivered,
+
+ /// Nobody took them in time: the running Filee is starting, shutting down or hung.
+ NoAnswer,
+
+ /// The running Filee's pipe refuses this process: it runs with other rights (as administrator).
+ AccessDenied,
}
diff --git a/src/Filee.App/Services/StartupDiagnostics.cs b/src/Filee.App/Services/StartupDiagnostics.cs
new file mode 100644
index 000000000..aa6df5519
--- /dev/null
+++ b/src/Filee.App/Services/StartupDiagnostics.cs
@@ -0,0 +1,56 @@
+// What Filee writes to its log before and around start-up: one line describing this launch, and every exception that
+// would otherwise end the process (or vanish in a task) without a trace. A report of a Filee that ignored every
+// gesture came with an empty log; these lines are what tells such cases apart.
+
+using System.Runtime.InteropServices;
+using Filee.Core.Platform;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Filee.App.Services;
+
+internal static class StartupDiagnostics
+{
+ ///
+ /// A logger for Program, before the service container exists. It writes to the same daily file. A log folder that
+ /// can't be created gives a logger that drops everything: logging must never stop Filee from starting.
+ ///
+ public static ILogger CreateEarlyLogger(string logDirectory)
+ {
+ try
+ {
+ return new FileLoggerProvider(logDirectory).CreateLogger("Filee.App.Program");
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
+ {
+ return NullLogger.Instance;
+ }
+ }
+
+ ///
+ /// The start-up line: "Filee 1.11.0 starting: C:\…\Filee.exe (installed), integrity Medium, Explorer Medium,
+ /// Microsoft Windows 10.0.26200 X64, 2 argument(s)". Arguments are counted, not written: they are file paths.
+ ///
+ public static string Describe(string version, string? executable, string installKind, ElevationState elevation,
+ string operatingSystem, int argumentCount) =>
+ $"Filee {version} starting: {executable ?? "(unknown path)"} ({installKind}), {elevation}, {operatingSystem}, " +
+ $"{argumentCount} argument(s)";
+
+ /// How this copy got here: Setup, a release archive, or a build from source.
+ public static string InstallKind(bool installed, bool release) =>
+ installed ? "installed" : release ? "portable" : "development build";
+
+ /// "Microsoft Windows 10.0.26200 X64".
+ public static string OperatingSystemText() =>
+ $"{RuntimeInformation.OSDescription.Trim()} {RuntimeInformation.OSArchitecture}";
+
+ /// Writes exceptions from any thread and unobserved task failures to .
+ public static void LogUnhandledExceptions(ILogger log)
+ {
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ log.LogCritical(e.ExceptionObject as Exception, "Unhandled exception (process ending: {Terminating}): {Object}",
+ e.IsTerminating, e.ExceptionObject is Exception ? "see below" : e.ExceptionObject);
+ TaskScheduler.UnobservedTaskException += (_, e) =>
+ log.LogError(e.Exception, "A background task failed and nobody looked at the error");
+ }
+}
diff --git a/src/Filee.App/Services/UiWatchdog.cs b/src/Filee.App/Services/UiWatchdog.cs
new file mode 100644
index 000000000..8d397c180
--- /dev/null
+++ b/src/Filee.App/Services/UiWatchdog.cs
@@ -0,0 +1,86 @@
+// Notices when the UI thread stops answering. A report said every gesture, shortcut and drop did nothing while the log
+// stayed empty; a line saying the UI thread was stuck (and for how long) tells a hang from events that never arrived.
+// A background timer posts a ping to the UI thread every second. While a ping waits longer than the threshold, one
+// warning is written for that episode; when the ping finally runs, one line says how long the UI thread was gone.
+
+using Microsoft.Extensions.Logging;
+
+namespace Filee.App.Services;
+
+/// Logs UI thread stalls longer than a threshold, once per episode.
+internal sealed class UiWatchdog : IDisposable
+{
+ /// How often the UI thread is pinged.
+ public static readonly TimeSpan Interval = TimeSpan.FromSeconds(1);
+
+ /// Stalls shorter than this aren't worth a line (a big folder list, a first window).
+ public static readonly TimeSpan DefaultThreshold = TimeSpan.FromSeconds(5);
+
+ private readonly Action _post;
+ private readonly ILogger _log;
+ private readonly TimeProvider _time;
+ private readonly TimeSpan _threshold;
+ private readonly Lock _lock = new();
+ private DateTimeOffset? _pingSentAt;
+ private bool _reported;
+ private ITimer? _timer;
+
+ /// Queues an action on the UI thread (Dispatcher.UIThread.Post in the app).
+ public UiWatchdog(Action post, ILogger log, TimeProvider? time = null, TimeSpan? threshold = null)
+ {
+ _post = post;
+ _log = log;
+ _time = time ?? TimeProvider.System;
+ _threshold = threshold ?? DefaultThreshold;
+ }
+
+ /// Starts pinging every on a thread-pool timer.
+ public void Start() => _timer ??= _time.CreateTimer(_ => Tick(), null, Interval, Interval);
+
+ /// One timer tick: sends a ping, or checks how long the last one has been waiting.
+ internal void Tick()
+ {
+ lock (_lock)
+ {
+ if (_pingSentAt is { } sent)
+ {
+ var waited = _time.GetUtcNow() - sent;
+ if (!_reported && waited > _threshold)
+ {
+ _reported = true;
+ _log.LogWarning("UI thread blocked for {Seconds:0} s", waited.TotalSeconds);
+ }
+ return;
+ }
+ _pingSentAt = _time.GetUtcNow();
+ }
+ try
+ {
+ _post(Pong);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException)
+ {
+ // The dispatcher is shutting down: nothing left to watch.
+ lock (_lock)
+ _pingSentAt = null;
+ }
+ }
+
+ /// Runs on the UI thread when it got to the ping.
+ private void Pong()
+ {
+ lock (_lock)
+ {
+ if (_reported && _pingSentAt is { } sent)
+ _log.LogInformation("UI thread responsive again after {Seconds:0.0} s", (_time.GetUtcNow() - sent).TotalSeconds);
+ _pingSentAt = null;
+ _reported = false;
+ }
+ }
+
+ public void Dispose()
+ {
+ _timer?.Dispose();
+ _timer = null;
+ }
+}
diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs
index 1c23eec7b..be5014526 100644
--- a/src/Filee.App/Services/WindowService.cs
+++ b/src/Filee.App/Services/WindowService.cs
@@ -130,6 +130,17 @@ public void ShowFeedbackNotice()
_feedbackNotice.Show();
}
+ /// Shows the "Filee is running as administrator" card in the bottom-right corner (once per run).
+ public void ShowElevationNotice()
+ {
+ if (_elevationNoticeShown)
+ return;
+ _elevationNoticeShown = true;
+ new ElevationNoticeWindow(services.GetRequiredService()).Show();
+ }
+
+ private bool _elevationNoticeShown;
+
/// Opens the preset popup. Returns true when the user saved (the preset object was updated).
public async Task EditPresetAsync(Preset preset, Window? owner)
{
diff --git a/src/Filee.App/ViewModels/MainWindowViewModel.cs b/src/Filee.App/ViewModels/MainWindowViewModel.cs
index c64dcc4f9..629d9a810 100644
--- a/src/Filee.App/ViewModels/MainWindowViewModel.cs
+++ b/src/Filee.App/ViewModels/MainWindowViewModel.cs
@@ -109,8 +109,13 @@ public void Navigate(string key)
SelectedItem = item;
}
- // Pages are recreated on every visit so they always reflect the latest settings.
- partial void OnSelectedItemChanged(NavItem value) => CurrentPage = CreatePage(value);
+ // Pages are recreated on every visit so they always reflect the latest settings. A navigation list that loses its
+ // selection keeps the open page (Choices.Restore).
+ partial void OnSelectedItemChanged(NavItem? oldValue, NavItem newValue)
+ {
+ if (!Choices.Restore(oldValue, newValue, v => SelectedItem = v))
+ CurrentPage = CreatePage(newValue);
+ }
// Old pages may hold event subscriptions on long-lived services.
partial void OnCurrentPageChanged(ObservableObject? oldValue, ObservableObject newValue) => (oldValue as IDisposable)?.Dispose();
diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
index cd03b1fec..623d5b346 100644
--- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
@@ -65,7 +65,11 @@ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServic
public bool ShowDefaultSubfolder => DefaultLocation.Value == OutputLocation.Subfolder;
public bool ShowDefaultFolder => DefaultLocation.Value == OutputLocation.CustomFolder;
- partial void OnDefaultLocationChanged(Choice value) => Save(s => s.DefaultOutput.Location = value.Value);
+ partial void OnDefaultLocationChanged(Choice? oldValue, Choice newValue)
+ {
+ if (!Choices.Restore(oldValue, newValue, v => DefaultLocation = v))
+ Save(s => s.DefaultOutput.Location = newValue.Value);
+ }
partial void OnDefaultSubfolderChanged(string value) =>
Save(s => s.DefaultOutput.SubfolderName = string.IsNullOrWhiteSpace(value) ? "converted" : value.Trim());
@@ -96,7 +100,11 @@ partial void OnDefaultSubfolderChanged(string value) =>
public bool CanAddModernMenu => !ModernMenuBusy && ModernMenuState is ModernContextMenuState.Off or ModernContextMenuState.Outdated;
public bool CanRemoveModernMenu => !ModernMenuBusy && ModernMenuState is ModernContextMenuState.On or ModernContextMenuState.Outdated;
- partial void OnLanguageChanged(Choice value) => Save(s => s.Language = value.Value);
+ partial void OnLanguageChanged(Choice? oldValue, Choice newValue)
+ {
+ if (!Choices.Restore(oldValue, newValue, v => Language = v))
+ Save(s => s.Language = newValue.Value);
+ }
partial void OnStartWithSystemChanged(bool value) => Save(s => s.StartWithSystem = value);
partial void OnContextMenuChanged(bool value)
diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
index 274e7c6df..189d3d26e 100644
--- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
@@ -83,17 +83,21 @@ public sealed partial class HomePageViewModel : ObservableObject, IDisposable
private readonly IPlatformServices _platform;
private readonly Services.Triggers.TriggerService? _triggers;
private readonly ConversionService? _conversions;
+ private readonly ElevationService? _elevation;
public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, Services.Triggers.TriggerService? triggers = null,
- ConversionService? conversions = null)
+ ConversionService? conversions = null, ElevationService? elevation = null)
{
_store = store;
_loc = loc;
_platform = platform;
_triggers = triggers;
_conversions = conversions;
+ _elevation = elevation;
if (triggers is not null)
triggers.StatusChanged += OnTriggerStatusChanged;
+ if (elevation is not null)
+ elevation.PropertyChanged += OnElevationChanged;
var drag = store.Settings.Triggers.FirstOrDefault(t => t.Enabled && t.Kind == TriggerKind.Drag);
DropHint = loc.Format(OperatingSystem.IsMacOS() ? "home.drop_hint_macos" : OperatingSystem.IsLinux() ? "home.drop_hint_linux" : "home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers));
store.HistoryChanged += OnHistoryChanged;
@@ -107,10 +111,27 @@ public void Dispose()
_store.HistoryChanged -= OnHistoryChanged;
if (_triggers is not null)
_triggers.StatusChanged -= OnTriggerStatusChanged;
+ if (_elevation is not null)
+ _elevation.PropertyChanged -= OnElevationChanged;
}
private void OnTriggerStatusChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(NeedsAccessibility));
+ private void OnElevationChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) =>
+ OnPropertyChanged(nameof(ElevationRestartFailed));
+
+ ///
+ /// Windows: Filee runs as administrator while Explorer doesn't, so drops, the Explorer menu and the shortcut can't
+ /// reach it (the warning above the drop zone).
+ ///
+ public bool IsElevated => _elevation?.ShellCannotReachFilee == true;
+
+ /// The restart button didn't work: the warning says what to do instead.
+ public bool ElevationRestartFailed => _elevation?.RestartFailed == true;
+
+ [RelayCommand]
+ private void RestartWithoutAdministratorRights() => _elevation?.RestartWithoutAdministratorRights();
+
/// macOS: gestures wait for the Accessibility permission (the reminder above the drop zone).
public bool NeedsAccessibility => _triggers?.NeedsPermission == true;
diff --git a/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs
index c93ecbd47..badc7bc38 100644
--- a/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs
@@ -51,7 +51,11 @@ public ThemePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices
[ObservableProperty] private double _cornerRadius;
[ObservableProperty] private bool _reduceMotion;
- partial void OnModeChanged(Choice value) => Save(t => t.Mode = value.Value);
+ partial void OnModeChanged(Choice? oldValue, Choice newValue)
+ {
+ if (!Choices.Restore(oldValue, newValue, v => Mode = v))
+ Save(t => t.Mode = newValue.Value);
+ }
partial void OnAccentChanged(Color value)
{
var hex = $"#{value.R:X2}{value.G:X2}{value.B:X2}";
diff --git a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
index cf1c8bd22..9e5c8ae17 100644
--- a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs
@@ -85,6 +85,11 @@ public TriggerGestureViewModel(TriggerGesture gesture, ILocalizer loc, Action sa
public string ThresholdText => DragThreshold == 0 ? SystemLabel : $"{DragThreshold}px";
public string Summary => GestureText.Describe(_gesture);
+ // Runs before OnPropertyChanged below, which reads every choice.
+ partial void OnKindChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Kind = v);
+ partial void OnButtonChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Button = v);
+ partial void OnScopeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Scope = v);
+
protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e)
{
base.OnPropertyChanged(e);
diff --git a/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs
index 8e60dd376..e32635cd2 100644
--- a/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs
@@ -333,10 +333,19 @@ internal void RefreshResults()
partial void OnEnabledChanged(bool value) => Change(r => r.Enabled = value);
partial void OnFolderChanged(string value) => Change(r => r.Folder = value.Trim());
- partial void OnPresetChanged(Choice? value) => Change(r => r.PresetId = value?.Value ?? "");
+ // Null only while no preset was found for the rule (deleted); the combo box clearing itself doesn't forget the preset.
+ partial void OnPresetChanged(Choice? oldValue, Choice? newValue)
+ {
+ if (!Choices.Restore(oldValue, newValue, v => Preset = v))
+ Change(r => r.PresetId = newValue?.Value ?? "");
+ }
partial void OnIncludeSubfoldersChanged(bool value) => Change(r => r.IncludeSubfolders = value);
partial void OnOutputFolderChanged(string value) => Change(r => r.OutputFolder = value.Trim());
- partial void OnOriginalsChanged(Choice value) => Change(r => r.Originals = value.Value);
+ partial void OnOriginalsChanged(Choice? oldValue, Choice newValue)
+ {
+ if (!Choices.Restore(oldValue, newValue, v => Originals = v))
+ Change(r => r.Originals = newValue.Value);
+ }
partial void OnIncludeTextChanged(string value)
{
diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs
index 45382072c..46e3e9eef 100644
--- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs
+++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs
@@ -25,6 +25,23 @@ public sealed record Choice(T Value, string Label, string? Detail = null) : I
public override string ToString() => Label;
}
+/// Selections bound to properties that always hold a value (a choice, the open page).
+public static class Choices
+{
+ ///
+ /// For the OnXxxChanged(oldValue, newValue) hook of such a property. A combo box or list box sets null when it
+ /// loses its selection (the selected item toggled off, its items replaced): the previous value is put back through
+ /// , which also shows it in the control again. Returns true then, so the hook stops.
+ ///
+ public static bool Restore(T? oldValue, T? newValue, Action restore) where T : class
+ {
+ if (newValue is not null || oldValue is null)
+ return false;
+ restore(oldValue);
+ return true;
+ }
+}
+
public sealed partial class PresetEditorViewModel : ObservableObject
{
private static readonly HashSet LossyTargets = ["jpg", "webp", "avif", "heic"];
@@ -94,6 +111,7 @@ .. FormatRegistry.Known
_wavFormat = WavFormats.First(f => f.Value == _edit.Media.WavFormat);
_videoCodecValue = _edit.Media.VideoCodec;
_audioCodecValue = _edit.Media.AudioCodec;
+ _hardwareEncoding = _edit.Media.HardwareEncoding;
_archiveLevel = ArchiveLevels.First(l => l.Value == _edit.Archive.Level);
_combineIntoOne = _edit.Archive.CombineIntoOne;
_traceMode = TraceModes.First(m => m.Value == _edit.Trace.Mode);
@@ -158,7 +176,7 @@ .. FormatRegistry.Known
[NotifyPropertyChangedFor(nameof(ShowImage), nameof(ShowTurnForPdf), nameof(ShowQuality), nameof(ShowTiff), nameof(ShowMultiPageTiff), nameof(ShowWebp), nameof(ShowIco),
nameof(ShowPdf), nameof(ShowPdfBuild), nameof(ShowDocument), nameof(ShowPdfSplit), nameof(ShowPdfCombine),
nameof(ShowMedia), nameof(ShowMediaQuality), nameof(ShowMaxHeight), nameof(ShowAudioBitrate), nameof(ShowRemoveAudio),
- nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec),
+ nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec), nameof(ShowHardwareEncoding),
nameof(AudioCodecs), nameof(AudioCodecChoice), nameof(ShowAudioCodec),
nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote), nameof(ShowTrace))]
private Choice _target;
@@ -181,11 +199,18 @@ public Choice VideoCodecChoice
get => VideoCodecs.FirstOrDefault(c => c.Value == _videoCodecValue) ?? VideoCodecs[0];
set
{
- // The combo box clears its selection while its list is replaced.
- if (value is null || value.Value == _videoCodecValue)
+ // The combo box clears its selection while its list is replaced (or the selected item is toggled off):
+ // announcing the value shows it again.
+ if (value is null)
+ {
+ OnPropertyChanged();
+ return;
+ }
+ if (value.Value == _videoCodecValue)
return;
_videoCodecValue = value.Value;
OnPropertyChanged();
+ OnPropertyChanged(nameof(ShowHardwareEncoding));
}
}
@@ -199,7 +224,12 @@ public Choice AudioCodecChoice
get => AudioCodecs.FirstOrDefault(c => c.Value == _audioCodecValue) ?? AudioCodecs[0];
set
{
- if (value is null || value.Value == _audioCodecValue)
+ if (value is null)
+ {
+ OnPropertyChanged();
+ return;
+ }
+ if (value.Value == _audioCodecValue)
return;
_audioCodecValue = value.Value;
OnPropertyChanged();
@@ -281,6 +311,9 @@ public bool ColorProfileChecked
[ObservableProperty] private Choice _sampleRate;
[ObservableProperty] private Choice _wavFormat;
[ObservableProperty] private bool _removeAudio;
+
+ /// Encode H.264 / HEVC on the graphics chip when one works (MediaOptions.HardwareEncoding).
+ [ObservableProperty] private bool _hardwareEncoding;
[ObservableProperty] private Choice _archiveLevel;
[ObservableProperty] private bool _combineIntoOne;
@@ -290,6 +323,25 @@ public bool ColorProfileChecked
[ObservableProperty] private int _traceColors;
[ObservableProperty] private int _traceDetail;
+ // A combo box that loses its selection sets null: every choice keeps its value (see Choices.Restore).
+ partial void OnTargetChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Target = v);
+ partial void OnResizeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Resize = v);
+ partial void OnRotationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Rotation = v);
+ partial void OnFlipChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Flip = v);
+ partial void OnPageSizeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => PageSize = v);
+ partial void OnLocationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Location = v);
+ partial void OnConflictChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Conflict = v);
+ partial void OnTiffCompressionChanged(Choice? oldValue, Choice newValue) =>
+ Choices.Restore(oldValue, newValue, v => TiffCompression = v);
+ partial void OnMediaQualityChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => MediaQuality = v);
+ partial void OnMaxHeightChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => MaxHeight = v);
+ partial void OnAudioBitrateChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => AudioBitrate = v);
+ partial void OnAudioChannelsChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => AudioChannels = v);
+ partial void OnSampleRateChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => SampleRate = v);
+ partial void OnWavFormatChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => WavFormat = v);
+ partial void OnArchiveLevelChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => ArchiveLevel = v);
+ partial void OnTraceModeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => TraceMode = v);
+
private string TargetId => Target.Value;
private FormatCategory? TargetCategory => FormatRegistry.FindById(TargetId)?.Category;
private bool TargetIsImage => TargetId == BuiltInData.SameAsSource || TargetCategory == FormatCategory.Image;
@@ -325,6 +377,8 @@ public bool ColorProfileChecked
public bool ShowWav => TargetId is "wav" or "w64";
/// MP4, MKV, MOV and WebM can be written with HEVC or AV1 besides their usual codec.
public bool ShowVideoCodec => TargetIsVideo && VideoCodecs.Count > 1;
+ /// Hardware encoding applies to H.264 and HEVC only (not VP9, AV1, MPEG-2, …).
+ public bool ShowHardwareEncoding => TargetIsVideo && MediaCodecs.TakesHardwareEncoding(TargetId, _videoCodecValue);
/// M4A / M4B can hold Apple Lossless besides AAC.
public bool ShowAudioCodec => AudioCodecs.Count > 1;
public bool ShowMediaQuality => TargetIsLossyMedia;
@@ -413,6 +467,7 @@ public bool Apply()
media.WavFormat = WavFormat.Value;
media.VideoCodec = MediaCodecs.Effective(TargetId, _videoCodecValue);
media.AudioCodec = MediaCodecs.Effective(TargetId, _audioCodecValue);
+ media.HardwareEncoding = HardwareEncoding;
_original.Archive.Level = ArchiveLevel.Value;
_original.Archive.CombineIntoOne = CombineIntoOne;
_original.Trace.Mode = TraceMode.Value;
diff --git a/src/Filee.App/ViewModels/RenameToolViewModel.cs b/src/Filee.App/ViewModels/RenameToolViewModel.cs
index c26b5d327..adc373aa5 100644
--- a/src/Filee.App/ViewModels/RenameToolViewModel.cs
+++ b/src/Filee.App/ViewModels/RenameToolViewModel.cs
@@ -187,9 +187,11 @@ public RenameStepViewModel(RenameStep step, RenameToolViewModel tool)
_ => "watch.ph.replace",
});
- partial void OnKindChanged(Choice value)
+ partial void OnKindChanged(Choice? oldValue, Choice newValue)
{
- Step.Kind = value.Value;
+ if (Choices.Restore(oldValue, newValue, v => Kind = v))
+ return;
+ Step.Kind = newValue.Value;
OnPropertyChanged(nameof(ShowFind));
OnPropertyChanged(nameof(ShowReplace));
OnPropertyChanged(nameof(ShowArrow));
diff --git a/src/Filee.App/Views/ElevationNoticeWindow.axaml b/src/Filee.App/Views/ElevationNoticeWindow.axaml
new file mode 100644
index 000000000..6eaa795af
--- /dev/null
+++ b/src/Filee.App/Views/ElevationNoticeWindow.axaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/ElevationNoticeWindow.axaml.cs b/src/Filee.App/Views/ElevationNoticeWindow.axaml.cs
new file mode 100644
index 000000000..76dd5cd45
--- /dev/null
+++ b/src/Filee.App/Views/ElevationNoticeWindow.axaml.cs
@@ -0,0 +1,51 @@
+// Bottom-right card saying Filee runs as administrator, with the restart that fixes it (ElevationService).
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Filee.App.Services;
+
+namespace Filee.App.Views;
+
+/// One-time notice for an elevated Filee. "Restart" starts it again through Explorer and quits this one.
+public partial class ElevationNoticeWindow : Window
+{
+ private const int ScreenMargin = 12;
+ private readonly ElevationService? _elevation;
+
+ /// For the XAML previewer.
+ public ElevationNoticeWindow() : this(null)
+ {
+ }
+
+ public ElevationNoticeWindow(ElevationService? elevation)
+ {
+ _elevation = elevation;
+ InitializeComponent();
+ Motion.Track(this);
+ SizeChanged += (_, _) => PlaceBottomRight();
+ }
+
+ private void OnRestart(object? sender, RoutedEventArgs e)
+ {
+ if (_elevation is null)
+ return;
+ if (_elevation.RestartWithoutAdministratorRights())
+ Close();
+ else
+ FailedText.IsVisible = true;
+ }
+
+ private void OnClose(object? sender, RoutedEventArgs e) => Close();
+
+ private void PlaceBottomRight()
+ {
+ var screen = Screens.Primary;
+ if (screen is null)
+ return;
+ var area = screen.WorkingArea;
+ var width = (int)Math.Ceiling(Bounds.Width * screen.Scaling);
+ var height = (int)Math.Ceiling(Bounds.Height * screen.Scaling);
+ Position = new PixelPoint(area.Right - width - ScreenMargin, area.Bottom - height - ScreenMargin);
+ }
+}
diff --git a/src/Filee.App/Views/Pages/HomePage.axaml b/src/Filee.App/Views/Pages/HomePage.axaml
index 4eab3558f..65434a922 100644
--- a/src/Filee.App/Views/Pages/HomePage.axaml
+++ b/src/Filee.App/Views/Pages/HomePage.axaml
@@ -39,6 +39,21 @@
+
+
+
+
+
+
+
+
+
+
+
+ /// The first drag over the drop zone of this run is logged: a log without it after "drops don't work" means the
+ /// drag never reached Filee (Windows refuses drops from Explorer into a Filee running as administrator).
+ ///
+ private static int _dragEnterLogged;
+
public HomePage()
{
InitializeComponent();
DragDrop.AddDragEnterHandler(DropZone, (_, e) =>
{
DropZone.Classes.Add("over");
- e.DragEffects = e.DataTransfer.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None;
+ var files = e.DataTransfer.Contains(DataFormat.File);
+ e.DragEffects = files ? DragDropEffects.Copy : DragDropEffects.None;
+ if (Interlocked.Exchange(ref _dragEnterLogged, 1) == 0)
+ AppHost.Get>().LogInformation("First drag over the drop zone (files: {Files})", files);
});
DragDrop.AddDragOverHandler(DropZone, (_, e) =>
e.DragEffects = e.DataTransfer.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None);
diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml
index 01686024e..27d533489 100644
--- a/src/Filee.App/Views/PresetEditorView.axaml
+++ b/src/Filee.App/Views/PresetEditorView.axaml
@@ -163,6 +163,10 @@
+
+
+
+
diff --git a/src/Filee.App/Views/RadialWindow.axaml.cs b/src/Filee.App/Views/RadialWindow.axaml.cs
index cf7b04072..0cfaab790 100644
--- a/src/Filee.App/Views/RadialWindow.axaml.cs
+++ b/src/Filee.App/Views/RadialWindow.axaml.cs
@@ -24,6 +24,9 @@ public RadialWindow()
public DonutMenu DonutControl => Donut;
+ /// A drag entered the window, with or without files (for the log: did the drag reach the donut at all).
+ public event Action? DragEntered;
+
/// Files entered the window during a drag.
public event Action>? FilesEntered;
@@ -44,6 +47,7 @@ public RadialWindow()
private void OnDragEnter(object? sender, DragEventArgs e)
{
+ DragEntered?.Invoke();
var files = LocalFiles(e);
if (files.Count == 0)
{
diff --git a/src/Filee.Core/Conversion/ConverterCatalog.cs b/src/Filee.Core/Conversion/ConverterCatalog.cs
index 584765fc2..886923e14 100644
--- a/src/Filee.Core/Conversion/ConverterCatalog.cs
+++ b/src/Filee.Core/Conversion/ConverterCatalog.cs
@@ -6,7 +6,9 @@ namespace Filee.Core.Conversion;
public sealed class ConverterCatalog
{
private readonly IReadOnlyList _all;
- private Dictionary? _statusCache;
+ private volatile Dictionary? _statusCache;
+ private int _generation;
+ private readonly Lock _statusLock = new();
private IReadOnlyList _priority = [];
public ConverterCatalog(IEnumerable converters) => _all = converters.ToList();
@@ -36,19 +38,48 @@ public IReadOnlyList Priority
_all.OrderBy(c => PriorityIndex(c)).ThenBy(c => _all.ToList().IndexOf(c)).ToList();
/// Cached availability of a converter.
- public EngineStatus StatusOf(IConverter converter)
- {
- _statusCache ??= _all.ToDictionary(c => c.Id, SafeStatus);
- return _statusCache[converter.Id];
- }
+ public EngineStatus StatusOf(IConverter converter) => Statuses()[converter.Id];
/// Re-checks every engine (e.g. after the user changed an engine path).
public void Refresh()
{
+ Interlocked.Increment(ref _generation);
_statusCache = null;
Changed?.Invoke(this, EventArgs.Empty);
}
+ ///
+ /// Checks every engine on a background thread, so the first conversion menu doesn't wait for them (some start a
+ /// program to read its version). A caller that needs a status meanwhile waits for this check instead of starting
+ /// its own.
+ ///
+ public Task CheckInBackgroundAsync() => Task.Run(() => Statuses());
+
+ ///
+ /// The statuses, checked once and shared between threads. A during a check makes it check
+ /// again, so a result from before a settings change is never kept.
+ ///
+ private Dictionary Statuses()
+ {
+ if (_statusCache is { } cached)
+ return cached;
+ lock (_statusLock)
+ {
+ while (true)
+ {
+ if (_statusCache is { } done)
+ return done;
+ var generation = Volatile.Read(ref _generation);
+ var statuses = _all.ToDictionary(c => c.Id, SafeStatus);
+ if (generation == Volatile.Read(ref _generation))
+ {
+ _statusCache = statuses;
+ return statuses;
+ }
+ }
+ }
+ }
+
/// Creates a planner over the currently available converters.
public RoutePlanner CreatePlanner() =>
new(_all.Where(c => StatusOf(c).IsAvailable), c => Math.Min(PriorityIndex(c), 9));
diff --git a/src/Filee.Core/Platform/ElevationState.cs b/src/Filee.Core/Platform/ElevationState.cs
new file mode 100644
index 000000000..26b6a94bb
--- /dev/null
+++ b/src/Filee.Core/Platform/ElevationState.cs
@@ -0,0 +1,57 @@
+// Whether Filee runs with more rights than the desktop shell. Windows keeps a lower process (Explorer) from sending
+// input to a higher one (User Interface Privilege Isolation): drops from Explorer are refused, the second Filee its
+// menu starts can't open the running Filee's mutex or pipe, and the running Filee can't read Explorer's selection.
+// The decision is plain logic over integrity levels, so it is tested everywhere; reading the levels is Windows code.
+
+namespace Filee.Core.Platform;
+
+/// Windows mandatory integrity levels, lowest first.
+public enum IntegrityLevel
+{
+ /// Not known: another operating system, or the token could not be read.
+ Unknown,
+ Untrusted,
+ Low,
+ Medium,
+ MediumPlus,
+ High,
+ System,
+}
+
+/// Integrity levels of this process and of the desktop shell (Explorer).
+/// This process.
+/// The process that owns the desktop window (Explorer), or Unknown when there is none.
+/// True when this process runs with a full administrator token (UAC "Run as administrator").
+public sealed record ElevationState(IntegrityLevel Process, IntegrityLevel Shell, bool Elevated)
+{
+ /// Nothing known (any OS but Windows).
+ public static ElevationState Unknown { get; } = new(IntegrityLevel.Unknown, IntegrityLevel.Unknown, false);
+
+ ///
+ /// True when Filee runs as administrator while Explorer doesn't: Explorer can't drop files on Filee or hand files
+ /// over through its menu. Unknown levels never count, and neither does a PC where Explorer runs elevated too
+ /// (UAC switched off).
+ ///
+ public bool ShellCannotReachFilee =>
+ Process >= IntegrityLevel.High && Shell != IntegrityLevel.Unknown && Shell < Process;
+
+ ///
+ /// Maps the last sub-authority of a mandatory label SID (S-1-16-rid) to a level. Values between the
+ /// well-known ones belong to the level below them, as Windows compares them numerically.
+ ///
+ public static IntegrityLevel FromRid(uint rid) => rid switch
+ {
+ >= 0x4000 => IntegrityLevel.System,
+ >= 0x3000 => IntegrityLevel.High,
+ >= 0x2100 => IntegrityLevel.MediumPlus,
+ >= 0x2000 => IntegrityLevel.Medium,
+ >= 0x1000 => IntegrityLevel.Low,
+ _ => IntegrityLevel.Untrusted,
+ };
+
+ /// For the log: "integrity High (elevated), Explorer Medium".
+ public override string ToString() =>
+ Process == IntegrityLevel.Unknown && Shell == IntegrityLevel.Unknown && !Elevated
+ ? "integrity unknown"
+ : $"integrity {Process}{(Elevated ? " (elevated)" : "")}, Explorer {Shell}";
+}
diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs
index 7a440a48a..5a1b0d9fc 100644
--- a/src/Filee.Core/Platform/IPlatformServices.cs
+++ b/src/Filee.Core/Platform/IPlatformServices.cs
@@ -31,6 +31,12 @@ static IReadOnlyList AppNames(IEnumerable names)
/// Files currently selected in the foreground file manager window (empty if none).
IReadOnlyList GetFileManagerSelection();
+ ///
+ /// For the log when a shortcut found no selected files: the foreground window's class and process
+ /// ("CabinetWClass / explorer"). Empty where the platform can't tell.
+ ///
+ string DescribeForegroundWindow() => "";
+
/// System drag threshold in the same units as screen coordinates.
int SystemDragThreshold { get; }
diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs
index 725509487..adc9a2f28 100644
--- a/src/Filee.Core/Presets/Preset.cs
+++ b/src/Filee.Core/Presets/Preset.cs
@@ -367,6 +367,14 @@ public sealed class MediaOptions
/// Audio codec for targets that can hold several ().
public AudioCodec AudioCodec { get; set; } = AudioCodec.Default;
+ ///
+ /// Encode H.264 and HEVC video with the graphics chip's encoder (NVENC, Quick Sync, AMF, VideoToolbox or VA-API)
+ /// when one works on this computer, else with x264 / x265 as usual. Much faster and lighter on power, a little
+ /// larger at the same quality. Off by default (and for presets saved before it existed), so results never change
+ /// without the user asking for it.
+ ///
+ public bool HardwareEncoding { get; set; }
+
public MediaOptions Clone() => (MediaOptions)MemberwiseClone();
}
@@ -413,6 +421,20 @@ public static class MediaCodecs
public static VideoCodec Effective(string target, VideoCodec codec) =>
VideoCodecsFor(target).Contains(codec) ? codec : VideoCodec.Default;
+ /// Video targets whose usual codec is H.264 (the others have their container's own: VP9, MPEG-2, …).
+ public static IReadOnlySet H264Targets { get; } = new HashSet { "mp4", "m4v", "mov", "mkv", "flv", "ts", "m2ts", "3gp", "3g2" };
+
+ ///
+ /// True when with is written as H.264 or HEVC, the codecs
+ /// applies to.
+ ///
+ public static bool TakesHardwareEncoding(string target, VideoCodec codec) => Effective(target, codec) switch
+ {
+ VideoCodec.Hevc => true,
+ VideoCodec.Default => H264Targets.Contains(target),
+ _ => false,
+ };
+
/// The codec is written with: the chosen one if the target takes it, else Default.
public static AudioCodec Effective(string target, AudioCodec codec) =>
AudioCodecsFor(target).Contains(codec) ? codec : AudioCodec.Default;
diff --git a/src/Filee.Engines/EngineRegistry.cs b/src/Filee.Engines/EngineRegistry.cs
index 0a2e3a42e..612351ed0 100644
--- a/src/Filee.Engines/EngineRegistry.cs
+++ b/src/Filee.Engines/EngineRegistry.cs
@@ -19,6 +19,7 @@
using Filee.Engines.Subtitles;
using Filee.Engines.Text;
using Filee.Engines.Vector;
+using Microsoft.Extensions.Logging;
namespace Filee.Engines;
@@ -30,7 +31,8 @@ public static class EngineRegistry
/// AppSettings.EnginePriority: built-in engines first, the optional downloads last. Software installed on the
/// system (Microsoft Office, a system-wide LibreOffice) is never used.
///
- public static IReadOnlyList CreateAll(EngineEnvironment env) =>
+ /// The app's log; FFmpeg writes which hardware encoders work and when one failed.
+ public static IReadOnlyList CreateAll(EngineEnvironment env, ILoggerFactory? loggers = null) =>
[
new MagickImageConverter(),
new PdfSharpConverter(),
@@ -50,7 +52,7 @@ public static IReadOnlyList CreateAll(EngineEnvironment env) =>
new EmlConverter(),
new DataConverter(),
new SubtitleConverter(),
- new FfmpegConverter(),
+ new FfmpegConverter(loggers?.CreateLogger()),
new CadConverter(),
new ModelConverter(),
new EbookConverter(),
diff --git a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
index 77db4ef38..4e28b23f5 100644
--- a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
+++ b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
@@ -142,10 +142,48 @@ public static bool UsesFileesCopy(string id) => OwnCopyFolder(id) is null
SystemCopyFolder(id) is { } folder ? Path.Combine(folder, ProgramName(program)) : null;
/// Searches the PC again (Settings → Engines → Check again, settings changes).
- public static void ForgetSystemCopies() => SystemCopies.Clear();
+ public static void ForgetSystemCopies()
+ {
+ SystemCopies.Clear();
+ ReachableShares.Clear();
+ }
private static string? FindOnSystem(string id) =>
- SystemFolders(id).Select(folder => FolderWithPrograms(id, folder)).FirstOrDefault(found => found is not null);
+ SystemFolders(id).Where(IsReachable).Select(folder => FolderWithPrograms(id, folder)).FirstOrDefault(found => found is not null);
+
+ /// How long a network share on the PATH may take to answer before the search leaves it out.
+ private static readonly TimeSpan ShareTimeout = TimeSpan.FromSeconds(2);
+
+ private static readonly ConcurrentDictionary ReachableShares = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// False for a folder on a network share that doesn't answer in time. A PATH entry on a server that is offline
+ /// (a mapped work share at home) made every file check wait for the network timeout, many seconds each, and the
+ /// search runs for every engine. Each share is asked once, until .
+ ///
+ private static bool IsReachable(string folder)
+ {
+ if (ShareOf(folder) is not { } share)
+ return true;
+ return ReachableShares.GetOrAdd(share, s =>
+ {
+ var probe = Task.Run(() => Directory.Exists(s));
+ return probe.Wait(ShareTimeout) && probe.Result;
+ });
+ }
+
+ ///
+ /// The share root of a UNC path (\\server\share), or null for a local path. Device paths (\\?\,
+ /// \\.\) are local. Plain string work, the same on every OS.
+ ///
+ internal static string? ShareOf(string path)
+ {
+ if (!path.StartsWith(@"\\", StringComparison.Ordinal) || path.StartsWith(@"\\?\", StringComparison.Ordinal)
+ || path.StartsWith(@"\\.\", StringComparison.Ordinal))
+ return null;
+ var parts = path[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries);
+ return parts.Length >= 2 ? $@"\\{parts[0]}\{parts[1]}" : parts.Length == 1 ? $@"\\{parts[0]}" : null;
+ }
private static IEnumerable SystemFolders(string id)
{
diff --git a/src/Filee.Engines/Infrastructure/engines.json b/src/Filee.Engines/Infrastructure/engines.json
index b26578051..a9bb55111 100644
--- a/src/Filee.Engines/Infrastructure/engines.json
+++ b/src/Filee.Engines/Infrastructure/engines.json
@@ -105,7 +105,7 @@
},
"calibre": {
"version": "9.15.0",
- "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0-x86_64.txz",
+ "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0-x86_64.txz",
"sha256": "3F5301C0AA51E5FB2D5F6DCD04024BA4E86501AB328CE5D9D6760EFCCB887990",
"size": 194998300,
"kind": "tar.xz"
@@ -144,7 +144,7 @@
},
"calibre": {
"version": "9.15.0",
- "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0-arm64.txz",
+ "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0-arm64.txz",
"sha256": "15AB887F9D787809BC834CA0EA6408E981B409F0B23046C41C18DE948EB50A38",
"size": 182488416,
"kind": "tar.xz"
@@ -176,7 +176,7 @@
},
"calibre": {
"version": "9.15.0",
- "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0.dmg",
+ "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0.dmg",
"sha256": "1B3A7451175B73AE2BAA28FE1BC1C53EE34D65B489881E7EC02C20F9E0FF6019",
"size": 350293492,
"kind": "dmg"
@@ -215,7 +215,7 @@
},
"calibre": {
"version": "9.15.0",
- "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0.dmg",
+ "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0.dmg",
"sha256": "1B3A7451175B73AE2BAA28FE1BC1C53EE34D65B489881E7EC02C20F9E0FF6019",
"size": 350293492,
"kind": "dmg"
diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs
index 5bc2877a7..787de120c 100644
--- a/src/Filee.Engines/Media/FfmpegConverter.cs
+++ b/src/Filee.Engines/Media/FfmpegConverter.cs
@@ -2,26 +2,40 @@
// engines/ffmpeg): video ↔ video (H.264, HEVC, AV1, …), video → animated GIF / APNG or a still frame, audio
// extraction, subtitle extraction (the first text track as SRT / ASS / WebVTT), audio ↔ audio and GIF, APNG or a still
// picture → video, and images → HEIC (FFmpeg's HEVC encoder, Filee's own container writer).
-// Container and codec choices live in MediaEncoding, the process handling in FfmpegRunner.
+// Container and codec choices live in MediaEncoding, the process handling in FfmpegRunner, hardware encoders in
+// HardwareVideo (options) and HardwareProbe (which one works here).
using System.Collections.Concurrent;
-using System.Diagnostics;
using System.Text.RegularExpressions;
using Filee.Core.Conversion;
using Filee.Core.Formats;
using Filee.Engines.Heic;
using Filee.Engines.Infrastructure;
using Filee.Engines.Magick;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
namespace Filee.Engines.Media;
/// Converts video and audio files with Filee's own copy of FFmpeg.
-public sealed partial class FfmpegConverter : IConverter, IImageTransformer
+/// Receives which hardware encoders work and why a hardware encode was redone in software.
+public sealed partial class FfmpegConverter(ILogger? logger = null) : IConverter, IImageTransformer
{
private static readonly ConcurrentDictionary Versions = new();
+ /// ffmpeg -version and -encoders answer at once; a copy that does not is broken or hangs.
+ internal static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(10);
+
+ private readonly ILogger _logger = logger ?? NullLogger.Instance;
+
private (string Ffmpeg, string Ffprobe)? _tools;
+ ///
+ /// Picks the hardware encoder for (ffmpeg, codec, 10-bit, encoders) instead of .
+ /// Tests put an encoder here that FFmpeg does not have, to see the software retry.
+ ///
+ internal Func?, CancellationToken, Task>? SelectHardware { get; init; }
+
public string Id => "ffmpeg";
public string DisplayName => "FFmpeg";
@@ -107,21 +121,28 @@ public async Task> ConvertAsync(ConversionStep step, IProg
? await CoverArt.PrepareAsync(ffmpeg, input, coverStream, step.To, step.WorkDirectory, cancellationToken)
: null;
+ var media = step.Preset.Media;
+ var encoders = still ? null : EncodersOf(ffmpeg);
+ // Hardware encoding (preset option): the first GPU encoder that passes its test encode, if any.
+ HardwareEncoder? hardware = null;
+ if (!still && MediaEncoding.HardwareCodec(step.To, media, info) is { } codec)
+ {
+ var tenBit = HardwareVideo.TenBit(codec, info.Video!);
+ hardware = SelectHardware is { } select
+ ? await select(ffmpeg, codec, tenBit, encoders, cancellationToken)
+ : await HardwareProbe.SelectAsync(ffmpeg, codec, tenBit, encoders, _logger, cancellationToken);
+ }
+
+ IReadOnlyList Plan(HardwareEncoder? encoder) => still
+ ? MediaEncoding.PlanStill(input, output, step.To, media, info)
+ : MediaEncoding.Plan(input, output, step.To, media, info, step.WorkDirectory, cover, encoders, encoder);
// Throws for a missing stream before anything is written (an existing file of that name stays untouched).
- var passes = still
- ? MediaEncoding.PlanStill(input, output, step.To, step.Preset.Media, info)
- : MediaEncoding.Plan(input, output, step.To, step.Preset.Media, info, step.WorkDirectory, cover, EncodersOf(ffmpeg));
+ var passes = Plan(hardware);
var duration = still ? MediaEncoding.StillDuration : info.Duration;
try
{
- var done = 0.0;
- foreach (var pass in passes)
- {
- await RunPassAsync(ffmpeg, pass, duration, progress, done, cancellationToken);
- done += pass.Weight;
- }
- if (!File.Exists(output) || new FileInfo(output).Length == 0)
- throw new InvalidOperationException("FFmpeg did not write any output.");
+ await RunWithFallbackAsync(passes, hardware, Plan,
+ planned => RunPassesAsync(ffmpeg, planned, output, duration, progress, cancellationToken), output, _logger, cancellationToken);
}
catch
{
@@ -134,6 +155,49 @@ public async Task> ConvertAsync(ConversionStep step, IProg
return [output];
}
+ ///
+ /// Runs , planned with . When a hardware encode fails (a driver
+ /// error, a picture larger than the chip encodes, …) the file is encoded again as plans it
+ /// without hardware: an encoder that passed its test encode can still refuse one particular video, and the user
+ /// asked for a converted file, not for the GPU. The failure is logged, not reported. Cancellation is never retried.
+ ///
+ internal static async Task RunWithFallbackAsync(IReadOnlyList passes, HardwareEncoder? hardware,
+ Func> plan, Func, Task> run, string output, ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ if (hardware is null)
+ {
+ await run(passes);
+ return;
+ }
+ try
+ {
+ await run(passes);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException && !cancellationToken.IsCancellationRequested)
+ {
+ logger.LogWarning("{File}: hardware encoder {Encoder} failed ({Reason}); encoding it again with the software encoder",
+ output, hardware.Name, ex.Message);
+ TryDelete(output);
+ // An FFmpeg without x265 says so here: the hardware encoder was the only way to write HEVC.
+ await run(plan(null));
+ }
+ }
+
+ /// Runs every pass, then checks that FFmpeg wrote something.
+ private static async Task RunPassesAsync(string ffmpeg, IReadOnlyList passes, string output, TimeSpan? duration,
+ IProgress? progress, CancellationToken cancellationToken)
+ {
+ var done = 0.0;
+ foreach (var pass in passes)
+ {
+ await RunPassAsync(ffmpeg, pass, duration, progress, done, cancellationToken);
+ done += pass.Weight;
+ }
+ if (!File.Exists(output) || new FileInfo(output).Length == 0)
+ throw new InvalidOperationException("FFmpeg did not write any output.");
+ }
+
/// Runs one pass and maps FFmpeg's position (seconds) onto this pass' share of the progress.
private static async Task RunPassAsync(string ffmpeg, FfmpegPass pass, TimeSpan? duration, IProgress? progress, double done, CancellationToken cancellationToken)
{
@@ -165,26 +229,23 @@ internal static (string Ffmpeg, string Ffprobe)? Locate()
}
/// "9.0.2" from ffmpeg -version; cached per executable so re-checking the engines stays cheap.
- private static string VersionOf(string ffmpeg) =>
+ /// FFmpeg does not start, fails or does not answer within .
+ internal static string VersionOf(string ffmpeg, TimeSpan? timeout = null) =>
Versions.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}", _ =>
{
- using var process = Process.Start(new ProcessStartInfo(ffmpeg, "-hide_banner -version")
+ ProcessResult result;
+ try
{
- UseShellExecute = false,
- CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- }) ?? throw new InvalidOperationException("FFmpeg could not be started.");
- var firstLine = process.StandardOutput.ReadLine() ?? "";
- process.StandardOutput.ReadToEnd();
- if (!process.WaitForExit(10_000))
+ result = FfmpegRunner.RunBriefly(ffmpeg, ["-hide_banner", "-version"], timeout ?? QueryTimeout);
+ }
+ catch (TimeoutException)
{
- process.Kill(entireProcessTree: true);
throw new InvalidOperationException("FFmpeg did not start in time.");
}
- if (process.ExitCode != 0)
- throw new InvalidOperationException($"FFmpeg does not start (exit code {process.ExitCode}).");
+ if (result.ExitCode != 0)
+ throw new InvalidOperationException($"FFmpeg does not start (exit code {result.ExitCode}).");
// "ffmpeg version 9.0.2-full_build-www.gyan.dev Copyright …" → "9.0.2"
+ var firstLine = result.StandardOutput.Split('\n', 2)[0];
var match = VersionPattern().Match(firstLine);
return match.Success ? match.Groups[1].Value : firstLine.Trim();
});
@@ -198,38 +259,29 @@ private static string VersionOf(string ffmpeg) =>
/// The encoders of (ffmpeg -encoders), cached per executable: a copy the user
/// picked may lack x265 or SVT-AV1. Null (assume Filee's build) when the list can't be read.
///
- private static IReadOnlySet? EncodersOf(string ffmpeg) =>
+ internal static IReadOnlySet? EncodersOf(string ffmpeg, TimeSpan? timeout = null) =>
Encoders.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}", _ =>
{
try
{
- using var process = Process.Start(new ProcessStartInfo(ffmpeg, "-hide_banner -encoders")
- {
- UseShellExecute = false,
- CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- });
- if (process is null)
- return null;
- process.ErrorDataReceived += (_, _) => { };
- process.BeginErrorReadLine();
- var list = process.StandardOutput.ReadToEnd();
- if (!process.WaitForExit(10_000) || process.ExitCode != 0)
- return null;
- // " V....D libx265 libx265 H.265 / HEVC (codec hevc)"
- return list.Split('\n')
- .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries))
- .Where(parts => parts.Length >= 2 && parts[0].Length == 6 && parts[0][0] is 'V' or 'A' or 'S')
- .Select(parts => parts[1])
- .ToHashSet(StringComparer.Ordinal);
+ var result = FfmpegRunner.RunBriefly(ffmpeg, ["-hide_banner", "-encoders"], timeout ?? QueryTimeout);
+ return result.ExitCode == 0 ? ParseEncoders(result.StandardOutput) : null;
}
- catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or IOException)
+ catch (Exception ex) when (ex is TimeoutException or InvalidOperationException or System.ComponentModel.Win32Exception or IOException)
{
return null;
}
});
+ /// The encoder names in ffmpeg -encoders output.
+ internal static IReadOnlySet ParseEncoders(string list) =>
+ // " V....D libx265 libx265 H.265 / HEVC (codec hevc)"
+ list.Split('\n')
+ .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ .Where(parts => parts.Length >= 2 && parts[0].Length == 6 && parts[0][0] is 'V' or 'A' or 'S')
+ .Select(parts => parts[1])
+ .ToHashSet(StringComparer.Ordinal);
+
private static void TryDelete(string path)
{
try
diff --git a/src/Filee.Engines/Media/FfmpegRunner.cs b/src/Filee.Engines/Media/FfmpegRunner.cs
index b4cb60b44..5c3426c36 100644
--- a/src/Filee.Engines/Media/FfmpegRunner.cs
+++ b/src/Filee.Engines/Media/FfmpegRunner.cs
@@ -108,6 +108,48 @@ public static async Task RunAsync(
return new ProcessResult(process.ExitCode, "", stderr);
}
+ ///
+ /// Runs a short query (ffmpeg -version, -encoders) synchronously. Both streams are read in the
+ /// background, so the time limit holds even when the program hangs with its output open (reading it to the end
+ /// first would wait for the hang).
+ ///
+ /// The program did not exit within ; it was killed.
+ /// The program could not be started.
+ internal static ProcessResult RunBriefly(string executable, IReadOnlyList arguments, TimeSpan timeout)
+ {
+ var psi = new ProcessStartInfo(executable)
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+ foreach (var argument in arguments)
+ psi.ArgumentList.Add(argument);
+
+ using var process = new Process { StartInfo = psi };
+ var stdout = new StringBuilder();
+ var stderr = new StringBuilder();
+ process.OutputDataReceived += (_, e) => { if (e.Data is not null) lock (stdout) stdout.Append(e.Data).Append('\n'); };
+ process.ErrorDataReceived += (_, e) => { if (e.Data is not null) lock (stderr) stderr.Append(e.Data).Append('\n'); };
+ if (!process.Start())
+ throw new InvalidOperationException($"Could not start {executable}.");
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ if (!process.WaitForExit(timeout))
+ {
+ Kill(process);
+ throw new TimeoutException($"{Path.GetFileName(executable)} did not finish within {timeout.TotalSeconds:0} s.");
+ }
+ // Make sure the asynchronous readers have delivered the last lines.
+ process.WaitForExit();
+ lock (stdout)
+ lock (stderr)
+ return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString());
+ }
+
///
/// The position in seconds from one line of -progress output (out_time_us=2000000) or of the
/// palette pass' frame stats (frame_time=2.5); null for every other line.
diff --git a/src/Filee.Engines/Media/HardwareProbe.cs b/src/Filee.Engines/Media/HardwareProbe.cs
new file mode 100644
index 000000000..eaaff7597
--- /dev/null
+++ b/src/Filee.Engines/Media/HardwareProbe.cs
@@ -0,0 +1,68 @@
+// Finds out which hardware video encoder works on this computer. FFmpeg lists every encoder it was built with, also
+// NVENC without an NVIDIA card or Quick Sync without an Intel GPU, so each candidate gets a tiny test encode first. The
+// result is kept per FFmpeg executable, encoder and bit depth for as long as Filee runs.
+
+using System.Collections.Concurrent;
+using Filee.Engines.Infrastructure;
+using Microsoft.Extensions.Logging;
+
+namespace Filee.Engines.Media;
+
+/// Picks the first hardware encoder whose test encode succeeds.
+internal static class HardwareProbe
+{
+ ///
+ /// A test encode takes 0.1–2 s (Quick Sync loads its runtime on the first one); a driver that hangs is given up on
+ /// after this long and the software encoder is used.
+ ///
+ public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
+
+ private static readonly ConcurrentDictionary>> Results = new();
+
+ ///
+ /// The hardware encoder to write ("h264" or "hevc") with, or null when none works here
+ /// (then x264 / x265 encode as usual).
+ ///
+ /// The video is encoded in 10 bits (HEVC Main 10): an encoder must handle that too.
+ /// The encoders lists; null when the list could not be read.
+ public static async Task SelectAsync(string ffmpeg, string codec, bool tenBit, IReadOnlySet? encoders,
+ ILogger logger, CancellationToken cancellationToken)
+ {
+ var vaapi = HardwareVideo.Current == HostPlatform.Linux && File.Exists(HardwareVideo.VaapiDevice);
+ foreach (var candidate in HardwareVideo.Candidates(codec, HardwareVideo.Current, encoders, vaapi))
+ {
+ // The probe itself is not cancelled with the conversion: other conversions may be waiting for its result.
+ if (await WorksAsync(ffmpeg, candidate, tenBit, logger).WaitAsync(cancellationToken))
+ return candidate;
+ }
+ return null;
+ }
+
+ /// The cached result of the test encode of (run on first use).
+ internal static Task WorksAsync(string ffmpeg, HardwareEncoder encoder, bool tenBit, ILogger logger) =>
+ Results.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}|{encoder.Name}|{encoder.RateControl}|{(tenBit ? 10 : 8)}",
+ _ => new Lazy>(() => ProbeAsync(ffmpeg, encoder, tenBit, logger))).Value;
+
+ private static async Task ProbeAsync(string ffmpeg, HardwareEncoder encoder, bool tenBit, ILogger logger)
+ {
+ var bits = tenBit ? 10 : 8;
+ try
+ {
+ var result = await ProcessRunner.RunAsync(ffmpeg, HardwareVideo.ProbeArguments(encoder, tenBit), Timeout, CancellationToken.None);
+ if (result.ExitCode == 0)
+ {
+ logger.LogInformation("Hardware encoder {Encoder} ({RateControl}, {Bits}-bit) works", encoder.Name, encoder.RateControl, bits);
+ return true;
+ }
+ logger.LogInformation("Hardware encoder {Encoder} ({RateControl}, {Bits}-bit) is not usable: {Reason}", encoder.Name,
+ encoder.RateControl, bits, FfmpegRunner.Summarize(result.StandardError));
+ return false;
+ }
+ catch (Exception ex) when (ex is TimeoutException or InvalidOperationException or System.ComponentModel.Win32Exception or IOException)
+ {
+ logger.LogInformation("Hardware encoder {Encoder} ({RateControl}, {Bits}-bit) is not usable: {Reason}", encoder.Name,
+ encoder.RateControl, bits, ex.Message);
+ return false;
+ }
+ }
+}
diff --git a/src/Filee.Engines/Media/HardwareVideo.cs b/src/Filee.Engines/Media/HardwareVideo.cs
new file mode 100644
index 000000000..8253460ce
--- /dev/null
+++ b/src/Filee.Engines/Media/HardwareVideo.cs
@@ -0,0 +1,229 @@
+// Hardware video encoders (NVENC, Quick Sync, AMF, VideoToolbox, VA-API) for H.264 and HEVC: which ones to try on
+// which system, and their FFmpeg options. Pure (no processes), so every choice is unit tested without a GPU; whether
+// an encoder actually works on this computer is found out by HardwareProbe.
+
+using System.Globalization;
+using Filee.Core.Presets;
+
+namespace Filee.Engines.Media;
+
+/// The vendor interface a hardware encoder goes through; it decides the encoder's options.
+internal enum HardwareApi
+{
+ /// NVIDIA (h264_nvenc, hevc_nvenc).
+ Nvenc,
+
+ /// Intel Quick Sync Video (h264_qsv, hevc_qsv).
+ Qsv,
+
+ /// AMD Advanced Media Framework (h264_amf, hevc_amf).
+ Amf,
+
+ /// Apple VideoToolbox (h264_videotoolbox, hevc_videotoolbox).
+ VideoToolbox,
+
+ /// Linux VA-API (h264_vaapi, hevc_vaapi): frames are uploaded to the GPU first.
+ Vaapi,
+}
+
+/// How the preset's quality reaches a hardware encoder.
+internal enum HardwareRateControl
+{
+ /// A constant quality or quantizer, like x264's CRF.
+ Quality,
+
+ ///
+ /// An average bitrate from the picture size and frame rate: VideoToolbox on Intel Macs has no constant quality mode
+ /// (FFmpeg rejects -q:v there).
+ ///
+ Bitrate,
+}
+
+/// The operating systems hardware encoders are chosen for.
+internal enum HostPlatform
+{
+ Windows,
+ MacOS,
+ Linux,
+ Other,
+}
+
+/// One hardware encoder of FFmpeg, with the way it is told the quality.
+/// FFmpeg encoder name, e.g. hevc_nvenc.
+internal sealed record HardwareEncoder(string Name, HardwareApi Api, HardwareRateControl RateControl = HardwareRateControl.Quality)
+{
+ /// "h264" or "hevc": the part before the underscore.
+ public string Codec => Name[..Name.IndexOf('_', StringComparison.Ordinal)];
+}
+
+/// Hardware encoder candidates and command-line options.
+internal static class HardwareVideo
+{
+ ///
+ /// The first DRM render node: the integrated or only GPU on almost every Linux system. A second GPU has
+ /// renderD129, but NVIDIA's (the usual second one) is taken through NVENC, which is tried first.
+ ///
+ public const string VaapiDevice = "/dev/dri/renderD128";
+
+ /// The system Filee runs on.
+ public static HostPlatform Current { get; } =
+ OperatingSystem.IsWindows() ? HostPlatform.Windows
+ : OperatingSystem.IsMacOS() ? HostPlatform.MacOS
+ : OperatingSystem.IsLinux() ? HostPlatform.Linux
+ : HostPlatform.Other;
+
+ ///
+ /// The hardware encoders for ("h264" or "hevc") worth trying, best first. Windows:
+ /// NVIDIA, then Intel, then AMD (a laptop with an Intel chip and an NVIDIA card encodes on the card). macOS:
+ /// VideoToolbox with constant quality (Apple Silicon), then with a bitrate (Intel Macs). Linux: NVIDIA, then
+ /// VA-API when a render node exists. Only encoders the FFmpeg in use lists are tried (all of them when the list
+ /// could not be read); the probe decides which one really works.
+ ///
+ public static IReadOnlyList Candidates(string codec, HostPlatform platform, IReadOnlySet? encoders, bool vaapiDevice)
+ {
+ if (codec is not ("h264" or "hevc"))
+ return [];
+ List candidates = platform switch
+ {
+ HostPlatform.Windows => [new($"{codec}_nvenc", HardwareApi.Nvenc), new($"{codec}_qsv", HardwareApi.Qsv), new($"{codec}_amf", HardwareApi.Amf)],
+ HostPlatform.MacOS =>
+ [
+ new($"{codec}_videotoolbox", HardwareApi.VideoToolbox),
+ new($"{codec}_videotoolbox", HardwareApi.VideoToolbox, HardwareRateControl.Bitrate),
+ ],
+ HostPlatform.Linux => vaapiDevice
+ ? [new($"{codec}_nvenc", HardwareApi.Nvenc), new($"{codec}_vaapi", HardwareApi.Vaapi)]
+ : [new($"{codec}_nvenc", HardwareApi.Nvenc)],
+ _ => [],
+ };
+ return encoders is null ? candidates : [.. candidates.Where(c => encoders.Contains(c.Name))];
+ }
+
+ ///
+ /// True when the video is encoded in 10 bits: HEVC keeps 10-bit and HDR sources in Main 10 (as x265 does). H.264
+ /// is always 8-bit 4:2:0, the only format every player decodes (as with x264).
+ ///
+ public static bool TenBit(string codec, VideoStreamInfo video) => codec == "hevc" && (video.IsHighBitDepth || video.IsHdr);
+
+ ///
+ /// The preset's quality on the encoders' 0–51 quantizer scale (NVENC -cq, Quick Sync -global_quality,
+ /// AMF and VA-API -qp): one above x264's CRF 18/23/28 for H.264 and x265's 22/26/30 for HEVC. Hardware
+ /// encoders lack x264/x265's psychovisual tuning and spend bits less wisely, so the same number gives a larger
+ /// file; one step coarser keeps the size close at a slightly lower quality.
+ ///
+ public static int Quantizer(string codec, MediaQuality quality) => codec == "hevc"
+ ? Choose(quality, 23, 27, 31)
+ : Choose(quality, 19, 24, 29);
+
+ /// VideoToolbox's constant quality (-q:v, 1–100, higher is better) on Apple Silicon.
+ public static int VideoToolboxQuality(MediaQuality quality) => Choose(quality, 70, 60, 50);
+
+ ///
+ /// The average bitrate (kbit/s) for VideoToolbox on Intel Macs: bits per pixel per frame of the output picture,
+ /// e.g. 1080p at 30 fps gets about 9 / 5.6 / 3.1 Mbit/s as H.264 and two thirds of that as HEVC.
+ ///
+ public static int BitrateKbps(string codec, MediaQuality quality, VideoStreamInfo video, int maxHeight)
+ {
+ double height = video.DisplayHeight > 0 ? video.DisplayHeight : 1080;
+ if (maxHeight > 0 && height > Math.Max(16, maxHeight))
+ height = Math.Max(16, maxHeight);
+ var width = height * (video.DisplayAspect > 0 && double.IsFinite(video.DisplayAspect) ? video.DisplayAspect : 16 / 9.0);
+ var fps = video.FrameRate is > 0 and <= 240 ? video.FrameRate : 30;
+ var bitsPerPixel = Choose(quality, 0.14, 0.09, 0.05) * (codec == "hevc" ? 0.65 : 1);
+ return (int)Math.Clamp(Math.Round(width * height * fps * bitsPerPixel / 1000), 300, 100_000);
+ }
+
+ /// Global options before the input: VA-API needs its device.
+ public static string[] InputArguments(HardwareEncoder encoder) =>
+ encoder.Api == HardwareApi.Vaapi ? ["-vaapi_device", VaapiDevice] : [];
+
+ ///
+ /// Appended to the video filter chain: VA-API encodes frames in GPU memory, so the scaled frames are converted to
+ /// NV12 / P010 and uploaded. The others take frames from system memory (-pix_fmt).
+ ///
+ public static string FilterSuffix(HardwareEncoder encoder, bool tenBit) =>
+ encoder.Api == HardwareApi.Vaapi ? $",format={(tenBit ? "p010le" : "nv12")},hwupload" : "";
+
+ ///
+ /// The encoder options for one video: rate control from the preset's quality, NV12 (8-bit) or P010 with the Main 10
+ /// profile (10-bit HEVC), the source's HDR colour tags, and the hvc1 tag Apple players need for HEVC in
+ /// MP4/MOV.
+ ///
+ public static List CodecArguments(HardwareEncoder encoder, string target, MediaQuality quality, VideoStreamInfo video, int maxHeight)
+ {
+ var codec = encoder.Codec;
+ var tenBit = TenBit(codec, video);
+ var quantizer = Text(Quantizer(codec, quality));
+ List args = ["-c:v", encoder.Name];
+ switch (encoder.Api)
+ {
+ case HardwareApi.Nvenc:
+ // Constant quality inside VBR; -b:v 0 lifts NVENC's default 2 Mbit/s cap. P5 is the "slow, good quality"
+ // preset, still many times faster than x264's medium.
+ args.AddRange(["-preset", "p5", "-rc", "vbr", "-cq", quantizer, "-b:v", "0"]);
+ break;
+ case HardwareApi.Qsv:
+ // global_quality without a bitrate selects ICQ (intelligent constant quality), or CQP where the driver
+ // has no ICQ.
+ args.AddRange(["-preset", "medium", "-global_quality", quantizer]);
+ break;
+ case HardwareApi.Amf:
+ // Constant quantizer; I-frames two steps finer, as AMF's own presets do.
+ args.AddRange(["-quality", "balanced", "-rc", "cqp", "-qp_i", Text(Math.Max(0, Quantizer(codec, quality) - 2)), "-qp_p", quantizer]);
+ if (codec == "h264")
+ args.AddRange(["-qp_b", quantizer]);
+ break;
+ case HardwareApi.VideoToolbox when encoder.RateControl == HardwareRateControl.Bitrate:
+ args.AddRange(["-b:v", Text(BitrateKbps(codec, quality, video, maxHeight)) + "k"]);
+ break;
+ case HardwareApi.VideoToolbox:
+ args.AddRange(["-q:v", Text(VideoToolboxQuality(quality))]);
+ break;
+ case HardwareApi.Vaapi:
+ args.AddRange(["-rc_mode", "CQP", "-qp", quantizer]);
+ break;
+ }
+
+ if (codec == "h264")
+ args.AddRange(["-profile:v", "high"]);
+ else
+ args.AddRange(["-profile:v", tenBit ? "main10" : "main"]);
+ // VA-API's frames are already uploaded in the right format (FilterSuffix).
+ if (encoder.Api != HardwareApi.Vaapi)
+ args.AddRange(["-pix_fmt", tenBit ? "p010le" : "nv12"]);
+ if (tenBit)
+ args.AddRange(MediaEncoding.ColorArguments(video));
+ if (codec == "hevc" && target is "mp4" or "m4v" or "mov")
+ args.AddRange(["-tag:v", "hvc1"]);
+ return args;
+ }
+
+ /// The source of a probe: a tenth of a second of test pattern, three small frames.
+ private const string ProbeSource = "testsrc=size=256x144:rate=30:duration=0.1";
+
+ ///
+ /// A tiny test encode with exactly the options a real one uses (rate control, profile, pixel format), so an
+ /// encoder FFmpeg lists but the computer can't run (no GPU, old driver, no 10-bit support, no constant quality on
+ /// an Intel Mac) fails here instead of on the user's file.
+ ///
+ public static List ProbeArguments(HardwareEncoder encoder, bool tenBit)
+ {
+ var picture = new VideoStreamInfo(256, 144, 1, 0, 30, PixelFormat: tenBit ? "yuv420p10le" : "yuv420p");
+ return
+ [
+ .. MediaEncoding.Common, .. InputArguments(encoder), "-f", "lavfi", "-i", ProbeSource,
+ "-vf", "null" + FilterSuffix(encoder, tenBit),
+ .. CodecArguments(encoder, "mkv", MediaQuality.Balanced, picture, 0),
+ "-f", "null", "-",
+ ];
+ }
+
+ private static T Choose(MediaQuality quality, T high, T balanced, T small) => quality switch
+ {
+ MediaQuality.High => high,
+ MediaQuality.Small => small,
+ _ => balanced,
+ };
+
+ private static string Text(int value) => value.ToString(CultureInfo.InvariantCulture);
+}
diff --git a/src/Filee.Engines/Media/MediaEncoding.cs b/src/Filee.Engines/Media/MediaEncoding.cs
index ff791f175..44ac80de1 100644
--- a/src/Filee.Engines/Media/MediaEncoding.cs
+++ b/src/Filee.Engines/Media/MediaEncoding.cs
@@ -141,9 +141,13 @@ private static readonly (int Num, int Den)[] BroadcastRates =
/// Scratch folder (the GIF palette goes there).
/// The source's cover for targets FFmpeg can't put a picture stream into ().
/// The encoders the FFmpeg in use has (ffmpeg -encoders); null assumes the pinned build's.
+ ///
+ /// The hardware encoder that passed its probe for of this file; null encodes with x264 /
+ /// x265 (also the retry after a hardware encode failed).
+ ///
/// The file lacks the stream the target needs (e.g. no audio track).
public static IReadOnlyList Plan(string input, string output, string target, MediaOptions options, MediaInfo info, string workDirectory,
- CoverExtras? cover = null, IReadOnlySet? encoders = null)
+ CoverExtras? cover = null, IReadOnlySet? encoders = null, HardwareEncoder? hardware = null)
{
if (target == "gif")
return AnimatedGif(input, output, options, info, workDirectory);
@@ -152,7 +156,7 @@ public static IReadOnlyList Plan(string input, string output, string
if (target == "png")
return [new FfmpegPass(PosterFrame(input, output, info), 1)];
if (VideoTargets.TryGetValue(target, out var video))
- return [new FfmpegPass(Video(input, output, target, video, options, info, encoders), 1)];
+ return [new FfmpegPass(Video(input, output, target, video, options, info, encoders, hardware), 1)];
if (AudioTargets.TryGetValue(target, out var audio))
return [new FfmpegPass(Audio(input, output, target, audio, options, info, cover), 1)];
if (SubtitleTargets.ContainsKey(target))
@@ -162,8 +166,19 @@ public static IReadOnlyList Plan(string input, string output, string
// ───────────────────────── Video ─────────────────────────
+ ///
+ /// The codec a hardware encoder would have to write for this file ("h264" or "hevc"), or null when the preset does
+ /// not ask for hardware encoding, the target is written with another codec (VP9, AV1, MPEG-2, …), or the video is
+ /// copied anyway.
+ ///
+ internal static string? HardwareCodec(string target, MediaOptions options, MediaInfo info) =>
+ options.HardwareEncoding && info.Video is { } video && VideoTargets.ContainsKey(target)
+ && MediaCodecs.TakesHardwareEncoding(target, options.VideoCodec) && !CanCopyVideo(target, video, options)
+ ? MediaCodecs.Effective(target, options.VideoCodec) == Core.Presets.VideoCodec.Hevc ? "hevc" : "h264"
+ : null;
+
private static List Video(string input, string output, string target, MediaTarget spec, MediaOptions options, MediaInfo info,
- IReadOnlySet? encoders)
+ IReadOnlySet? encoders, HardwareEncoder? hardware)
{
var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
// An input without sound is fine: the output simply has no audio track.
@@ -180,6 +195,13 @@ private static List Video(string input, string output, string target, Me
if (video.Codec == "hevc" && target is "mp4" or "m4v" or "mov")
args.AddRange(["-tag:v", "hvc1"]);
}
+ else if (hardware is not null && HardwareCodec(target, options, info) == hardware.Codec)
+ {
+ // VA-API's device is a global option, so it goes before the input.
+ args.InsertRange(Common.Length, HardwareVideo.InputArguments(hardware));
+ args.AddRange(["-vf", ScaleFilter(options.MaxHeight) + HardwareVideo.FilterSuffix(hardware, HardwareVideo.TenBit(hardware.Codec, video))]);
+ args.AddRange(HardwareVideo.CodecArguments(hardware, target, options.Quality, video, options.MaxHeight));
+ }
else
{
var pal = IsPal(video.FrameRate);
@@ -367,8 +389,14 @@ .. HighBitDepthArguments(video), "-x265-params", "log-level=error",
internal static string[] HighBitDepthArguments(VideoStreamInfo video)
{
var tenBit = video.IsHighBitDepth || video.IsHdr;
- List args = ["-pix_fmt", tenBit ? "yuv420p10le" : "yuv420p"];
- if (tenBit && video.Color is { } color)
+ return ["-pix_fmt", tenBit ? "yuv420p10le" : "yuv420p", .. tenBit ? ColorArguments(video) : []];
+ }
+
+ /// The source's colour tags (primaries, transfer, matrix), for 10-bit and HDR encodes.
+ internal static List ColorArguments(VideoStreamInfo video)
+ {
+ List args = [];
+ if (video.Color is { } color)
{
if (color.Primaries is { } primaries)
args.AddRange(["-color_primaries", primaries]);
@@ -377,7 +405,7 @@ internal static string[] HighBitDepthArguments(VideoStreamInfo video)
if (color.Matrix is { } matrix)
args.AddRange(["-colorspace", matrix]);
}
- return [.. args];
+ return args;
}
/// -r for a rate, nothing when the source rate is unknown (FFmpeg then keeps its own guess).
diff --git a/src/Filee.Platform.Windows/Elevation.cs b/src/Filee.Platform.Windows/Elevation.cs
new file mode 100644
index 000000000..22f2ce53a
--- /dev/null
+++ b/src/Filee.Platform.Windows/Elevation.cs
@@ -0,0 +1,251 @@
+// Integrity levels of Filee and Explorer, and starting a program with Explorer's rights instead of Filee's.
+//
+// Filee runs as administrator when something elevated started it: an installer the user ran "as administrator" starts
+// Filee with its own token at the end, and the in-app updater inherits that. Explorer then can't drop files on Filee
+// or hand files to it (see ElevationState). Starting Filee again "as the user" goes through Explorer itself: the
+// desktop's shell view gives out its Shell.Application object, and ShellExecute on that object runs inside Explorer,
+// so the new process gets Explorer's token. This is the documented way back down (no API lowers a running process).
+
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using Filee.Core.Platform;
+
+namespace Filee.Platform.Windows;
+
+/// Elevation checks and the de-elevated restart. Every member is safe to call on any OS.
+public static class Elevation
+{
+ /// Reads the integrity levels of this process and of Explorer. off Windows.
+ public static ElevationState Probe()
+ {
+ if (!OperatingSystem.IsWindows())
+ return ElevationState.Unknown;
+ try
+ {
+ var process = LevelOfProcess(NativeMethods.GetCurrentProcess(), out var elevated);
+ return new ElevationState(process, ShellLevel(), elevated);
+ }
+ catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
+ {
+ return ElevationState.Unknown;
+ }
+ }
+
+ ///
+ /// Starts through Explorer, so it gets Explorer's rights instead of this process's.
+ /// Returns false when there is no Explorer desktop to ask (another shell, Explorer restarting) or it refused;
+ /// then says why, in English.
+ ///
+ public static bool RelaunchThroughShell(string executable, string arguments, out string? error)
+ {
+ if (OperatingSystem.IsWindows())
+ return RelaunchOnWindows(executable, arguments, out error);
+ error = "Not supported on this operating system.";
+ return false;
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static bool RelaunchOnWindows(string executable, string arguments, out string? error)
+ {
+ // Explorer's COM objects want a single-threaded apartment; a thread of its own keeps the caller's apartment
+ // out of it and bounds the wait if Explorer doesn't answer.
+ string? failure = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ ShellExecuteFromDesktop(executable, arguments);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ // Anything Explorer or the COM binder throws: report it, never take Filee down from this thread.
+ failure = $"{ex.GetType().Name}: {ex.Message}";
+ }
+ })
+ { IsBackground = true, Name = "Filee shell relaunch" };
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ if (!thread.Join(TimeSpan.FromSeconds(15)))
+ {
+ error = "Explorer did not answer.";
+ return false;
+ }
+ error = failure;
+ return failure is null;
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static void ShellExecuteFromDesktop(string executable, string arguments)
+ {
+ var shellWindowsType = Type.GetTypeFromCLSID(new Guid("9BA05972-F6A8-11CF-A442-00A0C90A8F39"), throwOnError: true)!;
+ object? shellWindows = null, desktop = null, browser = null, view = null, background = null;
+ try
+ {
+ shellWindows = Activator.CreateInstance(shellWindowsType)
+ ?? throw new InvalidOperationException("ShellWindows is not available.");
+ object location = CSIDL_DESKTOP;
+ object? empty = null; // VT_EMPTY
+ desktop = ((IShellWindows)shellWindows).FindWindowSW(ref location, ref empty, SWC_DESKTOP, out _, SWFO_NEEDDISPATCH)
+ ?? throw new InvalidOperationException("Explorer's desktop window was not found.");
+ var topLevelBrowser = SID_STopLevelBrowser;
+ var shellBrowserId = typeof(IShellBrowser).GUID;
+ browser = ((IServiceProvider)desktop).QueryService(ref topLevelBrowser, ref shellBrowserId);
+ view = ((IShellBrowser)browser).QueryActiveShellView();
+ var dispatchId = IID_IDispatch;
+ background = ((IShellView)view).GetItemObject(SVGIO_BACKGROUND, ref dispatchId);
+ // IShellFolderViewDual.Application is Explorer's IShellDispatch2: its ShellExecute runs in Explorer.
+ dynamic folderView = background;
+ dynamic application = folderView.Application;
+ try
+ {
+ application.ShellExecute(executable, arguments, Path.GetDirectoryName(executable) ?? "", "", SW_SHOWNORMAL);
+ }
+ finally
+ {
+ Marshal.FinalReleaseComObject((object)application);
+ }
+ }
+ finally
+ {
+ // Released here, on their apartment's thread, which ends right after. Two of them may be one object.
+ foreach (var item in new[] { background, view, browser, desktop, shellWindows }
+ .OfType