ai-chatbot/lib/video-canvas/media-processing.ts

214 lines
6.4 KiB
TypeScript

import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { toVideoTimeRange, type VideoTimeRange } from "./media-contract";
export type MediaRect = { x: number; y: number; width: number; height: number };
export function toVideoPixelRect(rect: MediaRect, width: number, height: number) {
if (
![rect.x, rect.y, rect.width, rect.height, width, height].every(Number.isFinite) ||
rect.x < 0 ||
rect.y < 0 ||
rect.width <= 0 ||
rect.height <= 0 ||
rect.x + rect.width > 1.001 ||
rect.y + rect.height > 1.001
)
throw new Error("裁剪区域无效");
const even = (n: number) => Math.floor(n / 2) * 2;
const x = even(rect.x * width),
y = even(rect.y * height);
const w = Math.min(even(rect.width * width), even(width - x)),
h = Math.min(even(rect.height * height), even(height - y));
if (w < 2 || h < 2) throw new Error("裁剪区域太小");
return { x, y, width: w, height: h };
}
const run = promisify(execFile);
const inputSafety = [
"-protocol_whitelist",
"file",
"-format_whitelist",
"mov,matroska,webm,avi,mpeg,mpegts,flv,ogg",
];
export async function processVideoFile(
input: string,
output: string,
operation: "crop" | "extract-audio" | "silent-video" | "trim" | "frame" | "slow",
rect?: MediaRect,
range?: VideoTimeRange,
options?: { kind?: "first" | "last" | "current"; time?: number; factor?: number },
) {
const { stdout } = await run(
"ffprobe",
["-v", "error", ...inputSafety, "-show_streams", "-show_format", "-of", "json", input],
{ timeout: 15000, maxBuffer: 1024 * 1024 },
);
const info = JSON.parse(stdout),
video = info.streams?.find((s: any) => s.codec_type === "video");
if (!video) throw new Error("素材中没有视频画面");
if (
!(Number(video.width) > 0 && Number(video.height) > 0) ||
Number(video.width) * Number(video.height) > 33554432
)
throw new Error("视频尺寸不受支持,请使用 8K 以内的视频");
const duration = Number(video.duration || info.format?.duration || 0);
if (!Number.isFinite(duration) || duration <= 0 || duration > 600)
throw new Error("本地视频处理支持 10 分钟以内的视频");
let args: string[];
let dimensions = { width: Number(video.width), height: Number(video.height) };
let outputDuration = duration;
if (operation === "frame") {
const [numerator, denominator] = String(video.avg_frame_rate || "30/1")
.split("/")
.map(Number);
const fps = numerator / denominator;
const last = Math.max(0, duration - 1 / (Number.isFinite(fps) && fps > 0 ? fps : 30));
const time =
options?.kind === "last"
? last
: options?.kind === "current"
? Math.min(last, Math.max(0, Number(options.time) || 0))
: 0;
args = ["-ss", String(time), "-map", "0:v:0", "-frames:v", "1", "-an", "-c:v", "png"];
outputDuration = 0;
} else if (operation === "slow") {
const factor = options?.factor;
if (!factor || ![2, 3, 5].includes(factor)) throw Error("慢放倍数无效");
outputDuration = duration * factor;
if (outputDuration > 600) throw Error("慢放后视频不能超过 10 分钟");
let speed = 1 / factor;
const filters: string[] = [];
while (speed < 0.5) {
filters.push("atempo=0.5");
speed /= 0.5;
}
filters.push(`atempo=${speed}`);
args = [
"-map",
"0:v:0",
"-map",
"0:a?",
"-vf",
`setpts=${factor}*(PTS-STARTPTS),scale=trunc(iw/2)*2:trunc(ih/2)*2`,
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"20",
"-pix_fmt",
"yuv420p",
];
if (info.streams.some((stream: any) => stream.codec_type === "audio"))
args.push("-af", filters.join(","), "-c:a", "aac");
args.push("-movflags", "+faststart");
dimensions = {
width: Math.floor(dimensions.width / 2) * 2,
height: Math.floor(dimensions.height / 2) * 2,
};
} else if (operation === "extract-audio") {
if (!info.streams.some((s: any) => s.codec_type === "audio"))
throw new Error("原视频不包含音轨");
args = ["-map", "0:a:0", "-vn", "-c:a", "aac", "-b:a", "192k"];
} else if (operation === "silent-video") {
args = ["-map", "0:v:0", "-an", "-c:v", "copy", "-movflags", "+faststart"];
} else if (operation === "trim") {
if (!range) throw new Error("请选择剪辑范围");
const selected = toVideoTimeRange(range, duration);
outputDuration = selected.duration;
dimensions = {
width: Math.floor(dimensions.width / 2) * 2,
height: Math.floor(dimensions.height / 2) * 2,
};
args = [
"-ss",
String(selected.start),
"-t",
String(selected.duration),
"-map",
"0:v:0",
"-map",
"0:a?",
"-vf",
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"20",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-movflags",
"+faststart",
];
} else {
if (!rect) throw new Error("请选择裁剪区域");
const crop = toVideoPixelRect(rect, dimensions.width, dimensions.height);
dimensions = crop;
args = [
"-map",
"0:v:0",
"-map",
"0:a?",
"-vf",
`crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}`,
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"20",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-movflags",
"+faststart",
];
}
await run(
"ffmpeg",
[
"-nostdin",
"-y",
"-v",
"error",
"-threads",
"2",
"-filter_threads",
"1",
...inputSafety,
"-i",
input,
...args,
"-threads",
"2",
output,
],
{ timeout: 180000, maxBuffer: 1024 * 1024 },
);
if (operation === "frame") {
const probe = await run(
"ffprobe",
[
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"json",
output,
],
{ timeout: 15000, maxBuffer: 1024 * 1024 },
);
const frame = JSON.parse(probe.stdout).streams?.[0];
if (!frame?.width || !frame?.height) throw Error("未截取到有效视频帧");
dimensions = { width: Number(frame.width), height: Number(frame.height) };
}
return { duration: outputDuration, ...dimensions };
}