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
.
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)
}
}