I'm trying to create a desktop TikTok like application using Angular and NestJs, where user is able to record and upload video from the web camera. This application is supposed to have some sort of a feed page where other users' videos will be displayed. For each video within the feed component I want to implement a behavior similar to that of a video on youtube or tiktok itself, where it is not downloaded entirely at once, but in parts as needed. And since i want more control over the video downloading process rather than just pipe the whole size to the client and set video src as an endpoint url, i want to use MSE API capabilities.
To do that, at first, i implemented the NestJS endpoint, that is capable to serve videos in chunks accroding to the request's headers:
post.controller.ts
:
@Get('/video/:videoId')
async getPostsVideo(@Req() request: Request,
@Res() response: Response,
@Param('videoId') fileId: string) {
const range = request.headers.range;
if(!range) {
response.status(400).send('Bad Request');
return;
}
console.log(range);
const videoData: IFileData = await this.postService.getVideo(fileId, range);
response.set({
'Content-Range': `${videoData.range}`,
'Accept-Ranges': 'bytes',
'Content-Length': `${videoData.size}`,
'Content-Type': 'application/octet-stream',
'Access-Control-Expose-Headers': 'Content-Range',
})
console.log(videoData.buffer.byteLength);
response.status(206).send(Buffer.from(videoData.buffer));
}
post.service.ts
:
async getVideo(fileId: string, range: string) {
return await this.googleService.downloadFile(fileId, range);
}
All the videos are stored on the google drive, and since I found that the disk api also accepts a Range header to retrieve the desired byte range of the file, I am trying the following:
google.service.ts
:
async downloadFile(fileId: string, range: string): Promise<IFileData> {
try {
const drive = await this.getDriveInstance();
const response = await drive.files.get({
fileId,
alt: 'media',
headers: { "Range": range }
}, { responseType: 'arraybuffer' });
return { size: 0, buffer: response.data, range: response.headers['content-range'] };
} catch (e) {
throw new HttpException(e.message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
On the client Angular side, i have the feed.service.ts
that is responsible for sending requests to the NestJS endpoint:
loadVideo(id: string, range: string) {
const headers = new HttpHeaders().set('range', range);
return this.http.get(`${environment['API_VIDEO_ROUTE']}/${id}`, {
responseType: 'arraybuffer',
headers,
observe: 'response',
});
}
And, finally, the post.component.ts
implements the following logic:
export class PostComponent implements OnDestroy, OnInit {
constructor(
private feedService: FeedService,
) {}
subscription: Subscription | null = null;
@ViewChild('videoContent', { static: false })
videoElementRef!: ElementRef;
videoUrl: string = '';
videoLength!: number;
videoBufferData: any[] = [];
startByte: number = 0;
chunkSize: number = Number(environment['VIDEO_CHUNK_SIZE_BYTES']);
mediaSource!: MediaSource;
mediaSourceBuffer!: SourceBuffer;
ngOnInit(): void {
this.mediaSource = new MediaSource();
this.videoUrl = URL.createObjectURL(this.mediaSource);
this.mediaSource.addEventListener("sourceopen", () => {
console.log('opened');
this.mediaSourceBuffer = this.mediaSource.addSourceBuffer('video/mp4');
this.loadNextChunk();
});
this.mediaSource.addEventListener("sourceclose", () => {
console.log('closed');
});
}
ngOnDestroy(): void {
if(this.subscription) {
this.subscription.unsubscribe();
}
}
loadNextChunk() {
const endByte = this.startByte + this.chunkSize - 1;
// video id is temporarily hardcoded
this.feedService.loadVideo('1egi_avKBpNHgaPuVYyHDTYeKjvTO1SLQ', `bytes=${this.startByte}-${endByte}`)
.subscribe(
{
next: (response) => {
const rangeHeader = response.headers.get('content-range');
this.videoLength = Number(rangeHeader?.split('/').at(1));
const chunk = response.body;
if(chunk) {
console.log('received chunk: ', chunk);
this.mediaSourceBuffer.appendBuffer(chunk);
}
this.startByte += this.chunkSize;
if (this.startByte < this.videoLength) {
this.loadNextChunk();
}
}
}
)
}
}
Inside post.component.html
:
...
<video crossorigin="anonymous" controls #videoContent [src]="videoUrl"></video>
...
The problem is that when the post.component
is displayed, after the very first request to the server, the following warning is shown in Mozilla Firefox:
Media resource blob:http://localhost:4200/6ff35685-4d22-4aa5-8f81-61f059a198e7 could not be decoded.
And, after that, the MediaSource is closed and the video apparently doesn't show up at all. The following warning appears after the MediaSource is closed:
Media resource blob:http://localhost:4200/6ff35685-4d22-4aa5-8f81-61f059a198e7 could not be decoded, error: Error Code: NS_ERROR_FAILURE (0x80004005)
Details: virtual MediaResult __cdecl mozilla::MP4ContainerParser::IsInitSegmentPresent(const MediaSpan &): Invalid Top-Level Box:�
In Chrome, the following error is displayed:
core.mjs:8400 ERROR DOMException: Failed to execute 'addSourceBuffer' on 'MediaSource': The type provided ('video/mp4') is unsupported.
I suppose the cause may be due to a wrong approach of transferring the file in chunks, causing the necessary metadata to be lost. But at the moment i have no idea what is the real reason and how to fix it. Any links to the related articles will be appreciated.
I have tried another, more straightforward approach. All the code is the same except this fragment inside loadNextChunk
function in post.component.ts
function:
this.videoBufferData.push(new Blob([response.body!], { type: 'video/mp4' }));
const videoBlob = new Blob(this.videoBufferData, { type: 'video/mp4' });
this.videoUrl = URL.createObjectURL(videoBlob);
/*const chunk = response.body;
if(chunk) {
console.log('received chunk: ', chunk);
this.mediaSourceBuffer.appendBuffer(chunk);
}*/
That approach works completely fine for me in Firefox, but Chrome is still displaying the same error. The problem of this solution is obvious: video blinks every time it's src is updated, that is undesirable for me.
Ok, the issue was in the video MIME type, should be video/webm
instead of video/mp4
for the webcam video, now works fine.