The private FunASR/HTDemucs audio service was already live and used by the smart edit pipeline, but the canvas video toolbar still claimed voice/background separation was unavailable. Add a dedicated /api/canvas/audio-separate route (download, ffmpeg stem extraction, remote separation, storage upload) and route the toolbar's voice and background modes through it, replacing the stale not-connected notice.
112 lines
3.7 KiB
TypeScript
112 lines
3.7 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");
|
|
await ffmpeg(
|
|
[
|
|
...mediaSafety,
|
|
"-i",
|
|
input,
|
|
"-vn",
|
|
"-af",
|
|
"apad",
|
|
"-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 });
|
|
}
|
|
}
|