@omnimedia/omnitool
Version:
open source video processing tools
485 lines (417 loc) • 13.2 kB
text/typescript
import {hex} from "@e280/stz"
import {TextStyleOptions} from "pixi.js"
import {Media} from "../parts/media.js"
import {Id, TimelineFile} from "../parts/basics.js"
import {FilterAction, FilterActions} from "../parts/filters.js"
import {filters, FilterParams, FilterType} from "../parts/filters.js"
import {Transcription} from "../../features/speech/transcribe/types.js"
import {Crop, FilterableItem, Item, ItemBase, Kind, VisualAnimatableItem} from "../parts/item.js"
import {animationPresets, makeAnimationPresets, visualAnimations} from "../parts/animations/registry.js"
import {TransitionAction, TransitionActions, transitions, TransitionName} from "../parts/transitions.js"
import {AnimationPreset, PresetAnimateAction, PresetAnimateActions, PresetAnimation, PresetOptions} from "../parts/animations/types.js"
import {CaptionAction, CaptionActions, captionDuration, CaptionOptions, CaptionPreset, captionPresets, CaptionSourceItem} from "../parts/captions.js"
import {Anim, AnimateAction, Interpolation, Keyframes, ScalarAnimation, TrackTransform, Transform, TransformAnimation, TransformOptions, Vec2, VisualAnimationInput, VisualAnimationValue, VisualAnimations} from "../types.js"
type VisualAnimateActions = {
[TKey in keyof VisualAnimations]-?: AnimateAction<TKey>
}
type ContainerInput = [label: string, ...items: Item.Any[]] | Item.Any[]
export class O {
constructor(public state: {timeline: TimelineFile}) {}
require<T extends Item.Any>(id: Id | undefined) {
if (id === undefined)
return undefined
const item = this.state.timeline.items.find(item => item.id === id)
return item as T | undefined
}
get timeline() {
return this.state.timeline
}
getId() {
return hex.toInteger(hex.random())
}
#mutate(fn: (project: TimelineFile) => TimelineFile) {
this.state.timeline = fn(this.state.timeline)
}
register(item: Item.Any) {
this.#mutate(state => ({
...state,
items: [...state.items, item]
}))
}
textStyle = (style: TextStyleOptions): Item.TextStyle => {
const item = {
id: this.getId(),
kind: Kind.TextStyle,
style,
} as Item.TextStyle
this.register(item)
return item
}
spatial = (transform?: Transform, crop?: Crop): Item.Spatial => {
const item: Item.Spatial = {
id: this.getId(),
kind: Kind.Spatial,
transform: transform ?? this.transform(),
crop,
}
this.register(item)
return item
}
#registerAnimation = (anims: VisualAnimations): Item.Animation => {
const item: Item.Animation = {
id: this.getId(),
kind: Kind.Animation,
anims,
}
this.register(item)
return item
}
#attachAnimation = <T extends VisualAnimatableItem>(item: T, animation: Item.Animation): T => {
const next = {
...item,
animationIds: [...(item.animationIds ?? []), animation.id]
}
this.set<T>(item.id, next as Partial<T>)
return next
}
anim = {
scalar: (terp: Interpolation, track: Keyframes): Anim<Keyframes> => ({terp, track}),
vec2: (terp: Interpolation, source: Keyframes<Vec2>) => {
const track = {x: [] as Keyframes, y: [] as Keyframes}
for (const [time, [x, y]] of source) {
track.x.push([time, x])
track.y.push([time, y])
}
return {terp, track}
},
transform: (terp: Interpolation, source: Keyframes<Transform>): Anim<TrackTransform> => {
const track: TrackTransform = {
position: {x: [], y: []},
scale: {x: [], y: []},
rotation: [],
}
for (const [time, [position, scale, rotation]] of source) {
track.position.x.push([time, position[0]])
track.position.y.push([time, position[1]])
track.scale.x.push([time, scale[0]])
track.scale.y.push([time, scale[1]])
track.rotation.push([time, rotation])
}
return {terp, track}
},
}
#animationPresets = makeAnimationPresets(
(terp, track) => ({terp, track}),
(terp, source) => this.anim.transform(terp, source),
options => this.transform(options),
)
#makeFilter = <TFilter extends FilterType>(type: TFilter) => {
const make = (params?: FilterParams<TFilter>) => {
const item: Item.Filter<TFilter> = {
id: this.getId(),
kind: Kind.Filter,
type,
params,
}
this.register(item)
return item
}
const action = (<T extends FilterableItem>(item: T, params?: FilterParams<TFilter>): T => {
const filter = make(params)
const next = {
...item,
filterIds: [...(item.filterIds ?? []), filter.id]
}
this.set<T>(item.id, next as Partial<T>)
return next
}) as FilterAction<TFilter>
action.make = make
return action
}
#makeFilters = (): FilterActions => {
const entries = Object.entries(filters)
.map(([name, filter]) => [name, this.#makeFilter(filter.type)])
return Object.fromEntries(entries) as FilterActions
}
filter = this.#makeFilters()
#makeAnimationValue = <TKey extends keyof VisualAnimations>(
key: TKey,
terp: Interpolation,
track: VisualAnimationInput<TKey>
): VisualAnimationValue<TKey> =>
(
key === "transform"
? this.anim.transform(terp, track as Keyframes<Transform>)
: this.anim.scalar(terp, track as Keyframes)
) as VisualAnimationValue<TKey>
#makeAnimate = <TKey extends keyof VisualAnimations>(key: TKey): AnimateAction<TKey> => {
const make = (terp: Interpolation, track: VisualAnimationInput<TKey>) =>
this.#registerAnimation({
[key]: this.#makeAnimationValue(key, terp, track)
} as Pick<VisualAnimations, TKey>)
const action = (<T extends VisualAnimatableItem>(
item: T,
terp: Interpolation,
track: VisualAnimationInput<TKey>
): T => {
const animation = make(terp, track)
return this.#attachAnimation(item, animation)
}) as AnimateAction<TKey>
action.make = make
return action
}
#makePresetAnimate = <TKey extends AnimationPreset>(key: TKey): PresetAnimateAction => {
const make = (options?: PresetOptions) => {
const preset = animationPresets[key]
const anim = this.#animationPresets[key](options as never) as PresetAnimation
return this.#registerAnimation(
preset.type === "motion"
? {transform: anim as TransformAnimation}
: {opacity: anim as ScalarAnimation}
)
}
const action = (<T extends VisualAnimatableItem>(item: T, options?: PresetOptions): T => {
const animation = make(options)
return this.#attachAnimation(item, animation)
}) as PresetAnimateAction
action.make = make
return action
}
#makeAnimateActions = (): VisualAnimateActions => {
const entries = Object.keys(visualAnimations)
.map(key => [key, this.#makeAnimate(key as keyof VisualAnimations)])
return Object.fromEntries(entries) as VisualAnimateActions
}
#makePresetAnimateActions = (): PresetAnimateActions => {
const entries = Object.keys(animationPresets)
.map(key => [key, this.#makePresetAnimate(key as AnimationPreset)])
return Object.fromEntries(entries) as PresetAnimateActions
}
animate = {
...this.#makeAnimateActions(),
presets: this.#makePresetAnimateActions(),
}
sequence = (...input: ContainerInput): Item.Sequence => {
const [first, ...rest] = input
const label = typeof first === "string" ? first : undefined
const items = (label ? rest : input) as Item.Any[]
const item = {
id: this.getId(),
kind: Kind.Sequence,
label,
childrenIds: items.map(item => item.id)
} as Item.Sequence
this.register(item)
return item
}
stack = (...input: ContainerInput): Item.Stack => {
const [first, ...rest] = input
const label = typeof first === "string" ? first : undefined
const items = (label ? rest : input) as Item.Any[]
const item = {
kind: Kind.Stack,
id: this.getId(),
label,
childrenIds: items.map(item => item.id)
} as Item.Stack
this.register(item)
return item
}
video = (
media: Media,
options?: {
start?: number,
duration?: number
label?: string
enabled?: boolean
}): Item.Video => {
if(!media.hasVideo)
throw new Error(`Video clip error: media "${media.datafile.filename}" has no video track.`)
const item: Item.Video = {
kind: Kind.Video,
id: this.getId(),
label: options?.label,
enabled: options?.enabled,
mediaHash: media.datafile.checksum.hash,
start: options?.start ?? 0,
duration: options?.duration ?? media.duration
}
this.register(item)
return item
}
image = (
media: Media,
options?: {
duration?: number
label?: string
enabled?: boolean
}): Item.Image => {
if(!media.isImage)
throw new Error(`Image error: media "${media.datafile.filename}" is not an image.`)
const item: Item.Image = {
kind: Kind.Image,
id: this.getId(),
label: options?.label,
enabled: options?.enabled,
mediaHash: media.datafile.checksum.hash,
duration: options?.duration ?? 2000
}
this.register(item)
return item
}
audio = (
media: Media,
options?: {
start?: number,
duration?: number,
gain?: number
label?: string
enabled?: boolean
}): Item.Audio => {
if(!media.hasAudio)
throw new Error(`Audio clip error: media "${media.datafile.filename}" has no audio track.`)
const item: Item.Audio = {
kind: Kind.Audio,
id: this.getId(),
label: options?.label,
enabled: options?.enabled,
mediaHash: media.datafile.checksum.hash,
start: options?.start ?? 0,
duration: options?.duration ?? media.duration,
gain: options?.gain ?? 1
}
this.register(item)
return item
}
text = (content: string, options?: {
duration?: number,
styles?: TextStyleOptions
label?: string
enabled?: boolean
}): Item.Text => {
const item = {
id: this.getId(),
label: options?.label,
enabled: options?.enabled,
content,
kind: Kind.Text,
duration: options?.duration ?? 2000
} as Item.Text
if(options?.styles)
item.styleId = this.textStyle(options.styles).id
this.register(item)
return item
}
#makeCaption = (
transcript: Transcription,
preset: CaptionPreset,
options?: CaptionOptions,
): Item.Caption => {
const start = options?.start ?? 0
const duration = options?.duration ?? Math.max(0, captionDuration(transcript, options) - start)
const item: Item.Caption = {
id: this.getId(),
kind: Kind.Caption,
label: options?.label,
enabled: options?.enabled,
transcript,
itemId: options?.itemId,
start,
duration,
maxChars: options?.maxChars,
maxDuration: options?.maxDuration,
maxSilence: options?.maxSilence,
}
item.styleId = this.textStyle(options?.styles ?? preset.styles).id
item.spatialId = this.spatial(this.transform(preset.transform)).id
this.register(item)
return item
}
#makeCaptionAction = (preset: CaptionPreset): CaptionAction => {
const make = (transcript: Transcription, options?: CaptionOptions) =>
this.#makeCaption(transcript, preset, options)
const action = ((item: CaptionSourceItem, transcript: Transcription, options?: CaptionOptions): Item.Stack => {
const caption = make(transcript, {
...options,
itemId: item.id,
start: options?.start ?? item.start,
duration: options?.duration ?? item.duration,
})
return this.stack(caption, item)
}) as CaptionAction
action.make = make
return action
}
#makeCaptionPresetActions = () => {
const entries = Object.entries(captionPresets)
.map(([name, preset]) => [name, this.#makeCaptionAction(preset)])
return Object.fromEntries(entries) as CaptionActions["presets"]
}
captions = Object.assign(
this.#makeCaptionAction(captionPresets.default),
{presets: this.#makeCaptionPresetActions()}
) as CaptionActions
gap = (duration: number, options?: ItemBase): Item.Gap => {
const item = {
id: this.getId(),
kind: Kind.Gap,
label: options?.label,
enabled: options?.enabled,
duration
} as Item.Gap
this.register(item)
return item
}
#makeTransition = (key: TransitionName): TransitionAction => {
return (duration: number, options?: ItemBase): Item.Transition => {
const item: Item.Transition = {
id: this.getId(),
kind: Kind.Transition,
label: options?.label,
enabled: options?.enabled,
name: transitions[key].name,
duration,
}
this.register(item)
return item
}
}
#makeTransitions = (): TransitionActions => {
const entries = Object.keys(transitions)
.map(key => [key, this.#makeTransition(key as TransitionName)])
return Object.fromEntries(entries) as TransitionActions
}
transition = this.#makeTransitions()
transform = (options?: TransformOptions): Transform => {
const position: Vec2 = [
options?.position?.[0] ?? 0,
options?.position?.[1] ?? 0
]
const scale: Vec2 = [
options?.scale?.[0] ?? 1,
options?.scale?.[1] ?? 1
]
const rotation = options?.rotation ?? 0
return [position, scale, rotation]
}
addChildren(parent: Item.Stack | Item.Sequence, ...items: Item.Any[]) {
this.#mutate(state => {
const parentItem = state.items.find(({id}) => id === parent.id) as Item.Stack
parentItem.childrenIds.push(...items.map(item => item.id))
return state
})
}
set = <K extends Item.Any>(
id: Id,
partial: Partial<K>
) => {
this.#mutate(project => ({
...project,
items: project.items.map(item =>
item.id === id
? { ...item, ...partial }
: item
)
}))
}
}