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
+10 -1
View File
@@ -475,7 +475,16 @@ public sealed record MovieResponse(
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
/// gilt wie bisher der Hintergrund.
/// </remarks>
string? HeroImage = null);
string? HeroImage = null,
/// <summary>Der Abspann, sofern er im Bild gefunden wurde.</summary>
///
/// <remarks>
/// Bei einem Film gibt es keinen Vorspann zu finden — erkannt wird er
/// daran, dass er sich über die Folgen einer Staffel wiederholt, und
/// ein Film hat nichts, womit er sich vergleichen liesse.
/// </remarks>
IReadOnlyList<MarkerResponse>? Markers = null);
public sealed record CreditResponse(
Guid Id, string Name, string Role, string? Character, string? Image);
@@ -151,12 +151,37 @@ public static class LibraryContentEndpoints
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
var roh = await db.Marken.AsNoTracking()
.Where(m => folgen.Contains(m.EpisodeId))
.Select(m => new { m.EpisodeId, m.Art, m.VonSekunden, m.BisSekunden })
.Where(m => m.EpisodeId != null && folgen.Contains(m.EpisodeId!.Value))
.Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync();
return roh.ToLookup(
m => m.EpisodeId,
m => m.Id,
m => new MarkerResponse(
m.Art == Markenart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
}
/// <summary>Dasselbe für Filme.</summary>
///
/// <remarks>
/// Getrennt und nicht über eine gemeinsame Kennung: eine Marke gehört
/// entweder zu einer Folge oder zu einem Film, und eine Abfrage, die
/// beides zugleich sucht, fände zu einer Filmkennung auch eine Folge
/// mit derselben — unmöglich heute, aber nichts hält es fest.
/// </remarks>
private static async Task<ILookup<Guid, MarkerResponse>> FilmmarkenZu(
FlaroContext db, IReadOnlyCollection<Guid> filme)
{
if (filme.Count == 0)
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
var roh = await db.Marken.AsNoTracking()
.Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value))
.Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync();
return roh.ToLookup(
m => m.Id,
m => new MarkerResponse(
m.Art == Markenart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
}
@@ -549,6 +574,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: ids);
var marken = await FilmmarkenZu(db, ids);
var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
@@ -561,7 +587,8 @@ public static class LibraryContentEndpoints
TitlePinned: m.TitlePinned,
SortTitle: m.SortTitleValue,
SortTitlePinned: m.SortTitlePinned,
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow)))
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow),
Markers: marken[m.Id].ToList()))
.ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt));
@@ -756,7 +783,8 @@ public static class LibraryContentEndpoints
PublishAt: film.PublishAt,
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null),
LibraryId: film.LibraryId,
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow)));
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow),
Markers: (await FilmmarkenZu(db, [id]))[id].ToList()));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async (
@@ -852,6 +880,29 @@ public static class LibraryContentEndpoints
// Der Lauf dauert Minuten: er liest von jeder Folge einer Staffel
// ein Stück Ton und vergleicht die Abdrücke. Deshalb 202 und ein
// Hintergrundlauf statt einer Antwort, auf die jemand wartet.
// Dasselbe für einen Film — dort sucht der Server im Bild.
app.MapPost("/api/v1/movies/{id:guid}/marken", async (
Guid id, FlaroContext db, Markenerkennung erkennung, bool erneut = false) =>
{
if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
_ = erkennung.FilmAsync(id, erneut);
return Results.Accepted();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Eine Marke wieder wegnehmen.**
//
// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal
// recht hat: ein Fehlgriff bliebe stehen, und niemand könnte etwas
// dagegen tun ausser die Datenbank anzufassen.
app.MapDelete("/api/v1/movies/{id:guid}/marken", async (Guid id, FlaroContext db) =>
{
var weg = await db.Marken.Where(m => m.MovieId == id).ExecuteDeleteAsync();
// Der Vermerk bleibt: „gesucht" stimmt weiterhin, und ohne ihn
// legte der nächste Scan dieselbe falsche Marke wieder an.
return weg > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPost("/api/v1/series/{id:guid}/marken", async (
Guid id, FlaroContext db, Markenerkennung erkennung, bool erneut = false) =>
{
@@ -105,6 +105,11 @@ public static class PlaybackEndpoints
.Where(m => m.EpisodeId == folge && m.Art == Markenart.Abspann)
.Select(m => (double?)m.VonSekunden)
.FirstOrDefaultAsync()
: zustand.MovieId is { } film
? await db.Marken.AsNoTracking()
.Where(m => m.MovieId == film && m.Art == Markenart.Abspann)
.Select(m => (double?)m.VonSekunden)
.FirstOrDefaultAsync()
: null;
var ergebnis = ProgressRule.Apply(
+137 -5
View File
@@ -47,6 +47,14 @@ public sealed class Markenerkennung(
/// <summary>Wie viel vom Ende nach einem Abspann abgesucht wird.</summary>
public const double Abspannfenster = 10 * 60;
/// <summary>Wie weit vom Ende eines Films nach dem Abspann gesucht wird.</summary>
///
/// <remarks>
/// Zwanzig Minuten. „Die letzten zehn Prozent" wäre bei einem
/// Dreistünder zu viel und bei einem Kurzfilm zu wenig.
/// </remarks>
public const double Suchbereich = 20 * 60;
/// <summary>Was ein gefundenes Stück höchstens lang sein darf.</summary>
///
/// <remarks>
@@ -130,9 +138,112 @@ public sealed class Markenerkennung(
finally { _laufend.TryRemove(serie, out _); }
}
await FilmeAsync(db, mediathek, ct);
log.LogInformation("Marken für Mediathek {Kennung} durch.", mediathek);
}
/// <summary>Die Filme einer Mediathek, denen noch ein Abspann fehlt.</summary>
///
/// <remarks>
/// Getrennt von den Serien, weil der Weg ein anderer ist — und weil
/// eine Mediathek immer nur eines von beidem enthält. Die Schleife
/// läuft dann ins Leere, und das kostet eine Abfrage.
/// </remarks>
private async Task FilmeAsync(FlaroContext db, Guid mediathek, CancellationToken ct)
{
var offen = await db.Movies.AsNoTracking()
.Where(m => m.LibraryId == mediathek)
.Where(m => !db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann))
.Select(m => m.Id)
.ToListAsync(ct);
if (offen.Count == 0) return;
log.LogInformation("Suche den Abspann in {Anzahl} Filmen.", offen.Count);
var gefunden = 0;
foreach (var film in offen)
{
if (ct.IsCancellationRequested) return;
try { if (await FilmAsync(film, erneut: false, ct)) gefunden++; }
catch (Exception fehler)
{
log.LogWarning(fehler, "Abspannsuche für Film {Film} abgebrochen.", film);
}
}
log.LogInformation("Abspann in {Gefunden} von {Anzahl} Filmen gefunden.",
gefunden, offen.Count);
}
/// <summary>Sucht den Abspann eines Films — im Bild.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum hier ein anderer Weg.</b> Bei einer Folge verrät sich der
/// Abspann dadurch, dass er in jeder Folge derselbe ist. Ein Film hat
/// nichts, womit er sich vergleichen liesse — sein Abspann kommt genau
/// einmal vor. Was bleibt, ist, dass er dunkel ist und bis zum Ende
/// durchhält.
/// </para>
/// <para>
/// <b>Erst grob, dann zielen.</b> Ein Überblick über die letzten zwanzig
/// Minuten sagt, <i>ob</i> es eine dunkle Strecke gibt; er kommt aus
/// Schlüsselbildern und ist deshalb billig, aber auf fünf bis zehn
/// Sekunden ungenau. Die Kante holt danach eine Handvoll kurzer Sonden.
/// </para>
/// </remarks>
public async Task<bool> FilmAsync(Guid filmId, bool erneut, CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == filmId)
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
(m, d) => new { m.Id, d.Path, d.Duration })
.FirstOrDefaultAsync(ct);
// Ohne Datei nichts abzutasten, ohne Laufzeit kein Ende zu finden.
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit) return false;
if (!erneut && await db.Markensuchen.AsNoTracking()
.AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct))
return false;
var ab = Math.Max(0, laufzeit.TotalSeconds - Suchbereich);
var proben = await abtaster.UeberblickAsync(
film.Path, ab, laufzeit.TotalSeconds - ab, ct);
await VermerkenAsync(db, filmId, Markenart.Abspann, ct);
var regeln = Abspannregeln.Vorgabe;
var grob = Abspannkante.Suchen(proben, regeln);
if (grob is { } stelle)
{
var hell = Abspannkante.HellDavor(proben, stelle, regeln) ?? ab;
var kante = await Abspannkante.VerfeinernAsync(
hell, stelle, regeln, wo => abtaster.HelligkeitAsync(film.Path, wo, ct));
await SchreibenAsync(db, filmId, istFilm: true, Markenart.Abspann,
kante, laufzeit.TotalSeconds, ct);
}
else
{
// Nichts gefunden ist ein Ergebnis. Eine alte Marke, die jetzt
// nicht mehr bestätigt wird, gehört weg — sonst bliebe ein
// Fehlgriff für immer stehen.
var alt = await db.Marken
.FirstOrDefaultAsync(m => m.MovieId == filmId && m.Art == Markenart.Abspann, ct);
if (alt is not null) db.Marken.Remove(alt);
}
await db.SaveChangesAsync(ct);
return grob is not null;
}
private async Task LaufenAsync(Guid serieId, bool erneut, CancellationToken ct)
{
using var raum = scopes.CreateScope();
@@ -223,7 +334,7 @@ public sealed class Markenerkennung(
// Ende an; ohne diesen Aufschlag stünde der Knopf bei
// Minute zwei.
var versatz = fensteranfang.GetValueOrDefault(folgenId);
if (await SchreibenAsync(db, folgenId, art,
if (await SchreibenAsync(db, folgenId, istFilm: false, art,
stelle.Von + versatz, stelle.Bis + versatz, ct))
gefunden++;
}
@@ -253,6 +364,24 @@ public sealed class Markenerkennung(
return art == Markenart.Intro ? (0, fenster) : (werte - fenster, fenster);
}
/// <summary>Hält fest, dass bei diesem Film gesucht wurde.</summary>
private async Task VermerkenAsync(
FlaroContext db, Guid filmId, Markenart art, CancellationToken ct)
{
var alt = await db.Markensuchen
.FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct);
if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; }
db.Markensuchen.Add(new Markensuche
{
MovieId = filmId,
Art = art,
Folgen = 1,
GelaufenAm = clock.GetUtcNow(),
});
}
/// <summary>Hält fest, dass in dieser Staffel gesucht wurde.</summary>
private async Task VermerkenAsync(
FlaroContext db, Guid serieId, int staffel, Markenart art, int folgen, CancellationToken ct)
@@ -278,10 +407,12 @@ public sealed class Markenerkennung(
}
private async Task<bool> SchreibenAsync(
FlaroContext db, Guid folgenId, Markenart art, double von, double bis, CancellationToken ct)
FlaroContext db, Guid titelId, bool istFilm, Markenart art,
double von, double bis, CancellationToken ct)
{
var alt = await db.Marken
.FirstOrDefaultAsync(m => m.EpisodeId == folgenId && m.Art == art, ct);
var alt = istFilm
? await db.Marken.FirstOrDefaultAsync(m => m.MovieId == titelId && m.Art == art, ct)
: await db.Marken.FirstOrDefaultAsync(m => m.EpisodeId == titelId && m.Art == art, ct);
if (alt is not null)
{
@@ -293,7 +424,8 @@ public sealed class Markenerkennung(
db.Marken.Add(new Marke
{
EpisodeId = folgenId,
EpisodeId = istFilm ? null : titelId,
MovieId = istFilm ? titelId : null,
Art = art,
VonSekunden = von,
BisSekunden = bis,
+2
View File
@@ -173,6 +173,8 @@ builder.Services.AddSingleton<ScanCoordinator>();
// Vorspann und Abspann: der Leser ist zustandslos, die Erkennung hält den
// Vermerk, welche Serie gerade abgetastet wird.
builder.Services.AddSingleton<Tonabdruckleser>();
// Bei Filmen führt kein Ton zum Abspann — nur das Bild.
builder.Services.AddSingleton<Bildabtaster>();
builder.Services.AddSingleton<Markenerkennung>();
builder.Services.AddSingleton<PackageCoordinator>();
@@ -151,7 +151,17 @@ public enum Markenart
public sealed class Marke
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid EpisodeId { get; init; }
/// <summary>Zu welcher Folge sie gehört — oder zu welchem Film.</summary>
///
/// <remarks>
/// <b>Genau eines von beiden.</b> Gefunden werden sie auf
/// verschiedenen Wegen: bei einer Folge am Ton, weil der Vorspann sich
/// über die Staffel wiederholt, bei einem Film am Bild, weil ein Film
/// nichts hat, womit er sich vergleichen liesse. Was dabei herauskommt,
/// ist dasselbe — eine Stelle, an der man springen darf.
/// </remarks>
public Guid? EpisodeId { get; init; }
public Guid? MovieId { get; init; }
public required Markenart Art { get; init; }
public required double VonSekunden { get; set; }
public required double BisSekunden { get; set; }
@@ -188,11 +198,22 @@ public sealed class Marke
public sealed class Markensuche
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid SeriesId { get; init; }
public required int Staffel { get; init; }
/// <summary>Eine Staffel einer Serie — oder ein Film.</summary>
///
/// <remarks>
/// Genau eines von beiden, wie bei <see cref="Marke"/>. Bei einer
/// Staffel steht die Nummer daneben; ein Film hat keine.
/// </remarks>
public Guid? SeriesId { get; init; }
public int? Staffel { get; init; }
public Guid? MovieId { get; init; }
public required Markenart Art { get; init; }
/// <summary>Wie viele Folgen die Staffel hatte, als gesucht wurde.</summary>
///
/// <remarks>Bei einem Film immer eins — es gibt nichts zu zählen.</remarks>
public required int Folgen { get; set; }
public DateTimeOffset GelaufenAm { get; set; }
+161
View File
@@ -0,0 +1,161 @@
namespace Flaro.Core.Media;
/// <summary>Ein Messpunkt aus dem Bild: wann, und wie hell.</summary>
///
/// <param name="Sekunde">Die Stelle im Film.</param>
/// <param name="Helligkeit">Mittlere Leuchtdichte, 0 bis 255.</param>
public readonly record struct Bildprobe(double Sekunde, double Helligkeit);
/// <summary>Die Schwellen, nach denen eine dunkle Strecke ein Abspann ist.</summary>
///
/// <remarks>
/// <b>Alle an einer Stelle, damit man sie gemeinsam verschieben kann.</b>
/// Jede einzelne ist eine Setzung; verstreut über drei Dateien liesse sich
/// nicht mehr sagen, warum ein Fund herausfiel.
/// </remarks>
public readonly record struct Abspannregeln
{
/// <summary>Ab wann ein Bild als dunkel gilt (0–255).</summary>
///
/// <remarks>
/// Gemessen an einem erzeugten Film: Handlung 125, Abspann 18.
/// Fünfunddreissig liegt weit von beiden.
/// </remarks>
public double Dunkelschwelle { get; init; }
/// <summary>Wie viele helle Proben am Stück eine Strecke nicht brechen.</summary>
///
/// <remarks>
/// Eine Schrifttafel blitzt hell auf, ein Logo steht zwei Sekunden im
/// Bild. Ohne Nachsicht zerfiele ein Abspann in fünf kurze Strecken,
/// von denen keine lang genug wäre.
/// </remarks>
public int Lücke { get; init; }
public double Mindestlänge { get; init; }
public double Höchstlänge { get; init; }
public static Abspannregeln Vorgabe => new()
{
Dunkelschwelle = 35,
Lücke = 2,
Mindestlänge = 45,
Höchstlänge = 12 * 60,
};
}
/// <summary>Findet, wo der Abspann eines Films anfängt.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum das Bild und nicht der Ton.</b> Bei einer Serie erkennt man
/// einen Abspann daran, dass er in jeder Folge derselbe ist. Ein Film hat
/// nichts, womit er sich vergleichen liesse — sein Abspann kommt genau
/// einmal vor. Was bleibt, ist, dass er dunkel ist.
/// </para>
/// <para>
/// <b>Gesucht wird nicht „ein dunkles Bild", sondern die Kante.</b> Dunkle
/// Einzelbilder gibt es in jedem Film. Ein Abspann ist eine dunkle Strecke,
/// die bis zum Ende durchhält — und genau das Durchhalten ist das Merkmal,
/// nicht die Dunkelheit.
/// </para>
/// <para>
/// <b>Reine Rechnung, ohne Datei.</b> Das Abtasten braucht ffmpeg; das
/// Beurteilen braucht nichts. Getrennt lässt sich das Schwierige prüfen,
/// ohne einen Film vorzuhalten — dieselbe Naht wie bei
/// <see cref="Tonabgleich"/>.
/// </para>
/// </remarks>
public static class Abspannkante
{
/// <summary>Die dunkle Strecke, die bis zum Ende durchhält — oder nichts.</summary>
///
/// <param name="proben">
/// Messpunkte über das Ende des Films, aufsteigend nach Zeit. Sie dürfen
/// ungleichmässig liegen: Schlüsselbilder tun das.
/// </param>
public static double? Suchen(IReadOnlyList<Bildprobe> proben, Abspannregeln regeln)
{
if (proben.Count < 2) return null;
// **Von hinten.** Der Abspann endet am Dateiende; alles, was davor
// dunkel war und wieder hell wurde, war eine Schwarzblende.
var letzte = proben.Count - 1;
if (proben[letzte].Helligkeit >= regeln.Dunkelschwelle) return null;
var anfang = letzte;
var hellAmStück = 0;
for (var i = letzte - 1; i >= 0; i--)
{
if (proben[i].Helligkeit < regeln.Dunkelschwelle)
{
anfang = i;
hellAmStück = 0;
continue;
}
if (++hellAmStück > regeln.Lücke) break;
}
var länge = proben[letzte].Sekunde - proben[anfang].Sekunde;
if (länge < regeln.Mindestlänge || länge > regeln.Höchstlänge) return null;
// **Die erste dunkle Probe ist nicht der Anfang.** Zwischen ihr und
// der letzten hellen davor liegt die Kante irgendwo; wo genau, sagt
// erst das Nachmessen. Hier steht die späteste Stelle, an der sie
// liegen kann — und die ist als Marke schon brauchbar.
return proben[anfang].Sekunde;
}
/// <summary>Die letzte helle Probe vor dem Fund — der Suchbereich fürs Nachmessen.</summary>
public static double? HellDavor(
IReadOnlyList<Bildprobe> proben, double dunkelAb, Abspannregeln regeln)
{
double? letzteHelle = null;
foreach (var p in proben)
{
if (p.Sekunde >= dunkelAb) break;
if (p.Helligkeit >= regeln.Dunkelschwelle) letzteHelle = p.Sekunde;
}
return letzteHelle;
}
/// <summary>Sucht die Kante zwischen hell und dunkel genauer ein.</summary>
///
/// <remarks>
/// <para>
/// <b>Binäre Suche mit kurzen Sonden, nicht noch ein Durchlauf.</b> Die
/// grobe Reihe stammt aus Schlüsselbildern, und die liegen je nach
/// Aufnahme fünf bis zehn Sekunden auseinander. Den ganzen Bereich fein
/// abzutasten hiesse, ihn zu dekodieren; zehn Sonden von je einer
/// Sekunde kosten einen Bruchteil davon. Denselben Weg geht Jellyfins
/// Intro Skipper, und hört bei denselben vier Sekunden auf.
/// </para>
/// <para>
/// <paramref name="messen"/> liefert die Helligkeit an einer Stelle oder
/// <c>null</c>, wenn dort nichts zu holen war. Eine Sonde ohne Antwort
/// beendet die Suche — dann gilt, was die grobe Reihe sagte.
/// </para>
/// </remarks>
public static async Task<double> VerfeinernAsync(
double hell, double dunkel, Abspannregeln regeln,
Func<double, Task<double?>> messen, double genauigkeit = 4)
{
if (genauigkeit <= 0) genauigkeit = 1;
while (dunkel - hell > genauigkeit)
{
var mitte = hell + (dunkel - hell) / 2;
var wert = await messen(mitte);
if (wert is not { } helligkeit) return dunkel;
if (helligkeit < regeln.Dunkelschwelle) dunkel = mitte;
else hell = mitte;
}
// Die dunkle Seite gewinnt: lieber ein paar Sekunden Abspann zu viel
// überspringen als eine Sekunde Handlung.
return dunkel;
}
}
+9 -3
View File
@@ -311,10 +311,12 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
b.Entity<Marke>(e =>
{
e.HasKey(x => x.Id);
// Eine Art einmal je Folge: ein zweiter Lauf schreibt um, statt
// Eine Art einmal je Titel: ein zweiter Lauf schreibt um, statt
// eine zweite Zeile anzulegen.
e.HasIndex(x => new { x.EpisodeId, x.Art }).IsUnique();
e.HasIndex(x => new { x.EpisodeId, x.Art }).IsUnique().HasFilter("\"EpisodeId\" IS NOT NULL");
e.HasIndex(x => new { x.MovieId, x.Art }).IsUnique().HasFilter("\"MovieId\" IS NOT NULL");
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
// Als Zahl, nicht als Text: die Namen stehen im Quelltext und
// sollen sich umbenennen lassen, ohne die Datenbank anzufassen.
e.Property(x => x.Art).HasConversion<int>();
@@ -323,8 +325,12 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
b.Entity<Markensuche>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.SeriesId, x.Staffel, x.Art }).IsUnique();
e.HasIndex(x => new { x.SeriesId, x.Staffel, x.Art }).IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
e.HasIndex(x => new { x.MovieId, x.Art }).IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.Property(x => x.Art).HasConversion<int>();
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Filmmarken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Markensuchen_SeriesId_Staffel_Art",
table: "Markensuchen");
migrationBuilder.DropIndex(
name: "IX_Marken_EpisodeId_Art",
table: "Marken");
migrationBuilder.AlterColumn<int>(
name: "Staffel",
table: "Markensuchen",
type: "INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AlterColumn<Guid>(
name: "SeriesId",
table: "Markensuchen",
type: "TEXT",
nullable: true,
oldClrType: typeof(Guid),
oldType: "TEXT");
migrationBuilder.AddColumn<Guid>(
name: "MovieId",
table: "Markensuchen",
type: "TEXT",
nullable: true);
migrationBuilder.AlterColumn<Guid>(
name: "EpisodeId",
table: "Marken",
type: "TEXT",
nullable: true,
oldClrType: typeof(Guid),
oldType: "TEXT");
migrationBuilder.AddColumn<Guid>(
name: "MovieId",
table: "Marken",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Markensuchen_MovieId_Art",
table: "Markensuchen",
columns: new[] { "MovieId", "Art" },
unique: true,
filter: "\"MovieId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Markensuchen_SeriesId_Staffel_Art",
table: "Markensuchen",
columns: new[] { "SeriesId", "Staffel", "Art" },
unique: true,
filter: "\"SeriesId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Marken_EpisodeId_Art",
table: "Marken",
columns: new[] { "EpisodeId", "Art" },
unique: true,
filter: "\"EpisodeId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Marken_MovieId_Art",
table: "Marken",
columns: new[] { "MovieId", "Art" },
unique: true,
filter: "\"MovieId\" IS NOT NULL");
migrationBuilder.AddForeignKey(
name: "FK_Marken_Movies_MovieId",
table: "Marken",
column: "MovieId",
principalTable: "Movies",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Markensuchen_Movies_MovieId",
table: "Markensuchen",
column: "MovieId",
principalTable: "Movies",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Marken_Movies_MovieId",
table: "Marken");
migrationBuilder.DropForeignKey(
name: "FK_Markensuchen_Movies_MovieId",
table: "Markensuchen");
migrationBuilder.DropIndex(
name: "IX_Markensuchen_MovieId_Art",
table: "Markensuchen");
migrationBuilder.DropIndex(
name: "IX_Markensuchen_SeriesId_Staffel_Art",
table: "Markensuchen");
migrationBuilder.DropIndex(
name: "IX_Marken_EpisodeId_Art",
table: "Marken");
migrationBuilder.DropIndex(
name: "IX_Marken_MovieId_Art",
table: "Marken");
migrationBuilder.DropColumn(
name: "MovieId",
table: "Markensuchen");
migrationBuilder.DropColumn(
name: "MovieId",
table: "Marken");
migrationBuilder.AlterColumn<int>(
name: "Staffel",
table: "Markensuchen",
type: "INTEGER",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
migrationBuilder.AlterColumn<Guid>(
name: "SeriesId",
table: "Markensuchen",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.AlterColumn<Guid>(
name: "EpisodeId",
table: "Marken",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Markensuchen_SeriesId_Staffel_Art",
table: "Markensuchen",
columns: new[] { "SeriesId", "Staffel", "Art" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Marken_EpisodeId_Art",
table: "Marken",
columns: new[] { "EpisodeId", "Art" },
unique: true);
}
}
}
@@ -418,19 +418,27 @@ namespace Flaro.Data.Migrations
b.Property<double>("BisSekunden")
.HasColumnType("REAL");
b.Property<Guid>("EpisodeId")
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("ErkanntAm")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<double>("VonSekunden")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Art")
.IsUnique();
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("MovieId", "Art")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.ToTable("Marken");
});
@@ -450,16 +458,24 @@ namespace Flaro.Data.Migrations
b.Property<long>("GelaufenAm")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Staffel")
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<int?>("Staffel")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MovieId", "Art")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Staffel", "Art")
.IsUnique();
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Markensuchen");
});
@@ -1194,17 +1210,25 @@ namespace Flaro.Data.Migrations
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Markensuche", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
+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();
}
+31 -1
View File
@@ -42,13 +42,43 @@ public sealed class Schluesselbilder(FfmpegOptions optionen, ILogger<Schluesselb
/// </remarks>
private static readonly TimeSpan Rueckschau = TimeSpan.FromSeconds(30);
/// <summary>Wie weit über den Wunsch hinaus gelesen wird.</summary>
///
/// <remarks>
/// <para>
/// <b>Ohne diesen Nachlauf fehlte das letzte Schlüsselbild.</b> Hier
/// stand eine halbe Sekunde, und ffprobe hörte damit regelmässig eine
/// ganze Bildgruppe zu früh auf: <c>-read_intervals</c> bricht am Ende
/// des Fensters auf Paketebene ab, und das letzte Bild darin kam gar
/// nicht mehr heraus.
/// </para>
/// <para>
/// Nachgemessen an einer Datei mit Schlüsselbildern alle vier Sekunden,
/// gegen die Stelle, an der ffmpeg tatsächlich anfängt:
/// </para>
/// <code>
/// 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
/// </code>
/// <para>
/// Zu früh gemeldet heisst: der Spieler glaubt, früher zu stehen, als er
/// steht — und die Untertitel kommen vier Sekunden zu spät. Mit zehn
/// Sekunden Nachlauf lag dieselbe Messung bei allen Werten auf 0,000.
/// Was über den Wunsch hinausgelesen wird, verwirft die Filterung
/// ohnehin; es kostet ein paar Einträge mehr und sonst nichts.
/// </para>
/// </remarks>
private const double Nachlauf = 10;
/// <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 spanne = $"{Zahl(von)}%+{Zahl(ab - von + Nachlauf)}";
var start = new ProcessStartInfo(optionen.Ffprobe)
{