225 lines
7.1 KiB
TypeScript
225 lines
7.1 KiB
TypeScript
import { remoteAudio, remoteAudioConfig } from "./remote-audio";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
SECOND,
|
|
editPlanSchema,
|
|
planTimeline,
|
|
editableCaptions,
|
|
newCaptionStyle,
|
|
type EditPlan,
|
|
type TimelineCaption,
|
|
} from "./contract";
|
|
import {
|
|
materialize,
|
|
probeMedia,
|
|
ffmpeg,
|
|
mediaSafety,
|
|
prepareAudio,
|
|
pythonAnalysis,
|
|
} from "./local";
|
|
|
|
export async function processEditAudio(
|
|
operation: "separate" | "transcribe",
|
|
plan: EditPlan,
|
|
clipId: string | undefined,
|
|
userId: string,
|
|
dir: string,
|
|
progress: (stage: string) => Promise<void>,
|
|
upload: (file: string, suffix: string) => Promise<string>,
|
|
track: "video" | "audio" = "video",
|
|
keep: "both" | "vocals" | "background" = "both",
|
|
requestKey: string = userId
|
|
): Promise<EditPlan> {
|
|
const timeline = planTimeline(plan);
|
|
if (operation === "separate") {
|
|
const index = plan.clips.findIndex((c) => c.id === clipId);
|
|
const audio =
|
|
track === "audio"
|
|
? plan.audioClips?.find((c) => c.id === clipId)
|
|
: undefined;
|
|
const clip = track === "audio" ? audio : plan.clips[index];
|
|
if (!clip || (track === "video" && plan.clips[index].audioDetached))
|
|
throw Error("请选择可分离的音视频片段");
|
|
if (
|
|
(plan.audioClips?.length || 0) +
|
|
(keep === "both" ? 2 : 1) -
|
|
(audio ? 1 : 0) >
|
|
24
|
|
)
|
|
throw Error("分离需要空闲音轨,最多支持 24 个音频片段");
|
|
const asset = plan.assets.find((a) => a.id === clip.assetId)!;
|
|
if (!asset.hasAudio) throw Error("当前视频没有音轨");
|
|
await progress("准备人声分离素材");
|
|
const media = await materialize(asset.url, userId);
|
|
if (media.hash !== asset.hash) throw Error("原素材内容已变化,请重新解析");
|
|
const actual = await probeMedia(media.file);
|
|
if (!actual.hasAudio || Math.abs(actual.duration - asset.duration) > 100000)
|
|
throw Error("原音轨参数已变化,请重新解析");
|
|
const inputAudio = path.join(dir, "separation-input.wav");
|
|
await ffmpeg([
|
|
...mediaSafety,
|
|
"-ss",
|
|
String(clip.sourceIn / SECOND),
|
|
"-i",
|
|
media.file,
|
|
"-t",
|
|
String((clip.sourceOut - clip.sourceIn) / SECOND),
|
|
"-vn",
|
|
"-af",
|
|
"apad",
|
|
"-ac",
|
|
"2",
|
|
"-ar",
|
|
"44100",
|
|
"-c:a",
|
|
"pcm_f32le",
|
|
inputAudio,
|
|
]);
|
|
await progress("正在分离人声与背景声");
|
|
if (remoteAudioConfig()) {
|
|
await remoteAudio(
|
|
"separate",
|
|
inputAudio,
|
|
userId,
|
|
requestKey,
|
|
dir,
|
|
progress
|
|
);
|
|
} else
|
|
try {
|
|
await promisify(execFile)(
|
|
process.env.SMART_EDIT_PYTHON || "python3",
|
|
[path.resolve("services/smart-edit/separate.py"), inputAudio, dir],
|
|
{ timeout: 600000, maxBuffer: 2 * 1024 * 1024 }
|
|
);
|
|
} catch (e) {
|
|
const detail = String((e as { stderr?: string }).stderr || "");
|
|
throw Error(
|
|
detail.includes("尚未配置")
|
|
? "人声分离模型尚未配置"
|
|
: "人声分离失败或超时,原音轨已保留"
|
|
);
|
|
}
|
|
await progress("保存人声与背景声音轨");
|
|
const audioClips = (plan.audioClips || []).filter(
|
|
(c) => !audio || c.id !== audio.id
|
|
);
|
|
const used = new Set([...plan.clips, ...audioClips].map((c) => c.assetId));
|
|
const assets = plan.assets.filter((a) => used.has(a.id));
|
|
const t = timeline.clips[index],
|
|
next = timeline.clips[index + 1];
|
|
for (const [stem, title] of [
|
|
["vocals", "人声"],
|
|
["background", "背景声"],
|
|
]) {
|
|
if (keep !== "both" && keep !== stem) continue;
|
|
const file = path.join(dir, stem + ".wav"),
|
|
metadata = await probeMedia(file);
|
|
if (
|
|
Math.abs(metadata.duration - (clip.sourceOut - clip.sourceIn)) > 50000
|
|
)
|
|
throw Error("分离音轨时长异常,原音轨已保留");
|
|
const id = randomUUID(),
|
|
bytes = await readFile(file);
|
|
assets.push({
|
|
id,
|
|
name: `${asset.name.slice(0, 180)} · ${title}`,
|
|
url: await upload(file, "wav"),
|
|
hash: createHash("sha256").update(bytes).digest("hex"),
|
|
subtitles: [],
|
|
...metadata,
|
|
});
|
|
audioClips.push({
|
|
id: randomUUID(),
|
|
assetId: id,
|
|
start: audio ? audio.start : t.start,
|
|
sourceIn: 0,
|
|
sourceOut: clip.sourceOut - clip.sourceIn,
|
|
speed: clip.speed,
|
|
gain: clip.gain,
|
|
muted: clip.muted,
|
|
fadeIn: audio ? audio.fadeIn : (t.overlap - t.padBefore) / SECOND,
|
|
fadeInStart: audio
|
|
? audio.fadeInStart
|
|
: t.overlap
|
|
? t.padBefore / t.overlap
|
|
: 0,
|
|
fadeOut: audio
|
|
? audio.fadeOut
|
|
: next
|
|
? (next.overlap - t.padAfter) / SECOND
|
|
: 0,
|
|
fadeOutEnd: audio
|
|
? audio.fadeOutEnd
|
|
: next?.overlap
|
|
? t.padAfter / next.overlap
|
|
: 0,
|
|
});
|
|
}
|
|
return editPlanSchema.parse({
|
|
...plan,
|
|
assets,
|
|
audioClips,
|
|
clips: plan.clips.map((c) =>
|
|
!audio && c.id === clip.id ? { ...c, audioDetached: true } : c
|
|
),
|
|
});
|
|
}
|
|
const captions: TimelineCaption[] = editableCaptions(plan).filter(
|
|
(c) => !c.id.startsWith("asr-")
|
|
);
|
|
let recognized = 0;
|
|
for (const [index, clip] of timeline.clips.entries()) {
|
|
const asset = plan.assets.find((a) => a.id === clip.assetId)!;
|
|
if (!asset.hasAudio) continue;
|
|
await progress(`自动转写 ${index + 1}/${plan.clips.length}`);
|
|
const media = await materialize(asset.url, userId);
|
|
if (media.hash !== asset.hash) throw Error("原素材内容已变化,请重新解析");
|
|
const actual = await probeMedia(media.file);
|
|
if (!actual.hasAudio || Math.abs(actual.duration - asset.duration) > 100000)
|
|
throw Error("原音轨参数已变化,请重新解析");
|
|
const audio = await prepareAudio(
|
|
media.file,
|
|
path.join(media.dir, media.hash + ".wav")
|
|
);
|
|
const analysis = await pythonAnalysis(
|
|
{ version: 1, audio, transcribe: true },
|
|
media.dir,
|
|
requestKey,
|
|
progress
|
|
);
|
|
for (const [j, s] of analysis.subtitles.entries()) {
|
|
const from = Math.max(s.start, clip.sourceIn),
|
|
to = Math.min(s.end, clip.sourceOut);
|
|
if (to <= from) continue;
|
|
const start = Math.round(
|
|
clip.start + (from - clip.sourceIn) / clip.speed
|
|
);
|
|
const end = Math.min(
|
|
timeline.duration,
|
|
Math.round(clip.start + (to - clip.sourceIn) / clip.speed)
|
|
);
|
|
if (end <= start) continue;
|
|
captions.push({
|
|
id: `asr-${clip.id}-${j}`,
|
|
start,
|
|
end,
|
|
text: s.text,
|
|
style: { ...newCaptionStyle },
|
|
});
|
|
recognized++;
|
|
}
|
|
}
|
|
if (!recognized) throw Error("未识别到有效人声,原字幕已保留");
|
|
if (captions.length > 500) throw Error("转写后字幕超过 500 条,请缩短素材");
|
|
return editPlanSchema.parse({
|
|
...plan,
|
|
subtitles: true,
|
|
captions: captions.sort((a, b) => a.start - b.start),
|
|
});
|
|
}
|