JavaCV
JavaCV 是一个开源的计算机视觉和多媒体处理库,它为Java开发者提供了访问一系列底层C/C++库的能力,而无需直接编写C/C++代码。JavaCV通过JNI(Java Native Interface)和JavaCPP项目实现这一目标,使得Java应用程序能够高效地调用这些库的功能。它提供了一套统一的API,让开发者能够在Java中轻松地执行图像和视频的处理任务,如图像识别、人脸识别、字符识别、视频流媒体处理等。
JavaCV封装了多个知名的计算机视觉和多媒体处理库,包括但不限于:
-
OpenCV:用于计算机视觉和图像处理。
-
FFmpeg:用于多媒体文件的编码、解码、转码、复用和分离。
-
TensorFlow:用于机器学习和深度学习。
-
Caffe:另一种用于深度学习的框架。
-
Tesseract:用于光学字符识别(OCR)。
-
libdc1394:用于Firewire(IEEE 1394)摄像头的控制。
-
OpenKinect:用于Kinect传感器的驱动和数据读取。
-
videoInput:用于捕获视频流。
-
ARToolKitPlus:用于增强现实应用。
Maven依赖
javacv-platform
是JavaCV的核心依赖。当你在项目中添加javacv-platform
依赖时,Maven或Gradle构建系统会解析这个依赖,并下载JavaCV所需的所有子依赖项,包括用于不同平台的本地库和JNI桥接库。
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv-platform</artifactId>
<version>版本号</version>
</dependency>
HLS协议
HTTP Live Streaming(HLS)是苹果公司实现的基于HTTP的流媒体传输协议,可实现流媒体的直播和点播,具体网上有很多的介绍。
HLS直播最大的不同在于,直播客户端获取到的并不是一个完整的数据流,HLS协议在服务器端将直播数据流存储为连续的、很短时长的媒体文件(MPEG-TS格式),而客户端则不断的下载并播放这些小文件,因为服务器总是会将最新的直播数据生成新的小文件,这样客户端只要不停的按顺序播放从服务器获取到的文件,就实现了直播。由此可见,基本上可以认为,HLS是以点播的技术方式实现直播。由于数据通过HTTP协议传输,所以完全不用考虑防火墙或者代理的问题,而且分段文件的时长很短,客户端可以很快的选择和切换码率,以适应不同带宽条件下的播放。不过HLS的这种技术特点,决定了它的延迟一般总是会高于普通的流媒体直播协议。
以监控摄像头的直播为例,通常分为三步:
-
从设备获取音视频流
-
利用javacv进行解码(例如flv或m3u8)
-
将视频解码后数据推送到前端页面播放
-
在此基础上还需要保证推流直播复用,如果该设备某个客户端已经在解码直播了,其他客户端只需要拿该设备解码后的视频帧数据进行播放即可,而无需重复上面三步,实现一次解码,多客户端播放。
为什么不让前端直接对接设备厂商提供的API去实现呢,有几个方面的考虑:
-
安全性与授权:可以避免将敏感的API密钥或设备访问凭证暴露给前端,从而提高系统的整体安全性。
-
负载均衡与故障转移:服务端可以处理多个前端用户的请求,并智能地分配视频流资源,确保即使在高并发场景下也能提供稳定的服务。此外,后端还可以实施故障转移策略,当某个视频源不可用时自动切换到备用源。
-
缓存与优化:对于重复的视频流请求,服务端可以实施缓存策略,减少对源服务器的直接请求,从而降低带宽成本并提高响应速度。
实时播放实现
1.建立Netty服务器,负责接收客户端的播放请求连接,维护了一个DeviceContext
设备容器,存放各个设备的TransferToFlv
实例
各编解码器的含义
-
HttpResponseEncoder:这个处理器用于将Java对象编码成HTTP响应的二进制格式。在服务端,它会把响应对象转换成可以在网络上传输的字节流。
-
HttpRequestDecoder:这个处理器用于将接收到的HTTP请求的二进制数据解码成Java对象。这样可以更容易地处理HTTP请求的头信息、请求体等。
-
ChunkedWriteHandler:这个处理器用于处理HTTP的分块传输编码。它主要用于未知长度的数据传输,比如流媒体或者大文件的传输,可以边读取边发送,不需要等待所有数据加载完毕再发送。
-
HttpObjectAggregator:它用于聚合HTTP请求或响应的不同部分,例如请求行、头和实体内容。通过聚合,你可以更方便地获取一个完整的HTTP请求或响应对象。
60 * 1024
表示用来聚合HTTP消息的最大内容长度,单位是字节。这里等于65536字节,即64KB。如果HTTP消息的大小超过了这个限制,那么HttpObjectAggregator
将不会继续聚合剩余的部分,这可能会导致不完整的消息被传递给后续的处理器。因此,选择合适的缓冲区大小很重要,它应该足够大以容纳大部分HTTP消息,但又不能太大以免浪费内存资源。在实际应用中,可以根据预期的HTTP消息大小和性能需求来调整这个参数。 -
CorsHandler:用于处理跨域资源共享(CORS)的处理器。它可以配置允许的源、是否允许凭据、预检请求的有效期等。
@Slf4j
@Component
public class MediaServer implements CommandLineRunner {
public static ConcurrentHashMap<String, TransferToFlv> deviceContext = new ConcurrentHashMap<>();
@Autowired
private LiveHandler liveHandler;
public void start() {
InetSocketAddress socketAddress = new InetSocketAddress("0.0.0.0", 8234);
//主线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
//工作线程组
EventLoopGroup workGroup = new NioEventLoopGroup(200);
ServerBootstrap bootstrap = new ServerBootstrap()
.group(bossGroup, workGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) {
CorsConfig corsConfig = CorsConfigBuilder.forAnyOrigin().allowNullOrigin().allowCredentials().build();
socketChannel.pipeline()
.addLast(new HttpResponseEncoder())
.addLast(new HttpRequestDecoder())
.addLast(new ChunkedWriteHandler())
.addLast(new HttpObjectAggregator(64 * 1024))
.addLast(new CorsHandler(corsConfig))
.addLast(liveHandler);
}
})
.localAddress(socketAddress)
// 设置服务器端接受连接的队列大小
.option(ChannelOption.SO_BACKLOG, 128)
// 选择直接内存
.option(ChannelOption.ALLOCATOR, PreferredDirectByteBufAllocator.DEFAULT)
// 控制是否启用 Nagle 算法
.childOption(ChannelOption.TCP_NODELAY, true)
// 控制TCP连接是否开启TCP keep-alive机制
.childOption(ChannelOption.SO_KEEPALIVE, true)
// 设置服务器端的接收缓冲区大小
.childOption(ChannelOption.SO_RCVBUF, 128 * 1024)
// 设置服务器端的发送缓冲区大小
.childOption(ChannelOption.SO_SNDBUF, 1024 * 1024)
// 设置Channel写缓冲区的水位线:当写缓冲区的数据量达到1MB时,Netty会暂停接收新数据,直到写缓冲区的数据量降至0.5MB以下,才重新开始接收 数据
.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(1024 * 1024 / 2, 1024 * 1024));
try {
// 绑定端口,开始接收进来的连接
ChannelFuture future = bootstrap.bind(socketAddress).sync();
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//关闭主线程组
bossGroup.shutdownGracefully();
//关闭工作线程组
workGroup.shutdownGracefully();
}
}
@Override
public void run(String... args) {
this.start();
}
}
2.编写自定义入栈处理器,判断请求地址是否为/live,并且获取地址中的deviceId,并将channel加入到设备的httpClients
@Service
@ChannelHandler.Sharable
public class LiveHandler extends SimpleChannelInboundHandler<Object> {
/**
* 当Netty接收到一个请求并传递给LiveHandler时,此方法会被调用。
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
// 检查请求是否为FullHttpRequest类
FullHttpRequest req = (FullHttpRequest) msg;
// 解析URL中的查询字符串
QueryStringDecoder decoder = new QueryStringDecoder(req.uri());
// 判断请求uri
if (!"/live".equals(decoder.path())) {
sendError(ctx, HttpResponseStatus.BAD_REQUEST);
return;
}
// 进一步检查请求中是否包含deviceId参数
List<String> parameters = decoder.parameters().get("deviceId");
if(parameters == null || parameters.isEmpty()){
sendError(ctx, HttpResponseStatus.BAD_REQUEST);
return;
}
// 提取deviceId参数值
String deviceId = parameters.get(0);
// 发送FLV格式的响应头
sendFlvResHeader(ctx);
// 处理视频流播放
Device device = new Device(deviceId, MediaServer.YOUR_VIDEO_PATH);
playForHttp(device, ctx);
}
public void playForHttp(Device device, ChannelHandlerContext ctx) {
try {
TransferToFlv mediaConvert = new TransferToFlv();
if (MediaServer.deviceContext.containsKey(device.getDeviceId())) {
mediaConvert = MediaServer.deviceContext.get(device.getDeviceId());
mediaConvert.getMediaChannel().addChannel(ctx, true);
return;
}
mediaConvert.setCurrentDevice(device);
MediaChannel mediaChannel = new MediaChannel(device);
mediaConvert.setMediaChannel(mediaChannel);
MediaServer.deviceContext.put(device.getDeviceId(), mediaConvert);
//注册事件
mediaChannel.getEventBus().register(mediaConvert);
new Thread(mediaConvert).start();
mediaConvert.getMediaChannel().addChannel(ctx, false);
} catch (InterruptedException | FFmpegFrameRecorder.Exception e) {
throw new RuntimeException(e);
}
}
/**
* 错误请求响应
* @param ctx
* @param status
*/
private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status,
Unpooled.copiedBuffer("请求地址有误: " + status + "\r\n", CharsetUtil.UTF_8));
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
/**
* 发送req header,告知浏览器是flv格式
* @param ctx
*/
private void sendFlvResHeader(ChannelHandlerContext ctx) {
HttpResponse rsp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
rsp.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE)
.set(HttpHeaderNames.CONTENT_TYPE, "video/x-flv").set(HttpHeaderNames.ACCEPT_RANGES, "bytes")
.set(HttpHeaderNames.PRAGMA, "no-cache").set(HttpHeaderNames.CACHE_CONTROL, "no-cache")
.set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED).set(HttpHeaderNames.SERVER, "测试");
ctx.writeAndFlush(rsp);
}
}
3.主要负责每个设备的channel添加、关闭,以及向channel发送数据。利用newScheduledThreadPool
进行周期性检查channel的在线情况,如果全部channel下线,则使用事件总线EventBus通知关闭解码推流。
@Data
@AllArgsConstructor
public class MediaChannel {
private Device currentDevice;
public ConcurrentHashMap<String, ChannelHandlerContext> httpClients;
private ScheduledFuture<?> checkFuture;
private final ScheduledExecutorService scheduler;
protected EventBus eventBus;
public MediaChannel(Device currentDevice) {
this.currentDevice = currentDevice;
this.httpClients = new ConcurrentHashMap<>();
this.scheduler = Executors.newScheduledThreadPool(1);
this.eventBus = new EventBus();
}
public void addChannel(ChannelHandlerContext ctx, boolean needSendFlvHeader) throws InterruptedException, FFmpegFrameRecorder.Exception {
if (ctx.channel().isWritable()) {
ChannelFuture channelFuture = null;
if (needSendFlvHeader) {
//如果当前设备正在有channel播放,则先发送flvheader,再发送视频数据。
byte[] flvHeader = MediaServer.deviceContext.get(currentDevice.getDeviceId()).getFlvHeader();
channelFuture = ctx.writeAndFlush(Unpooled.copiedBuffer(flvHeader));
} else {
channelFuture = ctx.writeAndFlush(Unpooled.copiedBuffer(new ByteArrayOutputStream().toByteArray()));
}
channelFuture.addListener(future -> {
if (future.isSuccess()) {
httpClients.put(ctx.channel().id().toString(), ctx);
}
});
this.checkFuture = scheduler.scheduleAtFixedRate(this::checkChannel, 0, 10, TimeUnit.SECONDS);
System.out.println(currentDevice.getDeviceId() + ":channel:" + ctx.channel().id() + "创建成功");
}
Thread.sleep(50);
}
/**
* 检查是否存在channel
*/
private void checkChannel() {
if (httpClients.isEmpty()) {
System.out.println("通知关闭推流");
eventBus.post(this.currentDevice);
this.checkFuture = null;
scheduler.shutdown();
}
}
/**
* 关闭通道
*/
public void closeChannel() {
for (Map.Entry<String, ChannelHandlerContext> entry : httpClients.entrySet()) {
entry.getValue().close();
}
}
/**
* 发送数据
* @param data
*/
public void sendData(byte[] data) {
for (Map.Entry<String, ChannelHandlerContext> entry : httpClients.entrySet()) {
if (entry.getValue().channel().isWritable()) {
entry.getValue().writeAndFlush(Unpooled.copiedBuffer(data));
} else {
httpClients.remove(entry.getKey());
System.out.println(currentDevice.getDeviceId() + ":channel:" + entry.getKey() + "已被去除");
}
}
}
}
4.流的解码、推送部分就是在这个类里面,使用的是javacv封装的ffmpeg库,将音视频流转换为flv格式。实际的参数可以根据业务调整。 这里增加了一个获取flv格式header数据方法,因为flv格式视频必须要包含flv header
才能播放。复用推流数据的时候,先向前端发送flv格式header,再发送流数据。
@Slf4j
@Data
public class TransferToFlv implements Runnable {
private volatile boolean running = false;
private FFmpegFrameGrabber grabber;
private FFmpegFrameRecorder recorder;
public ByteArrayOutputStream bos = new ByteArrayOutputStream();
private Device currentDevice;
private MediaChannel mediaChannel;
public ConcurrentHashMap<String, ChannelHandlerContext> httpClients = new ConcurrentHashMap<>();
/**
* 创建拉流器
*
* @return
*/
protected void createGrabber(String url) throws FFmpegFrameGrabber.Exception {
grabber = new FFmpegFrameGrabber(url);
//拉流超时时间(10秒)
grabber.setOption("stimeout", "10000000");
grabber.setOption("threads", "1");
grabber.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
// 设置缓存大小,提高画质、减少卡顿花屏
grabber.setOption("buffer_size", "1024000");
// 读写超时,适用于所有协议的通用读写超时
grabber.setOption("rw_timeout", "15000000");
// 探测视频流信息,为空默认5000000微秒
// grabber.setOption("probesize", "5000000");
// 解析视频流信息,为空默认5000000微秒
//grabber.setOption("analyzeduration", "5000000");
grabber.start();
}
/**
* 创建录制器
*
* @return
*/
protected void createTransterOrRecodeRecorder() throws FFmpegFrameRecorder.Exception {
recorder = new FFmpegFrameRecorder(bos, grabber.getImageWidth(), grabber.getImageHeight(),
grabber.getAudioChannels());
setRecorderParams(recorder);
recorder.start();
}
/**
* 设置录制器参数
*
* @param fFmpegFrameRecorder
*/
private void setRecorderParams(FFmpegFrameRecorder fFmpegFrameRecorder) {
// 指定输出的容器格式为FLV
fFmpegFrameRecorder.setFormat("flv");
// 控制音频和视频流是否交织在一起。设为false表示不交织,意味着音频和视频流将分别独立存储
fFmpegFrameRecorder.setInterleaved(false);
/*
设置视频编码的额外选项:
"tune":设置编码器优化目标为zerolatency,意味着优化低延迟流传输
"preset":设置编码预置为ultrafast,牺牲一些压缩效率以获得更快的编码速度
"crf":设置恒定速率因子,数值越小质量越高,一般范围是18-28,23是一个常见的平衡点
"threads":设置使用的编码线程数
*/
fFmpegFrameRecorder.setVideoOption("tune", "zerolatency");
fFmpegFrameRecorder.setVideoOption("preset", "ultrafast");
fFmpegFrameRecorder.setVideoOption("crf", "23");
fFmpegFrameRecorder.setVideoOption("threads", "1");
fFmpegFrameRecorder.setFrameRate(25);// 设置帧率,即每秒显示的图像数量
fFmpegFrameRecorder.setGopSize(25);// 设置gop,与帧率相同
//recorder.setVideoBitrate(500 * 1000);// 码率500kb/s
fFmpegFrameRecorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);//设置视频编码器为H.264
fFmpegFrameRecorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);//设置像素格式为YUV420P,用于H.264编码。
fFmpegFrameRecorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);//设置音频编码器为AAC
fFmpegFrameRecorder.setOption("keyint_min", "25"); //gop最小间隔
fFmpegFrameRecorder.setTrellis(1);//控制编码器是否使用trellis量化,设置为1可以稍微提高编码质量。
fFmpegFrameRecorder.setMaxDelay(0);// 设置延迟,设为0表示尽量减少延迟
}
/**
* 获取flv格式header数据
*
* @return
* @throws FFmpegFrameRecorder.Exception
*/
public byte[] getFlvHeader() throws FFmpegFrameRecorder.Exception {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
FFmpegFrameRecorder fFmpegFrameRecorder = new FFmpegFrameRecorder(byteArrayOutputStream, grabber.getImageWidth(), grabber.getImageHeight(),
grabber.getAudioChannels());
setRecorderParams(fFmpegFrameRecorder);
fFmpegFrameRecorder.start();
return byteArrayOutputStream.toByteArray();
}
/**
* 将视频源转换为flv
*/
protected void transferToFlv() {
//创建拉流器
try {
createGrabber(currentDevice.getRtmpUrl());
//创建录制器
createTransterOrRecodeRecorder();
grabber.flush();
running = true;
// 时间戳计算
long startTime = 0;
long lastTime = System.currentTimeMillis();
while (running) {
// 转码
Frame frame = grabber.grab();
if (frame != null && frame.image != null) {
lastTime = System.currentTimeMillis();
recorder.setTimestamp((1000 * (System.currentTimeMillis() - startTime)));
recorder.record(frame);
if (bos.size() > 0) {
byte[] b = bos.toByteArray();
bos.reset();
sendFrameData(b);
continue;
}
}
//10秒内读不到视频帧,则关闭连接
if ((System.currentTimeMillis() / 1000 - lastTime / 1000) > 10) {
System.out.println(currentDevice.getDeviceId() + ":10秒内读不到视频帧");
break;
}
}
} catch (FFmpegFrameRecorder.Exception | FrameGrabber.Exception e) {
throw new RuntimeException(e);
} finally {
try {
recorder.close();
grabber.close();
bos.close();
closeMedia();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
/**
* 发送帧数据
*
* @param data
*/
private void sendFrameData(byte[] data) {
mediaChannel.sendData(data);
}
/**
* 关闭流媒体
*/
private void closeMedia() {
running = false;
MediaServer.deviceContext.remove(currentDevice.getDeviceId());
mediaChannel.closeChannel();
}
/**
* 通知关闭推流
*
* @param device
*/
@Subscribe
public void checkChannel(Device device) {
if (device.getDeviceId().equals(currentDevice.getDeviceId())) {
closeMedia();
System.out.println("关闭推流完成");
}
}
@Override
public void run() {
transferToFlv();
}
}
通过上述方案,我们不仅实现了基于JavaCV和Netty的直播推流复用,还兼顾了安全性、效率和资源优化。此方案特别适合需要高性能、低延迟和大规模用户同时观看直播的场景。
引用文章:Javacv + Netty实现推流直播复用(支持flv+ m3u8)
录播回放实现
将直播的视频流按照固定大小分片存储, 根据请求携带的时间戳,找到最近的分片开始读取,并从指定时间开始回放,在读取完一个分片后自动切换到下一个分片。
@Slf4j
@Data
public class TransferToFlv implements Runnable {
private volatile boolean running = false;
private FFmpegFrameGrabber grabber;
private FFmpegFrameRecorder recorder;
public ByteArrayOutputStream bos = new ByteArrayOutputStream();
private Device currentDevice;
private MediaChannel mediaChannel;
//public ByteArrayOutputStream bosHD = new ByteArrayOutputStream();
//public ByteArrayOutputStream bosSD = new ByteArrayOutputStream();
//public ByteArrayOutputStream bosFHD = new ByteArrayOutputStream();
private long segmentDurationMillis = 60000; // 分片持续时间 1 分钟
public ConcurrentHashMap<String, ChannelHandlerContext> httpClients = new ConcurrentHashMap<>();
/**
* 保存视频分片
*/
public void saveStreamToSegments(String outputDir, long startTimeMillis) {
try {
createGrabber(currentDevice.getRtmpUrl(), startTimeMillis); // 从指定时间戳开始拉流
grabber.start();
long segmentStartTime = System.currentTimeMillis();
int segmentIndex = 0;
while (running) {
String segmentFile = outputDir + "/segment_" + segmentIndex + ".flv";
recorder = new FFmpegFrameRecorder(segmentFile, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels());
recorder.setFormat("flv");
recorder.start();
while (System.currentTimeMillis() - segmentStartTime < segmentDurationMillis && running) {
Frame frame = grabber.grab();
if (frame != null) {
recorder.record(frame);
}
}
recorder.close();
segmentStartTime = System.currentTimeMillis();
segmentIndex++;
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
try {
grabber.close();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
/**
* 获取flv格式header数据
*
* @return
* @throws FFmpegFrameRecorder.Exception
*/
public byte[] getFlvHeader() throws FFmpegFrameRecorder.Exception {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
FFmpegFrameRecorder fFmpegFrameRecorder = new FFmpegFrameRecorder(byteArrayOutputStream, grabber.getImageWidth(), grabber.getImageHeight(),
grabber.getAudioChannels());
setRecorderParams(fFmpegFrameRecorder);
fFmpegFrameRecorder.start();
return byteArrayOutputStream.toByteArray();
}
/**
* 将视频源转换为flv
*/
protected void transferToFlv() {
try {
//创建拉流器
createGrabber(currentDevice.getRtmpUrl(), 0L);
//创建录制器
createTransterOrRecodeRecorder();
grabber.flush();
running = true;
// 时间戳计算
long startTime = 0;
long lastTime = System.currentTimeMillis();
while (running) {
// 转码
Frame frame = grabber.grab();
if (frame != null && frame.image != null) {
lastTime = System.currentTimeMillis();
recorder.setTimestamp((1000 * (System.currentTimeMillis() - startTime)));
recorder.record(frame);
if (bos.size() > 0) {
byte[] b = bos.toByteArray();
bos.reset();
sendFrameData(b);
continue;
}
}
//10秒内读不到视频帧,则关闭连接
if ((System.currentTimeMillis() / 1000 - lastTime / 1000) > 10) {
System.out.println(currentDevice.getDeviceId() + ":10秒内读不到视频帧");
break;
}
}
} catch (FFmpegFrameRecorder.Exception | FrameGrabber.Exception e) {
throw new RuntimeException(e);
} finally {
try {
recorder.close();
grabber.close();
bos.close();
closeMedia();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
/**
* 创建拉流器
*
* @return
*/
protected void createGrabber(String url, long startTimeMillis) throws FFmpegFrameGrabber.Exception {
grabber = new FFmpegFrameGrabber(url);
//拉流超时时间(10秒)
grabber.setOption("stimeout", "10000000");
grabber.setOption("threads", "1");
grabber.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
// 设置缓存大小,提高画质、减少卡顿花屏
grabber.setOption("buffer_size", "1024000");
// 读写超时,适用于所有协议的通用读写超时
grabber.setOption("rw_timeout", "15000000");
if(startTimeMillis > 0) {
grabber.setTimestamp(startTimeMillis * 1000); // FFmpeg的时间戳单位是微秒
}
grabber.start();
}
/**
* 创建录制器
*
* @return
*/
protected void createTransterOrRecodeRecorder() throws FFmpegFrameRecorder.Exception {
recorder = new FFmpegFrameRecorder(bos, grabber.getImageWidth(), grabber.getImageHeight(),
grabber.getAudioChannels());
setRecorderParams(recorder);
recorder.start();
}
/**
* 设置录制器参数
*
* @param fFmpegFrameRecorder
*/
private void setRecorderParams(FFmpegFrameRecorder fFmpegFrameRecorder) {
// 指定输出的容器格式为FLV
fFmpegFrameRecorder.setFormat("flv");
// 控制音频和视频流是否交织在一起。设为false表示不交织,意味着音频和视频流将分别独立存储
fFmpegFrameRecorder.setInterleaved(false);
/*
设置视频编码的额外选项:
"tune":设置编码器优化目标为zerolatency,意味着优化低延迟流传输
"preset":设置编码预置为ultrafast,牺牲一些压缩效率以获得更快的编码速度
"crf":设置恒定速率因子,数值越小质量越高,一般范围是18-28,23是一个常见的平衡点
"threads":设置使用的编码线程数
*/
fFmpegFrameRecorder.setVideoOption("tune", "zerolatency");
fFmpegFrameRecorder.setVideoOption("preset", "ultrafast");
fFmpegFrameRecorder.setVideoOption("crf", "23");
fFmpegFrameRecorder.setVideoOption("threads", "1");
fFmpegFrameRecorder.setFrameRate(25);// 设置帧率,即每秒显示的图像数量
fFmpegFrameRecorder.setGopSize(25);// 设置gop,与帧率相同
//recorder.setVideoBitrate(500 * 1000);// 码率500kb/s
fFmpegFrameRecorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);//设置视频编码器为H.264
fFmpegFrameRecorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);//设置像素格式为YUV420P,用于H.264编码。
fFmpegFrameRecorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);//设置音频编码器为AAC
fFmpegFrameRecorder.setOption("keyint_min", "25"); //gop最小间隔
fFmpegFrameRecorder.setTrellis(1);//控制编码器是否使用trellis量化,设置为1可以稍微提高编码质量。
fFmpegFrameRecorder.setMaxDelay(0);// 设置延迟,设为0表示尽量减少延迟
}
// 从指定时间开始读取分片视频的方法,并自动切换到下一个分片
public void playFromSegments(String segmentsDir, long startTimeMillis) {
try {
long segmentIndex = startTimeMillis / segmentDurationMillis;// 起始分片的索引
long segmentStartOffset = startTimeMillis % segmentDurationMillis;// 起始分片内的偏移量
while (running) {
String segmentFile = segmentsDir + "/segment_" + segmentIndex + ".flv";// 当前分片的文件路径
grabber = new FFmpegFrameGrabber(segmentFile);
grabber.start();
// 创建一个FFmpegFrameRecorder对象,将读取的分片流写入到目标URL
recorder = new FFmpegFrameRecorder(currentDevice.getRtmpUrl(), grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels());
recorder.setFormat("flv");
recorder.start();
// 如果是从指定时间点开始播放的第一个分片,设置grabber的时间戳从segmentStartOffset开始读取
if (segmentIndex == startTimeMillis / segmentDurationMillis) {
grabber.setTimestamp(segmentStartOffset * 1000); // 设置开始时间
}
// 循环读取每一帧,并通过recorder将其录制
while (running && grabber.grabFrame() != null) {
Frame frame = grabber.grabFrame();
if (frame != null) {
// 每次读取帧后输出
recorder.record(frame);
}
}
// 关闭当前分片
recorder.close();
grabber.close();
// 移动到下一个分片
segmentIndex++;
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
try {
if (recorder != null) {
recorder.close();
}
if (grabber != null) {
grabber.close();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
/**
* 发送帧数据
*
* @param data
*/
private void sendFrameData(byte[] data) {
mediaChannel.sendData(data);
}
/**
* 通知关闭推流
*
* @param device
*/
@Subscribe
public void checkChannel(Device device) {
if (device.getDeviceId().equals(currentDevice.getDeviceId())) {
closeMedia();
System.out.println("关闭推流完成");
}
}
/**
* 关闭流媒体
*/
private void closeMedia() {
running = false;
MediaServer.deviceContext.remove(currentDevice.getDeviceId());
mediaChannel.closeChannel();
}
@Override
public void run() {
transferToFlv();
}
}
更新 LiveHandler
类,使其支持按需读取分片并自动切换。
@Service
@ChannelHandler.Sharable
public class LiveHandler extends SimpleChannelInboundHandler<Object> {
/**
* 当Netty接收到一个请求并传递给LiveHandler时,此方法会被调用。
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
// 检查请求是否为FullHttpRequest类
FullHttpRequest req = (FullHttpRequest) msg;
// 解析URL中的查询字符串
QueryStringDecoder decoder = new QueryStringDecoder(req.uri());
// 判断请求uri
if (!"/live".equals(decoder.path())) {
sendError(ctx, HttpResponseStatus.BAD_REQUEST);
return;
}
// 进一步检查请求中是否包含deviceId参数
List<String> parameters = decoder.parameters().get("deviceId");
if(parameters == null || parameters.isEmpty()){
sendError(ctx, HttpResponseStatus.BAD_REQUEST);
return;
}
// 提取deviceId参数值
String deviceId = parameters.get(0);
// 提取开始时间参数值
List<String> startTimes = decoder.parameters().get("startTime");
long startTimeMillis = 0;
if (startTimes != null && !startTimes.isEmpty()) {
try {
startTimeMillis = Long.parseLong(startTimes.get(0));
} catch (NumberFormatException e) {
sendError(ctx, HttpResponseStatus.BAD_REQUEST);
return;
}
}
// 发送FLV格式的响应头
sendFlvResHeader(ctx);
// 处理视频流播放
Device device = new Device(deviceId, MediaServer.YOUR_VIDEO_PATH);
TransferToFlv mediaConvert = new TransferToFlv();
mediaConvert.setCurrentDevice(device);
// 如果指定了开始时间,则启动分片播放线程;否则,启动保存流媒体到分片的线程。
if (startTimeMillis > 0) {
new Thread(() -> mediaConvert.playFromSegments("path/to/segments/dir", startTimeMillis)).start(); //读
} else {
new Thread(() -> mediaConvert.saveStreamToSegments("path/to/segments/dir")).start();//写
}
playForHttp(device, ctx, startTimeMillis);
}
public void playForHttp(Device device, ChannelHandlerContext ctx, long startTimeMillis) {
try {
TransferToFlv mediaConvert = new TransferToFlv();
// 检查设备是否已经存在于上下文中,如果存在,则添加当前HTTP上下文到媒体通道
if (MediaServer.deviceContext.containsKey(device.getDeviceId())) {
mediaConvert = MediaServer.deviceContext.get(device.getDeviceId());
mediaConvert.getMediaChannel().addChannel(ctx, true);
return;
}
mediaConvert.setCurrentDevice(device);
MediaChannel mediaChannel = new MediaChannel(device);
mediaConvert.setMediaChannel(mediaChannel);
MediaServer.deviceContext.put(device.getDeviceId(), mediaConvert);
//注册事件
mediaChannel.getEventBus().register(mediaConvert);
// 如果指定了开始时间,则启动分片播放线程;否则,启动直播线程。
if (startTimeMillis > 0) {
new Thread(() -> mediaConvert.playFromSegments("path/to/segments/dir", startTimeMillis)).start();
} else {
new Thread(mediaConvert).start();
}
// 将当前HTTP上下文添加到媒体通道
mediaConvert.getMediaChannel().addChannel(ctx, false);
} catch (InterruptedException | FFmpegFrameRecorder.Exception e) {
throw new RuntimeException(e);
}
}
/**
* 错误请求响应
* @param ctx
* @param status
*/
private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status,
Unpooled.copiedBuffer("请求地址有误: " + status + "\r\n", CharsetUtil.UTF_8));
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
/**
* 发送req header,告知浏览器是flv格式
* @param ctx
*/
private void sendFlvResHeader(ChannelHandlerContext ctx) {
HttpResponse rsp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
rsp.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE)
.set(HttpHeaderNames.CONTENT_TYPE, "video/x-flv").set(HttpHeaderNames.ACCEPT_RANGES, "bytes")
.set(HttpHeaderNames.PRAGMA, "no-cache").set(HttpHeaderNames.CACHE_CONTROL, "no-cache")
.set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED).set(HttpHeaderNames.SERVER, "测试");
ctx.writeAndFlush(rsp);
}
}
这样,客户端在请求时可以通过添加 startTime
参数来指定视频的开始播放时间。如果没有指定 startTime
参数,则开始保存直播视频流。如果指定了 startTime
参数,则从保存的视频文件中读取数据并开始播放。