„Auf der App höre ich einfach alle Audiospuren gleichzeitig."
MP4 ordnet Spuren derselben Art einer `alternate_group` zu, und ein
Abspieler nimmt daraus genau eine. ffmpeg leitet diese Gruppe aus der
Markierung der Quelle ab — sind dort zwei Tonspuren als Vorgabe markiert,
hält es sie für Verschiedenes und legt sie in getrennte Gruppen. Dann ist
jede für sich eingeschaltet, und man hört beide übereinander. Bei Rips aus
freier Wildbahn ist die doppelte Markierung eher Regel als Ausnahme; in
Matroska stört sie niemanden, in MP4 ist sie der Unterschied zwischen Wahl
und Chor.
Gemessen an einer Quelle mit zwei markierten Tonspuren, am Spurkopf der
fertigen MP4:
vorher Spur 2: an, Gruppe 1
Spur 3: an, Gruppe 2 <- beide laufen
nachher Spur 2: an, Gruppe 1
Spur 3: aus, Gruppe 1 <- eine zur Wahl
Das Umpacken schreibt jetzt genau eine Vorgabe je Spurart: die, die es in
der Quelle schon war, sonst die erste. Auch die Nullen gehören
hingeschrieben — ffmpeg übernähme sonst die Markierung der Quelle für alle
übrigen, und genau die ist ja das Problem. „Erzwungen" wird dabei
ausdrücklich erhalten, weil `-disposition` die ganze Markierung ersetzt
statt sie zu ergänzen.
Untertitel bekommen dieselbe Behandlung. War in der Quelle keiner
vorgesehen, bleibt es dabei — sonst erschienen auf einmal zwei übereinander.
Der Name im Zwischenspeicher trägt jetzt die Bauart mit. Ohne das bliebe
auf einem NAS, das schon umgepackt hat, die fehlerhafte Datei liegen und
würde weiter ausgeliefert, obwohl der Server es längst besser kann.
Geprüft an der echten Datei, am `tkhd` und nicht an der Disposition: was
ein Abspieler tut, hängt an „enabled" und `alternate_group`, und ffprobe
zeigt davon nur einen Teil. Ohne die Korrektur fällt die Prüfung um
(Gruppe 1 gegen 2).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
12 KiB
C#
282 lines
12 KiB
C#
using System.Buffers.Binary;
|
|
using System.Text;
|
|
using Flaro.Core.Delivery;
|
|
using Flaro.Core.Library;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace Flaro.Media.Tests;
|
|
|
|
/// <summary>Umpacken, von der echten Datei bis zum Ergebnis.</summary>
|
|
public class RemuxTests
|
|
{
|
|
private string _ordner = "";
|
|
private FfprobeRunner _ffprobe = null!;
|
|
private FfmpegRemuxer _umpacker = null!;
|
|
|
|
[SetUp]
|
|
public void Anlegen()
|
|
{
|
|
if (!Testdateien.FfmpegVorhanden) Assert.Ignore("ffmpeg ist auf diesem Rechner nicht installiert.");
|
|
_ordner = Directory.CreateDirectory(
|
|
Path.Combine(Path.GetTempPath(), "flaro-umpacken-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
|
var optionen = new FfmpegOptions(Testdateien.Suchen("ffmpeg")!, Testdateien.Suchen("ffprobe")!);
|
|
_ffprobe = new FfprobeRunner(optionen, NullLogger<FfprobeRunner>.Instance);
|
|
_umpacker = new FfmpegRemuxer(optionen, NullLogger<FfmpegRemuxer>.Instance);
|
|
}
|
|
|
|
[TearDown]
|
|
public void Aufraeumen()
|
|
{
|
|
try { if (_ordner.Length > 0) Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
|
|
}
|
|
|
|
private async Task<(RemuxOutcome Ergebnis, ProbeResult? Danach)> Umpacken(
|
|
string quelle, IProgress<double>? fortschritt = null)
|
|
{
|
|
var davor = await _ffprobe.ProbeAsync(quelle);
|
|
Assert.That(davor, Is.Not.Null, "Die Quelldatei liess sich nicht lesen");
|
|
|
|
var plan = DeliveryDecider.ForDownload(davor!.Container, davor.Streams);
|
|
var ziel = Path.Combine(_ordner, Path.GetFileNameWithoutExtension(quelle) + ".mp4");
|
|
|
|
var ergebnis = await _umpacker.RunAsync(
|
|
new RemuxRequest(quelle, ziel, davor.Streams, plan), davor.Duration, fortschritt);
|
|
|
|
return (ergebnis, ergebnis.Success ? await _ffprobe.ProbeAsync(ziel) : null);
|
|
}
|
|
|
|
/// <summary>Der Beweis, auf dem das ganze Projekt ruht.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Zwei Tonspuren und ein Untertitel gehen hinein, und alles kommt in
|
|
/// der MP4 wieder heraus</b> — samt Sprachkennung und Namen. Genau das
|
|
/// kann Jellyfins Transcode-Endpunkt nicht: der bildet je Sitzung eine
|
|
/// Tonspur ab, und beim Umpacken verliert er zusätzlich die
|
|
/// Sprachkennungen. Dass hier beides erhalten bleibt, ist der Grund für
|
|
/// diesen Server.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Alle_Ton_und_Textspuren_ueberstehen_das_Umpacken()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "zwei-tonspuren.mkv");
|
|
Testdateien.MitZweiTonspuren(quelle);
|
|
|
|
var (ergebnis, danach) = await Umpacken(quelle);
|
|
|
|
Assert.That(ergebnis.Success, Is.True, ergebnis.Error);
|
|
Assert.That(danach!.Container, Is.EqualTo("mp4"));
|
|
|
|
var ton = danach.Streams.Where(s => s.Kind == MediaStreamKind.Audio).ToList();
|
|
Assert.That(ton, Has.Count.EqualTo(2), "Beide Tonspuren müssen da sein");
|
|
Assert.That(ton.Select(t => t.Title), Is.EqualTo(new[] { "Deutsch", "Audiodeskription" }),
|
|
"Die Namen unterscheiden die beiden — ohne sie wäre keine Auswahl möglich");
|
|
Assert.That(ton.Select(t => t.Language), Is.EqualTo(new[] { "deu", "deu" }),
|
|
"Die Sprachkennung überlebt — Jellyfin verliert sie an dieser Stelle");
|
|
|
|
Assert.That(danach.Streams.Count(s => s.Kind == MediaStreamKind.Subtitle), Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Der_Untertitel_liegt_danach_als_tx3g_vor()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "mit-text.mkv");
|
|
Testdateien.MitZweiTonspuren(quelle);
|
|
|
|
var (_, danach) = await Umpacken(quelle);
|
|
|
|
var text = danach!.Streams.Single(s => s.Kind == MediaStreamKind.Subtitle);
|
|
Assert.That(text.Codec, Is.EqualTo("mov_text"), "MP4 kennt nur diese eine Form");
|
|
Assert.That(text.Language, Is.EqualTo("deu"));
|
|
}
|
|
|
|
/// <summary>Nur die untaugliche Spur wird gerechnet, die andere kopiert.</summary>
|
|
[Test]
|
|
public async Task Eine_untaugliche_Tonspur_wird_gerechnet_die_taugliche_nicht()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "gemischt.mkv");
|
|
Testdateien.MitUntauglichemTon(quelle);
|
|
|
|
var davor = await _ffprobe.ProbeAsync(quelle);
|
|
Assert.That(davor!.Streams.Select(s => s.Codec), Does.Contain("dts"), "Die Vorlage muss DTS enthalten");
|
|
|
|
var (ergebnis, danach) = await Umpacken(quelle);
|
|
Assert.That(ergebnis.Success, Is.True, ergebnis.Error);
|
|
|
|
var ton = danach!.Streams.Where(s => s.Kind == MediaStreamKind.Audio).ToList();
|
|
Assert.That(ton, Has.Count.EqualTo(2));
|
|
Assert.That(ton.Select(t => t.Codec), Is.EqualTo(new[] { "aac", "aac" }),
|
|
"DTS wurde zu AAC, und die erste Spur blieb, was sie war");
|
|
Assert.That(ton.Select(t => t.Language), Is.EqualTo(new[] { "deu", "eng" }),
|
|
"Auch die neu gerechnete Spur behält ihre Sprache");
|
|
}
|
|
|
|
/// <summary>Das Bild wird nie angefasst — nachgewiesen an der Bildgrösse und am Codec.</summary>
|
|
[Test]
|
|
public async Task Das_Bild_bleibt_unberuehrt()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "bild.mkv");
|
|
Testdateien.MitZweiTonspuren(quelle);
|
|
|
|
var davor = await _ffprobe.ProbeAsync(quelle);
|
|
var (_, danach) = await Umpacken(quelle);
|
|
|
|
var vorher = davor!.Streams.Single(s => s.Kind == MediaStreamKind.Video);
|
|
var nachher = danach!.Streams.Single(s => s.Kind == MediaStreamKind.Video);
|
|
|
|
Assert.That(nachher.Codec, Is.EqualTo(vorher.Codec));
|
|
Assert.That((nachher.Width, nachher.Height), Is.EqualTo((vorher.Width, vorher.Height)));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Der_Fortschritt_wird_gemeldet_und_endet_bei_eins()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "fortschritt.mkv");
|
|
Testdateien.MitZweiTonspuren(quelle);
|
|
|
|
var gemeldet = new List<double>();
|
|
var (ergebnis, _) = await Umpacken(quelle, new Progress<double>(w => { lock (gemeldet) gemeldet.Add(w); }));
|
|
|
|
Assert.That(ergebnis.Success, Is.True);
|
|
// Progress<T> meldet über den Synchronisierungskontext — kurz warten.
|
|
await Task.Delay(200);
|
|
lock (gemeldet)
|
|
{
|
|
Assert.That(gemeldet, Is.Not.Empty);
|
|
Assert.That(gemeldet[^1], Is.EqualTo(1.0));
|
|
Assert.That(gemeldet, Is.Ordered, "Der Fortschritt darf nicht zurückspringen");
|
|
}
|
|
}
|
|
|
|
/// <summary>Eine halbe Datei ist schlimmer als keine.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Bliebe sie liegen, hielte der nächste Aufruf sie für fertig und gäbe
|
|
/// einen abgeschnittenen Film heraus — der beim Abspielen mittendrin
|
|
/// abbricht.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Nach_einem_Fehlschlag_liegt_keine_halbe_Datei_herum()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "kaputt.mkv");
|
|
await File.WriteAllTextAsync(quelle, "das ist kein Film");
|
|
var ziel = Path.Combine(_ordner, "kaputt.mp4");
|
|
|
|
var ergebnis = await _umpacker.RunAsync(new RemuxRequest(
|
|
quelle, ziel, [], new DeliveryPlan(DeliveryMethod.Remux, [], [], [])));
|
|
|
|
Assert.That(ergebnis.Success, Is.False);
|
|
Assert.That(ergebnis.Error, Is.Not.Null.And.Not.Empty);
|
|
Assert.That(File.Exists(ziel), Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ein_Abbruch_laesst_nichts_zurueck()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "abbruch.mkv");
|
|
Testdateien.MitZweiTonspuren(quelle);
|
|
var ziel = Path.Combine(_ordner, "abbruch.mp4");
|
|
|
|
var davor = await _ffprobe.ProbeAsync(quelle);
|
|
using var abbruch = new CancellationTokenSource();
|
|
await abbruch.CancelAsync();
|
|
|
|
Assert.ThrowsAsync<OperationCanceledException>(() => _umpacker.RunAsync(
|
|
new RemuxRequest(quelle, ziel, davor!.Streams,
|
|
DeliveryDecider.ForDownload(davor.Container, davor.Streams)),
|
|
ct: abbruch.Token));
|
|
|
|
Assert.That(File.Exists(ziel), Is.False);
|
|
}
|
|
|
|
/// <summary>Zwei Tonspuren stehen zur Wahl — sie laufen nicht gleichzeitig.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Der gemeldete Fehler, in einer Datei festgehalten.</b> „Auf der App
|
|
/// höre ich einfach alle Audiospuren gleichzeitig." So entsteht das: MP4
|
|
/// ordnet Spuren derselben Art einer <c>alternate_group</c> zu, und ein
|
|
/// Abspieler nimmt daraus genau eine. ffmpeg leitet die Gruppe aus der
|
|
/// Markierung der Quelle ab — sind dort zwei Tonspuren als Vorgabe
|
|
/// markiert, hält es sie für Verschiedenes und legt sie in getrennte
|
|
/// Gruppen. Dann laufen beide.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Geprüft wird am Spurkopf, nicht an der Disposition.</b> Was ein
|
|
/// Abspieler tut, hängt an <c>alternate_group</c> und am Schalter
|
|
/// „enabled" im <c>tkhd</c>; ffprobe zeigt davon nur einen Teil. Die
|
|
/// zwanzig Zeilen Leser unten sind der Preis dafür, dass diese Prüfung
|
|
/// dasselbe liest wie das iPad.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Zwei_Tonspuren_stehen_zur_Wahl_statt_zugleich_zu_laufen()
|
|
{
|
|
var quelle = Path.Combine(_ordner, "zweivorgaben.mkv");
|
|
Testdateien.MitZweiVorgabetonspuren(quelle);
|
|
|
|
var (ergebnis, _) = await Umpacken(quelle);
|
|
Assert.That(ergebnis.Success, Is.True, ergebnis.Error);
|
|
|
|
var koepfe = Spurkoepfe(Path.Combine(_ordner, "zweivorgaben.mp4"));
|
|
var ton = koepfe.Skip(1).ToList();
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(ton, Has.Count.EqualTo(2));
|
|
Assert.That(ton[0].Gruppe, Is.EqualTo(ton[1].Gruppe),
|
|
"beide Tonspuren müssen in derselben Alternativgruppe stehen, sonst sind sie keine Alternativen");
|
|
Assert.That(ton.Count(t => t.Eingeschaltet), Is.EqualTo(1),
|
|
"genau eine läuft; sind es zwei, hört man beide übereinander");
|
|
});
|
|
}
|
|
|
|
/// <summary>Ein Spurkopf, so weit ein Abspieler ihn liest.</summary>
|
|
private readonly record struct Spurkopf(bool Eingeschaltet, int Gruppe);
|
|
|
|
/// <summary>Liest die <c>tkhd</c>-Kästen einer MP4 der Reihe nach.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Nur so weit, wie diese Prüfung es braucht: Kästen haben eine Länge
|
|
/// und einen Namen, <c>moov</c> und <c>trak</c> enthalten weitere.
|
|
/// </remarks>
|
|
private static List<Spurkopf> Spurkoepfe(string datei)
|
|
{
|
|
var daten = File.ReadAllBytes(datei);
|
|
var koepfe = new List<Spurkopf>();
|
|
Durchgehen(daten, 0, daten.Length);
|
|
return koepfe;
|
|
|
|
void Durchgehen(byte[] d, int von, int bis)
|
|
{
|
|
var p = von;
|
|
while (p + 8 <= bis)
|
|
{
|
|
var groesse = (long)BinaryPrimitives.ReadUInt32BigEndian(d.AsSpan(p, 4));
|
|
var art = Encoding.ASCII.GetString(d, p + 4, 4);
|
|
var kopf = 8;
|
|
|
|
if (groesse == 1)
|
|
{
|
|
groesse = (long)BinaryPrimitives.ReadUInt64BigEndian(d.AsSpan(p + 8, 8));
|
|
kopf = 16;
|
|
}
|
|
|
|
if (groesse < kopf || p + groesse > bis) return;
|
|
|
|
if (art is "moov" or "trak") Durchgehen(d, p + kopf, (int)(p + groesse));
|
|
if (art == "tkhd")
|
|
{
|
|
var fassung = d[p + kopf];
|
|
var schalter = (d[p + kopf + 1] << 16) | (d[p + kopf + 2] << 8) | d[p + kopf + 3];
|
|
// Nach den Schaltern: Zeiten und Kennung, dann Ebene und Gruppe.
|
|
var stelle = p + kopf + 4 + (fassung == 0 ? 28 : 40);
|
|
var gruppe = BinaryPrimitives.ReadInt16BigEndian(d.AsSpan(stelle + 2, 2));
|
|
koepfe.Add(new Spurkopf((schalter & 1) != 0, gruppe));
|
|
}
|
|
|
|
p += (int)groesse;
|
|
}
|
|
}
|
|
}
|
|
}
|