263 lines
7.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}