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:
adminandClaude Opus 5.5 committed 2026-10-12 08:57:26 +02:00
1 parent 4f0272638e
commit c4650e43f0
10 files changed
+559 -227

No files matched your search

+9 -4
View File
@@ -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 });
}
}
+6 -4
View File
@@ -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;
+6 -5
View File
@@ -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);
+94 -58
View File
@@ -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. */