Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion Sources/PalmierPro/Agent/Tools/ToolExecutor+Clips.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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,
Expand Down
35 changes: 35 additions & 0 deletions Tests/PalmierProTests/Agent/ToolExecutorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading