功能:根据指定的大小压缩视频
思路:视频文件比较大,我这里的实现是前端先直连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();
}
}