Skip to content

Commit 248558f

Browse files
authored
Merge pull request #269 from SubtitleEdit/typewriter-effect
Add Typewriter effect SE5 plugin (Avalonia)
2 parents 9c9cd2b + 4e586c7 commit 248558f

14 files changed

Lines changed: 801 additions & 0 deletions

.github/workflows/typewriter.yml

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
name: Build TypewriterEffect (SE5)
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'se5/TypewriterEffect/**'
8+
- '.github/workflows/typewriter.yml'
9+
pull_request:
10+
paths:
11+
- 'se5/TypewriterEffect/**'
12+
- '.github/workflows/typewriter.yml'
13+
workflow_dispatch:
14+
inputs:
15+
tag:
16+
description: 'Release tag to publish the zips under (e.g. se5-typewriter-v1.0). Leave empty to build artifacts only.'
17+
required: false
18+
type: string
19+
20+
jobs:
21+
build:
22+
runs-on: ubuntu-latest
23+
strategy:
24+
fail-fast: false
25+
matrix:
26+
include:
27+
- rid: win-x64
28+
os: windows
29+
exe: TypewriterEffect.exe
30+
- rid: win-arm64
31+
os: windows
32+
exe: TypewriterEffect.exe
33+
- rid: linux-x64
34+
os: linux
35+
exe: TypewriterEffect
36+
- rid: linux-arm64
37+
os: linux
38+
exe: TypewriterEffect
39+
- rid: osx-x64
40+
os: macos
41+
exe: TypewriterEffect
42+
- rid: osx-arm64
43+
os: macos
44+
exe: TypewriterEffect
45+
46+
steps:
47+
- name: Checkout
48+
uses: actions/checkout@v4
49+
50+
- name: Setup .NET 8
51+
uses: actions/setup-dotnet@v4
52+
with:
53+
dotnet-version: '8.0.x'
54+
55+
- name: Publish self-contained (${{ matrix.rid }})
56+
working-directory: se5/TypewriterEffect
57+
run: |
58+
dotnet publish TypewriterEffect.csproj \
59+
-c Release \
60+
-r ${{ matrix.rid }} \
61+
--self-contained true \
62+
-p:PublishSingleFile=true \
63+
-p:IncludeNativeLibrariesForSelfExtract=true \
64+
-o staging/TypewriterEffect
65+
66+
- name: Rewrite plugin.json for ${{ matrix.os }}
67+
working-directory: se5/TypewriterEffect
68+
run: |
69+
jq '
70+
del(.runtime, .entry) |
71+
.executables = { "${{ matrix.os }}": "${{ matrix.exe }}" }
72+
' plugin.json > staging/TypewriterEffect/plugin.json
73+
74+
- name: Package zip
75+
working-directory: se5/TypewriterEffect/staging
76+
run: zip -r "$GITHUB_WORKSPACE/TypewriterEffect-${{ matrix.rid }}.zip" TypewriterEffect
77+
78+
- name: Upload artifact
79+
uses: actions/upload-artifact@v4
80+
with:
81+
name: TypewriterEffect-${{ matrix.rid }}
82+
path: TypewriterEffect-${{ matrix.rid }}.zip
83+
84+
release:
85+
name: Publish GitHub Release
86+
needs: build
87+
if: github.event_name == 'workflow_dispatch' && github.event.inputs.tag != ''
88+
runs-on: ubuntu-latest
89+
permissions:
90+
contents: write
91+
steps:
92+
- name: Download all zips
93+
uses: actions/download-artifact@v4
94+
with:
95+
path: dist
96+
pattern: TypewriterEffect-*
97+
merge-multiple: true
98+
99+
- name: Create release and upload zips
100+
env:
101+
GH_TOKEN: ${{ github.token }}
102+
run: |
103+
gh release create "${{ github.event.inputs.tag }}" dist/TypewriterEffect-*.zip \
104+
--repo "${{ github.repository }}" \
105+
--target "${{ github.sha }}" \
106+
--title "TypewriterEffect ${{ github.event.inputs.tag }}" \
107+
--notes "Self-contained TypewriterEffect plugin builds for win/linux/osx (x64 + arm64)."

se5-plugins.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
{
22
"plugins": [
3+
{
4+
"name": "Typewriter effect",
5+
"description": "Splits each subtitle line into short timed parts that progressively reveal the text, character by character.",
6+
"version": "1.0.0",
7+
"author": "Subtitle Edit",
8+
"url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/TypewriterEffect",
9+
"date": "2026-05-17",
10+
"minSeVersion": "5.0.0",
11+
"downloads": {
12+
"win-x64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-win-x64.zip",
13+
"win-arm64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-win-arm64.zip",
14+
"linux-x64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-linux-x64.zip",
15+
"linux-arm64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-linux-arm64.zip",
16+
"osx-x64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-osx-x64.zip",
17+
"osx-arm64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-osx-arm64.zip"
18+
}
19+
},
320
{
421
"name": "Haxor",
522
"description": "Translates the text of the selected lines (or all lines) to haxor.",

se5/TypewriterEffect/App.axaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<Application xmlns="https://github.com/avaloniaui"
2+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
x:Class="SubtitleEdit.Plugins.TypewriterEffect.App"
4+
RequestedThemeVariant="Default">
5+
<Application.Styles>
6+
<FluentTheme />
7+
</Application.Styles>
8+
</Application>

se5/TypewriterEffect/App.axaml.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using Avalonia;
2+
using Avalonia.Controls.ApplicationLifetimes;
3+
using Avalonia.Markup.Xaml;
4+
using Avalonia.Styling;
5+
6+
namespace SubtitleEdit.Plugins.TypewriterEffect;
7+
8+
public partial class App : Application
9+
{
10+
/// <summary>Set by Program.Main before Avalonia starts.</summary>
11+
public static PluginRequest? PendingRequest;
12+
13+
/// <summary>Filled in by MainWindow when the user clicks OK or Cancel.</summary>
14+
public static PluginResponse? Response;
15+
16+
public override void Initialize() => AvaloniaXamlLoader.Load(this);
17+
18+
public override void OnFrameworkInitializationCompleted()
19+
{
20+
if (PendingRequest is not null)
21+
{
22+
RequestedThemeVariant = string.Equals(PendingRequest.Theme, "Dark", System.StringComparison.OrdinalIgnoreCase)
23+
? ThemeVariant.Dark
24+
: ThemeVariant.Light;
25+
26+
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
27+
{
28+
desktop.MainWindow = new MainWindow(PendingRequest);
29+
}
30+
}
31+
32+
base.OnFrameworkInitializationCompleted();
33+
}
34+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<Window xmlns="https://github.com/avaloniaui"
2+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
4+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
5+
mc:Ignorable="d"
6+
x:Class="SubtitleEdit.Plugins.TypewriterEffect.MainWindow"
7+
Title="Typewriter effect"
8+
Width="520" Height="360"
9+
WindowStartupLocation="CenterScreen"
10+
CanResize="False">
11+
<DockPanel Margin="20">
12+
<StackPanel DockPanel.Dock="Bottom"
13+
Orientation="Horizontal"
14+
HorizontalAlignment="Right"
15+
Spacing="10"
16+
Margin="0,16,0,0">
17+
<Button Content="Cancel"
18+
Click="OnCancel"
19+
MinWidth="100" />
20+
<Button x:Name="OkButton"
21+
Content="OK"
22+
Click="OnOk"
23+
MinWidth="100"
24+
Classes="accent"
25+
IsDefault="True" />
26+
</StackPanel>
27+
28+
<StackPanel Orientation="Vertical" Spacing="14">
29+
<TextBlock FontSize="18"
30+
FontWeight="SemiBold"
31+
Text="Typewriter effect" />
32+
33+
<TextBlock x:Name="InfoLabel"
34+
Opacity="0.8"
35+
TextWrapping="Wrap" />
36+
37+
<StackPanel Orientation="Vertical" Spacing="6">
38+
<TextBlock Text="End delay (seconds)" />
39+
<NumericUpDown x:Name="EndDelayInput"
40+
Minimum="0"
41+
Maximum="10"
42+
Increment="0.5"
43+
FormatString="0.0##"
44+
Value="0.5"
45+
Width="160"
46+
HorizontalAlignment="Left" />
47+
<TextBlock Opacity="0.65"
48+
FontSize="11"
49+
TextWrapping="Wrap"
50+
Text="After the last character appears, the full line stays on screen for this long before the original end time." />
51+
</StackPanel>
52+
</StackPanel>
53+
</DockPanel>
54+
</Window>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using Avalonia.Controls;
2+
using Avalonia.Interactivity;
3+
using Avalonia.Markup.Xaml;
4+
using System;
5+
using System.Text.Json;
6+
7+
namespace SubtitleEdit.Plugins.TypewriterEffect;
8+
9+
public partial class MainWindow : Window
10+
{
11+
private readonly PluginRequest _request;
12+
13+
// Parameterless constructor only exists for the XAML designer.
14+
public MainWindow() : this(new PluginRequest()) { }
15+
16+
public MainWindow(PluginRequest request)
17+
{
18+
InitializeComponent();
19+
_request = request;
20+
21+
var selectedCount = request.SelectedIndices.Count;
22+
InfoLabel.Text = selectedCount > 0
23+
? $"Each of the {selectedCount} selected line(s) will be split into several short lines that progressively reveal the text."
24+
: "Every line will be split into several short lines that progressively reveal the text.";
25+
26+
EndDelayInput.Value = (decimal)LoadEndDelaySetting(request.Settings, defaultValue: 0.5);
27+
}
28+
29+
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
30+
31+
private void OnCancel(object? sender, RoutedEventArgs e)
32+
{
33+
App.Response = new PluginResponse { Status = "cancelled" };
34+
Close();
35+
}
36+
37+
private void OnOk(object? sender, RoutedEventArgs e)
38+
{
39+
var endDelaySec = (double)(EndDelayInput.Value ?? 0m);
40+
41+
try
42+
{
43+
var blocks = SubRipParser.Parse(_request.Subtitle.SubRip);
44+
var selected = new HashSet<int>(_request.SelectedIndices);
45+
var (resultBlocks, changed) = TypewriterEngine.Apply(blocks, selected, endDelaySec);
46+
47+
App.Response = new PluginResponse
48+
{
49+
Status = "ok",
50+
Message = changed == 0
51+
? "No lines changed."
52+
: $"Typewriter applied to {changed} line(s).",
53+
UndoDescription = "Typewriter effect",
54+
Subtitle = new PluginSubtitle
55+
{
56+
Format = "SubRip",
57+
Native = SubRipParser.Serialize(resultBlocks),
58+
},
59+
Settings = BuildSettings(endDelaySec),
60+
};
61+
}
62+
catch (Exception ex)
63+
{
64+
App.Response = new PluginResponse { Status = "error", Message = ex.Message };
65+
}
66+
67+
Close();
68+
}
69+
70+
private static double LoadEndDelaySetting(JsonElement? settings, double defaultValue)
71+
{
72+
if (settings is null || settings.Value.ValueKind != JsonValueKind.Object)
73+
{
74+
return defaultValue;
75+
}
76+
if (settings.Value.TryGetProperty("endDelay", out var value) &&
77+
value.ValueKind == JsonValueKind.Number &&
78+
value.TryGetDouble(out var parsed))
79+
{
80+
return parsed;
81+
}
82+
return defaultValue;
83+
}
84+
85+
private static JsonElement BuildSettings(double endDelay)
86+
{
87+
using var doc = JsonDocument.Parse($"{{\"endDelay\":{endDelay.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)}}}");
88+
return doc.RootElement.Clone();
89+
}
90+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System.Text.Json;
2+
3+
namespace SubtitleEdit.Plugins.TypewriterEffect;
4+
5+
// Mirrors the Subtitle Edit 5 plugin JSON contract.
6+
// See https://github.com/SubtitleEdit/subtitleedit/blob/main/docs/plugin.md
7+
8+
public sealed class PluginRequest
9+
{
10+
public int ApiVersion { get; set; } = 1;
11+
public string RequestType { get; set; } = "run";
12+
public string ResponseFilePath { get; set; } = string.Empty;
13+
public string TempDirectory { get; set; } = string.Empty;
14+
public PluginSubtitle Subtitle { get; set; } = new();
15+
public List<int> SelectedIndices { get; set; } = new();
16+
public string VideoFileName { get; set; } = string.Empty;
17+
public double FrameRate { get; set; }
18+
public string UiLanguage { get; set; } = string.Empty;
19+
public string Theme { get; set; } = string.Empty;
20+
public string SeVersion { get; set; } = string.Empty;
21+
public JsonElement? Settings { get; set; }
22+
}
23+
24+
public sealed class PluginSubtitle
25+
{
26+
public string Format { get; set; } = string.Empty;
27+
public string FileName { get; set; } = string.Empty;
28+
public string Native { get; set; } = string.Empty;
29+
public string SubRip { get; set; } = string.Empty;
30+
}
31+
32+
public sealed class PluginResponse
33+
{
34+
public int ApiVersion { get; set; } = 1;
35+
public string Status { get; set; } = "cancelled";
36+
public string? Message { get; set; }
37+
public PluginSubtitle? Subtitle { get; set; }
38+
public JsonElement? Settings { get; set; }
39+
public string? UndoDescription { get; set; }
40+
}

0 commit comments

Comments
 (0)