前端vue后端java+springboot如何显示视频压缩

功能:根据指定的大小压缩视频

思路:视频文件比较大,我这里的实现是前端先直连oss上传返回视频链接,再把视频链接传给后端,避免几十上百兆的视频卡在服务器上;如果觉得自己服务器够大的,也可以直接用简单的方式,直接上传视频流file给后端就行

不喜欢开发的也可以收藏一下,免费压缩视频和视频相关处理

先看效果,可直接点击链接看:点透办公-视频压缩-指定大小压缩

前端就是简单的vue样式,自己随便写一下就行了

后端处理必须用:ffempg, 在你的电脑或者linux系统上去下载这个安装好,使用命令去处理,ffempg是专业处理音频相关的工具,而且是开源免费

下面我就贴上部分后端代码,如果想要全部前后端代码的,可以找我,免费的可以分享给大家

java 复制代码
package com.ruoyi.basicTool.video;

import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.stream.Stream;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import javax.imageio.ImageIO;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;

/**
 * 站内视频小工具:调用本机 ffmpeg/ffprobe 处理上传文件(临时目录读写,处理完即删)。
 * 可执行文件解析顺序与开放平台时长解析一致:{@code ffmpeg.binary}(可由 {@code application.yml} 中
 * {@code ffmpeg.binary} 经 {@code com.ruoyi.framework.config.FfmpegPathConfig} 注入)/ {@code FFMPEG_BINARY} / {@code ffmpeg}。
 */
@Service
public class OfficeVideoFfmpegService
{
    private static final Logger log = LoggerFactory.getLogger(OfficeVideoFfmpegService.class);

    private static final long MAX_INPUT_BYTES = 100L * 1024 * 1024;
    /** 图片水印远程下载 / 上传上限(与前端「水印图不超过 8MB」一致) */
    private static final long MAX_WATERMARK_IMAGE_BYTES = 8L * 1024 * 1024;
    private static final int MERGE_MAX_FILES = 10;
    /** ffmpeg 单次处理超时:90 秒(1 分 30 秒) */
    private static final int FFMPEG_TIMEOUT_SECONDS = 90;
    private static final Pattern CROP_DETECT = Pattern.compile("crop=(\\d+):(\\d+):(\\d+):(\\d+)");

    /**
     * 压缩编码方案:按目标体积比例(相对源文件)估算平均码率,或退回纯 CRF(兼容旧 customCrf)。
     */
    private static final class CompressEncodePlan
    {
        /** true 时使用 {@link #crf},忽略 targetSizeRatio */
        final boolean crfOnly;
        final int crf;
        /** 目标输出体积 / 源文件体积,如 0.4 表示约 40% */
        final double targetSizeRatio;
        final boolean preferSpeed;

        private CompressEncodePlan(boolean crfOnly, int crf, double targetSizeRatio, boolean preferSpeed)
        {
            this.crfOnly = crfOnly;
            this.crf = crf;
            this.targetSizeRatio = targetSizeRatio;
            this.preferSpeed = preferSpeed;
        }

        static CompressEncodePlan crf(int crf)
        {
            return new CompressEncodePlan(true, crf, 0d, false);
        }

        static CompressEncodePlan targetRatio(double ratio, boolean preferSpeed)
        {
            return new CompressEncodePlan(false, 0, ratio, preferSpeed);
        }

        /**
         * 四档模式与前端约定一致:
         * <ul>
         * <li>default:目标约原文件 30%~50%(取中值 40%)</li>
         * <li>high:约 50%~70%(取中值 60%)</li>
         * <li>fast / 极速:约 10%~30%(取中值 20%),并启用更快 preset</li>
         * <li>custom:compressPercent 为 10~90,表示<strong>体积减小约百分之几</strong>(如 30 即约减小 30%,目标约为原文件的 70%;50 即约 50% 剩余)</li>
         * </ul>
         */
        static CompressEncodePlan resolve(String mode, Integer customCrf, Integer compressPercent)
        {
            String m = mode == null ? "default" : mode.trim().toLowerCase(Locale.ROOT);
            if ("custom".equals(m))
            {
                if (compressPercent != null)
                {
                    int p = Math.min(90, Math.max(10, compressPercent));
                    /* 目标体积比例 = 1 - 减小比例;如 100MB、减小 30% → 约 70MB → ratio=0.7 */
                    double targetRatio = (100 - p) / 100.0;
                    if (targetRatio < 0.08)
                    {
                        targetRatio = 0.08;
                    }
                    if (targetRatio > 0.95)
                    {
                        targetRatio = 0.95;
                    }
                    return targetRatio(targetRatio, false);
                }
                if (customCrf != null)
                {
                    int c = customCrf;
                    if (c < 18)
                    {
                        c = 18;
                    }
                    if (c > 35)
                    {
                        c = 35;
                    }
                    return crf(c);
                }
                /* 默认按减小 30% → 约保留 70% */
                return targetRatio((100 - 30) / 100.0, false);
            }
            switch (m)
            {
                case "high":
                    return targetRatio(0.60, false);
                case "fast":
                case "极速":
                    return targetRatio(0.20, true);
                case "default":
                default:
                    return targetRatio(0.40, false);
            }
        }
    }

    private static String ffmpegBin()
    {
        String p = System.getProperty("ffmpeg.binary");
        if (StringUtils.isNotEmpty(p))
        {
            return p.trim();
        }
        String env = System.getenv("FFMPEG_BINARY");
        if (StringUtils.isNotEmpty(env))
        {
            return env.trim();
        }
        return "ffmpeg";
    }

    private static String ffprobeBin()
    {
        String p = System.getProperty("ffprobe.binary");
        if (StringUtils.isNotEmpty(p))
        {
            return p.trim();
        }
        String env = System.getenv("FFPROBE_BINARY");
        if (StringUtils.isNotEmpty(env))
        {
            return env.trim();
        }
        String ffmpeg = ffmpegBin();
        String lower = ffmpeg.toLowerCase(Locale.ROOT);
        if (lower.endsWith("ffmpeg.exe"))
        {
            return ffmpeg.substring(0, ffmpeg.length() - "ffmpeg.exe".length()) + "ffprobe.exe";
        }
        if (lower.endsWith("ffmpeg"))
        {
            return ffmpeg.substring(0, ffmpeg.length() - "ffmpeg".length()) + "ffprobe";
        }
        return "ffprobe";
    }

    /**
     * 将解析到的字体复制到临时目录下的固定短文件名,供 drawtext 使用<strong>不含盘符</strong>的 {@code fontfile=wmfont.ttf},
     * 彻底避免 Windows 上 {@code C:} 被当作滤镜选项分隔符(单引号、{@code C\:} 等在不同 FFmpeg 版本上易失败)。
     */
    private static Path copyDrawtextFontIntoWork(Path work, Path sourceFont) throws IOException
    {
        String lower = sourceFont.getFileName().toString().toLowerCase(Locale.ROOT);
        String localName = lower.endsWith(".ttc") || lower.endsWith(".otc") ? "wmfont.ttc" : "wmfont.ttf";
        Path dest = work.resolve(localName);
        Files.copy(sourceFont, dest, StandardCopyOption.REPLACE_EXISTING);
        return dest;
    }

    /**
     * 解析 drawtext 可用的字体文件:优先 {@code office.video.drawtext.font},否则探测常见系统字体。
     * 不带 {@code fontfile=} 时 drawtext 依赖 Fontconfig,在 Windows 上常报错甚至导致 ffmpeg 异常退出。
     *
     * @param fontFamily 前端 CSS 风格提示(如 sans-serif、serif),用于在默认列表中优先匹配。
     */
    private static Path resolveDrawtextFontPath(String fontFamily)
    {
        String prop = System.getProperty("office.video.drawtext.font", "");
        if (StringUtils.isNotEmpty(prop))
        {
            Path p = Paths.get(prop.trim());
            if (Files.isReadable(p))
            {
                return p;
            }
        }
        String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
        if (os.contains("win"))
        {
            String windir = System.getenv("WINDIR");
            if (StringUtils.isEmpty(windir))
            {
                windir = "C:\\Windows";
            }
            String fam = fontFamily == null ? "" : fontFamily.trim().toLowerCase(Locale.ROOT);
            if (fam.contains("mono"))
            {
                String[] mono = new String[] { "consola.ttf", "cour.ttf", "lucon.ttf" };
                for (String n : mono)
                {
                    Path fp = Paths.get(windir, "Fonts", n);
                    if (Files.isReadable(fp))
                    {
                        return fp;
                    }
                }
            }
            if (fam.contains("serif") && !fam.contains("sans"))
            {
                String[] serif = new String[] { "times.ttf", "timesi.ttf", "timesbd.ttf", "georgia.ttf" };
                for (String n : serif)
                {
                    Path fp = Paths.get(windir, "Fonts", n);
                    if (Files.isReadable(fp))
                    {
                        return fp;
                    }
                }
            }
            String[] names = new String[] {
                    "arial.ttf",
                    "calibri.ttf",
                    "segoeui.ttf",
                    "msyh.ttc",
                    "simhei.ttf",
                    "simsun.ttc",
            };
            for (String n : names)
            {
                Path fp = Paths.get(windir, "Fonts", n);
                if (Files.isReadable(fp))
                {
                    return fp;
                }
            }
        }
        else
        {
            String[] paths = new String[] {
                    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
                    "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
                    "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
            };
            for (String s : paths)
            {
                Path fp = Paths.get(s);
                if (Files.isReadable(fp))
                {
                    return fp;
                }
            }
        }
        return null;
    }

    private static volatile String cachedH264Encoder;
    private static volatile String cachedAacEncoder;

    private static String pickH264Encoder()
    {
        if (cachedH264Encoder != null)
        {
            return cachedH264Encoder;
        }
        String encoders = runFfmpegHelpText(Arrays.asList(ffmpegBin(), "-hide_banner", "-encoders"));
        String[] candidates = new String[] {
                "libx264",
                "h264_nvenc",
                "h264_qsv",
                "h264_amf",
                "h264_mf",
                "h264_vaapi",
        };
        for (String c : candidates)
        {
            if (containsWord(encoders, c))
            {
                cachedH264Encoder = c;
                return c;
            }
        }
        throw new ServiceException("服务器 ffmpeg 未提供可用的 H264 编码器(缺少 libx264 等),请联系运维安装完整版 ffmpeg");
    }

    private static String pickAacEncoder()
    {
        if (cachedAacEncoder != null)
        {
            return cachedAacEncoder;
        }
        String encoders = runFfmpegHelpText(Arrays.asList(ffmpegBin(), "-hide_banner", "-encoders"));
        String[] candidates = new String[] { "aac", "libfdk_aac" };
        for (String c : candidates)
        {
            if (containsWord(encoders, c))
            {
                cachedAacEncoder = c;
                return c;
            }
        }
        throw new ServiceException("服务器 ffmpeg 未提供可用的 AAC 编码器,请联系运维安装完整版 ffmpeg");
    }

    /**
     * @param preferSpeed 为 true 时在画质可接受前提下偏向更快编码(格式转换等);合并/裁剪等仍用默认偏稳参数。
     */
    private static void appendH264VideoArgs(List<String> cmd, int crf, boolean preferSpeed)
    {
        String v = pickH264Encoder();
        cmd.add("-c:v");
        cmd.add(v);
        if ("libx264".equals(v))
        {
            cmd.add("-preset");
            cmd.add(preferSpeed ? "veryfast" : "fast");
            cmd.add("-crf");
            cmd.add(String.valueOf(crf));
            return;
        }
        if ("h264_nvenc".equals(v))
        {
            cmd.add("-preset");
            cmd.add(preferSpeed ? "p6" : "p4");
            cmd.add("-rc");
            cmd.add("vbr");
            cmd.add("-cq");
            cmd.add(String.valueOf(Math.min(51, Math.max(0, crf))));
            return;
        }
        if ("h264_qsv".equals(v))
        {
            cmd.add("-preset");
            cmd.add(preferSpeed ? "faster" : "veryfast");
            cmd.add("-global_quality");
            cmd.add(String.valueOf(Math.min(51, Math.max(1, crf))));
            return;
        }
        if ("h264_amf".equals(v))
        {
            cmd.add("-quality");
            cmd.add(preferSpeed ? "speed" : "balanced");
            cmd.add("-rc");
            cmd.add("cqp");
            cmd.add("-qp_i");
            cmd.add(String.valueOf(Math.min(51, Math.max(10, crf))));
            cmd.add("-qp_p");
            cmd.add(String.valueOf(Math.min(51, Math.max(10, crf))));
            return;
        }
        if ("h264_mf".equals(v))
        {
            cmd.add("-rate_control");
            cmd.add("quality");
            cmd.add("-quality");
            cmd.add(String.valueOf(Math.min(100, Math.max(0, 100 - crf * 2 - (preferSpeed ? 5 : 0)))));
            return;
        }
        if ("h264_vaapi".equals(v))
        {
            cmd.add("-global_quality");
            cmd.add(String.valueOf(Math.min(51, Math.max(1, crf + (preferSpeed ? 2 : 0)))));
            return;
        }
        throw new ServiceException("不支持的 H264 编码器: " + v);
    }

    /**
     * 带 drawtext 等滤镜重编码时,优先使用 libx264;Windows 自带 h264_mf 与部分滤镜组合易异常退出(0xC0000005)。
     * 若本机 ffmpeg 无 libx264,则退回 {@link #appendH264VideoArgs(List, int)}。
     */
    private static void appendH264VideoArgsAfterTextFilter(List<String> cmd, int crf)
    {
        String encoders = runFfmpegHelpText(Arrays.asList(ffmpegBin(), "-hide_banner", "-encoders"));
        if (containsWord(encoders, "libx264"))
        {
            cmd.add("-c:v");
            cmd.add("libx264");
            cmd.add("-preset");
            cmd.add("fast");
            cmd.add("-crf");
            cmd.add(String.valueOf(crf));
            return;
        }
        appendH264VideoArgs(cmd, crf);
    }

    private static void appendH264VideoArgs(List<String> cmd, int crf)
    {
        appendH264VideoArgs(cmd, crf, false);
    }

    private static void appendAacAudioArgs(List<String> cmd, String bitrate)
    {
        String a = pickAacEncoder();
        cmd.add("-c:a");
        cmd.add(a);
        cmd.add("-b:a");
        cmd.add(bitrate);
    }

    /** 合并前统一重编码为 MP4(48k 立体声),便于 concat copy。 */
    private List<String> buildMergeNormalizeCmd(Path raw, Path norm) throws Exception
    {
        List<String> cmd = new ArrayList<>();
        cmd.add(ffmpegBin());
        cmd.add("-hide_banner");
        cmd.add("-y");
        cmd.add("-i");
        cmd.add(raw.toString());
        if (!hasAudioStream(raw))
        {
            cmd.add("-f");
            cmd.add("lavfi");
            cmd.add("-i");
            cmd.add("anullsrc=channel_layout=stereo:sample_rate=48000");
            cmd.add("-map");
            cmd.add("0:v:0");
            cmd.add("-map");
            cmd.add("1:a:0");
            cmd.add("-shortest");
        }
        appendH264VideoArgs(cmd, 23);
        appendAacAudioArgs(cmd, "128k");
        cmd.add("-ar");
        cmd.add("48000");
        cmd.add("-ac");
        cmd.add("2");
        cmd.add("-movflags");
        cmd.add("+faststart");
        cmd.add(norm.toString());
        return cmd;
    }

    private static String runFfmpegHelpText(List<String> cmd)
    {
        try
        {
            ProcessBuilder pb = new ProcessBuilder(cmd);
            pb.redirectErrorStream(true);
            Process p = pb.start();
            byte[] raw;
            try (InputStream in = p.getInputStream())
            {
                raw = drain(in);
            }
            int code = p.waitFor();
            String out = new String(raw, StandardCharsets.UTF_8);
            if (code != 0)
            {
                log.warn("ffmpeg help failed code={} cmd={} {}", code, cmd.size() > 3 ? cmd.subList(0, 4) : cmd, abbreviateTail(out, 800));
                return "";
            }
            return out;
        }
        catch (Exception e)
        {
            log.warn("ffmpeg help exception: {}", e.getMessage());
            return "";
        }
    }

    private static boolean containsWord(String haystack, String word)
    {
        if (haystack == null || word == null)
        {
            return false;
        }
        String w = word.trim();
        if (w.isEmpty())
        {
            return false;
        }
        Pattern p = Pattern.compile("(?m)(^|\\s)" + Pattern.quote(w) + "(\\s|$)");
        return p.matcher(haystack).find();
    }

    public void assertVideoMultipart(MultipartFile file)
    {
        if (file == null || file.isEmpty())
        {
            throw new ServiceException("请上传视频文件");
        }
        if (file.getSize() > MAX_INPUT_BYTES)
        {
            throw new ServiceException("单个视频不能超过 100MB");
        }
        String name = file.getOriginalFilename();
        if (!isAllowedVideoName(name))
        {
            throw new ServiceException("不支持的视频格式,请使用 MP4、MOV、MKV、AVI、WEBM、WMV、FLV、M4V、MPEG、3GP 等常见格式");
        }
    }

    public void assertVideoSource(MultipartFile file, String fileUrl)
    {
        if (file != null && !file.isEmpty())
        {
            assertVideoMultipart(file);
            return;
        }
        if (StringUtils.isEmpty(fileUrl))
        {
            throw new ServiceException("请上传视频文件");
        }
        String url = fileUrl.trim().toLowerCase(Locale.ROOT);
        if (!(url.startsWith("http://") || url.startsWith("https://")))
        {
            throw new ServiceException("视频链接格式不正确");
        }
    }

    public void assertVideoMultipartList(List<MultipartFile> files)
    {
        if (files == null || files.isEmpty())
        {
            throw new ServiceException("请至少上传一个视频文件");
        }
        if (files.size() > MERGE_MAX_FILES)
        {
            throw new ServiceException("最多合并 " + MERGE_MAX_FILES + " 个视频");
        }
        long sum = 0;
        for (MultipartFile f : files)
        {
            assertVideoMultipart(f);
            sum += f.getSize();
        }
        if (sum > MAX_INPUT_BYTES * 2L)
        {
            throw new ServiceException("合并总大小过大,请减少文件数量或体积");
        }
    }

    private static boolean isAllowedVideoName(String name)
    {
        if (name == null)
        {
            return false;
        }
        String n = name.toLowerCase(Locale.ROOT);
        return n.endsWith(".mp4") || n.endsWith(".mov") || n.endsWith(".mkv") || n.endsWith(".avi")
                || n.endsWith(".webm") || n.endsWith(".wmv") || n.endsWith(".flv") || n.endsWith(".m4v")
                || n.endsWith(".mpg") || n.endsWith(".mpeg") || n.endsWith(".3gp") || n.endsWith(".ogv")
                || n.endsWith(".f4v") || n.endsWith(".ts") || n.endsWith(".m2ts");
    }

    /** 格式转换:输出容器由 outputFormat 决定(支持常见 FFmpeg 容器格式)。 */
    public byte[] formatConvert(MultipartFile file, String outputFormat) throws Exception
    {
        return formatConvert(file, null, outputFormat);
    }

    public byte[] formatConvert(MultipartFile file, String fileUrl, String outputFormat) throws Exception
    {
        assertVideoSource(file, fileUrl);
        String fmt = normalizeOutputFormat(outputFormat);
        Path work = Files.createTempDirectory("ovf_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            Path out = work.resolve("out." + fmt);
            List<String> cmd = new ArrayList<>();
            cmd.add(ffmpegBin());
            cmd.add("-hide_banner");
            cmd.add("-y");
            cmd.add("-i");
            cmd.add(in.toString());
            addEncodeForFormat(cmd, fmt);
            cmd.add(out.toString());
            runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    private static String normalizeOutputFormat(String outputFormat)
    {
        if (StringUtils.isEmpty(outputFormat))
        {
            return "mp4";
        }
        String f = outputFormat.trim().toLowerCase(Locale.ROOT);
        if ("mp4".equals(f) || "mov".equals(f) || "mkv".equals(f) || "webm".equals(f) || "avi".equals(f)
                || "wmv".equals(f) || "flv".equals(f) || "m4v".equals(f)
                || "mpeg".equals(f) || "vob".equals(f)
                || "ogv".equals(f) || "3gp".equals(f) || "f4v".equals(f) || "swf".equals(f))
        {
            return f;
        }
        return "mp4";
    }

    /**
     * 格式转换专用编码参数:在可接受画质下尽量提高速度(H.264 见 {@link #appendH264VideoArgs(List, int, boolean)} preferSpeed)。
     */
    private static void addEncodeForFormat(List<String> cmd, String fmt)
    {
        final boolean fast = true;
        switch (fmt)
        {
            case "webm":
                /*
                 * VP9:同体积下一般比 VP8 更清晰;CRF 越低画质越好(0~63),cpu-used 越低越慢越细(0~5)。
                 * 此前 VP8+CRF30+高 cpu-used 易发糊、体积偏小;此处偏画质,体积可能接近或略大于「强压」的 mp4。
                 */
                cmd.add("-c:v");
                cmd.add("libvpx-vp9");
                cmd.add("-crf");
                cmd.add("22");
                cmd.add("-b:v");
                cmd.add("0");
                cmd.add("-deadline");
                cmd.add("good");
                cmd.add("-cpu-used");
                cmd.add("1");
                cmd.add("-row-mt");
                cmd.add("1");
                cmd.add("-c:a");
                cmd.add("libopus");
                cmd.add("-b:a");
                cmd.add("192k");
                break;
            case "wmv":
                /* wmv2 偏旧;略提码率减轻块效应,编码本身较快 */
                cmd.add("-c:v");
                cmd.add("wmv2");
                cmd.add("-b:v");
                cmd.add("2000k");
                cmd.add("-c:a");
                cmd.add("wmav2");
                cmd.add("-b:a");
                cmd.add("160k");
                break;
            case "flv":
            case "swf":
                cmd.add("-c:v");
                cmd.add("flv1");
                cmd.add("-b:v");
                cmd.add("1500k");
                cmd.add("-g");
                cmd.add("150");
                cmd.add("-c:a");
                cmd.add("libmp3lame");
                cmd.add("-q:a");
                cmd.add("2");
                break;
            case "f4v":
                /* F4V 为 ISO 分片 + H.264/AAC(与 FLV1 无关) */
                appendH264VideoArgs(cmd, 23, fast);
                appendAacAudioArgs(cmd, "160k");
                cmd.add("-movflags");
                cmd.add("+faststart+frag_keyframe+empty_moov");
                break;
            case "avi":
                /* mpeg4:q:v 越小画质越好、越慢;4 为常用折中 */
                cmd.add("-c:v");
                cmd.add("mpeg4");
                cmd.add("-q:v");
                cmd.add("4");
                cmd.add("-c:a");
                cmd.add("libmp3lame");
                cmd.add("-q:a");
                cmd.add("4");
                break;
            case "ogv":
                /* Theora:q 5~6 兼顾;Vorbis q 5 音质略好于 4 */
                cmd.add("-c:v");
                cmd.add("libtheora");
                cmd.add("-q:v");
                cmd.add("6");
                cmd.add("-c:a");
                cmd.add("libvorbis");
                cmd.add("-q:a");
                cmd.add("5");
                break;
            case "3gp":
                cmd.add("-vf");
                cmd.add("scale='min(854,iw)':-2");
                appendH264VideoArgs(cmd, 23, fast);
                appendAacAudioArgs(cmd, "96k");
                break;
            case "mpeg":
            case "mpg":
            case "vob":
                cmd.add("-c:v");
                cmd.add("mpeg2video");
                cmd.add("-q:v");
                cmd.add("4");
                cmd.add("-c:a");
                cmd.add("mp2");
                cmd.add("-b:a");
                cmd.add("224k");
                break;
            case "ts":
            case "m2ts":
                appendH264VideoArgs(cmd, 23, fast);
                appendAacAudioArgs(cmd, "160k");
                break;
            case "m4v":
            case "mkv":
            case "mov":
            case "mp4":
            default:
                appendH264VideoArgs(cmd, 23, fast);
                appendAacAudioArgs(cmd, "192k");
                if ("mp4".equals(fmt) || "mov".equals(fmt) || "m4v".equals(fmt))
                {
                    cmd.add("-movflags");
                    cmd.add("+faststart");
                }
                break;
        }
    }

    /** 视频压缩:输出 MP4,按模式选择目标体积比例或 CRF(兼容旧 customCrf)。 */
    /** 历史重载:仅本地文件输入。 */
    public byte[] compress(MultipartFile file, String mode, Integer customCrf) throws Exception
    {
        return compress(file, null, mode, customCrf, null);
    }

    /** 历史重载:支持 file/fileUrl,默认不传 compressPercent。 */
    public byte[] compress(MultipartFile file, String fileUrl, String mode, Integer customCrf) throws Exception
    {
        return compress(file, fileUrl, mode, customCrf, null);
    }

    /**
     * 视频压缩(单文件)。
     *
     * @param compressPercent 自定义 10~90(仅 mode=custom):体积<strong>减小</strong>约百分之几(30 即约减 30%,目标约剩 70%);优先于 customCrf
     */
    public byte[] compress(MultipartFile file, String fileUrl, String mode, Integer customCrf, Integer compressPercent) throws Exception
    {
        assertVideoSource(file, fileUrl);
        CompressEncodePlan plan = CompressEncodePlan.resolve(mode, customCrf, compressPercent);
        Path work = Files.createTempDirectory("ovc_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            Path out = work.resolve("out.mp4");
            encodeCompressToMp4(in, out, plan);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    /**
     * 批量压缩:多个 URL 或多个 multipart 文件,总大小不超过 {@link #MAX_INPUT_BYTES},最多 {@link #MERGE_MAX_FILES} 个。
     * 返回 1 个输出时为 MP4 字节;多个为 ZIP(内为 compressed_序号.mp4)。
     */
    public byte[] compressBatch(List<String> fileUrls, List<MultipartFile> multipartFiles, String mode, Integer customCrf,
            Integer compressPercent) throws Exception
    {
        List<MultipartFile> mfs = multipartFiles == null ? new ArrayList<>() : multipartFiles;
        List<String> urls = fileUrls == null ? new ArrayList<>() : new ArrayList<>(fileUrls);
        urls.removeIf(s -> StringUtils.isEmpty(s));
        mfs.removeIf(f -> f == null || f.isEmpty());
        if (!mfs.isEmpty() && !urls.isEmpty())
        {
            throw new ServiceException("请勿同时上传文件与文件地址列表");
        }
        if (mfs.isEmpty() && urls.isEmpty())
        {
            throw new ServiceException("请至少选择一个视频");
        }
        if (!mfs.isEmpty())
        {
            assertCompressBatchMultipart(mfs);
        }
        else
        {
            if (urls.size() > MERGE_MAX_FILES)
            {
                throw new ServiceException("最多压缩 " + MERGE_MAX_FILES + " 个视频");
            }
        }
        CompressEncodePlan plan = CompressEncodePlan.resolve(mode, customCrf, compressPercent);
        Path batchWork = Files.createTempDirectory("ovcb_" + UUID.randomUUID());
        try
        {
            long totalBytes = 0;
            List<Path> outs = new ArrayList<>();
            if (!mfs.isEmpty())
            {
                for (int i = 0; i < mfs.size(); i++)
                {
                    Path in = prepareInputVideo(batchWork, mfs.get(i), null, "in_" + i + "_");
                    Path out = batchWork.resolve("out_" + i + ".mp4");
                    encodeCompressToMp4(in, out, plan);
                    outs.add(out);
                }
            }
            else
            {
                for (int i = 0; i < urls.size(); i++)
                {
                    Path in = prepareInputVideo(batchWork, null, urls.get(i), "in_" + i + "_");
                    totalBytes += Files.size(in);
                    if (totalBytes > MAX_INPUT_BYTES)
                    {
                        throw new ServiceException("批量压缩总大小不能超过 100MB");
                    }
                    Path out = batchWork.resolve("out_" + i + ".mp4");
                    encodeCompressToMp4(in, out, plan);
                    outs.add(out);
                }
            }
            if (outs.size() == 1)
            {
                return Files.readAllBytes(outs.get(0));
            }
            return zipCompressedMp4s(outs);
        }
        finally
        {
            deleteTreeQuietly(batchWork);
        }
    }

    private void assertCompressBatchMultipart(List<MultipartFile> files) throws Exception
    {
        if (files.size() > MERGE_MAX_FILES)
        {
            throw new ServiceException("最多压缩 " + MERGE_MAX_FILES + " 个视频");
        }
        long sum = 0L;
        for (MultipartFile f : files)
        {
            assertVideoMultipart(f);
            sum += f.getSize();
        }
        if (sum > MAX_INPUT_BYTES)
        {
            throw new ServiceException("批量压缩总大小不能超过 100MB");
        }
    }

    /**
     * 按方案压缩为 MP4:优先 libx264 + 目标平均码率(按源文件大小与时长估算,贴近目标体积比例);
     * 非 libx264 时退回 CRF + preferSpeed。
     */
    private void encodeCompressToMp4(Path in, Path out, CompressEncodePlan plan) throws Exception
    {
        if (plan.crfOnly)
        {
            encodeCompressToMp4Crf(in, out, plan.crf, false);
            return;
        }
        double ratio = plan.targetSizeRatio;
        if (ratio <= 0 || ratio > 1.0)
        {
            ratio = 0.40;
        }
        long inBytes = Files.size(in);
        double durSec = probeFormatDurationSeconds(in);
        if (durSec <= 0.05)
        {
            throw new ServiceException("无法读取视频时长,请更换视频后重试");
        }
        long targetBytes = Math.round(inBytes * ratio);
        double totalKbps = (targetBytes * 8.0) / durSec / 1000.0;
        int audioKbps = plan.preferSpeed ? 96 : 128;
        int videoKbps = (int) Math.round(totalKbps - audioKbps);
        videoKbps = Math.max(120, Math.min(videoKbps, 50000));

        String enc = pickH264Encoder();
        if ("libx264".equals(enc))
        {
            encodeCompressLibx264Abr(in, out, videoKbps, plan.preferSpeed, audioKbps);
        }
        else
        {
            int crf = targetSizeRatioToCrfFallback(ratio);
            encodeCompressToMp4Crf(in, out, crf, plan.preferSpeed);
        }
    }

    private static double probeFormatDurationSeconds(Path videoFile) throws Exception
    {
        List<String> cmd = Arrays.asList(
                ffprobeBin(), "-v", "error",
                "-show_entries", "format=duration",
                "-of", "default=noprint_wrappers=1:nokey=1",
                videoFile.toString());
        ProcessBuilder pb = new ProcessBuilder(cmd);
        pb.redirectErrorStream(true);
        Process p = pb.start();
        String out;
        try (InputStream stream = p.getInputStream())
        {
            out = new String(drain(stream), StandardCharsets.UTF_8).trim();
        }
        int code = p.waitFor();
        if (code != 0 || StringUtils.isEmpty(out))
        {
            return -1;
        }
        String first = out.split("[\\r\\n]+")[0].trim();
        try
        {
            return Double.parseDouble(first);
        }
        catch (NumberFormatException e)
        {
            return -1;
        }
    }

    /** 非 libx264 时按目标体积比例映射到 CRF(约 18~35)。 */
    private static int targetSizeRatioToCrfFallback(double ratio)
    {
        double r = Math.min(0.95, Math.max(0.08, ratio));
        int crf = (int) Math.round(35.0 - (r - 0.08) / (0.95 - 0.08) * (35.0 - 18.0));
        return Math.min(35, Math.max(18, crf));
    }

    private void encodeCompressToMp4Crf(Path in, Path out, int crf, boolean preferSpeed) throws Exception
    {
        List<String> cmd = new ArrayList<>();
        cmd.add(ffmpegBin());
        cmd.add("-hide_banner");
        cmd.add("-y");
        cmd.add("-i");
        cmd.add(in.toString());
        appendH264VideoArgs(cmd, crf, preferSpeed);
        appendAacAudioArgs(cmd, preferSpeed ? "96k" : "128k");
        cmd.add("-movflags");
        cmd.add("+faststart");
        cmd.add(out.toString());
        runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);
    }

    /** libx264 平均码率模式,便于贴近目标文件大小。 */
    private void encodeCompressLibx264Abr(Path in, Path out, int videoKbps, boolean preferSpeed, int audioKbps) throws Exception
    {
        List<String> cmd = new ArrayList<>();
        cmd.add(ffmpegBin());
        cmd.add("-hide_banner");
        cmd.add("-y");
        cmd.add("-i");
        cmd.add(in.toString());
        cmd.add("-c:v");
        cmd.add("libx264");
        cmd.add("-preset");
        cmd.add(preferSpeed ? "veryfast" : "fast");
        cmd.add("-b:v");
        cmd.add(videoKbps + "k");
        cmd.add("-maxrate");
        cmd.add((int) Math.round(videoKbps * 1.25) + "k");
        cmd.add("-bufsize");
        cmd.add((videoKbps * 2) + "k");
        cmd.add("-pix_fmt");
        cmd.add("yuv420p");
        appendAacAudioArgs(cmd, audioKbps + "k");
        cmd.add("-movflags");
        cmd.add("+faststart");
        cmd.add(out.toString());
        runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);
    }

    private static byte[] zipCompressedMp4s(List<Path> mp4Files) throws IOException
    {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        try (ZipOutputStream zos = new ZipOutputStream(bos))
        {
            for (int i = 0; i < mp4Files.size(); i++)
            {
                ZipEntry e = new ZipEntry("compressed_" + (i + 1) + ".mp4");
                zos.putNextEntry(e);
                Files.copy(mp4Files.get(i), zos);
                zos.closeEntry();
            }
        }
        return bos.toByteArray();
    }

    private static final int CROP_MAX_SEGMENTS = 20;

    /** 按时间段裁剪(秒,含 start,不含 end)。 */
    /** 历史重载:仅本地文件输入。 */
    public byte[] crop(MultipartFile file, double startSec, double endSec) throws Exception
    {
        return crop(file, null, startSec, endSec, null, null);
    }

    /** 历史重载:支持 file/fileUrl,不传 aspectRatio。 */
    public byte[] crop(MultipartFile file, String fileUrl, double startSec, double endSec) throws Exception
    {
        return crop(file, fileUrl, startSec, endSec, null, null);
    }

    /**
     * 时间段裁剪;时间点支持小数(约毫秒级)。可选画幅比例:居中裁剪,不拉伸。
     *
     * @param aspectRatio 空 / original 表示不裁画幅;16:9、9:16、4:3、1:1 为居中裁切
     */
    /** 历史重载:支持按画幅比例居中裁切。 */
    public byte[] crop(MultipartFile file, String fileUrl, double startSec, double endSec, String aspectRatio) throws Exception
    {
        return crop(file, fileUrl, startSec, endSec, aspectRatio, null);
    }

    /**
     * 同 {@link #crop(MultipartFile, String, double, double, String)};可选 cropRectJson 为源视频像素坐标矩形,优先于 aspectRatio。
     * JSON:{"x":0,"y":0,"w":1920,"h":1080}
     */
    public byte[] crop(MultipartFile file, String fileUrl, double startSec, double endSec, String aspectRatio, String cropRectJson) throws Exception
    {
        assertVideoSource(file, fileUrl);
        validateCropRange(startSec, endSec);
        Path work = Files.createTempDirectory("ovcrop_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            Path out = work.resolve("out.mp4");
            runCropExtract(in, out, startSec, endSec, aspectRatio, cropRectJson);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    /**
     * 多段不连续时间区间依次截取后合并为一个 MP4(JSON:[{"start":1.2,"end":3.5},...])。
     */
    /** 历史重载:多段裁剪,不传像素裁剪框。 */
    public byte[] cropMultiSegments(MultipartFile file, String fileUrl, String segmentsJson, String aspectRatio) throws Exception
    {
        return cropMultiSegments(file, fileUrl, segmentsJson, aspectRatio, null);
    }

    /**
     * 多段裁剪;可选 cropRectJson 与单段接口含义相同。
     */
    public byte[] cropMultiSegments(MultipartFile file, String fileUrl, String segmentsJson, String aspectRatio, String cropRectJson) throws Exception
    {
        assertVideoSource(file, fileUrl);
        if (StringUtils.isEmpty(segmentsJson))
        {
            throw new ServiceException("多段裁剪参数为空");
        }
        JSONArray arr = JSON.parseArray(segmentsJson);
        if (arr == null || arr.isEmpty())
        {
            throw new ServiceException("请至少选择一段裁剪区间");
        }
        if (arr.size() > CROP_MAX_SEGMENTS)
        {
            throw new ServiceException("最多支持 " + CROP_MAX_SEGMENTS + " 个片段");
        }
        List<double[]> segments = new ArrayList<>();
        for (int i = 0; i < arr.size(); i++)
        {
            JSONObject o = arr.getJSONObject(i);
            double s = o.getDoubleValue("start");
            double e = o.getDoubleValue("end");
            validateCropRange(s, e);
            segments.add(new double[] { s, e });
        }
        segments.sort(Comparator.comparingDouble(a -> a[0]));
        Path work = Files.createTempDirectory("ovcropm_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            List<Path> parts = new ArrayList<>();
            for (int i = 0; i < segments.size(); i++)
            {
                double s = segments.get(i)[0];
                double e = segments.get(i)[1];
                Path part = work.resolve("part_" + i + ".mp4");
                runCropExtract(in, part, s, e, aspectRatio, cropRectJson);
                parts.add(part);
            }
            if (parts.size() == 1)
            {
                return Files.readAllBytes(parts.get(0));
            }
            Path listFile = work.resolve("concat.txt");
            StringBuilder sb = new StringBuilder();
            for (Path p : parts)
            {
                sb.append("file '").append(escapeConcatPath(p)).append("'\n");
            }
            Files.write(listFile, sb.toString().getBytes(StandardCharsets.UTF_8));
            Path merged = work.resolve("merged.mp4");
            List<String> concatCmd = new ArrayList<>();
            concatCmd.add(ffmpegBin());
            concatCmd.add("-hide_banner");
            concatCmd.add("-y");
            concatCmd.add("-f");
            concatCmd.add("concat");
            concatCmd.add("-safe");
            concatCmd.add("0");
            concatCmd.add("-i");
            concatCmd.add(listFile.toString());
            concatCmd.add("-c");
            concatCmd.add("copy");
            concatCmd.add(merged.toString());
            int timeout = FFMPEG_TIMEOUT_SECONDS * Math.min(12, 2 + parts.size());
            runFfmpeg(concatCmd, timeout);
            return Files.readAllBytes(merged);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    private static void validateCropRange(double startSec, double endSec)
    {
        if (endSec <= startSec || startSec < 0)
        {
            throw new ServiceException("裁剪时间范围无效");
        }
        double dur = endSec - startSec;
        if (dur > 7200)
        {
            throw new ServiceException("单次裁剪时长不能超过 2 小时");
        }
    }

    /**
     * 精确截取:-ss/-t 放在输入之后,利于关键帧对齐与毫秒级起止;可选画幅(cropRectJson 优先于 aspectRatio)。
     */
    private void runCropExtract(Path in, Path out, double startSec, double endSec, String aspectRatio, String cropRectJson) throws Exception
    {
        double dur = endSec - startSec;
        String vf = buildSpatialCropFilter(in, aspectRatio, cropRectJson);
        List<String> cmd = new ArrayList<>();
        cmd.add(ffmpegBin());
        cmd.add("-hide_banner");
        cmd.add("-y");
        cmd.add("-i");
        cmd.add(in.toString());
        cmd.add("-ss");
        cmd.add(String.format(Locale.ROOT, "%.6f", startSec));
        cmd.add("-t");
        cmd.add(String.format(Locale.ROOT, "%.6f", dur));
        if (StringUtils.isNotEmpty(vf))
        {
            cmd.add("-vf");
            cmd.add(vf);
        }
        appendH264VideoArgs(cmd, 23);
        appendAacAudioArgs(cmd, "128k");
        cmd.add("-movflags");
        cmd.add("+faststart");
        cmd.add(out.toString());
        runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);
    }

    /** 读取首路视频宽高;失败返回 null。 */
    private static int[] probeVideoWidthHeight(Path videoFile) throws Exception
    {
        List<String> cmd = Arrays.asList(
                ffprobeBin(), "-v", "error",
                "-select_streams", "v:0",
                "-show_entries", "stream=width,height",
                "-of", "csv=p=0",
                videoFile.toString());
        ProcessBuilder pb = new ProcessBuilder(cmd);
        pb.redirectErrorStream(true);
        Process p = pb.start();
        String out;
        try (InputStream stream = p.getInputStream())
        {
            out = new String(drain(stream), StandardCharsets.UTF_8).trim();
        }
        int code = p.waitFor();
        if (code != 0 || StringUtils.isEmpty(out))
        {
            return null;
        }
        String[] parts = out.split(",");
        if (parts.length < 2)
        {
            return null;
        }
        try
        {
            int w = Integer.parseInt(parts[0].trim());
            int h = Integer.parseInt(parts[1].trim());
            if (w <= 0 || h <= 0)
            {
                return null;
            }
            return new int[] { w, h };
        }
        catch (NumberFormatException e)
        {
            return null;
        }
    }

    /**
     * 空间裁剪:前端传入的像素矩形优先;否则按 aspectRatio 居中裁切。
     */
    private String buildSpatialCropFilter(Path in, String aspectRatio, String cropRectJson) throws Exception
    {
        if (StringUtils.isNotEmpty(cropRectJson))
        {
            String rectVf = buildRectCropFilter(in, cropRectJson);
            if (StringUtils.isNotEmpty(rectVf))
            {
                return rectVf;
            }
        }
        return buildAspectCropFilter(in, aspectRatio);
    }

    /**
     * 源视频像素坐标,FFmpeg crop=w:h:x:y;宽高为偶数以适配 yuv420p。
     */
    private static String buildRectCropFilter(Path in, String cropRectJson) throws Exception
    {
        JSONObject o = JSON.parseObject(cropRectJson);
        if (o == null || !o.containsKey("w") || !o.containsKey("h"))
        {
            return null;
        }
        int[] whSrc = probeVideoWidthHeight(in);
        if (whSrc == null)
        {
            throw new ServiceException("无法读取视频宽高");
        }
        int iw = whSrc[0];
        int ih = whSrc[1];
        int x = o.getIntValue("x");
        int y = o.getIntValue("y");
        int w = o.getIntValue("w");
        int h = o.getIntValue("h");
        if (w < 2 || h < 2)
        {
            throw new ServiceException("裁剪区域无效");
        }
        x = Math.max(0, Math.min(x, iw - 2));
        y = Math.max(0, Math.min(y, ih - 2));
        w = Math.max(2, Math.min(w, iw - x));
        h = Math.max(2, Math.min(h, ih - y));
        x -= x % 2;
        y -= y % 2;
        w -= w % 2;
        h -= h % 2;
        if (w < 2 || h < 2)
        {
            throw new ServiceException("裁剪区域过小");
        }
        return String.format(Locale.ROOT, "crop=%d:%d:%d:%d", w, h, x, y);
    }

    /**
     * 居中裁切到目标宽高比(不拉伸)。arW:arH 如 16:9。
     */
    private static String buildAspectCropFilter(Path in, String aspectRatio) throws Exception
    {
        if (StringUtils.isEmpty(aspectRatio))
        {
            return null;
        }
        String a = aspectRatio.trim().toLowerCase(Locale.ROOT);
        if ("original".equals(a) || "none".equals(a) || "0".equals(a))
        {
            return null;
        }
        double arW;
        double arH;
        switch (a)
        {
            case "16:9":
            case "16x9":
                arW = 16;
                arH = 9;
                break;
            case "9:16":
            case "9x16":
                arW = 9;
                arH = 16;
                break;
            case "4:3":
            case "4x3":
                arW = 4;
                arH = 3;
                break;
            case "1:1":
            case "1x1":
                arW = 1;
                arH = 1;
                break;
            default:
                return null;
        }
        int[] wh = probeVideoWidthHeight(in);
        if (wh == null)
        {
            return null;
        }
        int iw = wh[0];
        int ih = wh[1];
        double targetAr = arW / arH;
        double inAr = (double) iw / (double) ih;
        int cw;
        int ch;
        int x;
        int y;
        if (inAr > targetAr)
        {
            ch = ih;
            cw = (int) Math.round(ch * targetAr);
            cw = cw - cw % 2;
            cw = Math.max(2, Math.min(cw, iw));
            x = (iw - cw) / 2;
            x = x - x % 2;
            y = 0;
        }
        else
        {
            cw = iw;
            ch = (int) Math.round(cw / targetAr);
            ch = ch - ch % 2;
            ch = Math.max(2, Math.min(ch, ih));
            x = 0;
            y = (ih - ch) / 2;
            y = y - y % 2;
        }
        return String.format(Locale.ROOT, "crop=%d:%d:%d:%d", cw, ch, x, y);
    }

    /** 顺序合并多个视频为 MP4(统一重编码,避免流参数不一致失败)。 */
    /** 历史重载:仅本地文件输入。 */
    public byte[] merge(List<MultipartFile> files) throws Exception
    {
        return merge(files, null);
    }

    /** 顺序合并多个视频为 MP4(支持 multipart 文件或 fileUrl 列表)。 */
    public byte[] merge(List<MultipartFile> files, List<String> fileUrls) throws Exception
    {
        List<MultipartFile> safeFiles = files == null ? new ArrayList<>() : files;
        List<String> safeUrls = fileUrls == null ? new ArrayList<>() : fileUrls;
        if (safeFiles.isEmpty() && safeUrls.isEmpty())
        {
            throw new ServiceException("请至少上传一个视频文件");
        }
        if (!safeFiles.isEmpty())
        {
            assertVideoMultipartList(safeFiles);
        }
        if (!safeUrls.isEmpty())
        {
            if (safeUrls.size() > MERGE_MAX_FILES)
            {
                throw new ServiceException("最多合并 " + MERGE_MAX_FILES + " 个视频");
            }
            for (String u : safeUrls)
            {
                assertVideoSource(null, u);
            }
        }
        Path work = Files.createTempDirectory("ovm_" + UUID.randomUUID());
        try
        {
            List<Path> normalized = new ArrayList<>();
            int i = 0;
            for (MultipartFile f : safeFiles)
            {
                Path raw = prepareInputVideo(work, f, null, "raw_" + i + "_");
                Path norm = work.resolve("norm_" + i + ".mp4");
                List<String> normCmd = buildMergeNormalizeCmd(raw, norm);
                runFfmpeg(normCmd, FFMPEG_TIMEOUT_SECONDS);
                normalized.add(norm);
                i++;
            }
            for (String u : safeUrls)
            {
                Path raw = prepareInputVideo(work, null, u, "raw_" + i + "_");
                Path norm = work.resolve("norm_" + i + ".mp4");
                List<String> normCmd = buildMergeNormalizeCmd(raw, norm);
                runFfmpeg(normCmd, FFMPEG_TIMEOUT_SECONDS);
                normalized.add(norm);
                i++;
            }
            Path listFile = work.resolve("concat.txt");
            StringBuilder sb = new StringBuilder();
            for (Path p : normalized)
            {
                sb.append("file '").append(escapeConcatPath(p)).append("'\n");
            }
            Files.write(listFile, sb.toString().getBytes(StandardCharsets.UTF_8));
            Path merged = work.resolve("merged.mp4");
            List<String> concatCmd = Arrays.asList(
                    ffmpegBin(), "-hide_banner", "-y",
                    "-f", "concat", "-safe", "0",
                    "-i", listFile.toString(),
                    "-c", "copy",
                    merged.toString());
            runFfmpeg(concatCmd, FFMPEG_TIMEOUT_SECONDS);
            return Files.readAllBytes(merged);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    private static String escapeConcatPath(Path p)
    {
        return p.toAbsolutePath().toString().replace("'", "'\\''");
    }

    /** 提取音频:mp3 / wav / aac / m4a / flac / ogg */
    /** 历史重载:仅本地文件输入。 */
    public byte[] extractAudio(MultipartFile file, String audioFormat) throws Exception
    {
        return extractAudio(file, null, audioFormat);
    }

    public byte[] extractAudio(MultipartFile file, String fileUrl, String audioFormat) throws Exception
    {
        assertVideoSource(file, fileUrl);
        String af = audioFormat == null ? "mp3" : audioFormat.trim().toLowerCase(Locale.ROOT);
        Path work = Files.createTempDirectory("ovea_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            if (!hasAudioStream(in))
            {
                throw new ServiceException("该视频没有音轨,无法提取音频");
            }
            String ext;
            List<String> tail = new ArrayList<>();
            switch (af)
            {
                case "wav":
                    ext = "wav";
                    tail.addAll(Arrays.asList("-vn", "-c:a", "pcm_s16le"));
                    break;
                case "aac":
                case "m4a":
                    ext = "m4a".equals(af) ? "m4a" : "aac";
                    tail.addAll(Arrays.asList("-vn", "-c:a", "aac", "-b:a", "192k"));
                    break;
                case "flac":
                    ext = "flac";
                    tail.addAll(Arrays.asList("-vn", "-c:a", "flac"));
                    break;
                case "ogg":
                    ext = "ogg";
                    tail.addAll(Arrays.asList("-vn", "-c:a", "libvorbis", "-q:a", "5"));
                    break;
                case "mp3":
                default:
                    ext = "mp3";
                    tail.addAll(Arrays.asList("-vn", "-c:a", "libmp3lame", "-q:a", "4"));
                    break;
            }
            Path out = work.resolve("out." + ext);
            List<String> cmd = new ArrayList<>();
            cmd.add(ffmpegBin());
            cmd.add("-hide_banner");
            cmd.add("-y");
            cmd.add("-i");
            cmd.add(in.toString());
            cmd.addAll(tail);
            cmd.add(out.toString());
            runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    /** 去掉全部音轨,视频流尽量复制。 */
    /** 历史重载:仅本地文件输入。 */
    public byte[] mute(MultipartFile file) throws Exception
    {
        return mute(file, null);
    }

    public byte[] mute(MultipartFile file, String fileUrl) throws Exception
    {
        assertVideoSource(file, fileUrl);
        Path work = Files.createTempDirectory("ovmute_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            Path out = work.resolve("out.mp4");
            List<String> cmd = Arrays.asList(
                    ffmpegBin(), "-hide_banner", "-y",
                    "-i", in.toString(),
                    "-c:v", "copy",
                    "-an",
                    "-movflags", "+faststart",
                    out.toString());
            runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    /**
     * 旋转/翻转:transpose 取值与 ffmpeg transpose 滤镜一致(0~7),常用:1=顺时针90°,2=逆时针90°,3=180°。
     * 另支持 preset:cw90 / ccw90 / r180 / hflip / vflip(与 transpose 二选一,preset 优先)。
     */
    /** 历史重载:仅本地文件输入。 */
    public byte[] rotate(MultipartFile file, Integer transpose, String preset) throws Exception
    {
        return rotate(file, null, transpose, preset);
    }

    public byte[] rotate(MultipartFile file, String fileUrl, Integer transpose, String preset) throws Exception
    {
        assertVideoSource(file, fileUrl);
        String vf = resolveRotateVf(transpose, preset);
        Path work = Files.createTempDirectory("ovrot_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            Path out = work.resolve("out.mp4");
            List<String> cmd = new ArrayList<>();
            cmd.add(ffmpegBin());
            cmd.add("-hide_banner");
            cmd.add("-y");
            cmd.add("-i");
            cmd.add(in.toString());
            cmd.add("-vf");
            cmd.add(vf);
            appendH264VideoArgs(cmd, 20);
            cmd.add("-c:a");
            cmd.add("copy");
            cmd.add("-movflags");
            cmd.add("+faststart");
            cmd.add(out.toString());
            runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    private static String resolveRotateVf(Integer transpose, String preset)
    {
        if (StringUtils.isNotEmpty(preset))
        {
            String p = preset.trim().toLowerCase(Locale.ROOT);
            switch (p)
            {
                case "cw90":
                case "90cw":
                    return "transpose=1";
                case "ccw90":
                case "90ccw":
                    return "transpose=2";
                case "r180":
                case "180":
                    return "transpose=1,transpose=1";
                case "hflip":
                    return "hflip";
                case "vflip":
                    return "vflip";
                default:
                    break;
            }
        }
        if (transpose != null && transpose >= 0 && transpose <= 7)
        {
            return "transpose=" + transpose;
        }
        return "transpose=1";
    }

    private static int clampDrawtextFontSize(Integer fontSize)
    {
        int fs = fontSize == null ? 50 : fontSize;
        return Math.min(96, Math.max(8, fs));
    }

    private static int normalizeWatermarkOpacityPct(Integer opacity)
    {
        if (opacity == null)
        {
            return 85;
        }
        return Math.min(100, Math.max(0, opacity));
    }

    private static double clamp01(double v)
    {
        if (v < 0)
        {
            return 0;
        }
        if (v > 1)
        {
            return 1;
        }
        return v;
    }

    private static String buildDrawtextFontcolor(String colorHex, int opacityPct)
    {
        double a = opacityPct / 100.0;
        String hex = "FFFFFF";
        if (colorHex != null)
        {
            String c = colorHex.trim();
            if (c.startsWith("#"))
            {
                c = c.substring(1);
            }
            if (c.length() == 6 && c.matches("[0-9A-Fa-f]{6}"))
            {
                hex = c.toUpperCase(Locale.ROOT);
            }
        }
        return String.format(Locale.ROOT, "0x%s@%.3f", hex, a);
    }

    /**
     * 单段 {@code drawtext}(不显式传 angle:部分 Windows 自带/精简 FFmpeg 的 drawtext 无 {@code angle} 选项会报错)。
     */
    private static String oneDrawtextSegment(String fontFf, String textLiteral, int fontsize, String fontcolor,
            String xyPair)
    {
        StringBuilder sb = new StringBuilder();
        sb.append("drawtext=fontfile=").append(fontFf);
        sb.append(":text='").append(textLiteral).append("'");
        sb.append(":fontcolor=").append(fontcolor);
        sb.append(":fontsize=").append(fontsize);
        sb.append(":").append(xyPair);
        return sb.toString();
    }

    /**
     * 文字水印:单段 {@code drawtext},位置为画面归一化中心 (0~1),缺省为左上角约 12px 边距。
     */
    private static String buildWatermarkDrawtextVf(String fontFf, String textSanitized, int fontsize, String fontcolor,
            Double positionX, Double positionY)
    {
        String xy;
        if (positionX != null && positionY != null)
        {
            double px = clamp01(positionX);
            double py = clamp01(positionY);
            xy = String.format(Locale.ROOT, "x=w*%.8f-text_w/2:y=h*%.8f-text_h/2", px, py);
        }
        else
        {
            /* 与前端默认一致:左上约 12px(未传 position 时) */
            xy = "x=12:y=12";
        }
        return oneDrawtextSegment(fontFf, textSanitized, fontsize, fontcolor, xy);
    }

    private static int[] parseHexColorRgb(String colorHex)
    {
        int r = 255;
        int g = 255;
        int b = 255;
        if (colorHex != null)
        {
            String c = colorHex.trim();
            if (c.startsWith("#"))
            {
                c = c.substring(1);
            }
            if (c.length() == 6 && c.matches("[0-9A-Fa-f]{6}"))
            {
                int v = Integer.parseInt(c, 16);
                r = (v >> 16) & 0xff;
                g = (v >> 8) & 0xff;
                b = v & 0xff;
            }
        }
        return new int[] { r, g, b };
    }

    /** 与字号大致成比例的 overlay 宽度占比,供栅格文字走 scale2ref。 */
    private static int textWatermarkScalePercent(int fontSize)
    {
        return Math.min(90, Math.max(12, (int) Math.round(fontSize * 1.25)));
    }

    /**
     * 将文字(含旋转、透明度)栅格为 PNG,再走与图片水印相同的 overlay;避免依赖 drawtext 的 angle 选项。
     */
    private static Path rasterizeTextWatermarkPng(Path work, Path fontFile, String text, int fontSize, String colorHex,
            int opacityPct, double rotationDeg) throws Exception
    {
        int[] rgb = parseHexColorRgb(colorHex);
        float alpha = Math.min(1f, Math.max(0f, opacityPct / 100f));
        Font font;
        try (InputStream fin = Files.newInputStream(fontFile))
        {
            font = Font.createFont(Font.TRUETYPE_FONT, fin).deriveFont(Font.PLAIN, (float) fontSize);
        }
        BufferedImage measure = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
        FontMetrics fm;
        Graphics2D gm = measure.createGraphics();
        try
        {
            gm.setFont(font);
            fm = gm.getFontMetrics();
        }
        finally
        {
            gm.dispose();
        }
        int sw = Math.max(1, fm.stringWidth(text));
        int sh = Math.max(1, fm.getHeight());
        double rad = Math.toRadians(rotationDeg);
        double rw = Math.abs(sw * Math.cos(rad)) + Math.abs(sh * Math.sin(rad));
        double rh = Math.abs(sw * Math.sin(rad)) + Math.abs(sh * Math.cos(rad));
        int pad = 6;
        int iw = (int) Math.ceil(rw) + pad * 2;
        int ih = (int) Math.ceil(rh) + pad * 2;
        iw = Math.max(8, iw);
        ih = Math.max(8, ih);

        BufferedImage img = new BufferedImage(iw, ih, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g2 = img.createGraphics();
        try
        {
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
            g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha));
            g2.setColor(new Color(rgb[0], rgb[1], rgb[2]));
            g2.translate(iw / 2.0, ih / 2.0);
            g2.rotate(rad);
            g2.setFont(font);
            int baselineY = (int) Math.round(fm.getAscent() - sh / 2.0);
            g2.drawString(text, -sw / 2, baselineY);
        }
        finally
        {
            g2.dispose();
        }
        Path out = work.resolve("textwm.png");
        if (!ImageIO.write(img, "png", out.toFile()))
        {
            throw new ServiceException("文字水印渲染失败");
        }
        return out;
    }

    /** 文字水印;文本中的单引号等会被剥离以避免滤镜注入。 */
    /** 历史重载:仅本地文件输入,使用默认水印参数。 */
    public byte[] watermarkText(MultipartFile file, String text) throws Exception
    {
        return watermarkText(file, null, text, null, null, null, null, null, null, null);
    }

    /** 历史重载:支持 file/fileUrl,使用默认水印参数。 */
    public byte[] watermarkText(MultipartFile file, String fileUrl, String text) throws Exception
    {
        return watermarkText(file, fileUrl, text, null, null, null, null, null, null, null);
    }

    /**
     * 文字水印:{@code drawtext} 滤镜 + 显式 {@code fontfile}(避免依赖 Fontconfig);参数与前端页对齐。
     */
    public byte[] watermarkText(MultipartFile file, String fileUrl, String text,
            Double positionX, Double positionY, Integer opacity, Integer fontSize,
            String color, Double rotation, String fontFamily) throws Exception
    {
        assertVideoSource(file, fileUrl);
        String t = text == null ? "" : text.trim();
        if (t.isEmpty())
        {
            t = "Watermark";
        }
        t = t.replace("'", "").replace(":", "").replace("%", "");
        Path work = Files.createTempDirectory("ovwm_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            Path out = work.resolve("out.mp4");
            Path fontPath = resolveDrawtextFontPath(fontFamily);
            if (fontPath == null)
            {
                throw new ServiceException(
                        "未找到可用于文字水印的字体。请安装系统字体,或在 JVM 启动参数中设置 -Doffice.video.drawtext.font=字体文件完整路径");
            }
            Path fontLocal = copyDrawtextFontIntoWork(work, fontPath);
            String fontFf = fontLocal.getFileName().toString();
            int fs = clampDrawtextFontSize(fontSize);
            int op = normalizeWatermarkOpacityPct(opacity);
            String fontcolor = buildDrawtextFontcolor(color, op);
            double rot = rotation == null ? 0.0 : rotation;
            List<String> cmd = new ArrayList<>();
            cmd.add(ffmpegBin());
            cmd.add("-hide_banner");
            cmd.add("-y");
            cmd.add("-i");
            cmd.add(in.getFileName().toString());
            if (Math.abs(rot) <= 1e-3)
            {
                String draw = buildWatermarkDrawtextVf(fontFf, t, fs, fontcolor, positionX, positionY);
                cmd.add("-vf");
                cmd.add(draw);
            }
            else
            {
                Path textPng = rasterizeTextWatermarkPng(work, fontLocal, t, fs, color, op, rot);
                cmd.add("-i");
                cmd.add(textPng.getFileName().toString());
                int sc = textWatermarkScalePercent(fs);
                cmd.add("-filter_complex");
                /* PNG 已含透明度,此处不再用 colorchannelmixer 降透明度(避免双重乘算) */
                cmd.add(buildImageWatermarkFilterComplex(positionX, positionY, sc, 100, 0.0));
            }
            cmd.add("-pix_fmt");
            cmd.add("yuv420p");
            appendH264VideoArgsAfterTextFilter(cmd, 22);
            cmd.add("-c:a");
            cmd.add("copy");
            cmd.add("-movflags");
            cmd.add("+faststart");
            cmd.add(out.getFileName().toString());
            runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS, work);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    private static int clampWatermarkImageScalePct(Integer p)
    {
        if (p == null)
        {
            return 50;
        }
        return Math.min(200, Math.max(10, p));
    }

    /**
     * 图片水印:{@code scale2ref} 按主画面宽度比例缩放水印图,{@code format=rgba}+{@code colorchannelmixer} 调透明度,
     * 可选 {@code rotate},最后用 {@code overlay} 叠到视频上(与文字水印同一套归一化中心坐标,缺省为左上角约 12px)。
     */
    public byte[] watermarkImage(MultipartFile file, String fileUrl,
            MultipartFile watermarkFile, String watermarkImageUrl,
            Double positionX, Double positionY, Integer opacity, Integer watermarkScalePct,
            Double rotation) throws Exception
    {
        assertVideoSource(file, fileUrl);
        assertWatermarkImageSource(watermarkFile, watermarkImageUrl);
        Path work = Files.createTempDirectory("ovwmi_" + UUID.randomUUID());
        try
        {
            Path videoIn = prepareInputVideo(work, file, fileUrl);
            Path wmIn = prepareWatermarkImage(work, watermarkFile, watermarkImageUrl);
            Path out = work.resolve("out.mp4");
            int op = normalizeWatermarkOpacityPct(opacity);
            int sc = clampWatermarkImageScalePct(watermarkScalePct);
            double rot = rotation == null ? 0.0 : rotation;
            String fc = buildImageWatermarkFilterComplex(positionX, positionY, sc, op, rot);
            List<String> cmd = new ArrayList<>();
            cmd.add(ffmpegBin());
            cmd.add("-hide_banner");
            cmd.add("-y");
            cmd.add("-i");
            cmd.add(videoIn.getFileName().toString());
            cmd.add("-i");
            cmd.add(wmIn.getFileName().toString());
            cmd.add("-filter_complex");
            cmd.add(fc);
            cmd.add("-pix_fmt");
            cmd.add("yuv420p");
            appendH264VideoArgsAfterTextFilter(cmd, 22);
            cmd.add("-c:a");
            cmd.add("copy");
            cmd.add("-movflags");
            cmd.add("+faststart");
            cmd.add(out.getFileName().toString());
            runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS, work);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    private void assertWatermarkImageSource(MultipartFile watermarkFile, String watermarkImageUrl)
    {
        if (watermarkFile != null && !watermarkFile.isEmpty())
        {
            if (watermarkFile.getSize() > MAX_WATERMARK_IMAGE_BYTES)
            {
                throw new ServiceException("水印图片不能超过 8MB");
            }
            return;
        }
        if (StringUtils.isEmpty(watermarkImageUrl) || watermarkImageUrl.trim().isEmpty())
        {
            throw new ServiceException("请上传水印图片或提供水印图链接");
        }
        String u = watermarkImageUrl.trim().toLowerCase(Locale.ROOT);
        if (!(u.startsWith("http://") || u.startsWith("https://")))
        {
            throw new ServiceException("水印图链接格式不正确");
        }
    }

    private static Path prepareWatermarkImage(Path work, MultipartFile watermarkFile, String watermarkImageUrl) throws Exception
    {
        if (watermarkFile != null && !watermarkFile.isEmpty())
        {
            Path in = work.resolve("wm" + extFromImageFilename(watermarkFile.getOriginalFilename()));
            Files.copy(watermarkFile.getInputStream(), in, StandardCopyOption.REPLACE_EXISTING);
            return in;
        }
        String url = watermarkImageUrl == null ? "" : watermarkImageUrl.trim();
        Path in = work.resolve("wm" + extFromImageUrl(url));
        fetchHttpToFileLimited(url, in, MAX_WATERMARK_IMAGE_BYTES);
        return in;
    }

    private static String extFromImageFilename(String name)
    {
        if (name == null || !name.contains("."))
        {
            return ".png";
        }
        String e = name.substring(name.lastIndexOf('.')).toLowerCase(Locale.ROOT);
        if (".png".equals(e) || ".jpg".equals(e) || ".jpeg".equals(e) || ".webp".equals(e) || ".gif".equals(e)
                || ".bmp".equals(e))
        {
            return e;
        }
        return ".png";
    }

    private static String extFromImageUrl(String url)
    {
        try
        {
            String path = new URL(url).getPath();
            if (path != null && path.contains("."))
            {
                String e = path.substring(path.lastIndexOf('.')).toLowerCase(Locale.ROOT);
                if (e.length() <= 8 && !e.contains("/"))
                {
                    return extFromImageFilename("f" + e);
                }
            }
        }
        catch (Exception ignored)
        {
        }
        return ".png";
    }

    private static void fetchHttpToFileLimited(String url, Path dest, long maxBytes) throws Exception
    {
        HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
        conn.setConnectTimeout(20000);
        conn.setReadTimeout(120000);
        conn.setInstanceFollowRedirects(true);
        conn.setRequestProperty("User-Agent", "OfficeClip-Watermark/1.0");
        int code = conn.getResponseCode();
        if (code < 200 || code >= 300)
        {
            throw new ServiceException("水印图片下载失败(HTTP " + code + ")");
        }
        long total = 0;
        try (InputStream in = conn.getInputStream(); OutputStream os = Files.newOutputStream(dest))
        {
            byte[] buf = new byte[16384];
            int n;
            while ((n = in.read(buf)) >= 0)
            {
                total += n;
                if (total > maxBytes)
                {
                    throw new ServiceException("水印图片不能超过 8MB");
                }
                os.write(buf, 0, n);
            }
        }
        catch (ServiceException e)
        {
            Files.deleteIfExists(dest);
            throw e;
        }
        if (!Files.exists(dest) || Files.size(dest) <= 0)
        {
            Files.deleteIfExists(dest);
            throw new ServiceException("水印图片下载失败");
        }
    }

    private static String buildImageWatermarkFilterComplex(Double positionX, Double positionY,
            int scalePct, int opacityPct, double rotationDeg)
    {
        double a = Math.min(1.0, Math.max(0.0, opacityPct / 100.0));
        String alpha = String.format(Locale.ROOT, "%.4f", a);
        // scale2ref:待缩放的水印为 [1:v],参考视频为 [0:v]。目标宽度 = 画面宽 × 比例;
        // 高度须用 main_h/main_w 显式写出:仅用 h=-1 时 FFmpeg 会算错高度/SAR,宽图会被压成「窄条」。
        String scaleW = "trunc(ref_w*" + scalePct + "/100)";
        String scaleH = "trunc(ref_w*" + scalePct + "/100*main_h/max(1\\,main_w))";
        StringBuilder wm = new StringBuilder();
        wm.append("[wm0]format=rgba");
        if (Math.abs(rotationDeg) > 1e-4)
        {
            wm.append(",rotate=angle=PI*").append(String.format(Locale.ROOT, "%.6f", rotationDeg))
                    .append("/180:fillcolor=0x00000000");
        }
        wm.append(",colorchannelmixer=aa=").append(alpha).append("[wm1]");
        String overlay;
        if (positionX != null && positionY != null)
        {
            double px = clamp01(positionX);
            double py = clamp01(positionY);
            overlay = String.format(Locale.ROOT, "x='W*%.8f-w/2':y='H*%.8f-h/2'", px, py);
        }
        else
        {
            overlay = "x=12:y=12";
        }
        return "[1:v][0:v]scale2ref=w='" + scaleW + "':h='" + scaleH + "'[wm0][bg];" + wm + ";[bg][wm1]overlay=" + overlay;
    }

    /** 音量:volumeFactor 如 1.5 表示 150%(0.1~5)。 */
    /** 历史重载:仅本地文件输入。 */
    public byte[] volume(MultipartFile file, double volumeFactor) throws Exception
    {
        return volume(file, null, volumeFactor);
    }

    public byte[] volume(MultipartFile file, String fileUrl, double volumeFactor) throws Exception
    {
        assertVideoSource(file, fileUrl);
        if (volumeFactor < 0.1 || volumeFactor > 5.0)
        {
            throw new ServiceException("音量系数需在 0.1~5 之间");
        }
        Path work = Files.createTempDirectory("ovvol_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            Path out = work.resolve("out.mp4");
            if (!hasAudioStream(in))
            {
                throw new ServiceException("该视频没有音轨,无法调节音量");
            }
            /*
             * 仅用 -af volume 时,部分环境下「放大」不明显或映射异常;改为对首条音轨显式 filter_complex + map,
             * 并强制 AAC 重编码,避免与 -c:v copy 组合时只降不升等问题。
             */
            String vol = String.format(Locale.ROOT, "%.6f", volumeFactor);
            String fc = "[0:a:0]volume=" + vol + "[aout]";
            List<String> cmd = Arrays.asList(
                    ffmpegBin(), "-hide_banner", "-y",
                    "-i", in.toString(),
                    "-filter_complex", fc,
                    "-map", "0:v:0",
                    "-map", "[aout]",
                    "-c:v", "copy",
                    "-c:a", "aac",
                    "-b:a", "192k",
                    "-movflags", "+faststart",
                    out.toString());
            runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    /** 倍速(0.25~4),变速同时调整音频(atempo 链)。 */
    /** 历史重载:仅本地文件输入。 */
    public byte[] speed(MultipartFile file, double speed) throws Exception
    {
        return speed(file, null, speed);
    }

    public byte[] speed(MultipartFile file, String fileUrl, double speed) throws Exception
    {
        assertVideoSource(file, fileUrl);
        if (speed < 0.25 || speed > 4.0)
        {
            throw new ServiceException("倍速需在 0.25~4 之间");
        }
        Path work = Files.createTempDirectory("ovsp_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            Path out = work.resolve("out.mp4");
            String setpts = String.format(Locale.ROOT, "setpts=%.6f*PTS", 1.0 / speed);
            List<String> cmd;
            if (hasAudioStream(in))
            {
                String atempoChain = buildAtempoChain(speed);
                String filter = String.format(Locale.ROOT, "[0:v]%s[v];[0:a]%s[a]", setpts, atempoChain);
                cmd = new ArrayList<>();
                cmd.add(ffmpegBin());
                cmd.add("-hide_banner");
                cmd.add("-y");
                cmd.add("-i");
                cmd.add(in.toString());
                cmd.add("-filter_complex");
                cmd.add(filter);
                cmd.add("-map");
                cmd.add("[v]");
                cmd.add("-map");
                cmd.add("[a]");
                appendH264VideoArgs(cmd, 23);
                appendAacAudioArgs(cmd, "192k");
                cmd.add("-movflags");
                cmd.add("+faststart");
                cmd.add(out.toString());
            }
            else
            {
                cmd = new ArrayList<>();
                cmd.add(ffmpegBin());
                cmd.add("-hide_banner");
                cmd.add("-y");
                cmd.add("-i");
                cmd.add(in.toString());
                cmd.add("-vf");
                cmd.add(setpts);
                cmd.add("-an");
                appendH264VideoArgs(cmd, 23);
                cmd.add("-movflags");
                cmd.add("+faststart");
                cmd.add(out.toString());
            }
            runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    /**
     * atempo 倍率与前端 speed 一致:1.5 即 1.5 倍速,不可用 1/speed(会做成减速)。
     * 单段 atempo 仅支持约 0.5~2,超出则链式相乘。
     */
    private static String buildAtempoChain(double speed)
    {
        List<String> parts = new ArrayList<>();
        double remain = speed;
        while (remain > 2.0 + 1e-6)
        {
            parts.add("atempo=2.0");
            remain /= 2.0;
        }
        while (remain < 0.5 - 1e-6)
        {
            parts.add("atempo=0.5");
            remain /= 0.5;
        }
        parts.add(String.format(Locale.ROOT, "atempo=%.6f", remain));
        return String.join(",", parts);
    }

    /** 自动去黑边:cropdetect 解析最后一行 crop= */
    /** 历史重载:仅本地文件输入。 */
    public byte[] removeLetterbox(MultipartFile file) throws Exception
    {
        return removeLetterbox(file, null);
    }

    public byte[] removeLetterbox(MultipartFile file, String fileUrl) throws Exception
    {
        assertVideoSource(file, fileUrl);
        Path work = Files.createTempDirectory("ovlb_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            Path out = work.resolve("out.mp4");
            List<String> detectCmd = Arrays.asList(
                    ffmpegBin(), "-hide_banner",
                    "-i", in.toString(),
                    "-vf", "cropdetect=limit=24:round=16:reset=0",
                    "-frames:v", "3",
                    "-f", "null", "-");
            String log = runFfmpegCapture(detectCmd, 30, null);
            String crop = parseLastCrop(log);
            if (crop == null)
            {
                throw new ServiceException("未能识别黑边区域,请换片段或稍后重试");
            }
            assertValidLetterboxCrop(in, crop);
            List<String> applyCmd = new ArrayList<>();
            applyCmd.add(ffmpegBin());
            applyCmd.add("-hide_banner");
            applyCmd.add("-y");
            applyCmd.add("-i");
            applyCmd.add(in.toString());
            applyCmd.add("-vf");
            applyCmd.add("crop=" + crop);
            appendH264VideoArgs(applyCmd, 22);
            applyCmd.add("-c:a");
            applyCmd.add("copy");
            applyCmd.add("-movflags");
            applyCmd.add("+faststart");
            applyCmd.add(out.toString());
            runFfmpeg(applyCmd, FFMPEG_TIMEOUT_SECONDS);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    private static String parseLastCrop(String log)
    {
        if (log == null)
        {
            return null;
        }
        String last = null;
        Matcher m = CROP_DETECT.matcher(log);
        while (m.find())
        {
            last = m.group(1) + ":" + m.group(2) + ":" + m.group(3) + ":" + m.group(4);
        }
        return last;
    }

    /**
     * cropdetect 在无黑边或极短样本时可能给出宽或高为 0 等非法值,需在调用 crop 前拦截并提示用户。
     */
    private static void assertValidLetterboxCrop(Path videoFile, String crop) throws Exception
    {
        String[] p = crop.split(":");
        if (p.length != 4)
        {
            throw new ServiceException("黑边识别结果异常,请换视频重试或使用「视频裁剪」手动处理。");
        }
        int cw;
        int ch;
        int cx;
        int cy;
        try
        {
            cw = Integer.parseInt(p[0].trim());
            ch = Integer.parseInt(p[1].trim());
            cx = Integer.parseInt(p[2].trim());
            cy = Integer.parseInt(p[3].trim());
        }
        catch (NumberFormatException e)
        {
            throw new ServiceException("黑边识别结果异常,请换视频重试或使用「视频裁剪」手动处理。");
        }
        if (cw <= 0 || ch <= 0)
        {
            throw new ServiceException("当前视频未检测到可裁剪的黑边(画面可能已无黑边或样本不足)。无需去黑边;若需裁画面请使用「视频裁剪」。");
        }
        int[] wh = probeVideoWidthHeight(videoFile);
        if (wh != null)
        {
            int vw = wh[0];
            int vh = wh[1];
            if (cx < 0 || cy < 0 || cx >= vw || cy >= vh)
            {
                throw new ServiceException("黑边识别结果与画面尺寸不匹配,请换视频重试或使用「视频裁剪」手动处理。");
            }
            if (cx + cw > vw + 8 || cy + ch > vh + 8)
            {
                throw new ServiceException("黑边识别结果与画面尺寸不匹配,请换视频重试或使用「视频裁剪」手动处理。");
            }
            boolean fullFrame = cw >= vw - 4 && ch >= vh - 4 && cx <= 4 && cy <= 4;
            if (fullFrame)
            {
                throw new ServiceException("未检测到明显上下黑边,画面已基本满屏,无需去黑边。若需手动裁剪请使用「视频裁剪」。");
            }
        }
    }

    /** 转 GIF:startSec、durationSec、fps、maxWidth */
    /** 历史重载:仅本地文件输入。 */
    public byte[] toGif(MultipartFile file, double startSec, double durationSec, int fps, int maxWidth) throws Exception
    {
        return toGif(file, null, startSec, durationSec, fps, maxWidth);
    }

    public byte[] toGif(MultipartFile file, String fileUrl, double startSec, double durationSec, int fps, int maxWidth) throws Exception
    {
        assertVideoSource(file, fileUrl);
        if (startSec < 0)
        {
            startSec = 0;
        }
        if (durationSec <= 0 || durationSec > 60)
        {
            throw new ServiceException("GIF 时长需在 0~60 秒之间");
        }
        if (fps < 5 || fps > 24)
        {
            fps = 12;
        }
        if (maxWidth < 160 || maxWidth > 1280)
        {
            maxWidth = 480;
        }
        Path work = Files.createTempDirectory("ovgif_" + UUID.randomUUID());
        try
        {
            Path in = prepareInputVideo(work, file, fileUrl);
            Path out = work.resolve("out.gif");
            String vf = String.format(Locale.ROOT, "fps=%d,scale=%d:-1:flags=lanczos", fps, maxWidth);
            List<String> cmd = Arrays.asList(
                    ffmpegBin(), "-hide_banner", "-y",
                    "-ss", String.format(Locale.ROOT, "%.3f", startSec),
                    "-t", String.format(Locale.ROOT, "%.3f", durationSec),
                    "-i", in.toString(),
                    "-vf", vf,
                    "-loop", "0",
                    out.toString());
            runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);
            return Files.readAllBytes(out);
        }
        finally
        {
            deleteTreeQuietly(work);
        }
    }

    private static String extFromFilename(String name)
    {
        if (name == null || !name.contains("."))
        {
            return ".mp4";
        }
        return name.substring(name.lastIndexOf('.')).toLowerCase(Locale.ROOT);
    }

    private static Path prepareInputVideo(Path work, MultipartFile file, String fileUrl) throws Exception
    {
        return prepareInputVideo(work, file, fileUrl, "in");
    }

    private static Path prepareInputVideo(Path work, MultipartFile file, String fileUrl, String namePrefix) throws Exception
    {
        if (file != null && !file.isEmpty())
        {
            Path in = work.resolve(namePrefix + extFromFilename(file.getOriginalFilename()));
            Files.copy(file.getInputStream(), in, StandardCopyOption.REPLACE_EXISTING);
            return in;
        }
        String u = fileUrl == null ? "" : fileUrl.trim();
        String ext = extFromUrl(u);
        Path in = work.resolve(namePrefix + ext);
        stageRemoteInputWithFfmpeg(u, in);
        return in;
    }

    private static String extFromUrl(String url)
    {
        try
        {
            URL u = new URL(url);
            String p = u.getPath();
            if (p == null || !p.contains("."))
            {
                return ".mp4";
            }
            String ext = p.substring(p.lastIndexOf('.')).toLowerCase(Locale.ROOT);
            if (".mp4".equals(ext) || ".mov".equals(ext) || ".mkv".equals(ext) || ".avi".equals(ext) || ".webm".equals(ext)
                    || ".wmv".equals(ext) || ".flv".equals(ext) || ".m4v".equals(ext) || ".mpg".equals(ext) || ".mpeg".equals(ext)
                    || ".3gp".equals(ext) || ".ogv".equals(ext) || ".f4v".equals(ext) || ".ts".equals(ext) || ".m2ts".equals(ext))
            {
                return ext;
            }
        }
        catch (Exception ignored)
        {
        }
        return ".mp4";
    }

    /**
     * 远程 URL 输入统一先落盘,启用 ffmpeg 断线重连参数,提升网络波动下的稳定性。
     */
    private static void stageRemoteInputWithFfmpeg(String fileUrl, Path target) throws Exception
    {
        List<String> cmd = new ArrayList<>();
        cmd.add(ffmpegBin());
        cmd.add("-hide_banner");
        cmd.add("-y");
        cmd.add("-reconnect");
        cmd.add("1");
        cmd.add("-reconnect_streamed");
        cmd.add("1");
        cmd.add("-reconnect_delay_max");
        cmd.add("10");
        cmd.add("-rw_timeout");
        cmd.add("30000000");
        cmd.add("-i");
        cmd.add(fileUrl);
        cmd.add("-map");
        cmd.add("0");
        cmd.add("-c");
        cmd.add("copy");
        cmd.add(target.toString());
        runFfmpeg(cmd, FFMPEG_TIMEOUT_SECONDS);

        if (!Files.exists(target) || Files.size(target) <= 0)
        {
            throw new ServiceException("视频链接下载失败,请确认链接可访问");
        }
        if (Files.size(target) > MAX_INPUT_BYTES)
        {
            throw new ServiceException("单个视频不能超过 100MB");
        }
    }

    /** 在默认工作目录执行 ffmpeg。 */
    private static void runFfmpeg(List<String> cmd, int timeoutSeconds) throws Exception
    {
        runFfmpeg(cmd, timeoutSeconds, null);
    }

    /** @param workingDir 非空时设为进程工作目录,便于 {@code -i}、{@code fontfile} 使用无盘符的相对路径(Windows 滤镜兼容) */
    private static void runFfmpeg(List<String> cmd, int timeoutSeconds, Path workingDir) throws Exception
    {
        runFfmpegCapture(cmd, timeoutSeconds, workingDir);
    }

    private static String runFfmpegCapture(List<String> cmd, int timeoutSeconds, Path workingDir) throws Exception
    {
        ProcessBuilder pb = new ProcessBuilder(cmd);
        if (workingDir != null)
        {
            pb.directory(workingDir.toFile());
        }
        pb.redirectErrorStream(true);
        Process p = pb.start();
        ExecutorService io = Executors.newSingleThreadExecutor();
        Future<byte[]> read = io.submit(() -> drain(p.getInputStream()));
        try
        {
            byte[] raw = read.get((long) timeoutSeconds, TimeUnit.SECONDS);
            long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(timeoutSeconds);
            while (p.isAlive())
            {
                if (System.currentTimeMillis() > deadline)
                {
                    p.destroyForcibly();
                    read.cancel(true);
                    throw new ServiceException("视频处理超时,请缩短时长或降低分辨率后重试");
                }
                try
                {
                    Thread.sleep(100);
                }
                catch (InterruptedException ie)
                {
                    Thread.currentThread().interrupt();
                    p.destroyForcibly();
                    read.cancel(true);
                    throw new ServiceException("处理被中断");
                }
            }
            int code = p.exitValue();
            String out = new String(raw, StandardCharsets.UTF_8);
            if (code != 0)
            {
                log.warn("ffmpeg exit {} cmd[0..3]={} {}", code, cmd.size() > 3 ? cmd.subList(0, 4) : cmd, abbreviateTail(out, 1200));
                throw new ServiceException("视频处理失败,请确认格式与参数后重试");
            }
            return out;
        }
        catch (TimeoutException e)
        {
            p.destroyForcibly();
            read.cancel(true);
            throw new ServiceException("视频处理超时,请稍后重试");
        }
        catch (InterruptedException e)
        {
            Thread.currentThread().interrupt();
            p.destroyForcibly();
            throw new ServiceException("处理被中断");
        }
        catch (ExecutionException e)
        {
            Throwable c = e.getCause() != null ? e.getCause() : e;
            throw new Exception(c);
        }
        finally
        {
            io.shutdownNow();
        }
    }

    /** 读取输入流全部字节。 */
    private static byte[] drain(InputStream in) throws IOException
    {
        ByteArrayOutputStream b = new ByteArrayOutputStream();
        byte[] buf = new byte[8192];
        int n;
        while ((n = in.read(buf)) != -1)
        {
            b.write(buf, 0, n);
        }
        return b.toByteArray();
    }

    /** 删除临时目录树,任何异常均吞掉,避免影响主流程。 */
    private static void deleteTreeQuietly(Path root)
    {
        try
        {
            if (Files.isDirectory(root))
            {
                try (Stream<Path> s = Files.walk(root))
                {
                    s.sorted((a, b) -> b.compareTo(a)).forEach(p -> {
                        try
                        {
                            Files.deleteIfExists(p);
                        }
                        catch (IOException ignored)
                        {
                        }
                    });
                }
            }
        }
        catch (Exception ignored)
        {
        }
    }

    /** 日志截断:仅保留尾部 max 字符,避免刷屏。 */
    private static String abbreviateTail(String s, int max)
    {
        if (s == null)
        {
            return "";
        }
        String t = s.replace("\r\n", "\n");
        return t.length() <= max ? t : "..." + t.substring(t.length() - max);
    }

    /** 判断视频是否包含至少一条音轨。 */
    private static boolean hasAudioStream(Path videoFile) throws Exception
    {
        List<String> cmd = Arrays.asList(
                ffprobeBin(), "-v", "error",
                "-select_streams", "a",
                "-show_entries", "stream=index",
                "-of", "csv=p=0",
                videoFile.toString());
        ProcessBuilder pb = new ProcessBuilder(cmd);
        pb.redirectErrorStream(true);
        Process p = pb.start();
        String out;
        try (InputStream in = p.getInputStream())
        {
            out = new String(drain(in), StandardCharsets.UTF_8).trim();
        }
        p.waitFor();
        return !out.isEmpty();
    }
}
相关推荐
欧阳天风1 小时前
electron播放本地音乐的问题
前端·javascript·electron
艾伦野鸽ggg1 小时前
CSS布局与动效知识梳理
前端·css
ljt27249606611 小时前
Vue笔记(二)--组件的属性和方法
前端·vue.js·笔记
Boop_wu1 小时前
[前端] CSS 常用样式(聊天界面 / 网页布局专用)
前端·css·css3
声声codeGrandMaster1 小时前
React框架的基础代码使用
前端·react.js·前端框架
叫我少年1 小时前
Vue 3 集成 Vue Router:从基础配置到项目实践
前端·路由
Highcharts.js1 小时前
Highcharts React 5.0 正式版:支持 ES 模块化、组件更精简、开发体验全面升级
前端·javascript·react.js·elasticsearch·前端框架·highcharts
大江东去浪淘尽千古风流人物1 小时前
【X-Restormer++】全天候图像恢复赛冠军方案:三项创新解析及对VIO/SLAM前端的工程价值
前端
LaughingZhu1 小时前
Claude Code 时代的写作:为什么 HTML 正在取代 Markdown
前端·人工智能·html