├── .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 |
--------------------------------------------------------------------------------