# Conflicts: # fys-admin/src/main/java/com/fuyuanshen/app/controller/AppVideoController.java
358 lines
13 KiB
Java
358 lines
13 KiB
Java
package com.fuyuanshen.web.util;
|
||
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.bytedeco.javacv.FFmpegFrameGrabber;
|
||
import org.bytedeco.javacv.Frame;
|
||
import org.bytedeco.javacv.Java2DFrameUtils;
|
||
import org.springframework.stereotype.Component;
|
||
import org.springframework.web.multipart.MultipartFile;
|
||
|
||
import javax.imageio.ImageIO;
|
||
import java.awt.image.BufferedImage;
|
||
import java.io.ByteArrayOutputStream;
|
||
import java.io.File;
|
||
import java.io.IOException;
|
||
import java.io.OutputStream;
|
||
import java.nio.file.Files;
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* 视频处理工具类
|
||
*/
|
||
@Slf4j
|
||
@Component
|
||
public class VideoProcessUtil {
|
||
|
||
private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
|
||
|
||
/**
|
||
* 创建临时视频文件
|
||
*/
|
||
public File createTempVideoFile(MultipartFile file) throws Exception {
|
||
File tempFile = Files.createTempFile("upload-", ".mp4").toFile();
|
||
file.transferTo(tempFile);
|
||
log.debug("创建临时视频文件: {}", tempFile.getAbsolutePath());
|
||
return tempFile;
|
||
}
|
||
|
||
/**
|
||
* 处理视频并转换为Hex字符串列表
|
||
*/
|
||
public List<String> processVideoToHex(File videoFile, int frameRate, int duration, int width, int height, int code) throws Exception {
|
||
// 1. 提取视频帧
|
||
List<BufferedImage> frames = extractFramesFromVideo(videoFile, frameRate, duration, width, height);
|
||
|
||
if (code == 1) {
|
||
// 1. 转换为RGB565格式
|
||
byte[] binaryData = convertFramesToRGB565(frames);
|
||
|
||
// 2. 转换为Hex字符串列表
|
||
return bytesToHexList(binaryData);
|
||
} else {
|
||
// 1. 转换为BGR565格式
|
||
byte[] binaryData = convertFramesToBGR565(frames);
|
||
|
||
// 新增:直接生成 mp4
|
||
//bgr565ToMp4(binaryData, width, height, frameRate, "output.mp4");
|
||
|
||
// 2. 转换为Hex字符串列表
|
||
return bytesToHexList(binaryData);
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 从视频中提取帧
|
||
*
|
||
* @param videoFile 视频文件对象
|
||
* @param frameRate 每秒提取的帧数(帧率)
|
||
* @param duration 需要提取的视频时长(秒)
|
||
* @param width 提取帧的宽度
|
||
* @param height 提取帧的高度
|
||
* @return 提取的帧图像列表
|
||
* @throws Exception 如果在提取过程中发生错误
|
||
*/
|
||
private List<BufferedImage> extractFramesFromVideo(File videoFile, int frameRate, int duration, int width, int height) throws Exception {
|
||
// 初始化帧列表
|
||
List<BufferedImage> frames = new ArrayList<>();
|
||
// 计算需要提取的总帧数 = 帧率 × 时长
|
||
int totalFramesToExtract = frameRate * duration;
|
||
|
||
// 使用FFmpegFrameGrabber从视频文件中抓取帧
|
||
try (FFmpegFrameGrabber grabber = FFmpegFrameGrabber.createDefault(videoFile)) {
|
||
// 启动抓取器
|
||
grabber.start();
|
||
|
||
// 获取视频总帧数
|
||
long totalFramesInVideo = grabber.getLengthInFrames();
|
||
// 获取视频帧率,如果获取不到则默认为30fps
|
||
int fps = (int) Math.round(grabber.getFrameRate());
|
||
if (fps <= 0) fps = 30;
|
||
|
||
// 计算视频总时长(秒)
|
||
double durationSeconds = (double) totalFramesInVideo / fps;
|
||
// 检查视频时长是否满足要求
|
||
if (durationSeconds < duration) {
|
||
throw new IllegalArgumentException("视频太短,至少需要 " + duration + " 秒");
|
||
}
|
||
|
||
// 计算帧间隔,用于均匀分布提取的帧
|
||
double frameInterval = (double) totalFramesInVideo / totalFramesToExtract;
|
||
|
||
// 循环提取指定数量的帧
|
||
for (int i = 0; i < totalFramesToExtract; i++) {
|
||
// 计算目标帧号
|
||
int targetFrameNumber = (int) Math.round(i * frameInterval);
|
||
|
||
// 检查目标帧号是否超出视频范围
|
||
if (targetFrameNumber >= totalFramesInVideo) {
|
||
throw new IllegalArgumentException("目标帧超出范围: " + targetFrameNumber);
|
||
}
|
||
|
||
// 设置抓取器到目标帧
|
||
grabber.setFrameNumber(targetFrameNumber);
|
||
// 抓取当前帧
|
||
Frame frame = grabber.grab();
|
||
|
||
// 如果成功抓取到帧且帧图像不为空
|
||
if (frame != null && frame.image != null) {
|
||
// 将帧转换为BufferedImage并裁剪到指定尺寸
|
||
BufferedImage bufferedImage = Java2DFrameUtils.toBufferedImage(frame);
|
||
frames.add(cropImage(bufferedImage, width, height));
|
||
} else {
|
||
// 如果无法获取帧则抛出异常
|
||
throw new IllegalArgumentException("无法获取第 " + targetFrameNumber + "帧");
|
||
}
|
||
}
|
||
|
||
// 停止抓取器
|
||
grabber.stop();
|
||
}
|
||
|
||
// 记录提取的帧数
|
||
log.debug("从视频中提取了 {} 帧", frames.size());
|
||
// 返回提取的帧列表
|
||
return frames;
|
||
}
|
||
|
||
|
||
/**
|
||
* 将所有帧转换为 RGB565 格式字节数组
|
||
*/
|
||
private byte[] convertFramesToRGB565(List<BufferedImage> frames) throws Exception {
|
||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||
|
||
for (BufferedImage image : frames) {
|
||
byte[] rgb565Bytes = convertToRGB565(image);
|
||
byteArrayOutputStream.write(rgb565Bytes);
|
||
}
|
||
|
||
byte[] result = byteArrayOutputStream.toByteArray();
|
||
log.debug("转换RGB565数据完成,总字节数: {}", result.length);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 将所有帧转换为 BGR565 格式字节数组
|
||
*/
|
||
private byte[] convertFramesToBGR565(List<BufferedImage> frames) throws Exception {
|
||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||
|
||
for (BufferedImage image : frames) {
|
||
byte[] bgr565Bytes = convertToBGR565(image);
|
||
byteArrayOutputStream.write(bgr565Bytes);
|
||
}
|
||
|
||
byte[] result = byteArrayOutputStream.toByteArray();
|
||
log.debug("转换BGR565数据完成,总字节数: {}", result.length);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 将BufferedImage转换为真正的BGR565格式字节数组
|
||
*/
|
||
private byte[] convertToBGR565(BufferedImage image) {
|
||
int width = image.getWidth();
|
||
int height = image.getHeight();
|
||
byte[] bgr565Data = new byte[width * height * 2];
|
||
|
||
int index = 0;
|
||
for (int y = 0; y < height; y++) {
|
||
for (int x = 0; x < width; x++) {
|
||
int rgb = image.getRGB(x, y);
|
||
|
||
// 提取RGB分量
|
||
int red = (rgb >> 16) & 0xFF;
|
||
int green = (rgb >> 8) & 0xFF;
|
||
int blue = rgb & 0xFF;
|
||
|
||
int b = (blue >> 3) & 0x1F; // 5位蓝色
|
||
int g = (green >> 2) & 0x3F; // 6位绿色
|
||
int r = (red >> 3) & 0x1F; // 5位红色
|
||
|
||
// 正确的BGR565组合:红色在高位,蓝色在低位
|
||
int bgr565 = (b << 11) | (g << 5) | r;
|
||
|
||
bgr565Data[index++] = (byte) ((bgr565 >> 8) & 0xFF);
|
||
// 小端序存储
|
||
bgr565Data[index++] = (byte) (bgr565 & 0xFF);
|
||
}
|
||
}
|
||
return bgr565Data;
|
||
}
|
||
|
||
/**
|
||
* 将字节数组转换为Hex字符串列表
|
||
*/
|
||
private List<String> bytesToHexList(byte[] bytes) {
|
||
List<String> hexList = new ArrayList<>();
|
||
for (byte b : bytes) {
|
||
int value = b & 0xFF;
|
||
char high = HEX_ARRAY[value >>> 4];
|
||
char low = HEX_ARRAY[value & 0x0F];
|
||
hexList.add(String.valueOf(high) + low);
|
||
}
|
||
return hexList;
|
||
}
|
||
|
||
/**
|
||
* 删除临时文件
|
||
*/
|
||
public void deleteTempFile(File file) {
|
||
if (file != null && file.exists()) {
|
||
if (file.delete()) {
|
||
log.debug("删除临时文件成功: {}", file.getAbsolutePath());
|
||
} else {
|
||
log.warn("无法删除临时文件: {}", file.getAbsolutePath());
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 裁剪图像到目标尺寸
|
||
*/
|
||
private BufferedImage cropImage(BufferedImage img, int targetWidth, int targetHeight) {
|
||
int w = Math.min(img.getWidth(), targetWidth);
|
||
int h = Math.min(img.getHeight(), targetHeight);
|
||
return img.getSubimage(0, 0, w, h);
|
||
}
|
||
|
||
/**
|
||
* 将 BufferedImage 转换为 RGB565 格式的字节数组
|
||
*/
|
||
private byte[] convertToRGB565(BufferedImage image) {
|
||
int width = image.getWidth();
|
||
int height = image.getHeight();
|
||
byte[] result = new byte[width * height * 2];
|
||
int index = 0;
|
||
|
||
for (int y = 0; y < height; y++) {
|
||
for (int x = 0; x < width; x++) {
|
||
int rgb = image.getRGB(x, y);
|
||
int r = ((rgb >> 16) & 0xFF) >> 3;
|
||
int g = ((rgb >> 8) & 0xFF) >> 2;
|
||
int b = (rgb & 0xFF) >> 3;
|
||
short pixel = (short) ((r << 11) | (g << 5) | b);
|
||
|
||
result[index++] = (byte) (pixel >> 8);
|
||
result[index++] = (byte) pixel;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 保存帧到本地(用于调试)
|
||
*/
|
||
public void saveFramesToLocal(List<BufferedImage> frames, String prefix) {
|
||
File outputDir = new File("output_frames");
|
||
if (!outputDir.exists()) {
|
||
outputDir.mkdirs();
|
||
}
|
||
|
||
int index = 0;
|
||
for (BufferedImage frame : frames) {
|
||
try {
|
||
File outputImage = new File(outputDir, prefix + "_" + (index++) + ".png");
|
||
ImageIO.write(frame, "png", outputImage);
|
||
log.debug("保存帧图片成功: {}", outputImage.getAbsolutePath());
|
||
} catch (Exception e) {
|
||
log.error("保存帧图片失败", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把 BGR565 字节流直接写成 MP4(H.264)
|
||
* @param bgr565 完整的 BGR565 裸帧流(每像素 2 字节)
|
||
* @param width 帧宽
|
||
* @param height 帧高
|
||
* @param fps 帧率
|
||
* @param outMp4 输出 mp4 文件绝对路径
|
||
* @throws IOException 进程启动 / IO 失败
|
||
*/
|
||
public static void bgr565ToMp4(byte[] bgr565,
|
||
int width,
|
||
int height,
|
||
int fps,
|
||
String outMp4) throws IOException {
|
||
|
||
int framePixels = width * height;
|
||
int frameBytes = framePixels * 2;
|
||
if (bgr565.length % frameBytes != 0) {
|
||
throw new IllegalArgumentException("字节数组长度不是整帧");
|
||
}
|
||
|
||
/* 1. 构造 FFmpeg 命令 */
|
||
String[] cmd = {
|
||
"ffmpeg",
|
||
"-y", // 覆盖输出
|
||
"-f", "rawvideo",
|
||
"-pixel_format", "bgr24",
|
||
"-video_size", width + "x" + height,
|
||
"-framerate", String.valueOf(fps),
|
||
"-i", "-", // 从 stdin 读
|
||
"-c:v", "libx264",
|
||
"-pix_fmt", "yuv420p",
|
||
"-crf", "23", // 画质可自己调
|
||
outMp4
|
||
};
|
||
|
||
/* 2. 启动进程 */
|
||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||
pb.redirectError(ProcessBuilder.Redirect.INHERIT); // 把 FFmpeg 日志打到控制台
|
||
Process p = pb.start();
|
||
try (OutputStream ffmpegIn = p.getOutputStream()) {
|
||
|
||
/* 3. 逐帧转换并写入管道 */
|
||
byte[] bgr24 = new byte[framePixels * 3];
|
||
for (int off = 0; off < bgr565.length; off += frameBytes) {
|
||
for (int i = 0, j = 0; i < frameBytes; i += 2, j += 3) {
|
||
int u = ((bgr565[off + i + 1] & 0xFF) << 8)
|
||
| (bgr565[off + i] & 0xFF);
|
||
int b = (u & 0x1F) << 3;
|
||
int g = ((u >> 5) & 0x3F) << 2;
|
||
int r = ((u >> 11) & 0x1F) << 3;
|
||
bgr24[j] = (byte) b;
|
||
bgr24[j + 1] = (byte) g;
|
||
bgr24[j + 2] = (byte) r;
|
||
}
|
||
ffmpegIn.write(bgr24);
|
||
}
|
||
ffmpegIn.flush();
|
||
}
|
||
|
||
/* 4. 等待编码结束 */
|
||
try {
|
||
int exit = p.waitFor();
|
||
if (exit != 0) {
|
||
throw new IOException("FFmpeg 异常退出,code=" + exit);
|
||
}
|
||
} catch (InterruptedException e) {
|
||
Thread.currentThread().interrupt();
|
||
throw new IOException("等待 FFmpeg 被中断", e);
|
||
}
|
||
}
|
||
} |