react-native-frame-capture 1.0.1 → 1.1.0

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.
Files changed (50) hide show
  1. package/FrameCapture.podspec +21 -21
  2. package/LICENSE +20 -20
  3. package/README.md +159 -158
  4. package/android/build.gradle +77 -77
  5. package/android/gradle.properties +5 -5
  6. package/android/src/main/AndroidManifest.xml +20 -20
  7. package/android/src/main/java/com/framecapture/CaptureManager.kt +1013 -831
  8. package/android/src/main/java/com/framecapture/Constants.kt +205 -196
  9. package/android/src/main/java/com/framecapture/ErrorHandler.kt +165 -165
  10. package/android/src/main/java/com/framecapture/FrameCaptureModule.kt +653 -653
  11. package/android/src/main/java/com/framecapture/OverlayRenderer.kt +423 -423
  12. package/android/src/main/java/com/framecapture/PermissionHandler.kt +150 -150
  13. package/android/src/main/java/com/framecapture/ScreenCaptureService.kt +366 -366
  14. package/android/src/main/java/com/framecapture/StorageManager.kt +221 -221
  15. package/android/src/main/java/com/framecapture/capture/BitmapProcessor.kt +157 -157
  16. package/android/src/main/java/com/framecapture/capture/CaptureEventEmitter.kt +150 -120
  17. package/android/src/main/java/com/framecapture/capture/ChangeDetector.kt +191 -0
  18. package/android/src/main/java/com/framecapture/models/CaptureModels.kt +343 -302
  19. package/android/src/main/java/com/framecapture/models/EnumsAndExtensions.kt +67 -60
  20. package/android/src/main/java/com/framecapture/models/OverlayModels.kt +154 -154
  21. package/android/src/main/java/com/framecapture/service/CaptureNotificationManager.kt +286 -286
  22. package/android/src/main/java/com/framecapture/storage/StorageStrategies.kt +317 -317
  23. package/android/src/main/java/com/framecapture/utils/ValidationUtils.kt +379 -379
  24. package/ios/FrameCapture.h +5 -5
  25. package/ios/FrameCapture.mm +21 -21
  26. package/lib/module/NativeFrameCapture.js.map +1 -1
  27. package/lib/module/constants.js +45 -0
  28. package/lib/module/constants.js.map +1 -1
  29. package/lib/module/normalize.js +10 -1
  30. package/lib/module/normalize.js.map +1 -1
  31. package/lib/module/types.js +9 -0
  32. package/lib/module/types.js.map +1 -1
  33. package/lib/module/validation.js +86 -9
  34. package/lib/module/validation.js.map +1 -1
  35. package/lib/typescript/src/NativeFrameCapture.d.ts +7 -0
  36. package/lib/typescript/src/NativeFrameCapture.d.ts.map +1 -1
  37. package/lib/typescript/src/constants.d.ts +33 -0
  38. package/lib/typescript/src/constants.d.ts.map +1 -1
  39. package/lib/typescript/src/normalize.d.ts +8 -2
  40. package/lib/typescript/src/normalize.d.ts.map +1 -1
  41. package/lib/typescript/src/types.d.ts +29 -5
  42. package/lib/typescript/src/types.d.ts.map +1 -1
  43. package/lib/typescript/src/validation.d.ts.map +1 -1
  44. package/package.json +199 -196
  45. package/src/NativeFrameCapture.ts +8 -0
  46. package/src/constants.ts +45 -0
  47. package/src/normalize.ts +23 -3
  48. package/src/types.ts +30 -2
  49. package/src/validation.ts +132 -13
  50. package/plugin/build/index.js +0 -48
@@ -1,423 +1,423 @@
1
- package com.framecapture
2
-
3
- import android.content.Context
4
- import android.graphics.*
5
- import android.net.Uri
6
- import android.util.Log
7
- import android.util.LruCache
8
- import com.framecapture.models.OverlayConfig
9
- import com.framecapture.models.OverlayPosition
10
- import com.framecapture.models.TextStyle
11
- import java.io.File
12
- import java.text.SimpleDateFormat
13
- import java.util.*
14
-
15
- /**
16
- * Renders overlays (text and images) on captured frame bitmaps
17
- * Uses caching for performance optimization
18
- */
19
- class OverlayRenderer(
20
- private val context: Context,
21
- private val eventEmitter: ((String, com.facebook.react.bridge.WritableMap?) -> Unit)? = null,
22
- cacheSize: Int = Constants.DEFAULT_OVERLAY_IMAGE_CACHE_SIZE
23
- ) {
24
-
25
- companion object {
26
- private const val TAG = "OverlayRenderer"
27
- }
28
-
29
- // Image cache to avoid repeated file I/O
30
- private var imageCache: LruCache<String, Bitmap> = object : LruCache<String, Bitmap>(cacheSize) {
31
- override fun sizeOf(key: String, bitmap: Bitmap): Int {
32
- return bitmap.byteCount
33
- }
34
- }
35
-
36
- /**
37
- * Updates the cache size (recreates the cache)
38
- */
39
- fun updateCacheSize(newSize: Int) {
40
- clearCaches()
41
- imageCache = object : LruCache<String, Bitmap>(newSize) {
42
- override fun sizeOf(key: String, bitmap: Bitmap): Int {
43
- return bitmap.byteCount
44
- }
45
- }
46
- }
47
-
48
- // Paint object pool for text rendering
49
- private val textPaintCache = mutableMapOf<String, Paint>()
50
-
51
- /**
52
- * Renders all overlays on the bitmap
53
- *
54
- * @param bitmap The bitmap to draw overlays on (modified in-place)
55
- * @param overlays List of overlay configurations
56
- * @param frameNumber Current frame number for variable substitution
57
- * @param sessionId Current session ID for variable substitution
58
- */
59
- fun renderOverlays(
60
- bitmap: Bitmap,
61
- overlays: List<OverlayConfig>,
62
- frameNumber: Int,
63
- sessionId: String
64
- ) {
65
- if (overlays.isEmpty()) return
66
-
67
- val canvas = Canvas(bitmap)
68
-
69
- overlays.forEachIndexed { index, overlay ->
70
- try {
71
- when (overlay) {
72
- is OverlayConfig.Text -> {
73
- // Validate text overlay has content
74
- if (overlay.content.isEmpty()) {
75
- return@forEachIndexed
76
- }
77
- renderTextOverlay(canvas, overlay, frameNumber, sessionId)
78
- }
79
- is OverlayConfig.Image -> {
80
- // Validate image overlay has source
81
- if (overlay.source.isEmpty()) {
82
- return@forEachIndexed
83
- }
84
- renderImageOverlay(canvas, overlay, bitmap.width, bitmap.height)
85
- }
86
- }
87
- } catch (e: Exception) {
88
- Log.e(TAG, "Failed to render overlay at index $index (type: ${overlay.type}): ${e.message}", e)
89
- emitOverlayError(index, overlay.type, e.message ?: "Unknown error")
90
- }
91
- }
92
- }
93
-
94
- /**
95
- * Substitutes template variables in text content
96
- */
97
- private fun substituteVariables(content: String, frameNumber: Int, sessionId: String): String {
98
- var result = content
99
-
100
- // {frameNumber}
101
- result = result.replace(Constants.TEMPLATE_VAR_FRAME_NUMBER, frameNumber.toString())
102
-
103
- // {sessionId}
104
- result = result.replace(Constants.TEMPLATE_VAR_SESSION_ID, sessionId)
105
-
106
- // {timestamp}
107
- val timestamp = SimpleDateFormat(Constants.OVERLAY_TIMESTAMP_FORMAT, Locale.US).apply {
108
- timeZone = TimeZone.getTimeZone(Constants.OVERLAY_TIMESTAMP_TIMEZONE)
109
- }.format(Date())
110
- result = result.replace(Constants.TEMPLATE_VAR_TIMESTAMP, timestamp)
111
-
112
- return result
113
- }
114
-
115
- /**
116
- * Calculates absolute pixel position from OverlayPosition
117
- */
118
- private fun calculatePosition(
119
- position: OverlayPosition,
120
- canvasWidth: Int,
121
- canvasHeight: Int,
122
- overlayWidth: Int,
123
- overlayHeight: Int
124
- ): Pair<Int, Int> {
125
- return when (position) {
126
- is OverlayPosition.Preset -> {
127
- val padding = Constants.OVERLAY_DEFAULT_PADDING
128
- when (position.value) {
129
- Constants.POSITION_TOP_LEFT -> padding to padding
130
- Constants.POSITION_TOP_RIGHT -> (canvasWidth - overlayWidth - padding) to padding
131
- Constants.POSITION_BOTTOM_LEFT -> padding to (canvasHeight - overlayHeight - padding)
132
- Constants.POSITION_BOTTOM_RIGHT -> (canvasWidth - overlayWidth - padding) to (canvasHeight - overlayHeight - padding)
133
- Constants.POSITION_CENTER -> ((canvasWidth - overlayWidth) / 2) to ((canvasHeight - overlayHeight) / 2)
134
- else -> padding to padding
135
- }
136
- }
137
- is OverlayPosition.Coordinates -> {
138
- val x = if (position.unit == Constants.POSITION_UNIT_PERCENTAGE) {
139
- (position.x * canvasWidth).toInt()
140
- } else {
141
- position.x.toInt()
142
- }
143
-
144
- val y = if (position.unit == Constants.POSITION_UNIT_PERCENTAGE) {
145
- (position.y * canvasHeight).toInt()
146
- } else {
147
- position.y.toInt()
148
- }
149
-
150
- // Clamp to canvas bounds
151
- val clampedX = x.coerceIn(0, canvasWidth - overlayWidth)
152
- val clampedY = y.coerceIn(0, canvasHeight - overlayHeight)
153
-
154
- clampedX to clampedY
155
- }
156
- }
157
- }
158
-
159
- /**
160
- * Renders a text overlay on the canvas
161
- */
162
- private fun renderTextOverlay(
163
- canvas: Canvas,
164
- overlay: OverlayConfig.Text,
165
- frameNumber: Int,
166
- sessionId: String
167
- ) {
168
- try {
169
- // Substitute template variables
170
- val text = substituteVariables(overlay.content, frameNumber, sessionId)
171
-
172
- // Get or create paint object
173
- val paint = getTextPaint(overlay.style)
174
-
175
- // Measure text bounds
176
- val bounds = Rect()
177
- paint.getTextBounds(text, 0, text.length, bounds)
178
-
179
- val textWidth = bounds.width()
180
- val textHeight = bounds.height()
181
-
182
- // Calculate position
183
- val (x, y) = calculatePosition(
184
- overlay.position,
185
- canvas.width,
186
- canvas.height,
187
- textWidth + overlay.style.padding * 2,
188
- textHeight + overlay.style.padding * 2
189
- )
190
-
191
- // Draw background if specified
192
- if (overlay.style.backgroundColor.isNotEmpty()) {
193
- val parsedColor = parseColor(overlay.style.backgroundColor)
194
-
195
- val bgPaint = Paint().apply {
196
- color = parsedColor
197
- style = Paint.Style.FILL
198
- }
199
-
200
- canvas.drawRect(
201
- x.toFloat(),
202
- y.toFloat(),
203
- x + textWidth + overlay.style.padding * 2f,
204
- y + textHeight + overlay.style.padding * 2f,
205
- bgPaint
206
- )
207
- }
208
-
209
- // Draw text
210
- canvas.drawText(
211
- text,
212
- x + overlay.style.padding.toFloat(),
213
- y + textHeight + overlay.style.padding.toFloat(),
214
- paint
215
- )
216
- } catch (e: Exception) {
217
- Log.e(TAG, "Error rendering text overlay: ${e.message}", e)
218
- throw e
219
- }
220
- }
221
-
222
- /**
223
- * Renders an image overlay on the canvas
224
- */
225
- private fun renderImageOverlay(
226
- canvas: Canvas,
227
- overlay: OverlayConfig.Image,
228
- canvasWidth: Int,
229
- canvasHeight: Int
230
- ) {
231
- try {
232
- // Load image (from cache or file)
233
- val image = loadImage(overlay.source)
234
- ?: throw IllegalArgumentException("Failed to load image from source: ${overlay.source}")
235
-
236
- // Determine image size
237
- val (width, height) = overlay.size?.let { it.width to it.height }
238
- ?: (image.width to image.height)
239
-
240
- // Calculate position
241
- val (x, y) = calculatePosition(
242
- overlay.position,
243
- canvasWidth,
244
- canvasHeight,
245
- width,
246
- height
247
- )
248
-
249
- // Create paint with opacity
250
- val paint = Paint().apply {
251
- alpha = (overlay.opacity * 255).toInt()
252
- }
253
-
254
- // Scale image if needed
255
- val scaledImage = if (overlay.size != null) {
256
- Bitmap.createScaledBitmap(image, width, height, true)
257
- } else {
258
- image
259
- }
260
-
261
- // Draw image
262
- canvas.drawBitmap(scaledImage, x.toFloat(), y.toFloat(), paint)
263
-
264
- // Clean up scaled bitmap if created
265
- if (scaledImage != image) {
266
- scaledImage.recycle()
267
- }
268
- } catch (e: Exception) {
269
- Log.e(TAG, "Error rendering image overlay from ${overlay.source}: ${e.message}", e)
270
- throw e
271
- }
272
- }
273
-
274
- /**
275
- * Gets or creates a Paint object for text rendering
276
- */
277
- private fun getTextPaint(style: TextStyle): Paint {
278
- val cacheKey = "${style.fontSize}_${style.color}_${style.fontWeight}_${style.textAlign}"
279
-
280
- return textPaintCache.getOrPut(cacheKey) {
281
- Paint().apply {
282
- color = parseColor(style.color)
283
- textSize = style.fontSize.toFloat()
284
- isAntiAlias = true
285
- isFakeBoldText = style.fontWeight == Constants.TEXT_WEIGHT_BOLD
286
- textAlign = when (style.textAlign) {
287
- Constants.TEXT_ALIGN_CENTER -> Paint.Align.CENTER
288
- Constants.TEXT_ALIGN_RIGHT -> Paint.Align.RIGHT
289
- else -> Paint.Align.LEFT
290
- }
291
- }
292
- }
293
- }
294
-
295
- /**
296
- * Loads an image from drawable resource, file, or cache
297
- *
298
- * Supports:
299
- * - Drawable resource names: "logo", "ic_watermark" (looks in app's drawable folder)
300
- * - File URIs: "file:///path/to/image.png"
301
- * - Content URIs: "content://media/external/images/media/123"
302
- */
303
- private fun loadImage(source: String): Bitmap? {
304
- // Check cache first
305
- imageCache.get(source)?.let { return it }
306
-
307
- return try {
308
- val bitmap = when {
309
- // Check if it's a drawable resource name (no scheme, just a name)
310
- !source.contains("://") && !source.startsWith("/") -> {
311
- loadImageFromDrawable(source)
312
- }
313
- // File or content URI
314
- else -> {
315
- loadImageFromUri(source)
316
- }
317
- }
318
-
319
- // Cache the loaded image
320
- bitmap?.let { imageCache.put(source, it) }
321
-
322
- bitmap
323
- } catch (e: Exception) {
324
- null
325
- }
326
- }
327
-
328
- /**
329
- * Loads an image from drawable resources by name
330
- */
331
- private fun loadImageFromDrawable(resourceName: String): Bitmap? {
332
- return try {
333
- val resourceId = context.resources.getIdentifier(
334
- resourceName,
335
- Constants.RESOURCE_TYPE_DRAWABLE,
336
- context.packageName
337
- )
338
-
339
- if (resourceId == 0) {
340
- return null
341
- }
342
-
343
- BitmapFactory.decodeResource(context.resources, resourceId)
344
- } catch (e: Exception) {
345
- null
346
- }
347
- }
348
-
349
- /**
350
- * Loads an image from file or content URI
351
- */
352
- private fun loadImageFromUri(source: String): Bitmap? {
353
- return try {
354
- val uri = Uri.parse(source)
355
- val path = when (uri.scheme) {
356
- Constants.URI_SCHEME_FILE -> uri.path
357
- Constants.URI_SCHEME_CONTENT -> uri.toString()
358
- else -> source
359
- }
360
-
361
- if (path != null && File(path).exists()) {
362
- BitmapFactory.decodeFile(path)
363
- } else {
364
- context.contentResolver.openInputStream(uri)?.use {
365
- BitmapFactory.decodeStream(it)
366
- }
367
- }
368
- } catch (e: Exception) {
369
- null
370
- }
371
- }
372
-
373
- /**
374
- * Parses hex color string to Android Color int
375
- * Supports both #RRGGBB and #RRGGBBAA formats
376
- */
377
- private fun parseColor(colorString: String): Int {
378
- return try {
379
- var color = colorString.trim()
380
-
381
- // Handle #RRGGBBAA format (8 chars including #)
382
- if (color.length == 9 && color.startsWith("#")) {
383
- // Extract components
384
- val r = color.substring(1, 3).toInt(16)
385
- val g = color.substring(3, 5).toInt(16)
386
- val b = color.substring(5, 7).toInt(16)
387
- val a = color.substring(7, 9).toInt(16)
388
-
389
- // Construct ARGB color
390
- return Color.argb(a, r, g, b)
391
- }
392
-
393
- // For other formats, use standard parser
394
- Color.parseColor(color)
395
- } catch (e: Exception) {
396
- Color.WHITE
397
- }
398
- }
399
-
400
- /**
401
- * Emits overlay error event to JavaScript
402
- */
403
- private fun emitOverlayError(overlayIndex: Int, overlayType: String, errorMessage: String) {
404
- try {
405
- val params = com.facebook.react.bridge.Arguments.createMap().apply {
406
- putInt("overlayIndex", overlayIndex)
407
- putString("overlayType", overlayType)
408
- putString("message", errorMessage)
409
- }
410
- eventEmitter?.invoke(Constants.EVENT_OVERLAY_ERROR, params)
411
- } catch (e: Exception) {
412
- // Silently fail - event emission is not critical
413
- }
414
- }
415
-
416
- /**
417
- * Clears all caches
418
- */
419
- fun clearCaches() {
420
- imageCache.evictAll()
421
- textPaintCache.clear()
422
- }
423
- }
1
+ package com.framecapture
2
+
3
+ import android.content.Context
4
+ import android.graphics.*
5
+ import android.net.Uri
6
+ import android.util.Log
7
+ import android.util.LruCache
8
+ import com.framecapture.models.OverlayConfig
9
+ import com.framecapture.models.OverlayPosition
10
+ import com.framecapture.models.TextStyle
11
+ import java.io.File
12
+ import java.text.SimpleDateFormat
13
+ import java.util.*
14
+
15
+ /**
16
+ * Renders overlays (text and images) on captured frame bitmaps
17
+ * Uses caching for performance optimization
18
+ */
19
+ class OverlayRenderer(
20
+ private val context: Context,
21
+ private val eventEmitter: ((String, com.facebook.react.bridge.WritableMap?) -> Unit)? = null,
22
+ cacheSize: Int = Constants.DEFAULT_OVERLAY_IMAGE_CACHE_SIZE
23
+ ) {
24
+
25
+ companion object {
26
+ private const val TAG = "OverlayRenderer"
27
+ }
28
+
29
+ // Image cache to avoid repeated file I/O
30
+ private var imageCache: LruCache<String, Bitmap> = object : LruCache<String, Bitmap>(cacheSize) {
31
+ override fun sizeOf(key: String, bitmap: Bitmap): Int {
32
+ return bitmap.byteCount
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Updates the cache size (recreates the cache)
38
+ */
39
+ fun updateCacheSize(newSize: Int) {
40
+ clearCaches()
41
+ imageCache = object : LruCache<String, Bitmap>(newSize) {
42
+ override fun sizeOf(key: String, bitmap: Bitmap): Int {
43
+ return bitmap.byteCount
44
+ }
45
+ }
46
+ }
47
+
48
+ // Paint object pool for text rendering
49
+ private val textPaintCache = mutableMapOf<String, Paint>()
50
+
51
+ /**
52
+ * Renders all overlays on the bitmap
53
+ *
54
+ * @param bitmap The bitmap to draw overlays on (modified in-place)
55
+ * @param overlays List of overlay configurations
56
+ * @param frameNumber Current frame number for variable substitution
57
+ * @param sessionId Current session ID for variable substitution
58
+ */
59
+ fun renderOverlays(
60
+ bitmap: Bitmap,
61
+ overlays: List<OverlayConfig>,
62
+ frameNumber: Int,
63
+ sessionId: String
64
+ ) {
65
+ if (overlays.isEmpty()) return
66
+
67
+ val canvas = Canvas(bitmap)
68
+
69
+ overlays.forEachIndexed { index, overlay ->
70
+ try {
71
+ when (overlay) {
72
+ is OverlayConfig.Text -> {
73
+ // Validate text overlay has content
74
+ if (overlay.content.isEmpty()) {
75
+ return@forEachIndexed
76
+ }
77
+ renderTextOverlay(canvas, overlay, frameNumber, sessionId)
78
+ }
79
+ is OverlayConfig.Image -> {
80
+ // Validate image overlay has source
81
+ if (overlay.source.isEmpty()) {
82
+ return@forEachIndexed
83
+ }
84
+ renderImageOverlay(canvas, overlay, bitmap.width, bitmap.height)
85
+ }
86
+ }
87
+ } catch (e: Exception) {
88
+ Log.e(TAG, "Failed to render overlay at index $index (type: ${overlay.type}): ${e.message}", e)
89
+ emitOverlayError(index, overlay.type, e.message ?: "Unknown error")
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Substitutes template variables in text content
96
+ */
97
+ private fun substituteVariables(content: String, frameNumber: Int, sessionId: String): String {
98
+ var result = content
99
+
100
+ // {frameNumber}
101
+ result = result.replace(Constants.TEMPLATE_VAR_FRAME_NUMBER, frameNumber.toString())
102
+
103
+ // {sessionId}
104
+ result = result.replace(Constants.TEMPLATE_VAR_SESSION_ID, sessionId)
105
+
106
+ // {timestamp}
107
+ val timestamp = SimpleDateFormat(Constants.OVERLAY_TIMESTAMP_FORMAT, Locale.US).apply {
108
+ timeZone = TimeZone.getTimeZone(Constants.OVERLAY_TIMESTAMP_TIMEZONE)
109
+ }.format(Date())
110
+ result = result.replace(Constants.TEMPLATE_VAR_TIMESTAMP, timestamp)
111
+
112
+ return result
113
+ }
114
+
115
+ /**
116
+ * Calculates absolute pixel position from OverlayPosition
117
+ */
118
+ private fun calculatePosition(
119
+ position: OverlayPosition,
120
+ canvasWidth: Int,
121
+ canvasHeight: Int,
122
+ overlayWidth: Int,
123
+ overlayHeight: Int
124
+ ): Pair<Int, Int> {
125
+ return when (position) {
126
+ is OverlayPosition.Preset -> {
127
+ val padding = Constants.OVERLAY_DEFAULT_PADDING
128
+ when (position.value) {
129
+ Constants.POSITION_TOP_LEFT -> padding to padding
130
+ Constants.POSITION_TOP_RIGHT -> (canvasWidth - overlayWidth - padding) to padding
131
+ Constants.POSITION_BOTTOM_LEFT -> padding to (canvasHeight - overlayHeight - padding)
132
+ Constants.POSITION_BOTTOM_RIGHT -> (canvasWidth - overlayWidth - padding) to (canvasHeight - overlayHeight - padding)
133
+ Constants.POSITION_CENTER -> ((canvasWidth - overlayWidth) / 2) to ((canvasHeight - overlayHeight) / 2)
134
+ else -> padding to padding
135
+ }
136
+ }
137
+ is OverlayPosition.Coordinates -> {
138
+ val x = if (position.unit == Constants.POSITION_UNIT_PERCENTAGE) {
139
+ (position.x * canvasWidth).toInt()
140
+ } else {
141
+ position.x.toInt()
142
+ }
143
+
144
+ val y = if (position.unit == Constants.POSITION_UNIT_PERCENTAGE) {
145
+ (position.y * canvasHeight).toInt()
146
+ } else {
147
+ position.y.toInt()
148
+ }
149
+
150
+ // Clamp to canvas bounds
151
+ val clampedX = x.coerceIn(0, canvasWidth - overlayWidth)
152
+ val clampedY = y.coerceIn(0, canvasHeight - overlayHeight)
153
+
154
+ clampedX to clampedY
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Renders a text overlay on the canvas
161
+ */
162
+ private fun renderTextOverlay(
163
+ canvas: Canvas,
164
+ overlay: OverlayConfig.Text,
165
+ frameNumber: Int,
166
+ sessionId: String
167
+ ) {
168
+ try {
169
+ // Substitute template variables
170
+ val text = substituteVariables(overlay.content, frameNumber, sessionId)
171
+
172
+ // Get or create paint object
173
+ val paint = getTextPaint(overlay.style)
174
+
175
+ // Measure text bounds
176
+ val bounds = Rect()
177
+ paint.getTextBounds(text, 0, text.length, bounds)
178
+
179
+ val textWidth = bounds.width()
180
+ val textHeight = bounds.height()
181
+
182
+ // Calculate position
183
+ val (x, y) = calculatePosition(
184
+ overlay.position,
185
+ canvas.width,
186
+ canvas.height,
187
+ textWidth + overlay.style.padding * 2,
188
+ textHeight + overlay.style.padding * 2
189
+ )
190
+
191
+ // Draw background if specified
192
+ if (overlay.style.backgroundColor.isNotEmpty()) {
193
+ val parsedColor = parseColor(overlay.style.backgroundColor)
194
+
195
+ val bgPaint = Paint().apply {
196
+ color = parsedColor
197
+ style = Paint.Style.FILL
198
+ }
199
+
200
+ canvas.drawRect(
201
+ x.toFloat(),
202
+ y.toFloat(),
203
+ x + textWidth + overlay.style.padding * 2f,
204
+ y + textHeight + overlay.style.padding * 2f,
205
+ bgPaint
206
+ )
207
+ }
208
+
209
+ // Draw text
210
+ canvas.drawText(
211
+ text,
212
+ x + overlay.style.padding.toFloat(),
213
+ y + textHeight + overlay.style.padding.toFloat(),
214
+ paint
215
+ )
216
+ } catch (e: Exception) {
217
+ Log.e(TAG, "Error rendering text overlay: ${e.message}", e)
218
+ throw e
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Renders an image overlay on the canvas
224
+ */
225
+ private fun renderImageOverlay(
226
+ canvas: Canvas,
227
+ overlay: OverlayConfig.Image,
228
+ canvasWidth: Int,
229
+ canvasHeight: Int
230
+ ) {
231
+ try {
232
+ // Load image (from cache or file)
233
+ val image = loadImage(overlay.source)
234
+ ?: throw IllegalArgumentException("Failed to load image from source: ${overlay.source}")
235
+
236
+ // Determine image size
237
+ val (width, height) = overlay.size?.let { it.width to it.height }
238
+ ?: (image.width to image.height)
239
+
240
+ // Calculate position
241
+ val (x, y) = calculatePosition(
242
+ overlay.position,
243
+ canvasWidth,
244
+ canvasHeight,
245
+ width,
246
+ height
247
+ )
248
+
249
+ // Create paint with opacity
250
+ val paint = Paint().apply {
251
+ alpha = (overlay.opacity * 255).toInt()
252
+ }
253
+
254
+ // Scale image if needed
255
+ val scaledImage = if (overlay.size != null) {
256
+ Bitmap.createScaledBitmap(image, width, height, true)
257
+ } else {
258
+ image
259
+ }
260
+
261
+ // Draw image
262
+ canvas.drawBitmap(scaledImage, x.toFloat(), y.toFloat(), paint)
263
+
264
+ // Clean up scaled bitmap if created
265
+ if (scaledImage != image) {
266
+ scaledImage.recycle()
267
+ }
268
+ } catch (e: Exception) {
269
+ Log.e(TAG, "Error rendering image overlay from ${overlay.source}: ${e.message}", e)
270
+ throw e
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Gets or creates a Paint object for text rendering
276
+ */
277
+ private fun getTextPaint(style: TextStyle): Paint {
278
+ val cacheKey = "${style.fontSize}_${style.color}_${style.fontWeight}_${style.textAlign}"
279
+
280
+ return textPaintCache.getOrPut(cacheKey) {
281
+ Paint().apply {
282
+ color = parseColor(style.color)
283
+ textSize = style.fontSize.toFloat()
284
+ isAntiAlias = true
285
+ isFakeBoldText = style.fontWeight == Constants.TEXT_WEIGHT_BOLD
286
+ textAlign = when (style.textAlign) {
287
+ Constants.TEXT_ALIGN_CENTER -> Paint.Align.CENTER
288
+ Constants.TEXT_ALIGN_RIGHT -> Paint.Align.RIGHT
289
+ else -> Paint.Align.LEFT
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Loads an image from drawable resource, file, or cache
297
+ *
298
+ * Supports:
299
+ * - Drawable resource names: "logo", "ic_watermark" (looks in app's drawable folder)
300
+ * - File URIs: "file:///path/to/image.png"
301
+ * - Content URIs: "content://media/external/images/media/123"
302
+ */
303
+ private fun loadImage(source: String): Bitmap? {
304
+ // Check cache first
305
+ imageCache.get(source)?.let { return it }
306
+
307
+ return try {
308
+ val bitmap = when {
309
+ // Check if it's a drawable resource name (no scheme, just a name)
310
+ !source.contains("://") && !source.startsWith("/") -> {
311
+ loadImageFromDrawable(source)
312
+ }
313
+ // File or content URI
314
+ else -> {
315
+ loadImageFromUri(source)
316
+ }
317
+ }
318
+
319
+ // Cache the loaded image
320
+ bitmap?.let { imageCache.put(source, it) }
321
+
322
+ bitmap
323
+ } catch (e: Exception) {
324
+ null
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Loads an image from drawable resources by name
330
+ */
331
+ private fun loadImageFromDrawable(resourceName: String): Bitmap? {
332
+ return try {
333
+ val resourceId = context.resources.getIdentifier(
334
+ resourceName,
335
+ Constants.RESOURCE_TYPE_DRAWABLE,
336
+ context.packageName
337
+ )
338
+
339
+ if (resourceId == 0) {
340
+ return null
341
+ }
342
+
343
+ BitmapFactory.decodeResource(context.resources, resourceId)
344
+ } catch (e: Exception) {
345
+ null
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Loads an image from file or content URI
351
+ */
352
+ private fun loadImageFromUri(source: String): Bitmap? {
353
+ return try {
354
+ val uri = Uri.parse(source)
355
+ val path = when (uri.scheme) {
356
+ Constants.URI_SCHEME_FILE -> uri.path
357
+ Constants.URI_SCHEME_CONTENT -> uri.toString()
358
+ else -> source
359
+ }
360
+
361
+ if (path != null && File(path).exists()) {
362
+ BitmapFactory.decodeFile(path)
363
+ } else {
364
+ context.contentResolver.openInputStream(uri)?.use {
365
+ BitmapFactory.decodeStream(it)
366
+ }
367
+ }
368
+ } catch (e: Exception) {
369
+ null
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Parses hex color string to Android Color int
375
+ * Supports both #RRGGBB and #RRGGBBAA formats
376
+ */
377
+ private fun parseColor(colorString: String): Int {
378
+ return try {
379
+ var color = colorString.trim()
380
+
381
+ // Handle #RRGGBBAA format (8 chars including #)
382
+ if (color.length == 9 && color.startsWith("#")) {
383
+ // Extract components
384
+ val r = color.substring(1, 3).toInt(16)
385
+ val g = color.substring(3, 5).toInt(16)
386
+ val b = color.substring(5, 7).toInt(16)
387
+ val a = color.substring(7, 9).toInt(16)
388
+
389
+ // Construct ARGB color
390
+ return Color.argb(a, r, g, b)
391
+ }
392
+
393
+ // For other formats, use standard parser
394
+ Color.parseColor(color)
395
+ } catch (e: Exception) {
396
+ Color.WHITE
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Emits overlay error event to JavaScript
402
+ */
403
+ private fun emitOverlayError(overlayIndex: Int, overlayType: String, errorMessage: String) {
404
+ try {
405
+ val params = com.facebook.react.bridge.Arguments.createMap().apply {
406
+ putInt("overlayIndex", overlayIndex)
407
+ putString("overlayType", overlayType)
408
+ putString("message", errorMessage)
409
+ }
410
+ eventEmitter?.invoke(Constants.EVENT_OVERLAY_ERROR, params)
411
+ } catch (e: Exception) {
412
+ // Silently fail - event emission is not critical
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Clears all caches
418
+ */
419
+ fun clearCaches() {
420
+ imageCache.evictAll()
421
+ textPaintCache.clear()
422
+ }
423
+ }