| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250 |
- 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 <type>", `Template: ${TEMPLATE_TYPES.join("|")}`)
- .option("-p, --platform <preset>", "Platform preset", "bilibili")
- .option("-o, --output <dir>", "Output directory (default: config output.dir or ./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)")
- .option("--channel-name <name>", "Channel name displayed in video watermark")
- .option("--no-publish", "Skip OSS upload + Feishu notification")
- // 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) => {
- 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<string, string> | 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: <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");
- }
- 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<string, string> = {
- 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<string, any> | 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<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;
- }
|