|
|
@@ -1,6 +1,7 @@
|
|
|
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";
|
|
|
@@ -9,20 +10,39 @@ import { parse as parseYaml } from "yaml";
|
|
|
const skipTts = (opts: any) => opts.tts === false;
|
|
|
|
|
|
export const renderCommand = new Command("render")
|
|
|
- .description("Generate video from structured JSON input")
|
|
|
- .argument("<input>", "Input JSON file path")
|
|
|
+ .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("--tts-provider <provider>", "TTS provider")
|
|
|
- .option("--voice <id>", "Voice ID override")
|
|
|
.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)")
|
|
|
- .option("-v, --verbose", "Verbose logging")
|
|
|
+
|
|
|
.action(async (input, opts) => {
|
|
|
const template = opts.template as string;
|
|
|
if (!TEMPLATE_TYPES.includes(template as any)) {
|
|
|
@@ -30,35 +50,59 @@ export const renderCommand = new Command("render")
|
|
|
process.exit(1);
|
|
|
}
|
|
|
|
|
|
- const platform = opts.platform as string;
|
|
|
- if (!PLATFORM_PRESET_KEYS.includes(platform as any)) {
|
|
|
- console.error(`Error: Invalid platform "${platform}". Use: ${PLATFORM_PRESET_KEYS.join(", ")}`);
|
|
|
+ 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);
|
|
|
}
|
|
|
|
|
|
- // Read JSON input file
|
|
|
- const inputPath = resolve(input);
|
|
|
- if (!existsSync(inputPath)) {
|
|
|
- console.error(`Error: Input file not found: ${inputPath}`);
|
|
|
- process.exit(1);
|
|
|
- }
|
|
|
- const text = readFileSync(inputPath, "utf-8");
|
|
|
+ const config = loadConfig(opts.config);
|
|
|
|
|
|
- // Validate it's valid JSON
|
|
|
- try {
|
|
|
- JSON.parse(text);
|
|
|
- } catch {
|
|
|
- console.error("Error: Input file is not valid JSON");
|
|
|
- process.exit(1);
|
|
|
+ // 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 config = loadConfig(opts.config);
|
|
|
+ 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) : dirname(inputPath);
|
|
|
|
|
|
const noTts = skipTts(opts);
|
|
|
const flags: string[] = [];
|
|
|
@@ -66,15 +110,16 @@ export const renderCommand = new Command("render")
|
|
|
|
|
|
const pipelineConfig: PipelineConfig = {
|
|
|
llm: {
|
|
|
- baseURL: config?.llm?.baseURL,
|
|
|
- apiKey: config?.llm?.apiKey,
|
|
|
- model: config?.llm?.model || "gpt-4o",
|
|
|
+ baseURL: opts.llmBaseUrl || config?.llm?.baseURL,
|
|
|
+ apiKey: opts.llmApiKey || config?.llm?.apiKey,
|
|
|
+ model: opts.llmModel || config?.llm?.model || "gpt-4o",
|
|
|
},
|
|
|
tts: {
|
|
|
- provider: opts.ttsProvider || config?.tts?.provider || "openai-tts",
|
|
|
- voiceId: opts.voice || config?.tts?.[opts.ttsProvider || config?.tts?.provider]?.defaultVoice,
|
|
|
- format: "mp3",
|
|
|
- speed: 1.0,
|
|
|
+ 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"),
|
|
|
@@ -82,38 +127,45 @@ export const renderCommand = new Command("render")
|
|
|
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 ${platform}`);
|
|
|
+ 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,
|
|
|
- platform: platform as any,
|
|
|
- ttsProvider: (opts.ttsProvider || config?.tts?.provider || "openai-tts") as any,
|
|
|
- voiceId: opts.voice,
|
|
|
+ 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: "Parsing JSON input",
|
|
|
+ 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",
|
|
|
};
|
|
|
- console.log(` [${stages.indexOf(stage) + 1}/6] ${labels[stage] || stage}...`);
|
|
|
+ const stepNum = stages.indexOf(name as string) + 1;
|
|
|
+ console.log(` [${stepNum}/6] ${labels[name] || name}${platformLabel}...`);
|
|
|
},
|
|
|
onStageComplete: (stage) => {
|
|
|
- console.log(` [${stages.indexOf(stage) + 1}/6] ${stage} done`);
|
|
|
+ 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}`);
|
|
|
@@ -150,10 +202,14 @@ function buildAlignmentConfig(
|
|
|
}
|
|
|
|
|
|
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[];
|
|
|
|