342 lines
14 KiB
TypeScript
342 lines
14 KiB
TypeScript
import "server-only";
|
||
import { processEditAudio } from "./audio-process";
|
||
import { sql } from "drizzle-orm";
|
||
import { db } from "@/lib/db";
|
||
import { uploadFile } from "@/lib/storage/service";
|
||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||
import { tmpdir } from "node:os";
|
||
import path from "node:path";
|
||
import { randomUUID } from "node:crypto";
|
||
import {
|
||
SECOND,
|
||
smartEditRequestSchema,
|
||
buildEditPlans,
|
||
planTimeline,
|
||
editPlanSchema,
|
||
type EditAsset,
|
||
type EditCandidate,
|
||
type EditPlan,
|
||
} from "./contract";
|
||
import {
|
||
materialize,
|
||
probeMedia,
|
||
prepareAnalysis,
|
||
pythonAnalysis,
|
||
prepareAudio,
|
||
ffmpeg,
|
||
pruneSmartEditCache,
|
||
} from "./local";
|
||
import { selectEditCandidates } from "./semantic";
|
||
import { renderJob } from "./render-job";
|
||
import { remoteRender, remoteRenderConfig, type RemoteRenderCheckpoint } from "./remote-render";
|
||
import { ProviderTaskUncertainError } from "../video-task-lifecycle";
|
||
export type SmartEditCheckpoint = {
|
||
operation: "smart-edit";
|
||
action: "plan" | "render" | "separate" | "transcribe";
|
||
phase: "local" | "analyzed" | "selecting" | "selected" | "remote-render";
|
||
remote?: RemoteRenderCheckpoint;
|
||
stage: string;
|
||
assets?: EditAsset[];
|
||
candidates?: EditCandidate[];
|
||
beats?: number[];
|
||
warnings?: string[];
|
||
plans?: EditPlan[];
|
||
};
|
||
export async function executeSmartEdit(
|
||
value: unknown,
|
||
userId: string,
|
||
lifecycle: {
|
||
taskId: string;
|
||
resume?: SmartEditCheckpoint | null;
|
||
assertActive: () => Promise<void>;
|
||
checkpoint: (result: Record<string, unknown>) => Promise<void>;
|
||
},
|
||
) {
|
||
const params = smartEditRequestSchema.parse(value);
|
||
let state: SmartEditCheckpoint = lifecycle.resume || {
|
||
operation: "smart-edit",
|
||
action: params.operation,
|
||
phase: "local",
|
||
stage: "等待处理",
|
||
};
|
||
const save = async (patch: Partial<SmartEditCheckpoint>) => {
|
||
await lifecycle.assertActive();
|
||
state = { ...state, ...patch };
|
||
await lifecycle.checkpoint({ smartEdit: state });
|
||
};
|
||
await save({});
|
||
const upload = async (file: string, suffix: string) => {
|
||
await lifecycle.assertActive();
|
||
const bytes = await readFile(file);
|
||
return (
|
||
await uploadFile(`smart-edit-${randomUUID()}.${suffix}`, Uint8Array.from(bytes).buffer)
|
||
).url;
|
||
};
|
||
if (params.operation === "render" && (state.remote || remoteRenderConfig())) {
|
||
const dir = await mkdtemp(path.join(tmpdir(), "remote-render-"));
|
||
try {
|
||
const rendered = await remoteRender(params.plan, userId, lifecycle.taskId, dir, {
|
||
resume: state.remote,
|
||
active: lifecycle.assertActive,
|
||
save: (remote, stage) => save({ phase: "remote-render", remote, stage }),
|
||
});
|
||
const actual = await probeMedia(rendered.file, 300.2);
|
||
if (
|
||
Math.abs(actual.duration / SECOND - planTimeline(params.plan).duration / SECOND) >
|
||
0.15 ||
|
||
actual.width !== rendered.width ||
|
||
actual.height !== rendered.height
|
||
)
|
||
throw Error("远程成片与剪辑方案不一致");
|
||
await save({ stage: "转存成片" });
|
||
const url = await upload(rendered.file, "mp4"),
|
||
subtitleUrl = await upload(rendered.srt, "srt");
|
||
return {
|
||
url,
|
||
subtitleUrl,
|
||
plan: params.plan,
|
||
metadata: {
|
||
width: actual.width,
|
||
height: actual.height,
|
||
durationSeconds: actual.duration / SECOND,
|
||
storageStatus: "archived",
|
||
renderBackend: "remote",
|
||
},
|
||
};
|
||
} finally {
|
||
await rm(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
return db.transaction(async (tx) => {
|
||
const rows = await tx.execute(sql`select pg_try_advisory_xact_lock(91827, 1) as acquired`);
|
||
if (!(rows as any)[0]?.acquired)
|
||
throw new ProviderTaskUncertainError("剪辑处理器繁忙,任务已保存并等待继续");
|
||
await pruneSmartEditCache();
|
||
const dir = await mkdtemp(path.join(tmpdir(), "smart-edit-"));
|
||
try {
|
||
if (params.operation === "plan") {
|
||
if (state.phase === "selected" && state.plans)
|
||
return { plans: state.plans, summary: "剪辑方案已生成" };
|
||
if (state.phase === "selecting")
|
||
throw new ProviderTaskUncertainError(
|
||
"智能选片请求状态未确认,禁止自动重发;请核对模型记录",
|
||
);
|
||
let assets = state.assets || [],
|
||
candidates = state.candidates || [],
|
||
beats = state.beats || [],
|
||
warnings = state.warnings || [];
|
||
if (state.phase === "local") {
|
||
assets = [];
|
||
candidates = [];
|
||
warnings = [];
|
||
beats = [];
|
||
let total = 0;
|
||
for (const input of params.assets) {
|
||
await save({ stage: `解析素材 ${assets.length + 1}/${params.assets.length}` });
|
||
const media = await materialize(input.url, userId),
|
||
meta = await probeMedia(media.file);
|
||
if (!meta.width || !meta.height) throw Error(`${input.name} 不包含视频画面`);
|
||
total += meta.duration;
|
||
if (total > 600 * SECOND) throw Error("素材总时长不能超过 10 分钟");
|
||
// Opening a timeline only probes source files; it must not run scene analysis or models.
|
||
const analysis =
|
||
params.mode === "sequence"
|
||
? {
|
||
scenes: [[0, meta.duration]],
|
||
active: [[0, meta.duration]],
|
||
subtitles: input.subtitles,
|
||
warnings: [],
|
||
}
|
||
: await (async () => {
|
||
const prepared = await prepareAnalysis(
|
||
media.file,
|
||
media.hash,
|
||
media.dir,
|
||
meta.hasAudio,
|
||
);
|
||
return pythonAnalysis(
|
||
{
|
||
version: 1,
|
||
video: prepared.proxy,
|
||
audio: prepared.audio,
|
||
duration: meta.duration,
|
||
transcribe: params.transcribe && !input.subtitles.length,
|
||
},
|
||
media.dir,
|
||
);
|
||
})();
|
||
const subtitles = input.subtitles.length ? input.subtitles : analysis.subtitles;
|
||
if (subtitles.some((s) => s.end > meta.duration + 100000))
|
||
throw Error(`${input.name} 的字幕时间超出视频长度`);
|
||
const asset: EditAsset = { ...input, ...meta, hash: media.hash, subtitles };
|
||
assets.push(asset);
|
||
warnings.push(...analysis.warnings.map((w) => `${input.name}:${w}`));
|
||
const speech = params.mode === "talk" || params.mode === "explain";
|
||
if (speech && !subtitles.length)
|
||
warnings.push(
|
||
`${input.name} 没有语句时间戳,使用画面镜头选片。可导入 SRT 或开启自动转写。`,
|
||
);
|
||
const regions =
|
||
speech && subtitles.length
|
||
? subtitles.map((s) => ({ start: s.start, end: s.end, text: s.text }))
|
||
: analysis.scenes.map(([start, end]) => ({
|
||
start,
|
||
end: Math.min(end, meta.duration),
|
||
text: "",
|
||
}));
|
||
for (const region of regions) {
|
||
const ranges =
|
||
params.removeSilence && meta.hasAudio && !(speech && subtitles.length)
|
||
? analysis.active.map(([a, b]) => ({
|
||
start: Math.max(region.start, a),
|
||
end: Math.min(region.end, b),
|
||
text: region.text,
|
||
}))
|
||
: [region];
|
||
for (const range of ranges) {
|
||
// Keep full speech sentences; split long visual scenes into bounded candidates.
|
||
for (let start = range.start; start < range.end; ) {
|
||
const end =
|
||
params.mode === "sequence" || (speech && subtitles.length)
|
||
? range.end
|
||
: Math.min(
|
||
range.end,
|
||
start +
|
||
Math.max(5, Math.min(30, (params.targetDuration || 20) / 12)) *
|
||
SECOND,
|
||
);
|
||
if (end - start >= 200000)
|
||
candidates.push({
|
||
id: `candidate-${candidates.length}`,
|
||
assetId: asset.id,
|
||
start,
|
||
end,
|
||
text: range.text,
|
||
});
|
||
start = end;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Bound model context while sampling every source fairly.
|
||
const fair: EditCandidate[] = [];
|
||
const grouped = assets.map((a) => candidates.filter((c) => c.assetId === a.id));
|
||
for (let i = 0; fair.length < 24 && grouped.some((g) => i < g.length); i++)
|
||
for (const group of grouped) if (group[i] && fair.length < 24) fair.push(group[i]);
|
||
if (fair.length < candidates.length)
|
||
warnings.push(
|
||
`共识别 ${candidates.length} 个候选,本轮按素材轮询选取 24 个进入选片。`,
|
||
);
|
||
candidates = fair;
|
||
if (params.beatSync && params.music) {
|
||
await save({ stage: "检测音乐节拍" });
|
||
const music = await materialize(params.music.url, userId),
|
||
meta = await probeMedia(music.file);
|
||
if (!meta.hasAudio) throw Error("背景音乐没有音轨");
|
||
if (params.music.start >= meta.duration) throw Error("音乐开始时间超出文件");
|
||
const audio = await prepareAudio(
|
||
music.file,
|
||
path.join(music.dir, music.hash + ".wav"),
|
||
);
|
||
const analysis = await pythonAnalysis(
|
||
{ version: 1, audio, beats: true },
|
||
music.dir,
|
||
);
|
||
beats = analysis.beats;
|
||
warnings.push(...analysis.warnings);
|
||
} else if (params.beatSync) warnings.push("未选择音乐,跳过音乐卡点。");
|
||
await save({
|
||
phase: "analyzed",
|
||
stage: "素材解析完成",
|
||
assets,
|
||
candidates,
|
||
beats,
|
||
warnings,
|
||
});
|
||
}
|
||
if (!candidates.length) throw Error("没有可用片段,请关闭去静音或更换素材");
|
||
let order = candidates.map((c) => c.id);
|
||
if (params.semantic && params.mode !== "sequence") {
|
||
const images: string[] = [];
|
||
for (const candidate of candidates) {
|
||
await lifecycle.assertActive();
|
||
const asset = assets.find((a) => a.id === candidate.assetId)!;
|
||
const media = await materialize(asset.url, userId);
|
||
if (media.hash !== asset.hash) throw Error("原素材内容已变化,请重新解析");
|
||
await prepareAnalysis(media.file, media.hash, media.dir, false);
|
||
const frame = path.join(dir, `${images.length}.jpg`);
|
||
await ffmpeg([
|
||
"-ss",
|
||
String((candidate.start + candidate.end) / 2 / SECOND),
|
||
"-i",
|
||
path.join(media.dir, media.hash + ".proxy.mp4"),
|
||
"-frames:v",
|
||
"1",
|
||
"-vf",
|
||
"scale=320:180:force_original_aspect_ratio=decrease",
|
||
frame,
|
||
]);
|
||
images.push("data:image/jpeg;base64," + (await readFile(frame)).toString("base64"));
|
||
}
|
||
await save({ phase: "selecting", stage: "千问智能选片" });
|
||
try {
|
||
order = (
|
||
await selectEditCandidates(
|
||
candidates,
|
||
images,
|
||
params.prompt,
|
||
params.preferredModelRef,
|
||
)
|
||
).order;
|
||
} catch (error) {
|
||
throw new ProviderTaskUncertainError(
|
||
`智能选片未完成,系统不会自动重复调用:${error instanceof Error ? error.message : "请求中断"}`,
|
||
);
|
||
}
|
||
}
|
||
const plans = buildEditPlans(params, assets, candidates, order, beats, warnings);
|
||
await save({ phase: "selected", stage: "剪辑方案已生成", plans });
|
||
return { plans, summary: "剪辑方案已生成" };
|
||
}
|
||
if (params.operation === "separate" || params.operation === "transcribe") {
|
||
if (state.phase === "selected" && state.plans) return { plans: state.plans };
|
||
const plan = await processEditAudio(
|
||
params.operation,
|
||
params.plan,
|
||
params.operation === "separate" ? params.clipId : undefined,
|
||
userId,
|
||
dir,
|
||
(stage) => save({ stage }),
|
||
upload,
|
||
params.operation === "separate" ? params.track : "video",
|
||
params.operation === "separate" ? params.keep : "both",
|
||
);
|
||
await save({ phase: "selected", stage: "音频处理完成", plans: [plan] });
|
||
return { plans: [plan] };
|
||
}
|
||
const plan = editPlanSchema.parse(params.plan);
|
||
planTimeline(plan);
|
||
const rendered = await renderJob(plan, userId, dir, (stage) => save({ stage }));
|
||
const actual = await probeMedia(rendered.file, 300.2);
|
||
if (Math.abs(actual.duration / SECOND - rendered.duration) > 0.15)
|
||
throw Error("导出时长与剪辑方案不一致,未发布结果");
|
||
await save({ stage: "转存成片" });
|
||
const url = await upload(rendered.file, "mp4"),
|
||
subtitleUrl = await upload(rendered.srt, "srt");
|
||
return {
|
||
url,
|
||
subtitleUrl,
|
||
plan,
|
||
metadata: {
|
||
width: rendered.width,
|
||
height: rendered.height,
|
||
durationSeconds: actual.duration / SECOND,
|
||
storageStatus: "archived",
|
||
},
|
||
};
|
||
} finally {
|
||
await rm(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
}
|