263 lines
7.2 KiB
TypeScript
263 lines
7.2 KiB
TypeScript
import { remoteAudio, remoteAudioConfig } from "./remote-audio";
|
||
import { execFile } from "node:child_process";
|
||
import { promisify } from "node:util";
|
||
import {
|
||
readFile,
|
||
writeFile,
|
||
mkdir,
|
||
stat,
|
||
rename,
|
||
readdir,
|
||
rm,
|
||
} from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { createHash, randomUUID } from "node:crypto";
|
||
import { downloadCanvasMedia } from "../download-media";
|
||
import { SECOND, subtitleSchema, type EditAsset } from "./contract";
|
||
const exec = promisify(execFile);
|
||
export const mediaSafety = [
|
||
"-protocol_whitelist",
|
||
"file",
|
||
"-format_whitelist",
|
||
"mov,matroska,webm,avi,mpeg,mpegts,flv,ogg,wav,mp3,flac,aac",
|
||
];
|
||
export async function ffmpeg(args: string[], cwd?: string, timeout = 240000) {
|
||
await exec(
|
||
"ffmpeg",
|
||
[
|
||
"-nostdin",
|
||
"-v",
|
||
"error",
|
||
"-y",
|
||
"-threads",
|
||
"2",
|
||
"-filter_threads",
|
||
"1",
|
||
"-filter_complex_threads",
|
||
"1",
|
||
...args,
|
||
],
|
||
{ cwd, timeout, maxBuffer: 2 * 1024 * 1024 }
|
||
);
|
||
}
|
||
export async function probeMedia(file: string, maxSeconds = 180) {
|
||
const { stdout } = await exec(
|
||
"ffprobe",
|
||
[
|
||
"-v",
|
||
"error",
|
||
...mediaSafety,
|
||
"-show_streams",
|
||
"-show_format",
|
||
"-of",
|
||
"json",
|
||
file,
|
||
],
|
||
{ timeout: 15000, maxBuffer: 1024 * 1024 }
|
||
);
|
||
const info = JSON.parse(stdout),
|
||
v = info.streams?.find((s: any) => s.codec_type === "video");
|
||
const duration = Math.round(
|
||
Number(info.format?.duration || v?.duration) * SECOND
|
||
);
|
||
if (
|
||
!Number.isFinite(duration) ||
|
||
duration <= 0 ||
|
||
duration > maxSeconds * SECOND
|
||
)
|
||
throw Error(`媒体时长需在 0–${maxSeconds} 秒以内`);
|
||
if (v && (!v.width || !v.height || v.width * v.height > 33554432))
|
||
throw Error("素材画面尺寸超过处理上限");
|
||
return {
|
||
duration,
|
||
width: Number(v?.width || 0),
|
||
height: Number(v?.height || 0),
|
||
hasAudio: info.streams?.some((s: any) => s.codec_type === "audio") === true,
|
||
};
|
||
}
|
||
export function cacheRoot() {
|
||
return path.resolve(process.env.SMART_EDIT_CACHE_DIR || ".cache/smart-edit");
|
||
}
|
||
export async function materialize(url: string, userId: string) {
|
||
const root = cacheRoot();
|
||
await mkdir(root, { recursive: true, mode: 0o700 });
|
||
const user = createHash("sha256").update(userId).digest("hex").slice(0, 20);
|
||
const dir = path.join(root, user);
|
||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||
const key = createHash("sha256").update(url).digest("hex");
|
||
const mapFile = path.join(dir, key + ".json");
|
||
try {
|
||
const map = JSON.parse(await readFile(mapFile, "utf8"));
|
||
if (
|
||
/^[a-f0-9]{64}$/.test(map.hash) &&
|
||
Date.now() - map.created < 86400000
|
||
) {
|
||
const file = path.join(dir, map.hash);
|
||
await stat(file);
|
||
return { file, hash: map.hash as string, dir };
|
||
}
|
||
} catch {}
|
||
const temp = path.join(dir, randomUUID() + ".part");
|
||
try {
|
||
await downloadCanvasMedia(url, temp);
|
||
const bytes = await readFile(temp);
|
||
const hash = createHash("sha256").update(bytes).digest("hex"),
|
||
file = path.join(dir, hash);
|
||
await rename(temp, file);
|
||
await writeFile(mapFile, JSON.stringify({ hash, created: Date.now() }), {
|
||
mode: 0o600,
|
||
});
|
||
return { file, hash, dir };
|
||
} finally {
|
||
await rm(temp, { force: true });
|
||
}
|
||
}
|
||
export async function pruneSmartEditCache() {
|
||
const root = cacheRoot();
|
||
await mkdir(root, { recursive: true, mode: 0o700 });
|
||
const files: { file: string; size: number; mtime: number }[] = [];
|
||
for (const name of await readdir(root)) {
|
||
if (!/^[a-f0-9]{20}$/.test(name)) continue;
|
||
const dir = path.join(root, name);
|
||
for (const item of await readdir(dir)) {
|
||
const file = path.join(dir, item),
|
||
s = await stat(file);
|
||
if (s.isFile()) files.push({ file, size: s.size, mtime: s.mtimeMs });
|
||
}
|
||
}
|
||
let total = files.reduce((sum, f) => sum + f.size, 0);
|
||
for (const f of files.sort((a, b) => a.mtime - b.mtime))
|
||
if (Date.now() - f.mtime > 7 * 86400000 || total > 4 * 1024 ** 3) {
|
||
await rm(f.file, { force: true });
|
||
total -= f.size;
|
||
}
|
||
}
|
||
export type LocalAnalysis = {
|
||
scenes: number[][];
|
||
active: number[][];
|
||
subtitles: EditAsset["subtitles"];
|
||
beats: number[];
|
||
warnings: string[];
|
||
};
|
||
export async function pythonAnalysis(
|
||
data: Record<string, unknown>,
|
||
dir: string,
|
||
requestKey: string = dir,
|
||
progress?: (stage: string) => Promise<void>
|
||
): Promise<LocalAnalysis> {
|
||
const remote = data.transcribe && data.audio ? remoteAudioConfig() : null;
|
||
const key = createHash("sha256")
|
||
.update("analysis-v4:" + (remote?.base || "local") + ":")
|
||
.update(JSON.stringify(data))
|
||
.digest("hex"),
|
||
input = path.join(dir, key + ".analysis-input.json"),
|
||
output = path.join(dir, key + ".analysis.json");
|
||
try {
|
||
const value = JSON.parse(await readFile(output, "utf8"));
|
||
if (
|
||
!value.error &&
|
||
Array.isArray(value.scenes) &&
|
||
(!remote || value.remoteAudioDone === true)
|
||
)
|
||
return value;
|
||
} catch {}
|
||
await writeFile(
|
||
input,
|
||
JSON.stringify(remote ? { ...data, transcribe: false } : data),
|
||
{ mode: 0o600 }
|
||
);
|
||
try {
|
||
await exec(
|
||
process.env.SMART_EDIT_PYTHON || "python3",
|
||
[path.resolve("services/smart-edit/analyze.py"), input, output],
|
||
{
|
||
timeout: 300000,
|
||
maxBuffer: 4 * 1024 * 1024,
|
||
env: {
|
||
...process.env,
|
||
OMP_NUM_THREADS: "2",
|
||
OPENBLAS_NUM_THREADS: "2",
|
||
},
|
||
}
|
||
);
|
||
} catch {
|
||
const value = JSON.parse(
|
||
await readFile(output, "utf8").catch(
|
||
() => '{"error":"分析运行环境不可用,请安装 services/smart-edit 依赖"}'
|
||
)
|
||
);
|
||
throw Error(value.error || "素材分析失败或超时");
|
||
}
|
||
const value = JSON.parse(await readFile(output, "utf8"));
|
||
if (value.error) throw Error(value.error);
|
||
if (remote) {
|
||
const result = await remoteAudio(
|
||
"transcribe",
|
||
String(data.audio),
|
||
dir,
|
||
requestKey,
|
||
dir,
|
||
progress
|
||
);
|
||
value.subtitles = result.subtitles;
|
||
value.remoteAudioDone = true;
|
||
await writeFile(output, JSON.stringify(value), { mode: 0o600 });
|
||
}
|
||
value.subtitles = (value.subtitles || [])
|
||
.map((s: unknown) => subtitleSchema.parse(s))
|
||
.slice(0, 500);
|
||
return value;
|
||
}
|
||
export async function prepareAnalysis(
|
||
file: string,
|
||
hash: string,
|
||
dir: string,
|
||
hasAudio: boolean
|
||
) {
|
||
const proxy = path.join(dir, hash + ".proxy.mp4"),
|
||
audio = path.join(dir, hash + ".wav");
|
||
try {
|
||
await stat(proxy);
|
||
} catch {
|
||
const temp = proxy + ".part.mp4";
|
||
await ffmpeg([
|
||
...mediaSafety,
|
||
"-i",
|
||
file,
|
||
"-an",
|
||
"-vf",
|
||
"scale=640:360:force_original_aspect_ratio=decrease,fps=15",
|
||
"-c:v",
|
||
"libx264",
|
||
"-preset",
|
||
"ultrafast",
|
||
temp,
|
||
]);
|
||
await rename(temp, proxy);
|
||
}
|
||
if (hasAudio) await prepareAudio(file, audio);
|
||
return { proxy, audio: hasAudio ? audio : undefined };
|
||
}
|
||
export async function prepareAudio(file: string, audio: string) {
|
||
try {
|
||
await stat(audio);
|
||
} catch {
|
||
const temp = audio + ".part.wav";
|
||
await ffmpeg([
|
||
...mediaSafety,
|
||
"-i",
|
||
file,
|
||
"-vn",
|
||
"-ac",
|
||
"1",
|
||
"-ar",
|
||
"22050",
|
||
"-c:a",
|
||
"pcm_s16le",
|
||
temp,
|
||
]);
|
||
await rename(temp, audio);
|
||
}
|
||
return audio;
|
||
}
|