diff --git a/Sources/PalmierPro/Agent/Tools/ToolExecutor+Clips.swift b/Sources/PalmierPro/Agent/Tools/ToolExecutor+Clips.swift index fd4367e83..701137fc2 100644 --- a/Sources/PalmierPro/Agent/Tools/ToolExecutor+Clips.swift +++ b/Sources/PalmierPro/Agent/Tools/ToolExecutor+Clips.swift @@ -290,7 +290,26 @@ extension ToolExecutor { guard let trackIdx = editor.timeline.tracks.firstIndex(where: { $0.id == trackId }) else { throw ToolError("entries[\(i)]: destination track no longer exists") } - editor.clearRegion(trackIndex: trackIdx, start: spec.startFrame, end: spec.startFrame + spec.durationFrames, prune: false) + + // Collect linked partner track IDs before clearing. clearRegion calls splitClip + // which splits linked partners on other tracks, but then removeClips only removes + // the target-track fragment — leaving a stranded audio fragment that causes + // double playback and pushes the new audio onto a spurious extra track. + let rangeEnd = spec.startFrame + spec.durationFrames + let partnerTrackIds: Set = editor.timeline.tracks[trackIdx].type == .video + ? Set(editor.timeline.tracks[trackIdx].clips + .filter { $0.linkGroupId != nil && $0.startFrame < rangeEnd && $0.endFrame > spec.startFrame } + .flatMap { editor.linkedPartnerIds(of: $0.id) } + .compactMap { pid in editor.findClip(id: pid).map { editor.timeline.tracks[$0.trackIndex].id } }) + : [] + + editor.clearRegion(trackIndex: trackIdx, start: spec.startFrame, end: rangeEnd, prune: false) + for tid in partnerTrackIds { + if let idx = editor.timeline.tracks.firstIndex(where: { $0.id == tid }) { + editor.clearRegion(trackIndex: idx, start: spec.startFrame, end: rangeEnd, prune: false) + } + } + let ids = editor.placeClip( asset: spec.asset, trackIndex: trackIdx, startFrame: spec.startFrame, durationFrames: spec.durationFrames, diff --git a/Tests/PalmierProTests/Agent/ToolExecutorTests.swift b/Tests/PalmierProTests/Agent/ToolExecutorTests.swift index fc2c720e6..52c4f823c 100644 --- a/Tests/PalmierProTests/Agent/ToolExecutorTests.swift +++ b/Tests/PalmierProTests/Agent/ToolExecutorTests.swift @@ -954,6 +954,41 @@ struct ToolExecutorClipTests { #expect(musicTrack.element.clips.contains { $0.mediaRef == music.id && $0.linkGroupId == nil }) } + @Test func addClipsClearsLinkedAudioWhenOverwritingLinkedPair() async throws { + // Place an existing linked V+A clip [0, 90) via the editor, then use add_clips to + // overwrite the middle [30, 60). Without the fix, clearRegion splits the linked audio + // but removeClips only removes the video fragment, leaving a stranded audio [30, 60) + // on A1; placeClip then finds A1 occupied and creates a spurious A2 — double playback. + let h = ToolHarness() + _ = h.editor.insertTrack(at: 0, type: .video) + let existing = h.addAsset(id: "existing", type: .video, duration: 3.0, hasAudio: true) + _ = h.editor.placeClip(asset: existing, trackIndex: 0, startFrame: 0, durationFrames: 90) + // After placeClip: V1[0,90) linked with A1[0,90). Two tracks total. + #expect(h.editor.timeline.tracks.count == 2) + + let newAsset = h.addAsset(id: "new", type: .video, duration: 1.0, hasAudio: true) + let result = await h.runRaw("add_clips", args: [ + "entries": [[ + "mediaRef": newAsset.id, + "trackIndex": 0, + "startFrame": 30, + "endFrame": 60, + ]] + ]) + #expect(result.isError == false, "\(ToolHarness.textOf(result))") + + // No spurious third track should be created. + #expect(h.editor.timeline.tracks.count == 2) + + // A1 must have exactly 3 clips: old[0,30), new[30,60), old[60,90). + let audioTrack = h.editor.timeline.tracks[1] + #expect(audioTrack.clips.count == 3) + + // The clip in [30, 60) must belong to the new asset, not the stranded old audio. + let mid = audioTrack.clips.first { $0.startFrame == 30 } + #expect(mid?.mediaRef == newAsset.id) + } + // MARK: - remove_clips @Test func removeClipsDropsClipsByIds() async throws {