diff --git a/README.md b/README.md
index 0b9eb05..d14a7c5 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,8 @@
- Слежение за папками: новые файлы подхватываются сами, без кнопки.
- Метаданные (длительность, разрешение, кодек) через ffprobe.
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
+- Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются
+ кадры, снятые по всей длительности.
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
библиотеки с удалением, параметры превью и сканирования, тема, очистка кэша. Всё пишется
@@ -95,6 +97,35 @@ dotnet test
экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в
приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка
появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей.
+- **Индексация в три прохода.** Сначала метаданные и постеры для всех файлов, затем
+ анимированные превью, и только потом отпечатки. Каждый следующий проход берёт больше кадров
+ на файл; вперемешку они задерживали бы каждую следующую карточку на всю цепочку, и сетка
+ наполнялась бы в разы медленнее. Порядок — по видимости: постер нужен, чтобы карточка
+ вообще появилась, анимация — чтобы она ожила под курсором, хеш всплывает только при поиске
+ дублей. Поэтому `IsIndexed` намеренно не включает ни анимацию, ни хеш: это готовность к
+ показу, а не завершённость всей обработки. Поздние проходы умеют стартовать только после
+ первого — кадры распределяются по длительности, а её устанавливает probe.
+- **Анимированное превью — кадры стопкой в одном JPEG, не GIF.** Гифку никто ниже по течению
+ не проиграет: Avalonia декодирует только первый кадр анимированного изображения, так что
+ за GIF пришлось бы тащить отдельный декодер. Стопка кадров обходится тем же декодером, что
+ и постеры — `FilmstripImage` просто рисует каждый тик другой срез, — и весит долю от
+ 256-цветной гифки тех же кадров, а это цена за каждое видео в библиотеке. Рендерит один
+ процесс ffmpeg: по входу на таймкод (seek до входа, то есть прыжок на ключевой кадр,
+ а не декодирование до него) и один `vstack`; процесс на кадр умножил бы проход на их число.
+ Число кадров зашито в имя файла, поэтому смена настройки не режет старую полосу неверным
+ шагом — она просто промахивается мимо кэша, а лишнее подберёт очистка.
+ Полоса живёт только пока играет: она весит как все её кадры вместе, а под курсором всегда
+ одна карточка, так что держать её в общем кэше значило бы менять ограниченную память на
+ растущую с тем, сколько библиотеки пролистали.
+- **pHash по рецепту stash.** 25 кадров сеткой 5×5, по 5% времени отброшено с каждого
+ конца, кадр по ширине 160 — то есть хеш описывает те же кадры, что и у stash. Декодирует
+ и масштабирует ffmpeg, отдавая сырой 8-битный серый, поэтому графическая библиотека не
+ нужна вовсе: монтаж, уменьшение до 64×64 и DCT — арифметика над массивом байт. Бит
+ ставится сравнением коэффициента с медианой блока 8×8.
+ Сравнивается хеш расстоянием Хэмминга, а не на равенство; группы дублей собираются
+ системой непересекающихся множеств, чтобы цепочка «A похож на B, B на C» дала одну группу.
+ **Побитовая совместимость со stash не проверена** — разные реализации ресайза способны
+ перевернуть биты у коэффициентов рядом с медианой.
- **Теги и коллекции — одна сущность.** `LibraryLabel` с `LabelKind`: связь с видео у них
одинаковая, различается только назначение. Одна сущность — одна таблица связей, один
репозиторий и одно правило именования; разделить потом можно переименованием и миграцией,
@@ -107,16 +138,20 @@ dotnet test
разницу и так умеет считать сканирование.
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
- на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
+ на месте (`IMediaArtifactCache.IsAvailable`), и перерисовывает удалённые; после полного
прохода лишние файлы вычищаются (`PurgeUnusedAsync`). Незавершённые `.tmp` удаляются
только если им больше часа — иначе можно снести рендер второго запущенного экземпляра.
+ Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и
+ описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда
+ появился второй вид файлов.
## Данные
Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`:
-- `library.db` — SQLite с метаданными;
+- `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash;
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
+- `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени;
- `settings.json` — папки, параметры превью и сканирования, тема, громкость;
перечитывается на лету;
- `logs/` — Serilog, ротация по дням.
diff --git a/src/PLib.Application/Abstractions/IAnimatedPreviewGenerator.cs b/src/PLib.Application/Abstractions/IAnimatedPreviewGenerator.cs
new file mode 100644
index 0000000..d321f56
--- /dev/null
+++ b/src/PLib.Application/Abstractions/IAnimatedPreviewGenerator.cs
@@ -0,0 +1,26 @@
+namespace PLib.Application.Abstractions;
+
+///
+/// An animated preview: frames sampled across the video and stacked into a single image,
+/// which the card cycles through while the pointer rests on it.
+///
+/// Absolute path of the stacked image.
+///
+/// How many frames are stacked in it. Without this the image is just a very tall picture —
+/// it is what lets the player slice it back into frames.
+///
+public sealed record AnimatedPreview(string Path, int FrameCount);
+
+/// Produces (and caches on disk) the animated preview of a video file.
+public interface IAnimatedPreviewGenerator : IMediaArtifactCache
+{
+ ///
+ /// Returns the preview for , rendering it if it is not
+ /// cached yet, or null when one could not be produced — an unreadable file or an
+ /// unknown duration, both of which are ordinary outcomes rather than errors.
+ ///
+ Task GetOrCreateAsync(
+ string videoPath,
+ TimeSpan? duration,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Abstractions/IMediaArtifactCache.cs b/src/PLib.Application/Abstractions/IMediaArtifactCache.cs
new file mode 100644
index 0000000..11fbb74
--- /dev/null
+++ b/src/PLib.Application/Abstractions/IMediaArtifactCache.cs
@@ -0,0 +1,36 @@
+namespace PLib.Application.Abstractions;
+
+///
+/// A cache of files derived from the videos themselves — poster frames, animated previews.
+///
+///
+/// Derived artefacts share one lifecycle regardless of what they are: they can vanish from
+/// disk behind the library's back, they go stale when nothing points at them any more, and
+/// the user is entitled to see what they cost and to throw them away. Saying that once keeps
+/// the settings screen honest as artefact kinds are added — a size that only counted poster
+/// frames would understate the cache the moment previews appeared.
+///
+public interface IMediaArtifactCache
+{
+ ///
+ /// True when a previously generated file is still present. The cache directory is
+ /// ordinary user-writable storage, so a path the library remembers is not proof that the
+ /// file behind it still exists.
+ ///
+ bool IsAvailable(string? path);
+
+ ///
+ /// Deletes cached files that no library item points at any more, and returns how many
+ /// were removed. Call only after a complete scan: anything not in
+ /// is treated as garbage.
+ ///
+ Task PurgeUnusedAsync(
+ IReadOnlyCollection inUsePaths,
+ CancellationToken cancellationToken = default);
+
+ /// How much disk this cache currently occupies, in bytes.
+ Task GetCacheSizeInBytesAsync(CancellationToken cancellationToken = default);
+
+ /// Empties the cache completely. Returns how many files were removed.
+ Task ClearAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Abstractions/IThumbnailGenerator.cs b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs
index ba3c5f2..dbe7a6e 100644
--- a/src/PLib.Application/Abstractions/IThumbnailGenerator.cs
+++ b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs
@@ -1,37 +1,15 @@
-namespace PLib.Application.Abstractions;
-
-/// Produces (and caches on disk) a poster frame for a video file.
-public interface IThumbnailGenerator
-{
- ///
- /// Returns the absolute path of the poster frame for ,
- /// generating it if it is not cached yet. Returns null when no frame could be
- /// extracted; a missing thumbnail is a normal outcome, not an error.
- ///
- Task GetOrCreateAsync(
- string videoPath,
- TimeSpan? duration,
- CancellationToken cancellationToken = default);
-
- ///
- /// True when a previously generated poster frame is still present in the cache. The
- /// cache directory is ordinary user-writable storage, so a path the library remembers
- /// is not proof that the file behind it still exists.
- ///
- bool IsAvailable(string? thumbnailPath);
-
- ///
- /// Deletes cached frames that no library item points at any more, and returns how many
- /// files were removed. Call only after a complete scan: anything not in
- /// is treated as garbage.
- ///
- Task PurgeUnusedAsync(
- IReadOnlyCollection inUsePaths,
- CancellationToken cancellationToken = default);
-
- /// How much disk the cache currently occupies, in bytes.
- Task GetCacheSizeInBytesAsync(CancellationToken cancellationToken = default);
-
- /// Empties the cache completely. Returns how many files were removed.
- Task ClearAsync(CancellationToken cancellationToken = default);
-}
+namespace PLib.Application.Abstractions;
+
+/// Produces (and caches on disk) a poster frame for a video file.
+public interface IThumbnailGenerator : IMediaArtifactCache
+{
+ ///
+ /// Returns the absolute path of the poster frame for ,
+ /// generating it if it is not cached yet. Returns null when no frame could be
+ /// extracted; a missing thumbnail is a normal outcome, not an error.
+ ///
+ Task GetOrCreateAsync(
+ string videoPath,
+ TimeSpan? duration,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Abstractions/IVideoPerceptualHasher.cs b/src/PLib.Application/Abstractions/IVideoPerceptualHasher.cs
new file mode 100644
index 0000000..fdaf10b
--- /dev/null
+++ b/src/PLib.Application/Abstractions/IVideoPerceptualHasher.cs
@@ -0,0 +1,19 @@
+namespace PLib.Application.Abstractions;
+
+///
+/// Computes a perceptual hash of a video's visual content.
+///
+///
+/// The hash describes what the video looks like, not what its bytes are: the same film
+/// re-encoded, resized or slightly re-cut lands within a few bits of the original, while an
+/// unrelated video lands far away. That is what makes it useful for finding duplicates a
+/// checksum would miss.
+///
+public interface IVideoPerceptualHasher
+{
+ ///
+ /// Returns the hash, or null when the file could not be sampled — an unreadable
+ /// or zero-length video is a normal outcome here, not an error.
+ ///
+ Task ComputeAsync(string videoPath, TimeSpan? duration, CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs
index 9d72b21..d303d55 100644
--- a/src/PLib.Application/Library/ILibraryService.cs
+++ b/src/PLib.Application/Library/ILibraryService.cs
@@ -28,6 +28,18 @@ public interface ILibraryService
/// Remembers where playback stopped so the video can be resumed later.
Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default);
+ ///
+ /// Groups of videos that look alike, by perceptual hash. Videos without a hash, and
+ /// groups of one, are left out.
+ ///
+ ///
+ /// How many differing bits still count as the same video. Zero means visually identical;
+ /// the useful range for re-encodes is a handful of bits.
+ ///
+ Task>> FindDuplicatesAsync(
+ int maxDistance,
+ CancellationToken cancellationToken = default);
+
/// One video with its labels loaded, or null if it is gone.
Task GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default);
diff --git a/src/PLib.Application/Library/LibraryOptions.cs b/src/PLib.Application/Library/LibraryOptions.cs
index 825ff84..9e71628 100644
--- a/src/PLib.Application/Library/LibraryOptions.cs
+++ b/src/PLib.Application/Library/LibraryOptions.cs
@@ -27,6 +27,20 @@ public sealed class LibraryOptions
[Range(0.0, 0.9)]
public double ThumbnailPositionRatio { get; init; } = 0.15;
+ ///
+ /// How many frames the animated preview is made of. Each one costs a seek and a decode,
+ /// so this is the main lever on how long the preview pass takes.
+ ///
+ [Range(2, 60)]
+ public int PreviewFrameCount { get; init; } = 12;
+
+ ///
+ /// Width of each preview frame in pixels. Deliberately smaller than the poster frame:
+ /// the preview is a dozen images in one file and is only ever seen card-sized.
+ ///
+ [Range(120, 960)]
+ public int PreviewWidth { get; init; } = 240;
+
/// How many files may be probed / rendered concurrently.
[Range(1, 32)]
public int MaxIndexingConcurrency { get; init; } = 4;
diff --git a/src/PLib.Application/Library/LibraryScanEvent.cs b/src/PLib.Application/Library/LibraryScanEvent.cs
index acd43e3..9571f47 100644
--- a/src/PLib.Application/Library/LibraryScanEvent.cs
+++ b/src/PLib.Application/Library/LibraryScanEvent.cs
@@ -1,32 +1,41 @@
-using PLib.Domain.Videos;
-
-namespace PLib.Application.Library;
-
-///
-/// Something that happened during a scan. The scan is exposed as a stream of these so the
-/// UI can render cards as soon as they are known instead of waiting for the whole pass.
-///
-public abstract record LibraryScanEvent
-{
- private LibraryScanEvent()
- {
- }
-
- /// The file system walk finished and we know how much work there is.
- public sealed record DiscoveryCompleted(int FilesFound) : LibraryScanEvent;
-
- /// A video is now part of the library (possibly still without a poster frame).
- public sealed record ItemAdded(VideoItem Item) : LibraryScanEvent;
-
- /// A video that is already displayed gained metadata or a poster frame.
- public sealed record ItemUpdated(VideoItem Item) : LibraryScanEvent;
-
- /// A video disappeared from disk and was dropped from the library.
- public sealed record ItemRemoved(Guid Id) : LibraryScanEvent;
-
- /// Indexing progress, reported after every processed file.
- public sealed record IndexingProgress(int Processed, int Total) : LibraryScanEvent;
-
- /// The scan finished successfully.
- public sealed record Completed(int LibrarySize) : LibraryScanEvent;
-}
+using PLib.Domain.Videos;
+
+namespace PLib.Application.Library;
+
+///
+/// Something that happened during a scan. The scan is exposed as a stream of these so the
+/// UI can render cards as soon as they are known instead of waiting for the whole pass.
+///
+public abstract record LibraryScanEvent
+{
+ private LibraryScanEvent()
+ {
+ }
+
+ /// The file system walk finished and we know how much work there is.
+ public sealed record DiscoveryCompleted(int FilesFound) : LibraryScanEvent;
+
+ /// A video is now part of the library (possibly still without a poster frame).
+ public sealed record ItemAdded(VideoItem Item) : LibraryScanEvent;
+
+ /// A video that is already displayed gained metadata or a poster frame.
+ public sealed record ItemUpdated(VideoItem Item) : LibraryScanEvent;
+
+ /// A video disappeared from disk and was dropped from the library.
+ public sealed record ItemRemoved(Guid Id) : LibraryScanEvent;
+
+ /// Progress of the first pass — metadata and poster frames.
+ public sealed record IndexingProgress(int Processed, int Total) : LibraryScanEvent;
+
+ /// Progress of the second pass — animated previews.
+ public sealed record PreviewProgress(int Processed, int Total) : LibraryScanEvent;
+
+ ///
+ /// Progress of the last pass — perceptual hashes. Reported separately because it runs
+ /// long after the grid is usable, and saying "indexing" again would misdescribe it.
+ ///
+ public sealed record HashingProgress(int Processed, int Total) : LibraryScanEvent;
+
+ /// The scan finished successfully.
+ public sealed record Completed(int LibrarySize) : LibraryScanEvent;
+}
diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs
index 95d5579..365a455 100644
--- a/src/PLib.Application/Library/LibraryService.cs
+++ b/src/PLib.Application/Library/LibraryService.cs
@@ -14,6 +14,8 @@ public sealed class LibraryService(
IVideoFileScanner scanner,
IMediaProbe mediaProbe,
IThumbnailGenerator thumbnailGenerator,
+ IAnimatedPreviewGenerator previewGenerator,
+ IVideoPerceptualHasher perceptualHasher,
IOptions options,
ILogger logger) : ILibraryService
{
@@ -44,6 +46,58 @@ public sealed class LibraryService(
await repository.SaveChangesAsync(cancellationToken);
}
+ public async Task>> FindDuplicatesAsync(
+ int maxDistance,
+ CancellationToken cancellationToken = default)
+ {
+ var hashed = (await repository.GetAllAsync(cancellationToken))
+ .Where(item => item.PerceptualHash is not null)
+ .ToList();
+
+ // Union-find over the pairwise comparison: two videos land in the same group if a
+ // chain of near-matches connects them, which is what "these are the same film"
+ // means when one copy sits between two others.
+ var groupOf = new int[hashed.Count];
+
+ for (var i = 0; i < groupOf.Length; i++)
+ {
+ groupOf[i] = i;
+ }
+
+ int Root(int index)
+ {
+ while (groupOf[index] != index)
+ {
+ groupOf[index] = groupOf[groupOf[index]];
+ index = groupOf[index];
+ }
+
+ return index;
+ }
+
+ for (var i = 0; i < hashed.Count; i++)
+ {
+ for (var j = i + 1; j < hashed.Count; j++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (hashed[i].DistanceTo(hashed[j]) <= maxDistance)
+ {
+ groupOf[Root(i)] = Root(j);
+ }
+ }
+ }
+
+ return
+ [
+ .. hashed
+ .Select((item, index) => (item, group: Root(index)))
+ .GroupBy(pair => pair.group)
+ .Where(group => group.Count() > 1)
+ .Select(IReadOnlyList (group) => [.. group.Select(pair => pair.item)])
+ ];
+ }
+
public Task GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default) =>
repository.FindWithLabelsAsync(videoId, cancellationToken);
@@ -93,8 +147,16 @@ public sealed class LibraryService(
}
}
- public Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) =>
- thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken);
+ /// Every cache of files derived from the videos, so none is ever forgotten.
+ private IEnumerable ArtifactCaches => [thumbnailGenerator, previewGenerator];
+
+ public async Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default)
+ {
+ var sizes = await Task.WhenAll(
+ ArtifactCaches.Select(cache => cache.GetCacheSizeInBytesAsync(cancellationToken)));
+
+ return sizes.Sum();
+ }
public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default)
{
@@ -103,6 +165,7 @@ public sealed class LibraryService(
foreach (var item in items)
{
item.DetachThumbnail();
+ item.DetachPreview();
}
// Forget the paths before deleting the files. Interrupted the other way round, the
@@ -110,8 +173,8 @@ public sealed class LibraryService(
// a full scan notices. This order leaves at worst some orphans, which the purge eats.
await repository.SaveChangesAsync(cancellationToken);
- var removed = await thumbnailGenerator.ClearAsync(cancellationToken);
- logger.LogInformation("Cleared {Count} cached poster frames on request", removed);
+ var removed = await Task.WhenAll(ArtifactCaches.Select(cache => cache.ClearAsync(cancellationToken)));
+ logger.LogInformation("Cleared {Count} cached images on request", removed.Sum());
}
public async IAsyncEnumerable ScanAsync(
@@ -132,13 +195,27 @@ public sealed class LibraryService(
{
existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt);
- // The cache directory is ordinary user storage: a poster frame we remember
- // may simply have been deleted. Trusting the stored path would leave the
- // card blank forever, because the item still looks indexed.
+ // The cache directory is ordinary user storage: an image we remember may
+ // simply have been deleted. Trusting the stored path would leave the card
+ // blank forever, because the item still looks indexed.
+ var forgotten = false;
+
if (existing.ThumbnailPath is not null &&
!thumbnailGenerator.IsAvailable(existing.ThumbnailPath))
{
existing.DetachThumbnail();
+ forgotten = true;
+ }
+
+ if (existing.PreviewPath is not null &&
+ !previewGenerator.IsAvailable(existing.PreviewPath))
+ {
+ existing.DetachPreview();
+ forgotten = true;
+ }
+
+ if (forgotten)
+ {
yield return new LibraryScanEvent.ItemUpdated(existing);
}
}
@@ -170,32 +247,64 @@ public sealed class LibraryService(
await repository.SaveChangesAsync(cancellationToken);
+ // First pass: metadata and poster frames, so the grid fills in as fast as the files
+ // allow.
await foreach (var indexed in IndexAsync(pending, cancellationToken))
{
yield return indexed;
}
await repository.SaveChangesAsync(cancellationToken);
- await PurgeThumbnailCacheAsync(known.Values, cancellationToken);
+
+ // Second pass: animated previews. Ordered ahead of hashing because it is the one the
+ // user can see — a hash only ever surfaces when duplicates are searched for.
+ var withoutPreview = known.Values.Where(item => item.NeedsAnimatedPreview).ToList();
+
+ await foreach (var previewed in PreviewAsync(withoutPreview, cancellationToken))
+ {
+ yield return previewed;
+ }
+
+ await repository.SaveChangesAsync(cancellationToken);
+
+ // Last pass: perceptual hashes. Two dozen frame grabs per file makes this the
+ // expensive one, and it can only start once the first pass has established the
+ // duration to spread those frames across.
+ var unhashed = known.Values.Where(item => item.NeedsPerceptualHash).ToList();
+
+ await foreach (var hashed in HashAsync(unhashed, cancellationToken))
+ {
+ yield return hashed;
+ }
+
+ await repository.SaveChangesAsync(cancellationToken);
+ await PurgeArtifactCachesAsync(known.Values, cancellationToken);
yield return new LibraryScanEvent.Completed(known.Count);
}
///
- /// Drops cached frames nothing points at any more. Safe only here, at the end of a
+ /// Drops cached images nothing points at any more. Safe only here, at the end of a
/// completed scan, because that is the only moment the library is known to be whole —
- /// running it mid-scan would delete frames of items not reconciled yet.
+ /// running it mid-scan would delete the frames of items not reconciled yet.
///
- private async Task PurgeThumbnailCacheAsync(
+ private async Task PurgeArtifactCachesAsync(
IEnumerable library,
CancellationToken cancellationToken)
{
- var inUse = library.Select(x => x.ThumbnailPath).OfType().ToArray();
- var removed = await thumbnailGenerator.PurgeUnusedAsync(inUse, cancellationToken);
+ var items = library as IReadOnlyCollection ?? [.. library];
+
+ var removed = await thumbnailGenerator.PurgeUnusedAsync(
+ [.. items.Select(x => x.ThumbnailPath).OfType()],
+ cancellationToken);
+
+ removed += await previewGenerator.PurgeUnusedAsync(
+ [.. items.Select(x => x.PreviewPath).OfType()],
+ cancellationToken);
if (removed > 0)
{
- logger.LogInformation("Removed {Count} orphaned poster frames from the cache", removed);
+ logger.LogInformation("Removed {Count} orphaned images from the cache", removed);
}
}
@@ -223,20 +332,136 @@ public sealed class LibraryService(
}
///
- /// Probes and renders poster frames with bounded concurrency. The expensive work runs in
- /// parallel, but the results are applied to the entities one at a time by the consumer
- /// because change tracking is not thread safe.
+ /// First pass: probes each file and renders its poster frame, with bounded concurrency.
+ /// The expensive work runs in parallel, but results are applied to the entities one at a
+ /// time by the consumer because change tracking is not thread safe.
///
private async IAsyncEnumerable IndexAsync(
IReadOnlyList pending,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
- if (pending.Count == 0)
+ var processed = 0;
+
+ var results = InParallelAsync(
+ pending,
+ async (item, token) =>
+ {
+ var info = await mediaProbe.ProbeAsync(item.FullPath, token);
+ var thumbnail = await thumbnailGenerator.GetOrCreateAsync(item.FullPath, info.Duration, token);
+ return (Item: item, Info: info, Thumbnail: thumbnail);
+ },
+ cancellationToken);
+
+ await foreach (var result in results)
+ {
+ result.Item.ApplyTechnicalInfo(result.Info);
+
+ if (result.Thumbnail is not null)
+ {
+ result.Item.AttachThumbnail(result.Thumbnail);
+ }
+
+ processed++;
+
+ yield return new LibraryScanEvent.ItemUpdated(result.Item);
+ yield return new LibraryScanEvent.IndexingProgress(processed, pending.Count);
+
+ if (processed % SaveBatchSize == 0)
+ {
+ await repository.SaveChangesAsync(cancellationToken);
+ }
+ }
+
+ if (processed > 0)
+ {
+ logger.LogInformation("Indexed {Processed} of {Total} video files", processed, pending.Count);
+ }
+ }
+
+ /// Second pass: the animated preview of each video that does not have one yet.
+ private async IAsyncEnumerable PreviewAsync(
+ IReadOnlyList pending,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var processed = 0;
+
+ var results = InParallelAsync(
+ pending,
+ async (item, token) => (Item: item, Preview: await previewGenerator.GetOrCreateAsync(item.FullPath, item.Duration, token)),
+ cancellationToken);
+
+ await foreach (var result in results)
+ {
+ if (result.Preview is { } preview)
+ {
+ result.Item.AttachPreview(preview.Path, preview.FrameCount);
+ }
+
+ processed++;
+
+ yield return new LibraryScanEvent.ItemUpdated(result.Item);
+ yield return new LibraryScanEvent.PreviewProgress(processed, pending.Count);
+
+ if (processed % SaveBatchSize == 0)
+ {
+ await repository.SaveChangesAsync(cancellationToken);
+ }
+ }
+
+ if (processed > 0)
+ {
+ logger.LogInformation("Rendered previews for {Processed} of {Total} video files", processed, pending.Count);
+ }
+ }
+
+ /// Last pass: the perceptual hash of each video that does not have one yet.
+ private async IAsyncEnumerable HashAsync(
+ IReadOnlyList pending,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var processed = 0;
+
+ var results = InParallelAsync(
+ pending,
+ async (item, token) => (Item: item, Hash: await perceptualHasher.ComputeAsync(item.FullPath, item.Duration, token)),
+ cancellationToken);
+
+ await foreach (var result in results)
+ {
+ result.Item.ApplyPerceptualHash(result.Hash);
+ processed++;
+
+ yield return new LibraryScanEvent.ItemUpdated(result.Item);
+ yield return new LibraryScanEvent.HashingProgress(processed, pending.Count);
+
+ if (processed % SaveBatchSize == 0)
+ {
+ await repository.SaveChangesAsync(cancellationToken);
+ }
+ }
+
+ if (processed > 0)
+ {
+ logger.LogInformation("Hashed {Processed} of {Total} video files", processed, pending.Count);
+ }
+ }
+
+ ///
+ /// Runs over the items with bounded concurrency and streams the
+ /// results back as they finish. A bounded channel is what keeps the producers from
+ /// running ahead of a consumer that has to apply each result one at a time.
+ ///
+ private async IAsyncEnumerable InParallelAsync(
+ IReadOnlyList items,
+ Func> work,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (items.Count == 0)
{
yield break;
}
- var channel = Channel.CreateBounded(new BoundedChannelOptions(_options.MaxIndexingConcurrency * 4)
+ var channel = Channel.CreateBounded(new BoundedChannelOptions(_options.MaxIndexingConcurrency * 4)
{
SingleReader = true,
});
@@ -253,14 +478,9 @@ public sealed class LibraryService(
};
await Parallel.ForEachAsync(
- pending,
+ items,
parallelOptions,
- async (item, token) =>
- {
- var info = await mediaProbe.ProbeAsync(item.FullPath, token);
- var thumbnail = await thumbnailGenerator.GetOrCreateAsync(item.FullPath, info.Duration, token);
- await channel.Writer.WriteAsync(new IndexResult(item, info, thumbnail), token);
- });
+ async (item, token) => await channel.Writer.WriteAsync(await work(item, token), token));
channel.Writer.Complete();
}
@@ -271,31 +491,12 @@ public sealed class LibraryService(
},
cancellationToken);
- var processed = 0;
-
await foreach (var result in channel.Reader.ReadAllAsync(cancellationToken))
{
- result.Item.ApplyTechnicalInfo(result.Info);
-
- if (result.ThumbnailPath is not null)
- {
- result.Item.AttachThumbnail(result.ThumbnailPath);
- }
-
- processed++;
-
- yield return new LibraryScanEvent.ItemUpdated(result.Item);
- yield return new LibraryScanEvent.IndexingProgress(processed, pending.Count);
-
- if (processed % SaveBatchSize == 0)
- {
- await repository.SaveChangesAsync(cancellationToken);
- }
+ yield return result;
}
await producer;
- logger.LogInformation("Indexed {Processed} of {Total} video files", processed, pending.Count);
}
- private readonly record struct IndexResult(VideoItem Item, VideoTechnicalInfo Info, string? ThumbnailPath);
}
diff --git a/src/PLib.Desktop/Controls/FilmstripImage.cs b/src/PLib.Desktop/Controls/FilmstripImage.cs
new file mode 100644
index 0000000..69f9a50
--- /dev/null
+++ b/src/PLib.Desktop/Controls/FilmstripImage.cs
@@ -0,0 +1,261 @@
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+
+namespace PLib.Desktop.Controls;
+
+///
+/// Plays an animated preview: one image holding several frames stacked on top of each other,
+/// drawn a slice at a time.
+///
+///
+/// The strip is loaded when playback starts and disposed when it stops, rather than kept in
+/// the shared thumbnail cache. A strip weighs as much as its frame count put together, and
+/// only the card under the pointer is ever playing — caching them the way poster frames are
+/// cached would trade a bounded cost for one that grows with how much of the library the
+/// user has swept across.
+///
+public sealed class FilmstripImage : Control
+{
+ public static readonly StyledProperty SourceProperty =
+ AvaloniaProperty.Register(nameof(Source));
+
+ public static readonly StyledProperty FrameCountProperty =
+ AvaloniaProperty.Register(nameof(FrameCount));
+
+ public static readonly StyledProperty IsPlayingProperty =
+ AvaloniaProperty.Register(nameof(IsPlaying));
+
+ /// Slow enough to read as a preview rather than a flicker, and cheap to draw.
+ private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(125);
+
+ ///
+ /// How long the pointer has to rest before anything is decoded. Without it, dragging the
+ /// pointer across the grid would start a decode for every card it crossed.
+ ///
+ private static readonly TimeSpan StartDelay = TimeSpan.FromMilliseconds(250);
+
+ private readonly DispatcherTimer _timer = new() { Interval = FrameInterval };
+
+ private CancellationTokenSource? _pending;
+ private Bitmap? _strip;
+ private int _frame;
+ private bool _isAttached;
+
+ static FilmstripImage()
+ {
+ AffectsRender(SourceProperty, FrameCountProperty);
+ }
+
+ public FilmstripImage()
+ {
+ Opacity = 0;
+ Transitions =
+ [
+ new DoubleTransition
+ {
+ Property = OpacityProperty,
+ Duration = TimeSpan.FromMilliseconds(180),
+ Easing = new CubicEaseOut(),
+ },
+ ];
+
+ _timer.Tick += OnTick;
+ }
+
+ /// Absolute path of the stacked image.
+ public string? Source
+ {
+ get => GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ /// How many frames the image is made of; below two there is nothing to animate.
+ public int FrameCount
+ {
+ get => GetValue(FrameCountProperty);
+ set => SetValue(FrameCountProperty, value);
+ }
+
+ /// Set by a style while the pointer is over the card.
+ public bool IsPlaying
+ {
+ get => GetValue(IsPlayingProperty);
+ set => SetValue(IsPlayingProperty, value);
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ _isAttached = true;
+ Restart();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ _isAttached = false;
+ Stop();
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == IsPlayingProperty ||
+ change.Property == SourceProperty ||
+ change.Property == FrameCountProperty)
+ {
+ Restart();
+ }
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ var bounds = new Rect(Bounds.Size);
+
+ if (_strip is null || bounds.Width <= 0 || bounds.Height <= 0)
+ {
+ return;
+ }
+
+ var frames = FrameCount;
+ var frameHeight = frames < 2 ? 0 : _strip.PixelSize.Height / frames;
+
+ if (frameHeight <= 0)
+ {
+ return;
+ }
+
+ var slice = new Rect(
+ 0,
+ Math.Min(_frame, frames - 1) * frameHeight,
+ _strip.PixelSize.Width,
+ frameHeight);
+
+ context.DrawImage(_strip, Cover(slice, bounds), bounds);
+ }
+
+ ///
+ /// The largest centred part of that has the same aspect ratio as
+ /// the destination — CSS object-fit: cover, matching how the poster frame is drawn
+ /// so that the preview does not jump when it fades in over it.
+ ///
+ private static Rect Cover(Rect source, Rect destination)
+ {
+ var scale = Math.Max(destination.Width / source.Width, destination.Height / source.Height);
+ var width = destination.Width / scale;
+ var height = destination.Height / scale;
+
+ return new Rect(
+ source.X + ((source.Width - width) / 2),
+ source.Y + ((source.Height - height) / 2),
+ width,
+ height);
+ }
+
+ private void OnTick(object? sender, EventArgs e)
+ {
+ var frames = FrameCount;
+
+ if (frames < 2)
+ {
+ return;
+ }
+
+ _frame = (_frame + 1) % frames;
+ InvalidateVisual();
+ }
+
+ private void Restart()
+ {
+ Stop();
+
+ if (!_isAttached || !IsPlaying || FrameCount < 2 || string.IsNullOrEmpty(Source))
+ {
+ return;
+ }
+
+ var cts = new CancellationTokenSource();
+ _pending = cts;
+
+ _ = LoadAsync(Source, cts);
+ }
+
+ private async Task LoadAsync(string path, CancellationTokenSource cts)
+ {
+ try
+ {
+ await Task.Delay(StartDelay, cts.Token);
+
+ var strip = await Task.Run(() => Decode(path), cts.Token);
+
+ if (strip is null)
+ {
+ return;
+ }
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ // The card may have been left, or its container recycled onto another video,
+ // while the strip was being decoded.
+ if (cts.IsCancellationRequested || !ReferenceEquals(_pending, cts))
+ {
+ strip.Dispose();
+ return;
+ }
+
+ _strip = strip;
+ _frame = 0;
+ Opacity = 1;
+ _timer.Start();
+ InvalidateVisual();
+ });
+ }
+ catch (OperationCanceledException)
+ {
+ // The pointer left before the preview was ready; nothing to show, nothing to report.
+ }
+ }
+
+ private static Bitmap? Decode(string path)
+ {
+ try
+ {
+ using var stream = File.OpenRead(path);
+
+ // Decoded at its natural size: the strip is already rendered small, and scaling it
+ // would only blur frames that are about to be drawn card-sized anyway.
+ return new Bitmap(stream);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
+ {
+ return null;
+ }
+ }
+
+ private void Stop()
+ {
+ _timer.Stop();
+
+ if (_pending is { } cts)
+ {
+ _pending = null;
+ cts.Cancel();
+ cts.Dispose();
+ }
+
+ // Owned here, unlike the poster frames, so it is disposed rather than left to the
+ // garbage collector — otherwise a browse through the library would pile up strips.
+ _strip?.Dispose();
+ _strip = null;
+ _frame = 0;
+
+ Opacity = 0;
+ InvalidateVisual();
+ }
+}
diff --git a/src/PLib.Desktop/Themes/LibraryStyles.axaml b/src/PLib.Desktop/Themes/LibraryStyles.axaml
index 519b62f..f593c6d 100644
--- a/src/PLib.Desktop/Themes/LibraryStyles.axaml
+++ b/src/PLib.Desktop/Themes/LibraryStyles.axaml
@@ -1,5 +1,6 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:controls="clr-namespace:PLib.Desktop.Controls">
@@ -76,6 +77,15 @@
+
+
+
+
+
+
+
+
+
+
+
+