diff --git a/App.xaml b/App.xaml
index 63302b1..7da4a18 100644
--- a/App.xaml
+++ b/App.xaml
@@ -19,72 +19,279 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 039209b..7c56875 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -1,51 +1,43 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
+
@@ -58,7 +50,7 @@
-
+
@@ -66,82 +58,25 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
+ Foreground="{DynamicResource MutedBrush}" TextWrapping="Wrap" Margin="0,8,0,0"/>
-
@@ -149,12 +84,121 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 896e7c1..1c3f99f 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -26,6 +26,11 @@ public partial class MainWindow : Window
EndCardBox.Text = _loadedSettings.EndCardPath;
FfmpegPathBox.Text = _loadedSettings.FfmpegPath;
FfprobePathBox.Text = _loadedSettings.FfprobePath;
+ TrimBox.Text = _loadedSettings.TrimStart;
+ FadeBox.Text = _loadedSettings.FadeSeconds;
+ HoldBox.Text = _loadedSettings.EndCardHoldSeconds;
+ IntervalBox.Text = _loadedSettings.ThumbnailInterval;
+ SelectEncoder(_loadedSettings.Encoder);
SelectTheme(_loadedSettings.Theme);
ApplyTheme(_loadedSettings.Theme);
Closing += (_, _) => SaveSettings();
@@ -65,6 +70,14 @@ public partial class MainWindow : Window
if (dialog.ShowDialog(this) == true) FfprobePathBox.Text = dialog.FileName;
}
+ private void OpenSettings_Click(object sender, RoutedEventArgs e) => SettingsOverlay.Visibility = Visibility.Visible;
+
+ private void CloseSettings_Click(object sender, RoutedEventArgs e)
+ {
+ SettingsOverlay.Visibility = Visibility.Collapsed;
+ SaveSettings();
+ }
+
private async void Render_Click(object sender, RoutedEventArgs e)
{
try
@@ -174,10 +187,16 @@ public partial class MainWindow : Window
EndCardBox.Text.Trim(),
FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(),
- SelectedTheme()));
+ SelectedTheme(),
+ SelectedEncoder(),
+ TrimBox.Text.Trim(),
+ FadeBox.Text.Trim(),
+ HoldBox.Text.Trim(),
+ IntervalBox.Text.Trim()));
}
private string SelectedTheme() => ((ComboBoxItem)ThemeBox.SelectedItem).Tag!.ToString()!;
+ private string SelectedEncoder() => ((ComboBoxItem)EncoderBox.SelectedItem).Tag!.ToString()!;
private void SelectTheme(string theme)
{
@@ -193,6 +212,20 @@ public partial class MainWindow : Window
ThemeBox.SelectedIndex = 0;
}
+ private void SelectEncoder(string encoder)
+ {
+ foreach (ComboBoxItem item in EncoderBox.Items)
+ {
+ if (string.Equals(item.Tag?.ToString(), encoder, StringComparison.OrdinalIgnoreCase))
+ {
+ EncoderBox.SelectedItem = item;
+ return;
+ }
+ }
+
+ EncoderBox.SelectedIndex = 0;
+ }
+
private void ApplyTheme(string theme)
{
bool light = string.Equals(theme, "Light", StringComparison.OrdinalIgnoreCase);
@@ -217,7 +250,6 @@ public partial class MainWindow : Window
private void SetBrush(string key, string color)
{
- if (Application.Current.Resources[key] is SolidColorBrush brush)
- brush.Color = (Color)ColorConverter.ConvertFromString(color);
+ Application.Current.Resources[key] = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color));
}
}
diff --git a/Models/AppSettings.cs b/Models/AppSettings.cs
index 4d529ce..f123818 100644
--- a/Models/AppSettings.cs
+++ b/Models/AppSettings.cs
@@ -5,7 +5,34 @@ public sealed record AppSettings(
string EndCardPath,
string FfmpegPath,
string FfprobePath,
- string Theme)
+ string Theme,
+ string Encoder,
+ string TrimStart,
+ string FadeSeconds,
+ string EndCardHoldSeconds,
+ string ThumbnailInterval)
{
- public static AppSettings Default(string outputFolder) => new(outputFolder, "", "", "", "Dark");
+ public static AppSettings Default(string outputFolder) => new(
+ outputFolder,
+ "",
+ "",
+ "",
+ "Dark",
+ "Auto",
+ "4.414",
+ "3",
+ "4",
+ "10");
+
+ public AppSettings Normalize(string fallbackOutputFolder) => new(
+ string.IsNullOrWhiteSpace(OutputFolder) ? fallbackOutputFolder : OutputFolder,
+ EndCardPath ?? "",
+ FfmpegPath ?? "",
+ FfprobePath ?? "",
+ string.IsNullOrWhiteSpace(Theme) ? "Dark" : Theme,
+ string.IsNullOrWhiteSpace(Encoder) ? "Auto" : Encoder,
+ string.IsNullOrWhiteSpace(TrimStart) ? "4.414" : TrimStart,
+ string.IsNullOrWhiteSpace(FadeSeconds) ? "3" : FadeSeconds,
+ string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds,
+ string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval);
}
diff --git a/README.md b/README.md
index 5f430e6..7c72282 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ Windows WPF replacement for `amigadb-render.sh`. The first milestone implements:
- PNG/JPG screenshots at a configurable interval;
- animated WebP screenshot preview;
- optional custom paths for `ffmpeg.exe` and `ffprobe.exe`;
+- automatic FFmpeg library probing when shared `avformat`/`avutil` DLLs are present beside `ffmpeg.exe`;
- non-fatal missing thumbnails;
- live FFmpeg log, progress and cancellation;
- one-file Windows publishing with embedded FFmpeg and FFprobe.
@@ -46,3 +47,7 @@ licensing notices.
If you prefer not to use the embedded binaries at runtime, you can point the UI
at external `ffmpeg.exe` and `ffprobe.exe` files. Those paths are saved in the
user settings file under `%LOCALAPPDATA%\AmiReel\settings.json`.
+
+If the selected FFmpeg folder also contains shared FFmpeg DLLs such as
+`avformat-*.dll` and `avutil-*.dll`, AmiReel will use those libraries for media
+probing in place of `ffprobe.exe` while keeping rendering on `ffmpeg.exe`.
diff --git a/Services/AppSettingsStore.cs b/Services/AppSettingsStore.cs
index 5bb9d5e..cc185bb 100644
--- a/Services/AppSettingsStore.cs
+++ b/Services/AppSettingsStore.cs
@@ -7,22 +7,24 @@ namespace AmigaDB.VideoRenderer.Services;
public static class AppSettingsStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+ private static string DefaultOutputFolder => Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
public static AppSettings Load()
{
string path = GetSettingsPath();
if (!File.Exists(path))
- return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
+ return AppSettings.Default(DefaultOutputFolder);
try
{
string json = File.ReadAllText(path);
- return JsonSerializer.Deserialize(json, JsonOptions)
- ?? AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
+ return (JsonSerializer.Deserialize(json, JsonOptions)
+ ?? AppSettings.Default(DefaultOutputFolder))
+ .Normalize(DefaultOutputFolder);
}
catch
{
- return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
+ return AppSettings.Default(DefaultOutputFolder);
}
}
diff --git a/Services/FfmpegLibraryProbe.cs b/Services/FfmpegLibraryProbe.cs
new file mode 100644
index 0000000..86190f6
--- /dev/null
+++ b/Services/FfmpegLibraryProbe.cs
@@ -0,0 +1,238 @@
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace AmigaDB.VideoRenderer.Services;
+
+internal sealed partial class FfmpegLibraryProbe : IDisposable
+{
+ private const int AvMediaTypeAudio = 1;
+ private const int AvLogInfo = 32;
+
+ private static readonly object ProbeLock = new();
+ private static readonly AvLogCallback SharedLogCallback = HandleLogMessage;
+ private static StringBuilder? s_activeLogBuffer;
+ private static FfmpegBindings? s_activeBindings;
+
+ private readonly IntPtr _avformatHandle;
+ private readonly IntPtr _avutilHandle;
+ private readonly FfmpegBindings _bindings;
+ private readonly string _libraryDirectory;
+
+ private FfmpegLibraryProbe(string libraryDirectory, IntPtr avformatHandle, IntPtr avutilHandle)
+ {
+ _libraryDirectory = libraryDirectory;
+ _avformatHandle = avformatHandle;
+ _avutilHandle = avutilHandle;
+ _bindings = new FfmpegBindings(avformatHandle, avutilHandle);
+ }
+
+ public static bool TryCreate(string executablePath, out FfmpegLibraryProbe? probe)
+ {
+ probe = null;
+ string? directory = Path.GetDirectoryName(executablePath);
+ if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
+ return false;
+
+ string? avformatPath = FindLibrary(directory, "avformat");
+ string? avutilPath = FindLibrary(directory, "avutil");
+ if (avformatPath is null || avutilPath is null)
+ return false;
+
+ try
+ {
+ IntPtr avformatHandle = NativeLibrary.Load(avformatPath);
+ IntPtr avutilHandle = NativeLibrary.Load(avutilPath);
+ probe = new FfmpegLibraryProbe(directory, avformatHandle, avutilHandle);
+ return true;
+ }
+ catch
+ {
+ probe?.Dispose();
+ probe = null;
+ return false;
+ }
+ }
+
+ public double ProbeDuration(string inputPath)
+ {
+ string dump = ProbeFormatDump(inputPath);
+ Match match = DurationRegex().Match(dump);
+ if (!match.Success)
+ throw new InvalidOperationException("FFmpeg library probing could not determine media duration.");
+
+ int hours = int.Parse(match.Groups["hours"].Value, CultureInfo.InvariantCulture);
+ int minutes = int.Parse(match.Groups["minutes"].Value, CultureInfo.InvariantCulture);
+ double seconds = double.Parse(match.Groups["seconds"].Value, CultureInfo.InvariantCulture);
+ return new TimeSpan(0, hours, minutes, 0).TotalSeconds + seconds;
+ }
+
+ public bool HasAudio(string inputPath)
+ {
+ IntPtr context = OpenInput(inputPath);
+ try
+ {
+ int streamIndex = _bindings.AvFindBestStream(context, AvMediaTypeAudio, -1, -1, IntPtr.Zero, 0);
+ return streamIndex >= 0;
+ }
+ finally
+ {
+ _bindings.AvFormatCloseInput(ref context);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_avformatHandle != IntPtr.Zero) NativeLibrary.Free(_avformatHandle);
+ if (_avutilHandle != IntPtr.Zero) NativeLibrary.Free(_avutilHandle);
+ }
+
+ private string ProbeFormatDump(string inputPath)
+ {
+ IntPtr context = OpenInput(inputPath);
+ try
+ {
+ lock (ProbeLock)
+ {
+ StringBuilder buffer = new();
+ s_activeBindings = _bindings;
+ s_activeLogBuffer = buffer;
+ _bindings.AvLogSetLevel(AvLogInfo);
+ _bindings.AvLogSetCallback(SharedLogCallback);
+ _bindings.AvDumpFormat(context, 0, inputPath, 0);
+ s_activeLogBuffer = null;
+ s_activeBindings = null;
+ return buffer.ToString();
+ }
+ }
+ finally
+ {
+ _bindings.AvFormatCloseInput(ref context);
+ }
+ }
+
+ private IntPtr OpenInput(string inputPath)
+ {
+ IntPtr context = IntPtr.Zero;
+ int openResult = _bindings.AvFormatOpenInput(ref context, inputPath, IntPtr.Zero, IntPtr.Zero);
+ if (openResult < 0)
+ throw new InvalidOperationException($"FFmpeg library probing failed to open '{inputPath}': {FormatError(openResult)}");
+
+ int infoResult = _bindings.AvFormatFindStreamInfo(context, IntPtr.Zero);
+ if (infoResult < 0)
+ {
+ _bindings.AvFormatCloseInput(ref context);
+ throw new InvalidOperationException($"FFmpeg library probing failed to read stream info for '{inputPath}': {FormatError(infoResult)}");
+ }
+
+ return context;
+ }
+
+ private string FormatError(int errorCode)
+ {
+ IntPtr buffer = Marshal.AllocHGlobal(1024);
+ try
+ {
+ int result = _bindings.AvStrError(errorCode, buffer, (UIntPtr)1024);
+ if (result < 0)
+ return $"error {errorCode}";
+ return Marshal.PtrToStringUTF8(buffer) ?? $"error {errorCode}";
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(buffer);
+ }
+ }
+
+ private static string? FindLibrary(string directory, string prefix) =>
+ Directory.EnumerateFiles(directory, $"{prefix}*.dll", SearchOption.TopDirectoryOnly)
+ .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
+ .FirstOrDefault();
+
+ private static void HandleLogMessage(IntPtr avcl, int level, IntPtr format, IntPtr args)
+ {
+ StringBuilder? buffer = s_activeLogBuffer;
+ FfmpegBindings? bindings = s_activeBindings;
+ if (buffer is null || bindings is null)
+ return;
+
+ IntPtr lineBuffer = Marshal.AllocHGlobal(4096);
+ try
+ {
+ Marshal.Copy(new byte[4096], 0, lineBuffer, 4096);
+ int printPrefix = 1;
+ bindings.AvLogFormatLine2(avcl, level, format, args, lineBuffer, 4096, ref printPrefix);
+ string? line = Marshal.PtrToStringUTF8(lineBuffer);
+ if (!string.IsNullOrWhiteSpace(line))
+ buffer.Append(line);
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(lineBuffer);
+ }
+ }
+
+ [GeneratedRegex(@"Duration:\s(?\d{2}):(?\d{2}):(?\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
+ private static partial Regex DurationRegex();
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate void AvLogCallback(IntPtr avcl, int level, IntPtr format, IntPtr args);
+
+ private sealed class FfmpegBindings
+ {
+ public FfmpegBindings(IntPtr avformatHandle, IntPtr avutilHandle)
+ {
+ AvFormatOpenInput = GetDelegate(avformatHandle, "avformat_open_input");
+ AvFormatFindStreamInfo = GetDelegate(avformatHandle, "avformat_find_stream_info");
+ AvFormatCloseInput = GetDelegate(avformatHandle, "avformat_close_input");
+ AvFindBestStream = GetDelegate(avformatHandle, "av_find_best_stream");
+ AvDumpFormat = GetDelegate(avformatHandle, "av_dump_format");
+ AvLogSetCallback = GetDelegate(avutilHandle, "av_log_set_callback");
+ AvLogSetLevel = GetDelegate(avutilHandle, "av_log_set_level");
+ AvLogFormatLine2 = GetDelegate(avutilHandle, "av_log_format_line2");
+ AvStrError = GetDelegate(avutilHandle, "av_strerror");
+ }
+
+ public AvFormatOpenInputDelegate AvFormatOpenInput { get; }
+ public AvFormatFindStreamInfoDelegate AvFormatFindStreamInfo { get; }
+ public AvFormatCloseInputDelegate AvFormatCloseInput { get; }
+ public AvFindBestStreamDelegate AvFindBestStream { get; }
+ public AvDumpFormatDelegate AvDumpFormat { get; }
+ public AvLogSetCallbackDelegate AvLogSetCallback { get; }
+ public AvLogSetLevelDelegate AvLogSetLevel { get; }
+ public AvLogFormatLine2Delegate AvLogFormatLine2 { get; }
+ public AvStrErrorDelegate AvStrError { get; }
+
+ private static T GetDelegate(IntPtr handle, string exportName) where T : Delegate =>
+ Marshal.GetDelegateForFunctionPointer(NativeLibrary.GetExport(handle, exportName));
+ }
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate int AvFormatOpenInputDelegate(ref IntPtr context, [MarshalAs(UnmanagedType.LPUTF8Str)] string url, IntPtr format, IntPtr options);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate int AvFormatFindStreamInfoDelegate(IntPtr context, IntPtr options);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate void AvFormatCloseInputDelegate(ref IntPtr context);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate int AvFindBestStreamDelegate(IntPtr context, int mediaType, int wantedStream, int relatedStream, IntPtr decoder, int flags);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate void AvDumpFormatDelegate(IntPtr context, int index, [MarshalAs(UnmanagedType.LPUTF8Str)] string url, int isOutput);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate void AvLogSetCallbackDelegate(AvLogCallback callback);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate void AvLogSetLevelDelegate(int level);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate int AvLogFormatLine2Delegate(IntPtr avcl, int level, IntPtr format, IntPtr args, IntPtr line, int lineSize, ref int printPrefix);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate int AvStrErrorDelegate(int errorCode, IntPtr errorBuffer, UIntPtr errorBufferSize);
+}
diff --git a/Services/MediaProbe.cs b/Services/MediaProbe.cs
new file mode 100644
index 0000000..7132900
--- /dev/null
+++ b/Services/MediaProbe.cs
@@ -0,0 +1,49 @@
+using System.Globalization;
+using System.IO;
+
+namespace AmigaDB.VideoRenderer.Services;
+
+internal sealed class MediaProbe
+{
+ private readonly ProcessRunner _runner;
+ private readonly Action? _log;
+
+ public MediaProbe(ProcessRunner runner, Action? log)
+ {
+ _runner = runner;
+ _log = log;
+ }
+
+ public async Task ProbeDurationAsync(ToolPaths tools, string input, CancellationToken token)
+ {
+ using FfmpegLibraryProbe? libraryProbe = CreateLibraryProbe(tools);
+ if (libraryProbe is not null)
+ return libraryProbe.ProbeDuration(input);
+
+ string output = await _runner.RunAsync(tools.Ffprobe,
+ ["-v", "error", "-show_entries", "format=duration", "-of", "default=nk=1:nw=1", input],
+ null, null, token);
+ return double.Parse(output.Trim(), CultureInfo.InvariantCulture);
+ }
+
+ public async Task HasAudioAsync(ToolPaths tools, string input, CancellationToken token)
+ {
+ using FfmpegLibraryProbe? libraryProbe = CreateLibraryProbe(tools);
+ if (libraryProbe is not null)
+ return libraryProbe.HasAudio(input);
+
+ string output = await _runner.RunAsync(tools.Ffprobe,
+ ["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", input],
+ null, null, token);
+ return !string.IsNullOrWhiteSpace(output);
+ }
+
+ private FfmpegLibraryProbe? CreateLibraryProbe(ToolPaths tools)
+ {
+ if (!FfmpegLibraryProbe.TryCreate(tools.Ffmpeg, out FfmpegLibraryProbe? probe))
+ return null;
+
+ _log?.Invoke($"Using FFmpeg shared libraries for media probing from '{Path.GetDirectoryName(tools.Ffmpeg)}'.");
+ return probe;
+ }
+}
diff --git a/Services/ProcessRunner.cs b/Services/ProcessRunner.cs
index 39d63ff..40dce08 100644
--- a/Services/ProcessRunner.cs
+++ b/Services/ProcessRunner.cs
@@ -8,13 +8,16 @@ namespace AmigaDB.VideoRenderer.Services;
public sealed partial class ProcessRunner
{
+ public sealed record ProcessOutput(string Line, TimeSpan? Time, double? FramesPerSecond, double? Speed, int? Frame);
+
public async Task RunAsync(
string executable,
IEnumerable arguments,
Action? log,
Action? position,
CancellationToken token,
- bool allowFailure = false)
+ bool allowFailure = false,
+ Action? outputHandler = null)
{
ProcessStartInfo start = new()
{
@@ -33,8 +36,8 @@ public sealed partial class ProcessRunner
StringBuilder output = new();
process.Start();
- Task stdout = PumpAsync(process.StandardOutput, output, log, position, token);
- Task stderr = PumpAsync(process.StandardError, output, log, position, token);
+ Task stdout = PumpAsync(process.StandardOutput, output, log, position, token, outputHandler);
+ Task stderr = PumpAsync(process.StandardError, output, log, position, token, outputHandler);
using CancellationTokenRegistration registration = token.Register(() =>
{
try { if (!process.HasExited) process.Kill(true); } catch { }
@@ -48,18 +51,97 @@ public sealed partial class ProcessRunner
private static async Task PumpAsync(
StreamReader reader, StringBuilder output, Action? log,
- Action? position, CancellationToken token)
+ Action? position, CancellationToken token, Action? outputHandler)
{
while (await reader.ReadLineAsync(token) is { } line)
{
output.AppendLine(line);
- log?.Invoke(line);
- Match match = TimeRegex().Match(line);
- if (match.Success && TimeSpan.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan time))
+ ProcessOutput parsed = ParseOutput(line);
+ if (!IsNoise(parsed))
+ log?.Invoke(line);
+ if (parsed.Time is { } time)
position?.Invoke(time);
+ outputHandler?.Invoke(parsed);
}
}
+ private static ProcessOutput ParseOutput(string line)
+ {
+ TimeSpan? time = null;
+ double? fps = null;
+ double? speed = null;
+ int? frame = null;
+
+ Match timeMatch = TimeRegex().Match(line);
+ if (timeMatch.Success && TimeSpan.TryParse(timeMatch.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan parsedTime))
+ time = parsedTime;
+
+ Match frameMatch = FrameRegex().Match(line);
+ if (frameMatch.Success)
+ {
+ if (int.TryParse(frameMatch.Groups["frame"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedFrame))
+ frame = parsedFrame;
+ if (double.TryParse(frameMatch.Groups["fps"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedFps))
+ fps = parsedFps;
+ if (double.TryParse(frameMatch.Groups["speed"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedSpeed))
+ speed = parsedSpeed;
+ }
+
+ return new ProcessOutput(line, time, fps, speed, frame);
+ }
+
+ private static bool IsNoise(ProcessOutput output)
+ {
+ if (output.Frame is not null || output.Time is not null)
+ return true;
+
+ string line = output.Line.TrimStart();
+ if (string.IsNullOrWhiteSpace(line))
+ return true;
+
+ if (line.StartsWith("ERROR:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("WARNING:", StringComparison.OrdinalIgnoreCase)
+ || line.Contains("selected.", StringComparison.OrdinalIgnoreCase)
+ || line.Contains("Falling back to FFmpeg from PATH", StringComparison.OrdinalIgnoreCase)
+ || line.Contains("skipped", StringComparison.OrdinalIgnoreCase)
+ || line.Contains("could not", StringComparison.OrdinalIgnoreCase)
+ || line.Contains("failed", StringComparison.OrdinalIgnoreCase)
+ || line.Contains("not found", StringComparison.OrdinalIgnoreCase)
+ || line.Contains("Output file is empty", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return line.StartsWith("ffmpeg version ", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("built with ", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("configuration:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("libavutil", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("libavcodec", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("libavformat", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("libavdevice", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("libavfilter", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("libswscale", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("libswresample", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("Input #", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("Output #", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("Stream mapping:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("Stream #", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("Metadata:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("Side data:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("encoder :", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("title :", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("Press [q] to stop", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("[", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("Duration:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("video:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("audio:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("subtitle:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("other streams:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("global headers:", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("muxing overhead:", StringComparison.OrdinalIgnoreCase);
+ }
+
[GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
private static partial Regex TimeRegex();
+
+ [GeneratedRegex(@"frame=\s*(?\d+).*?fps=\s*(?\d+(?:\.\d+)?).*?speed=\s*(?\d+(?:\.\d+)?)x", RegexOptions.CultureInvariant)]
+ private static partial Regex FrameRegex();
}
diff --git a/Services/RenderPipeline.cs b/Services/RenderPipeline.cs
index f30f73a..2b08ab2 100644
--- a/Services/RenderPipeline.cs
+++ b/Services/RenderPipeline.cs
@@ -8,6 +8,12 @@ namespace AmigaDB.VideoRenderer.Services;
public sealed class RenderPipeline
{
private readonly ProcessRunner _runner = new();
+ private readonly MediaProbe _probe;
+
+ public RenderPipeline()
+ {
+ _probe = new MediaProbe(_runner, null);
+ }
public async Task RenderAsync(
RenderSettings settings,
@@ -17,6 +23,8 @@ public sealed class RenderPipeline
{
Validate(settings);
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
+ tools = await PrepareToolsAsync(tools, log, token);
+ MediaProbe probe = new(_runner, log);
Directory.CreateDirectory(settings.OutputDirectory);
string workDir = Path.Combine(Path.GetTempPath(), "AmiReel", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
@@ -32,6 +40,7 @@ public sealed class RenderPipeline
string concat = Path.Combine(workDir, "inputs.txt");
await File.WriteAllLinesAsync(concat, settings.InputFiles.Select(f => $"file '{EscapeConcat(f)}'"), token);
string encoder = await SelectEncoderAsync(tools, settings.Encoder, log, token);
+ double joinDuration = await ProbeCombinedDurationAsync(probe, tools, settings.InputFiles, token);
progress.Report(new(2, "Joining recordings", "Creating the 4K 50 FPS intermediate video"));
List joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat,
@@ -39,9 +48,9 @@ public sealed class RenderPipeline
"-r", settings.FramesPerSecond.ToString(CultureInfo.InvariantCulture)];
joinArgs.AddRange(EncoderArguments(encoder));
joinArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", main]);
- await RunStageAsync(tools.Ffmpeg, joinArgs, log, progress, 2, 48, null, token);
+ await RunStageAsync(tools.Ffmpeg, joinArgs, log, progress, "Joining recordings", 2, 48, joinDuration, token);
- double originalDuration = await ProbeDurationAsync(tools.Ffprobe, main, token);
+ double originalDuration = await probe.ProbeDurationAsync(tools, main, token);
double trimmedDuration = originalDuration - settings.TrimStart;
if (trimmedDuration <= 0)
throw new InvalidOperationException("Trim start is beyond the end of the video.");
@@ -84,7 +93,7 @@ public sealed class RenderPipeline
double fadeStart = Math.Max(0, trimmedDuration - settings.FadeSeconds);
double finalDuration = trimmedDuration + settings.EndCardHoldSeconds;
double cardDuration = finalDuration + 2;
- bool hasAudio = await HasAudioAsync(tools.Ffprobe, main, token);
+ bool hasAudio = await probe.HasAudioAsync(tools, main, token);
progress.Report(new(60, "Final render", "Applying trim, fade and AmiReel end card"));
List finalArgs = ["-y", "-ss", F(settings.TrimStart), "-i", main,
@@ -95,7 +104,7 @@ public sealed class RenderPipeline
"-map", "[v]", "-map", "[a]", "-r", settings.FramesPerSecond.ToString()]);
finalArgs.AddRange(EncoderArguments(encoder));
finalArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", "-movflags", "+faststart", final]);
- await RunStageAsync(tools.Ffmpeg, finalArgs, log, progress, 60, 99, finalDuration, token);
+ await RunStageAsync(tools.Ffmpeg, finalArgs, log, progress, "Final render", 60, 99, finalDuration, token);
progress.Report(new(100, "Complete", final));
}
finally
@@ -127,6 +136,73 @@ public sealed class RenderPipeline
return "x264";
}
+ private async Task PrepareToolsAsync(ToolPaths tools, Action log, CancellationToken token)
+ {
+ try
+ {
+ await ValidateToolAsync(
+ tools.Ffmpeg,
+ ["-version"],
+ "FFmpeg",
+ "Configure AmiReel to use a real standalone FFmpeg build, or replace the embedded ThirdParty binaries with standalone ffmpeg.exe and ffprobe.exe files.",
+ token);
+
+ await ValidateToolAsync(
+ tools.Ffprobe,
+ ["-version"],
+ "FFprobe",
+ "Configure AmiReel to use a real standalone FFprobe build, or replace the embedded ThirdParty binaries with standalone ffmpeg.exe and ffprobe.exe files.",
+ token);
+
+ return tools;
+ }
+ catch (InvalidOperationException)
+ {
+ if (!ToolExtractor.TryResolveFromSystemPath(out ToolPaths? systemTools) || systemTools is null)
+ throw;
+
+ await ValidateToolAsync(
+ systemTools.Ffmpeg,
+ ["-version"],
+ "FFmpeg",
+ "AmiReel found FFmpeg on PATH, but it could not be started.",
+ token);
+
+ await ValidateToolAsync(
+ systemTools.Ffprobe,
+ ["-version"],
+ "FFprobe",
+ "AmiReel found FFprobe on PATH, but it could not be started.",
+ token);
+
+ log($"Configured or embedded FFmpeg tools are unusable. Falling back to FFmpeg from PATH: '{systemTools.Ffmpeg}' and '{systemTools.Ffprobe}'.");
+ return systemTools;
+ }
+ }
+
+ private async Task ValidateToolAsync(
+ string executable,
+ IEnumerable arguments,
+ string toolName,
+ string guidance,
+ CancellationToken token)
+ {
+ try
+ {
+ await _runner.RunAsync(executable, arguments, null, null, token);
+ }
+ catch (InvalidOperationException exception) when (exception.Message.Contains("Cannot find file at", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ $"{toolName} is not a standalone binary and points to missing companion files.\n{guidance}", exception);
+ }
+ catch (InvalidOperationException exception)
+ {
+ throw new InvalidOperationException(
+ $"{toolName} could not be started from '{executable}'.\n{guidance}", exception);
+ }
+ }
+
private static IEnumerable EncoderArguments(string encoder) => encoder == "nvenc"
? ["-c:v", "h264_nvenc", "-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", "16", "-b:v", "0",
"-maxrate", "100M", "-bufsize", "200M", "-multipass", "fullres", "-spatial-aq", "1", "-aq-strength", "8",
@@ -150,30 +226,41 @@ public sealed class RenderPipeline
}
}
+ private static async Task ProbeCombinedDurationAsync(MediaProbe probe, ToolPaths tools, IEnumerable inputs, CancellationToken token)
+ {
+ double total = 0;
+ foreach (string input in inputs)
+ total += await probe.ProbeDurationAsync(tools, input, token);
+ return total;
+ }
+
private async Task RunStageAsync(string executable, IEnumerable args, Action log,
- IProgress progress, double from, double to, double? duration, CancellationToken token)
+ IProgress progress, string stageName, double from, double to, double? duration, CancellationToken token)
{
- await _runner.RunAsync(executable, args, log, time =>
+ await _runner.RunAsync(executable, args, log, null, token, outputHandler: output =>
{
- if (duration > 0)
- progress.Report(new(from + Math.Min(1, time.TotalSeconds / duration.Value) * (to - from), "Rendering", time.ToString(@"hh\:mm\:ss")));
- }, token);
+ if (duration is not > 0 || output.Time is not { } time)
+ return;
+
+ double percent = from + Math.Min(1, time.TotalSeconds / duration.Value) * (to - from);
+ string message = BuildProgressMessage(time, duration.Value, output.FramesPerSecond, output.Speed, output.Frame);
+ progress.Report(new(percent, stageName, message));
+ });
}
- private async Task ProbeDurationAsync(string ffprobe, string input, CancellationToken token)
+ private static string BuildProgressMessage(TimeSpan current, double durationSeconds, double? fps, double? speed, int? frame)
{
- string output = await _runner.RunAsync(ffprobe,
- ["-v", "error", "-show_entries", "format=duration", "-of", "default=nk=1:nw=1", input],
- null, null, token);
- return double.Parse(output.Trim(), CultureInfo.InvariantCulture);
- }
+ TimeSpan total = TimeSpan.FromSeconds(durationSeconds);
+ List parts = [$"{current:hh\\:mm\\:ss} / {total:hh\\:mm\\:ss}"];
- private async Task HasAudioAsync(string ffprobe, string input, CancellationToken token)
- {
- string output = await _runner.RunAsync(ffprobe,
- ["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", input],
- null, null, token);
- return !string.IsNullOrWhiteSpace(output);
+ if (fps is > 0)
+ parts.Add($"{fps:0.#} fps");
+ if (speed is > 0)
+ parts.Add($"{speed:0.##}x");
+ if (frame is > 0)
+ parts.Add($"frame {frame.Value:N0}");
+
+ return string.Join(" · ", parts);
}
private static string BuildFinalFilter(RenderSettings s, double trimmed, double fadeStart, double finalDuration, bool hasAudio)
diff --git a/Services/ToolExtractor.cs b/Services/ToolExtractor.cs
index c884ad3..d77c4a7 100644
--- a/Services/ToolExtractor.cs
+++ b/Services/ToolExtractor.cs
@@ -22,6 +22,18 @@ public static class ToolExtractor
return await ExtractAsync(cancellationToken);
}
+ public static bool TryResolveFromSystemPath(out ToolPaths? tools)
+ {
+ tools = null;
+ string? ffmpeg = FindOnPath("ffmpeg.exe");
+ string? ffprobe = FindOnPath("ffprobe.exe");
+ if (ffmpeg is null || ffprobe is null)
+ return false;
+
+ tools = new ToolPaths(ffmpeg, ffprobe);
+ return true;
+ }
+
private static async Task ExtractAsync(CancellationToken cancellationToken = default)
{
string toolDir = Path.Combine(
@@ -66,4 +78,26 @@ public static class ToolExtractor
byte[] bHash = sha.ComputeHash(b);
return aHash.AsSpan().SequenceEqual(bHash);
}
+
+ private static string? FindOnPath(string fileName)
+ {
+ string? path = Environment.GetEnvironmentVariable("PATH");
+ if (string.IsNullOrWhiteSpace(path))
+ return null;
+
+ foreach (string directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ try
+ {
+ string candidate = Path.Combine(directory, fileName);
+ if (File.Exists(candidate))
+ return candidate;
+ }
+ catch
+ {
+ }
+ }
+
+ return null;
+ }
}
diff --git a/publish-win-x64.ps1 b/publish-win-x64.ps1
index 4bbb053..4b505c0 100644
--- a/publish-win-x64.ps1
+++ b/publish-win-x64.ps1
@@ -4,22 +4,33 @@ $projectDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ffmpeg = Join-Path $projectDir 'ThirdParty\ffmpeg.exe'
$ffprobe = Join-Path $projectDir 'ThirdParty\ffprobe.exe'
$releaseExe = Join-Path $projectDir 'bin\Release\net8.0-windows\win-x64\AmiReel.exe'
+$publishDir = Join-Path $projectDir 'bin\Release\net8.0-windows\win-x64\publish'
+$publishExe = Join-Path $publishDir 'AmiReel.exe'
if (-not (Test-Path $ffmpeg) -or -not (Test-Path $ffprobe)) {
throw 'Add ffmpeg.exe and ffprobe.exe to ThirdParty before publishing.'
}
-if (Test-Path $releaseExe) {
+if ((Test-Path $releaseExe) -or (Test-Path $publishExe)) {
$runningReleaseInstances = Get-Process AmiReel -ErrorAction SilentlyContinue |
- Where-Object { $_.Path -and [string]::Equals($_.Path, $releaseExe, [System.StringComparison]::OrdinalIgnoreCase) }
+ Where-Object {
+ $_.Path -and (
+ [string]::Equals($_.Path, $releaseExe, [System.StringComparison]::OrdinalIgnoreCase) -or
+ [string]::Equals($_.Path, $publishExe, [System.StringComparison]::OrdinalIgnoreCase)
+ )
+ }
if ($runningReleaseInstances) {
- Write-Host 'Stopping running release build before publish...' -ForegroundColor Yellow
+ Write-Host 'Stopping running AmiReel build before publish...' -ForegroundColor Yellow
$runningReleaseInstances | Stop-Process -Force
- Start-Sleep -Milliseconds 500
+ Start-Sleep -Milliseconds 800
}
}
+if (Test-Path $publishDir) {
+ Remove-Item -LiteralPath $publishDir -Recurse -Force -ErrorAction SilentlyContinue
+}
+
dotnet publish (Join-Path $projectDir 'AmigaDB.VideoRenderer.csproj') `
-c Release `
-r win-x64 `