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)