分片文件上传之后需要进行合并操作,

  • 视频文件合并之后需要进行切割同时生成索引文件.m3u8和切片.ts,同时生成视频缩略图
  • 图片文件同样需要生成缩略图

FFmpeg

FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。采用LGPL或GPL许可证。它提供了录制、转换以及流化音视频的完整解决方案。

安装FFmpeg

本次视频的切片使用的是FFmpeg工具,使用Maven方式,无需在机器上部署FFmpeg,其Maven依赖如下:

<!--FFmpeg-->
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-all-deps</artifactId>
<version>3.3.1</version>
</dependency>

视频操作

新建com.swx.easypan.utils包,并在该包下创建 FfmpegUtil,添加如下方法

FfmpegUtil
public class FfmpegUtil {
private static final Logger logger = LoggerFactory.getLogger(ProcessUtils.class);

/**
* 生成index.ts
*
* @param videoFilePath 视频路径
* @param tsPath ts文件路径
*/
public static void transfer2ts(String videoFilePath, String tsPath) {
// "ffmpeg -y -i %s -vcodec copy -acodec copy -vbsf h264_mp4toannexb %s"
try {
ProcessWrapper ffmpeg = new DefaultFFMPEGLocator().createExecutor();
ffmpeg.addArgument("-y");
ffmpeg.addArgument("-i");
ffmpeg.addArgument(videoFilePath);
ffmpeg.addArgument("-vcodec");
ffmpeg.addArgument("copy");
ffmpeg.addArgument("-acodec");
ffmpeg.addArgument("copy");
ffmpeg.addArgument("-vbsf");
ffmpeg.addArgument("h264_mp4toannexb");
ffmpeg.addArgument(tsPath);
ffmpeg.execute();
try (BufferedReader br = new BufferedReader(new InputStreamReader(ffmpeg.getErrorStream()))) {
blockFfmpeg(br);
}
} catch (IOException e) {
logger.error("ts文件生成失败", e);
throw new BizException("视频转换失败");
}
}

/**
* 视频切片
*
* @param tsPath ts文件路径
* @param m3u8Path index.m3u8路径
* @param tsFolder 切片文件目录路径
* @param fileId 文件ID
*/
public static void cutTs(String tsPath, String m3u8Path, String tsFolder, String fileId) {
// "ffmpeg -i %s -c copy -map 0 -f segment -segment_list %s -segment_time 30 %s/%s_%%4d.ts"
try {
ProcessWrapper ffmpeg = new DefaultFFMPEGLocator().createExecutor();
ffmpeg.addArgument("-i");
ffmpeg.addArgument(tsPath);
ffmpeg.addArgument("-c");
ffmpeg.addArgument("copy");
ffmpeg.addArgument("-map");
ffmpeg.addArgument("0");
ffmpeg.addArgument("-f");
ffmpeg.addArgument("segment");
ffmpeg.addArgument("-segment_list");
ffmpeg.addArgument(m3u8Path);
ffmpeg.addArgument("-segment_time");
ffmpeg.addArgument("30");
ffmpeg.addArgument(String.format("%s/%s_%%4d.ts", tsFolder, fileId));
ffmpeg.execute();
try (BufferedReader br = new BufferedReader(new InputStreamReader(ffmpeg.getErrorStream()))) {
blockFfmpeg(br);
}
} catch (IOException e) {
logger.error("文件切片失败", e);
throw new BizException("视频转换失败");
}
}


/**
* 获取视频缩略图
*
* @param sourceFile 视频文件地址
* @param width 缩略图宽度
* @param targetFile 缩略图地址
*/
public static void createTargetThumbnail(File sourceFile, Integer width, File targetFile) {
// "ffmpeg -i %s -y -vframes 1 -vf scale=%d:%d/a %s"
try {
ProcessWrapper ffmpeg = new DefaultFFMPEGLocator().createExecutor();
ffmpeg.addArgument("-i");
ffmpeg.addArgument(sourceFile.getAbsoluteFile().toString());
ffmpeg.addArgument("-y");
ffmpeg.addArgument("-vframes");
ffmpeg.addArgument("1");
ffmpeg.addArgument("-vf");
ffmpeg.addArgument(String.format("scale=%d:%d/a", width, width));
ffmpeg.addArgument(targetFile.getAbsoluteFile().toString());
ffmpeg.execute();
try (BufferedReader br = new BufferedReader(new InputStreamReader(ffmpeg.getErrorStream()))) {
blockFfmpeg(br);
}
} catch (IOException e) {
logger.error("生成封面失败", e);
throw new BizException("生成封面失败");
}
}

/**
* 等待命令执行成功,退出
*
* @param br
* @throws IOException
*/
private static void blockFfmpeg(BufferedReader br) throws IOException {
String line;
// 该方法阻塞线程,直至合成成功
while ((line = br.readLine()) != null) {
doNothing(line);
}
}

private static void doNothing(String line) {
// logger.info("ffmpeg命令执行中————{}", line);
}

}

图片操作

在 FfmpegUtil 中添加图片处理方法,生成图片缩略图

FfmpegUtil
public static Boolean createThumbnailWidthFFmpeg(File sourceFile, Integer width, File targetFile, Boolean delSource) {
try {
BufferedImage src = ImageIO.read(sourceFile);
int sourceW = src.getWidth();
// 小于指定高宽不要压缩
if (sourceW < width) {
return false;
}
compressImage(sourceFile, width, targetFile, delSource);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

/**
* 压缩图片,获取图片缩略图
*
* @param sourceFile 源图片地址
* @param width 图片压缩宽度
* @param targetFile 缩略图
* @param delSource 是否删除原图
*/
public static void compressImage(File sourceFile, Integer width, File targetFile, Boolean delSource) {
// String cmd = "ffmpeg -i %s -vf scale=%d:-1 %s -y"
try {
ProcessWrapper ffmpeg = new DefaultFFMPEGLocator().createExecutor();
ffmpeg.addArgument("-i");
ffmpeg.addArgument(sourceFile.getAbsoluteFile().toString());
ffmpeg.addArgument("-vf");
ffmpeg.addArgument(String.format("scale=%d:-1", width));
ffmpeg.addArgument(targetFile.getAbsolutePath());
ffmpeg.addArgument("-y");
ffmpeg.execute();
try (BufferedReader br = new BufferedReader(new InputStreamReader(ffmpeg.getErrorStream()))) {
blockFfmpeg(br);
}
if (delSource) {
FileUtils.deleteDirectory(sourceFile);
}
} catch (IOException e) {
logger.error("压缩图片失败", e);
throw new BizException("生成封面失败");
}
}

定义常量

静态常量,在com.swx.easypan.entity.constants包下的 Constants 类中定义常量

Constants
public class Constants {
public static final String FILE_FOLDER_FILE = "/file/";
public static final String FILE_FOLDER_TEMP = "/file/temp/";
public static final String IMAGE_PNG_SUFFIX = ".png";
public static final String TS_NAME = "index.ts";
public static final String M3U8_NAME = "index.m3u8";
}

文件转码

找到 FileInfoServiceImpl,定义 转码方法 transferFile,添加@Async异步注解

FileInfoServiceImpl
public class FileInfoServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> implements FileInfoService {
private final AppConfig appConfig;

@Async
public void transferFile(String fileId, String userId) {
boolean transferSuccess = true;
String targetFilePath = null;
String cover = null;
FileInfo fileInfo = getByMultiId(fileId, userId);
if (fileInfo == null || !FileStatusEnums.TRANSFER.getStatus().equals(fileInfo.getStatus())) {
return;
}
try {
// 临时目录
String tempFolderName = appConfig.getProjectFolder() + Constants.FILE_FOLDER_TEMP;
String currentUserFolderName = userId + fileId;
File fileFolder = new File(tempFolderName + currentUserFolderName);
String fileSuffix = StringTools.getFileSuffix(fileInfo.getFilename());
String month = fileInfo.getCreateTime().format(DateTimeFormatter.ofPattern(DateTimePatternEnum.YYYYMM.getPattern()));
// 目标目录
String targetFolderName = appConfig.getProjectFolder() + Constants.FILE_FOLDER_FILE;
File targetFolder = new File(targetFolderName + "/" + month);
if (!targetFolder.exists()) {
targetFolder.mkdirs();
}
// 真实文件名
String realFilename = currentUserFolderName + fileSuffix;
targetFilePath = targetFolder.getPath() + "/" + realFilename;
// 合并文件
union(fileFolder.getPath(), targetFilePath, fileInfo.getFilename(), true);
// 视频文件切割
Integer fileType = fileInfo.getFileType();
// 文件缩略图
if (FileTypeEnums.VIDEO.getType().equals(fileType)) {
cutFile4Video(fileId, targetFilePath);
// 视频缩略图
cover = month + "/" + currentUserFolderName + Constants.IMAGE_PNG_SUFFIX;
String coverPath = targetFolderName + "/" + cover;
FfmpegUtil.createTargetThumbnail(new File(targetFilePath), Constants.LENGTH_150, new File(coverPath));
} else if (FileTypeEnums.IMAGE.getType().equals(fileType)) {
// 图片缩略图
cover = month + "/" + realFilename.replace(".", "_.");
String coverPath = targetFolderName + "/" + cover;
Boolean created = FfmpegUtil.createThumbnailWidthFFmpeg(new File(targetFilePath), Constants.LENGTH_150, new File(coverPath), false);
if (!created) {
FileUtils.copyFile(new File(targetFilePath), new File(coverPath));
}
}
} catch (Exception e) {
log.error("文件转码失败, 文件ID: {}, userId: {}", fileId, userId, e);
transferSuccess = false;
} finally {
// 更新文件Size和封面
FileInfo updateInfo = new FileInfo();
if (targetFilePath == null) {
updateInfo.setFileSize(0L);
} else {
updateInfo.setFileSize(new File(targetFilePath).length());
}
updateInfo.setFileCover(cover);
updateInfo.setStatus(transferSuccess ? FileStatusEnums.USING.getStatus() : FileStatusEnums.TRANSFER_FAIL.getStatus());
updateByMultiId(updateInfo, fileId, userId);
}
}

/**
* 多主键更新
*
* @param id 文件ID
* @param userId 用户ID
*/
private Boolean updateByMultiId(FileInfo fileInfo, String id, String userId) {
return update(fileInfo, new LambdaUpdateWrapper<FileInfo>().eq(FileInfo::getId, id).eq(FileInfo::getUserId, userId));
}

/**
* 多主键查询
*
* @param id 文件ID
* @param userId 用户ID
*/
private FileInfo getByMultiId(String id, String userId) {
return getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getId, id).eq(FileInfo::getUserId, userId));
}

/**
* 文件合并
*
* @param dirPath 分片所在目录
* @param toFilePath 合并目标文件
* @param filename 合并文件名
* @param delSource 是否删除分片文件
*/
private void union(String dirPath, String toFilePath, String filename, Boolean delSource) {
File dir = new File(dirPath);
if (!dir.exists()) {
throw new BizException("目录不存在");
}
File[] files = dir.listFiles();
File targetFile = new File(toFilePath);
RandomAccessFile writeFile = null;
try {
writeFile = new RandomAccessFile(targetFile, "rw");
byte[] b = new byte[1024 * 10];
for (int i = 0; i < files.length; i++) {
int len = -1;
File chunkFile = new File(dirPath + "/" + i);
try (RandomAccessFile readFile = new RandomAccessFile(chunkFile, "r")) {
while ((len = readFile.read(b)) != -1) {
writeFile.write(b, 0, len);
}
} catch (Exception e) {
log.error("合并分片失败", e);
throw new BizException("合并分片失败");
}
}
} catch (Exception e) {
log.error("合并文件:{}失败", filename, e);
throw new BizException("合并文件" + filename + "出错了");
} finally {
if (null != writeFile) {
try {
writeFile.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (delSource && dir.exists()) {
try {
FileUtils.deleteDirectory(dir);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

/**
* 视频文件切片
*
* @param fileId 文件ID
* @param videoFilePath 分片到目录
*/
private void cutFile4Video(String fileId, String videoFilePath) {
// 创建同名切片目录
File tsFolder = new File(videoFilePath.substring(0, videoFilePath.lastIndexOf(".")));
if (!tsFolder.exists()) {
tsFolder.mkdirs();
}
// 生成ts
String tsPath = tsFolder + "/" + Constants.TS_NAME;
FfmpegUtil.transfer2ts(videoFilePath, tsPath);
// 生成索引文件.m3u8和切片.ts文件
String indexTs = tsFolder.getPath() + "/" + Constants.M3U8_NAME;
FfmpegUtil.cutTs(tsPath, indexTs, tsFolder.getPath(), fileId);
// 删除index.ts
new File(tsPath).delete();
}
}