视频下载增加水印说明文档
该功能提供了一个 HTTP 接口用于视频下载,同时在视频中增加右下角的水印。主要流程如下:
客户端通过 URL 发起请求,示例如下:
http://localhost/video/download/water?path=/data/07/videos/1080p30/CombinedScene.mp4&text=videotutor.io&filename=什么三角函数.mp4
请求参数说明:
- path:视频文件存储的相对或绝对路径(本例中拼接了 “.” 作为根目录)。
- text:水印文字内容,将在视频右下角显示。
- filename:下载时客户端保存的视频文件名(通过设置
Content-Disposition
头实现)。
如果请求中包含水印文本,将先检查目标视频是否已经添加过相同文本的水印。
- 使用 MD5 值生成唯一标识。
- 若不存在,则调用
VideoWaterUtils.addWatermark
方法通过ffmpeg
执行水印添加操作,生成新的视频文件。
返回视频时支持 HTTP 的 Range 请求(即断点续传),如果包含 Range 信息,则只返回对应字节范围的内容。
- 同时设置响应头如
Accept-Ranges
、Content-Range
和Content-Disposition
(当 filename 参数不为空时)。 - 注意视频文件本身已经是压缩格式,故禁用 gzip 压缩以避免解码错误。
- 同时设置响应头如
下面是完整的代码及说明。
1. VideoWaterHandler 类
该类为 HTTP 请求处理类,主要负责接收客户端请求,根据请求参数对视频进行处理并返回响应结果。代码详细流程如下:
参数获取与校验
获取请求参数path
、text
和filename
。如果path
为空则直接返回提示信息,同时判断指定的视频文件是否存在,不存在则返回 404 状态码。获取文件后缀与内容类型
根据文件名解析出后缀,并使用工具类获取对应的 MIME 类型,使浏览器能够识别并正确处理返回数据。视频水印处理
如果请求中包含text
参数,则生成该文本的 MD5 值来构造输出文件名,使用VideoWaterUtils.addWatermark
方法调用外部ffmpeg
命令实现水印添加。分段读取支持断点续传
如果请求头中存在Range
参数,则使用RandomAccessFile
按照指定的字节范围读取文件内容,并设置Content-Range
及Accept-Ranges
响应头,状态码置为 206(Partial Content)。否则直接读出全部文件内容。设置下载文件名
无论在 Range 分支还是完整文件分支,如果传入了filename
参数,都设置Content-Disposition
头,令浏览器按照指定文件名保存文件。禁用 gzip 压缩
由于视频文件已经是压缩格式,启用 gzip 压缩可能导致浏览器解码异常,因此调用response.setHasGzipped(true)
。
完整代码如下:
package com.litongjava.linux.handler;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import com.litongjava.media.utils.VideoWaterUtils;
import com.litongjava.tio.boot.http.TioRequestContext;
import com.litongjava.tio.http.common.HttpRequest;
import com.litongjava.tio.http.common.HttpResponse;
import com.litongjava.tio.http.common.ResponseHeaderKey;
import com.litongjava.tio.http.server.util.CORSUtils;
import com.litongjava.tio.http.server.util.Resps;
import com.litongjava.tio.utils.crypto.Md5Utils;
import com.litongjava.tio.utils.http.ContentTypeUtils;
import com.litongjava.tio.utils.hutool.FileUtil;
import com.litongjava.tio.utils.hutool.FilenameUtils;
import com.litongjava.tio.utils.hutool.StrUtil;
public class VideoWaterHandler {
public HttpResponse index(HttpRequest request) {
HttpResponse response = TioRequestContext.getResponse();
CORSUtils.enableCORS(response);
String path = request.getString("path");
String text = request.getString("text");
String filename = request.getString("filename");
if (StrUtil.isBlank(path)) {
return response.setString("path can not be empty");
}
String targetFile = "." + path;
File file = new File(targetFile);
if (!file.exists()) {
response.setStatus(404);
return response;
}
String suffix = FilenameUtils.getSuffix(path);
String contentType = ContentTypeUtils.getContentType(suffix);
if (StrUtil.isNotBlank(text)) {
String md5 = Md5Utils.getMD5(text);
String subPath = FilenameUtils.getSubPath(targetFile);
String baseName = FilenameUtils.getBaseName(targetFile);
String outputFile = subPath + File.separator + baseName + "_" + md5 + "." + suffix;
file = new File(outputFile);
if (!file.exists()) {
try {
VideoWaterUtils.addWatermark(targetFile, outputFile, 48, text);
targetFile = outputFile;
} catch (IOException e) {
e.printStackTrace();
return response.setString(e.getMessage());
} catch (InterruptedException e) {
e.printStackTrace();
return response.setString(e.getMessage());
}
}
}
long fileLength = file.length();
// 检查是否存在 Range 头信息
String range = request.getHeader("Range");
if (range != null && range.startsWith("bytes=")) {
String rangeValue = range.substring("bytes=".length());
String[] parts = rangeValue.split("-");
try {
long start = parts[0].isEmpty() ? 0 : Long.parseLong(parts[0]);
long end = (parts.length > 1 && !parts[1].isEmpty()) ? Long.parseLong(parts[1]) : fileLength - 1;
if (start > end || end >= fileLength) {
response.setStatus(416);
return response;
}
long contentLength = end - start + 1;
byte[] data = new byte[(int) contentLength];
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
raf.seek(start);
raf.readFully(data);
}
// 设置响应头
response.setStatus(206); // Partial Content
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength);
response.setHeader("Accept-Ranges", "bytes");
response.setHeader(ResponseHeaderKey.Content_Length, String.valueOf(contentLength));
// 如果传入了 filename,则在响应头中指定下载文件名
if (StrUtil.isNotBlank(filename)) {
response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
}
Resps.bytesWithContentType(response, data, contentType);
} catch (Exception e) {
response.setStatus(416);
}
} else {
// 如果没有 Range 头,则直接返回整个文件
byte[] readBytes = FileUtil.readBytes(file);
response.setHeader("Accept-Ranges", "bytes");
if (StrUtil.isNotBlank(filename)) {
response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
}
Resps.bytesWithContentType(response, readBytes, contentType);
}
// 视频文件(如 mp4)本身已经是压缩格式,再进行 gzip 压缩可能会破坏文件格式,导致浏览器无法正确解码。
response.setHasGzipped(true);
return response;
}
}
代码说明
CORS 支持
调用CORSUtils.enableCORS(response)
后,使前端跨域请求得到支持。参数与文件检查
使用StrUtil.isBlank(path)
校验参数,并通过new File(targetFile)
检查目标文件是否存在。水印处理逻辑
当text
不为空时,利用 MD5 值与文件基本名称组合生成新的输出文件名。如果对应的水印视频文件尚不存在,则调用VideoWaterUtils.addWatermark
完成水印添加操作。若过程中产生异常,则返回错误信息。Range 分支处理
根据Range
请求头解析出起始与结束字节,使用RandomAccessFile
按指定范围读取字节,构造部分视频数据,设置相应响应头(例如Content-Range
和Content-Length
),并返回状态码 206。完整内容返回
若未检测到Range
请求,则通过工具方法一次性读取全部文件字节数据,并返回。同时也设置了Content-Disposition
用于指定下载文件名。禁止 gzip 压缩
使用response.setHasGzipped(true)
指定视频文件在传输过程中不做额外 gzip 压缩。
2. VideoWaterUtils 类
该工具类负责通过外部命令行工具 ffmpeg
为视频添加水印。关键实现点如下:
操作系统判断与字体选择
根据当前操作系统类型(Windows、macOS、Linux/Unix),选择对应的字体文件。不同操作系统中常见的字体文件路径不同,确保水印中文显示正常。构造 drawtext 过滤器
使用drawtext
过滤器向视频中添加文本水印,其参数解释如下:fontfile
:指定使用的字体文件路径。text
:水印文本(传入参数)。x=w-tw-10:y=h-th-10
:设置水印位置为视频右下角,并距离边缘 10 像素。fontsize
:文字字号。fontcolor
:字体颜色,此例设置为黄色。
构造 ffmpeg 命令参数
将以上参数拼接为 ffmpeg 命令的参数列表,命令中保留原音频数据(-codec:a copy
),并将视频输出到指定文件中。输出重定向
将 ffmpeg 的标准输出及错误输出分别重定向到ffmpeg_stdout.log
和ffmpeg_stderr.log
文件中,便于后续调试与日志查看。启动进程并等待完成
使用ProcessBuilder
启动 ffmpeg 进程并等待该进程结束,返回命令执行结果状态码。
完整代码如下:
package com.litongjava.media.utils;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class VideoWaterUtils {
/**
* 使用 ffmpeg 给视频添加右下角水印,并将标准输出和错误输出分别写入日志文件。
*
* @param inputFile 输入视频文件路径
* @param outputFile 输出视频文件路径
* @param fontSize 水印文字的字号
* @param watermarkText 水印文本
* @throws IOException 当执行命令时发生 I/O 错误
* @throws InterruptedException 当线程等待 ffmpeg 进程结束时被中断
*/
public static int addWatermark(String inputFile, String outputFile, int fontSize, String watermarkText) throws IOException, InterruptedException {
String osName = System.getProperty("os.name").toLowerCase();
String fontFile;
if (osName.contains("win")) {
fontFile = "C\\:/Windows/Fonts/simhei.ttf";
} else if (osName.contains("mac")) {
fontFile = "/Library/Fonts/Arial Unicode.ttf";
} else if (osName.contains("nix") || osName.contains("nux") || osName.contains("aix")) {
fontFile = "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc";
} else {
fontFile = "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc";
}
// 构造 drawtext 过滤器参数
// x=w-tw-10:y=h-th-10 表示让水印距离右下角各留10像素
String template = "drawtext=fontfile='%s':text='%s':x=w-tw-10:y=h-th-10:fontsize=%d:fontcolor=yellow";
String filterSpec = String.format(template, fontFile, watermarkText, fontSize);
// 构造 ffmpeg 命令参数列表
List<String> command = new ArrayList<>();
command.add("ffmpeg");
command.add("-i");
command.add(inputFile);
command.add("-vf");
command.add(filterSpec);
command.add("-codec:a");
command.add("copy");
command.add(outputFile);
System.out.println("cmd:" + String.join(" ", command));
ProcessBuilder pb = new ProcessBuilder(command);
// 设置将标准输出和错误输出分别重定向到文件
File stdoutFile = new File("ffmpeg_stdout.log");
File stderrFile = new File("ffmpeg_stderr.log");
pb.redirectOutput(ProcessBuilder.Redirect.to(stdoutFile));
pb.redirectError(ProcessBuilder.Redirect.to(stderrFile));
// 启动进程并等待完成
Process process = pb.start();
return process.waitFor();
}
}
代码说明
字体文件选择
根据系统平台自动选择对应字体的路径,确保水印中文和特殊字符能够正常显示。注:对于 Windows 平台使用的路径格式为
C\\:/Windows/Fonts/simhei.ttf
。drawtext 参数构造
通过String.format
方式构造 ffmpeg 的drawtext
滤镜参数,将用户传入的水印文本、字号、字体文件嵌入命令行参数中。日志输出重定向
利用ProcessBuilder
的redirectOutput
和redirectError
方法将 ffmpeg 的输出写入日志文件,这对于调试和排查问题非常有帮助。执行等待
使用process.waitFor()
阻塞等待外部进程执行完毕,并返回运行状态码。
3. 测试与使用说明
前置条件
- 服务器需预先安装
ffmpeg
命令行工具。 - 系统中需要包含所选字体,确保对应路径有效。如果在特定平台上字体路径有所不同,请修改
VideoWaterUtils.addWatermark
中的字体路径。
- 服务器需预先安装
接口测试
在浏览器或使用 HTTP 客户端(如 Postman)访问以下 URL 示例:http://localhost/video/download/water?path=/data/07/videos/1080p30/CombinedScene.mp4&text=videotutor.io&filename=什么三角函数.mp4
- 若指定的
text
参数存在,则服务器会先生成一个添加水印的视频文件(命名规则为{原文件名}_{水印文本MD5}.{后缀}
)。 - 返回的 HTTP 响应将设置
Content-Disposition
头,从而使得浏览器下载时默认文件名为“什么三角函数.mp4”。 - 同时支持 Range 请求,能够支持视频的断点续传播放。
- 若指定的
日志查看
运行过程中 ffmpeg 的标准输出和错误输出均被重定向到当前目录下的ffmpeg_stdout.log
与ffmpeg_stderr.log
文件,便于调试视频处理的过程。
4. 总结
该实现结合了 HTTP 请求处理和基于 ffmpeg 的视频后处理逻辑,实现了视频水印添加以及支持断点续传下载。文档中详细介绍了代码逻辑、关键参数设置及相关说明,开发人员可以根据具体需求进行调整和扩展。
以上即为完整的“视频下载增加水印”的实现文档和代码示例。