Sostituzione FFMpegCore con FFMediaToolkit
- Rimosso FFMpegCore e introdotto FFMediaToolkit per semplificare la gestione dei video e migliorare le prestazioni. - Aggiunti `FrameExtractor` e `VideoAnalyzer` per l'estrazione dei frame e l'analisi dei metadati video. - Riprogettata l'interfaccia utente con tema moderno e navigazione a schede (Processing, Library, Settings). - Integrate le impostazioni nella scheda "Settings", eliminando la finestra legacy. - Aggiornato `VideoProcessingService` per utilizzare i nuovi wrapper e migliorata la gestione della coda. - Tradotti i testi dell'interfaccia dall'italiano all'inglese. - Aggiornata la documentazione (`README.md`) con dettagli sulle funzionalità, lo stack tecnologico e la struttura del progetto. - Ottimizzate le prestazioni e migliorata la gestione degli errori. - Aggiunto supporto per nuovi formati video (FLV, WebM). - Rimossi codice e risorse obsolete, migliorando la manutenibilità.
This commit is contained in:
@@ -13,9 +13,6 @@
|
||||
<setting name="LastVideoPath" serializeAs="String">
|
||||
<value />
|
||||
</setting>
|
||||
<setting name="FFmpegBinFolder" serializeAs="String">
|
||||
<value>C:\Users\balbo\source\repos\Ganimede\Ganimede\Ganimede\FFMpeg</value>
|
||||
</setting>
|
||||
</Ganimede.Properties.Settings>
|
||||
</userSettings>
|
||||
</configuration>
|
||||
@@ -7,10 +7,12 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
||||
<PackageReference Include="FFMediaToolkit" Version="4.8.1" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,48 +5,47 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:Ganimede"
|
||||
mc:Ignorable="d"
|
||||
Title="Estrattore Frame Video" Height="800" Width="1250"
|
||||
Background="#1E2228" WindowStartupLocation="CenterScreen">
|
||||
Title="Ganimede - Video Frame Extractor" Height="750" Width="1200"
|
||||
Background="#F5F7FA" WindowStartupLocation="CenterScreen">
|
||||
<Window.Resources>
|
||||
<local:StatusColorConverter x:Key="StatusColorConverter"/>
|
||||
|
||||
<!-- Color resources -->
|
||||
<Color x:Key="AccentColor">#268BFF</Color>
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/>
|
||||
<SolidColorBrush x:Key="AccentBrushLight" Color="#39A3FF"/>
|
||||
<SolidColorBrush x:Key="BaseBrush" Color="#1E2228"/>
|
||||
<SolidColorBrush x:Key="PanelBrush" Color="#242A31"/>
|
||||
<SolidColorBrush x:Key="PanelSubBrush" Color="#2C333B"/>
|
||||
<SolidColorBrush x:Key="BorderBrushColor" Color="#38424D"/>
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#B5BDC7"/>
|
||||
<!-- Modern Color Palette -->
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="#6366F1"/>
|
||||
<SolidColorBrush x:Key="PrimaryDarkBrush" Color="#4F46E5"/>
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#10B981"/>
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#EF4444"/>
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="#F59E0B"/>
|
||||
<SolidColorBrush x:Key="BackgroundBrush" Color="#F5F7FA"/>
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#E5E7EB"/>
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#111827"/>
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#6B7280"/>
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#9CA3AF"/>
|
||||
|
||||
<!-- Button Style -->
|
||||
<Style TargetType="Button" x:Key="ToolbarButton">
|
||||
<!-- Modern Button Style -->
|
||||
<Style TargetType="Button" x:Key="ModernButton">
|
||||
<Setter Property="Background" Value="{StaticResource PrimaryBrush}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="Background" Value="#2F3740"/>
|
||||
<Setter Property="BorderBrush" Value="#3F4A55"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="14 8"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="16,10"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinHeight" Value="40"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8" SnapsToDevicePixels="True">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
CornerRadius="8"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="#39444F"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter Property="Background" Value="#46525E"/>
|
||||
<Setter Property="Background" Value="{StaticResource PrimaryDarkBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.4"/>
|
||||
<Setter Property="Opacity" Value="0.5"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
@@ -54,59 +53,133 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Accent Button -->
|
||||
<Style TargetType="Button" x:Key="AccentButton" BasedOn="{StaticResource ToolbarButton}">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="#1673D5"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrushLight}"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- Danger Button -->
|
||||
<Style TargetType="Button" x:Key="DangerButton" BasedOn="{StaticResource ToolbarButton}">
|
||||
<Setter Property="Background" Value="#D9534F"/>
|
||||
<Setter Property="BorderBrush" Value="#B33E3B"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="#E36460"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- Flat small badge button -->
|
||||
<Style TargetType="Button" x:Key="SmallGhostButton" BasedOn="{StaticResource ToolbarButton}">
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Padding" Value="12 6"/>
|
||||
<Setter Property="MinHeight" Value="36"/>
|
||||
<Setter Property="Background" Value="#2F3740"/>
|
||||
</Style>
|
||||
|
||||
<!-- ProgressBar -->
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="Height" Value="6"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
|
||||
<Setter Property="Background" Value="#313941"/>
|
||||
<Style TargetType="Button" x:Key="OutlineButton" BasedOn="{StaticResource ModernButton}">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1.5"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ProgressBar">
|
||||
<Border Background="{TemplateBinding Background}" CornerRadius="3">
|
||||
<Grid x:Name="PART_Track">
|
||||
<Rectangle x:Name="PART_Indicator" Fill="{TemplateBinding Foreground}" RadiusX="3" RadiusY="3"/>
|
||||
</Grid>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="8"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="#F9FAFB"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Button" x:Key="DangerButton" BasedOn="{StaticResource ModernButton}">
|
||||
<Setter Property="Background" Value="{StaticResource DangerBrush}"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Button" x:Key="SuccessButton" BasedOn="{StaticResource ModernButton}">
|
||||
<Setter Property="Background" Value="{StaticResource SuccessBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Modern TabControl Style -->
|
||||
<Style TargetType="TabControl">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TabItem">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border Name="Border"
|
||||
Background="Transparent"
|
||||
BorderThickness="0,0,0,3"
|
||||
BorderBrush="Transparent"
|
||||
Padding="20,12"
|
||||
Margin="0,0,8,0">
|
||||
<ContentPresenter x:Name="ContentSite"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
ContentSource="Header"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource PrimaryBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource PrimaryBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="False">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="#F9FAFB"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Setter Property="FontSize" Value="15"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
</Style>
|
||||
|
||||
<!-- Modern Card Style -->
|
||||
<Style x:Key="Card" TargetType="Border">
|
||||
<Setter Property="Background" Value="{StaticResource SurfaceBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="12"/>
|
||||
<Setter Property="Padding" Value="20"/>
|
||||
<Setter Property="Effect">
|
||||
<Setter.Value>
|
||||
<DropShadowEffect Color="#000000" Opacity="0.05" BlurRadius="10" ShadowDepth="0"/>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Modern TextBox Style -->
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Background" Value="{StaticResource SurfaceBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1.5"/>
|
||||
<Setter Property="Padding" Value="12,8"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="8">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ScrollViewer styling for thin scrollbar -->
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Width" Value="10"/>
|
||||
<Setter Property="Background" Value="#20252B"/>
|
||||
<!-- Modern ProgressBar Style -->
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="Height" Value="8"/>
|
||||
<Setter Property="Background" Value="#E5E7EB"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource PrimaryBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ProgressBar">
|
||||
<Border Background="{TemplateBinding Background}" CornerRadius="4">
|
||||
<Rectangle Name="PART_Indicator"
|
||||
Fill="{TemplateBinding Foreground}"
|
||||
RadiusX="4" RadiusY="4"
|
||||
HorizontalAlignment="Left"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
@@ -117,81 +190,177 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- TOOLBAR -->
|
||||
<Border Background="#242A31" Padding="16 12" BorderBrush="#303840" BorderThickness="0,0,0,1">
|
||||
<DockPanel LastChildFill="False">
|
||||
<StackPanel Orientation="Horizontal" DockPanel.Dock="Left">
|
||||
<Button x:Name="BrowseVideoButton" Style="{StaticResource AccentButton}" Content="➕ Aggiungi Video" Click="BrowseVideoButton_Click" Margin="0,0,10,0"/>
|
||||
<Button x:Name="ImportFolderButton" Style="{StaticResource ToolbarButton}" Content="📁 Importa Cartella" Click="ImportFolderButton_Click" Margin="0,0,10,0"/>
|
||||
<Button x:Name="SelectOutputFolderButton" Style="{StaticResource ToolbarButton}" Content="🗂 Seleziona Cartella Output" Click="SelectOutputFolderButton_Click" Margin="0,0,10,0"/>
|
||||
<!-- Header -->
|
||||
<Border Background="{StaticResource SurfaceBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="24,16">
|
||||
<DockPanel>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="🎬" FontSize="28" Margin="0,0,12,0"/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="Ganimede"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="Video Frame Extractor"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right">
|
||||
<Button x:Name="ConfigureSelectedButton" Style="{StaticResource ToolbarButton}" Content="⚙ Configura Selezionati" Width="195" IsEnabled="False" Click="ConfigureSelectedButton_Click" Margin="0,0,10,0"/>
|
||||
<Button x:Name="StartQueueButton" Style="{StaticResource AccentButton}" Content="▶ Avvia Coda" Width="150" Click="StartQueueButton_Click" Margin="0,0,10,0"/>
|
||||
<Button x:Name="StopQueueButton" Style="{StaticResource DangerButton}" Content="⏹ Ferma" Width="110" IsEnabled="False" Click="StopQueueButton_Click" Margin="0,0,10,0"/>
|
||||
<Button x:Name="ClearCompletedButton" Style="{StaticResource SmallGhostButton}" Content="🧹 Pulisci Completati" Click="ClearCompletedButton_Click" Margin="0,0,10,0"/>
|
||||
<Button x:Name="ClearAllButton" Style="{StaticResource SmallGhostButton}" Content="🗑 Pulisci Tutto" Click="ClearAllButton_Click" Margin="0,0,10,0"/>
|
||||
<Button x:Name="SettingsButton" Style="{StaticResource SmallGhostButton}" Content="⚙ Impostazioni" Click="SettingsButton_Click"/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- CONTENUTO PRINCIPALE -->
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="370"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Coda -->
|
||||
<Grid Margin="18 12 8 12">
|
||||
<!-- Main Content with Tabs -->
|
||||
<TabControl Grid.Row="1" Margin="24,16,24,16">
|
||||
<!-- Processing Tab -->
|
||||
<TabItem Header="🎥 Processing">
|
||||
<Grid Margin="0,24,0,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel Orientation="Horizontal" Margin="0 0 0 10" VerticalAlignment="Center">
|
||||
<TextBlock Text="Coda Job" FontSize="18" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="QueueCountText" Text="(0)" Foreground="{StaticResource TextSecondaryBrush}" Margin="8,4,0,0"/>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,16">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Style="{StaticResource ModernButton}"
|
||||
Content="➕ Add Videos"
|
||||
Click="BrowseVideoButton_Click"
|
||||
Margin="0,0,12,0"/>
|
||||
<Button Style="{StaticResource OutlineButton}"
|
||||
Content="📁 Import Folder"
|
||||
Click="ImportFolderButton_Click"
|
||||
Margin="0,0,12,0"/>
|
||||
<Button Style="{StaticResource OutlineButton}"
|
||||
Content="⚙️ Configure Selected"
|
||||
x:Name="ConfigureSelectedButton"
|
||||
IsEnabled="False"
|
||||
Click="ConfigureSelectedButton_Click"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="1" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrushColor}" BorderThickness="1" CornerRadius="8" Padding="4">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button Style="{StaticResource SuccessButton}"
|
||||
Content="▶️ Start Queue"
|
||||
Width="140"
|
||||
x:Name="StartQueueButton"
|
||||
Click="StartQueueButton_Click"
|
||||
Margin="0,0,8,0"/>
|
||||
<Button Style="{StaticResource DangerButton}"
|
||||
Content="⏹️ Stop"
|
||||
Width="100"
|
||||
x:Name="StopQueueButton"
|
||||
IsEnabled="False"
|
||||
Click="StopQueueButton_Click"
|
||||
Margin="0,0,8,0"/>
|
||||
<Button Style="{StaticResource OutlineButton}"
|
||||
Content="🧹 Clear"
|
||||
Click="ClearCompletedButton_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Queue List -->
|
||||
<Border Grid.Row="1" Style="{StaticResource Card}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<DockPanel Margin="0,0,0,16">
|
||||
<TextBlock Text="Processing Queue"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="QueueCountText"
|
||||
Text="(0)"
|
||||
FontSize="16"
|
||||
Foreground="{StaticResource TextMutedBrush}"
|
||||
Margin="8,2,0,0"/>
|
||||
</DockPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl x:Name="QueueItemsControl">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{StaticResource PanelSubBrush}" Margin="6" Padding="10" CornerRadius="6" BorderBrush="#3A454F" BorderThickness="1">
|
||||
<Border Background="#F9FAFB"
|
||||
Margin="0,0,0,12"
|
||||
Padding="16"
|
||||
CornerRadius="8"
|
||||
BorderBrush="{StaticResource BorderBrush}"
|
||||
BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<CheckBox x:Name="JobCheckBox" Grid.Row="0" Grid.Column="0" Margin="0,0,10,0" VerticalAlignment="Center" Tag="{Binding}" Checked="JobCheckBox_CheckedChanged" Unchecked="JobCheckBox_CheckedChanged"/>
|
||||
<StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding VideoName}" Foreground="White" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" Margin="0,0,8,0"/>
|
||||
<TextBlock Text="{Binding Progress, StringFormat={}{0:0}%}" Foreground="{StaticResource TextSecondaryBrush}" FontSize="11"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Row="0" Grid.Column="2" Content="✕" Width="30" Height="26" Style="{StaticResource SmallGhostButton}" Tag="{Binding}" Click="RemoveQueueItem_Click" ToolTip="Rimuovi"/>
|
||||
<CheckBox x:Name="JobCheckBox"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0"
|
||||
Tag="{Binding}"
|
||||
Checked="JobCheckBox_CheckedChanged"
|
||||
Unchecked="JobCheckBox_CheckedChanged"/>
|
||||
|
||||
<ProgressBar Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,8,0,0" Value="{Binding Progress}"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Text="{Binding StatusMessage}" Foreground="{StaticResource TextSecondaryBrush}" FontSize="11" Margin="0,6,0,0" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="3" FontSize="10" Foreground="#87939F" Margin="0,8,0,0" TextWrapping="Wrap">
|
||||
<TextBlock.Text>
|
||||
<MultiBinding StringFormat="{}📁 {0} 📐 {1} 🔄 {2} 🏷 {3} 🎯 {4}">
|
||||
<Binding Path="OutputFolderDisplay"/>
|
||||
<Binding Path="FrameSizeDisplay"/>
|
||||
<Binding Path="OverwriteModeDisplay"/>
|
||||
<Binding Path="NamingPatternDisplay"/>
|
||||
<Binding Path="ExtractionModeDisplay"/>
|
||||
</MultiBinding>
|
||||
</TextBlock.Text>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding VideoName}"
|
||||
FontWeight="Medium"
|
||||
FontSize="15"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
FontSize="13"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding Progress, StringFormat={}{0:0}%}"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource PrimaryBrush}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,16,0"/>
|
||||
|
||||
<Button Grid.Column="3"
|
||||
Content="✕"
|
||||
Width="32"
|
||||
Height="32"
|
||||
FontSize="16"
|
||||
Style="{StaticResource OutlineButton}"
|
||||
Padding="0"
|
||||
Tag="{Binding}"
|
||||
Click="RemoveQueueItem_Click"/>
|
||||
</Grid>
|
||||
|
||||
<ProgressBar Grid.Row="1"
|
||||
Value="{Binding Progress}"
|
||||
Margin="0,12,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="2"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource TextMutedBrush}"
|
||||
Margin="0,8,0,0">
|
||||
<Run Text="Mode:"/>
|
||||
<Run Text="{Binding ExtractionModeDisplay}" FontWeight="Medium"/>
|
||||
<Run Text=" • Output:"/>
|
||||
<Run Text="{Binding OutputFolderDisplay}" FontWeight="Medium"/>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -199,50 +368,196 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Pannello destro -->
|
||||
<StackPanel Grid.Column="1" Margin="8 12 18 12">
|
||||
<Border Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrushColor}" BorderThickness="1" CornerRadius="8" Padding="14">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Impostazioni Globali" FontSize="16" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="Cartella Output" Foreground="{StaticResource TextSecondaryBrush}" FontSize="12" Margin="0,10,0,2"/>
|
||||
<DockPanel LastChildFill="True">
|
||||
<TextBox x:Name="GlobalOutputFolderTextBox" Height="34" Margin="0,0,10,0" IsReadOnly="True" Background="#2C333B" BorderBrush="#3A434C" Foreground="White" BorderThickness="1"/>
|
||||
<Button Content="Sfoglia" Width="80" Style="{StaticResource SmallGhostButton}" Click="SelectOutputFolderButton_Click"/>
|
||||
<!-- Library Tab -->
|
||||
<TabItem Header="📚 Library">
|
||||
<Grid Margin="0,24,0,0">
|
||||
<Border Style="{StaticResource Card}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="Output Preview"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
Margin="0,0,0,8"/>
|
||||
|
||||
<DockPanel Grid.Row="1" Margin="0,0,0,16">
|
||||
<TextBlock Text="Output Folder:"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0"/>
|
||||
<Button DockPanel.Dock="Right"
|
||||
Content="Browse"
|
||||
Style="{StaticResource OutlineButton}"
|
||||
Padding="12,6"
|
||||
Click="SelectOutputFolderButton_Click"
|
||||
Margin="12,0,0,0"/>
|
||||
<TextBox x:Name="GlobalOutputFolderTextBox"
|
||||
IsReadOnly="True"
|
||||
VerticalContentAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Anteprime (Thumbnails)" Foreground="{StaticResource TextSecondaryBrush}" FontSize="12" Margin="0,14,0,4"/>
|
||||
<Border Background="{StaticResource PanelSubBrush}" BorderBrush="#3A454F" BorderThickness="1" CornerRadius="6" Padding="6" Height="260">
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Background="#F9FAFB"
|
||||
BorderBrush="{StaticResource BorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="12">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl x:Name="ThumbnailsPanel">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel IsItemsHost="True"/>
|
||||
<WrapPanel/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Margin="4" BorderBrush="#3D4853" BorderThickness="1" CornerRadius="4">
|
||||
<Image Source="{Binding}" Width="90" Height="52" Stretch="UniformToFill"/>
|
||||
<Border Margin="6"
|
||||
BorderBrush="{StaticResource BorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="6"
|
||||
Background="White">
|
||||
<Image Source="{Binding}"
|
||||
Width="140"
|
||||
Height="80"
|
||||
Stretch="UniformToFill"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<TabItem Header="⚙️ Settings">
|
||||
<ScrollViewer Margin="0,24,0,0" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel MaxWidth="800">
|
||||
<!-- Frame Settings Card -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Frame Settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
Margin="0,0,0,16"/>
|
||||
|
||||
<TextBlock Text="Default Frame Size"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
Margin="0,0,0,8"/>
|
||||
<ComboBox x:Name="FrameSizeComboBox"
|
||||
Height="42"
|
||||
FontSize="14"
|
||||
Margin="0,0,0,16">
|
||||
<ComboBoxItem Content="Original Size" Tag="original" IsSelected="True"/>
|
||||
<ComboBoxItem Content="320x180 (Fast)" Tag="320,180"/>
|
||||
<ComboBoxItem Content="640x360 (Medium)" Tag="640,360"/>
|
||||
<ComboBoxItem Content="1280x720 (HD)" Tag="1280,720"/>
|
||||
<ComboBoxItem Content="1920x1080 (Full HD)" Tag="1920,1080"/>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Text="Extraction Mode"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
Margin="0,0,0,8"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<RadioButton x:Name="DefaultModeFullRadio"
|
||||
Content="Full Extraction"
|
||||
GroupName="DefExtraction"
|
||||
IsChecked="True"
|
||||
Margin="0,0,24,0"/>
|
||||
<RadioButton x:Name="DefaultModeSingleRadio"
|
||||
Content="Single Frame"
|
||||
GroupName="DefExtraction"
|
||||
Margin="0,0,24,0"/>
|
||||
<RadioButton x:Name="DefaultModeAutoRadio"
|
||||
Content="Auto Detect"
|
||||
GroupName="DefExtraction"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="Auto mode analyzes the video and decides the best extraction method."
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource TextMutedBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- BARRA STATO -->
|
||||
<Border Grid.Row="2" Background="#242A31" BorderBrush="#303840" BorderThickness="1,1,1,0" Padding="16 6" CornerRadius="6 6 0 0" Margin="14 0 14 14">
|
||||
<!-- Output Settings Card -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Output Settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
Margin="0,0,0,16"/>
|
||||
|
||||
<CheckBox x:Name="CreateSubfolderCheckBox"
|
||||
Content="Create subfolder for each video"
|
||||
IsChecked="True"
|
||||
Margin="0,0,0,16"/>
|
||||
|
||||
<CheckBox x:Name="SingleFrameUseSubfolderCheckBox"
|
||||
Content="Use subfolder for single frame extraction"
|
||||
Margin="0,0,0,16"/>
|
||||
|
||||
<TextBlock Text="Overwrite Behavior"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
Margin="0,0,0,8"/>
|
||||
<ComboBox x:Name="OverwriteModeComboBox"
|
||||
Height="42"
|
||||
FontSize="14">
|
||||
<ComboBoxItem Content="Ask before overwrite" Tag="Ask" IsSelected="True"/>
|
||||
<ComboBoxItem Content="Skip existing files" Tag="Skip"/>
|
||||
<ComboBoxItem Content="Overwrite existing files" Tag="Overwrite"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="Save Settings"
|
||||
Style="{StaticResource ModernButton}"
|
||||
Width="140"
|
||||
Click="SaveSettings_Click"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
|
||||
<!-- Footer Status Bar -->
|
||||
<Border Grid.Row="2"
|
||||
Background="{StaticResource SurfaceBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="24,12">
|
||||
<DockPanel>
|
||||
<TextBlock x:Name="StatusText" Foreground="{StaticResource TextSecondaryBrush}" FontSize="13" VerticalAlignment="Center" Text="Pronto"/>
|
||||
<TextBlock Text=" | " Foreground="#55606B" Margin="6,0"/>
|
||||
<TextBlock Text="Job:" Foreground="#77818B" Margin="0,0,4,0"/>
|
||||
<TextBlock x:Name="JobsSummaryText" Foreground="#4F5962" FontSize="11" VerticalAlignment="Center"/>
|
||||
<StackPanel Orientation="Horizontal" DockPanel.Dock="Left">
|
||||
<TextBlock Text="●"
|
||||
Foreground="{StaticResource SuccessBrush}"
|
||||
FontSize="16"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBlock x:Name="StatusText"
|
||||
Text="Ready"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
FontSize="13"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock x:Name="JobsSummaryText"
|
||||
DockPanel.Dock="Right"
|
||||
Foreground="{StaticResource TextMutedBrush}"
|
||||
FontSize="12"
|
||||
HorizontalAlignment="Right"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -8,7 +8,6 @@ using System.Windows.Media.Imaging;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using FFMpegCore;
|
||||
using Ganimede.Properties;
|
||||
using Ganimede.Services;
|
||||
using Ganimede.Models;
|
||||
@@ -30,7 +29,6 @@ namespace Ganimede
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeUI();
|
||||
ConfigureFFMpeg();
|
||||
}
|
||||
|
||||
private void InitializeUI()
|
||||
@@ -51,9 +49,67 @@ namespace Ganimede
|
||||
_processingService.ProcessingStopped += OnProcessingStopped;
|
||||
_processingService.JobQueue.CollectionChanged += (s, e) => UpdateQueueCount();
|
||||
|
||||
// Initialize settings controls
|
||||
LoadSettingsControls();
|
||||
|
||||
UpdateQueueCount();
|
||||
}
|
||||
|
||||
private void LoadSettingsControls()
|
||||
{
|
||||
// Controls are loaded after InitializeComponent, so we can't access them in constructor
|
||||
// We'll load settings when the Settings tab is first accessed instead
|
||||
Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load frame size
|
||||
var frameSize = Settings.Default.FrameSize;
|
||||
foreach (ComboBoxItem item in FrameSizeComboBox.Items)
|
||||
{
|
||||
if (item.Tag?.ToString() == frameSize)
|
||||
{
|
||||
FrameSizeComboBox.SelectedItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Load overwrite mode
|
||||
var overwriteMode = Settings.Default.DefaultOverwriteMode;
|
||||
foreach (ComboBoxItem item in OverwriteModeComboBox.Items)
|
||||
{
|
||||
if (item.Tag?.ToString() == overwriteMode)
|
||||
{
|
||||
OverwriteModeComboBox.SelectedItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Load extraction mode
|
||||
switch (Settings.Default.DefaultExtractionMode)
|
||||
{
|
||||
case "SingleFrame":
|
||||
DefaultModeSingleRadio.IsChecked = true;
|
||||
break;
|
||||
case "Auto":
|
||||
DefaultModeAutoRadio.IsChecked = true;
|
||||
break;
|
||||
default:
|
||||
DefaultModeFullRadio.IsChecked = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Load folder settings
|
||||
CreateSubfolderCheckBox.IsChecked = Settings.Default.CreateSubfolder;
|
||||
SingleFrameUseSubfolderCheckBox.IsChecked = Settings.Default.SingleFrameUseSubfolder;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[ERROR] Failed to load settings controls: {ex.Message}");
|
||||
}
|
||||
}, System.Windows.Threading.DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private void UpdateQueueCount()
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
@@ -71,7 +127,7 @@ namespace Ganimede
|
||||
var failed = _processingService.JobQueue.Count(j => j.Status == JobStatus.Failed);
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
JobsSummaryText.Text = $"In attesa: {pending} | In corso: {processing} | Completati: {completed} | Falliti: {failed}";
|
||||
JobsSummaryText.Text = $"Pending: {pending} | Processing: {processing} | Completed: {completed} | Failed: {failed}";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,7 +137,7 @@ namespace Ganimede
|
||||
{
|
||||
StartQueueButton.IsEnabled = false;
|
||||
StopQueueButton.IsEnabled = true;
|
||||
StatusText.Text = "Elaborazione coda...";
|
||||
StatusText.Text = "Processing queue...";
|
||||
UpdateJobsSummary();
|
||||
});
|
||||
}
|
||||
@@ -92,7 +148,7 @@ namespace Ganimede
|
||||
{
|
||||
StartQueueButton.IsEnabled = true;
|
||||
StopQueueButton.IsEnabled = false;
|
||||
StatusText.Text = "Coda fermata";
|
||||
StatusText.Text = "Queue stopped";
|
||||
UpdateJobsSummary();
|
||||
});
|
||||
}
|
||||
@@ -101,7 +157,7 @@ namespace Ganimede
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
StatusText.Text = $"✓ Completato: {job.VideoName}";
|
||||
StatusText.Text = $"✓ Completed: {job.VideoName}";
|
||||
LoadThumbnailsFromFolder(job.OutputFolder);
|
||||
UpdateJobsSummary();
|
||||
});
|
||||
@@ -111,7 +167,7 @@ namespace Ganimede
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
StatusText.Text = $"✗ Fallito: {job.VideoName}";
|
||||
StatusText.Text = $"✗ Failed: {job.VideoName}";
|
||||
UpdateJobsSummary();
|
||||
});
|
||||
}
|
||||
@@ -139,122 +195,16 @@ namespace Ganimede
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureFFMpeg()
|
||||
{
|
||||
var ffmpegBin = Settings.Default.FFmpegBinFolder;
|
||||
if (!string.IsNullOrEmpty(ffmpegBin) && ValidateFFMpegBinaries(ffmpegBin))
|
||||
FFMpegCore.GlobalFFOptions.Configure(o => o.BinaryFolder = ffmpegBin);
|
||||
else if (TryUseSystemFFMpeg()) { }
|
||||
else if (TryFixMissingFFMpeg(ffmpegBin))
|
||||
FFMpegCore.GlobalFFOptions.Configure(o => o.BinaryFolder = ffmpegBin);
|
||||
}
|
||||
|
||||
private bool ValidateFFMpegBinaries(string binFolder) =>
|
||||
Directory.Exists(binFolder) &&
|
||||
File.Exists(Path.Combine(binFolder, "ffmpeg.exe")) &&
|
||||
File.Exists(Path.Combine(binFolder, "ffprobe.exe"));
|
||||
|
||||
private bool TryUseSystemFFMpeg()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo { FileName = "ffmpeg", Arguments = "-version", UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true };
|
||||
using var p = Process.Start(psi);
|
||||
return p != null && p.WaitForExit(4000) && p.ExitCode == 0;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private bool TryFixMissingFFMpeg(string binFolder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(binFolder) || !Directory.Exists(binFolder)) return false;
|
||||
var ffmpegPath = Path.Combine(binFolder, "ffmpeg.exe");
|
||||
var ffprobePath = Path.Combine(binFolder, "ffprobe.exe");
|
||||
if (!File.Exists(ffmpegPath) && File.Exists(ffprobePath))
|
||||
{
|
||||
try { File.Copy(ffprobePath, ffmpegPath, true); return File.Exists(ffmpegPath); } catch { return false; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void BrowseVideoButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new WpfOpenFileDialog { Filter = "Video (*.mp4;*.avi;*.mov;*.mkv;*.wmv)|*.mp4;*.avi;*.mov;*.mkv;*.wmv|Tutti i file (*.*)|*.*", Multiselect = true };
|
||||
if (dialog.ShowDialog() == true) AddVideosToQueue(dialog.FileNames);
|
||||
}
|
||||
|
||||
private void ImportFolderButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
using var dialog = new System.Windows.Forms.FolderBrowserDialog { Description = "Seleziona la cartella con i video", ShowNewFolderButton = false };
|
||||
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
{
|
||||
try
|
||||
{
|
||||
var files = Directory.EnumerateFiles(dialog.SelectedPath, "*.*", SearchOption.TopDirectoryOnly).Where(IsVideoFile).ToArray();
|
||||
if (files.Length == 0)
|
||||
{
|
||||
WpfMessageBox.Show("Nessun file video valido trovato.", "Importa Cartella", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
AddVideosToQueue(files);
|
||||
StatusText.Text = $"Importati {files.Length} video.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WpfMessageBox.Show($"Errore: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddVideosToQueue(string[] paths)
|
||||
{
|
||||
if (string.IsNullOrEmpty(outputFolder))
|
||||
{
|
||||
WpfMessageBox.Show("Seleziona prima una cartella di output.", "Cartella Output Richiesta", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
var createSub = Settings.Default.CreateSubfolder;
|
||||
foreach (var p in paths) _processingService.AddJob(p, outputFolder, createSub);
|
||||
StatusText.Text = $"Aggiunti {paths.Length} video (In attesa)";
|
||||
Settings.Default.LastVideoPath = paths.FirstOrDefault();
|
||||
Settings.Default.Save();
|
||||
UpdateQueueCount();
|
||||
}
|
||||
|
||||
private void SelectOutputFolderButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
using var dialog = new System.Windows.Forms.FolderBrowserDialog();
|
||||
if (!string.IsNullOrEmpty(outputFolder)) dialog.SelectedPath = outputFolder;
|
||||
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
{
|
||||
outputFolder = dialog.SelectedPath;
|
||||
GlobalOutputFolderTextBox.Text = outputFolder;
|
||||
StatusText.Text = "Cartella output aggiornata";
|
||||
Settings.Default.LastOutputFolder = outputFolder;
|
||||
Settings.Default.Save();
|
||||
}
|
||||
}
|
||||
|
||||
private void SettingsButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var win = new SettingsWindow { Owner = this };
|
||||
if (win.ShowDialog() == true)
|
||||
{
|
||||
ConfigureFFMpeg();
|
||||
StatusText.Text = "Impostazioni aggiornate";
|
||||
}
|
||||
}
|
||||
|
||||
private async void StartQueueButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_processingService.JobQueue.Count == 0)
|
||||
{
|
||||
WpfMessageBox.Show("Nessun video in coda.", "Coda Vuota", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
WpfMessageBox.Show("No videos in queue.", "Empty Queue", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
if (_processingService.JobQueue.All(j => j.Status != JobStatus.Pending))
|
||||
{
|
||||
WpfMessageBox.Show("Nessun job in stato In attesa.", "Nessun Job", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
WpfMessageBox.Show("No pending jobs in queue.", "No Jobs", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
await _processingService.StartProcessingAsync();
|
||||
@@ -264,7 +214,7 @@ namespace Ganimede
|
||||
private void StopQueueButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_processingService.StopProcessing();
|
||||
StatusText.Text = "Arresto in corso...";
|
||||
StatusText.Text = "Stopping...";
|
||||
UpdateJobsSummary();
|
||||
}
|
||||
|
||||
@@ -281,15 +231,24 @@ namespace Ganimede
|
||||
private void ConfigureSelectedButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selectedJobs.Count == 0) return;
|
||||
|
||||
var cfg = new JobConfigWindow(_selectedJobs.ToList()) { Owner = this };
|
||||
if (cfg.ShowDialog() == true)
|
||||
{
|
||||
StatusText.Text = $"Configurazione applicata a {_selectedJobs.Count} job";
|
||||
StatusText.Text = $"Configuration applied to {_selectedJobs.Count} job(s)";
|
||||
|
||||
// Update output folders only if outputFolder is set
|
||||
if (!string.IsNullOrEmpty(outputFolder))
|
||||
{
|
||||
foreach (var job in _selectedJobs.Where(j => string.IsNullOrEmpty(j.CustomOutputFolder)))
|
||||
{
|
||||
var createSub = Settings.Default.CreateSubfolder;
|
||||
job.OutputFolder = job.ExtractionMode == ExtractionMode.SingleFrame ? outputFolder : (createSub ? Path.Combine(outputFolder, job.VideoName) : outputFolder);
|
||||
job.OutputFolder = job.ExtractionMode == ExtractionMode.SingleFrame
|
||||
? outputFolder
|
||||
: (createSub ? Path.Combine(outputFolder, job.VideoName) : outputFolder);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateJobsSummary();
|
||||
}
|
||||
}
|
||||
@@ -308,7 +267,7 @@ namespace Ganimede
|
||||
private void ClearCompletedButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_processingService.RemoveCompletedJobs();
|
||||
StatusText.Text = "Job completati rimossi";
|
||||
StatusText.Text = "Completed jobs removed";
|
||||
UpdateQueueCount();
|
||||
}
|
||||
|
||||
@@ -317,7 +276,7 @@ namespace Ganimede
|
||||
var processing = _processingService.JobQueue.Any(j => j.Status == JobStatus.Processing);
|
||||
if (processing)
|
||||
{
|
||||
var res = WpfMessageBox.Show("Ci sono job in elaborazione.\n\nSi: Ferma e svuota la coda\nNo: Rimuovi solo job non in elaborazione\nAnnulla: Annulla", "Conferma", MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
|
||||
var res = WpfMessageBox.Show("There are jobs being processed.\n\nYes: Stop and clear queue\nNo: Remove only non-processing jobs\nCancel: Cancel operation", "Confirm", MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
|
||||
if (res == MessageBoxResult.Cancel) return;
|
||||
if (res == MessageBoxResult.Yes)
|
||||
{
|
||||
@@ -334,16 +293,107 @@ namespace Ganimede
|
||||
}
|
||||
else
|
||||
{
|
||||
if (WpfMessageBox.Show("Rimuovere tutti i job?", "Conferma", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
|
||||
if (WpfMessageBox.Show("Remove all jobs from queue?", "Confirm", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
|
||||
{
|
||||
_processingService.JobQueue.Clear();
|
||||
thumbnails.Clear();
|
||||
}
|
||||
}
|
||||
StatusText.Text = "Coda aggiornata";
|
||||
StatusText.Text = "Queue updated";
|
||||
UpdateQueueCount();
|
||||
}
|
||||
|
||||
private void BrowseVideoButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new WpfOpenFileDialog { Filter = "Video Files|*.mp4;*.avi;*.mov;*.mkv;*.wmv;*.flv;*.webm|All Files|*.*", Multiselect = true };
|
||||
if (dialog.ShowDialog() == true) AddVideosToQueue(dialog.FileNames);
|
||||
}
|
||||
|
||||
private void ImportFolderButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
using var dialog = new System.Windows.Forms.FolderBrowserDialog { Description = "Select folder containing videos", ShowNewFolderButton = false };
|
||||
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
{
|
||||
try
|
||||
{
|
||||
var files = Directory.EnumerateFiles(dialog.SelectedPath, "*.*", SearchOption.TopDirectoryOnly).Where(IsVideoFile).ToArray();
|
||||
if (files.Length == 0)
|
||||
{
|
||||
WpfMessageBox.Show("No valid video files found.", "Import Folder", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
AddVideosToQueue(files);
|
||||
StatusText.Text = $"Imported {files.Length} video(s)";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WpfMessageBox.Show($"Error: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddVideosToQueue(string[] paths)
|
||||
{
|
||||
if (string.IsNullOrEmpty(outputFolder))
|
||||
{
|
||||
WpfMessageBox.Show("Please select an output folder first.", "Output Folder Required", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
var createSub = Settings.Default.CreateSubfolder;
|
||||
foreach (var p in paths) _processingService.AddJob(p, outputFolder, createSub);
|
||||
StatusText.Text = $"Added {paths.Length} video(s)";
|
||||
Settings.Default.LastVideoPath = paths.FirstOrDefault();
|
||||
Settings.Default.Save();
|
||||
UpdateQueueCount();
|
||||
}
|
||||
|
||||
private void SelectOutputFolderButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
using var dialog = new System.Windows.Forms.FolderBrowserDialog();
|
||||
if (!string.IsNullOrEmpty(outputFolder)) dialog.SelectedPath = outputFolder;
|
||||
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
{
|
||||
outputFolder = dialog.SelectedPath;
|
||||
GlobalOutputFolderTextBox.Text = outputFolder;
|
||||
StatusText.Text = "Output folder updated";
|
||||
Settings.Default.LastOutputFolder = outputFolder;
|
||||
Settings.Default.Save();
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveSettings_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var selectedFrameSize = FrameSizeComboBox.SelectedItem as ComboBoxItem;
|
||||
Settings.Default.FrameSize = selectedFrameSize?.Tag?.ToString() ?? "original";
|
||||
|
||||
var selectedOverwrite = OverwriteModeComboBox.SelectedItem as ComboBoxItem;
|
||||
Settings.Default.DefaultOverwriteMode = selectedOverwrite?.Tag?.ToString() ?? "Ask";
|
||||
|
||||
if (DefaultModeSingleRadio.IsChecked == true)
|
||||
Settings.Default.DefaultExtractionMode = "SingleFrame";
|
||||
else if (DefaultModeAutoRadio.IsChecked == true)
|
||||
Settings.Default.DefaultExtractionMode = "Auto";
|
||||
else
|
||||
Settings.Default.DefaultExtractionMode = "Full";
|
||||
|
||||
Settings.Default.CreateSubfolder = CreateSubfolderCheckBox.IsChecked ?? true;
|
||||
Settings.Default.SingleFrameUseSubfolder = SingleFrameUseSubfolderCheckBox.IsChecked ?? false;
|
||||
|
||||
Settings.Default.Save();
|
||||
|
||||
StatusText.Text = "✓ Settings saved successfully";
|
||||
Debug.WriteLine("[SETTINGS] Settings saved from inline tab");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText.Text = "✗ Failed to save settings";
|
||||
Debug.WriteLine($"[ERROR] Failed to save settings: {ex.Message}");
|
||||
WpfMessageBox.Show($"Error saving settings: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsVideoFile(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
|
||||
12
Ganimede/Ganimede/Properties/Settings.Designer.cs
generated
12
Ganimede/Ganimede/Properties/Settings.Designer.cs
generated
@@ -47,18 +47,6 @@ namespace Ganimede.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("C:\\Users\\balbo\\source\\repos\\Ganimede\\Ganimede\\Ganimede\\FFMpeg")]
|
||||
public string FFmpegBinFolder {
|
||||
get {
|
||||
return ((string)(this["FFmpegBinFolder"]));
|
||||
}
|
||||
set {
|
||||
this["FFmpegBinFolder"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("True")]
|
||||
|
||||
150
Ganimede/Ganimede/README.md
Normal file
150
Ganimede/Ganimede/README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Ganimede - Video Frame Extractor
|
||||
|
||||
## ?? Overview
|
||||
Ganimede is a modern .NET 8 WPF application for extracting frames from video files. The application features a clean, Material Design-inspired interface with tabbed navigation.
|
||||
|
||||
## ? Key Features
|
||||
|
||||
### Video Processing
|
||||
- **Multiple video formats supported**: MP4, AVI, MOV, MKV, WMV, FLV, WebM
|
||||
- **Batch processing**: Add multiple videos to queue
|
||||
- **Folder import**: Import entire folders of videos
|
||||
- **Three extraction modes**:
|
||||
- **Full**: Extract all frames from video
|
||||
- **Single Frame**: Extract one representative frame
|
||||
- **Auto**: Automatically determine best extraction method
|
||||
|
||||
### Modern UI
|
||||
- **Tab-based navigation**:
|
||||
- ?? **Processing**: Manage video queue and processing
|
||||
- ?? **Library**: Preview extracted frames
|
||||
- ?? **Settings**: Configure application preferences
|
||||
- **Clean Material Design** aesthetic
|
||||
- **Real-time progress tracking**
|
||||
- **Thumbnail preview** of extracted frames
|
||||
|
||||
### Settings
|
||||
- **Frame size options**: Original, 320x180, 640x360, 1280x720, 1920x1080
|
||||
- **Overwrite behavior**: Ask, Skip, Overwrite
|
||||
- **Subfolder creation** options
|
||||
- **Extraction mode** defaults
|
||||
|
||||
## ??? Technology Stack
|
||||
|
||||
### Core Technologies
|
||||
- **.NET 8** (Windows)
|
||||
- **WPF** (Windows Presentation Foundation)
|
||||
- **XAML** for UI design
|
||||
|
||||
### Video Processing
|
||||
- **FFMediaToolkit 4.8.1** - Native .NET video processing
|
||||
- **FFmpeg.AutoGen 7.1.1** - FFmpeg bindings
|
||||
- **System.Drawing.Common 10.0.0** - Image manipulation
|
||||
|
||||
### Architecture
|
||||
- **MVVM-inspired** pattern
|
||||
- **Async/await** for responsive UI
|
||||
- **ObservableCollection** for data binding
|
||||
- **Custom wrapper classes** for video operations:
|
||||
- `VideoAnalyzer` - Video metadata extraction
|
||||
- `FrameExtractor` - Frame extraction operations
|
||||
- `VideoProcessingService` - Queue management
|
||||
|
||||
## ?? Project Structure
|
||||
|
||||
```
|
||||
Ganimede/
|
||||
??? VideoProcessing/
|
||||
? ??? VideoAnalyzer.cs # Video analysis wrapper
|
||||
? ??? FrameExtractor.cs # Frame extraction wrapper
|
||||
??? Services/
|
||||
? ??? VideoProcessingService.cs # Processing queue management
|
||||
??? Models/
|
||||
? ??? VideoJob.cs # Job data model
|
||||
??? Windows/
|
||||
? ??? JobConfigWindow.xaml # Job configuration dialog
|
||||
? ??? SettingsWindow.xaml # Legacy settings window (unused)
|
||||
??? Helpers/
|
||||
? ??? NamingHelper.cs # File naming utilities
|
||||
??? Converters/
|
||||
? ??? StatusColorConverter.cs # Status-to-color converter
|
||||
??? MainWindow.xaml # Main application window
|
||||
```
|
||||
|
||||
## ?? Recent Updates
|
||||
|
||||
### UI Redesign (v2.0)
|
||||
- Complete UI overhaul with modern Material Design
|
||||
- Tab-based navigation replacing sidebar layout
|
||||
- Integrated settings (no separate window needed)
|
||||
- Light theme with clean aesthetics
|
||||
- Improved color palette (Indigo primary)
|
||||
- Enhanced card-based layouts
|
||||
- Refined typography and spacing
|
||||
|
||||
### Video Processing Refactor (v2.0)
|
||||
- **Replaced FFMpegCore** with FFMediaToolkit
|
||||
- **No external FFmpeg binaries required**
|
||||
- Custom wrapper architecture for video operations
|
||||
- Improved performance with direct memory access
|
||||
- Simplified configuration (no FFmpeg path needed)
|
||||
|
||||
## ?? Requirements
|
||||
|
||||
- **Windows** operating system
|
||||
- **.NET 8 Runtime** (or SDK for development)
|
||||
- **FFmpeg libraries** (automatically included via FFMediaToolkit)
|
||||
|
||||
## ?? Color Palette
|
||||
|
||||
- **Primary**: #6366F1 (Indigo)
|
||||
- **Success**: #10B981 (Green)
|
||||
- **Danger**: #EF4444 (Red)
|
||||
- **Warning**: #F59E0B (Amber)
|
||||
- **Background**: #F5F7FA (Light Gray)
|
||||
- **Surface**: #FFFFFF (White)
|
||||
- **Border**: #E5E7EB (Gray)
|
||||
|
||||
## ?? Usage
|
||||
|
||||
1. **Add Videos**: Click "Add Videos" or "Import Folder"
|
||||
2. **Select Output**: Choose output folder for extracted frames
|
||||
3. **Configure** (Optional): Configure individual jobs or use default settings
|
||||
4. **Start Queue**: Process all pending videos
|
||||
5. **View Results**: Switch to Library tab to preview extracted frames
|
||||
|
||||
## ?? Development
|
||||
|
||||
### Building
|
||||
```bash
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### Running
|
||||
```bash
|
||||
dotnet run --project Ganimede\Ganimede.csproj
|
||||
```
|
||||
|
||||
### Configuration
|
||||
Settings are stored in `Settings.settings` and persisted between sessions:
|
||||
- Default output folder
|
||||
- Frame size preferences
|
||||
- Extraction mode defaults
|
||||
- Overwrite behavior
|
||||
- Subfolder creation options
|
||||
|
||||
## ?? License
|
||||
|
||||
[Your License Here]
|
||||
|
||||
## ?? Author
|
||||
|
||||
[Your Name/Organization]
|
||||
|
||||
## ?? Known Issues
|
||||
|
||||
None currently reported.
|
||||
|
||||
## ?? Support
|
||||
|
||||
[Your Support Contact]
|
||||
@@ -3,11 +3,11 @@ using System.Collections.ObjectModel;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using System.Diagnostics;
|
||||
using FFMpegCore;
|
||||
using System.IO;
|
||||
using Ganimede.Models;
|
||||
using Ganimede.Properties;
|
||||
using Ganimede.Helpers;
|
||||
using Ganimede.VideoProcessing;
|
||||
|
||||
namespace Ganimede.Services
|
||||
{
|
||||
@@ -146,11 +146,10 @@ namespace Ganimede.Services
|
||||
{
|
||||
Debug.WriteLine($"[PROCESS] Starting job: {job.VideoName}");
|
||||
|
||||
// (Do not decide folder change yet; need analysis for Auto mode)
|
||||
|
||||
var mediaInfo = await FFProbe.AnalyseAsync(job.VideoPath);
|
||||
int frameRate = (int)(mediaInfo.PrimaryVideoStream?.FrameRate ?? 24);
|
||||
int totalFrames = (int)(mediaInfo.Duration.TotalSeconds * frameRate);
|
||||
// Analyze video using VideoAnalyzer
|
||||
var mediaInfo = await Task.Run(() => VideoAnalyzer.Analyze(job.VideoPath), cancellationToken);
|
||||
int frameRate = (int)mediaInfo.FrameRate;
|
||||
int totalFrames = mediaInfo.TotalFrames;
|
||||
Debug.WriteLine($"[INFO] Video {job.VideoName}: {totalFrames} frames at {frameRate} fps, duration {mediaInfo.Duration}");
|
||||
|
||||
// Heuristic suggestion
|
||||
@@ -161,11 +160,10 @@ namespace Ganimede.Services
|
||||
suggestSingleFrame = true;
|
||||
else if (mediaInfo.Duration.TotalSeconds >= 3 && mediaInfo.Duration.TotalSeconds <= 45)
|
||||
{
|
||||
var primary = mediaInfo.PrimaryVideoStream;
|
||||
if (primary != null && primary.BitRate > 0 && primary.Width > 0 && primary.Height > 0)
|
||||
if (mediaInfo.BitRate > 0 && mediaInfo.Width > 0 && mediaInfo.Height > 0)
|
||||
{
|
||||
double pixels = primary.Width * primary.Height;
|
||||
if (primary.BitRate < pixels * 0.3)
|
||||
double pixels = mediaInfo.Width * mediaInfo.Height;
|
||||
if (mediaInfo.BitRate < pixels * 0.3)
|
||||
suggestSingleFrame = true;
|
||||
}
|
||||
}
|
||||
@@ -224,7 +222,7 @@ namespace Ganimede.Services
|
||||
job.StatusMessage = "Frame already exists (skipped)";
|
||||
else
|
||||
{
|
||||
await ExtractFrameAsync(job, targetIndex, frameRate, frameSize, framePath);
|
||||
await ExtractFrameAsync(job, frameTime, frameSize, framePath, cancellationToken);
|
||||
job.StatusMessage = "Single frame extracted";
|
||||
}
|
||||
job.Progress = 100;
|
||||
@@ -234,11 +232,39 @@ namespace Ganimede.Services
|
||||
return;
|
||||
}
|
||||
|
||||
// Full extraction loop (unchanged)
|
||||
// Full extraction using FrameExtractor
|
||||
int processedFrames = 0;
|
||||
int skippedFrames = 0;
|
||||
for (int i = 0; i < totalFrames; i++)
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
FrameExtractor.ExtractAllFrames(
|
||||
job.VideoPath,
|
||||
job.OutputFolder,
|
||||
(frameIndex, timePosition) => NamingHelper.GenerateFileName(namingPattern, job, frameIndex, timePosition, customPrefix),
|
||||
frameSize.width,
|
||||
frameSize.height,
|
||||
(current, total) =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
job.Progress = (double)current / total * 100;
|
||||
job.StatusMessage = $"Processed {current}/{total} frames ({job.Progress:F1}%)";
|
||||
processedFrames = current;
|
||||
},
|
||||
(framePath) =>
|
||||
{
|
||||
if (File.Exists(framePath) && overwriteMode == OverwriteMode.Skip)
|
||||
{
|
||||
skippedFrames++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}, cancellationToken);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
job.Status = JobStatus.Cancelled;
|
||||
@@ -246,20 +272,7 @@ namespace Ganimede.Services
|
||||
Debug.WriteLine($"[CANCELLED] Job cancelled: {job.VideoName}");
|
||||
return;
|
||||
}
|
||||
var frameTime = TimeSpan.FromSeconds((double)i / frameRate);
|
||||
var fileName = NamingHelper.GenerateFileName(namingPattern, job, i, frameTime, customPrefix);
|
||||
string framePath = Path.Combine(job.OutputFolder, fileName);
|
||||
if (File.Exists(framePath) && overwriteMode == OverwriteMode.Skip)
|
||||
skippedFrames++;
|
||||
else
|
||||
{
|
||||
await ExtractFrameAsync(job, i, frameRate, frameSize, framePath);
|
||||
processedFrames++;
|
||||
}
|
||||
job.Progress = (double)(i + 1) / totalFrames * 100;
|
||||
job.StatusMessage = $"Processed {processedFrames}/{totalFrames} frames ({job.Progress:F1}%)" + (skippedFrames > 0 ? $" - Skipped {skippedFrames}" : "");
|
||||
if (i % 10 == 0) await Task.Delay(1, cancellationToken);
|
||||
}
|
||||
|
||||
job.Status = JobStatus.Completed;
|
||||
job.StatusMessage = $"Completed - {processedFrames} frames processed" + (skippedFrames > 0 ? $", {skippedFrames} skipped" : "");
|
||||
job.Progress = 100;
|
||||
@@ -327,62 +340,25 @@ namespace Ganimede.Services
|
||||
return Settings.Default.DefaultCustomPrefix ?? "custom";
|
||||
}
|
||||
|
||||
private async Task ExtractFrameAsync(VideoJob job, int frameIndex, int frameRate, (int width, int height) frameSize, string framePath)
|
||||
{
|
||||
var frameTime = TimeSpan.FromSeconds((double)frameIndex / frameRate);
|
||||
|
||||
try
|
||||
{
|
||||
if (frameSize.width == -1 && frameSize.height == -1)
|
||||
private async Task ExtractFrameAsync(VideoJob job, TimeSpan frameTime, (int width, int height) frameSize, string framePath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await FFMpegArguments
|
||||
.FromFileInput(job.VideoPath)
|
||||
.OutputToFile(framePath, true, options => options
|
||||
.Seek(frameTime)
|
||||
.WithFrameOutputCount(1)
|
||||
.WithVideoCodec("png"))
|
||||
.ProcessAsynchronously();
|
||||
return;
|
||||
}
|
||||
catch
|
||||
await Task.Run(() =>
|
||||
{
|
||||
await FFMpegArguments
|
||||
.FromFileInput(job.VideoPath)
|
||||
.OutputToFile(framePath, true, options => options
|
||||
.Seek(frameTime)
|
||||
.WithFrameOutputCount(1))
|
||||
.ProcessAsynchronously();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await FFMpegArguments
|
||||
.FromFileInput(job.VideoPath)
|
||||
.OutputToFile(framePath, true, options => options
|
||||
.Seek(frameTime)
|
||||
.WithFrameOutputCount(1)
|
||||
.WithVideoCodec("png")
|
||||
.Resize(frameSize.width, frameSize.height))
|
||||
.ProcessAsynchronously();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await FFMpegArguments
|
||||
.FromFileInput(job.VideoPath)
|
||||
.OutputToFile(framePath, true, options => options
|
||||
.Seek(frameTime)
|
||||
.WithFrameOutputCount(1)
|
||||
.Resize(frameSize.width, frameSize.height))
|
||||
.ProcessAsynchronously();
|
||||
}
|
||||
FrameExtractor.ExtractFrame(
|
||||
job.VideoPath,
|
||||
frameTime,
|
||||
framePath,
|
||||
frameSize.width,
|
||||
frameSize.height
|
||||
);
|
||||
}, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[ERROR] Failed to extract frame {frameIndex} from {job.VideoName}: {ex.Message}");
|
||||
Debug.WriteLine($"[ERROR] Failed to extract frame from {job.VideoName}: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
194
Ganimede/Ganimede/VideoProcessing/FrameExtractor.cs
Normal file
194
Ganimede/Ganimede/VideoProcessing/FrameExtractor.cs
Normal file
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using FFMediaToolkit;
|
||||
using FFMediaToolkit.Decoding;
|
||||
using FFMediaToolkit.Graphics;
|
||||
|
||||
namespace Ganimede.VideoProcessing
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides frame extraction capabilities from video files using FFMediaToolkit
|
||||
/// </summary>
|
||||
public class FrameExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts a single frame from a video at a specific time position
|
||||
/// </summary>
|
||||
/// <param name="videoPath">Path to the video file</param>
|
||||
/// <param name="timePosition">Time position in the video</param>
|
||||
/// <param name="outputPath">Output path for the PNG image</param>
|
||||
/// <param name="targetWidth">Target width for resizing (optional, -1 for original)</param>
|
||||
/// <param name="targetHeight">Target height for resizing (optional, -1 for original)</param>
|
||||
public static void ExtractFrame(
|
||||
string videoPath,
|
||||
TimeSpan timePosition,
|
||||
string outputPath,
|
||||
int targetWidth = -1,
|
||||
int targetHeight = -1)
|
||||
{
|
||||
if (!File.Exists(videoPath))
|
||||
throw new FileNotFoundException($"Video file not found: {videoPath}");
|
||||
|
||||
try
|
||||
{
|
||||
using var file = MediaFile.Open(videoPath, new MediaOptions { StreamsToLoad = MediaMode.Video, VideoPixelFormat = ImagePixelFormat.Bgr24 });
|
||||
|
||||
if (!file.HasVideo)
|
||||
throw new InvalidOperationException("The file does not contain a video stream");
|
||||
|
||||
// Get the frame at the specified time
|
||||
var imageData = file.Video.GetFrame(timePosition);
|
||||
|
||||
// Create bitmap and copy data
|
||||
using var bitmap = new Bitmap(imageData.ImageSize.Width, imageData.ImageSize.Height, PixelFormat.Format24bppRgb);
|
||||
var rect = new Rectangle(Point.Empty, bitmap.Size);
|
||||
var bitmapData = bitmap.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
|
||||
|
||||
unsafe
|
||||
{
|
||||
var dst = (byte*)bitmapData.Scan0;
|
||||
var rowSize = imageData.ImageSize.Width * 3;
|
||||
|
||||
for (int y = 0; y < imageData.ImageSize.Height; y++)
|
||||
{
|
||||
var srcRow = imageData.Data.Slice(y * imageData.Stride, rowSize);
|
||||
var dstRow = new Span<byte>(dst + y * bitmapData.Stride, rowSize);
|
||||
srcRow.CopyTo(dstRow);
|
||||
}
|
||||
}
|
||||
|
||||
bitmap.UnlockBits(bitmapData);
|
||||
|
||||
// Resize if needed
|
||||
if (targetWidth > 0 && targetHeight > 0 && (targetWidth != imageData.ImageSize.Width || targetHeight != imageData.ImageSize.Height))
|
||||
{
|
||||
using var resized = new Bitmap(bitmap, targetWidth, targetHeight);
|
||||
SaveBitmapAsPng(resized, outputPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
SaveBitmapAsPng(bitmap, outputPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to extract frame: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all frames from a video
|
||||
/// </summary>
|
||||
/// <param name="videoPath">Path to the video file</param>
|
||||
/// <param name="outputFolder">Output folder for PNG images</param>
|
||||
/// <param name="fileNameGenerator">Function to generate file names for each frame</param>
|
||||
/// <param name="targetWidth">Target width for resizing (optional, -1 for original)</param>
|
||||
/// <param name="targetHeight">Target height for resizing (optional, -1 for original)</param>
|
||||
/// <param name="onProgress">Progress callback (frame index, total frames)</param>
|
||||
/// <param name="shouldSkipFrame">Function to determine if a frame should be skipped</param>
|
||||
public static void ExtractAllFrames(
|
||||
string videoPath,
|
||||
string outputFolder,
|
||||
Func<int, TimeSpan, string> fileNameGenerator,
|
||||
int targetWidth = -1,
|
||||
int targetHeight = -1,
|
||||
Action<int, int>? onProgress = null,
|
||||
Func<string, bool>? shouldSkipFrame = null)
|
||||
{
|
||||
if (!File.Exists(videoPath))
|
||||
throw new FileNotFoundException($"Video file not found: {videoPath}");
|
||||
|
||||
if (!Directory.Exists(outputFolder))
|
||||
Directory.CreateDirectory(outputFolder);
|
||||
|
||||
try
|
||||
{
|
||||
using var file = MediaFile.Open(videoPath, new MediaOptions { StreamsToLoad = MediaMode.Video, VideoPixelFormat = ImagePixelFormat.Bgr24 });
|
||||
|
||||
if (!file.HasVideo)
|
||||
throw new InvalidOperationException("The file does not contain a video stream");
|
||||
|
||||
var video = file.Video;
|
||||
var info = video.Info;
|
||||
|
||||
// Calculate total frames
|
||||
double frameRate = info.AvgFrameRate;
|
||||
int totalFrames = info.NumberOfFrames ?? (int)(info.Duration.TotalSeconds * frameRate);
|
||||
|
||||
int frameIndex = 0;
|
||||
|
||||
// Create a reusable buffer
|
||||
var buffer = new byte[video.Info.FrameSize.Width * video.Info.FrameSize.Height * 3];
|
||||
|
||||
while (video.TryGetNextFrame(buffer))
|
||||
{
|
||||
var timePosition = video.Position;
|
||||
var fileName = fileNameGenerator(frameIndex, timePosition);
|
||||
var fullPath = Path.Combine(outputFolder, fileName);
|
||||
|
||||
// Check if frame should be skipped
|
||||
if (shouldSkipFrame != null && shouldSkipFrame(fullPath))
|
||||
{
|
||||
frameIndex++;
|
||||
onProgress?.Invoke(frameIndex, totalFrames);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create bitmap from buffer
|
||||
using var bitmap = new Bitmap(info.FrameSize.Width, info.FrameSize.Height, PixelFormat.Format24bppRgb);
|
||||
var rect = new Rectangle(Point.Empty, bitmap.Size);
|
||||
var bitmapData = bitmap.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
|
||||
|
||||
unsafe
|
||||
{
|
||||
var dst = (byte*)bitmapData.Scan0;
|
||||
var rowSize = info.FrameSize.Width * 3;
|
||||
|
||||
for (int y = 0; y < info.FrameSize.Height; y++)
|
||||
{
|
||||
var srcRow = buffer.AsSpan(y * rowSize, rowSize);
|
||||
var dstRow = new Span<byte>(dst + y * bitmapData.Stride, rowSize);
|
||||
srcRow.CopyTo(dstRow);
|
||||
}
|
||||
}
|
||||
|
||||
bitmap.UnlockBits(bitmapData);
|
||||
|
||||
// Resize if needed
|
||||
if (targetWidth > 0 && targetHeight > 0)
|
||||
{
|
||||
using var resized = new Bitmap(bitmap, targetWidth, targetHeight);
|
||||
SaveBitmapAsPng(resized, fullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
SaveBitmapAsPng(bitmap, fullPath);
|
||||
}
|
||||
|
||||
frameIndex++;
|
||||
onProgress?.Invoke(frameIndex, totalFrames);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to extract frames: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a bitmap as PNG file
|
||||
/// </summary>
|
||||
private static void SaveBitmapAsPng(Bitmap bitmap, string outputPath)
|
||||
{
|
||||
// Ensure directory exists
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Save as PNG
|
||||
bitmap.Save(outputPath, ImageFormat.Png);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
Ganimede/Ganimede/VideoProcessing/VideoAnalyzer.cs
Normal file
76
Ganimede/Ganimede/VideoProcessing/VideoAnalyzer.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using FFMediaToolkit;
|
||||
using FFMediaToolkit.Decoding;
|
||||
|
||||
namespace Ganimede.VideoProcessing
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides video analysis capabilities using FFMediaToolkit
|
||||
/// </summary>
|
||||
public class VideoAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyzes a video file and returns its metadata
|
||||
/// </summary>
|
||||
public static VideoMetadata Analyze(string videoPath)
|
||||
{
|
||||
if (!File.Exists(videoPath))
|
||||
throw new FileNotFoundException($"Video file not found: {videoPath}");
|
||||
|
||||
try
|
||||
{
|
||||
using var file = MediaFile.Open(videoPath);
|
||||
|
||||
if (!file.HasVideo)
|
||||
throw new InvalidOperationException("The file does not contain a video stream");
|
||||
|
||||
var video = file.Video;
|
||||
var info = video.Info;
|
||||
|
||||
// Get frame rate
|
||||
double frameRate = info.AvgFrameRate;
|
||||
|
||||
// Calculate total frames from duration and frame rate
|
||||
var duration = info.Duration;
|
||||
int totalFrames = info.NumberOfFrames ?? (int)(duration.TotalSeconds * frameRate);
|
||||
|
||||
// Get video dimensions
|
||||
int width = info.FrameSize.Width;
|
||||
int height = info.FrameSize.Height;
|
||||
|
||||
// Estimate bitrate from file info
|
||||
long bitrate = file.Info.Bitrate;
|
||||
|
||||
return new VideoMetadata
|
||||
{
|
||||
Duration = duration,
|
||||
FrameRate = frameRate,
|
||||
TotalFrames = totalFrames,
|
||||
Width = width,
|
||||
Height = height,
|
||||
BitRate = bitrate,
|
||||
CodecName = info.CodecName ?? "unknown"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to analyze video: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains metadata information about a video file
|
||||
/// </summary>
|
||||
public class VideoMetadata
|
||||
{
|
||||
public TimeSpan Duration { get; set; }
|
||||
public double FrameRate { get; set; }
|
||||
public int TotalFrames { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public long BitRate { get; set; }
|
||||
public string CodecName { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
Title="Impostazioni" Height="600" Width="640"
|
||||
Title="Impostazioni" Height="550" Width="640"
|
||||
Background="#1E2228" WindowStartupLocation="CenterOwner">
|
||||
<Grid Margin="22">
|
||||
<Grid.RowDefinitions>
|
||||
@@ -19,24 +19,6 @@
|
||||
<!-- Settings Content -->
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<!-- FFmpeg Settings -->
|
||||
<GroupBox Header="Configurazione FFmpeg" Foreground="White" BorderBrush="#444" Margin="0,0,0,18">
|
||||
<StackPanel Margin="12">
|
||||
<TextBlock Text="Cartella Binari FFmpeg:" Foreground="#CCC" Margin="0,0,0,6"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="FFmpegPathTextBox" Grid.Column="0" Height="34" VerticalContentAlignment="Center"
|
||||
Background="#333" Foreground="White" BorderBrush="#555" Margin="0,0,10,0"/>
|
||||
<Button x:Name="BrowseFFmpegButton" Grid.Column="1" Content="Sfoglia" Width="90" Height="34"
|
||||
Click="BrowseFFmpegButton_Click"/>
|
||||
</Grid>
|
||||
<TextBlock x:Name="FFmpegStatusText" Foreground="#AAA" FontSize="12" Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<!-- Output Settings -->
|
||||
<GroupBox Header="Output" Foreground="White" BorderBrush="#444" Margin="0,0,0,18">
|
||||
<StackPanel Margin="12">
|
||||
|
||||
@@ -20,7 +20,6 @@ namespace Ganimede.Windows
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
FFmpegPathTextBox.Text = Settings.Default.FFmpegBinFolder;
|
||||
DefaultOutputTextBox.Text = Settings.Default.LastOutputFolder;
|
||||
CreateSubfolderCheckBox.IsChecked = Settings.Default.CreateSubfolder;
|
||||
var singleFrameChk = GetCheckBox("SingleFrameUseSubfolderCheckBox");
|
||||
@@ -55,8 +54,6 @@ namespace Ganimede.Windows
|
||||
default:
|
||||
GetDefaultModeRadio("DefaultModeFullRadio")!.IsChecked = true; break;
|
||||
}
|
||||
|
||||
UpdateFFmpegStatus();
|
||||
}
|
||||
|
||||
private string GetSelectedDefaultExtractionMode()
|
||||
@@ -68,43 +65,6 @@ namespace Ganimede.Windows
|
||||
|
||||
private bool GetSingleFrameUseSubfolder() => GetCheckBox("SingleFrameUseSubfolderCheckBox")?.IsChecked == true;
|
||||
|
||||
private void UpdateFFmpegStatus()
|
||||
{
|
||||
var path = FFmpegPathTextBox.Text;
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
FFmpegStatusText.Text = "Nessun percorso specificato";
|
||||
FFmpegStatusText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Orange);
|
||||
}
|
||||
else if (ValidateFFMpegPath(path))
|
||||
{
|
||||
FFmpegStatusText.Text = "? FFmpeg valido";
|
||||
FFmpegStatusText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.LightGreen);
|
||||
}
|
||||
else
|
||||
{
|
||||
FFmpegStatusText.Text = "? Binari FFmpeg non trovati";
|
||||
FFmpegStatusText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Red);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ValidateFFMpegPath(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return false;
|
||||
return File.Exists(Path.Combine(path, "ffmpeg.exe")) && File.Exists(Path.Combine(path, "ffprobe.exe"));
|
||||
}
|
||||
|
||||
private void BrowseFFmpegButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
using var dialog = new System.Windows.Forms.FolderBrowserDialog { Description = "Seleziona cartella binari FFmpeg", ShowNewFolderButton = false };
|
||||
if (!string.IsNullOrEmpty(FFmpegPathTextBox.Text)) dialog.SelectedPath = FFmpegPathTextBox.Text;
|
||||
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
{
|
||||
FFmpegPathTextBox.Text = dialog.SelectedPath;
|
||||
UpdateFFmpegStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowseOutputButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
using var dialog = new System.Windows.Forms.FolderBrowserDialog { Description = "Seleziona cartella output predefinita", ShowNewFolderButton = true };
|
||||
@@ -119,7 +79,6 @@ namespace Ganimede.Windows
|
||||
{
|
||||
try
|
||||
{
|
||||
Settings.Default.FFmpegBinFolder = FFmpegPathTextBox.Text;
|
||||
Settings.Default.LastOutputFolder = DefaultOutputTextBox.Text;
|
||||
Settings.Default.CreateSubfolder = CreateSubfolderCheckBox.IsChecked ?? true;
|
||||
var selectedFrameSizeItem = FrameSizeComboBox.SelectedItem as System.Windows.Controls.ComboBoxItem;
|
||||
|
||||
Reference in New Issue
Block a user