Working version
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 `
|
||||
|
||||
Reference in New Issue
Block a user