diff --git a/voicevox-remotion-template/AGENTS.MD b/voicevox-remotion-template/AGENTS.MD index 664a25b..e314dd2 100644 --- a/voicevox-remotion-template/AGENTS.MD +++ b/voicevox-remotion-template/AGENTS.MD @@ -11,6 +11,7 @@ ## 実用上の制約 - `src/data/{コンポジション名}/script.ts` が時系列脚本の唯一の編集元。`src/data/script.json` は互換用であり、現在は参照しない。 - `npm run voice:generate` はヘルプ表示のみで音声生成しない。音声生成は `npm run voice:generate:{コンポジション名}` または `npm run voice:generate:all` を使い、VOICEVOX エンジンが `VOICEVOX_URL` で起動していることを前提にする。 +- `npm run lipsync:generate` はヘルプ表示のみで口パクデータを生成しない。口パク生成は `npm run lipsync:generate:{コンポジション名}` または `npm run lipsync:generate:all` を使い、Rhubarb CLI が利用できることを前提にする。 - VOICEVOX の話者名・スタイル名は利用環境の `/speakers` に依存する。合わない場合は `characters.*.voicevox` を調整する。 - `say(...)` を追加・変更した場合は、音声と `src/data/{コンポジション名}/voicevox-manifest.json` を再生成する。 - `public/audio/{コンポジション名}/lines/*.wav` は動画再現に必要な音声素材として扱う。`out/` などレンダリング済み動画はコミットしない。 diff --git a/voicevox-remotion-template/README.md b/voicevox-remotion-template/README.md index 7496e41..a2fc2f2 100644 --- a/voicevox-remotion-template/README.md +++ b/voicevox-remotion-template/README.md @@ -74,10 +74,18 @@ npm run lipsync:generate ``` +対象一覧と使い方を表示します。このコマンドだけでは口パクデータを生成しません。 + 生成物は、Rhubarb の生 JSON が `public/lipsync/raw/*.rhubarb.json`、 Remotion 用に正規化した JSON が `src/generated/lipsync/*.mouth.json`、 プレビュー時に同期 import する集約 manifest が `src/generated/lipsync/manifest.json` です。 +全コンポジションの口パクデータをまとめて生成する場合は、次を実行します。 + +```bash +npm run lipsync:generate:all +``` + 単体音声だけ再生成する場合は、次のように音声ファイルを指定できます。 ```bash @@ -90,15 +98,16 @@ npm run lipsync:generate -- --source-manifest src/data/yukkuri-composition/voicevox-manifest.json ``` -YukkuriComposition と PizzaOvenProject01 には専用コマンドもあります。 +コンポジション別の専用コマンドもあります。 ```bash npm run lipsync:generate:yukkuri-composition +npm run lipsync:generate:pizza-kiln npm run lipsync:generate:pizza-oven-project-01 ``` 処理順は `1. npm run voice:generate:all` または対象別の `voice:generate:*`、 -`2. npm run lipsync:generate`、 +`2. npm run lipsync:generate:all` または対象別の `lipsync:generate:*`、 `3. npm run start` です。音声を作り直したら、口パク指示データも再生成してください。 Rhubarb CLI は次の順で検出します。 @@ -249,8 +258,8 @@ ./node_modules/.bin/remotion still src/index.ts PizzaKilnSayo /tmp/pizza-kiln.png --frame=30 ``` -音声や `say(...)` を変更した場合は、対象別の `voice:generate:*` または `npm run voice:generate:all` と -`npm run lipsync:generate` も実行してください。立ち絵画像だけを差し替える場合は、 -口パクタイミングの再生成は不要です。 +音声や `say(...)` を変更した場合は、対象別の `voice:generate:*` または `npm run voice:generate:all` と、 +対象別の `lipsync:generate:*` または `npm run lipsync:generate:all` も実行してください。 +立ち絵画像だけを差し替える場合は、口パクタイミングの再生成は不要です。 以前の `public/audio/zundamon.txt` と `src/data/script.json` は互換用に残していますが、現在は参照しません。 diff --git a/voicevox-remotion-template/package.json b/voicevox-remotion-template/package.json index dfa2be7..9dc3a48 100644 --- a/voicevox-remotion-template/package.json +++ b/voicevox-remotion-template/package.json @@ -8,8 +8,10 @@ "render": "remotion render", "lint": "eslint .", "lipsync:generate": "node scripts/generate-lipsync.js", - "lipsync:generate:yukkuri-composition": "node scripts/generate-lipsync.js --source-manifest src/data/yukkuri-composition/voicevox-manifest.json", - "lipsync:generate:pizza-oven-project-01": "node scripts/generate-lipsync.js --source-manifest src/data/pizza-oven-project-01/voicevox-manifest.json", + "lipsync:generate:all": "node scripts/generate-lipsync.js --all", + "lipsync:generate:yukkuri-composition": "node scripts/generate-lipsync.js --project yukkuri-composition", + "lipsync:generate:pizza-kiln": "node scripts/generate-lipsync.js --project pizza-kiln", + "lipsync:generate:pizza-oven-project-01": "node scripts/generate-lipsync.js --project pizza-oven-project-01", "test:lipsync": "node --test scripts/lipsync-utils.test.js", "voice:generate": "node scripts/voicevox-generate.js", "voice:generate:all": "node scripts/voicevox-generate.js --all", diff --git a/voicevox-remotion-template/scripts/generate-lipsync.js b/voicevox-remotion-template/scripts/generate-lipsync.js index 109e6a9..760c46b 100644 --- a/voicevox-remotion-template/scripts/generate-lipsync.js +++ b/voicevox-remotion-template/scripts/generate-lipsync.js @@ -11,12 +11,25 @@ const generatedDir = path.join(projectRoot, "src/generated/lipsync"); const rawDir = path.join(publicDir, "lipsync/raw"); -const DEFAULT_SOURCE_MANIFESTS = [ - "src/data/yukkuri-composition/voicevox-manifest.json", - "src/data/pizza-kiln/voicevox-manifest.json", - "src/data/pizza-oven-project-01/voicevox-manifest.json", +const lipsyncTargets = [ + { + name: "yukkuri-composition", + sourceManifest: "src/data/yukkuri-composition/voicevox-manifest.json", + }, + { + name: "pizza-kiln", + sourceManifest: "src/data/pizza-kiln/voicevox-manifest.json", + }, + { + name: "pizza-oven-project-01", + sourceManifest: "src/data/pizza-oven-project-01/voicevox-manifest.json", + }, ]; +const lipsyncTargetByName = new Map( + lipsyncTargets.map((target) => [target.name, target]) +); + // 用途: プロジェクト基準の相対パスを、ファイル操作で使う絶対パスへ変換する。 // 使用方法: CLI引数や環境変数から受け取ったパスを、読み書き前にこの関数へ渡す。 // オプションや引数詳細: value は絶対パスならそのまま、相対パスなら projectRoot から解決する。 @@ -53,23 +66,89 @@ } }; -// 用途: npm run lip:generate に渡された CLI オプションを読み取り、生成対象と出力先を決める。 -// 使用方法: 起動直後に呼び出し、audioPath・outPath・rawOutPath・manifestPath・sourceManifestPaths を取得する。 -// オプションや引数詳細: 音声パスを1つ指定でき、--out、--raw-out、--manifest、複数の --source-manifest に対応する。 -const parseArgs = () => { - const values = { +// 用途: npm run lipsync:generate のヘルプを表示し、明示的な生成コマンドを案内する。 +// 使用方法: 引数なし、または --help / -h が指定されたときに呼び出す。 +// オプションや引数詳細: 生成対象一覧は lipsyncTargets から作り、対象追加時の表示漏れを防ぐ。 +const printHelp = () => { + const projectCommands = lipsyncTargets + .map(({name}) => ` npm run lipsync:generate:${name}`) + .join("\n"); + const projectOptions = lipsyncTargets + .map(({name}) => ` --project ${name}`) + .join("\n"); + + console.log(`Rhubarb 口パクデータ生成 + +Usage: + npm run lipsync:generate + npm run lipsync:generate:all +${projectCommands} + npm run lipsync:generate -- public/audio/yukkuri-composition/lines/zunda-001.wav + +Options: + --all +${projectOptions} + --source-manifest + --manifest + --out --raw-out + --help, -h + +引数なしの npm run lipsync:generate は、このヘルプだけを表示して口パクデータを生成しません。`); +}; + +// 用途: 値を取る CLI オプションについて、次の引数を検証して取得する。 +// 使用方法: --project や --source-manifest などを読み取る直前に呼び出す。 +// オプションや引数詳細: value が未指定、または別オプションに見える場合は利用者向けエラーを投げる。 +const getOptionValue = (args, index, arg) => { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`Option "${arg}" needs a value.`); + } + + return value; +}; + +// 用途: CLI パース中に集める一時的な値入れ物を作る。 +// 使用方法: collectCliValues の先頭で呼び出し、各オプションの読み取り結果を詰める。 +// オプションや引数詳細: manifest は lipsync manifest の既定出力先で初期化する。 +const createCliValues = () => ({ + help: false, + generateAll: false, + projectName: null, + audioPaths: [], + options: { out: undefined, rawOut: undefined, manifest: "src/generated/lipsync/manifest.json", sourceManifests: [], - }; - const audioPaths = []; - const args = process.argv.slice(2); + }, +}); +// 用途: npm run lipsync:generate に渡された CLI 引数を一時値へ読み取る。 +// 使用方法: parseArgs から呼び出し、後続の検証と実行モード決定に使う。 +// オプションや引数詳細: --help、--all、--project、音声パス、--out、--raw-out、--manifest、--source-manifest に対応する。 +const collectCliValues = (args) => { + const values = createCliValues(); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; + if (arg === "--help" || arg === "-h") { + values.help = true; + continue; + } + + if (arg === "--all") { + values.generateAll = true; + continue; + } + + if (arg === "--project") { + values.projectName = getOptionValue(args, index, arg); + index += 1; + continue; + } + if (!arg.startsWith("--")) { - audioPaths.push(arg); + values.audioPaths.push(arg); continue; } @@ -78,40 +157,138 @@ throw new Error(`Unknown option "${arg}".`); } - const value = args[index + 1]; - if (!value || value.startsWith("--")) { - throw new Error(`Option "${arg}" needs a value.`); - } + const value = getOptionValue(args, index, arg); if (key === "source-manifest") { - values.sourceManifests.push(value); + values.options.sourceManifests.push(value); } else if (key === "raw-out") { - values.rawOut = value; + values.options.rawOut = value; } else { - values[key] = value; + values.options[key] = value; } index += 1; } - if (audioPaths.length > 1) { - throw new Error("Only one audio path can be specified."); - } - if (audioPaths[0] && values.sourceManifests.length > 0) { - throw new Error("Audio path and --source-manifest cannot be used together."); - } - if (!audioPaths[0] && (values.out || values.rawOut)) { - throw new Error("--out and --raw-out can only be used with one audio path."); + return values; +}; + +// 用途: --all 指定時に併用できない入力がないかを検証し、全対象の実行設定を返す。 +// 使用方法: parseArgs 内で generateAll が true のときに呼び出す。 +// オプションや引数詳細: --manifest は併用可能で、生成済み lipsync manifest を全置換する。 +const parseAllArgs = ({audioPaths, projectName, options}) => { + if ( + audioPaths[0] || + projectName || + options.sourceManifests.length > 0 || + options.out || + options.rawOut + ) { + throw new Error( + "--all cannot be combined with audio path, --project, --source-manifest, --out, or --raw-out." + ); } return { - audioPath: audioPaths[0], - outPath: values.out ? resolveProjectPath(values.out) : undefined, - rawOutPath: values.rawOut ? resolveProjectPath(values.rawOut) : undefined, - manifestPath: resolveProjectPath(values.manifest), - sourceManifestPaths: values.sourceManifests.map(resolveProjectPath), + mode: "all", + audioPath: undefined, + outPath: undefined, + rawOutPath: undefined, + manifestPath: resolveProjectPath(options.manifest), + sourceManifestPaths: lipsyncTargets.map(({sourceManifest}) => + resolveProjectPath(sourceManifest) + ), + shouldMergeExistingManifest: false, }; }; +// 用途: --project 指定時に対象 manifest を解決し、単一コンポジションの実行設定を返す。 +// 使用方法: parseArgs 内で projectName があるときに呼び出す。 +// オプションや引数詳細: --manifest は併用可能で、既存 lipsync manifest へマージする。 +const parseProjectArgs = ({audioPaths, projectName, options}) => { + if ( + audioPaths[0] || + options.sourceManifests.length > 0 || + options.out || + options.rawOut + ) { + throw new Error( + "--project cannot be combined with audio path, --source-manifest, --out, or --raw-out." + ); + } + + const target = lipsyncTargetByName.get(projectName); + if (!target) { + const availableTargets = lipsyncTargets.map(({name}) => name).join(", "); + throw new Error( + `Unknown project "${projectName}". Available projects: ${availableTargets}` + ); + } + + return { + mode: "single", + audioPath: undefined, + outPath: undefined, + rawOutPath: undefined, + manifestPath: resolveProjectPath(options.manifest), + sourceManifestPaths: [resolveProjectPath(target.sourceManifest)], + shouldMergeExistingManifest: true, + }; +}; + +// 用途: 音声パス指定または --source-manifest 指定の実行設定を返す。 +// 使用方法: parseArgs 内で --all / --project 以外の生成指定を処理するときに呼び出す。 +// オプションや引数詳細: 単一音声では --out と --raw-out を使え、--source-manifest では既存 manifest へマージする。 +const parseSingleArgs = ({audioPaths, options}) => { + if (audioPaths[0] && options.sourceManifests.length > 0) { + throw new Error("Audio path and --source-manifest cannot be used together."); + } + if (!audioPaths[0] && (options.out || options.rawOut)) { + throw new Error("--out and --raw-out can only be used with one audio path."); + } + + if (!audioPaths[0] && options.sourceManifests.length === 0) { + return {mode: "help"}; + } + + return { + mode: "single", + audioPath: audioPaths[0], + outPath: options.out ? resolveProjectPath(options.out) : undefined, + rawOutPath: options.rawOut + ? resolveProjectPath(options.rawOut) + : undefined, + manifestPath: resolveProjectPath(options.manifest), + sourceManifestPaths: options.sourceManifests.map(resolveProjectPath), + shouldMergeExistingManifest: true, + }; +}; + +// 用途: npm run lipsync:generate に渡された CLI オプションを読み取り、実行モードと生成対象を決める。 +// 使用方法: 起動直後に呼び出し、help・single・all のどれとして動くかを取得する。 +// オプションや引数詳細: 引数なしは help、--all は全対象、--project は登録済み対象、音声パスや --source-manifest は単独生成として扱う。 +const parseArgs = () => { + const args = process.argv.slice(2); + if (args.length === 0) { + return {mode: "help"}; + } + + const values = collectCliValues(args); + if (values.help) { + return {mode: "help"}; + } + if (values.audioPaths.length > 1) { + throw new Error("Only one audio path can be specified."); + } + if (values.generateAll) { + return parseAllArgs(values); + } + if (values.projectName) { + return parseProjectArgs(values); + } + + return parseSingleArgs(values); +}; + // 用途: 実行環境に応じた Rhubarb CLI の実行ファイル名候補を返す。 // 使用方法: findRhubarbBin の候補パス作成時に呼び出す。 // オプションや引数詳細: Windows では rhubarb.exe・rhubarb.cmd・rhubarb、それ以外では rhubarb を候補にする。 @@ -248,7 +425,7 @@ }; // 用途: VOICEVOX manifest の各音声エントリから、口パク生成タスク群を作る。 -// 使用方法: defaultTasks または --source-manifest 指定時の処理で await して呼び出す。 +// 使用方法: --all、--project、または --source-manifest 指定時の処理で await して呼び出す。 // オプションや引数詳細: manifestPath は id と file を含む JSON 配列を想定し、file は public 配下から解決する。 const tasksForVoicevoxManifest = async (manifestPath) => { if (!(await pathExists(manifestPath))) { @@ -284,16 +461,12 @@ ); }; -// 用途: 既定または指定された VOICEVOX manifest から、まとめて生成する口パクタスクを集める。 -// 使用方法: 単一音声パスが指定されていない場合に await して呼び出す。 -// オプションや引数詳細: sourceManifestPaths が空なら DEFAULT_SOURCE_MANIFESTS を使い、1件も見つからなければエラーにする。 -const defaultTasks = async (sourceManifestPaths) => { - const manifests = - sourceManifestPaths.length > 0 - ? sourceManifestPaths - : DEFAULT_SOURCE_MANIFESTS.map(resolveProjectPath); +// 用途: 指定された VOICEVOX manifest から、まとめて生成する口パクタスクを集める。 +// 使用方法: --all、--project、または --source-manifest 指定時に await して呼び出す。 +// オプションや引数詳細: sourceManifestPaths は1件以上を想定し、読み取れる音声エントリが1件もなければエラーにする。 +const tasksForVoicevoxManifests = async (sourceManifestPaths) => { const taskGroups = await Promise.all( - manifests.map((manifest) => tasksForVoicevoxManifest(manifest)) + sourceManifestPaths.map((manifest) => tasksForVoicevoxManifest(manifest)) ); const tasks = taskGroups.flat(); if (tasks.length === 0) { @@ -337,21 +510,33 @@ return timeline; }; -const {audioPath, outPath, rawOutPath, manifestPath, sourceManifestPaths} = - parseArgs(); -const rhubarbBin = await findRhubarbBin(); -const tasks = audioPath - ? [await taskForAudioPath({audioPath, outPath, rawOutPath})] - : await defaultTasks(sourceManifestPaths); -const shouldMergeExistingManifest = - Boolean(audioPath) || sourceManifestPaths.length > 0; -const generatedManifest = shouldMergeExistingManifest - ? await loadExistingGeneratedManifest(manifestPath) - : {version: 1, timelines: {}}; +const parsedArgs = parseArgs(); +if (parsedArgs.mode === "help") { + printHelp(); +} else { + const { + audioPath, + outPath, + rawOutPath, + manifestPath, + sourceManifestPaths, + shouldMergeExistingManifest, + } = parsedArgs; + const rhubarbBin = await findRhubarbBin(); + const tasks = audioPath + ? [await taskForAudioPath({audioPath, outPath, rawOutPath})] + : await tasksForVoicevoxManifests(sourceManifestPaths); + const generatedManifest = shouldMergeExistingManifest + ? await loadExistingGeneratedManifest(manifestPath) + : {version: 1, timelines: {}}; -for (const task of tasks) { - generatedManifest.timelines[task.id] = await generateTask(rhubarbBin, task); + for (const task of tasks) { + generatedManifest.timelines[task.id] = await generateTask( + rhubarbBin, + task + ); + } + + await writeJson(manifestPath, generatedManifest); + console.log(`Updated ${toProjectRelative(manifestPath)}`); } - -await writeJson(manifestPath, generatedManifest); -console.log(`Updated ${toProjectRelative(manifestPath)}`);