Working version

This commit is contained in:
2026-08-11 12:34:02 +02:00
parent 438f163630
commit ad205bb534
12 changed files with 1006 additions and 179 deletions
+259 -43
View File
@@ -19,72 +19,279 @@
<SolidColorBrush x:Key="PrimaryTextBrush" Color="#07111D"/>
<SolidColorBrush x:Key="SelectionBrush" Color="#295C87"/>
<SolidColorBrush x:Key="SelectionTextBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ScrollTrackBrush" Color="#11192B"/>
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#31476D"/>
<SolidColorBrush x:Key="ScrollThumbHoverBrush" Color="#42608F"/>
<SolidColorBrush x:Key="PopupBrush" Color="#10192B"/>
<SolidColorBrush x:Key="HeroBorderBrush" Color="#35507D"/>
<LinearGradientBrush x:Key="HeroBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0" Color="#122441"/>
<GradientStop Offset="0.55" Color="#0F1830"/>
<GradientStop Offset="1" Color="#1A2D4D"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="PageOverlayBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0" Color="#12000000"/>
<GradientStop Offset="1" Color="#00000000"/>
</LinearGradientBrush>
<Style TargetType="Window">
<Setter Property="Background" Value="{StaticResource PageBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="Background" Value="{DynamicResource PageBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="FontFamily" Value="Bahnschrift"/>
</Style>
<Style TargetType="ScrollViewer">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollViewer">
<Grid Background="{TemplateBinding Background}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ScrollContentPresenter Grid.Row="0" Grid.Column="0" Margin="{TemplateBinding Padding}"/>
<ScrollBar x:Name="PART_VerticalScrollBar"
Grid.Row="0"
Grid.Column="1"
Orientation="Vertical"
Value="{TemplateBinding VerticalOffset}"
Maximum="{TemplateBinding ScrollableHeight}"
ViewportSize="{TemplateBinding ViewportHeight}"
Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"/>
<ScrollBar x:Name="PART_HorizontalScrollBar"
Grid.Row="1"
Grid.Column="0"
Orientation="Horizontal"
Value="{TemplateBinding HorizontalOffset}"
Maximum="{TemplateBinding ScrollableWidth}"
ViewportSize="{TemplateBinding ViewportWidth}"
Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"/>
<Border x:Name="CornerBox"
Grid.Row="1"
Grid.Column="1"
Width="14"
Height="14"
Background="{DynamicResource ScrollTrackBrush}"
Visibility="Collapsed"/>
</Grid>
<ControlTemplate.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="ComputedVerticalScrollBarVisibility" Value="Visible"/>
<Condition Property="ComputedHorizontalScrollBarVisibility" Value="Visible"/>
</MultiTrigger.Conditions>
<Setter TargetName="CornerBox" Property="Visibility" Value="Visible"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ScrollBarThumbStyle" TargetType="Thumb">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border Background="{DynamicResource ScrollThumbBrush}" CornerRadius="6" Margin="2"/>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="1"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="RepeatButton" x:Key="ScrollBarButtonStyle">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="{TemplateBinding Background}" CornerRadius="6"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="14"/>
<Setter Property="Background" Value="{DynamicResource ScrollTrackBrush}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent" Width="{TemplateBinding Width}">
<Border Background="{DynamicResource ScrollTrackBrush}" CornerRadius="7" Margin="2"/>
<Track x:Name="PART_Track" IsDirectionReversed="True" Margin="1">
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollBarButtonStyle}" Command="ScrollBar.PageUpCommand"/>
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollBarThumbStyle}"/>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollBarButtonStyle}" Command="ScrollBar.PageDownCommand"/>
</Track.IncreaseRepeatButton>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="14"/>
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</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="Background" Value="{DynamicResource FieldBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="CaretBrush" Value="{DynamicResource TextBrush}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource SelectionBrush}"/>
<Setter Property="SelectionTextBrush" Value="{DynamicResource 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="Background" Value="{DynamicResource FieldBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Padding" Value="8,0"/>
<Setter Property="Height" Value="42"/>
<Setter Property="MinHeight" Value="42"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="OuterBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="12"
Height="{TemplateBinding Height}"
MinHeight="{TemplateBinding MinHeight}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="42"/>
</Grid.ColumnDefinitions>
<ContentPresenter Margin="12,0,8,0"
VerticalAlignment="Center"
HorizontalAlignment="Left"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"/>
<Border Grid.Column="1" Background="{DynamicResource AccentSoftBrush}" CornerRadius="0,12,12,0" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1,0,0,0">
<Path HorizontalAlignment="Center"
VerticalAlignment="Center"
Fill="{DynamicResource AccentBrush}"
Data="M 0 0 L 4 4 L 8 0 Z"/>
</Border>
</Grid>
</Border>
<Popup x:Name="PART_Popup"
Placement="Bottom"
AllowsTransparency="True"
Focusable="False"
IsOpen="{TemplateBinding IsDropDownOpen}"
PopupAnimation="Fade">
<Border Margin="0,8,0,0"
Background="{DynamicResource PopupBrush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1"
CornerRadius="14"
MinWidth="{TemplateBinding ActualWidth}">
<ScrollViewer Margin="6" SnapsToDevicePixels="True" MaxHeight="280">
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Border>
</Popup>
<ToggleButton x:Name="HitTarget" Opacity="0" Focusable="False" IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="OuterBorder" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter TargetName="OuterBorder" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.65"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="Background" Value="{DynamicResource FieldBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Style.Triggers>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="ItemBorder" Background="{TemplateBinding Background}" CornerRadius="10" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource AccentSoftBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryTextBrush}"/>
<Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource 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="Background" Value="{DynamicResource FieldBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource 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}"/>
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource 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="Background" Value="{DynamicResource ButtonBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource 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">
@@ -102,30 +309,37 @@
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource ButtonHoverBrush}"/>
<Setter Property="Background" Value="{DynamicResource ButtonHoverBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="{StaticResource ButtonDisabledBrush}"/>
<Setter Property="Foreground" Value="{StaticResource ButtonDisabledTextBrush}"/>
<Setter Property="Background" Value="{DynamicResource ButtonDisabledBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource 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="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="22,11"/>
</Style>
<Style x:Key="IconButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="46"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="FontSize" Value="20"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="SecondaryPanel" TargetType="Border">
<Setter Property="Background" Value="{StaticResource PanelBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="Background" Value="{DynamicResource PanelBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource 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="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="Background" Value="{DynamicResource PanelBrush}"/>
<Setter Property="Padding" Value="12"/>
<Setter Property="Margin" Value="0,0,0,16"/>
<Setter Property="Template">
@@ -144,7 +358,7 @@
<TextBlock Text="{TemplateBinding Header}"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
Foreground="{DynamicResource TextBrush}"
Margin="0,0,0,12"/>
<ContentPresenter Grid.Row="1"/>
</Grid>
@@ -154,12 +368,14 @@
</Setter>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Setter Property="Background" Value="{DynamicResource FieldBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Height" Value="12"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
</Style>
</Application.Resources>
</Application>
+132 -88
View File
@@ -1,51 +1,43 @@
<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"
Title="AmiReel" Height="720" Width="980" MinHeight="620" MinWidth="840"
WindowStartupLocation="CenterScreen">
<Grid Margin="24">
<Grid Background="{DynamicResource PageBrush}">
<Grid>
<Grid.Background>
<DrawingBrush Stretch="None" Viewport="0,0,36,36" ViewportUnits="Absolute" Opacity="0.08">
<DrawingBrush.Drawing>
<GeometryDrawing Brush="{DynamicResource PageOverlayBrush}">
<GeometryDrawing.Geometry>
<GeometryGroup>
<EllipseGeometry Center="10,10" RadiusX="1.2" RadiusY="1.2"/>
<EllipseGeometry Center="28,18" RadiusX="1.2" RadiusY="1.2"/>
</GeometryGroup>
</GeometryDrawing.Geometry>
</GeometryDrawing>
</DrawingBrush.Drawing>
</DrawingBrush>
</Grid.Background>
</Grid>
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Margin="0,0,0,20">
<Grid>
<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*"/>
<ColumnDefinition Width="1.08*"/>
<ColumnDefinition Width="14"/>
<ColumnDefinition Width="0.92*"/>
</Grid.ColumnDefinitions>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<GroupBox Header="Source recordings">
<Grid>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="125"/></Grid.RowDefinitions>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="110"/></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"/>
@@ -58,7 +50,7 @@
<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"/>
<TextBlock Text="Leave blank to use a built-in black end card." Foreground="{DynamicResource 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"/>
@@ -66,13 +58,102 @@
<TextBox x:Name="OutputNameBox" Grid.Row="3" Grid.ColumnSpan="2" Text="amigadb_intro" ToolTip="Output filename prefix"/>
</Grid>
</GroupBox>
</StackPanel>
</ScrollViewer>
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<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="{DynamicResource AccentBrush}" DockPanel.Dock="Right"/>
</DockPanel>
<ProgressBar x:Name="RenderProgress" Minimum="0" Maximum="100" Foreground="{DynamicResource AccentBrush}"/>
<TextBlock x:Name="StatusText" Text="Select the recordings and start the render. The end card is optional."
Foreground="{DynamicResource MutedBrush}" TextWrapping="Wrap" Margin="0,8,0,0"/>
</StackPanel>
</GroupBox>
<GroupBox Header="FFmpeg log">
<TextBox x:Name="LogBox" Height="220" IsReadOnly="True" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
FontFamily="Consolas" FontSize="11" TextWrapping="NoWrap"/>
</GroupBox>
</StackPanel>
</ScrollViewer>
</Grid>
<DockPanel Grid.Row="1" Margin="0,14,0,0">
<Button x:Name="OpenOutputButton" Content="Open output folder" Click="OpenOutput_Click" IsEnabled="False"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="OpenSettingsButton" Content="⚙" Click="OpenSettings_Click" Style="{StaticResource IconButton}" Margin="0,0,10,0" ToolTip="Settings"/>
<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>
<Border x:Name="SettingsOverlay" Grid.RowSpan="2" Background="#88060B14" Visibility="Collapsed">
<Grid Margin="40">
<Border Width="760"
MaxHeight="720"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{DynamicResource PanelBrush}"
BorderBrush="{DynamicResource HeroBorderBrush}"
BorderThickness="1.5"
CornerRadius="24"
Padding="20">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Settings" FontSize="24" FontWeight="SemiBold" Margin="0,0,0,14"/>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<GroupBox Header="Appearance">
<StackPanel>
<TextBlock Text="Theme" Foreground="{DynamicResource 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>
</GroupBox>
<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="{DynamicResource MutedBrush}"/>
<TextBox x:Name="TrimBox"/>
</StackPanel>
<StackPanel Grid.Column="1" Margin="8,0,0,10">
<TextBlock Text="Fade (seconds)" Foreground="{DynamicResource MutedBrush}"/>
<TextBox x:Name="FadeBox"/>
</StackPanel>
<StackPanel Grid.Row="1" Margin="0,0,8,0">
<TextBlock Text="End-card hold (seconds)" Foreground="{DynamicResource MutedBrush}"/>
<TextBox x:Name="HoldBox"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Margin="8,0,0,0">
<TextBlock Text="Thumbnail interval (seconds)" Foreground="{DynamicResource MutedBrush}"/>
<TextBox x:Name="IntervalBox"/>
</StackPanel>
</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"/>
<TextBlock Text="Encoder" Foreground="{DynamicResource 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"/>
@@ -80,10 +161,10 @@
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="1" Margin="8,0,0,8">
<TextBlock Text="Output" Foreground="{StaticResource MutedBrush}" Margin="0,0,0,4"/>
<TextBlock Text="Output" Foreground="{DynamicResource 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}"
<TextBlock Grid.Row="1" Grid.ColumnSpan="2" Foreground="{DynamicResource MutedBrush}"
Text="NVENC: preset P6 / CQ 16 · CPU: slow / CRF 13"/>
</Grid>
</GroupBox>
@@ -93,7 +174,7 @@
<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"/>
Foreground="{DynamicResource 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"/>
@@ -103,58 +184,21 @@
</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"/>
<Grid Grid.Row="2" Margin="0,14,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Advanced settings are saved automatically." Foreground="{DynamicResource MutedBrush}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Close" Click="CloseSettings_Click" MinWidth="140" Margin="0,0,10,0"/>
<Button Content="Done" Style="{StaticResource PrimaryButton}" Click="CloseSettings_Click" MinWidth="140"/>
</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>
</Border>
</Grid>
</Border>
</Grid>
</Grid>
</Window>
+35 -3
View File
@@ -26,6 +26,11 @@ public partial class MainWindow : Window
EndCardBox.Text = _loadedSettings.EndCardPath;
FfmpegPathBox.Text = _loadedSettings.FfmpegPath;
FfprobePathBox.Text = _loadedSettings.FfprobePath;
TrimBox.Text = _loadedSettings.TrimStart;
FadeBox.Text = _loadedSettings.FadeSeconds;
HoldBox.Text = _loadedSettings.EndCardHoldSeconds;
IntervalBox.Text = _loadedSettings.ThumbnailInterval;
SelectEncoder(_loadedSettings.Encoder);
SelectTheme(_loadedSettings.Theme);
ApplyTheme(_loadedSettings.Theme);
Closing += (_, _) => SaveSettings();
@@ -65,6 +70,14 @@ public partial class MainWindow : Window
if (dialog.ShowDialog(this) == true) FfprobePathBox.Text = dialog.FileName;
}
private void OpenSettings_Click(object sender, RoutedEventArgs e) => SettingsOverlay.Visibility = Visibility.Visible;
private void CloseSettings_Click(object sender, RoutedEventArgs e)
{
SettingsOverlay.Visibility = Visibility.Collapsed;
SaveSettings();
}
private async void Render_Click(object sender, RoutedEventArgs e)
{
try
@@ -174,10 +187,16 @@ public partial class MainWindow : Window
EndCardBox.Text.Trim(),
FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(),
SelectedTheme()));
SelectedTheme(),
SelectedEncoder(),
TrimBox.Text.Trim(),
FadeBox.Text.Trim(),
HoldBox.Text.Trim(),
IntervalBox.Text.Trim()));
}
private string SelectedTheme() => ((ComboBoxItem)ThemeBox.SelectedItem).Tag!.ToString()!;
private string SelectedEncoder() => ((ComboBoxItem)EncoderBox.SelectedItem).Tag!.ToString()!;
private void SelectTheme(string theme)
{
@@ -193,6 +212,20 @@ public partial class MainWindow : Window
ThemeBox.SelectedIndex = 0;
}
private void SelectEncoder(string encoder)
{
foreach (ComboBoxItem item in EncoderBox.Items)
{
if (string.Equals(item.Tag?.ToString(), encoder, StringComparison.OrdinalIgnoreCase))
{
EncoderBox.SelectedItem = item;
return;
}
}
EncoderBox.SelectedIndex = 0;
}
private void ApplyTheme(string theme)
{
bool light = string.Equals(theme, "Light", StringComparison.OrdinalIgnoreCase);
@@ -217,7 +250,6 @@ public partial class MainWindow : Window
private void SetBrush(string key, string color)
{
if (Application.Current.Resources[key] is SolidColorBrush brush)
brush.Color = (Color)ColorConverter.ConvertFromString(color);
Application.Current.Resources[key] = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color));
}
}
+29 -2
View File
@@ -5,7 +5,34 @@ public sealed record AppSettings(
string EndCardPath,
string FfmpegPath,
string FfprobePath,
string Theme)
string Theme,
string Encoder,
string TrimStart,
string FadeSeconds,
string EndCardHoldSeconds,
string ThumbnailInterval)
{
public static AppSettings Default(string outputFolder) => new(outputFolder, "", "", "", "Dark");
public static AppSettings Default(string outputFolder) => new(
outputFolder,
"",
"",
"",
"Dark",
"Auto",
"4.414",
"3",
"4",
"10");
public AppSettings Normalize(string fallbackOutputFolder) => new(
string.IsNullOrWhiteSpace(OutputFolder) ? fallbackOutputFolder : OutputFolder,
EndCardPath ?? "",
FfmpegPath ?? "",
FfprobePath ?? "",
string.IsNullOrWhiteSpace(Theme) ? "Dark" : Theme,
string.IsNullOrWhiteSpace(Encoder) ? "Auto" : Encoder,
string.IsNullOrWhiteSpace(TrimStart) ? "4.414" : TrimStart,
string.IsNullOrWhiteSpace(FadeSeconds) ? "3" : FadeSeconds,
string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds,
string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval);
}
+5
View File
@@ -9,6 +9,7 @@ Windows WPF replacement for `amigadb-render.sh`. The first milestone implements:
- PNG/JPG screenshots at a configurable interval;
- animated WebP screenshot preview;
- optional custom paths for `ffmpeg.exe` and `ffprobe.exe`;
- automatic FFmpeg library probing when shared `avformat`/`avutil` DLLs are present beside `ffmpeg.exe`;
- non-fatal missing thumbnails;
- live FFmpeg log, progress and cancellation;
- one-file Windows publishing with embedded FFmpeg and FFprobe.
@@ -46,3 +47,7 @@ licensing notices.
If you prefer not to use the embedded binaries at runtime, you can point the UI
at external `ffmpeg.exe` and `ffprobe.exe` files. Those paths are saved in the
user settings file under `%LOCALAPPDATA%\AmiReel\settings.json`.
If the selected FFmpeg folder also contains shared FFmpeg DLLs such as
`avformat-*.dll` and `avutil-*.dll`, AmiReel will use those libraries for media
probing in place of `ffprobe.exe` while keeping rendering on `ffmpeg.exe`.
+6 -4
View File
@@ -7,22 +7,24 @@ namespace AmigaDB.VideoRenderer.Services;
public static class AppSettingsStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private static string DefaultOutputFolder => Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
public static AppSettings Load()
{
string path = GetSettingsPath();
if (!File.Exists(path))
return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
return AppSettings.Default(DefaultOutputFolder);
try
{
string json = File.ReadAllText(path);
return JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
return (JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? AppSettings.Default(DefaultOutputFolder))
.Normalize(DefaultOutputFolder);
}
catch
{
return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
return AppSettings.Default(DefaultOutputFolder);
}
}
+238
View File
@@ -0,0 +1,238 @@
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
namespace AmigaDB.VideoRenderer.Services;
internal sealed partial class FfmpegLibraryProbe : IDisposable
{
private const int AvMediaTypeAudio = 1;
private const int AvLogInfo = 32;
private static readonly object ProbeLock = new();
private static readonly AvLogCallback SharedLogCallback = HandleLogMessage;
private static StringBuilder? s_activeLogBuffer;
private static FfmpegBindings? s_activeBindings;
private readonly IntPtr _avformatHandle;
private readonly IntPtr _avutilHandle;
private readonly FfmpegBindings _bindings;
private readonly string _libraryDirectory;
private FfmpegLibraryProbe(string libraryDirectory, IntPtr avformatHandle, IntPtr avutilHandle)
{
_libraryDirectory = libraryDirectory;
_avformatHandle = avformatHandle;
_avutilHandle = avutilHandle;
_bindings = new FfmpegBindings(avformatHandle, avutilHandle);
}
public static bool TryCreate(string executablePath, out FfmpegLibraryProbe? probe)
{
probe = null;
string? directory = Path.GetDirectoryName(executablePath);
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
return false;
string? avformatPath = FindLibrary(directory, "avformat");
string? avutilPath = FindLibrary(directory, "avutil");
if (avformatPath is null || avutilPath is null)
return false;
try
{
IntPtr avformatHandle = NativeLibrary.Load(avformatPath);
IntPtr avutilHandle = NativeLibrary.Load(avutilPath);
probe = new FfmpegLibraryProbe(directory, avformatHandle, avutilHandle);
return true;
}
catch
{
probe?.Dispose();
probe = null;
return false;
}
}
public double ProbeDuration(string inputPath)
{
string dump = ProbeFormatDump(inputPath);
Match match = DurationRegex().Match(dump);
if (!match.Success)
throw new InvalidOperationException("FFmpeg library probing could not determine media duration.");
int hours = int.Parse(match.Groups["hours"].Value, CultureInfo.InvariantCulture);
int minutes = int.Parse(match.Groups["minutes"].Value, CultureInfo.InvariantCulture);
double seconds = double.Parse(match.Groups["seconds"].Value, CultureInfo.InvariantCulture);
return new TimeSpan(0, hours, minutes, 0).TotalSeconds + seconds;
}
public bool HasAudio(string inputPath)
{
IntPtr context = OpenInput(inputPath);
try
{
int streamIndex = _bindings.AvFindBestStream(context, AvMediaTypeAudio, -1, -1, IntPtr.Zero, 0);
return streamIndex >= 0;
}
finally
{
_bindings.AvFormatCloseInput(ref context);
}
}
public void Dispose()
{
if (_avformatHandle != IntPtr.Zero) NativeLibrary.Free(_avformatHandle);
if (_avutilHandle != IntPtr.Zero) NativeLibrary.Free(_avutilHandle);
}
private string ProbeFormatDump(string inputPath)
{
IntPtr context = OpenInput(inputPath);
try
{
lock (ProbeLock)
{
StringBuilder buffer = new();
s_activeBindings = _bindings;
s_activeLogBuffer = buffer;
_bindings.AvLogSetLevel(AvLogInfo);
_bindings.AvLogSetCallback(SharedLogCallback);
_bindings.AvDumpFormat(context, 0, inputPath, 0);
s_activeLogBuffer = null;
s_activeBindings = null;
return buffer.ToString();
}
}
finally
{
_bindings.AvFormatCloseInput(ref context);
}
}
private IntPtr OpenInput(string inputPath)
{
IntPtr context = IntPtr.Zero;
int openResult = _bindings.AvFormatOpenInput(ref context, inputPath, IntPtr.Zero, IntPtr.Zero);
if (openResult < 0)
throw new InvalidOperationException($"FFmpeg library probing failed to open '{inputPath}': {FormatError(openResult)}");
int infoResult = _bindings.AvFormatFindStreamInfo(context, IntPtr.Zero);
if (infoResult < 0)
{
_bindings.AvFormatCloseInput(ref context);
throw new InvalidOperationException($"FFmpeg library probing failed to read stream info for '{inputPath}': {FormatError(infoResult)}");
}
return context;
}
private string FormatError(int errorCode)
{
IntPtr buffer = Marshal.AllocHGlobal(1024);
try
{
int result = _bindings.AvStrError(errorCode, buffer, (UIntPtr)1024);
if (result < 0)
return $"error {errorCode}";
return Marshal.PtrToStringUTF8(buffer) ?? $"error {errorCode}";
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
private static string? FindLibrary(string directory, string prefix) =>
Directory.EnumerateFiles(directory, $"{prefix}*.dll", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
private static void HandleLogMessage(IntPtr avcl, int level, IntPtr format, IntPtr args)
{
StringBuilder? buffer = s_activeLogBuffer;
FfmpegBindings? bindings = s_activeBindings;
if (buffer is null || bindings is null)
return;
IntPtr lineBuffer = Marshal.AllocHGlobal(4096);
try
{
Marshal.Copy(new byte[4096], 0, lineBuffer, 4096);
int printPrefix = 1;
bindings.AvLogFormatLine2(avcl, level, format, args, lineBuffer, 4096, ref printPrefix);
string? line = Marshal.PtrToStringUTF8(lineBuffer);
if (!string.IsNullOrWhiteSpace(line))
buffer.Append(line);
}
finally
{
Marshal.FreeHGlobal(lineBuffer);
}
}
[GeneratedRegex(@"Duration:\s(?<hours>\d{2}):(?<minutes>\d{2}):(?<seconds>\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
private static partial Regex DurationRegex();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AvLogCallback(IntPtr avcl, int level, IntPtr format, IntPtr args);
private sealed class FfmpegBindings
{
public FfmpegBindings(IntPtr avformatHandle, IntPtr avutilHandle)
{
AvFormatOpenInput = GetDelegate<AvFormatOpenInputDelegate>(avformatHandle, "avformat_open_input");
AvFormatFindStreamInfo = GetDelegate<AvFormatFindStreamInfoDelegate>(avformatHandle, "avformat_find_stream_info");
AvFormatCloseInput = GetDelegate<AvFormatCloseInputDelegate>(avformatHandle, "avformat_close_input");
AvFindBestStream = GetDelegate<AvFindBestStreamDelegate>(avformatHandle, "av_find_best_stream");
AvDumpFormat = GetDelegate<AvDumpFormatDelegate>(avformatHandle, "av_dump_format");
AvLogSetCallback = GetDelegate<AvLogSetCallbackDelegate>(avutilHandle, "av_log_set_callback");
AvLogSetLevel = GetDelegate<AvLogSetLevelDelegate>(avutilHandle, "av_log_set_level");
AvLogFormatLine2 = GetDelegate<AvLogFormatLine2Delegate>(avutilHandle, "av_log_format_line2");
AvStrError = GetDelegate<AvStrErrorDelegate>(avutilHandle, "av_strerror");
}
public AvFormatOpenInputDelegate AvFormatOpenInput { get; }
public AvFormatFindStreamInfoDelegate AvFormatFindStreamInfo { get; }
public AvFormatCloseInputDelegate AvFormatCloseInput { get; }
public AvFindBestStreamDelegate AvFindBestStream { get; }
public AvDumpFormatDelegate AvDumpFormat { get; }
public AvLogSetCallbackDelegate AvLogSetCallback { get; }
public AvLogSetLevelDelegate AvLogSetLevel { get; }
public AvLogFormatLine2Delegate AvLogFormatLine2 { get; }
public AvStrErrorDelegate AvStrError { get; }
private static T GetDelegate<T>(IntPtr handle, string exportName) where T : Delegate =>
Marshal.GetDelegateForFunctionPointer<T>(NativeLibrary.GetExport(handle, exportName));
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AvFormatOpenInputDelegate(ref IntPtr context, [MarshalAs(UnmanagedType.LPUTF8Str)] string url, IntPtr format, IntPtr options);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AvFormatFindStreamInfoDelegate(IntPtr context, IntPtr options);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AvFormatCloseInputDelegate(ref IntPtr context);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AvFindBestStreamDelegate(IntPtr context, int mediaType, int wantedStream, int relatedStream, IntPtr decoder, int flags);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AvDumpFormatDelegate(IntPtr context, int index, [MarshalAs(UnmanagedType.LPUTF8Str)] string url, int isOutput);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AvLogSetCallbackDelegate(AvLogCallback callback);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AvLogSetLevelDelegate(int level);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AvLogFormatLine2Delegate(IntPtr avcl, int level, IntPtr format, IntPtr args, IntPtr line, int lineSize, ref int printPrefix);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AvStrErrorDelegate(int errorCode, IntPtr errorBuffer, UIntPtr errorBufferSize);
}
+49
View File
@@ -0,0 +1,49 @@
using System.Globalization;
using System.IO;
namespace AmigaDB.VideoRenderer.Services;
internal sealed class MediaProbe
{
private readonly ProcessRunner _runner;
private readonly Action<string>? _log;
public MediaProbe(ProcessRunner runner, Action<string>? log)
{
_runner = runner;
_log = log;
}
public async Task<double> ProbeDurationAsync(ToolPaths tools, string input, CancellationToken token)
{
using FfmpegLibraryProbe? libraryProbe = CreateLibraryProbe(tools);
if (libraryProbe is not null)
return libraryProbe.ProbeDuration(input);
string output = await _runner.RunAsync(tools.Ffprobe,
["-v", "error", "-show_entries", "format=duration", "-of", "default=nk=1:nw=1", input],
null, null, token);
return double.Parse(output.Trim(), CultureInfo.InvariantCulture);
}
public async Task<bool> HasAudioAsync(ToolPaths tools, string input, CancellationToken token)
{
using FfmpegLibraryProbe? libraryProbe = CreateLibraryProbe(tools);
if (libraryProbe is not null)
return libraryProbe.HasAudio(input);
string output = await _runner.RunAsync(tools.Ffprobe,
["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", input],
null, null, token);
return !string.IsNullOrWhiteSpace(output);
}
private FfmpegLibraryProbe? CreateLibraryProbe(ToolPaths tools)
{
if (!FfmpegLibraryProbe.TryCreate(tools.Ffmpeg, out FfmpegLibraryProbe? probe))
return null;
_log?.Invoke($"Using FFmpeg shared libraries for media probing from '{Path.GetDirectoryName(tools.Ffmpeg)}'.");
return probe;
}
}
+88 -6
View File
@@ -8,13 +8,16 @@ namespace AmigaDB.VideoRenderer.Services;
public sealed partial class ProcessRunner
{
public sealed record ProcessOutput(string Line, TimeSpan? Time, double? FramesPerSecond, double? Speed, int? Frame);
public async Task<string> RunAsync(
string executable,
IEnumerable<string> arguments,
Action<string>? log,
Action<TimeSpan>? position,
CancellationToken token,
bool allowFailure = false)
bool allowFailure = false,
Action<ProcessOutput>? outputHandler = null)
{
ProcessStartInfo start = new()
{
@@ -33,8 +36,8 @@ public sealed partial class ProcessRunner
StringBuilder output = new();
process.Start();
Task stdout = PumpAsync(process.StandardOutput, output, log, position, token);
Task stderr = PumpAsync(process.StandardError, output, log, position, token);
Task stdout = PumpAsync(process.StandardOutput, output, log, position, token, outputHandler);
Task stderr = PumpAsync(process.StandardError, output, log, position, token, outputHandler);
using CancellationTokenRegistration registration = token.Register(() =>
{
try { if (!process.HasExited) process.Kill(true); } catch { }
@@ -48,18 +51,97 @@ public sealed partial class ProcessRunner
private static async Task PumpAsync(
StreamReader reader, StringBuilder output, Action<string>? log,
Action<TimeSpan>? position, CancellationToken token)
Action<TimeSpan>? position, CancellationToken token, Action<ProcessOutput>? outputHandler)
{
while (await reader.ReadLineAsync(token) is { } line)
{
output.AppendLine(line);
ProcessOutput parsed = ParseOutput(line);
if (!IsNoise(parsed))
log?.Invoke(line);
Match match = TimeRegex().Match(line);
if (match.Success && TimeSpan.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan time))
if (parsed.Time is { } time)
position?.Invoke(time);
outputHandler?.Invoke(parsed);
}
}
private static ProcessOutput ParseOutput(string line)
{
TimeSpan? time = null;
double? fps = null;
double? speed = null;
int? frame = null;
Match timeMatch = TimeRegex().Match(line);
if (timeMatch.Success && TimeSpan.TryParse(timeMatch.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan parsedTime))
time = parsedTime;
Match frameMatch = FrameRegex().Match(line);
if (frameMatch.Success)
{
if (int.TryParse(frameMatch.Groups["frame"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedFrame))
frame = parsedFrame;
if (double.TryParse(frameMatch.Groups["fps"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedFps))
fps = parsedFps;
if (double.TryParse(frameMatch.Groups["speed"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedSpeed))
speed = parsedSpeed;
}
return new ProcessOutput(line, time, fps, speed, frame);
}
private static bool IsNoise(ProcessOutput output)
{
if (output.Frame is not null || output.Time is not null)
return true;
string line = output.Line.TrimStart();
if (string.IsNullOrWhiteSpace(line))
return true;
if (line.StartsWith("ERROR:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("WARNING:", StringComparison.OrdinalIgnoreCase)
|| line.Contains("selected.", StringComparison.OrdinalIgnoreCase)
|| line.Contains("Falling back to FFmpeg from PATH", StringComparison.OrdinalIgnoreCase)
|| line.Contains("skipped", StringComparison.OrdinalIgnoreCase)
|| line.Contains("could not", StringComparison.OrdinalIgnoreCase)
|| line.Contains("failed", StringComparison.OrdinalIgnoreCase)
|| line.Contains("not found", StringComparison.OrdinalIgnoreCase)
|| line.Contains("Output file is empty", StringComparison.OrdinalIgnoreCase))
return false;
return line.StartsWith("ffmpeg version ", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("built with ", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("configuration:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavutil", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavcodec", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavformat", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavdevice", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavfilter", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libswscale", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libswresample", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Input #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Output #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Stream mapping:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Stream #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Metadata:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Side data:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("encoder :", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("title :", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Press [q] to stop", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("[", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Duration:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("video:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("audio:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("subtitle:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("other streams:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("global headers:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("muxing overhead:", StringComparison.OrdinalIgnoreCase);
}
[GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
private static partial Regex TimeRegex();
[GeneratedRegex(@"frame=\s*(?<frame>\d+).*?fps=\s*(?<fps>\d+(?:\.\d+)?).*?speed=\s*(?<speed>\d+(?:\.\d+)?)x", RegexOptions.CultureInvariant)]
private static partial Regex FrameRegex();
}
+108 -21
View File
@@ -8,6 +8,12 @@ namespace AmigaDB.VideoRenderer.Services;
public sealed class RenderPipeline
{
private readonly ProcessRunner _runner = new();
private readonly MediaProbe _probe;
public RenderPipeline()
{
_probe = new MediaProbe(_runner, null);
}
public async Task RenderAsync(
RenderSettings settings,
@@ -17,6 +23,8 @@ public sealed class RenderPipeline
{
Validate(settings);
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
tools = await PrepareToolsAsync(tools, log, token);
MediaProbe probe = new(_runner, log);
Directory.CreateDirectory(settings.OutputDirectory);
string workDir = Path.Combine(Path.GetTempPath(), "AmiReel", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
@@ -32,6 +40,7 @@ public sealed class RenderPipeline
string concat = Path.Combine(workDir, "inputs.txt");
await File.WriteAllLinesAsync(concat, settings.InputFiles.Select(f => $"file '{EscapeConcat(f)}'"), token);
string encoder = await SelectEncoderAsync(tools, settings.Encoder, log, token);
double joinDuration = await ProbeCombinedDurationAsync(probe, tools, settings.InputFiles, token);
progress.Report(new(2, "Joining recordings", "Creating the 4K 50 FPS intermediate video"));
List<string> joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat,
@@ -39,9 +48,9 @@ public sealed class RenderPipeline
"-r", settings.FramesPerSecond.ToString(CultureInfo.InvariantCulture)];
joinArgs.AddRange(EncoderArguments(encoder));
joinArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", main]);
await RunStageAsync(tools.Ffmpeg, joinArgs, log, progress, 2, 48, null, token);
await RunStageAsync(tools.Ffmpeg, joinArgs, log, progress, "Joining recordings", 2, 48, joinDuration, token);
double originalDuration = await ProbeDurationAsync(tools.Ffprobe, main, token);
double originalDuration = await probe.ProbeDurationAsync(tools, main, token);
double trimmedDuration = originalDuration - settings.TrimStart;
if (trimmedDuration <= 0)
throw new InvalidOperationException("Trim start is beyond the end of the video.");
@@ -84,7 +93,7 @@ public sealed class RenderPipeline
double fadeStart = Math.Max(0, trimmedDuration - settings.FadeSeconds);
double finalDuration = trimmedDuration + settings.EndCardHoldSeconds;
double cardDuration = finalDuration + 2;
bool hasAudio = await HasAudioAsync(tools.Ffprobe, main, token);
bool hasAudio = await probe.HasAudioAsync(tools, main, token);
progress.Report(new(60, "Final render", "Applying trim, fade and AmiReel end card"));
List<string> finalArgs = ["-y", "-ss", F(settings.TrimStart), "-i", main,
@@ -95,7 +104,7 @@ public sealed class RenderPipeline
"-map", "[v]", "-map", "[a]", "-r", settings.FramesPerSecond.ToString()]);
finalArgs.AddRange(EncoderArguments(encoder));
finalArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", "-movflags", "+faststart", final]);
await RunStageAsync(tools.Ffmpeg, finalArgs, log, progress, 60, 99, finalDuration, token);
await RunStageAsync(tools.Ffmpeg, finalArgs, log, progress, "Final render", 60, 99, finalDuration, token);
progress.Report(new(100, "Complete", final));
}
finally
@@ -127,6 +136,73 @@ public sealed class RenderPipeline
return "x264";
}
private async Task<ToolPaths> PrepareToolsAsync(ToolPaths tools, Action<string> log, CancellationToken token)
{
try
{
await ValidateToolAsync(
tools.Ffmpeg,
["-version"],
"FFmpeg",
"Configure AmiReel to use a real standalone FFmpeg build, or replace the embedded ThirdParty binaries with standalone ffmpeg.exe and ffprobe.exe files.",
token);
await ValidateToolAsync(
tools.Ffprobe,
["-version"],
"FFprobe",
"Configure AmiReel to use a real standalone FFprobe build, or replace the embedded ThirdParty binaries with standalone ffmpeg.exe and ffprobe.exe files.",
token);
return tools;
}
catch (InvalidOperationException)
{
if (!ToolExtractor.TryResolveFromSystemPath(out ToolPaths? systemTools) || systemTools is null)
throw;
await ValidateToolAsync(
systemTools.Ffmpeg,
["-version"],
"FFmpeg",
"AmiReel found FFmpeg on PATH, but it could not be started.",
token);
await ValidateToolAsync(
systemTools.Ffprobe,
["-version"],
"FFprobe",
"AmiReel found FFprobe on PATH, but it could not be started.",
token);
log($"Configured or embedded FFmpeg tools are unusable. Falling back to FFmpeg from PATH: '{systemTools.Ffmpeg}' and '{systemTools.Ffprobe}'.");
return systemTools;
}
}
private async Task ValidateToolAsync(
string executable,
IEnumerable<string> arguments,
string toolName,
string guidance,
CancellationToken token)
{
try
{
await _runner.RunAsync(executable, arguments, null, null, token);
}
catch (InvalidOperationException exception) when (exception.Message.Contains("Cannot find file at", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"{toolName} is not a standalone binary and points to missing companion files.\n{guidance}", exception);
}
catch (InvalidOperationException exception)
{
throw new InvalidOperationException(
$"{toolName} could not be started from '{executable}'.\n{guidance}", exception);
}
}
private static IEnumerable<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",
@@ -150,30 +226,41 @@ public sealed class RenderPipeline
}
}
private static async Task<double> ProbeCombinedDurationAsync(MediaProbe probe, ToolPaths tools, IEnumerable<string> inputs, CancellationToken token)
{
double total = 0;
foreach (string input in inputs)
total += await probe.ProbeDurationAsync(tools, input, token);
return total;
}
private async Task RunStageAsync(string executable, IEnumerable<string> args, Action<string> log,
IProgress<RenderProgress> progress, double from, double to, double? duration, CancellationToken token)
IProgress<RenderProgress> progress, string stageName, double from, double to, double? duration, CancellationToken token)
{
await _runner.RunAsync(executable, args, log, time =>
await _runner.RunAsync(executable, args, log, null, token, outputHandler: output =>
{
if (duration > 0)
progress.Report(new(from + Math.Min(1, time.TotalSeconds / duration.Value) * (to - from), "Rendering", time.ToString(@"hh\:mm\:ss")));
}, token);
if (duration is not > 0 || output.Time is not { } time)
return;
double percent = from + Math.Min(1, time.TotalSeconds / duration.Value) * (to - from);
string message = BuildProgressMessage(time, duration.Value, output.FramesPerSecond, output.Speed, output.Frame);
progress.Report(new(percent, stageName, message));
});
}
private async Task<double> ProbeDurationAsync(string ffprobe, string input, CancellationToken token)
private static string BuildProgressMessage(TimeSpan current, double durationSeconds, double? fps, double? speed, int? frame)
{
string output = await _runner.RunAsync(ffprobe,
["-v", "error", "-show_entries", "format=duration", "-of", "default=nk=1:nw=1", input],
null, null, token);
return double.Parse(output.Trim(), CultureInfo.InvariantCulture);
}
TimeSpan total = TimeSpan.FromSeconds(durationSeconds);
List<string> parts = [$"{current:hh\\:mm\\:ss} / {total:hh\\:mm\\:ss}"];
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);
if (fps is > 0)
parts.Add($"{fps:0.#} fps");
if (speed is > 0)
parts.Add($"{speed:0.##}x");
if (frame is > 0)
parts.Add($"frame {frame.Value:N0}");
return string.Join(" · ", parts);
}
private static string BuildFinalFilter(RenderSettings s, double trimmed, double fadeStart, double finalDuration, bool hasAudio)
+34
View File
@@ -22,6 +22,18 @@ public static class ToolExtractor
return await ExtractAsync(cancellationToken);
}
public static bool TryResolveFromSystemPath(out ToolPaths? tools)
{
tools = null;
string? ffmpeg = FindOnPath("ffmpeg.exe");
string? ffprobe = FindOnPath("ffprobe.exe");
if (ffmpeg is null || ffprobe is null)
return false;
tools = new ToolPaths(ffmpeg, ffprobe);
return true;
}
private static async Task<ToolPaths> ExtractAsync(CancellationToken cancellationToken = default)
{
string toolDir = Path.Combine(
@@ -66,4 +78,26 @@ public static class ToolExtractor
byte[] bHash = sha.ComputeHash(b);
return aHash.AsSpan().SequenceEqual(bHash);
}
private static string? FindOnPath(string fileName)
{
string? path = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(path))
return null;
foreach (string directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
try
{
string candidate = Path.Combine(directory, fileName);
if (File.Exists(candidate))
return candidate;
}
catch
{
}
}
return null;
}
}
+15 -4
View File
@@ -4,22 +4,33 @@ $projectDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ffmpeg = Join-Path $projectDir 'ThirdParty\ffmpeg.exe'
$ffprobe = Join-Path $projectDir 'ThirdParty\ffprobe.exe'
$releaseExe = Join-Path $projectDir 'bin\Release\net8.0-windows\win-x64\AmiReel.exe'
$publishDir = Join-Path $projectDir 'bin\Release\net8.0-windows\win-x64\publish'
$publishExe = Join-Path $publishDir 'AmiReel.exe'
if (-not (Test-Path $ffmpeg) -or -not (Test-Path $ffprobe)) {
throw 'Add ffmpeg.exe and ffprobe.exe to ThirdParty before publishing.'
}
if (Test-Path $releaseExe) {
if ((Test-Path $releaseExe) -or (Test-Path $publishExe)) {
$runningReleaseInstances = Get-Process AmiReel -ErrorAction SilentlyContinue |
Where-Object { $_.Path -and [string]::Equals($_.Path, $releaseExe, [System.StringComparison]::OrdinalIgnoreCase) }
Where-Object {
$_.Path -and (
[string]::Equals($_.Path, $releaseExe, [System.StringComparison]::OrdinalIgnoreCase) -or
[string]::Equals($_.Path, $publishExe, [System.StringComparison]::OrdinalIgnoreCase)
)
}
if ($runningReleaseInstances) {
Write-Host 'Stopping running release build before publish...' -ForegroundColor Yellow
Write-Host 'Stopping running AmiReel build before publish...' -ForegroundColor Yellow
$runningReleaseInstances | Stop-Process -Force
Start-Sleep -Milliseconds 500
Start-Sleep -Milliseconds 800
}
}
if (Test-Path $publishDir) {
Remove-Item -LiteralPath $publishDir -Recurse -Force -ErrorAction SilentlyContinue
}
dotnet publish (Join-Path $projectDir 'AmigaDB.VideoRenderer.csproj') `
-c Release `
-r win-x64 `