Beim Staffelfinale klingt der Abspann anders

Und genau daran fiel er durch. Der Tonabgleich lebt davon, dass der
Abspann in jeder Folge derselbe ist; beim Finale ist er es oft nicht —
anderer Schluss, andere Musik, kein Treffer. In jeder Staffel fehlte
deshalb genau eine Marke, immer die letzte, und wer dort weitersah, sah
keinen Knopf. Das war der ganze Fehler. Der Spieler war nie schuld, und
meine Schranken-Vermutung von eben war falsch.

Was der Ton nicht findet, sucht jetzt das Bild — derselbe Weg wie bei
einem Film: dunkel werden und bis zum Ende durchhalten. Das Verfahren
lag fertig in `FilmAsync` und war nur dort eingebaut; es steht jetzt für
sich und wird von beiden benutzt. Nur für die Lücken: bei einer Staffel
ohne fehlende Marke läuft der Abtaster gar nicht.

**Drei Punkte heissen „mehr davon", nicht „tu etwas".** Bei einer Folge
taten sie etwas — wegräumen aus „Fortsetzen", ohne Rückfrage. Jeder, der
sie sah, hat ein Menü erwartet, draufgetippt, und dann baute die Seite
sich neu auf und die Punkte waren weg. Jetzt ein Kreuz, und eine Zeile,
die sagt, was geschehen ist.

Und die Markenanzeige nennt die Zeiten, nicht nur die Namen: dass eine
Marke da ist, sagt noch nicht, dass sie an der richtigen Stelle steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-13 20:49:50 +02:00
co-authored by Claude Opus 5
parent 74643b8242
commit 1340176bf0
2 changed files with 97 additions and 21 deletions
+77 -17
View File
@@ -227,25 +227,12 @@ public sealed class Markenerkennung(
.AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct)) .AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct))
return false; 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); await VermerkenAsync(db, filmId, Markenart.Abspann, ct);
var regeln = Abspannregeln.Vorgabe; var gefunden = await BildabspannAsync(
var grob = Abspannkante.Suchen(proben, regeln); db, abtaster, filmId, istFilm: true, film.Path, laufzeit.TotalSeconds, ct);
if (grob is { } stelle) if (!gefunden)
{
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 // Nichts gefunden ist ein Ergebnis. Eine alte Marke, die jetzt
// nicht mehr bestätigt wird, gehört weg — sonst bliebe ein // nicht mehr bestätigt wird, gehört weg — sonst bliebe ein
@@ -256,7 +243,43 @@ public sealed class Markenerkennung(
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return grob is not null; return gefunden;
}
/// <summary>Sucht den Abspann im Bild — bei einem Film wie bei einer Folge.</summary>
///
/// <remarks>
/// <para>
/// <b>Dunkel und bis zum Ende durchhalten</b> — mehr braucht es nicht,
/// und mehr hat ein Abspann auch nicht gemeinsam. Erst ein grober
/// Überblick über die letzten zwanzig Minuten aus Schlüsselbildern
/// (billig, aber auf fünf bis zehn Sekunden ungenau), dann eine Handvoll
/// kurzer Sonden für die Kante.
/// </para>
/// <para>
/// <b>Warum das auch bei Folgen gebraucht wird.</b> Der Tonabgleich lebt
/// davon, dass der Abspann in jeder Folge derselbe ist — und genau das
/// stimmt beim Staffelfinale oft nicht: anderer Schluss, andere Musik,
/// kein Treffer. Das Bild weiss davon nichts; dort sieht das Finale aus
/// wie jede andere Folge.
/// </para>
/// </remarks>
private async Task<bool> BildabspannAsync(
FlaroContext db, Bildabtaster abtaster, Guid titelId, bool istFilm,
string pfad, double laufzeit, CancellationToken ct)
{
var ab = Math.Max(0, laufzeit - Suchbereich);
var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct);
var regeln = Abspannregeln.Vorgabe;
if (Abspannkante.Suchen(proben, regeln) is not { } stelle) return false;
var hell = Abspannkante.HellDavor(proben, stelle, regeln) ?? ab;
var kante = await Abspannkante.VerfeinernAsync(
hell, stelle, regeln, wo => abtaster.HelligkeitAsync(pfad, wo, ct));
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann,
kante, laufzeit, ct);
} }
private async Task LaufenAsync(Guid serieId, bool erneut, CancellationToken ct) private async Task LaufenAsync(Guid serieId, bool erneut, CancellationToken ct)
@@ -264,6 +287,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 leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>(); var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
if (!await leser.VorhandenAsync(ct)) if (!await leser.VorhandenAsync(ct))
{ {
@@ -356,6 +380,42 @@ public sealed class Markenerkennung(
gefunden++; gefunden++;
} }
} }
if (!fehlt.Contains(Markenart.Abspann)) continue;
// **Was der Ton nicht fand, sucht das Bild.**
//
// Der Abgleich lebt davon, dass der Abspann in jeder Folge
// derselbe ist — und beim Staffelfinale stimmt das oft nicht:
// anderer Schluss, andere Musik, kein Treffer. Genau dort fiel
// die letzte Folge jeder Staffel durch, und nur dort. Das Bild
// weiss davon nichts; dort sieht das Finale aus wie jede
// andere Folge.
//
// **Nur für die Lücken.** Der Überblick dekodiert zwanzig
// Minuten Schlüsselbilder; bei einer Staffel ohne Lücke läuft
// er gar nicht, mit Lücke ein- bis zweimal. Erst speichern,
// sonst sieht die Abfrage die eben geschriebenen Marken nicht.
await db.SaveChangesAsync(ct);
var nummern = geordnet.Select(f => f.Id).ToList();
var mitAbspann = await db.Marken.AsNoTracking()
.Where(m => m.Art == Markenart.Abspann
&& m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
.Select(m => m.EpisodeId!.Value)
.ToListAsync(ct);
foreach (var folge in geordnet)
{
if (mitAbspann.Contains(folge.Id)) continue;
if (folge.Duration is not { TotalSeconds: > 0 } dauer) continue;
if (await BildabspannAsync(db, abtaster, folge.Id, istFilm: false,
folge.Path, dauer.TotalSeconds, ct))
{
gefunden++;
}
}
} }
// Immer speichern: auch ein Lauf ohne Fund hinterlässt Vermerke, // Immer speichern: auch ein Lauf ohne Fund hinterlässt Vermerke,
+20 -4
View File
@@ -455,9 +455,20 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten', stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten',
async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); })); async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); }));
// **Drei Punkte heissen „mehr davon", nicht „tu etwas".**
//
// Hier taten sie etwas: wegräumen aus „Fortsetzen", ohne Rückfrage.
// Jeder, der sie sah, hat ein Menü erwartet, draufgetippt — und dann
// baute die Seite sich neu auf und die Punkte waren weg. Von aussen
// sah das aus wie ein Fehler, und ohne Rückmeldung war nicht einmal
// zu erkennen, was passiert war.
if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) { if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) {
reihe.appendChild(rundKnopf('mehr', false, 'Aus „Fortsetzen" entfernen', reihe.appendChild(rundKnopf('schliessen', false, 'Aus „Fortsetzen" entfernen',
async () => { await weglegen(eintrag.id); await laden(); })); async () => {
await weglegen(eintrag.id);
hinweisZeigen('Aus „Fortsetzen" entfernt.');
await laden();
}));
} }
// **Diesen einen Titel neu holen, nicht den ganzen Bestand.** // **Diesen einen Titel neu holen, nicht den ganzen Bestand.**
@@ -1094,9 +1105,14 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
function markenwort(marken: Folge['markers']): string { function markenwort(marken: Folge['markers']): string {
if (!istAdmin()) return ''; if (!istAdmin()) return '';
if (!marken || marken.length === 0) return 'keine Marken'; if (!marken || marken.length === 0) return 'keine Marken';
// **Mit Zeiten, nicht bloss mit Namen.** Dass eine Marke da ist, sagt
// noch nicht, dass sie an der richtigen Stelle steht — und genau das
// ist der Unterschied zwischen „die Erkennung hat nichts gefunden"
// und „der Knopf kommt zur falschen Minute".
return marken return marken
.map(m => m.kind === 'intro' ? 'Vorspann' : 'Abspann') .map(m => `${m.kind === 'intro' ? 'Vorspann' : 'Abspann'} `
.join(' + '); + `${dauer(m.startSeconds)}–${dauer(m.endSeconds)}`)
.join(' · ');
} }
function folgenblock(folge: Folge): HTMLElement { function folgenblock(folge: Folge): HTMLElement {