Eine Hauptliste, damit auch AVPlayer die Untertitel findet
`index.m3u8` ist eine Medienliste: eine Reihe von Schnipseln, sonst nichts. Dem Browser reicht das, weil er die Untertitel als eigene Spur ans Videoelement gehängt bekommt. AVPlayer kann das nicht — dort gibt es keinen Weg, einer laufenden Wiedergabe eine Textspur von aussen beizulegen. Was er findet, findet er in der Abspielliste oder gar nicht. Also eine Hauptliste: dieselbe Medienliste als Bildspur, daneben je eine Textspur als eigene Liste mit genau einem Stück — unsere WebVTT-Datei über den ganzen Rest des Films. Der Vorlauf der Sitzung hängt daran, denn die Zeitstempel in der Datei zählen ab Dateianfang und der Strom ab null. Für den Browser ändert sich nichts; er holt weiterhin `index.m3u8`. Eine Direktwiedergabe bekommt keine Hauptliste: die Datei bringt ihre Spuren selbst mit, und eine Liste darum herum wäre eine Hülle um nichts. Vorarbeit für die App, die bisher auf eine fertig umgepackte MP4 wartet — dreissig Sekunden, wo das Web nach einer anfängt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
912969d829
commit
4a5576f939
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
@@ -78,6 +79,51 @@ public class StreamTests
|
||||
return ziel;
|
||||
}
|
||||
|
||||
/// <summary>Derselbe Film, dazu eine Textspur auf Deutsch.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Für die Hauptliste braucht es etwas, das darin stehen kann. Eine
|
||||
/// Bildspur (PGS) wäre dafür untauglich und soll ausdrücklich fehlen —
|
||||
/// die prüft <see cref="Eine_Bildspur_steht_nicht_in_der_Hauptliste"/>
|
||||
/// nicht, weil ffmpeg sie nicht nebenbei erzeugt.
|
||||
/// </remarks>
|
||||
private string FilmMitUntertitel(string name, int sekunden = 4)
|
||||
{
|
||||
var srt = Path.Combine(_bestand, "text.srt");
|
||||
File.WriteAllText(srt,
|
||||
"1\n00:00:00,500 --> 00:00:02,000\nEin Satz.\n\n"
|
||||
+ "2\n00:00:02,500 --> 00:00:03,500\nNoch einer.\n");
|
||||
|
||||
var ziel = Path.Combine(_bestand, $"{name}.mkv");
|
||||
var start = new ProcessStartInfo(Werkzeug("ffmpeg")!)
|
||||
{
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (var a in new[]
|
||||
{
|
||||
"-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", $"testsrc=duration={sekunden}:size=320x240:rate=10",
|
||||
"-f", "lavfi", "-i", $"sine=frequency=440:duration={sekunden}",
|
||||
"-i", srt,
|
||||
"-map", "0:v", "-map", "1:a", "-map", "2:s",
|
||||
"-metadata:s:s:0", "language=deu", "-metadata:s:s:0", "title=Deutsch",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-c:s", "srt",
|
||||
ziel,
|
||||
})
|
||||
{
|
||||
start.ArgumentList.Add(a);
|
||||
}
|
||||
|
||||
using var prozess = Process.Start(start)!;
|
||||
var fehler = prozess.StandardError.ReadToEnd();
|
||||
prozess.WaitForExit();
|
||||
if (prozess.ExitCode != 0) throw new InvalidOperationException(fehler);
|
||||
File.Delete(srt);
|
||||
return ziel;
|
||||
}
|
||||
|
||||
private async Task<(HttpClient Client, MediaFileResponse Datei)> Eingelesen()
|
||||
{
|
||||
var server = _server = new FlaroServer();
|
||||
@@ -374,4 +420,142 @@ public class StreamTests
|
||||
var antwort = await client.GetAsync($"/api/v1/stream/{Guid.CreateVersion7()}/index.m3u8");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
// --- Die Hauptliste, für Abspieler ohne eigenen Weg zu Untertiteln ---
|
||||
|
||||
/// <summary>Die Hauptliste nennt die Bildspur und eine Bandbreite.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>BANDWIDTH ist Pflicht.</b> Eine Hauptliste ohne diese Angabe weist
|
||||
/// AVPlayer ab — und zwar wortlos, mit einem schwarzen Bild.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Die_Hauptliste_nennt_die_Bildspur()
|
||||
{
|
||||
Film("Hauptliste (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
var strom = await Strom(client, datei.Id, Browser());
|
||||
|
||||
Assert.That(strom.MasterUrl, Is.Not.Null, "Eine Sitzung hat eine Hauptliste.");
|
||||
|
||||
var liste = await client.GetStringAsync(strom.MasterUrl!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(liste, Does.StartWith("#EXTM3U"));
|
||||
Assert.That(liste, Does.Contain("#EXT-X-STREAM-INF:BANDWIDTH="));
|
||||
Assert.That(liste, Does.Contain("index.m3u8"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Jede Textspur steht als eigene Spur in der Hauptliste.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Das ist der ganze Grund für diese Liste.</b> AVPlayer kennt keinen
|
||||
/// Weg, einer laufenden Wiedergabe eine Textspur von aussen beizulegen;
|
||||
/// was er findet, findet er hier oder gar nicht.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Die_Hauptliste_nennt_jede_Textspur()
|
||||
{
|
||||
FilmMitUntertitel("Mit Text (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
var strom = await Strom(client, datei.Id, Browser());
|
||||
|
||||
var spur = strom.SubtitleStreams.Single();
|
||||
var liste = await client.GetStringAsync(strom.MasterUrl!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(liste, Does.Contain("#EXT-X-MEDIA:TYPE=SUBTITLES"));
|
||||
Assert.That(liste, Does.Contain($"URI=\"untertitel/{spur.Index}.m3u8\""));
|
||||
Assert.That(liste, Does.Contain("SUBTITLES=\"untertitel\""));
|
||||
Assert.That(liste, Does.Contain("DEFAULT=NO"),
|
||||
"Was gezeigt wird, entscheidet der Spieler — nicht die Liste.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Die Spurliste zeigt auf die Spur, mit dem Vorlauf der Sitzung.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Ohne <c>ab</c> läge jeder Satz in der Zukunft.</b> Die Zeitstempel
|
||||
/// in der Datei zählen ab Dateianfang, der Strom ab null.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Verglichen wird mit dem gemeldeten Vorlauf, nicht mit dem
|
||||
/// gewünschten</b> — und das ist keine Bequemlichkeit. Beim Kopieren
|
||||
/// beginnt ffmpeg am Schlüsselbild vor dem Wunsch; in einem Prüfclip von
|
||||
/// vier Sekunden ist das die Null. Der Untertitel muss zu dem passen, was
|
||||
/// wirklich läuft.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Die_Spurliste_traegt_den_Vorlauf_der_Sitzung()
|
||||
{
|
||||
FilmMitUntertitel("Mit Vorlauf (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
var strom = await Strom(client, datei.Id, Browser(ab: 2));
|
||||
var spur = strom.SubtitleStreams.Single();
|
||||
|
||||
var liste = await client.GetStringAsync(
|
||||
$"/api/v1/stream/{strom.SessionId}/untertitel/{spur.Index}.m3u8");
|
||||
|
||||
var vorlauf = strom.StartSeconds.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(liste, Does.StartWith("#EXTM3U"));
|
||||
Assert.That(liste, Does.Contain("#EXT-X-ENDLIST"),
|
||||
"Eine Textspur ist vollständig, sobald es sie gibt — sie wächst nicht.");
|
||||
Assert.That(liste, Does.Contain(
|
||||
$"/api/v1/files/{datei.Id}/subtitles/{spur.Index}.vtt?ab={vorlauf}"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Eine Spur, die es nicht gibt, gibt es auch hier nicht.</summary>
|
||||
[Test]
|
||||
public async Task Eine_unbekannte_Spur_hat_keine_Liste()
|
||||
{
|
||||
FilmMitUntertitel("Ohne diese Spur (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
var strom = await Strom(client, datei.Id, Browser());
|
||||
|
||||
var antwort = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/untertitel/99.m3u8");
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
/// <summary>Ohne Sitzung keine Liste — auch nicht die Hauptliste.</summary>
|
||||
[Test]
|
||||
public async Task Ohne_Sitzung_gibt_es_keine_Hauptliste()
|
||||
{
|
||||
Film("Keine Sitzung (2020)");
|
||||
var (client, _) = await Eingelesen();
|
||||
|
||||
var antwort = await client.GetAsync($"/api/v1/stream/{Guid.NewGuid()}/master.m3u8");
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
/// <summary>Eine Direktwiedergabe braucht keine Hauptliste.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Die Datei bringt ihre Spuren selbst mit; eine Liste darum herum wäre
|
||||
/// eine Hülle um nichts.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Eine_Direktwiedergabe_hat_keine_Hauptliste()
|
||||
{
|
||||
Film("Direkt (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
var strom = await Strom(client, datei.Id, new StreamRequest(
|
||||
Containers: ["matroska", "mkv"],
|
||||
VideoCodecs: ["h264"],
|
||||
AudioCodecs: ["aac", "eac3"]));
|
||||
|
||||
Assume.That(strom.Method, Is.EqualTo("direct"));
|
||||
Assert.That(strom.MasterUrl, Is.Null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user