diff --git a/Assets/thumbnail.png b/Assets/thumbnail.png index 43c6719..e8b2c63 100644 Binary files a/Assets/thumbnail.png and b/Assets/thumbnail.png differ diff --git a/Assets/thumbnail@2x.png b/Assets/thumbnail@2x.png index cf126e6..3eb925d 100644 Binary files a/Assets/thumbnail@2x.png and b/Assets/thumbnail@2x.png differ diff --git a/Info.plist b/Info.plist index 4578673..ba86c9d 100644 --- a/Info.plist +++ b/Info.plist @@ -8,8 +8,8 @@ CFBundleInfoDictionaryVersion6.0 CFBundleNameSnoopy CFBundlePackageTypeBNDL -CFBundleShortVersionString1.0 -CFBundleVersion1 +CFBundleShortVersionString1.1 +CFBundleVersion2 LSMinimumSystemVersion12.0 NSHighResolutionCapable NSPrincipalClassSnoopyScreenSaverView diff --git a/Sources/ScreenSaverView.swift b/Sources/ScreenSaverView.swift index 3e42b21..9d0cdbd 100644 --- a/Sources/ScreenSaverView.swift +++ b/Sources/ScreenSaverView.swift @@ -5,9 +5,20 @@ import ScreenSaver @objc(SnoopyScreenSaverView) final class SnoopyScreenSaverView: ScreenSaverView { + private final class WeakView { + weak var value: SnoopyScreenSaverView? + init(_ value: SnoopyScreenSaverView) { self.value = value } + } + + /// Wallpaper mode may construct a replacement view without reliably + /// stopping the previous one. Keep one live view per display (and one + /// preview) so stale 30 fps animation loops cannot accumulate for days. + private static var activeViews: [String: WeakView] = [:] + private struct ActivePlayback { let container: CALayer let player: AVPlayer + let playerLayer: AVPlayerLayer let scene: AssetRecord let startedAt: Date } @@ -24,21 +35,32 @@ final class SnoopyScreenSaverView: ScreenSaverView { private var watchdog: DispatchWorkItem? private var bootstrapLayer: CALayer? private var preparedIdle: IdleScenePlayback? + private var retiringActives: [ActivePlayback] = [] + private var retiringIdles: [IdleScenePlayback] = [] private var generation = 0 private var lastTransitionPairID: String? private var revealedPlayerID: ObjectIdentifier? + private var sessionActive = false + private var coordinatorKey: String? + private var nextEnvironmentRefresh = Date.distantPast override init?(frame: NSRect, isPreview: Bool) { super.init(frame: frame, isPreview: isPreview); configure() } required init?(coder: NSCoder) { super.init(coder: coder); configure() } + deinit { shutdown() } private func configure() { animationTimeInterval = 1.0 / 30; wantsLayer = true layer?.backgroundColor = NSColor.black.cgColor; layer?.masksToBounds = true } override func startAnimation() { - super.startAnimation(); generation += 1; let expected = generation + guard !sessionActive else { return } + super.startAnimation() + sessionActive = true + claimActiveSlot() + generation += 1; let expected = generation + nextEnvironmentRefresh = Date() showBootstrap() environment.start() if catalog != nil { playInitialScene(); return } @@ -46,7 +68,7 @@ final class SnoopyScreenSaverView: ScreenSaverView { DispatchQueue.global(qos: .userInitiated).async { [weak self] in let result = Result { try AssetCatalog(bundle: bundle) } DispatchQueue.main.async { - guard let self, self.isAnimating, self.generation == expected else { return } + guard let self, self.sessionActive, self.generation == expected else { return } switch result { case .success(let catalog): self.catalog = catalog; self.playInitialScene() case .failure(let error): @@ -54,7 +76,7 @@ final class SnoopyScreenSaverView: ScreenSaverView { // Retry once: ScreenSaverEngine may have raced a freshly // replaced bundle on disk. DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard let self, self.isAnimating, self.generation == expected else { return } + guard let self, self.sessionActive, self.generation == expected else { return } self.startCatalogRetry(bundle: bundle, generation: expected) } } @@ -66,21 +88,40 @@ final class SnoopyScreenSaverView: ScreenSaverView { DispatchQueue.global(qos: .userInitiated).async { [weak self] in let result = try? AssetCatalog(bundle: bundle) DispatchQueue.main.async { - guard let self, self.isAnimating, self.generation == expected, let result else { return } + guard let self, self.sessionActive, self.generation == expected, let result else { return } self.catalog = result; self.playInitialScene() } } } override func stopAnimation() { - generation += 1; removeObservers(); watchdog?.cancel(); watchdog = nil - idle?.stop(); stop(active?.player); preparedIdle?.stop() - idle = nil; active = nil; preparedIdle = nil - environment.stop(); SpriteAssetPlayer.purgeCaches() - layer?.sublayers?.forEach { $0.removeFromSuperlayer() }; bootstrapLayer = nil + shutdown() super.stopAnimation() } - override func animateOneFrame() { idle?.tick(); environment.refreshIfNeeded() } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil, sessionActive { + claimActiveSlot() + } else if window == nil, sessionActive { + // Deferring avoids treating a transient detach/reattach as the end + // of a wallpaper session. + DispatchQueue.main.async { [weak self] in + guard let self, self.window == nil, self.sessionActive else { return } + self.stopAnimation() + } + } + } + + override func animateOneFrame() { + guard sessionActive else { return } + let now = Date() + idle?.tick(now) + if now >= nextEnvironmentRefresh { + nextEnvironmentRefresh = now.addingTimeInterval(60) + environment.refreshIfNeeded() + } + } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize); bootstrapLayer?.frame = bounds idle?.resize(bounds); active?.container.frame = bounds @@ -94,9 +135,15 @@ final class SnoopyScreenSaverView: ScreenSaverView { @objc func forceNextSceneForTesting() { playActive() } @objc func forceIdleSceneForTesting() { playIdle(durationOverride: 12) } + @objc func forceLongIdleSceneForTesting() { playIdle(durationOverride: 120) } @objc func idleTargetDurationForTesting() -> Double { idle?.targetDuration ?? 0 } @objc func idleCharacterAnimationCountForTesting() -> Int { idle?.specialAnimationCount ?? 0 } @objc func idleHalftonePresentForTesting() -> Bool { idle?.debugHasHalftone ?? false } + @objc func sessionActiveForTesting() -> Bool { sessionActive } + @objc class func activeInstanceCountForTesting() -> Int { + activeViews = activeViews.filter { $0.value.value != nil } + return activeViews.count + } private func playInitialScene() { // Keep the black bootstrap visible until the selected scene is ready, @@ -108,7 +155,7 @@ final class SnoopyScreenSaverView: ScreenSaverView { durationOverride: TimeInterval? = nil, transitionOverride: TransitionChoice? = nil ) { - guard isAnimating, let catalog, let root = layer else { return } + guard sessionActive, let catalog, let root = layer else { return } removeObservers(); watchdog?.cancel(); watchdog = nil let oldActive = active, oldIdle = idle let incoming: IdleScenePlayback @@ -125,11 +172,13 @@ final class SnoopyScreenSaverView: ScreenSaverView { frame: bounds, targetDuration: duration ) } + retainForTransition(oldActive) + retainForTransition(oldIdle) incoming.container.opacity = 0 incoming.container.removeFromSuperlayer() idle = incoming; active = nil; root.addSublayer(incoming.container); incoming.start(); incoming.tick() incoming.onFinished = { [weak self, weak incoming] in - guard let self, let incoming, self.idle === incoming, self.isAnimating else { return } + guard let self, let incoming, self.idle === incoming, self.sessionActive else { return } self.playActive() } let choice: TransitionChoice @@ -147,18 +196,21 @@ final class SnoopyScreenSaverView: ScreenSaverView { style: choice.style, in: bounds ) { [weak self, weak incoming] in guard let self, let incoming, self.idle === incoming else { return } - self.stop(oldActive?.player); oldActive?.container.removeFromSuperlayer() - oldIdle?.stop(); oldIdle?.container.removeFromSuperlayer(); self.bootstrapLayer?.removeFromSuperlayer(); self.bootstrapLayer = nil + self.finishRetiring(oldActive) + self.finishRetiring(oldIdle) + self.bootstrapLayer?.removeFromSuperlayer(); self.bootstrapLayer = nil } } private func playActive() { - guard isAnimating, let catalog, let root = layer else { return } + guard sessionActive, let catalog, let root = layer else { return } removeObservers(); watchdog?.cancel(); watchdog = nil preparedIdle?.stop(); preparedIdle?.container.removeFromSuperlayer(); preparedIdle = nil let outgoingIdle = idle, oldActive = active guard let scene = catalog.selectActive(environment: environment.snapshot(), scorer: scorer), let sprite = scene.sprites.first, let url = sprite.movieURL else { return } + retainForTransition(oldActive) + retainForTransition(outgoingIdle) let item = AVPlayerItem(url: url), player = AVPlayer(playerItem: item) player.isMuted = true; player.volume = 0; player.actionAtItemEnd = .pause let container = CALayer(); container.frame = bounds; container.backgroundColor = NSColor.black.cgColor @@ -168,7 +220,9 @@ final class SnoopyScreenSaverView: ScreenSaverView { // while filling the display, accepting only symmetric edge cropping. // IdleScene uses the metadata-driven aspect-fit viewport instead. video.videoGravity = .resizeAspectFill; container.addSublayer(video); root.addSublayer(container) - let playback = ActivePlayback(container: container, player: player, scene: scene, startedAt: Date()) + let playback = ActivePlayback( + container: container, player: player, playerLayer: video, scene: scene, startedAt: Date() + ) active = playback; revealedPlayerID = nil let isInitialScene = outgoingIdle == nil && oldActive == nil let choice = isInitialScene @@ -194,8 +248,8 @@ final class SnoopyScreenSaverView: ScreenSaverView { style: choice.style, in: self.bounds ) { [weak self, weak player] in guard let self, let player, self.active?.player === player else { return } - self.stop(oldActive?.player); oldActive?.container.removeFromSuperlayer() - outgoingIdle?.stop(); outgoingIdle?.container.removeFromSuperlayer() + self.finishRetiring(oldActive) + self.finishRetiring(outgoingIdle) if let outgoingIdle, self.idle === outgoingIdle { self.idle = nil } self.bootstrapLayer?.removeFromSuperlayer(); self.bootstrapLayer = nil } @@ -258,7 +312,7 @@ final class SnoopyScreenSaverView: ScreenSaverView { } private func prepareIdleForActiveExit() { - guard preparedIdle == nil, let catalog, let root = layer else { return } + guard sessionActive, preparedIdle == nil, let catalog, let root = layer else { return } let duration = Double.random(in: 20...60) guard let composition = catalog.makeIdleComposition( environment: environment.snapshot(), scorer: scorer @@ -280,8 +334,40 @@ final class SnoopyScreenSaverView: ScreenSaverView { endObserver = nil; failureObserver = nil; readiness?.invalidate(); readiness = nil } - private func stop(_ player: AVPlayer?) { - player?.cancelPendingPrerolls(); player?.pause(); player?.replaceCurrentItem(with: nil) + private func stop(_ playback: ActivePlayback?) { + guard let playback else { return } + playback.playerLayer.player = nil + playback.player.cancelPendingPrerolls() + playback.player.pause() + playback.player.replaceCurrentItem(with: nil) + playback.container.removeAllAnimations() + playback.container.removeFromSuperlayer() + } + + private func retainForTransition(_ playback: ActivePlayback?) { + guard let playback, + !retiringActives.contains(where: { $0.player === playback.player }) else { return } + retiringActives.append(playback) + } + + private func retainForTransition(_ playback: IdleScenePlayback?) { + guard let playback, + !retiringIdles.contains(where: { $0 === playback }) else { return } + retiringIdles.append(playback) + } + + private func finishRetiring(_ playback: ActivePlayback?) { + guard let playback else { return } + stop(playback) + retiringActives.removeAll { $0.player === playback.player } + } + + private func finishRetiring(_ playback: IdleScenePlayback?) { + guard let playback else { return } + playback.stop() + playback.container.removeAllAnimations() + playback.container.removeFromSuperlayer() + retiringIdles.removeAll { $0 === playback } } private func showBootstrap() { @@ -290,4 +376,70 @@ final class SnoopyScreenSaverView: ScreenSaverView { let holder = CALayer(); holder.frame = bounds; holder.backgroundColor = NSColor.black.cgColor root.addSublayer(holder); bootstrapLayer = holder } + + private func shutdown() { + guard sessionActive || idle != nil || active != nil || preparedIdle != nil + || !retiringActives.isEmpty || !retiringIdles.isEmpty else { + _ = releaseActiveSlot() + return + } + sessionActive = false + generation += 1 + removeObservers() + watchdog?.cancel(); watchdog = nil + + idle?.stop(); idle?.container.removeAllAnimations(); idle?.container.removeFromSuperlayer() + preparedIdle?.stop(); preparedIdle?.container.removeAllAnimations(); preparedIdle?.container.removeFromSuperlayer() + stop(active) + retiringActives.forEach { stop($0) } + retiringIdles.forEach { + $0.stop(); $0.container.removeAllAnimations(); $0.container.removeFromSuperlayer() + } + retiringActives.removeAll(); retiringIdles.removeAll() + idle = nil; active = nil; preparedIdle = nil + revealedPlayerID = nil + environment.stop() + layer?.sublayers?.forEach { + if let playerLayer = $0 as? AVPlayerLayer { playerLayer.player = nil } + $0.removeAllAnimations() + $0.removeFromSuperlayer() + } + bootstrapLayer = nil + if releaseActiveSlot() { SpriteAssetPlayer.purgeCaches() } + } + + private func claimActiveSlot() { + let key = activationKey() + if let previousKey = coordinatorKey, previousKey != key, + Self.activeViews[previousKey]?.value === self { + Self.activeViews.removeValue(forKey: previousKey) + } + Self.activeViews = Self.activeViews.filter { $0.value.value != nil } + let previous = Self.activeViews[key]?.value + coordinatorKey = key + Self.activeViews[key] = WeakView(self) + if let previous, previous !== self { + NSLog("Snoopy stopping stale screen saver view for %@", key) + previous.stopAnimation() + } + } + + @discardableResult + private func releaseActiveSlot() -> Bool { + if let key = coordinatorKey, Self.activeViews[key]?.value === self { + Self.activeViews.removeValue(forKey: key) + } + coordinatorKey = nil + Self.activeViews = Self.activeViews.filter { $0.value.value != nil } + return Self.activeViews.isEmpty + } + + private func activationKey() -> String { + if isPreview { return "preview" } + let screenNumberKey = NSDeviceDescriptionKey("NSScreenNumber") + if let number = window?.screen?.deviceDescription[screenNumberKey] as? NSNumber { + return "screen-\(number.stringValue)" + } + return "fullscreen" + } } diff --git a/Sources/SpritePlayer.swift b/Sources/SpritePlayer.swift index a0b354e..216d03d 100644 --- a/Sources/SpritePlayer.swift +++ b/Sources/SpritePlayer.swift @@ -3,23 +3,151 @@ import AVFoundation import ImageIO import QuartzCore -private final class FrameCache { - static let shared = FrameCache() - private let cache = NSCache() +/// Decodes HEIC frames away from the Core Animation commit path. `NSImage` +/// keeps its ImageIO source lazy, so merely creating one on the main thread +/// still leaves the expensive HEIF decompression for `CALayer` to perform. +/// The immediate thumbnail below guarantees that layers only ever receive +/// decoded pixels. A bounded shared cache and two workers limit memory and CPU. +private final class FrameDecoder { + private final class Entry { + let image: CGImage + let cost: Int + init(_ image: CGImage) { + self.image = image + cost = image.bytesPerRow * image.height + } + } + + static let shared = FrameDecoder() + + typealias Callback = (CGImage?) -> Void + private final class Request { + var clients: Set = [] + var callbacks: [UUID: [Callback]] = [:] + var operation: BlockOperation? + } + + private let cache = NSCache() + private let queue: OperationQueue + private let lock = NSLock() + private var inFlight: [String: Request] = [:] + private init() { - cache.countLimit = 32 - cache.totalCostLimit = 192 * 1024 * 1024 + cache.countLimit = 64 + cache.totalCostLimit = 256 * 1024 * 1024 + queue = OperationQueue() + queue.name = "local.snoopy.frame-decoder" + queue.qualityOfService = .utility + queue.maxConcurrentOperationCount = 2 } - func image(_ url: URL) -> NSImage? { - let key = url.path as NSString - if let image = cache.object(forKey: key) { return image } - guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), - let cg = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return nil } - let image = NSImage(cgImage: cg, size: .zero) - cache.setObject(image, forKey: key, cost: cg.width * cg.height * 4) - return image + + func key(for url: URL, maxPixelSize: Int) -> String { + "\(url.path)#\(maxPixelSize)" + } + + func cachedImage(for url: URL, maxPixelSize: Int) -> CGImage? { + cache.object(forKey: key(for: url, maxPixelSize: maxPixelSize) as NSString)?.image + } + + func request( + _ url: URL, + maxPixelSize: Int, + clientID: UUID, + completion: Callback? = nil + ) { + let cacheKey = key(for: url, maxPixelSize: maxPixelSize) + if let image = cache.object(forKey: cacheKey as NSString)?.image { + completion?(image) + return + } + + lock.lock() + if let request = inFlight[cacheKey] { + request.clients.insert(clientID) + if let completion { request.callbacks[clientID, default: []].append(completion) } + lock.unlock() + return + } + let request = Request() + request.clients.insert(clientID) + if let completion { request.callbacks[clientID] = [completion] } + let operation = BlockOperation() + request.operation = operation + inFlight[cacheKey] = request + lock.unlock() + + operation.addExecutionBlock { [weak self, weak operation] in + guard let self, operation?.isCancelled == false else { return } + let image = autoreleasepool { + Self.decode(url, maxPixelSize: maxPixelSize) + } + guard operation?.isCancelled == false else { + self.finish(cacheKey, image: nil) + return + } + self.finish(cacheKey, image: image) + } + queue.addOperation(operation) + } + + func cancelRequests(for clientID: UUID) { + lock.lock() + for key in Array(inFlight.keys) { + guard let request = inFlight[key] else { continue } + request.clients.remove(clientID) + request.callbacks.removeValue(forKey: clientID) + if request.clients.isEmpty { + request.operation?.cancel() + inFlight.removeValue(forKey: key) + } + } + lock.unlock() + } + + func removeAll() { + lock.lock() + let operations = inFlight.values.compactMap(\.operation) + inFlight.removeAll() + lock.unlock() + operations.forEach { $0.cancel() } + cache.removeAllObjects() + } + + private func finish(_ cacheKey: String, image: CGImage?) { + lock.lock() + let request = inFlight.removeValue(forKey: cacheKey) + lock.unlock() + guard let request else { return } + if let image { + let entry = Entry(image) + cache.setObject(entry, forKey: cacheKey as NSString, cost: entry.cost) + } + let callbacks = request.callbacks.values.flatMap { $0 } + guard !callbacks.isEmpty else { return } + OperationQueue.main.addOperation { + callbacks.forEach { $0(image) } + } + } + + private static func decode(_ url: URL, maxPixelSize: Int) -> CGImage? { + guard let source = CGImageSourceCreateWithURL( + url as CFURL, + [kCGImageSourceShouldCache: false] as CFDictionary + ) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: max(64, maxPixelSize), + kCGImageSourceShouldCacheImmediately: true, + ] + guard let sourceImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + + // `ShouldCacheImmediately` makes this a decoded thumbnail rather than + // the lazy ImageIO-backed image used by the old implementation. + return sourceImage } - func removeAll() { cache.removeAllObjects() } } final class SpriteAssetPlayer { @@ -35,6 +163,12 @@ final class SpriteAssetPlayer { private var frameLayer: CALayer? private var frameDescriptor: SpriteDescriptor? private var frameStarted = Date() + private var frameGeneration = 0 + private var frameReady = false + private var desiredFrameKey: String? + private var pendingFrameKey: String? + private var decoderClientID = UUID() + private var frameURLs: [String: URL] = [:] private(set) var isPlaying = false var debugLayerCount: Int { layers.count } var onReady: (() -> Void)? @@ -45,7 +179,7 @@ final class SpriteAssetPlayer { deinit { stop() } - static func purgeCaches() { FrameCache.shared.removeAll() } + static func purgeCaches() { FrameDecoder.shared.removeAll() } func play( _ asset: AssetRecord, @@ -70,12 +204,11 @@ final class SpriteAssetPlayer { } func tick(_ date: Date = Date()) { - guard let descriptor = frameDescriptor, let layer = frameLayer else { return } + guard frameReady, let descriptor = frameDescriptor, let layer = frameLayer else { return } let elapsed = Int(max(0, date.timeIntervalSince(frameStarted)) * 30) let local = descriptor.loops ? elapsed % descriptor.frameCount : min(descriptor.frameCount - 1, elapsed) - if let url = descriptor.frameURL(descriptor.startFrame + local), let image = FrameCache.shared.image(url) { - layer.contents = image - } + requestFrame(descriptor.startFrame + local, descriptor: descriptor, layer: layer, generation: frameGeneration) + prefetch(descriptor, after: local, count: 6) } func resize() { @@ -87,15 +220,23 @@ final class SpriteAssetPlayer { func stop() { isPlaying = false + FrameDecoder.shared.cancelRequests(for: decoderClientID) + decoderClientID = UUID() + frameGeneration += 1 + frameReady = false + desiredFrameKey = nil + pendingFrameKey = nil + frameURLs.removeAll(keepingCapacity: false) onReady = nil workItems.forEach { $0.cancel() }; workItems.removeAll() observers.forEach(NotificationCenter.default.removeObserver); observers.removeAll() observations.forEach { $0.invalidate() }; observations.removeAll() + layers.compactMap { $0 as? AVPlayerLayer }.forEach { $0.player = nil } players.forEach { $0.cancelPendingPrerolls(); $0.pause(); $0.replaceCurrentItem(with: nil) } players.removeAll() - layers.forEach { $0.removeFromSuperlayer() }; layers.removeAll() + layers.forEach { $0.removeAllAnimations(); $0.removeFromSuperlayer() }; layers.removeAll() layerDescriptors.removeAll() frameLayer = nil; frameDescriptor = nil } @@ -168,22 +309,30 @@ final class SpriteAssetPlayer { return } let layer = CALayer(); layer.contentsGravity = .resizeAspect + layer.opacity = 0 configure(layer, sprite: intro); frameLayer = layer - activateFrame(intro, on: layer); reveal(layer) let introDuration = Double(intro.frameCount) / 30 let outroDuration = Double(outro.frameCount) / 30 let loopDuration = max(Double(loop.frameCount) / 30, targetDuration - introDuration - outroDuration) - schedule(introDuration) { [weak self, weak layer] in + activateFrame(intro, on: layer) { [weak self, weak layer] in guard let self, let layer, self.isPlaying else { return } - // Keep the same CALayer and replace contents only after the first - // incoming frame has decoded. There is no remove/add blank frame. - self.activateFrame(loop, on: layer) - self.schedule(loopDuration) { [weak self, weak layer] in + self.schedule(introDuration) { [weak self, weak layer] in guard let self, let layer, self.isPlaying else { return } - self.activateFrame(outro, on: layer) - self.schedule(outroDuration) { [weak self] in - guard let self, self.isPlaying else { return } - self.isPlaying = false; completion?() + // Keep the same CALayer and retain the previous phase's last + // frame until the first decoded frame of the next phase is + // available. This avoids the one-frame transparent gap. + self.activateFrame(loop, on: layer) { [weak self, weak layer] in + guard let self, let layer, self.isPlaying else { return } + self.schedule(loopDuration) { [weak self, weak layer] in + guard let self, let layer, self.isPlaying else { return } + self.activateFrame(outro, on: layer) { [weak self] in + guard let self, self.isPlaying else { return } + self.schedule(outroDuration) { [weak self] in + guard let self, self.isPlaying else { return } + self.isPlaying = false; completion?() + } + } + } } } } @@ -192,6 +341,7 @@ final class SpriteAssetPlayer { private func playOneShot(_ sprite: SpriteDescriptor, repeatWhole: Bool, completion: (() -> Void)?) { if sprite.mediaType == .frameSequence { let layer = CALayer(); layer.contentsGravity = .resizeAspect + layer.opacity = 0 configure(layer, sprite: sprite); frameLayer = layer var descriptor = sprite if repeatWhole && !sprite.loops { @@ -200,9 +350,14 @@ final class SpriteAssetPlayer { alignment: sprite.alignment, plane: sprite.plane, loops: true, startFrame: sprite.startFrame, endFrame: sprite.endFrame, digitCount: sprite.digitCount) } - activateFrame(descriptor, on: layer); reveal(layer) - if !repeatWhole && !sprite.loops { - schedule(Double(sprite.frameCount) / 30) { [weak self] in self?.isPlaying = false; completion?() } + activateFrame(descriptor, on: layer) { [weak self] in + guard let self, self.isPlaying else { return } + if !repeatWhole && !sprite.loops { + self.schedule(Double(sprite.frameCount) / 30) { [weak self] in + guard let self, self.isPlaying else { return } + self.isPlaying = false; completion?() + } + } } } else if let url = sprite.movieURL { let item = AVPlayerItem(url: url), player = AVPlayer(playerItem: item) @@ -241,13 +396,123 @@ final class SpriteAssetPlayer { CATransaction.commit() onReady?(); onReady = nil } - private func activateFrame(_ descriptor: SpriteDescriptor, on layer: CALayer) { - if let url = descriptor.frameURL(descriptor.startFrame), let image = FrameCache.shared.image(url) { - layer.contents = image - } - frameDescriptor = descriptor; frameStarted = Date() + private func activateFrame( + _ descriptor: SpriteDescriptor, + on layer: CALayer, + completion: (() -> Void)? = nil + ) { + frameGeneration += 1 + let generation = frameGeneration + frameDescriptor = descriptor + frameReady = false + desiredFrameKey = nil + pendingFrameKey = nil layerDescriptors[ObjectIdentifier(layer)] = descriptor layer.frame = ViewportLayout.spriteFrame(descriptor, in: hostBounds(), sceneOffset: sceneOffset) + requestFrame( + descriptor.startFrame, + descriptor: descriptor, + layer: layer, + generation: generation + ) { [weak self, weak layer] in + guard let self, let layer, self.isPlaying, self.frameGeneration == generation else { return } + self.frameReady = true + self.frameStarted = Date() + self.reveal(layer) + completion?() + } + prefetch(descriptor, after: 0, count: 6) + } + + private func requestFrame( + _ frameNumber: Int, + descriptor: SpriteDescriptor, + layer: CALayer, + generation: Int, + firstFrameCompletion: (() -> Void)? = nil + ) { + guard let url = frameURL(frameNumber, descriptor: descriptor) else { return } + let pixelSize = targetPixelSize(for: descriptor) + let key = FrameDecoder.shared.key(for: url, maxPixelSize: pixelSize) + desiredFrameKey = key + + if let image = FrameDecoder.shared.cachedImage(for: url, maxPixelSize: pixelSize) { + installFrame( + image, + key: key, + layer: layer, + generation: generation, + completion: firstFrameCompletion + ) + return + } + guard pendingFrameKey != key else { return } + pendingFrameKey = key + FrameDecoder.shared.request( + url, + maxPixelSize: pixelSize, + clientID: decoderClientID + ) { [weak self, weak layer] image in + guard let self, let layer else { return } + if self.pendingFrameKey == key { self.pendingFrameKey = nil } + guard let image, self.desiredFrameKey == key else { return } + self.installFrame( + image, + key: key, + layer: layer, + generation: generation, + completion: firstFrameCompletion + ) + } + } + + private func installFrame( + _ image: CGImage, + key: String, + layer: CALayer, + generation: Int, + completion: (() -> Void)? + ) { + guard isPlaying, frameGeneration == generation, desiredFrameKey == key, frameLayer === layer else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.contents = image + CATransaction.commit() + completion?() + } + + private func prefetch(_ descriptor: SpriteDescriptor, after localFrame: Int, count: Int) { + let pixelSize = targetPixelSize(for: descriptor) + for offset in 1...count { + let local: Int + if descriptor.loops { + local = (localFrame + offset) % descriptor.frameCount + } else { + local = min(descriptor.frameCount - 1, localFrame + offset) + } + guard let url = frameURL(descriptor.startFrame + local, descriptor: descriptor) else { continue } + FrameDecoder.shared.request(url, maxPixelSize: pixelSize, clientID: decoderClientID) + if !descriptor.loops && local == descriptor.frameCount - 1 { break } + } + } + + private func targetPixelSize(for descriptor: SpriteDescriptor) -> Int { + let frame = ViewportLayout.spriteFrame(descriptor, in: hostBounds(), sceneOffset: sceneOffset) + // Decode at the logical display size. The line-art assets remain crisp, + // while a 40-frame base-pose loop can stay resident in the bounded + // cache instead of being decompressed again on every repetition. + let longestSide = min(1920, max(frame.width, frame.height)) + // Quantization prevents a resize from generating many nearly identical + // cache variants while still decoding at the current display size. + return max(256, Int(ceil(longestSide / 128)) * 128) + } + + private func frameURL(_ number: Int, descriptor: SpriteDescriptor) -> URL? { + let key = "\(descriptor.baseName)#\(number)" + if let url = frameURLs[key] { return url } + guard let url = descriptor.frameURL(number) else { return nil } + frameURLs[key] = url + return url } private func configure(_ layer: CALayer, sprite: SpriteDescriptor) { layer.frame = ViewportLayout.spriteFrame(sprite, in: hostBounds(), sceneOffset: sceneOffset)