| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224 |
- import { Command } from "commander";
- import { runPipeline, type PipelineConfig } from "@pipeline/core";
- import { PLATFORM_PRESETS, TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared";
- 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;
- 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 <type>", `Template: ${TEMPLATE_TYPES.join("|")}`)
- .option("-p, --platform <preset>", "Platform preset", "bilibili")
- .option("-o, --output <dir>", "Output directory", "./output")
- .option("--assets-dir <dir>", "Image assets directory (defaults to JSON file location)")
- .option("--config <path>", "Config file path")
- .option("-v, --verbose", "Verbose logging")
- // Data source
- .option("--source <name>", "Collect data from source before rendering")
- .option("--source-owner <owner>", "Repo owner (for github-repo source)")
- .option("--source-repo <repo>", "Repo name (for github-repo source)")
- // LLM
- .option("--skip-llm", "Skip LLM processing (require valid VideoInputSchema JSON)")
- .option("--llm-model <model>", "LLM model name")
- .option("--llm-base-url <url>", "LLM API base URL")
- .option("--llm-api-key <key>", "LLM API key")
- // TTS — general
- .option("--tts-provider <provider>", "TTS provider (openai-tts|fish-audio|minimax|elevenlabs)")
- .option("--voice <id>", "Voice ID")
- .option("--tts-model <model>", "TTS model name (provider-specific)")
- .option("--tts-speed <speed>", "TTS speech speed (0.5-2.0)", parseFloat)
- .option("--tts-format <format>", "TTS audio format (mp3|wav|pcm)")
- .option("--no-tts", "Skip TTS (generate silent video)")
- // Alignment
- .option("--alignment <mode>", "Timestamp alignment: whisper|native")
- .option("--whisper-model <model>", "Whisper model size (tiny/base/small/medium/large)", "base")
- .option("--alignment-language <lang>", "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<string, string> = {};
- 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: <input> 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 flags: string[] = [];
- if (noTts) flags.push("no-tts");
- const pipelineConfig: PipelineConfig = {
- 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: resolve(opts.output || config?.output?.dir || "./output"),
- },
- assets: { root: assetsRoot, inputDir },
- templates: { entryPoint: templatesEntry },
- skipTts: noTts,
- skipLlm: opts.skipLlm,
- alignment: buildAlignmentConfig(opts, config),
- };
- const stages = ["parse", "tts", "assets", "compose", "render", "export"];
- 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: resolve(opts.output || "./output"),
- },
- pipelineConfig,
- {
- onStageStart: (stage) => {
- const [name, plat] = stage.split(":");
- const platformLabel = plat ? ` [${plat}]` : "";
- const labels: Record<string, string> = {
- 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",
- };
- const stepNum = stages.indexOf(name as string) + 1;
- console.log(` [${stepNum}/6] ${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}/6] ${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 <file> -t <template> --no-tts\n");
- }
- },
- }
- );
- if (job.status === "completed" && job.exported) {
- console.log(`\nCompleted! Job ID: ${job.id}`);
- for (const file of job.exported.files) {
- console.log(` -> ${file.filePath} (${(file.fileSizeBytes / 1024 / 1024).toFixed(1)}MB, ${file.width}x${file.height})`);
- }
- } else {
- console.error(`\nFailed: ${job.error}`);
- process.exit(1);
- }
- });
- function buildAlignmentConfig(
- opts: any,
- config: Record<string, any> | null
- ): PipelineConfig["alignment"] {
- const mode = opts.alignment || config?.alignment?.provider;
- if (mode !== "whisper" && mode !== "native") return undefined;
- return {
- provider: mode,
- whisperModel: opts.whisperModel || config?.alignment?.whisperModel || "base",
- language: opts.alignmentLanguage || config?.alignment?.language,
- };
- }
- function loadConfig(configPath?: string): Record<string, any> | 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;
- }
|