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,492 @@
1
+ package expo.modules.provideoeditor.src.features.render.helpers
2
+
3
+ import expo.modules.provideoeditor.src.core.constants.RENDER_TAG
4
+ import android.content.Context
5
+ import android.media.MediaExtractor
6
+ import android.media.MediaFormat
7
+ import androidx.media3.common.util.UnstableApi
8
+ import expo.modules.provideoeditor.src.shared.logging.PluginLog as Log
9
+ import expo.modules.provideoeditor.src.shared.media.PcmRangeDecoder
10
+ import java.io.BufferedOutputStream
11
+ import java.io.File
12
+ import java.io.FileInputStream
13
+ import java.io.FileOutputStream
14
+ import java.io.OutputStream
15
+ import java.io.RandomAccessFile
16
+ import java.nio.ByteBuffer
17
+ import java.nio.ByteOrder
18
+
19
+ /**
20
+ * Pre-renders a custom audio track into a single, gap-less PCM WAV file
21
+ * that is ready to be inserted as ONE EditedMediaItem in the Media3
22
+ * composition.
23
+ *
24
+ * This is the core fix for audible clicks/gaps that occurred at every
25
+ * loop boundary and silence/audio transition with the previous
26
+ * implementation, where each loop iteration and silence segment was a
27
+ * separate `EditedMediaItem`. Each item boundary forced AAC encoder
28
+ * frame realignment, producing audible artifacts.
29
+ *
30
+ * The output file contains, in order:
31
+ * 1. Leading silence (matching `compositionStartUs`)
32
+ * 2. The trimmed source audio looped (or played once) to cover
33
+ * `compositionDurationUs`, with sample-exact tail trimming
34
+ * 3. Trailing silence (matching `videoDurationUs - compositionStartUs -
35
+ * compositionDurationUs`)
36
+ *
37
+ * The output sample rate, channel count and bit depth match the decoder
38
+ * output of the source file. Float PCM is converted to 16-bit signed PCM.
39
+ * Final resampling/mixing happens later inside Media3's encoder pipeline.
40
+ */
41
+ @UnstableApi
42
+ object AudioPreRenderer {
43
+
44
+ /**
45
+ * Result of a successful pre-render operation.
46
+ *
47
+ * @property outputFile The pre-rendered PCM WAV file (caller is
48
+ * responsible for deleting it when no longer needed).
49
+ * @property sampleRate Sample rate of the WAV body (Hz).
50
+ * @property channelCount Number of channels in the WAV body.
51
+ */
52
+ data class Result(
53
+ val outputFile: File,
54
+ val sampleRate: Int,
55
+ val channelCount: Int
56
+ )
57
+
58
+ /**
59
+ * Pre-renders the audio track described by the parameters.
60
+ *
61
+ * @param context Android context (used for `cacheDir`).
62
+ * @param audioPath Absolute path to the source audio file.
63
+ * @param audioStartUs Trim start within the source (microseconds, >=0).
64
+ * @param audioEndUs Trim end within the source (microseconds, null
65
+ * = use full source duration).
66
+ * @param loop If true, the trimmed window repeats to fill
67
+ * `compositionDurationUs`. If false, plays once and any remaining
68
+ * composition time is filled with silence.
69
+ * @param compositionStartUs Where on the composition timeline the
70
+ * audio body should start. The output file contains this much
71
+ * leading silence.
72
+ * @param compositionDurationUs How long the audio body should sound
73
+ * on the composition timeline.
74
+ * @param videoDurationUs Total duration of the composition (used to
75
+ * determine trailing silence).
76
+ * @return [Result] on success, null on failure (file missing, decode
77
+ * error, invalid parameters).
78
+ */
79
+ fun render(
80
+ context: Context,
81
+ audioPath: String,
82
+ audioStartUs: Long,
83
+ audioEndUs: Long?,
84
+ loop: Boolean,
85
+ compositionStartUs: Long,
86
+ compositionDurationUs: Long,
87
+ videoDurationUs: Long
88
+ ): Result? {
89
+ val sourceFile = File(audioPath)
90
+ if (!sourceFile.exists()) {
91
+ Log.e(RENDER_TAG, "AudioPreRenderer: source file not found: $audioPath")
92
+ return null
93
+ }
94
+
95
+ if (compositionDurationUs <= 0L) {
96
+ Log.w(RENDER_TAG, "AudioPreRenderer: compositionDurationUs <= 0, skipping")
97
+ return null
98
+ }
99
+
100
+ // Step 1: Decode the trimmed source range into a scratch PCM file.
101
+ val decoded = try {
102
+ decodeRange(
103
+ context.cacheDir, audioPath, audioStartUs.coerceAtLeast(0L), audioEndUs
104
+ )
105
+ } catch (e: Exception) {
106
+ Log.e(RENDER_TAG, "AudioPreRenderer: decode failed: ${e.message}")
107
+ return null
108
+ } ?: return null
109
+
110
+ try {
111
+ return assemble(
112
+ context = context,
113
+ decoded = decoded,
114
+ loop = loop,
115
+ compositionStartUs = compositionStartUs,
116
+ compositionDurationUs = compositionDurationUs,
117
+ videoDurationUs = videoDurationUs
118
+ )
119
+ } finally {
120
+ decoded.pcmFile.delete()
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Writes the WAV described by [decoded] and the composition timing, i.e.
126
+ * everything after the source has been decoded.
127
+ */
128
+ private fun assemble(
129
+ context: Context,
130
+ decoded: DecodedAudio,
131
+ loop: Boolean,
132
+ compositionStartUs: Long,
133
+ compositionDurationUs: Long,
134
+ videoDurationUs: Long
135
+ ): Result? {
136
+ if (decoded.byteLength <= 0L) {
137
+ Log.e(RENDER_TAG, "AudioPreRenderer: decoder produced no PCM data")
138
+ return null
139
+ }
140
+
141
+ val sampleRate = decoded.sampleRate
142
+ val channelCount = decoded.channelCount
143
+ val bytesPerFrame = channelCount * 2 // 16-bit PCM
144
+
145
+ // Step 2: Compute byte sizes for leading silence, audio body and
146
+ // trailing silence using the native sample rate.
147
+ val leadingSilenceBytes = alignToFrame(
148
+ usToBytes(compositionStartUs, sampleRate, bytesPerFrame),
149
+ bytesPerFrame
150
+ )
151
+ val bodyBytes = alignToFrame(
152
+ usToBytes(compositionDurationUs, sampleRate, bytesPerFrame),
153
+ bytesPerFrame
154
+ )
155
+ val totalCompositionBytes = leadingSilenceBytes + bodyBytes
156
+ val totalVideoBytes = alignToFrame(
157
+ usToBytes(videoDurationUs, sampleRate, bytesPerFrame),
158
+ bytesPerFrame
159
+ )
160
+ val trailingSilenceBytes = (totalVideoBytes - totalCompositionBytes)
161
+ .coerceAtLeast(0L)
162
+
163
+ // Step 3: Open the output WAV file and stream the data.
164
+ val outputFile = File(
165
+ context.cacheDir,
166
+ "prerender_audio_${System.currentTimeMillis()}_${System.nanoTime()}.wav"
167
+ )
168
+
169
+ try {
170
+ RandomAccessFile(outputFile, "rw").use { raf ->
171
+ writeWavHeader(raf, sampleRate, channelCount, dataSize = 0)
172
+
173
+ writeSilence(raf, leadingSilenceBytes)
174
+
175
+ val bodyBytesWritten = writeAudioBody(
176
+ raf = raf,
177
+ sourcePcm = decoded.pcmFile,
178
+ targetBytes = bodyBytes,
179
+ loop = loop,
180
+ bytesPerFrame = bytesPerFrame
181
+ )
182
+
183
+ writeSilence(raf, trailingSilenceBytes)
184
+
185
+ // Update RIFF/data chunk sizes in the header.
186
+ val totalDataBytes =
187
+ leadingSilenceBytes + bodyBytesWritten + trailingSilenceBytes
188
+ updateWavSizes(raf, totalDataBytes)
189
+ }
190
+ } catch (e: Exception) {
191
+ Log.e(RENDER_TAG, "AudioPreRenderer: write failed: ${e.message}")
192
+ outputFile.delete()
193
+ return null
194
+ }
195
+
196
+ Log.d(
197
+ RENDER_TAG,
198
+ "AudioPreRenderer: rendered ${outputFile.length()} bytes, " +
199
+ "${sampleRate}Hz x ${channelCount}ch, " +
200
+ "leadSilence=${compositionStartUs / 1000}ms, " +
201
+ "body=${compositionDurationUs / 1000}ms, " +
202
+ "loop=$loop"
203
+ )
204
+
205
+ return Result(outputFile, sampleRate, channelCount)
206
+ }
207
+
208
+ // ---------------------------------------------------------------------
209
+ // Internal: decoding
210
+ // ---------------------------------------------------------------------
211
+
212
+ /**
213
+ * The decoded source range, spooled to disk.
214
+ *
215
+ * @property pcmFile Scratch file holding the raw PCM; the caller deletes it.
216
+ * @property byteLength How many PCM bytes [pcmFile] holds.
217
+ */
218
+ private class DecodedAudio(
219
+ val pcmFile: File,
220
+ val byteLength: Long,
221
+ val sampleRate: Int,
222
+ val channelCount: Int
223
+ )
224
+
225
+ /**
226
+ * Decodes the audio range `[startUs, endUs)` from `path` into a scratch file
227
+ * of 16-bit signed little-endian PCM.
228
+ *
229
+ * The PCM goes to disk rather than into a byte array because it is the one
230
+ * allocation here that grows with the clip: a three-minute stereo track
231
+ * decodes to ~30 MB, and holding that (plus the copy every array growth
232
+ * makes) is enough to push an export over Android's managed-heap limit.
233
+ *
234
+ * Float PCM is converted to int16. Output sample rate / channel
235
+ * count match the decoder output.
236
+ */
237
+ private fun decodeRange(
238
+ cacheDir: File,
239
+ path: String,
240
+ startUs: Long,
241
+ endUs: Long?
242
+ ): DecodedAudio? {
243
+ val extractor = MediaExtractor()
244
+ val pcmFile = File(
245
+ cacheDir,
246
+ "prerender_pcm_${System.currentTimeMillis()}_${System.nanoTime()}.raw"
247
+ )
248
+ var pcmOutput: OutputStream? = null
249
+ var decodedFully = false
250
+
251
+ try {
252
+ extractor.setDataSource(path)
253
+
254
+ var audioTrackIndex = -1
255
+ var inputFormat: MediaFormat? = null
256
+ for (i in 0 until extractor.trackCount) {
257
+ val format = extractor.getTrackFormat(i)
258
+ val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
259
+ if (mime.startsWith("audio/")) {
260
+ audioTrackIndex = i
261
+ inputFormat = format
262
+ break
263
+ }
264
+ }
265
+
266
+ if (audioTrackIndex < 0 || inputFormat == null) {
267
+ Log.e(RENDER_TAG, "AudioPreRenderer: no audio track in $path")
268
+ return null
269
+ }
270
+
271
+ val sink = BufferedOutputStream(FileOutputStream(pcmFile))
272
+ pcmOutput = sink
273
+
274
+ val effectiveEndUs = endUs ?: Long.MAX_VALUE
275
+ var pcmByteLength = 0L
276
+ var sampleRate = 0
277
+ var channelCount = 0
278
+
279
+ // The extractor seeks to the sync frame at or before `startUs`, so
280
+ // the first buffer usually begins early. Dropping that lead-in — and
281
+ // the tail past `endUs` — is what makes the trim sample-exact, which
282
+ // is what keeps a loop boundary gap-less.
283
+ var hasCrossedStart = false
284
+
285
+ PcmRangeDecoder.decode(
286
+ extractor = extractor,
287
+ audioTrackIndex = audioTrackIndex,
288
+ inputFormat = inputFormat,
289
+ startUs = startUs,
290
+ endUs = effectiveEndUs,
291
+ stopAfterEndUs = true,
292
+ onFormat = { format ->
293
+ sampleRate = format.sampleRate
294
+ channelCount = format.channelCount
295
+ },
296
+ onPcm = { pcm, bufferStartUs, format ->
297
+ val bytesPerFrame = (format.channelCount * 2).coerceAtLeast(1)
298
+ val rate = format.sampleRate
299
+
300
+ val skipBytes = if (!hasCrossedStart && rate > 0) {
301
+ val deltaUs = (startUs - bufferStartUs).coerceAtLeast(0L)
302
+ ((deltaUs * rate / 1_000_000L) * bytesPerFrame)
303
+ .coerceAtMost(pcm.size.toLong()).toInt()
304
+ } else 0
305
+
306
+ val dropBytes = if (effectiveEndUs != Long.MAX_VALUE && rate > 0) {
307
+ val frameDurationUs = 1_000_000.0 / rate
308
+ val bufferEndUs = bufferStartUs +
309
+ ((pcm.size / bytesPerFrame) * frameDurationUs).toLong()
310
+ if (bufferEndUs > effectiveEndUs) {
311
+ val overUs = bufferEndUs - effectiveEndUs
312
+ ((overUs * rate / 1_000_000L) * bytesPerFrame)
313
+ .coerceAtMost((pcm.size - skipBytes).toLong()).toInt()
314
+ } else 0
315
+ } else 0
316
+
317
+ val writeLen = pcm.size - skipBytes - dropBytes
318
+ if (writeLen > 0) {
319
+ sink.write(pcm, skipBytes, writeLen)
320
+ pcmByteLength += writeLen
321
+ hasCrossedStart = true
322
+ }
323
+ },
324
+ )
325
+
326
+ sink.flush()
327
+
328
+ if (sampleRate <= 0 || channelCount <= 0) {
329
+ Log.e(RENDER_TAG, "AudioPreRenderer: decoder reported no usable PCM format")
330
+ return null
331
+ }
332
+
333
+ decodedFully = true
334
+ return DecodedAudio(
335
+ pcmFile = pcmFile,
336
+ byteLength = pcmByteLength,
337
+ sampleRate = sampleRate,
338
+ channelCount = channelCount
339
+ )
340
+ } finally {
341
+ try {
342
+ pcmOutput?.close()
343
+ } catch (_: Exception) {
344
+ }
345
+ try {
346
+ extractor.release()
347
+ } catch (_: Exception) {
348
+ }
349
+ // A range that never made it to a DecodedAudio owns no scratch file.
350
+ if (!decodedFully) pcmFile.delete()
351
+ }
352
+ }
353
+
354
+ // ---------------------------------------------------------------------
355
+ // Internal: WAV writing
356
+ // ---------------------------------------------------------------------
357
+
358
+ private fun writeWavHeader(
359
+ raf: RandomAccessFile,
360
+ sampleRate: Int,
361
+ channelCount: Int,
362
+ dataSize: Int
363
+ ) {
364
+ val bitsPerSample = 16
365
+ val byteRate = sampleRate * channelCount * bitsPerSample / 8
366
+ val blockAlign = (channelCount * bitsPerSample / 8).toShort()
367
+
368
+ val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
369
+ header.put("RIFF".toByteArray(Charsets.US_ASCII))
370
+ header.putInt(36 + dataSize) // RIFF chunk size
371
+ header.put("WAVE".toByteArray(Charsets.US_ASCII))
372
+ header.put("fmt ".toByteArray(Charsets.US_ASCII))
373
+ header.putInt(16) // fmt subchunk size (PCM)
374
+ header.putShort(1) // PCM format
375
+ header.putShort(channelCount.toShort())
376
+ header.putInt(sampleRate)
377
+ header.putInt(byteRate)
378
+ header.putShort(blockAlign)
379
+ header.putShort(bitsPerSample.toShort())
380
+ header.put("data".toByteArray(Charsets.US_ASCII))
381
+ header.putInt(dataSize)
382
+
383
+ raf.seek(0)
384
+ raf.write(header.array())
385
+ }
386
+
387
+ /**
388
+ * Writes the measured [dataSize] into the RIFF and data chunk headers.
389
+ *
390
+ * RIFF sizes are *unsigned* 32-bit, so they are clamped at `0xFFFFFFFF`
391
+ * (~4 GB, the format's ceiling) rather than at [Int.MAX_VALUE] — clamping at
392
+ * half the range would still let `36 + dataSize` wrap negative and write a
393
+ * size no reader accepts, which is the corrupt-file case the measured byte
394
+ * count exists to prevent.
395
+ */
396
+ internal fun updateWavSizes(raf: RandomAccessFile, dataSize: Long) {
397
+ val safeDataSize = dataSize.coerceIn(0L, MAX_RIFF_SIZE)
398
+ val riffSize = (36L + safeDataSize).coerceAtMost(MAX_RIFF_SIZE)
399
+ // RIFF chunk size at offset 4 (little-endian).
400
+ raf.seek(4)
401
+ raf.write(uInt32ToLittleEndian(riffSize))
402
+ // data chunk size at offset 40 (little-endian).
403
+ raf.seek(40)
404
+ raf.write(uInt32ToLittleEndian(safeDataSize))
405
+ // Move back to end so subsequent writes append correctly.
406
+ raf.seek(raf.length())
407
+ }
408
+
409
+ private fun writeSilence(raf: RandomAccessFile, byteCount: Long) {
410
+ if (byteCount <= 0L) return
411
+ val chunk = ByteArray(8192)
412
+ var remaining = byteCount
413
+ while (remaining > 0) {
414
+ val toWrite = minOf(remaining, chunk.size.toLong()).toInt()
415
+ raf.write(chunk, 0, toWrite)
416
+ remaining -= toWrite
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Fills [targetBytes] of audio body into [raf]: [sourcePcm] copied in,
422
+ * replayed from the start as often as needed when [loop] is set, and
423
+ * whatever the source cannot cover left silent.
424
+ *
425
+ * That silence is the documented behavior for a non-looping track shorter
426
+ * than its slot (see [render]) — and dropping the gap instead would end the
427
+ * WAV early, finishing the export short of the video. Covering it here
428
+ * rather than at the call site keeps the two from drifting apart.
429
+ *
430
+ * Returns how many bytes were written, which is [targetBytes] rounded down
431
+ * to a frame boundary. The count is what the WAV header declares, and a
432
+ * header that disagrees with the body by even one frame makes the file
433
+ * unreadable.
434
+ */
435
+ internal fun writeAudioBody(
436
+ raf: RandomAccessFile,
437
+ sourcePcm: File,
438
+ targetBytes: Long,
439
+ loop: Boolean,
440
+ bytesPerFrame: Int
441
+ ): Long {
442
+ if (targetBytes <= 0L) return 0L
443
+
444
+ // Align targetBytes to frame boundary (defensive).
445
+ val alignedTarget = (targetBytes / bytesPerFrame) * bytesPerFrame
446
+ val buffer = ByteArray(1 shl 16)
447
+ var written = 0L
448
+
449
+ while (written < alignedTarget) {
450
+ val passStart = written
451
+ FileInputStream(sourcePcm).use { source ->
452
+ while (written < alignedTarget) {
453
+ val wanted = minOf(buffer.size.toLong(), alignedTarget - written).toInt()
454
+ val read = source.read(buffer, 0, wanted)
455
+ if (read <= 0) break
456
+ raf.write(buffer, 0, read)
457
+ written += read
458
+ }
459
+ }
460
+ // An empty (or vanished) source would otherwise spin here forever.
461
+ if (written == passStart || !loop) break
462
+ }
463
+
464
+ writeSilence(raf, alignedTarget - written)
465
+ return alignedTarget
466
+ }
467
+
468
+ // ---------------------------------------------------------------------
469
+ // Internal: math helpers
470
+ // ---------------------------------------------------------------------
471
+
472
+ private fun usToBytes(durationUs: Long, sampleRate: Int, bytesPerFrame: Int): Long {
473
+ if (durationUs <= 0L) return 0L
474
+ // (durationUs * sampleRate / 1_000_000) frames * bytesPerFrame
475
+ // Use Math.multiplyExact-style guard via Long multiplication.
476
+ val frames = (durationUs.toDouble() * sampleRate / 1_000_000.0).toLong()
477
+ return frames * bytesPerFrame
478
+ }
479
+
480
+ private fun alignToFrame(byteCount: Long, bytesPerFrame: Int): Long {
481
+ if (bytesPerFrame <= 1) return byteCount
482
+ return (byteCount / bytesPerFrame) * bytesPerFrame
483
+ }
484
+
485
+ /** [value] as the four little-endian bytes of an unsigned 32-bit field. */
486
+ private fun uInt32ToLittleEndian(value: Long): ByteArray {
487
+ return ByteArray(4) { i -> ((value ushr (8 * i)) and 0xFF).toByte() }
488
+ }
489
+
490
+ /** The largest value a RIFF/data chunk size field can hold. */
491
+ private const val MAX_RIFF_SIZE = 0xFFFFFFFFL
492
+ }
@@ -0,0 +1,144 @@
1
+ package expo.modules.provideoeditor.src.features.render.helpers
2
+
3
+ import expo.modules.provideoeditor.src.core.constants.RENDER_TAG
4
+ import android.content.Context
5
+ import android.net.Uri
6
+ import androidx.media3.common.MediaItem
7
+ import androidx.media3.common.audio.AudioProcessor
8
+ import androidx.media3.common.util.UnstableApi
9
+ import androidx.media3.transformer.EditedMediaItem
10
+ import androidx.media3.transformer.EditedMediaItemSequence
11
+ import androidx.media3.transformer.Effects
12
+ import expo.modules.provideoeditor.src.shared.logging.PluginLog as Log
13
+ import java.io.File
14
+
15
+ /**
16
+ * Builder class for creating custom audio sequences in video compositions.
17
+ *
18
+ * Pre-renders the entire audio track (leading silence + looped/clipped
19
+ * source + trailing silence) into a single PCM WAV file via
20
+ * [AudioPreRenderer], then exposes it as ONE [EditedMediaItem] inside an
21
+ * [EditedMediaItemSequence].
22
+ *
23
+ * Using a single item per audio track removes the AAC frame realignment
24
+ * artifacts (clicks/gaps) that previously occurred at every loop boundary
25
+ * and silence/audio transition when the sequence contained multiple
26
+ * items.
27
+ *
28
+ * The generated WAV file path is returned from [build] alongside the
29
+ * sequence so the caller can register it for cleanup once the
30
+ * Transformer export finishes.
31
+ */
32
+ @UnstableApi
33
+ class AudioSequenceBuilder(
34
+ private val context: Context,
35
+ private val audioPath: String,
36
+ private val videoDurationUs: Long
37
+ ) {
38
+ /**
39
+ * The result of a successful [build]: the audio sequence ready to be
40
+ * added to the composition, plus the temporary WAV file that must be
41
+ * deleted by the caller after rendering completes.
42
+ */
43
+ data class BuildResult(
44
+ val sequence: EditedMediaItemSequence,
45
+ val temporaryFile: File
46
+ )
47
+
48
+ private var loopAudio: Boolean = true
49
+ private var startTimeUs: Long = 0
50
+ private var audioEndTimeUs: Long? = null
51
+ private var compositionStartTimeUs: Long? = null
52
+ private var compositionEndTimeUs: Long? = null
53
+
54
+ /**
55
+ * Sets whether the audio should loop to fill the play range.
56
+ */
57
+ fun setLoop(loop: Boolean): AudioSequenceBuilder {
58
+ this.loopAudio = loop
59
+ return this
60
+ }
61
+
62
+ /**
63
+ * Sets the start time offset within the source audio file.
64
+ */
65
+ fun setStartTime(startTimeUs: Long?): AudioSequenceBuilder {
66
+ this.startTimeUs = (startTimeUs ?: 0L).coerceAtLeast(0L)
67
+ return this
68
+ }
69
+
70
+ /**
71
+ * Sets the end time within the source audio file.
72
+ */
73
+ fun setAudioEndTime(endTimeUs: Long?): AudioSequenceBuilder {
74
+ this.audioEndTimeUs = endTimeUs
75
+ return this
76
+ }
77
+
78
+ /**
79
+ * Sets when this audio track should start playing on the composition
80
+ * timeline.
81
+ */
82
+ fun setCompositionStartTime(startTimeUs: Long?): AudioSequenceBuilder {
83
+ this.compositionStartTimeUs = startTimeUs
84
+ return this
85
+ }
86
+
87
+ /**
88
+ * Sets when this audio track should stop playing on the composition
89
+ * timeline.
90
+ */
91
+ fun setCompositionEndTime(endTimeUs: Long?): AudioSequenceBuilder {
92
+ this.compositionEndTimeUs = endTimeUs
93
+ return this
94
+ }
95
+
96
+ /**
97
+ * Builds the audio sequence by pre-rendering the source into a single
98
+ * gap-less PCM WAV file.
99
+ *
100
+ * @return [BuildResult] with the sequence and the temporary file to
101
+ * delete after export, or null if the audio could not be prepared.
102
+ */
103
+ fun build(): BuildResult? {
104
+ val sourceFile = File(audioPath)
105
+ if (!sourceFile.exists()) {
106
+ Log.e(RENDER_TAG, "Custom audio file not found: $audioPath")
107
+ return null
108
+ }
109
+
110
+ val compStart = (compositionStartTimeUs ?: 0L).coerceAtLeast(0L)
111
+ val compEnd = (compositionEndTimeUs ?: videoDurationUs)
112
+ .coerceAtMost(videoDurationUs)
113
+ val playDurationUs = (compEnd - compStart).coerceAtLeast(0L)
114
+
115
+ if (playDurationUs <= 0L) {
116
+ Log.w(RENDER_TAG, "Custom audio play duration <= 0, skipping track")
117
+ return null
118
+ }
119
+
120
+ val preRender = AudioPreRenderer.render(
121
+ context = context,
122
+ audioPath = audioPath,
123
+ audioStartUs = startTimeUs,
124
+ audioEndUs = audioEndTimeUs,
125
+ loop = loopAudio,
126
+ compositionStartUs = compStart,
127
+ compositionDurationUs = playDurationUs,
128
+ videoDurationUs = videoDurationUs
129
+ ) ?: return null
130
+
131
+ val mediaItem = MediaItem.Builder()
132
+ .setUri(Uri.fromFile(preRender.outputFile))
133
+ .build()
134
+
135
+ val editedItem = EditedMediaItem.Builder(mediaItem)
136
+ .setRemoveVideo(true)
137
+ .setEffects(Effects(emptyList<AudioProcessor>(), emptyList()))
138
+ .build()
139
+
140
+ val sequence = EditedMediaItemSequence.Builder(editedItem).build()
141
+
142
+ return BuildResult(sequence, preRender.outputFile)
143
+ }
144
+ }
@@ -0,0 +1,45 @@
1
+ package expo.modules.provideoeditor.src.features.render.helpers
2
+
3
+ /**
4
+ * Decides whether a requested bitrate cap forces the video track through the
5
+ * encoder instead of Media3's lossless transmux fast path.
6
+ *
7
+ * The requested bitrate is a guaranteed *maximum* for the rendered output.
8
+ * Transmuxing copies the source samples verbatim, so it is only allowed when
9
+ * every source video track already fits the cap (within [TOLERANCE], so
10
+ * compliant sources keep the near-instant lossless path).
11
+ *
12
+ * Kept free of Android framework dependencies so it is unit-testable; the
13
+ * actual source bitrates are probed by `MediaInfoExtractor.getVideoBitrate`.
14
+ */
15
+ object BitrateCapPolicy {
16
+
17
+ /**
18
+ * Sources up to cap × tolerance keep the lossless fast path. The headroom
19
+ * absorbs probe inaccuracy (the container-level fallback includes audio
20
+ * and muxer overhead) and encoder rate-control drift around the target.
21
+ */
22
+ const val TOLERANCE = 1.2
23
+
24
+ /**
25
+ * Returns true when the video track must be re-encoded to honor the cap.
26
+ *
27
+ * @param requestedBitrate Requested maximum in bits per second, or null
28
+ * when no cap was requested (never forces encoding).
29
+ * @param sourceBitrates Probed bitrate of each source video, in bits per
30
+ * second. A null entry means the bitrate could not be determined — the
31
+ * cap cannot be proven, so encoding is forced.
32
+ * @param tolerance Multiplier on the cap below which a source counts as
33
+ * compliant.
34
+ */
35
+ fun shouldForceEncode(
36
+ requestedBitrate: Int?,
37
+ sourceBitrates: List<Long?>,
38
+ tolerance: Double = TOLERANCE,
39
+ ): Boolean {
40
+ if (requestedBitrate == null) return false
41
+ if (sourceBitrates.isEmpty()) return false
42
+ val budget = requestedBitrate.toDouble() * tolerance
43
+ return sourceBitrates.any { it == null || it.toDouble() > budget }
44
+ }
45
+ }