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 @@ + + + + + + + + + + + +