Просмотр исходного кода

支持一次性生成多平台内容

lkatzey 1 месяц назад
Родитель
Сommit
30adfed2d6

+ 15 - 7
apps/cli/src/commands/render.ts

@@ -50,9 +50,11 @@ 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);
     }
 
@@ -131,14 +133,14 @@ export const renderCommand = new Command("render")
 
     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,
+        platforms: platforms as any,
         ttsProvider: providerName as any,
         voiceId: pipelineConfig.tts.voiceId,
         outputPath: resolve(opts.output || "./output"),
@@ -146,6 +148,8 @@ export const renderCommand = new Command("render")
       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)" : ""}`,
@@ -154,10 +158,14 @@ export const renderCommand = new Command("render")
             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}`);

+ 23 - 23
apps/web/src/app/api/render/route.ts

@@ -13,7 +13,8 @@ export async function POST(request: Request) {
       source?: string;
       sourceArgs?: Record<string, string>;
       template: string;
-      platform: string;
+      platform?: string;
+      platforms?: string[];
       ttsProvider: string;
       voiceId?: string;
     };
@@ -22,11 +23,14 @@ export async function POST(request: Request) {
     };
   };
 
+  const platforms: string[] = input.platforms
+    ?? (input.platform ? input.platform.split(",").map(p => p.trim()) : ["bilibili"]);
+
   if (!input.text && !input.source) {
     return NextResponse.json({ error: "text or source is required" }, { status: 400 });
   }
 
-  const job = createJob({ ...input, text: input.text ?? "" });
+  const job = createJob({ ...input, text: input.text ?? "", platforms });
 
   const jobDir = join(tmpdir(), "pipeline-jobs", job.id);
   mkdirSync(jobDir, { recursive: true });
@@ -34,7 +38,7 @@ export async function POST(request: Request) {
   const cliArgs = [
     "render",
     "-t", input.template,
-    "-p", input.platform,
+    "-p", platforms.join(","),
     "--tts-provider", input.ttsProvider,
     "--output", resolve("output"),
   ];
@@ -79,29 +83,25 @@ export async function POST(request: Request) {
 
   child.on("close", (code: number) => {
     if (code === 0) {
-      const match = stdout.match(/-> (.+\.mp4)/);
-      const filePath = match ? match[1].trim() : "";
-
-      let fileSizeBytes = 0;
-      if (filePath) {
-        try {
-          fileSizeBytes = statSync(filePath).size;
-        } catch {}
-      }
+      const mp4Matches = [...stdout.matchAll(/-> (.+\.mp4)/g)];
+      const filePaths = mp4Matches.map(m => m[1].trim());
+
+      const outputFiles = filePaths.map(filePath => {
+        let fileSizeBytes = 0;
+        try { fileSizeBytes = statSync(filePath).size; } catch {}
+        const isPortrait = filePath.includes("douyin") || filePath.includes("9x16");
+        return {
+          filePath,
+          fileSizeBytes,
+          width: isPortrait ? 1080 : 1920,
+          height: isPortrait ? 1920 : 1080,
+          durationSeconds: 0,
+        };
+      });
 
       updateJob(job.id, {
         status: "completed",
-        outputFiles: filePath
-          ? [
-              {
-                filePath,
-                fileSizeBytes,
-                width: input.platform.includes("9:16") || input.platform.includes("douyin") ? 1080 : 1920,
-                height: input.platform.includes("9:16") || input.platform.includes("douyin") ? 1920 : 1080,
-                durationSeconds: 0,
-              },
-            ]
-          : undefined,
+        outputFiles: outputFiles.length > 0 ? outputFiles : undefined,
       });
     } else {
       updateJob(job.id, {

+ 3 - 2
apps/web/src/app/jobs/[id]/page.tsx

@@ -17,7 +17,8 @@ interface Job {
   input: {
     text: string;
     template: string;
-    platform: string;
+    platform?: string;
+    platforms?: string[];
     ttsProvider: string;
   };
   createdAt: number;
@@ -112,7 +113,7 @@ export default function JobDetailPage({ params }: { params: Promise<{ id: string
         </div>
         <div className="card">
           <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Platform</div>
-          <div style={{ fontWeight: 600 }}>{job.input.platform}</div>
+          <div style={{ fontWeight: 600 }}>{job.input.platforms?.join(", ") ?? job.input.platform}</div>
         </div>
         <div className="card">
           <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>TTS Provider</div>

+ 2 - 2
apps/web/src/app/jobs/page.tsx

@@ -6,7 +6,7 @@ import Link from "next/link";
 interface Job {
   id: string;
   status: "pending" | "running" | "completed" | "failed";
-  input: { template: string; platform: string };
+  input: { template: string; platform?: string; platforms?: string[] };
   createdAt: number;
   updatedAt: number;
   error?: string;
@@ -58,7 +58,7 @@ export default function JobsPage() {
               <div className="card card-hover" style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
                 <div>
                   <div style={{ fontWeight: 600, marginBottom: 4 }}>
-                    {job.input.template} / {job.input.platform}
+                    {job.input.template} / {(job.input.platforms ?? [job.input.platform]).join(", ")}
                   </div>
                   <div style={{ fontSize: 13, color: "var(--text-muted)" }}>
                     {new Date(job.createdAt).toLocaleString("zh-CN")}

+ 2 - 1
apps/web/src/lib/job-store.ts

@@ -8,7 +8,8 @@ export interface JobData {
   input: {
     text: string;
     template: string;
-    platform: string;
+    platform?: string;
+    platforms?: string[];
     ttsProvider: string;
     voiceId?: string;
   };

+ 38 - 29
packages/core/src/pipeline.ts

@@ -4,6 +4,7 @@ import { join } from "node:path";
 import type {
   PipelineInput,
   PipelineJob,
+  ExportFile,
 } from "@pipeline/shared";
 import { PLATFORM_PRESETS } from "@pipeline/shared";
 import { parseText } from "./stages/parse.js";
@@ -60,6 +61,7 @@ export async function runPipeline(
   const workDir = join(config.output.dir, "tmp", jobId);
   await mkdir(workDir, { recursive: true });
 
+  const platforms = input.platforms;
   const job: PipelineJob = {
     id: jobId,
     input,
@@ -69,7 +71,7 @@ export async function runPipeline(
   };
 
   try {
-    // Stage 1: Parse
+    // Stage 1: Parse (shared across all platforms)
     callbacks?.onStageStart?.("parse");
     const parsed = await parseText(input.text, input.template, {
       llm: config.llm,
@@ -78,7 +80,7 @@ export async function runPipeline(
     job.parsed = parsed;
     callbacks?.onStageComplete?.("parse");
 
-    // Stage 2: TTS (can be skipped with --no-tts)
+    // Stage 2: TTS (shared across all platforms)
     callbacks?.onStageStart?.("tts");
     const tts = await generateTTS(parsed, workDir, {
       provider: config.tts.provider,
@@ -92,37 +94,44 @@ export async function runPipeline(
     job.tts = tts;
     callbacks?.onStageComplete?.("tts");
 
-    // Stage 3: Assets
-    callbacks?.onStageStart?.("assets");
-    const assets = await resolveAssets(
-      parsed, workDir, config.assets.root, config.assets.inputDir,
-      { template: input.template, aspect: PLATFORM_PRESETS[input.platform].aspect }
-    );
-    callbacks?.onStageComplete?.("assets");
+    // Stages 3-6: Per-platform loop
+    const allExportFiles: ExportFile[] = [];
+    job.composed = {};
 
-    // Stage 4: Compose
-    callbacks?.onStageStart?.("compose");
-    const composed = composeProject(parsed, tts, assets, input.platform, input.template);
-    job.composed = composed;
-    callbacks?.onStageComplete?.("compose");
+    for (const platform of platforms) {
+      // Stage 3: Assets
+      callbacks?.onStageStart?.(`assets:${platform}`);
+      const assets = await resolveAssets(
+        parsed, workDir, config.assets.root, config.assets.inputDir,
+        { template: input.template, aspect: PLATFORM_PRESETS[platform].aspect }
+      );
+      callbacks?.onStageComplete?.(`assets:${platform}`);
 
-    // Stage 5: Render
-    callbacks?.onStageStart?.("render");
-    const renderOutput = join(workDir, "render.mp4");
-    await renderVideo(composed, renderOutput, config.templates.entryPoint);
-    callbacks?.onStageComplete?.("render");
+      // Stage 4: Compose
+      callbacks?.onStageStart?.(`compose:${platform}`);
+      const composed = composeProject(parsed, tts, assets, platform, input.template);
+      job.composed[platform] = composed;
+      callbacks?.onStageComplete?.(`compose:${platform}`);
 
-    // Stage 6: Export
-    callbacks?.onStageStart?.("export");
-    const exported = await exportVideo(
-      renderOutput,
-      composed,
-      config.output.dir,
-      jobId
-    );
-    job.exported = exported;
-    callbacks?.onStageComplete?.("export");
+      // Stage 5: Render
+      callbacks?.onStageStart?.(`render:${platform}`);
+      const renderOutput = join(workDir, `render-${platform}.mp4`);
+      await renderVideo(composed, renderOutput, config.templates.entryPoint);
+      callbacks?.onStageComplete?.(`render:${platform}`);
 
+      // Stage 6: Export
+      callbacks?.onStageStart?.(`export:${platform}`);
+      const exported = await exportVideo(
+        renderOutput,
+        composed,
+        config.output.dir,
+        jobId
+      );
+      allExportFiles.push(...exported.files);
+      callbacks?.onStageComplete?.(`export:${platform}`);
+    }
+
+    job.exported = { jobId, createdAt: Date.now(), files: allExportFiles };
     job.status = "completed";
   } catch (err) {
     job.status = "failed";

+ 3 - 3
packages/shared/src/types/pipeline.ts

@@ -4,13 +4,13 @@ import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSch
 export const PipelineInputSchema = z.object({
   text: z.string(),
   template: z.enum(["news", "knowledge", "opinion", "marketing"]),
-  platform: z.enum([
+  platforms: z.array(z.enum([
     "bilibili",
     "douyin-long",
     "douyin-short",
     "universal-16x9",
     "universal-9x16",
-  ]),
+  ])).min(1),
   ttsProvider: z.enum(["fish-audio", "minimax", "elevenlabs", "openai-tts"]),
   voiceId: z.string().optional(),
   outputPath: z.string().optional(),
@@ -128,7 +128,7 @@ export const PipelineJobSchema = z.object({
   error: z.string().optional(),
   parsed: ParsedContentSchema.optional(),
   tts: TTSStageResultSchema.optional(),
-  composed: ComposedProjectSchema.optional(),
+  composed: z.record(z.string(), ComposedProjectSchema).optional(),
   exported: ExportManifestSchema.optional(),
   createdAt: z.number(),
   updatedAt: z.number(),