import { Command } from "commander"; import { runPipeline, resolvePublishConfig, type PipelineConfig } from "@pipeline/core"; import { PLATFORM_PRESETS, TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared"; import { resolveOutputDir } from "@pipeline/shared/node"; import { getCollector, listCollectorNames } from "@pipeline/collect"; import { readFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { parse as parseYaml } from "yaml"; // Commander --no-tts sets opts.tts = false (not opts.noTts) const skipTts = (opts: any) => opts.tts === false; // Commander --no-publish sets opts.publish = false const skipPublish = (opts: any) => opts.publish === false; export const renderCommand = new Command("render") .description("Generate video from text, markdown, or structured JSON input") .argument("[input]", "Input file path (JSON, markdown, or plain text). Required unless --source is used.") .requiredOption("-t, --template ", `Template: ${TEMPLATE_TYPES.join("|")}`) .option("-p, --platform ", "Platform preset", "bilibili") .option("-o, --output ", "Output directory (default: config output.dir or ./output)") .option("--assets-dir ", "Image assets directory (defaults to JSON file location)") .option("--config ", "Config file path") .option("-v, --verbose", "Verbose logging") // Data source .option("--source ", "Collect data from source before rendering") .option("--source-owner ", "Repo owner (for github-repo source)") .option("--source-repo ", "Repo name (for github-repo source)") // LLM .option("--skip-llm", "Skip LLM processing (require valid VideoInputSchema JSON)") .option("--llm-model ", "LLM model name") .option("--llm-base-url ", "LLM API base URL") .option("--llm-api-key ", "LLM API key") // TTS — general .option("--tts-provider ", "TTS provider (openai-tts|fish-audio|minimax|elevenlabs)") .option("--voice ", "Voice ID") .option("--tts-model ", "TTS model name (provider-specific)") .option("--tts-speed ", "TTS speech speed (0.5-2.0)", parseFloat) .option("--tts-format ", "TTS audio format (mp3|wav|pcm)") .option("--no-tts", "Skip TTS (generate silent video)") .option("--channel-name ", "Channel name displayed in video watermark") .option("--no-publish", "Skip OSS upload + Feishu notification") // Alignment .option("--alignment ", "Timestamp alignment: whisper|native") .option("--whisper-model ", "Whisper model size (tiny/base/small/medium/large)", "base") .option("--alignment-language ", "Language hint for whisper (e.g. zh, en)") .action(async (input, opts) => { const template = opts.template as string; if (!TEMPLATE_TYPES.includes(template as any)) { console.error(`Error: Invalid template "${template}". Use: ${TEMPLATE_TYPES.join(", ")}`); process.exit(1); } const platformStr = opts.platform as string; const platforms = platformStr.split(",").map((p: string) => p.trim()); const invalidPlatforms = platforms.filter((p: string) => !PLATFORM_PRESET_KEYS.includes(p as any)); if (invalidPlatforms.length > 0) { console.error(`Error: Invalid platform(s): ${invalidPlatforms.join(", ")}. Use: ${PLATFORM_PRESET_KEYS.join(", ")}`); process.exit(1); } const config = loadConfig(opts.config); // Collect data from source or read from file let text: string; let inputDir: string; if (opts.source) { const available = listCollectorNames(); if (!available.includes(opts.source)) { console.error(`Error: Unknown source "${opts.source}". Available: ${available.join(", ")}`); process.exit(1); } const collectorConfig = config?.collect?.[opts.source]; const collector = getCollector(opts.source, collectorConfig); const args: Record = {}; if (opts.sourceOwner) args.owner = opts.sourceOwner; if (opts.sourceRepo) args.repo = opts.sourceRepo; console.log(`Collecting from ${opts.source}...`); const result = await collector.collect( Object.keys(args).length > 0 ? { args } : undefined ); text = result.type === "json" ? JSON.stringify(result.content) : result.content; inputDir = resolve(process.cwd()); } else { if (!input) { console.error("Error: file path is required when --source is not provided"); process.exit(1); } const inputPath = resolve(input); if (!existsSync(inputPath)) { console.error(`Error: Input file not found: ${inputPath}`); process.exit(1); } text = readFileSync(inputPath, "utf-8"); inputDir = opts.assetsDir ? resolve(opts.assetsDir) : dirname(inputPath); } const providerName = opts.ttsProvider || config?.tts?.provider || "openai-tts"; const providerConfig = config?.tts?.[providerName]; const cliDir = dirname(new URL(import.meta.url).pathname); const projectRoot = resolve(cliDir, "../../../../"); const templatesEntry = resolve(projectRoot, "packages/templates/src/entry.ts"); const assetsRoot = resolve(projectRoot, "assets"); const noTts = skipTts(opts); const noPublish = skipPublish(opts); const flags: string[] = []; if (noTts) flags.push("no-tts"); if (noPublish) flags.push("no-publish"); // Unified output directory (honors OUTPUT_DIR env > config > ./output). const outputDir = resolveOutputDir(opts.output || config?.output?.dir); const retentionDays = Number( process.env.OUTPUT_RETENTION_DAYS ?? config?.output?.retentionDays ?? 30 ); const publish = noPublish ? undefined : resolvePublishConfig(config); const pipelineConfig: PipelineConfig = { branding: { channelName: opts.channelName || config?.branding?.channelName || "Pipeline", }, llm: { baseURL: opts.llmBaseUrl || config?.llm?.baseURL, apiKey: opts.llmApiKey || config?.llm?.apiKey, model: opts.llmModel || config?.llm?.model || "gpt-4o", }, tts: { provider: providerName, voiceId: opts.voice || providerConfig?.defaultVoice, model: opts.ttsModel || providerConfig?.model, format: opts.ttsFormat || config?.tts?.format || "mp3", speed: opts.ttsSpeed || config?.tts?.speed || 1.0, }, output: { dir: outputDir, retentionDays, }, publish, skipPublish: noPublish, assets: { root: assetsRoot, inputDir }, templates: { entryPoint: templatesEntry }, skipTts: noTts, skipLlm: opts.skipLlm, alignment: buildAlignmentConfig(opts, config), }; const stages = ["parse", "tts", "assets", "compose", "render", "export", "publish"]; console.log(`\nPipeline: generating ${template} video for ${platforms.join(", ")}`); console.log(`Input: ${text.length} chars${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`); const job = await runPipeline( { text, template: template as any, platforms: platforms as any, ttsProvider: providerName as any, voiceId: pipelineConfig.tts.voiceId, outputPath: outputDir, source: opts.source, }, pipelineConfig, { onStageStart: (stage) => { const [name, plat] = stage.split(":"); const platformLabel = plat ? ` [${plat}]` : ""; const labels: Record = { parse: opts.skipLlm ? "Parsing JSON input" : "Parsing input (AI-assisted if needed)", tts: noTts ? "Generating silent audio" : `Generating narration${pipelineConfig.alignment?.provider === "whisper" ? " (whisper alignment)" : ""}`, assets: "Resolving assets", compose: "Composing scenes", render: "Rendering video", export: "Exporting", publish: "Publishing (OSS + Feishu)", }; const stepNum = stages.indexOf(name as string) + 1; console.log(` [${stepNum}/${stages.length}] ${labels[name] || name}${platformLabel}...`); }, onStageComplete: (stage) => { const [name, plat] = stage.split(":"); const platformLabel = plat ? ` [${plat}]` : ""; const stepNum = stages.indexOf(name as string) + 1; console.log(` [${stepNum}/${stages.length}] ${name}${platformLabel} done`); }, onError: (stage, error) => { console.error(`\n Error in ${stage}: ${error.message}`); if (error.message.includes("API_KEY") || error.message.includes("apiKey")) { console.error("\n Hint: Use --no-tts to skip TTS:"); console.error(" pipeline render -t