mirror of
https://github.com/samsonjs/SJSAssetExportSession.git
synced 2026-03-25 08:45:50 +00:00
Compare commits
34 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c9e24d2bc | |||
| 93806c5ed0 | |||
| c77c3cacd7 | |||
| 7dee5ab772 | |||
| 1d4e486041 | |||
| f60f5a9035 | |||
| 0eefb949e2 | |||
| 34c374d914 | |||
| b627e9bf50 | |||
| 7b7891ce14 | |||
| 1e768033a1 | |||
| 2f1b859a03 | |||
| 10c717ab99 | |||
| 62a7a375c0 | |||
| 49d41080bb | |||
| 865e524be6 | |||
| 2dac7d58dc | |||
| f72a073b36 | |||
| 3be5b7f28e | |||
| 4c7b64f045 | |||
| 33152e4e44 | |||
| c5b127c702 | |||
| e1a9f38d5a | |||
| 9297a14920 | |||
| e7fbbacd30 | |||
| e4b0671475 | |||
| f9bacbe9be | |||
| 63dc13d316 | |||
| 979a8e23ee | |||
| d82bd64635 | |||
| d995fbab46 | |||
| 6bda654048 | |||
| 6f6a5f00e0 | |||
| 7edb4b7f46 |
11 changed files with 403 additions and 256 deletions
153
Changelog.md
Normal file
153
Changelog.md
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Your change here.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.4.0...HEAD
|
||||||
|
|
||||||
|
## [0.4.0] - 2025-09-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed building with Xcode 26 RC
|
||||||
|
|
||||||
|
[0.4.0]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.3.9...0.4.0
|
||||||
|
|
||||||
|
## [0.3.9] - 2025-05-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed crash on iOS 17 by using a new task instead of assumeIsolated
|
||||||
|
|
||||||
|
[0.3.9]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.3.8...0.3.9
|
||||||
|
|
||||||
|
## [0.3.8] - 2025-04-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed crash when cancelled while writing samples
|
||||||
|
- Fixed tests with Swift 6.1 on macOS
|
||||||
|
- Fixed tests in Xcode 16.4 on macOS 15.5
|
||||||
|
- Fixed warnings in tests in Xcode 16.3
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Stopped relying on specific delay in cancellation test
|
||||||
|
- Updated readme for 0.3.8
|
||||||
|
|
||||||
|
[0.3.8]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.3.7...0.3.8
|
||||||
|
|
||||||
|
## [0.3.7] - 2025-01-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Simplified cancellation and fixed memory leak
|
||||||
|
|
||||||
|
[0.3.7]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.3.6...0.3.7
|
||||||
|
|
||||||
|
## [0.3.6] - 2025-01-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Attempted to fix possible retain cycle
|
||||||
|
|
||||||
|
[0.3.6]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.3.5...0.3.6
|
||||||
|
|
||||||
|
## [0.3.5] - 2025-01-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Improved cancellation response (potential memory leak issue)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Deleted dead code
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Extracted BaseTests class for better test organization
|
||||||
|
|
||||||
|
[0.3.5]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.3.4...0.3.5
|
||||||
|
|
||||||
|
## [0.3.4] - 2024-11-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- [#3](https://github.com/samsonjs/SJSAssetExportSession/pull/3): Fixed encoding stalling by interleaving audio and video samples - [@samsonjs](https://github.com/samsonjs).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated readme with additional documentation
|
||||||
|
|
||||||
|
[0.3.4]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.3.3...0.3.4
|
||||||
|
|
||||||
|
## [0.3.3] - 2024-10-19
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Made AudioOutputSettings and VideoOutputSettings properties public
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Made tests work on iOS 18.0 and iOS 18.1
|
||||||
|
- Fixed progress test
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Removed SampleWriter.duration property
|
||||||
|
|
||||||
|
[0.3.3]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.3.2...0.3.3
|
||||||
|
|
||||||
|
## [0.3.2] - 2024-10-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed release builds by using makeStream for SampleWriter's progress
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated example in readme to version 0.3.2
|
||||||
|
|
||||||
|
[0.3.2]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.3.1...0.3.2
|
||||||
|
|
||||||
|
## [0.3.1] - 2024-10-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Removed unnecessary Task.yield() to fix intermittent hang
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Improved code style and debuggability
|
||||||
|
- Updated version in readme to 0.3.1
|
||||||
|
|
||||||
|
[0.3.1]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.3...0.3.1
|
||||||
|
|
||||||
|
## [0.3] - 2024-10-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Made audio/video settings Hashable, Sendable, and Codable
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated readme for version 0.3
|
||||||
|
- Fixed SwiftPM instructions in readme
|
||||||
|
|
||||||
|
[0.3]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.2...0.3
|
||||||
|
|
||||||
|
## [0.2] - 2024-10-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- [#2](https://github.com/samsonjs/SJSAssetExportSession/pull/2): Fixed spatial audio handling by dropping spatial audio tracks to fix encoding iPhone 16 videos - [@samsonjs](https://github.com/samsonjs).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Code style improvements
|
||||||
|
- Updated version in readme's SPM example
|
||||||
|
|
||||||
|
[0.2]: https://github.com/samsonjs/SJSAssetExportSession/compare/0.1...0.2
|
||||||
|
|
||||||
|
## [0.1] - 2024-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release as Swift Package
|
||||||
|
- Alternative to AVAssetExportSession with custom audio/video settings
|
||||||
|
- Builder pattern API for AudioOutputSettings and VideoOutputSettings
|
||||||
|
- Flexible raw dictionary API for maximum control
|
||||||
|
- Progress reporting via AsyncStream
|
||||||
|
- Support for iOS 17.0+, macOS 14.0+, and visionOS 1.3+
|
||||||
|
- Swift 6 strict concurrency support
|
||||||
|
- Comprehensive test suite with multiple video formats
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Converted from Xcode project to Swift package
|
||||||
|
- Made yielding last progress value more reliable
|
||||||
|
- Set deployment targets to iOS 17, macOS 14, and visionOS 1.3
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Support for writing metadata on assets
|
||||||
|
- Documentation for most public API
|
||||||
|
- README and license files
|
||||||
|
|
||||||
|
[0.1]: https://github.com/samsonjs/SJSAssetExportSession/releases/tag/0.1
|
||||||
|
|
@ -24,6 +24,7 @@ let package = Package(
|
||||||
.process("Resources/test-no-audio.mp4"),
|
.process("Resources/test-no-audio.mp4"),
|
||||||
.process("Resources/test-no-video.m4a"),
|
.process("Resources/test-no-video.m4a"),
|
||||||
.process("Resources/test-spatial-audio.mov"),
|
.process("Resources/test-spatial-audio.mov"),
|
||||||
|
.process("Resources/test-x264-1080p-h264-60fps.mp4"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
# SJSAssetExportSession
|
# SJSAssetExportSession
|
||||||
|
|
||||||
|
[](https://0dependencies.dev)
|
||||||
|
[](https://swiftpackageindex.com/samsonjs/SJSAssetExportSession)
|
||||||
|
[](https://swiftpackageindex.com/samsonjs/SJSAssetExportSession)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`SJSAssetExportSession` is an alternative to [`AVAssetExportSession`][AV] that lets you provide custom audio and video settings, without dropping down into the world of `AVAssetReader` and `AVAssetWriter`. It has similar capabilites to [SDAVAssetExportSession][SDAV] but the API is completely different, the code is written in Swift, and it's ready for the world of strict concurrency.
|
`SJSAssetExportSession` is an alternative to [`AVAssetExportSession`][AV] that lets you provide custom audio and video settings, without dropping down into the world of `AVAssetReader` and `AVAssetWriter`. It has similar capabilites to [SDAVAssetExportSession][SDAV] but the API is completely different, the code is written in Swift, and it's ready for the world of strict concurrency.
|
||||||
|
|
@ -30,7 +34,7 @@ When you're integrating this into an app with Xcode then go to your project's Pa
|
||||||
When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file:
|
When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
.package(url: "https://github.com/samsonjs/SJSAssetExportSession.git", .upToNextMajor(from: "0.3.1"))
|
.package(url: "https://github.com/samsonjs/SJSAssetExportSession.git", .upToNextMajor(from: "0.4.0"))
|
||||||
```
|
```
|
||||||
|
|
||||||
and then add `"SJSAssetExportSession"` to the list of dependencies in your target as well.
|
and then add `"SJSAssetExportSession"` to the list of dependencies in your target as well.
|
||||||
|
|
@ -194,6 +198,6 @@ try await exporter.export(
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright © 2024 Sami Samhuri, https://samhuri.net <sami@samhuri.net>. Released under the terms of the [MIT License][MIT].
|
Copyright © 2024-2025 [Sami Samhuri](https://samhuri.net) <sami@samhuri.net>. Released under the terms of the [MIT License][MIT].
|
||||||
|
|
||||||
[MIT]: https://sjs.mit-license.org
|
[MIT]: https://sjs.mit-license.org
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ public struct AudioOutputSettings: Hashable, Sendable, Codable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let format: AudioFormatID
|
public let format: AudioFormatID
|
||||||
let channels: Int
|
public let channels: Int
|
||||||
let sampleRate: Int?
|
public let sampleRate: Int?
|
||||||
|
|
||||||
/// Specifies the AAC format with 2 channels at a 44.1 KHz sample rate.
|
/// Specifies the AAC format with 2 channels at a 44.1 KHz sample rate.
|
||||||
public static var `default`: AudioOutputSettings {
|
public static var `default`: AudioOutputSettings {
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ public final class ExportSession: Sendable {
|
||||||
fileType: fileType
|
fileType: fileType
|
||||||
)
|
)
|
||||||
Task { [progressContinuation] in
|
Task { [progressContinuation] in
|
||||||
for await progress in await sampleWriter.progressStream {
|
for await progress in sampleWriter.progressStream {
|
||||||
progressContinuation.yield(progress)
|
progressContinuation.yield(progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +149,7 @@ public final class ExportSession: Sendable {
|
||||||
fileType: fileType
|
fileType: fileType
|
||||||
)
|
)
|
||||||
Task { [progressContinuation] in
|
Task { [progressContinuation] in
|
||||||
for await progress in await sampleWriter.progressStream {
|
for await progress in sampleWriter.progressStream {
|
||||||
progressContinuation.yield(progress)
|
progressContinuation.yield(progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,9 @@ actor SampleWriter {
|
||||||
queue.asUnownedSerialExecutor()
|
queue.asUnownedSerialExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy var progressStream: AsyncStream<Float> = AsyncStream { continuation in
|
let progressStream: AsyncStream<Float>
|
||||||
progressContinuation = continuation
|
|
||||||
}
|
private let progressContinuation: AsyncStream<Float>.Continuation
|
||||||
private var progressContinuation: AsyncStream<Float>.Continuation?
|
|
||||||
|
|
||||||
// MARK: Inputs
|
// MARK: Inputs
|
||||||
|
|
||||||
|
|
@ -38,18 +37,16 @@ actor SampleWriter {
|
||||||
private let audioMix: AVAudioMix?
|
private let audioMix: AVAudioMix?
|
||||||
private let videoOutputSettings: [String: any Sendable]
|
private let videoOutputSettings: [String: any Sendable]
|
||||||
private let videoComposition: AVVideoComposition?
|
private let videoComposition: AVVideoComposition?
|
||||||
private let duration: CMTime
|
|
||||||
private let timeRange: CMTimeRange
|
private let timeRange: CMTimeRange
|
||||||
|
|
||||||
// MARK: Internal state
|
// MARK: Internal state
|
||||||
|
|
||||||
private let reader: AVAssetReader
|
private var reader: AVAssetReader?
|
||||||
private let writer: AVAssetWriter
|
private var writer: AVAssetWriter?
|
||||||
private var audioOutput: AVAssetReaderAudioMixOutput?
|
private var audioOutput: AVAssetReaderAudioMixOutput?
|
||||||
private var audioInput: AVAssetWriterInput?
|
private var audioInput: AVAssetWriterInput?
|
||||||
private var videoOutput: AVAssetReaderVideoCompositionOutput?
|
private var videoOutput: AVAssetReaderVideoCompositionOutput?
|
||||||
private var videoInput: AVAssetWriterInput?
|
private var videoInput: AVAssetWriterInput?
|
||||||
private var isCancelled = false
|
|
||||||
|
|
||||||
nonisolated init(
|
nonisolated init(
|
||||||
asset: sending AVAsset,
|
asset: sending AVAsset,
|
||||||
|
|
@ -65,18 +62,28 @@ actor SampleWriter {
|
||||||
) async throws {
|
) async throws {
|
||||||
precondition(!videoOutputSettings.isEmpty)
|
precondition(!videoOutputSettings.isEmpty)
|
||||||
|
|
||||||
let duration = if let timeRange {
|
(progressStream, progressContinuation) = AsyncStream<Float>.makeStream()
|
||||||
timeRange.duration
|
|
||||||
} else {
|
|
||||||
try await asset.load(.duration)
|
|
||||||
}
|
|
||||||
let reader = try AVAssetReader(asset: asset)
|
let reader = try AVAssetReader(asset: asset)
|
||||||
if let timeRange {
|
if let timeRange {
|
||||||
reader.timeRange = timeRange
|
reader.timeRange = timeRange
|
||||||
}
|
}
|
||||||
|
self.reader = reader
|
||||||
|
|
||||||
let writer = try AVAssetWriter(outputURL: outputURL, fileType: fileType)
|
let writer = try AVAssetWriter(outputURL: outputURL, fileType: fileType)
|
||||||
writer.shouldOptimizeForNetworkUse = optimizeForNetworkUse
|
writer.shouldOptimizeForNetworkUse = optimizeForNetworkUse
|
||||||
writer.metadata = metadata
|
writer.metadata = metadata
|
||||||
|
self.writer = writer
|
||||||
|
|
||||||
|
self.audioOutputSettings = audioOutputSettings
|
||||||
|
self.audioMix = audioMix
|
||||||
|
self.videoOutputSettings = videoOutputSettings
|
||||||
|
self.videoComposition = videoComposition
|
||||||
|
self.timeRange = if let timeRange {
|
||||||
|
timeRange
|
||||||
|
} else {
|
||||||
|
try await CMTimeRange(start: .zero, duration: asset.load(.duration))
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out disabled tracks to avoid problems encoding spatial audio. Ideally this would
|
// Filter out disabled tracks to avoid problems encoding spatial audio. Ideally this would
|
||||||
// preserve track groups and make that all configurable.
|
// preserve track groups and make that all configurable.
|
||||||
|
|
@ -85,7 +92,24 @@ actor SampleWriter {
|
||||||
// Audio is optional so only validate output settings when it's applicable.
|
// Audio is optional so only validate output settings when it's applicable.
|
||||||
if !audioTracks.isEmpty {
|
if !audioTracks.isEmpty {
|
||||||
try Self.validateAudio(outputSettings: audioOutputSettings, writer: writer)
|
try Self.validateAudio(outputSettings: audioOutputSettings, writer: writer)
|
||||||
|
let audioOutput = AVAssetReaderAudioMixOutput(audioTracks: audioTracks, audioSettings: nil)
|
||||||
|
audioOutput.alwaysCopiesSampleData = false
|
||||||
|
audioOutput.audioMix = audioMix
|
||||||
|
guard reader.canAdd(audioOutput) else {
|
||||||
|
throw Error.setupFailure(.cannotAddAudioOutput)
|
||||||
|
}
|
||||||
|
reader.add(audioOutput)
|
||||||
|
self.audioOutput = audioOutput
|
||||||
|
|
||||||
|
let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioOutputSettings)
|
||||||
|
audioInput.expectsMediaDataInRealTime = false
|
||||||
|
guard writer.canAdd(audioInput) else {
|
||||||
|
throw Error.setupFailure(.cannotAddAudioInput)
|
||||||
|
}
|
||||||
|
writer.add(audioInput)
|
||||||
|
self.audioInput = audioInput
|
||||||
}
|
}
|
||||||
|
|
||||||
let videoTracks = try await asset.loadTracks(withMediaType: .video)
|
let videoTracks = try await asset.loadTracks(withMediaType: .video)
|
||||||
.filterAsync { try await $0.load(.isEnabled) }
|
.filterAsync { try await $0.load(.isEnabled) }
|
||||||
guard !videoTracks.isEmpty else { throw Error.setupFailure(.videoTracksEmpty) }
|
guard !videoTracks.isEmpty else { throw Error.setupFailure(.videoTracksEmpty) }
|
||||||
|
|
@ -94,91 +118,6 @@ actor SampleWriter {
|
||||||
renderSize: videoComposition.renderSize,
|
renderSize: videoComposition.renderSize,
|
||||||
settings: videoOutputSettings
|
settings: videoOutputSettings
|
||||||
)
|
)
|
||||||
|
|
||||||
self.audioOutputSettings = audioOutputSettings
|
|
||||||
self.audioMix = audioMix
|
|
||||||
self.videoOutputSettings = videoOutputSettings
|
|
||||||
self.videoComposition = videoComposition
|
|
||||||
self.reader = reader
|
|
||||||
self.writer = writer
|
|
||||||
self.duration = duration
|
|
||||||
self.timeRange = timeRange ?? CMTimeRange(start: .zero, duration: duration)
|
|
||||||
|
|
||||||
try await setUpAudio(audioTracks: audioTracks)
|
|
||||||
try await setUpVideo(videoTracks: videoTracks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeSamples() async throws {
|
|
||||||
try Task.checkCancellation()
|
|
||||||
|
|
||||||
progressContinuation?.yield(0.0)
|
|
||||||
|
|
||||||
writer.startWriting()
|
|
||||||
writer.startSession(atSourceTime: timeRange.start)
|
|
||||||
reader.startReading()
|
|
||||||
try Task.checkCancellation()
|
|
||||||
|
|
||||||
await encodeAudioTracks()
|
|
||||||
try Task.checkCancellation()
|
|
||||||
|
|
||||||
await encodeVideoTracks()
|
|
||||||
try Task.checkCancellation()
|
|
||||||
|
|
||||||
guard reader.status != .cancelled && writer.status != .cancelled else {
|
|
||||||
throw CancellationError()
|
|
||||||
}
|
|
||||||
guard writer.status != .failed else {
|
|
||||||
reader.cancelReading()
|
|
||||||
throw Error.writeFailure(writer.error)
|
|
||||||
}
|
|
||||||
guard reader.status != .failed else {
|
|
||||||
writer.cancelWriting()
|
|
||||||
throw Error.readFailure(reader.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
writer.finishWriting {
|
|
||||||
continuation.resume(returning: ())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progressContinuation?.yield(1.0)
|
|
||||||
|
|
||||||
// Make sure the last progress value is yielded before returning.
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
progressContinuation?.onTermination = { _ in
|
|
||||||
continuation.resume(returning: ())
|
|
||||||
}
|
|
||||||
progressContinuation?.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
|
||||||
|
|
||||||
private func setUpAudio(audioTracks: [AVAssetTrack]) throws {
|
|
||||||
guard !audioTracks.isEmpty else { return }
|
|
||||||
|
|
||||||
let audioOutput = AVAssetReaderAudioMixOutput(audioTracks: audioTracks, audioSettings: nil)
|
|
||||||
audioOutput.alwaysCopiesSampleData = false
|
|
||||||
audioOutput.audioMix = audioMix
|
|
||||||
guard reader.canAdd(audioOutput) else {
|
|
||||||
throw Error.setupFailure(.cannotAddAudioOutput)
|
|
||||||
}
|
|
||||||
reader.add(audioOutput)
|
|
||||||
self.audioOutput = audioOutput
|
|
||||||
|
|
||||||
let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioOutputSettings)
|
|
||||||
audioInput.expectsMediaDataInRealTime = false
|
|
||||||
guard writer.canAdd(audioInput) else {
|
|
||||||
throw Error.setupFailure(.cannotAddAudioInput)
|
|
||||||
}
|
|
||||||
writer.add(audioInput)
|
|
||||||
self.audioInput = audioInput
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setUpVideo(videoTracks: [AVAssetTrack]) throws {
|
|
||||||
precondition(!videoTracks.isEmpty, "Video tracks must be provided")
|
|
||||||
|
|
||||||
let videoOutput = AVAssetReaderVideoCompositionOutput(
|
let videoOutput = AVAssetReaderVideoCompositionOutput(
|
||||||
videoTracks: videoTracks,
|
videoTracks: videoTracks,
|
||||||
videoSettings: nil
|
videoSettings: nil
|
||||||
|
|
@ -200,85 +139,100 @@ actor SampleWriter {
|
||||||
self.videoInput = videoInput
|
self.videoInput = videoInput
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() async {
|
func writeSamples() async throws {
|
||||||
isCancelled = true
|
guard let reader, let writer else { throw CancellationError() }
|
||||||
|
try Task.checkCancellation()
|
||||||
|
|
||||||
|
// Clear all of these properties otherwise when we get cancelled then we leak a bunch of
|
||||||
|
// pixel buffers.
|
||||||
|
defer {
|
||||||
|
if Task.isCancelled {
|
||||||
|
reader.cancelReading()
|
||||||
|
writer.cancelWriting()
|
||||||
|
}
|
||||||
|
self.reader = nil
|
||||||
|
self.writer = nil
|
||||||
|
audioInput = nil
|
||||||
|
audioOutput = nil
|
||||||
|
videoInput = nil
|
||||||
|
videoOutput = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
progressContinuation.yield(0.0)
|
||||||
|
|
||||||
|
writer.startWriting()
|
||||||
|
writer.startSession(atSourceTime: timeRange.start)
|
||||||
|
reader.startReading()
|
||||||
|
|
||||||
|
try Task.checkCancellation()
|
||||||
|
|
||||||
|
startEncodingAudioTracks()
|
||||||
|
startEncodingVideoTracks()
|
||||||
|
|
||||||
|
while reader.status == .reading, writer.status == .writing {
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard writer.status != .failed else {
|
||||||
|
reader.cancelReading()
|
||||||
|
throw Error.writeFailure(writer.error)
|
||||||
|
}
|
||||||
|
guard reader.status != .failed else {
|
||||||
|
writer.cancelWriting()
|
||||||
|
throw Error.readFailure(reader.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
writer.finishWriting {
|
||||||
|
continuation.resume(returning: ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressContinuation.yield(1.0)
|
||||||
|
|
||||||
|
// Make sure the last progress value is yielded before returning.
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
progressContinuation.onTermination = { _ in
|
||||||
|
continuation.resume(returning: ())
|
||||||
|
}
|
||||||
|
progressContinuation.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encoding
|
// MARK: - Encoding
|
||||||
|
|
||||||
private func encodeAudioTracks() async {
|
private func startEncodingAudioTracks() {
|
||||||
// Don't do anything when we have no audio to encode.
|
// Don't do anything when we have no audio to encode.
|
||||||
guard audioInput != nil, audioOutput != nil else {
|
guard let audioInput, audioOutput != nil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await withTaskCancellationHandler {
|
audioInput.requestMediaDataWhenReady(on: queue) {
|
||||||
await withCheckedContinuation { continuation in
|
Task { await self.writeAllReadySamples() }
|
||||||
self.audioInput!.requestMediaDataWhenReady(on: queue) {
|
|
||||||
self.assumeIsolated { _self in
|
|
||||||
guard !_self.isCancelled else {
|
|
||||||
log.debug("Cancelled while encoding audio")
|
|
||||||
_self.reader.cancelReading()
|
|
||||||
_self.writer.cancelWriting()
|
|
||||||
continuation.resume()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasMoreSamples = _self.writeReadySamples(
|
|
||||||
output: _self.audioOutput!,
|
|
||||||
input: _self.audioInput!
|
|
||||||
)
|
|
||||||
if !hasMoreSamples {
|
|
||||||
log.debug("Finished encoding audio")
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} onCancel: {
|
|
||||||
log.debug("Task cancelled while encoding audio")
|
|
||||||
Task {
|
|
||||||
await self.cancel()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func encodeVideoTracks() async {
|
private func startEncodingVideoTracks() {
|
||||||
await withTaskCancellationHandler {
|
videoInput?.requestMediaDataWhenReady(on: queue) {
|
||||||
await withCheckedContinuation { continuation in
|
Task { await self.writeAllReadySamples() }
|
||||||
self.videoInput!.requestMediaDataWhenReady(on: queue) {
|
}
|
||||||
// NOTE: assumeIsolated crashes on macOS at the moment
|
}
|
||||||
self.assumeIsolated { _self in
|
|
||||||
guard !_self.isCancelled else {
|
|
||||||
log.debug("Cancelled while encoding video")
|
|
||||||
_self.reader.cancelReading()
|
|
||||||
_self.writer.cancelWriting()
|
|
||||||
continuation.resume()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasMoreSamples = _self.writeReadySamples(
|
private func writeAllReadySamples() {
|
||||||
output: _self.videoOutput!,
|
if let audioInput, let audioOutput {
|
||||||
input: _self.videoInput!
|
let hasMoreAudio = writeReadySamples(output: audioOutput, input: audioInput)
|
||||||
)
|
if !hasMoreAudio { log.debug("Finished encoding audio") }
|
||||||
if !hasMoreSamples {
|
}
|
||||||
log.debug("Finished encoding video")
|
|
||||||
continuation.resume()
|
if let videoInput, let videoOutput {
|
||||||
}
|
let hasMoreVideo = writeReadySamples(output: videoOutput, input: videoInput)
|
||||||
}
|
if !hasMoreVideo { log.debug("Finished encoding video") }
|
||||||
}
|
|
||||||
}
|
|
||||||
} onCancel: {
|
|
||||||
log.debug("Task cancelled while encoding video")
|
|
||||||
Task {
|
|
||||||
await self.cancel()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func writeReadySamples(output: AVAssetReaderOutput, input: AVAssetWriterInput) -> Bool {
|
private func writeReadySamples(output: AVAssetReaderOutput, input: AVAssetWriterInput) -> Bool {
|
||||||
while input.isReadyForMoreMediaData {
|
while input.isReadyForMoreMediaData {
|
||||||
guard reader.status == .reading && writer.status == .writing,
|
guard reader?.status == .reading && writer?.status == .writing,
|
||||||
let sampleBuffer = output.copyNextSampleBuffer() else {
|
let sampleBuffer = output.copyNextSampleBuffer() else {
|
||||||
input.markAsFinished()
|
input.markAsFinished()
|
||||||
return false
|
return false
|
||||||
|
|
@ -286,9 +240,10 @@ actor SampleWriter {
|
||||||
|
|
||||||
// Only yield progress values for video. Audio is insignificant in comparison.
|
// Only yield progress values for video. Audio is insignificant in comparison.
|
||||||
if output == videoOutput {
|
if output == videoOutput {
|
||||||
let samplePresentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - timeRange.start
|
let endTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||||
let progress = Float(samplePresentationTime.seconds / duration.seconds)
|
let samplePresentationTime = endTime - timeRange.start
|
||||||
progressContinuation?.yield(progress)
|
let progress = Float(samplePresentationTime.seconds / timeRange.duration.seconds)
|
||||||
|
progressContinuation.yield(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard input.append(sampleBuffer) else {
|
guard input.append(sampleBuffer) else {
|
||||||
|
|
@ -336,7 +291,7 @@ actor SampleWriter {
|
||||||
let renderWidth = Int(renderSize.width)
|
let renderWidth = Int(renderSize.width)
|
||||||
let renderHeight = Int(renderSize.height)
|
let renderHeight = Int(renderSize.height)
|
||||||
if renderWidth != settingsWidth || renderHeight != settingsHeight {
|
if renderWidth != settingsWidth || renderHeight != settingsHeight {
|
||||||
log.warning("Video composition's render size (\(renderWidth)x\(renderHeight)) will be overriden by video output settings (\(settingsWidth)x\(settingsHeight))")
|
log.warning("Video composition's render size (\(renderWidth)x\(renderHeight)) will be overridden by video output settings (\(settingsWidth)x\(settingsHeight))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,11 @@ public struct VideoOutputSettings: Hashable, Sendable, Codable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let codec: Codec
|
public let codec: Codec
|
||||||
let size: CGSize
|
public let size: CGSize
|
||||||
let fps: Int?
|
public let fps: Int?
|
||||||
let bitrate: Int?
|
public let bitrate: Int?
|
||||||
let color: Color?
|
public let color: Color?
|
||||||
|
|
||||||
public static func codec(_ codec: Codec, size: CGSize) -> VideoOutputSettings {
|
public static func codec(_ codec: Codec, size: CGSize) -> VideoOutputSettings {
|
||||||
.init(codec: codec, size: size, fps: nil, bitrate: nil, color: nil)
|
.init(codec: codec, size: size, fps: nil, bitrate: nil, color: nil)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
//
|
|
||||||
// AVAsset+sending.swift
|
|
||||||
// SJSAssetExportSessionTests
|
|
||||||
//
|
|
||||||
// Created by Sami Samhuri on 2024-07-07.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AVFoundation
|
|
||||||
|
|
||||||
extension AVAsset {
|
|
||||||
func sendTracks(withMediaType mediaType: AVMediaType) async throws -> sending [AVAssetTrack] {
|
|
||||||
try await loadTracks(withMediaType: mediaType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
50
Tests/SJSAssetExportSessionTests/BaseTests.swift
Normal file
50
Tests/SJSAssetExportSessionTests/BaseTests.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
//
|
||||||
|
// BaseTests.swift
|
||||||
|
// SJSAssetExportSession
|
||||||
|
//
|
||||||
|
// Created by Sami Samhuri on 2025-01-19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
class BaseTests {
|
||||||
|
func resourceURL(named name: String) -> URL {
|
||||||
|
Bundle.module.resourceURL!.appending(component: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAsset(url: URL) -> sending AVAsset {
|
||||||
|
AVURLAsset(url: url, options: [
|
||||||
|
AVURLAssetPreferPreciseDurationAndTimingKey: true,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTemporaryURL(function: String = #function) -> AutoDestructingURL {
|
||||||
|
let timestamp = Int(Date.now.timeIntervalSince1970)
|
||||||
|
let f = function.replacing(/[\(\)]/, with: { _ in "" })
|
||||||
|
let filename = "\(Self.self)_\(f)_\(timestamp).mp4"
|
||||||
|
let url = URL.temporaryDirectory.appending(component: filename)
|
||||||
|
return AutoDestructingURL(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeVideoComposition(
|
||||||
|
assetURL: URL,
|
||||||
|
size: CGSize? = nil,
|
||||||
|
fps: Int? = nil
|
||||||
|
) async throws -> sending AVMutableVideoComposition {
|
||||||
|
let asset = makeAsset(url: assetURL)
|
||||||
|
let videoComposition = try await AVMutableVideoComposition.videoComposition(
|
||||||
|
withPropertiesOf: asset
|
||||||
|
)
|
||||||
|
if let size {
|
||||||
|
videoComposition.renderSize = size
|
||||||
|
}
|
||||||
|
if let fps {
|
||||||
|
let seconds = 1.0 / TimeInterval(fps)
|
||||||
|
videoComposition.sourceTrackIDForFrameTiming = kCMPersistentTrackID_Invalid
|
||||||
|
videoComposition.frameDuration = CMTime(seconds: seconds, preferredTimescale: 600)
|
||||||
|
}
|
||||||
|
return videoComposition
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
|
@ -10,45 +10,7 @@ import CoreLocation
|
||||||
import SJSAssetExportSession
|
import SJSAssetExportSession
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
final class ExportSessionTests {
|
final class ExportSessionTests: BaseTests {
|
||||||
private func resourceURL(named name: String) -> URL {
|
|
||||||
Bundle.module.resourceURL!.appending(component: name)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeAsset(url: URL) -> sending AVAsset {
|
|
||||||
AVURLAsset(url: url, options: [
|
|
||||||
AVURLAssetPreferPreciseDurationAndTimingKey: true,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeTemporaryURL(function: String = #function) -> AutoDestructingURL {
|
|
||||||
let timestamp = Int(Date.now.timeIntervalSince1970)
|
|
||||||
let f = function.replacing(/[\(\)]/, with: { _ in "" })
|
|
||||||
let filename = "\(Self.self)_\(f)_\(timestamp).mp4"
|
|
||||||
let url = URL.temporaryDirectory.appending(component: filename)
|
|
||||||
return AutoDestructingURL(url: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeVideoComposition(
|
|
||||||
assetURL: URL,
|
|
||||||
size: CGSize? = nil,
|
|
||||||
fps: Int? = nil
|
|
||||||
) async throws -> sending AVMutableVideoComposition {
|
|
||||||
let asset = makeAsset(url: assetURL)
|
|
||||||
let videoComposition = try await AVMutableVideoComposition.videoComposition(
|
|
||||||
withPropertiesOf: asset
|
|
||||||
)
|
|
||||||
if let size {
|
|
||||||
videoComposition.renderSize = size
|
|
||||||
}
|
|
||||||
if let fps {
|
|
||||||
let seconds = 1.0 / TimeInterval(fps)
|
|
||||||
videoComposition.sourceTrackIDForFrameTiming = kCMPersistentTrackID_Invalid
|
|
||||||
videoComposition.frameDuration = CMTime(seconds: seconds, preferredTimescale: 600)
|
|
||||||
}
|
|
||||||
return videoComposition
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func test_sugary_export_720p_h264_24fps() async throws {
|
@Test func test_sugary_export_720p_h264_24fps() async throws {
|
||||||
let sourceURL = resourceURL(named: "test-4k-hdr-hevc-30fps.mov")
|
let sourceURL = resourceURL(named: "test-4k-hdr-hevc-30fps.mov")
|
||||||
let destinationURL = makeTemporaryURL()
|
let destinationURL = makeTemporaryURL()
|
||||||
|
|
@ -68,19 +30,20 @@ final class ExportSessionTests {
|
||||||
let exportedAsset = AVURLAsset(url: destinationURL.url)
|
let exportedAsset = AVURLAsset(url: destinationURL.url)
|
||||||
#expect(try await exportedAsset.load(.duration) == .seconds(1))
|
#expect(try await exportedAsset.load(.duration) == .seconds(1))
|
||||||
// Audio
|
// Audio
|
||||||
try #require(try await exportedAsset.sendTracks(withMediaType: .audio).count == 1)
|
try #require(try await exportedAsset.loadTracks(withMediaType: .audio).count == 1)
|
||||||
let audioTrack = try #require(await exportedAsset.sendTracks(withMediaType: .audio).first)
|
let audioTrack = try #require(await exportedAsset.loadTracks(withMediaType: .audio).first)
|
||||||
let audioFormat = try #require(await audioTrack.load(.formatDescriptions).first)
|
let audioFormat = try #require(await audioTrack.load(.formatDescriptions).first)
|
||||||
#expect(audioFormat.mediaType == .audio)
|
#expect(audioFormat.mediaType == .audio)
|
||||||
#expect(audioFormat.mediaSubType == .mpeg4AAC)
|
#expect(audioFormat.mediaSubType == .mpeg4AAC)
|
||||||
#expect(audioFormat.audioChannelLayout?.numberOfChannels == 2)
|
#expect(audioFormat.audioChannelLayout?.numberOfChannels == 2)
|
||||||
#expect(audioFormat.audioStreamBasicDescription?.mSampleRate == 44_100)
|
#expect(audioFormat.audioStreamBasicDescription?.mSampleRate == 44_100)
|
||||||
// Video
|
// Video
|
||||||
try #require(await exportedAsset.sendTracks(withMediaType: .video).count == 1)
|
try #require(await exportedAsset.loadTracks(withMediaType: .video).count == 1)
|
||||||
let videoTrack = try #require(await exportedAsset.sendTracks(withMediaType: .video).first)
|
let videoTrack = try #require(await exportedAsset.loadTracks(withMediaType: .video).first)
|
||||||
#expect(try await videoTrack.load(.naturalSize) == CGSize(width: 1280, height: 720))
|
#expect(try await videoTrack.load(.naturalSize) == CGSize(width: 1280, height: 720))
|
||||||
#expect(try await videoTrack.load(.nominalFrameRate) == 24.0)
|
#expect(try await videoTrack.load(.nominalFrameRate) == 24.0)
|
||||||
#expect(try await videoTrack.load(.estimatedDataRate) == 1_036_128)
|
let dataRate = try await videoTrack.load(.estimatedDataRate)
|
||||||
|
#expect((900_000 ... 1_130_000).contains(dataRate))
|
||||||
let videoFormat = try #require(await videoTrack.load(.formatDescriptions).first)
|
let videoFormat = try #require(await videoTrack.load(.formatDescriptions).first)
|
||||||
#expect(videoFormat.mediaType == .video)
|
#expect(videoFormat.mediaType == .video)
|
||||||
#expect(videoFormat.mediaSubType == .h264)
|
#expect(videoFormat.mediaSubType == .h264)
|
||||||
|
|
@ -116,19 +79,20 @@ final class ExportSessionTests {
|
||||||
let exportedAsset = AVURLAsset(url: destinationURL.url)
|
let exportedAsset = AVURLAsset(url: destinationURL.url)
|
||||||
#expect(try await exportedAsset.load(.duration) == .seconds(1))
|
#expect(try await exportedAsset.load(.duration) == .seconds(1))
|
||||||
// Audio
|
// Audio
|
||||||
try #require(try await exportedAsset.sendTracks(withMediaType: .audio).count == 1)
|
try #require(try await exportedAsset.loadTracks(withMediaType: .audio).count == 1)
|
||||||
let audioTrack = try #require(await exportedAsset.sendTracks(withMediaType: .audio).first)
|
let audioTrack = try #require(await exportedAsset.loadTracks(withMediaType: .audio).first)
|
||||||
let audioFormat = try #require(await audioTrack.load(.formatDescriptions).first)
|
let audioFormat = try #require(await audioTrack.load(.formatDescriptions).first)
|
||||||
#expect(audioFormat.mediaType == .audio)
|
#expect(audioFormat.mediaType == .audio)
|
||||||
#expect(audioFormat.mediaSubType == .mpeg4AAC)
|
#expect(audioFormat.mediaSubType == .mpeg4AAC)
|
||||||
#expect(audioFormat.audioChannelLayout?.numberOfChannels == 2)
|
#expect(audioFormat.audioChannelLayout?.numberOfChannels == 2)
|
||||||
#expect(audioFormat.audioStreamBasicDescription?.mSampleRate == 44_100)
|
#expect(audioFormat.audioStreamBasicDescription?.mSampleRate == 44_100)
|
||||||
// Video
|
// Video
|
||||||
try #require(await exportedAsset.sendTracks(withMediaType: .video).count == 1)
|
try #require(await exportedAsset.loadTracks(withMediaType: .video).count == 1)
|
||||||
let videoTrack = try #require(await exportedAsset.sendTracks(withMediaType: .video).first)
|
let videoTrack = try #require(await exportedAsset.loadTracks(withMediaType: .video).first)
|
||||||
#expect(try await videoTrack.load(.naturalSize) == CGSize(width: 1280, height: 720))
|
#expect(try await videoTrack.load(.naturalSize) == CGSize(width: 1280, height: 720))
|
||||||
#expect(try await videoTrack.load(.nominalFrameRate) == 24.0)
|
#expect(try await videoTrack.load(.nominalFrameRate) == 24.0)
|
||||||
#expect(try await videoTrack.load(.estimatedDataRate) == 1_036_128)
|
let dataRate = try await videoTrack.load(.estimatedDataRate)
|
||||||
|
#expect((900_000 ... 1_130_000).contains(dataRate))
|
||||||
let videoFormat = try #require(await videoTrack.load(.formatDescriptions).first)
|
let videoFormat = try #require(await videoTrack.load(.formatDescriptions).first)
|
||||||
#expect(videoFormat.mediaType == .video)
|
#expect(videoFormat.mediaType == .video)
|
||||||
#expect(videoFormat.mediaSubType == .h264)
|
#expect(videoFormat.mediaSubType == .h264)
|
||||||
|
|
@ -137,7 +101,7 @@ final class ExportSessionTests {
|
||||||
#expect(videoFormat.extensions[.yCbCrMatrix] == .yCbCrMatrix(.itu_R_709_2))
|
#expect(videoFormat.extensions[.yCbCrMatrix] == .yCbCrMatrix(.itu_R_709_2))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func test_export_default_timerange() async throws {
|
@Test func test_export_default_time_range() async throws {
|
||||||
let sourceURL = resourceURL(named: "test-720p-h264-24fps.mov")
|
let sourceURL = resourceURL(named: "test-720p-h264-24fps.mov")
|
||||||
let destinationURL = makeTemporaryURL()
|
let destinationURL = makeTemporaryURL()
|
||||||
|
|
||||||
|
|
@ -190,6 +154,36 @@ final class ExportSessionTests {
|
||||||
#expect(try await exportedTrack.load(.naturalSize) == CGSize(width: 1280, height: 720))
|
#expect(try await exportedTrack.load(.naturalSize) == CGSize(width: 1280, height: 720))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func test_export_x264_60fps() async throws {
|
||||||
|
let sourceURL = resourceURL(named: "test-x264-1080p-h264-60fps.mp4")
|
||||||
|
let destinationURL = makeTemporaryURL()
|
||||||
|
|
||||||
|
let subject = ExportSession()
|
||||||
|
try await subject.export(
|
||||||
|
asset: makeAsset(url: sourceURL),
|
||||||
|
video: .codec(.h264, width: 1920, height: 1080)
|
||||||
|
.bitrate(2_500_000)
|
||||||
|
.fps(30),
|
||||||
|
to: destinationURL.url,
|
||||||
|
as: .mp4
|
||||||
|
)
|
||||||
|
|
||||||
|
let exportedAsset = AVURLAsset(url: destinationURL.url)
|
||||||
|
let videoTrack = try #require(await exportedAsset.loadTracks(withMediaType: .video).first)
|
||||||
|
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||||
|
#expect(naturalSize == CGSize(width: 1920, height: 1080))
|
||||||
|
let fps = try await videoTrack.load(.nominalFrameRate)
|
||||||
|
#expect(Int(fps.rounded()) == 30)
|
||||||
|
let dataRate = try await videoTrack.load(.estimatedDataRate)
|
||||||
|
#expect((2_400_000 ... 2_700_000).contains(dataRate))
|
||||||
|
let videoFormat = try #require(await videoTrack.load(.formatDescriptions).first)
|
||||||
|
#expect(videoFormat.mediaType == .video)
|
||||||
|
#expect(videoFormat.mediaSubType == .h264)
|
||||||
|
#expect(videoFormat.extensions[.colorPrimaries] == .colorPrimaries(.itu_R_709_2))
|
||||||
|
#expect(videoFormat.extensions[.transferFunction] == .transferFunction(.itu_R_709_2))
|
||||||
|
#expect(videoFormat.extensions[.yCbCrMatrix] == .yCbCrMatrix(.itu_R_709_2))
|
||||||
|
}
|
||||||
|
|
||||||
@Test func test_export_progress() async throws {
|
@Test func test_export_progress() async throws {
|
||||||
let sourceURL = resourceURL(named: "test-720p-h264-24fps.mov")
|
let sourceURL = resourceURL(named: "test-720p-h264-24fps.mov")
|
||||||
let progressValues = SendableWrapper<[Float]>([])
|
let progressValues = SendableWrapper<[Float]>([])
|
||||||
|
|
@ -207,6 +201,8 @@ final class ExportSessionTests {
|
||||||
as: .mov
|
as: .mov
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Wait for last progress value to be yielded.
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
#expect(progressValues.value.count > 2, "There should be intermediate progress updates")
|
#expect(progressValues.value.count > 2, "There should be intermediate progress updates")
|
||||||
#expect(progressValues.value.first == 0.0)
|
#expect(progressValues.value.first == 0.0)
|
||||||
#expect(progressValues.value.last == 1.0)
|
#expect(progressValues.value.last == 1.0)
|
||||||
|
|
@ -230,17 +226,17 @@ final class ExportSessionTests {
|
||||||
|
|
||||||
@Test func test_export_throws_with_empty_audio_settings() async throws {
|
@Test func test_export_throws_with_empty_audio_settings() async throws {
|
||||||
try await #require(throws: ExportSession.Error.setupFailure(.audioSettingsEmpty)) {
|
try await #require(throws: ExportSession.Error.setupFailure(.audioSettingsEmpty)) {
|
||||||
let sourceURL = resourceURL(named: "test-720p-h264-24fps.mov")
|
let sourceURL = self.resourceURL(named: "test-720p-h264-24fps.mov")
|
||||||
let videoComposition = try await makeVideoComposition(assetURL: sourceURL)
|
let videoComposition = try await self.makeVideoComposition(assetURL: sourceURL)
|
||||||
|
|
||||||
let subject = ExportSession()
|
let subject = ExportSession()
|
||||||
try await subject.export(
|
try await subject.export(
|
||||||
asset: makeAsset(url: sourceURL),
|
asset: self.makeAsset(url: sourceURL),
|
||||||
audioOutputSettings: [:], // Here it matters because there's an audio track
|
audioOutputSettings: [:], // Here it matters because there's an audio track
|
||||||
videoOutputSettings: VideoOutputSettings
|
videoOutputSettings: VideoOutputSettings
|
||||||
.codec(.h264, size: videoComposition.renderSize).settingsDictionary,
|
.codec(.h264, size: videoComposition.renderSize).settingsDictionary,
|
||||||
composition: videoComposition,
|
composition: videoComposition,
|
||||||
to: makeTemporaryURL().url,
|
to: self.makeTemporaryURL().url,
|
||||||
as: .mov
|
as: .mov
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -248,18 +244,18 @@ final class ExportSessionTests {
|
||||||
|
|
||||||
@Test func test_export_throws_with_invalid_audio_settings() async throws {
|
@Test func test_export_throws_with_invalid_audio_settings() async throws {
|
||||||
try await #require(throws: ExportSession.Error.setupFailure(.audioSettingsInvalid)) {
|
try await #require(throws: ExportSession.Error.setupFailure(.audioSettingsInvalid)) {
|
||||||
let sourceURL = resourceURL(named: "test-720p-h264-24fps.mov")
|
let sourceURL = self.resourceURL(named: "test-720p-h264-24fps.mov")
|
||||||
|
|
||||||
let subject = ExportSession()
|
let subject = ExportSession()
|
||||||
try await subject.export(
|
try await subject.export(
|
||||||
asset: makeAsset(url: sourceURL),
|
asset: self.makeAsset(url: sourceURL),
|
||||||
audioOutputSettings: [
|
audioOutputSettings: [
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
AVNumberOfChannelsKey: NSNumber(value: -1), // invalid number of channels
|
AVNumberOfChannelsKey: NSNumber(value: -1), // invalid number of channels
|
||||||
],
|
],
|
||||||
videoOutputSettings: VideoOutputSettings
|
videoOutputSettings: VideoOutputSettings
|
||||||
.codec(.h264, size: CGSize(width: 1280, height: 720)).settingsDictionary,
|
.codec(.h264, size: CGSize(width: 1280, height: 720)).settingsDictionary,
|
||||||
to: makeTemporaryURL().url,
|
to: self.makeTemporaryURL().url,
|
||||||
as: .mov
|
as: .mov
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -267,12 +263,12 @@ final class ExportSessionTests {
|
||||||
|
|
||||||
@Test func test_export_throws_with_invalid_video_settings() async throws {
|
@Test func test_export_throws_with_invalid_video_settings() async throws {
|
||||||
try await #require(throws: ExportSession.Error.setupFailure(.videoSettingsInvalid)) {
|
try await #require(throws: ExportSession.Error.setupFailure(.videoSettingsInvalid)) {
|
||||||
let sourceURL = resourceURL(named: "test-720p-h264-24fps.mov")
|
let sourceURL = self.resourceURL(named: "test-720p-h264-24fps.mov")
|
||||||
let size = CGSize(width: 1280, height: 720)
|
let size = CGSize(width: 1280, height: 720)
|
||||||
|
|
||||||
let subject = ExportSession()
|
let subject = ExportSession()
|
||||||
try await subject.export(
|
try await subject.export(
|
||||||
asset: makeAsset(url: sourceURL),
|
asset: self.makeAsset(url: sourceURL),
|
||||||
audioOutputSettings: AudioOutputSettings.default.settingsDictionary,
|
audioOutputSettings: AudioOutputSettings.default.settingsDictionary,
|
||||||
videoOutputSettings: [
|
videoOutputSettings: [
|
||||||
// missing codec
|
// missing codec
|
||||||
|
|
@ -280,7 +276,7 @@ final class ExportSessionTests {
|
||||||
AVVideoHeightKey: NSNumber(value: Int(size.height)),
|
AVVideoHeightKey: NSNumber(value: Int(size.height)),
|
||||||
],
|
],
|
||||||
composition: nil,
|
composition: nil,
|
||||||
to: makeTemporaryURL().url,
|
to: self.makeTemporaryURL().url,
|
||||||
as: .mov
|
as: .mov
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -288,12 +284,12 @@ final class ExportSessionTests {
|
||||||
|
|
||||||
@Test func test_export_throws_with_no_video_track() async throws {
|
@Test func test_export_throws_with_no_video_track() async throws {
|
||||||
try await #require(throws: ExportSession.Error.setupFailure(.videoTracksEmpty)) {
|
try await #require(throws: ExportSession.Error.setupFailure(.videoTracksEmpty)) {
|
||||||
let sourceURL = resourceURL(named: "test-no-video.m4a")
|
let sourceURL = self.resourceURL(named: "test-no-video.m4a")
|
||||||
let subject = ExportSession()
|
let subject = ExportSession()
|
||||||
try await subject.export(
|
try await subject.export(
|
||||||
asset: makeAsset(url: sourceURL),
|
asset: self.makeAsset(url: sourceURL),
|
||||||
video: .codec(.h264, width: 1280, height: 720),
|
video: .codec(.h264, width: 1280, height: 720),
|
||||||
to: makeTemporaryURL().url,
|
to: self.makeTemporaryURL().url,
|
||||||
as: .mov
|
as: .mov
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -302,11 +298,11 @@ final class ExportSessionTests {
|
||||||
@Test func test_export_cancellation() async throws {
|
@Test func test_export_cancellation() async throws {
|
||||||
let sourceURL = resourceURL(named: "test-720p-h264-24fps.mov")
|
let sourceURL = resourceURL(named: "test-720p-h264-24fps.mov")
|
||||||
let destinationURL💥 = makeTemporaryURL()
|
let destinationURL💥 = makeTemporaryURL()
|
||||||
|
let subject = ExportSession()
|
||||||
let task = Task {
|
let task = Task {
|
||||||
let sourceAsset = AVURLAsset(url: sourceURL, options: [
|
let sourceAsset = AVURLAsset(url: sourceURL, options: [
|
||||||
AVURLAssetPreferPreciseDurationAndTimingKey: true,
|
AVURLAssetPreferPreciseDurationAndTimingKey: true,
|
||||||
])
|
])
|
||||||
let subject = ExportSession()
|
|
||||||
try await subject.export(
|
try await subject.export(
|
||||||
asset: sourceAsset,
|
asset: sourceAsset,
|
||||||
video: .codec(.h264, width: 1280, height: 720),
|
video: .codec(.h264, width: 1280, height: 720),
|
||||||
|
|
@ -315,8 +311,10 @@ final class ExportSessionTests {
|
||||||
)
|
)
|
||||||
Issue.record("Task should be cancelled long before we get here")
|
Issue.record("Task should be cancelled long before we get here")
|
||||||
}
|
}
|
||||||
NSLog("Sleeping for 0.3s")
|
NSLog("Waiting for encoding to begin...")
|
||||||
try await Task.sleep(for: .milliseconds(300))
|
for await progress in subject.progressStream where progress > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
NSLog("Cancelling task")
|
NSLog("Cancelling task")
|
||||||
task.cancel()
|
task.cancel()
|
||||||
try? await task.value // Wait for task to complete
|
try? await task.value // Wait for task to complete
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue