Newer
Older
remotion_docker_devcontainer / voicevox-remotion-template / scripts / generate-lipsync.js
import fs from "node:fs/promises";
import path from "node:path";
import {spawn} from "node:child_process";
import {fileURLToPath} from "node:url";
import {normalizeRhubarbJson} from "./lipsync-utils.js";

const projectRoot = path.resolve(
  fileURLToPath(new URL("..", import.meta.url))
);
const publicDir = path.join(projectRoot, "public");
const generatedDir = path.join(projectRoot, "src/generated/lipsync");
const rawDir = path.join(publicDir, "lipsync/raw");

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 から解決する。
const resolveProjectPath = (value) =>
  path.isAbsolute(value) ? value : path.resolve(projectRoot, value);

// 用途: ログやエラーメッセージに表示しやすいプロジェクト相対パスへ変換する。
// 使用方法: 内部処理で使う絶対パスを、利用者向けに表示したい場面で呼び出す。
// オプションや引数詳細: targetPath は projectRoot からの相対表記にし、区切り文字はスラッシュへ統一する。
const toProjectRelative = (targetPath) =>
  path.relative(projectRoot, targetPath).split(path.sep).join("/");

// 用途: public 配下の音声ファイルを、Remotion から参照できる public 相対パスへ変換する。
// 使用方法: lipsync manifest に sourceAudio を記録するときに呼び出す。
// オプションや引数詳細: targetPath が public 配下なら public 相対、外側ならプロジェクト相対の表示用パスを返す。
const toPublicRelative = (targetPath) => {
  const relativePath = path.relative(publicDir, targetPath);
  if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
    return toProjectRelative(targetPath);
  }

  return relativePath.split(path.sep).join("/");
};

// 用途: 指定されたファイルまたはディレクトリが存在するかを確認する。
// 使用方法: 入力音声、manifest、Rhubarb 実行ファイルの存在チェックで await して呼び出す。
// オプションや引数詳細: targetPath は確認対象の絶対または相対パスで、アクセスできれば true、できなければ false を返す。
const pathExists = async (targetPath) => {
  try {
    await fs.access(targetPath);
    return true;
  } catch {
    return false;
  }
};

// 用途: 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 <path>
  --manifest <path>
  --out <path> --raw-out <path>
  --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: [],
  },
});

// 用途: 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("--")) {
      values.audioPaths.push(arg);
      continue;
    }

    const key = arg.slice(2);
    if (!["out", "raw-out", "manifest", "source-manifest"].includes(key)) {
      throw new Error(`Unknown option "${arg}".`);
    }

    const value = getOptionValue(args, index, arg);

    if (key === "source-manifest") {
      values.options.sourceManifests.push(value);
    } else if (key === "raw-out") {
      values.options.rawOut = value;
    } else {
      values.options[key] = value;
    }
    index += 1;
  }

  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 {
    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 を候補にする。
const executableNames = () =>
  process.platform === "win32"
    ? ["rhubarb.exe", "rhubarb.cmd", "rhubarb"]
    : ["rhubarb"];

// 用途: 環境変数、ローカル配置、PATH から利用可能な Rhubarb CLI を探す。
// 使用方法: lipsync 生成処理の前に await して呼び出し、runRhubarb に渡す実行ファイルパスを取得する。
// オプションや引数詳細: RHUBARB_BIN が指定されていれば優先し、見つからない場合は node_modules/.bin、tools、vendor、PATH を順に探す。
const findRhubarbBin = async () => {
  if (process.env.RHUBARB_BIN) {
    const envPath = resolveProjectPath(process.env.RHUBARB_BIN);
    if (await pathExists(envPath)) {
      return envPath;
    }
    throw new Error(`RHUBARB_BIN was set, but not found: ${envPath}`);
  }

  const candidates = [];
  for (const name of executableNames()) {
    candidates.push(path.join(projectRoot, "node_modules/.bin", name));
    candidates.push(path.join(projectRoot, "tools/rhubarb", name));
    candidates.push(path.join(projectRoot, "vendor/rhubarb", name));
  }

  for (const candidate of candidates) {
    if (await pathExists(candidate)) {
      return candidate;
    }
  }

  for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
    for (const name of executableNames()) {
      const candidate = path.join(directory, name);
      if (await pathExists(candidate)) {
        return candidate;
      }
    }
  }

  throw new Error(
    [
      "Rhubarb Lip Sync CLI was not found.",
      "Set RHUBARB_BIN to the Rhubarb executable path to use any installed CLI.",
      "Executable names differ by OS, for example rhubarb, rhubarb.exe, or rhubarb.cmd.",
      "When using a Dev Container, install the Linux Rhubarb binary and point RHUBARB_BIN to it.",
    ].join("\n")
  );
};

// 用途: Rhubarb CLI を実行し、音声ファイルから口パク用の生 JSON を生成する。
// 使用方法: generateTask 内で await し、task.inputPath から task.rawOutputPath へ Rhubarb 出力を書き出す。
// オプションや引数詳細: rhubarbBin は実行ファイルパス、inputPath は音声ファイル、rawOutputPath は Rhubarb JSON の出力先を指定する。
const runRhubarb = (rhubarbBin, inputPath, rawOutputPath) =>
  new Promise((resolve, reject) => {
    const args = [
      "--recognizer",
      "phonetic",
      "--exportFormat",
      "json",
      "--extendedShapes",
      "X",
      "--output",
      rawOutputPath,
      inputPath,
    ];
    const child = spawn(rhubarbBin, args, {cwd: projectRoot});
    let stdout = "";
    let stderr = "";

    child.stdout.on("data", (chunk) => {
      stdout += chunk;
    });
    child.stderr.on("data", (chunk) => {
      stderr += chunk;
    });
    child.on("error", reject);
    child.on("close", (code) => {
      if (code === 0) {
        resolve({stdout, stderr});
        return;
      }

      reject(
        new Error(
          `Rhubarb exited with code ${code} for ${toProjectRelative(inputPath)}.\n${stderr || stdout}`
        )
      );
    });
  });

// 用途: JSON ファイルを読み込み、JavaScript の値として返す。
// 使用方法: manifest、Rhubarb 生 JSON、既存 lipsync manifest の読み込みで await して呼び出す。
// オプションや引数詳細: targetPath は UTF-8 の JSON ファイルパスを指定し、内容は JSON.parse で解釈する。
const loadJson = async (targetPath) =>
  JSON.parse(await fs.readFile(targetPath, "utf8"));

// 用途: 既存の lipsync manifest を読み込み、追記可能な manifest 形式に整える。
// 使用方法: 単一音声や source manifest 指定時に、既存 timelines を維持するため await して呼び出す。
// オプションや引数詳細: manifestPath が存在しない、または version 1 の timelines 形式でない場合は空の manifest を返す。
const loadExistingGeneratedManifest = async (manifestPath) => {
  if (!(await pathExists(manifestPath))) {
    return {version: 1, timelines: {}};
  }

  const manifest = await loadJson(manifestPath);
  if (manifest?.version !== 1 || typeof manifest.timelines !== "object") {
    return {version: 1, timelines: {}};
  }

  return manifest;
};

// 用途: CLI で直接指定された単一音声ファイルから、口パク生成タスクを作る。
// 使用方法: audioPath が指定されたときに await し、generateTask へ渡す task オブジェクトを得る。
// オプションや引数詳細: audioPath は入力音声、outPath は正規化後 JSON、rawOutPath は Rhubarb 生 JSON の任意出力先を指定する。
const taskForAudioPath = async ({audioPath, outPath, rawOutPath}) => {
  const inputPath = resolveProjectPath(audioPath);
  if (!(await pathExists(inputPath))) {
    throw new Error(`Input audio file was not found: ${audioPath}`);
  }

  const id = path.basename(inputPath, path.extname(inputPath));

  return {
    id,
    inputPath,
    sourceAudio: toPublicRelative(inputPath),
    rawOutputPath: rawOutPath ?? path.join(rawDir, `${id}.rhubarb.json`),
    outputPath: outPath ?? path.join(generatedDir, `${id}.mouth.json`),
  };
};

// 用途: VOICEVOX manifest の各音声エントリから、口パク生成タスク群を作る。
// 使用方法: --all、--project、または --source-manifest 指定時の処理で await して呼び出す。
// オプションや引数詳細: manifestPath は id と file を含む JSON 配列を想定し、file は public 配下から解決する。
const tasksForVoicevoxManifest = async (manifestPath) => {
  if (!(await pathExists(manifestPath))) {
    return [];
  }

  const entries = await loadJson(manifestPath);
  if (!Array.isArray(entries)) {
    throw new Error(`${toProjectRelative(manifestPath)} must be a JSON array.`);
  }

  return Promise.all(
    entries.map(async (entry) => {
      if (!entry?.id || !entry?.file) {
        throw new Error(
          `${toProjectRelative(manifestPath)} entries need id and file.`
        );
      }

      const inputPath = path.join(publicDir, entry.file);
      if (!(await pathExists(inputPath))) {
        throw new Error(`Input audio file was not found: ${entry.file}`);
      }

      return {
        id: entry.id,
        inputPath,
        sourceAudio: entry.file,
        rawOutputPath: path.join(rawDir, `${entry.id}.rhubarb.json`),
        outputPath: path.join(generatedDir, `${entry.id}.mouth.json`),
      };
    })
  );
};

// 用途: 指定された VOICEVOX manifest から、まとめて生成する口パクタスクを集める。
// 使用方法: --all、--project、または --source-manifest 指定時に await して呼び出す。
// オプションや引数詳細: sourceManifestPaths は1件以上を想定し、読み取れる音声エントリが1件もなければエラーにする。
const tasksForVoicevoxManifests = async (sourceManifestPaths) => {
  const taskGroups = await Promise.all(
    sourceManifestPaths.map((manifest) => tasksForVoicevoxManifest(manifest))
  );
  const tasks = taskGroups.flat();
  if (tasks.length === 0) {
    throw new Error("No VOICEVOX manifest entries were found.");
  }

  return tasks;
};

// 用途: JSON を整形してファイルへ書き出す。
// 使用方法: 正規化済み口パク JSON や lipsync manifest を保存するときに await して呼び出す。
// オプションや引数詳細: targetPath は出力先、value は JSON.stringify(value, null, 2) で書き出す値を指定する。
const writeJson = async (targetPath, value) => {
  await fs.mkdir(path.dirname(targetPath), {recursive: true});
  await fs.writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`);
};

// 用途: 1件の音声タスクについて Rhubarb 実行、正規化、JSON 保存までを行う。
// 使用方法: tasks の各要素に対して await し、manifest に格納する timeline を受け取る。
// オプションや引数詳細: rhubarbBin は Rhubarb CLI、task は id・inputPath・sourceAudio・rawOutputPath・outputPath を含む。
const generateTask = async (rhubarbBin, task) => {
  await fs.mkdir(path.dirname(task.rawOutputPath), {recursive: true});
  await runRhubarb(rhubarbBin, task.inputPath, task.rawOutputPath);

  const rawJson = await loadJson(task.rawOutputPath);
  const {timeline, warnings} = normalizeRhubarbJson(rawJson, {
    audio: task.sourceAudio,
  });

  warnings.forEach((warning) => {
    console.warn(`${task.id}: ${warning}`);
  });
  await writeJson(task.outputPath, timeline);

  console.log(
    `Wrote ${toProjectRelative(task.outputPath)} from ${toProjectRelative(
      task.inputPath
    )}`
  );

  return timeline;
};

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
    );
  }

  await writeJson(manifestPath, generatedManifest);
  console.log(`Updated ${toProjectRelative(manifestPath)}`);
}