diff --git a/.github/workflows/typewriter.yml b/.github/workflows/typewriter.yml
index 4c0e5102..30f50be5 100644
--- a/.github/workflows/typewriter.yml
+++ b/.github/workflows/typewriter.yml
@@ -5,10 +5,12 @@ on:
branches: [main]
paths:
- 'se5/TypewriterEffect/**'
+ - 'se5/TypewriterEffect.Tests/**'
- '.github/workflows/typewriter.yml'
pull_request:
paths:
- 'se5/TypewriterEffect/**'
+ - 'se5/TypewriterEffect.Tests/**'
- '.github/workflows/typewriter.yml'
workflow_dispatch:
inputs:
@@ -88,8 +90,22 @@ jobs:
echo "ref=$ref" >> "$GITHUB_OUTPUT"
echo "release=$release_flag" >> "$GITHUB_OUTPUT"
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Test
+ run: dotnet test se5/TypewriterEffect.Tests/TypewriterEffect.Tests.csproj -c Release
+
build:
- needs: prepare
+ needs: [prepare, test]
runs-on: ubuntu-latest
strategy:
fail-fast: false
diff --git a/se5/TypewriterEffect.Tests/BatchModeTests.cs b/se5/TypewriterEffect.Tests/BatchModeTests.cs
new file mode 100644
index 00000000..887dd413
--- /dev/null
+++ b/se5/TypewriterEffect.Tests/BatchModeTests.cs
@@ -0,0 +1,77 @@
+using SubtitleEdit.Plugins.TypewriterEffect;
+using System.Text.Json;
+
+namespace TypewriterEffect.Tests;
+
+/// Subtitle Edit Batch convert: "batch" applies the effect to every line headless, "configure" returns the end delay only.
+public class BatchModeTests
+{
+ private const string TwoLines = "1\n00:00:01,000 --> 00:00:03,000\nHi\n\n2\n00:00:04,000 --> 00:00:06,000\nYo\n";
+
+ private static PluginRequest Request(string requestType, string srt, string? settingsJson = null, params int[] selected) => new()
+ {
+ RequestType = requestType,
+ Subtitle = new PluginSubtitle { Format = "SubRip", SubRip = srt },
+ SelectedIndices = selected.ToList(),
+ Settings = settingsJson == null ? null : JsonDocument.Parse(settingsJson).RootElement.Clone(),
+ };
+
+ [Fact]
+ public void Batch_AppliesToAllLinesWithDefaultEndDelay()
+ {
+ var response = TypewriterResponses.BuildBatchResponse(Request("batch", TwoLines));
+
+ Assert.Equal("ok", response.Status);
+ Assert.Null(response.Settings);
+ Assert.Null(response.SettingsVersion);
+ var blocks = SubRipParser.Parse(response.Subtitle!.Native);
+ // Each line: "H", "Hi" + a 0.5 s end-delay hold of the full line.
+ Assert.Equal(new[] { "H", "Hi", "Hi", "Y", "Yo", "Yo" }, blocks.Select(b => b.Text));
+ Assert.Equal(2500, blocks[2].StartMs);
+ Assert.Equal(3000, blocks[2].EndMs);
+ }
+
+ [Fact]
+ public void Batch_UsesSavedEndDelay()
+ {
+ var response = TypewriterResponses.BuildBatchResponse(Request("batch", TwoLines, "{\"endDelay\":0}"));
+
+ var blocks = SubRipParser.Parse(response.Subtitle!.Native);
+ Assert.Equal(new[] { "H", "Hi", "Y", "Yo" }, blocks.Select(b => b.Text));
+ Assert.Equal(3000, blocks[1].EndMs);
+ }
+
+ [Fact]
+ public void Batch_NothingToChange_ReturnsOkWithoutSubtitle()
+ {
+ // Lines of 500 ms or less are too short to split.
+ var srt = "1\n00:00:01,000 --> 00:00:01,400\nHi\n";
+ var response = TypewriterResponses.BuildBatchResponse(Request("batch", srt));
+
+ Assert.Equal("ok", response.Status);
+ Assert.Null(response.Subtitle);
+ }
+
+ [Fact]
+ public void Run_StillHonoursSelectionAndReturnsSettings()
+ {
+ var response = TypewriterResponses.BuildRunResponse(Request("run", TwoLines, null, 1), 0.5);
+
+ Assert.Equal("ok", response.Status);
+ var blocks = SubRipParser.Parse(response.Subtitle!.Native);
+ Assert.Equal(new[] { "Hi", "Y", "Yo", "Yo" }, blocks.Select(b => b.Text));
+ Assert.Equal(0.5, response.Settings!.Value.GetProperty("endDelay").GetDouble());
+ }
+
+ [Fact]
+ public void Configure_ReturnsSettingsWithoutSubtitle()
+ {
+ Assert.True(TypewriterResponses.IsConfigure(Request("configure", string.Empty)));
+
+ var response = TypewriterResponses.BuildConfigureResponse(1.5);
+
+ Assert.Equal("ok", response.Status);
+ Assert.Null(response.Subtitle);
+ Assert.Equal(1.5, response.Settings!.Value.GetProperty("endDelay").GetDouble());
+ }
+}
diff --git a/se5/TypewriterEffect.Tests/TypewriterEffect.Tests.csproj b/se5/TypewriterEffect.Tests/TypewriterEffect.Tests.csproj
new file mode 100644
index 00000000..b5672685
--- /dev/null
+++ b/se5/TypewriterEffect.Tests/TypewriterEffect.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/se5/TypewriterEffect/MainWindow.axaml b/se5/TypewriterEffect/MainWindow.axaml
index bfc130c3..c157f42e 100644
--- a/se5/TypewriterEffect/MainWindow.axaml
+++ b/se5/TypewriterEffect/MainWindow.axaml
@@ -30,6 +30,20 @@
FontWeight="SemiBold"
Text="Typewriter effect" />
+
+
+
+
+
+
+
diff --git a/se5/TypewriterEffect/MainWindow.axaml.cs b/se5/TypewriterEffect/MainWindow.axaml.cs
index 38abb105..543e3f94 100644
--- a/se5/TypewriterEffect/MainWindow.axaml.cs
+++ b/se5/TypewriterEffect/MainWindow.axaml.cs
@@ -2,8 +2,6 @@
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using System;
-using System.Globalization;
-using System.Text.Json;
namespace SubtitleEdit.Plugins.TypewriterEffect;
@@ -12,6 +10,7 @@ public partial class MainWindow : Window
private readonly PluginRequest _request;
private TextBlock _infoLabel = null!;
private NumericUpDown _endDelayInput = null!;
+ private Control _batchSettingsPanel = null!;
// Parameterless constructor only exists for the XAML designer.
public MainWindow() : this(new PluginRequest()) { }
@@ -21,12 +20,21 @@ public MainWindow(PluginRequest request)
_request = request;
InitializeComponent();
- var selectedCount = request.SelectedIndices.Count;
- _infoLabel.Text = selectedCount > 0
- ? $"Each of the {selectedCount} selected line(s) will be split into several short lines that progressively reveal the text."
- : "Every line will be split into several short lines that progressively reveal the text.";
+ if (TypewriterResponses.IsConfigure(request))
+ {
+ // Opened from Batch convert's Configure button: there is no subtitle, only the setting.
+ _infoLabel.IsVisible = false;
+ _batchSettingsPanel.IsVisible = true;
+ }
+ else
+ {
+ var selectedCount = request.SelectedIndices.Count;
+ _infoLabel.Text = selectedCount > 0
+ ? $"Each of the {selectedCount} selected line(s) will be split into several short lines that progressively reveal the text."
+ : "Every line will be split into several short lines that progressively reveal the text.";
+ }
- _endDelayInput.Value = (decimal)LoadEndDelaySetting(request.Settings, defaultValue: 0.5);
+ _endDelayInput.Value = (decimal)TypewriterResponses.LoadEndDelaySetting(request.Settings, TypewriterResponses.DefaultEndDelay);
}
private void InitializeComponent()
@@ -34,6 +42,7 @@ private void InitializeComponent()
AvaloniaXamlLoader.Load(this);
_infoLabel = this.FindControl("InfoLabel")!;
_endDelayInput = this.FindControl("EndDelayInput")!;
+ _batchSettingsPanel = this.FindControl("BatchSettingsPanel")!;
}
protected override void OnOpened(EventArgs e)
@@ -58,55 +67,9 @@ private void OnCancel(object? sender, RoutedEventArgs e)
private void OnOk(object? sender, RoutedEventArgs e)
{
var endDelaySec = (double)(_endDelayInput.Value ?? 0m);
-
- try
- {
- var blocks = SubRipParser.Parse(_request.Subtitle.SubRip);
- var selected = new HashSet(_request.SelectedIndices);
- var (resultBlocks, changed) = TypewriterEngine.Apply(blocks, selected, endDelaySec);
-
- App.Response = new PluginResponse
- {
- Status = "ok",
- Message = changed == 0
- ? "No lines changed."
- : $"Typewriter applied to {changed} line(s).",
- UndoDescription = "Typewriter effect",
- Subtitle = new PluginSubtitle
- {
- Format = "SubRip",
- Native = SubRipParser.Serialize(resultBlocks),
- },
- Settings = BuildSettings(endDelaySec),
- };
- }
- catch (Exception ex)
- {
- App.Response = new PluginResponse { Status = "error", Message = ex.Message };
- }
-
+ App.Response = TypewriterResponses.IsConfigure(_request)
+ ? TypewriterResponses.BuildConfigureResponse(endDelaySec)
+ : TypewriterResponses.BuildRunResponse(_request, endDelaySec);
Close();
}
-
- private static double LoadEndDelaySetting(JsonElement? settings, double defaultValue)
- {
- if (settings is null || settings.Value.ValueKind != JsonValueKind.Object)
- {
- return defaultValue;
- }
- if (settings.Value.TryGetProperty("endDelay", out var value) &&
- value.ValueKind == JsonValueKind.Number &&
- value.TryGetDouble(out var parsed))
- {
- return parsed;
- }
- return defaultValue;
- }
-
- private static JsonElement BuildSettings(double endDelay)
- {
- var json = $"{{\"endDelay\":{endDelay.ToString("0.###", CultureInfo.InvariantCulture)}}}";
- using var doc = JsonDocument.Parse(json);
- return doc.RootElement.Clone();
- }
}
diff --git a/se5/TypewriterEffect/Program.cs b/se5/TypewriterEffect/Program.cs
index b307493b..27cbcb8a 100644
--- a/se5/TypewriterEffect/Program.cs
+++ b/se5/TypewriterEffect/Program.cs
@@ -47,6 +47,14 @@ public static int Main(string[] args)
return 1;
}
+ // Batch convert: no UI, every line, saved settings (see TypewriterResponses).
+ if (TypewriterResponses.IsBatch(request))
+ {
+ var batchResponse = TypewriterResponses.BuildBatchResponse(request);
+ File.WriteAllText(request.ResponseFilePath, JsonSerializer.Serialize(batchResponse, JsonOpts));
+ return 0;
+ }
+
App.PendingRequest = request;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(Array.Empty());
diff --git a/se5/TypewriterEffect/README.md b/se5/TypewriterEffect/README.md
index 7dbe92cb..31ccdc7e 100644
--- a/se5/TypewriterEffect/README.md
+++ b/se5/TypewriterEffect/README.md
@@ -9,6 +9,13 @@ Ported from the SE4 *Effect → Typewriter…* dialog, repackaged as a standalon
Avalonia 11 desktop app that communicates with Subtitle Edit through the
[SE5 plugin JSON contract](https://github.com/SubtitleEdit/subtitleedit/blob/main/docs/plugin.md).
+## Batch convert
+
+Typewriter effect supports Subtitle Edit's **Batch convert** (Tools → Batch convert → Run plugin; needs a Subtitle Edit version with plugin batch support).
+
+- **Configure...** opens the window without a subtitle to set the end delay (0.5 s by default).
+- During the batch every line is split with the saved end delay, without showing the window. Lines of 500 ms or less are left as they are, as in the window; files with nothing to change are left untouched.
+
## Files
| File | Purpose |
@@ -19,6 +26,7 @@ Avalonia 11 desktop app that communicates with Subtitle Edit through the
| `App.axaml(.cs)` | Avalonia application. Picks `Dark` or `Light` theme variant based on `request.theme`. |
| `MainWindow.axaml(.cs)` | The settings dialog (end-delay numeric input + OK/Cancel). |
| `SubRipParser.cs` | Minimal SRT parser/serializer. |
+| `TypewriterResponses.cs` | Builds the run / batch / configure responses. |
| `TypewriterEngine.cs` | Generates the exploded paragraphs from the original ones. |
## Build (local)
diff --git a/se5/TypewriterEffect/TypewriterResponses.cs b/se5/TypewriterEffect/TypewriterResponses.cs
new file mode 100644
index 00000000..510133a7
--- /dev/null
+++ b/se5/TypewriterEffect/TypewriterResponses.cs
@@ -0,0 +1,112 @@
+using System.Globalization;
+using System.Text.Json;
+
+namespace SubtitleEdit.Plugins.TypewriterEffect;
+
+///
+/// Builds the plugin responses. With "batch": true in plugin.json, Subtitle Edit's Batch
+/// convert sends requestType "batch" (no UI, every line, saved settings) and "configure" (show
+/// the settings without a subtitle and return them). Older SE versions only send "run".
+///
+public static class TypewriterResponses
+{
+ public const string RequestTypeBatch = "batch";
+ public const string RequestTypeConfigure = "configure";
+ public const double DefaultEndDelay = 0.5;
+
+ public static bool IsConfigure(PluginRequest request) =>
+ string.Equals(request.RequestType, RequestTypeConfigure, StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsBatch(PluginRequest request) =>
+ string.Equals(request.RequestType, RequestTypeBatch, StringComparison.OrdinalIgnoreCase);
+
+ /// The response for OK in the window ("run"): apply to the selected lines (all when none are selected).
+ public static PluginResponse BuildRunResponse(PluginRequest request, double endDelaySec)
+ {
+ try
+ {
+ var blocks = SubRipParser.Parse(request.Subtitle.SubRip);
+ var selected = new HashSet(request.SelectedIndices);
+ var (resultBlocks, changed) = TypewriterEngine.Apply(blocks, selected, endDelaySec);
+
+ return new PluginResponse
+ {
+ Status = "ok",
+ Message = changed == 0
+ ? "No lines changed."
+ : $"Typewriter applied to {changed} line(s).",
+ UndoDescription = "Typewriter effect",
+ Subtitle = new PluginSubtitle
+ {
+ Format = "SubRip",
+ Native = SubRipParser.Serialize(resultBlocks),
+ },
+ Settings = BuildSettings(endDelaySec),
+ };
+ }
+ catch (Exception ex)
+ {
+ return new PluginResponse { Status = "error", Message = ex.Message };
+ }
+ }
+
+ ///
+ /// A "batch" request: apply the effect to every line with the saved end delay, without UI.
+ /// No line long enough to split is "ok" without a subtitle. Settings are not returned.
+ ///
+ public static PluginResponse BuildBatchResponse(PluginRequest request)
+ {
+ try
+ {
+ var endDelaySec = LoadEndDelaySetting(request.Settings, DefaultEndDelay);
+ var blocks = SubRipParser.Parse(request.Subtitle.SubRip);
+ var (resultBlocks, changed) = TypewriterEngine.Apply(blocks, new HashSet(), endDelaySec);
+ if (changed == 0)
+ {
+ return new PluginResponse { Status = "ok", Message = "Typewriter effect: no lines changed." };
+ }
+
+ return new PluginResponse
+ {
+ Status = "ok",
+ Message = $"Typewriter applied to {changed} line(s).",
+ UndoDescription = "Typewriter effect",
+ Subtitle = new PluginSubtitle
+ {
+ Format = "SubRip",
+ Native = SubRipParser.Serialize(resultBlocks),
+ },
+ };
+ }
+ catch (Exception ex)
+ {
+ return new PluginResponse { Status = "error", Message = ex.Message };
+ }
+ }
+
+ /// A "configure" request: hand back the settings only, for the next Batch convert run.
+ public static PluginResponse BuildConfigureResponse(double endDelaySec) =>
+ new() { Status = "ok", Settings = BuildSettings(endDelaySec) };
+
+ public static double LoadEndDelaySetting(JsonElement? settings, double defaultValue)
+ {
+ if (settings is null || settings.Value.ValueKind != JsonValueKind.Object)
+ {
+ return defaultValue;
+ }
+ if (settings.Value.TryGetProperty("endDelay", out var value) &&
+ value.ValueKind == JsonValueKind.Number &&
+ value.TryGetDouble(out var parsed))
+ {
+ return parsed;
+ }
+ return defaultValue;
+ }
+
+ public static JsonElement BuildSettings(double endDelay)
+ {
+ var json = $"{{\"endDelay\":{endDelay.ToString("0.###", CultureInfo.InvariantCulture)}}}";
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+}
diff --git a/se5/TypewriterEffect/plugin.json b/se5/TypewriterEffect/plugin.json
index 5fa7539a..498f4942 100644
--- a/se5/TypewriterEffect/plugin.json
+++ b/se5/TypewriterEffect/plugin.json
@@ -7,6 +7,7 @@
"url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/TypewriterEffect",
"menu": "Tools",
"minSeVersion": "5.0.0",
+ "batch": true,
"runtime": "dotnet",
"entry": "TypewriterEffect.dll"
}