react-native-compressor 1.18.2 → 2.0.0
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/android/CMakeLists.txt +27 -0
- package/android/build.gradle +56 -58
- package/android/gradle.properties +5 -5
- package/android/src/main/AndroidManifestNew.xml +3 -0
- package/android/src/main/cpp/cpp-adapter.cpp +8 -0
- package/android/src/main/java/com/margelo/nitro/compressor/HybridCompressor.kt +213 -0
- package/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +41 -21
- package/android/src/main/java/com/reactnativecompressor/NitroCompressorPackage.kt +30 -0
- package/android/src/main/java/com/reactnativecompressor/NitroPromiseAdapter.kt +55 -0
- package/android/src/main/java/com/reactnativecompressor/Utils/EventEmitterHandler.kt +49 -45
- package/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt +1 -1
- package/android/src/main/java/com/reactnativecompressor/Video/AutoVideoCompression.kt +1 -1
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressionProfile.kt +21 -11
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt +311 -76
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/CompressorUtils.kt +96 -5
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/LocationExtractor.kt +397 -0
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/LocationBox.kt +47 -0
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/MP4Builder.kt +39 -7
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/Mp4Movie.kt +7 -0
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/videoHelpers/OutputSurface.kt +30 -4
- package/android/src/main/java/com/reactnativecompressor/Video/VideoCompressorHelper.kt +28 -2
- package/ios/Audio/FormatConverter/FormatConverter+Compressed.swift +1 -1
- package/ios/Audio/FormatConverter/FormatConverter+Utilities.swift +1 -1
- package/ios/Audio/FormatConverter/FormatConverter.swift +5 -3
- package/ios/HybridCompressor.swift +247 -0
- package/ios/Image/ImageCompressor.swift +8 -2
- package/ios/Utils/EventEmitterHandler.swift +56 -34
- package/ios/Video/NextLevelSessionExporter.swift +43 -8
- package/ios/Video/VideoMain.swift +44 -14
- package/lib/commonjs/Audio/index.js +1 -1
- package/lib/commonjs/Audio/index.js.map +1 -1
- package/lib/commonjs/Image/index.js +2 -22
- package/lib/commonjs/Image/index.js.map +1 -1
- package/lib/commonjs/Main.js +8 -8
- package/lib/commonjs/Main.js.map +1 -1
- package/lib/commonjs/Video/index.js +25 -65
- package/lib/commonjs/Video/index.js.map +1 -1
- package/lib/commonjs/specs/Compressor.nitro.js +6 -0
- package/lib/commonjs/specs/Compressor.nitro.js.map +1 -0
- package/lib/commonjs/utils/Downloader.js +8 -24
- package/lib/commonjs/utils/Downloader.js.map +1 -1
- package/lib/commonjs/utils/Uploader.js +3 -18
- package/lib/commonjs/utils/Uploader.js.map +1 -1
- package/lib/commonjs/utils/helpers.js +18 -1
- package/lib/commonjs/utils/helpers.js.map +1 -1
- package/lib/commonjs/utils/index.js +13 -13
- package/lib/commonjs/utils/index.js.map +1 -1
- package/lib/module/Audio/index.js +2 -2
- package/lib/module/Audio/index.js.map +1 -1
- package/lib/module/Image/index.js +3 -23
- package/lib/module/Image/index.js.map +1 -1
- package/lib/module/Main.js +9 -9
- package/lib/module/Main.js.map +1 -1
- package/lib/module/Video/index.js +26 -66
- package/lib/module/Video/index.js.map +1 -1
- package/lib/module/specs/Compressor.nitro.js +4 -0
- package/lib/module/specs/Compressor.nitro.js.map +1 -0
- package/lib/module/utils/Downloader.js +10 -26
- package/lib/module/utils/Downloader.js.map +1 -1
- package/lib/module/utils/Uploader.js +5 -20
- package/lib/module/utils/Uploader.js.map +1 -1
- package/lib/module/utils/helpers.js +16 -0
- package/lib/module/utils/helpers.js.map +1 -1
- package/lib/module/utils/index.js +2 -1
- package/lib/module/utils/index.js.map +1 -1
- package/lib/typescript/src/Image/index.d.ts.map +1 -1
- package/lib/typescript/src/Main.d.ts +2 -1
- package/lib/typescript/src/Main.d.ts.map +1 -1
- package/lib/typescript/src/Video/index.d.ts +1 -1
- package/lib/typescript/src/Video/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/specs/Compressor.nitro.d.ts +44 -0
- package/lib/typescript/src/specs/Compressor.nitro.d.ts.map +1 -0
- package/lib/typescript/src/utils/Downloader.d.ts.map +1 -1
- package/lib/typescript/src/utils/Uploader.d.ts.map +1 -1
- package/lib/typescript/src/utils/helpers.d.ts +7 -0
- package/lib/typescript/src/utils/helpers.d.ts.map +1 -1
- package/lib/typescript/src/utils/index.d.ts.map +1 -1
- package/nitrogen/generated/android/NitroCompressor+autolinking.cmake +81 -0
- package/nitrogen/generated/android/NitroCompressor+autolinking.gradle +27 -0
- package/nitrogen/generated/android/NitroCompressorOnLoad.cpp +60 -0
- package/nitrogen/generated/android/NitroCompressorOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridCompressorSpec.cpp +293 -0
- package/nitrogen/generated/android/c++/JHybridCompressorSpec.hpp +78 -0
- package/nitrogen/generated/android/c++/JVideoThumbnailResult.hpp +73 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/Func_void_double_double.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/HybridCompressorSpec.kt +141 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/NitroCompressorOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/compressor/VideoThumbnailResult.kt +71 -0
- package/nitrogen/generated/ios/c++/HybridCompressorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridCompressorSpecSwift.hpp +204 -0
- package/nitrogen/generated/ios/react_native_compressor+autolinking.rb +62 -0
- package/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.cpp +89 -0
- package/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Bridge.hpp +337 -0
- package/nitrogen/generated/ios/react_native_compressor-Swift-Cxx-Umbrella.hpp +51 -0
- package/nitrogen/generated/ios/react_native_compressorAutolinking.mm +33 -0
- package/nitrogen/generated/ios/react_native_compressorAutolinking.swift +26 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_VideoThumbnailResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_double.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_AnyMap_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridCompressorSpec.swift +70 -0
- package/nitrogen/generated/ios/swift/HybridCompressorSpec_cxx.swift +493 -0
- package/nitrogen/generated/ios/swift/VideoThumbnailResult.swift +49 -0
- package/nitrogen/generated/shared/c++/HybridCompressorSpec.cpp +36 -0
- package/nitrogen/generated/shared/c++/HybridCompressorSpec.hpp +83 -0
- package/nitrogen/generated/shared/c++/VideoThumbnailResult.hpp +99 -0
- package/package.json +8 -12
- package/react-native-compressor.podspec +6 -24
- package/src/Audio/index.tsx +2 -2
- package/src/Image/index.tsx +3 -26
- package/src/Main.tsx +14 -16
- package/src/Video/index.tsx +25 -77
- package/src/specs/Compressor.nitro.ts +56 -0
- package/src/utils/Downloader.tsx +8 -27
- package/src/utils/Uploader.tsx +12 -24
- package/src/utils/helpers.ts +18 -0
- package/src/utils/index.tsx +3 -2
- package/android/src/main/java/com/reactnativecompressor/CompressorModule.kt +0 -180
- package/android/src/main/java/com/reactnativecompressor/CompressorPackage.kt +0 -34
- package/android/src/newarch/CompressorSpec.kt +0 -5
- package/android/src/oldarch/CompressorSpec.kt +0 -36
- package/ios/Compressor-Bridging-Header.h +0 -6
- package/ios/Compressor.h +0 -5
- package/ios/Compressor.mm +0 -82
- package/ios/CompressorManager.swift +0 -117
- package/lib/commonjs/Spec/NativeCompressor.js +0 -9
- package/lib/commonjs/Spec/NativeCompressor.js.map +0 -1
- package/lib/module/Spec/NativeCompressor.js +0 -5
- package/lib/module/Spec/NativeCompressor.js.map +0 -1
- package/lib/typescript/src/Spec/NativeCompressor.d.ts +0 -30
- package/lib/typescript/src/Spec/NativeCompressor.d.ts.map +0 -1
- package/src/Spec/NativeCompressor.ts +0 -41
|
@@ -2,6 +2,8 @@ package com.reactnativecompressor.Video.VideoCompressor.video
|
|
|
2
2
|
|
|
3
3
|
import android.graphics.SurfaceTexture
|
|
4
4
|
import android.graphics.SurfaceTexture.OnFrameAvailableListener
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.HandlerThread
|
|
5
7
|
import android.view.Surface
|
|
6
8
|
|
|
7
9
|
class OutputSurface : OnFrameAvailableListener {
|
|
@@ -12,6 +14,15 @@ class OutputSurface : OnFrameAvailableListener {
|
|
|
12
14
|
private var mFrameAvailable = false
|
|
13
15
|
private var mTextureRender: TextureRenderer? = null
|
|
14
16
|
|
|
17
|
+
// Dedicated thread for SurfaceTexture's onFrameAvailable callback.
|
|
18
|
+
// Without this, Android delivers the callback on the main UI thread
|
|
19
|
+
// (because the compression coroutine has no Looper), so awaitNewImage()
|
|
20
|
+
// stalls whenever the main thread is busy with UI / JS bridge work.
|
|
21
|
+
// Routing the callback to its own thread removes that contention and
|
|
22
|
+
// is the single biggest throughput win for the decoder→encoder pipeline.
|
|
23
|
+
private val mCallbackThread = HandlerThread("CompressorSurfaceTexCb").apply { start() }
|
|
24
|
+
private val mCallbackHandler = Handler(mCallbackThread.looper)
|
|
25
|
+
|
|
15
26
|
/**
|
|
16
27
|
* Creates an OutputSurface using the current EGL context. This Surface will be
|
|
17
28
|
* passed to MediaCodec.configure().
|
|
@@ -35,7 +46,7 @@ class OutputSurface : OnFrameAvailableListener {
|
|
|
35
46
|
// causes the native finalizer to run.
|
|
36
47
|
mSurfaceTexture = SurfaceTexture(it.getTextureId())
|
|
37
48
|
mSurfaceTexture?.let { surfaceTexture ->
|
|
38
|
-
surfaceTexture.setOnFrameAvailableListener(this)
|
|
49
|
+
surfaceTexture.setOnFrameAvailableListener(this, mCallbackHandler)
|
|
39
50
|
mSurface = Surface(mSurfaceTexture)
|
|
40
51
|
}
|
|
41
52
|
}
|
|
@@ -43,6 +54,13 @@ class OutputSurface : OnFrameAvailableListener {
|
|
|
43
54
|
|
|
44
55
|
/**
|
|
45
56
|
* Discards all resources held by this class, notably the EGL context.
|
|
57
|
+
*
|
|
58
|
+
* quitSafely() returns immediately; the HandlerThread's native pthread
|
|
59
|
+
* may still be terminating when callers proceed to tear down MediaCodec.
|
|
60
|
+
* If the ART sampling profiler walks threads during that window it can
|
|
61
|
+
* dereference a stale pthread_t and SIGABRT. join(500) blocks until the
|
|
62
|
+
* thread is fully exited (pthread_join) so the pthread_t is no longer
|
|
63
|
+
* tracked. Bounded at 500ms to avoid hanging on a pathological looper.
|
|
46
64
|
*/
|
|
47
65
|
fun release() {
|
|
48
66
|
mSurface?.release()
|
|
@@ -50,6 +68,13 @@ class OutputSurface : OnFrameAvailableListener {
|
|
|
50
68
|
mTextureRender = null
|
|
51
69
|
mSurface = null
|
|
52
70
|
mSurfaceTexture = null
|
|
71
|
+
|
|
72
|
+
mCallbackThread.quitSafely()
|
|
73
|
+
try {
|
|
74
|
+
mCallbackThread.join(500)
|
|
75
|
+
} catch (ignored: InterruptedException) {
|
|
76
|
+
Thread.currentThread().interrupt()
|
|
77
|
+
}
|
|
53
78
|
}
|
|
54
79
|
|
|
55
80
|
/**
|
|
@@ -63,12 +88,13 @@ class OutputSurface : OnFrameAvailableListener {
|
|
|
63
88
|
* data is available.
|
|
64
89
|
*/
|
|
65
90
|
fun awaitNewImage() {
|
|
66
|
-
|
|
91
|
+
// 10s timeout to avoid spurious failures under heavy main-thread load.
|
|
92
|
+
// The callback now arrives on a dedicated thread, so realistic frames
|
|
93
|
+
// land in <50ms; this bound only catches a stuck pipeline.
|
|
94
|
+
val timeOutMS = 10_000
|
|
67
95
|
synchronized(mFrameSyncObject) {
|
|
68
96
|
while (!mFrameAvailable) {
|
|
69
97
|
try {
|
|
70
|
-
// Wait for onFrameAvailable() to signal us. Use a timeout to avoid
|
|
71
|
-
// stalling the test if it doesn't arrive.
|
|
72
98
|
mFrameSyncObject.wait(timeOutMS.toLong())
|
|
73
99
|
if (!mFrameAvailable) {
|
|
74
100
|
throw RuntimeException("Surface frame wait timed out")
|
|
@@ -5,6 +5,7 @@ import android.content.Context
|
|
|
5
5
|
import android.media.MediaMetadataRetriever
|
|
6
6
|
import android.net.Uri
|
|
7
7
|
import android.os.Handler
|
|
8
|
+
import android.os.Looper
|
|
8
9
|
import android.os.PowerManager
|
|
9
10
|
import android.os.PowerManager.WakeLock
|
|
10
11
|
import com.facebook.react.bridge.LifecycleEventListener
|
|
@@ -58,7 +59,9 @@ class VideoCompressorHelper {
|
|
|
58
59
|
if (!wakeLock!!.isHeld()) {
|
|
59
60
|
wakeLock!!.acquire()
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
+
// Bind to the main Looper: under Nitro this runs on a background executor
|
|
63
|
+
// thread (no Looper), so the no-arg Handler() constructor would throw.
|
|
64
|
+
handler = Handler(Looper.getMainLooper())
|
|
62
65
|
runnable = Runnable { }
|
|
63
66
|
handler!!.post(runnable!!)
|
|
64
67
|
return ""
|
|
@@ -100,6 +103,29 @@ class VideoCompressorHelper {
|
|
|
100
103
|
?: 0
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Derive the source video frame rate. METADATA_KEY_CAPTURE_FRAMERATE
|
|
108
|
+
* is only populated for slow-motion captures, so most regular videos
|
|
109
|
+
* return 0 and downstream code falls back to a hard-coded 30 fps —
|
|
110
|
+
* which silently halves the frame count of any 60 fps source and
|
|
111
|
+
* produces visibly choppy output.
|
|
112
|
+
*
|
|
113
|
+
* Strategy: trust CAPTURE_FRAMERATE when present, otherwise compute
|
|
114
|
+
* fps from frame count / duration (API 28+ exposes the frame count
|
|
115
|
+
* via METADATA_KEY_VIDEO_FRAME_COUNT). Returns 0 if neither path
|
|
116
|
+
* yields a usable value.
|
|
117
|
+
*/
|
|
118
|
+
fun getSourceFrameRate(metaRetriever: MediaMetadataRetriever): Int {
|
|
119
|
+
val capture = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
|
|
120
|
+
if (capture > 0) return capture
|
|
121
|
+
|
|
122
|
+
val frameCount = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT)
|
|
123
|
+
val durationMs = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_DURATION)
|
|
124
|
+
if (frameCount <= 0 || durationMs <= 0) return 0
|
|
125
|
+
val fps = (frameCount.toLong() * 1000L / durationMs.toLong()).toInt()
|
|
126
|
+
return fps.coerceIn(0, 240)
|
|
127
|
+
}
|
|
128
|
+
|
|
103
129
|
fun VideoCompressManual(fileUrl: String?, options: VideoCompressorHelper, promise: Promise, reactContext: ReactApplicationContext?) {
|
|
104
130
|
try {
|
|
105
131
|
val uri = Uri.parse(fileUrl)
|
|
@@ -110,7 +136,7 @@ class VideoCompressorHelper {
|
|
|
110
136
|
val height = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
|
111
137
|
val width = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
|
112
138
|
val bitrate = getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
|
113
|
-
val frameRate =
|
|
139
|
+
val frameRate = getSourceFrameRate(metaRetriever)
|
|
114
140
|
if (height <= 0 || width <= 0) {
|
|
115
141
|
promise.reject(Throwable("Failed to read the input video dimensions"))
|
|
116
142
|
return
|
|
@@ -19,7 +19,9 @@ import AVFoundation
|
|
|
19
19
|
}
|
|
20
20
|
```
|
|
21
21
|
*/
|
|
22
|
-
public
|
|
22
|
+
// `internal` (not `public`): keeps these types out of the Swift↔C++ interop surface
|
|
23
|
+
// that Nitro enables module-wide, which otherwise fails to link the nested `Options` type.
|
|
24
|
+
class FormatConverter {
|
|
23
25
|
// MARK: - properties
|
|
24
26
|
|
|
25
27
|
/// The source audio file
|
|
@@ -122,7 +124,7 @@ public class FormatConverter {
|
|
|
122
124
|
|
|
123
125
|
// MARK: - Definitions
|
|
124
126
|
|
|
125
|
-
|
|
127
|
+
enum AudioFileFormat: String {
|
|
126
128
|
case aac
|
|
127
129
|
case aif
|
|
128
130
|
case aifc
|
|
@@ -141,7 +143,7 @@ public enum AudioFileFormat: String {
|
|
|
141
143
|
case wav
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
|
|
146
|
+
extension FormatConverter {
|
|
145
147
|
|
|
146
148
|
/// FormatConverterCallback is the callback format for start()
|
|
147
149
|
/// - Parameter: error This will contain one parameter of type Error which is nil if the conversion was successful.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
//
|
|
2
|
+
// HybridCompressor.swift
|
|
3
|
+
// react-native-compressor
|
|
4
|
+
//
|
|
5
|
+
// Nitro HybridObject implementation of the single `Compressor` native module.
|
|
6
|
+
// It is a thin binding layer: it converts Nitro's `AnyMap` options into the
|
|
7
|
+
// `NSDictionary`/`[String: Any]` the existing domain code already consumes,
|
|
8
|
+
// bridges the Nitro `Promise` to the domain layer's `RCTPromiseResolveBlock`/
|
|
9
|
+
// `RCTPromiseRejectBlock`, and registers progress callbacks (keyed by `uuid`)
|
|
10
|
+
// with `EventEmitterHandler`. All heavy logic stays in the domain classes.
|
|
11
|
+
//
|
|
12
|
+
|
|
13
|
+
import Foundation
|
|
14
|
+
import NitroModules
|
|
15
|
+
|
|
16
|
+
// Previously imported from React. We define them locally so this Swift module no
|
|
17
|
+
// longer needs to `import React`: under Nitro's Swift↔C++ interop, importing
|
|
18
|
+
// React pulls move-only C++ types (e.g. jsinspector's RuntimeSamplingProfile)
|
|
19
|
+
// into Swift's importer and fails to compile. These blocks are created here and
|
|
20
|
+
// consumed by the domain layer entirely in Swift, so plain closures suffice.
|
|
21
|
+
typealias RCTPromiseResolveBlock = (Any?) -> Void
|
|
22
|
+
typealias RCTPromiseRejectBlock = (String?, String?, (any Error)?) -> Void
|
|
23
|
+
|
|
24
|
+
private let videoCompressor = VideoCompressor()
|
|
25
|
+
private let uploader = Uploader()
|
|
26
|
+
|
|
27
|
+
final class HybridCompressor: HybridCompressorSpec {
|
|
28
|
+
// MARK: - Image
|
|
29
|
+
|
|
30
|
+
func image_compress(imagePath: String, optionMap: AnyMap, onDownloadProgress: ((Double) -> Void)?) throws -> Promise<String> {
|
|
31
|
+
let promise = Promise<String>()
|
|
32
|
+
var options = dictionary(from: optionMap)
|
|
33
|
+
// Remote-image download progress is keyed by `uuid` inside the Downloader. The JS
|
|
34
|
+
// layer no longer sends one (the callback is passed directly), so mint one here.
|
|
35
|
+
let uuid = (options["uuid"] as? String) ?? UUID().uuidString
|
|
36
|
+
options["uuid"] = uuid
|
|
37
|
+
EventEmitterHandler.registerDownloadProgress(uuid: uuid, onDownloadProgress)
|
|
38
|
+
ImageMain.image_compress(imagePath, optionMap: options as NSDictionary, resolve: resolveString(promise, uuid), reject: reject(promise, uuid))
|
|
39
|
+
return promise
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func getImageMetaData(filePath: String) throws -> Promise<AnyMap> {
|
|
43
|
+
let promise = Promise<AnyMap>()
|
|
44
|
+
ImageMain.getImageMetaData(filePath, resolve: resolveAnyMap(promise, nil), reject: reject(promise, nil))
|
|
45
|
+
return promise
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// MARK: - Video
|
|
49
|
+
|
|
50
|
+
func compress(fileUrl: String, optionMap: AnyMap, onProgress: ((Double) -> Void)?, onDownloadProgress: ((Double) -> Void)?) throws -> Promise<String> {
|
|
51
|
+
let promise = Promise<String>()
|
|
52
|
+
let options = dictionary(from: optionMap)
|
|
53
|
+
let uuid = (options["uuid"] as? String) ?? ""
|
|
54
|
+
EventEmitterHandler.registerVideoCompressProgress(uuid: uuid, onProgress)
|
|
55
|
+
EventEmitterHandler.registerDownloadProgress(uuid: uuid, onDownloadProgress)
|
|
56
|
+
videoCompressor.compress(fileUrl: fileUrl, options: options, resolve: resolveString(promise, uuid), reject: reject(promise, uuid))
|
|
57
|
+
return promise
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func cancelCompression(uuid: String) throws {
|
|
61
|
+
videoCompressor.cancelCompression(uuid: uuid)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func getVideoMetaData(filePath: String) throws -> Promise<AnyMap> {
|
|
65
|
+
let promise = Promise<AnyMap>()
|
|
66
|
+
videoCompressor.getVideoMetaData(filePath, resolve: resolveAnyMap(promise, nil), reject: reject(promise, nil))
|
|
67
|
+
return promise
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func activateBackgroundTask(options: AnyMap, onExpired: (() -> Void)?) throws -> Promise<String> {
|
|
71
|
+
let promise = Promise<String>()
|
|
72
|
+
EventEmitterHandler.setBackgroundTaskExpiredCallback(onExpired)
|
|
73
|
+
videoCompressor.activateBackgroundTask(options: dictionary(from: options), resolve: resolveString(promise, nil), reject: reject(promise, nil))
|
|
74
|
+
return promise
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func deactivateBackgroundTask(options: AnyMap) throws -> Promise<String> {
|
|
78
|
+
let promise = Promise<String>()
|
|
79
|
+
EventEmitterHandler.setBackgroundTaskExpiredCallback(nil)
|
|
80
|
+
videoCompressor.deactivateBackgroundTask(options: dictionary(from: options), resolve: resolveString(promise, nil), reject: reject(promise, nil))
|
|
81
|
+
return promise
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// MARK: - Audio
|
|
85
|
+
|
|
86
|
+
func compress_audio(fileUrl: String, optionMap: AnyMap) throws -> Promise<String> {
|
|
87
|
+
let promise = Promise<String>()
|
|
88
|
+
AudioMain.compress_audio(fileUrl, optionMap: dictionary(from: optionMap) as NSDictionary, resolve: resolveString(promise, nil), reject: reject(promise, nil))
|
|
89
|
+
return promise
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// MARK: - Upload / Download
|
|
93
|
+
|
|
94
|
+
func upload(fileUrl: String, options: AnyMap, onProgress: ((Double, Double) -> Void)?) throws -> Promise<AnyMap> {
|
|
95
|
+
let promise = Promise<AnyMap>()
|
|
96
|
+
let dict = dictionary(from: options)
|
|
97
|
+
let uuid = (dict["uuid"] as? String) ?? ""
|
|
98
|
+
EventEmitterHandler.registerUploadProgress(uuid: uuid, onProgress)
|
|
99
|
+
uploader.upload(filePath: fileUrl, options: dict, resolve: resolveAnyMap(promise, uuid), reject: reject(promise, uuid))
|
|
100
|
+
return promise
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func cancelUpload(uuid: String, shouldCancelAll: Bool) throws {
|
|
104
|
+
uploader.cancelUpload(uuid: uuid, shouldCancelAll: shouldCancelAll)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func download(fileUrl: String, options: AnyMap, onProgress: ((Double) -> Void)?) throws -> Promise<String> {
|
|
108
|
+
let promise = Promise<String>()
|
|
109
|
+
let dict = dictionary(from: options)
|
|
110
|
+
let uuid = (dict["uuid"] as? String) ?? ""
|
|
111
|
+
let progressDivider = (dict["progressDivider"] as? NSNumber)?.intValue ?? 0
|
|
112
|
+
EventEmitterHandler.registerDownloadProgress(uuid: uuid, onProgress)
|
|
113
|
+
Downloader.downloadFileAndSaveToCache(fileUrl, uuid: uuid, progressDivider: progressDivider) { downloadedPath in
|
|
114
|
+
EventEmitterHandler.unregister(uuid: uuid)
|
|
115
|
+
promise.resolve(withResult: downloadedPath)
|
|
116
|
+
}
|
|
117
|
+
return promise
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// MARK: - Others
|
|
121
|
+
|
|
122
|
+
func generateFilePath(fileExtension: String) throws -> Promise<String> {
|
|
123
|
+
let promise = Promise<String>()
|
|
124
|
+
Utils.generateFilePath(fileExtension, resolve: resolveString(promise, nil), reject: reject(promise, nil))
|
|
125
|
+
return promise
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func getRealPath(path: String, type: String) throws -> Promise<String> {
|
|
129
|
+
let promise = Promise<String>()
|
|
130
|
+
Utils.getRealPath(path, type: type, resolve: resolveString(promise, nil), reject: reject(promise, nil))
|
|
131
|
+
return promise
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func getFileSize(filePath: String) throws -> Promise<String> {
|
|
135
|
+
let promise = Promise<String>()
|
|
136
|
+
Utils.getFileSize(filePath, resolve: resolveString(promise, nil), reject: reject(promise, nil))
|
|
137
|
+
return promise
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func createVideoThumbnail(fileUrl: String, options: AnyMap) throws -> Promise<VideoThumbnailResult> {
|
|
141
|
+
let promise = Promise<VideoThumbnailResult>()
|
|
142
|
+
let thumbnail = CreateVideoThumbnail()
|
|
143
|
+
thumbnail.create(fileUrl, options: dictionary(from: options) as NSDictionary, resolve: { value in
|
|
144
|
+
let dict = HybridCompressor.stringKeyedDictionary(value)
|
|
145
|
+
let result = VideoThumbnailResult(
|
|
146
|
+
path: dict["path"] as? String ?? "",
|
|
147
|
+
size: HybridCompressor.doubleValue(dict["size"]),
|
|
148
|
+
mime: dict["mime"] as? String ?? "",
|
|
149
|
+
width: HybridCompressor.doubleValue(dict["width"]),
|
|
150
|
+
height: HybridCompressor.doubleValue(dict["height"])
|
|
151
|
+
)
|
|
152
|
+
promise.resolve(withResult: result)
|
|
153
|
+
}, rejecter: reject(promise, nil))
|
|
154
|
+
return promise
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func clearCache(cacheDir: String?) throws -> Promise<String> {
|
|
158
|
+
let promise = Promise<String>()
|
|
159
|
+
CreateVideoThumbnail.cleanCacheDir(cacheDir: cacheDir ?? "", resolve: resolveString(promise, nil), rejecter: reject(promise, nil))
|
|
160
|
+
return promise
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// MARK: - Helpers
|
|
164
|
+
|
|
165
|
+
/// Convert a Nitro `AnyMap` to a `[String: Any]`, round-tripping through
|
|
166
|
+
/// `NSDictionary` so numbers are boxed as `NSNumber` (the domain parsers rely
|
|
167
|
+
/// on `as? Int` / `as? Bool`, which fail on a bare Swift `Double`).
|
|
168
|
+
private func dictionary(from map: AnyMap) -> [String: Any] {
|
|
169
|
+
let normalized = (HybridCompressor.normalize(map.toDictionary()) as? [String: Any]) ?? [:]
|
|
170
|
+
return ((normalized as NSDictionary) as? [String: Any]) ?? normalized
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private func resolveString(_ promise: Promise<String>, _ uuid: String?) -> RCTPromiseResolveBlock {
|
|
174
|
+
return { value in
|
|
175
|
+
if let uuid = uuid { EventEmitterHandler.unregister(uuid: uuid) }
|
|
176
|
+
promise.resolve(withResult: value as? String ?? "")
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private func resolveAnyMap(_ promise: Promise<AnyMap>, _ uuid: String?) -> RCTPromiseResolveBlock {
|
|
181
|
+
return { value in
|
|
182
|
+
if let uuid = uuid { EventEmitterHandler.unregister(uuid: uuid) }
|
|
183
|
+
promise.resolve(withResult: AnyMap.fromDictionaryIgnoreIncompatible(HybridCompressor.stringKeyedOptionalDictionary(value)))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private func reject<T>(_ promise: Promise<T>, _ uuid: String?) -> RCTPromiseRejectBlock {
|
|
188
|
+
return { code, message, error in
|
|
189
|
+
if let uuid = uuid { EventEmitterHandler.unregister(uuid: uuid) }
|
|
190
|
+
let nsError = error ?? RuntimeError.error(withMessage: message ?? code ?? "react-native-compressor error")
|
|
191
|
+
promise.reject(withError: nsError)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Recursively strip optionals so a `[String: Any?]` (what `AnyMap.toDictionary()`
|
|
196
|
+
/// returns, including nested objects/arrays) becomes a plain `[String: Any]`.
|
|
197
|
+
private static func normalize(_ value: Any?) -> Any? {
|
|
198
|
+
switch value {
|
|
199
|
+
case let dict as [String: Any?]:
|
|
200
|
+
var out = [String: Any]()
|
|
201
|
+
for (key, nested) in dict {
|
|
202
|
+
if let nestedValue = normalize(nested) { out[key] = nestedValue }
|
|
203
|
+
}
|
|
204
|
+
return out
|
|
205
|
+
case let array as [Any?]:
|
|
206
|
+
return array.compactMap { normalize($0) }
|
|
207
|
+
default:
|
|
208
|
+
return value
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private static func stringKeyedDictionary(_ value: Any?) -> [String: Any] {
|
|
213
|
+
if let dict = value as? [String: Any] { return dict }
|
|
214
|
+
if let ns = value as? NSDictionary {
|
|
215
|
+
var out = [String: Any]()
|
|
216
|
+
for (key, val) in ns where key is String {
|
|
217
|
+
out[key as! String] = val
|
|
218
|
+
}
|
|
219
|
+
return out
|
|
220
|
+
}
|
|
221
|
+
return [:]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private static func stringKeyedOptionalDictionary(_ value: Any?) -> [String: Any?] {
|
|
225
|
+
if let ns = value as? NSDictionary {
|
|
226
|
+
var out = [String: Any?]()
|
|
227
|
+
for (key, val) in ns where key is String {
|
|
228
|
+
out[key as! String] = val
|
|
229
|
+
}
|
|
230
|
+
return out
|
|
231
|
+
}
|
|
232
|
+
if let dict = value as? [String: Any] {
|
|
233
|
+
var out = [String: Any?]()
|
|
234
|
+
for (key, val) in dict { out[key] = val }
|
|
235
|
+
return out
|
|
236
|
+
}
|
|
237
|
+
return [:]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private static func doubleValue(_ value: Any?) -> Double {
|
|
241
|
+
if let number = value as? NSNumber { return number.doubleValue }
|
|
242
|
+
if let double = value as? Double { return double }
|
|
243
|
+
if let float = value as? Float { return Double(float) }
|
|
244
|
+
if let int = value as? Int { return Double(int) }
|
|
245
|
+
return 0
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import Accelerate
|
|
2
2
|
import CoreGraphics
|
|
3
3
|
import Photos
|
|
4
|
-
import React
|
|
5
4
|
import Foundation
|
|
6
5
|
import MobileCoreServices
|
|
7
6
|
|
|
8
7
|
|
|
8
|
+
// Local stand-in for the single `RCTResizeMode` case this file relies on, so the
|
|
9
|
+
// module no longer needs to `import React` (see HybridCompressor for the reason).
|
|
10
|
+
private enum CompressorResizeMode {
|
|
11
|
+
case contain
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
9
15
|
class ImageCompressor {
|
|
10
16
|
static func findTargetSize(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> CGSize {
|
|
11
17
|
let width = image.size.width
|
|
@@ -392,7 +398,7 @@ class ImageCompressor {
|
|
|
392
398
|
let imageURL = URL(string: imagePath.replacingOccurrences(of: " ", with: "%20"))
|
|
393
399
|
var size = CGSize.zero
|
|
394
400
|
var scale: CGFloat = 1
|
|
395
|
-
var resizeMode =
|
|
401
|
+
var resizeMode = CompressorResizeMode.contain
|
|
396
402
|
let assetID = imagePath.replacingOccurrences(of: "ph://", with: "")
|
|
397
403
|
let results = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil)
|
|
398
404
|
|
|
@@ -6,49 +6,71 @@
|
|
|
6
6
|
//
|
|
7
7
|
|
|
8
8
|
import Foundation
|
|
9
|
+
import UIKit
|
|
9
10
|
|
|
11
|
+
/// Routes native progress emissions to the per-call JS callbacks that
|
|
12
|
+
/// `HybridCompressor` registers. This replaces the old `RCTEventEmitter` bridge:
|
|
13
|
+
/// Nitro delivers progress through callback parameters, so the domain layer's
|
|
14
|
+
/// `emit*` calls (unchanged) are dispatched to the callback registered under the
|
|
15
|
+
/// same `uuid` that the JS layer threads through the options map.
|
|
10
16
|
class EventEmitterHandler {
|
|
11
|
-
static
|
|
12
|
-
static var
|
|
13
|
-
|
|
14
|
-
static
|
|
15
|
-
|
|
17
|
+
private static let lock = NSLock()
|
|
18
|
+
private static var videoCompressProgressCallbacks = [String: (Double) -> Void]()
|
|
19
|
+
private static var downloadProgressCallbacks = [String: (Double) -> Void]()
|
|
20
|
+
private static var uploadProgressCallbacks = [String: (Double, Double) -> Void]()
|
|
21
|
+
private static var backgroundTaskExpiredCallback: (() -> Void)?
|
|
22
|
+
|
|
23
|
+
// MARK: - Registration (called by HybridCompressor)
|
|
24
|
+
|
|
25
|
+
static func registerVideoCompressProgress(uuid: String, _ callback: ((Double) -> Void)?) {
|
|
26
|
+
guard let callback = callback else { return }
|
|
27
|
+
lock.lock(); defer { lock.unlock() }
|
|
28
|
+
videoCompressProgressCallbacks[uuid] = callback
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static func registerDownloadProgress(uuid: String, _ callback: ((Double) -> Void)?) {
|
|
32
|
+
guard let callback = callback else { return }
|
|
33
|
+
lock.lock(); defer { lock.unlock() }
|
|
34
|
+
downloadProgressCallbacks[uuid] = callback
|
|
16
35
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
36
|
+
|
|
37
|
+
static func registerUploadProgress(uuid: String, _ callback: ((Double, Double) -> Void)?) {
|
|
38
|
+
guard let callback = callback else { return }
|
|
39
|
+
lock.lock(); defer { lock.unlock() }
|
|
40
|
+
uploadProgressCallbacks[uuid] = callback
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static func unregister(uuid: String) {
|
|
44
|
+
lock.lock(); defer { lock.unlock() }
|
|
45
|
+
videoCompressProgressCallbacks[uuid] = nil
|
|
46
|
+
downloadProgressCallbacks[uuid] = nil
|
|
47
|
+
uploadProgressCallbacks[uuid] = nil
|
|
21
48
|
}
|
|
22
49
|
|
|
23
|
-
static func
|
|
24
|
-
|
|
50
|
+
static func setBackgroundTaskExpiredCallback(_ callback: (() -> Void)?) {
|
|
51
|
+
lock.lock(); defer { lock.unlock() }
|
|
52
|
+
backgroundTaskExpiredCallback = callback
|
|
25
53
|
}
|
|
26
|
-
|
|
54
|
+
|
|
55
|
+
// MARK: - Emission (called by the domain layer — method names preserved)
|
|
56
|
+
|
|
27
57
|
static func emitDownloadProgress(_ progress: NSNumber, uuid: String) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
params["data"] = data
|
|
33
|
-
|
|
34
|
-
(sharedCompressorObject as AnyObject).sendEvent(withName: "downloadProgress", body: params)
|
|
35
|
-
}
|
|
36
|
-
|
|
58
|
+
lock.lock(); let callback = downloadProgressCallbacks[uuid]; lock.unlock()
|
|
59
|
+
callback?(progress.doubleValue)
|
|
60
|
+
}
|
|
61
|
+
|
|
37
62
|
static func emitVideoCompressProgress(_ progress: Float, uuid: String) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
63
|
+
lock.lock(); let callback = videoCompressProgressCallbacks[uuid]; lock.unlock()
|
|
64
|
+
callback?(Double(progress))
|
|
41
65
|
}
|
|
42
|
-
|
|
66
|
+
|
|
43
67
|
static func emituploadProgress(_ uuid: String, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
(sharedCompressorObject as AnyObject).sendEvent(withName: "backgroundTaskExpired", body: ["backgroundTaskId": backgroundTaskId])
|
|
52
|
-
}
|
|
68
|
+
lock.lock(); let callback = uploadProgressCallbacks[uuid]; lock.unlock()
|
|
69
|
+
callback?(Double(totalBytesSent), Double(totalBytesExpectedToSend))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static func emitBackgroundTaskExpired(_ backgroundTaskId: UIBackgroundTaskIdentifier) {
|
|
73
|
+
lock.lock(); let callback = backgroundTaskExpiredCallback; lock.unlock()
|
|
74
|
+
callback?()
|
|
53
75
|
}
|
|
54
76
|
}
|
|
@@ -13,7 +13,9 @@ public enum NextLevelSessionExporterError: Error, CustomStringConvertible {
|
|
|
13
13
|
case readingFailure
|
|
14
14
|
case writingFailure
|
|
15
15
|
case cancelled
|
|
16
|
-
|
|
16
|
+
case unsupportedVideoOutputConfiguration
|
|
17
|
+
case missingVideoTrackInOutput
|
|
18
|
+
|
|
17
19
|
public var description: String {
|
|
18
20
|
get {
|
|
19
21
|
switch self {
|
|
@@ -25,6 +27,10 @@ public enum NextLevelSessionExporterError: Error, CustomStringConvertible {
|
|
|
25
27
|
return "Writing failure"
|
|
26
28
|
case .cancelled:
|
|
27
29
|
return "Cancelled"
|
|
30
|
+
case .unsupportedVideoOutputConfiguration:
|
|
31
|
+
return "The writer rejected the video output configuration"
|
|
32
|
+
case .missingVideoTrackInOutput:
|
|
33
|
+
return "Export finished without a video track in the output"
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
36
|
}
|
|
@@ -249,7 +255,16 @@ extension NextLevelSessionExporter {
|
|
|
249
255
|
}
|
|
250
256
|
}
|
|
251
257
|
|
|
252
|
-
|
|
258
|
+
// Fail loudly when the writer rejects the video output configuration.
|
|
259
|
+
// Continuing here used to export only the audio track and still resolve
|
|
260
|
+
// as a success (issue #400).
|
|
261
|
+
guard self.setupVideoOutput(withAsset: asset) else {
|
|
262
|
+
DispatchQueue.main.async {
|
|
263
|
+
self._completionHandler?(.failure(NextLevelSessionExporterError.unsupportedVideoOutputConfiguration))
|
|
264
|
+
self._completionHandler = nil
|
|
265
|
+
}
|
|
266
|
+
return
|
|
267
|
+
}
|
|
253
268
|
if !self.stripAudio {
|
|
254
269
|
self.setupAudioOutput(withAsset: asset)
|
|
255
270
|
self.setupAudioInput()
|
|
@@ -316,11 +331,14 @@ extension NextLevelSessionExporter {
|
|
|
316
331
|
|
|
317
332
|
extension NextLevelSessionExporter {
|
|
318
333
|
|
|
319
|
-
|
|
334
|
+
/// Returns `false` when the asset has video tracks but a video writer input
|
|
335
|
+
/// could not be created — exporting would silently produce an audio-only
|
|
336
|
+
/// file (see numandev1/react-native-compressor#400).
|
|
337
|
+
private func setupVideoOutput(withAsset asset: AVAsset) -> Bool {
|
|
320
338
|
let videoTracks = asset.tracks(withMediaType: AVMediaType.video)
|
|
321
|
-
|
|
339
|
+
|
|
322
340
|
guard videoTracks.count > 0 else {
|
|
323
|
-
return
|
|
341
|
+
return true
|
|
324
342
|
}
|
|
325
343
|
|
|
326
344
|
self._videoOutput = AVAssetReaderVideoCompositionOutput(videoTracks: videoTracks, videoSettings: self.videoInputConfiguration)
|
|
@@ -344,8 +362,8 @@ extension NextLevelSessionExporter {
|
|
|
344
362
|
self._videoInput = AVAssetWriterInput(mediaType: AVMediaType.video, outputSettings: self.videoOutputConfiguration)
|
|
345
363
|
self._videoInput?.expectsMediaDataInRealTime = self.expectsMediaDataInRealTime
|
|
346
364
|
} else {
|
|
347
|
-
print("
|
|
348
|
-
return
|
|
365
|
+
print("NextLevelSessionExporter, unsupported video output configuration, failing export instead of writing an audio-only file")
|
|
366
|
+
return false
|
|
349
367
|
}
|
|
350
368
|
|
|
351
369
|
if let writer = self._writer,
|
|
@@ -367,8 +385,9 @@ extension NextLevelSessionExporter {
|
|
|
367
385
|
|
|
368
386
|
self._pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: videoInput, sourcePixelBufferAttributes: pixelBufferAttrib)
|
|
369
387
|
}
|
|
388
|
+
return true
|
|
370
389
|
}
|
|
371
|
-
|
|
390
|
+
|
|
372
391
|
private func setupAudioOutput(withAsset asset: AVAsset) {
|
|
373
392
|
let audioTracks = asset.tracks(withMediaType: AVMediaType.audio)
|
|
374
393
|
var audioTracksToUse: [AVAssetTrack] = []
|
|
@@ -632,6 +651,22 @@ extension NextLevelSessionExporter {
|
|
|
632
651
|
break
|
|
633
652
|
}
|
|
634
653
|
|
|
654
|
+
// Guard against silently returning an audio-only file: when the source
|
|
655
|
+
// has a video track and a video output was configured, the exported file
|
|
656
|
+
// must contain a video track as well. A configuration the encoder rejects
|
|
657
|
+
// at write time can still end with `.completed` while the video track is
|
|
658
|
+
// dropped (issue #400) — surface that as an error instead of a success.
|
|
659
|
+
if self.videoOutputConfiguration != nil,
|
|
660
|
+
let asset = self.asset,
|
|
661
|
+
let outputURL = self.outputURL,
|
|
662
|
+
asset.tracks(withMediaType: AVMediaType.video).count > 0,
|
|
663
|
+
AVAsset(url: outputURL).tracks(withMediaType: AVMediaType.video).count == 0 {
|
|
664
|
+
try? FileManager.default.removeItem(at: outputURL)
|
|
665
|
+
self._completionHandler?(.failure(NextLevelSessionExporterError.missingVideoTrackInOutput))
|
|
666
|
+
self._completionHandler = nil
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
|
|
635
670
|
self._completionHandler?(.success(self.status))
|
|
636
671
|
self._completionHandler = nil
|
|
637
672
|
}
|