react-native-video-trim 8.1.1 → 8.1.3
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/README.md +16 -0
- package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +103 -91
- package/android/src/main/java/com/videotrim/enums/ErrorCode.kt +1 -0
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +280 -116
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +4 -5
- package/ios/ErrorCode.swift +1 -0
- package/ios/VideoTrim.swift +34 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -567,6 +567,7 @@ These options only apply when `type: 'audio'`. The waveform replaces the thumbna
|
|
|
567
567
|
| Option | Type | Default | Description |
|
|
568
568
|
|--------|------|---------|-------------|
|
|
569
569
|
| `enableHapticFeedback` | `boolean` | `true` | Enable haptic feedback |
|
|
570
|
+
| `enableEditTools` | `boolean` | `true` | Show the top toolbar of edit tools (flip, rotate, crop, mute, speed, undo, redo). Set to `false` to hide the entire toolbar. Video only |
|
|
570
571
|
| `closeWhenFinish` | `boolean` | `true` | Close editor when done |
|
|
571
572
|
| `enablePreciseTrimming` | `boolean` | `false` | Re-encode for frame-accurate cuts (slower, see [Precise Frame Trimming](#precise-frame-trimming)) |
|
|
572
573
|
| `changeStatusBarColorOnOpen` | `boolean` | `false` | Change status bar color (Android only) |
|
|
@@ -760,6 +761,21 @@ The editor includes built-in transform controls — horizontal flip, 90° left r
|
|
|
760
761
|
|
|
761
762
|
When any transform is applied, FFmpeg automatically re-encodes the video using the platform's hardware encoder (`h264_videotoolbox` on iOS, `h264_mediacodec` on Android) at the source bitrate to preserve quality. No additional configuration is needed.
|
|
762
763
|
|
|
764
|
+
### Android encoder compatibility (auto fallback)
|
|
765
|
+
|
|
766
|
+
On Android, a small number of devices ship a hardware H.264 encoder (`h264_mediacodec`) that refuses to configure for valid H.264 inputs — typically with `MediaCodec configure failed, Generic error in an external library` (e.g. LG G8 ThinQ on Snapdragon 855, and other older Qualcomm/MediaTek chipsets). This affects only the re-encode path (transform / crop / `enablePreciseTrimming` / non-`1.0` speed); plain trims use stream copy and never touch an encoder.
|
|
767
|
+
|
|
768
|
+
The library handles this automatically with a two-step encoder fallback chain:
|
|
769
|
+
|
|
770
|
+
1. `h264_mediacodec` — hardware H.264, fast, default. Used on every device first.
|
|
771
|
+
2. `mpeg4 -q:v 3` — software MPEG-4 Part 2, always available. Only used if attempt 1 fails at `configure()` with a hardware-encoder signature. Lower visual quality and larger files than H.264, but guaranteed to work.
|
|
772
|
+
|
|
773
|
+
No configuration is needed — the fallback is transparent. When a retry happens, a notice is emitted via the existing `onLog` event (`Hardware encoder failed; retrying with software encoder fallback`) so you can observe quality degradation if you want to log it.
|
|
774
|
+
|
|
775
|
+
If every attempt in the chain fails, `onError` is emitted with `errorCode: "HARDWARE_ENCODER_FAILED"` (instead of the generic `"TRIMMING_FAILED"`) so apps can present a more specific message or telemetry.
|
|
776
|
+
|
|
777
|
+
> iOS does not need this fallback — `h264_videotoolbox` runs against Apple's single-vendor stack and has no known reproducible configure-time failures on supported devices. The `HARDWARE_ENCODER_FAILED` `errorCode` is still emitted on iOS as a defensive measure if the rare VideoToolbox failure does occur.
|
|
778
|
+
|
|
763
779
|
### Precise Frame Trimming
|
|
764
780
|
|
|
765
781
|
By default, trimming uses FFmpeg's stream copy (`-c copy`), which is very fast but can only cut at keyframes. The actual start/end points may drift by several seconds from what the user selected.
|
|
@@ -634,111 +634,123 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
634
634
|
options.getBoolean("enablePreciseTrimming")
|
|
635
635
|
val needsReEncode = enablePrecise || speed != 1.0
|
|
636
636
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
637
|
+
// Headless trim still benefits from the encoder fallback chain whenever it
|
|
638
|
+
// re-encodes (hardware encoder failures are device-specific, not path-specific).
|
|
639
|
+
// No-op log/stats/progress callbacks because headless trim returns via Promise
|
|
640
|
+
// rather than emitting events.
|
|
641
|
+
val onSuccess: () -> Unit = {
|
|
642
|
+
val duration = endTime - startTime
|
|
643
|
+
val result = Arguments.createMap()
|
|
644
|
+
result.putString("outputPath", resolvedOutputFile)
|
|
645
|
+
result.putDouble("startTime", startTime)
|
|
646
|
+
result.putDouble("endTime", endTime)
|
|
647
|
+
result.putDouble("duration", duration)
|
|
648
|
+
result.putBoolean("success", true)
|
|
649
|
+
|
|
650
|
+
if (options?.getBoolean("saveToPhoto") == true && options.getString("type") == "video") {
|
|
651
|
+
Log.d(TAG, "Android trim: saveToPhoto is true, attempting to save to gallery")
|
|
652
|
+
try {
|
|
653
|
+
StorageUtil.saveToGallery(reactApplicationContext, resolvedOutputFile)
|
|
654
|
+
Log.d(TAG, "Edited video saved to Photo Library successfully.")
|
|
655
|
+
if (options.getBoolean("removeAfterSavedToPhoto")) {
|
|
656
|
+
Log.d(TAG, "Removing file after successful save to photo")
|
|
657
|
+
StorageUtil.deleteFile(resolvedOutputFile)
|
|
658
|
+
}
|
|
659
|
+
promise.resolve(result)
|
|
660
|
+
} catch (e: IOException) {
|
|
661
|
+
e.printStackTrace()
|
|
662
|
+
if (options.getBoolean("removeAfterFailedToSavePhoto")) {
|
|
663
|
+
Log.d(TAG, "Removing file after failed save to photo")
|
|
664
|
+
StorageUtil.deleteFile(resolvedOutputFile)
|
|
665
|
+
}
|
|
666
|
+
promise.reject(
|
|
667
|
+
Exception("Failed to save edited video to Photo Library: " + e.localizedMessage)
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
Log.d(TAG, "Android trim: saveToPhoto is false or not video type, resolving with structured result")
|
|
672
|
+
promise.resolve(result)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
val callbacks = VideoTrimmerUtil.TrimCallbacks(
|
|
677
|
+
onLog = { msg -> msg.getString("message")?.let { Log.d(TAG, it) } },
|
|
678
|
+
onStatistics = { /* headless trim does not surface statistics */ },
|
|
679
|
+
onProgress = { /* headless trim does not surface progress */ },
|
|
680
|
+
onSuccess = onSuccess,
|
|
681
|
+
onCancel = {
|
|
682
|
+
println("FFmpeg command was cancelled")
|
|
683
|
+
promise.reject(Exception("FFmpeg command was cancelled"))
|
|
684
|
+
},
|
|
685
|
+
onError = { errorMessage, _ ->
|
|
686
|
+
Log.d(TAG, errorMessage)
|
|
687
|
+
promise.reject(Exception(errorMessage))
|
|
688
|
+
},
|
|
642
689
|
)
|
|
643
690
|
|
|
644
|
-
if (needsReEncode) {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
691
|
+
if (!needsReEncode) {
|
|
692
|
+
// Stream copy: no encoder is opened so the fallback chain doesn't apply.
|
|
693
|
+
// -y overwrites any pre-existing output file without prompting.
|
|
694
|
+
val cmds = mutableListOf(
|
|
695
|
+
"-y",
|
|
696
|
+
"-ss", "${startTime}ms",
|
|
697
|
+
"-to", "${endTime}ms",
|
|
698
|
+
"-i", url,
|
|
699
|
+
)
|
|
700
|
+
if (removeAudio) {
|
|
701
|
+
cmds.addAll(listOf("-c:v", "copy", "-an"))
|
|
702
|
+
} else {
|
|
703
|
+
cmds.addAll(listOf("-c", "copy"))
|
|
704
|
+
}
|
|
705
|
+
cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", resolvedOutputFile))
|
|
706
|
+
VideoTrimmerUtil.executeWithEncoderFallback(
|
|
707
|
+
encoderConfigs = listOf(emptyList()),
|
|
708
|
+
buildCommand = { cmds.toTypedArray() },
|
|
709
|
+
videoDurationMs = 0,
|
|
710
|
+
callbacks = callbacks,
|
|
711
|
+
)
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
var bitrateStr = "10M"
|
|
716
|
+
try {
|
|
717
|
+
val retriever = MediaMetadataRetriever()
|
|
718
|
+
retriever.setDataSource(reactApplicationContext, Uri.parse(url))
|
|
719
|
+
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
|
720
|
+
?.toLongOrNull() ?: 0L
|
|
721
|
+
if (bitrate > 0) bitrateStr = "$bitrate"
|
|
722
|
+
retriever.release()
|
|
723
|
+
} catch (_: Exception) {}
|
|
654
724
|
|
|
655
|
-
|
|
725
|
+
val buildCommand: (List<String>) -> Array<String> = { encoderArgs ->
|
|
726
|
+
// -y overwrites the output file without prompting, so the encoder fallback
|
|
727
|
+
// chain's software retry can reuse the same output path left behind by the
|
|
728
|
+
// failed hardware attempt instead of aborting on FFmpeg's overwrite prompt.
|
|
729
|
+
val cmds = mutableListOf(
|
|
730
|
+
"-y",
|
|
731
|
+
"-ss", "${startTime}ms",
|
|
732
|
+
"-to", "${endTime}ms",
|
|
733
|
+
"-i", url,
|
|
734
|
+
)
|
|
656
735
|
if (speed != 1.0) {
|
|
657
736
|
cmds.addAll(listOf("-vf", "setpts=${1.0 / speed}*PTS"))
|
|
658
737
|
}
|
|
659
|
-
cmds.addAll(
|
|
738
|
+
cmds.addAll(encoderArgs)
|
|
660
739
|
when {
|
|
661
740
|
removeAudio -> cmds.add("-an")
|
|
662
741
|
speed != 1.0 -> cmds.addAll(listOf("-af", VideoTrimmerUtil.buildAtempoChain(speed)))
|
|
663
742
|
else -> cmds.addAll(listOf("-c:a", "copy"))
|
|
664
743
|
}
|
|
665
744
|
cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", resolvedOutputFile))
|
|
666
|
-
|
|
667
|
-
cmds.addAll(listOf("-i", url))
|
|
668
|
-
if (removeAudio) {
|
|
669
|
-
cmds.addAll(listOf("-c:v", "copy", "-an"))
|
|
670
|
-
} else {
|
|
671
|
-
cmds.addAll(listOf("-c", "copy"))
|
|
672
|
-
}
|
|
673
|
-
cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", resolvedOutputFile))
|
|
745
|
+
cmds.toTypedArray()
|
|
674
746
|
}
|
|
675
747
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
when {
|
|
683
|
-
ReturnCode.isSuccess(returnCode) -> {
|
|
684
|
-
val duration = endTime - startTime
|
|
685
|
-
val result = Arguments.createMap()
|
|
686
|
-
|
|
687
|
-
result.putString("outputPath", resolvedOutputFile)
|
|
688
|
-
result.putDouble("startTime", startTime)
|
|
689
|
-
result.putDouble("endTime", endTime)
|
|
690
|
-
result.putDouble("duration", duration)
|
|
691
|
-
result.putBoolean("success", true)
|
|
692
|
-
|
|
693
|
-
if (options?.getBoolean("saveToPhoto") == true && options.getString("type") == "video") {
|
|
694
|
-
Log.d(TAG, "Android trim: saveToPhoto is true, attempting to save to gallery")
|
|
695
|
-
try {
|
|
696
|
-
StorageUtil.saveToGallery(reactApplicationContext, resolvedOutputFile)
|
|
697
|
-
Log.d(TAG, "Edited video saved to Photo Library successfully.")
|
|
698
|
-
if (options.getBoolean("removeAfterSavedToPhoto")) {
|
|
699
|
-
Log.d(TAG, "Removing file after successful save to photo")
|
|
700
|
-
StorageUtil.deleteFile(resolvedOutputFile)
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
promise.resolve(result)
|
|
704
|
-
} catch (e: IOException) {
|
|
705
|
-
e.printStackTrace()
|
|
706
|
-
|
|
707
|
-
if (options.getBoolean("removeAfterFailedToSavePhoto")) {
|
|
708
|
-
Log.d(TAG, "Removing file after failed save to photo")
|
|
709
|
-
StorageUtil.deleteFile(resolvedOutputFile)
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
promise.reject(
|
|
713
|
-
Exception("Failed to save edited video to Photo Library: " + e.localizedMessage)
|
|
714
|
-
)
|
|
715
|
-
}
|
|
716
|
-
} else {
|
|
717
|
-
Log.d(TAG, "Android trim: saveToPhoto is false or not video type, resolving with structured result")
|
|
718
|
-
|
|
719
|
-
promise.resolve(result)
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
ReturnCode.isCancel(returnCode) -> {
|
|
723
|
-
println("FFmpeg command was cancelled")
|
|
724
|
-
promise.reject(
|
|
725
|
-
Exception("FFmpeg command was cancelled with code $returnCode")
|
|
726
|
-
)
|
|
727
|
-
}
|
|
728
|
-
else -> {
|
|
729
|
-
val errorMessage = String.format("Command failed with state %s and rc %s.%s", state, returnCode, session.getFailStackTrace())
|
|
730
|
-
Log.d(TAG, errorMessage)
|
|
731
|
-
promise.reject(
|
|
732
|
-
Exception(errorMessage)
|
|
733
|
-
)
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
}, { log ->
|
|
738
|
-
Log.d(TAG, "FFmpeg process started with log ${log.message}")
|
|
739
|
-
}, { statistics ->
|
|
740
|
-
// Handle statistics if needed
|
|
741
|
-
})
|
|
748
|
+
VideoTrimmerUtil.executeWithEncoderFallback(
|
|
749
|
+
encoderConfigs = VideoTrimmerUtil.reEncodeEncoderConfigs(bitrateStr),
|
|
750
|
+
buildCommand = buildCommand,
|
|
751
|
+
videoDurationMs = 0,
|
|
752
|
+
callbacks = callbacks,
|
|
753
|
+
)
|
|
742
754
|
}
|
|
743
755
|
|
|
744
756
|
// Extracts a single video frame as JPEG/PNG. Output goes to the cache directory.
|
|
@@ -10,6 +10,7 @@ import com.arthenica.ffmpegkit.FFmpegSession
|
|
|
10
10
|
import com.arthenica.ffmpegkit.ReturnCode
|
|
11
11
|
import com.facebook.react.bridge.Arguments
|
|
12
12
|
import com.facebook.react.bridge.UiThreadUtil
|
|
13
|
+
import com.facebook.react.bridge.WritableMap
|
|
13
14
|
import com.videotrim.enums.ErrorCode
|
|
14
15
|
import com.videotrim.interfaces.VideoTrimListener
|
|
15
16
|
import iknow.android.utils.DeviceUtil
|
|
@@ -21,6 +22,28 @@ import java.util.Date
|
|
|
21
22
|
import java.util.TimeZone
|
|
22
23
|
import kotlin.math.roundToInt
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Handle returned from FFmpeg trim invocations that own an encoder-fallback chain.
|
|
27
|
+
*
|
|
28
|
+
* A single trim request may span multiple FFmpeg sessions when the first encoder
|
|
29
|
+
* (typically [h264_mediacodec]) fails at configure-time on a quirky hardware
|
|
30
|
+
* encoder and we retry with a software fallback. The handle tracks the currently
|
|
31
|
+
* running session so callers can cancel without caring which attempt is active.
|
|
32
|
+
*/
|
|
33
|
+
class TrimSession {
|
|
34
|
+
@Volatile private var currentSession: FFmpegSession? = null
|
|
35
|
+
@Volatile internal var cancelled = false
|
|
36
|
+
|
|
37
|
+
internal fun setCurrentSession(session: FFmpegSession?) {
|
|
38
|
+
currentSession = session
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fun cancel() {
|
|
42
|
+
cancelled = true
|
|
43
|
+
currentSession?.cancel()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
object VideoTrimmerUtil {
|
|
25
48
|
|
|
26
49
|
private val TAG: String = VideoTrimmerUtil::class.java.simpleName
|
|
@@ -53,6 +76,186 @@ object VideoTrimmerUtil {
|
|
|
53
76
|
return filters.joinToString(",")
|
|
54
77
|
}
|
|
55
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Encoder fallback chain used when a re-encode is required. Tried in order
|
|
81
|
+
* until one succeeds at FFmpeg's configure() step.
|
|
82
|
+
*
|
|
83
|
+
* 1. `h264_mediacodec` — Android's hardware H.264 encoder. Fastest and the
|
|
84
|
+
* default. Fails on some quirky Qualcomm/MediaTek implementations with
|
|
85
|
+
* `MediaCodec configure failed` (see "Android encoder compatibility" in
|
|
86
|
+
* README).
|
|
87
|
+
* 2. `mpeg4` — software MPEG-4 Part 2 (Simple Profile). Always available in
|
|
88
|
+
* every FFmpegKit build, succeeds on every device. Produces larger files
|
|
89
|
+
* at lower visual quality than H.264, used only as a last resort when
|
|
90
|
+
* the hardware encoder rejects the configuration.
|
|
91
|
+
*
|
|
92
|
+
* The chain only matters for the re-encode branch (transform/crop/precise/
|
|
93
|
+
* speed); the default stream-copy path doesn't open an encoder at all.
|
|
94
|
+
*/
|
|
95
|
+
internal fun reEncodeEncoderConfigs(bitrateStr: String): List<List<String>> = listOf(
|
|
96
|
+
listOf("-c:v", "h264_mediacodec", "-b:v", bitrateStr),
|
|
97
|
+
listOf("-c:v", "mpeg4", "-q:v", "3")
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Scan an FFmpeg session's log for signatures that indicate the hardware
|
|
102
|
+
* H.264 encoder (MediaCodec) refused to configure on this device. These
|
|
103
|
+
* lines come from FFmpeg's mediacodec wrapper:
|
|
104
|
+
*
|
|
105
|
+
* `[amediacodec @ ...] android.media.MediaCodec$CodecException: Error 0xffffffc3`
|
|
106
|
+
* `[h264_mediacodec @ ...] MediaCodec configure failed, Generic error in an external library`
|
|
107
|
+
*
|
|
108
|
+
* When this happens we re-issue the command with the next encoder in the
|
|
109
|
+
* fallback chain instead of surfacing the failure to the user.
|
|
110
|
+
*/
|
|
111
|
+
internal fun classifyFFmpegError(session: FFmpegSession): ErrorCode {
|
|
112
|
+
val logs = try { session.allLogsAsString ?: "" } catch (_: Exception) { "" }
|
|
113
|
+
val hardwareEncoderSignals = listOf(
|
|
114
|
+
"MediaCodec configure failed",
|
|
115
|
+
"Error initializing output stream",
|
|
116
|
+
)
|
|
117
|
+
val matchedHardwareSignal = hardwareEncoderSignals.any { logs.contains(it) }
|
|
118
|
+
if (matchedHardwareSignal && logs.contains("mediacodec")) {
|
|
119
|
+
return ErrorCode.HARDWARE_ENCODER_FAILED
|
|
120
|
+
}
|
|
121
|
+
return ErrorCode.TRIMMING_FAILED
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Callbacks used by [executeWithEncoderFallback]. Per-attempt log and
|
|
126
|
+
* statistics are forwarded as-is; the success/cancel/error callbacks fire
|
|
127
|
+
* exactly once for the overall trim request after the fallback chain
|
|
128
|
+
* settles.
|
|
129
|
+
*/
|
|
130
|
+
internal class TrimCallbacks(
|
|
131
|
+
val onLog: (WritableMap) -> Unit,
|
|
132
|
+
val onStatistics: (WritableMap) -> Unit,
|
|
133
|
+
val onProgress: (Int) -> Unit,
|
|
134
|
+
val onSuccess: () -> Unit,
|
|
135
|
+
val onCancel: () -> Unit,
|
|
136
|
+
val onError: (String, ErrorCode) -> Unit,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Execute an FFmpeg trim command with an encoder-fallback chain.
|
|
141
|
+
*
|
|
142
|
+
* The command is built lazily per attempt by [buildCommand], which receives
|
|
143
|
+
* the current encoder args (e.g. `-c:v h264_mediacodec -b:v 8000000`) and
|
|
144
|
+
* returns the full argv. This lets each attempt swap only the encoder
|
|
145
|
+
* portion while reusing the same input/filter/output args.
|
|
146
|
+
*
|
|
147
|
+
* On non-success non-cancel completion, the session log is scanned via
|
|
148
|
+
* [classifyFFmpegError]. If it looks like a hardware encoder rejection and
|
|
149
|
+
* there are more encoders in the chain, we silently retry. Otherwise the
|
|
150
|
+
* error is surfaced via [TrimCallbacks.onError] with the classified code.
|
|
151
|
+
*
|
|
152
|
+
* Returns a [TrimSession] handle whose [TrimSession.cancel] cancels the
|
|
153
|
+
* currently running attempt and prevents any further attempts.
|
|
154
|
+
*/
|
|
155
|
+
internal fun executeWithEncoderFallback(
|
|
156
|
+
encoderConfigs: List<List<String>>,
|
|
157
|
+
buildCommand: (encoderArgs: List<String>) -> Array<String>,
|
|
158
|
+
videoDurationMs: Int,
|
|
159
|
+
callbacks: TrimCallbacks,
|
|
160
|
+
): TrimSession {
|
|
161
|
+
val trimSession = TrimSession()
|
|
162
|
+
runAttempt(trimSession, encoderConfigs.iterator(), buildCommand, videoDurationMs, callbacks)
|
|
163
|
+
return trimSession
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private fun runAttempt(
|
|
167
|
+
trimSession: TrimSession,
|
|
168
|
+
encoderIterator: Iterator<List<String>>,
|
|
169
|
+
buildCommand: (encoderArgs: List<String>) -> Array<String>,
|
|
170
|
+
videoDurationMs: Int,
|
|
171
|
+
callbacks: TrimCallbacks,
|
|
172
|
+
) {
|
|
173
|
+
if (trimSession.cancelled) {
|
|
174
|
+
UiThreadUtil.runOnUiThread { callbacks.onCancel() }
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (!encoderIterator.hasNext()) {
|
|
178
|
+
// Should be unreachable because we only retry on HARDWARE_ENCODER_FAILED;
|
|
179
|
+
// any non-retryable error is surfaced before exhausting the iterator.
|
|
180
|
+
UiThreadUtil.runOnUiThread {
|
|
181
|
+
callbacks.onError("Encoder fallback chain exhausted", ErrorCode.TRIMMING_FAILED)
|
|
182
|
+
}
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
val encoderArgs = encoderIterator.next()
|
|
187
|
+
val command = buildCommand(encoderArgs)
|
|
188
|
+
val cmdStr = "Command: ${command.joinToString(" ")}"
|
|
189
|
+
Log.d(TAG, cmdStr)
|
|
190
|
+
val cmdMap = Arguments.createMap()
|
|
191
|
+
cmdMap.putString("message", cmdStr)
|
|
192
|
+
callbacks.onLog(cmdMap)
|
|
193
|
+
|
|
194
|
+
val session = FFmpegKit.executeWithArgumentsAsync(command, { session ->
|
|
195
|
+
val state = session.state
|
|
196
|
+
val returnCode = session.returnCode
|
|
197
|
+
UiThreadUtil.runOnUiThread {
|
|
198
|
+
when {
|
|
199
|
+
ReturnCode.isSuccess(returnCode) -> callbacks.onSuccess()
|
|
200
|
+
ReturnCode.isCancel(returnCode) -> callbacks.onCancel()
|
|
201
|
+
else -> {
|
|
202
|
+
val classified = classifyFFmpegError(session)
|
|
203
|
+
if (classified == ErrorCode.HARDWARE_ENCODER_FAILED && encoderIterator.hasNext()) {
|
|
204
|
+
// Log fallback decision via the existing onLog channel so consumers
|
|
205
|
+
// can observe quality degradation without a new dedicated event.
|
|
206
|
+
val notice = Arguments.createMap()
|
|
207
|
+
notice.putString(
|
|
208
|
+
"message",
|
|
209
|
+
"Hardware encoder failed; retrying with software encoder fallback"
|
|
210
|
+
)
|
|
211
|
+
callbacks.onLog(notice)
|
|
212
|
+
runAttempt(trimSession, encoderIterator, buildCommand, videoDurationMs, callbacks)
|
|
213
|
+
} else {
|
|
214
|
+
val errorMessage =
|
|
215
|
+
"Command failed with state $state and rc $returnCode.${session.failStackTrace}"
|
|
216
|
+
callbacks.onError(errorMessage, classified)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}, { log ->
|
|
222
|
+
Log.d(TAG, "FFmpeg process started with log ${log.message}")
|
|
223
|
+
val map = Arguments.createMap()
|
|
224
|
+
map.putInt("level", log.level.value)
|
|
225
|
+
map.putString("message", log.message)
|
|
226
|
+
map.putDouble("sessionId", log.sessionId.toDouble())
|
|
227
|
+
map.putString("logStr", log.toString())
|
|
228
|
+
UiThreadUtil.runOnUiThread { callbacks.onLog(map) }
|
|
229
|
+
}, { statistics ->
|
|
230
|
+
val timeInMilliseconds = statistics.time.toInt()
|
|
231
|
+
if (timeInMilliseconds > 0 && videoDurationMs > 0) {
|
|
232
|
+
val completePercentage = (timeInMilliseconds * 100) / videoDurationMs
|
|
233
|
+
UiThreadUtil.runOnUiThread {
|
|
234
|
+
callbacks.onProgress(completePercentage.coerceIn(0, 100))
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
val map = Arguments.createMap()
|
|
238
|
+
map.putDouble("sessionId", statistics.sessionId.toDouble())
|
|
239
|
+
map.putInt("videoFrameNumber", statistics.videoFrameNumber)
|
|
240
|
+
map.putDouble("videoFps", statistics.videoFps.toDouble())
|
|
241
|
+
map.putDouble("videoQuality", statistics.videoQuality.toDouble())
|
|
242
|
+
map.putDouble("size", statistics.size.toDouble())
|
|
243
|
+
map.putDouble("time", statistics.time)
|
|
244
|
+
map.putDouble("bitrate", statistics.bitrate.toDouble())
|
|
245
|
+
map.putDouble("speed", statistics.speed.toDouble())
|
|
246
|
+
map.putString("statisticsStr", statistics.toString())
|
|
247
|
+
UiThreadUtil.runOnUiThread { callbacks.onStatistics(map) }
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
trimSession.setCurrentSession(session)
|
|
251
|
+
|
|
252
|
+
// If cancel arrived between the iterator check and session start, cancel
|
|
253
|
+
// immediately so the FFmpeg session won't run to completion.
|
|
254
|
+
if (trimSession.cancelled) {
|
|
255
|
+
session.cancel()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
56
259
|
fun trim(
|
|
57
260
|
inputFile: String,
|
|
58
261
|
outputFile: String,
|
|
@@ -69,154 +272,115 @@ object VideoTrimmerUtil {
|
|
|
69
272
|
removeAudio: Boolean,
|
|
70
273
|
speed: Double,
|
|
71
274
|
callback: VideoTrimListener
|
|
72
|
-
):
|
|
275
|
+
): TrimSession {
|
|
73
276
|
val currentDate = Date()
|
|
74
277
|
@SuppressLint("SimpleDateFormat")
|
|
75
278
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
|
|
76
279
|
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
|
|
77
280
|
val formattedDateTime = dateFormat.format(currentDate)
|
|
78
281
|
|
|
79
|
-
val cmds = mutableListOf<String>()
|
|
80
|
-
cmds.add("-ss")
|
|
81
|
-
cmds.add("${startMs}ms")
|
|
82
|
-
cmds.add("-to")
|
|
83
|
-
cmds.add("${endMs}ms")
|
|
84
|
-
|
|
85
282
|
val hasUserTransform = userRotationCount != 0 || userIsFlipped
|
|
86
283
|
// Re-encode is required when: (1) user applied flip/rotate, (2) user cropped, or
|
|
87
284
|
// (3) enablePreciseTrimming is on, or (4) speed != 1.0. In those cases, -c copy
|
|
88
285
|
// won't work because either we need video filters or we need frame-accurate cut points.
|
|
89
286
|
val needsReEncode = hasUserTransform || cropNormalized != null || enablePreciseTrimming || speed != 1.0
|
|
90
287
|
|
|
91
|
-
|
|
92
|
-
|
|
288
|
+
val callbacks = TrimCallbacks(
|
|
289
|
+
onLog = { callback.onLog(it) },
|
|
290
|
+
onStatistics = { callback.onStatistics(it) },
|
|
291
|
+
onProgress = { callback.onTrimmingProgress(it) },
|
|
292
|
+
onSuccess = { callback.onFinishTrim(outputFile, startMs, endMs, videoDuration) },
|
|
293
|
+
onCancel = { callback.onCancelTrim() },
|
|
294
|
+
onError = { msg, code -> callback.onError(msg, code) },
|
|
295
|
+
)
|
|
93
296
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
if (
|
|
100
|
-
|
|
297
|
+
if (!needsReEncode) {
|
|
298
|
+
// Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
|
|
299
|
+
// No encoder is opened so the fallback chain doesn't apply — single attempt.
|
|
300
|
+
// -y overwrites any pre-existing output file without prompting.
|
|
301
|
+
val cmds = mutableListOf("-y", "-ss", "${startMs}ms", "-to", "${endMs}ms", "-i", inputFile)
|
|
302
|
+
if (removeAudio) {
|
|
303
|
+
cmds.addAll(listOf("-c:v", "copy", "-an"))
|
|
304
|
+
} else {
|
|
305
|
+
cmds.addAll(listOf("-c", "copy"))
|
|
101
306
|
}
|
|
307
|
+
cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
|
|
308
|
+
return executeWithEncoderFallback(
|
|
309
|
+
encoderConfigs = listOf(emptyList()),
|
|
310
|
+
buildCommand = { cmds.toTypedArray() },
|
|
311
|
+
videoDurationMs = videoDuration,
|
|
312
|
+
callbacks = callbacks,
|
|
313
|
+
)
|
|
314
|
+
}
|
|
102
315
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
316
|
+
// Build the video filters once. They're encoder-independent.
|
|
317
|
+
val videoFilters = mutableListOf<String>()
|
|
318
|
+
when (userRotationCount) {
|
|
319
|
+
1 -> videoFilters.add("transpose=2")
|
|
320
|
+
2 -> { videoFilters.add("transpose=2"); videoFilters.add("transpose=2") }
|
|
321
|
+
3 -> videoFilters.add("transpose=1")
|
|
322
|
+
}
|
|
323
|
+
if (userIsFlipped) {
|
|
324
|
+
videoFilters.add("hflip")
|
|
325
|
+
}
|
|
326
|
+
// Convert normalized crop rect [0..1] to pixel coordinates in the post-rotation frame.
|
|
327
|
+
if (cropNormalized != null && videoWidth > 0 && videoHeight > 0) {
|
|
328
|
+
val postW: Int
|
|
329
|
+
val postH: Int
|
|
330
|
+
// After 90°/270° rotation the width and height are swapped.
|
|
331
|
+
if (userRotationCount % 2 != 0) {
|
|
332
|
+
postW = videoHeight; postH = videoWidth
|
|
333
|
+
} else {
|
|
334
|
+
postW = videoWidth; postH = videoHeight
|
|
123
335
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
336
|
+
val cx = (cropNormalized.left * postW).roundToInt()
|
|
337
|
+
val cy = (cropNormalized.top * postH).roundToInt()
|
|
338
|
+
var cw = (cropNormalized.width() * postW).roundToInt()
|
|
339
|
+
var ch = (cropNormalized.height() * postH).roundToInt()
|
|
340
|
+
// H.264 requires even dimensions; round down to nearest even number.
|
|
341
|
+
cw = cw and 1.inv()
|
|
342
|
+
ch = ch and 1.inv()
|
|
343
|
+
if (cw > 0 && ch > 0) {
|
|
344
|
+
videoFilters.add("crop=$cw:$ch:$cx:$cy")
|
|
127
345
|
}
|
|
346
|
+
}
|
|
347
|
+
if (speed != 1.0) {
|
|
348
|
+
videoFilters.add("setpts=${1.0 / speed}*PTS")
|
|
349
|
+
}
|
|
350
|
+
val filterString = videoFilters.joinToString(",")
|
|
128
351
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
val bitrateStr = if (videoBitrate > 0) "$videoBitrate" else "10M"
|
|
352
|
+
// Preserve source quality by matching the original bitrate. Falls back to 10 Mbps.
|
|
353
|
+
val bitrateStr = if (videoBitrate > 0) "$videoBitrate" else "10M"
|
|
132
354
|
|
|
133
|
-
|
|
355
|
+
// Note: Android FFmpegKit auto-rotates by default, so no -noautorotate is needed.
|
|
356
|
+
// The transpose filters above only handle user-initiated rotation, not source metadata.
|
|
357
|
+
val buildCommand: (List<String>) -> Array<String> = { encoderArgs ->
|
|
358
|
+
// -y overwrites the output file without prompting. This is critical for the
|
|
359
|
+
// encoder fallback chain: the first (hardware) attempt opens/creates the output
|
|
360
|
+
// file before MediaCodec fails, so the software retry reuses the same path and
|
|
361
|
+
// would otherwise hit FFmpeg's interactive "Overwrite? [y/N]" prompt and abort.
|
|
362
|
+
val cmds = mutableListOf("-y", "-ss", "${startMs}ms", "-to", "${endMs}ms", "-i", inputFile)
|
|
134
363
|
// When enablePreciseTrimming is the only reason for re-encode (no transforms),
|
|
135
364
|
// videoFilters is empty — skip -vf entirely to avoid FFmpeg error on empty filter.
|
|
136
365
|
if (filterString.isNotEmpty()) {
|
|
137
366
|
cmds.addAll(listOf("-vf", filterString))
|
|
138
367
|
}
|
|
139
|
-
|
|
140
|
-
// Note: Android FFmpegKit auto-rotates by default, so no -noautorotate is needed.
|
|
141
|
-
// The transpose filters above only handle user-initiated rotation, not source metadata.
|
|
142
|
-
cmds.addAll(listOf("-c:v", "h264_mediacodec", "-b:v", bitrateStr))
|
|
368
|
+
cmds.addAll(encoderArgs)
|
|
143
369
|
when {
|
|
144
370
|
removeAudio -> cmds.add("-an")
|
|
145
371
|
speed != 1.0 -> cmds.addAll(listOf("-af", buildAtempoChain(speed)))
|
|
146
372
|
else -> cmds.addAll(listOf("-c:a", "copy"))
|
|
147
373
|
}
|
|
148
374
|
cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
|
|
149
|
-
|
|
150
|
-
// Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
|
|
151
|
-
cmds.addAll(listOf("-i", inputFile))
|
|
152
|
-
if (removeAudio) {
|
|
153
|
-
cmds.addAll(listOf("-c:v", "copy", "-an"))
|
|
154
|
-
} else {
|
|
155
|
-
cmds.addAll(listOf("-c", "copy"))
|
|
156
|
-
}
|
|
157
|
-
cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
|
|
375
|
+
cmds.toTypedArray()
|
|
158
376
|
}
|
|
159
377
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
m.putString("message", cmdStr)
|
|
167
|
-
callback.onLog(m)
|
|
168
|
-
|
|
169
|
-
return FFmpegKit.executeWithArgumentsAsync(command, { session ->
|
|
170
|
-
val state = session.state
|
|
171
|
-
val returnCode = session.returnCode
|
|
172
|
-
UiThreadUtil.runOnUiThread {
|
|
173
|
-
when {
|
|
174
|
-
ReturnCode.isSuccess(returnCode) -> {
|
|
175
|
-
callback.onFinishTrim(outputFile, startMs, endMs, videoDuration)
|
|
176
|
-
}
|
|
177
|
-
ReturnCode.isCancel(returnCode) -> {
|
|
178
|
-
callback.onCancelTrim()
|
|
179
|
-
}
|
|
180
|
-
else -> {
|
|
181
|
-
val errorMessage = "Command failed with state $state and rc $returnCode.${session.failStackTrace}"
|
|
182
|
-
callback.onError(errorMessage, ErrorCode.TRIMMING_FAILED)
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}, { log ->
|
|
187
|
-
Log.d(TAG, "FFmpeg process started with log ${log.message}")
|
|
188
|
-
|
|
189
|
-
val map = Arguments.createMap()
|
|
190
|
-
map.putInt("level", log.level.value)
|
|
191
|
-
map.putString("message", log.message)
|
|
192
|
-
map.putDouble("sessionId", log.sessionId.toDouble())
|
|
193
|
-
map.putString("logStr", log.toString())
|
|
194
|
-
UiThreadUtil.runOnUiThread {
|
|
195
|
-
callback.onLog(map)
|
|
196
|
-
}
|
|
197
|
-
}, { statistics ->
|
|
198
|
-
val timeInMilliseconds = statistics.time.toInt()
|
|
199
|
-
if (timeInMilliseconds > 0) {
|
|
200
|
-
val completePercentage = (timeInMilliseconds * 100) / videoDuration
|
|
201
|
-
UiThreadUtil.runOnUiThread {
|
|
202
|
-
callback.onTrimmingProgress(completePercentage.coerceIn(0, 100))
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
val map = Arguments.createMap()
|
|
207
|
-
map.putDouble("sessionId", statistics.sessionId.toDouble())
|
|
208
|
-
map.putInt("videoFrameNumber", statistics.videoFrameNumber)
|
|
209
|
-
map.putDouble("videoFps", statistics.videoFps.toDouble())
|
|
210
|
-
map.putDouble("videoQuality", statistics.videoQuality.toDouble())
|
|
211
|
-
map.putDouble("size", statistics.size.toDouble())
|
|
212
|
-
map.putDouble("time", statistics.time)
|
|
213
|
-
map.putDouble("bitrate", statistics.bitrate.toDouble())
|
|
214
|
-
map.putDouble("speed", statistics.speed.toDouble())
|
|
215
|
-
map.putString("statisticsStr", statistics.toString())
|
|
216
|
-
UiThreadUtil.runOnUiThread {
|
|
217
|
-
callback.onStatistics(map)
|
|
218
|
-
}
|
|
219
|
-
})
|
|
378
|
+
return executeWithEncoderFallback(
|
|
379
|
+
encoderConfigs = reEncodeEncoderConfigs(bitrateStr),
|
|
380
|
+
buildCommand = buildCommand,
|
|
381
|
+
videoDurationMs = videoDuration,
|
|
382
|
+
callbacks = callbacks,
|
|
383
|
+
)
|
|
220
384
|
}
|
|
221
385
|
|
|
222
386
|
fun shootVideoThumbInBackground(
|
|
@@ -39,7 +39,6 @@ import android.widget.TextView
|
|
|
39
39
|
|
|
40
40
|
import androidx.appcompat.app.AlertDialog
|
|
41
41
|
|
|
42
|
-
import com.arthenica.ffmpegkit.FFmpegSession
|
|
43
42
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
44
43
|
import com.facebook.react.bridge.ReadableMap
|
|
45
44
|
import com.facebook.react.bridge.UiThreadUtil.runOnUiThread
|
|
@@ -172,7 +171,7 @@ class VideoTrimmerView(
|
|
|
172
171
|
private var jumpToPositionOnLoad = 0L
|
|
173
172
|
private lateinit var headerView: FrameLayout
|
|
174
173
|
private lateinit var headerText: TextView
|
|
175
|
-
private var
|
|
174
|
+
private var trimSession: com.videotrim.utils.TrimSession? = null
|
|
176
175
|
private var alertOnFailToLoad = true
|
|
177
176
|
private var alertOnFailTitle = "Error"
|
|
178
177
|
private var alertOnFailMessage = "Fail to load media. Possibly invalid file or no network connection"
|
|
@@ -692,7 +691,7 @@ class VideoTrimmerView(
|
|
|
692
691
|
?.toLongOrNull() ?: 0L
|
|
693
692
|
}
|
|
694
693
|
val effectiveRemoveAudio = isMuted || configRemoveAudio
|
|
695
|
-
|
|
694
|
+
trimSession = VideoTrimmerUtil.trim(
|
|
696
695
|
mSourceUri.toString(),
|
|
697
696
|
StorageUtil.getOutputPath(mContext, mOutputExt),
|
|
698
697
|
mDuration,
|
|
@@ -712,8 +711,8 @@ class VideoTrimmerView(
|
|
|
712
711
|
}
|
|
713
712
|
|
|
714
713
|
fun onCancelTrimClicked() {
|
|
715
|
-
if (
|
|
716
|
-
|
|
714
|
+
if (trimSession != null) {
|
|
715
|
+
trimSession!!.cancel()
|
|
717
716
|
} else {
|
|
718
717
|
mOnTrimVideoListener.onCancelTrim()
|
|
719
718
|
}
|
package/ios/ErrorCode.swift
CHANGED
|
@@ -9,6 +9,7 @@ import Foundation
|
|
|
9
9
|
|
|
10
10
|
enum ErrorCode: String {
|
|
11
11
|
case trimmingFailed = "TRIMMING_FAILED"
|
|
12
|
+
case hardwareEncoderFailed = "HARDWARE_ENCODER_FAILED"
|
|
12
13
|
case failToLoadMedia = "FAIL_TO_LOAD_MEDIA"
|
|
13
14
|
case failToSaveToPhoto = "FAIL_TO_SAVE_TO_PHOTO"
|
|
14
15
|
case failToShare = "FAIL_TO_SHARE"
|
package/ios/VideoTrim.swift
CHANGED
|
@@ -359,7 +359,11 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
359
359
|
root.present(progressAlert, animated: true, completion: nil)
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
// -y overwrites any pre-existing output file without prompting, so FFmpeg never
|
|
363
|
+
// blocks on an interactive "Overwrite? [y/N]" prompt (hardening; also keeps the
|
|
364
|
+
// command symmetric with Android, where -y is required by the encoder fallback chain).
|
|
362
365
|
var cmds = [
|
|
366
|
+
"-y",
|
|
363
367
|
"-ss",
|
|
364
368
|
"\(startTime * 1000)ms",
|
|
365
369
|
"-to",
|
|
@@ -571,7 +575,8 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
571
575
|
self.emitEventToJS("onCancelTrimming", eventData: nil)
|
|
572
576
|
} else {
|
|
573
577
|
// FAILURE
|
|
574
|
-
|
|
578
|
+
let classified = VideoTrim.classifyFFmpegError(session: session)
|
|
579
|
+
self.onError(message: "Command failed with state \(String(describing: FFmpegKitConfig.sessionState(toString: state ?? .failed))) and rc \(String(describing: returnCode)).\(String(describing: session?.getFailStackTrace()))", code: classified)
|
|
575
580
|
shouldCloseEditor = self.closeWhenFinish
|
|
576
581
|
}
|
|
577
582
|
|
|
@@ -647,7 +652,9 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
647
652
|
|
|
648
653
|
let startTime = config["startTime"] as? Double ?? 0
|
|
649
654
|
let endTime = config["endTime"] as? Double ?? 0
|
|
655
|
+
// -y overwrites any pre-existing output file without prompting (hardening).
|
|
650
656
|
var cmds = [
|
|
657
|
+
"-y",
|
|
651
658
|
"-ss",
|
|
652
659
|
"\(startTime)ms",
|
|
653
660
|
"-to",
|
|
@@ -904,6 +911,32 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
904
911
|
]
|
|
905
912
|
self.emitEventToJS("onError", eventData: eventPayload)
|
|
906
913
|
}
|
|
914
|
+
|
|
915
|
+
/// Scan an FFmpeg session's log for signatures that indicate the hardware
|
|
916
|
+
/// video encoder (`h264_videotoolbox`) refused to configure on this device.
|
|
917
|
+
/// VideoToolbox failures are extremely rare on supported iOS hardware — Apple
|
|
918
|
+
/// controls the entire stack and regression-tests it — but the classifier
|
|
919
|
+
/// exists for API parity with Android (where the same hardware-encoder bug is
|
|
920
|
+
/// common and reproducible) and to give consumers a more actionable
|
|
921
|
+
/// `errorCode` than the generic `TRIMMING_FAILED` if it ever happens.
|
|
922
|
+
///
|
|
923
|
+
/// Detected signals:
|
|
924
|
+
/// - `VTCompressionSession` errors
|
|
925
|
+
/// - `Error initializing output stream` / `Error while opening encoder`
|
|
926
|
+
/// combined with a `videotoolbox` mention in the same session log
|
|
927
|
+
static func classifyFFmpegError(session: FFmpegSession?) -> ErrorCode {
|
|
928
|
+
let logs = session?.getAllLogsAsString() ?? ""
|
|
929
|
+
let hardwareEncoderSignals = [
|
|
930
|
+
"VTCompressionSession",
|
|
931
|
+
"Error initializing output stream",
|
|
932
|
+
"Error while opening encoder",
|
|
933
|
+
]
|
|
934
|
+
let matchedHardwareSignal = hardwareEncoderSignals.contains { logs.contains($0) }
|
|
935
|
+
if matchedHardwareSignal && logs.localizedCaseInsensitiveContains("videotoolbox") {
|
|
936
|
+
return .hardwareEncoderFailed
|
|
937
|
+
}
|
|
938
|
+
return .trimmingFailed
|
|
939
|
+
}
|
|
907
940
|
}
|
|
908
941
|
|
|
909
942
|
// MARK: @objc instance methods
|