@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.
@@ -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,17 @@
1
+ {
2
+ "name": "MediaEngine",
3
+ "platforms": [
4
+ "ios",
5
+ "android"
6
+ ],
7
+ "ios": {
8
+ "modules": [
9
+ "MediaEngineModule"
10
+ ]
11
+ },
12
+ "android": {
13
+ "modules": [
14
+ "com.projectyoked.mediaengine.MediaEngineModule"
15
+ ]
16
+ }
17
+ }
@@ -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