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 processVideoToHex(File videoFile, int frameRate, int duration, int width, int height, int code) throws Exception { // 1. 提取视频帧 List 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 extractFramesFromVideo(File videoFile, int frameRate, int duration, int width, int height) throws Exception { // 初始化帧列表 List 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 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 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 bytesToHexList(byte[] bytes) { List 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 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); } } }