Explorar o código

oss-store 更新 media 包

reghao hai 1 mes
pai
achega
9238f1f695

+ 0 - 15
oss-store/src/main/java/cn/reghao/oss/store/media/KeyFrameInfo.java

@@ -1,15 +0,0 @@
-package cn.reghao.oss.store.media;
-
-import lombok.Getter;
-import lombok.Setter;
-
-/**
- * @author reghao
- * @date 2026-02-04 16:44:50
- */
-@Setter
-@Getter
-public class KeyFrameInfo {
-    private long offset;      // 对应 pkt_pos (字节偏移)
-    private double timestamp; // 对应 pkt_pts_time (时间点)
-}

+ 0 - 69
oss-store/src/main/java/cn/reghao/oss/store/media/MafdAnalyzer.java

@@ -1,69 +0,0 @@
-package cn.reghao.oss.store.media;
-
-import java.util.List;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-/**
- * @author reghao
- * @date 2026-02-04 21:23:53
- */
-public class MafdAnalyzer {
-    // 匹配 scd.score: 13.304 和 scd.time: 44.171973
-    private static final Pattern SCD_PATTERN =
-            Pattern.compile("lavfi\\.scd\\.score:\\s+([0-9.]*),\\s+lavfi\\.scd\\.time:\\s+([0-9.]+)");
-    // 存储最终检测到的场景点
-    private final List<SceneSegment> detectedScenes = new CopyOnWriteArrayList<>();
-    // 配置参数
-    private static final double MIN_SCENE_DURATION = 1.5; // 两个场景切换之间至少间隔 1.5 秒
-    private double lastSceneTime = -1.0;
-
-    public void processLine(String line) {
-        Matcher matcher = SCD_PATTERN.matcher(line);
-        if (matcher.find()) {
-            double score = Double.parseDouble(matcher.group(1));
-            double timestamp = Double.parseDouble(matcher.group(2));
-            // 记录或处理检测到的场景点
-            handleSceneChange(score, timestamp);
-        }
-    }
-
-    /**
-     * 处理从日志正则中提取到的数据
-     * @param score FFmpeg 计算出的 scd.score
-     * @param timestamp FFmpeg 报告的 scd.time
-     */
-    public void handleSceneChange(double score, double timestamp) {
-        // 1. 过滤掉时间轴异常情况(FFmpeg 偶尔会在开头打出负值或 0)
-        if (timestamp < 0.1) return;
-
-        // 2. 最小间隔算法:防止“假性重复触发”
-        // 有些转场可能持续几帧,FFmpeg 会连续报错多个高分,我们只取第一个
-        if (lastSceneTime != -1.0 && (timestamp - lastSceneTime) < MIN_SCENE_DURATION) {
-            // 如果当前分值远高于上一次,可以考虑更新时间点,否则直接跳过
-            return;
-        }
-
-        // 3. 分级判定逻辑
-        if (score > 15.0) {
-            // 极高分:确定是强切(Hard Cut)
-            addScene(timestamp, score, "STRONG_CUT");
-        } else if (score > 8.0) {
-            // 中等分:可能是淡入淡出或光影剧烈变化
-            addScene(timestamp, score, "SOFT_TRANSITION");
-        }
-    }
-
-    private void addScene(double time, double score, String type) {
-        lastSceneTime = time;
-        SceneSegment segment = new SceneSegment(time, score);
-        detectedScenes.add(segment);
-
-        // 4. 实时反馈:可以通过 WebSocket 或日志通知前端
-        System.out.printf("[检测到场景] 类型: %s, 时间: %.2fs, 分值: %.2f%n", type, time, score);
-
-        // 如果需要写入 Redis 供前端轮询进度
-        // redisTemplate.opsForList().rightPush("video:scenes:" + videoId, time);
-    }
-}

+ 0 - 15
oss-store/src/main/java/cn/reghao/oss/store/media/SceneSegment.java

@@ -1,15 +0,0 @@
-package cn.reghao.oss.store.media;
-
-/**
- * @author reghao
- * @date 2026-02-04 21:53:32
- */
-public class SceneSegment {
-    private double startTime;
-    private double score;
-    // 可以添加其他元数据,如预览图路径等
-    public SceneSegment(double startTime, double score) {
-        this.startTime = startTime;
-        this.score = score;
-    }
-}

+ 325 - 0
oss-store/src/main/java/cn/reghao/oss/store/media/VideoFileProcessor.java

@@ -0,0 +1,325 @@
+package cn.reghao.oss.store.media;
+
+import cn.reghao.oss.store.media.handler.*;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStreamReader;
+import java.time.Duration;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * @author reghao
+ * @date 2026-02-04 16:45:16
+ */
+@Slf4j
+public class VideoFileProcessor {
+    static final ObjectMapper mapper = new ObjectMapper();
+
+
+    /**
+     * 获取视频最详尽的信息:格式、所有流、程序、章节、元数据
+     */
+    public static JsonNode getVideoMetadata(String filePath) {
+        // 构建全量探测命令
+        List<String> cmd = Arrays.asList(
+                "ffprobe",
+                "-v", "quiet",           // 不打印日志头
+                "-print_format", "json", // 输出 JSON
+                "-show_format",          // 容器格式信息 (bitrate, size, duration, tags)
+                "-show_streams",         // 所有流 (video, audio, subtitle, data)
+                "-show_chapters",        // 章节信息
+                "-show_programs",        // 节目信息 (常用于 TS 流)
+                "-show_error",           // 如果解析出错,输出错误 JSON
+                filePath
+        );
+
+        try {
+            Process process = new ProcessBuilder(cmd).start();
+            StringBuilder jsonOutput = new StringBuilder();
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    jsonOutput.append(line);
+                }
+            }
+
+            int exitCode = process.waitFor();
+            if (exitCode != 0) {
+                throw new RuntimeException("FFprobe 解析失败,退出码: " + exitCode);
+            }
+
+            return mapper.readTree(jsonOutput.toString());
+        } catch (Exception e) {
+            throw new RuntimeException(e.getMessage());
+        }
+    }
+
+    /**
+     * 辅助方法:快速判断视频是否包含特定类型的流
+     */
+    public static boolean hasStreamType(JsonNode fullMeta, String type) {
+        JsonNode streams = fullMeta.get("streams");
+        if (streams != null && streams.isArray()) {
+            for (JsonNode stream : streams) {
+                if (type.equalsIgnoreCase(stream.path("codec_type").asText())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    public static void checkVideo(File inputFile) {
+        List<String> command = Arrays.asList(
+                "ffmpeg", "-v", "error",
+                "-i", inputFile.getAbsolutePath(),
+                "-f", "null", "-"
+        );
+
+        try {
+            OutputHandler outputHandler = new EmptyHandler();
+            int exitCode = ShellWrapper.executeFFmpeg(command, outputHandler, outputHandler);
+            if (exitCode != 0) {
+                String errorMsg = "video invalid";
+                throw new RuntimeException(errorMsg);
+            }
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public static void convertToWebVideo(File inputFile, File outputFile, double duration) {
+        List<String> command = Arrays.asList(
+                "ffmpeg", "-y", "-hide_banner",
+                "-i", inputFile.getAbsolutePath(),
+                "-c:v", "h264_nvenc",
+                // 1. 替换 -crf 为 NVENC 的质量控制模式
+                "-rc", "vbr",         // 启用可变码率控制 (Variable Bit Rate)
+                "-cq", "24",          // 设置目标质量,23-28 是平衡点(数值越小画质越好)
+                "-qmin", "24",        // 限制最小量化值,防止码率过高
+                "-qmax", "24",        // 限制最大量化值,防止画质突降
+                "-preset", "p4",      // NVENC 新版预设为 p1-p7,p4 是 medium,p7 是最慢最高质
+                "-pix_fmt", "yuv420p",
+                "-c:a", "aac",
+                "-b:a", "128k",
+                "-movflags", "+faststart",
+                outputFile.getAbsolutePath()
+        );
+        List<String> command1 = Arrays.asList(
+                "ffmpeg", "-y", "-hide_banner",
+                "-i", inputFile.getAbsolutePath(),
+                // CPU 上处理滤镜后再交给 GPU 处理
+                "-vf", "scale=-2:480",
+                "-c:v", "h264_nvenc",
+                // 2. 硬件控制参数
+                "-rc", "vbr",
+                "-cq", "24",          // 缩放到480p后,像素密度降低,24-26通常就足够清晰
+                "-qmin", "24",
+                "-qmax", "24",
+                "-preset", "p4",      // 对应 medium
+                "-pix_fmt", "yuv420p",
+                "-c:a", "aac",
+                "-b:a", "128k",
+                "-movflags", "+faststart",
+                outputFile.getAbsolutePath()
+        );
+
+        List<String> command2 = Arrays.asList(
+                "ffmpeg", "-y", "-hide_banner",
+                "-i", inputFile.getAbsolutePath(),
+                // 裁剪滤镜:保留中间 1/3
+                "-vf", "crop=iw/3:ih:iw/3:0",
+                "-c:v", "h264_nvenc",
+                "-rc", "vbr",
+                "-cq", "24",
+                "-qmin", "24",
+                "-qmax", "24",
+                "-preset", "p4",
+                "-pix_fmt", "yuv420p",
+                "-c:a", "aac",
+                "-b:a", "128k",
+                "-movflags", "+faststart",
+                outputFile.getAbsolutePath()
+        );
+
+        try {
+            OutputHandler stdoutHandler = new EmptyHandler();
+            OutputHandler stderrHandler = new ConvertVideoOutputHandler(duration);
+            int exitCode = ShellWrapper.executeFFmpeg(command1, stdoutHandler, stderrHandler);
+            if (exitCode != 0) {
+                String errorMsg = "convert video failed";
+                throw new RuntimeException(errorMsg);
+            }
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public static String formatSeconds(double seconds) {
+        // 将 double 转换为 Duration
+        Duration duration = Duration.ofMillis((long) (seconds * 1000));
+        // Java 9+ 提供的便捷方法
+        long hours = duration.toHours();
+        int minutes = duration.toMinutesPart();
+        int secs = duration.toSecondsPart();
+        return String.format("%02d:%02d:%02d", hours, minutes, secs);
+    }
+
+    public static void volumeDetect(File videoFile) {
+        List<String> command = Arrays.asList(
+                "ffmpeg", "-hide_banner",
+                "-i", videoFile.getAbsolutePath(),
+                "-af", "volumedetect", "-vn", "-sn", "-f", "null", "/dev/null"
+        );
+
+        List<String> command1 = Arrays.asList(
+                "ffmpeg", "-hide_banner",
+                "-i", videoFile.getAbsolutePath(),
+                "-af", "loudnorm=print_format=summary", "-vn", "-sn", "-f", "null", "/dev/null"
+        );
+
+        try {
+            // 匹配任意字符串
+            Pattern stdoutPattern = Pattern.compile(".*");
+            Pattern stderrPattern = Pattern.compile("([^:]+):\\s*([-+]?\\d+\\.?\\d*)");
+            ShellResult shellResult = ShellWrapper.executeWithResult(command1, stdoutPattern, stderrPattern);
+            if (shellResult.getExitCode() == 0) {
+                if (!shellResult.getStderr().isBlank()) {
+                    Map<String, Double> metrics = new HashMap<>();
+                    for (String line : shellResult.getStderr().split(System.lineSeparator())) {
+                        Matcher matcher = stderrPattern.matcher(line);
+                        if (matcher.find()) {
+                            String key = matcher.group(1).trim().toLowerCase().replace(" ", "_");
+                            double value = Double.parseDouble(matcher.group(2));
+                            metrics.put(key, value);
+                        }
+                    }
+
+                    if (!metrics.isEmpty()) {
+                        System.out.println("--- 响度分析报告 ---");
+                        System.out.printf("整体响度 (I): %.2f LUFS\n", metrics.get("input_integrated"));
+                        System.out.printf("真实峰值 (TP): %.2f dBTP\n", metrics.get("input_true_peak"));
+                        System.out.printf("动态范围 (LRA): %.2f LU\n", metrics.get("input_lra"));
+                        System.out.printf("Input Threshold: %.2f LUFS\n", metrics.get("input_threshold"));
+                        // 决策逻辑
+                        if (metrics.get("input_integrated") < -20) {
+                            System.out.println("建议:该视频音量太小,需要增强。");
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public static void process(File videoFile) {
+        // 1.检测是否为视频文件
+        // 2.检查视频文件是否损坏
+        // 3.将视频文件转码为可在 web 播放的格式
+        String videoPath = videoFile.getAbsolutePath();
+        JsonNode meta = getVideoMetadata(videoPath);
+        // 1. 检查是否伪装
+        if (!hasStreamType(meta, "video")) {
+            System.out.println("这不是一个视频文件!");
+            return;
+        }
+
+        String durationStr = meta.path("format").path("duration").asText();
+        double duration = durationStr.isEmpty() ? 0.0 : Double.parseDouble(durationStr);
+
+        String videoCodec = "";
+        int width = 0;
+        int height = 0;
+        String audioCodec = "";
+        // 遍历 streams 数组
+        JsonNode streams = meta.path("streams");
+        for (JsonNode stream : streams) {
+            String type = stream.path("codec_type").asText();
+            if ("video".equals(type)) {
+                System.out.println("--- 视频信息 ---");
+                System.out.println("视频编码: " + stream.path("codec_name").asText());
+                System.out.println("分辨率: " + stream.path("width").asInt() + "x" + stream.path("height").asInt());
+                // 视频流可能有自己的码率
+                String vBitrate = stream.path("bit_rate").asText();
+                System.out.println("视频码率: " + (vBitrate.isEmpty() ? "未知" : vBitrate + " bps"));
+            }
+            else if ("audio".equals(type)) {
+                System.out.println("--- 音频信息 ---");
+                System.out.println("音频编码: " + stream.path("codec_name").asText());
+                System.out.println("音频采样率: " + stream.path("sample_rate").asText() + " Hz");
+                System.out.println("音频码率: " + stream.path("bit_rate").asText() + " bps");
+            }
+        }
+
+        // 2. 获取总码率 (Format 级别)
+        //System.out.println("--- 总体信息 ---");
+        JsonNode format = meta.path("format");
+        System.out.println("容器格式: " + format.path("format_name").asText());
+        System.out.println("文件总码率: " + format.path("bit_rate").asText() + " bps");
+        System.out.println("总时长: " + duration + " 秒, " + formatSeconds(duration));
+
+        // 2. 漂亮地打印出整个 JSON(调试用)
+        //System.out.println(meta.toPrettyString());
+
+        /*long start = System.currentTimeMillis();
+        checkVideo(videoFile);
+        log.info("check video cost {}s", (System.currentTimeMillis()-start)/1000);
+
+        String outputPath = String.format("%s.mp4", videoFile.getAbsolutePath());
+        File outputFile = new File(outputPath);
+        start = System.currentTimeMillis();
+        convertToWebVideo(videoFile, outputFile, duration);
+        log.info("convert video cost {}s", (System.currentTimeMillis()-start)/1000);*/
+    }
+
+    public static void main(String[] args) {
+        String baseDir = "/home/reghao/Downloads/0/2";
+        //baseDir = "/home/reghao/disk/2/porn/zzz/flv/";
+        for (File videoFile : Objects.requireNonNull(new File(baseDir).listFiles())) {
+            //String videoPath = "/home/reghao/Downloads/0/123.flv.mp4";
+            //File videoFile = new File(videoPath);
+            log.info("process video {}", videoFile.getAbsolutePath());
+            //process(videoFile);
+            //volumeDetect(videoFile);
+
+            /*Map<String, Double> metrics = analyzeLoudness(videoFile.getAbsolutePath());
+            if (!metrics.isEmpty()) {
+                System.out.println("--- 响度分析报告 ---");
+                System.out.printf("整体响度 (I): %.2f LUFS\n", metrics.get("input_i"));
+                System.out.printf("真实峰值 (TP): %.2f dB\n", metrics.get("input_tp"));
+                System.out.printf("动态范围 (LRA): %.2f LU\n", metrics.get("input_lra"));
+                // 决策逻辑
+                if (metrics.get("input_i") < -20) {
+                    System.out.println("建议:该视频音量太小,需要增强。");
+                }
+            }*/
+        }
+
+        File inputFile = new File("/home/reghao/Downloads/rtmp.mp4");
+        File outputFile = new File("/home/reghao/Downloads/rtmp.mp4.mp4");
+
+        String filePath = "/home/reghao/Downloads/123abc.mp4";
+        JsonNode meta = getVideoMetadata(inputFile.getAbsolutePath());
+        String durationStr = meta.path("format").path("duration").asText();
+        double duration = durationStr.isEmpty() ? 0.0 : Double.parseDouble(durationStr);
+        convertToWebVideo(inputFile, outputFile, duration);
+
+        String listFile = "list.txt";
+        String outputPath = "output.mp4";
+        List<String> command = Arrays.asList(
+                "ffmpeg", "-y", "-hide_banner",
+                "-f", "concat", "-safe", "0", "-i", listFile,
+                "-c:v", "copy",       // 视频流直接拷贝,速度极快
+                "-c:a", "aac",        // 音频流重新编码,解决时间戳不连续
+                "-b:a", "128k",
+                outputPath
+        );
+    }
+}

+ 0 - 70
oss-store/src/main/java/cn/reghao/oss/store/media/VideoMetadataValidator.java

@@ -1,70 +0,0 @@
-package cn.reghao.oss.store.media;
-
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.BufferedReader;
-import java.io.InputStreamReader;
-import java.util.Arrays;
-import java.util.List;
-
-/**
- * @author reghao
- * @date 2026-02-04 22:15:42
- */
-public class VideoMetadataValidator {
-    private static final Logger log = LoggerFactory.getLogger(VideoMetadataValidator.class);
-    private static final ObjectMapper mapper = new ObjectMapper();
-
-    /**
-     * 获取视频最详尽的信息:格式、所有流、程序、章节、元数据
-     */
-    public JsonNode getFullMetadata(String filePath) throws Exception {
-        // 构建全量探测命令
-        List<String> cmd = Arrays.asList(
-                "ffprobe",
-                "-v", "quiet",           // 不打印日志头
-                "-print_format", "json", // 输出 JSON
-                "-show_format",          // 容器格式信息 (bitrate, size, duration, tags)
-                "-show_streams",         // 所有流 (video, audio, subtitle, data)
-                "-show_chapters",        // 章节信息
-                "-show_programs",        // 节目信息 (常用于 TS 流)
-                "-show_error",           // 如果解析出错,输出错误 JSON
-                filePath
-        );
-
-        Process process = new ProcessBuilder(cmd).start();
-
-        StringBuilder jsonOutput = new StringBuilder();
-        try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
-            String line;
-            while ((line = reader.readLine()) != null) {
-                jsonOutput.append(line);
-            }
-        }
-
-        int exitCode = process.waitFor();
-        if (exitCode != 0) {
-            throw new RuntimeException("FFprobe 解析失败,退出码: " + exitCode);
-        }
-
-        return mapper.readTree(jsonOutput.toString());
-    }
-
-    /**
-     * 辅助方法:快速判断视频是否包含特定类型的流
-     */
-    public boolean hasStreamType(JsonNode fullMeta, String type) {
-        JsonNode streams = fullMeta.get("streams");
-        if (streams != null && streams.isArray()) {
-            for (JsonNode stream : streams) {
-                if (type.equalsIgnoreCase(stream.path("codec_type").asText())) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-}

+ 26 - 336
oss-store/src/main/java/cn/reghao/oss/store/media/VideoProcessor.java

@@ -1,5 +1,9 @@
 package cn.reghao.oss.store.media;
 
+import cn.reghao.oss.store.media.handler.ConvertVideoOutputHandler;
+import cn.reghao.oss.store.media.handler.EmptyHandler;
+import cn.reghao.oss.store.media.handler.OutputHandler;
+import cn.reghao.oss.store.media.handler.ShellWrapper;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
@@ -23,185 +27,6 @@ import java.util.regex.Pattern;
  */
 @Slf4j
 public class VideoProcessor {
-    static String baseDir = "/home/reghao/Downloads/3";
-
-    static List<KeyFrameInfo> getKeyFrameOffsets(List<String> commands) throws Exception {
-        Process process = new ProcessBuilder(commands).start();
-        // 使用 Jackson 或 Gson 解析 ffprobe 输出的 JSON
-        ObjectMapper mapper = new ObjectMapper();
-        JsonNode root = mapper.readTree(process.getInputStream());
-        JsonNode frames = root.get("frames");
-
-        List<KeyFrameInfo> keyFrames = new ArrayList<>();
-        if (frames.isArray()) {
-            for (JsonNode frame : frames) {
-                // 只提取 I 帧 (关键帧)
-                if ("I".equals(frame.get("pict_type").asText())) {
-                    KeyFrameInfo info = new KeyFrameInfo();
-                    info.setOffset(frame.get("pkt_pos").asLong());
-                    info.setTimestamp(frame.get("pkt_pts_time").asDouble());
-                    keyFrames.add(info);
-                }
-            }
-        }
-        return keyFrames;
-    }
-
-    static void run() throws IOException {
-        String outputPath = "/mnt/nfs/vod/raw/videoId.mp4";
-
-        // -c copy 表示直接拷贝编码后的数据,不重编码,CPU 占用极低
-        List<String> command = Arrays.asList(
-                "ffmpeg",
-                "-i", "rtmpUrl",
-                "-c", "copy",
-                "-f", "mp4",
-                "-movflags", "+faststart", // 方便后续秒开播放
-                outputPath
-        );
-
-        ProcessBuilder pb = new ProcessBuilder(command);
-        // 同样需要处理 ErrorStream,参考我们之前聊过的“僵尸进程”防御
-        Process process = pb.start();
-    }
-
-    static Pattern timePattern = Pattern.compile("time=(\\d{2}:\\d{2}:\\d{2}.\\d{2})");
-    static MafdAnalyzer analyzer = new MafdAnalyzer();
-    static int executeFFmpeg(List<String> commands, double totalSeconds) throws Exception {
-        ProcessBuilder pb = new ProcessBuilder(commands);
-        Process process = pb.start();
-        // 父进程使用两个线程分别读取子进程的 stdout 和 stderr, 也就是子进程会向父进程写数据
-        // FFmpeg 的日志输出在 stderr, Java 程序必须不断读取 process.getErrorStream(), 否则会导致缓冲区满造成进程挂起(Zombie Process)
-        ExecutorService executor = Executors.newFixedThreadPool(2);
-        executor.submit(() -> {
-            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
-                String line;
-                while ((line = reader.readLine()) != null) {
-                    log.info("FFmpeg INFO: {}", line);
-                    /*if (line.contains("lavfi")) {
-                        analyzer.processLine(line);
-                    }*/
-                }
-            } catch (IOException e) {
-                log.error("读取标准流异常", e);
-            }
-        });
-        executor.submit(() -> {
-            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
-                String line;
-                // BufferedReader 是同步阻塞式的
-                // 有数据: 立即读取并返回
-                // 没数据但流没关: 线程进入等待状态,让出 CPU 资源,直到操作系统通知有新数据到达
-                // 流关闭时(子进程退出): readLine 返回 null, 此时阻塞解除
-                while ((line = reader.readLine()) != null) {
-                    // 这里可以解析 line 来获取转码进度(如 time=00:00:10.50)
-                    log.error("FFmpeg ERROR: {}", line);
-                    /*if (line.contains("lavfi")) {
-                        analyzer.processLine(line);
-                    }*/
-
-                    /*Matcher matcher = timePattern.matcher(line);
-                    if (matcher.find()) {
-                        String timeStr = matcher.group(1);
-                        double currentSeconds = convertToSeconds(timeStr);
-                        double percent = (currentSeconds / totalSeconds) * 100;
-                        log.error("FFmpeg ERROR: {}", percent);
-                        // 限制频率,防止每帧都发消息给前端(比如每增加 1% 发一次)
-                        //updateProgressToFrontend(videoId, percent);
-                    }*/
-                }
-            } catch (IOException e) {
-                log.error("读取错误流异常", e);
-            }
-        });
-        // 停止接收新任务,但会把已提交的任务执行完
-        // executor 默认创建的都是非守护
-        executor.shutdown();
-
-        // 执行 kill -9 会让操作系统直接从内存和 CPU 调度中抹除进程, JVM 进程还没来得及执行下一行指令就被杀死,所以 ShutdownHook 自然无法运行
-        // ShutdownHook 依赖于 JVM 接收到操作系统的信号后的内部处理机制
-        // 子进程默认情况下变成"孤儿进程", 它会立即被 1 号进程(init 或 systemd) 领养
-        // 如果子进程正通过 stdin/stdout 与父进程进行实时通信, 父进程被杀时 pipe 关闭, 子进程在下一次尝试向 pipe 写数据时会收到操作系统的 SIGPIPE 信号, 大多数程序在收到 SIGPIPE 时的默认行为是退出, 但如果子进程忽略了该信号或没有写操作那它依然会继续运行
-        // 如果子进程只是在读,那么它收不到 SIGPIPE 信号, 当子进程尝试从 pipe 读取数据, 而写入端(父进程)被关闭时, 读操作会立即返回 0(表示 EOF, 文件结束符), 读取到 EOF 的结果取决于子进程的代码逻辑
-        // 注册钩子:当 JVM 退出时执行
-        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
-            if (process.isAlive()) {
-                process.destroyForcibly();
-                log.info("JVM 退出,已清理残留的 FFmpeg 进程");
-            }
-        }));
-
-        // 1. 设置强制超时,防止 FFmpeg 陷入无限循环
-        if (!process.waitFor(10, TimeUnit.MINUTES)) {
-            // 超过 2 小时还没转完,直接干掉
-            process.destroyForcibly();
-        }
-        // 2. 确保 Java 退出时,FFmpeg 也跟着死
-        process.descendants().forEach(ProcessHandle::destroyForcibly);
-        // 1. 处理错误流 (stderr) - FFmpeg 的主要输出都在这
-        /*Thread errorThread = new Thread(() -> {
-            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
-                String line;
-                while ((line = reader.readLine()) != null) {
-                    // 这里可以解析 line 来获取转码进度(如 time=00:00:10.50)
-                    log.info("FFmpeg Log: {}", line);
-                }
-            } catch (IOException e) {
-                log.error("读取错误流异常", e);
-            }
-        });
-
-        // 2. 处理标准输出流 (stdout) - 预防万一
-        Thread outputThread = new Thread(() -> {
-            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
-                String line;
-                while ((line = reader.readLine()) != null) {
-                    log.info("FFmpeg Log: {}", line);
-                }
-            } catch (IOException e) {
-                log.error("读取标准流异常", e);
-            }
-        });
-
-        errorThread.start();
-        outputThread.start();*/
-
-        // 3. 等待进程结束
-        int exitCode = process.waitFor();
-        System.out.println("exitCode: " + exitCode);
-
-        // 确保线程执行完毕
-//        errorThread.join();
-//        outputThread.join();
-        return exitCode;
-    }
-
-    /**
-     * 将 FFmpeg 的时间字符串 (HH:mm:ss.SS) 转换为总秒数
-     * @param timeStr 例如 "00:02:16.65"
-     * @return 总秒数,例如 136.65
-     */
-    static double convertToSeconds(String timeStr) {
-        if (timeStr == null || !timeStr.contains(":")) {
-            return 0.0;
-        }
-
-        try {
-            String[] parts = timeStr.split(":");
-            if (parts.length != 3) return 0.0;
-
-            double hours = Double.parseDouble(parts[0]);
-            double minutes = Double.parseDouble(parts[1]);
-            double seconds = Double.parseDouble(parts[2]);
-
-            // 计算总秒数: 时*3600 + 分*60 + 秒
-            return hours * 3600 + minutes * 60 + seconds;
-        } catch (NumberFormatException e) {
-            // 记录异常日志,防止解析错误导致线程崩溃
-            return 0.0;
-        }
-    }
-
     static List<Double> getSceneTimestamps(String videoPath) throws Exception {
         List<Double> timestamps = new ArrayList<>();
         timestamps.add(0.0); // 默认从0秒开始
@@ -233,7 +58,7 @@ public class VideoProcessor {
 
     static void splitByScenes(String input, List<Double> points) {
         File file = new File(input);
-        String parent = file.getParent();
+        String parentPath = file.getParent();
         String filename = file.getName();
         double total = getVideoTotalSeconds(input);
         for (int i = 0; i < points.size(); i++) {
@@ -247,66 +72,45 @@ public class VideoProcessor {
             // 如果是最后一段,时长由 ffprobe 获取的总长度决定,这里简化演示
             double duration = (i < points.size() - 1) ? (points.get(i + 1) - start) : -1;
 
-            String output = String.format("%s/%s_part%s.mp4", parent, filename, i);
+            String output = String.format("%s/%s_part%s.mp4", parentPath, filename, i);
             // 构造精准剪切命令
-            List<String> cmd = new ArrayList<>(Arrays.asList(
+            List<String> command = new ArrayList<>(Arrays.asList(
                     "ffmpeg", "-hide_banner", "-y",
                     "-ss", String.valueOf(start),
                     "-i", input
             ));
             if (duration != -1) {
-                cmd.add("-t"); cmd.add(String.valueOf(duration));
+                command.add("-t");
+                command.add(String.valueOf(duration));
             }
-            cmd.addAll(Arrays.asList(
+            command.addAll(Arrays.asList(
                     "-c:v", "h264_nvenc",
-                    "-preset", "p4",
+                    "-rc", "vbr",
                     "-cq", "24",
+                    "-qmin", "24",
+                    "-qmax", "24",
+                    "-preset", "p4",
+                    "-pix_fmt", "yuv420p",
                     "-c:a", "aac",
-                    "-avoid_negative_ts", "make_zero",
-                    "-map_metadata", "-1",
+                    "-b:a", "128k",
                     "-movflags", "+faststart",
                     output
             ));
 
             try {
-                executeFFmpeg(cmd, total0);
+                OutputHandler stdoutHandler = new EmptyHandler();
+                OutputHandler stderrHandler = new ConvertVideoOutputHandler(total0);
+                int exitCode = ShellWrapper.executeFFmpeg(command, stdoutHandler, stderrHandler);
+                if (exitCode != 0) {
+                    String errorMsg = "convert video failed";
+                    throw new RuntimeException(errorMsg);
+                }
             } catch (Exception e) {
                 throw new RuntimeException(e);
             }
         }
     }
 
-    static void getMafd(String input) {
-        // 构造精准剪切命令
-        List<String> cmd = new ArrayList<>(Arrays.asList(
-                "ffmpeg", "-i", input,
-                "-filter_complex", "scdet=threshold=10",
-                "-f", "null", "-"
-        ));
-
-        try {
-            executeFFmpeg(cmd, 0.0);
-        } catch (Exception e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    static double calculateAdaptiveThreshold(List<Double> mafdList) {
-        // 1. 计算均值
-        double sum = 0;
-        for (double d : mafdList) sum += d;
-        double mean = sum / mafdList.size();
-
-        // 2. 计算标准差
-        double varsum = 0;
-        for (double d : mafdList) varsum += Math.pow(d - mean, 2);
-        double stdev = Math.sqrt(varsum / mafdList.size());
-
-        // 3. 动态返回:底色越乱,门槛越高
-        // 这里的 10.0 是基础门槛,3.5 是波动系数
-        return Math.max(10.0, mean + 3.5 * stdev);
-    }
-
     static double getVideoTotalSeconds(String videoPath) {
         List<String> cmd = Arrays.asList(
                 "ffprobe",
@@ -331,128 +135,14 @@ public class VideoProcessor {
         return 0.0;
     }
 
-    static void convertToWebStandard(File inputFile) {
-        double total = getVideoTotalSeconds(inputFile.getAbsolutePath());
-
-        List<String> cmd = new ArrayList<>(Arrays.asList(
-                "ffmpeg", "-y",
-                "-i", inputFile.getAbsolutePath()
-        ));
-
-        String outputPath = inputFile.getAbsolutePath() + ".mp4";
-        cmd.addAll(Arrays.asList(
-                "-c:v", "h264_nvenc",
-                "-preset", "p4",
-                "-cq", "24",
-                "-c:a", "aac",
-                "-avoid_negative_ts", "make_zero",
-                "-map_metadata", "-1",
-                "-movflags", "+faststart",
-                outputPath
-        ));
-
-        // 基础参数
-        List<String> command = new ArrayList<>();
-        command.add("ffmpeg");
-        command.add("-y");
-        command.add("-hide_banner");
-        command.add("-i");
-        command.add(inputFile.getAbsolutePath());
-        // 视频参数:强制 H.264
-        command.add("-c:v"); command.add("h264_nvenc");
-        command.add("-pix_fmt"); command.add("yuv420p");
-        // 适配 FastStart (Web 播放关键)
-        command.add("-movflags"); command.add("+faststart");
-        // 音频参数
-        command.add("-c:a"); command.add("aac");
-        command.add(outputPath);
-        try {
-            executeFFmpeg(command, total);
-        } catch (Exception e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    static void transcodeWithEnhanceAndWatermark(String input) {
-        String text = "画质增强预览";
-        String fontPath = "/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc";
-        String filterChain =
-                // 1. 去噪 (hqdn3d)
-                "hqdn3d=2:2:7:7," +
-                        // 2. 锐化 (unsharp)
-                        "unsharp=5:5:1.0:5:5:1.0," +
-                        // 3. 缩放至 720P 并补黑边 (scale + pad)
-                        "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:black," +
-                        // 4. 色彩增强 (eq)
-                        "eq=contrast=1.1:saturation=1.3," +
-                        // 5. 叠加文字水印 (drawtext)
-                        "drawtext=text='" + text + "':fontfile='" + fontPath + "':fontsize=24:fontcolor=white@0.5:x=w-tw-40:y=40";
-
-        String output = String.format("%s.mp4", input);
-        List<String> command = Arrays.asList(
-                "ffmpeg", "-i", input,
-                "-vf", filterChain,
-                "-c:v", "libx264", "-preset", "slow", "-crf", "20",
-                "-c:a", "aac", "-b:a", "128k",
-                "-movflags", "+faststart",
-                "-y", output
-        );
-
-        double total = getVideoTotalSeconds(input);
-        try {
-            executeFFmpeg(command, total);
-        } catch (Exception e) {
-            throw new RuntimeException(e);
-        }
-    }
-
     public static void main(String[] args) throws Exception {
-        String videoPath = "/home/reghao/Downloads/0/abc2.mp4";
-        //videoPath = "/home/reghao/Downloads/start_app.sh";
-        List<String> commands = Arrays.asList(
-                "ffprobe", "-v", "error",
-                "-select_streams", "v",
-                "-show_frames",
-                "-show_entries", "frame=pkt_pos,pkt_pts_time,pict_type",
-                "-of", "json",
-                videoPath
-        );
-
-        // 使用 ffprobe 检查是否能读取流信息
-        // 如果文件头破损,命令会直接返回非 0 退出码
-        List<String> cmd = Arrays.asList("ffprobe", "-v", "error", "-show_format", "-show_streams", videoPath);
-
-        //executeFFmpeg(cmd, 0);
-        //List<KeyFrameInfo> list = getKeyFrameOffsets(commands);
-
-        String dir = "/home/reghao/disk/2/porn/yyy.待去广告/aaa/";
+        String dir = "/home/reghao/disk/3/porn/yyy.待去广告/aaa/1";
         for (File file : new File(dir).listFiles()) {
             String absolutePath = file.getAbsolutePath();
             long start = System.currentTimeMillis();
-            List<Double> list = getSceneTimestamps(videoPath);
-            log.info("cost {} ms", (System.currentTimeMillis()-start)/1000);
+            List<Double> list = getSceneTimestamps(file.getAbsolutePath());
             splitByScenes(absolutePath, list);
-            log.info("cost {} ms", (System.currentTimeMillis()-start)/1000);
+            log.info("process {} cost {}s", absolutePath, (System.currentTimeMillis()-start)/1000);
         }
-
-        String videoPath1 = "/home/reghao/Downloads/3/scene_125.mp4";
-        //getMafd(videoPath1);
-
-        /*VideoMetadataValidator validator = new VideoMetadataValidator();
-        JsonNode meta = validator.getFullMetadata(videoPath);*/
-
-        // 1. 检查是否伪装
-        /*if (!validator.hasStreamType(meta, "video")) {
-            System.out.println("这不是一个视频文件!");
-        }*/
-        // 2. 漂亮地打印出整个 JSON(调试用)
-        //System.out.println(meta.toPrettyString());
-        // 3. 获取特定的元数据,比如视频的编码规格(Profile)
-        //String profile = meta.path("streams").get(0).path("profile").asText();
-        //System.out.println("视频 Profile: " + profile); // 例如: High 或 Main
-
-        videoPath = "/home/reghao/Downloads/1/abc1.mp4";
-//        convertToWebStandard(new File(videoPath));
-//        transcodeWithEnhanceAndWatermark(videoPath);
     }
 }

+ 60 - 0
oss-store/src/main/java/cn/reghao/oss/store/media/handler/ConvertVideoOutputHandler.java

@@ -0,0 +1,60 @@
+package cn.reghao.oss.store.media.handler;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * @author reghao
+ * @date 2026-04-26 13:35:22
+ */
+@Slf4j
+public class ConvertVideoOutputHandler implements OutputHandler {
+    static Pattern timePattern = Pattern.compile("time=(\\d{2}:\\d{2}:\\d{2}.\\d{2})");
+    private final double totalSeconds;
+
+    public ConvertVideoOutputHandler(double totalSeconds) {
+        this.totalSeconds = totalSeconds;
+    }
+
+    @Override
+    public void handle(String line) {
+        Matcher matcher = timePattern.matcher(line);
+        if (matcher.find()) {
+            String timeStr = matcher.group(1);
+            double currentSeconds = convertToSeconds(timeStr);
+            double percent = (currentSeconds / totalSeconds) * 100;
+            String percentStr = String.format("%.2f", percent);
+            log.error("FFmpeg ERROR: {}", percentStr);
+            // 限制频率,防止每帧都发消息给前端(比如每增加 1% 发一次)
+            //updateProgressToFrontend(videoId, percent);
+        }
+    }
+
+    /**
+     * 将 FFmpeg 的时间字符串 (HH:mm:ss.SS) 转换为总秒数
+     * @param timeStr 例如 "00:02:16.65"
+     * @return 总秒数,例如 136.65
+     */
+    static double convertToSeconds(String timeStr) {
+        if (timeStr == null || !timeStr.contains(":")) {
+            return 0.0;
+        }
+
+        try {
+            String[] parts = timeStr.split(":");
+            if (parts.length != 3) return 0.0;
+
+            double hours = Double.parseDouble(parts[0]);
+            double minutes = Double.parseDouble(parts[1]);
+            double seconds = Double.parseDouble(parts[2]);
+
+            // 计算总秒数: 时*3600 + 分*60 + 秒
+            return hours * 3600 + minutes * 60 + seconds;
+        } catch (NumberFormatException e) {
+            // 记录异常日志,防止解析错误导致线程崩溃
+            return 0.0;
+        }
+    }
+}

+ 11 - 0
oss-store/src/main/java/cn/reghao/oss/store/media/handler/EmptyHandler.java

@@ -0,0 +1,11 @@
+package cn.reghao.oss.store.media.handler;
+
+/**
+ * @author reghao
+ * @date 2026-04-26 13:42:47
+ */
+public class EmptyHandler implements OutputHandler {
+    @Override
+    public void handle(String line) {
+    }
+}

+ 9 - 0
oss-store/src/main/java/cn/reghao/oss/store/media/handler/OutputHandler.java

@@ -0,0 +1,9 @@
+package cn.reghao.oss.store.media.handler;
+
+/**
+ * @author reghao
+ * @date 2026-04-26 13:34:36
+ */
+public interface OutputHandler {
+    void handle(String line);
+}

+ 18 - 0
oss-store/src/main/java/cn/reghao/oss/store/media/handler/ShellResult.java

@@ -0,0 +1,18 @@
+package cn.reghao.oss.store.media.handler;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * @author reghao
+ * @date 2026-04-26 14:45:50
+ */
+@NoArgsConstructor
+@AllArgsConstructor
+@Data
+public class ShellResult {
+    private int exitCode;
+    private String stdout;
+    private String stderr;
+}

+ 160 - 0
oss-store/src/main/java/cn/reghao/oss/store/media/handler/ShellWrapper.java

@@ -0,0 +1,160 @@
+package cn.reghao.oss.store.media.handler;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * @author reghao
+ * @date 2026-04-26 13:33:22
+ */
+@Slf4j
+public class ShellWrapper {
+    public static int executeFFmpeg(List<String> commands, OutputHandler stdoutHandler, OutputHandler stderrHandler) throws Exception {
+        ProcessBuilder pb = new ProcessBuilder(commands);
+        Process process = pb.start();
+
+        StringBuilder stdout = new StringBuilder();
+        StringBuilder stderr = new StringBuilder();
+        // 父进程使用两个线程分别读取子进程的 stdout 和 stderr, 也就是子进程会向父进程写数据
+        // FFmpeg 的日志输出在 stderr, Java 程序必须不断读取 process.getErrorStream(), 否则会导致缓冲区满造成进程挂起(Zombie Process)
+        try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
+            executor.submit(() -> {
+                try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+                    String line;
+                    while ((line = reader.readLine()) != null) {
+                        //log.info("FFmpeg INFO: {}", line);
+                        stdout.append(line).append(System.lineSeparator());
+                        stdoutHandler.handle(line);
+                    }
+                } catch (IOException e) {
+                    log.error("读取标准流异常", e);
+                }
+            });
+            executor.submit(() -> {
+                try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
+                    String line;
+                    // BufferedReader 是同步阻塞式的
+                    // 有数据: 立即读取并返回
+                    // 没数据但流没关: 线程进入等待状态,让出 CPU 资源,直到操作系统通知有新数据到达
+                    // 流关闭时(子进程退出): readLine 返回 null, 此时阻塞解除
+                    while ((line = reader.readLine()) != null) {
+                        //log.error("FFmpeg ERROR: {}", line);
+                        stderr.append(line).append(System.lineSeparator());
+                        stderrHandler.handle(line);
+                    }
+                } catch (IOException e) {
+                    log.error("读取错误流异常", e);
+                }
+            });
+            // 停止接收新任务,但会把已提交的任务执行完
+            // executor 默认创建的都是非守护
+            executor.shutdown();
+
+            // 执行 kill -9 会让操作系统直接从内存和 CPU 调度中抹除进程, JVM 进程还没来得及执行下一行指令就被杀死,所以 ShutdownHook 自然无法运行
+            // ShutdownHook 依赖于 JVM 接收到操作系统的信号后的内部处理机制
+            // 子进程默认情况下变成"孤儿进程", 它会立即被 1 号进程(init 或 systemd) 领养
+            // 如果子进程正通过 stdin/stdout 与父进程进行实时通信, 父进程被杀时 pipe 关闭, 子进程在下一次尝试向 pipe 写数据时会收到操作系统的 SIGPIPE 信号, 大多数程序在收到 SIGPIPE 时的默认行为是退出, 但如果子进程忽略了该信号或没有写操作那它依然会继续运行
+            // 如果子进程只是在读,那么它收不到 SIGPIPE 信号, 当子进程尝试从 pipe 读取数据, 而写入端(父进程)被关闭时, 读操作会立即返回 0(表示 EOF, 文件结束符), 读取到 EOF 的结果取决于子进程的代码逻辑
+            // 注册钩子:当 JVM 退出时执行
+            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+                if (process.isAlive()) {
+                    process.destroyForcibly();
+                    log.error("JVM 退出,已清理残留的 FFmpeg 进程");
+                }
+            }));
+
+            // 1. 设置强制超时,防止 FFmpeg 陷入无限循环
+            if (!process.waitFor(10, TimeUnit.MINUTES)) {
+                // 超过 2 小时还没转完,直接干掉
+                process.destroyForcibly();
+            }
+            // 2. 确保 Java 退出时,FFmpeg 也跟着死
+            process.descendants().forEach(ProcessHandle::destroyForcibly);
+            // 3. 等待进程结束
+            int exitCode = process.waitFor();
+
+            ShellResult shellResult = new ShellResult(exitCode, stdout.toString(), stderr.toString());
+            return exitCode;
+        }
+    }
+
+    public static ShellResult executeWithResult(List<String> commands, Pattern stdoutPattern, Pattern stderrPattern) throws Exception {
+        ProcessBuilder pb = new ProcessBuilder(commands);
+        Process process = pb.start();
+
+        StringBuilder stdout = new StringBuilder();
+        StringBuilder stderr = new StringBuilder();
+        // 父进程使用两个线程分别读取子进程的 stdout 和 stderr, 也就是子进程会向父进程写数据
+        // FFmpeg 的日志输出在 stderr, Java 程序必须不断读取 process.getErrorStream(), 否则会导致缓冲区满造成进程挂起(Zombie Process)
+        try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
+            executor.submit(() -> {
+                try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+                    String line;
+                    while ((line = reader.readLine()) != null) {
+                        //log.info("FFmpeg INFO: {}", line);
+                        Matcher matcher = stdoutPattern.matcher(line);
+                        if (matcher.find()) {
+                            stdout.append(line).append(System.lineSeparator());
+                        }
+                    }
+                } catch (IOException e) {
+                    log.error("读取标准流异常", e);
+                }
+            });
+            executor.submit(() -> {
+                try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
+                    String line;
+                    // BufferedReader 是同步阻塞式的
+                    // 有数据: 立即读取并返回
+                    // 没数据但流没关: 线程进入等待状态,让出 CPU 资源,直到操作系统通知有新数据到达
+                    // 流关闭时(子进程退出): readLine 返回 null, 此时阻塞解除
+                    while ((line = reader.readLine()) != null) {
+                        //log.error("FFmpeg ERROR: {}", line);
+                        Matcher matcher = stderrPattern.matcher(line);
+                        if (matcher.find()) {
+                            stderr.append(line).append(System.lineSeparator());
+                        }
+                    }
+                } catch (IOException e) {
+                    log.error("读取错误流异常", e);
+                }
+            });
+            // 停止接收新任务,但会把已提交的任务执行完
+            // executor 默认创建的都是非守护
+            executor.shutdown();
+
+            // 执行 kill -9 会让操作系统直接从内存和 CPU 调度中抹除进程, JVM 进程还没来得及执行下一行指令就被杀死,所以 ShutdownHook 自然无法运行
+            // ShutdownHook 依赖于 JVM 接收到操作系统的信号后的内部处理机制
+            // 子进程默认情况下变成"孤儿进程", 它会立即被 1 号进程(init 或 systemd) 领养
+            // 如果子进程正通过 stdin/stdout 与父进程进行实时通信, 父进程被杀时 pipe 关闭, 子进程在下一次尝试向 pipe 写数据时会收到操作系统的 SIGPIPE 信号, 大多数程序在收到 SIGPIPE 时的默认行为是退出, 但如果子进程忽略了该信号或没有写操作那它依然会继续运行
+            // 如果子进程只是在读,那么它收不到 SIGPIPE 信号, 当子进程尝试从 pipe 读取数据, 而写入端(父进程)被关闭时, 读操作会立即返回 0(表示 EOF, 文件结束符), 读取到 EOF 的结果取决于子进程的代码逻辑
+            // 注册钩子:当 JVM 退出时执行
+            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+                if (process.isAlive()) {
+                    process.destroyForcibly();
+                    log.error("JVM 退出,已清理残留的 FFmpeg 进程");
+                }
+            }));
+
+            // 1. 设置强制超时,防止 FFmpeg 陷入无限循环
+            if (!process.waitFor(10, TimeUnit.MINUTES)) {
+                // 超过 2 小时还没转完,直接干掉
+                process.destroyForcibly();
+            }
+            // 2. 确保 Java 退出时,FFmpeg 也跟着死
+            process.descendants().forEach(ProcessHandle::destroyForcibly);
+            // 3. 等待进程结束
+            int exitCode = process.waitFor();
+            return new ShellResult(exitCode, stdout.toString(), stderr.toString());
+        }
+    }
+}

+ 27 - 0
oss-store/src/main/java/cn/reghao/oss/store/media/handler/SoundOutputHandler.java

@@ -0,0 +1,27 @@
+package cn.reghao.oss.store.media.handler;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * @author reghao
+ * @date 2026-04-26 13:41:49
+ */
+public class SoundOutputHandler implements OutputHandler {
+    // 定义正则表达式匹配 max_volume 和 mean_volume
+    Pattern maxVolPattern = Pattern.compile("max_volume: ([-+]?\\d+\\.?\\d*) dB");
+    Pattern meanVolPattern = Pattern.compile("mean_volume: ([-+]?\\d+\\.?\\d*) dB");
+
+    @Override
+    public void handle(String line) {
+        Matcher maxMatcher = maxVolPattern.matcher(line);
+        if (maxMatcher.find()) {
+            System.out.println(">>> 检测到最大音量: " + maxMatcher.group(1) + " dB");
+        }
+
+        Matcher meanMatcher = meanVolPattern.matcher(line);
+        if (meanMatcher.find()) {
+            System.out.println(">>> 检测到平均音量: " + meanMatcher.group(1) + " dB");
+        }
+    }
+}

+ 2 - 2
oss-store/src/main/java/cn/reghao/oss/store/rpc/StoreServiceImpl.java

@@ -8,7 +8,7 @@ import cn.reghao.oss.api.dto.media.VideoInfo;
 import cn.reghao.oss.api.iface.StoreService;
 import cn.reghao.oss.api.util.OssSamplingHash;
 import cn.reghao.oss.store.disk.DiskService;
-import cn.reghao.oss.store.media.VideoMetadataValidator;
+import cn.reghao.oss.store.media.VideoFileProcessor;
 import com.fasterxml.jackson.databind.JsonNode;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.io.FileUtils;
@@ -69,7 +69,7 @@ public class StoreServiceImpl implements StoreService {
             }
 
             log.info("{}", file.getAbsolutePath());
-            JsonNode jsonNode = VideoMetadataValidator.getFullMetadata(file.getAbsolutePath());
+            JsonNode jsonNode = VideoFileProcessor.getVideoMetadata(file.getAbsolutePath());
             VideoInfo videoInfo = parseVideoInfo(jsonNode);
             videoInfo.setObjectId(objectId);
             videoInfo.setUrlType("mp4");