Ein Bild, wo bisher nur eine Zahl stand
Wer zur Zeitleiste greift, sieht jetzt das Standbild der Stelle — im Web beim Überfahren, in der App beim Ziehen. **Erzeugt wird beim Abspielen, nicht auf Vorrat.** Ein Lauf über 453 Filme und 4213 Folgen wäre ein Abend ffmpeg und mehrere Gigabyte für Titel, die niemand anfasst. Startet jemand einen Titel, stellt sich die Datei hinten an; bis zum ersten Griff zur Leiste ist der Lauf durch. Gemessen an einer erzeugten Datei: 191 ms für 250 Sekunden Film, ein Zweistünder liegt damit bei etwa fünf Sekunden. **Ein ffmpeg-Aufruf, nicht siebenhundert.** `fps=1/10,scale=320:-2, tile=10x10` macht aus einem Durchlauf fertige Blätter zu hundert Kacheln. Bezahlbar ist das durch `-skip_frame nokey` — dieselbe Abkürzung wie beim Abtasten der Abspannhelligkeit: nur Keyframes dekodieren. Dass die Vorschau dadurch um zwei Sekunden danebenliegen kann, sieht niemand. **Der Spieler lädt ein Blatt und verschiebt danach nur den Ausschnitt.** Beim Fahren über die Leiste wird kein einziger Abruf mehr fällig. Die Rechnung Sekunde → Kachel steht an drei Stellen — Server, Web, App — und deshalb auch dreimal dieselbe; weicht eine ab, hält man die Vorschau für ein unscharfes Bild statt für einen Fehler. Neun Prüfungen decken die Ränder ab: Sekunde null, letzte Kachel eines Blattes, erste des nächsten, und hinter dem Ende. **Die Kachelmasse werden gemessen, nicht gerechnet.** `scale=320:-2` hält das Seitenverhältnis, ein Breitbildfilm ergibt also flachere Kacheln. Geraten wäre jede Kachel um ein paar Bildpunkte verschoben. Aufgeräumt wird mit dem Transcode-Cache: der Namensanfang trägt Kennung und Änderungszeit der Mediendatei, verwaiste Blätter sind daran ohne Tabelle erkennbar. Der Ordner taucht unter „Speicher" auf. **Und die Pfeiltasten spulen wieder.** Hatte die Zeitleiste den Fokus, überliess der Spieler die Pfeile dem Browser — der verschiebt `value` und löst `input` aus, aber `change` kommt bei Tastatureingabe nicht verlässlich, also wurde nie gesprungen. Die Ausnahme fällt weg; Pfeil heisst überall ±10 Sekunden. Nativ wären es ohnehin 100 Millisekunden gewesen, weil `step` auf 0,1 steht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ed13d5fa43
commit
68abd71074
@@ -1077,3 +1077,21 @@ public sealed record Sprachauswahl(
|
||||
IReadOnlyList<Sprachangabe> Audio,
|
||||
IReadOnlyList<Sprachangabe> Subtitles,
|
||||
int Partial);
|
||||
|
||||
/// <summary>Wie die Vorschaubilder eines Titels zusammengesetzt sind.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Kachelmasse stehen nicht fest.</b> Skaliert wird auf eine feste
|
||||
/// Breite, die Höhe folgt dem Seitenverhältnis — ein Breitbildfilm ergibt
|
||||
/// flachere Kacheln. Deshalb werden sie gemessen und mitgeteilt; geraten
|
||||
/// wäre jede Kachel um ein paar Bildpunkte verschoben, und das sieht man
|
||||
/// nicht als Fehler, sondern hält es für ein unscharfes Bild.
|
||||
/// </remarks>
|
||||
public sealed record VorschauAuskunft(
|
||||
bool Fertig,
|
||||
double Intervall,
|
||||
int Spalten,
|
||||
int Zeilen,
|
||||
int KachelBreite,
|
||||
int KachelHoehe,
|
||||
int Blaetter);
|
||||
|
||||
@@ -62,7 +62,7 @@ public static class StreamEndpoints
|
||||
app.MapPost("/api/v1/files/{id:guid}/stream", async (
|
||||
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
|
||||
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr,
|
||||
Untertitelvorrat vorrat) =>
|
||||
Untertitelvorrat vorrat, Vorschauvorrat vorschau) =>
|
||||
{
|
||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
|
||||
@@ -84,6 +84,13 @@ public static class StreamEndpoints
|
||||
// Antwort statt eines Fehlers.
|
||||
vorrat.Anstellen(gewuenschte.Id, vordraengeln: true);
|
||||
|
||||
// **Und die Vorschaubilder gleich mit.** Denselben Grund: sie
|
||||
// entstehen aus einem Durchlauf über die Datei, und bis jemand
|
||||
// zur Zeitleiste greift, ist der längst durch. Auf Vorrat über
|
||||
// den ganzen Bestand wären es Gigabyte für Titel, die niemand
|
||||
// anfasst.
|
||||
vorschau.Anstellen(gewuenschte.Id);
|
||||
|
||||
var profil = Profil(anfrage);
|
||||
var hoehe = anfrage.Height is { } gewuenscht
|
||||
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
|
||||
@@ -400,6 +407,68 @@ public static class StreamEndpoints
|
||||
// in dessen Zukunft, und zwar für den ganzen Rest des Films. Gemessen
|
||||
// im Spieler: bei „Fortsetzen ab 8:37" stand der Satz von Sekunde 0
|
||||
// auf dem Bild.
|
||||
// **Die Vorschau beim Überfahren der Zeitleiste.**
|
||||
//
|
||||
// Zwei Endpunkte: die Auskunft sagt, wie die Blätter aussehen, das
|
||||
// Blatt liefert die Bilder. Der Spieler holt die Auskunft einmal
|
||||
// und danach nur noch Blätter — beim Fahren über die Leiste wird
|
||||
// kein einziger Abruf mehr fällig, nur der Ausschnitt verschoben.
|
||||
app.MapGet("/api/v1/files/{id:guid}/vorschau", async (
|
||||
Guid id, FlaroContext db, Vorschauvorrat vorschau, TimeProvider uhr,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
if (datei is null || !File.Exists(datei.Path))
|
||||
return Einwand.Fehlt("Diese Datei liegt nicht mehr da.");
|
||||
|
||||
// **Mit Freigabeprüfung, anders als beim Untertitel.** Ein
|
||||
// Standbild zeigt den Inhalt — bei einem Titel, der erst
|
||||
// nächste Woche gilt, wäre es genau das, was die Freigabe
|
||||
// verhindern soll.
|
||||
if (await DeliveryEndpoints.FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht)
|
||||
return DeliveryEndpoints.NochNicht(nochNicht);
|
||||
|
||||
// Wer fragt, will sie auch: anstellen, falls noch nichts da ist.
|
||||
vorschau.Anstellen(id);
|
||||
|
||||
var plan = vorschau.Liegtvor(id, datei.ModifiedAt);
|
||||
return Results.Ok(new VorschauAuskunft(
|
||||
plan is not null,
|
||||
plan?.Intervall ?? Vorschaubilder.Intervall,
|
||||
plan?.Spalten ?? Vorschaubilder.Spalten,
|
||||
plan?.Zeilen ?? Vorschaubilder.Zeilen,
|
||||
plan?.KachelBreite ?? 0,
|
||||
plan?.KachelHoehe ?? 0,
|
||||
plan?.Blaetter ?? 0));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/files/{id:guid}/vorschau/{nummer:int}.jpg", async (
|
||||
Guid id, int nummer, FlaroContext db, Vorschauvorrat vorschau, TimeProvider uhr,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
if (datei is null) return Einwand.Fehlt("Diese Datei liegt nicht mehr da.");
|
||||
|
||||
if (await DeliveryEndpoints.FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht)
|
||||
return DeliveryEndpoints.NochNicht(nochNicht);
|
||||
|
||||
var blatt = vorschau.Blatt(id, datei.ModifiedAt, nummer);
|
||||
return blatt is null
|
||||
? Einwand.Fehlt("Dieses Blatt gibt es nicht.")
|
||||
: Results.File(blatt, "image/jpeg");
|
||||
})
|
||||
// **Für immer gültig.** Der Dateiname trägt die Änderungszeit der
|
||||
// Mediendatei; wird sie ersetzt, ändert sich die Adresse. Dasselbe
|
||||
// Versprechen wie bei den Bildern aus `ImageEndpoints`.
|
||||
.AddEndpointFilter(async (zusammenhang, weiter) =>
|
||||
{
|
||||
var antwort = await weiter(zusammenhang);
|
||||
zusammenhang.HttpContext.Response.Headers.CacheControl =
|
||||
"private, max-age=31536000, immutable";
|
||||
return antwort;
|
||||
})
|
||||
.RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
|
||||
Guid id, int spur, FlaroContext db, Untertitelvorrat vorrat,
|
||||
CancellationToken ct, double ab = 0) =>
|
||||
|
||||
@@ -184,6 +184,8 @@ builder.Services.AddSingleton<PackageCoordinator>();
|
||||
builder.Services.AddSingleton<Streamkoordinator>();
|
||||
builder.Services.AddSingleton<UntertitelLeser>();
|
||||
builder.Services.AddSingleton<Untertitelvorrat>();
|
||||
builder.Services.AddSingleton<Vorschaubilder>();
|
||||
builder.Services.AddSingleton<Vorschauvorrat>();
|
||||
// Der Transcode-Cache räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
|
||||
builder.Services.AddScoped<Transcodeeinstellungen>();
|
||||
builder.Services.AddSingleton<Transcodepflege>();
|
||||
|
||||
@@ -65,11 +65,14 @@ public sealed class Transcodepflege(
|
||||
var (paketfrei, paketzahl) = await PaketeAufraeumenAsync(jetzt, ct);
|
||||
var (textfrei, textzahl) = await UntertitelAufraeumenAsync(ct);
|
||||
|
||||
var (vorschaufrei, vorschauzahl) = await VorschauAufraeumenAsync(ct);
|
||||
|
||||
var teile = new List<Teilbericht>
|
||||
{
|
||||
new("stroeme", stromfrei, 0),
|
||||
new("packages", paketfrei, paketzahl),
|
||||
new("untertitel", textfrei, textzahl),
|
||||
new("vorschau", vorschaufrei, vorschauzahl),
|
||||
};
|
||||
|
||||
var bericht = new Aufraeumbericht(
|
||||
@@ -159,6 +162,66 @@ public sealed class Transcodepflege(
|
||||
/// lässt den Server bei jedem Start eine vierzig Gigabyte grosse Datei
|
||||
/// neu durchlesen, um wieder nichts zu finden.
|
||||
/// </remarks>
|
||||
/// <summary>Vorschaublätter, zu denen es keine Datei mehr gibt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Nur verwaist, wie bei den Untertiteln.</b> Weder Alter noch
|
||||
/// Obergrenze: Blätter entstehen beim Abspielen, und wer einen Film
|
||||
/// zweimal im Jahr ansieht, soll beim zweiten Mal nicht wieder warten.
|
||||
/// Sie sind klein — ein ganzer Film liegt bei wenigen Megabyte.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Verwaist heisst: der Namensanfang passt zu nichts.</b> Er trägt
|
||||
/// Kennung und Änderungszeit der Mediendatei; wird sie ersetzt oder
|
||||
/// verschwindet sie, gehören die Blätter zu niemandem mehr. Dafür
|
||||
/// braucht es keine Tabelle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<(long Frei, int Dateien)> VorschauAufraeumenAsync(CancellationToken ct)
|
||||
{
|
||||
var ordner = Path.Combine(pfade.Transcode, "vorschau");
|
||||
if (!Directory.Exists(ordner)) return (0, 0);
|
||||
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
|
||||
var gefunden = new List<(string Pfad, Guid Id, long Ms)>();
|
||||
foreach (var pfad in Directory.EnumerateFiles(ordner))
|
||||
{
|
||||
var teile = Path.GetFileNameWithoutExtension(pfad).Split('-');
|
||||
if (teile.Length < 2
|
||||
|| !Guid.TryParseExact(teile[0], "N", out var id)
|
||||
|| !long.TryParse(teile[1], out var ms))
|
||||
{
|
||||
// Was hier nicht hingehört, gehört weg.
|
||||
gefunden.Add((pfad, Guid.Empty, -1));
|
||||
continue;
|
||||
}
|
||||
|
||||
gefunden.Add((pfad, id, ms));
|
||||
}
|
||||
|
||||
if (gefunden.Count == 0) return (0, 0);
|
||||
|
||||
var kennungen = gefunden.Where(g => g.Ms >= 0).Select(g => g.Id).Distinct().ToList();
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(f => kennungen.Contains(f.Id))
|
||||
.Select(f => new { f.Id, f.ModifiedAt })
|
||||
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
long frei = 0;
|
||||
var anzahl = 0;
|
||||
foreach (var g in gefunden)
|
||||
{
|
||||
if (g.Ms >= 0 && dateien.TryGetValue(g.Id, out var ms) && ms == g.Ms) continue;
|
||||
frei += Loeschen(g.Pfad);
|
||||
anzahl++;
|
||||
}
|
||||
|
||||
return (frei, anzahl);
|
||||
}
|
||||
|
||||
private async Task<(long Frei, int Dateien)> UntertitelAufraeumenAsync(CancellationToken ct)
|
||||
{
|
||||
var ordner = Path.Combine(pfade.Transcode, "untertitel");
|
||||
@@ -240,6 +303,7 @@ public sealed class Transcodepflege(
|
||||
("stroeme", stroeme.Wurzel),
|
||||
("packages", pakete.Ordner),
|
||||
("untertitel", Path.Combine(pfade.Transcode, "untertitel")),
|
||||
("vorschau", Path.Combine(pfade.Transcode, "vorschau")),
|
||||
};
|
||||
|
||||
return ordner.Select(o => new Teilbericht(o.Item1, Streamkoordinator.Belegung(o.Item2), Zaehlen(o.Item2)))
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using Flaro.Core.Media;
|
||||
using Flaro.Data;
|
||||
using Flaro.Media;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Erzeugt Vorschaubilder im Hintergrund, eine Datei nach der anderen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Beim Abspielen, nicht auf Vorrat über den Bestand.</b> Bei 453
|
||||
/// Filmen und 4213 Folgen wäre ein Lauf über alles ein Abend ffmpeg und
|
||||
/// mehrere Gigabyte für Titel, die niemand anfasst. Wer einen Titel
|
||||
/// startet, braucht die Bilder aber selten in den ersten Sekunden —
|
||||
/// bis zum ersten Griff zur Leiste ist der Lauf längst durch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Eine zur Zeit.</b> Jeder Lauf liest eine Mediendatei von vorn bis
|
||||
/// hinten durch; zwei gleichzeitig hiesse zwei Leseköpfe auf derselben
|
||||
/// Platte, und nebenher läuft vielleicht noch ein Umrechnen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Vorschauvorrat(
|
||||
IServiceScopeFactory scopes,
|
||||
FlaroPaths pfade,
|
||||
Vorschaubilder bilder,
|
||||
ILogger<Vorschauvorrat> log)
|
||||
{
|
||||
private readonly LinkedList<Guid> _reihe = new();
|
||||
private readonly Lock _schloss = new();
|
||||
private bool _arbeitet;
|
||||
|
||||
/// <summary>Welche Datei gerade dran ist — für die Auskunft.</summary>
|
||||
public Guid? Laufende { get; private set; }
|
||||
|
||||
private string Ordner => Path.Combine(pfade.Transcode, "vorschau");
|
||||
|
||||
/// <summary>Der gemeinsame Namensanfang der Blätter einer Datei.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Mit der Änderungszeit, und das ist der Trick.</b> Wird die Datei
|
||||
/// ersetzt, ändert sich der Anfang — die alten Blätter gehören dann
|
||||
/// zu niemandem mehr, und das Aufräumen erkennt sie daran, ohne dass
|
||||
/// irgendwo eine Tabelle mitgeführt werden müsste. Dasselbe Schema
|
||||
/// wie beim Untertitelvorrat.
|
||||
/// </remarks>
|
||||
public static string Praefix(Guid dateiId, DateTimeOffset geaendert) =>
|
||||
$"{dateiId:N}-{geaendert.ToUnixTimeMilliseconds()}";
|
||||
|
||||
/// <summary>Was schon dasteht — oder nichts.</summary>
|
||||
public Vorschauplan? Liegtvor(Guid dateiId, DateTimeOffset geaendert)
|
||||
{
|
||||
var praefix = Praefix(dateiId, geaendert);
|
||||
if (!Directory.Exists(Ordner)) return null;
|
||||
|
||||
var blaetter = Directory.GetFiles(Ordner, $"{praefix}-*.jpg").Length;
|
||||
if (blaetter == 0) return null;
|
||||
|
||||
// Die Masse stehen in der Begleitdatei: sie hängen am
|
||||
// Seitenverhältnis des Films und lassen sich nicht herleiten.
|
||||
var masse = Path.Combine(Ordner, $"{praefix}.masse");
|
||||
if (!File.Exists(masse)) return null;
|
||||
|
||||
var teile = File.ReadAllText(masse).Split('x');
|
||||
if (teile.Length != 2
|
||||
|| !int.TryParse(teile[0], out var breite)
|
||||
|| !int.TryParse(teile[1], out var hoehe))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Vorschauplan(
|
||||
Vorschaubilder.Intervall, Vorschaubilder.Spalten, Vorschaubilder.Zeilen,
|
||||
breite, hoehe, blaetter);
|
||||
}
|
||||
|
||||
/// <summary>Stellt eine Datei an, wenn ihre Bilder noch fehlen.</summary>
|
||||
public void Anstellen(Guid dateiId)
|
||||
{
|
||||
lock (_schloss)
|
||||
{
|
||||
if (Laufende == dateiId || _reihe.Contains(dateiId)) return;
|
||||
_reihe.AddLast(dateiId);
|
||||
if (_arbeitet) return;
|
||||
_arbeitet = true;
|
||||
}
|
||||
|
||||
_ = Task.Run(AbarbeitenAsync);
|
||||
}
|
||||
|
||||
private async Task AbarbeitenAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Guid naechste;
|
||||
lock (_schloss)
|
||||
{
|
||||
if (_reihe.First is null) { _arbeitet = false; Laufende = null; return; }
|
||||
naechste = _reihe.First.Value;
|
||||
_reihe.RemoveFirst();
|
||||
Laufende = naechste;
|
||||
}
|
||||
|
||||
try { await ZiehenAsync(naechste); }
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Vorschau für {Datei} abgebrochen: {Grund}",
|
||||
naechste, Fehlertext.FuerMenschen(fehler));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ZiehenAsync(Guid dateiId)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
|
||||
var datei = await db.MediaFiles.AsNoTracking()
|
||||
.Where(f => f.Id == dateiId)
|
||||
.Select(f => new { f.Path, f.ModifiedAt })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (datei is null || !File.Exists(datei.Path)) return;
|
||||
|
||||
var praefix = Praefix(dateiId, datei.ModifiedAt);
|
||||
if (Liegtvor(dateiId, datei.ModifiedAt) is not null) return;
|
||||
|
||||
var plan = await bilder.ErzeugenAsync(datei.Path, Ordner, praefix);
|
||||
if (plan is not { } fertig) return;
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(Ordner, $"{praefix}.masse"),
|
||||
$"{fertig.KachelBreite}x{fertig.KachelHoehe}");
|
||||
|
||||
log.LogInformation(
|
||||
"Vorschau für {Datei}: {Blaetter} Blatt/Blätter, Kachel {Breite}×{Hoehe}.",
|
||||
Path.GetFileName(datei.Path), fertig.Blaetter, fertig.KachelBreite, fertig.KachelHoehe);
|
||||
}
|
||||
|
||||
/// <summary>Der Pfad eines Blattes — oder <c>null</c>, wenn es das nicht gibt.</summary>
|
||||
public string? Blatt(Guid dateiId, DateTimeOffset geaendert, int nummer)
|
||||
{
|
||||
if (nummer is < 0 or > 999) return null;
|
||||
|
||||
var pfad = Path.Combine(Ordner, $"{Praefix(dateiId, geaendert)}-{nummer:000}.jpg");
|
||||
return File.Exists(pfad) ? pfad : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace Flaro.Core.Media;
|
||||
|
||||
/// <summary>Welche Kachel eines Blattes eine Stelle zeigt.</summary>
|
||||
///
|
||||
/// <param name="Blatt">Das wievielte Blatt, von null an.</param>
|
||||
/// <param name="Spalte">Spalte darin, von null an.</param>
|
||||
/// <param name="Zeile">Zeile darin, von null an.</param>
|
||||
public readonly record struct Vorschaukachel(int Blatt, int Spalte, int Zeile);
|
||||
|
||||
/// <summary>Wie die Vorschaubilder eines Titels zusammengesetzt sind.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Gekachelt, nicht einzeln.</b> Ein Zweistünder ergibt bei einem Bild
|
||||
/// alle zehn Sekunden siebenhundert Stück. Einzeln wären das
|
||||
/// siebenhundert Dateien und siebenhundert Abrufe; als Blätter zu
|
||||
/// hundert sind es sieben. Der Spieler lädt das Blatt einmal und
|
||||
/// verschiebt danach nur noch den Ausschnitt — deshalb fühlt sich das
|
||||
/// Fahren über die Leiste flüssig an und nicht wie ein Daumenkino aus
|
||||
/// dem Netz.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Die Kachelmasse stehen nicht fest.</b> Skaliert wird auf eine feste
|
||||
/// Breite, die Höhe folgt dem Seitenverhältnis — ein Breitbildfilm
|
||||
/// ergibt flachere Kacheln als eine Fernsehserie. Deshalb werden sie
|
||||
/// gemessen und mitgeteilt, statt gerechnet zu werden.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reine Rechnung, ohne Datei.</b> Das Erzeugen braucht ffmpeg; das
|
||||
/// Ausrechnen, welche Kachel eine Sekunde zeigt, braucht nichts —
|
||||
/// dieselbe Naht wie zwischen <see cref="Frameprobe"/> und dem Abtaster.
|
||||
/// Beide Spieler rechnen dasselbe, und was hier steht, ist die Vorlage.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
///
|
||||
/// <param name="Intervall">Abstand zweier Bilder in Sekunden.</param>
|
||||
/// <param name="Spalten">Kacheln nebeneinander auf einem Blatt.</param>
|
||||
/// <param name="Zeilen">Kacheln übereinander auf einem Blatt.</param>
|
||||
/// <param name="KachelBreite">Breite einer Kachel in Bildpunkten.</param>
|
||||
/// <param name="KachelHoehe">Höhe einer Kachel in Bildpunkten.</param>
|
||||
/// <param name="Blaetter">Wie viele Blätter es gibt.</param>
|
||||
public readonly record struct Vorschauplan(
|
||||
double Intervall,
|
||||
int Spalten,
|
||||
int Zeilen,
|
||||
int KachelBreite,
|
||||
int KachelHoehe,
|
||||
int Blaetter)
|
||||
{
|
||||
/// <summary>Wie viele Kacheln auf ein Blatt passen.</summary>
|
||||
public int ProBlatt => Spalten * Zeilen;
|
||||
|
||||
/// <summary>Wie viele Bilder es insgesamt gibt.</summary>
|
||||
public int Kacheln => ProBlatt * Blaetter;
|
||||
|
||||
/// <summary>Welche Kachel die Stelle zeigt — oder nichts.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Hinter dem letzten Bild gilt das letzte Bild.</b> Das Raster
|
||||
/// endet dort, wo ffmpeg aufgehört hat; eine Sekunde danach — bei
|
||||
/// ungenauer Laufzeit durchaus möglich — soll die Vorschau nicht
|
||||
/// verschwinden, sondern stehen bleiben.
|
||||
/// </remarks>
|
||||
public Vorschaukachel? Kachel(double sekunde)
|
||||
{
|
||||
if (Blaetter <= 0 || Spalten <= 0 || Zeilen <= 0 || Intervall <= 0) return null;
|
||||
|
||||
var nummer = (int)Math.Floor(Math.Max(0, sekunde) / Intervall);
|
||||
nummer = Math.Min(nummer, Kacheln - 1);
|
||||
|
||||
var rest = nummer % ProBlatt;
|
||||
return new Vorschaukachel(nummer / ProBlatt, rest % Spalten, rest / Spalten);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Flaro.Core.Media;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Flaro.Media;
|
||||
|
||||
/// <summary>Erzeugt die Vorschaubilder eines Titels — in einem Lauf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Ein Aufruf, nicht siebenhundert.</b> Ein Zweistünder ergibt bei
|
||||
/// einem Bild alle zehn Sekunden siebenhundert Stück. Sie einzeln zu
|
||||
/// ziehen hiesse, die Datei siebenhundertmal zu öffnen und zu suchen;
|
||||
/// <c>fps</c> und <c>tile</c> machen daraus einen Durchlauf, der fertige
|
||||
/// Blätter ausgibt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>-skip_frame nokey</c> ist der Grund, warum das bezahlbar ist.</b>
|
||||
/// Dieselbe Abkürzung wie beim Abtasten der Abspannhelligkeit: nur
|
||||
/// Keyframes dekodieren. Gemessen dort 0,24 s je 180 Sekunden 1080p — ein
|
||||
/// ganzer Film kostet damit Sekunden statt Minuten. Keyframes liegen alle
|
||||
/// zwei bis zehn Sekunden, es ist also stets eines nahe genug an der
|
||||
/// Marke; dass die Vorschau um zwei Sekunden danebenliegen kann, sieht
|
||||
/// niemand.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Erst <c>.teil</c>, dann umbenennen</b> — wie beim Untertitelziehen.
|
||||
/// Bricht der Lauf ab, bleibt kein halbes Blatt liegen, das später für
|
||||
/// fertig gehalten würde.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Vorschaubilder(FfmpegOptions optionen, ILogger<Vorschaubilder> log)
|
||||
{
|
||||
/// <summary>Abstand zweier Bilder in Sekunden.</summary>
|
||||
public const double Intervall = 10;
|
||||
|
||||
/// <summary>Kacheln je Blatt — zehn mal zehn, wie bei Jellyfin und Plex.</summary>
|
||||
public const int Spalten = 10;
|
||||
public const int Zeilen = 10;
|
||||
|
||||
/// <summary>Breite einer Kachel. Die Höhe folgt dem Seitenverhältnis.</summary>
|
||||
public const int Breite = 320;
|
||||
|
||||
/// <summary>Wie lange ein ganzer Film höchstens dauern darf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Grosszügig: ein Dreistünder auf langsamer Platte darf sich Zeit
|
||||
/// lassen. Ohne Frist bliebe ein hängendes ffmpeg für immer stehen
|
||||
/// und belegte den einen Platz der Warteschlange.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan Frist = TimeSpan.FromMinutes(20);
|
||||
|
||||
/// <summary>Zieht alle Blätter und sagt, wie sie aussehen.</summary>
|
||||
///
|
||||
/// <param name="praefix">
|
||||
/// Der gemeinsame Namensanfang, etwa <c>{dateiId:N}-{geaendertMs}</c>.
|
||||
/// Die Blätter heissen <c>{praefix}-000.jpg</c> und so fort — damit
|
||||
/// ist ohne Datenbanktabelle entscheidbar, was zu einer Datei gehört
|
||||
/// und was von einer älteren Fassung übrig ist.
|
||||
/// </param>
|
||||
public async Task<Vorschauplan?> ErzeugenAsync(
|
||||
string pfad, string ordner, string praefix, CancellationToken ct = default)
|
||||
{
|
||||
Directory.CreateDirectory(ordner);
|
||||
|
||||
var roh = Path.Combine(ordner, $"{praefix}.teil-%03d.jpg");
|
||||
|
||||
var start = new ProcessStartInfo(optionen.Ffmpeg)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
foreach (var a in new[]
|
||||
{
|
||||
// Kein Lesen von der Tastatur: ohne das greift ffmpeg nach der
|
||||
// Standardeingabe des Dienstes.
|
||||
"-nostdin", "-v", "error",
|
||||
"-skip_frame", "nokey",
|
||||
"-i", pfad,
|
||||
"-an", "-sn",
|
||||
"-vf", $"fps=1/{Zahl(Intervall)},scale={Breite}:-2,tile={Spalten}x{Zeilen}",
|
||||
// Sechs ist die Stelle, an der ein Vorschaubild noch gut
|
||||
// aussieht und ein Blatt unter einem halben Megabyte bleibt.
|
||||
"-qscale:v", "6",
|
||||
"-y", roh,
|
||||
})
|
||||
{
|
||||
start.ArgumentList.Add(a);
|
||||
}
|
||||
|
||||
using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
frist.CancelAfter(Frist);
|
||||
|
||||
try
|
||||
{
|
||||
using var prozess = Process.Start(start);
|
||||
if (prozess is null) return null;
|
||||
|
||||
var fehlerLesen = prozess.StandardError.ReadToEndAsync(frist.Token);
|
||||
await prozess.WaitForExitAsync(frist.Token);
|
||||
var fehlertext = await fehlerLesen;
|
||||
|
||||
if (prozess.ExitCode != 0)
|
||||
{
|
||||
log.LogWarning("ffmpeg gab {Code} für die Vorschau von {Pfad} zurück: {Fehler}",
|
||||
prozess.ExitCode, pfad, fehlertext.Trim());
|
||||
Aufraeumen(ordner, praefix);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
Aufraeumen(ordner, praefix);
|
||||
throw;
|
||||
}
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Vorschau für {Pfad} misslungen.", pfad);
|
||||
Aufraeumen(ordner, praefix);
|
||||
return null;
|
||||
}
|
||||
|
||||
var teile = Directory.GetFiles(ordner, $"{praefix}.teil-*.jpg").OrderBy(p => p).ToList();
|
||||
if (teile.Count == 0) return null;
|
||||
|
||||
// **Die Kachelmasse messen, nicht rechnen.** `scale=320:-2` rundet
|
||||
// auf gerade Zahlen, und das Seitenverhältnis hängt am Film. Eine
|
||||
// falsche Höhe verschiebt jede Kachel — und zwar unauffällig.
|
||||
var masse = await MasseAsync(teile[0], frist.Token);
|
||||
if (masse is not ({ } breite, { } hoehe))
|
||||
{
|
||||
Aufraeumen(ordner, praefix);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < teile.Count; i++)
|
||||
{
|
||||
File.Move(teile[i], Path.Combine(ordner, $"{praefix}-{i:000}.jpg"), overwrite: true);
|
||||
}
|
||||
|
||||
return new Vorschauplan(
|
||||
Intervall, Spalten, Zeilen,
|
||||
breite / Spalten, hoehe / Zeilen, teile.Count);
|
||||
}
|
||||
|
||||
/// <summary>Die Masse eines Blattes, von ffprobe.</summary>
|
||||
private async Task<(int Breite, int Hoehe)?> MasseAsync(string pfad, CancellationToken ct)
|
||||
{
|
||||
var start = new ProcessStartInfo(optionen.Ffprobe)
|
||||
{
|
||||
ArgumentList =
|
||||
{
|
||||
"-v", "quiet", "-print_format", "json",
|
||||
"-show_entries", "stream=width,height", "-i", pfad,
|
||||
},
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var prozess = Process.Start(start);
|
||||
if (prozess is null) return null;
|
||||
|
||||
var lesen = prozess.StandardOutput.ReadToEndAsync(ct);
|
||||
await prozess.WaitForExitAsync(ct);
|
||||
if (prozess.ExitCode != 0) return null;
|
||||
|
||||
using var papier = JsonDocument.Parse(await lesen);
|
||||
var strom = papier.RootElement.GetProperty("streams")[0];
|
||||
return (strom.GetProperty("width").GetInt32(), strom.GetProperty("height").GetInt32());
|
||||
}
|
||||
catch (Exception fehler) when (fehler is not OperationCanceledException)
|
||||
{
|
||||
log.LogWarning(fehler, "Masse von {Pfad} liessen sich nicht lesen.", pfad);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Halbe Blätter wegräumen — sonst gelten sie später als fertig.</summary>
|
||||
private static void Aufraeumen(string ordner, string praefix)
|
||||
{
|
||||
foreach (var datei in Directory.GetFiles(ordner, $"{praefix}.teil-*.jpg"))
|
||||
{
|
||||
try { File.Delete(datei); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
private static string Zahl(double wert) => wert.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -234,9 +234,9 @@ public sealed class TranscodeTests
|
||||
Assert.That(File.Exists(weg), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>Die Übersicht nennt die drei Unterordner mit Grösse und Anzahl.</summary>
|
||||
/// <summary>Die Übersicht nennt jeden Unterordner mit Grösse und Anzahl.</summary>
|
||||
[Test]
|
||||
public async Task Die_Belegung_nennt_die_drei_Unterordner()
|
||||
public async Task Die_Belegung_nennt_die_Unterordner()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var (client, kratz, dateiId) = await Aufgesetzt(server);
|
||||
@@ -250,7 +250,7 @@ public sealed class TranscodeTests
|
||||
{
|
||||
Assert.That(transcode.Teile, Is.Not.Null);
|
||||
Assert.That(transcode.Teile!.Select(t => t.Name),
|
||||
Is.EquivalentTo(new[] { "stroeme", "packages", "untertitel" }));
|
||||
Is.EquivalentTo(new[] { "stroeme", "packages", "untertitel", "vorschau" }));
|
||||
var pakete = transcode.Teile!.First(t => t.Name == "packages");
|
||||
Assert.That(pakete.Dateien, Is.EqualTo(1));
|
||||
Assert.That(pakete.Frei, Is.EqualTo(8 * 1024), "das Feld Frei trägt hier die Belegung des Ordners");
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using Flaro.Core.Media;
|
||||
|
||||
namespace Flaro.Core.Tests;
|
||||
|
||||
/// <summary>Welche Kachel eine Stelle zeigt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Rechnung steht hier, weil beide Spieler sie brauchen.</b> Web und
|
||||
/// App verschieben denselben Ausschnitt; zweimal formuliert driftet er
|
||||
/// auseinander, und eine verschobene Vorschau sieht man nicht sofort — man
|
||||
/// hält sie für ein ungenaues Bild.
|
||||
/// </remarks>
|
||||
public class VorschauplanTests
|
||||
{
|
||||
/// <summary>Hundert Kacheln je Blatt, alle zehn Sekunden eine.</summary>
|
||||
private static readonly Vorschauplan Plan = new(
|
||||
Intervall: 10, Spalten: 10, Zeilen: 10,
|
||||
KachelBreite: 320, KachelHoehe: 180, Blaetter: 3);
|
||||
|
||||
[Test]
|
||||
public void Der_Anfang_ist_die_erste_Kachel()
|
||||
{
|
||||
Assert.That(Plan.Kachel(0), Is.EqualTo(new Vorschaukachel(0, 0, 0)));
|
||||
}
|
||||
|
||||
/// <summary>Innerhalb eines Intervalls gilt dieselbe Kachel.</summary>
|
||||
[Test]
|
||||
public void Neun_Sekunden_sind_noch_die_erste()
|
||||
{
|
||||
Assert.That(Plan.Kachel(9.9), Is.EqualTo(new Vorschaukachel(0, 0, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Zehn_Sekunden_sind_die_zweite()
|
||||
{
|
||||
Assert.That(Plan.Kachel(10), Is.EqualTo(new Vorschaukachel(0, 1, 0)));
|
||||
}
|
||||
|
||||
/// <summary>Nach zehn Kacheln beginnt die zweite Zeile, nicht das zweite Blatt.</summary>
|
||||
[Test]
|
||||
public void Die_elfte_Kachel_steht_eine_Zeile_tiefer()
|
||||
{
|
||||
Assert.That(Plan.Kachel(100), Is.EqualTo(new Vorschaukachel(0, 0, 1)));
|
||||
}
|
||||
|
||||
/// <summary>Die Ränder eines Blattes — dort verrechnet man sich.</summary>
|
||||
[Test]
|
||||
public void Der_Blattwechsel_liegt_richtig()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
// Kachel 99: letzte Spalte, letzte Zeile, erstes Blatt.
|
||||
Assert.That(Plan.Kachel(990), Is.EqualTo(new Vorschaukachel(0, 9, 9)));
|
||||
// Kachel 100: erste Spalte, erste Zeile, zweites Blatt.
|
||||
Assert.That(Plan.Kachel(1000), Is.EqualTo(new Vorschaukachel(1, 0, 0)));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Hinter dem letzten Bild bleibt das letzte Bild stehen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Das Raster endet, wo ffmpeg aufgehört hat. Eine Sekunde darüber
|
||||
/// hinaus — bei ungenauer Laufzeit durchaus möglich — soll die
|
||||
/// Vorschau nicht verschwinden.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Hinter_dem_Ende_gilt_die_letzte_Kachel()
|
||||
{
|
||||
Assert.That(Plan.Kachel(99_999), Is.EqualTo(new Vorschaukachel(2, 9, 9)));
|
||||
}
|
||||
|
||||
/// <summary>Eine negative Stelle gibt es nicht, und sie stürzt auch nicht ab.</summary>
|
||||
[Test]
|
||||
public void Vor_dem_Anfang_gilt_die_erste_Kachel()
|
||||
{
|
||||
Assert.That(Plan.Kachel(-30), Is.EqualTo(new Vorschaukachel(0, 0, 0)));
|
||||
}
|
||||
|
||||
/// <summary>Ohne Blätter gibt es nichts zu zeigen.</summary>
|
||||
[Test]
|
||||
public void Ein_leerer_Plan_liefert_nichts()
|
||||
{
|
||||
var leer = Plan with { Blaetter = 0 };
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(leer.Kachel(42), Is.Null);
|
||||
Assert.That((Plan with { Intervall = 0 }).Kachel(42), Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Die_Zahlen_haengen_zusammen()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Plan.ProBlatt, Is.EqualTo(100));
|
||||
Assert.That(Plan.Kacheln, Is.EqualTo(300));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Diagnostics;
|
||||
using Flaro.Media;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Flaro.Media.Tests;
|
||||
|
||||
/// <summary>Die Blätter der Vorschau — Zahl, Masse, Namen.</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
|
||||
/// Kacheln liegen da, wo der Spieler sie sucht" liegt alles. Eine um eine
|
||||
/// Kachel verschobene Vorschau sieht niemand als Fehler — man hält sie
|
||||
/// für ein ungenaues Bild.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Erzeugt wird ein Film von 250 Sekunden. Bei einem Bild alle zehn
|
||||
/// Sekunden sind das 25 Kacheln, und die passen auf ein Blatt von zehn
|
||||
/// mal zehn.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class VorschaubilderTests
|
||||
{
|
||||
private string _ordner = "";
|
||||
private string _datei = "";
|
||||
|
||||
[OneTimeSetUp]
|
||||
public void Erzeugen()
|
||||
{
|
||||
_ordner = Directory.CreateDirectory(Path.Combine(
|
||||
Path.GetTempPath(), "flaro-vorschau-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
_datei = Path.Combine(_ordner, "quelle.mp4");
|
||||
|
||||
var start = new ProcessStartInfo("ffmpeg")
|
||||
{
|
||||
ArgumentList =
|
||||
{
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=640x360:rate=25:duration=250",
|
||||
"-c:v", "libx264", "-preset", "ultrafast",
|
||||
"-g", "50", "-pix_fmt", "yuv420p", _datei,
|
||||
},
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var prozess = Process.Start(start);
|
||||
prozess!.WaitForExit(120_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) { }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_Lauf_ergibt_ein_Blatt_mit_richtigen_Massen()
|
||||
{
|
||||
var ziel = Path.Combine(_ordner, "blaetter");
|
||||
var bilder = new Vorschaubilder(
|
||||
FfmpegOptions.FromEnvironment(), NullLogger<Vorschaubilder>.Instance);
|
||||
|
||||
var plan = await bilder.ErzeugenAsync(_datei, ziel, "pruef-123");
|
||||
|
||||
Assert.That(plan, Is.Not.Null, "der Lauf muss Blätter ergeben");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(plan!.Value.Blaetter, Is.EqualTo(1),
|
||||
"25 Kacheln passen auf ein Blatt von zehn mal zehn");
|
||||
Assert.That(plan.Value.KachelBreite, Is.EqualTo(Vorschaubilder.Breite));
|
||||
// 640×360 ist 16:9 — bei 320 Breite also 180 hoch.
|
||||
Assert.That(plan.Value.KachelHoehe, Is.EqualTo(180).Within(2));
|
||||
Assert.That(plan.Value.Intervall, Is.EqualTo(Vorschaubilder.Intervall));
|
||||
});
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(File.Exists(Path.Combine(ziel, "pruef-123-000.jpg")),
|
||||
"die Blätter heissen nach dem Präfix und ihrer Nummer");
|
||||
Assert.That(Directory.GetFiles(ziel, "*.teil-*"), Is.Empty,
|
||||
"nichts Halbes bleibt liegen");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Eine Datei, die es nicht gibt, ergibt nichts — und wirft nicht.</summary>
|
||||
[Test]
|
||||
public async Task Ohne_Quelle_kein_Plan()
|
||||
{
|
||||
var bilder = new Vorschaubilder(
|
||||
FfmpegOptions.FromEnvironment(), NullLogger<Vorschaubilder>.Instance);
|
||||
|
||||
var plan = await bilder.ErzeugenAsync(
|
||||
Path.Combine(_ordner, "gibtsnicht.mkv"), Path.Combine(_ordner, "leer"), "x");
|
||||
|
||||
Assert.That(plan, Is.Null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user