render.ts 10 KB

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