Files
openmaic/OpenMAIC/lib/choreography/descriptors/spotlight.ts
2026-08-16 14:58:47 +08:00

176 lines
6.0 KiB
TypeScript

import type { AnimationDescriptor } from './types';
/**
* spotlight.v1 — focus a single element, dimming the rest.
*
* An SVG mask (0-100 viewBox) punches a rounded cutout over a dimmed full-screen
* rect; a white border traces the cutout. Values captured verbatim from the
* `SpotlightOverlay` effect component (`motion/react`):
* - cutout: 600ms expo-out, insets from ±8/rx4 to the tight ±~0.5/rx1 frame.
* Modeled as a `role: 'mask'` layer — it is not painted itself; the `dim`
* layer subtracts it (`maskedBy`), so a non-React consumer reconstructs the
* "dim everywhere except the cutout" compositing rather than "draw a black
* rect".
* - border: 500ms expo-out, delayed 50ms, fading in as it settles.
* - dim: static `rgba(0,0,0,{dimness})`, dimness default 0.5, with the cutout
* subtracted.
*
* The `dimness` default is 0.5 — the value the runtime actually renders: a
* spotlight action with no `dimOpacity` is stored as `action.dimOpacity ?? 0.5`
* (`ActionEngine.executeSpotlight`; DSL documents `dimOpacity` default 0.5), so
* the component's own `?? 0.7` fallback is unreachable at playback. The exporter
* must use 0.5 to match.
*
* Cutout/border easing `[0.16, 1, 0.3, 1]` (the spotlight expo-out).
*/
export const spotlightV1: AnimationDescriptor = {
id: 'spotlight.v1',
version: 1,
effect: 'spotlight',
params: { dimness: 0.5 },
zIndex: 100,
layers: [
{
id: 'cutout',
// Geometry only — subtracted from `dim` (see its maskedBy), not painted.
role: 'mask',
staticProps: { fill: '#000000' },
tracks: [
{
property: 'x',
from: { ref: 'x', offset: -8 },
to: { ref: 'x', offset: -0.4 },
durationMs: 600,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
{
property: 'y',
from: { ref: 'y', offset: -8 },
to: { ref: 'y', offset: -0.6 },
durationMs: 600,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
{
property: 'width',
from: { ref: 'w', offset: 16 },
to: { ref: 'w', offset: 0.8 },
durationMs: 600,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
{
property: 'height',
from: { ref: 'h', offset: 16 },
to: { ref: 'h', offset: 1.2 },
durationMs: 600,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
{
property: 'rx',
from: 4,
to: 1,
durationMs: 600,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
],
},
{
id: 'border',
// The white outline sits inside the same wrapper motion.div as the dim
// rect, so it fades in/out with the whole effect. `dim` carries the
// canonical wrapper opacity fade; the border rides it (on top of its own
// geometry + border-opacity tracks below).
inheritsFrom: { parentId: 'dim', props: ['opacity'] },
staticProps: {
stroke: 'rgba(255,255,255,0.7)',
strokeWidth: 1.2,
fill: 'none',
vectorEffect: 'non-scaling-stroke',
},
tracks: [
{
property: 'x',
from: { ref: 'x', offset: -4 },
to: { ref: 'x', offset: -0.4 },
durationMs: 500,
delayMs: 50,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
{
property: 'y',
from: { ref: 'y', offset: -4 },
to: { ref: 'y', offset: -0.6 },
durationMs: 500,
delayMs: 50,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
{
property: 'width',
from: { ref: 'w', offset: 8 },
to: { ref: 'w', offset: 0.8 },
durationMs: 500,
delayMs: 50,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
{
property: 'height',
from: { ref: 'h', offset: 8 },
to: { ref: 'h', offset: 1.2 },
durationMs: 500,
delayMs: 50,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
{
property: 'opacity',
from: 0,
to: 1,
durationMs: 500,
delayMs: 50,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
{
property: 'rx',
from: 2,
to: 1,
durationMs: 500,
delayMs: 50,
easing: { type: 'cubicBezier', points: [0.16, 1, 0.3, 1] },
},
],
},
{
id: 'dim',
// Full-screen dim behind the cutout, with the cutout subtracted (SVG
// <mask>: white full-cover minus the black cutout rect). Spans the whole
// 0..100 viewport (`<rect width="100" height="100">` at the origin) — made
// explicit so a literal consumer dims the entire frame rather than nothing.
maskedBy: { layerId: 'cutout', mode: 'subtract' },
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 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,
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',
},
],
},
],
};