Drag clips of videos and sounds straight onto a track (#425)
A clip of a video could not be laid on a composition track at all, and neither could a clip of a sound, although #418 and the wiki said so. The only place a drag starts is the asset panel, and it listed assets, not clips. The texture label of an item playing a video clip was dead too, because the reference lookup did not know video clips. - The asset panel gives a sound or video with clips a chevron of its own. It unfolds one row per clip and starts closed. - A clip row drags like an asset. One click shows the clip in the inspector without leaving the tab (`EditorState.glance`). A double click opens its sound or video with the clip picked. - A video clip dropped on a composition track becomes an item as long as the clip. It wears the sprite of its whole video, so a composition keeps one sprite per video, and the clip is a texture override on the object. Sprite, item and override are one undo step (`droppedPicture`). - A sound clip becomes an audio item as long as the clip. The song tab takes sound clips on the same way, through `findSound`. - `referencedTarget` lets a reference label link to the clip itself. One click pings the clip row, a double click opens the asset with the clip picked. The Plays row of an audio item reads "Sound / Clip". - `clipOwner` in `sound.ts` finds the sound or video a clip id belongs to. `activateAssetOrClip` uses it for the double clicks. Found on the way: the Blip of the e2e seed is only 0.25 s long, so a clip of 0.2 to 0.7 s on it is cut to one frame. 69s takes the Drone (0.75 s) instead. Tests: - tsc, eslint, Prettier clean - Unit on stein2 (composition, references, sound, video, editor): 94 files, 1238 tests green - e2e 69 on melektaus-atx: ALL PASSED, 25 checks, 2 of them new (69s) - Gate on melektaus-atx with the fix for 68k on top: CHECK EXIT 0, 313 files, 4841 tests - Counter checks, all red: no clip rows in e2e 69 (no drag starts), no clip override in the unit test, the link aimed at the asset instead of the clip - Not covered by its own e2e check: sound clips on the song tab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0701fe3856
commit
406839baea
20 files changed
+469
-94
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, 2088 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2090 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
|
||||
|
||||
@@ -12,12 +12,14 @@ 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 { findVideo } from '../model/video';
|
||||
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';
|
||||
import { SetOverrideCommand } from './sceneObjectCommands';
|
||||
|
||||
/**
|
||||
* What a sprite made of `asset` wears: a texture, a drawn picture (#185) or a video; a sequence of
|
||||
@@ -30,6 +32,29 @@ export function wornPicture(asset: Asset): string | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** What an item dropped on a track wears: the picture its sprite is made of, and a clip of it (#425). */
|
||||
export interface DroppedPicture {
|
||||
picture: string;
|
||||
/** The clip of a video the item plays, as an override on its object; null for the whole picture. */
|
||||
clip: string | null;
|
||||
/** The name a new sprite for the picture gets. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an asset or a clip of a video (#425) dragged onto a track wears. A clip shares the sprite of
|
||||
* its video, so one composition keeps one sprite per video. Null for anything that is no picture.
|
||||
*/
|
||||
export function droppedPicture(project: Project, id: string): DroppedPicture | null {
|
||||
const asset = findAsset(project, id);
|
||||
if (asset) {
|
||||
const picture = wornPicture(asset);
|
||||
return picture === null ? null : { picture, clip: null, name: asset.name };
|
||||
}
|
||||
const video = findVideo(project, id);
|
||||
return video?.clip ? { picture: video.asset.id, clip: video.clip.id, name: video.asset.name } : 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(
|
||||
@@ -49,20 +74,25 @@ function newCompositionSprite(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A texture, a drawn picture, a video or a clip of one 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. A clip is
|
||||
* the texture of the item's object.
|
||||
*/
|
||||
export function addPictureItem(
|
||||
project: Project,
|
||||
compId: string,
|
||||
textureId: string,
|
||||
name: string,
|
||||
dropped: DroppedPicture,
|
||||
item: Omit<CompositionItem, 'id'>,
|
||||
): { command: Command; objectId: string } {
|
||||
const kept = keptSprite(project, compId, textureId);
|
||||
const sprite = kept ?? newCompositionSprite(project, compId, textureId, name);
|
||||
const kept = keptSprite(project, compId, dropped.picture);
|
||||
const sprite = kept ?? newCompositionSprite(project, compId, dropped.picture, dropped.name);
|
||||
const { command, objectId } = addCompositionItem(project, compId, sprite.id, item);
|
||||
return { command: kept ? command : oneStep('Add item', [new AddSpriteCommand(sprite), command]), objectId };
|
||||
const steps = [
|
||||
...(kept ? [] : [new AddSpriteCommand(sprite)]),
|
||||
command,
|
||||
...(dropped.clip ? [new SetOverrideCommand(compId, objectId, 'texture', dropped.clip)] : []),
|
||||
];
|
||||
return { command: steps.length === 1 ? command : oneStep('Add item', steps), objectId };
|
||||
}
|
||||
|
||||
class AddSpriteCommand implements Command {
|
||||
|
||||
@@ -5,13 +5,7 @@
|
||||
* into.
|
||||
*/
|
||||
import { videoLoopsOf } from '../properties/videos';
|
||||
import type {
|
||||
Asset,
|
||||
CompositionAsset,
|
||||
CompositionAudioKind,
|
||||
CompositionClip,
|
||||
CompositionItem,
|
||||
} from './Asset';
|
||||
import type { CompositionAsset, CompositionAudioKind, CompositionClip, CompositionItem } from './Asset';
|
||||
import {
|
||||
audioRoundSeconds,
|
||||
audioSeconds,
|
||||
@@ -407,11 +401,11 @@ export function audioKindOf(project: Project, assetId: string): CompositionAudio
|
||||
}
|
||||
|
||||
/**
|
||||
* How long an item of `asset` is when it is dropped in: as long as the video it is (#419) or its
|
||||
* sprite wears, or what it plays as an audio item (#418); else 3 s.
|
||||
* How long an item of the asset `id` names is when it is dropped in: as long as the video or clip of
|
||||
* one it is (#419, #425) or its sprite wears, or what it plays as an audio item (#418); else 3 s.
|
||||
*/
|
||||
export function droppedLength(project: Project, comp: CompositionAsset, asset: Asset): number {
|
||||
const kind = audioKindOf(project, asset.id);
|
||||
export function droppedLength(project: Project, comp: CompositionAsset, id: string): number {
|
||||
const kind = audioKindOf(project, id);
|
||||
if (kind)
|
||||
return fittedLength(project, comp, {
|
||||
id: '',
|
||||
@@ -419,10 +413,10 @@ export function droppedLength(project: Project, comp: CompositionAsset, asset: A
|
||||
start: 0,
|
||||
length: 1,
|
||||
offset: 0,
|
||||
audio: { kind, asset: asset.id, volume: 0, bus: '' },
|
||||
audio: { kind, asset: id, volume: 0, bus: '' },
|
||||
});
|
||||
const texture =
|
||||
asset.type === 'sprite' ? asset.props.texture : asset.type === 'video' ? asset.id : undefined;
|
||||
const asset = findAsset(project, id);
|
||||
const texture = asset?.type === 'sprite' ? asset.props.texture : id;
|
||||
const video = typeof texture === 'string' ? findVideo(project, texture) : null;
|
||||
const fps = compositionFps(comp);
|
||||
const seconds = video ? videoLengthOf(video) : 0;
|
||||
|
||||
+14
-1
@@ -55,6 +55,14 @@ export function assetNamedBy(assets: readonly Asset[], id: string): Asset | unde
|
||||
);
|
||||
}
|
||||
|
||||
/** The sound or video `id` is a clip of; undefined for an asset's own id or anything else. */
|
||||
export function clipOwner(project: Project, id: string): ClipOwner | undefined {
|
||||
if (id === '' || findAsset(project, id)) return undefined;
|
||||
return project.assets.find(
|
||||
(a): a is ClipOwner => hasClips(a) && clipsOf<{ id: string }>(a).some((c) => c.id === id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the clips of a copy fresh ids, since a clip's id is named across the document, and notes
|
||||
* each old id with its new one in `ids` so the places that named a clip can follow.
|
||||
@@ -70,7 +78,12 @@ export function renewClipIds(copy: Asset, ids: Map<string, string>): void {
|
||||
|
||||
/** "Kick" for the sound, "Kick / Clip 1" for a clip of it. */
|
||||
export function soundRefName(ref: SoundRef, soundName = ref.asset.name): string {
|
||||
return ref.clip ? `${soundName}${CLIP_SEPARATOR}${ref.clip.name}` : soundName;
|
||||
return ref.clip ? clipRefName(soundName, ref.clip.name) : soundName;
|
||||
}
|
||||
|
||||
/** "Kick / Clip 1": a clip of a sound or a video by name. */
|
||||
export function clipRefName(assetName: string, clipName: string): string {
|
||||
return `${assetName}${CLIP_SEPARATOR}${clipName}`;
|
||||
}
|
||||
|
||||
/** "Clip 1", 2, 3 …: the first number none of the asset's clips has. */
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { type PropertyBag, type PropertyValue, textOf } from '../model/Properties';
|
||||
import { findPixelFrame } from '../model/pixelart';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import { findSound } from '../model/sound';
|
||||
import { clipOwner, findSound } from '../model/sound';
|
||||
import { findFrame } from '../model/texture';
|
||||
import { findVideo } from '../model/video';
|
||||
import { isKindWith } from './kinds';
|
||||
import type { PropertyDef } from './PropertySchema';
|
||||
|
||||
/**
|
||||
* The asset a reference field points at, null when it names nothing or no longer resolves. A frame
|
||||
* names the texture or pixel art it was cut from – that is the asset one can open (#221) – and a
|
||||
* clip its sound.
|
||||
* clip its sound or video.
|
||||
*/
|
||||
export function referencedAssetId(project: Project, def: PropertyDef, value: PropertyValue): string | null {
|
||||
if (!isKindWith('reference', def.kind)) return null;
|
||||
@@ -18,7 +19,22 @@ export function referencedAssetId(project: Project, def: PropertyDef, value: Pro
|
||||
if (findAsset(project, id)) return id;
|
||||
// A clip (#357) names the sound it was cut from, as a frame does.
|
||||
if (def.kind !== 'texture') return findSound(project, id)?.asset.id ?? null;
|
||||
return findFrame(project, id)?.asset.id ?? findPixelFrame(project, id)?.asset.id ?? null;
|
||||
return (
|
||||
findFrame(project, id)?.asset.id ??
|
||||
findPixelFrame(project, id)?.asset.id ??
|
||||
findVideo(project, id)?.asset.id ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the label of a reference row links to: the asset it names, or the clip of a sound or a video
|
||||
* itself (#425) – a clip has a row of its own in the asset panel and is picked in its asset's tab.
|
||||
*/
|
||||
export function referencedTarget(project: Project, def: PropertyDef, value: PropertyValue): string | null {
|
||||
const assetId = referencedAssetId(project, def, value);
|
||||
const id = textOf(value);
|
||||
return assetId && clipOwner(project, id)?.id === assetId ? id : assetId;
|
||||
}
|
||||
|
||||
export interface Reference {
|
||||
|
||||
@@ -643,6 +643,7 @@ export class Editor implements PlayHost {
|
||||
// brings it up. Selecting an asset never does: that is what opening it (double-click, Enter,
|
||||
// creating it) is for – see the `open` event below.
|
||||
state.on('selection', () => {
|
||||
if (state.glancing) return;
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'node' && s.assetId === ROUTING_ID) this.openDocument('routing');
|
||||
else if (
|
||||
|
||||
@@ -32,6 +32,7 @@ import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../c
|
||||
import { ownerOf } from '../core/music/embedded';
|
||||
import type { SceneObject } from '../core/model/SceneObject';
|
||||
import { pixelArtRefIds } from '../core/model/pixelart';
|
||||
import { clipOwner } from '../core/model/sound';
|
||||
import { textureRefIds } from '../core/model/texture';
|
||||
import { videoRefIds } from '../core/model/video';
|
||||
import {
|
||||
@@ -54,6 +55,13 @@ export function activateAsset(state: EditorState, assetId: string): void {
|
||||
state.openAsset(assetId);
|
||||
}
|
||||
|
||||
/** The same for an id that may name a clip of a sound or a video (#425): its tab opens with the clip picked. */
|
||||
export function activateAssetOrClip(state: EditorState, id: string): void {
|
||||
const owner = clipOwner(state.project, id);
|
||||
if (owner) state.select({ kind: 'clip', assetId: owner.id, clipId: id });
|
||||
else activateAsset(state, id);
|
||||
}
|
||||
|
||||
/** Groups the objects into a new container asset placed in their stead; selects the container. */
|
||||
export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
|
||||
const command = new CreateContainerCommand(holderId, objectIds);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
removeFromComposition,
|
||||
SetCompositionCommand,
|
||||
} from '../../core/commands/compositionCommands';
|
||||
import { addPictureItem, wornPicture } from '../../core/commands/compositionSprites';
|
||||
import { addPictureItem, droppedPicture } from '../../core/commands/compositionSprites';
|
||||
import { newId } from '../../core/ids';
|
||||
import { isPlaceableType } from '../../core/model/AssetTypes';
|
||||
import type {
|
||||
@@ -906,10 +906,10 @@ export class CompositionTimeline extends Widget {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; a sound, a clip
|
||||
* of one, a pattern or a song as an audio item as long as one pass of it (#418); 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, a video or a clip of one
|
||||
* (#419, #425) lands as an item under the pointer, as long as its media or 3 s, over whatever lies
|
||||
* there; a sound, a clip of one, a pattern or a song as an audio item as long as one pass of it
|
||||
* (#418); an animation as a clip one pass long, where it fits whole.
|
||||
*/
|
||||
private followDrop(asset: CompositionAsset, assetId: string, local: Vec2, g: Vec2): void {
|
||||
const project = this.state.project;
|
||||
@@ -917,10 +917,11 @@ export class CompositionTimeline extends Widget {
|
||||
const audio = audioKindOf(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 picture = dragged ? wornPicture(dragged) : null;
|
||||
const picture = droppedPicture(project, assetId);
|
||||
const usable =
|
||||
audio !== null ||
|
||||
(dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type) || picture !== null));
|
||||
picture !== null ||
|
||||
(dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type)));
|
||||
if (!inside || row === null || !usable) {
|
||||
if (this.drop) {
|
||||
this.drop = null;
|
||||
@@ -942,7 +943,7 @@ export class CompositionTimeline extends Widget {
|
||||
length: fittedLength(project, asset, item),
|
||||
rejection: '',
|
||||
};
|
||||
} else if (dragged!.type === 'animation') {
|
||||
} else if (dragged?.type === 'animation') {
|
||||
const length = clipLength(this.state.project, asset, {
|
||||
id: '',
|
||||
track,
|
||||
@@ -971,7 +972,7 @@ export class CompositionTimeline extends Widget {
|
||||
assetId,
|
||||
track,
|
||||
start: at,
|
||||
length: droppedLength(this.state.project, asset, dragged!),
|
||||
length: droppedLength(this.state.project, asset, assetId),
|
||||
rejection: picture === null ? placementRejection(this.state.project, asset, dragged!) : '',
|
||||
};
|
||||
this.redraw();
|
||||
@@ -1025,12 +1026,10 @@ export class CompositionTimeline extends Widget {
|
||||
if (this.state.execute(command)) this.pickItems([added.id]);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
const picture = droppedPicture(this.state.project, drop.assetId);
|
||||
const { command, objectId } = picture
|
||||
? addPictureItem(this.state.project, asset.id, picture, item)
|
||||
: addCompositionItem(this.state.project, asset.id, drop.assetId, item);
|
||||
if (this.state.execute(command)) this.state.select(objectSelection(asset.id, [objectId]));
|
||||
}
|
||||
|
||||
|
||||
@@ -428,6 +428,8 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
/** An asset's row, its group unfolded first as a user would; `assetRowShown` leaves it folded. */
|
||||
assetRow: (id: string) => assets.rowGlobal(id, true),
|
||||
assetRowShown: (id: string) => assets.rowGlobal(id),
|
||||
/** The chevron that folds the clips of a sound or a video (#425); null while its row has none. */
|
||||
assetChevron: (id: string) => assets.chevronGlobal(id),
|
||||
/** The asset row lighting up after a ping (#221) and how brightly, or null once it has faded. */
|
||||
assetPing: () => assets.pingState(),
|
||||
assetRowColor: (id: string) => assets.rowColor(id),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import type { FederatedPointerEvent } from 'pixi.js';
|
||||
import { Label, Panel, pressCount, ScrollContainer, ui, type Widget } from '../../ui';
|
||||
import { activateAsset } from '../actions';
|
||||
import { activateAssetOrClip } from '../actions';
|
||||
import { type EditorState, sameSelection, type Selection } from '../state';
|
||||
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
|
||||
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
|
||||
@@ -66,13 +66,13 @@ export class Inspector extends Panel {
|
||||
}
|
||||
|
||||
/**
|
||||
* A link in a reference row (#221): a click pings the asset in the asset panel, a double-click
|
||||
* opens it. Pinging leaves the selection alone, so this panel keeps showing the object and both
|
||||
* presses land on the same link.
|
||||
* A link in a reference row (#221): a click pings the asset or clip (#425) in the asset panel, a
|
||||
* double-click opens it. Pinging leaves the selection alone, so this panel keeps showing the object
|
||||
* and both presses land on the same link.
|
||||
*/
|
||||
private followReference(assetId: string, e: FederatedPointerEvent): void {
|
||||
if (pressCount(e, `inspector-link:${assetId}`) === 2) activateAsset(this.state, assetId);
|
||||
else this.state.pingAsset(assetId);
|
||||
private followReference(id: string, e: FederatedPointerEvent): void {
|
||||
if (pressCount(e, `inspector-link:${id}`) === 2) activateAssetOrClip(this.state, id);
|
||||
else this.state.pingAsset(id);
|
||||
}
|
||||
|
||||
/** Signing out destroys the panel while the page stays up (#177), so the handler goes with it. */
|
||||
@@ -106,7 +106,7 @@ export class Inspector extends Panel {
|
||||
private emptyHost(): InspectorHost {
|
||||
return emptyHost(this.state, this.scroll, {
|
||||
liveVariables: (assetId) => this.liveVariables(assetId),
|
||||
followReference: (assetId, e) => this.followReference(assetId, e),
|
||||
followReference: (id, e) => this.followReference(id, e),
|
||||
listLimit: INLINE_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ function buildAudioItem(
|
||||
): void {
|
||||
addTitle(host, AUDIO_TITLES[audio.kind]);
|
||||
const played = playedAsset(host.state.project, audio);
|
||||
if (played) assetLink(host, PLAYS_ROW, 'Plays', played);
|
||||
if (played) assetLink(host, PLAYS_ROW, 'Plays', played, audio.asset);
|
||||
else addNote(host, `Its ${audio.kind} is gone.`, 'warning');
|
||||
addTimingRows(host, assetId, item.id);
|
||||
const edit = (label: string, change: Partial<CompositionAudio>) => {
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { referencedAssetId } from '../../core/properties/references';
|
||||
import { referencedTarget } from '../../core/properties/references';
|
||||
import {
|
||||
NO_TEXTURE_LABEL,
|
||||
pictureChoices,
|
||||
@@ -80,10 +80,11 @@ import type { ObjectPicker } from '../objectPick';
|
||||
export interface FieldContext {
|
||||
project(): Project;
|
||||
/**
|
||||
* A press on the label of a reference row that names an asset (#221). Without it the label stays
|
||||
* plain text – only the inspector knows what a click (ping) and a double-click (open) do.
|
||||
* A press on the label of a reference row that names an asset (#221) or a clip of one (#425).
|
||||
* Without it the label stays plain text – only the inspector knows what a click (ping) and a
|
||||
* double-click (open) do.
|
||||
*/
|
||||
followReference?(assetId: string, e: FederatedPointerEvent): void;
|
||||
followReference?(id: string, e: FederatedPointerEvent): void;
|
||||
/** Items a list draws inline before "… N more" (#252); none means all of them. */
|
||||
listLimit?: number;
|
||||
/**
|
||||
@@ -291,7 +292,7 @@ export function createFieldRow(
|
||||
let modified = false;
|
||||
let target: string | null = null;
|
||||
const retarget = (v: PropertyValue): void => {
|
||||
target = referencedAssetId(ctx.project(), def, v);
|
||||
target = referencedTarget(ctx.project(), def, v);
|
||||
paintLabel(target, modified);
|
||||
};
|
||||
retarget(value);
|
||||
|
||||
@@ -31,6 +31,7 @@ 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 { assetRefLabel } from '../../core/properties/assetRef';
|
||||
import {
|
||||
COMMON_PROPS,
|
||||
isCommonProp,
|
||||
@@ -336,11 +337,19 @@ const PART_OF_ROW = '#part-of';
|
||||
/**
|
||||
* What the object was placed from: the label is a link like on any reference row (click pings,
|
||||
* double-click opens), the name next to it only reads – changing it would mean another object.
|
||||
* `named` is a clip of the asset where the row names one (#425); the link goes to the clip then.
|
||||
*/
|
||||
export function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void {
|
||||
export function assetLink(
|
||||
host: InspectorHost,
|
||||
key: string,
|
||||
text: string,
|
||||
asset: Asset,
|
||||
named = asset.id,
|
||||
): void {
|
||||
const { row, label } = labelledRow(text);
|
||||
linkLabel(host, label)(asset.id);
|
||||
const name = new TextField(markedName(asset), { readOnly: true });
|
||||
linkLabel(host, label)(named);
|
||||
const shown = named === asset.id ? markedName(asset) : assetRefLabel(host.state.project, named);
|
||||
const name = new TextField(shown, { readOnly: true });
|
||||
row.add(name, 1);
|
||||
host.body.add(row);
|
||||
host.fields.set(key, { row, setValue: (v) => name.setValue(textOf(v)), setModified: () => {} });
|
||||
|
||||
@@ -18,6 +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 { clipOwner, clipRefName, clipsOf, hasClips } from '../../core/model/sound';
|
||||
import { listedAssets, ownerOf } from '../../core/music/embedded';
|
||||
import { spriteFromTexture } from '../../core/properties/aspect';
|
||||
import { assetDeviates } from '../../core/properties/deviation';
|
||||
@@ -42,7 +43,7 @@ import { type AssetCategory, type AssetCategoryGroup, ASSET_GROUPS, groupAssets
|
||||
import { ASSET_ICON_SVG } from './assetIcons';
|
||||
import { assetMenuCategories } from './assetMenu';
|
||||
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
||||
import { activateAsset, confirmDeleteAsset } from '../actions';
|
||||
import { activateAssetOrClip, confirmDeleteAsset } from '../actions';
|
||||
import { openReferenceItems } from '../objectMenu';
|
||||
import { generalSettings, onGeneralSettings } from '../generalSettings';
|
||||
import { scrollTween } from '../viewTween';
|
||||
@@ -76,6 +77,8 @@ export class AssetPanel extends Panel {
|
||||
private readonly unfolded = new Set<AssetType>();
|
||||
/** Categories folded away, for the session; every category starts unfolded, showing its groups. */
|
||||
private readonly foldedCategories = new Set<AssetCategory>();
|
||||
/** Sounds and videos whose clips are shown under them (#425), for the session; all start folded. */
|
||||
private readonly unfoldedClips = new Set<string>();
|
||||
/** Ids of the rows as shown, headings included. */
|
||||
private rows: string[] = [];
|
||||
/**
|
||||
@@ -104,14 +107,15 @@ export class AssetPanel extends Panel {
|
||||
this.header.add(add);
|
||||
|
||||
this.list.emptyText = 'No assets – press + or right-click';
|
||||
this.list.onSelect = (id) => state.select(id ? { kind: 'asset', assetId: id } : null);
|
||||
this.list.onSelect = (id) => this.pick(id);
|
||||
this.list.onActivate = (id) => this.activate(id);
|
||||
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
|
||||
this.list.onListMenu = (x, y, headingId) =>
|
||||
new Menu(this.addMenuEntries(headingId), { title: ADD_MENU_TITLE }).open(x, y);
|
||||
this.list.onDragOut = (id) => this.startPlacementDrag(id);
|
||||
this.list.onToggle = (id, expand) => {
|
||||
if (id.startsWith(CATEGORY))
|
||||
if (findAsset(state.project, id)) this.setClipsFolded(id, !expand);
|
||||
else if (id.startsWith(CATEGORY))
|
||||
this.setCategoryFolded(id.slice(CATEGORY.length) as AssetCategory, !expand);
|
||||
else this.setFolded(id.slice(HEADING.length) as AssetType, !expand);
|
||||
};
|
||||
@@ -128,6 +132,7 @@ export class AssetPanel extends Panel {
|
||||
state.on('load', () => {
|
||||
this.unfolded.clear();
|
||||
this.foldedCategories.clear();
|
||||
this.unfoldedClips.clear();
|
||||
this.scrolledTo = null;
|
||||
this.refresh();
|
||||
});
|
||||
@@ -148,17 +153,26 @@ export class AssetPanel extends Panel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. What a
|
||||
* song or a composition keeps to itself has no row, so its owner lights up (#355, #419).
|
||||
* Unfolds and scrolls to the pinged asset or clip (#425) and lets its row light up; the selection
|
||||
* stays. What a 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 ?? '');
|
||||
const pinged = this.state.pingedAssetId ?? '';
|
||||
const asset = findAsset(this.state.project, pinged) ?? clipOwner(this.state.project, pinged);
|
||||
if (!asset) return;
|
||||
const id = ownerOf(this.state.project, asset)?.id ?? asset.id;
|
||||
const id = ownerOf(this.state.project, asset)?.id ?? pinged;
|
||||
this.reveal(id);
|
||||
this.list.ping(id);
|
||||
}
|
||||
|
||||
/** A row picked in the list: an asset, or a clip (#425), looked at in the inspector without its tab. */
|
||||
private pick(id: string | null): void {
|
||||
const owner = id ? clipOwner(this.state.project, id) : undefined;
|
||||
if (owner && id) this.state.glance({ kind: 'clip', assetId: owner.id, clipId: id });
|
||||
else this.state.select(id ? { kind: 'asset', assetId: id } : null);
|
||||
}
|
||||
|
||||
/** The row lighting up after a ping and how brightly, or null (test hook). */
|
||||
pingState(): { id: string; alpha: number } | null {
|
||||
return this.list.pingState();
|
||||
@@ -224,14 +238,19 @@ export class AssetPanel extends Panel {
|
||||
faint: assets.length === 0,
|
||||
});
|
||||
if (!expanded) continue;
|
||||
for (const asset of assets)
|
||||
for (const asset of assets) {
|
||||
const clips = hasClips(asset) ? clipsOf<{ id: string; name: string }>(asset) : [];
|
||||
const clipsShown = this.unfoldedClips.has(asset.id);
|
||||
items.push({
|
||||
id: asset.id,
|
||||
label: asset.name,
|
||||
marks: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined,
|
||||
depth: 2,
|
||||
overridden: assetDeviates(project, asset),
|
||||
expanded: clips.length > 0 ? clipsShown : undefined,
|
||||
});
|
||||
if (clipsShown) for (const clip of clips) items.push({ id: clip.id, label: clip.name, depth: 3 });
|
||||
}
|
||||
}
|
||||
}
|
||||
this.rows = items.map((item) => item.id);
|
||||
@@ -255,6 +274,13 @@ export class AssetPanel extends Panel {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** Unlike a group, folding keeps a picked clip: it may be the one being worked on in its tab. */
|
||||
private setClipsFolded(assetId: string, fold: boolean): void {
|
||||
if (fold) this.unfoldedClips.delete(assetId);
|
||||
else this.unfoldedClips.add(assetId);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private setCategoryFolded(category: AssetCategory, fold: boolean): void {
|
||||
if (fold) {
|
||||
// Like a group: folding away the selected asset lets go of it.
|
||||
@@ -273,7 +299,7 @@ export class AssetPanel extends Panel {
|
||||
|
||||
private syncSelection(): void {
|
||||
const s = this.state.selection;
|
||||
const id = s?.kind === 'asset' ? s.assetId : null;
|
||||
const id = s?.kind === 'asset' ? s.assetId : s?.kind === 'clip' ? s.clipId : null;
|
||||
// A selection made anywhere else – a graph node, a picker, a fresh asset – has to be reachable
|
||||
// in the list: its category and group unfold, and with many assets the row is often below the fold.
|
||||
const type = id ? findAsset(this.state.project, id)?.type : undefined;
|
||||
@@ -281,6 +307,12 @@ export class AssetPanel extends Panel {
|
||||
this.setFolded(type, false);
|
||||
return;
|
||||
}
|
||||
// Only a clip picked here unfolds its sound or video: one picked in its tab leaves the list alone.
|
||||
if (s?.kind === 'clip' && !this.rows.includes(s.clipId)) {
|
||||
this.list.select(null, true);
|
||||
this.scrolledTo = null;
|
||||
return;
|
||||
}
|
||||
this.list.select(id, true);
|
||||
if (id && id !== this.scrolledTo) this.scrollTo(id);
|
||||
this.scrolledTo = id;
|
||||
@@ -420,9 +452,12 @@ export class AssetPanel extends Panel {
|
||||
openVideoPicker({ state: this.state, videos: this.videos });
|
||||
}
|
||||
|
||||
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
|
||||
/**
|
||||
* Double-click / Enter opens the asset's tab; a scene also becomes the active one, a clip (#425) is
|
||||
* picked in its tab. Placing is a drag.
|
||||
*/
|
||||
private activate(id: string): void {
|
||||
activateAsset(this.state, id);
|
||||
activateAssetOrClip(this.state, id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -434,11 +469,21 @@ export class AssetPanel extends Panel {
|
||||
return this.list.rowGlobal(assetId);
|
||||
}
|
||||
|
||||
/** Unfolds the asset's group and scrolls to its row – before a ping, and as a user would before clicking it (test hooks). */
|
||||
private reveal(assetId: string): void {
|
||||
const type = findAsset(this.state.project, assetId)?.type;
|
||||
/**
|
||||
* Unfolds the asset's group – and for a clip (#425) its sound or video – and scrolls to its row:
|
||||
* before a ping, and as a user would before clicking it (test hooks).
|
||||
*/
|
||||
private reveal(id: string): void {
|
||||
const owner = clipOwner(this.state.project, id);
|
||||
const type = findAsset(this.state.project, owner?.id ?? id)?.type;
|
||||
if (type && !this.shows(type)) this.setFolded(type, false);
|
||||
this.scrollTo(assetId);
|
||||
if (owner && !this.unfoldedClips.has(owner.id)) this.setClipsFolded(owner.id, false);
|
||||
this.scrollTo(id);
|
||||
}
|
||||
|
||||
/** Centre of a shown row's chevron (test hook). */
|
||||
chevronGlobal(id: string): { x: number; y: number } | null {
|
||||
return this.list.chevronGlobal(id);
|
||||
}
|
||||
|
||||
/** Centre of a group heading's icon (test hook). */
|
||||
@@ -455,9 +500,16 @@ 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 picture or video as an item (#419)
|
||||
* and a sound, pattern or song as an audio item (#418). A scene has nowhere to go.
|
||||
* and a sound, pattern or song as an audio item (#418). A scene has nowhere to go. A clip of a sound
|
||||
* or a video goes where its asset would, to a song or a composition (#425).
|
||||
*/
|
||||
private startPlacementDrag(assetId: string): void {
|
||||
const owner = clipOwner(this.state.project, assetId);
|
||||
if (owner) {
|
||||
const clip = clipsOf<{ id: string; name: string }>(owner).find((c) => c.id === assetId)!;
|
||||
startDrag({ kind: 'asset', id: assetId }, clipRefName(owner.name, clip.name));
|
||||
return;
|
||||
}
|
||||
const asset = findAsset(this.state.project, assetId);
|
||||
const laid =
|
||||
asset?.type === 'pattern' ||
|
||||
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
import { ticksOfSeconds, ticksPerBar } from '../../core/music/time';
|
||||
import type { Asset, SongAsset } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset, findAssetOf } from '../../core/model/Project';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { findSound } from '../../core/model/sound';
|
||||
import {
|
||||
activeDrag,
|
||||
crispText,
|
||||
@@ -1065,9 +1066,13 @@ export class SongTimeline extends Widget {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Follows an asset dragged in from the panel: a pattern or a sound lands on the lane under the pointer. */
|
||||
/**
|
||||
* Follows an asset dragged in from the panel: a pattern, a sound or a clip of one (#425) lands on the
|
||||
* lane under the pointer.
|
||||
*/
|
||||
private trackDrop(asset: SongAsset, assetId: string, local: Vec2, g: Vec2): void {
|
||||
const dragged = findAsset(this.state.project, assetId);
|
||||
const pattern = findAssetOf(this.state.project, assetId, 'pattern');
|
||||
const sound = pattern ? null : findSound(this.state.project, assetId);
|
||||
// Left of tick 0 is there to look at: nothing lands there.
|
||||
const inside =
|
||||
local.x >= HEAD_WIDTH &&
|
||||
@@ -1076,7 +1081,7 @@ export class SongTimeline extends Widget {
|
||||
local.y <= this.h &&
|
||||
xToTick(this.view, g.x) >= 0;
|
||||
const row = trackAtY(this.view, asset.tracks.length, g.y);
|
||||
if (!inside || row === null || !dragged || (dragged.type !== 'pattern' && dragged.type !== 'sound')) {
|
||||
if (!inside || row === null || !(pattern || sound)) {
|
||||
if (this.drop) {
|
||||
this.drop = null;
|
||||
this.invalidate();
|
||||
@@ -1087,14 +1092,14 @@ export class SongTimeline extends Widget {
|
||||
const tick = Math.max(0, snapDown(laneTick(this.view, g.x), grid));
|
||||
const bar = ticksPerBar(songMeter(asset.props));
|
||||
let length: number;
|
||||
if (dragged.type === 'pattern')
|
||||
length = droppedLength(null, patternLength(dragged), songTempo(asset.props), bar);
|
||||
if (pattern) length = droppedLength(null, patternLength(pattern), songTempo(asset.props), bar);
|
||||
else {
|
||||
const played = playedSoundOf({ asset: dragged, clip: null });
|
||||
const played = playedSoundOf(sound!);
|
||||
this.ensureLoaded(played);
|
||||
length = droppedLength(soundLibrary.duration(played), null, songTempo(asset.props), bar);
|
||||
}
|
||||
this.drop = { kind: dragged.type, asset: dragged.id, track: asset.tracks[row]!.id, tick, length };
|
||||
const kind = pattern ? 'pattern' : 'sound';
|
||||
this.drop = { kind, asset: assetId, track: asset.tracks[row]!.id, tick, length };
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
|
||||
+18
-3
@@ -294,8 +294,10 @@ export class EditorState {
|
||||
* working rather than something the project remembers.
|
||||
*/
|
||||
tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '', focus: 'all' };
|
||||
/** The asset the last `pingAsset` pointed at. */
|
||||
/** The asset or clip (#425) the last `pingAsset` pointed at. */
|
||||
pingedAssetId: string | null = null;
|
||||
/** Set while `glance` selects: the selection stays where it is shown. */
|
||||
glancing = false;
|
||||
/** Objects the scene view is dragging: a running game holds their bodies to the pointer. */
|
||||
heldObjectIds: ReadonlySet<string> = new Set();
|
||||
/** The eyedropper of an object's place while it runs (#276). */
|
||||
@@ -570,14 +572,27 @@ export class EditorState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows where an asset is listed without selecting it – what one click on a link in the inspector
|
||||
* does (#221), so the inspector stays on the object the link belongs to.
|
||||
* Shows where an asset or a clip of one (#425) is listed without selecting it – what one click on a
|
||||
* link in the inspector does (#221), so the inspector stays on the object the link belongs to.
|
||||
*/
|
||||
pingAsset(assetId: string): void {
|
||||
this.pingedAssetId = assetId;
|
||||
this.emit('ping');
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects without bringing up the tab a clip lives in: a click on a clip's row in the asset panel
|
||||
* is a look at the inspector, as one on an asset's is (#425).
|
||||
*/
|
||||
glance(selection: Selection): void {
|
||||
this.glancing = true;
|
||||
try {
|
||||
this.select(selection);
|
||||
} finally {
|
||||
this.glancing = false;
|
||||
}
|
||||
}
|
||||
|
||||
select(selection: Selection): void {
|
||||
// Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
|
||||
@@ -472,8 +472,8 @@ describe('items and their objects', () => {
|
||||
const cut = makeCut();
|
||||
const screen = new CreateAssetCommand('sprite', 'Screen', { texture: cut.videoId });
|
||||
cut.stack.execute(screen);
|
||||
expect(droppedLength(cut.project, cut.comp(), screen.asset)).toBe(300);
|
||||
expect(droppedLength(cut.project, cut.comp(), findAsset(cut.project, cut.boxId)!)).toBe(180);
|
||||
expect(droppedLength(cut.project, cut.comp(), screen.asset.id)).toBe(300);
|
||||
expect(droppedLength(cut.project, cut.comp(), cut.boxId)).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,19 +11,22 @@ import {
|
||||
DuplicateAssetCommand,
|
||||
MakeProjectAssetCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { AddClipCommand } from '../../src/core/commands/clipCommands';
|
||||
import type { Command } from '../../src/core/commands/Command';
|
||||
import { pasteCompositionItems, removeFromComposition } from '../../src/core/commands/compositionCommands';
|
||||
import {
|
||||
addPictureItem,
|
||||
droppedPicture,
|
||||
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 { isCompositionSprite, itemVideo } 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 { VIDEO_END_KEY, VIDEO_START_KEY } from '../../src/core/properties/videos';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { makeProject } from '../helpers';
|
||||
|
||||
@@ -45,8 +48,8 @@ function makeFilm() {
|
||||
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', {
|
||||
const drop = (start: number, compId = film.asset.id, dropped = video.asset.id) => {
|
||||
const { command, objectId } = addPictureItem(f.project, compId, droppedPicture(f.project, dropped)!, {
|
||||
track: comp(compId).tracks[0]!.id,
|
||||
start,
|
||||
length: 150,
|
||||
@@ -92,7 +95,7 @@ describe('a picture laid on a track', () => {
|
||||
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);
|
||||
expect(droppedLength(film.project, film.comp(), video.id)).toBe(300);
|
||||
});
|
||||
|
||||
it('wears a sprite that "Make project asset" lists like any other, and it stays when the item goes', () => {
|
||||
@@ -133,6 +136,55 @@ describe('a picture laid on a track', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a clip of a video laid on a track (#425)', () => {
|
||||
const withClip = () => {
|
||||
const film = makeFilm();
|
||||
const add = new AddClipCommand(film.videoId, { [VIDEO_START_KEY]: 1, [VIDEO_END_KEY]: 3 });
|
||||
film.run(add);
|
||||
return { ...film, clipId: add.clipId };
|
||||
};
|
||||
const objectOf = (film: ReturnType<typeof withClip>, id: string) =>
|
||||
film.comp().objects.find((o) => o.id === id)!;
|
||||
|
||||
it('wears the sprite of its video with the clip as the texture, as long as the clip', () => {
|
||||
const film = withClip();
|
||||
expect(droppedPicture(film.project, film.clipId)).toEqual({
|
||||
picture: film.videoId,
|
||||
clip: film.clipId,
|
||||
name: 'Intro',
|
||||
});
|
||||
expect(droppedLength(film.project, film.comp(), film.clipId)).toBe(120);
|
||||
const id = film.drop(0, film.filmId, film.clipId);
|
||||
const [sprite] = film.sprites();
|
||||
expect(sprite!.props).toMatchObject({ texture: film.videoId, width: 320, height: 180 });
|
||||
expect(objectOf(film, id)).toMatchObject({ assetId: sprite!.id, overrides: { texture: film.clipId } });
|
||||
const item = film.comp().items.find((i) => i.id === id)!;
|
||||
expect(itemVideo(film.project, film.comp(), item)?.clip?.id).toBe(film.clipId);
|
||||
});
|
||||
|
||||
it('shares the sprite with the whole video, and one undo takes a first item with its sprite', () => {
|
||||
const film = withClip();
|
||||
const whole = film.drop(0);
|
||||
const cut = film.drop(200, film.filmId, film.clipId);
|
||||
expect(film.sprites()).toHaveLength(1);
|
||||
expect(film.wornBy(film.comp(), cut)).toBe(film.wornBy(film.comp(), whole));
|
||||
expect(objectOf(film, whole).overrides).toEqual({});
|
||||
film.stack.undo();
|
||||
expect(film.comp().objects.map((o) => o.id)).toEqual([whole]);
|
||||
film.stack.undo();
|
||||
film.drop(0, film.filmId, film.clipId);
|
||||
film.stack.undo();
|
||||
expect(film.sprites()).toHaveLength(0);
|
||||
expect(film.comp().objects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is no picture for a sound or a scene', () => {
|
||||
const film = withClip();
|
||||
expect(droppedPicture(film.project, film.sceneId)).toBeNull();
|
||||
expect(droppedPicture(film.project, 'nothing')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a copied item', () => {
|
||||
it('pasted after a cut wears its sprite again', () => {
|
||||
const film = makeFilm();
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/** Which asset a reference field points at – what "Open texture" and the inspector link open (#221). */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
CreateSoundCommand,
|
||||
CreateTextureCommand,
|
||||
CreateVideoCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { AddClipCommand } from '../../src/core/commands/clipCommands';
|
||||
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
|
||||
import type { PixelArtAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { type PropertyDef, schemaForAsset } from '../../src/core/properties/PropertySchema';
|
||||
import { referencedAssetId, referencesOf } from '../../src/core/properties/references';
|
||||
import { referencedAssetId, referencedTarget, referencesOf } from '../../src/core/properties/references';
|
||||
import { resolveAssetProps } from '../../src/core/properties/resolve';
|
||||
import { SHADER_KEY } from '../../src/core/properties/shaders';
|
||||
import { makeSpriteScene } from '../helpers';
|
||||
@@ -20,6 +26,33 @@ const SHADER: PropertyDef = {
|
||||
assetTypes: ['shader'],
|
||||
};
|
||||
|
||||
const SOUND: PropertyDef = { key: 'sound', label: 'Sound', kind: 'sound', default: '' };
|
||||
|
||||
function withClips() {
|
||||
const f = setup();
|
||||
const video = new CreateVideoCommand('Film', `${'cd'.repeat(32)}.mp4`, {
|
||||
width: 320,
|
||||
height: 180,
|
||||
duration: 4,
|
||||
fps: 30,
|
||||
audio: false,
|
||||
});
|
||||
const sound = new CreateSoundCommand('Kick', `${'ef'.repeat(32)}.ogg`);
|
||||
f.stack.execute(video);
|
||||
f.stack.execute(sound);
|
||||
const videoClip = new AddClipCommand(video.asset.id, { start: 1, end: 2 });
|
||||
const soundClip = new AddClipCommand(sound.asset.id, { start: 0.1, end: 0.2 });
|
||||
f.stack.execute(videoClip);
|
||||
f.stack.execute(soundClip);
|
||||
return {
|
||||
...f,
|
||||
videoId: video.asset.id,
|
||||
soundId: sound.asset.id,
|
||||
videoClip: videoClip.clipId,
|
||||
soundClip: soundClip.clipId,
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const fixture = makeSpriteScene();
|
||||
const texture = new CreateTextureCommand('Sheet', { file: FILE, width: 64, height: 32 });
|
||||
@@ -69,6 +102,23 @@ describe('referencedAssetId', () => {
|
||||
const text: PropertyDef = { key: 'name', label: 'Name', kind: 'text', default: '' };
|
||||
expect(referencedAssetId(f.project, text, f.textureId)).toBeNull();
|
||||
});
|
||||
|
||||
it('is the video for a clip of one in a texture field (#425)', () => {
|
||||
const f = withClips();
|
||||
expect(referencedAssetId(f.project, TEXTURE, f.videoClip)).toBe(f.videoId);
|
||||
expect(referencedAssetId(f.project, SOUND, f.soundClip)).toBe(f.soundId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('referencedTarget (#425)', () => {
|
||||
it('is the clip itself for a clip of a video or a sound, the asset for anything else', () => {
|
||||
const f = withClips();
|
||||
expect(referencedTarget(f.project, TEXTURE, f.videoClip)).toBe(f.videoClip);
|
||||
expect(referencedTarget(f.project, SOUND, f.soundClip)).toBe(f.soundClip);
|
||||
expect(referencedTarget(f.project, TEXTURE, f.videoId)).toBe(f.videoId);
|
||||
expect(referencedTarget(f.project, TEXTURE, f.frameId)).toBe(f.textureId);
|
||||
expect(referencedTarget(f.project, SHADER, f.videoClip)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('referencesOf', () => {
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* dragged in is an audio item, heard while the playhead runs; the handle of its fade in drags out a
|
||||
* fade its bus rises along. A video item's sprite reads as driven by the composition and goes into
|
||||
* its bus, which has a node in the master routing; after a hitch the sound is started again where
|
||||
* the playhead is.
|
||||
* the playhead is. Clips of a sound and a video have rows in the Assets panel, are dragged onto a
|
||||
* track like an asset, and the Texture label links the clip.
|
||||
*
|
||||
* Everything it adds carries a `ct69` id and goes again at the end.
|
||||
*/
|
||||
@@ -702,6 +703,133 @@ export async function compositionTab(t) {
|
||||
JSON.stringify({ hitchBefore, hitchAfter }),
|
||||
);
|
||||
|
||||
// 69s (#425). The clips of a sound and of a video have rows of their own under it in the Assets
|
||||
// panel, folded until its chevron opens them. Each dragged onto a track is an item as long as the
|
||||
// clip: the sound's an audio item playing it, the video's wearing the sprite of its video with the
|
||||
// clip as texture, its picture from the clip's start. Its Texture label links the clip: a click
|
||||
// pings its row, a double-click opens the video with the clip picked.
|
||||
// The drone of the seed lasts 0.75 s, so its clip of 0.5 s is told apart from it.
|
||||
const drone = sounds.find((item) => item.bytes === 12044);
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets.find((a) => a.id === '${VIDEO}').clips = [{ id: 'ct69-vclip', name: 'Middle', props: { start: 0.5, end: 1.5 } }];
|
||||
p.assets.push({ id: 'ct69-drone', type: 'sound', name: 'Drone 69', props: {}, file: ${JSON.stringify(drone?.file ?? '')},
|
||||
clips: [{ id: 'ct69-sclip', name: 'Bit', props: { start: 0.2, end: 0.7 } }] });
|
||||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||||
c.objects = [];
|
||||
c.items = [];
|
||||
c.clips = [];`);
|
||||
await evaluate('__gm.composition.scrub(0)');
|
||||
await json(`__gm.assetRow('${VIDEO}')`);
|
||||
const folded = await json(`__gm.assetRowShown('ct69-vclip')`);
|
||||
const chevron = await json(`__gm.assetChevron('${VIDEO}')`);
|
||||
if (chevron) await t.click(chevron.x, chevron.y);
|
||||
const unfolded = await json(`__gm.assetRowShown('ct69-vclip')`);
|
||||
/** Like `dragAsset`, but held over the lane until the drop is as long as `frames`: a cut decodes first. */
|
||||
const dragClip = async (id, to, frames) => {
|
||||
await sleep(450);
|
||||
const from = await json(`__gm.assetRow(${JSON.stringify(id)})`);
|
||||
// No row, nothing to drag: the check says so rather than the module throwing.
|
||||
if (!from) return null;
|
||||
await mouse('mouseMoved', from.x, from.y);
|
||||
await mouse('mousePressed', from.x, from.y);
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
await mouse(
|
||||
'mouseMoved',
|
||||
from.x + ((to.x - from.x) * i) / 8,
|
||||
from.y + ((to.y - from.y) * i) / 8,
|
||||
'left',
|
||||
{
|
||||
buttons: 1,
|
||||
},
|
||||
);
|
||||
await sleep(16);
|
||||
}
|
||||
let drop = await json('__gm.composition.drop()');
|
||||
for (let i = 0; i < 20 && drop?.length !== frames; i++) {
|
||||
await sleep(50);
|
||||
await mouse('mouseMoved', to.x + (i % 2), to.y, 'left', { buttons: 1 });
|
||||
drop = await json('__gm.composition.drop()');
|
||||
}
|
||||
await mouse('mouseReleased', to.x, to.y);
|
||||
await sleep(200);
|
||||
return drop;
|
||||
};
|
||||
const videoClipDrop = await dragClip(
|
||||
'ct69-vclip',
|
||||
await json(`__gm.composition.laneAt('ct69-t1', 40)`),
|
||||
10,
|
||||
);
|
||||
const soundClipDrop = await dragClip('ct69-sclip', await json(`__gm.composition.laneAt('ct69-t2', 10)`), 5);
|
||||
const placed = await json(`(() => {
|
||||
const p = __gm.state.project;
|
||||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||||
const pictureItem = c.items.find((i) => !i.audio);
|
||||
const object = c.objects.find((o) => o.id === pictureItem?.id);
|
||||
const sprite = object && p.assets.find((a) => a.id === object.assetId);
|
||||
return { pictureItem, audioItem: c.items.find((i) => i.audio) ?? null, overrides: object?.overrides ?? null,
|
||||
sprite: sprite ? { owner: sprite.owner, texture: sprite.props.texture } : null };
|
||||
})()`);
|
||||
const clipPlayer = `__gm.videos().find((v) => v.key.includes('#0.5~1.5~') && v.key.endsWith(':${placed.pictureItem?.id}')) ?? null`;
|
||||
await evaluate('__gm.composition.scrub(43)');
|
||||
let middle = null;
|
||||
for (let i = 0; i < 40 && !(middle?.ready && !middle.seeking && !middle.landing); i++) {
|
||||
await sleep(150);
|
||||
middle = await json(clipPlayer);
|
||||
}
|
||||
const middleDrawn = await json(`__gm.composition.drawn('${placed.pictureItem?.id}')`);
|
||||
check(
|
||||
'clips have folded rows of their own, and a clip of a video and of a sound lands as long as the clip',
|
||||
folded === null &&
|
||||
chevron !== null &&
|
||||
unfolded !== null &&
|
||||
videoClipDrop?.what === 'item' &&
|
||||
videoClipDrop.rejection === '' &&
|
||||
placed.pictureItem?.start === 40 &&
|
||||
placed.pictureItem.length === 10 &&
|
||||
placed.pictureItem.fitMedia === true &&
|
||||
placed.overrides?.texture === 'ct69-vclip' &&
|
||||
placed.sprite?.owner === CUT &&
|
||||
placed.sprite.texture === VIDEO &&
|
||||
drone !== undefined &&
|
||||
soundClipDrop?.what === 'item' &&
|
||||
placed.audioItem?.audio?.asset === 'ct69-sclip' &&
|
||||
placed.audioItem.start === 10 &&
|
||||
placed.audioItem.length === 5 &&
|
||||
Math.abs((middle?.time ?? 0) - 0.8) < 0.05 &&
|
||||
middleDrawn !== null,
|
||||
JSON.stringify({ folded, chevron, unfolded, videoClipDrop, soundClipDrop, placed, middle, middleDrawn }),
|
||||
);
|
||||
|
||||
await evaluate(
|
||||
`__gm.state.select({ kind: 'object', holderId: '${CUT}', objectId: '${placed.pictureItem?.id}' })`,
|
||||
);
|
||||
await sleep(200);
|
||||
// Folded again, so the ping has to unfold the clips itself.
|
||||
const again = await json(`__gm.assetChevron('${VIDEO}')`);
|
||||
if (again) await t.click(again.x, again.y);
|
||||
const refolded = await json(`__gm.assetRowShown('ct69-vclip')`);
|
||||
const textureLink = await json(`__gm.inspectorLink('Texture')`);
|
||||
if (textureLink) await t.click(textureLink.x, textureLink.y);
|
||||
const clipPing = { ping: await json('__gm.assetPing()'), selection: await json('__gm.state.selection') };
|
||||
await sleep(1200);
|
||||
const relink = await json(`__gm.inspectorLink('Texture')`);
|
||||
if (relink) await t.doubleClick(relink.x, relink.y);
|
||||
await sleep(200);
|
||||
const clipOpened = { selection: await json('__gm.state.selection'), docs: await json('__gm.documents()') };
|
||||
check(
|
||||
'the Texture label links the clip: a click pings its row, a double-click opens the video with the clip picked',
|
||||
refolded === null &&
|
||||
textureLink?.underlined === true &&
|
||||
textureLink.pressable === true &&
|
||||
clipPing.ping?.id === 'ct69-vclip' &&
|
||||
clipPing.selection?.kind === 'object' &&
|
||||
clipOpened.selection?.kind === 'clip' &&
|
||||
clipOpened.selection.clipId === 'ct69-vclip' &&
|
||||
clipOpened.docs.active === `asset:${VIDEO}`,
|
||||
JSON.stringify({ refolded, textureLink, clipPing, clipOpened }),
|
||||
);
|
||||
|
||||
await showTab('scene');
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
|
||||
Reference in new issue
Block a user