Drop a picture or video onto a composition track (#419)
A track item has always carried an object, and an object needs an asset. Instead of objects without one, a dropped picture or video gets a sprite the composition owns, the way a song owns its sounds (#355). To the rest of the code it is a sprite like any other. - The asset list starts a drag for textures, vector art, pixel art and videos. Dropped on a track, they make the sprite and the item in one undo step. A video item is as long as the video and follows it. - One sprite per picture and composition. A second drop of the same video shares it. Once no item wears it, it goes in the same step. - Deleting a composition takes its sprites along. A duplicate gets sprites of its own. - Pasting into a scene or container turns the sprite into a project asset. Pasting into another composition gives that one a copy. The clipboard carries the sprite, so cut and paste loses nothing. - Owned sprites stay out of the asset list and the pickers. The inspector shows them with a dot in front. - "Make project asset" in the item's menu, on the timeline and in the hierarchy, turns the sprite into an ordinary one. - The first track of the Composition tab gets its top line, as the Song tab's has. Found on the way: loading kept an owner only for the asset types a song can hold. A composition's sprite would have lost its owner and turned up in the list after a reload. Tests: - Unit composition-sprites: 11 new. Without the rule 5 of them are red, without the loading fix the loading test is. - e2e 69 on melektaus-atx: ALL PASSED, 15 checks, 69k new - Gate on melektaus-atx: 305 files, 4714 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f171c77f91
commit
b16fe8c718
18 files changed
+573
-49
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2047 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2048 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* What a composition keeps to itself (#419): a texture or video laid on a track wears a sprite the
|
||||
* composition owns. It is an asset like any other, so everything that finds an asset by its id finds
|
||||
* it, but the lists leave it out, as with the takes of a song (#355). After every edit the project is
|
||||
* held to that: a composition using the sprite of another gets a copy, one placed anywhere else makes
|
||||
* it a project asset, and one nothing uses any more goes.
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import type { Asset, CompositionItem, SpriteAsset } from '../model/Asset';
|
||||
import { isCompositionSprite } from '../model/composition';
|
||||
import { generatesPictures } from '../model/generated';
|
||||
import { pixelArtWornId } from '../model/pixelart';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { findAsset, objectHolders, type Project } from '../model/Project';
|
||||
import { spriteFromTexture } from '../properties/aspect';
|
||||
import { type Removed, removeWhere, restoreAt } from '../util/list';
|
||||
import type { Command } from './Command';
|
||||
import { addCompositionItem } from './compositionCommands';
|
||||
import { oneStep } from './CompoundCommand';
|
||||
import { nextAssetName } from './naming';
|
||||
|
||||
/**
|
||||
* What a sprite made of `asset` wears: a texture, a drawn picture (#185) or a video; a sequence of
|
||||
* pixel art frames its first frame. Null for an asset that is no picture.
|
||||
*/
|
||||
export function wornPicture(asset: Asset): string | null {
|
||||
if (asset.type === 'pixelart') return pixelArtWornId(asset);
|
||||
return asset.type === 'texture' || asset.type === 'video' || generatesPictures(asset.type)
|
||||
? asset.id
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The sprite the composition already keeps for this picture: one serves every item wearing it. */
|
||||
function keptSprite(project: Project, compId: string, textureId: string): SpriteAsset | undefined {
|
||||
return project.assets.find(
|
||||
(a): a is SpriteAsset => isCompositionSprite(a) && a.owner === compId && a.props['texture'] === textureId,
|
||||
);
|
||||
}
|
||||
|
||||
/** A new sprite of the composition's own, wearing the picture at its own size. */
|
||||
function newCompositionSprite(
|
||||
project: Project,
|
||||
compId: string,
|
||||
textureId: string,
|
||||
name: string,
|
||||
): SpriteAsset {
|
||||
const props: PropertyBag = spriteFromTexture(project, textureId);
|
||||
return { id: newId(), type: 'sprite', name, props, owner: compId };
|
||||
}
|
||||
|
||||
/**
|
||||
* A texture, a drawn picture or a video laid on a track: an item wearing the sprite the composition
|
||||
* keeps for that picture, made first when it has none yet, in one undo step.
|
||||
*/
|
||||
export function addPictureItem(
|
||||
project: Project,
|
||||
compId: string,
|
||||
textureId: string,
|
||||
name: string,
|
||||
item: Omit<CompositionItem, 'id'>,
|
||||
): { command: Command; objectId: string } {
|
||||
const kept = keptSprite(project, compId, textureId);
|
||||
const sprite = kept ?? newCompositionSprite(project, compId, textureId, name);
|
||||
const { command, objectId } = addCompositionItem(project, compId, sprite.id, item);
|
||||
return { command: kept ? command : oneStep('Add item', [new AddSpriteCommand(sprite), command]), objectId };
|
||||
}
|
||||
|
||||
class AddSpriteCommand implements Command {
|
||||
readonly label = 'Add sprite';
|
||||
|
||||
constructor(private readonly sprite: SpriteAsset) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
project.assets.push(structuredClone(this.sprite));
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
project.assets = project.assets.filter((a) => a.id !== this.sprite.id);
|
||||
}
|
||||
}
|
||||
|
||||
interface Retarget {
|
||||
holderId: string;
|
||||
objectId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface Owned {
|
||||
assetId: string;
|
||||
from: { owner: string; name: string };
|
||||
to: { owner: string; name: string };
|
||||
}
|
||||
|
||||
export interface SpriteChange {
|
||||
/** Copies for compositions using a sprite another one keeps. */
|
||||
added: Asset[];
|
||||
retargets: Retarget[];
|
||||
/** Sprites made project assets or handed to the composition now using them. */
|
||||
owners: Owned[];
|
||||
dropped: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds every composition's sprites to what uses them, in place. A sprite placed in a scene or a
|
||||
* container becomes a project asset. Otherwise it stays with its composition while that uses it, else
|
||||
* goes to the first composition that does; every other composition using it gets a copy of its own.
|
||||
*/
|
||||
export function settleCompositionSprites(project: Project): SpriteChange {
|
||||
const change: SpriteChange = { added: [], retargets: [], owners: [], dropped: new Set() };
|
||||
const sprites = project.assets.filter(isCompositionSprite);
|
||||
if (sprites.length === 0) return change;
|
||||
const holders = objectHolders(project);
|
||||
for (const sprite of sprites) {
|
||||
const using = holders.filter((h) => h.objects.some((o) => o.assetId === sprite.id));
|
||||
const from = { owner: sprite.owner!, name: sprite.name };
|
||||
if (using.some((h) => h.type !== 'composition')) {
|
||||
const to = { owner: '', name: nextAssetName(project, sprite.name, sprite.id) };
|
||||
setOwner(sprite, to);
|
||||
change.owners.push({ assetId: sprite.id, from, to });
|
||||
continue;
|
||||
}
|
||||
if (using.length === 0) {
|
||||
change.dropped.add(sprite.id);
|
||||
continue;
|
||||
}
|
||||
const keeper = using.find((h) => h.id === sprite.owner) ?? using[0]!;
|
||||
if (keeper.id !== sprite.owner) {
|
||||
const to = { owner: keeper.id, name: sprite.name };
|
||||
setOwner(sprite, to);
|
||||
change.owners.push({ assetId: sprite.id, from, to });
|
||||
}
|
||||
for (const holder of using) {
|
||||
if (holder === keeper) continue;
|
||||
const copy: SpriteAsset = { ...structuredClone(sprite), id: newId(), owner: holder.id };
|
||||
change.added.push(copy);
|
||||
for (const object of holder.objects) {
|
||||
if (object.assetId !== sprite.id) continue;
|
||||
change.retargets.push({ holderId: holder.id, objectId: object.id, from: sprite.id, to: copy.id });
|
||||
object.assetId = copy.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
project.assets.push(...change.added);
|
||||
return change;
|
||||
}
|
||||
|
||||
function setOwner(asset: Asset, to: { owner: string; name: string }): void {
|
||||
if (to.owner) asset.owner = to.owner;
|
||||
else delete asset.owner;
|
||||
asset.name = to.name;
|
||||
}
|
||||
|
||||
function retarget(project: Project, retargets: readonly Retarget[], to: (r: Retarget) => string): void {
|
||||
for (const r of retargets) {
|
||||
const holder = objectHolders(project).find((h) => h.id === r.holderId);
|
||||
const object = holder?.objects.find((o) => o.id === r.objectId);
|
||||
if (object) object.assetId = to(r);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Any edit, with the compositions' sprites settled after it (#419). Worked out once, so a redo brings
|
||||
* back the very same copies; taken back along with the edit.
|
||||
*/
|
||||
export class KeepCompositionSpritesCommand implements Command {
|
||||
private change: SpriteChange | null = null;
|
||||
private removed: Removed<Asset>[] = [];
|
||||
|
||||
constructor(readonly inner: Command) {}
|
||||
|
||||
get label(): string {
|
||||
return this.inner.label;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.inner.execute(project);
|
||||
if (!this.change) this.change = settleCompositionSprites(project);
|
||||
else {
|
||||
for (const o of this.change.owners) setOwner(findAsset(project, o.assetId)!, o.to);
|
||||
project.assets.push(...this.change.added);
|
||||
retarget(project, this.change.retargets, (r) => r.to);
|
||||
}
|
||||
const dropped = this.change.dropped;
|
||||
this.removed = removeWhere(project.assets, (a) => dropped.has(a.id));
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (this.change) {
|
||||
restoreAt(project.assets, this.removed);
|
||||
retarget(project, this.change.retargets, (r) => r.from);
|
||||
const added = new Set(this.change.added.map((a) => a.id));
|
||||
project.assets = project.assets.filter((a) => !added.has(a.id));
|
||||
for (const o of [...this.change.owners].reverse()) setOwner(findAsset(project, o.assetId)!, o.from);
|
||||
}
|
||||
this.inner.undo(project);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { annotationList, type Graph } from '../graph/Graph';
|
||||
import { isTerminalType, terminalNode } from '../graph/nested';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { newId } from '../ids';
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { GraphClip, ObjectClip } from '../model/clipboard';
|
||||
import { findGroup } from '../model/hierarchy';
|
||||
import { isLiveInstance } from '../model/instance';
|
||||
@@ -28,6 +29,7 @@ function freshIds(ids: readonly string[]): Map<string, string> {
|
||||
export class PasteObjectsCommand implements Command {
|
||||
readonly label: string;
|
||||
private readonly ids: Map<string, string>;
|
||||
private restored: Asset[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly holderId: string,
|
||||
@@ -46,8 +48,10 @@ export class PasteObjectsCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
// A cut took the sprites a composition kept for it along (#419); who keeps them is settled after.
|
||||
const restored = (this.clip.sprites ?? []).filter((s) => !findAsset(project, s.id));
|
||||
for (const object of this.clip.objects) {
|
||||
const asset = findAsset(project, object.assetId);
|
||||
const asset = findAsset(project, object.assetId) ?? restored.find((s) => s.id === object.assetId);
|
||||
if (!asset) throw new CommandError('A copied object belongs to an asset that no longer exists');
|
||||
const rejection = placementRejection(project, holder, asset);
|
||||
if (rejection) throw new CommandError(rejection);
|
||||
@@ -55,6 +59,8 @@ export class PasteObjectsCommand implements Command {
|
||||
for (const parentId of new Set(Object.values(this.parents)))
|
||||
if (parentId && isLiveInstance(project, holder, requireStructuralObject(project, holder, parentId)))
|
||||
throw new CommandError('A placed container shows its template; unpack it to put objects inside');
|
||||
this.restored = structuredClone(restored);
|
||||
project.assets.push(...this.restored);
|
||||
const roots = new Map(this.clip.roots.map((root) => [root.id, root]));
|
||||
const pasted = this.clip.objects.map((object): SceneObject => {
|
||||
const copy = structuredClone(object);
|
||||
@@ -87,6 +93,8 @@ export class PasteObjectsCommand implements Command {
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
const ids = new Set(this.ids.values());
|
||||
holder.objects = holder.objects.filter((o) => !ids.has(o.id));
|
||||
const restored = new Set(this.restored.map((s) => s.id));
|
||||
project.assets = project.assets.filter((a) => !restored.has(a.id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ export interface AssetBase {
|
||||
type: AssetType;
|
||||
name: string;
|
||||
props: PropertyBag;
|
||||
/** The song a sound, pattern or sound FX made in it belongs to (#355); absent on a project asset. */
|
||||
/**
|
||||
* The song a sound, pattern or sound FX made in it belongs to (#355), or the composition a sprite
|
||||
* worn by its items does (#419); absent on a project asset.
|
||||
*/
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ 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 { CompositionItem, SpriteAsset } from './Asset';
|
||||
import { isCompositionSprite } from './composition';
|
||||
import type { Mat } from './matrix';
|
||||
import type { Position } from './Properties';
|
||||
import type { ObjectHolder, Project } from './Project';
|
||||
@@ -37,6 +38,8 @@ export interface ObjectClip {
|
||||
roots: ClipRoot[];
|
||||
/** Copied out of a composition (#417): the items of the roots that are items, for a paste there. */
|
||||
items?: CompositionItem[];
|
||||
/** The sprites of a composition's own the objects wear (#419), back for a paste after a cut. */
|
||||
sprites?: SpriteAsset[];
|
||||
}
|
||||
|
||||
export interface GraphClip {
|
||||
@@ -80,10 +83,13 @@ export function copyObjects(
|
||||
.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)) : [];
|
||||
const worn = new Set(objects.map((o) => o.assetId));
|
||||
const sprites = project.assets.filter(isCompositionSprite).filter((a) => worn.has(a.id));
|
||||
return {
|
||||
kind: 'objects',
|
||||
objects,
|
||||
...(items.length > 0 && { items: structuredClone(items) }),
|
||||
...(sprites.length > 0 && { sprites: structuredClone(sprites) }),
|
||||
roots: roots.map((id) => {
|
||||
const object = findObject(view, id)!;
|
||||
return {
|
||||
|
||||
@@ -16,10 +16,12 @@ import { type ReadAt, writeObjectRow } from './animationPreview';
|
||||
import type {
|
||||
AnimationAsset,
|
||||
AnimationTrack,
|
||||
Asset,
|
||||
CompositionAsset,
|
||||
CompositionClip,
|
||||
CompositionItem,
|
||||
CompositionTrack,
|
||||
SpriteAsset,
|
||||
} from './Asset';
|
||||
import { curveLookup } from './curve';
|
||||
import {
|
||||
@@ -47,6 +49,11 @@ export function compositionLoops(comp: CompositionAsset): boolean {
|
||||
return booleanOf(comp.props[COMPOSITION_LOOP_KEY]);
|
||||
}
|
||||
|
||||
/** A sprite a composition keeps to itself (#419), worn by the items a texture or video was laid as. */
|
||||
export function isCompositionSprite(asset: Asset): asset is SpriteAsset {
|
||||
return asset.type === 'sprite' && (asset.owner ?? '') !== '';
|
||||
}
|
||||
|
||||
export function newCompositionTrack(index: number): CompositionTrack {
|
||||
return { id: newId(), name: `Track ${index}` };
|
||||
}
|
||||
|
||||
@@ -235,9 +235,13 @@ export function snappedToMedia(frame: number, media: ItemMedia | null, reach: nu
|
||||
);
|
||||
}
|
||||
|
||||
/** How long an item of `asset` is when it is dropped in: as long as the video its sprite wears, else 3 s. */
|
||||
/**
|
||||
* How long an item of `asset` is when it is dropped in: as long as the video it is (#419) or its
|
||||
* sprite wears, else 3 s.
|
||||
*/
|
||||
export function droppedLength(project: Project, comp: CompositionAsset, asset: Asset): number {
|
||||
const texture = asset.type === 'sprite' ? asset.props.texture : undefined;
|
||||
const texture =
|
||||
asset.type === 'sprite' ? asset.props.texture : asset.type === 'video' ? asset.id : undefined;
|
||||
const video = typeof texture === 'string' ? findVideo(project, texture) : null;
|
||||
const fps = compositionFps(comp);
|
||||
const seconds = video ? videoLengthOf(video) : 0;
|
||||
|
||||
@@ -53,20 +53,19 @@ export function embeddedIn(project: Project, songId: string): Asset[] {
|
||||
return project.assets.filter((a) => a.owner === songId);
|
||||
}
|
||||
|
||||
/** The song an asset belongs to; undefined for a project asset. */
|
||||
export function ownerSong(project: Project, asset: Asset): SongAsset | undefined {
|
||||
const owner = asset.owner ? findAsset(project, asset.owner) : undefined;
|
||||
return owner?.type === 'song' ? owner : undefined;
|
||||
}
|
||||
|
||||
export function isRowHiddenWhenEmbedded(asset: Asset, key: string): boolean {
|
||||
return isEmbedded(asset) && (HIDDEN_ROWS[asset.type]?.includes(key) ?? false);
|
||||
}
|
||||
|
||||
/** The song or composition (#419) an asset belongs to; undefined for a project asset. */
|
||||
export function ownerOf(project: Project, asset: Asset): Asset | undefined {
|
||||
return isEmbedded(asset) ? findAsset(project, asset.owner!) : undefined;
|
||||
}
|
||||
|
||||
/** "Song › Take 1" for an embedded asset, its own name for any other. */
|
||||
export function assetTitle(project: Project, asset: Asset): string {
|
||||
const song = ownerSong(project, asset);
|
||||
return song ? `${song.name} › ${asset.name}` : asset.name;
|
||||
const owner = ownerOf(project, asset);
|
||||
return owner ? `${owner.name} › ${asset.name}` : asset.name;
|
||||
}
|
||||
|
||||
/** The tempo and the meter of a song, as the props a pattern made in it starts with. */
|
||||
|
||||
@@ -369,11 +369,14 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
assets.push({ id, type, name, props });
|
||||
}
|
||||
const owner = typeof a.owner === 'string' ? a.owner : '';
|
||||
if (owner && isEmbeddable(type)) assets[assets.length - 1]!.owner = owner;
|
||||
if (owner && (isEmbeddable(type) || type === 'sprite')) assets[assets.length - 1]!.owner = owner;
|
||||
}
|
||||
// One whose song or composition (#419) is gone is kept as a project asset rather than lost (#355).
|
||||
const ownerType = new Map(assets.map((a) => [a.id, a.type]));
|
||||
for (const asset of assets) {
|
||||
if (!asset.owner) continue;
|
||||
if (ownerType.get(asset.owner) !== (asset.type === 'sprite' ? 'composition' : 'song')) delete asset.owner;
|
||||
}
|
||||
// One whose song is gone is kept as a project asset rather than lost (#355).
|
||||
const songs = new Set(assets.filter((a) => a.type === 'song').map((a) => a.id));
|
||||
for (const asset of assets) if (asset.owner && !songs.has(asset.owner)) delete asset.owner;
|
||||
|
||||
let activeSceneId = typeof r.activeSceneId === 'string' ? r.activeSceneId : '';
|
||||
if (!assets.some((a) => a.type === 'scene' && a.id === activeSceneId)) {
|
||||
|
||||
@@ -29,7 +29,7 @@ import { isTemplateContent } from '../core/model/instance';
|
||||
import type { Command } from '../core/commands/Command';
|
||||
import { findAnnotation, findNode, nodeDef } from '../core/graph';
|
||||
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
|
||||
import { ownerSong } from '../core/music/embedded';
|
||||
import { ownerOf } from '../core/music/embedded';
|
||||
import type { SceneObject } from '../core/model/SceneObject';
|
||||
import { pixelArtRefIds } from '../core/model/pixelart';
|
||||
import { textureRefIds } from '../core/model/texture';
|
||||
@@ -255,9 +255,11 @@ export function removeSelection(state: EditorState): boolean {
|
||||
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
if (!asset) return;
|
||||
const song = ownerSong(state.project, asset);
|
||||
if (song) {
|
||||
state.setNotice(`"${asset.name}" lives in the song "${song.name}" and goes with what uses it there`);
|
||||
const owner = ownerOf(state.project, asset);
|
||||
if (owner) {
|
||||
state.setNotice(
|
||||
`"${asset.name}" lives in the ${owner.type} "${owner.name}" and goes with what uses it there`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const usages = findUsages(state.project, assetId).length;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
removeFromComposition,
|
||||
SetCompositionCommand,
|
||||
} from '../../core/commands/compositionCommands';
|
||||
import { addPictureItem, wornPicture } from '../../core/commands/compositionSprites';
|
||||
import { newId } from '../../core/ids';
|
||||
import { isPlaceableType } from '../../core/model/AssetTypes';
|
||||
import type { CompositionAsset, CompositionClip, CompositionItem } from '../../core/model/Asset';
|
||||
@@ -56,7 +57,7 @@ import {
|
||||
Widget,
|
||||
zoomFactor,
|
||||
} from '../../ui';
|
||||
import { fitToMedia } from '../inspector/compositionSection';
|
||||
import { fitToMedia, makeProjectAssetEntry } from '../inspector/compositionSection';
|
||||
import { clampTimePan, tickToX, xToTick, zoomTime } from '../music/timeView';
|
||||
import {
|
||||
type Box,
|
||||
@@ -713,15 +714,17 @@ export class CompositionTimeline extends Widget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows an asset dragged in from the panel: a placeable one lands as an item under the pointer,
|
||||
* as long as its media or 3 s, over whatever lies there; an animation as a clip one pass long, where
|
||||
* it fits whole.
|
||||
* Follows an asset dragged in from the panel: a placeable one, a picture or a video (#419) lands as
|
||||
* an item under the pointer, as long as its media or 3 s, over whatever lies there; an animation as
|
||||
* a clip one pass long, where it fits whole.
|
||||
*/
|
||||
private followDrop(asset: CompositionAsset, assetId: string, local: Vec2, g: Vec2): void {
|
||||
const dragged = findAsset(this.state.project, assetId);
|
||||
const row = trackAtY(this.view, asset.tracks.length, g.y);
|
||||
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
|
||||
const usable = dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type));
|
||||
const picture = dragged ? wornPicture(dragged) : null;
|
||||
const usable =
|
||||
dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type) || picture !== null);
|
||||
if (!inside || row === null || !dragged || !usable) {
|
||||
if (this.drop) {
|
||||
this.drop = null;
|
||||
@@ -761,7 +764,7 @@ export class CompositionTimeline extends Widget {
|
||||
track,
|
||||
start: at,
|
||||
length: droppedLength(this.state.project, asset, dragged),
|
||||
rejection: placementRejection(this.state.project, asset, dragged),
|
||||
rejection: picture === null ? placementRejection(this.state.project, asset, dragged) : '',
|
||||
};
|
||||
this.redraw();
|
||||
}
|
||||
@@ -801,13 +804,13 @@ export class CompositionTimeline extends Widget {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { command, objectId } = addCompositionItem(this.state.project, asset.id, drop.assetId, {
|
||||
track: drop.track,
|
||||
start: drop.start,
|
||||
length: drop.length,
|
||||
offset: 0,
|
||||
fitMedia: true,
|
||||
});
|
||||
const item = { track: drop.track, start: drop.start, length: drop.length, offset: 0, fitMedia: true };
|
||||
const dragged = findAsset(this.state.project, drop.assetId);
|
||||
const picture = dragged ? wornPicture(dragged) : null;
|
||||
const { command, objectId } =
|
||||
dragged && picture !== null
|
||||
? addPictureItem(this.state.project, asset.id, picture, dragged.name, item)
|
||||
: addCompositionItem(this.state.project, asset.id, drop.assetId, item);
|
||||
if (this.state.execute(command)) this.state.select(objectSelection(asset.id, [objectId]));
|
||||
}
|
||||
|
||||
@@ -843,6 +846,7 @@ export class CompositionTimeline extends Widget {
|
||||
disabled: ids.length !== 1,
|
||||
onSelect: () => fitToMedia({ state: this.state }, asset.id, hit.id),
|
||||
},
|
||||
makeProjectAssetEntry({ state: this.state }, asset.id, hit.id),
|
||||
{ separator: true },
|
||||
{
|
||||
label: ids.length > 1 ? `Remove ${ids.length} items` : 'Remove item',
|
||||
@@ -1020,9 +1024,11 @@ export class CompositionTimeline extends Widget {
|
||||
.rect(HEAD_WIDTH, y, this.lanesWidth, h)
|
||||
.fill({ color: 0xffffff, alpha: row % 2 === 0 ? 0.02 : 0.04 });
|
||||
if (end > zero) this.lanes.rect(zero, y, end - zero, h).fill({ color: 0xffffff, alpha: 0.02 });
|
||||
if (row === 0) this.lanes.rect(HEAD_WIDTH, y, this.lanesWidth, 1).fill({ color: c.border, alpha: 0.6 });
|
||||
this.lanes.rect(HEAD_WIDTH, y + h - 1, this.lanesWidth, 1).fill({ color: c.border, alpha: 0.6 });
|
||||
if (y < RULER_HEIGHT) return;
|
||||
if (track.id === lit) this.bg.rect(0, y, HEAD_WIDTH - 1, h).fill({ color: c.selection, alpha: 0.6 });
|
||||
if (row === 0) this.bg.rect(0, y, HEAD_WIDTH, 1).fill({ color: c.border, alpha: 0.6 });
|
||||
this.bg.rect(0, y + h - 1, HEAD_WIDTH, 1).fill({ color: c.border, alpha: 0.6 });
|
||||
heads.push({ text: track.name, x: 8, y: y + 4, w: HEAD_WIDTH - 16 });
|
||||
});
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { MakeProjectAssetCommand } from '../../core/commands/assetCommands';
|
||||
import { removeFromComposition, SetCompositionCommand } from '../../core/commands/compositionCommands';
|
||||
import type { CompositionAsset, CompositionItem } from '../../core/model/Asset';
|
||||
import { clipLength, compositionFps, fittedLength, itemVideo } from '../../core/model/composition';
|
||||
import {
|
||||
clipLength,
|
||||
compositionFps,
|
||||
fittedLength,
|
||||
isCompositionSprite,
|
||||
itemVideo,
|
||||
} from '../../core/model/composition';
|
||||
import { resizedItem } from '../../core/model/compositionEdit';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, formatNumber } from '../../ui';
|
||||
import { Button, formatNumber, type MenuEntry } from '../../ui';
|
||||
import { createFieldRow } from './fieldFactory';
|
||||
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { assetLink } from './objectSection';
|
||||
@@ -215,6 +222,25 @@ export function fitToMedia(host: Pick<InspectorHost, 'state'>, holderId: string,
|
||||
host.state.execute(new SetCompositionCommand(holderId, { items }, 'Fit to media'));
|
||||
}
|
||||
|
||||
/**
|
||||
* For an item wearing a sprite its composition keeps (#419): that sprite turned into a project asset,
|
||||
* listed and placeable anywhere. Greyed out for any other.
|
||||
*/
|
||||
export function makeProjectAssetEntry(
|
||||
host: Pick<InspectorHost, 'state'>,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
): MenuEntry {
|
||||
const { project } = host.state;
|
||||
const object = findAssetOf(project, holderId, 'composition')?.objects.find((o) => o.id === objectId);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
return {
|
||||
label: 'Make project asset',
|
||||
disabled: !asset || !isCompositionSprite(asset),
|
||||
onSelect: () => asset && host.state.execute(new MakeProjectAssetCommand(asset.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function withOffset(item: CompositionItem, offset: number): CompositionItem {
|
||||
const { fitMedia: _, ...rest } = item;
|
||||
return { ...rest, offset: Number.isFinite(offset) ? offset : 0 };
|
||||
|
||||
@@ -30,6 +30,7 @@ import { type ObjectChoice, objectChoices } from '../../core/model/objectRefs';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
|
||||
import { objectName, type SceneObject } from '../../core/model/SceneObject';
|
||||
import { markedName } from '../../core/music/embedded';
|
||||
import {
|
||||
COMMON_PROPS,
|
||||
isCommonProp,
|
||||
@@ -322,7 +323,7 @@ const PART_OF_ROW = '#part-of';
|
||||
export function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void {
|
||||
const { row, label } = labelledRow(text);
|
||||
linkLabel(host, label)(asset.id);
|
||||
const name = new TextField(asset.name, { readOnly: true });
|
||||
const name = new TextField(markedName(asset), { readOnly: true });
|
||||
row.add(name, 1);
|
||||
host.body.add(row);
|
||||
host.fields.set(key, { row, setValue: (v) => name.setValue(textOf(v)), setModified: () => {} });
|
||||
@@ -361,7 +362,7 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje
|
||||
const { object, asset } = shown;
|
||||
host.nameField?.setValue(object.name);
|
||||
// A rename of the asset changes no structure, so it arrives here rather than as a rebuild.
|
||||
host.fields.get(ASSET_ROW)?.setValue(asset.name);
|
||||
host.fields.get(ASSET_ROW)?.setValue(markedName(asset));
|
||||
const target = targetOf(project, holderId, objectId);
|
||||
if (target) host.fields.get(PART_OF_ROW)?.setValue(target.asset.name);
|
||||
refreshTimingRows(host, holderId, objectId);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from './actions';
|
||||
import { hasAssetEditor } from './assetTabs';
|
||||
import { canPasteObjects, copyObjectIds, pasteObjects } from './clipboard';
|
||||
import { fitToMedia } from './inspector/compositionSection';
|
||||
import { fitToMedia, makeProjectAssetEntry } from './inspector/compositionSection';
|
||||
import { groupMenuItems } from './objectGroups';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
@@ -83,6 +83,7 @@ export function openObjectMenu(
|
||||
disabled: many || fromTemplate,
|
||||
onSelect: () => fitToMedia({ state }, holder.id, id),
|
||||
},
|
||||
makeProjectAssetEntry({ state }, holder.id, id),
|
||||
{
|
||||
label: items.length > 1 ? `Remove ${items.length} items` : 'Remove item',
|
||||
danger: true,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SetActiveSceneCommand,
|
||||
SetStartSceneCommand,
|
||||
} from '../../core/commands/assetCommands';
|
||||
import { wornPicture } from '../../core/commands/compositionSprites';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { nextAssetName } from '../../core/commands/naming';
|
||||
import { ASSET_TYPE_LABELS, type AssetType, isPlaceableType } from '../../core/model/AssetTypes';
|
||||
@@ -17,7 +18,7 @@ import { generatesPictures } from '../../core/model/generated';
|
||||
import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { listedAssets, ownerSong } from '../../core/music/embedded';
|
||||
import { listedAssets, ownerOf } from '../../core/music/embedded';
|
||||
import { spriteFromTexture } from '../../core/properties/aspect';
|
||||
import { assetDeviates } from '../../core/properties/deviation';
|
||||
import { schemaForAsset } from '../../core/properties/PropertySchema';
|
||||
@@ -140,12 +141,12 @@ export class AssetPanel extends Panel {
|
||||
|
||||
/**
|
||||
* Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. What a
|
||||
* song keeps to itself has no row, so its song lights up (#355).
|
||||
* song or a composition keeps to itself has no row, so its owner lights up (#355, #419).
|
||||
*/
|
||||
private ping(): void {
|
||||
const asset = findAsset(this.state.project, this.state.pingedAssetId ?? '');
|
||||
if (!asset) return;
|
||||
const id = ownerSong(this.state.project, asset)?.id ?? asset.id;
|
||||
const id = ownerOf(this.state.project, asset)?.id ?? asset.id;
|
||||
this.reveal(id);
|
||||
this.list.ping(id);
|
||||
}
|
||||
@@ -444,12 +445,13 @@ export class AssetPanel extends Panel {
|
||||
|
||||
/**
|
||||
* Hands the asset to the view under the pointer: a scene places it, a song lays out a pattern or a
|
||||
* sound (#334), a composition an animation as a clip (#417). A scene has nowhere to go.
|
||||
* sound (#334), a composition an animation as a clip (#417) and a picture or video as an item
|
||||
* (#419). A scene has nowhere to go.
|
||||
*/
|
||||
private startPlacementDrag(assetId: string): void {
|
||||
const asset = findAsset(this.state.project, assetId);
|
||||
const laid = asset?.type === 'pattern' || asset?.type === 'sound' || asset?.type === 'animation';
|
||||
if (!asset || !(isPlaceableType(asset.type) || laid)) return;
|
||||
if (!asset || !(isPlaceableType(asset.type) || laid || wornPicture(asset) !== null)) return;
|
||||
startDrag({ kind: 'asset', id: assetId }, asset.name);
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -32,6 +32,7 @@ import type { VisibilityPart } from '../core/model/sceneVisibility';
|
||||
import type { ValuePath } from '../core/properties/valuePath';
|
||||
import { KeepRoutingCommand } from '../core/commands/routingCommands';
|
||||
import { FitMediaCommand } from '../core/commands/compositionCommands';
|
||||
import { KeepCompositionSpritesCommand } from '../core/commands/compositionSprites';
|
||||
import { settleRouting } from '../core/music/projectRouting';
|
||||
|
||||
export type Selection =
|
||||
@@ -472,7 +473,9 @@ export class EditorState {
|
||||
*/
|
||||
execute(command: Command): boolean {
|
||||
return this.runEdit(() => {
|
||||
this.commands.execute(new KeepRoutingCommand(new FitMediaCommand(command)));
|
||||
this.commands.execute(
|
||||
new KeepRoutingCommand(new FitMediaCommand(new KeepCompositionSpritesCommand(command))),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* What a composition keeps to itself (#419): a texture or video laid on a track wears a sprite the
|
||||
* composition owns, one per picture. It goes when nothing uses it, comes back with a pasted cut,
|
||||
* turns into a project asset once placed in a scene and is copied for another composition.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
CreateVideoCommand,
|
||||
DeleteAssetCommand,
|
||||
DuplicateAssetCommand,
|
||||
MakeProjectAssetCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import type { Command } from '../../src/core/commands/Command';
|
||||
import { pasteCompositionItems, removeFromComposition } from '../../src/core/commands/compositionCommands';
|
||||
import {
|
||||
addPictureItem,
|
||||
KeepCompositionSpritesCommand,
|
||||
wornPicture,
|
||||
} from '../../src/core/commands/compositionSprites';
|
||||
import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands';
|
||||
import { copyObjects } from '../../src/core/model/clipboard';
|
||||
import { isCompositionSprite } from '../../src/core/model/composition';
|
||||
import { droppedLength } from '../../src/core/model/compositionEdit';
|
||||
import { findAsset, findAssetOf } from '../../src/core/model/Project';
|
||||
import { assetTitle, listedAssets } from '../../src/core/music/embedded';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { makeProject } from '../helpers';
|
||||
|
||||
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
|
||||
|
||||
function makeFilm() {
|
||||
const f = makeProject();
|
||||
const run = (command: Command) => f.stack.execute(new KeepCompositionSpritesCommand(command));
|
||||
const scene = new CreateAssetCommand('scene', 'Level');
|
||||
const film = new CreateAssetCommand('composition', 'Film');
|
||||
const video = new CreateVideoCommand('Intro', FILE, {
|
||||
width: 320,
|
||||
height: 180,
|
||||
duration: 5,
|
||||
fps: 30,
|
||||
audio: false,
|
||||
});
|
||||
for (const c of [scene, film, video]) run(c);
|
||||
const comp = (id = film.asset.id) => findAssetOf(f.project, id, 'composition')!;
|
||||
const sprites = () => f.project.assets.filter(isCompositionSprite);
|
||||
/** The video laid on the first track of `compId` at `start`, run through the stack as the timeline does. */
|
||||
const drop = (start: number, compId = film.asset.id) => {
|
||||
const { command, objectId } = addPictureItem(f.project, compId, video.asset.id, 'Intro', {
|
||||
track: comp(compId).tracks[0]!.id,
|
||||
start,
|
||||
length: 150,
|
||||
offset: 0,
|
||||
fitMedia: true,
|
||||
});
|
||||
run(command);
|
||||
return objectId;
|
||||
};
|
||||
const wornBy = (holder: { objects: { id: string; assetId: string }[] }, id: string) =>
|
||||
holder.objects.find((o) => o.id === id)!.assetId;
|
||||
return {
|
||||
...f,
|
||||
run,
|
||||
sceneId: scene.asset.id,
|
||||
filmId: film.asset.id,
|
||||
videoId: video.asset.id,
|
||||
comp,
|
||||
sprites,
|
||||
drop,
|
||||
wornBy,
|
||||
};
|
||||
}
|
||||
|
||||
describe('a picture laid on a track', () => {
|
||||
it('wears a sprite the composition keeps, at the picture size, left out of the lists', () => {
|
||||
const film = makeFilm();
|
||||
const id = film.drop(0);
|
||||
const [sprite] = film.sprites();
|
||||
expect(sprite).toMatchObject({
|
||||
owner: film.filmId,
|
||||
name: 'Intro',
|
||||
props: { texture: film.videoId, width: 320, height: 180 },
|
||||
});
|
||||
expect(film.wornBy(film.comp(), id)).toBe(sprite!.id);
|
||||
expect(film.comp().items).toMatchObject([{ id, start: 0, length: 150, fitMedia: true }]);
|
||||
expect(listedAssets(film.project).some((a) => a.id === sprite!.id)).toBe(false);
|
||||
expect(assetTitle(film.project, sprite!)).toBe('Film › Intro');
|
||||
});
|
||||
|
||||
it('is a texture, a drawn picture or a video, and a video lasts as long as it plays', () => {
|
||||
const film = makeFilm();
|
||||
const video = findAsset(film.project, film.videoId)!;
|
||||
expect(wornPicture(video)).toBe(film.videoId);
|
||||
expect(wornPicture(findAsset(film.project, film.sceneId)!)).toBeNull();
|
||||
expect(droppedLength(film.project, film.comp(), video)).toBe(300);
|
||||
});
|
||||
|
||||
it('wears a sprite that "Make project asset" lists like any other, and it stays when the item goes', () => {
|
||||
const film = makeFilm();
|
||||
const id = film.drop(0);
|
||||
const sprite = film.wornBy(film.comp(), id);
|
||||
film.run(new MakeProjectAssetCommand(sprite));
|
||||
expect(findAsset(film.project, sprite)).toMatchObject({ name: 'Intro 2' });
|
||||
expect(listedAssets(film.project).some((a) => a.id === sprite)).toBe(true);
|
||||
film.run(removeFromComposition(film.project, film.filmId, [id], []));
|
||||
expect(findAsset(film.project, sprite)).toBeDefined();
|
||||
});
|
||||
|
||||
it('shares the sprite with every item of the same picture, and the first undo takes only the item', () => {
|
||||
const film = makeFilm();
|
||||
const first = film.drop(0);
|
||||
const second = film.drop(200);
|
||||
expect(film.sprites()).toHaveLength(1);
|
||||
expect(film.wornBy(film.comp(), second)).toBe(film.wornBy(film.comp(), first));
|
||||
film.stack.undo();
|
||||
expect(film.sprites()).toHaveLength(1);
|
||||
film.stack.undo();
|
||||
expect(film.sprites()).toHaveLength(0);
|
||||
expect(film.comp().objects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('takes its sprite along when its last item goes, and an undo brings both back', () => {
|
||||
const film = makeFilm();
|
||||
const a = film.drop(0);
|
||||
const b = film.drop(200);
|
||||
film.run(removeFromComposition(film.project, film.filmId, [a], []));
|
||||
expect(film.sprites()).toHaveLength(1);
|
||||
film.run(removeFromComposition(film.project, film.filmId, [b], []));
|
||||
expect(film.sprites()).toHaveLength(0);
|
||||
film.stack.undo();
|
||||
expect(film.sprites()).toHaveLength(1);
|
||||
expect(film.wornBy(film.comp(), b)).toBe(film.sprites()[0]!.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a copied item', () => {
|
||||
it('pasted after a cut wears its sprite again', () => {
|
||||
const film = makeFilm();
|
||||
const id = film.drop(0);
|
||||
const sprite = film.sprites()[0]!;
|
||||
const clip = copyObjects(film.project, film.comp(), [id])!;
|
||||
film.run(removeFromComposition(film.project, film.filmId, [id], []));
|
||||
expect(film.sprites()).toHaveLength(0);
|
||||
const pasted = pasteCompositionItems(film.project, film.filmId, clip, 50)!;
|
||||
film.run(pasted.command);
|
||||
expect(film.sprites()).toEqual([sprite]);
|
||||
expect(film.wornBy(film.comp(), pasted.itemIds[0]!)).toBe(sprite.id);
|
||||
film.stack.undo();
|
||||
expect(film.sprites()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('pasted into a scene makes the sprite a project asset, named apart from the video', () => {
|
||||
const film = makeFilm();
|
||||
const id = film.drop(0);
|
||||
const clip = copyObjects(film.project, film.comp(), [id])!;
|
||||
const paste = new PasteObjectsCommand(film.sceneId, clip, {}, { x: 0, y: 0 });
|
||||
film.run(paste);
|
||||
const sprite = findAsset(film.project, film.wornBy(film.comp(), id))!;
|
||||
expect(sprite.owner).toBeUndefined();
|
||||
expect(sprite.name).toBe('Intro 2');
|
||||
expect(film.wornBy(findAssetOf(film.project, film.sceneId, 'scene')!, paste.rootIds[0]!)).toBe(sprite.id);
|
||||
film.stack.undo();
|
||||
expect(findAsset(film.project, sprite.id)).toMatchObject({ owner: film.filmId, name: 'Intro' });
|
||||
});
|
||||
|
||||
it('pasted into another composition gets a copy that one keeps', () => {
|
||||
const film = makeFilm();
|
||||
const other = new CreateAssetCommand('composition', 'Other');
|
||||
film.run(other);
|
||||
const id = film.drop(0);
|
||||
const clip = copyObjects(film.project, film.comp(), [id])!;
|
||||
const pasted = pasteCompositionItems(film.project, other.asset.id, clip, 0)!;
|
||||
film.run(pasted.command);
|
||||
const mine = film.wornBy(film.comp(), id);
|
||||
const theirs = film.wornBy(film.comp(other.asset.id), pasted.itemIds[0]!);
|
||||
expect(theirs).not.toBe(mine);
|
||||
expect(findAsset(film.project, mine)!.owner).toBe(film.filmId);
|
||||
expect(findAsset(film.project, theirs)).toMatchObject({
|
||||
owner: other.asset.id,
|
||||
props: { texture: film.videoId },
|
||||
});
|
||||
film.stack.undo();
|
||||
expect(film.sprites().map((s) => s.id)).toEqual([mine]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the composition', () => {
|
||||
it('duplicated, keeps sprites of its own', () => {
|
||||
const film = makeFilm();
|
||||
film.drop(0);
|
||||
const duplicate = new DuplicateAssetCommand(film.filmId);
|
||||
film.run(duplicate);
|
||||
const copy = film.comp(duplicate.copyId);
|
||||
const worn = copy.objects[0]!.assetId;
|
||||
expect(worn).not.toBe(film.comp().objects[0]!.assetId);
|
||||
expect(findAsset(film.project, worn)!.owner).toBe(duplicate.copyId);
|
||||
});
|
||||
|
||||
it('deleted, takes its sprites along', () => {
|
||||
const film = makeFilm();
|
||||
film.drop(0);
|
||||
film.run(new DeleteAssetCommand(film.filmId));
|
||||
expect(film.sprites()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('saved and loaded, still keeps its sprites; one whose composition is gone is a project asset', () => {
|
||||
const film = makeFilm();
|
||||
film.drop(0);
|
||||
const sprite = film.sprites()[0]!;
|
||||
const raw = serializeProject(film.project) as { assets: { id: string; owner?: string }[] };
|
||||
expect(findAsset(deserializeProject(structuredClone(raw)), sprite.id)!.owner).toBe(film.filmId);
|
||||
raw.assets = raw.assets.filter((a) => a.id !== film.filmId);
|
||||
expect(findAsset(deserializeProject(raw), sprite.id)!.owner).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@
|
||||
* past it. An animation dragged in becomes a clip that moves the item it overlaps in the picture. The hierarchy lists what is alive at the playhead, Delete
|
||||
* takes an item with its object, and Play runs the playhead to the end. The video of an item about
|
||||
* to begin is opened ahead, so the cut to it shows its picture at once, and Play waits for a video
|
||||
* whose player is still loading. A copied item is pasted under the pointer or at the playhead.
|
||||
* whose player is still loading. A copied item is pasted under the pointer or at the playhead. A
|
||||
* video dragged in wears a sprite the composition keeps to itself.
|
||||
*
|
||||
* Everything it adds carries a `ct69` id and goes again at the end.
|
||||
*/
|
||||
@@ -308,8 +309,47 @@ export async function compositionTab(t) {
|
||||
JSON.stringify({ copies, picked: pickedNow?.objectIds }),
|
||||
);
|
||||
|
||||
// 69k. A video dragged straight onto a track (#419) is an item as long as the video, wearing a sprite
|
||||
// the composition keeps and the Assets panel leaves out; a second drop shares it, and undoing both
|
||||
// takes the sprite along.
|
||||
const kept = (id) =>
|
||||
json(`(() => {
|
||||
const p = __gm.state.project;
|
||||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||||
const object = c.objects.find((o) => o.id === ${JSON.stringify(id)});
|
||||
const sprite = object && p.assets.find((a) => a.id === object.assetId);
|
||||
return { item: c.items.find((i) => i.id === ${JSON.stringify(id)}) ?? null,
|
||||
sprite: sprite ? { id: sprite.id, type: sprite.type, owner: sprite.owner, texture: sprite.props.texture } : null,
|
||||
owned: p.assets.filter((a) => a.owner === '${CUT}').length };
|
||||
})()`);
|
||||
const videoDrop = await dragAsset(VIDEO, await json(`__gm.composition.laneAt('ct69-t2', 70)`));
|
||||
const one = await kept((await json('__gm.state.selection'))?.objectIds?.[0] ?? '');
|
||||
const row = one.sprite ? await json(`__gm.assetRow('${one.sprite.id}')`) : 'none';
|
||||
await dragAsset(VIDEO, await json(`__gm.composition.laneAt('ct69-t2', 100)`));
|
||||
const two = await kept((await json('__gm.state.selection'))?.objectIds?.[0] ?? '');
|
||||
await evaluate('__gm.state.undo()');
|
||||
await evaluate('__gm.state.undo()');
|
||||
const undone = await kept('');
|
||||
check(
|
||||
'a video dropped on a track wears a sprite the composition keeps, shared by a second drop',
|
||||
videoDrop?.what === 'item' &&
|
||||
videoDrop.rejection === '' &&
|
||||
one.item?.track === 'ct69-t2' &&
|
||||
one.item.start === 70 &&
|
||||
one.item.length === 20 &&
|
||||
one.sprite?.type === 'sprite' &&
|
||||
one.sprite.owner === CUT &&
|
||||
one.sprite.texture === VIDEO &&
|
||||
row === null &&
|
||||
two.item?.start === 100 &&
|
||||
two.sprite?.id === one.sprite.id &&
|
||||
two.owned === 1 &&
|
||||
undone.owned === 0,
|
||||
JSON.stringify({ videoDrop, one, row, two, undone }),
|
||||
);
|
||||
|
||||
await showTab('scene');
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-'));`);
|
||||
p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-') && !a.owner?.startsWith('ct69-'));`);
|
||||
}
|
||||
Reference in new issue
Block a user