Skip to content

Commit ae1e3d0

Browse files
committed
feat: add change detection capture mode
- New capture mode that captures frames only when screen content changes - Configurable threshold, minInterval, maxInterval, sampleRate options - New CHANGE_DETECTED event for debugging and monitoring - ChangeDetector.kt with pixel sampling algorithm - Updated example app with mode selection UI - Updated documentation (README, configuration, events, usage-examples)
1 parent 1f4fafa commit ae1e3d0

18 files changed

Lines changed: 964 additions & 36 deletions

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.0] - 2025-02-09
9+
10+
### Added
11+
12+
#### Change Detection Capture Mode
13+
14+
- New capture mode that captures frames only when screen content changes
15+
- Configurable change detection options:
16+
- `threshold` - Percentage of pixels that must change to trigger capture (1-100%)
17+
- `minInterval` - Minimum milliseconds between captures (100-60000ms)
18+
- `maxInterval` - Maximum milliseconds before forced capture (0 = disabled)
19+
- `sampleRate` - Pixel sampling rate for performance optimization (1-100)
20+
- `detectionRegion` - Optional region to monitor for changes
21+
- New `CHANGE_DETECTED` event for debugging and monitoring
22+
- Pixel sampling algorithm for efficient frame comparison
23+
24+
### Example
25+
26+
```typescript
27+
await FrameCapture.startCapture({
28+
capture: {
29+
mode: 'change-detection',
30+
changeDetection: {
31+
threshold: 15, // Capture when 15% of screen changes
32+
minInterval: 500, // Poll every 500ms
33+
maxInterval: 5000, // Force capture at least every 5s
34+
},
35+
},
36+
image: { quality: 80, format: 'jpeg' },
37+
});
38+
```
39+
40+
---
41+
842
## [1.0.0] - 2025-11-11
943

1044
### Added
@@ -141,4 +175,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
141175

142176
This is the initial stable release of React Native Frame Capture. The library provides production-ready screen capture functionality with a focus on reliability, performance, and developer experience.
143177

178+
[1.1.0]: https://github.com/nasyx-rakeeb/react-native-frame-capture/releases/tag/v1.1.0
144179
[1.0.0]: https://github.com/nasyx-rakeeb/react-native-frame-capture/releases/tag/v1.0.0

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
[![license](https://img.shields.io/npm/l/react-native-frame-capture.svg)](https://github.com/nasyx-rakeeb/react-native-frame-capture/blob/main/LICENSE)
66
[![platform](https://img.shields.io/badge/platform-Android-green.svg)](https://www.android.com/)
77

8-
Reliable screen capture for React Native Android. Capture frames at intervals with customizable overlays and storage options.
8+
Reliable screen capture for React Native Android. Capture frames at intervals or when screen content changes, with customizable overlays and storage options.
99

1010
## Features
1111

1212
- 📸 **Interval-based capture** - Capture frames at configurable intervals (100ms - 60s)
13+
- 🔍 **Change detection mode** - Capture only when screen content changes (NEW!)
1314
- 🎨 **Customizable overlays** - Add text and image overlays with template variables
1415
- 💾 **Flexible storage** - Save to app-specific, public, or custom directories
1516
- 🔄 **Background capture** - Continues capturing when app is minimized (foreground service)

android/src/main/java/com/framecapture/CaptureManager.kt

Lines changed: 185 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import com.framecapture.models.CaptureState
2222
import com.framecapture.models.CaptureStatus
2323
import com.framecapture.models.ErrorCode
2424
import com.framecapture.models.FrameInfo
25+
import com.framecapture.models.CaptureMode
2526
import com.framecapture.capture.CaptureEventEmitter
2627
import com.framecapture.capture.BitmapProcessor
28+
import com.framecapture.capture.ChangeDetector
2729
import java.util.UUID
2830
import java.util.concurrent.ExecutorService
2931
import java.util.concurrent.Executors
@@ -88,6 +90,9 @@ class CaptureManager(
8890
private lateinit var eventEmitterManager: CaptureEventEmitter
8991
private lateinit var bitmapProcessor: BitmapProcessor
9092

93+
// Change detection support
94+
private var changeDetector: ChangeDetector? = null
95+
9196
companion object {
9297
private const val TAG = "CaptureManager"
9398
private const val VIRTUAL_DISPLAY_NAME = "ScreenCapture"
@@ -357,6 +362,158 @@ class CaptureManager(
357362
}
358363
}
359364

365+
/**
366+
* Starts change detection capture mode
367+
*
368+
* Polls for frames at minInterval and compares them to detect changes.
369+
* Only captures when change exceeds threshold or maxInterval forces capture.
370+
*/
371+
private fun startChangeDetectionCapture() {
372+
try {
373+
val changeConfig = captureOptions?.changeDetection
374+
val minInterval = changeConfig?.minInterval ?: Constants.DEFAULT_CHANGE_MIN_INTERVAL
375+
val maxInterval = changeConfig?.maxInterval ?: Constants.DEFAULT_CHANGE_MAX_INTERVAL
376+
val threshold = changeConfig?.threshold ?: Constants.DEFAULT_CHANGE_THRESHOLD
377+
378+
captureRunnable = object : Runnable {
379+
override fun run() {
380+
try {
381+
if (isCapturing && !isPaused) {
382+
val image = imageReader?.acquireLatestImage()
383+
if (image != null) {
384+
processingExecutor.execute {
385+
try {
386+
processChangeDetectionFrame(image, threshold, minInterval, maxInterval)
387+
} catch (e: Exception) {
388+
Log.e(TAG, "Error processing change detection frame", e)
389+
handleError(e)
390+
} finally {
391+
image.close()
392+
}
393+
}
394+
}
395+
}
396+
397+
// Schedule next check if still capturing
398+
if (isCapturing) {
399+
captureHandler.postDelayed(this, minInterval)
400+
}
401+
} catch (e: Exception) {
402+
Log.e(TAG, "Error in change detection runnable", e)
403+
handleError(e)
404+
}
405+
}
406+
}
407+
408+
// Start the change detection polling
409+
captureHandler.post(captureRunnable!!)
410+
411+
} catch (e: Exception) {
412+
Log.e(TAG, "Failed to start change detection capture", e)
413+
throw e
414+
}
415+
}
416+
417+
/**
418+
* Processes a frame for change detection
419+
*
420+
* Compares frame with previous, determines if capture should occur,
421+
* and emits change detection events.
422+
*/
423+
private fun processChangeDetectionFrame(
424+
image: Image,
425+
threshold: Float,
426+
minInterval: Long,
427+
maxInterval: Long
428+
) {
429+
var bitmap: Bitmap? = null
430+
431+
try {
432+
// Convert to bitmap for comparison
433+
bitmap = bitmapProcessor.imageToBitmap(image, captureOptions)
434+
435+
val currentTime = System.currentTimeMillis()
436+
val timeSinceLastCapture = currentTime - lastCaptureTime
437+
438+
// Detect change percentage
439+
val detector = changeDetector ?: return
440+
val changePercent = detector.detectChange(bitmap)
441+
442+
// Determine if we should capture
443+
val shouldCaptureDueToChange = changePercent >= threshold
444+
val shouldCaptureDueToMaxInterval = maxInterval > 0 && timeSinceLastCapture >= maxInterval
445+
val hasMinIntervalPassed = timeSinceLastCapture >= minInterval
446+
447+
val shouldDoCapture = hasMinIntervalPassed && (shouldCaptureDueToChange || shouldCaptureDueToMaxInterval)
448+
449+
// Emit change detected event for debugging/monitoring
450+
eventEmitterManager.emitChangeDetected(
451+
changePercent = changePercent,
452+
threshold = threshold,
453+
captured = shouldDoCapture,
454+
timeSinceLastCapture = timeSinceLastCapture
455+
)
456+
457+
if (shouldDoCapture) {
458+
// Update previous frame for next comparison
459+
detector.updatePreviousFrame(bitmap)
460+
lastCaptureTime = currentTime
461+
462+
// Process and save the frame (reuse processImage logic)
463+
processImageFromBitmap(bitmap)
464+
// Don't recycle - processImageFromBitmap handles it
465+
bitmap = null
466+
}
467+
468+
} catch (e: Exception) {
469+
Log.e(TAG, "Error in change detection frame processing", e)
470+
handleError(e)
471+
} finally {
472+
bitmap?.recycle()
473+
}
474+
}
475+
476+
/**
477+
* Processes an already-converted bitmap (for change detection mode)
478+
*/
479+
private fun processImageFromBitmap(bitmap: Bitmap) {
480+
try {
481+
val currentSessionId = sessionId ?: throw IllegalStateException("No active session")
482+
val currentOptions = captureOptions ?: throw IllegalStateException("No capture options")
483+
484+
frameCount++
485+
486+
// Render overlays if configured
487+
val overlays = currentOptions.overlays
488+
if (!overlays.isNullOrEmpty()) {
489+
overlayRenderer.renderOverlays(
490+
bitmap = bitmap,
491+
overlays = overlays,
492+
frameNumber = frameCount - 1,
493+
sessionId = currentSessionId
494+
)
495+
}
496+
497+
// Check storage space and emit warning if low
498+
val threshold = currentOptions.advanced.storage.warningThreshold
499+
storageManager.isStorageAvailable(threshold)
500+
501+
// Save frame (temp or permanent based on saveFrames option)
502+
val frameInfo = storageManager.saveFrame(
503+
bitmap = bitmap,
504+
sessionId = currentSessionId,
505+
frameNumber = frameCount - 1,
506+
options = currentOptions
507+
)
508+
509+
// Emit frame captured event
510+
eventEmitterManager.emitFrameCaptured(frameInfo, frameCount - 1)
511+
512+
} finally {
513+
bitmap.recycle()
514+
}
515+
}
516+
360517
/**
361518
* Checks if a frame should be captured based on throttling logic
362519
*
@@ -475,7 +632,8 @@ class CaptureManager(
475632
/**
476633
* Starts the capture session
477634
*
478-
* Sets up VirtualDisplay and ImageReader, then begins interval-based periodic capture.
635+
* Sets up VirtualDisplay and ImageReader, then begins capture based on configured mode.
636+
* Supports interval-based capture and change-detection capture.
479637
*
480638
* @throws IllegalStateException if already capturing or not initialized
481639
*/
@@ -506,8 +664,24 @@ class CaptureManager(
506664
// Create VirtualDisplay
507665
createVirtualDisplay(metrics)
508666

509-
// Start periodic capture (interval mode only)
510-
startPeriodicCapture()
667+
// Start capture based on mode
668+
val options = captureOptions!!
669+
when (options.captureMode) {
670+
CaptureMode.CHANGE_DETECTION -> {
671+
// Initialize change detector
672+
val changeConfig = options.changeDetection
673+
changeDetector = ChangeDetector(
674+
threshold = changeConfig?.threshold ?: Constants.DEFAULT_CHANGE_THRESHOLD,
675+
sampleRate = changeConfig?.sampleRate ?: Constants.DEFAULT_CHANGE_SAMPLE_RATE,
676+
detectionRegion = changeConfig?.detectionRegion
677+
)
678+
startChangeDetectionCapture()
679+
}
680+
else -> {
681+
// Default to interval mode
682+
startPeriodicCapture()
683+
}
684+
}
511685

512686
// Emit capture start event to JavaScript
513687
val currentSessionId = sessionId ?: throw IllegalStateException("No session ID")
@@ -716,6 +890,14 @@ class CaptureManager(
716890
Log.e(TAG, "Error clearing overlay caches", e)
717891
}
718892

893+
// Clear change detector
894+
try {
895+
changeDetector?.clear()
896+
changeDetector = null
897+
} catch (e: Exception) {
898+
Log.e(TAG, "Error clearing change detector", e)
899+
}
900+
719901
// Clear all state variables
720902
captureOptions = null
721903
sessionId = null

android/src/main/java/com/framecapture/Constants.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ object Constants {
2828

2929
// Capture mode strings
3030
const val CAPTURE_MODE_INTERVAL = "interval"
31+
const val CAPTURE_MODE_CHANGE_DETECTION = "change-detection"
32+
33+
// Change detection defaults
34+
const val DEFAULT_CHANGE_THRESHOLD = 5f // 5% of pixels changed
35+
const val DEFAULT_CHANGE_MIN_INTERVAL = 500L // 500ms minimum between captures
36+
const val DEFAULT_CHANGE_MAX_INTERVAL = 0L // 0 = disabled (no forced captures)
37+
const val DEFAULT_CHANGE_SAMPLE_RATE = 16 // Sample every 16th pixel
38+
const val CHANGE_PIXEL_TOLERANCE = 10 // RGB difference tolerance for "changed" pixel
3139

3240
// Storage warning threshold (100MB in bytes) - DEFAULT VALUE
3341
const val DEFAULT_STORAGE_WARNING_THRESHOLD = 100L * 1024L * 1024L
@@ -108,6 +116,7 @@ object Constants {
108116
const val EVENT_CAPTURE_PAUSE = "onCapturePause"
109117
const val EVENT_CAPTURE_RESUME = "onCaptureResume"
110118
const val EVENT_OVERLAY_ERROR = "onOverlayError"
119+
const val EVENT_CHANGE_DETECTED = "onChangeDetected"
111120

112121
// Resource cleanup timeout (in milliseconds) - DEFAULT VALUES
113122
const val DEFAULT_EXECUTOR_SHUTDOWN_TIMEOUT = 5000L

android/src/main/java/com/framecapture/capture/CaptureEventEmitter.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,34 @@ class CaptureEventEmitter(
117117
// Silently fail - event emission is not critical
118118
}
119119
}
120+
121+
/**
122+
* Emits onChangeDetected event to JavaScript for debugging/monitoring
123+
*
124+
* Contains change detection results even when capture is not triggered.
125+
*
126+
* @param changePercent Percentage of pixels that changed (0-100)
127+
* @param threshold The configured threshold for triggering capture
128+
* @param captured Whether a capture was triggered
129+
* @param timeSinceLastCapture Milliseconds since last capture
130+
*/
131+
fun emitChangeDetected(
132+
changePercent: Float,
133+
threshold: Float,
134+
captured: Boolean,
135+
timeSinceLastCapture: Long
136+
) {
137+
try {
138+
val params = Arguments.createMap().apply {
139+
putDouble("changePercent", changePercent.toDouble())
140+
putDouble("threshold", threshold.toDouble())
141+
putBoolean("captured", captured)
142+
putDouble("timeSinceLastCapture", timeSinceLastCapture.toDouble())
143+
}
144+
eventEmitter(Constants.EVENT_CHANGE_DETECTED, params)
145+
} catch (e: Exception) {
146+
// Silently fail - event emission is not critical
147+
}
148+
}
120149
}
150+

0 commit comments

Comments
 (0)