端午都要到了,你不会 还不会 视频分片上传吧?

844 阅读8分钟

我正在参加「初夏创意投稿大赛」详情请看:初夏创意投稿大赛

伴随着互联网的高速发展,大家对互联网的依赖程度越来越高,尤其是在看视频这一块尤其依赖。大家是否有在做一个视频网站或者做一个预览视频网站的时候,做好了,但是体验贼差, 播放视频一卡一卡,连贯性差。影响观看体验呢? 今天就让我来带大家揭秘如何处理大视频切片!

最终效果我放在了 哔哩网站上了 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 官网下载

image.png

2 设置环境变量

解压下载的文件,然后设置环境变量

image.png 检查是否安装成功 ffmpeg -version

image.png

3、开始实战编码演练

分析:演练之前我们先来说一说 为什么要分片 分片的好处。我们一般去视频网站上看视频的时候,是不是感觉加载很宽,播放也很流畅?身为技术人员,大多数可能认为,就是将视屏文件放到服务器上然后做一下nginx静态文件映射?或者用cdn 加速? 当我们实战的时候 如果视频小 可能还能正常播放,要是万一是 好几个G 的视频呢?这个时候 光是加载就很慢,现在用户的耐心越来越低,超过3秒 就不愿意等待。那你做出来的视频播放又有何意义?

解决方式:这个时候我们就有必要将视屏分割成N个小片段,粒度可以设置为3-5秒 一个片段.这样将一个大文件视频分开,然后用户在播放视频的时候 预加载,就会很流畅,当用户快进的时候 也是拉取计算后对应的片段。而且加载速度也是很快对的。

开始实战,我们使用SpringBoot 进行实战演练

  1. 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>
  1. 关键切片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]
  1. 切片工具类关键的方法
/**
     * 生成随机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;
    }
  1. 配置文件 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
  1. html 文件

4、总结

学无止境,有句话说得好:活到老学到老,知识无价!
最后祝大家身体健康,万事如意!