diff --git a/voicevox-remotion-template/src/data/pizza-kiln/characters.ts b/voicevox-remotion-template/src/data/pizza-kiln/characters.ts new file mode 100644 index 0000000..1689eee --- /dev/null +++ b/voicevox-remotion-template/src/data/pizza-kiln/characters.ts @@ -0,0 +1,29 @@ +import {getStandeeSet, type AvatarDefinition} from "../../standee-sets"; + +export type VoicevoxVoice = Readonly<{ + speakerName: string; + styleName: string; +}>; + +export type CharacterDefinition = Readonly<{ + displayName: string; + voicevox: VoicevoxVoice; + avatar: AvatarDefinition; +}>; + +export const characters = { + sayo: { + displayName: "小夜", + voicevox: { + speakerName: "小夜/SAYO", + styleName: "ノーマル", + }, + avatar: { + ...getStandeeSet("sayo_ohnegus_ai"), + accentColor: "#6b5f83", + speakingAnimationType: "rhubarbLipSync", + }, + }, +} as const satisfies Record; + +export type CharacterId = keyof typeof characters; diff --git a/voicevox-remotion-template/src/data/pizza-kiln/script.ts b/voicevox-remotion-template/src/data/pizza-kiln/script.ts index caf2aa4..fbe9ced 100644 --- a/voicevox-remotion-template/src/data/pizza-kiln/script.ts +++ b/voicevox-remotion-template/src/data/pizza-kiln/script.ts @@ -1,61 +1,16 @@ -import {getStandeeSet, type AvatarDefinition} from "../../standee-sets"; +import { + defineVQTimeline, + say, + type VQSpeechEvent, +} from "../../lib/VQRemotionLib"; +import type {CharacterId, VoicevoxVoice} from "./characters"; -export type VoicevoxVoice = Readonly<{ - speakerName: string; - styleName: string; -}>; +export {characters} from "./characters"; +export type {CharacterDefinition, CharacterId, VoicevoxVoice} from "./characters"; -export type CharacterDefinition = Readonly<{ - displayName: string; - voicevox: VoicevoxVoice; - avatar: AvatarDefinition; -}>; +export type SpeechEvent = VQSpeechEvent; -export const characters = { - sayo: { - displayName: "小夜", - voicevox: { - speakerName: "小夜/SAYO", - styleName: "ノーマル", - }, - avatar: { - ...getStandeeSet("sayo_ohnegus_ai"), - accentColor: "#6b5f83", - speakingAnimationType: "rhubarbLipSync", - }, - }, -} as const satisfies Record; - -export type CharacterId = keyof typeof characters; - -export type SpeechOptions = Readonly<{ - subtitle?: string; - voicevox?: Partial; -}>; - -export type SpeechEvent = Readonly<{ - type: "say"; - id: string; - character: CharacterId; - text: string; - subtitle?: string; - voicevox?: Partial; -}>; - -export const say = ( - id: string, - character: CharacterId, - text: string, - options: SpeechOptions = {} -): SpeechEvent => ({ - type: "say", - id, - character, - text, - ...options, -}); - -export const timeline = [ +export const timeline = defineVQTimeline([ say( "pizza-kiln-sayo-001", "sayo", @@ -66,6 +21,4 @@ "sayo", "以上、小夜がお届けしました。ピザ窯の雰囲気、伝わっていたらうれしいです。" ), -] satisfies SpeechEvent[]; - -export const script = timeline; +] satisfies readonly SpeechEvent[]); diff --git a/voicevox-remotion-template/src/data/pizza-oven-project-01/characters.ts b/voicevox-remotion-template/src/data/pizza-oven-project-01/characters.ts new file mode 100644 index 0000000..c5a7de6 --- /dev/null +++ b/voicevox-remotion-template/src/data/pizza-oven-project-01/characters.ts @@ -0,0 +1,30 @@ +import {getStandeeSet, type AvatarDefinition} from "../../standee-sets"; + +export type VoicevoxVoice = Readonly<{ + speakerName: string; + styleName: string; +}>; + +export type CharacterDefinition = Readonly<{ + displayName: string; + voicevox: VoicevoxVoice; + avatar: AvatarDefinition; +}>; + +export const characters = { + sayo: { + displayName: "小夜", + voicevox: { + speakerName: "小夜/SAYO", + styleName: "ノーマル", + }, + avatar: { + ...getStandeeSet("sayo_ohnegus_ai"), + accentColor: "#6b5f83", + speakingAnimationType: "rhubarbLipSync", + idleAnimationType: "none", + }, + }, +} as const satisfies Record; + +export type CharacterId = keyof typeof characters; diff --git a/voicevox-remotion-template/src/data/pizza-oven-project-01/script.ts b/voicevox-remotion-template/src/data/pizza-oven-project-01/script.ts index 8ee408e..501a9f2 100644 --- a/voicevox-remotion-template/src/data/pizza-oven-project-01/script.ts +++ b/voicevox-remotion-template/src/data/pizza-oven-project-01/script.ts @@ -1,93 +1,25 @@ -import {getStandeeSet, type AvatarDefinition} from "../../standee-sets"; -import type {VQStillEvent, VQWaitEvent} from "../../lib/VQRemotionLib"; +import { + defineVQTimeline, + say, + still, + wait, + type VQSpeechEvent, + type VQTimelineEvent, + type VQTimelineInputEvent, +} from "../../lib/VQRemotionLib"; +import type {CharacterId, VoicevoxVoice} from "./characters"; -export type VoicevoxVoice = Readonly<{ - speakerName: string; - styleName: string; -}>; +export {characters} from "./characters"; +export type {CharacterDefinition, CharacterId, VoicevoxVoice} from "./characters"; -export type CharacterDefinition = Readonly<{ - displayName: string; - voicevox: VoicevoxVoice; - avatar: AvatarDefinition; -}>; +export type SpeechEvent = VQSpeechEvent; +export type TimelineEvent = VQTimelineEvent; +export type TimelineInputEvent = VQTimelineInputEvent< + CharacterId, + VoicevoxVoice +>; -export const characters = { - sayo: { - displayName: "小夜", - voicevox: { - speakerName: "小夜/SAYO", - styleName: "ノーマル", - }, - avatar: { - ...getStandeeSet("sayo_ohnegus_ai"), - accentColor: "#6b5f83", - speakingAnimationType: "rhubarbLipSync", - idleAnimationType: "none", - }, - }, -} as const satisfies Record; - -export type CharacterId = keyof typeof characters; - -export type SpeechOptions = Readonly<{ - subtitle?: string; - voicevox?: Partial; - durationSeconds?: number; -}>; - -export type SpeechEvent = Readonly<{ - type: "say"; - id: string; - character: CharacterId; - text: string; - subtitle?: string; - voicevox?: Partial; - durationSeconds?: number; -}>; - -export type StillEvent = VQStillEvent; -export type WaitEvent = VQWaitEvent; - -export type TimelineEvent = SpeechEvent | StillEvent | WaitEvent; - -export const say = ( - id: string, - character: CharacterId, - text: string, - options: SpeechOptions = {} -): SpeechEvent => ({ - type: "say", - id, - character, - text, - ...options, -}); - -export const still = ( - id: string, - imagePath: string, - options: Omit = {} -): StillEvent => ({ - type: "still", - id, - imagePath, - ...options, -}); - -let waitEventIndex = 0; - -export const wait = (durationSeconds: number): WaitEvent => { - waitEventIndex += 1; - - return { - type: "wait", - id: `wait-${String(waitEventIndex).padStart(3, "0")}`, - durationSeconds, - }; -}; - -export const timeline = [ +export const timeline = defineVQTimeline([ say("pizza-oven-project-01-sayo-001", "sayo", "こんにちは。小夜です。"), say("pizza-oven-project-01-sayo-002", "sayo", "ピザって美味しいじゃないですか。"), say("pizza-oven-project-01-sayo-003", "sayo", "だから、作る事にしたんですよね。"), @@ -102,10 +34,4 @@ say("pizza-oven-project-01-sayo-004", "sayo", "ピザ窯を。"), wait(1), say("pizza-oven-project-01-sayo-005", "sayo", "まずはblender上で、耐熱レンガの寸法を元に積み方を設計することにしました。"), -] satisfies TimelineEvent[]; - -export const isSpeechEvent = ( - event: TimelineEvent -): event is SpeechEvent => event.type === "say"; - -export const script = timeline.filter(isSpeechEvent); +] satisfies readonly TimelineInputEvent[]); diff --git a/voicevox-remotion-template/src/data/yukkuri-composition/characters.ts b/voicevox-remotion-template/src/data/yukkuri-composition/characters.ts new file mode 100644 index 0000000..e0b6fdb --- /dev/null +++ b/voicevox-remotion-template/src/data/yukkuri-composition/characters.ts @@ -0,0 +1,47 @@ +import {getStandeeSet, type AvatarDefinition} from "../../standee-sets"; + +export type VoicevoxVoice = Readonly<{ + speakerName: string; + styleName: string; +}>; + +export type CharacterDefinition = Readonly<{ + displayName: string; + voicevox: VoicevoxVoice; + avatar: AvatarDefinition; +}>; + +export const characters = { + zundamon: { + displayName: "ずんだもん", + voicevox: { + speakerName: "ずんだもん", + styleName: "ノーマル", + }, + avatar: { + ...getStandeeSet("zundamon_ohnegus_ai"), + accentColor: "#79d36f", + nameplatePosition: "none", + idleAnimationType: "none", + speakingAnimationType: "rhubarbLipSync", + }, + }, + sayo: { + displayName: "小夜", + voicevox: { + speakerName: "小夜/SAYO", + styleName: "ノーマル", + }, + avatar: { + ...getStandeeSet("sayo_ohnegus_ai"), + accentColor: "#6b5f83", + nameplatePosition: "none", + idleAnimationType: "none", + speakingAnimationType: "rhubarbLipSync", + }, + }, +} as const satisfies Record; + +export type CharacterId = keyof typeof characters; + +export const initialVisibleCharacters: CharacterId[] = ["zundamon"]; diff --git a/voicevox-remotion-template/src/data/yukkuri-composition/script.ts b/voicevox-remotion-template/src/data/yukkuri-composition/script.ts index b0380f6..96c372e 100644 --- a/voicevox-remotion-template/src/data/yukkuri-composition/script.ts +++ b/voicevox-remotion-template/src/data/yukkuri-composition/script.ts @@ -1,89 +1,39 @@ -import {getStandeeSet, type AvatarDefinition} from "../../standee-sets"; +import { + defineVQTimeline, + say, + type VQCustomTimelineEvent, + type VQSpeechEvent, + type VQTimelineEvent, + type VQTimelineInputEvent, +} from "../../lib/VQRemotionLib"; +import type {CharacterId, VoicevoxVoice} from "./characters"; -export type VoicevoxVoice = Readonly<{ - speakerName: string; - styleName: string; -}>; - -export type CharacterDefinition = Readonly<{ - displayName: string; - voicevox: VoicevoxVoice; - avatar: AvatarDefinition; -}>; - -export const characters = { - zundamon: { - displayName: "ずんだもん", - voicevox: { - speakerName: "ずんだもん", - styleName: "ノーマル", - }, - avatar: { - ...getStandeeSet("zundamon_ohnegus_ai"), - accentColor: "#79d36f", - nameplatePosition: "none", - idleAnimationType: "none", - speakingAnimationType: "rhubarbLipSync", - }, - }, - sayo: { - displayName: "小夜", - voicevox: { - speakerName: "小夜/SAYO", - styleName: "ノーマル", - }, - avatar: { - ...getStandeeSet("sayo_ohnegus_ai"), - accentColor: "#6b5f83", - nameplatePosition: "none", - idleAnimationType: "none", - speakingAnimationType: "rhubarbLipSync", - }, - }, -} as const satisfies Record; - -export type CharacterId = keyof typeof characters; - -export type SpeechOptions = Readonly<{ - subtitle?: string; - voicevox?: Partial; -}>; +export {characters, initialVisibleCharacters} from "./characters"; +export type {CharacterDefinition, CharacterId, VoicevoxVoice} from "./characters"; export type ShowOptions = Readonly<{ caption?: string; durationSeconds?: number; }>; -export type SpeechEvent = Readonly<{ - type: "say"; - id: string; - character: CharacterId; - text: string; - subtitle?: string; - voicevox?: Partial; -}>; +export type SpeechEvent = VQSpeechEvent; -export type ShowEvent = Readonly<{ - type: "show"; +export type ShowEvent = VQCustomTimelineEvent<"show", { character: CharacterId; caption?: string; durationSeconds?: number; }>; -export type TimelineEvent = SpeechEvent | ShowEvent; - -export const say = ( - id: string, - character: CharacterId, - text: string, - options: SpeechOptions = {} -): SpeechEvent => ({ - type: "say", - id, - character, - text, - ...options, -}); +export type TimelineEvent = VQTimelineEvent< + CharacterId, + VoicevoxVoice, + ShowEvent +>; +export type TimelineInputEvent = VQTimelineInputEvent< + CharacterId, + VoicevoxVoice, + ShowEvent +>; export const show = ( character: CharacterId, @@ -94,9 +44,7 @@ ...options, }); -export const initialVisibleCharacters: CharacterId[] = ["zundamon"]; - -export const timeline: TimelineEvent[] = [ +export const timeline = defineVQTimeline([ say("zunda-001", "zundamon", "みなさんこんにちは、ずんだもんなのだ!"), say( "zunda-002", @@ -122,10 +70,4 @@ "小夜です。ネコミミ代表として、猫耳のかわいさを証明しに来ました。" ), say("zunda-005", "zundamon", "それじゃあ、また次回なのだ!"), -]; - -export const isSpeechEvent = ( - event: TimelineEvent -): event is SpeechEvent => event.type === "say"; - -export const script = timeline.filter(isSpeechEvent); +] satisfies readonly TimelineInputEvent[]); diff --git a/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQStillBackground.tsx b/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQStillBackground.tsx index 63d8300..8ab408a 100644 --- a/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQStillBackground.tsx +++ b/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQStillBackground.tsx @@ -1,6 +1,6 @@ import React from "react"; import {AbsoluteFill, Img, staticFile} from "remotion"; -import type {VQStillEvent} from "../types"; +import type {VQStillEvent} from "../timeline"; const remoteUrlPattern = /^https?:\/\//; diff --git a/voicevox-remotion-template/src/lib/VQRemotionLib/index.ts b/voicevox-remotion-template/src/lib/VQRemotionLib/index.ts index b965a02..5fd79e1 100644 --- a/voicevox-remotion-template/src/lib/VQRemotionLib/index.ts +++ b/voicevox-remotion-template/src/lib/VQRemotionLib/index.ts @@ -1,6 +1,7 @@ export * from "./types"; export * from "./avatarAnimations"; export * from "./scenario"; +export * from "./timeline"; export * from "./components/VQCaptionOverlay"; export * from "./components/VQCharacterAvatar"; export * from "./components/VQCharacterStage"; diff --git a/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts b/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts new file mode 100644 index 0000000..8bb34fb --- /dev/null +++ b/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts @@ -0,0 +1,166 @@ +export type VQSpeechOptions = Readonly<{ + subtitle?: string; + voicevox?: Partial; + durationSeconds?: number; +}>; + +export type VQSpeechEvent< + CharacterId extends string = string, + Voice = unknown +> = Readonly<{ + type: "say"; + id: string; + character: CharacterId; + text: string; + subtitle?: string; + voicevox?: Partial; + durationSeconds?: number; +}>; + +export type VQStillObjectFit = + | "cover" + | "contain" + | "fill" + | "none" + | "scale-down"; + +export type VQStillEvent = Readonly<{ + type: "still"; + id: string; + imagePath: string; + durationSeconds?: number; + fit?: VQStillObjectFit; + objectPosition?: string; + opacity?: number; +}>; + +export type VQWaitEvent = Readonly<{ + type: "wait"; + id: string; + durationSeconds: number; +}>; + +export type VQWaitEventInput = Readonly<{ + type: "wait"; + durationSeconds: number; +}>; + +export type VQCustomTimelineEvent< + Type extends string, + Payload extends object = object +> = Readonly< + { + type: Type; + } & Payload +>; + +export type VQTimelineEvent< + CharacterId extends string = string, + Voice = unknown, + ExtraEvent extends Readonly<{type: string}> = never +> = VQSpeechEvent | VQStillEvent | VQWaitEvent | ExtraEvent; + +export type VQTimelineInputEvent< + CharacterId extends string = string, + Voice = unknown, + ExtraEvent extends Readonly<{type: string}> = never +> = + | VQSpeechEvent + | VQStillEvent + | VQWaitEvent + | VQWaitEventInput + | ExtraEvent; + +type VQTimelineInputEventBase = Readonly<{type: string}>; + +export type VQNormalizeTimelineEvent = + Event extends Readonly<{type: "wait"; durationSeconds: infer Duration}> + ? Readonly & {id: string; durationSeconds: Duration}> + : Event; + +export type VQDefinedTimeline< + Timeline extends readonly VQTimelineInputEventBase[] +> = { + readonly [Index in keyof Timeline]: VQNormalizeTimelineEvent; +}; + +// 用途: VOICEVOX発話をタイムラインイベントとして定義する。 +// 使用方法: script.ts の timeline 内で say("id", "character", "text", options) として呼び出す。 +// オプションや引数詳細: options には字幕・VOICEVOX上書き・音声未生成時の秒数を指定できる。 +export const say = ( + id: string, + character: CharacterId, + text: string, + options: VQSpeechOptions = {} +): VQSpeechEvent => ({ + type: "say", + id, + character, + text, + ...options, +}); + +// 用途: 静止画をタイムラインイベントとして定義する。 +// 使用方法: script.ts の timeline 内で still("id", "path", options) として呼び出す。 +// オプションや引数詳細: options には表示秒数・object-fit・表示位置・透明度を指定できる。 +export const still = ( + id: string, + imagePath: string, + options: Omit = {} +): VQStillEvent => ({ + type: "still", + id, + imagePath, + ...options, +}); + +// 用途: 何も発話しない待機時間をタイムラインイベントとして定義する。 +// 使用方法: script.ts の timeline 内で wait(1) のように秒数だけを指定し、defineVQTimelineでIDを確定する。 +// オプションや引数詳細: durationSeconds は待機秒数で、IDはtimeline単位で wait-001 から自動採番される。 +export const wait = (durationSeconds: number): VQWaitEventInput => ({ + type: "wait", + durationSeconds, +}); + +// 用途: waitイベントの連番からタイムライン内IDを作る。 +// 使用方法: defineVQTimeline内で未解決waitイベントを正規化するときに呼び出す。 +// オプションや引数詳細: index は1始まりのwait連番で、3桁ゼロ埋めにする。 +const waitEventId = (index: number) => + `wait-${String(index).padStart(3, "0")}`; + +// 用途: タイムライン配列を確定し、未解決のwaitイベントへ安定したIDを付与する。 +// 使用方法: script.ts で defineVQTimeline([say(...), wait(1)]) の形でtimelineを定義する。 +// オプションや引数詳細: wait IDはこの関数に渡された配列の中だけで先頭から採番される。 +export const defineVQTimeline = < + const Timeline extends readonly VQTimelineInputEventBase[] +>( + timeline: Timeline +): VQDefinedTimeline => { + let waitEventIndex = 0; + + return timeline.map((event) => { + if (event.type === "wait") { + waitEventIndex += 1; + + if (!("id" in event)) { + return { + ...event, + id: waitEventId(waitEventIndex), + }; + } + } + + return event; + }) as VQDefinedTimeline; +}; + +// 用途: タイムラインイベントから発話イベントだけを型安全に判定する。 +// 使用方法: event.type を直接比較したくない場所で isVQSpeechEvent(event) として使う。 +// オプションや引数詳細: CharacterId・Voice・拡張イベント型は呼び出し元のTimelineEventに合わせて推論される。 +export const isVQSpeechEvent = < + CharacterId extends string = string, + Voice = unknown, + ExtraEvent extends Readonly<{type: string}> = never +>( + event: VQTimelineEvent +): event is VQSpeechEvent => event.type === "say"; diff --git a/voicevox-remotion-template/src/lib/VQRemotionLib/types.ts b/voicevox-remotion-template/src/lib/VQRemotionLib/types.ts index 99ad2df..11dcaa0 100644 --- a/voicevox-remotion-template/src/lib/VQRemotionLib/types.ts +++ b/voicevox-remotion-template/src/lib/VQRemotionLib/types.ts @@ -55,29 +55,6 @@ subtitle?: string; }>; -export type VQStillObjectFit = - | "cover" - | "contain" - | "fill" - | "none" - | "scale-down"; - -export type VQStillEvent = Readonly<{ - type: "still"; - id: string; - imagePath: string; - durationSeconds?: number; - fit?: VQStillObjectFit; - objectPosition?: string; - opacity?: number; -}>; - -export type VQWaitEvent = Readonly<{ - type: "wait"; - id: string; - durationSeconds: number; -}>; - export type VQMouthResolverContext = Readonly<{ characterId: CharacterId; diff --git a/voicevox-remotion-template/src/pizzaOvenProject01.ts b/voicevox-remotion-template/src/pizzaOvenProject01.ts index 4651533..8937ba2 100644 --- a/voicevox-remotion-template/src/pizzaOvenProject01.ts +++ b/voicevox-remotion-template/src/pizzaOvenProject01.ts @@ -10,7 +10,6 @@ import { characters, type SpeechEvent, - type StillEvent, type TimelineEvent, } from "./data/pizza-oven-project-01/script"; import { @@ -27,6 +26,7 @@ VQStillBackground, VQWarmGradientBackground, type VQStageCornerStandeeLayouts, + type VQStillEvent, vqDefaultStageCornerStandeeLayouts, } from "./lib/VQRemotionLib"; import {getMouthForSpeechFrame} from "./lipsync/manifest"; @@ -122,7 +122,7 @@ activeSegment && isInsideActiveSegment && activeSegment.event.type === "say" ? activeSegment.event : undefined; - const activeStill = scheduledEvents.reduce( + const activeStill = scheduledEvents.reduce( (currentStill, scheduledEvent) => scheduledEvent.from <= frame && scheduledEvent.event.type === "still" ? scheduledEvent.event diff --git a/voicevox-remotion-template/src/yukkuri-composition/index.tsx b/voicevox-remotion-template/src/yukkuri-composition/index.tsx index 4465d37..16579cc 100644 --- a/voicevox-remotion-template/src/yukkuri-composition/index.tsx +++ b/voicevox-remotion-template/src/yukkuri-composition/index.tsx @@ -138,6 +138,10 @@ ); } + if (event.type !== "show") { + return null; + } + return ( { - if (event.type === "say") { + if ("id" in event) { return event.id; } - return `show-${event.character}-${index}`; + return `${event.type}-${event.character}-${index}`; }; export const YukkuriComposition: React.FC = () => {