UNPKG

@omnimedia/omnitool

Version:

open source video processing tools

235 lines (202 loc) 6.19 kB
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) }