Newer
Older
remotion_docker_devcontainer / voicevox-remotion-template / scripts / create-composition.js
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 -- <slug>

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、characters.ts、timing.ts、voicevox-manifest.json を data ディレクトリへ作成する。
// 使用方法: 衝突チェック後に呼び出し、テンプレートから data ファイル一式を生成する。
// オプションや引数詳細: script.ts、characters.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/characters.ts.template"),
    to: path.join(paths.dataDir, "characters.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 = `      <Composition
        id="${pascalId}"
        component={${pascalId}}
        durationInFrames={total${pascalId}DurationInFrames(
          ${snakeId}_FPS
        )}
        fps={${snakeId}_FPS}
        width={1280}
        height={720}
      />`;

  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\` と \`timeline\`、必要に応じて
\`src/data/my-new-video/characters.ts\` のキャラクター定義を編集します。

音声と口パクを生成する場合は、作成された専用コマンドを実行します。

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