mirror of
https://github.com/unclshura/splitter.git
synced 2026-06-21 16:12:01 +00:00
Compare commits
3 Commits
c6ca4fcbb6
...
af363ebb9a
| Author | SHA1 | Date | |
|---|---|---|---|
| af363ebb9a | |||
| 9cdf611ec8 | |||
| 2dc7b050c8 |
@ -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)
|
||||
{
|
||||
|
||||
@ -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)
|
||||
|
||||
15
Splitter-UI/Converters/BoolInvertConverter.cs
Normal file
15
Splitter-UI/Converters/BoolInvertConverter.cs
Normal 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;
|
||||
}
|
||||
39
Splitter-UI/Converters/ConsoleColorToBrushConverter.cs
Normal file
39
Splitter-UI/Converters/ConsoleColorToBrushConverter.cs
Normal 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();
|
||||
}
|
||||
@ -1,6 +1,4 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace Splitter_UI.Models;
|
||||
namespace Splitter_UI.Models;
|
||||
|
||||
public class PreviewData
|
||||
{
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
namespace Splitter_UI.Models;
|
||||
|
||||
public class ProgressInfo
|
||||
{
|
||||
public double Percent { get; set; }
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
|
||||
@ -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)}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Media.Imaging;
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
using OpenCvSharp;
|
||||
using splitter.algo;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
internal class DummyDetector : IObjectDetector
|
||||
{
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
using splitter.probe;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public sealed class FileProbeService : IFileProbeService
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
using splitter.probe;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public interface IFileProbeService
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public interface IProcessingService
|
||||
{
|
||||
event Action<string, ProgressInfo>? ProgressChanged;
|
||||
|
||||
Task ProcessAsync(IEnumerable<SingleJob> jobs, CancellationToken token);
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using splitter.probe;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using splitter.probe;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using splitter.algo;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
|
||||
43
Splitter-UI/ViewModels/ProgressViewModel.cs
Normal file
43
Splitter-UI/ViewModels/ProgressViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,4 @@ public partial class StatusBarViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private double _percent;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _threadInfo = "Threads: 0/0";
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
50
Splitter-UI/Views/JobListItemView.axaml
Normal file
50
Splitter-UI/Views/JobListItemView.axaml
Normal 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>
|
||||
12
Splitter-UI/Views/JobListItemView.axaml.cs
Normal file
12
Splitter-UI/Views/JobListItemView.axaml.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class JobListItemView : UserControl
|
||||
{
|
||||
public JobListItemView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class MainWindow : Avalonia.Controls.Window
|
||||
|
||||
@ -4,7 +4,6 @@ using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using splitter.algo;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
50
Splitter-UI/Views/ProgressView.axaml
Normal file
50
Splitter-UI/Views/ProgressView.axaml
Normal 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>
|
||||
12
Splitter-UI/Views/ProgressView.axaml.cs
Normal file
12
Splitter-UI/Views/ProgressView.axaml.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class ProgressView : UserControl
|
||||
{
|
||||
public ProgressView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
using System.Globalization;
|
||||
using splitter.algo;
|
||||
using splitter.util;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
7
splitter-cli/IJobProcessor.cs
Normal file
7
splitter-cli/IJobProcessor.cs
Normal 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);
|
||||
}
|
||||
217
splitter-cli/JobProcessor.cs
Normal file
217
splitter-cli/JobProcessor.cs
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
using System.Globalization;
|
||||
using splitter.algo;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter.algo;
|
||||
namespace splitter.algo;
|
||||
|
||||
public enum TrackState
|
||||
{
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter.algo;
|
||||
namespace splitter.algo;
|
||||
|
||||
public interface IObjectDetector : IDisposable
|
||||
{
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using OpenCvSharp;
|
||||
using splitter.tui;
|
||||
using UltraFaceDotNet;
|
||||
|
||||
namespace splitter.algo;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter.probe;
|
||||
namespace splitter.probe;
|
||||
|
||||
public sealed class FrameRotationDetector
|
||||
{
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using splitter.algo;
|
||||
|
||||
namespace splitter.probe;
|
||||
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
using OpenCvSharp;
|
||||
using splitter.algo;
|
||||
|
||||
namespace splitter.probe;
|
||||
namespace splitter.probe;
|
||||
|
||||
public record VideoInfo(
|
||||
double Duration,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
using OpenCvSharp;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace splitter.probe;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {}
|
||||
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
using splitter;
|
||||
using splitter.algo;
|
||||
using splitter.probe;
|
||||
|
||||
public record SingleTask(
|
||||
SingleJob Job,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user