Er packte um, was gar nicht am Umpacken lag
Der Browser bekam stillschweigend Apples Geräteprofil — und mit ihm HEVC, AC3 und EAC3, von denen er keines öffnet. Der Server packte die Datei pflichtschuldig nach MP4 um, liess Bild und Ton dabei unberührt, und heraus kam eine tadellose Datei, mit der Chrome nichts anfangen konnte. Umpacken fasst das Bild nun einmal nicht an. Jetzt sagt das Gerät selbst, was es kann, und es gibt einen zweiten Weg neben der fertigen Datei: HLS, das nach Sekunden anfängt statt nach Minuten. Dasselbe Rohr erledigt beides — mkv nach fMP4 mit „-c:v copy", 4K nach 480p mit libx264 — und eine MP4, die der Browser ohnehin öffnet, geht ganz ohne ffmpeg hinaus. Dazu eine eigene Steuerleiste: Tonspur und Untertitel während der Wiedergabe umschaltbar, vier Auflösungsstufen, Tastenkürzel, und bei einem Fehler ein deutscher Satz statt eines schwarzen Bildes. Gemessen statt vermutet: Chromium antwortet auf canPlayType für HLS mit "maybe" und kann es dann doch nicht — deshalb entscheidet Hls.isSupported(). hls.js wiegt 372 KB und wird erst beim Abspielen geholt; die Oberfläche bleibt bei 109. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
557708b769
commit
4853200d5c
@@ -0,0 +1,330 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Wiedergabe, die sofort anfängt — von der Anfrage bis zum Segment.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Mit echtem ffmpeg, wie bei den Lieferungsprüfungen.</b> Ob eine Playlist
|
||||
/// entsteht und ob darin Segmente stehen, lässt sich nicht behaupten; es muss
|
||||
/// jemand nachsehen.
|
||||
/// </remarks>
|
||||
public class StreamTests
|
||||
{
|
||||
private string _bestand = "";
|
||||
private FlaroServer? _server;
|
||||
|
||||
private static string? Werkzeug(string name) =>
|
||||
(Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator)
|
||||
.Select(o => o.Length == 0 ? null : Path.Combine(o, name))
|
||||
.FirstOrDefault(p => p is not null && File.Exists(p));
|
||||
|
||||
[SetUp]
|
||||
public void Anlegen()
|
||||
{
|
||||
if (Werkzeug("ffmpeg") is null) Assert.Ignore("ffmpeg ist auf diesem Rechner nicht installiert.");
|
||||
_bestand = Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "flaro-strom-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
_server?.Dispose();
|
||||
_server = null;
|
||||
try { if (_bestand.Length > 0) Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
/// <summary>Was ein gewöhnlicher Browser kann — kein HEVC, kein EAC3.</summary>
|
||||
private static StreamRequest Browser(int? hoehe = null, int? ton = null, double? ab = null) => new(
|
||||
Containers: ["mp4"],
|
||||
VideoCodecs: ["h264", "avc1", "vp9"],
|
||||
AudioCodecs: ["aac", "mp3", "opus"],
|
||||
Height: hoehe, AudioStream: ton, StartSeconds: ab);
|
||||
|
||||
/// <summary>Eine MKV mit zwei Tonspuren, die zweite in einem Codec für Apple.</summary>
|
||||
private string Film(string name, int hoehe = 240, int sekunden = 4)
|
||||
{
|
||||
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={hoehe * 4 / 3}x{hoehe}:rate=10",
|
||||
"-f", "lavfi", "-i", $"sine=frequency=440:duration={sekunden}",
|
||||
"-f", "lavfi", "-i", $"sine=frequency=880:duration={sekunden}",
|
||||
"-map", "0:v", "-map", "1:a", "-map", "2:a",
|
||||
"-metadata:s:a:0", "language=deu", "-metadata:s:a:0", "title=Deutsch",
|
||||
"-metadata:s:a:1", "language=eng", "-metadata:s:a:1", "title=English",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
|
||||
"-c:a:0", "aac", "-c:a:1", "eac3",
|
||||
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);
|
||||
return ziel;
|
||||
}
|
||||
|
||||
private async Task<(HttpClient Client, MediaFileResponse Datei)> Eingelesen()
|
||||
{
|
||||
var server = _server = new FlaroServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var client = server.AlsAngemeldeter(admin.Token);
|
||||
|
||||
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
|
||||
new CreateLibraryRequest("Prüfbestand", "Movies", _bestand));
|
||||
angelegt.EnsureSuccessStatusCode();
|
||||
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
|
||||
|
||||
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
|
||||
if (stand!.State == "finished") break;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||
Assert.That(filme!.Items, Is.Not.Empty, "Der Scan hat nichts gefunden");
|
||||
return (client, filme.Items[0].Files.Single());
|
||||
}
|
||||
|
||||
private static async Task<StreamResponse> Strom(
|
||||
HttpClient client, Guid datei, StreamRequest anfrage)
|
||||
{
|
||||
var antwort = await client.PostAsJsonAsync($"/api/v1/files/{datei}/stream", anfrage);
|
||||
antwort.EnsureSuccessStatusCode();
|
||||
return (await antwort.Content.ReadFromJsonAsync<StreamResponse>())!;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Eine MKV wird für den Browser umgepackt, nicht neu gerechnet.</summary>
|
||||
[Test]
|
||||
public async Task Eine_mkv_wird_fuer_den_Browser_umgepackt()
|
||||
{
|
||||
Film("Umpacken (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
var strom = await Strom(client, datei.Id, Browser());
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(strom.Method, Is.EqualTo("remux"),
|
||||
"H.264 kennt jeder Browser — nur die Schachtel nicht");
|
||||
Assert.That(strom.Height, Is.Null, "ohne Wunsch bleibt die Auflösung, wie sie ist");
|
||||
Assert.That(strom.AudioStreams, Has.Count.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Die Playlist kommt, sobald das erste Segment da ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Und nicht als 204.</b> Erst stand hier eine leere Antwort, solange
|
||||
/// noch nichts fertig war — hls.js hielt das für einen Fehler und gab auf.
|
||||
/// Gewartet wird jetzt serverseitig.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Die_Playlist_enthaelt_Segmente()
|
||||
{
|
||||
Film("Playlist (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
var strom = await Strom(client, datei.Id, Browser());
|
||||
var antwort = await client.GetAsync(strom.Url);
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
var text = await antwort.Content.ReadAsStringAsync();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(text, Does.StartWith("#EXTM3U"));
|
||||
Assert.That(text, Does.Contain(".m4s"), "ohne Segment ist die Liste wertlos");
|
||||
Assert.That(text, Does.Contain("#EXT-X-MAP:URI=\"init.mp4\""));
|
||||
});
|
||||
|
||||
// Das Anfangsstück und das erste Segment müssen auch abholbar sein.
|
||||
var anfang = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/init.mp4");
|
||||
var erstes = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/seg00000.m4s");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(anfang.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
Assert.That(erstes.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Ein unbekannter Bildcodec führt zum Rechnen, nicht zum Umpacken.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Das war der Fehler, an dem jede Wiedergabe scheiterte.</b> Der
|
||||
/// Lieferweg für Downloads antwortete auch hier mit „umpacken" — und
|
||||
/// Umpacken fasst das Bild nicht an. Der Browser bekam eine tadellose MP4
|
||||
/// mit einem Codec, den er nicht kennt.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_unbekannter_Bildcodec_wird_gerechnet()
|
||||
{
|
||||
Film("Rechnen (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
// Ein Gerät, das H.264 **nicht** kann — der kürzeste Weg zu „muss gerechnet werden".
|
||||
var strom = await Strom(client, datei.Id, new StreamRequest(
|
||||
Containers: ["mp4"], VideoCodecs: ["vp9"], AudioCodecs: ["aac"]));
|
||||
|
||||
Assert.That(strom.Method, Is.EqualTo("transcode"));
|
||||
}
|
||||
|
||||
/// <summary>Eine kleinere Auflösung wird wirklich kleiner.</summary>
|
||||
[Test]
|
||||
public async Task Eine_kleinere_Stufe_wird_herunterskaliert()
|
||||
{
|
||||
Film("Gross (2020)", hoehe: 720);
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
var strom = await Strom(client, datei.Id, Browser(hoehe: 480));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(strom.Method, Is.EqualTo("transcode"));
|
||||
Assert.That(strom.Height, Is.EqualTo(480));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Hochrechnen ist keine Stufe.</summary>
|
||||
[Test]
|
||||
public async Task Eine_Stufe_ueber_der_Quelle_bleibt_beim_Umpacken()
|
||||
{
|
||||
Film("Klein (2020)", hoehe: 240);
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
var strom = await Strom(client, datei.Id, Browser(hoehe: 1080));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(strom.Height, Is.Null);
|
||||
Assert.That(strom.Method, Is.EqualTo("remux"),
|
||||
"240p auf 1080p zu rechnen kostet Last für kein besseres Bild");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Der Spieler erfährt, welche Tonspur wirklich läuft.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Nicht die gewünschte.</b> Vorher meldete der Server zurück, was
|
||||
/// gefragt worden war — meist <c>null</c> —, und im Menü stand bei keiner
|
||||
/// Spur ein Haken, obwohl eine davon lief.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Der_Strom_nennt_die_benutzte_Tonspur()
|
||||
{
|
||||
Film("Tonwahl (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
var ohneWunsch = await Strom(client, datei.Id, Browser());
|
||||
var mitWunsch = await Strom(client, datei.Id, Browser(ton: 2));
|
||||
var unsinn = await Strom(client, datei.Id, Browser(ton: 99));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ohneWunsch.AudioStream, Is.EqualTo(1), "ohne Wunsch die erste");
|
||||
Assert.That(mitWunsch.AudioStream, Is.EqualTo(2));
|
||||
Assert.That(unsinn.AudioStream, Is.EqualTo(1),
|
||||
"eine Spur, die es nicht gibt, macht den Film nicht stumm");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Der Einstiegspunkt kommt zurück, damit der Spieler richtig zählt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Sonst ginge jeder gemeldete Stand um den Vorlauf daneben.</b> Der
|
||||
/// Strom fängt bei null an, der Film bei Minute 30.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Der_Einstiegspunkt_steht_in_der_Antwort()
|
||||
{
|
||||
Film("Sprung (2020)", sekunden: 4);
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
var strom = await Strom(client, datei.Id, Browser(ab: 2));
|
||||
Assert.That(strom.StartSeconds, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
/// <summary>Schliessen räumt auf — sonst füllt sich der Kratzplatz.</summary>
|
||||
[Test]
|
||||
public async Task Schliessen_raeumt_die_Sitzung_weg()
|
||||
{
|
||||
Film("Aufraeumen (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
var strom = await Strom(client, datei.Id, Browser());
|
||||
(await client.GetAsync(strom.Url)).EnsureSuccessStatusCode();
|
||||
|
||||
var weg = await client.DeleteAsync($"/api/v1/stream/{strom.SessionId}");
|
||||
var danach = await client.GetAsync(strom.Url);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
||||
Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.NotFound),
|
||||
"eine beendete Sitzung gibt es nicht mehr");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Ein Pfad aus der Anfrage darf nie ans Dateisystem durchgereicht werden.</summary>
|
||||
[Test]
|
||||
public async Task Aus_der_Sitzung_kommt_man_nicht_heraus()
|
||||
{
|
||||
Film("Sicher (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
var strom = await Strom(client, datei.Id, Browser());
|
||||
|
||||
var hinaus = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/..%2F..%2Fflaro.db");
|
||||
var fremd = await client.GetAsync($"/api/v1/stream/{strom.SessionId}/index.m3u8x");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(hinaus.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
Assert.That(fremd.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Ohne Anmeldung kommt niemand an einen Strom.</summary>
|
||||
[Test]
|
||||
public async Task Ohne_Anmeldung_gibt_es_keinen_Strom()
|
||||
{
|
||||
Film("Zutritt (2020)");
|
||||
var (client, datei) = await Eingelesen();
|
||||
var strom = await Strom(client, datei.Id, Browser());
|
||||
|
||||
var ohne = _server!.OhneGedaechtnis();
|
||||
var antwort = await ohne.GetAsync(strom.Url);
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
/// <summary>Eine Sitzung, die es nicht gibt, ist eine 404 und kein Absturz.</summary>
|
||||
[Test]
|
||||
public async Task Eine_unbekannte_Sitzung_ist_nicht_gefunden()
|
||||
{
|
||||
Film("Unbekannt (2020)");
|
||||
var (client, _) = await Eingelesen();
|
||||
|
||||
var antwort = await client.GetAsync($"/api/v1/stream/{Guid.CreateVersion7()}/index.m3u8");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using Flaro.Core.Delivery;
|
||||
using Flaro.Core.Library;
|
||||
|
||||
namespace Flaro.Media.Tests;
|
||||
|
||||
/// <summary>Die ffmpeg-Zeile für einen laufenden Strom, ohne sie auszuführen.</summary>
|
||||
public class HlsArgumenteTests
|
||||
{
|
||||
private static ProbedStream Bild(string codec = "h264", int hoehe = 1080) =>
|
||||
new(0, MediaStreamKind.Video, codec, null, null, true, false, null, hoehe * 16 / 9, hoehe);
|
||||
|
||||
private static ProbedStream Ton(int i, string codec = "eac3", int kanaele = 6) =>
|
||||
new(i, MediaStreamKind.Audio, codec, "deu", null, false, false, kanaele, null, null);
|
||||
|
||||
private static ProbedStream Text(int i) =>
|
||||
new(i, MediaStreamKind.Subtitle, "subrip", "deu", null, false, false, null, null, null);
|
||||
|
||||
/// <summary>Was ein gewöhnlicher Browser kann.</summary>
|
||||
private static readonly DeviceProfile Browser = new(
|
||||
Containers: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "mp4", "webm" },
|
||||
VideoCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "h264", "avc1", "vp9" },
|
||||
AudioCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "aac", "mp3", "opus" });
|
||||
|
||||
private static string Zeile(
|
||||
IReadOnlyList<ProbedStream> spuren, string schachtel = "mkv",
|
||||
int? hoehe = null, int? tonspur = null, double ab = 0,
|
||||
Beschleunigung hardware = Beschleunigung.Software)
|
||||
{
|
||||
var plan = Stromentscheid.Fuer(schachtel, spuren, Browser, hoehe);
|
||||
return string.Join(' ', HlsArgumente.Fuer(new Stromanfrage(
|
||||
"/medien/film.mkv", "/kratz/sitzung", spuren, plan, tonspur, ab, hardware)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Umpacken: das Bild bleibt unberührt
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Eine MKV mit H.264 wird umgepackt, nicht neu gerechnet.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der häufigste Fall und der billigste.</b> Der Browser kennt die
|
||||
/// Schachtel nicht, das Bild darin sehr wohl.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Eine_mkv_mit_bekanntem_Bild_wird_nur_umgepackt()
|
||||
{
|
||||
var zeile = Zeile([Bild(), Ton(1, "aac")]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(zeile, Does.Contain("-c:v copy"));
|
||||
Assert.That(zeile, Does.Not.Contain("libx264"));
|
||||
Assert.That(zeile, Does.Contain("-f hls"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Der Ton wird immer nach AAC gerechnet — das war der Fehler.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Hier lag der Grund, aus dem gar nichts lief.</b> Beim Umpacken für
|
||||
/// den Download wird der Ton kopiert, weil Apples Profil EAC3 erlaubt. Der
|
||||
/// Browser bekam dieselbe Entscheidung — und mit ihr eine Datei, deren Ton
|
||||
/// er nicht öffnen kann.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Der_Ton_wird_immer_nach_AAC_gerechnet()
|
||||
{
|
||||
var zeile = Zeile([Bild(), Ton(1, "eac3")]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(zeile, Does.Contain("-c:a aac"));
|
||||
Assert.That(zeile, Does.Contain("-ac 2"), "5.1 wird auf Stereo gefaltet");
|
||||
Assert.That(zeile, Does.Not.Contain("-c:a copy"));
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Umkodieren: unbekanntes Bild oder kleinere Auflösung
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>HEVC kann kein Browser — da hilft Umpacken nichts.</summary>
|
||||
[Test]
|
||||
public void Ein_unbekannter_Bildcodec_wird_neu_gerechnet()
|
||||
{
|
||||
var zeile = Zeile([Bild("hevc"), Ton(1, "aac")]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(zeile, Does.Contain("-c:v libx264"));
|
||||
Assert.That(zeile, Does.Not.Contain("-c:v copy"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Ein 4K-Film soll sich auch in 480p ansehen lassen.</summary>
|
||||
[Test]
|
||||
public void Eine_kleinere_Aufloesung_wird_herunterskaliert()
|
||||
{
|
||||
var zeile = Zeile([Bild("h264", 2160), Ton(1, "aac")], hoehe: 480);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(zeile, Does.Contain("-vf scale=-2:480"),
|
||||
"-2 und nicht -1: die Breite muss gerade sein, sonst lehnt libx264 ab");
|
||||
Assert.That(zeile, Does.Contain("-c:v libx264"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Hochrechnen ist keine Stufe.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ein 720p-Film in „1080p" kostet mehr Last und mehr Daten für kein
|
||||
/// besseres Bild. Die Stufe wird dann stillschweigend übergangen.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Eine_Stufe_ueber_der_Quelle_wird_nicht_hochgerechnet()
|
||||
{
|
||||
var plan = Stromentscheid.Fuer("mp4", [Bild("h264", 720)], Browser, 1080);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(plan.Hoehe, Is.Null);
|
||||
Assert.That(plan.Art, Is.EqualTo(Stromart.Direkt));
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Spurwahl, Springen, Hardware
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Die_gewaehlte_Tonspur_wird_abgebildet()
|
||||
{
|
||||
var zeile = Zeile([Bild(), Ton(1), Ton(2), Ton(3)], tonspur: 3);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(zeile, Does.Contain("-map 0:0"));
|
||||
Assert.That(zeile, Does.Contain("-map 0:3"));
|
||||
Assert.That(zeile, Does.Not.Contain("-map 0:1"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Eine Spur, die es nicht gibt, macht den Film nicht stumm.</summary>
|
||||
[Test]
|
||||
public void Eine_unbekannte_Tonspur_faellt_auf_die_erste_zurueck()
|
||||
{
|
||||
var zeile = Zeile([Bild(), Ton(1), Ton(2)], tonspur: 99);
|
||||
|
||||
Assert.That(zeile, Does.Contain("-map 0:1"));
|
||||
}
|
||||
|
||||
/// <summary>Der Sprungpunkt steht vor der Eingabe.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Hinter <c>-i</c> wäre es ein genauer, aber langsamer Sprung:</b>
|
||||
/// ffmpeg läse die Datei bis dorthin durch. Bei Minute 90 eines Films
|
||||
/// dauert das länger als das Ansehen.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Der_Sprungpunkt_steht_vor_der_Eingabe()
|
||||
{
|
||||
var argumente = HlsArgumente.Fuer(new Stromanfrage(
|
||||
"/medien/film.mkv", "/kratz/sitzung",
|
||||
[Bild(), Ton(1)],
|
||||
Stromentscheid.Fuer("mkv", [Bild(), Ton(1)], Browser),
|
||||
Ab: 930.5));
|
||||
|
||||
var ss = argumente.IndexOf("-ss");
|
||||
var i = argumente.IndexOf("-i");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ss, Is.GreaterThanOrEqualTo(0));
|
||||
Assert.That(ss, Is.LessThan(i));
|
||||
Assert.That(argumente[ss + 1], Is.EqualTo("930.5"),
|
||||
"mit Punkt, nicht mit Komma — ffmpeg liest keine deutsche Schreibweise");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ohne_Sprungpunkt_steht_kein_ss_in_der_Zeile()
|
||||
{
|
||||
Assert.That(Zeile([Bild(), Ton(1)]), Does.Not.Contain("-ss"));
|
||||
}
|
||||
|
||||
/// <summary>Die Beschleunigung gehört vor die Eingabe, sonst wirkt sie nicht.</summary>
|
||||
[Test]
|
||||
public void Vaapi_steht_vor_der_Eingabe_und_nur_beim_Rechnen()
|
||||
{
|
||||
var mitRechnen = HlsArgumente.Fuer(new Stromanfrage(
|
||||
"/medien/film.mkv", "/kratz/sitzung",
|
||||
[Bild("hevc"), Ton(1)],
|
||||
Stromentscheid.Fuer("mkv", [Bild("hevc"), Ton(1)], Browser),
|
||||
Hardware: Beschleunigung.Vaapi));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(mitRechnen.IndexOf("-hwaccel"), Is.LessThan(mitRechnen.IndexOf("-i")));
|
||||
Assert.That(string.Join(' ', mitRechnen), Does.Contain("h264_vaapi"));
|
||||
|
||||
// Beim reinen Umpacken wird nichts dekodiert — die Grafikkarte
|
||||
// hätte dabei nichts zu tun.
|
||||
Assert.That(Zeile([Bild(), Ton(1)], hardware: Beschleunigung.Vaapi),
|
||||
Does.Not.Contain("-hwaccel"));
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Form der Ausgabe
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Die_Segmente_sind_unabhaengig_und_die_Liste_waechst()
|
||||
{
|
||||
var zeile = Zeile([Bild(), Ton(1)]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(zeile, Does.Contain("-hls_playlist_type event"),
|
||||
"vod behauptete, die Liste sei vollständig — der Spieler brach nach dem ersten Segment ab");
|
||||
Assert.That(zeile, Does.Contain("-hls_segment_type fmp4"));
|
||||
Assert.That(zeile, Does.Contain("-hls_flags independent_segments"));
|
||||
Assert.That(zeile, Does.EndWith(Path.Combine("/kratz/sitzung", "index.m3u8")));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Untertitel bleiben draussen — sie kommen als eigene Datei.</summary>
|
||||
[Test]
|
||||
public void Untertitel_wandern_nicht_in_den_Strom()
|
||||
{
|
||||
var zeile = Zeile([Bild(), Ton(1), Text(2)]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(zeile, Does.Contain("-sn"));
|
||||
Assert.That(zeile, Does.Not.Contain("-map 0:2"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Beim Rechnen wird jedes Segment gleich lang.</summary>
|
||||
[Test]
|
||||
public void Beim_Rechnen_stehen_die_Schluesselbilder_fest()
|
||||
{
|
||||
var zeile = Zeile([Bild("hevc"), Ton(1)]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(zeile, Does.Contain("-g 150"), "6 Sekunden × 25 Bilder");
|
||||
Assert.That(zeile, Does.Contain("-keyint_min 150"));
|
||||
Assert.That(zeile, Does.Contain("-sc_threshold 0"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Eine Datei ohne Ton bleibt eine Datei ohne Ton.</summary>
|
||||
[Test]
|
||||
public void Ohne_Tonspur_wird_nichts_erfunden()
|
||||
{
|
||||
var zeile = Zeile([Bild()]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(zeile, Does.Contain("-an"));
|
||||
Assert.That(zeile, Does.Not.Contain("-c:a aac"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Diagnostics;
|
||||
using Flaro.Core.Delivery;
|
||||
using Flaro.Core.Library;
|
||||
|
||||
namespace Flaro.Media.Tests;
|
||||
|
||||
/// <summary>Dieselbe Zeile, diesmal wirklich ausgeführt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Weil die reine Prüfung nur die Form zeigt.</b> Sie sagt, dass
|
||||
/// <c>-hls_segment_type fmp4</c> in der Zeile steht — nicht, dass ffmpeg die
|
||||
/// Kombination annimmt. Genau dort sitzen die teuren Fehler: eine Option, die
|
||||
/// für sich richtig aussieht und in Verbindung mit einer anderen zum Abbruch
|
||||
/// führt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Übersprungen, wo kein ffmpeg liegt.</b> Eine Prüfung, die auf einem
|
||||
/// Rechner ohne Werkzeug rot wird, sagt nichts über den Code.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class HlsLaufTests
|
||||
{
|
||||
private string _ordner = "";
|
||||
|
||||
[SetUp]
|
||||
public void Anlegen()
|
||||
{
|
||||
if (!Testdateien.FfmpegVorhanden) Assert.Ignore("Ohne ffmpeg nicht prüfbar.");
|
||||
_ordner = Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "flaro-hls-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private static readonly DeviceProfile Browser = new(
|
||||
Containers: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "mp4" },
|
||||
VideoCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "h264", "avc1" },
|
||||
AudioCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "aac", "mp3" });
|
||||
|
||||
/// <summary>Führt die gebaute Zeile aus und gibt den Zielordner zurück.</summary>
|
||||
private string Laufen(string quelle, int? hoehe = null, int? tonspur = null, double ab = 0)
|
||||
{
|
||||
var spuren = Spuren(quelle);
|
||||
var ziel = Directory.CreateDirectory(Path.Combine(_ordner, "aus" + Guid.NewGuid().ToString("N")[..6])).FullName;
|
||||
|
||||
var plan = Stromentscheid.Fuer(Path.GetExtension(quelle).TrimStart('.'), spuren, Browser, hoehe);
|
||||
var argumente = HlsArgumente.Fuer(new Stromanfrage(quelle, ziel, spuren, plan, tonspur, ab));
|
||||
|
||||
var start = new ProcessStartInfo(Testdateien.Suchen("ffmpeg")!)
|
||||
{
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (var a in argumente) start.ArgumentList.Add(a);
|
||||
|
||||
using var prozess = Process.Start(start)!;
|
||||
var fehler = prozess.StandardError.ReadToEnd();
|
||||
prozess.WaitForExit();
|
||||
|
||||
Assert.That(prozess.ExitCode, Is.Zero,
|
||||
$"ffmpeg lehnte die gebaute Zeile ab:\n{string.Join(' ', argumente)}\n\n{fehler}");
|
||||
return ziel;
|
||||
}
|
||||
|
||||
/// <summary>Was wirklich in der Datei steht — nicht, was wir annehmen.</summary>
|
||||
private static List<ProbedStream> Spuren(string datei)
|
||||
{
|
||||
var start = new ProcessStartInfo(Testdateien.Suchen("ffprobe")!)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (var a in new[]
|
||||
{
|
||||
"-v", "error", "-show_entries", "stream=index,codec_type,codec_name,height",
|
||||
"-of", "csv=p=0", datei,
|
||||
})
|
||||
{
|
||||
start.ArgumentList.Add(a);
|
||||
}
|
||||
|
||||
using var prozess = Process.Start(start)!;
|
||||
var ausgabe = prozess.StandardOutput.ReadToEnd();
|
||||
prozess.WaitForExit();
|
||||
|
||||
var spuren = new List<ProbedStream>();
|
||||
foreach (var zeile in ausgabe.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var teile = zeile.Trim().Split(',');
|
||||
if (teile.Length < 3) continue;
|
||||
MediaStreamKind? art = teile[2] switch
|
||||
{
|
||||
"video" => MediaStreamKind.Video,
|
||||
"audio" => MediaStreamKind.Audio,
|
||||
"subtitle" => MediaStreamKind.Subtitle,
|
||||
// Anhänge und Datenströme sind hier nichts, womit sich rechnen
|
||||
// liesse — sie zu einer Art zu erklären wäre eine Erfindung.
|
||||
_ => null,
|
||||
};
|
||||
if (art is null) continue;
|
||||
|
||||
int? hoehe = teile.Length > 3 && int.TryParse(teile[3], out var h) ? h : null;
|
||||
spuren.Add(new ProbedStream(
|
||||
int.Parse(teile[0]), art.Value, teile[1], null, null, false, false, null, null, hoehe));
|
||||
}
|
||||
return spuren;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Eine MKV wird umgepackt, und es entstehen abspielbare Segmente.</summary>
|
||||
[Test]
|
||||
public void Eine_mkv_wird_zu_Playlist_und_Segmenten()
|
||||
{
|
||||
var quelle = Path.Combine(_ordner, "film.mkv");
|
||||
Testdateien.MitZweiTonspuren(quelle);
|
||||
|
||||
var ziel = Laufen(quelle);
|
||||
|
||||
var playlist = Path.Combine(ziel, "index.m3u8");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(File.Exists(playlist), "die Liste muss da sein");
|
||||
Assert.That(File.Exists(Path.Combine(ziel, "init.mp4")),
|
||||
"fMP4 braucht ein Anfangsstück, sonst kann kein Spieler einsteigen");
|
||||
Assert.That(Directory.GetFiles(ziel, "*.m4s"), Is.Not.Empty);
|
||||
});
|
||||
|
||||
var text = File.ReadAllText(playlist);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(text, Does.StartWith("#EXTM3U"));
|
||||
Assert.That(text, Does.Contain("#EXT-X-MAP:URI=\"init.mp4\""));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Was herauskommt, ist H.264 und AAC — und nichts anderes.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Das ist der eigentliche Punkt.</b> Die Quelle hat eine Tonspur, die
|
||||
/// kein Browser kennt; wäre sie kopiert worden, bliebe der Film stumm —
|
||||
/// und genau so war es vorher.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Aus_untauglichem_Ton_wird_AAC()
|
||||
{
|
||||
var quelle = Path.Combine(_ordner, "dts.mkv");
|
||||
Testdateien.MitUntauglichemTon(quelle);
|
||||
|
||||
var ziel = Laufen(quelle, tonspur: 2); // die DTS-Spur ausdrücklich wählen
|
||||
var codecs = Spuren(Path.Combine(ziel, "index.m3u8")).Select(s => s.Codec).ToList();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(codecs, Does.Contain("h264"));
|
||||
Assert.That(codecs, Does.Contain("aac"));
|
||||
Assert.That(codecs, Does.Not.Contain("dts"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Herunterskalieren ändert die Bildhöhe wirklich.</summary>
|
||||
[Test]
|
||||
public void Herunterskalieren_aendert_die_Hoehe()
|
||||
{
|
||||
var quelle = Path.Combine(_ordner, "gross.mkv");
|
||||
Gross(quelle);
|
||||
|
||||
var ziel = Laufen(quelle, hoehe: 120);
|
||||
var bild = Spuren(Path.Combine(ziel, "index.m3u8"))
|
||||
.First(s => s.Kind == MediaStreamKind.Video);
|
||||
|
||||
Assert.That(bild.Height, Is.EqualTo(120));
|
||||
}
|
||||
|
||||
/// <summary>Ein Sprung mitten hinein liefert ebenfalls Segmente.</summary>
|
||||
[Test]
|
||||
public void Ein_Sprungpunkt_liefert_den_Rest_des_Films()
|
||||
{
|
||||
var quelle = Path.Combine(_ordner, "lang.mkv");
|
||||
Lang(quelle);
|
||||
|
||||
var ziel = Laufen(quelle, ab: 4);
|
||||
|
||||
Assert.That(Directory.GetFiles(ziel, "*.m4s"), Is.Not.Empty);
|
||||
}
|
||||
|
||||
private static void Gross(string ziel) => Werkzeug(
|
||||
[
|
||||
"-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "testsrc=duration=1:size=640x480:rate=10",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac",
|
||||
ziel,
|
||||
]);
|
||||
|
||||
private static void Lang(string ziel) => Werkzeug(
|
||||
[
|
||||
"-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "testsrc=duration=8:size=160x120:rate=10",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=8",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac",
|
||||
ziel,
|
||||
]);
|
||||
|
||||
private static void Werkzeug(string[] argumente)
|
||||
{
|
||||
var start = new ProcessStartInfo(Testdateien.Suchen("ffmpeg")!)
|
||||
{
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (var a in argumente) 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user