当前位置: 代码网 > it编程>编程语言>Java > SpringBoot使用FFmpeg实现M3U8切片转码播放

SpringBoot使用FFmpeg实现M3U8切片转码播放

2024年08月29日 Java 我要评论
概述视频上传到本地之后(此处可分片上传到本地,然后合并),使用ffmpeg对视频处理成m3u8文件,暂时只测试了avi和mp4格式的文件。代码pom.xml<project xmlns="htt

概述

视频上传到本地之后(此处可分片上传到本地,然后合并),使用ffmpeg对视频处理成m3u8文件,暂时只测试了avi和mp4格式的文件。

代码

pom.xml

<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>io.springboot</groupid>
	<artifactid>springboot-ffmpeg-demo</artifactid>
	<version>0.0.1-snapshot</version>



	<parent>
		<groupid>org.springframework.boot</groupid>
		<artifactid>spring-boot-starter-parent</artifactid>
		<version>2.5.4</version>
		<relativepath /> <!-- lookup parent from repository -->
	</parent>

	<dependencies>
		<dependency>
			<groupid>org.springframework.boot</groupid>
			<artifactid>spring-boot-starter-test</artifactid>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupid>org.junit.vintage</groupid>
			<artifactid>junit-vintage-engine</artifactid>
			<scope>test</scope>
		</dependency>

		<dependency>
			<groupid>org.springframework.boot</groupid>
			<artifactid>spring-boot-starter-web</artifactid>
			<exclusions>
				<exclusion>
					<groupid>org.springframework.boot</groupid>
					<artifactid>spring-boot-starter-tomcat</artifactid>
				</exclusion>
			</exclusions>
		</dependency>
		<dependency>
			<groupid>org.springframework.boot</groupid>
			<artifactid>spring-boot-starter-undertow</artifactid>
		</dependency>

		<dependency>
			<groupid>commons-codec</groupid>
			<artifactid>commons-codec</artifactid>
		</dependency>


		<dependency>
			<groupid>com.google.code.gson</groupid>
			<artifactid>gson</artifactid>
		</dependency>

	</dependencies>

	<build>
		<finalname>${project.artifactid}</finalname>
		<plugins>
			<plugin>
				<groupid>org.springframework.boot</groupid>
				<artifactid>spring-boot-maven-plugin</artifactid>
				<configuration>
					<executable>true</executable>
				</configuration>
			</plugin>
		</plugins>
	</build>

</project>

ffmpeg

ffmpegutils

package com.demo.ffmpeg;
import java.io.bufferedreader;
import java.io.file;
import java.io.ioexception;
import java.io.inputstreamreader;
import java.nio.charset.standardcharsets;
import java.nio.file.files;
import java.nio.file.path;
import java.nio.file.paths;
import java.nio.file.standardopenoption;
import java.security.nosuchalgorithmexception;
import java.util.arraylist;
import java.util.list;

import javax.crypto.keygenerator;

import org.apache.commons.codec.binary.hex;
import org.slf4j.logger;
import org.slf4j.loggerfactory;
import org.springframework.util.stringutils;

import com.google.gson.gson;


public class ffmpegutils {
	
	private static final logger logger = loggerfactory.getlogger(ffmpegutils.class);
	
	
	// 跨平台换行符
	private static final string line_separator = system.getproperty("line.separator");
	
	/**
	 * 生成随机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 filename			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, transcodeconfig 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("-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, "poster.jpg"), config.getposter())) {
			throw new runtimeexception("封面截取异常");
		}
		
		// 获取视频信息
		mediainfo mediainfo = getmediainfo(source);
		if (mediainfo == null) {
			throw new runtimeexception("获取媒体信息异常");
		}
		
		// 生成index.m3u8文件
		genindex(string.join(file.separator, destfolder, "index.m3u8"), "ts/index.m3u8", mediainfo.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;
	}
}


mediainfo

package com.demo.ffmpeg;

import java.util.list;

import com.google.gson.annotations.serializedname;

public class mediainfo {
	public static class format {
		@serializedname("bit_rate")
		private string bitrate;
		public string getbitrate() {
			return bitrate;
		}
		public void setbitrate(string bitrate) {
			this.bitrate = bitrate;
		}
	}

	public static class stream {
		@serializedname("index")
		private int index;

		@serializedname("codec_name")
		private string codecname;

		@serializedname("codec_long_name")
		private string codeclongame;

		@serializedname("profile")
		private string profile;
	}
	
	// ----------------------------------

	@serializedname("streams")
	private list<stream> streams;

	@serializedname("format")
	private format format;

	public list<stream> getstreams() {
		return streams;
	}

	public void setstreams(list<stream> streams) {
		this.streams = streams;
	}

	public format getformat() {
		return format;
	}

	public void setformat(format format) {
		this.format = format;
	}
}

transcodeconfig

package com.demo.ffmpeg;

public class transcodeconfig {
	private string poster;				// 截取封面的时间			hh:mm:ss.[sss]
	private string tsseconds;			// ts分片大小,单位是秒
	private string cutstart;			// 视频裁剪,开始时间		hh:mm:ss.[sss]
	private string cutend;				// 视频裁剪,结束时间		hh:mm:ss.[sss]
	public string getposter() {
		return poster;
	}

	public void setposter(string poster) {
		this.poster = poster;
	}

	public string gettsseconds() {
		return tsseconds;
	}

	public void settsseconds(string tsseconds) {
		this.tsseconds = tsseconds;
	}

	public string getcutstart() {
		return cutstart;
	}

	public void setcutstart(string cutstart) {
		this.cutstart = cutstart;
	}

	public string getcutend() {
		return cutend;
	}

	public void setcutend(string cutend) {
		this.cutend = cutend;
	}

	@override
	public string tostring() {
		return "transcodeconfig [poster=" + poster + ", tsseconds=" + tsseconds + ", cutstart=" + cutstart + ", cutend="
				+ cutend + "]";
	}
}

application.yml

server:
  port: 80

#logging:
#  level:
#    "root": debug

app:
  # 存储转码视频的文件夹
  video-folder: d:\video

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:${app.video-folder}" # 把视频文件夹目录,添加到静态资源目录列表

application

@springbootapplication
public class application {
	public static void main(string[] args) {
		springapplication.run(application.class, args);
	}
}

uploadcontroller

package com.demo.web.controller;

import java.io.ioexception;
import java.nio.file.files;
import java.nio.file.path;
import java.nio.file.paths;
import java.time.localdate;
import java.time.format.datetimeformatter;
import java.util.hashmap;
import java.util.map;

import org.slf4j.logger;
import org.slf4j.loggerfactory;
import org.springframework.beans.factory.annotation.value;
import org.springframework.http.httpstatus;
import org.springframework.http.responseentity;
import org.springframework.web.bind.annotation.postmapping;
import org.springframework.web.bind.annotation.requestmapping;
import org.springframework.web.bind.annotation.requestpart;
import org.springframework.web.bind.annotation.restcontroller;
import org.springframework.web.multipart.multipartfile;

import com.demo.ffmpeg.ffmpegutils;
import com.demo.ffmpeg.transcodeconfig;

@restcontroller
@requestmapping("/upload")
public class uploadcontroller {
	
	private static final logger logger = loggerfactory.getlogger(uploadcontroller.class);
	
	@value("${app.video-folder}")
	private string videofolder;

	private path tempdir = paths.get(system.getproperty("java.io.tmpdir"));
	
	/**
	 * 上传视频进行切片处理,返回访问路径
	 * @param video
	 * @param transcodeconfig
	 * @return
	 * @throws ioexception 
	 */
	@postmapping
	public object upload (@requestpart(name = "file", required = true) multipartfile video,
						@requestpart(name = "config", required = true) transcodeconfig transcodeconfig) throws ioexception {
		
		logger.info("文件信息:title={}, size={}", video.getoriginalfilename(), video.getsize());
		logger.info("转码配置:{}", transcodeconfig);
		
		// 原始文件名称,也就是视频的标题
		string title = video.getoriginalfilename();
		
		// io到临时文件
		path tempfile = tempdir.resolve(title);
		logger.info("io到临时文件:{}", tempfile.tostring());
		
		try {
			
			video.transferto(tempfile);
			
			// 删除后缀
			title = title.substring(0, title.lastindexof("."));
			
			// 按照日期生成子目录
			string today = datetimeformatter.ofpattern("yyyymmdd").format(localdate.now());
			
			// 尝试创建视频目录
			path targetfolder = files.createdirectories(paths.get(videofolder, today, title));
			
			logger.info("创建文件夹目录:{}", targetfolder);
			files.createdirectories(targetfolder);
			
			// 执行转码操作
			logger.info("开始转码");
			try {
				ffmpegutils.transcodetom3u8(tempfile.tostring(), targetfolder.tostring(), transcodeconfig);
			} catch (exception e) {
				logger.error("转码异常:{}", e.getmessage());
				map<string, object> result = new hashmap<>();
				result.put("success", false);
				result.put("message", e.getmessage());
				return responseentity.status(httpstatus.internal_server_error).body(result);
			}
			
			// 封装结果
			map<string, object> videoinfo = new hashmap<>();
			videoinfo.put("title", title);
			videoinfo.put("m3u8", string.join("/", "", today, title, "index.m3u8"));
			videoinfo.put("poster", string.join("/", "", today, title, "poster.jpg"));
			
			map<string, object> result = new hashmap<>();
			result.put("success", true);
			result.put("data", videoinfo);
			return result;
		} finally {
			// 始终删除临时文件
			files.delete(tempfile);
		}
	}
}

index.html

在resources/static/index.html

<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>title</title>
        <script src="https://cdn.jsdelivr.net/hls.js/latest/hls.min.js"></script>
    </head>
    <body>
        选择转码文件: <input name="file" type="file" accept="video/*" onchange="upload(event)">
        <hr/>
		<video id="video"  width="500" height="400" controls="controls"></video>
    </body>
    <script>
    
   		const video = document.getelementbyid('video');
    	
        function upload (e){
            let files = e.target.files
            if (!files) {
                return
            }
            
            // todo 转码配置这里固定死了
            var transcodeconfig = {
            	poster: "00:00:00.001", // 截取第1毫秒作为封面
            	tsseconds: 15,				
            	cutstart: "",
            	cutend: ""
            }
            
            // 执行上传
            let formdata = new formdata();
            formdata.append("file", files[0])
            formdata.append("config", new blob([json.stringify(transcodeconfig)], {type: "application/json; charset=utf-8"}))

            fetch('/upload', {
                method: 'post',
                body: formdata
            })
            .then(resp =>  resp.json())
            .then(message => {
            	if (message.success){
            		// 设置封面
            		video.poster = message.data.poster;
            		
            		// 渲染到播放器
            		var hls = new hls();
        		    hls.loadsource(message.data.m3u8);
        		    hls.attachmedia(video);
            	} else {
            		alert("转码异常,详情查看控制台");
            		console.log(message.message);
            	}
            })
            .catch(err => {
            	alert("转码异常,详情查看控制台");
                throw err
            })
        }
    </script>
</html>

测试

avi格式视频转码m3u8

01-jvm内存与垃圾回收篇概述  // 文件夹名称就是视频标题
  |-index.m3u8  // 主m3u8文件,里面可以配置多个码率的播放地址
  |-poster.jpg  // 截取的封面图片
  |-ts      // 切片目录
    |-index.m3u8  // 切片播放索引
    |-key   // 播放需要解密的aes key

mp4格式视频转码m3u8

以上就是springboot使用ffmpeg实现m3u8切片转码播放的详细内容,更多关于springboot m3u8转码播放的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2025  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com