Skip to content

Commit 8ee2542

Browse files
authored
[6/10] fix: apply effect and transition presets over keyframes in preview (#254)
* refactor: give the preset builders their own keyframe type * fix: preview transitions with the render's durations, curves and directions * fix: preview shuffle transitions * fix: preview slide effects with the render's distances * fix: hold the first keyframe value and ease gaps like the render * fix: apply effect and transition presets over keyframes in preview * fix: hold the first keyframe value and ease gaps like the render
1 parent 0039a74 commit 8ee2542

10 files changed

Lines changed: 286 additions & 276 deletions

‎src/components/canvas/players/player.ts‎

Lines changed: 48 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
import { ComposedKeyframeBuilder } from "@animations/composed-keyframe-builder";
21
import { EffectPresetBuilder } from "@animations/effect-preset-builder";
3-
import { KeyframeBuilder } from "@animations/keyframe-builder";
2+
import { KeyframeBuilder, type PresetPoint } from "@animations/keyframe-builder";
43
import { TransitionPresetBuilder } from "@animations/transition-preset-builder";
54
import { WipeFilter } from "@animations/wipe-filter";
65
import { type Edit } from "@core/edit-session";
76
import { InternalEvent } from "@core/events/edit-events";
87
import { calculateContainerScale, calculateFitScale, calculateSpriteTransform, type FitMode } from "@core/layout/fit-system";
98
import { type PlaybackReadiness } from "@core/pending-captures";
10-
import { hasKeyframedVisualProperty } from "@core/shared/clip-utils";
119
import {
1210
type AliasReference,
1311
type ResolvedTiming,
@@ -20,7 +18,7 @@ import {
2018
import { Pointer } from "@inputs/pointer";
2119
import { type Size, type Vector } from "@layouts/geometry";
2220
import { relativeToAbsolute, absoluteToRelative, rotationOriginOffset } from "@layouts/position-builder";
23-
import { type Clip, type ResolvedClip } from "@schemas";
21+
import { type Clip, type Keyframe, type ResolvedClip } from "@schemas";
2422
import * as pixi from "pixi.js";
2523

2624
import { Entity } from "../../../core/shared/entity";
@@ -88,13 +86,13 @@ export abstract class Player extends Entity {
8886

8987
private resolvedTiming: ResolvedTiming;
9088

91-
private offsetXKeyframeBuilder?: ComposedKeyframeBuilder;
92-
private offsetYKeyframeBuilder?: ComposedKeyframeBuilder;
93-
private scaleKeyframeBuilder?: ComposedKeyframeBuilder;
94-
private opacityKeyframeBuilder?: ComposedKeyframeBuilder;
95-
private rotationKeyframeBuilder?: ComposedKeyframeBuilder;
96-
private skewXKeyframeBuilder?: ComposedKeyframeBuilder;
97-
private skewYKeyframeBuilder?: ComposedKeyframeBuilder;
89+
private offsetXKeyframeBuilder?: KeyframeBuilder;
90+
private offsetYKeyframeBuilder?: KeyframeBuilder;
91+
private scaleKeyframeBuilder?: KeyframeBuilder;
92+
private opacityKeyframeBuilder?: KeyframeBuilder;
93+
private rotationKeyframeBuilder?: KeyframeBuilder;
94+
private skewXKeyframeBuilder?: KeyframeBuilder;
95+
private skewYKeyframeBuilder?: KeyframeBuilder;
9896
private maskXKeyframeBuilder?: KeyframeBuilder;
9997
private wipeBrightnessBuilder?: KeyframeBuilder;
10098
private wipeInFromRight: boolean = false;
@@ -146,50 +144,17 @@ export abstract class Player extends Entity {
146144
protected configureKeyframes() {
147145
const length = this.getLength();
148146
const config = this.clipConfiguration;
149-
150-
// Extract base values from clip configuration
151-
const baseOffsetX = typeof config.offset?.x === "number" ? config.offset.x : 0;
152-
const baseOffsetY = typeof config.offset?.y === "number" ? config.offset.y : 0;
153-
const baseScale = typeof config.scale === "number" ? config.scale : 1;
154-
const baseOpacity = typeof config.opacity === "number" ? config.opacity : 1;
155-
const baseRotation = typeof config.transform?.rotate?.angle === "number" ? config.transform.rotate.angle : 0;
156-
const baseSkewX = typeof config.transform?.skew?.x === "number" ? config.transform.skew.x : 0;
157-
const baseSkewY = typeof config.transform?.skew?.y === "number" ? config.transform.skew.y : 0;
158-
159-
// Create composed builders with base values
160-
this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetX, "additive");
161-
this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetY, "additive");
162-
this.scaleKeyframeBuilder = new ComposedKeyframeBuilder(baseScale, "multiplicative");
163-
this.opacityKeyframeBuilder = new ComposedKeyframeBuilder(baseOpacity, "multiplicative", { min: 0, max: 1 });
164-
this.rotationKeyframeBuilder = new ComposedKeyframeBuilder(baseRotation, "additive");
165-
this.skewXKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewX, "additive");
166-
this.skewYKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewY, "additive");
167-
168-
// If user has custom keyframes, add them and skip effect/transition layers
169-
if (hasKeyframedVisualProperty(this.clipConfiguration)) {
170-
if (Array.isArray(config.scale)) {
171-
this.scaleKeyframeBuilder.addLayer(config.scale);
172-
}
173-
if (Array.isArray(config.opacity)) {
174-
this.opacityKeyframeBuilder.addLayer(config.opacity);
175-
}
176-
if (Array.isArray(config.offset?.x)) {
177-
this.offsetXKeyframeBuilder.addLayer(config.offset.x);
178-
}
179-
if (Array.isArray(config.offset?.y)) {
180-
this.offsetYKeyframeBuilder.addLayer(config.offset.y);
181-
}
182-
if (Array.isArray(config.transform?.rotate?.angle)) {
183-
this.rotationKeyframeBuilder.addLayer(config.transform.rotate.angle);
184-
}
185-
if (Array.isArray(config.transform?.skew?.x)) {
186-
this.skewXKeyframeBuilder.addLayer(config.transform.skew.x);
187-
}
188-
if (Array.isArray(config.transform?.skew?.y)) {
189-
this.skewYKeyframeBuilder.addLayer(config.transform.skew.y);
190-
}
191-
return;
192-
}
147+
// A property that holds neither a number nor tweens, such as an unresolved merge field, plays its default.
148+
const curve = (value: Keyframe[] | number | undefined, fallback: number) =>
149+
new KeyframeBuilder(typeof value === "number" || (Array.isArray(value) && value.length > 0) ? value : fallback);
150+
151+
this.offsetXKeyframeBuilder = curve(config.offset?.x, 0);
152+
this.offsetYKeyframeBuilder = curve(config.offset?.y, 0);
153+
this.scaleKeyframeBuilder = curve(config.scale, 1);
154+
this.opacityKeyframeBuilder = curve(config.opacity, 1);
155+
this.rotationKeyframeBuilder = curve(config.transform?.rotate?.angle, 0);
156+
this.skewXKeyframeBuilder = curve(config.transform?.skew?.x, 0);
157+
this.skewYKeyframeBuilder = curve(config.transform?.skew?.y, 0);
193158

194159
// Build resolved clip config for preset builders
195160
const resolvedClipConfig: ResolvedClip = {
@@ -198,59 +163,40 @@ export abstract class Player extends Entity {
198163
length
199164
};
200165

201-
// Build relative effect keyframes (factors/deltas)
202166
const effectSet = new EffectPresetBuilder(resolvedClipConfig).buildRelative(this.edit.size, this.getSize());
203-
204-
// Build relative transition keyframes (separate in/out sets)
205167
const transitionSet = new TransitionPresetBuilder(resolvedClipConfig).buildRelative();
206168

207-
// Add effect layer
208-
this.offsetXKeyframeBuilder.addLayer(effectSet.offsetXKeyframes);
209-
this.offsetYKeyframeBuilder.addLayer(effectSet.offsetYKeyframes);
210-
this.scaleKeyframeBuilder.addLayer(effectSet.scaleKeyframes);
211-
this.opacityKeyframeBuilder.addLayer(effectSet.opacityKeyframes);
212-
this.rotationKeyframeBuilder.addLayer(effectSet.rotationKeyframes);
213-
214-
// Add transition-in layer
215-
this.offsetXKeyframeBuilder.addLayer(transitionSet.in.offsetXKeyframes);
216-
this.offsetYKeyframeBuilder.addLayer(transitionSet.in.offsetYKeyframes);
217-
this.scaleKeyframeBuilder.addLayer(transitionSet.in.scaleKeyframes);
218-
this.opacityKeyframeBuilder.addLayer(transitionSet.in.opacityKeyframes);
219-
this.rotationKeyframeBuilder.addLayer(transitionSet.in.rotationKeyframes);
220-
221-
// Add transition-out layer
222-
this.offsetXKeyframeBuilder.addLayer(transitionSet.out.offsetXKeyframes);
223-
this.offsetYKeyframeBuilder.addLayer(transitionSet.out.offsetYKeyframes);
224-
this.scaleKeyframeBuilder.addLayer(transitionSet.out.scaleKeyframes);
225-
this.opacityKeyframeBuilder.addLayer(transitionSet.out.opacityKeyframes);
226-
this.rotationKeyframeBuilder.addLayer(transitionSet.out.rotationKeyframes);
227-
228-
// Mask keyframes (reveal effect)
229-
const maskXKeyframes = [...transitionSet.in.maskXKeyframes, ...transitionSet.out.maskXKeyframes];
230-
if (maskXKeyframes.length) {
231-
this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes);
169+
// The render applies the effect, then the in transition, then the out, each over the curves left by those before.
170+
for (const preset of [effectSet, transitionSet.in, transitionSet.out]) {
171+
this.offsetXKeyframeBuilder.addPresetPoints(preset.offsetXKeyframes);
172+
this.offsetYKeyframeBuilder.addPresetPoints(preset.offsetYKeyframes);
173+
this.scaleKeyframeBuilder.addPresetPoints(preset.scaleKeyframes);
174+
this.opacityKeyframeBuilder.addPresetPoints(preset.opacityKeyframes);
175+
this.rotationKeyframeBuilder.addPresetPoints(preset.rotationKeyframes);
232176
}
233177

178+
// Curves only transitions drive, resting at a value that leaves the clip untouched.
179+
const transitionCurve = (rest: number, ...phases: PresetPoint[][]) => {
180+
if (!phases.some(points => points.length)) return undefined;
181+
const builder = new KeyframeBuilder(rest);
182+
phases.forEach(points => builder.addPresetPoints(points));
183+
return builder;
184+
};
185+
const { in: transitionIn, out: transitionOut } = transitionSet;
186+
187+
this.maskXKeyframeBuilder = transitionCurve(1, transitionIn.maskXKeyframes, transitionOut.maskXKeyframes);
188+
234189
// Wipe brightness sweep (wipeLeft/wipeRight) — driven through WipeFilter, not a mask.
235-
const brightnessKeyframes = [...transitionSet.in.brightnessKeyframes, ...transitionSet.out.brightnessKeyframes];
236-
if (brightnessKeyframes.length) {
237-
this.wipeBrightnessBuilder = new KeyframeBuilder(brightnessKeyframes);
238-
this.wipeInFromRight = transitionSet.in.wipeFromRight;
239-
this.wipeOutFromRight = transitionSet.out.wipeFromRight;
240-
this.wipeOutStart = transitionSet.out.brightnessKeyframes[0]?.start ?? Number.POSITIVE_INFINITY;
241-
}
190+
this.wipeBrightnessBuilder = transitionCurve(-1, transitionIn.brightnessKeyframes, transitionOut.brightnessKeyframes);
191+
this.wipeInFromRight = transitionIn.wipeFromRight;
192+
this.wipeOutFromRight = transitionOut.wipeFromRight;
193+
this.wipeOutStart = transitionOut.brightnessKeyframes[0]?.time ?? Number.POSITIVE_INFINITY;
242194

243-
const blurKeyframes = [...transitionSet.in.blurKeyframes, ...transitionSet.out.blurKeyframes];
244-
this.zoomBlurBuilder = blurKeyframes.length ? new KeyframeBuilder(blurKeyframes) : undefined;
195+
this.zoomBlurBuilder = transitionCurve(0, transitionIn.blurKeyframes, transitionOut.blurKeyframes);
245196

246-
// The render keeps one origin curve: the in shuffle's origin (or the centre) eases to the out shuffle's by the time it starts.
247-
const { origin: inOrigin } = transitionSet.in;
248-
const { origin: outOrigin } = transitionSet.out;
249-
const outStart = transitionSet.out.rotationKeyframes[0]?.start ?? length;
250-
const originCurve = (from = 0.5, to?: number) =>
251-
new KeyframeBuilder(to === undefined ? from : [{ from, to, start: 0, length: outStart, interpolation: "bezier", easing: "smooth" }]);
252-
this.rotationOrigin =
253-
inOrigin || outOrigin ? { x: originCurve(inOrigin?.x, outOrigin?.x), y: originCurve(inOrigin?.y, outOrigin?.y) } : undefined;
197+
const originX = transitionCurve(0.5, transitionIn.originXKeyframes, transitionOut.originXKeyframes);
198+
const originY = transitionCurve(0.5, transitionIn.originYKeyframes, transitionOut.originYKeyframes);
199+
this.rotationOrigin = originX && originY ? { x: originX, y: originY } : undefined;
254200
}
255201

256202
private getRotationOriginOffset(angle: number, scale: Vector): Vector {
@@ -562,7 +508,8 @@ export abstract class Player extends Entity {
562508
}
563509

564510
public getOpacity(): number {
565-
return this.opacityKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1;
511+
const opacity = this.opacityKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1;
512+
return Math.min(1, Math.max(0, opacity));
566513
}
567514

568515
public getPosition(): Vector {

‎src/core/animations/composed-keyframe-builder.ts‎

Lines changed: 0 additions & 64 deletions
This file was deleted.

‎src/core/animations/effect-preset-builder.ts‎

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import { type ResolvedClip, type Keyframe } from "@schemas";
1+
import { type ResolvedClip } from "@schemas";
22

33
import { type Size } from "../layouts/geometry";
44

5+
import { type PresetPoint } from "./keyframe-builder";
6+
57
export type EffectKeyframeSet = {
6-
offsetXKeyframes: Keyframe[];
7-
offsetYKeyframes: Keyframe[];
8-
opacityKeyframes: Keyframe[];
9-
scaleKeyframes: Keyframe[];
10-
rotationKeyframes: Keyframe[];
8+
offsetXKeyframes: PresetPoint[];
9+
offsetYKeyframes: PresetPoint[];
10+
opacityKeyframes: PresetPoint[];
11+
scaleKeyframes: PresetPoint[];
12+
rotationKeyframes: PresetPoint[];
1113
};
1214

1315
export type RelativeEffectKeyframeSet = EffectKeyframeSet;
@@ -26,11 +28,11 @@ export class EffectPresetBuilder {
2628
}
2729

2830
public buildRelative(editSize: Size, clipSize: Size): RelativeEffectKeyframeSet {
29-
const offsetXKeyframes: Keyframe[] = [];
30-
const offsetYKeyframes: Keyframe[] = [];
31-
const opacityKeyframes: Keyframe[] = [];
32-
const scaleKeyframes: Keyframe[] = [];
33-
const rotationKeyframes: Keyframe[] = [];
31+
const offsetXKeyframes: PresetPoint[] = [];
32+
const offsetYKeyframes: PresetPoint[] = [];
33+
const opacityKeyframes: PresetPoint[] = [];
34+
const scaleKeyframes: PresetPoint[] = [];
35+
const rotationKeyframes: PresetPoint[] = [];
3436

3537
const { effect, length } = this.clipConfiguration;
3638

@@ -43,15 +45,20 @@ export class EffectPresetBuilder {
4345

4446
switch (effectName) {
4547
case "zoomIn": {
46-
const zoomSpeed = this.getZoomSpeed();
47-
// Factor: starts at 1x, ends at zoomSpeed (e.g., 1.3x)
48-
scaleKeyframes.push({ from: 1, to: zoomSpeed, start, length, interpolation: "linear" });
48+
// The render adds the zoom to the scale it reads at the clip's start.
49+
const amount = this.getZoomSpeed() - 1;
50+
scaleKeyframes.push(
51+
{ time: start, value: 0, at: start, interpolation: "linear" },
52+
{ time: length, value: amount, at: start, interpolation: "linear" }
53+
);
4954
break;
5055
}
5156
case "zoomOut": {
52-
const zoomSpeed = this.getZoomSpeed();
53-
// Factor: starts at zoomSpeed (e.g., 1.3x), ends at 1x
54-
scaleKeyframes.push({ from: zoomSpeed, to: 1, start, length, interpolation: "linear" });
57+
const amount = this.getZoomSpeed() - 1;
58+
scaleKeyframes.push(
59+
{ time: start, value: amount, at: start, interpolation: "linear" },
60+
{ time: length, value: 0, at: start, interpolation: "linear" }
61+
);
5562
break;
5663
}
5764
case "slideLeft":
@@ -71,15 +78,16 @@ export class EffectPresetBuilder {
7178
let targetOffset = this.getSlideStart();
7279
const scaleFactor = 1 + targetOffset * 2;
7380

81+
// Like the render's, a slide sets the offset on its axis outright, and the scale too when it enlarges the clip.
7482
if (fittedDimension < editDimension * scaleFactor) {
75-
scaleKeyframes.push({ from: scaleFactor, to: scaleFactor, start, length, interpolation: "linear" });
83+
scaleKeyframes.push({ time: start, value: scaleFactor, interpolation: "constant" });
7684
} else {
7785
targetOffset = (fittedDimension - editDimension) / 2 / editDimension;
7886
}
7987

8088
const [from, to] = startsPositive ? [targetOffset, -targetOffset] : [-targetOffset, targetOffset];
8189
const targetKeyframes = isHorizontal ? offsetXKeyframes : offsetYKeyframes;
82-
targetKeyframes.push({ from, to, start, length });
90+
targetKeyframes.push({ time: start, value: from, interpolation: "linear" }, { time: length, value: to, interpolation: "linear" });
8391
break;
8492
}
8593
default:

0 commit comments

Comments
 (0)