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

+34 -1
View File
@@ -9,10 +9,12 @@ import {
heldCompositionItems,
heldCompositionTracks,
} from '../model/composition';
import { mediaKey } from '../model/compositionEdit';
import type { ObjectClip } from '../model/clipboard';
import { mediaKey, pastedItems } from '../model/compositionEdit';
import type { Project } from '../model/Project';
import type { Command } from './Command';
import { oneStep } from './CompoundCommand';
import { PasteObjectsCommand } from './pasteCommands';
import { requireComposition } from './require';
import { PlaceAssetCommand, RemoveSceneObjectsCommand } from './sceneObjectCommands';
@@ -80,6 +82,37 @@ export function addCompositionItem(
return { command: oneStep('Add item', [place, set]), objectId: place.objectId };
}
/**
* Copied items put back into a composition: their objects where the copied ones stood, the items
* timed as `pastedItems` says. Null when the copy holds anything but items – a scene's objects have
* no time to land at. `itemIds` are the new items', to pick them afterwards.
*/
export function pasteCompositionItems(
project: Project,
assetId: string,
clip: ObjectClip,
at: number,
row?: number,
): { command: Command; itemIds: string[] } | null {
const copied = new Map((clip.items ?? []).map((i) => [i.id, i]));
if (clip.roots.length === 0 || clip.roots.some((r) => !copied.has(r.id))) return null;
const comp = requireComposition(project, assetId);
const paste = new PasteObjectsCommand(assetId, clip, {}, { x: 0, y: 0 });
const ids = new Map(clip.roots.map((r, n) => [r.id, paste.rootIds[n]!]));
const items = pastedItems(
clip.roots.map((r) => copied.get(r.id)!),
ids,
at,
comp.tracks.map((t) => t.id),
row,
);
const set = new SetCompositionCommand(assetId, { items: [...comp.items, ...items] });
return {
command: oneStep(items.length === 1 ? 'Paste item' : 'Paste items', [paste, set]),
itemIds: items.map((i) => i.id),
};
}
/** Items and clips taken off together, the items' objects with them. */
export function removeFromComposition(
project: Project,
+5
View File
@@ -14,6 +14,7 @@ import {
import { editorTransform, parentTransform, positionOf } from '../properties/transform';
import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy';
import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance';
import type { CompositionItem } from './Asset';
import type { Mat } from './matrix';
import type { Position } from './Properties';
import type { ObjectHolder, Project } from './Project';
@@ -34,6 +35,8 @@ export interface ObjectClip {
/** Parents before children; a root's `parentId` is the one it had where it was copied. */
objects: SceneObject[];
roots: ClipRoot[];
/** Copied out of a composition (#417): the items of the roots that are items, for a paste there. */
items?: CompositionItem[];
}
export interface GraphClip {
@@ -76,9 +79,11 @@ export function copyObjects(
.map((entry) => entry.object)
.filter((o) => members.has(o.id) && !shownByInstance(o.id))
.map((o) => ({ ...structuredClone(o), editorOffset: { x: 0, y: 0 } }));
const items = holder.type === 'composition' ? holder.items.filter((i) => roots.includes(i.id)) : [];
return {
kind: 'objects',
objects,
...(items.length > 0 && { items: structuredClone(items) }),
roots: roots.map((id) => {
const object = findObject(view, id)!;
return {
+27
View File
@@ -124,6 +124,33 @@ export function movedClips(
return out && apart(out, lengthOf) ? out : null;
}
/**
* Copied items under the ids `ids` gives them, moved so the earliest starts at `at`: on their tracks,
* or, given `row`, the topmost on that track and the others as far below as they were, as far as
* there are tracks – a song's paste. An item whose track is gone counts as on the first.
*/
export function pastedItems(
items: readonly CompositionItem[],
ids: ReadonlyMap<string, string>,
at: number,
trackIds: readonly string[],
row?: number,
): CompositionItem[] {
if (items.length === 0) return [];
const first = Math.min(...items.map((i) => i.start));
const index = new Map(trackIds.map((id, i) => [id, i]));
const rows = items.map((i) => index.get(i.track) ?? 0);
const top = Math.min(...rows);
const bottom = Math.max(...rows);
const shift = row === undefined ? 0 : Math.max(-top, Math.min(trackIds.length - 1 - bottom, row - top));
return items.map((i, n) => ({
...i,
id: ids.get(i.id) ?? i.id,
start: Math.max(0, Math.round(at)) + (i.start - first),
track: trackIds[rows[n]! + shift] ?? i.track,
}));
}
/** 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;
+6
View File
@@ -6,6 +6,12 @@ import { storedFilePattern } from './storedFile';
const FILE_PATTERN = storedFilePattern('mp4');
/**
* How long a run waits for a driven video (#404) it shows to have its first picture before it goes
* anyway: at its start, and a composition (#417) at each item that brings one in.
*/
export const VIDEO_WAIT_MS = 3000;
export function isVideoFile(name: string): boolean {
return FILE_PATTERN.test(name);
}