diff --git a/server/src/Flaro.Api/Streamkoordinator.cs b/server/src/Flaro.Api/Streamkoordinator.cs index 2806ddd..7f2b464 100644 --- a/server/src/Flaro.Api/Streamkoordinator.cs +++ b/server/src/Flaro.Api/Streamkoordinator.cs @@ -152,19 +152,31 @@ public sealed class Streamkoordinator( // Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und // schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not // ein Stück zurückzuspringen. + // **Gesprungen wird auf den Wunsch, gemeldet wird das Schlüsselbild.** + // + // Beim Kopieren springt ffmpeg ungenau — siehe `HlsArgumente` — und + // landet damit auf dem Schlüsselbild vor dem Wunsch. Bild und Ton + // fangen dort gemeinsam an; das ist die Zusage, und sie hängt an + // keiner Rechnung hier. + // + // Wo genau das war, sagt die Suche. Sie ist dafür da, dem Spieler + // eine ehrliche Uhr zu geben — nicht dafür, die Ausrichtung + // herzustellen. Vergreift sie sich, geht die Uhr falsch; Bild und Ton + // bleiben zusammen. Umgekehrt wäre es ein schlechter Tausch: einen + // Sprungpunkt auf die Millisekunde zu treffen, ist zu viel verlangt. var gewuenscht = Math.Max(0, ab); - var anfang = plan.BildKopieren + var gemeldet = plan.BildKopieren ? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct) : gewuenscht; var anfrage = new Stromanfrage( - datei.Path, ordner, gelesen, plan, tonspur, anfang, Hardware(), stufe, tonversatz); + datei.Path, ordner, gelesen, plan, tonspur, gewuenscht, Hardware(), stufe, tonversatz); // **Die tatsächlich benutzte Spur, nicht die gewünschte.** Sonst // stünde im Menü bei keiner ein Haken, obwohl eine davon läuft — und // bei einer Spur, die es nicht mehr gibt, bei der falschen. var sitzung = new Stromsitzung( - kennung, dateiId, benutzer, ordner, anfang, + kennung, dateiId, benutzer, ordner, gemeldet, plan.Hoehe, HlsArgumente.Tonspur(gelesen, tonspur)?.Index, plan.Art, clock.GetUtcNow()); var prozess = Starten(anfrage, sitzung); diff --git a/server/src/Flaro.Media/HlsArgumente.cs b/server/src/Flaro.Media/HlsArgumente.cs index 2304e28..4b59215 100644 --- a/server/src/Flaro.Media/HlsArgumente.cs +++ b/server/src/Flaro.Media/HlsArgumente.cs @@ -106,8 +106,28 @@ public static class HlsArgumente if (anfrage.Ab > 0) { + // **Beim Kopieren wird ungenau gesprungen, und das mit Absicht.** + // + // Genau gesprungen schneidet ffmpeg den Ton auf die Sekunde, das + // kopierte Bild aber nicht — es kann erst beim Schlüsselbild davor + // anfangen. Dazwischen liegt Bild ohne Ton, und jeder Spieler darf + // diese Lücke deuten, wie er will; hls.js schiebt den Ton nach + // vorn. Gemessen an einer Datei mit Schlüsselbildern alle fünf + // Sekunden: bei einem Sprung auf 17 begann der Ton erst bei 1,96, + // bei 15,014 sogar erst bei 4,98. + // + // `-noaccurate_seek` lässt auch den Ton beim Schlüsselbild + // anfangen. Dieselben Sprünge, gemessen: 0,004 und 0,015 — und + // zwar bei *jedem* Wert, nicht nur bei gut getroffenen. Das ist + // der Unterschied zum Runden auf das Schlüsselbild: das muss auf + // die Millisekunde stimmen, sonst wird es schlimmer als vorher. + // + // Beim Umkodieren nicht: dort wird das Bild neu gerechnet und + // schneidet ohnehin genau. + if (anfrage.Plan.BildKopieren) argumente.Add("-noaccurate_seek"); + argumente.Add("-ss"); - argumente.Add(anfrage.Ab.ToString("0.###", CultureInfo.InvariantCulture)); + argumente.Add(Sekunden(anfrage.Ab)); } argumente.Add("-i"); @@ -122,11 +142,12 @@ public static class HlsArgumente if (zweiteEingabe) { argumente.Add("-itsoffset"); - argumente.Add(anfrage.Tonversatz.ToString("0.###", CultureInfo.InvariantCulture)); + argumente.Add(Sekunden(anfrage.Tonversatz)); if (anfrage.Ab > 0) { + if (anfrage.Plan.BildKopieren) argumente.Add("-noaccurate_seek"); argumente.Add("-ss"); - argumente.Add(anfrage.Ab.ToString("0.###", CultureInfo.InvariantCulture)); + argumente.Add(Sekunden(anfrage.Ab)); } argumente.Add("-i"); @@ -196,6 +217,18 @@ public static class HlsArgumente /// Haken, obwohl eine davon lief. /// /// + /// Eine Sekundenangabe für ffmpeg — mit Punkt und voller Genauigkeit. + /// + /// + /// Sechs Nachkommastellen, nicht drei. Auf drei gerundet kann ein + /// Sprungpunkt um eine Millisekunde zu klein werden, und eine Millisekunde + /// entscheidet, ob ffmpeg das gemeinte Schlüsselbild erwischt oder das + /// davor. Gemessen: bei 15,015 begann der Ton nach 0,004 s, bei 15,014 + /// nach 4,98 s. + /// + private static string Sekunden(double wert) => + wert.ToString("0.######", CultureInfo.InvariantCulture); + public static ProbedStream? Tonspur(IReadOnlyList spuren, int? gewuenscht) { var alle = spuren.Where(s => s.Kind == MediaStreamKind.Audio).ToList(); diff --git a/server/tests/Flaro.Media.Tests/HlsLaufTests.cs b/server/tests/Flaro.Media.Tests/HlsLaufTests.cs index efa235f..f4e446b 100644 --- a/server/tests/Flaro.Media.Tests/HlsLaufTests.cs +++ b/server/tests/Flaro.Media.Tests/HlsLaufTests.cs @@ -192,6 +192,39 @@ public class HlsLaufTests 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. /// /// diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts index c150269..898c140 100644 --- a/web/app/src/icons.ts +++ b/web/app/src/icons.ts @@ -95,6 +95,9 @@ export const icons = { * reserviert, was etwas wegnimmt. Wer meldet, dass ein Plakat nicht passt, * löst keinen Alarm aus. */ fahne: rahmen(''), + + /** Tonversatz — zwei Pfeile, die auseinandergehen. */ + versatz: rahmen(''), } as const; export type IconName = keyof typeof icons; diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index eea0446..2750b60 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -85,12 +85,13 @@ export function geraeteprofil(): { /** Wie oft der Stand zum Server geht. */ const MELDEABSTAND = 10_000; -/** In welchen Schritten sich der Tonversatz verstellen lässt. */ -const VERSATZSCHRITT = 100; - -/** Wie weit in beide Richtungen. Darüber hinaus ist es kein Versatz mehr, - * sondern eine kaputte Datei. */ -const VERSATZGRENZE = 5000; +/** Die Stufen des Tonversatzes in Millisekunden. + * + * **Nicht jeder Hunderter von −5000 bis +5000.** Das wären einhunderteins + * Einträge für etwas, das man im Jahr dreimal braucht. Diese elf decken ab, + * was vorkommt — und wer mehr braucht, hat keine Einstellung zu wenig, + * sondern eine kaputte Datei. */ +const VERSAETZE = [-1000, -500, -300, -200, -100, 0, 100, 200, 300, 500, 1000]; /** Wo gemerkte Tonversätze liegen — je Datei, in diesem Browser. * @@ -132,10 +133,11 @@ export function tonspracheMerken(sprache: string): void { try { localStorage.setItem(SPRACHSCHLUESSEL, sprache); } catch { /* Privates Fenster. */ } } -/** „läuft mit", „200 ms später", „300 ms früher". */ +/** „Ohne Versatz", „Ton 200 ms später", „Ton 1 s früher". */ function versatzWort(ms: number): string { - if (ms === 0) return 'läuft mit dem Bild'; - return ms > 0 ? `${ms} ms später` : `${-ms} ms früher`; + if (ms === 0) return 'Ohne Versatz'; + const betrag = Math.abs(ms) >= 1000 ? `${Math.abs(ms) / 1000} s` : `${Math.abs(ms)} ms`; + return `Ton ${betrag} ${ms > 0 ? 'später' : 'früher'}`; } /** Die Stufen, die im Menü stehen. Muss zu `Stromentscheid.Stufen` passen. */ @@ -373,6 +375,7 @@ export function spieler(anfrage: Spieleranfrage): void { const tonmenue = menue('tonspur', 'Tonspur'); const textmenue = menue('untertitel', 'Untertitel'); const stufenmenue = menue('regler', 'Qualität'); + const versatzmenue = menue('versatz', 'Tonversatz'); const links = document.createElement('div'); links.className = 'pl-gruppe'; @@ -380,7 +383,8 @@ export function spieler(anfrage: Spieleranfrage): void { const rechts = document.createElement('div'); rechts.className = 'pl-gruppe'; - rechts.append(rest, tonmenue.knopf, textmenue.knopf, stufenmenue.knopf, vollbild); + rechts.append( + rest, tonmenue.knopf, versatzmenue.knopf, textmenue.knopf, stufenmenue.knopf, vollbild); const unten = document.createElement('div'); unten.className = 'pl-unten'; @@ -647,30 +651,25 @@ export function spieler(anfrage: Spieleranfrage): void { })) : [{ text: 'Diese Datei hat keinen Ton.', gewaehlt: false, tun: () => {} }]; - // **Der Versatz steht im Tonmenü, nicht in einem eigenen.** Wer ihn - // sucht, sucht ihn beim Ton — und ein vierter Knopf in der Leiste wäre - // einer für etwas, das man im Jahr dreimal braucht. + tonmenue.setzen(spuren); + + // **Der Versatz hat ein eigenes Menü.** Er stand vorher unter den + // Tonspuren, und dort las er sich wie eine: „Später (+100 ms)" sah aus + // wie der Name einer Spur. Eine Einstellung gehört nicht in eine Liste + // von Inhalten. // - // Zwei Schritte und ein Zurück statt einer Liste: eine Aufzählung von - // −5000 bis +5000 in Hundertern wären einhunderteins Einträge, und man - // sucht ohnehin nicht einen Wert, sondern rückt ihn, bis es passt. - const verstellen = (um: number): void => { - tonversatz = Math.max(-VERSATZGRENZE, Math.min(VERSATZGRENZE, tonversatz + um)); - versatzMerken(anfrage.dateiId, tonversatz); - void stromAufbauen(filmzeit()); - }; - - // Ohne Tonspur gibt es nichts zu verschieben. - const versatzpunkte = toene.length === 0 ? [] : [ - { text: `Ton ${versatzWort(tonversatz)}`, gewaehlt: false, tun: () => {} }, - { text: `Später (+${VERSATZSCHRITT} ms)`, gewaehlt: false, tun: () => verstellen(VERSATZSCHRITT) }, - { text: `Früher (−${VERSATZSCHRITT} ms)`, gewaehlt: false, tun: () => verstellen(-VERSATZSCHRITT) }, - ...(tonversatz !== 0 - ? [{ text: 'Versatz zurücksetzen', gewaehlt: false, tun: () => verstellen(-tonversatz) }] - : []), - ]; - - tonmenue.setzen([...spuren, ...versatzpunkte]); + // Feste Stufen statt zweier Tasten, wie bei der Qualität: so zeigt der + // Haken, wo man steht, und man trifft einen Wert, statt sich + // hinzutasten — jeder Schritt baut den Strom neu auf. + versatzmenue.setzen(VERSAETZE.map(ms => ({ + text: versatzWort(ms), + gewaehlt: ms === tonversatz, + tun: () => { + tonversatz = ms; + versatzMerken(anfrage.dateiId, ms); + void stromAufbauen(filmzeit()); + }, + }))); const texte = auskunft?.subtitleStreams ?? []; textmenue.setzen(