From 6975b04c5cd9a3967b3286aaaa519cefe1f65d66 Mon Sep 17 00:00:00 2001 From: Jeremiah Oluwaseun Erinola Date: Thu, 29 Jan 2026 13:33:32 +0100 Subject: [PATCH 1/3] Optimize screenshot diff check using rawRgba bytes Replaces PNG encoding with raw RGBA bytes for the screenshot change detection. This avoids expensive PNG compression when checking if the screen has changed. --- .../screenshot/screenshot_capturer.dart | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/src/replay/screenshot/screenshot_capturer.dart b/lib/src/replay/screenshot/screenshot_capturer.dart index 79f50c03..e092d49a 100644 --- a/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/lib/src/replay/screenshot/screenshot_capturer.dart @@ -54,10 +54,10 @@ class ScreenshotCapturer { return min(width / srcWidth, height / srcHeight); } - Future _getImageBytes(ui.Image img) async { + Future _getImageBytes(ui.Image img, + {ui.ImageByteFormat format = ui.ImageByteFormat.png}) async { try { - final ByteData? byteData = - await img.toByteData(format: ui.ImageByteFormat.png); + final ByteData? byteData = await img.toByteData(format: format); if (byteData == null || byteData.lengthInBytes == 0) { printIfDebug('Error: Failed to convert image to byte data.'); return null; @@ -133,11 +133,10 @@ class ScreenshotCapturer { final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); - // using png because its compressed, the native SDKs will decompress it - // and transform to webp or jpeg if needed - // https://github.com/brendan-duncan/image does not have webp encoding - Uint8List? pngBytes = await _getImageBytes(image); - if (pngBytes == null || pngBytes.isEmpty) { + // using rawRgba for the diff check because it is faster than png encoding + Uint8List? imageBytes = + await _getImageBytes(image, format: ui.ImageByteFormat.rawRgba); + if (imageBytes == null || imageBytes.isEmpty) { printIfDebug( 'Error: Failed to convert image byte data to Uint8List.'); recorder.endRecording().dispose(); @@ -146,7 +145,7 @@ class ScreenshotCapturer { return; } - if (const PHListEquality().equals(pngBytes, statusView.imageBytes)) { + if (const PHListEquality().equals(imageBytes, statusView.imageBytes)) { printIfDebug( 'Debug: Snapshot is the same as the last one, nothing changed, do nothing.'); recorder.endRecording().dispose(); @@ -155,7 +154,7 @@ class ScreenshotCapturer { return; } - statusView.imageBytes = pngBytes; + statusView.imageBytes = imageBytes; try { canvas.drawImage(image, Offset.zero, Paint()); From e366903ac1625ee09e40a9e173bdbd382a4af7ca Mon Sep 17 00:00:00 2001 From: Jeremiah Oluwaseun Erinola Date: Thu, 29 Jan 2026 14:24:55 +0100 Subject: [PATCH 2/3] Add tests for screenshot performance and diff validation Introduces benchmark and validation tests for screenshot encoding and comparison. The benchmark test compares PNG and raw RGBA encoding speeds, while the validation test ensures raw RGBA bytes can accurately detect image differences. --- ...benchmark_screenshot_performance_test.dart | 72 +++++++++++++++++++ test/validation_screenshot_diff_test.dart | 62 ++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 test/benchmark_screenshot_performance_test.dart create mode 100644 test/validation_screenshot_diff_test.dart diff --git a/test/benchmark_screenshot_performance_test.dart b/test/benchmark_screenshot_performance_test.dart new file mode 100644 index 00000000..675700f5 --- /dev/null +++ b/test/benchmark_screenshot_performance_test.dart @@ -0,0 +1,72 @@ +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/replay/vendor/equality.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Benchmark: Compare PNG vs Raw RGBA encoding speed', () async { + // Create a 1024x1024 image to simulate a screenshot + final int width = 1024; + final int height = 1024; + final ui.PictureRecorder recorder = ui.PictureRecorder(); + final ui.Canvas canvas = ui.Canvas(recorder); + // Draw something complex to avoid compression triviality? No, a red square is fine. + final ui.Paint paint = ui.Paint()..color = const ui.Color(0xFFFF0000); + canvas.drawRect( + ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), paint); + final ui.Picture picture = recorder.endRecording(); + final ui.Image image = await picture.toImage(width, height); + + print('\n--- Benchmark Results (${width}x$height Image) ---'); + + // 1. Measure PNG Encoding + Compare (Old Method) + final stopwatchPng = Stopwatch()..start(); + // In the old way, we would capture PNG, then possibly compare. + // Assuming the comparison would be on the bytes. + final pngBytes1 = (await image.toByteData(format: ui.ImageByteFormat.png))! + .buffer + .asUint8List(); + // Simulate a second capture + final pngBytes2 = (await image.toByteData(format: ui.ImageByteFormat.png))! + .buffer + .asUint8List(); + + // Compare + bool isPngEqual = const PHListEquality().equals(pngBytes1, pngBytes2); + stopwatchPng.stop(); + print( + 'PNG Encoding (x2) + Compare: ${stopwatchPng.elapsedMicroseconds}µs (Equal: $isPngEqual)'); + + // 2. Measure Raw RGBA Encoding + Compare (New Method) + final stopwatchRaw = Stopwatch()..start(); + final rawBytes1 = + (await image.toByteData(format: ui.ImageByteFormat.rawRgba))! + .buffer + .asUint8List(); + final rawBytes2 = + (await image.toByteData(format: ui.ImageByteFormat.rawRgba))! + .buffer + .asUint8List(); + + // Compare + bool isRawEqual = const PHListEquality().equals(rawBytes1, rawBytes2); + stopwatchRaw.stop(); + print( + 'Raw RGBA Encoding (x2) + Compare: ${stopwatchRaw.elapsedMicroseconds}µs (Equal: $isRawEqual)'); + + final pngMicros = stopwatchPng.elapsedMicroseconds; + final rawMicros = stopwatchRaw.elapsedMicroseconds; + + // Avoid division by zero + final safeRawMicros = rawMicros == 0 ? 1 : rawMicros; + + final improvement = pngMicros / safeRawMicros; + print( + 'Speedup Factor (End-to-End): ${improvement.toStringAsFixed(1)}x faster'); + print('--------------------------------------------------\n'); + + image.dispose(); + }); +} diff --git a/test/validation_screenshot_diff_test.dart b/test/validation_screenshot_diff_test.dart new file mode 100644 index 00000000..d2354ab7 --- /dev/null +++ b/test/validation_screenshot_diff_test.dart @@ -0,0 +1,62 @@ +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Validation: Raw RGBA correctly detects image differences', () async { + // 1. Create Base Image (Red) + final int width = 1024; + final int height = 1024; + final ui.PictureRecorder recorder1 = ui.PictureRecorder(); + final ui.Canvas canvas1 = ui.Canvas(recorder1); + final ui.Paint paint1 = ui.Paint() + ..color = const ui.Color(0xFFFF0000); // Red + canvas1.drawRect( + ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), paint1); + final ui.Image image1 = + await recorder1.endRecording().toImage(width, height); + + // 2. Create Identical Image (Red) + final ui.PictureRecorder recorder2 = ui.PictureRecorder(); + final ui.Canvas canvas2 = ui.Canvas(recorder2); + final ui.Paint paint2 = ui.Paint() + ..color = const ui.Color(0xFFFF0000); // Red + canvas2.drawRect( + ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), paint2); + final ui.Image image2 = + await recorder2.endRecording().toImage(width, height); + + // 3. Create Different Image (Blue) + final ui.PictureRecorder recorder3 = ui.PictureRecorder(); + final ui.Canvas canvas3 = ui.Canvas(recorder3); + final ui.Paint paint3 = ui.Paint() + ..color = const ui.Color(0xFF0000FF); // Blue + canvas3.drawRect( + ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), paint3); + final ui.Image image3 = + await recorder3.endRecording().toImage(width, height); + + // 4. Get Bytes + final bytes1 = await image1.toByteData(format: ui.ImageByteFormat.rawRgba); + final bytes2 = await image2.toByteData(format: ui.ImageByteFormat.rawRgba); + final bytes3 = await image3.toByteData(format: ui.ImageByteFormat.rawRgba); + + // 5. Assertions + // Same images should have identical bytes + expect(bytes1!.buffer.asUint8List(), equals(bytes2!.buffer.asUint8List()), + reason: "Identical images should yield identical Raw RGBA bytes"); + + // Different images should have different bytes + expect(bytes1.buffer.asUint8List(), + isNot(equals(bytes3!.buffer.asUint8List())), + reason: "Different images should yield different Raw RGBA bytes"); + + print('Validation Passed: Raw RGBA allows accurate diffing.'); + + image1.dispose(); + image2.dispose(); + image3.dispose(); + }); +} From 528af12b9773800c8413d0784f3eded06fad3b91 Mon Sep 17 00:00:00 2001 From: Jeremiah Oluwaseun Erinola Date: Mon, 2 Feb 2026 14:12:47 +0100 Subject: [PATCH 3/3] Removed test files and updated changelog --- CHANGELOG.md | 1 + ...benchmark_screenshot_performance_test.dart | 72 ------------------- test/validation_screenshot_diff_test.dart | 62 ---------------- 3 files changed, 1 insertion(+), 134 deletions(-) delete mode 100644 test/benchmark_screenshot_performance_test.dart delete mode 100644 test/validation_screenshot_diff_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9683479a..f037292c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Next +- perf: Optimize Screenshot diff check ([#271](https://github.com/PostHog/posthog-flutter/pull/271)) - feat: add `beforeSend` callback to `PostHogConfig` for dropping or modifying events before they are sent to PostHog ([#255](https://github.com/PostHog/posthog-flutter/pull/255)) - **Limitation**: - Does NOT intercept native-initiated events such as: diff --git a/test/benchmark_screenshot_performance_test.dart b/test/benchmark_screenshot_performance_test.dart deleted file mode 100644 index 675700f5..00000000 --- a/test/benchmark_screenshot_performance_test.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'dart:ui' as ui; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:posthog_flutter/src/replay/vendor/equality.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('Benchmark: Compare PNG vs Raw RGBA encoding speed', () async { - // Create a 1024x1024 image to simulate a screenshot - final int width = 1024; - final int height = 1024; - final ui.PictureRecorder recorder = ui.PictureRecorder(); - final ui.Canvas canvas = ui.Canvas(recorder); - // Draw something complex to avoid compression triviality? No, a red square is fine. - final ui.Paint paint = ui.Paint()..color = const ui.Color(0xFFFF0000); - canvas.drawRect( - ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), paint); - final ui.Picture picture = recorder.endRecording(); - final ui.Image image = await picture.toImage(width, height); - - print('\n--- Benchmark Results (${width}x$height Image) ---'); - - // 1. Measure PNG Encoding + Compare (Old Method) - final stopwatchPng = Stopwatch()..start(); - // In the old way, we would capture PNG, then possibly compare. - // Assuming the comparison would be on the bytes. - final pngBytes1 = (await image.toByteData(format: ui.ImageByteFormat.png))! - .buffer - .asUint8List(); - // Simulate a second capture - final pngBytes2 = (await image.toByteData(format: ui.ImageByteFormat.png))! - .buffer - .asUint8List(); - - // Compare - bool isPngEqual = const PHListEquality().equals(pngBytes1, pngBytes2); - stopwatchPng.stop(); - print( - 'PNG Encoding (x2) + Compare: ${stopwatchPng.elapsedMicroseconds}µs (Equal: $isPngEqual)'); - - // 2. Measure Raw RGBA Encoding + Compare (New Method) - final stopwatchRaw = Stopwatch()..start(); - final rawBytes1 = - (await image.toByteData(format: ui.ImageByteFormat.rawRgba))! - .buffer - .asUint8List(); - final rawBytes2 = - (await image.toByteData(format: ui.ImageByteFormat.rawRgba))! - .buffer - .asUint8List(); - - // Compare - bool isRawEqual = const PHListEquality().equals(rawBytes1, rawBytes2); - stopwatchRaw.stop(); - print( - 'Raw RGBA Encoding (x2) + Compare: ${stopwatchRaw.elapsedMicroseconds}µs (Equal: $isRawEqual)'); - - final pngMicros = stopwatchPng.elapsedMicroseconds; - final rawMicros = stopwatchRaw.elapsedMicroseconds; - - // Avoid division by zero - final safeRawMicros = rawMicros == 0 ? 1 : rawMicros; - - final improvement = pngMicros / safeRawMicros; - print( - 'Speedup Factor (End-to-End): ${improvement.toStringAsFixed(1)}x faster'); - print('--------------------------------------------------\n'); - - image.dispose(); - }); -} diff --git a/test/validation_screenshot_diff_test.dart b/test/validation_screenshot_diff_test.dart deleted file mode 100644 index d2354ab7..00000000 --- a/test/validation_screenshot_diff_test.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:ui' as ui; - -import 'package:flutter_test/flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('Validation: Raw RGBA correctly detects image differences', () async { - // 1. Create Base Image (Red) - final int width = 1024; - final int height = 1024; - final ui.PictureRecorder recorder1 = ui.PictureRecorder(); - final ui.Canvas canvas1 = ui.Canvas(recorder1); - final ui.Paint paint1 = ui.Paint() - ..color = const ui.Color(0xFFFF0000); // Red - canvas1.drawRect( - ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), paint1); - final ui.Image image1 = - await recorder1.endRecording().toImage(width, height); - - // 2. Create Identical Image (Red) - final ui.PictureRecorder recorder2 = ui.PictureRecorder(); - final ui.Canvas canvas2 = ui.Canvas(recorder2); - final ui.Paint paint2 = ui.Paint() - ..color = const ui.Color(0xFFFF0000); // Red - canvas2.drawRect( - ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), paint2); - final ui.Image image2 = - await recorder2.endRecording().toImage(width, height); - - // 3. Create Different Image (Blue) - final ui.PictureRecorder recorder3 = ui.PictureRecorder(); - final ui.Canvas canvas3 = ui.Canvas(recorder3); - final ui.Paint paint3 = ui.Paint() - ..color = const ui.Color(0xFF0000FF); // Blue - canvas3.drawRect( - ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), paint3); - final ui.Image image3 = - await recorder3.endRecording().toImage(width, height); - - // 4. Get Bytes - final bytes1 = await image1.toByteData(format: ui.ImageByteFormat.rawRgba); - final bytes2 = await image2.toByteData(format: ui.ImageByteFormat.rawRgba); - final bytes3 = await image3.toByteData(format: ui.ImageByteFormat.rawRgba); - - // 5. Assertions - // Same images should have identical bytes - expect(bytes1!.buffer.asUint8List(), equals(bytes2!.buffer.asUint8List()), - reason: "Identical images should yield identical Raw RGBA bytes"); - - // Different images should have different bytes - expect(bytes1.buffer.asUint8List(), - isNot(equals(bytes3!.buffer.asUint8List())), - reason: "Different images should yield different Raw RGBA bytes"); - - print('Validation Passed: Raw RGBA allows accurate diffing.'); - - image1.dispose(); - image2.dispose(); - image3.dispose(); - }); -}