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.
- package/CapacitorVideoTranscoder.podspec +18 -0
- package/Package.swift +39 -0
- package/README.md +238 -2
- package/android/build.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/TranscodeOptions.kt +122 -0
- package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/VideoTranscoder.kt +265 -0
- package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/VideoTranscoderPlugin.kt +51 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +221 -0
- package/dist/esm/definitions.d.ts +120 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +6 -0
- package/dist/esm/web.js +10 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +24 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +27 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Package.swift +22 -0
- package/ios/Sources/VideoTranscoderCore/Geometry.swift +61 -0
- package/ios/Sources/VideoTranscoderCore/TranscodeOptions.swift +107 -0
- package/ios/Sources/VideoTranscoderCore/Transcoder.swift +388 -0
- package/ios/Sources/VideoTranscoderPlugin/VideoTranscoderPlugin.swift +73 -0
- package/ios/Tests/VideoTranscoderCoreTests/OptionsAndGeometryTests.swift +146 -0
- package/ios/Tests/VideoTranscoderCoreTests/TranscoderTests.swift +234 -0
- package/ios/Tests/VideoTranscoderPluginTests/VideoTranscoderPluginTests.swift +32 -0
- 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
|
+
}
|