From 51d1db4641bc309beb7e0ba1c4980b89b37a8ecf Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 7 Oct 2026 21:28:39 +0200 Subject: [PATCH] Zwei Uhren, und niemand hatte sie verglichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Untertitel zeigten auf keiner Spur Text. Der Grund: die Zeitstempel einer WebVTT-Spur zählen ab Dateianfang, der Strom zählt ab null. Wer bei Minute 30 einstieg, bekam jeden Satz dreissig Minuten in die Zukunft geschoben — und weil ein Strom nur bis zum Filmende reicht, erschien überhaupt keiner mehr. Gemessen im Spieler, vorher: „Fortsetzen ab 8:37" geklickt, deutsche Spur gewählt, Standanzeige 8:38 — angezeigt wurde der Satz von Sekunde 0. Versatz 515 Sekunden, also genau der Einstiegspunkt. Nachher, derselbe Weg: Standanzeige 9:02, angezeigt „Deutsch bei 540 s". Verschoben wird auf dem Server, nicht im Browser: dort hiesse es, die Spur selbst zu zerlegen und als VTTCue neu aufzubauen — ein eigener Parser für ein Format, das der Browser schon kann. Hier ist es eine Zeilenersetzung, und sie lässt sich prüfen. Was vor dem Einstieg endet, fällt samt Text weg; was darüber hinwegreicht, fängt bei null an. Zwei Kleinigkeiten lagen daneben und sind mit erledigt: - `srclang` bekam „ger".slice(0, 2) und damit „ge" — kein gültiges Kürzel. Jetzt bleibt das Feld leer, wenn das Kürzel nicht zweistellig ist. - Scheiterte das Laden einer Spur, geschah nichts Sichtbares. Jetzt sagt es der Spieler. Co-Authored-By: Claude Opus 5 --- .../Flaro.Api/Endpoints/StreamEndpoints.cs | 16 ++- server/src/Flaro.Media/Untertitelversatz.cs | 114 ++++++++++++++++ .../UntertitelversatzTests.cs | 122 ++++++++++++++++++ web/app/src/api.ts | 12 +- web/app/src/spieler.ts | 20 ++- 5 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 server/src/Flaro.Media/Untertitelversatz.cs create mode 100644 server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs index 8bb2e60..605f860 100644 --- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs @@ -212,16 +212,24 @@ public static class StreamEndpoints // So lassen sie sich während der Wiedergabe umschalten, ohne dass // irgendetwas neu gerechnet wird. In einer fMP4-Segmentierung hätten // sie ohnehin keinen Platz. + // **`ab` ist nicht geschmückt, sondern nötig.** Die Zeitstempel einer + // Untertitelspur zählen ab Dateianfang, der Strom zählt ab null — + // steigt jemand bei Minute 30 ein, liegt jeder Satz dreissig Minuten + // in dessen Zukunft, und zwar für den ganzen Rest des Films. Gemessen + // im Spieler: bei „Fortsetzen ab 8:37" stand der Satz von Sekunde 0 + // auf dem Bild. app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async ( - Guid id, int spur, FlaroContext db, UntertitelLeser leser, CancellationToken ct) => + Guid id, int spur, FlaroContext db, UntertitelLeser leser, + CancellationToken ct, double ab = 0) => { var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct); if (datei is null || !File.Exists(datei.Path)) return Results.NotFound(); var vtt = await leser.AlsWebVttAsync(datei.Path, spur, ct); - return vtt is null - ? Results.NotFound() - : Results.Text(vtt, "text/vtt", Encoding.UTF8); + if (vtt is null) return Results.NotFound(); + + return Results.Text( + Untertitelversatz.Verschieben(vtt, Math.Max(0, ab)), "text/vtt", Encoding.UTF8); }).RequireAuthorization(); } diff --git a/server/src/Flaro.Media/Untertitelversatz.cs b/server/src/Flaro.Media/Untertitelversatz.cs new file mode 100644 index 0000000..919aa60 --- /dev/null +++ b/server/src/Flaro.Media/Untertitelversatz.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace Flaro.Media; + +/// Rückt WebVTT auf die Zeitachse eines Stroms, der mittendrin anfängt. +/// +/// +/// +/// Zwei Uhren, und niemand hat sie verglichen. Die Zeitstempel einer +/// Untertitelspur zählen ab Dateianfang — bei Minute 30 steht dort +/// 00:30:00.000. Der Strom zählt dagegen ab null: steigt jemand bei +/// Minute 30 ein, ist dessen Sekunde 0 die Filmminute 30. Der Browser +/// vergleicht die Einblendzeiten aber ausschliesslich mit +/// video.currentTime, und der kennt nur die Stromuhr. +/// +/// +/// Gemessen im Spieler: „Fortsetzen ab 8:37" angeklickt, deutsche Spur +/// gewählt — angezeigt wurde der Satz von Sekunde 0, bei einer Standanzeige +/// von 8:38. Versatz 515 Sekunden, also genau der Einstiegspunkt. Und das +/// ist der freundliche Fall: liegen die ersten Sätze eines Films hinter dem +/// Vorspann, erscheint gar nichts mehr, weil jeder Einblendzeitpunkt hinter +/// dem Ende des Stroms liegt. Genau so wurde es gemeldet — „auf keiner Spur +/// kam Text". +/// +/// +/// Hier verschoben und nicht im Browser. Im Browser hiesse es, die +/// Spur selbst zu zerlegen und als VTTCue neu aufzubauen — ein eigener +/// Parser für ein Format, das der Browser schon kann. Hier ist es eine +/// Zeilenersetzung, und sie lässt sich prüfen. +/// +/// +public static partial class Untertitelversatz +{ + /// Verschiebt alle Zeitstempel um Sekunden nach vorn. + /// + /// + /// Was ganz vor dem Einstieg liegt, fällt weg; was darüber hinwegreicht, + /// fängt bei null an. Ein Satz, der schon lief, als man einstieg, soll + /// stehen bleiben — abgeschnitten ist er immer noch lesbar. + /// + public static string Verschieben(string vtt, double ab) + { + if (ab <= 0 || string.IsNullOrEmpty(vtt)) return vtt; + + var zeilen = vtt.Split('\n'); + var heraus = new StringBuilder(vtt.Length); + + // Ein Block ist das, was zwischen zwei Leerzeilen steht. Fällt seine + // Zeitzeile weg, fällt der ganze Block weg — sonst bliebe sein Text + // als Absatz ohne Zeitangabe stehen und der Browser verschluckt sich. + var block = new List(); + var behalten = true; + + foreach (var roh in zeilen) + { + var zeile = roh.TrimEnd('\r'); + + if (zeile.Trim().Length == 0) + { + if (behalten) foreach (var b in block) heraus.Append(b).Append('\n'); + heraus.Append('\n'); + block.Clear(); + behalten = true; + continue; + } + + if (Zeitzeile().Match(zeile) is { Success: true } treffer) + { + var von = Lesen(treffer.Groups["von"].Value) - ab; + var bis = Lesen(treffer.Groups["bis"].Value) - ab; + + // Ganz vorbei, bevor es losging. + if (bis <= 0) { behalten = false; block.Clear(); continue; } + + block.Add($"{Schreiben(Math.Max(0, von))} --> {Schreiben(bis)}" + + treffer.Groups["rest"].Value); + continue; + } + + block.Add(zeile); + } + + if (behalten) foreach (var b in block) heraus.Append(b).Append('\n'); + return heraus.ToString(); + } + + /// „00:01:02.500" oder „01:02.500" — beide Formen sind erlaubt. + private static double Lesen(string wert) + { + var teile = wert.Replace(',', '.').Split(':'); + double sekunden = 0; + foreach (var teil in teile) + { + sekunden = sekunden * 60 + + double.Parse(teil, NumberStyles.Float, CultureInfo.InvariantCulture); + } + + return sekunden; + } + + /// Immer mit Stunden — die kurze Form ist erlaubt, die lange nie falsch. + private static string Schreiben(double sekunden) + { + var t = TimeSpan.FromSeconds(sekunden); + return string.Create(CultureInfo.InvariantCulture, + $"{(int)t.TotalHours:00}:{t.Minutes:00}:{t.Seconds:00}.{t.Milliseconds:000}"); + } + + [GeneratedRegex( + @"^(?\d{1,2}:\d{2}(?::\d{2})?[.,]\d{1,3})\s*-->\s*(?\d{1,2}:\d{2}(?::\d{2})?[.,]\d{1,3})(?.*)$")] + private static partial Regex Zeitzeile(); +} diff --git a/server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs b/server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs new file mode 100644 index 0000000..cdd16e9 --- /dev/null +++ b/server/tests/Flaro.Media.Tests/UntertitelversatzTests.cs @@ -0,0 +1,122 @@ +namespace Flaro.Media.Tests; + +/// Untertitel auf der Zeitachse eines Stroms, der mittendrin anfängt. +/// +/// +/// Der Fehler, den diese Prüfung festhält. Die Zeitstempel zählen ab +/// Dateianfang, der Strom zählt ab null. Beim Einstieg bei Minute 30 lag +/// damit jeder Satz dreissig Minuten in der Zukunft — und weil ein Strom nur +/// bis zum Filmende reicht, erschien überhaupt keiner mehr. Gemeldet wurde +/// das als „auf keiner Spur kommt Text". +/// +public class UntertitelversatzTests +{ + private const string Probe = """ + WEBVTT + + 1 + 00:00:01.000 --> 00:00:04.000 + Ganz am Anfang + + 2 + 00:00:09.000 --> 00:00:12.000 + Über den Einstieg hinweg + + 3 + 00:00:20.000 --> 00:00:24.000 + Weit dahinter + + """; + + [Test] + public void Ohne_Versatz_bleibt_alles_wie_es_war() => + Assert.That(Untertitelversatz.Verschieben(Probe, 0), Is.EqualTo(Probe)); + + [Test] + public void Die_Zeiten_rutschen_um_den_Einstieg_nach_vorn() + { + var heraus = Untertitelversatz.Verschieben(Probe, 10); + + Assert.That(heraus, Does.Contain("00:00:10.000 --> 00:00:14.000"), + "Sekunde 20 im Film ist Sekunde 10 in einem Strom, der bei 10 beginnt."); + } + + /// Was schon vorbei war, fällt weg. + /// + /// + /// Und zwar samt seinem Text: bliebe der Absatz ohne Zeitzeile stehen, + /// hinge er als Fliesstext in der Datei und der Browser verschluckt sich + /// daran. + /// + [Test] + public void Was_vor_dem_Einstieg_endet_faellt_weg() + { + var heraus = Untertitelversatz.Verschieben(Probe, 10); + + Assert.Multiple(() => + { + Assert.That(heraus, Does.Not.Contain("Ganz am Anfang")); + Assert.That(heraus, Does.Not.Contain("00:00:01.000")); + }); + } + + /// Ein Satz, der beim Einstieg schon lief, bleibt stehen. + /// + /// + /// Abgeschnitten ist er immer noch lesbar — ihn wegzuwerfen hiesse, beim + /// Wiedereinstieg mitten in einem Dialog mit Stille zu beginnen. + /// + [Test] + public void Was_ueber_den_Einstieg_hinwegreicht_faengt_bei_null_an() + { + var heraus = Untertitelversatz.Verschieben(Probe, 10); + + Assert.Multiple(() => + { + Assert.That(heraus, Does.Contain("Über den Einstieg hinweg")); + Assert.That(heraus, Does.Contain("00:00:00.000 --> 00:00:02.000")); + }); + } + + [Test] + public void Der_Kopf_bleibt_stehen() => + Assert.That(Untertitelversatz.Verschieben(Probe, 10), Does.StartWith("WEBVTT")); + + /// Die kurze Schreibweise ohne Stunden ist erlaubt. + [Test] + public void Auch_ohne_Stundenangabe() + { + var kurz = "WEBVTT\n\n01:05.000 --> 01:09.000\nKurz geschrieben\n"; + + Assert.That(Untertitelversatz.Verschieben(kurz, 60), + Does.Contain("00:00:05.000 --> 00:00:09.000")); + } + + /// Was hinter der Zeitzeile steht, bleibt dran. + /// + /// + /// Dort stehen Platzangaben wie line:90% align:center. Sie + /// abzuschneiden hiesse, erzwungene Untertitel mitten ins Bild zu setzen. + /// + [Test] + public void Die_Platzangaben_bleiben_erhalten() + { + var mitLage = "WEBVTT\n\n00:00:20.000 --> 00:00:24.000 line:90% align:center\nUnten\n"; + + Assert.That(Untertitelversatz.Verschieben(mitLage, 10), + Does.Contain("00:00:10.000 --> 00:00:14.000 line:90% align:center")); + } + + /// Eine Spur, die ganz vor dem Einstieg liegt, wird leer — aber gültig. + [Test] + public void Eine_leere_Spur_bleibt_lesbares_WebVTT() + { + var heraus = Untertitelversatz.Verschieben(Probe, 3600); + + Assert.Multiple(() => + { + Assert.That(heraus, Does.StartWith("WEBVTT")); + Assert.That(heraus, Does.Not.Contain("-->")); + }); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 6f7176e..e74a912 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -661,8 +661,16 @@ export const stromStarten = (datei: string, wunsch: Stromwunsch) => export const stromBeenden = (sitzung: string) => anfrage(`/stream/${sitzung}`, { method: 'DELETE' }); -export const untertitelAdresse = (datei: string, spur: number) => - `/api/v1/files/${datei}/subtitles/${spur}.vtt`; +/** Die Untertitelspur als WebVTT, passend zur Zeitachse des Stroms. + * + * **`vorlauf` ist nicht geschmückt, sondern nötig.** Die Zeitstempel in der + * Datei zählen ab Filmanfang, der Strom zählt ab null. Ohne diesen Wert lägen + * beim Einstieg in Minute 30 alle Einblendungen dreissig Minuten in der + * Zukunft — und weil der Strom nur bis zum Filmende reicht, käme kein + * einziger Satz mehr. */ +export const untertitelAdresse = (datei: string, spur: number, vorlauf = 0) => + `/api/v1/files/${datei}/subtitles/${spur}.vtt` + + (vorlauf > 0 ? `?ab=${vorlauf.toFixed(3)}` : ''); /* ---------- Verwaltung ---------- */ diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 2f83c8a..6114468 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -3,7 +3,7 @@ import { stromStarten, stromBeenden, untertitelAdresse, fortschritt, type Stromauskunft, type Spurauskunft, } from './api'; -import { dauer, knopf, meldung } from './util'; +import { dauer, knopf, meldung, hinweisZeigen } from './util'; import { icons, icon } from './icons'; /* ---------- Was der Browser kann ---------- */ @@ -745,9 +745,23 @@ export function spieler(anfrage: Spieleranfrage): void { const t = document.createElement('track'); t.kind = 'subtitles'; t.label = s.label; - if (s.language) t.srclang = s.language.slice(0, 2); - t.src = untertitelAdresse(anfrage.dateiId, s.index); + // **Nicht abschneiden.** `s.language` ist ein Dreibuchstabencode; + // „ger".slice(0, 2) ergibt „ge", und das ist keine Sprache — Deutsch + // wäre „de". Lieber keine Angabe als eine falsche: die Beschriftung + // steht ohnehin in `label`. + if (s.language?.length === 2) t.srclang = s.language; + + // Mit dem Vorlauf: der Strom beginnt bei null, die Datei nicht. + t.src = untertitelAdresse(anfrage.dateiId, s.index, vorlauf); t.dataset.spur = String(s.index); + + // **Ein gescheiterter Abruf war bisher vollkommen stumm.** Der Haken + // stand im Menü, als liefe es — und niemand konnte unterscheiden, ob + // die Spur leer ist oder der Abruf schiefging. + t.onerror = () => { + if (untertitel === s.index) hinweisZeigen('Diese Untertitelspur liess sich nicht laden.', 'fehler'); + }; + video.appendChild(t); }); if (untertitel !== null) untertitelWaehlen(untertitel);