大文件切片上传、视频切片上传转 m3u8 播放

本贴最后更新于 1060 天前,其中的信息可能已经时移世改

一、故事

前不久干项目,涉及到在线学习,简单来说就是对文章、视频进行在线学习,这个时候问题出现了,就是在上传视频的时候,速度很是慢,除此之外,视频播放也是卡的鸭皮,然后就开始疯狂网上搜刮知识,最终解决方案如下。

二、解决方案

1、视频采用切片上传,通过调用后端切片上传接口进行上传

2、切片上传结束后通过合并切片接口进行合并成为完整的视频

3、调用 ffmpeg 工具进行视频转 m3u8 格式形成 ts 切片

4、ts 切片多线程上传至 MinIO or OSS

5、返回 m3u8 格式文件地址,前端集成播放器进行播放。

三、项目地址

码云:https://gitee.com/sirwsl/uploadWheel

GitHub: https://github.com/sirwsl/uploadWheel

四、demo 效果展示

image.png

五、实现过程

1、下载 ffmpeg

为了开发方便,建议下载 windows 和 linux 两个版本

地址:Download FFmpeg

💩 比较懒的也可以跳过,毕竟代码里面我已经弄好了 💩

image.png

PS:此处需要注意,由于网上说的基本都需要配置环境变量,所以此处需要注意,下载的文件,按上图方式选择自己适合的版本下载解压。不需要配置环境变量

2、开始构建 springBoot 程序

1)导入依赖:

关键依赖项

... 
        <ffmpeg.version>0.6.2</ffmpeg.version>
...
        <dependency>
            <groupId>net.bramp.ffmpeg</groupId>
            <artifactId>ffmpeg</artifactId>
            <version>${ffmpeg.version}</version>
        </dependency>

完整依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.wsl</groupId>
    <artifactId>uploadWheel</artifactId>
    <version>1</version>
    <name>uploadWheel</name>
    <description>minIO文件上传及切片demo</description>

    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.3.7.RELEASE</spring-boot.version>
        <ffmpeg.version>0.6.2</ffmpeg.version>
        <hutool.version>5.7.15</hutool.version>
        <aliyun-sdk-oss.version>3.13.2</aliyun-sdk-oss.version>
        <fastjson.version>1.2.72</fastjson.version>
        <minio.version>6.0.8</minio.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>${fastjson.version}</version>
        </dependency>

        <dependency>
            <groupId>com.aliyun.oss</groupId>
            <artifactId>aliyun-sdk-oss</artifactId>
            <version>${aliyun-sdk-oss.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>${minio.version}</version>
        </dependency>
        <dependency>
            <groupId>net.bramp.ffmpeg</groupId>
            <artifactId>ffmpeg</artifactId>
            <version>${ffmpeg.version}</version>
        </dependency>

        <!--hutool-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>
        <!--配置文件处理器-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <version>${spring-boot.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-test</artifactId>
        </dependency>

    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>com.wslhome.demo.Application</mainClass>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
        <resources>
            <resource>
                <directory>src/main/java</directory>
                <includes>
                    <include>**/*.properties</include>
                    <include>**/*.xml</include>
                    <include>**/*.yml</include>
                </includes>
                <!--是否替换资源中的属性-->
                <filtering>false</filtering>
            </resource>
            <resource>
                <directory>src/main/resources</directory>
                <includes>
                    <include>**/*.*</include>
                </includes>
                <!--是否替换资源中的属性-->
                <filtering>false</filtering>
            </resource>
        </resources>
    </build>

</project>

3、确定项目结构

为了开发方便,同时以后谁都能启动这项目,所以把 ffmpeg 的两个版本都丢进来。需要注意版本问题。

image.png

小事故:windows 的 ffmpeg 倒是容易搞到,毕竟就只需要两个 exe,问题是 linux 版本的时候出现了问题,因为是采用 docker 打包,所以我不想去什么配置什么环境变量、下载 ffmpeg 之类的。所以 ffmpeg 的 linux 版本选择很重要。

开始时候我直接打包后,发现这个 demo 是运行不了的,因为读取不到 exe 文件,之后尝试了去 linux 安装好后 copy 下来,发现也不行(因为姿势不对)所以下载时候版本很重要。

六、开始编码

1、编写配置文件

PS:1)minIo or OSS 配置文件,任选其一进行配置

2)thymeleaf 部分可以不要,此处为了展示 demo

server:
  port: 8080

minio:
  url: #http://
  access: #admin
  secret: #admin

#m3u8视频转换配置
m3u8:
  convertor:
    base-path: /file/m3u8/
    temp-path: /file/temp/
    big-path: /file/big/
    proxy: m3u8/

ali:
  oss:
    end-point:
    access-key-id:
    access-key-secret:
    bucket-name: websources
    url:
    ali-url:
    get-file-url: ${aliyun.oss.url}${aliyun.oss.fileDir}
    my-host-url:

# 应用名称
spring:
  application:
    name: minioDemo
  servlet:
    multipart:
      max-file-size: 2048MB
      max-request-size: 2048MB
  resources:
    static-locations: classpath:static/
  thymeleaf:
    cache: false
    check-template: true # 检查模板是否存在,然后再呈现
    check-template-location: true # 检查模板位置是否正确(默认值 :true )
    enabled: true # 开启 MVC Thymeleaf 视图解析(默认值: true )
    encoding: UTF-8 # 模板编码
    excluded-view-names: # 要被排除在解析之外的视图名称列表,⽤逗号分隔
    mode: HTML5 # 要运⽤于模板之上的模板模式。另⻅ StandardTemplate-ModeHandlers( 默认值: HTML5)
    prefix: classpath:/templates/ # 在构建 URL 时添加到视图名称前的前缀(默认值: classpath:/templates/ )
    suffix: .html # 在构建 URL 时添加到视图名称后的后缀(默认值: .html )
    servlet:
      content-type: text/html #Content-Type 的值(默认值: text/html )


2、读取 ffmpeg

先写好 config 来读取 ffmpeg,因为我们没配置环境变量,所以这个很关键

package com.wslhome.demo.config;

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import net.bramp.ffmpeg.FFmpeg;
import net.bramp.ffmpeg.FFprobe;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * ffmpeg 工具路径设置
 * @Author sirwsl
 * @Version 1.0
 */
@Configuration
@Slf4j
public class FFmpegConfig {

    @SneakyThrows
    @Bean
    public FFmpeg fFmpeg() {
        String path = System.getProperty("user.dir");
        if (isLinux()){
            path+="ffmpeg/ffmpeg";
        }else if (isWindows()){
            path+="/ffmpeg-win/ffmpeg/bin/ffmpeg.exe";
        }
        log.info("ffmpeg.exe 路径为{}",path);
        return new FFmpeg(path);
    }

    @SneakyThrows
    @Bean
    public FFprobe fFprobe() {
        String path = System.getProperty("user.dir");
        if (isLinux()){
            path+="ffmpeg/ffprobe";
        }else if (isWindows()){
            path+="/ffmpeg-win/ffmpeg/bin/ffprobe.exe";
        }
        log.info("ffprobe.exe 路径为{}",path);
        return new FFprobe(path);
    }
    public static boolean isLinux() {
        return System.getProperty("os.name").toLowerCase().contains("linux");
    }

    public static boolean isWindows() {
        return System.getProperty("os.name").toLowerCase().contains("windows");
    }


}

3、编写存储路径配置文件

因为上传切片、合并文件、m3u8 转码等会涉及到较多的临时文件,所以得对这些乱七八糟的文件夹进行管理

package com.wslhome.demo.config;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * @Author wsl
 * @Version 1.0
 */
@Data
@Component
@ConfigurationProperties(prefix = "m3u8.convertor")
public class FilePath {
    /**
    * 文件上传临时路径 (本地文件转换不需要)
    */
    private String tempPath = "/file/tmp/";

    /**
     * m3u8文件转换后,储存的根路径
     */
    private String basePath = "/file/m3u8/";

    /**
     * m3u8文件转换后,储存的根路径
     */
    private String bigPath = "/file/big/";

    private String proxy = "m3u8/";
}

3、来个线程池,管理多线程上传

package com.wslhome.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

/**
 * 线程池配置
 *
 * @author sirwsl
 * @date 2022/01/11-11:14
 **/
@Configuration
@EnableAsync
public class SpringAsyncConfig {
    /**
     * 线程池参数根据minIO设置,如果开启线程太多会被MinIO拒绝
     * @return :
     */
    @Bean("minIOUploadTreadPool")
    public ThreadPoolTaskExecutor  asyncServiceExecutorForMinIo() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 设置核心线程数,采用IO密集 h/(1-拥塞)
        executor.setCorePoolSize(6);
        // 设置最大线程数,由于minIO连接数量有限,此处尽力设计大点
        executor.setMaxPoolSize(500);
        // 设置线程活跃时间(秒)
        executor.setKeepAliveSeconds(30);
        // 设置默认线程名称
        executor.setThreadNamePrefix("minio-upload-task-");
        // 等待所有任务结束后再关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        //执行初始化
        executor.initialize();
        return executor;
    }

    /**
     * oss async
     * @return
     */
    @Bean("ossUploadTreadPool")
    public ThreadPoolTaskExecutor  asyncServiceExecutorForOss() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 设置核心线程数,采用IO密集 h/(1-拥塞)
        executor.setCorePoolSize(8);
        // 设置最大线程数,由于minIO连接数量有限,此处尽力设计大点
        executor.setMaxPoolSize(120);
        // 设置线程活跃时间(秒)
        executor.setKeepAliveSeconds(30);
        // 设置默认线程名称
        executor.setThreadNamePrefix("ossUploadTask-");
        // 等待所有任务结束后再关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        //执行初始化
        executor.initialize();
        return executor;
    }

}

4、编写 minIO or OSS 的上传组件

这玩意以 OSS 为例,Google 一下,到处都是

1)阿里

package com.wslhome.demo.component;

import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.*;
import com.wslhome.demo.config.AliOssProperties;;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import java.io.*;
import java.net.URL;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * 阿里云 OSS 工具类
 *
 * @author caibenhao
 */
@Component
@Slf4j
@Getter
public class OssComponent{

    @Resource
    private AliOssProperties aliOssProperties;


    /* -----------------对外功能---------------- */

    /**
     * 本地文件切片上传
     *
     * @param objectName:文件名
     * @param path           : 本地完整路径,xxx/xxx.txt
     * @return :异常
     */
    public String uploadSlice(String objectName, String localPath,String path) throws IOException {
        OSS ossClient = new OSSClientBuilder().build(aliOssProperties.getEndpoint(), aliOssProperties.getAccessKeyId(), aliOssProperties.getAccessKeySecret());
        String keyPath = path+objectName;
        // 创建InitiateMultipartUploadRequest对象。
        InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(aliOssProperties.getBucketName(), keyPath);
        // 如果需要在初始化分片时设置请求头,请参考以下示例代码。
        ObjectMetadata metadata = new ObjectMetadata();
        // 指定该Object的网页缓存行为。
        metadata.setCacheControl("no-cache");
        // 指定该Object被下载时的名称。
        metadata.setContentDisposition("attachment;filename=" + objectName);
        // 指定初始化分片上传时是否覆盖同名Object。此处设置为true,表示禁止覆盖同名Object。
        metadata.setHeader("x-oss-forbid-overwrite", "true");

        // 初始化分片。
        InitiateMultipartUploadResult result = ossClient.initiateMultipartUpload(request);
        // 返回uploadId,它是分片上传事件的唯一标识。您可以根据该uploadId发起相关的操作,例如取消分片上传、查询分片上传等。
        String uploadId = result.getUploadId();

        List<PartETag> partETags = new ArrayList<>();
        // 每个分片的大小,用于计算文件有多少个分片。单位为字节。
        final long partSize = 5 * 1024 * 1024L;   //1 MB。

        // 填写本地文件的完整路径。如果未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件。
        final File sampleFile = new File(localPath);
        long fileLength = sampleFile.length();
        int partCount = (int) (fileLength / partSize);
        if (fileLength % partSize != 0) {
            partCount++;
        }

        // 遍历分片上传。
        for (int i = 0; i < partCount; i++) {
            long startPos = i * partSize;
            long curPartSize = (i + 1 == partCount) ? (fileLength - startPos) : partSize;

            try (InputStream inStream = new FileInputStream(sampleFile)) {
                // 跳过已经上传的分片。
                long skip = inStream.skip(startPos);
                UploadPartRequest uploadPartRequest = new UploadPartRequest();
                uploadPartRequest.setBucketName(aliOssProperties.getBucketName());
                uploadPartRequest.setKey(keyPath);
                uploadPartRequest.setUploadId(uploadId);
                uploadPartRequest.setInputStream(inStream);
                // 设置分片大小。除了最后一个分片没有大小限制,其他的分片最小为100 KB。
                uploadPartRequest.setPartSize(curPartSize);
                // 设置分片号。每一个上传的分片都有一个分片号,取值范围是1~10000,如果超出此范围,OSS将返回InvalidArgument错误码。
                uploadPartRequest.setPartNumber(i + 1);
                // 每个分片不需要按顺序上传,甚至可以在不同客户端上传,OSS会按照分片号排序组成完整的文件。
                UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
                // 每次上传分片之后,OSS的返回结果包含PartETag。PartETag将被保存在partETags中。
                partETags.add(uploadPartResult.getPartETag());
            }catch (Exception e){
                log.error("OSS切片上传异常,e:{}",e.getMessage());
            }

        }

        // 创建CompleteMultipartUploadRequest对象。
        // 在执行完成分片上传操作时,需要提供所有有效的partETags。OSS收到提交的partETags后,会逐一验证每个分片的有效性。当所有的数据分片验证通过后,OSS将把这些分片组合成一个完整的文件。
        CompleteMultipartUploadRequest completeMultipartUploadRequest =
                new CompleteMultipartUploadRequest(aliOssProperties.getBucketName(),keyPath , uploadId, partETags);

        // 完成分片上传。
        CompleteMultipartUploadResult completeMultipartUploadResult = ossClient.completeMultipartUpload(completeMultipartUploadRequest);
        log.info(completeMultipartUploadResult.getETag());
        // 关闭OSSClient。
        ossClient.shutdown();
        return path+objectName;
    }

    /**
     * 单个文件上传
     *
     * @param file 文件
     * @return 返回完整URL地址
     */
    public String uploadFile(String fileDir, MultipartFile file) {
        String fileUrl = upload2Oss(fileDir, file);
        String str = getFileUrl(fileDir, fileUrl);
        return str.trim();
    }

    /**
     * 单个文件上传(指定文件名(带后缀))
     *
     * @param inputStream 文件
     * @param fileName    文件名(带后缀)
     * @return 返回完整URL地址
     */
    public String uploadFile(String fileDir, InputStream inputStream, String fileName) {
        try {
            this.uploadFile2Oss(fileDir, inputStream, fileName);
            String url = getFileUrl(fileDir, fileName);
            if (url != null && url.length() > 0) {
                return url;
            }
        } catch (Exception e) {
            throw new RuntimeException("获取路径失败");
        }
        return "";
    }

    /**
     * 多文件上传
     *
     * @param fileList 文件列表
     * @return 返回完整URL,逗号分隔
     */
    public String uploadFile(String fileDir, List<MultipartFile> fileList) {
        String fileUrl;
        String str;
        StringBuilder photoUrl = new StringBuilder();
        for (int i = 0; i < fileList.size(); i++) {
            fileUrl = upload2Oss(fileDir, fileList.get(i));
            str = getFileUrl(fileDir, fileUrl);
            if (i == 0) {
                photoUrl = new StringBuilder(str);
            } else {
                photoUrl.append(",").append(str);
            }
        }
        return photoUrl.toString().trim();
    }

    public boolean deleteFile(String fileDir, String fileName) {
        OSS ossClient = new OSSClientBuilder().build(aliOssProperties.getEndpoint(), aliOssProperties.getAccessKeyId(), aliOssProperties.getAccessKeySecret());
        // 删除文件
        ossClient.deleteObject(aliOssProperties.getBucketName(), fileDir + fileName);
        // 判断文件是否存在
        boolean found = ossClient.doesObjectExist(aliOssProperties.getBucketName(), fileDir + fileName);
        // 如果文件存在则删除失败

        return !found;
    }

    /**
     * 通过文件名获取文完整件路径
     *
     * @param fileUrl 文件名
     * @return 完整URL路径
     */
    public String getFileUrl(String fileDir, String fileUrl) {
        if (fileUrl != null && fileUrl.length() > 0) {
            String[] split = fileUrl.replaceAll("\\\\","/").split("/");
            String url = aliOssProperties.getMyHostUrl() + fileDir + split[split.length - 1];
            return Objects.requireNonNull(url);
        }
        return null;
    }

    public File getFile(String url) {
        //对本地文件命名
        String fileName = url.substring(url.lastIndexOf("."));
        File file = null;
        try {
            file = File.createTempFile("net_url", fileName);
        } catch (Exception e) {
            log.error("创建默认文件夹net_url失败!原因e:{}", e.getMessage());
        }
        if (file != null) {
            try (InputStream inStream = new URL(url).openStream();
                 OutputStream os = new FileOutputStream(file)) {
                int bytesRead;
                byte[] buffer = new byte[8192];
                while ((bytesRead = inStream.read(buffer, 0, 8192)) != -1) {
                    os.write(buffer, 0, bytesRead);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return file;
    }

    /* -----------内部辅助功能------------------------ */

    /**
     * 获取去掉参数的完整路径
     *
     * @param url URL
     * @return 去掉参数的URL
     */
    private String getShortUrl(String url) {
        String[] imgUrls = url.split("\\?");
        return imgUrls[0].trim();
    }

    /**
     * 获得url真实外网链接
     * 不提供使用,因为会产生公网OOS流量下行费用
     *
     * @param key 文件名
     * @return URL
     */
    @Deprecated
    private String getUrl(String key) {
        OSS ossClient = new OSSClientBuilder().build(aliOssProperties.getEndpoint(), aliOssProperties.getAccessKeyId(), aliOssProperties.getAccessKeySecret());
        // 设置URL过期时间为20年  3600l* 1000*24*365*20
        Date expiration = new Date(System.currentTimeMillis() + 3600L * 1000 * 24 * 365 * 20);
        URL url = ossClient.generatePresignedUrl(aliOssProperties.getBucketName(), key, expiration);
        if (url != null) {
            String replaceUrl = url.toString()
                    .replace(aliOssProperties.getAliUrl(), aliOssProperties.getUrl());
            return getShortUrl(replaceUrl);
        }
        ossClient.shutdown();
        return null;
    }

    /**
     * 上传文件
     *
     * @param file 文件
     * @return 文件名
     */
    private String upload2Oss(String fileDir, MultipartFile file) {
        // 2、重命名文件
        String fileName = Objects.requireNonNull(file.getOriginalFilename(), "文件名不能为空");
        // 文件后缀
        String suffix = fileName.substring(fileName.lastIndexOf(".")).toLowerCase(Locale.ENGLISH);
        String uuid = UUID.randomUUID().toString();
        String name = uuid + suffix;
        try {
            InputStream inputStream = file.getInputStream();
            this.uploadFile2Oss(fileDir, inputStream, name);
            return name;
        } catch (Exception e) {
            throw new RuntimeException("上传失败");
        }
    }

    /**
     * 上传文件(指定文件名)
     *
     * @param inputStream 输入流
     * @param fileName    文件名
     */
    private void uploadFile2Oss(String fileDir, InputStream inputStream, String fileName) {
        OSS ossClient = new OSSClientBuilder().build(aliOssProperties.getEndpoint(), aliOssProperties.getAccessKeyId(), aliOssProperties.getAccessKeySecret());
        String ret;
        try {
            //创建上传Object的Metadata
            ObjectMetadata objectMetadata = new ObjectMetadata();
            objectMetadata.setContentLength(inputStream.available());
            objectMetadata.setCacheControl("no-cache");
            objectMetadata.setHeader("Pragma", "no-cache");
            objectMetadata.setContentType(getContentType(fileName.substring(fileName.lastIndexOf("."))));
            objectMetadata.setContentDisposition("inline;filename=" + fileName);
            //上传文件
            PutObjectResult putResult = ossClient.putObject(aliOssProperties.getBucketName(), fileDir + fileName, inputStream, objectMetadata);
            ret = putResult.getETag();
            if (StringUtils.isEmpty(ret)) {
                log.error("上传失败,文件ETag为空");
            }
            ossClient.shutdown();
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        } finally {
            try {
                inputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }


    /**
     * 请求类型
     *
     * @param filenameExtension :
     * @return :
     */
    private static String getContentType(String filenameExtension) {
        if (FileNameSuffixEnum.BMP.getSuffix().equalsIgnoreCase(filenameExtension)) {
            return "image/bmp";
        }
        if (FileNameSuffixEnum.GIF.getSuffix().equalsIgnoreCase(filenameExtension)) {
            return "image/gif";
        }
        if (FileNameSuffixEnum.JPEG.getSuffix().equalsIgnoreCase(filenameExtension) ||
                FileNameSuffixEnum.JPG.getSuffix().equalsIgnoreCase(filenameExtension) ||
                FileNameSuffixEnum.PNG.getSuffix().equalsIgnoreCase(filenameExtension)) {
            return "image/jpeg";
        }
        if (FileNameSuffixEnum.HTML.getSuffix().equalsIgnoreCase(filenameExtension)) {
            return "text/html";
        }
        if (FileNameSuffixEnum.TXT.getSuffix().equalsIgnoreCase(filenameExtension)) {
            return "text/plain";
        }
        if (FileNameSuffixEnum.VSD.getSuffix().equalsIgnoreCase(filenameExtension)) {
            return "application/vnd.visio";
        }
        if (FileNameSuffixEnum.PPTX.getSuffix().equalsIgnoreCase(filenameExtension) ||
                FileNameSuffixEnum.PPT.getSuffix().equalsIgnoreCase(filenameExtension)) {
            return "application/vnd.ms-powerpoint";
        }
        if (FileNameSuffixEnum.DOCX.getSuffix().equalsIgnoreCase(filenameExtension) ||
                FileNameSuffixEnum.DOC.getSuffix().equalsIgnoreCase(filenameExtension)) {
            return "application/msword";
        }
        if (FileNameSuffixEnum.XML.getSuffix().equalsIgnoreCase(filenameExtension)) {
            return "text/xml";
        }
        if (FileNameSuffixEnum.PDF.getSuffix().equalsIgnoreCase(filenameExtension)) {
            return "application/pdf";
        }
        return "image/jpeg";
    }



}

@Getter
enum FileNameSuffixEnum {

    /**
     * 文件后缀名
     */
    BMP(".bmp", "bmp文件"),
    GIF(".gif", "gif文件"),
    JPEG(".jpeg", "jpeg文件"),
    JPG(".jpg", "jpg文件"),
    PNG(".png", "png文件"),
    HTML(".html", "HTML文件"),
    TXT(".txt", "txt文件"),
    VSD(".vsd", "vsd文件"),
    PPTX(".pptx", "PPTX文件"),
    DOCX(".docx", "DOCX文件"),
    PPT(".ppt", "PPT文件"),
    DOC(".doc", "DOC文件"),
    XML(".xml", "XML文件"),
    PDF(".pdf", "PDF文件");

    /**
     * 后缀名
     */
    private final String suffix;

    /**
     * 描述
     */
    private final String description;

    FileNameSuffixEnum(String suffix, String description) {
        this.suffix = suffix;
        this.description = description;
    }
}






5、重点:编写 m3u8 转码组件

package com.wslhome.demo.component;

import cn.hutool.core.io.FileUtil;

import com.wslhome.demo.config.FilePath;
import com.wslhome.demo.util.m3u8Util;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import net.bramp.ffmpeg.FFmpeg;
import net.bramp.ffmpeg.FFmpegExecutor;
import net.bramp.ffmpeg.FFprobe;
import net.bramp.ffmpeg.builder.FFmpegBuilder;
import net.bramp.ffmpeg.probe.FFmpegProbeResult;
import net.bramp.ffmpeg.probe.FFmpegStream;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import java.io.File;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

/**
 * @Author sirwsl
 * @Version 1.0
 */
@Slf4j
@Component
public class M3u8Component {

    @Resource
    private FFmpeg ffmpeg;

    @Resource
    private FFprobe ffprobe;

    @Resource
    private FilePath filePath;



    /**
     * 视频文件转 m3u8
     * 支持: .mp4 | .flv | .avi | .mov | .wmv | .wav
     * @param file 视频文件
     * @return 路径
     */
    public String mediaFileToM3u8(MultipartFile file){
        if (file.isEmpty()) {
            throw new RuntimeException("未发现文件");
        }
        log.info("开始解析视频");
        long start = System.currentTimeMillis();
        //临时目录创建
        String path = new File(System.getProperty("user.dir")).getAbsolutePath();
        String tempFilePath = path+ filePath.getTempPath();
        if (!FileUtil.exist(tempFilePath)) {
            FileUtil.mkdir(tempFilePath);
        }
        String filePathName = tempFilePath + file.getOriginalFilename();
        File dest = new File(filePathName);
        try {
            file.transferTo(dest);
        }catch (Exception e){
            log.error("视频转m3u8格式存在异常,异常原因e:{}",e.getMessage());
        }
        long end = System.currentTimeMillis();
        log.info("临时文件上传成功......耗时:{} ms", end - start);
        String m3u8FilePath = localFileToM3u8(filePathName);
        log.info("视频转换已完成 !");
        return m3u8FilePath;
    }

    /**
     * 本地媒体资源转换
     * @param filePathName : 文件路径
     * @return :
     */
    @SneakyThrows
    public String localFileToM3u8(String filePathName) {
        long startTime = System.currentTimeMillis();
        final FFmpegProbeResult probe = ffprobe.probe(filePathName);
        final List<FFmpegStream> streams = probe.getStreams().stream().filter(fFmpegStream -> fFmpegStream.codec_type != null).collect(Collectors.toList());
        final Optional<FFmpegStream> audioStream = streams.stream().filter(fFmpegStream -> FFmpegStream.CodecType.AUDIO.equals(fFmpegStream.codec_type)).findFirst();
        final Optional<FFmpegStream> videoStream = streams.stream().filter(fFmpegStream -> FFmpegStream.CodecType.VIDEO.equals(fFmpegStream.codec_type)).findFirst();

        if (!audioStream.isPresent()) {
            log.error("未发现音频流");
        }
        if (!videoStream.isPresent()) {
            log.error("未发现视频流");
        }
        //m3u8文件 存储路径
        String filePath = m3u8Util.generateFilePath(this.filePath.getBasePath());
        if (!FileUtil.exist(filePath)) {
            FileUtil.mkdir(filePath);
        }
        String mainName = m3u8Util.getFileMainName(filePathName);
        String m3u8FileName = filePath + mainName + ".m3u8";

        //下面这一串参数别乱动,经过调优的,1G视频大概需要10秒左右,如果是大佬随意改
        //"-vsync", "2", "-c:v", "copy", "-c:a", "copy", "-tune", "fastdecode", "-hls_wrap", "0", "-hls_time", "10", "-hls_list_size", "0", "-threads", "12"
        FFmpegBuilder builder = new FFmpegBuilder()
                .setInput(filePathName)
                .overrideOutputFiles(true)
                .addOutput(m3u8FileName)//输出文件
                .setFormat(probe.getFormat().format_name) //"mp4"
                .setAudioBitRate(audioStream.map(fFmpegStream -> fFmpegStream.bit_rate).orElse(0L))
                .setAudioChannels(1)
                .setAudioCodec("aac")        // using the aac codec
                .setAudioSampleRate(audioStream.get().sample_rate)
                .setAudioBitRate(audioStream.get().bit_rate)
                .setStrict(FFmpegBuilder.Strict.STRICT)
                .setFormat("hls")
                .setPreset("ultrafast")
                .addExtraArgs("-vsync", "2", "-c:v", "copy", "-c:a", "copy", "-tune", "fastdecode", "-hls_wrap", "0", "-hls_time", "10", "-hls_list_size", "0", "-threads", "12")
                .done();

        FFmpegExecutor executor = new FFmpegExecutor(ffmpeg, ffprobe);
        // Run a one-pass encode
        executor.createJob(builder).run();

        File dest = new File(filePathName);
        if (dest.isFile() && dest.exists()) {
            dest.delete();
            System.gc();
            log.warn("临时文件 {}已删除", dest.getName());
        }
        long endTime = System.currentTimeMillis();
        log.info("文件:{} 转换完成!共耗时{} ms", dest.getName(), (endTime - startTime));
        return m3u8FileName;
    }

}

还有两个 Util 合并在下面了

m3u8Util.java

package com.wslhome.demo.util;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;

import java.io.*;
import java.time.LocalDateTime;

/**
 * @Description 工具类
 * @Author sirwsl
 * @Version 1.0
 */
public class m3u8Util {

    /**
    *@Description 根据基础路径,生成文件存储路径
    *@param basePath 基础路径(根路径)
    *@Return 
    */
    public static String generateFilePath(String basePath){
        String temp = basePath;
        if(StrUtil.isNotBlank(basePath)){
            if(basePath.endsWith("/")){
                temp = basePath.substring(0,basePath.lastIndexOf("/"));
            }
        }
        return temp+"/"+generateDateDir()+"/";
    }

    /**
     *@Description 根据当前时间,生成下级存储目录
     *@Return
     */
    public static String generateDateDir(){
        LocalDateTime now = LocalDateTime.now();
        return DateUtil.format(now, "yyyyMMdd/HH/mm/ss");
    }

    /**
     *@Description 根据文件全路径,获取文件主名称
     *@param fullPath 文件全路径(包含文件名)
     *@Return
     */
    public static String getFileMainName(String fullPath){
        String fileName = FileUtil.getName(fullPath);
        return fileName.substring(0,fileName.lastIndexOf("."));
    }


}

FileUtil.java

package com.wslhome.demo.util;

import java.io.*;

public class FileUtil {

    public static void deleteFiles(String path) {
        File file = new File(path);
        if (file.exists()) {
            if (file.isDirectory()) {
                File[] temp = file.listFiles(); //获取该文件夹下的所有文件
                for (File value : temp) {
                    deleteFile(value.getAbsolutePath());
                }
            } else {
                file.delete(); //删除子文件
            }
            file.delete(); //删除文件夹
        }
    }

    public static void deleteFile(String path){
        File dest = new File(path);
        if (dest.isFile() && dest.exists()) {
            dest.delete();
        }
    }

    public static void replaceTextContent(String path,String srcStr,String replaceStr) throws IOException {
        // 读
        File file = new File(path);
        FileReader in = new FileReader(file);
        BufferedReader bufIn = new BufferedReader(in);
        // 内存流, 作为临时流
        CharArrayWriter tempStream = new CharArrayWriter();
        // 替换
        String line = null;
        while ( (line = bufIn.readLine()) != null) {
            // 替换每行中, 符合条件的字符串
            line = line.replaceAll(srcStr, replaceStr);
            // 将该行写入内存
            tempStream.write(line);
            // 添加换行符
            tempStream.append(System.getProperty("line.separator"));
        }
        // 关闭 输入流
        bufIn.close();
        // 将内存中的流 写入 文件
        FileWriter out = new FileWriter(file);
        tempStream.writeTo(out);
        out.close();
        System.out.println("====path:"+path);

    }
}

6、重点 Service 编写

package com.wslhome.demo.service;

import cn.hutool.core.date.DateUtil;
import com.wslhome.demo.api.Result;
import com.wslhome.demo.component.M3u8Component;
import com.wslhome.demo.component.MinioComponent;
import com.wslhome.demo.component.OssComponent;
import com.wslhome.demo.config.AliOssProperties;
import com.wslhome.demo.config.FilePath;
import com.wslhome.demo.util.FileUtil;
import io.minio.ObjectStat;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.tomcat.util.http.fileupload.servlet.ServletFileUpload;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.CountDownLatch;

/**
 * 文件上传
 */
@Component
@Slf4j
public class FileService {

    @Resource
    private M3u8Component m3U8ComponentTemplate;

    @Resource
    private FilePath filePath;

    //@Resource(name = "minIOUploadTreadPool")
    @Resource(name = "ossUploadTreadPool")
    private ThreadPoolTaskExecutor poolTaskExecutor;

    @Resource
    private MinioComponent minioComponent;

    @Resource
    private AliOssProperties aliOssProperties;

    @Resource
    private OssComponent ossComponent;

    String projectUrl = System.getProperty("user.dir").replaceAll("\\\\", "/");


    /**
     * 视频上传并转m3u8,转存至oss或minIO
     *
     * @param file : 视频文件
     * @return 保存路径
     */
    public String uploadVideo2M3u8(MultipartFile file) throws Exception {
        String path = m3U8ComponentTemplate.mediaFileToM3u8(file);
        return upload2M3u8(path);
    }

    /**
     * 本地视频转m3u8后上传至OSS或minIO
     *
     * @param path
     * @return
     * @throws Exception
     */
    public String localVideo2M3u8(String path) throws Exception {
        String paths = m3U8ComponentTemplate.localFileToM3u8(path);
        return upload2M3u8(paths);
    }

    /**
     * 上传转码后得视频至OSS或minIOn
     * @param path
     * @return 路径
     * @throws Exception
     */
    public String upload2M3u8(String path) throws Exception {
        //存储转码后文件
        String realPath = path.substring(0, path.lastIndexOf("/"));
        log.info("视频解析后的 realPath {}", realPath);
        String name = path.substring(path.lastIndexOf("/") + 1);
        log.info("解析后视频 name {}", name);
        File allFile = new File(realPath);
        File[] files = allFile.listFiles();
        if (null == files || files.length == 0) {
            return null;
        }
        String patch = DateUtil.format(LocalDateTime.now(), "yyyy/MM/") + name.substring(0, name.lastIndexOf(".")) + "/";
        List<File> errorFile = new ArrayList<>();

        long start = System.currentTimeMillis();
        //替换m3u8文件中的路径
        FileUtil.replaceTextContent(path, name.substring(0, name.lastIndexOf(".")),
                aliOssProperties.getMyHostUrl() + filePath.getProxy() + patch +
                        name.substring(0, name.lastIndexOf(".")));
        //开始上传
        CountDownLatch countDownLatch = new CountDownLatch(files.length);
        Arrays.stream(files).forEach(li -> poolTaskExecutor.execute(() -> {
            try (FileInputStream fileInputStream = new FileInputStream(li)) {
                //minioComponent.FileUploaderExist("m3u8", patch + li.getName(), fileInputStream);
                ossComponent.uploadFile(filePath.getProxy() + patch, fileInputStream, li.getName());
                log.info("文件:{} 正在上传", li.getName());
            } catch (Exception e) {
                errorFile.add(li);
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        }));
        countDownLatch.await();
        long end = System.currentTimeMillis();
        log.info("解析文件上传成功,共计:{} 个文件,失败:{},共耗时: {}ms", files.length, errorFile.size(), end - start);
        //  try {
        //      minioComponent.mkBucket("m3u8");
        //  } catch (Exception e) {
        //      log.error("创建Bucket失败!");
        //  }

        //异步移除所有文件
        poolTaskExecutor.execute(() -> {
                FileUtil.deleteFile(projectUrl+filePath.getTempPath());
        });
        if (CollectionUtils.isEmpty(errorFile)) {
            return aliOssProperties.getMyHostUrl() + filePath.getProxy() + patch + name;
        }
        return "";
    }
    /**
     * 普通上传文件转存至Oss或MinIo
     *
     * @param file : 文件
     * @return : 文件路径
     */
    public String uploadFile(MultipartFile file) {
        //文件名字
        String fileName = file.getOriginalFilename();
        if (StringUtils.isBlank(fileName)) {
            return null;
        }
        String patch = "test/" +DateUtil.format(LocalDateTime.now(), "yyyy/MM/dd/");
        try {
            //String contentType = fileName.substring(fileName.lastIndexOf(".") + 1);
            //minioComponent.fileUploader(contentType, patch + fileName, file.getInputStream());
            ossComponent.uploadFile( patch, file.getInputStream(), fileName);
        } catch (Exception e) {
            log.error("文件上传失败");
            return "文件上传失败!";
        } finally {
            try {
                file.getInputStream().close();
            } catch (Exception e) {
                log.error("关闭文件流异常,异常原因e:{}", e.getMessage());
            }
        }
        return patch + fileName;
    }


    /**
     * 大文件上传至本地
     *
     * @param request :请求
     * @param guid    : 编码文件名
     * @param chunk   : 切片数
     * @param file    : 切片文件
     * @return : 是否成功
     */
    public boolean uploadSlice(HttpServletRequest request, String guid, Integer chunk, MultipartFile file) {
        try {


            boolean isMultipart = ServletFileUpload.isMultipartContent(request);
            if (isMultipart) {
                if (chunk == null) chunk = 0;
                // 临时目录用来存放所有分片文件
                String tempFileDir = projectUrl + filePath.getBigPath() + guid;
                File parentFileDir = new File(tempFileDir);
                if (!parentFileDir.exists()) {
                    parentFileDir.mkdirs();
                }
                // 分片处理时,前台会多次调用上传接口,每次都会上传文件的一部分到后台
                File tempPartFile = new File(parentFileDir, guid + "_" + chunk + ".part");
                FileUtils.copyInputStreamToFile(file.getInputStream(), tempPartFile);
            }
        } catch (Exception e) {
            return false;
        }
        return true;
    }

    /**
     * 合并切片并上传至服务器
     * @param guid :
     * @param fileName :
     * @return :
     */
    public String uploadMerge(String guid, String fileName){
        String localPath = mergeFile(guid, fileName);
        //此处需要注意,OSS需要再次切片上传,但minIO是不用得,它默认5M超过就会自动切片
        String path = "";
       if (StringUtils.isNotBlank(localPath)){
           try {
               path = ossComponent.uploadSlice(fileName, localPath,"file/bigfile/");
           }catch (Exception e){
               log.error("OSS切片上传失败!");
           }

       }
        //移除文件
        poolTaskExecutor.execute(() -> {
            FileUtil.deleteFile(projectUrl+filePath.getBigPath());
        });
        return path;
    }

    /**
     * 合并切片文件至本地
     *
     * @param guid     : 编码
     * @param fileName : 文件名
     * @return : 是否成功
     */
    public String mergeFile(String guid, String fileName) {
        try {
            String sName = fileName.substring(fileName.lastIndexOf("."));
            //时间格式化格式
            Date currentTime = new Date();
            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMddHHmmssSSS");
            //获取当前时间并作为时间戳
            String timeStamp = simpleDateFormat.format(currentTime);
            //拼接新的文件名
            String newName = timeStamp + sName;
            simpleDateFormat = new SimpleDateFormat("yyyyMM");
            String tempPath = projectUrl + filePath.getBigPath()+guid;
            String margePath = projectUrl + filePath.getBigPath()+simpleDateFormat.format(currentTime);
            File parentFileDir = new File(tempPath);
            if (parentFileDir.isDirectory()) {
                File destTempFile = new File(margePath, newName);
                if (!destTempFile.exists()) {
                    //先得到文件的上级目录,并创建上级目录,在创建文件
                    destTempFile.getParentFile().mkdir();
                    destTempFile.createNewFile();
                }
                for (int i = 0; i < Objects.requireNonNull(parentFileDir.listFiles()).length; i++) {
                    File partFile = new File(parentFileDir, guid + "_" + i + ".part");
                    FileOutputStream destTempfos = new FileOutputStream(destTempFile, true);
                    //遍历"所有分片文件"到"最终文件"中
                    FileUtils.copyFile(partFile, destTempfos);
                    destTempfos.close();
                }
                // 删除临时目录中的分片文件
                FileUtils.deleteDirectory(parentFileDir);
                return destTempFile.getAbsolutePath();
            }
        } catch (Exception e) {
            log.error("切片文件合并,失败原因e:{}", e.getMessage());
        }
        return null;

    }

    /**
     * minIO文件下载
     *
     * @param response : 相应
     * @param bucket   : bucket名称
     * @param fileName : 文件名
     * @throws Exception : 异常
     */
    public void downloadFileMinIO(HttpServletResponse response, String bucket, String fileName) throws Exception {
        ObjectStat objectStat = minioComponent.statObject(bucket, fileName);
        response.setContentType(objectStat.contentType());
        response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"));
        response.addHeader("Content-Length", String.valueOf(objectStat.length()));
        byte[] bytes = minioComponent.fileDownloader(bucket, fileName);
        OutputStream outputStream = response.getOutputStream();
        outputStream.write(bytes);
        outputStream.close();
    }


    /**
     * Oss文件下载
     *
     * @param response:相应
     * @param url         : 路径
     * @param fileName    : 文件名
     * @throws Exception : 异常
     */
    public void downloadFileOss(HttpServletResponse response, String url, String fileName) throws Exception {
        response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"));
        File file = ossComponent.getFile(url);
        OutputStream outputStream = response.getOutputStream();
        outputStream.write(new BufferedInputStream(new FileInputStream(file)).read());
        outputStream.close();
    }


    public String uploadVideoMerge(String guid, String fileName) {
        String localPath = mergeFile(guid, fileName);
        //此处需要注意,OSS需要再次切片上传,但minIO是不用得,它默认5M超过就会自动切片
        String path = "";
        try {
            path = localVideo2M3u8(localPath);
        }catch (Exception e){
            log.error("OSS切片上传失败!");
        }
        //移除文件
        poolTaskExecutor.execute(() -> {
            String[] split = localPath.replaceAll("\\\\","/").split("/");
            if (split.length >= 1) {
                FileUtil.deleteFile(split[1]);
            } else {
                FileUtil.deleteFile(split[0]);
            }
        });
        return path;

    }
}

7、写个 Controller

package com.wslhome.demo.api;


import cn.hutool.core.util.StrUtil;
import com.wslhome.demo.service.FileService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;


import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


/**
 * @Author sirwsl
 * @Version 1.0
 */
@RestController
@RequestMapping("/")
@Slf4j
public class FileApi {


    @Resource
    private FileService fileService;



    /**
     * 测试文件上传
     * @param file : 上传文件
     * @return : 路径
     */
    @PostMapping("/uploadFile")
    public Result uploadImg(@RequestParam("file") MultipartFile file) {
        String path = fileService.uploadFile(file);
        if (StringUtils.isNotBlank(path)) {
            return Result.success("上传成功",path);
        }
        return Result.error("上传失败");
    }


    /**
     * 整个文件上传
    * 上传视频文件转m3u8
    *@param file 文件
    *@return  String: 路径
    */
    @PostMapping("/uploadVideo")
    public Result uploadVideo(@RequestPart("file") MultipartFile file) { ;
        try {
            String path = fileService.uploadVideo2M3u8(file);
            if (StringUtils.isNotBlank(path)) {
                return Result.success("上传成功",path);
            }
        }catch (Exception e){
            log.error("视频上传转码异常,异常原因e:{}",e.getMessage());
        }
        return Result.error("上传失败");
    }


    /**
     * 大文件上传至本地
     * @param request : 请求
     * @param guid : 编码
     * @param chunk : 切片数
     * @param file : 文件
     * @return : 返回结果
     */
    @PostMapping("/uploadSlice")
    public Result uploadSlice(HttpServletRequest request, @RequestParam("guid") String guid,
                              @RequestParam("chunk") Integer chunk,
                              @RequestParam("file") MultipartFile file) {
        if (fileService.uploadSlice(request, guid, chunk, file)){
            return Result.success("上传成功","");
        }else{
            return Result.error();
        }
    }

    /**
     * 大文件上传后合并
     * @param guid:
     * @param fileName :
     * @return :
     */
    @RequestMapping("/uploadMerge")
    public Result uploadMerge(@RequestParam("guid") String guid, @RequestParam("fileName") String fileName) {
        // 得到 destTempFile 就是最终的文件
            String path = fileService.uploadMerge(guid, fileName);
            if (StringUtils.isNotBlank(path)){
                return Result.success("合并成功",path);
            }else{
                return Result.error("合并文件失败");
            }


    }

    /**
     * 切片上传后合并转M3U8格式 :
     * @param fileName :文件名
     * @param guid: 随机id
     * @return :
     */
    @PostMapping("/uploadVideoMerge")
    public Result uploadVideoMerge(@RequestParam("guid") String guid, @RequestParam("fileName") String fileName) {
        try {
            String path = fileService.uploadVideoMerge(guid, fileName);
            if (StringUtils.isNotBlank(path)) {
                return Result.success("上传成功",path);
            }
        }catch (Exception e){
            log.error("视频上传转码异常,异常原因e:{}",e.getMessage());
        }
        return Result.error("上传失败");
    }

    /**
     * 下载文件
     *
     * @param response response
     * @param url   bucket名称
     * @param fileName 文件名
     */
    @GetMapping("/downloadFile")
    public void downloadFile(HttpServletResponse response, String url, String fileName) throws Exception {
        fileService.downloadFileOss(response,url,fileName);
    }
}


八、前端代码

1、封装后的 JS

前端为了万能兼容,以后管你啥语言都能够移植过去,因此就采用最古老的 XMLHttpRequest 请求方式。这里做了封装,拿走不谢。

/************************
 * 普通文件上传
 * *********************
 */

function request(method,path,param,callback) {
    let XHR = null;
    if (window.XMLHttpRequest) {
        XHR = new XMLHttpRequest();
    } else if (window.ActiveXObject) {
        XHR = new ActiveXObject("Microsoft.XMLHTTP");
    } else {
        XHR = null;
    }
    if (XHR) {
        XHR.open(method, path);
        XHR.onreadystatechange = function () {
            if (XHR.readyState == 4 && XHR.status == 200) {
                callback(XHR.responseText);
            }
        }
    }
    XHR.send(param);
}


/**
 * 切片上传
 * @returns {*}
 */
function partUpload(GUID, partFile, name, chunks, chunk, partUrl, partMethod,callback) {
    const form = new FormData();
    form.append("guid", GUID);
    form.append("file", partFile);  //slice方法用于切出文件的一部分
    form.append("fileName", name);
    form.append("chunks", chunks);  //总片数
    form.append("chunk", chunk);    //当前是第几片
    return request(partMethod,partUrl,form,callback);
}

/**
 * 文件合并
 * @returns {*}
 */
function mergeFile(GUID, name, mergeUrl, partMethod,callback) {
    const formMerge = new FormData();
    formMerge.append("guid", GUID);
    formMerge.append("fileName", name);
    return request(partMethod,mergeUrl,formMerge,callback);
}

/**
 * 生成id
 * @returns {string}
 */
 function guid(prefix) {
    let counter = 0;
    let guid = (+new Date()).toString(32),
        i = 0;
    for (; i < 5; i++) {
        guid += Math.floor(Math.random() * 65535).toString(32);
    }
    return (prefix || 'sirwsl_') + guid + (counter++).toString(32);
}


2、懒的人直接 copy 这 html 把

<!DOCTYPE html>
<html lang="zh-CN">
<head>

    <meta charset="UTF-8">
    <title></title>
    <link href="https://unpkg.com/video.js/dist/video-js.css" th:href="@{https://unpkg.com/video.js/dist/video-js.css}" rel="stylesheet">
    <script src="https://unpkg.com/video.js/dist/video.js" th:src="@{https://unpkg.com/video.js/dist/video.js}"></script>
    <script src="https://unpkg.com/videojs-contrib-hls/dist/videojs-contrib-hls.js" th:src="@{https://unpkg.com/videojs-contrib-hls/dist/videojs-contrib-hls.js}"></script>
    <meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>
    <style type="text/css">
        #red {
            width: 350px;
            height: 350px;
            background: #fedcbd;
            float: left;
        }

        #blue {
            width: 350px;
            height: 350px;
            background: #cde6c7;
            float: left;
        }

        #green {
            width: 350px;
            height: 350px;
            background: #d5c59f;
            float: left;
            clear: left;
        }

        #grey {
            width: 350px;
            height: 350px;
            background: #fffef9;
            float: left;
        }

        .video-js .vjs-tech {
            position: relative !important;
        }

        .all {
            width: 350px;
            height: 50px;
        }

    </style>
</head>
<body>
<div style=" position: absolute;left: 50%;top: 50%;transform: translate(-50%,-50%);">
    <div id="red">
        <div style="width:350px;height:100px;text-align:center"><h4>普通文件上传至OSS或MinIOn</h4></div>
        <input id = "uploadFile" class="all" type="file" name="uploadFile"/></br>
        <input type="submit" value="上传" onclick="upload()"/>
        <div id="detail1" style="text-align:center"></div>
    </div>
    <div id="blue">
        <div style="width:350px;height:100px;text-align:center"><h4>大文件切片上传至OSS或MINIO</h4></div>
        <input id ="uploadBigFile"  class="all" type="file" name="uploadBigFile"/></br>
        <input type="submit" value="大文件切片上传" onclick="uploadBigFile()"/>
        <div id="detail2" style="text-align:center"></div>
    </div>

    <div id="green">
        <div style="width:350px;height:100px;text-align:center"><h4>视频文件上传转m3u8格式</br>并保存至OSS或minIOn</h4></div>
        <input id = "uploadVideo" class="all" type="file" name="uploadFile"/></br>
        <input type="submit" value="普通上传并转码" onclick="uploadVideo()"/>
        <input type="submit" value="切片上传并转码" onclick="uploadVideoSlice()"/>
        <div id="detail3" style=" width:300px;word-break:break-all; text-align:center"></div>

    </div>


    <div id="grey">
        <div style="width:350px;height:350px;position:absolute;top:50%;left:50%;">
            <video id="myVideo"
                   style="width:350px;height:350px;"
                   class="video-js vjs-default-skin vjs-big-play-centered" controls preload="auto" data-setup='{}'>
                <source id="source" src="/test.m3u8" type="application/x-mpegURL"> </source>
            </video>
        </div>
    </div>
</div>
</body>

<script src="/index.js" th:src="@{/index.js}"></script>
<script type="text/javascript">

    function upload(){
        let file = document.getElementById("uploadFile").files[0];//IE10以下不支持
        let fd = new FormData();
        fd.append('file', file);
        document.getElementById("detail1").innerHTML = '文件正在上传... ...';
        let call = function (result) {
                let res = JSON.parse(result);
                if (res.code == 200&&res.data !='' ){
                    console.log(res);
                    document.getElementById("detail1").innerHTML = '文件上传成功!</br>路径为:'+res.data;
                }
        }
        request("POST", "/uploadFile", fd, call);


    }
    function uploadBigFile(){
        let file = document.getElementById("uploadBigFile").files[0];//IE10以下不支持
        let name = file.name,        //文件名
            size = file.size;        //总大小
        let GUID = guid();
        let shardSize = 2 * 1024 * 1024,    //以1MB为一个分片
            shardCount = Math.ceil(size / shardSize);  //总片数
        let count = 0;
        for (let i = 0; i < shardCount; ++i) {
            //计算每一片的起始与结束位置
            let start = i * shardSize,
                end = Math.min(size, start + shardSize);
            let partFile = file.slice(start, end);
            let call = function (result) {
                let res = JSON.parse(result);
                if (res.code == 200){
                    document.getElementById("detail2").innerHTML = '此次文件共切片'+shardCount+'片</br>切片上传进度:'+(i+1)+"/"+shardCount;
                    count++;
                }

                if (count == shardCount){
                    let call2 = function (result) {
                        let res = JSON.parse(result);
                        if (res.code == 200){
                            document.getElementById("detail2").innerHTML = "切片上传成功!地址:"+res.data;
                        }
                    }
                    document.getElementById("detail2").innerHTML = "正在进行文件合并并上传至文件服务";
                    mergeFile(GUID,name,"/uploadMerge","POST",call2);
                }
            }
            partUpload(GUID, partFile, name, shardCount, i, "/uploadSlice", "POST",call);
        }


    }

    function uploadVideoSlice(){
        let file = document.getElementById("uploadVideo").files[0];//IE10以下不支持
        let name = file.name,        //文件名
            size = file.size;        //总大小
        let GUID = this.guid();
        let shardSize = 5 * 1024 * 1024,    //以1MB为一个分片
            shardCount = Math.ceil(size / shardSize);  //总片数
        let count = 0;
        for (let i = 0; i < shardCount; ++i) {
            //计算每一片的起始与结束位置
            let start = i * shardSize,
                end = Math.min(size, start + shardSize);
            let partFile = file.slice(start, end);
            let call1 = function (result){
                let res = JSON.parse(result);
                if (res.code == 200){
                    document.getElementById("detail3").innerHTML = '此次文件共切片'+shardCount+'切片上传进度:'+(i+1)+"/"+shardCount;
                    count++;
                }
                if (count == shardCount){
                    document.getElementById("detail3").innerHTML = "正在进行视频合并、视频转码并转存、请耐心等待...如果觉得看不到进度,可以自己用Socket实现";
                    let call2 = function (result) {
                        let res = JSON.parse(result);
                        if (res.code == 200){
                            document.getElementById("detail3").innerHTML = "视频转码成功,且已存储!地址:"+res.data;
                            changeVideo(res.data);
                        }
                    }
                    mergeFile(GUID,name,"/uploadVideoMerge","POST",call2);
                }
            }
            partUpload(GUID, partFile, name, shardCount, i, "/uploadSlice", "POST",call1);
        }
    }

    function uploadVideo(){
        let file = document.getElementById("uploadVideo").files[0];//IE10以下不支持
        let fd = new FormData();
        fd.append('file', file);
        document.getElementById("detail3").innerHTML = '视频上传中...';
        let call = function (result) {
            let res = JSON.parse(result)
            if (res.code == 200&&res.data !='' ){
                document.getElementById("detail3").innerHTML = '文件上传成功正在加载,解析后路径为路径为:'+res.data;
                changeVideo(res.data);
            }
        }
        request("POST","/uploadVideo",fd,call);
    }


    /**
     * m3u8 播放器
     */
    let myVideo = videojs('myVideo', {
        bigPlayButton: true,
        textTrackDisplay: false,
        posterImage: false,
        errorDisplay: false,
    })
    myVideo.play()

    let changeVideo = function (vdoSrc) {
        if (/\.m3u8$/.test(vdoSrc)) {
            myVideo.src({
                src: vdoSrc,
                type: 'application/x-mpegURL'
            })
        } else {
            myVideo.src(vdoSrc)
        }
        myVideo.load();
        myVideo.play();
    }

</script>
</html>

九、收尾

截至目前,项目正常来说应该可以在 windows 上跑起来了,但是不能够在 linux 上跑更不能 docker run,因此我们来写个 dockerfile

1、dockerfile 编写

#指定基础镜像,在其上进行定制
FROM java:8

#维护者信息
MAINTAINER wangshilei <sirwsl@163.com>
VOLUME /tmp

COPY target/uploadWheel-1.jar upload-wheel.jar
COPY ffmpeg-linux/ffmpeg  /ffmpeg
RUN bash -c "touch /upload-wheel.jar" &&\
  cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime &&\
  echo "Asia/Shanghai" >> /etc/timezone
EXPOSE 8080

ENTRYPOINT [ "java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "/upload-wheel.jar" ]

2、编写 nginx 配置文件


location /m3u8 {
     types{
          application/vnd.apple.mpegurl m3u8;
          video/mp2t ts;
     }
    proxy_pass xxx
    access_log off;
    add_header Cache-Control no-cache;

}

如果涉及到跨域请加入

        add_header Access-Control-Allow-Origin *;
        add_header Access-Control-Allow-Methods GET,POST,PUT,DELETE,OPTIONS always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Access-Control-Allow-Headers DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,x-auth-token always;

十、优化

在调用 ffmpeg 进行转码时候,上面已经优化过,1G 视频转码大概需要 10 秒,在没优化之前大概需要 50 多分钟,如果觉得慢自己去优化。

优化前:

image.png

优化后:

image.png

优化参数:

"-vsync", "2", 
"-c:v", "copy", 
"-c:a", "copy", 
"-tune", "fastdecode",
 "-hls_wrap", "0", 
"-hls_time", "10", 
"-hls_list_size", "0", 
"-threads", "12"

十一、项目缺陷

1、文件在下载时候,没办法下载视频文件,因为视频都被切片了,因为我们项目不需要,所以就没写

2、多线程上传没有优化、需要自己根据系统情况进行优化

3、不管是 linux 还是 window 打成 jar 包要运行,需注意 jar 包位置与 ffmpeg 文件的位置,不然运行不了

4、因为开发和部署方便,所以该项目只是做了在 idea、和 docker 中能够平稳运行

十二、项目地址:

码云:https://gitee.com/sirwsl/uploadWheel

GitHub: https://github.com/sirwsl/uploadWheel

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3190 引用 • 8214 回帖 • 1 关注
  • 开源

    Open Source, Open Mind, Open Sight, Open Future!

    407 引用 • 3578 回帖 • 1 关注
  • 文件上传
    8 引用 • 133 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...