@api.video/media-stream-composer
Version:
api.video media stream composer
409 lines (349 loc) • 15.1 kB
text/typescript
import { DragEvent, DragStart } from "../mouse-event-listener";
import { Position, Resolution, StreamMask, StreamPosition, StreamPositionType } from "../stream-position";
import {
Webcam,
Player,
Dom,
Module,
Effect,
MediaStreamCapture,
MediaStream as BanubaMediaStream,
} from "@banuba/webar";
interface StreamAudio {
audioSource?: MediaStreamAudioSourceNode
audioOutput?: GainNode
}
interface StreamDisplaySettings {
displayResolution: Resolution,
streamResolution: Resolution,
position: Position,
radius?: number;
}
type StreamType = "AUDIO" | "VIDEO";
export interface StreamOptions {
name?: string;
position: StreamPosition;
draggable: boolean;
resizable: boolean;
mask: StreamMask;
mute: boolean;
hidden: boolean;
opacity: number;
index: number;
onClick?: (streamId: string, event: { x: number, y: number }) => void;
}
export interface StreamDetails {
type: StreamType;
id: string;
options: StreamOptions;
displaySettings?: StreamDisplaySettings;
stream?: MediaStream;
streamVideo?: CanvasImageSource;
streamAudio?: StreamAudio;
}
interface BanubaEffect {
clientToken: string;
moduleUrls?: string[];
effectUrl?: string;
}
export interface StreamUserOptions {
name?: string;
position?: StreamPositionType;
x?: number;
y?: number;
width?: number;
height?: number;
draggable?: boolean;
resizable?: boolean;
mask?: StreamMask;
index?: number;
mute?: boolean;
hidden?: boolean;
opacity?: number;
onClick?: (streamId: string, event: { x: number, y: number }) => void;
banubaEffect?: BanubaEffect
}
export class Stream {
private static lastStreamId = 0;
private id: string;
private name?: string;
private position: StreamPosition = Position.cover;
private draggable: boolean = false;
private resizable: boolean = false;
private mask: StreamMask = "none";
private mute: boolean = false;
private hidden: boolean = false;
private opacity: number = 100;
private onClick?: (streamId: string, event: { x: number, y: number }) => void;
private mediaStream?: MediaStream;
private videoElement?: CanvasImageSource;
private streamAudio?: StreamAudio;
private type: StreamType;
private displaySettings?: StreamDisplaySettings;
private containerResolution: Resolution;
private audioDelayNode: DelayNode;
constructor(type: StreamType, audioDelayNode: DelayNode, containerResolution: Resolution) {
this.containerResolution = containerResolution;
this.type = type;
this.audioDelayNode = audioDelayNode;
this.id = `${type.toLowerCase()}_${Stream.lastStreamId++}`;
}
public async load(mediaStream: MediaStream | CanvasImageSource, audioContext: AudioContext, options: StreamUserOptions,) {
if (mediaStream instanceof MediaStream) {
if(options.banubaEffect) {
this.mediaStream = await this.createBanubaEffect(mediaStream, options.banubaEffect)
} else {
this.mediaStream = mediaStream;
}
this.videoElement = this.createStreamVideoElement(this.mediaStream);
this.videoElement.onresize = (_) => this.updateDisplaySettings();
} else {
this.videoElement = mediaStream;
(this.videoElement as HTMLImageElement).onload = (_) => this.updateDisplaySettings();
}
this.displaySettings = this.updateOptions(options);
if (this.mediaStream && this.mediaStream.getAudioTracks().length > 0 && audioContext && !this.mute) {
this.streamAudio = this.createStreamAudioElement(audioContext, this.audioDelayNode, this.mediaStream);
}
}
private async createBanubaEffect(stream: MediaStream, banuba: BanubaEffect) {
const player = await Player.create({ clientToken: banuba.clientToken });
(banuba.moduleUrls || []).forEach(async (url) => {
await player.addModule(new Module(url));
});
player.use(new BanubaMediaStream(stream));
if(banuba.effectUrl) {
await player.applyEffect(new Effect(banuba.effectUrl));
}
stream = new MediaStreamCapture(player);
player.play();
return stream;
}
getId(): string {
return this.id;
}
public updateOptions(userOptions: StreamUserOptions) {
const convertPosition = (options: StreamUserOptions): StreamPosition => {
switch (options.position) {
case "cover":
return Position.cover;
case "contain":
return Position.contain;
case "fixed":
return Position.fixed({
x: options.x,
y: options.y,
height: options.height,
width: options.width,
});
default:
throw new Error("Invalid position");
}
}
if (userOptions.name !== undefined) {
this.name = userOptions.name;
}
if (userOptions.position !== undefined) {
this.position = convertPosition(userOptions);
}
if (userOptions.draggable !== undefined) {
this.draggable = userOptions.draggable;
}
if (userOptions.resizable !== undefined) {
this.resizable = userOptions.resizable;
}
if (userOptions.mask !== undefined) {
this.mask = userOptions.mask;
}
if (userOptions.mute !== undefined) {
this.mute = userOptions.mute;
}
if (userOptions.hidden !== undefined) {
this.hidden = userOptions.hidden;
}
if (userOptions.opacity !== undefined) {
this.opacity = userOptions.opacity;
}
if (userOptions.onClick !== undefined) {
this.onClick = userOptions.onClick;
}
return this.updateDisplaySettings();
}
public destroy() {
if (this.streamAudio) {
if (this.streamAudio.audioSource) {
this.streamAudio.audioSource = undefined;
}
if (this.streamAudio.audioOutput) {
this.streamAudio.audioOutput.disconnect(this.audioDelayNode);
this.streamAudio.audioOutput = undefined;
}
}
if (this.videoElement && typeof this.videoElement === "object" && this.videoElement instanceof HTMLVideoElement) {
this.videoElement.remove();
}
(this.mediaStream?.getTracks() || []).forEach(x => x.stop());
}
public getDisplaySettings(): StreamDisplaySettings | undefined {
return this.displaySettings;
}
public getStreamDetails(): StreamDetails {
return {
type: this.type,
id: this.id,
options: {
name: this.name,
position: this.position,
draggable: this.draggable,
resizable: this.resizable,
mask: this.mask,
mute: this.mute,
hidden: this.hidden,
opacity: this.opacity,
onClick: this.onClick,
index: -1,
},
displaySettings: this.displaySettings,
stream: this.mediaStream,
streamVideo: this.videoElement,
streamAudio: this.streamAudio,
}
}
public updatePosition(position: StreamPosition) {
this.position = position;
this.updateDisplaySettings();
}
public draw(canvasRenderingContext: CanvasRenderingContext2D) {
if (!canvasRenderingContext || !this.displaySettings) return;
const { displayResolution,
streamResolution,
position,
radius } = this.displaySettings;
if (this.hidden || !canvasRenderingContext || !this.videoElement) {
return;
}
canvasRenderingContext.save();
canvasRenderingContext.globalAlpha = this.opacity / 100;
const image = this.videoElement;
switch (this.mask) {
case "circle":
canvasRenderingContext.beginPath();
canvasRenderingContext.arc(
position.x + radius!,
position.y + radius!,
radius!,
0,
Math.PI * 2,
false
);
canvasRenderingContext.clip();
const wider = streamResolution.width > streamResolution.height;
const adaptedWidth = displayResolution.width * streamResolution.width / streamResolution.height;
const adaptedHeight = displayResolution.height * streamResolution.height / streamResolution.width;
canvasRenderingContext.drawImage(image,
wider ? position.x - (adaptedWidth - displayResolution.width) / 2 : position.x,
wider ? position.y : position.y - (adaptedHeight - displayResolution.height) / 2,
wider ? adaptedWidth : displayResolution.width,
wider ? displayResolution.height : adaptedHeight,
);
break;
default:
canvasRenderingContext.drawImage(image, position.x, position.y, displayResolution.width, displayResolution.height);
}
canvasRenderingContext.restore();
}
public hasDisplay(): boolean {
return !!this.displaySettings;
}
public onMouseDrag(e: DragEvent) {
const streamDetails = this.getStreamDetails();
if (streamDetails.options.resizable && e.dragStart.locations?.find((location) => ["top", "right", "bottom", "left", "circle"].indexOf(location) !== -1)) {
this.onMouseResize(e.dragStart, e.x, e.y);
} else if (streamDetails.options.draggable && e.dragStart.locations?.indexOf("inside") !== -1) {
this.updatePosition(Position.fixed({
x: e.x - e.dragStart.offsetX!,
y: e.y - e.dragStart.offsetY!,
width: streamDetails.displaySettings!.displayResolution.width,
height: streamDetails.displaySettings!.displayResolution.height,
}));
}
}
private onMouseResize(dragStart: DragStart, mouseX: number, mouseY: number) {
if (dragStart.locations?.indexOf("circle") !== -1) {
const circleCenter = {
x: dragStart.x - dragStart.offsetX! + (dragStart.circleRadius || 0),
y: dragStart.y - dragStart.offsetY! + (dragStart.circleRadius || 0)
};
const newRadius = Math.sqrt(Math.pow(mouseX - circleCenter.x, 2) + Math.pow(mouseY - circleCenter.y, 2));
const change = newRadius / dragStart.circleRadius!;
this.updatePosition(Position.fixed({
width: dragStart.streamWidth! * change,
height: dragStart.streamHeight! * change,
x: dragStart.x - dragStart.offsetX! + (dragStart.circleRadius! - newRadius),
y: dragStart.y - dragStart.offsetY! + (dragStart.circleRadius! - newRadius),
}));
} else if (dragStart.locations?.indexOf("bottom") !== -1) {
const height = dragStart.streamHeight! + mouseY - dragStart.y;
const width = dragStart.streamWidth! * height / dragStart.streamHeight!;
const x = dragStart.x - dragStart.offsetX! - (width - dragStart.streamWidth!) / 2;
const y = dragStart.y - dragStart.offsetY!;
this.updatePosition(Position.fixed({ height, width, x, y }));
} else if (dragStart.locations?.indexOf("top") !== -1) {
const height = dragStart.streamHeight! - (mouseY - dragStart.y);
const width = dragStart.streamWidth! * height / dragStart.streamHeight!;
const y = dragStart.y - dragStart.offsetY! + (mouseY - dragStart.y);
const x = dragStart.x - dragStart.offsetX! - (width - dragStart.streamWidth!) / 2;
this.updatePosition(Position.fixed({ height, width, x, y }));
} else if (dragStart.locations?.indexOf("left") !== -1) {
const width = dragStart.streamWidth! - (mouseX - dragStart.x);
const height = dragStart.streamHeight! * width / dragStart.streamWidth!;
const x = dragStart.x - dragStart.offsetX! + (mouseX - dragStart.x);
const y = dragStart.y - dragStart.offsetY! - (height - dragStart.streamHeight!) / 2;
this.updatePosition(Position.fixed({ height, width, x, y }));
} else if (dragStart.locations?.indexOf("right") !== -1) {
const width = dragStart.streamWidth! + (mouseX - dragStart.x)
const height = dragStart.streamHeight! * width / dragStart.streamWidth!;
const y = dragStart.y - dragStart.offsetY! - (height - dragStart.streamHeight!) / 2;
const x = dragStart.x - dragStart.offsetX!;
this.updatePosition(Position.fixed({ height, width, x, y }));
}
}
private updateDisplaySettings() {
if (this.type === "AUDIO") return;
const trackSettings = this.mediaStream?.getVideoTracks()[0].getSettings();
if(trackSettings && !trackSettings.width) {
trackSettings.width = 1024;
trackSettings.height = 768;
}
const streamResolution = trackSettings
? { width: trackSettings.width as number, height: trackSettings.height as number }
: { width: this.videoElement?.width as number, height: this.videoElement?.height as number };
return this.displaySettings = {
...this.position.calculatePositionAndDimensions(this.containerResolution, streamResolution, this.mask),
streamResolution,
};
}
private createStreamVideoElement(mediaStream: MediaStream): HTMLVideoElement {
const videoElement = document.createElement('video');
videoElement.autoplay = true;
videoElement.muted = true;
videoElement.playsInline = true;
videoElement.srcObject = mediaStream;
videoElement.setAttribute('style', 'position:fixed; left: 0px; top:0px; pointer-events: none; opacity:0;');
document.body.appendChild(videoElement);
const res = videoElement.play();
res.catch(null);
return videoElement;
}
private createStreamAudioElement(audioContext: AudioContext, audioDelayNode: DelayNode, mediaStream: MediaStream): StreamAudio {
const audioSource = audioContext.createMediaStreamSource(mediaStream);
const audioOutput = audioContext.createGain(); // Intermediate gain node
audioOutput.gain.value = 1;
audioSource.connect(audioOutput); // Default is direct connect
audioOutput.connect(audioDelayNode);
return {
audioSource,
audioOutput
}
}
}