99 lines
3.9 KiB
TypeScript
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 });
|
|
}
|
|
}
|