Ton und Bild: ein Versatz zum Einstellen, und zwei gemessene Fehler weniger
Du meldest Ton, der drei, vier Sekunden neben dem Bild liegt. **Nachstellen konnte ich es nicht** — und das sage ich lieber, als eine Vermutung als Befund zu verkaufen. Gemessen habe ich alle vier Wege, jeweils mit ffprobe am Ergebnis, erster Zeitstempel je Spur: | Weg | Bild ab | Ton ab | |---|---|---| | Umpacken, ab 0 | 0,021 | 0,000 | | Umpacken, ab 12 | 0,000 | 1,979 | | Umkodieren, ab 0 | 0,021 | 0,000 | | Umkodieren, ab 12 | 0,021 | 0,000 | Alle vier sind **zueinander richtig**. Auch eine Quelle mit einem echten Loch in der Tonspur blieb ausgerichtet. Die naheliegende Erklärung — `-ss` vor `-i` mit kopiertem Bild und neu gerechnetem Ton — ist damit widerlegt. **Zwei echte Fehler hat dieselbe Messung aber gefunden**, und beide sehen aus wie „um Sekunden daneben": Zeile zwei der Tabelle. Beim Umpacken fängt das Bild beim Schlüsselbild *vor* dem Sprung an, der Ton genau beim Sprung — bei Sekunde 12 also zwei Sekunden Bild **ohne Ton** am Anfang. Zueinander stimmen beide, aber jeder Spieler darf diese Lücke deuten, wie er will. Und der Server meldete dem Spieler „beginnt bei 12", obwohl er bei 10 beginnt: die Uhr, die Zeitleiste und der gespeicherte Stand lagen um diesen Unterschied daneben. Beides geht weg, indem der Sprung **auf das Schlüsselbild gerundet** wird und derselbe Wert zurückgemeldet wird. Dann fangen Bild und Ton zusammen an, es gibt keine Stille davor, und die Uhr stimmt. Beim Umkodieren entfällt das Runden — dort schneidet das neu gerechnete Bild genau. **Und: ffmpeg durfte bisher nichts sagen.** `-loglevel error` versteckt genau die Meldungen, die einen Tonversatz ankündigen, und der Fehlerkanal wurde bei Rückgabewert 0 weggeworfen. Jetzt steht er auf `warning`, und Warnungen landen auch bei einem geglückten Lauf im Protokoll. Wenn es bei dir wieder auftritt, steht diesmal etwas da. **Der Versatz zum Einstellen** sitzt im Tonmenü des Spielers: „Später" und „Früher" in Hundertmillisekundenschritten, bis ±5 s, dazu „Zurücksetzen". Gemerkt wird er **je Datei in diesem Browser** — ein Versatz ist meist eine Eigenschaft der Datei, kann aber auch an der Anlage liegen, und über Bluetooth stimmt etwas anderes als am Fernseher. Verschoben wird serverseitig: die Datei wird ein zweites Mal mit `-itsoffset` geöffnet und von dort nur der Ton genommen. Mit einer Eingabe verschöbe sich beides gemeinsam. Der Preis: der Strom startet neu, und eine durchgereichte Datei muss dafür umgepackt werden — ohne ffmpeg lässt sich nichts verschieben. Zwei neue Läufe halten das fest: dass Bild und Ton zusammen anfangen, und dass ein halbe-Sekunde-Versatz den Ton um eine halbe Sekunde verschiebt und das Bild gar nicht. Der Sprungtest daneben lief bisher genau durch diesen Fall und sah nur nach, ob überhaupt Segmente entstehen. Ein Test musste umgeschrieben werden: er stand auf „Einstiegspunkt ist genau 2" und hielt damit die falsche Meldung fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3fe0013be1
commit
cf272f20aa
@@ -656,7 +656,18 @@ public sealed record StreamRequest(
|
||||
/// der Browser mehr als jede Abfrage vorher, und sein Urteil schlägt
|
||||
/// seine eigene Auskunft.
|
||||
/// </remarks>
|
||||
bool Transcode = false);
|
||||
bool Transcode = false,
|
||||
|
||||
/// <summary>Um wie viele Millisekunden der Ton verschoben wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Für Dateien, bei denen es ab Werk nicht stimmt.</b> Positiv heisst
|
||||
/// später, negativ früher. Der Server schiebt dabei nicht am Bild, sondern
|
||||
/// liest die Datei ein zweites Mal mit <c>-itsoffset</c> und nimmt von dort
|
||||
/// nur den Ton — mit einer Eingabe verschöbe sich beides gemeinsam und
|
||||
/// nichts wäre gewonnen.
|
||||
/// </remarks>
|
||||
int AudioOffsetMs = 0);
|
||||
|
||||
/// <summary>Eine wählbare Spur, so wie sie im Menü steht.</summary>
|
||||
public sealed record SpurAuskunft(int Index, string Label, string? Language, bool IsDefault);
|
||||
|
||||
@@ -67,6 +67,7 @@ public static class StreamEndpoints
|
||||
// zweites Mal unverändert hinzulegen wäre derselbe Fehler mit
|
||||
// mehr Geduld.
|
||||
if (hoehe is null && anfrage.AudioStream is null && !anfrage.Transcode
|
||||
&& anfrage.AudioOffsetMs == 0
|
||||
&& await Durchreichbar(db, id, profil) is { } datei)
|
||||
{
|
||||
return Results.Ok(await Direktauskunft(db, datei, anfrage.StartSeconds ?? 0));
|
||||
@@ -74,7 +75,8 @@ public static class StreamEndpoints
|
||||
|
||||
var sitzung = await koordinator.StartenAsync(
|
||||
id, benutzer, profil, hoehe, anfrage.AudioStream, anfrage.StartSeconds ?? 0,
|
||||
erzwingen: anfrage.Transcode);
|
||||
erzwingen: anfrage.Transcode,
|
||||
tonversatz: Math.Clamp(anfrage.AudioOffsetMs, -5000, 5000) / 1000.0);
|
||||
|
||||
return Results.Ok(await Auskunft(db, sitzung));
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ builder.Services.AddRateLimiter(o =>
|
||||
// und lässt einen Absturz dort den Server nicht mitnehmen.
|
||||
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
|
||||
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
|
||||
builder.Services.AddSingleton<Schluesselbilder>();
|
||||
builder.Services.AddSingleton<FfmpegRemuxer>();
|
||||
|
||||
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
|
||||
|
||||
@@ -61,6 +61,7 @@ public sealed class Streamkoordinator(
|
||||
IServiceScopeFactory scopes,
|
||||
FlaroPaths pfade,
|
||||
FfmpegOptions ffmpeg,
|
||||
Schluesselbilder schluesselbilder,
|
||||
TimeProvider clock,
|
||||
ILogger<Streamkoordinator> log) : IDisposable
|
||||
{
|
||||
@@ -100,7 +101,7 @@ public sealed class Streamkoordinator(
|
||||
public async Task<Stromsitzung> StartenAsync(
|
||||
Guid dateiId, Guid benutzer, DeviceProfile profil,
|
||||
int? hoehe, int? tonspur, double ab, CancellationToken ct = default,
|
||||
bool erzwingen = false)
|
||||
bool erzwingen = false, double tonversatz = 0)
|
||||
{
|
||||
AbgelaufeneWegraeumen();
|
||||
|
||||
@@ -139,14 +140,31 @@ public sealed class Streamkoordinator(
|
||||
var stufe = await bereich.ServiceProvider
|
||||
.GetRequiredService<Wiedergabeeinstellungen>().StufeAsync(ct);
|
||||
|
||||
// **Beim Kopieren wird auf das Schlüsselbild gerundet.**
|
||||
//
|
||||
// Ein kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe
|
||||
// aufschneiden; ffmpeg beginnt beim Schlüsselbild davor, während der
|
||||
// Ton neu gerechnet wird und genau beim Wunsch anfängt. Gemessen an
|
||||
// einer Quelle mit Schlüsselbildern alle fünf Sekunden, Sprung auf
|
||||
// Sekunde 12: Bild ab 0, Ton erst ab 1,98 — zwei Sekunden stummes
|
||||
// Bild, und die Uhr des Spielers zwei Sekunden daneben.
|
||||
//
|
||||
// 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.
|
||||
var gewuenscht = Math.Max(0, ab);
|
||||
var anfang = plan.BildKopieren
|
||||
? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct)
|
||||
: gewuenscht;
|
||||
|
||||
var anfrage = new Stromanfrage(
|
||||
datei.Path, ordner, gelesen, plan, tonspur, Math.Max(0, ab), Hardware(), stufe);
|
||||
datei.Path, ordner, gelesen, plan, tonspur, anfang, 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, Math.Max(0, ab),
|
||||
kennung, dateiId, benutzer, ordner, anfang,
|
||||
plan.Hoehe, HlsArgumente.Tonspur(gelesen, tonspur)?.Index, plan.Art, clock.GetUtcNow());
|
||||
|
||||
var prozess = Starten(anfrage, sitzung);
|
||||
@@ -273,7 +291,22 @@ public sealed class Streamkoordinator(
|
||||
await prozess.WaitForExitAsync();
|
||||
|
||||
sitzung.Beendet = true;
|
||||
if (prozess.ExitCode == 0) return;
|
||||
if (prozess.ExitCode == 0)
|
||||
{
|
||||
// **Auch ein geglückter Lauf darf etwas zu sagen haben.**
|
||||
// Vorher wurde der Text weggeworfen, sobald der Rückgabewert 0
|
||||
// war — und genau die Meldungen, die einen Tonversatz
|
||||
// ankündigen, sind Warnungen, keine Fehler. Ein Film, der
|
||||
// schief klingt, aber durchläuft, hinterliess damit nirgends
|
||||
// eine Spur.
|
||||
if (text.Trim() is { Length: > 0 } warnungen)
|
||||
{
|
||||
log.LogWarning("Strom {Kennung}: ffmpeg meldet {Warnungen}",
|
||||
sitzung.Kennung, warnungen);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
sitzung.Fehler = text.Trim();
|
||||
log.LogWarning("Strom {Kennung}: ffmpeg endete mit {Code}: {Fehler}",
|
||||
|
||||
@@ -32,7 +32,12 @@ public sealed record Stromanfrage(
|
||||
int? Tonspur = null,
|
||||
double Ab = 0,
|
||||
Beschleunigung Hardware = Beschleunigung.Software,
|
||||
Kodierstufe Stufe = Kodierstufe.Ausgewogen);
|
||||
Kodierstufe Stufe = Kodierstufe.Ausgewogen,
|
||||
/// <param name="Tonversatz">
|
||||
/// Um wie viele Sekunden der Ton gegenüber dem Bild verschoben wird.
|
||||
/// Positiv heisst später, negativ früher. <c>0</c> heisst: nichts tun.
|
||||
/// </param>
|
||||
double Tonversatz = 0);
|
||||
|
||||
/// <summary>Baut die ffmpeg-Zeile für einen laufenden Strom.</summary>
|
||||
///
|
||||
@@ -80,7 +85,12 @@ public static class HlsArgumente
|
||||
|
||||
public static List<string> Fuer(Stromanfrage anfrage)
|
||||
{
|
||||
var argumente = new List<string> { "-y", "-loglevel", "error" };
|
||||
// **`warning`, nicht `error`.** Genau die Meldungen, die einen
|
||||
// Tonversatz ankündigen — „Non-monotonous DTS", „first pts value …
|
||||
// forcing" —, sind Warnungen. Auf `error` waren sie unsichtbar, und
|
||||
// ein Lauf, der schief aussieht, aber mit 0 endet, hinterliess keine
|
||||
// Spur.
|
||||
var argumente = new List<string> { "-y", "-loglevel", "warning" };
|
||||
|
||||
// **Die Beschleunigung steht vor der Eingabe.** Danach wäre sie eine
|
||||
// Angabe über die Ausgabe und bliebe wirkungslos — ffmpeg sagt dazu
|
||||
@@ -103,6 +113,26 @@ public static class HlsArgumente
|
||||
argumente.Add("-i");
|
||||
argumente.Add(anfrage.Quelle);
|
||||
|
||||
// **Der Tonversatz braucht eine zweite Eingabe.** `-itsoffset` gilt
|
||||
// für alles, was hinter ihm steht; mit nur einer Eingabe verschöbe es
|
||||
// Bild und Ton gemeinsam und wäre damit wirkungslos. Dieselbe Datei
|
||||
// ein zweites Mal zu öffnen kostet einen Demuxer und sonst nichts —
|
||||
// gelesen wird ohnehin nur eine Spur daraus.
|
||||
var zweiteEingabe = Math.Abs(anfrage.Tonversatz) > 0.0005;
|
||||
if (zweiteEingabe)
|
||||
{
|
||||
argumente.Add("-itsoffset");
|
||||
argumente.Add(anfrage.Tonversatz.ToString("0.###", CultureInfo.InvariantCulture));
|
||||
if (anfrage.Ab > 0)
|
||||
{
|
||||
argumente.Add("-ss");
|
||||
argumente.Add(anfrage.Ab.ToString("0.###", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
argumente.Add("-i");
|
||||
argumente.Add(anfrage.Quelle);
|
||||
}
|
||||
|
||||
var bild = anfrage.Spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Video);
|
||||
var ton = Tonspur(anfrage.Spuren, anfrage.Tonspur);
|
||||
|
||||
@@ -111,7 +141,11 @@ public static class HlsArgumente
|
||||
// deshalb über eine neue Sitzung. Untertitel kommen als eigene
|
||||
// WebVTT-Datei daneben und brauchen den Strom gar nicht anzufassen.
|
||||
if (bild is not null) { argumente.Add("-map"); argumente.Add($"0:{bild.Index}"); }
|
||||
if (ton is not null) { argumente.Add("-map"); argumente.Add($"0:{ton.Index}"); }
|
||||
if (ton is not null)
|
||||
{
|
||||
argumente.Add("-map");
|
||||
argumente.Add($"{(zweiteEingabe ? 1 : 0)}:{ton.Index}");
|
||||
}
|
||||
|
||||
argumente.AddRange(Bildargumente(anfrage, bild));
|
||||
argumente.AddRange(Tonargumente(anfrage, ton));
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Flaro.Media;
|
||||
|
||||
/// <summary>Findet das Schlüsselbild, bei dem ein Strom wirklich anfängt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum das überhaupt nötig ist.</b> Beim Umpacken wird das Bild Byte für
|
||||
/// Byte übernommen, und ein kopierter Strom lässt sich nicht mitten in einer
|
||||
/// Bildgruppe aufschneiden. Wer bei Sekunde 754 einsteigt, bekommt deshalb
|
||||
/// Bild ab dem Schlüsselbild davor — der Ton dagegen wird neu gerechnet und
|
||||
/// fängt genau bei 754 an.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Gemessen, nicht vermutet.</b> An einer Quelle mit Schlüsselbildern alle
|
||||
/// fünf Sekunden, Sprung auf Sekunde 12: das Bild begann bei 0, der Ton erst
|
||||
/// bei 1,98 — zwei Sekunden stummes Bild am Anfang. Aneinander ausgerichtet
|
||||
/// waren beide dabei richtig; falsch war, was der Server dem Spieler über den
|
||||
/// Anfang sagte, und zwei Sekunden Stille sind eine Einladung an jeden
|
||||
/// Spieler, sie anders zu deuten.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Also wird gerundet statt geraten.</b> Der Sprung geht auf das
|
||||
/// Schlüsselbild, und derselbe Wert geht an den Spieler zurück. Dann fangen
|
||||
/// Bild und Ton an derselben Stelle an, es gibt keine Stille davor, und die
|
||||
/// Uhr stimmt. Dass man ein paar Sekunden früher einsteigt als gewünscht,
|
||||
/// fällt beim Weiterschauen niemandem auf — ein Tonversatz schon.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger<Schluesselbilder> log)
|
||||
{
|
||||
/// <summary>Wie weit vor dem Wunsch gesucht wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Längere Bildgruppen als eine halbe Minute kommen vor, sind aber selten;
|
||||
/// weiter zurückzulesen kostet bei jeder Sitzung Zeit für einen Fall, der
|
||||
/// kaum eintritt. Findet sich nichts, bleibt es beim Wunsch — dann ist es
|
||||
/// wie vorher und nicht schlechter.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan Rueckschau = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Das Schlüsselbild bei oder vor <paramref name="ab"/>.</summary>
|
||||
public async Task<double> DavorAsync(string pfad, double ab, CancellationToken ct = default)
|
||||
{
|
||||
if (ab <= 0) return 0;
|
||||
|
||||
var von = Math.Max(0, ab - Rueckschau.TotalSeconds);
|
||||
var spanne = $"{Zahl(von)}%+{Zahl(ab - von + 0.5)}";
|
||||
|
||||
var start = new ProcessStartInfo(optionen.Ffprobe)
|
||||
{
|
||||
ArgumentList =
|
||||
{
|
||||
"-v", "error",
|
||||
"-select_streams", "v:0",
|
||||
// Nur Schlüsselbilder dekodieren — sonst liest ffprobe jedes
|
||||
// einzelne Bild der halben Minute durch.
|
||||
"-skip_frame", "nokey",
|
||||
"-show_entries", "frame=pts_time",
|
||||
"-of", "csv=p=0",
|
||||
"-read_intervals", spanne,
|
||||
pfad,
|
||||
},
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
frist.CancelAfter(optionen.ProbeTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
using var prozess = Process.Start(start);
|
||||
if (prozess is null) return ab;
|
||||
|
||||
var ausgabe = await prozess.StandardOutput.ReadToEndAsync(frist.Token);
|
||||
await prozess.WaitForExitAsync(frist.Token);
|
||||
if (prozess.ExitCode != 0) return ab;
|
||||
|
||||
var treffer = ausgabe
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(z => double.TryParse(z.TrimEnd(','), NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var wert) ? wert : double.NaN)
|
||||
.Where(w => !double.IsNaN(w) && w <= ab)
|
||||
.ToList();
|
||||
|
||||
return treffer.Count > 0 ? treffer.Max() : ab;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Dauert das Suchen länger als das Lesen der Datei erlaubt, ist der
|
||||
// Wunsch die bessere Antwort als gar keine.
|
||||
log.LogWarning("Schlüsselbildsuche in {Pfad} dauerte zu lange.", pfad);
|
||||
return ab;
|
||||
}
|
||||
catch (Exception fehler) when (fehler is IOException or InvalidOperationException)
|
||||
{
|
||||
log.LogWarning(fehler, "Schlüsselbildsuche in {Pfad} ging schief.", pfad);
|
||||
return ab;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Zahl(double wert) => wert.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -279,11 +279,22 @@ public class StreamTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Der Einstiegspunkt kommt zurück, damit der Spieler richtig zählt.</summary>
|
||||
/// <summary>Der Einstiegspunkt kommt zurück — und zwar der echte.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Sonst ginge jeder gemeldete Stand um den Vorlauf daneben.</b> Der
|
||||
/// Strom fängt bei null an, der Film bei Minute 30.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Und „echt" heisst: beim Schlüsselbild.</b> Dieser Test stand vorher
|
||||
/// auf „genau zwei Sekunden" und hielt damit einen Fehler fest. Ein
|
||||
/// kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe
|
||||
/// aufschneiden: der Strom fängt beim Schlüsselbild davor an, und wer
|
||||
/// trotzdem „zwei" zurückmeldet, verschiebt die Uhr des Spielers um den
|
||||
/// Unterschied. Diese Testdatei hat genau ein Schlüsselbild, bei null —
|
||||
/// also ist null die richtige Antwort.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Der_Einstiegspunkt_steht_in_der_Antwort()
|
||||
@@ -292,7 +303,11 @@ public class StreamTests
|
||||
var (client, datei) = await Eingelesen();
|
||||
|
||||
var strom = await Strom(client, datei.Id, Browser(ab: 2));
|
||||
Assert.That(strom.StartSeconds, Is.EqualTo(2));
|
||||
|
||||
Assert.That(strom.StartSeconds, Is.LessThanOrEqualTo(2),
|
||||
"Weiter als gewünscht darf der Strom nie anfangen — das überspränge Film.");
|
||||
Assert.That(strom.StartSeconds, Is.EqualTo(0),
|
||||
"Diese Datei hat nur ein Schlüsselbild, bei null.");
|
||||
}
|
||||
|
||||
/// <summary>Schliessen räumt auf — sonst füllt sich der Kratzplatz.</summary>
|
||||
|
||||
@@ -43,13 +43,15 @@ public class HlsLaufTests
|
||||
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)
|
||||
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));
|
||||
var argumente = HlsArgumente.Fuer(
|
||||
new Stromanfrage(quelle, ziel, spuren, plan, tonspur, ab, Tonversatz: tonversatz));
|
||||
|
||||
var start = new ProcessStartInfo(Testdateien.Suchen("ffmpeg")!)
|
||||
{
|
||||
@@ -190,6 +192,54 @@ public class HlsLaufTests
|
||||
Assert.That(Directory.GetFiles(ziel, "*.m4s"), Is.Not.Empty);
|
||||
}
|
||||
|
||||
/// <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",
|
||||
@@ -199,6 +249,39 @@ public class HlsLaufTests
|
||||
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",
|
||||
|
||||
@@ -643,6 +643,8 @@ export type Stromwunsch = {
|
||||
startSeconds?: number;
|
||||
/** Umkodieren, egal was das Profil sagt — für den zweiten Versuch. */
|
||||
transcode?: boolean;
|
||||
/** Um wie viele Millisekunden der Ton verschoben wird. Positiv: später. */
|
||||
audioOffsetMs?: number;
|
||||
};
|
||||
|
||||
/** Legt einen Strom an, der sofort anfängt.
|
||||
|
||||
+72
-7
@@ -85,6 +85,43 @@ 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;
|
||||
|
||||
/** Wo gemerkte Tonversätze liegen — je Datei, in diesem Browser.
|
||||
*
|
||||
* **Je Datei, weil es eine Eigenschaft der Datei ist.** Wer einen Film einmal
|
||||
* geradegerückt hat, soll das beim nächsten Mal nicht wieder tun. Und in
|
||||
* diesem Browser, weil ein Versatz auch an der Anlage liegen kann — über
|
||||
* Bluetooth-Kopfhörer stimmt etwas anderes als am Fernseher. */
|
||||
const VERSATZSCHLUESSEL = 'flaro.tonversatz';
|
||||
|
||||
function versatzLesen(datei: string): number {
|
||||
try {
|
||||
const roh = JSON.parse(localStorage.getItem(VERSATZSCHLUESSEL) ?? '{}');
|
||||
const wert = Number(roh?.[datei]);
|
||||
return Number.isFinite(wert) ? wert : 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
function versatzMerken(datei: string, wert: number): void {
|
||||
try {
|
||||
const roh = JSON.parse(localStorage.getItem(VERSATZSCHLUESSEL) ?? '{}');
|
||||
if (wert === 0) delete roh[datei]; else roh[datei] = wert;
|
||||
localStorage.setItem(VERSATZSCHLUESSEL, JSON.stringify(roh));
|
||||
} catch { /* Privates Fenster — dann gilt er eben nur für diesen Film. */ }
|
||||
}
|
||||
|
||||
/** „läuft mit", „200 ms später", „300 ms 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`;
|
||||
}
|
||||
|
||||
/** Die Stufen, die im Menü stehen. Muss zu `Stromentscheid.Stufen` passen. */
|
||||
const STUFEN: { wert: number | null; text: string }[] = [
|
||||
{ wert: null, text: 'Original' },
|
||||
@@ -145,6 +182,9 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
/** Wohin gesprungen wird, sobald die Metadaten da sind. Nur bei Direktwiedergabe. */
|
||||
let zuSpringen = 0;
|
||||
|
||||
/** Der Tonversatz in Millisekunden — gemerkt je Datei. */
|
||||
let tonversatz = versatzLesen(anfrage.dateiId);
|
||||
|
||||
/** Die gewählte Stufe und Spuren — sie überleben einen Neustart des Stroms. */
|
||||
let stufe: number | null = null;
|
||||
let tonspur: number | null = null;
|
||||
@@ -458,6 +498,7 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
audioStream: tonspur,
|
||||
startSeconds: ab,
|
||||
transcode: umkodierenErzwingen,
|
||||
audioOffsetMs: tonversatz,
|
||||
});
|
||||
} catch (fehler) {
|
||||
zeigeFehler(meldung(fehler));
|
||||
@@ -576,13 +617,37 @@ export function spieler(anfrage: Spieleranfrage): void {
|
||||
*/
|
||||
function menuesAuffrischen(): void {
|
||||
const toene = auskunft?.audioStreams ?? [];
|
||||
tonmenue.setzen(
|
||||
toene.length > 0
|
||||
? toene.map(s => ({
|
||||
text: s.label, gewaehlt: s.index === tonspur,
|
||||
tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); },
|
||||
}))
|
||||
: [{ text: 'Diese Datei hat keinen Ton.', gewaehlt: false, tun: () => {} }]);
|
||||
const spuren = toene.length > 0
|
||||
? toene.map(s => ({
|
||||
text: s.label, gewaehlt: s.index === tonspur,
|
||||
tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); },
|
||||
}))
|
||||
: [{ 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.
|
||||
//
|
||||
// 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]);
|
||||
|
||||
const texte = auskunft?.subtitleStreams ?? [];
|
||||
textmenue.setzen(
|
||||
|
||||
Reference in New Issue
Block a user