|
| 1 | +import { endBatch, startBatch } from '../../historyStore'; |
| 2 | +import { Logger } from '../../../services/logger'; |
| 3 | +import type { TimelineClip, TimelineTrack } from '../../../types/timeline'; |
| 4 | +import { calculateTimelineDuration } from '../../../utils/speedIntegration'; |
| 5 | +import { clearProcessedAudioAnalysisRefs } from '../helpers/audioAnalysisStateHelpers'; |
| 6 | +import { |
| 7 | + CLIP_SPEED_MAX_MULTIPLIER, |
| 8 | + CLIP_SPEED_MIN_MULTIPLIER, |
| 9 | + isLinkedAudioFollowingVideo, |
| 10 | + resolveLinkedVideoAudioPair, |
| 11 | + resolveSpeedMutationTarget, |
| 12 | + synchronizeFollowerSpeedKeyframes, |
| 13 | +} from '../helpers/linkedClipSpeed'; |
| 14 | +import type { SetClipSpeedOptions } from '../storeTypes/clipSpeedActionTypes'; |
| 15 | +import type { ClipActionContext } from './clipActionContext'; |
| 16 | + |
| 17 | +const log = Logger.create('ClipSpeedActions'); |
| 18 | + |
| 19 | +function isClipOnLockedTrack( |
| 20 | + clips: readonly TimelineClip[], |
| 21 | + tracks: readonly TimelineTrack[], |
| 22 | + clipId: string, |
| 23 | +): boolean { |
| 24 | + const clip = clips.find(candidate => candidate.id === clipId); |
| 25 | + return !!clip && tracks.find(track => track.id === clip.trackId)?.locked === true; |
| 26 | +} |
| 27 | + |
| 28 | +export function toggleClipReverseAction( |
| 29 | + { set, get }: ClipActionContext, |
| 30 | + id: string, |
| 31 | +): void { |
| 32 | + const { clips, tracks, invalidateCache } = get(); |
| 33 | + const clip = clips.find(candidate => candidate.id === id); |
| 34 | + if (!clip) return; |
| 35 | + const pair = resolveLinkedVideoAudioPair(clips, id); |
| 36 | + const affectedIds = pair ? [pair.video.id, pair.audio.id] : [id]; |
| 37 | + if (affectedIds.some(clipId => isClipOnLockedTrack(clips, tracks, clipId))) { |
| 38 | + log.warn('Cannot reverse clip on locked track', { id }); |
| 39 | + return; |
| 40 | + } |
| 41 | + const reversed = !clip.reversed; |
| 42 | + set({ |
| 43 | + clips: clips.map(candidate => affectedIds.includes(candidate.id) |
| 44 | + ? { ...clearProcessedAudioAnalysisRefs(candidate), reversed } |
| 45 | + : candidate), |
| 46 | + }); |
| 47 | + invalidateCache(); |
| 48 | +} |
| 49 | + |
| 50 | +export function setClipSpeedAction( |
| 51 | + { set, get }: ClipActionContext, |
| 52 | + clipId: string, |
| 53 | + speed: number, |
| 54 | + options: SetClipSpeedOptions = {}, |
| 55 | +): boolean { |
| 56 | + const magnitude = Math.abs(speed); |
| 57 | + if ( |
| 58 | + !Number.isFinite(speed) || |
| 59 | + magnitude < CLIP_SPEED_MIN_MULTIPLIER || |
| 60 | + magnitude > CLIP_SPEED_MAX_MULTIPLIER |
| 61 | + ) { |
| 62 | + log.warn('Clip speed is outside the supported range', { clipId, speed }); |
| 63 | + return false; |
| 64 | + } |
| 65 | + |
| 66 | + const initialState = get(); |
| 67 | + const target = resolveSpeedMutationTarget(initialState.clips, clipId); |
| 68 | + if (!target) return false; |
| 69 | + const affectedIds = [target.leader.id, ...(target.follower ? [target.follower.id] : [])]; |
| 70 | + if (affectedIds.some(id => isClipOnLockedTrack(initialState.clips, initialState.tracks, id))) { |
| 71 | + log.warn('Cannot update linked clip speed on a locked track', { clipId, affectedIds }); |
| 72 | + return false; |
| 73 | + } |
| 74 | + |
| 75 | + const historyBatch = startBatch(target.follower ? 'Change linked clip speed' : 'Change clip speed'); |
| 76 | + try { |
| 77 | + const propertyHasKeyframes = initialState.hasKeyframes(target.leader.id, 'speed'); |
| 78 | + const shouldWriteKeyframe = initialState.isRecording(target.leader.id, 'speed') || propertyHasKeyframes; |
| 79 | + if (shouldWriteKeyframe) { |
| 80 | + get().addKeyframe(target.leader.id, 'speed', speed); |
| 81 | + } |
| 82 | + |
| 83 | + const currentState = get(); |
| 84 | + const currentLeader = currentState.clips.find(candidate => candidate.id === target.leader.id); |
| 85 | + if (!currentLeader) return false; |
| 86 | + const sourceDuration = currentLeader.outPoint - currentLeader.inPoint; |
| 87 | + const leaderKeyframes = currentState.clipKeyframes.get(currentLeader.id) ?? []; |
| 88 | + const speedKeyframes = leaderKeyframes.filter(keyframe => keyframe.property === 'speed'); |
| 89 | + const hasForwardSpeed = speedKeyframes.some(keyframe => keyframe.value > 0); |
| 90 | + const hasReverseSpeed = speedKeyframes.some(keyframe => keyframe.value < 0); |
| 91 | + const changesDirection = hasForwardSpeed && hasReverseSpeed; |
| 92 | + const duration = shouldWriteKeyframe |
| 93 | + ? changesDirection |
| 94 | + // Direction-changing curves are non-monotonic, so source time has no |
| 95 | + // unique inverse. Preserve the authored clip length for those ramps. |
| 96 | + ? currentLeader.duration |
| 97 | + : calculateTimelineDuration(leaderKeyframes, sourceDuration, speed) |
| 98 | + : sourceDuration / magnitude; |
| 99 | + |
| 100 | + let nextKeyframes = new Map(currentState.clipKeyframes); |
| 101 | + if (target.follower && target.pair) { |
| 102 | + nextKeyframes = synchronizeFollowerSpeedKeyframes(nextKeyframes, target.pair); |
| 103 | + } |
| 104 | + |
| 105 | + const nextClips = currentState.clips.map(candidate => { |
| 106 | + const isLeader = candidate.id === currentLeader.id; |
| 107 | + const isFollower = target.follower?.id === candidate.id; |
| 108 | + if (!isLeader && !isFollower) return candidate; |
| 109 | + return clearProcessedAudioAnalysisRefs({ |
| 110 | + ...candidate, |
| 111 | + speed, |
| 112 | + duration, |
| 113 | + ...(options.preservesPitch !== undefined |
| 114 | + ? { preservesPitch: options.preservesPitch } |
| 115 | + : {}), |
| 116 | + }); |
| 117 | + }); |
| 118 | + set({ clips: nextClips, clipKeyframes: nextKeyframes }); |
| 119 | + get().updateDuration(); |
| 120 | + get().invalidateCache(); |
| 121 | + return true; |
| 122 | + } finally { |
| 123 | + if (historyBatch.opened) endBatch(); |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +export function setLinkedClipSpeedEnabledAction( |
| 128 | + { set, get }: ClipActionContext, |
| 129 | + clipId: string, |
| 130 | + enabled: boolean, |
| 131 | +): boolean { |
| 132 | + const initialState = get(); |
| 133 | + const pair = resolveLinkedVideoAudioPair(initialState.clips, clipId); |
| 134 | + if (!pair) return false; |
| 135 | + if ([pair.video.id, pair.audio.id].some(id => ( |
| 136 | + isClipOnLockedTrack(initialState.clips, initialState.tracks, id) |
| 137 | + ))) { |
| 138 | + log.warn('Cannot change linked speed setting on a locked track', { clipId }); |
| 139 | + return false; |
| 140 | + } |
| 141 | + if (isLinkedAudioFollowingVideo(pair) === enabled) return true; |
| 142 | + |
| 143 | + const historyBatch = startBatch(enabled ? 'Link audio speed' : 'Unlink audio speed'); |
| 144 | + try { |
| 145 | + const nextKeyframes = enabled |
| 146 | + ? synchronizeFollowerSpeedKeyframes(initialState.clipKeyframes, pair) |
| 147 | + : new Map(initialState.clipKeyframes); |
| 148 | + const nextClips = initialState.clips.map(candidate => { |
| 149 | + if (candidate.id !== pair.audio.id) return candidate; |
| 150 | + return clearProcessedAudioAnalysisRefs({ |
| 151 | + ...candidate, |
| 152 | + followsLinkedVideoSpeed: enabled ? undefined : false, |
| 153 | + ...(enabled ? { |
| 154 | + speed: pair.video.speed, |
| 155 | + duration: pair.video.duration, |
| 156 | + } : {}), |
| 157 | + }); |
| 158 | + }); |
| 159 | + set({ clips: nextClips, clipKeyframes: nextKeyframes }); |
| 160 | + get().updateDuration(); |
| 161 | + get().invalidateCache(); |
| 162 | + return true; |
| 163 | + } finally { |
| 164 | + if (historyBatch.opened) endBatch(); |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +export function setClipPreservesPitchAction( |
| 169 | + { set, get }: ClipActionContext, |
| 170 | + clipId: string, |
| 171 | + preservesPitch: boolean, |
| 172 | +): void { |
| 173 | + const { clips, tracks } = get(); |
| 174 | + const pair = resolveLinkedVideoAudioPair(clips, clipId); |
| 175 | + const targetClipId = pair && pair.video.id === clipId && isLinkedAudioFollowingVideo(pair) |
| 176 | + ? pair.audio.id |
| 177 | + : clipId; |
| 178 | + if (isClipOnLockedTrack(clips, tracks, targetClipId)) { |
| 179 | + log.warn('Cannot update clip pitch on locked track', { clipId }); |
| 180 | + return; |
| 181 | + } |
| 182 | + set({ |
| 183 | + clips: get().clips.map(candidate => candidate.id === targetClipId |
| 184 | + ? clearProcessedAudioAnalysisRefs({ ...candidate, preservesPitch }) |
| 185 | + : candidate), |
| 186 | + }); |
| 187 | +} |
0 commit comments