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
107 changes: 107 additions & 0 deletions .github/workflows/typewriter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: Build TypewriterEffect (SE5)

on:
push:
branches: [main]
paths:
- 'se5/TypewriterEffect/**'
- '.github/workflows/typewriter.yml'
pull_request:
paths:
- 'se5/TypewriterEffect/**'
- '.github/workflows/typewriter.yml'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish the zips under (e.g. se5-typewriter-v1.0). Leave empty to build artifacts only.'
required: false
type: string

jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- rid: win-x64
os: windows
exe: TypewriterEffect.exe
- rid: win-arm64
os: windows
exe: TypewriterEffect.exe
- rid: linux-x64
os: linux
exe: TypewriterEffect
- rid: linux-arm64
os: linux
exe: TypewriterEffect
- rid: osx-x64
os: macos
exe: TypewriterEffect
- rid: osx-arm64
os: macos
exe: TypewriterEffect

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

- name: Publish self-contained (${{ matrix.rid }})
working-directory: se5/TypewriterEffect
run: |
dotnet publish TypewriterEffect.csproj \
-c Release \
-r ${{ matrix.rid }} \
--self-contained true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-o staging/TypewriterEffect

- name: Rewrite plugin.json for ${{ matrix.os }}
working-directory: se5/TypewriterEffect
run: |
jq '
del(.runtime, .entry) |
.executables = { "${{ matrix.os }}": "${{ matrix.exe }}" }
' plugin.json > staging/TypewriterEffect/plugin.json

- name: Package zip
working-directory: se5/TypewriterEffect/staging
run: zip -r "$GITHUB_WORKSPACE/TypewriterEffect-${{ matrix.rid }}.zip" TypewriterEffect

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: TypewriterEffect-${{ matrix.rid }}
path: TypewriterEffect-${{ matrix.rid }}.zip

release:
name: Publish GitHub Release
needs: build
if: github.event_name == 'workflow_dispatch' && github.event.inputs.tag != ''
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all zips
uses: actions/download-artifact@v4
with:
path: dist
pattern: TypewriterEffect-*
merge-multiple: true

- name: Create release and upload zips
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.event.inputs.tag }}" dist/TypewriterEffect-*.zip \
--repo "${{ github.repository }}" \
--target "${{ github.sha }}" \
--title "TypewriterEffect ${{ github.event.inputs.tag }}" \
--notes "Self-contained TypewriterEffect plugin builds for win/linux/osx (x64 + arm64)."
17 changes: 17 additions & 0 deletions se5-plugins.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
{
"plugins": [
{
"name": "Typewriter effect",
"description": "Splits each subtitle line into short timed parts that progressively reveal the text, character by character.",
"version": "1.0.0",
"author": "Subtitle Edit",
"url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/TypewriterEffect",
"date": "2026-05-17",
"minSeVersion": "5.0.0",
"downloads": {
"win-x64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-win-x64.zip",
"win-arm64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-win-arm64.zip",
"linux-x64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-linux-x64.zip",
"linux-arm64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-linux-arm64.zip",
"osx-x64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-osx-x64.zip",
"osx-arm64": "https://github.com/SubtitleEdit/plugins/releases/download/se5-typewriter-v1.0/TypewriterEffect-osx-arm64.zip"
}
},
{
"name": "Haxor",
"description": "Translates the text of the selected lines (or all lines) to haxor.",
Expand Down
8 changes: 8 additions & 0 deletions se5/TypewriterEffect/App.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="SubtitleEdit.Plugins.TypewriterEffect.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
34 changes: 34 additions & 0 deletions se5/TypewriterEffect/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;

namespace SubtitleEdit.Plugins.TypewriterEffect;

public partial class App : Application
{
/// <summary>Set by Program.Main before Avalonia starts.</summary>
public static PluginRequest? PendingRequest;

/// <summary>Filled in by MainWindow when the user clicks OK or Cancel.</summary>
public static PluginResponse? Response;

public override void Initialize() => AvaloniaXamlLoader.Load(this);

public override void OnFrameworkInitializationCompleted()
{
if (PendingRequest is not null)
{
RequestedThemeVariant = string.Equals(PendingRequest.Theme, "Dark", System.StringComparison.OrdinalIgnoreCase)
? ThemeVariant.Dark
: ThemeVariant.Light;

if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow(PendingRequest);
}
}

base.OnFrameworkInitializationCompleted();
}
}
54 changes: 54 additions & 0 deletions se5/TypewriterEffect/MainWindow.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="SubtitleEdit.Plugins.TypewriterEffect.MainWindow"
Title="Typewriter effect"
Width="520" Height="360"
WindowStartupLocation="CenterScreen"
CanResize="False">
<DockPanel Margin="20">
<StackPanel DockPanel.Dock="Bottom"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="10"
Margin="0,16,0,0">
<Button Content="Cancel"
Click="OnCancel"
MinWidth="100" />
<Button x:Name="OkButton"
Content="OK"
Click="OnOk"
MinWidth="100"
Classes="accent"
IsDefault="True" />
</StackPanel>

<StackPanel Orientation="Vertical" Spacing="14">
<TextBlock FontSize="18"
FontWeight="SemiBold"
Text="Typewriter effect" />

<TextBlock x:Name="InfoLabel"
Opacity="0.8"
TextWrapping="Wrap" />

<StackPanel Orientation="Vertical" Spacing="6">
<TextBlock Text="End delay (seconds)" />
<NumericUpDown x:Name="EndDelayInput"
Minimum="0"
Maximum="10"
Increment="0.5"
FormatString="0.0##"
Value="0.5"
Width="160"
HorizontalAlignment="Left" />
<TextBlock Opacity="0.65"
FontSize="11"
TextWrapping="Wrap"
Text="After the last character appears, the full line stays on screen for this long before the original end time." />
</StackPanel>
</StackPanel>
</DockPanel>
</Window>
90 changes: 90 additions & 0 deletions se5/TypewriterEffect/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using System;
using System.Text.Json;

namespace SubtitleEdit.Plugins.TypewriterEffect;

public partial class MainWindow : Window
{
private readonly PluginRequest _request;

// Parameterless constructor only exists for the XAML designer.
public MainWindow() : this(new PluginRequest()) { }

public MainWindow(PluginRequest request)
{
InitializeComponent();
_request = request;

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);
}

private void InitializeComponent() => AvaloniaXamlLoader.Load(this);

private void OnCancel(object? sender, RoutedEventArgs e)
{
App.Response = new PluginResponse { Status = "cancelled" };
Close();
}

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 };
}

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)
{
using var doc = JsonDocument.Parse($"{{\"endDelay\":{endDelay.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)}}}");
return doc.RootElement.Clone();
}
}
40 changes: 40 additions & 0 deletions se5/TypewriterEffect/PluginContract.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Text.Json;

namespace SubtitleEdit.Plugins.TypewriterEffect;

// Mirrors the Subtitle Edit 5 plugin JSON contract.
// See https://github.com/SubtitleEdit/subtitleedit/blob/main/docs/plugin.md

public sealed class PluginRequest
{
public int ApiVersion { get; set; } = 1;
public string RequestType { get; set; } = "run";
public string ResponseFilePath { get; set; } = string.Empty;
public string TempDirectory { get; set; } = string.Empty;
public PluginSubtitle Subtitle { get; set; } = new();
public List<int> SelectedIndices { get; set; } = new();
public string VideoFileName { get; set; } = string.Empty;
public double FrameRate { get; set; }
public string UiLanguage { get; set; } = string.Empty;
public string Theme { get; set; } = string.Empty;
public string SeVersion { get; set; } = string.Empty;
public JsonElement? Settings { get; set; }
}

public sealed class PluginSubtitle
{
public string Format { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string Native { get; set; } = string.Empty;
public string SubRip { get; set; } = string.Empty;
}

public sealed class PluginResponse
{
public int ApiVersion { get; set; } = 1;
public string Status { get; set; } = "cancelled";
public string? Message { get; set; }
public PluginSubtitle? Subtitle { get; set; }
public JsonElement? Settings { get; set; }
public string? UndoDescription { get; set; }
}
Loading
Loading