Wait for a composition's videos and copy and paste its items (#417)

A composition lost the first frames of a video item. Its clock ran
while the player was still loading, in the tab and in the game. The
game only waited for videos once, when it started. A composition that
a behavior starts later spawned its items after that wait.

- The view now records which video sprites it left out for lack of a
  first picture, one entry per object.
- The composition tab holds its playhead while the preview misses a
  video, for at most VIDEO_WAIT_MS (3 s) like the game.
- CompositionSystem holds a composition's clock while one of its live
  items misses its video. The rest of the game keeps running.
- The editor play and the published game pass their view to the run
  as RunServices.pictures.
- VIDEO_WAIT_MS moves to core/model/video.ts, because the runtime
  does not import from viewport/.
- Videos loaded ahead for coming items do not hold the clock.

Items can now be copied and pasted inside the composition tab.

- Ctrl+C keeps the timing of the copied items next to the objects.
- Ctrl+V pastes objects and items in one undo step. The earliest item
  lands at the frame under the pointer, on the track there.
- Without the pointer over the tracks it lands at the playhead, on the
  selected track or on the top track of the selected items.
- Several items keep their distances and tracks to each other.
- The same copy pastes into a scene as plain objects. Objects copied
  from a scene do not paste into a composition, they have no timing.

Tests:
- Unit composition-edit, clipboard, composition-play: 3 new for
  pasting, 1 new for the clock waiting in the game
- e2e 69i (play from the end waits for the video), 69j (paste under
  the pointer and at the playhead), 68d (a composition started from
  On start waits for its video)
- each new check is red without its fix
- README: 2036 editor checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 10:38:19 +02:00
1 parent c4650e43f0
commit 6e6b6a4d9a
20 files changed
+421 -19

No files matched your search

+20 -1
View File
@@ -1,5 +1,6 @@
import type { Asset, CompositionAsset } from '../../core/model/Asset';
import { compositionFps, compositionLength } from '../../core/model/composition';
import { VIDEO_WAIT_MS } from '../../core/model/video';
import { Button, formatNumber, VStack, type Widget } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
@@ -87,10 +88,28 @@ export class CompositionEditor extends AssetTabEditor<CompositionAsset, Composit
/** Only called while the tab is visible. */
tick(dt: number): void {
const drawn = this.preview.redrawCount;
if (this.canvas.isPlaying) this.canvas.tick(dt);
if (this.canvas.isPlaying && !this.waitsForVideo()) this.canvas.tick(dt);
this.preview.animate(drawn);
}
/** Until when the playhead waits for a video it shows, see `waitsForVideo`. */
private videoWait: number | null = null;
/**
* Whether the playhead stands while the picture still lacks a video it shows, as a run waits at
* its start: an item whose player opened only now played on without its first frames. At most
* `VIDEO_WAIT_MS` at a time.
*/
private waitsForVideo(): boolean {
if (!this.preview.missesVideo()) {
this.videoWait = null;
return false;
}
const now = performance.now();
this.videoWait ??= now + VIDEO_WAIT_MS;
return now < this.videoWait;
}
get isPlaying(): boolean {
return this.canvas.isPlaying;
}
+49 -1
View File
@@ -8,6 +8,7 @@ import {
} from 'pixi.js';
import {
addCompositionItem,
pasteCompositionItems,
removeCompositionTrack,
removeFromComposition,
SetCompositionCommand,
@@ -168,6 +169,8 @@ export class CompositionTimeline extends Widget {
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;
/** Where the pointer was last, in local coordinates: a paste lands there while it is over the lanes. */
private pointer: Vec2 | null = null;
private needsFit = true;
/** Where the playhead stands, in frames – fractional while playing. */
playhead = 0;
@@ -310,6 +313,50 @@ export class CompositionTimeline extends Widget {
this.onPlayhead();
}
/**
* Puts copied items in (Ctrl+V): the earliest under the pointer while it is over the lanes, the
* topmost on its track; else at the playhead, on the picked track or the track of the picked item,
* or on the tracks they came from. False when the copy is not items of a composition.
*/
paste(): boolean {
const asset = this.asset();
const clip = this.state.clipboard?.clip;
if (!asset || clip?.kind !== 'objects') return false;
const spot = this.pasteSpot(asset);
const picked = this.pickedTrack(asset);
const row = spot?.row ?? (picked >= 0 ? picked : undefined);
const at = spot?.frame ?? Math.floor(this.playhead + 1e-6);
const pasted = pasteCompositionItems(this.state.project, asset.id, clip, at, row);
if (!pasted || !this.state.execute(pasted.command)) return false;
this.state.select(objectSelection(asset.id, pasted.itemIds));
return true;
}
/** Where a paste goes with the pointer over the lanes: the frame under it and the nearest track; else null. */
private pasteSpot(asset: CompositionAsset): { frame: number; row: number } | null {
const p = this.pointer;
if (!p || p.x < HEAD_WIDTH || p.x > this.w || p.y < RULER_HEIGHT || p.y > this.h) return null;
if (asset.tracks.length === 0) return null;
const g = this.inLanes(p);
return {
frame: Math.max(0, Math.round(xToTick(this.view, g.x))),
row: nearestTrack(this.view, asset.tracks.length, g.y),
};
}
/**
* The row of the picked track or of the picked clips' track, else the topmost the picked items lie
* on – the items just copied land on their own tracks again; -1 without.
*/
private pickedTrack(asset: CompositionAsset): number {
const s = this.state.selection;
const rowOf = (track: string) => asset.tracks.findIndex((t) => t.id === track);
if (s?.kind === 'composition' && s.assetId === asset.id) return rowOf(s.trackId);
const picked = new Set(this.pickedItems(asset));
const rows = asset.items.filter((i) => picked.has(i.id)).map((i) => rowOf(i.track));
return rows.length > 0 ? Math.min(...rows) : -1;
}
// --- test hooks --------------------------------------------------------------------------------
/** Where a track and frame lie on screen, in global coordinates: the middle of the track. */
@@ -491,7 +538,8 @@ export class CompositionTimeline extends Widget {
private onPointerMove(e: FederatedPointerEvent): void {
const asset = this.asset();
const local = this.toLocal(e.global);
const g = this.inLanes({ x: local.x, y: local.y });
this.pointer = { x: local.x, y: local.y };
const g = this.inLanes(this.pointer);
const dragged = activeDrag();
if (dragged?.kind === 'asset' && asset) {
this.followDrop(asset, dragged.id, local, g);