import { Command } from "commander"; import { runDocument, resolvePublishConfig, type DocumentRunConfig } from "@pipeline/core"; import { PLATFORM_PRESETS, TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared"; import { resolveOutputDir } from "@pipeline/shared/node"; 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) => { try { 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 | undefined; let sourceArgs: Record | undefined; if (opts.source) { // Data-source collection now happens inside the text module (generateDocument), // which reads config.collect[source]. Just pass the source name + args. sourceArgs = {}; if (opts.sourceOwner) sourceArgs.owner = opts.sourceOwner; if (opts.sourceRepo) sourceArgs.repo = opts.sourceRepo; if (Object.keys(sourceArgs).length === 0) sourceArgs = undefined; console.log(`Collecting from ${opts.source} (inside text module)...`); } 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"); } 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 inputDir = opts.assetsDir ? resolve(opts.assetsDir) : resolve(process.cwd()); 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 runConfig: DocumentRunConfig = { branding: { channelName: opts.channelName || config?.branding?.channelName || "Pipeline", }, llm: { baseURL: opts.llmBaseUrl || config?.llm?.baseURL, apiKey: opts.llmApiKey || config?.llm?.apiKey, // Model resolution (flag > LLM_MODEL env > config) lives in core's // runDocument — clients only forward the pieces. model: config?.llm?.model, }, 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, alignment: buildAlignmentConfig(opts, config), }, output: { dir: outputDir, retentionDays }, publish, publishMeta: config?.publishMeta, collect: config?.collect, state: { coveredWindowDays: config?.state?.coveredWindowDays }, assets: { root: assetsRoot, inputDir }, templates: { entryPoint: templatesEntry }, }; const stages = ["text", "audio", "render"]; console.log(`\nPipeline: generating ${template} video for ${platforms.join(", ")}`); // When --source is used, text is undefined here — the collected content // (markdown fed to the LLM) only materializes inside the text module, so // there is no char count to show yet. Log the source name instead of the // misleading length of a "(source)" placeholder. const inputLabel = text !== undefined ? `${text.length} chars` : `source "${opts.source}" (content collected inside text module)`; console.log(`Input: ${inputLabel}${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`); // Surface publish resolution so a misconfigured OSS/Feishu is obvious. if (noPublish) { console.log("Publish: skipped (--no-publish)"); } else if (publish) { console.log(`Publish: oss=${publish.oss ? "on" : "off"} feishu=${publish.feishu ? "on" : "off"}`); } else { console.log("Publish: skipped (no OSS/Feishu configured)"); } const result = await runDocument( { text, source: opts.source, sourceArgs, template: template as any, platforms: platforms as any, ttsProvider: providerName as any, voiceId: runConfig.tts.voiceId, skipTts: noTts, skipLlm: opts.skipLlm, skipPublish: noPublish, llmModel: opts.llmModel, }, runConfig, { onStage: (stage) => { const stepNum = stages.indexOf(stage) + 1; const labels: Record = { text: opts.skipLlm ? "Generating document (JSON input)" : "Generating document (AI text)", audio: noTts ? "Generating silent audio" : `Generating narration${runConfig.tts.alignment?.provider === "whisper" ? " (whisper alignment)" : ""}`, render: "Rendering video (Remotion)", }; console.log(` [${stepNum}/${stages.length}] ${labels[stage] || stage}...`); }, } ); if (result.status === "completed" && result.files.length > 0) { console.log(`\nCompleted! Job ID: ${result.jobId}`); for (const file of result.files) { console.log(` -> ${file.filePath} (${(file.fileSizeBytes / 1024 / 1024).toFixed(1)}MB, ${file.width}x${file.height})`); if (file.ossUrl) console.log(` oss: ${file.ossUrl}`); } if (result.stateError) { console.error(`\nState warning (dedup degraded): ${result.stateError}`); } if (result.publishError) { console.error(`\nPublish warning: ${result.publishError}`); } } else { console.error(`\nFailed: ${result.error}`); process.exit(1); } } catch (err) { // Expected pipeline errors (source/template mismatch, missing config, // collector failures...) — print the message cleanly, no stack trace. console.error(`\nFailed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); function buildAlignmentConfig( opts: any, config: Record | null ): DocumentRunConfig["tts"]["alignment"] { const mode = opts.alignment || config?.tts?.alignment?.provider || config?.alignment?.provider; if (mode !== "whisper" && mode !== "native") return undefined; return { provider: mode, whisperModel: opts.whisperModel || config?.tts?.alignment?.whisperModel || config?.alignment?.whisperModel || "base", language: opts.alignmentLanguage || config?.tts?.alignment?.language || config?.alignment?.language, }; } function loadConfig(configPath?: string): Record | null { const cliDir = dirname(new URL(import.meta.url).pathname); const projectRoot = resolve(cliDir, "../../../../"); const paths = [ configPath, "pipeline.config.yaml", "pipeline.config.yml", resolve(projectRoot, "config/default.yaml"), "config/default.yaml", ].filter(Boolean) as string[]; for (const p of paths) { const resolved = resolve(p); if (existsSync(resolved)) { const content = readFileSync(resolved, "utf-8"); return parseYaml(content); } } return null; }