diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 50d4e233..b3058fa9 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -4,6 +4,7 @@ import { starterInstrumentGraph } from '../instrument/nodes'; import type { Asset, FontAsset, + SongAsset, SoundAsset, StateMachineAsset, TextureAsset, @@ -11,6 +12,7 @@ import type { VariablesAsset, } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; +import { embeddedIn } from '../music/embedded'; import { defaultTracks } from '../music/song'; import { heldSongGraph } from '../music/routing'; import { linearCurve } from '../model/curve'; @@ -32,8 +34,9 @@ import { import type { SceneObject } from '../model/SceneObject'; import { isTextureFile } from '../model/texture'; import { cellsWithout } from '../model/tilemap'; +import { ASSET_FX_KEY } from '../properties/assetFx'; import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema'; -import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; +import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list'; import { Recorder } from '../util/recorder'; import { type Command, CommandError } from './Command'; import { nextAssetName } from './naming'; @@ -160,22 +163,45 @@ export class DuplicateAssetCommand implements Command { readonly label = 'Duplicate asset'; readonly copyId = newId(); private copy?: Asset; + /** What a song keeps to itself, copied for the copy (#355). */ + private owned: Asset[] = []; constructor(private readonly assetId: string) {} execute(project: Project): void { const original = requireAsset(project, this.assetId); - this.copy ??= copyOf(original, this.copyId, nextAssetName(project, original.name)); + if (!this.copy) { + this.copy = copyOf(original, this.copyId, nextAssetName(project, original.name)); + if (this.copy.type === 'song') this.owned = copyEmbedded(project, original.id, this.copy); + } project.assets.splice(project.assets.indexOf(original) + 1, 0, this.copy); + project.assets.push(...this.owned); } undo(project: Project): void { - project.assets = removeById(project.assets, this.copyId); + const gone = new Set([this.copyId, ...this.owned.map((a) => a.id)]); + project.assets = project.assets.filter((a) => !gone.has(a.id)); } } +/** Copies of the assets `from` keeps to itself, owned by `song`, whose places are pointed at them. */ +function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[] { + const ids = new Map(embeddedIn(project, from).map((a) => [a.id, newId()])); + const renamed = (id: string) => ids.get(id) ?? id; + for (const track of song.tracks) track.fx = renamed(track.fx); + for (const item of song.items) { + item.asset = renamed(item.asset); + item.fx = renamed(item.fx); + } + const fx = song.props[ASSET_FX_KEY]; + if (typeof fx === 'string') song.props[ASSET_FX_KEY] = renamed(fx); + return embeddedIn(project, from).map((a) => ({ ...structuredClone(a), id: renamed(a.id), owner: song.id })); +} + +/** A copy is a project asset of its own, even one of something a song kept to itself. */ function copyOf(original: Asset, id: string, name: string): Asset { const copy = { ...structuredClone(original), id, name }; + delete copy.owner; if (copy.type === 'texture' || copy.type === 'pixelart') for (const frame of copy.frames) frame.id = newId(); if (copy.type === 'scene' || copy.type === 'container') { @@ -259,13 +285,42 @@ export class CreateFontCommand extends ImportFileCommand { } } -function buildSoundAsset(name: string, file: string): SoundAsset { - return { id: newId(), name, props: defaultsFor('sound'), type: 'sound', file }; +function buildSoundAsset(name: string, file: string, owner: string): SoundAsset { + const sound: SoundAsset = { id: newId(), name, props: defaultsFor('sound'), type: 'sound', file }; + if (owner) sound.owner = owner; + return sound; } +/** `owner` makes it a sound the song of that id keeps to itself (#355). */ export class CreateSoundCommand extends ImportFileCommand { - constructor(name: string, file: string) { - super(buildSoundAsset(name, file), 'sound', isSoundFile); + constructor(name: string, file: string, owner = '') { + super(buildSoundAsset(name, file, owner), 'sound', isSoundFile); + } +} + +/** + * Makes an asset a song kept to itself a project asset (#355): it shows in the lists, and a name a + * listed asset has already gets a number. + */ +export class MakeProjectAssetCommand implements Command { + readonly label = 'Make project asset'; + private previous: { owner: string; name: string } | null = null; + + constructor(private readonly assetId: string) {} + + execute(project: Project): void { + const asset = requireAsset(project, this.assetId); + if (!asset.owner) throw new CommandError(`"${asset.name}" is a project asset already`); + this.previous = { owner: asset.owner, name: asset.name }; + delete asset.owner; + asset.name = nextAssetName(project, asset.name, asset.id); + } + + undo(project: Project): void { + if (!this.previous) return; + const asset = requireAsset(project, this.assetId); + asset.owner = this.previous.owner; + asset.name = this.previous.name; } } @@ -278,6 +333,8 @@ export class CreateSoundCommand extends ImportFileCommand { export class DeleteAssetCommand implements Command { readonly label = 'Delete asset'; private removed?: Removed; + /** What a song kept to itself (#355) goes with it. */ + private owned: Removed[] = []; private removedObjects: { holderId: string; removed: Removed[] }[] = []; private emptied: { layer: TilemapLayer; cells: Record }[] = []; private readonly references = new Recorder(); @@ -308,6 +365,7 @@ export class DeleteAssetCommand implements Command { this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) }); } this.removed = removeFirst(project.assets, (a) => a.id === this.assetId); + this.owned = removeWhere(project.assets, (a) => a.owner === this.assetId); dropReferencesToGone(project, before, this.references); this.previousActiveScene = project.activeSceneId; if (project.activeSceneId === this.assetId) { @@ -319,6 +377,7 @@ export class DeleteAssetCommand implements Command { undo(project: Project): void { if (!this.removed) return; this.references.revert(); + restoreAt(project.assets, this.owned); restoreAt(project.assets, [this.removed]); const holders = new Map(objectHolders(project).map((h) => [h.id, h])); for (const entry of this.removedObjects) { diff --git a/src/core/commands/naming.ts b/src/core/commands/naming.ts index 9042e6fb..d3582bf7 100644 --- a/src/core/commands/naming.ts +++ b/src/core/commands/naming.ts @@ -8,10 +8,13 @@ function firstFree(taken: ReadonlySet, candidate: (i: number) => string, } } -/** "Sprite", "Sprite 2", "Sprite 3" … – the first name not yet used by an asset. */ -export function nextAssetName(project: Project, base: string): string { +/** + * "Sprite", "Sprite 2", "Sprite 3" … – the first name not yet used by a listed asset other than + * `self`. What a song keeps to itself (#355) is named within the song. + */ +export function nextAssetName(project: Project, base: string, self = ''): string { return nextFreeName( - project.assets.map((a) => a.name), + project.assets.filter((a) => !a.owner && a.id !== self).map((a) => a.name), base, ); } diff --git a/src/core/commands/songCommands.ts b/src/core/commands/songCommands.ts index 8a2bc32c..1e474e6a 100644 --- a/src/core/commands/songCommands.ts +++ b/src/core/commands/songCommands.ts @@ -5,21 +5,33 @@ * routing follows the tracks (#350). */ import type { EdgeSend, Graph } from '../graph/Graph'; -import type { SongAsset } from '../model/Asset'; +import type { Asset, SongAsset } from '../model/Asset'; import type { Project } from '../model/Project'; +import { settleEmbedded } from '../music/embedded'; import { heldSend, heldSongGraph } from '../music/routing'; import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song'; import { heldLoop, type LoopRange } from '../music/sequence'; +import { type Removed, removeWhere, restoreAt } from '../util/list'; import type { Command } from './Command'; import { requireEdge, requireSong } from './require'; export interface SongParts { tracks?: readonly SongTrack[]; items?: readonly SongItem[]; + /** Embedded assets copied along with items (#355), for places naming one that is gone by now. */ + spare?: readonly Asset[]; } +/** + * Writes the tracks and items whole and settles what the song keeps to itself (#355) in the same + * step: copies for places that share an embedded asset, and away with the ones nothing names. The + * outcome is worked out once, so a redo brings back the very same copies. + */ export class SetSongCommand implements Command { private previous: { tracks: SongTrack[]; items: SongItem[]; graph: Graph } | null = null; + private outcome: { tracks: SongTrack[]; items: SongItem[]; added: Asset[]; dropped: Set } | null = + null; + private removed: Removed[] = []; constructor( private readonly assetId: string, @@ -37,13 +49,25 @@ export class SetSongCommand implements Command { execute(project: Project): void { const asset = requireSong(project, this.assetId); this.previous = { tracks: asset.tracks, items: asset.items, graph: asset.graph }; - asset.tracks = heldSongTracks(this.parts.tracks ?? asset.tracks); - asset.items = heldSongItems(this.parts.items ?? asset.items, asset.tracks); + if (!this.outcome) { + const tracks = heldSongTracks(this.parts.tracks ?? asset.tracks); + const items = heldSongItems(this.parts.items ?? asset.items, tracks); + const { added, dropped } = settleEmbedded(project, asset, tracks, items, this.parts.spare); + this.outcome = { tracks, items, added, dropped: new Set(dropped) }; + } + const { tracks, items, added, dropped } = this.outcome; + asset.tracks = tracks; + asset.items = items; asset.graph = heldSongGraph(asset.graph, asset.tracks); + project.assets.push(...added); + this.removed = removeWhere(project.assets, (a) => dropped.has(a.id)); } undo(project: Project): void { if (!this.previous) return; + restoreAt(project.assets, this.removed); + const added = new Set(this.outcome?.added.map((a) => a.id)); + project.assets = project.assets.filter((a) => !added.has(a.id)); const asset = requireSong(project, this.assetId); asset.tracks = this.previous.tracks; asset.items = this.previous.items; diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index bd6b21f6..e24d4f01 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -13,6 +13,8 @@ export interface AssetBase { type: AssetType; name: string; props: PropertyBag; + /** The song a sound, pattern or sound FX made in it belongs to (#355); absent on a project asset. */ + owner?: string; } export interface SpriteAsset extends AssetBase { diff --git a/src/core/music/embedded.ts b/src/core/music/embedded.ts new file mode 100644 index 00000000..31e6a008 --- /dev/null +++ b/src/core/music/embedded.ts @@ -0,0 +1,128 @@ +/** + * What a song keeps to itself (#355): a sound, a pattern or a sound FX made in a song is an asset like + * any other, so everything that finds an asset by its id finds it, but its `owner` names the song. + * The lists of assets leave it out, and it has exactly one user in its song: the song itself, a + * track or an item. `settleEmbedded` holds a song to that after every edit, so a split, a duplicate + * or a paste gets a copy of its own and what nothing uses any more goes. + */ +import { newId } from '../ids'; +import type { Asset, SongAsset } from '../model/Asset'; +import type { AssetType } from '../model/AssetTypes'; +import { findAsset, type Project } from '../model/Project'; +import { ASSET_FX_KEY, assetFxOf, NO_FX } from '../properties/assetFx'; +import type { SongItem, SongTrack } from './song'; + +export const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx']; + +/** The rows of an embedded asset that do nothing in a song: where it goes and how it repeats is the song's. */ +const HIDDEN_ROWS: Partial> = { + sound: ['loop', 'bus'], +}; + +export function isEmbeddable(type: AssetType): boolean { + return EMBEDDABLE_TYPES.includes(type); +} + +export function isEmbedded(asset: Asset): boolean { + return (asset.owner ?? '') !== ''; +} + +/** The assets a list offers: all but those a song keeps to itself. */ +export function listedAssets(project: Project): Asset[] { + return project.assets.filter((a) => !isEmbedded(a)); +} + +export function embeddedIn(project: Project, songId: string): Asset[] { + return project.assets.filter((a) => a.owner === songId); +} + +/** The song an asset belongs to; undefined for a project asset. */ +export function ownerSong(project: Project, asset: Asset): SongAsset | undefined { + const owner = asset.owner ? findAsset(project, asset.owner) : undefined; + return owner?.type === 'song' ? owner : undefined; +} + +export function isRowHiddenWhenEmbedded(asset: Asset, key: string): boolean { + return isEmbedded(asset) && (HIDDEN_ROWS[asset.type]?.includes(key) ?? false); +} + +/** "Song › Take 1" for an embedded asset, its own name for any other. */ +export function assetTitle(project: Project, asset: Asset): string { + const song = ownerSong(project, asset); + return song ? `${song.name} › ${asset.name}` : asset.name; +} + +/** A copy of a project asset for the song to keep to itself (#355), to bend without touching the rest. */ +export function embeddedCopy(asset: Asset, songId: string): Asset { + return { ...structuredClone(asset), id: newId(), owner: songId }; +} + +/** A place in a song that names an asset, in the order the places claim what they name. */ +interface Slot { + id: string; + types: readonly AssetType[]; + set(id: string): void; +} + +function slotsOf(props: SongAsset['props'], tracks: SongTrack[], items: SongItem[]): Slot[] { + const slots: Slot[] = [ + { id: assetFxOf(props), types: ['soundfx'], set: (id) => (props[ASSET_FX_KEY] = id) }, + ]; + for (const track of tracks) slots.push({ id: track.fx, types: ['soundfx'], set: (id) => (track.fx = id) }); + for (const item of items) { + slots.push({ id: item.asset, types: [item.kind], set: (id) => (item.asset = id) }); + slots.push({ id: item.fx, types: ['soundfx'], set: (id) => (item.fx = id) }); + } + return slots; +} + +/** The embedded assets the places of these items name, wherever they belong: what a copy of them takes along. */ +export function embeddedUsedBy(project: Project, items: readonly SongItem[]): Asset[] { + const ids = new Set(items.flatMap((i) => [i.asset, i.fx])); + return project.assets.filter((a) => isEmbedded(a) && ids.has(a.id)); +} + +export interface Settled { + /** Copies made for a place whose asset another place claimed first, or that belongs elsewhere. */ + added: Asset[]; + /** The song's own assets nothing names any more. */ + dropped: string[]; +} + +/** + * Holds the song to one user per embedded asset, writing the places in `tracks` and `items` (fresh + * lists the caller owns): a place naming an asset of the song that an earlier place claimed, or one of + * another song, gets a copy; one naming an asset that is gone but in `spare` – what was copied along + * with items – gets that back. The song's own props are only read: they claim, never copy. + */ +export function settleEmbedded( + project: Project, + song: SongAsset, + tracks: SongTrack[], + items: SongItem[], + spare: readonly Asset[] = [], +): Settled { + const claimed = new Set(); + const added: Asset[] = []; + const taken = new Set(project.assets.map((a) => a.id)); + const own = { ...song.props }; + slotsOf(own, tracks, items).forEach((slot, index) => { + if (slot.id === '' || slot.id === NO_FX) return; + const found = findAsset(project, slot.id) ?? added.find((a) => a.id === slot.id); + const source = found ?? spare.find((a) => a.id === slot.id); + if (!source || !slot.types.includes(source.type) || (found && !isEmbedded(found))) return; + if (found?.owner === song.id && !claimed.has(found.id)) { + claimed.add(found.id); + return; + } + if (index === 0) return; + const id = found || taken.has(source.id) ? newId() : source.id; + const copy = { ...structuredClone(source), id, owner: song.id }; + taken.add(id); + added.push(copy); + claimed.add(id); + slot.set(id); + }); + const dropped = project.assets.filter((a) => a.owner === song.id && !claimed.has(a.id)).map((a) => a.id); + return { added, dropped }; +} diff --git a/src/core/music/take.ts b/src/core/music/take.ts index eb664ed9..075a324a 100644 --- a/src/core/music/take.ts +++ b/src/core/music/take.ts @@ -95,9 +95,9 @@ export function wavBytes(parts: readonly Int16Array[], sampleRate: number): Uint return bytes; } -/** "Song – Take 1", 2, 3 …: the first number no asset of the project has. */ -export function takeName(songName: string, assetNames: readonly string[]): string { - return nextNumberedName(assetNames, `${songName} – Take`); +/** "Take 1", 2, 3 …: the first number none of what the song keeps to itself has (#355). */ +export function takeName(embeddedNames: readonly string[]): string { + return nextNumberedName(embeddedNames, 'Take'); } /** Where the take's own track goes: right under the track clicked last, else at the bottom. */ diff --git a/src/core/properties/assetRef.ts b/src/core/properties/assetRef.ts index 8000537a..c2602db4 100644 --- a/src/core/properties/assetRef.ts +++ b/src/core/properties/assetRef.ts @@ -1,6 +1,7 @@ import type { Asset } from '../model/Asset'; import { type AssetType, isPlaceableType } from '../model/AssetTypes'; import { findAsset, type Project } from '../model/Project'; +import { listedAssets } from '../music/embedded'; import { NO_FX } from './assetFx'; import { MASTER_BUS } from './buses'; @@ -19,12 +20,13 @@ export function assetRefLabel(project: Project | null, id: string): string { /** * The assets an `asset` property offers, in menu order; without `assetTypes` every placeable one. - * Sorted by name so the order does not depend on creation time. + * Sorted by name so the order does not depend on creation time. What a song keeps to itself (#355) + * is offered nowhere. */ export function assetRefChoices(project: Project, assetTypes?: readonly AssetType[]): Asset[] { const allowed = (asset: Asset): boolean => assetTypes ? assetTypes.includes(asset.type) : isPlaceableType(asset.type); - return project.assets + return listedAssets(project) .filter(allowed) .sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type)); } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 6de6c3b1..041c41c6 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -22,6 +22,7 @@ import { isShaderNodeDef } from '../shader/nodes'; import { isSoundFxNodeDef } from '../soundfx/nodes'; import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes'; import { heldPatternNotes } from '../music/pattern'; +import { isEmbeddable } from '../music/embedded'; import { heldSongItems, heldSongTracks } from '../music/song'; import { heldSend, heldSongGraph } from '../music/routing'; import { isSongRoutingDef } from '../music/routingNodes'; @@ -302,7 +303,12 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { } else { assets.push({ id, type, name, props }); } + const owner = typeof a.owner === 'string' ? a.owner : ''; + if (owner && isEmbeddable(type)) assets[assets.length - 1]!.owner = owner; } + // One whose song is gone is kept as a project asset rather than lost (#355). + const songs = new Set(assets.filter((a) => a.type === 'song').map((a) => a.id)); + for (const asset of assets) if (asset.owner && !songs.has(asset.owner)) delete asset.owner; let activeSceneId = typeof r.activeSceneId === 'string' ? r.activeSceneId : ''; if (!assets.some((a) => a.type === 'scene' && a.id === activeSceneId)) { diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 2209a6bf..c8186d4e 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -1,6 +1,7 @@ import type { Application, Ticker } from 'pixi.js'; import type { Asset } from '../core/model/Asset'; import { findAsset, findObjectHolder } from '../core/model/Project'; +import { assetTitle } from '../core/music/embedded'; import { mixOf } from '../core/music/mixer'; import type { Role } from '../core/store/AuthStore'; import type { ProjectStore } from '../core/store/ProjectStore'; @@ -877,7 +878,8 @@ export class Editor implements PlayHost { if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins'; const target = this.valueTargets.get(doc); if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? ''; - return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? ''; + const asset = findAsset(this.state.project, doc.slice('asset:'.length)); + return asset ? assetTitle(this.state.project, asset) : ''; } /** The icon in front of a document's name: its asset type's, as in the asset panel. */ diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 8eaa0ede..ebdfc871 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -27,6 +27,7 @@ import { isTemplateContent } from '../core/model/instance'; import type { Command } from '../core/commands/Command'; import { findAnnotation, findNode, nodeDef } from '../core/graph'; import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project'; +import { ownerSong } from '../core/music/embedded'; import type { SceneObject } from '../core/model/SceneObject'; import { pixelArtRefIds } from '../core/model/pixelart'; import { textureRefIds } from '../core/model/texture'; @@ -234,6 +235,11 @@ export function removeSelection(state: EditorState): boolean { export function confirmDeleteAsset(state: EditorState, assetId: string): void { const asset = findAsset(state.project, assetId); if (!asset) return; + const song = ownerSong(state.project, asset); + if (song) { + state.setNotice(`"${asset.name}" lives in the song "${song.name}" and goes with what uses it there`); + return; + } const usages = findUsages(state.project, assetId).length; // A texture is never placed but worn through a property, so counting placed instances would // report "unused". Its frames count as the texture itself. diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 982b604f..cd4a60f4 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -41,6 +41,7 @@ import { } from '../../core/commands/structTypeCommands'; import { containsType, type FieldRename, structAssetOf } from '../../core/model/structTypes'; import { findAsset, type Project } from '../../core/model/Project'; +import { isRowHiddenWhenEmbedded } from '../../core/music/embedded'; import type { PropertyBag, PropertyValue } from '../../core/model/Properties'; import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope'; import { type NestedKind, VARIABLE_KINDS } from '../../core/properties/kinds'; @@ -93,7 +94,9 @@ export function assetSignature(project: Project, assetId: string): string { // Vector art shows its shapes under its rows (#129), so a shape added or renamed is a new structure. const shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : ''; const values = resolveAssetProps(project, asset); - const rows = schemaForAsset(project, asset).filter((def) => isRowShown(def, values)); + const rows = schemaForAsset(project, asset).filter( + (def) => isRowShown(def, values) && !isRowHiddenWhenEmbedded(asset, def.key), + ); // A Type row shows the name of its Struct asset, or "Own fields" once that asset is gone (#253). const types = `|types:${typeNames(project, rows)}`; return rows.map(defSignature).join(',') + machine + kept + keeps + shapes + types; @@ -202,7 +205,7 @@ const isSwitch = (key: string): boolean => function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[], title: string): void { const project = () => host.state.project; const values = resolveAssetProps(project(), asset); - const shown = schema.filter((d) => isRowShown(d, values)); + const shown = schema.filter((d) => isRowShown(d, values) && !isRowHiddenWhenEmbedded(asset, d.key)); if (shown.length > 0) addTitle(host, title); for (const def of shown) { const row = createFieldRow( diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 182fb653..36eb9cd2 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -17,6 +17,7 @@ import { generatesPictures } from '../../core/model/generated'; import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart'; import type { PropertyBag } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; +import { listedAssets, ownerSong } from '../../core/music/embedded'; import { spriteFromTexture } from '../../core/properties/aspect'; import { assetDeviates } from '../../core/properties/deviation'; import { schemaForAsset } from '../../core/properties/PropertySchema'; @@ -112,10 +113,14 @@ export class AssetPanel extends Panel { this.refresh(); } - /** Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. */ + /** + * Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. What a + * song keeps to itself has no row, so its song lights up (#355). + */ private ping(): void { - const id = this.state.pingedAssetId; - if (!id || !findAsset(this.state.project, id)) return; + const asset = findAsset(this.state.project, this.state.pingedAssetId ?? ''); + if (!asset) return; + const id = ownerSong(this.state.project, asset)?.id ?? asset.id; this.reveal(id); this.list.ping(id); } @@ -153,7 +158,7 @@ export class AssetPanel extends Panel { private refresh(): void { const project = this.state.project; const items: ListItem[] = []; - for (const { category, groups } of groupAssets(project.assets)) { + for (const { category, groups } of groupAssets(listedAssets(project))) { const open = !this.foldedCategories.has(category); items.push({ id: categoryId(category), @@ -249,7 +254,7 @@ export class AssetPanel extends Panel { expanded: boolean; ids: string[]; }[] { - return groupAssets(this.state.project.assets).flatMap(({ category, groups }) => + return groupAssets(listedAssets(this.state.project)).flatMap(({ category, groups }) => groups.map(({ type, assets }) => ({ category, type, @@ -262,7 +267,7 @@ export class AssetPanel extends Panel { /** The categories in order, and whether each is unfolded (test hook). */ categories(): { category: AssetCategory; expanded: boolean }[] { - return groupAssets(this.state.project.assets).map(({ category }) => ({ + return groupAssets(listedAssets(this.state.project)).map(({ category }) => ({ category, expanded: !this.foldedCategories.has(category), })); @@ -319,7 +324,7 @@ export class AssetPanel extends Panel { */ private createPixelArt(): void { const project = this.state.project; - const existing = groupAssets(project.assets) + const existing = groupAssets(listedAssets(project)) .flatMap((category) => category.groups) .find((group) => group.type === 'palette')?.assets[0]; const palette = existing diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index d5c2c9a7..2cef0f87 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -1,5 +1,6 @@ import { CreateSoundCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands'; import { CompoundCommand } from '../../core/commands/CompoundCommand'; +import { nextAssetName } from '../../core/commands/naming'; import { SetSongCommand } from '../../core/commands/songCommands'; import { newItem, @@ -10,6 +11,7 @@ import { songSequence, songTempo, } from '../../core/music/song'; +import { embeddedIn } from '../../core/music/embedded'; import { roundAt } from '../../core/music/ghosts'; import { patternLength } from '../../core/music/pattern'; import { fxVoiceOf } from '../../core/music/needs'; @@ -358,11 +360,7 @@ export class SongEditor extends AssetTabEditor { this.state.setNotice('Nothing was recorded'); return; } - const song = findAssetOf(this.state.project, take.songId, 'song'); - const name = takeName( - song?.name ?? 'Recording', - this.state.project.assets.map((a) => a.name), - ); + const name = takeName(embeddedIn(this.state.project, take.songId).map((a) => a.name)); const seconds = sampleCount(parts) / take.recorder.sampleRate; await this.upload(take, wavBytes(parts, take.recorder.sampleRate), name, seconds); } @@ -410,14 +408,19 @@ export class SongEditor extends AssetTabEditor { this.syncPlayButton(); } - /** The sound, its track and its item, one undo step; the song gone meanwhile, only the sound. */ + /** + * The sound, its track and its item, one undo step. The sound is the song's own (#355); with the + * song gone meanwhile, it is a project asset by itself. + */ private place(take: Take, file: string, name: string, seconds: number): void { - const create = new CreateSoundCommand(name, file); const song = findAssetOf(this.state.project, take.songId, 'song'); if (!song) { - if (this.state.execute(create)) this.state.setNotice(`Recorded "${name}" – its song is gone`); + const create = new CreateSoundCommand(nextAssetName(this.state.project, name), file); + if (this.state.execute(create)) + this.state.setNotice(`Recorded "${create.asset.name}" – its song is gone`); return; } + const create = new CreateSoundCommand(name, file, song.id); const track = takeTrack(); const tracks = [...song.tracks]; tracks.splice(Math.min(take.index, tracks.length), 0, track); diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index ee622361..79bbe23e 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -5,7 +5,9 @@ import { Graphics, type Text, } from 'pixi.js'; -import { SetSongCommand, SetSongLoopCommand } from '../../core/commands/songCommands'; +import { MakeProjectAssetCommand } from '../../core/commands/assetCommands'; +import { SetSongCommand, SetSongLoopCommand, type SongParts } from '../../core/commands/songCommands'; +import { embeddedCopy, embeddedUsedBy, isEmbedded, listedAssets } from '../../core/music/embedded'; import { patternLength } from '../../core/music/pattern'; import type { LoopRange } from '../../core/music/sequence'; import { @@ -24,7 +26,7 @@ import { TRACK_MIN_DB, } from '../../core/music/song'; import { ticksOfSeconds, ticksPerBar } from '../../core/music/time'; -import type { SongAsset } from '../../core/model/Asset'; +import type { Asset, SongAsset } from '../../core/model/Asset'; import type { Vec2 } from '../../core/model/Properties'; import { findAsset, findAssetOf } from '../../core/model/Project'; import { @@ -116,6 +118,8 @@ const SOLO_COLOR = 0xc9c23a; const FX_COLOR = 0x8fb8de; /** Height of an item's name strip; what it plays is drawn below. */ const LABEL_STRIP = 15; +/** In front of the name of what the song keeps to itself (#355). */ +export const EMBEDDED_MARK = '•'; /** The letter on a lit mute, solo or FX button. */ const DARK_LABEL = textStyle('small').clone(); DARK_LABEL.fill = 0x30343c; @@ -222,6 +226,8 @@ export class SongTimeline extends Widget { private startTick = 0; /** What Ctrl+C took, for any song of the project. */ private clipboard: SongItem[] = []; + /** What the song kept to itself of the copied items (#355), as it was then: a paste still has it after a delete. */ + private clipboardAssets: Asset[] = []; /** Sounds asked for, so a waveform is drawn once its bytes are there. */ private readonly loading = new Set(); /** Where the playhead stands while the song plays, in ticks; null while it does not. */ @@ -367,6 +373,7 @@ export class SongTimeline extends Widget { const picked = new Set(this.pickedIds); if (!asset || picked.size === 0) return false; this.clipboard = asset.items.filter((i) => picked.has(i.id)).map((i) => ({ ...i })); + this.clipboardAssets = structuredClone(embeddedUsedBy(this.state.project, this.clipboard)); const n = this.clipboard.length; this.state.setNotice(`Copied ${n} ${n === 1 ? 'item' : 'items'}`, 'quiet'); return true; @@ -389,7 +396,7 @@ export class SongTimeline extends Widget { ); this.commit( asset, - { items: [...asset.items, ...copies] }, + { items: [...asset.items, ...copies], spare: this.clipboardAssets }, copies.length === 1 ? 'Paste item' : 'Paste items', ); this.pick(copies.map((i) => i.id)); @@ -987,7 +994,7 @@ export class SongTimeline extends Widget { this.invalidate(); } - private commit(asset: SongAsset, parts: { tracks?: SongTrack[]; items?: SongItem[] }, label: string): void { + private commit(asset: SongAsset, parts: SongParts, label: string): void { if (SetSongCommand.changes(asset, parts)) this.state.execute(new SetSongCommand(asset.id, parts, label)); } @@ -1010,10 +1017,36 @@ export class SongTimeline extends Widget { /** The menu of the item under a right-click, picked first unless it is already. */ private openItemMenu(asset: SongAsset, g: Vec2, global: Vec2): void { - const hit = itemAt(this.view, asset.items, asset.tracks, g); + const at = itemAt(this.view, asset.items, asset.tracks, g); + const hit = at && asset.items.find((i) => i.id === at.id); if (!hit) return; if (!this.pickedIds.includes(hit.id)) this.pick([hit.id]); - new Menu([{ label: 'Delete', onSelect: () => this.deleteSelection() }]).open(global.x, global.y); + const played = findAsset(this.state.project, hit.asset); + const embedded = played !== undefined && isEmbedded(played); + new Menu([ + { label: 'Open', disabled: !played, onSelect: () => this.onOpenItem(hit, hit.tick) }, + embedded + ? { + label: 'Make project asset', + onSelect: () => this.state.execute(new MakeProjectAssetCommand(hit.asset)), + } + : { label: 'Embed a copy', disabled: !played, onSelect: () => this.embedCopy(asset, hit) }, + { separator: true }, + { label: 'Delete', onSelect: () => this.deleteSelection() }, + ]).open(global.x, global.y); + } + + /** Points the item at a copy of its asset that only the song has (#355), to bend without touching the rest. */ + embedCopy(asset: SongAsset, item: SongItem): boolean { + const played = findAsset(this.state.project, item.asset); + if (!played || isEmbedded(played)) return false; + const copy = embeddedCopy(played, asset.id); + this.commit( + asset, + { items: asset.items.map((i) => (i.id === item.id ? { ...i, asset: copy.id } : i)), spare: [copy] }, + 'Embed a copy', + ); + return true; } /** The sound FX a track goes through (#346): none, or one of the project's, by name. */ @@ -1024,7 +1057,7 @@ export class SongTimeline extends Widget { { tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)) }, 'Set track sound FX', ); - const effects = this.state.project.assets + const effects = listedAssets(this.state.project) .filter((a) => a.type === 'soundfx') .sort((a, b) => a.name.localeCompare(b.name)); new Menu([ @@ -1162,8 +1195,11 @@ export class SongTimeline extends Widget { return rounds ? loopSeams(item, rounds.period, item.tick - rounds.base) : []; } + /** Its asset's name, with a mark in front for one the song keeps to itself (#355). */ private labelOf(item: SongItem): string { - return findAsset(this.state.project, item.asset)?.name ?? 'missing'; + const asset = findAsset(this.state.project, item.asset); + if (!asset) return 'missing'; + return isEmbedded(asset) ? `${EMBEDDED_MARK} ${asset.name}` : asset.name; } private contentOf(item: SongItem): 'notes' | 'wave' | 'none' { diff --git a/src/editor/soundfx/SoundFxEditor.ts b/src/editor/soundfx/SoundFxEditor.ts index 053d3ab1..75c2df20 100644 --- a/src/editor/soundfx/SoundFxEditor.ts +++ b/src/editor/soundfx/SoundFxEditor.ts @@ -6,6 +6,7 @@ import { volumeOf } from '../../core/properties/sounds'; import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile'; import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; +import { listedAssets } from '../../core/music/embedded'; import { playedBus } from '../../core/music/mixer'; import { AssetTabEditor } from '../AssetTabEditor'; import { GraphCanvas } from '../graph/GraphCanvas'; @@ -75,7 +76,7 @@ export class SoundFxEditor extends AssetTabEditor { /** The sound the header plays: the one picked, else the project's first. */ get previewSoundId(): string { - const sounds = this.state.project.assets.filter((a) => a.type === 'sound'); + const sounds = listedAssets(this.state.project).filter((a) => a.type === 'sound'); return sounds.some((a) => a.id === this.soundId) ? this.soundId : (sounds[0]?.id ?? ''); } @@ -183,7 +184,7 @@ export class SoundFxEditor extends AssetTabEditor { private openSoundPicker(): void { const current = this.previewSoundId; - const items = this.state.project.assets + const items = listedAssets(this.state.project) .filter((a) => a.type === 'sound') .map((a) => ({ label: a.name, current: a.id === current, onSelect: () => this.pickSound(a.id) })); if (items.length > 0) new Menu(items).openBelow(this.soundPicker); diff --git a/tests/core/embedded.test.ts b/tests/core/embedded.test.ts new file mode 100644 index 00000000..c4d64d77 --- /dev/null +++ b/tests/core/embedded.test.ts @@ -0,0 +1,213 @@ +/** + * What a song keeps to itself (#355): an asset with the song as its owner, left out of every list, + * with one user in the song. Split, duplicate and paste give a place a copy of its own; what nothing + * names goes in the same step, and comes back with an undo. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + CreateSoundCommand, + DeleteAssetCommand, + DuplicateAssetCommand, + MakeProjectAssetCommand, +} from '../../src/core/commands/assetCommands'; +import { nextAssetName } from '../../src/core/commands/naming'; +import { SetSongCommand } from '../../src/core/commands/songCommands'; +import type { Asset, SongAsset } from '../../src/core/model/Asset'; +import { findAsset, findAssetOf } from '../../src/core/model/Project'; +import { + assetTitle, + embeddedCopy, + embeddedIn, + embeddedUsedBy, + isRowHiddenWhenEmbedded, + listedAssets, +} from '../../src/core/music/embedded'; +import { newItem, type SongItem, songSequence } from '../../src/core/music/song'; +import { assetRefChoices } from '../../src/core/properties/assetRef'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { makeProject } from '../helpers'; + +const FILE = `${'c4'.repeat(32)}.wav`; +const BAR = 1920; + +/** A song "Theme" with a take of its own on an item, and a project sound "Blip". */ +function setup() { + const f = makeProject(); + const made = new CreateAssetCommand('song', 'Theme'); + f.stack.execute(made); + const songId = made.asset.id; + const song = () => findAssetOf(f.project, songId, 'song')!; + const blip = new CreateSoundCommand('Blip', FILE); + f.stack.execute(blip); + const take = new CreateSoundCommand('Take 1', FILE, songId); + f.stack.execute(take); + const track = song().tracks[0]!.id; + const item = newItem({ track, kind: 'sound', asset: take.asset.id, tick: 0, length: 2 * BAR }); + f.stack.execute(new SetSongCommand(songId, { items: [item] })); + const edit = (items: SongItem[], extra: { spare?: Asset[] } = {}) => + f.stack.execute(new SetSongCommand(songId, { items, ...extra })); + return { f, songId, song, track, blipId: blip.asset.id, takeId: take.asset.id, itemId: item.id, edit }; +} + +const ids = (assets: readonly Asset[]) => assets.map((a) => a.id); + +describe('an embedded asset (#355)', () => { + it('is found by its id but left out of every list and of the names', () => { + const { f, songId, takeId, blipId, song } = setup(); + expect(findAsset(f.project, takeId)?.owner).toBe(songId); + expect(ids(listedAssets(f.project))).not.toContain(takeId); + expect(ids(assetRefChoices(f.project, ['sound']))).toEqual([blipId]); + expect(nextAssetName(f.project, 'Take 1')).toBe('Take 1'); + expect(songSequence(f.project, song()).clips?.map((c) => c.sound)).toEqual([takeId]); + expect(assetTitle(f.project, findAsset(f.project, takeId)!)).toBe('Theme › Take 1'); + }); + + it('hides the rows that do nothing in a song, only while embedded', () => { + const { f, takeId, blipId } = setup(); + const take = findAsset(f.project, takeId)!; + expect(['loop', 'bus', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([ + true, + true, + false, + false, + ]); + expect(isRowHiddenWhenEmbedded(findAsset(f.project, blipId)!, 'loop')).toBe(false); + }); + + it('gives the right half of a split a copy of its own, the same one again on redo', () => { + const { f, song, takeId, edit } = setup(); + const left = song().items[0]!; + edit([ + { ...left, length: BAR }, + { ...left, id: 'right', tick: BAR, length: BAR }, + ]); + const [a, b] = song().items; + expect(a!.asset).toBe(takeId); + expect(b!.asset).not.toBe(takeId); + const copy = findAsset(f.project, b!.asset)!; + expect([copy.owner, copy.name, copy.type]).toEqual([song().id, 'Take 1', 'sound']); + f.stack.undo(); + expect(findAsset(f.project, copy.id)).toBeUndefined(); + expect(embeddedIn(f.project, song().id).map((x) => x.id)).toEqual([takeId]); + f.stack.redo(); + expect(song().items[1]!.asset).toBe(copy.id); + expect(findAsset(f.project, copy.id)).toBe(copy); + }); + + it('goes with the item that names it, and comes back on undo where it was', () => { + const { f, songId, takeId, edit } = setup(); + const at = f.project.assets.findIndex((a) => a.id === takeId); + edit([]); + expect(findAsset(f.project, takeId)).toBeUndefined(); + f.stack.undo(); + expect(f.project.assets.findIndex((a) => a.id === takeId)).toBe(at); + expect(findAsset(f.project, takeId)?.owner).toBe(songId); + }); + + it('comes back from what was copied along when a paste names it after it went', () => { + const { f, song, takeId, edit } = setup(); + const copied = song().items.map((i) => ({ ...i })); + const spare = structuredClone(embeddedUsedBy(f.project, copied)); + expect(ids(spare)).toEqual([takeId]); + edit([]); + edit( + copied.map((i) => ({ ...i, id: 'pasted' })), + { spare }, + ); + expect(song().items[0]!.asset).toBe(takeId); + expect(findAsset(f.project, takeId)?.owner).toBe(song().id); + }); + + it('is copied for a song that names one of another song', () => { + const { f, song, takeId } = setup(); + const other = new CreateAssetCommand('song', 'Other'); + f.stack.execute(other); + const otherSong = other.asset as SongAsset; + const item = newItem({ + track: otherSong.tracks[0]!.id, + kind: 'sound', + asset: takeId, + tick: 0, + length: BAR, + }); + f.stack.execute(new SetSongCommand(otherSong.id, { items: [item] })); + const named = findAssetOf(f.project, otherSong.id, 'song')!.items[0]!.asset; + expect(named).not.toBe(takeId); + expect(findAsset(f.project, named)?.owner).toBe(otherSong.id); + expect(findAsset(f.project, takeId)?.owner).toBe(song().id); + }); + + it('leaves a project asset named by several items alone', () => { + const { f, song, blipId, edit, track } = setup(); + const before = f.project.assets.length; + edit([ + newItem({ track, kind: 'sound', asset: blipId, tick: 0, length: BAR }), + newItem({ track, kind: 'sound', asset: blipId, tick: BAR, length: BAR }), + ]); + expect(song().items.map((i) => i.asset)).toEqual([blipId, blipId]); + expect(f.project.assets.length).toBe(before - 1); + }); + + it('is embedded as a copy of a project asset, which stays', () => { + const { f, song, blipId, edit, track } = setup(); + const copy = embeddedCopy(findAsset(f.project, blipId)!, song().id); + edit([newItem({ track, kind: 'sound', asset: copy.id, tick: 0, length: BAR })], { spare: [copy] }); + expect(song().items[0]!.asset).toBe(copy.id); + expect(findAsset(f.project, copy.id)?.owner).toBe(song().id); + expect(findAsset(f.project, blipId)?.owner).toBeUndefined(); + }); + + it('goes with its song, and comes back with it', () => { + const { f, songId, takeId } = setup(); + f.stack.execute(new DeleteAssetCommand(songId)); + expect(findAsset(f.project, takeId)).toBeUndefined(); + f.stack.undo(); + expect(findAsset(f.project, takeId)?.owner).toBe(songId); + expect(findAsset(f.project, songId)).toBeDefined(); + }); + + it('is copied for a duplicate of its song, which names the copy', () => { + const { f, songId, takeId } = setup(); + const dup = new DuplicateAssetCommand(songId); + f.stack.execute(dup); + const copy = findAssetOf(f.project, dup.copyId, 'song')!; + const named = copy.items[0]!.asset; + expect(named).not.toBe(takeId); + expect(findAsset(f.project, named)?.owner).toBe(copy.id); + expect(embeddedIn(f.project, songId).map((a) => a.id)).toEqual([takeId]); + f.stack.undo(); + expect(findAsset(f.project, named)).toBeUndefined(); + }); + + it('becomes a project asset of a free name, and back on undo', () => { + const { f, songId, takeId } = setup(); + f.stack.execute(new CreateSoundCommand('Take 1', FILE)); + f.stack.execute(new MakeProjectAssetCommand(takeId)); + const take = findAsset(f.project, takeId)!; + expect([take.owner, take.name]).toEqual([undefined, 'Take 1 2']); + expect(ids(listedAssets(f.project))).toContain(takeId); + f.stack.undo(); + expect([take.owner, take.name]).toEqual([songId, 'Take 1']); + }); + + it('a duplicate of it is a project asset', () => { + const { f, takeId } = setup(); + const dup = new DuplicateAssetCommand(takeId); + f.stack.execute(dup); + expect(findAsset(f.project, dup.copyId)?.owner).toBeUndefined(); + }); + + it('keeps its owner through a save, and is a project asset once its song is gone', () => { + const { f, songId, takeId } = setup(); + const raw = serializeProject(f.project) as { assets: Record[] }; + expect(deserializeProject(raw).assets.find((a) => a.id === takeId)?.owner).toBe(songId); + const orphan = { ...raw, assets: raw.assets.filter((a) => a.id !== songId) }; + expect(deserializeProject(orphan).assets.find((a) => a.id === takeId)?.owner).toBeUndefined(); + const sprite = { + ...raw, + assets: [...raw.assets, { id: 'sp', type: 'sprite', name: 'S', props: {}, owner: songId }], + }; + expect(deserializeProject(sprite).assets.find((a) => a.id === 'sp')?.owner).toBeUndefined(); + }); +}); diff --git a/tests/core/take.test.ts b/tests/core/take.test.ts index 2c86f5f9..26ce9693 100644 --- a/tests/core/take.test.ts +++ b/tests/core/take.test.ts @@ -68,9 +68,10 @@ describe('a recorded take (#342)', () => { expect([view.getInt16(44, true), view.getInt16(46, true), view.getInt16(48, true)]).toEqual([1, -2, 300]); }); - it('is named after its song, numbered past the takes there are', () => { - expect(takeName('Theme', ['Theme'])).toBe('Theme – Take 1'); - expect(takeName('Theme', ['Theme', 'Theme – Take 1', 'Theme – Take 2'])).toBe('Theme – Take 3'); + it('is numbered past the takes its song keeps (#355)', () => { + expect(takeName([])).toBe('Take 1'); + expect(takeName(['Take 1', 'Take 2', 'Intro'])).toBe('Take 3'); + expect(takeName(['Take 2'])).toBe('Take 1'); }); it('gets a track of its own under the one clicked last, else at the bottom', () => { diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index a3968008..07e0b503 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -695,19 +695,21 @@ export async function song(t) { ); const takeTrack = kept.tracks[1]; const takeItem = kept.items.find((i) => i.track === takeTrack?.id); + const takeId = JSON.stringify(takeItem?.asset ?? ''); const takeAsset = await json( - `(() => { const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(takeItem?.asset ?? '')}); return a ? { name: a.name, file: a.file, type: a.type } : null; })()`, + `(() => { const a = __gm.state.project.assets.find((x) => x.id === ${takeId}); return a ? { name: a.name, file: a.file, type: a.type, owner: a.owner } : null; })()`, ); const songName = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`); const takeSeconds = takeAsset ? await evaluate(`__gm.sound.load(${JSON.stringify(takeAsset.file)})`) : 0; check( - 'Space keeps it: a WAV sound named after the song, on the track "Recording", from the cursor, as long as it is', + 'Space keeps it: a WAV sound of the song\'s own, "Take 1", on the track "Recording", from the cursor, as long as it is', kept.tracks[0]?.id === before.tracks[0]?.id && takeTrack?.name === 'Recording' && takeItem?.kind === 'sound' && takeItem.tick === BAR && takeAsset?.type === 'sound' && - takeAsset.name === `${songName} – Take 1` && + takeAsset.name === 'Take 1' && + takeAsset.owner === id && /^[a-f0-9]{12}\/[a-f0-9]{64}\.wav$/.test(takeAsset.file) && takeSeconds > 1 && Math.abs(takeItem.length - Math.floor(takeSeconds * 960)) <= 1 && @@ -715,6 +717,23 @@ export async function song(t) { (await json('__gm.song.view()')).take === null, JSON.stringify({ kept, takeAsset, takeSeconds }), ); + // #355: the take is the song's own – no row in the asset panel, a mark on its item, its song in the tab title. + const takeRow = await json(`__gm.assetRow(${takeId})`); + const takeLabel = (await json('__gm.song.items()')).find((i) => i.id === takeItem?.id)?.label; + p = await json(`__gm.song.itemAt(${JSON.stringify(takeItem?.id ?? '')}, 'body')`); + await doubleClick(p.x, p.y); + await sleep(200); + const takeTab = await json(`{ tab: __gm.tab(), title: __gm.tabRow().labels['asset:' + ${takeId}] }`); + check( + 'the take lives in the song: no row in the asset panel, • on its item, a double-click opens "Song › Take 1"', + takeRow === null && + takeLabel === '• Take 1' && + takeTab.tab === 'sound' && + takeTab.title === `${songName} › Take 1`, + JSON.stringify({ takeRow, takeLabel, takeTab }), + ); + await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); + await sleep(200); await press('z', { ctrl: true }); const undone = await model(); check(