render.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import { Command } from "commander";
  2. import { runPipeline, type PipelineConfig } from "@pipeline/core";
  3. import { PLATFORM_PRESETS, TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared";
  4. import { getCollector, listCollectorNames } from "@pipeline/collect";
  5. import { readFileSync, existsSync } from "node:fs";
  6. import { resolve, dirname } from "node:path";
  7. import { parse as parseYaml } from "yaml";
  8. // Commander --no-tts sets opts.tts = false (not opts.noTts)
  9. const skipTts = (opts: any) => opts.tts === false;
  10. export const renderCommand = new Command("render")
  11. .description("Generate video from text, markdown, or structured JSON input")
  12. .argument("[input]", "Input file path (JSON, markdown, or plain text). Required unless --source is used.")
  13. .requiredOption("-t, --template <type>", `Template: ${TEMPLATE_TYPES.join("|")}`)
  14. .option("-p, --platform <preset>", "Platform preset", "bilibili")
  15. .option("-o, --output <dir>", "Output directory", "./output")
  16. .option("--assets-dir <dir>", "Image assets directory (defaults to JSON file location)")
  17. .option("--config <path>", "Config file path")
  18. .option("-v, --verbose", "Verbose logging")
  19. // Data source
  20. .option("--source <name>", "Collect data from source before rendering")
  21. .option("--source-owner <owner>", "Repo owner (for github-repo source)")
  22. .option("--source-repo <repo>", "Repo name (for github-repo source)")
  23. // LLM
  24. .option("--skip-llm", "Skip LLM processing (require valid VideoInputSchema JSON)")
  25. .option("--llm-model <model>", "LLM model name")
  26. .option("--llm-base-url <url>", "LLM API base URL")
  27. .option("--llm-api-key <key>", "LLM API key")
  28. // TTS — general
  29. .option("--tts-provider <provider>", "TTS provider (openai-tts|fish-audio|minimax|elevenlabs)")
  30. .option("--voice <id>", "Voice ID")
  31. .option("--tts-model <model>", "TTS model name (provider-specific)")
  32. .option("--tts-speed <speed>", "TTS speech speed (0.5-2.0)", parseFloat)
  33. .option("--tts-format <format>", "TTS audio format (mp3|wav|pcm)")
  34. .option("--no-tts", "Skip TTS (generate silent video)")
  35. // Alignment
  36. .option("--alignment <mode>", "Timestamp alignment: whisper|native")
  37. .option("--whisper-model <model>", "Whisper model size (tiny/base/small/medium/large)", "base")
  38. .option("--alignment-language <lang>", "Language hint for whisper (e.g. zh, en)")
  39. .action(async (input, opts) => {
  40. const template = opts.template as string;
  41. if (!TEMPLATE_TYPES.includes(template as any)) {
  42. console.error(`Error: Invalid template "${template}". Use: ${TEMPLATE_TYPES.join(", ")}`);
  43. process.exit(1);
  44. }
  45. const platformStr = opts.platform as string;
  46. const platforms = platformStr.split(",").map((p: string) => p.trim());
  47. const invalidPlatforms = platforms.filter((p: string) => !PLATFORM_PRESET_KEYS.includes(p as any));
  48. if (invalidPlatforms.length > 0) {
  49. console.error(`Error: Invalid platform(s): ${invalidPlatforms.join(", ")}. Use: ${PLATFORM_PRESET_KEYS.join(", ")}`);
  50. process.exit(1);
  51. }
  52. const config = loadConfig(opts.config);
  53. // Collect data from source or read from file
  54. let text: string;
  55. let inputDir: string;
  56. if (opts.source) {
  57. const available = listCollectorNames();
  58. if (!available.includes(opts.source)) {
  59. console.error(`Error: Unknown source "${opts.source}". Available: ${available.join(", ")}`);
  60. process.exit(1);
  61. }
  62. const collectorConfig = config?.collect?.[opts.source];
  63. const collector = getCollector(opts.source, collectorConfig);
  64. const args: Record<string, string> = {};
  65. if (opts.sourceOwner) args.owner = opts.sourceOwner;
  66. if (opts.sourceRepo) args.repo = opts.sourceRepo;
  67. console.log(`Collecting from ${opts.source}...`);
  68. const result = await collector.collect(
  69. Object.keys(args).length > 0 ? { args } : undefined
  70. );
  71. text = result.type === "json" ? JSON.stringify(result.content) : result.content;
  72. inputDir = resolve(process.cwd());
  73. } else {
  74. if (!input) {
  75. console.error("Error: <input> file path is required when --source is not provided");
  76. process.exit(1);
  77. }
  78. const inputPath = resolve(input);
  79. if (!existsSync(inputPath)) {
  80. console.error(`Error: Input file not found: ${inputPath}`);
  81. process.exit(1);
  82. }
  83. text = readFileSync(inputPath, "utf-8");
  84. inputDir = opts.assetsDir ? resolve(opts.assetsDir) : dirname(inputPath);
  85. }
  86. const providerName = opts.ttsProvider || config?.tts?.provider || "openai-tts";
  87. const providerConfig = config?.tts?.[providerName];
  88. const cliDir = dirname(new URL(import.meta.url).pathname);
  89. const projectRoot = resolve(cliDir, "../../../../");
  90. const templatesEntry = resolve(projectRoot, "packages/templates/src/entry.ts");
  91. const assetsRoot = resolve(projectRoot, "assets");
  92. const noTts = skipTts(opts);
  93. const flags: string[] = [];
  94. if (noTts) flags.push("no-tts");
  95. const pipelineConfig: PipelineConfig = {
  96. llm: {
  97. baseURL: opts.llmBaseUrl || config?.llm?.baseURL,
  98. apiKey: opts.llmApiKey || config?.llm?.apiKey,
  99. model: opts.llmModel || config?.llm?.model || "gpt-4o",
  100. },
  101. tts: {
  102. provider: providerName,
  103. voiceId: opts.voice || providerConfig?.defaultVoice,
  104. model: opts.ttsModel || providerConfig?.model,
  105. format: opts.ttsFormat || config?.tts?.format || "mp3",
  106. speed: opts.ttsSpeed || config?.tts?.speed || 1.0,
  107. },
  108. output: {
  109. dir: resolve(opts.output || config?.output?.dir || "./output"),
  110. },
  111. assets: { root: assetsRoot, inputDir },
  112. templates: { entryPoint: templatesEntry },
  113. skipTts: noTts,
  114. skipLlm: opts.skipLlm,
  115. alignment: buildAlignmentConfig(opts, config),
  116. };
  117. const stages = ["parse", "tts", "assets", "compose", "render", "export"];
  118. console.log(`\nPipeline: generating ${template} video for ${platforms.join(", ")}`);
  119. console.log(`Input: ${text.length} chars${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
  120. const job = await runPipeline(
  121. {
  122. text,
  123. template: template as any,
  124. platforms: platforms as any,
  125. ttsProvider: providerName as any,
  126. voiceId: pipelineConfig.tts.voiceId,
  127. outputPath: resolve(opts.output || "./output"),
  128. },
  129. pipelineConfig,
  130. {
  131. onStageStart: (stage) => {
  132. const [name, plat] = stage.split(":");
  133. const platformLabel = plat ? ` [${plat}]` : "";
  134. const labels: Record<string, string> = {
  135. parse: opts.skipLlm ? "Parsing JSON input" : "Parsing input (AI-assisted if needed)",
  136. tts: noTts ? "Generating silent audio" : `Generating narration${pipelineConfig.alignment?.provider === "whisper" ? " (whisper alignment)" : ""}`,
  137. assets: "Resolving assets",
  138. compose: "Composing scenes",
  139. render: "Rendering video",
  140. export: "Exporting",
  141. };
  142. const stepNum = stages.indexOf(name as string) + 1;
  143. console.log(` [${stepNum}/6] ${labels[name] || name}${platformLabel}...`);
  144. },
  145. onStageComplete: (stage) => {
  146. const [name, plat] = stage.split(":");
  147. const platformLabel = plat ? ` [${plat}]` : "";
  148. const stepNum = stages.indexOf(name as string) + 1;
  149. console.log(` [${stepNum}/6] ${name}${platformLabel} done`);
  150. },
  151. onError: (stage, error) => {
  152. console.error(`\n Error in ${stage}: ${error.message}`);
  153. if (error.message.includes("API_KEY") || error.message.includes("apiKey")) {
  154. console.error("\n Hint: Use --no-tts to skip TTS:");
  155. console.error(" pipeline render <file> -t <template> --no-tts\n");
  156. }
  157. },
  158. }
  159. );
  160. if (job.status === "completed" && job.exported) {
  161. console.log(`\nCompleted! Job ID: ${job.id}`);
  162. for (const file of job.exported.files) {
  163. console.log(` -> ${file.filePath} (${(file.fileSizeBytes / 1024 / 1024).toFixed(1)}MB, ${file.width}x${file.height})`);
  164. }
  165. } else {
  166. console.error(`\nFailed: ${job.error}`);
  167. process.exit(1);
  168. }
  169. });
  170. function buildAlignmentConfig(
  171. opts: any,
  172. config: Record<string, any> | null
  173. ): PipelineConfig["alignment"] {
  174. const mode = opts.alignment || config?.alignment?.provider;
  175. if (mode !== "whisper" && mode !== "native") return undefined;
  176. return {
  177. provider: mode,
  178. whisperModel: opts.whisperModel || config?.alignment?.whisperModel || "base",
  179. language: opts.alignmentLanguage || config?.alignment?.language,
  180. };
  181. }
  182. function loadConfig(configPath?: string): Record<string, any> | null {
  183. const cliDir = dirname(new URL(import.meta.url).pathname);
  184. const projectRoot = resolve(cliDir, "../../../../");
  185. const paths = [
  186. configPath,
  187. "pipeline.config.yaml",
  188. "pipeline.config.yml",
  189. resolve(projectRoot, "config/default.yaml"),
  190. "config/default.yaml",
  191. ].filter(Boolean) as string[];
  192. for (const p of paths) {
  193. const resolved = resolve(p);
  194. if (existsSync(resolved)) {
  195. const content = readFileSync(resolved, "utf-8");
  196. return parseYaml(content);
  197. }
  198. }
  199. return null;
  200. }