Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

[camera_web] Recording Video #4210

Merged
merged 32 commits into from
Sep 16, 2021
Merged

Conversation

ABausG
Copy link
Contributor

@ABausG ABausG commented Jul 31, 2021

Adds ability to record webm of the camera platform interface.

Implements calls startVideoRecording, pauseVideoRecording, resumeVideoRecording and stopVideoRecording

Part of flutter/flutter#45297.

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [relevant style guides] and ran [the auto-formatter]. (Note that unlike the flutter/flutter repo, the flutter/plugins repo does use dart format.)
  • I signed the [CLA].
  • The title of the PR starts with the name of the plugin surrounded by square brackets, e.g. [shared_preferences]
  • I listed at least one issue that this PR fixes in the description above.
  • I updated pubspec.yaml with an appropriate new version according to the [pub versioning philosophy].
  • I updated CHANGELOG.md to add a description of the change.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making or feature I am adding, or Hixie said the PR is test exempt.
  • All existing and new tests are passing.

@ABausG
Copy link
Contributor Author

ABausG commented Jul 31, 2021

@bselwe @felangel @ditman I saw that you did some work on camera_web recently. Could you take a look at this?

@ABausG ABausG changed the title [camera_web] Recording [camera_web] Recording Video Jul 31, 2021
Copy link
Member

@ditman ditman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ABausG for the contribution, this is great!

I've left some questions about the current approach (mostly about cross-browser compat).

BTW, @bselwe is currently OOTO (until the end of the week), but he was working in normalizing the exceptions thrown from the code.

In this PR, for example, we're manually throwing DomExceptions from the low-level Camera object, but I think @bselwe was working on getting some standardization in what types of exceptions to throw from where. The guiding lines here would be the core camera plugin, seeing what types of Exceptions are expected there, so everything works transparently.

I'll defer to @bselwe for more comments in that regard!

Other than that, this is looking amazing! Thanks again for the contribution!

/// /// Throws a [html.DomException.INVALID_STATE] if there already is an active Recording
Future<void> startVideoRecording({Duration? maxVideoDuration}) async {
if (_mediaRecorder != null && _mediaRecorder!.state != 'inactive') {
throw html.DomException.INVALID_STATE;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(See my general comment about the exception style within the plugin. @bselwe was overhauling this, and I don't think we'd want to throw a raw DomException, even from the low-level Camera object.

Copy link
Contributor

@bselwe bselwe Aug 5, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, AFAIK, it is not possible to create an instance of html.DomException (the class has only a private factory). It also seems that html.DomException.INVALID_STATE is a constant string rather than an exception so we would not be able to provide both code and description for the error.

I think the general rule would be to follow MethodChannelCamera (an implementation of the camera platform used for iOS and Android platforms) and CameraController:

  • Video recording methods in MethodChannelCamera invoke methods on the method channel - this may throw a PlatformException. For consistency, any error that is related to an invalid state/execution of the camera during video recording should throw a PlatformException as well.
  • We should rather avoid throwing a CameraException in internal classes (not exposed to the end user). If we throw platform exceptions, they are usually caught and mapped to camera exceptions in the CameraController.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing a PlatformException now and not checking fo the state of the mediaRecorder`

if (maxVideoDuration != null) {
_mediaRecorder!.addEventListener('dataavailable', (event) {
final blob = (event as html.BlobEvent).data;
final file = XFile(html.Url.createObjectUrl(blob));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file needs some extra data, like name and mimeType to be initialized at this point. This will help later when the user wants to save it to disk.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added mimeType and Filename. For now using the hashcode of the blob as filename

/// Pauses the current video Recording
/// Throws a [html.DomException.INVALID_STATE] if there is no active Recording
Future<void> pauseVideoRecording() async {
if (_mediaRecorder == null || _mediaRecorder!.state == 'inactive') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the docs, the _mediaRecorder state check is performed by the browser?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be probably enough to throw a PlatformException with an appropriate code and description if _mediaRecorder is null. Any exception thrown by MediaRecorder.pause would then be caught in the camera platform here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above. Now throwing PlatformExceptions

Comment on lines 233 to 238
final availableData = Completer<XFile>();
_mediaRecorder!.addEventListener('dataavailable', (event) {
final blob = (event as html.BlobEvent).data;
availableData.complete(XFile(html.Url.createObjectUrl(blob)));
});
_mediaRecorder?.stop();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be some code duplication here vs startVideoRecording (with a max length).

Could you unify these two codepaths so the implementation of thedataavailable event on startVideoRecording uses the same logic as "stop"?

I also wonder why the start with max length emits a VideoRecordedEvent, but stopVideoRecording doesn't?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unified it by registering the listener on dataavailable only in the startVideoRecording.

If the listener is triggered it will do the following things:

  • emit a VideoRecordedEvent
  • Complete the Completer used to obtain the XFile in stopVideoRecording
  • remove the listener
  • stop the MediaRecorder
    • This stop is necessary to only get data once if a maxVideoDuration is provided as MediaRecorder only takes in splices instead of a maxDuration

.add(VideoRecordedEvent(this.textureId, file, maxVideoDuration));
_mediaRecorder!.stop();
});
_mediaRecorder!.start(maxVideoDuration.inMilliseconds);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if maxVideoDuration is 0 milliseconds? Should that be disallowed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could throw a DomException with NotSupportedError that would be caught in the camera platform here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing a PlatformException

Comment on lines 221 to 223
if (_mediaRecorder == null || _mediaRecorder!.state == 'inactive') {
throw html.DomException.INVALID_STATE;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(These exceptions are already thrown by the browser, no need to assert for _mediaRecorder.state)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, same as above. It would be probably enough to throw a PlatformException with an appropriate code and description if _mediaRecorder is null. Any exception thrown by MediaRecorder.resume would then be caught in the camera platform here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

Copy link
Contributor

@bselwe bselwe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thanks for the contribution @ABausG and your comments @ditman! 🙌

I've added some clarification regarding which exceptions to throw and when. The general rule would be to try to match the exceptions thrown in MethodChannelCamera (an implementation of the camera platform used for iOS and Android platforms) and handled in CameraController. Let me know if there's anything unclear about this approach.

I think it would also be nice to include Camera tests. That could include:

  • For startVideoRecording:
    • Throws an exception when the media recorder is in an invalid state.
    • Initializes a media recorder instance from the video element stream.
    • Uses an appropriate mime type for the video recording based on MediaRecorder.isTypeSupported().
    • Uses maxVideoDuration to record a video.
    • Emits a VideoRecordedEvent when the video recording has been stopped.
  • For pauseVideoRecording:
    • Throws an exception when the media recorder is null.
    • Calls pause on the media recorder.
  • And similar tests for other methods.

Thanks again!

final StreamController<VideoRecordedEvent> _videoRecorderController =
StreamController();

/// Returns a Stream that emits when a video Recodring with a defined maxVideoDuration was created
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Returns a Stream that emits when a video Recodring with a defined maxVideoDuration was created
/// Returns a Stream that emits when a video recording with a defined maxVideoDuration was created.

/// /// Throws a [html.DomException.INVALID_STATE] if there already is an active Recording
Future<void> startVideoRecording({Duration? maxVideoDuration}) async {
if (_mediaRecorder != null && _mediaRecorder!.state != 'inactive') {
throw html.DomException.INVALID_STATE;
Copy link
Contributor

@bselwe bselwe Aug 5, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, AFAIK, it is not possible to create an instance of html.DomException (the class has only a private factory). It also seems that html.DomException.INVALID_STATE is a constant string rather than an exception so we would not be able to provide both code and description for the error.

I think the general rule would be to follow MethodChannelCamera (an implementation of the camera platform used for iOS and Android platforms) and CameraController:

  • Video recording methods in MethodChannelCamera invoke methods on the method channel - this may throw a PlatformException. For consistency, any error that is related to an invalid state/execution of the camera during video recording should throw a PlatformException as well.
  • We should rather avoid throwing a CameraException in internal classes (not exposed to the end user). If we throw platform exceptions, they are usually caught and mapped to camera exceptions in the CameraController.

Comment on lines 221 to 223
if (_mediaRecorder == null || _mediaRecorder!.state == 'inactive') {
throw html.DomException.INVALID_STATE;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, same as above. It would be probably enough to throw a PlatformException with an appropriate code and description if _mediaRecorder is null. Any exception thrown by MediaRecorder.resume would then be caught in the camera platform here.

/// Pauses the current video Recording
/// Throws a [html.DomException.INVALID_STATE] if there is no active Recording
Future<void> pauseVideoRecording() async {
if (_mediaRecorder == null || _mediaRecorder!.state == 'inactive') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be probably enough to throw a PlatformException with an appropriate code and description if _mediaRecorder is null. Any exception thrown by MediaRecorder.pause would then be caught in the camera platform here.

.add(VideoRecordedEvent(this.textureId, file, maxVideoDuration));
_mediaRecorder!.stop();
});
_mediaRecorder!.start(maxVideoDuration.inMilliseconds);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could throw a DomException with NotSupportedError that would be caught in the camera platform here.

videoElement.captureStream(), {'mimeType': 'video/webm'});

if (maxVideoDuration != null) {
_mediaRecorder!.addEventListener('dataavailable', (event) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to remove the event listener (removeEventListener) when the video recording is stopped?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Removing it now

StreamController();

/// Returns a Stream that emits when a video Recodring with a defined maxVideoDuration was created
Stream<VideoRecordedEvent> get onVideoRecordedEventStream =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about renaming it to onVideoRecorded for consistency with other stream names in the camera plugin (onCameraInitialized, onCameraResolutionChanged, etc.)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed it to onVideoRecordedEvent to match the naming of the getter in the camera_plugin_interface

Copy link
Contributor

@bselwe bselwe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we maybe remove throwing an unimplemented error in prepareForVideoRecording?

Match naming from camera_plugin_interface
@ABausG
Copy link
Contributor Author

ABausG commented Aug 5, 2021

Should we maybe remove throwing an unimplemented error in [prepareForVideoRecording]

I was thinking about that method as well. I'm seeing two options:

  1. Make it no-op
  2. Perform initialisation in the actual camera implementation like setting up the media recorder.

ABausG added 3 commits August 5, 2021 16:53
Some issues with removing listener and stopping the mediaRecorder
ABausG added 2 commits August 7, 2021 14:00
MediaRecorder.start with a Timeslice does not seem to fire in the test case
testWidgets(
'starts a video recording with a given maxDuration '
'emits a VideoRecordedEvent', (tester) async {
// TODO: MediaRecorder with a Timeslice does not seem to call the dataavailable Listener in a Test
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could not get this test to work. As it does not seem to trigger the datavailable event of the media recorder.
I was able to manually test the behaviour with setting a max duration (I tested with 4 seconds) however this test seems to timeout waiting for the first element of the event stream.
Could you take a look at what's might be going on?
Also is there a (nice) way to debug the integration tests?

Copy link
Contributor

@bselwe bselwe Aug 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will have a look at this test.

There is a way to debug integration tests in the browser. You'll need to execute this in camera_web/example:

flutter run -d web-server --target integration_test/camera_test.dart --debug

It will produce a link that you should open in the browser, open inspection tools (CMD+Option+J in Chrome), find a dart file you want to debug in the "Source" tab and mark a breakpoint. Refresh the web page and it should stop at the breakpoint.


await camera.startVideoRecording();

expect('recording', camera.mediaRecorder!.state);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expect('recording', camera.mediaRecorder!.state);
expect(camera.mediaRecorder!.state, equals('recording`));

Comment on lines 353 to 354
throwsA(predicate<PlatformException>(
(ex) => ex.code == CameraErrorCode.notSupported.toString())));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
throwsA(predicate<PlatformException>(
(ex) => ex.code == CameraErrorCode.notSupported.toString())));
throwsA(predicate<PlatformException>(
(ex) => ex.code == CameraErrorCode.notSupported.toString(),),),);


await camera.pauseVideoRecording();

expect('paused', camera.mediaRecorder!.state);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expect('paused', camera.mediaRecorder!.state);
expect(camera.mediaRecorder!.state, equals('paused'));


await camera.resumeVideoRecording();

expect('recording', camera.mediaRecorder!.state);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expect('recording', camera.mediaRecorder!.state);
expect(camera.mediaRecorder!.state, equals('recording'));

@ditman
Copy link
Member

ditman commented Aug 31, 2021

Updating _videoRecorderController to a broadcast stream controller fixes this issue.

Ahhh, I didn't notice that, very good catch there @bselwe!

Copy link
Member

@ditman ditman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After reading the PR again, I don't understand very well how these features are currently implemented:

  • How does this respect the max length of a video recording passed by a user? I don't think we ever check or use the value of max length (only null checks).
  • How does this combine several ondataavailable events with video "chunks" to produce one single, large video file that can be used later? It seems this code emits the File as soon as the first ondataavailable event fires.
    • See the how they use ondataavailable and stop events here (it's for audio, but the API is the same).
    • Is this even supported by XFile, do we need to change anything there? Do we need to be able to create a XFile from an html.File, or a bunch of Blobs or something other than "bytes"?
  • @bselwe noticed that video on Chrome/Edge is missing some metadata, and the duration is listed as Infinity; have you noticed this? It seems to be this bug. SO1, SO2. (Is this something that can be fixed when putting together the XFile from all the "chunks"? Does the mp4 codec have this problem?)

For now, I'm going to publish the camera_web plugin with what we currently have, and when this PR matures a little bit more, we can release it in a new version. This is a great start, but I'm a little concerned about the comments above :S

@ditman
Copy link
Member

ditman commented Sep 3, 2021

How does this respect the max length of a video recording passed by a user? I don't think we ever check or use the value of max length (only null checks).

We discussed this, and it seems that we're leveraging the timeslice feature (that I had misunderstood), and we only use one blob of video, until we call stop, or the timeslice is done.

How does this combine several ondataavailable events with video "chunks" to produce one single, large video file

This is already achieved by the behavior of the ondataavailable event, and @bselwe just made it so that we use the stop event to collect the recorded video, closer to the example.

@google-cla
Copy link

google-cla bot commented Sep 16, 2021

All (the pull request submitter and all commit authors) CLAs are signed, but one or more commits were authored or co-authored by someone other than the pull request submitter.

We need to confirm that all authors are ok with their commits being contributed to this project. Please have them confirm that by leaving a comment that contains only @googlebot I consent. in this pull request.

Note to project maintainer: There may be cases where the author cannot leave a comment, or the comment is not properly detected as consent. In those cases, you can manually confirm consent of the commit author(s), and set the cla label to yes (if enabled on your project).

ℹ️ Googlers: Go here for more info.

@google-cla google-cla bot added cla: no and removed cla: yes labels Sep 16, 2021
@ditman
Copy link
Member

ditman commented Sep 16, 2021

@googlebot I consent.

@google-cla google-cla bot added cla: yes and removed cla: no labels Sep 16, 2021
@ditman ditman self-requested a review September 16, 2021 17:15
Copy link
Member

@ditman ditman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! All tests pass locally, let's go with this one!

@ditman ditman merged commit aa69736 into flutter:master Sep 16, 2021
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Sep 18, 2021
amantoux pushed a commit to amantoux/plugins that referenced this pull request Sep 27, 2021
* feat: Add Support for Video Recording in Camera Web
* docs: add video recording documentation

Co-authored-by: Bartosz Selwesiuk <[email protected]>
KyleFin pushed a commit to KyleFin/plugins that referenced this pull request Dec 21, 2021
* feat: Add Support for Video Recording in Camera Web
* docs: add video recording documentation

Co-authored-by: Bartosz Selwesiuk <[email protected]>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants