diff --git a/.github/workflows/arte-check.yml b/.github/workflows/arte-check.yml new file mode 100644 index 0000000..4e79de9 --- /dev/null +++ b/.github/workflows/arte-check.yml @@ -0,0 +1,196 @@ +name: Build ArteCheck (SE5) + +on: + push: + branches: [main] + paths: + - 'se5/ArteCheck/**' + - 'se5/ArteCheck.Tests/**' + - 'se5/Plugin-Shared/**' + - '.github/workflows/split-dialogs.yml' + pull_request: + paths: + - 'se5/ArteCheck/**' + - 'se5/ArteCheck.Tests/**' + - 'se5/Plugin-Shared/**' + - '.github/workflows/split-dialogs.yml' + workflow_dispatch: + inputs: + release: + description: 'Publish a GitHub release. Bumps the minor in plugin.json and commits it back.' + type: boolean + default: false + version: + description: 'Optional explicit version (e.g. 1.2.0). Overrides the auto-minor-bump.' + required: false + type: string + +permissions: + contents: write + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set.outputs.version }} + tag: ${{ steps.set.outputs.tag }} + ref: ${{ steps.set.outputs.ref }} + release: ${{ steps.set.outputs.release }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve version (and bump on release) + id: set + run: | + set -e + current=$(jq -r .version se5/ArteCheck/plugin.json) + release_flag="false" + ref="${{ github.sha }}" + version="$current" + tag="" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.release }}" = "true" ]; then + release_flag="true" + if [ -n "${{ inputs.version }}" ]; then + version="${{ inputs.version }}" + else + IFS=. read -r major minor patch <<< "$current" + version="$major.$((minor + 1)).0" + fi + + tmp=$(mktemp) + jq --arg v "$version" '.version = $v' se5/ArteCheck/plugin.json > "$tmp" + mv "$tmp" se5/ArteCheck/plugin.json + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add se5/ArteCheck/plugin.json + git commit -m "Bump ArteCheck to $version [skip ci]" + + # Retry-with-rebase: if a concurrent release dispatched for a different + # plugin won the push first, pull --rebase and try again. + attempts=0 + while ! git push 2>/tmp/push.err; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 5 ]; then + echo "Failed to push after $attempts attempts:" >&2 + cat /tmp/push.err >&2 + exit 1 + fi + echo "Push rejected (attempt $attempts), pulling --rebase and retrying..." + git pull --rebase --no-edit + done + ref=$(git rev-parse HEAD) + + IFS=. read -r major minor patch <<< "$version" + tag="se5-arte-check-v$major.$minor" + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + 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/ArteCheck.Tests/ArteCheck.Tests.csproj -c Release + + build: + needs: [prepare, test] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + os: windows + exe: ArteCheck.exe + - rid: win-arm64 + os: windows + exe: ArteCheck.exe + - rid: linux-x64 + os: linux + exe: ArteCheck + - rid: linux-arm64 + os: linux + exe: ArteCheck + - rid: osx-x64 + os: macos + exe: ArteCheck + - rid: osx-arm64 + os: macos + exe: ArteCheck + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Publish self-contained (${{ matrix.rid }}) + working-directory: se5/ArteCheck + run: | + dotnet publish ArteCheck.csproj \ + -c Release \ + -r ${{ matrix.rid }} \ + --self-contained true \ + -p:DebugType=None \ + -p:DebugSymbols=false \ + -o staging/ArteCheck + + - name: Rewrite plugin.json for ${{ matrix.os }} + working-directory: se5/ArteCheck + run: | + jq ' + del(.runtime, .entry) | + .executables = { "${{ matrix.os }}": "${{ matrix.exe }}" } + ' plugin.json > staging/ArteCheck/plugin.json + + - name: Package zip + working-directory: se5/ArteCheck/staging + run: zip -r "$GITHUB_WORKSPACE/ArteCheck-${{ matrix.rid }}.zip" ArteCheck + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ArteCheck-${{ matrix.rid }} + path: ArteCheck-${{ matrix.rid }}.zip + + release: + name: Publish GitHub Release + needs: [prepare, build] + if: needs.prepare.outputs.release == 'true' + runs-on: ubuntu-latest + steps: + - name: Download all zips + uses: actions/download-artifact@v4 + with: + path: dist + pattern: ArteCheck-* + merge-multiple: true + + - name: Create release and upload zips + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ needs.prepare.outputs.tag }}" dist/ArteCheck-*.zip \ + --repo "${{ github.repository }}" \ + --target "${{ needs.prepare.outputs.ref }}" \ + --title "ArteCheck v${{ needs.prepare.outputs.version }}" \ + --notes "Self-contained ArteCheck plugin builds for win/linux/osx (x64 + arm64)." diff --git a/se5/ArteCheck.Tests/ArteCheck.Tests.csproj b/se5/ArteCheck.Tests/ArteCheck.Tests.csproj new file mode 100644 index 0000000..8553914 --- /dev/null +++ b/se5/ArteCheck.Tests/ArteCheck.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/se5/ArteCheck.Tests/ArteCheckerTests.cs b/se5/ArteCheck.Tests/ArteCheckerTests.cs new file mode 100644 index 0000000..ce92040 --- /dev/null +++ b/se5/ArteCheck.Tests/ArteCheckerTests.cs @@ -0,0 +1,244 @@ +using System.Text.RegularExpressions; +using Projektanker.Icons.Avalonia.MaterialDesign; +using SubtitleEdit.Plugins.ArteCheck; +using SubtitleEdit.Plugins.Shared; + +namespace ArteCheck.Tests; + +public class ArteCheckerTests +{ + private const double Hour10 = 10 * 3_600_000.0; + + private static PluginParagraph P(double startMs, double endMs, string text, string? row = "22") => + new() { StartMs = startMs, EndMs = endMs, Text = text, MarginV = row }; + + private static ArteOptions Options(params ArteCheckType[] checks) => new() + { + EnabledChecks = checks.Length == 0 ? Enum.GetValues().ToHashSet() : checks.ToHashSet(), + }; + + private static string ArteHeader() + { + var header = GsiHeader.CreateDefault(); + header.CodePage = "850"; + header.DiskFormatCode = "STL25.01"; + header.DisplayStandardCode = "2"; + header.LanguageCode = "08"; + return header.ToString(); + } + + [Fact] + public void GsiHeader_DefaultIs1024AndRoundTrips() + { + var header = GsiHeader.CreateDefault(); + Assert.Equal(1024, header.ToString().Length); + Assert.True(GsiHeader.IsStlHeader(header.ToString())); + header.LanguageCode = "2D"; + header.TimeCodeStartOfProgramme = "10000000"; + var parsed = GsiHeader.Parse(header.ToString()); + Assert.Equal("2D", parsed.LanguageCode); + Assert.Equal("10000000", parsed.TimeCodeStartOfProgramme); + Assert.Equal("STL25.01", parsed.DiskFormatCode); + } + + [Fact] + public void Header_ProposesArteValues() + { + var fixes = ArteChecker.Analyze(new[] { P(Hour10, Hour10 + 200, "") }, GsiHeader.CreateDefault().ToString(), Options()); + var fix = Assert.Single(fixes, f => f.Kind == ArteFixKind.Header); + var header = GsiHeader.Parse(fix.ProposedHeader!); + Assert.Equal("850", header.CodePage); + Assert.Equal("2", header.DisplayStandardCode); + Assert.Equal("08", header.LanguageCode); + } + + [Fact] + public void BlankSubtitle_MissingIsInsertedAtTheHour() + { + var source = new[] { P(Hour10 + 5000, Hour10 + 8000, "Hallo") }; + var fixes = ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.BlankSubtitle)); + var fix = Assert.Single(fixes); + Assert.Equal(ArteFixKind.CreateBlankSubtitle, fix.Kind); + + var result = ArteChecker.Apply(source, ArteHeader(), fixes, Options()); + Assert.Equal(2, result.Paragraphs.Count); + Assert.Equal(Hour10, result.Paragraphs[0].StartMs); + Assert.Equal(Hour10 + 200, result.Paragraphs[0].EndMs); + Assert.Equal(string.Empty, result.Paragraphs[0].Text); + } + + [Fact] + public void FrameRateConversion_KeepsTheProgrammeStart() + { + var source = new List { P(Hour10, Hour10 + 200, ""), P(Hour10 + 60_000, Hour10 + 62_000, "Hallo") }; + var converted = ArteChecker.ConvertFrameRate(source.Select(p => p.Clone()).ToList(), 23.976); + Assert.Equal(Hour10, converted[0].StartMs); + Assert.Equal(Hour10 + 60_000 * (24000.0 / 1001.0) / 25.0, converted[1].StartMs, 3); + } + + [Fact] + public void FrameAccurate_RoundsToWholeFrames() + { + var source = new[] { P(Hour10 + 1010, Hour10 + 3030, "Hallo") }; + var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.FrameAccurateTimeCodes))); + Assert.Equal(Hour10 + 1000, fix.ProposedStartMs); + Assert.Equal(Hour10 + 3040, fix.ProposedEndMs); + } + + [Fact] + public void MinimumGap_SharedBetweenOutAndInTime() + { + var source = new[] { P(Hour10, Hour10 + 4000, "Eins"), P(Hour10 + 4000, Hour10 + 8000, "Zwei") }; + var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.MinimumGaps))); + Assert.True(fix.CanBeFixed); + var result = ArteChecker.Apply(source, ArteHeader(), new[] { fix }, Options()); + Assert.Equal(Hour10 + 4000 - 2 * 40, result.Paragraphs[0].EndMs); + Assert.Equal(Hour10 + 4000 + 3 * 40, result.Paragraphs[1].StartMs); + } + + [Fact] + public void MinimumGap_NoRoomIsAnAlarm() + { + var source = new[] { P(Hour10, Hour10 + 900, "Eins zwei drei vier"), P(Hour10 + 900, Hour10 + 1800, "Fünf sechs sieben acht") }; + var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.MinimumGaps))); + Assert.True(fix.IsAlarm); + } + + [Fact] + public void LinePosition_OneLineOnRow23MovesTo22() + { + var source = new[] { P(Hour10, Hour10 + 3000, "Hallo", "23"), P(Hour10 + 4000, Hour10 + 7000, "Eins\nZwei", "22") }; + var fixes = ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.TeletextLinePosition)); + Assert.Equal(new[] { "22", "20" }, fixes.Select(f => f.After)); + } + + [Fact] + public void Colors_NormalSubtitlesBecomeYellowEverywhere() + { + var source = new[] { P(Hour10, Hour10 + 3000, "Hallo"), P(Hour10 + 4000, Hour10 + 7000, "Welt") }; + var fixes = ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.TeletextColors)); + Assert.Equal(2, fixes.Count); + Assert.All(fixes, f => Assert.StartsWith("", f.After)); + } + + [Fact] + public void Colors_SdhKeepsTheNearestTeletextColor() + { + var options = Options(ArteCheckType.TeletextColors); + options.Profile = ArteProfile.All.Single(p => p.Code == "HG-DEU"); + var source = new[] { P(Hour10, Hour10 + 3000, "Hallo") }; + var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), options), f => f.Group == ArteChecker.GroupColors); + Assert.Equal("Hallo", fix.After); + } + + [Fact] + public void LineLength_ColorCellCountsAgainstTheLimit() + { + var line = new string('a', 36); + Assert.True(TeletextText.Fits(line + "b", 37)); + Assert.False(TeletextText.Fits("" + line + "b", 37)); + Assert.True(TeletextText.Fits("" + line + "", 37)); + } + + [Fact] + public void LineLength_TooLongIsRebalanced() + { + var source = new[] { P(Hour10, Hour10 + 5000, "Das ist eine ziemlich lange Zeile, die nicht passt", "22") }; + var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.TeletextLineLength))); + Assert.Equal(ArteFixKind.Rebalance, fix.Kind); + Assert.Equal("Das ist eine ziemlich lange Zeile,\ndie nicht passt", fix.After); + + var result = ArteChecker.Apply(source, ArteHeader(), new[] { fix }, Options()); + Assert.Equal("20", result.Paragraphs[0].MarginV); // two double-height lines keep the bottom edge + } + + [Fact] + public void ThreeLines_SplitIntoTwoSubtitlesInsideTheTimeRange() + { + var text = "Wir müssen jetzt sofort gehen, sonst kommen wir zu spät.\nDer Zug wartet nicht auf uns.\nNiemand wartet auf uns."; + var source = new[] { P(Hour10, Hour10 + 9000, text, "18") }; + var fix = Assert.Single(ArteChecker.Analyze(source, ArteHeader(), Options(ArteCheckType.MaximumTwoLines))); + Assert.Equal(ArteFixKind.Split, fix.Kind); + + var result = ArteChecker.Apply(source, ArteHeader(), new[] { fix }, Options()); + Assert.True(result.Paragraphs.Count >= 2); + Assert.Equal(Hour10, result.Paragraphs[0].StartMs); + Assert.Equal(Hour10 + 9000, result.Paragraphs[^1].EndMs); + Assert.All(result.Paragraphs, p => Assert.True(TeletextText.Fits(p.Text, 37))); + for (var i = 1; i < result.Paragraphs.Count; i++) + { + Assert.True(result.Paragraphs[i].StartMs - result.Paragraphs[i - 1].EndMs >= 5 * 40); + } + + Assert.Equal(TeletextText.Words(text), TeletextText.Words(string.Join(" ", result.Paragraphs.Select(p => p.Text)))); + } + + [Fact] + public void Italic_IsRemoved() + { + var fix = Assert.Single(ArteChecker.Analyze(new[] { P(Hour10, Hour10 + 3000, "Hallo") }, ArteHeader(), Options(ArteCheckType.Italic))); + Assert.Equal("Hallo", fix.After); + } + + [Fact] + public void ShiftStartTimeCode_MovesEverythingAndUpdatesTcp() + { + var source = new[] { P(3_600_000, 3_600_200, ""), P(3_605_000, 3_608_000, "Hallo") }; + var options = Options(ArteCheckType.BlankSubtitle); + options.ShiftToStartTimeCode = true; + options.TargetStartMs = Hour10; + var fixes = ArteChecker.Analyze(source, ArteHeader(), options); + var result = ArteChecker.Apply(source, ArteHeader(), fixes.Where(f => f.Kind == ArteFixKind.ShiftStartTimeCode).ToList(), options); + Assert.Equal(Hour10, result.Paragraphs[0].StartMs); + Assert.Equal(Hour10 + 5000, result.Paragraphs[1].StartMs); + Assert.Equal("10000000", GsiHeader.Parse(result.Header!).TimeCodeStartOfProgramme); + } + + [Fact] + public void CleanFile_HasNoFindings() + { + var source = new[] + { + P(Hour10, Hour10 + 200, ""), + P(Hour10 + 1000, Hour10 + 4000, "Guten Tag."), + P(Hour10 + 4200, Hour10 + 8000, "Wie geht es Ihnen\nheute Abend?", "20"), + }; + Assert.Empty(ArteChecker.Analyze(source, ArteHeader(), Options())); + } + + [Fact] + public void EveryIconNameExists() + { + var files = Directory.GetFiles(FindSourceFolder(), "*.*", SearchOption.TopDirectoryOnly) + .Where(f => f.EndsWith(".cs") || f.EndsWith(".axaml")); + var names = files.SelectMany(f => Regex.Matches(File.ReadAllText(f), "\"(mdi-[a-z0-9-]+)\"").Select(m => m.Groups[1].Value)).Distinct().ToList(); + Assert.NotEmpty(names); + var provider = new MaterialDesignIconProvider(); + var getIcon = typeof(MaterialDesignIconProvider).GetMethods() + .First(m => m.ReturnType != typeof(void) && m.GetParameters() is [{ ParameterType: var t }] && t == typeof(string)); + var missing = names.Where(n => + { + try + { + getIcon.Invoke(provider, new object[] { n }); + return false; + } + catch + { + return true; + } + }).ToList(); + Assert.Empty(missing); + } + + private static string FindSourceFolder() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "ArteCheck"))) + { + dir = dir.Parent; + } + + return Path.Combine(dir!.FullName, "ArteCheck"); + } +} diff --git a/se5/ArteCheck/App.axaml b/se5/ArteCheck/App.axaml new file mode 100644 index 0000000..fb25693 --- /dev/null +++ b/se5/ArteCheck/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/se5/ArteCheck/App.axaml.cs b/se5/ArteCheck/App.axaml.cs new file mode 100644 index 0000000..90fd69d --- /dev/null +++ b/se5/ArteCheck/App.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.ArteCheck; + +public partial class App : PluginApp +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + protected override Window CreateMainWindow(PluginRequest request) => new MainWindow(request); +} diff --git a/se5/ArteCheck/ArteCheck.csproj b/se5/ArteCheck/ArteCheck.csproj new file mode 100644 index 0000000..809556d --- /dev/null +++ b/se5/ArteCheck/ArteCheck.csproj @@ -0,0 +1,27 @@ + + + + WinExe + net8.0 + enable + enable + ArteCheck + SubtitleEdit.Plugins.ArteCheck + true + app.manifest + + + + + + + + + + + + + + + + diff --git a/se5/ArteCheck/ArteChecker.cs b/se5/ArteCheck/ArteChecker.cs new file mode 100644 index 0000000..9e4f9bd --- /dev/null +++ b/se5/ArteCheck/ArteChecker.cs @@ -0,0 +1,859 @@ +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.ArteCheck; + +/// +/// ARTE EBU STL / teletext delivery checks (ported from SubtitleEdit/subtitleedit#15185 by +/// Triathlon-rally). ARTE delivers at 25 fps, so every time code is a multiple of 40 ms. +/// +public sealed class ArteChecker +{ + public const double FrameMs = 40.0; + public const int BottomRow = 23; + + public const string GroupHeader = "Header / language"; + public const string GroupStartTimeCode = "Start time code"; + public const string GroupBlank = "ARTE blank subtitle"; + public const string GroupFrames = "Frame-accurate time codes"; + public const string GroupDuration = "Display duration"; + public const string GroupLayout = "Split / rebalance"; + public const string GroupPosition = "Teletext line position"; + public const string GroupColors = "Teletext colors"; + public const string GroupItalic = "Italic formatting"; + public const string GroupSpaces = "Unneeded spaces"; + public const string GroupGaps = "Minimum gaps"; + + private readonly ArteOptions _options; + private readonly List _fixes = new(); + + private ArteChecker(ArteOptions options) => _options = options; + + private double MinimumGapMs => _options.MinimumGapFrames * FrameMs; + + public static List Analyze(IReadOnlyList source, string? header, ArteOptions options) + { + var checker = new ArteChecker(options); + var subtitle = ConvertFrameRate(source.Select(p => p.Clone()).ToList(), options.SourceFrameRate); + checker.Run(subtitle, header); + foreach (var fix in checker._fixes.Where(f => f.Index > 0 && f.Index <= subtitle.Count && string.IsNullOrEmpty(f.TimeRange))) + { + fix.TimeRange = FormatTimeRange(subtitle[fix.Index - 1]); + } + + return checker._fixes; + } + + private void Run(List subtitle, string? header) + { + var enabled = _options.EnabledChecks; + AnalyzeHeader(header); + if (_options.ShiftToStartTimeCode) + { + AnalyzeStartTimeCodeShift(subtitle); + } + + if (enabled.Contains(ArteCheckType.BlankSubtitle)) AnalyzeBlankSubtitle(subtitle); + if (enabled.Contains(ArteCheckType.FrameAccurateTimeCodes)) AnalyzeFrameAccurateTimeCodes(subtitle); + if (enabled.Contains(ArteCheckType.DisplayDuration)) AnalyzeDisplayDurations(subtitle); + if (enabled.Contains(ArteCheckType.MaximumTwoLines)) AnalyzeMaximumTwoLines(subtitle); + if (enabled.Contains(ArteCheckType.TeletextLinePosition)) AnalyzeLinePositions(subtitle); + + // A color code takes a teletext cell, so colors are normalized before line lengths are measured. + if (enabled.Contains(ArteCheckType.TeletextColors)) AnalyzeColors(subtitle); + if (enabled.Contains(ArteCheckType.TeletextLineLength)) AnalyzeLineLengths(subtitle); + if (enabled.Contains(ArteCheckType.Italic)) AnalyzeItalic(subtitle); + if (enabled.Contains(ArteCheckType.UnneededSpaces)) AnalyzeUnneededSpaces(subtitle); + if (enabled.Contains(ArteCheckType.MinimumGaps)) AnalyzeMinimumGaps(subtitle); + } + + private void Add(ArteFix fix) => _fixes.Add(fix); + + // ---- frame rate / time code helpers ------------------------------------------------------ + + public static double GetFrameForCalculation(double frameRate) => + Math.Abs(frameRate - 23.976) < 0.001 ? 24000.0 / 1001.0 : + Math.Abs(frameRate - 29.97) < 0.001 ? 30000.0 / 1001.0 : + Math.Abs(frameRate - 59.94) < 0.001 ? 60000.0 / 1001.0 : + frameRate; + + /// + /// Converts to 25 fps around the programme start (10:00:00:00, 01:00:00:00, ...): only the time + /// relative to it is scaled, so the start time code itself does not move. + /// + public static List ConvertFrameRate(List subtitle, double sourceFrameRate) + { + if (Math.Abs(sourceFrameRate - 25.0) < 0.001 || subtitle.Count == 0) + { + return subtitle; + } + + var factor = GetFrameForCalculation(sourceFrameRate) / 25.0; + var reference = GetStartTimeCodeReference(subtitle); + foreach (var p in subtitle) + { + p.StartMs = reference + (p.StartMs - reference) * factor; + p.EndMs = reference + (p.EndMs - reference) * factor; + } + + return subtitle; + } + + /// The leading blank subtitle's start, or else the full hour the first subtitle is in. + public static double GetStartTimeCodeReference(IReadOnlyList subtitle) + { + if (subtitle.Count == 0) + { + return 0; + } + + var first = subtitle[0]; + return TeletextText.IsBlank(first.Text) + ? first.StartMs + : Math.Floor(first.StartMs / 3_600_000.0) * 3_600_000.0; + } + + public static double RoundToFrame(double ms) => Math.Round(ms / FrameMs, MidpointRounding.AwayFromZero) * FrameMs; + + public static string FormatTimeCode(double ms) + { + var frames = (long)Math.Round(ms / FrameMs, MidpointRounding.AwayFromZero); + return $"{frames / 90000:00}:{frames / 1500 % 60:00}:{frames / 25 % 60:00}:{frames % 25:00}"; + } + + /// HH:MM:SS.mmm - shows the off-frame milliseconds that frame rounding removes. + public static string FormatPrecise(double ms) + { + var t = TimeSpan.FromMilliseconds(Math.Round(ms)); + return $"{(int)t.TotalHours:00}:{t.Minutes:00}:{t.Seconds:00}.{t.Milliseconds:000}"; + } + + public static string FormatDuration(double ms) + { + var frames = (long)Math.Round(ms / FrameMs, MidpointRounding.AwayFromZero); + return $"{frames / 25} s {frames % 25:00} fr"; + } + + public static string FormatTimeRange(PluginParagraph p) => + $"{FormatTimeCode(p.StartMs)} → {FormatTimeCode(p.EndMs)} · {FormatDuration(p.EndMs - p.StartMs)}"; + + public static bool TryParseTimeCode(string text, out double ms) + { + ms = 0; + var parts = text.Trim().Split(':', ';', '.', ','); + if (parts.Length != 4 || !parts.All(p => int.TryParse(p, out _))) + { + return false; + } + + var v = parts.Select(int.Parse).ToArray(); + if (v[1] > 59 || v[2] > 59 || v[3] > 24) + { + return false; + } + + ms = ((v[0] * 3600.0 + v[1] * 60 + v[2]) * 25 + v[3]) * FrameMs; + return true; + } + + // ---- checks ------------------------------------------------------------------------------ + + private void AnalyzeHeader(string? headerText) + { + var hasStlHeader = GsiHeader.IsStlHeader(headerText); + var header = hasStlHeader ? GsiHeader.Parse(headerText!) : GsiHeader.CreateDefault(); + var original = header.Clone(); + header.CodePage = "850"; + header.DiskFormatCode = "STL25.01"; + header.DisplayStandardCode = "2"; + header.CharacterCodeTable = "00"; + header.LanguageCode = _options.Profile.LanguageCode; + header.MaxCharactersPerRow = "40"; + header.MaxRows = "23"; + var after = header.DescribeDifferences(original); + if (after.Length > 0) + { + var before = hasStlHeader ? original.DescribeDifferences(header) : "No EBU STL header"; + Add(new ArteFix(GroupHeader, true, 0, before, after, + hasStlHeader + ? $"Set the EBU header to the {_options.Profile.Name} delivery values (25 fps teletext, code page 850)." + : $"Create an EBU header with the {_options.Profile.Name} delivery values.", + ArteFixKind.Header) + { + ProposedHeader = header.ToString(), + }); + } + } + + private void AnalyzeStartTimeCodeShift(List subtitle) + { + var current = GetStartTimeCodeReference(subtitle); + var target = RoundToFrame(_options.TargetStartMs); + if (Math.Abs(target - current) < 0.01) + { + return; + } + + Add(new ArteFix(GroupStartTimeCode, true, 0, FormatTimeCode(current), FormatTimeCode(target), + "Shift every subtitle so the programme starts at the requested time code.", ArteFixKind.ShiftStartTimeCode) + { + ProposedStartMs = target - current, + }); + } + + private void AnalyzeBlankSubtitle(List subtitle) + { + if (subtitle.Count == 0) + { + return; + } + + var first = subtitle[0]; + var startMs = GetStartTimeCodeReference(subtitle); + var expectedEndMs = startMs + 200.0; + if (!TeletextText.IsBlank(first.Text)) + { + if (Math.Abs(first.StartMs - startMs) < 0.01) + { + Add(new ArteFix(GroupBlank, false, 1, first.Text, string.Empty, + "The first subtitle starts on the programme start time code, where ARTE needs the blank control subtitle. Move it manually.")); + return; + } + + var blank = new PluginParagraph { StartMs = startMs, EndMs = expectedEndMs, Text = string.Empty, MarginV = "22" }; + Add(new ArteFix(GroupBlank, true, 0, "Missing", FormatTimeRange(blank), + "Insert the blank ARTE control subtitle (5 frames) at the programme start.", ArteFixKind.CreateBlankSubtitle) + { + ProposedParagraph = blank, + }); + return; + } + + if (Math.Abs(first.EndMs - expectedEndMs) >= 0.01) + { + var nextStart = subtitle.Count > 1 ? subtitle[1].StartMs : double.PositiveInfinity; + var canFix = expectedEndMs <= nextStart - MinimumGapMs; + var expected = new PluginParagraph { StartMs = startMs, EndMs = expectedEndMs }; + Add(new ArteFix(GroupBlank, canFix, 1, FormatTimeRange(first), canFix ? FormatTimeRange(expected) : string.Empty, + canFix + ? "The blank control subtitle must last exactly 5 frames." + : "The blank control subtitle must last 5 frames, but the next subtitle starts too early.", + ArteFixKind.DisplayDuration) + { + ProposedEndMs = canFix ? expectedEndMs : null, + }); + } + } + + private void AnalyzeFrameAccurateTimeCodes(List subtitle) + { + for (var i = 0; i < subtitle.Count; i++) + { + var p = subtitle[i]; + var start = RoundToFrame(p.StartMs); + var end = RoundToFrame(p.EndMs); + if (Math.Abs(p.StartMs - start) < 0.01 && Math.Abs(p.EndMs - end) < 0.01) + { + continue; + } + + if (end <= start) + { + Add(new ArteFix(GroupFrames, false, i + 1, FormatTimeRange(p), string.Empty, + "Rounding to whole 25 fps frames leaves no duration.")); + continue; + } + + Add(new ArteFix(GroupFrames, true, i + 1, $"{FormatPrecise(p.StartMs)} → {FormatPrecise(p.EndMs)}", + $"{FormatPrecise(start)} → {FormatPrecise(end)}", + "Round the time codes to whole 25 fps frames.", ArteFixKind.FrameAccurateTimeCode) + { + ProposedStartMs = start, + ProposedEndMs = end, + }); + } + } + + /// Reading time the text needs (minimum duration or CPS, whichever is longer). + private double RequiredDurationMs(string text) + { + var cps = _options.MaximumCharactersPerSecond; + var reading = cps > 0 ? TeletextText.VisibleCharacterCount(text) * 1000.0 / cps : 0; + return Math.Max(_options.MinimumDurationMs, reading); + } + + private double AcceptedMinimumDurationMs(string text) => + _options.AcceptShortDurations + ? _options.ShortMinimumFrames * FrameMs + : RequiredDurationMs(text) * Math.Max(0, 1.0 - _options.ReadingDurationTolerancePercent / 100.0); + + private void AnalyzeDisplayDurations(List subtitle) + { + var maximumMs = _options.MaximumDurationMs; + for (var i = 0; i < subtitle.Count; i++) + { + var p = subtitle[i]; + if (TeletextText.IsBlank(p.Text)) + { + continue; + } + + var duration = p.EndMs - p.StartMs; + var required = RequiredDurationMs(p.Text); + var accepted = AcceptedMinimumDurationMs(p.Text); + var tooShort = duration < accepted; + var tooLong = maximumMs > 0 && duration > maximumMs; + if (!tooShort && !tooLong) + { + continue; + } + + var desiredEnd = tooShort + ? RoundToFrame(p.StartMs + (_options.AcceptShortDurations ? _options.ShortMinimumFrames * FrameMs : required)) + : RoundToFrame(p.StartMs + maximumMs); + var nextStart = i + 1 < subtitle.Count ? subtitle[i + 1].StartMs - MinimumGapMs : double.PositiveInfinity; + var canFix = desiredEnd > p.StartMs && desiredEnd <= nextStart && (!tooLong || desiredEnd >= required + p.StartMs); + var issue = tooShort + ? _options.AcceptShortDurations + ? $"Shown for {FormatDuration(duration)}, below the {_options.ShortMinimumFrames}-frame short minimum." + : $"Shown for {FormatDuration(duration)}, below the tolerated minimum of {FormatDuration(accepted)} " + + $"({_options.ReadingDurationTolerancePercent:0.#}% under the {FormatDuration(required)} reading time)." + : $"Shown for {FormatDuration(duration)}, above the maximum of {FormatDuration(maximumMs)}."; + Add(new ArteFix(GroupDuration, canFix, i + 1, FormatDuration(duration), + canFix ? FormatDuration(desiredEnd - p.StartMs) : string.Empty, + canFix ? issue + " Optional: move the out time." : issue + " No room to move the out time - edit manually.", + ArteFixKind.DisplayDuration, applyByDefault: false) + { + ProposedEndMs = canFix ? desiredEnd : null, + }); + } + } + + private void AnalyzeMaximumTwoLines(List subtitle) + { + for (var i = 0; i < subtitle.Count; i++) + { + var lineCount = TeletextText.LineCount(subtitle[i].Text); + if (lineCount > 2) + { + AddLayoutProposal(subtitle[i], i + 1, $"{lineCount} lines - teletext allows two."); + } + } + } + + private void AnalyzeLinePositions(List subtitle) + { + // Double height: the stored row is the first physical row, so one line belongs on 22 (22+23) + // and two lines on 20 (20+21, 22+23). + var correctBottom = 0; + var oneRowHigh = 0; + foreach (var p in subtitle) + { + if (string.IsNullOrWhiteSpace(p.Text) || !int.TryParse(p.MarginV, out var row)) + { + continue; + } + + var lines = TeletextText.LineCount(p.Text); + if (lines > 2) + { + continue; + } + + var expected = lines == 1 ? 22 : 20; + if (row == expected) + { + correctBottom++; + } + else if (row == expected - 1) + { + oneRowHigh++; + } + } + + var shiftWholeFile = oneRowHigh > 0 && oneRowHigh > correctBottom; + for (var i = 0; i < subtitle.Count; i++) + { + var p = subtitle[i]; + if (string.IsNullOrWhiteSpace(p.Text)) + { + if (p.MarginV != "22") + { + Add(new ArteFix(GroupPosition, true, i + 1, string.IsNullOrWhiteSpace(p.MarginV) ? "Not set" : p.MarginV, "22", + "A blank subtitle sits on row 22.", ArteFixKind.TeletextLinePosition)); + } + + continue; + } + + var lineCount = TeletextText.LineCount(p.Text); + if (lineCount > 2) + { + continue; + } + + var expectedRow = lineCount == 1 ? 22 : 20; + var hasRow = int.TryParse(p.MarginV, out var currentRow); + if (shiftWholeFile && hasRow) + { + Add(new ArteFix(GroupPosition, true, i + 1, currentRow.ToString(), (currentRow + 1).ToString(), + "Most subtitles sit one row too high - move the whole file down one row.", ArteFixKind.TeletextLinePosition)); + continue; + } + + if (hasRow && ((lineCount == 1 && currentRow == BottomRow) || (lineCount == 2 && currentRow == BottomRow - 1))) + { + Add(new ArteFix(GroupPosition, true, i + 1, currentRow.ToString(), expectedRow.ToString(), + $"Double height: {lineCount} line(s) must start on row {expectedRow} to stay on the page.", + ArteFixKind.TeletextLinePosition)); + continue; + } + + // Higher rows are deliberate (e.g. a title at the top) and stay as they are. + if (!hasRow) + { + Add(new ArteFix(GroupPosition, true, i + 1, "Not set", expectedRow.ToString(), + $"No teletext row - place {lineCount} line(s) at the bottom (row {expectedRow}).", + ArteFixKind.TeletextLinePosition)); + } + } + } + + private static string NormalizeColors(string text, bool isSdh, out bool hasUnsupported) + { + var unsupported = false; + var normalized = TeletextText.ColorAttribute.Replace(text, match => + { + var color = match.Groups["quoted"].Success ? match.Groups["quoted"].Value : + match.Groups["single"].Success ? match.Groups["single"].Value : match.Groups["bare"].Value; + var teletext = TeletextText.NearestTeletextColor(color); + if (teletext == null) + { + unsupported = true; + return match.Value; + } + + return "color=\"" + (isSdh ? teletext : "Yellow") + "\""; + }); + hasUnsupported = unsupported; + return normalized; + } + + private void AnalyzeColors(List subtitle) + { + var isSdh = _options.Profile.IsSdh; + + // A normal (non-SDH) file that uses color at all must use yellow for every subtitle; + // an uncolored file stays uncolored. + var yellowEverywhere = !isSdh && subtitle.Any(p => TeletextText.HasFontColor(p.Text)); + for (var i = 0; i < subtitle.Count; i++) + { + var text = subtitle[i].Text; + var withoutBox = isSdh ? text : TeletextText.RemoveBox(text); + var normalized = NormalizeColors(withoutBox, isSdh, out var unsupported); + if (yellowEverywhere && !string.IsNullOrWhiteSpace(text)) + { + normalized = System.Text.RegularExpressions.Regex.Replace(normalized, + @"]*\bcolor\s*=[^>]*>(?.*?)", "${text}", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); + normalized = "" + normalized + ""; + } + + if (normalized != text) + { + Add(new ArteFix(GroupColors, true, i + 1, text, normalized, + isSdh ? "Map the colors to the eight teletext colors." : + TeletextText.HasBox(text) ? "Boxing is for SDH only; normal subtitles are yellow or uncolored." : + "Normal subtitles are either all yellow or uncolored.", + ArteFixKind.TeletextColor)); + } + + if (unsupported) + { + Add(new ArteFix(GroupColors, false, i + 1, text, string.Empty, + "A color cannot be mapped to a teletext color - choose one manually.")); + } + } + } + + private void AnalyzeLineLengths(List subtitle) + { + for (var i = 0; i < subtitle.Count; i++) + { + var p = subtitle[i]; + if (string.IsNullOrWhiteSpace(p.Text)) + { + continue; + } + + // Measure the text as it will be written: after a proposed color normalization. + var text = _fixes.LastOrDefault(f => f.Index == i + 1 && f.Kind == ArteFixKind.TeletextColor)?.After ?? p.Text; + var lines = TeletextText.MeasureLines(text); + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var (visible, colorCells) = lines[lineIndex]; + var maximum = Math.Max(1, _options.TeletextMaxCells - colorCells); + if (visible.Length > maximum) + { + var layout = text == p.Text ? p : new PluginParagraph { StartMs = p.StartMs, EndMs = p.EndMs, Text = text, MarginV = p.MarginV }; + AddLayoutProposal(layout, i + 1, + $"Line {lineIndex + 1} has {visible.Length} characters" + + (colorCells > 0 ? $" + {colorCells} color cell(s)" : string.Empty) + + $" - the limit is {_options.TeletextMaxCells}."); + break; + } + } + } + } + + private void AddLayoutProposal(PluginParagraph p, int index, string reason) + { + // A subtitle can fail both the line-count and the line-width check; offer one fix. + if (_fixes.Any(f => f.Index == index && f.Group == GroupLayout)) + { + return; + } + + var lines = TeletextText.SplitLines(p.Text); + var isDialog = lines.Length == 2 && lines.All(l => TeletextText.RemoveTags(l).TrimStart().StartsWith('-')); + var proposed = isDialog ? null : TeletextText.Rebalance(p.Text, _options.TeletextMaxCells); + if (proposed != null && proposed != p.Text) + { + Add(new ArteFix(GroupLayout, true, index, p.Text, proposed, reason + " Rebalance the line break.", ArteFixKind.Rebalance)); + return; + } + + if (!AddSplitProposal(p, index, reason)) + { + Add(new ArteFix(GroupLayout, false, index, p.Text, string.Empty, reason + " No automatic split fits - edit manually.")); + } + } + + private bool AddSplitProposal(PluginParagraph p, int index, string reason) + { + bool Alarm(string message) + { + Add(new ArteFix(GroupLayout, false, index, p.Text, string.Empty, reason + " " + message)); + return true; + } + + if (TeletextText.HasFontColor(p.Text)) + { + return Alarm("Colored text is not split automatically - edit manually."); + } + + var texts = TeletextText.SplitToFit(TeletextText.RemoveItalic(p.Text), _options.TeletextMaxCells); + if (texts == null) + { + return false; + } + + if (TeletextText.Words(string.Join(" ", texts)) != TeletextText.Words(TeletextText.RemoveItalic(p.Text))) + { + return Alarm("A split would change the text - edit manually."); + } + + var parts = texts.Select(t => new PluginParagraph { Text = t }).ToList(); + if (!TryFitSplitTiming(parts, p.StartMs, p.EndMs, out var timingError)) + { + return Alarm(timingError); + } + + var preview = string.Join("\n\n", parts.Select((part, n) => $"{n + 1}. {FormatTimeRange(part)}\n{part.Text}")); + Add(new ArteFix(GroupLayout, true, index, p.Text, preview, + reason + $" Split into {parts.Count} subtitles inside the original time range." + + (TeletextText.HasItalic(p.Text) ? " Removes the italic tags." : string.Empty), + ArteFixKind.Split) + { + SplitParagraphs = parts, + }); + return true; + } + + /// Shares the original time range out in whole frames, by text length, with the minimum gap between parts. + private bool TryFitSplitTiming(List parts, double startMs, double endMs, out string error) + { + error = string.Empty; + startMs = RoundToFrame(startMs); + endMs = RoundToFrame(endMs); + var available = (int)Math.Round((endMs - startMs) / FrameMs) - _options.MinimumGapFrames * (parts.Count - 1); + var weights = parts.Select(p => Math.Max(1, TeletextText.VisibleCharacterCount(p.Text))).ToArray(); + var required = parts.Select(p => Math.Max(1, (int)Math.Ceiling(RequiredDurationMs(p.Text) / FrameMs))).ToArray(); + var minimum = parts.Select(p => Math.Max(1, (int)Math.Ceiling(AcceptedMinimumDurationMs(p.Text) / FrameMs))).ToArray(); + if (available < minimum.Sum()) + { + error = $"Not enough time for {parts.Count} subtitles plus {_options.MinimumGapFrames}-frame gaps - edit manually."; + return false; + } + + var maximum = _options.MaximumDurationMs > 0 ? Math.Max(1, (int)Math.Floor(_options.MaximumDurationMs / FrameMs)) : available; + var durations = minimum.ToArray(); + for (var remaining = available - durations.Sum(); remaining > 0; remaining--) + { + // Fill the parts still short of their reading time first, then share the rest by length. + var best = -1; + for (var i = 0; i < parts.Count; i++) + { + if (durations[i] >= maximum) + { + continue; + } + + if (best < 0) + { + best = i; + continue; + } + + var shortI = durations[i] < required[i]; + var shortBest = durations[best] < required[best]; + if (shortI != shortBest ? shortI : (double)durations[i] / weights[i] < (double)durations[best] / weights[best]) + { + best = i; + } + } + + if (best < 0) + { + break; + } + + durations[best]++; + } + + var cursor = startMs; + for (var i = 0; i < parts.Count; i++) + { + parts[i].StartMs = cursor; + parts[i].EndMs = cursor + durations[i] * FrameMs; + cursor += (durations[i] + _options.MinimumGapFrames) * FrameMs; + } + + return true; + } + + private void AnalyzeItalic(List subtitle) + { + for (var i = 0; i < subtitle.Count; i++) + { + var text = subtitle[i].Text; + if (TeletextText.HasItalic(text)) + { + Add(new ArteFix(GroupItalic, true, i + 1, text, TeletextText.RemoveItalic(text), + "Teletext has no italics - remove the italic tags.", ArteFixKind.RemoveItalic)); + } + } + } + + private void AnalyzeUnneededSpaces(List subtitle) + { + for (var i = 0; i < subtitle.Count; i++) + { + var p = subtitle[i]; + if (_fixes.Any(f => f.Index == i + 1 && f.Kind is ArteFixKind.Rebalance or ArteFixKind.Split)) + { + continue; + } + + var cleaned = TeletextText.TrimLines(p.Text); + if (cleaned != TeletextText.NormalizeNewLines(p.Text)) + { + Add(new ArteFix(GroupSpaces, true, i + 1, p.Text, cleaned, + "Leading or trailing spaces take teletext cells.", ArteFixKind.UnneededSpaces)); + } + } + } + + private void AnalyzeMinimumGaps(List subtitle) + { + var minimumGapFrames = _options.MinimumGapFrames; + var starts = subtitle.Select(p => RoundToFrame(p.StartMs)).ToArray(); + var ends = subtitle.Select(p => RoundToFrame(p.EndMs)).ToArray(); + for (var i = 1; i < subtitle.Count; i++) + { + var gapFrames = (int)Math.Round((starts[i] - ends[i - 1]) / FrameMs, MidpointRounding.AwayFromZero); + if (gapFrames >= minimumGapFrames) + { + continue; + } + + var missing = minimumGapFrames - gapFrames; + var split = _fixes.FirstOrDefault(f => f.Index == i && f.SplitParagraphs != null); + var previousStart = split?.SplitParagraphs![^1].StartMs ?? starts[i - 1]; + var previousText = split?.SplitParagraphs![^1].Text ?? subtitle[i - 1].Text; + var previousCapacity = Math.Max(0, (int)Math.Floor((ends[i - 1] - previousStart - AcceptedMinimumDurationMs(previousText)) / FrameMs + 0.0001)); + var currentCapacity = Math.Max(0, (int)Math.Floor((ends[i] - starts[i] - AcceptedMinimumDurationMs(subtitle[i].Text)) / FrameMs + 0.0001)); + var gapText = $"{gapFrames} frame{(Math.Abs(gapFrames) == 1 ? string.Empty : "s")}"; + if (previousCapacity + currentCapacity < missing) + { + Add(new ArteFix(GroupGaps, false, i, gapText, string.Empty, + $"No room for a {minimumGapFrames}-frame gap between subtitles {i} and {i + 1} without going under the minimum durations - edit manually.", + ArteFixKind.MinimumGap)); + continue; + } + + // Share the missing frames: half from the previous out time, half from the next in time. + var previousShift = Math.Min(previousCapacity, missing / 2); + var currentShift = Math.Min(currentCapacity, missing - previousShift); + var remaining = missing - previousShift - currentShift; + var addPrevious = Math.Min(previousCapacity - previousShift, remaining); + previousShift += addPrevious; + remaining -= addPrevious; + currentShift += Math.Min(currentCapacity - currentShift, remaining); + + var newEnd = ends[i - 1] - previousShift * FrameMs; + var newStart = starts[i] + currentShift * FrameMs; + ends[i - 1] = newEnd; + starts[i] = newStart; + var how = previousShift > 0 && currentShift > 0 + ? $"Out time of {i} {previousShift} frame(s) earlier, in time of {i + 1} {currentShift} frame(s) later." + : previousShift > 0 + ? $"Out time of {i} {previousShift} frame(s) earlier." + : $"In time of {i + 1} {currentShift} frame(s) later."; + Add(new ArteFix(GroupGaps, true, i, gapText, $"{minimumGapFrames} frames", how, ArteFixKind.MinimumGap) + { + ProposedEndMs = newEnd, + ProposedStartMs = newStart, + }); + } + } + + // ---- apply ------------------------------------------------------------------------------- + + public sealed record ApplyResult(List Paragraphs, string? Header, int Applied); + + public static ApplyResult Apply(IReadOnlyList source, string? header, IReadOnlyList fixes, ArteOptions options) + { + var subtitle = source.Select(p => p.Clone()).ToList(); + var applied = 0; + if (Math.Abs(options.SourceFrameRate - 25.0) > 0.001) + { + subtitle = ConvertFrameRate(subtitle, options.SourceFrameRate); + applied++; + } + + var selected = fixes.Where(f => f.CanBeFixed && f.Apply).ToList(); + + // Rows first (a rebalance keeps the corrected bottom edge), italic removal last. + foreach (var fix in selected + .Where(f => f.Kind is not (ArteFixKind.Split or ArteFixKind.CreateBlankSubtitle or ArteFixKind.ShiftStartTimeCode)) + .OrderBy(f => f.Kind == ArteFixKind.TeletextLinePosition ? 0 : f.Kind == ArteFixKind.RemoveItalic ? 2 : 1)) + { + if (fix.Kind == ArteFixKind.Header) + { + header = fix.ProposedHeader; + applied++; + continue; + } + + if (fix.Index <= 0 || fix.Index > subtitle.Count) + { + continue; + } + + var p = subtitle[fix.Index - 1]; + switch (fix.Kind) + { + case ArteFixKind.FrameAccurateTimeCode: + p.StartMs = fix.ProposedStartMs!.Value; + p.EndMs = fix.ProposedEndMs!.Value; + break; + case ArteFixKind.DisplayDuration: + p.EndMs = fix.ProposedEndMs!.Value; + break; + case ArteFixKind.MinimumGap: + p.EndMs = fix.ProposedEndMs!.Value; + if (fix.ProposedStartMs.HasValue && fix.Index < subtitle.Count) + { + subtitle[fix.Index].StartMs = fix.ProposedStartMs.Value; + } + + break; + case ArteFixKind.TeletextLinePosition: + p.MarginV = fix.After; + break; + case ArteFixKind.Rebalance: + p.MarginV = RowKeepingBottomEdge(p.MarginV, TeletextText.LineCount(p.Text), TeletextText.LineCount(fix.After), options.DoubleHeight) ?? p.MarginV; + p.Text = fix.After; + break; + case ArteFixKind.RemoveItalic: + p.Text = TeletextText.RemoveItalic(p.Text); + break; + case ArteFixKind.UnneededSpaces: + case ArteFixKind.TeletextColor: + p.Text = fix.After; + break; + default: + continue; + } + + applied++; + } + + // Splits after every index-based edit, from the end so earlier indices stay valid. + foreach (var fix in selected.Where(f => f.Kind == ArteFixKind.Split).OrderByDescending(f => f.Index)) + { + var original = subtitle[fix.Index - 1]; + var trim = selected.Any(f => f.Index == fix.Index && f.Kind == ArteFixKind.UnneededSpaces); + var replacements = fix.SplitParagraphs!.Select(part => + { + var result = original.Clone(); + result.Text = trim ? TeletextText.TrimLines(part.Text) : part.Text; + result.StartMs = part.StartMs; + result.EndMs = part.EndMs; + result.MarginV = RowKeepingBottomEdge(original.MarginV, TeletextText.LineCount(original.Text), TeletextText.LineCount(result.Text), options.DoubleHeight) ?? original.MarginV; + return result; + }).ToList(); + + // A gap fix may have moved the original start or end. + replacements[0].StartMs = original.StartMs; + replacements[^1].EndMs = original.EndMs; + subtitle.RemoveAt(fix.Index - 1); + subtitle.InsertRange(fix.Index - 1, replacements); + applied++; + } + + foreach (var fix in selected.Where(f => f.Kind == ArteFixKind.CreateBlankSubtitle)) + { + subtitle.Insert(0, fix.ProposedParagraph!.Clone()); + applied++; + } + + foreach (var fix in selected.Where(f => f.Kind == ArteFixKind.ShiftStartTimeCode)) + { + var offset = fix.ProposedStartMs!.Value; + foreach (var p in subtitle) + { + p.StartMs += offset; + p.EndMs += offset; + } + + if (GsiHeader.IsStlHeader(header)) + { + var gsi = GsiHeader.Parse(header!); + var frames = Math.Max(0L, (long)Math.Round(GetStartTimeCodeReference(subtitle) / FrameMs)); + gsi.TimeCodeStartOfProgramme = $"{frames / 90000:00}{frames / 1500 % 60:00}{frames / 25 % 60:00}{frames % 25:00}"; + header = gsi.ToString(); + } + + applied++; + } + + return new ApplyResult(subtitle, header, applied); + } + + /// + /// The row that keeps a bottom-anchored subtitle's bottom edge when its line count changes + /// (double height: two rows per line). Null when the row must stay as it is. + /// + public static string? RowKeepingBottomEdge(string? marginV, int oldLineCount, int newLineCount, bool doubleHeight) + { + if (oldLineCount == newLineCount || oldLineCount < 1 || newLineCount < 1 || + !int.TryParse(marginV, out var row) || row < 1 || row > BottomRow) + { + return null; + } + + var newRow = row + (oldLineCount - newLineCount) * (doubleHeight ? 2 : 1); + return newRow is >= 1 and <= BottomRow ? newRow.ToString() : null; + } +} diff --git a/se5/ArteCheck/ArteFix.cs b/se5/ArteCheck/ArteFix.cs new file mode 100644 index 0000000..9b019d0 --- /dev/null +++ b/se5/ArteCheck/ArteFix.cs @@ -0,0 +1,69 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using SubtitleEdit.Plugins.Shared; + +namespace SubtitleEdit.Plugins.ArteCheck; + +public enum ArteFixKind +{ + None, + Header, + ShiftStartTimeCode, + CreateBlankSubtitle, + FrameAccurateTimeCode, + DisplayDuration, + MinimumGap, + TeletextLinePosition, + Rebalance, + Split, + TeletextColor, + RemoveItalic, + UnneededSpaces, +} + +/// One finding: a correction that can be applied, or an alarm that needs a manual edit. +public sealed partial class ArteFix : ObservableObject +{ + [ObservableProperty] private bool _apply; + + public ArteFix(string group, bool canBeFixed, int index, string before, string after, string reason, + ArteFixKind kind = ArteFixKind.None, bool applyByDefault = true) + { + Group = group; + CanBeFixed = canBeFixed; + Index = index; + Before = before; + After = after; + Reason = reason; + Kind = kind; + _apply = canBeFixed && applyByDefault; + } + + public string Group { get; } + public bool CanBeFixed { get; } + public bool IsAlarm => !CanBeFixed; + + /// 1-based subtitle number; 0 for file-level findings (header, start time code, ...). + public int Index { get; } + + public string IndexDisplay => Index > 0 ? "#" + Index : "File"; + public string Before { get; } + public string After { get; } + public bool HasAfter => !string.IsNullOrEmpty(After); + public string Reason { get; } + public ArteFixKind Kind { get; } + public string TimeRange { get; set; } = string.Empty; + public bool HasTimeRange => !string.IsNullOrEmpty(TimeRange); + + /// Before/after are subtitle text (shown as a teletext preview) rather than time codes or header values. + public bool IsTextChange => Kind is ArteFixKind.Rebalance or ArteFixKind.TeletextColor or ArteFixKind.RemoveItalic or ArteFixKind.UnneededSpaces || + (IsAlarm && Group is ArteChecker.GroupLayout or ArteChecker.GroupColors); + + public bool IsPlainChange => !IsTextChange; + public string SeverityIcon => CanBeFixed ? "mdi-wrench-outline" : "mdi-alert-octagon-outline"; + + internal string? ProposedHeader { get; init; } + internal double? ProposedStartMs { get; init; } + internal double? ProposedEndMs { get; init; } + internal PluginParagraph? ProposedParagraph { get; init; } + internal List? SplitParagraphs { get; init; } +} diff --git a/se5/ArteCheck/ArteOptions.cs b/se5/ArteCheck/ArteOptions.cs new file mode 100644 index 0000000..99081f9 --- /dev/null +++ b/se5/ArteCheck/ArteOptions.cs @@ -0,0 +1,62 @@ +namespace SubtitleEdit.Plugins.ArteCheck; + +public enum ArteCheckType +{ + BlankSubtitle, + FrameAccurateTimeCodes, + DisplayDuration, + MaximumTwoLines, + TeletextLinePosition, + TeletextColors, + TeletextLineLength, + Italic, + UnneededSpaces, + MinimumGaps, +} + +public sealed class ArteOptions +{ + public const int PresetTeletextMaxCells = 37; + public const int PresetMinimumGapFrames = 5; + public const double PresetReadingDurationTolerancePercent = 15.0; + public const int PresetShortMinimumFrames = 18; + + public ArteProfile Profile { get; set; } = ArteProfile.All[0]; + + /// Frame rate the subtitle was timed for; ARTE always delivers 25 fps. + public double SourceFrameRate { get; set; } = 25.0; + + public bool ShiftToStartTimeCode { get; set; } + public double TargetStartMs { get; set; } + + /// Working limit of visible characters per row; the encoded row is always 40 cells. + public int TeletextMaxCells { get; set; } = PresetTeletextMaxCells; + + public int MinimumGapFrames { get; set; } = PresetMinimumGapFrames; + public double ReadingDurationTolerancePercent { get; set; } = PresetReadingDurationTolerancePercent; + public bool AcceptShortDurations { get; set; } + public int ShortMinimumFrames { get; set; } = PresetShortMinimumFrames; + + public double MinimumDurationMs { get; set; } = 1000; + public double MaximumDurationMs { get; set; } = 8000; + public double MaximumCharactersPerSecond { get; set; } = 25; + public bool DoubleHeight { get; set; } = true; + + public HashSet EnabledChecks { get; set; } = Enum.GetValues().ToHashSet(); + + public bool IsPreset => + TeletextMaxCells == PresetTeletextMaxCells && + MinimumGapFrames == PresetMinimumGapFrames && + Math.Abs(ReadingDurationTolerancePercent - PresetReadingDurationTolerancePercent) < 0.001 && + !AcceptShortDurations && + ShortMinimumFrames == PresetShortMinimumFrames; + + public void ApplyPreset() + { + TeletextMaxCells = PresetTeletextMaxCells; + MinimumGapFrames = PresetMinimumGapFrames; + ReadingDurationTolerancePercent = PresetReadingDurationTolerancePercent; + AcceptShortDurations = false; + ShortMinimumFrames = PresetShortMinimumFrames; + } +} diff --git a/se5/ArteCheck/ArteProfile.cs b/se5/ArteCheck/ArteProfile.cs new file mode 100644 index 0000000..88a8b36 --- /dev/null +++ b/se5/ArteCheck/ArteProfile.cs @@ -0,0 +1,18 @@ +namespace SubtitleEdit.Plugins.ArteCheck; + +/// An ARTE delivery profile: the EBU language code it is delivered with, and whether it is SDH. +public sealed record ArteProfile(string Code, string Name, string Description, string LanguageCode, bool IsSdh) +{ + public static readonly IReadOnlyList All = new[] + { + new ArteProfile("STA", "ARTE STA", "German subtitles", "08", false), + new ArteProfile("STF", "ARTE STF", "French subtitles", "0F", false), + new ArteProfile("HG-DEU", "ARTE HG DEU", "German SDH (VA-MAL)", "2D", true), + new ArteProfile("HG-FRA", "ARTE HG FRA", "French SDH (VF-MAL)", "2F", true), + }; + + public static ArteProfile FromLanguageCode(string? languageCode) => + All.FirstOrDefault(p => p.LanguageCode == languageCode) ?? All[0]; + + public override string ToString() => $"{Name} - {Description}"; +} diff --git a/se5/ArteCheck/FixGroup.cs b/se5/ArteCheck/FixGroup.cs new file mode 100644 index 0000000..0aad0e1 --- /dev/null +++ b/se5/ArteCheck/FixGroup.cs @@ -0,0 +1,91 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace SubtitleEdit.Plugins.ArteCheck; + +public sealed partial class FixGroup : ObservableObject +{ + [ObservableProperty] private bool _isExpanded; + + public FixGroup(string name, IReadOnlyList items, bool isExpanded) + { + Name = name; + Items = items; + _isExpanded = isExpanded; + Icon = IconFor(name); + } + + public string Name { get; } + public string Icon { get; } + public IReadOnlyList Items { get; } + public int FixCount => Items.Count(i => i.CanBeFixed); + public int AlarmCount => Items.Count(i => i.IsAlarm); + public bool HasFixes => FixCount > 0; + public bool HasAlarms => AlarmCount > 0; + + [RelayCommand] + private void SelectAll() + { + foreach (var item in Items.Where(i => i.CanBeFixed)) + { + item.Apply = true; + } + } + + [RelayCommand] + private void SelectNone() + { + foreach (var item in Items) + { + item.Apply = false; + } + } + + public static string IconFor(string group) => group switch + { + ArteChecker.GroupHeader => "mdi-file-cog-outline", + ArteChecker.GroupStartTimeCode => "mdi-clock-start", + ArteChecker.GroupBlank => "mdi-checkbox-blank-outline", + ArteChecker.GroupFrames => "mdi-filmstrip", + ArteChecker.GroupDuration => "mdi-timer-outline", + ArteChecker.GroupLayout => "mdi-call-split", + ArteChecker.GroupPosition => "mdi-format-vertical-align-bottom", + ArteChecker.GroupColors => "mdi-palette-outline", + ArteChecker.GroupItalic => "mdi-format-italic", + ArteChecker.GroupSpaces => "mdi-keyboard-space", + ArteChecker.GroupGaps => "mdi-arrow-expand-horizontal", + _ => "mdi-alert-circle-outline", + }; +} + +public sealed partial class CheckItem : ObservableObject +{ + [ObservableProperty] private bool _isEnabled = true; + + public CheckItem(ArteCheckType type, string name, string description, string icon) + { + Type = type; + Name = name; + Description = description; + Icon = icon; + } + + public ArteCheckType Type { get; } + public string Name { get; } + public string Description { get; } + public string Icon { get; } + + public static IReadOnlyList CreateAll() => new[] + { + new CheckItem(ArteCheckType.BlankSubtitle, "Blank control subtitle", "5-frame blank subtitle at the programme start", FixGroup.IconFor(ArteChecker.GroupBlank)), + new CheckItem(ArteCheckType.FrameAccurateTimeCodes, "Frame-accurate time codes", "Whole 25 fps frames", FixGroup.IconFor(ArteChecker.GroupFrames)), + new CheckItem(ArteCheckType.DisplayDuration, "Display duration", "Reading time, minimum and maximum", FixGroup.IconFor(ArteChecker.GroupDuration)), + new CheckItem(ArteCheckType.MaximumTwoLines, "Maximum two lines", "Rebalance or split longer subtitles", "mdi-format-line-spacing"), + new CheckItem(ArteCheckType.TeletextLinePosition, "Teletext row", "Rows 22 / 20 with double height", FixGroup.IconFor(ArteChecker.GroupPosition)), + new CheckItem(ArteCheckType.TeletextColors, "Teletext colors", "Yellow or uncolored, SDH: 8 colors", FixGroup.IconFor(ArteChecker.GroupColors)), + new CheckItem(ArteCheckType.TeletextLineLength, "Characters per row", "Visible characters + color cells", "mdi-ruler"), + new CheckItem(ArteCheckType.Italic, "No italics", "Teletext cannot show italics", FixGroup.IconFor(ArteChecker.GroupItalic)), + new CheckItem(ArteCheckType.UnneededSpaces, "Unneeded spaces", "Leading and trailing spaces", FixGroup.IconFor(ArteChecker.GroupSpaces)), + new CheckItem(ArteCheckType.MinimumGaps, "Minimum gaps", "Frames between subtitles", FixGroup.IconFor(ArteChecker.GroupGaps)), + }; +} diff --git a/se5/ArteCheck/GsiHeader.cs b/se5/ArteCheck/GsiHeader.cs new file mode 100644 index 0000000..8c48bda --- /dev/null +++ b/se5/ArteCheck/GsiHeader.cs @@ -0,0 +1,79 @@ +using System.Globalization; + +namespace SubtitleEdit.Plugins.ArteCheck; + +/// +/// The EBU STL GSI block as Subtitle Edit holds it: a 1024-character string in a single-byte +/// code page, so every field sits at its fixed byte offset (EBU Tech 3264). +/// +public sealed class GsiHeader +{ + public const int Length = 1024; + + private char[] _chars; + + private GsiHeader(string text) => _chars = text.ToCharArray(); + + public static bool IsStlHeader(string? header) + { + if (header is null || header.Length != Length) + { + return false; + } + + return int.TryParse(header.AsSpan(0, 3), NumberStyles.None, CultureInfo.InvariantCulture, out _) && + header.AsSpan(3, 3).SequenceEqual("STL"); + } + + public static GsiHeader Parse(string header) => new(header); + + /// Subtitle Edit's default header (the one written for a file without a GSI block). + public static GsiHeader CreateDefault() + { + var text = + "437" + "STL25.01" + "0" + "00" + "0A" + + "No Title".PadRight(32) + new string(' ', 32) + new string(' ', 32) + new string(' ', 32) + + new string(' ', 32) + new string(' ', 32) + "0".PadRight(16) + + "101021" + "101021" + "01" + "00725" + "00725" + "001" + "40" + "23" + "1" + + "00000000" + "00000001" + "1" + "1" + "USA" + + new string(' ', 32) + new string(' ', 32) + new string(' ', 32) + + new string(' ', 75) + new string(' ', 576); + return new GsiHeader(text); + } + + public string CodePage { get => Get(0, 3); set => Set(0, 3, value); } + public string DiskFormatCode { get => Get(3, 8); set => Set(3, 8, value); } + public string DisplayStandardCode { get => Get(11, 1); set => Set(11, 1, value); } + public string CharacterCodeTable { get => Get(12, 2); set => Set(12, 2, value); } + public string LanguageCode { get => Get(14, 2); set => Set(14, 2, value); } + public string MaxCharactersPerRow { get => Get(251, 2); set => Set(251, 2, value); } + public string MaxRows { get => Get(253, 2); set => Set(253, 2, value); } + + /// TCP - time code start of programme, HHMMSSFF. + public string TimeCodeStartOfProgramme { get => Get(256, 8); set => Set(256, 8, value); } + + /// The fields that differ from , one per line, e.g. "Language: 08". + public string DescribeDifferences(GsiHeader other) + { + var mine = Describe().Split('\n'); + var theirs = other.Describe().Split('\n'); + return string.Join("\n", mine.Where((line, i) => line != theirs[i])); + } + + public string Describe() => + $"Code page: {CodePage}\nDisk format: {DiskFormatCode}\nDisplay standard: {DisplayStandardCode}\n" + + $"Character table: {CharacterCodeTable}\nLanguage: {LanguageCode}\n" + + $"Characters per row: {MaxCharactersPerRow}\nRows: {MaxRows}"; + + public GsiHeader Clone() => new(ToString()); + + public override string ToString() => new(_chars); + + private string Get(int offset, int length) => new(_chars, offset, length); + + private void Set(int offset, int length, string value) + { + var padded = value.Length >= length ? value[..length] : value.PadRight(length); + padded.CopyTo(0, _chars, offset, length); + } +} diff --git a/se5/ArteCheck/MainViewModel.cs b/se5/ArteCheck/MainViewModel.cs new file mode 100644 index 0000000..0c3fe59 --- /dev/null +++ b/se5/ArteCheck/MainViewModel.cs @@ -0,0 +1,502 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SubtitleEdit.Plugins.Shared; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace SubtitleEdit.Plugins.ArteCheck; + +public enum FindingFilter +{ + All, + Fixes, + Alarms, +} + +public sealed partial class MainViewModel : ObservableObject +{ + private const int SettingsVersion = 1; + public const string EbuFormatName = "EBU STL"; + + private readonly PluginRequest _request; + private readonly bool _isEbu; + private readonly bool _legacyRequest; + private readonly long _videoOffsetMs; + private readonly Stack<(List Paragraphs, string? Header, double FrameRate, int Applied)> _undo = new(); + private readonly Dictionary _expandedGroups = new(); + private List _paragraphs; + private string? _header; + private List _fixes = new(); + private bool _suspendAnalysis; + + [ObservableProperty] private ArteProfile _selectedProfile; + [ObservableProperty] private double _selectedFrameRate = 25.0; + [ObservableProperty] private bool _shiftToStartTimeCode; + [ObservableProperty] private string _targetStartTimeCode = "10:00:00:00"; + [ObservableProperty] private bool _isTargetStartTimeCodeValid = true; + [ObservableProperty] private decimal? _teletextMaxCells = ArteOptions.PresetTeletextMaxCells; + [ObservableProperty] private decimal? _minimumGapFrames = ArteOptions.PresetMinimumGapFrames; + [ObservableProperty] private decimal? _readingTolerancePercent = (decimal)ArteOptions.PresetReadingDurationTolerancePercent; + [ObservableProperty] private bool _acceptShortDurations; + [ObservableProperty] private decimal? _shortMinimumFrames = ArteOptions.PresetShortMinimumFrames; + [ObservableProperty] private FindingFilter _filter = FindingFilter.All; + [ObservableProperty] private int _fixCount; + [ObservableProperty] private int _alarmCount; + [ObservableProperty] private int _selectedCount; + [ObservableProperty] private int _appliedCount; + [ObservableProperty] private string _statusText = string.Empty; + [ObservableProperty] private bool _isPreset = true; + [ObservableProperty] private bool _canUndo; + + public MainViewModel(PluginRequest request) + { + _request = request; + _isEbu = string.Equals(request.Subtitle.Format, EbuFormatName, StringComparison.OrdinalIgnoreCase); + _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; + _legacyRequest = request.Subtitle.Paragraphs == null; + _paragraphs = _legacyRequest + ? SubRipParser.Parse(request.Subtitle.SubRip) + .Select(b => new PluginParagraph { StartMs = b.StartMs, EndMs = b.EndMs, Text = b.Text }).ToList() + : request.Subtitle.Paragraphs!.Select(p => p.Clone()).ToList(); + + // Rows hold video-relative times; ARTE time codes (10:00:00:00, ...) are what the file shows. + foreach (var p in _paragraphs) + { + p.StartMs += _videoOffsetMs; + p.EndMs += _videoOffsetMs; + } + + _header = request.Subtitle.Header; + var headerLanguage = GsiHeader.IsStlHeader(_header) ? GsiHeader.Parse(_header!).LanguageCode : null; + _selectedProfile = ArteProfile.FromLanguageCode(headerLanguage); + if (_paragraphs.Count > 0) + { + _targetStartTimeCode = ArteChecker.FormatTimeCode(ArteChecker.GetStartTimeCodeReference(_paragraphs)); + } + + Checks = CheckItem.CreateAll(); + foreach (var check in Checks) + { + check.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(CheckItem.IsEnabled)) + { + Analyze(); + } + }; + } + + _suspendAnalysis = true; + LoadSettings(); + _suspendAnalysis = false; + Analyze(); + } + + public IReadOnlyList Profiles => ArteProfile.All; + public IReadOnlyList FrameRates { get; } = new[] { 23.976, 24.0, 25.0, 29.97, 30.0 }; + public IReadOnlyList Checks { get; } + public ObservableCollection Groups { get; } = new(); + + public string SubtitleInfo + { + get + { + 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 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 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; } } + public string PresetLabel => IsPreset ? "ARTE preset" : "Custom"; + public bool HasSelection => SelectedCount > 0; + + partial void OnSelectedProfileChanged(ArteProfile value) => Analyze(); + partial void OnSelectedFrameRateChanged(double value) => Analyze(); + partial void OnShiftToStartTimeCodeChanged(bool value) => Analyze(); + partial void OnTeletextMaxCellsChanged(decimal? value) => Analyze(); + partial void OnMinimumGapFramesChanged(decimal? value) => Analyze(); + partial void OnReadingTolerancePercentChanged(decimal? value) => Analyze(); + partial void OnAcceptShortDurationsChanged(bool value) => Analyze(); + partial void OnShortMinimumFramesChanged(decimal? value) => Analyze(); + + partial void OnTargetStartTimeCodeChanged(string value) + { + IsTargetStartTimeCodeValid = ArteChecker.TryParseTimeCode(value, out _); + if (ShiftToStartTimeCode) + { + Analyze(); + } + } + + partial void OnFilterChanged(FindingFilter value) + { + OnPropertyChanged(nameof(ShowAll)); + OnPropertyChanged(nameof(ShowFixes)); + OnPropertyChanged(nameof(ShowAlarms)); + RebuildGroups(); + } + + partial void OnIsPresetChanged(bool value) => OnPropertyChanged(nameof(PresetLabel)); + partial void OnSelectedCountChanged(int value) => OnPropertyChanged(nameof(HasSelection)); + partial void OnAppliedCountChanged(int value) => OnPropertyChanged(nameof(HasApplied)); + + public bool HasApplied => AppliedCount > 0; + + private ArteOptions BuildOptions() + { + var rules = _request.Rules; + var options = new ArteOptions + { + Profile = SelectedProfile, + SourceFrameRate = SelectedFrameRate, + ShiftToStartTimeCode = ShiftToStartTimeCode && IsTargetStartTimeCodeValid, + TeletextMaxCells = (int)(TeletextMaxCells ?? ArteOptions.PresetTeletextMaxCells), + MinimumGapFrames = (int)(MinimumGapFrames ?? ArteOptions.PresetMinimumGapFrames), + ReadingDurationTolerancePercent = (double)(ReadingTolerancePercent ?? 0), + AcceptShortDurations = AcceptShortDurations, + ShortMinimumFrames = (int)(ShortMinimumFrames ?? ArteOptions.PresetShortMinimumFrames), + EnabledChecks = Checks.Where(c => c.IsEnabled).Select(c => c.Type).ToHashSet(), + }; + + if (ArteChecker.TryParseTimeCode(TargetStartTimeCode, out var targetMs)) + { + options.TargetStartMs = targetMs; + } + + if (rules != null) + { + options.MinimumDurationMs = rules.SubtitleMinimumDisplayMilliseconds; + options.MaximumDurationMs = rules.SubtitleMaximumDisplayMilliseconds; + options.MaximumCharactersPerSecond = rules.SubtitleMaximumCharactersPerSeconds; + options.DoubleHeight = rules.EbuStlTeletextUseDoubleHeight; + } + + if (!_isEbu) + { + options.EnabledChecks.Remove(ArteCheckType.TeletextLinePosition); + } + + return options; + } + + private void Analyze() + { + if (_suspendAnalysis) + { + return; + } + + var options = BuildOptions(); + IsPreset = options.IsPreset; + _fixes = ArteChecker.Analyze(_paragraphs, _header, options); + if (!_isEbu) + { + _fixes.RemoveAll(f => f.Kind == ArteFixKind.Header); + } + + foreach (var fix in _fixes) + { + fix.PropertyChanged += OnFixChanged; + } + + FixCount = _fixes.Count(f => f.CanBeFixed); + AlarmCount = _fixes.Count(f => f.IsAlarm); + OnPropertyChanged(nameof(HasFindings)); + OnPropertyChanged(nameof(IsClean)); + UpdateSelectedCount(); + RebuildGroups(); + } + + private void RebuildGroups() + { + foreach (var group in Groups) + { + _expandedGroups[group.Name] = group.IsExpanded; + } + + Groups.Clear(); + var visible = _fixes.Where(f => Filter switch + { + FindingFilter.Fixes => f.CanBeFixed, + FindingFilter.Alarms => f.IsAlarm, + _ => true, + }); + + var first = true; + foreach (var group in visible.GroupBy(f => f.Group)) + { + var expanded = _expandedGroups.TryGetValue(group.Key, out var wasExpanded) ? wasExpanded : first; + Groups.Add(new FixGroup(group.Key, group.ToList(), expanded)); + first = false; + } + } + + private void OnFixChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ArteFix.Apply)) + { + UpdateSelectedCount(); + } + } + + private void UpdateSelectedCount() + { + SelectedCount = _fixes.Count(f => f.CanBeFixed && f.Apply); + var frameRateNote = Math.Abs(SelectedFrameRate - 25.0) > 0.001 ? $" + {SelectedFrameRate:0.###} → 25 fps conversion" : string.Empty; + StatusText = HasFindings + ? $"{SelectedCount} of {FixCount} correction(s) selected{frameRateNote}" + : $"No issues found{frameRateNote}"; + } + + [RelayCommand] + private void ApplyPreset() + { + _suspendAnalysis = true; + TeletextMaxCells = ArteOptions.PresetTeletextMaxCells; + MinimumGapFrames = ArteOptions.PresetMinimumGapFrames; + ReadingTolerancePercent = (decimal)ArteOptions.PresetReadingDurationTolerancePercent; + AcceptShortDurations = false; + ShortMinimumFrames = ArteOptions.PresetShortMinimumFrames; + _suspendAnalysis = false; + Analyze(); + } + + [RelayCommand] + private void SelectAll() + { + foreach (var fix in _fixes.Where(f => f.CanBeFixed)) + { + fix.Apply = true; + } + } + + [RelayCommand] + private void SelectNone() + { + foreach (var fix in _fixes) + { + fix.Apply = false; + } + } + + [RelayCommand] + private void ExpandAll() + { + foreach (var group in Groups) + { + group.IsExpanded = true; + } + } + + [RelayCommand] + private void CollapseAll() + { + foreach (var group in Groups) + { + group.IsExpanded = false; + } + } + + /// Applies the selected corrections to the working copy and checks again (fixes can enable further fixes). + [RelayCommand] + private void ApplySelected() + { + if (!ApplyPending()) + { + return; + } + + Analyze(); + } + + private bool ApplyPending() + { + var options = BuildOptions(); + var hasConversion = Math.Abs(options.SourceFrameRate - 25.0) > 0.001; + if (SelectedCount == 0 && !hasConversion) + { + return false; + } + + var result = ArteChecker.Apply(_paragraphs, _header, _fixes, options); + _undo.Push((_paragraphs, _header, SelectedFrameRate, AppliedCount)); + CanUndo = true; + _paragraphs = result.Paragraphs; + _header = result.Header; + AppliedCount += result.Applied; + + // The working copy is on the 25 fps timeline now. + _suspendAnalysis = true; + SelectedFrameRate = 25.0; + _suspendAnalysis = false; + OnPropertyChanged(nameof(SubtitleInfo)); + return true; + } + + [RelayCommand] + private void Undo() + { + if (_undo.Count == 0) + { + return; + } + + var (paragraphs, header, frameRate, applied) = _undo.Pop(); + _paragraphs = paragraphs; + _header = header; + AppliedCount = applied; + CanUndo = _undo.Count > 0; + _suspendAnalysis = true; + SelectedFrameRate = frameRate; + _suspendAnalysis = false; + OnPropertyChanged(nameof(SubtitleInfo)); + Analyze(); + } + + public string BuildReport() + { + var sb = new StringBuilder(); + sb.AppendLine($"ARTE check - {SelectedProfile}"); + sb.AppendLine(SubtitleInfo); + sb.AppendLine($"{FixCount} correction(s), {AlarmCount} alarm(s)"); + foreach (var group in _fixes.GroupBy(f => f.Group)) + { + sb.AppendLine(); + sb.AppendLine($"{group.Key} ({group.Count()})"); + sb.AppendLine(new string('-', group.Key.Length + 5)); + foreach (var fix in group) + { + var location = fix.IndexDisplay; + if (fix.HasTimeRange) + { + location += $" {fix.TimeRange}"; + } + + sb.AppendLine($"{(fix.CanBeFixed ? "FIX " : "ALARM")} {location}"); + sb.AppendLine($" {fix.Reason}"); + } + } + + return sb.ToString(); + } + + /// The response for OK: applies what is still selected, then hands the working copy back. + public PluginResponse BuildResponse() + { + ApplyPending(); + if (AppliedCount == 0) + { + return new PluginResponse { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + } + + var paragraphs = _paragraphs.Select(p => + { + var copy = p.Clone(); + copy.StartMs -= _videoOffsetMs; + copy.EndMs -= _videoOffsetMs; + return copy; + }).ToList(); + + var subtitle = _legacyRequest + ? new PluginSubtitle + { + Format = "SubRip", + Native = SubRipParser.Serialize(paragraphs.Select(p => new SrtBlock + { + StartMs = (long)Math.Round(p.StartMs), + EndMs = (long)Math.Round(p.EndMs), + Text = p.Text, + }).ToList()), + } + : new PluginSubtitle + { + Format = _request.Subtitle.Format, + Header = _isEbu ? _header : null, + Paragraphs = paragraphs, + }; + + return new PluginResponse + { + Status = PluginStatus.Ok, + Message = $"ARTE check: {AppliedCount} correction(s) applied.", + UndoDescription = "ARTE check", + Subtitle = subtitle, + Settings = BuildSettings(), + SettingsVersion = SettingsVersion, + }; + } + + public PluginResponse BuildCancelResponse() => + new() { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + + private JsonElement BuildSettings() + { + var payload = new Dictionary + { + ["teletextMaxCells"] = (int)(TeletextMaxCells ?? ArteOptions.PresetTeletextMaxCells), + ["minimumGapFrames"] = (int)(MinimumGapFrames ?? ArteOptions.PresetMinimumGapFrames), + ["readingTolerancePercent"] = (double)(ReadingTolerancePercent ?? 0), + ["acceptShortDurations"] = AcceptShortDurations, + ["shortMinimumFrames"] = (int)(ShortMinimumFrames ?? ArteOptions.PresetShortMinimumFrames), + ["disabledChecks"] = Checks.Where(c => !c.IsEnabled).Select(c => c.Type.ToString()).ToArray(), + }; + return JsonSerializer.SerializeToElement(payload); + } + + private void LoadSettings() + { + if (_request.SettingsVersion != SettingsVersion || _request.Settings is not { ValueKind: JsonValueKind.Object } settings) + { + return; + } + + if (settings.TryGetProperty("teletextMaxCells", out var cells) && cells.TryGetInt32(out var c) && c is >= 10 and <= 40) + { + TeletextMaxCells = c; + } + + if (settings.TryGetProperty("minimumGapFrames", out var gap) && gap.TryGetInt32(out var g) && g is >= 0 and <= 50) + { + MinimumGapFrames = g; + } + + if (settings.TryGetProperty("readingTolerancePercent", out var tolerance) && tolerance.TryGetDouble(out var t) && t is >= 0 and <= 100) + { + ReadingTolerancePercent = (decimal)t; + } + + if (settings.TryGetProperty("acceptShortDurations", out var shortDurations) && shortDurations.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + AcceptShortDurations = shortDurations.GetBoolean(); + } + + if (settings.TryGetProperty("shortMinimumFrames", out var shortFrames) && shortFrames.TryGetInt32(out var s) && s is >= 1 and <= 250) + { + ShortMinimumFrames = s; + } + + if (settings.TryGetProperty("disabledChecks", out var disabled) && disabled.ValueKind == JsonValueKind.Array) + { + var names = disabled.EnumerateArray().Select(e => e.GetString()).ToHashSet(); + foreach (var check in Checks) + { + check.IsEnabled = !names.Contains(check.Type.ToString()); + } + } + } + + public static string FormatFrameRate(double fps) => fps.ToString("0.###", CultureInfo.InvariantCulture) + " fps"; +} diff --git a/se5/ArteCheck/MainWindow.axaml b/se5/ArteCheck/MainWindow.axaml new file mode 100644 index 0000000..4050431 --- /dev/null +++ b/se5/ArteCheck/MainWindow.axaml @@ -0,0 +1,446 @@ + + + + + + + + + + + + Cascadia Mono, Consolas, Menlo, DejaVu Sans Mono, monospace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +