The unbounded apad filter made ffmpeg pad the 3-second test clip endlessly (a 22.9GB WAV) until the disk filled, which was the real cause of the voice separation failures. Trim to the probed duration and drop apad.
113 lines
3.8 KiB
TypeScript
113 lines
3.8 KiB
TypeScript
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<string>();
|
|
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 });
|
|
}
|
|
}
|