Files
feuerwerk/src/core/model/compositionEdit.ts
T
adminandClaude Opus 5.5 406839baea Drag clips of videos and sounds straight onto a track (#425)
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>
2026-10-15 08:01:04 +02:00

432 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) : '';
}