/* ===================================================================== 아바타 v3 반응 클립 재생기 — 결정문 §6.1 키프레임 보간·엔벨로프·합성. 순수 함수뿐이다. 시간은 인자로만 받는다. ===================================================================== */ import { mirrorDelta, type ChannelDelta, type ChannelId } from "./channels"; import type { ClipEase, Keyframe, ReactionClip } from "./clipCatalog"; function ease(kind: ClipEase, t: number): number { switch (kind) { case "in": return t * t; case "out": return 1 - (1 - t) * (1 - t); case "inOut": return 3 * t * t - 2 * t * t * t; case "hold": return t >= 1 ? 1 : 0; case "linear": default: return t; } } function sampleTrack(frames: readonly Keyframe[], tMs: number): number { const first = frames[0]; if (tMs <= first[0]) return first[1]; const last = frames[frames.length - 1]; if (tMs >= last[0]) return last[1]; for (let i = 1; i < frames.length; i++) { const [t1, v1, arrivalEase] = frames[i]; if (tMs > t1) continue; const [t0, v0] = frames[i - 1]; const span = t1 - t0; const localT = span <= 0 ? 1 : (tMs - t0) / span; const eased = ease(arrivalEase ?? "linear", localT); return v0 + (v1 - v0) * eased; } return last[1]; } /** 클립의 tMs 시점 채널 delta(가산값). t가 구간 밖이면 빈 delta. */ export function sampleClip(clip: ReactionClip, tMs: number): ChannelDelta { if (tMs < 0 || tMs > clip.durationMs) return {}; const out: ChannelDelta = {}; for (const id of Object.keys(clip.tracks) as ChannelId[]) { const frames = clip.tracks[id]; if (!frames || frames.length === 0) continue; out[id] = sampleTrack(frames, tMs); } return out; } /** fadeIn→hold→fadeOut 엔벨로프(0..1). 구간 밖은 0. */ export function envelope(clip: ReactionClip, tMs: number): number { if (tMs < 0 || tMs > clip.durationMs) return 0; const { fadeInMs, fadeOutMs, durationMs } = clip; if (fadeInMs > 0 && tMs < fadeInMs) return tMs / fadeInMs; const fadeOutStart = durationMs - fadeOutMs; if (fadeOutMs > 0 && tMs > fadeOutStart) return (durationMs - tMs) / fadeOutMs; return 1; } export interface ResolveClipOptions { weight?: number; mirror?: boolean; } /** sampleClip × envelope × weight, mirror면 좌우 반전까지 적용한 최종 delta. */ export function resolveClipDelta(clip: ReactionClip, tMs: number, opts: ResolveClipOptions = {}): ChannelDelta { const raw = sampleClip(clip, tMs); const scale = envelope(clip, tMs) * (opts.weight ?? 1); const out: ChannelDelta = {}; for (const id of Object.keys(raw) as ChannelId[]) { out[id] = (raw[id] as number) * scale; } return opts.mirror ? mirrorDelta(out) : out; }