Compare commits

...

3 Commits

19 changed files with 599 additions and 133 deletions

View File

@ -0,0 +1,21 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace Splitter_UI.Converters;
public sealed class RotationAngleToIconConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value switch
{
90 => "\uf2f9", // FA7 (fa-rotate-left / fa-arrow-rotate-left / fa-undo)
180 => "\uf2f1", // FA7 (fa-sync-alt)
270 => "\uf2ea", // FA7 (fa-rotate-right / fa-arrow-rotate-right / fa-redo)
_ => null
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@ -0,0 +1,13 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace Splitter_UI.Converters;
public sealed class ZeroToBoolConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> (value is int i && i == 0);
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@ -2,10 +2,17 @@
namespace Splitter_UI.Models; namespace Splitter_UI.Models;
public sealed class PreviewData public class PreviewData
{ {
public Avalonia.Media.Imaging.Bitmap? Frame { get; init; } public Avalonia.Media.Imaging.Bitmap? Frame { get; }
public IReadOnlyList<Rect> FaceBoxes { get; init; } = []; public IReadOnlyList<Rect> DetectedBoxes { get; }
public IReadOnlyList<Rect> BodyBoxes { get; init; } = []; public Rect? CropRect { get; }
public Rect? CropRect { get; init; }
public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList<Rect> boxes, Rect? crop)
{
Frame = frame;
DetectedBoxes = boxes;
CropRect = crop;
}
} }

View File

@ -1,6 +1,7 @@
using Avalonia; using Avalonia;
using Avalonia.Media; using Avalonia.Media;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Splitter_UI; namespace Splitter_UI;
@ -31,12 +32,28 @@ internal sealed class Program
services.AddTransient<StatusBarViewModel>(); services.AddTransient<StatusBarViewModel>();
services.AddTransient<LogPaneViewModel>(); services.AddTransient<LogPaneViewModel>();
// splitter services
services.AddSingleton<UltraFaceDetector>();
services.AddSingleton<YoloOnnxObjectDetector>();
services.AddSingleton( x => new SingleThreadedDetector<UltraFaceDetector>(x.GetRequiredService<UltraFaceDetector>()) );
services.AddSingleton( x => new SingleThreadedDetector<YoloOnnxObjectDetector>(x.GetRequiredService<YoloOnnxObjectDetector>()));
services.AddSingleton<Func<string, IObjectDetector>>( x => detectorName =>
{
return detectorName switch
{
"face" => x.GetRequiredService<SingleThreadedDetector<UltraFaceDetector>>(),
"body" => x.GetRequiredService<SingleThreadedDetector<YoloOnnxObjectDetector>>(),
_ => new DummyDetector()
};
});
services.AddSingleton<splitter.ILogger, GlobalLogger>();
// Domain services (your pipeline) // Domain services (your pipeline)
services.AddTransient<IFileProbeService, FileProbeService>(); services.AddTransient<IFileProbeService, FileProbeService>();
services.AddTransient<IThumbnailService, ThumbnailService>(); services.AddTransient<IThumbnailService, ThumbnailService>();
services.AddSingleton<IAutoDecisionService, AutoDecisionService>(); services.AddSingleton<IAutoDecisionService, AutoDecisionService>();
services.AddSingleton<IProcessingService, ProcessingService>(); services.AddSingleton<IProcessingService, ProcessingService>();
services.AddSingleton<ILogService, LogService>(); services.AddSingleton<ILogService, LogService>();
services.AddSingleton<IFileJobFactory, FileJobFactory>(); services.AddSingleton<IFileJobFactory, FileJobFactory>();

View File

@ -0,0 +1,43 @@
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Media.Imaging;
using OpenCvSharp;
namespace Splitter_UI.Services;
public static class AvaloniaBitmapExtensions
{
public static Mat ToMatContinuous(this Bitmap bmp)
{
var w = bmp.PixelSize.Width;
var h = bmp.PixelSize.Height;
var stride = w * 4;
var size = h * stride;
var buffer = new byte[size];
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
bmp.CopyPixels(
new PixelRect(0, 0, w, h),
handle.AddrOfPinnedObject(),
size,
stride);
return Mat.FromPixelData(h, w, MatType.CV_8UC4, buffer);
}
finally
{
handle.Free();
}
}
public static Mat ToMatBgrContinuous(this Bitmap bmp)
{
using var bgra = bmp.ToMatContinuous();
var bgr = new Mat();
Cv2.CvtColor(bgra, bgr, ColorConversionCodes.BGRA2BGR);
return bgr;
}
}

View File

@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Text;
using OpenCvSharp;
namespace Splitter_UI.Services;
internal class DummyDetector : IObjectDetector
{
public List<(Rect box, splitter.Point2f center)> DetectAll(Mat frameCont) => [];
public void Dispose() {}
}

View File

@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Splitter_UI.Services;
internal class GlobalLogger(ILogService _logService) : ILogger
{
public void ClearProgress(int progressLevel) { }
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) { }
public void Log(string prefix, ConsoleColor color, string msg)
{
_logService.Write($"[{prefix}] {msg}");
}
}

View File

@ -5,5 +5,5 @@ namespace Splitter_UI.Services;
public interface IThumbnailService public interface IThumbnailService
{ {
Task<Bitmap?> CreateThumbnailAsync(string file, VideoInfo probe); Task<Bitmap?> CreateThumbnailAsync(string file, VideoInfo probe, TimeSpan? skip = null, int? width = null, int? height = null, int? rotateDegree = null);
} }

View File

@ -0,0 +1,23 @@
using OpenCvSharp;
namespace Splitter_UI.Services;
public class SingleThreadedDetector<T>(IObjectDetector _detector) : IObjectDetector
where T : IObjectDetector
{
private Lock _lock = new();
public List<(Rect box, splitter.Point2f center)> DetectAll(Mat frameCont)
{
lock (_lock)
{
return _detector.DetectAll(frameCont);
}
}
public void Dispose()
{
if ( _detector is IDisposable d )
d.Dispose();
}
}

View File

@ -7,41 +7,55 @@ namespace Splitter_UI.Services;
public sealed class ThumbnailService : IThumbnailService public sealed class ThumbnailService : IThumbnailService
{ {
private readonly int _thumbWidth = 160; private const int _thumbWidth = 160;
private readonly int _thumbHeight = 90; private const int _thumbHeight = 90;
// Reusable buffer for BGR24 → 3 bytes per pixel private readonly byte [] _bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3];
private readonly byte[] _bgrBuffer; private readonly byte [] _bgraBuffer = new byte[_thumbWidth * _thumbHeight * 4];
private readonly byte[] _bgraBuffer;
public ThumbnailService() public async Task<Bitmap?> CreateThumbnailAsync(
string file,
VideoInfo probe,
TimeSpan? skip = null,
int? width = null,
int? height = null,
int? rotateDegree = null)
{ {
_bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3]; width ??= _thumbWidth;
_bgraBuffer = new byte[_thumbWidth * _thumbHeight * 4]; height ??= _thumbHeight;
} skip ??= TimeSpan.Zero;
// buffer for BGR24 → 3 bytes per pixel
var canUseStaticBuffers =
width.Value == _thumbWidth &&
height.Value == _thumbHeight;
var bgrBuffer = canUseStaticBuffers ? _bgrBuffer : new byte[width.Value * height.Value * 3];
var bgraBuffer = canUseStaticBuffers ? _bgraBuffer : new byte[width.Value * height.Value * 4];
public async Task<Bitmap?> CreateThumbnailAsync(string file, VideoInfo probe)
{
// Decode a single frame using ffmpeg → raw BGR24 into _bgrBuffer // Decode a single frame using ffmpeg → raw BGR24 into _bgrBuffer
bool ok = await DecodeFrameAsync(file); bool ok = await DecodeFrameAsync(bgrBuffer, file, skip.Value, width.Value, height.Value, rotateDegree);
if (!ok) if (!ok)
return null; return null;
// Convert BGR24 → BGRA32 // Convert BGR24 → BGRA32
ConvertBgrToBgra(_bgrBuffer, _bgraBuffer, _thumbWidth, _thumbHeight); ConvertBgrToBgra(bgrBuffer, bgraBuffer, width.Value, height.Value);
// Create Avalonia Bitmap // Create Avalonia Bitmap
return CreateBitmap(_bgraBuffer, _thumbWidth, _thumbHeight); return CreateBitmap(bgraBuffer, width.Value, height.Value, rotateDegree == 90 || rotateDegree == 270);
} }
private async Task<bool> DecodeFrameAsync(string file) private static async Task<bool> DecodeFrameAsync(byte [] bgrBuffer, string file, TimeSpan skip, int width, int height, int? rotateDegree)
{ {
var rotationStr = TrackingSplitter.GetRorationArg(rotateDegree);
// ffmpeg command: decode one frame, resize, output raw BGR24 // ffmpeg command: decode one frame, resize, output raw BGR24
var args = var args =
$"-ss 0 -t 0.1 -i \"{file}\" " + $"-ss {skip.TotalSeconds} -t 0.1 -i \"{file}\" " +
"-an -sn " + "-an -sn " +
$"-vf \"scale={_thumbWidth}:{_thumbHeight}:force_original_aspect_ratio=decrease," + $"-vf \"scale={width}:{height}:force_original_aspect_ratio=decrease," +
$"pad={_thumbWidth}:{_thumbHeight}:(ow-iw)/2:(oh-ih)/2,format=bgr24\" " + $"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=bgr24{rotationStr}\" " +
"-f rawvideo -"; "-f rawvideo -";
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
@ -57,14 +71,14 @@ public sealed class ThumbnailService : IThumbnailService
var p = new Process { StartInfo = psi }; var p = new Process { StartInfo = psi };
p.Start(); p.Start();
int needed = _bgrBuffer.Length; int needed = bgrBuffer.Length;
int read = 0; int read = 0;
using var stdout = p.StandardOutput.BaseStream; using var stdout = p.StandardOutput.BaseStream;
while (read < needed) while (read < needed)
{ {
int r = await stdout.ReadAsync(_bgrBuffer, read, needed - read); int r = await stdout.ReadAsync(bgrBuffer, read, needed - read);
if (r == 0) if (r == 0)
{ {
TryKill(p); TryKill(p);
@ -101,8 +115,13 @@ public sealed class ThumbnailService : IThumbnailService
} }
} }
private static unsafe Bitmap CreateBitmap(byte[] bgra, int width, int height) private static unsafe Bitmap CreateBitmap(byte[] bgra, int width, int height, bool isRotated)
{ {
if (isRotated)
{
(height, width) = (width, height);
}
int stride = width * 4; int stride = width * 4;
fixed (byte* p = bgra) fixed (byte* p = bgra)

View File

@ -2,7 +2,9 @@
using System.Collections.Specialized; using System.Collections.Specialized;
using System.ComponentModel; using System.ComponentModel;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Splitter_UI.ViewModels; namespace Splitter_UI.ViewModels;
@ -10,7 +12,8 @@ public partial class JobViewModel : ObservableObject
{ {
public SingleJob Job { get; } public SingleJob Job { get; }
public VideoInfo? Probe { get; set; } public VideoInfo? Probe { get; set; }
public PreviewData? Preview { get; set; } [ObservableProperty]
private PreviewData? _preview = new(null, [], null);
public ProgressInfo? Progress { get; set; } public ProgressInfo? Progress { get; set; }
[ObservableProperty] [ObservableProperty]
@ -19,11 +22,33 @@ public partial class JobViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private string _suggestedAction = ""; private string _suggestedAction = "";
private readonly IThumbnailService _thumbnails; // This updates continuously
private readonly IFileProbeService _fileProbe; [ObservableProperty]
private double _sliderLiveValue;
// This updates only on release
[ObservableProperty]
private double _positionSeconds;
public double DurationSeconds => Probe?.Duration ?? 0;
public IRelayCommand StepForwardCommand { get; }
public IRelayCommand StepBackwardCommand { get; }
private readonly IThumbnailService _thumbnails;
private readonly IFileProbeService _fileProbe;
private readonly DispatcherTimer _debounceTimer;
private readonly Func<string, IObjectDetector> _detectorFactory;
private readonly ILogger _log;
public string FileName => Path.GetFileName(Job.InputFile); public string FileName => Path.GetFileName(Job.InputFile);
public string TextDesc => Probe != null
? $"{Probe.Width}x{Probe.Height}, {TimeSpan.FromSeconds(Probe.Duration).ToString(@"hh\:mm\:ss")}), FPS: {Probe.Fps:F2}, Bitrate: {Probe.Bitrate/1024/1024:F2} MB/s"
: "";
public override string ToString() => $"{FileName} - {TextDesc}";
public ObservableCollection<ParameterEntry> ParametersList { get; } public ObservableCollection<ParameterEntry> ParametersList { get; }
= new(); = new();
@ -88,14 +113,17 @@ public partial class JobViewModel : ObservableObject
{ {
Job.Rotate = value; Job.Rotate = value;
OnPropertyChanged(); OnPropertyChanged();
Task.Run(CreatePreview);
} }
} }
public JobViewModel(SingleJob job, IThumbnailService thumbnails, IFileProbeService fileProbe) public JobViewModel(SingleJob job, IThumbnailService thumbnails, IFileProbeService fileProbe, Func<string, IObjectDetector> detectorFactory, ILogger log)
{ {
Job = job; Job = job;
_thumbnails = thumbnails; _thumbnails = thumbnails;
_fileProbe = fileProbe; _fileProbe = fileProbe;
_detectorFactory = detectorFactory;
_log = log;
ParametersList.Add(new ParameterEntry("DropoutToleranceFrames", "")); ParametersList.Add(new ParameterEntry("DropoutToleranceFrames", ""));
ParametersList.Add(new ParameterEntry("EmaFactor", "")); ParametersList.Add(new ParameterEntry("EmaFactor", ""));
@ -113,6 +141,14 @@ public partial class JobViewModel : ObservableObject
ParametersList.CollectionChanged += OnParametersCollectionChanged; ParametersList.CollectionChanged += OnParametersCollectionChanged;
StepForwardCommand = new RelayCommand(StepForward);
StepBackwardCommand = new RelayCommand(StepBackward);
_debounceTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(1)
};
_debounceTimer.Tick += DebounceTimerTick;
_ = Task.Run( LoadThumbnailAsync ); _ = Task.Run( LoadThumbnailAsync );
} }
@ -120,8 +156,34 @@ public partial class JobViewModel : ObservableObject
private async Task LoadThumbnailAsync() private async Task LoadThumbnailAsync()
{ {
Probe = await _fileProbe.ProbeAsync(Job); Probe = await _fileProbe.ProbeAsync(Job);
Thumbnail = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe); Thumbnail = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, rotateDegree: Job.Rotate);
SuggestedAction = Probe.Rotation == 0 ? "crop" : "rotate"; SuggestedAction = Probe.Rotation == 0 ? "crop" : "rotate";
await CreatePreview();
}
private async Task CreatePreview()
{
if ( Probe == null)
return;
try
{
var frame = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, TimeSpan.FromSeconds(PositionSeconds), Probe.Width, Probe.Height, Job.Rotate);
if ( frame == null )
return;
Preview = new PreviewData(frame, [], null);
var detector = _detectorFactory(Job.Detect ?? "");
var detections = detector.DetectAll(frame.ToMatContinuous());
var boxes = detections.Select(x => new Avalonia.Rect(x.box.X, x.box.Y, x.box.Width, x.box.Height)).ToList();
Preview = new PreviewData(frame, boxes, null);
}
catch (Exception ex)
{
_log.LogError($"Error creating preview for {FileName}: {ex.Message}");
}
} }
private void OnParameterChanged(object? sender, PropertyChangedEventArgs e) private void OnParameterChanged(object? sender, PropertyChangedEventArgs e)
@ -153,4 +215,45 @@ public partial class JobViewModel : ObservableObject
} }
} }
private void StepForward()
{
if (DurationSeconds <= 0)
return;
var step = DurationSeconds * 0.1; // 10% of total duration
SliderLiveValue = Math.Min(DurationSeconds, SliderLiveValue + step);
// trigger seek in your playback pipeline here
}
private void StepBackward()
{
if (DurationSeconds <= 0)
return;
var step = DurationSeconds * 0.1; // 10% of total duration
SliderLiveValue = Math.Max(0, SliderLiveValue - step);
// trigger seek in your playback pipeline here
}
partial void OnSliderLiveValueChanged(double value)
{
// Restart debounce timer on every slider update
_debounceTimer.Stop();
_debounceTimer.Start();
}
private void DebounceTimerTick(object? sender, EventArgs e)
{
_debounceTimer.Stop();
// Commit the final value
PositionSeconds = SliderLiveValue;
}
partial void OnPositionSecondsChanged(double value)
{
Task.Run(CreatePreview);
}
} }

View File

@ -1,4 +1,5 @@
using CommunityToolkit.Mvvm.ComponentModel; using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.ViewModels; namespace Splitter_UI.ViewModels;
@ -7,7 +8,23 @@ public partial class PreviewPaneViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private JobViewModel? _selected; private JobViewModel? _selected;
public PreviewPaneViewModel() public PreviewData? Preview => Selected?.Preview;
partial void OnSelectedChanged(JobViewModel? oldValue, JobViewModel? newValue)
{ {
if (oldValue != null)
oldValue.PropertyChanged -= SelectedPropertyChanged;
if (newValue != null)
newValue.PropertyChanged += SelectedPropertyChanged;
OnPropertyChanged(nameof(Preview));
}
private void SelectedPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(JobViewModel.Preview))
OnPropertyChanged(nameof(Preview));
} }
} }

View File

@ -3,10 +3,15 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels" xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
xmlns:views="clr-namespace:Splitter_UI.Views" xmlns:views="clr-namespace:Splitter_UI.Views"
xmlns:svg="clr-namespace:Avalonia.Svg.Skia;assembly=Avalonia.Svg.Skia" xmlns:conv="clr-namespace:Splitter_UI.Converters"
x:Class="Splitter_UI.Views.FileListView" x:Class="Splitter_UI.Views.FileListView"
x:DataType="vm:FileListViewModel"> x:DataType="vm:FileListViewModel">
<UserControl.Resources>
<conv:ZeroToBoolConverter x:Key="ZeroToBoolConverter"/>
<conv:RotationAngleToIconConverter x:Key="RotationAngleToIconConverter"/>
</UserControl.Resources>
<UserControl.Styles> <UserControl.Styles>
<Style Selector="views|FileListView Border#DropZone"> <Style Selector="views|FileListView Border#DropZone">
<Setter Property="BorderBrush" Value="Transparent"/> <Setter Property="BorderBrush" Value="Transparent"/>
@ -20,75 +25,91 @@
</UserControl.Styles> </UserControl.Styles>
<Border x:Name="DropZone" <Border x:Name="DropZone"
Background="#1E1E1E" Background="#1E1E1E"
Padding="10" Padding="10"
DragDrop.AllowDrop="True" DragDrop.AllowDrop="True"
DragDrop.Drop="OnDrop" DragDrop.Drop="OnDrop"
DragDrop.DragOver="OnDragOver" DragDrop.DragOver="OnDragOver"
DragDrop.DragEnter="OnDragEnter" DragDrop.DragEnter="OnDragEnter"
DragDrop.DragLeave="OnDragLeave"> DragDrop.DragLeave="OnDragLeave">
<Grid>
<ScrollViewer> <!-- Empty message -->
<ListBox ItemsSource="{Binding Files}" <TextBlock Text="Drag files here"
SelectedItems="{Binding SelectedFiles}" HorizontalAlignment="Center"
SelectedItem="{Binding Selected}" VerticalAlignment="Center"
SelectionMode="Multiple" FontSize="20"
BorderThickness="0" Foreground="#666"
Background="Transparent"> IsVisible="{Binding Files.Count, Converter={StaticResource ZeroToBoolConverter}}"/>
<ListBox.ItemsPanel> <!-- File list -->
<ItemsPanelTemplate> <ScrollViewer>
<WrapPanel Orientation="Horizontal"/> <ListBox ItemsSource="{Binding Files}"
</ItemsPanelTemplate> SelectedItems="{Binding SelectedFiles}"
</ListBox.ItemsPanel> SelectedItem="{Binding Selected}"
SelectionMode="Multiple"
BorderThickness="0"
Background="Transparent">
<ListBox.Styles> <ListBox.ItemsPanel>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter"> <ItemsPanelTemplate>
<Setter Property="Background" Value="#9A9A9A"/> <WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.Styles>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="#9A9A9A"/>
</Style> </Style>
</ListBox.Styles> </ListBox.Styles>
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:JobViewModel"> <DataTemplate x:DataType="vm:JobViewModel">
<Border x:Name="ItemRoot" <Border x:Name="ItemRoot"
Margin="0" Margin="0"
Padding="0" Padding="0"
CornerRadius="4" CornerRadius="4"
Background="#2A2A2A"> Background="#2A2A2A">
<StackPanel MinWidth="160" MaxWidth="160"> <StackPanel MinWidth="160" MaxWidth="160">
<Border Width="160" Height="90" ClipToBounds="True"> <Border Width="160" Height="90" ClipToBounds="True">
<Grid> <Grid>
<Image Source="{Binding Thumbnail}" <Image Source="{Binding Thumbnail}"
Stretch="UniformToFill" Stretch="UniformToFill"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<TextBlock FontFamily="{StaticResource FontAwesome}" <TextBlock FontFamily="{StaticResource FontAwesome}"
Text="{Binding SuggestedAction, Converter={StaticResource ActionToIconConverter}}" Text="{Binding SuggestedAction, Converter={StaticResource ActionToIconConverter}}"
FontSize="12" FontSize="12"
HorizontalAlignment="Right" HorizontalAlignment="Right"
Foreground="LimeGreen"/> Foreground="LimeGreen"/>
</Grid> <TextBlock FontFamily="{StaticResource FontAwesome}"
</Border> Text="{Binding Rotate, Converter={StaticResource RotationAngleToIconConverter}}"
FontSize="12"
HorizontalAlignment="Left"
Foreground="LimeGreen"/>
</Grid>
</Border>
<TextBlock Text="{Binding FileName}" <TextBlock Text="{Binding FileName}"
TextWrapping="Wrap" TextWrapping="Wrap"
Margin="0,6,0,0" Margin="0,6,0,0"
FontSize="10"/> FontSize="10"/>
<ProgressBar MinWidth="160" <ProgressBar MinWidth="160"
MaxWidth="160" MaxWidth="160"
Height="10" Height="10"
Margin="0,4,0,0" Margin="0,4,0,0"
Value="{Binding Progress.Percent}" /> Value="{Binding Progress.Percent}" />
</StackPanel> </StackPanel>
</Border> </Border>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
</ScrollViewer> </ScrollViewer>
</Grid>
</Border> </Border>
</UserControl> </UserControl>

View File

@ -12,8 +12,9 @@ x:DataType="vm:InspectorPaneViewModel">
<TextBlock Text="Parameters" FontSize="10" Margin="0,0,0,10" FontWeight="Bold"/> <TextBlock Text="Parameters" FontSize="10" Margin="0,0,0,10" FontWeight="Bold"/>
<!-- InputFile --> <!-- InputFile -->
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Vertical" Spacing="8">
<TextBlock Text="{Binding Selected.FileName}" Width="360" Margin="0,0,0,10" FontStyle="Italic"/> <TextBlock Text="{Binding Selected.FileName}" Width="360" Margin="0,0,0,5" FontStyle="Italic"/>
<TextBlock Text="{Binding Selected.TextDesc}" Width="360" FontSize="10" Margin="0,0,0,10" FontWeight="Bold" Foreground="#676767"/>
</StackPanel> </StackPanel>
<!-- Rotate --> <!-- Rotate -->
@ -113,13 +114,22 @@ x:DataType="vm:InspectorPaneViewModel">
<DataGrid ItemsSource="{Binding Selected.ParametersList}" <DataGrid ItemsSource="{Binding Selected.ParametersList}"
AutoGenerateColumns="False" AutoGenerateColumns="False"
HeadersVisibility="Column" HeadersVisibility="Column"
Margin="0,0,20,0"
Height="160"> Height="160">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Key" <DataGridTemplateColumn Header="Key" Width="*">
Binding="{Binding Key}" <DataGridTemplateColumn.CellTemplate>
Width="*"/> <DataTemplate>
<TextBlock Text="{Binding Key}"
FontSize="10"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Key}">
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Value" Width="2*"> <DataGridTemplateColumn Header="Value" Width="2*">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>

View File

@ -6,7 +6,7 @@
x:Class="Splitter_UI.Views.MainWindow" x:Class="Splitter_UI.Views.MainWindow"
x:DataType="vm:MainViewModel" x:DataType="vm:MainViewModel"
Width="1400" Width="1400"
Height="900" Height="950"
Title="Splitter UI"> Title="Splitter UI">
<DockPanel> <DockPanel>
@ -20,7 +20,7 @@
DataContext="{Binding LogPane}" /> DataContext="{Binding LogPane}" />
<!-- Main Content --> <!-- Main Content -->
<Grid ColumnDefinitions="2*,3*,2*"> <Grid ColumnDefinitions="2*,3*,430">
<!-- File List --> <!-- File List -->
<views:FileListView Grid.Column="0" <views:FileListView Grid.Column="0"

View File

@ -0,0 +1,100 @@
using System.ComponentModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
namespace Splitter_UI.Views;
public sealed class PreviewCanvas : Control
{
public static readonly StyledProperty<PreviewData?> PreviewProperty =
AvaloniaProperty.Register<PreviewCanvas, PreviewData?>(nameof(Preview));
public PreviewData? Preview
{
get => GetValue(PreviewProperty);
set => SetValue(PreviewProperty, value);
}
static PreviewCanvas()
{
PreviewProperty.Changed.AddClassHandler<PreviewCanvas>(
(canvas, args) =>
canvas.OnPreviewChanged(args.OldValue as PreviewData,
args.NewValue as PreviewData));
}
private void OnPreviewChanged(PreviewData? oldValue, PreviewData? newValue)
{
if (oldValue is INotifyPropertyChanged oldNotify)
oldNotify.PropertyChanged -= PreviewPropertyChanged;
if (newValue is INotifyPropertyChanged newNotify)
newNotify.PropertyChanged += PreviewPropertyChanged;
// Always marshal to UI thread
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
}
private void PreviewPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(PreviewData.Frame) ||
e.PropertyName == nameof(PreviewData.DetectedBoxes) ||
e.PropertyName == nameof(PreviewData.CropRect))
{
// Always marshal to UI thread
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
}
}
protected override Size MeasureOverride(Size availableSize) => availableSize;
protected override Size ArrangeOverride(Size finalSize) => finalSize;
public override void Render(DrawingContext context)
{
var preview = Preview;
if (preview?.Frame is null)
return;
var frame = preview.Frame;
var rawW = frame.PixelSize.Width;
var rawH = frame.PixelSize.Height;
var dispW = Bounds.Width;
var dispH = Bounds.Height;
if (dispW <= 0 || dispH <= 0)
return;
var scale = Math.Min(dispW / rawW, dispH / rawH);
var scaledW = rawW * scale;
var scaledH = rawH * scale;
var offsetX = (dispW - scaledW) / 2;
var offsetY = (dispH - scaledH) / 2;
// draw frame
context.DrawImage(frame,
new Rect(0, 0, rawW, rawH),
new Rect(offsetX, offsetY, scaledW, scaledH));
// draw overlays
if (preview.DetectedBoxes is { Count: > 0 })
{
var pen = new Pen(Brushes.Lime, 2);
foreach (var r in preview.DetectedBoxes )
{
var rr = new Rect(
offsetX + r.X * scale,
offsetY + r.Y * scale,
r.Width * scale,
r.Height * scale);
context.DrawRectangle(null, pen, rr);
}
}
}
}

View File

@ -1,24 +1,57 @@
<UserControl <UserControl
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Splitter_UI.Views.PreviewPane"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels" xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
xmlns:local="clr-namespace:Splitter_UI.Views"
x:Class="Splitter_UI.Views.PreviewPane"
x:DataType="vm:PreviewPaneViewModel"> x:DataType="vm:PreviewPaneViewModel">
<Border Background="#202020" Padding="10"> <Border Background="#202020" Padding="10">
<Grid> <Grid RowDefinitions="*,Auto">
<Image Source="{Binding Selected.Preview.Frame}" Stretch="Uniform"/>
<local:PreviewCanvas
Grid.Row="0"
Preview="{Binding Preview}" />
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto"
Margin="0,10,0,0">
<Button Grid.Column="0"
HorizontalAlignment="Left"
Width="24" Height="24"
Padding="0"
Margin="0,0,5,0"
Command="{Binding Selected.StepBackwardCommand}">
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="&#xf048;"
FontSize="12"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
</Button>
<Slider Grid.Column="1"
Minimum="0"
Maximum="{Binding Selected.DurationSeconds}"
Value="{Binding Selected.SliderLiveValue, Mode=TwoWay}"
Margin="5,0,5,0" />
<Button Grid.Column="2"
HorizontalAlignment="Right"
Width="24" Height="24"
Padding="0"
Margin="5,0,0,0"
Command="{Binding Selected.StepForwardCommand}">
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="&#xf051;"
FontSize="12"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
</Button>
</Grid>
<!-- Optional overlays -->
<ItemsControl ItemsSource="{Binding Selected.Preview.FaceBoxes}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="Lime" BorderThickness="2"
Width="{Binding Width}" Height="{Binding Height}"
Canvas.Left="{Binding X}" Canvas.Top="{Binding Y}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid> </Grid>
</Border> </Border>
</UserControl> </UserControl>

View File

@ -1,4 +1,8 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.VisualTree;
namespace Splitter_UI.Views; namespace Splitter_UI.Views;
@ -8,4 +12,5 @@ public partial class PreviewPane : UserControl
{ {
InitializeComponent(); InitializeComponent();
} }
} }

View File

@ -170,16 +170,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
var ss = start .ToString("0.###", CultureInfo.InvariantCulture); var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
var t = length.ToString("0.###", CultureInfo.InvariantCulture); var t = length.ToString("0.###", CultureInfo.InvariantCulture);
var rotateStr = ""; var rotateStr = GetRorationArg(rotate);
if (rotate != null)
{
switch (rotate.Value)
{
case 90: rotateStr = ",transpose=1"; break;
case 180: rotateStr = ",transpose=PI"; break;
case 270: rotateStr = ",transpose=2"; break;
}
}
var args = var args =
$"-i \"{inputFile}\" -ss {ss} -t {t} " + $"-i \"{inputFile}\" -ss {ss} -t {t} " +
@ -217,6 +208,22 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
return p; return p;
} }
public static string GetRorationArg(int? rotate)
{
var rotateStr = "";
if (rotate != null)
{
switch (rotate.Value)
{
case 90: rotateStr = ",transpose=1"; break;
case 180: rotateStr = ",transpose=PI"; break;
case 270: rotateStr = ",transpose=2"; break;
}
}
return rotateStr;
}
private Process StartFfmpegEncode( private Process StartFfmpegEncode(
string inputFile, string inputFile,
string outputFile, string outputFile,