Skip to content
Closed
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
77 changes: 77 additions & 0 deletions Sources/PalmierPro/Editor/ViewModel/EditorViewModel+Ripple.swift
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,83 @@ extension EditorViewModel {
return created
}

func rippleMoveClips(_ moves: [(clipId: String, toTrack: Int, toFrame: Int)]) {
var infos: [(clip: Clip, toTrack: Int, toFrame: Int)] = []
for m in moves {
guard let loc = findClip(id: m.clipId),
timeline.tracks.indices.contains(m.toTrack) else { continue }
let clip = timeline.tracks[loc.trackIndex].clips[loc.clipIndex]
guard timeline.tracks[m.toTrack].type.isCompatible(with: timeline.tracks[loc.trackIndex].type) else { continue }
infos.append((clip, m.toTrack, max(0, m.toFrame)))
}
guard !infos.isEmpty else { return }

let insertFrame = infos.map(\.toFrame).min()!
let pushAmount = infos.map { $0.toFrame + $0.clip.durationFrames }.max()! - insertFrame
guard pushAmount > 0 else { return }

if let reason = multicamMoveViolation(moves: infos.map { ($0.clip.id, $0.toTrack, $0.toFrame) }) {
refuseWithToast(reason)
return
}
let destTracks = Set(infos.map(\.toTrack))
let pushTrackIndexes = timeline.tracks.indices.filter { destTracks.contains($0) || timeline.tracks[$0].syncLocked }
if let reason = multicamManualRippleViolation(
shiftingTrackIds: Set(pushTrackIndexes.map { timeline.tracks[$0].id }),
atFrame: insertFrame
) {
refuseRipple(reason: reason)
return
}

withTimelineSwap(actionName: infos.count == 1 ? "Ripple Move Clip" : "Ripple Move Clips") {
let toTrackIds = infos.map { timeline.tracks[$0.toTrack].id }
let pushTrackIds = pushTrackIndexes.map { timeline.tracks[$0].id }

for info in infos {
if let loc = findClip(id: info.clip.id) {
timeline.tracks[loc.trackIndex].clips.remove(at: loc.clipIndex)
}
}

var splitRightIds: [String] = []
for tid in pushTrackIds {
guard let ti = timeline.tracks.firstIndex(where: { $0.id == tid }) else { continue }
if let straddler = timeline.tracks[ti].clips.first(where: {
$0.startFrame < insertFrame && insertFrame < $0.endFrame
}) {
splitRightIds += splitClip(clipId: straddler.id, atFrame: insertFrame)
}
}

for tid in pushTrackIds {
guard let ti = timeline.tracks.firstIndex(where: { $0.id == tid }) else { continue }
applyShifts(RippleEngine.computeRipplePush(
clips: timeline.tracks[ti].clips,
insertFrame: insertFrame,
pushAmount: pushAmount
))
}

// splitClip also splits linked partners; push their right halves on tracks outside the push set.
let pushTrackIdSet = Set(pushTrackIds)
for id in splitRightIds {
guard let loc = findClip(id: id),
!pushTrackIdSet.contains(timeline.tracks[loc.trackIndex].id) else { continue }
timeline.tracks[loc.trackIndex].clips[loc.clipIndex].startFrame += pushAmount
}

for (i, info) in infos.enumerated() {
guard let idx = timeline.tracks.firstIndex(where: { $0.id == toTrackIds[i] }) else { continue }
var clip = info.clip
clip.startFrame = info.toFrame
timeline.tracks[idx].clips.append(clip)
}
for i in timeline.tracks.indices { sortClips(trackIndex: i) }
pruneEmptyTracks()
}
}

// MARK: - Internal

fileprivate func trimClipInternal(clipId: String, trimStartFrame: Int, trimEndFrame: Int, protecting: Set<String> = []) {
Expand Down
2 changes: 1 addition & 1 deletion Sources/PalmierPro/Help/ShortcutsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct ShortcutsPane: View {
("Backspace", "Delete"),
("Shift + Backspace", "Ripple Delete"),
("Shift + Drag Edge", "Ripple Trim"),
("Cmd + Drag Media", "Ripple Insert"),
("Cmd + Drag", "Ripple Insert"),
("Opt + Drag", "Duplicate Clip"),
]),
ShortcutGroup(title: "Timeline", shortcuts: [
Expand Down
1 change: 1 addition & 0 deletions Sources/PalmierPro/Timeline/DragState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ enum DragState {
var deltaFrames: Int = 0
var dropTarget: TrackDropTarget
let isDuplicate: Bool
var isRipple: Bool = false

var all: [Participant] { [lead] + companions }

Expand Down
73 changes: 52 additions & 21 deletions Sources/PalmierPro/Timeline/TimelineInputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ final class TimelineInputController {
companions: companions,
grabOffsetFrames: grabFrame - clip.startFrame,
dropTarget: .existingTrack(hit.trackIndex),
isDuplicate: isOption
isDuplicate: isOption,
isRipple: isCommand && !isOption
))
}
} else {
Expand Down Expand Up @@ -278,6 +279,7 @@ final class TimelineInputController {
editor.setTimelineRange(startFrame: drag.anchorFrame, endFrame: rangeEndFrame)

case .moveClip(var drag):
drag.isRipple = event.modifierFlags.contains(.command) && !drag.isDuplicate
let candidateFrame = frame - drag.grabOffsetFrames
let allDraggedIds = Set(drag.all.map(\.clipId))
let targets = SnapEngine.collectTargets(
Expand Down Expand Up @@ -441,40 +443,43 @@ final class TimelineInputController {
view.needsDisplay = true
}

// MARK: - Flags changed

func flagsChanged(with event: NSEvent) {
guard case .moveClip(var drag) = dragState else { return }
let isRipple = event.modifierFlags.contains(.command) && !drag.isDuplicate
guard isRipple != drag.isRipple else { return }
drag.isRipple = isRipple
dragState = .moveClip(drag)
view.needsDisplay = true
}

// MARK: - Mouse up

func mouseUp(with event: NSEvent, geometry: TimelineGeometry) {
stopPlayheadAutoScroll()

switch dragState {
case .moveClip(let drag):
case .moveClip(var drag):
drag.isRipple = event.modifierFlags.contains(.command) && !drag.isDuplicate
if case .existingTrack(let idx) = drag.dropTarget,
idx == drag.lead.originalTrack, drag.deltaFrames == 0 {
break
}

let resolved = resolvedMoveParticipants(for: drag)
guard let resolvedLead = resolved.first(where: { $0.participant.clipId == drag.lead.clipId }) else {
break
}
let minOrigFrame = resolved.map { $0.frame }.min()!
let frameDelta = max(-minOrigFrame, drag.deltaFrames)
let pinned = pinnedCompanionIds(for: drag)
let leadTrack = resolvedLead.trackIndex

switch drag.dropTarget {
case .existingTrack:
// Rigid translation: non-pinned shift by trackDelta; pinned hold their row.
let delta = drag.dropTargetTrackIndex.map { $0 - leadTrack } ?? 0
let moves = resolved.map { item in
let p = item.participant
let toTrack = pinned.contains(p.clipId) ? item.trackIndex : item.trackIndex + delta
return (clipId: p.clipId, toTrack: toTrack, toFrame: item.frame + frameDelta)
}
commitMoves(moves, isDuplicate: drag.isDuplicate)
guard let moves = existingTrackMoves(for: drag) else { break }
commitMoves(moves, isDuplicate: drag.isDuplicate, isRipple: drag.isRipple)

case .newTrackAt(let insertIndex):
guard let leadTrackType = resolvedLeadTrackType(for: drag) else { break }
let resolved = resolvedMoveParticipants(for: drag)
guard let resolvedLead = resolved.first(where: { $0.participant.clipId == drag.lead.clipId }),
let leadTrackType = resolvedLeadTrackType(for: drag) else { break }
let minOrigFrame = resolved.map { $0.frame }.min()!
let frameDelta = max(-minOrigFrame, drag.deltaFrames)
let pinned = pinnedCompanionIds(for: drag)
let leadTrack = resolvedLead.trackIndex
if !drag.isDuplicate,
let reason = editor.multicamMoveViolation(moves: resolved.map {
(clipId: $0.participant.clipId, toTrack: $0.trackIndex, toFrame: $0.frame + frameDelta)
Expand Down Expand Up @@ -985,14 +990,40 @@ final class TimelineInputController {
editor.seekToFrame(frame, mode: .interactiveScrub)
}

private func commitMoves(_ moves: [(clipId: String, toTrack: Int, toFrame: Int)], isDuplicate: Bool) {
private func commitMoves(_ moves: [(clipId: String, toTrack: Int, toFrame: Int)], isDuplicate: Bool, isRipple: Bool = false) {
if isDuplicate {
editor.duplicateClipsToPositions(moves)
} else if isRipple {
editor.rippleMoveClips(moves)
} else {
editor.moveClips(moves)
}
}

func existingTrackMoves(for drag: DragState.MoveClipDrag) -> [(clipId: String, toTrack: Int, toFrame: Int)]? {
let resolved = resolvedMoveParticipants(for: drag)
guard let resolvedLead = resolved.first(where: { $0.participant.clipId == drag.lead.clipId }) else {
return nil
}
let minOrigFrame = resolved.map { $0.frame }.min()!
let frameDelta = max(-minOrigFrame, drag.deltaFrames)
let pinned = pinnedCompanionIds(for: drag)
let delta = drag.dropTargetTrackIndex.map { $0 - resolvedLead.trackIndex } ?? 0
return resolved.map { item in
let p = item.participant
let toTrack = pinned.contains(p.clipId) ? item.trackIndex : item.trackIndex + delta
return (clipId: p.clipId, toTrack: toTrack, toFrame: item.frame + frameDelta)
}
}

func rippleMoveInsertFrame() -> Int? {
guard case .moveClip(let drag) = dragState,
drag.isRipple, !drag.isDuplicate,
case .existingTrack(let idx) = drag.dropTarget,
!(idx == drag.lead.originalTrack && drag.deltaFrames == 0) else { return nil }
return existingTrackMoves(for: drag)?.map(\.toFrame).min()
}

private func resolvedMoveParticipants(
for drag: DragState.MoveClipDrag
) -> [(participant: DragState.Participant, trackIndex: Int, frame: Int)] {
Expand Down
27 changes: 20 additions & 7 deletions Sources/PalmierPro/Timeline/TimelineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ final class TimelineView: NSView {
required init?(coder: NSCoder) { fatalError() }

override var isFlipped: Bool { true }
override var acceptsFirstResponder: Bool { true }

// MARK: - Viewport canvas

Expand Down Expand Up @@ -229,6 +230,8 @@ final class TimelineView: NSView {
drawRippleInsertIndicator(atFrame: externalDragFrame, geometry: geo, context: ctx)
drawRippleInsertBadge(atFrame: externalDragFrame, geometry: geo, scrollOffset: scrollOffset, visibleWidth: visibleWidth, context: ctx)
}
} else if let insertFrame = inputController.rippleMoveInsertFrame() {
drawRippleInsertIndicator(atFrame: insertFrame, geometry: geo, context: ctx)
}

if case .marquee(let marq) = inputController.dragState,
Expand Down Expand Up @@ -400,12 +403,18 @@ final class TimelineView: NSView {
}
clipDisplayRects[clip.id] = ghostRect
if ghostRect.intersects(dirtyRect) {
ClipRenderer.draw(ghostClip, type: clip.mediaType, in: ghostRect,
isSelected: true, opacity: 0.7, context: ctx,
cache: editor.mediaVisualCache,
displayName: editor.clipDisplayLabel(for: clip),
multicamAngleLabel: angleLabel(clip),
fps: editor.timeline.fps, isMissing: clipMissing, isGenerating: clipGenerating)
let chip = angleLabel(clip)
let cache = editor.mediaVisualCache
let name = editor.clipDisplayLabel(for: clip)
let fps = editor.timeline.fps
// Ghosts draw after all clips so they stay on top in either drag direction.
deferredDraws.append {
ClipRenderer.draw(ghostClip, type: clip.mediaType, in: ghostRect,
isSelected: true, opacity: 0.7, context: ctx,
cache: cache, displayName: name,
multicamAngleLabel: chip,
fps: fps, isMissing: clipMissing, isGenerating: clipGenerating)
}
}
continue
}
Expand Down Expand Up @@ -670,7 +679,6 @@ final class TimelineView: NSView {
let assets = externalDragAssets,
!assets.isEmpty,
let target = externalDropTarget else { return nil }

let plan = editor.resolveDropPlan(cursor: target, assets: assets, atFrame: externalDragFrame, segments: externalDragSegments)
return editor.planRippleInsertPreview(dropPlan: plan, atFrame: externalDragFrame)
}
Expand Down Expand Up @@ -799,6 +807,7 @@ final class TimelineView: NSView {
// MARK: - Input forwarding

override func mouseDown(with event: NSEvent) {
window?.makeFirstResponder(self)
inputController.mouseDown(with: event, geometry: geometry)
}

Expand All @@ -810,6 +819,10 @@ final class TimelineView: NSView {
inputController.mouseUp(with: event, geometry: geometry)
}

override func flagsChanged(with event: NSEvent) {
inputController.flagsChanged(with: event)
}

override func mouseMoved(with event: NSEvent) {
inputController.mouseMoved(with: event, geometry: geometry)
}
Expand Down
Loading
Loading