Compare commits

...

3 Commits

65 changed files with 788 additions and 482 deletions

View File

@ -8,7 +8,9 @@ namespace Splitter_UI;
public partial class App : Application
{
private readonly ServiceProvider _provider;
private readonly ServiceProvider _provider = null!;
public App() { }
public App(ServiceProvider provider)
{

View File

@ -7,12 +7,15 @@ public sealed class ActionToIconConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value switch
{
"crop" => "\uf125", // FA7 crop
"rotate" => "\uf2f1", // FA7 rotate
_ => null
};
if (value == null)
return null;
var p = System.Convert.ToInt32(value);
return p == 0
? "\uf125" // FA7 crop
: "\uf2f1" // FA7 rotate
;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)

View File

@ -0,0 +1,15 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace Splitter_UI.Converters;
public sealed class BoolInvertConverter : IValueConverter
{
public static readonly BoolInvertConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b ? !b : value;
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b ? !b : value;
}

View File

@ -0,0 +1,39 @@
using Avalonia.Data.Converters;
using Avalonia.Media;
using System.Globalization;
namespace Splitter_UI.Converters;
public sealed class ConsoleColorToBrushConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is ConsoleColor c)
return new SolidColorBrush(ToColor(c));
return Brushes.White;
}
private static Color ToColor(ConsoleColor c) =>
c switch
{
ConsoleColor.Black => Colors.Black,
ConsoleColor.DarkBlue => Colors.DarkBlue,
ConsoleColor.DarkGreen => Colors.DarkGreen,
ConsoleColor.DarkCyan => Colors.DarkCyan,
ConsoleColor.DarkRed => Colors.DarkRed,
ConsoleColor.DarkMagenta => Colors.DarkMagenta,
ConsoleColor.DarkYellow => Colors.Olive,
ConsoleColor.Gray => Colors.Gray,
ConsoleColor.DarkGray => Colors.DarkGray,
ConsoleColor.Blue => Colors.Blue,
ConsoleColor.Green => Colors.Green,
ConsoleColor.Cyan => Colors.Cyan,
ConsoleColor.Red => Colors.Red,
ConsoleColor.Magenta => Colors.Magenta,
ConsoleColor.Yellow => Colors.Yellow,
ConsoleColor.White => Colors.White,
_ => Colors.White
};
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException();
}

View File

@ -1,6 +1,4 @@
using Avalonia;
namespace Splitter_UI.Models;
namespace Splitter_UI.Models;
public class PreviewData
{

View File

@ -1,6 +0,0 @@
namespace Splitter_UI.Models;
public class ProgressInfo
{
public double Percent { get; set; }
}

View File

@ -1,8 +1,6 @@
using Avalonia;
using Avalonia.Media;
using Microsoft.Extensions.DependencyInjection;
using splitter.algo;
using splitter.tui;
namespace Splitter_UI;
@ -25,13 +23,16 @@ internal sealed class Program
{
var services = new ServiceCollection();
var logPaveVM = new LogPaneViewModel();
// ViewModels
services.AddTransient<MainViewModel>();
services.AddTransient<FileListViewModel>();
services.AddTransient<PreviewPaneViewModel>();
services.AddTransient<InspectorPaneViewModel>();
services.AddTransient<StatusBarViewModel>();
services.AddTransient<LogPaneViewModel>();
services.AddSingleton<StatusBarViewModel>();
services.AddSingleton<ProgressViewModel>();
services.AddSingleton<LogPaneViewModel>(logPaveVM);
services.AddSingleton<ILogService>(logPaveVM);
// splitter services
services.AddSingleton<UltraFaceDetector>();
@ -48,13 +49,12 @@ internal sealed class Program
};
});
services.AddSingleton<ILogger, GlobalLogger>();
services.AddSingleton<IJobProcessor, JobProcessor>();
// Domain services (your pipeline)
services.AddTransient<IFileProbeService, FileProbeService>();
services.AddTransient<IThumbnailService, ThumbnailService>();
services.AddSingleton<IAutoDecisionService, AutoDecisionService>();
services.AddSingleton<IProcessingService, ProcessingService>();
services.AddSingleton<ILogService, LogService>();
services.AddSingleton<IFileJobFactory, FileJobFactory>();

View File

@ -1,8 +1,4 @@
using NcnnDotNet.Layers;
using OpenCvSharp;
using splitter.tui;
namespace Splitter_UI.Services;
namespace Splitter_UI.Services;
public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProbeService _fileProbe, ILogger _log) : IAutoDecisionService
{
@ -15,19 +11,65 @@ public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProb
{
try
{
job.GravitateTo = new(0.5f, 0.5f);
job.OverrideTargetDuration = 58.0;
job.Mask = "[NAME]_seg[NN].[EXT]";
job.OutputFolder = Path.Combine(Path.GetDirectoryName(job.InputFile)!, "splitter");
job.Probe = await _fileProbe.ProbeAsync(job.InputFile);
job.Thumbnail = await _thumbnails.CreateThumbnailAsync(job.InputFile, job.Probe, rotateDegree: job.Rotate);
var sampler = new VideoRotationSampler(null);
job.Rotate = await sampler.DetectRotationAsync(job.InputFile, job.Probe.Duration);
job.SuggestedAction = job.Rotate == 0 ? "crop" : "rotate";
if (job.SuggestedAction == "crop")
if (job.Probe.Width > job.Probe.Height)
{
job.Detect = "body";
job.Rotate = 0;
CalculateCrop(job);
}
else
{
var sampler = new VideoRotationSampler(null);
job.Rotate = await sampler.DetectRotationAsync(job.InputFile, job.Probe.Duration);
job.Detect = job.Rotate == 0 ? null : "body";
}
_log.LogInfo(job.ToString());
}
catch (Exception ex)
{
_log.LogError($"Error creating thumbnail for {Path.GetFileName(job.InputFile)}: {ex.Message}");
}
}
private static void CalculateCrop(JobViewModel job)
{
var targetAR = (float)CommandLine.DefaultW / CommandLine.DefaultH;
var pixelAspect = job.Probe!.Sar.X / job.Probe.Sar.Y;
float srcW = job.Probe.Width * pixelAspect;
float srcH = job.Probe.Height;
var srcAR = srcW / srcH;
float cropH = srcH;
float cropW = cropH * targetAR;
if (cropW > srcW)
{
cropW = srcW;
cropH = cropW / targetAR;
}
float x = (srcW - cropW) * 0.5f;
float y = (srcH - cropH) * 0.5f;
float invPixelAspect = 1f / pixelAspect;
float cropW_px = cropW * invPixelAspect;
float cropH_px = cropH;
float x_px = x * invPixelAspect;
float y_px = y;
job.CropText = $"{(int)MathF.Round(cropW_px)},{(int)MathF.Round(cropH_px)}";
}
}

View File

@ -1,7 +1,6 @@
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Media.Imaging;
using OpenCvSharp;
namespace Splitter_UI.Services;

View File

@ -1,7 +1,4 @@
using OpenCvSharp;
using splitter.algo;
namespace Splitter_UI.Services;
namespace Splitter_UI.Services;
internal class DummyDetector : IObjectDetector
{

View File

@ -1,6 +1,4 @@
using splitter.probe;
namespace Splitter_UI.Services;
namespace Splitter_UI.Services;
public sealed class FileProbeService : IFileProbeService
{

View File

@ -1,13 +1,24 @@
using splitter.tui;
namespace Splitter_UI.Services;
namespace Splitter_UI.Services;
internal class GlobalLogger(ILogService _logService) : ILogger
internal class GlobalLogger(ILogService _logService, StatusBarViewModel _statusBar, ProgressViewModel _progress) : ILogger
{
public void ClearProgress(int progressLevel) { }
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) { }
public void ClearProgress(string name, int progressLine)
{
if (progressLine == 0)
_statusBar.Percent = 0;
else
_progress.ClearProgress(name, progressLine-1);
}
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed)
{
if (progressLine == 0)
_statusBar.Percent = progress;
else
_progress.DrawProgress(name, progressLine - 1, progress, eta, speed);
}
public void Log(string prefix, ConsoleColor color, string msg)
{
_logService.Write($"[{prefix}] {msg}");
_logService.Log(prefix, color, msg);
}
}

View File

@ -1,6 +1,4 @@
using splitter.probe;
namespace Splitter_UI.Services;
namespace Splitter_UI.Services;
public interface IFileProbeService
{

View File

@ -3,7 +3,5 @@ namespace Splitter_UI.Services;
public interface ILogService
{
event Action<string>? MessageLogged;
void Write(string message);
void Log(string prefix, ConsoleColor color, string msg);
}

View File

@ -1,8 +0,0 @@
namespace Splitter_UI.Services;
public interface IProcessingService
{
event Action<string, ProgressInfo>? ProgressChanged;
Task ProcessAsync(IEnumerable<SingleJob> jobs, CancellationToken token);
}

View File

@ -1,5 +1,4 @@
using Avalonia.Media.Imaging;
using splitter.probe;
namespace Splitter_UI.Services;

View File

@ -1,11 +0,0 @@
namespace Splitter_UI.Services;
public sealed class LogService : ILogService
{
public event Action<string>? MessageLogged;
public void Write(string message)
{
MessageLogged?.Invoke(message);
}
}

View File

@ -1,25 +0,0 @@
namespace Splitter_UI.Services;
public sealed class ProcessingService : IProcessingService
{
public event Action<string, ProgressInfo>? ProgressChanged;
public async Task ProcessAsync(IEnumerable<SingleJob> jobs, CancellationToken token)
{
foreach (var job in jobs)
{
for (int i = 0; i <= 100; i += 20)
{
if (token.IsCancellationRequested)
return;
var progress = new ProgressInfo { Percent = i };
// Notify UI
ProgressChanged?.Invoke(job.InputFile, progress);
await Task.Delay(100, token);
}
}
}
}

View File

@ -1,7 +1,4 @@
using OpenCvSharp;
using splitter.algo;
namespace Splitter_UI.Services;
namespace Splitter_UI.Services;
public class SingleThreadedDetector<T>(IObjectDetector _detector) : IObjectDetector
where T : IObjectDetector

View File

@ -2,7 +2,6 @@
using Avalonia;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using splitter.probe;
namespace Splitter_UI.Services;

View File

@ -39,4 +39,21 @@ public partial class FileListViewModel : ObservableObject
Selected = Files.LastOrDefault();
}
internal void DeleteSelected()
{
if (SelectedFiles.Any())
{
var toDelete = SelectedFiles.ToList();
foreach (var item in toDelete)
Files.Remove(item);
}
else if ( Selected != null)
{
var sel = Selected;
Files.Remove(sel);
}
Selected = Files.LastOrDefault();
}
}

View File

@ -16,10 +16,12 @@ public partial class InspectorPaneViewModel : ObservableObject
"face", "body", "none"
];
public List<int> RotationAngles =>
[
0, 90, 180, 270
];
[RelayCommand]
private void TransformAll()
{
_ = _main.Start();
}
[RelayCommand]
private void ApplyOverrides()
@ -48,12 +50,16 @@ public partial class InspectorPaneViewModel : ObservableObject
public IRelayCommand RotateLeftCommand { get; }
public IRelayCommand RotateRightCommand { get; }
private MainViewModel _main = null!;
public InspectorPaneViewModel()
{
RotateLeftCommand = new RelayCommand(() => AdjustRotation(-90));
RotateRightCommand = new RelayCommand(() => AdjustRotation(+90));
}
public void SetMain(MainViewModel main) => _main = main;
private void AdjustRotation(int delta)
{
if ( Selected == null)

View File

@ -5,21 +5,18 @@ using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using splitter.algo;
using splitter.probe;
using splitter.tui;
namespace Splitter_UI.ViewModels;
public partial class JobViewModel : ObservableObject
{
private SingleJob Job { get; }
public SingleJob GetJob() => Job;
[ObservableProperty] private VideoInfo? _probe;
[ObservableProperty] private PreviewData? _preview = new(null, [], null, new(0.5f, 0.5f));
[ObservableProperty] private ProgressInfo? _progress;
[ObservableProperty] private Bitmap? _thumbnail;
[ObservableProperty] private string _suggestedAction = "";
[ObservableProperty] private double _sliderLiveValue;
[ObservableProperty] private double _positionSeconds;
@ -40,7 +37,7 @@ public partial class JobViewModel : ObservableObject
? $"{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 override string ToString() => $"{FileName}: {TextDesc}";
public ObservableCollection<ParameterEntry> ParametersList { get; }
= new();
@ -217,6 +214,13 @@ public partial class JobViewModel : ObservableObject
entry.PropertyChanged += OnParameterChanged;
}
PropertyChanged += (sender, e) =>
{
if (e.PropertyName == nameof(Probe))
{
OnPropertyChanged(nameof(DurationSeconds));
}
};
ParametersList.CollectionChanged += OnParametersCollectionChanged;
StepForwardCommand = new RelayCommand(StepForward);
@ -364,4 +368,5 @@ public partial class JobViewModel : ObservableObject
{
Task.Run(CreatePreview);
}
}

View File

@ -1,15 +1,35 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using System.Collections.ObjectModel;
namespace Splitter_UI.ViewModels;
public partial class LogPaneViewModel : ObservableObject
{
public ObservableCollection<string> Logs { get; } = [];
public sealed record LogEntry(string Prefix, ConsoleColor Color, string Message);
public void Add(string message)
public partial class LogPaneViewModel : ObservableObject, ILogService
{
public ObservableCollection<LogEntry> Logs { get; } = [];
public void Log(string prefix, ConsoleColor color, string msg)
{
Logs.Add(message);
Add(new LogEntry(prefix.Replace("[", "").Replace("]", ""), color, msg));
}
private void Add(LogEntry entry)
{
if (Dispatcher.UIThread.CheckAccess())
{
AddInternal(entry);
}
else
{
Dispatcher.UIThread.Post(() => AddInternal(entry));
}
}
private void AddInternal(LogEntry entry)
{
Logs.Add(entry);
if (Logs.Count > 5000)
Logs.RemoveAt(0);
}

View File

@ -1,38 +1,82 @@
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.ViewModels;
public partial class MainViewModel : ViewModelBase
{
public FileListViewModel FileList { get; }
public PreviewPaneViewModel Preview { get; } = new PreviewPaneViewModel();
public InspectorPaneViewModel Inspector { get; } = new InspectorPaneViewModel();
public StatusBarViewModel StatusBar { get; } = new StatusBarViewModel();
public LogPaneViewModel LogPane { get; } = new LogPaneViewModel();
public FileListViewModel FileList { get; }
public PreviewPaneViewModel Preview { get; }
public InspectorPaneViewModel Inspector { get; }
public StatusBarViewModel StatusBar { get; }
public LogPaneViewModel LogPane { get; }
public ProgressViewModel Progress { get; }
private IJobProcessor _processor = null!;
public MainViewModel(IFileJobFactory fileJobFactory, IAutoDecisionService autoDecisionService)
[ObservableProperty] private bool _transformMode = false;
private ILogger _logger;
public MainViewModel(
FileListViewModel fileListVM,
PreviewPaneViewModel ppVM,
InspectorPaneViewModel iVM,
LogPaneViewModel lpVM,
StatusBarViewModel sbVM,
ProgressViewModel pVM,
IJobProcessor processor,
ILogger logger
)
{
FileList = new FileListViewModel(fileJobFactory, autoDecisionService);
// Wire selection → preview + inspector
FileList = fileListVM;
Preview = ppVM;
Inspector = iVM;
LogPane = lpVM;
StatusBar = sbVM;
Progress = pVM;
_processor = processor;
_logger = logger;
// Wire selection -> preview + inspector
FileList.SelectedFileChanged += file =>
{
Preview.Selected = file;
Inspector.Selected = file;
};
Inspector.SetMain(this);
Inspector.Files = FileList.Files;
}
[RelayCommand]
private void Start()
public async Task Start()
{
StatusBar.StatusText = "Processing…";
// call IProcessingService here
try
{
StatusBar.StatusText = "Processing…";
StatusBar.Percent = 0;
TransformMode = true;
var files = FileList.Files.ToList();
var jobs = new List<SingleTask>();
foreach (var file in files)
{
var fileJobs = await _processor.GenerateJobs(file.GetJob(), false);
jobs.AddRange(fileJobs);
}
await _processor.ProcessJobs(jobs, false);
}
catch (Exception ex)
{
// Handle exception
StatusBar.StatusText = "Error occurred…";
_logger.LogError($"Error: {ex.Message}");
}
finally
{
StatusBar.StatusText = "Ready…";
StatusBar.Percent = 0;
TransformMode = false;
}
}
[RelayCommand]
private void Stop()
{
StatusBar.StatusText = "Stopped";
}
}

View File

@ -1,6 +1,5 @@
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using splitter.algo;
namespace Splitter_UI.ViewModels;

View File

@ -0,0 +1,43 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.ViewModels;
public record ProgressInfo(string Name, int ProgressLine, double Progress, TimeSpan Eta, double Speed);
public partial class ProgressViewModel : ObservableObject
{
[ObservableProperty] private int _numberOfProcesses = 0;
public ObservableCollection<ProgressInfo> Processes { get; } = [];
private Lock _lock = new();
public void ClearProgress(string name, int progressLine)
{
lock (_lock)
{
if (progressLine < 0 || progressLine > Processes.Count)
return;
NumberOfProcesses -= 1;
Processes[progressLine] = new ProgressInfo("", progressLine, 0, TimeSpan.Zero, 0);
}
}
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed)
{
lock (_lock)
{
if (progressLine < 0)
return;
while (Processes.Count <= progressLine)
{
Processes.Add(new ProgressInfo("", Processes.Count, 0, TimeSpan.Zero, 0));
}
if (Processes[progressLine].Name == "")
NumberOfProcesses += 1;
Processes[progressLine] = new ProgressInfo(name, progressLine, progress, eta, speed);
}
}
}

View File

@ -10,6 +10,4 @@ public partial class StatusBarViewModel : ObservableObject
[ObservableProperty]
private double _percent;
[ObservableProperty]
private string _threadInfo = "Threads: 0/0";
}

View File

@ -5,7 +5,9 @@
xmlns:views="clr-namespace:Splitter_UI.Views"
xmlns:conv="clr-namespace:Splitter_UI.Converters"
x:Class="Splitter_UI.Views.FileListView"
x:DataType="vm:FileListViewModel">
x:DataType="vm:FileListViewModel"
KeyDown="OnKeyDown"
Focusable="True">
<UserControl.Resources>
<conv:ZeroToBoolConverter x:Key="ZeroToBoolConverter"/>
@ -67,47 +69,10 @@
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:JobViewModel">
<Border x:Name="ItemRoot"
Margin="0"
Padding="0"
CornerRadius="4"
Background="#2A2A2A">
<StackPanel MinWidth="160" MaxWidth="160">
<Border Width="160" Height="90" ClipToBounds="True">
<Grid>
<Image Source="{Binding Thumbnail}"
Stretch="UniformToFill"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="{Binding SuggestedAction, Converter={StaticResource ActionToIconConverter}}"
FontSize="12"
HorizontalAlignment="Right"
Foreground="LimeGreen"/>
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="{Binding Rotate, Converter={StaticResource RotationAngleToIconConverter}}"
FontSize="12"
HorizontalAlignment="Left"
Foreground="LimeGreen"/>
</Grid>
</Border>
<TextBlock Text="{Binding FileName}"
TextWrapping="Wrap"
Margin="0,6,0,0"
FontSize="10"/>
<ProgressBar MinWidth="160"
MaxWidth="160"
Height="10"
Margin="0,4,0,0"
Value="{Binding Progress.Percent}" />
</StackPanel>
</Border>
<views:JobListItemView/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</Grid>

View File

@ -21,6 +21,14 @@ public partial class FileListView : UserControl
InitializeComponent();
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Delete)
{
if (DataContext is FileListViewModel vm)
vm.DeleteSelected();
}
}
private void OnDragEnter(object? sender, DragEventArgs e)
{
IsDragActive = true;

View File

@ -147,10 +147,21 @@ x:DataType="vm:InspectorPaneViewModel">
<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"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,10,0,0">
<Button Content="Apply to Selected"
Command="{Binding ApplyOverridesCommand}"/>
<Button Content="Transform all"
Background="#AA0000"
Foreground="White"
Command="{Binding TransformAllCommand}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>

View File

@ -0,0 +1,50 @@
<UserControl
x:Class="Splitter_UI.Views.JobListItemView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
xmlns:conv="clr-namespace:Splitter_UI.Converters"
x:DataType="vm:JobViewModel">
<UserControl.Resources>
<conv:RotationAngleToIconConverter x:Key="RotationAngleToIconConverter"/>
<conv:ActionToIconConverter x:Key="ActionToIconConverter"/>
</UserControl.Resources>
<Border Margin="0"
Padding="0"
CornerRadius="4"
Background="#2A2A2A">
<StackPanel MinWidth="160" MaxWidth="160">
<Border Width="160" Height="90" ClipToBounds="True">
<Grid>
<Image Source="{Binding Thumbnail}"
Stretch="UniformToFill"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="{Binding Rotate, Converter={StaticResource ActionToIconConverter}}"
FontSize="12"
HorizontalAlignment="Right"
Foreground="LimeGreen"/>
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="{Binding Rotate, Converter={StaticResource RotationAngleToIconConverter}}"
FontSize="12"
HorizontalAlignment="Left"
Foreground="LimeGreen"/>
</Grid>
</Border>
<TextBlock Text="{Binding FileName}"
TextWrapping="Wrap"
Margin="0,6,0,0"
FontSize="10"/>
</StackPanel>
</Border>
</UserControl>

View File

@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class JobListItemView : UserControl
{
public JobListItemView()
{
InitializeComponent();
}
}

View File

@ -2,15 +2,30 @@
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Splitter_UI.Views.LogPane"
xmlns:conv="clr-namespace:Splitter_UI.Converters"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
x:DataType="vm:LogPaneViewModel">
<UserControl.Resources>
<conv:ConsoleColorToBrushConverter x:Key="ConsoleColorToBrushConverter"/>
</UserControl.Resources>
<Border Background="#111" Padding="8">
<ScrollViewer>
<ScrollViewer x:Name="Scroller">
<ItemsControl ItemsSource="{Binding Logs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontFamily="Consolas" FontSize="12"/>
<DataTemplate x:DataType="vm:LogEntry">
<StackPanel Orientation="Horizontal">
<TextBlock Text="[" FontFamily="Consolas" FontSize="12"/>
<TextBlock Text="{Binding Prefix}"
FontFamily="Consolas"
FontSize="12"
Foreground="{Binding Color, Converter={StaticResource ConsoleColorToBrushConverter}}"/>
<TextBlock Text="] " FontFamily="Consolas" FontSize="12"/>
<TextBlock Text="{Binding Message}"
FontFamily="Consolas"
FontSize="12"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

View File

@ -1,4 +1,5 @@
using Avalonia.Controls;
using Avalonia.Threading;
namespace Splitter_UI.Views;
@ -7,5 +8,24 @@ public partial class LogPane : UserControl
public LogPane()
{
InitializeComponent();
// When DataContext changes, subscribe to collection changes
this.DataContextChanged += (_, _) =>
{
if (DataContext is LogPaneViewModel vm)
{
vm.Logs.CollectionChanged += (_, _) => ScrollToEnd();
}
};
}
private void ScrollToEnd()
{
// Must run after layout pass
Dispatcher.UIThread.Post(() =>
{
if (Scroller != null)
Scroller.ScrollToEnd();
}, DispatcherPriority.Background);
}
}

View File

@ -3,12 +3,18 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:Splitter_UI.Views"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
xmlns:conv="clr-namespace:Splitter_UI.Converters"
x:Class="Splitter_UI.Views.MainWindow"
x:DataType="vm:MainViewModel"
x:Name="Root"
Width="1400"
Height="950"
Title="Splitter UI">
<Window.Resources>
<conv:BoolInvertConverter x:Key="BoolInvertConverter"/>
</Window.Resources>
<DockPanel>
<!-- Status Bar -->
@ -19,21 +25,28 @@
<views:LogPane DockPanel.Dock="Bottom" Height="150"
DataContext="{Binding LogPane}" />
<!-- Main Content -->
<Grid ColumnDefinitions="2*,3*,430">
<Grid>
<!-- Main Content -->
<Grid ColumnDefinitions="2*,3*,430" IsVisible="{Binding TransformMode, Converter={StaticResource BoolInvertConverter}}">
<!-- File List -->
<views:FileListView Grid.Column="0"
DataContext="{Binding FileList}" />
<!-- File List -->
<views:FileListView Grid.Column="0"
DataContext="{Binding FileList}" />
<!-- Preview -->
<views:PreviewPane Grid.Column="1"
DataContext="{Binding Preview}" />
<!-- Preview -->
<views:PreviewPane Grid.Column="1"
DataContext="{Binding Preview}" />
<!-- Inspector -->
<views:InspectorPane Grid.Column="2"
DataContext="{Binding Inspector}" />
<!-- Inspector -->
<views:InspectorPane Grid.Column="2"
DataContext="{Binding Inspector}" />
</Grid>
<!-- Progress view (replaces entire grid) -->
<views:ProgressView
DataContext="{Binding Progress}"
IsVisible="{Binding #Root.DataContext.TransformMode}"/>
</Grid>
</DockPanel>

View File

@ -1,5 +1,3 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class MainWindow : Avalonia.Controls.Window

View File

@ -4,7 +4,6 @@ using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Threading;
using splitter.algo;
namespace Splitter_UI.Views;

View File

@ -14,7 +14,7 @@
Preview="{Binding Preview}"
Sar="{Binding Sar}"
RotateAngle="{Binding Rotate}"
GravitateTo="{Binding GravitateTo}"/>
GravitateTo="{Binding GravitateTo, Mode=TwoWay}"/>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto"

View File

@ -0,0 +1,50 @@
<UserControl
x:Class="Splitter_UI.Views.ProgressView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
x:DataType="vm:ProgressViewModel">
<Border Background="#111" Padding="8">
<ItemsControl ItemsSource="{Binding Processes}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ProgressInfo">
<Grid ColumnDefinitions="2*,3*,Auto,Auto"
Margin="0,2">
<!-- Name -->
<TextBlock Grid.Column="0"
Text="{Binding Name}"
VerticalAlignment="Center"
FontSize="12"/>
<!-- Progress bar -->
<ProgressBar Grid.Column="1"
Height="12"
Minimum="0"
Maximum="1"
Value="{Binding Progress}"
Margin="8,0"/>
<!-- ETA -->
<TextBlock Grid.Column="2"
Width="70"
Text="{Binding Eta, StringFormat={}{0:hh\\:mm\\:ss}}"
VerticalAlignment="Center"
Margin="12,0"
FontSize="12"/>
<!-- Speed -->
<TextBlock Grid.Column="3"
Width="70"
Text="{Binding Speed, StringFormat={}{0:0.00}}"
VerticalAlignment="Center"
Margin="12,0"
FontSize="12"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</UserControl>

View File

@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class ProgressView : UserControl
{
public ProgressView()
{
InitializeComponent();
}
}

View File

@ -12,13 +12,13 @@
Text="{Binding StatusText}" />
<ProgressBar Grid.Column="1"
Width="200" Height="16"
Width="200"
Height="16"
Minimum="0"
Maximum="1"
VerticalAlignment="Center"
Value="{Binding Percent}" />
<TextBlock Grid.Column="2"
VerticalAlignment="Center"
Text="{Binding ThreadInfo}" />
</Grid>
</Border>
</UserControl>

View File

@ -1,5 +1,4 @@
using System.Globalization;
using splitter.algo;
using splitter.util;
namespace splitter;

View File

@ -0,0 +1,7 @@
namespace splitter;
public interface IJobProcessor
{
Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly);
Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded);
}

View File

@ -0,0 +1,217 @@
using System.Collections.Concurrent;
using System.Diagnostics;
namespace splitter;
public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcessor
{
public async Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly)
{
var baseName = Path.GetFileNameWithoutExtension(job.InputFile);
if (!File.Exists(job.InputFile))
{
LogError($"{baseName}: Input file not found.");
return [];
}
if (!Directory.Exists(job.OutputFolder))
Directory.CreateDirectory(job.OutputFolder);
var info = await ProbeVideo.Probe(job.InputFile, job.RotateAuto);
if (info.Duration <= 0)
{
LogError($"{baseName}: Could not read duration.");
return [];
}
var target = job.OverrideTargetDuration ?? 58.0;
int segments;
double segmentLength;
if (job.ForceFixed)
{
// Fixed chunk size, last one may be shorter
segments = (int)Math.Ceiling(info.Duration / target);
segmentLength = target;
}
else
{
// Equalized segments
segments = (int)Math.Ceiling(info.Duration / target);
segmentLength = info.Duration / segments;
}
LogInfo($"{baseName}: Duration {info.Duration:F2}s, {info.Width}x{info.Height} @ {info.Fps:F3}fps {info.Bitrate / 1024:F0}kbps," +
$" Target duration: {target:F2}s Segments: {segments} segment length: {segmentLength:F2}s {(job.ForceFixed ? " fixed" : "")}");
if (estimateOnly)
return [];
Func<int, ISegmentProcessor> processorFactory;
if (job.Crop != null)
{
processorFactory = i =>
{
IObjectDetector detector = job.Detect switch
{
"face" => new UltraFaceDetector(_logger),
"body" => new YoloOnnxObjectDetector(_logger),
_ => throw new InvalidOperationException($"Unknown detector: {job.Detect}")
};
return new TrackingSplitter(i, detector, job, _logger);
};
}
else
{
processorFactory = i => new SimpleSplitter(i, _logger);
}
var jobs = Enumerable.Range(0, segments)
.Select(i => new SingleTask
(
Job : job,
Info: info,
OutputFileName : BuildOutputFileName(job, i),
SegmentIndex : i,
TotalSegments : segments,
SegmentStart : i * segmentLength,
SegmentLength : (i == segments - 1)
? Math.Max(0.1, info.Duration - i * segmentLength)
: segmentLength,
ProcessorFactory : processorFactory
)
)
.ToList();
return jobs;
}
public async Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded)
{
if (singleThreaded)
{
LogInfo("Starting single-threaded splitting...");
await RunSingleThreaded(tasks);
}
else
{
LogInfo("Starting multi-threaded splitting...");
await RunMultiThreaded(tasks);
}
LogInfo("Done.");
return true;
}
private void LogProgress(double progress, TimeSpan eta, double speed) => _logger.DrawProgress("Total", 0, progress, eta, speed);
// -----------------------------
// ffprobe
// -----------------------------
// -----------------------------
// Multi-threaded splitting
// -----------------------------
private async Task RunMultiThreaded(List<SingleTask> jobs)
{
LogProgress(0.0, TimeSpan.Zero, 0.0);
var maxDegree = Math.Max(1, Environment.ProcessorCount / 2);
using var sem = new SemaphoreSlim(maxDegree);
var tasks = new List<Task>();
// Slot pool: 0..maxDegree-1
var freeSlots = new ConcurrentQueue<int>(Enumerable.Range(0, maxDegree));
var totalSegments = jobs.Count;
var processedSegments = 0;
var totalDuration = jobs.Sum(j => j.SegmentLength);
var sw = Stopwatch.StartNew();
foreach (var job in jobs)
{
await sem.WaitAsync();
tasks.Add(Task.Run(async () =>
{
int slot = -1;
try
{
// Acquire a slot ID
while (!freeSlots.TryDequeue(out slot))
await Task.Yield();
await ProcessSegment(job, slot + 1);
var processed = Interlocked.Increment(ref processedSegments);
var elapsed = sw.Elapsed;
var eta = TimeSpan.FromTicks(elapsed.Ticks * (totalSegments - processed) / processed);
var speed = (processed * totalDuration) / elapsed.TotalSeconds;
LogProgress((double)processed / totalSegments, eta, speed);
}
finally
{
// Return slot to pool
if (slot >= 0)
freeSlots.Enqueue(slot);
sem.Release();
}
}));
}
await Task.WhenAll(tasks);
}
// -----------------------------
// Single-threaded splitting
// -----------------------------
private async Task RunSingleThreaded(List<SingleTask> jobs)
{
foreach (var job in jobs)
{
await ProcessSegment(job, 0);
}
}
private async Task ProcessSegment(SingleTask t, int slot)
{
var processor = t.ProcessorFactory(slot);
try
{
await processor.ProcessSegment(t);
}
finally
{
if (processor is IDisposable disposable)
disposable.Dispose();
}
}
private static string BuildOutputFileName(SingleJob job, int index)
{
string fileName;
fileName = Path.GetFileName(job.Mask ?? "[NAME]_seg[NN].[EXT]")
.Replace("[NAME]", Path.GetFileNameWithoutExtension(job.InputFile))
.Replace("[N]", index.ToString())
.Replace("[NN]", index.ToString("00"))
.Replace("[NNN]", index.ToString("000"))
.Replace("[NNNN]", index.ToString("0000"))
.Replace("[EXT]", Path.GetExtension(job.InputFile).TrimStart('.'))
;
return Path.Combine(job.OutputFolder, fileName);
}
}

View File

@ -1,7 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using splitter.algo;
using splitter.tui;
namespace splitter;
@ -59,7 +57,7 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger,
proc.WaitForExit();
ClearProgress();
ClearProgress(name);
if (proc.ExitCode != 0)
LogError($"Segment {name} FFmpeg encoding failed");

View File

@ -1,5 +1,4 @@
using System.Globalization;
using splitter.algo;
namespace splitter;

View File

@ -1,9 +1,6 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using OpenCvSharp;
using splitter.algo;
using splitter.tui;
namespace splitter;
@ -155,7 +152,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
try { if (!decode.HasExited) decode.Kill(entireProcessTree: true); } catch { }
try { if (!decode.HasExited) await decode.WaitForExitAsync(); } catch { }
ClearProgress();
ClearProgress(name);
if (encode.ExitCode != 0)

View File

@ -1,6 +1,4 @@
using OpenCvSharp;
namespace splitter.algo;
namespace splitter.algo;
public enum TrackState
{

View File

@ -1,6 +1,4 @@
using OpenCvSharp;
namespace splitter.algo;
namespace splitter.algo;
public interface IObjectDetector : IDisposable
{

View File

@ -1,6 +1,4 @@
using System.Runtime.InteropServices;
using OpenCvSharp;
using splitter.tui;
using UltraFaceDotNet;
namespace splitter.algo;

View File

@ -1,8 +1,6 @@
using System.Runtime.CompilerServices;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp;
using splitter.tui;
namespace splitter.algo;

View File

@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace splitter.probe;

View File

@ -1,9 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
using static splitter.probe.ProbeVideo;
namespace splitter.probe;
namespace splitter.probe;
public sealed class FfprobeResult
{

View File

@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace splitter.probe;

View File

@ -1,6 +1,4 @@
using OpenCvSharp;
namespace splitter.probe;
namespace splitter.probe;
public sealed class FrameRotationDetector
{

View File

@ -2,7 +2,6 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using splitter.algo;
namespace splitter.probe;

View File

@ -1,7 +1,4 @@
using OpenCvSharp;
using splitter.algo;
namespace splitter.probe;
namespace splitter.probe;
public record VideoInfo(
double Duration,

View File

@ -1,5 +1,4 @@
using OpenCvSharp;
using System.Diagnostics;
using System.Diagnostics;
namespace splitter.probe;

View File

@ -1,10 +1,4 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Spectre.Console;
using splitter;
using splitter.algo;
using splitter.probe;
using splitter.tui;
static partial class Program
{
@ -36,24 +30,26 @@ static partial class Program
uiTask = logger.RunAsync(cts.Token);
}
var processor = new JobProcessor(_logger);
if (cmd.Master.EstimateOnly)
LogInfo("=== ESTIMATE MODE ===");
_logger.LogInfo("=== ESTIMATE MODE ===");
var allJobs = new List<SingleTask>();
foreach ( var job in cmd.Jobs )
{
var jobs = await GenerateJobs(cmd, job);
var jobs = await processor.GenerateJobs(job, cmd.Master.EstimateOnly);
allJobs.AddRange(jobs);
}
if ( allJobs.Count == 0)
{
if ( !cmd.Master.EstimateOnly)
LogWarn("No valid jobs to process.");
_logger.LogWarn("No valid jobs to process.");
return 0;
}
var success = await ProcessJobs(cmd, allJobs);
var success = await processor.ProcessJobs(allJobs, cmd.Master.SingleThreaded);
if (uiTask != null)
{
if ( cts != null )
@ -66,217 +62,4 @@ static partial class Program
return success ? 1 : 0;
}
private static async Task<List<SingleTask>> GenerateJobs(CommandLine cmd, SingleJob job)
{
var baseName = Path.GetFileNameWithoutExtension(job.InputFile);
if (!File.Exists(job.InputFile))
{
LogError($"{baseName}: Input file not found.");
return [];
}
if (!Directory.Exists(job.OutputFolder))
Directory.CreateDirectory(job.OutputFolder);
var info = await ProbeVideo.Probe(job.InputFile, job.RotateAuto);
if (info.Duration <= 0)
{
LogError($"{baseName}: Could not read duration.");
return [];
}
var target = job.OverrideTargetDuration ?? 58.0;
int segments;
double segmentLength;
if (job.ForceFixed)
{
// Fixed chunk size, last one may be shorter
segments = (int)Math.Ceiling(info.Duration / target);
segmentLength = target;
}
else
{
// Equalized segments
segments = (int)Math.Ceiling(info.Duration / target);
segmentLength = info.Duration / segments;
}
LogInfo($"{baseName}: Duration {info.Duration:F2}s, {info.Width}x{info.Height} @ {info.Fps:F3}fps {info.Bitrate/1024:F0}kbps," +
$" Target duration: {target:F2}s Segments: {segments} segment length: {segmentLength:F2}s {(job.ForceFixed ? " fixed" : "")}" );
if (cmd.Master.EstimateOnly)
return [];
Func<int, ISegmentProcessor> processorFactory;
if (job.Crop != null)
{
processorFactory = i =>
{
IObjectDetector detector = job.Detect switch
{
"face" => new UltraFaceDetector(_logger),
"body" => new YoloOnnxObjectDetector(_logger),
_ => throw new InvalidOperationException($"Unknown detector: {job.Detect}")
};
return new TrackingSplitter(i, detector, job, _logger);
};
}
else
{
processorFactory = i => new SimpleSplitter(i, _logger);
}
var jobs = Enumerable.Range(0, segments)
.Select(i => new SingleTask
(
Job : job,
Info: info,
OutputFileName : BuildOutputFileName(job, i),
SegmentIndex : i,
TotalSegments : segments,
SegmentStart : i * segmentLength,
SegmentLength : (i == segments - 1)
? Math.Max(0.1, info.Duration - i * segmentLength)
: segmentLength,
ProcessorFactory : processorFactory
)
)
.ToList();
return jobs;
}
private static async Task<bool> ProcessJobs(CommandLine cmd, List<SingleTask> tasks)
{
if (cmd.Master.SingleThreaded)
{
LogInfo("Starting single-threaded splitting...");
await RunSingleThreaded(tasks);
}
else
{
LogInfo("Starting multi-threaded splitting...");
await RunMultiThreaded(tasks);
}
LogInfo("Done.");
return true;
}
private static void LogInfo(string message) => _logger.LogInfo(message);
private static void LogWarn(string message) => _logger.LogWarn(message);
private static void LogError(string message) => _logger.LogError(message);
private static void LogProgress(double progress, TimeSpan eta, double speed) => _logger.DrawProgress("Total", 0, progress, eta, speed);
// -----------------------------
// ffprobe
// -----------------------------
// -----------------------------
// Multi-threaded splitting
// -----------------------------
static async Task RunMultiThreaded(List<SingleTask> jobs)
{
LogProgress(0.0, TimeSpan.Zero, 0.0);
var maxDegree = Math.Max(1, Environment.ProcessorCount / 2);
using var sem = new SemaphoreSlim(maxDegree);
var tasks = new List<Task>();
// Slot pool: 0..maxDegree-1
var freeSlots = new ConcurrentQueue<int>(Enumerable.Range(0, maxDegree));
var totalSegments = jobs.Count;
var processedSegments = 0;
var totalDuration = jobs.Sum(j => j.SegmentLength);
var sw = Stopwatch.StartNew();
foreach (var job in jobs)
{
await sem.WaitAsync();
tasks.Add(Task.Run(async () =>
{
int slot = -1;
try
{
// Acquire a slot ID
while (!freeSlots.TryDequeue(out slot))
await Task.Yield();
await ProcessSegment(job,slot + 1);
var processed = Interlocked.Increment(ref processedSegments);
var elapsed = sw.Elapsed;
var eta = TimeSpan.FromTicks(elapsed.Ticks * (totalSegments - processed) / processed);
var speed = (processed * totalDuration) / elapsed.TotalSeconds;
LogProgress((double)processed / totalSegments, eta, speed);
}
finally
{
// Return slot to pool
if (slot >= 0)
freeSlots.Enqueue(slot);
sem.Release();
}
}));
}
await Task.WhenAll(tasks);
}
// -----------------------------
// Single-threaded splitting
// -----------------------------
static async Task RunSingleThreaded(List<SingleTask> jobs)
{
foreach (var job in jobs)
{
await ProcessSegment(job, 0);
}
}
private static async Task ProcessSegment(SingleTask t, int slot)
{
var processor = t.ProcessorFactory(slot);
try
{
await processor.ProcessSegment(t);
}
finally
{
if (processor is IDisposable disposable)
disposable.Dispose();
}
}
static string BuildOutputFileName(SingleJob job, int index)
{
string fileName;
fileName = Path.GetFileName(job.Mask ?? "[NAME]_seg[NN].[EXT]")
.Replace("[NAME]", Path.GetFileNameWithoutExtension(job.InputFile))
.Replace("[N]" , index.ToString())
.Replace("[NN]" , index.ToString("00"))
.Replace("[NNN]" , index.ToString("000"))
.Replace("[NNNN]", index.ToString("0000"))
.Replace("[EXT]" , Path.GetExtension(job.InputFile).TrimStart('.'))
;
return Path.Combine(job.OutputFolder, fileName);
}
}

View File

@ -2,7 +2,7 @@
public interface ILogger
{
void ClearProgress(int progressLevel);
void ClearProgress(string name, int progressLine);
void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed);
void Log(string prefix, ConsoleColor color, string msg);

View File

@ -1,7 +1,11 @@
namespace splitter.tui;
public abstract class LoggingBase(ILogger _logger, int _progressLine)
public abstract class LoggingBase(ILogger logger, int _progressLine)
{
#pragma warning disable IDE1006 // Naming Styles
protected ILogger _logger = logger;
#pragma warning restore IDE1006 // Naming Styles
protected void Log(string level, ConsoleColor color, string message)
=> _logger.Log(level, color, message);
@ -17,6 +21,6 @@ public abstract class LoggingBase(ILogger _logger, int _progressLine)
protected void DrawProgress(string name, double percent, TimeSpan eta, double fps)
=> _logger.DrawProgress(name, _progressLine, percent, eta, fps);
protected void ClearProgress()
=> _logger.ClearProgress(_progressLine);
protected void ClearProgress(string name)
=> _logger.ClearProgress(name,_progressLine);
}

View File

@ -51,11 +51,11 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
// ---- ILogger ----
public void ClearProgress(int progressLevel)
public void ClearProgress(string name, int progressLine)
{
lock (_sync)
{
_progress[progressLevel] = ProgressEntry.Empty;
_progress[progressLine] = ProgressEntry.Empty;
}
}

View File

@ -13,6 +13,6 @@ public class TextLogger() : ILogger
}
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) {}
public void ClearProgress(int progressLevel){}
public void ClearProgress(string name, int progressLine) {}
}

View File

@ -1,6 +1,4 @@
using splitter;
using splitter.algo;
using splitter.probe;
public record SingleTask(
SingleJob Job,