提交 47dbe6cd 作者: 方治民

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

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