Appearance
ずんだもんキャラクター設計
概要
ずんだもんの**顔のみ(バストアップ)**を画面右下に表示し、VOICEVOX の音素情報に基づいて口パクアニメーションを行う。
口パクアニメーション方式
母音ベース画像切り替え方式
日本語の母音(あ・い・う・え・お)に対応する口の形の画像を用意し、VOICEVOX から取得した音素タイミングに合わせて切り替える。
必要な画像素材
| ファイル名 | 母音 | 口の形 |
|---|---|---|
mouth-a.png | あ (a) | 大きく開く |
mouth-i.png | い (i) | 横に広げる |
mouth-u.png | う (u) | すぼめる |
mouth-e.png | え (e) | 中程度に開く |
mouth-o.png | お (o) | 丸く開く |
mouth-closed.png | 無音 | 閉じる |
mouth-n.png | ん (N) | 閉じ気味 |
画像構成
キャラクター画像はベース(顔)と口を分離して管理する。
assets/zundamon/
├── face-base.png # 口を除いた顔のベース画像
├── mouth-a.png # 口パーツ: あ
├── mouth-i.png # 口パーツ: い
├── mouth-u.png # 口パーツ: う
├── mouth-e.png # 口パーツ: え
├── mouth-o.png # 口パーツ: お
├── mouth-closed.png # 口パーツ: 閉じ
└── mouth-n.png # 口パーツ: んコンポーネント設計
ZundamonCharacter.tsx
tsx
import { useCurrentFrame, useVideoConfig, Img, staticFile } from 'remotion';
interface PhonemeEntry {
time: number;
duration: number;
vowel: 'a' | 'i' | 'u' | 'e' | 'o' | 'N' | 'silent';
}
interface Props {
phonemes: PhonemeEntry[];
}
const MOUTH_MAP: Record<string, string> = {
a: 'mouth-a.png',
i: 'mouth-i.png',
u: 'mouth-u.png',
e: 'mouth-e.png',
o: 'mouth-o.png',
N: 'mouth-n.png',
silent: 'mouth-closed.png',
};
export const ZundamonCharacter: React.FC<Props> = ({ phonemes }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentTime = frame / fps;
// 現在時刻に対応する音素を取得
const currentPhoneme = phonemes.find(
p => currentTime >= p.time && currentTime < p.time + p.duration
);
const mouthImage = currentPhoneme
? MOUTH_MAP[currentPhoneme.vowel]
: 'mouth-closed.png';
return (
<div style={{
position: 'absolute',
right: 40,
bottom: 120,
width: 200,
height: 200,
}}>
{/* 顔ベース */}
<Img
src={staticFile('zundamon/face-base.png')}
style={{ position: 'absolute', width: '100%', height: '100%' }}
/>
{/* 口パーツ(アニメーション) */}
<Img
src={staticFile(`zundamon/${mouthImage}`)}
style={{
position: 'absolute',
width: '40%',
left: '30%',
top: '60%',
}}
/>
</div>
);
};VOICEVOX 音素マッピング
VOICEVOX の accent_phrases から口パクデータへの変換ロジック:
typescript
function extractPhonemes(audioQuery: AudioQuery): PhonemeEntry[] {
const phonemes: PhonemeEntry[] = [];
let currentTime = 0;
for (const phrase of audioQuery.accent_phrases) {
for (const mora of phrase.moras) {
// 子音部分(口は閉じ気味)
if (mora.consonant_length) {
phonemes.push({
time: currentTime,
duration: mora.consonant_length,
vowel: 'silent',
});
currentTime += mora.consonant_length;
}
// 母音部分(口の形を変える)
phonemes.push({
time: currentTime,
duration: mora.vowel_length,
vowel: mora.vowel.toLowerCase() as PhonemeEntry['vowel'],
});
currentTime += mora.vowel_length;
}
// ポーズ(無音区間)
if (phrase.pause_mora) {
phonemes.push({
time: currentTime,
duration: phrase.pause_mora.vowel_length,
vowel: 'silent',
});
currentTime += phrase.pause_mora.vowel_length;
}
}
return phonemes;
}アニメーション品質向上
補間処理
急激な口の形の変化を避けるため、フレーム間の補間を行う:
- 口の開閉は 2-3 フレームかけてスムーズに遷移
- 同じ母音が連続する場合は微細な揺れを追加
- 無音区間が長い場合はまばたきアニメーションを追加
まばたき
- 3-5秒に1回ランダムでまばたき
- まばたきは 3フレーム(閉じ → 半開き → 開き)
- 目の画像パーツも分離して管理
assets/zundamon/
├── eyes-open.png # 通常
├── eyes-half.png # 半開き
└── eyes-closed.png # まばたき