Vier Sekunden, die ffprobe zu früh aufhörte zu lesen

Die Untertitel kamen vier Sekunden zu spät. Nicht an ihnen lag es: der
Server meldete dem Spieler einen Anfang, der vor dem lag, an dem der
Strom wirklich begann, und genau diese Differenz landet eins zu eins im
Versatz.

Die Schlüsselbildsuche las mit `-read_intervals` bis eine halbe Sekunde
hinter den Wunsch. ffprobe bricht am Ende eines Fensters auf Paketebene
ab — das letzte Schlüsselbild darin kam regelmässig gar nicht mehr
heraus, und die Suche gab das davor zurück. Bei Bildgruppen von vier
Sekunden sind das vier Sekunden.

Nachgemessen gegen die Stelle, an der ffmpeg tatsächlich anfängt, an
einer Datei mit Schlüsselbildern alle vier Sekunden:

    Wunsch   Suche (0,5 s)   ffmpeg    Abweichung
      52,0            48      52,0         −4,0
      73,3            68      72,0         −4,0
     100,2            96     100,0         −4,0

Mit zehn Sekunden Nachlauf lag dieselbe Messung bei allen sieben Werten
auf 0,000. Was über den Wunsch hinausgelesen wird, verwirft die
Filterung ohnehin.

Die Prüfung vergleicht nicht gegen eine erwartete Zahl, sondern gegen
ffmpeg selbst — mit dem alten Wert fällt sie um.

Dazu der Filmteil der Abspannerkennung: `Abspannkante` als reine
Rechnung mit elf Prüfungen, `Bildabtaster` über ffmpeg, Marken auch an
Filmen, Knöpfe zum Suchen und Verwerfen auf der Filmseite. An einer
erzeugten Datei trifft die Kante auf vier Sekunden genau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-13 06:42:16 +02:00
co-authored by Claude Opus 5
parent 8112c15d4d
commit 7673c73757
22 changed files with 2825 additions and 32 deletions
+192
View File
@@ -0,0 +1,192 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
using Flaro.Core.Media;
using Microsoft.Extensions.Logging;
namespace Flaro.Media;
/// <summary>Misst, wie hell ein Film an einer Stelle ist.</summary>
///
/// <remarks>
/// <para>
/// <b>Der einzige Teil der Abspannsuche, der ffmpeg anfasst.</b> Was
/// daraus folgt, rechnet <see cref="Abspannkante"/> ohne Datei — dieselbe
/// Naht wie zwischen <c>Tonabdruckleser</c> und <c>Tonabgleich</c>.
/// </para>
/// <para>
/// <b>Zwei Griffe, weil zwei Fragen.</b> „Gibt es am Ende überhaupt eine
/// dunkle Strecke" verlangt einen groben Überblick über zwanzig Minuten;
/// „wo genau fängt sie an" verlangt eine einzelne Stelle. Mit demselben
/// Werkzeug beantwortet wären beide zu teuer: der Überblick fein
/// abgetastet heisst zwanzig Minuten dekodieren, die Stelle grob gesucht
/// heisst zehn Sekunden Ungenauigkeit.
/// </para>
/// </remarks>
public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabtaster> log)
{
/// <summary>Auf welche Breite verkleinert wird, bevor gerechnet wird.</summary>
///
/// <remarks>
/// Für einen Mittelwert über das ganze Bild braucht es keine 1920
/// Spalten. Gemessen: bei 160 Spalten kostet der Filtergraph nichts
/// Nennenswertes mehr, und der Mittelwert ist derselbe.
/// </remarks>
private const string Breite = "160";
/// <summary>Die Helligkeit an den Schlüsselbildern eines Abschnitts.</summary>
///
/// <remarks>
/// <b>Nur Schlüsselbilder, und das ist der ganze Trick.</b> Gemessen an
/// 180 Sekunden 1080p: jedes zweite Bild zu dekodieren kostet 2,89
/// Sekunden, nur die Schlüsselbilder 0,24. Dafür liegen die Messpunkte
/// fünf bis zehn Sekunden auseinander — genug, um eine dunkle Strecke
/// zu finden, zu wenig für ihre Kante. Die holt
/// <see cref="HelligkeitAsync"/> nach.
/// </remarks>
public async Task<IReadOnlyList<Bildprobe>> UeberblickAsync(
string pfad, double ab, double dauer, CancellationToken ct = default)
{
var ausgabe = await LaufenAsync(
pfad, ab, dauer, nurSchluesselbilder: true, TimeSpan.FromMinutes(5), ct);
return Lesen(ausgabe, ab);
}
/// <summary>Die mittlere Helligkeit einer einzelnen Sekunde.</summary>
///
/// <remarks>
/// Alle Bilder, nicht nur Schlüsselbilder: eine Sekunde enthält sonst
/// vielleicht gar keines. Dass ffmpeg dafür vom vorigen Schlüsselbild
/// an dekodieren muss, ist der Preis — bei zehn Sekunden Abstand sind
/// das zehn Sekunden Bildmaterial, also Millisekunden.
/// </remarks>
public async Task<double?> HelligkeitAsync(
string pfad, double bei, CancellationToken ct = default)
{
var ausgabe = await LaufenAsync(
pfad, Math.Max(0, bei), dauer: 1, nurSchluesselbilder: false,
TimeSpan.FromSeconds(60), ct);
var proben = Lesen(ausgabe, 0);
if (proben.Count == 0) return null;
return proben.Average(p => p.Helligkeit);
}
private async Task<string> LaufenAsync(
string pfad, double ab, double dauer, bool nurSchluesselbilder,
TimeSpan frist, CancellationToken ct)
{
var start = new ProcessStartInfo(optionen.Ffmpeg)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
// **Kein Lesen von der Tastatur.** Ohne das greift ffmpeg nach der
// Standardeingabe des Servers und kann einen Dienst anhalten, der
// ohne Terminal läuft.
start.ArgumentList.Add("-nostdin");
start.ArgumentList.Add("-v");
start.ArgumentList.Add("error");
if (nurSchluesselbilder)
{
start.ArgumentList.Add("-skip_frame");
start.ArgumentList.Add("nokey");
}
// **Suchen vor dem Öffnen.** Danach müsste ffmpeg die Datei von
// vorn dekodieren — bei einem Zweistünder zwei Stunden Bild für
// zwanzig Minuten Antwort.
start.ArgumentList.Add("-ss");
start.ArgumentList.Add(ab.ToString("0.###", CultureInfo.InvariantCulture));
start.ArgumentList.Add("-i");
start.ArgumentList.Add(pfad);
start.ArgumentList.Add("-t");
start.ArgumentList.Add(dauer.ToString("0.###", CultureInfo.InvariantCulture));
start.ArgumentList.Add("-an");
start.ArgumentList.Add("-sn");
start.ArgumentList.Add("-vf");
start.ArgumentList.Add(
$"scale={Breite}:-2,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-");
start.ArgumentList.Add("-f");
start.ArgumentList.Add("null");
start.ArgumentList.Add("-");
using var zeit = CancellationTokenSource.CreateLinkedTokenSource(ct);
zeit.CancelAfter(frist);
try
{
using var prozess = Process.Start(start);
if (prozess is null) return "";
var lesen = prozess.StandardOutput.ReadToEndAsync(zeit.Token);
var fehlerLesen = prozess.StandardError.ReadToEndAsync(zeit.Token);
await prozess.WaitForExitAsync(zeit.Token);
var ausgabe = await lesen;
var fehlertext = await fehlerLesen;
if (prozess.ExitCode != 0)
{
log.LogWarning("ffmpeg gab {Code} beim Abtasten von {Pfad} zurück: {Fehler}",
prozess.ExitCode, pfad, fehlertext.Trim());
return "";
}
return ausgabe;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception fehler)
{
log.LogWarning(fehler, "Abtasten von {Pfad} misslungen.", pfad);
return "";
}
}
/// <summary>Liest die Ausgabe des <c>metadata</c>-Filters.</summary>
///
/// <remarks>
/// Sie besteht aus Paaren: eine Zeile mit <c>pts_time</c>, darunter eine
/// mit dem Wert. Der Zeitstempel zählt ab dem Suchpunkt, nicht ab dem
/// Dateianfang — deshalb der Aufschlag.
/// </remarks>
public static IReadOnlyList<Bildprobe> Lesen(string ausgabe, double versatz)
{
var proben = new List<Bildprobe>();
double? zeit = null;
foreach (var zeile in ausgabe.Split('\n'))
{
if (Zeitzeile().Match(zeile) is { Success: true } z
&& double.TryParse(z.Groups[1].Value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var t))
{
zeit = t;
continue;
}
if (Wertzeile().Match(zeile) is { Success: true } w
&& zeit is { } sekunde
&& double.TryParse(w.Groups[1].Value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var wert))
{
proben.Add(new Bildprobe(versatz + sekunde, wert));
zeit = null;
}
}
return proben;
}
[GeneratedRegex(@"pts_time:([\d.]+)")]
private static partial Regex Zeitzeile();
[GeneratedRegex(@"lavfi\.signalstats\.YAVG=([\d.]+)")]
private static partial Regex Wertzeile();
}