Let a composition item's clips move along with it

A clip only hung at its track and frame and worked on whatever item it
overlapped. Moving an item left its clips behind, so they started to
animate the next item that came along.

- A clip belongs to the item that lies on its track at its start frame.
  With two there, it belongs to the one in front. A clip that only
  reaches into an item stays with the item before. The model does not
  change, the rule is worked out each time.
- Dragging an item, also onto another track, and typing its Start in
  the inspector take its clips along in the same undo step.
- Where a carried clip would land on a foreign one, the whole move
  holds at the last place where everything fits, as dragging clips
  already does.
- Pressing Alt during the drag moves the item alone. Alt on the press
  already starts the clip band since 0648853.
- Copying an item takes its clips along with new ids. On a collision
  items and clips slide on together.
- Deleting an item deletes its clips. Trimming and dragging its edges
  leave them where they are.

Tests:
- tsc, eslint, Prettier clean
- Unit composition-edit 36 green (6 new), composition-sprites 11
- Without the rule 5 of the 6 new tests are red
- e2e 69 ALL PASSED, 18 checks (69n new)
- Gate on melektaus-atx green: 305 files, 4736 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 08:03:35 +02:00
1 parent 480db9c4e4
commit 126a5a3a62
8 files changed
+365 -62

No files matched your search

+17 -6
View File
@@ -3,14 +3,16 @@
* are written whole once the pointer lets go. An item comes and goes together with its object.
*/
import type { CompositionAsset, CompositionClip, CompositionItem, CompositionTrack } from '../model/Asset';
import { newId } from '../ids';
import {
clipLength,
fittedLength,
heldCompositionClips,
heldCompositionItems,
heldCompositionTracks,
} from '../model/composition';
import type { ObjectClip } from '../model/clipboard';
import { mediaKey, pastedItems } from '../model/compositionEdit';
import { carriedClips, mediaKey, pastedItemsWithClips } from '../model/compositionEdit';
import type { Project } from '../model/Project';
import type { Command } from './Command';
import { oneStep } from './CompoundCommand';
@@ -84,7 +86,8 @@ export function addCompositionItem(
/**
* Copied items put back into a composition: their objects where the copied ones stood, the items
* timed as `pastedItems` says. Null when the copy holds anything but items – a scene's objects have
* timed as `pastedItems` says, the clips that came with them moved by as much – all moved on together
* where one of those clips would lie over a clip already there. Null when the copy holds anything but items – a scene's objects have
* no time to land at. `itemIds` are the new items', to pick them afterwards.
*/
export function pasteCompositionItems(
@@ -99,21 +102,28 @@ export function pasteCompositionItems(
const comp = requireComposition(project, assetId);
const paste = new PasteObjectsCommand(assetId, clip, {}, { x: 0, y: 0 });
const ids = new Map(clip.roots.map((r, n) => [r.id, paste.rootIds[n]!]));
const items = pastedItems(
for (const c of clip.clips ?? []) ids.set(c.id, newId());
const { items, clips } = pastedItemsWithClips(
clip.roots.map((r) => copied.get(r.id)!),
clip.clips ?? [],
comp.clips,
ids,
at,
comp.tracks.map((t) => t.id),
(c) => Math.max(1, clipLength(project, comp, c)),
row,
);
const set = new SetCompositionCommand(assetId, { items: [...comp.items, ...items] });
const set = new SetCompositionCommand(assetId, {
items: [...comp.items, ...items],
clips: [...comp.clips, ...clips],
});
return {
command: oneStep(items.length === 1 ? 'Paste item' : 'Paste items', [paste, set]),
itemIds: items.map((i) => i.id),
};
}
/** Items and clips taken off together, the items' objects with them. */
/** Items and clips taken off together, the items' objects and the clips that belong to them with them. */
export function removeFromComposition(
project: Project,
assetId: string,
@@ -121,7 +131,8 @@ export function removeFromComposition(
clipIds: readonly string[],
): Command {
const comp = requireComposition(project, assetId);
const gone = new Set([...itemIds, ...clipIds]);
const carried = carriedClips(comp.items, comp.clips, new Set(itemIds));
const gone = new Set([...itemIds, ...clipIds, ...carried]);
const objects = comp.objects.filter((o) => gone.has(o.id)).map((o) => o.id);
const set = new SetCompositionCommand(assetId, {
items: comp.items.filter((i) => !gone.has(i.id)),
+7
View File
@@ -16,6 +16,7 @@ import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy';
import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance';
import type { CompositionClip, CompositionItem, SpriteAsset } from './Asset';
import { isCompositionSprite } from './composition';
import { carriedClips } from './compositionEdit';
import type { Mat } from './matrix';
import type { Position } from './Properties';
import type { ObjectHolder, Project } from './Project';
@@ -38,6 +39,8 @@ export interface ObjectClip {
roots: ClipRoot[];
/** Copied out of a composition (#417): the items of the roots that are items, for a paste there. */
items?: CompositionItem[];
/** The animation clips that belong to those items, to go with them. */
clips?: CompositionClip[];
/** The sprites of a composition's own the objects wear (#419), back for a paste after a cut. */
sprites?: SpriteAsset[];
}
@@ -89,12 +92,16 @@ export function copyObjects(
.filter((o) => members.has(o.id) && !shownByInstance(o.id))
.map((o) => ({ ...structuredClone(o), editorOffset: { x: 0, y: 0 } }));
const items = holder.type === 'composition' ? holder.items.filter((i) => roots.includes(i.id)) : [];
const carried =
holder.type === 'composition' ? carriedClips(holder.items, holder.clips, new Set(roots)) : new Set();
const clips = holder.type === 'composition' ? holder.clips.filter((c) => carried.has(c.id)) : [];
const worn = new Set(objects.map((o) => o.assetId));
const sprites = project.assets.filter(isCompositionSprite).filter((a) => worn.has(a.id));
return {
kind: 'objects',
objects,
...(items.length > 0 && { items: structuredClone(items) }),
...(clips.length > 0 && { clips: structuredClone(clips) }),
...(sprites.length > 0 && { sprites: structuredClone(sprites) }),
roots: roots.map((id) => {
const object = findObject(view, id)!;
+107 -7
View File
@@ -111,6 +111,69 @@ export function movedItems(
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.
*/
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.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[],
@@ -162,6 +225,32 @@ export function pastedClips(
}
}
/**
* Copied items placed as `pastedItems` 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 last = Math.max(0, ...clips.map((c) => c.start + Math.max(1, lengthOf(c))));
for (let by = 0; ; by++) {
const moved = placedClips.map((c) => ({ ...c, start: c.start + by }));
if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last))
return { items: placedItems.map((i) => ({ ...i, start: i.start + by })), clips: moved };
}
}
function pasted<T extends Placed>(
items: readonly T[],
ids: ReadonlyMap<string, string>,
@@ -169,19 +258,30 @@ function pasted<T extends Placed>(
trackIds: readonly string[],
row?: number,
): T[] {
if (items.length === 0) return [];
const place = placing(items, at, trackIds, row);
return items.map((i) => place(i, ids));
}
/** How a paste moves what was copied: the earliest of `items` to `at`, their rows as `pastedItems` says. */
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 items.map((i, n) => ({
...i,
id: ids.get(i.id) ?? i.id,
start: Math.max(0, Math.round(at)) + (i.start - first),
track: trackIds[rows[n]! + shift] ?? i.track,
}));
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. */