From 40f41f0cee1717dd854f9d98b80b69799a3cafcc Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 8 Oct 2026 06:32:25 +0200 Subject: [PATCH] Zwei Tonspuren sind eine Wahl, kein Chor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit „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 --- server/src/Flaro.Api/PackageCoordinator.cs | 18 +++- server/src/Flaro.Media/RemuxArguments.cs | 61 ++++++++++++ .../Flaro.Media.Tests/RemuxArgumentsTests.cs | 80 ++++++++++++++++ server/tests/Flaro.Media.Tests/RemuxTests.cs | 93 +++++++++++++++++++ server/tests/Flaro.Media.Tests/Testdateien.cs | 22 +++++ 5 files changed, 273 insertions(+), 1 deletion(-) diff --git a/server/src/Flaro.Api/PackageCoordinator.cs b/server/src/Flaro.Api/PackageCoordinator.cs index ce91445..fd11912 100644 --- a/server/src/Flaro.Api/PackageCoordinator.cs +++ b/server/src/Flaro.Api/PackageCoordinator.cs @@ -59,8 +59,24 @@ public sealed class PackageCoordinator( /// heisst das Ergebnis anders — und niemand bekommt die alte Fassung /// ausgeliefert, bloss weil sie zufällig noch herumliegt. /// + /// Wie das Umpacken gebaut wurde — Teil des Namens. + /// + /// + /// Sonst bleibt ein Fehler liegen, der behoben ist. Der Name trug + /// bisher nur Kennung und Änderungszeitpunkt der Quelle. Ändert sich das + /// Umpacken selbst, passt beides weiterhin — und eine fehlerhaft + /// gebaute Datei wird weiter ausgeliefert, obwohl der Server es längst + /// besser könnte. + /// + /// 2: genau eine Vorgabe je Spurart. Davor landeten zwei als + /// Vorgabe markierte Tonspuren in getrennten Alternativgruppen und + /// liefen gleichzeitig. + /// + private const int Bauart = 2; + public string PathFor(Guid fileId, DateTimeOffset geändert) => - Path.Combine(pfade.Transcode, "packages", $"{fileId:N}-{geändert.ToUnixTimeMilliseconds()}.mp4"); + Path.Combine(pfade.Transcode, "packages", + $"{fileId:N}-{geändert.ToUnixTimeMilliseconds()}-{Bauart}.mp4"); public bool IsReady(Guid fileId, DateTimeOffset geändert) => File.Exists(PathFor(fileId, geändert)); diff --git a/server/src/Flaro.Media/RemuxArguments.cs b/server/src/Flaro.Media/RemuxArguments.cs index f1e1a7c..0cf464c 100644 --- a/server/src/Flaro.Media/RemuxArguments.cs +++ b/server/src/Flaro.Media/RemuxArguments.cs @@ -87,6 +87,30 @@ public static class RemuxArguments argumente.Add(Bitrate(spur.Channels).ToString()); } + // **Genau eine Vorgabe je Art — sonst laufen alle Spuren zugleich.** + // + // Gemeldet als „auf der App höre ich einfach alle Audiospuren + // gleichzeitig", und so entsteht es: 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 — bei Rips aus freier + // Wildbahn eher Regel als Ausnahme —, hält ffmpeg sie für + // Verschiedenes und legt sie in getrennte Gruppen. + // + // Gemessen an einer Quelle mit zwei markierten Tonspuren: + // + // ohne diese Zeilen Spur 2: an, Gruppe 1 + // Spur 3: an, Gruppe 2 <- beide laufen + // mit diesen Zeilen Spur 2: an, Gruppe 1 + // Spur 3: aus, Gruppe 1 <- eine zur Wahl + // + // Welche die Vorgabe wird: die, die es in der Quelle schon war. Gab + // es keine, die erste. Das ist keine Auswahl für den Zuschauer — er + // schaltet im Spieler um —, sondern nur die Zusage, dass es eine + // gibt und nicht zwei. + Vorgabe(argumente, "a", tonspuren); + Vorgabe(argumente, "s", genommen.Where(s => s.Kind == MediaStreamKind.Subtitle).ToList()); + // Das Inhaltsverzeichnis nach vorn. Ohne das muss ein Abspieler erst // ans Dateiende springen, bevor er irgendetwas zeigen kann — bei einer // heruntergeladenen Datei ist das der Unterschied zwischen „läuft @@ -98,6 +122,43 @@ public static class RemuxArguments return argumente; } + /// Markiert genau eine Spur einer Art als Vorgabe, alle anderen nicht. + /// + /// + /// Auch die Null gehört hingeschrieben. Es genügt nicht, die eine + /// zu markieren: ffmpeg übernimmt sonst die Markierung der Quelle für + /// alle übrigen, und genau die ist ja das Problem. + /// + /// Bei Untertiteln kann die Vorgabe fehlen, und das ist richtig + /// so — war in der Quelle keiner vorgesehen, soll auch keiner von selbst + /// erscheinen. Bei Ton gibt es dagegen immer eine; eine Datei ganz ohne + /// eingeschaltete Tonspur wäre stumm. + /// + /// -disposition ersetzt die ganze Markierung, sie ergänzt sie + /// nicht. „Erzwungen" muss deshalb ausdrücklich mitgeschrieben + /// werden, sonst verlöre eine erzwungene Untertitelspur genau das, was + /// sie ausmacht. Was nicht kennt — etwa + /// „für Hörgeschädigte" oder „Kommentar" —, geht dabei verloren; das ist + /// der Preis dafür, dass überhaupt etwas abspielbar herauskommt. + /// + private static void Vorgabe(List argumente, string art, IReadOnlyList spuren) + { + if (spuren.Count < 2) return; + + var ausQuelle = spuren.ToList().FindIndex(s => s.IsDefault); + var gewaehlt = ausQuelle >= 0 ? ausQuelle : art == "a" ? 0 : -1; + + for (var stelle = 0; stelle < spuren.Count; stelle++) + { + var teile = new List(); + if (stelle == gewaehlt) teile.Add("default"); + if (spuren[stelle].IsForced) teile.Add("forced"); + + argumente.Add($"-disposition:{art}:{stelle}"); + argumente.Add(teile.Count > 0 ? string.Join('+', teile) : "0"); + } + } + private static int Bitrate(int? kanäle) => Math.Min(HöchsteBitrate, Math.Max(1, kanäle ?? 2) * BitrateJeKanal); } diff --git a/server/tests/Flaro.Media.Tests/RemuxArgumentsTests.cs b/server/tests/Flaro.Media.Tests/RemuxArgumentsTests.cs index 430fd69..c5fbede 100644 --- a/server/tests/Flaro.Media.Tests/RemuxArgumentsTests.cs +++ b/server/tests/Flaro.Media.Tests/RemuxArgumentsTests.cs @@ -118,4 +118,84 @@ public class RemuxArgumentsTests Assert.That(argumente[argumente.IndexOf("-i") + 1], Is.EqualTo("/medien/film.mkv")); Assert.That(argumente[^1], Is.EqualTo("/kratz/film.mp4"), "Das Ziel steht bei ffmpeg immer zuletzt"); } + + /// Genau eine Tonspur ist die Vorgabe — sonst laufen alle zugleich. + /// + /// + /// Gemeldet als „ich höre 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, legt es sie in getrennte Gruppen, und beide laufen. + /// + [Test] + public void Nur_eine_Tonspur_bleibt_Vorgabe() + { + var zwei = new List + { + Bild(), + Ton(1) with { IsDefault = true }, + Ton(2) with { IsDefault = true }, + }; + + var zeile = AlsZeile(Bauen(zwei)); + + Assert.Multiple(() => + { + Assert.That(zeile, Does.Contain("-disposition:a:0 default")); + Assert.That(zeile, Does.Contain("-disposition:a:1 0"), + "auch die Null gehört hingeschrieben: ffmpeg übernähme sonst die Markierung der Quelle"); + }); + } + + /// Die Vorgabe der Quelle bleibt die Vorgabe. + [Test] + public void Die_schon_vorgegebene_Spur_bleibt_es() + { + var zeile = AlsZeile(Bauen([Bild(), Ton(1), Ton(2) with { IsDefault = true }])); + + Assert.Multiple(() => + { + Assert.That(zeile, Does.Contain("-disposition:a:0 0")); + Assert.That(zeile, Does.Contain("-disposition:a:1 default")); + }); + } + + /// „Erzwungen" überlebt die Behandlung. + /// + /// + /// -disposition ersetzt die ganze Markierung, statt sie zu + /// ergänzen. Wer hier nur „default" schreibt, nimmt einer erzwungenen + /// Untertitelspur genau das, was sie ausmacht. + /// + [Test] + public void Erzwungene_Untertitel_bleiben_erzwungen() + { + var zeile = AlsZeile(Bauen( + [Bild(), Ton(1), Text(2) with { IsForced = true }, Text(3)])); + + Assert.That(zeile, Does.Contain("-disposition:s:0 forced")); + } + + /// Ohne Vorgabe in der Quelle erscheint auch kein Untertitel von selbst. + [Test] + public void Untertitel_ohne_Vorgabe_bekommen_keine() + { + var zeile = AlsZeile(Bauen([Bild(), Ton(1), Text(2), Text(3)])); + + Assert.Multiple(() => + { + Assert.That(zeile, Does.Contain("-disposition:s:0 0")); + Assert.That(zeile, Does.Not.Contain("-disposition:s:0 default")); + }); + } + + /// Bei einer einzigen Spur gibt es nichts zu entscheiden. + [Test] + public void Eine_einzige_Tonspur_wird_nicht_angefasst() + { + var zeile = AlsZeile(Bauen([Bild(), Ton(1)])); + + Assert.That(zeile, Does.Not.Contain("-disposition:a")); + } } diff --git a/server/tests/Flaro.Media.Tests/RemuxTests.cs b/server/tests/Flaro.Media.Tests/RemuxTests.cs index 3a51241..9d33613 100644 --- a/server/tests/Flaro.Media.Tests/RemuxTests.cs +++ b/server/tests/Flaro.Media.Tests/RemuxTests.cs @@ -1,3 +1,5 @@ +using System.Buffers.Binary; +using System.Text; using Flaro.Core.Delivery; using Flaro.Core.Library; using Microsoft.Extensions.Logging.Abstractions; @@ -185,4 +187,95 @@ public class RemuxTests Assert.That(File.Exists(ziel), Is.False); } + + /// Zwei Tonspuren stehen zur Wahl — sie laufen nicht gleichzeitig. + /// + /// + /// + /// Der gemeldete Fehler, in einer Datei festgehalten. „Auf der App + /// höre ich einfach alle Audiospuren gleichzeitig." So entsteht das: MP4 + /// ordnet Spuren derselben Art einer alternate_group 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. + /// + /// + /// Geprüft wird am Spurkopf, nicht an der Disposition. Was ein + /// Abspieler tut, hängt an alternate_group und am Schalter + /// „enabled" im tkhd; 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. + /// + /// + [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"); + }); + } + + /// Ein Spurkopf, so weit ein Abspieler ihn liest. + private readonly record struct Spurkopf(bool Eingeschaltet, int Gruppe); + + /// Liest die tkhd-Kästen einer MP4 der Reihe nach. + /// + /// + /// Nur so weit, wie diese Prüfung es braucht: Kästen haben eine Länge + /// und einen Namen, moov und trak enthalten weitere. + /// + private static List Spurkoepfe(string datei) + { + var daten = File.ReadAllBytes(datei); + var koepfe = new List(); + 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; + } + } + } } diff --git a/server/tests/Flaro.Media.Tests/Testdateien.cs b/server/tests/Flaro.Media.Tests/Testdateien.cs index 430c792..52a78b0 100644 --- a/server/tests/Flaro.Media.Tests/Testdateien.cs +++ b/server/tests/Flaro.Media.Tests/Testdateien.cs @@ -50,6 +50,28 @@ public static class Testdateien File.Delete(untertitel); } + /// Zwei Tonspuren, und beide sind als Vorgabe markiert. + /// + /// + /// Keine Schikane, sondern der Normalfall. Wer eine Datei aus + /// mehreren Quellen zusammenmuxt, lässt die Markierung gern auf jeder + /// stehen. In Matroska stört das niemanden; in MP4 heisst es, dass ein + /// Abspieler beide Spuren für etwas Eigenes hält und gleichzeitig + /// abspielt. + /// + public static void MitZweiVorgabetonspuren(string ziel) => Ausführen("ffmpeg", + [ + "-y", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-f", "lavfi", "-i", "sine=frequency=880:duration=1", + "-map", "0:v", "-map", "1:a", "-map", "2:a", + "-metadata:s:a:0", "language=deu", "-metadata:s:a:1", "language=eng", + "-disposition:a:0", "default", "-disposition:a:1", "default", + "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", + ziel, + ]); + /// Eine schlichte MP4 mit einer Tonspur. public static void Einfach(string ziel) => Ausführen("ffmpeg", [