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 {
  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;
  }>;

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

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

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

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))",
}: 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 imageTranslateY = avatar.imageLayout?.translateY ?? 0;
  const imageScaleX = avatar.imageLayout?.flipX ? -1 : 1;
  const imageTransform = `translateY(${imageTranslateY}px) scaleX(${imageScaleX})`;
  const nameplatePosition = avatar.nameplatePosition ?? "bottom";
  const showNameplate = nameplatePosition !== "none";
  const mouth = mouthForAvatar({
    characterId,
    character,
    isSpeaking,
    speakingSpeechId,
    speakingLocalFrame,
    fps,
    resolveMouth,
  });

  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={avatar.imagePath}
        mouthImageDir={avatar.mouthImageDir}
        mouth={mouth}
        width={avatar.imageLayout?.width ?? 320}
        height={avatar.imageLayout?.height}
        maxHeight={avatar.imageLayout?.maxHeight ?? 360}
        filter={imageFilter}
        transform={imageTransform}
      />
      {showNameplate && nameplatePosition === "bottom" ? nameplate : null}
    </div>
  );
};