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
+559
-227
No files matched your search
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user