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
@@ -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) =>
{