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