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:
co-authored by
Claude Opus 5
parent
8112c15d4d
commit
7673c73757
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -216,4 +216,84 @@ public class MarkenTests
|
||||
"die Folgenzahl löst den nächsten Lauf aus");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ein Film trägt seine Abspannmarke genauso.</summary>
|
||||
[Test]
|
||||
public async Task Ein_Film_bekommt_seine_Marke()
|
||||
{
|
||||
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var client = await Eingelesen(server, "Movies");
|
||||
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||
var film = filme!.Items[0].Id;
|
||||
|
||||
await server.InDerDatenbank(async db =>
|
||||
{
|
||||
db.Marken.Add(new Marke
|
||||
{
|
||||
MovieId = film, Art = Markenart.Abspann,
|
||||
VonSekunden = 6900, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var einzeln = await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film}");
|
||||
var liste = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(einzeln!.Markers!.Single().Kind, Is.EqualTo("credits"));
|
||||
Assert.That(einzeln.Markers!.Single().StartSeconds, Is.EqualTo(6900));
|
||||
Assert.That(liste!.Items[0].Markers, Has.Count.EqualTo(1), "auch in der Liste");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Eine falsche Marke lässt sich wieder wegnehmen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Eine_falsche_Filmmarke_laesst_sich_verwerfen()
|
||||
{
|
||||
Datei("Matrix (1999)/Matrix (1999).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var client = await Eingelesen(server, "Movies");
|
||||
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||
var film = filme!.Items[0].Id;
|
||||
|
||||
await server.InDerDatenbank(async db =>
|
||||
{
|
||||
db.Marken.Add(new Marke
|
||||
{
|
||||
MovieId = film, Art = Markenart.Abspann,
|
||||
VonSekunden = 60, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow,
|
||||
});
|
||||
db.Markensuchen.Add(new Markensuche
|
||||
{
|
||||
MovieId = film, Art = Markenart.Abspann, Folgen = 1,
|
||||
GelaufenAm = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var weg = await client.DeleteAsync($"/api/v1/movies/{film}/marken");
|
||||
var danach = await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film}");
|
||||
|
||||
var vermerke = 0;
|
||||
await server.InDerDatenbank(async db =>
|
||||
vermerke = await db.Markensuchen.CountAsync(x => x.MovieId == film));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
|
||||
Assert.That(danach!.Markers, Is.Empty);
|
||||
Assert.That(vermerke, Is.EqualTo(1),
|
||||
"der Vermerk bleibt, sonst legte der nächste Scan dieselbe Marke wieder an");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using Flaro.Core.Media;
|
||||
|
||||
namespace Flaro.Core.Tests;
|
||||
|
||||
/// <summary>Wo der Abspann eines Films anfängt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Mit erfundenen Messreihen statt mit Filmen.</b> Geprüft wird das
|
||||
/// Beurteilen, nicht ob ffmpeg rechnen kann. Die Zahlen stammen aus einer
|
||||
/// Messung an einem erzeugten Film: Handlung 125, Abspann 18.
|
||||
/// </remarks>
|
||||
public class AbspannkanteTests
|
||||
{
|
||||
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
|
||||
|
||||
/// <summary>Eine Reihe von Messpunkten im Abstand von zehn Sekunden.</summary>
|
||||
///
|
||||
/// <param name="abspannAb">Ab wann dunkel. <c>null</c> heisst: nie.</param>
|
||||
private static List<Bildprobe> Reihe(double von, double bis, double? abspannAb)
|
||||
{
|
||||
var proben = new List<Bildprobe>();
|
||||
for (var t = von; t <= bis; t += 10)
|
||||
proben.Add(new Bildprobe(t, abspannAb is { } ab && t >= ab ? 18 : 125));
|
||||
return proben;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Eine_dunkle_Strecke_bis_zum_Ende_ist_der_Abspann()
|
||||
{
|
||||
var proben = Reihe(0, 600, abspannAb: 420);
|
||||
|
||||
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(420));
|
||||
}
|
||||
|
||||
/// <summary>Ein Film, der hell endet, hat keinen erkennbaren Abspann.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Namen über laufendem Bild — Marvel, Pixar, jede zweite Komödie. Kein
|
||||
/// Fund ist hier die richtige Antwort: ein geratener Knopf schnitte
|
||||
/// Handlung weg.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Ohne_dunkles_Ende_kein_Fund()
|
||||
{
|
||||
Assert.That(Abspannkante.Suchen(Reihe(0, 600, abspannAb: null), Regeln), Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>Eine Schwarzblende mitten im Film zählt nicht.</summary>
|
||||
[Test]
|
||||
public void Eine_Schwarzblende_in_der_Mitte_zaehlt_nicht()
|
||||
{
|
||||
var proben = Reihe(0, 600, abspannAb: null);
|
||||
for (var i = 20; i < 30; i++) proben[i] = proben[i] with { Helligkeit = 10 };
|
||||
|
||||
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null,
|
||||
"sie hält nicht bis zum Ende durch");
|
||||
}
|
||||
|
||||
/// <summary>Eine Szene nach dem Abspann nimmt den Fund weg.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Lieber keine Marke als eine, die über die Szene hinwegspringt.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Eine_Szene_nach_dem_Abspann_nimmt_den_Fund_weg()
|
||||
{
|
||||
var proben = Reihe(0, 600, abspannAb: 420);
|
||||
for (var i = proben.Count - 5; i < proben.Count; i++)
|
||||
proben[i] = proben[i] with { Helligkeit = 120 };
|
||||
|
||||
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>Eine helle Schrifttafel mittendrin bricht die Strecke nicht.</summary>
|
||||
[Test]
|
||||
public void Eine_kurze_helle_Tafel_bricht_nichts()
|
||||
{
|
||||
var proben = Reihe(0, 600, abspannAb: 420);
|
||||
// Zwei Proben, also zwanzig Sekunden, sind hell.
|
||||
proben[50] = proben[50] with { Helligkeit = 140 };
|
||||
proben[51] = proben[51] with { Helligkeit = 140 };
|
||||
|
||||
Assert.That(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);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Die_letzte_helle_Probe_begrenzt_die_Suche()
|
||||
{
|
||||
var proben = Reihe(0, 600, abspannAb: 420);
|
||||
|
||||
Assert.That(Abspannkante.HellDavor(proben, 420, Regeln), Is.EqualTo(410));
|
||||
}
|
||||
|
||||
// ---------- Nachmessen ----------
|
||||
|
||||
/// <summary>Die binäre Suche findet die Kante auf vier Sekunden genau.</summary>
|
||||
[Test]
|
||||
public async Task Die_Kante_wird_eingegrenzt()
|
||||
{
|
||||
const double echteKante = 417.3;
|
||||
var sonden = 0;
|
||||
|
||||
var kante = await Abspannkante.VerfeinernAsync(410, 420, Regeln, stelle =>
|
||||
{
|
||||
sonden++;
|
||||
return Task.FromResult<double?>(stelle >= echteKante ? 18 : 125);
|
||||
});
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(kante, Is.EqualTo(echteKante).Within(4));
|
||||
Assert.That(kante, Is.GreaterThanOrEqualTo(echteKante),
|
||||
"lieber ein paar Sekunden Abspann zu viel als eine Sekunde Handlung");
|
||||
Assert.That(sonden, Is.LessThanOrEqualTo(4), "zehn Sekunden auf vier: zwei Halbierungen");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Antwortet eine Sonde nicht, gilt der grobe Fund.</summary>
|
||||
[Test]
|
||||
public async Task Ohne_Antwort_bleibt_es_beim_groben_Fund()
|
||||
{
|
||||
var kante = await Abspannkante.VerfeinernAsync(
|
||||
410, 420, Regeln, _ => Task.FromResult<double?>(null));
|
||||
|
||||
Assert.That(kante, Is.EqualTo(420));
|
||||
}
|
||||
|
||||
/// <summary>Ein schon enger Bereich braucht keine Sonde.</summary>
|
||||
[Test]
|
||||
public async Task Ein_enger_Bereich_wird_nicht_angefasst()
|
||||
{
|
||||
var sonden = 0;
|
||||
var kante = await Abspannkante.VerfeinernAsync(418, 420, Regeln, _ =>
|
||||
{
|
||||
sonden++;
|
||||
return Task.FromResult<double?>(18);
|
||||
});
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(kante, Is.EqualTo(420));
|
||||
Assert.That(sonden, Is.Zero);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Diagnostics;
|
||||
using Flaro.Core.Media;
|
||||
using Flaro.Media;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Flaro.Media.Tests;
|
||||
|
||||
/// <summary>Die Ausgabe des Abtasters lesen — ohne ffmpeg.</summary>
|
||||
public class BildabtasterLesenTests
|
||||
{
|
||||
[Test]
|
||||
public void Zeit_und_Wert_werden_zu_Proben()
|
||||
{
|
||||
var ausgabe = """
|
||||
frame:0 pts:30720 pts_time:2.5
|
||||
lavfi.signalstats.YAVG=125.065
|
||||
frame:1 pts:61440 pts_time:5
|
||||
lavfi.signalstats.YAVG=18.4
|
||||
|
||||
""";
|
||||
|
||||
var proben = Bildabtaster.Lesen(ausgabe, versatz: 60);
|
||||
|
||||
Assert.That(proben, Is.EqualTo(new[]
|
||||
{
|
||||
new Bildprobe(62.5, 125.065),
|
||||
new Bildprobe(65, 18.4),
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Ohne Zeitstempel kein Messpunkt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Eine Wertzeile ohne vorangehende Zeitzeile gehört zu keinem
|
||||
/// Zeitpunkt. Sie trotzdem zu nehmen hiesse, sie der vorigen Zeit
|
||||
/// zuzuschlagen — und damit eine Messung zu erfinden.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Ein_Wert_ohne_Zeit_faellt_heraus()
|
||||
{
|
||||
Assert.That(Bildabtaster.Lesen("lavfi.signalstats.YAVG=18.4\n", 0), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Leere_Ausgabe_ergibt_nichts()
|
||||
{
|
||||
Assert.That(Bildabtaster.Lesen("", 0), Is.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Den ganzen Weg an einer erzeugten Datei.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Die einzige Prüfung hier, die ffmpeg braucht</b> — und sie ist es
|
||||
/// wert: zwischen „der Filtergraph ist richtig geschrieben" und „die
|
||||
/// Zahlen kommen an der richtigen Stelle heraus" liegt alles, was man
|
||||
/// sonst erst am Bestand merkt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Die Datei entsteht im Test: 90 Sekunden Bild, danach 60 Sekunden weisse
|
||||
/// Schrift auf schwarzem Grund. Fehlt ffmpeg, wird übersprungen statt
|
||||
/// rotgemeldet — ein fehlendes Werkzeug ist kein Fehler im Quelltext.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class BildabtasterLaufTests
|
||||
{
|
||||
private string _ordner = "";
|
||||
private string _datei = "";
|
||||
|
||||
[OneTimeSetUp]
|
||||
public void Erzeugen()
|
||||
{
|
||||
_ordner = Directory.CreateDirectory(Path.Combine(
|
||||
Path.GetTempPath(), "flaro-abspann-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
_datei = Path.Combine(_ordner, "pruef.mp4");
|
||||
|
||||
var start = new ProcessStartInfo("ffmpeg")
|
||||
{
|
||||
ArgumentList =
|
||||
{
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=640x360:rate=24:duration=90",
|
||||
"-f", "lavfi", "-i", "color=c=black:s=640x360:r=24:d=60",
|
||||
"-f", "lavfi", "-i", "color=c=white:s=180x14:r=24:d=60",
|
||||
"-filter_complex",
|
||||
"[1][2]overlay=x=230:y='H-mod(t*45,H+h)'[cr];[0][cr]concat=n=2:v=1:a=0[v]",
|
||||
"-map", "[v]", "-c:v", "libx264", "-preset", "ultrafast",
|
||||
"-g", "48", "-pix_fmt", "yuv420p", _datei,
|
||||
},
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var prozess = Process.Start(start);
|
||||
prozess!.WaitForExit(60_000);
|
||||
if (prozess.ExitCode != 0 || !File.Exists(_datei))
|
||||
Assert.Ignore("ffmpeg konnte die Prüfdatei nicht erzeugen.");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Assert.Ignore("Ohne ffmpeg lässt sich das nicht prüfen.");
|
||||
}
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private static Bildabtaster Abtaster() =>
|
||||
new(FfmpegOptions.FromEnvironment(), NullLogger<Bildabtaster>.Instance);
|
||||
|
||||
/// <summary>Die Kante liegt da, wo sie erzeugt wurde: bei 90 Sekunden.</summary>
|
||||
[Test]
|
||||
public async Task Der_Abspann_wird_gefunden()
|
||||
{
|
||||
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 hell = Abspannkante.HellDavor(proben, grob!.Value, Abspannregeln.Vorgabe) ?? 0;
|
||||
var abtaster = Abtaster();
|
||||
var kante = await Abspannkante.VerfeinernAsync(
|
||||
hell, grob.Value, Abspannregeln.Vorgabe,
|
||||
stelle => abtaster.HelligkeitAsync(_datei, stelle));
|
||||
|
||||
Assert.That(kante, Is.EqualTo(90).Within(4),
|
||||
"erzeugt wurde der Übergang bei genau 90 Sekunden");
|
||||
}
|
||||
|
||||
/// <summary>Im hellen Teil gibt es nichts zu finden.</summary>
|
||||
[Test]
|
||||
public async Task Im_hellen_Teil_kein_Fund()
|
||||
{
|
||||
var proben = await Abtaster().UeberblickAsync(_datei, ab: 0, dauer: 80);
|
||||
|
||||
Assert.That(Abspannkante.Suchen(proben, Abspannregeln.Vorgabe), Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>Eine einzelne Sekunde lässt sich messen.</summary>
|
||||
[Test]
|
||||
public async Task Eine_Sekunde_im_Abspann_ist_dunkel()
|
||||
{
|
||||
var dunkel = await Abtaster().HelligkeitAsync(_datei, bei: 120);
|
||||
var hell = await Abtaster().HelligkeitAsync(_datei, bei: 40);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(dunkel, Is.Not.Null.And.LessThan(Abspannregeln.Vorgabe.Dunkelschwelle));
|
||||
Assert.That(hell, Is.Not.Null.And.GreaterThan(Abspannregeln.Vorgabe.Dunkelschwelle));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Flaro.Media;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Flaro.Media.Tests;
|
||||
|
||||
/// <summary>Sagt die Schlüsselbildsuche dasselbe wie ffmpeg?</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Der Fehler, für den diese Prüfung steht.</b> Die Suche las mit
|
||||
/// <c>-read_intervals</c> bis eine halbe Sekunde hinter den Wunsch — und
|
||||
/// ffprobe hörte damit regelmässig eine ganze Bildgruppe zu früh auf. Der
|
||||
/// Server meldete dem Spieler dann einen Anfang, der vier Sekunden vor dem
|
||||
/// lag, an dem der Strom wirklich begann; die Untertitel kamen vier
|
||||
/// Sekunden zu spät.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Gegen ffmpeg geprüft, nicht gegen eine Erwartung.</b> Was zählt, ist
|
||||
/// nicht, ob die Zahl „schön" ist, sondern ob sie mit der Stelle
|
||||
/// übereinstimmt, an der der Strom tatsächlich anfängt. Die holt derselbe
|
||||
/// Aufruf wie im Betrieb, nur mit <c>-copyts</c>, damit die Zeit absolut
|
||||
/// bleibt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SchluesselbilderTests
|
||||
{
|
||||
private string _ordner = "";
|
||||
private string _datei = "";
|
||||
|
||||
/// <summary>Vier Sekunden — so kodiert, was von Streamingdiensten kommt.</summary>
|
||||
private const double Bildgruppe = 4;
|
||||
|
||||
[OneTimeSetUp]
|
||||
public void Erzeugen()
|
||||
{
|
||||
_ordner = Directory.CreateDirectory(Path.Combine(
|
||||
Path.GetTempPath(), "flaro-keyframe-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
_datei = Path.Combine(_ordner, "quelle.mp4");
|
||||
|
||||
var gelungen = Starten("ffmpeg",
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=120",
|
||||
"-c:v", "libx264", "-preset", "ultrafast",
|
||||
"-g", "100", "-pix_fmt", "yuv420p", _datei);
|
||||
|
||||
if (!gelungen || !File.Exists(_datei))
|
||||
Assert.Ignore("Ohne ffmpeg lässt sich das nicht prüfen.");
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
/// <summary>Die Stelle, an der ffmpeg beim Kopieren wirklich anfängt.</summary>
|
||||
private double? EchterAnfang(double ab)
|
||||
{
|
||||
var ziel = Path.Combine(_ordner, "schnitt.mp4");
|
||||
var gelungen = Starten("ffmpeg",
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-noaccurate_seek", "-ss", ab.ToString("0.###", CultureInfo.InvariantCulture),
|
||||
"-i", _datei, "-map", "0:0", "-c", "copy", "-copyts", "-f", "mp4", ziel);
|
||||
if (!gelungen) return null;
|
||||
|
||||
var ausgabe = Lesen("ffprobe",
|
||||
"-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=start_time", "-of", "csv=p=0", ziel);
|
||||
|
||||
return double.TryParse(ausgabe?.Trim(), NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var wert) ? wert : null;
|
||||
}
|
||||
|
||||
/// <summary>Jeder dieser Werte lag vorher um eine ganze Bildgruppe daneben.</summary>
|
||||
[TestCase(50.0)]
|
||||
[TestCase(50.5)]
|
||||
[TestCase(52.0)]
|
||||
[TestCase(52.1)]
|
||||
[TestCase(56.0)]
|
||||
[TestCase(73.3)]
|
||||
[TestCase(100.2)]
|
||||
public async Task Die_Suche_trifft_den_Anfang_des_Stroms(double wunsch)
|
||||
{
|
||||
var suche = new Schluesselbilder(
|
||||
FfmpegOptions.FromEnvironment(), NullLogger<Schluesselbilder>.Instance);
|
||||
|
||||
var gemeldet = await suche.DavorAsync(_datei, wunsch);
|
||||
var echt = EchterAnfang(wunsch);
|
||||
|
||||
if (echt is null) Assert.Ignore("ffmpeg lieferte keinen Anfang.");
|
||||
var stelle = echt!.Value;
|
||||
|
||||
Assert.That(gemeldet, Is.EqualTo(stelle).Within(0.05),
|
||||
$"gemeldet {gemeldet}, tatsächlich {stelle} — die Differenz landet "
|
||||
+ "eins zu eins im Versatz der Untertitel");
|
||||
}
|
||||
|
||||
/// <summary>Und der Fund ist nie später als der Wunsch.</summary>
|
||||
[TestCase(50.5)]
|
||||
[TestCase(73.3)]
|
||||
public async Task Nie_hinter_dem_Wunsch(double wunsch)
|
||||
{
|
||||
var suche = new Schluesselbilder(
|
||||
FfmpegOptions.FromEnvironment(), NullLogger<Schluesselbilder>.Instance);
|
||||
|
||||
Assert.That(await suche.DavorAsync(_datei, wunsch), Is.LessThanOrEqualTo(wunsch));
|
||||
}
|
||||
|
||||
// ---------- Werkzeug ----------
|
||||
|
||||
private static bool Starten(string programm, params string[] argumente)
|
||||
{
|
||||
var start = new ProcessStartInfo(programm)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (var a in argumente) start.ArgumentList.Add(a);
|
||||
|
||||
try
|
||||
{
|
||||
using var prozess = Process.Start(start);
|
||||
if (prozess is null) return false;
|
||||
prozess.WaitForExit(120_000);
|
||||
return prozess.ExitCode == 0;
|
||||
}
|
||||
catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
private static string? Lesen(string programm, params string[] argumente)
|
||||
{
|
||||
var start = new ProcessStartInfo(programm)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (var a in argumente) start.ArgumentList.Add(a);
|
||||
|
||||
try
|
||||
{
|
||||
using var prozess = Process.Start(start);
|
||||
if (prozess is null) return null;
|
||||
var text = prozess.StandardOutput.ReadToEnd();
|
||||
prozess.WaitForExit(60_000);
|
||||
return prozess.ExitCode == 0 ? text : null;
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user