Initial AmiReel application
@@ -0,0 +1,14 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.vscode/
|
||||
*.pdb
|
||||
*.cache
|
||||
*.tmp
|
||||
*.log
|
||||
*.userosscache
|
||||
TestResults/
|
||||
ThirdParty/ffmpeg.exe
|
||||
ThirdParty/ffprobe.exe
|
||||
*.user
|
||||
*.suo
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace AmigaDB.VideoRenderer;
|
||||
|
||||
public partial class App : System.Windows.Application
|
||||
{
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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`.
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 200 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -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
|
||||