@omnimedia/omnitool
Version:
open source video processing tools
372 lines (330 loc) • 10.4 kB
text/typescript
import {pub} from "@e280/stz"
import {
autoDetectRenderer,
ColorMatrixFilter,
Container,
FederatedPointerEvent,
Filter,
Graphics,
RenderTexture,
Renderer,
Sprite,
Text,
Texture
} from "pixi.js"
import * as PixiFilters from "pixi-filters"
import {Id} from "../../timeline/index.js"
import {Crop} from "../../timeline/parts/item.js"
import {findPixiFilter} from "../utils/find-pixi-filter.js"
import {Mat6, mat6ToMatrix} from "../../timeline/utils/matrix.js"
import {makeTransition} from "../../features/transition/transition.js"
import {Composition, FilterSpec, ImageLayer, Layer, TransitionLayer} from "../fns/schematic.js"
export class Compositor {
onPointerDown = pub<[{event: FederatedPointerEvent, id: Id, object: Container}]>()
onPointerMove = pub<[{event: FederatedPointerEvent, id: Id, object: Container}]>()
onPointerUp = pub<[{event: FederatedPointerEvent, id: Id, object: Container}]>()
onDispose = pub<[{id: Id, object: Container}]>()
static async setup() {
const renderer = await autoDetectRenderer({
width: 1920,
height: 1080,
preference: "webgl", // webgl and webgl2 causes memory leaks on chrome
background: "black",
preferWebGLVersion: 2
})
const stage = new Container()
stage.interactive = true
return new this({renderer, stage})
}
constructor(public pixi: {renderer: Renderer, stage: Container}) {}
#transitions = new Map<Id, {
transition: ReturnType<typeof makeTransition>
from: RenderTexture
to: RenderTexture
}>()
// objects rendered for current Composition
#activeObjects = new Map<number, {sprite: Container, dispose: () => void}>()
#cropMasks = new WeakMap<Container, Graphics>()
resize(width: number, height: number) {
this.pixi.renderer.resize(width, height)
}
async composite(
composition: Composition,
) {
const {stage, renderer} = this.pixi
const activeIds = this.#collectIds(composition)
this.#cleanup(activeIds)
this.#cleanupTransitions(activeIds)
const {dispose} = await this.#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: 0,
duration: 0,
})
dispose()
return frame
}
/**
* get object for current Composition
* */
getActiveObject(id: Id) {
return this.#activeObjects.get(id)?.sprite
}
async #renderLayer(
layer: Layer | Composition,
parent: Container,
) {
if (Array.isArray(layer)) {
const disposers: (() => void)[] = []
for (const child of [...layer].reverse()) {
const result = await this.#renderLayer(child, parent)
disposers.push(result.dispose)
}
return {dispose: () => disposers.forEach(d => d())}
}
switch (layer.kind) {
case 'text':
return this.#renderTextLayer(layer, parent)
case 'image':
return this.#renderImageLayer(layer, parent)
case 'transition':
return this.#renderTransitionLayer(layer, parent)
case 'gap': {
this.pixi?.renderer.clear()
return {dispose: () => {}}
}
default:
console.warn('Unknown layer kind', (layer as any).kind)
return {dispose: () => {}}
}
}
#renderTextLayer(
layer: Extract<Layer, {kind: 'text'}>,
parent: Container,
) {
const sprite = this.#findOrCreate<Text>(layer)!
sprite.text = layer.content
if (layer.style)
sprite.style = layer.style
this.#applyTransform(sprite, layer.matrix)
this.#applyAlpha(sprite, layer.alpha)
this.#applyCrop(sprite, layer.crop)
this.#applyFilters(sprite, layer.filters)
parent.addChild(sprite)
return {
dispose: () => {}
}
}
#renderImageLayer(
layer: ImageLayer,
parent: Container,
) {
const sprite = this.#findOrCreate<Sprite>(layer)!
if (sprite.texture && sprite.texture !== Texture.EMPTY) {
sprite.texture.destroy(true)
}
this.#applyImageLayer(sprite, layer)
parent.addChild(sprite)
return {
dispose: () => {
layer.frame.close()
}
}
}
#renderTransitionLayer(
{id, from, to, progress, name}: TransitionLayer,
parent: Container,
) {
const transition = this.#transitions.get(id) ?? this.#createTransition(id, name)
const {renderer} = this.pixi
transition.from.resize(renderer.width, renderer.height)
transition.to.resize(renderer.width, renderer.height)
this.#renderImageToTexture(from, transition.from)
this.#renderImageToTexture(to, transition.to)
const texture = transition.transition.render({
from: transition.from,
to: transition.to,
progress,
width: renderer.width,
height: renderer.height
})
const sprite = new Sprite(texture)
parent.addChild(sprite)
return {dispose: () => sprite.destroy(false)}
}
#createTransition(id: Id, name: TransitionLayer['name']) {
const transition = {
transition: makeTransition({name, renderer: this.pixi.renderer}),
from: RenderTexture.create({width: 1, height: 1}),
to: RenderTexture.create({width: 1, height: 1})
}
this.#transitions.set(id, transition)
return transition
}
#renderImageToTexture(layer: ImageLayer, target: RenderTexture) {
const sprite = new Sprite()
const texture = this.#applyImageLayer(sprite, layer)
this.pixi.renderer.render({container: sprite, target, clear: true})
sprite.destroy({children: true})
texture.destroy(true)
layer.frame.close()
}
#applyImageLayer(sprite: Sprite, layer: ImageLayer) {
const texture = Texture.from(layer.frame)
sprite.texture = texture
this.#applyTransform(sprite, layer.matrix)
this.#applyAlpha(sprite, layer.alpha)
this.#applyCrop(sprite, layer.crop)
this.#applyFilters(sprite, layer.filters)
return texture
}
#applyTransform(target: Sprite | Text, worldMatrix?: Mat6) {
if (!worldMatrix) return
const mx = mat6ToMatrix(worldMatrix)
target.setFromMatrix(mx)
}
#applyAlpha(target: Container, alpha?: number) {
target.alpha = alpha ?? 1
}
#applyCrop(target: Container, crop?: Crop) {
const existing = this.#cropMasks.get(target)
if (existing) {
if (target.mask === existing)
target.mask = null
existing.removeFromParent()
existing.clear()
}
if (!crop || crop.every(value => value === 0))
return
const [top, right, bottom, left] = crop
const activeMask = target.mask
target.mask = null
const bounds = target.getLocalBounds()
target.mask = activeMask
const x = bounds.x + bounds.width * left
const y = bounds.y + bounds.height * top
const width = bounds.width * Math.max(0, 1 - left - right)
const height = bounds.height * Math.max(0, 1 - top - bottom)
const mask = existing ?? new Graphics()
mask.clear()
mask.beginFill(0xffffff)
mask.drawRect(x, y, width, height)
mask.endFill()
target.addChild(mask)
target.mask = mask
this.#cropMasks.set(target, mask)
}
#applyFilters(target: Container, specs: FilterSpec[] | undefined) {
if (!specs?.length) {
target.filters = null
return
}
target.filters = specs
.map((spec): Filter | undefined => {
switch (spec.type) {
case "ColorMatrixFilter": {
const {matrix, ...params} = spec.params ?? {}
const filter = new ColorMatrixFilter(params)
if (matrix)
filter.matrix = matrix
return filter
}
case "EmbossFilter":
return new PixiFilters.EmbossFilter(spec.params?.strength)
case "PixelateFilter":
return new PixiFilters.PixelateFilter(spec.params?.size)
default: {
const PixiFilter = findPixiFilter(spec.type)
return PixiFilter ? new PixiFilter(spec.params) : undefined
}
}
})
.filter((filter): filter is Filter => !!filter)
}
#findOrCreate<T = Container>(layer: Layer) {
const object = this.#activeObjects.get(layer.id)
if(!object) {
switch (layer.kind) {
case 'text': {
const text = new Text({
text: layer.content,
style: layer.style
})
text.eventMode = "static"
const down = (event: FederatedPointerEvent) => this.onPointerDown.publish({event, id: layer.id, object: text})
const up = (event: FederatedPointerEvent) => this.onPointerUp.publish({event, id: layer.id, object: text})
const move = (event: FederatedPointerEvent) => this.onPointerUp.publish({event, id: layer.id, object: text})
text.on("pointerdown", down)
text.on("pointerup", up)
text.on("pointermove", move)
return this.#activeObjects
.set(layer.id, {
sprite: text,
dispose: () => {
this.onDispose.publish({id: layer.id, object: text})
text.off("pointermove", move)
text.off("pointerdown", down)
text.off("pointerup", up)
}
})
.get(layer.id)?.sprite as T
}
case 'image': {
const sprite = new Sprite()
sprite.eventMode = "static"
const down = (event: FederatedPointerEvent) => this.onPointerDown.publish({event, id: layer.id, object: sprite})
const up = (event: FederatedPointerEvent) => this.onPointerUp.publish({event, id: layer.id, object: sprite})
const move = (event: FederatedPointerEvent) => this.onPointerUp.publish({event, id: layer.id, object: sprite})
sprite.on("pointerdown", down)
sprite.on("pointermove", move)
sprite.on("pointerup", up)
return this.#activeObjects
.set(layer.id, {
sprite,
dispose: () => {
this.onDispose.publish({id: layer.id, object: sprite})
sprite.off("pointerdown", down)
sprite.off("pointermove", move)
sprite.off("pointerup", up)
}
})
.get(layer.id)?.sprite as T
}
}
} else return object.sprite as T
}
#collectIds(layers: Layer | Composition): Set<number> {
const result = new Set<number>()
const traverse = (node: Layer | Composition) => {
if (Array.isArray(node)) {
for (const child of node) traverse(child)
} else {
result.add(node.id)
}
}
traverse(layers)
return result
}
#cleanup(activeIds: Set<number>) {
for (const id of this.#activeObjects.keys()) {
if (!activeIds.has(id)) {
const {sprite, dispose} = this.#activeObjects.get(id)!
dispose()
sprite.destroy(true)
this.#activeObjects.delete(id)
}
}
}
#cleanupTransitions(activeIds: Set<number>) {
for (const [id, cached] of this.#transitions) {
if (!activeIds.has(id)) {
cached.transition.dispose()
cached.from.destroy(true)
cached.to.destroy(true)
this.#transitions.delete(id)
}
}
}
}