/**
* Effect emission — an IR {@link EffectSegment} → the overlay HTML and the GSAP
* timeline statements that reproduce a spotlight / laser at render time.
*
* The two shipped descriptors (`spotlight.v1`, `laser.v1`) were transcribed from
* the live React overlays, whose *rendering* is effect-specific (spotlight is an
* SVG mask; laser is nested CSS divs) — a fact the descriptor data model does not
* encode. So each effect has a targeted emitter here that mirrors its component
* structure and reads the per-instance values (geometry, effective params,
* timing) straight from the IR segment. The animation constants (durations,
* offsets, easings) match the descriptor tracks 1:1.
*
* Determinism: everything is a paused-timeline tween — no wall-clock animation,
* no infinite repeats (the laser ring pulse is expanded to a finite count over
* the effect's lifetime), no `Date.now`/`Math.random`. All times are seconds on
* the global composition clock.
*
* Pure: string generation only; depends on the IR types.
*/
import type { EffectSegment, PercentageGeometry } from '../ir';
import { escapeHtml, sec } from './format';
/** One emitted effect: the DOM to place in the stage and the tweens to add to `tl`. */
export interface EmittedEffect {
html: string;
statements: string[];
}
/** 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). */
laser: 'EASE_LASER',
/** laser exit — cubic-bezier(0.4, 0, 1, 1). */
in: 'EASE_IN',
/** ring pulse — Motion `easeOut` ≈ cubic-bezier(0, 0, 0.58, 1). */
out: 'EASE_OUT',
} as const;
/** The inline script defining the eases the effect tweens reference. Deterministic, dependency-free. */
export const EASE_DEFS = `
function cubicBezier(x1, y1, x2, y2) {
// Deterministic cubic-bezier easing as a GSAP ease function (progress → eased).
var cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
var cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
function sampleX(t) { return ((ax * t + bx) * t + cx) * t; }
function sampleY(t) { return ((ay * t + by) * t + cy) * t; }
function solveX(x) {
var t = x;
for (var i = 0; i < 8; i++) {
var xEst = sampleX(t) - x;
if (Math.abs(xEst) < 1e-6) return t;
var d = (3 * ax * t + 2 * bx) * t + cx;
if (Math.abs(d) < 1e-6) break;
t -= xEst / d;
}
var lo = 0, hi = 1;
t = x;
while (lo < hi) {
var xEst2 = sampleX(t);
if (Math.abs(xEst2 - x) < 1e-6) return t;
if (x > xEst2) lo = t; else hi = t;
t = (hi - lo) / 2 + lo;
}
return t;
}
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);
`.trim();
/** Format a number for HTML/JS output: trimmed to 4 decimals, no exponent. */
function n(value: number): string {
return Number(value.toFixed(4)).toString();
}
/**
* Spotlight overlay — a full-frame SVG whose dim rect is punched out by an
* animated mask cutout, plus a settling border. Mirrors `SpotlightOverlay` and
* the `spotlight.v1` cutout/border/dim tracks (all in 0–100 percentage space).
*/
function emitSpotlight(seg: EffectSegment, g: PercentageGeometry, id: string): EmittedEffect {
const dimness = typeof seg.params.dimness === 'number' ? seg.params.dimness : 0.5;
const start = sec(seg.startMs);
const exit = Math.max(start, sec(seg.startMs + seg.durationMs) - 0.3);
const maskId = `${id}-mask`;
const cut = `#${id}-cut`;
const border = `#${id}-border`;
const html = [
`
`,
` `,
`
`,
].join('\n');
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:${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:${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)});`,
];
return { html, statements };
}
/** Frame dimensions in px — needed to express the laser fly-in as a pixel transform. */
export interface FrameDims {
width: number;
height: number;
}
/**
* Laser pointer — a dot (glowing core + pulsing ring) that flies in from the
* nearest off-frame corner to the target center. Mirrors `LaserOverlay` and the
* `laser.v1` dot/ring/core layers. The ring's infinite pulse is expanded to a
* finite repeat count over the effect's lifetime (determinism red-line).
*
* The dot is positioned statically at the target center (`left`/`top` %); the
* fly-in and fly-out animate `x`/`y` **transforms** (px), not `left`/`top` — the
* seek-by-frame capture engine snaps layout properties to device pixels and
* stutters, but interpolates transforms sub-pixel.
*/
function emitLaser(
seg: EffectSegment,
g: PercentageGeometry,
id: string,
dims: FrameDims,
): EmittedEffect {
const color = typeof seg.params.color === 'string' ? seg.params.color : '#ff0000';
const start = sec(seg.startMs);
const exit = Math.max(start, sec(seg.startMs + seg.durationMs) - 0.25);
const end = sec(seg.startMs + seg.durationMs);
const startXPct = g.centerX > 50 ? 105 : -5;
const startYPct = g.centerY > 50 ? 105 : -5;
// Corner offset from the target center, in px, as an x/y transform delta.
const dx = ((startXPct - g.centerX) / 100) * dims.width;
const dy = ((startYPct - g.centerY) / 100) * dims.height;
// Ring pulse: 1.5s tween + 0.3s repeatDelay per cycle. Expand to a finite
// count that fills the lifetime (never `repeat: -1`, which breaks the capturer).
const ringCycle = 1.8;
const ringRepeat = Math.max(0, Math.ceil(sec(seg.durationMs) / ringCycle) - 1);
const safeColor = escapeHtml(color);
const html = [
`
`,
`
`,
` `,
` `,
`
`,
`
`,
].join('\n');
const statements = [
`tl.fromTo('#${id}',{autoAlpha:0},{autoAlpha:1,duration:0.15,ease:'none'},${n(start)});`,
`tl.fromTo('#${id}',{x:${n(dx)},y:${n(dy)}},{x:0,y:0,duration:0.5,ease:${EASE.laser}},${n(start)});`,
`tl.fromTo('#${id}-ring',{scale:1,opacity:0.6},{scale:2.8,opacity:0,duration:1.5,ease:${EASE.out},repeat:${ringRepeat},repeatDelay:0.3},${n(start)});`,
`tl.to('#${id}',{autoAlpha:0,x:${n(dx)},y:${n(dy)},duration:0.25,ease:${EASE.in}},${n(exit)});`,
// Hard kills at the boundary so a non-linear seek past the exit finds the
// overlay (and its still-pulsing ring) fully hidden, not mid-animation.
`tl.set('#${id}',{autoAlpha:0},${n(end)});`,
`tl.set('#${id}-ring',{opacity:0},${n(end)});`,
];
return { html, statements };
}
/**
* Emit one effect segment. Degraded segments (unresolved geometry) produce no
* output — the base frame still renders, matching the compiler's degrade-on-miss
* contract. Unknown effect types are skipped defensively.
*/
export function emitEffect(seg: EffectSegment, id: string, dims: FrameDims): EmittedEffect {
if (seg.degraded || !seg.geometry) return { html: '', statements: [] };
if (seg.type === 'spotlight') return emitSpotlight(seg, seg.geometry, id);
if (seg.type === 'laser') return emitLaser(seg, seg.geometry, id, dims);
return { html: '', statements: [] };
}