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:
1 parent
c4650e43f0
commit
6e6b6a4d9a
20 files changed
+421
-19
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user