UNPKG

mfx

Version:

In-browser video editing toolkit, with effects accelerated by WebGL

299 lines (263 loc) 7.66 kB
import { getCodecFromMimeType, getContainerFromMimeType, next } from "./utils"; import { MFXTransformStream } from "./stream"; import { ExtendedVideoFrame } from "./frame"; import { vp9 } from "./codec/vp9"; import { MP4ContainerEncoder } from "./container/mp4/MP4ContainerEncoder"; import { WebMContainerEncoder } from "./container/webM/WebMContainerEncoder"; import { GIFContainerEncoder } from "./container/gif/GIFContainerEncoder"; import type { MFXEncodedChunk } from "./types"; import type { ContainerEncoderConfig } from "./container/encoderConfig"; export { MP4ContainerEncoder, WebMContainerEncoder, GIFContainerEncoder }; /** * @group Encode */ export interface MFXVideoEncoderConfig extends VideoEncoderConfig { /** * Encodes a frame as keyframe every nth second (in seconds) * Set to `Infinity` to disable periodic keyframes * @default 30 */ keyframeEveryNthSecond?: number; } /** * @group Encode */ export const encode = ({ mimeType, video, audio, ...config }: { mimeType: string; video?: Omit<MFXVideoEncoderConfig, "codec"> & { stream: MFXTransformStream<any, VideoFrame> | ReadableStream<VideoFrame>; codec?: string; }; audio?: Omit<AudioEncoderConfig, "codec"> & { stream: MFXTransformStream<any, AudioData> | ReadableStream<AudioData>; codec?: string; }; } & Omit<ContainerEncoderConfig, "video" | "audio">) => { const containerType = getContainerFromMimeType(mimeType); const { videoCodec, audioCodec } = getCodecFromMimeType(mimeType); if (!["mp4", "webm", "gif"].includes(containerType)) { throw new Error(`Unsupported container type ${containerType}`); } const { stream: videoStream, ...videoConfigRaw } = video || {}; const { stream: audioStream, ...audioConfigRaw } = audio || {}; const videoConfig = { codec: videoCodec, ...videoConfigRaw, } as VideoEncoderConfig; const audioConfig = { codec: audioCodec, ...audioConfigRaw, } as AudioEncoderConfig; const containerConfig: ContainerEncoderConfig = { ...config, ...(video ? { video: videoConfig, } : {}), ...(audio ? { audio: audioConfig, } : {}), }; const decoderClass = { mp4: MP4ContainerEncoder, webm: WebMContainerEncoder, gif: GIFContainerEncoder, }; const container = new decoderClass[containerType](containerConfig); let streams: ReadableStream<any>[] = []; if (video) { const videoOutput = ((videoStream as TransformStream).readable || videoStream) as ReadableStream<VideoFrame>; if (containerType === "gif") { streams.push(videoOutput); } else { streams.push(videoOutput.pipeThrough(new MFXVideoEncoder(videoConfig))); } } if (audio && containerType !== "gif") { const audioOutput = ((audioStream as TransformStream).readable || audioStream) as ReadableStream<AudioData>; streams.push(audioOutput.pipeThrough(new MFXAudioEncoder(audioConfig))); } const writer = container.writable.getWriter(); (async () => { let promises = []; for (let stream of streams) { promises.push( (async () => { for await (const chunk of stream as any) { writer.write(chunk); } })(), ); } await Promise.all(promises); writer.close(); })(); return container.readable; }; /** * @group Encode */ export class MFXVideoEncoder extends MFXTransformStream< ExtendedVideoFrame, MFXEncodedChunk > { get identifier() { return "MFXVideoEncoder"; } constructor(config: MFXVideoEncoderConfig) { let backpressure = Promise.resolve(); const encoder = new VideoEncoder({ output: async (chunk, metadata) => { backpressure = this.queue({ video: { chunk, metadata, }, }); }, error: (e) => { console.trace(e); this.dispatchError(e); }, }); encoder.configure({ ...config, ...(config.codec === "vp9" ? { codec: vp9.autoSelectCodec({ width: config.width, height: config.height, bitrate: config.bitrate, bitDepth: 8, }), } : {}), }); const maxKFInterval = 1e6 * (config.keyframeEveryNthSecond || 30); // Force first frame to be keyFrame let lastKFTimestamp = -maxKFInterval; super( { transform: async (frame) => { // Prevent forward backpressure await backpressure; // Prevent backwards backpressure while (encoder.encodeQueueSize > 10) { await next(); } if (encoder.state !== "configured") { throw new Error( `VideoEncoder is in invalid state ${encoder.state}`, ); } if ( !( frame instanceof VideoFrame || (frame as any) instanceof ExtendedVideoFrame ) ) { throw new Error( `VideoEncoder received invalid type, check that your pipeline correctly decodes videos`, ); } if (frame.duration <= 0) { frame.close(); return; } if ( frame.properties?.keyFrame || frame.timestamp - lastKFTimestamp >= maxKFInterval ) { encoder.encode(frame, { keyFrame: true }); lastKFTimestamp = frame.timestamp; } else { encoder.encode(frame, { keyFrame: false }); } frame.close(); }, flush: async () => { await encoder.flush(); encoder.close(); }, }, new CountQueuingStrategy({ highWaterMark: 10, // Input chunks (tuned for low memory usage) }), new CountQueuingStrategy({ highWaterMark: 10, // Input chunks (tuned for low memory usage) }), ); } } /** * @group Encode */ export class MFXAudioEncoder extends MFXTransformStream< AudioData, MFXEncodedChunk > { get identifier() { return "MFXAudioEncoder"; } constructor(config: AudioEncoderConfig) { let backpressure = Promise.resolve(); const encoder = new AudioEncoder({ output: async (chunk, metadata) => { backpressure = this.queue({ audio: { chunk, metadata, }, }); }, error: (e) => { console.trace(e); this.dispatchError(e); }, }); encoder.configure(config); super( { transform: async (frame) => { // Prevent forward backpressure await backpressure; // Prevent backwards backpressure while (encoder.encodeQueueSize > 10) { await next(); } if (encoder.state !== "configured") { throw new Error( `AudioEncoder is in invalid state ${encoder.state}`, ); } if (!(frame instanceof AudioData)) { throw new Error( `AudioEncoder received invalid type, check that your pipeline correctly decodes audio`, ); } encoder.encode(frame); frame.close(); }, flush: async () => { await encoder.flush(); encoder.close(); }, }, new CountQueuingStrategy({ highWaterMark: 10, // Input chunks (tuned for low memory usage) }), new CountQueuingStrategy({ highWaterMark: 10, // Input chunks (tuned for low memory usage) }), ); } }