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:
Yizuki_Ame
2026-07-29 09:12:37 -04:00
committed by GitHub
parent 0cf2a33041
commit 23a9e757d5
8 changed files with 667 additions and 115 deletions
@@ -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,
};
}
+22 -7
View File
@@ -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',
},
],
},
],
+5 -2
View File
@@ -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.