我正在参加「初夏创意投稿大赛」详情请看:初夏创意投稿大赛
伴随着互联网的高速发展,大家对互联网的依赖程度越来越高,尤其是在看视频这一块尤其依赖。大家是否有在做一个视频网站或者做一个预览视频网站的时候,做好了,但是体验贼差, 播放视频一卡一卡,连贯性差。影响观看体验呢? 今天就让我来带大家揭秘如何处理大视频切片!
最终效果我放在了 哔哩网站上了 Springboot+ffmpeg视频分片演示效果
下面我们就来讲一讲如何使用Springboot 实现视频分片上传吧
1、FFmpeg是什么?有命令?
- a、ffmpeg(命令行工具) 是一个快速的音视频转换工具
- b、ffmpeg 大致指令有:
参数 | 说明 |
---|---|
-version | 显示版本 |
-formats | 显示可用的格式(包括设备)。 |
-demuxers | 显示可用的demuxers。 |
-muxers | 显示可用的muxers。 |
-devices | 显示可用的设备。 |
-codecs | 显示libavcodec已知的所有编解码器。 |
-decoders | 显示可用的解码器。 |
-encoders | 显示所有可用的编码器。 |
-bsfs | 显示可用的比特流filter。 |
-protocols | 显示可用的协议。 |
-filters | 显示可用的libavfilter过滤器。 |
-pix_fmts | 显示可用的像素格式。 |
-sample_fmts | 显示可用的采样格式。 |
-layouts | 显示channel名称和标准channel布局。 |
-colors | 显示识别的颜色名称。 |
- c、主要参数说明 | 参数 | 说明| | --- | --- | | -f fmt(输入/输出) | 强制输入或输出文件格式。 格式通常是自动检测输入文件,并从输出文件的文件扩展名中猜测出来,所以在大多数情况下这个选项是不需要的。 | | -i url(输入) | 输入文件的网址 | | -y(全局参数) | 覆盖输出文件而不询问。 | | -n(全局参数) | 不要覆盖输出文件,如果指定的输出文件已经存在,请立即退出。 | | -c [:stream_specifier] codec(输入/输出,每个流)| 选择一个编码器(当在输出文件之前使用)或解码器(当在输入文件之前使用时)用于一个或多个流。codec 是解码器/编码器的名称或 copy(仅输出)以指示该流不被重新编码。如:ffmpeg -i INPUT -map 0 -c:v libx264 -c:a copy OUTPUT。| | -codec [:stream_specifier]编解码器(输入/输出,每个流) | 同 -c | | -t duration(输入/输出) | 当用作输入选项(在-i之前)时,限制从输入文件读取的数据的持续时间。当用作输出选项时(在输出url之前),在持续时间到达持续时间之后停止输出。 | | -ss位置(输入/输出) | 当用作输入选项时(在-i之前),在这个输入文件中寻找位置。 请注意,在大多数格式中,不可能精确搜索,因此ffmpeg将在位置之前寻找最近的搜索点。 当转码和-accurate_seek被启用时(默认),搜索点和位置之间的这个额外的分段将被解码和丢弃。 当进行流式复制或使用-noaccurate_seek时,它将被保留。当用作输出选项(在输出url之前)时,解码但丢弃输入,直到时间戳到达位置。 | | -frames [:stream_specifier] framecount(output,per-stream) | 停止在帧计数帧之后写入流。 | | -filter [:stream_specifier] filtergraph(output,per-stream) | 创建由filtergraph指定的过滤器图,并使用它来过滤流。filtergraph是应用于流的filtergraph的描述,并且必须具有相同类型的流的单个输入和单个输出。在过滤器图形中,输入与标签中的标签相关联,标签中的输出与标签相关联。有关filtergraph语法的更多信息,请参阅ffmpeg-filters手册。 |
- d、视频相关参数 | 参数 | 说明| | --- | --- | | -vframes num(输出) | 设置要输出的视频帧的数量。对于-frames:v,这是一个过时的别名,您应该使用它。 | | -r [:stream_specifier] fps(输入/输出,每个流) | 设置帧率(Hz值,分数或缩写)。作为输入选项,忽略存储在文件中的任何时间戳,根据速率生成新的时间戳。这与用于-framerate选项不同(它在FFmpeg的旧版本中使用的是相同的)。如果有疑问,请使用-framerate而不是输入选项-r。作为输出选项,复制或丢弃输入帧以实现恒定输出帧频fps。 | | -s [:stream_specifier]大小(输入/输出,每个流) | 设置窗口大小。作为输入选项,这是video_size专用选项的快捷方式,由某些分帧器识别,其帧尺寸未被存储在文件中。作为输出选项,这会将缩放视频过滤器插入到相应过滤器图形的末尾。请直接使用比例过滤器将其插入到开头或其他地方。格式是'wxh'(默认 - 与源相同)。 | | -aspect [:stream_specifier] 宽高比(输出,每个流) | 设置方面指定的视频显示宽高比。aspect可以是浮点数字符串,也可以是num:den形式的字符串,其中num和den是宽高比的分子和分母。例如“4:3”,“16:9”,“1.3333”和“1.7777”是有效的参数值。如果与-vcodec副本一起使用,则会影响存储在容器级别的宽高比,但不会影响存储在编码帧中的宽高比(如果存在)。 | | -vn(输出) | 禁用视频录制。 | | -vcodec编解码器(输出) | 设置视频编解码器。这是-codec:v的别名。 | | -vf filtergraph(输出) | 创建由filtergraph指定的过滤器图,并使用它来过滤流。 |
2 安装FFmpeg
1 官网下载
2 设置环境变量
解压下载的文件,然后设置环境变量
检查是否安装成功 ffmpeg -version
3、开始实战编码演练
分析:演练之前我们先来说一说 为什么要分片 分片的好处。我们一般去视频网站上看视频的时候,是不是感觉加载很宽,播放也很流畅?身为技术人员,大多数可能认为,就是将视屏文件放到服务器上然后做一下nginx静态文件映射?或者用cdn 加速? 当我们实战的时候 如果视频小 可能还能正常播放,要是万一是 好几个G 的视频呢?这个时候 光是加载就很慢,现在用户的耐心越来越低,超过3秒 就不愿意等待。那你做出来的视频播放又有何意义?
解决方式:这个时候我们就有必要将视屏分割成N个小片段,粒度可以设置为3-5秒 一个片段.这样将一个大文件视频分开,然后用户在播放视频的时候 预加载,就会很流畅,当用户快进的时候 也是拉取计算后对应的片段。而且加载速度也是很快对的。
开始实战,我们使用SpringBoot 进行实战演练
- pom FFmpeg依赖
<!-- javacv 和 ffmpeg的依赖包 -->
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv</artifactId>
<version>${javacv.version}</version>
<exclusions>
<exclusion>
<groupId>org.bytedeco</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg-platform</artifactId>
<version>${ffmpeg.version}</version>
</dependency>
- 关键切片TranscodeInfo实体类
private String poster = "00:00:00.001"; // 截取封面的时间 HH:mm:ss.[SSS]
private String tsSeconds = "2"; // ts分片大小,单位是秒
private String cutStart; // 视频裁剪,开始时间 HH:mm:ss.[SSS]
private String cutEnd; // 视频裁剪,结束时间 HH:mm:ss.[SSS]
- 切片工具类关键的方法
/**
* 生成随机16个字节的AESKEY
*
* @return
*/
private static byte[] genAesKey() {
try {
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(128);
return keyGenerator.generateKey().getEncoded();
} catch (NoSuchAlgorithmException e) {
return null;
}
}
/**
* 在指定的目录下生成key_info, key文件,返回key_info文件
*
* @param folder
* @throws IOException
*/
private static Path genKeyInfo(String folder) throws IOException {
// AES 密钥
byte[] aesKey = genAesKey();
// AES 向量
String iv = Hex.encodeHexString(genAesKey());
// key 文件写入
Path keyFile = Paths.get(folder, "key");
Files.write(keyFile, aesKey, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
// key_info 文件写入
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("key").append(LINE_SEPARATOR); // m3u8加载key文件网络路径
stringBuilder.append(keyFile.toString()).append(LINE_SEPARATOR); // FFmeg加载key_info文件路径
stringBuilder.append(iv); // ASE 向量
Path keyInfo = Paths.get(folder, "key_info");
Files.write(keyInfo, stringBuilder.toString().getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
return keyInfo;
}
/**
* 指定的目录下生成 master index.m3u8 文件
*
* @param file master m3u8文件地址
* @param indexPath 访问子index.m3u8的路径
* @param bandWidth 流码率
* @throws IOException
*/
private static void genIndex(String file, String indexPath, String bandWidth) throws IOException {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("#EXTM3U").append(LINE_SEPARATOR);
stringBuilder.append("#EXT-X-STREAM-INF:BANDWIDTH=" + bandWidth).append(LINE_SEPARATOR); // 码率
stringBuilder.append(indexPath);
Files.write(Paths.get(file), stringBuilder.toString().getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
}
/**
* 转码视频为m3u8
*
* @param source 源视频
* @param destFolder 目标文件夹
* @param config 配置信息
* @throws IOException
* @throws InterruptedException
*/
public static void transcodeToM3u8(String source, String destFolder, TranscodeInfo config) throws IOException, InterruptedException {
// 判断源视频是否存在
if (!Files.exists(Paths.get(source))) {
throw new IllegalArgumentException("文件不存在:" + source);
}
// 创建工作目录
Path workDir = Paths.get(destFolder, TS);
Files.createDirectories(workDir);
// 在工作目录生成KeyInfo文件
Path keyInfo = genKeyInfo(workDir.toString());
// 构建命令
List<String> commands = new ArrayList<>();
commands.add("ffmpeg");
commands.add("-i");
commands.add(source); // 源文件
commands.add("-force_key_frames"); // 强制每1秒一个关键帧
commands.add("expr:gte(t,n_forced*1)"); // 强制每1秒一个关键帧
commands.add("-c:v");
commands.add("libx264"); // 视频编码为H264
commands.add("-c:a");
commands.add("copy"); // 音频直接copy
commands.add("-hls_key_info_file");
commands.add(keyInfo.toString()); // 指定密钥文件路径
commands.add("-hls_time");
commands.add(config.getTsSeconds()); // ts切片大小
commands.add("-hls_playlist_type");
commands.add("vod"); // 点播模式
commands.add("-hls_segment_filename");
commands.add("%06d.ts"); // ts切片文件名称
if (StringUtils.hasText(config.getCutStart())) {
commands.add("-ss");
commands.add(config.getCutStart()); // 开始时间
}
if (StringUtils.hasText(config.getCutEnd())) {
commands.add("-to");
commands.add(config.getCutEnd()); // 结束时间
}
commands.add("index.m3u8"); // 生成m3u8文件
// 构建进程
Process process = new ProcessBuilder()
.command(commands)
.directory(workDir.toFile())
.start();
// 读取进程标准输出
new Thread(() -> {
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line = null;
while ((line = bufferedReader.readLine()) != null) {
LOGGER.info(line);
}
} catch (IOException e) {
}
}).start();
// 读取进程异常输出
new Thread(() -> {
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line = null;
while ((line = bufferedReader.readLine()) != null) {
LOGGER.info(line);
}
} catch (IOException e) {
}
}).start();
// 阻塞直到任务结束
if (process.waitFor() != 0) {
throw new RuntimeException("视频切片异常");
}
// 切出封面
if (!screenShots(source, String.join(File.separator, destFolder, TS+"/poster.jpg"), config.getPoster())) {
throw new RuntimeException("封面截取异常");
}
// 获取视频信息
final MediaInfo[] mediaInfo = {getMediaInfo(source)};
if (mediaInfo[0] == null) {
throw new RuntimeException("获取媒体信息异常");
}
// 生成index.m3u8文件
genIndex(String.join(File.separator, destFolder, TS+"index.m3u8"), TS+"/index.m3u8", mediaInfo[0].getFormat().getBitRate());
// 删除keyInfo文件
Files.delete(keyInfo);
}
/**
* 获取视频文件的媒体信息
*
* @param source
* @return
* @throws IOException
* @throws InterruptedException
*/
public static MediaInfo getMediaInfo(String source) throws IOException, InterruptedException {
List<String> commands = new ArrayList<>();
commands.add("ffprobe");
commands.add("-i");
commands.add(source);
commands.add("-show_format");
commands.add("-show_streams");
commands.add("-print_format");
commands.add("json");
Process process = new ProcessBuilder(commands)
.start();
MediaInfo mediaInfo = null;
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
mediaInfo = new Gson().fromJson(bufferedReader, MediaInfo.class);
} catch (IOException e) {
e.printStackTrace();
}
if (process.waitFor() != 0) {
return null;
}
return mediaInfo;
}
/**
* 截取视频的指定时间帧,生成图片文件
*
* @param source 源文件
* @param file 图片文件
* @param time 截图时间 HH:mm:ss.[SSS]
* @throws IOException
* @throws InterruptedException
*/
public static boolean screenShots(String source, String file, String time) throws IOException, InterruptedException {
List<String> commands = new ArrayList<>();
commands.add("ffmpeg");
commands.add("-i");
commands.add(source);
commands.add("-ss");
commands.add(time);
commands.add("-y");
commands.add("-q:v");
commands.add("1");
commands.add("-frames:v");
commands.add("1");
commands.add("-f");
;
commands.add("image2");
commands.add(file);
Process process = new ProcessBuilder(commands)
.start();
// 读取进程标准输出
new Thread(() -> {
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line = null;
while ((line = bufferedReader.readLine()) != null) {
LOGGER.info(line);
}
} catch (IOException e) {
}
}).start();
// 读取进程异常输出
new Thread(() -> {
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line = null;
while ((line = bufferedReader.readLine()) != null) {
LOGGER.error(line);
}
} catch (IOException e) {
}
}).start();
return process.waitFor() == 0;
}
- 配置文件 file-path 记得改成自己电脑磁盘路劲哦
server:
port: 8080
# 存储转码视频的文件夹
video:
file-path: D:\springbootwork\springboot_ffmpeg\src\main\resources\ffmpegVideo
spring:
servlet:
multipart:
enabled: true
# 不限制文件大小
max-file-size: -1
# 不限制请求体大小
max-request-size: -1
# 临时IO目录
location: "${java.io.tmpdir}"
# 不延迟解析
resolve-lazily: false
# 超过1Mb,就IO到临时目录
file-size-threshold: 1MB
web:
resources:
static-locations:
- "classpath:/static/"
- "file:${video.file-path}" # 把视频文件夹目录,添加到静态资源目录列表
thymeleaf:
prefix: classpath:/templates/
suffix: .html
cache: false
- html 文件
4、总结
学无止境,有句话说得好:活到老学到老,知识无价!
最后祝大家身体健康,万事如意!