WIP: add video thumbnails

This commit is contained in:
Akshay Kolli
2026-07-01 15:47:45 -07:00
parent 23788fd136
commit e1557f42b2
8 changed files with 182 additions and 11 deletions

View File

@@ -1,7 +1,10 @@
import AppKit
import AVFoundation
import Foundation
final class ClipboardCacheService {
typealias VideoThumbnailProvider = (URL) -> NSImage?
private let thumbnailCache = NSCache<NSString, NSImage>()
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "clipboard.cache.service", qos: .utility)
@@ -9,8 +12,13 @@ final class ClipboardCacheService {
private let attachmentDirectory: URL
private let temporaryPreviewDirectory: URL
private let encryptionService: ClipboardEncryptionService
private let videoThumbnailProvider: VideoThumbnailProvider
init(baseURL: URL? = nil, encryptionService: ClipboardEncryptionService = ClipboardEncryptionService()) {
init(
baseURL: URL? = nil,
encryptionService: ClipboardEncryptionService = ClipboardEncryptionService(),
videoThumbnailProvider: VideoThumbnailProvider? = nil
) {
let base = baseURL ?? ClipboardStore.storageDirectory()
imageDirectory = base.appendingPathComponent("images", isDirectory: true)
attachmentDirectory = base.appendingPathComponent("attachments", isDirectory: true)
@@ -18,6 +26,7 @@ final class ClipboardCacheService {
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
.appendingPathComponent("Previews", isDirectory: true)
self.encryptionService = encryptionService
self.videoThumbnailProvider = videoThumbnailProvider ?? Self.makeVideoThumbnail
thumbnailCache.countLimit = 128
try? fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true)
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
@@ -109,7 +118,10 @@ final class ClipboardCacheService {
case .file:
return filePreviewThumbnail(for: item.payload)
case .text, .unknown, .audio, .richText, .color, .code, .video:
case .video:
return videoPreviewThumbnail(for: item)
case .text, .unknown, .audio, .richText, .color, .code:
return nil
}
}
@@ -154,6 +166,51 @@ final class ClipboardCacheService {
return image
}
private func videoPreviewThumbnail(for item: ClipboardItem) -> NSImage? {
let key = NSString(string: "video-preview:\(item.id.uuidString):\(item.payload)")
if let cached = thumbnailCache.object(forKey: key) {
return cached
}
guard
let data = data(for: item.payload),
let temporaryURL = writeTemporaryCopy(
data: data,
id: item.id,
fileExtension: VideoPayload.fileExtension(from: item.payload)
)
else {
return nil
}
defer { removeTemporaryCopyIfPossible(temporaryURL) }
guard let image = videoThumbnailProvider(temporaryURL), hasDrawableSize(image) else {
return nil
}
let thumbnail = image.resized(to: CGSize(width: 260, height: 132))
thumbnailCache.setObject(thumbnail, forKey: key)
return thumbnail
}
private static func makeVideoThumbnail(from url: URL) -> NSImage? {
let asset = AVURLAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 520, height: 264)
generator.requestedTimeToleranceBefore = .zero
generator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 30)
for time in [CMTime(seconds: 0.08, preferredTimescale: 600), .zero] {
if let cgImage = try? generator.copyCGImage(at: time, actualTime: nil) {
return NSImage(
cgImage: cgImage,
size: NSSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height))
)
}
}
return nil
}
private func hasDrawableSize(_ image: NSImage) -> Bool {
image.size.width > 0 && image.size.height > 0
}
@@ -347,6 +404,13 @@ final class ClipboardCacheService {
}
}
private func removeTemporaryCopyIfPossible(_ url: URL) {
try? fileManager.removeItem(at: url)
if ((try? fileManager.contentsOfDirectory(at: temporaryPreviewDirectory, includingPropertiesForKeys: nil)) ?? []).isEmpty {
try? fileManager.removeItem(at: temporaryPreviewDirectory)
}
}
private func webLocationData(for value: String) -> Data? {
let trimmed = value.clipboardTrimmed
guard !trimmed.isEmpty else { return nil }

View File

@@ -3716,6 +3716,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
case .audio:
return audioPreviewView(for: item)
case .video:
if let thumbnail {
return videoMediaPreviewView(for: item, thumbnail: thumbnail)
}
return videoPreviewView(for: item)
case .color:
return colorPreviewView(for: item)
@@ -4382,6 +4385,50 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return container
}
private func videoMediaPreviewView(for item: ClipboardItem, thumbnail: NSImage) -> NSView {
let container = NSView()
container.translatesAutoresizingMaskIntoConstraints = false
let imageView = AspectFillImageView(image: thumbnail)
imageView.translatesAutoresizingMaskIntoConstraints = false
let playBadge = NSView()
playBadge.wantsLayer = true
playBadge.layer?.cornerRadius = 22
playBadge.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.48).cgColor
playBadge.layer?.borderWidth = 0.8
playBadge.layer?.borderColor = NSColor.white.withAlphaComponent(0.30).cgColor
playBadge.translatesAutoresizingMaskIntoConstraints = false
let play = headerIcon("play.fill", color: .white)
play.translatesAutoresizingMaskIntoConstraints = false
playBadge.addSubview(play)
let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: NSColor.black.withAlphaComponent(0.60))
extensionPill.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(imageView)
container.addSubview(playBadge)
container.addSubview(extensionPill)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
imageView.topAnchor.constraint(equalTo: container.topAnchor),
imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
playBadge.centerXAnchor.constraint(equalTo: container.centerXAnchor),
playBadge.centerYAnchor.constraint(equalTo: container.centerYAnchor),
playBadge.widthAnchor.constraint(equalToConstant: 44),
playBadge.heightAnchor.constraint(equalToConstant: 44),
play.centerXAnchor.constraint(equalTo: playBadge.centerXAnchor, constant: 1),
play.centerYAnchor.constraint(equalTo: playBadge.centerYAnchor),
play.widthAnchor.constraint(equalToConstant: 16),
play.heightAnchor.constraint(equalToConstant: 16),
extensionPill.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
extensionPill.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10)
])
return container
}
private func capsuleLabel(_ text: String, color: NSColor) -> NSTextField {
let label = NSTextField(labelWithString: text)
label.translatesAutoresizingMaskIntoConstraints = false
@@ -4443,7 +4490,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
case .audio:
return "audio-preview"
case .video:
return "video-preview"
return thumbnail == nil ? "video-preview" : "video-media-preview"
case .color:
return "color-preview"
case .code: