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
@@ -4,9 +4,11 @@ import {
|
||||
type FederatedWheelEvent,
|
||||
Graphics,
|
||||
type Text,
|
||||
type TextStyle,
|
||||
} from 'pixi.js';
|
||||
import {
|
||||
addCompositionItem,
|
||||
removeCompositionTrack,
|
||||
removeFromComposition,
|
||||
SetCompositionCommand,
|
||||
} from '../../core/commands/compositionCommands';
|
||||
@@ -22,13 +24,17 @@ import {
|
||||
itemVideo,
|
||||
} from '../../core/model/composition';
|
||||
import {
|
||||
clampedMove,
|
||||
clipSpans,
|
||||
droppedLength,
|
||||
droppedSpan,
|
||||
itemSpans,
|
||||
itemMedia,
|
||||
type ItemMedia,
|
||||
mediaMarks,
|
||||
movedClips,
|
||||
movedItems,
|
||||
resizedItem,
|
||||
snappedToMedia,
|
||||
trimmedItem,
|
||||
} from '../../core/model/compositionEdit';
|
||||
import { placementRejection } from '../../core/model/placement';
|
||||
@@ -51,7 +57,16 @@ import {
|
||||
} from '../../ui';
|
||||
import { fitToMedia } from '../inspector/compositionSection';
|
||||
import { clampTimePan, tickToX, xToTick, zoomTime } from '../music/timeView';
|
||||
import { clampTracks, fitSong, nearestTrack, trackAtY, trackTop, zoomTracks } from '../song/songView';
|
||||
import {
|
||||
type Box,
|
||||
clampTracks,
|
||||
EDGE_REACH,
|
||||
fitSong,
|
||||
nearestTrack,
|
||||
trackAtY,
|
||||
trackTop,
|
||||
zoomTracks,
|
||||
} from '../song/songView';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
import { axisTween } from '../viewTween';
|
||||
import {
|
||||
@@ -62,10 +77,14 @@ import {
|
||||
hitAt,
|
||||
itemBox,
|
||||
MAX_PX_PER_FRAME,
|
||||
overlaps,
|
||||
type Part,
|
||||
RULER_HEIGHT,
|
||||
rulerStep,
|
||||
rulerText,
|
||||
stacked,
|
||||
TRACK_HEIGHT,
|
||||
uncoveredEnd,
|
||||
} from './compositionView';
|
||||
|
||||
const DRAG_THRESHOLD = 3;
|
||||
@@ -74,6 +93,10 @@ const VIDEO_COLOR = 0x2f6f52;
|
||||
const CLIP_COLOR = 0x8a5a2b;
|
||||
const REJECTED_COLOR = 0x6b3a3a;
|
||||
const DEFAULT_VIEW: CompositionView = { panX: 0, pxPerTick: 2, panY: 0, trackHeight: TRACK_HEIGHT };
|
||||
/** Room at the top of an item for its name; marks of its video keep below it. */
|
||||
const LABEL_STRIP = 15;
|
||||
const CLIP_LABEL = textStyle('small').clone();
|
||||
CLIP_LABEL.fill = Theme.colors.text;
|
||||
|
||||
type Gesture =
|
||||
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
|
||||
@@ -88,7 +111,7 @@ type Gesture =
|
||||
frames: number;
|
||||
tracks: number;
|
||||
}
|
||||
| { kind: 'resize' | 'trim'; anchor: CompositionItem; item: CompositionItem };
|
||||
| { kind: 'resize' | 'trim'; anchor: CompositionItem; item: CompositionItem; media: ItemMedia | null };
|
||||
|
||||
/** An asset dragged in from the panel: an item or, for an animation, a clip – and whether it fits there. */
|
||||
interface Drop {
|
||||
@@ -111,8 +134,9 @@ interface Placed {
|
||||
* The timeline of the Composition tab (#417): track heads on the left, a ruler in seconds on top,
|
||||
* the items on their tracks and the animation clips over them, narrower. An asset dragged in from
|
||||
* the panel becomes an item – an animation a clip –, an item is moved, its ends dragged; a clip is
|
||||
* moved. Nothing of a track overlaps: a drop lands in the gap it falls into, a move stops short.
|
||||
* The ruler scrubs the playhead the picture above shows.
|
||||
* moved. Items of a track may overlap, the later one drawn over the earlier; clips of a track do not:
|
||||
* a dropped clip lands in the gap it falls into, a moved one stops short. The ruler scrubs the
|
||||
* playhead the picture above shows.
|
||||
*/
|
||||
export class CompositionTimeline extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
@@ -120,9 +144,11 @@ export class CompositionTimeline extends Widget {
|
||||
private readonly over = new Graphics();
|
||||
private readonly clip = new Graphics();
|
||||
private readonly itemLayer = new Container();
|
||||
private readonly clipLayer = new Container();
|
||||
private readonly headLayer = new Container();
|
||||
private readonly rulerLayer = new Container();
|
||||
private readonly itemTexts: Text[] = [];
|
||||
private readonly clipTexts: Text[] = [];
|
||||
private readonly headTexts: Text[] = [];
|
||||
private readonly rulerTexts: Text[] = [];
|
||||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||
@@ -140,6 +166,8 @@ export class CompositionTimeline extends Widget {
|
||||
private assetId = '';
|
||||
private gesture: Gesture | null = null;
|
||||
private drop: Drop | null = null;
|
||||
/** The end of an item the pointer would drag; it lights up, and the cursor says so. */
|
||||
private hover: { id: string; part: Part } | null = null;
|
||||
private needsFit = true;
|
||||
/** Where the playhead stands, in frames – fractional while playing. */
|
||||
playhead = 0;
|
||||
@@ -156,6 +184,7 @@ export class CompositionTimeline extends Widget {
|
||||
this.lanes,
|
||||
this.itemLayer,
|
||||
this.over,
|
||||
this.clipLayer,
|
||||
this.headLayer,
|
||||
this.rulerLayer,
|
||||
this.hint,
|
||||
@@ -164,8 +193,10 @@ export class CompositionTimeline extends Widget {
|
||||
this.lanes.mask = this.clip;
|
||||
this.itemLayer.mask = this.clip;
|
||||
this.over.mask = this.clip;
|
||||
this.clipLayer.mask = this.clip;
|
||||
this.hint.eventMode = 'none';
|
||||
this.itemLayer.eventMode = 'none';
|
||||
this.clipLayer.eventMode = 'none';
|
||||
this.headLayer.eventMode = 'none';
|
||||
this.rulerLayer.eventMode = 'none';
|
||||
this.focusable = true;
|
||||
@@ -312,8 +343,19 @@ export class CompositionTimeline extends Widget {
|
||||
return this.drop && { ...this.drop };
|
||||
}
|
||||
|
||||
viewInfo(): CompositionView & { playhead: number; playing: boolean } {
|
||||
return { ...this.view, playhead: this.playhead, playing: this.playingNow };
|
||||
viewInfo(): CompositionView & {
|
||||
playhead: number;
|
||||
playing: boolean;
|
||||
hover: { id: string; part: Part } | null;
|
||||
cursor: string;
|
||||
} {
|
||||
return {
|
||||
...this.view,
|
||||
playhead: this.playhead,
|
||||
playing: this.playingNow,
|
||||
hover: this.hover,
|
||||
cursor: String(this.cursor ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
// --- pointer -----------------------------------------------------------------------------------
|
||||
@@ -431,8 +473,9 @@ export class CompositionTimeline extends Widget {
|
||||
}
|
||||
if (!picked.includes(item.id) || hit.part !== 'body')
|
||||
this.state.select(objectSelection(asset.id, [item.id]));
|
||||
if (hit.part === 'end') this.gesture = { kind: 'resize', anchor: item, item };
|
||||
else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor: item, item };
|
||||
const media = itemMedia(this.state.project, asset, item);
|
||||
if (hit.part === 'end') this.gesture = { kind: 'resize', anchor: item, item, media };
|
||||
else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor: item, item, media };
|
||||
else this.startMove('item', item, g);
|
||||
}
|
||||
|
||||
@@ -455,7 +498,11 @@ export class CompositionTimeline extends Widget {
|
||||
return;
|
||||
}
|
||||
const gesture = this.gesture;
|
||||
if (!gesture || !asset) return;
|
||||
if (!asset) return;
|
||||
if (!gesture) {
|
||||
this.followHover(asset, local, g);
|
||||
return;
|
||||
}
|
||||
const frame = xToTick(this.view, g.x);
|
||||
switch (gesture.kind) {
|
||||
case 'pan':
|
||||
@@ -483,34 +530,72 @@ export class CompositionTimeline extends Widget {
|
||||
if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD)
|
||||
return;
|
||||
gesture.moved = true;
|
||||
const frames = Math.round(frame - gesture.grab) - gesture.anchor.start;
|
||||
const row = nearestTrack(this.view, asset.tracks.length, g.y);
|
||||
const tracks = row - asset.tracks.findIndex((t) => t.id === gesture.anchor.track);
|
||||
// Where it would overlap, it stays at the last place it fitted.
|
||||
if (this.movedList(asset, gesture.what, frames, tracks)) {
|
||||
gesture.frames = frames;
|
||||
gesture.tracks = tracks;
|
||||
} else if (this.movedList(asset, gesture.what, frames, gesture.tracks)) gesture.frames = frames;
|
||||
const held = this.heldMove(
|
||||
asset,
|
||||
gesture.what,
|
||||
Math.round(frame - gesture.grab) - gesture.anchor.start,
|
||||
row - asset.tracks.findIndex((t) => t.id === gesture.anchor.track),
|
||||
gesture,
|
||||
);
|
||||
gesture.frames = held.frames;
|
||||
gesture.tracks = held.tracks;
|
||||
break;
|
||||
}
|
||||
case 'resize':
|
||||
gesture.item = resizedItem(asset.items, gesture.anchor, Math.round(frame) - gesture.anchor.start);
|
||||
case 'resize': {
|
||||
const end = this.snapped(frame, gesture.media);
|
||||
gesture.item = resizedItem(gesture.anchor, end - gesture.anchor.start);
|
||||
break;
|
||||
case 'trim': {
|
||||
const video = itemVideo(this.state.project, asset, gesture.anchor) !== null;
|
||||
}
|
||||
case 'trim':
|
||||
gesture.item = trimmedItem(
|
||||
asset.items,
|
||||
gesture.anchor,
|
||||
Math.round(frame),
|
||||
this.snapped(frame, gesture.media),
|
||||
compositionFps(asset),
|
||||
video,
|
||||
gesture.media !== null || itemVideo(this.state.project, asset, gesture.anchor) !== null,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** A frame on an edge of the item's video when the pointer is that close to one, else the frame nearest. */
|
||||
private snapped(frame: number, media: ItemMedia | null): number {
|
||||
return Math.round(snappedToMedia(frame, media, EDGE_REACH / this.view.pxPerTick));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a move goes that the pointer asks to go `frames` and `tracks`: held to frame 0 and the
|
||||
* tracks there are. Items go there whatever lies there; clips stop short of another clip – on the
|
||||
* way back to the last place they fitted, on the track asked for, else on the one they were on.
|
||||
*/
|
||||
private heldMove(
|
||||
asset: CompositionAsset,
|
||||
what: 'item' | 'clip',
|
||||
frames: number,
|
||||
tracks: number,
|
||||
last: { frames: number; tracks: number },
|
||||
): { frames: number; tracks: number } {
|
||||
const picked = new Set(what === 'item' ? this.pickedItems(asset) : this.pickedClips(asset));
|
||||
const list = what === 'item' ? asset.items : asset.clips;
|
||||
const wanted = clampedMove(
|
||||
list,
|
||||
picked,
|
||||
asset.tracks.map((t) => t.id),
|
||||
frames,
|
||||
tracks,
|
||||
);
|
||||
if (what === 'item') return wanted;
|
||||
for (const on of [wanted.tracks, last.tracks]) {
|
||||
const step = Math.sign(last.frames - wanted.frames);
|
||||
for (let at = wanted.frames; ; at += step) {
|
||||
if (this.movedList(asset, what, at, on)) return { frames: at, tracks: on };
|
||||
if (at === last.frames) break;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
private movedList(
|
||||
asset: CompositionAsset,
|
||||
what: 'item' | 'clip',
|
||||
@@ -536,6 +621,7 @@ export class CompositionTimeline extends Widget {
|
||||
const gesture = this.gesture;
|
||||
this.gesture = null;
|
||||
if (!gesture || !asset) return;
|
||||
this.hover = null;
|
||||
switch (gesture.kind) {
|
||||
case 'move': {
|
||||
if (!gesture.moved) {
|
||||
@@ -579,8 +665,9 @@ export class CompositionTimeline extends Widget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows an asset dragged in from the panel: a placeable one lands as an item in the gap under the
|
||||
* pointer, as long as its media or 3 s; an animation as a clip one pass long, where it fits whole.
|
||||
* Follows an asset dragged in from the panel: a placeable one lands as an item under the pointer,
|
||||
* as long as its media or 3 s, over whatever lies there; an animation as a clip one pass long, where
|
||||
* it fits whole.
|
||||
*/
|
||||
private followDrop(asset: CompositionAsset, assetId: string, local: Vec2, g: Vec2): void {
|
||||
const dragged = findAsset(this.state.project, assetId);
|
||||
@@ -619,20 +706,27 @@ export class CompositionTimeline extends Widget {
|
||||
length: Math.max(1, length),
|
||||
rejection,
|
||||
};
|
||||
} else {
|
||||
const wanted = droppedLength(this.state.project, asset, dragged);
|
||||
const span = droppedSpan(itemSpans(this.liveItems(asset), track), at, wanted);
|
||||
const rejection =
|
||||
placementRejection(this.state.project, asset, dragged) || (span ? '' : 'No room there');
|
||||
} else
|
||||
this.drop = {
|
||||
what: 'item',
|
||||
assetId,
|
||||
track,
|
||||
start: span?.start ?? at,
|
||||
length: span?.length ?? wanted,
|
||||
rejection,
|
||||
start: at,
|
||||
length: droppedLength(this.state.project, asset, dragged),
|
||||
rejection: placementRejection(this.state.project, asset, dragged),
|
||||
};
|
||||
}
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** The end of an item under the pointer it can be dragged by: a resize cursor, and the end lights up. */
|
||||
private followHover(asset: CompositionAsset, local: Vec2, g: Vec2): void {
|
||||
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
|
||||
const hit = inside ? this.hitAt(asset, g) : null;
|
||||
const hover = hit?.kind === 'item' && hit.part !== 'body' ? { id: hit.id, part: hit.part } : null;
|
||||
const cursor = hover ? 'ew-resize' : 'default';
|
||||
if (this.cursor !== cursor) this.cursor = cursor;
|
||||
if (JSON.stringify(hover) === JSON.stringify(this.hover)) return;
|
||||
this.hover = hover;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
@@ -692,7 +786,6 @@ export class CompositionTimeline extends Widget {
|
||||
private openMenu(asset: CompositionAsset, local: Vec2, g: Vec2, global: Vec2): void {
|
||||
const row = local.y >= RULER_HEIGHT ? trackAtY(this.view, asset.tracks.length, g.y) : null;
|
||||
const hit = local.x >= HEAD_WIDTH && local.y >= RULER_HEIGHT ? this.hitAt(asset, g) : null;
|
||||
const addTrack = { label: 'Add track', onSelect: () => this.addTrack() };
|
||||
if (hit?.kind === 'item') {
|
||||
if (!this.pickedItems(asset).includes(hit.id)) this.state.select(objectSelection(asset.id, [hit.id]));
|
||||
const ids = this.pickedItems(asset);
|
||||
@@ -739,31 +832,43 @@ export class CompositionTimeline extends Widget {
|
||||
]).open(global.x, global.y);
|
||||
return;
|
||||
}
|
||||
const track = row === null ? undefined : asset.tracks[row];
|
||||
if (row !== null) this.openTrackMenu(asset, row, global);
|
||||
else if (local.y >= RULER_HEIGHT)
|
||||
new Menu([{ label: 'Add track', onSelect: () => this.addTrack() }]).open(global.x, global.y);
|
||||
}
|
||||
|
||||
/** The menu of a track, on its head or an empty spot of it – the Song tab's. */
|
||||
private openTrackMenu(asset: CompositionAsset, row: number, global: Vec2): void {
|
||||
const track = asset.tracks[row]!;
|
||||
const swap = (other: number) => {
|
||||
const tracks = [...asset.tracks];
|
||||
[tracks[row], tracks[other]] = [tracks[other]!, tracks[row]!];
|
||||
this.commit(asset, { tracks }, 'Move track');
|
||||
};
|
||||
new Menu([
|
||||
addTrack,
|
||||
...(track
|
||||
? [
|
||||
{
|
||||
label: `Select "${track.name}"`,
|
||||
onSelect: () =>
|
||||
this.state.select({ kind: 'composition', assetId: asset.id, trackId: track.id, clipIds: [] }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ label: 'Add track above', onSelect: () => this.addTrack(row) },
|
||||
{ label: 'Add track below', onSelect: () => this.addTrack(row + 1) },
|
||||
{ label: 'Move up', disabled: row === 0, onSelect: () => swap(row - 1) },
|
||||
{ label: 'Move down', disabled: row === asset.tracks.length - 1, onSelect: () => swap(row + 1) },
|
||||
{
|
||||
label: 'Delete track',
|
||||
disabled: asset.tracks.length <= 1,
|
||||
onSelect: () => {
|
||||
if (this.state.execute(removeCompositionTrack(this.state.project, asset.id, track.id)))
|
||||
this.state.select({ kind: 'asset', assetId: asset.id });
|
||||
},
|
||||
},
|
||||
]).open(global.x, global.y);
|
||||
}
|
||||
|
||||
/** A new track at the bottom, behind all the others. */
|
||||
addTrack(): void {
|
||||
/** A new track at `index` – at the bottom, behind all the others, unless told. */
|
||||
addTrack(index?: number): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const track = { id: newId(), name: `Track ${asset.tracks.length + 1}` };
|
||||
if (
|
||||
this.state.execute(
|
||||
new SetCompositionCommand(asset.id, { tracks: [...asset.tracks, track] }, 'Add track'),
|
||||
)
|
||||
)
|
||||
const tracks = [...asset.tracks];
|
||||
tracks.splice(index ?? tracks.length, 0, track);
|
||||
if (this.state.execute(new SetCompositionCommand(asset.id, { tracks }, 'Add track')))
|
||||
this.state.select({ kind: 'composition', assetId: asset.id, trackId: track.id, clipIds: [] });
|
||||
}
|
||||
|
||||
@@ -827,19 +932,21 @@ export class CompositionTimeline extends Widget {
|
||||
this.lanes.clear();
|
||||
this.over.clear();
|
||||
const items: Placed[] = [];
|
||||
const clips: Placed[] = [];
|
||||
const heads: Placed[] = [];
|
||||
const ruler: Placed[] = [];
|
||||
if (asset) {
|
||||
this.drawTracks(asset, heads);
|
||||
this.drawItems(asset, items);
|
||||
this.drawClips(asset, items);
|
||||
this.drawClips(asset, clips);
|
||||
this.drawDrop(asset);
|
||||
this.drawRuler(asset, ruler);
|
||||
this.drawPlayhead();
|
||||
}
|
||||
syncTexts(this.itemTexts, this.itemLayer, items, 'small');
|
||||
syncTexts(this.headTexts, this.headLayer, heads, 'body');
|
||||
syncTexts(this.rulerTexts, this.rulerLayer, ruler, 'small');
|
||||
syncTexts(this.itemTexts, this.itemLayer, items, textStyle('small'));
|
||||
syncTexts(this.clipTexts, this.clipLayer, clips, CLIP_LABEL);
|
||||
syncTexts(this.headTexts, this.headLayer, heads, textStyle('body'));
|
||||
syncTexts(this.rulerTexts, this.rulerLayer, ruler, textStyle('small'));
|
||||
const hint = !asset
|
||||
? ''
|
||||
: asset.items.length === 0 && asset.clips.length === 0
|
||||
@@ -881,12 +988,16 @@ export class CompositionTimeline extends Widget {
|
||||
const c = Theme.colors;
|
||||
const rows = new Map(asset.tracks.map((t, i) => [t.id, i]));
|
||||
const picked = new Set(this.pickedItems(asset));
|
||||
for (const item of this.shownItems(asset)) {
|
||||
const shown = stacked(this.shownItems(asset));
|
||||
const boxes = new Map<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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user