diff --git a/packages/app/studio/src/service/StudioShortcutManager.ts b/packages/app/studio/src/service/StudioShortcutManager.ts index 61346c65..92ad16f0 100644 --- a/packages/app/studio/src/service/StudioShortcutManager.ts +++ b/packages/app/studio/src/service/StudioShortcutManager.ts @@ -15,7 +15,7 @@ import {StudioService} from "@/service/StudioService" import {DefaultWorkspace} from "@/ui/workspace/Default" import {PanelType} from "@/ui/workspace/PanelType" import {Workspace} from "@/ui/workspace/Workspace" -import {DeviceHost, Devices, ProjectUtils} from "@opendaw/studio-adapters" +import {DeviceHost, Devices, TransferAudioUnits} from "@opendaw/studio-adapters" import {ContentEditorShortcuts, ContentEditorShortcutsFactory} from "@/ui/shortcuts/ContentEditorShortcuts" import {PianoPanelShortcuts, PianoPanelShortcutsFactory} from "@/ui/shortcuts/PianoPanelShortcuts" import {RegionsShortcuts, RegionsShortcutsFactory} from "@/ui/shortcuts/RegionsShortcuts" @@ -147,8 +147,8 @@ export namespace StudioShortcutManager { ({editing, boxAdapters, userEditingManager, skeleton}) => userEditingManager.audioUnit.get() .ifSome(({box}) => { const deviceHost: DeviceHost = boxAdapters.adapterFor(box, Devices.isHost) - const copies = editing.modify(() => ProjectUtils - .extractAudioUnits([deviceHost.audioUnitBoxAdapter().box], skeleton), false).unwrap() + const copies = editing.modify(() => TransferAudioUnits + .transfer([deviceHost.audioUnitBoxAdapter().box], skeleton), false).unwrap() userEditingManager.audioUnit.edit(copies[0].editing) }))), gc.register(gs["workspace-next-screen"].shortcut, () => { diff --git a/packages/app/studio/src/ui/timeline/tracks/audio-unit/headers/TrackHeaderMenu.ts b/packages/app/studio/src/ui/timeline/tracks/audio-unit/headers/TrackHeaderMenu.ts index 6b44c41b..4b343b4b 100644 --- a/packages/app/studio/src/ui/timeline/tracks/audio-unit/headers/TrackHeaderMenu.ts +++ b/packages/app/studio/src/ui/timeline/tracks/audio-unit/headers/TrackHeaderMenu.ts @@ -1,6 +1,6 @@ import {CaptureAudio, MenuItem, MonitoringMode, Project} from "@opendaw/studio-core" import {isInstanceOf, Procedure, RuntimeNotifier, UUID} from "@opendaw/lib-std" -import {AudioUnitBoxAdapter, DeviceAccepts, ProjectUtils, TrackBoxAdapter, TrackType} from "@opendaw/studio-adapters" +import {AudioUnitBoxAdapter, DeviceAccepts, TrackBoxAdapter, TrackType, TransferAudioUnits} from "@opendaw/studio-adapters" import {DebugMenus} from "@/ui/menu/debug" import {MidiImport} from "@/ui/timeline/MidiImport.ts" import {CaptureMidiBox, TrackBox} from "@opendaw/studio-boxes" @@ -72,8 +72,8 @@ export const installTrackHeaderMenu = (service: StudioService, shortcut: GlobalShortcuts["copy-device"].shortcut.format(), separatorBefore: true }).setTriggerProcedure(() => { - const copies = editing.modify(() => ProjectUtils - .extractAudioUnits([trackBoxAdapter.audioUnit], project.skeleton), false).unwrap() + const copies = editing.modify(() => TransferAudioUnits + .transfer([trackBoxAdapter.audioUnit], project.skeleton), false).unwrap() userEditingManager.audioUnit.edit(copies[0].editing) }), MenuItem.default({ @@ -90,7 +90,7 @@ export const installTrackHeaderMenu = (service: StudioService, editing.modify(() => { const {boxGraph, skeleton} = newProject boxGraph.beginTransaction() - ProjectUtils.extractAudioUnits([trackBoxAdapter.audioUnit], skeleton) + TransferAudioUnits.transfer([trackBoxAdapter.audioUnit], skeleton) boxGraph.endTransaction() }) service.projectProfileService.setProject(newProject, "NEW") diff --git a/packages/lib/box/src/graph.ts b/packages/lib/box/src/graph.ts index 047ead6b..962df67f 100644 --- a/packages/lib/box/src/graph.ts +++ b/packages/lib/box/src/graph.ts @@ -282,7 +282,7 @@ export class BoxGraph { return pointers } - dependenciesOf(box: Box, options: { + dependenciesOf(root: Box | ReadonlyArray, options: { excludeBox?: Predicate alwaysFollowMandatory?: boolean stopAtResources?: boolean @@ -293,20 +293,14 @@ export class BoxGraph { const boxes = new Set() const pointers = new Set() const trace = (box: Box, isStartingBox: boolean = false): void => { - // Don't apply excludeBox to the starting box - only to its dependencies if (boxes.has(box) || (!isStartingBox && excludeBox(box))) {return} boxes.add(box) - // Handle resource boxes especially when stopAtResources is enabled if (stopAtResources && isDefined(box.resource)) { - // Resource boxes are endpoints, but we still need their "children" - // Children = boxes that point to FIELDS within this box (not the box itself) box.incomingEdges() .forEach(pointer => { pointers.add(pointer) - // Only follow if a pointer targets a FIELD (child), not the BOX (user) const targetsField = pointer.targetAddress.mapOr(address => !address.isBox(), false) if (pointer.mandatory && targetsField) { - // For shared resources, skip pointers to mandatory (ownership) fields if (box.resource === "shared") { const isOwnershipField = pointer.targetAddress .flatMap(address => this.findVertex(address)) @@ -316,10 +310,8 @@ export class BoxGraph { trace(pointer.box) } }) - // Don't trace outgoing edges - resources are endpoints return } - // Normal box traversal box.outgoingEdges() .filter(([pointer]) => !pointers.has(pointer)) .forEach(([source, targetAddress]: [PointerField, Address]) => { @@ -328,7 +320,6 @@ export class BoxGraph { pointers.add(source) if (targetVertex.pointerRules.mandatory && (alwaysFollowMandatory || targetVertex.pointerHub.incoming() - // Only check pointers to this specific address, not to other fields in the box .filter(p => p.targetAddress.mapOr(address => address.equals(targetAddress), false)) .every(pointer => pointers.has(pointer)))) { return trace(targetVertex.box) @@ -342,8 +333,9 @@ export class BoxGraph { } }) } - trace(box, true) - boxes.delete(box) + const startingBoxes: ReadonlyArray = Array.isArray(root) ? root : [root] + startingBoxes.forEach(startingBox => trace(startingBox, true)) + startingBoxes.forEach(startingBox => boxes.delete(startingBox)) return {boxes, pointers: Array.from(pointers).reverse()} } diff --git a/packages/studio/adapters/src/preset/PresetDecoder.ts b/packages/studio/adapters/src/preset/PresetDecoder.ts index 39b86c56..353db8c3 100644 --- a/packages/studio/adapters/src/preset/PresetDecoder.ts +++ b/packages/studio/adapters/src/preset/PresetDecoder.ts @@ -24,9 +24,9 @@ import { TrackBox } from "@opendaw/studio-boxes" import {ProjectSkeleton} from "../project/ProjectSkeleton" -import {ProjectUtils} from "../project/ProjectUtils" -import {TrackType} from "../timeline/TrackType" +import {TransferUtils} from "../transfer" import {PresetHeader} from "./PresetHeader" +import {TrackType} from "../timeline/TrackType" export namespace PresetDecoder { export const decode = (bytes: ArrayBufferLike, target: ProjectSkeleton) => { @@ -58,7 +58,22 @@ export namespace PresetDecoder { const sourceAudioUnitBoxes = sourceBoxGraph.boxes() .filter(box => isInstanceOf(box, AudioUnitBox)) .filter(box => box.type.getValue() !== AudioUnitType.Output) - ProjectUtils.extractAudioUnits(sourceAudioUnitBoxes, target, {excludeTimeline: true}) + const excludeBox = (box: Box): boolean => + TransferUtils.shouldExclude(box) || TransferUtils.excludeTimelinePredicate(box) + const dependencies = Array.from(sourceBoxGraph.dependenciesOf(sourceAudioUnitBoxes, { + alwaysFollowMandatory: true, + stopAtResources: true, + excludeBox + }).boxes) + const {mandatoryBoxes: {rootBox, primaryAudioBus}} = target + const uuidMap = TransferUtils.generateMap( + sourceAudioUnitBoxes, dependencies, rootBox.audioUnits.address.uuid, primaryAudioBus.address.uuid) + TransferUtils.copyBoxes(uuidMap, target.boxGraph, sourceAudioUnitBoxes, dependencies) + TransferUtils.reorderAudioUnits(uuidMap, sourceAudioUnitBoxes, rootBox) + sourceAudioUnitBoxes + .map(source => asInstanceOf(rootBox.graph + .findBox(uuidMap.get(source.address.uuid).target) + .unwrap("Target AudioUnit has not been copied"), AudioUnitBox)) .filter(box => box.type.getValue() !== AudioUnitType.Output) .forEach((audioUnitBox) => { const inputBox = audioUnitBox.input.pointerHub.incoming().at(0)?.box diff --git a/packages/studio/adapters/src/preset/PresetEncoder.ts b/packages/studio/adapters/src/preset/PresetEncoder.ts index 38671592..7ed69532 100644 --- a/packages/studio/adapters/src/preset/PresetEncoder.ts +++ b/packages/studio/adapters/src/preset/PresetEncoder.ts @@ -1,7 +1,8 @@ import {Arrays, ByteArrayOutput} from "@opendaw/lib-std" +import {Box} from "@opendaw/lib-box" import {AudioUnitBox} from "@opendaw/studio-boxes" import {ProjectSkeleton} from "../project/ProjectSkeleton" -import {ProjectUtils} from "../project/ProjectUtils" +import {TransferUtils} from "../transfer" import {PresetHeader} from "./PresetHeader" export namespace PresetEncoder { @@ -10,11 +11,23 @@ export namespace PresetEncoder { header.writeInt(PresetHeader.MAGIC_HEADER_OPEN) header.writeInt(PresetHeader.FORMAT_VERSION) const preset = ProjectSkeleton.empty({createOutputCompressor: false, createDefaultUser: false}) - preset.boxGraph.beginTransaction() - ProjectUtils.extractAudioUnits([audioUnitBox], preset, {excludeTimeline: true}) - preset.boxGraph.endTransaction() + const {boxGraph, mandatoryBoxes: {rootBox, primaryAudioBus}} = preset + const audioUnitBoxes = [audioUnitBox] + const excludeBox = (box: Box): boolean => + TransferUtils.shouldExclude(box) || TransferUtils.excludeTimelinePredicate(box) + boxGraph.beginTransaction() + const dependencies = Array.from(audioUnitBox.graph.dependenciesOf(audioUnitBoxes, { + alwaysFollowMandatory: true, + stopAtResources: true, + excludeBox + }).boxes) + const uuidMap = TransferUtils.generateMap( + audioUnitBoxes, dependencies, rootBox.audioUnits.address.uuid, primaryAudioBus.address.uuid) + TransferUtils.copyBoxes(uuidMap, boxGraph, audioUnitBoxes, dependencies) + TransferUtils.reorderAudioUnits(uuidMap, audioUnitBoxes, rootBox) + boxGraph.endTransaction() console.debug("SAVING...") - preset.boxGraph.debugBoxes() - return Arrays.concatArrayBuffers(header.toArrayBuffer(), preset.boxGraph.toArrayBuffer()) + boxGraph.debugBoxes() + return Arrays.concatArrayBuffers(header.toArrayBuffer(), boxGraph.toArrayBuffer()) } -} \ No newline at end of file +} diff --git a/packages/studio/adapters/src/project/ProjectUtils.ts b/packages/studio/adapters/src/project/ProjectUtils.ts deleted file mode 100644 index 6c93c45c..00000000 --- a/packages/studio/adapters/src/project/ProjectUtils.ts +++ /dev/null @@ -1,206 +0,0 @@ -import {ppqn} from "@opendaw/lib-dsp" -import { - Arrays, - asInstanceOf, - assert, - ByteArrayInput, - isInstanceOf, - Option, - Predicate, - SetMultimap, - SortedSet, - UUID -} from "@opendaw/lib-std" -import {AudioUnitBox, AuxSendBox, BoxIO, BoxVisitor, RootBox, TrackBox} from "@opendaw/studio-boxes" -import {Address, Box, BoxGraph, IndexedBox, PointerField} from "@opendaw/lib-box" -import {ProjectSkeleton} from "./ProjectSkeleton" -import {AnyRegionBox, UnionBoxTypes} from "../unions" -import {AudioUnitOrdering} from "../factories/AudioUnitOrdering" - -export namespace ProjectUtils { - type UUIDMapper = { source: UUID.Bytes, target: UUID.Bytes } - - const isSameGraph: (a: Box, b: Box) => boolean = ({graph: a}, {graph: b}) => a === b - const compareIndex = (a: IndexedBox, b: IndexedBox) => a.index.getValue() - b.index.getValue() - const excludeTimelinePredicate = (box: Box) => box.accept>({ - visitTrackBox: (_box: TrackBox): boolean => true - }) === true - const shouldExclude = (box: Box): boolean => box.ephemeral || box.name === AuxSendBox.ClassName - - export const extractAudioUnits = (audioUnitBoxes: ReadonlyArray, - {boxGraph, mandatoryBoxes: {primaryAudioBus, rootBox}}: ProjectSkeleton, - options: { - includeAux?: boolean, - includeBus?: boolean, - excludeTimeline?: boolean, - } = {}) - : ReadonlyArray => { - assert(Arrays.satisfy(audioUnitBoxes, isSameGraph), "AudioUnits must share the same BoxGraph") - assert(!options.includeAux && !options.includeBus, "Options are not yet implemented") - const excludeBox = (box: Box): boolean => - shouldExclude(box) || (options?.excludeTimeline === true && excludeTimelinePredicate(box)) - const seen = UUID.newSet(uuid => uuid) - const dependencies = audioUnitBoxes - .flatMap(box => Array.from(box.graph.dependenciesOf(box, { - alwaysFollowMandatory: true, - stopAtResources: true, - excludeBox - }).boxes)) - .filter(box => { - if (seen.hasKey(box.address.uuid)) {return false} - seen.add(box.address.uuid) - return true - }) - const uuidMap = generateTransferMap( - audioUnitBoxes, dependencies, rootBox.audioUnits.address.uuid, primaryAudioBus.address.uuid) - copy(uuidMap, boxGraph, audioUnitBoxes, dependencies) - reorderAudioUnits(uuidMap, audioUnitBoxes, rootBox) - console.debug("inTransaction", boxGraph.inTransaction()) - return audioUnitBoxes.map(source => asInstanceOf(rootBox.graph - .findBox(uuidMap.get(source.address.uuid).target) - .unwrap("Target Track has not been copied"), AudioUnitBox)) - } - - export const extractRegions = (regionBoxes: ReadonlyArray, - {boxGraph, mandatoryBoxes: {primaryAudioBus, rootBox}}: ProjectSkeleton, - insertPosition: ppqn = 0.0): void => { - assert(Arrays.satisfy(regionBoxes, isSameGraph), - "Region smust be from the same BoxGraph") - const regionBoxSet = new Set(regionBoxes) - const trackBoxSet = new Set() - const audioUnitBoxSet = new SetMultimap() - regionBoxes.forEach(regionBox => { - const trackBox = asInstanceOf(regionBox.regions.targetVertex.unwrap().box, TrackBox) - trackBoxSet.add(trackBox) - const audioUnitBox = asInstanceOf(trackBox.tracks.targetVertex.unwrap().box, AudioUnitBox) - audioUnitBoxSet.add(audioUnitBox, trackBox) - }) - console.debug(`Found ${audioUnitBoxSet.keyCount()} audioUnits`) - console.debug(`Found ${trackBoxSet.size} tracks`) - const audioUnitBoxes = [...audioUnitBoxSet.keys()] - const excludeBox: Predicate = (box: Box): boolean => - shouldExclude(box) - || (isInstanceOf(box, TrackBox) && !trackBoxSet.has(box)) - || (UnionBoxTypes.isRegionBox(box) && !regionBoxSet.has(box)) - const dependencies = audioUnitBoxes - .flatMap(box => Array.from(box.graph.dependenciesOf(box, { - excludeBox, - alwaysFollowMandatory: true, - stopAtResources: true - }).boxes)) - const uuidMap = generateTransferMap( - audioUnitBoxes, dependencies, rootBox.audioUnits.address.uuid, primaryAudioBus.address.uuid) - copy(uuidMap, boxGraph, audioUnitBoxes, dependencies) - reorderAudioUnits(uuidMap, audioUnitBoxes, rootBox) - audioUnitBoxSet.forEach((_, trackBoxes) => [...trackBoxes] - .sort(compareIndex) - .forEach((source: TrackBox, index) => { - const box = boxGraph - .findBox(uuidMap.get(source.address.uuid).target) - .unwrap("Target Track has not been copied") - asInstanceOf(box, TrackBox).index.setValue(index) - })) - const minPosition = regionBoxes.reduce((min, region) => - Math.min(min, region.position.getValue()), Number.MAX_VALUE) - const delta = insertPosition - minPosition - regionBoxes.forEach((source: AnyRegionBox) => { - const box = boxGraph - .findBox(uuidMap.get(source.address.uuid).target) - .unwrap("Target Track has not been copied") - const {position} = UnionBoxTypes.asRegionBox(box) - position.setValue(position.getValue() + delta) - }) - } - - const generateTransferMap = (audioUnitBoxes: ReadonlyArray, - dependencies: ReadonlyArray, - rootBoxUUID: UUID.Bytes, - masterBusBoxUUID: UUID.Bytes): SortedSet => { - const uuidMap = UUID.newSet(({source}) => source) - uuidMap.addMany([ - ...audioUnitBoxes - .filter(({output: {targetAddress}}) => targetAddress.nonEmpty()) - .map(box => ({ - source: box.output.targetAddress.unwrap().uuid, - target: masterBusBoxUUID - })), - ...audioUnitBoxes - .map(box => ({ - source: box.collection.targetAddress.unwrap("AudioUnitBox was not connected to a RootBox").uuid, - target: rootBoxUUID - })), - ...audioUnitBoxes - .map(box => ({ - source: box.address.uuid, - target: UUID.generate() - })), - ...dependencies - .map(box => ({ - source: box.address.uuid, - target: box.resource === "preserved" ? box.address.uuid : UUID.generate() - })) - ]) - return uuidMap - } - - const copy = (uuidMap: SortedSet, - boxGraph: BoxGraph, - audioUnitBoxes: ReadonlyArray, - dependencies: ReadonlyArray) => { - const existingExternalResourceUUIDs = UUID.newSet(uuid => uuid) - dependencies.forEach((source: Box) => { - if (source.resource === "preserved" && boxGraph.findBox(source.address.uuid).nonEmpty()) { - existingExternalResourceUUIDs.add(source.address.uuid) - } - }) - const isOwnedByExistingExternalResource = (box: Box): boolean => { - for (const [pointer, targetAddress] of box.outgoingEdges()) { - if (pointer.mandatory && !targetAddress.isBox()) { - if (existingExternalResourceUUIDs.hasKey(targetAddress.uuid)) { - return true - } - } - } - return false - } - PointerField.decodeWith({ - map: (_pointer: PointerField, newAddress: Option
): Option
=> - newAddress.map(address => uuidMap.opt(address.uuid).match({ - none: () => address, - some: ({target}) => address.moveTo(target) - })) - }, () => { - audioUnitBoxes - .forEach((source: AudioUnitBox) => { - const input = new ByteArrayInput(source.toArrayBuffer()) - const key = source.name as keyof BoxIO.TypeMap - const uuid = uuidMap.get(source.address.uuid).target - boxGraph.createBox(key, uuid, box => box.read(input)) - }) - dependencies - .forEach((source: Box) => { - if (existingExternalResourceUUIDs.hasKey(source.address.uuid)) {return} - if (isOwnedByExistingExternalResource(source)) {return} - const input = new ByteArrayInput(source.toArrayBuffer()) - const key = source.name as keyof BoxIO.TypeMap - const uuid = uuidMap.get(source.address.uuid).target - boxGraph.createBox(key, uuid, box => box.read(input)) - }) - }) - } - - const reorderAudioUnits = (uuidMap: SortedSet, - audioUnitBoxes: ReadonlyArray, - rootBox: RootBox): void => audioUnitBoxes - .toSorted(compareIndex) - .map(source => (asInstanceOf(rootBox.graph - .findBox(uuidMap.get(source.address.uuid).target) - .unwrap("Target Track has not been copied"), AudioUnitBox))) - .forEach((target) => - IndexedBox.collectIndexedBoxes(rootBox.audioUnits, AudioUnitBox).toSorted((a, b) => { - const orderA = AudioUnitOrdering[a.type.getValue()] - const orderB = AudioUnitOrdering[b.type.getValue()] - const orderDifference = orderA - orderB - return orderDifference === 0 ? b === target ? -1 : 1 : orderDifference - }).forEach((box, index) => box.index.setValue(index))) -} \ No newline at end of file diff --git a/packages/studio/adapters/src/project/ProjectUtils.test.ts b/packages/studio/adapters/src/transfer/TransferAudioUnits.test.ts similarity index 73% rename from packages/studio/adapters/src/project/ProjectUtils.test.ts rename to packages/studio/adapters/src/transfer/TransferAudioUnits.test.ts index 51a1d52c..7beda771 100644 --- a/packages/studio/adapters/src/project/ProjectUtils.test.ts +++ b/packages/studio/adapters/src/transfer/TransferAudioUnits.test.ts @@ -10,11 +10,11 @@ import { TransientMarkerBox } from "@opendaw/studio-boxes" import {AudioUnitType} from "@opendaw/studio-enums" -import {ProjectSkeleton} from "./ProjectSkeleton" -import {ProjectUtils} from "./ProjectUtils" +import {ProjectSkeleton} from "../project/ProjectSkeleton" import {TrackType} from "../timeline/TrackType" +import {TransferAudioUnits} from "./TransferAudioUnits" -describe("ProjectUtils.extractAudioUnits", () => { +describe("TransferAudioUnits.transfer", () => { let skeleton: ProjectSkeleton beforeEach(() => { @@ -24,231 +24,186 @@ describe("ProjectUtils.extractAudioUnits", () => { }) }) - const createAudioUnitWithInstrument = (skeleton: ProjectSkeleton): { + const createAudioUnitWithInstrument = (target: ProjectSkeleton): { audioUnitBox: AudioUnitBox, instrumentBox: TapeDeviceBox, captureBox: CaptureAudioBox } => { - const {boxGraph, mandatoryBoxes: {rootBox, primaryAudioBus}} = skeleton - + const {boxGraph, mandatoryBoxes: {rootBox, primaryAudioBus}} = target let audioUnitBox!: AudioUnitBox let instrumentBox!: TapeDeviceBox let captureBox!: CaptureAudioBox - boxGraph.beginTransaction() - audioUnitBox = AudioUnitBox.create(boxGraph, UUID.generate(), box => { box.type.setValue(AudioUnitType.Instrument) box.collection.refer(rootBox.audioUnits) box.output.refer(primaryAudioBus.input) box.index.setValue(1) }) - captureBox = CaptureAudioBox.create(boxGraph, UUID.generate()) audioUnitBox.capture.refer(captureBox) - instrumentBox = TapeDeviceBox.create(boxGraph, UUID.generate(), box => { box.label.setValue("Test Tape") box.host.refer(audioUnitBox.input) }) - boxGraph.endTransaction() - return {audioUnitBox, instrumentBox, captureBox} } - const createAudioRegion = ( - skeleton: ProjectSkeleton, - audioUnitBox: AudioUnitBox - ): {trackBox: TrackBox, regionBox: AudioRegionBox, audioFileBox: AudioFileBox} => { - const {boxGraph} = skeleton - + const createAudioRegion = (target: ProjectSkeleton, audioUnitBox: AudioUnitBox): { + trackBox: TrackBox, + regionBox: AudioRegionBox, + audioFileBox: AudioFileBox + } => { + const {boxGraph} = target let trackBox!: TrackBox let regionBox!: AudioRegionBox let audioFileBox!: AudioFileBox - boxGraph.beginTransaction() - trackBox = TrackBox.create(boxGraph, UUID.generate(), box => { box.type.setValue(TrackType.Audio) box.tracks.refer(audioUnitBox.tracks) box.target.refer(audioUnitBox) box.index.setValue(0) }) - audioFileBox = AudioFileBox.create(boxGraph, UUID.generate(), box => { box.startInSeconds.setValue(0.0) box.endInSeconds.setValue(10.0) box.fileName.setValue("test-audio.wav") }) - regionBox = AudioRegionBox.create(boxGraph, UUID.generate(), box => { box.regions.refer(trackBox.regions) box.file.refer(audioFileBox) box.position.setValue(0) box.duration.setValue(1000) }) - boxGraph.endTransaction() - return {trackBox, regionBox, audioFileBox} } - const addTransientMarkers = (skeleton: ProjectSkeleton, audioFileBox: AudioFileBox, count: number): TransientMarkerBox[] => { - const {boxGraph} = skeleton + const addTransientMarkers = (target: ProjectSkeleton, audioFileBox: AudioFileBox, count: number): TransientMarkerBox[] => { + const {boxGraph} = target const markers: TransientMarkerBox[] = [] - boxGraph.beginTransaction() - for (let i = 0; i < count; i++) { + for (let index = 0; index < count; index++) { markers.push(TransientMarkerBox.create(boxGraph, UUID.generate(), box => { box.owner.refer(audioFileBox.transientMarkers) - box.position.setValue(i * 0.1) + box.position.setValue(index * 0.1) })) } boxGraph.endTransaction() - return markers } - it("should create new AudioUnitBox with new UUID", () => { + it("creates new AudioUnitBox with new UUID", () => { const {audioUnitBox} = createAudioUnitWithInstrument(skeleton) const originalUUID = audioUnitBox.address.uuid - skeleton.boxGraph.beginTransaction() - const [copiedAudioUnit] = ProjectUtils.extractAudioUnits([audioUnitBox], skeleton) + const [copiedAudioUnit] = TransferAudioUnits.transfer([audioUnitBox], skeleton) skeleton.boxGraph.endTransaction() - expect(copiedAudioUnit).toBeDefined() expect(UUID.equals(copiedAudioUnit.address.uuid, originalUUID)).toBe(false) }) - it("should copy instrument with AudioUnitBox", () => { + it("copies instrument with AudioUnitBox", () => { const {audioUnitBox, instrumentBox} = createAudioUnitWithInstrument(skeleton) const originalInstrumentUUID = instrumentBox.address.uuid - skeleton.boxGraph.beginTransaction() - const [copiedAudioUnit] = ProjectUtils.extractAudioUnits([audioUnitBox], skeleton) + const [copiedAudioUnit] = TransferAudioUnits.transfer([audioUnitBox], skeleton) skeleton.boxGraph.endTransaction() - - // Find the copied instrument const copiedInstrument = copiedAudioUnit.input.pointerHub.incoming().at(0)?.box expect(copiedInstrument).toBeDefined() expect(copiedInstrument).toBeInstanceOf(TapeDeviceBox) expect(UUID.equals(copiedInstrument!.address.uuid, originalInstrumentUUID)).toBe(false) }) - it("should preserve AudioFileBox UUID (shared resource)", () => { + it("preserves AudioFileBox UUID (shared resource)", () => { const {audioUnitBox} = createAudioUnitWithInstrument(skeleton) const {audioFileBox} = createAudioRegion(skeleton, audioUnitBox) const originalFileUUID = audioFileBox.address.uuid - skeleton.boxGraph.beginTransaction() - const [copiedAudioUnit] = ProjectUtils.extractAudioUnits([audioUnitBox], skeleton) + const [copiedAudioUnit] = TransferAudioUnits.transfer([audioUnitBox], skeleton) skeleton.boxGraph.endTransaction() - - // Find the copied region's file reference const copiedTrack = copiedAudioUnit.tracks.pointerHub.incoming().at(0)?.box as TrackBox const copiedRegion = copiedTrack?.regions.pointerHub.incoming().at(0)?.box as AudioRegionBox const copiedFileUUID = copiedRegion?.file.targetAddress.unwrap().uuid - expect(UUID.equals(copiedFileUUID, originalFileUUID)).toBe(true) }) - it("should not duplicate regions when copying twice", () => { + it("does not duplicate regions when copying twice", () => { const {audioUnitBox} = createAudioUnitWithInstrument(skeleton) createAudioRegion(skeleton, audioUnitBox) - - // First copy skeleton.boxGraph.beginTransaction() - const [firstCopy] = ProjectUtils.extractAudioUnits([audioUnitBox], skeleton) + const [firstCopy] = TransferAudioUnits.transfer([audioUnitBox], skeleton) skeleton.boxGraph.endTransaction() - - // Count regions in first copy const firstCopyTrack = firstCopy.tracks.pointerHub.incoming().at(0)?.box as TrackBox const firstCopyRegionCount = firstCopyTrack?.regions.pointerHub.incoming().length ?? 0 - - // Second copy skeleton.boxGraph.beginTransaction() - const [secondCopy] = ProjectUtils.extractAudioUnits([audioUnitBox], skeleton) + const [secondCopy] = TransferAudioUnits.transfer([audioUnitBox], skeleton) skeleton.boxGraph.endTransaction() - - // Count regions in second copy const secondCopyTrack = secondCopy.tracks.pointerHub.incoming().at(0)?.box as TrackBox const secondCopyRegionCount = secondCopyTrack?.regions.pointerHub.incoming().length ?? 0 - - // Verify first copy still has same number of regions (not modified) const firstCopyRegionCountAfter = firstCopyTrack?.regions.pointerHub.incoming().length ?? 0 - expect(firstCopyRegionCount).toBe(1) expect(secondCopyRegionCount).toBe(1) expect(firstCopyRegionCountAfter).toBe(1) }) - it("should not include regions from previous copies", () => { + it("does not include regions from previous copies", () => { const {audioUnitBox} = createAudioUnitWithInstrument(skeleton) createAudioRegion(skeleton, audioUnitBox) - - // Count total AudioRegionBoxes before copies const initialRegionCount = skeleton.boxGraph.boxes() .filter(box => box instanceof AudioRegionBox).length - - // First copy skeleton.boxGraph.beginTransaction() - ProjectUtils.extractAudioUnits([audioUnitBox], skeleton) + TransferAudioUnits.transfer([audioUnitBox], skeleton) skeleton.boxGraph.endTransaction() - const afterFirstCopyCount = skeleton.boxGraph.boxes() .filter(box => box instanceof AudioRegionBox).length - - // Second copy skeleton.boxGraph.beginTransaction() - ProjectUtils.extractAudioUnits([audioUnitBox], skeleton) + TransferAudioUnits.transfer([audioUnitBox], skeleton) skeleton.boxGraph.endTransaction() - const afterSecondCopyCount = skeleton.boxGraph.boxes() .filter(box => box instanceof AudioRegionBox).length - - // Each copy should add exactly 1 region expect(afterFirstCopyCount).toBe(initialRegionCount + 1) expect(afterSecondCopyCount).toBe(initialRegionCount + 2) }) - it("should skip TransientMarkerBox when AudioFileBox already exists", () => { + it("skips TransientMarkerBox when AudioFileBox already exists", () => { const {audioUnitBox} = createAudioUnitWithInstrument(skeleton) const {audioFileBox} = createAudioRegion(skeleton, audioUnitBox) addTransientMarkers(skeleton, audioFileBox, 5) - const initialMarkerCount = skeleton.boxGraph.boxes() .filter(box => box instanceof TransientMarkerBox).length - - // Copy should not duplicate transient markers skeleton.boxGraph.beginTransaction() - ProjectUtils.extractAudioUnits([audioUnitBox], skeleton) + TransferAudioUnits.transfer([audioUnitBox], skeleton) skeleton.boxGraph.endTransaction() - const afterCopyMarkerCount = skeleton.boxGraph.boxes() .filter(box => box instanceof TransientMarkerBox).length - expect(initialMarkerCount).toBe(5) - expect(afterCopyMarkerCount).toBe(5) // No new markers created + expect(afterCopyMarkerCount).toBe(5) }) - it("should handle multiple copies without exponential growth", () => { + it("handles multiple copies without exponential growth", () => { const {audioUnitBox} = createAudioUnitWithInstrument(skeleton) createAudioRegion(skeleton, audioUnitBox) - const copyCount = 5 - for (let i = 0; i < copyCount; i++) { + for (let index = 0; index < copyCount; index++) { skeleton.boxGraph.beginTransaction() - ProjectUtils.extractAudioUnits([audioUnitBox], skeleton) + TransferAudioUnits.transfer([audioUnitBox], skeleton) skeleton.boxGraph.endTransaction() } - - // Count all AudioRegionBoxes const totalRegionCount = skeleton.boxGraph.boxes() .filter(box => box instanceof AudioRegionBox).length - - // Should be: 1 original + 5 copies = 6 total expect(totalRegionCount).toBe(1 + copyCount) }) + + it("copies to target graph in cross-project scenario", () => { + const source = ProjectSkeleton.empty({createDefaultUser: false, createOutputCompressor: false}) + const {audioUnitBox} = createAudioUnitWithInstrument(source) + skeleton.boxGraph.beginTransaction() + const [copiedAudioUnit] = TransferAudioUnits.transfer([audioUnitBox], skeleton) + skeleton.boxGraph.endTransaction() + expect(copiedAudioUnit.graph).toBe(skeleton.boxGraph) + expect(copiedAudioUnit.graph).not.toBe(source.boxGraph) + }) }) diff --git a/packages/studio/adapters/src/transfer/TransferAudioUnits.ts b/packages/studio/adapters/src/transfer/TransferAudioUnits.ts new file mode 100644 index 00000000..734e3631 --- /dev/null +++ b/packages/studio/adapters/src/transfer/TransferAudioUnits.ts @@ -0,0 +1,36 @@ +import {asInstanceOf} from "@opendaw/lib-std" +import {Box} from "@opendaw/lib-box" +import {AudioUnitBox} from "@opendaw/studio-boxes" +import {ProjectSkeleton} from "../project/ProjectSkeleton" +import {TransferUtils} from "./TransferUtils" + +export namespace TransferAudioUnits { + /** + * Copies audio units and their dependencies to a target project. + * Preserved resources already present in the target graph are shared, not duplicated. + * @returns the newly created audio unit boxes in the target graph + */ + export const transfer = (audioUnitBoxes: ReadonlyArray, + {boxGraph: targetBoxGraph, mandatoryBoxes: {primaryAudioBus, rootBox}}: ProjectSkeleton, + options: { + includeAux?: boolean, + includeBus?: boolean, + excludeTimeline?: boolean, + } = {}): ReadonlyArray => { + const excludeBox = (box: Box): boolean => + TransferUtils.shouldExclude(box) + || (options?.excludeTimeline === true && TransferUtils.excludeTimelinePredicate(box)) + const dependencies = Array.from(audioUnitBoxes[0].graph.dependenciesOf(audioUnitBoxes, { + alwaysFollowMandatory: true, + stopAtResources: true, + excludeBox + }).boxes) + const uuidMap = TransferUtils.generateMap( + audioUnitBoxes, dependencies, rootBox.audioUnits.address.uuid, primaryAudioBus.address.uuid) + TransferUtils.copyBoxes(uuidMap, targetBoxGraph, audioUnitBoxes, dependencies) + TransferUtils.reorderAudioUnits(uuidMap, audioUnitBoxes, rootBox) + return audioUnitBoxes.map(source => asInstanceOf(rootBox.graph + .findBox(uuidMap.get(source.address.uuid).target) + .unwrap("Target AudioUnit has not been copied"), AudioUnitBox)) + } +} diff --git a/packages/studio/core/src/ui/transfer/TransferRegions.test.ts b/packages/studio/adapters/src/transfer/TransferRegions.test.ts similarity index 79% rename from packages/studio/core/src/ui/transfer/TransferRegions.test.ts rename to packages/studio/adapters/src/transfer/TransferRegions.test.ts index bf93556e..e04920a0 100644 --- a/packages/studio/core/src/ui/transfer/TransferRegions.test.ts +++ b/packages/studio/adapters/src/transfer/TransferRegions.test.ts @@ -1,46 +1,26 @@ -import "../../polyfill" -import {afterEach, beforeEach, describe, expect, it} from "vitest" -import {Terminable, UUID} from "@opendaw/lib-std" +import {describe, expect, it, beforeEach} from "vitest" +import {UUID} from "@opendaw/lib-std" import {AudioUnitBox, CaptureAudioBox, TrackBox, ValueEventCollectionBox, ValueRegionBox} from "@opendaw/studio-boxes" import {AudioUnitType} from "@opendaw/studio-enums" -import {ProjectSkeleton, TrackType} from "@opendaw/studio-adapters" -import {Project, ProjectEnv} from "../../project" +import {ProjectSkeleton} from "../project/ProjectSkeleton" +import {TrackType} from "../timeline/TrackType" import {TransferRegions} from "./TransferRegions" -const createEnv = (): ProjectEnv => ({ - audioContext: {} as AudioContext, - audioWorklets: {} as ProjectEnv["audioWorklets"], - sampleManager: { - getOrCreate: () => ({}) as any, - record: () => {}, - remove: () => {}, - invalidate: () => {}, - register: () => Terminable.Empty - }, - soundfontManager: { - getOrCreate: () => ({}) as any, - remove: () => {}, - invalidate: () => {} - } -}) +const createSkeleton = (): ProjectSkeleton => + ProjectSkeleton.empty({createDefaultUser: true, createOutputCompressor: false}) -const createProject = (): Project => { - const skeleton = ProjectSkeleton.empty({createDefaultUser: true, createOutputCompressor: false}) - return Project.fromSkeleton(createEnv(), skeleton, false) -} - -const createTrackWithRegion = (project: Project, position: number = 100, duration: number = 200): { +const createTrackWithRegion = (skeleton: ProjectSkeleton, position: number = 100, duration: number = 200): { audioUnitBox: AudioUnitBox, trackBox: TrackBox, regionBox: ValueRegionBox, collectionBox: ValueEventCollectionBox } => { - const {boxGraph} = project + const {boxGraph, mandatoryBoxes: {rootBox, primaryAudioBus}} = skeleton const captureBox = CaptureAudioBox.create(boxGraph, UUID.generate()) const audioUnitBox = AudioUnitBox.create(boxGraph, UUID.generate(), box => { box.type.setValue(AudioUnitType.Instrument) - box.collection.refer(project.rootBox.audioUnits) - box.output.refer(project.masterBusBox.input) + box.collection.refer(rootBox.audioUnits) + box.output.refer(primaryAudioBus.input) box.capture.refer(captureBox) box.index.setValue(1) }) @@ -63,16 +43,15 @@ const createTrackWithRegion = (project: Project, position: number = 100, duratio } describe("TransferRegions.transfer", () => { - let project: Project + let skeleton: ProjectSkeleton - beforeEach(() => {project = createProject()}) - afterEach(() => project.terminate()) + beforeEach(() => {skeleton = createSkeleton()}) describe("same graph", () => { it("copies region to same track at new position", () => { - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox, regionBox} = createTrackWithRegion(project) + const {trackBox, regionBox} = createTrackWithRegion(skeleton) boxGraph.endTransaction() boxGraph.beginTransaction() const copied = TransferRegions.transfer(regionBox, trackBox, 500, false) @@ -84,9 +63,9 @@ describe("TransferRegions.transfer", () => { }) it("copies region to different track", () => { - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox: trackA, regionBox, audioUnitBox} = createTrackWithRegion(project) + const {trackBox: trackA, regionBox, audioUnitBox} = createTrackWithRegion(skeleton) const trackB = TrackBox.create(boxGraph, UUID.generate(), box => { box.type.setValue(TrackType.Value) box.tracks.refer(audioUnitBox.tracks) @@ -106,9 +85,9 @@ describe("TransferRegions.transfer", () => { }) it("creates new UUID for copied region", () => { - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox, regionBox} = createTrackWithRegion(project) + const {trackBox, regionBox} = createTrackWithRegion(skeleton) boxGraph.endTransaction() boxGraph.beginTransaction() const copied = TransferRegions.transfer(regionBox, trackBox, 500, false) @@ -117,9 +96,9 @@ describe("TransferRegions.transfer", () => { }) it("preserves region properties", () => { - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox, regionBox} = createTrackWithRegion(project) + const {trackBox, regionBox} = createTrackWithRegion(skeleton) boxGraph.endTransaction() boxGraph.beginTransaction() const copied = TransferRegions.transfer(regionBox, trackBox, 500, false) @@ -130,9 +109,9 @@ describe("TransferRegions.transfer", () => { }) it("deletes source when deleteSource is true", () => { - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox, regionBox} = createTrackWithRegion(project) + const {trackBox, regionBox} = createTrackWithRegion(skeleton) boxGraph.endTransaction() boxGraph.beginTransaction() TransferRegions.transfer(regionBox, trackBox, 500) @@ -143,9 +122,9 @@ describe("TransferRegions.transfer", () => { }) it("keeps source when deleteSource is false", () => { - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox, regionBox} = createTrackWithRegion(project) + const {trackBox, regionBox} = createTrackWithRegion(skeleton) boxGraph.endTransaction() boxGraph.beginTransaction() TransferRegions.transfer(regionBox, trackBox, 500, false) @@ -155,9 +134,9 @@ describe("TransferRegions.transfer", () => { }) it("copies event collection dependency with new UUID", () => { - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox, regionBox, collectionBox} = createTrackWithRegion(project) + const {trackBox, regionBox, collectionBox} = createTrackWithRegion(skeleton) boxGraph.endTransaction() boxGraph.beginTransaction() const copied = TransferRegions.transfer(regionBox, trackBox, 500, false) @@ -170,19 +149,18 @@ describe("TransferRegions.transfer", () => { }) describe("cross graph", () => { - let sourceProject: Project + let sourceSkeleton: ProjectSkeleton - beforeEach(() => {sourceProject = createProject()}) - afterEach(() => sourceProject.terminate()) + beforeEach(() => {sourceSkeleton = createSkeleton()}) it("copies region to target graph at given position", () => { - const sourceGraph = sourceProject.boxGraph + const sourceGraph = sourceSkeleton.boxGraph sourceGraph.beginTransaction() - const {regionBox: sourceRegion} = createTrackWithRegion(sourceProject) + const {regionBox: sourceRegion} = createTrackWithRegion(sourceSkeleton) sourceGraph.endTransaction() - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox: targetTrack} = createTrackWithRegion(project) + const {trackBox: targetTrack} = createTrackWithRegion(skeleton) boxGraph.endTransaction() const regionsBefore = targetTrack.regions.pointerHub.incoming().length boxGraph.beginTransaction() @@ -197,13 +175,13 @@ describe("TransferRegions.transfer", () => { }) it("creates new UUID for copied region", () => { - const sourceGraph = sourceProject.boxGraph + const sourceGraph = sourceSkeleton.boxGraph sourceGraph.beginTransaction() - const {regionBox: sourceRegion} = createTrackWithRegion(sourceProject) + const {regionBox: sourceRegion} = createTrackWithRegion(sourceSkeleton) sourceGraph.endTransaction() - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox: targetTrack} = createTrackWithRegion(project) + const {trackBox: targetTrack} = createTrackWithRegion(skeleton) boxGraph.endTransaction() boxGraph.beginTransaction() const copied = TransferRegions.transfer(sourceRegion, targetTrack, 500, false) @@ -212,13 +190,13 @@ describe("TransferRegions.transfer", () => { }) it("preserves region properties", () => { - const sourceGraph = sourceProject.boxGraph + const sourceGraph = sourceSkeleton.boxGraph sourceGraph.beginTransaction() - const {regionBox: sourceRegion} = createTrackWithRegion(sourceProject, 100, 300) + const {regionBox: sourceRegion} = createTrackWithRegion(sourceSkeleton, 100, 300) sourceGraph.endTransaction() - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox: targetTrack} = createTrackWithRegion(project) + const {trackBox: targetTrack} = createTrackWithRegion(skeleton) boxGraph.endTransaction() boxGraph.beginTransaction() const copied = TransferRegions.transfer(sourceRegion, targetTrack, 500, false) @@ -229,13 +207,13 @@ describe("TransferRegions.transfer", () => { }) it("keeps source in source graph when deleteSource is false", () => { - const sourceGraph = sourceProject.boxGraph + const sourceGraph = sourceSkeleton.boxGraph sourceGraph.beginTransaction() - const {regionBox: sourceRegion} = createTrackWithRegion(sourceProject) + const {regionBox: sourceRegion} = createTrackWithRegion(sourceSkeleton) sourceGraph.endTransaction() - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox: targetTrack} = createTrackWithRegion(project) + const {trackBox: targetTrack} = createTrackWithRegion(skeleton) boxGraph.endTransaction() boxGraph.beginTransaction() TransferRegions.transfer(sourceRegion, targetTrack, 500, false) @@ -245,13 +223,13 @@ describe("TransferRegions.transfer", () => { }) it("deletes source when deleteSource is true", () => { - const sourceGraph = sourceProject.boxGraph + const sourceGraph = sourceSkeleton.boxGraph sourceGraph.beginTransaction() - const {regionBox: sourceRegion} = createTrackWithRegion(sourceProject) + const {regionBox: sourceRegion} = createTrackWithRegion(sourceSkeleton) sourceGraph.endTransaction() - const {boxGraph} = project + const {boxGraph} = skeleton boxGraph.beginTransaction() - const {trackBox: targetTrack} = createTrackWithRegion(project) + const {trackBox: targetTrack} = createTrackWithRegion(skeleton) boxGraph.endTransaction() sourceGraph.beginTransaction() boxGraph.beginTransaction() diff --git a/packages/studio/core/src/ui/transfer/TransferRegions.ts b/packages/studio/adapters/src/transfer/TransferRegions.ts similarity index 98% rename from packages/studio/core/src/ui/transfer/TransferRegions.ts rename to packages/studio/adapters/src/transfer/TransferRegions.ts index 2f4eb0d7..0ae0649b 100644 --- a/packages/studio/core/src/ui/transfer/TransferRegions.ts +++ b/packages/studio/adapters/src/transfer/TransferRegions.ts @@ -3,7 +3,7 @@ import {Box, PointerField} from "@opendaw/lib-box" import {ppqn} from "@opendaw/lib-dsp" import {Pointers} from "@opendaw/studio-enums" import {BoxIO, TrackBox} from "@opendaw/studio-boxes" -import {AnyRegionBox, UnionBoxTypes} from "@opendaw/studio-adapters" +import {AnyRegionBox, UnionBoxTypes} from "../unions" export namespace TransferRegions { /** @@ -76,4 +76,4 @@ export namespace TransferRegions { if (deleteSource) {region.delete()} return asDefined(result, "Failed to create region copy") } -} \ No newline at end of file +} diff --git a/packages/studio/adapters/src/transfer/TransferUtils.ts b/packages/studio/adapters/src/transfer/TransferUtils.ts new file mode 100644 index 00000000..e48aa512 --- /dev/null +++ b/packages/studio/adapters/src/transfer/TransferUtils.ts @@ -0,0 +1,164 @@ +import {ppqn} from "@opendaw/lib-dsp" +import { + Arrays, + asInstanceOf, + assert, + ByteArrayInput, + isInstanceOf, + Option, + Predicate, + SetMultimap, + SortedSet, + UUID +} from "@opendaw/lib-std" +import {AudioUnitBox, AuxSendBox, BoxIO, BoxVisitor, RootBox, TrackBox} from "@opendaw/studio-boxes" +import {Address, Box, BoxGraph, IndexedBox, PointerField} from "@opendaw/lib-box" +import {ProjectSkeleton} from "../project/ProjectSkeleton" +import {AnyRegionBox, UnionBoxTypes} from "../unions" +import {AudioUnitOrdering} from "../factories/AudioUnitOrdering" + +export namespace TransferUtils { + export type UUIDMapper = { source: UUID.Bytes, target: UUID.Bytes } + + const isSameGraph = ({graph: a}: Box, {graph: b}: Box): boolean => a === b + const compareIndex = (a: IndexedBox, b: IndexedBox) => a.index.getValue() - b.index.getValue() + export const excludeTimelinePredicate = (box: Box): boolean => + box.accept>({visitTrackBox: () => true}) === true + export const shouldExclude = (box: Box): boolean => box.ephemeral || box.name === AuxSendBox.ClassName + + export const generateMap = (audioUnitBoxes: ReadonlyArray, + dependencies: ReadonlyArray, + rootBoxUUID: UUID.Bytes, + masterBusBoxUUID: UUID.Bytes): SortedSet => { + const uuidMap = UUID.newSet(({source}) => source) + uuidMap.addMany([ + ...audioUnitBoxes + .filter(({output: {targetAddress}}) => targetAddress.nonEmpty()) + .map(box => ({ + source: box.output.targetAddress.unwrap().uuid, + target: masterBusBoxUUID + })), + ...audioUnitBoxes + .map(box => ({ + source: box.collection.targetAddress.unwrap("AudioUnitBox was not connected to a RootBox").uuid, + target: rootBoxUUID + })), + ...audioUnitBoxes + .map(box => ({ + source: box.address.uuid, + target: UUID.generate() + })), + ...dependencies + .map(box => ({ + source: box.address.uuid, + target: box.resource === "preserved" ? box.address.uuid : UUID.generate() + })) + ]) + return uuidMap + } + + export const copyBoxes = (uuidMap: SortedSet, + targetBoxGraph: BoxGraph, + audioUnitBoxes: ReadonlyArray, + dependencies: ReadonlyArray): void => { + const existingPreservedUuids = UUID.newSet(uuid => uuid) + dependencies.forEach((source: Box) => { + if (source.resource === "preserved" && targetBoxGraph.findBox(source.address.uuid).nonEmpty()) { + existingPreservedUuids.add(source.address.uuid) + } + }) + const isOwnedByExistingPreserved = (box: Box): boolean => { + for (const [pointer, targetAddress] of box.outgoingEdges()) { + if (pointer.mandatory && !targetAddress.isBox()) { + if (existingPreservedUuids.hasKey(targetAddress.uuid)) {return true} + } + } + return false + } + PointerField.decodeWith({ + map: (_pointer: PointerField, address: Option
): Option
=> + address.map(addr => uuidMap.opt(addr.uuid).match({ + none: () => addr, + some: ({target}) => addr.moveTo(target) + })) + }, () => { + audioUnitBoxes.forEach((source: AudioUnitBox) => { + const input = new ByteArrayInput(source.toArrayBuffer()) + const uuid = uuidMap.get(source.address.uuid).target + targetBoxGraph.createBox(source.name as keyof BoxIO.TypeMap, uuid, box => box.read(input)) + }) + dependencies.forEach((source: Box) => { + if (existingPreservedUuids.hasKey(source.address.uuid)) {return} + if (isOwnedByExistingPreserved(source)) {return} + const input = new ByteArrayInput(source.toArrayBuffer()) + const uuid = uuidMap.get(source.address.uuid).target + targetBoxGraph.createBox(source.name as keyof BoxIO.TypeMap, uuid, box => box.read(input)) + }) + }) + } + + export const reorderAudioUnits = (uuidMap: SortedSet, + audioUnitBoxes: ReadonlyArray, + rootBox: RootBox): void => audioUnitBoxes + .toSorted(compareIndex) + .map(source => asInstanceOf(rootBox.graph + .findBox(uuidMap.get(source.address.uuid).target) + .unwrap("Target AudioUnit has not been copied"), AudioUnitBox)) + .forEach((target) => + IndexedBox.collectIndexedBoxes(rootBox.audioUnits, AudioUnitBox).toSorted((a, b) => { + const orderA = AudioUnitOrdering[a.type.getValue()] + const orderB = AudioUnitOrdering[b.type.getValue()] + const orderDifference = orderA - orderB + return orderDifference === 0 ? b === target ? -1 : 1 : orderDifference + }).forEach((box, index) => box.index.setValue(index))) + + export const extractRegions = (regionBoxes: ReadonlyArray, + {boxGraph, mandatoryBoxes: {primaryAudioBus, rootBox}}: ProjectSkeleton, + insertPosition: ppqn = 0.0): void => { + assert(Arrays.satisfy(regionBoxes, isSameGraph), + "Region smust be from the same BoxGraph") + const regionBoxSet = new Set(regionBoxes) + const trackBoxSet = new Set() + const audioUnitBoxSet = new SetMultimap() + regionBoxes.forEach(regionBox => { + const trackBox = asInstanceOf(regionBox.regions.targetVertex.unwrap().box, TrackBox) + trackBoxSet.add(trackBox) + const audioUnitBox = asInstanceOf(trackBox.tracks.targetVertex.unwrap().box, AudioUnitBox) + audioUnitBoxSet.add(audioUnitBox, trackBox) + }) + console.debug(`Found ${audioUnitBoxSet.keyCount()} audioUnits`) + console.debug(`Found ${trackBoxSet.size} tracks`) + const audioUnitBoxes = [...audioUnitBoxSet.keys()] + const excludeBox: Predicate = (box: Box): boolean => + shouldExclude(box) + || (isInstanceOf(box, TrackBox) && !trackBoxSet.has(box)) + || (UnionBoxTypes.isRegionBox(box) && !regionBoxSet.has(box)) + const dependencies = Array.from(audioUnitBoxes[0].graph.dependenciesOf(audioUnitBoxes, { + alwaysFollowMandatory: true, + stopAtResources: true, + excludeBox + }).boxes) + const uuidMap = generateMap( + audioUnitBoxes, dependencies, rootBox.audioUnits.address.uuid, primaryAudioBus.address.uuid) + copyBoxes(uuidMap, boxGraph, audioUnitBoxes, dependencies) + reorderAudioUnits(uuidMap, audioUnitBoxes, rootBox) + audioUnitBoxSet.forEach((_, trackBoxes) => [...trackBoxes] + .sort(compareIndex) + .forEach((source: TrackBox, index) => { + const box = boxGraph + .findBox(uuidMap.get(source.address.uuid).target) + .unwrap("Target Track has not been copied") + asInstanceOf(box, TrackBox).index.setValue(index) + })) + const minPosition = regionBoxes.reduce((min, region) => + Math.min(min, region.position.getValue()), Number.MAX_VALUE) + const delta = insertPosition - minPosition + regionBoxes.forEach((source: AnyRegionBox) => { + const box = boxGraph + .findBox(uuidMap.get(source.address.uuid).target) + .unwrap("Target Track has not been copied") + const {position} = UnionBoxTypes.asRegionBox(box) + position.setValue(position.getValue() + delta) + }) + } +} diff --git a/packages/studio/adapters/src/transfer/index.ts b/packages/studio/adapters/src/transfer/index.ts new file mode 100644 index 00000000..7c63067b --- /dev/null +++ b/packages/studio/adapters/src/transfer/index.ts @@ -0,0 +1,3 @@ +export * from "./TransferAudioUnits" +export * from "./TransferRegions" +export * from "./TransferUtils" diff --git a/packages/studio/core/src/ui/index.ts b/packages/studio/core/src/ui/index.ts index ca02631d..db1db702 100644 --- a/packages/studio/core/src/ui/index.ts +++ b/packages/studio/core/src/ui/index.ts @@ -16,4 +16,3 @@ export * from "./timeline/TrackResolver" export * from "./timeline/TimeGrid" export * from "./timeline/TimelineFocus" export * from "./timeline/TimelineRange" -export * from "./transfer" \ No newline at end of file diff --git a/packages/studio/core/src/ui/transfer/index.ts b/packages/studio/core/src/ui/transfer/index.ts deleted file mode 100644 index 07f50279..00000000 --- a/packages/studio/core/src/ui/transfer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./TransferRegions" \ No newline at end of file diff --git a/plans/copy-and-paste.md b/plans/copy-and-paste.md index ad0e1d5a..f9156b18 100644 --- a/plans/copy-and-paste.md +++ b/plans/copy-and-paste.md @@ -679,7 +679,7 @@ describe("CopyBuffer", () => { | `lib/box/src/graph.ts` | Modify | Add `stopAtResources` option to `dependenciesOf` | | `forge-boxes/src/schema/std/AudioFileBox.ts` | Modify | Add `resource: "external"` | | `forge-boxes/src/schema/std/SoundfontFileBox.ts` | Modify | Add `resource: "external"` | -| `adapters/src/project/ProjectUtils.ts` | Modify | Refactor to use `stopAtResources` | +| `adapters/src/project/TransferUtils.ts` | Modify | Refactor to use `stopAtResources` | | `adapters/src/project/CopyBuffer.ts` | Create | CopyBuffer type + serialize/deserialize + clipboard read/write | | `lib/box/src/graph.test.ts` | Modify | Add tests for `stopAtResources` | | `adapters/src/project/ProjectUtils.test.ts` | Modify | Refactor tests to use new resource handling | @@ -713,7 +713,7 @@ Phase 3: Dependency Collection │ ▼ Phase 4: Refactor ProjectUtils - └── ProjectUtils.ts + └── TransferUtils.ts │ ▼ Phase 5: Copy/Paste API