render.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { Command } from "commander";
  2. import { runDocument, resolvePublishConfig, type DocumentRunConfig } from "@pipeline/core";
  3. import { PLATFORM_PRESETS, TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared";
  4. import { resolveOutputDir } from "@pipeline/shared/node";
  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. // Commander --no-publish sets opts.publish = false
  11. const skipPublish = (opts: any) => opts.publish === false;
  12. export const renderCommand = new Command("render")
  13. .description("Generate video from text, markdown, or structured JSON input")
  14. .argument("[input]", "Input file path (JSON, markdown, or plain text). Required unless --source is used.")
  15. .requiredOption("-t, --template <type>", `Template: ${TEMPLATE_TYPES.join("|")}`)
  16. .option("-p, --platform <preset>", "Platform preset", "bilibili")
  17. .option("-o, --output <dir>", "Output directory (default: config output.dir or ./output)")
  18. .option("--assets-dir <dir>", "Image assets directory (defaults to JSON file location)")
  19. .option("--config <path>", "Config file path")
  20. .option("-v, --verbose", "Verbose logging")
  21. // Data source
  22. .option("--source <name>", "Collect data from source before rendering")
  23. .option("--source-owner <owner>", "Repo owner (for github-repo source)")
  24. .option("--source-repo <repo>", "Repo name (for github-repo source)")
  25. // LLM
  26. .option("--skip-llm", "Skip LLM processing (require valid VideoInputSchema JSON)")
  27. .option("--llm-model <model>", "LLM model name")
  28. .option("--llm-base-url <url>", "LLM API base URL")
  29. .option("--llm-api-key <key>", "LLM API key")
  30. // TTS — general
  31. .option("--tts-provider <provider>", "TTS provider (openai-tts|fish-audio|minimax|elevenlabs)")
  32. .option("--voice <id>", "Voice ID")
  33. .option("--tts-model <model>", "TTS model name (provider-specific)")
  34. .option("--tts-speed <speed>", "TTS speech speed (0.5-2.0)", parseFloat)
  35. .option("--tts-format <format>", "TTS audio format (mp3|wav|pcm)")
  36. .option("--no-tts", "Skip TTS (generate silent video)")
  37. .option("--channel-name <name>", "Channel name displayed in video watermark")
  38. .option("--no-publish", "Skip OSS upload + Feishu notification")
  39. // Alignment
  40. .option("--alignment <mode>", "Timestamp alignment: whisper|native")
  41. .option("--whisper-model <model>", "Whisper model size (tiny/base/small/medium/large)", "base")
  42. .option("--alignment-language <lang>", "Language hint for whisper (e.g. zh, en)")
  43. .action(async (input, opts) => {
  44. try {
  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 | undefined;
  60. let sourceArgs: Record<string, string> | undefined;
  61. if (opts.source) {
  62. // Data-source collection now happens inside the text module (generateDocument),
  63. // which reads config.collect[source]. Just pass the source name + args.
  64. sourceArgs = {};
  65. if (opts.sourceOwner) sourceArgs.owner = opts.sourceOwner;
  66. if (opts.sourceRepo) sourceArgs.repo = opts.sourceRepo;
  67. if (Object.keys(sourceArgs).length === 0) sourceArgs = undefined;
  68. console.log(`Collecting from ${opts.source} (inside text module)...`);
  69. } else {
  70. if (!input) {
  71. console.error("Error: <input> file path is required when --source is not provided");
  72. process.exit(1);
  73. }
  74. const inputPath = resolve(input);
  75. if (!existsSync(inputPath)) {
  76. console.error(`Error: Input file not found: ${inputPath}`);
  77. process.exit(1);
  78. }
  79. text = readFileSync(inputPath, "utf-8");
  80. }
  81. const providerName = opts.ttsProvider || config?.tts?.provider || "openai-tts";
  82. const providerConfig = config?.tts?.[providerName];
  83. const cliDir = dirname(new URL(import.meta.url).pathname);
  84. const projectRoot = resolve(cliDir, "../../../../");
  85. const templatesEntry = resolve(projectRoot, "packages/templates/src/entry.ts");
  86. const assetsRoot = resolve(projectRoot, "assets");
  87. const inputDir = opts.assetsDir ? resolve(opts.assetsDir) : resolve(process.cwd());
  88. const noTts = skipTts(opts);
  89. const noPublish = skipPublish(opts);
  90. const flags: string[] = [];
  91. if (noTts) flags.push("no-tts");
  92. if (noPublish) flags.push("no-publish");
  93. // Unified output directory (honors OUTPUT_DIR env > config > ./output).
  94. const outputDir = resolveOutputDir(opts.output || config?.output?.dir);
  95. const retentionDays = Number(
  96. process.env.OUTPUT_RETENTION_DAYS ?? config?.output?.retentionDays ?? 30
  97. );
  98. const publish = noPublish ? undefined : resolvePublishConfig(config);
  99. const runConfig: DocumentRunConfig = {
  100. branding: {
  101. channelName: opts.channelName || config?.branding?.channelName || "Pipeline",
  102. },
  103. llm: {
  104. baseURL: opts.llmBaseUrl || config?.llm?.baseURL,
  105. apiKey: opts.llmApiKey || config?.llm?.apiKey,
  106. // Model resolution (flag > LLM_MODEL env > config) lives in core's
  107. // runDocument — clients only forward the pieces.
  108. model: config?.llm?.model,
  109. },
  110. tts: {
  111. provider: providerName,
  112. voiceId: opts.voice || providerConfig?.defaultVoice,
  113. model: opts.ttsModel || providerConfig?.model,
  114. format: opts.ttsFormat || config?.tts?.format || "mp3",
  115. speed: opts.ttsSpeed || config?.tts?.speed || 1.0,
  116. alignment: buildAlignmentConfig(opts, config),
  117. },
  118. output: { dir: outputDir, retentionDays },
  119. publish,
  120. publishMeta: config?.publishMeta,
  121. collect: config?.collect,
  122. state: { coveredWindowDays: config?.state?.coveredWindowDays },
  123. assets: { root: assetsRoot, inputDir },
  124. templates: { entryPoint: templatesEntry },
  125. };
  126. const stages = ["text", "audio", "render"];
  127. console.log(`\nPipeline: generating ${template} video for ${platforms.join(", ")}`);
  128. // When --source is used, text is undefined here — the collected content
  129. // (markdown fed to the LLM) only materializes inside the text module, so
  130. // there is no char count to show yet. Log the source name instead of the
  131. // misleading length of a "(source)" placeholder.
  132. const inputLabel =
  133. text !== undefined
  134. ? `${text.length} chars`
  135. : `source "${opts.source}" (content collected inside text module)`;
  136. console.log(`Input: ${inputLabel}${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
  137. // Surface publish resolution so a misconfigured OSS/Feishu is obvious.
  138. if (noPublish) {
  139. console.log("Publish: skipped (--no-publish)");
  140. } else if (publish) {
  141. console.log(`Publish: oss=${publish.oss ? "on" : "off"} feishu=${publish.feishu ? "on" : "off"}`);
  142. } else {
  143. console.log("Publish: skipped (no OSS/Feishu configured)");
  144. }
  145. const result = await runDocument(
  146. {
  147. text,
  148. source: opts.source,
  149. sourceArgs,
  150. template: template as any,
  151. platforms: platforms as any,
  152. ttsProvider: providerName as any,
  153. voiceId: runConfig.tts.voiceId,
  154. skipTts: noTts,
  155. skipLlm: opts.skipLlm,
  156. skipPublish: noPublish,
  157. llmModel: opts.llmModel,
  158. },
  159. runConfig,
  160. {
  161. onStage: (stage) => {
  162. const stepNum = stages.indexOf(stage) + 1;
  163. const labels: Record<string, string> = {
  164. text: opts.skipLlm ? "Generating document (JSON input)" : "Generating document (AI text)",
  165. audio: noTts ? "Generating silent audio" : `Generating narration${runConfig.tts.alignment?.provider === "whisper" ? " (whisper alignment)" : ""}`,
  166. render: "Rendering video (Remotion)",
  167. };
  168. console.log(` [${stepNum}/${stages.length}] ${labels[stage] || stage}...`);
  169. },
  170. }
  171. );
  172. if (result.status === "completed" && result.files.length > 0) {
  173. console.log(`\nCompleted! Job ID: ${result.jobId}`);
  174. for (const file of result.files) {
  175. console.log(` -> ${file.filePath} (${(file.fileSizeBytes / 1024 / 1024).toFixed(1)}MB, ${file.width}x${file.height})`);
  176. if (file.ossUrl) console.log(` oss: ${file.ossUrl}`);
  177. }
  178. if (result.stateError) {
  179. console.error(`\nState warning (dedup degraded): ${result.stateError}`);
  180. }
  181. if (result.publishError) {
  182. console.error(`\nPublish warning: ${result.publishError}`);
  183. }
  184. } else {
  185. console.error(`\nFailed: ${result.error}`);
  186. process.exit(1);
  187. }
  188. } catch (err) {
  189. // Expected pipeline errors (source/template mismatch, missing config,
  190. // collector failures...) — print the message cleanly, no stack trace.
  191. console.error(`\nFailed: ${err instanceof Error ? err.message : String(err)}`);
  192. process.exit(1);
  193. }
  194. });
  195. function buildAlignmentConfig(
  196. opts: any,
  197. config: Record<string, any> | null
  198. ): DocumentRunConfig["tts"]["alignment"] {
  199. const mode = opts.alignment || config?.tts?.alignment?.provider || config?.alignment?.provider;
  200. if (mode !== "whisper" && mode !== "native") return undefined;
  201. return {
  202. provider: mode,
  203. whisperModel: opts.whisperModel || config?.tts?.alignment?.whisperModel || config?.alignment?.whisperModel || "base",
  204. language: opts.alignmentLanguage || config?.tts?.alignment?.language || 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. }