JavaCV + Netty实现推流直播复用、录播回放

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的这种技术特点,决定了它的延迟一般总是会高于普通的流媒体直播协议。

以监控摄像头的直播为例,通常分为三步:

  1. 从设备获取音视频流

  2. 利用javacv进行解码(例如flv或m3u8)

  3. 将视频解码后数据推送到前端页面播放

  4. 在此基础上还需要保证推流直播复用,如果该设备某个客户端已经在解码直播了,其他客户端只需要拿该设备解码后的视频帧数据进行播放即可,而无需重复上面三步,实现一次解码,多客户端播放。

为什么不让前端直接对接设备厂商提供的API去实现呢,有几个方面的考虑:

  1. 安全性与授权:可以避免将敏感的API密钥或设备访问凭证暴露给前端,从而提高系统的整体安全性。

  2. 负载均衡与故障转移:服务端可以处理多个前端用户的请求,并智能地分配视频流资源,确保即使在高并发场景下也能提供稳定的服务。此外,后端还可以实施故障转移策略,当某个视频源不可用时自动切换到备用源。

  3. 缓存与优化:对于重复的视频流请求,服务端可以实施缓存策略,减少对源服务器的直接请求,从而降低带宽成本并提高响应速度。

实时播放实现

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 参数,则从保存的视频文件中读取数据并开始播放。

### OmegaConf 配置管理简介 OmegaConf 是一个强大的 Python 库,用于处理复杂的配置文件。它支持多种数据结构(如字典、列表)以及 YAML 文件的解析和操作。以下是有关如何使用 OmegaConf 的详细介绍。 #### 创建配置对象 可以通过 `OmegaConf.create` 方法创建一个新的配置对象。该方法可以接受字典、YAML 字符串或其他兼容的数据结构作为输入[^1]。 ```python import omegaconf from omegaconf import OmegaConf config_dict = {"database": {"host": "localhost", "port": 6379}} config = OmegaConf.create(config_dict) print(OmegaConf.to_yaml(config)) # 将配置转换为 YAML 格式的字符串 ``` #### 加载外部 YAML 文件 如果需要加载外部 YAML 文件,可使用 `OmegaConf.load` 方法。这使得程序能够轻松读取并应用存储在磁盘上的配置文件。 ```python yaml_file_path = "./example_config.yaml" file_conf = OmegaConf.load(yaml_file_path) # 打印加载后的配置内容 print(file_conf.database.host) # 输出 'localhost' ``` #### 合并多个配置源 当存在多个配置来源时(例如默认设置与命令行参数),可以使用 `OmegaConf.merge` 来无缝合并它们。此功能允许开发者优先级较高的配置覆盖较低级别的配置项。 ```python default_configs = OmegaConf.create({"model": {"type": "resnet50"}}) cli_args = OmegaConf.from_dotlist(["model.type=vgg16"]) merged_config = OmegaConf.merge(default_configs, cli_args) assert merged_config.model.type == "vgg16" # 命令行参数成功覆盖默认值 ``` #### 动态更新配置 除了静态定义外,还可以通过访问器动态修改现有配置中的字段。这种灵活性非常适合运行时调整某些超参数或环境变量。 ```python dynamic_update = file_conf.copy() dynamic_update.database.port = 8080 print(dynamic_update.database.port) # 输出新的端口号 8080 ``` #### 错误处理机制 为了防止非法赋值破坏整个系统的稳定性,OmegaConf 提供了严格的模式控制选项。启用严格模式后,任何未声明过的键都将引发异常提示用户修正错误。 ```python strict_mode_enabled = file_conf.copy() strict_mode_enabled.set_struct(True) # 开启只读保护状态 try: strict_mode_enabled.new_field = True # 此处会抛出 AttributeError 异常 except AttributeError as e: print(f"Catch expected error: {e}") ``` --- ### 总结 以上展示了 OmegaConf 在不同场景下的典型用法,包括但不限于初始化配置实例、加载外部资源、融合多层设定逻辑以及实施安全防护措施等方面的功能特性。希望这些例子能帮助快速掌握其核心概念和技术要点!
评论 14
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值