UI for SingleJob added.

This commit is contained in:
Alexander Shabarshov 2026-05-22 08:58:09 +01:00
parent ad418e18a9
commit e18d043b78
14 changed files with 432 additions and 87 deletions

View File

@ -17,6 +17,7 @@
<Application.Styles>
<FluentTheme/>
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Styles>
</Application>

View File

@ -0,0 +1,15 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.Models;
public partial class ParameterEntry : ObservableObject
{
public string Key { get; }
[ObservableProperty] private string _value;
public ParameterEntry(string key, string value)
{
Key = key;
Value = value;
}
}

View File

@ -9,9 +9,9 @@ public sealed class FileJobFactory : IFileJobFactory
_services = services;
}
public FileJobViewModel Create(SingleJob job)
public JobViewModel Create(SingleJob job)
{
// Resolve a fresh VM + fresh services
return ActivatorUtilities.CreateInstance<FileJobViewModel>(_services, job);
return ActivatorUtilities.CreateInstance<JobViewModel>(_services, job);
}
}

View File

@ -6,5 +6,5 @@ namespace Splitter_UI.Services;
public interface IFileJobFactory
{
FileJobViewModel Create(SingleJob job);
JobViewModel Create(SingleJob job);
}

View File

@ -18,6 +18,7 @@
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.3" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.3" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.3" />

View File

@ -1,42 +0,0 @@
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.ViewModels;
public partial class FileJobViewModel : ObservableObject
{
public SingleJob Job { get; }
public VideoInfo? Probe { get; set; }
public PreviewData? Preview { get; set; }
public ProgressInfo? Progress { get; set; }
[ObservableProperty]
private Bitmap? _thumbnail;
public string FileName { get; set; }
[ObservableProperty]
private string _suggestedAction = "";
private readonly IThumbnailService _thumbnails;
private readonly IFileProbeService _fileProbe;
public FileJobViewModel(SingleJob job, IThumbnailService thumbnails, IFileProbeService fileProbe)
{
Job = job;
_thumbnails = thumbnails;
_fileProbe = fileProbe;
FileName = Path.GetFileName(job.InputFile);
_ = Task.Run( LoadThumbnailAsync );
}
private async Task LoadThumbnailAsync()
{
Probe = await _fileProbe.ProbeAsync(Job);
Thumbnail = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe);
SuggestedAction = Probe.Rotation == 0 ? "crop" : "rotate";
}
}

View File

@ -7,20 +7,20 @@ namespace Splitter_UI.ViewModels;
public partial class FileListViewModel : ObservableObject
{
private readonly IFileJobFactory _factory;
public ObservableCollection<FileJobViewModel> Files { get; } = [];
public ObservableCollection<FileJobViewModel> SelectedFiles { get; } = [];
public ObservableCollection<JobViewModel> Files { get; } = [];
public ObservableCollection<JobViewModel> SelectedFiles { get; } = [];
[ObservableProperty]
private FileJobViewModel? _selected;
private JobViewModel? _selected;
public event Action<FileJobViewModel?>? SelectedFileChanged;
public event Action<JobViewModel?>? SelectedFileChanged;
public FileListViewModel(IFileJobFactory factory)
{
_factory = factory;
}
partial void OnSelectedChanged(FileJobViewModel? value)
partial void OnSelectedChanged(JobViewModel? value)
=> SelectedFileChanged?.Invoke(value);
[RelayCommand]

View File

@ -8,13 +8,18 @@ namespace Splitter_UI.ViewModels;
public partial class InspectorPaneViewModel : ObservableObject
{
[ObservableProperty]
private FileJobViewModel? _selected;
private JobViewModel? _selected;
public List<string> DetectModes =>
[
"face", "body", "none"
];
public List<int> RotationAngles =>
[
0, 90, 180, 270
];
[RelayCommand]
private void ApplyOverrides()
{
@ -22,4 +27,26 @@ public partial class InspectorPaneViewModel : ObservableObject
return;
}
public IRelayCommand RotateLeftCommand { get; }
public IRelayCommand RotateRightCommand { get; }
public InspectorPaneViewModel()
{
RotateLeftCommand = new RelayCommand(() => AdjustRotation(-90));
RotateRightCommand = new RelayCommand(() => AdjustRotation(+90));
}
private void AdjustRotation(int delta)
{
if (Selected?.Job == null)
return;
var r = Selected.Job.Rotate ?? 0;
r = (r + delta) % 360;
if (r < 0) r += 360;
Selected.Rotate = r;
}
}

View File

@ -0,0 +1,156 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.ViewModels;
public partial class JobViewModel : ObservableObject
{
public SingleJob Job { get; }
public VideoInfo? Probe { get; set; }
public PreviewData? Preview { get; set; }
public ProgressInfo? Progress { get; set; }
[ObservableProperty]
private Bitmap? _thumbnail;
[ObservableProperty]
private string _suggestedAction = "";
private readonly IThumbnailService _thumbnails;
private readonly IFileProbeService _fileProbe;
public string FileName => Path.GetFileName(Job.InputFile);
public ObservableCollection<ParameterEntry> ParametersList { get; }
= new();
public string CropText
{
get => Job.Crop is { } c ? $"{c.width},{c.height}" : "";
set
{
if (string.IsNullOrWhiteSpace(value))
{
Job.Crop = null;
}
else
{
var parts = value.Split(',');
if (parts.Length == 2 &&
int.TryParse(parts[0], out var w) &&
int.TryParse(parts[1], out var h))
Job.Crop = (w, h);
}
OnPropertyChanged();
}
}
public string GravitateText
{
get => Job.GravitateTo is { } p ? $"{p.X:F3},{p.Y:F3}" : "";
set
{
if (string.IsNullOrWhiteSpace(value))
{
Job.GravitateTo = null;
}
else
{
var parts = value.Split(',');
if (parts.Length == 2 &&
float.TryParse(parts[0], out var x) &&
float.TryParse(parts[1], out var y))
Job.GravitateTo = new Point2f(x, y);
}
OnPropertyChanged();
}
}
public string PassthroughText
{
get => string.Join(' ', Job.Passthrough);
set
{
Job.Passthrough = string.IsNullOrWhiteSpace(value)
? Array.Empty<string>()
: value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
OnPropertyChanged();
}
}
public int? Rotate
{
get => Job.Rotate;
set
{
Job.Rotate = value;
OnPropertyChanged();
}
}
public JobViewModel(SingleJob job, IThumbnailService thumbnails, IFileProbeService fileProbe)
{
Job = job;
_thumbnails = thumbnails;
_fileProbe = fileProbe;
ParametersList.Add(new ParameterEntry("DropoutToleranceFrames", ""));
ParametersList.Add(new ParameterEntry("EmaFactor", ""));
ParametersList.Add(new ParameterEntry("CameraEasing", ""));
ParametersList.Add(new ParameterEntry("LostFreezeFrames", ""));
ParametersList.Add(new ParameterEntry("RotationDetectorSampleCount", ""));
ParametersList.Add(new ParameterEntry("RotationDetectorSampleLength", ""));
ParametersList.Add(new ParameterEntry("RotationDetectorFrameWidth", ""));
ParametersList.Add(new ParameterEntry("RotationDetectorFrameHeight", ""));
foreach (var entry in ParametersList)
{
entry.PropertyChanged += OnParameterChanged;
}
ParametersList.CollectionChanged += OnParametersCollectionChanged;
_ = Task.Run( LoadThumbnailAsync );
}
private async Task LoadThumbnailAsync()
{
Probe = await _fileProbe.ProbeAsync(Job);
Thumbnail = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe);
SuggestedAction = Probe.Rotation == 0 ? "crop" : "rotate";
}
private void OnParameterChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is ParameterEntry p && e.PropertyName == nameof(ParameterEntry.Value))
{
Job.Parameters[p.Key] = p.Value;
}
}
private void OnParametersCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
{
foreach (ParameterEntry p in e.NewItems)
{
Job.Parameters[p.Key] = p.Value;
p.PropertyChanged += OnParameterChanged;
}
}
if (e.OldItems != null)
{
foreach (ParameterEntry p in e.OldItems)
{
Job.Parameters.Remove(p.Key);
p.PropertyChanged -= OnParameterChanged;
}
}
}
}

View File

@ -5,7 +5,7 @@ namespace Splitter_UI.ViewModels;
public partial class PreviewPaneViewModel : ObservableObject
{
[ObservableProperty]
private FileJobViewModel? _selected;
private JobViewModel? _selected;
public PreviewPaneViewModel()
{

View File

@ -51,7 +51,7 @@
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:FileJobViewModel">
<DataTemplate x:DataType="vm:JobViewModel">
<Border x:Name="ItemRoot"
Margin="0"
Padding="0"

View File

@ -1,42 +1,156 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Splitter_UI.Views.InspectorPane"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
x:DataType="vm:InspectorPaneViewModel">
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Splitter_UI.Views.InspectorPane"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
x:DataType="vm:InspectorPaneViewModel">
<Border Background="#252525" Padding="12">
<StackPanel Spacing="8">
<ScrollViewer>
<StackPanel Spacing="2">
<TextBlock Text="Parameters" FontSize="18" Margin="0,0,0,10"/>
<TextBlock Text="Parameters" FontSize="10" Margin="0,0,0,10" FontWeight="Bold"/>
<!-- InputFile -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Selected.FileName}" Width="360" Margin="0,0,0,10" FontStyle="Italic"/>
</StackPanel>
<!-- Rotate -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Rotate" Width="120"/>
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Width="24" Height="24"
Padding="0"
Command="{Binding RotateLeftCommand}">
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="&#xf2ea;"
FontSize="12"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Margin="0,-1,0,0"/>
</Button>
<Button Width="24" Height="24"
Padding="0"
Command="{Binding RotateRightCommand}">
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="&#xf2f9;"
FontSize="12"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Margin="0,-1,0,0"/>
</Button>
<!-- Angle display -->
<TextBlock Text="{Binding Selected.Rotate}"
Width="40"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
<!-- Mask -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Mask" Width="120"/>
<TextBox Text="{Binding Selected.Job.Mask}" Width="260"/>
</StackPanel>
<!-- OutputFolder -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Output Folder" Width="120"/>
<TextBox Text="{Binding Selected.Job.OutputFolder}" Width="260"/>
</StackPanel>
<!-- Crop -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Crop (w,h)" Width="120"/>
<TextBox Text="{Binding Selected.CropText}" Width="160"/>
</StackPanel>
<!-- GravitateTo -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="GravitateTo" Width="120"/>
<TextBox Text="{Binding Selected.GravitateText}" Width="160"/>
</StackPanel>
<!-- Detect -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Detect" Width="120"/>
<ComboBox ItemsSource="{Binding DetectModes}"
SelectedItem="{Binding Selected.Job.Detect}"
Width="160"/>
</StackPanel>
<!-- OverrideTargetDuration -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Target Duration" Width="120"/>
<NumericUpDown Value="{Binding Selected.Job.OverrideTargetDuration}" Width="120"/>
</StackPanel>
<!-- RotateAuto -->
<CheckBox Content="Rotate Auto"
IsChecked="{Binding Selected.Job.RotateAuto}"/>
<!-- ForceFixed -->
<CheckBox Content="Force Fixed Duration"
IsChecked="{Binding Selected.Job.ForceFixed}"/>
<!-- SingleThreaded -->
<CheckBox Content="Single Threaded"
IsChecked="{Binding Selected.Job.SingleThreaded}"/>
<!-- Debug -->
<CheckBox Content="Debug Mode"
IsChecked="{Binding Selected.Job.Debug}"/>
<!-- Parameters dictionary -->
<TextBlock Text="Advanced Parameters" FontSize="10" Margin="0,10,0,0" FontWeight="Bold"/>
<DataGrid ItemsSource="{Binding Selected.ParametersList}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
Height="160">
<DataGrid.Columns>
<DataGridTextColumn Header="Key"
Binding="{Binding Key}"
Width="*"/>
<DataGridTemplateColumn Header="Value" Width="2*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Value}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
<DataGridTemplateColumn.CellEditingTemplate>
<DataTemplate>
<TextBox Text="{Binding Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
</DataTemplate>
</DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<!-- Passthrough -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Passthrough" Width="120"/>
<TextBox Text="{Binding Selected.PassthroughText}" Width="260"/>
</StackPanel>
<Button Content="Apply to Selected"
Command="{Binding ApplyOverridesCommand}"
HorizontalAlignment="Right"
Margin="0,10,0,0"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Rotate:" Width="100"/>
<NumericUpDown Value="{Binding Selected.Job.Rotate}" Width="120"/>
</StackPanel>
<!--
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Crop:" Width="100"/>
<TextBox Text="{Binding Selected.CropText}" Width="200"/>
</StackPanel>
-->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Detect:" Width="100"/>
<ComboBox ItemsSource="{Binding DetectModes}"
SelectedItem="{Binding Selected.Job.Detect}"
Width="200"/>
</StackPanel>
<CheckBox Content="Force Fixed Duration"
IsChecked="{Binding Selected.Job.ForceFixed}"/>
<CheckBox Content="Debug Mode"
IsChecked="{Binding Selected.Job.Debug}"/>
<Button Content="Apply to Selected"
Command="{Binding ApplyOverridesCommand}"
Margin="0,10,0,0"/>
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>

View File

@ -4,21 +4,93 @@ namespace splitter;
public class SingleJob
{
/// <summary>
/// File path of the input video. This is required for each job and should be
/// set to a valid video file path. The splitter will read this file, analyze it,
/// and split it into segments based on the specified parameters.
/// The output segments will be saved in the OutputFolder with names
/// derived from this input file and the Mask pattern if provided.
/// </summary>
public string InputFile { get; set; } = null!;
/// <summary>
/// Output folder where the split segments will be saved. This should be set
/// to a valid directory path.
/// </summary>
public string OutputFolder { get; set; } = null!;
/// <summary>
/// Crop parameters. Width and height for cropping the video. If set, the
/// splitter will crop the video to the specified dimensions while tracking the subject.
/// </summary>
public (int width, int height)? Crop { get; set; }
/// <summary>
/// The fallback point to gravitate towards when tracking the subject. Coordinates are normalized (0.0 to 1.0).
/// By default , the splitter gravitates towards the center of the frame (0.5, 0.5).
/// Setting this allows you to bias the tracking towards a specific area of the frame,
/// such as left-center (0.2, 0.5) or top-right (0.8, 0.2). This can be useful for
/// videos where the subject tends to be off-center or for creative framing choices.
/// </summary>
public Point2f? GravitateTo { get; set; }
/// <summary>
/// Destination file mask.
/// </summary>
public string? Mask { get; set; }
/// <summary>
/// Instead of producing the output, just generate debug frames with tracking
/// overlay to visually verify that the tracking is working correctly.
/// </summary>
public bool Debug { get; set; }
/// <summary>
/// Type of detector to use for tracking. Supported values are: face (UltraFace),
/// body (YoloOnnx, default), none (no tracking, just a center point).
/// </summary>
public string? Detect { get; set; }
/// <summary>
/// Set starget segments length explicitly. By default, the splitter calculates segment
/// lengths to be equal and not exceed 58 seconds.
/// </summary>
public double? OverrideTargetDuration { get; set; }
/// <summary>
/// Parameters to pass thru to ffmpeg. These are specified after "--" in the command
/// line and are passed directly to the ffmpeg command line for each segment.
/// </summary>
public string[] Passthrough { get; set; } = [];
/// <summary>
/// Debugging parameter. Instead of text UI putput lines in plain text.
/// This is useful when the output is being piped to a file or another program,
/// or when the user prefers a simpler log format without progress bars and dynamic updates.
/// </summary>
public bool PlainText { get; set; }
/// <summary>
/// Debugging parameter. Just show estimated segments length, count, and other info
/// without actually performing the splitting.
/// </summary>
public bool EstimateOnly { get; set; }
/// <summary>
/// Do not adapt segment length. When set, the splitter will use the exact
/// segment duration specified by --duration for all segments except possibly
/// the last one, which may be shorter.
/// </summary>
public bool ForceFixed { get; set; }
/// <summary>
/// Use single thread for operations. When set, the splitter will not run
/// multiple ffmpeg processes in parallel.
/// </summary>
public bool SingleThreaded { get; set; }
/// <summary>
/// Rotation angle: 90, 180, or 270 degrees. This is useful for videos that
/// have incorrect orientation metadata.
/// </summary>
public int? Rotate { get; set; }
/// <summary>
/// Autodetect if rotation is needed. Not very reliable but can work for some videos.
/// Uses edge orientation statistics to determine if the video is rotated and
/// applies the appropriate rotation if needed.
/// </summary>
public bool RotateAuto { get; set; }
/// <summary>
/// Override internal parameters. This allows you to set custom parameters for the
/// object detector or rotation detector.
/// </summary>
public Dictionary<string, string> Parameters { get; set; } = [];
public void Override<T>(ref T member, string name)

View File

@ -9,6 +9,7 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<PlatformTarget>x64</PlatformTarget>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<BuildNumber>0</BuildNumber>
</PropertyGroup>
<!-- DEBUG CONFIGURATION -->