Ein Abspann darf aus Stücken bestehen

Zwei Fälle fielen durch, beide am Bestand gesehen. Bei einem Marvel-Film
liegt hinter dem Abspann eine Post-Credit-Szene: die Suche begann am
Dateiende, fand nur den kurzen Rest dahinter und verwarf ihn als zu kurz.
Und ein Abspann, den eine Szene in der Mitte teilt, zerfiel in zwei
Reste, von denen keiner lang genug war. Dieselbe Ursache: der Abspann
musste eine zusammenhängende dunkle Strecke bis zum Dateiende sein.

Jetzt bildet die Suche Läufe, fasst sie über Lücken bis vier Minuten
zusammen — so lang ist die längste bekannte Post-Credit-Szene plus
Reserve — und lässt die **Summe** über die Mindestlänge entscheiden statt
jedes einzelne Stück. Ein Lauf unter fünfzehn Sekunden ist eine Blende
und zählt nicht mit.

Dafür mussten die eindeutigen Indizes über (Titel, Art) weichen; sie
standen mit dem Vermerk „eine Art einmal je Titel" da, und genau das
stimmt nicht mehr. `SchreibenAsync` ersetzt jetzt die Menge, statt eine
Zeile umzuschreiben — das ist der einzige Weg, der in beide Richtungen
stimmt, wenn aus zwei Stücken wieder eines wird.

**Drei Stellen trugen das nicht.** Im Web hiess „Abspann überspringen"
bei einem Film immer Schluss — beim ersten von zwei Stücken bekäme
niemand die Szene je zu sehen; geschlossen wird jetzt nur, wenn nichts
mehr folgt. Auf iOS verwarf der Spieler die Abspannmarke eines Films
ganz; diese Regel gehört nicht dorthin, sondern auf den Server, und ist
ersatzlos weg. Und die Gesehen-Schwelle nahm irgendeine Abspannzeile —
ohne `OrderBy` entschied die Datenbank; gemeint ist immer die früheste.

Dazu der Schalter „Zusätzliche Szenen mit überspringen" (Vorgabe aus) bei
den Geräteeinstellungen, und ein Messwerkzeug: ein Knopf auf der
Filmseite lädt die Kurve je Schlüsselbild als Tabelle — Dunkelanteil des
ganzen Bildes und der beiden Randstreifen. Ob bei Outtakes im Abspann die
Ränder dunkel bleiben, ist eine Vermutung; bevor daraus eine Schwelle
wird, will ich die Zahlen sehen.

Fünf neue Prüfungen, darunter die wichtigste: ein durchgehender Abspann
bleibt ein Stück.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-15 16:46:27 +02:00
co-authored by Claude Opus 5
parent 791c00dacc
commit 32f592b83e
18 changed files with 7216 additions and 5343 deletions
File diff suppressed because it is too large Load Diff
+10
View File
@@ -32,6 +32,7 @@ final class PlaybackSettings {
static let cellularQuality = "flaro.playback.quality.cellular"
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
static let skipExtraScenes = "flaro.playback.skipExtraScenes"
static let autoPlayNext = "flaro.playback.autoPlayNext"
static let locksLandscape = "flaro.playback.locksLandscape"
static let rewindSeconds = "flaro.playback.rewindSeconds"
@@ -58,6 +59,14 @@ final class PlaybackSettings {
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
/// Ob „Abspann überspringen" auch über Post-Credit-Szenen hinweggeht.
///
/// **Eine Gerätesache, keine Servereinstellung.** Ob jemand die Szene
/// sehen will, entscheidet er am Gerät, auf dem er sitzt — wie bei
/// `autoSkipIntro`. Vorgabe aus: wer den Knopf drückt, landet bei der
/// Szene und kann sie wegdrücken, wenn er mag.
var skipExtraScenes: Bool { didSet { defaults.set(skipExtraScenes, forKey: Key.skipExtraScenes) } }
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
@@ -117,6 +126,7 @@ final class PlaybackSettings {
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
self.skipExtraScenes = defaults.object(forKey: Key.skipExtraScenes) as? Bool ?? false
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
+21 -10
View File
@@ -1656,9 +1656,20 @@ final class PlayerModel {
/// Springt ans Ende der laufenden Marke.
func skipActiveMarker() {
guard let marker = activeMarker else { return }
guard let marker = activeMarker, let item else { return }
skippedMarkers.insert(marker.id)
seek(to: .milliseconds(marker.endMilliseconds))
// **Mit dem Schalter in einem Zug bis ans Ende.** Sonst endet der
// Sprung an dieser Marke, also bei der Post-Credit-Szene — wer sie
// nicht sehen will, stellt es einmal um und drückt einmal.
let ziel = settings.skipExtraScenes
? item.markers
.filter { $0.kind == marker.kind && $0.startMilliseconds >= marker.startMilliseconds }
.map(\.endMilliseconds)
.max() ?? marker.endMilliseconds
: marker.endMilliseconds
seek(to: .milliseconds(ziel))
}
/// Ob es bei der Tonspur überhaupt etwas zu wählen gibt.
@@ -1838,14 +1849,14 @@ final class PlayerModel {
guard let item else { return }
let milliseconds = currentTime.milliseconds
activeMarker = item.markers.first { marker in
guard marker.contains(milliseconds) else { return false }
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
// „Film beenden".
if marker.isCredits && item.kind != .episode { return false }
return true
}
// **Was der Server schickt, gilt.** Hier stand eine eigene Regel:
// bei einem Film wurde die Abspannmarke verworfen, weil
// „überspringen" dort gleichbedeutend mit „beenden" war. Das ist
// eine Entscheidung über Marken, und die trifft der Server — er
// schickt sie nur, wenn er einen Abspann gefunden hat, und seit
// ein Abspann geteilt sein darf, führt der Knopf zur
// Post-Credit-Szene statt ins Nichts.
activeMarker = item.markers.first { $0.contains(milliseconds) }
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
@@ -102,6 +102,7 @@ struct PlaybackSettingsView: View {
Section {
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
Toggle("settings.skipExtraScenes", isOn: $settings.skipExtraScenes)
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
} header: {
Text("settings.episodes")
@@ -1,5 +1,8 @@
using System.Globalization;
using System.Text;
using Flaro.Api.Auth;
using Flaro.Core.Library;
using Flaro.Media;
using Flaro.Core.Media;
using System.Security.Claims;
using Flaro.Core.Playback;
@@ -152,6 +155,9 @@ public static class LibraryContentEndpoints
var roh = await db.Marken.AsNoTracking()
.Where(m => m.EpisodeId != null && folgen.Contains(m.EpisodeId!.Value))
// In Zeitfolge: ein geteilter Abspann kommt als zwei Marken, und
// die Spieler nehmen die erste passende.
.OrderBy(m => m.VonSekunden)
.Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync();
@@ -177,6 +183,7 @@ public static class LibraryContentEndpoints
var roh = await db.Marken.AsNoTracking()
.Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value))
.OrderBy(m => m.VonSekunden)
.Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync();
@@ -902,6 +909,42 @@ public static class LibraryContentEndpoints
return Results.Accepted();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Die Messkurve eines Films — zum Nachsehen, nicht zum Erkennen.**
//
// Die Erkennung sagt „kein Abspann" und nennt die Gründe; was sie
// nicht sagen kann, ist, wie die Kurve aussieht. Genau die braucht
// es, bevor eine neue Schwelle gesetzt wird — zum Beispiel für die
// Frage, ob bei Outtakes im Abspann wenigstens die Ränder dunkel
// bleiben.
app.MapGet("/api/v1/movies/{id:guid}/markenkurve", async (
Guid id, FlaroContext db, Bildabtaster abtaster, CancellationToken ct) =>
{
var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == id)
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
(m, d) => new { m.Title, d.Path, d.Duration })
.FirstOrDefaultAsync(ct);
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit)
return Einwand.Bitte("Zu diesem Film gibt es keine Datei mit Laufzeit.");
var ab = Math.Max(0, laufzeit.TotalSeconds - Markenerkennung.Suchbereich);
var kurve = await abtaster.KurveAsync(film.Path, ab, laufzeit.TotalSeconds - ab, ct);
var text = new StringBuilder("sekunde;gesamt;links;rechts\n");
foreach (var punkt in kurve)
{
text.Append(CultureInfo.InvariantCulture,
$"{punkt.Sekunde:0.###};{punkt.Gesamt:0.#};{punkt.Links:0.#};{punkt.Rechts:0.#}\n");
}
var name = string.Concat((film.Title ?? "film")
.Select(c => char.IsLetterOrDigit(c) ? c : '-'));
return Results.File(Encoding.UTF8.GetBytes(text.ToString()),
"text/csv", $"markenkurve-{name}.csv");
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Eine Marke wieder wegnehmen.**
//
// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal
@@ -100,14 +100,21 @@ public static class PlaybackEndpoints
// **Der Abspann, wenn er erkannt wurde.** Er zieht die Schwelle
// fürs Häkchen nach vorn — siehe `ProgressRule.Apply`.
//
// **Das früheste Stück, nicht irgendeines.** Teilt eine
// Post-Credit-Szene den Abspann, gibt es zwei Zeilen; ohne
// `OrderBy` entschied die Datenbank, welche gilt. Gemeint ist
// immer die erste — dort ist die Handlung vorbei.
var abspannAb = zustand.EpisodeId is { } folge
? await db.Marken.AsNoTracking()
.Where(m => m.EpisodeId == folge && m.Art == Markenart.Abspann)
.OrderBy(m => m.VonSekunden)
.Select(m => (double?)m.VonSekunden)
.FirstOrDefaultAsync()
: zustand.MovieId is { } film
? await db.Marken.AsNoTracking()
.Where(m => m.MovieId == film && m.Art == Markenart.Abspann)
.OrderBy(m => m.VonSekunden)
.Select(m => (double?)m.VonSekunden)
.FirstOrDefaultAsync()
: null;
+66 -25
View File
@@ -503,7 +503,8 @@ public sealed class Markenerkennung(
// 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)
var stücke = Abspannkante.Suchen(proben, regeln, out var grund);
if (stücke.Count == 0)
{
// **Mit der Kapitellage.** Ob eine Datei gar keine Kapitel hat
// oder nur namenlose, entscheidet, ob sich an diesem Weg
@@ -517,16 +518,34 @@ public sealed class Markenerkennung(
return false;
}
var hell = Abspannkante.HellDavor(proben, stelle, regeln) ?? ab;
// **Nur die erste Kante wird nachgemessen.** Sie ist die, die der
// Zuschauer zuerst sieht, und die, an der „gilt als gesehen" hängt.
// Die übrigen Grenzen liegen an Schlüsselbildern und sind damit auf
// fünf bis zehn Sekunden genau — dafür lohnt keine Sonde.
var hell = Abspannkante.HellDavor(proben, stücke[0].Von, regeln) ?? ab;
var kante = await Abspannkante.VerfeinernAsync(
hell, stelle, regeln.Mindestanteil,
hell, stücke[0].Von, regeln.Mindestanteil,
wo => abtaster.DunkelanteilAsync(pfad, wo, ct));
log.LogInformation("„{Titel}“: Abspann im Bild ab {Stelle:h\\:mm\\:ss}.",
name, TimeSpan.FromSeconds(kante));
var fein = stücke.ToList();
fein[0] = fein[0] with { Von = kante };
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann,
kante, laufzeit, ct);
if (fein.Count == 1)
{
log.LogInformation("„{Titel}“: Abspann im Bild ab {Stelle:h\\:mm\\:ss}.",
name, TimeSpan.FromSeconds(kante));
}
else
{
log.LogInformation(
"„{Titel}“: Abspann im Bild in {Stücke} Stücken ab {Stelle:h\\:mm\\:ss} "
+ "— dazwischen {Lücken}.",
name, fein.Count, TimeSpan.FromSeconds(kante),
string.Join(", ", fein.Zip(fein.Skip(1),
(a, b) => $"{Zeit(a.Bis)}–{Zeit(b.Von)}")));
}
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann, fein, ct);
}
/// <summary>Was ein Serienlauf ergeben hat — für die Zusammenfassung.</summary>
@@ -917,31 +936,53 @@ public sealed class Markenerkennung(
});
}
private Task<bool> SchreibenAsync(
FlaroContext db, Guid titelId, bool istFilm, Markenart art,
double von, double bis, CancellationToken ct) =>
SchreibenAsync(db, titelId, istFilm, art, [new Abspannstück(von, bis)], ct);
/// <summary>Setzt die Marken einer Art auf genau diese Stücke.</summary>
///
/// <remarks>
/// <para>
/// <b>Ersetzen, nicht umschreiben.</b> Hier stand ein Abgleich auf
/// genau eine Zeile je Titel und Art — das ging, solange ein Abspann
/// eine zusammenhängende Strecke war. Seit eine Post-Credit-Szene ihn
/// teilen darf, sind es ein bis zwei Zeilen, und beim nächsten Lauf
/// vielleicht wieder eine. Die alten zu löschen und die neuen
/// anzulegen ist der einzige Weg, der in beide Richtungen stimmt.
/// </para>
/// <para>
/// <b>In derselben Transaktion</b> — gespeichert wird weiter oben, und
/// bis dahin stehen Löschung und Anlage zusammen. Dazwischen gibt es
/// keinen Augenblick, in dem der Titel gar keine Marke hätte.
/// </para>
/// </remarks>
private async Task<bool> SchreibenAsync(
FlaroContext db, Guid titelId, bool istFilm, Markenart art,
double von, double bis, CancellationToken ct)
IReadOnlyList<Abspannstück> stücke, CancellationToken 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 (stücke.Count == 0) return false;
if (alt is not null)
var alte = istFilm
? await db.Marken.Where(m => m.MovieId == titelId && m.Art == art).ToListAsync(ct)
: await db.Marken.Where(m => m.EpisodeId == titelId && m.Art == art).ToListAsync(ct);
db.Marken.RemoveRange(alte);
foreach (var stück in stücke)
{
alt.VonSekunden = von;
alt.BisSekunden = bis;
alt.ErkanntAm = clock.GetUtcNow();
return true;
db.Marken.Add(new Marke
{
EpisodeId = istFilm ? null : titelId,
MovieId = istFilm ? titelId : null,
Art = art,
VonSekunden = stück.Von,
BisSekunden = stück.Bis,
ErkanntAm = clock.GetUtcNow(),
});
}
db.Marken.Add(new Marke
{
EpisodeId = istFilm ? null : titelId,
MovieId = istFilm ? titelId : null,
Art = art,
VonSekunden = von,
BisSekunden = bis,
ErkanntAm = clock.GetUtcNow(),
});
return true;
}
}
+136 -44
View File
@@ -22,6 +22,19 @@ namespace Flaro.Core.Media;
/// <param name="Dunkelanteil">Wie viel Prozent des Bildes dunkel sind, 0 bis 100.</param>
public readonly record struct Bildprobe(double Sekunde, double Dunkelanteil);
/// <summary>Ein Stück Abspann: von wann bis wann.</summary>
///
/// <remarks>
/// <b>Mehrere, weil ein Abspann unterbrochen sein kann.</b> Liegt eine
/// Post-Credit-Szene dazwischen, sind es zwei Stücke mit der Szene als
/// Lücke — und der Überspringen-Knopf bringt den Zuschauer genau dorthin
/// statt an ihr vorbei.
/// </remarks>
public readonly record struct Abspannstück(double Von, double Bis)
{
public double Länge => Math.Max(0, Bis - Von);
}
/// <summary>Die Schwellen, nach denen eine dunkle Strecke ein Abspann ist.</summary>
///
/// <remarks>
@@ -87,6 +100,28 @@ public readonly record struct Abspannregeln
/// </remarks>
public double Schlusstafel { get; init; }
/// <summary>Wie kurz ein einzelnes Stück sein darf, um noch zu zählen.</summary>
///
/// <remarks>
/// Ein Abspann kann aus zwei Stücken bestehen, wenn eine Post-Credit-
/// Szene dazwischenliegt. Zehn Sekunden Schwarz sind aber kein Stück
/// Abspann, sondern eine Blende — die Mindestlänge gilt für die Summe,
/// diese Schranke für das einzelne Stück.
/// </remarks>
public double Mindeststück { get; init; }
/// <summary>Wie lang eine Post-Credit-Szene höchstens sein darf.</summary>
///
/// <remarks>
/// <b>Die Lücke, über die hinweg zusammengefasst wird.</b> Eine
/// Post-Credit-Szene im MCU dauert typischerweise eine Minute; die
/// längste gemessene (Thunderbolts*) 2:54. Vier Minuten sind der
/// längste bekannte Wert plus Reserve — und zugleich die Schranke
/// dagegen, dass die Suche rückwärts durch den halben Film läuft.
/// </remarks>
public double Lückentoleranz { get; init; }
/// <summary>Die Summe aller Stücke, nicht die Länge eines einzelnen.</summary>
public double Mindestlänge { get; init; }
public double Höchstlänge { get; init; }
@@ -95,6 +130,8 @@ public readonly record struct Abspannregeln
Mindestanteil = 60,
Nachsicht = 10,
Schlusstafel = 20,
Mindeststück = 15,
Lückentoleranz = 4 * 60,
Mindestlänge = 45,
Höchstlänge = 12 * 60,
};
@@ -124,13 +161,14 @@ public readonly record struct Abspannregeln
/// </remarks>
public static class Abspannkante
{
/// <summary>Die dunkle Strecke, die bis zum Ende durchhält — oder nichts.</summary>
/// <summary>Die Stücke Abspann am Ende — oder eine leere Liste.</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) =>
public static IReadOnlyList<Abspannstück> Suchen(
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln) =>
Suchen(proben, regeln, out _);
/// <param name="grund">
@@ -139,68 +177,106 @@ public static class Abspannkante
/// sind drei verschiedene Ursachen mit drei verschiedenen Antworten,
/// und ohne diese Zeile beginnt jede Nachfrage wieder beim Raten.
/// </param>
public static double? Suchen(
public static IReadOnlyList<Abspannstück> Suchen(
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln, out string grund)
{
grund = "gefunden";
if (proben.Count < 2)
{
grund = "zu wenige Proben";
return null;
return [];
}
// **Von hinten.** Der Abspann endet am Dateiende; alles, was davor
// dunkel war und wieder hell wurde, war eine Schwarzblende.
//
// **Das allerletzte Bild muss aber nicht dunkel sein.** Hinter dem
// Abspann steht bei vielen Produktionen noch eine Logotafel —
// Sender, Studio, Förderer —, oft hell und ein paar Sekunden lang.
// Darauf zu bestehen, dass die letzte Probe dunkel ist, hiess: ein
// einziges weisses Schlussbild macht den ganzen Abspann unsichtbar.
var letzte = proben.Count - 1;
var ende = proben[letzte].Sekunde;
while (letzte > 0 && proben[letzte].Dunkelanteil < regeln.Mindestanteil)
var läufe = Läufe(proben, regeln);
if (läufe.Count == 0)
{
if (ende - proben[letzte].Sekunde >= regeln.Schlusstafel)
{
grund = "das Ende ist zu lange hell";
return null;
}
letzte--;
grund = "keine dunkle Strecke im Suchfenster";
return [];
}
if (proben[letzte].Dunkelanteil < regeln.Mindestanteil)
// **Das letzte Stück muss das Ende erreichen.** Ein Abspann endet am
// Dateiende; was davor dunkel war und wieder hell wurde, war eine
// Schwarzblende. Eine helle Logotafel dahinter ist erlaubt — Sender,
// Studio, Förderer stehen bei vielen Produktionen noch hinter dem
// Abspann.
var ende = proben[^1].Sekunde;
if (ende - läufe[^1].Bis >= regeln.Schlusstafel)
{
grund = "das Ende ist hell";
return null;
return [];
}
// **Rückwärts, und helle Stellen in Sekunden verzeihen.** Gezählt
// wurden hier früher Proben, und was das bedeutet, hängt allein an
// der Schlüsselbilddichte der Datei — siehe `Nachsicht`.
var anfang = letzte;
for (var i = letzte - 1; i >= 0; i--)
// **Rückwärts zusammenfassen.** Jede Lücke unter der Toleranz ist
// eine Post-Credit-Szene und kein Abbruch; die erste grössere Lücke
// beendet die Suche.
var stücke = new List<Abspannstück> { läufe[^1] };
for (var i = läufe.Count - 2; i >= 0; i--)
{
if (proben[i].Dunkelanteil >= regeln.Mindestanteil) { anfang = i; continue; }
if (proben[anfang].Sekunde - proben[i].Sekunde > regeln.Nachsicht) break;
if (stücke[^1].Von - läufe[i].Bis >= regeln.Lückentoleranz) break;
stücke.Add(läufe[i]);
}
stücke.Reverse();
// **Die Summe entscheidet, nicht das einzelne Stück.** Ein Abspann
// von vier Minuten, den eine Szene in der Mitte teilt, ist ein
// Abspann von vier Minuten — vorher zerfiel er in zwei Reste, von
// denen keiner lang genug war, und der Film bekam gar nichts.
var summe = stücke.Sum(t => t.Länge);
if (summe < regeln.Mindestlänge)
{
grund = $"die dunklen Strecken ergeben zusammen nur {summe:0} s";
return [];
}
if (summe > regeln.Höchstlänge)
{
grund = $"die dunklen Strecken ergeben zusammen {summe:0} s und damit zu viel";
return [];
}
var länge = proben[letzte].Sekunde - proben[anfang].Sekunde;
if (länge < regeln.Mindestlänge)
// Das letzte Stück reicht bis ans Dateiende: eine Logotafel dahinter
// gehört zum Abspann, nicht zum Film.
stücke[^1] = stücke[^1] with { Bis = ende };
return stücke;
}
/// <summary>Zusammenhängende dunkle Strecken, kurze Helligkeiten verziehen.</summary>
///
/// <remarks>
/// <b>Die Nachsicht zählt Sekunden, nicht Proben.</b> Eine Schrifttafel
/// blitzt auf, ein Logo steht zwei Sekunden im Bild; wie viele
/// Messpunkte darauf fallen, hängt allein an der Schlüsselbilddichte
/// der Datei und schwankt um eine Zehnerpotenz.
/// </remarks>
private static List<Abspannstück> Läufe(
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln)
{
var läufe = new List<Abspannstück>();
double? von = null;
double bis = 0;
foreach (var probe in proben)
{
grund = $"die dunkle Strecke ist mit {länge:0} s zu kurz";
return null;
}
if (länge > regeln.Höchstlänge)
{
grund = $"die dunkle Strecke ist mit {länge:0} s zu lang";
return null;
if (probe.Dunkelanteil >= regeln.Mindestanteil)
{
von ??= probe.Sekunde;
bis = probe.Sekunde;
continue;
}
// Hell: der Lauf endet erst, wenn die Helligkeit lange genug
// anhält.
if (von is not null && probe.Sekunde - bis > regeln.Nachsicht)
{
läufe.Add(new Abspannstück(von.Value, bis));
von = 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;
if (von is not null) läufe.Add(new Abspannstück(von.Value, bis));
// Zehn Sekunden Schwarz sind eine Blende, kein Stück Abspann.
läufe.RemoveAll(l => l.Länge < regeln.Mindeststück);
return läufe;
}
/// <summary>Die letzte helle Probe vor dem Fund — der Suchbereich fürs Nachmessen.</summary>
@@ -254,3 +330,19 @@ public static class Abspannkante
return dunkel;
}
}
/// <summary>Ein Messpunkt des Werkzeugs: das ganze Bild und die Ränder.</summary>
///
/// <remarks>
/// <b>Nur zum Nachsehen, nicht für die Erkennung.</b> Ob die Ränder bei
/// Outtakes im Abspann dunkel bleiben, ist eine Vermutung — und eine, die
/// sich nur an echten Dateien beantworten lässt. Diese Reihe ist die
/// Grundlage dafür.
/// </remarks>
///
/// <param name="Sekunde">Die Stelle im Film.</param>
/// <param name="Gesamt">Dunkelanteil des ganzen Bildes, 0 bis 100.</param>
/// <param name="Links">Dunkelanteil der linken fünftel Breite.</param>
/// <param name="Rechts">Dunkelanteil der rechten fünftel Breite.</param>
public readonly record struct Kurvenpunkt(
double Sekunde, double Gesamt, double Links, double Rechts);
+8 -4
View File
@@ -311,10 +311,14 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
b.Entity<Marke>(e =>
{
e.HasKey(x => x.Id);
// Eine Art einmal je Titel: ein zweiter Lauf schreibt um, statt
// eine zweite Zeile anzulegen.
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");
// **Mehrere Stücke je Art, und das ist der ganze Zweck.** Hier
// stand ein eindeutiger Index mit dem Vermerk „eine Art einmal
// je Titel" — richtig, solange ein Abspann eine
// zusammenhängende Strecke war. Teilt ihn eine Post-Credit-
// Szene, sind es zwei Zeilen, und der Index wies die zweite ab.
// Zum Nachschlagen bleiben die Indizes, nur ohne `IsUnique`.
e.HasIndex(x => new { x.EpisodeId, x.Art });
e.HasIndex(x => new { x.MovieId, x.Art });
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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Abspannstuecke : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Marken_EpisodeId_Art",
table: "Marken");
migrationBuilder.DropIndex(
name: "IX_Marken_MovieId_Art",
table: "Marken");
migrationBuilder.CreateIndex(
name: "IX_Marken_EpisodeId_Art",
table: "Marken",
columns: new[] { "EpisodeId", "Art" });
migrationBuilder.CreateIndex(
name: "IX_Marken_MovieId_Art",
table: "Marken",
columns: new[] { "MovieId", "Art" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Marken_EpisodeId_Art",
table: "Marken");
migrationBuilder.DropIndex(
name: "IX_Marken_MovieId_Art",
table: "Marken");
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");
}
}
}
@@ -432,13 +432,9 @@ namespace Flaro.Data.Migrations
b.HasKey("Id");
b.HasIndex("EpisodeId", "Art")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("EpisodeId", "Art");
b.HasIndex("MovieId", "Art")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("MovieId", "Art");
b.ToTable("Marken");
});
+50 -2
View File
@@ -92,9 +92,56 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
return proben.Average(p => p.Dunkelanteil);
}
/// <param name="zuschnitt">
/// Ein <c>crop</c>-Ausdruck vor der Messung, oder <c>null</c> für das
/// ganze Bild. Nur das Messwerkzeug benutzt das.
/// </param>
/// <summary>Drei Kurven über denselben Abschnitt — zum Nachsehen.</summary>
///
/// <remarks>
/// <para>
/// <b>Ein Werkzeug, keine Erkennung.</b> Die Randstreifen sind eine
/// Vermutung: bei Outtakes im Abspann läuft das Bild vielleicht in der
/// Mitte und die Ränder bleiben schwarz. Ob das trägt, sieht man nur an
/// echten Dateien — und dafür braucht es die Zahlen, bevor eine Schwelle
/// gesetzt wird.
/// </para>
/// <para>
/// <b>Drei Durchläufe, nicht einer.</b> Zwei <c>blackframe</c> in einer
/// Kette schrieben denselben Metadatenschlüssel und überschrieben
/// einander; mit <c>split</c> ginge es, kostete aber drei Ausgabewege
/// und eine Zusammenführung. Für etwas, das jemand einmal je Film von
/// Hand anstösst, sind drei Läufe über Schlüsselbilder das günstigere
/// Geschäft.
/// </para>
/// </remarks>
public async Task<IReadOnlyList<Kurvenpunkt>> KurveAsync(
string pfad, double ab, double dauer, CancellationToken ct = default)
{
async Task<IReadOnlyList<Bildprobe>> Lauf(string? zuschnitt) => Lesen(
await LaufenAsync(pfad, ab, dauer, nurSchluesselbilder: true,
TimeSpan.FromMinutes(10), ct, zuschnitt),
ab);
var gesamt = await Lauf(null);
var links = await Lauf("crop=iw*0.2:ih:0:0");
var rechts = await Lauf("crop=iw*0.2:ih:iw*0.8:0");
// Nach Zeit zusammenführen: die drei Läufe sehen dieselben
// Schlüsselbilder, aber darauf zu bauen wäre eine Annahme.
double BeiZeit(IReadOnlyList<Bildprobe> reihe, double sekunde) =>
reihe.Count == 0 ? double.NaN
: reihe.MinBy(p => Math.Abs(p.Sekunde - sekunde)).Dunkelanteil;
return gesamt
.Select(p => new Kurvenpunkt(
p.Sekunde, p.Dunkelanteil, BeiZeit(links, p.Sekunde), BeiZeit(rechts, p.Sekunde)))
.ToList();
}
private async Task<string> LaufenAsync(
string pfad, double ab, double dauer, bool nurSchluesselbilder,
TimeSpan frist, CancellationToken ct)
TimeSpan frist, CancellationToken ct, string? zuschnitt = null)
{
var start = new ProcessStartInfo(optionen.Ffmpeg)
{
@@ -135,7 +182,8 @@ public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabt
// dieselbe Schwelle bei der einen Datei etwas anderes als bei der
// nächsten — „Schwelle 247,6" im Protokoll war der Hinweis darauf.
start.ArgumentList.Add(
$"scale={Breite}:-2,format=gray,"
(zuschnitt is null ? "" : zuschnitt + ",")
+ $"scale={Breite}:-2,format=gray,"
+ $"blackframe=amount=0:threshold={Dunkelschwelle},"
+ "metadata=print:key=lavfi.blackframe.pblack:file=-");
start.ArgumentList.Add("-f");
@@ -15,6 +15,16 @@ public class AbspannkanteTests
{
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
/// <summary>Der Anfang des ersten Stücks — oder nichts.</summary>
///
/// <remarks>
/// Die meisten Prüfungen hier fragen nur „wo fängt der Abspann an".
/// Seit die Suche Stücke zurückgibt, steht das in <c>[0].Von</c>; ein
/// Helfer hält die Erwartungen lesbar, statt überall zu indizieren.
/// </remarks>
private static double? Anfang(IReadOnlyList<Abspannstück> stücke) =>
stücke.Count == 0 ? null : stücke[0].Von;
/// <summary>Eine Reihe von Messpunkten im Abstand von zehn Sekunden.</summary>
///
/// <param name="abspannAb">Ab wann dunkel. <c>null</c> heisst: nie.</param>
@@ -31,7 +41,7 @@ public class AbspannkanteTests
{
var proben = Reihe(0, 600, abspannAb: 420);
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(420));
Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.EqualTo(420));
}
/// <summary>Ein Film, der hell endet, hat keinen erkennbaren Abspann.</summary>
@@ -44,7 +54,7 @@ public class AbspannkanteTests
[Test]
public void Ohne_dunkles_Ende_kein_Fund()
{
Assert.That(Abspannkante.Suchen(Reihe(0, 600, abspannAb: null), Regeln), Is.Null);
Assert.That(Anfang(Abspannkante.Suchen(Reihe(0, 600, abspannAb: null), Regeln)), Is.Null);
}
/// <summary>Eine Schwarzblende mitten im Film zählt nicht.</summary>
@@ -54,7 +64,7 @@ public class AbspannkanteTests
var proben = Reihe(0, 600, abspannAb: null);
for (var i = 20; i < 30; i++) proben[i] = proben[i] with { Dunkelanteil = 92 };
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null,
Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.Null,
"sie hält nicht bis zum Ende durch");
}
@@ -70,7 +80,7 @@ public class AbspannkanteTests
for (var i = proben.Count - 5; i < proben.Count; i++)
proben[i] = proben[i] with { Dunkelanteil = 0 };
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null);
Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.Null);
}
/// <summary>Eine helle Schrifttafel mittendrin bricht die Strecke nicht.</summary>
@@ -89,21 +99,21 @@ public class AbspannkanteTests
var proben = Reihe(0, 600, abspannAb: 420);
proben[50] = proben[50] with { Dunkelanteil = 0 };
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(420));
Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.EqualTo(420));
}
/// <summary>Zwanzig Sekunden Dunkelheit sind kein Abspann.</summary>
[Test]
public void Zu_kurz_zaehlt_nicht()
{
Assert.That(Abspannkante.Suchen(Reihe(0, 600, abspannAb: 580), Regeln), Is.Null);
Assert.That(Anfang(Abspannkante.Suchen(Reihe(0, 600, abspannAb: 580), Regeln)), Is.Null);
}
/// <summary>Ein halber Film im Dunkeln ist ein Fehlfund.</summary>
[Test]
public void Zu_lang_zaehlt_auch_nicht()
{
Assert.That(Abspannkante.Suchen(Reihe(0, 3600, abspannAb: 600), Regeln), Is.Null);
Assert.That(Anfang(Abspannkante.Suchen(Reihe(0, 3600, abspannAb: 600), Regeln)), Is.Null);
}
[Test]
@@ -199,6 +209,16 @@ public class AbspannAnteilTests
{
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
/// <summary>Der Anfang des ersten Stücks — oder nichts.</summary>
///
/// <remarks>
/// Die meisten Prüfungen hier fragen nur „wo fängt der Abspann an".
/// Seit die Suche Stücke zurückgibt, steht das in <c>[0].Von</c>; ein
/// Helfer hält die Erwartungen lesbar, statt überall zu indizieren.
/// </remarks>
private static double? Anfang(IReadOnlyList<Abspannstück> stücke) =>
stücke.Count == 0 ? null : stücke[0].Von;
/// <summary>Ein Suchfenster, wie es im Betrieb entsteht.</summary>
///
/// <remarks>
@@ -220,7 +240,7 @@ public class AbspannAnteilTests
{
var proben = Fenster(abspannAb: 2340, imAbspann: 84);
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(2340.0).Within(5));
Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.EqualTo(2340.0).Within(5));
}
[Test]
@@ -228,7 +248,7 @@ public class AbspannAnteilTests
{
var proben = Fenster(abspannAb: 2340, imAbspann: 87);
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(2340.0).Within(5));
Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.EqualTo(2340.0).Within(5));
}
/// <summary>**Eine dunkle Nachtszene ist kein Abspann.**</summary>
@@ -244,7 +264,7 @@ public class AbspannAnteilTests
{
var proben = Fenster(abspannAb: 2340, imAbspann: 37, imInhalt: 12);
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null);
Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.Null);
}
/// <summary>Und ein heller Film bis zum Ende bleibt Handlung.</summary>
@@ -253,7 +273,7 @@ public class AbspannAnteilTests
{
var proben = Fenster(abspannAb: double.MaxValue, imAbspann: 99);
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null);
Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.Null);
}
}
@@ -270,6 +290,16 @@ public class AbspannSchlusstafelTests
{
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
/// <summary>Der Anfang des ersten Stücks — oder nichts.</summary>
///
/// <remarks>
/// Die meisten Prüfungen hier fragen nur „wo fängt der Abspann an".
/// Seit die Suche Stücke zurückgibt, steht das in <c>[0].Von</c>; ein
/// Helfer hält die Erwartungen lesbar, statt überall zu indizieren.
/// </remarks>
private static double? Anfang(IReadOnlyList<Abspannstück> stücke) =>
stücke.Count == 0 ? null : stücke[0].Von;
private static List<Bildprobe> Fenster(double abspannAb, double tafelAb = double.MaxValue)
{
var proben = new List<Bildprobe>();
@@ -288,7 +318,7 @@ public class AbspannSchlusstafelTests
// Fünfzehn Sekunden weiss hinter dem Abspann.
var proben = Fenster(abspannAb: 2340, tafelAb: 2690);
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(2340).Within(5));
Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.EqualTo(2340).Within(5));
}
/// <summary>Eine lange helle Strecke am Ende schon.</summary>
@@ -302,7 +332,7 @@ public class AbspannSchlusstafelTests
{
var proben = Fenster(abspannAb: 2340, tafelAb: 2600);
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null);
Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.Null);
}
}
@@ -329,6 +359,16 @@ public class AbspannNachsichtTests
{
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
/// <summary>Der Anfang des ersten Stücks — oder nichts.</summary>
///
/// <remarks>
/// Die meisten Prüfungen hier fragen nur „wo fängt der Abspann an".
/// Seit die Suche Stücke zurückgibt, steht das in <c>[0].Von</c>; ein
/// Helfer hält die Erwartungen lesbar, statt überall zu indizieren.
/// </remarks>
private static double? Anfang(IReadOnlyList<Abspannstück> stücke) =>
stücke.Count == 0 ? null : stücke[0].Von;
/// <summary>Abspann ab 2340 s, darin fünf Sekunden helles Logo.</summary>
private static List<Bildprobe> Fenster(double takt)
{
@@ -345,7 +385,7 @@ public class AbspannNachsichtTests
[Test]
public void Ein_Logo_zerreisst_den_Abspann_nicht_bei_dichten_Proben()
{
Assert.That(Abspannkante.Suchen(Fenster(takt: 0.9), Regeln),
Assert.That(Anfang(Abspannkante.Suchen(Fenster(takt: 0.9), Regeln)),
Is.EqualTo(2340.0).Within(2));
}
@@ -353,18 +393,20 @@ public class AbspannNachsichtTests
[Test]
public void Und_auch_nicht_bei_duennen_Proben()
{
Assert.That(Abspannkante.Suchen(Fenster(takt: 10), Regeln),
Assert.That(Anfang(Abspannkante.Suchen(Fenster(takt: 10), Regeln)),
Is.EqualTo(2340.0).Within(11));
}
/// <summary>Eine lange helle Strecke bricht weiterhin ab.</summary>
/// <summary>Eine Minute helles Bild mitten im Abspann ist eine Szene.</summary>
///
/// <remarks>
/// Sonst liefe die Suche rückwärts durch jede Nachtszene und meldete
/// den halben Film als Abspann.
/// <b>Früher brach die Suche hier ab</b> und meldete nur das Stück
/// dahinter. Genau daran scheiterten Marvel-Filme: hinter dem Abspann
/// eine Post-Credit-Szene, davor der eigentliche Abspann — und
/// gefunden wurde nur der kurze Rest, der dann als zu kurz durchfiel.
/// </remarks>
[Test]
public void Eine_lange_helle_Strecke_bricht_die_Suche()
public void Eine_Minute_hell_mittendrin_teilt_den_Abspann_in_zwei_Stuecke()
{
var proben = new List<Bildprobe>();
for (var t = 1500.0; t <= 2700; t += 1)
@@ -374,8 +416,17 @@ public class AbspannNachsichtTests
proben.Add(new Bildprobe(t, hell ? 0 : 85));
}
// Gefunden wird nur das Stück hinter der hellen Minute.
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(2460.0).Within(2));
var stücke = Abspannkante.Suchen(proben, Regeln);
Assert.Multiple(() =>
{
Assert.That(stücke, Has.Count.EqualTo(2));
Assert.That(stücke[0].Von, Is.EqualTo(2340.0).Within(2));
Assert.That(stücke[0].Bis, Is.EqualTo(2399.0).Within(2));
Assert.That(stücke[1].Von, Is.EqualTo(2460.0).Within(2));
Assert.That(stücke[1].Bis, Is.EqualTo(2700.0).Within(2),
"das letzte Stück reicht bis ans Dateiende");
});
}
/// <summary>Und der Grund steht für das Protokoll bereit.</summary>
@@ -386,13 +437,125 @@ public class AbspannNachsichtTests
for (var t = 1500.0; t <= 2700; t += 5) hell.Add(new Bildprobe(t, 0));
Abspannkante.Suchen(hell, Regeln, out var grund);
Assert.That(grund, Does.Contain("hell"));
Assert.That(grund, Does.Contain("keine dunkle Strecke"));
var kurz = new List<Bildprobe>();
for (var t = 1500.0; t <= 2700; t += 5)
kurz.Add(new Bildprobe(t, t < 2680 ? 0 : 85));
Abspannkante.Suchen(kurz, Regeln, out var zuKurz);
Assert.That(zuKurz, Does.Contain("kurz"));
Assert.That(zuKurz, Does.Contain("zusammen nur"));
}
}
/// <summary>Ein Abspann darf aus Stücken bestehen.</summary>
///
/// <remarks>
/// <para>
/// <b>Wofür diese Prüfungen stehen.</b> Zwei Fälle fielen durch, beide am
/// Bestand gesehen. Bei einem Marvel-Film liegt hinter dem Abspann eine
/// Post-Credit-Szene: die Suche begann am Dateiende, fand nur den kurzen
/// Rest dahinter und verwarf ihn als zu kurz. Und ein Abspann, den eine
/// Szene in der Mitte teilt, zerfiel in zwei Reste, von denen keiner lang
/// genug war.
/// </para>
/// <para>
/// Die Antwort ist dieselbe: Stücke über die Lücke hinweg zusammenfassen
/// und die <b>Summe</b> über die Mindestlänge entscheiden lassen.
/// </para>
/// </remarks>
public class AbspannstueckeTests
{
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
/// <summary>Eine Reihe im Sekundentakt, dunkel in den angegebenen Spannen.</summary>
private static List<Bildprobe> Reihe(double bis, params (double Von, double Bis)[] dunkel)
{
var proben = new List<Bildprobe>();
for (var t = 0.0; t <= bis; t += 1)
{
var istDunkel = Array.Exists(dunkel, d => t >= d.Von && t < d.Bis);
proben.Add(new Bildprobe(t, istDunkel ? 85 : 0));
}
return proben;
}
/// <summary>Der Marvel-Fall: Abspann, Szene, Abspann.</summary>
[Test]
public void Zwei_Stuecke_mit_einer_Szene_dazwischen()
{
// 180 s Abspann, 90 s Szene, 60 s Abspann bis zum Ende.
var stücke = Abspannkante.Suchen(Reihe(600, (330, 510), (540, 601)), Regeln);
Assert.Multiple(() =>
{
Assert.That(stücke, Has.Count.EqualTo(2));
Assert.That(stücke[0].Von, Is.EqualTo(330.0).Within(2));
Assert.That(stücke[1].Von, Is.EqualTo(540.0).Within(2));
Assert.That(stücke[1].Bis, Is.EqualTo(600.0).Within(2));
});
}
/// <summary>**Die Summe entscheidet, nicht das einzelne Stück.**</summary>
///
/// <remarks>
/// Genau hier scheiterte es vorher: 30 s plus 30 s sind zusammen 60 s
/// und damit ein Abspann, einzeln aber zweimal zu wenig.
/// </remarks>
[Test]
public void Zwei_kurze_Stuecke_ergeben_zusammen_einen_Abspann()
{
var stücke = Abspannkante.Suchen(Reihe(600, (450, 480), (570, 601)), Regeln);
Assert.Multiple(() =>
{
Assert.That(stücke, Has.Count.EqualTo(2));
Assert.That(stücke.Sum(t => t.Länge), Is.GreaterThanOrEqualTo(Regeln.Mindestlänge));
});
}
/// <summary>Eine zu lange Lücke trennt — dann zählt nur das letzte Stück.</summary>
///
/// <remarks>
/// Sonst liefe die Suche rückwärts durch jede Nachtszene und hinge den
/// halben Film an den Abspann. Hinter einer Lücke von fünf Minuten
/// bleibt hier ein Stück von 30 s übrig, und das ist zu kurz.
/// </remarks>
[Test]
public void Eine_zu_lange_Luecke_trennt()
{
var stücke = Abspannkante.Suchen(Reihe(900, (200, 400), (870, 901)), Regeln);
Assert.That(stücke, Is.Empty);
}
/// <summary>Ein zu kurzes Stück zwischen zwei gültigen zählt nicht mit.</summary>
[Test]
public void Ein_Stueck_von_zehn_Sekunden_faellt_heraus()
{
// Das mittlere Stück ist 10 s lang und damit unter `Mindeststück`.
var stücke = Abspannkante.Suchen(
Reihe(600, (300, 360), (450, 460), (540, 601)), Regeln);
Assert.That(stücke, Has.Count.EqualTo(2), "das Zehnsekundenstück gehört nicht dazu");
}
/// <summary>Und ein durchgehender Abspann bleibt ein Stück.</summary>
///
/// <remarks>
/// Die wichtigste Prüfung dieser Gruppe: der Normalfall darf sich durch
/// die Stückelung nicht verändern.
/// </remarks>
[Test]
public void Ein_durchgehender_Abspann_bleibt_ein_Stueck()
{
var stücke = Abspannkante.Suchen(Reihe(600, (420, 601)), Regeln);
Assert.Multiple(() =>
{
Assert.That(stücke, Has.Count.EqualTo(1));
Assert.That(stücke[0].Von, Is.EqualTo(420.0).Within(2));
Assert.That(stücke[0].Bis, Is.EqualTo(600.0).Within(2));
});
}
}
@@ -127,13 +127,14 @@ public class BildabtasterLaufTests
var proben = await Abtaster().UeberblickAsync(_datei, ab: 30, dauer: 120);
Assert.That(proben, Is.Not.Empty, "der Filtergraph muss Zahlen liefern");
var grob = Abspannkante.Suchen(proben, Abspannregeln.Vorgabe);
Assert.That(grob, Is.Not.Null, "eine dunkle Strecke bis zum Ende muss auffallen");
var stücke = Abspannkante.Suchen(proben, Abspannregeln.Vorgabe);
Assert.That(stücke, Is.Not.Empty, "eine dunkle Strecke bis zum Ende muss auffallen");
var hell = Abspannkante.HellDavor(proben, grob!.Value, Abspannregeln.Vorgabe) ?? 0;
var grob = stücke[0].Von;
var hell = Abspannkante.HellDavor(proben, grob, Abspannregeln.Vorgabe) ?? 0;
var abtaster = Abtaster();
var kante = await Abspannkante.VerfeinernAsync(
hell, grob.Value, Abspannregeln.Vorgabe.Mindestanteil,
hell, grob, Abspannregeln.Vorgabe.Mindestanteil,
stelle => abtaster.DunkelanteilAsync(_datei, stelle));
Assert.That(kante, Is.EqualTo(90).Within(4),
@@ -146,7 +147,7 @@ public class BildabtasterLaufTests
{
var proben = await Abtaster().UeberblickAsync(_datei, ab: 0, dauer: 80);
Assert.That(Abspannkante.Suchen(proben, Abspannregeln.Vorgabe), Is.Null);
Assert.That(Abspannkante.Suchen(proben, Abspannregeln.Vorgabe), Is.Empty);
}
/// <summary>Eine einzelne Sekunde lässt sich messen — und liegt richtig.</summary>
+8
View File
@@ -701,6 +701,14 @@ export async function markenHolen(titelId: string): Promise<Marke[]> {
export const mediathekMarken = (id: string, umfang: Markenumfang) =>
anfrage<void>(`/libraries/${id}/marken?umfang=${umfang}`, { method: 'POST' });
/** Die Adresse der Messkurve eines Films — zum Herunterladen.
*
* **Keine Erkennung, sondern Zahlen zum Ansehen.** Welche Schwelle bei
* einem Abspann über laufendem Bild trägt, lässt sich nur an der Kurve
* entscheiden, nicht am Ergebnis der Erkennung. */
export const markenkurveAdresse = (filmId: string) =>
`/api/v1/movies/${filmId}/markenkurve`;
/** Nimmt die Marken eines Films wieder weg — wenn die Erkennung danebenlag. */
export const filmmarkenVerwerfen = (filmId: string) =>
anfrage<void>(`/movies/${filmId}/marken`, { method: 'DELETE' });
+10 -3
View File
@@ -886,9 +886,16 @@ export function spieler(anfrage: Spieleranfrage): void {
weiter ? 'btn' : 'btn btn-primary');
u.onclick = e => {
e.stopPropagation();
// Bei einem Film und bei der letzten Folge heisst „überspringen"
// Schluss: ans Ende zu spulen liesse ein stehendes Bild zurück.
if (abspann && !weiter) { void beenden(); return; }
// **Kommt hinter diesem Stück noch eines, ist der Film nicht aus.**
//
// Ein Abspann kann geteilt sein: davor der eine Teil, dann eine
// Post-Credit-Szene, dann der Rest. Hier stand „bei einem Film
// heisst überspringen Schluss" — und damit bekam niemand die Szene
// je zu sehen. Geschlossen wird nur, wenn nach dieser Marke nichts
// mehr kommt.
const danach = marken.some(m => m.startSeconds >= drin.endSeconds - 1);
if (abspann && !weiter && !danach) { void beenden(); return; }
void springen(drin.endSeconds);
};
markenreihe.appendChild(u);
+11 -1
View File
@@ -3,7 +3,7 @@ import { ApiFehler,
vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen,
type Film, type Serie, type Folge, type Datei, type Stand,
type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, markenSuchen,
filmmarkenSuchen, filmmarkenVerwerfen,
filmmarkenSuchen, filmmarkenVerwerfen, markenkurveAdresse,
} from '../api';
import {
esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen,
@@ -521,6 +521,16 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
window.setTimeout(() => void laden(), 4000);
}));
// **Die Zahlen hinter dem Urteil.** „Kein Abspann" nennt den Grund,
// aber nicht die Kurve — und ohne die lässt sich keine Schwelle
// begründen. Der Knopf lädt sie als Tabelle herunter.
reihe.appendChild(rundKnopf('protokoll', false, 'Messwerte des Abspanns holen',
async () => {
window.location.href = markenkurveAdresse(eintrag.id);
hinweisZeigen('Die Messung läuft — der Server sieht sich dafür die '
+ 'letzten zwanzig Minuten dreimal an.');
}));
// **Nur, wenn es etwas zu verwerfen gibt.** Ein Knopf, der nichts
// wegnehmen kann, ist eine Behauptung über einen Zustand.
if ('markers' in eintrag && (eintrag.markers?.length ?? 0) > 0) {