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
18 changes: 17 additions & 1 deletion .github/workflows/typewriter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions se5/TypewriterEffect.Tests/BatchModeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using SubtitleEdit.Plugins.TypewriterEffect;
using System.Text.Json;

namespace TypewriterEffect.Tests;

/// <summary>Subtitle Edit Batch convert: "batch" applies the effect to every line headless, "configure" returns the end delay only.</summary>
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());
}
}
27 changes: 27 additions & 0 deletions se5/TypewriterEffect.Tests/TypewriterEffect.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<PropertyGroup>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\TypewriterEffect\TypewriterEffect.csproj" />
</ItemGroup>

</Project>
14 changes: 14 additions & 0 deletions se5/TypewriterEffect/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@
FontWeight="SemiBold"
Text="Typewriter effect" />

<Border x:Name="BatchSettingsPanel"
IsVisible="False"
Padding="12"
CornerRadius="6"
BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Text="Batch convert settings" />
<TextBlock Opacity="0.8"
TextWrapping="Wrap"
Text="Set the end delay and click OK. Batch convert then applies the typewriter effect to every line in each file without showing this window." />
</StackPanel>
</Border>

<TextBlock x:Name="InfoLabel"
Opacity="0.8"
TextWrapping="Wrap" />
Expand Down
75 changes: 19 additions & 56 deletions se5/TypewriterEffect/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using System;
using System.Globalization;
using System.Text.Json;

namespace SubtitleEdit.Plugins.TypewriterEffect;

Expand All @@ -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()) { }
Expand All @@ -21,19 +20,29 @@ 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()
{
AvaloniaXamlLoader.Load(this);
_infoLabel = this.FindControl<TextBlock>("InfoLabel")!;
_endDelayInput = this.FindControl<NumericUpDown>("EndDelayInput")!;
_batchSettingsPanel = this.FindControl<Control>("BatchSettingsPanel")!;
}

protected override void OnOpened(EventArgs e)
Expand All @@ -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<int>(_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();
}
}
8 changes: 8 additions & 0 deletions se5/TypewriterEffect/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>());

Expand Down
8 changes: 8 additions & 0 deletions se5/TypewriterEffect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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)
Expand Down
Loading
Loading