vignette/apps/web/src/components/avatar/engine/clipPlayer.ts
Yun Chan 85bd079d18 아바타 v3 표현 엔진·리노컷 렌더러·립싱크와 Lab
- 표현 엔진: 채널 34개(mouthRound 추가), 표정 28종·강도 곡선·반응 클립 26종·지문 cue, 미세표정 누설
- 발화층: 한글 자모 비짐 9종, TTS 오디오 선분석 정렬, 60ms 앞당김·모음 간 비닫힘
- 발화 동반층: 억양·강세에 맞춘 고개 표류·끄덕임·질문 갸웃·들숨·눈썹 박·시선 회피·쉼 깜빡임
- P1 서연 리노컷 렌더러: 원화 픽셀 입술 띠 변형, 하관 띠 변형(턱·코 들썩), 볼 불룩, 작은 크기 선 보정, 모티프·배경 색면
- /dev/avatar-lab, check:avatar-presets·check:avatar-lipsync, avatar-lab E2E 19개
2026-10-01 09:58:00 +09:00

78 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =====================================================================
아바타 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;
}