@projectyoked/expo-media-engine 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/build.gradle +22 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/projectyoked/mediaengine/MediaEngineModule.kt +191 -0
- package/android/src/main/java/com/projectyoked/mediaengine/VideoComposer.kt +611 -0
- package/expo-module.config.json +17 -0
- package/ios/MediaEngine.podspec +27 -0
- package/ios/MediaEngineModule.swift +374 -0
- package/package.json +77 -0
- package/src/index.d.ts +84 -0
- package/src/index.js +42 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
package com.projectyoked.mediaengine
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.BitmapFactory
|
|
5
|
+
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Color
|
|
7
|
+
import android.graphics.Paint
|
|
8
|
+
import android.graphics.Rect
|
|
9
|
+
import android.graphics.Typeface
|
|
10
|
+
import android.graphics.YuvImage
|
|
11
|
+
import android.media.MediaCodec
|
|
12
|
+
import android.media.MediaCodecInfo
|
|
13
|
+
import android.media.MediaExtractor
|
|
14
|
+
import android.media.MediaFormat
|
|
15
|
+
import android.media.MediaMuxer
|
|
16
|
+
import android.util.Log
|
|
17
|
+
import java.io.ByteArrayOutputStream
|
|
18
|
+
import java.io.File
|
|
19
|
+
import java.nio.ByteBuffer
|
|
20
|
+
import java.util.ArrayList
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Robust VideoComposer
|
|
24
|
+
* Implements complete Video Pipeline (YUV->Bitmap->YUV->H.264)
|
|
25
|
+
* Implements complete Audio Pipeline (Format->PCM->AAC)
|
|
26
|
+
* Handles Muxer interleaving via Audio Buffering.
|
|
27
|
+
*/
|
|
28
|
+
class VideoComposer(
|
|
29
|
+
private val inputPath: String,
|
|
30
|
+
private val outputPath: String
|
|
31
|
+
) {
|
|
32
|
+
private val TAG = "VideoComposer"
|
|
33
|
+
|
|
34
|
+
// Media Components
|
|
35
|
+
private var videoExtractor: MediaExtractor? = null
|
|
36
|
+
private var audioExtractor: MediaExtractor? = null
|
|
37
|
+
private var videoDecoder: MediaCodec? = null
|
|
38
|
+
private var videoEncoder: MediaCodec? = null
|
|
39
|
+
private var audioDecoder: MediaCodec? = null
|
|
40
|
+
private var audioEncoder: MediaCodec? = null
|
|
41
|
+
private var muxer: MediaMuxer? = null
|
|
42
|
+
|
|
43
|
+
// Config
|
|
44
|
+
private var videoWidth = 0
|
|
45
|
+
private var videoHeight = 0
|
|
46
|
+
private var realWidth = 0
|
|
47
|
+
private var realHeight = 0
|
|
48
|
+
private var videoBitrate = 2000000
|
|
49
|
+
private var videoFrameRate = 30
|
|
50
|
+
private var rotation = 0
|
|
51
|
+
private var muxerStarted = false
|
|
52
|
+
|
|
53
|
+
// Track Management
|
|
54
|
+
private var videoTrackIndex = -1
|
|
55
|
+
private var audioTrackIndex = -1
|
|
56
|
+
private var sourceAudioTrackIndex = -1
|
|
57
|
+
|
|
58
|
+
// Audio Buffering for Interleaving
|
|
59
|
+
private data class AudioSample(
|
|
60
|
+
val data: ByteArray,
|
|
61
|
+
val info: MediaCodec.BufferInfo
|
|
62
|
+
)
|
|
63
|
+
private val audioBuffer = ArrayList<AudioSample>()
|
|
64
|
+
|
|
65
|
+
data class TextOverlay(
|
|
66
|
+
val text: String,
|
|
67
|
+
val x: Double,
|
|
68
|
+
val y: Double,
|
|
69
|
+
val color: String,
|
|
70
|
+
val size: Double,
|
|
71
|
+
val start: Double,
|
|
72
|
+
val duration: Double
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
data class EmojiOverlay(
|
|
76
|
+
val emoji: String,
|
|
77
|
+
val x: Double,
|
|
78
|
+
val y: Double,
|
|
79
|
+
val size: Double,
|
|
80
|
+
val start: Double,
|
|
81
|
+
val duration: Double
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
fun composeVideo(
|
|
85
|
+
textOverlays: List<TextOverlay>,
|
|
86
|
+
emojiOverlays: List<EmojiOverlay>,
|
|
87
|
+
filterId: String?,
|
|
88
|
+
filterIntensity: Double,
|
|
89
|
+
musicPath: String?,
|
|
90
|
+
musicVolume: Double,
|
|
91
|
+
originalVolume: Double
|
|
92
|
+
): String {
|
|
93
|
+
Log.d(TAG, "Starting composition for: $inputPath")
|
|
94
|
+
try {
|
|
95
|
+
setupExtractors()
|
|
96
|
+
|
|
97
|
+
// Validate dimensions
|
|
98
|
+
if (videoWidth % 2 != 0) videoWidth--
|
|
99
|
+
if (videoHeight % 2 != 0) videoHeight--
|
|
100
|
+
|
|
101
|
+
setupMuxer()
|
|
102
|
+
|
|
103
|
+
// 1. Process Audio First (Buffer it)
|
|
104
|
+
// This ensures we have the Audio Track format ready for the Muxer
|
|
105
|
+
if (originalVolume > 0 && sourceAudioTrackIndex != -1) {
|
|
106
|
+
Log.d(TAG, "Processing audio track...")
|
|
107
|
+
processAudioTranscode()
|
|
108
|
+
} else {
|
|
109
|
+
Log.d(TAG, "Skipping audio (Volume: $originalVolume, TrackIndex: $sourceAudioTrackIndex)")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. Process Video & Mux (Interleave)
|
|
113
|
+
Log.d(TAG, "Processing video track...")
|
|
114
|
+
processVideo(textOverlays, emojiOverlays, filterId, filterIntensity)
|
|
115
|
+
|
|
116
|
+
return outputPath
|
|
117
|
+
} catch (e: Exception) {
|
|
118
|
+
Log.e(TAG, "Composition fatal error", e)
|
|
119
|
+
throw Exception("Video composition failed: ${e.message}")
|
|
120
|
+
} finally {
|
|
121
|
+
cleanup()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private fun setupExtractors() {
|
|
126
|
+
val vExtractor = MediaExtractor()
|
|
127
|
+
vExtractor.setDataSource(inputPath)
|
|
128
|
+
var videoFound = false
|
|
129
|
+
for (i in 0 until vExtractor.trackCount) {
|
|
130
|
+
val format = vExtractor.getTrackFormat(i)
|
|
131
|
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: ""
|
|
132
|
+
if (mime.startsWith("video/")) {
|
|
133
|
+
vExtractor.selectTrack(i)
|
|
134
|
+
videoFound = true
|
|
135
|
+
videoWidth = safeGetInteger(format, MediaFormat.KEY_WIDTH, 1280)
|
|
136
|
+
videoHeight = safeGetInteger(format, MediaFormat.KEY_HEIGHT, 720)
|
|
137
|
+
realWidth = videoWidth
|
|
138
|
+
realHeight = videoHeight
|
|
139
|
+
videoBitrate = safeGetInteger(format, MediaFormat.KEY_BIT_RATE, 2000000)
|
|
140
|
+
if (format.containsKey(MediaFormat.KEY_ROTATION)) {
|
|
141
|
+
rotation = format.getInteger(MediaFormat.KEY_ROTATION)
|
|
142
|
+
if (rotation == 90 || rotation == 270) {
|
|
143
|
+
val t = videoWidth; videoWidth = videoHeight; videoHeight = t
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
videoExtractor = vExtractor
|
|
150
|
+
if (!videoFound) throw Exception("No video track found")
|
|
151
|
+
|
|
152
|
+
val aExtractor = MediaExtractor()
|
|
153
|
+
try {
|
|
154
|
+
aExtractor.setDataSource(inputPath)
|
|
155
|
+
for (i in 0 until aExtractor.trackCount) {
|
|
156
|
+
val format = aExtractor.getTrackFormat(i)
|
|
157
|
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: ""
|
|
158
|
+
if (mime.startsWith("audio/")) {
|
|
159
|
+
aExtractor.selectTrack(i)
|
|
160
|
+
sourceAudioTrackIndex = i
|
|
161
|
+
audioExtractor = aExtractor
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (e: Exception) {
|
|
166
|
+
Log.w(TAG, "Audio extractor init failed", e)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private fun setupMuxer() {
|
|
171
|
+
val file = File(outputPath)
|
|
172
|
+
if (file.exists()) file.delete()
|
|
173
|
+
muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
|
174
|
+
if (rotation != 0) muxer?.setOrientationHint(rotation)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- Audio Pipeline ---
|
|
178
|
+
|
|
179
|
+
private fun processAudioTranscode() {
|
|
180
|
+
val extractor = audioExtractor ?: return
|
|
181
|
+
val inputFormat = extractor.getTrackFormat(sourceAudioTrackIndex)
|
|
182
|
+
val mime = inputFormat.getString(MediaFormat.KEY_MIME) ?: ""
|
|
183
|
+
|
|
184
|
+
audioDecoder = MediaCodec.createDecoderByType(mime).apply {
|
|
185
|
+
configure(inputFormat, null, null, 0)
|
|
186
|
+
start()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
val sampleRate = safeGetInteger(inputFormat, MediaFormat.KEY_SAMPLE_RATE, 44100)
|
|
190
|
+
val channelCount = safeGetInteger(inputFormat, MediaFormat.KEY_CHANNEL_COUNT, 2)
|
|
191
|
+
|
|
192
|
+
val outputFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount).apply {
|
|
193
|
+
setInteger(MediaFormat.KEY_BIT_RATE, 128000)
|
|
194
|
+
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
audioEncoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
|
|
198
|
+
configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
|
199
|
+
start()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
val decoder = audioDecoder!!
|
|
203
|
+
val encoder = audioEncoder!!
|
|
204
|
+
val bufferInfo = MediaCodec.BufferInfo()
|
|
205
|
+
var inputDone = false
|
|
206
|
+
var outputDone = false
|
|
207
|
+
val timeout = 5000L
|
|
208
|
+
|
|
209
|
+
while (!outputDone) {
|
|
210
|
+
if (!inputDone) {
|
|
211
|
+
val inIndex = decoder.dequeueInputBuffer(timeout)
|
|
212
|
+
if (inIndex >= 0) {
|
|
213
|
+
val buffer = decoder.getInputBuffer(inIndex)!!
|
|
214
|
+
val size = extractor.readSampleData(buffer, 0)
|
|
215
|
+
if (size < 0) {
|
|
216
|
+
decoder.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
217
|
+
inputDone = true
|
|
218
|
+
} else {
|
|
219
|
+
decoder.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0)
|
|
220
|
+
extractor.advance()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Dec -> Enc
|
|
226
|
+
var outIndex = decoder.dequeueOutputBuffer(bufferInfo, timeout)
|
|
227
|
+
if (outIndex >= 0) {
|
|
228
|
+
if (bufferInfo.size > 0) {
|
|
229
|
+
val pcmData = decoder.getOutputBuffer(outIndex)!!
|
|
230
|
+
val encIndex = encoder.dequeueInputBuffer(timeout)
|
|
231
|
+
if (encIndex >= 0) {
|
|
232
|
+
val encBuf = encoder.getInputBuffer(encIndex)!!
|
|
233
|
+
encBuf.clear()
|
|
234
|
+
encBuf.put(pcmData)
|
|
235
|
+
encoder.queueInputBuffer(encIndex, 0, bufferInfo.size, bufferInfo.presentationTimeUs, 0)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
decoder.releaseOutputBuffer(outIndex, false)
|
|
239
|
+
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
|
240
|
+
val eIdx = encoder.dequeueInputBuffer(timeout)
|
|
241
|
+
if (eIdx >= 0) {
|
|
242
|
+
encoder.queueInputBuffer(eIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Enc Output -> Buffer
|
|
248
|
+
var encOutIndex = encoder.dequeueOutputBuffer(bufferInfo, timeout)
|
|
249
|
+
while (encOutIndex >= 0) {
|
|
250
|
+
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
|
|
251
|
+
bufferInfo.size = 0
|
|
252
|
+
}
|
|
253
|
+
if (bufferInfo.size > 0) {
|
|
254
|
+
val buffer = encoder.getOutputBuffer(encOutIndex)!!
|
|
255
|
+
buffer.position(bufferInfo.offset)
|
|
256
|
+
buffer.limit(bufferInfo.offset + bufferInfo.size)
|
|
257
|
+
val data = ByteArray(bufferInfo.size)
|
|
258
|
+
buffer.get(data)
|
|
259
|
+
|
|
260
|
+
// Copy info
|
|
261
|
+
val infoCopy = MediaCodec.BufferInfo()
|
|
262
|
+
infoCopy.set(0, bufferInfo.size, bufferInfo.presentationTimeUs, bufferInfo.flags)
|
|
263
|
+
audioBuffer.add(AudioSample(data, infoCopy))
|
|
264
|
+
}
|
|
265
|
+
encoder.releaseOutputBuffer(encOutIndex, false)
|
|
266
|
+
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
|
267
|
+
outputDone = true
|
|
268
|
+
}
|
|
269
|
+
encOutIndex = encoder.dequeueOutputBuffer(bufferInfo, 0) // drain
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (encOutIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
|
273
|
+
val newFormat = encoder.outputFormat
|
|
274
|
+
audioTrackIndex = muxer?.addTrack(newFormat) ?: -1
|
|
275
|
+
Log.d(TAG, "Audio track added: $audioTrackIndex")
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- Video Pipeline ---
|
|
281
|
+
|
|
282
|
+
private fun processVideo(
|
|
283
|
+
textOverlays: List<TextOverlay>,
|
|
284
|
+
emojiOverlays: List<EmojiOverlay>,
|
|
285
|
+
filterId: String?,
|
|
286
|
+
filterIntensity: Double
|
|
287
|
+
) {
|
|
288
|
+
val extractor = videoExtractor!!
|
|
289
|
+
val inputFormat = extractor.getTrackFormat(extractor.sampleTrackIndex)
|
|
290
|
+
val mime = inputFormat.getString(MediaFormat.KEY_MIME) ?: ""
|
|
291
|
+
|
|
292
|
+
videoDecoder = MediaCodec.createDecoderByType(mime).apply {
|
|
293
|
+
configure(inputFormat, null, null, 0)
|
|
294
|
+
start()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
val outputFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, realWidth, realHeight).apply {
|
|
298
|
+
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar)
|
|
299
|
+
setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate)
|
|
300
|
+
setInteger(MediaFormat.KEY_FRAME_RATE, videoFrameRate)
|
|
301
|
+
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
videoEncoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC).apply {
|
|
305
|
+
configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
|
306
|
+
start()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
val decoder = videoDecoder!!
|
|
310
|
+
val encoder = videoEncoder!!
|
|
311
|
+
val mux = muxer!!
|
|
312
|
+
val bufferInfo = MediaCodec.BufferInfo()
|
|
313
|
+
val timeout = 10000L
|
|
314
|
+
|
|
315
|
+
var inputDone = false
|
|
316
|
+
var outputDone = false
|
|
317
|
+
var audioSampleIndex = 0
|
|
318
|
+
|
|
319
|
+
while (!outputDone) {
|
|
320
|
+
if (!inputDone) {
|
|
321
|
+
val inIndex = decoder.dequeueInputBuffer(timeout)
|
|
322
|
+
if (inIndex >= 0) {
|
|
323
|
+
val buffer = decoder.getInputBuffer(inIndex)!!
|
|
324
|
+
val size = extractor.readSampleData(buffer, 0)
|
|
325
|
+
if (size < 0) {
|
|
326
|
+
decoder.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
327
|
+
inputDone = true
|
|
328
|
+
} else {
|
|
329
|
+
decoder.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0)
|
|
330
|
+
extractor.advance()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Decode -> Edit -> Encode
|
|
336
|
+
val outIndex = decoder.dequeueOutputBuffer(bufferInfo, timeout)
|
|
337
|
+
if (outIndex >= 0) {
|
|
338
|
+
val doRender = bufferInfo.size > 0
|
|
339
|
+
if (doRender) {
|
|
340
|
+
val image = decoder.getOutputImage(outIndex)
|
|
341
|
+
if (image != null) {
|
|
342
|
+
val bitmap = yuvImageToBitmap(image, realWidth, realHeight)
|
|
343
|
+
image.close()
|
|
344
|
+
|
|
345
|
+
if (bitmap != null) {
|
|
346
|
+
val canvas = Canvas(bitmap)
|
|
347
|
+
val timeSec = bufferInfo.presentationTimeUs / 1_000_000.0
|
|
348
|
+
drawOverlays(canvas, textOverlays, emojiOverlays, timeSec)
|
|
349
|
+
if (filterId != null) applyFilter(bitmap, filterId, filterIntensity)
|
|
350
|
+
|
|
351
|
+
val encIndex = encoder.dequeueInputBuffer(timeout)
|
|
352
|
+
if (encIndex >= 0) {
|
|
353
|
+
val encBuf = encoder.getInputBuffer(encIndex)!!
|
|
354
|
+
encBuf.clear()
|
|
355
|
+
val nv12 = bitmapToNV12(bitmap)
|
|
356
|
+
encBuf.put(nv12)
|
|
357
|
+
encoder.queueInputBuffer(encIndex, 0, nv12.size, bufferInfo.presentationTimeUs, 0)
|
|
358
|
+
}
|
|
359
|
+
bitmap.recycle()
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
decoder.releaseOutputBuffer(outIndex, false)
|
|
364
|
+
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
|
365
|
+
val eIdx = encoder.dequeueInputBuffer(timeout)
|
|
366
|
+
if (eIdx >= 0) {
|
|
367
|
+
encoder.queueInputBuffer(eIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Encode -> Mux
|
|
373
|
+
var encOutIndex = encoder.dequeueOutputBuffer(bufferInfo, timeout)
|
|
374
|
+
while (encOutIndex >= 0) {
|
|
375
|
+
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) bufferInfo.size = 0
|
|
376
|
+
|
|
377
|
+
if (bufferInfo.size > 0) {
|
|
378
|
+
if (!muxerStarted) {
|
|
379
|
+
// Must have dropped frames before track format
|
|
380
|
+
} else {
|
|
381
|
+
val buffer = encoder.getOutputBuffer(encOutIndex)!!
|
|
382
|
+
buffer.position(bufferInfo.offset)
|
|
383
|
+
buffer.limit(bufferInfo.offset + bufferInfo.size)
|
|
384
|
+
|
|
385
|
+
// Interleave Audio
|
|
386
|
+
while (audioSampleIndex < audioBuffer.size) {
|
|
387
|
+
val sample = audioBuffer[audioSampleIndex]
|
|
388
|
+
if (sample.info.presentationTimeUs <= bufferInfo.presentationTimeUs) {
|
|
389
|
+
val buf = ByteBuffer.wrap(sample.data)
|
|
390
|
+
mux.writeSampleData(audioTrackIndex, buf, sample.info)
|
|
391
|
+
audioSampleIndex++
|
|
392
|
+
} else {
|
|
393
|
+
break
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
mux.writeSampleData(videoTrackIndex, buffer, bufferInfo)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
encoder.releaseOutputBuffer(encOutIndex, false)
|
|
401
|
+
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) outputDone = true
|
|
402
|
+
encOutIndex = encoder.dequeueOutputBuffer(bufferInfo, 0)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (encOutIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
|
406
|
+
videoTrackIndex = mux.addTrack(encoder.outputFormat)
|
|
407
|
+
if (videoTrackIndex != -1 && (sourceAudioTrackIndex == -1 || audioTrackIndex != -1)) {
|
|
408
|
+
mux.start()
|
|
409
|
+
muxerStarted = true
|
|
410
|
+
Log.d(TAG, "Muxer started!")
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Write remaining audio
|
|
416
|
+
while (audioSampleIndex < audioBuffer.size && muxerStarted) {
|
|
417
|
+
val sample = audioBuffer[audioSampleIndex]
|
|
418
|
+
val buf = ByteBuffer.wrap(sample.data)
|
|
419
|
+
mux.writeSampleData(audioTrackIndex, buf, sample.info)
|
|
420
|
+
audioSampleIndex++
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// --- Helpers ---
|
|
425
|
+
|
|
426
|
+
private fun yuvImageToBitmap(image: android.media.Image, width: Int, height: Int): Bitmap? {
|
|
427
|
+
return try {
|
|
428
|
+
val planes = image.planes
|
|
429
|
+
val yBuffer = planes[0].buffer
|
|
430
|
+
val uBuffer = planes[1].buffer
|
|
431
|
+
val vBuffer = planes[2].buffer
|
|
432
|
+
|
|
433
|
+
val yStride = planes[0].rowStride
|
|
434
|
+
val uvStride = planes[1].rowStride
|
|
435
|
+
val pixelStride = planes[1].pixelStride
|
|
436
|
+
|
|
437
|
+
// NV21 expects: Y plane followed by V U V U...
|
|
438
|
+
// Total size = W*H + W*H/2
|
|
439
|
+
val nv21 = ByteArray(width * height * 3 / 2)
|
|
440
|
+
|
|
441
|
+
// 1. Copy Y Plane (Handling Stride)
|
|
442
|
+
var inputOffset = 0
|
|
443
|
+
var outputOffset = 0
|
|
444
|
+
|
|
445
|
+
for (row in 0 until height) {
|
|
446
|
+
yBuffer.position(row * yStride)
|
|
447
|
+
yBuffer.get(nv21, outputOffset, width)
|
|
448
|
+
outputOffset += width
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 2. Copy UV Planes (Handling Stride & Pixel Stride)
|
|
452
|
+
// NV21: V then U (interleaved)
|
|
453
|
+
// Subsample: UV height is H/2, width is W/2
|
|
454
|
+
|
|
455
|
+
val uvHeight = height / 2
|
|
456
|
+
val uvWidth = width / 2
|
|
457
|
+
|
|
458
|
+
// Temp buffers for one row of U and V data if needed?
|
|
459
|
+
// Safer to read directly from buffer with absolute positioning?
|
|
460
|
+
// Unfortunately ByteBuffer.get(index) doesn't advance.
|
|
461
|
+
// Let's iterate using get(index).
|
|
462
|
+
|
|
463
|
+
val vOffset = 0 // V buffer start
|
|
464
|
+
val uOffset = 0 // U buffer start
|
|
465
|
+
|
|
466
|
+
// Optimized loop for UV
|
|
467
|
+
var nv21Index = width * height
|
|
468
|
+
|
|
469
|
+
for (row in 0 until uvHeight) {
|
|
470
|
+
for (col in 0 until uvWidth) {
|
|
471
|
+
// NV21 wants V then U
|
|
472
|
+
val vVal = vBuffer.get(row * uvStride + col * pixelStride)
|
|
473
|
+
val uVal = uBuffer.get(row * uvStride + col * pixelStride)
|
|
474
|
+
|
|
475
|
+
nv21[nv21Index++] = vVal
|
|
476
|
+
nv21[nv21Index++] = uVal
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
val yuvImage = YuvImage(nv21, android.graphics.ImageFormat.NV21, width, height, null)
|
|
481
|
+
val out = ByteArrayOutputStream()
|
|
482
|
+
// High quality for intermediate frame
|
|
483
|
+
yuvImage.compressToJpeg(Rect(0, 0, width, height), 100, out)
|
|
484
|
+
val imageBytes = out.toByteArray()
|
|
485
|
+
|
|
486
|
+
val options = BitmapFactory.Options()
|
|
487
|
+
// Make mutable directly? No, decode is immutable.
|
|
488
|
+
|
|
489
|
+
val immutable = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
|
490
|
+
return immutable?.copy(Bitmap.Config.ARGB_8888, true)
|
|
491
|
+
} catch (e: Exception) {
|
|
492
|
+
Log.e(TAG, "YUV Conversion Error", e)
|
|
493
|
+
null
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private fun bitmapToNV12(bitmap: Bitmap): ByteArray {
|
|
498
|
+
val width = bitmap.width; val height = bitmap.height
|
|
499
|
+
val size = width * height
|
|
500
|
+
val yuv = ByteArray(size * 3 / 2)
|
|
501
|
+
val argb = IntArray(size)
|
|
502
|
+
bitmap.getPixels(argb, 0, width, 0, 0, width, height)
|
|
503
|
+
var yIdx = 0; var uvIdx = size
|
|
504
|
+
for (j in 0 until height) {
|
|
505
|
+
for (i in 0 until width) {
|
|
506
|
+
val c = argb[yIdx]
|
|
507
|
+
val r = (c shr 16) and 0xFF
|
|
508
|
+
val g = (c shr 8) and 0xFF
|
|
509
|
+
val b = c and 0xFF
|
|
510
|
+
val y = ((66 * r + 129 * g + 25 * b + 128) shr 8) + 16
|
|
511
|
+
yuv[yIdx++] = y.coerceIn(0, 255).toByte()
|
|
512
|
+
if (j % 2 == 0 && i % 2 == 0) {
|
|
513
|
+
val u = ((-38 * r - 74 * g + 112 * b + 128) shr 8) + 128
|
|
514
|
+
val v = ((112 * r - 94 * g - 18 * b + 128) shr 8) + 128
|
|
515
|
+
yuv[uvIdx++] = u.coerceIn(0, 255).toByte() // NV12: U then V? No, standard is UV
|
|
516
|
+
yuv[uvIdx++] = v.coerceIn(0, 255).toByte()
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return yuv
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private fun drawOverlays(c: Canvas, texts: List<TextOverlay>, emojis: List<EmojiOverlay>, currentTimeSec: Double) {
|
|
524
|
+
// SCALING FIX:
|
|
525
|
+
// React Native text size is in DP (approx 1/360 of screen width).
|
|
526
|
+
// Video text size is in Pixels.
|
|
527
|
+
// We need to scale the font based on the ratio of Video Width to a "Standard Phone Width".
|
|
528
|
+
// Using 375.0 as standard width (iPhone standard).
|
|
529
|
+
// If Video is 720p (720px width), scale is ~2.0. Font 24 -> 48px.
|
|
530
|
+
// If Video is 1080p (1080px width), scale is ~3.0. Font 24 -> 72px.
|
|
531
|
+
// Use 'realWidth' for the logic because drawing happens on raw bitmap (even if logic uses swapped).
|
|
532
|
+
// Actually, for Rotation=90/270, the visual width is videoHeight (short dimension).
|
|
533
|
+
// We should scale based on the "Visual Width" of the video to match Screen Width.
|
|
534
|
+
|
|
535
|
+
val visualWidth = if (rotation == 90 || rotation == 270) realHeight else realWidth
|
|
536
|
+
val scale = visualWidth / 375.0
|
|
537
|
+
|
|
538
|
+
val p = Paint().apply { isAntiAlias = true; textAlign = Paint.Align.CENTER; typeface = Typeface.DEFAULT_BOLD }
|
|
539
|
+
|
|
540
|
+
fun drawItem(text: String, xPerc: Double, yPerc: Double, logicalSize: Double, color: Int) {
|
|
541
|
+
val finalSize = (logicalSize * scale).toFloat()
|
|
542
|
+
p.color = color
|
|
543
|
+
p.textSize = finalSize
|
|
544
|
+
|
|
545
|
+
var drawX = 0f
|
|
546
|
+
var drawY = 0f
|
|
547
|
+
var angle = 0f
|
|
548
|
+
|
|
549
|
+
// Map logical coordinates (0.0-1.0) to Raw Bitmap pixels based on rotation
|
|
550
|
+
when (rotation) {
|
|
551
|
+
90 -> { // Portrait (Recorded on phone)
|
|
552
|
+
drawX = (yPerc * realWidth).toFloat()
|
|
553
|
+
drawY = ((1.0 - xPerc) * realHeight).toFloat()
|
|
554
|
+
angle = -90f
|
|
555
|
+
}
|
|
556
|
+
270 -> { // Reverse Portrait
|
|
557
|
+
drawX = ((1.0 - yPerc) * realWidth).toFloat()
|
|
558
|
+
drawY = (xPerc * realHeight).toFloat()
|
|
559
|
+
angle = 90f
|
|
560
|
+
}
|
|
561
|
+
180 -> { // Reverse Landscape
|
|
562
|
+
drawX = ((1.0 - xPerc) * realWidth).toFloat()
|
|
563
|
+
drawY = ((1.0 - yPerc) * realHeight).toFloat()
|
|
564
|
+
angle = 180f
|
|
565
|
+
}
|
|
566
|
+
else -> { // 0 - Landscape
|
|
567
|
+
drawX = (xPerc * realWidth).toFloat()
|
|
568
|
+
drawY = (yPerc * realHeight).toFloat()
|
|
569
|
+
angle = 0f
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
c.save()
|
|
574
|
+
c.rotate(angle, drawX, drawY)
|
|
575
|
+
c.drawText(text, drawX, drawY, p)
|
|
576
|
+
c.restore()
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Filter and Draw with Logging
|
|
580
|
+
// if (frameIndex % 30 == 0) Log.d(TAG, "Frame Time: $currentTimeSec")
|
|
581
|
+
|
|
582
|
+
texts.forEach {
|
|
583
|
+
// Log.d(TAG, "Text '${it.text}': ${it.start} -> ${it.start + it.duration}")
|
|
584
|
+
if (currentTimeSec >= it.start && currentTimeSec < (it.start + it.duration)) {
|
|
585
|
+
drawItem(it.text, it.x, it.y, it.size, parseColor(it.color))
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
emojis.forEach {
|
|
589
|
+
if (currentTimeSec >= it.start && currentTimeSec < (it.start + it.duration)) {
|
|
590
|
+
drawItem(it.emoji, it.x, it.y, it.size, Color.BLACK)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private fun applyFilter(b: Bitmap, id: String, i: Double) {
|
|
596
|
+
// Placeholder for filter logic if needed
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private fun parseColor(s: String) = try { Color.parseColor(s) } catch (e: Exception) { Color.WHITE }
|
|
600
|
+
private fun safeGetInteger(f: MediaFormat, k: String, d: Int) = if (f.containsKey(k)) f.getInteger(k) else d
|
|
601
|
+
|
|
602
|
+
private fun cleanup() {
|
|
603
|
+
try { videoDecoder?.stop(); videoDecoder?.release() } catch (e: Exception) {}
|
|
604
|
+
try { videoEncoder?.stop(); videoEncoder?.release() } catch (e: Exception) {}
|
|
605
|
+
try { audioDecoder?.stop(); audioDecoder?.release() } catch (e: Exception) {}
|
|
606
|
+
try { audioEncoder?.stop(); audioEncoder?.release() } catch (e: Exception) {}
|
|
607
|
+
try { if (muxerStarted) muxer?.stop(); muxer?.release() } catch (e: Exception) {}
|
|
608
|
+
try { videoExtractor?.release() } catch (e: Exception) {}
|
|
609
|
+
try { audioExtractor?.release() } catch (e: Exception) {}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'MediaEngine'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = 'Native Media Engine for video composition and audio processing'
|
|
9
|
+
s.description = 'Expo native module for video composition with text/emoji overlays, audio extraction, and waveform generation'
|
|
10
|
+
s.license = package['license']
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.homepage = package['homepage'] || 'https://github.com/projectyoked/react-native-media-engine'
|
|
13
|
+
s.platforms = { :ios => '13.4' }
|
|
14
|
+
s.swift_version = '5.4'
|
|
15
|
+
s.source = { git: '' }
|
|
16
|
+
s.static_framework = true
|
|
17
|
+
|
|
18
|
+
s.dependency 'ExpoModulesCore'
|
|
19
|
+
|
|
20
|
+
# Swift/Objective-C compatibility
|
|
21
|
+
s.pod_target_xcconfig = {
|
|
22
|
+
'DEFINES_MODULE' => 'YES',
|
|
23
|
+
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
s.source_files = "**/*.{h,m,mm,swift}"
|
|
27
|
+
end
|