import { FROM_ASSET_BUS, MASTER_MIX, mixBusName, pickedComposition } from '../properties/buses'; import { videoLoopsOf, videoSpanOf, videoVolumeOf } from '../properties/videos'; import type { VideoAsset, VideoClip } from './Asset'; import { findAsset, findAssetOf, type Project } from './Project'; import { clipsOf } from './sound'; import { storedFilePattern } from './storedFile'; const FILE_PATTERN = storedFilePattern('mp4'); /** * How long a run waits for a driven video (#404) it shows to have its first picture before it goes * anyway: at its start, and a composition (#417) at each item that brings one in. */ export const VIDEO_WAIT_MS = 3000; export function isVideoFile(name: string): boolean { return FILE_PATTERN.test(name); } /** What a texture field names when it names a video: the whole video, or one of its clips. */ export interface VideoRef { asset: VideoAsset; clip: VideoClip | null; } /** A video or a clip of one, by id; clip ids share the generator with asset ids, as frames do. */ export function findVideo(project: Project, id: string): VideoRef | null { if (id === '') return null; const asset = findAsset(project, id); if (asset) return asset.type === 'video' ? { asset, clip: null } : null; for (const video of project.assets) { if (video.type !== 'video') continue; const clip = clipsOf(video).find((c) => c.id === id); if (clip) return { asset: video, clip }; } return null; } /** Every id that means "this video": the asset itself and each of its clips. */ export function videoRefIds(asset: VideoAsset): string[] { return [asset.id, ...clipsOf(asset).map((c) => c.id)]; } /** * The stretch a clip plays, inside the video: a start before its end, an end of 0 or past the * video's length being its end. Without a length (not known yet) the asked values stand. */ export function videoSpan(duration: number, start: number, end: number): { start: number; end: number } { const length = duration > 0 ? duration : Infinity; const from = Math.min(Math.max(0, start), Number.isFinite(length) ? Math.max(0, length - 0.001) : start); const to = end > 0 ? Math.min(end, length) : length; return { start: from, end: to > from ? to : length }; } /** How a video is played: which file, which stretch of it, whether it repeats, how loud. */ export interface VideoPlay { file: string; start: number; /** Seconds; Infinity for the end of the video, wherever that is. */ end: number; loop: boolean; volume: number; /** The bus of the mix its sound goes into, where a sprite picked one; along its routing where left out. */ bus?: string; } export function videoPlayOf(ref: VideoRef): VideoPlay { const asked = ref.clip ? videoSpanOf(ref.clip.props) : { start: 0, end: 0 }; const span = videoSpan(ref.asset.duration, asked.start, asked.end); return { file: ref.asset.file, start: span.start, end: ref.asset.duration > 0 && span.end >= ref.asset.duration ? Infinity : span.end, loop: videoLoopsOf(ref.asset.props), volume: videoVolumeOf(ref.asset.props), }; } /** Seconds a video or clip lasts; 0 while the video's length is not known yet. */ export function videoLengthOf(ref: VideoRef): number { const play = videoPlayOf(ref); const end = Number.isFinite(play.end) ? play.end : ref.asset.duration; return end > play.start ? end - play.start : 0; } /** A key of a `Video time` row: seconds into the clip on a frame of the animation. */ export interface FrameValue { frame: number; value: number; } /** * Where a driven video playing at 1× from `value` seconds on `frame` reaches the end of a clip * `length` seconds long (#407): the last whole frame at `fps` before it, and the clip's time there. * Null when not one frame fits. */ export function realTimeEnd(frame: number, value: number, length: number, fps: number): FrameValue | null { if (!(length > 0) || !(fps > 0) || !Number.isFinite(value)) return null; const frames = Math.floor((length - value) * fps + 1e-6); if (frames < 1) return null; return { frame: frame + frames, value: Math.round((value + frames / fps) * 1e6) / 1e6 }; } const PREFIX = 'video:'; /** * The name a playing video goes by where a picture is named by `TextureRef.file` (see * `generated.ts`): `video:#~~~`, and `!` where its sound goes * into a bus a sprite picked. No uploaded image looks like it, and one key is one player: two sprites * wearing the same clip show the same picture. */ export function videoKey(play: VideoPlay): string { const end = Number.isFinite(play.end) ? play.end : 0; const bus = play.bus ? `!${play.bus}` : ''; return `${PREFIX}${play.file}#${play.start}~${end}~${play.loop ? 1 : 0}~${play.volume}${bus}`; } /** * The bus of the mix the Video bus of a sprite names: null along the routing of its video, the * composition's for one it picked for a sprite of its items. A bus that is gone is the master. */ export function videoMixBus(project: Project, picked: string): string | null { if (picked === '' || picked === FROM_ASSET_BUS) return null; const comp = pickedComposition(picked); if (comp !== null) return findAssetOf(project, comp, 'composition') ? mixBusName(comp) : null; return findAssetOf(project, picked, 'bus') ? mixBusName(picked) : MASTER_MIX; } /** The key of the same video heard on `bus` (#418); along its routing for null. */ export function routedVideoKey(key: string, bus: string | null): string { const play = parseVideoKey(key); if (!play || isDrivenVideoKey(key)) return key; const { bus: _, ...rest } = play; return videoKey(bus ? { ...rest, bus } : rest); } /** * The key of a player one wearer drives on its own (#404): two sprites driving the same clip must * not fight over one `