Logging aded. Autoscroll for logging.

This commit is contained in:
Alexander Shabarshov 2026-05-25 10:44:05 +01:00
parent 2dc7b050c8
commit 9cdf611ec8
26 changed files with 451 additions and 350 deletions

View File

@ -0,0 +1,40 @@
using Avalonia.Data.Converters;
using Avalonia.Media;
using System;
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 +0,0 @@
namespace Splitter_UI.Models;
public class ProgressInfo
{
public double Percent { get; set; }
}

View File

@ -23,13 +23,15 @@ 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<LogPaneViewModel>(logPaveVM);
services.AddSingleton<ILogService>(logPaveVM);
// splitter services
services.AddSingleton<UltraFaceDetector>();
@ -51,8 +53,6 @@ internal sealed class Program
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,6 +1,4 @@
using OpenCvSharp.Dnn;
namespace Splitter_UI.Services;
namespace Splitter_UI.Services;
public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProbeService _fileProbe, ILogger _log) : IAutoDecisionService
{
@ -34,6 +32,8 @@ public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProb
job.Rotate = await sampler.DetectRotationAsync(job.InputFile, job.Probe.Duration);
job.Detect = job.Rotate == 0 ? null : "body";
}
_log.LogInfo(job.ToString());
}
catch (Exception ex)
{

View File

@ -1,11 +1,20 @@
namespace Splitter_UI.Services;
internal class GlobalLogger(ILogService _logService) : ILogger
internal class GlobalLogger(ILogService _logService, StatusBarViewModel _statusBar) : 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;
}
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed)
{
if (progressLine == 0)
_statusBar.Percent = progress;
}
public void Log(string prefix, ConsoleColor color, string msg)
{
_logService.Write($"[{prefix}] {msg}");
_logService.Log(prefix, color, msg);
}
}

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,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

@ -14,7 +14,6 @@ public partial class JobViewModel : ObservableObject
[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 double _sliderLiveValue;
[ObservableProperty] private double _positionSeconds;
@ -36,7 +35,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();

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

@ -4,15 +4,26 @@ 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 MainViewModel(IFileJobFactory fileJobFactory, IAutoDecisionService autoDecisionService)
public MainViewModel(
IFileJobFactory fileJobFactory,
IAutoDecisionService autoDecisionService,
PreviewPaneViewModel ppVM,
InspectorPaneViewModel iVM,
LogPaneViewModel lpVM,
StatusBarViewModel sbVM
)
{
FileList = new FileListViewModel(fileJobFactory, autoDecisionService);
FileList = new FileListViewModel(fileJobFactory, autoDecisionService);
Preview = ppVM;
Inspector = iVM;
LogPane = lpVM;
StatusBar = sbVM;
// Wire selection → preview + inspector
FileList.SelectedFileChanged += file =>
{

View File

@ -67,47 +67,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 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"/>
<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

@ -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

@ -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

@ -57,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

@ -152,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,3 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Spectre.Console;
using splitter;
static partial class Program
@ -33,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 )
@ -63,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) {}
}