diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index fe5ec3a9d1d..75998da5579 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,113 +1,114 @@ -## NEXT +## 0.3.6 -* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. +- Supporting camera image stream on web. +- Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. ## 0.3.5 -* Migrates to package:web to support WASM -* Updates minimum supported SDK version to Flutter 3.19/Dart 3.3. +- Migrates to package:web to support WASM +- Updates minimum supported SDK version to Flutter 3.19/Dart 3.3. ## 0.3.4 -* Removes `maxVideoDuration`/`maxDuration`, as the feature was never exposed at +- Removes `maxVideoDuration`/`maxDuration`, as the feature was never exposed at the app-facing package level, and is deprecated at the platform interface level. -* Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. +- Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. ## 0.3.3 -* Adds support to control video FPS and bitrate. See `CameraController.withSettings`. -* Updates minimum supported SDK version to Flutter 3.13/Dart 3.1. +- Adds support to control video FPS and bitrate. See `CameraController.withSettings`. +- Updates minimum supported SDK version to Flutter 3.13/Dart 3.1. ## 0.3.2+4 -* Updates minimum supported SDK version to Flutter 3.10/Dart 3.0. -* Fixes new lint warnings. +- Updates minimum supported SDK version to Flutter 3.10/Dart 3.0. +- Fixes new lint warnings. ## 0.3.2+3 -* Migrates to `dart:ui_web` APIs. -* Updates minimum supported SDK version to Flutter 3.13.0/Dart 3.1.0. +- Migrates to `dart:ui_web` APIs. +- Updates minimum supported SDK version to Flutter 3.13.0/Dart 3.1.0. ## 0.3.2+2 -* Adds pub topics to package metadata. -* Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. +- Adds pub topics to package metadata. +- Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. ## 0.3.2+1 -* Updates README to improve example of `Image` creation. +- Updates README to improve example of `Image` creation. ## 0.3.2 -* Changes `availableCameras` to not ask for the microphone permission. +- Changes `availableCameras` to not ask for the microphone permission. ## 0.3.1+4 -* Removes obsolete null checks on non-nullable values. -* Updates minimum supported SDK version to Flutter 3.3/Dart 2.18. +- Removes obsolete null checks on non-nullable values. +- Updates minimum supported SDK version to Flutter 3.3/Dart 2.18. ## 0.3.1+3 -* Clarifies explanation of endorsement in README. -* Aligns Dart and Flutter SDK constraints. +- Clarifies explanation of endorsement in README. +- Aligns Dart and Flutter SDK constraints. ## 0.3.1+2 -* Updates links for the merge of flutter/plugins into flutter/packages. -* Updates minimum Flutter version to 3.0. +- Updates links for the merge of flutter/plugins into flutter/packages. +- Updates minimum Flutter version to 3.0. ## 0.3.1+1 -* Updates code for stricter lint checks. +- Updates code for stricter lint checks. ## 0.3.1 -* Updates to latest camera platform interface, and fails if user attempts to use streaming with recording (since streaming is currently unsupported on web). +- Updates to latest camera platform interface, and fails if user attempts to use streaming with recording (since streaming is currently unsupported on web). ## 0.3.0+1 -* Updates imports for `prefer_relative_imports`. -* Updates minimum Flutter version to 2.10. -* Fixes avoid_redundant_argument_values lint warnings and minor typos. -* Ignores unnecessary import warnings in preparation for [upcoming Flutter changes](https://github.com/flutter/flutter/pull/106316). +- Updates imports for `prefer_relative_imports`. +- Updates minimum Flutter version to 2.10. +- Fixes avoid_redundant_argument_values lint warnings and minor typos. +- Ignores unnecessary import warnings in preparation for [upcoming Flutter changes](https://github.com/flutter/flutter/pull/106316). ## 0.3.0 -* **BREAKING CHANGE**: Renames error code `cameraPermission` to `CameraAccessDenied` to be consistent with other platforms. +- **BREAKING CHANGE**: Renames error code `cameraPermission` to `CameraAccessDenied` to be consistent with other platforms. ## 0.2.1+6 -* Minor fixes for new analysis options. +- Minor fixes for new analysis options. ## 0.2.1+5 -* Fixes library_private_types_in_public_api, sort_child_properties_last and use_key_in_widget_constructors +- Fixes library_private_types_in_public_api, sort_child_properties_last and use_key_in_widget_constructors lint warnings. ## 0.2.1+4 -* Migrates from `ui.hash*` to `Object.hash*`. -* Updates minimum Flutter version for changes in 0.2.1+3. +- Migrates from `ui.hash*` to `Object.hash*`. +- Updates minimum Flutter version for changes in 0.2.1+3. ## 0.2.1+3 -* Internal code cleanup for stricter analysis options. +- Internal code cleanup for stricter analysis options. ## 0.2.1+2 -* Fixes cameraNotReadable error that prevented access to the camera on some Android devices when initializing a camera. -* Implemented support for new Dart SDKs with an async requestFullscreen API. +- Fixes cameraNotReadable error that prevented access to the camera on some Android devices when initializing a camera. +- Implemented support for new Dart SDKs with an async requestFullscreen API. ## 0.2.1+1 -* Update usage documentation. +- Update usage documentation. ## 0.2.1 -* Add video recording functionality. -* Fix cameraNotReadable error that prevented access to the camera on some Android devices. +- Add video recording functionality. +- Fix cameraNotReadable error that prevented access to the camera on some Android devices. ## 0.2.0 -* Initial release, adapted from the Flutter [I/O Photobooth](https://photobooth.flutter.dev/) project. +- Initial release, adapted from the Flutter [I/O Photobooth](https://photobooth.flutter.dev/) project. diff --git a/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart b/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart index 3e2c9bd40c9..616a5adb286 100644 --- a/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart @@ -106,6 +106,10 @@ void main() { ).thenAnswer( (_) => Future.value(canvasElement.captureStream())); + when( + () => cameraService.hasPropertyOffScreenCanvas(), + ).thenAnswer((_) => true); + final Camera camera = Camera( textureId: cameraId, cameraService: cameraService, diff --git a/packages/camera/camera_web/example/integration_test/camera_service_test.dart b/packages/camera/camera_web/example/integration_test/camera_service_test.dart index 2ed0c54e633..69b7cdfb3a3 100644 --- a/packages/camera/camera_web/example/integration_test/camera_service_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_service_test.dart @@ -4,6 +4,7 @@ // ignore_for_file: only_throw_errors +import 'dart:async'; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; @@ -903,5 +904,69 @@ void main() { ); }); }); + + group('camera image stream', () { + setUp( + () { + cameraService.jsUtil = jsUtil; + }, + ); + testWidgets( + 'returns true if broswer has OffscreenCanvas ' + 'otherwise false', + (WidgetTester widgetTester) async { + when( + () => jsUtil.hasProperty( + window, + 'OffscreenCanvas'.toJS, + ), + ).thenReturn(true); + final bool hasOffScreenCanvas = + cameraService.hasPropertyOffScreenCanvas(); + expect( + hasOffScreenCanvas, + true, + ); + when( + () => jsUtil.hasProperty( + window, + 'OffscreenCanvas'.toJS, + ), + ).thenReturn(false); + final bool hasNotOffScreenCanvas = + cameraService.hasPropertyOffScreenCanvas(); + expect( + hasNotOffScreenCanvas, + false, + ); + }, + ); + testWidgets( + 'returns Camera Image of Size ' + 'when videoElement is of Size', + (WidgetTester widgetTester) async { + const Size size = Size(10, 10); + final Completer completer = Completer(); + final web.VideoElement videoElement = + getVideoElementWithBlankStream(size) + ..onLoadedMetadata.listen((_) { + completer.complete(); + }) + ..load(); + await completer.future; + final CameraImageData cameraImageData = cameraService.takeFrame( + videoElement, + canUseOffscreenCanvas: true, + ); + expect( + size, + Size( + cameraImageData.width.toDouble(), + cameraImageData.height.toDouble(), + ), + ); + }, + ); + }); }); } diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index e953a06b0e8..dc7df7f2de6 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; +import 'dart:typed_data'; import 'dart:ui'; import 'package:async/async.dart'; @@ -61,6 +62,10 @@ void main() { cameraId: any(named: 'cameraId'), ), ).thenAnswer((_) => Future.value(mediaStream)); + + when( + () => cameraService.hasPropertyOffScreenCanvas(), + ).thenAnswer((_) => true); }); setUpAll(() { @@ -1704,5 +1709,56 @@ void main() { }); }); }); + group('cameraFrameStream', () { + testWidgets( + 'bytes is a multiple of 4', + (WidgetTester tester) async { + final VideoElement videoElement = getVideoElementWithBlankStream( + const Size(10, 10), + ); + + final Camera camera = Camera( + textureId: textureId, + cameraService: cameraService, + )..videoElement = videoElement; + + when( + () => cameraService.takeFrame( + videoElement, + canUseOffscreenCanvas: camera.canUseOffscreenCanvas, + ), + ).thenAnswer( + (_) => CameraImageData( + format: const CameraImageFormat( + ImageFormatGroup.jpeg, + raw: '', + ), + planes: [ + CameraImagePlane( + bytes: Uint8List(32), + bytesPerRow: 0, + ), + ], + height: 10, + width: 10, + ), + ); + + final CameraImageData cameraImageData = + await camera.cameraFrameStream().first; + expect( + cameraImageData, + equals( + isA().having( + (CameraImageData e) => e.planes.first.bytes.length % 4, + 'bytes', + equals(0), + ), + ), + ); + }, + timeout: const Timeout(Duration(seconds: 2)), + ); + }); }); } diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index ac31979eb17..cbc015b947a 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -100,6 +100,10 @@ void main() { (_) async => videoElement.captureStream(), ); + when( + () => cameraService.hasPropertyOffScreenCanvas(), + ).thenAnswer((_) => true); + CameraPlatform.instance = CameraPlugin( cameraService: cameraService, )..window = window; diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 124f595fecf..756eb6fa9b7 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -6,9 +6,9 @@ import 'dart:async'; import 'dart:js_interop'; import 'dart:ui'; import 'dart:ui_web' as ui_web; - import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:web/web.dart' as web; import 'package:web/web.dart'; @@ -159,6 +159,10 @@ class Camera { final StreamController videoRecorderController = StreamController.broadcast(); + /// Used to check if allowed to paint canvas off screen + @visibleForTesting + bool canUseOffscreenCanvas = false; + /// Initializes the camera stream displayed in the [videoElement]. /// Registers the camera view with [textureId] under [_getViewType] type. /// Emits the camera default video track on the [onEnded] stream when it ends. @@ -200,6 +204,8 @@ class Camera { onEndedController.add(defaultVideoTrack); }); } + + canUseOffscreenCanvas = _cameraService.hasPropertyOffScreenCanvas(); } /// Starts the camera stream. @@ -638,4 +644,74 @@ class Camera { ..height = '100%' ..objectFit = 'cover'; } + + final StreamController _cameraFrameStreamController = + StreamController.broadcast(); + + // TODO(replace): introduced fps in + /// [CameraImageStreamOptions] + final int cameraStreamFPS = 30; + + /// Returns a stream of camera frames. + /// + /// To stop listening to new animation frames close all listening streams. + Stream cameraFrameStream({ + CameraImageStreamOptions? options, + }) { + _cameraFrameStreamController.onListen = () { + _triggerAnimationFramesLoop( + _addCameraImageDataEvent, + fps: cameraStreamFPS, + ); + }; + + return _cameraFrameStreamController.stream; + } + + /// Triggers animation frames in a loop as long as + /// [_cameraFrameStreamController.hasListener] and executes the callback + void _triggerAnimationFramesLoop( + VoidCallback action, { + required int fps, + }) { + Completer completer = Completer(); + completer.complete(); + + void onAnimate(num _) { + if (!_cameraFrameStreamController.hasListener) { + return; + } + + if (!completer.isCompleted) { + // Schedule the next frame + window.requestAnimationFrame(onAnimate.toJS); + return; + } + + // Perform the action task + action(); + + // Reset the completer and set up a delay + completer = Completer(); + Future.delayed( + Duration(milliseconds: 1000 ~/ fps), + ).then((_) { + completer.complete(); + // Schedule the next frame after the delay + window.requestAnimationFrame(onAnimate.toJS); + }); + } + + // Start the animation loop + window.requestAnimationFrame(onAnimate.toJS); + } + + /// Used to trigger add event of camera image data in camera frame stream + void _addCameraImageDataEvent() { + final CameraImageData image = _cameraService.takeFrame( + videoElement, + canUseOffscreenCanvas: canUseOffscreenCanvas, + ); + _cameraFrameStreamController.add(image); + } } diff --git a/packages/camera/camera_web/lib/src/camera_service.dart b/packages/camera/camera_web/lib/src/camera_service.dart index 072fe06859f..28fd3840858 100644 --- a/packages/camera/camera_web/lib/src/camera_service.dart +++ b/packages/camera/camera_web/lib/src/camera_service.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:js_interop'; +import 'dart:typed_data'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/foundation.dart'; @@ -363,4 +364,78 @@ class CameraService { return DeviceOrientation.portraitUp; } } + + ///Used to check if browser has OffscreenCanvas capability + bool hasPropertyOffScreenCanvas() { + return jsUtil.hasProperty(window, 'OffscreenCanvas'.toJS); + } + + ///Used in [takeFrame] if [canUseOffscreenCanvas] is false + web.CanvasElement? _canvasElement; + + ///Used in [takeFrame] if [canUseOffscreenCanvas] is false + web.OffscreenCanvas? _offscreenCanvas; + + ///Returns frame at a specific time using video element + CameraImageData takeFrame( + web.VideoElement videoElement, { + bool canUseOffscreenCanvas = false, + }) { + final int width = videoElement.videoWidth; + final int height = videoElement.videoHeight; + if (width == 0 || height == 0) { + throw Exception( + 'Computed dimensions are zero: width=$width, height=$height', + ); + } + late web.ImageData imageData; + if (canUseOffscreenCanvas) { + if (_offscreenCanvas == null || + _offscreenCanvas!.width != width || + _offscreenCanvas!.height != height) { + _offscreenCanvas = web.OffscreenCanvas(width, height); + } + final web.OffscreenCanvasRenderingContext2D context = + _offscreenCanvas!.getContext( + '2d', + {'willReadFrequently': true}.toJSBox, + )! as web.OffscreenCanvasRenderingContext2D; + context.drawImage(videoElement, 0, 0); + imageData = context.getImageData(0, 0, width, height); + } else { + if (_canvasElement == null || + _canvasElement!.width != width || + _canvasElement!.height != height) { + _canvasElement = web.CanvasElement() + ..height = height + ..width = width; + } + final web.CanvasRenderingContext2D context = _canvasElement!.context2D; + + context.drawImageScaled( + videoElement, + 0, + 0, + width.toDouble(), + height.toDouble(), + ); + imageData = context.getImageData(0, 0, width, height); + } + final ByteBuffer byteBuffer = imageData.data.toDart.buffer; + + return CameraImageData( + format: const CameraImageFormat( + ImageFormatGroup.jpeg, + raw: '', + ), + planes: [ + CameraImagePlane( + bytes: byteBuffer.asUint8List(), + bytesPerRow: 0, + ), + ], + height: height, + width: width, + ); + } } diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 11d14316971..f8d232a1c0a 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -633,6 +633,21 @@ class CameraPlugin extends CameraPlatform { } } + @override + Stream onStreamedFrameAvailable( + int cameraId, { + CameraImageStreamOptions? options, + }) { + try { + return getCamera(cameraId).cameraFrameStream(options: options); + } on web.DOMException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } + } + @override Future pausePreview(int cameraId) async { try { diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index 4052a701b86..abf5de713f4 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_web description: A Flutter plugin for getting information about and controlling the camera on Web. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.3.5 +version: 0.3.6 environment: sdk: ^3.4.0