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.

This commit is contained in:
Leonid Pershin
2026-08-09 07:48:45 +03:00
parent 10c66baea8
commit 3c77baced7
36 changed files with 2711 additions and 471 deletions
+69 -1
View File
@@ -64,6 +64,19 @@ public sealed class VideoItem
/// <summary>Absolute path of the generated poster frame, or <c>null</c> if none exists yet.</summary>
public string? ThumbnailPath { get; private set; }
/// <summary>
/// Absolute path of the animated preview — frames sampled across the video and stacked
/// into one image — or <c>null</c> if it has not been rendered yet.
/// </summary>
public string? PreviewPath { get; private set; }
/// <summary>
/// How many frames <see cref="PreviewPath"/> holds. Stored rather than assumed, because
/// the setting that produced it can change while old previews stay on disk, and a strip
/// sliced by the wrong count animates as a jumble.
/// </summary>
public int PreviewFrameCount { get; private set; }
/// <summary>Last write time of the file when it was last indexed.</summary>
public DateTimeOffset FileModifiedAt { get; private set; }
@@ -80,12 +93,35 @@ public sealed class VideoItem
/// <summary>How many times the video was watched through to the end.</summary>
public int PlayCount { get; private set; }
/// <summary>
/// Perceptual hash of the video's visuals, or <c>null</c> if it has not been computed.
/// Compared by Hamming distance rather than for equality.
/// </summary>
public ulong? PerceptualHash { get; private set; }
/// <summary>Tags and collections this video belongs to.</summary>
public IReadOnlyCollection<LibraryLabel> Labels => _labels;
/// <summary>True once the file has been probed and a poster frame produced.</summary>
/// <summary>
/// True once the file has been probed and a poster frame produced — everything the grid
/// needs. The perceptual hash is deliberately not part of this: it costs far more than
/// the rest put together and is computed in a pass of its own, after the cards are
/// already on screen.
/// </summary>
public bool IsIndexed => Duration is not null && ThumbnailPath is not null;
/// <summary>
/// True when the animated preview is still to be rendered. Like hashing, it samples the
/// running time and therefore cannot start before the duration is known.
/// </summary>
public bool NeedsAnimatedPreview => Duration is not null && PreviewPath is null;
/// <summary>
/// True when the video is ready to be hashed but has not been. Hashing samples frames
/// across the running time, so it cannot start before the duration is known.
/// </summary>
public bool NeedsPerceptualHash => Duration is not null && PerceptualHash is null;
/// <summary>How far through the video the viewer got, as a fraction, for the card overlay.</summary>
public double WatchedFraction => Duration is { TotalSeconds: > 0 } total && ResumePosition is { } position
? Math.Clamp(position / total, 0, 1)
@@ -151,6 +187,21 @@ public sealed class VideoItem
public bool RemoveLabel(Guid labelId) => _labels.RemoveAll(label => label.Id == labelId) > 0;
public void ApplyPerceptualHash(ulong? hash) => PerceptualHash = hash;
/// <summary>
/// How many bits differ from another video's hash, or <c>null</c> when either side has
/// no hash to compare.
/// </summary>
public int? DistanceTo(VideoItem other)
{
ArgumentNullException.ThrowIfNull(other);
return PerceptualHash is { } mine && other.PerceptualHash is { } theirs
? System.Numerics.BitOperations.PopCount(mine ^ theirs)
: null;
}
public void AttachThumbnail(string thumbnailPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(thumbnailPath);
@@ -159,6 +210,21 @@ public sealed class VideoItem
public void DetachThumbnail() => ThumbnailPath = null;
public void AttachPreview(string previewPath, int frameCount)
{
ArgumentException.ThrowIfNullOrWhiteSpace(previewPath);
ArgumentOutOfRangeException.ThrowIfLessThan(frameCount, 2);
PreviewPath = previewPath;
PreviewFrameCount = frameCount;
}
public void DetachPreview()
{
PreviewPath = null;
PreviewFrameCount = 0;
}
/// <summary>
/// Refreshes the file system facts after the file changed on disk, and invalidates
/// everything that was derived from the previous revision of the file.
@@ -175,6 +241,8 @@ public sealed class VideoItem
SizeInBytes = sizeInBytes;
FileModifiedAt = fileModifiedAt;
ApplyTechnicalInfo(VideoTechnicalInfo.Unknown);
ApplyPerceptualHash(null);
DetachThumbnail();
DetachPreview();
}
}