利用FFmpeg将HLS直播列表.m3u8格式转为mp4保存

利用FFmpeg将HLS直播列表.m3u8格式转为mp4保存

将直播流转为mp4保存是最近需要完成的一个小功能。

我们知道javacv是java里一个处理音视频的高效依赖包。然而扫地生在使用的过程发现它并不支持将.m3u8格式作为视频源处理,即FFmpegFrameGrabber采集器采集不了.m3u8格式的视频(或许是扫地生深度不够,目前尚未能利用grabber直接采集.m3u8格式的视频源)。

这个过程中仍然是利用javacv,但不是直接使用,而是使用原生的FFmpeg(也在依赖包中,可以不用安装),如果需要安装可以参考:FFMpeg的下载及其简单使用

开始之前,先了解一下m3u8文件格式:阅读笔记-m3u8文件格式

开始:

1 程序项目搭建

1.1 搭建一个springboot项目,

image-20220115140529290

选择web项目即可:

image-20220115140607413

image-20220115140834388

2 导入依赖

<!--      javacv 和 ffmpeg的依赖包      -->
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>javacv</artifactId>
    <version>1.5.4</version>
    <exclusions>
        <exclusion>
            <groupId>org.bytedeco</groupId>
            <artifactId>*</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>ffmpeg-platform</artifactId>
    <version>4.3.1-1.5.4</version>
</dependency>

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.4</version>
    <scope>compile</scope>
</dependency>

3 代码实现

项目架构

image-20220115141916228

3.1 Hls转MP4

因为没法直接使用采集、录制的方式直接操作m3u8文件,所以就使用更原生的方式实现:

package com.saodisheng.processor;

import lombok.extern.slf4j.Slf4j;
import org.bytedeco.javacpp.Loader;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * description:
 * 根据hls直播地址,将hls的m3u8播放列表格转为mp4格式
 * 如果原视频不是m3u8格式则不用进行中间转换。
 *
 * javacv的采集器FFmpegFrameGrabber并不支持直接读取hls的m3u8格式文件,
 * 所以没法直接用采集、录制的方式进行m3u8到mp4的转换。
 * 这里的实现过程是直接操作ffmpeg,
 *
 * todo:使用ffmepg用于格式转换速度较慢
 *
 * @author liuxingwu
 * @date 2022/1/9
 */
@Slf4j
public class HlsToMp4Processor {
    static final String DEST_VIDEO_TYPE = ".mp4";
    static final SimpleDateFormat SDF = new SimpleDateFormat("yyyyMMddHHmmssSSS");
    static ExecutorService fixedThreadPool = Executors.newFixedThreadPool(2);
    /**
     * 方法入口
     *
     * @param sourceVideoPath   视频源路径
     * @return
     */
    public static String process(String sourceVideoPath) {
        log.info("开始进行格式转换");
        if (!checkContentType(sourceVideoPath)) {
            log.info("请输入.m3u8格式的文件");
            return "";
        }
        // 获取文件名
        String destVideoPath = getFileName(sourceVideoPath)
                + "_" + SDF.format(new Date()) + DEST_VIDEO_TYPE;
        // 执行转换逻辑
        return processToMp4(sourceVideoPath, destVideoPath) ? destVideoPath : "";
    }

    private static String getFileName(String sourceVideoPath) {
        return sourceVideoPath.substring(sourceVideoPath.contains("/") ?
                        sourceVideoPath.lastIndexOf("/") + 1 : sourceVideoPath.lastIndexOf("\\") + 1,
                sourceVideoPath.lastIndexOf("."));
    }

    /**
     * 执行转换逻辑
     * @author saodisheng_liuxingwu
     * @modifyDate 2022/1/9
     */
    private static boolean processToMp4(String sourceVideoPath, String destVideoPath) {
        long startTime = System.currentTimeMillis();

        List<String> command = new ArrayList<String>();
        //获取JavaCV中的ffmpeg本地库的调用路径
        String ffmpeg = Loader.load(org.bytedeco.ffmpeg.ffmpeg.class);
        command.add(ffmpeg);
        // 设置支持的网络协议
        command.add("-protocol_whitelist");
        command.add("concat,file,http,https,tcp,tls,crypto");
        command.add("-i");
        command.add(sourceVideoPath);
        command.add(destVideoPath);
        try {
            Process videoProcess = new ProcessBuilder(command).redirectErrorStream(true).start();

            fixedThreadPool.execute(new ReadStreamInfo(videoProcess.getErrorStream()));
            fixedThreadPool.execute(new ReadStreamInfo(videoProcess.getInputStream()));

            videoProcess.waitFor();

            log.info("中间转换已完成,生成文件:" + destVideoPath);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        } finally {
            long endTime = System.currentTimeMillis();
            log.info("用时:" + (int)((endTime - startTime) / 1000) + "秒");
        }
    }

    /**
     * 检验是否为m3u8文件
     * @author saodisheng_liuxingwu
     * @modifyDate 2022/1/9
    */
    private static boolean checkContentType(String filePath) {
        if (StringUtils.isEmpty(filePath)) {
            return false;
        }
        String type = filePath.substring(filePath.lastIndexOf(".") + 1, filePath.length()).toLowerCase();
        return "m3u8".equals(type);
    }
}
package com.saodisheng.processor;

import java.io.InputStream;

/**
 * description:
 * 在用Runtime.getRuntime().exec()或ProcessBuilder(array).start()创建子进程Process之后,
 * 一定要及时取走子进程的输出信息和错误信息,否则输出信息流和错误信息流很可能因为信息太多导致被填满,
 * 最终导致子进程阻塞住,然后执行不下去。
 *
 * @author liuxingwu_saodisheng(01420175)
 * @date 2022/1/10
 */
public class ReadStreamInfo extends Thread {
    InputStream is = null;
    public ReadStreamInfo(InputStream is) {
        this.is = is;
    }

    @Override
    public void run() {
        try {
            while(true) {
                int ch = is.read();
                if(ch != -1) {
                    System.out.print((char)ch);
                } else {
                    break;
                }
            }
            if (is != null) {
                is.close();
            }
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }
}

3.2 推流器的实现

其实如果是只将转换后的MP4文件保留到本地的话,是不需要在额外处理的,但如果是想推送到服务器的话或者直接推流到指定文件需要借助一下的推流器实现。

当然,如果说处理的原视频不是m3u8格式文件,那么直接用推流器也可以实现基本的视频格式转换了。

package com.saodisheng.processor;

import org.apache.commons.lang3.StringUtils;
import org.bytedeco.ffmpeg.avcodec.AVPacket;
import org.bytedeco.ffmpeg.global.avcodec;
import org.bytedeco.ffmpeg.global.avutil;
import org.bytedeco.javacv.*;

import java.io.InputStream;
import java.io.OutputStream;

/**
 * description:
 * 视频推流器(这里推送mp4)
 * 如果原视频不是hls的m3u8格式,可以直接调用推流器进行格式转换并推送
 *
 * @author liuxingwu_saodisheng(01420175)
 * @date 2022/1/13
 */
public class VideoPusher {
    /** 采集器 **/
    private FFmpegFrameGrabber grabber;
    /** 录制器 **/
    private FFmpegFrameRecorder recorder;

    static final String DEST_VIDEO_TYPE = ".mp4";

    public VideoPusher() {
        // 在FFmpegFrameGrabber.start()之前设置FFmpeg日志级别
        avutil.av_log_set_level(avutil.AV_LOG_INFO);
        FFmpegLogCallback.set();
    }

    /**
     * 处理视频源
     * 输入流和输出地址必须有一个是有效输入
     * @param inputStream   输入流
     * @param inputAddress  输入地址
     * @return
     */
    public VideoPusher from(InputStream inputStream, String inputAddress) {
        if (inputStream != null) {
            grabber = new FFmpegFrameGrabber(inputStream);
        } else if (StringUtils.isNotEmpty(inputAddress)) {
            grabber = new FFmpegFrameGrabber(inputAddress);
        } else {
            throw new RuntimeException("视频源为空错误,请确定输入有效视频源");
        }

        // 开始采集
        try {
            grabber.start();
        } catch (FrameGrabber.Exception e) {
            e.printStackTrace();
        }

        return this;
    }

    public VideoPusher from(InputStream inputStream) {
        return from(inputStream, null);
    }

    public VideoPusher from(String inputAddress) {
        return from(null, inputAddress);
    }

    /**
     * 设置输出
     *
     * @param outputStream
     * @param outputAddress
     * @return
     */
    public VideoPusher to(OutputStream outputStream, String outputAddress) {
        if (outputStream != null) {
            recorder = new FFmpegFrameRecorder(outputStream, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels());
        } else if (StringUtils.isNotEmpty(outputAddress)) {
            recorder = new FFmpegFrameRecorder(outputAddress, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels());
        } else {
            throw new RuntimeException("输入路径为空错误,请指定正确输入路径或输出流");
        }
        // 设置格式
        recorder.setFormat(DEST_VIDEO_TYPE);

        recorder.setOption("method", "POST");
        recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
        recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);

        // 开始录制
        try {
            recorder.start(grabber.getFormatContext());
        } catch (FrameRecorder.Exception e) {
            e.printStackTrace();
        }
        return this;
    }

    public VideoPusher to(OutputStream outputStream) {
        return to(outputStream, null);
    }

    public VideoPusher to(String outputAddress) {
        return to(null, outputAddress);
    }


    /**
     * 转封装,推送流
     */
    public void go() {
        AVPacket pkt;
        try {
            while ((pkt = grabber.grabPacket()) != null) {
                recorder.recordPacket(pkt);
            }
        } catch (FrameGrabber.Exception | FrameRecorder.Exception e) {
            e.printStackTrace();
        }
        close();
    }

    public void close() {
        try {
            if (recorder != null) {
                recorder.close();
            }
        } catch (FrameRecorder.Exception e) {
            e.printStackTrace();
        }
        try {
            if (grabber != null) {
                // 因为grabber的close调用了stop和release,而stop也是调用了release,为了防止重复调用,直接使用release
                grabber.release();
            }
        } catch (FrameGrabber.Exception e) {
            e.printStackTrace();
        }
    }
}

3.3 模拟接受前端的参数

package com.saodisheng.controller;

import com.saodisheng.controller.vo.VideoParamsVO;

import com.saodisheng.processor.HlsToMp4Processor;
import com.saodisheng.processor.VideoPusher;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.io.File;


/**
 * description:
 *
 * @author liuxingwu_saodisheng(01420175)
 * @date 2022/1/12
 */
@RestController
@Slf4j
public class HlsToMp4TestController {

    @PostMapping("convert_to_mp4/")
    public void convertToMp4(@RequestBody VideoParamsVO dataVO) {
        String sourceVideoUrl = dataVO.getSourceVideoUrl();
        Assert.notNull(sourceVideoUrl, "视频源不能为空");

        // 将m3u8格式视频转为mp4本地文件(用于转换格式的中间文件)
        String destFileName = HlsToMp4Processor.process(sourceVideoUrl);
        if (StringUtils.isEmpty(destFileName)) {
            log.error("操作失败");
        }

        // 推送流
        if(StringUtils.isNotEmpty(dataVO.getDestVideoPath())) {
            new VideoPusher().from(destFileName).to(dataVO.getDestVideoPath() + destFileName).go();
            // 删除中间文件
            new File(destFileName).delete();
        }
    }
}
package com.saodisheng.controller.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/**
 * description: 接收前端数据
 *
 * @author liuxingwu_saodisheng(01420175)
 * @date 2022/1/13
 */
@AllArgsConstructor
@NoArgsConstructor
@Data
public class VideoParamsVO implements Serializable {
    /** 视频源地址 .m3u8格式 **/
    private String sourceVideoUrl;

    /** 推送目标地址 **/
    private String destVideoPath;
}

4 启动程序测试

启动程序后采用ApiPost接口调试工具来调试一下:

image-20220115144411721

image-20220115144354760

image-20220115145356799

image-20220115145413767

image-20220115145513267

5 注意事项

image-20220115145600143

因为这里的ffmpeg使用的是依赖包里的,而不是本地下载的ffmpeg.exe插件,所以对于本地的m3u8格式文件里的ts路径要用的是绝对路径。如果说用的是ffmpeg.exe插件,那么在系统环境变量配置了FFMPEG_HOME后将上述代码改为:

/**
     * 执行转换逻辑
     * @author saodisheng_liuxingwu
     * @modifyDate 2022/1/9
     */
    private static boolean processToMp4(String sourceVideoPath, String destVideoPath) {
        long startTime = System.currentTimeMillis();

        List<String> command = new ArrayList<String>();
//        //获取JavaCV中的ffmpeg本地库的调用路径
//        String ffmpeg = Loader.load(org.bytedeco.ffmpeg.ffmpeg.class);
        command.add("ffmpeg");
        // 设置支持的网络协议
        command.add("-protocol_whitelist");
        command.add("concat,file,http,https,tcp,tls,crypto");
        command.add("-i");
        command.add(sourceVideoPath);
        command.add(destVideoPath);
        try {
            Process videoProcess = new ProcessBuilder(command).redirectErrorStream(true).start();

            fixedThreadPool.execute(new ReadStreamInfo(videoProcess.getErrorStream()));
            fixedThreadPool.execute(new ReadStreamInfo(videoProcess.getInputStream()));

            videoProcess.waitFor();

            log.info("中间转换已完成,生成文件:" + destVideoPath);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        } finally {
            long endTime = System.currentTimeMillis();
            log.info("用时:" + (int)((endTime - startTime) / 1000) + "秒");
        }
    }

然后对于m3u8的里播放列表就不需要特定指向绝对路径了。

代码链接

posted @ 2022-01-15 15:08  技术扫地生—楼上老刘  阅读(2236)  评论(0编辑  收藏  举报