规则之树

RuleApi整合FFmpeg实现视频转码和封面获取

这个功能因为比较消耗服务器的性能,所以导致假如我整合进官网项目会导致服务器性能损耗比较大,所以就单独开个教程出来,需要的直接按教程把相关代码加入后端项目就好,普通版和PRO版都能用。FFmpeg可以有效的解决做视频站的时候,因为视频格式手机无法播放导致的问题。

首先,需要在服务器安装FFmpeg

安装ffmpeg,而且还要并加入PATH。

这个百度出来的教程太多,我就不单独放了,反正只要最后服务器执行如下命令的, 能有截图效果。

ffmpeg -version

然后,下载RuleApi后端的源码,用编程软件导入。

在/src/main/java/com/RuleApi/common下新增文件VideoTranscodeUtil.java,内容如下:

package com.RuleApi.common;

import lombok.extern.slf4j.Slf4j;
import java.io.*;
import java.util.UUID;

/**
 * 视频转码工具类
 * 使用 FFmpeg 将视频统一转为 H.264 + AAC 的 MP4(兼容全平台 uni-app + 微信小程序)
 */
@Slf4j
public class VideoTranscodeUtil {

    // FFmpeg 可执行文件路径,服务器需安装 ffmpeg 并加入 PATH
    private static final String FFMPEG_PATH = "ffmpeg";

    /**
     * 判断文件是否为视频格式
     */
    public static boolean isVideoFile(String extension) {
        if (extension == null) return false;
        String ext = extension.toLowerCase();
        return ext.equals(".mp4") || ext.equals(".avi") || ext.equals(".mkv") ||
               ext.equals(".mov") || ext.equals(".wmv") || ext.equals(".flv") ||
               ext.equals(".webm") || ext.equals(".m4v") || ext.equals(".3gp") ||
               ext.equals(".ts") || ext.equals(".rmvb");
    }

    /**
     * 转码为 H.264 + AAC 的 MP4(兼容全平台)
     * 不重新编码时仅移动 moov atom(秒级完成)
     * 需要重新编码时使用 crf 23 + preset medium
     *
     * @param inputFile  原始文件
     * @param outputFile 输出文件
     * @return 是否成功
     */
    public static boolean transcodeToMp4(File inputFile, File outputFile) {
        log.info("[视频转码] 开始, 输入文件: {} ({}MB), 输出文件: {}",
            inputFile.getAbsolutePath(), inputFile.length() / 1024 / 1024, outputFile.getAbsolutePath());
        try {
            // 先尝试快速模式(仅移动 moov atom,不重新编码)
            log.info("[视频转码] 尝试快速模式 (copy + faststart)");
            ProcessBuilder pb = new ProcessBuilder(
                FFMPEG_PATH,
                "-y",
                "-i", inputFile.getAbsolutePath(),
                "-c", "copy",
                "-movflags", "+faststart",
                outputFile.getAbsolutePath()
            );
            pb.redirectErrorStream(true);
            Process process = pb.start();
            String ffmpegLog = drainStream(process.getInputStream());
            int exit = process.waitFor();
            if (exit == 0) {
                log.info("[视频转码] 快速模式成功, 输出大小: {}MB", outputFile.length() / 1024 / 1024);
                return true;
            }
            log.warn("[视频转码] 快速模式失败(exit={}), 回退完整重编码. ffmpeg输出:\n{}", exit, ffmpegLog);

            // 快速模式失败,回退到完全重编码
            log.info("[视频转码] 开始完整重编码 (libx264 medium crf23)");
            ProcessBuilder pb2 = new ProcessBuilder(
                FFMPEG_PATH,
                "-y",
                "-i", inputFile.getAbsolutePath(),
                "-c:v", "libx264",
                "-preset", "medium",
                "-crf", "23",
                "-profile:v", "high",
                "-pix_fmt", "yuv420p",
                "-c:a", "aac",
                "-b:a", "128k",
                "-movflags", "+faststart",
                outputFile.getAbsolutePath()
            );
            pb2.redirectErrorStream(true);
            Process process2 = pb2.start();
            String ffmpegLog2 = drainStream(process2.getInputStream());
            int exit2 = process2.waitFor();
            if (exit2 == 0) {
                log.info("[视频转码] 重编码成功, 输出大小: {}MB", outputFile.length() / 1024 / 1024);
                return true;
            }
            log.error("[视频转码] 重编码失败(exit={}), ffmpeg输出:\n{}", exit2, ffmpegLog2);
            return false;

        } catch (Exception e) {
            log.error("[视频转码] 异常", e);
            return false;
        }
    }

    /**
     * 从视频截取封面图(第1秒位置)
     *
     * @param videoFile  视频文件
     * @param outputFile 封面输出文件(.jpg)
     * @return 是否成功
     */
    public static boolean captureCover(File videoFile, File outputFile) {
        log.info("[视频封面] 开始截取, 视频: {} ({}MB)", videoFile.getAbsolutePath(), videoFile.length() / 1024 / 1024);
        try {
            ProcessBuilder pb = new ProcessBuilder(
                FFMPEG_PATH,
                "-y",
                "-i", videoFile.getAbsolutePath(),
                "-ss", "00:00:01",
                "-vframes", "1",
                "-q:v", "2",
                outputFile.getAbsolutePath()
            );
            pb.redirectErrorStream(true);
            Process process = pb.start();
            String ffmpegLog = drainStream(process.getInputStream());
            int exit = process.waitFor();
            boolean ok = exit == 0 && outputFile.exists() && outputFile.length() > 0;
            if (ok) {
                log.info("[视频封面] 截取成功, 封面大小: {}KB", outputFile.length() / 1024);
            } else {
                log.warn("[视频封面] 截取失败(exit={}, exists={}, size={}), ffmpeg输出:\n{}",
                    exit, outputFile.exists(), outputFile.length(), ffmpegLog);
            }
            return ok;
        } catch (Exception e) {
            log.error("[视频封面] 异常", e);
            return false;
        }
    }

    /**
     * 生成临时输出文件路径
     */
    public static File createTempOutputFile(String prefix, String suffix) throws IOException {
        File tempDir = new File(System.getProperty("java.io.tmpdir"), "video-transcode");
        if (!tempDir.exists()) {
            tempDir.mkdirs();
        }
        return new File(tempDir, prefix + "_" + UUID.randomUUID().toString().substring(0, 8) + suffix);
    }

    /**
     * 安全删除文件
     */
    public static void deleteQuietly(File file) {
        if (file != null && file.exists()) {
            file.delete();
        }
    }

    private static String drainStream(InputStream is) {
        StringBuilder sb = new StringBuilder();
        try {
            byte[] buf = new byte[4096];
            int len;
            while ((len = is.read(buf)) != -1) {
                sb.append(new String(buf, 0, len));
            }
        } catch (IOException ignored) {
        }
        return sb.toString();
    }
}

然后修改上传方法,找到/src/main/java/com/RuleApi/web/UploadController.java,修改full方法,在合适的位置添加如下代码:

     //验证上传大小结束
    // ========== 视频转码:统一转为 H.264+AAC MP4 ==========
    File transcodedVideo = null;
    File coverFile = null;
    MultipartFile finalFile = file;
    if(flieUploadType.equals(2)){
        log.info("[上传] 开始视频转码流程");
        try {
            // 保存原始文件到临时目录
            File tempInput = VideoTranscodeUtil.createTempOutputFile("video_input",
                    oldFileName.substring(oldFileName.lastIndexOf(".")));
            file.transferTo(tempInput);
            log.info("[上传] 原始文件已保存到临时目录: {}", tempInput.getAbsolutePath());

            // 转码
            transcodedVideo = VideoTranscodeUtil.createTempOutputFile("video_out", ".mp4");
            if(VideoTranscodeUtil.transcodeToMp4(tempInput, transcodedVideo)) {
                log.info("[上传] 转码成功, 输出: {}KB", transcodedVideo.length() / 1024);
                // 用转码后的文件替换
                String newName = oldFileName.substring(0, oldFileName.lastIndexOf(".")) + ".mp4";
                finalFile = new MockMultipartFile(
                        file.getName(), newName, "video/mp4",
                        new FileInputStream(transcodedVideo)
                );
                // 截取封面
                coverFile = VideoTranscodeUtil.createTempOutputFile("video_cover", ".jpg");
                VideoTranscodeUtil.captureCover(transcodedVideo, coverFile);
            } else {
                log.warn("[上传] 转码失败, 将使用原始文件上传");
            }
            VideoTranscodeUtil.deleteQuietly(tempInput);
        } catch (Exception e) {
            log.error("[上传] 转码流程异常", e);
            // 转码失败,使用原始文件
            finalFile = file;
        }
    }
    // ========== 视频转码结束 ==========

然后还可以通过如下代码,从视频里抽帧获取封面

        // ========== 上传视频封面 ==========
        if(flieUploadType.equals(2) && coverFile != null && coverFile.exists()){
            log.info("[上传] 开始上传封面, 大小={}KB", coverFile.length() / 1024);
            try {
                String coverResult = uploadCoverFile(coverFile, uploadType, dataprefix, apiconfig, uid);
                JSONObject coverObj = JSON.parseObject(coverResult);
                if("1".equals(coverObj.get("code").toString())){
                    JSONObject coverData = JSON.parseObject(coverObj.get("data").toString());
                    String coverUrl = coverData.get("url").toString();
                    log.info("[上传] 封面上传成功, coverUrl={}", coverUrl);
                    // 将封面 URL 追加到返回结果中
                    resultInfo.put("cover", coverUrl);
                    resultObject.put("data", resultInfo);
                    result = resultObject.toJSONString();
                } else {
                    log.warn("[上传] 封面上传失败, result={}", coverResult);
                }
            } catch (Exception e) {
                log.error("[上传] 封面上传异常", e);
                // 封面上传失败不影响主流程
            }
        } else if(flieUploadType.equals(2)) {
            log.warn("[上传] 无封面文件可上传 (coverFile={})", coverFile);
        }
        // ========== 封面结束 ==========

最后,别忘了加上清理临时文件代码:

    // 清理临时文件
    VideoTranscodeUtil.deleteQuietly(transcodedVideo);
    VideoTranscodeUtil.deleteQuietly(coverFile);

完整之后,前端可以支持的视频上传格式如下,我封装成了方法:

    isVideoFile(file) {
      const acceptedVideoTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/webm', 'video/x-flv', 'video/x-ms-wmv', 'video/mpeg', 'video/3gpp', 'video/ogg'];
      return acceptedVideoTypes.includes(file.type);
    },

并且上传接口识别到是视频上传,都会返回视频封面。

因为最近Pro版已经上线了短视频功能,不过默认只支持MP4格式,如果需要进一步拓展,可以参考此教程完成。

当前页面是本站的「Google AMP」版。查看和发表评论请点击:完整版 »