diff --git a/voicevox-remotion-template/src/avatar-animations.ts b/voicevox-remotion-template/src/avatar-animations.ts new file mode 100644 index 0000000..3fcadf5 --- /dev/null +++ b/voicevox-remotion-template/src/avatar-animations.ts @@ -0,0 +1,33 @@ +export type AvatarAnimationContext = Readonly<{ + frame: number; + fps: number; + focused: boolean; + hasMultipleCharacters: boolean; +}>; + +export type AvatarAnimation = (context: AvatarAnimationContext) => number; + +const gentleBobAmplitude = ({focused}: AvatarAnimationContext) => + focused ? 10 : 3.5; + +export const idleAvatarAnimations = { + none: () => 0, + gentleBob: (context) => + Math.sin((context.frame / context.fps) * Math.PI * 2) * + gentleBobAmplitude(context), +} satisfies Record; + +export type IdleAvatarAnimationType = keyof typeof idleAvatarAnimations; + +export const speakingAvatarAnimations = { + none: () => 0, + gentleBob: idleAvatarAnimations.gentleBob, + quickHop: ({frame, fps}) => { + const cycleFrames = Math.max(1, Math.round(fps * 0.25)); + const progress = (frame % cycleFrames) / cycleFrames; + + return -Math.sin(progress * Math.PI) * 7; + }, +} satisfies Record; + +export type SpeakingAvatarAnimationType = keyof typeof speakingAvatarAnimations; diff --git a/voicevox-remotion-template/src/data/script.ts b/voicevox-remotion-template/src/data/script.ts index 82a2767..fdc83fd 100644 --- a/voicevox-remotion-template/src/data/script.ts +++ b/voicevox-remotion-template/src/data/script.ts @@ -1,3 +1,8 @@ +import type { + IdleAvatarAnimationType, + SpeakingAvatarAnimationType, +} from "../avatar-animations"; + export type VoicevoxVoice = Readonly<{ speakerName: string; styleName: string; @@ -14,6 +19,8 @@ flipX?: boolean; }>; nameplatePosition?: "top" | "bottom" | "none"; + idleAnimationType?: IdleAvatarAnimationType; + speakingAnimationType?: SpeakingAvatarAnimationType; }>; export type CharacterDefinition = Readonly<{ @@ -40,6 +47,8 @@ flipX: true, }, nameplatePosition: "none", + idleAnimationType: "none", + speakingAnimationType: "quickHop", }, }, sayo: { @@ -58,6 +67,8 @@ translateY: -60, }, nameplatePosition: "none", + idleAnimationType: "none", + speakingAnimationType: "quickHop", }, }, } as const satisfies Record; diff --git a/voicevox-remotion-template/src/yukkuri-composition.tsx b/voicevox-remotion-template/src/yukkuri-composition.tsx index 0e6c7d3..47b2ea7 100644 --- a/voicevox-remotion-template/src/yukkuri-composition.tsx +++ b/voicevox-remotion-template/src/yukkuri-composition.tsx @@ -19,6 +19,10 @@ type TimelineEvent, } from "./data/script"; import { + idleAvatarAnimations, + speakingAvatarAnimations, +} from "./avatar-animations"; +import { audioFileForSpeech, GAP_FRAMES, durationForTimelineEvent, @@ -381,14 +385,30 @@ characterId: CharacterId; focused: boolean; hasMultipleCharacters: boolean; - bounce: number; + frame: number; + fps: number; + isSpeaking: boolean; }> -> = ({characterId, focused, hasMultipleCharacters, bounce}) => { +> = ({characterId, focused, hasMultipleCharacters, frame, fps, isSpeaking}) => { const character = characters[characterId]; const {avatar}: {avatar: AvatarDefinition} = character; const scale = hasMultipleCharacters ? 0.88 : focused ? 1.05 : 1; const opacity = focused || !hasMultipleCharacters ? 1 : 0.72; - const translateY = focused ? bounce : bounce * 0.35; + const idleAnimationType = avatar.idleAnimationType ?? "gentleBob"; + const speakingAnimationType = avatar.speakingAnimationType ?? "gentleBob"; + const translateY = isSpeaking + ? speakingAvatarAnimations[speakingAnimationType]({ + frame, + fps, + focused, + hasMultipleCharacters, + }) + : idleAvatarAnimations[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})`; @@ -462,9 +482,11 @@ Readonly<{ visibleCharacters: CharacterId[]; focusedCharacter?: CharacterId; - bounce: number; + speakingCharacter?: CharacterId; + frame: number; + fps: number; }> -> = ({visibleCharacters, focusedCharacter, bounce}) => { +> = ({visibleCharacters, focusedCharacter, speakingCharacter, frame, fps}) => { const hasMultipleCharacters = visibleCharacters.length > 1; return ( @@ -485,7 +507,9 @@ characterId={characterId} focused={focusedCharacter === characterId} hasMultipleCharacters={hasMultipleCharacters} - bounce={bounce} + frame={frame} + fps={fps} + isSpeaking={speakingCharacter === characterId} /> ))} @@ -559,12 +583,10 @@ fps, config: {damping: 18, mass: 0.6}, }); - - const bounce = interpolate( - Math.sin((frame / fps) * Math.PI * 2), - [-1, 1], - [-10, 10] - ); + const speakingCharacter = + isInsideActiveSegment && activeSegment.event.type === "say" + ? activeSegment.event.character + : undefined; const sequences = scheduledEvents.map((scheduledEvent, index) => ( {sequences}