├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── SwiftUIViewRecorder │ ├── Common │ ├── ViewFrame.swift │ ├── ViewRecordingError.swift │ ├── ViewRecordingSession.swift │ ├── ViewRecordingSessionViewModel.swift │ └── ViewToUIView.swift │ ├── FramesRenderer.swift │ ├── Image │ ├── ViewAsImage.swift │ └── ViewImageCapturingViewModel.swift │ ├── Video │ ├── UIImagesToVideo.swift │ ├── UIImagesToVideoError.swift │ ├── VideoRenderer.swift │ └── ViewAsVideo.swift │ └── ViewAssetRecordingSession.swift └── Tests ├── LinuxMain.swift └── SwiftUIViewRecorderTests ├── AssetGenerationError.swift ├── FramesRendererMock.swift ├── TestImageData.swift ├── UIImagesToVideoTest.swift ├── ViewRecordingSessionTest.swift ├── ViewRecordingSessionViewModelTest.swift └── XCTestManifests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ilya Frolov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "UIImageExtensions", 6 | "repositoryURL": "https://github.com/frolovilya/UIImageExtensions.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "21c0dea5f6bdd0dd2ab40488b2b5da302c842838", 10 | "version": "0.2.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftUIViewRecorder", 8 | platforms: [ 9 | .iOS(.v13) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 13 | .library( 14 | name: "SwiftUIViewRecorder", 15 | targets: ["SwiftUIViewRecorder"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | .package(url: "https://github.com/frolovilya/UIImageExtensions.git", .upToNextMinor(from: "0.2.0")), 20 | //.package(path: "UIImageExtensions") 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 25 | .target( 26 | name: "SwiftUIViewRecorder", 27 | dependencies: ["UIImageExtensions"]), 28 | .testTarget( 29 | name: "SwiftUIViewRecorderTests", 30 | dependencies: ["SwiftUIViewRecorder"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUIViewRecorder 2 | 3 | Package to efficiently record any SwiftUI `View` as image or video. 4 | 5 | * [Requirements](#requirements) 6 | * [Installation](#installation) 7 | * [Usage](#usage) 8 | * [Record view as video](#viewAsVideo) 9 | * [Record view as image](#viewAsImage) 10 | * [Custom view recording](#viewAsCustomAsset) 11 | 12 | 13 | 14 | ## Requirements 15 | * iOS 13.0 16 | * Swift 5.2 17 | 18 | 19 | 20 | ## Installation 21 | Use Xcode's built-in Swift Package Manager: 22 | 23 | * Open Xcode 24 | * Click File -> Swift Packages -> Add Package Dependency 25 | * Paste package repository https://github.com/frolovilya/SwiftUIViewRecorder.git and press return 26 | * Import module to any file using `import SwiftUIViewRecorder` 27 | 28 | 29 | 30 | ## Usage 31 | 32 | 33 | 34 | ### Record `View` animation as a *.mov* QuickTime video 35 | 36 | ```swift 37 | func recordVideo(duration: Double? = nil, 38 | framesPerSecond: Double = 24, 39 | useSnapshots: Bool = false) throws -> ViewRecordingSession 40 | ``` 41 | 42 | Note that each SwiftUI `View` is a **struct**, thus it's copied on every assignment. 43 | Video capturing happens off-screen on a view's copy and intended for animation to video conversion rather than live screen recording. 44 | 45 | Recording performance is much better when setting `useSnapshots` to `true`. In this case view frame capturing and image rendering phases are separated, but the feature is only available on a simulator due to security limitations. 46 | Use snapshotting when you need to record high-FPS animation on a simulator to render it as a video. 47 | 48 | You could use provided `ViewRecordingSessionViewModel` or write your own recording session view model to handle recording progress. 49 | 50 | ```swift 51 | import SwiftUI 52 | import SwiftUIViewRecorder 53 | 54 | struct MyViewWithAnimation: View { 55 | 56 | // observe changes using built-in recording view model 57 | @ObservedObject var recordingViewModel: ViewRecordingSessionViewModel 58 | 59 | private var viewToRecord: some View { 60 | // some view with animation which we'd like to record as a video 61 | } 62 | 63 | var body: some View { 64 | ZStack { 65 | if (recordingViewModel.asset != nil) { 66 | Text("Video URL \(recordingViewModel.asset!)") 67 | } else { 68 | Text("Recording video...") 69 | } 70 | } 71 | .onAppear { 72 | recordingViewModel.handleRecording(session: try! viewToRecord.recordVideo()) 73 | } 74 | } 75 | 76 | } 77 | ``` 78 | 79 | 80 | 81 | ### Snapshot `View` as `UIImage` 82 | 83 | ```swift 84 | func asImage() -> Future 85 | ``` 86 | 87 | Simply call `asImage()` on any `View` to capture it's current state as an image. 88 | Since image is returned as a `Future` value, view model must be used to handle the result. 89 | You could use provided `ViewImageCapturingViewModel` or write your own. 90 | 91 | ```swift 92 | import SwiftUI 93 | import SwiftUIViewRecorder 94 | 95 | struct ContentView: View { 96 | 97 | // observe image generation using built-in recording view model 98 | @ObservedObject var imageCapturingViewModel: ViewImageCapturingViewModel 99 | 100 | var viewToImage: some View { 101 | Circle() 102 | .fill(Color.yellow) 103 | .frame(width: 50, height: 50) 104 | .asImage() 105 | } 106 | 107 | var body: some View { 108 | ZStack { 109 | if (imageCapturingViewModel.image != nil) { 110 | Image(uiImage: imageCapturingViewModel.image!) 111 | .resizable() 112 | .scaledToFit() 113 | .frame(width: 200, height: 200) 114 | } 115 | } 116 | .onAppear { 117 | imageCapturingViewModel.captureImage(view: viewToImage) 118 | } 119 | } 120 | 121 | } 122 | ``` 123 | 124 | 125 | 126 | ### Record view with a custom frames renderer 127 | 128 | It's possible to impement and use your own `FramesRenderer` to convert array of `UIImage` frames to some asset. 129 | 130 | First, implement `FramesRenderer` protocol: 131 | 132 | ```swift 133 | class CustomRenderer: FramesRenderer { 134 | func render(frames: [UIImage], framesPerSecond: Double) -> Future { 135 | // process frames to a CustomAsset 136 | } 137 | } 138 | ``` 139 | Second, init `ViewRecordingSession` with your custom renderer: 140 | 141 | ```swift 142 | extension SwiftUI.View { 143 | public func toCustomAsset(duration: Double? = nil, 144 | framesPerSecond: Double = 24) throws -> ViewRecordingSession { 145 | try ViewRecordingSession(view: self, 146 | framesRenderer: CustomRenderer(), 147 | duration: duration, 148 | framesPerSecond: framesPerSecond) 149 | } 150 | } 151 | ``` 152 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Common/ViewFrame.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Represents either `UIImage` already rendered view frame or `UIView` view snapshot for delayed rendering 4 | struct ViewFrame { 5 | private let image: UIImage? 6 | private let snapshot: UIView? 7 | 8 | init(image: UIImage) { 9 | self.image = image 10 | self.snapshot = nil 11 | } 12 | 13 | init(snapshot: UIView) { 14 | self.image = nil 15 | self.snapshot = snapshot 16 | } 17 | 18 | func render() -> UIImage { 19 | if image != nil { 20 | return image! 21 | } else { 22 | return snapshot!.asImage(afterScreenUpdates: true) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Common/ViewRecordingError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Errors which may occur during view recording session 4 | public enum ViewRecordingError: Error, Equatable { 5 | case illegalDuration 6 | case illegalFramesPerSecond 7 | case renderingError(reason: String? = nil) 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Common/ViewRecordingSession.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | /** 5 | Session handler to manage recording process and receive resulting `Asset`. 6 | 7 | Session handler can not be reused once stopped. Start new recording session with a new handler instance. 8 | */ 9 | public class ViewRecordingSession: ViewAssetRecordingSession { 10 | 11 | private let view: AnyView 12 | private let framesRenderer: ([UIImage]) -> Future 13 | 14 | private let useSnapshots: Bool 15 | private let duration: Double? 16 | private let framesPerSecond: Double 17 | 18 | private var isRecording: Bool = true 19 | private var frames: [ViewFrame] = [] 20 | 21 | private let resultSubject: PassthroughSubject = PassthroughSubject() 22 | private var assetGenerationCancellable: AnyCancellable? = nil 23 | 24 | /** 25 | Initialize new view recording session. 26 | 27 | Note that each SwiftUI view is a _struct_, thus it's copied on every assignment. 28 | Video capturing happens off-screen on a view's copy and intended for animation to video conversion rather than live screen recording. 29 | 30 | Recording performance is much better when setting `useSnapshots` to `true`. 31 | But this feature is only available on a simulator due to security limitations. 32 | Use snapshotting when you need to record high-FPS animation on a simulator to render it as a video. 33 | 34 | - Precondition: `duration` must be either `nil` or greater than 0. 35 | - Precondition: `framesPerSecond` must be greater than 0. 36 | - Precondition: `useSnapshots` isn't available on a real iOS device. 37 | 38 | - Parameter view: some SwiftUI `View` to record 39 | - Parameter framesRenderer: some `FramesRenderer` implementation to render captured frames to resulting `Asset` 40 | - Parameter useSnapshots: significantly improves recording performance, but doesn't work on a real iOS device due to privacy limitations 41 | - Parameter duration: optional fixed recording duration time in seconds. If `nil`, then need to call `stopRecording()` method to stop recording session. 42 | - Parameter framesPerSecond: number of frames to capture per second 43 | 44 | - Throws: `ViewRecordingError` if preconditions aren't met 45 | */ 46 | public init(view: V, 47 | framesRenderer: Renderer, 48 | useSnapshots: Bool = false, 49 | duration: Double? = nil, 50 | framesPerSecond: Double) throws where Renderer.Asset == Asset { 51 | guard duration == nil || duration! > 0 52 | else { throw ViewRecordingError.illegalDuration } 53 | guard framesPerSecond > 0 54 | else { throw ViewRecordingError.illegalFramesPerSecond } 55 | 56 | self.view = AnyView(view) 57 | self.duration = duration 58 | self.framesPerSecond = framesPerSecond 59 | self.useSnapshots = useSnapshots 60 | 61 | self.framesRenderer = { images in 62 | framesRenderer.render(frames: images, framesPerSecond: framesPerSecond) 63 | } 64 | 65 | recordView() 66 | } 67 | 68 | /// Subscribe to receive generated `Asset` or generation `ViewRecordingError` 69 | public var resultPublisher: AnyPublisher { 70 | resultSubject 71 | .receive(on: DispatchQueue.main) 72 | .eraseToAnyPublisher() 73 | } 74 | 75 | /// Stop current recording session and start `Asset` generation 76 | public func stopRecording() -> Void { 77 | guard isRecording else { return } 78 | 79 | print("Stop recording") 80 | isRecording = false 81 | generateAsset() 82 | } 83 | 84 | private var fixedFramesCount: Int? { 85 | duration != nil ? Int(duration! * framesPerSecond) : nil 86 | } 87 | 88 | private var allFramesCaptured: Bool { 89 | fixedFramesCount != nil && frames.count >= fixedFramesCount! 90 | } 91 | 92 | private var description: String { 93 | (duration != nil ? "\(duration!) seconds," : "") 94 | + (fixedFramesCount != nil ? " \(fixedFramesCount!) frames," : "") 95 | + " \(framesPerSecond) fps" 96 | } 97 | 98 | private func recordView() -> Void { 99 | print("Start recording \(description)") 100 | 101 | let uiView = view.placeUIView() 102 | 103 | Timer.scheduledTimer(withTimeInterval: 1 / framesPerSecond, repeats: true) { timer in 104 | if (!self.isRecording) { 105 | timer.invalidate() 106 | uiView.removeFromSuperview() 107 | } else { 108 | if self.useSnapshots, let snapshotView = uiView.snapshotView(afterScreenUpdates: false) { 109 | self.frames.append(ViewFrame(snapshot: snapshotView)) 110 | } else { 111 | self.frames.append(ViewFrame(image: uiView.asImage(afterScreenUpdates: false))) 112 | } 113 | 114 | if (self.allFramesCaptured) { 115 | self.stopRecording() 116 | } 117 | } 118 | } 119 | } 120 | 121 | private func generateAsset() -> Void { 122 | assetGenerationCancellable?.cancel() 123 | 124 | let frameImages = frames.map { $0.render() } 125 | print("Rendered \(frameImages.count) frames") 126 | 127 | assetGenerationCancellable = framesRenderer(frameImages) 128 | .mapError { error in ViewRecordingError.renderingError(reason: error.localizedDescription) } 129 | .subscribe(on: DispatchQueue.global(qos: .userInitiated)) 130 | .subscribe(resultSubject) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Common/ViewRecordingSessionViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | /// `View` recording session `ViewModel` 5 | public class ViewRecordingSessionViewModel: ObservableObject { 6 | 7 | /// Resulting `Asset` 8 | @Published public var asset: Asset? 9 | 10 | /// Error during recording session 11 | @Published public var error: ViewRecordingError? 12 | 13 | private var session: ViewRecordingSession? 14 | 15 | private var sessionResultCancellable: AnyCancellable? 16 | private var imageCancellable: AnyCancellable? 17 | 18 | public init() {} 19 | 20 | /** 21 | Track recording session. 22 | 23 | Subscribes to a session's `resultPublisher` and updates `@Published` `asset` and `error` `ViewModel`'s parameters. 24 | 25 | - Parameter session: `View` recording session handler to track 26 | */ 27 | public func handleRecording(session: ViewRecordingSession) -> Void { 28 | self.session = session 29 | 30 | sessionResultCancellable = session.resultPublisher 31 | .sink(receiveCompletion: { [weak self] completion in 32 | switch completion { 33 | case .failure(let error): 34 | self?.error = error 35 | break 36 | default: 37 | break 38 | } 39 | }, receiveValue: { [weak self] value in 40 | self?.asset = value 41 | }) 42 | } 43 | 44 | /// Stop currently tracked recording session 45 | public func stopRecording() { 46 | self.session?.stopRecording() 47 | } 48 | 49 | public func captureImage(view: V) where Asset == UIImage { 50 | imageCancellable = view.asImage().sink { image in 51 | self.asset = image 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Common/ViewToUIView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | 4 | extension SwiftUI.View { 5 | 6 | private var appWindow: UIWindow { 7 | if let window = UIApplication.shared.windows.first { 8 | return window 9 | } else { 10 | let window = UIWindow(frame: UIScreen.main.bounds) 11 | window.rootViewController = UIViewController() 12 | window.makeKeyAndVisible() 13 | return window 14 | } 15 | } 16 | 17 | func placeUIView() -> UIView { 18 | let controller = UIHostingController(rootView: self) 19 | let uiView = controller.view! 20 | 21 | // out of screen 22 | uiView.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1) 23 | appWindow.rootViewController?.view.addSubview(uiView) 24 | 25 | let size = controller.sizeThatFits(in: UIScreen.main.bounds.size) 26 | uiView.bounds = CGRect(origin: .zero, size: size) 27 | uiView.sizeToFit() 28 | 29 | return uiView 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/FramesRenderer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import Combine 4 | 5 | /// Abstract `UIImage` frames renderer 6 | public protocol FramesRenderer { 7 | associatedtype Asset 8 | 9 | /** 10 | Render `UIImage` collection as some `Asset` 11 | 12 | - Parameter frames: list of `UIImage` frames to use for conversion 13 | - Parameter framesPerSecond: number of `UIImage` to be rendered per second 14 | - Returns: eventually returns generated `Asset` or `Error` 15 | */ 16 | func render(frames: [UIImage], framesPerSecond: Double) -> Future 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Image/ViewAsImage.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import Combine 4 | 5 | extension UIView { 6 | /** 7 | Convert UIKit `UIView` to `UIImage` 8 | 9 | - Parameter afterScreenUpdates: A `Bool` value that indicates whether the snapshot should be rendered after recent changes have been incorporated. Specify the value `false` if you want to render a snapshot in the view hierarchy’s current state, which might not include recent changes. 10 | 11 | - Returns: view's `UIImage` presentation 12 | */ 13 | public func asImage(afterScreenUpdates: Bool = true) -> UIImage { 14 | let format = UIGraphicsImageRendererFormat.default() 15 | format.opaque = true 16 | format.scale = UIScreen.main.scale 17 | 18 | let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format) 19 | return renderer.image { rendererContext in 20 | drawHierarchy(in: bounds, afterScreenUpdates: afterScreenUpdates) 21 | } 22 | } 23 | } 24 | 25 | extension SwiftUI.View { 26 | /** 27 | Convert SwiftUI `View` to `UIImage` 28 | 29 | - Returns: future view's `UIImage` presentation 30 | */ 31 | public func asImage() -> Future { 32 | Future() { promise in 33 | let uiView = self.placeUIView() 34 | 35 | DispatchQueue.main.async { 36 | let image = uiView.asImage() 37 | uiView.removeFromSuperview() 38 | 39 | promise(.success(image)) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Image/ViewImageCapturingViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | /// `View` image capturing `ViewModel` 5 | public class ViewImageCapturingViewModel: ObservableObject { 6 | 7 | /// Generated `View` image representation 8 | @Published public var image: UIImage? 9 | private var imageCancellable: AnyCancellable? 10 | 11 | public init() {} 12 | 13 | /** 14 | Capture `View` as `UIImage`. 15 | 16 | Result will be published in the `image` view model's property. 17 | */ 18 | public func captureImage(view: V) { 19 | imageCancellable = view.asImage().sink { uiImage in 20 | self.image = uiImage 21 | } 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Video/UIImagesToVideo.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import UIKit 3 | import Combine 4 | import CoreMedia 5 | 6 | extension Array where Element == UIImage { 7 | 8 | private func makeUniqueTempVideoURL() -> URL { 9 | let tempDir = FileManager.default.temporaryDirectory 10 | let fileName = ProcessInfo.processInfo.globallyUniqueString 11 | return tempDir.appendingPathComponent(fileName).appendingPathExtension("mov") 12 | } 13 | 14 | private var frameSize: CGSize { 15 | CGSize(width: (first?.size.width ?? 0) * UIScreen.main.scale, 16 | height: (first?.size.height ?? 0) * UIScreen.main.scale) 17 | } 18 | 19 | private func videoSettings(codecType: AVVideoCodecType) -> [String: Any] { 20 | return [ 21 | AVVideoCodecKey: codecType, 22 | AVVideoWidthKey: frameSize.width, 23 | AVVideoHeightKey: frameSize.height 24 | ] 25 | } 26 | 27 | private var pixelAdaptorAttributes: [String: Any] { 28 | [ 29 | kCVPixelBufferPixelFormatTypeKey as String : Int(kCMPixelFormat_32BGRA) 30 | ] 31 | } 32 | 33 | /** 34 | Convert array of `UIImage`s to QuickTime video. 35 | 36 | This method runs on a Main queue by default. 37 | Video generation is a time consuming process, subscribe on a different background queue for better performance. 38 | 39 | Video file is generated in a temporary directory. It's a calling code responsibility to unlink the file once not needed. 40 | 41 | - Precondition: `framesPerSecond` must be greater than 0 42 | 43 | - Parameter framesPerSecond: video FPS. How many samples are presented per second. 44 | - Parameter codecType: video codec to use. By default is H264. See `AVVideoCodecType` for other available options. 45 | 46 | - Returns: Future URL of a generated video file or Error 47 | */ 48 | func toVideo(framesPerSecond: Double, 49 | codecType: AVVideoCodecType = .h264) -> Future { 50 | print("Generating video framesPerSecond=\(framesPerSecond), codecType=\(codecType.rawValue)") 51 | 52 | return Future { promise in 53 | guard self.count > 0 else { 54 | promise(.failure(UIImagesToVideoError.noFrames)) 55 | return 56 | } 57 | 58 | guard framesPerSecond > 0 else { 59 | promise(.failure(UIImagesToVideoError.invalidFramesPerSecond)) 60 | return 61 | } 62 | 63 | let url = self.makeUniqueTempVideoURL() 64 | 65 | let writer: AVAssetWriter 66 | do { 67 | writer = try AVAssetWriter(outputURL: url, fileType: .mov) 68 | } catch { 69 | promise(.failure(error)) 70 | return 71 | } 72 | 73 | let input = AVAssetWriterInput(mediaType: .video, 74 | outputSettings: self.videoSettings(codecType: codecType)) 75 | 76 | if (writer.canAdd(input)) { 77 | writer.add(input) 78 | } else { 79 | promise(.failure(UIImagesToVideoError.internalError)) 80 | return 81 | } 82 | 83 | let pixelAdaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: input, 84 | sourcePixelBufferAttributes: pixelAdaptorAttributes) 85 | 86 | writer.startWriting() 87 | writer.startSession(atSourceTime: CMTime.zero) 88 | 89 | var frameIndex: Int = 0 90 | while frameIndex < self.count { 91 | if (input.isReadyForMoreMediaData) { 92 | if let buffer = self[frameIndex].toSampleBuffer(frameIndex: frameIndex, 93 | framesPerSecond: framesPerSecond) { 94 | pixelAdaptor.append(CMSampleBufferGetImageBuffer(buffer)!, 95 | withPresentationTime: CMSampleBufferGetOutputPresentationTimeStamp(buffer)) 96 | } 97 | 98 | frameIndex += 1 99 | } 100 | } 101 | 102 | writer.finishWriting { 103 | switch writer.status { 104 | case .completed: 105 | print("Successfully finished writing video \(url)") 106 | promise(.success(url)) 107 | break 108 | default: 109 | let error = writer.error ?? UIImagesToVideoError.internalError 110 | print("Finished writing video without success \(error)") 111 | promise(.failure(error)) 112 | } 113 | } 114 | } 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Video/UIImagesToVideoError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum UIImagesToVideoError: Error { 4 | case noFrames 5 | case invalidFramesPerSecond 6 | case internalError 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Video/VideoRenderer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import UIImageExtensions 3 | import Combine 4 | 5 | class VideoRenderer: FramesRenderer { 6 | func render(frames: [UIImage], framesPerSecond: Double) -> Future { 7 | frames.toVideo(framesPerSecond: framesPerSecond) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/Video/ViewAsVideo.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | extension SwiftUI.View { 5 | /** 6 | Record `View` animation as video 7 | 8 | - Parameter duration: fixed recording duration in seconds. 9 | If `nil`, then call `stopRecording()` on a `ViewRecordingSession` returned by this method to stop recording. 10 | - Parameter framesPerSecond: number of frames to take per second. By default is 24. 11 | - Parameter useSnapshots: separate capturing and rendering phases. Works on a simulator only. See `ViewRecordingSession` docs for details. 12 | 13 | - Returns: `ViewRecordingSession` recording handler to control recording process. 14 | */ 15 | public func recordVideo(duration: Double? = nil, 16 | framesPerSecond: Double = 24, 17 | useSnapshots: Bool = false) throws -> ViewRecordingSession { 18 | try ViewRecordingSession(view: self, 19 | framesRenderer: VideoRenderer(), 20 | useSnapshots: useSnapshots, 21 | duration: duration, 22 | framesPerSecond: framesPerSecond) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SwiftUIViewRecorder/ViewAssetRecordingSession.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | /// Abstract recording session handler 5 | public protocol ViewAssetRecordingSession { 6 | associatedtype Asset 7 | 8 | /// Subscribe to receive `Asset` or `ViewRecordingError` once recording is finished 9 | var resultPublisher: AnyPublisher { get } 10 | 11 | /// Stop current recording session and start `Asset` generation 12 | func stopRecording() -> Void 13 | } 14 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SwiftUIViewRecorderTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += ViewRecordingSessionTest.allTests() 7 | tests += ViewRecordingSessionViewModelTest.allTests() 8 | tests += UIImagesToVideoTest.allTests() 9 | XCTMain(tests) 10 | -------------------------------------------------------------------------------- /Tests/SwiftUIViewRecorderTests/AssetGenerationError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct AssetGenerationError: Error, LocalizedError { 4 | var errorDescription: String? 5 | } 6 | -------------------------------------------------------------------------------- /Tests/SwiftUIViewRecorderTests/FramesRendererMock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import UIKit 4 | @testable import SwiftUIViewRecorder 5 | 6 | class FramesRendererMock: FramesRenderer { 7 | 8 | var result: String? = "someGeneratedAsset" 9 | var error: Error? = nil 10 | 11 | var capturedFrames: [UIImage] = [] 12 | 13 | func render(frames: [UIImage], framesPerSecond: Double) -> Future { 14 | print("Start rendering \(frames.count) frames") 15 | 16 | capturedFrames = frames 17 | 18 | return Future() { promise in 19 | if (self.error == nil) { 20 | print("Successfully finished rendering frames") 21 | promise(.success(self.result)) 22 | } else { 23 | print("Finished rendering frames with error \(self.error!)") 24 | promise(.failure(self.error!)) 25 | } 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftUIViewRecorderTests/TestImageData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class TestImageData { 4 | 5 | static let tennisBall: String = "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" 6 | 7 | } 8 | -------------------------------------------------------------------------------- /Tests/SwiftUIViewRecorderTests/UIImagesToVideoTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import UIKit 3 | import Combine 4 | @testable import SwiftUIViewRecorder 5 | 6 | final class UIImagesToVideoTest: XCTestCase { 7 | 8 | private var cancellables: [AnyCancellable] = [] 9 | 10 | private func createExpectation(description: String, isInverted: Bool = false) -> XCTestExpectation { 11 | let someExpectation = expectation(description: description) 12 | someExpectation.isInverted = isInverted 13 | someExpectation.expectedFulfillmentCount = 1 14 | 15 | return someExpectation 16 | } 17 | 18 | func testNoFrames() { 19 | let emptyArray: [UIImage] = [] 20 | 21 | let videoGenerated = createExpectation(description: "received video URL", isInverted: true) 22 | let errorReceived = createExpectation(description: "received video generation error") 23 | let finishedReceived = createExpectation(description: "publisher finished", isInverted: true) 24 | 25 | cancellables.append(emptyArray.toVideo(framesPerSecond: 24) 26 | .sink(receiveCompletion: { completion in 27 | switch completion { 28 | case .failure(let error): 29 | if (error as? UIImagesToVideoError == UIImagesToVideoError.noFrames) { 30 | errorReceived.fulfill() 31 | } 32 | break 33 | case .finished: 34 | finishedReceived.fulfill() 35 | break 36 | } 37 | }, receiveValue: { value in 38 | videoGenerated.fulfill() 39 | })) 40 | 41 | wait(for: [videoGenerated, errorReceived, finishedReceived], timeout: 1) 42 | } 43 | 44 | func testIncorrectFPS(framesPerSecond: Double) { 45 | let imagesArray: [UIImage] = [UIImage.fromBase64String(TestImageData.tennisBall)!] 46 | 47 | let videoGenerated = createExpectation(description: "received video URL", isInverted: true) 48 | let errorReceived = createExpectation(description: "received video generation error") 49 | let finishedReceived = createExpectation(description: "publisher finished", isInverted: true) 50 | 51 | cancellables.append(imagesArray.toVideo(framesPerSecond: framesPerSecond) 52 | .sink(receiveCompletion: { completion in 53 | switch completion { 54 | case .failure(let error): 55 | if (error as? UIImagesToVideoError == UIImagesToVideoError.invalidFramesPerSecond) { 56 | errorReceived.fulfill() 57 | } 58 | break 59 | case .finished: 60 | finishedReceived.fulfill() 61 | break 62 | } 63 | }, receiveValue: { value in 64 | videoGenerated.fulfill() 65 | })) 66 | 67 | wait(for: [videoGenerated, errorReceived, finishedReceived], timeout: 1) 68 | } 69 | 70 | func testIncorrectFPS() { 71 | testIncorrectFPS(framesPerSecond: 0) 72 | testIncorrectFPS(framesPerSecond: -1) 73 | } 74 | 75 | func testVideoGenerated() { 76 | let imagesArray: [UIImage] = [UIImage.fromBase64String(TestImageData.tennisBall)!] 77 | 78 | let videoGenerated = createExpectation(description: "received video URL") 79 | let errorReceived = createExpectation(description: "received video generation error", isInverted: true) 80 | let finishedReceived = createExpectation(description: "publisher finished") 81 | 82 | cancellables.append(imagesArray.toVideo(framesPerSecond: 24) 83 | .sink(receiveCompletion: { completion in 84 | switch completion { 85 | case .failure: 86 | errorReceived.fulfill() 87 | break 88 | case .finished: 89 | finishedReceived.fulfill() 90 | break 91 | } 92 | }, receiveValue: { value in 93 | if value != nil { 94 | self.removeGeneratedAsset(url: value!) 95 | videoGenerated.fulfill() 96 | } 97 | })) 98 | 99 | wait(for: [videoGenerated, errorReceived, finishedReceived], timeout: 1) 100 | } 101 | 102 | private func removeGeneratedAsset(url: URL) -> Void { 103 | do { 104 | try FileManager.default.removeItem(at: url) 105 | } catch(let e) { 106 | print(e) 107 | } 108 | } 109 | 110 | static var allTests = [ 111 | ("testNoFrames", testNoFrames), 112 | ("testIncorrectFPS", testIncorrectFPS), 113 | ("testVideoGenerated", testVideoGenerated) 114 | ] 115 | 116 | } 117 | -------------------------------------------------------------------------------- /Tests/SwiftUIViewRecorderTests/ViewRecordingSessionTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Combine 4 | @testable import SwiftUIViewRecorder 5 | 6 | final class ViewRecordingSessionTest: XCTestCase { 7 | 8 | private var cancellables: [AnyCancellable] = [] 9 | 10 | private var testView: some View { 11 | Circle() 12 | .fill(Color.yellow) 13 | .frame(width: 50, height: 50) 14 | } 15 | 16 | func testIncorrectInitialization(duration: Double?, fps: Double, expectedError: ViewRecordingError) { 17 | var exception: Error? 18 | 19 | XCTAssertThrowsError(try ViewRecordingSession(view: testView, 20 | framesRenderer: VideoRenderer(), 21 | duration: duration, 22 | framesPerSecond: fps)) { error in 23 | exception = error 24 | } 25 | 26 | XCTAssertEqual(exception as? ViewRecordingError, expectedError) 27 | } 28 | 29 | func testIncorrectDuration() { 30 | testIncorrectInitialization(duration: 0, fps: 24, expectedError: .illegalDuration) 31 | testIncorrectInitialization(duration: -1, fps: 24, expectedError: .illegalDuration) 32 | } 33 | 34 | func testIncorrectFramesPerSecond() throws { 35 | testIncorrectInitialization(duration: nil, fps: 0, expectedError: .illegalFramesPerSecond) 36 | testIncorrectInitialization(duration: 100, fps: -24, expectedError: .illegalFramesPerSecond) 37 | } 38 | 39 | func testAssetGenerationError() { 40 | let framesRenderer = FramesRendererMock() 41 | framesRenderer.error = AssetGenerationError(errorDescription: "some error reason") 42 | 43 | let session = try? ViewRecordingSession(view: testView, 44 | framesRenderer: framesRenderer, 45 | duration: nil, 46 | framesPerSecond: 24) 47 | XCTAssertNotNil(session) 48 | 49 | let assetGenerated = expectation(description: "asset generated") 50 | assetGenerated.expectedFulfillmentCount = 1 51 | assetGenerated.isInverted = true 52 | 53 | let errorReceived = expectation(description: "error received") 54 | errorReceived.expectedFulfillmentCount = 1 55 | 56 | cancellables.append(session!.resultPublisher.sink(receiveCompletion: { completion in 57 | switch completion { 58 | case .finished: 59 | print("Finished") 60 | break 61 | case .failure(let e): 62 | print("Error \(e)") 63 | XCTAssertEqual(e, ViewRecordingError.renderingError(reason: "some error reason")) 64 | errorReceived.fulfill() 65 | break 66 | } 67 | }, receiveValue: { value in 68 | print("Value \(String(describing: value))") 69 | if (value != nil) { 70 | assetGenerated.fulfill() 71 | } 72 | })) 73 | 74 | session?.stopRecording() 75 | 76 | wait(for: [assetGenerated, errorReceived], timeout: 1) 77 | } 78 | 79 | func testSuccessAssetGeneration() { 80 | let framesRenderer = FramesRendererMock() 81 | 82 | let session = try? ViewRecordingSession(view: testView, 83 | framesRenderer: framesRenderer, 84 | duration: nil, 85 | framesPerSecond: 24) 86 | XCTAssertNotNil(session) 87 | 88 | let assetGenerated = expectation(description: "asset generated") 89 | assetGenerated.expectedFulfillmentCount = 1 90 | 91 | let errorReceived = expectation(description: "error received") 92 | errorReceived.expectedFulfillmentCount = 1 93 | errorReceived.isInverted = true 94 | 95 | cancellables.append(session!.resultPublisher.sink(receiveCompletion: { completion in 96 | switch completion { 97 | case .finished: 98 | print("Finished") 99 | break 100 | case .failure(let e): 101 | print("Error \(e)") 102 | errorReceived.fulfill() 103 | break 104 | } 105 | }, receiveValue: { value in 106 | print("Value \(String(describing: value))") 107 | if (value != nil) { 108 | assetGenerated.fulfill() 109 | } 110 | })) 111 | 112 | session?.stopRecording() 113 | 114 | wait(for: [assetGenerated, errorReceived], timeout: 1) 115 | } 116 | 117 | func testNoAssetGeneratedWithoutCallingStop() { 118 | let framesRenderer = FramesRendererMock() 119 | 120 | let session = try? ViewRecordingSession(view: testView, 121 | framesRenderer: framesRenderer, 122 | duration: nil, 123 | framesPerSecond: 24) 124 | XCTAssertNotNil(session) 125 | 126 | let assetGenerated = expectation(description: "asset generated") 127 | assetGenerated.expectedFulfillmentCount = 1 128 | assetGenerated.isInverted = true 129 | 130 | let errorReceived = expectation(description: "error received") 131 | errorReceived.expectedFulfillmentCount = 1 132 | errorReceived.isInverted = true 133 | 134 | cancellables.append(session!.resultPublisher.sink(receiveCompletion: { completion in 135 | switch completion { 136 | case .finished: 137 | print("Finished") 138 | break 139 | case .failure(let e): 140 | print("Error \(e)") 141 | errorReceived.fulfill() 142 | break 143 | } 144 | }, receiveValue: { value in 145 | print("Value \(String(describing: value))") 146 | if (value != nil) { 147 | assetGenerated.fulfill() 148 | } 149 | })) 150 | 151 | wait(for: [assetGenerated, errorReceived], timeout: 1) 152 | } 153 | 154 | func testAssetGenerationWithFixedDuration() { 155 | let framesRenderer = FramesRendererMock() 156 | 157 | let session = try? ViewRecordingSession(view: testView, 158 | framesRenderer: framesRenderer, 159 | duration: 1, 160 | framesPerSecond: 24) 161 | XCTAssertNotNil(session) 162 | 163 | let assetGenerated = expectation(description: "asset generated") 164 | assetGenerated.expectedFulfillmentCount = 1 165 | 166 | let errorReceived = expectation(description: "error received") 167 | errorReceived.expectedFulfillmentCount = 1 168 | errorReceived.isInverted = true 169 | 170 | cancellables.append(session!.resultPublisher.sink(receiveCompletion: { completion in 171 | switch completion { 172 | case .finished: 173 | print("Finished") 174 | XCTAssertEqual(24, framesRenderer.capturedFrames.count) 175 | break 176 | case .failure(let e): 177 | print("Error \(e)") 178 | errorReceived.fulfill() 179 | break 180 | } 181 | }, receiveValue: { value in 182 | print("Value \(String(describing: value))") 183 | if (value != nil) { 184 | assetGenerated.fulfill() 185 | } 186 | })) 187 | 188 | wait(for: [assetGenerated, errorReceived], timeout: 2) 189 | } 190 | 191 | static var allTests = [ 192 | ("testIncorrectDuration", testIncorrectDuration), 193 | ("testIncorrectFramesPerSecond", testIncorrectFramesPerSecond), 194 | ("testAssetGenerationError", testAssetGenerationError), 195 | ("testSuccessAssetGeneration", testSuccessAssetGeneration), 196 | ("testNoAssetGeneratedWithoutCallingStop", testNoAssetGeneratedWithoutCallingStop), 197 | ("testAssetGenerationWithFixedDuration", testAssetGenerationWithFixedDuration), 198 | ] 199 | } 200 | -------------------------------------------------------------------------------- /Tests/SwiftUIViewRecorderTests/ViewRecordingSessionViewModelTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Combine 4 | @testable import SwiftUIViewRecorder 5 | 6 | final class ViewRecordingViewModelTest: XCTestCase { 7 | 8 | private var cancellables: [AnyCancellable] = [] 9 | 10 | private var testView: some View { 11 | Circle() 12 | .fill(Color.yellow) 13 | .frame(width: 50, height: 50) 14 | } 15 | 16 | private let viewModel = ViewRecordingSessionViewModel() 17 | 18 | func testErrorPublishing() { 19 | let framesRenderer = FramesRendererMock() 20 | framesRenderer.error = AssetGenerationError(errorDescription: "some error reason") 21 | 22 | let session = try? ViewRecordingSession(view: testView, 23 | framesRenderer: framesRenderer, 24 | duration: nil, 25 | framesPerSecond: 24) 26 | XCTAssertNotNil(session) 27 | 28 | viewModel.handleRecording(session: session!) 29 | 30 | let assetGenerated = expectation(description: "asset generated") 31 | assetGenerated.expectedFulfillmentCount = 1 // nil 32 | 33 | let errorReceived = expectation(description: "error received") 34 | errorReceived.expectedFulfillmentCount = 2 // nil, error 35 | 36 | cancellables.append(viewModel.$asset.sink { value in 37 | assetGenerated.fulfill() 38 | }) 39 | 40 | cancellables.append(viewModel.$error.sink { error in 41 | if (error != nil) { 42 | XCTAssertEqual(error, ViewRecordingError.renderingError(reason: "some error reason")) 43 | } 44 | errorReceived.fulfill() 45 | }) 46 | 47 | viewModel.stopRecording() 48 | 49 | wait(for: [assetGenerated, errorReceived], timeout: 1) 50 | } 51 | 52 | func testErrorPublishingWithFixedRecordingDuration() { 53 | let framesRenderer = FramesRendererMock() 54 | framesRenderer.error = AssetGenerationError(errorDescription: "some error reason") 55 | 56 | let session = try? ViewRecordingSession(view: testView, 57 | framesRenderer: framesRenderer, 58 | duration: 1, 59 | framesPerSecond: 24) 60 | XCTAssertNotNil(session) 61 | 62 | viewModel.handleRecording(session: session!) 63 | 64 | let assetGenerated = expectation(description: "asset generated") 65 | assetGenerated.expectedFulfillmentCount = 1 // nil 66 | 67 | let errorReceived = expectation(description: "error received") 68 | errorReceived.expectedFulfillmentCount = 2 // nil, error 69 | 70 | cancellables.append(viewModel.$asset.sink { value in 71 | assetGenerated.fulfill() 72 | }) 73 | 74 | cancellables.append(viewModel.$error.sink { error in 75 | if (error != nil) { 76 | XCTAssertEqual(error, ViewRecordingError.renderingError(reason: "some error reason")) 77 | } 78 | errorReceived.fulfill() 79 | }) 80 | 81 | wait(for: [assetGenerated, errorReceived], timeout: 2) 82 | } 83 | 84 | func testValuePublishing() { 85 | let framesRenderer = FramesRendererMock() 86 | 87 | let session = try? ViewRecordingSession(view: testView, 88 | framesRenderer: framesRenderer, 89 | duration: nil, 90 | framesPerSecond: 24) 91 | XCTAssertNotNil(session) 92 | 93 | viewModel.handleRecording(session: session!) 94 | 95 | let assetGenerated = expectation(description: "asset generated") 96 | assetGenerated.expectedFulfillmentCount = 2 // nil, value 97 | 98 | let errorReceived = expectation(description: "error received") 99 | errorReceived.expectedFulfillmentCount = 1 // nil 100 | 101 | cancellables.append(viewModel.$asset.sink { value in 102 | assetGenerated.fulfill() 103 | }) 104 | 105 | cancellables.append(viewModel.$error.sink { error in 106 | errorReceived.fulfill() 107 | }) 108 | 109 | viewModel.stopRecording() 110 | 111 | wait(for: [assetGenerated, errorReceived], timeout: 1) 112 | } 113 | 114 | func testValuePublishingWithFixedRecordingDuration() { 115 | let framesRenderer = FramesRendererMock() 116 | 117 | let session = try? ViewRecordingSession(view: testView, 118 | framesRenderer: framesRenderer, 119 | duration: 1/24, 120 | framesPerSecond: 24) 121 | XCTAssertNotNil(session) 122 | 123 | viewModel.handleRecording(session: session!) 124 | 125 | let assetGenerated = expectation(description: "asset generated") 126 | assetGenerated.expectedFulfillmentCount = 2 // nil, value 127 | 128 | let errorReceived = expectation(description: "error received") 129 | errorReceived.expectedFulfillmentCount = 1 // nil 130 | 131 | cancellables.append(viewModel.$asset.sink { value in 132 | assetGenerated.fulfill() 133 | }) 134 | 135 | cancellables.append(viewModel.$error.sink { error in 136 | errorReceived.fulfill() 137 | }) 138 | 139 | wait(for: [assetGenerated, errorReceived], timeout: 1) 140 | } 141 | 142 | static var allTests = [ 143 | ("testErrorPublishing", testErrorPublishing), 144 | ("testErrorPublishingWithFixedRecordingDuration", testErrorPublishingWithFixedRecordingDuration), 145 | ("testValuePublishing", testValuePublishing), 146 | ("testValuePublishingWithFixedRecordingDuration", testValuePublishingWithFixedRecordingDuration) 147 | ] 148 | } 149 | -------------------------------------------------------------------------------- /Tests/SwiftUIViewRecorderTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(ViewRecordingSessionTest.allTests), 7 | testCase(ViewRecordingSessionViewModelTest.allTests), 8 | testCase(UIImagesToVideoTest.allTests) 9 | ] 10 | } 11 | #endif 12 | --------------------------------------------------------------------------------