From f515c6043b1a123819e18ec1860623cddbe4a6a9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 30 Oct 2026 21:51:27 -0500 Subject: [PATCH 1/2] fix(desktop qr): scale large camera frames down before scanning Camera frames are now scaled to fit 1280 px before scanning, which keeps decoding fast when a camera hands back a large frame. Before the Windows scanner read frames from the preview, a USB webcam there returned 3264x2448 photos even at the medium preset, which took 2-3 s per frame to decode and scan on an older laptop. Windows preview captures now use the same 1280 px cap. Files from Select file are still scanned at full size. --- lib/utilities/qr_image_decoder.dart | 22 ++++++++++++++++++- .../desktop/qr_code_scanner_dialog.dart | 18 ++++++++++----- test/utilities/qr_image_decoder_test.dart | 15 +++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/lib/utilities/qr_image_decoder.dart b/lib/utilities/qr_image_decoder.dart index be063ccdb..308ba893b 100644 --- a/lib/utilities/qr_image_decoder.dart +++ b/lib/utilities/qr_image_decoder.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:typed_data'; import 'package:image/image.dart' as img; @@ -18,7 +19,26 @@ String? decodeQrFromImageBytes(Uint8List bytes) { /// Decodes a QR code from [image], whatever its bit depth, channel count or /// palette. Returns null if no QR code is found. -String? decodeQrFromImage(img.Image image) { +/// +/// If [maxDimension] is set, larger images are scaled down to fit it first. +/// Some webcams hand back full sensor-size photos (3264x2448 from a USB +/// webcam on Windows), which took 2-3 s per frame to scan on an older laptop. +String? decodeQrFromImage(img.Image image, {int? maxDimension}) { + if (maxDimension != null && + math.max(image.width, image.height) > maxDimension) { + image = image.width >= image.height + ? img.copyResize( + image, + width: maxDimension, + interpolation: img.Interpolation.average, + ) + : img.copyResize( + image, + height: maxDimension, + interpolation: img.Interpolation.average, + ); + } + final LuminanceSource source; try { source = RGBLuminanceSource(image.width, image.height, _argbPixels(image)); diff --git a/lib/widgets/desktop/qr_code_scanner_dialog.dart b/lib/widgets/desktop/qr_code_scanner_dialog.dart index 2ff1a2c2a..995fbb628 100644 --- a/lib/widgets/desktop/qr_code_scanner_dialog.dart +++ b/lib/widgets/desktop/qr_code_scanner_dialog.dart @@ -63,9 +63,6 @@ class _QrCodeScannerDialogState extends State { bool _loggedBlankWindowsFrame = false; bool _hasWindowsFrame = false; - // Bigger captures cost more to read back and scan without helping zxing. - static const _maxWindowsFrameDimension = 1280; - @override void initState() { super.initState(); @@ -294,7 +291,7 @@ class _QrCodeScannerDialogState extends State { if (preview != null && preview.width > 0 && preview.height > 0) { final fit = min( 1.0, - _maxWindowsFrameDimension / max(preview.width, preview.height), + _cameraFrameMaxDimension / max(preview.width, preview.height), ); // toImage() rounds each side up, and the laid-out box is rarely exactly // the camera's aspect ratio, so take the smaller ratio and stay a hair @@ -406,7 +403,10 @@ class _QrCodeScannerDialogState extends State { }); } - final String? scanResult = await _scanImage(image); + final String? scanResult = await _scanImage( + image, + maxDimension: _cameraFrameMaxDimension, + ); if (scanResult != null && scanResult.isNotEmpty) { await _stopCamera(); @@ -441,7 +441,13 @@ class _QrCodeScannerDialogState extends State { } } - Future _scanImage(img.Image image) async => decodeQrFromImage(image); + // Camera frames bigger than this are scaled down before scanning, and + // Windows preview captures are taken at no more than this. Files picked + // with "Select file" are scanned at full size. + static const _cameraFrameMaxDimension = 1280; + + Future _scanImage(img.Image image, {int? maxDimension}) async => + decodeQrFromImage(image, maxDimension: maxDimension); Widget _buildCameraView(BuildContext context) { if (Platform.isWindows && _isCameraOpen) { diff --git a/test/utilities/qr_image_decoder_test.dart b/test/utilities/qr_image_decoder_test.dart index 0f38f18e1..dd2ee0917 100644 --- a/test/utilities/qr_image_decoder_test.dart +++ b/test/utilities/qr_image_decoder_test.dart @@ -68,6 +68,21 @@ void main() { expect(decodeQrFromImage(decoded), _address); }); + test("decodes a large camera-sized frame scaled down to maxDimension", () { + // A QR code held up to a 3264x2448 webcam, filling about a third of the + // frame's height. + final frame = img.Image(width: 3264, height: 2448); + img.fill(frame, color: img.ColorRgb8(200, 200, 200)); + final code = _qrImage(_address, scale: 18); + img.compositeImage(frame, code, dstX: 1200, dstY: 700); + final jpeg = img.decodeJpg(img.encodeJpg(frame, quality: 85))!; + expect(decodeQrFromImage(jpeg, maxDimension: 1280), _address); + }); + + test("leaves images within maxDimension alone", () { + expect(decodeQrFromImage(rgb, maxDimension: 1280), _address); + }); + test("decodes a JPEG", () { final bytes = Uint8List.fromList(img.encodeJpg(rgb, quality: 85)); expect(decodeQrFromImageBytes(bytes), _address); From 5fd61ea5d9fc64037cb5a82d76a9e34b685eb730 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Nov 2026 12:50:39 -0600 Subject: [PATCH 2/2] fix(desktop qr): notice when the Windows camera preview freezes camera_windows keeps showing its last frame after the capture engine fails (device unplugged, driver error), and toImage() keeps returning that frame. Every capture then looked like a usable frame, so the frame timeout never fired and the scanner sat on a frozen preview with no message. Fingerprint each capture and treat a byte-for-byte repeat of the last one as no frame, so the existing timeout notices a stalled preview. Live sensor frames never repeat exactly, so this doesn't trip on a steady scene. Also listen to the plugin's camera error and closing events and show the error straight away, keeping it even if a frame still arrives, since the plugin never recovers on its own. Reproduced on Windows with a capture engine error injected into the plugin: 143 identical captures over 36 s with the old rule and no error, the fingerprint rule reports it after 10 s. --- lib/utilities/camera_preview_frame.dart | 17 +++++ .../desktop/qr_code_scanner_dialog.dart | 71 ++++++++++++++++++- test/utilities/camera_preview_frame_test.dart | 38 ++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/lib/utilities/camera_preview_frame.dart b/lib/utilities/camera_preview_frame.dart index bdf83533b..c6c4911a3 100644 --- a/lib/utilities/camera_preview_frame.dart +++ b/lib/utilities/camera_preview_frame.dart @@ -75,3 +75,20 @@ img.Image imageFromMirroredPreviewRgba( order: img.ChannelOrder.rgba, ); } + +/// A 64-bit FNV-1a hash of [rgba], for telling a new camera frame from a +/// repeat of the last one. +/// +/// Frames from a live sensor never repeat byte for byte, so two captures +/// with the same fingerprint mean the preview has stopped updating. This is +/// a full pass over the buffer; an indexed loop keeps it to a few +/// milliseconds for a 1280 px frame. +int previewFrameFingerprint(Uint8List rgba) { + var hash = 0xcbf29ce484222325; + final length = rgba.length; + for (var i = 0; i < length; i++) { + hash ^= rgba[i]; + hash *= 0x100000001b3; + } + return hash; +} diff --git a/lib/widgets/desktop/qr_code_scanner_dialog.dart b/lib/widgets/desktop/qr_code_scanner_dialog.dart index 995fbb628..38a53de05 100644 --- a/lib/widgets/desktop/qr_code_scanner_dialog.dart +++ b/lib/widgets/desktop/qr_code_scanner_dialog.dart @@ -62,6 +62,14 @@ class _QrCodeScannerDialogState extends State { Size? _windowsPreviewSize; bool _loggedBlankWindowsFrame = false; bool _hasWindowsFrame = false; + // camera_windows keeps showing the last frame after the capture engine + // fails, and toImage() keeps returning it. The fingerprint tells a new + // frame from a repeat; the error event tells us the camera is gone at once. + int? _lastWindowsFrameFingerprint; + bool _loggedRepeatedWindowsFrame = false; + bool _windowsCameraLost = false; + StreamSubscription? _windowsErrorSub; + bool _cameraStopped = false; @override void initState() { @@ -171,6 +179,11 @@ class _QrCodeScannerDialogState extends State { await _cameraWindowsPlugin.dispose(_cameraId); return false; } + // The plugin's only closing event comes from our own dispose(), so + // the error event is the one that matters. + _windowsErrorSub = _cameraWindowsPlugin + .onCameraError(_cameraId) + .listen((e) => _onWindowsCameraLost(e.description)); Logging.instance.d( "Windows Camera initialized with ID: $_cameraId, " "preview $_windowsPreviewSize", @@ -228,27 +241,56 @@ class _QrCodeScannerDialogState extends State { return false; } - /// Shows an error once no usable frame has arrived for [_frameTimeout]. + /// Shows an error once no new frame has arrived for [_frameTimeout]. void _checkFrameTimeout() { if (_sinceLastFrame.elapsed >= _frameTimeout && _cameraError == null && mounted) { setState(() { _cameraError = - "The camera is not returning images. You can still " + "The camera preview has stopped updating. You can still " "use \"Select file\" to scan a QR code image."; }); } } + /// The plugin reported a capture engine error. Its preview keeps the last + /// frame and it never restarts the stream, so stop scanning and say so now + /// rather than waiting for the frame timeout. + void _onWindowsCameraLost(String description) { + // The engine can report one failure several times, and an event already + // queued can still arrive after the dialog started closing. + if (_windowsCameraLost || !_isScanning) return; + _windowsCameraLost = true; + _isScanning = false; + Logging.instance.w("Windows camera error: $description"); + // FormatMessage text ends in ".\r\n", and unknown errors come through + // empty. + final detail = description.trim().replaceFirst(RegExp(r"\.$"), ""); + if (!mounted) return; + setState(() { + _cameraError = + "The camera stopped working" + "${detail.isEmpty ? "" : " ($detail)"}. Close and reopen the " + "scanner to try again, or use \"Select file\" to scan a QR code " + "image."; + }); + } + Future _stopCamera() async { _isScanning = false; + // Runs from PopScope and from dispose(); the second call has nothing to + // do. + if (_cameraStopped) return; + _cameraStopped = true; try { if (Platform.isLinux && _cameraLinuxPlugin != null) { _cameraLinuxPlugin!.stopCamera(); Logging.instance.d("Linux Camera stopped"); } else if (Platform.isWindows && _cameraWindowsPlugin != null) { + await _windowsErrorSub?.cancel(); + _windowsErrorSub = null; // if (_cameraId >= 0) { await _cameraWindowsPlugin.dispose(_cameraId); Logging.instance.d("Windows Camera stopped with ID: $_cameraId"); @@ -270,8 +312,16 @@ class _QrCodeScannerDialogState extends State { } } + @override + void dispose() { + // PopScope normally gets here first; this covers a route removed without + // a pop. + unawaited(_stopCamera()); + super.dispose(); + } + /// Grabs the frame the Windows preview is showing, in memory. Returns null - /// when there is nothing to scan yet. + /// when there is nothing to scan yet, or nothing new since the last call. Future _captureWindowsPreviewFrame() async { final boundary = _windowsPreviewKey.currentContext?.findRenderObject(); // Not laid out or painted yet, e.g. just after the camera opened. @@ -333,6 +383,21 @@ class _QrCodeScannerDialogState extends State { } _loggedBlankWindowsFrame = false; + // A repeat of the last capture means the preview has stopped updating, + // so treat it like no frame and let the frame timeout notice. + final fingerprint = previewFrameFingerprint(rgba); + if (fingerprint == _lastWindowsFrameFingerprint) { + if (!_loggedRepeatedWindowsFrame) { + _loggedRepeatedWindowsFrame = true; + Logging.instance.w( + "Windows camera preview frame has not changed, waiting for a new one", + ); + } + return null; + } + _lastWindowsFrameFingerprint = fingerprint; + _loggedRepeatedWindowsFrame = false; + // camera_windows mirrors the preview, so flip it back for zxing. return imageFromMirroredPreviewRgba(rgba, width: width, height: height); } diff --git a/test/utilities/camera_preview_frame_test.dart b/test/utilities/camera_preview_frame_test.dart index 90591ed33..ffc7cadb5 100644 --- a/test/utilities/camera_preview_frame_test.dart +++ b/test/utilities/camera_preview_frame_test.dart @@ -128,4 +128,42 @@ void main() { ); }); }); + + group("previewFrameFingerprint", () { + test("is the same for identical frames", () { + final a = Uint8List.fromList( + List.generate(4 * 64, (i) => (i * 7) & 0xff), + ); + final b = Uint8List.fromList(a); + expect(previewFrameFingerprint(a), previewFrameFingerprint(b)); + }); + + test("changes when one byte changes", () { + final a = Uint8List.fromList( + List.generate(4 * 64, (i) => (i * 7) & 0xff), + ); + final b = Uint8List.fromList(a); + b[b.length ~/ 2] ^= 1; + expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b))); + }); + + test("changes when a pixel is moved", () { + // Same bytes, different order: a plain sum would miss this. + final a = _rgba([ + [1, 2, 3, 255], + [4, 5, 6, 255], + ]); + final b = _rgba([ + [4, 5, 6, 255], + [1, 2, 3, 255], + ]); + expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b))); + }); + + test("distinguishes frames of different lengths", () { + final a = Uint8List(16); + final b = Uint8List(20); + expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b))); + }); + }); }