Du hast recht, und zwar doppelt.
**Der Versatz stand im falschen Menü.** „Später (+100 ms)" unter den
Tonspuren liest sich wie der Name einer Spur. Jetzt hat er einen eigenen
Knopf in der Leiste, mit eigenem Zeichen und festen Stufen von „Ton 1 s
früher" bis „Ton 1 s später", dazwischen „Ohne Versatz" — mit Haken, wie
bei der Qualität. Im Tonmenü stehen wieder nur Sprachen.
**Und das Schlimmere: die Rundung aufs Schlüsselbild hat es verschlechtert.**
Sie musste auf die Millisekunde stimmen, sonst nimmt ffmpeg das
Schlüsselbild davor. Gemessen an einer Datei mit Schlüsselbildern alle
fünf Sekunden:
-ss 15.015 → Bild ab 0,000 | Ton ab 0,004
-ss 15.014 → Bild ab 0,000 | Ton ab 4,983
Eine Millisekunde. Und die Zeile, die den Wert an ffmpeg gibt, rundete auf
drei Nachkommastellen — bei 23,976 Bildern je Sekunde liegen
Schlüsselbilder auf Werten wie 3,336667, und die überleben das nicht
zuverlässig. Damit konnte jede Fortsetzung schlechter werden als vorher.
Das war mein Fehler, eingebaut in E8GSRE.
**Der richtige Hebel ist `-noaccurate_seek`**, und er braucht gar keine
Genauigkeit. Damit schneidet ffmpeg auch den Ton nicht auf die Sekunde,
sondern lässt ihn beim selben Schlüsselbild anfangen wie das kopierte
Bild. Gemessen, dieselbe Datei, fünf verschiedene Sprungwerte quer durch
die Bildgruppe:
15,014 → 0,015 | 17 → 0,004 | 19,9 → 0,004 | 12,5 → 0,015 | 3,2 → 0,011
Statt bis zu fünf Sekunden also überall unter zwanzig Millisekunden, und
zwar ohne dass irgendein Wert getroffen werden muss. Fünf Testfälle quer
durch die Bildgruppe halten das jetzt fest; vorher prüfte der Sprungtest
nur, ob überhaupt Segmente entstehen.
Die Schlüsselbildsuche bleibt — aber nur noch, um dem Spieler eine
ehrliche Uhr zu geben. Vergreift sie sich, geht die Uhr falsch; Bild und
Ton bleiben zusammen. Vorher hing die Ausrichtung daran, und das war der
schlechte Tausch.
Und die Sekundenangaben gehen jetzt mit sechs Nachkommastellen hinaus
statt mit drei.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
342 lines
13 KiB
C#
342 lines
13 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, 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;
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
/// <summary>Bild und Ton fangen zusammen an — bei jedem Sprungwert.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Das ist der Test, der gefehlt hat.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
[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.");
|
|
}
|
|
|
|
/// <summary>Bild und Ton fangen an derselben Stelle an.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Die Quelle hat genau ein Schlüsselbild, bei null.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
[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.");
|
|
}
|
|
|
|
/// <summary>Der Tonversatz verschiebt den Ton, nicht das Bild.</summary>
|
|
[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,
|
|
]);
|
|
|
|
/// <summary>Wo Bild und Ton im Ergebnis anfangen — in Sekunden.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Das ist die Prüfung, die gefehlt hat.</b> Der Sprungtest daneben sah
|
|
/// nur nach, ob überhaupt Segmente entstehen — und lief dabei genau durch
|
|
/// den Fall, der schiefgehen kann, ohne hinzusehen.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|