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
|
-
|
|
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
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
//
|
|
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
|
|
106
|
-
|
|
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.
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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)
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
val
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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 —
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
val
|
|
188
|
-
val
|
|
189
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
for (j in 0 until
|
|
208
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
|
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.
|
|
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",
|