diff --git a/src/core/commands/compositionCommands.ts b/src/core/commands/compositionCommands.ts index 3c0fc474..4a7e8c43 100644 --- a/src/core/commands/compositionCommands.ts +++ b/src/core/commands/compositionCommands.ts @@ -3,8 +3,13 @@ * 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 { heldCompositionClips, heldCompositionItems, heldCompositionTracks } from '../model/composition'; -import { mediaKey, refittedLength } from '../model/compositionEdit'; +import { + fittedLength, + heldCompositionClips, + heldCompositionItems, + heldCompositionTracks, +} from '../model/composition'; +import { mediaKey } from '../model/compositionEdit'; import type { Project } from '../model/Project'; import type { Command } from './Command'; import { oneStep } from './CompoundCommand'; @@ -106,7 +111,7 @@ export function removeCompositionTrack(project: Project, assetId: string, trackI clips: comp.clips.filter((c) => !clips.includes(c.id)), }); return oneStep( - 'Remove track', + 'Delete track', objects.length > 0 ? [new RemoveSceneObjectsCommand(assetId, objects), set] : [set], ); } @@ -162,7 +167,7 @@ function refitsAfter(project: Project, before: ReadonlyMap): Ref for (const item of comp.items) { const was = before.get(`${comp.id}/${item.id}`); if (!item.fitMedia || was === undefined || was === mediaKey(project, comp, item)) continue; - const to = refittedLength(project, comp, item); + const to = fittedLength(project, comp, item); if (to !== item.length) refits.push({ assetId: comp.id, itemId: item.id, from: item.length, to }); } } diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 4e98540c..277fb29d 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -268,8 +268,10 @@ export interface CompositionTrack { /** * One stretch on a track: the object of the same id lives from `start` for `length` frames. - * `offset` is how many seconds into its video the item begins, which a trim of the left edge moves. - * `fitMedia` says its length still follows that video, or the default while there is none. + * `offset` is how many seconds into its video the item begins, which a trim of the left edge moves; + * below 0 the item begins before the video and shows its first picture until then. `fitMedia` says + * its length still follows that video, or the default while there is none. Items of a track may + * overlap. */ export interface CompositionItem { id: string; @@ -281,8 +283,8 @@ export interface CompositionItem { } /** - * An animation laid on a track from `start`, one pass long. It plays on whichever item of its track - * it overlaps; its rows find their targets there as `Play animation` finds them. + * An animation laid on a track from `start`, one pass long. It plays on every item of its track it + * overlaps; its rows find their targets there as `Play animation` finds them. */ export interface CompositionClip { id: string; diff --git a/src/core/model/composition.ts b/src/core/model/composition.ts index c3d4f4ba..d46c74b2 100644 --- a/src/core/model/composition.ts +++ b/src/core/model/composition.ts @@ -101,7 +101,7 @@ function videoWorn(props: PropertyBag, project: Project): VideoRef | null { } /** - * The length an item with `fitMedia` takes: what is left of its video after `offset`, or the default + * The length an item with `fitMedia` takes: from its start to the end of its video, or the default * while it wears none or the video's length is not known yet. */ export function fittedLength(project: Project, comp: CompositionAsset, item: CompositionItem): number { @@ -187,8 +187,9 @@ export interface LiveItem { * Everything alive at `frame`, in drawing order: the last track first, so the first lies in front – * the order the items take below the composition, which decides between equal `z`. An item lives * from its start up to, not including, its end; one on a track that is gone, or whose object is gone, - * does not. A clip plays on an item of its track it overlaps once it has started, and holds its last - * frame for as long as that item lasts. + * does not. Items of a track that overlap both live, the later one in front. A clip plays on every + * item of its track it overlaps once it has started, and holds its last frame for as long as that + * item lasts. */ export function compositionAt(project: Project, comp: CompositionAsset, frame: number): LiveItem[] { const fps = compositionFps(comp); @@ -307,7 +308,7 @@ export function heldCompositionTracks(raw: unknown): CompositionTrack[] { /** * Held to what an item may be: the id of an object it spawns and no other item does, a track of - * the composition, a start in whole frames from 0, at least a frame long, an offset from 0. + * the composition, a start in whole frames from 0, at least a frame long, a finite offset. */ export function heldCompositionItems( raw: unknown, @@ -329,7 +330,7 @@ export function heldCompositionItems( track: text(i.track), start, length, - offset: offset > 0 ? offset : 0, + offset: Number.isFinite(offset) ? offset : 0, }; if (i.fitMedia === true) item.fitMedia = true; items.push(item); diff --git a/src/core/model/compositionEdit.ts b/src/core/model/compositionEdit.ts index 1033caa4..af5bb37b 100644 --- a/src/core/model/compositionEdit.ts +++ b/src/core/model/compositionEdit.ts @@ -1,10 +1,12 @@ /** * What the Composition tab does to items and clips (#417), as plain functions on lists so a test can - * check them. Items of a track never overlap, nor do clips of a track; an edit that would make them - * is held at the last place it fits, a drop lands in the gap it falls into. + * check them. Items of a track may overlap – both live there, as on two tracks –, clips of a track + * do not: a clip's edit that would make them is held short, a dropped clip lands in the gap it falls + * into. */ +import { videoLoopsOf } from '../properties/videos'; import type { Asset, CompositionAsset, CompositionClip, CompositionItem } from './Asset'; -import { compositionFps, DEFAULT_ITEM_SECONDS, fittedLength, itemEnd, itemVideo } from './composition'; +import { compositionFps, DEFAULT_ITEM_SECONDS, itemEnd, itemVideo } from './composition'; import type { Project } from './Project'; import { findVideo, videoLengthOf } from './video'; @@ -33,15 +35,6 @@ export function droppedSpan(taken: readonly Span[], at: number, length: number): return room >= 1 ? { start, length: Math.min(Math.max(1, length), room) } : null; } -/** The stretches the items of a track cover, all but `except`. */ -export function itemSpans( - items: readonly CompositionItem[], - track: string, - except: ReadonlySet = new Set(), -): Span[] { - return items.filter((i) => i.track === track && !except.has(i.id)); -} - /** The stretches the clips of a track cover, all but `except`. */ export function clipSpans( clips: readonly CompositionClip[], @@ -67,16 +60,32 @@ function apart(list: readonly T[], lengthOf: (t: T) => number) } /** - * The picked ones moved by `frames` and `tracks`; null where one would start before 0, leave the - * tracks there are, or come to lie over another. + * A move of the picked ones held so that none starts before 0 or leaves the tracks there are: what + * the pointer asks for, as far as it goes. */ +export function clampedMove( + list: readonly Placed[], + picked: ReadonlySet, + trackIds: readonly string[], + frames: number, + tracks: number, +): { frames: number; tracks: number } { + const chosen = list.filter((t) => picked.has(t.id)); + if (chosen.length === 0) return { frames: 0, tracks: 0 }; + const rows = chosen.map((t) => trackIds.indexOf(t.track)); + return { + // `|| 0`: no -0 for one at frame 0. + frames: Math.max(frames, -Math.min(...chosen.map((t) => t.start))) || 0, + tracks: Math.max(-Math.min(...rows), Math.min(trackIds.length - 1 - Math.max(...rows), tracks)) || 0, + }; +} + function moved( list: readonly T[], picked: ReadonlySet, trackIds: readonly string[], frames: number, tracks: number, - lengthOf: (t: T) => number, ): T[] | null { const out: T[] = []; for (const t of list) { @@ -88,9 +97,10 @@ function moved( if (track === undefined || t.start + frames < 0) return null; out.push({ ...t, start: t.start + frames, track }); } - return apart(out, lengthOf) ? out : null; + return out; } +/** The picked items moved by `frames` and `tracks`; null where one would start before 0 or leave the tracks. */ export function movedItems( items: readonly CompositionItem[], picked: ReadonlySet, @@ -98,9 +108,10 @@ export function movedItems( frames: number, tracks: number, ): CompositionItem[] | null { - return moved(items, picked, trackIds, frames, tracks, (i) => i.length); + return moved(items, picked, trackIds, frames, tracks); } +/** The picked clips moved like items; null, too, where one would come to lie over another. */ export function movedClips( clips: readonly CompositionClip[], picked: ReadonlySet, @@ -109,67 +120,92 @@ export function movedClips( tracks: number, lengthOf: (clip: CompositionClip) => number, ): CompositionClip[] | null { - return moved(clips, picked, trackIds, frames, tracks, lengthOf); + const out = moved(clips, picked, trackIds, frames, tracks); + return out && apart(out, lengthOf) ? out : null; } -/** The first frame after `item` another item of its track takes; Infinity when none follows. */ -function nextStart(items: readonly CompositionItem[], item: CompositionItem): number { - return Math.min( - ...items - .filter((i) => i.track === item.track && i.id !== item.id && i.start >= item.start) - .map((i) => i.start), - ); -} - -/** The end of the item before `item` on its track, 0 when none comes before. */ -function previousEnd(items: readonly CompositionItem[], item: CompositionItem): number { - return Math.max( - 0, - ...items.filter((i) => i.track === item.track && i.id !== item.id && i.start < item.start).map(itemEnd), - ); -} - -/** - * The item with its end edge dragged to make it `wanted` frames long: at least a frame, and no - * further than where the next item of its track begins. Its length is its own from then on. - */ -export function resizedItem( - items: readonly CompositionItem[], - item: CompositionItem, - wanted: number, -): CompositionItem { - const most = nextStart(items, item) - item.start; +/** The item with its end edge dragged to make it `wanted` frames long, at least one; its length is its own from then on. */ +export function resizedItem(item: CompositionItem, wanted: number): CompositionItem { const { fitMedia: _, ...rest } = item; - return { ...rest, length: Math.max(1, Math.min(Math.round(wanted), most)) }; + return { ...rest, length: Math.max(1, Math.round(wanted)) }; } /** - * The item with its start edge dragged to `wanted`: never onto its end, before 0 or into the item - * before. What it shows stays where it was in time – the video starts that much later or sooner – - * so an item wearing a video stops at the video's beginning. + * The item with its start edge dragged to `wanted`: never onto its end or before 0. What it shows + * stays where it was in time – the video starts that much later or sooner –, and pulled out before + * the video's beginning the item shows its first picture until the video starts. */ export function trimmedItem( - items: readonly CompositionItem[], item: CompositionItem, wanted: number, fps: number, video: boolean, ): CompositionItem { - let start = Math.min(Math.round(wanted), itemEnd(item) - 1); - start = Math.max(start, previousEnd(items, item)); - if (video) start = Math.max(start, item.start - Math.floor(item.offset * fps + 1e-6)); + const start = Math.max(0, Math.min(Math.round(wanted), itemEnd(item) - 1)); const by = start - item.start; const { fitMedia: _, ...rest } = item; - const offset = video ? Math.max(0, item.offset + by / fps) : item.offset; + const offset = video ? Math.round((item.offset + by / fps) * 1e6) / 1e6 || 0 : item.offset; return { ...rest, start, length: item.length - by, offset }; } +/** Where an item's video lies in the composition, in frames: where it begins, how long a pass is, whether it goes round. */ +export interface ItemMedia { + begins: number; + length: number; + loops: boolean; +} + +/** The video of an item as it lies on its track; null without one, or while its length is not known. */ +export function itemMedia(project: Project, comp: CompositionAsset, item: CompositionItem): ItemMedia | null { + const video = itemVideo(project, comp, item); + const seconds = video ? videoLengthOf(video) : 0; + if (!video || !(seconds > 0)) return null; + const fps = compositionFps(comp); + return { + begins: item.start - item.offset * fps, + length: seconds * fps, + loops: videoLoopsOf(video.asset.props), + }; +} + +/** The frames from `from` to `to` where the video begins or a pass of it ends. */ +export function mediaEdges(media: ItemMedia, from: number, to: number): number[] { + const edges = media.begins >= from && media.begins <= to ? [media.begins] : []; + const first = Math.max(1, Math.ceil((from - media.begins) / media.length)); + for (let pass = first; pass === 1 || media.loops; pass++) { + const end = media.begins + pass * media.length; + if (end > to) break; + if (end >= from) edges.push(end); + } + return edges; +} + +/** Half a frame: closer than this an edge counts as on the video's. */ +const ON_EDGE = 0.5; + /** - * The length an item that follows its media takes now, held to where the next item of its track - * begins: the items after it stay where they are. + * What the timeline marks on an item wearing a video: where inside it the video begins or a pass + * ends (`seams`), and which of its ends cut into the video – starting after its beginning, ending + * before its end. */ -export function refittedLength(project: Project, comp: CompositionAsset, item: CompositionItem): number { - return Math.max(1, Math.min(fittedLength(project, comp, item), nextStart(comp.items, item) - item.start)); +export function mediaMarks( + item: CompositionItem, + media: ItemMedia, +): { seams: number[]; cutStart: boolean; cutEnd: boolean } { + return { + seams: mediaEdges(media, item.start + ON_EDGE, itemEnd(item) - ON_EDGE), + cutStart: item.start > media.begins + ON_EDGE, + cutEnd: itemEnd(item) < media.begins + media.length - ON_EDGE, + }; +} + +/** A frame pulled onto the nearest edge of the video within `reach` frames; else as it is. */ +export function snappedToMedia(frame: number, media: ItemMedia | null, reach: number): number { + const edges = media ? mediaEdges(media, frame - reach, frame + reach) : []; + return edges.reduce( + (best, edge) => (Math.abs(edge - frame) < Math.abs(best - frame) ? edge : best), + edges[0] ?? frame, + ); } /** How long an item of `asset` is when it is dropped in: as long as the video its sprite wears, else 3 s. */ diff --git a/src/editor/composition/CompositionTimeline.ts b/src/editor/composition/CompositionTimeline.ts index 52d05458..70ace770 100644 --- a/src/editor/composition/CompositionTimeline.ts +++ b/src/editor/composition/CompositionTimeline.ts @@ -4,9 +4,11 @@ import { type FederatedWheelEvent, Graphics, type Text, + type TextStyle, } from 'pixi.js'; import { addCompositionItem, + removeCompositionTrack, removeFromComposition, SetCompositionCommand, } from '../../core/commands/compositionCommands'; @@ -22,13 +24,17 @@ import { itemVideo, } from '../../core/model/composition'; import { + clampedMove, clipSpans, droppedLength, droppedSpan, - itemSpans, + itemMedia, + type ItemMedia, + mediaMarks, movedClips, movedItems, resizedItem, + snappedToMedia, trimmedItem, } from '../../core/model/compositionEdit'; import { placementRejection } from '../../core/model/placement'; @@ -51,7 +57,16 @@ import { } from '../../ui'; import { fitToMedia } from '../inspector/compositionSection'; import { clampTimePan, tickToX, xToTick, zoomTime } from '../music/timeView'; -import { clampTracks, fitSong, nearestTrack, trackAtY, trackTop, zoomTracks } from '../song/songView'; +import { + type Box, + clampTracks, + EDGE_REACH, + fitSong, + nearestTrack, + trackAtY, + trackTop, + zoomTracks, +} from '../song/songView'; import { type EditorState, objectSelection } from '../state'; import { axisTween } from '../viewTween'; import { @@ -62,10 +77,14 @@ import { hitAt, itemBox, MAX_PX_PER_FRAME, + overlaps, + type Part, RULER_HEIGHT, rulerStep, rulerText, + stacked, TRACK_HEIGHT, + uncoveredEnd, } from './compositionView'; const DRAG_THRESHOLD = 3; @@ -74,6 +93,10 @@ const VIDEO_COLOR = 0x2f6f52; const CLIP_COLOR = 0x8a5a2b; const REJECTED_COLOR = 0x6b3a3a; const DEFAULT_VIEW: CompositionView = { panX: 0, pxPerTick: 2, panY: 0, trackHeight: TRACK_HEIGHT }; +/** Room at the top of an item for its name; marks of its video keep below it. */ +const LABEL_STRIP = 15; +const CLIP_LABEL = textStyle('small').clone(); +CLIP_LABEL.fill = Theme.colors.text; type Gesture = | { kind: 'pan'; pointer: Vec2; panX: number; panY: number } @@ -88,7 +111,7 @@ type Gesture = frames: number; tracks: number; } - | { kind: 'resize' | 'trim'; anchor: CompositionItem; item: CompositionItem }; + | { kind: 'resize' | 'trim'; anchor: CompositionItem; item: CompositionItem; media: ItemMedia | null }; /** An asset dragged in from the panel: an item or, for an animation, a clip – and whether it fits there. */ interface Drop { @@ -111,8 +134,9 @@ interface Placed { * The timeline of the Composition tab (#417): track heads on the left, a ruler in seconds on top, * 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. Nothing of a track overlaps: a drop lands in the gap it falls into, a move stops short. - * The ruler scrubs the playhead the picture above shows. + * 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. */ export class CompositionTimeline extends Widget { private readonly bg = new Graphics(); @@ -120,9 +144,11 @@ export class CompositionTimeline extends Widget { private readonly over = new Graphics(); private readonly clip = new Graphics(); private readonly itemLayer = new Container(); + private readonly clipLayer = new Container(); private readonly headLayer = new Container(); private readonly rulerLayer = new Container(); private readonly itemTexts: Text[] = []; + private readonly clipTexts: Text[] = []; private readonly headTexts: Text[] = []; private readonly rulerTexts: Text[] = []; private readonly hint = crispText({ text: '', style: textStyle('muted') }); @@ -140,6 +166,8 @@ export class CompositionTimeline extends Widget { private assetId = ''; private gesture: Gesture | null = null; private drop: Drop | null = null; + /** The end of an item the pointer would drag; it lights up, and the cursor says so. */ + private hover: { id: string; part: Part } | null = null; private needsFit = true; /** Where the playhead stands, in frames – fractional while playing. */ playhead = 0; @@ -156,6 +184,7 @@ export class CompositionTimeline extends Widget { this.lanes, this.itemLayer, this.over, + this.clipLayer, this.headLayer, this.rulerLayer, this.hint, @@ -164,8 +193,10 @@ export class CompositionTimeline extends Widget { this.lanes.mask = this.clip; this.itemLayer.mask = this.clip; this.over.mask = this.clip; + this.clipLayer.mask = this.clip; this.hint.eventMode = 'none'; this.itemLayer.eventMode = 'none'; + this.clipLayer.eventMode = 'none'; this.headLayer.eventMode = 'none'; this.rulerLayer.eventMode = 'none'; this.focusable = true; @@ -312,8 +343,19 @@ export class CompositionTimeline extends Widget { return this.drop && { ...this.drop }; } - viewInfo(): CompositionView & { playhead: number; playing: boolean } { - return { ...this.view, playhead: this.playhead, playing: this.playingNow }; + viewInfo(): CompositionView & { + playhead: number; + playing: boolean; + hover: { id: string; part: Part } | null; + cursor: string; + } { + return { + ...this.view, + playhead: this.playhead, + playing: this.playingNow, + hover: this.hover, + cursor: String(this.cursor ?? ''), + }; } // --- pointer ----------------------------------------------------------------------------------- @@ -431,8 +473,9 @@ export class CompositionTimeline extends Widget { } if (!picked.includes(item.id) || hit.part !== 'body') this.state.select(objectSelection(asset.id, [item.id])); - if (hit.part === 'end') this.gesture = { kind: 'resize', anchor: item, item }; - else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor: item, item }; + const media = itemMedia(this.state.project, asset, item); + if (hit.part === 'end') this.gesture = { kind: 'resize', anchor: item, item, media }; + else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor: item, item, media }; else this.startMove('item', item, g); } @@ -455,7 +498,11 @@ export class CompositionTimeline extends Widget { return; } const gesture = this.gesture; - if (!gesture || !asset) return; + if (!asset) return; + if (!gesture) { + this.followHover(asset, local, g); + return; + } const frame = xToTick(this.view, g.x); switch (gesture.kind) { case 'pan': @@ -483,34 +530,72 @@ 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; - const frames = Math.round(frame - gesture.grab) - gesture.anchor.start; const row = nearestTrack(this.view, asset.tracks.length, g.y); - const tracks = row - asset.tracks.findIndex((t) => t.id === gesture.anchor.track); - // Where it would overlap, it stays at the last place it fitted. - if (this.movedList(asset, gesture.what, frames, tracks)) { - gesture.frames = frames; - gesture.tracks = tracks; - } else if (this.movedList(asset, gesture.what, frames, gesture.tracks)) gesture.frames = frames; + const held = this.heldMove( + asset, + gesture.what, + 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; break; } - case 'resize': - gesture.item = resizedItem(asset.items, gesture.anchor, Math.round(frame) - gesture.anchor.start); + case 'resize': { + const end = this.snapped(frame, gesture.media); + gesture.item = resizedItem(gesture.anchor, end - gesture.anchor.start); break; - case 'trim': { - const video = itemVideo(this.state.project, asset, gesture.anchor) !== null; + } + case 'trim': gesture.item = trimmedItem( - asset.items, gesture.anchor, - Math.round(frame), + this.snapped(frame, gesture.media), compositionFps(asset), - video, + gesture.media !== null || itemVideo(this.state.project, asset, gesture.anchor) !== null, ); break; - } } this.redraw(); } + /** A frame on an edge of the item's video when the pointer is that close to one, else the frame nearest. */ + private snapped(frame: number, media: ItemMedia | null): number { + return Math.round(snappedToMedia(frame, media, EDGE_REACH / this.view.pxPerTick)); + } + + /** + * 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. + */ + private heldMove( + asset: CompositionAsset, + what: 'item' | 'clip', + frames: number, + tracks: number, + last: { frames: number; tracks: number }, + ): { frames: number; tracks: number } { + const picked = new Set(what === 'item' ? this.pickedItems(asset) : this.pickedClips(asset)); + const list = what === 'item' ? asset.items : asset.clips; + const wanted = clampedMove( + list, + picked, + asset.tracks.map((t) => t.id), + 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; + } + private movedList( asset: CompositionAsset, what: 'item' | 'clip', @@ -536,6 +621,7 @@ export class CompositionTimeline extends Widget { const gesture = this.gesture; this.gesture = null; if (!gesture || !asset) return; + this.hover = null; switch (gesture.kind) { case 'move': { if (!gesture.moved) { @@ -579,8 +665,9 @@ export class CompositionTimeline extends Widget { } /** - * Follows an asset dragged in from the panel: a placeable one lands as an item in the gap under the - * pointer, as long as its media or 3 s; an animation as a clip one pass long, where it fits whole. + * Follows an asset dragged in from the panel: a placeable one lands as an item under the pointer, + * as long as its media or 3 s, over whatever lies there; an animation as a clip one pass long, where + * it fits whole. */ private followDrop(asset: CompositionAsset, assetId: string, local: Vec2, g: Vec2): void { const dragged = findAsset(this.state.project, assetId); @@ -619,20 +706,27 @@ export class CompositionTimeline extends Widget { length: Math.max(1, length), rejection, }; - } else { - const wanted = droppedLength(this.state.project, asset, dragged); - const span = droppedSpan(itemSpans(this.liveItems(asset), track), at, wanted); - const rejection = - placementRejection(this.state.project, asset, dragged) || (span ? '' : 'No room there'); + } else this.drop = { what: 'item', assetId, track, - start: span?.start ?? at, - length: span?.length ?? wanted, - rejection, + start: at, + length: droppedLength(this.state.project, asset, dragged), + rejection: placementRejection(this.state.project, asset, dragged), }; - } + this.redraw(); + } + + /** The end of an item under the pointer it can be dragged by: a resize cursor, and the end lights up. */ + private followHover(asset: CompositionAsset, local: Vec2, g: Vec2): void { + const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h; + const hit = inside ? this.hitAt(asset, g) : null; + const hover = hit?.kind === 'item' && hit.part !== 'body' ? { id: hit.id, part: hit.part } : null; + const cursor = hover ? 'ew-resize' : 'default'; + if (this.cursor !== cursor) this.cursor = cursor; + if (JSON.stringify(hover) === JSON.stringify(this.hover)) return; + this.hover = hover; this.redraw(); } @@ -692,7 +786,6 @@ export class CompositionTimeline extends Widget { private openMenu(asset: CompositionAsset, local: Vec2, g: Vec2, global: Vec2): void { const row = local.y >= RULER_HEIGHT ? trackAtY(this.view, asset.tracks.length, g.y) : null; const hit = local.x >= HEAD_WIDTH && local.y >= RULER_HEIGHT ? this.hitAt(asset, g) : null; - const addTrack = { label: 'Add track', onSelect: () => this.addTrack() }; if (hit?.kind === 'item') { if (!this.pickedItems(asset).includes(hit.id)) this.state.select(objectSelection(asset.id, [hit.id])); const ids = this.pickedItems(asset); @@ -739,31 +832,43 @@ export class CompositionTimeline extends Widget { ]).open(global.x, global.y); return; } - const track = row === null ? undefined : asset.tracks[row]; + if (row !== null) this.openTrackMenu(asset, row, global); + else if (local.y >= RULER_HEIGHT) + new Menu([{ label: 'Add track', onSelect: () => this.addTrack() }]).open(global.x, global.y); + } + + /** The menu of a track, on its head or an empty spot of it – the Song tab's. */ + private openTrackMenu(asset: CompositionAsset, row: number, global: Vec2): void { + const track = asset.tracks[row]!; + const swap = (other: number) => { + const tracks = [...asset.tracks]; + [tracks[row], tracks[other]] = [tracks[other]!, tracks[row]!]; + this.commit(asset, { tracks }, 'Move track'); + }; new Menu([ - addTrack, - ...(track - ? [ - { - label: `Select "${track.name}"`, - onSelect: () => - this.state.select({ kind: 'composition', assetId: asset.id, trackId: track.id, clipIds: [] }), - }, - ] - : []), + { label: 'Add track above', onSelect: () => this.addTrack(row) }, + { label: 'Add track below', onSelect: () => this.addTrack(row + 1) }, + { label: 'Move up', disabled: row === 0, onSelect: () => swap(row - 1) }, + { label: 'Move down', disabled: row === asset.tracks.length - 1, onSelect: () => swap(row + 1) }, + { + label: 'Delete track', + disabled: asset.tracks.length <= 1, + onSelect: () => { + if (this.state.execute(removeCompositionTrack(this.state.project, asset.id, track.id))) + this.state.select({ kind: 'asset', assetId: asset.id }); + }, + }, ]).open(global.x, global.y); } - /** A new track at the bottom, behind all the others. */ - addTrack(): void { + /** A new track at `index` – at the bottom, behind all the others, unless told. */ + addTrack(index?: number): void { const asset = this.asset(); if (!asset) return; const track = { id: newId(), name: `Track ${asset.tracks.length + 1}` }; - if ( - this.state.execute( - new SetCompositionCommand(asset.id, { tracks: [...asset.tracks, track] }, 'Add track'), - ) - ) + const tracks = [...asset.tracks]; + tracks.splice(index ?? tracks.length, 0, track); + if (this.state.execute(new SetCompositionCommand(asset.id, { tracks }, 'Add track'))) this.state.select({ kind: 'composition', assetId: asset.id, trackId: track.id, clipIds: [] }); } @@ -827,19 +932,21 @@ export class CompositionTimeline extends Widget { this.lanes.clear(); this.over.clear(); const items: Placed[] = []; + const clips: Placed[] = []; const heads: Placed[] = []; const ruler: Placed[] = []; if (asset) { this.drawTracks(asset, heads); this.drawItems(asset, items); - this.drawClips(asset, items); + this.drawClips(asset, clips); this.drawDrop(asset); this.drawRuler(asset, ruler); this.drawPlayhead(); } - syncTexts(this.itemTexts, this.itemLayer, items, 'small'); - syncTexts(this.headTexts, this.headLayer, heads, 'body'); - syncTexts(this.rulerTexts, this.rulerLayer, ruler, 'small'); + syncTexts(this.itemTexts, this.itemLayer, items, textStyle('small')); + syncTexts(this.clipTexts, this.clipLayer, clips, CLIP_LABEL); + syncTexts(this.headTexts, this.headLayer, heads, textStyle('body')); + syncTexts(this.rulerTexts, this.rulerLayer, ruler, textStyle('small')); const hint = !asset ? '' : asset.items.length === 0 && asset.clips.length === 0 @@ -881,12 +988,16 @@ export class CompositionTimeline extends Widget { const c = Theme.colors; const rows = new Map(asset.tracks.map((t, i) => [t.id, i])); const picked = new Set(this.pickedItems(asset)); - for (const item of this.shownItems(asset)) { + const shown = stacked(this.shownItems(asset)); + const boxes = new Map(); + for (const item of shown) { const row = rows.get(item.track); if (row === undefined) continue; const box = this.onScreen(itemBox(this.view, row, item)); + boxes.set(item.id, box); if (box.x > this.w || box.x + box.w < HEAD_WIDTH) continue; const lit = picked.has(item.id); + const media = itemMedia(this.state.project, asset, item); const color = itemVideo(this.state.project, asset, item) ? VIDEO_COLOR : ITEM_COLOR; this.lanes .roundRect(box.x, box.y, box.w, box.h, 3) @@ -897,12 +1008,78 @@ export class CompositionTimeline extends Widget { alpha: lit ? 1 : 0.45, alignment: 1, }); + if (media) this.drawMediaMarks(item, media, box); const object = itemObject(asset, item); const placed = object && findAsset(this.state.project, object.assetId); const name = object ? objectName(object, placed) : ''; - if (box.w > 18) - labels.push({ text: name, x: Math.max(box.x, HEAD_WIDTH) + 4, y: box.y + 2, w: box.w - 8 }); + const seen = HEAD_WIDTH + tickToX(this.view, uncoveredEnd(shown, item)) - box.x; + if (seen > 18) + labels.push({ text: name, x: Math.max(box.x, HEAD_WIDTH) + 4, y: box.y + 2, w: seen - 8 }); } + this.drawOverlaps(shown, rows); + const hover = this.hover && boxes.get(this.hover.id); + if (hover && this.hover!.part !== 'body') this.drawGrip(hover, this.hover!.part); + } + + /** + * Where the video of an item lies against the item: a line where it begins or a pass of it ends + * inside – the item reaches out before or past it –, and a notch at an end that cuts into it. + */ + private drawMediaMarks(item: CompositionItem, media: ItemMedia, box: Box): void { + const marks = mediaMarks(item, media); + const top = box.y + LABEL_STRIP; + for (const frame of marks.seams) { + const x = Math.round(HEAD_WIDTH + tickToX(this.view, frame)); + if (x <= box.x + 1 || x >= box.x + box.w - 1) continue; + this.lanes.rect(x - 1, top, 1, box.h - LABEL_STRIP).fill({ color: 0x000000, alpha: 0.5 }); + this.lanes.rect(x, top, 1, box.h - LABEL_STRIP).fill({ color: 0xffffff, alpha: 0.55 }); + this.lanes + .moveTo(x - 3, top) + .lineTo(x + 4, top) + .lineTo(x + 0.5, top + 4) + .closePath() + .fill({ color: 0xffffff, alpha: 0.8 }); + } + const size = Math.min(7, box.w / 3, box.h / 3); + const bottom = box.y + box.h; + if (marks.cutStart) + this.lanes + .moveTo(box.x, bottom) + .lineTo(box.x + size, bottom) + .lineTo(box.x, bottom - size) + .closePath() + .fill({ color: 0xffffff, alpha: 0.75 }); + if (marks.cutEnd) { + const right = box.x + box.w; + this.lanes + .moveTo(right, bottom) + .lineTo(right - size, bottom) + .lineTo(right, bottom - size) + .closePath() + .fill({ color: 0xffffff, alpha: 0.75 }); + } + } + + /** Where items of a track overlap, shaded, with the covered end of the earlier one drawn through. */ + private drawOverlaps(items: readonly CompositionItem[], rows: ReadonlyMap): void { + for (const span of overlaps(items)) { + const row = rows.get(span.track); + if (row === undefined) continue; + const box = this.onScreen( + itemBox(this.view, row, { start: span.start, length: span.end - span.start }), + ); + if (box.x > this.w || box.x + box.w < HEAD_WIDTH) continue; + this.lanes.rect(box.x, box.y, box.w, box.h).fill({ color: 0x000000, alpha: 0.25 }); + this.lanes.rect(box.x + box.w - 1, box.y, 1, box.h).fill({ color: 0xffffff, alpha: 0.6 }); + } + } + + /** The end of an item the pointer would drag, lit – over an item that covers it, too. */ + private drawGrip(box: Box, part: 'start' | 'end'): void { + const reach = Math.min(EDGE_REACH, box.w / 3); + const x = part === 'end' ? box.x + box.w - reach : box.x; + this.over.rect(x, box.y, reach, box.h).fill({ color: 0xffffff, alpha: 0.28 }); + this.over.rect(part === 'end' ? box.x + box.w - 2 : box.x, box.y, 2, box.h).fill(Theme.colors.accentText); } private drawClips(asset: CompositionAsset, labels: Placed[]): void { @@ -992,11 +1169,11 @@ export class CompositionTimeline extends Widget { } /** Puts the texts where they are wanted, reusing the ones there are and hiding the rest. */ -function syncTexts(pool: Text[], layer: Container, wanted: readonly Placed[], style: 'small' | 'body'): void { +function syncTexts(pool: Text[], layer: Container, wanted: readonly Placed[], style: TextStyle): void { wanted.forEach((place, i) => { let text = pool[i]; if (!text) { - text = crispText({ style: textStyle(style) }); + text = crispText({ style }); pool.push(text); layer.addChild(text); } diff --git a/src/editor/composition/compositionView.ts b/src/editor/composition/compositionView.ts index 180dbe1d..7bc719fa 100644 --- a/src/editor/composition/compositionView.ts +++ b/src/editor/composition/compositionView.ts @@ -48,6 +48,15 @@ export type Part = 'start' | 'body' | 'end'; /** What lies under a point of the lanes: a clip before an item, as clips are drawn over items. */ export type Hit = { kind: 'clip'; id: string } | { kind: 'item'; id: string; part: Part }; +/** Items in the order they are drawn: a later start over an earlier one, as in the picture. */ +export function stacked(items: readonly T[]): T[] { + return [...items].sort((a, b) => a.start - b.start); +} + +/** + * What lies under a point. Of items an edge within reach comes first, the nearest one – also the end + * of one an overlapping item covers, so it can still be dragged –, else the body drawn on top. + */ export function hitAt( view: CompositionView, tracks: readonly CompositionTrack[], @@ -64,17 +73,49 @@ export function hitAt( if (row !== undefined && inside(clipBox(view, row, { start: clip.start, length: clipLength(clip) }))) return { kind: 'clip', id: clip.id }; } - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]!; + const order = stacked(items); + let edge: { hit: Hit; distance: number } | null = null; + let body: Hit | null = null; + for (let i = order.length - 1; i >= 0; i--) { + const item = order[i]!; const row = rows.get(item.track); if (row === undefined) continue; const box = itemBox(view, row, item); if (!inside(box)) continue; const reach = Math.min(EDGE_REACH, box.w / 3); - const part = p.x >= box.x + box.w - reach ? 'end' : p.x <= box.x + reach ? 'start' : 'body'; - return { kind: 'item', id: item.id, part }; + const toEnd = box.x + box.w - p.x; + const toStart = p.x - box.x; + const part = toEnd <= reach ? 'end' : toStart <= reach ? 'start' : 'body'; + const distance = Math.min(toStart, toEnd); + if (part === 'body') body ??= { kind: 'item', id: item.id, part }; + else if (!edge || distance < edge.distance) edge = { hit: { kind: 'item', id: item.id, part }, distance }; } - return null; + return edge?.hit ?? body; +} + +/** 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 }[] = []; + const order = stacked(items); + order.forEach((a, i) => { + for (const b of order.slice(i + 1)) { + const start = Math.max(a.start, b.start); + const end = Math.min(a.start + a.length, b.start + b.length); + if (a.track === b.track && end > start) out.push({ track: a.track, start, end }); + } + }); + return out; +} + +/** The frame up to which an item is seen: where an item of its track drawn over it begins, else its end. */ +export function uncoveredEnd(items: readonly CompositionItem[], item: CompositionItem): number { + const order = stacked(items); + const end = item.start + item.length; + const over = order + .slice(order.indexOf(item) + 1) + .filter((i) => i.track === item.track && i.start < end) + .map((i) => i.start); + return Math.min(end, ...over); } /** diff --git a/src/editor/inspector/compositionSection.ts b/src/editor/inspector/compositionSection.ts index 2e6be8a8..a69cf50c 100644 --- a/src/editor/inspector/compositionSection.ts +++ b/src/editor/inspector/compositionSection.ts @@ -1,11 +1,7 @@ -import { - removeCompositionTrack, - removeFromComposition, - SetCompositionCommand, -} from '../../core/commands/compositionCommands'; +import { removeFromComposition, SetCompositionCommand } from '../../core/commands/compositionCommands'; import type { CompositionAsset, CompositionItem } from '../../core/model/Asset'; -import { clipLength, compositionFps, itemVideo } from '../../core/model/composition'; -import { refittedLength, resizedItem } from '../../core/model/compositionEdit'; +import { clipLength, compositionFps, fittedLength, itemVideo } from '../../core/model/composition'; +import { resizedItem } from '../../core/model/compositionEdit'; import { findAssetOf, type Project } from '../../core/model/Project'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Button, formatNumber } from '../../ui'; @@ -19,12 +15,12 @@ const OFFSET_ROW = 'composition:offset'; const CLIP_START_ROW = 'composition:clipStart'; const ANIMATION_ROW = 'composition:animation'; -const seconds = (key: string, label: string): PropertyDef => ({ +const seconds = (key: string, label: string, negative = false): PropertyDef => ({ key, label, kind: 'number', default: 0, - min: 0, + ...(negative ? {} : { min: 0 }), step: 0.1, decimals: 3, }); @@ -41,7 +37,7 @@ export function compositionSignature( return clipIds.length > 0 ? `clips:${clipIds.join(',')}` : 'track'; } -/** A track: its name and the way to remove it. Clips: what they play, from when, and the way to remove them. */ +/** A track: its name; the timeline's menu adds, moves and deletes tracks. Clips: what they play, from when, and the way to remove them. */ export function buildCompositionSection( host: InspectorHost, assetId: string, @@ -61,13 +57,6 @@ export function buildCompositionSection( }, }); addNote(host, 'An upper track lies in front of the ones below it.'); - addGap(host); - host.body.add( - new Button('Remove track', { - variant: 'danger', - onClick: () => host.state.execute(removeCompositionTrack(host.state.project, assetId, trackId)), - }), - ); return; } const clip = clipIds.length === 1 ? comp.clips.find((c) => c.id === clipIds[0]) : undefined; @@ -93,7 +82,7 @@ export function buildCompositionSection( const length = clipLength(host.state.project, comp, clip); addNote( host, - `One pass: ${formatNumber(length / fps, 2)} s. It plays on the item of its track it overlaps.`, + `One pass: ${formatNumber(length / fps, 2)} s. It plays on every item of its track it overlaps.`, ); } else addTitle(host, `${clipIds.length} ANIMATION CLIPS`); addGap(host); @@ -145,29 +134,26 @@ export function addTimingRows(host: InspectorHost, holderId: string, objectId: s const fps = compositionFps(comp); const video = itemVideo(host.state.project, comp, item) !== null; addTitle(host, 'TIMING'); - const write = ( - label: string, - edit: (current: CompositionItem, all: CompositionItem[]) => CompositionItem, - ) => { + const write = (label: string, edit: (current: CompositionItem) => CompositionItem) => { const current = compositionItemOf(host.state.project, holderId, objectId); if (!current) return; - const items = current.comp.items.map((i) => (i.id === objectId ? edit(i, current.comp.items) : i)); + const items = current.comp.items.map((i) => (i.id === objectId ? edit(i) : i)); if (SetCompositionCommand.changes(host.state.project, holderId, { items })) host.state.execute(new SetCompositionCommand(holderId, { items }, label)); }; const frames = (value: unknown) => Math.round(Number(value) * fps); - const rows: [string, string, number, (value: unknown) => void][] = [ + const rows: [string, string, number, (value: unknown) => void, boolean?][] = [ [ START_ROW, 'Start', item.start / fps, - (value) => write('Move item', (i, all) => moveTo(all, i, frames(value))), + (value) => write('Move item', (i) => ({ ...i, start: Math.max(0, frames(value)) })), ], [ LENGTH_ROW, 'Length', item.length / fps, - (value) => write('Resize item', (i, all) => resizedItem(all, i, frames(value))), + (value) => write('Resize item', (i) => resizedItem(i, frames(value))), ], ]; if (video) @@ -176,9 +162,14 @@ export function addTimingRows(host: InspectorHost, holderId: string, objectId: s 'Offset', item.offset, (value) => write('Set item offset', (i) => withOffset(i, Number(value))), + true, ]); - for (const [key, label, value, onChange] of rows) { - const row = createFieldRow(host, seconds(key, label), value, { onInput: () => undefined, onChange }); + // An offset below 0 starts the item before its video. + for (const [key, label, value, onChange, negative] of rows) { + const row = createFieldRow(host, seconds(key, label, negative), value, { + onInput: () => undefined, + onChange, + }); host.fields.set(key, row); host.body.add(row.row); } @@ -192,7 +183,7 @@ export function addTimingRows(host: InspectorHost, holderId: string, objectId: s 'small', ); const fit = new Button('Fit to media', { onClick: () => fitToMedia(host, holderId, objectId) }); - fit.setEnabled(!(item.fitMedia === true && item.length === refittedLength(host.state.project, comp, item))); + fit.setEnabled(!(item.fitMedia === true && item.length === fittedLength(host.state.project, comp, item))); host.body.add(fit); } @@ -210,7 +201,7 @@ export function timingSignature(project: Project, holderId: string, objectId: st const found = compositionItemOf(project, holderId, objectId); if (!found) return ''; const video = itemVideo(project, found.comp, found.item) !== null; - const fitted = found.item.length === refittedLength(project, found.comp, found.item); + const fitted = found.item.length === fittedLength(project, found.comp, found.item); return `|timing:${video}:${found.item.fitMedia === true}:${fitted}`; } @@ -218,23 +209,13 @@ export function timingSignature(project: Project, holderId: string, objectId: st export function fitToMedia(host: Pick, holderId: string, objectId: string): void { const found = compositionItemOf(host.state.project, holderId, objectId); if (!found) return; - const length = refittedLength(host.state.project, found.comp, found.item); + const length = fittedLength(host.state.project, found.comp, found.item); const items = found.comp.items.map((i) => (i.id === objectId ? { ...i, length, fitMedia: true } : i)); if (SetCompositionCommand.changes(host.state.project, holderId, { items })) host.state.execute(new SetCompositionCommand(holderId, { items }, 'Fit to media')); } -/** The item moved to `start`, as far as the items around it let it: a clash keeps it where it was. */ -function moveTo(all: readonly CompositionItem[], item: CompositionItem, start: number): CompositionItem { - const at = Math.max(0, start); - const clash = all.some( - (i) => - i.id !== item.id && i.track === item.track && i.start < at + item.length && at < i.start + i.length, - ); - return clash ? item : { ...item, start: at }; -} - function withOffset(item: CompositionItem, offset: number): CompositionItem { const { fitMedia: _, ...rest } = item; - return { ...rest, offset: Number.isFinite(offset) && offset > 0 ? offset : 0 }; + return { ...rest, offset: Number.isFinite(offset) ? offset : 0 }; } diff --git a/tests/core/composition-edit.test.ts b/tests/core/composition-edit.test.ts index 630573ea..c492ab1a 100644 --- a/tests/core/composition-edit.test.ts +++ b/tests/core/composition-edit.test.ts @@ -1,7 +1,8 @@ /** - * Editing a composition (#417): drops land in the gap they fall into, nothing of a track overlaps, - * the edges of an item keep to its neighbours, and an item that follows its media is fitted to it - * again – in the same undo step – when an override gives it another video. + * Editing a composition (#417): items of a track may overlap, clips may not – a dropped clip lands in + * the gap it falls into –, the edges of an item go where they are dragged, its video's edges marked + * and snapped to, and an item that follows its media is fitted to it again – in the same undo step – + * when an override gives it another video. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateVideoCommand } from '../../src/core/commands/assetCommands'; @@ -15,13 +16,19 @@ import { import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import type { CompositionClip, CompositionItem } from '../../src/core/model/Asset'; import { + clampedMove, droppedLength, droppedSpan, + type ItemMedia, + mediaEdges, + mediaMarks, movedClips, movedItems, resizedItem, + snappedToMedia, trimmedItem, } from '../../src/core/model/compositionEdit'; +import { hitAt, 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'; @@ -77,11 +84,17 @@ describe('moving', () => { expect(movedItems(items, new Set(['b']), tracks, 0, 1)?.find((i) => i.id === 'b')?.track).toBe('t2'); }); - it('refuses a move onto another item, before 0 or off the tracks', () => { - expect(movedItems(items, new Set(['a']), tracks, 50, 0)).toBeNull(); + it('lets an item onto another, and refuses one before 0 or off the tracks', () => { + expect(movedItems(items, new Set(['a']), tracks, 50, 0)?.map((i) => i.start)).toEqual([50, 100, 0]); + expect(movedItems(items, new Set(['a']), tracks, 0, 1)?.map((i) => i.track)).toEqual(['t2', 't1', 't2']); expect(movedItems(items, new Set(['b']), tracks, -150, 0)).toBeNull(); expect(movedItems(items, new Set(['c']), tracks, 0, 1)).toBeNull(); - expect(movedItems(items, new Set(['a']), tracks, 0, 1)).toBeNull(); + }); + + it('holds a move at frame 0 and the tracks there are, as far as the pointer goes', () => { + expect(clampedMove(items, new Set(['b']), tracks, -150, 0)).toEqual({ frames: -100, tracks: 0 }); + expect(clampedMove(items, new Set(['a', 'c']), tracks, 20, 1)).toEqual({ frames: 20, tracks: 0 }); + expect(clampedMove(items, new Set(['a']), tracks, 0, -3)).toEqual({ frames: 0, tracks: 0 }); }); it('lets a picked item go where another picked one was', () => { @@ -101,38 +114,82 @@ describe('moving', () => { }); describe('the edges of an item', () => { - const items = [ - item('a', 't1', 0, 60), - item('b', 't1', 100, 60, { fitMedia: true, offset: 1 }), - item('c', 't1', 200, 10), - ]; + const b = item('b', 't1', 100, 60, { fitMedia: true, offset: 1 }); - it('a dragged end stops where the next item begins, and the length is the item’s own from then on', () => { - const b = items[1]!; - expect(resizedItem(items, b, 500)).toEqual({ id: 'b', track: 't1', start: 100, length: 100, offset: 1 }); - expect(resizedItem(items, b, 0).length).toBe(1); + it('a dragged end goes where it is let go, at least a frame, and the length is the item’s own from then on', () => { + expect(resizedItem(b, 500)).toEqual({ id: 'b', track: 't1', start: 100, length: 500, offset: 1 }); + expect(resizedItem(b, 0).length).toBe(1); }); - it('a dragged start stops at the item before, and moves where the video begins with it', () => { - const b = items[1]!; - const trimmed = trimmedItem(items, b, 130, 60, true); + it('a dragged start moves where the video begins with it', () => { + const trimmed = trimmedItem(b, 130, 60, true); expect([trimmed.start, trimmed.length, trimmed.offset, trimmed.fitMedia]).toEqual([ 130, 30, 1.5, undefined, ]); - expect(trimmedItem(items, b, 70, 60, false).start).toBe(70); - expect(trimmedItem(items, b, 10, 60, false).start).toBe(60); + expect(trimmedItem(b, 10, 60, false)).toMatchObject({ start: 10, length: 150, offset: 1 }); + expect(trimmedItem(b, 500, 60, false).start).toBe(159); }); - it('with a video, a dragged start stops at the video’s beginning', () => { - const b = items[1]!; - const trimmed = trimmedItem(items, b, 70, 60, true); - expect([trimmed.start, trimmed.offset]).toEqual([70, 0.5]); - // Alone on its track, one second into its video: it can reach back one second, no further. - expect(trimmedItem([b], b, 0, 60, true)).toMatchObject({ start: 40, length: 120, offset: 0 }); - expect(trimmedItem([b], b, 0, 60, false)).toMatchObject({ start: 0, length: 160, offset: 1 }); + it('with a video, a dragged start goes on before the video’s beginning, the offset below 0', () => { + // One second into its video: back a second the video begins with the item, back 1.5 s half a second after it. + expect(trimmedItem(b, 40, 60, true)).toMatchObject({ start: 40, length: 120, offset: 0 }); + expect(trimmedItem(b, 10, 60, true)).toMatchObject({ start: 10, length: 150, offset: -0.5 }); + expect(trimmedItem(b, -20, 60, true).start).toBe(0); + }); +}); + +describe('the video of an item on the timeline', () => { + // A 2 s video at 60 fps that began at frame 40: the item from 100 is one second in. + const once: ItemMedia = { begins: 40, length: 120, loops: false }; + const round: ItemMedia = { ...once, loops: true }; + + it('has its edges where it begins and each pass ends, round only when it loops', () => { + expect(mediaEdges(once, 0, 1000)).toEqual([40, 160]); + expect(mediaEdges(round, 100, 500)).toEqual([160, 280, 400]); + expect(mediaEdges(round, 0, 30)).toEqual([]); + }); + + it('marks an end cutting into the video, and a line where the item reaches past it', () => { + expect(mediaMarks(item('i', 't', 100, 30), once)).toEqual({ seams: [], cutStart: true, cutEnd: true }); + expect(mediaMarks(item('i', 't', 40, 120), once)).toEqual({ seams: [], cutStart: false, cutEnd: false }); + expect(mediaMarks(item('i', 't', 20, 200), once)).toEqual({ + seams: [40, 160], + cutStart: false, + cutEnd: false, + }); + expect(mediaMarks(item('i', 't', 100, 200), round).seams).toEqual([160, 280]); + }); + + it('pulls a dragged edge onto the video’s within reach, and leaves it alone further off', () => { + expect(snappedToMedia(157, once, 4)).toBe(160); + expect(snappedToMedia(150, once, 4)).toBe(150); + expect(snappedToMedia(42, null, 4)).toBe(42); + }); +}); + +describe('overlapping items on the timeline', () => { + const view = { panX: 0, pxPerTick: 2, panY: 0, trackHeight: 48 }; + const tracks = [{ id: 't1', name: 'Track 1' }]; + // `late` covers `early` from frame 50 on; on screen the overlap runs from x 100 to 120. + const early = item('early', 't1', 0, 60); + const late = item('late', 't1', 50, 60); + const hit = (x: number) => hitAt(view, tracks, [late, early], [], () => 0, { x, y: 20 }); + + it('takes the end of the one covered, the body of the one on top', () => { + expect(hit(119)).toEqual({ kind: 'item', id: 'early', part: 'end' }); + expect(hit(110)).toEqual({ kind: 'item', id: 'late', part: 'body' }); + expect(hit(101)).toEqual({ kind: 'item', id: 'late', part: 'start' }); + expect(hit(40)).toEqual({ kind: 'item', id: 'early', part: 'body' }); + }); + + it('knows where they overlap and how much of the earlier one is seen', () => { + expect(overlaps([late, early])).toEqual([{ track: 't1', start: 50, end: 60 }]); + expect(overlaps([early, item('other', 't2', 0, 60)])).toEqual([]); + expect(uncoveredEnd([late, early], early)).toBe(50); + expect(uncoveredEnd([late, early], late)).toBe(110); }); }); @@ -240,13 +297,13 @@ describe('an item that follows its media (fitMedia)', () => { expect(cut.comp().items[0]!.length).toBe(180); }); - it('stops where the next item of its track begins, which stays where it is', () => { + it('runs on over the next item of its track, which stays where it is', () => { const cut = makeCut(); const id = cut.add(0, 180); const next = cut.add(240, 60); wear(cut, id, cut.videoId); expect(cut.comp().items.map((i) => [i.id, i.start, i.length])).toEqual([ - [id, 0, 240], + [id, 0, 300], [next, 240, 60], ]); }); @@ -254,7 +311,7 @@ describe('an item that follows its media (fitMedia)', () => { it('is left alone once its length is its own', () => { const cut = makeCut(); const id = cut.add(0, 180); - const items = cut.comp().items.map((i) => resizedItem(cut.comp().items, i, 90)); + const items = cut.comp().items.map((i) => resizedItem(i, 90)); cut.stack.execute(new SetCompositionCommand(cut.compId, { items })); wear(cut, id, cut.videoId); expect(cut.comp().items[0]!.length).toBe(90); diff --git a/tests/core/composition.test.ts b/tests/core/composition.test.ts index 39adfed1..83621a98 100644 --- a/tests/core/composition.test.ts +++ b/tests/core/composition.test.ts @@ -358,6 +358,29 @@ describe('animation clips', () => { expect(x(42, second)).toBe(70); }); + it('over two items of a track that overlap, play on both – the later one in front', () => { + const cut = makeCut(); + const track = firstTrack(cut); + const early = cut.item(track, cut.spriteId, 0, 60); + const late = cut.item(track, cut.spriteId, 30, 60); + cut.clip( + track, + cut.animation(cut.spriteId, [ + [ + 'x', + [ + [0, 0], + [9, 90], + ], + ], + ]), + 0, + ); + expect(liveIds(cut, 42)).toEqual([early, late]); + const holder = compositionHolder(cut.project, cut.comp(), 42); + expect([early, late].map((id) => holder.objects.find((o) => o.id === id)!.position.x)).toEqual([70, 70]); + }); + it('add a relative row to the value the item has', () => { const cut = makeCut(); const item = cut.item(firstTrack(cut), cut.spriteId, 0, 120); @@ -610,7 +633,7 @@ describe('saving and loading a composition', () => { expect(loaded.clips).toEqual([]); }); - it('keeps every object at the top level, a negative offset at 0 and at least one track', () => { + it('keeps every object at the top level, a negative offset, and at least one track', () => { const cut = makeCut(); const track = firstTrack(cut); const parent = cut.item(track, cut.spriteId, 0, 10); @@ -624,7 +647,7 @@ describe('saving and loading a composition', () => { expect(loaded.items).toEqual([]); const again = findAssetOf(roundTrip(cut.project), cut.compId, 'composition')!; expect(again.objects.map((o) => o.parentId)).toEqual(['', '']); - expect(again.items.find((i) => i.id === child)!.offset).toBe(0); + expect(again.items.find((i) => i.id === child)!.offset).toBe(-3); expect(again.items.some((i) => 'fitMedia' in i)).toBe(false); }); }); diff --git a/tools/e2e/69-composition-tab.mjs b/tools/e2e/69-composition-tab.mjs index 6388c329..7593c722 100644 --- a/tools/e2e/69-composition-tab.mjs +++ b/tools/e2e/69-composition-tab.mjs @@ -1,8 +1,8 @@ /** * The Composition tab (#417). A sprite dragged in from the Assets panel becomes an item, 3 s long - * and picked, so the inspector shows its TIMING; a second one dropped onto the first lands right - * after it. A dragged end stops at the next item. An animation dragged in becomes a clip that moves - * the item it overlaps in the picture. The hierarchy lists what is alive at the playhead, Delete + * and picked, so the inspector shows its TIMING; a second one dropped onto the first lands there, + * over it. The end of the first, covered by the second, lights up under the pointer and is dragged + * past it. An animation dragged in becomes a clip that moves the item it overlaps in the picture. The hierarchy lists what is alive at the playhead, Delete * takes an item with its object, and Play runs the playhead to the end. The video of an item about * to begin is opened ahead, so the cut to it shows its picture at once. * @@ -92,27 +92,36 @@ export async function compositionTab(t) { const timing = await json(`__gm.section('TIMING')`); check('the inspector shows the picked item’s TIMING', timing !== null, JSON.stringify(timing)); - // 69b. A second one let go over the first lands right after it. + // 69b. A second one let go over the first lands where it is let go, over the first. await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 20)`)); m = await model(); const b = m.items.find((i) => i.id !== a.id); check( - 'one dropped onto an item lands right after it', - m.items.length === 2 && b?.start === 35 && b.length === 30, + 'one dropped onto an item lands where it is let go, over it', + m.items.length === 2 && b?.start === 20 && b.length === 30, JSON.stringify(m.items), ); - // 69c. The first item's end dragged past the second stops where the second begins. + // 69c. The first item's end, under the second, lights up with a resize cursor and is dragged past it. const box = await json(`__gm.composition.box('${a.id}')`); + const end = { x: box.x + box.w - 2, y: box.y + box.h / 2 }; + await mouse('mouseMoved', end.x - 20, end.y); + await mouse('mouseMoved', end.x, end.y); + await sleep(80); + const hovered = await json('__gm.composition.view()'); const far = await json(`__gm.composition.laneAt('ct69-t1', 60)`); - await drag(box.x + box.w - 2, box.y + box.h / 2, far.x, box.y + box.h / 2); + await drag(end.x, end.y, far.x, end.y); await sleep(150); m = await model(); const resized = m.items.find((i) => i.id === a.id); check( - 'a dragged end stops where the next item begins, and the length is the item’s own then', - resized?.length === 30 && resized.fitMedia === undefined, - JSON.stringify(resized), + 'the covered end of an item lights up with a resize cursor, and is dragged past the item over it', + hovered.hover?.id === a.id && + hovered.hover.part === 'end' && + hovered.cursor === 'ew-resize' && + resized?.length === 55 && + resized.fitMedia === undefined, + JSON.stringify({ hover: hovered.hover, cursor: hovered.cursor, resized }), ); // 69d. The animation dropped at frame 5 is a clip one pass long that moves the item in the picture. @@ -148,7 +157,7 @@ export async function compositionTab(t) { 'the hierarchy lists the items alive at the playhead', rows[0].length === 0 && JSON.stringify(rows[1]) === JSON.stringify([a.id]) && - JSON.stringify(rows[2]) === JSON.stringify([b.id]), + JSON.stringify(rows[2]) === JSON.stringify([a.id, b.id]), JSON.stringify(rows), ); @@ -184,7 +193,7 @@ export async function compositionTab(t) { } check( 'Play runs the playhead to the end and stops there', - running.playing && running.playhead > 0 && !ended.playing && ended.playhead === 65, + running.playing && running.playhead > 0 && !ended.playing && ended.playhead === 60, JSON.stringify({ running, ended }), );