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:
1 parent
480db9c4e4
commit
126a5a3a62
8 files changed
+365
-62
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2059 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2060 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
* are written whole once the pointer lets go. An item comes and goes together with its object.
|
* 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 type { CompositionAsset, CompositionClip, CompositionItem, CompositionTrack } from '../model/Asset';
|
||||||
|
import { newId } from '../ids';
|
||||||
import {
|
import {
|
||||||
|
clipLength,
|
||||||
fittedLength,
|
fittedLength,
|
||||||
heldCompositionClips,
|
heldCompositionClips,
|
||||||
heldCompositionItems,
|
heldCompositionItems,
|
||||||
heldCompositionTracks,
|
heldCompositionTracks,
|
||||||
} from '../model/composition';
|
} from '../model/composition';
|
||||||
import type { ObjectClip } from '../model/clipboard';
|
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 { Project } from '../model/Project';
|
||||||
import type { Command } from './Command';
|
import type { Command } from './Command';
|
||||||
import { oneStep } from './CompoundCommand';
|
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
|
* 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.
|
* no time to land at. `itemIds` are the new items', to pick them afterwards.
|
||||||
*/
|
*/
|
||||||
export function pasteCompositionItems(
|
export function pasteCompositionItems(
|
||||||
@@ -99,21 +102,28 @@ export function pasteCompositionItems(
|
|||||||
const comp = requireComposition(project, assetId);
|
const comp = requireComposition(project, assetId);
|
||||||
const paste = new PasteObjectsCommand(assetId, clip, {}, { x: 0, y: 0 });
|
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 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.roots.map((r) => copied.get(r.id)!),
|
||||||
|
clip.clips ?? [],
|
||||||
|
comp.clips,
|
||||||
ids,
|
ids,
|
||||||
at,
|
at,
|
||||||
comp.tracks.map((t) => t.id),
|
comp.tracks.map((t) => t.id),
|
||||||
|
(c) => Math.max(1, clipLength(project, comp, c)),
|
||||||
row,
|
row,
|
||||||
);
|
);
|
||||||
const set = new SetCompositionCommand(assetId, { items: [...comp.items, ...items] });
|
const set = new SetCompositionCommand(assetId, {
|
||||||
|
items: [...comp.items, ...items],
|
||||||
|
clips: [...comp.clips, ...clips],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
command: oneStep(items.length === 1 ? 'Paste item' : 'Paste items', [paste, set]),
|
command: oneStep(items.length === 1 ? 'Paste item' : 'Paste items', [paste, set]),
|
||||||
itemIds: items.map((i) => i.id),
|
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(
|
export function removeFromComposition(
|
||||||
project: Project,
|
project: Project,
|
||||||
assetId: string,
|
assetId: string,
|
||||||
@@ -121,7 +131,8 @@ export function removeFromComposition(
|
|||||||
clipIds: readonly string[],
|
clipIds: readonly string[],
|
||||||
): Command {
|
): Command {
|
||||||
const comp = requireComposition(project, assetId);
|
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 objects = comp.objects.filter((o) => gone.has(o.id)).map((o) => o.id);
|
||||||
const set = new SetCompositionCommand(assetId, {
|
const set = new SetCompositionCommand(assetId, {
|
||||||
items: comp.items.filter((i) => !gone.has(i.id)),
|
items: comp.items.filter((i) => !gone.has(i.id)),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy';
|
|||||||
import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance';
|
import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance';
|
||||||
import type { CompositionClip, CompositionItem, SpriteAsset } from './Asset';
|
import type { CompositionClip, CompositionItem, SpriteAsset } from './Asset';
|
||||||
import { isCompositionSprite } from './composition';
|
import { isCompositionSprite } from './composition';
|
||||||
|
import { carriedClips } from './compositionEdit';
|
||||||
import type { Mat } from './matrix';
|
import type { Mat } from './matrix';
|
||||||
import type { Position } from './Properties';
|
import type { Position } from './Properties';
|
||||||
import type { ObjectHolder, Project } from './Project';
|
import type { ObjectHolder, Project } from './Project';
|
||||||
@@ -38,6 +39,8 @@ export interface ObjectClip {
|
|||||||
roots: ClipRoot[];
|
roots: ClipRoot[];
|
||||||
/** Copied out of a composition (#417): the items of the roots that are items, for a paste there. */
|
/** Copied out of a composition (#417): the items of the roots that are items, for a paste there. */
|
||||||
items?: CompositionItem[];
|
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. */
|
/** The sprites of a composition's own the objects wear (#419), back for a paste after a cut. */
|
||||||
sprites?: SpriteAsset[];
|
sprites?: SpriteAsset[];
|
||||||
}
|
}
|
||||||
@@ -89,12 +92,16 @@ export function copyObjects(
|
|||||||
.filter((o) => members.has(o.id) && !shownByInstance(o.id))
|
.filter((o) => members.has(o.id) && !shownByInstance(o.id))
|
||||||
.map((o) => ({ ...structuredClone(o), editorOffset: { x: 0, y: 0 } }));
|
.map((o) => ({ ...structuredClone(o), editorOffset: { x: 0, y: 0 } }));
|
||||||
const items = holder.type === 'composition' ? holder.items.filter((i) => roots.includes(i.id)) : [];
|
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 worn = new Set(objects.map((o) => o.assetId));
|
||||||
const sprites = project.assets.filter(isCompositionSprite).filter((a) => worn.has(a.id));
|
const sprites = project.assets.filter(isCompositionSprite).filter((a) => worn.has(a.id));
|
||||||
return {
|
return {
|
||||||
kind: 'objects',
|
kind: 'objects',
|
||||||
objects,
|
objects,
|
||||||
...(items.length > 0 && { items: structuredClone(items) }),
|
...(items.length > 0 && { items: structuredClone(items) }),
|
||||||
|
...(clips.length > 0 && { clips: structuredClone(clips) }),
|
||||||
...(sprites.length > 0 && { sprites: structuredClone(sprites) }),
|
...(sprites.length > 0 && { sprites: structuredClone(sprites) }),
|
||||||
roots: roots.map((id) => {
|
roots: roots.map((id) => {
|
||||||
const object = findObject(view, id)!;
|
const object = findObject(view, id)!;
|
||||||
|
|||||||
@@ -111,6 +111,69 @@ export function movedItems(
|
|||||||
return moved(items, picked, trackIds, frames, tracks);
|
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. */
|
/** The picked clips moved like items; null, too, where one would come to lie over another. */
|
||||||
export function movedClips(
|
export function movedClips(
|
||||||
clips: readonly CompositionClip[],
|
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>(
|
function pasted<T extends Placed>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
ids: ReadonlyMap<string, string>,
|
ids: ReadonlyMap<string, string>,
|
||||||
@@ -169,19 +258,30 @@ function pasted<T extends Placed>(
|
|||||||
trackIds: readonly string[],
|
trackIds: readonly string[],
|
||||||
row?: number,
|
row?: number,
|
||||||
): T[] {
|
): 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 first = Math.min(...items.map((i) => i.start));
|
||||||
const index = new Map(trackIds.map((id, i) => [id, i]));
|
const index = new Map(trackIds.map((id, i) => [id, i]));
|
||||||
const rows = items.map((i) => index.get(i.track) ?? 0);
|
const rows = items.map((i) => index.get(i.track) ?? 0);
|
||||||
const top = Math.min(...rows);
|
const top = Math.min(...rows);
|
||||||
const bottom = Math.max(...rows);
|
const bottom = Math.max(...rows);
|
||||||
const shift = row === undefined ? 0 : Math.max(-top, Math.min(trackIds.length - 1 - bottom, row - top));
|
const shift = row === undefined ? 0 : Math.max(-top, Math.min(trackIds.length - 1 - bottom, row - top));
|
||||||
return items.map((i, n) => ({
|
return (t, ids) => ({
|
||||||
...i,
|
...t,
|
||||||
id: ids.get(i.id) ?? i.id,
|
id: ids.get(t.id) ?? t.id,
|
||||||
start: Math.max(0, Math.round(at)) + (i.start - first),
|
start: Math.max(0, Math.round(at)) + (t.start - first),
|
||||||
track: trackIds[rows[n]! + shift] ?? i.track,
|
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. */
|
/** The item with its end edge dragged to make it `wanted` frames long, at least one; its length is its own from then on. */
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ import {
|
|||||||
droppedSpan,
|
droppedSpan,
|
||||||
itemMedia,
|
itemMedia,
|
||||||
type ItemMedia,
|
type ItemMedia,
|
||||||
|
heldMove,
|
||||||
mediaMarks,
|
mediaMarks,
|
||||||
movedClips,
|
movedClips,
|
||||||
movedItems,
|
movedItems,
|
||||||
|
movedWithClips,
|
||||||
pastedClips,
|
pastedClips,
|
||||||
resizedItem,
|
resizedItem,
|
||||||
snappedToMedia,
|
snappedToMedia,
|
||||||
@@ -118,6 +120,8 @@ type Gesture =
|
|||||||
moved: boolean;
|
moved: boolean;
|
||||||
frames: number;
|
frames: number;
|
||||||
tracks: 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 };
|
| { kind: 'resize' | 'trim'; anchor: CompositionItem; item: CompositionItem; media: ItemMedia | null };
|
||||||
|
|
||||||
@@ -564,7 +568,17 @@ export class CompositionTimeline extends Widget {
|
|||||||
g: Vec2,
|
g: Vec2,
|
||||||
): void {
|
): void {
|
||||||
const grab = xToTick(this.view, g.x) - anchor.start;
|
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 {
|
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)
|
if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD)
|
||||||
return;
|
return;
|
||||||
gesture.moved = true;
|
gesture.moved = true;
|
||||||
|
gesture.alone = gesture.what === 'item' && e.altKey;
|
||||||
const row = nearestTrack(this.view, asset.tracks.length, g.y);
|
const row = nearestTrack(this.view, asset.tracks.length, g.y);
|
||||||
const held = this.heldMove(
|
const held = this.heldDrag(
|
||||||
asset,
|
asset,
|
||||||
gesture.what,
|
gesture,
|
||||||
Math.round(frame - gesture.grab) - gesture.anchor.start,
|
Math.round(frame - gesture.grab) - gesture.anchor.start,
|
||||||
row - asset.tracks.findIndex((t) => t.id === gesture.anchor.track),
|
row - asset.tracks.findIndex((t) => t.id === gesture.anchor.track),
|
||||||
gesture,
|
|
||||||
);
|
);
|
||||||
gesture.frames = held.frames;
|
gesture.frames = held.frames;
|
||||||
gesture.tracks = held.tracks;
|
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
|
* 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
|
* tracks there are. Items go there whatever lies there; clips – the picked ones, or those that go
|
||||||
* way back to the last place they fitted, on the track asked for, else on the one they were on.
|
* 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,
|
asset: CompositionAsset,
|
||||||
what: 'item' | 'clip',
|
gesture: Extract<Gesture, { kind: 'move' }>,
|
||||||
frames: number,
|
frames: number,
|
||||||
tracks: number,
|
tracks: number,
|
||||||
last: { frames: number; tracks: number },
|
|
||||||
): { 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 picked = new Set(what === 'item' ? this.pickedItems(asset) : this.pickedClips(asset));
|
||||||
const list = what === 'item' ? asset.items : asset.clips;
|
const list = what === 'item' ? asset.items : asset.clips;
|
||||||
const wanted = clampedMove(
|
const wanted = clampedMove(
|
||||||
@@ -672,27 +687,40 @@ export class CompositionTimeline extends Widget {
|
|||||||
frames,
|
frames,
|
||||||
tracks,
|
tracks,
|
||||||
);
|
);
|
||||||
if (what === 'item') return wanted;
|
const fits = (f: number, t: number) => this.movedParts(asset, gesture, f, t) !== null;
|
||||||
for (const on of [wanted.tracks, last.tracks]) {
|
// Alt let go, the last place may not hold the clips: back to where the drag began.
|
||||||
const step = Math.sign(last.frames - wanted.frames);
|
const last = fits(gesture.frames, gesture.tracks) ? gesture : { frames: 0, tracks: 0 };
|
||||||
for (let at = wanted.frames; ; at += step) {
|
return heldMove(wanted, { frames: last.frames, tracks: last.tracks }, fits);
|
||||||
if (this.movedList(asset, what, at, on)) return { frames: at, tracks: on };
|
|
||||||
if (at === last.frames) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return last;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
asset: CompositionAsset,
|
||||||
what: 'item' | 'clip',
|
gesture: Extract<Gesture, { kind: 'move' }>,
|
||||||
frames: number,
|
frames: number,
|
||||||
tracks: number,
|
tracks: number,
|
||||||
): CompositionItem[] | CompositionClip[] | null {
|
): { items: CompositionItem[]; clips: CompositionClip[] } | null {
|
||||||
const ids = asset.tracks.map((t) => t.id);
|
const ids = asset.tracks.map((t) => t.id);
|
||||||
return what === 'item'
|
if (gesture.what === 'item')
|
||||||
? movedItems(asset.items, new Set(this.pickedItems(asset)), ids, frames, tracks)
|
return movedWithClips(
|
||||||
: movedClips(asset.clips, new Set(this.pickedClips(asset)), ids, frames, tracks, this.lengthOf(asset));
|
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 {
|
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]));
|
if (gesture.what === 'item') this.state.select(objectSelection(asset.id, [gesture.anchor.id]));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const list = this.movedList(asset, gesture.what, gesture.frames, gesture.tracks);
|
const parts = this.movedParts(asset, gesture, gesture.frames, gesture.tracks);
|
||||||
if (!list || (gesture.frames === 0 && gesture.tracks === 0)) break;
|
if (!parts || (gesture.frames === 0 && gesture.tracks === 0)) break;
|
||||||
const several =
|
const several =
|
||||||
(gesture.what === 'item' ? this.pickedItems(asset) : this.pickedClips(asset)).length > 1;
|
(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' : ''}`;
|
const label = `Move ${gesture.what}${several ? 's' : ''}`;
|
||||||
this.commit(asset, parts, label);
|
this.commit(asset, parts, label);
|
||||||
break;
|
break;
|
||||||
@@ -1014,17 +1038,8 @@ export class CompositionTimeline extends Widget {
|
|||||||
|
|
||||||
private shownClips(asset: CompositionAsset): CompositionClip[] {
|
private shownClips(asset: CompositionAsset): CompositionClip[] {
|
||||||
const g = this.gesture;
|
const g = this.gesture;
|
||||||
if (g?.kind === 'move' && g.what === 'clip' && g.moved)
|
if (g?.kind === 'move' && g.moved)
|
||||||
return (
|
return this.movedParts(asset, g, g.frames, g.tracks)?.clips ?? asset.clips;
|
||||||
movedClips(
|
|
||||||
asset.clips,
|
|
||||||
new Set(this.pickedClips(asset)),
|
|
||||||
asset.tracks.map((t) => t.id),
|
|
||||||
g.frames,
|
|
||||||
g.tracks,
|
|
||||||
this.lengthOf(asset),
|
|
||||||
) ?? asset.clips
|
|
||||||
);
|
|
||||||
return asset.clips;
|
return asset.clips;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
isCompositionSprite,
|
isCompositionSprite,
|
||||||
itemVideo,
|
itemVideo,
|
||||||
} from '../../core/model/composition';
|
} 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 { findAsset, findAssetOf, type Project } from '../../core/model/Project';
|
||||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||||
import { Button, formatNumber, type MenuEntry } from '../../ui';
|
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));
|
host.state.execute(new SetCompositionCommand(holderId, { items }, label));
|
||||||
};
|
};
|
||||||
const frames = (value: unknown) => Math.round(Number(value) * fps);
|
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?][] = [
|
const rows: [string, string, number, (value: unknown) => void, boolean?][] = [
|
||||||
[
|
[START_ROW, 'Start', item.start / fps, moveTo],
|
||||||
START_ROW,
|
|
||||||
'Start',
|
|
||||||
item.start / fps,
|
|
||||||
(value) => write('Move item', (i) => ({ ...i, start: Math.max(0, frames(value)) })),
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
LENGTH_ROW,
|
LENGTH_ROW,
|
||||||
'Length',
|
'Length',
|
||||||
|
|||||||
@@ -17,16 +17,20 @@ import {
|
|||||||
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||||
import type { CompositionClip, CompositionItem } from '../../src/core/model/Asset';
|
import type { CompositionClip, CompositionItem } from '../../src/core/model/Asset';
|
||||||
import {
|
import {
|
||||||
|
carriedClips,
|
||||||
clampedMove,
|
clampedMove,
|
||||||
droppedLength,
|
droppedLength,
|
||||||
droppedSpan,
|
droppedSpan,
|
||||||
|
heldMove,
|
||||||
type ItemMedia,
|
type ItemMedia,
|
||||||
mediaEdges,
|
mediaEdges,
|
||||||
mediaMarks,
|
mediaMarks,
|
||||||
movedClips,
|
movedClips,
|
||||||
movedItems,
|
movedItems,
|
||||||
|
movedWithClips,
|
||||||
pastedClips,
|
pastedClips,
|
||||||
pastedItems,
|
pastedItems,
|
||||||
|
pastedItemsWithClips,
|
||||||
resizedItem,
|
resizedItem,
|
||||||
snappedToMedia,
|
snappedToMedia,
|
||||||
trimmedItem,
|
trimmedItem,
|
||||||
@@ -123,6 +127,68 @@ describe('moving', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const clip = (id: string, track: string, start: number) =>
|
||||||
|
({ id, track, animation: 'anim', start }) satisfies CompositionClip;
|
||||||
|
|
||||||
|
describe('clips that go with their item', () => {
|
||||||
|
const tracks = ['t1', 't2'];
|
||||||
|
// Every clip is 10 frames long.
|
||||||
|
const lengthOf = () => 10;
|
||||||
|
|
||||||
|
it('belong to the item at their first frame, of two there the one in front', () => {
|
||||||
|
const items = [item('a', 't1', 0, 60), item('b', 't1', 40, 60), item('c', 't1', 40, 10)];
|
||||||
|
// `x` starts in `a` alone; `y` where `a`, `b` and `c` lie – `c`, the later in the list, is in
|
||||||
|
// front; `z` starts before `b` and only reaches into it, so it stays with `a`.
|
||||||
|
const clips = [clip('x', 't1', 10), clip('y', 't1', 45), clip('z', 't1', 35), clip('w', 't2', 10)];
|
||||||
|
expect([...carriedClips(items, clips, new Set(['a']))]).toEqual(['x', 'z']);
|
||||||
|
expect([...carriedClips(items, clips, new Set(['b']))]).toEqual([]);
|
||||||
|
expect([...carriedClips(items, clips, new Set(['c']))]).toEqual(['y']);
|
||||||
|
// One past an item's end it is not the item's any more.
|
||||||
|
expect([...carriedClips(items, [clip('v', 't1', 100)], new Set(['b']))]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('move with their item, also onto another track; with Alt they stay', () => {
|
||||||
|
const items = [item('a', 't1', 0, 60), item('b', 't2', 0, 60)];
|
||||||
|
const clips = [clip('x', 't1', 10), clip('y', 't2', 50)];
|
||||||
|
const moved = movedWithClips(items, clips, new Set(['a']), tracks, 5, 1, lengthOf)!;
|
||||||
|
expect(moved.items[0]).toMatchObject({ start: 5, track: 't2' });
|
||||||
|
expect(moved.clips).toEqual([clip('x', 't2', 15), clip('y', 't2', 50)]);
|
||||||
|
const alone = movedWithClips(items, clips, new Set(['a']), tracks, 5, 1, lengthOf, true)!;
|
||||||
|
expect(alone.clips).toEqual(clips);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hold the move short of another clip, the item with them', () => {
|
||||||
|
const items = [item('a', 't1', 0, 60)];
|
||||||
|
const clips = [clip('x', 't1', 10), clip('y', 't1', 70)];
|
||||||
|
const picked = new Set(['a']);
|
||||||
|
const fits = (by: number) => movedWithClips(items, clips, picked, tracks, by, 0, lengthOf) !== null;
|
||||||
|
// `x` at 10 + 55 would lie over `y` (70–80): the move stops where `x` ends right at 70.
|
||||||
|
expect(movedWithClips(items, clips, picked, tracks, 55, 0, lengthOf)).toBeNull();
|
||||||
|
expect(heldMove({ frames: 55, tracks: 0 }, { frames: 0, tracks: 0 }, fits)).toEqual({
|
||||||
|
frames: 50,
|
||||||
|
tracks: 0,
|
||||||
|
});
|
||||||
|
// Past `y` it fits again.
|
||||||
|
expect(heldMove({ frames: 70, tracks: 0 }, { frames: 0, tracks: 0 }, fits)).toEqual({
|
||||||
|
frames: 70,
|
||||||
|
tracks: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('are pasted with their items, all moved on until the clips lie apart', () => {
|
||||||
|
const items = [item('a', 't1', 20, 60)];
|
||||||
|
const copied = [clip('x', 't1', 30)];
|
||||||
|
const ids = new Map([
|
||||||
|
['a', 'a2'],
|
||||||
|
['x', 'x2'],
|
||||||
|
]);
|
||||||
|
// Landing at 100, `x2` would start at 110, over `y` (105–115): both move on by 5.
|
||||||
|
const out = pastedItemsWithClips(items, copied, [clip('y', 't1', 105)], ids, 100, tracks, lengthOf);
|
||||||
|
expect(out.items).toEqual([item('a2', 't1', 105, 60)]);
|
||||||
|
expect(out.clips).toEqual([clip('x2', 't1', 115)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the edges of an item', () => {
|
describe('the edges of an item', () => {
|
||||||
const b = item('b', 't1', 100, 60, { fitMedia: true, offset: 1 });
|
const b = item('b', 't1', 100, 60, { fitMedia: true, offset: 1 });
|
||||||
|
|
||||||
@@ -365,6 +431,38 @@ describe('items and their objects', () => {
|
|||||||
expect(pasteCompositionItems(cut.project, cut.compId, plain, 0)).toBeNull();
|
expect(pasteCompositionItems(cut.project, cut.compId, plain, 0)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a deleted item takes its clips with it, in the same undo step', () => {
|
||||||
|
const cut = makeCut();
|
||||||
|
const a = cut.add(0, 60);
|
||||||
|
const b = cut.add(60, 60);
|
||||||
|
const clips = [clip('mine', cut.track, 10), clip('next', cut.track, 70)];
|
||||||
|
cut.stack.execute(new SetCompositionCommand(cut.compId, { clips }));
|
||||||
|
cut.stack.execute(removeFromComposition(cut.project, cut.compId, [a], []));
|
||||||
|
expect(cut.comp().items.map((i) => i.id)).toEqual([b]);
|
||||||
|
expect(cut.comp().clips.map((c) => c.id)).toEqual(['next']);
|
||||||
|
cut.stack.undo();
|
||||||
|
expect(cut.comp().clips.map((c) => c.id)).toEqual(['mine', 'next']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a copied item is pasted with its clips under new ids, moved on with them where a clip lies', () => {
|
||||||
|
const cut = makeCut();
|
||||||
|
const a = cut.add(0, 60);
|
||||||
|
cut.stack.execute(new SetCompositionCommand(cut.compId, { clips: [clip('mine', cut.track, 10)] }));
|
||||||
|
const copy = copyObjects(cut.project, cut.comp(), [a])!;
|
||||||
|
expect(copy.clips?.map((c) => c.id)).toEqual(['mine']);
|
||||||
|
// Its animation is not there, so the clip is a frame long. Landing at 0 the copy of the clip
|
||||||
|
// would lie right over `mine`: item and clip move on by a frame.
|
||||||
|
const pasted = pasteCompositionItems(cut.project, cut.compId, copy, 0)!;
|
||||||
|
cut.stack.execute(pasted.command);
|
||||||
|
const [b] = pasted.itemIds;
|
||||||
|
expect(cut.comp().items.find((i) => i.id === b)?.start).toBe(1);
|
||||||
|
const added = cut.comp().clips.filter((c) => c.id !== 'mine');
|
||||||
|
expect(added).toHaveLength(1);
|
||||||
|
expect(added[0]).toMatchObject({ track: cut.track, animation: 'anim', start: 11 });
|
||||||
|
cut.stack.undo();
|
||||||
|
expect(cut.comp().clips.map((c) => c.id)).toEqual(['mine']);
|
||||||
|
});
|
||||||
|
|
||||||
it('a sprite wearing a video is dropped in as long as the video, anything else 3 s', () => {
|
it('a sprite wearing a video is dropped in as long as the video, anything else 3 s', () => {
|
||||||
const cut = makeCut();
|
const cut = makeCut();
|
||||||
const screen = new CreateAssetCommand('sprite', 'Screen', { texture: cut.videoId });
|
const screen = new CreateAssetCommand('sprite', 'Screen', { texture: cut.videoId });
|
||||||
|
|||||||
@@ -431,6 +431,61 @@ export async function compositionTab(t) {
|
|||||||
JSON.stringify({ banded: banded?.objectIds, altBanded, firstPaste, secondPaste, pastedPick }),
|
JSON.stringify({ banded: banded?.objectIds, altBanded, firstPaste, secondPaste, pastedPick }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 69n. A dragged item takes the clips that begin on it along; with Alt pressed once the drag runs
|
||||||
|
// it goes alone. The clip of the item next to it stays where it is.
|
||||||
|
await mutate(`
|
||||||
|
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||||||
|
c.items = [
|
||||||
|
{ id: 'ct69-b1', track: 'ct69-t1', start: 0, length: 30, offset: 0 },
|
||||||
|
{ id: 'ct69-b2', track: 'ct69-t1', start: 70, length: 30, offset: 0 },
|
||||||
|
{ id: 'ct69-b3', track: 'ct69-t2', start: 0, length: 30, offset: 0 },
|
||||||
|
];
|
||||||
|
c.clips = [
|
||||||
|
{ id: 'ct69-k1', track: 'ct69-t1', animation: '${SLIDE}', start: 5 },
|
||||||
|
{ id: 'ct69-k2', track: 'ct69-t1', animation: '${SLIDE}', start: 75 },
|
||||||
|
];`);
|
||||||
|
await sleep(100);
|
||||||
|
const frameWidth =
|
||||||
|
((await json(`__gm.composition.laneAt('ct69-t1', 100)`)).x -
|
||||||
|
(await json(`__gm.composition.laneAt('ct69-t1', 0)`)).x) /
|
||||||
|
100;
|
||||||
|
/** Drags the item by `frames` from its middle – where no clip lies –, Alt held only while it moves. */
|
||||||
|
const dragItem = async (id, frames, alt) => {
|
||||||
|
const box = await json(`__gm.composition.box('${id}')`);
|
||||||
|
const from = { x: box.x + box.w / 2, y: box.y + box.h / 2 };
|
||||||
|
const to = from.x + frames * frameWidth;
|
||||||
|
await mouse('mouseMoved', from.x, from.y);
|
||||||
|
await mouse('mousePressed', from.x, from.y);
|
||||||
|
for (let i = 1; i <= 12; i++) {
|
||||||
|
await mouse('mouseMoved', from.x + ((to - from.x) * i) / 12, from.y, 'left', {
|
||||||
|
buttons: 1,
|
||||||
|
modifiers: alt ? 1 : 0,
|
||||||
|
});
|
||||||
|
await sleep(16);
|
||||||
|
}
|
||||||
|
await mouse('mouseReleased', to, from.y);
|
||||||
|
await sleep(150);
|
||||||
|
const now = await model();
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
picked: (await json('__gm.state.selection'))?.objectIds,
|
||||||
|
item: now.items.find((i) => i.id === id)?.start,
|
||||||
|
clips: Object.fromEntries(now.clips.map((c) => [c.id, c.start])),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const along = await dragItem('ct69-b1', 20, false);
|
||||||
|
const alone = await dragItem('ct69-b1', -15, true);
|
||||||
|
check(
|
||||||
|
'a dragged item takes the clips that begin on it along, with Alt it goes alone',
|
||||||
|
along.item === 20 &&
|
||||||
|
along.clips['ct69-k1'] === 25 &&
|
||||||
|
along.clips['ct69-k2'] === 75 &&
|
||||||
|
alone.item === 5 &&
|
||||||
|
alone.clips['ct69-k1'] === 25 &&
|
||||||
|
alone.clips['ct69-k2'] === 75,
|
||||||
|
JSON.stringify({ frameWidth, along, alone }),
|
||||||
|
);
|
||||||
|
|
||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const p = __gm.state.project;
|
const p = __gm.state.project;
|
||||||
|
|||||||
Reference in new issue
Block a user