Skip to content

Commit a4127fe

Browse files
authored
fix: let keyframed clips take effects and transitions in the toolbar (#255)
1 parent 8ee2542 commit a4127fe

4 files changed

Lines changed: 48 additions & 102 deletions

File tree

‎src/core/shared/clip-utils.ts‎

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,3 @@ export function stripInternalProperties(clip: Clip): Clip {
1919
export function isKeyframedValue(value: unknown): boolean {
2020
return Boolean(value) && typeof value !== "number";
2121
}
22-
23-
/**
24-
* True when any visual property is keyframed or bound.
25-
*
26-
* The toolbars keep effects and transitions off such clips, so they must agree on
27-
* the property list; keep this the only copy.
28-
*/
29-
export function hasKeyframedVisualProperty(clip: Clip): boolean {
30-
return [
31-
clip.opacity,
32-
clip.scale,
33-
clip.offset?.x,
34-
clip.offset?.y,
35-
clip.transform?.rotate?.angle,
36-
clip.transform?.skew?.x,
37-
clip.transform?.skew?.y
38-
].some(isKeyframedValue);
39-
}

‎src/core/ui/media-toolbar.ts‎

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
} from "@core/animations/opacity-keyframes";
1212
import type { Edit } from "@core/edit-session";
1313
import { EditEvent } from "@core/events/edit-events";
14-
import { hasKeyframedVisualProperty } from "@core/shared/clip-utils";
1514
import { validateAssetUrl } from "@core/shared/utils";
1615
import { ShotstackEdit } from "@core/shotstack-edit";
1716
import type { ResolvedClip } from "@schemas";
@@ -949,20 +948,16 @@ export class MediaToolbar extends BaseToolbar {
949948
const fps = this.edit.getOutputFps();
950949
const currentPoint = localTime === null ? undefined : findOpacityPoint(visiblePoints, localTime, fps);
951950

952-
const hasEffect = Boolean(clip.effect);
953-
const hasTransition = Boolean(clip.transition?.in || clip.transition?.out);
954-
const hasPreset = hasEffect || hasTransition;
955951
const animated = Array.isArray(clip.opacity) || (points?.length ?? 0) > 0;
956952
let disabledReason = "";
957953
if (!editable) disabledReason = "This opacity animation can be previewed but not edited";
958954
else if (isBound) disabledReason = "Remove the merge field before keyframing opacity";
959-
else if (hasPreset) disabledReason = "Remove the clip effect or transition before keyframing opacity";
960955
else if (localTime === null) disabledReason = "Move the playhead over the clip to edit opacity keyframes";
961956
else if (currentPoint && points?.length === 2 && points.some(point => point !== currentPoint && point.time > clip.length + TIME_EPSILON)) {
962957
disabledReason = "Extend the clip before removing this keyframe";
963958
}
964959

965-
this.opacitySlider?.setEnabled(!isBound && editable && (!animated || (!hasPreset && localTime !== null)));
960+
this.opacitySlider?.setEnabled(!isBound && editable && (!animated || localTime !== null));
966961

967962
if (this.opacityKeyframeBtn) {
968963
let state: keyof typeof KEYFRAME_BUTTON_STATES = "static";
@@ -979,23 +974,6 @@ export class MediaToolbar extends BaseToolbar {
979974
const navigationTime = this.edit.playbackTime - clip.start;
980975
if (this.opacityPreviousKeyframeBtn) this.opacityPreviousKeyframeBtn.disabled = !findOpacityPoint(visiblePoints, navigationTime, fps, -1);
981976
if (this.opacityNextKeyframeBtn) this.opacityNextKeyframeBtn.disabled = !findOpacityPoint(visiblePoints, navigationTime, fps, 1);
982-
983-
// Only committed keyframes gate presets. An armed-but-unwritten point has no
984-
// document presence, so gating on it would disable controls with nothing to undo.
985-
const hasVisualKeyframes = hasKeyframedVisualProperty(clip);
986-
this.setPresetButtonEnabled(this.effectBtn, !(hasVisualKeyframes && !hasEffect), "Effects can't be combined with keyframes in the editor");
987-
this.setPresetButtonEnabled(
988-
this.transitionBtn,
989-
!(hasVisualKeyframes && !hasTransition),
990-
"Transitions can't be combined with keyframes in the editor"
991-
);
992-
}
993-
994-
private setPresetButtonEnabled(button: HTMLButtonElement | null, enabled: boolean, disabledReason: string): void {
995-
if (!button) return;
996-
Object.assign(button, { disabled: !enabled, title: enabled ? "" : disabledReason });
997-
if (enabled) button.removeAttribute("aria-label");
998-
else button.setAttribute("aria-label", disabledReason);
999977
}
1000978

1001979
// ─── Two-Phase Drag Helpers ──────────────────────────────────────────────────
@@ -1076,7 +1054,7 @@ export class MediaToolbar extends BaseToolbar {
10761054
let opacity: ResolvedClip["opacity"] = nextValue;
10771055
if (points.length > 0) {
10781056
const localTime = this.opacityDragTime ?? this.getOpacityTime(clip);
1079-
if (localTime === null || clip.effect || clip.transition?.in || clip.transition?.out) {
1057+
if (localTime === null) {
10801058
this.syncOpacityState(clip);
10811059
return;
10821060
}
@@ -1107,14 +1085,7 @@ export class MediaToolbar extends BaseToolbar {
11071085

11081086
const points = this.getOpacityPoints(clip, clipId);
11091087
const localTime = this.getOpacityTime(clip);
1110-
if (
1111-
points === null ||
1112-
localTime === null ||
1113-
this.getShotstackEdit()?.getMergeFieldForProperty(clipId, "opacity") ||
1114-
clip.effect ||
1115-
clip.transition?.in ||
1116-
clip.transition?.out
1117-
) {
1088+
if (points === null || localTime === null || this.getShotstackEdit()?.getMergeFieldForProperty(clipId, "opacity")) {
11181089
this.syncOpacityState(clip);
11191090
return;
11201091
}
@@ -1329,27 +1300,13 @@ export class MediaToolbar extends BaseToolbar {
13291300

13301301
private applyTransitionUpdate(): void {
13311302
const transition = this.transitionPanel?.getClipValue();
1332-
const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx);
1333-
if (clip && hasKeyframedVisualProperty(clip)) {
1334-
const changesIn = Boolean(transition?.in && transition.in !== clip.transition?.in);
1335-
const changesOut = Boolean(transition?.out && transition.out !== clip.transition?.out);
1336-
if (changesIn || changesOut) {
1337-
this.transitionPanel?.setFromClip(clip.transition);
1338-
return;
1339-
}
1340-
}
13411303
this.applyClipUpdate({ transition });
13421304
}
13431305

13441306
// ─── Effect (using composite) ────────────────────────────────────────────────
13451307

13461308
private applyEffect(): void {
13471309
const effectValue = this.effectPanel?.getClipValue();
1348-
const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx);
1349-
if (effectValue && clip && hasKeyframedVisualProperty(clip)) {
1350-
this.effectPanel?.setFromClip(clip.effect);
1351-
return;
1352-
}
13531310
this.applyClipUpdate({ effect: effectValue });
13541311
}
13551312

‎tests/clip-utils.test.ts‎

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
import { hasKeyframedVisualProperty, isKeyframedValue } from "@core/shared/clip-utils";
2-
import type { Clip } from "@schemas";
1+
import { isKeyframedValue } from "@core/shared/clip-utils";
32

43
const tween = [{ from: 0, to: 1, start: 0, length: 1, interpolation: "linear" as const }];
54

6-
function clip(overrides: Partial<Clip> = {}): Clip {
7-
return { asset: { type: "image", src: "https://example.com/a.jpg" }, start: 0, length: 5, ...overrides } as Clip;
8-
}
9-
105
describe("isKeyframedValue", () => {
116
it("accepts plain numbers as editable, including zero", () => {
127
expect(isKeyframedValue(0)).toBe(false);
@@ -19,28 +14,3 @@ describe("isKeyframedValue", () => {
1914
expect(isKeyframedValue("{{ MEDIA_OPACITY }}")).toBe(true);
2015
});
2116
});
22-
23-
describe("hasKeyframedVisualProperty", () => {
24-
it("is false for a clip whose visual properties are all fixed or absent", () => {
25-
expect(hasKeyframedVisualProperty(clip())).toBe(false);
26-
expect(hasKeyframedVisualProperty(clip({ opacity: 0, scale: 1 }))).toBe(false);
27-
});
28-
29-
it.each([
30-
["opacity", { opacity: tween }],
31-
["scale", { scale: tween }],
32-
["offset.x", { offset: { x: tween } }],
33-
["offset.y", { offset: { y: tween } }],
34-
["rotation", { transform: { rotate: { angle: tween } } }],
35-
["skew.x", { transform: { skew: { x: tween } } }],
36-
["skew.y", { transform: { skew: { y: tween } } }]
37-
])("is true when %s is keyframed", (_name, overrides) => {
38-
expect(hasKeyframedVisualProperty(clip(overrides as Partial<Clip>))).toBe(true);
39-
});
40-
41-
it("ignores volume, which is asset-level and takes no part in preset composition", () => {
42-
expect(hasKeyframedVisualProperty(clip({ asset: { type: "video", src: "https://example.com/a.mp4", volume: tween } } as Partial<Clip>))).toBe(
43-
false
44-
);
45-
});
46-
});

‎tests/media-toolbar.test.ts‎

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -508,27 +508,64 @@ describe("MediaToolbar", () => {
508508
toolbar.dispose();
509509
});
510510

511-
it("blocks effects and transitions while opacity is animated", () => {
511+
it("offers effects and transitions while opacity is animated", () => {
512512
const mockEdit = createMockEditSession();
513513
mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens }));
514514

515515
const { toolbar, parent } = mountToolbar(mockEdit);
516516
toolbar.show(0, 0);
517517

518-
expect((parent.querySelector('[data-action="effect"]') as HTMLButtonElement).disabled).toBe(true);
519-
expect((parent.querySelector('[data-action="transition"]') as HTMLButtonElement).disabled).toBe(true);
518+
expect((parent.querySelector('[data-action="effect"]') as HTMLButtonElement).disabled).toBe(false);
519+
expect((parent.querySelector('[data-action="transition"]') as HTMLButtonElement).disabled).toBe(false);
520+
toolbar.dispose();
521+
});
522+
523+
it("adds an effect and a transition to a clip with animated opacity", () => {
524+
const mockEdit = createMockEditSession();
525+
mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens }));
526+
527+
const { toolbar, parent } = mountToolbar(mockEdit);
528+
toolbar.show(0, 0);
529+
(parent.querySelector('[data-effect-type="zoom"]') as HTMLButtonElement).click();
530+
(parent.querySelector('[data-effect="fade"]') as HTMLButtonElement).click();
531+
532+
expect(mockEdit.updateClip).toHaveBeenCalledWith(0, 0, { effect: expect.stringMatching(/^zoom/) });
533+
expect(mockEdit.updateClip).toHaveBeenCalledWith(0, 0, { transition: expect.objectContaining({ in: "fade" }) });
520534
toolbar.dispose();
521535
});
522536

523-
it("blocks keyframe activation while a preset is present", () => {
537+
it("keyframes opacity on a clip with an effect and a transition", () => {
524538
const mockEdit = createMockEditSession();
525-
mockEdit.getResolvedClip.mockReturnValue(createImageClip({ effect: "zoomIn" }));
539+
mockEdit.playbackTime = 1;
540+
mockEdit.getResolvedClip.mockReturnValue(createImageClip({ effect: "zoomIn", transition: { in: "fade" } }));
526541

527542
const { toolbar, parent } = mountToolbar(mockEdit);
528543
toolbar.show(0, 0);
529544
const keyframe = parent.querySelector("[data-opacity-keyframe]") as HTMLButtonElement;
530-
expect(keyframe.disabled).toBe(true);
531-
expect(keyframe.title).toContain("effect or transition");
545+
expect(keyframe.disabled).toBe(false);
546+
keyframe.click();
547+
mockEdit.playbackTime = 3;
548+
keyframe.click();
549+
550+
expect(mockEdit.updateClip).toHaveBeenCalledWith(0, 0, { opacity: expect.arrayContaining([expect.objectContaining({ start: 1, length: 2 })]) });
551+
toolbar.dispose();
552+
});
553+
554+
it("auto-keys a slider drag on an animated clip with a transition", () => {
555+
const mockEdit = createMockEditSession();
556+
mockEdit.playbackTime = 2;
557+
mockEdit.getResolvedClip.mockReturnValue(createImageClip({ opacity: opacityTweens, transition: { in: "fade" } }));
558+
559+
const { toolbar, parent } = mountToolbar(mockEdit);
560+
toolbar.show(0, 0);
561+
const range = parent.querySelector("[data-opacity-slider-mount] input[type='range']") as HTMLInputElement;
562+
range.dispatchEvent(new Event("pointerdown", { bubbles: true }));
563+
range.value = "40";
564+
range.dispatchEvent(new Event("input", { bubbles: true }));
565+
range.dispatchEvent(new Event("change", { bubbles: true }));
566+
567+
const update = mockEdit.updateClipInDocument.mock.calls[0]?.[1];
568+
expect(update?.opacity).toEqual(expect.arrayContaining([{ from: 0.2, to: 0.4, start: 1, length: 1, interpolation: "linear" }]));
532569
toolbar.dispose();
533570
});
534571

0 commit comments

Comments
 (0)