214 lines
6.4 KiB
TypeScript
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 };
|
|
}
|