@stefanmartin/expo-video-watermark 0.3.0 → 0.3.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.
|
@@ -3,6 +3,7 @@ package expo.modules.videowatermark
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.graphics.Bitmap
|
|
5
5
|
import android.graphics.BitmapFactory
|
|
6
|
+
import android.media.MediaCodecList
|
|
6
7
|
import android.media.MediaMetadataRetriever
|
|
7
8
|
import android.os.Build
|
|
8
9
|
import android.os.Handler
|
|
@@ -10,6 +11,7 @@ import android.os.Looper
|
|
|
10
11
|
import android.util.Log
|
|
11
12
|
import androidx.annotation.OptIn
|
|
12
13
|
import androidx.media3.common.MediaItem
|
|
14
|
+
import androidx.media3.common.MimeTypes
|
|
13
15
|
import androidx.media3.common.util.UnstableApi
|
|
14
16
|
import androidx.media3.effect.BitmapOverlay
|
|
15
17
|
import androidx.media3.effect.OverlayEffect
|
|
@@ -35,6 +37,23 @@ class ExpoVideoWatermarkModule : Module() {
|
|
|
35
37
|
companion object {
|
|
36
38
|
private const val TAG = "ExpoVideoWatermark"
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Check if the device has a hardware H.265/HEVC encoder
|
|
42
|
+
*/
|
|
43
|
+
fun hasHevcEncoder(): Boolean {
|
|
44
|
+
return try {
|
|
45
|
+
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
|
46
|
+
codecList.codecInfos.any { codecInfo ->
|
|
47
|
+
codecInfo.isEncoder && codecInfo.supportedTypes.any { type ->
|
|
48
|
+
type.equals(MimeTypes.VIDEO_H265, ignoreCase = true)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (e: Exception) {
|
|
52
|
+
Log.w(TAG, "Failed to check HEVC encoder support: ${e.message}")
|
|
53
|
+
false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
/**
|
|
39
58
|
* Get device information for debugging
|
|
40
59
|
*/
|
|
@@ -309,6 +328,10 @@ class ExpoVideoWatermarkModule : Module() {
|
|
|
309
328
|
mainHandler.post {
|
|
310
329
|
try {
|
|
311
330
|
val transformer = Transformer.Builder(context)
|
|
331
|
+
// Force H.264 output for maximum compatibility
|
|
332
|
+
.setVideoMimeType(MimeTypes.VIDEO_H264)
|
|
333
|
+
// Enable HDR to SDR tone mapping for videos with HDR content
|
|
334
|
+
.setHdrMode(Transformer.HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_OPEN_GL)
|
|
312
335
|
.addListener(object : Transformer.Listener {
|
|
313
336
|
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
|
|
314
337
|
Log.d(TAG, "[Step 15] Transform completed successfully")
|
|
@@ -318,7 +341,88 @@ class ExpoVideoWatermarkModule : Module() {
|
|
|
318
341
|
"averageVideoBitrate: ${exportResult.averageVideoBitrate}, " +
|
|
319
342
|
"videoFrameCount: ${exportResult.videoFrameCount}")
|
|
320
343
|
watermarkBitmap.recycle()
|
|
321
|
-
|
|
344
|
+
|
|
345
|
+
// Step 16: Re-encode to H.265 if device supports HEVC encoder
|
|
346
|
+
val supportsHevc = hasHevcEncoder()
|
|
347
|
+
Log.d(TAG, "[Step 16] HEVC encoder support: $supportsHevc")
|
|
348
|
+
|
|
349
|
+
if (!supportsHevc) {
|
|
350
|
+
Log.d(TAG, "[Step 16] Skipping H.265 re-encode - no HEVC encoder available")
|
|
351
|
+
promise.resolve("file://$cleanOutputPath")
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Create temp path for the H.264 watermarked video, rename current output
|
|
356
|
+
val h264File = File(cleanOutputPath)
|
|
357
|
+
val h264TempPath = cleanOutputPath.replace(".mp4", "_h264_temp.mp4")
|
|
358
|
+
val h264TempFile = File(h264TempPath)
|
|
359
|
+
|
|
360
|
+
if (!h264File.renameTo(h264TempFile)) {
|
|
361
|
+
Log.e(TAG, "[Step 16] Failed to rename H.264 file for re-encoding, returning H.264 output")
|
|
362
|
+
promise.resolve("file://$cleanOutputPath")
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
Log.d(TAG, "[Step 16] Starting H.265 re-encode from: $h264TempPath to: $cleanOutputPath")
|
|
367
|
+
|
|
368
|
+
// Build transformer for H.265 re-encoding
|
|
369
|
+
val hevcTransformer = Transformer.Builder(context)
|
|
370
|
+
.setVideoMimeType(MimeTypes.VIDEO_H265)
|
|
371
|
+
.addListener(object : Transformer.Listener {
|
|
372
|
+
override fun onCompleted(composition: Composition, hevcExportResult: ExportResult) {
|
|
373
|
+
Log.d(TAG, "[Step 16] H.265 re-encode completed successfully")
|
|
374
|
+
Log.d(TAG, "[Step 16] Export result - durationMs: ${hevcExportResult.durationMs}, " +
|
|
375
|
+
"fileSizeBytes: ${hevcExportResult.fileSizeBytes}, " +
|
|
376
|
+
"averageAudioBitrate: ${hevcExportResult.averageAudioBitrate}, " +
|
|
377
|
+
"averageVideoBitrate: ${hevcExportResult.averageVideoBitrate}, " +
|
|
378
|
+
"videoFrameCount: ${hevcExportResult.videoFrameCount}")
|
|
379
|
+
|
|
380
|
+
// Calculate compression ratio
|
|
381
|
+
val h264Size = exportResult.fileSizeBytes
|
|
382
|
+
val h265Size = hevcExportResult.fileSizeBytes
|
|
383
|
+
if (h264Size > 0 && h265Size > 0) {
|
|
384
|
+
val savings = ((h264Size - h265Size) * 100.0 / h264Size)
|
|
385
|
+
Log.d(TAG, "[Step 16] Size reduction: H.264=${h264Size/1024}KB -> H.265=${h265Size/1024}KB (${String.format("%.1f", savings)}% smaller)")
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Clean up temp H.264 file
|
|
389
|
+
if (h264TempFile.exists()) {
|
|
390
|
+
h264TempFile.delete()
|
|
391
|
+
Log.d(TAG, "[Step 16] Cleaned up temp H.264 file")
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
promise.resolve("file://$cleanOutputPath")
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
override fun onError(
|
|
398
|
+
composition: Composition,
|
|
399
|
+
hevcExportResult: ExportResult,
|
|
400
|
+
hevcExportException: ExportException
|
|
401
|
+
) {
|
|
402
|
+
val errorCodeName = getExportErrorCodeName(hevcExportException.errorCode)
|
|
403
|
+
Log.e(TAG, "[Step 16] H.265 re-encode failed: $errorCodeName - ${hevcExportException.message}")
|
|
404
|
+
Log.e(TAG, Log.getStackTraceString(hevcExportException))
|
|
405
|
+
|
|
406
|
+
// Restore H.264 file as output on failure
|
|
407
|
+
if (h264TempFile.exists()) {
|
|
408
|
+
val outputFile = File(cleanOutputPath)
|
|
409
|
+
if (outputFile.exists()) {
|
|
410
|
+
outputFile.delete()
|
|
411
|
+
}
|
|
412
|
+
h264TempFile.renameTo(outputFile)
|
|
413
|
+
Log.d(TAG, "[Step 16] Restored H.264 output after H.265 failure")
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Still resolve with the H.264 version rather than failing completely
|
|
417
|
+
Log.d(TAG, "[Step 16] Returning H.264 output instead")
|
|
418
|
+
promise.resolve("file://$cleanOutputPath")
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
.build()
|
|
422
|
+
|
|
423
|
+
val hevcMediaItem = MediaItem.fromUri("file://$h264TempPath")
|
|
424
|
+
val hevcEditedMediaItem = EditedMediaItem.Builder(hevcMediaItem).build()
|
|
425
|
+
hevcTransformer.start(hevcEditedMediaItem, cleanOutputPath)
|
|
322
426
|
}
|
|
323
427
|
|
|
324
428
|
override fun onError(
|