利用FFmpeg將HLS直播列表.m3u8格式轉為mp4保存
將直播流轉為mp4保存是最近需要完成的一個小功能。
我們知道javacv是java里一個處理音視頻的高效依賴包。然而掃地生在使用的過程發現它並不支持將.m3u8格式作為視頻源處理,即FFmpegFrameGrabber采集器采集不了.m3u8格式的視頻(或許是掃地生深度不夠,目前尚未能利用grabber直接采集.m3u8格式的視頻源)。
這個過程中仍然是利用javacv,但不是直接使用,而是使用原生的FFmpeg(也在依賴包中,可以不用安裝),如果需要安裝可以參考:FFMpeg的下載及其簡單使用
開始之前,先了解一下m3u8文件格式:閱讀筆記-m3u8文件格式
開始:
1 程序項目搭建
1.1 搭建一個springboot項目,
選擇web項目即可:
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 代碼實現
項目架構
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接口調試工具來調試一下:
5 注意事項
因為這里的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的里播放列表就不需要特定指向絕對路徑了。