@api.video/media-stream-composer
Version:
api.video media stream composer
442 lines (348 loc) • 14.9 kB
text/typescript
import { ApiVideoMediaRecorder, Options as RecorderOptions, ProgressiveUploaderOptionsWithAccessToken, ProgressiveUploaderOptionsWithUploadToken, VideoUploadResponse } from "@api.video/media-recorder";
import { DrawingLayer, DrawingSettings } from "./drawing-layer";
import MouseEventListener, { DragEvent, MoveEvent } from "./mouse-event-listener";
import { Resolution } from "./stream-position";
import { Stream, StreamDetails, StreamUserOptions } from "./stream/stream";
export { StreamDetails };
export interface Options {
resolution: Resolution;
};
export interface AudioSourceDetails {
id: string;
stream: MediaStream;
}
declare type EventType = "error" | "recordingStopped" | "videoPlayable";
let PACKAGE_VERSION = "";
try {
// @ts-ignore
PACKAGE_VERSION = __PACKAGE_VERSION__ || "";
} catch (e) {
// ignore
}
declare global {
interface Window {
AudioContext: AudioContext;
webkitAudioContext: any;
}
interface AudioContext {
createGainNode: any;
}
interface HTMLCanvasElement {
captureStream(frameRate?: number): MediaStream;
}
interface HTMLMediaElement {
_mediaElementSource: any
}
interface HTMLVideoElement {
playsInline: boolean;
}
}
export type MouseTool = "draw" | "move-resize";
type RecordingOptions = RecorderOptions & (ProgressiveUploaderOptionsWithUploadToken | ProgressiveUploaderOptionsWithAccessToken) & {timeslice?: number};
const DEFAULT_TIMESLICE = 5000;
export class MediaStreamComposer {
private result: MediaStream | null = null;
private recorder?: ApiVideoMediaRecorder;
private streams: Stream[] = [];
private eventTarget: EventTarget;
private fps = 25;
private resolution: Resolution;
private audioContext?: AudioContext;
private audioDestinationNode?: MediaStreamAudioDestinationNode;
private audioDelayNode?: DelayNode;
private canvas?: HTMLCanvasElement;
private canvasRenderingContext: CanvasRenderingContext2D | null = null;
private frameCount = 0;
private started = false;
private mouseTool: MouseTool | null = "move-resize";
private drawingLayer: DrawingLayer;
constructor(options: Partial<Options>) {
this.eventTarget = new EventTarget();
this.resolution = options.resolution || { width: 1280, height: 720 };
this.drawingLayer = new DrawingLayer();
}
private init() {
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioSupport = !!(window.AudioContext && (new AudioContext()).createMediaStreamDestination);
const canvasSupport = !!document.createElement('canvas').captureStream;
if (!audioSupport || !canvasSupport) {
throw new Error("Audio and canvas are required for MediaStreamComposer to work");
}
this.audioContext = new AudioContext();
this.audioDestinationNode = this.audioContext.createMediaStreamDestination();
this.canvas = document.createElement("canvas");
this.canvas.setAttribute('width', this.resolution.width.toString());
this.canvas.setAttribute('height', this.resolution.height.toString());
this.canvas.setAttribute('style', 'position:fixed; left: 110%; pointer-events: none'); // Push off screen
this.canvasRenderingContext = this.canvas.getContext('2d');
// delay node for video sync
this.audioDelayNode = this.audioContext.createDelay(5.0);
this.audioDelayNode.connect(this.audioDestinationNode);
this._backgroundAudioHack();
this.started = true;
this.drawingLayer.init();
this._requestAnimationFrame(() => this._draw());
// Add video
this.result = this.canvas?.captureStream(this.fps) || null;
// Remove "dead" audio track
const deadTrack = this.result?.getAudioTracks()[0];
if (deadTrack) { this.result?.removeTrack(deadTrack); }
// Add audio
const audioTracks = this.audioDestinationNode.stream.getAudioTracks();
if (audioTracks && audioTracks.length) {
this.result?.addTrack(audioTracks[0]);
}
const d = this;
const mouseEventListener = new MouseEventListener(this.canvas!, this.streams);
mouseEventListener.onClick((e) => {
if(!e.stream) return;
const options = e.stream.getStreamDetails().options;
options.onClick && options.onClick(e.stream.getId(), { x: e.x, y: e.y })
});
mouseEventListener.onDrag((e) => this.onMouseDrag(e));
mouseEventListener.onDragEnd(() => this.onMouseDragEnd());
mouseEventListener.onMove((e) => this.onMouseMove(e));
}
private _backgroundAudioHack() {
const createConstantSource = () => {
if (this.audioContext?.createConstantSource) {
return this.audioContext.createConstantSource();
}
// not really a constantSourceNode, just a looping buffer filled with the offset value
const constantSourceNode = this.audioContext!.createBufferSource();
const constantBuffer = this.audioContext!.createBuffer(1, 1, this.audioContext!.sampleRate);
const bufferData = constantBuffer.getChannelData(0);
bufferData[0] = (0 * 1200) + 10;
constantSourceNode.buffer = constantBuffer;
constantSourceNode.loop = true;
return constantSourceNode;
}
// stop browser from throttling timers by playing almost-silent audio
const source = createConstantSource();
const gainNode = this.audioContext!.createGain();
if (gainNode && source) {
gainNode.gain.value = 0.001; // required to prevent popping on start
source.connect(gainNode);
gainNode.connect(this.audioContext!.destination);
source.start();
}
}
public getResultStream() {
return this.result;
}
public static getSupportedMimeTypes() {
return ApiVideoMediaRecorder.getSupportedMimeTypes();
}
public startRecording(options: RecordingOptions) {
if(!this.started) this.init();
this.recorder = new ApiVideoMediaRecorder(this.result!, {
...options,
origin: {
sdk: {
name: "media-stream-composer",
version: PACKAGE_VERSION
},
...options.origin
},
});
const eventTypes: EventType[] = ["error", "recordingStopped", "videoPlayable"];
eventTypes.forEach(event => {
this.recorder?.addEventListener(event, (e) => this.eventTarget.dispatchEvent(Object.assign(new Event(event), { data: (e as any).data })));
});
this.recorder.start({ timeslice: options.timeslice || DEFAULT_TIMESLICE });
}
public destroy() {
this.drawingLayer.destroy();
this.recorder?.stop();
this.recorder = undefined;
this.streams.forEach(stream => this.removeStream(stream.getId()));
this.canvas?.parentElement?.removeChild(this.canvas);
this.canvas = undefined;
this.audioContext?.close();
this.audioContext = undefined;
this.audioDestinationNode = undefined;
this.result?.getTracks().forEach(track => track.stop());
this.result = null;
this.started = false;
}
public addEventListener(type: EventType, callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions | undefined): void {
this.eventTarget.addEventListener(type, callback, options);
}
public stopRecording(): Promise<VideoUploadResponse> {
if (!this.recorder) {
throw new Error("Recorder is not started");
}
return this.recorder.stop();
}
public updateStream(streamId: string, userOptions: StreamUserOptions) {
const stream = this.streams.find(s => s.getId() === streamId);
if (!stream) {
throw new Error(`Stream with id ${streamId} does not exist`);
}
stream.updateOptions(userOptions);
}
public appendCanvasTo(containerQuerySelector: string) {
if(!this.started) this.init();
const container = document.querySelector(containerQuerySelector) as HTMLCanvasElement;
if (!container) {
throw new Error("Container not found");
}
if (!this.canvas) {
throw new Error("Canvas is not created yet");
}
container.appendChild(this.canvas!);
this.canvas!.style.position = "unset";
this.canvas!.style.pointerEvents = "unset";
}
public removeStream(streamId: string) {
const streamIndex = this.streams.findIndex(stream => stream.getId() === streamId);
if (streamIndex === -1) {
throw new Error(`Stream with id ${streamId} does not exist`);
}
const stream = this.streams[streamIndex];
this.streams.splice(streamIndex, 1);
stream.destroy();
}
public addAudioSource(mediaStream: MediaStream): string {
if(!this.started) this.init();
const stream = new Stream("AUDIO", this.audioDelayNode!, this.resolution);
stream.load(mediaStream, this.audioContext!, {})
this.streams.push(stream);
return stream.getId();
}
public removeAudioSource(id: string) {
this.removeStream(id);
}
public async addStream(mediaStream: MediaStream | HTMLImageElement, userOptions: StreamUserOptions): Promise<string> {
if(!this.started) this.init();
const stream = new Stream( "VIDEO", this.audioDelayNode!, this.resolution);
await stream.load(mediaStream, this.audioContext!, userOptions)
this.streams.push(stream);
return stream.getId();
}
public getCanvas() {
if(!this.started) this.init();
return this.canvas;
}
public getAudioSources(): AudioSourceDetails[] {
return this.streams.filter(x => !x.hasDisplay()).map(x => ({
id: x.getId(),
stream: x.getStreamDetails().stream!,
}));
}
public getAudioSource(id: string): AudioSourceDetails | undefined {
return this.getAudioSources().find(x => x.id === id);
}
public getStreams(): StreamDetails[] {
return this.streams.filter(s => s.hasDisplay()).map((stream, index) => {
const details = stream.getStreamDetails();
return {
...details,
options: {
...details.options,
index,
}
}
});
}
public getStream(id: string): StreamDetails | undefined {
return this.getStreams().find(x => x.id === id);
}
public moveUp(streamId: string) {
const streamIndex = this.streams.findIndex(stream => stream.getId() === streamId);
if(streamIndex === -1 || streamIndex >= this.streams.length - 1) {
return;
}
this.streams.splice(streamIndex, 2, this.streams[streamIndex+1], this.streams[streamIndex])
}
public moveDown(streamId: string) {
const streamIndex = this.streams.findIndex(stream => stream.getId() === streamId);
if(streamIndex <= 0) {
return;
}
this.streams.splice(streamIndex - 1, 2, this.streams[streamIndex], this.streams[streamIndex-1])
}
public setMouseTool(tool: MouseTool) {
this.mouseTool = tool;
}
public setDrawingSettings(settings: Partial<DrawingSettings>) {
this.drawingLayer.setDrawingSettings(settings);
}
public clearDrawing() {
this.drawingLayer.clear();
}
private dispatch(type: EventType, data: any): boolean {
return this.eventTarget.dispatchEvent(Object.assign(new Event(type), { data }));
}
private async _draw() {
if (!this.started) { return; }
this.frameCount++;
const updateProcessingDelay = this.frameCount % 60 === 0;
const t0 = performance.now();
this.canvasRenderingContext?.clearRect(0, 0, this.resolution.width, this.resolution.height);
for (const stream of this.streams) {
stream.draw(this.canvasRenderingContext!);
}
this.drawingLayer.draw(this.canvasRenderingContext!);
const delay = performance.now() - t0;
if (updateProcessingDelay) {
// this._updateAudioDelay(delay);
this._updateAudioDelay(0); // fixme
}
setTimeout(() => this._requestAnimationFrame(() => this._draw()), 1000 / this.fps - delay);
}
private _requestAnimationFrame(callback: () => void) {
let fired = false;
const interval = setInterval(() => {
if (!fired && document.hidden) {
fired = true;
clearInterval(interval);
callback();
}
}, 1000 / this.fps);
requestAnimationFrame(() => {
if (!fired) {
fired = true;
clearInterval(interval);
callback();
}
});
}
private _updateAudioDelay(delayInMs: number) {
if (this.audioDelayNode && this.audioContext) {
this.audioDelayNode.delayTime.setValueAtTime(delayInMs / 1000, this.audioContext.currentTime);
}
}
private onMouseMove(e: MoveEvent) {
let cursor = "auto";
if (e.stream && this.mouseTool === "move-resize") {
const options = e.stream.getStreamDetails().options;
if (options.draggable && e.locations?.indexOf("inside") !== -1) {
cursor = "grab";
}
if (options.resizable) {
if (e.locations?.indexOf("circle") !== -1) cursor = "all-scroll";
else if (e.locations?.indexOf("top") !== -1) cursor = "ns-resize";
else if (e.locations?.indexOf("left") !== -1) cursor = "ew-resize";
else if (e.locations?.indexOf("bottom") !== -1) cursor = "ns-resize";
else if (e.locations?.indexOf("right") !== -1) cursor = "ew-resize";
}
}
this.canvas!.style.cursor = cursor;
}
private onMouseDragEnd() {
if(!this.started) return;
if (this.mouseTool === "draw") {
this.drawingLayer.onMouseDragEnd();
}
}
private onMouseDrag(e: DragEvent) {
if(!this.started) return;
if (this.mouseTool === "draw") {
this.drawingLayer.onMouseDrag(e);
}
if (this.mouseTool === "move-resize" && e.dragStart.stream) {
e.dragStart.stream.onMouseDrag(e);
}
}
}