I have a website (Angular 15 & Ionic 6
) where a user uploads a video, then I apply some alterations to it on a canvas, capture the canvas stream, and output to a file
I first create a MediaStream
of the video canvas that my alterations are occurring on along with the audio tracks from the video file captureStream
or an AudioContext
Then I capture that MediaStream
with a MediaRecorder
and onstop
write the data to a Blob
My current code works when uploading a file in Chrome from my Windows Desktop (many different file types) but when I choose a video on iOS, if I add audio tracks to the MediaStream
then the MediaRecorder
I capture it with has 0 bytes.
Can anyone help me figure out what I need to do to get the audio tracks added to my stream on Safari iOS?
This is my HTML (the source) and what I capture. Both have display: none
because the user does not see the processing until later:
<video id="uploadedVideo" #uploadedVideo autoplay webkit-playsinline playsinline></video>
<canvas id="canvasVideo" #canvasVideo></canvas>
This is how I access these elements:
@ViewChild('uploadedVideo', { static: false }) videoElementRef: ElementRef<HTMLVideoElement>;
video: HTMLVideoElement;
@ViewChild('fileUpload', { static: false }) fileUpload: ElementRef;
@ViewChild('canvasVideo', { static: false }) videoCanvasElementRef: ElementRef<HTMLCanvasElement>;
videoCanvas: HTMLCanvasElement;
videoCanvasCtx: CanvasRenderingContext2D;
I put the gain of the video to 0 on init because I don't want the sound to play when you don't see the video:
silenceVideoAudio() {
if (this.videoConnectedToAudioContext) {
return;
}
this.audioContext = new AudioContext();
this.sourceNode = this.audioContext.createMediaElementSource(this.video);
const gainNode = this.audioContext.createGain();
gainNode.gain.value = 0; // Ensure audio is silent
this.sourceNode.connect(gainNode);
gainNode.connect(this.audioContext.destination);
this.videoConnectedToAudioContext = true;
}
When the file is selected I start the processing and set up for the MediaRecorder
to stop when the video ends:
onVideoFileSelected(file: File) {
this.video.src = URL.createObjectURL(file);
this.silenceVideoAudio();
this.video.onloadeddata = async () => {
await this.startVideoProcessing(); };
this.video.onended = async () => {
this.recorder.stop();
};
}
This is the startVideoProcessing
where I get the audio tracks, set up the MediaStream
and MediaRecorder
and deal with the chunks when I'm done. There are no errors only 0 bytes on Safari:
async startVideoProcessing() {
// Do some other setup that is unrelated
let stream: MediaStream;
if (this.isSafari) {
stream = new MediaStream([...this.videoCanvas.captureStream(30).getVideoTracks()]);
} else {
stream = new MediaStream([...this.videoCanvas.captureStream(30).getVideoTracks(), this.getAudioTrack()]);
}
const options = {
audioBitsPerSecond: 128000,
videoBitsPerSecond: 2500000,
mimeType: this.mimeType,
};
this.recorder = new MediaRecorder(stream, options);
this.recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
}
};
this.recorder.start();
this.recorder.onerror = (event) => {
console.error(event);
throw new Error(event.toString());
};
this.recorder.onstop = () => {
const videoBlob = new Blob(this.chunks, { type: this.mimeType });
const videoFile = new File([videoBlob], this.mimeType === 'video/mp4' ? 'video-green-screen.mp4' : 'video-green-screen.webm', { type: this.mimeType });
this.fileSelected.emit(videoFile);
};
}
As you can see in the conditional above where I check isSafari
I do not add any audio track currently because if I do add it like in the else
using this getAudioTrack
method to get them like stream = new MediaStream([...this.videoCanvas.captureStream(30).getVideoTracks(), this.getAudioTrack()]);
then the ondataavailable
and this.chunks
when the recorder stops is always empty. If I do not try to add the audio track then it is has the proper bytes. Adding the audio track like this works on every platform except Safari.
getAudioTrack() {
if ((this.video as any).captureStream) {
this.videoAudioTracks = (this.video as any).captureStream().getAudioTracks()[0];
} else if ((this.video as any).mozCaptureStream) {
this.videoAudioTracks = (this.video as any).mozCaptureStream().getAudioTracks()[0];
} else {
const destination = this.audioContext.createMediaStreamDestination();
this.sourceNode.connect(destination);
this.videoAudioTracks = destination.stream.getAudioTracks()[0];
}
return this.videoAudioTracks;
}
I am testing on Safari iOS 16.2
Can anyone help me alter these methods in a way that:
And help me understand why when I add an audio track to the MediaStream
on Safari it makes all the MediaRecorder
data empty
The code in the question works fine for setting up an AudioContext
on Safari, the problem is that:
A newly-created AudioContext will always begin in the suspended state, and a state change event will be fired whenever the state changes to a different state. This event is fired before the complete event is fired.
(source: https://webaudio.github.io/web-audio-api/#dom-baseaudiocontext-onstatechange)running
without user interaction (and in this case onVideoFileSelected
is not direct enough). Safari also blocks playback in certain cases without user interaction. Read these two sections of this page for a breakdown on when and how Safari will deal with playing videos with and without audio and with and without user interaction: https://developer.apple.com/documentation/webkit/delivering_video_content_for_safari/#3030259)
https://developer.apple.com/documentation/webkit/delivering_video_content_for_safari/#3030251Safari requires user interaction to change the state of a new AudioContext
to running
in this case. The call to resume or play the video must take place in the call stack of the button or element the user interacts with.
In the case of this code break out the processing to happen as a result of a user clicking a button to process the video (at least on mobile or Safari):
videoFile: File;
onVideoFileSelected(file: File) {
this.videoFile = file;
}
startVideoProcessing() {
this.video.src = URL.createObjectURL(this.videoFile);
this.silenceVideoAudio();
this.video.onloadeddata = async () => {
await this.videoProcessing();
this.registerVideoFrameCallback();
};
this.video.onended = async () => {
this.recorder.stop();
};
}
<ion-button (click)="startVideoProcessing()">Process Video</ion-button>