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,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
|
+
}
|