キャラクター(PSD)

PSD ファイルをそのまま立ち絵として読み込んで、表情差分・口パク・目パチをコードから制御できる。 音声に合わせた口パク自動化や、会話シナリオでの話者切り替えまで一気通貫でできるのが特徴。

前提

PSD のレイヤー構成によって書き方が2系統に分かれる:
- psd-tool-kit 互換(オプション切替の PSD)→ kind: "enum"
- 普通のレイヤー(表示/非表示で切り替える PSD)→ kind: "bool"

<PsdCharacter>

PSD を canvas に描画して、子要素のモーションコンポーネントから差分を切り替える親コンポーネント。

import { BEZIER_SMOOTH } from "../src/lib/animation/functions"
import { seconds } from "../src/lib/frame"
import {
  PsdCharacter,
  MotionSequence,
  MotionWithVars,
  createSimpleLipSync,
} from "../src/lib/character/character-unit"

const SimpleLipSync = createSimpleLipSync({
  kind: "bool",
  options: {
    Default: "表情/口/1",
    Open:    "表情/口/1",
    Closed:  "表情/口/5",
  },
})

<PsdCharacter psd="../assets/char.psd" className="char">
  <MotionSequence>
    <SimpleLipSync voice="../assets/001_char.wav" />

    <MotionWithVars
      variables={{ t: 0 as number }}
      animation={async (ctx, variables) => {
        await ctx.move(variables.t).to(1, seconds(1), BEZIER_SMOOTH)
      }}
      motion={(variables, frames) => {
        const t = variables.t.get(frames[0])
        if (t > 0.5) {
          return { "表情/目/9": false, "表情/目/17": true }
        }
        return {}
      }}
    />
  </MotionSequence>
</PsdCharacter>
PsdCharacter の中ではフックが使えない

<PsdCharacter> 配下の motion 内では React フックを呼べない。
変数の値は useCurrentFrame() ではなく、引数の frames[0]variable.get(frames[0]) に渡して取得する。

子要素として並べるコンポーネント

<MotionSequence>

子要素を直列につなげる。内部的には <Sequence> で各要素が <Clip> に包まれる。 会話・モーション・口パクを順番にこなすときの基本コンテナ。

<MotionSequence>
  <Voice voice="../assets/001_char.wav" />
  <Voice voice="../assets/002_char.wav" />
</MotionSequence>

<MotionClip>

<MotionSequence> 直下で並列に重ねるためのラッパ。

<MotionSequence>
  <Voice voice="../assets/001_char.wav" />
  <MotionClip>
    <Voice voice="../assets/002_char.wav" />
    <Voice voice="../assets/003_char.wav" />
  </MotionClip>
</MotionSequence>

<Voice>

音声を配置する。内部で <Clip> に包まれるので、長さは音声に合わせて自動。

<Voice voice="../assets/001_char.wav" />

<MotionWithVars>

変数とアニメーションをこのスコープ専用で宣言して、PSD のオプションに反映する。

<MotionWithVars
  variables={{ t: 0 as number }}
  animation={async (ctx, vars) => {
    await ctx.move(vars.t).to(1, seconds(1), BEZIER_SMOOTH)
  }}
  motion={(vars, frames) => {
    const t = vars.t.get(frames[0])
    return t > 0.5
      ? { "表情/目/9": false, "表情/目/17": true }
      : {}
  }}
/>

口パク(lip sync)

createSimpleLipSync — 音量ベース

音声の音量で「開く/閉じる」を切り替えるだけのシンプル版。 会話を量産するなら最初はこれで十分。

bool 系(普通の PSD)
const SimpleLipSync = createSimpleLipSync({
  kind: "bool",
  options: {
    Default: "表情/口/1",
    Open:    "表情/口/1",
    Closed:  "表情/口/5",
  },
})

<SimpleLipSync voice="../assets/001_char.wav" />
enum 系(psd-tool-kit)
const SimpleLipSync = createSimpleLipSync({
  kind: "enum",
  options: {
    Mouth:   "表情/口",
    Default: "1",
    Open:    "1",
    Closed:  "5",
  },
})

createLipSync — 母音ベース

母音ごとに口形を切り替える。rhubarbrhubarb-lip-sync)の出力をそのまま data に渡せる。

const LipSync = createLipSync({
  kind: "enum",
  options: {
    Mouth: "表情/口",
    Default: "1",
    A: "1", I: "2", U: "3", E: "4", O: "5", X: "6",
  },
})

const lipsync = {
  mouthCues: [
    { start: 0.00, end: 0.03, value: "A" },
    { start: 0.03, end: 0.09, value: "B" },
    /* ... */
  ],
}

<PsdCharacter psd="../assets/char.psd">
  <Voice voice="../assets/001_char.wav" />
  <LipSync data={lipsync} />
</PsdCharacter>

目パチ — createBlink

目の状態(Open / HalfOpen / HalfClosed / Closed)を時系列で切り替える。datavalue"A""D" の固定マッピング。

data の value対応するオプション
"A"Open
"B"HalfOpen
"C"HalfClosed
"D"Closed
const Blink = createBlink({
  kind: "enum",
  options: {
    Mouth: "表情/目",
    Default: "1",
    Open: "1",
    HalfOpen: "2",
    HalfClosed: "3",
    Closed: "4",
  },
})

// 自動生成も可能
// const blink = generateBlinkData(0, 10)

<PsdCharacter psd="../assets/char.psd">
  <Voice voice="../assets/001_char.wav" />
  <Blink data={blink} />
</PsdCharacter>

会話シナリオ — <DialogueScenario>

複数キャラの会話形式の動画を組むためのコンテナ。
キャラを宣言 → シナリオを並べる → 各 Chapter で話者を指定、という流れになる。

import {
  DialogueScenario,
  DeclareCharacters,
  DeclareCharacter,
  Scenario,
  Chapter,
  Speaker,
} from "../src/lib/character/character-manager"
import { Voice } from "../src/lib/character/character-unit"

<DialogueScenario>
  <DeclareCharacters>
    <DeclareCharacter
      name="akane"
      psd="../assets/akane.psd"
      idleClassName="akane"
      speakingClassName="akane"
    />
    <DeclareCharacter
      name="aoi"
      psd="../assets/aoi.psd"
      idleClassName="aoi"
      speakingClassName="aoi"
    />
  </DeclareCharacters>

  <Scenario>
    <Chapter>
      <Speaker name="aoi">
        <Voice voice="../assets/001_aoi.wav" />
      </Speaker>
    </Chapter>
    <Chapter>
      <Speaker name="akane">
        <Voice voice="../assets/002_akane.wav" />
      </Speaker>
    </Chapter>
  </Scenario>
</DialogueScenario>
小ネタ

PSD 周りはレイヤー名のスペル間違いで死ぬほどハマる。 PSD のレイヤー一覧を最初にダンプして、コード側の文字列と完全一致しているか確認するのが いちばん早い。