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格式,如果需要进一步拓展,可以参考此教程完成。