Newer
Older
remotion_docker_devcontainer / voicevox-remotion-template / src / lib / VQRemotionLib / components / VQCharacterAvatar.tsx
import React from "react";
import {vqIdleAvatarAnimations, vqSpeakingAvatarAnimations} from "../avatarAnimations";
import type {
  VQAvatarImageLayout,
  VQCharacterDefinition,
  VQMouthResolver,
  VQMouthShape,
} from "../types";
import {VQLipSyncedStandeeImage} from "./VQLipSyncedStandeeImage";

export type VQCharacterAvatarProps<CharacterId extends string = string> =
  Readonly<{
    characterId: CharacterId;
    character: VQCharacterDefinition;
    focused: boolean;
    hasMultipleCharacters: boolean;
    frame: number;
    fps: number;
    isSpeaking: boolean;
    speakingSpeechId?: string;
    speakingLocalFrame: number;
    resolveMouth?: VQMouthResolver<CharacterId>;
    fontFamily?: string;
    imageFilter?: string;
    imagePath?: string;
    lipSyncEnabled?: boolean;
    imageFlipX?: boolean;
    imageTranslateX?: number;
    imageTranslateY?: number;
  }>;

const mouthForAvatar = <CharacterId extends string,>({
  characterId,
  character,
  isSpeaking,
  speakingSpeechId,
  speakingLocalFrame,
  fps,
  resolveMouth,
  lipSyncEnabled,
}: Readonly<{
  characterId: CharacterId;
  character: VQCharacterDefinition;
  isSpeaking: boolean;
  speakingSpeechId?: string;
  speakingLocalFrame: number;
  fps: number;
  resolveMouth?: VQMouthResolver<CharacterId>;
  lipSyncEnabled?: boolean;
}>): VQMouthShape => {
  const speakingAnimationType =
    character.avatar.speakingAnimationType ?? "gentleBob";

  if (
    lipSyncEnabled === false ||
    !isSpeaking ||
    speakingAnimationType !== "rhubarbLipSync"
  ) {
    return "rest";
  }

  return (
    resolveMouth?.({
      characterId,
      character,
      speechId: speakingSpeechId,
      speakingLocalFrame,
      fps,
      speakingAnimationType,
    }) ?? "rest"
  );
};

// 用途: 立ち絵の頭幅メタデータから追加スケールを算出する。
// 使用方法: VQCharacterAvatar 内で imageLayout と実表示幅を渡し、画像transformのscaleへ反映する。
// オプションや引数詳細: sourceWidth/sourceHeadWidth/targetHeadWidth が揃わない場合は scale のみを使う。
const imageScaleForLayout = (
  imageLayout: VQAvatarImageLayout | undefined,
  imageWidth: number | string
) => {
  const renderedHeadWidth =
    typeof imageWidth === "number" &&
    imageLayout?.sourceWidth &&
    imageLayout?.sourceHeadWidth
      ? (imageLayout.sourceHeadWidth * imageWidth) / imageLayout.sourceWidth
      : undefined;
  const headWidthScale =
    renderedHeadWidth && imageLayout?.targetHeadWidth
      ? imageLayout.targetHeadWidth / renderedHeadWidth
      : 1;

  return (imageLayout?.scale ?? 1) * headWidthScale;
};

export const VQCharacterAvatar = <CharacterId extends string,>({
  characterId,
  character,
  focused,
  hasMultipleCharacters,
  frame,
  fps,
  isSpeaking,
  speakingSpeechId,
  speakingLocalFrame,
  resolveMouth,
  fontFamily = "system-ui, sans-serif",
  imageFilter = "drop-shadow(0 18px 40px rgba(31, 42, 68, 0.22))",
  imagePath,
  lipSyncEnabled,
  imageFlipX,
  imageTranslateX = 0,
  imageTranslateY,
}: VQCharacterAvatarProps<CharacterId>) => {
  const {avatar} = character;
  const scale = hasMultipleCharacters ? 0.82 : focused ? 0.94 : 0.9;
  const opacity = focused || !hasMultipleCharacters ? 1 : 0.72;
  const idleAnimationType = avatar.idleAnimationType ?? "gentleBob";
  const speakingAnimationType = avatar.speakingAnimationType ?? "gentleBob";
  const translateY = isSpeaking
    ? vqSpeakingAvatarAnimations[speakingAnimationType]({
        frame,
        fps,
        focused,
        hasMultipleCharacters,
      })
    : vqIdleAvatarAnimations[idleAnimationType]({
        frame,
        fps,
        focused,
        hasMultipleCharacters,
      });
  const resolvedImageTranslateY =
    imageTranslateY ?? avatar.imageLayout?.translateY ?? 0;
  const imageScaleX = (imageFlipX ?? avatar.imageLayout?.flipX) ? -1 : 1;
  const imageWidth = avatar.imageLayout?.width ?? 320;
  const imageScale = imageScaleForLayout(avatar.imageLayout, imageWidth);
  const imageTransform = `translate(${imageTranslateX}px, ${resolvedImageTranslateY}px) scale(${imageScale}) scaleX(${imageScaleX})`;
  const nameplatePosition = avatar.nameplatePosition ?? "bottom";
  const showNameplate = nameplatePosition !== "none";
  const mouth = mouthForAvatar({
    characterId,
    character,
    isSpeaking,
    speakingSpeechId,
    speakingLocalFrame,
    fps,
    resolveMouth,
    lipSyncEnabled,
  });

  const nameplate = (
    <div
      style={{
        position: "relative",
        zIndex: 2,
        fontFamily,
        fontSize: 24,
        fontWeight: 700,
        color: "#ffffff",
        backgroundColor: avatar.accentColor,
        padding: "6px 18px",
        borderRadius: 999,
        boxShadow: "0 8px 20px rgba(31, 42, 68, 0.14)",
      }}
    >
      {character.displayName}
    </div>
  );

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 16,
        opacity,
        position: "relative",
        transform: `translateY(${translateY}px) scale(${scale})`,
      }}
    >
      {showNameplate && nameplatePosition === "top" ? nameplate : null}
      <VQLipSyncedStandeeImage
        imagePath={imagePath ?? avatar.imagePath}
        mouthImageDir={avatar.mouthImageDir}
        mouth={mouth}
        mouthCoverLayer={avatar.mouthCoverLayer}
        width={imageWidth}
        height={avatar.imageLayout?.height}
        maxHeight={avatar.imageLayout?.maxHeight ?? 360}
        filter={imageFilter}
        transform={imageTransform}
      />
      {showNameplate && nameplatePosition === "bottom" ? nameplate : null}
    </div>
  );
};