using System.Diagnostics;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
namespace Flaro.Media.Tests;
/// Dieselbe Zeile, diesmal wirklich ausgeführt.
///
///
///
/// Weil die reine Prüfung nur die Form zeigt. Sie sagt, dass
/// -hls_segment_type fmp4 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.
///
///
/// Übersprungen, wo kein ffmpeg liegt. Eine Prüfung, die auf einem
/// Rechner ohne Werkzeug rot wird, sagt nichts über den Code.
///
///
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(StringComparer.OrdinalIgnoreCase) { "mp4" },
VideoCodecs: new HashSet(StringComparer.OrdinalIgnoreCase) { "h264", "avc1" },
AudioCodecs: new HashSet(StringComparer.OrdinalIgnoreCase) { "aac", "mp3" });
/// Führt die gebaute Zeile aus und gibt den Zielordner zurück.
private string Laufen(
string quelle, int? hoehe = null, int? tonspur = null, double ab = 0, double tonversatz = 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, Tonversatz: tonversatz));
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;
}
/// Was wirklich in der Datei steht — nicht, was wir annehmen.
private static List 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();
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;
}
// ------------------------------------------------------------------
/// Eine MKV wird umgepackt, und es entstehen abspielbare Segmente.
[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\""));
});
}
/// Was herauskommt, ist H.264 und AAC — und nichts anderes.
///
///
/// Das ist der eigentliche Punkt. Die Quelle hat eine Tonspur, die
/// kein Browser kennt; wäre sie kopiert worden, bliebe der Film stumm —
/// und genau so war es vorher.
///
[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"));
});
}
/// Herunterskalieren ändert die Bildhöhe wirklich.
[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));
}
/// Ein Sprung mitten hinein liefert ebenfalls Segmente.
[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);
}
/// Bild und Ton fangen zusammen an — bei jedem Sprungwert.
///
///
///
/// Das ist der Test, der gefehlt hat. Die Quelle hat genau ein
/// Schlüsselbild, bei null; jeder Sprung landet also mitten in der
/// Bildgruppe — der schlimmste Fall. Genau gesprungen schnitte ffmpeg den
/// Ton auf die Sekunde, das kopierte Bild aber nicht, und dazwischen läge
/// Bild ohne Ton.
///
///
/// Zuerst hatte ich dafür den Sprung auf das Schlüsselbild gerundet. Das
/// war zu scharf geschliffen: eine Millisekunde zu klein, und ffmpeg nimmt
/// das Schlüsselbild davor — gemessen wurden aus 0,004 s Abstand dann
/// 4,98. `-noaccurate_seek` braucht diese Genauigkeit nicht.
///
///
[TestCase(0.0)]
[TestCase(1.5)]
[TestCase(3.2)]
[TestCase(4.0)]
[TestCase(6.75)]
public void Bild_und_Ton_fangen_bei_jedem_Sprung_zusammen_an(double ab)
{
var quelle = Path.Combine(_ordner, "lang.mkv");
Lang(quelle);
var (bild, ton) = Anfang(Laufen(quelle, ab: ab));
Assert.That(Math.Abs(bild - ton), Is.LessThan(0.2),
$"Sprung auf {ab}: Bild beginnt bei {bild}, Ton bei {ton} — das hört man.");
}
/// Bild und Ton fangen an derselben Stelle an.
///
///
///
/// Die Quelle hat genau ein Schlüsselbild, bei null. Ein Sprung auf
/// Sekunde 4 ist damit der schlimmste Fall: das kopierte Bild muss ganz
/// vorn anfangen, der neu gerechnete Ton könnte bei 4 anfangen — vier
/// Sekunden stummes Bild, und eine Uhr, die um vier Sekunden danebenliegt.
///
///
/// Gemessen an einer Quelle mit Schlüsselbildern alle fünf Sekunden und
/// einem Sprung auf Sekunde 12 war es genau so: Bild ab 0, Ton ab 1,98.
/// Deshalb rundet der Koordinator den Sprung auf das Schlüsselbild —
/// hier wird geprüft, dass die Zeile das auch verträgt.
///
///
[Test]
public void Bild_und_Ton_fangen_zusammen_an()
{
var quelle = Path.Combine(_ordner, "lang.mkv");
Lang(quelle);
// Auf dem Schlüsselbild, so wie der Koordinator es rundet.
var (bild, ton) = Anfang(Laufen(quelle, ab: 0));
Assert.That(Math.Abs(bild - ton), Is.LessThan(0.2),
$"Bild beginnt bei {bild}, Ton bei {ton} — das hört man.");
}
/// Der Tonversatz verschiebt den Ton, nicht das Bild.
[Test]
public void Ein_Tonversatz_verschiebt_nur_den_Ton()
{
var quelle = Path.Combine(_ordner, "lang.mkv");
Lang(quelle);
var (bildOhne, tonOhne) = Anfang(Laufen(quelle));
var (bildMit, tonMit) = Anfang(Laufen(quelle, tonversatz: 0.5));
Assert.Multiple(() =>
{
Assert.That(Math.Abs(bildMit - bildOhne), Is.LessThan(0.1),
"Das Bild darf sich dabei nicht bewegen.");
Assert.That(tonMit - tonOhne, Is.EqualTo(0.5).Within(0.1),
"Eine halbe Sekunde später heisst eine halbe Sekunde später.");
});
}
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,
]);
/// Wo Bild und Ton im Ergebnis anfangen — in Sekunden.
///
///
/// Das ist die Prüfung, die gefehlt hat. Der Sprungtest daneben sah
/// nur nach, ob überhaupt Segmente entstehen — und lief dabei genau durch
/// den Fall, der schiefgehen kann, ohne hinzusehen.
///
private static (double Bild, double Ton) Anfang(string ziel)
{
double Erste(string spur)
{
var start = new ProcessStartInfo(Testdateien.Suchen("ffprobe")!)
{
ArgumentList =
{
"-v", "error", "-select_streams", spur,
"-show_entries", "packet=pts_time", "-of", "csv=p=0",
Path.Combine(ziel, HlsArgumente.Playlist),
},
RedirectStandardOutput = true,
UseShellExecute = false,
};
using var prozess = Process.Start(start)!;
var text = prozess.StandardOutput.ReadToEnd();
prozess.WaitForExit();
var erste = text.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0].TrimEnd(',', '\r');
return double.Parse(erste, System.Globalization.CultureInfo.InvariantCulture);
}
return (Erste("v"), Erste("a"));
}
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);
}
}