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

+3
View File
@@ -8,6 +8,7 @@ import { findScene, type Project } from '../core/model/Project';
import { mixOf } from '../core/music/mixer';
import type { RecordingSink } from '../core/recording/recording';
import type { AudioSink } from './systems/Audio';
import type { VideoPictures } from './systems/CompositionSystem';
import { addGameSystems, type BehaviorSystem, type TimeScaled } from './systems/BehaviorSystem';
import type { Input } from './systems/Input';
import { bringObjects, keptEntities, objectsToBring } from './keptObjects';
@@ -21,6 +22,8 @@ export interface RunServices {
saves: SaveArea;
/** Outlives a `Load scene` and a restart, so a recording goes on through both (#405). */
recorder?: RecordingSink;
/** The view the run is shown in, asked whether a video has its first picture yet (#417). */
pictures?: VideoPictures;
}
/** What a scene holds of objects, which is all a run writes into it. */
+5 -2
View File
@@ -55,7 +55,7 @@ import {
import { CollisionSystem } from './CollisionSystem';
import type { CollisionSource } from './solids';
import { AnimationSystem } from './AnimationSystem';
import { CompositionSystem } from './CompositionSystem';
import { CompositionSystem, type VideoPictures } from './CompositionSystem';
import type { Input } from './Input';
import type { RecordingSink } from '../../core/recording/recording';
import type { System } from './System';
@@ -122,6 +122,8 @@ export interface GameSystemOptions {
recorder?: RecordingSink | null;
/** Whether `Play composition` plays (#417); the editor run ignores it. */
playsCompositions?: boolean;
/** The view a played game is shown in: a composition waits for its videos there (#417). */
pictures?: VideoPictures | null;
}
/** Collisions, compositions and animations are ticked first, so behaviors see this frame's events. */
@@ -139,10 +141,11 @@ export function addGameSystems(
saves = null,
recorder = null,
playsCompositions = true,
pictures = null,
}: GameSystemOptions = {},
): BehaviorSystem {
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
const compositions = new CompositionSystem(playsCompositions);
const compositions = new CompositionSystem(playsCompositions, pictures);
const animations = new AnimationSystem();
const sources = { collisions, animations, compositions, audio, clock, saves, recorder };
const behaviors = new BehaviorSystem(project, input, sources, shouldRun);
+32 -4
View File
@@ -13,7 +13,7 @@ import { curveLookup } from '../../core/model/curve';
import { contentId, placedWithContent } from '../../core/model/instance';
import { findAssetOf } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import { findVideo } from '../../core/model/video';
import { findVideo, VIDEO_WAIT_MS } from '../../core/model/video';
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../../core/properties/videos';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -38,6 +38,14 @@ interface Run {
id: number;
/** By item id. An item a behavior destroyed stays here until it ends, so it is not built again. */
live: Map<string, Spawned>;
/** Seconds the clock has stood so far for a video without its first picture, see `waits`. */
waited: number;
}
/** What the view that shows a run knows of its driven videos (#404). */
export interface VideoPictures {
/** Whether it left the sprite `objectId` out, its video still without a first picture. */
missing(objectId: string): boolean;
}
/**
@@ -46,12 +54,18 @@ interface Run {
* videos. Runs before the animations and the behaviors, so a behavior writes last and hears this
* frame's `finished`. The editor run plays none: it writes into the saved scene, where the objects of
* a cut would stay behind.
*
* While the view still waits for the first picture of a video an item brings in, the composition's
* clock stands, so the video starts at its first frame and not where the clock has got to meanwhile.
*/
export class CompositionSystem implements System {
finished: CompositionFinish[] = [];
private readonly runs = new Map<Entity, Run>();
constructor(private readonly plays = true) {}
constructor(
private readonly plays = true,
private readonly pictures: VideoPictures | null = null,
) {}
update(world: World, dt: number): void {
this.finished = [];
@@ -82,8 +96,8 @@ export class CompositionSystem implements System {
return;
}
run = this.runs.get(placement);
if (!run) this.runs.set(placement, (run = { id: player.run, live: new Map() }));
if (player.shown && isHandleEnabled(placement)) player.time += dt;
if (!run) this.runs.set(placement, (run = { id: player.run, live: new Map(), waited: 0 }));
if (player.shown && isHandleEnabled(placement) && !this.waits(run, dt)) player.time += dt;
player.shown = true;
const length = compositionLength(world.project, comp);
let frame = compositionFrameAt(comp, player.time);
@@ -99,6 +113,20 @@ export class CompositionSystem implements System {
this.show(world, comp, placement, run, frame);
}
/** Whether a video of a live item still has no picture; at most `VIDEO_WAIT_MS` at a stretch. */
private waits(run: Run, dt: number): boolean {
const pictures = this.pictures;
const missing =
pictures !== null &&
[...run.live.values()].some(({ videos }) => videos.some((v) => pictures.missing(v.objectId)));
if (!missing) {
run.waited = 0;
return false;
}
run.waited += dt;
return run.waited * 1000 < VIDEO_WAIT_MS;
}
/** Takes every item of the placement's run away. */
private end(world: World, placement: Entity): void {
const run = this.runs.get(placement);