From 3c77baced7c28b7346da17cb5b0d4388a74d7f87 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 9 Aug 2026 07:48:45 +0300 Subject: [PATCH] Implement animated previews and perceptual hashing in PLib video library manager. Introduce IAnimatedPreviewGenerator and IVideoPerceptualHasher interfaces, enhancing video item metadata with animated preview paths and perceptual hashes. Update LibraryService to manage indexing in three passes: metadata, animated previews, and perceptual hashes. Revise UI components to display animated previews and manage duplicate video detection. Enhance README.md to document these new features and usage instructions. --- README.md | 39 +- .../Abstractions/IAnimatedPreviewGenerator.cs | 26 + .../Abstractions/IMediaArtifactCache.cs | 36 ++ .../Abstractions/IThumbnailGenerator.cs | 52 +- .../Abstractions/IVideoPerceptualHasher.cs | 19 + .../Library/ILibraryService.cs | 12 + .../Library/LibraryOptions.cs | 14 + .../Library/LibraryScanEvent.cs | 73 +-- .../Library/LibraryService.cs | 293 +++++++++-- src/PLib.Desktop/Controls/FilmstripImage.cs | 261 ++++++++++ src/PLib.Desktop/Themes/LibraryStyles.axaml | 50 +- .../ViewModels/BackgroundTaskViewModel.cs | 74 +++ .../ViewModels/MainWindowViewModel.cs | 162 +++++- .../ViewModels/VideoCardViewModel.cs | 12 + .../ViewModels/VideoPlayerViewModel.cs | 7 + src/PLib.Desktop/Views/MainWindow.axaml | 72 ++- src/PLib.Desktop/appsettings.json | 2 + src/PLib.Domain/Videos/VideoItem.cs | 70 ++- .../DependencyInjection.cs | 2 + .../Media/FfmpegAnimatedPreviewGenerator.cs | 245 ++++++++++ .../Media/FfmpegThumbnailGenerator.cs | 93 +--- .../Media/FfmpegVideoPerceptualHasher.cs | 298 +++++++++++ .../Media/MediaArtifactCache.cs | 110 +++++ .../20260809041829_PerceptualHash.Designer.cs | 155 ++++++ .../20260809041829_PerceptualHash.cs | 37 ++ ...20260809044036_AnimatedPreview.Designer.cs | 162 ++++++ .../20260809044036_AnimatedPreview.cs | 40 ++ .../LibraryDbContextModelSnapshot.cs | 12 + .../Persistence/VideoItemConfiguration.cs | 16 +- src/PLib.Infrastructure/Storage/AppPaths.cs | 4 + src/PLib.Infrastructure/Storage/IAppPaths.cs | 6 + tests/PLib.Tests/Domain/VideoItemTests.cs | 165 ++++--- .../Library/DuplicateDetectionTests.cs | 96 ++++ tests/PLib.Tests/Library/LabelTests.cs | 2 + .../PLib.Tests/Library/LibraryServiceTests.cs | 461 +++++++++++------- .../Settings/AppSettingsStoreTests.cs | 4 + 36 files changed, 2711 insertions(+), 471 deletions(-) create mode 100644 src/PLib.Application/Abstractions/IAnimatedPreviewGenerator.cs create mode 100644 src/PLib.Application/Abstractions/IMediaArtifactCache.cs create mode 100644 src/PLib.Application/Abstractions/IVideoPerceptualHasher.cs create mode 100644 src/PLib.Desktop/Controls/FilmstripImage.cs create mode 100644 src/PLib.Desktop/ViewModels/BackgroundTaskViewModel.cs create mode 100644 src/PLib.Infrastructure/Media/FfmpegAnimatedPreviewGenerator.cs create mode 100644 src/PLib.Infrastructure/Media/FfmpegVideoPerceptualHasher.cs create mode 100644 src/PLib.Infrastructure/Media/MediaArtifactCache.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260809041829_PerceptualHash.Designer.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260809041829_PerceptualHash.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260809044036_AnimatedPreview.Designer.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260809044036_AnimatedPreview.cs create mode 100644 tests/PLib.Tests/Library/DuplicateDetectionTests.cs 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 @@ + + + + + + + + + + + +