Cancellation support added

This commit is contained in:
Alexander Shabarshov 2026-05-25 15:03:39 +01:00
parent af363ebb9a
commit 23bfdc8452
16 changed files with 135 additions and 75 deletions

View File

@ -2,12 +2,12 @@
public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProbeService _fileProbe, ILogger _log) : IAutoDecisionService
{
public void ApplyAutoDecisions(JobViewModel job)
public void ApplyAutoDecisions(JobViewModel job, CancellationToken token)
{
Task.Run(() => Detect(job));
Task.Run(() => Detect(job, token));
}
private async Task Detect(JobViewModel job)
private async Task Detect(JobViewModel job, CancellationToken token)
{
try
{
@ -16,7 +16,7 @@ public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProb
job.Mask = "[NAME]_seg[NN].[EXT]";
job.OutputFolder = Path.Combine(Path.GetDirectoryName(job.InputFile)!, "splitter");
job.Probe = await _fileProbe.ProbeAsync(job.InputFile);
job.Probe = await _fileProbe.ProbeAsync(job.InputFile, token);
job.Thumbnail = await _thumbnails.CreateThumbnailAsync(job.InputFile, job.Probe, rotateDegree: job.Rotate);
if (job.Probe.Width > job.Probe.Height)
@ -29,7 +29,7 @@ public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProb
else
{
var sampler = new VideoRotationSampler(null);
job.Rotate = await sampler.DetectRotationAsync(job.InputFile, job.Probe.Duration);
job.Rotate = await sampler.DetectRotationAsync(job.InputFile, job.Probe.Duration, token);
job.Detect = job.Rotate == 0 ? null : "body";
}

View File

@ -2,9 +2,9 @@
public sealed class FileProbeService : IFileProbeService
{
public async Task<VideoInfo> ProbeAsync(string inputFile)
public async Task<VideoInfo> ProbeAsync(string inputFile, CancellationToken token)
{
var res = await Task.Run(() => ProbeVideo.Probe(inputFile, false));
var res = await Task.Run(() => ProbeVideo.Probe(inputFile, false, token), token);
return res;
}
}

View File

@ -2,5 +2,5 @@
public interface IAutoDecisionService
{
void ApplyAutoDecisions(JobViewModel job);
void ApplyAutoDecisions(JobViewModel job, CancellationToken token);
}

View File

@ -2,5 +2,5 @@
public interface IFileProbeService
{
Task<VideoInfo> ProbeAsync(string inputFile);
Task<VideoInfo> ProbeAsync(string inputFile, CancellationToken token);
}

View File

@ -34,7 +34,7 @@ public partial class FileListViewModel : ObservableObject
var job = new SingleJob { InputFile = path };
var vm = _factory.Create(job);
Files.Add(vm);
_autoDecisionService.ApplyAutoDecisions(vm);
_autoDecisionService.ApplyAutoDecisions(vm, CancellationToken.None);
}
Selected = Files.LastOrDefault();

View File

@ -15,6 +15,8 @@ public partial class MainViewModel : ViewModelBase
[ObservableProperty] private bool _transformMode = false;
private ILogger _logger;
private CancellationTokenSource? _cancellationTokenSource;
public MainViewModel(
FileListViewModel fileListVM,
PreviewPaneViewModel ppVM,
@ -42,12 +44,19 @@ public partial class MainViewModel : ViewModelBase
Inspector.Selected = file;
};
Progress.SetMain(this);
Inspector.SetMain(this);
Inspector.Files = FileList.Files;
}
public void Cancel()
{
_cancellationTokenSource?.Cancel();
}
public async Task Start()
{
_cancellationTokenSource = new CancellationTokenSource();
try
{
StatusBar.StatusText = "Processing…";
@ -59,23 +68,26 @@ public partial class MainViewModel : ViewModelBase
foreach (var file in files)
{
var fileJobs = await _processor.GenerateJobs(file.GetJob(), false);
var fileJobs = await _processor.GenerateJobs(file.GetJob(), false, _cancellationTokenSource.Token);
jobs.AddRange(fileJobs);
}
await _processor.ProcessJobs(jobs, false);
await _processor.ProcessJobs(jobs, false, _cancellationTokenSource.Token);
}
catch (Exception ex)
{
// Handle exception
StatusBar.StatusText = "Error occurred…";
_logger.LogError($"Error: {ex.Message}");
_cancellationTokenSource.Cancel();
}
finally
{
StatusBar.StatusText = "Ready…";
StatusBar.Percent = 0;
TransformMode = false;
_cancellationTokenSource?.Dispose();
}
}

View File

@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Splitter_UI.Views;
namespace Splitter_UI.ViewModels;
@ -11,6 +13,20 @@ public partial class ProgressViewModel : ObservableObject
public ObservableCollection<ProgressInfo> Processes { get; } = [];
private Lock _lock = new();
private MainViewModel _mainModel = null!;
[RelayCommand]
private void Cancel()
{
_mainModel.Cancel();
}
public void SetMain(MainViewModel mainModel)
{
_mainModel = mainModel;
}
public void ClearProgress(string name, int progressLine)
{
lock (_lock)

View File

@ -5,20 +5,20 @@
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
x:DataType="vm:ProgressViewModel">
<Border Background="#111" Padding="8">
<ItemsControl ItemsSource="{Binding Processes}">
<Grid RowDefinitions="*,Auto" Background="#111">
<!-- Processes list -->
<ItemsControl Grid.Row="0" 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"
@ -26,7 +26,6 @@
Value="{Binding Progress}"
Margin="8,0"/>
<!-- ETA -->
<TextBlock Grid.Column="2"
Width="70"
Text="{Binding Eta, StringFormat={}{0:hh\\:mm\\:ss}}"
@ -34,7 +33,6 @@
Margin="12,0"
FontSize="12"/>
<!-- Speed -->
<TextBlock Grid.Column="3"
Width="70"
Text="{Binding Speed, StringFormat={}{0:0.00}}"
@ -46,5 +44,20 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- Bottom-right Cancel button -->
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,12,0,0">
<Button Content="Cancel"
Background="#700000"
Foreground="White"
Padding="12,6"
Margin="0,0,10,10"
Command="{Binding CancelCommand}"/>
</StackPanel>
</Grid>
</UserControl>

View File

@ -2,6 +2,6 @@
public interface IJobProcessor
{
Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly);
Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded);
Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, CancellationToken token);
Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token);
}

View File

@ -5,7 +5,7 @@ namespace splitter;
public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcessor
{
public async Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly)
public async Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, CancellationToken token)
{
var baseName = Path.GetFileNameWithoutExtension(job.InputFile);
@ -18,7 +18,14 @@ public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcesso
if (!Directory.Exists(job.OutputFolder))
Directory.CreateDirectory(job.OutputFolder);
var info = await ProbeVideo.Probe(job.InputFile, job.RotateAuto);
if (token.IsCancellationRequested)
return [];
var info = await ProbeVideo.Probe(job.InputFile, job.RotateAuto, token);
if (token.IsCancellationRequested)
return [];
if (info.Duration <= 0)
{
LogError($"{baseName}: Could not read duration.");
@ -88,18 +95,18 @@ public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcesso
return jobs;
}
public async Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded)
public async Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token)
{
if (singleThreaded)
{
LogInfo("Starting single-threaded splitting...");
await RunSingleThreaded(tasks);
await RunSingleThreaded(tasks, token);
}
else
{
LogInfo("Starting multi-threaded splitting...");
await RunMultiThreaded(tasks);
await RunMultiThreaded(tasks, token);
}
LogInfo("Done.");
@ -116,7 +123,7 @@ public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcesso
// Multi-threaded splitting
// -----------------------------
private async Task RunMultiThreaded(List<SingleTask> jobs)
private async Task RunMultiThreaded(List<SingleTask> jobs, CancellationToken token)
{
LogProgress(0.0, TimeSpan.Zero, 0.0);
@ -135,7 +142,7 @@ public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcesso
foreach (var job in jobs)
{
await sem.WaitAsync();
await sem.WaitAsync(token);
tasks.Add(Task.Run(async () =>
{
@ -145,9 +152,12 @@ public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcesso
{
// Acquire a slot ID
while (!freeSlots.TryDequeue(out slot))
{
if ( token.IsCancellationRequested)
return;
await Task.Yield();
await ProcessSegment(job, slot + 1);
}
await ProcessSegment(job, slot + 1, token);
var processed = Interlocked.Increment(ref processedSegments);
var elapsed = sw.Elapsed;
@ -174,21 +184,21 @@ public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcesso
// Single-threaded splitting
// -----------------------------
private async Task RunSingleThreaded(List<SingleTask> jobs)
private async Task RunSingleThreaded(List<SingleTask> jobs, CancellationToken token)
{
foreach (var job in jobs)
{
await ProcessSegment(job, 0);
await ProcessSegment(job, 0, token);
}
}
private async Task ProcessSegment(SingleTask t, int slot)
private async Task ProcessSegment(SingleTask t, int slot, CancellationToken token)
{
var processor = t.ProcessorFactory(slot);
try
{
await processor.ProcessSegment(t);
await processor.ProcessSegment(t, token);
}
finally
{

View File

@ -5,7 +5,7 @@ namespace splitter;
public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, segmentNo), ISegmentProcessor
{
public async Task ProcessSegment(SingleTask job)
public async Task ProcessSegment(SingleTask job, CancellationToken token)
{
string inputFile = job.Job.InputFile;
string outputFile = job.OutputFileName;
@ -43,19 +43,19 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger,
var psi = new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments = args,
FileName = "ffmpeg",
Arguments = args,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg.");
var name = Path.GetFileNameWithoutExtension(outputFile);
ShowFFMpegProgress(length, proc, name);
await ShowFFMpegProgress(length, proc, name, token);
proc.WaitForExit();
await proc.WaitForExitAsync(token);
ClearProgress(name);
@ -75,12 +75,12 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger,
};
private void ShowFFMpegProgress(double length, Process proc, string name)
private async Task ShowFFMpegProgress(double length, Process proc, string name, CancellationToken token)
{
var sw = Stopwatch.StartNew();
string? line;
while ((line = proc.StandardError.ReadLine()) != null)
while ((line = await proc.StandardError.ReadLineAsync(token)) != null)
{
// Look for "time=00:00:03.52"
var idx = line.IndexOf("time=", StringComparison.OrdinalIgnoreCase);

View File

@ -24,7 +24,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
d.Dispose();
}
public async Task ProcessSegment(SingleTask job)
public async Task ProcessSegment(SingleTask job, CancellationToken token)
{
string inputFile = job.Job.InputFile;
string outputFile = job.OutputFileName;
@ -57,11 +57,11 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
LogInfo($"{name}: src={videoWidth}x{videoHeight} @ {fps:F3}fps, seg=[{start:F3},{length:F3}] enc={encWidth}x{encHeight}");
// 2) Start FFmpeg decode (video only → raw BGR24 to stdout)
var decode = StartFfmpegDecode(inputFile, start, length, job.Job.Rotate, job.Job.PlainText);
var decode = await StartFfmpegDecode(inputFile, start, length, job.Job.Rotate, job.Job.PlainText, token);
using var decodeStdout = decode.StandardOutput.BaseStream;
// 3) Start FFmpeg encode (video from stdin + audio from original)
var encode = StartFfmpegEncode(
var encode = await StartFfmpegEncode(
inputFile,
outputFile,
start,
@ -70,7 +70,8 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
encHeight,
fps,
ffmpegPassthroughParameters,
job.Job.PlainText);
job.Job.PlainText,
token);
using var encodeStdin = encode.StandardInput.BaseStream;
@ -99,9 +100,11 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
while (frameIndex < totalFrames)
{
token.ThrowIfCancellationRequested();
frameIndex++;
var read = ReadExact(decodeStdout, inBuffer, 0, inBytes);
var read = await ReadExact(decodeStdout, inBuffer, 0, inBytes, token);
if (read != inBytes)
break;
@ -164,7 +167,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
// ---------- FFmpeg decode / encode ----------
private Process StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText)
private async Task<Process> StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText, CancellationToken token)
{
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
@ -192,12 +195,12 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
var fileName = Path.GetFileName(inputFile);
_ = Task.Run(() =>
_ = Task.Run(async () =>
{
try
{
string? line;
while ((line = p.StandardError.ReadLine()) != null)
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
if (plainText)
LogInfo($"[ffmpeg-decode] {fileName}: {line}");
}
@ -223,7 +226,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
return rotateStr;
}
private Process StartFfmpegEncode(
private async Task<Process> StartFfmpegEncode(
string inputFile,
string outputFile,
double start,
@ -232,7 +235,8 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
int height,
double fps,
string[] passthrough,
bool plainText)
bool plainText,
CancellationToken token)
{
var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : "";
var fpsStr = fps.ToString("0.###", CultureInfo.InvariantCulture);
@ -266,12 +270,12 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
var fileName = Path.GetFileName(outputFile);
_ = Task.Run(() =>
_ = Task.Run(async () =>
{
try
{
string? line;
while ((line = p.StandardError.ReadLine()) != null)
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
{
if (plainText)
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
@ -285,12 +289,12 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
// ---------- helpers ----------
private static int ReadExact(Stream s, byte[] buffer, int offset, int count)
private static async Task<int> ReadExact(Stream s, byte[] buffer, int offset, int count, CancellationToken token)
{
var total = 0;
while (total < count)
{
var read = s.Read(buffer, offset + total, count - total);
var read = await s.ReadAsync(buffer, offset + total, count - total, token);
if (read <= 0)
break;
total += read;

View File

@ -2,5 +2,5 @@
public interface ISegmentProcessor
{
Task ProcessSegment( SingleTask job );
Task ProcessSegment( SingleTask job, CancellationToken token);
}

View File

@ -14,20 +14,20 @@ public static class ProbeVideo
_ffprobeJsonOptions.Converters.Add(new FlexibleLongConverter());
}
public static async Task<VideoInfo> Probe(string inputFile, bool detectRotation)
public static async Task<VideoInfo> Probe(string inputFile, bool detectRotation, CancellationToken token)
{
var info = ProbeSize(inputFile);
var info = await ProbeSize(inputFile, token);
if (detectRotation)
{
var rotation = await ProbeRotation(inputFile, info.Duration);
var rotation = await ProbeRotation(inputFile, info.Duration, token);
info = info with { Rotation = rotation };
}
return info;
}
private static async Task<int> ProbeRotation(string inputFile, double duration)
=> await new VideoRotationSampler(null).DetectRotationAsync(inputFile, duration);
private static async Task<int> ProbeRotation(string inputFile, double duration, CancellationToken token)
=> await new VideoRotationSampler(null).DetectRotationAsync(inputFile, duration, token);
private static readonly JsonSerializerOptions _ffprobeJsonOptions =
new ()
@ -39,7 +39,7 @@ public static class ProbeVideo
UnknownTypeHandling = JsonUnknownTypeHandling.JsonElement
};
private static VideoInfo ProbeSize(string inputFile)
private static async Task<VideoInfo> ProbeSize(string inputFile, CancellationToken token)
{
var args =
"-v error " +
@ -61,8 +61,8 @@ public static class ProbeVideo
using var p = new Process { StartInfo = psi };
p.Start();
var json = p.StandardOutput.ReadToEnd();
p.WaitForExit();
var json = await p.StandardOutput.ReadToEndAsync(token);
await p.WaitForExitAsync(token);
var result = JsonSerializer.Deserialize<FfprobeResult>(json, _ffprobeJsonOptions);
var stream = result?.Streams?.FirstOrDefault();

View File

@ -6,7 +6,7 @@ public sealed class VideoRotationSampler
{
private readonly FrameRotationDetector _detector = new FrameRotationDetector();
public static int RotationDetectorSampleCount = 20;
public static int RotationDetectorSampleCount = 10;
public static double RotationDetectorSampleLength = 0.15; // seconds to decode per probe
public static int RotationDetectorFrameWidth = 320;
public static int RotationDetectorFrameHeight = 180;
@ -38,7 +38,8 @@ public sealed class VideoRotationSampler
public async Task<int> DetectRotationAsync(
string inputFile,
double videoLengthSeconds)
double videoLengthSeconds,
CancellationToken token)
{
if (videoLengthSeconds <= 0)
return 0;
@ -54,7 +55,8 @@ public sealed class VideoRotationSampler
t,
RotationDetectorSampleLength,
RotationDetectorFrameWidth,
RotationDetectorFrameHeight);
RotationDetectorFrameHeight,
token);
if (frame != null && !frame.Empty())
{
@ -98,18 +100,21 @@ public sealed class VideoRotationSampler
double start,
double length,
int width,
int height)
int height,
CancellationToken token)
{
var p = StartFfmpegDecode(inputFile, start, length, rotate: null, plainText: false);
int needed = _buffer.Length;
int read = 0;
var needed = _buffer.Length;
var read = 0;
using var stdout = p.StandardOutput.BaseStream;
while (read < needed)
{
int r = await stdout.ReadAsync(_buffer, read, needed - read);
token.ThrowIfCancellationRequested();
var r = await stdout.ReadAsync(_buffer, read, needed - read, token);
if (r == 0)
return null;
read += r;

View File

@ -38,7 +38,7 @@ static partial class Program
var allJobs = new List<SingleTask>();
foreach ( var job in cmd.Jobs )
{
var jobs = await processor.GenerateJobs(job, cmd.Master.EstimateOnly);
var jobs = await processor.GenerateJobs(job, cmd.Master.EstimateOnly, CancellationToken.None);
allJobs.AddRange(jobs);
}
@ -49,7 +49,7 @@ static partial class Program
return 0;
}
var success = await processor.ProcessJobs(allJobs, cmd.Master.SingleThreaded);
var success = await processor.ProcessJobs(allJobs, cmd.Master.SingleThreaded, CancellationToken.None);
if (uiTask != null)
{
if ( cts != null )