diff --git a/packages/studio/core/src/ui/clipboard/ClipboardUtils.ts b/packages/studio/core/src/ui/clipboard/ClipboardUtils.ts index b1c0fb99..26272124 100644 --- a/packages/studio/core/src/ui/clipboard/ClipboardUtils.ts +++ b/packages/studio/core/src/ui/clipboard/ClipboardUtils.ts @@ -15,12 +15,14 @@ export namespace ClipboardUtils { export const serializeBoxes = (boxes: ReadonlyArray, metadata: ArrayBufferLike = new ArrayBuffer(0)): ArrayBufferLike => { + const uniqueBoxes = UUID.newSet(box => box.address.uuid) + boxes.forEach(box => uniqueBoxes.add(box)) const typeCounts = new Map() - boxes.forEach(box => typeCounts.set(box.name, (typeCounts.get(box.name) ?? 0) + 1)) + uniqueBoxes.forEach(box => typeCounts.set(box.name, (typeCounts.get(box.name) ?? 0) + 1)) const types = [...typeCounts.entries()].map(([type, count]) => `${type}: ${count}`).join(", ") const clipboardGraph = new BoxGraph(Option.wrap(BoxIO.create)) clipboardGraph.beginTransaction() - boxes.forEach(sourceBox => { + uniqueBoxes.forEach(sourceBox => { const input = new ByteArrayInput(sourceBox.toArrayBuffer()) clipboardGraph.createBox(sourceBox.name as keyof BoxIO.TypeMap, sourceBox.address.uuid, box => box.read(input)) }) @@ -50,7 +52,21 @@ export namespace ClipboardUtils { input.readBytes(graphData) const clipboardGraph = new BoxGraph(Option.wrap(BoxIO.create)) clipboardGraph.fromArrayBuffer(graphData.buffer) - const sourceBoxes = clipboardGraph.boxes().filter(box => !options.excludeBox?.(box)) + const skippedExternalUuids = UUID.newSet(uuid => uuid) + clipboardGraph.boxes().forEach(box => { + if (box.resource === "external" && targetGraph.findBox(box.address.uuid).nonEmpty()) { + skippedExternalUuids.add(box.address.uuid) + } + }) + const shouldSkipBox = (box: Box): boolean => { + if (options.excludeBox?.(box)) {return true} + if (box.resource === "external" && skippedExternalUuids.hasKey(box.address.uuid)) {return true} + for (const [, targetAddress] of box.outgoingEdges()) { + if (skippedExternalUuids.hasKey(targetAddress.uuid) && !targetAddress.isBox()) {return true} + } + return false + } + const sourceBoxes = clipboardGraph.boxes().filter(box => !shouldSkipBox(box)) const typeCounts = new Map() sourceBoxes.forEach(box => typeCounts.set(box.name, (typeCounts.get(box.name) ?? 0) + 1)) console.debug("Clipboard paste:", [...typeCounts.entries()].map(([type, count]) => `${type}: ${count}`).join(", ")) @@ -59,6 +75,7 @@ export namespace ClipboardUtils { const isExternal = box.resource === "external" uuidMap.add({source: box.address.uuid, target: isExternal ? box.address.uuid : UUID.generate()}) }) + skippedExternalUuids.forEach(uuid => uuidMap.add({source: uuid, target: uuid})) const result: Array = [] PointerField.decodeWith({ map: (pointer, address) => { @@ -68,8 +85,6 @@ export namespace ClipboardUtils { return options.mapPointer(pointer, address) } }, () => sourceBoxes.forEach(sourceBox => { - const isExternal = sourceBox.resource === "external" - if (isExternal && targetGraph.findBox(sourceBox.address.uuid).nonEmpty()) {return} const inputStream = new ByteArrayInput(sourceBox.toArrayBuffer()) const targetUuid = uuidMap.get(sourceBox.address.uuid).target targetGraph.createBox(sourceBox.name as keyof BoxIO.TypeMap, targetUuid, box => { diff --git a/plans/copy-and-paste-regions.md b/plans/copy-and-paste-regions.md index 01c54cda..0b7db246 100644 --- a/plans/copy-and-paste-regions.md +++ b/plans/copy-and-paste-regions.md @@ -60,6 +60,30 @@ subscribeDeletion(listener: Exec): Subscription { } ``` +```typescript +// packages/lib/box/src/graph.ts +readonly #deletionListeners: SortedSet, { uuid: UUID.Bytes, listeners: Set }> + +subscribeDeletion(uuid: UUID.Bytes, listener: Exec): Subscription { + const entry = this.#deletionListeners.getOrCreate(uuid, () => ({uuid, listeners: new Set()})) + entry.listeners.add(listener) + return { + terminate: () => { + entry.listeners.delete(listener) + if (entry.listeners.size === 0) { + this.#deletionListeners.removeByKey(uuid) + } + } + } +} + +unstageBox(box: Box): void { + // ... existing code ... + this.#deletionListeners.removeByKeyIfExist(box.address.uuid)?.listeners.forEach(listener => listener()) + // ... rest of method ... +} +``` + Deletion listeners are stored in `BoxGraph.#deletionListeners` and notified during `unstageBox()`. --- @@ -381,6 +405,39 @@ const pasteRegions = ( - Silently skip entire paste - No partial pastes to avoid orphaned dependency boxes +### External Resource Handling (AudioFileBox) + +External resources like `AudioFileBox` require special handling during paste: + +1. **UUID Preservation**: External resources keep their original UUID (not regenerated) +2. **Skip if exists**: If the external resource already exists in the target graph, don't create a duplicate +3. **Skip field-level children**: Boxes that point to fields within an existing external resource (e.g., `TransientMarkerBox` pointing to `AudioFileBox.transientMarkers`) are skipped to avoid duplicates + +```typescript +// In ClipboardUtils.deserializeBoxes +const skippedExternalUuids = UUID.newSet(uuid => uuid) +clipboardGraph.boxes().forEach(box => { + if (box.resource === "external" && targetGraph.findBox(box.address.uuid).nonEmpty()) { + skippedExternalUuids.add(box.address.uuid) + } +}) + +const shouldSkipBox = (box: Box): boolean => { + if (options.excludeBox?.(box)) {return true} + // Skip external resources that already exist + if (box.resource === "external" && skippedExternalUuids.hasKey(box.address.uuid)) {return true} + // Skip boxes pointing to FIELDS within skipped external resources (children like TransientMarkerBox) + for (const [, targetAddress] of box.outgoingEdges()) { + if (skippedExternalUuids.hasKey(targetAddress.uuid) && !targetAddress.isBox()) {return true} + } + return false +} +``` + +The `!targetAddress.isBox()` check distinguishes between: +- `AudioRegionBox` → `AudioFileBox` (box-level reference, allowed) +- `TransientMarkerBox` → `AudioFileBox.transientMarkers` (field-level reference, skipped) + ### Audio File Not in Project - AudioFileBox created with same UUID - Audio won't play until file is available @@ -429,6 +486,7 @@ packages/studio/core/src/ui/timeline/TimelineFocus.ts packages/studio/core/src/project/Project.ts - added timelineFocus packages/lib/box/src/box.ts - added subscribeDeletion() packages/lib/box/src/graph.ts - added #deletionListeners and subscribeDeletion() +packages/studio/core/src/ui/clipboard/ClipboardUtils.ts - external resource handling, skip field-level children packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionsArea.tsx - focus handling, clipboard integration packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionLane.tsx - focus visual feedback packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionLane.sass - .focused styling