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. */
+56 -41
View File
@@ -32,9 +32,11 @@ import {
droppedSpan,
itemMedia,
type ItemMedia,
heldMove,
mediaMarks,
movedClips,
movedItems,
movedWithClips,
pastedClips,
resizedItem,
snappedToMedia,
@@ -118,6 +120,8 @@ type Gesture =
moved: boolean;
frames: number;
tracks: number;
/** Alt held while dragging items: they go without their clips. */
alone: boolean;
}
| { kind: 'resize' | 'trim'; anchor: CompositionItem; item: CompositionItem; media: ItemMedia | null };
@@ -564,7 +568,17 @@ export class CompositionTimeline extends Widget {
g: Vec2,
): void {
const grab = xToTick(this.view, g.x) - anchor.start;
this.gesture = { kind: 'move', what, anchor, grab, pointer: g, moved: false, frames: 0, tracks: 0 };
this.gesture = {
kind: 'move',
what,
anchor,
grab,
pointer: g,
moved: false,
frames: 0,
tracks: 0,
alone: false,
};
}
private onPointerMove(e: FederatedPointerEvent): void {
@@ -617,13 +631,13 @@ export class CompositionTimeline extends Widget {
if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD)
return;
gesture.moved = true;
gesture.alone = gesture.what === 'item' && e.altKey;
const row = nearestTrack(this.view, asset.tracks.length, g.y);
const held = this.heldMove(
const held = this.heldDrag(
asset,
gesture.what,
gesture,
Math.round(frame - gesture.grab) - gesture.anchor.start,
row - asset.tracks.findIndex((t) => t.id === gesture.anchor.track),
gesture,
);
gesture.frames = held.frames;
gesture.tracks = held.tracks;
@@ -653,16 +667,17 @@ export class CompositionTimeline extends Widget {
/**
* Where a move goes that the pointer asks to go `frames` and `tracks`: held to frame 0 and the
* tracks there are. Items go there whatever lies there; clips stop short of another clip – on the
* way back to the last place they fitted, on the track asked for, else on the one they were on.
* tracks there are. Items go there whatever lies there; clips – the picked ones, or those that go
* with the picked items – stop short of another clip: on the way back to the last place they
* fitted, on the track asked for, else on the one they were on.
*/
private heldMove(
private heldDrag(
asset: CompositionAsset,
what: 'item' | 'clip',
gesture: Extract<Gesture, { kind: 'move' }>,
frames: number,
tracks: number,
last: { frames: number; tracks: number },
): { frames: number; tracks: number } {
const what = gesture.what;
const picked = new Set(what === 'item' ? this.pickedItems(asset) : this.pickedClips(asset));
const list = what === 'item' ? asset.items : asset.clips;
const wanted = clampedMove(
@@ -672,27 +687,40 @@ export class CompositionTimeline extends Widget {
frames,
tracks,
);
if (what === 'item') return wanted;
for (const on of [wanted.tracks, last.tracks]) {
const step = Math.sign(last.frames - wanted.frames);
for (let at = wanted.frames; ; at += step) {
if (this.movedList(asset, what, at, on)) return { frames: at, tracks: on };
if (at === last.frames) break;
}
}
return last;
const fits = (f: number, t: number) => this.movedParts(asset, gesture, f, t) !== null;
// Alt let go, the last place may not hold the clips: back to where the drag began.
const last = fits(gesture.frames, gesture.tracks) ? gesture : { frames: 0, tracks: 0 };
return heldMove(wanted, { frames: last.frames, tracks: last.tracks }, fits);
}
private movedList(
/** The items and clips as a move by `frames` and `tracks` would leave them; null where it may not go. */
private movedParts(
asset: CompositionAsset,
what: 'item' | 'clip',
gesture: Extract<Gesture, { kind: 'move' }>,
frames: number,
tracks: number,
): CompositionItem[] | CompositionClip[] | null {
): { items: CompositionItem[]; clips: CompositionClip[] } | null {
const ids = asset.tracks.map((t) => t.id);
return what === 'item'
? movedItems(asset.items, new Set(this.pickedItems(asset)), ids, frames, tracks)
: movedClips(asset.clips, new Set(this.pickedClips(asset)), ids, frames, tracks, this.lengthOf(asset));
if (gesture.what === 'item')
return movedWithClips(
asset.items,
asset.clips,
new Set(this.pickedItems(asset)),
ids,
frames,
tracks,
this.lengthOf(asset),
gesture.alone,
);
const clips = movedClips(
asset.clips,
new Set(this.pickedClips(asset)),
ids,
frames,
tracks,
this.lengthOf(asset),
);
return clips && { items: asset.items, clips };
}
private onPointerUp(): void {
@@ -718,14 +746,10 @@ export class CompositionTimeline extends Widget {
if (gesture.what === 'item') this.state.select(objectSelection(asset.id, [gesture.anchor.id]));
break;
}
const list = this.movedList(asset, gesture.what, gesture.frames, gesture.tracks);
if (!list || (gesture.frames === 0 && gesture.tracks === 0)) break;
const parts = this.movedParts(asset, gesture, gesture.frames, gesture.tracks);
if (!parts || (gesture.frames === 0 && gesture.tracks === 0)) break;
const several =
(gesture.what === 'item' ? this.pickedItems(asset) : this.pickedClips(asset)).length > 1;
const parts =
gesture.what === 'item'
? { items: list as CompositionItem[] }
: { clips: list as CompositionClip[] };
const label = `Move ${gesture.what}${several ? 's' : ''}`;
this.commit(asset, parts, label);
break;
@@ -1014,17 +1038,8 @@ export class CompositionTimeline extends Widget {
private shownClips(asset: CompositionAsset): CompositionClip[] {
const g = this.gesture;
if (g?.kind === 'move' && g.what === 'clip' && g.moved)
return (
movedClips(
asset.clips,
new Set(this.pickedClips(asset)),
asset.tracks.map((t) => t.id),
g.frames,
g.tracks,
this.lengthOf(asset),
) ?? asset.clips
);
if (g?.kind === 'move' && g.moved)
return this.movedParts(asset, g, g.frames, g.tracks)?.clips ?? asset.clips;
return asset.clips;
}
+24 -7
View File
@@ -8,7 +8,7 @@ import {
isCompositionSprite,
itemVideo,
} from '../../core/model/composition';
import { resizedItem } from '../../core/model/compositionEdit';
import { heldMove, movedWithClips, resizedItem } from '../../core/model/compositionEdit';
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, formatNumber, type MenuEntry } from '../../ui';
@@ -139,13 +139,30 @@ export function addTimingRows(host: InspectorHost, holderId: string, objectId: s
host.state.execute(new SetCompositionCommand(holderId, { items }, label));
};
const frames = (value: unknown) => Math.round(Number(value) * fps);
// The item's clips go with it, held short where one would come to lie over another clip.
const moveTo = (value: unknown) => {
const current = compositionItemOf(host.state.project, holderId, objectId);
if (!current) return;
const { comp: c, item: i } = current;
const project = host.state.project;
const moved = (by: number) =>
movedWithClips(
c.items,
c.clips,
new Set([objectId]),
c.tracks.map((t) => t.id),
by,
0,
(clip) => Math.max(1, clipLength(project, c, clip)),
);
const wanted = { frames: Math.max(0, frames(value)) - i.start, tracks: 0 };
const held = heldMove(wanted, { frames: 0, tracks: 0 }, (by) => moved(by) !== null);
const parts = moved(held.frames);
if (parts && SetCompositionCommand.changes(project, holderId, parts))
host.state.execute(new SetCompositionCommand(holderId, parts, 'Move item'));
};
const rows: [string, string, number, (value: unknown) => void, boolean?][] = [
[
START_ROW,
'Start',
item.start / fps,
(value) => write('Move item', (i) => ({ ...i, start: Math.max(0, frames(value)) })),
],
[START_ROW, 'Start', item.start / fps, moveTo],
[
LENGTH_ROW,
'Length',