Erst fragen, ob die Datei den Abspann selbst benennt

Zweimal gemessen scheitert die Helligkeitssuche am selben: „Alice im
Wunderland" legt den Abspann über verzierte Hintergründe, „(T)Raumschiff
Surprise" zeigt darin Outtakes. Deine Zeile sagt es genau — Schwelle
49,4, letzte Probe 1, dunkle Strecke drei Sekunden: der Film endet
schwarz, und davor ist volles Bild. Helligkeit kann dazu nichts sagen,
und das ist kein Fehler, sondern ihre Grenze.

Ein Kapitel sagt es dagegen genau. Die Suche fragt jetzt zuerst danach:
ffprobe liest dafür nur den Kopf der Datei, Millisekunden gegen zwanzig
Minuten Schlüsselbilder. Findet sie ein benanntes Kapitel am Ende, ist
sie fertig; sonst geht es wie bisher über das Bild.

**Nur bei einem Namen, der es hergibt** — „Chapter 12" ist keine
Auskunft, und das letzte Kapitel ist nicht automatisch der Abspann:
bei vielen Dateien ist es die Schlussszene. Lieber kein Fund als ein
Knopf, der die letzten zehn Minuten Handlung überspringt. „Opening
Credits" bei Minute zwei trägt dasselbe Wort und ist das Gegenteil,
also zählt nur, was im letzten Drittel liegt.

**Und wo nichts gefunden wird, steht jetzt die Kapitellage dabei** —
„Keine Kapitel.", „14 Kapitel, alle ohne Namen." oder „12 Kapitel,
letztes „Finale"." Ob sich an diesem Weg weiterbauen lohnt, sieht man
erst an vielen Dateien, und genau das misst diese Zeile.

Elf Prüfungen: das Lesen der ffprobe-Antwort ohne ffprobe, und die
Regel, wann ein Kapitel zählt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-15 14:48:22 +02:00
co-authored by Claude Opus 5
parent f79ecf2757
commit 121ee62be9
6 changed files with 400 additions and 9 deletions
+44 -9
View File
@@ -390,6 +390,7 @@ public sealed class Markenerkennung(
using var raum = scopes.CreateScope(); using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>(); var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>(); var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
var film = await db.Movies.AsNoTracking() var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == filmId) .Where(m => m.Id == filmId)
@@ -432,7 +433,8 @@ public sealed class Markenerkennung(
await VermerkenAsync(db, filmId, Markenart.Abspann, ct); await VermerkenAsync(db, filmId, Markenart.Abspann, ct);
var gefunden = await BildabspannAsync( var gefunden = await BildabspannAsync(
db, abtaster, filmId, istFilm: true, titel, film.Path, laufzeit.TotalSeconds, ct); db, abtaster, kapitelleser, filmId, istFilm: true, titel, film.Path,
laufzeit.TotalSeconds, ct);
if (!gefunden) if (!gefunden)
{ {
@@ -472,25 +474,46 @@ public sealed class Markenerkennung(
/// ein Pfad beantwortet die Frage „welche Folge war das?" nicht. /// ein Pfad beantwortet die Frage „welche Folge war das?" nicht.
/// </param> /// </param>
private async Task<bool> BildabspannAsync( private async Task<bool> BildabspannAsync(
FlaroContext db, Bildabtaster abtaster, Guid titelId, bool istFilm, FlaroContext db, Bildabtaster abtaster, Kapitelleser kapitelleser,
string name, string pfad, double laufzeit, CancellationToken ct) Guid titelId, bool istFilm, string name, string pfad, double laufzeit,
CancellationToken ct)
{ {
var regeln = Abspannregeln.Vorgabe;
// **Erst fragen, ob die Datei es selbst weiss.** Ein Kapitel kostet
// Millisekunden — ffprobe liest nur den Kopf —, die Helligkeit
// zwanzig Minuten Schlüsselbilder. Und es findet, woran die
// Helligkeit scheitert: einen Abspann über laufendem Bild, wie bei
// „Alice im Wunderland" oder den Outtakes in „(T)Raumschiff
// Surprise".
var kapitel = await kapitelleser.LesenAsync(pfad, ct);
if (Abspannkapitel.Suchen(kapitel, laufzeit, regeln) is { } ausKapitel)
{
log.LogInformation("„{Titel}“: Abspann laut Kapitel ab {Stelle:h\\:mm\\:ss}.",
name, TimeSpan.FromSeconds(ausKapitel));
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann,
ausKapitel, laufzeit, ct);
}
var ab = Math.Max(0, laufzeit - Suchbereich); var ab = Math.Max(0, laufzeit - Suchbereich);
var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct); var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct);
var regeln = Abspannregeln.Vorgabe;
// **Ein Lauf ohne Fund soll sagen, warum.** „Kein Abspann erkannt" // **Ein Lauf ohne Fund soll sagen, warum.** „Kein Abspann erkannt"
// allein ist keine Auskunft: ob die Datei zu wenige Schlüsselbilder // allein ist keine Auskunft: ob die Datei zu wenige Schlüsselbilder
// hat, ob der Abspann zu hell war oder ob am Schluss eine Tafel // hat, ob der Abspann zu hell war oder ob am Schluss eine Tafel
// steht, sieht man nur an diesen drei Zahlen — und sonst gar nicht. // steht, sieht man nur an diesen drei Zahlen — und sonst gar nicht.
if (Abspannkante.Suchen(proben, regeln, out var grund) is not { } stelle) if (Abspannkante.Suchen(proben, regeln, out var grund) is not { } stelle)
{ {
// **Mit der Kapitellage.** Ob eine Datei gar keine Kapitel hat
// oder nur namenlose, entscheidet, ob sich an diesem Weg
// weiterbauen lohnt — und das sieht man nur an vielen Dateien.
log.LogInformation( log.LogInformation(
"„{Titel}“: kein Abspann — {Grund}. {Proben} Probe(n), " "„{Titel}“: kein Abspann — {Grund}. {Proben} Probe(n), "
+ "Schwelle {Schwelle:0.#}, letzte Probe {Letzte:0.#}.", + "Schwelle {Schwelle:0.#}, letzte Probe {Letzte:0.#}. {Kapitel}",
name, grund, proben.Count, Abspannkante.Schwelle(proben, regeln), name, grund, proben.Count, Abspannkante.Schwelle(proben, regeln),
proben.Count > 0 ? proben[^1].Helligkeit : double.NaN); proben.Count > 0 ? proben[^1].Helligkeit : double.NaN,
Kapitellage(kapitel));
return false; return false;
} }
@@ -525,6 +548,7 @@ public sealed class Markenerkennung(
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>(); var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>(); var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>(); var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
var serienname = await db.Series.AsNoTracking() var serienname = await db.Series.AsNoTracking()
.Where(x => x.Id == serieId).Select(x => x.Title).FirstOrDefaultAsync(ct) .Where(x => x.Id == serieId).Select(x => x.Title).FirstOrDefaultAsync(ct)
@@ -713,8 +737,8 @@ public sealed class Markenerkennung(
melden?.Invoke(new Scanschritt(Scanphase.Marken, Name(i))); melden?.Invoke(new Scanschritt(Scanphase.Marken, Name(i)));
if (await BildabspannAsync(db, abtaster, folge.Id, istFilm: false, if (await BildabspannAsync(db, abtaster, kapitelleser, folge.Id,
Name(i), folge.Path, dauer.TotalSeconds, ct)) istFilm: false, Name(i), folge.Path, dauer.TotalSeconds, ct))
{ {
ueberBild.Add(folge.Id); ueberBild.Add(folge.Id);
gefundenAbspann++; gefundenAbspann++;
@@ -795,6 +819,17 @@ public sealed class Markenerkennung(
} }
} }
/// <summary>Was die Datei an Kapiteln mitbringt — in einem Halbsatz.</summary>
private static string Kapitellage(IReadOnlyList<Kapitel> kapitel)
{
if (kapitel.Count == 0) return "Keine Kapitel.";
var benannt = kapitel.Count(k => !string.IsNullOrWhiteSpace(k.Titel));
return benannt == 0
? $"{kapitel.Count} Kapitel, alle ohne Namen."
: $"{kapitel.Count} Kapitel, letztes „{kapitel[^1].Titel}“.";
}
/// <summary>„41:12" oder „1:52:10" — kurz genug für eine Protokollzeile.</summary> /// <summary>„41:12" oder „1:52:10" — kurz genug für eine Protokollzeile.</summary>
private static string Zeit(double sekunden) private static string Zeit(double sekunden)
{ {
+1
View File
@@ -175,6 +175,7 @@ builder.Services.AddSingleton<ScanCoordinator>();
builder.Services.AddSingleton<Tonabdruckleser>(); builder.Services.AddSingleton<Tonabdruckleser>();
// Bei Filmen führt kein Ton zum Abspann — nur das Bild. // Bei Filmen führt kein Ton zum Abspann — nur das Bild.
builder.Services.AddSingleton<Bildabtaster>(); builder.Services.AddSingleton<Bildabtaster>();
builder.Services.AddSingleton<Kapitelleser>();
builder.Services.AddSingleton<Markenerkennung>(); builder.Services.AddSingleton<Markenerkennung>();
builder.Services.AddSingleton<PackageCoordinator>(); builder.Services.AddSingleton<PackageCoordinator>();
@@ -0,0 +1,72 @@
namespace Flaro.Core.Media;
/// <summary>Ein Kapitel, wie es in der Datei steht.</summary>
///
/// <param name="Von">Anfang in Sekunden.</param>
/// <param name="Bis">Ende in Sekunden.</param>
/// <param name="Titel">Was draufsteht — oft leer oder „Chapter 12".</param>
public readonly record struct Kapitel(double Von, double Bis, string Titel);
/// <summary>Findet den Abspann, wenn die Datei ihn selbst benennt.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum neben der Helligkeit.</b> <see cref="Abspannkante"/> findet,
/// was dunkel ist — und daran scheitert sie zweimal gemessen: „Alice im
/// Wunderland" legt den Abspann über verzierte Hintergründe, „(T)Raumschiff
/// Surprise" zeigt darin Outtakes. Beides ist volles Bild. Ein Kapitel
/// sagt es dagegen genau, kostet nichts und ist nicht geraten.
/// </para>
/// <para>
/// <b>Nur, wenn der Name es hergibt.</b> „Chapter 12" ist keine Auskunft,
/// und das letzte Kapitel ist nicht automatisch der Abspann — bei vielen
/// Dateien ist es die Schlussszene. Lieber kein Fund als ein Knopf, der
/// die letzten zehn Minuten Handlung überspringt.
/// </para>
/// </remarks>
public static class Abspannkapitel
{
/// <summary>Woran ein Abspannkapitel zu erkennen ist.</summary>
///
/// <remarks>
/// Englisch und deutsch, weil beides vorkommt — oft in derselben
/// Sammlung. „Ende" steht bewusst nicht dabei: „Das Ende" ist bei
/// einem Film die Schlussszene, nicht der Abspann.
/// </remarks>
private static readonly string[] Woerter =
[
"abspann", "nachspann", "credit", "closing", "outro", "schlusstitel",
];
/// <summary>Ab welchem Anteil der Laufzeit ein Kapitel in Frage kommt.</summary>
///
/// <remarks>
/// Ein Abspann liegt am Ende. Ein Kapitel namens „Opening Credits" bei
/// Minute zwei trägt dasselbe Wort und ist das Gegenteil.
/// </remarks>
private const double Spaetestens = 0.6;
/// <summary>Wo der Abspann laut Datei anfängt — oder <c>null</c>.</summary>
public static double? Suchen(
IReadOnlyList<Kapitel> kapitel, double laufzeit, Abspannregeln regeln)
{
if (laufzeit <= 0) return null;
var frühestens = laufzeit * Spaetestens;
return kapitel
.Where(k => k.Von >= frühestens)
.Where(k => laufzeit - k.Von >= regeln.Mindestlänge)
.Where(k => Passt(k.Titel))
// Der früheste Treffer: ein Abspann, der in „Credits" und
// „Studio-Logo" zerfällt, fängt beim ersten an.
.OrderBy(k => k.Von)
.Select(k => (double?)k.Von)
.FirstOrDefault();
}
private static bool Passt(string titel) =>
!string.IsNullOrWhiteSpace(titel)
&& Array.Exists(Woerter,
w => titel.Contains(w, StringComparison.OrdinalIgnoreCase));
}
+123
View File
@@ -0,0 +1,123 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Flaro.Core.Media;
namespace Flaro.Media;
/// <summary>Liest die Kapitelmarken einer Datei.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum überhaupt.</b> Die Abspannsuche im Bild findet, was dunkel ist
/// — und genau daran scheitert sie zweimal gemessen: „Alice im Wunderland"
/// legt den Abspann über verzierte Hintergründe, „(T)Raumschiff Surprise"
/// zeigt darin Outtakes. Beides ist volles Bild, und Helligkeit sagt dazu
/// nichts.
/// </para>
/// <para>
/// <b>Ein Kapitel sagt es dagegen genau</b> — wenn es eines gibt. Das ist
/// die ganze Unsicherheit: viele Dateien haben keine Kapitel, und von
/// denen, die welche haben, nennen viele sie nur „Chapter 12". Deshalb
/// steht hier nur das Lesen; was daraus folgt, entscheidet der Aufrufer.
/// </para>
/// <para>
/// <b>Billig.</b> ffprobe liest dafür nur den Kopf der Datei, kein Bild.
/// Millisekunden gegen zwanzig Minuten Schlüsselbilder.
/// </para>
/// </remarks>
public sealed class Kapitelleser(FfmpegOptions optionen, ILogger<Kapitelleser> log)
{
public async Task<IReadOnlyList<Kapitel>> LesenAsync(
string pfad, CancellationToken ct = default)
{
var start = new ProcessStartInfo(optionen.Ffprobe)
{
ArgumentList =
{
"-v", "quiet", "-print_format", "json", "-show_chapters", "-i", 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 [];
var lesen = prozess.StandardOutput.ReadToEndAsync(frist.Token);
var fehlerLesen = prozess.StandardError.ReadToEndAsync(frist.Token);
await prozess.WaitForExitAsync(frist.Token);
var ausgabe = await lesen;
await fehlerLesen;
return prozess.ExitCode == 0 ? Lesen(ausgabe) : [];
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception fehler)
{
log.LogWarning(fehler, "Kapitel von {Pfad} liessen sich nicht lesen.", pfad);
return [];
}
}
/// <summary>Die Antwort von <c>-show_chapters</c> auswerten.</summary>
///
/// <remarks>
/// Getrennt vom Aufruf, damit es sich ohne ffprobe prüfen lässt — wie
/// bei <c>Bildabtaster.Lesen</c> und <c>Tonabdruckleser.Lesen</c>.
/// </remarks>
public static IReadOnlyList<Kapitel> Lesen(string json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try
{
using var papier = JsonDocument.Parse(json);
if (!papier.RootElement.TryGetProperty("chapters", out var liste)
|| liste.ValueKind != JsonValueKind.Array)
{
return [];
}
var kapitel = new List<Kapitel>();
foreach (var eintrag in liste.EnumerateArray())
{
if (!Zeit(eintrag, "start_time", out var von)) continue;
if (!Zeit(eintrag, "end_time", out var bis)) bis = von;
var titel = eintrag.TryGetProperty("tags", out var marken)
&& marken.TryGetProperty("title", out var name)
? name.GetString() ?? ""
: "";
kapitel.Add(new Kapitel(von, bis, titel));
}
return kapitel;
}
catch (JsonException)
{
return [];
}
}
private static bool Zeit(JsonElement eintrag, string feld, out double wert)
{
wert = 0;
return eintrag.TryGetProperty(feld, out var roh)
&& roh.ValueKind == JsonValueKind.String
&& double.TryParse(roh.GetString(), NumberStyles.Float,
CultureInfo.InvariantCulture, out wert);
}
}
@@ -0,0 +1,106 @@
using Flaro.Core.Media;
namespace Flaro.Core.Tests;
/// <summary>Wann ein Kapitel der Abspann ist — und wann nicht.</summary>
///
/// <remarks>
/// <b>Der Anlass.</b> Die Helligkeitssuche findet, was dunkel ist, und
/// scheitert zweimal gemessen an genau dem Gegenteil: „Alice im
/// Wunderland" legt den Abspann über verzierte Hintergründe,
/// „(T)Raumschiff Surprise" zeigt darin Outtakes — Schwelle 49,4, letzte
/// Probe 1, und die dunkle Strecke war drei Sekunden lang. Ein Kapitel
/// sagt es genau, wenn die Datei eines hat.
/// </remarks>
public class AbspannkapitelTests
{
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
private const double Laufzeit = 7200;
[Test]
public void Ein_benanntes_Kapitel_am_Ende_zaehlt()
{
var kapitel = new List<Kapitel>
{
new(0, 600, "Opening"),
new(600, 6600, "Der Film"),
new(6600, 7200, "End Credits"),
};
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.EqualTo(6600));
}
/// <summary>Deutsch genauso — beides kommt in derselben Sammlung vor.</summary>
[Test]
public void Abspann_auf_Deutsch_auch()
{
var kapitel = new List<Kapitel> { new(6900, 7200, "Abspann") };
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.EqualTo(6900));
}
/// <summary>„Chapter 12" ist keine Auskunft.</summary>
///
/// <remarks>
/// Das letzte Kapitel ist nicht automatisch der Abspann — bei vielen
/// Dateien ist es die Schlussszene. Lieber kein Fund als ein Knopf,
/// der die letzten zehn Minuten Handlung überspringt.
/// </remarks>
[Test]
public void Namenlose_Kapitel_zaehlen_nicht()
{
var kapitel = new List<Kapitel>
{
new(0, 3600, "Chapter 1"),
new(3600, 7200, "Chapter 2"),
};
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.Null);
}
/// <summary>„Opening Credits" trägt dasselbe Wort und ist das Gegenteil.</summary>
[Test]
public void Ein_Vorspannkapitel_zaehlt_nicht()
{
var kapitel = new List<Kapitel> { new(60, 180, "Opening Credits") };
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.Null);
}
/// <summary>Ein Abspannkapitel von zehn Sekunden ist keins.</summary>
[Test]
public void Zu_kurz_zaehlt_nicht()
{
var kapitel = new List<Kapitel> { new(7190, 7200, "Credits") };
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.Null);
}
/// <summary>Zerfällt er in zwei, gilt der erste.</summary>
[Test]
public void Der_frueheste_Treffer_gewinnt()
{
var kapitel = new List<Kapitel>
{
new(6600, 7100, "Closing Credits"),
new(7100, 7200, "Studio-Logo mit Credits"),
};
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.EqualTo(6600));
}
[Test]
public void Ohne_Kapitel_kein_Fund()
{
Assert.That(Abspannkapitel.Suchen([], Laufzeit, Regeln), Is.Null);
}
/// <summary>Ohne Laufzeit lässt sich „am Ende" nicht beantworten.</summary>
[Test]
public void Ohne_Laufzeit_kein_Fund()
{
var kapitel = new List<Kapitel> { new(6600, 7200, "Credits") };
Assert.That(Abspannkapitel.Suchen(kapitel, 0, Regeln), Is.Null);
}
}
@@ -0,0 +1,54 @@
using Flaro.Core.Media;
using Flaro.Media;
namespace Flaro.Media.Tests;
/// <summary>Die Antwort von <c>ffprobe -show_chapters</c> lesen — ohne ffprobe.</summary>
public class KapitelleserTests
{
[Test]
public void Kapitel_mit_Namen_und_Zeiten()
{
var json = """
{
"chapters": [
{ "id": 0, "start_time": "0.000000", "end_time": "600.000000",
"tags": { "title": "Anfang" } },
{ "id": 1, "start_time": "6600.000000", "end_time": "7200.000000",
"tags": { "title": "End Credits" } }
]
}
""";
var kapitel = Kapitelleser.Lesen(json);
Assert.That(kapitel, Is.EqualTo(new[]
{
new Kapitel(0, 600, "Anfang"),
new Kapitel(6600, 7200, "End Credits"),
}));
}
/// <summary>Ohne <c>tags</c> bleibt der Name leer — und das ist kein Fehler.</summary>
[Test]
public void Ein_Kapitel_ohne_Namen_kommt_trotzdem_an()
{
var json = """
{ "chapters": [ { "start_time": "10.5", "end_time": "20.25" } ] }
""";
Assert.That(Kapitelleser.Lesen(json), Is.EqualTo(new[] { new Kapitel(10.5, 20.25, "") }));
}
[Test]
public void Eine_Datei_ohne_Kapitel_ergibt_nichts()
{
Assert.Multiple(() =>
{
Assert.That(Kapitelleser.Lesen("""{ "chapters": [] }"""), Is.Empty);
Assert.That(Kapitelleser.Lesen("{}"), Is.Empty);
Assert.That(Kapitelleser.Lesen(""), Is.Empty);
Assert.That(Kapitelleser.Lesen("kein JSON"), Is.Empty);
});
}
}