iosswiftcore-animationmetalmac-catalyst

How do I work around a Mac Catalyst framework bug where no Core Animation output is shown in an export session?


This is verified to be a framework bug (occurs on Mac Catalyst but not iOS or iPadOS), and it seems the culprit is AVVideoCompositionCoreAnimationTool?

/// Exports a video with the target animating.
    func exportVideo() {
        let destinationURL = createExportFileURL(from: Date())
        guard let videoURL = Bundle.main.url(forResource: "black_video", withExtension: "mp4") else {
            delegate?.exporterDidFailExporting(exporter: self)
            print("Can't find video")
            return
        }

        // Initialize the video asset
        let asset = AVURLAsset(url: videoURL, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true])
        guard let assetVideoTrack: AVAssetTrack = asset.tracks(withMediaType: AVMediaType.video).first,
              let assetAudioTrack: AVAssetTrack = asset.tracks(withMediaType: AVMediaType.audio).first else { return }
        let composition = AVMutableComposition()
        guard let videoCompTrack = composition.addMutableTrack(withMediaType: AVMediaType.video, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)),
              let audioCompTrack = composition.addMutableTrack(withMediaType: AVMediaType.audio, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) else { return }
        videoCompTrack.preferredTransform = assetVideoTrack.preferredTransform

        // Get the duration
        let videoDuration = asset.duration.seconds

        // Get the video rect
        let videoSize = assetVideoTrack.naturalSize.applying(assetVideoTrack.preferredTransform)
        let videoRect = CGRect(origin: .zero, size: videoSize)

        // Initialize the target layers and animations
        animationLayers = TargetView.initTargetViewAndAnimations(atPoint: CGPoint(x: videoRect.midX, y: videoRect.midY), atSecondsIntoVideo: 2, videoRect: videoRect)

        // Set the playback speed
        let duration = CMTime(seconds: videoDuration,
                              preferredTimescale: CMTimeScale(600))
        let appliedRange = CMTimeRange(start: .zero, end: duration)
        videoCompTrack.scaleTimeRange(appliedRange, toDuration: duration)
        audioCompTrack.scaleTimeRange(appliedRange, toDuration: duration)

        // Create the video layer.
        let videolayer = CALayer()
        videolayer.frame = CGRect(origin: .zero, size: videoSize)

        // Create the parent layer.
        let parentlayer = CALayer()
        parentlayer.frame = CGRect(origin: .zero, size: videoSize)
        parentlayer.addSublayer(videolayer)

        let times = timesForEvent(startTime: 0.1, endTime: duration.seconds - 0.01)
        let timeRangeForCurrentSlice = times.timeRange
        // Insert the relevant video track segment
        do {
            try videoCompTrack.insertTimeRange(timeRangeForCurrentSlice, of: assetVideoTrack, at: .zero)
            try audioCompTrack.insertTimeRange(timeRangeForCurrentSlice, of: assetAudioTrack, at: .zero)
        }
        catch let compError {
            print("TrimVideo: error during composition: \(compError)")
            delegate?.exporterDidFailExporting(exporter: self)
            return
        }

        // Add all the non-nil animation layers to be exported.
        for layer in animationLayers.compactMap({ $0 }) {
            parentlayer.addSublayer(layer)
        }

        // Configure the layer composition.
        let layerComposition = AVMutableVideoComposition()
        layerComposition.frameDuration = CMTimeMake(value: 1, timescale: 30)
        layerComposition.renderSize = videoSize
        layerComposition.animationTool = AVVideoCompositionCoreAnimationTool(
            postProcessingAsVideoLayer: videolayer,
            in: parentlayer)
        let instructions = initVideoCompositionInstructions(
            videoCompositionTrack: videoCompTrack, assetVideoTrack: assetVideoTrack)
        layerComposition.instructions = instructions

        // Creates the export session and exports the video asynchronously.
        guard let exportSession = initExportSession(
                composition: composition,
                destinationURL: destinationURL,
                layerComposition: layerComposition) else {
            delegate?.exporterDidFailExporting(exporter: self)
            return
        }
        // Execute the exporting
        exportSession.exportAsynchronously(completionHandler: {
            if let error = exportSession.error {
                print("Export error: \(error), \(error.localizedDescription)")
            }
            self.delegate?.exporterDidFinishExporting(exporter: self, with: destinationURL)
        })
    }

Not sure how to implement a custom compositor that performs the same animations as this reproducible case:

class AnimationCreator: NSObject {

    // MARK: - Target Animations

    /// Creates the target animations.
    static func addAnimationsToTargetView(_ targetView: TargetView, startTime: Double) {
        // Add the appearance animation
        AnimationCreator.addAppearanceAnimation(on: targetView, defaultBeginTime: AVCoreAnimationBeginTimeAtZero, startTime: startTime)
        // Add the pulse animation.
        AnimationCreator.addTargetPulseAnimation(on: targetView, defaultBeginTime: AVCoreAnimationBeginTimeAtZero, startTime: startTime)
        // Add the rotation animation
        AnimationCreator.addRotationAnimation(to: targetView, beginTime: AVCoreAnimationBeginTimeAtZero, startTime: startTime)
    }

    /// Adds the appearance animation to the target
    private static func addAppearanceAnimation(on targetView: TargetView, defaultBeginTime: Double = 0, startTime: Double = 0) {
        // Starts the target transparent and then turns it opaque at the specified time
        targetView.targetImageView.layer.opacity = 0
        let appear = CABasicAnimation(keyPath: "opacity")
        appear.duration = .greatestFiniteMagnitude // stay on screen forever
        appear.fromValue = 1.0 // Opaque
        appear.toValue = 1.0 // Opaque
        appear.beginTime = defaultBeginTime + startTime
        targetView.targetImageView.layer.add(appear, forKey: "appear")
    }

    /// Adds a pulsing animation to the target.
    private static func addTargetPulseAnimation(on targetView: TargetView, defaultBeginTime: Double = 0, startTime: Double = 0) {
        let targetPulse = CABasicAnimation(keyPath: "transform.scale")
        targetPulse.fromValue = 1 // Regular size
        targetPulse.toValue = 1.1 // Slightly larger size
        targetPulse.duration = 0.4
        targetPulse.beginTime = defaultBeginTime + startTime
        targetPulse.autoreverses = true
        targetPulse.repeatCount = .greatestFiniteMagnitude
        targetView.targetImageView.layer.add(targetPulse, forKey: "pulse_animation")
    }

    /// Adds a spinning animation to the target.
    private static func addRotationAnimation(to targetView: TargetView, beginTime: Double = 0, startTime: Double = 0) {
        let rotation: CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
        rotation.toValue = Double.pi * 2 // rotate in a complete circle
        rotation.duration = 1.0
        rotation.isCumulative = true
        rotation.repeatCount = .greatestFiniteMagnitude
        rotation.beginTime = beginTime
        targetView.targetImageView.layer.add(rotation, forKey: "rotation_animation")
    }
}

In a larger project, I attempted to change

    /// Creates the score change animation for a new point.
    func initScoreChangeStartAnimation(scoreViewLayer: CALayer, startTime: Double, duration: Double) {
        let scoreAnimation = CABasicAnimation(keyPath: "opacity")
        scoreAnimation.duration = duration - VideoHighlightReelOverlayUIConstants.pointChangeAnimationDuration
        scoreAnimation.fromValue = 1
        scoreAnimation.toValue = 1
        scoreAnimation.beginTime = startTime

        // Add the animation to the score view layer.
        scoreViewLayer.add(scoreAnimation, forKey: "scoreChangeStart")
    }

    /// Creates the score change animation for the score of the next point.
    func initScoreChangeEndAnimation(scoreViewLayer: CALayer, startTime: Double, duration: Double) {
        let scoreAnimation = CABasicAnimation(keyPath: "opacity")
        scoreAnimation.duration = VideoHighlightReelOverlayUIConstants.pointChangeAnimationDuration
        scoreAnimation.fromValue = 1
        scoreAnimation.toValue = 1
        scoreAnimation.beginTime = startTime + (duration - VideoHighlightReelOverlayUIConstants.pointChangeAnimationDuration)

        // Add the animation to the score view layer.
        scoreViewLayer.add(scoreAnimation, forKey: "scoreChangeEnd")
    }

to

    /// Creates the score change animation for a new point.
    func initScoreChangeStartAnimation(scoreViewLayer: CALayer, startTime: Double, duration: Double) {
        let scoreAnimation = CAKeyframeAnimation(keyPath: "opacity")
        let mainDuration = duration - VideoHighlightReelOverlayUIConstants.pointChangeAnimationDuration
        
        scoreAnimation.values = [1.0, 1.0]
        scoreAnimation.keyTimes = [0, NSNumber(value: mainDuration/duration)]
        scoreAnimation.duration = duration
        scoreAnimation.beginTime = startTime
        
        // Add the animation to the score view layer.
        scoreViewLayer.add(scoreAnimation, forKey: "scoreChangeStart")
    }

    /// Creates the score change animation for the score of the next point.
    func initScoreChangeEndAnimation(scoreViewLayer: CALayer, startTime: Double, duration: Double) {
        let scoreAnimation = CAKeyframeAnimation(keyPath: "opacity")
        let mainDuration = VideoHighlightReelOverlayUIConstants.pointChangeAnimationDuration
        
        scoreAnimation.values = [1.0, 1.0]
        scoreAnimation.keyTimes = [NSNumber(value: (duration - mainDuration)/duration), 1]
        scoreAnimation.duration = duration
        scoreAnimation.beginTime = startTime
        
        // Add the animation to the score view layer.
        scoreViewLayer.add(scoreAnimation, forKey: "scoreChangeEnd")
    }

but it doesn't appear in Mac Catalyst exports. I've also commented out other instances of CABasicAnimation.


Solution

  • I was not able to determine why CABasicAnimation doesn't work, but CAKeyframeAnimation does. Verified working code below. Please let me know if you have any questions.

    There is nothing you can do with CABasicAnimation that cannot be done with CAKeyframeAnimation so this enables a fully featured workaround, as you have requested. In fact, CAKeyframeAnimation is considerably more capable.

    import UIKit
    import AVFoundation
    
    class AnimationCreator: NSObject {
    
        // MARK: - Target Animations
    
        /// Creates the target animations.
        static func addAnimationsToTargetView(_ targetView: TargetView, startTime: Double) {
            // Add the appearance animation
            AnimationCreator.addAppearanceAnimation(on: targetView, defaultBeginTime: AVCoreAnimationBeginTimeAtZero, startTime: startTime)
            // Add the pulse animation.
            AnimationCreator.addTargetPulseAnimation(on: targetView, defaultBeginTime: AVCoreAnimationBeginTimeAtZero, startTime: startTime)
            // Add the rotation animation
            AnimationCreator.addRotationAnimation(to: targetView, beginTime: AVCoreAnimationBeginTimeAtZero, startTime: startTime)
        }
    
        /// Adds the appearance animation to the target
        private static func addAppearanceAnimation(on targetView: TargetView, defaultBeginTime: Double = 0, startTime: Double = 0) {
            // Starts the target transparent and then turns it opaque at the specified time
            targetView.targetImageView.layer.opacity = 1.0
    
            let animation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.opacity))
            animation.duration = 2.25
            animation.repeatCount = .zero
            animation.values = [
                CGFloat(0.0),
                CGFloat(1.0)
                ] as [CGFloat]
            animation.beginTime = AVCoreAnimationBeginTimeAtZero
            animation.isRemovedOnCompletion = false
            targetView.targetImageView.layer.add(animation, forKey: nil)
        }
    
        /// Adds a pulsing animation to the target.
        private static func addTargetPulseAnimation(on targetView: TargetView, defaultBeginTime: Double = 0, startTime: Double = 0) {
            let animation = CAKeyframeAnimation(keyPath: "transform.scale")
            animation.duration = 2.25
            animation.repeatCount = .infinity
            animation.values = [1.0, 1.1]
            animation.beginTime = AVCoreAnimationBeginTimeAtZero
            animation.isRemovedOnCompletion = false
            targetView.targetImageView.layer.add(animation, forKey: nil)
        }
    
        /// Adds a spinning animation to the target.
        private static func addRotationAnimation(to targetView: TargetView, beginTime: Double = 0, startTime: Double = 0) {
            let animation = CAKeyframeAnimation(keyPath: "transform.rotation")
            animation.duration = 1.0
            animation.repeatCount = .infinity
            animation.values = [0.0, Double.pi * 2]
            animation.beginTime = AVCoreAnimationBeginTimeAtZero
            animation.isRemovedOnCompletion = false
            targetView.targetImageView.layer.add(animation, forKey: nil)
        }
    }