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:
co-authored by
Claude Opus 5
parent
57fff63516
commit
51d1db4641
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user