import { createHash, randomUUID } from "node:crypto"; import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { uploadFile } from "@/lib/storage/service"; import { ffmpeg, mediaSafety, probeMedia } from "@/lib/video-canvas/smart-edit/local"; import { remoteAudio, remoteAudioConfig, } from "@/lib/video-canvas/smart-edit/remote-audio"; export const runtime = "nodejs"; export const maxDuration = 600; const running = new Set(); const allowedStems = ["vocals", "background"] as const; export async function POST(request: Request) { const session = await auth(); const userId = session?.user?.id; if (!userId) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); const body = (await request.json().catch(() => null)) as { url?: unknown; stem?: unknown; } | null; const url = typeof body?.url === "string" ? body.url.trim() : ""; const stem = typeof body?.stem === "string" ? body.stem : ""; if (!/^https?:\/\//i.test(url) || !allowedStems.includes(stem as (typeof allowedStems)[number])) return NextResponse.json({ success: false, error: "参数不完整" }, { status: 400 }); if (!remoteAudioConfig()) return NextResponse.json( { success: false, error: "音频分离服务未配置,请联系管理员" }, { status: 503 } ); const lockKey = `${userId}:${url}:${stem}`; if (running.has(lockKey) || running.size >= 3) return NextResponse.json( { success: false, error: "已有分离任务在进行,请稍候" }, { status: 429 } ); running.add(lockKey); let dir = ""; try { dir = await mkdtemp(path.join(tmpdir(), "canvas-separate-")); const input = path.join(dir, "input"); const response = await fetch(url, { signal: AbortSignal.timeout(180000), cache: "no-store", }); if (!response.ok) throw new Error("素材下载失败,请确认素材仍可访问"); const bytes = Buffer.from(await response.arrayBuffer()); if (!bytes.length || bytes.length > 150 * 1024 * 1024) throw new Error("素材必须非空且不超过 125 MiB"); await writeFile(input, bytes); const info = await probeMedia(input, 600); if (!info.hasAudio) throw new Error("当前视频没有音轨"); const inputAudio = path.join(dir, "separation-input.wav"); const durationSeconds = Math.round(info.duration / 1e6); await ffmpeg( [ ...mediaSafety, "-i", input, "-vn", "-t", String(durationSeconds), "-ac", "2", "-ar", "44100", "-c:a", "pcm_f32le", inputAudio, ], undefined, 240000 ); const requestKey = createHash("sha256") .update(`${url}:${stem}:${(await stat(inputAudio)).size}`) .digest("hex"); await remoteAudio("separate", inputAudio, userId, requestKey, dir); const stemFile = path.join(dir, `${stem}.wav`); const wav = await readFile(stemFile); if (wav.length < 1024) throw new Error("分离结果异常,请稍后重试"); const uploaded = await uploadFile( `canvas-separate-${randomUUID()}.wav`, await new Blob([wav]).arrayBuffer() ); return NextResponse.json({ success: true, data: { url: uploaded.url, duration: Math.round(info.duration / 1e6), }, }); } catch (error) { return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "人声分离失败,原音轨已保留", }, { status: 500 } ); } finally { running.delete(lockKey); if (dir) await rm(dir, { recursive: true, force: true }); } }