import { NextResponse } from "next/server"; import { createJob, updateJob } from "@/lib/job-store"; import { loadConfig } from "@/lib/config"; import { resolveOutputDir } from "@pipeline/shared/node"; import { spawn } from "node:child_process"; import { writeFileSync, mkdirSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; export async function POST(request: Request) { const body = await request.json(); const { input, options } = body as { input: { text?: string; source?: string; sourceArgs?: Record; template: string; platform?: string; platforms?: string[]; ttsProvider: string; voiceId?: string; }; options?: { noTts?: boolean; }; }; 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 ?? "", platforms }); const jobDir = join(tmpdir(), "pipeline-jobs", job.id); mkdirSync(jobDir, { recursive: true }); // Same unified output directory the CLI resolves (OUTPUT_DIR > config > ./output). const config = loadConfig(); const outputDir = resolveOutputDir(config?.output?.dir); const cliArgs = [ "render", "-t", input.template, "-p", platforms.join(","), "--tts-provider", input.ttsProvider, "--output", outputDir, ]; if (input.source) { cliArgs.push("--source", input.source); if (input.sourceArgs?.owner) cliArgs.push("--source-owner", input.sourceArgs.owner); if (input.sourceArgs?.repo) cliArgs.push("--source-repo", input.sourceArgs.repo); } else { const inputFile = join(jobDir, "input.json"); writeFileSync(inputFile, input.text!, "utf-8"); cliArgs.splice(1, 0, inputFile); } if (input.voiceId) { cliArgs.push("--voice", input.voiceId); } if (options?.noTts) { cliArgs.push("--no-tts"); } const cliPath = resolve(process.cwd(), "../cli/dist/index.js"); const projectRoot = resolve(process.cwd(), ".."); updateJob(job.id, { status: "running" }); const child = spawn("node", [cliPath, ...cliArgs], { cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env }, }); let stdout = ""; let stderr = ""; child.stdout.on("data", (data: Buffer) => { stdout += data.toString(); }); child.stderr.on("data", (data: Buffer) => { stderr += data.toString(); }); child.on("close", (code: number) => { if (code === 0) { 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, }; }); const ossUrls = [...stdout.matchAll(/^[ \t]*oss: (\S+)/gm)].map(m => m[1].trim()); const publishWarn = stdout.match(/^Publish warning: (.+)$/m); const publishError = publishWarn ? publishWarn[1].trim() : undefined; updateJob(job.id, { status: "completed", outputFiles: outputFiles.length > 0 ? outputFiles : undefined, ossUrls: ossUrls.length > 0 ? ossUrls : undefined, publishError, }); } else { updateJob(job.id, { status: "failed", error: stderr.trim() || `Process exited with code ${code}`, }); } }); child.on("error", (err: Error) => { updateJob(job.id, { status: "failed", error: err.message, }); }); return NextResponse.json({ jobId: job.id, status: "started" }); }