mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 01:15:18 +08:00
refactor(choreography): drive overlays from descriptors (#947)
* feat(choreography): add Motion descriptor adapter * fix(choreography): harden descriptor lookups * fix(choreography): preserve descriptor special keys * refactor(choreography): drive overlays from descriptors * fix(choreography): preserve spotlight fade easing * fix(choreography): align descriptor consumers * docs(choreography): clarify spotlight easing
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'motion/react';
|
||||
import { laserV1 } from '@/lib/choreography';
|
||||
import type { PercentageGeometry } from '@/lib/types/action';
|
||||
import { resolveMotionLayer } from './motion-descriptor-adapter';
|
||||
|
||||
interface LaserOverlayProps {
|
||||
geometry: PercentageGeometry;
|
||||
@@ -17,66 +19,38 @@ interface LaserOverlayProps {
|
||||
* - Elegant light dot with soft breathing glow
|
||||
* - Uses percentage positioning (0-100)
|
||||
*/
|
||||
export function LaserOverlay({
|
||||
geometry,
|
||||
color = '#ff3b30',
|
||||
duration: _duration = 3000,
|
||||
}: LaserOverlayProps) {
|
||||
export function LaserOverlay({ geometry, color, duration: _duration = 3000 }: LaserOverlayProps) {
|
||||
const { centerX, centerY } = geometry;
|
||||
|
||||
const startPos = {
|
||||
x: centerX > 50 ? 105 : -5,
|
||||
y: centerY > 50 ? 105 : -5,
|
||||
const layerOptions = {
|
||||
geometry,
|
||||
units: { left: '%', top: '%' },
|
||||
...(color !== undefined ? { params: { color } } : {}),
|
||||
};
|
||||
const dot = resolveMotionLayer(laserV1, 'dot', layerOptions);
|
||||
const ring = resolveMotionLayer(laserV1, 'ring', layerOptions);
|
||||
const core = resolveMotionLayer(laserV1, 'core', layerOptions);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`laser-${centerX}-${centerY}`}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
left: `${startPos.x}%`,
|
||||
top: `${startPos.y}%`,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
left: `${centerX}%`,
|
||||
top: `${centerY}%`,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
left: `${startPos.x}%`,
|
||||
top: `${startPos.y}%`,
|
||||
transition: { duration: 0.25, ease: [0.4, 0, 1, 1] },
|
||||
}}
|
||||
transition={{
|
||||
left: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
|
||||
top: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
|
||||
opacity: { duration: 0.15 },
|
||||
}}
|
||||
className="absolute z-[101] pointer-events-none"
|
||||
initial={dot.initial}
|
||||
animate={dot.animate}
|
||||
exit={dot.exit}
|
||||
transition={dot.transition}
|
||||
className="absolute pointer-events-none"
|
||||
style={{ zIndex: laserV1.zIndex }}
|
||||
>
|
||||
<div className="relative -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="relative" style={dot.staticProps}>
|
||||
{/* Ring pulse */}
|
||||
<motion.div
|
||||
animate={{ scale: [1, 2.8], opacity: [0.6, 0] }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 1.5,
|
||||
ease: 'easeOut',
|
||||
repeatDelay: 0.3,
|
||||
}}
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{ border: `1.5px solid ${color}` }}
|
||||
initial={ring.initial}
|
||||
animate={ring.animate}
|
||||
transition={ring.transition}
|
||||
style={ring.staticProps}
|
||||
/>
|
||||
|
||||
{/* Light core */}
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
boxShadow: `0 0 8px 2px ${color}60`,
|
||||
}}
|
||||
/>
|
||||
<div style={core.staticProps} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { useRef, useState, useLayoutEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { spotlightV1 } from '@/lib/choreography';
|
||||
import { useSceneSelector } from '@/lib/contexts/scene-context';
|
||||
import { useCanvasStore } from '@/lib/store/canvas';
|
||||
import type { SlideContent } from '@/lib/types/stage';
|
||||
import type { PPTElement } from '@openmaic/dsl';
|
||||
import { resolveMotionLayer } from './motion-descriptor-adapter';
|
||||
|
||||
interface SpotlightRect {
|
||||
x: number;
|
||||
@@ -80,20 +82,31 @@ export function SpotlightOverlay({ domIdPrefix = 'screen-element-' }: SpotlightO
|
||||
}, [measure, elements]);
|
||||
|
||||
const active = !!spotlightElementId && !!spotlightOptions && !!rect;
|
||||
const dimness = spotlightOptions?.dimness ?? 0.7;
|
||||
const resolvedLayers = rect
|
||||
? {
|
||||
dim: resolveMotionLayer(spotlightV1, 'dim', {
|
||||
geometry: rect,
|
||||
params: { dimness: spotlightOptions?.dimness },
|
||||
}),
|
||||
cutout: resolveMotionLayer(spotlightV1, 'cutout', { geometry: rect }),
|
||||
border: resolveMotionLayer(spotlightV1, 'border', { geometry: rect }),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-[100] pointer-events-none overflow-hidden"
|
||||
className="absolute inset-0 pointer-events-none overflow-hidden"
|
||||
style={{ zIndex: spotlightV1.zIndex }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{active && rect && (
|
||||
{active && rect && resolvedLayers && (
|
||||
<motion.div
|
||||
key={`spotlight-${spotlightElementId}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={resolvedLayers.dim.initial}
|
||||
animate={resolvedLayers.dim.animate}
|
||||
exit={resolvedLayers.dim.exit}
|
||||
transition={resolvedLayers.dim.transition}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<svg
|
||||
@@ -109,25 +122,10 @@ export function SpotlightOverlay({ domIdPrefix = 'screen-element-' }: SpotlightO
|
||||
<rect x="0" y="0" width="100" height="100" fill="white" />
|
||||
{/* Black rectangle = hide mask layer (highlighted area / cutout) */}
|
||||
<motion.rect
|
||||
fill="black"
|
||||
initial={{
|
||||
x: rect.x - 8,
|
||||
y: rect.y - 8,
|
||||
width: rect.w + 16,
|
||||
height: rect.h + 16,
|
||||
rx: 4,
|
||||
}}
|
||||
animate={{
|
||||
x: rect.x - 0.4,
|
||||
y: rect.y - 0.6,
|
||||
width: rect.w + 0.8,
|
||||
height: rect.h + 1.2,
|
||||
rx: 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
{...resolvedLayers.cutout.staticProps}
|
||||
initial={resolvedLayers.cutout.initial}
|
||||
animate={resolvedLayers.cutout.animate}
|
||||
transition={resolvedLayers.cutout.transition}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
@@ -138,40 +136,14 @@ export function SpotlightOverlay({ domIdPrefix = 'screen-element-' }: SpotlightO
|
||||
Tailwind 3 silently dropped `backdrop-blur-[1.5px]` on SVG via
|
||||
--tw-* variables; Tailwind 4 emits the property directly and
|
||||
surfaced the bug. */}
|
||||
<rect
|
||||
width="100"
|
||||
height="100"
|
||||
fill={`rgba(0,0,0,${dimness})`}
|
||||
mask={`url(#mask-${spotlightElementId})`}
|
||||
/>
|
||||
<rect {...resolvedLayers.dim.staticProps} mask={`url(#mask-${spotlightElementId})`} />
|
||||
|
||||
{/* THE ONE BORDER - white border */}
|
||||
<motion.rect
|
||||
initial={{
|
||||
x: rect.x - 4,
|
||||
y: rect.y - 4,
|
||||
width: rect.w + 8,
|
||||
height: rect.h + 8,
|
||||
opacity: 0,
|
||||
rx: 2,
|
||||
}}
|
||||
animate={{
|
||||
x: rect.x - 0.4,
|
||||
y: rect.y - 0.6,
|
||||
width: rect.w + 0.8,
|
||||
height: rect.h + 1.2,
|
||||
opacity: 1,
|
||||
rx: 1,
|
||||
}}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.7)"
|
||||
strokeWidth="1.2"
|
||||
style={{ vectorEffect: 'non-scaling-stroke' } as React.CSSProperties}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.05,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
{...resolvedLayers.border.staticProps}
|
||||
initial={resolvedLayers.border.initial}
|
||||
animate={resolvedLayers.border.animate}
|
||||
transition={resolvedLayers.border.transition}
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { Target, TargetAndTransition, Transition } from 'motion/react';
|
||||
import type { AnimatableValue, AnimationDescriptor, GeometryRef, Track } from '@/lib/choreography';
|
||||
|
||||
export interface ResolvedMotionLayer {
|
||||
initial: Target;
|
||||
animate: Target;
|
||||
exit?: TargetAndTransition;
|
||||
transition: Transition;
|
||||
staticProps: Record<string, string | number>;
|
||||
}
|
||||
|
||||
type MotionValue = string | number;
|
||||
type MotionTarget = Record<string, MotionValue | MotionValue[]>;
|
||||
type Params = Record<string, string | number>;
|
||||
|
||||
function hasOwn(record: object, property: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(record, property);
|
||||
}
|
||||
|
||||
function setOwn<T extends Record<string, unknown>>(
|
||||
record: T,
|
||||
property: string,
|
||||
value: T[string],
|
||||
): void {
|
||||
Object.defineProperty(record, property, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function requireGeometry(ref: GeometryRef, geometry: Partial<Record<GeometryRef, number>>): number {
|
||||
const value = geometry[ref];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing geometry field "${ref}"`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveValue(
|
||||
value: AnimatableValue,
|
||||
geometry: Partial<Record<GeometryRef, number>>,
|
||||
params: Params,
|
||||
descriptorId: string,
|
||||
): MotionValue {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return resolveParameterizedValue(value, params, descriptorId);
|
||||
}
|
||||
|
||||
if ('ref' in value) {
|
||||
return requireGeometry(value.ref, geometry) * (value.scale ?? 1) + (value.offset ?? 0);
|
||||
}
|
||||
|
||||
return requireGeometry(value.axis, geometry) > value.threshold
|
||||
? value.whenAbove
|
||||
: value.whenBelow;
|
||||
}
|
||||
|
||||
function applyUnit(value: MotionValue, unit: string | undefined): MotionValue {
|
||||
return unit !== undefined && typeof value === 'number' ? `${value}${unit}` : value;
|
||||
}
|
||||
|
||||
function resolveTransition(track: Track): Transition {
|
||||
const transition: Record<string, unknown> = {};
|
||||
|
||||
if (track.durationMs !== undefined) transition.duration = track.durationMs / 1000;
|
||||
if (track.delayMs !== undefined) transition.delay = track.delayMs / 1000;
|
||||
|
||||
if (track.easing?.type === 'cubicBezier') {
|
||||
transition.ease = track.easing.points;
|
||||
} else if (track.easing?.type === 'named') {
|
||||
transition.ease = track.easing.name;
|
||||
} else if (track.easing?.type === 'spring') {
|
||||
transition.type = 'spring';
|
||||
transition.stiffness = track.easing.stiffness;
|
||||
transition.damping = track.easing.damping;
|
||||
if (track.easing.mass !== undefined) transition.mass = track.easing.mass;
|
||||
}
|
||||
|
||||
if (track.repeat !== undefined) {
|
||||
transition.repeat = track.repeat === 'infinite' ? Infinity : track.repeat;
|
||||
}
|
||||
if (track.repeatDelayMs !== undefined) {
|
||||
transition.repeatDelay = track.repeatDelayMs / 1000;
|
||||
}
|
||||
|
||||
return transition as Transition;
|
||||
}
|
||||
|
||||
function resolveParameterizedValue(value: string | number, params: Params, descriptorId: string) {
|
||||
if (typeof value === 'number') return value;
|
||||
|
||||
const exactMatch = /^\{([^{}]+)\}$/.exec(value);
|
||||
if (exactMatch) {
|
||||
return requireParameter(exactMatch[1], params, descriptorId);
|
||||
}
|
||||
|
||||
return value.replace(/\{([^{}]+)\}/g, (_placeholder, name: string) =>
|
||||
String(requireParameter(name, params, descriptorId)),
|
||||
);
|
||||
}
|
||||
|
||||
function requireParameter(name: string, params: Params, descriptorId: string): string | number {
|
||||
const value = params[name];
|
||||
if (!hasOwn(params, name) || value === undefined) {
|
||||
throw new Error(`Unresolved parameter "${name}" in descriptor "${descriptorId}"`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolveMotionLayer(
|
||||
descriptor: AnimationDescriptor,
|
||||
layerId: string,
|
||||
options?: {
|
||||
geometry?: Partial<Record<GeometryRef, number>>;
|
||||
params?: Record<string, string | number | undefined>;
|
||||
units?: Partial<Record<string, string>>;
|
||||
},
|
||||
): ResolvedMotionLayer {
|
||||
const layer = descriptor.layers.find((candidate) => candidate.id === layerId);
|
||||
if (!layer) {
|
||||
throw new Error(`Layer "${layerId}" was not found in descriptor "${descriptor.id}"`);
|
||||
}
|
||||
|
||||
const params: Params = {};
|
||||
for (const [name, value] of Object.entries(descriptor.params ?? {})) {
|
||||
setOwn(params, name, value);
|
||||
}
|
||||
for (const [name, value] of Object.entries(options?.params ?? {})) {
|
||||
if (value !== undefined) setOwn(params, name, value);
|
||||
}
|
||||
|
||||
const geometry = options?.geometry ?? {};
|
||||
const initial: MotionTarget = {};
|
||||
const animate: MotionTarget = {};
|
||||
const transition: Record<string, Transition> = {};
|
||||
const exitTarget: MotionTarget = {};
|
||||
const exitTransition: Record<string, Transition> = {};
|
||||
let hasExit = false;
|
||||
|
||||
for (const track of layer.tracks) {
|
||||
const units = options?.units;
|
||||
const unit = units && hasOwn(units, track.property) ? units[track.property] : undefined;
|
||||
const from = applyUnit(resolveValue(track.from, geometry, params, descriptor.id), unit);
|
||||
const to = applyUnit(resolveValue(track.to, geometry, params, descriptor.id), unit);
|
||||
const propertyTransition = resolveTransition(track);
|
||||
|
||||
if ((track.phase ?? 'enter') === 'exit') {
|
||||
hasExit = true;
|
||||
setOwn(exitTarget, track.property, to);
|
||||
setOwn(exitTransition, track.property, propertyTransition);
|
||||
} else {
|
||||
if (track.repeat !== undefined) {
|
||||
setOwn(animate, track.property, [from, to]);
|
||||
} else {
|
||||
setOwn(initial, track.property, from);
|
||||
setOwn(animate, track.property, to);
|
||||
}
|
||||
setOwn(transition, track.property, propertyTransition);
|
||||
}
|
||||
}
|
||||
|
||||
const staticProps: Record<string, string | number> = {};
|
||||
for (const [property, value] of Object.entries(layer.staticProps ?? {})) {
|
||||
setOwn(staticProps, property, resolveParameterizedValue(value, params, descriptor.id));
|
||||
}
|
||||
|
||||
return {
|
||||
initial: initial as Target,
|
||||
animate: animate as Target,
|
||||
...(hasExit
|
||||
? {
|
||||
exit: {
|
||||
...exitTarget,
|
||||
transition: exitTransition as Transition,
|
||||
} as TargetAndTransition,
|
||||
}
|
||||
: {}),
|
||||
transition: transition as Transition,
|
||||
staticProps,
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import type { AnimationDescriptor } from './types';
|
||||
* the component's own `?? 0.7` fallback is unreachable at playback. The exporter
|
||||
* must use 0.5 to match.
|
||||
*
|
||||
* Shared easing `[0.16, 1, 0.3, 1]` (the spotlight expo-out).
|
||||
* Cutout/border easing `[0.16, 1, 0.3, 1]` (the spotlight expo-out).
|
||||
*/
|
||||
export const spotlightV1: AnimationDescriptor = {
|
||||
id: 'spotlight.v1',
|
||||
@@ -147,13 +147,28 @@ export const spotlightV1: AnimationDescriptor = {
|
||||
staticProps: { x: 0, y: 0, width: 100, height: 100, fill: 'rgba(0,0,0,{dimness})' },
|
||||
// The whole effect is wrapped in a motion.div that fades opacity 0→1 on
|
||||
// enter and →0 on exit. The source sets no explicit transition, so Motion
|
||||
// applies its default tween (300ms) — pinned here explicitly so a
|
||||
// non-Motion consumer (the exporter) reproduces the fade instead of
|
||||
// popping the spotlight on/off. This layer carries the canonical wrapper
|
||||
// fade; the border layer inherits its opacity so both fade out together.
|
||||
// applies its default 300ms tween with [0.25, 0.1, 0.35, 1] easing. Both
|
||||
// values are pinned here so consumers reproduce that implicit source
|
||||
// transition instead of selecting a different engine default. This layer
|
||||
// carries the canonical wrapper fade; the border layer inherits its
|
||||
// opacity so both fade out together.
|
||||
tracks: [
|
||||
{ property: 'opacity', from: 0, to: 1, durationMs: 300, phase: 'enter' },
|
||||
{ property: 'opacity', from: 1, to: 0, durationMs: 300, phase: 'exit' },
|
||||
{
|
||||
property: 'opacity',
|
||||
from: 0,
|
||||
to: 1,
|
||||
durationMs: 300,
|
||||
easing: { type: 'cubicBezier', points: [0.25, 0.1, 0.35, 1] },
|
||||
phase: 'enter',
|
||||
},
|
||||
{
|
||||
property: 'opacity',
|
||||
from: 1,
|
||||
to: 0,
|
||||
durationMs: 300,
|
||||
easing: { type: 'cubicBezier', points: [0.25, 0.1, 0.35, 1] },
|
||||
phase: 'exit',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface EmittedEffect {
|
||||
|
||||
/** Named easing identifiers defined in the composition's inline script (see `EASE_DEFS`). */
|
||||
const EASE = {
|
||||
/** spotlight wrapper fade — cubic-bezier(0.25, 0.1, 0.35, 1). */
|
||||
spotlightFade: 'EASE_SPOTLIGHT_FADE',
|
||||
/** spotlight cutout/border — cubic-bezier(0.16, 1, 0.3, 1). */
|
||||
outExpo: 'EASE_OUT_EXPO',
|
||||
/** laser enter travel — cubic-bezier(0.22, 1, 0.36, 1). */
|
||||
@@ -67,6 +69,7 @@ function cubicBezier(x1, y1, x2, y2) {
|
||||
}
|
||||
return function (p) { return p <= 0 ? 0 : p >= 1 ? 1 : sampleY(solveX(p)); };
|
||||
}
|
||||
var EASE_SPOTLIGHT_FADE = cubicBezier(0.25, 0.1, 0.35, 1);
|
||||
var EASE_OUT_EXPO = cubicBezier(0.16, 1, 0.3, 1);
|
||||
var EASE_LASER = cubicBezier(0.22, 1, 0.36, 1);
|
||||
var EASE_IN = cubicBezier(0.4, 0, 1, 1);
|
||||
@@ -107,10 +110,10 @@ function emitSpotlight(seg: EffectSegment, g: PercentageGeometry, id: string): E
|
||||
const cutTo = `{x:${n(g.x - 0.4)},y:${n(g.y - 0.6)},width:${n(g.w + 0.8)},height:${n(g.h + 1.2)},rx:1}`;
|
||||
const end = sec(seg.startMs + seg.durationMs);
|
||||
const statements = [
|
||||
`tl.fromTo('#${id}',{autoAlpha:0},{autoAlpha:1,duration:0.3,ease:'none'},${n(start)});`,
|
||||
`tl.fromTo('#${id}',{autoAlpha:0},{autoAlpha:1,duration:0.3,ease:${EASE.spotlightFade}},${n(start)});`,
|
||||
`tl.fromTo('${cut}',{attr:{x:${n(g.x - 8)},y:${n(g.y - 8)},width:${n(g.w + 16)},height:${n(g.h + 16)},rx:4}},{attr:${cutTo},duration:0.6,ease:${EASE.outExpo}},${n(start)});`,
|
||||
`tl.fromTo('${border}',{attr:{x:${n(g.x - 4)},y:${n(g.y - 4)},width:${n(g.w + 8)},height:${n(g.h + 8)},rx:2},opacity:0},{attr:${cutTo},opacity:1,duration:0.5,ease:${EASE.outExpo}},${n(start + 0.05)});`,
|
||||
`tl.to('#${id}',{autoAlpha:0,duration:0.3,ease:'none'},${n(exit)});`,
|
||||
`tl.to('#${id}',{autoAlpha:0,duration:0.3,ease:${EASE.spotlightFade}},${n(exit)});`,
|
||||
// Hard kill at the boundary: a non-linear seek landing after the fade must
|
||||
// find the overlay hidden, not in a stale mid-fade state.
|
||||
`tl.set('#${id}',{autoAlpha:0},${n(end)});`,
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AnimationDescriptor } from '@/lib/choreography';
|
||||
import { laserV1, spotlightV1 } from '@/lib/choreography';
|
||||
import { resolveMotionLayer } from '@/components/slide-renderer/Editor/motion-descriptor-adapter';
|
||||
|
||||
describe('resolveMotionLayer', () => {
|
||||
it('resolves the Spotlight cutout geometry and cubic transitions', () => {
|
||||
const layer = resolveMotionLayer(spotlightV1, 'cutout', {
|
||||
geometry: { x: 20, y: 30, w: 40, h: 10 },
|
||||
});
|
||||
|
||||
expect(layer).toEqual({
|
||||
initial: { x: 12, y: 22, width: 56, height: 26, rx: 4 },
|
||||
animate: { x: 19.6, y: 29.4, width: 40.8, height: 11.2, rx: 1 },
|
||||
transition: {
|
||||
x: { duration: 0.6, ease: [0.16, 1, 0.3, 1] },
|
||||
y: { duration: 0.6, ease: [0.16, 1, 0.3, 1] },
|
||||
width: { duration: 0.6, ease: [0.16, 1, 0.3, 1] },
|
||||
height: { duration: 0.6, ease: [0.16, 1, 0.3, 1] },
|
||||
rx: { duration: 0.6, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
staticProps: { fill: '#000000' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a zero Spotlight dimness override and resolves enter and exit fades', () => {
|
||||
const layer = resolveMotionLayer(spotlightV1, 'dim', {
|
||||
params: { dimness: 0 },
|
||||
});
|
||||
|
||||
expect(layer).toEqual({
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
opacity: { duration: 0.3, ease: [0.25, 0.1, 0.35, 1] },
|
||||
},
|
||||
},
|
||||
transition: {
|
||||
opacity: { duration: 0.3, ease: [0.25, 0.1, 0.35, 1] },
|
||||
},
|
||||
staticProps: { x: 0, y: 0, width: 100, height: 100, fill: 'rgba(0,0,0,0)' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'above',
|
||||
centerX: 75,
|
||||
centerY: 80,
|
||||
startLeft: '105%',
|
||||
startTop: '105%',
|
||||
},
|
||||
{
|
||||
name: 'below',
|
||||
centerX: 25,
|
||||
centerY: 40,
|
||||
startLeft: '-5%',
|
||||
startTop: '-5%',
|
||||
},
|
||||
{
|
||||
name: 'exactly at the threshold',
|
||||
centerX: 50,
|
||||
centerY: 50,
|
||||
startLeft: '-5%',
|
||||
startTop: '-5%',
|
||||
},
|
||||
])(
|
||||
'resolves Laser dot corners $name with percent units and source timings',
|
||||
({ centerX, centerY, startLeft, startTop }) => {
|
||||
const layer = resolveMotionLayer(laserV1, 'dot', {
|
||||
geometry: { centerX, centerY },
|
||||
units: { left: '%', top: '%' },
|
||||
});
|
||||
|
||||
expect(layer.initial).toEqual({ left: startLeft, top: startTop, opacity: 0 });
|
||||
expect(layer.animate).toEqual({ left: `${centerX}%`, top: `${centerY}%`, opacity: 1 });
|
||||
expect(layer.transition).toEqual({
|
||||
left: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
|
||||
top: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
|
||||
opacity: { duration: 0.15 },
|
||||
});
|
||||
expect(layer.exit).toEqual({
|
||||
left: startLeft,
|
||||
top: startTop,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
left: { duration: 0.25, ease: [0.4, 0, 1, 1] },
|
||||
top: { duration: 0.25, ease: [0.4, 0, 1, 1] },
|
||||
opacity: { duration: 0.25, ease: [0.4, 0, 1, 1] },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('resolves Laser ring keyframes and infinite named-ease repeats', () => {
|
||||
const layer = resolveMotionLayer(laserV1, 'ring');
|
||||
|
||||
expect(layer.initial).toEqual({});
|
||||
expect(layer.animate).toEqual({ scale: [1, 2.8], opacity: [0.6, 0] });
|
||||
expect(layer.transition).toEqual({
|
||||
scale: { duration: 1.5, ease: 'easeOut', repeat: Infinity, repeatDelay: 0.3 },
|
||||
opacity: { duration: 1.5, ease: 'easeOut', repeat: Infinity, repeatDelay: 0.3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('substitutes exact and embedded Laser core color parameters', () => {
|
||||
const layer = resolveMotionLayer(laserV1, 'core', {
|
||||
params: { color: '#12ab34' },
|
||||
});
|
||||
|
||||
expect(layer).toEqual({
|
||||
initial: {},
|
||||
animate: {},
|
||||
transition: {},
|
||||
staticProps: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 9999,
|
||||
backgroundColor: '#12ab34',
|
||||
boxShadow: '0 0 8px 2px #12ab3460',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('substitutes exact and embedded parameters in animated endpoints before applying units', () => {
|
||||
const descriptor: AnimationDescriptor = {
|
||||
id: 'animated-params.v1',
|
||||
version: 1,
|
||||
effect: 'laser',
|
||||
params: { distance: 12, color: '#ff0000', label: 'default' },
|
||||
zIndex: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'motion',
|
||||
tracks: [
|
||||
{ property: 'x', from: '{distance}', to: '{distance}' },
|
||||
{
|
||||
property: 'paint',
|
||||
from: 'start:{color}',
|
||||
to: 'end:{color}:{label}',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const layer = resolveMotionLayer(descriptor, 'motion', {
|
||||
params: { distance: 0, color: '#12ab34', label: undefined },
|
||||
units: { x: 'px' },
|
||||
});
|
||||
|
||||
expect(layer.initial).toEqual({ x: '0px', paint: 'start:#12ab34' });
|
||||
expect(layer.animate).toEqual({ x: '0px', paint: 'end:#12ab34:default' });
|
||||
});
|
||||
|
||||
it('rejects missing and inherited parameters in animated endpoints', () => {
|
||||
const missingDescriptor: AnimationDescriptor = {
|
||||
id: 'missing-animated-param.v1',
|
||||
version: 1,
|
||||
effect: 'laser',
|
||||
zIndex: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'motion',
|
||||
tracks: [{ property: 'color', from: '{required}', to: 'end:{required}' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const inheritedDescriptor: AnimationDescriptor = {
|
||||
id: 'inherited-animated-param.v1',
|
||||
version: 1,
|
||||
effect: 'laser',
|
||||
zIndex: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'motion',
|
||||
tracks: [{ property: 'color', from: '{constructor}', to: 'black' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => resolveMotionLayer(missingDescriptor, 'motion')).toThrow(
|
||||
/required.*missing-animated-param\.v1/i,
|
||||
);
|
||||
expect(() => resolveMotionLayer(inheritedDescriptor, 'motion')).toThrow(
|
||||
/constructor.*inherited-animated-param\.v1/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves an own __proto__ parameter in animated endpoints', () => {
|
||||
const descriptor: AnimationDescriptor = {
|
||||
id: 'special-animated-param.v1',
|
||||
version: 1,
|
||||
effect: 'laser',
|
||||
zIndex: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'motion',
|
||||
tracks: [{ property: 'color', from: '{__proto__}', to: 'tone:{__proto__}' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const params = JSON.parse('{"__proto__":"violet"}') as Record<
|
||||
string,
|
||||
string | number | undefined
|
||||
>;
|
||||
|
||||
const layer = resolveMotionLayer(descriptor, 'motion', { params });
|
||||
|
||||
expect(layer.initial).toEqual({ color: 'violet' });
|
||||
expect(layer.animate).toEqual({ color: 'tone:violet' });
|
||||
});
|
||||
|
||||
it('converts finite repeats and spring settings without inventing defaults', () => {
|
||||
const descriptor: AnimationDescriptor = {
|
||||
id: 'synthetic.v1',
|
||||
version: 1,
|
||||
effect: 'laser',
|
||||
params: { count: 7, label: 'default' },
|
||||
zIndex: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'pulse',
|
||||
staticProps: { exact: '{count}', embedded: 'value:{label}' },
|
||||
tracks: [
|
||||
{
|
||||
property: 'x',
|
||||
from: 1,
|
||||
to: 2,
|
||||
durationMs: 800,
|
||||
delayMs: 100,
|
||||
easing: { type: 'spring', stiffness: 120, damping: 18, mass: 0.7 },
|
||||
repeat: 2,
|
||||
repeatDelayMs: 250,
|
||||
},
|
||||
{
|
||||
property: 'visibility',
|
||||
from: 'hidden',
|
||||
to: 'visible',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const layer = resolveMotionLayer(descriptor, 'pulse', {
|
||||
params: { count: 0, label: undefined },
|
||||
units: { x: 'px' },
|
||||
});
|
||||
|
||||
expect(layer).toEqual({
|
||||
initial: { visibility: 'hidden' },
|
||||
animate: { x: ['1px', '2px'], visibility: 'visible' },
|
||||
transition: {
|
||||
x: {
|
||||
duration: 0.8,
|
||||
delay: 0.1,
|
||||
type: 'spring',
|
||||
stiffness: 120,
|
||||
damping: 18,
|
||||
mass: 0.7,
|
||||
repeat: 2,
|
||||
repeatDelay: 0.25,
|
||||
},
|
||||
visibility: {},
|
||||
},
|
||||
staticProps: { exact: 0, embedded: 'value:default' },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts an empty string parameter override', () => {
|
||||
const layer = resolveMotionLayer(laserV1, 'core', { params: { color: '' } });
|
||||
|
||||
expect(layer.staticProps.backgroundColor).toBe('');
|
||||
expect(layer.staticProps.boxShadow).toBe('0 0 8px 2px 60');
|
||||
});
|
||||
|
||||
it('rejects inherited Object prototype values as unresolved parameters', () => {
|
||||
const descriptor: AnimationDescriptor = {
|
||||
id: 'prototype-param.v1',
|
||||
version: 1,
|
||||
effect: 'laser',
|
||||
zIndex: 1,
|
||||
layers: [{ id: 'static', tracks: [], staticProps: { value: '{constructor}' } }],
|
||||
};
|
||||
|
||||
expect(() => resolveMotionLayer(descriptor, 'static')).toThrow(/constructor/);
|
||||
});
|
||||
|
||||
it('does not treat an inherited Object prototype key as a configured unit', () => {
|
||||
const descriptor: AnimationDescriptor = {
|
||||
id: 'prototype-unit.v1',
|
||||
version: 1,
|
||||
effect: 'laser',
|
||||
zIndex: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'motion',
|
||||
tracks: [{ property: 'constructor', from: 1, to: 2 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const layer = resolveMotionLayer(descriptor, 'motion', { units: {} });
|
||||
const initial = layer.initial as Record<string, unknown>;
|
||||
const animate = layer.animate as Record<string, unknown>;
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(initial, 'constructor')).toBe(true);
|
||||
expect(initial.constructor).toBe(1);
|
||||
expect(animate.constructor).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves a defined own __proto__ parameter override', () => {
|
||||
const descriptor: AnimationDescriptor = {
|
||||
id: 'special-param.v1',
|
||||
version: 1,
|
||||
effect: 'laser',
|
||||
zIndex: 1,
|
||||
layers: [{ id: 'static', tracks: [], staticProps: { value: '{__proto__}' } }],
|
||||
};
|
||||
const params = JSON.parse('{"__proto__":"violet"}') as Record<
|
||||
string,
|
||||
string | number | undefined
|
||||
>;
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(params, '__proto__')).toBe(true);
|
||||
expect(resolveMotionLayer(descriptor, 'static', { params }).staticProps.value).toBe('violet');
|
||||
});
|
||||
|
||||
it('preserves __proto__ as own enumerable output keys', () => {
|
||||
const descriptor: AnimationDescriptor = {
|
||||
id: 'special-output-key.v1',
|
||||
version: 1,
|
||||
effect: 'laser',
|
||||
zIndex: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'motion',
|
||||
staticProps: JSON.parse('{"__proto__":"static-value"}') as Record<
|
||||
string,
|
||||
string | number
|
||||
>,
|
||||
tracks: [
|
||||
{ property: '__proto__', from: 1, to: 2, durationMs: 100 },
|
||||
{ property: '__proto__', from: 2, to: 3, durationMs: 50, phase: 'exit' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const layer = resolveMotionLayer(descriptor, 'motion');
|
||||
const exit = layer.exit as Record<string, unknown>;
|
||||
const exitTransition = layer.exit?.transition as Record<string, unknown>;
|
||||
|
||||
expect(Object.entries(layer.initial)).toContainEqual(['__proto__', 1]);
|
||||
expect(Object.entries(layer.animate)).toContainEqual(['__proto__', 2]);
|
||||
expect(Object.entries(layer.transition)).toContainEqual(['__proto__', { duration: 0.1 }]);
|
||||
expect(Object.entries(exit)).toContainEqual(['__proto__', 3]);
|
||||
expect(Object.entries(exitTransition)).toContainEqual(['__proto__', { duration: 0.05 }]);
|
||||
expect(Object.entries(layer.staticProps)).toContainEqual(['__proto__', 'static-value']);
|
||||
});
|
||||
|
||||
it('throws errors that name missing layers, geometry, and parameters', () => {
|
||||
expect(() => resolveMotionLayer(spotlightV1, 'missing')).toThrow(/missing.*spotlight\.v1/i);
|
||||
expect(() =>
|
||||
resolveMotionLayer(spotlightV1, 'cutout', { geometry: { y: 10, w: 20, h: 30 } }),
|
||||
).toThrow(/x/);
|
||||
|
||||
const descriptor: AnimationDescriptor = {
|
||||
id: 'missing-param.v1',
|
||||
version: 1,
|
||||
effect: 'laser',
|
||||
zIndex: 1,
|
||||
layers: [{ id: 'static', tracks: [], staticProps: { value: '{required}' } }],
|
||||
};
|
||||
expect(() => resolveMotionLayer(descriptor, 'static')).toThrow(/required/);
|
||||
});
|
||||
});
|
||||
@@ -71,15 +71,16 @@ function cubicBezier(x1, y1, x2, y2) {
|
||||
}
|
||||
return function (p) { return p <= 0 ? 0 : p >= 1 ? 1 : sampleY(solveX(p)); };
|
||||
}
|
||||
var EASE_SPOTLIGHT_FADE = cubicBezier(0.25, 0.1, 0.35, 1);
|
||||
var EASE_OUT_EXPO = cubicBezier(0.16, 1, 0.3, 1);
|
||||
var EASE_LASER = cubicBezier(0.22, 1, 0.36, 1);
|
||||
var EASE_IN = cubicBezier(0.4, 0, 1, 1);
|
||||
var EASE_OUT = cubicBezier(0, 0, 0.58, 1);
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo('#fx-0-1',{autoAlpha:0},{autoAlpha:1,duration:0.3,ease:'none'},2);
|
||||
tl.fromTo('#fx-0-1',{autoAlpha:0},{autoAlpha:1,duration:0.3,ease:EASE_SPOTLIGHT_FADE},2);
|
||||
tl.fromTo('#fx-0-1-cut',{attr:{x:17,y:16.8889,width:66,height:65.7778,rx:4}},{attr:{x:24.6,y:24.2889,width:50.8,height:50.9778,rx:1},duration:0.6,ease:EASE_OUT_EXPO},2);
|
||||
tl.fromTo('#fx-0-1-border',{attr:{x:21,y:20.8889,width:58,height:57.7778,rx:2},opacity:0},{attr:{x:24.6,y:24.2889,width:50.8,height:50.9778,rx:1},opacity:1,duration:0.5,ease:EASE_OUT_EXPO},2.05);
|
||||
tl.to('#fx-0-1',{autoAlpha:0,duration:0.3,ease:'none'},4.7);
|
||||
tl.to('#fx-0-1',{autoAlpha:0,duration:0.3,ease:EASE_SPOTLIGHT_FADE},4.7);
|
||||
tl.set('#fx-0-1',{autoAlpha:0},5);
|
||||
tl.fromTo('#fx-0-2',{autoAlpha:0},{autoAlpha:1,duration:0.15,ease:'none'},2);
|
||||
tl.fromTo('#fx-0-2',{x:-1056,y:-591.6},{x:0,y:0,duration:0.5,ease:EASE_LASER},2);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { spotlightV1 } from '@/lib/choreography';
|
||||
import { compileVideoTimeline, emitHyperframes } from '@/lib/video-export';
|
||||
import type { AssetMeta } from '@/lib/video-export';
|
||||
import {
|
||||
@@ -86,6 +87,26 @@ describe('emitHyperframes', () => {
|
||||
expect(html).toContain('#00ff88'); // authored laser color survives into the DOM
|
||||
});
|
||||
|
||||
it('matches the descriptor-authored spotlight fade easing for enter and exit', () => {
|
||||
const dim = spotlightV1.layers.find((layer) => layer.id === 'dim');
|
||||
const enter = dim?.tracks.find((track) => (track.phase ?? 'enter') === 'enter');
|
||||
const exit = dim?.tracks.find((track) => track.phase === 'exit');
|
||||
|
||||
expect(enter?.easing).toEqual(exit?.easing);
|
||||
if (enter?.easing?.type !== 'cubicBezier') {
|
||||
throw new Error('spotlight.v1 dim enter track must use cubic-bezier easing');
|
||||
}
|
||||
const points = enter.easing.points.join(', ');
|
||||
|
||||
expect(html).toContain(`var EASE_SPOTLIGHT_FADE = cubicBezier(${points});`);
|
||||
expect(html).toContain(
|
||||
"tl.fromTo('#fx-0-1',{autoAlpha:0},{autoAlpha:1,duration:0.3,ease:EASE_SPOTLIGHT_FADE},2);",
|
||||
);
|
||||
expect(html).toContain(
|
||||
"tl.to('#fx-0-1',{autoAlpha:0,duration:0.3,ease:EASE_SPOTLIGHT_FADE},4.7);",
|
||||
);
|
||||
});
|
||||
|
||||
it('does not burn in subtitles by default (clean video + sidecar files)', () => {
|
||||
// Burn-in is opt-in (#867 item 2): no caption overlay in the composition,
|
||||
// but the sidecar subtitle files are still written.
|
||||
|
||||
Reference in New Issue
Block a user