capacitor-video-transcoder 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CapacitorVideoTranscoder.podspec +18 -0
  2. package/Package.swift +39 -0
  3. package/README.md +238 -2
  4. package/android/build.gradle +68 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/TranscodeOptions.kt +122 -0
  7. package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/VideoTranscoder.kt +265 -0
  8. package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/VideoTranscoderPlugin.kt +51 -0
  9. package/android/src/main/res/.gitkeep +0 -0
  10. package/dist/docs.json +221 -0
  11. package/dist/esm/definitions.d.ts +120 -0
  12. package/dist/esm/definitions.js +2 -0
  13. package/dist/esm/definitions.js.map +1 -0
  14. package/dist/esm/index.d.ts +4 -0
  15. package/dist/esm/index.js +7 -0
  16. package/dist/esm/index.js.map +1 -0
  17. package/dist/esm/web.d.ts +6 -0
  18. package/dist/esm/web.js +10 -0
  19. package/dist/esm/web.js.map +1 -0
  20. package/dist/plugin.cjs.js +24 -0
  21. package/dist/plugin.cjs.js.map +1 -0
  22. package/dist/plugin.js +27 -0
  23. package/dist/plugin.js.map +1 -0
  24. package/ios/Package.swift +22 -0
  25. package/ios/Sources/VideoTranscoderCore/Geometry.swift +61 -0
  26. package/ios/Sources/VideoTranscoderCore/TranscodeOptions.swift +107 -0
  27. package/ios/Sources/VideoTranscoderCore/Transcoder.swift +388 -0
  28. package/ios/Sources/VideoTranscoderPlugin/VideoTranscoderPlugin.swift +73 -0
  29. package/ios/Tests/VideoTranscoderCoreTests/OptionsAndGeometryTests.swift +146 -0
  30. package/ios/Tests/VideoTranscoderCoreTests/TranscoderTests.swift +234 -0
  31. package/ios/Tests/VideoTranscoderPluginTests/VideoTranscoderPluginTests.swift +32 -0
  32. package/package.json +101 -4
@@ -0,0 +1,388 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ /// What a finished transcode wrote.
5
+ public struct TranscodeOutcome: Equatable {
6
+ public let url: URL
7
+ /// The size a player shows, upright.
8
+ public let width: Int
9
+ public let height: Int
10
+ public let durationMs: Int
11
+ public let size: Int
12
+
13
+ public init(url: URL, width: Int, height: Int, durationMs: Int, size: Int) {
14
+ self.url = url
15
+ self.width = width
16
+ self.height = height
17
+ self.durationMs = durationMs
18
+ self.size = size
19
+ }
20
+ }
21
+
22
+ /// Transcodes a video into an MP4 a web browser plays, with AVAssetReader and AVAssetWriter: H.264 High, 8-bit 4:2:0 tagged
23
+ /// BT.709 (an HDR source is converted by the decoder, which is asked for BT.709), AAC audio (an AAC source is copied, anything
24
+ /// else encoded), the longer side at most `maxSize`, cut at `maxDurationMs`, `moov` before `mdat`, and the source's rotation kept as
25
+ /// the track transform. One transcode at a time.
26
+ public final class Transcoder: @unchecked Sendable {
27
+ private let lock = NSLock()
28
+ private var running = false
29
+ private var cancelled = false
30
+ private var reader: AVAssetReader?
31
+
32
+ public init() {}
33
+
34
+ /// Stops the running transcode, which then throws `CANCELLED`. Does nothing when none is running.
35
+ public func cancel() {
36
+ lock.lock()
37
+ let reader = running ? self.reader : nil
38
+ if running {
39
+ cancelled = true
40
+ }
41
+ lock.unlock()
42
+ reader?.cancelReading()
43
+ }
44
+
45
+ /// Takes the one slot there is, or throws `BUSY`. `transcode` takes it on its own; a caller that has to answer `BUSY` before it
46
+ /// can start a task (the bridge) takes it first and passes `reserved: true`.
47
+ public func reserve() throws {
48
+ lock.lock()
49
+ defer { lock.unlock() }
50
+ if running {
51
+ throw TranscodeError(.busy, "another transcode is still running")
52
+ }
53
+ running = true
54
+ cancelled = false
55
+ }
56
+
57
+ public func transcode(
58
+ _ options: TranscodeOptions,
59
+ reserved: Bool = false,
60
+ progress: @escaping @Sendable (Double) -> Void = { _ in }
61
+ ) async throws -> TranscodeOutcome {
62
+ if !reserved {
63
+ try reserve()
64
+ }
65
+ defer { release() }
66
+
67
+ let output = options.destination ?? Transcoder.newDestination()
68
+ do {
69
+ return try await run(options, output: output, progress: progress)
70
+ } catch {
71
+ try? FileManager.default.removeItem(at: output)
72
+ if isCancelled {
73
+ throw TranscodeError(.cancelled, "the transcode was cancelled")
74
+ }
75
+ if let error = error as? TranscodeError {
76
+ throw error
77
+ }
78
+ throw TranscodeError(.failed, error.localizedDescription)
79
+ }
80
+ }
81
+
82
+ private func release() {
83
+ lock.lock()
84
+ running = false
85
+ reader = nil
86
+ lock.unlock()
87
+ }
88
+
89
+ /// Makes `reader` the one `cancel()` stops, and says whether a cancel already came.
90
+ private func attach(_ reader: AVAssetReader) -> Bool {
91
+ lock.lock()
92
+ defer { lock.unlock() }
93
+ self.reader = reader
94
+ return cancelled
95
+ }
96
+
97
+ private var isCancelled: Bool {
98
+ lock.lock()
99
+ defer { lock.unlock() }
100
+ return cancelled
101
+ }
102
+
103
+ static func newDestination() -> URL {
104
+ FileManager.default.temporaryDirectory
105
+ .appendingPathComponent("video-transcoder", isDirectory: true)
106
+ .appendingPathComponent(UUID().uuidString + ".mp4")
107
+ }
108
+
109
+ static let bt709: [String: Any] = [
110
+ AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
111
+ AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
112
+ AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2
113
+ ]
114
+
115
+ static let aacSubtypes: Set<AudioFormatID> = [kAudioFormatMPEG4AAC, kAudioFormatMPEG4AAC_HE, kAudioFormatMPEG4AAC_HE_V2]
116
+
117
+ // swiftlint:disable:next function_body_length cyclomatic_complexity
118
+ private func run(_ options: TranscodeOptions, output: URL, progress: @escaping @Sendable (Double) -> Void) async throws -> TranscodeOutcome {
119
+ var isDirectory: ObjCBool = false
120
+ guard FileManager.default.fileExists(atPath: options.source.path, isDirectory: &isDirectory), !isDirectory.boolValue,
121
+ FileManager.default.isReadableFile(atPath: options.source.path) else {
122
+ throw TranscodeError(.notFound, "there is no readable file at \(options.source.path)")
123
+ }
124
+
125
+ let asset = AVURLAsset(url: options.source)
126
+ let videoTrack: AVAssetTrack
127
+ let audioTrack: AVAssetTrack?
128
+ let duration: CMTime
129
+ let naturalSize: CGSize
130
+ let transform: CGAffineTransform
131
+ do {
132
+ guard let track = try await asset.loadTracks(withMediaType: .video).first else {
133
+ throw TranscodeError(.noVideo, "\(options.source.lastPathComponent) has no video track")
134
+ }
135
+ videoTrack = track
136
+ audioTrack = try await asset.loadTracks(withMediaType: .audio).first
137
+ duration = try await asset.load(.duration)
138
+ naturalSize = try await videoTrack.load(.naturalSize)
139
+ transform = try await videoTrack.load(.preferredTransform)
140
+ } catch let error as TranscodeError {
141
+ throw error
142
+ } catch {
143
+ throw TranscodeError(.noVideo, "\(options.source.lastPathComponent) is not a video this device reads: \(error.localizedDescription)")
144
+ }
145
+
146
+ let limit = CMTime(value: CMTimeValue(options.maxDurationMs), timescale: 1000)
147
+ let clip = duration.isNumeric && duration > .zero ? CMTimeMinimum(duration, limit) : limit
148
+ let size = Geometry.outputSize(width: Int(naturalSize.width), height: Int(naturalSize.height), maxSize: options.maxSize)
149
+
150
+ let reader = try AVAssetReader(asset: asset)
151
+ reader.timeRange = CMTimeRange(start: .zero, duration: clip)
152
+ // 8-bit 4:2:0 out of the decoder, converted to BT.709: this is where an HDR (10-bit HLG or PQ) source is tone-mapped.
153
+ let videoOut = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: [
154
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
155
+ AVVideoColorPropertiesKey: Transcoder.bt709
156
+ ])
157
+ videoOut.alwaysCopiesSampleData = false
158
+ guard reader.canAdd(videoOut) else {
159
+ throw TranscodeError(.failed, "the video track cannot be decoded")
160
+ }
161
+ reader.add(videoOut)
162
+
163
+ try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true)
164
+ try? FileManager.default.removeItem(at: output)
165
+ let writer = try AVAssetWriter(outputURL: output, fileType: .mp4)
166
+ writer.shouldOptimizeForNetworkUse = true // moov before mdat
167
+ let videoIn = AVAssetWriterInput(mediaType: .video, outputSettings: [
168
+ AVVideoCodecKey: AVVideoCodecType.h264,
169
+ AVVideoWidthKey: size.width,
170
+ AVVideoHeightKey: size.height,
171
+ AVVideoScalingModeKey: AVVideoScalingModeResizeAspect,
172
+ AVVideoColorPropertiesKey: Transcoder.bt709,
173
+ AVVideoCompressionPropertiesKey: [
174
+ AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
175
+ AVVideoAverageBitRateKey: options.videoBitrate
176
+ ]
177
+ ])
178
+ // The rotation stays metadata, as the phone wrote it: the frames are stored as recorded and the player turns them upright.
179
+ videoIn.transform = transform
180
+ videoIn.expectsMediaDataInRealTime = false
181
+ guard writer.canAdd(videoIn) else {
182
+ throw TranscodeError(.failed, "the H.264 encoder does not take \(size.width)x\(size.height)")
183
+ }
184
+ writer.add(videoIn)
185
+
186
+ var audio: (output: AVAssetReaderTrackOutput, input: AVAssetWriterInput)?
187
+ if let audioTrack {
188
+ audio = try await audioPair(for: audioTrack)
189
+ if let audio, reader.canAdd(audio.output), writer.canAdd(audio.input) {
190
+ reader.add(audio.output)
191
+ writer.add(audio.input)
192
+ } else {
193
+ throw TranscodeError(.failed, "the audio track cannot be converted to AAC")
194
+ }
195
+ }
196
+
197
+ if attach(reader) {
198
+ throw TranscodeError(.cancelled, "the transcode was cancelled")
199
+ }
200
+
201
+ guard reader.startReading() else {
202
+ throw TranscodeError(.failed, "the video cannot be read: \(reader.error?.localizedDescription ?? "unknown error")")
203
+ }
204
+ guard writer.startWriting() else {
205
+ reader.cancelReading()
206
+ throw TranscodeError(.failed, "the MP4 cannot be written: \(writer.error?.localizedDescription ?? "unknown error")")
207
+ }
208
+ writer.startSession(atSourceTime: .zero)
209
+
210
+ let pumps = Pumps(count: audio == nil ? 1 : 2)
211
+ let clipSeconds = clip.seconds
212
+ let reported = Reported()
213
+ pump(videoIn, from: videoOut, label: "video", pumps: pumps, writer: writer) { time in
214
+ let value = Geometry.progress(seconds: time.seconds, of: clipSeconds)
215
+ if reported.advance(to: value) {
216
+ progress(value)
217
+ }
218
+ }
219
+ if let audio {
220
+ pump(audio.input, from: audio.output, label: "audio", pumps: pumps, writer: writer) { _ in }
221
+ }
222
+ await pumps.wait()
223
+
224
+ if isCancelled {
225
+ writer.cancelWriting()
226
+ throw TranscodeError(.cancelled, "the transcode was cancelled")
227
+ }
228
+ if reader.status == .failed || writer.status == .failed {
229
+ let reason = (writer.error ?? reader.error)?.localizedDescription ?? "unknown error"
230
+ writer.cancelWriting()
231
+ throw TranscodeError(.failed, reason)
232
+ }
233
+ writer.endSession(atSourceTime: clip)
234
+ await writer.finishWriting()
235
+ guard writer.status == .completed else {
236
+ throw TranscodeError(.failed, "the MP4 was not finished: \(writer.error?.localizedDescription ?? "unknown error")")
237
+ }
238
+
239
+ let written = AVURLAsset(url: output)
240
+ let writtenDuration = (try? await written.load(.duration)) ?? clip
241
+ let fileSize = (try? FileManager.default.attributesOfItem(atPath: output.path)[.size] as? NSNumber)?.intValue ?? 0
242
+ let shown = Geometry.displayedSize(size, rotationDegrees: Geometry.rotationDegrees(of: transform))
243
+ progress(1)
244
+ return TranscodeOutcome(
245
+ url: output,
246
+ width: shown.width,
247
+ height: shown.height,
248
+ durationMs: Int((writtenDuration.seconds * 1000).rounded()),
249
+ size: fileSize
250
+ )
251
+ }
252
+
253
+ /// The reader output and writer input for an audio track: AAC is passed through untouched, anything else decoded to PCM and
254
+ /// encoded to AAC with the source's channel count (more than two are mixed down to stereo).
255
+ private func audioPair(for track: AVAssetTrack) async throws -> (output: AVAssetReaderTrackOutput, input: AVAssetWriterInput) {
256
+ let descriptions = try await track.load(.formatDescriptions)
257
+ let description = descriptions.first
258
+ let basic = description.flatMap { CMAudioFormatDescriptionGetStreamBasicDescription($0)?.pointee }
259
+ if let description, let basic, Transcoder.aacSubtypes.contains(basic.mFormatID) {
260
+ let output = AVAssetReaderTrackOutput(track: track, outputSettings: nil)
261
+ let input = AVAssetWriterInput(mediaType: .audio, outputSettings: nil, sourceFormatHint: description)
262
+ input.expectsMediaDataInRealTime = false
263
+ return (output, input)
264
+ }
265
+ let format = Geometry.aacFormat(
266
+ sourceChannels: Int(basic?.mChannelsPerFrame ?? 2),
267
+ sourceSampleRate: basic?.mSampleRate ?? 44_100
268
+ )
269
+ let output = AVAssetReaderTrackOutput(track: track, outputSettings: [
270
+ AVFormatIDKey: kAudioFormatLinearPCM,
271
+ AVSampleRateKey: format.sampleRate,
272
+ AVNumberOfChannelsKey: format.channels,
273
+ AVLinearPCMBitDepthKey: 16,
274
+ AVLinearPCMIsFloatKey: false,
275
+ AVLinearPCMIsBigEndianKey: false,
276
+ AVLinearPCMIsNonInterleaved: false
277
+ ])
278
+ let input = AVAssetWriterInput(mediaType: .audio, outputSettings: [
279
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
280
+ AVNumberOfChannelsKey: format.channels,
281
+ AVSampleRateKey: format.sampleRate
282
+ ])
283
+ input.expectsMediaDataInRealTime = false
284
+ return (output, input)
285
+ }
286
+
287
+ /// Copies one track from the reader to the writer on its own queue, and tells `pumps` when the track ends, the transcode is
288
+ /// cancelled, or the writer stops taking samples.
289
+ private func pump(
290
+ _ input: AVAssetWriterInput,
291
+ from output: AVAssetReaderTrackOutput,
292
+ label: String,
293
+ pumps: Pumps,
294
+ writer: AVAssetWriter,
295
+ onSample: @escaping (CMTime) -> Void
296
+ ) {
297
+ var left = false
298
+ func leave(finished: Bool) {
299
+ guard !left else { return }
300
+ left = true
301
+ if finished {
302
+ input.markAsFinished()
303
+ }
304
+ pumps.leave(finished: finished)
305
+ }
306
+ input.requestMediaDataWhenReady(on: DispatchQueue(label: "video-transcoder.\(label)")) { [weak self] in
307
+ while input.isReadyForMoreMediaData, !left {
308
+ if self?.isCancelled ?? true || writer.status != .writing {
309
+ leave(finished: false)
310
+ return
311
+ }
312
+ guard let sample = output.copyNextSampleBuffer() else {
313
+ leave(finished: !(self?.isCancelled ?? true) && writer.status == .writing)
314
+ return
315
+ }
316
+ onSample(CMSampleBufferGetPresentationTimeStamp(sample))
317
+ if !input.append(sample) {
318
+ leave(finished: false)
319
+ return
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+
326
+ /// Waits for the track pumps. Every one has to finish for the file to be complete, but one that stops early (a cancel, a failed
327
+ /// append) ends the wait at once: the other may never be asked for data again, because the writer holds it back until the stopped
328
+ /// track catches up. Waiting for both is how a cancel hung on an iPhone, with the audio pump parked behind the video.
329
+ final class Pumps: @unchecked Sendable {
330
+ private let lock = NSLock()
331
+ private var remaining: Int
332
+ private var over = false
333
+ private var waiter: CheckedContinuation<Void, Never>?
334
+
335
+ init(count: Int) {
336
+ remaining = count
337
+ }
338
+
339
+ func leave(finished: Bool) {
340
+ lock.lock()
341
+ remaining -= 1
342
+ let end = !over && (!finished || remaining == 0)
343
+ if end {
344
+ over = true
345
+ }
346
+ let resume = end ? waiter : nil
347
+ if end {
348
+ waiter = nil
349
+ }
350
+ lock.unlock()
351
+ resume?.resume()
352
+ }
353
+
354
+ func wait() async {
355
+ await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
356
+ if !park(continuation) {
357
+ continuation.resume()
358
+ }
359
+ }
360
+ }
361
+
362
+ /// Keeps the continuation to resume later, or says the wait is already over.
363
+ private func park(_ continuation: CheckedContinuation<Void, Never>) -> Bool {
364
+ lock.lock()
365
+ defer { lock.unlock() }
366
+ if over {
367
+ return false
368
+ }
369
+ waiter = continuation
370
+ return true
371
+ }
372
+ }
373
+
374
+ /// The last progress value reported, so that only a value at least a percent further is reported again.
375
+ private final class Reported: @unchecked Sendable {
376
+ private let lock = NSLock()
377
+ private var last = -1.0
378
+
379
+ func advance(to value: Double) -> Bool {
380
+ lock.lock()
381
+ defer { lock.unlock() }
382
+ guard value >= last + 0.01 else {
383
+ return false
384
+ }
385
+ last = value
386
+ return true
387
+ }
388
+ }
@@ -0,0 +1,73 @@
1
+ import Capacitor
2
+ import Foundation
3
+ // A separate module under Swift Package Manager, the same module under CocoaPods.
4
+ #if canImport(VideoTranscoderCore)
5
+ import VideoTranscoderCore
6
+ #endif
7
+
8
+ @objc(VideoTranscoderPlugin)
9
+ public class VideoTranscoderPlugin: CAPPlugin, CAPBridgedPlugin {
10
+ public let identifier = "VideoTranscoderPlugin"
11
+ public let jsName = "VideoTranscoder"
12
+ public let pluginMethods: [CAPPluginMethod] = [
13
+ CAPPluginMethod(name: "transcode", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "cancel", returnType: CAPPluginReturnPromise)
15
+ ]
16
+ private let transcoder = Transcoder()
17
+
18
+ @objc func transcode(_ call: CAPPluginCall) {
19
+ let options: TranscodeOptions
20
+ do {
21
+ options = try TranscodeOptions.parse(VideoTranscoderPlugin.raw(call.options))
22
+ // taken here, before the task, so that a second call made straight after this one is answered BUSY
23
+ try transcoder.reserve()
24
+ } catch {
25
+ reject(call, error)
26
+ return
27
+ }
28
+ Task {
29
+ do {
30
+ let outcome = try await transcoder.transcode(options, reserved: true) { [weak self] progress in
31
+ self?.notifyListeners("progress", data: ["progress": progress])
32
+ }
33
+ call.resolve(VideoTranscoderPlugin.result(outcome))
34
+ } catch {
35
+ reject(call, error)
36
+ }
37
+ }
38
+ }
39
+
40
+ @objc func cancel(_ call: CAPPluginCall) {
41
+ transcoder.cancel()
42
+ call.resolve()
43
+ }
44
+
45
+ static func raw(_ options: [AnyHashable: Any]?) -> [String: Any] {
46
+ var raw: [String: Any] = [:]
47
+ for (key, value) in options ?? [:] {
48
+ if let key = key as? String {
49
+ raw[key] = value
50
+ }
51
+ }
52
+ return raw
53
+ }
54
+
55
+ static func result(_ outcome: TranscodeOutcome) -> [String: Any] {
56
+ [
57
+ "path": outcome.url.path,
58
+ "uri": outcome.url.absoluteString,
59
+ "width": outcome.width,
60
+ "height": outcome.height,
61
+ "durationMs": outcome.durationMs,
62
+ "size": outcome.size
63
+ ]
64
+ }
65
+
66
+ private func reject(_ call: CAPPluginCall, _ error: Error) {
67
+ if let error = error as? TranscodeError {
68
+ call.reject(error.message, error.code.rawValue)
69
+ } else {
70
+ call.reject(error.localizedDescription, TranscodeErrorCode.failed.rawValue)
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,146 @@
1
+ import CoreGraphics
2
+ import XCTest
3
+ @testable import VideoTranscoderCore
4
+
5
+ final class OptionsTests: XCTestCase {
6
+ func testFillsTheDefaults() throws {
7
+ let options = try TranscodeOptions.parse(["source": "/var/in.mov"])
8
+ XCTAssertEqual(options.source.path, "/var/in.mov")
9
+ XCTAssertNil(options.destination)
10
+ XCTAssertEqual(options.maxSize, 1280)
11
+ XCTAssertEqual(options.maxDurationMs, 30_000)
12
+ XCTAssertEqual(options.videoBitrate, 1_800_000)
13
+ }
14
+
15
+ func testTakesFileURLsWithEscapes() throws {
16
+ let options = try TranscodeOptions.parse([
17
+ "source": "file:///var/mobile/My%20Video.mov",
18
+ "destination": "file://localhost/tmp/out.mp4",
19
+ "maxSize": 640,
20
+ "maxDurationMs": 1500.0,
21
+ "videoBitrate": NSNumber(value: 900_000)
22
+ ])
23
+ XCTAssertEqual(options.source.path, "/var/mobile/My Video.mov")
24
+ XCTAssertEqual(options.destination?.path, "/tmp/out.mp4")
25
+ XCTAssertEqual(options.maxSize, 640)
26
+ XCTAssertEqual(options.maxDurationMs, 1500)
27
+ XCTAssertEqual(options.videoBitrate, 900_000)
28
+ }
29
+
30
+ func testANullIsTheDefault() throws {
31
+ let options = try TranscodeOptions.parse(["source": "/a.mov", "destination": NSNull(), "maxSize": NSNull()])
32
+ XCTAssertNil(options.destination)
33
+ XCTAssertEqual(options.maxSize, 1280)
34
+ }
35
+
36
+ func testRefusesWhatIsNotALocalFile() {
37
+ for source in ["", "relative/in.mov", "content://media/external/video/1", "https://example.com/a.mp4", "file://", "/"] {
38
+ XCTAssertThrowsError(try TranscodeOptions.parse(["source": source]), source) { error in
39
+ XCTAssertEqual((error as? TranscodeError)?.code, .invalidArgument)
40
+ }
41
+ }
42
+ XCTAssertThrowsError(try TranscodeOptions.parse([:]))
43
+ XCTAssertThrowsError(try TranscodeOptions.parse(["source": 42]))
44
+ XCTAssertThrowsError(try TranscodeOptions.parse(["source": "/a.mov", "destination": "out.mp4"]))
45
+ XCTAssertThrowsError(try TranscodeOptions.parse(["source": "/a.mov", "destination": 1]))
46
+ }
47
+
48
+ func testRefusesNumbersOutOfRangeOrNotWhole() {
49
+ let bad: [(String, Any)] = [
50
+ ("maxSize", 15), ("maxSize", 640.5), ("maxSize", "640"), ("maxSize", true), ("maxSize", Double.nan),
51
+ ("maxDurationMs", 0), ("maxDurationMs", -1), ("videoBitrate", 0), ("videoBitrate", 1e12)
52
+ ]
53
+ for (key, value) in bad {
54
+ XCTAssertThrowsError(try TranscodeOptions.parse(["source": "/a.mov", key: value]), "\(key)=\(value)") { error in
55
+ XCTAssertEqual((error as? TranscodeError)?.code, .invalidArgument)
56
+ XCTAssertTrue((error as? TranscodeError)?.message.contains(key) ?? false)
57
+ }
58
+ }
59
+ XCTAssertEqual(try TranscodeOptions.parse(["source": "/a.mov", "maxSize": 16]).maxSize, 16)
60
+ }
61
+ }
62
+
63
+ final class GeometryTests: XCTestCase {
64
+ func testScalesTheLongerSideDownToMaxSize() {
65
+ XCTAssertEqual(Geometry.outputSize(width: 1920, height: 1080, maxSize: 1280), VideoSize(width: 1280, height: 720))
66
+ XCTAssertEqual(Geometry.outputSize(width: 1080, height: 1920, maxSize: 1280), VideoSize(width: 720, height: 1280))
67
+ XCTAssertEqual(Geometry.outputSize(width: 3840, height: 2160, maxSize: 1280), VideoSize(width: 1280, height: 720))
68
+ XCTAssertEqual(Geometry.outputSize(width: 1440, height: 1080, maxSize: 1280), VideoSize(width: 1280, height: 960))
69
+ }
70
+
71
+ func testNeverScalesUpAndKeepsSidesEven() {
72
+ XCTAssertEqual(Geometry.outputSize(width: 640, height: 360, maxSize: 1280), VideoSize(width: 640, height: 360))
73
+ XCTAssertEqual(Geometry.outputSize(width: 321, height: 241, maxSize: 1280), VideoSize(width: 320, height: 240))
74
+ // an odd maxSize is not exceeded by rounding
75
+ XCTAssertEqual(Geometry.outputSize(width: 1920, height: 1080, maxSize: 1281), VideoSize(width: 1280, height: 720))
76
+ // a sliver keeps at least two pixels
77
+ XCTAssertEqual(Geometry.outputSize(width: 4000, height: 3, maxSize: 1280), VideoSize(width: 1280, height: 2))
78
+ }
79
+
80
+ func testReadsTheRotationOfATrackTransform() {
81
+ XCTAssertEqual(Geometry.rotationDegrees(of: .identity), 0)
82
+ XCTAssertEqual(Geometry.rotationDegrees(of: CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 1080, ty: 0)), 90)
83
+ XCTAssertEqual(Geometry.rotationDegrees(of: CGAffineTransform(a: -1, b: 0, c: 0, d: -1, tx: 1920, ty: 1080)), 180)
84
+ XCTAssertEqual(Geometry.rotationDegrees(of: CGAffineTransform(a: 0, b: -1, c: 1, d: 0, tx: 0, ty: 1920)), 270)
85
+ XCTAssertEqual(Geometry.displayedSize(VideoSize(width: 1280, height: 720), rotationDegrees: 90), VideoSize(width: 720, height: 1280))
86
+ XCTAssertEqual(Geometry.displayedSize(VideoSize(width: 1280, height: 720), rotationDegrees: 180), VideoSize(width: 1280, height: 720))
87
+ }
88
+
89
+ func testAudioFollowsTheSourceChannels() {
90
+ XCTAssertEqual(Geometry.aacFormat(sourceChannels: 1, sourceSampleRate: 44_100).channels, 1)
91
+ XCTAssertEqual(Geometry.aacFormat(sourceChannels: 2, sourceSampleRate: 48_000).channels, 2)
92
+ XCTAssertEqual(Geometry.aacFormat(sourceChannels: 6, sourceSampleRate: 48_000).channels, 2)
93
+ XCTAssertEqual(Geometry.aacFormat(sourceChannels: 2, sourceSampleRate: 16_000).sampleRate, 16_000)
94
+ XCTAssertEqual(Geometry.aacFormat(sourceChannels: 2, sourceSampleRate: 44_000).sampleRate, 44_100)
95
+ XCTAssertEqual(Geometry.aacFormat(sourceChannels: 2, sourceSampleRate: 96_000).sampleRate, 48_000)
96
+ }
97
+
98
+ func testProgressStaysBelowOneUntilTheEnd() {
99
+ XCTAssertEqual(Geometry.progress(seconds: 0, of: 10), 0)
100
+ XCTAssertEqual(Geometry.progress(seconds: 5, of: 10), 0.5)
101
+ XCTAssertEqual(Geometry.progress(seconds: 10, of: 10), 0.99)
102
+ XCTAssertEqual(Geometry.progress(seconds: -1, of: 10), 0)
103
+ XCTAssertEqual(Geometry.progress(seconds: 1, of: 0), 0)
104
+ XCTAssertEqual(Geometry.progress(seconds: .nan, of: 10), 0)
105
+ }
106
+ }
107
+
108
+ final class PumpsTests: XCTestCase {
109
+ func testWaitsForEveryTrackThatFinishes() async {
110
+ let pumps = Pumps(count: 2)
111
+ let ended = Flag()
112
+ let waiting = Task {
113
+ await pumps.wait()
114
+ ended.set()
115
+ }
116
+ pumps.leave(finished: true)
117
+ try? await Task.sleep(nanoseconds: 50_000_000)
118
+ XCTAssertFalse(ended.value, "one of two tracks is not the end")
119
+ pumps.leave(finished: true)
120
+ await waiting.value
121
+ XCTAssertTrue(ended.value)
122
+ }
123
+
124
+ func testATrackThatStopsEarlyEndsTheWaitAtOnce() async {
125
+ // the other track never reports: the writer holds it back behind the stopped one
126
+ let pumps = Pumps(count: 2)
127
+ pumps.leave(finished: false)
128
+ await pumps.wait()
129
+ pumps.leave(finished: true) // a late report is ignored
130
+ }
131
+ }
132
+
133
+ final class Flag: @unchecked Sendable {
134
+ private let lock = NSLock()
135
+ private var flag = false
136
+ func set() {
137
+ lock.lock()
138
+ flag = true
139
+ lock.unlock()
140
+ }
141
+ var value: Bool {
142
+ lock.lock()
143
+ defer { lock.unlock() }
144
+ return flag
145
+ }
146
+ }