/** * What the Composition tab does to items and clips (#417), as plain functions on lists so a test can * check them. Items of a track may overlap – both live there, as on two tracks –, clips of a track * do not: a clip's edit that would make them is held short, a dropped clip lands in the gap it falls * into. */ import { videoLoopsOf } from '../properties/videos'; import type { CompositionAsset, CompositionAudioKind, CompositionClip, CompositionItem } from './Asset'; import { audioRoundSeconds, audioSeconds, compositionFps, DEFAULT_ITEM_SECONDS, fittedLength, itemEnd, itemFades, itemVideo, } from './composition'; import { findAsset, type Project } from './Project'; import { findSound } from './sound'; import { findVideo, videoLengthOf } from './video'; /** A stretch of a track, in frames. */ export interface Span { start: number; length: number; } interface Placed { id: string; track: string; start: number; } /** * Where something `length` frames long lands when dropped at `at`: there, or right after whatever * of `taken` covers `at`, and cut short where the next begins. Null where not a frame is free. */ export function droppedSpan(taken: readonly Span[], at: number, length: number): Span | null { let start = Math.max(0, Math.round(at)); for (const span of [...taken].sort((a, b) => a.start - b.start)) if (span.start <= start && start < span.start + span.length) start = span.start + span.length; const next = Math.min(...taken.filter((s) => s.start >= start).map((s) => s.start)); const room = Number.isFinite(next) ? next - start : Infinity; return room >= 1 ? { start, length: Math.min(Math.max(1, length), room) } : null; } /** The stretches the clips of a track cover, all but `except`. */ export function clipSpans( clips: readonly CompositionClip[], track: string, lengthOf: (clip: CompositionClip) => number, except: ReadonlySet = new Set(), ): Span[] { return clips .filter((c) => c.track === track && !except.has(c.id)) .map((c) => ({ start: c.start, length: Math.max(1, lengthOf(c)) })); } /** Whether no two of `spans` on the same track share a frame. */ function apart(list: readonly T[], lengthOf: (t: T) => number): boolean { const byTrack = new Map(); for (const t of list) byTrack.set(t.track, [...(byTrack.get(t.track) ?? []), t]); for (const on of byTrack.values()) { const sorted = [...on].sort((a, b) => a.start - b.start); for (let i = 1; i < sorted.length; i++) if (sorted[i]!.start < sorted[i - 1]!.start + Math.max(1, lengthOf(sorted[i - 1]!))) return false; } return true; } /** * A move of the picked ones held so that none starts before 0 or leaves the tracks there are: what * the pointer asks for, as far as it goes. */ export function clampedMove( list: readonly Placed[], picked: ReadonlySet, trackIds: readonly string[], frames: number, tracks: number, ): { frames: number; tracks: number } { const chosen = list.filter((t) => picked.has(t.id)); if (chosen.length === 0) return { frames: 0, tracks: 0 }; const rows = chosen.map((t) => trackIds.indexOf(t.track)); return { // `|| 0`: no -0 for one at frame 0. frames: Math.max(frames, -Math.min(...chosen.map((t) => t.start))) || 0, tracks: Math.max(-Math.min(...rows), Math.min(trackIds.length - 1 - Math.max(...rows), tracks)) || 0, }; } function moved( list: readonly T[], picked: ReadonlySet, trackIds: readonly string[], frames: number, tracks: number, ): T[] | null { const out: T[] = []; for (const t of list) { if (!picked.has(t.id)) { out.push(t); continue; } const track = trackIds[trackIds.indexOf(t.track) + tracks]; if (track === undefined || t.start + frames < 0) return null; out.push({ ...t, start: t.start + frames, track }); } return out; } /** The picked items moved by `frames` and `tracks`; null where one would start before 0 or leave the tracks. */ export function movedItems( items: readonly CompositionItem[], picked: ReadonlySet, trackIds: readonly string[], frames: number, tracks: number, ): CompositionItem[] | null { return moved(items, picked, trackIds, frames, tracks); } /** * The clips that belong to the picked items: a clip belongs to the item lying on its track at its * first frame, of two there the one in front – the later start, at the same start the later in the * list. A clip that begins before an item and only reaches into it belongs to the one before. An * audio item (#418) has nothing a clip could move, and so has no clips. */ export function carriedClips( items: readonly CompositionItem[], clips: readonly CompositionClip[], picked: ReadonlySet, ): Set { const carried = new Set(); if (picked.size === 0) return carried; for (const clip of clips) { let owner: CompositionItem | undefined; for (const item of items) if (!item.audio && item.track === clip.track && item.start <= clip.start && clip.start < itemEnd(item)) if (!owner || item.start >= owner.start) owner = item; if (owner && picked.has(owner.id)) carried.add(clip.id); } return carried; } /** * The picked items moved, their clips with them unless `alone`; null where an item would start * before 0 or leave the tracks, or a clip would come to lie over another. */ export function movedWithClips( items: readonly CompositionItem[], clips: readonly CompositionClip[], picked: ReadonlySet, trackIds: readonly string[], frames: number, tracks: number, lengthOf: (clip: CompositionClip) => number, alone = false, ): { items: CompositionItem[]; clips: CompositionClip[] } | null { const outItems = movedItems(items, picked, trackIds, frames, tracks); const carried = alone ? new Set() : carriedClips(items, clips, picked); if (!outItems) return null; if (carried.size === 0) return { items: outItems, clips: [...clips] }; const outClips = movedClips(clips, carried, trackIds, frames, tracks, lengthOf); return outClips && { items: outItems, clips: outClips }; } /** * Where a move asked to go `wanted` lands when only places that `fit` will do: on the way back to * `last`, on the track asked for, else on the one it was on; `last` itself when nothing fits. */ export function heldMove( wanted: { frames: number; tracks: number }, last: { frames: number; tracks: number }, fits: (frames: number, tracks: number) => boolean, ): { frames: number; tracks: number } { for (const on of [wanted.tracks, last.tracks]) { const step = Math.sign(last.frames - wanted.frames); for (let at = wanted.frames; ; at += step) { if (fits(at, on)) return { frames: at, tracks: on }; if (at === last.frames) break; } } return last; } /** The picked clips moved like items; null, too, where one would come to lie over another. */ export function movedClips( clips: readonly CompositionClip[], picked: ReadonlySet, trackIds: readonly string[], frames: number, tracks: number, lengthOf: (clip: CompositionClip) => number, ): CompositionClip[] | null { const out = moved(clips, picked, trackIds, frames, tracks); return out && apart(out, lengthOf) ? out : null; } /** * Copied clips placed as `placing` places what is pasted, then moved on frame by frame as long as * one would lie over a clip of `clips` or over another of the copy – clips of a track never overlap. */ export function pastedClips( copied: readonly CompositionClip[], clips: readonly CompositionClip[], ids: ReadonlyMap, at: number, trackIds: readonly string[], lengthOf: (clip: CompositionClip) => number, row?: number, ): CompositionClip[] { const place = placing(copied, at, trackIds, row); const placed = copied.map((c) => place(c, ids)); const by = framesToRoom(placed, clips, lengthOf); return placed.map((c) => ({ ...c, start: c.start + by })); } /** * Copied items placed as `placing` places them, the copied clips that came with them moved by as * much; then both moved on frame by frame as long as one of the clips would lie over a clip of * `clips` – clips of a track never overlap. */ export function pastedItemsWithClips( items: readonly CompositionItem[], copied: readonly CompositionClip[], clips: readonly CompositionClip[], ids: ReadonlyMap, at: number, trackIds: readonly string[], lengthOf: (clip: CompositionClip) => number, row?: number, ): { items: CompositionItem[]; clips: CompositionClip[] } { const place = placing(items, at, trackIds, row); const placedItems = items.map((i) => place(i, ids)); const placedClips = copied.map((c) => place(c, ids)); const by = framesToRoom(placedClips, clips, lengthOf); return { items: placedItems.map((i) => ({ ...i, start: i.start + by })), clips: placedClips.map((c) => ({ ...c, start: c.start + by })), }; } /** The frames `placed` moves on until none of it lies over a clip of `clips` or over another of it. */ function framesToRoom( placed: readonly CompositionClip[], clips: readonly CompositionClip[], lengthOf: (clip: CompositionClip) => number, ): number { // Past the end of every clip there is room, so the search ends there at the latest. const last = Math.max(0, ...clips.map((c) => c.start + Math.max(1, lengthOf(c)))); for (let by = 0; ; by++) { const moved = placed.map((c) => ({ ...c, start: c.start + by })); if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last)) return by; } } /** * How a paste moves what was copied, under the ids `ids` gives it: the earliest of `items` to `at`, * on their tracks, or, given `row`, the topmost on that track and the others as far below as they * were, as far as there are tracks – a song's paste. One whose track is gone counts as on the first. */ function placing( items: readonly Placed[], at: number, trackIds: readonly string[], row?: number, ): (t: T, ids: ReadonlyMap) => T { if (items.length === 0) return (t) => t; const first = Math.min(...items.map((i) => i.start)); const index = new Map(trackIds.map((id, i) => [id, i])); const rows = items.map((i) => index.get(i.track) ?? 0); const top = Math.min(...rows); const bottom = Math.max(...rows); const shift = row === undefined ? 0 : Math.max(-top, Math.min(trackIds.length - 1 - bottom, row - top)); return (t, ids) => ({ ...t, id: ids.get(t.id) ?? t.id, start: Math.max(0, Math.round(at)) + (t.start - first), track: trackIds[(index.get(t.track) ?? 0) + shift] ?? t.track, }); } /** The item with its end edge dragged to make it `wanted` frames long, at least one; its length is its own from then on. */ export function resizedItem(item: CompositionItem, wanted: number): CompositionItem { const { fitMedia: _, ...rest } = item; return { ...rest, length: Math.max(1, Math.round(wanted)) }; } /** * The item with its start edge dragged to `wanted`: never onto its end or before 0. What it shows * stays where it was in time – the video starts that much later or sooner –, and pulled out before * the video's beginning the item shows its first picture until the video starts. */ export function trimmedItem( item: CompositionItem, wanted: number, fps: number, video: boolean, ): CompositionItem { const start = Math.max(0, Math.min(Math.round(wanted), itemEnd(item) - 1)); const by = start - item.start; const { fitMedia: _, ...rest } = item; const offset = video ? Math.round((item.offset + by / fps) * 1e6) / 1e6 || 0 : item.offset; return { ...rest, start, length: item.length - by, offset }; } /** * The item with the handle of its fade in or out dragged to `frame`: the fade from its edge to there, * none where that is at or past the edge, no further than the other fade lets it. A fade of 0 is left * out of the item. */ export function fadedItem(item: CompositionItem, end: 'in' | 'out', frame: number): CompositionItem { const fades = itemFades(item); const asked = end === 'in' ? frame - item.start : itemEnd(item) - frame; const room = item.length - (end === 'in' ? fades.fadeOut : fades.fadeIn); const frames = Math.max(0, Math.min(room, Math.round(asked))); const key = end === 'in' ? 'fadeIn' : 'fadeOut'; const { [key]: _, ...rest } = item; return frames > 0 ? { ...rest, [key]: frames } : rest; } /** * Where an item's video lies in the composition, in frames: where it begins, how long its first pass * is, whether it goes round, and how long every pass after the first is where that differs – a song * goes round its loop only. */ export interface ItemMedia { begins: number; length: number; loops: boolean; round?: number; } /** * The video of an item as it lies on its track, or what an audio item plays (#418) – a sound and a * pattern go round, a song to the end of its loop and then round it –; null without one, or while * its length is not known. */ export function itemMedia(project: Project, comp: CompositionAsset, item: CompositionItem): ItemMedia | null { const video = item.audio ? null : itemVideo(project, comp, item); const seconds = item.audio ? audioSeconds(project, item.audio) : video ? videoLengthOf(video) : 0; if (!(seconds > 0)) return null; const fps = compositionFps(comp); const media = { begins: item.start - item.offset * fps, length: seconds * fps, loops: item.audio ? true : videoLoopsOf(video!.asset.props), }; const round = item.audio?.kind === 'song' ? audioRoundSeconds(project, item.audio) * fps : 0; return round > 0 ? { ...media, round } : media; } /** The frames from `from` to `to` where the video begins or a pass of it ends. */ export function mediaEdges(media: ItemMedia, from: number, to: number): number[] { const edges = media.begins >= from && media.begins <= to ? [media.begins] : []; const round = media.round ?? media.length; const first = media.begins + media.length; for (let pass = Math.max(0, Math.ceil((from - first) / round)); pass === 0 || media.loops; pass++) { const end = first + pass * round; if (end > to) break; if (end >= from) edges.push(end); } return edges; } /** Half a frame: closer than this an edge counts as on the video's. */ const ON_EDGE = 0.5; /** * What the timeline marks on an item wearing a video: where inside it the video begins or a pass * ends (`seams`), and which of its ends cut into the video – starting after its beginning, ending * before its end. */ export function mediaMarks( item: CompositionItem, media: ItemMedia, ): { seams: number[]; cutStart: boolean; cutEnd: boolean } { return { seams: mediaEdges(media, item.start + ON_EDGE, itemEnd(item) - ON_EDGE), cutStart: item.start > media.begins + ON_EDGE, cutEnd: itemEnd(item) < media.begins + media.length - ON_EDGE, }; } /** A frame pulled onto the nearest edge of the video within `reach` frames; else as it is. */ export function snappedToMedia(frame: number, media: ItemMedia | null, reach: number): number { const edges = media ? mediaEdges(media, frame - reach, frame + reach) : []; return edges.reduce( (best, edge) => (Math.abs(edge - frame) < Math.abs(best - frame) ? edge : best), edges[0] ?? frame, ); } /** What an asset is laid on a track as when it is to be heard (#418): a sound, a clip of one, a pattern or a song. */ export function audioKindOf(project: Project, assetId: string): CompositionAudioKind | null { const asset = findAsset(project, assetId); if (asset?.type === 'pattern' || asset?.type === 'song') return asset.type; return findSound(project, assetId) ? 'sound' : null; } /** * How long an item of the asset `id` names is when it is dropped in: as long as the video or clip of * one it is (#419, #425) or its sprite wears, or what it plays as an audio item (#418); else 3 s. */ export function droppedLength(project: Project, comp: CompositionAsset, id: string): number { const kind = audioKindOf(project, id); if (kind) return fittedLength(project, comp, { id: '', track: '', start: 0, length: 1, offset: 0, audio: { kind, asset: id, volume: 0, bus: '' }, }); const asset = findAsset(project, id); const texture = asset?.type === 'sprite' ? asset.props.texture : id; const video = typeof texture === 'string' ? findVideo(project, texture) : null; const fps = compositionFps(comp); const seconds = video ? videoLengthOf(video) : 0; return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps); } /** What an item wears that its length follows: the video or clip's id, what an audio item plays, '' for none. */ export function mediaKey(project: Project, comp: CompositionAsset, item: CompositionItem): string { if (item.audio) return item.audio.asset; const video = itemVideo(project, comp, item); return video ? (video.clip?.id ?? video.asset.id) : ''; }