diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index 46d7958a8eb..93751515450 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.3.6 +* Adds support for camera image stream on web. * Updates minimum supported SDK version to Flutter 3.35/Dart 3.9. ## 0.3.5+3 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 371f23d341b..5c0017d8eb0 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 @@ -91,6 +91,8 @@ void main() { cameraService.getMediaStreamForOptions(options, cameraId: cameraId), ).thenAnswer((_) async => canvasElement.captureStream()); + when(cameraService.hasPropertyOffScreenCanvas()).thenAnswer((_) => true); + final 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 2805ae06935..e0397f4b9ab 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'; @@ -975,5 +976,55 @@ void main() { ); }); }); + + group('camera image stream', () { + setUp(() { + cameraService = cameraService..jsUtil = jsUtil; + }); + testWidgets('returns true if broswer has OffscreenCanvas ' + 'otherwise false', (WidgetTester widgetTester) async { + for (final supportsOffscreenCanvas in [true, false]) { + when( + jsUtil.hasProperty(window, 'OffscreenCanvas'.toJS), + ).thenReturn(supportsOffscreenCanvas); + final bool hasOffScreenCanvas = cameraService + .hasPropertyOffScreenCanvas(); + expect( + hasOffScreenCanvas, + supportsOffscreenCanvas ? isTrue : isFalse, + ); + } + }); + testWidgets('returns Camera Image of Size ' + 'when videoElement is of Size ' + 'regardless of OffscreenCanvas support', ( + WidgetTester widgetTester, + ) async { + const size = Size(10, 10); + final completer = Completer(); + final web.VideoElement videoElement = + getVideoElementWithBlankStream(size) + ..onLoadedMetadata.listen((_) { + completer.complete(); + }) + ..load(); + await completer.future; + for (final supportsOffscreenCanvas in [true, false]) { + when( + jsUtil.hasProperty(window, 'OffscreenCanvas'.toJS), + ).thenReturn(supportsOffscreenCanvas); + final CameraImageData cameraImageData = cameraService.takeFrame( + videoElement, + ); + 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 29af6ceddbd..da1e41512ac 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,8 @@ void main() { cameraId: anyNamed('cameraId'), ), ).thenAnswer((_) => Future.value(mediaStream)); + + when(cameraService.hasPropertyOffScreenCanvas()).thenAnswer((_) => true); }); group('initialize', () { @@ -1537,5 +1540,59 @@ void main() { }); }); }); + group('cameraFrameStream', () { + testWidgets('Target cameraStreamFPS is 60', (WidgetTester tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + expect(camera.cameraStreamFPS, equals(60)); + }); + testWidgets('CameraImageData bytes is a multiple of 4 ' + 'regardless of OffscreenCanvas support', (WidgetTester tester) async { + final VideoElement videoElement = getVideoElementWithBlankStream( + const Size(10, 10), + ); + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + )..videoElement = videoElement; + + for (final supportsOffscreenCanvas in [true, false]) { + when( + cameraService.hasPropertyOffScreenCanvas(), + ).thenReturn(supportsOffscreenCanvas); + + when(cameraService.takeFrame(videoElement)).thenAnswer( + (_) => CameraImageData( + format: const CameraImageFormat(ImageFormatGroup.unknown, raw: 0), + planes: [ + CameraImagePlane( + bytes: Uint8List(32), + bytesPerRow: videoElement.width * 4, + ), + ], + 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), + ), + ), + ); + } + }); + }); }); } 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 5542bac34ec..46875c06008 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 @@ -95,6 +95,8 @@ void main() { ), ).thenAnswer((_) async => videoElement.captureStream()); + when(cameraService.hasPropertyOffScreenCanvas()).thenAnswer((_) => true); + CameraPlatform.instance = CameraPlugin(cameraService: cameraService) ..window = window; }); @@ -2219,6 +2221,12 @@ void main() { ); }); + testWidgets('supportsImageStreaming returns true', ( + WidgetTester tester, + ) async { + expect(CameraPlatform.instance.supportsImageStreaming(), isTrue); + }); + group('dispose', () { late Camera camera; late MockVideoElement mockVideoElement; diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.mocks.dart index 321507d7717..44959b87e7e 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.mocks.dart @@ -1,14 +1,14 @@ -// Mocks generated by Mockito 5.4.5 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in camera_web_integration_tests/integration_test/helpers/mocks.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; +import 'dart:async' as _i6; import 'dart:js_interop' as _i13; import 'dart:ui' as _i4; import 'package:camera_platform_interface/camera_platform_interface.dart' - as _i7; + as _i5; import 'package:camera_web/src/camera.dart' as _i10; import 'package:camera_web/src/camera_service.dart' as _i8; import 'package:camera_web/src/shims/dart_js_util.dart' as _i2; @@ -16,7 +16,7 @@ import 'package:camera_web/src/types/types.dart' as _i3; import 'package:flutter/services.dart' as _i11; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i12; -import 'package:web/web.dart' as _i6; +import 'package:web/web.dart' as _i7; import 'mocks.dart' as _i9; @@ -33,6 +33,7 @@ import 'mocks.dart' as _i9; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeJsUtil_0 extends _i1.SmartFake implements _i2.JsUtil { _FakeJsUtil_0(Object parent, Invocation parentInvocation) @@ -50,37 +51,43 @@ class _FakeSize_2 extends _i1.SmartFake implements _i4.Size { : super(parent, parentInvocation); } -class _FakeCameraOptions_3 extends _i1.SmartFake implements _i3.CameraOptions { - _FakeCameraOptions_3(Object parent, Invocation parentInvocation) +class _FakeCameraImageData_3 extends _i1.SmartFake + implements _i5.CameraImageData { + _FakeCameraImageData_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeStreamController_4 extends _i1.SmartFake - implements _i5.StreamController { - _FakeStreamController_4(Object parent, Invocation parentInvocation) +class _FakeCameraOptions_4 extends _i1.SmartFake implements _i3.CameraOptions { + _FakeCameraOptions_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeEventStreamProvider_5 extends _i1.SmartFake - implements _i6.EventStreamProvider { - _FakeEventStreamProvider_5(Object parent, Invocation parentInvocation) +class _FakeStreamController_5 extends _i1.SmartFake + implements _i6.StreamController { + _FakeStreamController_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeXFile_6 extends _i1.SmartFake implements _i7.XFile { - _FakeXFile_6(Object parent, Invocation parentInvocation) +class _FakeEventStreamProvider_6 extends _i1.SmartFake + implements _i7.EventStreamProvider { + _FakeEventStreamProvider_6(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeAudioConstraints_7 extends _i1.SmartFake +class _FakeXFile_7 extends _i1.SmartFake implements _i5.XFile { + _FakeXFile_7(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeAudioConstraints_8 extends _i1.SmartFake implements _i3.AudioConstraints { - _FakeAudioConstraints_7(Object parent, Invocation parentInvocation) + _FakeAudioConstraints_8(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeVideoConstraints_8 extends _i1.SmartFake +class _FakeVideoConstraints_9 extends _i1.SmartFake implements _i3.VideoConstraints { - _FakeVideoConstraints_8(Object parent, Invocation parentInvocation) + _FakeVideoConstraints_9(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } @@ -89,19 +96,13 @@ class _FakeVideoConstraints_8 extends _i1.SmartFake /// See the documentation for Mockito's code generation for more information. class MockCameraService extends _i1.Mock implements _i8.CameraService { @override - _i6.Window get window => + _i7.Window get window => (super.noSuchMethod( Invocation.getter(#window), returnValue: _i9.windowShim(), returnValueForMissingStub: _i9.windowShim(), ) - as _i6.Window); - - @override - set window(_i6.Window? _window) => super.noSuchMethod( - Invocation.setter(#window, _window), - returnValueForMissingStub: null, - ); + as _i7.Window); @override _i2.JsUtil get jsUtil => @@ -116,13 +117,19 @@ class MockCameraService extends _i1.Mock implements _i8.CameraService { as _i2.JsUtil); @override - set jsUtil(_i2.JsUtil? _jsUtil) => super.noSuchMethod( - Invocation.setter(#jsUtil, _jsUtil), + set window(_i7.Window? value) => super.noSuchMethod( + Invocation.setter(#window, value), + returnValueForMissingStub: null, + ); + + @override + set jsUtil(_i2.JsUtil? value) => super.noSuchMethod( + Invocation.setter(#jsUtil, value), returnValueForMissingStub: null, ); @override - _i5.Future<_i6.MediaStream> getMediaStreamForOptions( + _i6.Future<_i7.MediaStream> getMediaStreamForOptions( _i3.CameraOptions? options, { int? cameraId = 0, }) => @@ -141,7 +148,7 @@ class MockCameraService extends _i1.Mock implements _i8.CameraService { cameraId: cameraId, ), ) - as _i5.Future<_i6.MediaStream>); + as _i6.Future<_i7.MediaStream>); @override _i3.ZoomLevelCapability getZoomLevelCapabilityForCamera( @@ -161,7 +168,7 @@ class MockCameraService extends _i1.Mock implements _i8.CameraService { as _i3.ZoomLevelCapability); @override - String? getFacingModeForVideoTrack(_i6.MediaStreamTrack? videoTrack) => + String? getFacingModeForVideoTrack(_i7.MediaStreamTrack? videoTrack) => (super.noSuchMethod( Invocation.method(#getFacingModeForVideoTrack, [videoTrack]), returnValueForMissingStub: null, @@ -169,13 +176,13 @@ class MockCameraService extends _i1.Mock implements _i8.CameraService { as String?); @override - _i7.CameraLensDirection mapFacingModeToLensDirection(String? facingMode) => + _i5.CameraLensDirection mapFacingModeToLensDirection(String? facingMode) => (super.noSuchMethod( Invocation.method(#mapFacingModeToLensDirection, [facingMode]), - returnValue: _i7.CameraLensDirection.front, - returnValueForMissingStub: _i7.CameraLensDirection.front, + returnValue: _i5.CameraLensDirection.front, + returnValueForMissingStub: _i5.CameraLensDirection.front, ) - as _i7.CameraLensDirection); + as _i5.CameraLensDirection); @override _i3.CameraType mapFacingModeToCameraType(String? facingMode) => @@ -187,7 +194,7 @@ class MockCameraService extends _i1.Mock implements _i8.CameraService { as _i3.CameraType); @override - _i4.Size mapResolutionPresetToSize(_i7.ResolutionPreset? resolutionPreset) => + _i4.Size mapResolutionPresetToSize(_i5.ResolutionPreset? resolutionPreset) => (super.noSuchMethod( Invocation.method(#mapResolutionPresetToSize, [resolutionPreset]), returnValue: _FakeSize_2( @@ -203,7 +210,7 @@ class MockCameraService extends _i1.Mock implements _i8.CameraService { @override int mapResolutionPresetToVideoBitrate( - _i7.ResolutionPreset? resolutionPreset, + _i5.ResolutionPreset? resolutionPreset, ) => (super.noSuchMethod( Invocation.method(#mapResolutionPresetToVideoBitrate, [ @@ -216,7 +223,7 @@ class MockCameraService extends _i1.Mock implements _i8.CameraService { @override int mapResolutionPresetToAudioBitrate( - _i7.ResolutionPreset? resolutionPreset, + _i5.ResolutionPreset? resolutionPreset, ) => (super.noSuchMethod( Invocation.method(#mapResolutionPresetToAudioBitrate, [ @@ -262,6 +269,30 @@ class MockCameraService extends _i1.Mock implements _i8.CameraService { returnValueForMissingStub: _i11.DeviceOrientation.portraitUp, ) as _i11.DeviceOrientation); + + @override + bool hasPropertyOffScreenCanvas() => + (super.noSuchMethod( + Invocation.method(#hasPropertyOffScreenCanvas, []), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); + + @override + _i5.CameraImageData takeFrame(_i7.HTMLVideoElement? videoElement) => + (super.noSuchMethod( + Invocation.method(#takeFrame, [videoElement]), + returnValue: _FakeCameraImageData_3( + this, + Invocation.method(#takeFrame, [videoElement]), + ), + returnValueForMissingStub: _FakeCameraImageData_3( + this, + Invocation.method(#takeFrame, [videoElement]), + ), + ) + as _i5.CameraImageData); } /// A class which mocks [JsUtil]. @@ -303,11 +334,11 @@ class MockCamera extends _i1.Mock implements _i10.Camera { _i3.CameraOptions get options => (super.noSuchMethod( Invocation.getter(#options), - returnValue: _FakeCameraOptions_3( + returnValue: _FakeCameraOptions_4( this, Invocation.getter(#options), ), - returnValueForMissingStub: _FakeCameraOptions_3( + returnValueForMissingStub: _FakeCameraOptions_4( this, Invocation.getter(#options), ), @@ -324,124 +355,95 @@ class MockCamera extends _i1.Mock implements _i10.Camera { as ({int? audioBitrate, int? videoBitrate})); @override - _i6.HTMLVideoElement get videoElement => + _i7.HTMLVideoElement get videoElement => (super.noSuchMethod( Invocation.getter(#videoElement), returnValue: _i9.videoElementShim(), returnValueForMissingStub: _i9.videoElementShim(), ) - as _i6.HTMLVideoElement); - - @override - set videoElement(_i6.HTMLVideoElement? _videoElement) => super.noSuchMethod( - Invocation.setter(#videoElement, _videoElement), - returnValueForMissingStub: null, - ); + as _i7.HTMLVideoElement); @override - _i6.HTMLDivElement get divElement => + _i7.HTMLDivElement get divElement => (super.noSuchMethod( Invocation.getter(#divElement), returnValue: _i9.divElementShim(), returnValueForMissingStub: _i9.divElementShim(), ) - as _i6.HTMLDivElement); + as _i7.HTMLDivElement); @override - set divElement(_i6.HTMLDivElement? _divElement) => super.noSuchMethod( - Invocation.setter(#divElement, _divElement), - returnValueForMissingStub: null, - ); - - @override - set stream(_i6.MediaStream? _stream) => super.noSuchMethod( - Invocation.setter(#stream, _stream), - returnValueForMissingStub: null, - ); + _i6.Stream<_i7.MediaStreamTrack> get onEnded => + (super.noSuchMethod( + Invocation.getter(#onEnded), + returnValue: _i6.Stream<_i7.MediaStreamTrack>.empty(), + returnValueForMissingStub: _i6.Stream<_i7.MediaStreamTrack>.empty(), + ) + as _i6.Stream<_i7.MediaStreamTrack>); @override - _i5.StreamController<_i6.MediaStreamTrack> get onEndedController => + _i6.StreamController<_i7.MediaStreamTrack> get onEndedController => (super.noSuchMethod( Invocation.getter(#onEndedController), - returnValue: _FakeStreamController_4<_i6.MediaStreamTrack>( + returnValue: _FakeStreamController_5<_i7.MediaStreamTrack>( this, Invocation.getter(#onEndedController), ), returnValueForMissingStub: - _FakeStreamController_4<_i6.MediaStreamTrack>( + _FakeStreamController_5<_i7.MediaStreamTrack>( this, Invocation.getter(#onEndedController), ), ) - as _i5.StreamController<_i6.MediaStreamTrack>); + as _i6.StreamController<_i7.MediaStreamTrack>); + + @override + _i6.Stream<_i7.ErrorEvent> get onVideoRecordingError => + (super.noSuchMethod( + Invocation.getter(#onVideoRecordingError), + returnValue: _i6.Stream<_i7.ErrorEvent>.empty(), + returnValueForMissingStub: _i6.Stream<_i7.ErrorEvent>.empty(), + ) + as _i6.Stream<_i7.ErrorEvent>); @override - _i6.EventStreamProvider<_i6.Event> get mediaRecorderOnErrorProvider => + _i7.EventStreamProvider<_i7.Event> get mediaRecorderOnErrorProvider => (super.noSuchMethod( Invocation.getter(#mediaRecorderOnErrorProvider), - returnValue: _FakeEventStreamProvider_5<_i6.Event>( + returnValue: _FakeEventStreamProvider_6<_i7.Event>( this, Invocation.getter(#mediaRecorderOnErrorProvider), ), - returnValueForMissingStub: _FakeEventStreamProvider_5<_i6.Event>( + returnValueForMissingStub: _FakeEventStreamProvider_6<_i7.Event>( this, Invocation.getter(#mediaRecorderOnErrorProvider), ), ) - as _i6.EventStreamProvider<_i6.Event>); - - @override - set mediaRecorderOnErrorProvider( - _i6.EventStreamProvider<_i6.Event>? _mediaRecorderOnErrorProvider, - ) => super.noSuchMethod( - Invocation.setter( - #mediaRecorderOnErrorProvider, - _mediaRecorderOnErrorProvider, - ), - returnValueForMissingStub: null, - ); + as _i7.EventStreamProvider<_i7.Event>); @override - _i5.StreamController<_i6.ErrorEvent> get videoRecordingErrorController => + _i6.StreamController<_i7.ErrorEvent> get videoRecordingErrorController => (super.noSuchMethod( Invocation.getter(#videoRecordingErrorController), - returnValue: _FakeStreamController_4<_i6.ErrorEvent>( + returnValue: _FakeStreamController_5<_i7.ErrorEvent>( this, Invocation.getter(#videoRecordingErrorController), ), - returnValueForMissingStub: _FakeStreamController_4<_i6.ErrorEvent>( + returnValueForMissingStub: _FakeStreamController_5<_i7.ErrorEvent>( this, Invocation.getter(#videoRecordingErrorController), ), ) - as _i5.StreamController<_i6.ErrorEvent>); + as _i6.StreamController<_i7.ErrorEvent>); @override - set flashMode(_i7.FlashMode? _flashMode) => super.noSuchMethod( - Invocation.setter(#flashMode, _flashMode), - returnValueForMissingStub: null, - ); - - @override - _i6.Window get window => + _i7.Window get window => (super.noSuchMethod( Invocation.getter(#window), returnValue: _i9.windowShim(), returnValueForMissingStub: _i9.windowShim(), ) - as _i6.Window); - - @override - set window(_i6.Window? _window) => super.noSuchMethod( - Invocation.setter(#window, _window), - returnValueForMissingStub: null, - ); - - @override - set mediaRecorder(_i6.MediaRecorder? _mediaRecorder) => super.noSuchMethod( - Invocation.setter(#mediaRecorder, _mediaRecorder), - returnValueForMissingStub: null, - ); + as _i7.Window); @override bool Function(String) get isVideoTypeSupported => @@ -453,89 +455,131 @@ class MockCamera extends _i1.Mock implements _i10.Camera { as bool Function(String)); @override - set isVideoTypeSupported(bool Function(String)? _isVideoTypeSupported) => - super.noSuchMethod( - Invocation.setter(#isVideoTypeSupported, _isVideoTypeSupported), - returnValueForMissingStub: null, - ); - - @override - _i6.Blob Function(List<_i6.Blob>, String) get blobBuilder => + _i7.Blob Function(List<_i7.Blob>, String) get blobBuilder => (super.noSuchMethod( Invocation.getter(#blobBuilder), returnValue: _i9.blobBuilderShim(), returnValueForMissingStub: _i9.blobBuilderShim(), ) - as _i6.Blob Function(List<_i6.Blob>, String)); + as _i7.Blob Function(List<_i7.Blob>, String)); @override - set blobBuilder(_i6.Blob Function(List<_i6.Blob>, String)? _blobBuilder) => - super.noSuchMethod( - Invocation.setter(#blobBuilder, _blobBuilder), - returnValueForMissingStub: null, - ); + _i6.Stream<_i5.VideoRecordedEvent> get onVideoRecordedEvent => + (super.noSuchMethod( + Invocation.getter(#onVideoRecordedEvent), + returnValue: _i6.Stream<_i5.VideoRecordedEvent>.empty(), + returnValueForMissingStub: + _i6.Stream<_i5.VideoRecordedEvent>.empty(), + ) + as _i6.Stream<_i5.VideoRecordedEvent>); @override - _i5.StreamController<_i7.VideoRecordedEvent> get videoRecorderController => + _i6.StreamController<_i5.VideoRecordedEvent> get videoRecorderController => (super.noSuchMethod( Invocation.getter(#videoRecorderController), - returnValue: _FakeStreamController_4<_i7.VideoRecordedEvent>( + returnValue: _FakeStreamController_5<_i5.VideoRecordedEvent>( this, Invocation.getter(#videoRecorderController), ), returnValueForMissingStub: - _FakeStreamController_4<_i7.VideoRecordedEvent>( + _FakeStreamController_5<_i5.VideoRecordedEvent>( this, Invocation.getter(#videoRecorderController), ), ) - as _i5.StreamController<_i7.VideoRecordedEvent>); + as _i6.StreamController<_i5.VideoRecordedEvent>); @override - _i5.Stream<_i6.MediaStreamTrack> get onEnded => + bool get canUseOffscreenCanvas => (super.noSuchMethod( - Invocation.getter(#onEnded), - returnValue: _i5.Stream<_i6.MediaStreamTrack>.empty(), - returnValueForMissingStub: _i5.Stream<_i6.MediaStreamTrack>.empty(), + Invocation.getter(#canUseOffscreenCanvas), + returnValue: false, + returnValueForMissingStub: false, ) - as _i5.Stream<_i6.MediaStreamTrack>); + as bool); @override - _i5.Stream<_i6.ErrorEvent> get onVideoRecordingError => + int get cameraStreamFPS => (super.noSuchMethod( - Invocation.getter(#onVideoRecordingError), - returnValue: _i5.Stream<_i6.ErrorEvent>.empty(), - returnValueForMissingStub: _i5.Stream<_i6.ErrorEvent>.empty(), + Invocation.getter(#cameraStreamFPS), + returnValue: 0, + returnValueForMissingStub: 0, ) - as _i5.Stream<_i6.ErrorEvent>); + as int); @override - _i5.Stream<_i7.VideoRecordedEvent> get onVideoRecordedEvent => - (super.noSuchMethod( - Invocation.getter(#onVideoRecordedEvent), - returnValue: _i5.Stream<_i7.VideoRecordedEvent>.empty(), - returnValueForMissingStub: - _i5.Stream<_i7.VideoRecordedEvent>.empty(), - ) - as _i5.Stream<_i7.VideoRecordedEvent>); + set videoElement(_i7.HTMLVideoElement? value) => super.noSuchMethod( + Invocation.setter(#videoElement, value), + returnValueForMissingStub: null, + ); + + @override + set divElement(_i7.HTMLDivElement? value) => super.noSuchMethod( + Invocation.setter(#divElement, value), + returnValueForMissingStub: null, + ); + + @override + set stream(_i7.MediaStream? value) => super.noSuchMethod( + Invocation.setter(#stream, value), + returnValueForMissingStub: null, + ); @override - _i5.Future initialize() => + set mediaRecorderOnErrorProvider(_i7.EventStreamProvider<_i7.Event>? value) => + super.noSuchMethod( + Invocation.setter(#mediaRecorderOnErrorProvider, value), + returnValueForMissingStub: null, + ); + + @override + set flashMode(_i5.FlashMode? value) => super.noSuchMethod( + Invocation.setter(#flashMode, value), + returnValueForMissingStub: null, + ); + + @override + set window(_i7.Window? value) => super.noSuchMethod( + Invocation.setter(#window, value), + returnValueForMissingStub: null, + ); + + @override + set mediaRecorder(_i7.MediaRecorder? value) => super.noSuchMethod( + Invocation.setter(#mediaRecorder, value), + returnValueForMissingStub: null, + ); + + @override + set isVideoTypeSupported(bool Function(String)? value) => super.noSuchMethod( + Invocation.setter(#isVideoTypeSupported, value), + returnValueForMissingStub: null, + ); + + @override + set blobBuilder(_i7.Blob Function(List<_i7.Blob>, String)? value) => + super.noSuchMethod( + Invocation.setter(#blobBuilder, value), + returnValueForMissingStub: null, + ); + + @override + _i6.Future initialize() => (super.noSuchMethod( Invocation.method(#initialize, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override - _i5.Future play() => + _i6.Future play() => (super.noSuchMethod( Invocation.method(#play, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override void pause() => super.noSuchMethod( @@ -550,17 +594,17 @@ class MockCamera extends _i1.Mock implements _i10.Camera { ); @override - _i5.Future<_i7.XFile> takePicture() => + _i6.Future<_i5.XFile> takePicture() => (super.noSuchMethod( Invocation.method(#takePicture, []), - returnValue: _i5.Future<_i7.XFile>.value( - _FakeXFile_6(this, Invocation.method(#takePicture, [])), + returnValue: _i6.Future<_i5.XFile>.value( + _FakeXFile_7(this, Invocation.method(#takePicture, [])), ), - returnValueForMissingStub: _i5.Future<_i7.XFile>.value( - _FakeXFile_6(this, Invocation.method(#takePicture, [])), + returnValueForMissingStub: _i6.Future<_i5.XFile>.value( + _FakeXFile_7(this, Invocation.method(#takePicture, [])), ), ) - as _i5.Future<_i7.XFile>); + as _i6.Future<_i5.XFile>); @override _i4.Size getVideoSize() => @@ -578,7 +622,7 @@ class MockCamera extends _i1.Mock implements _i10.Camera { as _i4.Size); @override - void setFlashMode(_i7.FlashMode? mode) => super.noSuchMethod( + void setFlashMode(_i5.FlashMode? mode) => super.noSuchMethod( Invocation.method(#setFlashMode, [mode]), returnValueForMissingStub: null, ); @@ -623,53 +667,64 @@ class MockCamera extends _i1.Mock implements _i10.Camera { as String); @override - _i5.Future startVideoRecording() => + _i6.Future startVideoRecording() => (super.noSuchMethod( Invocation.method(#startVideoRecording, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override - _i5.Future pauseVideoRecording() => + _i6.Future pauseVideoRecording() => (super.noSuchMethod( Invocation.method(#pauseVideoRecording, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override - _i5.Future resumeVideoRecording() => + _i6.Future resumeVideoRecording() => (super.noSuchMethod( Invocation.method(#resumeVideoRecording, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override - _i5.Future<_i7.XFile> stopVideoRecording() => + _i6.Future<_i5.XFile> stopVideoRecording() => (super.noSuchMethod( Invocation.method(#stopVideoRecording, []), - returnValue: _i5.Future<_i7.XFile>.value( - _FakeXFile_6(this, Invocation.method(#stopVideoRecording, [])), + returnValue: _i6.Future<_i5.XFile>.value( + _FakeXFile_7(this, Invocation.method(#stopVideoRecording, [])), ), - returnValueForMissingStub: _i5.Future<_i7.XFile>.value( - _FakeXFile_6(this, Invocation.method(#stopVideoRecording, [])), + returnValueForMissingStub: _i6.Future<_i5.XFile>.value( + _FakeXFile_7(this, Invocation.method(#stopVideoRecording, [])), ), ) - as _i5.Future<_i7.XFile>); + as _i6.Future<_i5.XFile>); @override - _i5.Future dispose() => + _i6.Future dispose() => (super.noSuchMethod( Invocation.method(#dispose, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + _i6.Stream<_i5.CameraImageData> cameraFrameStream({ + _i5.CameraImageStreamOptions? options, + }) => + (super.noSuchMethod( + Invocation.method(#cameraFrameStream, [], {#options: options}), + returnValue: _i6.Stream<_i5.CameraImageData>.empty(), + returnValueForMissingStub: _i6.Stream<_i5.CameraImageData>.empty(), ) - as _i5.Future); + as _i6.Stream<_i5.CameraImageData>); } /// A class which mocks [CameraOptions]. @@ -681,11 +736,11 @@ class MockCameraOptions extends _i1.Mock implements _i3.CameraOptions { _i3.AudioConstraints get audio => (super.noSuchMethod( Invocation.getter(#audio), - returnValue: _FakeAudioConstraints_7( + returnValue: _FakeAudioConstraints_8( this, Invocation.getter(#audio), ), - returnValueForMissingStub: _FakeAudioConstraints_7( + returnValueForMissingStub: _FakeAudioConstraints_8( this, Invocation.getter(#audio), ), @@ -696,11 +751,11 @@ class MockCameraOptions extends _i1.Mock implements _i3.CameraOptions { _i3.VideoConstraints get video => (super.noSuchMethod( Invocation.getter(#video), - returnValue: _FakeVideoConstraints_8( + returnValue: _FakeVideoConstraints_9( this, Invocation.getter(#video), ), - returnValueForMissingStub: _FakeVideoConstraints_8( + returnValueForMissingStub: _FakeVideoConstraints_9( this, Invocation.getter(#video), ), @@ -708,11 +763,11 @@ class MockCameraOptions extends _i1.Mock implements _i3.CameraOptions { as _i3.VideoConstraints); @override - _i6.MediaStreamConstraints toMediaStreamConstraints() => + _i7.MediaStreamConstraints toMediaStreamConstraints() => (super.noSuchMethod( Invocation.method(#toMediaStreamConstraints, []), returnValue: _i9.toMediaStreamConstraintsShim(), returnValueForMissingStub: _i9.toMediaStreamConstraintsShim(), ) - as _i6.MediaStreamConstraints); + as _i7.MediaStreamConstraints); } diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index dd773d380c7..cf17e34bf0f 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -48,8 +48,11 @@ class Camera { required this.textureId, required CameraService cameraService, this.options = const CameraOptions(), - this.recorderOptions = const (audioBitrate: null, videoBitrate: null), - }) : _cameraService = cameraService; + ({int? audioBitrate, int? videoBitrate})? recorderOptions, + }) : recorderOptions = + recorderOptions ?? (audioBitrate: null, videoBitrate: null), + _cameraService = cameraService, + canUseOffscreenCanvas = cameraService.hasPropertyOffScreenCanvas(); /// The texture id used to register the camera view. final int textureId; @@ -159,6 +162,13 @@ class Camera { final StreamController videoRecorderController = StreamController.broadcast(); + /// Used to check if allowed to paint canvas off screen + @visibleForTesting + final bool canUseOffscreenCanvas; + + /// The tolerance for the camera streaming frame time. + int get _frameTimeToleranceMs => 1000 / cameraStreamFPS ~/ 2; + /// 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. @@ -642,4 +652,74 @@ class Camera { ..height = '100%' ..objectFit = 'cover'; } + + final StreamController _cameraFrameStreamController = + StreamController.broadcast(); + + // TODO(TecHaxter): Introduce FPS in CameraImageStreamOptions of + // package:camera_platform_interface. + // https://github.com/flutter/flutter/issues/176148 + /// The target FPS for the camera frame stream. + /// + /// Frames are emitted within a tolerance window, so actual delivery may + /// slightly exceed or fall below this target depending on browser timing. + @visibleForTesting + final int cameraStreamFPS = 60; + + /// 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 at a specified FPS + /// as long as [animationFrameId] is not cancelled + void _triggerAnimationFramesLoop(VoidCallback action, {required int fps}) { + int? animationFrameId; + final num fpsInterval = 1000 / fps; + num lastFrameTimestamp = 0; + + int? animate(num timestamp) { + // Schedule the next frame + animationFrameId = window.requestAnimationFrame(animate.toJS); + // Calculate the elapsed time since the last frame + final num elapsed = timestamp - lastFrameTimestamp; + + // If we're close to the next frame (~`_frameTimeToleranceMs`), do it. + if (fpsInterval - elapsed <= _frameTimeToleranceMs) { + // Get ready for next frame + lastFrameTimestamp = timestamp; + // Perform the action task + action(); + } + return animationFrameId; + } + + // Initialize the animation loop + animationFrameId = animate(window.performance.now()); + + // Listen for the stream controller cancellation to stop the animation + _cameraFrameStreamController.onCancel = () { + if (animationFrameId != null) { + window.cancelAnimationFrame(animationFrameId!); + animationFrameId = null; + } + }; + } + + /// Used to trigger add event of camera image data in camera frame stream + void _addCameraImageDataEvent() { + final CameraImageData image = _cameraService.takeFrame(videoElement); + _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 9bf588a456d..bcc49ae1e76 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'; @@ -361,4 +362,117 @@ class CameraService { return DeviceOrientation.portraitUp; } } + + /// Used to check if browser has OffscreenCanvas capability + bool hasPropertyOffScreenCanvas() { + return jsUtil.hasProperty(window, 'OffscreenCanvas'.toJS); + } + + /// Returns frame at a specific time using video element + CameraImageData takeFrame(web.VideoElement videoElement) { + 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', + ); + } + final imageDataSettings = WebTweakImageDataSettings(format: 'rgba-unorm8'); + final web.ImageData imageData; + if (hasPropertyOffScreenCanvas()) { + imageData = _takeOffscreenCanvasFrame( + videoElement, + width: width, + height: height, + settings: imageDataSettings, + ); + } else { + imageData = _takeFallbackCanvasFrame( + videoElement, + width: width, + height: height, + settings: imageDataSettings, + ); + } + final ByteBuffer byteBuffer = imageData.data.toDart.buffer; + + return CameraImageData( + format: const CameraImageFormat( + // TODO(TecHaxter): Introduce ImageFormatGroup.rgba8888 in + // package:camera_platform_interface. + // https://github.com/flutter/flutter/issues/151193 + ImageFormatGroup.unknown, + raw: 'rgba8888', + ), + planes: [ + CameraImagePlane( + bytes: byteBuffer.asUint8List(), + bytesPerRow: width * 4, + ), + ], + height: height, + width: width, + ); + } + + /// Used by [_takeOffscreenCanvasFrame] to cache the offscreen canvas + web.OffscreenCanvas? _offscreenCanvas; + + /// Used by [_takeOffscreenCanvasFrame] to cache the offscreen canvas context + web.OffscreenCanvasRenderingContext2D? _offscreenCanvasContext; + + /// Takes a video frame using `OffscreenCanvas` for better performance + web.ImageData _takeOffscreenCanvasFrame( + web.VideoElement videoElement, { + required int width, + required int height, + required WebTweakImageDataSettings settings, + }) { + _offscreenCanvas ??= web.OffscreenCanvas(width, height); + if (_offscreenCanvas!.width != width || + _offscreenCanvas!.height != height) { + _offscreenCanvas! + ..width = width + ..height = height; + } + _offscreenCanvasContext ??= + _offscreenCanvas!.getContext( + '2d', + {'willReadFrequently': true}.jsify(), + )! + as web.OffscreenCanvasRenderingContext2D; + + _offscreenCanvasContext!.drawImage(videoElement, 0, 0); + return _offscreenCanvasContext!.getImageData(0, 0, width, height, settings); + } + + /// Used by [_takeFallbackCanvasFrame] to cache the canvas element + web.CanvasElement? _canvasElement; + + /// Takes a video frame using a regular `CanvasElement` + web.ImageData _takeFallbackCanvasFrame( + web.VideoElement videoElement, { + required int width, + required int height, + required WebTweakImageDataSettings settings, + }) { + _canvasElement ??= web.CanvasElement() + ..height = height + ..width = width; + if (_canvasElement!.width != width || _canvasElement!.height != height) { + _canvasElement! + ..width = width + ..height = height; + } + final web.CanvasRenderingContext2D context = _canvasElement!.context2D; + + context.drawImageScaled( + videoElement, + 0, + 0, + width.toDouble(), + height.toDouble(), + ); + return context.getImageData(0, 0, width, height, settings); + } } diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 90033681d28..f346a6fb95b 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -629,6 +629,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 { @@ -711,4 +726,7 @@ class CameraPlugin extends CameraPlatform { ), ); } + + @override + bool supportsImageStreaming() => true; } diff --git a/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart b/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart index f5d54d7dfdf..31098db3594 100644 --- a/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart +++ b/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart @@ -72,3 +72,18 @@ extension type WebTweakMediaTrackConstraints._(JSObject _) implements JSObject { ConstrainBoolean torch, }); } + +extension type WebTweakImageDataSettings._(JSObject _) + implements ImageDataSettings { + external factory WebTweakImageDataSettings({ + PredefinedColorSpace colorSpace, + WebTweakPredefinedFormat? format, + }); + + external PredefinedColorSpace get colorSpace; + external set colorSpace(PredefinedColorSpace value); + external WebTweakPredefinedFormat? get format; + external set format(WebTweakPredefinedFormat? value); +} + +typedef WebTweakPredefinedFormat = String; diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index 4b360375638..e0807d39a80 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+3 +version: 0.3.6 environment: sdk: ^3.9.0 @@ -17,7 +17,7 @@ flutter: fileName: camera_web.dart dependencies: - camera_platform_interface: ^2.6.0 + camera_platform_interface: ^2.12.0 flutter: sdk: flutter flutter_web_plugins: