import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { uploadFile } from "@/lib/storage/service"; import { probeVideoMetadata } from "./video-probe"; export type VideoMediaOperation = "extract-frame" | "trim" | "crop" | "extract-audio" | "mute"; export type VideoMediaInput = { operation: VideoMediaOperation; url: string; time?: number; end?: number; frame?: "first" | "last" | "time"; x?: number; y?: number; width?: number; height?: number; }; const execute = promisify(execFile); export async function processVideoMedia(input: VideoMediaInput, upload = uploadFile) { if (!/^https?:\/\//i.test(input.url)) throw new Error("视频缺少已保存的远程链接"); const metadata = await probeVideoMetadata(input.url); if (metadata.duration > 120) throw new Error("当前 Agent 媒体工具单次支持不超过 120 秒的视频,请先选择短片段"); const directory = await mkdtemp(join(tmpdir(), "canvas-agent-media-")); const type: "image" | "audio" | "video" = input.operation === "extract-frame" ? "image" : input.operation === "extract-audio" ? "audio" : "video"; const extension = type === "image" ? "png" : type === "audio" ? "m4a" : "mp4"; const path = join(directory, `result.${extension}`); try { const args = ["-v", "error", "-nostdin", "-threads", "2"]; let seek = Math.max(0, input.time || 0); if (input.operation === "extract-frame" && input.frame === "last") seek = Math.max(0, metadata.duration - 0.1); if (input.operation === "extract-frame" && input.frame === "first") seek = 0; if (seek >= metadata.duration) throw new Error("截取时间超出真实视频时长"); if (input.operation === "extract-frame" || input.operation === "trim") args.push("-ss", String(seek)); args.push("-protocol_whitelist", "http,https,tcp,tls,crypto", "-i", input.url); if (input.operation === "extract-frame") args.push("-frames:v", "1", "-update", "1"); else if (input.operation === "extract-audio") args.push("-vn", "-c:a", "aac"); else { if (input.operation === "trim") { const end = Math.min(metadata.duration, input.end ?? metadata.duration); if (end <= seek) throw new Error("剪辑结束时间必须大于开始时间"); args.push("-t", String(end - seek)); } if (input.operation === "crop") { const rect = [input.x, input.y, input.width, input.height]; if ( !rect.every((value) => typeof value === "number" && Number.isInteger(value)) || input.x! < 0 || input.y! < 0 || input.width! < 2 || input.height! < 2 || input.x! + input.width! > metadata.width || input.y! + input.height! > metadata.height ) throw new Error("裁剪坐标超出真实视频尺寸"); args.push("-vf", `crop=${input.width}:${input.height}:${input.x}:${input.y}`); } if (input.operation === "mute") args.push("-an"); args.push( "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-movflags", "+faststart", ); } args.push("-y", path); await execute("ffmpeg", args, { timeout: 180_000, maxBuffer: 1024 * 1024 }); const bytes = await readFile(path); if (!bytes.length) throw new Error("媒体处理未产生文件"); const uploaded = await upload( `video-agent-${crypto.randomUUID()}.${extension}`, new Uint8Array(bytes).buffer, ); return { url: uploaded.url, type, duration: input.operation === "trim" ? Math.min(metadata.duration, input.end ?? metadata.duration) - seek : metadata.duration, }; } finally { await rm(directory, { recursive: true, force: true }); } }