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", [