@omnimedia/omnitool
Version:
open source video processing tools
235 lines (202 loc) • 6.19 kB
text/typescript
import {Comrade} from "@e280/comrade"
import {autoDetectRenderer, Container, Renderer, Sprite, Text, Texture, DOMAdapter, WebWorkerAdapter} from "pixi.js"
import {Input, ALL_FORMATS, VideoSampleSink, Output, Mp4OutputFormat, VideoSampleSource, VideoSample, AudioSampleSink, AudioSampleSource, AudioSample, StreamTarget, BlobSource, UrlSource} from "mediabunny"
import {Composition, DriverSchematic, Layer, Transform} from "./schematic.js"
DOMAdapter.set(WebWorkerAdapter)
export const setupDriverWork = (
Comrade.work<DriverSchematic>(shell => ({
async hello() {
await shell.host.world()
},
async decode({source, video, audio}) {
const loadSource = async () => {
if(source instanceof Blob) {
return new BlobSource(source)
} else {
return new UrlSource(source)
}
}
const input = new Input({
source: await loadSource(),
formats: ALL_FORMATS
})
const [videoTrack, audioTrack] = await Promise.all([
input.getPrimaryVideoTrack(),
input.getPrimaryAudioTrack()
])
const videoDecodable = await videoTrack?.canDecode()
const audioDecodable = await audioTrack?.canDecode()
const videoWriter = video.getWriter()
const audioWriter = audio.getWriter()
await Promise.all([
(async () => {
if (videoDecodable && videoTrack) {
const sink = new VideoSampleSink(videoTrack)
for await (const sample of sink.samples()) {
const frame = sample.toVideoFrame()
await videoWriter.write(frame)
sample.close()
frame.close()
}
await videoWriter.close()
}
})(),
(async () => {
if (audioDecodable && audioTrack) {
const sink = new AudioSampleSink(audioTrack)
for await (const sample of sink.samples()) {
const frame = sample.toAudioData()
await audioWriter.write(frame)
sample.close()
frame.close()
}
await audioWriter.close()
}
})()
])
},
async encode({readables, config, bridge}) {
const output = new Output({
format: new Mp4OutputFormat(),
target: new StreamTarget(bridge, {chunked: true})
})
const videoSource = new VideoSampleSource(config.video)
output.addVideoTrack(videoSource)
// since AudioSample is not transferable it fails to transfer encoder bitrate config
// so it needs to be hardcoded not set through constants eg QUALITY_LOW
const audioSource = new AudioSampleSource(config.audio)
output.addAudioTrack(audioSource)
await output.start()
const videoReader = readables.video.getReader()
const audioReader = readables.audio.getReader()
await Promise.all([
(async () => {
while (true) {
const {done, value} = await videoReader.read()
if (done) break
const sample = new VideoSample(value)
await videoSource.add(sample)
sample.close()
}
})(),
(async () => {
while (true) {
const {done, value} = await audioReader.read()
if (done) break
const sample = new AudioSample(value)
await audioSource.add(sample)
sample.close()
value.close()
}
})()
])
await output.finalize()
},
async composite(composition) {
const {stage, renderer} = await renderPIXI(1920, 1080)
stage.removeChildren()
const {baseFrame, disposables} = await renderLayer(composition, stage)
renderer.render(stage)
// make sure browser support webgl/webgpu otherwise it might take much longer to construct frame
// if its very slow on eg edge try chrome
const frame = new VideoFrame(renderer.canvas, {
timestamp: baseFrame?.timestamp,
duration: baseFrame?.duration ?? undefined,
})
baseFrame?.close()
renderer.clear()
for (const disposable of disposables) {
disposable.destroy(true)
}
shell.transfer = [frame]
return frame
}
}))
)
// TODO suspicious global, probably bad
let pixi: {
renderer: Renderer
stage: Container
} | null = null
async function renderPIXI(width: number, height: number) {
if (pixi)
return pixi
const renderer = await autoDetectRenderer({
width,
height,
preference: "webgl", // webgl and webgl2 causes memory leaks on chrome
background: "black",
preferWebGLVersion: 2
})
const stage = new Container()
pixi = {renderer, stage}
return pixi
}
type RenderableObject = Sprite | Text | Texture
async function renderLayer(
layer: Layer | Composition,
parent: Container,
disposables: RenderableObject[] = []
) {
if (Array.isArray(layer)) {
let baseFrame: VideoFrame | undefined
for (const child of layer) {
const result = await renderLayer(child, parent, disposables)
baseFrame ??= result.baseFrame
}
return {baseFrame, disposables}
}
if (!isRenderableLayer(layer)) {
console.warn('Invalid layer', layer)
return {disposables}
}
switch (layer.kind) {
case 'text':
return renderTextLayer(layer, parent, disposables)
case 'image':
return renderImageLayer(layer, parent, disposables)
default:
console.warn('Unknown layer kind', (layer as any).kind)
return {disposables}
}
}
function isRenderableLayer(layer: any): layer is Layer {
return !!layer && typeof layer === 'object' && typeof layer.kind === 'string'
}
function renderTextLayer(
layer: Extract<Layer, {kind: 'text'}>,
parent: Container,
disposables: RenderableObject[]
) {
const text = new Text({
text: layer.content,
style: {
fontFamily: 'sans-serif',
fontSize: layer.fontSize ?? 48,
fill: layer.color ?? 'white'
}
})
applyTransform(text, layer)
parent.addChild(text)
disposables.push(text)
return {disposables}
}
function renderImageLayer(
layer: Extract<Layer, {kind: 'image'}>,
parent: Container,
disposables: RenderableObject[]
) {
const texture = Texture.from(layer.frame)
const sprite = new Sprite(texture)
applyTransform(sprite, layer)
parent.addChild(sprite)
disposables.push(sprite, texture)
return {baseFrame: layer.frame, disposables}
}
function applyTransform(target: Sprite | Text, t: Transform = {}) {
if(t.x) target.x = t.x
if(t.y) target.y = t.y
if(t.scale) target.scale.set(t.scale)
if(t.opacity) target.alpha = t.opacity
if(t.anchor && 'anchor' in target) target.anchor.set(t.anchor)
}