angulartypescriptfirefoxnestjsmedia-source

Partial video downloading Angular Nestjs GoogleDrive


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.


Solution

  • Ok, the issue was in the video MIME type, should be video/webm instead of video/mp4 for the webcam video, now works fine.