diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 5e0bedc7..d82724bd 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -1,13 +1,11 @@ -import { ComposedKeyframeBuilder } from "@animations/composed-keyframe-builder"; import { EffectPresetBuilder } from "@animations/effect-preset-builder"; -import { KeyframeBuilder } from "@animations/keyframe-builder"; +import { KeyframeBuilder, type PresetPoint } from "@animations/keyframe-builder"; import { TransitionPresetBuilder } from "@animations/transition-preset-builder"; import { WipeFilter } from "@animations/wipe-filter"; import { type Edit } from "@core/edit-session"; import { InternalEvent } from "@core/events/edit-events"; import { calculateContainerScale, calculateFitScale, calculateSpriteTransform, type FitMode } from "@core/layout/fit-system"; import { type PlaybackReadiness } from "@core/pending-captures"; -import { hasKeyframedVisualProperty } from "@core/shared/clip-utils"; import { type AliasReference, type ResolvedTiming, @@ -20,7 +18,7 @@ import { import { Pointer } from "@inputs/pointer"; import { type Size, type Vector } from "@layouts/geometry"; import { relativeToAbsolute, absoluteToRelative, rotationOriginOffset } from "@layouts/position-builder"; -import { type Clip, type ResolvedClip } from "@schemas"; +import { type Clip, type Keyframe, type ResolvedClip } from "@schemas"; import * as pixi from "pixi.js"; import { Entity } from "../../../core/shared/entity"; @@ -88,13 +86,13 @@ export abstract class Player extends Entity { private resolvedTiming: ResolvedTiming; - private offsetXKeyframeBuilder?: ComposedKeyframeBuilder; - private offsetYKeyframeBuilder?: ComposedKeyframeBuilder; - private scaleKeyframeBuilder?: ComposedKeyframeBuilder; - private opacityKeyframeBuilder?: ComposedKeyframeBuilder; - private rotationKeyframeBuilder?: ComposedKeyframeBuilder; - private skewXKeyframeBuilder?: ComposedKeyframeBuilder; - private skewYKeyframeBuilder?: ComposedKeyframeBuilder; + private offsetXKeyframeBuilder?: KeyframeBuilder; + private offsetYKeyframeBuilder?: KeyframeBuilder; + private scaleKeyframeBuilder?: KeyframeBuilder; + private opacityKeyframeBuilder?: KeyframeBuilder; + private rotationKeyframeBuilder?: KeyframeBuilder; + private skewXKeyframeBuilder?: KeyframeBuilder; + private skewYKeyframeBuilder?: KeyframeBuilder; private maskXKeyframeBuilder?: KeyframeBuilder; private wipeBrightnessBuilder?: KeyframeBuilder; private wipeInFromRight: boolean = false; @@ -146,50 +144,17 @@ export abstract class Player extends Entity { protected configureKeyframes() { const length = this.getLength(); const config = this.clipConfiguration; - - // Extract base values from clip configuration - const baseOffsetX = typeof config.offset?.x === "number" ? config.offset.x : 0; - const baseOffsetY = typeof config.offset?.y === "number" ? config.offset.y : 0; - const baseScale = typeof config.scale === "number" ? config.scale : 1; - const baseOpacity = typeof config.opacity === "number" ? config.opacity : 1; - const baseRotation = typeof config.transform?.rotate?.angle === "number" ? config.transform.rotate.angle : 0; - const baseSkewX = typeof config.transform?.skew?.x === "number" ? config.transform.skew.x : 0; - const baseSkewY = typeof config.transform?.skew?.y === "number" ? config.transform.skew.y : 0; - - // Create composed builders with base values - this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetX, "additive"); - this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetY, "additive"); - this.scaleKeyframeBuilder = new ComposedKeyframeBuilder(baseScale, "multiplicative"); - this.opacityKeyframeBuilder = new ComposedKeyframeBuilder(baseOpacity, "multiplicative", { min: 0, max: 1 }); - this.rotationKeyframeBuilder = new ComposedKeyframeBuilder(baseRotation, "additive"); - this.skewXKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewX, "additive"); - this.skewYKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewY, "additive"); - - // If user has custom keyframes, add them and skip effect/transition layers - if (hasKeyframedVisualProperty(this.clipConfiguration)) { - if (Array.isArray(config.scale)) { - this.scaleKeyframeBuilder.addLayer(config.scale); - } - if (Array.isArray(config.opacity)) { - this.opacityKeyframeBuilder.addLayer(config.opacity); - } - if (Array.isArray(config.offset?.x)) { - this.offsetXKeyframeBuilder.addLayer(config.offset.x); - } - if (Array.isArray(config.offset?.y)) { - this.offsetYKeyframeBuilder.addLayer(config.offset.y); - } - if (Array.isArray(config.transform?.rotate?.angle)) { - this.rotationKeyframeBuilder.addLayer(config.transform.rotate.angle); - } - if (Array.isArray(config.transform?.skew?.x)) { - this.skewXKeyframeBuilder.addLayer(config.transform.skew.x); - } - if (Array.isArray(config.transform?.skew?.y)) { - this.skewYKeyframeBuilder.addLayer(config.transform.skew.y); - } - return; - } + // A property that holds neither a number nor tweens, such as an unresolved merge field, plays its default. + const curve = (value: Keyframe[] | number | undefined, fallback: number) => + new KeyframeBuilder(typeof value === "number" || (Array.isArray(value) && value.length > 0) ? value : fallback); + + this.offsetXKeyframeBuilder = curve(config.offset?.x, 0); + this.offsetYKeyframeBuilder = curve(config.offset?.y, 0); + this.scaleKeyframeBuilder = curve(config.scale, 1); + this.opacityKeyframeBuilder = curve(config.opacity, 1); + this.rotationKeyframeBuilder = curve(config.transform?.rotate?.angle, 0); + this.skewXKeyframeBuilder = curve(config.transform?.skew?.x, 0); + this.skewYKeyframeBuilder = curve(config.transform?.skew?.y, 0); // Build resolved clip config for preset builders const resolvedClipConfig: ResolvedClip = { @@ -198,59 +163,40 @@ export abstract class Player extends Entity { length }; - // Build relative effect keyframes (factors/deltas) const effectSet = new EffectPresetBuilder(resolvedClipConfig).buildRelative(this.edit.size, this.getSize()); - - // Build relative transition keyframes (separate in/out sets) const transitionSet = new TransitionPresetBuilder(resolvedClipConfig).buildRelative(); - // Add effect layer - this.offsetXKeyframeBuilder.addLayer(effectSet.offsetXKeyframes); - this.offsetYKeyframeBuilder.addLayer(effectSet.offsetYKeyframes); - this.scaleKeyframeBuilder.addLayer(effectSet.scaleKeyframes); - this.opacityKeyframeBuilder.addLayer(effectSet.opacityKeyframes); - this.rotationKeyframeBuilder.addLayer(effectSet.rotationKeyframes); - - // Add transition-in layer - this.offsetXKeyframeBuilder.addLayer(transitionSet.in.offsetXKeyframes); - this.offsetYKeyframeBuilder.addLayer(transitionSet.in.offsetYKeyframes); - this.scaleKeyframeBuilder.addLayer(transitionSet.in.scaleKeyframes); - this.opacityKeyframeBuilder.addLayer(transitionSet.in.opacityKeyframes); - this.rotationKeyframeBuilder.addLayer(transitionSet.in.rotationKeyframes); - - // Add transition-out layer - this.offsetXKeyframeBuilder.addLayer(transitionSet.out.offsetXKeyframes); - this.offsetYKeyframeBuilder.addLayer(transitionSet.out.offsetYKeyframes); - this.scaleKeyframeBuilder.addLayer(transitionSet.out.scaleKeyframes); - this.opacityKeyframeBuilder.addLayer(transitionSet.out.opacityKeyframes); - this.rotationKeyframeBuilder.addLayer(transitionSet.out.rotationKeyframes); - - // Mask keyframes (reveal effect) - const maskXKeyframes = [...transitionSet.in.maskXKeyframes, ...transitionSet.out.maskXKeyframes]; - if (maskXKeyframes.length) { - this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes); + // The render applies the effect, then the in transition, then the out, each over the curves left by those before. + for (const preset of [effectSet, transitionSet.in, transitionSet.out]) { + this.offsetXKeyframeBuilder.addPresetPoints(preset.offsetXKeyframes); + this.offsetYKeyframeBuilder.addPresetPoints(preset.offsetYKeyframes); + this.scaleKeyframeBuilder.addPresetPoints(preset.scaleKeyframes); + this.opacityKeyframeBuilder.addPresetPoints(preset.opacityKeyframes); + this.rotationKeyframeBuilder.addPresetPoints(preset.rotationKeyframes); } + // Curves only transitions drive, resting at a value that leaves the clip untouched. + const transitionCurve = (rest: number, ...phases: PresetPoint[][]) => { + if (!phases.some(points => points.length)) return undefined; + const builder = new KeyframeBuilder(rest); + phases.forEach(points => builder.addPresetPoints(points)); + return builder; + }; + const { in: transitionIn, out: transitionOut } = transitionSet; + + this.maskXKeyframeBuilder = transitionCurve(1, transitionIn.maskXKeyframes, transitionOut.maskXKeyframes); + // Wipe brightness sweep (wipeLeft/wipeRight) — driven through WipeFilter, not a mask. - const brightnessKeyframes = [...transitionSet.in.brightnessKeyframes, ...transitionSet.out.brightnessKeyframes]; - if (brightnessKeyframes.length) { - this.wipeBrightnessBuilder = new KeyframeBuilder(brightnessKeyframes); - this.wipeInFromRight = transitionSet.in.wipeFromRight; - this.wipeOutFromRight = transitionSet.out.wipeFromRight; - this.wipeOutStart = transitionSet.out.brightnessKeyframes[0]?.start ?? Number.POSITIVE_INFINITY; - } + this.wipeBrightnessBuilder = transitionCurve(-1, transitionIn.brightnessKeyframes, transitionOut.brightnessKeyframes); + this.wipeInFromRight = transitionIn.wipeFromRight; + this.wipeOutFromRight = transitionOut.wipeFromRight; + this.wipeOutStart = transitionOut.brightnessKeyframes[0]?.time ?? Number.POSITIVE_INFINITY; - const blurKeyframes = [...transitionSet.in.blurKeyframes, ...transitionSet.out.blurKeyframes]; - this.zoomBlurBuilder = blurKeyframes.length ? new KeyframeBuilder(blurKeyframes) : undefined; + this.zoomBlurBuilder = transitionCurve(0, transitionIn.blurKeyframes, transitionOut.blurKeyframes); - // 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. - const { origin: inOrigin } = transitionSet.in; - const { origin: outOrigin } = transitionSet.out; - const outStart = transitionSet.out.rotationKeyframes[0]?.start ?? length; - const originCurve = (from = 0.5, to?: number) => - new KeyframeBuilder(to === undefined ? from : [{ from, to, start: 0, length: outStart, interpolation: "bezier", easing: "smooth" }]); - this.rotationOrigin = - inOrigin || outOrigin ? { x: originCurve(inOrigin?.x, outOrigin?.x), y: originCurve(inOrigin?.y, outOrigin?.y) } : undefined; + const originX = transitionCurve(0.5, transitionIn.originXKeyframes, transitionOut.originXKeyframes); + const originY = transitionCurve(0.5, transitionIn.originYKeyframes, transitionOut.originYKeyframes); + this.rotationOrigin = originX && originY ? { x: originX, y: originY } : undefined; } private getRotationOriginOffset(angle: number, scale: Vector): Vector { @@ -562,7 +508,8 @@ export abstract class Player extends Entity { } public getOpacity(): number { - return this.opacityKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; + const opacity = this.opacityKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; + return Math.min(1, Math.max(0, opacity)); } public getPosition(): Vector { diff --git a/src/core/animations/composed-keyframe-builder.ts b/src/core/animations/composed-keyframe-builder.ts deleted file mode 100644 index 842a4337..00000000 --- a/src/core/animations/composed-keyframe-builder.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { type AnimationKeyframe } from "@schemas"; - -import { KeyframeBuilder } from "./keyframe-builder"; - -type CompositionMode = "additive" | "multiplicative"; - -/** - * Composes multiple keyframe layers into a single value using additive or multiplicative blending. - * - * - **Additive mode**: `base + Σ(layer deltas)` - used for offset and rotation - * - **Multiplicative mode**: `base × Π(layer factors)` - used for scale and opacity - * - * This enables effects and transitions to run simultaneously without conflicts. - */ -export class ComposedKeyframeBuilder { - private readonly baseValue: number; - private readonly mode: CompositionMode; - private readonly layers: KeyframeBuilder[] = []; - private readonly clampRange?: { min: number; max: number }; - - constructor(baseValue: number, mode: CompositionMode, clampRange?: { min: number; max: number }) { - this.baseValue = baseValue; - this.mode = mode; - this.clampRange = clampRange; - } - - /** - * Add a keyframe layer to the composition. - * For additive mode, keyframes should represent deltas (e.g., 0 → 0.1 means "move by 0.1") - * For multiplicative mode, keyframes should represent factors (e.g., 1 → 1.3 means "scale by 1.3x") - */ - addLayer(keyframes: AnimationKeyframe[]): void { - if (keyframes.length === 0) return; - - this.layers.push(new KeyframeBuilder(keyframes)); - } - - /** - * Get the composed value at a specific time. - * Combines base value with all layer values using the composition mode. - */ - getValue(time: number): number { - if (this.layers.length === 0) { - return this.baseValue; - } - - if (this.mode === "additive") { - let result = this.baseValue; - for (const layer of this.layers) { - result += layer.getValue(time); - } - return result; - } - let result = this.baseValue; - for (const layer of this.layers) { - result *= layer.getValue(time); - } - // Clamp to range if specified (e.g., [0, 1] for opacity) - if (this.clampRange) { - result = Math.max(this.clampRange.min, Math.min(this.clampRange.max, result)); - } - return result; - } -} diff --git a/src/core/animations/effect-preset-builder.ts b/src/core/animations/effect-preset-builder.ts index ff367d85..eaaf5977 100644 --- a/src/core/animations/effect-preset-builder.ts +++ b/src/core/animations/effect-preset-builder.ts @@ -1,13 +1,15 @@ -import { type ResolvedClip, type Keyframe } from "@schemas"; +import { type ResolvedClip } from "@schemas"; import { type Size } from "../layouts/geometry"; +import { type PresetPoint } from "./keyframe-builder"; + export type EffectKeyframeSet = { - offsetXKeyframes: Keyframe[]; - offsetYKeyframes: Keyframe[]; - opacityKeyframes: Keyframe[]; - scaleKeyframes: Keyframe[]; - rotationKeyframes: Keyframe[]; + offsetXKeyframes: PresetPoint[]; + offsetYKeyframes: PresetPoint[]; + opacityKeyframes: PresetPoint[]; + scaleKeyframes: PresetPoint[]; + rotationKeyframes: PresetPoint[]; }; export type RelativeEffectKeyframeSet = EffectKeyframeSet; @@ -26,11 +28,11 @@ export class EffectPresetBuilder { } public buildRelative(editSize: Size, clipSize: Size): RelativeEffectKeyframeSet { - const offsetXKeyframes: Keyframe[] = []; - const offsetYKeyframes: Keyframe[] = []; - const opacityKeyframes: Keyframe[] = []; - const scaleKeyframes: Keyframe[] = []; - const rotationKeyframes: Keyframe[] = []; + const offsetXKeyframes: PresetPoint[] = []; + const offsetYKeyframes: PresetPoint[] = []; + const opacityKeyframes: PresetPoint[] = []; + const scaleKeyframes: PresetPoint[] = []; + const rotationKeyframes: PresetPoint[] = []; const { effect, length } = this.clipConfiguration; @@ -43,15 +45,20 @@ export class EffectPresetBuilder { switch (effectName) { case "zoomIn": { - const zoomSpeed = this.getZoomSpeed(); - // Factor: starts at 1x, ends at zoomSpeed (e.g., 1.3x) - scaleKeyframes.push({ from: 1, to: zoomSpeed, start, length, interpolation: "linear" }); + // The render adds the zoom to the scale it reads at the clip's start. + const amount = this.getZoomSpeed() - 1; + scaleKeyframes.push( + { time: start, value: 0, at: start, interpolation: "linear" }, + { time: length, value: amount, at: start, interpolation: "linear" } + ); break; } case "zoomOut": { - const zoomSpeed = this.getZoomSpeed(); - // Factor: starts at zoomSpeed (e.g., 1.3x), ends at 1x - scaleKeyframes.push({ from: zoomSpeed, to: 1, start, length, interpolation: "linear" }); + const amount = this.getZoomSpeed() - 1; + scaleKeyframes.push( + { time: start, value: amount, at: start, interpolation: "linear" }, + { time: length, value: 0, at: start, interpolation: "linear" } + ); break; } case "slideLeft": @@ -71,15 +78,16 @@ export class EffectPresetBuilder { let targetOffset = this.getSlideStart(); const scaleFactor = 1 + targetOffset * 2; + // Like the render's, a slide sets the offset on its axis outright, and the scale too when it enlarges the clip. if (fittedDimension < editDimension * scaleFactor) { - scaleKeyframes.push({ from: scaleFactor, to: scaleFactor, start, length, interpolation: "linear" }); + scaleKeyframes.push({ time: start, value: scaleFactor, interpolation: "constant" }); } else { targetOffset = (fittedDimension - editDimension) / 2 / editDimension; } const [from, to] = startsPositive ? [targetOffset, -targetOffset] : [-targetOffset, targetOffset]; const targetKeyframes = isHorizontal ? offsetXKeyframes : offsetYKeyframes; - targetKeyframes.push({ from, to, start, length }); + targetKeyframes.push({ time: start, value: from, interpolation: "linear" }, { time: length, value: to, interpolation: "linear" }); break; } default: diff --git a/src/core/animations/keyframe-builder.ts b/src/core/animations/keyframe-builder.ts index c177a92a..a776575d 100644 --- a/src/core/animations/keyframe-builder.ts +++ b/src/core/animations/keyframe-builder.ts @@ -1,4 +1,4 @@ -import { type AnimationKeyframe, type NumericKeyframe } from "@schemas"; +import { type Keyframe, type NumericKeyframe } from "@schemas"; import { CurveInterpolator } from "./curve-interpolator"; @@ -13,14 +13,17 @@ export const TIME_EPSILON = 1e-6; /** A curve point as the render keeps them: a segment takes its right point's interpolation and the handles facing into it. */ type Point = { time: number; value: number; interpolation: NumericKeyframe["interpolation"]; handleLeft: number[]; handleRight: number[] }; +/** A point a preset adds, with the render's default handles. `value` is absolute, or added to the curve's value at `at`. */ +export type PresetPoint = { time: number; value: number; interpolation: NonNullable; at?: number }; + const curves = new CurveInterpolator(); // The render gives every point its default curve's handles unless a tween sets them. const [DEFAULT_RIGHT, DEFAULT_LEFT] = curves.getHandles("smooth"); export class KeyframeBuilder { - private readonly points: Point[]; + private points: Point[]; - constructor(value: AnimationKeyframe[] | number) { + constructor(value: Keyframe[] | number) { this.points = this.createPoints(value); } @@ -43,7 +46,16 @@ export class KeyframeBuilder { } } - private createPoints(value: AnimationKeyframe[] | number): Point[] { + /** Adds a preset as the render does: every value is read before any point is added, and a point replaces any at its time. */ + public addPresetPoints(points: PresetPoint[]): void { + const values = points.map(point => point.value + (point.at === undefined ? 0 : this.getValue(point.at))); + points.forEach(({ time, interpolation }, index) => { + const point = { time, value: values[index], interpolation, handleLeft: DEFAULT_LEFT, handleRight: DEFAULT_RIGHT }; + this.points = [...this.points.filter(existing => Math.abs(existing.time - time) > TIME_EPSILON), point].sort((a, b) => a.time - b.time); + }); + } + + private createPoints(value: Keyframe[] | number): Point[] { if (typeof value === "number") { return [{ time: 0, value, interpolation: "constant", handleLeft: DEFAULT_LEFT, handleRight: DEFAULT_RIGHT }]; } @@ -67,9 +79,9 @@ export class KeyframeBuilder { }); } - private createNormalizedKeyframes(keyframes: AnimationKeyframe[]): NumericKeyframe[] { + private createNormalizedKeyframes(keyframes: Keyframe[]): NumericKeyframe[] { return keyframes - .filter((kf): kf is AnimationKeyframe & { start: number; length: number } => Number.isFinite(kf.start) && Number.isFinite(kf.length)) + .filter((kf): kf is Keyframe & { start: number; length: number } => Number.isFinite(kf.start) && Number.isFinite(kf.length)) .sort((a, b) => a.start - b.start) .map(keyframe => ({ start: keyframe.start, diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index 35532856..4318834f 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -1,21 +1,23 @@ -import { type Vector } from "@layouts/geometry"; -import { type ResolvedClip, type AnimationKeyframe } from "@schemas"; +import { type ResolvedClip } from "@schemas"; + +import { type PresetPoint } from "./keyframe-builder"; export type TransitionKeyframeSet = { - offsetXKeyframes: AnimationKeyframe[]; - offsetYKeyframes: AnimationKeyframe[]; - opacityKeyframes: AnimationKeyframe[]; - scaleKeyframes: AnimationKeyframe[]; - rotationKeyframes: AnimationKeyframe[]; - maskXKeyframes: AnimationKeyframe[]; + offsetXKeyframes: PresetPoint[]; + offsetYKeyframes: PresetPoint[]; + opacityKeyframes: PresetPoint[]; + scaleKeyframes: PresetPoint[]; + rotationKeyframes: PresetPoint[]; + maskXKeyframes: PresetPoint[]; /** Wipe brightness sweep (+1 hidden → −1 revealed), applied through WipeFilter. */ - brightnessKeyframes: AnimationKeyframe[]; + brightnessKeyframes: PresetPoint[]; /** Zoom blur radius, in pixels of the clip before its scale applies. */ - blurKeyframes: AnimationKeyframe[]; + blurKeyframes: PresetPoint[]; + /** The point the clip rotates about, as a fraction of its size from the top left. */ + originXKeyframes: PresetPoint[]; + originYKeyframes: PresetPoint[]; /** Wipe reveal direction for this phase: true = from the right edge inward, false = from the left. */ wipeFromRight: boolean; - /** The point the clip rotates about, as a fraction of its size from the top left; the centre when unset. */ - origin?: Vector; }; export type RelativeTransitionKeyframeSet = { @@ -24,11 +26,7 @@ export type RelativeTransitionKeyframeSet = { }; type ParsedTransition = { name: string; speed: string | undefined }; - -// The render's presets use its default Bezier handles, cubic-bezier(0.5, 0, 0.5, 1). -const SMOOTH = { interpolation: "bezier", easing: "smooth" } as const; -const LINEAR = { interpolation: "linear" } as const; -type Curve = typeof SMOOTH | typeof LINEAR; +type Curve = PresetPoint["interpolation"]; // The render's shuffles swing the clip about a point well outside it: [degrees, origin x, origin y]. const SHUFFLES: Record = { @@ -76,6 +74,8 @@ export class TransitionPresetBuilder { maskXKeyframes: [], brightnessKeyframes: [], blurKeyframes: [], + originXKeyframes: [], + originYKeyframes: [], wipeFromRight: false }; } @@ -90,22 +90,27 @@ export class TransitionPresetBuilder { const start = direction === "in" ? 0 : this.clipConfiguration.length - length; const transitionName = this.getPresetName(direction); const isIn = direction === "in"; - - // The render eases between a point at the clip's edge and one where the transition meets the clip, and each - // segment takes its later point's curve: an in transition follows the inner point, an out transition the edge. - const ramp = (edge: number, rest: number, edgeCurve: Curve, innerCurve: Curve): AnimationKeyframe => - isIn ? { from: edge, to: rest, start, length, ...innerCurve } : { from: rest, to: edge, start, length, ...edgeCurve }; - const fade = ramp(0, 1, SMOOTH, SMOOTH); + const edgeTime = isIn ? 0 : this.clipConfiguration.length; + const innerTime = isIn ? length : start; + + // Like the render, a transition reads the property at both its ends, then adds a point at each: the clip's edge + // takes `edge` (added to the value read at `edgeAt`, if given) and the inner end keeps the value read there. + const ramp = (edge: number, edgeAt: number | undefined, edgeCurve: Curve, innerCurve: Curve): PresetPoint[] => { + const edgePoint = { time: edgeTime, value: edge, at: edgeAt, interpolation: edgeCurve }; + const innerPoint = { time: innerTime, value: 0, at: innerTime, interpolation: innerCurve }; + return isIn ? [edgePoint, innerPoint] : [innerPoint, edgePoint]; + }; + const fade = ramp(0, undefined, "bezier", "bezier"); switch (transitionName) { case "fade": { - keyframes.opacityKeyframes.push(fade); + keyframes.opacityKeyframes.push(...fade); break; } case "zoom": { - keyframes.scaleKeyframes.push(ramp(10, 1, SMOOTH, LINEAR)); - keyframes.opacityKeyframes.push(fade); - keyframes.blurKeyframes.push(ramp(5, 0, SMOOTH, SMOOTH)); + keyframes.scaleKeyframes.push(...ramp(9, edgeTime, "bezier", "linear")); + keyframes.opacityKeyframes.push(...fade); + keyframes.blurKeyframes.push(...ramp(5, undefined, "bezier", "bezier")); break; } case "slideLeft": @@ -126,19 +131,19 @@ export class TransitionPresetBuilder { const edge = (isIn === entersFromPositive ? 1 : -1) * distance; const targetKeyframes = isHorizontal ? keyframes.offsetXKeyframes : keyframes.offsetYKeyframes; - targetKeyframes.push(ramp(edge, 0, SMOOTH, LINEAR)); - if (isSlide) keyframes.opacityKeyframes.push(fade); + targetKeyframes.push(...ramp(edge, edgeTime, "bezier", "linear")); + if (isSlide) keyframes.opacityKeyframes.push(...fade); break; } case "reveal": { - keyframes.maskXKeyframes.push(ramp(0, 1, SMOOTH, SMOOTH)); + keyframes.maskXKeyframes.push(...ramp(0, undefined, "bezier", "bezier")); break; } case "wipeLeft": case "wipeRight": { // Brightness sweeps +1 (hidden) → −1 (revealed) in, and back out, applied via WipeFilter. // wipeLeft reveals from the right edge inward; wipeRight from the left. - keyframes.brightnessKeyframes.push(ramp(1, -1, SMOOTH, SMOOTH)); + keyframes.brightnessKeyframes.push(...ramp(1, undefined, "bezier", "bezier")); keyframes.wipeFromRight = transitionName === "wipeLeft" ? isIn : !isIn; break; } @@ -146,9 +151,11 @@ export class TransitionPresetBuilder { const shuffle = SHUFFLES[transitionName]; if (!shuffle) break; const [angle, x, y] = shuffle; - keyframes.rotationKeyframes.push(ramp(angle, 0, SMOOTH, SMOOTH)); - keyframes.scaleKeyframes.push(ramp(1.5, 1, SMOOTH, SMOOTH)); - keyframes.origin = { x, y }; + // The render measures both ends of the swing from the rotation where it meets the clip. + keyframes.rotationKeyframes.push(...ramp(angle, innerTime, "bezier", "bezier")); + keyframes.scaleKeyframes.push(...ramp(0.5, edgeTime, "bezier", "bezier")); + keyframes.originXKeyframes.push({ time: start, value: x, interpolation: "bezier" }); + keyframes.originYKeyframes.push({ time: start, value: y, interpolation: "bezier" }); break; } } diff --git a/src/core/schemas/index.ts b/src/core/schemas/index.ts index 5b582eb6..ac339e8d 100644 --- a/src/core/schemas/index.ts +++ b/src/core/schemas/index.ts @@ -122,16 +122,13 @@ export type ExtendedCaptionAsset = CaptionAsset & { // ─── Internal Animation Types ─────────────────────────────────────────────── // Keyframes with all required fields and numeric values for animation interpolation -/** A tween as the animation code reads it. `smooth` is the render's default curve, which its presets use. */ -export type AnimationKeyframe = Omit & { easing?: Tween["easing"] | "smooth" }; - export interface NumericKeyframe { start: number; length: number; from: number; to: number; interpolation?: Tween["interpolation"]; - easing?: AnimationKeyframe["easing"]; + easing?: Tween["easing"]; } // ─── Zod Schemas (for validation) ────────────────────────────────────────── diff --git a/src/core/shared/clip-utils.ts b/src/core/shared/clip-utils.ts index bc4e4013..f567efb9 100644 --- a/src/core/shared/clip-utils.ts +++ b/src/core/shared/clip-utils.ts @@ -23,9 +23,8 @@ export function isKeyframedValue(value: unknown): boolean { /** * True when any visual property is keyframed or bound. * - * Studio previews such clips without effect and transition layers, so the player - * and the toolbars must agree on the property list; keep this the only copy. - * Preview-only: rendered output composes presets over keyframes instead. + * The toolbars keep effects and transitions off such clips, so they must agree on + * the property list; keep this the only copy. */ export function hasKeyframedVisualProperty(clip: Clip): boolean { return [ diff --git a/tests/effect-preset-builder.test.ts b/tests/effect-preset-builder.test.ts index 75f1b100..67e5e577 100644 --- a/tests/effect-preset-builder.test.ts +++ b/tests/effect-preset-builder.test.ts @@ -14,6 +14,12 @@ const build = (effect: string, clipSize = EDIT, fit: ResolvedClip["fit"] = "crop effect: effect as ResolvedClip["effect"] }).buildRelative(EDIT, clipSize); +/** The offsets a slide effect pans between, over the 5 s clip. */ +const pan = (from: number, to: number) => [ + { time: 0, value: from, interpolation: "linear" }, + { time: 5, value: to, interpolation: "linear" } +]; + describe("EffectPresetBuilder", () => { it.each([ ["slideLeft", 0.08], @@ -22,27 +28,39 @@ describe("EffectPresetBuilder", () => { ])("pans %s by the render's %s of the frame, enlarged to cover it", (effect, distance) => { const { offsetXKeyframes, scaleKeyframes } = build(effect); - expect(offsetXKeyframes).toEqual([{ from: distance, to: -distance, start: 0, length: 5 }]); - expect(scaleKeyframes[0]).toMatchObject({ from: 1 + 2 * distance, to: 1 + 2 * distance }); + expect(offsetXKeyframes).toEqual(pan(distance, -distance)); + expect(scaleKeyframes).toEqual([{ time: 0, value: 1 + 2 * distance, interpolation: "constant" }]); }); it.each([ ["slideUp", -0.08, 0.08], ["slideDown", 0.08, -0.08] ])("pans %s the way its name says (offset y points up)", (effect, from, to) => { - expect(build(effect).offsetYKeyframes).toEqual([{ from, to, start: 0, length: 5 }]); + expect(build(effect).offsetYKeyframes).toEqual(pan(from, to)); }); it("enlarges narrow media by the same amount whatever its fit", () => { const { scaleKeyframes } = build("slideLeft", { width: 1080, height: 1920 }, "contain"); - expect(scaleKeyframes[0]).toMatchObject({ from: 1.16, to: 1.16 }); + expect(scaleKeyframes[0]).toMatchObject({ value: 1.16 }); }); it.each(["crop", "contain"] as const)("pans the overflow of media fitted to the frame's height (%s)", fit => { const { offsetXKeyframes, scaleKeyframes } = build("slideRight", { width: 4000, height: 1000 }, fit); - expect(offsetXKeyframes).toEqual([{ from: -0.625, to: 0.625, start: 0, length: 5 }]); + expect(offsetXKeyframes).toEqual(pan(-0.625, 0.625)); expect(scaleKeyframes).toEqual([]); }); + + it.each([ + ["zoomIn", 0, 0.3], + ["zoomInSlow", 0, 0.1], + ["zoomOutFast", 0.7, 0] + ])("zooms %s from the scale at the clip's start, adding %s then %s", (effect, from, to) => { + const [first, last] = build(effect).scaleKeyframes; + + expect(first).toEqual({ time: 0, value: from, at: 0, interpolation: "linear" }); + expect(last).toMatchObject({ time: 5, at: 0, interpolation: "linear" }); + expect(last.value).toBeCloseTo(to); + }); }); diff --git a/tests/player-presets.test.ts b/tests/player-presets.test.ts new file mode 100644 index 00000000..ac5a0e9e --- /dev/null +++ b/tests/player-presets.test.ts @@ -0,0 +1,87 @@ +import { Player, PlayerType } from "@canvas/players/player"; +import type { Edit } from "@core/edit-session"; +import { sec } from "@core/timing/types"; +import type { Size } from "@layouts/geometry"; +import type { ResolvedClip } from "@schemas"; + +jest.mock("pixi.js", () => ({ + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports -- jest.mock factories can't use imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, + Container: jest.fn().mockImplementation(() => ({ addChild: jest.fn() })) +})); + +const EDIT_SIZE = { width: 1920, height: 1080 }; + +/** A still that fills the frame. */ +class StillPlayer extends Player { + public override getSize(): Size { + return this.edit.size; + } +} + +/** The player for a 5 s clip, read at a given time. */ +const play = (fields: Partial): ((time: number) => Player) => { + const edit = { playbackTime: 0, size: EDIT_SIZE }; + const clip = { id: "clip-1", asset: { type: "image", src: "https://example.com/a.png" }, start: sec(0), length: sec(5), ...fields }; + const player = new StillPlayer(edit as unknown as Edit, clip as ResolvedClip, PlayerType.Image); + player.reconfigureAfterRestore(); + return time => { + edit.playbackTime = time; + return player; + }; +}; + +describe("Player presets", () => { + it("fades a static opacity in along the render's default curve", () => { + const at = play({ opacity: 0.8, transition: { in: "fade" } }); + + expect(at(0).getOpacity()).toBe(0); + expect(at(0.5).getOpacity()).toBeCloseTo(0.4); + expect(at(2).getOpacity()).toBeCloseTo(0.8); + }); + + it("plays an empty tween list as the property's default", () => { + expect(play({ opacity: [], transition: { in: "fade" } })(2).getOpacity()).toBe(1); + }); + + it("fades in from the keyframed opacity where the fade ends", () => { + const at = play({ opacity: [{ from: 0.5, to: 1, start: 0, length: 5, interpolation: "linear" }], transition: { in: "fade" } }); + + expect(at(0).getOpacity()).toBe(0); + expect(at(1).getOpacity()).toBeCloseTo(0.6); + expect(at(3).getOpacity()).toBeCloseTo(0.8); + }); + + it("keeps keyframes that fall inside a transition", () => { + const at = play({ + opacity: [ + { from: 1, to: 0.2, start: 0, length: 0.5, interpolation: "linear" }, + { from: 0.2, to: 1, start: 0.5, length: 4.5, interpolation: "linear" } + ], + transition: { in: "fade" } + }); + + expect(at(0.25).getOpacity()).toBeCloseTo(0.1); + expect(at(0.5).getOpacity()).toBeCloseTo(0.2); + }); + + it("zooms in from the render's distance added to keyframed scale", () => { + const at = play({ scale: [{ from: 0.5, to: 1, start: 0, length: 5, interpolation: "linear" }], transition: { in: "zoom" } }); + + expect(at(0).getScale()).toBeCloseTo(9.5); + expect(at(0.4).getScale()).toBeCloseTo(0.54); + }); + + it("applies the effect before the transitions", () => { + const at = play({ effect: "zoomIn", transition: { out: "zoom" } }); + + expect(at(5).getScale()).toBeCloseTo(10.3); + }); + + it("pans a slide effect from the render's offset, whatever the clip's own", () => { + const at = play({ offset: { x: 0.2, y: 0 }, effect: "slideLeft" }); + + expect(at(0).getPosition().x).toBeCloseTo(0.08 * EDIT_SIZE.width); + expect(at(5).getPosition().x).toBeCloseTo(-0.08 * EDIT_SIZE.width); + }); +}); diff --git a/tests/transition-preset-builder.test.ts b/tests/transition-preset-builder.test.ts index 1fb354bb..f404190b 100644 --- a/tests/transition-preset-builder.test.ts +++ b/tests/transition-preset-builder.test.ts @@ -11,7 +11,8 @@ const build = (transition: ResolvedClip["transition"]) => transition }).buildRelative(); -const SMOOTH = { interpolation: "bezier", easing: "smooth" }; +/** A preset point; `at` makes `value` relative to the curve there. */ +const point = (time: number, value: number, interpolation: "bezier" | "linear", at?: number) => ({ time, value, interpolation, at }); describe("TransitionPresetBuilder", () => { it.each([ @@ -28,50 +29,52 @@ describe("TransitionPresetBuilder", () => { ["shuffleTopRightFast", 0.5] ])("runs %s for the render's %s seconds", (name, length) => { const { in: phase } = build({ in: name as never }); - const [keyframe] = [...phase.offsetXKeyframes, ...phase.offsetYKeyframes, ...phase.opacityKeyframes, ...phase.rotationKeyframes]; + const [edge, inner] = [...phase.offsetXKeyframes, ...phase.offsetYKeyframes, ...phase.opacityKeyframes, ...phase.rotationKeyframes]; - expect(keyframe.length).toBe(length); + expect(inner.time - edge.time).toBe(length); }); it.each([ - ["shuffleBottomLeft", 17, { x: -1.6, y: 0.5 }], - ["shuffleBottomRight", -17, { x: 2.6, y: 0.5 }], - ["shuffleTopLeft", -17, { x: -1.6, y: 0.5 }], - ["shuffleTopRight", 17, { x: 2.6, y: 0.5 }], - ["shuffleLeftBottom", -25, { x: 0.5, y: 3.6 }], - ["shuffleLeftTop", 25, { x: 0.5, y: -2.6 }], - ["shuffleRightBottom", 25, { x: 0.5, y: 3.6 }], - ["shuffleRightTop", -25, { x: 0.5, y: -2.6 }] - ])("swings %s by %s degrees about the render's origin", (name, angle, origin) => { + ["shuffleBottomLeft", 17, -1.6, 0.5], + ["shuffleBottomRight", -17, 2.6, 0.5], + ["shuffleTopLeft", -17, -1.6, 0.5], + ["shuffleTopRight", 17, 2.6, 0.5], + ["shuffleLeftBottom", -25, 0.5, 3.6], + ["shuffleLeftTop", 25, 0.5, -2.6], + ["shuffleRightBottom", 25, 0.5, 3.6], + ["shuffleRightTop", -25, 0.5, -2.6] + ])("swings %s by %s degrees about the render's origin", (name, angle, x, y) => { const { in: phaseIn, out: phaseOut } = build({ in: name as never, out: name as never }); - expect(phaseIn.rotationKeyframes).toEqual([{ from: angle, to: 0, start: 0, length: 1, ...SMOOTH }]); - expect(phaseOut.rotationKeyframes).toEqual([{ from: 0, to: angle, start: 4, length: 1, ...SMOOTH }]); - expect(phaseIn.origin).toEqual(origin); - expect(phaseOut.origin).toEqual(origin); + expect(phaseIn.rotationKeyframes).toEqual([point(0, angle, "bezier", 1), point(1, 0, "bezier", 1)]); + expect(phaseOut.rotationKeyframes).toEqual([point(4, 0, "bezier", 4), point(5, angle, "bezier", 4)]); + expect(phaseIn.originXKeyframes).toEqual([point(0, x, "bezier")]); + expect(phaseIn.originYKeyframes).toEqual([point(0, y, "bezier")]); + expect(phaseOut.originXKeyframes).toEqual([point(4, x, "bezier")]); + expect(phaseOut.originYKeyframes).toEqual([point(4, y, "bezier")]); }); it("grows shuffles by half at the clip's edge without fading them", () => { const { in: phaseIn, out: phaseOut } = build({ in: "shuffleLeftTop", out: "shuffleLeftTop" }); - expect(phaseIn.scaleKeyframes).toEqual([{ from: 1.5, to: 1, start: 0, length: 1, ...SMOOTH }]); - expect(phaseOut.scaleKeyframes).toEqual([{ from: 1, to: 1.5, start: 4, length: 1, ...SMOOTH }]); + expect(phaseIn.scaleKeyframes).toEqual([point(0, 0.5, "bezier", 0), point(1, 0, "bezier", 1)]); + expect(phaseOut.scaleKeyframes).toEqual([point(4, 0, "bezier", 4), point(5, 0.5, "bezier", 5)]); expect([...phaseIn.opacityKeyframes, ...phaseOut.opacityKeyframes]).toEqual([]); }); - it("eases fades with the render's default curve", () => { + it("fades from nothing to the opacity where the fade meets the clip, along the render's default curve", () => { const { in: fadeIn, out: fadeOut } = build({ in: "fade", out: "fade" }); - expect(fadeIn.opacityKeyframes).toEqual([{ from: 0, to: 1, start: 0, length: 1, ...SMOOTH }]); - expect(fadeOut.opacityKeyframes).toEqual([{ from: 1, to: 0, start: 4, length: 1, ...SMOOTH }]); + expect(fadeIn.opacityKeyframes).toEqual([point(0, 0, "bezier"), point(1, 0, "bezier", 1)]); + expect(fadeOut.opacityKeyframes).toEqual([point(4, 0, "bezier", 4), point(5, 0, "bezier")]); }); it("moves slides linearly on the way in and along the default curve on the way out", () => { const { in: slideIn, out: slideOut } = build({ in: "slideLeft", out: "slideLeft" }); - expect(slideIn.offsetXKeyframes).toEqual([{ from: 0.025, to: 0, start: 0, length: 0.5, interpolation: "linear" }]); - expect(slideOut.offsetXKeyframes).toEqual([{ from: 0, to: -0.025, start: 4.5, length: 0.5, ...SMOOTH }]); - expect(slideIn.opacityKeyframes).toEqual([{ from: 0, to: 1, start: 0, length: 0.5, ...SMOOTH }]); + expect(slideIn.offsetXKeyframes).toEqual([point(0, 0.025, "bezier", 0), point(0.5, 0, "linear", 0.5)]); + expect(slideOut.offsetXKeyframes).toEqual([point(4.5, 0, "linear", 4.5), point(5, -0.025, "bezier", 5)]); + expect(slideIn.opacityKeyframes).toEqual([point(0, 0, "bezier"), point(0.5, 0, "bezier", 0.5)]); }); it.each([ @@ -79,38 +82,34 @@ describe("TransitionPresetBuilder", () => { ["slideDown", 0.025, -0.025], ["carouselUp", -1.05, 1.1], ["carouselDown", 1.05, -1.1] - ])("moves %s the way its name says (offset y points up)", (name, inFrom, outTo) => { + ])("moves %s the way its name says (offset y points up)", (name, inEdge, outEdge) => { const { in: phaseIn, out: phaseOut } = build({ in: name as never, out: name as never }); - expect(phaseIn.offsetYKeyframes[0]).toMatchObject({ from: inFrom, to: 0 }); - expect(phaseOut.offsetYKeyframes[0]).toMatchObject({ from: 0, to: outTo }); + expect(phaseIn.offsetYKeyframes[0]).toMatchObject({ time: 0, value: inEdge }); + expect(phaseOut.offsetYKeyframes[1]).toMatchObject({ time: 5, value: outEdge }); }); it("eases carousels out along the default curve", () => { - expect(build({ out: "carouselLeft" }).out.offsetXKeyframes).toEqual([{ from: 0, to: -1, start: 4.5, length: 0.5, ...SMOOTH }]); + expect(build({ out: "carouselLeft" }).out.offsetXKeyframes).toEqual([point(4.5, 0, "linear", 4.5), point(5, -1, "bezier", 5)]); }); - it("zooms in linearly, zooms out along the default curve and fades both ways on the default curve", () => { + it("zooms by adding the render's distance to the scale, linearly in and along the default curve out", () => { const { in: zoomIn, out: zoomOut } = build({ in: "zoom", out: "zoom" }); - expect(zoomIn.scaleKeyframes).toEqual([{ from: 10, to: 1, start: 0, length: 0.4, interpolation: "linear" }]); - expect(zoomOut.scaleKeyframes).toEqual([{ from: 1, to: 10, start: 4.6, length: 0.4, ...SMOOTH }]); - expect(zoomIn.opacityKeyframes[0]).toMatchObject(SMOOTH); - expect(zoomOut.opacityKeyframes[0]).toMatchObject(SMOOTH); + expect(zoomIn.scaleKeyframes).toEqual([point(0, 9, "bezier", 0), point(0.4, 0, "linear", 0.4)]); + expect(zoomOut.scaleKeyframes).toEqual([point(4.6, 0, "linear", 4.6), point(5, 9, "bezier", 5)]); + expect(zoomIn.opacityKeyframes.map(({ interpolation }) => interpolation)).toEqual(["bezier", "bezier"]); }); it("blurs zooms by 5 pixels at the clip's edge", () => { const { in: zoomIn, out: zoomOut } = build({ in: "zoom", out: "zoom" }); - expect(zoomIn.blurKeyframes).toEqual([{ from: 5, to: 0, start: 0, length: 0.4, ...SMOOTH }]); - expect(zoomOut.blurKeyframes).toEqual([{ from: 0, to: 5, start: 4.6, length: 0.4, ...SMOOTH }]); + expect(zoomIn.blurKeyframes).toEqual([point(0, 5, "bezier"), point(0.4, 0, "bezier", 0.4)]); + expect(zoomOut.blurKeyframes).toEqual([point(4.6, 0, "bezier", 4.6), point(5, 5, "bezier")]); }); it("eases reveals and wipes along the default curve", () => { - const { in: reveal } = build({ in: "reveal" }); - const { in: wipe } = build({ in: "wipeLeft" }); - - expect(reveal.maskXKeyframes[0]).toMatchObject(SMOOTH); - expect(wipe.brightnessKeyframes[0]).toMatchObject(SMOOTH); + expect(build({ in: "reveal" }).in.maskXKeyframes).toEqual([point(0, 0, "bezier"), point(1, 0, "bezier", 1)]); + expect(build({ in: "wipeLeft" }).in.brightnessKeyframes).toEqual([point(0, 1, "bezier"), point(1, 0, "bezier", 1)]); }); });