From 5cc67d486ac20a19a3eb3f40ca7514ab53a470f1 Mon Sep 17 00:00:00 2001 From: jmal Date: Thu, 20 Jun 2024 17:15:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E6=B7=BB=E5=8A=A0Vtt=E7=BC=A9=E7=95=A5=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/rest/VideoController.java | 37 +++-- .../jmal/clouddisk/lucene/LuceneService.java | 13 +- .../service/impl/CommonFileService.java | 17 +-- .../service/impl/FileServiceImpl.java | 15 +- .../clouddisk/util/FileContentTypeUtils.java | 1 + .../com/jmal/clouddisk/util/MyFileUtils.java | 33 ++++- .../com/jmal/clouddisk/video/VideoInfo.java | 13 +- .../jmal/clouddisk/video/VideoInfoUtil.java | 11 +- .../clouddisk/video/VideoProcessService.java | 131 ++++++++++++++++-- 9 files changed, 216 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/jmal/clouddisk/controller/rest/VideoController.java b/src/main/java/com/jmal/clouddisk/controller/rest/VideoController.java index cf21a963..21c99b03 100644 --- a/src/main/java/com/jmal/clouddisk/controller/rest/VideoController.java +++ b/src/main/java/com/jmal/clouddisk/controller/rest/VideoController.java @@ -1,9 +1,12 @@ package com.jmal.clouddisk.controller.rest; +import cn.hutool.core.io.FileUtil; import com.jmal.clouddisk.config.FileProperties; import com.jmal.clouddisk.service.IShareService; +import com.jmal.clouddisk.util.FileContentTypeUtils; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; import org.springframework.core.io.UrlResource; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -12,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController; import java.io.IOException; +import java.net.MalformedURLException; import java.nio.file.Path; import java.nio.file.Paths; @@ -27,20 +31,31 @@ public class VideoController { @GetMapping("/video/hls/{username}/{fileId}.m3u8") public ResponseEntity m3u8(@PathVariable String username, @PathVariable String fileId) throws IOException { - Path m3u8Path = Paths.get(fileProperties.getRootDir(), fileProperties.getChunkFileDir(), username, fileProperties.getVideoTranscodeCache(), fileId, fileId + ".m3u8"); - UrlResource videoResource = new UrlResource(m3u8Path.toUri()); + return getUrlResourceResponseEntity(username, fileId, fileId + ".m3u8"); + } + + private @NotNull ResponseEntity getUrlResourceResponseEntity(String username, String fileId, String suffix) throws MalformedURLException { + Path txtPath = Paths.get(fileProperties.getRootDir(), fileProperties.getChunkFileDir(), username, fileProperties.getVideoTranscodeCache(), fileId, suffix); + UrlResource videoResource = new UrlResource(txtPath.toUri()); return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_TYPE, "application/vnd.apple.mpegurl") + .header(HttpHeaders.CONTENT_TYPE, FileContentTypeUtils.getContentType(FileUtil.extName(suffix))) + .header(HttpHeaders.CACHE_CONTROL, "max-age=600") .body(videoResource); } + @GetMapping("/video/hls/{username}/{fileId}.vtt") + public ResponseEntity vtt(@PathVariable String username, @PathVariable String fileId) throws IOException { + return getUrlResourceResponseEntity(username, fileId, fileId + ".vtt"); + } + + @GetMapping("/video/hls/{username}/{fileId}-vtt.jpg") + public ResponseEntity vttPNG(@PathVariable String username, @PathVariable String fileId) throws IOException { + return getUrlResourceResponseEntity(username, fileId, fileId + "-vtt.jpg"); + } + @GetMapping("/video/hls/{username}/{fileId}-{index}.ts") public ResponseEntity ts(@PathVariable String username, @PathVariable String fileId, @PathVariable String index) throws IOException { - Path m3u8Path = Paths.get(fileProperties.getRootDir(), fileProperties.getChunkFileDir(), username, fileProperties.getVideoTranscodeCache(), fileId, fileId + "-" + index + ".ts"); - UrlResource videoResource = new UrlResource(m3u8Path.toUri()); - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_TYPE, "application/vnd.apple.mpegurl") - .body(videoResource); + return getUrlResourceResponseEntity(username, fileId, fileId + "-" + index + ".ts"); } @GetMapping("/public/video/hls/{shareId}/{shareToken}/{username}/{fileId}.m3u8") @@ -49,6 +64,12 @@ public ResponseEntity publicM3u8(@PathVariable String username, @Pa return m3u8(username, fileId); } + @GetMapping("/public/video/hls/{shareId}/{shareToken}/{username}/{fileId}.vtt") + public ResponseEntity publicVtt(@PathVariable String username, @PathVariable String fileId, @PathVariable String shareId, @PathVariable String shareToken) throws IOException { + shareService.validShare(shareToken, shareId); + return vtt(username, fileId); + } + @GetMapping("/public/video/hls/{shareId}/{shareToken}/{username}/{fileId}-{index}.ts") public ResponseEntity publicTs(@PathVariable String username, @PathVariable String fileId, @PathVariable String index, @PathVariable String shareId, @PathVariable String shareToken) throws IOException { shareService.validShare(shareToken, shareId); diff --git a/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java b/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java index f7795cad..a0283d90 100644 --- a/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java +++ b/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java @@ -370,17 +370,16 @@ private boolean checkFileContent(File file) { return false; } String type = FileTypeUtil.getType(file); - if (MyFileUtils.hasContentFile(type)) return true; - String charset = UniversalDetector.detectCharset(file); - if (StrUtil.isNotBlank(charset)) { - if (fileProperties.getSimText().contains(type)) { - return true; - } + if (MyFileUtils.hasContentFile(type)) { + return true; + } + if (fileProperties.getSimText().contains(type)) { + return true; } + return MyFileUtils.hasCharset(file); } catch (Exception e) { return false; } - return false; } diff --git a/src/main/java/com/jmal/clouddisk/service/impl/CommonFileService.java b/src/main/java/com/jmal/clouddisk/service/impl/CommonFileService.java index f6835ad5..c11517bf 100644 --- a/src/main/java/com/jmal/clouddisk/service/impl/CommonFileService.java +++ b/src/main/java/com/jmal/clouddisk/service/impl/CommonFileService.java @@ -536,18 +536,11 @@ private void processImage(File file, Update update) { public static String getContentType(File file, String contentType) { try { - if (file == null) { - return contentType; - } - if (file.isDirectory()) { - return contentType; - } - if (contentType.contains(Constants.CONTENT_TYPE_MARK_DOWN)) { - return contentType; - } - String charset = UniversalDetector.detectCharset(file); - if (StrUtil.isNotBlank(charset) && StandardCharsets.UTF_8.name().equals(charset)) { - contentType = contentType + ";charset=utf-8"; + if (MyFileUtils.hasCharset(file)) { + String charset = UniversalDetector.detectCharset(file); + if (StrUtil.isNotBlank(charset) && StandardCharsets.UTF_8.name().equals(charset)) { + contentType = contentType + ";charset=utf-8"; + } } } catch (Exception e) { log.error(e.getMessage(), e); diff --git a/src/main/java/com/jmal/clouddisk/service/impl/FileServiceImpl.java b/src/main/java/com/jmal/clouddisk/service/impl/FileServiceImpl.java index 637440fa..7c0b470a 100644 --- a/src/main/java/com/jmal/clouddisk/service/impl/FileServiceImpl.java +++ b/src/main/java/com/jmal/clouddisk/service/impl/FileServiceImpl.java @@ -319,7 +319,6 @@ public ResponseResult searchFileAndOpenDir(UploadApiParamDTO upload, Str if (ossPath != null) { return webOssService.searchFileAndOpenOssFolder(path, upload); } - FileDocument fileDocument = mongoTemplate.findById(id, FileDocument.class, COLLECTION_NAME); if (fileDocument == null) { return ResultUtil.error(ExceptionType.FILE_NOT_FIND); @@ -501,10 +500,12 @@ public Optional getById(String id, Boolean content) { Path filepath = Paths.get(fileProperties.getRootDir(), username, currentDirectory, fileDocument.getName()); if (Files.exists(filepath)) { File file = filepath.toFile(); - Charset charset = MyFileUtils.getFileCharset(file); - fileDocument.setDecoder(charset.name()); - if (BooleanUtil.isTrue(content)) { - fileDocument.setContentText(FileUtil.readString(file, charset)); + if (MyFileUtils.hasCharset(file)) { + Charset charset = MyFileUtils.getFileCharset(file); + fileDocument.setDecoder(charset.name()); + if (BooleanUtil.isTrue(content)) { + fileDocument.setContentText(FileUtil.readString(file, charset)); + } } } return Optional.of(fileDocument); @@ -545,7 +546,9 @@ public FileDocument previewTextByPath(String filePath, String username) throws C throw new CommonException(ExceptionType.FILE_NOT_FIND); } FileDocument fileDocument = new FileDocument(); - fileDocument.setDecoder(MyFileUtils.getFileCharset(file).name()); + if (MyFileUtils.hasCharset(file)) { + fileDocument.setDecoder(MyFileUtils.getFileCharset(file).name()); + } Path path1 = path.subpath(0, path.getNameCount() - 1); int rootCount = Paths.get(fileProperties.getRootDir(), username).getNameCount(); int path1Count = path1.getNameCount(); diff --git a/src/main/java/com/jmal/clouddisk/util/FileContentTypeUtils.java b/src/main/java/com/jmal/clouddisk/util/FileContentTypeUtils.java index 67a01871..cb5d5013 100644 --- a/src/main/java/com/jmal/clouddisk/util/FileContentTypeUtils.java +++ b/src/main/java/com/jmal/clouddisk/util/FileContentTypeUtils.java @@ -333,6 +333,7 @@ public static String getContentType(String ext) { CONTENT_TYPE.put("m2t" , "video/mpeg"); CONTENT_TYPE.put("m3u" , "audio/x-mpegurl"); CONTENT_TYPE.put("m3u8" , "audio/x-mpegurl"); + CONTENT_TYPE.put("vtt" , "audio/x-mpegurl"); CONTENT_TYPE.put("m4" , "application/x-m4"); CONTENT_TYPE.put("m4a" , "audio/mp4"); CONTENT_TYPE.put("m4b" , "audio/x-m4b"); diff --git a/src/main/java/com/jmal/clouddisk/util/MyFileUtils.java b/src/main/java/com/jmal/clouddisk/util/MyFileUtils.java index 758f0665..8050d156 100644 --- a/src/main/java/com/jmal/clouddisk/util/MyFileUtils.java +++ b/src/main/java/com/jmal/clouddisk/util/MyFileUtils.java @@ -1,7 +1,10 @@ package com.jmal.clouddisk.util; +import cn.hutool.core.io.CharsetDetector; import cn.hutool.core.io.FileTypeUtil; +import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.StrUtil; +import com.jmal.clouddisk.service.Constants; import lombok.extern.slf4j.Slf4j; import org.mozilla.universalchardet.UniversalDetector; @@ -19,12 +22,37 @@ @Slf4j public class MyFileUtils { - public static List hasContentTypes = Arrays.asList("pdf", "ppt", "pptx", "doc", "docx", "drawio", "mind"); + public static List hasContentTypes = Arrays.asList("pdf", "drawio", "mind", "doc", "docx", "xls", "xlsx", "xlsm", "ppt", "pptx", "csv", "tsv", "dotm", "xlt", "xltm", "dot", "dotx", "xlam", "xla", "pages"); private MyFileUtils(){ } + public static boolean hasCharset(File file) { + try { + if (file == null) { + return false; + } + String suffix = FileUtil.extName(file.getName()); + String contentType = FileContentTypeUtils.getContentType(suffix); + if (file.isDirectory()) { + return false; + } + if (contentType.contains(Constants.VIDEO)) { + return false; + } + if (contentType.contains(Constants.CONTENT_TYPE_IMAGE)) { + return false; + } + if (contentType.contains(Constants.AUDIO)) { + return false; + } + return CharsetDetector.detect(file) != null; + } catch (Exception e) { + return false; + } + } + /*** * 获取文件的字符编码 * @param file 源文件 @@ -52,8 +80,7 @@ public static boolean checkNoCacheFile(File file) { } String type = FileTypeUtil.getType(file); if (hasContentFile(type)) return true; - String charset = UniversalDetector.detectCharset(file); - return !StrUtil.isBlank(charset); + return hasCharset(file); } catch (Exception e) { return false; } diff --git a/src/main/java/com/jmal/clouddisk/video/VideoInfo.java b/src/main/java/com/jmal/clouddisk/video/VideoInfo.java index 4cc9b780..4792516a 100644 --- a/src/main/java/com/jmal/clouddisk/video/VideoInfo.java +++ b/src/main/java/com/jmal/clouddisk/video/VideoInfo.java @@ -22,9 +22,14 @@ public VideoInfo() { this.duration = 0; } - public VideoInfo(String videoPath, int width, int height, String format, int bitrate, int duration) { - this.width = width; - this.height = height; + public VideoInfo(String videoPath, int width, int height, String format, int bitrate, int duration, int rotation) { + if (rotation == 90 || rotation == 270) { + this.width = height; + this.height = width; + } else { + this.width = width; + this.height = height; + } this.format = format; this.bitrate = bitrate; this.duration = duration; @@ -40,7 +45,7 @@ public VideoInfoDO toVideoInfoDO() { videoInfoDO.setFormat(this.format); } if (this.duration > 0) { - videoInfoDO.setDuration(VideoInfoUtil.formatTimestamp(this.duration)); + videoInfoDO.setDuration(VideoInfoUtil.formatTimestamp(this.duration, false)); } if (this.height > 0 && this.width > 0) { videoInfoDO.setHeight(this.height); diff --git a/src/main/java/com/jmal/clouddisk/video/VideoInfoUtil.java b/src/main/java/com/jmal/clouddisk/video/VideoInfoUtil.java index 63443cc6..f7d0b996 100644 --- a/src/main/java/com/jmal/clouddisk/video/VideoInfoUtil.java +++ b/src/main/java/com/jmal/clouddisk/video/VideoInfoUtil.java @@ -20,13 +20,18 @@ public static String convertBitrateToReadableFormat(long bitrate) { /** * 将视频时长换为可读的格式 * @param timestamp 视频时长(秒) + * @param milliseconds 是否包含毫秒 * @return 可读的视频时长格式 */ - public static String formatTimestamp(int timestamp) { + public static String formatTimestamp(int timestamp, boolean milliseconds) { int hours = timestamp / 3600; int minutes = (timestamp % 3600) / 60; int seconds = timestamp % 60; - return String.format("%02d:%02d:%02d", hours, minutes, seconds); + if (milliseconds) { + return String.format("%02d:%02d:%02d.000", hours, minutes, seconds); + } else { + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } } public static void main(String[] args) { @@ -34,7 +39,7 @@ public static void main(String[] args) { String readableBitrate = convertBitrateToReadableFormat(bitrate); System.out.println("可读的比特率格式为: " + readableBitrate); int duration = 3600; // 示例视频时长 - String readableDuration = formatTimestamp(duration); + String readableDuration = formatTimestamp(duration, false); System.out.println("可读的视频时长格式为: " + readableDuration); } diff --git a/src/main/java/com/jmal/clouddisk/video/VideoProcessService.java b/src/main/java/com/jmal/clouddisk/video/VideoProcessService.java index 890a5254..5a266827 100644 --- a/src/main/java/com/jmal/clouddisk/video/VideoProcessService.java +++ b/src/main/java/com/jmal/clouddisk/video/VideoProcessService.java @@ -32,6 +32,7 @@ import java.io.*; import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; @@ -82,6 +83,11 @@ public class VideoProcessService { */ private final String[] WEB_SUPPORTED_FORMATS = {"mp4", "webm", "ogg", "flv", "hls", "mkv"}; + /** + * 缩略图宽度 + */ + private static final int thumbnailWidth = 128; + private final static String TRANSCODE_VIDEO = "transcodeVideo"; @PostConstruct @@ -310,14 +316,14 @@ public VideoInfo getVideoCover(String fileId, String username, String relativePa */ private static ProcessBuilder getVideoCoverProcessBuilder(String videoPath, String outputPath, int videoDuration) { int targetTimestamp = (int) (videoDuration * 0.1); - String formattedTimestamp = VideoInfoUtil.formatTimestamp(targetTimestamp); + String formattedTimestamp = VideoInfoUtil.formatTimestamp(targetTimestamp, false); log.debug("\r\nvideoPath: {}, formattedTimestamp: {}", videoPath, formattedTimestamp); ProcessBuilder processBuilder = new ProcessBuilder( Constants.FFMPEG, "-y", "-ss", formattedTimestamp, "-i", videoPath, - "-vf", "scale=320:-2", + "-vf", String.format("scale=%s:-2", thumbnailWidth), "-frames:v", "1", outputPath ); @@ -373,14 +379,23 @@ private void videoToM3U8(String fileId, String username, String relativePath, St if (videoInfo.getHeight() < targetHeight) { targetHeight = videoInfo.getHeight(); } - ProcessBuilder processBuilder = cpuTranscoding(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath); + + // 计算缩略图间隔 + int vttInterval = getVttInterval(videoInfo); + Path vttPath = Paths.get(videoCacheDir, "vtt"); + if (!Files.exists(vttPath)) { + FileUtil.mkdir(vttPath); + } + String thumbnailPattern = Paths.get(vttPath.toString(), "thumb_%03d.png").toString(); + + ProcessBuilder processBuilder = cpuTranscoding(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern); if (!onlyCPU && checkNvidiaDrive()) { log.info("use NVENC hardware acceleration"); - processBuilder = useNvencCuda(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath); + processBuilder = useNvencCuda(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern); } if (!onlyCPU && checkMacAppleSilicon()) { log.info("use videotoolbox hardware acceleration"); - processBuilder = useVideotoolbox(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath); + processBuilder = useVideotoolbox(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern); } processBuilder.redirectErrorStream(true); Process process = processBuilder.start(); @@ -404,6 +419,16 @@ private void videoToM3U8(String fileId, String username, String relativePath, St } transcodingProgress(fileAbsolutePath, videoInfo.getDuration(), line); } + String vttFilePath = Paths.get(videoCacheDir, fileId + ".vtt").toString(); + + int columns = 10; // 合并图像的列数 + int rows = (int) Math.ceil((double) videoInfo.getDuration() / vttInterval / columns); + + String thumbnailImagePath = Paths.get(videoCacheDir, fileId + "-vtt.jpg").toString(); + + ProcessBuilder processBuilder1 = mergeVTT(thumbnailPattern, thumbnailImagePath, columns, rows); + processBuilder1.start().waitFor(); + generateVTT(videoInfo, vttInterval, vttFilePath, String.format("%s-vtt.jpg", fileId), 10); } finally { taskProgressService.removeTaskProgress(fileAbsolutePath.toFile()); } @@ -422,7 +447,72 @@ private void videoToM3U8(String fileId, String username, String relativePath, St } } - private static ProcessBuilder useVideotoolbox(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath) { + /** + * 获取VTT文件间隔 + * @param videoInfo 视频信息 + * @return VTT文件间隔 + */ + private int getVttInterval(VideoInfo videoInfo) { + // 期望的缩略图数量 50张 + int videoDuration = videoInfo.getDuration(); + // 计算缩略图间隔 + if (videoDuration <= 50) { + return 1; + } + return videoDuration / 50; + } + + /** + * 生成VTT文件 + * @param videoInfo 视频信息 + * @param interval 缩略图间隔 + * @param vttFilePath VTT文件路径 + * @param thumbnailImagePath 最终缩略图路径 + */ + public static void generateVTT(VideoInfo videoInfo, int interval, String vttFilePath, String thumbnailImagePath, int columns) { + try (BufferedWriter writer = new BufferedWriter(new FileWriter(vttFilePath))) { + writer.write("WEBVTT\n\n"); + int expectedThumbnails = videoInfo.getDuration() / interval; + for (int i = 0; i < expectedThumbnails; i++) { + String startTime = VideoInfoUtil.formatTimestamp(i * interval, true); + String endTime = VideoInfoUtil.formatTimestamp((i + 1) * interval, true); + + int column = i % columns; + int row = i / columns; + int thumbWidth = thumbnailWidth; + double tHeight = (double) videoInfo.getHeight() / ((double) videoInfo.getWidth() / thumbnailWidth); + int thumbHeight = (int) Math.ceil(tHeight); + int x = column * thumbWidth; + int y = row * thumbHeight; + + writer.write(String.format("%s --> %s\n", startTime, endTime)); + writer.write(String.format("%s#xywh=%d,%d,%d,%d\n\n", thumbnailImagePath, x, y, thumbWidth, thumbHeight)); + } + } catch (IOException e) { + log.warn(e.getMessage(), e); + } + } + + /** + * 合并缩略图 + * @param inputPattern 输入文件名格式 + * @param outputImage 输出图片 + * @param columns 列数 + * @param rows 行数 + * @return ProcessBuilder + */ + private static ProcessBuilder mergeVTT(String inputPattern, String outputImage, int columns, int rows) { + ProcessBuilder processBuilder = new ProcessBuilder( + Constants.FFMPEG, + "-i", inputPattern, + "-filter_complex", String.format("tile=%dx%d", columns, rows), + outputImage + ); + processBuilder.redirectErrorStream(true); + return processBuilder; + } + + private static ProcessBuilder useVideotoolbox(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern) { return new ProcessBuilder( Constants.FFMPEG, "-hwaccel", "videotoolbox", @@ -441,11 +531,13 @@ private static ProcessBuilder useVideotoolbox(String fileId, Path fileAbsolutePa "-sc_threshold", "0", "-f", "hls", "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(), - outputPath + outputPath, + "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval), + thumbnailPattern ); } - private static ProcessBuilder cpuTranscoding(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath) { + private static ProcessBuilder cpuTranscoding(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern) { return new ProcessBuilder( Constants.FFMPEG, @@ -463,11 +555,13 @@ private static ProcessBuilder cpuTranscoding(String fileId, Path fileAbsolutePat "-sc_threshold", "0", "-f", "hls", "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(), - outputPath + outputPath, + "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval), + thumbnailPattern ); } - private ProcessBuilder useNvencCuda(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath) { + private ProcessBuilder useNvencCuda(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern) { // 使用CUDA硬件加速和NVENC编码器 return new ProcessBuilder( Constants.FFMPEG, @@ -506,7 +600,9 @@ private ProcessBuilder useNvencCuda(String fileId, Path fileAbsolutePath, int bi "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(), "-hls_playlist_type", "vod", "-hls_list_size", "0", - outputPath + outputPath, + "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval), + thumbnailPattern ); } @@ -666,8 +762,19 @@ private VideoInfo getVideoInfo(String videoPath) { JSONObject streamObject = streamsArray.getJSONObject(0); int width = streamObject.getIntValue("width"); int height = streamObject.getIntValue("height"); + int rotation = 0; + if (streamObject.containsKey("side_data_list")) { + for (int i = 0; i < streamObject.getJSONArray("side_data_list").size(); i++) { + JSONObject sideData = streamObject.getJSONArray("side_data_list").getJSONObject(i); + if (sideData.containsKey("rotation")) { + rotation = sideData.getIntValue("rotation"); + break; + } + } + } + // 转换rotation int bitrate = streamObject.getIntValue("bit_rate"); // bps - return new VideoInfo(videoPath, width, height, format, bitrate, duration); + return new VideoInfo(videoPath, width, height, format, bitrate, duration, Math.abs(rotation)); } } From f49c8ad1fdd01716d0a749a4ece52095c9f19568 Mon Sep 17 00:00:00 2001 From: jmal Date: Thu, 20 Jun 2024 18:16:49 +0800 Subject: [PATCH 2/2] refactor: Optimize code --- .../com/jmal/clouddisk/ocr/OcrService.java | 4 +- .../com/jmal/clouddisk/util/FFMPEGUtils.java | 20 - .../jmal/clouddisk/video/FFMPEGCommand.java | 278 +++++++++++ .../com/jmal/clouddisk/video/FFMPEGUtils.java | 111 +++++ .../jmal/clouddisk/video/TranscodeConfig.java | 5 + .../clouddisk/video/VideoProcessService.java | 434 +++--------------- 6 files changed, 461 insertions(+), 391 deletions(-) create mode 100644 src/main/java/com/jmal/clouddisk/video/FFMPEGCommand.java create mode 100644 src/main/java/com/jmal/clouddisk/video/FFMPEGUtils.java diff --git a/src/main/java/com/jmal/clouddisk/ocr/OcrService.java b/src/main/java/com/jmal/clouddisk/ocr/OcrService.java index 94f1c263..3b128ca4 100644 --- a/src/main/java/com/jmal/clouddisk/ocr/OcrService.java +++ b/src/main/java/com/jmal/clouddisk/ocr/OcrService.java @@ -5,6 +5,7 @@ import cn.hutool.core.util.StrUtil; import com.jmal.clouddisk.config.FileProperties; import com.jmal.clouddisk.service.Constants; +import com.jmal.clouddisk.video.FFMPEGCommand; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.sourceforge.tess4j.ITesseract; @@ -18,7 +19,6 @@ import java.nio.file.Paths; import static com.jmal.clouddisk.util.FFMPEGUtils.getWaitingForResults; -import static com.jmal.clouddisk.util.FFMPEGUtils.hasNoFFmpeg; @Service @@ -77,7 +77,7 @@ public String generateOrcTempImagePath(String username) { * @return 预处理后的图片路径 */ public String getPreprocessedOCRImage(String inputPath, String outputPath) { - if (hasNoFFmpeg()) { + if (FFMPEGCommand.hasNoFFmpeg()) { return outputPath; } if (FileUtil.exist(outputPath)) { diff --git a/src/main/java/com/jmal/clouddisk/util/FFMPEGUtils.java b/src/main/java/com/jmal/clouddisk/util/FFMPEGUtils.java index 84d708bd..8f5074d0 100644 --- a/src/main/java/com/jmal/clouddisk/util/FFMPEGUtils.java +++ b/src/main/java/com/jmal/clouddisk/util/FFMPEGUtils.java @@ -74,24 +74,4 @@ public static String getWaitingForResults(String outputPath, ProcessBuilder proc return null; } - /** - * 检查是否没有ffmpeg - * @return true: 没有ffmpeg - */ - public static boolean hasNoFFmpeg() { - try { - Process process = Runtime.getRuntime().exec("ffmpeg -version"); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - while ((line = reader.readLine()) != null) { - if (line.contains("ffmpeg version")) { - return false; - } - } - return true; - } catch (IOException e) { - log.error(e.getMessage(), e); - } - return true; - } } diff --git a/src/main/java/com/jmal/clouddisk/video/FFMPEGCommand.java b/src/main/java/com/jmal/clouddisk/video/FFMPEGCommand.java new file mode 100644 index 00000000..e1792ba3 --- /dev/null +++ b/src/main/java/com/jmal/clouddisk/video/FFMPEGCommand.java @@ -0,0 +1,278 @@ +package com.jmal.clouddisk.video; + +import cn.hutool.core.convert.Convert; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.jmal.clouddisk.service.Constants; +import lombok.extern.slf4j.Slf4j; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.stream.Collectors; + +import static com.jmal.clouddisk.util.FFMPEGUtils.printErrorInfo; + +@Slf4j +public class FFMPEGCommand { + + /** + * 缩略图宽度 + */ + static final int thumbnailWidth = 128; + + /** + * 获取视频的分辨率和码率信息 + * + * @param videoPath 视频路径 + * @return 视频信息 + */ + static VideoInfo getVideoInfo(String videoPath) { + try { + ProcessBuilder processBuilder = new ProcessBuilder( + "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_format", "-show_streams", "-of", "json", videoPath); + Process process = processBuilder.start(); + try (InputStream inputStream = process.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String json = reader.lines().collect(Collectors.joining()); + JSONObject jsonObject = JSON.parseObject(json); + + // 获取视频格式信息 + JSONObject formatObject = jsonObject.getJSONObject("format"); + String format = formatObject.getString("format_name"); + + // 获取视频时长 + int duration = Convert.toInt(formatObject.get("duration")); + + // 获取视频流信息 + JSONArray streamsArray = jsonObject.getJSONArray("streams"); + if (!streamsArray.isEmpty()) { + JSONObject streamObject = streamsArray.getJSONObject(0); + int width = streamObject.getIntValue("width"); + int height = streamObject.getIntValue("height"); + int rotation = 0; + if (streamObject.containsKey("side_data_list")) { + for (int i = 0; i < streamObject.getJSONArray("side_data_list").size(); i++) { + JSONObject sideData = streamObject.getJSONArray("side_data_list").getJSONObject(i); + if (sideData.containsKey("rotation")) { + rotation = sideData.getIntValue("rotation"); + break; + } + } + } + // 转换rotation + int bitrate = streamObject.getIntValue("bit_rate"); // bps + return new VideoInfo(videoPath, width, height, format, bitrate, duration, Math.abs(rotation)); + } + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + printErrorInfo(processBuilder, process); + } + } catch (Exception e) { + log.error(e.getMessage(), e); + } + return new VideoInfo(); + } + + /** + * 合并缩略图 + * @param inputPattern 输入文件名格式 + * @param outputImage 输出图片 + * @param columns 列数 + * @param rows 行数 + * @return ProcessBuilder + */ + static ProcessBuilder mergeVTT(String inputPattern, String outputImage, int columns, int rows) { + ProcessBuilder processBuilder = new ProcessBuilder( + Constants.FFMPEG, + "-i", inputPattern, + "-filter_complex", String.format("tile=%dx%d", columns, rows), + outputImage + ); + processBuilder.redirectErrorStream(true); + return processBuilder; + } + + /** + * 获取视频封面 + * @param videoPath 视频路径 + * @param outputPath 输出路径 + * @param videoDuration 视频时长 + * @return ProcessBuilder + */ + static ProcessBuilder getVideoCoverProcessBuilder(String videoPath, String outputPath, int videoDuration) { + int targetTimestamp = (int) (videoDuration * 0.1); + String formattedTimestamp = VideoInfoUtil.formatTimestamp(targetTimestamp, false); + log.debug("\r\nvideoPath: {}, formattedTimestamp: {}", videoPath, formattedTimestamp); + ProcessBuilder processBuilder = new ProcessBuilder( + Constants.FFMPEG, + "-y", + "-ss", formattedTimestamp, + "-i", videoPath, + "-vf", String.format("scale=%s:-2", thumbnailWidth), + "-frames:v", "1", + outputPath + ); + processBuilder.redirectErrorStream(true); + return processBuilder; + } + + static ProcessBuilder useVideotoolbox(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern) { + return new ProcessBuilder( + Constants.FFMPEG, + "-hwaccel", "videotoolbox", + "-i", fileAbsolutePath.toString(), + "-c:v", "h264_videotoolbox", + "-profile:v", "main", + "-pix_fmt", "yuv420p", + "-level", "4.0", + "-start_number", "0", + "-hls_time", "10", + "-hls_list_size", "0", + "-vf", "scale=-2:" + height, + "-b:v", Convert.toStr(bitrate), + "-preset", "medium", + "-g", "48", + "-sc_threshold", "0", + "-f", "hls", + "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(), + outputPath, + "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval), + thumbnailPattern + ); + } + + static ProcessBuilder cpuTranscoding(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern) { + + return new ProcessBuilder( + Constants.FFMPEG, + "-i", fileAbsolutePath.toString(), + "-profile:v", "main", + "-pix_fmt", "yuv420p", + "-level", "4.0", + "-start_number", "0", + "-hls_time", "10", + "-hls_list_size", "0", + "-vf", "scale=-2:" + height, + "-b:v", Convert.toStr(bitrate), + "-preset", "medium", + "-g", "48", + "-sc_threshold", "0", + "-f", "hls", + "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(), + outputPath, + "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval), + thumbnailPattern + ); + } + + static ProcessBuilder useNvencCuda(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern) { + // 使用CUDA硬件加速和NVENC编码器 + return new ProcessBuilder( + Constants.FFMPEG, + "-init_hw_device", "cuda=cu:0", + "-filter_hw_device", "cu", + "-hwaccel", "cuda", + "-hwaccel_output_format", "cuda", + "-threads", "1", + "-autorotate", "0", + "-i", fileAbsolutePath.toString(), + "-autoscale", "0", + "-map_metadata", "-1", + "-map_chapters", "-1", + "-threads", "0", + "-map", "0:0", + "-map", "0:1", + "-map", "-0:s", + "-codec:v:0", "h264_nvenc", + "-preset", "p1", + "-b:v", Convert.toStr(bitrate), + "-maxrate", Convert.toStr(bitrate), + "-bufsize", "5643118", + "-g:v:0", "180", + "-keyint_min:v:0", "180", + "-vf", "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709,scale_cuda=-2:" + height + ":format=yuv420p", + "-codec:a:0", "copy", + "-copyts", + "-avoid_negative_ts", "disabled", + "-max_muxing_queue_size", "2048", + "-f", "hls", + "-max_delay", "5000000", + "-hls_time", "3", + "-hls_segment_type", "mpegts", + "-start_number", "0", + "-y", + "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(), + "-hls_playlist_type", "vod", + "-hls_list_size", "0", + outputPath, + "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval), + thumbnailPattern + ); + } + + /** + * 检测是否装有Mac Apple Silicon + */ + static boolean checkMacAppleSilicon() { + try { + Process process = Runtime.getRuntime().exec("ffmpeg -hwaccels"); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + if (line.contains("videotoolbox")) { + return true; + } + } + } catch (IOException e) { + return false; + } + return false; + } + + /** + * 检测是否装有NVIDIA显卡驱动 + */ + static boolean checkNvidiaDrive() { + try { + Process process = Runtime.getRuntime().exec("nvidia-smi"); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + if (line.contains("Version")) { + return true; + } + } + } catch (IOException e) { + return false; + } + return false; + } + + /** + * 检查是否没有ffmpeg + * @return true: 没有ffmpeg + */ + public static boolean hasNoFFmpeg() { + try { + Process process = Runtime.getRuntime().exec("ffmpeg -version"); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + if (line.contains("ffmpeg version")) { + return false; + } + } + return true; + } catch (IOException e) { + log.error(e.getMessage(), e); + } + return true; + } +} diff --git a/src/main/java/com/jmal/clouddisk/video/FFMPEGUtils.java b/src/main/java/com/jmal/clouddisk/video/FFMPEGUtils.java new file mode 100644 index 00000000..3a6b1a73 --- /dev/null +++ b/src/main/java/com/jmal/clouddisk/video/FFMPEGUtils.java @@ -0,0 +1,111 @@ +package com.jmal.clouddisk.video; + +import lombok.extern.slf4j.Slf4j; + +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; + +@Slf4j +public class FFMPEGUtils { + + /** + * h5播放器支持的视频格式 + */ + private static final String[] WEB_SUPPORTED_FORMATS = {"mp4", "webm", "ogg", "flv", "hls", "mkv"}; + + /** + * 获取VTT文件间隔 + * @param videoInfo 视频信息 + * @return VTT文件间隔 + */ + static int getVttInterval(VideoInfo videoInfo) { + // 期望的缩略图数量 50张 + int videoDuration = videoInfo.getDuration(); + // 计算缩略图间隔 + if (videoDuration <= 50) { + return 1; + } + return videoDuration / 50; + } + + /** + * 生成VTT文件 + * @param videoInfo 视频信息 + * @param interval 缩略图间隔 + * @param vttFilePath VTT文件路径 + * @param thumbnailImagePath 最终缩略图路径 + */ + static void generateVTT(VideoInfo videoInfo, int interval, String vttFilePath, String thumbnailImagePath, int columns) { + try (BufferedWriter writer = new BufferedWriter(new FileWriter(vttFilePath))) { + writer.write("WEBVTT\n\n"); + int expectedThumbnails = videoInfo.getDuration() / interval; + for (int i = 0; i < expectedThumbnails; i++) { + String startTime = VideoInfoUtil.formatTimestamp(i * interval, true); + String endTime = VideoInfoUtil.formatTimestamp((i + 1) * interval, true); + + int column = i % columns; + int row = i / columns; + int thumbWidth = FFMPEGCommand.thumbnailWidth; + double tHeight = (double) videoInfo.getHeight() / ((double) videoInfo.getWidth() / FFMPEGCommand.thumbnailWidth); + int thumbHeight = (int) Math.ceil(tHeight); + int x = column * thumbWidth; + int y = row * thumbHeight; + + writer.write(String.format("%s --> %s\n", startTime, endTime)); + writer.write(String.format("%s#xywh=%d,%d,%d,%d\n\n", thumbnailImagePath, x, y, thumbWidth, thumbHeight)); + } + } catch (IOException e) { + log.warn(e.getMessage(), e); + } + } + + /** + * 判断是否需要转码 + * + * @param videoInfo 视频信息 + * @return 是否需要转码 + */ + static boolean needTranscode(VideoInfo videoInfo, TranscodeConfig transcodeConfig) { + if ((videoInfo.getBitrate() > 0 && videoInfo.getBitrate() <= transcodeConfig.getBitrate()) && videoInfo.getHeight() <= transcodeConfig.getHeight()) { + return !isSupportedFormat(videoInfo.getFormat()); + } + return true; + } + + /** + * 判断视频格式是否为HTML5 Video Player支持的格式 + * + * @param format 视频格式 + * @return 是否支持 + */ + static boolean isSupportedFormat(String format) { + // HTML5 Video Player支持的视频格式 + String[] formatList = format.split(","); + for (String f : formatList) { + for (String supportedFormat : WEB_SUPPORTED_FORMATS) { + if (f.trim().equalsIgnoreCase(supportedFormat)) { + return true; + } + } + } + return false; + } + + /** + * 获取转码进度 + * @param videoDuration 视频时长 + * @param line 命令输出信息 + * @return 转码进度 + */ + static String getProgressStr(int videoDuration, String line) { + String[] parts = line.split("time=")[1].split(" ")[0].split(":"); + int hours = Integer.parseInt(parts[0]); + int minutes = Integer.parseInt(parts[1]); + int seconds = Integer.parseInt(parts[2].split("\\.")[0]); + int totalSeconds = hours * 3600 + minutes * 60 + seconds; + // 计算转码进度百分比 + double progress = (double) totalSeconds / videoDuration * 100; + return String.format("%.2f", progress); + } +} diff --git a/src/main/java/com/jmal/clouddisk/video/TranscodeConfig.java b/src/main/java/com/jmal/clouddisk/video/TranscodeConfig.java index d005d49f..1cddd9c8 100644 --- a/src/main/java/com/jmal/clouddisk/video/TranscodeConfig.java +++ b/src/main/java/com/jmal/clouddisk/video/TranscodeConfig.java @@ -19,6 +19,11 @@ public class TranscodeConfig { @Schema(description = "是否启用转码, 默认开启") private Boolean enable; + @Max(value = 8, message = "最大任务数不能超过8") + @Min(value = 1, message = "最大任务数不能小于1") + @Schema(description = "最大任务数, 最多同时处理的转码任务数, 默认为1") + private Integer maxThreads; + @Max(value = 1000000, message = "码率不能超过 1000000 kbps") @Min(value = 100, message = "码率不能低于 100 kbps") @Schema(description = "转码后的视频码率(kbps), 默认 2500 kbps, 小于该值则不转码") diff --git a/src/main/java/com/jmal/clouddisk/video/VideoProcessService.java b/src/main/java/com/jmal/clouddisk/video/VideoProcessService.java index 5a266827..c868cb08 100644 --- a/src/main/java/com/jmal/clouddisk/video/VideoProcessService.java +++ b/src/main/java/com/jmal/clouddisk/video/VideoProcessService.java @@ -1,12 +1,9 @@ package com.jmal.clouddisk.video; -import cn.hutool.core.convert.Convert; import cn.hutool.core.io.FileUtil; import cn.hutool.core.thread.ThreadUtil; import cn.hutool.core.util.BooleanUtil; -import com.alibaba.fastjson2.JSON; -import com.alibaba.fastjson2.JSONArray; -import com.alibaba.fastjson2.JSONObject; +import cn.hutool.core.util.ObjectUtil; import com.jmal.clouddisk.config.FileProperties; import com.jmal.clouddisk.lucene.TaskProgressService; import com.jmal.clouddisk.lucene.TaskType; @@ -39,7 +36,6 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; import static com.jmal.clouddisk.util.FFMPEGUtils.*; @@ -78,29 +74,45 @@ public class VideoProcessService { */ private final ReentrantLock toBeTranscodeLock = new ReentrantLock(); - /** - * h5播放器支持的视频格式 - */ - private final String[] WEB_SUPPORTED_FORMATS = {"mp4", "webm", "ogg", "flv", "hls", "mkv"}; - - /** - * 缩略图宽度 - */ - private static final int thumbnailWidth = 128; - private final static String TRANSCODE_VIDEO = "transcodeVideo"; @PostConstruct public void init() { - int processors = Runtime.getRuntime().availableProcessors() - 1; - if (processors < 1) { - processors = 1; + getVideoTranscodingService(); + addTranscodingTaskService = ThreadUtil.newFixedExecutor(8, 100, "addTranscodingTask", true); + } + + private void getVideoTranscodingService() { + TranscodeConfig transcodeConfig = getTranscodeConfig(); + int processors = 1; + if (transcodeConfig.getMaxThreads() != null) { + processors = transcodeConfig.getMaxThreads(); + } + synchronized (this) { + if (videoTranscodingService == null) { + createExecutor(processors); + return; + } } - if (processors > 3) { - processors = 3; + if (videoTranscodingService.isShutdown()) { + createExecutor(processors); + } else { + try { + videoTranscodingService.shutdown(); + if (!videoTranscodingService.awaitTermination(30, TimeUnit.MINUTES)) { + log.warn("等待转码超时, 尝试强制停止所有转码任务"); + videoTranscodingService.shutdownNow(); + } + } catch (InterruptedException e) { + videoTranscodingService.shutdownNow(); + Thread.currentThread().interrupt(); + } + createExecutor(processors); } + } + + private void createExecutor(int processors) { videoTranscodingService = ThreadUtil.newFixedExecutor(processors, 1, "videoTranscoding", true); - addTranscodingTaskService = ThreadUtil.newFixedExecutor(4, 100, "addTranscodingTask", true); } /** @@ -121,6 +133,10 @@ public void setTranscodeConfig(TranscodeConfig config) { update.set("bitrate", config.getBitrate()); update.set("height", config.getHeight()); mongoTemplate.updateFirst(query, update, TranscodeConfig.class); + if (ObjectUtil.equals(tc.getEnable(), config.getEnable())) { + // 重新加载转码线程池 + addTranscodingTaskService.execute(this::getVideoTranscodingService); + } } } @@ -247,19 +263,19 @@ public void deleteVideoCache(String username, String fileAbsolutePath) { public VideoInfo getVideoInfo(File videoFile) { VideoInfo videoInfo = new VideoInfo(); - if (hasNoFFmpeg()) { + if (FFMPEGCommand.hasNoFFmpeg()) { return videoInfo; } if (!videoFile.exists()) { return videoInfo; } - videoInfo = getVideoInfo(videoFile.getAbsolutePath()); + videoInfo = FFMPEGCommand.getVideoInfo(videoFile.getAbsolutePath()); return videoInfo; } public VideoInfo getVideoCover(String fileId, String username, String relativePath, String fileName) { VideoInfo videoInfo = new VideoInfo(); - if (hasNoFFmpeg()) { + if (FFMPEGCommand.hasNoFFmpeg()) { return videoInfo; } Path prePath = Paths.get(username, relativePath, fileName); @@ -288,10 +304,10 @@ public VideoInfo getVideoCover(String fileId, String username, String relativePa if (FileUtil.exist(outputPath)) { return videoInfo; } - videoInfo = getVideoInfo(videoPath); + videoInfo = FFMPEGCommand.getVideoInfo(videoPath); videoInfo.setCovertPath(outputPath); int videoDuration = videoInfo.getDuration(); - ProcessBuilder processBuilder = getVideoCoverProcessBuilder(videoPath, outputPath, videoDuration); + ProcessBuilder processBuilder = FFMPEGCommand.getVideoCoverProcessBuilder(videoPath, outputPath, videoDuration); printSuccessInfo(processBuilder); // 等待处理结果 outputPath = getWaitingForResults(outputPath, processBuilder); @@ -307,30 +323,6 @@ public VideoInfo getVideoCover(String fileId, String username, String relativePa return videoInfo; } - /** - * 获取视频封面 - * @param videoPath 视频路径 - * @param outputPath 输出路径 - * @param videoDuration 视频时长 - * @return ProcessBuilder - */ - private static ProcessBuilder getVideoCoverProcessBuilder(String videoPath, String outputPath, int videoDuration) { - int targetTimestamp = (int) (videoDuration * 0.1); - String formattedTimestamp = VideoInfoUtil.formatTimestamp(targetTimestamp, false); - log.debug("\r\nvideoPath: {}, formattedTimestamp: {}", videoPath, formattedTimestamp); - ProcessBuilder processBuilder = new ProcessBuilder( - Constants.FFMPEG, - "-y", - "-ss", formattedTimestamp, - "-i", videoPath, - "-vf", String.format("scale=%s:-2", thumbnailWidth), - "-frames:v", "1", - outputPath - ); - processBuilder.redirectErrorStream(true); - return processBuilder; - } - /** * 获取视频文件缓存目录 * @@ -355,7 +347,7 @@ private void videoToM3U8(String fileId, String username, String relativePath, St Path fileAbsolutePath = Paths.get(fileProperties.getRootDir(), username, relativePath, fileName); // 视频文件缓存目录 String videoCacheDir = getVideoCacheDir(username, fileId); - if (hasNoFFmpeg()) { + if (FFMPEGCommand.hasNoFFmpeg()) { return; } String outputPath = Paths.get(videoCacheDir, fileId + ".m3u8").toString(); @@ -363,9 +355,9 @@ private void videoToM3U8(String fileId, String username, String relativePath, St return; } // 获取原始视频的分辨率和码率信息 - VideoInfo videoInfo = getVideoInfo(fileAbsolutePath.toString()); + VideoInfo videoInfo = FFMPEGCommand.getVideoInfo(fileAbsolutePath.toString()); // 判断是否需要转码 - if (!needTranscode(videoInfo, transcodeConfig)) { + if (!FFMPEGUtils.needTranscode(videoInfo, transcodeConfig)) { return; } // 如果视频的码率小于配置码率,则使用视频的原始码率 @@ -381,21 +373,21 @@ private void videoToM3U8(String fileId, String username, String relativePath, St } // 计算缩略图间隔 - int vttInterval = getVttInterval(videoInfo); + int vttInterval = FFMPEGUtils.getVttInterval(videoInfo); Path vttPath = Paths.get(videoCacheDir, "vtt"); if (!Files.exists(vttPath)) { FileUtil.mkdir(vttPath); } String thumbnailPattern = Paths.get(vttPath.toString(), "thumb_%03d.png").toString(); - ProcessBuilder processBuilder = cpuTranscoding(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern); - if (!onlyCPU && checkNvidiaDrive()) { + ProcessBuilder processBuilder = FFMPEGCommand.cpuTranscoding(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern); + if (!onlyCPU && FFMPEGCommand.checkNvidiaDrive()) { log.info("use NVENC hardware acceleration"); - processBuilder = useNvencCuda(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern); + processBuilder = FFMPEGCommand.useNvencCuda(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern); } - if (!onlyCPU && checkMacAppleSilicon()) { + if (!onlyCPU && FFMPEGCommand.checkMacAppleSilicon()) { log.info("use videotoolbox hardware acceleration"); - processBuilder = useVideotoolbox(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern); + processBuilder = FFMPEGCommand.useVideotoolbox(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern); } processBuilder.redirectErrorStream(true); Process process = processBuilder.start(); @@ -419,16 +411,8 @@ private void videoToM3U8(String fileId, String username, String relativePath, St } transcodingProgress(fileAbsolutePath, videoInfo.getDuration(), line); } - String vttFilePath = Paths.get(videoCacheDir, fileId + ".vtt").toString(); - - int columns = 10; // 合并图像的列数 - int rows = (int) Math.ceil((double) videoInfo.getDuration() / vttInterval / columns); - - String thumbnailImagePath = Paths.get(videoCacheDir, fileId + "-vtt.jpg").toString(); - - ProcessBuilder processBuilder1 = mergeVTT(thumbnailPattern, thumbnailImagePath, columns, rows); - processBuilder1.start().waitFor(); - generateVTT(videoInfo, vttInterval, vttFilePath, String.format("%s-vtt.jpg", fileId), 10); + // 生成vtt缩略图 + generateVtt(fileId, videoCacheDir, videoInfo, vttInterval, thumbnailPattern); } finally { taskProgressService.removeTaskProgress(fileAbsolutePath.toFile()); } @@ -447,233 +431,17 @@ private void videoToM3U8(String fileId, String username, String relativePath, St } } - /** - * 获取VTT文件间隔 - * @param videoInfo 视频信息 - * @return VTT文件间隔 - */ - private int getVttInterval(VideoInfo videoInfo) { - // 期望的缩略图数量 50张 - int videoDuration = videoInfo.getDuration(); - // 计算缩略图间隔 - if (videoDuration <= 50) { - return 1; - } - return videoDuration / 50; - } - - /** - * 生成VTT文件 - * @param videoInfo 视频信息 - * @param interval 缩略图间隔 - * @param vttFilePath VTT文件路径 - * @param thumbnailImagePath 最终缩略图路径 - */ - public static void generateVTT(VideoInfo videoInfo, int interval, String vttFilePath, String thumbnailImagePath, int columns) { - try (BufferedWriter writer = new BufferedWriter(new FileWriter(vttFilePath))) { - writer.write("WEBVTT\n\n"); - int expectedThumbnails = videoInfo.getDuration() / interval; - for (int i = 0; i < expectedThumbnails; i++) { - String startTime = VideoInfoUtil.formatTimestamp(i * interval, true); - String endTime = VideoInfoUtil.formatTimestamp((i + 1) * interval, true); - - int column = i % columns; - int row = i / columns; - int thumbWidth = thumbnailWidth; - double tHeight = (double) videoInfo.getHeight() / ((double) videoInfo.getWidth() / thumbnailWidth); - int thumbHeight = (int) Math.ceil(tHeight); - int x = column * thumbWidth; - int y = row * thumbHeight; - - writer.write(String.format("%s --> %s\n", startTime, endTime)); - writer.write(String.format("%s#xywh=%d,%d,%d,%d\n\n", thumbnailImagePath, x, y, thumbWidth, thumbHeight)); - } - } catch (IOException e) { - log.warn(e.getMessage(), e); - } - } - - /** - * 合并缩略图 - * @param inputPattern 输入文件名格式 - * @param outputImage 输出图片 - * @param columns 列数 - * @param rows 行数 - * @return ProcessBuilder - */ - private static ProcessBuilder mergeVTT(String inputPattern, String outputImage, int columns, int rows) { - ProcessBuilder processBuilder = new ProcessBuilder( - Constants.FFMPEG, - "-i", inputPattern, - "-filter_complex", String.format("tile=%dx%d", columns, rows), - outputImage - ); - processBuilder.redirectErrorStream(true); - return processBuilder; - } - - private static ProcessBuilder useVideotoolbox(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern) { - return new ProcessBuilder( - Constants.FFMPEG, - "-hwaccel", "videotoolbox", - "-i", fileAbsolutePath.toString(), - "-c:v", "h264_videotoolbox", - "-profile:v", "main", - "-pix_fmt", "yuv420p", - "-level", "4.0", - "-start_number", "0", - "-hls_time", "10", - "-hls_list_size", "0", - "-vf", "scale=-2:" + height, - "-b:v", Convert.toStr(bitrate), - "-preset", "medium", - "-g", "48", - "-sc_threshold", "0", - "-f", "hls", - "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(), - outputPath, - "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval), - thumbnailPattern - ); - } - - private static ProcessBuilder cpuTranscoding(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern) { - - return new ProcessBuilder( - Constants.FFMPEG, - "-i", fileAbsolutePath.toString(), - "-profile:v", "main", - "-pix_fmt", "yuv420p", - "-level", "4.0", - "-start_number", "0", - "-hls_time", "10", - "-hls_list_size", "0", - "-vf", "scale=-2:" + height, - "-b:v", Convert.toStr(bitrate), - "-preset", "medium", - "-g", "48", - "-sc_threshold", "0", - "-f", "hls", - "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(), - outputPath, - "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval), - thumbnailPattern - ); - } - - private ProcessBuilder useNvencCuda(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern) { - // 使用CUDA硬件加速和NVENC编码器 - return new ProcessBuilder( - Constants.FFMPEG, - "-init_hw_device", "cuda=cu:0", - "-filter_hw_device", "cu", - "-hwaccel", "cuda", - "-hwaccel_output_format", "cuda", - "-threads", "1", - "-autorotate", "0", - "-i", fileAbsolutePath.toString(), - "-autoscale", "0", - "-map_metadata", "-1", - "-map_chapters", "-1", - "-threads", "0", - "-map", "0:0", - "-map", "0:1", - "-map", "-0:s", - "-codec:v:0", "h264_nvenc", - "-preset", "p1", - "-b:v", Convert.toStr(bitrate), - "-maxrate", Convert.toStr(bitrate), - "-bufsize", "5643118", - "-g:v:0", "180", - "-keyint_min:v:0", "180", - "-vf", "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709,scale_cuda=-2:" + height + ":format=yuv420p", - "-codec:a:0", "copy", - "-copyts", - "-avoid_negative_ts", "disabled", - "-max_muxing_queue_size", "2048", - "-f", "hls", - "-max_delay", "5000000", - "-hls_time", "3", - "-hls_segment_type", "mpegts", - "-start_number", "0", - "-y", - "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(), - "-hls_playlist_type", "vod", - "-hls_list_size", "0", - outputPath, - "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval), - thumbnailPattern - ); - } - - /** - * 检测是否装有Mac Apple Silicon - */ - private boolean checkMacAppleSilicon() { - try { - Process process = Runtime.getRuntime().exec("ffmpeg -hwaccels"); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - while ((line = reader.readLine()) != null) { - if (line.contains("videotoolbox")) { - return true; - } - } - } catch (IOException e) { - return false; - } - return false; - } - - /** - * 检测是否装有NVIDIA显卡驱动 - */ - private boolean checkNvidiaDrive() { - try { - Process process = Runtime.getRuntime().exec("nvidia-smi"); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - while ((line = reader.readLine()) != null) { - if (line.contains("Version")) { - return true; - } - } - } catch (IOException e) { - return false; - } - return false; - } - - /** - * 判断是否需要转码 - * - * @param videoInfo 视频信息 - * @return 是否需要转码 - */ - private boolean needTranscode(VideoInfo videoInfo, TranscodeConfig transcodeConfig) { - if ((videoInfo.getBitrate() > 0 && videoInfo.getBitrate() <= transcodeConfig.getBitrate()) && videoInfo.getHeight() <= transcodeConfig.getHeight()) { - return !isSupportedFormat(videoInfo.getFormat()); - } - return true; - } - - /** - * 判断视频格式是否为HTML5 Video Player支持的格式 - * - * @param format 视频格式 - * @return 是否支持 - */ - private boolean isSupportedFormat(String format) { - // HTML5 Video Player支持的视频格式 - String[] formatList = format.split(","); - for (String f : formatList) { - for (String supportedFormat : WEB_SUPPORTED_FORMATS) { - if (f.trim().equalsIgnoreCase(supportedFormat)) { - return true; - } - } - } - return false; + private static void generateVtt(String fileId, String videoCacheDir, VideoInfo videoInfo, int vttInterval, String thumbnailPattern) throws InterruptedException, IOException { + String vttFilePath = Paths.get(videoCacheDir, fileId + ".vtt").toString(); + int columns = 10; // 合并图像的列数 + int rows = (int) Math.ceil((double) videoInfo.getDuration() / vttInterval / columns); + String thumbnailImagePath = Paths.get(videoCacheDir, fileId + "-vtt.jpg").toString(); + ProcessBuilder processBuilder = FFMPEGCommand.mergeVTT(thumbnailPattern, thumbnailImagePath, columns, rows); + processBuilder.start().waitFor(); + FFMPEGUtils.generateVTT(videoInfo, vttInterval, vttFilePath, String.format("%s-vtt.jpg", fileId), columns); + // 删除vtt临时文件 + Path vttPath = Paths.get(videoCacheDir, "vtt"); + FileUtil.del(vttPath); } /** @@ -688,7 +456,7 @@ private void transcodingProgress(Path fileAbsolutePath, int videoDuration, Strin if (line.contains("time=")) { try { if (line.contains(":")) { - String progressStr = getProgressStr(videoDuration, line); + String progressStr = FFMPEGUtils.getProgressStr(videoDuration, line); log.info("{}, 转码进度: {}%", fileAbsolutePath.getFileName(), progressStr); taskProgressService.addTaskProgress(fileAbsolutePath.toFile(), TaskType.TRANSCODE_VIDEO, progressStr + "%"); } @@ -698,23 +466,6 @@ private void transcodingProgress(Path fileAbsolutePath, int videoDuration, Strin } } - /** - * 获取转码进度 - * @param videoDuration 视频时长 - * @param line 命令输出信息 - * @return 转码进度 - */ - private static String getProgressStr(int videoDuration, String line) { - String[] parts = line.split("time=")[1].split(" ")[0].split(":"); - int hours = Integer.parseInt(parts[0]); - int minutes = Integer.parseInt(parts[1]); - int seconds = Integer.parseInt(parts[2].split("\\.")[0]); - int totalSeconds = hours * 3600 + minutes * 60 + seconds; - // 计算转码进度百分比 - double progress = (double) totalSeconds / videoDuration * 100; - return String.format("%.2f", progress); - } - private void startConvert(String username, String relativePath, String fileName, String fileId) { Query query = new Query(); String userId = userService.getUserIdByUserName(username); @@ -733,61 +484,6 @@ private void startConvert(String username, String relativePath, String fileName, commonFileService.pushMessage(username, fileDocument, Constants.UPDATE_FILE); } - /** - * 获取视频的分辨率和码率信息 - * - * @param videoPath 视频路径 - * @return 视频信息 - */ - private VideoInfo getVideoInfo(String videoPath) { - try { - ProcessBuilder processBuilder = new ProcessBuilder( - "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_format", "-show_streams", "-of", "json", videoPath); - Process process = processBuilder.start(); - try (InputStream inputStream = process.getInputStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { - String json = reader.lines().collect(Collectors.joining()); - JSONObject jsonObject = JSON.parseObject(json); - - // 获取视频格式信息 - JSONObject formatObject = jsonObject.getJSONObject("format"); - String format = formatObject.getString("format_name"); - - // 获取视频时长 - int duration = Convert.toInt(formatObject.get("duration")); - - // 获取视频流信息 - JSONArray streamsArray = jsonObject.getJSONArray("streams"); - if (!streamsArray.isEmpty()) { - JSONObject streamObject = streamsArray.getJSONObject(0); - int width = streamObject.getIntValue("width"); - int height = streamObject.getIntValue("height"); - int rotation = 0; - if (streamObject.containsKey("side_data_list")) { - for (int i = 0; i < streamObject.getJSONArray("side_data_list").size(); i++) { - JSONObject sideData = streamObject.getJSONArray("side_data_list").getJSONObject(i); - if (sideData.containsKey("rotation")) { - rotation = sideData.getIntValue("rotation"); - break; - } - } - } - // 转换rotation - int bitrate = streamObject.getIntValue("bit_rate"); // bps - return new VideoInfo(videoPath, width, height, format, bitrate, duration, Math.abs(rotation)); - } - } - - int exitCode = process.waitFor(); - if (exitCode != 0) { - printErrorInfo(processBuilder, process); - } - } catch (Exception e) { - log.error(e.getMessage(), e); - } - return new VideoInfo(); - } - @PreDestroy private void destroy() { if (videoTranscodingService != null) {