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="PrimaryTextBrush" Color="#07111D"/>
<SolidColorBrush x:Key="SelectionBrush" Color="#295C87"/> <SolidColorBrush x:Key="SelectionBrush" Color="#295C87"/>
<SolidColorBrush x:Key="SelectionTextBrush" Color="#FFFFFF"/> <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"> <Style TargetType="Window">
<Setter Property="Background" Value="{StaticResource PageBrush}"/> <Setter Property="Background" Value="{DynamicResource PageBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="FontFamily" Value="Bahnschrift"/> <Setter Property="FontFamily" Value="Bahnschrift"/>
</Style> </Style>
<Style TargetType="ScrollViewer"> <Style TargetType="ScrollViewer">
<Setter Property="Background" Value="Transparent"/> <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>
<Style TargetType="TextBox"> <Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/> <Setter Property="Background" Value="{DynamicResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="CaretBrush" Value="{StaticResource TextBrush}"/> <Setter Property="CaretBrush" Value="{DynamicResource TextBrush}"/>
<Setter Property="SelectionBrush" Value="{StaticResource SelectionBrush}"/> <Setter Property="SelectionBrush" Value="{DynamicResource SelectionBrush}"/>
<Setter Property="SelectionTextBrush" Value="{StaticResource SelectionTextBrush}"/> <Setter Property="SelectionTextBrush" Value="{DynamicResource SelectionTextBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="9,7"/> <Setter Property="Padding" Value="9,7"/>
<Setter Property="VerticalContentAlignment" Value="Center"/> <Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="SnapsToDevicePixels" Value="True"/> <Setter Property="SnapsToDevicePixels" Value="True"/>
</Style> </Style>
<Style TargetType="ComboBox"> <Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/> <Setter Property="Background" Value="{DynamicResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <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>
<Style TargetType="ComboBoxItem"> <Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/> <Setter Property="Background" Value="{DynamicResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/> <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"> <Trigger Property="IsHighlighted" Value="True">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}"/> <Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource AccentSoftBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
</Trigger> </Trigger>
<Trigger Property="IsSelected" Value="True"> <Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/> <Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryTextBrush}"/> <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> </Trigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
<Style TargetType="ListBox"> <Style TargetType="ListBox">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/> <Setter Property="Background" Value="{DynamicResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
</Style> </Style>
<Style TargetType="ListBoxItem"> <Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="8,6"/> <Setter Property="Padding" Value="8,6"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background" Value="Transparent"/>
<Style.Triggers> <Style.Triggers>
<Trigger Property="IsSelected" Value="True"> <Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}"/> <Setter Property="Background" Value="{DynamicResource AccentSoftBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
</Trigger> </Trigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
<Style TargetType="Button"> <Style TargetType="Button">
<Setter Property="Background" Value="{StaticResource ButtonBrush}"/> <Setter Property="Background" Value="{DynamicResource ButtonBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,8"/> <Setter Property="Padding" Value="14,8"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template"> <Setter Property="Template">
@@ -102,30 +309,37 @@
</Setter> </Setter>
<Style.Triggers> <Style.Triggers>
<Trigger Property="IsMouseOver" Value="True"> <Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource ButtonHoverBrush}"/> <Setter Property="Background" Value="{DynamicResource ButtonHoverBrush}"/>
</Trigger> </Trigger>
<Trigger Property="IsEnabled" Value="False"> <Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="{StaticResource ButtonDisabledBrush}"/> <Setter Property="Background" Value="{DynamicResource ButtonDisabledBrush}"/>
<Setter Property="Foreground" Value="{StaticResource ButtonDisabledTextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource ButtonDisabledTextBrush}"/>
</Trigger> </Trigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
<Style x:Key="PrimaryButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"> <Style x:Key="PrimaryButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/> <Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryTextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/> <Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="22,11"/> <Setter Property="Padding" Value="22,11"/>
</Style> </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"> <Style x:Key="SecondaryPanel" TargetType="Border">
<Setter Property="Background" Value="{StaticResource PanelBrush}"/> <Setter Property="Background" Value="{DynamicResource PanelBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="18"/> <Setter Property="CornerRadius" Value="18"/>
</Style> </Style>
<Style TargetType="GroupBox"> <Style TargetType="GroupBox">
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="Background" Value="{StaticResource PanelBrush}"/> <Setter Property="Background" Value="{DynamicResource PanelBrush}"/>
<Setter Property="Padding" Value="12"/> <Setter Property="Padding" Value="12"/>
<Setter Property="Margin" Value="0,0,0,16"/> <Setter Property="Margin" Value="0,0,0,16"/>
<Setter Property="Template"> <Setter Property="Template">
@@ -144,7 +358,7 @@
<TextBlock Text="{TemplateBinding Header}" <TextBlock Text="{TemplateBinding Header}"
FontSize="16" FontSize="16"
FontWeight="SemiBold" FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}" Foreground="{DynamicResource TextBrush}"
Margin="0,0,0,12"/> Margin="0,0,0,12"/>
<ContentPresenter Grid.Row="1"/> <ContentPresenter Grid.Row="1"/>
</Grid> </Grid>
@@ -154,12 +368,14 @@
</Setter> </Setter>
</Style> </Style>
<Style TargetType="ProgressBar"> <Style TargetType="ProgressBar">
<Setter Property="Background" Value="{StaticResource FieldBrush}"/> <Setter Property="Background" Value="{DynamicResource FieldBrush}"/>
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/> <Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Height" Value="12"/> <Setter Property="Height" Value="12"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style> </Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
</Style> </Style>
</Application.Resources> </Application.Resources>
</Application> </Application>
+132 -88
View File
@@ -1,51 +1,43 @@
<Window x:Class="AmigaDB.VideoRenderer.MainWindow" <Window x:Class="AmigaDB.VideoRenderer.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 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"> 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> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid>
<Grid Margin="0,0,0,20">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="1.08*"/>
<ColumnDefinition Width="220"/> <ColumnDefinition Width="14"/>
</Grid.ColumnDefinitions> <ColumnDefinition Width="0.92*"/>
<StackPanel>
<TextBlock Text="AmiReel" Foreground="{StaticResource AccentBrush}" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="Video Renderer" FontSize="38" FontWeight="SemiBold"/>
<TextBlock Text="AVI recordings → 4K50 YouTube video, thumbnails and animated preview"
Foreground="{StaticResource MutedBrush}" Margin="0,6,0,0"/>
</StackPanel>
<Border Grid.Column="1" Style="{StaticResource SecondaryPanel}" Padding="14,10" HorizontalAlignment="Right">
<StackPanel>
<TextBlock Text="Theme" Foreground="{StaticResource MutedBrush}" Margin="0,0,0,4"/>
<ComboBox x:Name="ThemeBox" SelectionChanged="ThemeBox_SelectionChanged">
<ComboBoxItem Content="Dark" Tag="Dark"/>
<ComboBoxItem Content="Light" Tag="Light"/>
</ComboBox>
</StackPanel>
</Border>
</Grid>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1.05*"/>
<ColumnDefinition Width="18"/>
<ColumnDefinition Width="0.95*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"> <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel> <StackPanel>
<GroupBox Header="Source recordings"> <GroupBox Header="Source recordings">
<Grid> <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"> <StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Button Content="Add AVI files…" Click="AddInputs_Click" Margin="0,0,8,0"/> <Button Content="Add AVI files…" Click="AddInputs_Click" Margin="0,0,8,0"/>
<Button Content="Clear" Click="ClearInputs_Click"/> <Button Content="Clear" Click="ClearInputs_Click"/>
@@ -58,7 +50,7 @@
<Grid> <Grid>
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions> <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition/><RowDefinition/><RowDefinition/></Grid.RowDefinitions> <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"/> <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"/> <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"/> <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"/> <TextBox x:Name="OutputNameBox" Grid.Row="3" Grid.ColumnSpan="2" Text="amigadb_intro" ToolTip="Output filename prefix"/>
</Grid> </Grid>
</GroupBox> </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"> <GroupBox Header="Video encoding">
<Grid> <Grid>
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions> <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
<Grid.RowDefinitions><RowDefinition/><RowDefinition/></Grid.RowDefinitions> <Grid.RowDefinitions><RowDefinition/><RowDefinition/></Grid.RowDefinitions>
<StackPanel Margin="0,0,8,8"> <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"> <ComboBox x:Name="EncoderBox" SelectedIndex="0">
<ComboBoxItem Content="Auto (NVENC → CPU fallback)" Tag="Auto"/> <ComboBoxItem Content="Auto (NVENC → CPU fallback)" Tag="Auto"/>
<ComboBoxItem Content="NVIDIA NVENC" Tag="NvidiaNvenc"/> <ComboBoxItem Content="NVIDIA NVENC" Tag="NvidiaNvenc"/>
@@ -80,10 +161,10 @@
</ComboBox> </ComboBox>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Margin="8,0,0,8"> <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"/> <TextBox Text="3840 × 2160 · 50 FPS" IsReadOnly="True"/>
</StackPanel> </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"/> Text="NVENC: preset P6 / CQ 16 · CPU: slow / CRF 13"/>
</Grid> </Grid>
</GroupBox> </GroupBox>
@@ -93,7 +174,7 @@
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions> <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition/><RowDefinition/></Grid.RowDefinitions> <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition/><RowDefinition/></Grid.RowDefinitions>
<TextBlock Text="Optional override. Leave blank to use embedded FFmpeg and FFprobe." <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"/> <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"/> <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"/> <TextBox x:Name="FfprobePathBox" Grid.Row="2" Margin="0,0,8,0" ToolTip="Path to ffprobe.exe"/>
@@ -103,58 +184,21 @@
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"> <Grid Grid.Row="2" Margin="0,14,0,0">
<StackPanel> <Grid.ColumnDefinitions>
<GroupBox Header="Timing and screenshots"> <ColumnDefinition Width="*"/>
<Grid> <ColumnDefinition Width="Auto"/>
<Grid.ColumnDefinitions><ColumnDefinition/><ColumnDefinition/></Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Grid.RowDefinitions><RowDefinition/><RowDefinition/></Grid.RowDefinitions> <TextBlock Text="Advanced settings are saved automatically." Foreground="{DynamicResource MutedBrush}" VerticalAlignment="Center"/>
<StackPanel Margin="0,0,8,10"> <StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
<TextBlock Text="Trim start (seconds)" Foreground="{StaticResource MutedBrush}"/> <Button Content="Close" Click="CloseSettings_Click" MinWidth="140" Margin="0,0,10,0"/>
<TextBox x:Name="TrimBox" Text="4.414"/> <Button Content="Done" Style="{StaticResource PrimaryButton}" Click="CloseSettings_Click" MinWidth="140"/>
</StackPanel>
<StackPanel Grid.Column="1" Margin="8,0,0,10">
<TextBlock Text="Fade (seconds)" Foreground="{StaticResource MutedBrush}"/>
<TextBox x:Name="FadeBox" Text="3"/>
</StackPanel>
<StackPanel Grid.Row="1" Margin="0,0,8,0">
<TextBlock Text="End-card hold (seconds)" Foreground="{StaticResource MutedBrush}"/>
<TextBox x:Name="HoldBox" Text="4"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Margin="8,0,0,0">
<TextBlock Text="Thumbnail interval (seconds)" Foreground="{StaticResource MutedBrush}"/>
<TextBox x:Name="IntervalBox" Text="10"/>
</StackPanel> </StackPanel>
</Grid> </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> </Grid>
</Border>
<DockPanel Grid.Row="2" Margin="0,18,0,0"> </Grid>
<Button x:Name="OpenOutputButton" Content="Open output folder" Click="OpenOutput_Click" IsEnabled="False"/> </Border>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> </Grid>
<Button x:Name="CancelButton" Content="Cancel" Click="Cancel_Click" IsEnabled="False" Margin="0,0,10,0"/>
<Button x:Name="RenderButton" Content="Start render" Style="{StaticResource PrimaryButton}" Click="Render_Click"/>
</StackPanel>
</DockPanel>
</Grid> </Grid>
</Window> </Window>
+35 -3
View File
@@ -26,6 +26,11 @@ public partial class MainWindow : Window
EndCardBox.Text = _loadedSettings.EndCardPath; EndCardBox.Text = _loadedSettings.EndCardPath;
FfmpegPathBox.Text = _loadedSettings.FfmpegPath; FfmpegPathBox.Text = _loadedSettings.FfmpegPath;
FfprobePathBox.Text = _loadedSettings.FfprobePath; 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); SelectTheme(_loadedSettings.Theme);
ApplyTheme(_loadedSettings.Theme); ApplyTheme(_loadedSettings.Theme);
Closing += (_, _) => SaveSettings(); Closing += (_, _) => SaveSettings();
@@ -65,6 +70,14 @@ public partial class MainWindow : Window
if (dialog.ShowDialog(this) == true) FfprobePathBox.Text = dialog.FileName; 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) private async void Render_Click(object sender, RoutedEventArgs e)
{ {
try try
@@ -174,10 +187,16 @@ public partial class MainWindow : Window
EndCardBox.Text.Trim(), EndCardBox.Text.Trim(),
FfmpegPathBox.Text.Trim(), FfmpegPathBox.Text.Trim(),
FfprobePathBox.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 SelectedTheme() => ((ComboBoxItem)ThemeBox.SelectedItem).Tag!.ToString()!;
private string SelectedEncoder() => ((ComboBoxItem)EncoderBox.SelectedItem).Tag!.ToString()!;
private void SelectTheme(string theme) private void SelectTheme(string theme)
{ {
@@ -193,6 +212,20 @@ public partial class MainWindow : Window
ThemeBox.SelectedIndex = 0; 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) private void ApplyTheme(string theme)
{ {
bool light = string.Equals(theme, "Light", StringComparison.OrdinalIgnoreCase); bool light = string.Equals(theme, "Light", StringComparison.OrdinalIgnoreCase);
@@ -217,7 +250,6 @@ public partial class MainWindow : Window
private void SetBrush(string key, string color) private void SetBrush(string key, string color)
{ {
if (Application.Current.Resources[key] is SolidColorBrush brush) Application.Current.Resources[key] = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color));
brush.Color = (Color)ColorConverter.ConvertFromString(color);
} }
} }
+29 -2
View File
@@ -5,7 +5,34 @@ public sealed record AppSettings(
string EndCardPath, string EndCardPath,
string FfmpegPath, string FfmpegPath,
string FfprobePath, 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; - PNG/JPG screenshots at a configurable interval;
- animated WebP screenshot preview; - animated WebP screenshot preview;
- optional custom paths for `ffmpeg.exe` and `ffprobe.exe`; - 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; - non-fatal missing thumbnails;
- live FFmpeg log, progress and cancellation; - live FFmpeg log, progress and cancellation;
- one-file Windows publishing with embedded FFmpeg and FFprobe. - 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 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 at external `ffmpeg.exe` and `ffprobe.exe` files. Those paths are saved in the
user settings file under `%LOCALAPPDATA%\AmiReel\settings.json`. 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 public static class AppSettingsStore
{ {
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private static string DefaultOutputFolder => Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
public static AppSettings Load() public static AppSettings Load()
{ {
string path = GetSettingsPath(); string path = GetSettingsPath();
if (!File.Exists(path)) if (!File.Exists(path))
return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos)); return AppSettings.Default(DefaultOutputFolder);
try try
{ {
string json = File.ReadAllText(path); string json = File.ReadAllText(path);
return JsonSerializer.Deserialize<AppSettings>(json, JsonOptions) return (JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos)); ?? AppSettings.Default(DefaultOutputFolder))
.Normalize(DefaultOutputFolder);
} }
catch 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 partial class ProcessRunner
{ {
public sealed record ProcessOutput(string Line, TimeSpan? Time, double? FramesPerSecond, double? Speed, int? Frame);
public async Task<string> RunAsync( public async Task<string> RunAsync(
string executable, string executable,
IEnumerable<string> arguments, IEnumerable<string> arguments,
Action<string>? log, Action<string>? log,
Action<TimeSpan>? position, Action<TimeSpan>? position,
CancellationToken token, CancellationToken token,
bool allowFailure = false) bool allowFailure = false,
Action<ProcessOutput>? outputHandler = null)
{ {
ProcessStartInfo start = new() ProcessStartInfo start = new()
{ {
@@ -33,8 +36,8 @@ public sealed partial class ProcessRunner
StringBuilder output = new(); StringBuilder output = new();
process.Start(); process.Start();
Task stdout = PumpAsync(process.StandardOutput, output, log, position, token); Task stdout = PumpAsync(process.StandardOutput, output, log, position, token, outputHandler);
Task stderr = PumpAsync(process.StandardError, output, log, position, token); Task stderr = PumpAsync(process.StandardError, output, log, position, token, outputHandler);
using CancellationTokenRegistration registration = token.Register(() => using CancellationTokenRegistration registration = token.Register(() =>
{ {
try { if (!process.HasExited) process.Kill(true); } catch { } try { if (!process.HasExited) process.Kill(true); } catch { }
@@ -48,18 +51,97 @@ public sealed partial class ProcessRunner
private static async Task PumpAsync( private static async Task PumpAsync(
StreamReader reader, StringBuilder output, Action<string>? log, 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) while (await reader.ReadLineAsync(token) is { } line)
{ {
output.AppendLine(line); output.AppendLine(line);
ProcessOutput parsed = ParseOutput(line);
if (!IsNoise(parsed))
log?.Invoke(line); log?.Invoke(line);
Match match = TimeRegex().Match(line); if (parsed.Time is { } time)
if (match.Success && TimeSpan.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan time))
position?.Invoke(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)] [GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
private static partial Regex TimeRegex(); 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 public sealed class RenderPipeline
{ {
private readonly ProcessRunner _runner = new(); private readonly ProcessRunner _runner = new();
private readonly MediaProbe _probe;
public RenderPipeline()
{
_probe = new MediaProbe(_runner, null);
}
public async Task RenderAsync( public async Task RenderAsync(
RenderSettings settings, RenderSettings settings,
@@ -17,6 +23,8 @@ public sealed class RenderPipeline
{ {
Validate(settings); Validate(settings);
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token); 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); Directory.CreateDirectory(settings.OutputDirectory);
string workDir = Path.Combine(Path.GetTempPath(), "AmiReel", Guid.NewGuid().ToString("N")); string workDir = Path.Combine(Path.GetTempPath(), "AmiReel", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir); Directory.CreateDirectory(workDir);
@@ -32,6 +40,7 @@ public sealed class RenderPipeline
string concat = Path.Combine(workDir, "inputs.txt"); string concat = Path.Combine(workDir, "inputs.txt");
await File.WriteAllLinesAsync(concat, settings.InputFiles.Select(f => $"file '{EscapeConcat(f)}'"), token); await File.WriteAllLinesAsync(concat, settings.InputFiles.Select(f => $"file '{EscapeConcat(f)}'"), token);
string encoder = await SelectEncoderAsync(tools, settings.Encoder, log, 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")); progress.Report(new(2, "Joining recordings", "Creating the 4K 50 FPS intermediate video"));
List<string> joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat, List<string> joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat,
@@ -39,9 +48,9 @@ public sealed class RenderPipeline
"-r", settings.FramesPerSecond.ToString(CultureInfo.InvariantCulture)]; "-r", settings.FramesPerSecond.ToString(CultureInfo.InvariantCulture)];
joinArgs.AddRange(EncoderArguments(encoder)); joinArgs.AddRange(EncoderArguments(encoder));
joinArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", main]); 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; double trimmedDuration = originalDuration - settings.TrimStart;
if (trimmedDuration <= 0) if (trimmedDuration <= 0)
throw new InvalidOperationException("Trim start is beyond the end of the video."); 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 fadeStart = Math.Max(0, trimmedDuration - settings.FadeSeconds);
double finalDuration = trimmedDuration + settings.EndCardHoldSeconds; double finalDuration = trimmedDuration + settings.EndCardHoldSeconds;
double cardDuration = finalDuration + 2; 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")); progress.Report(new(60, "Final render", "Applying trim, fade and AmiReel end card"));
List<string> finalArgs = ["-y", "-ss", F(settings.TrimStart), "-i", main, 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()]); "-map", "[v]", "-map", "[a]", "-r", settings.FramesPerSecond.ToString()]);
finalArgs.AddRange(EncoderArguments(encoder)); finalArgs.AddRange(EncoderArguments(encoder));
finalArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", "-movflags", "+faststart", final]); 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)); progress.Report(new(100, "Complete", final));
} }
finally finally
@@ -127,6 +136,73 @@ public sealed class RenderPipeline
return "x264"; 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" private static IEnumerable<string> EncoderArguments(string encoder) => encoder == "nvenc"
? ["-c:v", "h264_nvenc", "-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", "16", "-b:v", "0", ? ["-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", "-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, 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) if (duration is not > 0 || output.Time is not { } time)
progress.Report(new(from + Math.Min(1, time.TotalSeconds / duration.Value) * (to - from), "Rendering", time.ToString(@"hh\:mm\:ss"))); return;
}, token);
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, TimeSpan total = TimeSpan.FromSeconds(durationSeconds);
["-v", "error", "-show_entries", "format=duration", "-of", "default=nk=1:nw=1", input], List<string> parts = [$"{current:hh\\:mm\\:ss} / {total:hh\\:mm\\:ss}"];
null, null, token);
return double.Parse(output.Trim(), CultureInfo.InvariantCulture);
}
private async Task<bool> HasAudioAsync(string ffprobe, string input, CancellationToken token) if (fps is > 0)
{ parts.Add($"{fps:0.#} fps");
string output = await _runner.RunAsync(ffprobe, if (speed is > 0)
["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", input], parts.Add($"{speed:0.##}x");
null, null, token); if (frame is > 0)
return !string.IsNullOrWhiteSpace(output); parts.Add($"frame {frame.Value:N0}");
return string.Join(" · ", parts);
} }
private static string BuildFinalFilter(RenderSettings s, double trimmed, double fadeStart, double finalDuration, bool hasAudio) 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); 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) private static async Task<ToolPaths> ExtractAsync(CancellationToken cancellationToken = default)
{ {
string toolDir = Path.Combine( string toolDir = Path.Combine(
@@ -66,4 +78,26 @@ public static class ToolExtractor
byte[] bHash = sha.ComputeHash(b); byte[] bHash = sha.ComputeHash(b);
return aHash.AsSpan().SequenceEqual(bHash); 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' $ffmpeg = Join-Path $projectDir 'ThirdParty\ffmpeg.exe'
$ffprobe = Join-Path $projectDir 'ThirdParty\ffprobe.exe' $ffprobe = Join-Path $projectDir 'ThirdParty\ffprobe.exe'
$releaseExe = Join-Path $projectDir 'bin\Release\net8.0-windows\win-x64\AmiReel.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)) { if (-not (Test-Path $ffmpeg) -or -not (Test-Path $ffprobe)) {
throw 'Add ffmpeg.exe and ffprobe.exe to ThirdParty before publishing.' 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 | $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) { 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 $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') ` dotnet publish (Join-Path $projectDir 'AmigaDB.VideoRenderer.csproj') `
-c Release ` -c Release `
-r win-x64 ` -r win-x64 `