아바타 v3 표현 엔진·리노컷 렌더러·립싱크와 Lab

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

View file

@ -0,0 +1,78 @@
/* =====================================================================
아바타 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;
}