From 26c681b685b4f11acbf2a66785271866e44e6b08 Mon Sep 17 00:00:00 2001 From: Olivier Lando Date: Tue, 24 Sep 2024 14:43:21 +0200 Subject: [PATCH] Fix parallel uploadLastPart calls --- CHANGELOG.md | 23 +++- package-lock.json | 18 +-- package.json | 4 +- src/index.ts | 344 +++++++++++++++++++++++++--------------------- 4 files changed, 220 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc2641..a908da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,42 +1,59 @@ # Changelog + All changes to this project will be documented in this file. +## [1.0.11] - 2024-09-25 + +- Fix parallel uploadLastPart + ## [1.0.10] - 2023-02-07 + - Make `getSupportedMimeTypes` static ## [1.0.9] - 2023-02-07 + - Add `mimeType` and `generateFileOnStop` options ## [1.0.8] - 2023-01-23 + - Add "videoPlayable" event ## [1.0.7] - 2022-10-10 + - Allow the user to customize the recorded video's name ## [1.0.6] - 2022-07-06 + - Update dependancies - Add origin headers - Handle async errors ## [1.0.5] - 2022-05-31 + - Add `getMediaRecorderState()` method - Fix `stop()` method when the recorder is not started ## [1.0.4] - 2022-05-24 + - Prevent last uploaded part to be empty ## [1.0.3] - 2022-03-23 + - Specify a return type for `ApiVideoMediaRecorder.stop` - + ## [1.0.2] - 2022-03-07 + - Make the start() timeslice parameter customizable ## [1.0.1] - 2021-11-25 + - Bump dependancies - + ## [1.0.0] - 2021-11-16 + - Bump dependancies - Readme update - + ## [0.0.1] - 2021-11-01 + - First version diff --git a/package-lock.json b/package-lock.json index ae883dc..08df6e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@api.video/media-recorder", - "version": "1.0.10", + "version": "1.0.11", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@api.video/media-recorder", - "version": "1.0.10", + "version": "1.0.11", "license": "MIT", "dependencies": { - "@api.video/video-uploader": "^1.1.3", + "@api.video/video-uploader": "^1.1.6", "core-js": "^3.23.3" }, "devDependencies": { @@ -30,9 +30,9 @@ } }, "node_modules/@api.video/video-uploader": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@api.video/video-uploader/-/video-uploader-1.1.3.tgz", - "integrity": "sha512-TuEYsBEFXnJZM7tSnFwemam+AdJzlpT1ZBI1PMB7ssTAMFY62HRyCYHD7bleEGYi4FjxaCVraQvkouNtyk5UpQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@api.video/video-uploader/-/video-uploader-1.1.6.tgz", + "integrity": "sha512-1NPNO1R5GSIRM7toZmuLE8dXek+/nXwRqs4s0/4I4XUzvoXhJenksqOSoMrsgfIF3OdzcfEnb5lYd+GlszIfcw==", "dependencies": { "core-js": "^3.25.5" } @@ -3451,9 +3451,9 @@ }, "dependencies": { "@api.video/video-uploader": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@api.video/video-uploader/-/video-uploader-1.1.3.tgz", - "integrity": "sha512-TuEYsBEFXnJZM7tSnFwemam+AdJzlpT1ZBI1PMB7ssTAMFY62HRyCYHD7bleEGYi4FjxaCVraQvkouNtyk5UpQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@api.video/video-uploader/-/video-uploader-1.1.6.tgz", + "integrity": "sha512-1NPNO1R5GSIRM7toZmuLE8dXek+/nXwRqs4s0/4I4XUzvoXhJenksqOSoMrsgfIF3OdzcfEnb5lYd+GlszIfcw==", "requires": { "core-js": "^3.25.5" } diff --git a/package.json b/package.json index 82a27e8..2d11ca9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@api.video/media-recorder", - "version": "1.0.10", + "version": "1.0.11", "description": "api.video media recorder - upload video from your webcam with ease", "repository": { "type": "git", @@ -39,7 +39,7 @@ "xhr-mock": "^2.5.1" }, "dependencies": { - "@api.video/video-uploader": "^1.1.3", + "@api.video/video-uploader": "^1.1.6", "core-js": "^3.23.3" } } diff --git a/src/index.ts b/src/index.ts index f62e0c0..2f52797 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,178 +1,212 @@ -import { ProgressiveUploader, ProgressiveUploaderOptionsWithAccessToken, ProgressiveUploaderOptionsWithUploadToken, VideoUploadResponse } from "@api.video/video-uploader"; +import { + ProgressiveUploader, + ProgressiveUploaderOptionsWithAccessToken, + ProgressiveUploaderOptionsWithUploadToken, + VideoUploadResponse, +} from "@api.video/video-uploader"; import { VideoUploadError } from "@api.video/video-uploader/dist/src/abstract-uploader"; -export { ProgressiveUploaderOptionsWithAccessToken, ProgressiveUploaderOptionsWithUploadToken, VideoUploadResponse } from "@api.video/video-uploader"; +export { + ProgressiveUploaderOptionsWithAccessToken, + ProgressiveUploaderOptionsWithUploadToken, + VideoUploadResponse, +} from "@api.video/video-uploader"; export { VideoUploadError } from "@api.video/video-uploader/dist/src/abstract-uploader"; export interface Options { - onError?: (error: VideoUploadError) => void; - generateFileOnStop?: boolean; - mimeType?: string; + onError?: (error: VideoUploadError) => void; + generateFileOnStop?: boolean; + mimeType?: string; } let PACKAGE_VERSION = ""; try { - // @ts-ignore - PACKAGE_VERSION = __PACKAGE_VERSION__ || ""; + // @ts-ignore + PACKAGE_VERSION = __PACKAGE_VERSION__ || ""; } catch (e) { - // ignore + // ignore } type EventType = "error" | "recordingStopped" | "videoPlayable"; - export class ApiVideoMediaRecorder { - private mediaRecorder: MediaRecorder; - private streamUpload: ProgressiveUploader; - private onVideoAvailable?: (video: VideoUploadResponse) => void; - private onStopError?: (error: VideoUploadError) => void; - private eventTarget: EventTarget; - private debugChunks: Blob[] = []; - private generateFileOnStop: boolean; - private mimeType: string; - - constructor(mediaStream: MediaStream, options: Options & (ProgressiveUploaderOptionsWithUploadToken | ProgressiveUploaderOptionsWithAccessToken)) { - this.eventTarget = new EventTarget(); - this.generateFileOnStop = options.generateFileOnStop || false; - - const findBestMimeType = () => { - const supportedTypes = ApiVideoMediaRecorder.getSupportedMimeTypes(); - if (supportedTypes.length === 0) { - throw new Error("No compatible supported video mime type"); - } - return supportedTypes[0]; + private mediaRecorder: MediaRecorder; + private streamUpload: ProgressiveUploader; + private onVideoAvailable?: (video: VideoUploadResponse) => void; + private onStopError?: (error: VideoUploadError) => void; + private eventTarget: EventTarget; + private debugChunks: Blob[] = []; + private generateFileOnStop: boolean; + private mimeType: string; + private previousPart: Blob | null = null; + + constructor( + mediaStream: MediaStream, + options: Options & + ( + | ProgressiveUploaderOptionsWithUploadToken + | ProgressiveUploaderOptionsWithAccessToken + ) + ) { + this.eventTarget = new EventTarget(); + this.generateFileOnStop = options.generateFileOnStop || false; + + const findBestMimeType = () => { + const supportedTypes = ApiVideoMediaRecorder.getSupportedMimeTypes(); + if (supportedTypes.length === 0) { + throw new Error("No compatible supported video mime type"); + } + return supportedTypes[0]; + }; + + this.mimeType = options.mimeType || findBestMimeType(); + + this.mediaRecorder = new MediaRecorder(mediaStream, { + mimeType: this.mimeType, + }); + + this.mediaRecorder.addEventListener("stop", () => { + const stopEventPayload = this.generateFileOnStop + ? { file: new Blob(this.debugChunks, { type: this.mimeType }) } + : {}; + this.dispatch("recordingStopped", stopEventPayload); + }); + + this.streamUpload = new ProgressiveUploader({ + preventEmptyParts: true, + ...options, + origin: { + sdk: { + name: "media-recorder", + version: PACKAGE_VERSION, + }, + ...options.origin, + }, + }); + + this.mediaRecorder.ondataavailable = (e) => this.onDataAvailable(e); + + this.mediaRecorder.onstop = async () => { + if (this.previousPart) { + const video = await this.streamUpload.uploadLastPart(this.previousPart); + if (this.onVideoAvailable) { + this.onVideoAvailable(video); } - - this.mimeType = options.mimeType || findBestMimeType(); - - this.mediaRecorder = new MediaRecorder(mediaStream, { - mimeType: this.mimeType, - }); - - this.mediaRecorder.addEventListener("stop", () => { - const stopEventPayload = this.generateFileOnStop ? { file: new Blob(this.debugChunks, { type: this.mimeType }) } : {}; - this.dispatch("recordingStopped", stopEventPayload); - }); - - this.streamUpload = new ProgressiveUploader({ - preventEmptyParts: true, - ...options, - origin: { - sdk: { - name: "media-recorder", - version: PACKAGE_VERSION - }, - ...options.origin - }, - }); - - this.mediaRecorder.ondataavailable = (e) => this.onDataAvailable(e); - (window as any).mediaRecorder = this.mediaRecorder; + } else if (this.onStopError) { + const error: VideoUploadError = { + raw: "No data available to upload", + title: "No data available to upload", + }; + this.onStopError(error); + } + }; + (window as any).mediaRecorder = this.mediaRecorder; + } + + public addEventListener( + type: EventType, + callback: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions | undefined + ): void { + if (type === "videoPlayable") { + this.streamUpload.onPlayable((video) => + this.dispatch("videoPlayable", video) + ); } - - public addEventListener(type: EventType, callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions | undefined): void { - if (type === "videoPlayable") { - this.streamUpload.onPlayable((video) => this.dispatch("videoPlayable", video)); - } - this.eventTarget.addEventListener(type, callback, options); + this.eventTarget.addEventListener(type, callback, options); + } + + private async onDataAvailable(ev: BlobEvent) { + const isLast = (ev as any).currentTarget.state === "inactive"; + try { + if (this.generateFileOnStop) { + this.debugChunks.push(ev.data); + } + if (this.previousPart) { + const toUpload = new Blob([this.previousPart]); + this.previousPart = ev.data; + await this.streamUpload.uploadPart(toUpload); + } else { + this.previousPart = ev.data; + } + } catch (error) { + if (!isLast) this.mediaRecorder.stop(); + this.dispatch("error", error); + if (this.onStopError) this.onStopError(error as VideoUploadError); } + } - private async onDataAvailable(ev: BlobEvent) { - const isLast = (ev as any).currentTarget.state === "inactive"; - try { - if (this.generateFileOnStop) { - this.debugChunks.push(ev.data); - } - if (isLast) { - const video = await this.streamUpload.uploadLastPart(ev.data); - - if (this.onVideoAvailable) { - this.onVideoAvailable(video); - } - } else { - await this.streamUpload.uploadPart(ev.data); - } - } catch (error) { - if (!isLast) this.mediaRecorder.stop(); - this.dispatch("error", error); - if (this.onStopError) this.onStopError(error as VideoUploadError); - } - } + private dispatch(type: EventType, data: any): boolean { + return this.eventTarget.dispatchEvent( + Object.assign(new Event(type), { data }) + ); + } - private dispatch(type: EventType, data: any): boolean { - return this.eventTarget.dispatchEvent(Object.assign(new Event(type), { data })); + public start(options?: { timeslice?: number }) { + if (this.getMediaRecorderState() === "recording") { + throw new Error("MediaRecorder is already recording"); } - - public start(options?: { timeslice?: number }) { - if (this.getMediaRecorderState() === "recording") { - throw new Error("MediaRecorder is already recording"); - } - this.mediaRecorder.start(options?.timeslice || 5000); + this.mediaRecorder.start(options?.timeslice || 5000); + } + + public getMediaRecorderState(): RecordingState { + return this.mediaRecorder.state; + } + + public stop(): Promise { + return new Promise((resolve, reject) => { + if (this.getMediaRecorderState() === "inactive") { + reject(new Error("MediaRecorder is already inactive")); + } + this.mediaRecorder.stop(); + this.onVideoAvailable = (v) => resolve(v); + this.onStopError = (e) => reject(e); + }); + } + + public pause() { + if (this.getMediaRecorderState() !== "recording") { + throw new Error("MediaRecorder is not recording"); } - - public getMediaRecorderState(): RecordingState { - return this.mediaRecorder.state; - } - - public stop(): Promise { - return new Promise((resolve, reject) => { - if (this.getMediaRecorderState() === "inactive") { - reject(new Error("MediaRecorder is already inactive")); - } - this.mediaRecorder.stop(); - this.onVideoAvailable = (v) => resolve(v); - this.onStopError = (e) => reject(e); - }) - } - - public pause() { - if (this.getMediaRecorderState() !== "recording") { - throw new Error("MediaRecorder is not recording"); - } - this.mediaRecorder.pause(); - } - - public static getSupportedMimeTypes() { - const VIDEO_TYPES = [ - "mp4", - "webm", - "ogg", - "x-matroska" + this.mediaRecorder.pause(); + } + + public static getSupportedMimeTypes() { + const VIDEO_TYPES = ["mp4", "webm", "ogg", "x-matroska"]; + const VIDEO_CODECS = [ + "vp9,opus", + "vp8,opus", + "vp9", + "vp9.0", + "vp8", + "vp8.0", + "h264", + "h.264", + "avc1", + "av1", + "h265", + "h.265", + ]; + + const supportedTypes: string[] = []; + VIDEO_TYPES.forEach((videoType) => { + const type = `video/${videoType}`; + VIDEO_CODECS.forEach((codec) => { + const variations = [ + `${type};codecs=${codec}`, + `${type};codecs:${codec}`, + `${type};codecs=${codec.toUpperCase()}`, + `${type};codecs:${codec.toUpperCase()}`, + `${type}`, ]; - const VIDEO_CODECS = [ - "vp9,opus", - "vp8,opus", - "vp9", - "vp9.0", - "vp8", - "vp8.0", - "h264", - "h.264", - "avc1", - "av1", - "h265", - "h.265", - ]; - - const supportedTypes: string[] = []; - VIDEO_TYPES.forEach((videoType) => { - const type = `video/${videoType}`; - VIDEO_CODECS.forEach((codec) => { - const variations = [ - `${type};codecs=${codec}`, - `${type};codecs:${codec}`, - `${type};codecs=${codec.toUpperCase()}`, - `${type};codecs:${codec.toUpperCase()}`, - `${type}` - ] - for (const variation of variations) { - if (MediaRecorder.isTypeSupported(variation)) { - supportedTypes.push(variation); - break; - } - } - }); - }); - return supportedTypes; - } -} \ No newline at end of file + for (const variation of variations) { + if (MediaRecorder.isTypeSupported(variation)) { + supportedTypes.push(variation); + break; + } + } + }); + }); + return supportedTypes; + } +}