From b1d7fbe819bac2c1164065b60860b5e263749140 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 31 May 2026 20:44:58 +0000 Subject: [PATCH] Fix deleting the last keyframe Co-authored-by: Jonny Burger --- .../update-keyframes/update-keyframes.ts | 4 + .../src/test/update-keyframes.test.ts | 91 +++++++++++++++++++ .../src/optimistic-delete-keyframe.ts | 6 ++ .../test/optimistic-delete-keyframe.test.ts | 79 ++++++++++++++++ 4 files changed, 180 insertions(+) diff --git a/packages/studio-server/src/codemods/update-keyframes/update-keyframes.ts b/packages/studio-server/src/codemods/update-keyframes/update-keyframes.ts index 5229f1137a5..3aa4b826224 100644 --- a/packages/studio-server/src/codemods/update-keyframes/update-keyframes.ts +++ b/packages/studio-server/src/codemods/update-keyframes/update-keyframes.ts @@ -325,6 +325,10 @@ const removeKeyframe = ({ (_keyframe, index) => index !== keyframeIndex, ); + if (nextKeyframes.length === 0) { + return existing.keyframes[keyframeIndex].output; + } + return createInterpolateExpression({ callee: existing.callee, input: existing.input, diff --git a/packages/studio-server/src/test/update-keyframes.test.ts b/packages/studio-server/src/test/update-keyframes.test.ts index 2dd26021d03..d3e2348a79d 100644 --- a/packages/studio-server/src/test/update-keyframes.test.ts +++ b/packages/studio-server/src/test/update-keyframes.test.ts @@ -235,6 +235,38 @@ test('updateSequenceKeyframes keeps an interpolation when one keyframe remains', expect(output).toContain('style={{scale: interpolate(frame, [100], [4])}}'); }); +test('updateSequenceKeyframes converts the last keyframe to a static value', async () => { + const input = sequenceInput.replace( + 'interpolate(frame, [0, 100], [2, 4])', + 'interpolate(frame, [12], [320])', + ); + const {output, oldValueStrings, newValueStrings, updatedNodePath} = + await updateSequenceKeyframes({ + input, + nodePath: lineColumnToNodePath(input, getLine(input, 'scale')), + updates: [ + { + key: 'style.scale', + operation: {type: 'remove', frame: 12}, + }, + ], + }); + + expect(oldValueStrings).toEqual(['interpolate(frame, [12], [320])']); + expect(newValueStrings).toEqual(['320']); + expect(output).toContain('style={{scale: 320}}'); + const status = computeSequencePropsStatusFromContent({ + fileContents: output, + nodePath: updatedNodePath, + keys: ['style.scale'], + effects: [], + }); + expect(status.props['style.scale']).toEqual({ + canUpdate: true, + codeValue: 320, + }); +}); + test('updateSequenceKeyframes keeps a color interpolation when one keyframe remains', async () => { const {output, oldValueStrings} = await updateSequenceKeyframes({ input: colorInput, @@ -253,6 +285,38 @@ test('updateSequenceKeyframes keeps a color interpolation when one keyframe rema expect(output).toContain("color={interpolateColors(frame, [100], ['blue'])}"); }); +test('updateSequenceKeyframes converts the last color keyframe to a static value', async () => { + const input = colorInput.replace( + "interpolateColors(frame, [0, 100], ['red', 'blue'])", + "interpolateColors(frame, [15], ['blue'])", + ); + const {output, oldValueStrings, newValueStrings, updatedNodePath} = + await updateSequenceKeyframes({ + input, + nodePath: lineColumnToNodePath(input, getLine(input, ' { const {serialized, oldValueStrings, effectCallee} = updateEffectKeyframesAst({ input: effectInput, @@ -300,3 +364,30 @@ test('updateEffectKeyframes keeps an effect prop interpolation with one keyframe expect(serialized).toContain('amount: interpolate(frame, [0], [0.2])'); }); + +test('updateEffectKeyframes converts the last effect keyframe to a static value', () => { + const input = effectInput.replace( + 'interpolate(frame, [0, 50, 100], [0.2, 0.5, 0.8])', + 'interpolate(frame, [40], [0.6])', + ); + const {serialized, oldValueStrings, newValueStrings, effectCallee} = + updateEffectKeyframesAst({ + input, + sequenceNodePath: lineColumnToNodePath( + input, + getLine(input, ' i !== index); + if (keyframes.length === 0) { + return { + canUpdate: true, + codeValue: status.keyframes[index].value, + }; + } // Easing holds one segment per gap between consecutive keyframes // (keyframes.length - 1 entries). Drop the segment adjacent to the removed diff --git a/packages/studio-shared/src/test/optimistic-delete-keyframe.test.ts b/packages/studio-shared/src/test/optimistic-delete-keyframe.test.ts index 776f7091cd0..8725da896f4 100644 --- a/packages/studio-shared/src/test/optimistic-delete-keyframe.test.ts +++ b/packages/studio-shared/src/test/optimistic-delete-keyframe.test.ts @@ -48,6 +48,39 @@ test('optimisticDeleteSequenceKeyframe removes the matching keyframe and an easi expect(status.easing).toEqual(['linear']); }); +test('optimisticDeleteSequenceKeyframe converts the last keyframe to a static value', () => { + const previous: CanUpdateSequencePropsResponse = { + canUpdate: true, + props: { + width: { + canUpdate: false, + reason: 'keyframed', + interpolationFunction: 'interpolate', + keyframes: [{frame: 12, value: 320}], + easing: [], + clamping: {left: 'extend', right: 'extend'}, + posterize: undefined, + }, + }, + effects: [], + }; + + const updated = optimisticDeleteSequenceKeyframe({ + previous, + fieldKey: 'width', + frame: 12, + }); + + if (!updated.canUpdate) { + throw new Error('expected canUpdate true'); + } + + expect(updated.props.width).toEqual({ + canUpdate: true, + codeValue: 320, + }); +}); + test('optimisticDeleteSequenceKeyframe is a no-op when no keyframe matches', () => { const previous: CanUpdateSequencePropsResponse = { canUpdate: true, @@ -143,6 +176,52 @@ test('optimisticDeleteEffectKeyframe removes the matching keyframe on the target expect(status.easing).toEqual([]); }); +test('optimisticDeleteEffectKeyframe converts the last keyframe on the target effect to a static value', () => { + const previous: CanUpdateSequencePropsResponse = { + canUpdate: true, + props: {}, + effects: [ + { + canUpdate: true, + effectIndex: 0, + callee: 'tint', + props: { + amount: { + canUpdate: false, + reason: 'keyframed', + interpolationFunction: 'interpolate', + keyframes: [{frame: 40, value: 0.6}], + easing: [], + clamping: {left: 'extend', right: 'extend'}, + posterize: undefined, + }, + }, + }, + ], + }; + + const updated = optimisticDeleteEffectKeyframe({ + previous, + effectIndex: 0, + fieldKey: 'amount', + frame: 40, + }); + + if (!updated.canUpdate) { + throw new Error('expected canUpdate true'); + } + + const effect = updated.effects[0]; + if (!effect.canUpdate) { + throw new Error('expected effect canUpdate true'); + } + + expect(effect.props.amount).toEqual({ + canUpdate: true, + codeValue: 0.6, + }); +}); + test('optimisticDeleteEffectKeyframe is a no-op when effect index not found', () => { const previous: CanUpdateSequencePropsResponse = { canUpdate: true,