ai-chatbot/lib/ai/canvas-agent-runtime/video-media-functions.ts

99 lines
3.9 KiB
TypeScript

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