package com.fuyuanshen.app.controller; import com.fuyuanshen.common.core.domain.R; import com.fuyuanshen.common.web.core.BaseController; import lombok.RequiredArgsConstructor; import org.bytedeco.javacv.FFmpegFrameGrabber; import org.bytedeco.javacv.Frame; import org.bytedeco.javacv.Java2DFrameUtils; import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; 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.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; import java.util.List; /** * APP 视频处理 * @date 2025-09-15 */ @Validated @RequiredArgsConstructor @RestController @RequestMapping("/app/video") public class AppVideoController extends BaseController { // 可配置项:建议从 application.yml 中读取 private static final int MAX_VIDEO_SIZE = 10 * 1024 * 1024; // 10 MB private static final int FRAME_RATE = 15; // 每秒抽15帧 private static final int DURATION = 2; // 抽2秒 private static final int TOTAL_FRAMES = FRAME_RATE * DURATION; private static final int WIDTH = 160; private static final int HEIGHT = 80; private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public R> upload(@RequestParam("file") MultipartFile file) { if (file == null || file.isEmpty()) { return R.fail("上传文件不能为空"); } if (!isVideo(file.getOriginalFilename())) { return R.fail("只允许上传视频文件"); } if (file.getSize() > MAX_VIDEO_SIZE) { return R.fail("视频大小不能超过10MB"); } File tempFile = null; try { // 创建临时文件保存上传的视频 tempFile = createTempVideoFile(file); List frames = extractFramesFromVideo(tempFile); if (frames.isEmpty()) { return R.fail("无法提取任何帧"); } // ✅ 新增:保存帧为图片 //saveFramesToLocal(frames, "extracted_frame"); byte[] binaryData = convertFramesToRGB565(frames); // String base64Data = Base64.getEncoder().encodeToString(binaryData); // // return R.ok(base64Data); // 构造响应头 // 将二进制数据转为 Hex 字符串 // 转换为 Hex 字符串列表 List hexList = bytesToHexList(binaryData); return R.ok(hexList); } catch (Exception e) { return R.fail("视频处理失败:" + e.getMessage()); } finally { deleteTempFile(tempFile); } } /** * rgb565 转 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; } /** * 创建临时文件并保存上传的视频 */ private File createTempVideoFile(MultipartFile file) throws Exception { File tempFile = Files.createTempFile("upload-", ".mp4").toFile(); file.transferTo(tempFile); return tempFile; } /** * 从视频中按时间均匀提取指定数量的帧 */ private List extractFramesFromVideo(File videoFile) throws Exception { List frames = new ArrayList<>(); try (FFmpegFrameGrabber grabber = FFmpegFrameGrabber.createDefault(videoFile)) { grabber.start(); // 获取视频总帧数和帧率 long totalFramesInVideo = grabber.getLengthInFrames(); 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 / TOTAL_FRAMES; for (int i = 0; i < TOTAL_FRAMES; 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 = Java2DFrameUtils.toBufferedImage(frame); frames.add(cropImage(bufferedImage, WIDTH, HEIGHT)); } else { throw new IllegalArgumentException("无法获取第 " + targetFrameNumber + "帧 "); } } grabber.stop(); } return frames; } /** * 将抽取的帧保存到本地,用于调试 */ private 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); System.out.println("保存帧图片成功: " + outputImage.getAbsolutePath()); } catch (Exception e) { throw new IllegalArgumentException("保存帧图片失败 " + e); } } } /** * 将所有帧转换为 RGB565 格式字节数组 */ private byte[] convertFramesToRGB565(List frames) throws Exception { ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); for (BufferedImage image : frames) { byte[] rgb565Bytes = convertToRGB565(image); byteArrayOutputStream.write(rgb565Bytes); } return byteArrayOutputStream.toByteArray(); } /** * 判断是否是支持的视频格式 */ private boolean isVideo(String filename) { String ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); return Arrays.asList(".mp4", ".avi", ".mov", ".mkv").contains(ext); } /** * 裁剪图像到目标尺寸 */ 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]; // RGB565: 2 bytes per pixel 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); // High byte first result[index++] = (byte) pixel; } } return result; } /** * 删除临时文件 */ private void deleteTempFile(File file) { if (file != null && file.exists()) { if (!file.delete()) { throw new IllegalArgumentException("无法删除临时文件: " + file.getAbsolutePath()); } } } }