mirror of
https://github.com/samsonjs/SJSAssetExportSession.git
synced 2026-03-25 08:45:50 +00:00
Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c9e24d2bc | |||
| 93806c5ed0 | |||
| c77c3cacd7 | |||
| 7dee5ab772 | |||
| 1d4e486041 | |||
| f60f5a9035 |
5 changed files with 211 additions and 85 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
|
||||||
|
|
@ -34,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.8"))
|
.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.
|
||||||
|
|
@ -198,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
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,22 @@ actor SampleWriter {
|
||||||
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.
|
||||||
|
|
@ -79,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) }
|
||||||
|
|
@ -88,21 +118,25 @@ actor SampleWriter {
|
||||||
renderSize: videoComposition.renderSize,
|
renderSize: videoComposition.renderSize,
|
||||||
settings: videoOutputSettings
|
settings: videoOutputSettings
|
||||||
)
|
)
|
||||||
|
let videoOutput = AVAssetReaderVideoCompositionOutput(
|
||||||
self.audioOutputSettings = audioOutputSettings
|
videoTracks: videoTracks,
|
||||||
self.audioMix = audioMix
|
videoSettings: nil
|
||||||
self.videoOutputSettings = videoOutputSettings
|
)
|
||||||
self.videoComposition = videoComposition
|
videoOutput.alwaysCopiesSampleData = false
|
||||||
self.reader = reader
|
videoOutput.videoComposition = videoComposition
|
||||||
self.writer = writer
|
guard reader.canAdd(videoOutput) else {
|
||||||
self.timeRange = if let timeRange {
|
throw Error.setupFailure(.cannotAddVideoOutput)
|
||||||
timeRange
|
|
||||||
} else {
|
|
||||||
try await CMTimeRange(start: .zero, duration: asset.load(.duration))
|
|
||||||
}
|
}
|
||||||
|
reader.add(videoOutput)
|
||||||
|
self.videoOutput = videoOutput
|
||||||
|
|
||||||
try await setUpAudio(audioTracks: audioTracks)
|
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoOutputSettings)
|
||||||
try await setUpVideo(videoTracks: videoTracks)
|
videoInput.expectsMediaDataInRealTime = false
|
||||||
|
guard writer.canAdd(videoInput) else {
|
||||||
|
throw Error.setupFailure(.cannotAddVideoInput)
|
||||||
|
}
|
||||||
|
writer.add(videoInput)
|
||||||
|
self.videoInput = videoInput
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSamples() async throws {
|
func writeSamples() async throws {
|
||||||
|
|
@ -165,53 +199,6 @@ actor SampleWriter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 let reader, 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 let writer, 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(
|
|
||||||
videoTracks: videoTracks,
|
|
||||||
videoSettings: nil
|
|
||||||
)
|
|
||||||
videoOutput.alwaysCopiesSampleData = false
|
|
||||||
videoOutput.videoComposition = videoComposition
|
|
||||||
guard let reader, reader.canAdd(videoOutput) else {
|
|
||||||
throw Error.setupFailure(.cannotAddVideoOutput)
|
|
||||||
}
|
|
||||||
reader.add(videoOutput)
|
|
||||||
self.videoOutput = videoOutput
|
|
||||||
|
|
||||||
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoOutputSettings)
|
|
||||||
videoInput.expectsMediaDataInRealTime = false
|
|
||||||
guard let writer, writer.canAdd(videoInput) else {
|
|
||||||
throw Error.setupFailure(.cannotAddVideoInput)
|
|
||||||
}
|
|
||||||
writer.add(videoInput)
|
|
||||||
self.videoInput = videoInput
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Encoding
|
// MARK: - Encoding
|
||||||
|
|
||||||
private func startEncodingAudioTracks() {
|
private func startEncodingAudioTracks() {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -30,16 +30,16 @@ final class ExportSessionTests: BaseTests {
|
||||||
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)
|
||||||
let dataRate = try await videoTrack.load(.estimatedDataRate)
|
let dataRate = try await videoTrack.load(.estimatedDataRate)
|
||||||
|
|
@ -79,16 +79,16 @@ final class ExportSessionTests: BaseTests {
|
||||||
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)
|
||||||
let dataRate = try await videoTrack.load(.estimatedDataRate)
|
let dataRate = try await videoTrack.load(.estimatedDataRate)
|
||||||
|
|
@ -169,7 +169,7 @@ final class ExportSessionTests: BaseTests {
|
||||||
)
|
)
|
||||||
|
|
||||||
let exportedAsset = AVURLAsset(url: destinationURL.url)
|
let exportedAsset = AVURLAsset(url: destinationURL.url)
|
||||||
let videoTrack = try #require(await exportedAsset.sendTracks(withMediaType: .video).first)
|
let videoTrack = try #require(await exportedAsset.loadTracks(withMediaType: .video).first)
|
||||||
let naturalSize = try await videoTrack.load(.naturalSize)
|
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||||
#expect(naturalSize == CGSize(width: 1920, height: 1080))
|
#expect(naturalSize == CGSize(width: 1920, height: 1080))
|
||||||
let fps = try await videoTrack.load(.nominalFrameRate)
|
let fps = try await videoTrack.load(.nominalFrameRate)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue