一、前言

在短视频平台、在线教育、素材管理、自媒体后台等业务场景中,视频自动截取封面、读取视频时长是非常高频的基础功能。原生Java没有内置视频解析能力,传统方案多调用FFmpeg命令行执行,存在进程开销大、并发阻塞、跨平台兼容差、资源泄漏严重、报错难捕获等问题,完全不适合高并发生产环境。

本文基于 SpringBoot2.x + JDK8 + JavaCV1.5.9 + FFmpeg6.0 技术栈,封装了一套高性能、高可用的视频工具类,彻底解决全网多数教程存在的OOM、堆外内存泄漏、文件句柄耗尽、手机视频截图倒置、FLV/MOV黑屏、并发文件覆盖、跨环境报错等疑难问题,适配Windows开发、Linux生产双环境。

二、技术栈

  • 核心框架:SpringBoot 2.x
  • 运行环境:JDK8
  • 视频解析核心:JavaCV 1.5.9、FFmpeg 6.0-1.5.9
  • 辅助工具:commons-lang3、lombok

纯Java代码无第三方命令行依赖、低内存占用、高并发友好、跨平台适配、异常兜底完善。

三、应用场景

后端视频处理业务,典型场景如下:

  1. 视频素材管理系统:视频上传后自动截取中间帧作为封面图,规避首帧黑屏问题;
  2. 短视频/直播后台:解析视频时长、分辨率,用于参数校验和前端展示;
  3. 在线教育平台:课程视频自动生成缩略图,统一素材展示样式;
  4. 内容审核系统:批量截取视频关键帧,用于AI画面审核;
  5. 文件迁移/归档系统:批量读取视频元信息,完成视频文件合规校验与分类。

四、核心功能

  • 智能中间帧截图:跳过视频开头黑屏帧,帧号精准跳转+时间戳兜底,兼容FLV/MOV无关键帧视频;
  • 自动画面矫正:自动识别视频旋转元数据(90°/180°/270°),旋转后自动扩画布不裁切画面;
  • 防OOM缩放机制:4K/8K超大分辨率视频自动等比例缩放,限制最大宽度,杜绝堆内存溢出;
  • 单机并发安全:自定义30s过期文件锁,自动清理崩溃残留锁,防止多线程并发文件覆盖、图片损坏;
  • 全链路资源释放:分层释放Frame缓存、FFmpeg解码器、IO句柄、图像缓存,零堆外内存泄漏;
  • 双环境适配:Windows开发、Linux生产自动适配,区分刷盘逻辑、路径长度限制;
  • 完善参数校验:文件大小、分辨率、视频格式、非法路径全方位校验,过滤损坏视频;
  • 多格式兼容:支持mp4/mov/avi/mkv/flv/wmv/rmvb/webm主流视频格式;
  • 图片格式可选:支持JPG(可配置压缩质量)、PNG无损截图。

五、Maven依赖配置

采用全局JavaCV核心依赖 + 分环境FFmpeg原生库加载方案,避免打包冗余、环境适配报错,自动适配Windows本地开发、Linux服务器生产环境。

<!-- JavaCV 核心依赖 全局通用 -->
<dependencies>
    <!-- JNI底层桥接核心 -->
    <dependency>
        <groupId>org.bytedeco</groupId>
        <artifactId>javacpp</artifactId>
        <version>1.5.9</version>
    </dependency>
    <!-- JavaCV 上层视频处理封装 -->
    <dependency>
        <groupId>org.bytedeco</groupId>
        <artifactId>javacv</artifactId>
        <version>1.5.9</version>
    </dependency>
</dependencies>

<!-- 分环境加载FFmpeg原生库 自动适配系统 -->
<profiles>
    <!-- Windows本地开发环境 -->
    <profile>
        <id>dev-win</id>
        <activation>
            <os>
                <family>windows</family>
            </os>
        </activation>
        <dependencies>
            <dependency>
                <groupId>org.bytedeco</groupId>
                <artifactId>ffmpeg</artifactId>
                <version>6.0-1.5.9</version>
                <classifier>windows-x86_64</classifier>
            </dependency>
        </dependencies>
    </profile>

    <!-- Linux生产服务器环境 -->
    <profile>
        <id>prod-linux</id>
        <activation>
            <os>
                <family>linux</family>
            </os>
        </activation>
        <dependencies>
            <dependency>
                <groupId>org.bytedeco</groupId>
                <artifactId>ffmpeg</artifactId>
                <version>6.0-1.5.9</version>
                <classifier>linux-x86_64</classifier>
            </dependency>
        </dependencies>
    </profile>
</profiles>

六、核心代码说明

6.1 双策略中间帧截取(解决FLV/MOV黑屏问题)

针对普通视频有帧索引、流式视频无帧索引的差异化场景,采用帧号精准跳转+时间戳兜底双方案,规避中间帧黑屏、截取失效问题,兼容所有主流视频格式。

// 优先按帧跳转;无帧索引则按时间戳兜底(flv/mov无关键帧兼容)
boolean jumpMid = false;
if (captureMidFrame) {
    long totalFrames = grabber.getLengthInFrames();
    long totalMicro = grabber.getLengthInTime();
    // 有帧索引,直接跳转中间帧,精准定位
    if (totalFrames > 0) {
        int midFrameIdx = (int) (totalFrames / 2);
        grabber.setFrameNumber(midFrameIdx);
        grabber.flush();
        jumpMid = true;
    } else if (totalMicro > 0) {
        // 兼容无关键帧视频,使用时间戳兜底跳转
        long midTime = totalMicro / 2;
        grabber.setTimestamp(midTime);
        grabber.flush();
        jumpMid = true;
        log.warn("视频无帧索引,使用时间戳跳转中间帧 path=[{}]", filePath);
    } else {
        log.warn("视频无有效时长/帧数,无法跳转中间帧 path=[{}]", filePath);
    }
}

6.2 视频旋转自动矫正(解决手机视频倒置问题)

自动解析视频旋转元数据,精准计算画面旋转角度,同时动态拓展画布、白底填充,解决90°/180°/270°画面倒置、裁切、偏移问题,适配手机、相机拍摄的非常规视频画面。

/** 读取视频旋转角度 */
private static int parseVideoRotateAngle(FFmpegFrameGrabber grabber) {
    String meta = grabber.getVideoMetadata("rotate");
    if (StringUtils.isBlank(meta)) {
        return 0;
    }
    int angle;
    try {
        angle = Integer.parseInt(meta.trim());
    } catch (NumberFormatException e) {
        log.warn("旋转元数据非数字 value=[{}]", meta);
        return 0;
    }
    // 标准化角度至0-360区间
    angle = (angle % 360 + 360) % 360;
    if (!LEGAL_ROTATE_ANGLES.contains(angle)) {
        log.warn("非法旋转角度{},重置0", angle);
        return 0;
    }
    return angle;
}

// 核心旋转画布适配逻辑,防止画面裁切
private static BufferedImage rotateImage(BufferedImage src, int angle) {
    int w = src.getWidth();
    int h = src.getHeight();
    double rad = Math.toRadians(angle);
    // 计算旋转后四个顶点坐标,动态适配画布大小
    Point2D.Double p1 = rotatePoint(0, 0, w / 2.0, h / 2.0, rad);
    Point2D.Double p2 = rotatePoint(w, 0, w / 2.0, h / 2.0, rad);
    Point2D.Double p3 = rotatePoint(w, h, w / 2.0, h / 2.0, rad);
    Point2D.Double p4 = rotatePoint(0, h, w / 2.0, h / 2.0, rad);

    // 自动扩画布,完整保留画面
    double minX = Math.min(Math.min(p1.x, p2.x), Math.min(p3.x, p4.x));
    double maxX = Math.max(Math.max(p1.x, p2.x), Math.max(p3.x, p4.x));
    double minY = Math.min(Math.min(p1.y, p2.y), Math.min(p3.y, p4.y));
    double maxY = Math.max(Math.max(p1.y, p2.y), Math.max(p3.y, p4.y));

    int destW = (int) Math.ceil(maxX - minX) + 3;
    int destH = (int) Math.ceil(maxY - minY) + 3;
    // 白底填充,无黑边无裁切
    BufferedImage dest = new BufferedImage(destW, destH, src.getType());
    // 省略坐标旋转工具方法,完整源码文末获取
}

6.3 超大视频防OOM缩放机制

针对4K/8K超高分辨率视频,设置分辨率阈值与最大截图宽度,自动等比例压缩画面,限制内存占用上限,彻底解决并发场景下堆内存溢出问题。

/** 等比例缩放图像,防OOM核心逻辑 */
private static BufferedImage scaleImageByMaxWidth(BufferedImage src, int maxWidth, String format) {
    int oriW = src.getWidth();
    // 宽度达标无需缩放,直接返回原图拷贝
    if (maxWidth <= 0 || oriW <= maxWidth) {
        return deepCopyImage(src);
    }
    int oriH = src.getHeight();
    // 等比例计算缩放尺寸
    double scale = (double) maxWidth / oriW;
    int targetW = maxWidth;
    int targetH = Math.max(MIN_SCALE_HEIGHT, (int) Math.round(oriH * scale));
    
    // 极窄视频兜底防护
    if (targetH == MIN_SCALE_HEIGHT) {
        log.warn("视频比例极窄,高度缩至最小兜底{}", MIN_SCALE_HEIGHT);
    }

    // 新建画布渲染压缩后画面,控制内存占用
    BufferedImage scaled = new BufferedImage(targetW, targetH, BufferedImage.TYPE_3BYTE_BGR);
    Graphics2D g2d = scaled.createGraphics();
    try {
        g2d.setColor(Color.WHITE);
        g2d.fillRect(0, 0, targetW, targetH);
        // 高质量缩放渲染,平衡画质与内存
        g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, IMG_INTERPOLATION);
        g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
        g2d.drawImage(src, 0, 0, targetW, targetH, null);
    } finally {
        g2d.dispose();
    }
    return scaled;
}

6.4 单机并发安全锁(解决文件覆盖损坏)

自定义带过期机制的本地文件锁,支持自动清理服务崩溃残留的无效锁文件,配合多重重试机制,彻底解决多线程/多进程并发截图时的文件覆盖、空文件、图片损坏问题。

// 锁过期时间30s,自动清理崩溃残留锁
private static final long LOCK_EXPIRE_MS = 30 * 1000L;
// 锁最大重试次数
private static final int LOCK_TRY_COUNT = 3;

// 并发锁核心获取逻辑
for (int lockTry = 0; lockTry < LOCK_TRY_COUNT; lockTry++) {
    if (lockMarkerFile.exists()) {
        // 判断锁是否过期,过期则自动清理
        boolean lockExpired = checkLockExpired(lockMarkerFile);
        if (lockExpired) {
            log.warn("清理过期锁 lock=[{}]", lockMarkerFile.getAbsolutePath());
            safeDeleteLockFile(lockMarkerFile);
        } else {
            log.warn("文件被其他进程占用,重试({}/3) path=[{}]", lockTry + 1, finalImagePath);
            safeSleep(100);
            continue;
        }
    }
    // 尝试创建锁文件,抢占执行权限
    try {
        holdMarkerLock = lockMarkerFile.createNewFile();
        if (holdMarkerLock) {
            // 写入时间戳,用于后续过期判断
            try (PrintWriter pw = new PrintWriter(Files.newOutputStream(lockMarkerFile.toPath()))) {
                pw.write(String.valueOf(System.currentTimeMillis()));
                pw.flush();
            }
            break;
        }
    } catch (IOException e) {
        log.warn("创建锁文件异常,重试({}/3) path=[{}]", lockTry + 1, finalImagePath, e);
        safeSleep(100);
    }
}

6.5 全链路资源释放(彻底杜绝内存泄漏)

工具类实现分层级全链路资源释放,逐一释放Frame帧缓存、FFmpeg解码器、图像缓存、IO句柄,彻底解决堆外内存泄漏、文件句柄耗尽问题。

finally {
    // 1. 释放锁文件资源
    releaseLockIfNeed(holdMarkerLock, lockMarkerFile);
    // 2. 释放FFmpeg原生Frame堆外内存
    if (validFrame != null) {
        try {
            validFrame.close();
        } catch (Exception e) {
            log.warn("释放Frame缓冲区警告", e);
        }
        validFrame = null;
    }
    // 3. 完整关闭FFmpeg解码器,释放句柄
    closeGrabber(grabber);
    // 4. 清空图像缓存,释放堆内存
    disposeImage(originImg);
    disposeImage(targetImage);
    originImg = null;
    targetImage = null;
}

/** 彻底释放FFmpeg抓取器资源,杜绝句柄泄漏 */
private static void closeGrabber(FFmpegFrameGrabber grabber) {
    if (grabber == null) {
        return;
    }
    try {
        grabber.stop();
        grabber.close();
        grabber.release();
    } catch (Exception e) {
        log.warn("释放FFmpeg抓取器警告", e);
    }
}

/** 清空图像缓存 */
private static void disposeImage(BufferedImage img) {
    if (img != null) {
        img.flush();
    }
}

6.6 双环境适配+多维度参数校验

通过系统判断适配Windows/Linux双环境,区分路径长度校验、磁盘刷盘逻辑;同时实现文件大小、分辨率、格式、非法路径全方位校验,过滤损坏视频与无效请求。

// 自动适配系统环境
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");

// Windows路径长度限制校验(系统最大260字符)
if (isWindows && finalImagePath.length() > WINDOWS_MAX_PATH_LENGTH) {
    log.error("截图路径过长,超出Windows限制 path=[{}]", finalImagePath);
    return NULL_RESULT;
}

// Linux环境强制磁盘刷盘,保障文件持久化
if (!isWindows && out instanceof FileOutputStream) {
    FileOutputStream fos = (FileOutputStream) out;
    try {
        fos.getFD().sync();
    } catch (IOException syncErr) {
        log.warn("磁盘刷盘延迟警告 path=[{}],message=[{}]", finalImagePath, syncErr.getMessage());
    }
}

// 视频合法性核心校验
if (videoW < MIN_VIDEO_PIXEL_SIZE || videoH < MIN_VIDEO_PIXEL_SIZE
        || videoW > MAX_VIDEO_PIXEL_LIMIT || videoH > MAX_VIDEO_PIXEL_LIMIT) {
    log.warn("视频分辨率超出有效范围 w={},h={},path=[{}]", videoW, videoH, filePath);
    return NULL_RESULT;
}

七、工具类测试main

public static void main(String[] args) {
        String videoPath = "E:\\project_files\\test-project\\file\\video\\VID_20230225_184356.mp4";
        File testFile = new File(videoPath);
        if (!testFile.exists()) {
            log.error("测试视频不存在 path=[{}]", videoPath);
            return;
        }
        String jpgPath = getScreenshot(videoPath);
        log.info("JPG截图路径:{}", jpgPath);
        String pngPath = getScreenshot(videoPath, null, true, 1080, true, IMG_FORMAT_PNG);
        log.info("PNG中间帧截图路径:{}", pngPath);
        long ms = getVideoDuration(videoPath);
        log.info("视频总时长:{} 秒", String.format("%.2f", ms / 1000.0));
    }

执行日志输出如下:
在这里插入图片描述
原方法parseVideoRotateAngle()使用,针对原手机拍摄命名的视频文件(VID_xxx.mp4),仍然会截图角度偏移问题,如下图所示:
在这里插入图片描述
修复图片角度偏移问题处理:

private static int parseVideoRotateAngle2(FFmpegFrameGrabber grabber) {
        // 1. 优先读取标准顶层rotate元数据(苹果/部分安卓标准MP4有效)
        String meta = grabber.getVideoMetadata("rotate");
        Integer metaAngle = null;
        if (StringUtils.isNotBlank(meta)) {
            try {
                int val = Integer.parseInt(meta.trim());
                val = (val % 360 + 360) % 360;
                if (LEGAL_ROTATE_ANGLES.contains(val)) {
                    metaAngle = val;
                    log.info("顶层metadata读取旋转角度:{}", metaAngle);
                }
            } catch (NumberFormatException e) {
                log.warn("旋转元数据非数字 value=[{}]", meta);
            }
        }
        if (metaAngle != null) {
            return metaAngle;
        }

        // 2. 顶层无rotate,使用displayRotation兜底(安卓无rotate标签视频)
        double displayRot = grabber.getDisplayRotation();
        int raw = (int) Math.round(displayRot);
        // 标准化到0~359
        int displayAngle = (raw % 360 + 360) % 360;

        // FFmpeg displaymatrix角度与图片渲染补偿角度相反,修正颠倒问题
        int realCompensateAngle;
        switch (displayAngle) {
            case 90:
                realCompensateAngle = 270;
                break;
            case 270:
                realCompensateAngle = 90;
                break;
            case 180:
                realCompensateAngle = 180;
                break;
            default:
                realCompensateAngle = 0;
        }

        if (LEGAL_ROTATE_ANGLES.contains(realCompensateAngle)) {
            log.info("displayRotation修正后补偿角度:{} 原始displayRot:{}", realCompensateAngle, displayRot);
            return realCompensateAngle;
        }

        log.warn("无有效旋转信息,默认0,截图可能翻转");
        return 0;
    }

调整后再次执行main方法测试,图片角度正常,如下图所示:
在这里插入图片描述

八、完整工具类

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.FrameGrabber;
import org.bytedeco.javacv.Java2DFrameConverter;

import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.*;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.security.AccessControlException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * 视频截图、读取时长工具类
 * 环境:JDK8 + SpringBoot2.x + JavaCV 1.5.9 + FFmpeg 6.0-1.5.9
 * 核心特性:
 *  1. 截取视频中间帧截图,自动处理视频旋转元数据矫正画面
 *  2. 自动计算视频总播放时长(毫秒)
 *  3. 超大分辨率视频自动缩放,防止堆内存OOM
 *  4. 单机多进程/多线程本地文件互斥锁,防止并发覆盖截图文件
 *  5. 全链路资源自动释放,无FFmpeg堆外内存泄漏、无图像IO句柄泄漏
 * 兼容视频格式:mp4/mov/avi/mkv/flv/wmv/rmvb/webm
 * @author Z
 */
@Slf4j
public final class ToolVideoUtil {
    // ====================== 业务常量 ======================
    /** 跳过前N帧,过滤视频开头黑屏 */
    private static final int SKIP_BLACK_FRAME_COUNT = 5;
    /** 帧读取循环上限,防止损坏视频死循环 */
    private static final int MAX_READ_FRAME_LIMIT = 300;
    /** 跳转中间帧空帧重试次数 */
    private static final int MID_FRAME_RETRY = 2;
    /** 空帧休眠间隔,降低CPU占用 */
    private static final long FRAME_EMPTY_SLEEP_MS = 10L;
    /** 文件锁过期30s,自动清理崩溃残留锁 */
    private static final long LOCK_EXPIRE_MS = 30 * 1000L;
    /** 锁创建最大重试次数 */
    private static final int LOCK_TRY_COUNT = 3;
    /** 锁文件删除重试次数 */
    private static final int LOCK_DELETE_TRY = 2;
    /** 旧截图删除重试次数 */
    private static final int DELETE_RETRY_TIMES = 2;
    /** 文件删除等待间隔 */
    private static final long DELETE_WAIT_MS = 150L;

    // 图片格式
    public static final String IMG_FORMAT_JPG = "jpg";
    public static final String IMG_FORMAT_PNG = "png";
    /** 默认输出图片格式 */
    private static final String DEFAULT_IMG_SUFFIX = IMG_FORMAT_JPG;
    /** JPG压缩质量 0~1(越大画质越高、文件越大) */
    private static final float IMG_COMPRESS_QUALITY = 0.8f;

    // 视频校验阈值(视频合法旋转角度)
    private static final Set<Integer> LEGAL_ROTATE_ANGLES = Collections.unmodifiableSet(
            new HashSet<>(Arrays.asList(0, 90, 180, 270))
    );
    /** 支持解析的视频后缀白名单 */
    private static final Set<String> VIDEO_SUFFIX_WHITE_LIST = Collections.unmodifiableSet(
            new HashSet<>(Arrays.asList("mp4", "mov", "avi", "mkv", "flv", "wmv", "rmvb", "webm"))
    );
    /** FFmpeg网络超时(微秒) */
    private static final long FFMPEG_NET_TIMEOUT_MS = 10000L;
    /** 最小视频文件 100KB */
    private static final long MIN_VIDEO_FILE_SIZE = 1024 * 100L;
    /** 画面最小宽高,过滤纯音频 */
    private static final int MIN_VIDEO_PIXEL_SIZE = 10;
    /** 缩放后图片最小高度兜底 */
    private static final int MIN_SCALE_HEIGHT = 10;
    /** 默认截图最大宽度 */
    private static final int DEFAULT_MAX_SCREEN_WIDTH = 1920;
    /** 分辨率上限 7680,防止超大图内存溢出 */
    private static final int MAX_VIDEO_PIXEL_LIMIT = 7680;
    /** Windows路径字符上限260 */
    private static final int WINDOWS_MAX_PATH_LENGTH = 260;

    // 渲染配置(图像缩放渲染插值算法,平衡画质与性能)
    private static final Object IMG_INTERPOLATION = RenderingHints.VALUE_INTERPOLATION_BILINEAR;
    /** ImageIO仅初始化一次,避免重复扫描插件 */
    private static final AtomicBoolean IMAGE_IO_INIT = new AtomicBoolean(false);
    /** 失败统一返回标识 */
    private static final String NULL_RESULT = null;

    // ====================== 构造器 ======================
    private ToolVideoUtil() {
        throw new UnsupportedOperationException("工具类禁止实例化");
    }

    // ====================== 对外API重载 ======================
    /**
     * 截取视频中间帧截图,默认同目录、覆盖、1920宽、jpg
     * @param filePath 视频路径
     * @return 截图路径 / 失败返回null
     */
    public static String getScreenshot(String filePath) {
        return getScreenshot(filePath, null, true, DEFAULT_MAX_SCREEN_WIDTH,
                false, DEFAULT_IMG_SUFFIX);
    }

    /**
     * 截取视频中间帧截图,指定截图保存目录,默认允许覆盖、1920宽度、jpg
     * @param filePath 视频路径
     * @param saveImageDir 截图存放文件夹
     * @return 截图路径,失败返回null
     */
    public static String getScreenshot(String filePath, String saveImageDir) {
        return getScreenshot(filePath, saveImageDir, true, DEFAULT_MAX_SCREEN_WIDTH,
                false, DEFAULT_IMG_SUFFIX);
    }

    /**
     * 截取视频中间帧截图,指定目录 + 是否覆盖已有截图
     * @param filePath 视频路径
     * @param saveImageDir 截图目录
     * @param overwrite true=覆盖已有截图 false=存在则直接返回旧截图路径
     * @return 截图路径,失败返回null
     */
    public static String getScreenshot(String filePath, String saveImageDir, boolean overwrite) {
        return getScreenshot(filePath, saveImageDir, overwrite, DEFAULT_MAX_SCREEN_WIDTH,
                false, DEFAULT_IMG_SUFFIX);
    }

    /**
     * 截图核心
     * @param filePath 视频路径
     * @param saveImageDir 截图存放目录,null则使用视频同级目录
     * @param overwrite 是否覆盖已有截图
     * @param maxWidth 截图最大宽度,0=原图不缩放,负数自动修正为0
     * @param captureMidFrame true=截取中间帧 false=从头取有效帧
     * @param imgFormat 输出图片格式 jpg/png
     * @return 截图文件完整路径,解析/写入失败返回null
     */
    public static String getScreenshot(String filePath, String saveImageDir, boolean overwrite, int maxWidth,
                                       boolean captureMidFrame, String imgFormat) {
        initImageIO();

        int realMaxWidth = Math.max(0, maxWidth);
        // 统一图片格式
        String realFormat = IMG_FORMAT_PNG.equalsIgnoreCase(imgFormat) ? IMG_FORMAT_PNG : IMG_FORMAT_JPG;
        boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");

        File videoFile;
        try {
            // 校验视频文件合法性
            videoFile = checkVideoFile(filePath, isWindows);
        } catch (IllegalArgumentException e) {
            log.error("视频文件校验失败:{} path=[{}]", e.getMessage(), filePath);
            return NULL_RESULT;
        }

        FFmpegFrameGrabber grabber = null;
        Java2DFrameConverter converter = null;
        BufferedImage originImg = null;
        BufferedImage targetImage = null;
        String finalImagePath = null;
        File imgFile = null;
        Frame validFrame = null;
        File lockMarkerFile = null;
        boolean holdMarkerLock = false;

        try {
            // 初始化FFmpeg视频解码器
            grabber = FFmpegFrameGrabber.createDefault(videoFile.getAbsolutePath());
            grabber.setOption("stimeout", String.valueOf(FFMPEG_NET_TIMEOUT_MS * 1000));
            grabber.start();

            // 校验视频是否包含画面流(纯音频无画面直接返回)
            if (grabber.getVideoStream() < 0) {
                log.error("文件无视频画面流,仅音频 path=[{}]", filePath);
                return NULL_RESULT;
            }
            int videoW = grabber.getImageWidth();
            int videoH = grabber.getImageHeight();
            // 过滤超小/超大分辨率视频
            if (videoW < MIN_VIDEO_PIXEL_SIZE || videoH < MIN_VIDEO_PIXEL_SIZE
                    || videoW > MAX_VIDEO_PIXEL_LIMIT || videoH > MAX_VIDEO_PIXEL_LIMIT) {
                log.warn("视频分辨率超出有效范围 w={},h={},path=[{}]", videoW, videoH, filePath);
                return NULL_RESULT;
            }

            boolean jumpMid = false;
            // 优先按帧跳转;无帧索引则按时间戳兜底(flv/mov无关键帧兼容)
            if (captureMidFrame) {
                long totalFrames = grabber.getLengthInFrames();
                long totalMicro = grabber.getLengthInTime();
                // 有帧索引,直接跳转中间帧
                if (totalFrames > 0) {
                    int midFrameIdx = (int) (totalFrames / 2);
                    grabber.setFrameNumber(midFrameIdx);
                    grabber.flush();
                    jumpMid = true;
                } else if (totalMicro > 0) {
                    // 兼容无关键帧视频(flv/部分mov),使用时间戳兜底跳转
                    long midTime = totalMicro / 2;
                    grabber.setTimestamp(midTime);
                    grabber.flush();
                    jumpMid = true;
                    log.warn("视频无帧索引,使用时间戳跳转中间帧 path=[{}]", filePath);
                } else {
                    log.warn("视频无有效时长/帧数,无法跳转中间帧 path=[{}]", filePath);
                }
            }

            // 读取有效画面帧
            try {
                validFrame = findValidFrame(grabber, jumpMid);
            } catch (FFmpegFrameGrabber.Exception e) {
                log.error("FFmpeg读取帧失败 path=[{}]", filePath, e);
                return NULL_RESULT;
            }
            if (validFrame == null || validFrame.image == null) {
                log.error("未读取到有效画面,视频损坏 path=[{}]", filePath);
                return NULL_RESULT;
            }

            // 获取视频旋转角度,矫正画面方向
//            int rotateAngle = parseVideoRotateAngle(grabber);
            // TODO VID_xxxxxx.mp4 原手机拍摄的视频文件 矫正画面方向偏转修复
            int rotateAngle = parseVideoRotateAngle2(grabber);

            // 深拷贝图像
            converter = new Java2DFrameConverter();
            BufferedImage rawImg = converter.getBufferedImage(validFrame);
            originImg = deepCopyImage(rawImg);
            rawImg.flush();

            // 旋转矫正
            if (rotateAngle != 0) {
                BufferedImage rotated = rotateImage(originImg, rotateAngle);
                disposeImage(originImg);
                originImg = rotated;
            }
            // 等比例缩放至最大宽度
            targetImage = scaleImageByMaxWidth(originImg, realMaxWidth, realFormat);
            disposeImage(originImg);
            originImg = null;

            // 构建截图输出目录与文件对象
            File saveDir = buildSaveDir(videoFile, saveImageDir);
            String fileNameNoSuffix = getFileNameWithoutSuffix(videoFile.getName());
            imgFile = new File(saveDir, fileNameNoSuffix + "." + realFormat);
            finalImagePath = imgFile.getAbsolutePath();
            // 唯一标识(文件名+路径哈希+时间戳)
            String lockUnique = fileNameNoSuffix + "_" + finalImagePath.hashCode() + "_" + System.currentTimeMillis();
            lockMarkerFile = new File(saveDir, lockUnique + ".lock");

            // Windows路径长度限制校验
            if (isWindows && finalImagePath.length() > WINDOWS_MAX_PATH_LENGTH) {
                log.error("截图路径过长,超出Windows限制 path=[{}]", finalImagePath);
                releaseLockIfNeed(holdMarkerLock, lockMarkerFile);
                return NULL_RESULT;
            }

            // ====================== 单机文件锁 ======================
            for (int lockTry = 0; lockTry < LOCK_TRY_COUNT; lockTry++) {
                if (lockMarkerFile.exists()) {
                    // 读取锁内时间戳判断是否过期
                    boolean lockExpired = checkLockExpired(lockMarkerFile);
                    if (lockExpired) {
                        log.warn("清理过期锁 lock=[{}]", lockMarkerFile.getAbsolutePath());
                        safeDeleteLockFile(lockMarkerFile);
                    } else {
                        log.warn("文件被其他进程占用,重试({}/3) path=[{}]", lockTry + 1, finalImagePath);
                        safeSleep(100);
                        continue;
                    }
                }
                try {
                    holdMarkerLock = lockMarkerFile.createNewFile();
                    if (holdMarkerLock) {
                        // 写入当前时间戳用于过期判断(锁有效期凭证)
                        try (PrintWriter pw = new PrintWriter(Files.newOutputStream(lockMarkerFile.toPath()))) {
                            pw.write(String.valueOf(System.currentTimeMillis()));
                            pw.flush();
                        }
                        break;
                    }
                } catch (IOException e) {
                    log.warn("创建锁文件异常,重试({}/3) path=[{}]", lockTry + 1, finalImagePath, e);
                    safeSleep(100);
                }
            }
            // 多次获取锁失败,直接返回
            if (!holdMarkerLock) {
                log.error("多次获取锁失败,放弃截图 path=[{}]", finalImagePath);
                releaseLockIfNeed(holdMarkerLock, lockMarkerFile);
                return NULL_RESULT;
            }

            // 旧截图覆盖
            if (imgFile.exists()) {
                if (!overwrite) {
                    if (imgFile.length() > 0) {
                        log.warn("截图已存在,禁止覆盖 path=[{}]", finalImagePath);
                        releaseLockIfNeed(holdMarkerLock, lockMarkerFile);
                        return finalImagePath;
                    }
                    log.warn("旧截图为空文件,自动覆盖 path=[{}]", finalImagePath);
                } else {
                    boolean delResult = false;
                    for (int i = 0; i < DELETE_RETRY_TIMES; i++) {
                        delResult = imgFile.delete();
                        if (delResult || !imgFile.exists()) {
                            break;
                        }
                        safeSleep(DELETE_WAIT_MS);
                    }
                    if (!delResult && imgFile.exists()) {
                        log.warn("旧截图删除失败,磁盘只读/占用 path=[{}]", finalImagePath);
                    }
                }
            }

            // ====================== 图像写入磁盘 ======================
            boolean writeSuccess = false;
            try (OutputStream out = Files.newOutputStream(imgFile.toPath());
                 ImageOutputStream ios = ImageIO.createImageOutputStream(out)) {
                Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName(realFormat);
                if (!writers.hasNext()) {
                    try {
                        writeSuccess = ImageIO.write(targetImage, realFormat, ios);
                        ios.flush();
                        out.flush();
                        // Windows不支持fd.sync,跳过避免IO异常
                        if (!isWindows && out instanceof FileOutputStream) {
                            FileOutputStream fos = (FileOutputStream) out;
                            try {
                                fos.getFD().sync();
                            } catch (IOException syncErr) {
                                log.warn("磁盘刷盘延迟警告 path=[{}],message=[{}]", finalImagePath,
                                        syncErr.getMessage());
                            }
                        }
                        if (!writeSuccess) {
                            log.warn("ImageIO写入失败,无对应图片编码器 format=[{}]", realFormat);
                        }
                    } catch (Exception e) {
                        log.error("图片写入异常 format=[{}]", realFormat, e);
                        writeSuccess = false;
                    }
                } else {
                    ImageWriter writer = writers.next();
                    try {
                        ImageWriteParam param = writer.getDefaultWriteParam();
                        // PNG编码器不支持压缩参数,仅JPG配置质量
                        if (IMG_FORMAT_JPG.equals(realFormat) && param.canWriteCompressed()) {
                            param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
                            param.setCompressionQuality(IMG_COMPRESS_QUALITY);
                        }
                        writer.setOutput(ios);
                        writer.write(null, new IIOImage(targetImage, null, null), param);
                        ios.flush();
                        out.flush();
                        if (!isWindows && out instanceof FileOutputStream) {
                            FileOutputStream fos = (FileOutputStream) out;
                            try {
                                fos.getFD().sync();
                            } catch (IOException syncErr) {
                                log.warn("磁盘刷盘延迟警告 path=[{}],message=[{}]", finalImagePath,
                                        syncErr.getMessage());
                            }
                        }
                        writeSuccess = true;
                    } finally {
                        // 释放图像编码器资源
                        writer.dispose();
                    }
                }
            } catch (IOException e) {
                // 写入失败,清理空文件
                cleanTempFile(imgFile, finalImagePath);
                throw e;
            }

            if (!writeSuccess) {
                log.error("图片编码器写入失败 path=[{}]", finalImagePath);
                cleanTempFile(imgFile, finalImagePath);
                releaseLockIfNeed(holdMarkerLock, lockMarkerFile);
                return NULL_RESULT;
            }
            if (!imgFile.exists() || imgFile.length() <= 0 || !imgFile.canRead()) {
                log.error("写入成功但文件损坏/丢失 path=[{}]", finalImagePath);
                cleanTempFile(imgFile, finalImagePath);
                releaseLockIfNeed(holdMarkerLock, lockMarkerFile);
                return NULL_RESULT;
            }
            // 更新文件修改时间戳
            imgFile.setLastModified(System.currentTimeMillis());

            log.info("视频截图完成 video=[{}] screenshot=[{}] overwrite={} format={}", filePath, finalImagePath,
                    overwrite, realFormat);
            String ret = finalImagePath;
            releaseLockIfNeed(holdMarkerLock, lockMarkerFile);
            return ret;

        } catch (FFmpegFrameGrabber.Exception e) {
            log.error("FFmpeg解码异常 path=[{}]", filePath, e);
        } catch (AccessControlException e) {
            log.error("文件读写权限不足 path=[{}]", filePath, e);
        } catch (InterruptedException e) {
            log.error("线程中断 path=[{}]", filePath, e);
            Thread.currentThread().interrupt();
        } catch (Exception e) {
            log.error("视频截图未知异常 path=[{}]", filePath, e);
        } finally {
            // 1. 释放锁文件
            releaseLockIfNeed(holdMarkerLock, lockMarkerFile);
            // 2. 释放FFmpeg堆外Frame原生内存
            if (validFrame != null) {
                try {
                    validFrame.close();
                } catch (Exception e) {
                    log.warn("释放Frame缓冲区警告", e);
                }
                validFrame = null;
            }
            // 3. 关闭FFmpeg解码器,释放解码器句柄,防止句柄耗尽
            closeGrabber(grabber);
            // 4. 释放图像缓存
            disposeImage(originImg);
            disposeImage(targetImage);
            originImg = null;
            targetImage = null;
        }
        return NULL_RESULT;
    }

    /**
     * 获取视频总播放时长(毫秒)
     * @param filePath 视频文件路径
     * @return 时长ms,失败返回0
     */
    public static long getVideoDuration(String filePath) {
        initImageIO();
        boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
        File videoFile;
        try {
            videoFile = checkVideoFile(filePath, isWindows);
        } catch (IllegalArgumentException e) {
            log.error("视频文件校验失败:{} path=[{}]", e.getMessage(), filePath);
            return 0;
        }

        FFmpegFrameGrabber grabber = null;
        long durationMs = 0;
        try {
            grabber = FFmpegFrameGrabber.createDefault(videoFile.getAbsolutePath());
            grabber.setOption("stimeout", String.valueOf(FFMPEG_NET_TIMEOUT_MS * 1000));
            grabber.start();
            if (grabber.getVideoStream() < 0) {
                log.warn("文件无视频流仅音频 path=[{}]", filePath);
                return 0;
            }
            long totalFrames = grabber.getLengthInFrames();
            double fps = grabber.getFrameRate();
            if (totalFrames > 0 && fps > 0) {
                durationMs = (long) (totalFrames / fps * 1000);
            } else {
            	// 无帧率时使用总微秒时长
                long totalMicro = grabber.getLengthInTime();
                durationMs = totalMicro / 1000;
                log.warn("无有效帧率,使用总微秒时长兜底 path=[{}]", filePath);
            }
        } catch (FFmpegFrameGrabber.Exception e) {
            log.error("读取视频时长FFmpeg异常 path=[{}]", filePath, e);
        } catch (AccessControlException e) {
            log.error("读取视频权限不足 path=[{}]", filePath, e);
        } catch (Exception e) {
            log.error("读取视频时长未知异常 path=[{}]", filePath, e);
        } finally {
            // 释放抓取器
            closeGrabber(grabber);
        }
        return durationMs;
    }

    // ====================== 私有工具方法 ======================
    /** ImageIO插件初始化 */
    private static void initImageIO() {
        if (IMAGE_IO_INIT.compareAndSet(false, true)) {
            ImageIO.scanForPlugins();
        }
    }

    /** 锁文件删除重试 */
    private static void safeDeleteLockFile(File lockFile) {
        for (int i = 0; i < LOCK_DELETE_TRY; i++) {
            if (!lockFile.exists() || lockFile.delete()) {
                break;
            }
            safeSleep(20);
        }
        if (lockFile.exists()) {
            log.error("锁文件删除失败 lock=[{}]", lockFile.getAbsolutePath());
        }
    }

    /** 统一锁释放(持有则释放) */
    private static void releaseLockIfNeed(boolean holdLock, File lockFile) {
        if (holdLock && lockFile != null) {
            safeDeleteLockFile(lockFile);
        }
    }

    /** 校验锁是否过期(读取锁内时间戳) */
    private static boolean checkLockExpired(File lockFile) {
        if (!lockFile.exists()) {
            return true;
        }
        long createTime = 0;
        try (java.io.BufferedReader br = new java.io.BufferedReader(new java.io.FileReader(lockFile))) {
            String line = br.readLine();
            if (line != null && line.matches("\\d+")) {
                createTime = Long.parseLong(line);
            }
        } catch (Exception e) {
            log.warn("读取锁时间戳失败,判定为过期锁 lock=[{}]", lockFile.getAbsolutePath());
            return true;
        }
        return System.currentTimeMillis() - createTime > LOCK_EXPIRE_MS;
    }

    /** 视频文件合法性校验 */
    private static File checkVideoFile(String filePath, boolean isWindows) {
        if (StringUtils.isBlank(filePath)) {
            throw new IllegalArgumentException("路径为空");
        }
        String trimPath = filePath.trim();
        if (trimPath.matches(".*[\\x00-\\x1F].*")) {
            throw new IllegalArgumentException("路径含非法控制字符");
        }
        File file = new File(trimPath).getAbsoluteFile();
        String absPath = file.getAbsolutePath();
        if (isWindows && absPath.length() > WINDOWS_MAX_PATH_LENGTH) {
            throw new IllegalArgumentException("路径超长");
        }
        if (!file.exists()) {
            throw new IllegalArgumentException("文件不存在");
        }
        if (!file.isFile()) {
            throw new IllegalArgumentException("非文件");
        }
        if (!file.canRead()) {
            throw new IllegalArgumentException("无读取权限");
        }
        if (file.length() < MIN_VIDEO_FILE_SIZE) {
            throw new IllegalArgumentException("文件过小损坏");
        }

        String fileName = file.getName();
        if (fileName.endsWith(".")) {
            throw new IllegalArgumentException("文件名不能以点结尾");
        }
        String suffix = getFileSuffix(fileName).toLowerCase();
        if (!VIDEO_SUFFIX_WHITE_LIST.contains(suffix)) {
            throw new IllegalArgumentException("不支持格式:" + suffix);
        }
        return file;
    }

    /** 线程休眠 */
    private static void safeSleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
            log.warn("休眠被中断");
        }
    }

    /** 循环读取有效画面帧,过滤空帧、黑屏帧 */
    private static Frame findValidFrame(FFmpegFrameGrabber grabber, boolean jumpMid) throws FrameGrabber.Exception, InterruptedException {
        if (Thread.currentThread().isInterrupted()) {
            return null;
        }
        if (jumpMid) {
            Frame frame;
            for (int i = 0; i < MID_FRAME_RETRY; i++) {
                frame = grabber.grabFrame();
                if (frame != null && frame.image != null) {
                    return frame;
                }
                safeSleep(FRAME_EMPTY_SLEEP_MS);
                if (Thread.currentThread().isInterrupted()) {
                    return null;
                }
            }
        }
        Frame validFrame = null;
        int index = 0;
        while (index < MAX_READ_FRAME_LIMIT) {
            if (Thread.currentThread().isInterrupted()) {
                return null;
            }
            Frame frame = grabber.grabFrame();
            if (frame == null) {
                safeSleep(FRAME_EMPTY_SLEEP_MS);
                index++;
                continue;
            }
            if (index > SKIP_BLACK_FRAME_COUNT && frame.image != null) {
                validFrame = frame;
                break;
            }
            index++;
        }
        return validFrame;
    }

    /** 读取视频旋转角度 */
    private static int parseVideoRotateAngle(FFmpegFrameGrabber grabber) {
        String meta = grabber.getVideoMetadata("rotate");
        if (StringUtils.isBlank(meta)) {
            return 0;
        }
        int angle;
        try {
            angle = Integer.parseInt(meta.trim());
        } catch (NumberFormatException e) {
            log.warn("旋转元数据非数字 value=[{}]", meta);
            return 0;
        }
        angle = (angle % 360 + 360) % 360;
        if (!LEGAL_ROTATE_ANGLES.contains(angle)) {
            log.warn("非法旋转角度{},重置0", angle);
            return 0;
        }
        return angle;
    }

	/** TODO 处理原手机拍摄的视频文件-截图后角度偏移90度问题 */
    private static int parseVideoRotateAngle2(FFmpegFrameGrabber grabber) {
        // 1. 优先读取标准顶层rotate元数据(优先级最高,苹果/部分安卓标准MP4有效)
        String meta = grabber.getVideoMetadata("rotate");
        Integer metaAngle = null;
        if (StringUtils.isNotBlank(meta)) {
            try {
                int val = Integer.parseInt(meta.trim());
                val = (val % 360 + 360) % 360;
                if (LEGAL_ROTATE_ANGLES.contains(val)) {
                    metaAngle = val;
                    log.info("顶层metadata读取旋转角度:{}", metaAngle);
                }
            } catch (NumberFormatException e) {
                log.warn("旋转元数据非数字 value=[{}]", meta);
            }
        }
        if (metaAngle != null) {
            return metaAngle;
        }

        // 2. 顶层无rotate,使用displayRotation兜底(安卓无rotate标签视频)
        double displayRot = grabber.getDisplayRotation();
        int raw = (int) Math.round(displayRot);
        // 标准化到0~359
        int displayAngle = (raw % 360 + 360) % 360;

        // 修复:FFmpeg displaymatrix角度与图片渲染补偿角度相反,修正颠倒问题
        int realCompensateAngle;
        switch (displayAngle) {
            case 90:
                realCompensateAngle = 270;
                break;
            case 270:
                realCompensateAngle = 90;
                break;
            case 180:
                realCompensateAngle = 180;
                break;
            default:
                realCompensateAngle = 0;
        }

        if (LEGAL_ROTATE_ANGLES.contains(realCompensateAngle)) {
            log.info("displayRotation修正后补偿角度:{} 原始displayRot:{}", realCompensateAngle, displayRot);
            return realCompensateAngle;
        }

        log.warn("无有效旋转信息,默认0,截图可能翻转");
        return 0;
    }

    /** 构建截图输出目录(输出目录不存在则自动递归创建) */
    private static File buildSaveDir(File videoFile, String saveDirPath) {
        File saveDir;
        if (StringUtils.isBlank(saveDirPath)) {
            saveDir = videoFile.getParentFile();
        } else {
            saveDir = new File(saveDirPath.trim()).getAbsoluteFile();
        }
        if (!saveDir.exists()) {
            boolean mkdir = saveDir.mkdirs();
            if (!mkdir && !saveDir.exists()) {
                throw new AccessControlException("目录创建失败:" + saveDir.getAbsolutePath());
            }
        }
        if (!saveDir.canWrite()) {
            throw new AccessControlException("目录无写入权限:" + saveDir.getAbsolutePath());
        }
        return saveDir;
    }

    /** 图像深拷贝 */
    private static BufferedImage deepCopyImage(BufferedImage src) {
        BufferedImage copy = new BufferedImage(src.getWidth(), src.getHeight(), src.getType());
        Graphics2D g2d = copy.createGraphics();
        try {
            g2d.drawImage(src, 0, 0, null);
        } finally {
            g2d.dispose();
        }
        return copy;
    }

    /** 图像旋转矫正,自动扩画布防裁切 */
    private static BufferedImage rotateImage(BufferedImage src, int angle) {
        int w = src.getWidth();
        int h = src.getHeight();
        double rad = Math.toRadians(angle);
        Point2D.Double p1 = rotatePoint(0, 0, w / 2.0, h / 2.0, rad);
        Point2D.Double p2 = rotatePoint(w, 0, w / 2.0, h / 2.0, rad);
        Point2D.Double p3 = rotatePoint(w, h, w / 2.0, h / 2.0, rad);
        Point2D.Double p4 = rotatePoint(0, h, w / 2.0, h / 2.0, rad);

        double minX = Math.min(Math.min(p1.x, p2.x), Math.min(p3.x, p4.x));
        double maxX = Math.max(Math.max(p1.x, p2.x), Math.max(p3.x, p4.x));
        double minY = Math.min(Math.min(p1.y, p2.y), Math.min(p3.y, p4.y));
        double maxY = Math.max(Math.max(p1.y, p2.y), Math.max(p3.y, p4.y));

        int destW = (int) Math.ceil(maxX - minX) + 3;
        int destH = (int) Math.ceil(maxY - minY) + 3;
        BufferedImage dest = new BufferedImage(destW, destH, src.getType());
        Graphics2D g2d = dest.createGraphics();
        try {
            g2d.setColor(Color.WHITE);
            g2d.fillRect(0, 0, destW, destH);
            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, IMG_INTERPOLATION);
            g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
            g2d.translate(-minX + 1, -minY + 1);
            g2d.rotate(rad, w / 2.0, h / 2.0);
            g2d.drawImage(src, 0, 0, null);
        } finally {
            g2d.dispose();
        }
        return dest;
    }

    /** 二维坐标旋转计算 */
    private static Point2D.Double rotatePoint(double x, double y, double cx, double cy, double rad) {
        double dx = x - cx;
        double dy = y - cy;
        double nx = cx + dx * Math.cos(rad) - dy * Math.sin(rad);
        double ny = cy + dx * Math.sin(rad) + dy * Math.cos(rad);
        return new Point2D.Double(nx, ny);
    }

    /** 等比例缩放图像 */
    private static BufferedImage scaleImageByMaxWidth(BufferedImage src, int maxWidth, String format) {
        int oriW = src.getWidth();
        if (maxWidth <= 0 || oriW <= maxWidth) {
            return deepCopyImage(src);
        }
        int oriH = src.getHeight();
        double scale = (double) maxWidth / oriW;
        int targetW = maxWidth;
        int targetH = Math.max(MIN_SCALE_HEIGHT, (int) Math.round(oriH * scale));
        if (targetH == MIN_SCALE_HEIGHT) {
            log.warn("视频比例极窄,高度缩至最小兜底{}", MIN_SCALE_HEIGHT);
        }

        BufferedImage scaled = new BufferedImage(targetW, targetH, BufferedImage.TYPE_3BYTE_BGR);
        Graphics2D g2d = scaled.createGraphics();
        try {
            g2d.setColor(Color.WHITE);
            g2d.fillRect(0, 0, targetW, targetH);
            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, IMG_INTERPOLATION);
            g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
            g2d.drawImage(src, 0, 0, targetW, targetH, null);
        } finally {
            g2d.dispose();
        }
        return scaled;
    }

    /** 安全关闭FFmpeg抓取器,释放解码器句柄 */
    private static void closeGrabber(FFmpegFrameGrabber grabber) {
        if (grabber == null) {
            return;
        }
        try {
            grabber.stop();
            grabber.close();
            grabber.release();
        } catch (Exception e) {
            log.warn("释放FFmpeg抓取器警告", e);
        }
    }

    /** 释放BufferedImage缓存 */
    private static void disposeImage(BufferedImage img) {
        if (img != null) {
            img.flush();
        }
    }

    /** 写入失败清理空文件 */
    private static void cleanTempFile(File imgFile, String path) {
        if (imgFile != null && imgFile.exists()) {
            boolean del = imgFile.delete();
            log.warn("清理写入残留图片 {} 删除结果:{}", path, del);
        }
    }

    /** 截取文件名(去除后缀) */
    private static String getFileNameWithoutSuffix(String fileName) {
        if (fileName == null) {
            return "";
        }
        int dotIdx = fileName.lastIndexOf(".");
        return dotIdx <= 0 ? fileName : fileName.substring(0, dotIdx);
    }

    /** 获取文件后缀 */
    private static String getFileSuffix(String fileName) {
        if (fileName == null) {
            return "";
        }
        int dotIdx = fileName.lastIndexOf(".");
        return dotIdx <= 0 ? "" : fileName.substring(dotIdx + 1);
    }

    // ====================== 本地测试main ======================
    public static void main(String[] args) {
        String videoPath = "E:\\project_files\\test-project\\file\\video\\VID_20230225_184356.mp4";
        File testFile = new File(videoPath);
        if (!testFile.exists()) {
            log.error("测试视频不存在 path=[{}]", videoPath);
            return;
        }
        String jpgPath = getScreenshot(videoPath);
        log.info("JPG截图路径:{}", jpgPath);
        String pngPath = getScreenshot(videoPath, null, true, 1080, true, IMG_FORMAT_PNG);
        log.info("PNG中间帧截图路径:{}", pngPath);
        long ms = getVideoDuration(videoPath);
        log.info("视频总时长:{} 秒", String.format("%.2f", ms / 1000.0));
    }
}

九、业务实际应用

视频工具类为纯静态方法实现,无需注入Bean,在业务Service中可直接调用:

import com.zhang.service.IVideoService;
import com.zhang.utils.ToolVideoUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

/**
 * 业务
 * @author Z
 */
@Service
public class VideoServiceImpl implements IVideoService {

    private static final Logger LOGGER = LoggerFactory.getLogger(VideoServiceImpl.class);

    /** 本地视频文件路径 */
    private static final String VIDEO_LOCAL_PATH = "E:\\project_files\\test-project\\file\\video\\VID_20230225_184356.mp4";

    @Override
    public String getScreenshot() {
        String saveImageDir = null;
        boolean overwrite = true;
        int maxWidth = 1080;
        boolean captureMidFrame = true;
        String imgFormat = ToolVideoUtil.IMG_FORMAT_JPG;

        // 截取1080P中间帧PNG截图
        String screenshotPath = ToolVideoUtil.getScreenshot(
                VIDEO_LOCAL_PATH,
                saveImageDir,
                overwrite,
                maxWidth,
                captureMidFrame,
                imgFormat
        );
        LOGGER.info("截图地址:{}", screenshotPath);
        return screenshotPath;
    }

    @Override
    public long getVideoDuration() {
        long durationMs = ToolVideoUtil.getVideoDuration(VIDEO_LOCAL_PATH);
        LOGGER.info("视频时长:{}", durationMs / 1000 + "s");
        return durationMs;
    }
}

十、所遇问题

问题1、FFmpeg堆外内存泄漏、句柄耗尽

问题现象:服务长期高并发运行时,出现堆外内存持续上涨、服务器文件打开数堆积、CPU占用升高、服务卡顿甚至宕机,无明显报错日志。
问题根源:网络开源通用示例仅简单调用 stop() 方法释放资源,未对FFmpeg帧缓存、解码器实例、IO文件句柄、Java图像缓存等原生资源做完整回收。高并发场景下资源持续堆积,最终导致文件句柄耗尽、堆外内存溢出。
优化解决方案:采用四级分层全链路资源释放机制,在finally代码块强制兜底执行,保障各类资源100%回收。依次完成Frame帧缓冲区关闭、FFmpeg抓取器stop/close/release全流程销毁、BufferedImage图像缓存flush清空、IO流资源关闭,彻底杜绝堆外内存泄漏与文件句柄堆积问题,支撑服务7×24小时稳定运行。

问题2、手机/相机实拍视频截图倒置、角度偏移

问题现象:手机原生相机、微信、录屏等渠道生成的视频(VID_、mmexport、ScreenRecorder等前缀文件),截图普遍出现90°、180°、270°角度倒置、画面偏移、边缘裁切问题,无法还原原始拍摄画面。
问题根源:手机、相机拍摄视频不会直接修改画面像素排布,而是将旋转角度信息存储在视频元数据中。原生JavaCV仅读取原始像素画面,不会自动识别、解析旋转元数据;同时部分安卓视频无标准rotate顶层标签,仅存在displayRotation旋转矩阵,原生方法无法适配解析,最终导致画面角度异常。
优化解决方案:重构视频旋转角度解析方法parseVideoRotateAngle(),采用双层兜底解析策略全覆盖适配。优先读取视频标准顶层rotate元数据,适配苹果、标准安卓MP4视频;无标准元数据的特殊安卓视频,兜底解析displayRotation旋转矩阵,并做反向角度补偿修正。同时动态计算画布尺寸、拓展画面边界、白底填充,彻底解决旋转后画面裁切、偏移、变形问题。
常见视频文件命名前缀:

视频文件前缀 视频来源场景
VID_ 系统原生相机实拍视频
IMG_ 系统原生相机实拍照片
TG_ Telegram 电报下载视频
mmexport 微信保存 / 拍摄视频
ScreenRecorder 系统屏幕录制
share_ 抖音 / 快手保存视频

修复源码

private static int parseVideoRotateAngle2(FFmpegFrameGrabber grabber) {
        // 1. 优先读取标准顶层rotate元数据(苹果/部分安卓标准MP4有效)
        String meta = grabber.getVideoMetadata("rotate");
        Integer metaAngle = null;
        if (StringUtils.isNotBlank(meta)) {
            try {
                int val = Integer.parseInt(meta.trim());
                val = (val % 360 + 360) % 360;
                if (LEGAL_ROTATE_ANGLES.contains(val)) {
                    metaAngle = val;
                    log.info("顶层metadata读取旋转角度:{}", metaAngle);
                }
            } catch (NumberFormatException e) {
                log.warn("旋转元数据非数字 value=[{}]", meta);
            }
        }
        if (metaAngle != null) {
            return metaAngle;
        }

        // 2. 顶层无rotate,使用displayRotation兜底(安卓无rotate标签视频)
        double displayRot = grabber.getDisplayRotation();
        int raw = (int) Math.round(displayRot);
        // 标准化到0~359
        int displayAngle = (raw % 360 + 360) % 360;

        // FFmpeg displaymatrix角度与图片渲染补偿角度相反,修正颠倒问题
        int realCompensateAngle;
        switch (displayAngle) {
            case 90:
                realCompensateAngle = 270;
                break;
            case 270:
                realCompensateAngle = 90;
                break;
            case 180:
                realCompensateAngle = 180;
                break;
            default:
                realCompensateAngle = 0;
        }

        if (LEGAL_ROTATE_ANGLES.contains(realCompensateAngle)) {
            log.info("displayRotation修正后补偿角度:{} 原始displayRot:{}", realCompensateAngle, displayRot);
            return realCompensateAngle;
        }

        log.warn("无有效旋转信息,默认0,截图可能翻转");
        return 0;
    }

修复效果:全量适配手机、相机、社交软件、短视频平台各类视频,彻底解决90°/180°/270°画面倒置偏移问题,矫正后画面无裁切、无变形,完全满足业务展示要求。

问题3、4K/8K超大分辨率视频并发触发OOM内存溢出

问题现象:处理4K、8K超高分辨率视频时,单任务内存占用极高,多线程并发场景下频繁触发Java堆内存溢出,导致截图任务失败、服务报错。
问题根源:超高分辨率视频原始像素数据体量巨大,直接全量加载至内存会占用大量堆空间,无任何缩放限制的情况下,并发处理极易打满堆内存,触发OOM异常。
优化解决方案:设置视频分辨率上下限,新增智能等比例缩放机制。支持自定义截图最大宽度,针对超大分辨率视频自动压缩适配尺寸,严格控制单张截图内存占用上限;同时增加最小分辨率兜底校验,过滤异常破损视频,从根源杜绝高并发OOM问题。

问题4、多线程并发截图文件覆盖、图片损坏

问题现象:多线程、多接口并行对同一视频执行截图操作时,频繁出现图片空白、文件破损、截图失效等问题,影响业务正常使用。
问题根源:原生文件读写无并发控制,多线程同时对同一图片文件执行写入、覆盖操作,IO读写冲突导致文件写入不完整、文件损坏、内容为空。
优化解决方案:采用30s过期本地文件锁机制,基于时间戳生成唯一锁文件,自动识别并清理服务崩溃残留的无效锁文件。搭配多次重试、文件存在性校验机制,实现单机并发读写安全,彻底避免多线程文件覆盖、图片损坏问题。

问题5、Windows/Linux跨环境运行报错、适配异常

问题现象:本地Windows开发环境正常运行,部署至Linux生产服务器后出现解码失败、文件读写异常、原生库加载失败等问题,反之亦然。
问题根源:FFmpeg原生依赖区分Windows、Linux系统,统一依赖会导致原生库加载失败;同时Windows存在260字符路径长度限制,Linux需强制磁盘刷盘保障文件完整性,通用代码无法兼顾双环境差异化特性。
优化解决方案:通过Maven Profile实现分环境自动依赖加载,区分Windows开发、Linux生产环境加载对应FFmpeg原生库;代码内自动识别操作系统,差异化处理路径长度校验、磁盘强制刷盘逻辑,完美适配本地开发与线上生产双环境,杜绝跨环境兼容报错。

问题6、FLV/MOV流式视频中间帧截取黑屏失效

问题现象:处理FLV、部分MOV流式视频时,无法截取有效中间帧,默认截取黑屏首帧,截图效果失效。
问题根源:FLV、流式MOV视频无完整关键帧索引,不支持通过帧号精准定位视频中间位置,常规帧跳转逻辑失效,仅能读取视频初始黑屏帧。
优化解决方案:采用帧号优先+时间戳兜底双策略适配全格式视频:带帧索引的标准视频,通过帧号精准跳转中间帧;无帧索引的流式视频,自动降级使用时间戳定位中间位置,彻底解决FLV、MOV等特殊视频黑屏问题。

问题7、PNG格式截图压缩报错、写入失败

问题现象:配置图片压缩参数后,JPG截图正常生成,PNG截图频繁出现参数异常、写入失败、文件为空等问题。
问题原因:PNG图像编码器特性与JPG不同,不支持自定义压缩质量参数,直接照搬JPG压缩配置会触发参数不匹配异常,导致截图写入流程中断。
优化解决方案:增加图片格式差异化判断逻辑,做适配化处理:JPG格式启用自定义压缩质量参数,平衡画质与文件大小;PNG格式自动跳过压缩参数配置,采用无损写入模式,实现两种主流图片格式稳定、高效写入。

十一、总结

本文基于SpringBoot2.x + JDK8 + JavaCV1.5.9技术栈,实现了一套高性能、高可用、跨环境适配的视频解析与截图工具类,彻底摒弃了传统FFmpeg命令行调用方案存在的进程开销高、并发差、漏洞多、兼容性弱的弊端,采用纯Java内嵌解析模式,适配线上高并发生产场景。工具类集成多项针对性优化能力,不仅实现了全场景视频画面自动矫正、4K/8K超大视频防OOM缩放、主流视频格式全覆盖、全链路异常兜底容错等核心功能,还通过四级分层资源释放机制彻底解决堆外内存泄漏与文件句柄耗尽问题,依托过期文件锁机制保障单机并发截图安全,同时适配Windows开发、Linux生产双环境,完美修复手机视频倒置、FLV/MOV黑屏、PNG写入异常、跨环境报错等全网通用教程的各类疑难BUG。该工具类轻量化、无冗余依赖、可直接开箱即用,能够全面覆盖视频素材管理、短视频后台、在线教育、内容审核等各类后端视频处理业务,具备极强的实用性与线上落地稳定性。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐