Zwei Uhren, und niemand hatte sie verglichen

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-07 21:28:39 +02:00
co-authored by Claude Opus 5
parent 57fff63516
commit 51d1db4641
5 changed files with 275 additions and 9 deletions
@@ -212,16 +212,24 @@ public static class StreamEndpoints
// So lassen sie sich während der Wiedergabe umschalten, ohne dass // So lassen sie sich während der Wiedergabe umschalten, ohne dass
// irgendetwas neu gerechnet wird. In einer fMP4-Segmentierung hätten // irgendetwas neu gerechnet wird. In einer fMP4-Segmentierung hätten
// sie ohnehin keinen Platz. // 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 ( 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); var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
if (datei is null || !File.Exists(datei.Path)) return Results.NotFound(); if (datei is null || !File.Exists(datei.Path)) return Results.NotFound();
var vtt = await leser.AlsWebVttAsync(datei.Path, spur, ct); var vtt = await leser.AlsWebVttAsync(datei.Path, spur, ct);
return vtt is null if (vtt is null) return Results.NotFound();
? Results.NotFound()
: Results.Text(vtt, "text/vtt", Encoding.UTF8); return Results.Text(
Untertitelversatz.Verschieben(vtt, Math.Max(0, ab)), "text/vtt", Encoding.UTF8);
}).RequireAuthorization(); }).RequireAuthorization();
} }
+114
View File
@@ -0,0 +1,114 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace Flaro.Media;
/// <summary>Rückt WebVTT auf die Zeitachse eines Stroms, der mittendrin anfängt.</summary>
///
/// <remarks>
/// <para>
/// <b>Zwei Uhren, und niemand hat sie verglichen.</b> Die Zeitstempel einer
/// Untertitelspur zählen ab Dateianfang — bei Minute 30 steht dort
/// <c>00:30:00.000</c>. 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
/// <c>video.currentTime</c>, und der kennt nur die Stromuhr.
/// </para>
/// <para>
/// <b>Gemessen im Spieler:</b> „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".
/// </para>
/// <para>
/// <b>Hier verschoben und nicht im Browser.</b> Im Browser hiesse es, die
/// Spur selbst zu zerlegen und als <c>VTTCue</c> 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.
/// </para>
/// </remarks>
public static partial class Untertitelversatz
{
/// <summary>Verschiebt alle Zeitstempel um <paramref name="ab"/> Sekunden nach vorn.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
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<string>();
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();
}
/// <summary>„00:01:02.500" oder „01:02.500" — beide Formen sind erlaubt.</summary>
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;
}
/// <summary>Immer mit Stunden — die kurze Form ist erlaubt, die lange nie falsch.</summary>
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(
@"^(?<von>\d{1,2}:\d{2}(?::\d{2})?[.,]\d{1,3})\s*-->\s*(?<bis>\d{1,2}:\d{2}(?::\d{2})?[.,]\d{1,3})(?<rest>.*)$")]
private static partial Regex Zeitzeile();
}
@@ -0,0 +1,122 @@
namespace Flaro.Media.Tests;
/// <summary>Untertitel auf der Zeitachse eines Stroms, der mittendrin anfängt.</summary>
///
/// <remarks>
/// <b>Der Fehler, den diese Prüfung festhält.</b> 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".
/// </remarks>
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.");
}
/// <summary>Was schon vorbei war, fällt weg.</summary>
///
/// <remarks>
/// Und zwar samt seinem Text: bliebe der Absatz ohne Zeitzeile stehen,
/// hinge er als Fliesstext in der Datei und der Browser verschluckt sich
/// daran.
/// </remarks>
[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"));
});
}
/// <summary>Ein Satz, der beim Einstieg schon lief, bleibt stehen.</summary>
///
/// <remarks>
/// Abgeschnitten ist er immer noch lesbar — ihn wegzuwerfen hiesse, beim
/// Wiedereinstieg mitten in einem Dialog mit Stille zu beginnen.
/// </remarks>
[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"));
/// <summary>Die kurze Schreibweise ohne Stunden ist erlaubt.</summary>
[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"));
}
/// <summary>Was hinter der Zeitzeile steht, bleibt dran.</summary>
///
/// <remarks>
/// Dort stehen Platzangaben wie <c>line:90% align:center</c>. Sie
/// abzuschneiden hiesse, erzwungene Untertitel mitten ins Bild zu setzen.
/// </remarks>
[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"));
}
/// <summary>Eine Spur, die ganz vor dem Einstieg liegt, wird leer — aber gültig.</summary>
[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("-->"));
});
}
}
+10 -2
View File
@@ -661,8 +661,16 @@ export const stromStarten = (datei: string, wunsch: Stromwunsch) =>
export const stromBeenden = (sitzung: string) => export const stromBeenden = (sitzung: string) =>
anfrage<void>(`/stream/${sitzung}`, { method: 'DELETE' }); anfrage<void>(`/stream/${sitzung}`, { method: 'DELETE' });
export const untertitelAdresse = (datei: string, spur: number) => /** Die Untertitelspur als WebVTT, passend zur Zeitachse des Stroms.
`/api/v1/files/${datei}/subtitles/${spur}.vtt`; *
* **`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 ---------- */ /* ---------- Verwaltung ---------- */
+17 -3
View File
@@ -3,7 +3,7 @@ import {
stromStarten, stromBeenden, untertitelAdresse, fortschritt, stromStarten, stromBeenden, untertitelAdresse, fortschritt,
type Stromauskunft, type Spurauskunft, type Stromauskunft, type Spurauskunft,
} from './api'; } from './api';
import { dauer, knopf, meldung } from './util'; import { dauer, knopf, meldung, hinweisZeigen } from './util';
import { icons, icon } from './icons'; import { icons, icon } from './icons';
/* ---------- Was der Browser kann ---------- */ /* ---------- Was der Browser kann ---------- */
@@ -745,9 +745,23 @@ export function spieler(anfrage: Spieleranfrage): void {
const t = document.createElement('track'); const t = document.createElement('track');
t.kind = 'subtitles'; t.kind = 'subtitles';
t.label = s.label; t.label = s.label;
if (s.language) t.srclang = s.language.slice(0, 2); // **Nicht abschneiden.** `s.language` ist ein Dreibuchstabencode;
t.src = untertitelAdresse(anfrage.dateiId, s.index); // „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); 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); video.appendChild(t);
}); });
if (untertitel !== null) untertitelWaehlen(untertitel); if (untertitel !== null) untertitelWaehlen(untertitel);