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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user