提交 47dbe6cd 作者: 方治民

feat: 采用本地环境 ffmpeg + wrap 方式处理音视频,减少依赖包大小

上级 075e438f
......@@ -54,10 +54,6 @@ dependencies {
implementation "io.minio:minio:${minioVersion}"
implementation "com.squareup.okhttp3:okhttp:${okhttpVersion}"
// Optional: 扩展实现在文件上传时对文件进行预处理,依赖 Minio 模块
// https://mvnrepository.com/artifact/org.bytedeco/javacv
implementation 'org.bytedeco:javacv:1.5.7'
// https://mvnrepository.com/artifact/org.bytedeco/ffmpeg-platform
implementation 'org.bytedeco:ffmpeg-platform:5.0-1.5.7'
// https://mvnrepository.com/artifact/org.apache.pdfbox/pdfbox
implementation "org.apache.pdfbox:pdfbox:${pdfboxVersion}"
// https://mvnrepository.com/artifact/net.bramp.ffmpeg/ffmpeg
......
......@@ -2,17 +2,17 @@
package com.yiring.app.service.upload;
import cn.hutool.core.io.FileUtil;
import com.yiring.common.config.EnvConfig;
import com.yiring.common.core.Minio;
import com.yiring.common.service.UploadProcessService;
import com.yiring.common.util.Commons;
import io.minio.ObjectWriteResponse;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
......@@ -22,9 +22,14 @@ import lombok.Cleanup;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import net.bramp.ffmpeg.FFmpeg;
import net.bramp.ffmpeg.FFmpegExecutor;
import net.bramp.ffmpeg.FFprobe;
import net.bramp.ffmpeg.builder.FFmpegBuilder;
import net.bramp.ffmpeg.probe.FFmpegFormat;
import net.bramp.ffmpeg.probe.FFmpegProbeResult;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.bytedeco.javacv.*;
import org.springframework.context.annotation.Primary;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
......@@ -42,11 +47,15 @@ import org.springframework.stereotype.Component;
public class UploadProcessServiceImpl implements UploadProcessService {
final Minio minio;
final EnvConfig env;
Pattern pattern = Pattern.compile("^.*\\.ts$");
@SneakyThrows
@Override
public String handle(String object, InputStream is) {
public String handle(String object, Path path) {
@Cleanup
FileInputStream is = new FileInputStream(path.toFile());
String suffix = FileUtil.getSuffix(object);
// Image: 在文件名上追加图片物理像素
......@@ -61,7 +70,7 @@ public class UploadProcessServiceImpl implements UploadProcessService {
// Video/Audio: 在文件名上追加时长,视频生成封面图
if (isSupportiveMedia(suffix)) {
object = handleMedia(object, suffix, is);
object = handleMedia(object, suffix, path);
}
return object;
......@@ -98,123 +107,111 @@ public class UploadProcessServiceImpl implements UploadProcessService {
}
@SneakyThrows
public String handleMedia(String object, String suffix, InputStream is) {
// 判断是否为视频
@Cleanup
FFmpegFrameGrabber ff = new FFmpegFrameGrabber(is);
ff.start();
public String handleMedia(String object, String suffix, Path path) {
// 判断是否配置 ffmpeg 环境
FFmpeg ffmpeg = new FFmpeg();
FFprobe ffprobe = new FFprobe();
if (!ffmpeg.isFFmpeg() || !ffprobe.isFFprobe()) {
return object;
}
// 解析媒体文件
FFmpegProbeResult probeResult = ffprobe.probe(path.toString());
FFmpegFormat format = probeResult.getFormat();
// 构建具有时长(秒)标记的存储地址
String filepath = fillSuffix(object, "T" + (ff.getLengthInTime() / (1000 * 1000)));
long sec = (long) format.duration;
String filepath = fillSuffix(object, "T" + sec);
// 视频截取首帧可见画面作为封面
if (isSupportiveVideo(suffix)) {
// 获取视频可见画面帧
Frame frame = getPictureFrame(ff);
// 将截取的封面并存储
String format = "jpg";
@Cleanup
ByteArrayOutputStream os = new ByteArrayOutputStream();
ImageIO.write(frameToBufferedImage(frame), format, os);
@Cleanup
InputStream io = new ByteArrayInputStream(os.toByteArray());
int size = io.available();
minio.putObject(io, MediaType.IMAGE_JPEG_VALUE, filepath + "." + format);
// 使用 ffmpeg 截取视频首帧图片
Path tempFile = Paths.get(path.getParent().toString(), FileUtil.getName(filepath) + ".jpg");
FFmpegBuilder builder = new FFmpegBuilder()
.setInput(path.toString())
.overrideOutputFiles(true)
.addOutput(tempFile.toString())
.setFrames(1)
.done();
FFmpegExecutor executor = new FFmpegExecutor(ffmpeg, ffprobe);
executor.createJob(builder).run();
// 判断封面图是否生成
if (Files.exists(tempFile)) {
// 上传封面图
minio.putObject(tempFile.toFile(), getObjectFolder(filepath));
}
// 大视频文件切片上传(> 10M
if (size > (10 * 10 * 1024)) {
filepath = fillSuffix(handleToM3u8(object, suffix, ff), "T" + (ff.getLengthInTime() / (1000 * 1000)));
// 大视频文件切片上传(> 5s
if (sec > 5) {
filepath = handleToHls(filepath, path, ffmpeg, ffprobe);
}
}
ff.stop();
return filepath;
}
@SneakyThrows
public String handleToM3u8(String object, String suffix, FFmpegFrameGrabber ff) {
File tmpDir = FileUtil.getTmpDir();
String sourceName = FileUtil.getName(object);
String objectFolder = object.replace("/" + sourceName, "");
Path m3u8 = Paths.get(
tmpDir.getPath(),
Commons.uuid(),
sourceName.replaceAll("^(.*)\\." + suffix + "$", "$1.m3u8")
);
FileUtil.mkParentDirs(m3u8);
String out = m3u8.toFile().getPath();
long start = System.currentTimeMillis();
@Cleanup
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(
out,
ff.getImageWidth(),
ff.getImageHeight(),
ff.getAudioChannels()
);
recorder.setFormat("hls");
recorder.setOption("hls_wrap", "0");
recorder.setOption("hls_time", "5");
recorder.setOption("hls_list_size", "0");
recorder.setOption("hls_flags", "delete_segments");
recorder.setOption("hls_segment_type", "mpegts");
recorder.setOption("hls_segment_filename", out.replace(".m3u8", "-%d.ts"));
recorder.setOption("hls_delete_threshold", "1");
recorder.setOption("vsync", "2");
recorder.setOption("c:v", "copy");
recorder.setOption("c:a", "copy");
recorder.setOption("tune", "fastdecode");
recorder.setOption("threads", "8");
recorder.start();
Frame frame;
while ((frame = ff.grabImage()) != null) {
try {
recorder.record(frame);
} catch (FrameRecorder.Exception e) {
log.error(e.getMessage(), e);
public String handleToHls(String object, Path path, FFmpeg ffmpeg, FFprobe ffprobe) {
String originName = FileUtil.getName(object);
String objectFolder = object.replace("/" + originName, "");
String targetName = originName.replaceAll("^(.*)\\." + FileUtil.getSuffix(originName) + "$", "$1.m3u8");
// 使用 ffmpeg 将视频文件转换成 hls 切片文件 m3u8+ts
// "-vsync", "2", "-c:v", "copy", "-c:a", "copy", "-tune", "fastdecode", "-hls_wrap", "0", "-hls_time", "10", "-hls_list_size", "0", "-threads", "12"
final FFmpegProbeResult probe = ffprobe.probe(path.toString());
Path tempFile = Paths.get(path.getParent().toString(), targetName);
FFmpegBuilder builder = new FFmpegBuilder()
.setInput(path.toString())
.overrideOutputFiles(true)
.addOutput(tempFile.toString())
.setFormat(probe.getFormat().format_name)
.setStrict(FFmpegBuilder.Strict.STRICT)
.setFormat("hls")
.setPreset("ultrafast")
.addExtraArgs(
"-vsync",
"2",
"-c:v",
"copy",
"-c:a",
"copy",
"-tune",
"fastdecode",
"-hls_wrap",
"0",
"-hls_time",
"5",
"-hls_list_size",
"0",
"-hls_segment_filename",
tempFile.toString().replaceAll("^(.*)\\.m3u8$", "$1-%d.ts"),
"-threads",
"8"
)
.done();
FFmpegExecutor executor = new FFmpegExecutor(ffmpeg, ffprobe);
executor.createJob(builder).run();
if (Files.exists(tempFile)) {
// 解析 m3u8 文件
List<String> lines = FileUtil.readLines(tempFile.toString(), StandardCharsets.UTF_8);
// 获取 ts 切片文件
List<String> tss = lines.stream().filter(line -> pattern.matcher(line).matches()).toList();
// 上传 ts 切片文件
for (String ts : tss) {
Path temp = Paths.get(tempFile.getParent().toString(), ts);
minio.putObject(temp.toFile(), objectFolder);
}
}
recorder.setTimestamp(ff.getTimestamp());
recorder.flush();
long end = System.currentTimeMillis();
long times = end - start;
log.info("[Times] {}: {} ms", "video convert to m3u8", times);
// 解析 m3u8 文件
List<String> lines = FileUtil.readLines(m3u8.toString(), StandardCharsets.UTF_8);
// 获取 ts 切片文件
List<String> tss = lines.stream().filter(line -> pattern.matcher(line).matches()).toList();
// 上传 ts 切片文件
for (String ts : tss) {
Path path = Paths.get(m3u8.getParent().toString(), ts);
minio.putObject(path.toFile(), objectFolder);
}
// 上传 m3u8 索引文件
ObjectWriteResponse objectWriteResponse = minio.putObject(m3u8.toFile(), objectFolder);
return objectWriteResponse.object();
}
// 上传 m3u8 索引文件
ObjectWriteResponse objectWriteResponse = minio.putObject(tempFile.toFile(), objectFolder);
object = objectWriteResponse.object();
public static Frame getPictureFrame(FFmpegFrameGrabber grabber) throws FFmpegFrameGrabber.Exception {
int ftp = grabber.getLengthInFrames();
int flag = 0;
Frame frame = null;
while (flag <= ftp) {
// 获取帧
frame = grabber.grabImage();
// 过滤前3帧,避免出现全黑图片
if ((flag > 3) && (frame != null)) {
break;
}
flag++;
// 手动 GC 一次
System.gc();
}
return frame;
}
public static RenderedImage frameToBufferedImage(Frame frame) {
@Cleanup
Java2DFrameConverter converter = new Java2DFrameConverter();
return converter.getBufferedImage(frame);
return object;
}
public static boolean isSupportiveMedia(String suffix) {
......@@ -242,4 +239,9 @@ public class UploadProcessServiceImpl implements UploadProcessService {
String regex = "^(.*)\\." + suffix + "$";
return object.replaceAll(regex, "$1." + fill + "." + suffix);
}
public static String getObjectFolder(String object) {
String sourceName = FileUtil.getName(object);
return object.replace("/" + sourceName, "");
}
}
......@@ -12,6 +12,7 @@ import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
import lombok.Cleanup;
import lombok.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
......@@ -106,7 +107,9 @@ public record Minio(MinioConfig config, MinioClient client) {
}
Path path = file.toPath();
return putObject(Files.newInputStream(path), Files.probeContentType(path), object);
@Cleanup
InputStream inputStream = Files.newInputStream(path);
return putObject(inputStream, Files.probeContentType(path), object);
}
/**
......
/* (C) 2022 YiRing, Inc. */
package com.yiring.common.service;
import java.io.InputStream;
import java.nio.file.Path;
/**
* 文件上传媒体文件预处理服务
......@@ -19,10 +19,10 @@ public interface UploadProcessService {
* 视频封面:haha.mp4 -> haha.mp4.jpg 截取视频封面
*
* @param object 上传文件存储地址
* @param is 文件流
* @param path 文件地址
* @return 预处理后的文件地址(可能对文件名追加了时长、页数、分辨率等标识)
*/
default String handle(String object, InputStream is) {
default String handle(String object, Path path) {
return object;
}
}
/* (C) 2023 YiRing, Inc. */
package com.yiring.common.service.impl;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.yiring.common.core.Minio;
import com.yiring.common.service.FileManageService;
import com.yiring.common.service.UploadProcessService;
import com.yiring.common.util.Commons;
import java.nio.file.Path;
import java.nio.file.Paths;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
......@@ -39,11 +43,20 @@ public class FileManageServiceImpl implements FileManageService {
String uuid = IdUtil.fastSimpleUUID();
String object = minio.buildUploadPath(filename, "", uuid);
// 将上传的文件转存一份到本地临时目录
Path tempFile = Paths.get(FileUtil.getTmpDirPath(), Commons.uuid(), filename);
FileUtil.mkParentDirs(tempFile);
file.transferTo(tempFile);
// 预处理(默认不做任何处理,具体逻辑需自行在外部实现)
object = service.handle(object, file.getInputStream());
// 上传原文件
minio.putObject(file.getInputStream(), file.getContentType(), object);
object = service.handle(object, tempFile);
// 删除为本次上传进行本地处理所创建的整个临时文件夹目录
FileUtil.del(tempFile.getParent());
// 上传原文件(如果是转换成了 m3u8 hls 文件则不保存原文件)
String suffix = ".m3u8";
if (filename.endsWith(suffix) || !object.endsWith(suffix)) {
minio.putObject(file.getInputStream(), file.getContentType(), object);
}
return minio.getDefaultURI(object);
}
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论