diff --git a/README.md b/README.md index 4c1bd433..bad24b13 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/compositionCommands.ts b/src/core/commands/compositionCommands.ts index a78b79cb..8a767676 100644 --- a/src/core/commands/compositionCommands.ts +++ b/src/core/commands/compositionCommands.ts @@ -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)), diff --git a/src/core/model/clipboard.ts b/src/core/model/clipboard.ts index b906dd4e..c6a082b0 100644 --- a/src/core/model/clipboard.ts +++ b/src/core/model/clipboard.ts @@ -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)!; diff --git a/src/core/model/compositionEdit.ts b/src/core/model/compositionEdit.ts index 31574561..8b379e07 100644 --- a/src/core/model/compositionEdit.ts +++ b/src/core/model/compositionEdit.ts @@ -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, +): Set { + const carried = new Set(); + 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, + 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() : 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, + 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( items: readonly T[], ids: ReadonlyMap, @@ -169,19 +258,30 @@ function pasted( 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: T, ids: ReadonlyMap) => 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. */ diff --git a/src/editor/composition/CompositionTimeline.ts b/src/editor/composition/CompositionTimeline.ts index 5705243a..c41b3925 100644 --- a/src/editor/composition/CompositionTimeline.ts +++ b/src/editor/composition/CompositionTimeline.ts @@ -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, 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, 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; } diff --git a/src/editor/inspector/compositionSection.ts b/src/editor/inspector/compositionSection.ts index 0979acc8..b34896b4 100644 --- a/src/editor/inspector/compositionSection.ts +++ b/src/editor/inspector/compositionSection.ts @@ -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', diff --git a/tests/core/composition-edit.test.ts b/tests/core/composition-edit.test.ts index d3239486..71c05bc6 100644 --- a/tests/core/composition-edit.test.ts +++ b/tests/core/composition-edit.test.ts @@ -17,16 +17,20 @@ import { import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import type { CompositionClip, CompositionItem } from '../../src/core/model/Asset'; import { + carriedClips, clampedMove, droppedLength, droppedSpan, + heldMove, type ItemMedia, mediaEdges, mediaMarks, movedClips, movedItems, + movedWithClips, pastedClips, pastedItems, + pastedItemsWithClips, resizedItem, snappedToMedia, 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', () => { 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(); }); + 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', () => { const cut = makeCut(); const screen = new CreateAssetCommand('sprite', 'Screen', { texture: cut.videoId }); diff --git a/tools/e2e/69-composition-tab.mjs b/tools/e2e/69-composition-tab.mjs index 4a557d9a..081caa11 100644 --- a/tools/e2e/69-composition-tab.mjs +++ b/tools/e2e/69-composition-tab.mjs @@ -431,6 +431,61 @@ export async function compositionTab(t) { 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 mutate(` const p = __gm.state.project;