A clip of a video could not be laid on a composition track at all, and neither could a clip of a sound, although #418 and the wiki said so. The only place a drag starts is the asset panel, and it listed assets, not clips. The texture label of an item playing a video clip was dead too, because the reference lookup did not know video clips. - The asset panel gives a sound or video with clips a chevron of its own. It unfolds one row per clip and starts closed. - A clip row drags like an asset. One click shows the clip in the inspector without leaving the tab (`EditorState.glance`). A double click opens its sound or video with the clip picked. - A video clip dropped on a composition track becomes an item as long as the clip. It wears the sprite of its whole video, so a composition keeps one sprite per video, and the clip is a texture override on the object. Sprite, item and override are one undo step (`droppedPicture`). - A sound clip becomes an audio item as long as the clip. The song tab takes sound clips on the same way, through `findSound`. - `referencedTarget` lets a reference label link to the clip itself. One click pings the clip row, a double click opens the asset with the clip picked. The Plays row of an audio item reads "Sound / Clip". - `clipOwner` in `sound.ts` finds the sound or video a clip id belongs to. `activateAssetOrClip` uses it for the double clicks. Found on the way: the Blip of the e2e seed is only 0.25 s long, so a clip of 0.2 to 0.7 s on it is cut to one frame. 69s takes the Drone (0.75 s) instead. Tests: - tsc, eslint, Prettier clean - Unit on stein2 (composition, references, sound, video, editor): 94 files, 1238 tests green - e2e 69 on melektaus-atx: ALL PASSED, 25 checks, 2 of them new (69s) - Gate on melektaus-atx with the fix for 68k on top: CHECK EXIT 0, 313 files, 4841 tests - Counter checks, all red: no clip rows in e2e 69 (no drag starts), no clip override in the unit test, the link aimed at the asset instead of the clip - Not covered by its own e2e check: sound clips on the song tab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
432 lines
17 KiB
TypeScript
432 lines
17 KiB
TypeScript
/**
|
||
* 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<string> = 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<T extends Placed>(list: readonly T[], lengthOf: (t: T) => number): boolean {
|
||
const byTrack = new Map<string, T[]>();
|
||
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<string>,
|
||
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<T extends Placed>(
|
||
list: readonly T[],
|
||
picked: ReadonlySet<string>,
|
||
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<string>,
|
||
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<string>,
|
||
): Set<string> {
|
||
const carried = new Set<string>();
|
||
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<string>,
|
||
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<string>() : 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<string>,
|
||
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<string, string>,
|
||
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<string, string>,
|
||
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 extends Placed>(t: T, ids: ReadonlyMap<string, string>) => 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) : '';
|
||
}
|