Skip to content

Commit 22d54e9

Browse files
committed
refactor: satisfy timeline release gates
1 parent 91720a0 commit 22d54e9

19 files changed

Lines changed: 366 additions & 308 deletions

package-lock.json

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
"wrangler": "^4.76.0"
109109
},
110110
"overrides": {
111-
"sharp": "^0.35.3"
111+
"sharp": "^0.35.3",
112+
"undici": "7.29.0"
112113
}
113114
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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

Comments
 (0)