From 406839baeaad6fd9590d31f5eb6f025657fbc7d7 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 15 Oct 2026 08:01:04 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/commands/compositionSprites.ts | 44 +++++- src/core/model/compositionEdit.ts | 22 ++- src/core/model/sound.ts | 15 +- src/core/properties/references.ts | 22 ++- src/editor/Editor.ts | 1 + src/editor/actions.ts | 8 ++ src/editor/composition/CompositionTimeline.ts | 29 ++-- src/editor/debug/uiHooks.ts | 2 + src/editor/inspector/Inspector.ts | 16 +-- src/editor/inspector/compositionSection.ts | 2 +- src/editor/inspector/fieldFactory.ts | 11 +- src/editor/inspector/objectSection.ts | 15 +- src/editor/panels/AssetPanel.ts | 84 ++++++++--- src/editor/song/SongTimeline.ts | 21 +-- src/editor/state.ts | 21 ++- tests/core/composition-edit.test.ts | 4 +- tests/core/composition-sprites.test.ts | 60 +++++++- tests/core/references.test.ts | 54 +++++++- tools/e2e/69-composition-tab.mjs | 130 +++++++++++++++++- 20 files changed, 469 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 51e38655..981018cf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/compositionSprites.ts b/src/core/commands/compositionSprites.ts index 699f5c9d..fd7dc714 100644 --- a/src/core/commands/compositionSprites.ts +++ b/src/core/commands/compositionSprites.ts @@ -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, ): { 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 { diff --git a/src/core/model/compositionEdit.ts b/src/core/model/compositionEdit.ts index 68944d35..6f187ecd 100644 --- a/src/core/model/compositionEdit.ts +++ b/src/core/model/compositionEdit.ts @@ -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; diff --git a/src/core/model/sound.ts b/src/core/model/sound.ts index 4ae98835..1cc2c625 100644 --- a/src/core/model/sound.ts +++ b/src/core/model/sound.ts @@ -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): 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. */ diff --git a/src/core/properties/references.ts b/src/core/properties/references.ts index 8073630b..0f54cbaa 100644 --- a/src/core/properties/references.ts +++ b/src/core/properties/references.ts @@ -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 { diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 9449e3f3..e4553761 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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 ( diff --git a/src/editor/actions.ts b/src/editor/actions.ts index e55fd9f6..e0478d00 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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); diff --git a/src/editor/composition/CompositionTimeline.ts b/src/editor/composition/CompositionTimeline.ts index 0922fe84..814e4a5c 100644 --- a/src/editor/composition/CompositionTimeline.ts +++ b/src/editor/composition/CompositionTimeline.ts @@ -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])); } diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 2d77d5f6..0564dc5d 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -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), diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index a6512a0c..502a83bb 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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, }); } diff --git a/src/editor/inspector/compositionSection.ts b/src/editor/inspector/compositionSection.ts index 8547f26f..5c1854f1 100644 --- a/src/editor/inspector/compositionSection.ts +++ b/src/editor/inspector/compositionSection.ts @@ -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) => { diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index b3abc3c6..24dc1601 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -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); diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index f74f308b..3d99c2f6 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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: () => {} }); diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 9115dd79..0875f4f2 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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(); /** Categories folded away, for the session; every category starts unfolded, showing its groups. */ private readonly foldedCategories = new Set(); + /** Sounds and videos whose clips are shown under them (#425), for the session; all start folded. */ + private readonly unfoldedClips = new Set(); /** 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' || diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index 71941a23..6bbd8c8c 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -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(); } diff --git a/src/editor/state.ts b/src/editor/state.ts index 8c3214cb..d54e89a2 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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 = 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 diff --git a/tests/core/composition-edit.test.ts b/tests/core/composition-edit.test.ts index 51d4c789..465854d8 100644 --- a/tests/core/composition-edit.test.ts +++ b/tests/core/composition-edit.test.ts @@ -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); }); }); diff --git a/tests/core/composition-sprites.test.ts b/tests/core/composition-sprites.test.ts index 27811b65..31b5a24d 100644 --- a/tests/core/composition-sprites.test.ts +++ b/tests/core/composition-sprites.test.ts @@ -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, 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(); diff --git a/tests/core/references.test.ts b/tests/core/references.test.ts index 81e65f0e..5eafe8c4 100644 --- a/tests/core/references.test.ts +++ b/tests/core/references.test.ts @@ -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', () => { diff --git a/tools/e2e/69-composition-tab.mjs b/tools/e2e/69-composition-tab.mjs index fd847cf9..0d1067cf 100644 --- a/tools/e2e/69-composition-tab.mjs +++ b/tools/e2e/69-composition-tab.mjs @@ -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;