diff --git a/voicevox-remotion-template/src/data/pizza-kiln/timing.ts b/voicevox-remotion-template/src/data/pizza-kiln/timing.ts index d0dadfa..0381e50 100644 --- a/voicevox-remotion-template/src/data/pizza-kiln/timing.ts +++ b/voicevox-remotion-template/src/data/pizza-kiln/timing.ts @@ -87,7 +87,8 @@ event.type === "clearVideo" || event.type === "standeePosition" || event.type === "standeeFacingDirection" || - event.type === "standeeVerticalOffset" + event.type === "standeeVerticalOffset" || + event.type === "standeeImage" ) { return 0; } diff --git a/voicevox-remotion-template/src/data/pizza-oven-project-01/timing.ts b/voicevox-remotion-template/src/data/pizza-oven-project-01/timing.ts index 9b7e466..f411f99 100644 --- a/voicevox-remotion-template/src/data/pizza-oven-project-01/timing.ts +++ b/voicevox-remotion-template/src/data/pizza-oven-project-01/timing.ts @@ -76,7 +76,8 @@ event.type === "clearVideo" || event.type === "standeePosition" || event.type === "standeeFacingDirection" || - event.type === "standeeVerticalOffset" + event.type === "standeeVerticalOffset" || + event.type === "standeeImage" ) { return 0; } diff --git a/voicevox-remotion-template/src/data/yukkuri-composition/timing.ts b/voicevox-remotion-template/src/data/yukkuri-composition/timing.ts index 1aa5d5b..3e9ca0e 100644 --- a/voicevox-remotion-template/src/data/yukkuri-composition/timing.ts +++ b/voicevox-remotion-template/src/data/yukkuri-composition/timing.ts @@ -51,7 +51,8 @@ event.type === "clearVideo" || event.type === "standeePosition" || event.type === "standeeFacingDirection" || - event.type === "standeeVerticalOffset" + event.type === "standeeVerticalOffset" || + event.type === "standeeImage" ) { return 0; } diff --git a/voicevox-remotion-template/src/data/zundamon-jiron/script.ts b/voicevox-remotion-template/src/data/zundamon-jiron/script.ts index f89a342..522a43b 100644 --- a/voicevox-remotion-template/src/data/zundamon-jiron/script.ts +++ b/voicevox-remotion-template/src/data/zundamon-jiron/script.ts @@ -4,11 +4,14 @@ defineVQTimeline, say, still, + standeeDefaultImage, standeeFacingDirection, + standeeImage, standeeShake, standeeVerticalOffset, type VQCustomTimelineEvent, type VQStandeeFacingDirectionEvent, + type VQStandeeImageEvent, type VQStandeeShakeEvent, type VQStandeeVerticalOffsetEvent, type VQSpeechEvent, @@ -51,6 +54,7 @@ | ShowEvent | HideTitleEvent | VQStandeeFacingDirectionEvent + | VQStandeeImageEvent | VQStandeeShakeEvent | VQStandeeVerticalOffsetEvent >; @@ -60,6 +64,7 @@ | ShowEvent | HideTitleEvent | VQStandeeFacingDirectionEvent + | VQStandeeImageEvent | VQStandeeShakeEvent | VQStandeeVerticalOffsetEvent >; @@ -106,12 +111,19 @@ say("zundamon-jiron-zunda-003", "zundamon", "そんなことないのだ!これが夏のベストスタイルなのだ!"), say("zundamon-jiron-zunda-004", "zundamon", "小夜もこのスタイルにしてみればいいのだ。たぶん似合うし、快適なのだ!"), say("zundamon-jiron-sayo-003", "sayo", "えー、私はいいや。なんか清潔じゃない感じがするし…。"), + standeeImage("zundamon-jiron-zunda-image-001", "zundamon", "image/zundamon-ohnegus-rework-baby/36-round-eyes-small-a.png", { + lipSync: false, + }), say("zundamon-jiron-zunda-005", "zundamon", "それは偏見なのだ!"), say("zundamon-jiron-zunda-006", "zundamon", "考えてみるのだ。紙おむつは常に新品をあてることになるから、むしろ清潔なのだ!"), say("zundamon-jiron-zunda-007", "zundamon", "毎日おろしたての下着を身につけるようなものなのだ。洗濯して使い続ける下着よりも清潔なのは明らかなのだ。"), say("zundamon-jiron-sayo-004", "sayo", "うーん、そう言われると確かにそうかも…。"), + standeeDefaultImage("zundamon-jiron-zunda-image-002", "zundamon"), say("zundamon-jiron-zunda-008", "zundamon", "なのだ?だからみんなもこのスタイルを試してみるといいのだ!"), say("zundamon-jiron-sayo-005", "sayo", "確かに言ってることはまちがってないんだけど、\n清潔なのは「穿いた直後」の話だよね?"), + standeeImage("zundamon-jiron-zunda-image-001", "zundamon", "image/zundamon-ohnegus-rework-baby/45-nnaa-n.png", { + lipSync: false, + }), say("zundamon-jiron-zunda-009", "zundamon", "うっ…!"), audio("zundamon-jiron-sound-effect-001", "audio/common_soundeffect/nc174220_osikko7.mp3", { @@ -126,10 +138,15 @@ frequencyHz: 16, }), wait(2.0), + standeeDefaultImage("zundamon-jiron-zunda-image-003", "zundamon"), say("zundamon-jiron-zunda-010", "zundamon", "……丁度いま、清潔じゃなくなったのだ。"), say("zundamon-jiron-sayo-006", "sayo", "そうだね…。"), + standeeImage("zundamon-jiron-sayo-image-001", "sayo", "image/sayo_by_sayonaka/variants/sayo_006_embarrassed.png", { + lipSync: false, + }), say("zundamon-jiron-sayo-007", "sayo", "(……でも、漏らさないで使う限りはずんだもんが言うとおりなのかな?)"), say("zundamon-jiron-zunda-011", "zundamon", "こまめに替えれば大丈夫なのだ!\nなので、小夜もおむつスタイルを試してみるといいのだ!"), + standeeDefaultImage("zundamon-jiron-sayo-image-002", "sayo"), say("zundamon-jiron-sayo-008", "sayo", "考えておくね。\nずんだもんは早くおむつ替えてね。"), say("zundamon-jiron-zunda-012", "zundamon", "そうするのだ!"), wait(4.0), diff --git a/voicevox-remotion-template/src/data/zundamon-jiron/timing.ts b/voicevox-remotion-template/src/data/zundamon-jiron/timing.ts index c56bf75..de21a24 100644 --- a/voicevox-remotion-template/src/data/zundamon-jiron/timing.ts +++ b/voicevox-remotion-template/src/data/zundamon-jiron/timing.ts @@ -24,6 +24,17 @@ export const ZUNDAMON_JIRON_GAP_FRAMES = 6; export const ZUNDAMON_JIRON_DEFAULT_SHOW_SECONDS = 1.5; +const zeroDurationEventTypes = new Set([ + "still", + "clearStill", + "clearVideo", + "hideTitle", + "standeePosition", + "standeeFacingDirection", + "standeeVerticalOffset", + "standeeImage", +]); + export const hasAudioForSpeech = (speech: SpeechEvent) => manifestById.has(speech.id); @@ -76,15 +87,7 @@ return 0; } - if ( - event.type === "still" || - event.type === "clearStill" || - event.type === "clearVideo" || - event.type === "hideTitle" || - event.type === "standeePosition" || - event.type === "standeeFacingDirection" || - event.type === "standeeVerticalOffset" - ) { + if (zeroDurationEventTypes.has(event.type)) { return 0; } @@ -96,12 +99,8 @@ }; const doesTimelineEventAdvanceTimeline = (event: TimelineEvent) => - event.type !== "still" && - event.type !== "clearStill" && event.type !== "audio" && - event.type !== "hideTitle" && - event.type !== "standeeFacingDirection" && - event.type !== "standeeVerticalOffset" && + !zeroDurationEventTypes.has(event.type) && event.type !== "standeeShake"; export const ZundamonJironAssetWorkflow: VQScenarioAssetWorkflow = diff --git a/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterAvatar.tsx b/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterAvatar.tsx index ec66a31..d7d752b 100644 --- a/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterAvatar.tsx +++ b/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterAvatar.tsx @@ -22,6 +22,8 @@ resolveMouth?: VQMouthResolver; fontFamily?: string; imageFilter?: string; + imagePath?: string; + lipSyncEnabled?: boolean; imageFlipX?: boolean; imageTranslateX?: number; imageTranslateY?: number; @@ -35,6 +37,7 @@ speakingLocalFrame, fps, resolveMouth, + lipSyncEnabled, }: Readonly<{ characterId: CharacterId; character: VQCharacterDefinition; @@ -43,11 +46,16 @@ speakingLocalFrame: number; fps: number; resolveMouth?: VQMouthResolver; + lipSyncEnabled?: boolean; }>): VQMouthShape => { const speakingAnimationType = character.avatar.speakingAnimationType ?? "gentleBob"; - if (!isSpeaking || speakingAnimationType !== "rhubarbLipSync") { + if ( + lipSyncEnabled === false || + !isSpeaking || + speakingAnimationType !== "rhubarbLipSync" + ) { return "rest"; } @@ -97,6 +105,8 @@ resolveMouth, fontFamily = "system-ui, sans-serif", imageFilter = "drop-shadow(0 18px 40px rgba(31, 42, 68, 0.22))", + imagePath, + lipSyncEnabled, imageFlipX, imageTranslateX = 0, imageTranslateY, @@ -135,6 +145,7 @@ speakingLocalFrame, fps, resolveMouth, + lipSyncEnabled, }); const nameplate = ( @@ -170,7 +181,7 @@ > {showNameplate && nameplatePosition === "top" ? nameplate : null} >; + avatarLipSyncEnabledByCharacter?: Partial>; avatarFlipXByCharacter?: Partial>; avatarTranslateXByCharacter?: Partial>; avatarTranslateYByCharacter?: Partial>; @@ -34,6 +36,8 @@ fontFamily, stageStyle, avatarImageFilter, + avatarImagePathByCharacter, + avatarLipSyncEnabledByCharacter, avatarFlipXByCharacter, avatarTranslateXByCharacter, avatarTranslateYByCharacter, @@ -68,6 +72,8 @@ resolveMouth={resolveMouth} fontFamily={fontFamily} imageFilter={avatarImageFilter} + imagePath={avatarImagePathByCharacter?.[characterId]} + lipSyncEnabled={avatarLipSyncEnabledByCharacter?.[characterId]} imageFlipX={avatarFlipXByCharacter?.[characterId]} imageTranslateX={avatarTranslateXByCharacter?.[characterId]} imageTranslateY={avatarTranslateYByCharacter?.[characterId]} diff --git a/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts b/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts index 1bdf390..6b2c4a9 100644 --- a/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts +++ b/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts @@ -81,6 +81,16 @@ translateY: number; }>; +export type VQStandeeImageEvent = + Readonly<{ + type: "standeeImage"; + id: string; + character: CharacterId; + imagePath?: string; + lipSync?: boolean; + resetToDefault?: boolean; + }>; + export type VQStandeeShakeEvent = Readonly<{ type: "standeeShake"; @@ -165,6 +175,7 @@ | VQStandeePositionEvent | VQStandeeFacingDirectionEvent | VQStandeeVerticalOffsetEvent + | VQStandeeImageEvent | VQStandeeShakeEvent | VQWaitEvent | VQAudioEvent @@ -183,6 +194,7 @@ | VQStandeePositionEvent | VQStandeeFacingDirectionEvent | VQStandeeVerticalOffsetEvent + | VQStandeeImageEvent | VQStandeeShakeEvent | VQWaitEvent | VQWaitEventInput @@ -309,6 +321,35 @@ translateY, }); +// 用途: キャラクター立ち絵の画像をタイムライン上で切り替えるイベントとして定義する。 +// 使用方法: script.ts の timeline 内で standeeImage("id", "zundamon", "image/path.png", {lipSync: false}) として呼び出す。 +// オプションや引数詳細: imagePath は public 配下からの相対パス、lipSync は口パク表示のON/OFFを指定する。 +export const standeeImage = ( + id: string, + character: CharacterId, + imagePath: string, + options: Pick, "lipSync"> = {} +): VQStandeeImageEvent => ({ + type: "standeeImage", + id, + character, + imagePath, + ...options, +}); + +// 用途: タイムライン上で切り替えた立ち絵画像と口パク設定を、キャラクター定義の既定値へ戻す。 +// 使用方法: script.ts の timeline 内で standeeDefaultImage("id", "zundamon") として呼び出す。 +// オプションや引数詳細: 追加オプションはなく、avatar.imagePath と avatar.speakingAnimationType の既定挙動に戻す。 +export const standeeDefaultImage = ( + id: string, + character: CharacterId +): VQStandeeImageEvent => ({ + type: "standeeImage", + id, + character, + resetToDefault: true, +}); + // 用途: 指定したキャラクター立ち絵を一定時間x/y方向へ振動させるタイムラインイベントとして定義する。 // 使用方法: script.ts の timeline 内で standeeShake("id", "zundamon", {durationSeconds: 2}) として呼び出す。 // オプションや引数詳細: amplitudeX/Y はpx単位の振れ幅、frequencyHz は1秒あたりの揺れ回数を指定する。 @@ -370,6 +411,7 @@ event.type !== "standeePosition" && event.type !== "standeeFacingDirection" && event.type !== "standeeVerticalOffset" && + event.type !== "standeeImage" && event.type !== "standeeShake" && event.type !== "audio" && (event.type !== "video" || diff --git a/voicevox-remotion-template/src/zundamon-jiron.tsx b/voicevox-remotion-template/src/zundamon-jiron.tsx index 4cffe92..342b623 100644 --- a/voicevox-remotion-template/src/zundamon-jiron.tsx +++ b/voicevox-remotion-template/src/zundamon-jiron.tsx @@ -42,6 +42,8 @@ titleVisible: boolean; visibleCharacters: CharacterId[]; focusedCharacter?: CharacterId; + avatarImagePathByCharacter: Partial>; + avatarLipSyncEnabledByCharacter: Partial>; avatarFlipXByCharacter: Partial>; avatarTranslateYByCharacter: Partial>; }>; @@ -71,6 +73,7 @@ event.type !== "hideTitle" && event.type !== "standeeFacingDirection" && event.type !== "standeeVerticalOffset" && + event.type !== "standeeImage" && event.type !== "standeeShake"; const audioSequenceDuration = ( @@ -92,6 +95,9 @@ const scheduleTimeline = (fps: number): ScheduledTimelineEvent[] => { let cursor = 0; const visibleCharacters = new Set(initialVisibleCharacters); + const avatarImagePathByCharacter: Partial> = {}; + const avatarLipSyncEnabledByCharacter: Partial> = + {}; const avatarFlipXByCharacter: Partial> = {}; const avatarTranslateYByCharacter: Partial> = {}; let focusedCharacter: CharacterId | undefined = initialVisibleCharacters[0]; @@ -118,6 +124,23 @@ avatarTranslateYByCharacter[event.character] = event.translateY; } + if (event.type === "standeeImage") { + if (event.resetToDefault) { + delete avatarImagePathByCharacter[event.character]; + delete avatarLipSyncEnabledByCharacter[event.character]; + } else { + if (event.imagePath !== undefined) { + avatarImagePathByCharacter[event.character] = event.imagePath; + } + + if (event.lipSync === undefined) { + delete avatarLipSyncEnabledByCharacter[event.character]; + } else { + avatarLipSyncEnabledByCharacter[event.character] = event.lipSync; + } + } + } + const durationInFrames = durationForTimelineEvent(event, fps); const scheduledEvent = { event, @@ -126,6 +149,8 @@ titleVisible, visibleCharacters: Array.from(visibleCharacters), focusedCharacter, + avatarImagePathByCharacter: {...avatarImagePathByCharacter}, + avatarLipSyncEnabledByCharacter: {...avatarLipSyncEnabledByCharacter}, avatarFlipXByCharacter: {...avatarFlipXByCharacter}, avatarTranslateYByCharacter: {...avatarTranslateYByCharacter}, }; @@ -385,6 +410,10 @@ resolveMouth={resolveMouth} fontFamily={roundedFontFamily} stageStyle={{paddingBottom: 130}} + avatarImagePathByCharacter={activeSegment.avatarImagePathByCharacter} + avatarLipSyncEnabledByCharacter={ + activeSegment.avatarLipSyncEnabledByCharacter + } avatarFlipXByCharacter={activeSegment.avatarFlipXByCharacter} avatarTranslateXByCharacter={avatarTranslateXByCharacter} avatarTranslateYByCharacter={avatarTranslateYByCharacter}