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