diff --git a/voicevox-remotion-template/README.md b/voicevox-remotion-template/README.md index be067d0..b2ed6d1 100644 --- a/voicevox-remotion-template/README.md +++ b/voicevox-remotion-template/README.md @@ -31,6 +31,26 @@ - `say(...)`: キャラクターに読み上げさせ、字幕と音声を同期します。 - 行ごとにスタイルを変える場合は `say(..., {voicevox: {styleName: "スタイル名"}})` を使います。 +### 3.1 新しいコンポジションを作成 +```bash +npm run composition:create -- my-new-video +``` + +コンポジション名は kebab-case の slug で指定します。 +上記の例では Remotion ID `MyNewVideo` と +`src/data/my-new-video/script.ts` が作成されます。 + +作成後は、原則として `src/data/my-new-video/script.ts` の +`compositionTitle`、`characters`、`timeline` を編集します。 + +音声と口パクを生成する場合は、作成された専用コマンドを実行します。 + +```bash +npm run voice:generate:my-new-video +npm run lipsync:generate:my-new-video +npm run start -- --webpack-poll=1000 +``` + ### 4. 音声を生成 ```bash npm run voice:generate diff --git a/voicevox-remotion-template/package.json b/voicevox-remotion-template/package.json index a556037..4be982a 100644 --- a/voicevox-remotion-template/package.json +++ b/voicevox-remotion-template/package.json @@ -7,6 +7,7 @@ "start": "remotion preview", "render": "remotion render", "lint": "eslint .", + "composition:create": "node scripts/create-composition.js", "lipsync:generate": "node scripts/generate-lipsync.js", "lipsync:generate:all": "node scripts/generate-lipsync.js --all", "lipsync:generate:yukkuri-composition": "node scripts/generate-lipsync.js --project yukkuri-composition", diff --git a/voicevox-remotion-template/scripts/create-composition.js b/voicevox-remotion-template/scripts/create-composition.js new file mode 100644 index 0000000..ebd4114 --- /dev/null +++ b/voicevox-remotion-template/scripts/create-composition.js @@ -0,0 +1,365 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +const projectRoot = path.resolve( + fileURLToPath(new URL("..", import.meta.url)) +); +const templateRoot = path.join( + projectRoot, + "scripts/templates/dialogue-standee" +); +const slugPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +// 用途: CLI利用者へ新規コンポジション作成コマンドの使い方を表示する。 +// 使用方法: 引数なし、または --help / -h が指定されたときに呼び出す。 +// オプションや引数詳細: slug は kebab-case のみ許可し、Remotion ID は自動生成する。 +const printHelp = () => { + console.log(`新規コンポジション作成 + +Usage: + npm run composition:create -- + +Example: + npm run composition:create -- my-new-video + +slug は kebab-case で指定してください。例: my-new-video`); +}; + +// 用途: kebab-case slug を Remotion の Composition ID 用 PascalCase へ変換する。 +// 使用方法: create-composition の入力 slug を検証した直後に呼び出す。 +// オプションや引数詳細: "my-new-video" は "MyNewVideo" に変換する。 +const toPascalCase = (slug) => + slug + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + +// 用途: kebab-case slug を TypeScript の定数名用 SNAKE_CASE へ変換する。 +// 使用方法: timing.ts と root.tsx のプレースホルダー置換に使う。 +// オプションや引数詳細: "my-new-video" は "MY_NEW_VIDEO" に変換する。 +const toScreamingSnakeCase = (slug) => slug.replaceAll("-", "_").toUpperCase(); + +// 用途: slug から人が読む初期タイトルを作る。 +// 使用方法: script.ts の compositionTitle 初期値へ埋め込む。 +// オプションや引数詳細: "my-new-video" は "My New Video" に変換する。 +const toTitle = (slug) => + slug + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + +// 用途: ファイルまたはディレクトリの存在確認を行う。 +// 使用方法: 生成先衝突チェックやテンプレート読み込み前に await して呼び出す。 +// オプションや引数詳細: アクセスできれば true、存在しない場合は false を返す。 +const pathExists = async (targetPath) => { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +}; + +// 用途: projectRoot 基準のパスをログ表示しやすいスラッシュ区切りへ変換する。 +// 使用方法: エラーや作成完了ログへファイルパスを表示するときに使う。 +// オプションや引数詳細: Windows と Linux の区切り文字差を吸収する。 +const toProjectRelative = (targetPath) => + path.relative(projectRoot, targetPath).split(path.sep).join("/"); + +// 用途: テンプレート内のプレースホルダーを新規コンポジション用の値へ置換する。 +// 使用方法: コピー元テンプレートファイルの本文を渡して、書き込み前に呼び出す。 +// オプションや引数詳細: replacements は __SLUG__ などの完全一致キーを値へ置換する。 +const replacePlaceholders = (content, replacements) => + Object.entries(replacements).reduce( + (result, [key, value]) => result.replaceAll(key, value), + content + ); + +// 用途: テンプレートファイルを読み込み、新規コンポジション用のファイルとして書き出す。 +// 使用方法: createDataFiles と createComponentFile から生成元・生成先を指定して呼び出す。 +// オプションや引数詳細: 生成先の親ディレクトリは必要に応じて作成する。 +const copyTemplateFile = async ({from, to, replacements}) => { + const content = await fs.readFile(from, "utf8"); + await fs.mkdir(path.dirname(to), {recursive: true}); + await fs.writeFile(to, replacePlaceholders(content, replacements)); +}; + +// 用途: CLI 引数を読み取り、生成対象 slug を決める。 +// 使用方法: main の最初に呼び出し、help または create の動作モードを返す。 +// オプションや引数詳細: slug 以外の余分な引数や不正な slug はエラーにする。 +const parseArgs = () => { + const args = process.argv.slice(2); + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + return {mode: "help"}; + } + + if (args.length !== 1) { + throw new Error("Specify exactly one slug."); + } + + const slug = args[0]; + if (!slugPattern.test(slug)) { + throw new Error( + `Invalid slug "${slug}". Use kebab-case like "my-new-video".` + ); + } + + return {mode: "create", slug}; +}; + +// 用途: 生成前に既存ディレクトリ、既存 component、既存 Remotion ID との衝突を検出する。 +// 使用方法: ファイル作成や登録更新より前に呼び出し、問題があれば停止する。 +// オプションや引数詳細: root.tsx 内の id と import 名も確認する。 +const assertNoConflicts = async ({slug, pascalId, paths}) => { + const collisions = []; + if (await pathExists(paths.dataDir)) { + collisions.push(toProjectRelative(paths.dataDir)); + } + if (await pathExists(paths.componentFile)) { + collisions.push(toProjectRelative(paths.componentFile)); + } + + const rootSource = await fs.readFile(paths.rootFile, "utf8"); + if (rootSource.includes(`id="${pascalId}"`)) { + collisions.push(`Remotion Composition ID "${pascalId}"`); + } + if (rootSource.includes(`{${pascalId}}`) || rootSource.includes(` ${pascalId}`)) { + collisions.push(`component export "${pascalId}"`); + } + + const packageJson = JSON.parse(await fs.readFile(paths.packageFile, "utf8")); + if (packageJson.scripts?.[`voice:generate:${slug}`]) { + collisions.push(`npm script "voice:generate:${slug}"`); + } + if (packageJson.scripts?.[`lipsync:generate:${slug}`]) { + collisions.push(`npm script "lipsync:generate:${slug}"`); + } + + if (collisions.length > 0) { + throw new Error(`Target already exists: ${collisions.join(", ")}`); + } +}; + +// 用途: script.ts、timing.ts、voicevox-manifest.json を data ディレクトリへ作成する。 +// 使用方法: 衝突チェック後に呼び出し、テンプレートから data ファイル一式を生成する。 +// オプションや引数詳細: script.ts と timing.ts は slug 等のプレースホルダーを置換する。 +const createDataFiles = async ({paths, replacements}) => { + await copyTemplateFile({ + from: path.join(templateRoot, "data/script.ts.template"), + to: path.join(paths.dataDir, "script.ts"), + replacements, + }); + await copyTemplateFile({ + from: path.join(templateRoot, "data/timing.ts.template"), + to: path.join(paths.dataDir, "timing.ts"), + replacements, + }); + await copyTemplateFile({ + from: path.join(templateRoot, "data/voicevox-manifest.json.template"), + to: path.join(paths.dataDir, "voicevox-manifest.json"), + replacements, + }); +}; + +// 用途: Remotion の描画 component ファイルを src 直下へ作成する。 +// 使用方法: data ファイル作成と同じ置換値を渡して呼び出す。 +// オプションや引数詳細: component export 名は PascalCase の Remotion ID と同じにする。 +const createComponentFile = async ({paths, replacements}) => { + await copyTemplateFile({ + from: path.join(templateRoot, "component.tsx.template"), + to: paths.componentFile, + replacements, + }); +}; + +// 用途: src/root.tsx へ import と Composition 登録を追加する。 +// 使用方法: component と timing の生成後、Remotion preview に表示するために呼び出す。 +// オプションや引数詳細: 既存 root の import 群末尾と fragment 末尾に挿入する。 +const updateRootFile = async ({paths, slug, pascalId, snakeId}) => { + const rootSource = await fs.readFile(paths.rootFile, "utf8"); + const importBlock = `import {${pascalId}} from "./${slug}"; +import { + ${snakeId}_FPS, + total${pascalId}DurationInFrames, +} from "./data/${slug}/timing";`; + const compositionBlock = ` `; + + if (!rootSource.includes("\n\nexport const Root")) { + throw new Error("Could not find import insertion point in src/root.tsx."); + } + if (!rootSource.includes(" ")) { + throw new Error("Could not find Composition insertion point in src/root.tsx."); + } + + const withImport = rootSource.replace( + "\n\nexport const Root", + `\n${importBlock}\n\nexport const Root` + ); + const withComposition = withImport.replace( + " ", + `${compositionBlock}\n ` + ); + await fs.writeFile(paths.rootFile, withComposition); +}; + +// 用途: package.json に composition:create と生成対象別 npm script を追加する。 +// 使用方法: 新規コンポジション生成時に呼び出し、ユーザー向けコマンドを登録する。 +// オプションや引数詳細: 既存 scripts を保ち、末尾に不足分だけ追加する。 +const updatePackageJson = async ({paths, slug}) => { + const packageJson = JSON.parse(await fs.readFile(paths.packageFile, "utf8")); + packageJson.scripts = { + ...packageJson.scripts, + "composition:create": + packageJson.scripts["composition:create"] ?? + "node scripts/create-composition.js", + [`voice:generate:${slug}`]: `node scripts/voicevox-generate.js --project ${slug}`, + [`lipsync:generate:${slug}`]: `node scripts/generate-lipsync.js --project ${slug}`, + }; + + await fs.writeFile( + paths.packageFile, + `${JSON.stringify(packageJson, null, 2)}\n` + ); +}; + +// 用途: scripts/voicevox-generate.js の生成対象一覧へ新規 slug を追加する。 +// 使用方法: package.json 更新後に呼び出し、--project と --all の対象に含める。 +// オプションや引数詳細: 既定 lipsyncEngine は書かず、既存処理の rhubarb 既定値を使う。 +const updateVoicevoxTargets = async ({paths, slug}) => { + const source = await fs.readFile(paths.voicevoxScript, "utf8"); + const marker = "\n];\n\nconst voiceTargetByName"; + if (!source.includes(marker)) { + throw new Error("Could not find voiceTargets insertion point."); + } + const targetBlock = ` + { + name: "${slug}", + script: "src/data/${slug}/script.ts", + output: "public/audio/${slug}/lines", + manifest: "src/data/${slug}/voicevox-manifest.json", + },`; + + await fs.writeFile( + paths.voicevoxScript, + source.replace(marker, `${targetBlock}${marker}`) + ); +}; + +// 用途: scripts/generate-lipsync.js の生成対象一覧へ新規 slug を追加する。 +// 使用方法: voicevoxTargets 更新後に呼び出し、--project と --all の対象に含める。 +// オプションや引数詳細: engine は書かず、既存処理の rhubarb 既定値を使う。 +const updateLipsyncTargets = async ({paths, slug}) => { + const source = await fs.readFile(paths.lipsyncScript, "utf8"); + const marker = "\n];\n\nconst lipsyncTargetByName"; + if (!source.includes(marker)) { + throw new Error("Could not find lipsyncTargets insertion point."); + } + const targetBlock = ` + { + name: "${slug}", + sourceManifest: "src/data/${slug}/voicevox-manifest.json", + },`; + + await fs.writeFile( + paths.lipsyncScript, + source.replace(marker, `${targetBlock}${marker}`) + ); +}; + +// 用途: README の新規コンポジション作成手順を必要に応じて追加する。 +// 使用方法: 初回導入時に呼び出し、既に同じ節があれば何もしない。 +// オプションや引数詳細: 脚本編集セクションの直後に短い手順を挿入する。 +const updateReadme = async ({paths}) => { + const source = await fs.readFile(paths.readmeFile, "utf8"); + if (source.includes("### 3.1 新しいコンポジションを作成")) { + return; + } + + const insertion = `### 3.1 新しいコンポジションを作成 +\`\`\`bash +npm run composition:create -- my-new-video +\`\`\` + +コンポジション名は kebab-case の slug で指定します。 +上記の例では Remotion ID \`MyNewVideo\` と +\`src/data/my-new-video/script.ts\` が作成されます。 + +作成後は、原則として \`src/data/my-new-video/script.ts\` の +\`compositionTitle\`、\`characters\`、\`timeline\` を編集します。 + +音声と口パクを生成する場合は、作成された専用コマンドを実行します。 + +\`\`\`bash +npm run voice:generate:my-new-video +npm run lipsync:generate:my-new-video +npm run start -- --webpack-poll=1000 +\`\`\` + +`; + const marker = "### 4. 音声を生成"; + if (!source.includes(marker)) { + throw new Error("Could not find README insertion point."); + } + + await fs.writeFile(paths.readmeFile, source.replace(marker, `${insertion}${marker}`)); +}; + +const main = async () => { + const parsed = parseArgs(); + if (parsed.mode === "help") { + printHelp(); + return; + } + + const {slug} = parsed; + const pascalId = toPascalCase(slug); + const snakeId = toScreamingSnakeCase(slug); + const replacements = { + "__SLUG__": slug, + "__PASCAL_ID__": pascalId, + "__SNAKE_ID__": snakeId, + "__TITLE__": toTitle(slug), + }; + const paths = { + dataDir: path.join(projectRoot, "src/data", slug), + componentFile: path.join(projectRoot, "src", `${slug}.tsx`), + rootFile: path.join(projectRoot, "src/root.tsx"), + packageFile: path.join(projectRoot, "package.json"), + voicevoxScript: path.join(projectRoot, "scripts/voicevox-generate.js"), + lipsyncScript: path.join(projectRoot, "scripts/generate-lipsync.js"), + readmeFile: path.join(projectRoot, "README.md"), + }; + + if (!(await pathExists(templateRoot))) { + throw new Error(`Template directory not found: ${toProjectRelative(templateRoot)}`); + } + + await assertNoConflicts({slug, pascalId, paths}); + await createDataFiles({paths, replacements}); + await createComponentFile({paths, replacements}); + await updateRootFile({paths, slug, pascalId, snakeId}); + await updatePackageJson({paths, slug}); + await updateVoicevoxTargets({paths, slug}); + await updateLipsyncTargets({paths, slug}); + await updateReadme({paths}); + + console.log(`Created composition "${pascalId}".`); + console.log(`Edit: src/data/${slug}/script.ts`); + console.log(`Generate voice: npm run voice:generate:${slug}`); + console.log(`Generate lipsync: npm run lipsync:generate:${slug}`); +}; + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/voicevox-remotion-template/scripts/templates/dialogue-standee/component.tsx.template b/voicevox-remotion-template/scripts/templates/dialogue-standee/component.tsx.template new file mode 100644 index 0000000..21fa3b9 --- /dev/null +++ b/voicevox-remotion-template/scripts/templates/dialogue-standee/component.tsx.template @@ -0,0 +1,227 @@ +import React from "react"; +import { + AbsoluteFill, + interpolate, + Sequence, + spring, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { + characters, + compositionTitle, + initialVisibleCharacters, + timeline, + type CharacterId, + type TimelineEvent, +} from "./data/__SLUG__/script"; +import { + __SNAKE_ID___GAP_FRAMES, + durationForTimelineEvent, + audioFileForSpeech, + hasAudioForSpeech, +} from "./data/__SLUG__/timing"; +import {roundedFontFamily} from "./fonts"; +import { + VQCaptionOverlay, + VQCharacterStage, + VQSpeechOverlay, + VQWarmGradientBackground, + type VQMouthResolver, +} from "./lib/VQRemotionLib"; +import {getMouthForSpeechFrame} from "./lipsync/manifest"; + +type ScheduledTimelineEvent = Readonly<{ + event: TimelineEvent; + from: number; + durationInFrames: number; + visibleCharacters: CharacterId[]; + focusedCharacter: CharacterId; +}>; + +const clampInterpolation = { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", +} as const; + +const subtitleOptions = { + fontFamily: roundedFontFamily, + fontSize: 36, + lineHeight: 1.4, + backgroundColor: "rgba(255, 255, 255, 0.88)", +} as const; + +// 用途: script.ts の timeline をフレーム単位の表示スケジュールへ変換する。 +// 使用方法: Composition コンポーネント内で fps を渡して呼び出す。 +// オプションや引数詳細: show と say は character を持つ前提で、表示済みキャラクターを維持する。 +const scheduleTimeline = (fps: number): ScheduledTimelineEvent[] => { + let cursor = 0; + const visibleCharacters = new Set(initialVisibleCharacters); + + return timeline.map((event, index) => { + visibleCharacters.add(event.character); + + const durationInFrames = durationForTimelineEvent(event, fps); + const scheduledEvent = { + event, + from: cursor, + durationInFrames, + visibleCharacters: Array.from(visibleCharacters), + focusedCharacter: event.character, + }; + + cursor += durationInFrames; + if (index < timeline.length - 1) { + cursor += __SNAKE_ID___GAP_FRAMES; + } + + return scheduledEvent; + }); +}; + +// 用途: 現在フレームに対応するタイムライン区間を取得する。 +// 使用方法: Composition の毎フレーム描画で activeSegment を求める。 +// オプションや引数詳細: 最後に開始した区間を返すため、ギャップ中も直前の表示状態を維持する。 +const activeSegmentForFrame = ( + scheduledEvents: ScheduledTimelineEvent[], + frame: number +) => { + let activeSegment = scheduledEvents[0]; + + for (const scheduledEvent of scheduledEvents) { + if (frame >= scheduledEvent.from) { + activeSegment = scheduledEvent; + } else { + break; + } + } + + return activeSegment; +}; + +const resolveMouth: VQMouthResolver = ({ + speechId, + speakingLocalFrame, + fps, +}) => getMouthForSpeechFrame(speechId, speakingLocalFrame, fps); + +const Title: React.FC> = ({progress}) => { + const opacity = interpolate(progress, [0, 1], [0, 1], clampInterpolation); + const translateY = interpolate(progress, [0, 1], [-30, 0], clampInterpolation); + + return ( +
+ {compositionTitle} +
+ ); +}; + +const TimelineOverlay: React.FC> = ({ + event, +}) => { + if (event.type === "say") { + const character = characters[event.character]; + + return ( + + ); + } + + if (event.type !== "show") { + return null; + } + + return ( + + ); +}; + +const keyForEvent = (event: TimelineEvent, index: number) => { + if ("id" in event) { + return event.id; + } + + return `${event.type}-${event.character}-${index}`; +}; + +export const __PASCAL_ID__: React.FC = () => { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const scheduledEvents = scheduleTimeline(fps); + const activeSegment = activeSegmentForFrame(scheduledEvents, frame); + const isInsideActiveSegment = + frame < activeSegment.from + activeSegment.durationInFrames; + + const titleProgress = spring({ + frame, + fps, + config: {damping: 18, mass: 0.6}, + }); + const activeSpeech = + isInsideActiveSegment && activeSegment.event.type === "say" + ? activeSegment.event + : undefined; + const speakingCharacter = activeSpeech?.character; + const speakingLocalFrame = activeSpeech ? frame - activeSegment.from : 0; + + const sequences = scheduledEvents.map((scheduledEvent, index) => ( + + + + )); + + return ( + + + + <VQCharacterStage + characters={characters} + visibleCharacters={activeSegment.visibleCharacters} + focusedCharacter={ + isInsideActiveSegment ? activeSegment.focusedCharacter : undefined + } + speakingCharacter={speakingCharacter} + speakingSpeechId={activeSpeech?.id} + speakingLocalFrame={speakingLocalFrame} + frame={frame} + fps={fps} + resolveMouth={resolveMouth} + fontFamily={roundedFontFamily} + /> + {sequences} + </AbsoluteFill> + ); +}; diff --git a/voicevox-remotion-template/scripts/templates/dialogue-standee/data/script.ts.template b/voicevox-remotion-template/scripts/templates/dialogue-standee/data/script.ts.template new file mode 100644 index 0000000..19ce32f --- /dev/null +++ b/voicevox-remotion-template/scripts/templates/dialogue-standee/data/script.ts.template @@ -0,0 +1,106 @@ +import {getStandeeSet, type AvatarDefinition} from "../../standee-sets"; +import { + defineVQTimeline, + say, + type VQCustomTimelineEvent, + type VQSpeechEvent, + type VQTimelineEvent, + type VQTimelineInputEvent, +} from "../../lib/VQRemotionLib/timeline"; + +export const compositionTitle = "__TITLE__"; + +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<string, CharacterDefinition>; + +export type CharacterId = keyof typeof characters; + +export const initialVisibleCharacters: CharacterId[] = ["zundamon"]; + +export type ShowOptions = Readonly<{ + caption?: string; + durationSeconds?: number; +}>; + +export type SpeechEvent = VQSpeechEvent<CharacterId, VoicevoxVoice>; + +export type ShowEvent = VQCustomTimelineEvent< + "show", + { + character: CharacterId; + caption?: string; + durationSeconds?: number; + } +>; + +export type TimelineEvent = VQTimelineEvent< + CharacterId, + VoicevoxVoice, + ShowEvent +>; +export type TimelineInputEvent = VQTimelineInputEvent< + CharacterId, + VoicevoxVoice, + ShowEvent +>; + +// 用途: キャラクターを画面に登場させ、任意の説明字幕を表示する。 +// 使用方法: timeline 内で show("sayo", {caption: "小夜が登場!"}) のように呼び出す。 +// オプションや引数詳細: durationSeconds を省略すると timing.ts の既定秒数で表示する。 +export const show = ( + character: CharacterId, + options: ShowOptions = {} +): ShowEvent => ({ + type: "show", + character, + ...options, +}); + +export const timeline = defineVQTimeline([ + say("__SLUG__-zunda-001", "zundamon", "みなさんこんにちは、ずんだもんなのだ!"), + say("__SLUG__-zunda-002", "zundamon", "今日は新しい動画の下書きを作っていくのだ。"), + show("sayo", { + caption: "小夜が登場!", + }), + say("__SLUG__-sayo-001", "sayo", "小夜です。ここから脚本を書き換えて、動画を育てていきましょう。"), + say("__SLUG__-zunda-003", "zundamon", "音声を作ったら、口パクも忘れずに生成するのだ。"), +] satisfies readonly TimelineInputEvent[]); diff --git a/voicevox-remotion-template/scripts/templates/dialogue-standee/data/timing.ts.template b/voicevox-remotion-template/scripts/templates/dialogue-standee/data/timing.ts.template new file mode 100644 index 0000000..6f80cd3 --- /dev/null +++ b/voicevox-remotion-template/scripts/templates/dialogue-standee/data/timing.ts.template @@ -0,0 +1,91 @@ +import { + defineVQScenarioAssetWorkflow, + type VQScenarioAssetWorkflow, +} from "../../lib/VQRemotionLib/scenario"; +import {timeline, type SpeechEvent, type TimelineEvent} from "./script"; +import voicevoxManifest from "./voicevox-manifest.json"; + +type ManifestEntry = { + id: string; + character?: string; + speakerName?: string; + styleName?: string; + speakerId?: number; + file: string; + durationSeconds: number; +}; + +const manifestEntries = voicevoxManifest as ManifestEntry[]; +const manifestById = new Map( + manifestEntries.map((entry) => [entry.id, entry]) +); + +export const __SNAKE_ID___FPS = 30; +export const __SNAKE_ID___GAP_FRAMES = 6; +export const __SNAKE_ID___DEFAULT_SHOW_SECONDS = 1.5; + +export const hasAudioForSpeech = (speech: SpeechEvent) => + manifestById.has(speech.id); + +export const audioFileForSpeech = (speech: SpeechEvent) => + manifestById.get(speech.id)?.file ?? `audio/__SLUG__/lines/${speech.id}.wav`; + +export const durationForSpeech = ( + speech: SpeechEvent, + fps = __SNAKE_ID___FPS +) => { + const entry = manifestById.get(speech.id); + if (entry && Number.isFinite(entry.durationSeconds)) { + return Math.max(1, Math.ceil(entry.durationSeconds * fps)); + } + + const textForEstimate = speech.readAs ?? speech.text; + const estimatedSeconds = Math.max(1.2, textForEstimate.length * 0.11); + return Math.ceil(estimatedSeconds * fps); +}; + +export const durationForTimelineEvent = ( + event: TimelineEvent, + fps = __SNAKE_ID___FPS +) => { + if (event.type === "say") { + return durationForSpeech(event, fps); + } + + if ( + event.type === "clearStill" || + event.type === "clearVideo" || + event.type === "standeePosition" + ) { + return 0; + } + + const durationSeconds = + "durationSeconds" in event && event.durationSeconds !== undefined + ? event.durationSeconds + : __SNAKE_ID___DEFAULT_SHOW_SECONDS; + return Math.max(1, Math.ceil(durationSeconds * fps)); +}; + +export const __PASCAL_ID__AssetWorkflow: VQScenarioAssetWorkflow = + defineVQScenarioAssetWorkflow({ + voicevox: { + scriptPath: "src/data/__SLUG__/script.ts", + outputDir: "public/audio/__SLUG__/lines", + manifestPath: "src/data/__SLUG__/voicevox-manifest.json", + }, + rhubarb: { + sourceManifestPath: "src/data/__SLUG__/voicevox-manifest.json", + manifestPath: "src/generated/lipsync/manifest.json", + outputDir: "src/generated/lipsync", + rawOutputDir: "public/lipsync/raw", + }, + }); + +export const total__PASCAL_ID__DurationInFrames = ( + fps = __SNAKE_ID___FPS +) => + timeline.reduce((sum, event, index) => { + const gap = index < timeline.length - 1 ? __SNAKE_ID___GAP_FRAMES : 0; + return sum + durationForTimelineEvent(event, fps) + gap; + }, 0); diff --git a/voicevox-remotion-template/scripts/templates/dialogue-standee/data/voicevox-manifest.json.template b/voicevox-remotion-template/scripts/templates/dialogue-standee/data/voicevox-manifest.json.template new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/voicevox-remotion-template/scripts/templates/dialogue-standee/data/voicevox-manifest.json.template @@ -0,0 +1 @@ +[]