rn-av-binder 1.0.0 → 1.0.1

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.
@@ -2,16 +2,17 @@ package expo.modules.imageaudiovideo
2
2
 
3
3
  import android.graphics.Bitmap
4
4
  import android.graphics.BitmapFactory
5
+ import android.media.Image
5
6
  import android.media.MediaCodec
6
7
  import android.media.MediaCodecInfo
7
8
  import android.media.MediaExtractor
8
9
  import android.media.MediaFormat
9
10
  import android.media.MediaMetadataRetriever
10
11
  import android.media.MediaMuxer
12
+ import android.net.Uri
13
+ import android.os.ParcelFileDescriptor
11
14
  import expo.modules.kotlin.modules.Module
12
15
  import expo.modules.kotlin.modules.ModuleDefinition
13
- import kotlinx.coroutines.Dispatchers
14
- import kotlinx.coroutines.withContext
15
16
  import java.io.File
16
17
  import java.nio.ByteBuffer
17
18
 
@@ -21,14 +22,16 @@ class ImageAudioVideoModule : Module() {
21
22
  Events("onProgress")
22
23
 
23
24
  AsyncFunction("createVideoAsync") { options: Map<String, String?> ->
24
- withContext(Dispatchers.IO) {
25
- createVideo(options)
26
- }
25
+ createVideo(options)
27
26
  }
28
27
  }
29
28
 
30
29
  private data class EncodedChunk(val data: ByteArray, val info: MediaCodec.BufferInfo)
31
30
 
31
+ // MP4 (MUXER_OUTPUT_MPEG_4) can only passthrough these audio codecs. MP3 (audio/mpeg)
32
+ // is NOT muxable into MP4 — we reject it up front with a clear error.
33
+ private val muxableAudioMimes = setOf("audio/mp4a-latm", "audio/3gpp", "audio/amr-wb")
34
+
32
35
  private fun createVideo(options: Map<String, String?>): Map<String, Any> {
33
36
  // ── Step 1 — Resolve paths ────────────────────────────────────────────────
34
37
  val imagePath = options["imageUri"]?.takeIf { it.isNotEmpty() }
@@ -42,176 +45,287 @@ class ImageAudioVideoModule : Module() {
42
45
  File(outputPath).takeIf { it.exists() }?.delete()
43
46
  sendEvent("onProgress", mapOf("progress" to 0.0))
44
47
 
45
- // ── Step 2 Load and prepare image ───────────────────────────────────────
46
- var bitmap = BitmapFactory.decodeFile(imagePath)
47
- ?: throw Exception("ERR_INVALID_IMAGE: could not decode image at $imagePath")
48
- if (bitmap.width > 4096 || bitmap.height > 4096) {
49
- val scale = 4096.0 / maxOf(bitmap.width, bitmap.height)
50
- bitmap = Bitmap.createScaledBitmap(
51
- bitmap, (bitmap.width * scale).toInt(), (bitmap.height * scale).toInt(), true
52
- )
53
- }
54
- if (bitmap.config != Bitmap.Config.ARGB_8888) {
55
- bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, false)
56
- }
57
- // Round both dimensions down to the nearest even integer (H.264 requires even dims)
58
- val width = (bitmap.width / 2) * 2
59
- val height = (bitmap.height / 2) * 2
60
- if (width <= 0 || height <= 0) throw Exception("ERR_INVALID_IMAGE: image dimensions are too small")
61
-
62
- // ── Step 3 — Get audio duration and validate ──────────────────────────────
63
- val retriever = MediaMetadataRetriever()
64
- retriever.setDataSource(audioPath)
65
- val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
66
- ?.toLongOrNull() ?: throw Exception("ERR_INVALID_AUDIO: cannot read audio duration")
67
- retriever.release()
68
- if (durationMs <= 0) throw Exception("ERR_INVALID_AUDIO: audio has zero or invalid duration")
69
- val durationUs = durationMs * 1000L
70
-
71
- val validator = MediaExtractor()
72
- validator.setDataSource(audioPath)
73
- val hasAudio = (0 until validator.trackCount).any {
74
- validator.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
75
- }
76
- validator.release()
77
- if (!hasAudio) throw Exception("ERR_NO_AUDIO_TRACK: audio file has no audio track")
78
-
79
- // ── Step 4 — Configure H.264 encoder ──────────────────────────────────────
80
- val videoFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
81
- setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)
82
- setInteger(MediaFormat.KEY_BIT_RATE, width * height * 2)
83
- setInteger(MediaFormat.KEY_FRAME_RATE, 1)
84
- setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
48
+ // All native resources are tracked here and released exactly once in `finally`, so a
49
+ // failure mid-pipeline never leaks the hardware encoder/muxer (which would otherwise
50
+ // brick every subsequent call) and never leaves a half-written MP4 on disk.
51
+ val openFds = mutableListOf<ParcelFileDescriptor>()
52
+ var retriever: MediaMetadataRetriever? = null
53
+ var validator: MediaExtractor? = null
54
+ var encoder: MediaCodec? = null
55
+ var muxer: MediaMuxer? = null
56
+ var muxerStarted = false
57
+ var extractor: MediaExtractor? = null
58
+ var bitmap: Bitmap? = null
59
+ var success = false
60
+
61
+ // Open a content:// (or other non-filesystem) URI through the ContentResolver and
62
+ // return a FileDescriptor; returns null for a plain filesystem path. expo-document-picker
63
+ // commonly hands back content:// URIs, which MediaExtractor/Retriever's String overloads
64
+ // cannot read.
65
+ fun openFd(path: String): java.io.FileDescriptor? {
66
+ if (path.startsWith("/")) return null
67
+ val resolver = appContext.reactContext?.contentResolver
68
+ ?: throw Exception("ERR_OPEN: no context to resolve $path")
69
+ val pfd = resolver.openFileDescriptor(Uri.parse(path), "r")
70
+ ?: throw Exception("ERR_OPEN: cannot open $path")
71
+ openFds.add(pfd)
72
+ return pfd.fileDescriptor
85
73
  }
86
- val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
87
- encoder.configure(videoFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
88
- encoder.start()
89
-
90
- // ── Step 6 Encode exactly 2 frames and collect output ───────────────────
91
- val videoChunks = mutableListOf<EncodedChunk>()
92
- var outputFormat: MediaFormat? = null
93
-
94
- fun encodeFrame(yuvData: ByteArray, presentationTimeUs: Long, isLast: Boolean) {
95
- val inputIndex = encoder.dequeueInputBuffer(10_000L)
96
- if (inputIndex >= 0) {
97
- val inputBuffer = encoder.getInputBuffer(inputIndex)!!
98
- inputBuffer.clear()
99
- inputBuffer.put(yuvData)
100
- val flags = if (isLast) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
101
- encoder.queueInputBuffer(inputIndex, 0, yuvData.size, presentationTimeUs, flags)
74
+
75
+ try {
76
+ // ── Step 2 — Load and prepare image ─────────────────────────────────────
77
+ val imageFd = openFd(imagePath)
78
+ var bmp = (if (imageFd != null) BitmapFactory.decodeFileDescriptor(imageFd)
79
+ else BitmapFactory.decodeFile(imagePath))
80
+ ?: throw Exception("ERR_INVALID_IMAGE: could not decode image at $imagePath")
81
+ bitmap = bmp
82
+ if (bmp.width > 4096 || bmp.height > 4096) {
83
+ val scale = 4096.0 / maxOf(bmp.width, bmp.height)
84
+ val scaled = Bitmap.createScaledBitmap(
85
+ bmp, (bmp.width * scale).toInt(), (bmp.height * scale).toInt(), true
86
+ )
87
+ if (scaled !== bmp) bmp.recycle()
88
+ bmp = scaled
89
+ bitmap = bmp
90
+ }
91
+ if (bmp.config != Bitmap.Config.ARGB_8888) {
92
+ val argb = bmp.copy(Bitmap.Config.ARGB_8888, false)
93
+ if (argb !== bmp) bmp.recycle()
94
+ bmp = argb
95
+ bitmap = bmp
102
96
  }
103
- // Drain output
97
+ // Round both dimensions down to the nearest even integer (H.264 requires even dims)
98
+ val width = (bmp.width / 2) * 2
99
+ val height = (bmp.height / 2) * 2
100
+ if (width <= 0 || height <= 0) throw Exception("ERR_INVALID_IMAGE: image dimensions are too small")
101
+
102
+ // ── Step 3 — Get audio duration and validate ────────────────────────────
103
+ retriever = MediaMetadataRetriever()
104
+ val retrieverFd = openFd(audioPath)
105
+ if (retrieverFd != null) retriever.setDataSource(retrieverFd) else retriever.setDataSource(audioPath)
106
+ val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
107
+ ?.toLongOrNull() ?: throw Exception("ERR_INVALID_AUDIO: cannot read audio duration")
108
+ if (durationMs <= 0) throw Exception("ERR_INVALID_AUDIO: audio has zero or invalid duration")
109
+ val durationUs = durationMs * 1000L
110
+
111
+ // Validate the audio track exists AND is muxable into MP4 — fail fast before the
112
+ // expensive encode so we never write a partial file.
113
+ val audioValidator = MediaExtractor()
114
+ validator = audioValidator
115
+ val validatorFd = openFd(audioPath)
116
+ if (validatorFd != null) audioValidator.setDataSource(validatorFd) else audioValidator.setDataSource(audioPath)
117
+ val audioMime = (0 until audioValidator.trackCount)
118
+ .map { audioValidator.getTrackFormat(it).getString(MediaFormat.KEY_MIME) }
119
+ .firstOrNull { it?.startsWith("audio/") == true }
120
+ ?: throw Exception("ERR_NO_AUDIO_TRACK: audio file has no audio track")
121
+ if (audioMime !in muxableAudioMimes) {
122
+ throw Exception(
123
+ "ERR_UNSUPPORTED_AUDIO: MP4 output supports AAC (.m4a) audio for passthrough; " +
124
+ "got '$audioMime'. Provide an AAC/.m4a file."
125
+ )
126
+ }
127
+ audioValidator.release()
128
+ validator = null
129
+
130
+ // ── Step 4 — Configure H.264 encoder ────────────────────────────────────
131
+ val videoFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
132
+ setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)
133
+ setInteger(MediaFormat.KEY_BIT_RATE, width * height * 2)
134
+ setInteger(MediaFormat.KEY_FRAME_RATE, 1)
135
+ setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
136
+ }
137
+ encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
138
+ encoder.configure(videoFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
139
+ encoder.start()
140
+
141
+ // ── Step 6 — Encode exactly 2 frames and collect output ─────────────────
142
+ val videoChunks = mutableListOf<EncodedChunk>()
143
+ var outputFormat: MediaFormat? = null
144
+
145
+ val pixels = IntArray(width * height)
146
+ bmp.getPixels(pixels, 0, width, 0, 0, width, height)
147
+ // Second frame PTS must be strictly greater than the first (0); clamp for very
148
+ // short (~1ms) audio so MediaMuxer doesn't reject non-monotonic timestamps.
149
+ val frameTimesUs = longArrayOf(0L, maxOf(durationUs - 1000L, 1000L))
150
+ val frameSize = width * height * 3 / 2
104
151
  val bufferInfo = MediaCodec.BufferInfo()
105
- var draining = true
106
- while (draining) {
152
+ var nextFrame = 0
153
+ var inputDone = false
154
+ var outputDone = false
155
+ var spins = 0
156
+
157
+ // Single feed-and-drain loop: queue both frames (the last carries EOS), then keep
158
+ // polling until the encoder reports END_OF_STREAM. INFO_TRY_AGAIN_LATER means "not
159
+ // ready yet" — we must keep looping, NOT stop draining.
160
+ while (!outputDone) {
161
+ if (!inputDone) {
162
+ val inputIndex = encoder.dequeueInputBuffer(10_000L)
163
+ if (inputIndex >= 0) {
164
+ // Fill via the Image plane API honoring rowStride/pixelStride. Writing raw I420
165
+ // bytes into a COLOR_FormatYUV420Flexible buffer produces garbled/green video on
166
+ // real (NV12) hardware encoders; getInputImage adapts to each device's layout.
167
+ val image = encoder.getInputImage(inputIndex)
168
+ ?: throw Exception("ERR_ENCODE: encoder did not provide an input image")
169
+ fillInputImage(image, pixels, width, height)
170
+ val isLast = nextFrame == frameTimesUs.size - 1
171
+ val flags = if (isLast) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
172
+ encoder.queueInputBuffer(inputIndex, 0, frameSize, frameTimesUs[nextFrame], flags)
173
+ nextFrame++
174
+ if (isLast) inputDone = true
175
+ }
176
+ }
177
+
107
178
  val outputIndex = encoder.dequeueOutputBuffer(bufferInfo, 10_000L)
108
179
  when {
109
- outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> outputFormat = encoder.outputFormat
180
+ outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
181
+ if (inputDone && ++spins > 2000) {
182
+ throw Exception("ERR_ENCODE: timed out waiting for encoder output")
183
+ }
184
+ }
185
+ outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
186
+ outputFormat = encoder.outputFormat
187
+ }
110
188
  outputIndex >= 0 -> {
189
+ spins = 0
111
190
  val outputBuffer = encoder.getOutputBuffer(outputIndex)!!
112
- val chunk = ByteArray(bufferInfo.size)
113
- outputBuffer.get(chunk)
114
- videoChunks.add(EncodedChunk(chunk, MediaCodec.BufferInfo().also {
115
- it.offset = 0
116
- it.size = bufferInfo.size
117
- it.presentationTimeUs = bufferInfo.presentationTimeUs
118
- it.flags = bufferInfo.flags
119
- }))
191
+ val isConfig = bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
192
+ // Skip codec-config (SPS/PPS) buffers — that csd is already carried in
193
+ // outputFormat, which we hand to the muxer via addTrack().
194
+ if (!isConfig && bufferInfo.size > 0) {
195
+ outputBuffer.position(bufferInfo.offset)
196
+ outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
197
+ val data = ByteArray(bufferInfo.size)
198
+ outputBuffer.get(data)
199
+ val sampleFlags = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM.inv()
200
+ videoChunks.add(EncodedChunk(data, MediaCodec.BufferInfo().also {
201
+ it.set(0, data.size, bufferInfo.presentationTimeUs, sampleFlags)
202
+ }))
203
+ }
120
204
  encoder.releaseOutputBuffer(outputIndex, false)
121
- if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) draining = false
205
+ if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) outputDone = true
122
206
  }
123
- else -> draining = false
124
207
  }
125
208
  }
126
- }
127
209
 
128
- val yuvData = bitmapToYuv420(bitmap, width, height)
129
- encodeFrame(yuvData, 0L, false)
130
- encodeFrame(yuvData, durationUs - 1000L, true)
131
-
132
- encoder.stop()
133
- encoder.release()
134
- sendEvent("onProgress", mapOf("progress" to 0.5))
135
-
136
- // ── Step 7 — Mux video + audio ────────────────────────────────────────────
137
- val muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
138
- val videoTrack = muxer.addTrack(
139
- outputFormat ?: throw Exception("ERR_ENCODE: encoder produced no output format")
140
- )
141
-
142
- // Add audio track from extractor
143
- val extractor = MediaExtractor()
144
- extractor.setDataSource(audioPath)
145
- val audioTrackIndex = (0 until extractor.trackCount).first {
146
- extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
147
- }
148
- extractor.selectTrack(audioTrackIndex)
149
- val audioTrack = muxer.addTrack(extractor.getTrackFormat(audioTrackIndex))
150
- muxer.start()
151
-
152
- // Write video
153
- val videoBuffer = ByteBuffer.allocate(1024 * 512)
154
- for (chunk in videoChunks) {
155
- videoBuffer.clear()
156
- videoBuffer.put(chunk.data)
157
- videoBuffer.flip()
158
- muxer.writeSampleData(videoTrack, videoBuffer, chunk.info)
159
- }
210
+ encoder.stop()
211
+ encoder.release()
212
+ encoder = null
213
+ if (outputFormat == null) throw Exception("ERR_ENCODE: encoder produced no output format")
214
+ sendEvent("onProgress", mapOf("progress" to 0.5))
160
215
 
161
- // Write audio (passthrough)
162
- val audioBuffer = ByteBuffer.allocate(1024 * 512)
163
- val audioInfo = MediaCodec.BufferInfo()
164
- while (true) {
165
- val sampleSize = extractor.readSampleData(audioBuffer, 0)
166
- if (sampleSize < 0) break
167
- audioInfo.offset = 0
168
- audioInfo.size = sampleSize
169
- audioInfo.presentationTimeUs = extractor.sampleTime
170
- audioInfo.flags = extractor.sampleFlags
171
- muxer.writeSampleData(audioTrack, audioBuffer, audioInfo)
172
- extractor.advance()
173
- }
216
+ // ── Step 7 — Mux video + audio ──────────────────────────────────────────
217
+ muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
218
+ val videoTrack = muxer.addTrack(outputFormat)
219
+
220
+ val audioExtractor = MediaExtractor()
221
+ extractor = audioExtractor
222
+ val extractorFd = openFd(audioPath)
223
+ if (extractorFd != null) audioExtractor.setDataSource(extractorFd) else audioExtractor.setDataSource(audioPath)
224
+ val audioTrackIndex = (0 until audioExtractor.trackCount).first {
225
+ audioExtractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
226
+ }
227
+ val audioFormat = audioExtractor.getTrackFormat(audioTrackIndex)
228
+ audioExtractor.selectTrack(audioTrackIndex)
229
+ val audioTrack = muxer.addTrack(audioFormat)
230
+ muxer.start()
231
+ muxerStarted = true
232
+
233
+ // Write the first video frame at t=0 (wrap its exact-sized array to avoid a fixed-buffer
234
+ // overflow on a large keyframe).
235
+ val firstChunk = videoChunks.firstOrNull()
236
+ ?: throw Exception("ERR_ENCODE: no encoded video frames")
237
+ firstChunk.info.presentationTimeUs = 0L
238
+ muxer.writeSampleData(videoTrack, ByteBuffer.wrap(firstChunk.data), firstChunk.info)
174
239
 
175
- extractor.release()
176
- muxer.stop()
177
- muxer.release()
240
+ // Write audio (passthrough), sizing the read buffer from the track's max input size and
241
+ // tracking the REAL end timestamp from the samples themselves.
242
+ val audioBufferSize = (if (audioFormat.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE))
243
+ audioFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE) else 0).coerceAtLeast(256 * 1024)
244
+ val audioBuffer = ByteBuffer.allocate(audioBufferSize)
245
+ val audioInfo = MediaCodec.BufferInfo()
246
+ var lastAudioPts = 0L
247
+ while (true) {
248
+ val sampleSize = audioExtractor.readSampleData(audioBuffer, 0)
249
+ if (sampleSize < 0) break
250
+ val pts = audioExtractor.sampleTime
251
+ audioInfo.set(0, sampleSize, pts, audioExtractor.sampleFlags)
252
+ muxer.writeSampleData(audioTrack, audioBuffer, audioInfo)
253
+ if (pts > lastAudioPts) lastAudioPts = pts
254
+ audioExtractor.advance()
255
+ }
256
+
257
+ // Stretch the still image across the ENTIRE audio: place the remaining video frame(s) at
258
+ // the real audio end (from the samples, not the sometimes-wrong duration metadata), so the
259
+ // output video length equals the audio length.
260
+ val videoEndUs = maxOf(lastAudioPts, 1000L)
261
+ for (i in 1 until videoChunks.size) {
262
+ val chunk = videoChunks[i]
263
+ chunk.info.presentationTimeUs = videoEndUs + (i - 1)
264
+ muxer.writeSampleData(videoTrack, ByteBuffer.wrap(chunk.data), chunk.info)
265
+ }
178
266
 
179
- sendEvent("onProgress", mapOf("progress" to 1.0))
180
- return mapOf("uri" to outputPath, "durationMs" to durationMs)
267
+ success = true
268
+ sendEvent("onProgress", mapOf("progress" to 1.0))
269
+ val outDurationMs = maxOf(durationMs, videoEndUs / 1000L)
270
+ return mapOf("uri" to outputPath, "durationMs" to outDurationMs)
271
+ } finally {
272
+ try { encoder?.stop() } catch (_: Exception) {}
273
+ encoder?.release()
274
+ try { if (muxerStarted) muxer?.stop() } catch (_: Exception) {}
275
+ muxer?.release()
276
+ extractor?.release()
277
+ validator?.release()
278
+ retriever?.release()
279
+ bitmap?.let { if (!it.isRecycled) it.recycle() }
280
+ for (fd in openFds) { try { fd.close() } catch (_: Exception) {} }
281
+ if (!success) File(outputPath).takeIf { it.exists() }?.delete()
282
+ }
181
283
  }
182
284
 
183
- // ── Step 5 — RGB YUV420 (I420 planar: Y, then U, then V) ──────────────────
184
- private fun bitmapToYuv420(bitmap: Bitmap, width: Int, height: Int): ByteArray {
185
- val ySize = width * height
186
- val cSize = (width / 2) * (height / 2)
187
- val out = ByteArray(ySize + 2 * cSize)
188
- val pixels = IntArray(width * height)
189
- bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
285
+ // ── Step 5 — Fill a YUV_420_888 encoder input image from ARGB pixels ────────
286
+ // Honors each plane's rowStride/pixelStride, so it works on both planar (pixelStride==1)
287
+ // and semi-planar/NV12 (pixelStride==2) hardware encoders.
288
+ private fun fillInputImage(image: Image, pixels: IntArray, width: Int, height: Int) {
289
+ val yPlane = image.planes[0]
290
+ val uPlane = image.planes[1]
291
+ val vPlane = image.planes[2]
292
+ val yBuf = yPlane.buffer
293
+ val uBuf = uPlane.buffer
294
+ val vBuf = vPlane.buffer
295
+ val yRow = yPlane.rowStride
296
+ val yPix = yPlane.pixelStride
297
+ val uRow = uPlane.rowStride
298
+ val uPix = uPlane.pixelStride
299
+ val vRow = vPlane.rowStride
300
+ val vPix = vPlane.pixelStride
190
301
 
191
302
  // Y plane (full resolution)
192
- var yIndex = 0
193
303
  for (j in 0 until height) {
304
+ val rowBase = j * yRow
305
+ val pixBase = j * width
194
306
  for (i in 0 until width) {
195
- val p = pixels[j * width + i]
307
+ val p = pixels[pixBase + i]
196
308
  val r = (p shr 16) and 0xFF
197
309
  val g = (p shr 8) and 0xFF
198
310
  val b = p and 0xFF
199
311
  val y = ((66 * r + 129 * g + 25 * b + 128) shr 8) + 16
200
- out[yIndex++] = clamp(y).toByte()
312
+ yBuf.put(rowBase + i * yPix, clamp(y).toByte())
201
313
  }
202
314
  }
203
315
 
204
316
  // U and V planes (subsampled — average each 2×2 block)
205
- var uIndex = ySize
206
- var vIndex = ySize + cSize
207
- for (j in 0 until height step 2) {
208
- for (i in 0 until width step 2) {
317
+ val chromaW = width / 2
318
+ val chromaH = height / 2
319
+ for (j in 0 until chromaH) {
320
+ val uRowBase = j * uRow
321
+ val vRowBase = j * vRow
322
+ for (i in 0 until chromaW) {
209
323
  var sumR = 0
210
324
  var sumG = 0
211
325
  var sumB = 0
212
326
  for (dj in 0 until 2) {
213
327
  for (di in 0 until 2) {
214
- val p = pixels[(j + dj) * width + (i + di)]
328
+ val p = pixels[(j * 2 + dj) * width + (i * 2 + di)]
215
329
  sumR += (p shr 16) and 0xFF
216
330
  sumG += (p shr 8) and 0xFF
217
331
  sumB += p and 0xFF
@@ -222,11 +336,10 @@ class ImageAudioVideoModule : Module() {
222
336
  val b = sumB / 4
223
337
  val u = ((-38 * r - 74 * g + 112 * b + 128) shr 8) + 128
224
338
  val v = ((112 * r - 94 * g - 18 * b + 128) shr 8) + 128
225
- out[uIndex++] = clamp(u).toByte()
226
- out[vIndex++] = clamp(v).toByte()
339
+ uBuf.put(uRowBase + i * uPix, clamp(u).toByte())
340
+ vBuf.put(vRowBase + i * vPix, clamp(v).toByte())
227
341
  }
228
342
  }
229
- return out
230
343
  }
231
344
 
232
345
  private fun clamp(value: Int): Int = when {
package/build/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export interface CreateVideoOptions {
2
2
  /** file:// URI to a JPEG or PNG image */
3
3
  imageUri: string;
4
- /** file:// URI to an AAC, MP3, or M4A audio file */
4
+ /** file:// URI to an AAC / .m4a audio file (passed through into the MP4; MP3 is not supported by the MP4 muxer) */
5
5
  audioUri: string;
6
6
  /**
7
7
  * Optional file:// URI for the output MP4.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rn-av-binder",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "On-device image + audio → MP4 composition for React Native. AVFoundation (iOS) and MediaCodec/MediaMuxer (Android). New Architecture compatible.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -1,7 +0,0 @@
1
- package expo.modules.imageaudiovideo
2
-
3
- import expo.modules.kotlin.Package
4
-
5
- class ImageAudioVideoPackage : Package {
6
- override fun createModules() = listOf(ImageAudioVideoModule())
7
- }