Initial AmiReel application

This commit is contained in:
2026-08-11 10:43:27 +02:00
commit 438f163630
28 changed files with 1160 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
bin/
obj/
.vs/
.vscode/
*.pdb
*.cache
*.tmp
*.log
*.userosscache
TestResults/
ThirdParty/ffmpeg.exe
ThirdParty/ffprobe.exe
*.user
*.suo
+30
View File
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon Condition="Exists('assets\\AmiReel.ico')">assets\AmiReel.ico</ApplicationIcon>
<AssemblyName>AmiReel</AssemblyName>
<RootNamespace>AmigaDB.VideoRenderer</RootNamespace>
<Product>AmiReel</Product>
<Title>AmiReel</Title>
<Version>0.1.0</Version>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="ThirdParty\ffmpeg.exe" Condition="Exists('ThirdParty\ffmpeg.exe')" LogicalName="AmigaDB.VideoRenderer.Tools.ffmpeg.exe" />
<EmbeddedResource Include="ThirdParty\ffprobe.exe" Condition="Exists('ThirdParty\ffprobe.exe')" LogicalName="AmigaDB.VideoRenderer.Tools.ffprobe.exe" />
</ItemGroup>
<Target Name="RequireFfmpegForPublish" BeforeTargets="Publish">
<Error Condition="!Exists('ThirdParty\ffmpeg.exe')" Text="ThirdParty\ffmpeg.exe is required for a portable publish." />
<Error Condition="!Exists('ThirdParty\ffprobe.exe')" Text="ThirdParty\ffprobe.exe is required for a portable publish." />
</Target>
</Project>
+19
View File
@@ -0,0 +1,19 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AmigaDB.VideoRenderer", "AmigaDB.VideoRenderer.csproj", "{7DF2BA53-4031-45EA-B4EF-27B86111FBFA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7DF2BA53-4031-45EA-B4EF-27B86111FBFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DF2BA53-4031-45EA-B4EF-27B86111FBFA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7DF2BA53-4031-45EA-B4EF-27B86111FBFA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7DF2BA53-4031-45EA-B4EF-27B86111FBFA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+165
View File
@@ -0,0 +1,165 @@
<Application x:Class="AmigaDB.VideoRenderer.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<SolidColorBrush x:Key="PageBrush" Color="#0E1525"/>
<SolidColorBrush x:Key="PanelBrush" Color="#162033"/>
<SolidColorBrush x:Key="PanelAltBrush" Color="#1A2740"/>
<SolidColorBrush x:Key="FieldBrush" Color="#1D2940"/>
<SolidColorBrush x:Key="BorderBrush" Color="#2B3A58"/>
<SolidColorBrush x:Key="AccentBrush" Color="#4AB8FF"/>
<SolidColorBrush x:Key="AccentSoftBrush" Color="#13324F"/>
<SolidColorBrush x:Key="TextBrush" Color="#F5F7FB"/>
<SolidColorBrush x:Key="MutedBrush" Color="#9FB1CC"/>
<SolidColorBrush x:Key="ButtonBrush" Color="#243554"/>
<SolidColorBrush x:Key="ButtonHoverBrush" Color="#2E446C"/>
<SolidColorBrush x:Key="ButtonDisabledBrush" Color="#2A3140"/>
<SolidColorBrush x:Key="ButtonDisabledTextBrush" Color="#7E8BA1"/>
<SolidColorBrush x:Key="PrimaryTextBrush" Color="#07111D"/>
<SolidColorBrush x:Key="SelectionBrush" Color="#295C87"/>
<SolidColorBrush x:Key="SelectionTextBrush" Color="#FFFFFF"/>
<Style TargetType="Window">
<Setter Property="Background" Value="{StaticResource PageBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="FontFamily" Value="Bahnschrift"/>
</Style>
<Style TargetType="ScrollViewer">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="CaretBrush" Value="{StaticResource TextBrush}"/>
<Setter Property="SelectionBrush" Value="{StaticResource SelectionBrush}"/>
<Setter Property="SelectionTextBrush" Value="{StaticResource SelectionTextBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="9,7"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryTextBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="ListBox">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="Button">
<Setter Property="Background" Value="{StaticResource ButtonBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,8"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="10">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource ButtonHoverBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="{StaticResource ButtonDisabledBrush}"/>
<Setter Property="Foreground" Value="{StaticResource ButtonDisabledTextBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="PrimaryButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryTextBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="22,11"/>
</Style>
<Style x:Key="SecondaryPanel" TargetType="Border">
<Setter Property="Background" Value="{StaticResource PanelBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="18"/>
</Style>
<Style TargetType="GroupBox">
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="Background" Value="{StaticResource PanelBrush}"/>
<Setter Property="Padding" Value="12"/>
<Setter Property="Margin" Value="0,0,0,16"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GroupBox">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1"
CornerRadius="18"
Padding="16">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Text="{TemplateBinding Header}"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
Margin="0,0,0,12"/>
<ContentPresenter Grid.Row="1"/>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Setter Property="Height" Value="12"/>
</Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
</Style>
</Application.Resources>
</Application>
+5
View File
@@ -0,0 +1,5 @@
namespace AmigaDB.VideoRenderer;
public partial class App : System.Windows.Application
{
}
+160
View File
@@ -0,0 +1,160 @@
<Window x:Class="AmigaDB.VideoRenderer.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AmiReel" Height="850" Width="1120" MinHeight="720" MinWidth="940"
WindowStartupLocation="CenterScreen">
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Margin="0,0,0,20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="220"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="AmiReel" Foreground="{StaticResource AccentBrush}" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="Video Renderer" FontSize="38" FontWeight="SemiBold"/>
<TextBlock Text="AVI recordings → 4K50 YouTube video, thumbnails and animated preview"
Foreground="{StaticResource MutedBrush}" Margin="0,6,0,0"/>
</StackPanel>
<Border Grid.Column="1" Style="{StaticResource SecondaryPanel}" Padding="14,10" HorizontalAlignment="Right">
<StackPanel>
<TextBlock Text="Theme" Foreground="{StaticResource MutedBrush}" Margin="0,0,0,4"/>
<ComboBox x:Name="ThemeBox" SelectionChanged="ThemeBox_SelectionChanged">
<ComboBoxItem Content="Dark" Tag="Dark"/>
<ComboBoxItem Content="Light" Tag="Light"/>
</ComboBox>
</StackPanel>
</Border>
</Grid>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1.05*"/>
<ColumnDefinition Width="18"/>
<ColumnDefinition Width="0.95*"/>
</Grid.ColumnDefinitions>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<GroupBox Header="Source recordings">
<Grid>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="125"/></Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Button Content="Add AVI files…" Click="AddInputs_Click" Margin="0,0,8,0"/>
<Button Content="Clear" Click="ClearInputs_Click"/>
</StackPanel>
<ListBox x:Name="InputList" Grid.Row="1"/>
</Grid>
</GroupBox>
<GroupBox Header="End card and output">
<Grid>
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition/><RowDefinition/><RowDefinition/></Grid.RowDefinitions>
<TextBlock Text="Leave blank to use a built-in black end card." Foreground="{StaticResource MutedBrush}" Margin="0,0,0,6"/>
<TextBox x:Name="EndCardBox" Grid.Row="1" Margin="0,0,8,8" ToolTip="Optional end-card image"/>
<Button Grid.Row="1" Grid.Column="1" Content="Browse…" Click="BrowseEndCard_Click" Margin="0,0,0,8"/>
<TextBox x:Name="OutputFolderBox" Grid.Row="2" Margin="0,0,8,8" ToolTip="Output folder"/>
<Button Grid.Row="2" Grid.Column="1" Content="Browse…" Click="BrowseOutput_Click" Margin="0,0,0,8"/>
<TextBox x:Name="OutputNameBox" Grid.Row="3" Grid.ColumnSpan="2" Text="amigadb_intro" ToolTip="Output filename prefix"/>
</Grid>
</GroupBox>
<GroupBox Header="Video encoding">
<Grid>
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
<Grid.RowDefinitions><RowDefinition/><RowDefinition/></Grid.RowDefinitions>
<StackPanel Margin="0,0,8,8">
<TextBlock Text="Encoder" Foreground="{StaticResource MutedBrush}" Margin="0,0,0,4"/>
<ComboBox x:Name="EncoderBox" SelectedIndex="0">
<ComboBoxItem Content="Auto (NVENC → CPU fallback)" Tag="Auto"/>
<ComboBoxItem Content="NVIDIA NVENC" Tag="NvidiaNvenc"/>
<ComboBoxItem Content="CPU libx264" Tag="CpuX264"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="1" Margin="8,0,0,8">
<TextBlock Text="Output" Foreground="{StaticResource MutedBrush}" Margin="0,0,0,4"/>
<TextBox Text="3840 × 2160 · 50 FPS" IsReadOnly="True"/>
</StackPanel>
<TextBlock Grid.Row="1" Grid.ColumnSpan="2" Foreground="{StaticResource MutedBrush}"
Text="NVENC: preset P6 / CQ 16 · CPU: slow / CRF 13"/>
</Grid>
</GroupBox>
<GroupBox Header="FFmpeg tools">
<Grid>
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition/><RowDefinition/></Grid.RowDefinitions>
<TextBlock Text="Optional override. Leave blank to use embedded FFmpeg and FFprobe."
Foreground="{StaticResource MutedBrush}" Margin="0,0,0,6"/>
<TextBox x:Name="FfmpegPathBox" Grid.Row="1" Margin="0,0,8,8" ToolTip="Path to ffmpeg.exe"/>
<Button Grid.Row="1" Grid.Column="1" Content="Browse…" Click="BrowseFfmpeg_Click" Margin="0,0,0,8"/>
<TextBox x:Name="FfprobePathBox" Grid.Row="2" Margin="0,0,8,0" ToolTip="Path to ffprobe.exe"/>
<Button Grid.Row="2" Grid.Column="1" Content="Browse…" Click="BrowseFfprobe_Click"/>
</Grid>
</GroupBox>
</StackPanel>
</ScrollViewer>
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<GroupBox Header="Timing and screenshots">
<Grid>
<Grid.ColumnDefinitions><ColumnDefinition/><ColumnDefinition/></Grid.ColumnDefinitions>
<Grid.RowDefinitions><RowDefinition/><RowDefinition/></Grid.RowDefinitions>
<StackPanel Margin="0,0,8,10">
<TextBlock Text="Trim start (seconds)" Foreground="{StaticResource MutedBrush}"/>
<TextBox x:Name="TrimBox" Text="4.414"/>
</StackPanel>
<StackPanel Grid.Column="1" Margin="8,0,0,10">
<TextBlock Text="Fade (seconds)" Foreground="{StaticResource MutedBrush}"/>
<TextBox x:Name="FadeBox" Text="3"/>
</StackPanel>
<StackPanel Grid.Row="1" Margin="0,0,8,0">
<TextBlock Text="End-card hold (seconds)" Foreground="{StaticResource MutedBrush}"/>
<TextBox x:Name="HoldBox" Text="4"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Margin="8,0,0,0">
<TextBlock Text="Thumbnail interval (seconds)" Foreground="{StaticResource MutedBrush}"/>
<TextBox x:Name="IntervalBox" Text="10"/>
</StackPanel>
</Grid>
</GroupBox>
<GroupBox Header="Render progress">
<StackPanel>
<DockPanel Margin="0,0,0,8">
<TextBlock x:Name="StageText" Text="Ready" FontWeight="SemiBold"/>
<TextBlock x:Name="PercentText" Text="0%" Foreground="{StaticResource AccentBrush}" DockPanel.Dock="Right"/>
</DockPanel>
<ProgressBar x:Name="RenderProgress" Minimum="0" Maximum="100" Foreground="{StaticResource AccentBrush}"/>
<TextBlock x:Name="StatusText" Text="Select the recordings and start the render. The end card is optional."
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" Margin="0,8,0,0"/>
</StackPanel>
</GroupBox>
<GroupBox Header="FFmpeg log">
<TextBox x:Name="LogBox" Height="280" IsReadOnly="True" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
FontFamily="Consolas" FontSize="11" TextWrapping="NoWrap"/>
</GroupBox>
</StackPanel>
</ScrollViewer>
</Grid>
<DockPanel Grid.Row="2" Margin="0,18,0,0">
<Button x:Name="OpenOutputButton" Content="Open output folder" Click="OpenOutput_Click" IsEnabled="False"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="CancelButton" Content="Cancel" Click="Cancel_Click" IsEnabled="False" Margin="0,0,10,0"/>
<Button x:Name="RenderButton" Content="Start render" Style="{StaticResource PrimaryButton}" Click="Render_Click"/>
</StackPanel>
</DockPanel>
</Grid>
</Window>
+223
View File
@@ -0,0 +1,223 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using AmigaDB.VideoRenderer.Models;
using AmigaDB.VideoRenderer.Services;
using Microsoft.Win32;
using System.Windows.Media;
namespace AmigaDB.VideoRenderer;
public partial class MainWindow : Window
{
private readonly ObservableCollection<string> _inputs = [];
private readonly AppSettings _loadedSettings;
private CancellationTokenSource? _renderCancellation;
public MainWindow()
{
InitializeComponent();
InputList.ItemsSource = _inputs;
_loadedSettings = AppSettingsStore.Load();
OutputFolderBox.Text = _loadedSettings.OutputFolder;
EndCardBox.Text = _loadedSettings.EndCardPath;
FfmpegPathBox.Text = _loadedSettings.FfmpegPath;
FfprobePathBox.Text = _loadedSettings.FfprobePath;
SelectTheme(_loadedSettings.Theme);
ApplyTheme(_loadedSettings.Theme);
Closing += (_, _) => SaveSettings();
}
private void AddInputs_Click(object sender, RoutedEventArgs e)
{
OpenFileDialog dialog = new() { Filter = "AVI recordings (*.avi)|*.avi", Multiselect = true };
if (dialog.ShowDialog(this) != true) return;
foreach (string file in dialog.FileNames.OrderBy(NaturalKey))
if (!_inputs.Contains(file, StringComparer.OrdinalIgnoreCase)) _inputs.Add(file);
}
private void ClearInputs_Click(object sender, RoutedEventArgs e) => _inputs.Clear();
private void BrowseEndCard_Click(object sender, RoutedEventArgs e)
{
OpenFileDialog dialog = new() { Filter = "Images|*.png;*.jpg;*.jpeg;*.webp;*.bmp|All files|*.*" };
if (dialog.ShowDialog(this) == true) EndCardBox.Text = dialog.FileName;
}
private void BrowseOutput_Click(object sender, RoutedEventArgs e)
{
OpenFolderDialog dialog = new() { InitialDirectory = OutputFolderBox.Text };
if (dialog.ShowDialog(this) == true) OutputFolderBox.Text = dialog.FolderName;
}
private void BrowseFfmpeg_Click(object sender, RoutedEventArgs e)
{
OpenFileDialog dialog = new() { Filter = "FFmpeg executable (ffmpeg.exe)|ffmpeg.exe|Executable files (*.exe)|*.exe|All files|*.*" };
if (dialog.ShowDialog(this) == true) FfmpegPathBox.Text = dialog.FileName;
}
private void BrowseFfprobe_Click(object sender, RoutedEventArgs e)
{
OpenFileDialog dialog = new() { Filter = "FFprobe executable (ffprobe.exe)|ffprobe.exe|Executable files (*.exe)|*.exe|All files|*.*" };
if (dialog.ShowDialog(this) == true) FfprobePathBox.Text = dialog.FileName;
}
private async void Render_Click(object sender, RoutedEventArgs e)
{
try
{
RenderSettings settings = ReadSettings();
SaveSettings();
SetRendering(true);
LogBox.Clear();
_renderCancellation = new CancellationTokenSource();
Progress<RenderProgress> progress = new(UpdateProgress);
await new RenderPipeline().RenderAsync(settings, progress, AppendLog, _renderCancellation.Token);
OpenOutputButton.IsEnabled = true;
MessageBox.Show(this, "The AmiReel render completed successfully.", "Render complete",
MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (OperationCanceledException)
{
UpdateProgress(new(RenderProgress.Value, "Cancelled", "The render was cancelled."));
}
catch (Exception exception)
{
AppendLog("ERROR: " + exception);
MessageBox.Show(this, exception.Message, "Render failed", MessageBoxButton.OK, MessageBoxImage.Error);
StageText.Text = "Failed";
}
finally
{
_renderCancellation?.Dispose();
_renderCancellation = null;
SetRendering(false);
}
}
private RenderSettings ReadSettings()
{
static double Number(string text, string name)
{
if (!double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || value < 0)
throw new ArgumentException($"Enter a valid non-negative value for {name}.");
return value;
}
if (!int.TryParse(IntervalBox.Text, out int interval) || interval < 1)
throw new ArgumentException("Thumbnail interval must be at least one second.");
EncoderMode encoder = Enum.Parse<EncoderMode>(((ComboBoxItem)EncoderBox.SelectedItem).Tag!.ToString()!);
return new(_inputs.ToList(), EndCardBox.Text.Trim(), OutputFolderBox.Text.Trim(), OutputNameBox.Text.Trim(),
FfmpegPathBox.Text.Trim(), FfprobePathBox.Text.Trim(),
encoder, Number(TrimBox.Text, "trim start"), Number(FadeBox.Text, "fade"), Number(HoldBox.Text, "end-card hold"), interval);
}
private void Cancel_Click(object sender, RoutedEventArgs e)
{
CancelButton.IsEnabled = false;
StatusText.Text = "Stopping FFmpeg…";
_renderCancellation?.Cancel();
}
private void OpenOutput_Click(object sender, RoutedEventArgs e)
{
if (Directory.Exists(OutputFolderBox.Text))
Process.Start(new ProcessStartInfo("explorer.exe", OutputFolderBox.Text) { UseShellExecute = true });
}
private void SetRendering(bool rendering)
{
RenderButton.IsEnabled = !rendering;
CancelButton.IsEnabled = rendering;
}
private void UpdateProgress(RenderProgress value)
{
RenderProgress.Value = value.Percent;
PercentText.Text = $"{value.Percent:0}%";
StageText.Text = value.Stage;
StatusText.Text = value.Message;
}
private void AppendLog(string line)
{
Dispatcher.Invoke(() =>
{
LogBox.AppendText(line + Environment.NewLine);
LogBox.ScrollToEnd();
});
}
private static string NaturalKey(string path)
{
string name = Path.GetFileNameWithoutExtension(path);
int underscore = name.LastIndexOf('_');
return underscore >= 0 && int.TryParse(name[(underscore + 1)..], out int number)
? name[..underscore] + number.ToString("D10")
: name;
}
private void ThemeBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!IsLoaded) return;
string theme = SelectedTheme();
ApplyTheme(theme);
SaveSettings();
}
private void SaveSettings()
{
AppSettingsStore.Save(new AppSettings(
OutputFolderBox.Text.Trim(),
EndCardBox.Text.Trim(),
FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(),
SelectedTheme()));
}
private string SelectedTheme() => ((ComboBoxItem)ThemeBox.SelectedItem).Tag!.ToString()!;
private void SelectTheme(string theme)
{
foreach (ComboBoxItem item in ThemeBox.Items)
{
if (string.Equals(item.Tag?.ToString(), theme, StringComparison.OrdinalIgnoreCase))
{
ThemeBox.SelectedItem = item;
return;
}
}
ThemeBox.SelectedIndex = 0;
}
private void ApplyTheme(string theme)
{
bool light = string.Equals(theme, "Light", StringComparison.OrdinalIgnoreCase);
SetBrush("PageBrush", light ? "#F4F7FB" : "#0E1525");
SetBrush("PanelBrush", light ? "#FFFFFF" : "#162033");
SetBrush("PanelAltBrush", light ? "#EEF4FB" : "#1A2740");
SetBrush("FieldBrush", light ? "#F7FAFD" : "#1D2940");
SetBrush("BorderBrush", light ? "#C7D3E3" : "#2B3A58");
SetBrush("AccentBrush", light ? "#0E9AEF" : "#4AB8FF");
SetBrush("AccentSoftBrush", light ? "#D6EEFF" : "#13324F");
SetBrush("TextBrush", light ? "#122033" : "#F5F7FB");
SetBrush("MutedBrush", light ? "#5F7390" : "#9FB1CC");
SetBrush("ButtonBrush", light ? "#E5EDF7" : "#243554");
SetBrush("ButtonHoverBrush", light ? "#D7E4F4" : "#2E446C");
SetBrush("ButtonDisabledBrush", light ? "#EEF2F7" : "#2A3140");
SetBrush("ButtonDisabledTextBrush", light ? "#8C99AB" : "#7E8BA1");
SetBrush("PrimaryTextBrush", light ? "#FFFFFF" : "#07111D");
SetBrush("SelectionBrush", light ? "#BEE4FF" : "#295C87");
SetBrush("SelectionTextBrush", light ? "#122033" : "#FFFFFF");
}
private void SetBrush(string key, string color)
{
if (Application.Current.Resources[key] is SolidColorBrush brush)
brush.Color = (Color)ColorConverter.ConvertFromString(color);
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace AmigaDB.VideoRenderer.Models;
public sealed record AppSettings(
string OutputFolder,
string EndCardPath,
string FfmpegPath,
string FfprobePath,
string Theme)
{
public static AppSettings Default(string outputFolder) => new(outputFolder, "", "", "", "Dark");
}
+28
View File
@@ -0,0 +1,28 @@
namespace AmigaDB.VideoRenderer.Models;
public enum EncoderMode
{
Auto,
NvidiaNvenc,
CpuX264
}
public sealed record RenderSettings(
IReadOnlyList<string> InputFiles,
string EndCardPath,
string OutputDirectory,
string OutputName,
string FfmpegPath,
string FfprobePath,
EncoderMode Encoder,
double TrimStart,
double FadeSeconds,
double EndCardHoldSeconds,
int ThumbnailInterval,
int Width = 3840,
int Height = 2160,
int FramesPerSecond = 50,
int ThumbnailWidth = 1280,
int ThumbnailHeight = 720);
public sealed record RenderProgress(double Percent, string Stage, string Message);
+48
View File
@@ -0,0 +1,48 @@
# AmiReel
Windows WPF replacement for `amigadb-render.sh`. The first milestone implements:
- multiple ordered AVI inputs;
- 3840×2160 at 50 FPS;
- automatic NVIDIA NVENC detection with libx264 fallback;
- configurable trim, fade and end-card hold;
- PNG/JPG screenshots at a configurable interval;
- animated WebP screenshot preview;
- optional custom paths for `ffmpeg.exe` and `ffprobe.exe`;
- non-fatal missing thumbnails;
- live FFmpeg log, progress and cancellation;
- one-file Windows publishing with embedded FFmpeg and FFprobe.
## Build
Requirements:
- Windows 10/11 x64
- .NET 8 SDK
- matching Windows x64 `ffmpeg.exe` and `ffprobe.exe` in `ThirdParty`
Development build (does not require FFmpeg until render is started):
```powershell
dotnet build .\AmigaDB.VideoRenderer.csproj
```
Portable publish:
```powershell
.\publish-win-x64.ps1
```
The publish target intentionally fails when either FFmpeg executable is absent,
preventing creation of an application that cannot render.
## Distribution and FFmpeg
The application embeds the selected FFmpeg binaries and extracts them into its
private local runtime cache. Include the license and source/build offer required
by the exact FFmpeg distribution you choose. Do not remove its copyright and
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`.
+40
View File
@@ -0,0 +1,40 @@
using System.IO;
using System.Text.Json;
using AmigaDB.VideoRenderer.Models;
namespace AmigaDB.VideoRenderer.Services;
public static class AppSettingsStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public static AppSettings Load()
{
string path = GetSettingsPath();
if (!File.Exists(path))
return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
try
{
string json = File.ReadAllText(path);
return JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
}
catch
{
return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
}
}
public static void Save(AppSettings settings)
{
string path = GetSettingsPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string json = JsonSerializer.Serialize(settings, JsonOptions);
File.WriteAllText(path, json);
}
private static string GetSettingsPath() => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AmiReel", "settings.json");
}
+65
View File
@@ -0,0 +1,65 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using System.IO;
namespace AmigaDB.VideoRenderer.Services;
public sealed partial class ProcessRunner
{
public async Task<string> RunAsync(
string executable,
IEnumerable<string> arguments,
Action<string>? log,
Action<TimeSpan>? position,
CancellationToken token,
bool allowFailure = false)
{
ProcessStartInfo start = new()
{
FileName = executable,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
StandardErrorEncoding = Encoding.UTF8,
StandardOutputEncoding = Encoding.UTF8
};
foreach (string argument in arguments)
start.ArgumentList.Add(argument);
using Process process = new() { StartInfo = start, EnableRaisingEvents = true };
StringBuilder output = new();
process.Start();
Task stdout = PumpAsync(process.StandardOutput, output, log, position, token);
Task stderr = PumpAsync(process.StandardError, output, log, position, token);
using CancellationTokenRegistration registration = token.Register(() =>
{
try { if (!process.HasExited) process.Kill(true); } catch { }
});
await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(token));
if (process.ExitCode != 0 && !allowFailure)
throw new InvalidOperationException($"Process exited with code {process.ExitCode}.\n{output}");
return output.ToString();
}
private static async Task PumpAsync(
StreamReader reader, StringBuilder output, Action<string>? log,
Action<TimeSpan>? position, CancellationToken token)
{
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))
position?.Invoke(time);
}
}
[GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
private static partial Regex TimeRegex();
}
+216
View File
@@ -0,0 +1,216 @@
using System.Globalization;
using System.IO;
using System.Text;
using AmigaDB.VideoRenderer.Models;
namespace AmigaDB.VideoRenderer.Services;
public sealed class RenderPipeline
{
private readonly ProcessRunner _runner = new();
public async Task RenderAsync(
RenderSettings settings,
IProgress<RenderProgress> progress,
Action<string> log,
CancellationToken token)
{
Validate(settings);
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
Directory.CreateDirectory(settings.OutputDirectory);
string workDir = Path.Combine(Path.GetTempPath(), "AmiReel", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
string endCardPath = await ResolveEndCardPathAsync(settings, workDir, token);
string main = Path.Combine(workDir, settings.OutputName + "_4k50.mp4");
string final = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4");
string thumbs = Path.Combine(settings.OutputDirectory, settings.OutputName + "_thumbnails");
string webp = Path.Combine(settings.OutputDirectory, settings.OutputName + "_screenshots.webp");
try
{
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);
progress.Report(new(2, "Joining recordings", "Creating the 4K 50 FPS intermediate video"));
List<string> joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat,
"-vf", $"scale=2880:2160:flags=lanczos:force_original_aspect_ratio=decrease,pad={settings.Width}:{settings.Height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
"-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);
double originalDuration = await ProbeDurationAsync(tools.Ffprobe, main, token);
double trimmedDuration = originalDuration - settings.TrimStart;
if (trimmedDuration <= 0)
throw new InvalidOperationException("Trim start is beyond the end of the video.");
Directory.CreateDirectory(thumbs);
int count = Math.Max(0, (int)Math.Floor((trimmedDuration - 0.001) / settings.ThumbnailInterval));
for (int index = 1; index <= count; index++)
{
token.ThrowIfCancellationRequested();
int timestamp = index * settings.ThumbnailInterval;
double seek = timestamp + settings.TrimStart;
string label = timestamp.ToString("0000", CultureInfo.InvariantCulture) + "s";
string prefix = Path.Combine(thumbs, $"{settings.OutputName}_thumb_{label}");
await TryThumbnailAsync(tools.Ffmpeg, main, seek, prefix + ".png", settings, true, log, token);
await TryThumbnailAsync(tools.Ffmpeg, main, seek, prefix + ".jpg", settings, false, log, token);
progress.Report(new(48 + (count == 0 ? 8 : 8d * index / count), "Thumbnails", $"Thumbnail {index} of {count}"));
}
string[] pngs = Directory.GetFiles(thumbs, "*.png");
if (pngs.Length > 0)
{
progress.Report(new(57, "Animated preview", "Creating animated WebP"));
string previewList = Path.Combine(workDir, "preview-images.txt");
List<string> previewLines = [];
foreach (string png in pngs.OrderBy(p => Path.GetFileName(p), StringComparer.OrdinalIgnoreCase))
{
previewLines.Add($"file '{EscapeConcat(png)}'");
previewLines.Add("duration 1");
}
// The concat demuxer requires the final image to be repeated so
// its duration is honored.
previewLines.Add($"file '{EscapeConcat(pngs.OrderBy(p => Path.GetFileName(p), StringComparer.OrdinalIgnoreCase).Last())}'");
await File.WriteAllLinesAsync(previewList, previewLines, token);
await _runner.RunAsync(tools.Ffmpeg,
["-y", "-f", "concat", "-safe", "0", "-i", previewList, "-vf", "fps=1",
"-loop", "0", "-c:v", "libwebp", "-quality", "80", "-compression_level", "6", webp],
log, null, token, allowFailure: true);
}
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);
progress.Report(new(60, "Final render", "Applying trim, fade and AmiReel end card"));
List<string> finalArgs = ["-y", "-ss", F(settings.TrimStart), "-i", main,
"-loop", "1", "-framerate", settings.FramesPerSecond.ToString(), "-t", F(cardDuration), "-i", endCardPath];
if (!hasAudio)
finalArgs.AddRange(["-f", "lavfi", "-t", F(finalDuration), "-i", "anullsrc=channel_layout=stereo:sample_rate=48000"]);
finalArgs.AddRange(["-filter_complex", BuildFinalFilter(settings, trimmedDuration, fadeStart, finalDuration, hasAudio),
"-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);
progress.Report(new(100, "Complete", final));
}
finally
{
try { Directory.Delete(workDir, true); } catch { }
}
}
private async Task<string> SelectEncoderAsync(ToolPaths tools, EncoderMode requested, Action<string> log, CancellationToken token)
{
if (requested == EncoderMode.CpuX264) return "x264";
bool nvenc;
try
{
await _runner.RunAsync(tools.Ffmpeg,
["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=128x128:r=1",
"-frames:v", "1", "-an", "-c:v", "h264_nvenc", "-preset", "p6", "-f", "null", "-"],
null, null, token);
nvenc = true;
}
catch (InvalidOperationException)
{
nvenc = false;
}
if (nvenc) { log("NVIDIA NVENC selected."); return "nvenc"; }
if (requested == EncoderMode.NvidiaNvenc)
throw new InvalidOperationException("NVIDIA NVENC was requested but could not initialize.");
log("NVENC unavailable; CPU libx264 selected.");
return "x264";
}
private static IEnumerable<string> 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",
"-temporal-aq", "1", "-rc-lookahead", "32", "-bf", "3", "-profile:v", "high", "-pix_fmt", "yuv420p"]
: ["-c:v", "libx264", "-preset", "slow", "-crf", "13", "-profile:v", "high", "-pix_fmt", "yuv420p"];
private async Task TryThumbnailAsync(string ffmpeg, string input, double seek, string output,
RenderSettings s, bool png, Action<string> log, CancellationToken token)
{
string filter = png
? $"scale={s.ThumbnailWidth}:{s.ThumbnailHeight}:force_original_aspect_ratio=decrease,pad={s.ThumbnailWidth}:{s.ThumbnailHeight}:(ow-iw)/2:(oh-ih)/2,format=rgb24"
: $"scale={s.ThumbnailWidth}:{s.ThumbnailHeight}:force_original_aspect_ratio=decrease:out_range=full,pad={s.ThumbnailWidth}:{s.ThumbnailHeight}:(ow-iw)/2:(oh-ih)/2:color=black,format=yuvj420p";
List<string> args = ["-y", "-ss", F(seek), "-i", input, "-frames:v", "1", "-vf", filter];
if (!png) args.AddRange(["-c:v", "mjpeg", "-q:v", "2", "-threads:v", "1", "-strict", "unofficial"]);
args.Add(output);
await _runner.RunAsync(ffmpeg, args, log, null, token, allowFailure: true);
if (!File.Exists(output) || new FileInfo(output).Length == 0)
{
if (File.Exists(output)) File.Delete(output);
log($"WARNING: No frame at {seek:0.###}s; skipped {Path.GetExtension(output)} thumbnail.");
}
}
private async Task RunStageAsync(string executable, IEnumerable<string> args, Action<string> log,
IProgress<RenderProgress> progress, double from, double to, double? duration, CancellationToken token)
{
await _runner.RunAsync(executable, args, log, time =>
{
if (duration > 0)
progress.Report(new(from + Math.Min(1, time.TotalSeconds / duration.Value) * (to - from), "Rendering", time.ToString(@"hh\:mm\:ss")));
}, token);
}
private async Task<double> ProbeDurationAsync(string ffprobe, string input, CancellationToken token)
{
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);
}
private async Task<bool> 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);
}
private static string BuildFinalFilter(RenderSettings s, double trimmed, double fadeStart, double finalDuration, bool hasAudio)
{
string common = $"[0:v]fps={s.FramesPerSecond},scale={s.Width}:{s.Height}:force_original_aspect_ratio=decrease,pad={s.Width}:{s.Height}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=rgba,setpts=PTS-STARTPTS[base];" +
$"[1:v]fps={s.FramesPerSecond},scale={s.Width}:{s.Height}:force_original_aspect_ratio=decrease,pad={s.Width}:{s.Height}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=rgba,split=2[cardfade][cardhold];" +
$"[cardfade]trim=duration={F(trimmed)},setpts=PTS-STARTPTS,fade=t=in:st={F(fadeStart)}:d={F(s.FadeSeconds)}:alpha=1[cardfade2];" +
"[base][cardfade2]overlay=0:0:shortest=1,format=yuv420p[vmain];" +
$"[cardhold]trim=duration={F(s.EndCardHoldSeconds)},setpts=PTS-STARTPTS,format=yuv420p[vhold];" +
"[vmain][vhold]concat=n=2:v=1:a=0[v];";
return common + (hasAudio
? $"[0:a]afade=t=out:st={F(fadeStart)}:d={F(s.FadeSeconds)},apad=pad_dur={F(s.EndCardHoldSeconds)},atrim=duration={F(finalDuration)}[a]"
: $"[2:a]atrim=duration={F(finalDuration)}[a]");
}
private static async Task<string> ResolveEndCardPathAsync(RenderSettings settings, string workDir, CancellationToken token)
{
if (!string.IsNullOrWhiteSpace(settings.EndCardPath))
return settings.EndCardPath;
string fallbackPath = Path.Combine(workDir, "default-endcard.ppm");
await File.WriteAllTextAsync(fallbackPath, "P3\n1 1\n255\n0 0 0\n", Encoding.ASCII, token);
return fallbackPath;
}
private static void Validate(RenderSettings s)
{
if (s.InputFiles.Count == 0) throw new ArgumentException("Select at least one AVI input file.");
if (s.InputFiles.Any(f => !File.Exists(f))) throw new FileNotFoundException("One or more AVI files no longer exist.");
if (!string.IsNullOrWhiteSpace(s.EndCardPath) && !File.Exists(s.EndCardPath))
throw new FileNotFoundException("End-card image was not found.");
if (string.IsNullOrWhiteSpace(s.OutputDirectory)) throw new ArgumentException("Select an output directory.");
if (string.IsNullOrWhiteSpace(s.OutputName) || s.OutputName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
throw new ArgumentException("Enter a valid output name.");
if (s.ThumbnailInterval < 1) throw new ArgumentException("Thumbnail interval must be at least one second.");
}
private static string EscapeConcat(string path) => path.Replace("'", "'\\''");
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
}
+69
View File
@@ -0,0 +1,69 @@
using System.IO;
using System.Reflection;
using System.Security.Cryptography;
namespace AmigaDB.VideoRenderer.Services;
public sealed record ToolPaths(string Ffmpeg, string Ffprobe);
public static class ToolExtractor
{
public static async Task<ToolPaths> ResolveAsync(string ffmpegPath, string ffprobePath, CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(ffmpegPath) || !string.IsNullOrWhiteSpace(ffprobePath))
{
if (string.IsNullOrWhiteSpace(ffmpegPath) || !File.Exists(ffmpegPath))
throw new FileNotFoundException("Configured ffmpeg.exe was not found.");
if (string.IsNullOrWhiteSpace(ffprobePath) || !File.Exists(ffprobePath))
throw new FileNotFoundException("Configured ffprobe.exe was not found.");
return new ToolPaths(ffmpegPath, ffprobePath);
}
return await ExtractAsync(cancellationToken);
}
private static async Task<ToolPaths> ExtractAsync(CancellationToken cancellationToken = default)
{
string toolDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AmiReel", "tools", "0.1.0");
Directory.CreateDirectory(toolDir);
string ffmpeg = await ExtractOneAsync("ffmpeg.exe", toolDir, cancellationToken);
string ffprobe = await ExtractOneAsync("ffprobe.exe", toolDir, cancellationToken);
return new ToolPaths(ffmpeg, ffprobe);
}
private static async Task<string> ExtractOneAsync(string fileName, string destination, CancellationToken token)
{
Assembly assembly = Assembly.GetExecutingAssembly();
string resourceName = $"AmigaDB.VideoRenderer.Tools.{fileName}";
await using Stream source = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException(
$"Embedded {fileName} was not found. Add it to ThirdParty and publish the application again.");
string target = Path.Combine(destination, fileName);
string temporary = target + ".new";
await using (FileStream output = File.Create(temporary))
await source.CopyToAsync(output, token);
if (File.Exists(target) && FilesMatch(target, temporary))
{
File.Delete(temporary);
return target;
}
File.Move(temporary, target, true);
return target;
}
private static bool FilesMatch(string first, string second)
{
using SHA256 sha = SHA256.Create();
using FileStream a = File.OpenRead(first);
byte[] aHash = sha.ComputeHash(a);
using FileStream b = File.OpenRead(second);
byte[] bHash = sha.ComputeHash(b);
return aHash.AsSpan().SequenceEqual(bHash);
}
}
+14
View File
@@ -0,0 +1,14 @@
# FFmpeg binaries
Before publishing, place these Windows x64 binaries here:
- `ffmpeg.exe`
- `ffprobe.exe`
Use one consistent, trusted FFmpeg build. The project embeds both files into the
published application. At runtime they are extracted under the current user's
local application-data directory.
Keep the matching FFmpeg license and source/build information with every
distributed release. Which obligations apply depends on how FFmpeg was built
(especially whether GPL components are enabled).
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+22
View File
@@ -0,0 +1,22 @@
AmiReel Icon Pack
=================
Contents:
- AmiReel.ico
Windows multi-resolution application icon containing:
16, 24, 32, 48, 64, 128 and 256 px.
- AmiReel-master-1254.png
Original transparent master artwork.
- png/
Individual transparent PNG files:
16, 24, 32, 48, 64, 128, 256, 512 and 1024 px.
Recommended WPF project setting:
<ApplicationIcon>Assets\AmiReel.ico</ApplicationIcon>
Product: AmiReel — Amiga Video Renderer
Brand: AmigaDB
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+31
View File
@@ -0,0 +1,31 @@
$ErrorActionPreference = 'Stop'
$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'
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) {
$runningReleaseInstances = Get-Process AmiReel -ErrorAction SilentlyContinue |
Where-Object { $_.Path -and [string]::Equals($_.Path, $releaseExe, [System.StringComparison]::OrdinalIgnoreCase) }
if ($runningReleaseInstances) {
Write-Host 'Stopping running release build before publish...' -ForegroundColor Yellow
$runningReleaseInstances | Stop-Process -Force
Start-Sleep -Milliseconds 500
}
}
dotnet publish (Join-Path $projectDir 'AmigaDB.VideoRenderer.csproj') `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true
Write-Host 'Published to bin\Release\net8.0-windows\win-x64\publish' -ForegroundColor Green