diff --git a/README.md b/README.md index 3b6f223b..bf929a19 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, 2056 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2057 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/model/clipboard.ts b/src/core/model/clipboard.ts index ce18abd0..b906dd4e 100644 --- a/src/core/model/clipboard.ts +++ b/src/core/model/clipboard.ts @@ -14,7 +14,7 @@ import { import { editorTransform, parentTransform, positionOf } from '../properties/transform'; import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy'; import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance'; -import type { CompositionItem, SpriteAsset } from './Asset'; +import type { CompositionClip, CompositionItem, SpriteAsset } from './Asset'; import { isCompositionSprite } from './composition'; import type { Mat } from './matrix'; import type { Position } from './Properties'; @@ -50,7 +50,13 @@ export interface GraphClip { annotations: { kind: AnnotationKind; annotation: GraphAnnotation }[]; } -export type Clip = ObjectClip | GraphClip; +/** Animation clips of a composition (#417), for a paste into the same one or another. */ +export interface CompositionClipsClip { + kind: 'compositionClips'; + clips: CompositionClip[]; +} + +export type Clip = ObjectClip | GraphClip | CompositionClipsClip; /** * The selected objects with everything below them. A placed container keeps showing its template, diff --git a/src/core/model/compositionEdit.ts b/src/core/model/compositionEdit.ts index 430e8c2a..31574561 100644 --- a/src/core/model/compositionEdit.ts +++ b/src/core/model/compositionEdit.ts @@ -136,6 +136,39 @@ export function pastedItems( trackIds: readonly string[], row?: number, ): CompositionItem[] { + return pasted(items, ids, at, trackIds, row); +} + +/** + * Copied clips placed as `pastedItems` places items, then moved on frame by frame as long as one + * would lie over a clip of `clips` or over another of the copy – clips of a track never overlap. + */ +export function pastedClips( + copied: readonly CompositionClip[], + clips: readonly CompositionClip[], + ids: ReadonlyMap, + at: number, + trackIds: readonly string[], + lengthOf: (clip: CompositionClip) => number, + row?: number, +): CompositionClip[] { + const placed = pasted(copied, ids, at, trackIds, row); + if (placed.length === 0) return []; + // Past the end of every clip there is room, so the search ends there at the latest. + const last = Math.max(0, ...clips.map((c) => c.start + Math.max(1, lengthOf(c)))); + for (let by = 0; ; by++) { + const moved = placed.map((c) => ({ ...c, start: c.start + by })); + if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last)) return moved; + } +} + +function pasted( + items: readonly T[], + ids: ReadonlyMap, + at: number, + trackIds: readonly string[], + row?: number, +): T[] { if (items.length === 0) return []; const first = Math.min(...items.map((i) => i.start)); const index = new Map(trackIds.map((id, i) => [id, i])); diff --git a/src/editor/clipboard.ts b/src/editor/clipboard.ts index 66499250..2afc50b5 100644 --- a/src/editor/clipboard.ts +++ b/src/editor/clipboard.ts @@ -18,6 +18,7 @@ import { findObject } from '../core/model/hierarchy'; import { isLiveInstance } from '../core/model/instance'; import type { Vec2 } from '../core/model/Properties'; import { + findAssetOf, findGraphAsset, findObjectHolder, type GraphHolder, @@ -59,10 +60,22 @@ export function copyObjectIds(state: EditorState, holderId: string, ids: readonl return put(state, clip, roots, counted(roots.length, clip.items ? 'item' : 'object')); } -/** Copies the selected objects, or the selected nodes and boxes; false when nothing of that is selected. */ +/** Copies animation clips of a composition; false when none of them is there. */ +export function copyCompositionClips(state: EditorState, assetId: string, ids: readonly string[]): boolean { + const asset = findAssetOf(state.project, assetId, 'composition'); + const clips = asset?.clips.filter((c) => ids.includes(c.id)).map((c) => ({ ...c })) ?? []; + if (clips.length === 0) return false; + return put(state, { kind: 'compositionClips', clips }, [], counted(clips.length, 'clip')); +} + +/** + * Copies the selected objects, the selected clips of a composition, or the selected nodes and boxes; + * false when nothing of that is selected. + */ export function copySelection(state: EditorState): boolean { const s = state.selection; if (s?.kind === 'object') return copyObjectIds(state, s.holderId, s.objectIds); + if (s?.kind === 'composition') return copyCompositionClips(state, s.assetId, s.clipIds); if (s?.kind === 'node' || s?.kind === 'annotation') { const graph = findGraphAsset(state.project, s.assetId)?.graph; const nodeIds = s.kind === 'node' ? s.nodeIds : []; diff --git a/src/editor/composition/CompositionTimeline.ts b/src/editor/composition/CompositionTimeline.ts index bc233482..5705243a 100644 --- a/src/editor/composition/CompositionTimeline.ts +++ b/src/editor/composition/CompositionTimeline.ts @@ -35,6 +35,7 @@ import { mediaMarks, movedClips, movedItems, + pastedClips, resizedItem, snappedToMedia, trimmedItem, @@ -46,6 +47,7 @@ import { objectName } from '../../core/model/SceneObject'; import { activeDrag, crispText, + drawBand, endDrag, Menu, onDragChange, @@ -73,11 +75,13 @@ import { type EditorState, objectSelection } from '../state'; import { axisTween } from '../viewTween'; import { clipBox, + clipsInBand, type CompositionView, HEAD_WIDTH, type Hit, hitAt, itemBox, + itemsInBand, MAX_PX_PER_FRAME, overlaps, type Part, @@ -103,6 +107,8 @@ CLIP_LABEL.fill = Theme.colors.text; type Gesture = | { kind: 'pan'; pointer: Vec2; panX: number; panY: number } | { kind: 'scrub' } + /** A band from an empty spot picks the items it touches, with Alt from anywhere the clips; Ctrl adds. */ + | { kind: 'band'; start: Vec2; current: Vec2; active: boolean; add: boolean; clips: boolean } | { kind: 'move'; what: 'item' | 'clip'; @@ -137,8 +143,8 @@ interface Placed { * the items on their tracks and the animation clips over them, narrower. An asset dragged in from * the panel becomes an item – an animation a clip –, an item is moved, its ends dragged; a clip is * moved. Items of a track may overlap, the later one drawn over the earlier; clips of a track do not: - * a dropped clip lands in the gap it falls into, a moved one stops short. The ruler scrubs the - * playhead the picture above shows. + * a dropped clip lands in the gap it falls into, a moved one stops short. A band picks items, with + * Alt clips. The ruler scrubs the playhead the picture above shows. */ export class CompositionTimeline extends Widget { private readonly bg = new Graphics(); @@ -317,22 +323,47 @@ export class CompositionTimeline extends Widget { /** * Puts copied items in (Ctrl+V): the earliest under the pointer while it is over the lanes, the * topmost on its track; else at the playhead, on the picked track or the track of the picked item, - * or on the tracks they came from. False when the copy is not items of a composition. + * or on the tracks they came from. Copied clips go the same way, on to the first frame where none + * lies over another. False when the copy is neither items nor clips of a composition. */ paste(): boolean { const asset = this.asset(); const clip = this.state.clipboard?.clip; - if (!asset || clip?.kind !== 'objects') return false; + if (!asset || (clip?.kind !== 'objects' && clip?.kind !== 'compositionClips')) return false; const spot = this.pasteSpot(asset); const picked = this.pickedTrack(asset); const row = spot?.row ?? (picked >= 0 ? picked : undefined); const at = spot?.frame ?? Math.floor(this.playhead + 1e-6); + if (clip.kind === 'compositionClips') return this.pasteClips(asset, clip.clips, at, row); const pasted = pasteCompositionItems(this.state.project, asset.id, clip, at, row); if (!pasted || !this.state.execute(pasted.command)) return false; this.state.select(objectSelection(asset.id, pasted.itemIds)); return true; } + private pasteClips( + asset: CompositionAsset, + copied: readonly CompositionClip[], + at: number, + row: number | undefined, + ): boolean { + if (asset.tracks.length === 0) return false; + const ids = new Map(copied.map((c) => [c.id, newId()])); + const trackIds = asset.tracks.map((t) => t.id); + const placed = pastedClips(copied, asset.clips, ids, at, trackIds, this.lengthOf(asset), row); + if (placed.length === 0) return false; + const label = placed.length === 1 ? 'Paste clip' : 'Paste clips'; + const command = new SetCompositionCommand(asset.id, { clips: [...asset.clips, ...placed] }, label); + if (!this.state.execute(command)) return false; + this.state.select({ + kind: 'composition', + assetId: asset.id, + trackId: placed[0]!.track, + clipIds: placed.map((c) => c.id), + }); + return true; + } + /** Where a paste goes with the pointer over the lanes: the frame under it and the nearest track; else null. */ private pasteSpot(asset: CompositionAsset): { frame: number; row: number } | null { const p = this.pointer; @@ -490,11 +521,11 @@ export class CompositionTimeline extends Widget { return; } const hit = this.hitAt(asset, g); - if (!hit) { - this.state.select({ kind: 'asset', assetId: asset.id }); + const toggle = e.ctrlKey || e.metaKey; + if (!hit || e.altKey) { + this.gesture = { kind: 'band', start: g, current: g, active: false, add: toggle, clips: e.altKey }; return; } - const toggle = e.ctrlKey || e.metaKey; if (hit.kind === 'clip') { const clip = asset.clips.find((c) => c.id === hit.id)!; const picked = this.pickedClips(asset); @@ -575,6 +606,13 @@ export class CompositionTimeline extends Widget { case 'scrub': this.scrub(Math.max(0, Math.round(frame))); return; + case 'band': { + gesture.current = g; + const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y)); + if (!gesture.active && span < DRAG_THRESHOLD) return; + gesture.active = true; + break; + } case 'move': { if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD) return; @@ -672,6 +710,9 @@ export class CompositionTimeline extends Widget { if (!gesture || !asset) return; this.hover = null; switch (gesture.kind) { + case 'band': + this.pickBand(asset, gesture); + break; case 'move': { if (!gesture.moved) { if (gesture.what === 'item') this.state.select(objectSelection(asset.id, [gesture.anchor.id])); @@ -704,6 +745,30 @@ export class CompositionTimeline extends Widget { this.redraw(); } + /** + * Picks what a band touched, with Ctrl what was picked before as well. Let go without dragging, or + * catching nothing, it picks the composition, as a click on an empty spot always did. + */ + private pickBand(asset: CompositionAsset, band: Extract): void { + if (band.clips) { + const caught = band.active + ? clipsInBand(this.view, asset.tracks, asset.clips, this.lengthOf(asset), band.start, band.current) + : []; + const ids = [...new Set([...(band.add ? this.pickedClips(asset) : []), ...caught])]; + const first = asset.clips.find((c) => c.id === ids[0]); + if (first) + this.state.select({ kind: 'composition', assetId: asset.id, trackId: first.track, clipIds: ids }); + else this.state.select({ kind: 'asset', assetId: asset.id }); + return; + } + const caught = band.active + ? itemsInBand(this.view, asset.tracks, this.liveItems(asset), band.start, band.current) + : []; + const ids = [...new Set([...(band.add ? this.pickedItems(asset) : []), ...caught])]; + if (ids.length > 0) this.state.select(objectSelection(asset.id, ids)); + else this.state.select({ kind: 'asset', assetId: asset.id }); + } + private commit( asset: CompositionAsset, parts: ConstructorParameters[1], @@ -996,6 +1061,9 @@ export class CompositionTimeline extends Widget { this.drawDrop(asset); this.drawRuler(asset, ruler); this.drawPlayhead(); + const g = this.gesture; + const local = (p: Vec2) => ({ x: p.x + HEAD_WIDTH, y: p.y + RULER_HEIGHT }); + if (g?.kind === 'band' && g.active) drawBand(this.over, local(g.start), local(g.current)); } syncTexts(this.itemTexts, this.itemLayer, items, textStyle('small')); syncTexts(this.clipTexts, this.clipLayer, clips, CLIP_LABEL); diff --git a/src/editor/composition/compositionView.ts b/src/editor/composition/compositionView.ts index 7bc719fa..aecaceb3 100644 --- a/src/editor/composition/compositionView.ts +++ b/src/editor/composition/compositionView.ts @@ -93,6 +93,53 @@ export function hitAt( return edge?.hit ?? body; } +/** The items a band from `a` to `b` touches, as in the Song tab. */ +export function itemsInBand( + view: CompositionView, + tracks: readonly CompositionTrack[], + items: readonly CompositionItem[], + a: Vec2, + b: Vec2, +): string[] { + return touched(tracks, items, (row, item) => itemBox(view, row, item), a, b); +} + +/** The clips a band from `a` to `b` touches. */ +export function clipsInBand( + view: CompositionView, + tracks: readonly CompositionTrack[], + clips: readonly CompositionClip[], + clipLength: (clip: CompositionClip) => number, + a: Vec2, + b: Vec2, +): string[] { + const box = (row: number, clip: CompositionClip) => + clipBox(view, row, { start: clip.start, length: clipLength(clip) }); + return touched(tracks, clips, box, a, b); +} + +function touched( + tracks: readonly CompositionTrack[], + list: readonly T[], + boxOf: (row: number, t: T) => Box, + a: Vec2, + b: Vec2, +): string[] { + const rows = new Map(tracks.map((t, i) => [t.id, i])); + const x0 = Math.min(a.x, b.x); + const x1 = Math.max(a.x, b.x); + const y0 = Math.min(a.y, b.y); + const y1 = Math.max(a.y, b.y); + return list + .filter((t) => { + const row = rows.get(t.track); + if (row === undefined) return false; + const box = boxOf(row, t); + return box.x <= x1 && box.x + box.w >= x0 && box.y <= y1 && box.y + box.h >= y0; + }) + .map((t) => t.id); +} + /** Where items of a track overlap, in frames: one stretch for each pair. */ export function overlaps(items: readonly CompositionItem[]): { track: string; start: number; end: number }[] { const out: { track: string; start: number; end: number }[] = []; diff --git a/tests/core/composition-edit.test.ts b/tests/core/composition-edit.test.ts index ce1c714d..d3239486 100644 --- a/tests/core/composition-edit.test.ts +++ b/tests/core/composition-edit.test.ts @@ -25,13 +25,20 @@ import { mediaMarks, movedClips, movedItems, + pastedClips, pastedItems, resizedItem, snappedToMedia, trimmedItem, } from '../../src/core/model/compositionEdit'; import { copyObjects } from '../../src/core/model/clipboard'; -import { hitAt, overlaps, uncoveredEnd } from '../../src/editor/composition/compositionView'; +import { + clipsInBand, + hitAt, + itemsInBand, + overlaps, + uncoveredEnd, +} from '../../src/editor/composition/compositionView'; import { placementRejection } from '../../src/core/model/placement'; import { findAsset, findAssetOf } from '../../src/core/model/Project'; import { makeProject } from '../helpers'; @@ -282,6 +289,54 @@ describe('items and their objects', () => { expect(pastedItems(copied, ids, 0, tracks, 2).map((i) => i.track)).toEqual(['t2', 't3']); }); + it('pasted clips keep their timing and move on until none lies over another clip', () => { + const clip = (id: string, track: string, start: number) => + ({ id, track, animation: 'anim', start }) satisfies CompositionClip; + // Every clip is 10 frames long. + const lengthOf = () => 10; + const tracks = ['t1', 't2']; + const copied = [clip('a', 't1', 0), clip('b', 't2', 15)]; + const ids = new Map([ + ['a', 'a2'], + ['b', 'b2'], + ]); + // Room where they land: there they stay. + expect(pastedClips(copied, [clip('x', 't1', 50)], ids, 30, tracks, lengthOf)).toEqual([ + clip('a2', 't1', 30), + clip('b2', 't2', 45), + ]); + // `a2` at 45 would lie over `x` (50–60), `b2` over `y` (60–70): `a2` waits until 60, `b2` comes along. + const taken = [clip('x', 't1', 50), clip('y', 't2', 60)]; + expect(pastedClips(copied, taken, ids, 45, tracks, lengthOf)).toEqual([ + clip('a2', 't1', 60), + clip('b2', 't2', 75), + ]); + // Onto the clips they were copied from, they go right behind them, shifted to a row as far as it goes. + expect(pastedClips([clip('a', 't1', 0)], [clip('a', 't1', 0)], ids, 0, tracks, lengthOf, 1)).toEqual([ + clip('a2', 't2', 0), + ]); + expect(pastedClips([clip('a', 't1', 0)], [clip('a', 't1', 0)], ids, 0, tracks, lengthOf)).toEqual([ + clip('a2', 't1', 10), + ]); + }); + + it('a band picks the items and clips it touches', () => { + const view = { panX: 0, pxPerTick: 1, panY: 0, trackHeight: 40 }; + const tracks = [ + { id: 't1', name: 'One' }, + { id: 't2', name: 'Two' }, + ]; + const items = [item('a', 't1', 0, 50), item('b', 't1', 100, 50), item('c', 't2', 0, 50)]; + const clips = [{ id: 'k', track: 't2', animation: 'anim', start: 20 }]; + // Across the first track, from 40 to 60: only `a` reaches in. + expect(itemsInBand(view, tracks, items, { x: 40, y: 5 }, { x: 60, y: 30 })).toEqual(['a']); + // Down over both tracks at the start, drawn from bottom right to top left. + expect(itemsInBand(view, tracks, items, { x: 10, y: 70 }, { x: 5, y: 10 })).toEqual(['a', 'c']); + // Clips lie in the lower part of their track: a band through its upper part misses them. + expect(clipsInBand(view, tracks, clips, () => 10, { x: 0, y: 42 }, { x: 60, y: 50 })).toEqual([]); + expect(clipsInBand(view, tracks, clips, () => 10, { x: 0, y: 42 }, { x: 60, y: 70 })).toEqual(['k']); + }); + it('a copied item is pasted with its object and overrides under new ids, in one undo step', () => { const cut = makeCut(); const id = cut.add(10, 60); diff --git a/tools/e2e/69-composition-tab.mjs b/tools/e2e/69-composition-tab.mjs index 92ad758b..4a557d9a 100644 --- a/tools/e2e/69-composition-tab.mjs +++ b/tools/e2e/69-composition-tab.mjs @@ -7,7 +7,8 @@ * to begin is opened ahead, so the cut to it shows its picture at once, and Play waits for a video * whose player is still loading. A copied item is pasted under the pointer or at the playhead. A * video dragged in wears a sprite the composition keeps to itself. An item dragged in the picture - * follows the pointer while the button is down. + * follows the pointer while the button is down. A band picks items, with Alt clips, and copied clips + * are pasted where they fit. * * Everything it adds carries a `ct69` id and goes again at the end. */ @@ -377,6 +378,59 @@ export async function compositionTab(t) { JSON.stringify({ front, startAt, during, xBefore, xAfter }), ); + // 69m. A band from an empty spot picks the items it touches, with Alt – from anywhere, an item too – + // the clips. Ctrl+C and Ctrl+V take the clips under the pointer; a second paste onto the first + // moves on until the clips fit, as clips of a track never overlap. + await mutate(` + const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); + const like = structuredClone(c.objects[0]); + c.objects = ['ct69-b1', 'ct69-b2', 'ct69-b3'].map((id) => ({ ...like, id, assetId: '${BOX}' })); + c.items = [ + { id: 'ct69-b1', track: 'ct69-t1', start: 0, length: 30, offset: 0 }, + { id: 'ct69-b2', track: 'ct69-t1', start: 40, 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: 0 }, + { id: 'ct69-k2', track: 'ct69-t2', animation: '${SLIDE}', start: 10 }, + ];`); + await sleep(100); + const empty = await json(`__gm.composition.laneAt('ct69-t2', 90)`); + const into = await json(`__gm.composition.laneAt('ct69-t1', 25)`); + await drag(empty.x, empty.y, into.x, into.y); + const banded = await json('__gm.state.selection'); + const k1 = await json(`__gm.composition.box('ct69-k1')`); + const k2 = await json(`__gm.composition.box('ct69-k2')`); + await drag(k1.x + 1, k1.y - 3, k2.x + k2.w + 3, k2.y + k2.h - 1, { modifiers: { alt: true } }); + const altBanded = await json('__gm.state.selection'); + await press('c', { ctrl: true }); + const pasteAt = await json(`__gm.composition.laneAt('ct69-t1', 50)`); + await mouse('mouseMoved', pasteAt.x, pasteAt.y); + await press('v', { ctrl: true }); + const firstPaste = (await model()).clips.slice(2); + await press('v', { ctrl: true }); + m = await model(); + const secondPaste = m.clips.slice(4); + const pastedPick = await json('__gm.state.selection'); + check( + 'a band picks the items it touches, with Alt the clips, and copied clips paste where they fit', + JSON.stringify([...(banded?.objectIds ?? [])].sort()) === + JSON.stringify(['ct69-b1', 'ct69-b2', 'ct69-b3']) && + altBanded?.kind === 'composition' && + JSON.stringify([...altBanded.clipIds].sort()) === JSON.stringify(['ct69-k1', 'ct69-k2']) && + firstPaste.length === 2 && + firstPaste[0].track === 'ct69-t1' && + firstPaste[0].start === 50 && + firstPaste[1].track === 'ct69-t2' && + firstPaste[1].start === 60 && + secondPaste.length === 2 && + secondPaste[0].start - firstPaste[0].start >= 5 && + secondPaste[0].start - firstPaste[0].start <= 6 && + secondPaste[1].start - secondPaste[0].start === 10 && + JSON.stringify(pastedPick?.clipIds) === JSON.stringify(secondPaste.map((c) => c.id)), + JSON.stringify({ banded: banded?.objectIds, altBanded, firstPaste, secondPaste, pastedPick }), + ); + await showTab('scene'); await mutate(` const p = __gm.state.project;