Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions se5/ArteCheck.Tests/BatchModeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using SubtitleEdit.Plugins.ArteCheck;
using SubtitleEdit.Plugins.Shared;
using System.Text.Json;

namespace ArteCheck.Tests;

/// <summary>Subtitle Edit Batch convert: "batch" applies every correction headless, "configure" returns settings only.</summary>
public class BatchModeTests
{
private const double Hour10 = 10 * 3_600_000.0;

private static PluginRequest MakeRequest(string requestType, string? settingsJson = null) => new()
{
RequestType = requestType,
Subtitle = new PluginSubtitle
{
Format = "SubRip",
Paragraphs = requestType == PluginRequestType.Configure
? new List<PluginParagraph>()
: new List<PluginParagraph>
{
new() { StartMs = Hour10 + 1000, EndMs = Hour10 + 4000, Text = " Hello there. " },
},
},
Settings = settingsJson == null ? null : JsonDocument.Parse(settingsJson).RootElement.Clone(),
SettingsVersion = settingsJson == null ? null : 1,
};

[Fact]
public void Batch_AppliesCorrections()
{
var response = new MainViewModel(MakeRequest(PluginRequestType.Batch)).BuildBatchResponse();

Assert.Equal(PluginStatus.Ok, response.Status);
Assert.NotNull(response.Subtitle?.Paragraphs);
Assert.Contains(response.Subtitle!.Paragraphs!, p => p.Text == "Hello there.");
}

[Fact]
public void Batch_UsesFrameRateSavedByConfigure()
{
var vm = new MainViewModel(MakeRequest(PluginRequestType.Batch, "{\"batchFrameRate\":29.97}"));

Assert.Equal(29.97, vm.SelectedFrameRate, 3);
}

[Fact]
public void Run_IgnoresButKeepsBatchSettings()
{
var vm = new MainViewModel(MakeRequest(PluginRequestType.Run, "{\"batchFrameRate\":29.97,\"batchShiftToStartTimeCode\":true}"));

Assert.Equal(25.0, vm.SelectedFrameRate, 3);
Assert.False(vm.ShiftToStartTimeCode);
var settings = vm.BuildCancelResponse().Settings!.Value;
Assert.Equal(29.97, settings.GetProperty("batchFrameRate").GetDouble(), 3);
Assert.True(settings.GetProperty("batchShiftToStartTimeCode").GetBoolean());
}

[Fact]
public void Configure_ReturnsSettingsWithoutSubtitle()
{
var vm = new MainViewModel(MakeRequest(PluginRequestType.Configure))
{
SelectedFrameRate = 30.0,
MinimumGapFrames = 4,
};

var response = vm.BuildResponse();

Assert.Equal(PluginStatus.Ok, response.Status);
Assert.Null(response.Subtitle);
Assert.Equal(1, response.SettingsVersion);
Assert.Equal(30.0, response.Settings!.Value.GetProperty("batchFrameRate").GetDouble(), 3);
Assert.Equal(4, response.Settings.Value.GetProperty("minimumGapFrames").GetInt32());
}

[Fact]
public void Bootstrap_BatchRequest_RunsHandlerWithoutUi()
{
var dir = Directory.CreateTempSubdirectory("arte-batch-test");
try
{
var request = MakeRequest(PluginRequestType.Batch);
request.ResponseFilePath = Path.Combine(dir.FullName, "response.json");
var requestPath = Path.Combine(dir.FullName, "request.json");
File.WriteAllText(requestPath, JsonSerializer.Serialize(request, PluginBootstrap.JsonOpts));

var exitCode = PluginBootstrap.Run<App>(new[] { requestPath }, r => new MainViewModel(r).BuildBatchResponse());

Assert.Equal(0, exitCode);
var response = JsonSerializer.Deserialize<PluginResponse>(File.ReadAllText(request.ResponseFilePath), PluginBootstrap.JsonOpts)!;
Assert.Equal(PluginStatus.Ok, response.Status);
}
finally
{
dir.Delete(recursive: true);
}
}
}
107 changes: 104 additions & 3 deletions se5/ArteCheck/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public sealed partial class MainViewModel : ObservableObject
private readonly PluginRequest _request;
private readonly bool _isEbu;
private readonly bool _legacyRequest;
private readonly bool _isConfigure;
private readonly bool _isBatch;
private readonly long _videoOffsetMs;
private readonly Stack<(List<PluginParagraph> Paragraphs, string? Header, double FrameRate, int Applied)> _undo = new();
private readonly Dictionary<string, bool> _expandedGroups = new();
Expand Down Expand Up @@ -54,6 +56,8 @@ public sealed partial class MainViewModel : ObservableObject
public MainViewModel(PluginRequest request)
{
_request = request;
_isConfigure = request.RequestType == PluginRequestType.Configure;
_isBatch = request.RequestType == PluginRequestType.Batch;
_isEbu = string.Equals(request.Subtitle.Format, EbuFormatName, StringComparison.OrdinalIgnoreCase);
_videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0;
_legacyRequest = request.Subtitle.Paragraphs == null;
Expand Down Expand Up @@ -100,23 +104,32 @@ public MainViewModel(PluginRequest request)
public IReadOnlyList<CheckItem> Checks { get; }
public ObservableCollection<FixGroup> Groups { get; } = new();

/// <summary>Opened from Batch convert's Configure button: only the settings matter, there is no subtitle.</summary>
public bool IsConfigure => _isConfigure;
public bool IsNotConfigure => !_isConfigure;

public string SubtitleInfo
{
get
{
if (_isConfigure)
{
return "Settings for Batch convert";
}

var name = string.IsNullOrEmpty(_request.Subtitle.FileName) ? "Untitled" : Path.GetFileName(_request.Subtitle.FileName);
var format = string.IsNullOrEmpty(_request.Subtitle.Format) ? string.Empty : $" · {_request.Subtitle.Format}";
return $"{name}{format} · {_paragraphs.Count} subtitles";
}
}

public bool ShowFormatWarning => !_isEbu;
public bool ShowFormatWarning => !_isEbu && !_isConfigure;
public string FormatWarning => _legacyRequest
? "This Subtitle Edit version only sends SubRip to plugins, so teletext rows and the EBU header are not checked. Update Subtitle Edit for the full check."
: $"The subtitle is {(_request.Subtitle.Format is { Length: > 0 } f ? f : "not EBU STL")}. Header and teletext row checks need EBU STL - save as EBU STL for delivery.";

public bool HasFindings => FixCount + AlarmCount > 0;
public bool IsClean => !HasFindings;
public bool HasFindings => FixCount + AlarmCount > 0 && !_isConfigure;
public bool IsClean => !HasFindings && !_isConfigure;
public bool ShowAll { get => Filter == FindingFilter.All; set { if (value) Filter = FindingFilter.All; } }
public bool ShowFixes { get => Filter == FindingFilter.Fixes; set { if (value) Filter = FindingFilter.Fixes; } }
public bool ShowAlarms { get => Filter == FindingFilter.Alarms; set { if (value) Filter = FindingFilter.Alarms; } }
Expand Down Expand Up @@ -396,12 +409,22 @@ public string BuildReport()
/// <summary>The response for OK: applies what is still selected, then hands the working copy back.</summary>
public PluginResponse BuildResponse()
{
if (_isConfigure)
{
return BuildConfigureResponse();
}

ApplyPending();
if (AppliedCount == 0)
{
return new PluginResponse { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion };
}

return BuildOkResponse();
}

private PluginResponse BuildOkResponse()
{
var paragraphs = _paragraphs.Select(p =>
{
var copy = p.Clone();
Expand Down Expand Up @@ -439,6 +462,39 @@ public PluginResponse BuildResponse()
};
}

/// <summary>
/// A "batch" request (Batch convert): apply every correction, re-checking until nothing is
/// left to fix (fixes can enable further fixes), and return the result without any UI.
/// </summary>
public PluginResponse BuildBatchResponse()
{
for (var pass = 0; pass < MaxBatchPasses && ApplyPending(); pass++)
{
Analyze();
}

if (AppliedCount == 0)
{
return new PluginResponse
{
Status = PluginStatus.Ok,
Message = AlarmCount > 0 ? $"ARTE check: no corrections, {AlarmCount} alarm(s)." : "ARTE check: no issues found.",
};
}

var response = BuildOkResponse();
response.Message = AlarmCount > 0
? $"ARTE check: {AppliedCount} correction(s) applied, {AlarmCount} alarm(s) need a manual edit."
: response.Message;
return response;
}

private const int MaxBatchPasses = 5;

/// <summary>A "configure" request: hand back the settings only, for the next Batch convert run.</summary>
public PluginResponse BuildConfigureResponse() =>
new() { Status = PluginStatus.Ok, Settings = BuildSettings(), SettingsVersion = SettingsVersion };

public PluginResponse BuildCancelResponse() =>
new() { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion };

Expand All @@ -453,16 +509,61 @@ private JsonElement BuildSettings()
["shortMinimumFrames"] = (int)(ShortMinimumFrames ?? ArteOptions.PresetShortMinimumFrames),
["disabledChecks"] = Checks.Where(c => !c.IsEnabled).Select(c => c.Type.ToString()).ToArray(),
};

// "Timed for" and the start time code describe one file, so a normal run never stores
// them - but Batch convert has no window to ask in, so Configure saves them for batch runs.
// A normal run keeps whatever Configure saved.
if (_isConfigure)
{
payload[BatchFrameRateKey] = SelectedFrameRate;
payload[BatchShiftToStartTimeCodeKey] = ShiftToStartTimeCode;
payload[BatchTargetStartTimeCodeKey] = TargetStartTimeCode;
}
else if (_request.SettingsVersion == SettingsVersion && _request.Settings is { ValueKind: JsonValueKind.Object } stored)
{
foreach (var key in new[] { BatchFrameRateKey, BatchShiftToStartTimeCodeKey, BatchTargetStartTimeCodeKey })
{
if (stored.TryGetProperty(key, out var value))
{
payload[key] = value.Clone();
}
}
}

return JsonSerializer.SerializeToElement(payload);
}

private const string BatchFrameRateKey = "batchFrameRate";
private const string BatchShiftToStartTimeCodeKey = "batchShiftToStartTimeCode";
private const string BatchTargetStartTimeCodeKey = "batchTargetStartTimeCode";

private void LoadSettings()
{
if (_request.SettingsVersion != SettingsVersion || _request.Settings is not { ValueKind: JsonValueKind.Object } settings)
{
return;
}

if (_isConfigure || _isBatch)
{
if (settings.TryGetProperty(BatchFrameRateKey, out var frameRate) && frameRate.TryGetDouble(out var fps) &&
FrameRates.Any(f => Math.Abs(f - fps) < 0.001))
{
SelectedFrameRate = fps;
}

if (settings.TryGetProperty(BatchShiftToStartTimeCodeKey, out var shift) && shift.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
ShiftToStartTimeCode = shift.GetBoolean();
}

if (settings.TryGetProperty(BatchTargetStartTimeCodeKey, out var target) && target.GetString() is { } tc &&
ArteChecker.TryParseTimeCode(tc, out _))
{
TargetStartTimeCode = tc;
}
}

if (settings.TryGetProperty("teletextMaxCells", out var cells) && cells.TryGetInt32(out var c) && c is >= 10 and <= 40)
{
TeletextMaxCells = c;
Expand Down
17 changes: 14 additions & 3 deletions se5/ArteCheck/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
<TextBlock Text="ARTE check" FontSize="20" FontWeight="SemiBold" />
<TextBlock Text="{Binding SubtitleInfo}" Classes="muted" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center" IsVisible="{Binding IsNotConfigure}">
<Border Classes="chip" Background="{StaticResource FixTint}">
<StackPanel Orientation="Horizontal" Spacing="6">
<i:Icon Value="mdi-wrench-outline" Foreground="{StaticResource FixBrush}" FontSize="15" />
Expand Down Expand Up @@ -137,13 +137,13 @@
</Border>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Command="{Binding UndoCommand}" IsEnabled="{Binding CanUndo}" ToolTip.Tip="Undo the last applied pass">
<Button Command="{Binding UndoCommand}" IsEnabled="{Binding CanUndo}" IsVisible="{Binding IsNotConfigure}" ToolTip.Tip="Undo the last applied pass">
<StackPanel Orientation="Horizontal" Spacing="6">
<i:Icon Value="mdi-undo" FontSize="16" />
<TextBlock Text="Undo" />
</StackPanel>
</Button>
<Button Command="{Binding ApplySelectedCommand}" ToolTip.Tip="Apply the selected corrections and check again">
<Button Command="{Binding ApplySelectedCommand}" IsVisible="{Binding IsNotConfigure}" ToolTip.Tip="Apply the selected corrections and check again">
<StackPanel Orientation="Horizontal" Spacing="6">
<i:Icon Value="mdi-auto-fix" FontSize="16" />
<TextBlock Text="Apply and re-check" />
Expand Down Expand Up @@ -321,6 +321,17 @@
</StackPanel>
</Grid>

<!-- Configure (Batch convert) -->
<StackPanel DockPanel.Dock="Top" IsVisible="{Binding IsConfigure}" HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="0,110,0,0" Spacing="10" MaxWidth="520">
<Border Width="84" Height="84" CornerRadius="42" Background="{StaticResource FixTint}" HorizontalAlignment="Center">
<i:Icon Value="mdi-cog-outline" FontSize="46" Foreground="{StaticResource FixBrush}" />
</Border>
<TextBlock Text="Batch convert settings" FontSize="18" FontWeight="SemiBold" HorizontalAlignment="Center" />
<TextBlock Classes="muted" TextWrapping="Wrap" TextAlignment="Center"
Text="Set the options on the left and click OK. Batch convert then applies every correction to each file without showing this window; alarms are counted but need a manual edit." />
</StackPanel>

<!-- Empty state -->
<StackPanel DockPanel.Dock="Top" IsVisible="{Binding IsClean}" HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="0,110,0,0" Spacing="10">
Expand Down
2 changes: 1 addition & 1 deletion se5/ArteCheck/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ public static class Program
public static int Main(string[] args)
{
IconProvider.Current.Register<MaterialDesignIconProvider>();
return PluginBootstrap.Run<App>(args);
return PluginBootstrap.Run<App>(args, request => new MainViewModel(request).BuildBatchResponse());
}
}
8 changes: 8 additions & 0 deletions se5/ArteCheck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ Needs a Subtitle Edit build whose plugin contract includes `subtitle.header` and

The limits and enabled checks are remembered between runs. Durations, CPS and double height come from Subtitle Edit's settings (`request.rules`).

## Batch convert

ARTE check supports Subtitle Edit's **Batch convert** (Tools → Batch convert → Run plugin; needs a Subtitle Edit version with plugin batch support).

- **Configure...** opens this window without a subtitle. Set the profile, limits, checks, *Timed for* frame rate and programme start, then click OK. The frame rate and programme start are saved for batch runs only — a normal run still asks per file.
- During the batch each file is checked and **every correction is applied**, re-checking until nothing is left to fix (up to five passes). Alarms are not fixed; the status message counts them.
- Header and teletext row checks need EBU STL input, as in the window.

## Build

See `.github/workflows/arte-check.yml`. Tests are in `../ArteCheck.Tests`:
Expand Down
1 change: 1 addition & 0 deletions se5/ArteCheck/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/ArteCheck",
"menu": "Tools",
"minSeVersion": "5.0.0",
"batch": true,
"runtime": "dotnet",
"entry": "ArteCheck.dll"
}
Loading
Loading