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, dir: string, requestKey: string = dir, progress?: (stage: string) => Promise ): Promise { 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; }