mfx
Version:
In-browser video editing toolkit, with effects accelerated by WebGL
595 lines (513 loc) • 15.6 kB
text/typescript
import * as twgl from "twgl.js";
import vertexShaderSource from "!!raw-loader!./shaders/vertex.glsl";
import paintShaderSource from "!!raw-loader!./shaders/paint.glsl";
import { MFXTransformStream } from "../stream";
import { ExtendedVideoFrame } from "../frame";
import type { Uniform, UniformProducer } from "./shaders";
import { mat4 } from "gl-matrix";
const identity = mat4.create();
mat4.identity(identity);
export type BoundTextureTransformer = (
gl: WebGL2RenderingContext,
type: "frameIn" | "frameOut" | "uniform",
key: string,
v: WebGLTexture,
) => void;
export interface Crop {
x: number;
y: number;
width: number;
height: number;
}
export type Uniforms =
| Record<string, Uniform<any>>
| ((frame: VideoFrame) => Promise<Record<string, Uniform<any>>>);
const checkStatus = (gl: WebGL2RenderingContext) => {
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
switch (status) {
case gl.FRAMEBUFFER_INCOMPLETE_ATTACHMENT:
console.error(
"Framebuffer incomplete: FRAMEBUFFER_INCOMPLETE_ATTACHMENT",
);
break;
case gl.FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT:
console.error(
"Framebuffer incomplete: FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT",
);
break;
case gl.FRAMEBUFFER_INCOMPLETE_DIMENSIONS:
console.error(
"Framebuffer incomplete: FRAMEBUFFER_INCOMPLETE_DIMENSIONS",
);
break;
case gl.FRAMEBUFFER_UNSUPPORTED:
console.error("Framebuffer incomplete: FRAMEBUFFER_UNSUPPORTED");
break;
default:
console.error("Framebuffer incomplete: Unknown error");
}
}
};
/** @group Effects */
export interface Effect<T = any> {
shader: string;
uniforms?: Record<string, Uniform<T>>;
}
export const u = async <T>(
o: Uniform<T>,
frame: ExtendedVideoFrame,
): Promise<T> => {
if (["string", "number", "boolean"].includes(typeof o)) {
return o as T;
}
if (typeof o === "function") {
return (o as UniformProducer<T>)(frame);
}
if (Array.isArray(o)) {
return Promise.all((o as any).map((v) => u(v, frame))) as T;
}
if (o instanceof VideoFrame || o instanceof ExtendedVideoFrame) {
return o;
}
throw new Error(`Invalid uniform type ${typeof o}`);
};
export class MFXGLHandle {
frame: VideoFrame;
context: MFXGLContext;
textures: number[];
closed: boolean;
busy: number;
// Dirty handles don't clear buffer between paints
isDirty: boolean = false;
constructor(frame: VideoFrame, context: MFXGLContext) {
this.context = context;
this.frame = frame;
const { gl, frameBufferInfo, textureIn } = context;
twgl.bindFramebufferInfo(gl, frameBufferInfo);
gl.bindTexture(gl.TEXTURE_2D, textureIn);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, frame);
// 8 textures are guaranteed by WebGL
// this is a theoretical minimum and browsers support significantly higher
// texture counts but 8 textures should cover all use-cases for MFX
this.textures = [
gl.TEXTURE1,
gl.TEXTURE2,
gl.TEXTURE3,
gl.TEXTURE4,
gl.TEXTURE5,
gl.TEXTURE6,
gl.TEXTURE7,
];
context.attachTextureToFramebuffer(textureIn);
}
compile(shader: string) {
return twgl.createProgramInfo(this.context.gl, [
vertexShaderSource,
shader,
]);
}
dirty() {
this.isDirty = true;
}
clean() {
this.isDirty = false;
}
async paint(
programInfo: twgl.ProgramInfo,
uniforms: Uniforms,
{
transformBoundTexture = (ctx, type, key, v) => v,
}: {
transformBoundTexture?: BoundTextureTransformer;
} = {},
) {
if (this.busy > 0) {
throw new Error(
"Encountered a busy MFXGLHandle. GL paints in MFX are not allowed to paint more than one frame per stream in order to re-use the framebuffer.",
);
}
this.busy++;
const { gl, cachedTextures, textureIn, textureOut, bufferInfo } =
this.context;
let resolvedUniforms = {};
let openFrames = new Set<VideoFrame>();
const inputUniforms =
typeof uniforms === "function" ? await uniforms(this.frame) : uniforms;
await Promise.all(
Object.keys(inputUniforms || {}).map(async (key) => {
const value = await u(inputUniforms[key], this.frame);
resolvedUniforms[key] = value;
}),
);
// Bind any textures assigned to uniforms
Object.keys(resolvedUniforms)
.filter(
(k) =>
resolvedUniforms[k] instanceof VideoFrame ||
resolvedUniforms[k] instanceof ExtendedVideoFrame,
)
.forEach((key: string, i) => {
const frame = resolvedUniforms[key] as VideoFrame;
const textureUnit = this.textures[i];
if (!textureUnit) {
throw new Error(
`Attempted to bind too many textures, total textures supported by MFX are capped at ${this.textures.length}`,
);
}
let texture = cachedTextures.get(frame);
if (!texture) {
texture = twgl.createTexture(gl, {
min: gl.NEAREST,
mag: gl.NEAREST,
wrapS: gl.CLAMP_TO_EDGE,
wrapT: gl.CLAMP_TO_EDGE,
width: frame.displayWidth,
height: frame.displayHeight,
});
gl.activeTexture(textureUnit);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
frame,
);
if ((frame as ExtendedVideoFrame).properties?.keepOpen) {
// Cache frames that remain open after painting
cachedTextures.set(frame, texture);
}
}
transformBoundTexture(gl, "uniform", key, texture);
resolvedUniforms[key] = texture;
if (!(frame as ExtendedVideoFrame).properties?.keepOpen) {
openFrames.add(frame);
}
});
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, textureIn);
this.resetBoundTexture();
transformBoundTexture(gl, "frameIn", "", textureIn);
this.context.attachTextureToFramebuffer(textureOut);
this.resetBoundTexture();
transformBoundTexture(gl, "frameOut", "", textureOut);
if (!this.isDirty) {
this.context.clear();
}
gl.useProgram(programInfo.program);
twgl.setBuffersAndAttributes(gl, programInfo, bufferInfo);
twgl.setUniforms(programInfo, {
transform: identity,
...resolvedUniforms,
frame: textureIn,
frameSize: [this.frame.displayWidth, this.frame.displayHeight],
MFXInternalFlipY: 0,
});
twgl.drawBufferInfo(gl, bufferInfo, gl.TRIANGLE_FAN);
// Swap textures
[this.context.textureIn, this.context.textureOut] = [textureOut, textureIn];
[...openFrames].forEach((frame) => frame.close());
this.busy--;
}
resetBoundTexture() {
const { gl } = this.context;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
}
// Draw action
close() {
if (this.busy > 0) {
console.error("Attempted to close a busy MFXGLHandle");
throw new Error("Attempted to close a busy MFXGLHandle");
}
if (this.closed) {
throw new Error("Attempted to close an already closed MFXGLHandle");
}
this.closed = true;
// Free resource after GPU paint
this.frame.close();
const { gl, bufferInfo, paintProgramInfo, textureIn, crop } = this.context;
const { width, height } = gl.canvas;
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, width, height);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, textureIn);
gl.useProgram(paintProgramInfo.program);
twgl.setBuffersAndAttributes(gl, paintProgramInfo, bufferInfo);
twgl.setUniforms(paintProgramInfo, {
frame: textureIn,
frameSize: [width, height],
transform: identity,
MFXInternalFlipY: 1,
});
twgl.drawBufferInfo(gl, bufferInfo, gl.TRIANGLE_FAN);
let source = gl.canvas;
// TODO: Pass scaling into a final resize/crop
if (crop) {
const final = new OffscreenCanvas(
Math.max(1, crop.width),
Math.max(1, crop.height),
);
const ctx2D = final.getContext(
"2d",
) as unknown as CanvasRenderingContext2D;
ctx2D.drawImage(
gl.canvas,
crop.x,
crop.y,
crop.width,
crop.height,
0,
0,
crop.width,
crop.height,
);
source = final;
}
return ExtendedVideoFrame.revise(this.frame, source, {
displayWidth: source.width,
displayHeight: source.height,
});
}
}
export class MFXGLContext {
gl: WebGL2RenderingContext;
paintProgramInfo: twgl.ProgramInfo;
bufferInfo: twgl.BufferInfo;
frameBufferInfo: twgl.FramebufferInfo;
textureIn: WebGLTexture;
textureOut: WebGLTexture;
crop: Crop | null = null;
cachedTextures: WeakMap<VideoFrame, WebGLTexture> = new WeakMap();
constructor(width: number, height: number) {
const canvas = new OffscreenCanvas(width, height);
const gl = canvas.getContext("webgl2", {
antialias: false,
desynchronized: true,
depth: true,
preserveDrawingBuffer: true,
premultipliedAlpha: false,
}) as WebGL2RenderingContext;
this.gl = gl;
this.paintProgramInfo = twgl.createProgramInfo(gl, [
vertexShaderSource,
paintShaderSource,
]);
const arrays = {
texcoord: {
numComponents: 2,
data: [
// x, y
-1,
-1, // Bottom-left corner (flipped)
-1,
+1, // Top-left corner (flipped)
+1,
+1, // Top-right corner (flipped)
+1,
-1, // Bottom-right corner (flipped)
],
},
};
this.bufferInfo = twgl.createBufferInfoFromArrays(this.gl, arrays);
this.textureIn = twgl.createTexture(gl, {
min: gl.NEAREST,
mag: gl.NEAREST,
wrapS: gl.CLAMP_TO_EDGE,
wrapT: gl.CLAMP_TO_EDGE,
width,
height,
});
this.textureOut = twgl.createTexture(gl, {
min: gl.NEAREST,
mag: gl.NEAREST,
wrapS: gl.CLAMP_TO_EDGE,
wrapT: gl.CLAMP_TO_EDGE,
width,
height,
});
this.frameBufferInfo = twgl.createFramebufferInfo(
gl,
[this.textureIn],
width,
height,
);
// Clear frame
this.clear();
}
clear() {
const { gl } = this;
gl.clearColor(0.0, 0.0, 0.0, 0.0);
gl.clear(gl.COLOR_BUFFER_BIT);
}
setCrop(crop: Crop) {
this.crop = crop;
}
attachTextureToFramebuffer(texture: WebGLTexture) {
const { gl, frameBufferInfo } = this;
gl.bindFramebuffer(gl.FRAMEBUFFER, frameBufferInfo.framebuffer);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture,
0,
);
checkStatus(gl);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
}
}
export class MFXGLEffect extends MFXTransformStream<MFXGLHandle, MFXGLHandle> {
get identifier() {
return "MFXGLEffect";
}
constructor(
shader: string,
uniforms: Uniforms = {},
{
isDirty = false,
transformBoundTexture,
transformContext = async () => {},
}: {
transformBoundTexture?: BoundTextureTransformer;
transformContext?: (
context: MFXGLContext,
frame: VideoFrame,
) => Promise<void>;
isDirty?: boolean;
} = {},
) {
let program: twgl.ProgramInfo;
super(
{
transform: async (handle, controller) => {
if (!program) {
program = handle.compile(shader);
}
if (isDirty) {
handle.dirty();
} else {
handle.clean();
}
try {
await handle.paint(program, uniforms, {
transformBoundTexture,
});
} catch (e) {
controller.error(e);
return;
}
await transformContext(handle.context, handle.frame);
controller.enqueue(handle);
},
},
// Only framebuffer at a time can be processed, this cannot change
new CountQueuingStrategy({ highWaterMark: 1 }),
new CountQueuingStrategy({ highWaterMark: 1 }),
);
}
}
/** @group Effects */
export class FrameToGL extends MFXTransformStream<
ExtendedVideoFrame,
MFXGLHandle
> {
get identifier() {
return "FrameToGL";
}
constructor() {
let context: MFXGLContext;
super(
{
transform: async (frame, controller) => {
if (!context) {
context = new MFXGLContext(frame.displayWidth, frame.displayHeight);
}
const handle = new MFXGLHandle(frame, context);
controller.enqueue(handle);
},
},
// Allow up to 1 minute of 60fps frames to buffer waiting for effects pipeline
// TODO: Make this configurable, needed for composing async effects without dropping frames
new CountQueuingStrategy({ highWaterMark: 60 * 60 }),
new CountQueuingStrategy({ highWaterMark: 1 }),
);
}
}
/** @group Effects */
export class GLToFrame extends MFXTransformStream<
MFXGLHandle,
ExtendedVideoFrame
> {
get identifier() {
return "GLToFrame";
}
constructor(
writableStrategy?: QueuingStrategy<MFXGLHandle>,
readableStrategy?: QueuingStrategy<ExtendedVideoFrame>,
) {
super(
{
transform: async (handle, controller) => {
controller.enqueue(handle.close());
},
},
writableStrategy,
readableStrategy,
);
}
}
export const effect = (
input: ReadableStream<VideoFrame>,
effects: MFXTransformStream<MFXGLHandle, MFXGLHandle>[][],
{
trim = {},
writableStrategy,
readableStrategy,
}: {
trim?: {
// Inclusive number of milliseconds to start cutting from (supports for microsecond fractions)
start?: number;
// Exclusive number of milliseconds to cut to (supports for microsecond fractions)
end?: number;
};
writableStrategy?: QueuingStrategy<any>;
readableStrategy?: QueuingStrategy<any>;
} = {},
) => {
// Converts VideoFrames to a WebGL2 handle (framebuffer)
const stream = new FrameToGL() as TransformStream;
const effectPipeline: ReadableStream<VideoFrame> = effects
.flat()
.reduce((accu, effect) => accu.pipeThrough(effect), stream.readable)
.pipeThrough(
// Converts framebuffers back to VideoFrame
new GLToFrame(writableStrategy, readableStrategy),
);
const trimPipeline = new TransformStream<VideoFrame, VideoFrame>({
transform: async (frame, controller) => {
if (
frame.timestamp / 1e3 < (trim.start || 0) ||
(trim.end > 0 && frame.timestamp / 1e3 > trim.end)
) {
controller.enqueue(frame);
return;
}
const writer = stream.writable.getWriter();
const reader = effectPipeline.getReader();
writer.write(frame);
const { value } = await reader.read();
controller.enqueue(value);
writer.releaseLock();
reader.releaseLock();
},
flush: async () => {
await stream.writable.close();
},
});
return input.pipeThrough(trimPipeline);
};