197 lines
6.3 KiB
TypeScript
197 lines
6.3 KiB
TypeScript
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) {
|
||
await exec(
|
||
"ffmpeg",
|
||
[
|
||
"-nostdin",
|
||
"-v",
|
||
"error",
|
||
"-y",
|
||
"-threads",
|
||
"2",
|
||
"-filter_threads",
|
||
"1",
|
||
"-filter_complex_threads",
|
||
"1",
|
||
...args,
|
||
],
|
||
{ cwd, timeout: 240000, maxBuffer: 2 * 1024 * 1024 },
|
||
);
|
||
}
|
||
export async function probeMedia(file: string) {
|
||
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 > 180 * SECOND)
|
||
throw Error("单个素材需在 0–180 秒以内");
|
||
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,
|
||
): Promise<LocalAnalysis> {
|
||
const key = createHash("sha256").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)) return value;
|
||
} catch {}
|
||
await writeFile(input, JSON.stringify(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);
|
||
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;
|
||
}
|