mfx
Version:
In-browser video editing toolkit, with effects accelerated by WebGL
261 lines (250 loc) • 7.16 kB
text/typescript
import { codecs, decode, effect, encode, ExtendedVideoFrame, keyframes, visual } from "mfx";
import { easing } from "ts-easing";
import type { TestDefinition } from "../types";
import { openURL } from "../utils";
const step = (v: number[], size = 2500) => v.map((s, i) => ({
time: i * size,
value: s
}));
async function createVideoFrameFromURL(url: string): Promise<VideoFrame> {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = url;
await img.decode();
const imageBitmap = await createImageBitmap(img);
return new VideoFrame(imageBitmap, { timestamp: 0 });
};
const createMaskFrame = (width: number, height: number, roundness = 50, color = "black") => {
const scale = 1;
const canvas = new OffscreenCanvas(width * scale, height * scale);
const ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(0, 0, canvas.width, canvas.height, roundness * scale);
ctx.fill();
return new VideoFrame(canvas, {
alpha: "keep",
timestamp: 0,
displayWidth: canvas.width,
displayHeight: canvas.height,
visibleRect: {
width: canvas.width,
height: canvas.height,
x: 0,
y: 0
},
});
};
const cornerXKeyframes = keyframes(step([
0, 0, 1, 1, 0, 0, 1, 1
]), easing.inOutSine);
const cornerYKeyframes = keyframes(step([
0, 0, 0, 0, 1, 1, 1, 1
]), easing.inOutSine);
export const definitions: TestDefinition[] = [{
id: "effect_zoom",
title: "Zoom",
description: "Zoom at a scale into a video coordinate",
input: "AI.mp4",
process: (stream) => (
effect(stream, [
visual.zoom({ factor: 2, x: 0.5, y: 0.5 })
])
)
}, {
id: "effect_zoom_sampling_check",
title: "Zoom Sampling",
description: "Verify zoom bilinear sampling",
input: "AI.mp4",
process: (stream) => (
effect(stream, [
visual.zoom({ factor: 10, x: 0.5, y: 0.5 })
])
)
}, {
id: "effect_zoom_corners",
title: "Zoom Corners",
description: "Zoom into x/y corners",
input: "beach.mp4",
process: (stream) => (
effect(stream, [
visual.zoom({ factor: 3, x: cornerXKeyframes, y: cornerYKeyframes })
])
),
}, {
id: "effect_zoom_stacked",
title: "Stacked Zoom Effects",
description: "Zoom at a scale into a video coordinate",
input: "AI.mp4",
process: (stream) => effect(stream, [
visual.zoom({ factor: 1.5, x: 1, y: 1 }),
visual.zoom({ factor: 1.25, x: 1, y: 1 }),
]),
}, {
id: "effect_zoom_out",
title: "Zoom Out",
description: "Zoom out adjusting the alpha channel on area out of zoom",
input: "AI.mp4",
process: (stream) => effect(stream, [
visual.zoom({ factor: 0.5, x: 0.5, y: 0.25 })
])
}, {
id: "effect_blur",
title: "Blur",
description: "Blur video using fast gaussian and convolution",
input: "boats.mp4",
process: (stream) => effect(stream, [
visual.blur({ passes: 10, quality: 0.05 })
])
}, {
id: "effect_scale_alpha",
title: "Scale with Alpha",
description: "Scale video down with an underlying background",
input: "boats.mp4",
process: async (stream) => {
const background = await createVideoFrameFromURL("https://images.unsplash.com/photo-1730982045412-326c81810ace?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D");
const backgroundFrame = ExtendedVideoFrame.revise(background, background, {}, {
keepOpen: true
});
return effect(stream, [
visual.scale({ values: [0.7, 0.7, 1] }),
visual.add(new ReadableStream<VideoFrame>({
pull: async (controller) => {
controller.enqueue(backgroundFrame);
},
}), {
alpha: 1
})
]);
}
}, {
id: "effect_rotate",
title: "Rotate",
description: "Rotate transformation",
input: "boats.mp4",
process: (stream) => effect(stream, [
visual.scale({ values: [1 / 1.5, 1 / 1.5, 1 / 1.5] }),
visual.rotate({ angle: keyframes([{
time: 0,
value: 0
}, {
time: 12000,
value: 720
}], easing.linear), values: [1, 1, 1] }),
visual.scale({ values: [1.5, 1.5, 1.5] }),
]),
output: async (v, a, vt) => {
return encode({
mimeType: `video/mp4; codecs="${codecs.avc.generateCodecString("baseline", "5.0")},opus"`,
streaming: true,
video: {
stream: v,
width: 640 * 4,
height: 360 * 4,
bitrate: 1e6 * 30,
},
});
}
}, {
id: "composition_add",
title: "Composition: Add",
description: "Adding a new layer via composition",
input: "boats.mp4",
process: async (stream) => {
const { video } = await decode(await openURL("city.mp4"), "video/mp4");
const videoConfig = video.track.config as VideoDecoderConfig;
return effect(stream, [
visual.add(effect(video, [
visual.mask(new ReadableStream({
pull: (controller) => {
controller.enqueue(createMaskFrame(videoConfig.codedWidth, videoConfig.codedHeight));
},
})),
visual.scale({ values: [0.25, 0.25, 1].map((v) => keyframes([{
time: 0,
value: 0,
}, {
time: 300,
value: v
}, {
time: 3000,
value: v
}, {
time: 3500,
value: 0
}], easing.inOutCubic)) as any, origin: [1, 1, 1]}),
visual.crop({ values: [0.25, 0.25, 1], origin: [1, 1, 1] }),
]), {
offset: [0.95, 0.9],
normal: 1
}),
], {
trim: {
start: 5000
}
})
},
output: async (v, a, vt) => {
return encode({
mimeType: `video/mp4; codecs="${codecs.avc.generateCodecString("baseline", "5.0")},opus"`,
streaming: true,
video: {
stream: v,
width: 640 * 4,
height: 360 * 4,
bitrate: 1e6 * 30,
},
});
}
}, {
id: "effect_mask",
title: "Masking",
description: "Masking a layer",
input: "AI.mp4",
process: async (stream, track) => {
return effect(stream, [
visual.mask(new ReadableStream({
pull: (controller) => {
controller.enqueue(createMaskFrame(track.config.codedWidth, track.config.codedHeight));
},
})),
visual.scale({ values: [0.9, 0.9, 1].map((v) => keyframes([{
time: 0,
value: 0,
}, {
time: 300,
value: v
}, {
time: 4000,
value: v
}, {
time: 4500,
value: 0
}], easing.inOutCubic)) as any, origin: [0.5, 0.5, 1]}),
visual.add(new ReadableStream({
pull: (controller) => {
controller.enqueue(createMaskFrame(track.config.codedWidth, track.config.codedHeight, 0, "red"));
},
}), {
alpha: 1
})
], {
trim: {
end: 4500
}
})
},
output: async (v, a, vt) => {
return encode({
mimeType: `video/mp4; codecs="${codecs.avc.generateCodecString("baseline", "5.0")},opus"`,
streaming: true,
video: {
stream: v,
width: 640 * 4,
height: 360 * 4,
bitrate: 1e6 * 30,
},
});
}
}];