Let composition items overlap and show where their video lies (#417)
Trying the tab turned up six things to fix. Items of a track could not overlap, so a drag stalled as soon as the pointer reached another item. An item's ends gave no hint they could be dragged, a trimmed video showed nowhere, clip names were hard to read and vanished when picked, and the track menu did not match the song tab. - Items of a track may overlap and nothing is cut or pushed aside. The later one lies in front, in the picture and on the timeline. The overlap is shaded and a line marks the covered end. - Edges win over bodies when hit, so the covered end of the lower item can still be dragged. Hovering an edge lights it up and shows the ew-resize cursor, as in the song tab. - A clip in the overlap plays on both items. Clips themselves still do not overlap; a moved clip now runs up to its neighbour instead of stopping short. - Fit to media and a new video no longer cap the length at the next item, which only made sense while overlaps were forbidden. - The left edge can be trimmed past the start of the video. The offset goes negative and the sprite shows the first picture until the video begins. - Video marks: a corner triangle where the item cuts into its video, a line with a triangle like the song loop where the video starts or ends inside the item. Edges snap to the video's ends within 6 px. - Clip names sit in their own layer above the bars, in text colour. - The track menu matches the song tab: add above or below, move up or down, delete. It opens on the head and on an empty spot of the track. The Remove track button left the inspector. Tests: - Unit composition and composition-edit: 67 green, several new or rewritten. Bending three spots in the rules turned their tests red. - e2e 69: ALL PASSED, 12 checks. 69b and 69c now drop the second item over the first and drag the covered end past it. Without edges winning the hit, 69c fails with hover: null. - Gate on melektaus-atx: CHECK EXIT 0, 302 files, 4672 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4f0272638e
commit
c4650e43f0
10 files changed
+556
-224
No files matched your search
@@ -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<string, string>): 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> = 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<T extends Placed>(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<string>,
|
||||
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<T extends Placed>(
|
||||
list: readonly T[],
|
||||
picked: ReadonlySet<string>,
|
||||
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<T extends Placed>(
|
||||
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<string>,
|
||||
@@ -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<string>,
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: '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: `Select "${track.name}"`,
|
||||
onSelect: () =>
|
||||
this.state.select({ kind: 'composition', assetId: asset.id, trackId: track.id, clipIds: [] }),
|
||||
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<string, Box>();
|
||||
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<string, number>): 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);
|
||||
}
|
||||
|
||||
@@ -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<T extends { start: number }>(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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<InspectorHost, 'state'>, 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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user