expo-pro-video-editor 0.1.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/.prettierrc +8 -0
- package/LICENSE +67 -0
- package/NOTICE.md +250 -0
- package/README.md +70 -0
- package/android/build.gradle +35 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/provideoeditor/ExpoProVideoEditorModule.kt +163 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/core/constants/LoggingConstants.kt +4 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/EffectsProcessor.kt +66 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/RenderVideo.kt +1089 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyAnimation.kt +290 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBitrate.kt +110 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBlur.kt +27 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyChromaKey.kt +58 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyClipTransition.kt +88 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyColorMatrix.kt +144 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyComposition.kt +86 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyCrop.kt +104 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyFlip.kt +27 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyFrameRate.kt +28 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyImageLayer.kt +397 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyOpacity.kt +24 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyPlaybackSpeed.kt +35 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyRotation.kt +26 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyScale.kt +29 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/AudioPreRenderer.kt +492 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/AudioSequenceBuilder.kt +144 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/BitrateCapPolicy.kt +45 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ChromaKeyEffect.kt +321 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ChromaKeyMath.kt +90 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ClipTransitionGeometry.kt +160 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ClipTransitionRenderer.kt +1002 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/CompositionBuilder.kt +166 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ConfigurableInAppMp4Muxer.kt +137 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/EncoderFailureClassifier.kt +71 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/GifDecoder.kt +76 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/LayeredCompositionBuilder.kt +556 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/MediaInfoExtractor.kt +364 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ResilientVideoEncoderFactory.kt +307 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoCompositionTransformation.kt +192 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoEncoderConfig.kt +179 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoGlobalTrimCalculator.kt +147 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoReverser.kt +1044 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoSequenceBuilder.kt +905 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoTimelineDurationCalculator.kt +19 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoTranscoder.kt +242 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VolumeAudioProcessor.kt +82 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VolumeControlAudioMixer.kt +182 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/CodecResourceExhaustedException.kt +24 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/RenderConfig.kt +572 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/RenderJobHandle.kt +45 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/VideoEncoderConfigurationException.kt +20 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/utils/RotatedVideoDimensions.kt +68 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/utils/VideoMimeUtils.kt +36 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/shared/concurrency/ExportGate.kt +102 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/shared/logging/PluginLog.kt +112 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/EncodedImage.kt +101 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/ImageOrientation.kt +265 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/PcmRangeDecoder.kt +239 -0
- package/build/ExpoProVideoEditor.types.d.ts +125 -0
- package/build/ExpoProVideoEditor.types.d.ts.map +1 -0
- package/build/ExpoProVideoEditor.types.js +8 -0
- package/build/ExpoProVideoEditor.types.js.map +1 -0
- package/build/ExpoProVideoEditorModule.d.ts +20 -0
- package/build/ExpoProVideoEditorModule.d.ts.map +1 -0
- package/build/ExpoProVideoEditorModule.js +3 -0
- package/build/ExpoProVideoEditorModule.js.map +1 -0
- package/build/ExpoProVideoEditorModule.web.d.ts +9 -0
- package/build/ExpoProVideoEditorModule.web.d.ts.map +1 -0
- package/build/ExpoProVideoEditorModule.web.js +16 -0
- package/build/ExpoProVideoEditorModule.web.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/bun.lock +2132 -0
- package/eslint.config.cjs +5 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoProVideoEditor.podspec +24 -0
- package/ios/ExpoProVideoEditorModule.swift +131 -0
- package/ios/src/features/render/RenderVideo.swift +1087 -0
- package/ios/src/features/render/helpers/ApplyAnimation.swift +198 -0
- package/ios/src/features/render/helpers/ApplyBitrate.swift +112 -0
- package/ios/src/features/render/helpers/ApplyBlur.swift +23 -0
- package/ios/src/features/render/helpers/ApplyChromaKey.swift +175 -0
- package/ios/src/features/render/helpers/ApplyColorMatrix.swift +100 -0
- package/ios/src/features/render/helpers/ApplyComposition.swift +45 -0
- package/ios/src/features/render/helpers/ApplyCrop.swift +53 -0
- package/ios/src/features/render/helpers/ApplyFlip.swift +24 -0
- package/ios/src/features/render/helpers/ApplyImageLayer.swift +65 -0
- package/ios/src/features/render/helpers/ApplyPlaybackSpeed.swift +57 -0
- package/ios/src/features/render/helpers/ApplyRotation.swift +32 -0
- package/ios/src/features/render/helpers/ApplyScale.swift +30 -0
- package/ios/src/features/render/helpers/AudioPreRenderer.swift +352 -0
- package/ios/src/features/render/helpers/AudioReverser.swift +308 -0
- package/ios/src/features/render/helpers/AudioSequenceBuilder.swift +162 -0
- package/ios/src/features/render/helpers/BitrateCapPolicy.swift +77 -0
- package/ios/src/features/render/helpers/BitrateCappedExporter.swift +501 -0
- package/ios/src/features/render/helpers/ClipTransitionGeometry.swift +111 -0
- package/ios/src/features/render/helpers/ClipTransitionRenderer.swift +397 -0
- package/ios/src/features/render/helpers/CompositionBuilder.swift +464 -0
- package/ios/src/features/render/helpers/DecodeOrientedImage.swift +60 -0
- package/ios/src/features/render/helpers/LayeredCompositionBuilder.swift +371 -0
- package/ios/src/features/render/helpers/MediaInfoExtractor.swift +381 -0
- package/ios/src/features/render/helpers/TrackEndTrimmer.swift +112 -0
- package/ios/src/features/render/helpers/VideoSequenceBuilder.swift +555 -0
- package/ios/src/features/render/helpers/VideoTranscoder.swift +283 -0
- package/ios/src/features/render/models/RenderConfig.swift +586 -0
- package/ios/src/features/render/models/VideoClip.swift +81 -0
- package/ios/src/features/render/models/VideoCompositorConfig.swift +78 -0
- package/ios/src/features/render/utils/VideoCompositor.swift +1014 -0
- package/ios/src/features/render/utils/VideoMimeUtils.swift +24 -0
- package/ios/src/shared/EncodedImage.swift +75 -0
- package/ios/src/shared/ExportGate.swift +79 -0
- package/ios/src/shared/ExportSessionDriver.swift +185 -0
- package/ios/src/shared/ExportSessionGuard.swift +85 -0
- package/ios/src/shared/ExportWatchdog.swift +198 -0
- package/ios/src/shared/LoggingConstants.swift +7 -0
- package/ios/src/shared/PluginLog.swift +107 -0
- package/package.json +59 -0
- package/src/ExpoProVideoEditor.types.ts +148 -0
- package/src/ExpoProVideoEditorModule.ts +22 -0
- package/src/ExpoProVideoEditorModule.web.ts +22 -0
- package/src/index.ts +4 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
package expo.modules.provideoeditor.src.features.render
|
|
2
|
+
|
|
3
|
+
import expo.modules.provideoeditor.src.core.constants.RENDER_TAG
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.Looper
|
|
7
|
+
import androidx.media3.common.util.UnstableApi
|
|
8
|
+
import androidx.media3.transformer.Composition
|
|
9
|
+
import androidx.media3.transformer.ExportException
|
|
10
|
+
import androidx.media3.transformer.ExportResult
|
|
11
|
+
import androidx.media3.transformer.ProgressHolder
|
|
12
|
+
import androidx.media3.transformer.Transformer
|
|
13
|
+
import expo.modules.provideoeditor.src.shared.logging.PluginLog as Log
|
|
14
|
+
import java.io.File
|
|
15
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
16
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
17
|
+
import expo.modules.provideoeditor.src.features.render.utils.mapFormatToMimeType
|
|
18
|
+
import expo.modules.provideoeditor.src.features.render.helpers.BitrateCapPolicy
|
|
19
|
+
import expo.modules.provideoeditor.src.features.render.helpers.EncoderFailureClassifier
|
|
20
|
+
import expo.modules.provideoeditor.src.features.render.helpers.ResilientVideoEncoderFactory
|
|
21
|
+
import expo.modules.provideoeditor.src.features.render.helpers.applyComposition
|
|
22
|
+
import expo.modules.provideoeditor.src.features.render.helpers.VolumeControlAudioMixerFactory
|
|
23
|
+
import expo.modules.provideoeditor.src.features.render.helpers.ConfigurableInAppMp4Muxer
|
|
24
|
+
import expo.modules.provideoeditor.src.features.render.helpers.VideoTranscoder
|
|
25
|
+
import expo.modules.provideoeditor.src.features.render.helpers.VideoReverser
|
|
26
|
+
import expo.modules.provideoeditor.src.features.render.helpers.ClipTransitionGeometry
|
|
27
|
+
import expo.modules.provideoeditor.src.features.render.helpers.ClipTransitionRenderer
|
|
28
|
+
import expo.modules.provideoeditor.src.features.render.helpers.MediaInfoExtractor
|
|
29
|
+
import expo.modules.provideoeditor.src.features.render.models.ChromaKeyConfig
|
|
30
|
+
import expo.modules.provideoeditor.src.features.render.models.CodecResourceExhaustedException
|
|
31
|
+
import expo.modules.provideoeditor.src.features.render.models.RenderConfig
|
|
32
|
+
import expo.modules.provideoeditor.src.features.render.models.RenderJobHandle
|
|
33
|
+
import expo.modules.provideoeditor.src.features.render.models.VideoClip
|
|
34
|
+
import expo.modules.provideoeditor.src.features.render.models.VideoEncoderConfigurationException
|
|
35
|
+
import expo.modules.provideoeditor.src.shared.concurrency.ExportGate
|
|
36
|
+
import expo.modules.provideoeditor.src.shared.concurrency.ExportGateGuard
|
|
37
|
+
import android.os.SystemClock
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Service for rendering video with applied effects and transformations.
|
|
41
|
+
*
|
|
42
|
+
* This class handles the complete video rendering pipeline using AndroidX Media3 Transformer:
|
|
43
|
+
* - Applies visual and audio effects based on configuration
|
|
44
|
+
* - Manages output file handling (both temporary and permanent)
|
|
45
|
+
* - Provides progress tracking during rendering
|
|
46
|
+
* - Supports cancellation of active render jobs
|
|
47
|
+
* - Pre-transcodes HEVC 10-bit HDR videos when GPU effects are needed
|
|
48
|
+
*/
|
|
49
|
+
@UnstableApi
|
|
50
|
+
class RenderVideo(private val context: Context) {
|
|
51
|
+
|
|
52
|
+
companion object {
|
|
53
|
+
/**
|
|
54
|
+
* Stall bound for a render encode. A gated render that makes no forward
|
|
55
|
+
* progress for this long (a wedged [android.media.MediaCodec] session at
|
|
56
|
+
* `progress == 0`) is force-cancelled so it releases its [ExportGate]
|
|
57
|
+
* slot instead of holding it forever and deadlocking every later export.
|
|
58
|
+
* Renders have no fixed upper length, so only the stall bound applies —
|
|
59
|
+
* a slow-but-progressing long render is never killed. More generous than
|
|
60
|
+
* the split's default because a heavy composited frame can legitimately
|
|
61
|
+
* take longer between progress ticks.
|
|
62
|
+
*/
|
|
63
|
+
private const val RENDER_STALL_TIMEOUT_MS = 20_000L
|
|
64
|
+
|
|
65
|
+
private const val RENDER_POLL_INTERVAL_MS = 200L
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Media3 error codes that mean "a codec could not be brought up". Each
|
|
69
|
+
* can be either transient resource pressure or a permanent
|
|
70
|
+
* incompatibility, so the cause chain decides (see [mapExportException]).
|
|
71
|
+
*/
|
|
72
|
+
private val CODEC_INIT_ERROR_CODES = setOf(
|
|
73
|
+
ExportException.ERROR_CODE_ENCODER_INIT_FAILED,
|
|
74
|
+
ExportException.ERROR_CODE_ENCODING_FORMAT_UNSUPPORTED,
|
|
75
|
+
// A starved *decoder* (a preview player still holding one, a
|
|
76
|
+
// concurrent export) is exactly as transient as a starved encoder.
|
|
77
|
+
ExportException.ERROR_CODE_DECODER_INIT_FAILED,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private val effectsProcessor = EffectsProcessor()
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Checks if the render configuration includes GPU-intensive effects
|
|
85
|
+
* that are incompatible with HEVC 10-bit HDR videos.
|
|
86
|
+
*/
|
|
87
|
+
private fun hasGpuEffects(config: RenderConfig): Boolean {
|
|
88
|
+
// These effects use GPU surfaces and fail with HEVC 10-bit HDR
|
|
89
|
+
val hasImageLayers = config.imageLayers.isNotEmpty()
|
|
90
|
+
val hasBlur = config.blur != null && config.blur > 0.0
|
|
91
|
+
val hasColorFilters = config.colorFilters.isNotEmpty()
|
|
92
|
+
|
|
93
|
+
// The chroma key is an ES 2.0 SDR shader, so a 10-bit HDR source has to
|
|
94
|
+
// be transcoded down first. Checked at every level it can be set:
|
|
95
|
+
// globally, per clip, and — for the layered path — per layer and per
|
|
96
|
+
// layer clip.
|
|
97
|
+
val hasChromaKey = config.chromaKey != null ||
|
|
98
|
+
config.videoClips.any { it.chromaKey != null } ||
|
|
99
|
+
config.composition?.layers?.any { layer ->
|
|
100
|
+
layer.chromaKey != null || layer.clips.any { it.chromaKey != null }
|
|
101
|
+
} == true
|
|
102
|
+
|
|
103
|
+
return hasImageLayers || hasBlur || hasColorFilters || hasChromaKey
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Checks if transcoding is needed for video compatibility.
|
|
108
|
+
*
|
|
109
|
+
* Transcoding is needed when:
|
|
110
|
+
* 1. GPU effects are used with HEVC 10-bit HDR videos
|
|
111
|
+
* 2. Multiple videos are being merged and at least one is HEVC 10-bit
|
|
112
|
+
* (mixing different codecs in a composition can cause frame processing errors)
|
|
113
|
+
*/
|
|
114
|
+
private fun needsPreTranscoding(config: RenderConfig): Boolean {
|
|
115
|
+
// Check for GPU effects
|
|
116
|
+
if (hasGpuEffects(config)) {
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// When multiple clips are being merged, check if any need transcoding
|
|
121
|
+
// Mixing different codecs (HEVC + H.264) can cause frame processing errors
|
|
122
|
+
if (config.videoClips.size > 1) {
|
|
123
|
+
val hasAnyHevc10bit = config.videoClips.any { clip ->
|
|
124
|
+
VideoTranscoder.needsTranscoding(clip.inputPath)
|
|
125
|
+
}
|
|
126
|
+
if (hasAnyHevc10bit) {
|
|
127
|
+
Log.d(
|
|
128
|
+
RENDER_TAG, "Multiple video clips with HEVC 10-bit detected, " +
|
|
129
|
+
"pre-transcoding to ensure codec compatibility"
|
|
130
|
+
)
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Starts an asynchronous video render job.
|
|
140
|
+
*
|
|
141
|
+
* This method configures and starts a Media3 Transformer to process the video
|
|
142
|
+
* with the specified effects. The operation runs asynchronously and provides
|
|
143
|
+
* callbacks for progress updates, completion, and errors.
|
|
144
|
+
*
|
|
145
|
+
* @param config Complete render configuration including input, output, and effects
|
|
146
|
+
* @param onProgress Callback invoked with progress updates (0.0 to 1.0)
|
|
147
|
+
* @param onComplete Callback invoked on success with output bytes (null if saved to file)
|
|
148
|
+
* @param onError Callback invoked if rendering fails
|
|
149
|
+
* @return RenderJobHandle that can be used to cancel the render job
|
|
150
|
+
*/
|
|
151
|
+
fun render(
|
|
152
|
+
config: RenderConfig,
|
|
153
|
+
onProgress: (Double) -> Unit,
|
|
154
|
+
onComplete: (ByteArray?) -> Unit,
|
|
155
|
+
onError: (Throwable) -> Unit
|
|
156
|
+
): RenderJobHandle {
|
|
157
|
+
val shouldStopPolling = AtomicBoolean(false)
|
|
158
|
+
val mainHandler = Handler(Looper.getMainLooper())
|
|
159
|
+
var transcodedFiles: List<String> = emptyList()
|
|
160
|
+
var reversedFiles: List<String> = emptyList()
|
|
161
|
+
var transitionFiles: List<String> = emptyList()
|
|
162
|
+
val transformerRef = AtomicReference<Transformer?>(null)
|
|
163
|
+
val outputFileRef = AtomicReference<File?>(null)
|
|
164
|
+
// Holds the process-wide encoder slot for this render's transformer
|
|
165
|
+
// phase; released on every terminal path (success, error, stall, cancel).
|
|
166
|
+
val exportGate = ExportGateGuard()
|
|
167
|
+
// Pre-rendered audio WAV temps for the active composition, exposed so the
|
|
168
|
+
// external-cancel terminal (which is listener-silent) can delete them too.
|
|
169
|
+
val audioTempFilesRef = AtomicReference<List<File>>(emptyList())
|
|
170
|
+
|
|
171
|
+
val needsPreTranscode = needsPreTranscoding(config)
|
|
172
|
+
val needsPreReverse = config.videoClips.any { it.reverseVideo }
|
|
173
|
+
// An overlap transition on a non-last clip blends into the next clip; on
|
|
174
|
+
// the last (or only) clip it wraps into the first for a seamless loop.
|
|
175
|
+
// Both are baked by [preRenderTransitions], so either needs the stage.
|
|
176
|
+
val needsPreTransitions =
|
|
177
|
+
config.videoClips.any { it.transition?.isOverlap == true }
|
|
178
|
+
|
|
179
|
+
// Progress split across the slow pre-render stages and the transformer.
|
|
180
|
+
// Reverse + overlap-transition pre-renders each get a share; whatever is
|
|
181
|
+
// left belongs to the transformer phase.
|
|
182
|
+
val reverseShare = if (needsPreReverse) 0.4 else 0.0
|
|
183
|
+
val transitionShare = if (needsPreTransitions) 0.4 else 0.0
|
|
184
|
+
val transformShare = 1.0 - reverseShare - transitionShare
|
|
185
|
+
val reverseProgress: (Double) -> Unit = { p ->
|
|
186
|
+
onProgress((p * reverseShare).coerceIn(0.0, 1.0))
|
|
187
|
+
}
|
|
188
|
+
val transitionProgress: (Double) -> Unit = { p ->
|
|
189
|
+
onProgress((reverseShare + p * transitionShare).coerceIn(0.0, 1.0))
|
|
190
|
+
}
|
|
191
|
+
val transformProgress: (Double) -> Unit = { p ->
|
|
192
|
+
onProgress(
|
|
193
|
+
(reverseShare + transitionShare + p * transformShare).coerceIn(0.0, 1.0)
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
val cleanupAllPreFiles: () -> Unit = {
|
|
198
|
+
VideoTranscoder.cleanupTranscodedFiles(transcodedFiles)
|
|
199
|
+
VideoReverser.cleanupReversedFiles(reversedFiles)
|
|
200
|
+
ClipTransitionRenderer.cleanupFiles(transitionFiles)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!needsPreTranscode && !needsPreReverse && !needsPreTransitions) {
|
|
204
|
+
// Fast path: nothing to pre-process.
|
|
205
|
+
renderInternal(
|
|
206
|
+
config = config,
|
|
207
|
+
onProgress = onProgress,
|
|
208
|
+
onComplete = onComplete,
|
|
209
|
+
onError = onError,
|
|
210
|
+
shouldStopPolling = shouldStopPolling,
|
|
211
|
+
mainHandler = mainHandler,
|
|
212
|
+
transformerRef = transformerRef,
|
|
213
|
+
outputFileRef = outputFileRef,
|
|
214
|
+
exportGate = exportGate,
|
|
215
|
+
audioTempFilesRef = audioTempFilesRef
|
|
216
|
+
)
|
|
217
|
+
} else {
|
|
218
|
+
Thread {
|
|
219
|
+
try {
|
|
220
|
+
var workingConfig = config
|
|
221
|
+
|
|
222
|
+
// 1) Pre-transcode HEVC 10-bit clips (if needed).
|
|
223
|
+
if (needsPreTranscode) {
|
|
224
|
+
Log.d(RENDER_TAG, "Pre-transcoding HEVC 10-bit videos...")
|
|
225
|
+
// Both paths, not just the single-track one: a GPU
|
|
226
|
+
// effect on a composition layer needs its clips
|
|
227
|
+
// transcoded too. ChromaKeyEffect is an ES 2.0 SDR
|
|
228
|
+
// shader and hard-fails on an HDR input, so leaving the
|
|
229
|
+
// layered clips untouched here aborted the export.
|
|
230
|
+
val inputPaths = (
|
|
231
|
+
workingConfig.videoClips.map { it.inputPath } +
|
|
232
|
+
(workingConfig.composition?.layers ?: emptyList())
|
|
233
|
+
.flatMap { layer -> layer.clips.map { it.inputPath } }
|
|
234
|
+
).distinct()
|
|
235
|
+
val transcodeMap = VideoTranscoder.transcodeClipsIfNeeded(
|
|
236
|
+
context, inputPaths
|
|
237
|
+
)
|
|
238
|
+
transcodedFiles = transcodeMap.values
|
|
239
|
+
.filter { it.contains("transcoded_") }
|
|
240
|
+
if (transcodedFiles.isNotEmpty()) {
|
|
241
|
+
Log.i(
|
|
242
|
+
RENDER_TAG,
|
|
243
|
+
"Pre-transcoded ${transcodedFiles.size} HEVC 10-bit videos to H.264"
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
fun retarget(clip: VideoClip): VideoClip {
|
|
247
|
+
val newPath = transcodeMap[clip.inputPath] ?: clip.inputPath
|
|
248
|
+
return if (newPath != clip.inputPath) {
|
|
249
|
+
clip.copy(inputPath = newPath)
|
|
250
|
+
} else clip
|
|
251
|
+
}
|
|
252
|
+
workingConfig = workingConfig.copy(
|
|
253
|
+
videoClips = workingConfig.videoClips.map(::retarget),
|
|
254
|
+
composition = workingConfig.composition?.let { composition ->
|
|
255
|
+
composition.copy(
|
|
256
|
+
layers = composition.layers.map { layer ->
|
|
257
|
+
layer.copy(clips = layer.clips.map(::retarget))
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 2) Pre-render reversed clips into single forward MP4 temp files.
|
|
265
|
+
if (needsPreReverse && !shouldStopPolling.get()) {
|
|
266
|
+
Log.d(
|
|
267
|
+
RENDER_TAG,
|
|
268
|
+
"Pre-rendering reversed segments (" +
|
|
269
|
+
"${workingConfig.videoClips.count { it.reverseVideo }} clip(s))"
|
|
270
|
+
)
|
|
271
|
+
val reversedPaths = mutableListOf<String>()
|
|
272
|
+
val reversedClips = preReverseClips(
|
|
273
|
+
workingConfig.videoClips,
|
|
274
|
+
enableAudio = workingConfig.enableAudio,
|
|
275
|
+
shouldStop = shouldStopPolling,
|
|
276
|
+
collectPath = { reversedPaths.add(it) },
|
|
277
|
+
onProgress = { f -> reverseProgress(f.toDouble()) },
|
|
278
|
+
)
|
|
279
|
+
reversedFiles = reversedPaths
|
|
280
|
+
workingConfig = workingConfig.copy(videoClips = reversedClips)
|
|
281
|
+
// Make sure the bar fully fills the reverse share before
|
|
282
|
+
// the transformer phase starts.
|
|
283
|
+
reverseProgress(1.0)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 3) Pre-render overlap transitions (dissolve/slide/push/wipe)
|
|
287
|
+
// into short blended clips spliced between the neighbours.
|
|
288
|
+
if (needsPreTransitions && !shouldStopPolling.get()) {
|
|
289
|
+
Log.d(RENDER_TAG, "Pre-rendering overlap clip transitions")
|
|
290
|
+
val paths = mutableListOf<String>()
|
|
291
|
+
val transitionedClips = preRenderTransitions(
|
|
292
|
+
workingConfig.videoClips,
|
|
293
|
+
enableAudio = workingConfig.enableAudio,
|
|
294
|
+
globalChromaKey = workingConfig.chromaKey,
|
|
295
|
+
shouldStop = shouldStopPolling,
|
|
296
|
+
collectPath = { paths.add(it) },
|
|
297
|
+
onProgress = { f -> transitionProgress(f.toDouble()) },
|
|
298
|
+
)
|
|
299
|
+
transitionFiles = paths
|
|
300
|
+
workingConfig = workingConfig.copy(videoClips = transitionedClips)
|
|
301
|
+
transitionProgress(1.0)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
val finalConfig = workingConfig
|
|
305
|
+
mainHandler.post {
|
|
306
|
+
if (shouldStopPolling.get()) {
|
|
307
|
+
cleanupAllPreFiles()
|
|
308
|
+
return@post
|
|
309
|
+
}
|
|
310
|
+
renderInternal(
|
|
311
|
+
config = finalConfig,
|
|
312
|
+
onProgress = transformProgress,
|
|
313
|
+
onComplete = { result ->
|
|
314
|
+
cleanupAllPreFiles()
|
|
315
|
+
onComplete(result)
|
|
316
|
+
},
|
|
317
|
+
onError = { error ->
|
|
318
|
+
cleanupAllPreFiles()
|
|
319
|
+
onError(error)
|
|
320
|
+
},
|
|
321
|
+
shouldStopPolling = shouldStopPolling,
|
|
322
|
+
mainHandler = mainHandler,
|
|
323
|
+
transformerRef = transformerRef,
|
|
324
|
+
outputFileRef = outputFileRef,
|
|
325
|
+
exportGate = exportGate,
|
|
326
|
+
audioTempFilesRef = audioTempFilesRef
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
} catch (e: Exception) {
|
|
330
|
+
mainHandler.post {
|
|
331
|
+
cleanupAllPreFiles()
|
|
332
|
+
onError(e)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}.start()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Return cancellation handle
|
|
339
|
+
return RenderJobHandle {
|
|
340
|
+
shouldStopPolling.set(true)
|
|
341
|
+
mainHandler.removeCallbacksAndMessages(null)
|
|
342
|
+
transformerRef.get()?.cancel()
|
|
343
|
+
// Transformer.cancel() is listener-silent, so no terminal callback
|
|
344
|
+
// will run — release the encoder slot and clean up here so a
|
|
345
|
+
// cancelled render neither leaves the gate held nor leaks its
|
|
346
|
+
// pre-rendered audio WAV temps.
|
|
347
|
+
exportGate.release()
|
|
348
|
+
cleanupAudioTempFiles(audioTempFilesRef.get())
|
|
349
|
+
VideoTranscoder.cleanupTranscodedFiles(transcodedFiles)
|
|
350
|
+
VideoReverser.cleanupReversedFiles(reversedFiles)
|
|
351
|
+
ClipTransitionRenderer.cleanupFiles(transitionFiles)
|
|
352
|
+
if (config.outputPath == null) {
|
|
353
|
+
outputFileRef.get()?.delete()
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Pre-renders all clips with `reverseVideo == true` into single forward
|
|
360
|
+
* MP4 temp files using [VideoReverser], and returns the rewritten clip
|
|
361
|
+
* list. Forward clips pass through unchanged.
|
|
362
|
+
*/
|
|
363
|
+
private fun preReverseClips(
|
|
364
|
+
clips: List<VideoClip>,
|
|
365
|
+
enableAudio: Boolean,
|
|
366
|
+
shouldStop: AtomicBoolean,
|
|
367
|
+
collectPath: (String) -> Unit,
|
|
368
|
+
onProgress: (Float) -> Unit,
|
|
369
|
+
): List<VideoClip> {
|
|
370
|
+
val reversedClipIndices = clips.withIndex()
|
|
371
|
+
.filter { it.value.reverseVideo }
|
|
372
|
+
.map { it.index }
|
|
373
|
+
if (reversedClipIndices.isEmpty()) return clips
|
|
374
|
+
val totalReversed = reversedClipIndices.size
|
|
375
|
+
val result = clips.toMutableList()
|
|
376
|
+
reversedClipIndices.forEachIndexed { progressIdx, clipIdx ->
|
|
377
|
+
if (shouldStop.get()) return result
|
|
378
|
+
val clip = clips[clipIdx]
|
|
379
|
+
val startUs = clip.startUs ?: 0L
|
|
380
|
+
val endUs = clip.endUs
|
|
381
|
+
?: expo.modules.provideoeditor.src.features.render.helpers
|
|
382
|
+
.MediaInfoExtractor.getVideoDuration(clip.inputPath)
|
|
383
|
+
try {
|
|
384
|
+
val reversed = VideoReverser.reverseSync(
|
|
385
|
+
context = context,
|
|
386
|
+
inputPath = clip.inputPath,
|
|
387
|
+
segmentStartUs = startUs,
|
|
388
|
+
segmentEndUs = endUs,
|
|
389
|
+
includeAudio = enableAudio && (clip.volume ?: 1.0f) > 0f,
|
|
390
|
+
onProgress = { clipFraction ->
|
|
391
|
+
// Map per-clip progress into the overall reverse phase.
|
|
392
|
+
val overall = (progressIdx + clipFraction) / totalReversed
|
|
393
|
+
onProgress(overall.coerceIn(0f, 1f))
|
|
394
|
+
},
|
|
395
|
+
)
|
|
396
|
+
collectPath(reversed.outputPath)
|
|
397
|
+
result[clipIdx] = clip.copy(
|
|
398
|
+
inputPath = reversed.outputPath,
|
|
399
|
+
startUs = 0L,
|
|
400
|
+
endUs = reversed.durationUs.takeIf { it > 0 },
|
|
401
|
+
reverseVideo = false,
|
|
402
|
+
)
|
|
403
|
+
} catch (e: Exception) {
|
|
404
|
+
Log.e(
|
|
405
|
+
RENDER_TAG,
|
|
406
|
+
"Reverse pre-render failed for ${clip.inputPath}: ${e.message}. " +
|
|
407
|
+
"Falling back to forward playback."
|
|
408
|
+
)
|
|
409
|
+
result[clipIdx] = clip.copy(reverseVideo = false)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return result
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Pre-renders overlap transitions (dissolve/slide/push/wipe) into short
|
|
417
|
+
* blended MP4 clips and rewrites the clip list so the main pipeline sees
|
|
418
|
+
* plain forward clips:
|
|
419
|
+
*
|
|
420
|
+
* For a transition between clip *i* and *i+1*, [ClipTransitionGeometry]
|
|
421
|
+
* resolves the blend in OUTPUT (post-speed) time: clip *i* is shortened by
|
|
422
|
+
* `output * speed_i` of source, the blended clip (output duration) is
|
|
423
|
+
* inserted, and clip *i+1*'s head is trimmed by `output * speed_{i+1}` of
|
|
424
|
+
* source. The blend itself is rendered at the requested speed for each side,
|
|
425
|
+
* so footage inside the transition plays at the same speed as the rest of
|
|
426
|
+
* the clip. If a transition cannot be rendered (e.g. dimension mismatch or
|
|
427
|
+
* not enough content) it degrades to a hard cut.
|
|
428
|
+
*/
|
|
429
|
+
private fun preRenderTransitions(
|
|
430
|
+
clips: List<VideoClip>,
|
|
431
|
+
enableAudio: Boolean,
|
|
432
|
+
globalChromaKey: ChromaKeyConfig?,
|
|
433
|
+
shouldStop: AtomicBoolean,
|
|
434
|
+
collectPath: (String) -> Unit,
|
|
435
|
+
onProgress: (Float) -> Unit,
|
|
436
|
+
): List<VideoClip> {
|
|
437
|
+
// An overlap transition on the last/only clip loops back into the first
|
|
438
|
+
// clip (seamless loop). Captured before the between-clip pass clears it.
|
|
439
|
+
val wrapTransition = clips.lastOrNull()?.transition?.takeIf {
|
|
440
|
+
it.isOverlap && !clips.last().reverseVideo && !clips.first().reverseVideo
|
|
441
|
+
}
|
|
442
|
+
val betweenCount = if (clips.size >= 2) {
|
|
443
|
+
(0 until clips.size - 1).count { clips[it].transition?.isOverlap == true }
|
|
444
|
+
} else 0
|
|
445
|
+
val total = betweenCount + (if (wrapTransition != null) 1 else 0)
|
|
446
|
+
if (total == 0) return clips
|
|
447
|
+
|
|
448
|
+
val work = clips.toMutableList()
|
|
449
|
+
val result = mutableListOf<VideoClip>()
|
|
450
|
+
// Append an original clip only if it still has positive duration. A blend
|
|
451
|
+
// can consume a neighbouring clip entirely (two adjacent transitions
|
|
452
|
+
// sharing a clip), leaving a zero-length body — drop it and let the blend
|
|
453
|
+
// clip take its place rather than feed a zero-length clip to the composer.
|
|
454
|
+
fun addClip(clip: VideoClip) {
|
|
455
|
+
val start = clip.startUs ?: 0L
|
|
456
|
+
val end = clip.endUs
|
|
457
|
+
if (end == null || end > start) result.add(clip)
|
|
458
|
+
}
|
|
459
|
+
var doneCount = 0
|
|
460
|
+
var i = 0
|
|
461
|
+
|
|
462
|
+
while (i < work.size) {
|
|
463
|
+
val current = work[i]
|
|
464
|
+
val next = work.getOrNull(i + 1)
|
|
465
|
+
val transition = current.transition
|
|
466
|
+
|
|
467
|
+
val canOverlap = next != null && transition != null && transition.isOverlap &&
|
|
468
|
+
!current.reverseVideo && !next.reverseVideo
|
|
469
|
+
|
|
470
|
+
if (shouldStop.get() || !canOverlap) {
|
|
471
|
+
// Clear an overlap transition so it is not reinterpreted later.
|
|
472
|
+
addClip(if (transition?.isOverlap == true) current.copy(transition = null) else current)
|
|
473
|
+
i++
|
|
474
|
+
continue
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
val curStart = current.startUs ?: 0L
|
|
478
|
+
val curEnd = current.endUs ?: MediaInfoExtractor.getVideoDuration(current.inputPath)
|
|
479
|
+
val nextStart = next!!.startUs ?: 0L
|
|
480
|
+
val nextEnd = next.endUs ?: MediaInfoExtractor.getVideoDuration(next.inputPath)
|
|
481
|
+
val curDur = curEnd - curStart
|
|
482
|
+
val nextDur = nextEnd - nextStart
|
|
483
|
+
|
|
484
|
+
// Resolve the overlap geometry in OUTPUT (post-speed) time so the
|
|
485
|
+
// requested transition duration matches the non-transition timeline
|
|
486
|
+
// and each side consumes `output * speed` of its own source.
|
|
487
|
+
val plan = ClipTransitionGeometry.planOverlap(
|
|
488
|
+
outgoingSourceDurationUs = curDur,
|
|
489
|
+
incomingSourceDurationUs = nextDur,
|
|
490
|
+
transitionDurationUs = transition!!.durationUs,
|
|
491
|
+
outgoingSpeed = current.playbackSpeed,
|
|
492
|
+
incomingSpeed = next.playbackSpeed,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
if (plan == null) {
|
|
496
|
+
Log.w(RENDER_TAG, "Transition: not enough content for boundary $i, hard cut")
|
|
497
|
+
addClip(current.copy(transition = null))
|
|
498
|
+
doneCount++
|
|
499
|
+
onProgress((doneCount.toFloat() / total).coerceIn(0f, 1f))
|
|
500
|
+
i++
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
val tailSrc = plan.outgoingTailSourceUs
|
|
505
|
+
val headSrc = plan.incomingHeadSourceUs
|
|
506
|
+
|
|
507
|
+
val rendered = ClipTransitionRenderer.renderSync(
|
|
508
|
+
context = context,
|
|
509
|
+
outgoingPath = current.inputPath,
|
|
510
|
+
outTailStartUs = curEnd - tailSrc,
|
|
511
|
+
outTailEndUs = curEnd,
|
|
512
|
+
incomingPath = next.inputPath,
|
|
513
|
+
inHeadStartUs = nextStart,
|
|
514
|
+
inHeadEndUs = nextStart + headSrc,
|
|
515
|
+
outputDurationUs = plan.outputDurationUs,
|
|
516
|
+
type = transition.type,
|
|
517
|
+
direction = transition.direction,
|
|
518
|
+
curve = transition.curve,
|
|
519
|
+
includeAudio = enableAudio &&
|
|
520
|
+
(current.volume ?: 1.0f) > 0f && (next.volume ?: 1.0f) > 0f,
|
|
521
|
+
onProgress = { f ->
|
|
522
|
+
onProgress(((doneCount + f) / total).coerceIn(0f, 1f))
|
|
523
|
+
},
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
if (rendered != null) {
|
|
527
|
+
collectPath(rendered.outputPath)
|
|
528
|
+
// Keep the outgoing clip's speed; it now ends `tailSrc` of source
|
|
529
|
+
// earlier (those frames moved into the speed-adjusted blend).
|
|
530
|
+
addClip(current.copy(endUs = curEnd - tailSrc, transition = null))
|
|
531
|
+
val blendKey = blendChromaKey(current, next, globalChromaKey, "$i")
|
|
532
|
+
result.add(
|
|
533
|
+
VideoClip(
|
|
534
|
+
inputPath = rendered.outputPath,
|
|
535
|
+
startUs = 0L,
|
|
536
|
+
endUs = rendered.durationUs.takeIf { it > 0 },
|
|
537
|
+
chromaKey = blendKey.config,
|
|
538
|
+
suppressChromaKey = blendKey.suppressed,
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
// Trim the incoming head in place; it keeps its own speed/transition.
|
|
542
|
+
work[i + 1] = next.copy(startUs = nextStart + headSrc)
|
|
543
|
+
} else {
|
|
544
|
+
Log.w(RENDER_TAG, "Transition render failed for boundary $i, hard cut")
|
|
545
|
+
addClip(current.copy(transition = null))
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
doneCount++
|
|
549
|
+
onProgress((doneCount.toFloat() / total).coerceIn(0f, 1f))
|
|
550
|
+
i++
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Wrap pass: render the last clip's tail dissolving into the first
|
|
554
|
+
// clip's head and append it, so any looping player restarts seamlessly.
|
|
555
|
+
// Overlap transitions between clips already rewrote `result`, so its
|
|
556
|
+
// first/last entries are the original first/last clips (blends only ever
|
|
557
|
+
// sit between them).
|
|
558
|
+
if (wrapTransition != null && !shouldStop.get() && result.isNotEmpty()) {
|
|
559
|
+
val lastIdx = result.size - 1
|
|
560
|
+
val first = result[0]
|
|
561
|
+
val last = result[lastIdx]
|
|
562
|
+
val singleClip = lastIdx == 0
|
|
563
|
+
|
|
564
|
+
val firstStart = first.startUs ?: 0L
|
|
565
|
+
val firstEnd = first.endUs ?: MediaInfoExtractor.getVideoDuration(first.inputPath)
|
|
566
|
+
val lastStart = last.startUs ?: 0L
|
|
567
|
+
val lastEnd = last.endUs ?: MediaInfoExtractor.getVideoDuration(last.inputPath)
|
|
568
|
+
|
|
569
|
+
// Single-clip loops carve the head and tail from the same source, so
|
|
570
|
+
// they need the stricter head+tail<L guard; multi-clip loops keep two
|
|
571
|
+
// independent sources and reuse the ordinary overlap geometry.
|
|
572
|
+
val plan = if (singleClip) {
|
|
573
|
+
ClipTransitionGeometry.planWrap(
|
|
574
|
+
sourceDurationUs = lastEnd - lastStart,
|
|
575
|
+
transitionDurationUs = wrapTransition.durationUs,
|
|
576
|
+
speed = last.playbackSpeed,
|
|
577
|
+
)
|
|
578
|
+
} else {
|
|
579
|
+
ClipTransitionGeometry.planOverlap(
|
|
580
|
+
outgoingSourceDurationUs = lastEnd - lastStart,
|
|
581
|
+
incomingSourceDurationUs = firstEnd - firstStart,
|
|
582
|
+
transitionDurationUs = wrapTransition.durationUs,
|
|
583
|
+
outgoingSpeed = last.playbackSpeed,
|
|
584
|
+
incomingSpeed = first.playbackSpeed,
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (plan == null) {
|
|
589
|
+
Log.w(RENDER_TAG, "Loop wrap: not enough content, seamless loop skipped")
|
|
590
|
+
} else {
|
|
591
|
+
val tailSrc = plan.outgoingTailSourceUs
|
|
592
|
+
val headSrc = plan.incomingHeadSourceUs
|
|
593
|
+
val rendered = ClipTransitionRenderer.renderSync(
|
|
594
|
+
context = context,
|
|
595
|
+
outgoingPath = last.inputPath,
|
|
596
|
+
outTailStartUs = lastEnd - tailSrc,
|
|
597
|
+
outTailEndUs = lastEnd,
|
|
598
|
+
incomingPath = first.inputPath,
|
|
599
|
+
inHeadStartUs = firstStart,
|
|
600
|
+
inHeadEndUs = firstStart + headSrc,
|
|
601
|
+
outputDurationUs = plan.outputDurationUs,
|
|
602
|
+
type = wrapTransition.type,
|
|
603
|
+
direction = wrapTransition.direction,
|
|
604
|
+
curve = wrapTransition.curve,
|
|
605
|
+
includeAudio = enableAudio &&
|
|
606
|
+
(last.volume ?: 1.0f) > 0f && (first.volume ?: 1.0f) > 0f,
|
|
607
|
+
onProgress = { f ->
|
|
608
|
+
onProgress(((doneCount + f) / total).coerceIn(0f, 1f))
|
|
609
|
+
},
|
|
610
|
+
)
|
|
611
|
+
if (rendered != null) {
|
|
612
|
+
collectPath(rendered.outputPath)
|
|
613
|
+
if (singleClip) {
|
|
614
|
+
// Trim both ends of the one clip; the carved head/tail
|
|
615
|
+
// moved into the appended blend.
|
|
616
|
+
result[0] = first.copy(
|
|
617
|
+
startUs = firstStart + headSrc,
|
|
618
|
+
endUs = lastEnd - tailSrc,
|
|
619
|
+
)
|
|
620
|
+
} else {
|
|
621
|
+
result[0] = first.copy(startUs = firstStart + headSrc)
|
|
622
|
+
result[lastIdx] = last.copy(endUs = lastEnd - tailSrc)
|
|
623
|
+
}
|
|
624
|
+
val wrapKey =
|
|
625
|
+
blendChromaKey(last, first, globalChromaKey, "loop wrap")
|
|
626
|
+
result.add(
|
|
627
|
+
VideoClip(
|
|
628
|
+
inputPath = rendered.outputPath,
|
|
629
|
+
startUs = 0L,
|
|
630
|
+
endUs = rendered.durationUs.takeIf { it > 0 },
|
|
631
|
+
chromaKey = wrapKey.config,
|
|
632
|
+
suppressChromaKey = wrapKey.suppressed,
|
|
633
|
+
)
|
|
634
|
+
)
|
|
635
|
+
} else {
|
|
636
|
+
Log.w(RENDER_TAG, "Loop wrap render failed, seamless loop skipped")
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
doneCount++
|
|
640
|
+
onProgress((doneCount.toFloat() / total).coerceIn(0f, 1f))
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return result
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The chroma key to apply to a pre-rendered overlap blend.
|
|
648
|
+
*
|
|
649
|
+
* The blend is composed from the raw sources by [ClipTransitionRenderer],
|
|
650
|
+
* which knows nothing about keying, so the key has to be re-applied to its
|
|
651
|
+
* output. That only has a defined meaning when both sides key the same way:
|
|
652
|
+
* blending a keyed clip with an unkeyed one produces mixed colors that no
|
|
653
|
+
* single key can undo. Mismatched sides are therefore left unkeyed and
|
|
654
|
+
* reported, rather than silently keyed with one side's settings.
|
|
655
|
+
*/
|
|
656
|
+
private fun blendChromaKey(
|
|
657
|
+
outgoing: VideoClip,
|
|
658
|
+
incoming: VideoClip,
|
|
659
|
+
global: ChromaKeyConfig?,
|
|
660
|
+
boundary: String,
|
|
661
|
+
): BlendChromaKey {
|
|
662
|
+
// Compare the *effective* keys. A clip that leaves its own key null
|
|
663
|
+
// still inherits the global one, so comparing the raw per-clip fields
|
|
664
|
+
// reported a mismatch for two clips that in fact key identically.
|
|
665
|
+
val outgoingKey = outgoing.chromaKey ?: global
|
|
666
|
+
val incomingKey = incoming.chromaKey ?: global
|
|
667
|
+
if (outgoingKey == incomingKey) return BlendChromaKey(outgoingKey, false)
|
|
668
|
+
|
|
669
|
+
Log.w(
|
|
670
|
+
RENDER_TAG,
|
|
671
|
+
"Chroma key: the two clips at boundary $boundary use different keys; " +
|
|
672
|
+
"the pre-rendered transition blend is emitted unkeyed"
|
|
673
|
+
)
|
|
674
|
+
// A null key alone would mean "inherit", and `VideoSequenceBuilder`
|
|
675
|
+
// would then fall back to the global key — applying to the blend the
|
|
676
|
+
// very thing this warning promises it will not. The suppress flag is
|
|
677
|
+
// what makes "unkeyed" actually mean unkeyed.
|
|
678
|
+
return BlendChromaKey(null, true)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** The key for a pre-rendered blend, and whether it opts out of the global one. */
|
|
682
|
+
private data class BlendChromaKey(
|
|
683
|
+
val config: ChromaKeyConfig?,
|
|
684
|
+
val suppressed: Boolean,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Internal render implementation after optional pre-transcoding.
|
|
689
|
+
*/
|
|
690
|
+
private fun renderInternal(
|
|
691
|
+
config: RenderConfig,
|
|
692
|
+
onProgress: (Double) -> Unit,
|
|
693
|
+
onComplete: (ByteArray?) -> Unit,
|
|
694
|
+
onError: (Throwable) -> Unit,
|
|
695
|
+
shouldStopPolling: AtomicBoolean,
|
|
696
|
+
mainHandler: Handler,
|
|
697
|
+
transformerRef: AtomicReference<Transformer?>,
|
|
698
|
+
outputFileRef: AtomicReference<File?>,
|
|
699
|
+
exportGate: ExportGateGuard,
|
|
700
|
+
audioTempFilesRef: AtomicReference<List<File>>
|
|
701
|
+
) {
|
|
702
|
+
// Determine output file location
|
|
703
|
+
val outputFile =
|
|
704
|
+
if (config.outputPath != null) {
|
|
705
|
+
File(config.outputPath)
|
|
706
|
+
} else {
|
|
707
|
+
File(
|
|
708
|
+
context.cacheDir,
|
|
709
|
+
// A UUID keeps concurrent renders from colliding on the same
|
|
710
|
+
// millisecond timestamp.
|
|
711
|
+
"video_output_${System.currentTimeMillis()}_" +
|
|
712
|
+
"${java.util.UUID.randomUUID()}.${config.outputFormat}"
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
outputFileRef.set(outputFile)
|
|
716
|
+
|
|
717
|
+
// Process effects from configuration
|
|
718
|
+
val (videoEffects, audioEffects) = effectsProcessor.process(config)
|
|
719
|
+
|
|
720
|
+
val outputMimeType = mapFormatToMimeType(config.outputFormat)
|
|
721
|
+
// Resilient factory tries Media3's fast default first (operating-rate =
|
|
722
|
+
// MAX, so working devices keep their speed) and only retries through a
|
|
723
|
+
// fallback chain (capped operating-rate → unset → Main/Baseline profile
|
|
724
|
+
// → software encoder as the slow last resort) when the encoder rejects
|
|
725
|
+
// it — the cause of the codec exception on many Qualcomm c2 encoders. It
|
|
726
|
+
// also keeps Media3's own per-encoder fallback for bitrate/profile/level
|
|
727
|
+
// adjustments.
|
|
728
|
+
val encoderFactory = ResilientVideoEncoderFactory(
|
|
729
|
+
context = context,
|
|
730
|
+
mimeType = outputMimeType,
|
|
731
|
+
bitrate = config.bitrate,
|
|
732
|
+
forceVideoEncoding = shouldForceVideoEncodeForBitrateCap(config),
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
// Declare transformer before listener to make it accessible
|
|
736
|
+
lateinit var transformer: Transformer
|
|
737
|
+
|
|
738
|
+
// Single terminal guard for the transformer phase: whichever of
|
|
739
|
+
// completion, error or the stall watchdog fires first wins, releases the
|
|
740
|
+
// encoder slot and delivers the result exactly once.
|
|
741
|
+
val renderFinished = AtomicBoolean(false)
|
|
742
|
+
|
|
743
|
+
// Check if we need custom audio mixing with volume control
|
|
744
|
+
val hasCustomAudio = config.audioTracks.isNotEmpty()
|
|
745
|
+
|
|
746
|
+
// Determine if video audio will be present in the mix
|
|
747
|
+
// Video audio is removed when audio is disabled or all clips have volume 0
|
|
748
|
+
val videoAudioPresent = config.enableAudio && (
|
|
749
|
+
config.videoClips.any { (it.volume ?: 1.0f) > 0.0f } ||
|
|
750
|
+
config.composition?.layers?.any { layer ->
|
|
751
|
+
layer.clips.any { (it.volume ?: 1.0f) > 0.0f }
|
|
752
|
+
} == true
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
// Build transformer with callbacks
|
|
756
|
+
val transformerBuilder = Transformer.Builder(context)
|
|
757
|
+
.setEncoderFactory(encoderFactory)
|
|
758
|
+
.setVideoMimeType(outputMimeType)
|
|
759
|
+
|
|
760
|
+
// Configure muxer for streaming optimization (moov atom placement)
|
|
761
|
+
// true = moov at start (streamable), false = moov at end (smaller file)
|
|
762
|
+
val muxerFactory = ConfigurableInAppMp4Muxer.Factory(
|
|
763
|
+
attemptStreamableOutput = config.shouldOptimizeForNetworkUse
|
|
764
|
+
)
|
|
765
|
+
transformerBuilder.setMuxerFactory(muxerFactory)
|
|
766
|
+
|
|
767
|
+
// Use custom audio mixer ONLY when mixing video audio with custom audio tracks
|
|
768
|
+
// For video-only volume adjustment, VolumeAudioProcessor is used per-clip instead
|
|
769
|
+
// (AudioProcessors don't work with parallel sequences, but work fine with single sequence)
|
|
770
|
+
if (hasCustomAudio) {
|
|
771
|
+
val trackVolumes = config.audioTracks.map { it.volume }
|
|
772
|
+
transformerBuilder.setAudioMixerFactory(
|
|
773
|
+
VolumeControlAudioMixerFactory(
|
|
774
|
+
trackVolumes = trackVolumes,
|
|
775
|
+
videoAudioPresent = videoAudioPresent
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
transformer = transformerBuilder
|
|
781
|
+
.addListener(object : Transformer.Listener {
|
|
782
|
+
override fun onCompleted(composition: Composition, result: ExportResult) {
|
|
783
|
+
if (!renderFinished.compareAndSet(false, true)) return
|
|
784
|
+
shouldStopPolling.set(true)
|
|
785
|
+
exportGate.release()
|
|
786
|
+
// Ensure 100% progress is always reported before completion
|
|
787
|
+
onProgress(1.0)
|
|
788
|
+
try {
|
|
789
|
+
if (config.outputPath != null) {
|
|
790
|
+
// Output saved to file, return null
|
|
791
|
+
onComplete(null)
|
|
792
|
+
} else {
|
|
793
|
+
// Read temporary file and return bytes
|
|
794
|
+
val resultBytes = outputFile.readBytes()
|
|
795
|
+
onComplete(resultBytes)
|
|
796
|
+
}
|
|
797
|
+
} catch (e: Exception) {
|
|
798
|
+
onError(e)
|
|
799
|
+
} finally {
|
|
800
|
+
mainHandler.removeCallbacksAndMessages(null)
|
|
801
|
+
if (config.outputPath == null) outputFile.delete()
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
override fun onError(
|
|
806
|
+
composition: Composition,
|
|
807
|
+
result: ExportResult,
|
|
808
|
+
exception: ExportException
|
|
809
|
+
) {
|
|
810
|
+
if (!renderFinished.compareAndSet(false, true)) return
|
|
811
|
+
shouldStopPolling.set(true)
|
|
812
|
+
exportGate.release()
|
|
813
|
+
onError(mapExportException(exception))
|
|
814
|
+
if (config.outputPath == null) outputFile.delete()
|
|
815
|
+
}
|
|
816
|
+
})
|
|
817
|
+
.build()
|
|
818
|
+
transformerRef.set(transformer)
|
|
819
|
+
|
|
820
|
+
// Watchdog terminal: a render whose encoder wedges (no forward progress
|
|
821
|
+
// for the stall bound) is force-cancelled so it releases its gate slot
|
|
822
|
+
// instead of deadlocking every later export.
|
|
823
|
+
fun failRenderStalled(progressFraction: Double) {
|
|
824
|
+
if (!renderFinished.compareAndSet(false, true)) return
|
|
825
|
+
shouldStopPolling.set(true)
|
|
826
|
+
try {
|
|
827
|
+
transformer.cancel()
|
|
828
|
+
} catch (_: Exception) {
|
|
829
|
+
}
|
|
830
|
+
exportGate.release()
|
|
831
|
+
// transformer.cancel() is listener-silent, so the audio-temp cleanup
|
|
832
|
+
// listener never runs — clean up here instead.
|
|
833
|
+
cleanupAudioTempFiles(audioTempFilesRef.get())
|
|
834
|
+
if (config.outputPath == null) outputFile.delete()
|
|
835
|
+
val seconds = RENDER_STALL_TIMEOUT_MS / 1000
|
|
836
|
+
val message = String.format(
|
|
837
|
+
java.util.Locale.US,
|
|
838
|
+
"Render export stalled after %ds with no progress " +
|
|
839
|
+
"[progress=%.2f format=%s bitrate=%s]",
|
|
840
|
+
seconds, progressFraction, config.outputFormat,
|
|
841
|
+
config.bitrate?.let { "${it / 1000}kbps" } ?: "preset"
|
|
842
|
+
)
|
|
843
|
+
Log.e(RENDER_TAG, message)
|
|
844
|
+
onError(IllegalStateException(message))
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Create composition (now fast - no manual audio mixing needed, Media3 handles it natively)
|
|
848
|
+
Thread {
|
|
849
|
+
try {
|
|
850
|
+
val compositionResult = applyComposition(
|
|
851
|
+
context = context,
|
|
852
|
+
config = config,
|
|
853
|
+
videoEffects = videoEffects,
|
|
854
|
+
audioEffects = audioEffects
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
mainHandler.post {
|
|
858
|
+
if (compositionResult != null) {
|
|
859
|
+
val composition = compositionResult.composition
|
|
860
|
+
val audioTempFiles = compositionResult.temporaryFiles
|
|
861
|
+
|
|
862
|
+
transformer.addListener(object : Transformer.Listener {
|
|
863
|
+
override fun onCompleted(
|
|
864
|
+
composition: Composition,
|
|
865
|
+
result: ExportResult
|
|
866
|
+
) {
|
|
867
|
+
cleanupAudioTempFiles(audioTempFiles)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
override fun onError(
|
|
871
|
+
composition: Composition,
|
|
872
|
+
result: ExportResult,
|
|
873
|
+
exception: ExportException
|
|
874
|
+
) {
|
|
875
|
+
cleanupAudioTempFiles(audioTempFiles)
|
|
876
|
+
}
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
// Serialize against other encodes (concurrent splits/
|
|
880
|
+
// renders). The wait is before the transformer starts and
|
|
881
|
+
// before the stall watchdog arms, so queueing never counts
|
|
882
|
+
// as a stall.
|
|
883
|
+
audioTempFilesRef.set(audioTempFiles)
|
|
884
|
+
ExportGate.acquire {
|
|
885
|
+
exportGate.markHeld()
|
|
886
|
+
if (shouldStopPolling.get() || renderFinished.get()) {
|
|
887
|
+
// Cancelled/finished while queued: hand the slot back.
|
|
888
|
+
exportGate.release()
|
|
889
|
+
cleanupAudioTempFiles(audioTempFiles)
|
|
890
|
+
return@acquire
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Transformer.start() can throw synchronously (illegal
|
|
894
|
+
// output/encoder config); on that path no listener fires,
|
|
895
|
+
// so release the slot and surface the error here or the
|
|
896
|
+
// gate would leak and deadlock every later export.
|
|
897
|
+
try {
|
|
898
|
+
transformer.start(composition, outputFile.absolutePath)
|
|
899
|
+
} catch (e: Exception) {
|
|
900
|
+
if (renderFinished.compareAndSet(false, true)) {
|
|
901
|
+
shouldStopPolling.set(true)
|
|
902
|
+
exportGate.release()
|
|
903
|
+
cleanupAudioTempFiles(audioTempFiles)
|
|
904
|
+
if (config.outputPath == null) outputFile.delete()
|
|
905
|
+
onError(e)
|
|
906
|
+
}
|
|
907
|
+
return@acquire
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Progress tracking loop + stall watchdog. `lastAdvanceAt`
|
|
911
|
+
// marks the last whole-percent advance; if progress
|
|
912
|
+
// never moves for the stall bound (a wedged encoder at
|
|
913
|
+
// 0%) the render is failed and the slot released.
|
|
914
|
+
val progressHolder = ProgressHolder()
|
|
915
|
+
mainHandler.post(object : Runnable {
|
|
916
|
+
private var lastProgress = -1
|
|
917
|
+
private val startedAt = SystemClock.uptimeMillis()
|
|
918
|
+
private var lastAdvanceAt = startedAt
|
|
919
|
+
|
|
920
|
+
override fun run() {
|
|
921
|
+
if (shouldStopPolling.get()) return
|
|
922
|
+
|
|
923
|
+
val progressState =
|
|
924
|
+
transformer.getProgress(progressHolder)
|
|
925
|
+
val now = SystemClock.uptimeMillis()
|
|
926
|
+
val percent = progressHolder.progress
|
|
927
|
+
if (percent >= 0) {
|
|
928
|
+
onProgress(percent / 100.0)
|
|
929
|
+
if (percent > lastProgress) {
|
|
930
|
+
lastProgress = percent
|
|
931
|
+
lastAdvanceAt = now
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Progress can't be determined for some exports
|
|
936
|
+
// (e.g. image inputs report UNAVAILABLE); don't
|
|
937
|
+
// count those as a stall.
|
|
938
|
+
val trackable = progressState !=
|
|
939
|
+
Transformer.PROGRESS_STATE_UNAVAILABLE
|
|
940
|
+
if (!trackable) lastAdvanceAt = now
|
|
941
|
+
|
|
942
|
+
// Adaptive stall bound. Media3 reports whole-
|
|
943
|
+
// percent progress, so a fixed bound would
|
|
944
|
+
// false-kill any render slower than 1% per bound
|
|
945
|
+
// (a ~34-min render at the 20s floor). Tolerate
|
|
946
|
+
// 3× the average time-per-percent seen so far —
|
|
947
|
+
// a legitimately slow long render is never killed
|
|
948
|
+
// — but at least RENDER_STALL_TIMEOUT_MS so a
|
|
949
|
+
// stuck-at-0 wedge is still caught quickly and
|
|
950
|
+
// releases the gate.
|
|
951
|
+
val bound =
|
|
952
|
+
if (lastProgress > 0) {
|
|
953
|
+
maxOf(
|
|
954
|
+
RENDER_STALL_TIMEOUT_MS,
|
|
955
|
+
3 * (now - startedAt) / lastProgress,
|
|
956
|
+
)
|
|
957
|
+
} else {
|
|
958
|
+
RENDER_STALL_TIMEOUT_MS
|
|
959
|
+
}
|
|
960
|
+
if (trackable && now - lastAdvanceAt >= bound) {
|
|
961
|
+
failRenderStalled(lastProgress.coerceAtLeast(0) / 100.0)
|
|
962
|
+
return
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
mainHandler.postDelayed(this, RENDER_POLL_INTERVAL_MS)
|
|
966
|
+
}
|
|
967
|
+
})
|
|
968
|
+
}
|
|
969
|
+
} else {
|
|
970
|
+
onError(IllegalStateException("Failed to create composition"))
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
} catch (e: Exception) {
|
|
974
|
+
mainHandler.post {
|
|
975
|
+
onError(e)
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}.start()
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Decides whether the requested bitrate cap forces the video track through
|
|
983
|
+
* the encoder (see [BitrateCapPolicy]).
|
|
984
|
+
*
|
|
985
|
+
* Called with the final (post pre-transcode/reverse/transition) config, so
|
|
986
|
+
* the probed files are exactly what the transformer will read. Sources
|
|
987
|
+
* already within cap × tolerance keep Media3's lossless transmux fast
|
|
988
|
+
* path; anything over budget — or unprobeable — is re-encoded so
|
|
989
|
+
* [ResilientVideoEncoderFactory] actually applies the bitrate.
|
|
990
|
+
*/
|
|
991
|
+
private fun shouldForceVideoEncodeForBitrateCap(config: RenderConfig): Boolean {
|
|
992
|
+
val cap = config.bitrate ?: return false
|
|
993
|
+
val clipPaths = buildList {
|
|
994
|
+
config.videoClips.forEach { add(it.inputPath) }
|
|
995
|
+
config.composition?.layers?.forEach { layer ->
|
|
996
|
+
layer.clips.forEach { add(it.inputPath) }
|
|
997
|
+
}
|
|
998
|
+
}.distinct()
|
|
999
|
+
if (clipPaths.isEmpty()) return false
|
|
1000
|
+
|
|
1001
|
+
val sourceBitrates = clipPaths.map { MediaInfoExtractor.getVideoBitrate(it) }
|
|
1002
|
+
val forceEncode = BitrateCapPolicy.shouldForceEncode(cap, sourceBitrates)
|
|
1003
|
+
Log.i(
|
|
1004
|
+
RENDER_TAG,
|
|
1005
|
+
"Bitrate cap ${cap / 1000} kbps: source bitrate(s) " +
|
|
1006
|
+
sourceBitrates.joinToString { "${(it ?: -1) / 1000} kbps" } +
|
|
1007
|
+
if (forceEncode) {
|
|
1008
|
+
" exceed cap × ${BitrateCapPolicy.TOLERANCE} — forcing video re-encode"
|
|
1009
|
+
} else {
|
|
1010
|
+
" within cap × ${BitrateCapPolicy.TOLERANCE} — transmux fast path allowed"
|
|
1011
|
+
}
|
|
1012
|
+
)
|
|
1013
|
+
return forceEncode
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Translates a Media3 [ExportException] into a more specific, descriptive
|
|
1018
|
+
* error where possible.
|
|
1019
|
+
*
|
|
1020
|
+
* Media3 reports two very different failures under the same error codes, so
|
|
1021
|
+
* the cause chain is inspected ([EncoderFailureClassifier]) to tell them
|
|
1022
|
+
* apart:
|
|
1023
|
+
*
|
|
1024
|
+
* - transient codec-resource pressure (exhausted codec pool / reclaimed
|
|
1025
|
+
* session) on either the encoder or a decoder — the same export succeeds
|
|
1026
|
+
* on a retry, so it becomes a [CodecResourceExhaustedException],
|
|
1027
|
+
* - a genuinely incompatible encoder configuration (which survived the
|
|
1028
|
+
* [ResilientVideoEncoderFactory] fallback chain) — retrying is pointless,
|
|
1029
|
+
* so it becomes a [VideoEncoderConfigurationException].
|
|
1030
|
+
*
|
|
1031
|
+
* Both are typed so this module can show a proper error state instead
|
|
1032
|
+
* of a generic render failure. All other failures — including a *permanent*
|
|
1033
|
+
* decoder failure, which is a source-media problem rather than an encoder
|
|
1034
|
+
* one — are passed through unchanged.
|
|
1035
|
+
*/
|
|
1036
|
+
private fun mapExportException(exception: ExportException): Throwable {
|
|
1037
|
+
if (exception.errorCode !in CODEC_INIT_ERROR_CODES) return exception
|
|
1038
|
+
|
|
1039
|
+
val detail = "Underlying error: ${exception.getErrorCodeName()} - " +
|
|
1040
|
+
"${exception.message}"
|
|
1041
|
+
|
|
1042
|
+
if (EncoderFailureClassifier.isTransientResourceFailure(exception)) {
|
|
1043
|
+
return CodecResourceExhaustedException(
|
|
1044
|
+
"The video codec could not be acquired: the device's codec " +
|
|
1045
|
+
"resources are exhausted or the codec session was " +
|
|
1046
|
+
"reclaimed. This is transient — retrying once other " +
|
|
1047
|
+
"codec sessions are released is expected to succeed. " +
|
|
1048
|
+
detail,
|
|
1049
|
+
exception,
|
|
1050
|
+
)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// A permanent decoder failure says nothing about the encoder, so it is
|
|
1054
|
+
// not dressed up as an encoder-configuration problem.
|
|
1055
|
+
if (exception.errorCode == ExportException.ERROR_CODE_DECODER_INIT_FAILED) {
|
|
1056
|
+
return exception
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return VideoEncoderConfigurationException(
|
|
1060
|
+
"The video encoder rejected the export configuration after " +
|
|
1061
|
+
"exhausting all fallbacks (operating-rate cap/removal, " +
|
|
1062
|
+
"software encoder, profile downgrade). " + detail,
|
|
1063
|
+
exception,
|
|
1064
|
+
)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Deletes pre-rendered audio temp files (typically WAVs from
|
|
1069
|
+
* AudioPreRenderer) created while building the composition.
|
|
1070
|
+
*/
|
|
1071
|
+
private fun cleanupAudioTempFiles(files: List<File>) {
|
|
1072
|
+
for (file in files) {
|
|
1073
|
+
try {
|
|
1074
|
+
if (file.exists()) {
|
|
1075
|
+
val deleted = file.delete()
|
|
1076
|
+
Log.d(
|
|
1077
|
+
RENDER_TAG,
|
|
1078
|
+
"Cleanup pre-rendered audio file: ${file.name}, deleted=$deleted"
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
} catch (e: Exception) {
|
|
1082
|
+
Log.w(
|
|
1083
|
+
RENDER_TAG,
|
|
1084
|
+
"Failed to delete pre-rendered audio file ${file.name}: ${e.message}"
|
|
1085
|
+
)
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|