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:
co-authored by
Claude Opus 5
parent
f79ecf2757
commit
121ee62be9
@@ -390,6 +390,7 @@ public sealed class Markenerkennung(
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
|
||||
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
|
||||
|
||||
var film = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.Id == filmId)
|
||||
@@ -432,7 +433,8 @@ public sealed class Markenerkennung(
|
||||
await VermerkenAsync(db, filmId, Markenart.Abspann, ct);
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -472,25 +474,46 @@ public sealed class Markenerkennung(
|
||||
/// ein Pfad beantwortet die Frage „welche Folge war das?" nicht.
|
||||
/// </param>
|
||||
private async Task<bool> BildabspannAsync(
|
||||
FlaroContext db, Bildabtaster abtaster, Guid titelId, bool istFilm,
|
||||
string name, string pfad, double laufzeit, CancellationToken ct)
|
||||
FlaroContext db, Bildabtaster abtaster, Kapitelleser kapitelleser,
|
||||
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 proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct);
|
||||
|
||||
var regeln = Abspannregeln.Vorgabe;
|
||||
|
||||
// **Ein Lauf ohne Fund soll sagen, warum.** „Kein Abspann erkannt"
|
||||
// allein ist keine Auskunft: ob die Datei zu wenige Schlüsselbilder
|
||||
// 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.
|
||||
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(
|
||||
"„{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),
|
||||
proben.Count > 0 ? proben[^1].Helligkeit : double.NaN);
|
||||
proben.Count > 0 ? proben[^1].Helligkeit : double.NaN,
|
||||
Kapitellage(kapitel));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -525,6 +548,7 @@ public sealed class Markenerkennung(
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
|
||||
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
|
||||
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
|
||||
|
||||
var serienname = await db.Series.AsNoTracking()
|
||||
.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)));
|
||||
|
||||
if (await BildabspannAsync(db, abtaster, folge.Id, istFilm: false,
|
||||
Name(i), folge.Path, dauer.TotalSeconds, ct))
|
||||
if (await BildabspannAsync(db, abtaster, kapitelleser, folge.Id,
|
||||
istFilm: false, Name(i), folge.Path, dauer.TotalSeconds, ct))
|
||||
{
|
||||
ueberBild.Add(folge.Id);
|
||||
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>
|
||||
private static string Zeit(double sekunden)
|
||||
{
|
||||
|
||||
@@ -175,6 +175,7 @@ builder.Services.AddSingleton<ScanCoordinator>();
|
||||
builder.Services.AddSingleton<Tonabdruckleser>();
|
||||
// Bei Filmen führt kein Ton zum Abspann — nur das Bild.
|
||||
builder.Services.AddSingleton<Bildabtaster>();
|
||||
builder.Services.AddSingleton<Kapitelleser>();
|
||||
builder.Services.AddSingleton<Markenerkennung>();
|
||||
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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user