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.
Files changed (125) hide show
  1. package/.prettierrc +8 -0
  2. package/LICENSE +67 -0
  3. package/NOTICE.md +250 -0
  4. package/README.md +70 -0
  5. package/android/build.gradle +35 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/expo/modules/provideoeditor/ExpoProVideoEditorModule.kt +163 -0
  8. package/android/src/main/java/expo/modules/provideoeditor/src/core/constants/LoggingConstants.kt +4 -0
  9. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/EffectsProcessor.kt +66 -0
  10. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/RenderVideo.kt +1089 -0
  11. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyAnimation.kt +290 -0
  12. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBitrate.kt +110 -0
  13. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBlur.kt +27 -0
  14. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyChromaKey.kt +58 -0
  15. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyClipTransition.kt +88 -0
  16. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyColorMatrix.kt +144 -0
  17. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyComposition.kt +86 -0
  18. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyCrop.kt +104 -0
  19. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyFlip.kt +27 -0
  20. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyFrameRate.kt +28 -0
  21. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyImageLayer.kt +397 -0
  22. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyOpacity.kt +24 -0
  23. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyPlaybackSpeed.kt +35 -0
  24. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyRotation.kt +26 -0
  25. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyScale.kt +29 -0
  26. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/AudioPreRenderer.kt +492 -0
  27. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/AudioSequenceBuilder.kt +144 -0
  28. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/BitrateCapPolicy.kt +45 -0
  29. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ChromaKeyEffect.kt +321 -0
  30. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ChromaKeyMath.kt +90 -0
  31. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ClipTransitionGeometry.kt +160 -0
  32. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ClipTransitionRenderer.kt +1002 -0
  33. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/CompositionBuilder.kt +166 -0
  34. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ConfigurableInAppMp4Muxer.kt +137 -0
  35. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/EncoderFailureClassifier.kt +71 -0
  36. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/GifDecoder.kt +76 -0
  37. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/LayeredCompositionBuilder.kt +556 -0
  38. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/MediaInfoExtractor.kt +364 -0
  39. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ResilientVideoEncoderFactory.kt +307 -0
  40. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoCompositionTransformation.kt +192 -0
  41. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoEncoderConfig.kt +179 -0
  42. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoGlobalTrimCalculator.kt +147 -0
  43. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoReverser.kt +1044 -0
  44. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoSequenceBuilder.kt +905 -0
  45. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoTimelineDurationCalculator.kt +19 -0
  46. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoTranscoder.kt +242 -0
  47. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VolumeAudioProcessor.kt +82 -0
  48. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VolumeControlAudioMixer.kt +182 -0
  49. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/CodecResourceExhaustedException.kt +24 -0
  50. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/RenderConfig.kt +572 -0
  51. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/RenderJobHandle.kt +45 -0
  52. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/VideoEncoderConfigurationException.kt +20 -0
  53. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/utils/RotatedVideoDimensions.kt +68 -0
  54. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/utils/VideoMimeUtils.kt +36 -0
  55. package/android/src/main/java/expo/modules/provideoeditor/src/shared/concurrency/ExportGate.kt +102 -0
  56. package/android/src/main/java/expo/modules/provideoeditor/src/shared/logging/PluginLog.kt +112 -0
  57. package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/EncodedImage.kt +101 -0
  58. package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/ImageOrientation.kt +265 -0
  59. package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/PcmRangeDecoder.kt +239 -0
  60. package/build/ExpoProVideoEditor.types.d.ts +125 -0
  61. package/build/ExpoProVideoEditor.types.d.ts.map +1 -0
  62. package/build/ExpoProVideoEditor.types.js +8 -0
  63. package/build/ExpoProVideoEditor.types.js.map +1 -0
  64. package/build/ExpoProVideoEditorModule.d.ts +20 -0
  65. package/build/ExpoProVideoEditorModule.d.ts.map +1 -0
  66. package/build/ExpoProVideoEditorModule.js +3 -0
  67. package/build/ExpoProVideoEditorModule.js.map +1 -0
  68. package/build/ExpoProVideoEditorModule.web.d.ts +9 -0
  69. package/build/ExpoProVideoEditorModule.web.d.ts.map +1 -0
  70. package/build/ExpoProVideoEditorModule.web.js +16 -0
  71. package/build/ExpoProVideoEditorModule.web.js.map +1 -0
  72. package/build/index.d.ts +3 -0
  73. package/build/index.d.ts.map +1 -0
  74. package/build/index.js +5 -0
  75. package/build/index.js.map +1 -0
  76. package/bun.lock +2132 -0
  77. package/eslint.config.cjs +5 -0
  78. package/expo-module.config.json +9 -0
  79. package/ios/ExpoProVideoEditor.podspec +24 -0
  80. package/ios/ExpoProVideoEditorModule.swift +131 -0
  81. package/ios/src/features/render/RenderVideo.swift +1087 -0
  82. package/ios/src/features/render/helpers/ApplyAnimation.swift +198 -0
  83. package/ios/src/features/render/helpers/ApplyBitrate.swift +112 -0
  84. package/ios/src/features/render/helpers/ApplyBlur.swift +23 -0
  85. package/ios/src/features/render/helpers/ApplyChromaKey.swift +175 -0
  86. package/ios/src/features/render/helpers/ApplyColorMatrix.swift +100 -0
  87. package/ios/src/features/render/helpers/ApplyComposition.swift +45 -0
  88. package/ios/src/features/render/helpers/ApplyCrop.swift +53 -0
  89. package/ios/src/features/render/helpers/ApplyFlip.swift +24 -0
  90. package/ios/src/features/render/helpers/ApplyImageLayer.swift +65 -0
  91. package/ios/src/features/render/helpers/ApplyPlaybackSpeed.swift +57 -0
  92. package/ios/src/features/render/helpers/ApplyRotation.swift +32 -0
  93. package/ios/src/features/render/helpers/ApplyScale.swift +30 -0
  94. package/ios/src/features/render/helpers/AudioPreRenderer.swift +352 -0
  95. package/ios/src/features/render/helpers/AudioReverser.swift +308 -0
  96. package/ios/src/features/render/helpers/AudioSequenceBuilder.swift +162 -0
  97. package/ios/src/features/render/helpers/BitrateCapPolicy.swift +77 -0
  98. package/ios/src/features/render/helpers/BitrateCappedExporter.swift +501 -0
  99. package/ios/src/features/render/helpers/ClipTransitionGeometry.swift +111 -0
  100. package/ios/src/features/render/helpers/ClipTransitionRenderer.swift +397 -0
  101. package/ios/src/features/render/helpers/CompositionBuilder.swift +464 -0
  102. package/ios/src/features/render/helpers/DecodeOrientedImage.swift +60 -0
  103. package/ios/src/features/render/helpers/LayeredCompositionBuilder.swift +371 -0
  104. package/ios/src/features/render/helpers/MediaInfoExtractor.swift +381 -0
  105. package/ios/src/features/render/helpers/TrackEndTrimmer.swift +112 -0
  106. package/ios/src/features/render/helpers/VideoSequenceBuilder.swift +555 -0
  107. package/ios/src/features/render/helpers/VideoTranscoder.swift +283 -0
  108. package/ios/src/features/render/models/RenderConfig.swift +586 -0
  109. package/ios/src/features/render/models/VideoClip.swift +81 -0
  110. package/ios/src/features/render/models/VideoCompositorConfig.swift +78 -0
  111. package/ios/src/features/render/utils/VideoCompositor.swift +1014 -0
  112. package/ios/src/features/render/utils/VideoMimeUtils.swift +24 -0
  113. package/ios/src/shared/EncodedImage.swift +75 -0
  114. package/ios/src/shared/ExportGate.swift +79 -0
  115. package/ios/src/shared/ExportSessionDriver.swift +185 -0
  116. package/ios/src/shared/ExportSessionGuard.swift +85 -0
  117. package/ios/src/shared/ExportWatchdog.swift +198 -0
  118. package/ios/src/shared/LoggingConstants.swift +7 -0
  119. package/ios/src/shared/PluginLog.swift +107 -0
  120. package/package.json +59 -0
  121. package/src/ExpoProVideoEditor.types.ts +148 -0
  122. package/src/ExpoProVideoEditorModule.ts +22 -0
  123. package/src/ExpoProVideoEditorModule.web.ts +22 -0
  124. package/src/index.ts +4 -0
  125. 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
+ }