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>
226 lines
8.1 KiB
C#
226 lines
8.1 KiB
C#
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);
|
|
}
|
|
}
|