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:
2025-12-07 23:34:48 +01:00
parent 959fdad037
commit 11931854c7
11 changed files with 1150 additions and 461 deletions

View File

@@ -13,9 +13,6 @@
<setting name="LastVideoPath" serializeAs="String"> <setting name="LastVideoPath" serializeAs="String">
<value /> <value />
</setting> </setting>
<setting name="FFmpegBinFolder" serializeAs="String">
<value>C:\Users\balbo\source\repos\Ganimede\Ganimede\Ganimede\FFMpeg</value>
</setting>
</Ganimede.Properties.Settings> </Ganimede.Properties.Settings>
</userSettings> </userSettings>
</configuration> </configuration>

View File

@@ -7,10 +7,12 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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>
<ItemGroup> <ItemGroup>

View File

@@ -5,48 +5,47 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Ganimede" xmlns:local="clr-namespace:Ganimede"
mc:Ignorable="d" mc:Ignorable="d"
Title="Estrattore Frame Video" Height="800" Width="1250" Title="Ganimede - Video Frame Extractor" Height="750" Width="1200"
Background="#1E2228" WindowStartupLocation="CenterScreen"> Background="#F5F7FA" WindowStartupLocation="CenterScreen">
<Window.Resources> <Window.Resources>
<local:StatusColorConverter x:Key="StatusColorConverter"/> <local:StatusColorConverter x:Key="StatusColorConverter"/>
<!-- Color resources --> <!-- Modern Color Palette -->
<Color x:Key="AccentColor">#268BFF</Color> <SolidColorBrush x:Key="PrimaryBrush" Color="#6366F1"/>
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/> <SolidColorBrush x:Key="PrimaryDarkBrush" Color="#4F46E5"/>
<SolidColorBrush x:Key="AccentBrushLight" Color="#39A3FF"/> <SolidColorBrush x:Key="SuccessBrush" Color="#10B981"/>
<SolidColorBrush x:Key="BaseBrush" Color="#1E2228"/> <SolidColorBrush x:Key="DangerBrush" Color="#EF4444"/>
<SolidColorBrush x:Key="PanelBrush" Color="#242A31"/> <SolidColorBrush x:Key="WarningBrush" Color="#F59E0B"/>
<SolidColorBrush x:Key="PanelSubBrush" Color="#2C333B"/> <SolidColorBrush x:Key="BackgroundBrush" Color="#F5F7FA"/>
<SolidColorBrush x:Key="BorderBrushColor" Color="#38424D"/> <SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#FFFFFF"/> <SolidColorBrush x:Key="BorderBrush" Color="#E5E7EB"/>
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#B5BDC7"/> <SolidColorBrush x:Key="TextPrimaryBrush" Color="#111827"/>
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#6B7280"/>
<SolidColorBrush x:Key="TextMutedBrush" Color="#9CA3AF"/>
<!-- Button Style --> <!-- Modern Button Style -->
<Style TargetType="Button" x:Key="ToolbarButton"> <Style TargetType="Button" x:Key="ModernButton">
<Setter Property="Background" Value="{StaticResource PrimaryBrush}"/>
<Setter Property="Foreground" Value="White"/> <Setter Property="Foreground" Value="White"/>
<Setter Property="Background" Value="#2F3740"/> <Setter Property="BorderThickness" Value="0"/>
<Setter Property="BorderBrush" Value="#3F4A55"/> <Setter Property="Padding" Value="16,10"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="14 8"/>
<Setter Property="FontSize" Value="14"/> <Setter Property="FontSize" Value="14"/>
<Setter Property="MinHeight" Value="40"/> <Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="Button"> <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"/> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border> </Border>
<ControlTemplate.Triggers> <ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"> <Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#39444F"/> <Setter Property="Background" Value="{StaticResource PrimaryDarkBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#46525E"/>
</Trigger> </Trigger>
<Trigger Property="IsEnabled" Value="False"> <Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/> <Setter Property="Opacity" Value="0.5"/>
</Trigger> </Trigger>
</ControlTemplate.Triggers> </ControlTemplate.Triggers>
</ControlTemplate> </ControlTemplate>
@@ -54,59 +53,133 @@
</Setter> </Setter>
</Style> </Style>
<!-- Accent Button --> <Style TargetType="Button" x:Key="OutlineButton" BasedOn="{StaticResource ModernButton}">
<Style TargetType="Button" x:Key="AccentButton" BasedOn="{StaticResource ToolbarButton}"> <Setter Property="Background" Value="Transparent"/>
<Setter Property="Background" Value="{StaticResource AccentBrush}"/> <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="#1673D5"/> <Setter Property="BorderThickness" Value="1.5"/>
<Setter Property="Foreground" Value="White"/> <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<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"/>
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="ProgressBar"> <ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="3"> <Border Background="{TemplateBinding Background}"
<Grid x:Name="PART_Track"> BorderBrush="{TemplateBinding BorderBrush}"
<Rectangle x:Name="PART_Indicator" Fill="{TemplateBinding Foreground}" RadiusX="3" RadiusY="3"/> BorderThickness="{TemplateBinding BorderThickness}"
</Grid> 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> </Border>
</ControlTemplate> </ControlTemplate>
</Setter.Value> </Setter.Value>
</Setter> </Setter>
</Style> </Style>
<!-- ScrollViewer styling for thin scrollbar --> <!-- Modern ProgressBar Style -->
<Style TargetType="ScrollBar"> <Style TargetType="ProgressBar">
<Setter Property="Width" Value="10"/> <Setter Property="Height" Value="8"/>
<Setter Property="Background" Value="#20252B"/> <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> </Style>
</Window.Resources> </Window.Resources>
@@ -117,132 +190,374 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- TOOLBAR --> <!-- Header -->
<Border Background="#242A31" Padding="16 12" BorderBrush="#303840" BorderThickness="0,0,0,1"> <Border Background="{StaticResource SurfaceBrush}"
<DockPanel LastChildFill="False"> BorderBrush="{StaticResource BorderBrush}"
<StackPanel Orientation="Horizontal" DockPanel.Dock="Left"> BorderThickness="0,0,0,1"
<Button x:Name="BrowseVideoButton" Style="{StaticResource AccentButton}" Content=" Aggiungi Video" Click="BrowseVideoButton_Click" Margin="0,0,10,0"/> Padding="24,16">
<Button x:Name="ImportFolderButton" Style="{StaticResource ToolbarButton}" Content="📁 Importa Cartella" Click="ImportFolderButton_Click" Margin="0,0,10,0"/> <DockPanel>
<Button x:Name="SelectOutputFolderButton" Style="{StaticResource ToolbarButton}" Content="🗂 Seleziona Cartella Output" Click="SelectOutputFolderButton_Click" Margin="0,0,10,0"/> <StackPanel Orientation="Horizontal" VerticalAlignment="Center">
</StackPanel> <TextBlock Text="🎬" FontSize="28" Margin="0,0,12,0"/>
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right"> <StackPanel>
<Button x:Name="ConfigureSelectedButton" Style="{StaticResource ToolbarButton}" Content="⚙ Configura Selezionati" Width="195" IsEnabled="False" Click="ConfigureSelectedButton_Click" Margin="0,0,10,0"/> <TextBlock Text="Ganimede"
<Button x:Name="StartQueueButton" Style="{StaticResource AccentButton}" Content="▶ Avvia Coda" Width="150" Click="StartQueueButton_Click" Margin="0,0,10,0"/> FontSize="20"
<Button x:Name="StopQueueButton" Style="{StaticResource DangerButton}" Content="⏹ Ferma" Width="110" IsEnabled="False" Click="StopQueueButton_Click" Margin="0,0,10,0"/> FontWeight="Bold"
<Button x:Name="ClearCompletedButton" Style="{StaticResource SmallGhostButton}" Content="🧹 Pulisci Completati" Click="ClearCompletedButton_Click" Margin="0,0,10,0"/> Foreground="{StaticResource TextPrimaryBrush}"/>
<Button x:Name="ClearAllButton" Style="{StaticResource SmallGhostButton}" Content="🗑 Pulisci Tutto" Click="ClearAllButton_Click" Margin="0,0,10,0"/> <TextBlock Text="Video Frame Extractor"
<Button x:Name="SettingsButton" Style="{StaticResource SmallGhostButton}" Content="⚙ Impostazioni" Click="SettingsButton_Click"/> FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}"/>
</StackPanel>
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>
</Border> </Border>
<!-- CONTENUTO PRINCIPALE --> <!-- Main Content with Tabs -->
<Grid Grid.Row="1"> <TabControl Grid.Row="1" Margin="24,16,24,16">
<Grid.ColumnDefinitions> <!-- Processing Tab -->
<ColumnDefinition Width="*"/> <TabItem Header="🎥 Processing">
<ColumnDefinition Width="370"/> <Grid Margin="0,24,0,0">
</Grid.ColumnDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Coda --> <!-- Actions Bar -->
<Grid Margin="18 12 8 12"> <Border Style="{StaticResource Card}" Margin="0,0,0,16">
<Grid.RowDefinitions> <Grid>
<RowDefinition Height="Auto"/> <Grid.ColumnDefinitions>
<RowDefinition Height="*"/> <ColumnDefinition Width="*"/>
</Grid.RowDefinitions> <ColumnDefinition Width="Auto"/>
<StackPanel Orientation="Horizontal" Margin="0 0 0 10" VerticalAlignment="Center"> </Grid.ColumnDefinitions>
<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"/>
</StackPanel>
<Border Grid.Row="1" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrushColor}" BorderThickness="1" CornerRadius="8" Padding="4"> <StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <Button Style="{StaticResource ModernButton}"
<ItemsControl x:Name="QueueItemsControl"> Content=" Add Videos"
<ItemsControl.ItemTemplate> Click="BrowseVideoButton_Click"
<DataTemplate> Margin="0,0,12,0"/>
<Border Background="{StaticResource PanelSubBrush}" Margin="6" Padding="10" CornerRadius="6" BorderBrush="#3A454F" BorderThickness="1"> <Button Style="{StaticResource OutlineButton}"
<Grid> Content="📁 Import Folder"
<Grid.RowDefinitions> Click="ImportFolderButton_Click"
<RowDefinition Height="Auto"/> Margin="0,0,12,0"/>
<RowDefinition Height="Auto"/> <Button Style="{StaticResource OutlineButton}"
<RowDefinition Height="Auto"/> Content="⚙️ Configure Selected"
<RowDefinition Height="Auto"/> x:Name="ConfigureSelectedButton"
</Grid.RowDefinitions> IsEnabled="False"
<Grid.ColumnDefinitions> Click="ConfigureSelectedButton_Click"/>
<ColumnDefinition Width="Auto"/> </StackPanel>
<ColumnDefinition Width="*"/>
<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.Column="1" Orientation="Horizontal">
<StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center"> <Button Style="{StaticResource SuccessButton}"
<TextBlock Text="{Binding VideoName}" Foreground="White" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" Margin="0,0,8,0"/> Content="▶️ Start Queue"
<TextBlock Text="{Binding Progress, StringFormat={}{0:0}%}" Foreground="{StaticResource TextSecondaryBrush}" FontSize="11"/> Width="140"
</StackPanel> x:Name="StartQueueButton"
<Button Grid.Row="0" Grid.Column="2" Content="✕" Width="30" Height="26" Style="{StaticResource SmallGhostButton}" Tag="{Binding}" Click="RemoveQueueItem_Click" ToolTip="Rimuovi"/> 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>
<ProgressBar Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,8,0,0" Value="{Binding Progress}"/> <!-- Queue List -->
<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"/> <Border Grid.Row="1" Style="{StaticResource Card}">
<TextBlock Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="3" FontSize="10" Foreground="#87939F" Margin="0,8,0,0" TextWrapping="Wrap"> <Grid>
<TextBlock.Text> <Grid.RowDefinitions>
<MultiBinding StringFormat="{}📁 {0} 📐 {1} 🔄 {2} 🏷 {3} 🎯 {4}"> <RowDefinition Height="Auto"/>
<Binding Path="OutputFolderDisplay"/> <RowDefinition Height="*"/>
<Binding Path="FrameSizeDisplay"/> </Grid.RowDefinitions>
<Binding Path="OverwriteModeDisplay"/>
<Binding Path="NamingPatternDisplay"/>
<Binding Path="ExtractionModeDisplay"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Grid>
<!-- Pannello destro --> <DockPanel Margin="0,0,0,16">
<StackPanel Grid.Column="1" Margin="8 12 18 12"> <TextBlock Text="Processing Queue"
<Border Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrushColor}" BorderThickness="1" CornerRadius="8" Padding="14"> FontSize="18"
<StackPanel> FontWeight="SemiBold"
<TextBlock Text="Impostazioni Globali" FontSize="16" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}"/> Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="Cartella Output" Foreground="{StaticResource TextSecondaryBrush}" FontSize="12" Margin="0,10,0,2"/> <TextBlock x:Name="QueueCountText"
<DockPanel LastChildFill="True"> Text="(0)"
<TextBox x:Name="GlobalOutputFolderTextBox" Height="34" Margin="0,0,10,0" IsReadOnly="True" Background="#2C333B" BorderBrush="#3A434C" Foreground="White" BorderThickness="1"/> FontSize="16"
<Button Content="Sfoglia" Width="80" Style="{StaticResource SmallGhostButton}" Click="SelectOutputFolderButton_Click"/> Foreground="{StaticResource TextMutedBrush}"
</DockPanel> Margin="8,2,0,0"/>
<TextBlock Text="Anteprime (Thumbnails)" Foreground="{StaticResource TextSecondaryBrush}" FontSize="12" Margin="0,14,0,4"/> </DockPanel>
<Border Background="{StaticResource PanelSubBrush}" BorderBrush="#3A454F" BorderThickness="1" CornerRadius="6" Padding="6" Height="260">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="ThumbnailsPanel"> <ItemsControl x:Name="QueueItemsControl">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel IsItemsHost="True"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<Border Margin="4" BorderBrush="#3D4853" BorderThickness="1" CornerRadius="4"> <Border Background="#F9FAFB"
<Image Source="{Binding}" Width="90" Height="52" Stretch="UniformToFill"/> 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"/>
</Grid.RowDefinitions>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<CheckBox x:Name="JobCheckBox"
Grid.Column="0"
VerticalAlignment="Center"
Margin="0,0,12,0"
Tag="{Binding}"
Checked="JobCheckBox_CheckedChanged"
Unchecked="JobCheckBox_CheckedChanged"/>
<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> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
</Border> </Grid>
</StackPanel> </Border>
</Border> </Grid>
</StackPanel> </TabItem>
</Grid>
<!-- BARRA STATO --> <!-- Library Tab -->
<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"> <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>
<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/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<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>
<!-- 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> <DockPanel>
<TextBlock x:Name="StatusText" Foreground="{StaticResource TextSecondaryBrush}" FontSize="13" VerticalAlignment="Center" Text="Pronto"/> <StackPanel Orientation="Horizontal" DockPanel.Dock="Left">
<TextBlock Text=" | " Foreground="#55606B" Margin="6,0"/> <TextBlock Text="●"
<TextBlock Text="Job:" Foreground="#77818B" Margin="0,0,4,0"/> Foreground="{StaticResource SuccessBrush}"
<TextBlock x:Name="JobsSummaryText" Foreground="#4F5962" FontSize="11" VerticalAlignment="Center"/> 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> </DockPanel>
</Border> </Border>
</Grid> </Grid>

View File

@@ -8,7 +8,6 @@ using System.Windows.Media.Imaging;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Collections.Generic; using System.Collections.Generic;
using FFMpegCore;
using Ganimede.Properties; using Ganimede.Properties;
using Ganimede.Services; using Ganimede.Services;
using Ganimede.Models; using Ganimede.Models;
@@ -30,7 +29,6 @@ namespace Ganimede
{ {
InitializeComponent(); InitializeComponent();
InitializeUI(); InitializeUI();
ConfigureFFMpeg();
} }
private void InitializeUI() private void InitializeUI()
@@ -51,9 +49,67 @@ namespace Ganimede
_processingService.ProcessingStopped += OnProcessingStopped; _processingService.ProcessingStopped += OnProcessingStopped;
_processingService.JobQueue.CollectionChanged += (s, e) => UpdateQueueCount(); _processingService.JobQueue.CollectionChanged += (s, e) => UpdateQueueCount();
// Initialize settings controls
LoadSettingsControls();
UpdateQueueCount(); 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() private void UpdateQueueCount()
{ {
Dispatcher.Invoke(() => Dispatcher.Invoke(() =>
@@ -71,7 +127,7 @@ namespace Ganimede
var failed = _processingService.JobQueue.Count(j => j.Status == JobStatus.Failed); var failed = _processingService.JobQueue.Count(j => j.Status == JobStatus.Failed);
Dispatcher.Invoke(() => 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; StartQueueButton.IsEnabled = false;
StopQueueButton.IsEnabled = true; StopQueueButton.IsEnabled = true;
StatusText.Text = "Elaborazione coda..."; StatusText.Text = "Processing queue...";
UpdateJobsSummary(); UpdateJobsSummary();
}); });
} }
@@ -92,7 +148,7 @@ namespace Ganimede
{ {
StartQueueButton.IsEnabled = true; StartQueueButton.IsEnabled = true;
StopQueueButton.IsEnabled = false; StopQueueButton.IsEnabled = false;
StatusText.Text = "Coda fermata"; StatusText.Text = "Queue stopped";
UpdateJobsSummary(); UpdateJobsSummary();
}); });
} }
@@ -101,7 +157,7 @@ namespace Ganimede
{ {
Dispatcher.Invoke(() => Dispatcher.Invoke(() =>
{ {
StatusText.Text = $"✓ Completato: {job.VideoName}"; StatusText.Text = $"✓ Completed: {job.VideoName}";
LoadThumbnailsFromFolder(job.OutputFolder); LoadThumbnailsFromFolder(job.OutputFolder);
UpdateJobsSummary(); UpdateJobsSummary();
}); });
@@ -111,7 +167,7 @@ namespace Ganimede
{ {
Dispatcher.Invoke(() => Dispatcher.Invoke(() =>
{ {
StatusText.Text = $"✗ Fallito: {job.VideoName}"; StatusText.Text = $"✗ Failed: {job.VideoName}";
UpdateJobsSummary(); 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) private async void StartQueueButton_Click(object sender, RoutedEventArgs e)
{ {
if (_processingService.JobQueue.Count == 0) 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; return;
} }
if (_processingService.JobQueue.All(j => j.Status != JobStatus.Pending)) 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; return;
} }
await _processingService.StartProcessingAsync(); await _processingService.StartProcessingAsync();
@@ -264,7 +214,7 @@ namespace Ganimede
private void StopQueueButton_Click(object sender, RoutedEventArgs e) private void StopQueueButton_Click(object sender, RoutedEventArgs e)
{ {
_processingService.StopProcessing(); _processingService.StopProcessing();
StatusText.Text = "Arresto in corso..."; StatusText.Text = "Stopping...";
UpdateJobsSummary(); UpdateJobsSummary();
} }
@@ -281,15 +231,24 @@ namespace Ganimede
private void ConfigureSelectedButton_Click(object sender, RoutedEventArgs e) private void ConfigureSelectedButton_Click(object sender, RoutedEventArgs e)
{ {
if (_selectedJobs.Count == 0) return; if (_selectedJobs.Count == 0) return;
var cfg = new JobConfigWindow(_selectedJobs.ToList()) { Owner = this }; var cfg = new JobConfigWindow(_selectedJobs.ToList()) { Owner = this };
if (cfg.ShowDialog() == true) if (cfg.ShowDialog() == true)
{ {
StatusText.Text = $"Configurazione applicata a {_selectedJobs.Count} job"; StatusText.Text = $"Configuration applied to {_selectedJobs.Count} job(s)";
foreach (var job in _selectedJobs.Where(j => string.IsNullOrEmpty(j.CustomOutputFolder)))
// Update output folders only if outputFolder is set
if (!string.IsNullOrEmpty(outputFolder))
{ {
var createSub = Settings.Default.CreateSubfolder; foreach (var job in _selectedJobs.Where(j => string.IsNullOrEmpty(j.CustomOutputFolder)))
job.OutputFolder = job.ExtractionMode == ExtractionMode.SingleFrame ? outputFolder : (createSub ? Path.Combine(outputFolder, job.VideoName) : outputFolder); {
var createSub = Settings.Default.CreateSubfolder;
job.OutputFolder = job.ExtractionMode == ExtractionMode.SingleFrame
? outputFolder
: (createSub ? Path.Combine(outputFolder, job.VideoName) : outputFolder);
}
} }
UpdateJobsSummary(); UpdateJobsSummary();
} }
} }
@@ -308,7 +267,7 @@ namespace Ganimede
private void ClearCompletedButton_Click(object sender, RoutedEventArgs e) private void ClearCompletedButton_Click(object sender, RoutedEventArgs e)
{ {
_processingService.RemoveCompletedJobs(); _processingService.RemoveCompletedJobs();
StatusText.Text = "Job completati rimossi"; StatusText.Text = "Completed jobs removed";
UpdateQueueCount(); UpdateQueueCount();
} }
@@ -317,7 +276,7 @@ namespace Ganimede
var processing = _processingService.JobQueue.Any(j => j.Status == JobStatus.Processing); var processing = _processingService.JobQueue.Any(j => j.Status == JobStatus.Processing);
if (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.Cancel) return;
if (res == MessageBoxResult.Yes) if (res == MessageBoxResult.Yes)
{ {
@@ -334,16 +293,107 @@ namespace Ganimede
} }
else 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(); _processingService.JobQueue.Clear();
thumbnails.Clear(); thumbnails.Clear();
} }
} }
StatusText.Text = "Coda aggiornata"; StatusText.Text = "Queue updated";
UpdateQueueCount(); 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) private static bool IsVideoFile(string path)
{ {
var ext = Path.GetExtension(path).ToLowerInvariant(); var ext = Path.GetExtension(path).ToLowerInvariant();

View File

@@ -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.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("True")] [global::System.Configuration.DefaultSettingValueAttribute("True")]

150
Ganimede/Ganimede/README.md Normal file
View 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]

View File

@@ -3,11 +3,11 @@ using System.Collections.ObjectModel;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Threading; using System.Threading;
using System.Diagnostics; using System.Diagnostics;
using FFMpegCore;
using System.IO; using System.IO;
using Ganimede.Models; using Ganimede.Models;
using Ganimede.Properties; using Ganimede.Properties;
using Ganimede.Helpers; using Ganimede.Helpers;
using Ganimede.VideoProcessing;
namespace Ganimede.Services namespace Ganimede.Services
{ {
@@ -146,11 +146,10 @@ namespace Ganimede.Services
{ {
Debug.WriteLine($"[PROCESS] Starting job: {job.VideoName}"); Debug.WriteLine($"[PROCESS] Starting job: {job.VideoName}");
// (Do not decide folder change yet; need analysis for Auto mode) // Analyze video using VideoAnalyzer
var mediaInfo = await Task.Run(() => VideoAnalyzer.Analyze(job.VideoPath), cancellationToken);
var mediaInfo = await FFProbe.AnalyseAsync(job.VideoPath); int frameRate = (int)mediaInfo.FrameRate;
int frameRate = (int)(mediaInfo.PrimaryVideoStream?.FrameRate ?? 24); int totalFrames = mediaInfo.TotalFrames;
int totalFrames = (int)(mediaInfo.Duration.TotalSeconds * frameRate);
Debug.WriteLine($"[INFO] Video {job.VideoName}: {totalFrames} frames at {frameRate} fps, duration {mediaInfo.Duration}"); Debug.WriteLine($"[INFO] Video {job.VideoName}: {totalFrames} frames at {frameRate} fps, duration {mediaInfo.Duration}");
// Heuristic suggestion // Heuristic suggestion
@@ -161,11 +160,10 @@ namespace Ganimede.Services
suggestSingleFrame = true; suggestSingleFrame = true;
else if (mediaInfo.Duration.TotalSeconds >= 3 && mediaInfo.Duration.TotalSeconds <= 45) else if (mediaInfo.Duration.TotalSeconds >= 3 && mediaInfo.Duration.TotalSeconds <= 45)
{ {
var primary = mediaInfo.PrimaryVideoStream; if (mediaInfo.BitRate > 0 && mediaInfo.Width > 0 && mediaInfo.Height > 0)
if (primary != null && primary.BitRate > 0 && primary.Width > 0 && primary.Height > 0)
{ {
double pixels = primary.Width * primary.Height; double pixels = mediaInfo.Width * mediaInfo.Height;
if (primary.BitRate < pixels * 0.3) if (mediaInfo.BitRate < pixels * 0.3)
suggestSingleFrame = true; suggestSingleFrame = true;
} }
} }
@@ -224,7 +222,7 @@ namespace Ganimede.Services
job.StatusMessage = "Frame already exists (skipped)"; job.StatusMessage = "Frame already exists (skipped)";
else else
{ {
await ExtractFrameAsync(job, targetIndex, frameRate, frameSize, framePath); await ExtractFrameAsync(job, frameTime, frameSize, framePath, cancellationToken);
job.StatusMessage = "Single frame extracted"; job.StatusMessage = "Single frame extracted";
} }
job.Progress = 100; job.Progress = 100;
@@ -234,32 +232,47 @@ namespace Ganimede.Services
return; return;
} }
// Full extraction loop (unchanged) // Full extraction using FrameExtractor
int processedFrames = 0; int processedFrames = 0;
int skippedFrames = 0; int skippedFrames = 0;
for (int i = 0; i < totalFrames; i++)
await Task.Run(() =>
{ {
if (cancellationToken.IsCancellationRequested) FrameExtractor.ExtractAllFrames(
{ job.VideoPath,
job.Status = JobStatus.Cancelled; job.OutputFolder,
job.StatusMessage = "Cancelled by user"; (frameIndex, timePosition) => NamingHelper.GenerateFileName(namingPattern, job, frameIndex, timePosition, customPrefix),
Debug.WriteLine($"[CANCELLED] Job cancelled: {job.VideoName}"); frameSize.width,
return; frameSize.height,
} (current, total) =>
var frameTime = TimeSpan.FromSeconds((double)i / frameRate); {
var fileName = NamingHelper.GenerateFileName(namingPattern, job, i, frameTime, customPrefix); if (cancellationToken.IsCancellationRequested)
string framePath = Path.Combine(job.OutputFolder, fileName); return;
if (File.Exists(framePath) && overwriteMode == OverwriteMode.Skip)
skippedFrames++; job.Progress = (double)current / total * 100;
else job.StatusMessage = $"Processed {current}/{total} frames ({job.Progress:F1}%)";
{ processedFrames = current;
await ExtractFrameAsync(job, i, frameRate, frameSize, framePath); },
processedFrames++; (framePath) =>
} {
job.Progress = (double)(i + 1) / totalFrames * 100; if (File.Exists(framePath) && overwriteMode == OverwriteMode.Skip)
job.StatusMessage = $"Processed {processedFrames}/{totalFrames} frames ({job.Progress:F1}%)" + (skippedFrames > 0 ? $" - Skipped {skippedFrames}" : ""); {
if (i % 10 == 0) await Task.Delay(1, cancellationToken); skippedFrames++;
return true;
}
return false;
}
);
}, cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
job.Status = JobStatus.Cancelled;
job.StatusMessage = "Cancelled by user";
Debug.WriteLine($"[CANCELLED] Job cancelled: {job.VideoName}");
return;
} }
job.Status = JobStatus.Completed; job.Status = JobStatus.Completed;
job.StatusMessage = $"Completed - {processedFrames} frames processed" + (skippedFrames > 0 ? $", {skippedFrames} skipped" : ""); job.StatusMessage = $"Completed - {processedFrames} frames processed" + (skippedFrames > 0 ? $", {skippedFrames} skipped" : "");
job.Progress = 100; job.Progress = 100;
@@ -327,62 +340,25 @@ namespace Ganimede.Services
return Settings.Default.DefaultCustomPrefix ?? "custom"; return Settings.Default.DefaultCustomPrefix ?? "custom";
} }
private async Task ExtractFrameAsync(VideoJob job, int frameIndex, int frameRate, (int width, int height) frameSize, string framePath) private async Task ExtractFrameAsync(VideoJob job, TimeSpan frameTime, (int width, int height) frameSize, string framePath, CancellationToken cancellationToken)
{ {
var frameTime = TimeSpan.FromSeconds((double)frameIndex / frameRate);
try try
{ {
if (frameSize.width == -1 && frameSize.height == -1) await Task.Run(() =>
{ {
try FrameExtractor.ExtractFrame(
{ job.VideoPath,
await FFMpegArguments frameTime,
.FromFileInput(job.VideoPath) framePath,
.OutputToFile(framePath, true, options => options frameSize.width,
.Seek(frameTime) frameSize.height
.WithFrameOutputCount(1) );
.WithVideoCodec("png")) }, cancellationToken);
.ProcessAsynchronously();
return;
}
catch
{
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();
}
} }
catch (Exception ex) 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;
} }
} }
} }

View 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);
}
}
}

View 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;
}
}

View File

@@ -4,7 +4,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" mc:Ignorable="d"
Title="Impostazioni" Height="600" Width="640" Title="Impostazioni" Height="550" Width="640"
Background="#1E2228" WindowStartupLocation="CenterOwner"> Background="#1E2228" WindowStartupLocation="CenterOwner">
<Grid Margin="22"> <Grid Margin="22">
<Grid.RowDefinitions> <Grid.RowDefinitions>
@@ -19,24 +19,6 @@
<!-- Settings Content --> <!-- Settings Content -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"> <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel> <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 --> <!-- Output Settings -->
<GroupBox Header="Output" Foreground="White" BorderBrush="#444" Margin="0,0,0,18"> <GroupBox Header="Output" Foreground="White" BorderBrush="#444" Margin="0,0,0,18">
<StackPanel Margin="12"> <StackPanel Margin="12">

View File

@@ -20,7 +20,6 @@ namespace Ganimede.Windows
private void LoadSettings() private void LoadSettings()
{ {
FFmpegPathTextBox.Text = Settings.Default.FFmpegBinFolder;
DefaultOutputTextBox.Text = Settings.Default.LastOutputFolder; DefaultOutputTextBox.Text = Settings.Default.LastOutputFolder;
CreateSubfolderCheckBox.IsChecked = Settings.Default.CreateSubfolder; CreateSubfolderCheckBox.IsChecked = Settings.Default.CreateSubfolder;
var singleFrameChk = GetCheckBox("SingleFrameUseSubfolderCheckBox"); var singleFrameChk = GetCheckBox("SingleFrameUseSubfolderCheckBox");
@@ -55,8 +54,6 @@ namespace Ganimede.Windows
default: default:
GetDefaultModeRadio("DefaultModeFullRadio")!.IsChecked = true; break; GetDefaultModeRadio("DefaultModeFullRadio")!.IsChecked = true; break;
} }
UpdateFFmpegStatus();
} }
private string GetSelectedDefaultExtractionMode() private string GetSelectedDefaultExtractionMode()
@@ -68,43 +65,6 @@ namespace Ganimede.Windows
private bool GetSingleFrameUseSubfolder() => GetCheckBox("SingleFrameUseSubfolderCheckBox")?.IsChecked == true; 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) private void BrowseOutputButton_Click(object sender, RoutedEventArgs e)
{ {
using var dialog = new System.Windows.Forms.FolderBrowserDialog { Description = "Seleziona cartella output predefinita", ShowNewFolderButton = true }; using var dialog = new System.Windows.Forms.FolderBrowserDialog { Description = "Seleziona cartella output predefinita", ShowNewFolderButton = true };
@@ -119,7 +79,6 @@ namespace Ganimede.Windows
{ {
try try
{ {
Settings.Default.FFmpegBinFolder = FFmpegPathTextBox.Text;
Settings.Default.LastOutputFolder = DefaultOutputTextBox.Text; Settings.Default.LastOutputFolder = DefaultOutputTextBox.Text;
Settings.Default.CreateSubfolder = CreateSubfolderCheckBox.IsChecked ?? true; Settings.Default.CreateSubfolder = CreateSubfolderCheckBox.IsChecked ?? true;
var selectedFrameSizeItem = FrameSizeComboBox.SelectedItem as System.Windows.Controls.ComboBoxItem; var selectedFrameSizeItem = FrameSizeComboBox.SelectedItem as System.Windows.Controls.ComboBoxItem;