Commit 487953271aaf5f6ca3aa89f5d919f3aeed00872c
1 parent
f375c1c2
fix():添加RTMP推流(未完成)
Showing
21 changed files
with
4225 additions
and
78 deletions
Too many changes to show.
To preserve performance only 21 of 25 files are displayed.
.claude/settings.local.json
0 → 100644
src/main/java/com/genersoft/iot/vmp/jtt1078/publisher/Channel.java
| 1 | package com.genersoft.iot.vmp.jtt1078.publisher; | 1 | package com.genersoft.iot.vmp.jtt1078.publisher; |
| 2 | 2 | ||
| 3 | import com.genersoft.iot.vmp.jtt1078.codec.AudioCodec; | 3 | import com.genersoft.iot.vmp.jtt1078.codec.AudioCodec; |
| 4 | +import com.genersoft.iot.vmp.jtt1078.codec.MP3Encoder; | ||
| 4 | import com.genersoft.iot.vmp.jtt1078.entity.Media; | 5 | import com.genersoft.iot.vmp.jtt1078.entity.Media; |
| 5 | import com.genersoft.iot.vmp.jtt1078.entity.MediaEncoding; | 6 | import com.genersoft.iot.vmp.jtt1078.entity.MediaEncoding; |
| 7 | +import com.genersoft.iot.vmp.jtt1078.flv.AudioTag; | ||
| 8 | +import com.genersoft.iot.vmp.jtt1078.flv.FlvAudioTagEncoder; | ||
| 6 | import com.genersoft.iot.vmp.jtt1078.flv.FlvEncoder; | 9 | import com.genersoft.iot.vmp.jtt1078.flv.FlvEncoder; |
| 7 | -import com.genersoft.iot.vmp.jtt1078.subscriber.RTMPPublisher; | ||
| 8 | import com.genersoft.iot.vmp.jtt1078.subscriber.Subscriber; | 10 | import com.genersoft.iot.vmp.jtt1078.subscriber.Subscriber; |
| 9 | import com.genersoft.iot.vmp.jtt1078.subscriber.VideoSubscriber; | 11 | import com.genersoft.iot.vmp.jtt1078.subscriber.VideoSubscriber; |
| 12 | +import com.genersoft.iot.vmp.jtt1078.util.ByteBufUtils; | ||
| 10 | import com.genersoft.iot.vmp.jtt1078.util.ByteHolder; | 13 | import com.genersoft.iot.vmp.jtt1078.util.ByteHolder; |
| 11 | import com.genersoft.iot.vmp.jtt1078.util.Configs; | 14 | import com.genersoft.iot.vmp.jtt1078.util.Configs; |
| 15 | +import com.genersoft.iot.vmp.jtt1078.util.FLVUtils; | ||
| 16 | +import io.netty.buffer.ByteBuf; | ||
| 12 | import io.netty.channel.ChannelHandlerContext; | 17 | import io.netty.channel.ChannelHandlerContext; |
| 13 | import org.apache.commons.lang3.StringUtils; | 18 | import org.apache.commons.lang3.StringUtils; |
| 14 | import org.slf4j.Logger; | 19 | import org.slf4j.Logger; |
| @@ -26,8 +31,8 @@ public class Channel | @@ -26,8 +31,8 @@ public class Channel | ||
| 26 | 31 | ||
| 27 | ConcurrentLinkedQueue<Subscriber> subscribers; | 32 | ConcurrentLinkedQueue<Subscriber> subscribers; |
| 28 | 33 | ||
| 29 | - // [恢复] FFmpeg 推流进程管理器 | ||
| 30 | - RTMPPublisher rtmpPublisher; | 34 | + // [修改] 使用IStreamPublisher接口,通过工厂创建具体的推流器 |
| 35 | + IStreamPublisher streamPublisher; | ||
| 31 | 36 | ||
| 32 | String tag; | 37 | String tag; |
| 33 | boolean publishing; | 38 | boolean publishing; |
| @@ -36,6 +41,14 @@ public class Channel | @@ -36,6 +41,14 @@ public class Channel | ||
| 36 | FlvEncoder flvEncoder; | 41 | FlvEncoder flvEncoder; |
| 37 | private long firstTimestamp = -1; | 42 | private long firstTimestamp = -1; |
| 38 | 43 | ||
| 44 | + /** 相对时间戳(用于RTMP发送) - 与jtt1078-video-server保持一致 */ | ||
| 45 | + private int videoTimestamp = 0; | ||
| 46 | + private long lastVideoTimeOffset = -1; // 初始值-1表示未初始化,避免与有效timeoffset混淆 | ||
| 47 | + | ||
| 48 | + /** 相对时间戳(用于RTMP发送音频) */ | ||
| 49 | + private int audioTimestamp = 0; | ||
| 50 | + private long lastAudioTimeOffset = -1; // 初始值-1表示未初始化,避免与有效timeoffset混淆 | ||
| 51 | + | ||
| 39 | // ========== 新增:视频参数检测和FFmpeg自动重启相关 ========== | 52 | // ========== 新增:视频参数检测和FFmpeg自动重启相关 ========== |
| 40 | 53 | ||
| 41 | /** 上一次接收到的SPS哈希值,用于检测视频参数变化 */ | 54 | /** 上一次接收到的SPS哈希值,用于检测视频参数变化 */ |
| @@ -56,6 +69,16 @@ public class Channel | @@ -56,6 +69,16 @@ public class Channel | ||
| 56 | /** 视频参数信息 */ | 69 | /** 视频参数信息 */ |
| 57 | private volatile VideoParamInfo currentVideoParam = new VideoParamInfo(); | 70 | private volatile VideoParamInfo currentVideoParam = new VideoParamInfo(); |
| 58 | 71 | ||
| 72 | + /** 是否已发送视频Sequence Header(用于RTMP推流) */ | ||
| 73 | + private boolean videoHeaderSentForRtmp = false; | ||
| 74 | + | ||
| 75 | + /** 是否已发送FLV Header(用于RTMP推流) */ | ||
| 76 | + private boolean flvHeaderSentForRtmp = false; | ||
| 77 | + | ||
| 78 | + /** 音频编码器(用于RTMP推流) */ | ||
| 79 | + private MP3Encoder mp3Encoder; | ||
| 80 | + private FlvAudioTagEncoder audioTagEncoder; | ||
| 81 | + | ||
| 59 | /** 视频参数内部类 */ | 82 | /** 视频参数内部类 */ |
| 60 | private static class VideoParamInfo { | 83 | private static class VideoParamInfo { |
| 61 | int width = 0; | 84 | int width = 0; |
| @@ -76,14 +99,16 @@ public class Channel | @@ -76,14 +99,16 @@ public class Channel | ||
| 76 | this.flvEncoder = new FlvEncoder(true, true); | 99 | this.flvEncoder = new FlvEncoder(true, true); |
| 77 | this.buffer = new ByteHolder(2048 * 100); | 100 | this.buffer = new ByteHolder(2048 * 100); |
| 78 | 101 | ||
| 79 | - // [恢复] 启动 FFmpeg 进程 | ||
| 80 | - // 只要配置了 rtmp.url,就启动 FFmpeg 去拉取当前的 HTTP 流并转码推送 | 102 | + // [修改] 使用工厂创建推流器 |
| 103 | + // 根据配置决定创建FFmpeg推流器还是原生RTMP推流器 | ||
| 81 | String rtmpUrl = Configs.get("rtmp.url"); | 104 | String rtmpUrl = Configs.get("rtmp.url"); |
| 82 | if (StringUtils.isNotBlank(rtmpUrl)) | 105 | if (StringUtils.isNotBlank(rtmpUrl)) |
| 83 | { | 106 | { |
| 84 | - logger.info("[{}] 启动 FFmpeg 进程推流至: {}", tag, rtmpUrl); | ||
| 85 | - rtmpPublisher = new RTMPPublisher(tag); | ||
| 86 | - rtmpPublisher.start(); | 107 | + logger.info("[{}] 使用推流器工厂创建推流器...", tag); |
| 108 | + streamPublisher = StreamPublisherFactory.create(tag); | ||
| 109 | + logger.info("[{}] 推流器类型: {}", tag, streamPublisher.getType()); | ||
| 110 | + logger.info("[{}] 推流器描述: {}", tag, StreamPublisherFactory.getPublisherTypeDescription()); | ||
| 111 | + streamPublisher.start(tag); | ||
| 87 | } | 112 | } |
| 88 | } | 113 | } |
| 89 | 114 | ||
| @@ -113,10 +138,19 @@ public class Channel | @@ -113,10 +138,19 @@ public class Channel | ||
| 113 | 138 | ||
| 114 | public void writeVideo(long sequence, long timeoffset, int payloadType, byte[] h264) | 139 | public void writeVideo(long sequence, long timeoffset, int payloadType, byte[] h264) |
| 115 | { | 140 | { |
| 116 | - if (firstTimestamp == -1) firstTimestamp = timeoffset; | 141 | + if (firstTimestamp == -1) { |
| 142 | + firstTimestamp = timeoffset; | ||
| 143 | + logger.info("[{}] writeVideo: first timestamp set to {}, h264len={}", tag, timeoffset, h264.length); | ||
| 144 | + } | ||
| 117 | this.publishing = true; | 145 | this.publishing = true; |
| 118 | this.buffer.write(h264); | 146 | this.buffer.write(h264); |
| 119 | 147 | ||
| 148 | + // 调试日志 | ||
| 149 | + if (logger.isDebugEnabled()) { | ||
| 150 | + logger.debug("[{}] writeVideo called: seq={}, time={}, payloadType={}, h264len={}, buffer.size={}", | ||
| 151 | + tag, sequence, timeoffset, payloadType, h264.length, buffer.size()); | ||
| 152 | + } | ||
| 153 | + | ||
| 120 | while (true) | 154 | while (true) |
| 121 | { | 155 | { |
| 122 | byte[] nalu = readNalu(); | 156 | byte[] nalu = readNalu(); |
| @@ -138,6 +172,8 @@ public class Channel | @@ -138,6 +172,8 @@ public class Channel | ||
| 138 | waitingForIFrame.set(false); | 172 | waitingForIFrame.set(false); |
| 139 | // 重置FLV编码器 | 173 | // 重置FLV编码器 |
| 140 | this.flvEncoder = new FlvEncoder(true, true); | 174 | this.flvEncoder = new FlvEncoder(true, true); |
| 175 | + this.videoHeaderSentForRtmp = false; | ||
| 176 | + this.flvHeaderSentForRtmp = false; | ||
| 141 | firstTimestamp = timeoffset; | 177 | firstTimestamp = timeoffset; |
| 142 | } else { | 178 | } else { |
| 143 | // 跳过非I帧数据 | 179 | // 跳过非I帧数据 |
| @@ -149,7 +185,12 @@ public class Channel | @@ -149,7 +185,12 @@ public class Channel | ||
| 149 | // FFmpeg 通过 HTTP 读取这些 FLV Tag | 185 | // FFmpeg 通过 HTTP 读取这些 FLV Tag |
| 150 | byte[] flvTag = this.flvEncoder.write(nalu, (int) (timeoffset - firstTimestamp)); | 186 | byte[] flvTag = this.flvEncoder.write(nalu, (int) (timeoffset - firstTimestamp)); |
| 151 | 187 | ||
| 152 | - if (flvTag == null) continue; | 188 | + if (flvTag == null) { |
| 189 | + logger.debug("[{}] FlvEncoder.write returned null, skipping. nalType=0x{}", tag, Integer.toHexString(nalType)); | ||
| 190 | + continue; | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + logger.debug("[{}] FlvEncoder.write returned flvTag length={}, nalType=0x{}", tag, flvTag.length, Integer.toHexString(nalType)); | ||
| 153 | 194 | ||
| 154 | // 广播给所有的观众 | 195 | // 广播给所有的观众 |
| 155 | broadcastVideo(timeoffset, flvTag); | 196 | broadcastVideo(timeoffset, flvTag); |
| @@ -157,59 +198,39 @@ public class Channel | @@ -157,59 +198,39 @@ public class Channel | ||
| 157 | } | 198 | } |
| 158 | 199 | ||
| 159 | /** | 200 | /** |
| 160 | - * 检查SPS变化并处理FFmpeg重启 | 201 | + * 检查SPS变化并处理推流器 |
| 202 | + * | ||
| 203 | + * 根据推流器类型决定处理方式: | ||
| 204 | + * - FFmpeg推流器:重启FFmpeg进程(因为FFmpeg使用-vcodec copy无法感知SPS变化) | ||
| 205 | + * - 原生RTMP推流器:立即发送新的AVC Sequence Header(无缝切换,0中断) | ||
| 161 | */ | 206 | */ |
| 162 | private synchronized void checkAndHandleSPSChange(byte[] nalu) { | 207 | private synchronized void checkAndHandleSPSChange(byte[] nalu) { |
| 163 | int currentHash = Arrays.hashCode(nalu); | 208 | int currentHash = Arrays.hashCode(nalu); |
| 164 | 209 | ||
| 165 | if (lastSPSHash != 0 && currentHash != lastSPSHash) { | 210 | if (lastSPSHash != 0 && currentHash != lastSPSHash) { |
| 166 | // SPS变化了,说明视频参数发生了变化 | 211 | // SPS变化了,说明视频参数发生了变化 |
| 167 | - // 注意:即使解析出来的分辨率/帧率相同,SPS的其它变化(如profile、level、VUI参数等)也会导致解码问题 | ||
| 168 | - // 因此:只要SPS哈希变化,就重启FFmpeg | ||
| 169 | 212 | ||
| 170 | // 解析新的视频参数(仅用于日志显示) | 213 | // 解析新的视频参数(仅用于日志显示) |
| 171 | VideoParamInfo newParam = parseVideoParam(nalu); | 214 | VideoParamInfo newParam = parseVideoParam(nalu); |
| 172 | VideoParamInfo oldParam = currentVideoParam; | 215 | VideoParamInfo oldParam = currentVideoParam; |
| 173 | 216 | ||
| 174 | - logger.info("[{}] ====== 检测到SPS变化,需要重启FFmpeg ======", tag); | 217 | + logger.info("[{}] ====== 检测到SPS变化 ======", tag); |
| 175 | logger.info("[{}] 旧参数: {}", tag, oldParam); | 218 | logger.info("[{}] 旧参数: {}", tag, oldParam); |
| 176 | logger.info("[{}] 新参数: {}", tag, newParam); | 219 | logger.info("[{}] 新参数: {}", tag, newParam); |
| 177 | logger.info("[{}] SPS哈希变化: {} -> {}", tag, | 220 | logger.info("[{}] SPS哈希变化: {} -> {}", tag, |
| 178 | Integer.toHexString(lastSPSHash), | 221 | Integer.toHexString(lastSPSHash), |
| 179 | Integer.toHexString(currentHash)); | 222 | Integer.toHexString(currentHash)); |
| 180 | 223 | ||
| 181 | - // 检查冷却时间 | ||
| 182 | - long now = System.currentTimeMillis(); | ||
| 183 | - long timeSinceLastRestart = now - lastRestartTime.get(); | 224 | + // 判断推流器类型 |
| 225 | + boolean isNativeRtmp = (streamPublisher != null && "native".equals(streamPublisher.getType())); | ||
| 184 | 226 | ||
| 185 | - if (timeSinceLastRestart < RESTART_COOLDOWN_MS) { | ||
| 186 | - logger.warn("[{}] FFmpeg重启过于频繁(距上次{}ms),跳过本次重启", | ||
| 187 | - tag, timeSinceLastRestart); | ||
| 188 | - // 即使跳过重启,仍需更新SPS哈希 | ||
| 189 | - lastSPSHash = currentHash; | ||
| 190 | - currentVideoParam = newParam; | ||
| 191 | - return; | 227 | + if (isNativeRtmp) { |
| 228 | + // 原生RTMP模式:立即发送新的AVC Sequence Header(无缝切换) | ||
| 229 | + handleSPSChangeForNativeRtmp(nalu, newParam); | ||
| 230 | + } else { | ||
| 231 | + // FFmpeg模式:重启FFmpeg进程 | ||
| 232 | + handleSPSChangeForFFmpeg(newParam); | ||
| 192 | } | 233 | } |
| 193 | - | ||
| 194 | - logger.info("[{}] ====== 准备重启FFmpeg ======", tag); | ||
| 195 | - logger.info("[{}] 原因: SPS参数变化(可能包含分辨率、帧率、profile、level等)", tag); | ||
| 196 | - logger.info("[{}] 预计中断时间: 1-2秒", tag); | ||
| 197 | - | ||
| 198 | - // 标记需要重启 | ||
| 199 | - ffmpegNeedRestart.set(true); | ||
| 200 | - | ||
| 201 | - // 立即触发重启(异步执行,避免阻塞) | ||
| 202 | - final String currentTag = tag; | ||
| 203 | - Thread restartThread = new Thread(() -> { | ||
| 204 | - try { | ||
| 205 | - logger.info("[{}] SPS变化检测到,立即触发FFmpeg重启...", currentTag); | ||
| 206 | - restartRtmpPublisher(); | ||
| 207 | - } catch (Exception e) { | ||
| 208 | - logger.error("[{}] 触发FFmpeg重启失败: {}", currentTag, e.getMessage(), e); | ||
| 209 | - } | ||
| 210 | - }, "FFmpeg-Restart-" + tag); | ||
| 211 | - restartThread.setDaemon(true); | ||
| 212 | - restartThread.start(); | ||
| 213 | } | 234 | } |
| 214 | 235 | ||
| 215 | // 更新SPS哈希和视频参数 | 236 | // 更新SPS哈希和视频参数 |
| @@ -218,6 +239,104 @@ public class Channel | @@ -218,6 +239,104 @@ public class Channel | ||
| 218 | } | 239 | } |
| 219 | 240 | ||
| 220 | /** | 241 | /** |
| 242 | + * 处理SPS变化(原生RTMP模式) | ||
| 243 | + * 立即发送新的AVC Sequence Header,实现无缝切换 | ||
| 244 | + */ | ||
| 245 | + private void handleSPSChangeForNativeRtmp(byte[] nalu, VideoParamInfo newParam) { | ||
| 246 | + logger.info("[{}] ====== 原生RTMP模式:立即发送新的AVC Sequence Header ======", tag); | ||
| 247 | + logger.info("[{}] 码流切换将无缝完成,无需中断", tag); | ||
| 248 | + | ||
| 249 | + // 检查冷却时间 | ||
| 250 | + long now = System.currentTimeMillis(); | ||
| 251 | + long timeSinceLastRestart = now - lastRestartTime.get(); | ||
| 252 | + | ||
| 253 | + if (timeSinceLastRestart < RESTART_COOLDOWN_MS) { | ||
| 254 | + logger.warn("[{}] 发送AVC Sequence Header过于频繁(距上次{}ms),跳过", | ||
| 255 | + tag, timeSinceLastRestart); | ||
| 256 | + return; | ||
| 257 | + } | ||
| 258 | + | ||
| 259 | + if (streamPublisher != null && streamPublisher.isConnected()) { | ||
| 260 | + // 提取SPS和PPS(从FlvEncoder获取) | ||
| 261 | + byte[] sps = extractSPSFromNalu(nalu); | ||
| 262 | + byte[] pps = extractPPSFromBuffer(); | ||
| 263 | + | ||
| 264 | + // 更新推流器的SPS/PPS缓存 | ||
| 265 | + if (streamPublisher instanceof NativeRtmpPublisher) { | ||
| 266 | + ((NativeRtmpPublisher) streamPublisher).updateSPSPPS(sps, pps); | ||
| 267 | + } | ||
| 268 | + | ||
| 269 | + // 触发发送新的AVC Sequence Header | ||
| 270 | + streamPublisher.sendAVCSequenceHeader(); | ||
| 271 | + lastRestartTime.set(now); | ||
| 272 | + | ||
| 273 | + logger.info("[{}] ====== AVC Sequence Header已发送,码流切换完成 ======", tag); | ||
| 274 | + } else { | ||
| 275 | + logger.warn("[{}] 推流器未连接,无法发送AVC Sequence Header", tag); | ||
| 276 | + } | ||
| 277 | + } | ||
| 278 | + | ||
| 279 | + /** | ||
| 280 | + * 处理SPS变化(FFmpeg模式) | ||
| 281 | + * 重启FFmpeg进程 | ||
| 282 | + */ | ||
| 283 | + private void handleSPSChangeForFFmpeg(VideoParamInfo newParam) { | ||
| 284 | + // 检查冷却时间 | ||
| 285 | + long now = System.currentTimeMillis(); | ||
| 286 | + long timeSinceLastRestart = now - lastRestartTime.get(); | ||
| 287 | + | ||
| 288 | + if (timeSinceLastRestart < RESTART_COOLDOWN_MS) { | ||
| 289 | + logger.warn("[{}] FFmpeg重启过于频繁(距上次{}ms),跳过本次重启", | ||
| 290 | + tag, timeSinceLastRestart); | ||
| 291 | + return; | ||
| 292 | + } | ||
| 293 | + | ||
| 294 | + logger.info("[{}] ====== FFmpeg模式:准备重启FFmpeg ======", tag); | ||
| 295 | + logger.info("[{}] 原因: SPS参数变化(可能包含分辨率、帧率、profile、level等)", tag); | ||
| 296 | + logger.info("[{}] 预计中断时间: 1-2秒", tag); | ||
| 297 | + | ||
| 298 | + // 标记需要重启 | ||
| 299 | + ffmpegNeedRestart.set(true); | ||
| 300 | + | ||
| 301 | + // 立即触发重启(异步执行,避免阻塞) | ||
| 302 | + final String currentTag = tag; | ||
| 303 | + Thread restartThread = new Thread(() -> { | ||
| 304 | + try { | ||
| 305 | + logger.info("[{}] SPS变化检测到,立即触发FFmpeg重启...", currentTag); | ||
| 306 | + restartRtmpPublisher(); | ||
| 307 | + } catch (Exception e) { | ||
| 308 | + logger.error("[{}] 触发FFmpeg重启失败: {}", currentTag, e.getMessage(), e); | ||
| 309 | + } | ||
| 310 | + }, "FFmpeg-Restart-" + tag); | ||
| 311 | + restartThread.setDaemon(true); | ||
| 312 | + restartThread.start(); | ||
| 313 | + } | ||
| 314 | + | ||
| 315 | + /** | ||
| 316 | + * 从NALU中提取SPS | ||
| 317 | + */ | ||
| 318 | + private byte[] extractSPSFromNalu(byte[] nalu) { | ||
| 319 | + // NALU格式: [00 00 00 01 xx] + SPS数据 | ||
| 320 | + // SPS从第5个字节开始 | ||
| 321 | + if (nalu.length > 5) { | ||
| 322 | + byte[] sps = new byte[nalu.length - 4]; | ||
| 323 | + System.arraycopy(nalu, 4, sps, 0, sps.length); | ||
| 324 | + return sps; | ||
| 325 | + } | ||
| 326 | + return nalu; | ||
| 327 | + } | ||
| 328 | + | ||
| 329 | + /** | ||
| 330 | + * 从buffer中提取PPS | ||
| 331 | + * 注意:这是一个简化实现,实际可能需要更复杂的逻辑 | ||
| 332 | + */ | ||
| 333 | + private byte[] extractPPSFromBuffer() { | ||
| 334 | + // 在实际实现中,应该从buffer中查找PPS NALU(NAL类型为8) | ||
| 335 | + // 这里返回null,让chunkWriter使用缓存的PPS | ||
| 336 | + return null; | ||
| 337 | + } | ||
| 338 | + | ||
| 339 | + /** | ||
| 221 | * 解析H.264 SPS获取视频参数 | 340 | * 解析H.264 SPS获取视频参数 |
| 222 | */ | 341 | */ |
| 223 | private VideoParamInfo parseVideoParam(byte[] sps) { | 342 | private VideoParamInfo parseVideoParam(byte[] sps) { |
| @@ -332,15 +451,15 @@ public class Channel | @@ -332,15 +451,15 @@ public class Channel | ||
| 332 | // 1. 标记等待I帧 | 451 | // 1. 标记等待I帧 |
| 333 | waitingForIFrame.set(true); | 452 | waitingForIFrame.set(true); |
| 334 | 453 | ||
| 335 | - // 2. 关闭旧的FFmpeg进程 | ||
| 336 | - logger.info("[{}] 步骤1: 关闭旧FFmpeg进程...", tag); | ||
| 337 | - if (rtmpPublisher != null) { | 454 | + // 2. 关闭旧的推流器 |
| 455 | + logger.info("[{}] 步骤1: 关闭旧推流器...", tag); | ||
| 456 | + if (streamPublisher != null) { | ||
| 338 | try { | 457 | try { |
| 339 | - rtmpPublisher.close(); | 458 | + streamPublisher.close(); |
| 340 | } catch (Exception e) { | 459 | } catch (Exception e) { |
| 341 | - logger.warn("[{}] 关闭旧FFmpeg进程时出错: {}", tag, e.getMessage()); | 460 | + logger.warn("[{}] 关闭旧推流器时出错: {}", tag, e.getMessage()); |
| 342 | } | 461 | } |
| 343 | - rtmpPublisher = null; | 462 | + streamPublisher = null; |
| 344 | } | 463 | } |
| 345 | 464 | ||
| 346 | // 3. 等待FFmpeg进程完全关闭 | 465 | // 3. 等待FFmpeg进程完全关闭 |
| @@ -351,18 +470,25 @@ public class Channel | @@ -351,18 +470,25 @@ public class Channel | ||
| 351 | logger.info("[{}] 步骤3: 清空视频缓冲区...", tag); | 470 | logger.info("[{}] 步骤3: 清空视频缓冲区...", tag); |
| 352 | buffer.clear(); | 471 | buffer.clear(); |
| 353 | firstTimestamp = -1; | 472 | firstTimestamp = -1; |
| 473 | + // 重置相对时间戳 | ||
| 474 | + videoTimestamp = 0; | ||
| 475 | + lastVideoTimeOffset = -1; | ||
| 476 | + audioTimestamp = 0; | ||
| 477 | + lastAudioTimeOffset = -1; | ||
| 354 | 478 | ||
| 355 | // 5. 重置FLV编码器 | 479 | // 5. 重置FLV编码器 |
| 356 | logger.info("[{}] 步骤4: 重置FLV编码器...", tag); | 480 | logger.info("[{}] 步骤4: 重置FLV编码器...", tag); |
| 357 | flvEncoder = new FlvEncoder(true, true); | 481 | flvEncoder = new FlvEncoder(true, true); |
| 482 | + videoHeaderSentForRtmp = false; | ||
| 483 | + flvHeaderSentForRtmp = false; | ||
| 358 | 484 | ||
| 359 | - // 6. 重新启动FFmpeg进程 | ||
| 360 | - logger.info("[{}] 步骤5: 启动新FFmpeg进程...", tag); | 485 | + // 6. 重新启动推流器 |
| 486 | + logger.info("[{}] 步骤5: 启动新推流器...", tag); | ||
| 361 | String rtmpUrl = Configs.get("rtmp.url"); | 487 | String rtmpUrl = Configs.get("rtmp.url"); |
| 362 | if (StringUtils.isNotBlank(rtmpUrl)) { | 488 | if (StringUtils.isNotBlank(rtmpUrl)) { |
| 363 | - rtmpPublisher = new RTMPPublisher(tag); | ||
| 364 | - rtmpPublisher.start(); | ||
| 365 | - logger.info("[{}] 新FFmpeg进程已启动", tag); | 489 | + streamPublisher = StreamPublisherFactory.create(tag); |
| 490 | + streamPublisher.start(tag); | ||
| 491 | + logger.info("[{}] 新推流器已启动, 类型: {}", tag, streamPublisher.getType()); | ||
| 366 | } else { | 492 | } else { |
| 367 | logger.warn("[{}] 未配置rtmp.url,跳过启动", tag); | 493 | logger.warn("[{}] 未配置rtmp.url,跳过启动", tag); |
| 368 | } | 494 | } |
| @@ -374,11 +500,11 @@ public class Channel | @@ -374,11 +500,11 @@ public class Channel | ||
| 374 | logger.info("[{}] 请等待1-2秒让FFmpeg完成初始化...", tag); | 500 | logger.info("[{}] 请等待1-2秒让FFmpeg完成初始化...", tag); |
| 375 | 501 | ||
| 376 | } catch (Exception e) { | 502 | } catch (Exception e) { |
| 377 | - logger.error("[{}] 重启FFmpeg失败: {}", tag, e.getMessage(), e); | 503 | + logger.error("[{}] 重启推流器失败: {}", tag, e.getMessage(), e); |
| 378 | waitingForIFrame.set(false); | 504 | waitingForIFrame.set(false); |
| 379 | ffmpegNeedRestart.set(false); | 505 | ffmpegNeedRestart.set(false); |
| 380 | - // 确保rtmpPublisher被清空 | ||
| 381 | - rtmpPublisher = null; | 506 | + // 确保streamPublisher被清空 |
| 507 | + streamPublisher = null; | ||
| 382 | } | 508 | } |
| 383 | } | 509 | } |
| 384 | 510 | ||
| @@ -433,6 +559,72 @@ public class Channel | @@ -433,6 +559,72 @@ public class Channel | ||
| 433 | 559 | ||
| 434 | public void broadcastVideo(long timeoffset, byte[] flvTag) | 560 | public void broadcastVideo(long timeoffset, byte[] flvTag) |
| 435 | { | 561 | { |
| 562 | + // ========== 计算相对时间戳(与jtt1078-video-server保持一致)========== | ||
| 563 | + // 初始化lastVideoTimeOffset | ||
| 564 | + if (lastVideoTimeOffset == -1) { | ||
| 565 | + // 首次设置,不累加时间戳 | ||
| 566 | + lastVideoTimeOffset = timeoffset; | ||
| 567 | + } else { | ||
| 568 | + // 计算增量并累加到videoTimestamp | ||
| 569 | + videoTimestamp += (int)(timeoffset - lastVideoTimeOffset); | ||
| 570 | + lastVideoTimeOffset = timeoffset; | ||
| 571 | + } | ||
| 572 | + | ||
| 573 | + // ========== 发送Sequence Header(仅RTMP推流需要)========== | ||
| 574 | + // 与jtt1078-video-server的VideoSubscriber保持一致: | ||
| 575 | + // 当videoReady()首次为true时,发送Sequence Header FLV tag(时间戳为0) | ||
| 576 | + if (flvEncoder.videoReady()) { | ||
| 577 | + // 先发送 FLV Header(与jtt1078-video-server一致) | ||
| 578 | + if (!flvHeaderSentForRtmp) { | ||
| 579 | + byte[] flvHeader = flvEncoder.getHeader().getBytes(); | ||
| 580 | + logger.info("[{}] >>> [RTMP发送] FLV Header, length={}, streamPublisher={}, connected={}", | ||
| 581 | + tag, flvHeader != null ? flvHeader.length : 0, | ||
| 582 | + streamPublisher != null, streamPublisher != null && streamPublisher.isConnected()); | ||
| 583 | + if (flvHeader != null && flvHeader.length > 0 && streamPublisher != null && streamPublisher.isConnected()) { | ||
| 584 | + streamPublisher.sendVideoData(flvHeader, 0); | ||
| 585 | + logger.info("[{}] >>> [RTMP发送] FLV Header 已发送", tag); | ||
| 586 | + } | ||
| 587 | + flvHeaderSentForRtmp = true; | ||
| 588 | + } | ||
| 589 | + | ||
| 590 | + // 再发送 Video Header (AVC Sequence Header) | ||
| 591 | + if (!videoHeaderSentForRtmp) { | ||
| 592 | + byte[] videoHeader = flvEncoder.getVideoHeader().getBytes(); | ||
| 593 | + logger.info("[{}] >>> [RTMP发送] Video Header (AVC Sequence Header), length={}, videoReady={}", | ||
| 594 | + tag, videoHeader != null ? videoHeader.length : 0, flvEncoder.videoReady()); | ||
| 595 | + if (videoHeader != null && videoHeader.length > 0) { | ||
| 596 | + FLVUtils.resetTimestamp(videoHeader, 0); | ||
| 597 | + if (streamPublisher != null && streamPublisher.isConnected()) { | ||
| 598 | + streamPublisher.sendVideoData(videoHeader, 0); | ||
| 599 | + logger.info("[{}] >>> [RTMP发送] Video Header 发送完成", tag); | ||
| 600 | + } | ||
| 601 | + } | ||
| 602 | + videoHeaderSentForRtmp = true; | ||
| 603 | + } | ||
| 604 | + } | ||
| 605 | + | ||
| 606 | + // 重置FLV Tag中的时间戳为累积的相对时间戳 | ||
| 607 | + if (flvTag != null) { | ||
| 608 | + FLVUtils.resetTimestamp(flvTag, videoTimestamp); | ||
| 609 | + } | ||
| 610 | + | ||
| 611 | + // 发送给native RTMP推流器(如果有) | ||
| 612 | + if (streamPublisher == null) { | ||
| 613 | + logger.info("[{}] broadcastVideo: streamPublisher is null", tag); | ||
| 614 | + } else if (!streamPublisher.isConnected()) { | ||
| 615 | + logger.info("[{}] broadcastVideo: streamPublisher not connected, connected={}", tag, streamPublisher.isConnected()); | ||
| 616 | + } else { | ||
| 617 | + try { | ||
| 618 | + if (flvTag != null && flvTag.length > 0) { | ||
| 619 | + logger.debug("[{}] >>> [RTMP发送] Video Frame, length={}, videoTimestamp={}", tag, flvTag.length, videoTimestamp); | ||
| 620 | + streamPublisher.sendVideoData(flvTag, videoTimestamp); | ||
| 621 | + } | ||
| 622 | + } catch (Exception e) { | ||
| 623 | + logger.error("[{}] 发送视频数据到RTMP失败: {}", tag, e.getMessage()); | ||
| 624 | + } | ||
| 625 | + } | ||
| 626 | + | ||
| 627 | + // 广播给HTTP订阅者(传递原始timeoffset,保持兼容性) | ||
| 436 | for (Subscriber subscriber : subscribers) | 628 | for (Subscriber subscriber : subscribers) |
| 437 | { | 629 | { |
| 438 | subscriber.onVideoData(timeoffset, flvTag, flvEncoder); | 630 | subscriber.onVideoData(timeoffset, flvTag, flvEncoder); |
| @@ -441,6 +633,58 @@ public class Channel | @@ -441,6 +633,58 @@ public class Channel | ||
| 441 | 633 | ||
| 442 | public void broadcastAudio(long timeoffset, byte[] flvTag) | 634 | public void broadcastAudio(long timeoffset, byte[] flvTag) |
| 443 | { | 635 | { |
| 636 | + // ========== 暂时禁用RTMP音频发送,只广播给HTTP订阅者 ========== | ||
| 637 | + // [TODO] 后续需要实现AAC编码后再启用音频RTMP发送 | ||
| 638 | + /* | ||
| 639 | + if (streamPublisher != null && streamPublisher.isConnected() && flvTag != null && flvTag.length > 0) { | ||
| 640 | + try { | ||
| 641 | + // 初始化音频编码器(如果尚未初始化) | ||
| 642 | + if (mp3Encoder == null) { | ||
| 643 | + mp3Encoder = new MP3Encoder(); | ||
| 644 | + } | ||
| 645 | + if (audioTagEncoder == null) { | ||
| 646 | + audioTagEncoder = new FlvAudioTagEncoder(); | ||
| 647 | + } | ||
| 648 | + | ||
| 649 | + // 音频时间戳处理(与视频类似) | ||
| 650 | + if (lastAudioTimeOffset == -1) { | ||
| 651 | + // 首次设置,不累加时间戳 | ||
| 652 | + lastAudioTimeOffset = timeoffset; | ||
| 653 | + } else { | ||
| 654 | + audioTimestamp += (int)(timeoffset - lastAudioTimeOffset); | ||
| 655 | + lastAudioTimeOffset = timeoffset; | ||
| 656 | + } | ||
| 657 | + | ||
| 658 | + // PCM编码为MP3 | ||
| 659 | + byte[] mp3Data = mp3Encoder.encode(flvTag); | ||
| 660 | + if (mp3Data == null || mp3Data.length == 0) { | ||
| 661 | + logger.debug("[{}] MP3编码失败,跳过该音频帧", tag); | ||
| 662 | + // 仍然广播给HTTP订阅者 | ||
| 663 | + for (Subscriber subscriber : subscribers) { | ||
| 664 | + subscriber.onAudioData(timeoffset, flvTag, flvEncoder); | ||
| 665 | + } | ||
| 666 | + return; | ||
| 667 | + } | ||
| 668 | + | ||
| 669 | + // 创建音频标签 (format=MP3, rate=22kHz, size=16bit, type=stereo) | ||
| 670 | + AudioTag audioTag = new AudioTag(0, mp3Data.length + 1, AudioTag.MP3, (byte) 2, (byte) 1, (byte) 1, mp3Data); | ||
| 671 | + | ||
| 672 | + // 编码为FLV音频标签 | ||
| 673 | + ByteBuf audioBuf = audioTagEncoder.encode(audioTag); | ||
| 674 | + byte[] audioFlvTag = ByteBufUtils.readReadableBytes(audioBuf); | ||
| 675 | + | ||
| 676 | + // 重置时间戳 | ||
| 677 | + FLVUtils.resetTimestamp(audioFlvTag, audioTimestamp); | ||
| 678 | + | ||
| 679 | + streamPublisher.sendVideoData(audioFlvTag, audioTimestamp); | ||
| 680 | + logger.debug("[{}] >>> [RTMP发送] Audio Frame, length={}, audioTimestamp={}", tag, audioFlvTag.length, audioTimestamp); | ||
| 681 | + } catch (Exception e) { | ||
| 682 | + logger.error("[{}] 发送音频数据到RTMP失败: {}", tag, e.getMessage()); | ||
| 683 | + } | ||
| 684 | + } | ||
| 685 | + */ | ||
| 686 | + | ||
| 687 | + // 广播给HTTP订阅者 | ||
| 444 | for (Subscriber subscriber : subscribers) | 688 | for (Subscriber subscriber : subscribers) |
| 445 | { | 689 | { |
| 446 | subscriber.onAudioData(timeoffset, flvTag, flvEncoder); | 690 | subscriber.onAudioData(timeoffset, flvTag, flvEncoder); |
| @@ -472,11 +716,11 @@ public class Channel | @@ -472,11 +716,11 @@ public class Channel | ||
| 472 | itr.remove(); | 716 | itr.remove(); |
| 473 | } | 717 | } |
| 474 | 718 | ||
| 475 | - // 关闭 FFmpeg 进程 | ||
| 476 | - if (rtmpPublisher != null) { | ||
| 477 | - logger.info("[{}] 关闭FFmpeg推流进程...", tag); | ||
| 478 | - rtmpPublisher.close(); | ||
| 479 | - rtmpPublisher = null; | 719 | + // 关闭推流器 |
| 720 | + if (streamPublisher != null) { | ||
| 721 | + logger.info("[{}] 关闭推流器...", tag); | ||
| 722 | + streamPublisher.close(); | ||
| 723 | + streamPublisher = null; | ||
| 480 | } | 724 | } |
| 481 | 725 | ||
| 482 | logger.info("[{}] Channel已关闭", tag); | 726 | logger.info("[{}] Channel已关闭", tag); |
| @@ -497,9 +741,12 @@ public class Channel | @@ -497,9 +741,12 @@ public class Channel | ||
| 497 | if (i == 0) continue; | 741 | if (i == 0) continue; |
| 498 | byte[] nalu = new byte[i]; | 742 | byte[] nalu = new byte[i]; |
| 499 | buffer.sliceInto(nalu, i); | 743 | buffer.sliceInto(nalu, i); |
| 744 | + logger.debug("[{}] readNalu: found NALU at i={}, len={}, nalType=0x{}", | ||
| 745 | + tag, i, nalu.length, nalu.length > 4 ? String.format("%02x", nalu[4] & 0x1F) : "N/A"); | ||
| 500 | return nalu; | 746 | return nalu; |
| 501 | } | 747 | } |
| 502 | } | 748 | } |
| 749 | + logger.debug("[{}] readNalu: no start code found, buffer.size={}", tag, buffer.size()); | ||
| 503 | return null; | 750 | return null; |
| 504 | } | 751 | } |
| 505 | } | 752 | } |
src/main/java/com/genersoft/iot/vmp/jtt1078/publisher/FFmpegPublisher.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.publisher; | ||
| 2 | + | ||
| 3 | +import com.genersoft.iot.vmp.jtt1078.util.*; | ||
| 4 | +import org.slf4j.Logger; | ||
| 5 | +import org.slf4j.LoggerFactory; | ||
| 6 | + | ||
| 7 | +import java.io.Closeable; | ||
| 8 | +import java.io.InputStream; | ||
| 9 | +import java.util.concurrent.TimeUnit; | ||
| 10 | + | ||
| 11 | +/** | ||
| 12 | + * FFmpeg 推流器 (仅处理视频直播流) | ||
| 13 | + * | ||
| 14 | + * 实现了IStreamPublisher接口,支持通过工厂创建 | ||
| 15 | + * | ||
| 16 | + * 修改记录: | ||
| 17 | + * 1. 实现IStreamPublisher接口,支持工厂模式 | ||
| 18 | + * 2. 添加详细的启动和关闭日志,便于排查问题 | ||
| 19 | + * 3. 优化关闭逻辑,确保FFmpeg进程被正确终止 | ||
| 20 | + * 4. 添加FFmpeg输出日志监控 | ||
| 21 | + * 5. 兼容Java 8 | ||
| 22 | + */ | ||
| 23 | +public class FFmpegPublisher extends Thread implements IStreamPublisher | ||
| 24 | +{ | ||
| 25 | + static Logger logger = LoggerFactory.getLogger(FFmpegPublisher.class); | ||
| 26 | + | ||
| 27 | + String tag = null; | ||
| 28 | + Process process = null; | ||
| 29 | + private volatile boolean running = true; | ||
| 30 | + private volatile boolean connected = false; | ||
| 31 | + | ||
| 32 | + /** FFmpeg路径 */ | ||
| 33 | + private String ffmpegPath = null; | ||
| 34 | + | ||
| 35 | + /** 目标RTMP地址 */ | ||
| 36 | + private String rtmpUrl = null; | ||
| 37 | + | ||
| 38 | + /** FFmpeg命令格式标志 */ | ||
| 39 | + private String formatFlag = null; | ||
| 40 | + | ||
| 41 | + /** 进程启动时间 */ | ||
| 42 | + private long processStartTime = 0; | ||
| 43 | + | ||
| 44 | + /** 推流器类型标识 */ | ||
| 45 | + private static final String PUBLISHER_TYPE = "ffmpeg"; | ||
| 46 | + | ||
| 47 | + public FFmpegPublisher(String tag) | ||
| 48 | + { | ||
| 49 | + this.tag = tag; | ||
| 50 | + this.setName("FFmpegPublisher-" + tag); | ||
| 51 | + this.setDaemon(true); | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + @Override | ||
| 55 | + public void start(String tag) { | ||
| 56 | + this.start(); | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + @Override | ||
| 60 | + public void run() | ||
| 61 | + { | ||
| 62 | + InputStream stderr = null; | ||
| 63 | + InputStream stdout = null; | ||
| 64 | + int len = -1; | ||
| 65 | + byte[] buff = new byte[512]; | ||
| 66 | + boolean debugMode = "on".equalsIgnoreCase(Configs.get("debug.mode")); | ||
| 67 | + | ||
| 68 | + try | ||
| 69 | + { | ||
| 70 | + // 获取FFmpeg配置 | ||
| 71 | + String sign = "41db35390ddad33f83944f44b8b75ded"; | ||
| 72 | + rtmpUrl = Configs.get("rtmp.url").replaceAll("\\{TAG\\}", tag).replaceAll("\\{sign\\}",sign); | ||
| 73 | + ffmpegPath = Configs.get("ffmpeg.path"); | ||
| 74 | + | ||
| 75 | + // 自动判断协议格式 | ||
| 76 | + formatFlag = ""; | ||
| 77 | + if (rtmpUrl.startsWith("rtsp://")) { | ||
| 78 | + formatFlag = "-f rtsp"; | ||
| 79 | + } else if (rtmpUrl.startsWith("rtmp://")) { | ||
| 80 | + formatFlag = "-f flv"; | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + // 构造FFmpeg命令 | ||
| 84 | + String cmd = String.format("%s -i http://127.0.0.1:%d/video/%s -vcodec copy -acodec aac %s %s", | ||
| 85 | + ffmpegPath, | ||
| 86 | + Configs.getInt("server.http.port", 3333), | ||
| 87 | + tag, | ||
| 88 | + formatFlag, | ||
| 89 | + rtmpUrl | ||
| 90 | + ); | ||
| 91 | + | ||
| 92 | + logger.info("==========================================="); | ||
| 93 | + logger.info("[{}] ====== FFmpeg推流任务启动 ======", tag); | ||
| 94 | + logger.info("[{}] FFmpeg路径: {}", tag, ffmpegPath); | ||
| 95 | + logger.info("[{}] 目标地址: {}", tag, rtmpUrl); | ||
| 96 | + logger.info("[{}] 完整命令: {}", tag, cmd); | ||
| 97 | + logger.info("[{}] HTTP端口: {}", tag, Configs.getInt("server.http.port", 3333)); | ||
| 98 | + logger.info("[{}] 源流地址: http://127.0.0.1:{}/video/{}", tag, Configs.getInt("server.http.port", 3333), tag); | ||
| 99 | + logger.info("[{}] 启动时间: {}", tag, new java.util.Date()); | ||
| 100 | + logger.info("==========================================="); | ||
| 101 | + | ||
| 102 | + // 执行FFmpeg命令 | ||
| 103 | + process = Runtime.getRuntime().exec(cmd); | ||
| 104 | + processStartTime = System.currentTimeMillis(); | ||
| 105 | + connected = true; | ||
| 106 | + | ||
| 107 | + // 记录进程启动信息(Java 8兼容方式) | ||
| 108 | + logger.info("[{}] FFmpeg进程已启动", tag); | ||
| 109 | + | ||
| 110 | + stderr = process.getErrorStream(); | ||
| 111 | + stdout = process.getInputStream(); | ||
| 112 | + | ||
| 113 | + // 启动一个线程消费 stdout,防止缓冲区满导致进程阻塞 | ||
| 114 | + final InputStream finalStdout = stdout; | ||
| 115 | + Thread stdoutConsumer = new Thread(() -> { | ||
| 116 | + try { | ||
| 117 | + byte[] buffer = new byte[512]; | ||
| 118 | + int count = 0; | ||
| 119 | + while (running && finalStdout.read(buffer) > -1) { | ||
| 120 | + count++; | ||
| 121 | + // 每1000次读取才打印一次(避免刷屏) | ||
| 122 | + if (debugMode && count % 1000 == 0) { | ||
| 123 | + logger.debug("[{}] FFmpeg stdout消费中... count: {}", tag, count); | ||
| 124 | + } | ||
| 125 | + } | ||
| 126 | + logger.info("[{}] FFmpeg stdout消费结束, 共消费{}次", tag, count); | ||
| 127 | + } catch (Exception e) { | ||
| 128 | + // 忽略异常 | ||
| 129 | + } | ||
| 130 | + }, "FFmpeg-stdout-" + tag); | ||
| 131 | + stdoutConsumer.setDaemon(true); | ||
| 132 | + stdoutConsumer.start(); | ||
| 133 | + | ||
| 134 | + // 消费 stderr 日志流 | ||
| 135 | + StringBuilder errorLog = new StringBuilder(); | ||
| 136 | + int errorCount = 0; | ||
| 137 | + while (running && (len = stderr.read(buff)) > -1) | ||
| 138 | + { | ||
| 139 | + if (debugMode) { | ||
| 140 | + System.out.print(new String(buff, 0, len)); | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + // 收集错误日志(便于排查问题) | ||
| 144 | + errorLog.append(new String(buff, 0, len)); | ||
| 145 | + errorCount++; | ||
| 146 | + | ||
| 147 | + // 每100条错误日志打印一次摘要 | ||
| 148 | + if (errorCount % 100 == 0) { | ||
| 149 | + String lastError = errorLog.length() > 500 | ||
| 150 | + ? errorLog.substring(errorLog.length() - 500) | ||
| 151 | + : errorLog.toString(); | ||
| 152 | + logger.debug("[{}] FFmpeg错误日志摘要: {}", tag, lastError); | ||
| 153 | + } | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + // 进程退出处理 | ||
| 157 | + int exitCode = process.waitFor(); | ||
| 158 | + long runDuration = System.currentTimeMillis() - processStartTime; | ||
| 159 | + connected = false; | ||
| 160 | + logger.warn("==========================================="); | ||
| 161 | + logger.warn("[{}] ====== FFmpeg推流任务结束 ======", tag); | ||
| 162 | + logger.warn("[{}] 退出代码: {}", tag, exitCode); | ||
| 163 | + logger.warn("[{}] 运行时间: {} ms", tag, runDuration); | ||
| 164 | + logger.warn("[{}] 错误日志条数: {}", tag, errorCount); | ||
| 165 | + | ||
| 166 | + // 分析退出原因 | ||
| 167 | + if (exitCode == 0) { | ||
| 168 | + logger.info("[{}] FFmpeg正常退出", tag); | ||
| 169 | + } else if (exitCode == -1 || exitCode == 255) { | ||
| 170 | + logger.warn("[{}] FFmpeg被信号终止 (exitCode={})", tag, exitCode); | ||
| 171 | + } else { | ||
| 172 | + // 保存最后一段错误日志 | ||
| 173 | + String lastError = errorLog.length() > 1000 | ||
| 174 | + ? errorLog.substring(errorLog.length() - 1000) | ||
| 175 | + : errorLog.toString(); | ||
| 176 | + logger.error("[{}] FFmpeg异常退出, 最后错误日志:\n{}", tag, lastError); | ||
| 177 | + } | ||
| 178 | + logger.warn("==========================================="); | ||
| 179 | + | ||
| 180 | + } | ||
| 181 | + catch(InterruptedException ex) | ||
| 182 | + { | ||
| 183 | + logger.info("[{}] FFmpegPublisher被中断: {}", tag, ex.getMessage()); | ||
| 184 | + Thread.currentThread().interrupt(); | ||
| 185 | + } | ||
| 186 | + catch(Exception ex) | ||
| 187 | + { | ||
| 188 | + logger.error("[{}] FFmpegPublisher异常: {}", tag, ex); | ||
| 189 | + } | ||
| 190 | + finally | ||
| 191 | + { | ||
| 192 | + connected = false; | ||
| 193 | + // 确保所有流都被关闭 | ||
| 194 | + closeQuietly(stderr); | ||
| 195 | + closeQuietly(stdout); | ||
| 196 | + if (process != null) { | ||
| 197 | + closeQuietly(process.getInputStream()); | ||
| 198 | + closeQuietly(process.getOutputStream()); | ||
| 199 | + closeQuietly(process.getErrorStream()); | ||
| 200 | + } | ||
| 201 | + logger.info("[{}] FFmpegPublisher资源已释放", tag); | ||
| 202 | + } | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + /** | ||
| 206 | + * 关闭FFmpeg推流 | ||
| 207 | + * 优化关闭逻辑,确保进程被正确终止(Java 8兼容) | ||
| 208 | + */ | ||
| 209 | + @Override | ||
| 210 | + public void close() | ||
| 211 | + { | ||
| 212 | + logger.info("[{}] ====== 开始关闭FFmpeg推流 ======", tag); | ||
| 213 | + logger.info("[{}] 关闭请求时间: {}", tag, new java.util.Date()); | ||
| 214 | + | ||
| 215 | + try { | ||
| 216 | + // 设置停止标志 | ||
| 217 | + running = false; | ||
| 218 | + connected = false; | ||
| 219 | + | ||
| 220 | + if (process != null) { | ||
| 221 | + long runDuration = processStartTime > 0 ? System.currentTimeMillis() - processStartTime : 0; | ||
| 222 | + logger.info("[{}] 正在终止FFmpeg进程... (已运行{}ms)", tag, runDuration); | ||
| 223 | + | ||
| 224 | + // 先尝试正常终止 | ||
| 225 | + process.destroy(); | ||
| 226 | + | ||
| 227 | + // 等待最多3秒 | ||
| 228 | + boolean exited = process.waitFor(3, TimeUnit.SECONDS); | ||
| 229 | + | ||
| 230 | + if (!exited) { | ||
| 231 | + logger.warn("[{}] FFmpeg进程未能在3秒内正常退出,开始强制终止...", tag); | ||
| 232 | + | ||
| 233 | + // 强制终止(Java 8的方式) | ||
| 234 | + process.destroyForcibly(); | ||
| 235 | + | ||
| 236 | + // 再等待2秒 | ||
| 237 | + try { | ||
| 238 | + exited = process.waitFor(2, TimeUnit.SECONDS); | ||
| 239 | + } catch (InterruptedException e) { | ||
| 240 | + Thread.currentThread().interrupt(); | ||
| 241 | + } | ||
| 242 | + if (!exited) { | ||
| 243 | + logger.error("[{}] FFmpeg进程强制终止失败,可能存在资源泄漏", tag); | ||
| 244 | + } else { | ||
| 245 | + logger.info("[{}] FFmpeg进程已强制终止", tag); | ||
| 246 | + } | ||
| 247 | + } else { | ||
| 248 | + int exitCode = process.exitValue(); | ||
| 249 | + logger.info("[{}] FFmpeg进程已正常终止, 退出代码: {}", tag, exitCode); | ||
| 250 | + } | ||
| 251 | + | ||
| 252 | + // 检查是否需要杀掉残留进程 | ||
| 253 | + checkAndKillOrphanedProcesses(); | ||
| 254 | + | ||
| 255 | + } else { | ||
| 256 | + logger.info("[{}] FFmpeg进程为空,无需关闭", tag); | ||
| 257 | + } | ||
| 258 | + | ||
| 259 | + logger.info("[{}] ====== FFmpeg推流已关闭 ======", tag); | ||
| 260 | + | ||
| 261 | + // 中断线程(如果还在阻塞读取) | ||
| 262 | + this.interrupt(); | ||
| 263 | + | ||
| 264 | + // 等待线程结束 | ||
| 265 | + this.join(2000); | ||
| 266 | + if (this.isAlive()) { | ||
| 267 | + logger.warn("[{}] FFmpegPublisher线程未能正常结束", tag); | ||
| 268 | + } | ||
| 269 | + | ||
| 270 | + } catch(Exception e) { | ||
| 271 | + logger.error("[{}] 关闭FFmpegPublisher时出错: {}", tag, e); | ||
| 272 | + } | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + /** | ||
| 276 | + * 检查并杀掉可能残留的FFmpeg进程 | ||
| 277 | + * 某些情况下FFmpeg进程可能没有被正确回收 | ||
| 278 | + */ | ||
| 279 | + private void checkAndKillOrphanedProcesses() { | ||
| 280 | + try { | ||
| 281 | + // 根据FFmpeg命令特征查找残留进程 | ||
| 282 | + // 注意:这里只是记录日志,实际杀进程需要谨慎 | ||
| 283 | + logger.debug("[{}] 检查是否有FFmpeg残留进程...", tag); | ||
| 284 | + } catch (Exception e) { | ||
| 285 | + logger.debug("[{}] 检查残留进程时出错: {}", tag, e.getMessage()); | ||
| 286 | + } | ||
| 287 | + } | ||
| 288 | + | ||
| 289 | + /** | ||
| 290 | + * 安全关闭流,忽略异常 | ||
| 291 | + */ | ||
| 292 | + private void closeQuietly(Closeable stream) { | ||
| 293 | + if (stream != null) { | ||
| 294 | + try { | ||
| 295 | + stream.close(); | ||
| 296 | + } catch (Exception e) { | ||
| 297 | + // 忽略 | ||
| 298 | + } | ||
| 299 | + } | ||
| 300 | + } | ||
| 301 | + | ||
| 302 | + /** | ||
| 303 | + * 发送FLV数据 - FFmpeg模式不需要此方法 | ||
| 304 | + * 数据通过HTTP接口由FFmpeg自动拉取 | ||
| 305 | + */ | ||
| 306 | + @Override | ||
| 307 | + public void sendVideoData(byte[] flvData, int timestamp) { | ||
| 308 | + // FFmpeg模式不需要实现此方法 | ||
| 309 | + // 数据通过HTTP接口由FFmpeg自动拉取 | ||
| 310 | + } | ||
| 311 | + | ||
| 312 | + /** | ||
| 313 | + * 发送AVC序列头 - FFmpeg模式不需要此方法 | ||
| 314 | + * FFmpeg模式下,SPS变化通过重启FFmpeg进程来解决 | ||
| 315 | + */ | ||
| 316 | + @Override | ||
| 317 | + public void sendAVCSequenceHeader() { | ||
| 318 | + // FFmpeg模式不需要实现此方法 | ||
| 319 | + // SPS变化时通过restartRtmpPublisher()重启FFmpeg解决 | ||
| 320 | + logger.debug("[{}] FFmpeg模式不需要sendAVCSequenceHeader", tag); | ||
| 321 | + } | ||
| 322 | + | ||
| 323 | + /** | ||
| 324 | + * 获取推流状态 | ||
| 325 | + */ | ||
| 326 | + @Override | ||
| 327 | + public boolean isConnected() { | ||
| 328 | + return connected && this.isAlive() && process != null; | ||
| 329 | + } | ||
| 330 | + | ||
| 331 | + /** | ||
| 332 | + * 获取推流器类型 | ||
| 333 | + */ | ||
| 334 | + @Override | ||
| 335 | + public String getType() { | ||
| 336 | + return PUBLISHER_TYPE; | ||
| 337 | + } | ||
| 338 | + | ||
| 339 | + /** | ||
| 340 | + * 获取推流器信息(用于调试) | ||
| 341 | + */ | ||
| 342 | + @Override | ||
| 343 | + public String getStatus() { | ||
| 344 | + if (process == null) { | ||
| 345 | + return "FFmpeg进程未启动"; | ||
| 346 | + } | ||
| 347 | + try { | ||
| 348 | + long runDuration = processStartTime > 0 ? System.currentTimeMillis() - processStartTime : 0; | ||
| 349 | + return String.format("FFmpeg推流器, 已运行%dms, 进程存活=%s, 连接状态=%s", | ||
| 350 | + runDuration, process.isAlive(), connected ? "已连接" : "未连接"); | ||
| 351 | + } catch (Exception e) { | ||
| 352 | + return "获取FFmpeg状态失败: " + e.getMessage(); | ||
| 353 | + } | ||
| 354 | + } | ||
| 355 | + | ||
| 356 | + /** | ||
| 357 | + * 获取FFmpeg进程信息(用于调试) | ||
| 358 | + */ | ||
| 359 | + public String getProcessInfo() { | ||
| 360 | + if (process == null) { | ||
| 361 | + return "process is null"; | ||
| 362 | + } | ||
| 363 | + try { | ||
| 364 | + long runDuration = processStartTime > 0 ? System.currentTimeMillis() - processStartTime : 0; | ||
| 365 | + return String.format("FFmpeg进程, 已运行%dms, alive=%s", | ||
| 366 | + runDuration, process.isAlive()); | ||
| 367 | + } catch (Exception e) { | ||
| 368 | + return "获取进程信息失败: " + e.getMessage(); | ||
| 369 | + } | ||
| 370 | + } | ||
| 371 | +} |
src/main/java/com/genersoft/iot/vmp/jtt1078/publisher/IStreamPublisher.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.publisher; | ||
| 2 | + | ||
| 3 | +/** | ||
| 4 | + * 推流器接口 | ||
| 5 | + * | ||
| 6 | + * 定义统一的推流器接口,支持多种推流实现(FFmpeg、原生RTMP等) | ||
| 7 | + * | ||
| 8 | + * 设计原则: | ||
| 9 | + * 1. 简单明了,只定义核心方法 | ||
| 10 | + * 2. FFmpeg推流器实现时,sendAVCSequenceHeader为空操作 | ||
| 11 | + * 3. 原生RTMP推流器实现时,SPS变化通过sendAVCSequenceHeader立即生效 | ||
| 12 | + */ | ||
| 13 | +public interface IStreamPublisher { | ||
| 14 | + | ||
| 15 | + /** | ||
| 16 | + * 启动推流 | ||
| 17 | + * @param tag 通道标识 | ||
| 18 | + */ | ||
| 19 | + void start(String tag); | ||
| 20 | + | ||
| 21 | + /** | ||
| 22 | + * 发送FLV视频数据 | ||
| 23 | + * @param flvData FLV Video Tag数据(包含PreviousTagSize和Video Tag) | ||
| 24 | + * @param timestamp 时间戳(毫秒) | ||
| 25 | + */ | ||
| 26 | + void sendVideoData(byte[] flvData, int timestamp); | ||
| 27 | + | ||
| 28 | + /** | ||
| 29 | + * 发送AVC序列头(SPS/PPS) | ||
| 30 | + * 当检测到SPS变化时调用此方法 | ||
| 31 | + * FFmpeg推流器不需要实现此方法(因为重启FFmpeg即可) | ||
| 32 | + * 原生RTMP推流器会立即发送新的AVC Sequence Header到RTMP服务器 | ||
| 33 | + */ | ||
| 34 | + void sendAVCSequenceHeader(); | ||
| 35 | + | ||
| 36 | + /** | ||
| 37 | + * 关闭推流 | ||
| 38 | + * 应该释放所有资源,包括进程、Socket连接等 | ||
| 39 | + */ | ||
| 40 | + void close(); | ||
| 41 | + | ||
| 42 | + /** | ||
| 43 | + * 检查是否连接中 | ||
| 44 | + * @return true表示推流器处于活跃状态 | ||
| 45 | + */ | ||
| 46 | + boolean isConnected(); | ||
| 47 | + | ||
| 48 | + /** | ||
| 49 | + * 获取推流器类型 | ||
| 50 | + * @return "ffmpeg" 或 "native" | ||
| 51 | + */ | ||
| 52 | + String getType(); | ||
| 53 | + | ||
| 54 | + /** | ||
| 55 | + * 获取推流器信息(用于调试) | ||
| 56 | + * @return 推流器状态描述 | ||
| 57 | + */ | ||
| 58 | + String getStatus(); | ||
| 59 | +} |
src/main/java/com/genersoft/iot/vmp/jtt1078/publisher/NativeRtmpPublisher.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.publisher; | ||
| 2 | + | ||
| 3 | +import com.genersoft.iot.vmp.jtt1078.rtmp.RtmpChunkWriter; | ||
| 4 | +import com.genersoft.iot.vmp.jtt1078.rtmp.RtmpConnection; | ||
| 5 | +import com.genersoft.iot.vmp.jtt1078.util.Configs; | ||
| 6 | +import org.slf4j.Logger; | ||
| 7 | +import org.slf4j.LoggerFactory; | ||
| 8 | + | ||
| 9 | +import java.util.concurrent.atomic.AtomicInteger; | ||
| 10 | + | ||
| 11 | +/** | ||
| 12 | + * 原生Java RTMP推流器 | ||
| 13 | + * | ||
| 14 | + * 实现了IStreamPublisher接口,使用原生Java实现RTMP协议推送 | ||
| 15 | + * | ||
| 16 | + * 核心功能: | ||
| 17 | + * 1. RTMP握手和连接 | ||
| 18 | + * 2. FLV数据到RTMP消息的转换 | ||
| 19 | + * 3. 断流检测和自动重连 | ||
| 20 | + * 4. 码流参数变化时无缝切换(发送新的SPS/PPS) | ||
| 21 | + */ | ||
| 22 | +public class NativeRtmpPublisher implements IStreamPublisher, Runnable { | ||
| 23 | + | ||
| 24 | + static Logger logger = LoggerFactory.getLogger(NativeRtmpPublisher.class); | ||
| 25 | + | ||
| 26 | + private String tag; | ||
| 27 | + private volatile boolean connected = false; | ||
| 28 | + private volatile boolean running = false; | ||
| 29 | + private static final String PUBLISHER_TYPE = "native"; | ||
| 30 | + | ||
| 31 | + // RTMP连接配置 | ||
| 32 | + private String host; | ||
| 33 | + private int port; | ||
| 34 | + private String app; | ||
| 35 | + private String tcUrl; | ||
| 36 | + private String streamName; | ||
| 37 | + | ||
| 38 | + // RTMP组件 | ||
| 39 | + private RtmpConnection connection; | ||
| 40 | + private RtmpChunkWriter chunkWriter; | ||
| 41 | + | ||
| 42 | + // 重连配置 | ||
| 43 | + private static final int MAX_RECONNECT_ATTEMPTS = 5; | ||
| 44 | + private static final long INITIAL_RECONNECT_DELAY_MS = 1000; | ||
| 45 | + private static final long MAX_RECONNECT_DELAY_MS = 16000; | ||
| 46 | + | ||
| 47 | + private final AtomicInteger reconnectAttempts = new AtomicInteger(0); | ||
| 48 | + private long lastReconnectTime = 0; | ||
| 49 | + | ||
| 50 | + // 心跳检测 | ||
| 51 | + private static final long HEARTBEAT_INTERVAL_MS = 5000; | ||
| 52 | + private long lastHeartbeatTime = 0; | ||
| 53 | + | ||
| 54 | + // 线程 | ||
| 55 | + private Thread heartbeatThread; | ||
| 56 | + private volatile Thread currentThread; | ||
| 57 | + | ||
| 58 | + // SPS/PPS缓存(用于无缝切换) | ||
| 59 | + private byte[] cachedSPS = null; | ||
| 60 | + private byte[] cachedPPS = null; | ||
| 61 | + private volatile boolean needSendNewSequenceHeader = false; | ||
| 62 | + | ||
| 63 | + public NativeRtmpPublisher(String tag) { | ||
| 64 | + this.tag = tag; | ||
| 65 | + loadConfig(); | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + /** | ||
| 69 | + * 从配置加载RTMP连接参数 | ||
| 70 | + * | ||
| 71 | + * 优先级: | ||
| 72 | + * 1. rtmp.native.tcUrl (完整TCUrl配置) | ||
| 73 | + * 2. rtmp.native.host/port/app (分离配置) | ||
| 74 | + * 3. rtmp.url (兼容旧配置) | ||
| 75 | + * 4. 默认值 | ||
| 76 | + */ | ||
| 77 | + private void loadConfig() { | ||
| 78 | + // 优先使用原生RTMP配置 | ||
| 79 | + String configHost = Configs.get("rtmp.native.host"); | ||
| 80 | + String configPort = Configs.get("rtmp.native.port"); | ||
| 81 | + String configApp = Configs.get("rtmp.native.app"); | ||
| 82 | + String configTcUrl = Configs.get("rtmp.native.tcUrl"); | ||
| 83 | + | ||
| 84 | + // 如果有完整的tcUrl配置,直接使用 | ||
| 85 | + if (configTcUrl != null && !configTcUrl.isEmpty()) { | ||
| 86 | + this.tcUrl = configTcUrl.replace("{TAG}", tag); | ||
| 87 | + // 从tcUrl解析host和port | ||
| 88 | + parseTcUrl(configTcUrl); | ||
| 89 | + logger.info("[{}] 使用配置的tcUrl: {}", tag, this.tcUrl); | ||
| 90 | + return; | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + // 如果有分离配置,使用分离配置 | ||
| 94 | + if (configHost != null && !configHost.isEmpty()) { | ||
| 95 | + this.host = configHost; | ||
| 96 | + } else { | ||
| 97 | + this.host = "127.0.0.1"; | ||
| 98 | + } | ||
| 99 | + | ||
| 100 | + if (configPort != null && !configPort.isEmpty()) { | ||
| 101 | + this.port = Integer.parseInt(configPort); | ||
| 102 | + } else { | ||
| 103 | + this.port = 1935; | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + if (configApp != null && !configApp.isEmpty()) { | ||
| 107 | + this.app = configApp; | ||
| 108 | + } else { | ||
| 109 | + this.app = "schedule"; | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + // streamName = tag | ||
| 113 | + this.streamName = tag; | ||
| 114 | + | ||
| 115 | + // 从rtmp.url获取sign参数(参考FFmpegPublisher的处理方式) | ||
| 116 | + String sign = "41db35390ddad33f83944f44b8b75ded"; // 默认sign | ||
| 117 | + String rtmpUrl = Configs.get("rtmp.url"); | ||
| 118 | + if (rtmpUrl != null && rtmpUrl.contains("sign=")) { | ||
| 119 | + int signStart = rtmpUrl.indexOf("sign=") + 5; | ||
| 120 | + int signEnd = rtmpUrl.indexOf("&", signStart); | ||
| 121 | + if (signEnd == -1) signEnd = rtmpUrl.length(); | ||
| 122 | + String extractedSign = rtmpUrl.substring(signStart, signEnd); | ||
| 123 | + if (!extractedSign.isEmpty() && !"{sign}".equals(extractedSign)) { | ||
| 124 | + sign = extractedSign; | ||
| 125 | + } | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + // FFmpeg使用完整URL: rtmp://host:port/app/streamName?sign=xxx,ZLM也能正确解析 | ||
| 129 | + // 将streamName和sign包含在tcUrl中 | ||
| 130 | + this.tcUrl = String.format("rtmp://%s:%d/%s/%s?sign=%s", host, port, app, tag, sign); | ||
| 131 | + | ||
| 132 | + logger.info("[{}] RTMP推流器配置: host={}, port={}, app={}, stream={}, tcUrl={}", | ||
| 133 | + tag, host, port, app, streamName, tcUrl); | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + /** | ||
| 137 | + * 从tcUrl解析host和port | ||
| 138 | + */ | ||
| 139 | + private void parseTcUrl(String tcUrl) { | ||
| 140 | + try { | ||
| 141 | + // 移除sign参数 | ||
| 142 | + if (tcUrl.contains("?")) { | ||
| 143 | + tcUrl = tcUrl.substring(0, tcUrl.indexOf("?")); | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + // 支持 rtmp://, rtsp://, http:// 等协议 | ||
| 147 | + String prefix = "://"; | ||
| 148 | + int prefixIndex = tcUrl.indexOf(prefix); | ||
| 149 | + if (prefixIndex == -1) { | ||
| 150 | + logger.warn("[{}] tcUrl没有有效的协议前缀: {}", tag, tcUrl); | ||
| 151 | + return; | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + String afterPrefix = tcUrl.substring(prefixIndex + prefix.length()); | ||
| 155 | + | ||
| 156 | + // 分割host:port和path | ||
| 157 | + int slashIndex = afterPrefix.indexOf("/"); | ||
| 158 | + String hostPort; | ||
| 159 | + String path; | ||
| 160 | + if (slashIndex == -1) { | ||
| 161 | + hostPort = afterPrefix; | ||
| 162 | + path = ""; | ||
| 163 | + } else { | ||
| 164 | + hostPort = afterPrefix.substring(0, slashIndex); | ||
| 165 | + path = afterPrefix.substring(slashIndex + 1); | ||
| 166 | + } | ||
| 167 | + | ||
| 168 | + // 解析host:port | ||
| 169 | + int colonIndex = hostPort.indexOf(":"); | ||
| 170 | + if (colonIndex == -1) { | ||
| 171 | + this.host = hostPort; | ||
| 172 | + // 默认端口根据协议 | ||
| 173 | + if (tcUrl.startsWith("rtmp://")) { | ||
| 174 | + this.port = 1935; | ||
| 175 | + } else if (tcUrl.startsWith("rtsp://")) { | ||
| 176 | + this.port = 554; | ||
| 177 | + } else { | ||
| 178 | + this.port = 1935; | ||
| 179 | + } | ||
| 180 | + } else { | ||
| 181 | + this.host = hostPort.substring(0, colonIndex); | ||
| 182 | + this.port = Integer.parseInt(hostPort.substring(colonIndex + 1)); | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + // app是路径的第一部分 | ||
| 186 | + if (path.length() > 0) { | ||
| 187 | + this.app = path; | ||
| 188 | + this.streamName = tag; | ||
| 189 | + this.tcUrl = String.format("rtmp://%s:%d/%s", host, port, path); | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + } catch (Exception e) { | ||
| 193 | + logger.error("[{}] 解析tcUrl失败: {}", tag, e.getMessage()); | ||
| 194 | + } | ||
| 195 | + } | ||
| 196 | + | ||
| 197 | + /** | ||
| 198 | + * 解析RTMP URL(兼容方法) | ||
| 199 | + */ | ||
| 200 | + private void parseRtmpUrl(String rtmpUrl) { | ||
| 201 | + try { | ||
| 202 | + // 移除sign参数 | ||
| 203 | + if (rtmpUrl.contains("?")) { | ||
| 204 | + rtmpUrl = rtmpUrl.substring(0, rtmpUrl.indexOf("?")); | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + // 检测协议前缀 | ||
| 208 | + String prefix = "://"; | ||
| 209 | + int prefixIndex = rtmpUrl.indexOf(prefix); | ||
| 210 | + if (prefixIndex == -1) { | ||
| 211 | + logger.warn("[{}] URL没有有效的协议前缀: {}", tag, rtmpUrl); | ||
| 212 | + return; | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + String afterPrefix = rtmpUrl.substring(prefixIndex + prefix.length()); | ||
| 216 | + | ||
| 217 | + // 分割host:port和app/stream | ||
| 218 | + int slashIndex = afterPrefix.indexOf("/"); | ||
| 219 | + if (slashIndex == -1) { | ||
| 220 | + slashIndex = afterPrefix.length(); | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + String hostPort = afterPrefix.substring(0, slashIndex); | ||
| 224 | + String appStream = afterPrefix.substring(slashIndex + 1); | ||
| 225 | + | ||
| 226 | + // 解析host:port | ||
| 227 | + int colonIndex = hostPort.indexOf(":"); | ||
| 228 | + if (colonIndex == -1) { | ||
| 229 | + this.host = hostPort; | ||
| 230 | + this.port = 1935; | ||
| 231 | + } else { | ||
| 232 | + this.host = hostPort.substring(0, colonIndex); | ||
| 233 | + this.port = Integer.parseInt(hostPort.substring(colonIndex + 1)); | ||
| 234 | + } | ||
| 235 | + | ||
| 236 | + // app和stream name | ||
| 237 | + this.app = appStream; | ||
| 238 | + this.streamName = tag; | ||
| 239 | + this.tcUrl = String.format("rtmp://%s:%d/%s", host, port, appStream); | ||
| 240 | + | ||
| 241 | + logger.info("[{}] 解析RTMP URL: host={}, port={}, tcUrl={}, stream={}, app={}", | ||
| 242 | + tag, host, port, tcUrl, streamName, app); | ||
| 243 | + | ||
| 244 | + } catch (Exception e) { | ||
| 245 | + logger.error("[{}] 解析RTMP URL失败: {}, 使用默认值", tag, e.getMessage()); | ||
| 246 | + this.host = "127.0.0.1"; | ||
| 247 | + this.port = 1935; | ||
| 248 | + this.app = "schedule"; | ||
| 249 | + this.tcUrl = "rtmp://127.0.0.1:1935/schedule"; | ||
| 250 | + this.streamName = tag; | ||
| 251 | + } | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + @Override | ||
| 255 | + public void start(String tag) { | ||
| 256 | + this.currentThread = Thread.currentThread(); | ||
| 257 | + this.running = true; | ||
| 258 | + | ||
| 259 | + logger.info("[{}] ====== 启动原生RTMP推流器 ======", tag); | ||
| 260 | + logger.info("[{}] 目标服务器: {}:{}", tag, host, port); | ||
| 261 | + logger.info("[{}] 推流地址: {}/{}", tag, tcUrl, streamName); | ||
| 262 | + logger.info("[{}] 完整推流URL: {}/{}?sign=xxx", tag, tcUrl, streamName); | ||
| 263 | + | ||
| 264 | + // 启动心跳线程 | ||
| 265 | + startHeartbeat(); | ||
| 266 | + | ||
| 267 | + // 执行连接 | ||
| 268 | + doConnect(); | ||
| 269 | + } | ||
| 270 | + | ||
| 271 | + @Override | ||
| 272 | + public void run() { | ||
| 273 | + start(tag); | ||
| 274 | + } | ||
| 275 | + | ||
| 276 | + /** | ||
| 277 | + * 执行连接 | ||
| 278 | + */ | ||
| 279 | + private void doConnect() { | ||
| 280 | + try { | ||
| 281 | + // 关闭旧连接 | ||
| 282 | + closeConnection(); | ||
| 283 | + | ||
| 284 | + // 创建新连接 | ||
| 285 | + connection = new RtmpConnection(host, port, tcUrl, streamName); | ||
| 286 | + | ||
| 287 | + if (!connection.connect()) { | ||
| 288 | + logger.error("[{}] RTMP连接失败", tag); | ||
| 289 | + scheduleReconnect(); | ||
| 290 | + return; | ||
| 291 | + } | ||
| 292 | + | ||
| 293 | + if (!connection.createStream()) { | ||
| 294 | + logger.error("[{}] RTMP创建流失败", tag); | ||
| 295 | + scheduleReconnect(); | ||
| 296 | + return; | ||
| 297 | + } | ||
| 298 | + | ||
| 299 | + if (!connection.publish()) { | ||
| 300 | + logger.error("[{}] RTMP发布失败", tag); | ||
| 301 | + scheduleReconnect(); | ||
| 302 | + return; | ||
| 303 | + } | ||
| 304 | + | ||
| 305 | + // 【关键修复】等待publish命令被ZLM处理完成 | ||
| 306 | + // jtt1078-video-server项目成功的原因:RtmpHandshakeHandler会等待服务器响应 | ||
| 307 | + // "NetStream.Publish.Start"后才进入STREAMING状态,才开始发送数据 | ||
| 308 | + // 当前项目需要等待一段时间确保ZLM完成publish处理 | ||
| 309 | + try { | ||
| 310 | + Thread.sleep(1000); | ||
| 311 | + } catch (InterruptedException e) { | ||
| 312 | + Thread.currentThread().interrupt(); | ||
| 313 | + } | ||
| 314 | + | ||
| 315 | + // 创建ChunkWriter | ||
| 316 | + chunkWriter = new RtmpChunkWriter(connection, tag); | ||
| 317 | + | ||
| 318 | + // 如果有缓存的SPS/PPS,标记需要发送 | ||
| 319 | + if (cachedSPS != null && cachedPPS != null) { | ||
| 320 | + needSendNewSequenceHeader = true; | ||
| 321 | + } | ||
| 322 | + | ||
| 323 | + connected = true; | ||
| 324 | + reconnectAttempts.set(0); | ||
| 325 | + lastHeartbeatTime = System.currentTimeMillis(); | ||
| 326 | + | ||
| 327 | + logger.info("[{}] ====== RTMP推流连接成功 ======", tag); | ||
| 328 | + | ||
| 329 | + } catch (Exception e) { | ||
| 330 | + logger.error("[{}] RTMP连接异常: {}", tag, e.getMessage(), e); | ||
| 331 | + scheduleReconnect(); | ||
| 332 | + } | ||
| 333 | + } | ||
| 334 | + | ||
| 335 | + /** | ||
| 336 | + * 启动心跳检测线程 | ||
| 337 | + */ | ||
| 338 | + private void startHeartbeat() { | ||
| 339 | + if (heartbeatThread != null && heartbeatThread.isAlive()) { | ||
| 340 | + return; | ||
| 341 | + } | ||
| 342 | + | ||
| 343 | + heartbeatThread = new Thread(() -> { | ||
| 344 | + logger.info("[{}] 心跳检测线程启动", tag); | ||
| 345 | + while (running && !Thread.currentThread().isInterrupted()) { | ||
| 346 | + try { | ||
| 347 | + Thread.sleep(HEARTBEAT_INTERVAL_MS); | ||
| 348 | + | ||
| 349 | + if (!running) { | ||
| 350 | + break; | ||
| 351 | + } | ||
| 352 | + | ||
| 353 | + // 检查连接状态 | ||
| 354 | + if (!connected || connection == null || !connection.isConnected()) { | ||
| 355 | + logger.warn("[{}] 心跳检测: 连接已断开,尝试重连", tag); | ||
| 356 | + scheduleReconnect(); | ||
| 357 | + break; | ||
| 358 | + } | ||
| 359 | + | ||
| 360 | + // 检查最后心跳时间 | ||
| 361 | + long now = System.currentTimeMillis(); | ||
| 362 | + if (now - lastHeartbeatTime > HEARTBEAT_INTERVAL_MS * 3) { | ||
| 363 | + logger.warn("[{}] 心跳超时: {}ms未收到数据", tag, now - lastHeartbeatTime); | ||
| 364 | + // 连接可能还活着,只是没数据 | ||
| 365 | + } | ||
| 366 | + | ||
| 367 | + lastHeartbeatTime = now; | ||
| 368 | + | ||
| 369 | + } catch (InterruptedException e) { | ||
| 370 | + logger.info("[{}] 心跳线程被中断", tag); | ||
| 371 | + break; | ||
| 372 | + } catch (Exception e) { | ||
| 373 | + logger.error("[{}] 心跳检测异常: {}", tag, e.getMessage()); | ||
| 374 | + } | ||
| 375 | + } | ||
| 376 | + logger.info("[{}] 心跳检测线程退出", tag); | ||
| 377 | + }, "RTMP-Heartbeat-" + tag); | ||
| 378 | + heartbeatThread.setDaemon(true); | ||
| 379 | + heartbeatThread.start(); | ||
| 380 | + } | ||
| 381 | + | ||
| 382 | + /** | ||
| 383 | + * 安排重连 | ||
| 384 | + */ | ||
| 385 | + private void scheduleReconnect() { | ||
| 386 | + if (!running) { | ||
| 387 | + return; | ||
| 388 | + } | ||
| 389 | + | ||
| 390 | + int attempts = reconnectAttempts.incrementAndGet(); | ||
| 391 | + if (attempts > MAX_RECONNECT_ATTEMPTS) { | ||
| 392 | + logger.error("[{}] 超过最大重连次数({}),停止重连", tag, MAX_RECONNECT_ATTEMPTS); | ||
| 393 | + logger.info("[{}] 请检查网络和RTMP服务器状态", tag); | ||
| 394 | + return; | ||
| 395 | + } | ||
| 396 | + | ||
| 397 | + // 计算退避延迟 | ||
| 398 | + long delay = Math.min( | ||
| 399 | + INITIAL_RECONNECT_DELAY_MS * (1L << (attempts - 1)), | ||
| 400 | + MAX_RECONNECT_DELAY_MS | ||
| 401 | + ); | ||
| 402 | + | ||
| 403 | + // 避免过于频繁的重连 | ||
| 404 | + long now = System.currentTimeMillis(); | ||
| 405 | + if (now - lastReconnectTime < delay && attempts > 1) { | ||
| 406 | + delay = Math.max(delay, now - lastReconnectTime); | ||
| 407 | + } | ||
| 408 | + lastReconnectTime = now; | ||
| 409 | + | ||
| 410 | + logger.info("[{}] 计划{}ms后进行第{}次重连 (最多{}次)", | ||
| 411 | + tag, delay, attempts, MAX_RECONNECT_ATTEMPTS); | ||
| 412 | + | ||
| 413 | + // 使用当前线程执行延迟重连 | ||
| 414 | + final long finalDelay = delay; | ||
| 415 | + new Thread(() -> { | ||
| 416 | + try { | ||
| 417 | + Thread.sleep(finalDelay); | ||
| 418 | + if (running) { | ||
| 419 | + doConnect(); | ||
| 420 | + } | ||
| 421 | + } catch (InterruptedException e) { | ||
| 422 | + logger.info("[{}] 重连延迟线程被中断", tag); | ||
| 423 | + } | ||
| 424 | + }, "RTMP-Reconnect-" + tag).start(); | ||
| 425 | + } | ||
| 426 | + | ||
| 427 | + /** | ||
| 428 | + * 关闭连接 | ||
| 429 | + */ | ||
| 430 | + private void closeConnection() { | ||
| 431 | + connected = false; | ||
| 432 | + | ||
| 433 | + if (connection != null) { | ||
| 434 | + try { | ||
| 435 | + connection.close(); | ||
| 436 | + } catch (Exception e) { | ||
| 437 | + logger.debug("[{}] 关闭连接时出错: {}", tag, e.getMessage()); | ||
| 438 | + } | ||
| 439 | + connection = null; | ||
| 440 | + } | ||
| 441 | + } | ||
| 442 | + | ||
| 443 | + @Override | ||
| 444 | + public void sendVideoData(byte[] flvData, int timestamp) { | ||
| 445 | + if (!connected || chunkWriter == null) { | ||
| 446 | + logger.info("[{}] sendVideoData: not connected or chunkWriter is null, connected={}, chunkWriter={}", | ||
| 447 | + tag, connected, chunkWriter); | ||
| 448 | + return; | ||
| 449 | + } | ||
| 450 | + | ||
| 451 | + try { | ||
| 452 | + lastHeartbeatTime = System.currentTimeMillis(); | ||
| 453 | + logger.info("[{}] sendVideoData: flvDataLen={}, timestamp={}", tag, flvData.length, timestamp); | ||
| 454 | + chunkWriter.sendVideoData(flvData, timestamp); | ||
| 455 | + } catch (Exception e) { | ||
| 456 | + logger.error("[{}] 发送视频数据失败: {}", tag, e.getMessage()); | ||
| 457 | + connected = false; | ||
| 458 | + scheduleReconnect(); | ||
| 459 | + } | ||
| 460 | + } | ||
| 461 | + | ||
| 462 | + @Override | ||
| 463 | + public void sendAVCSequenceHeader() { | ||
| 464 | + if (!connected || chunkWriter == null) { | ||
| 465 | + logger.warn("[{}] 未连接,无法发送AVC Sequence Header", tag); | ||
| 466 | + return; | ||
| 467 | + } | ||
| 468 | + | ||
| 469 | + try { | ||
| 470 | + // 强制重新发送SPS/PPS | ||
| 471 | + chunkWriter.forceSendAVCSequenceHeader(); | ||
| 472 | + logger.info("[{}] 已触发发送新的AVC Sequence Header", tag); | ||
| 473 | + } catch (Exception e) { | ||
| 474 | + logger.error("[{}] 发送AVC Sequence Header失败: {}", tag, e.getMessage()); | ||
| 475 | + } | ||
| 476 | + } | ||
| 477 | + | ||
| 478 | + @Override | ||
| 479 | + public void close() { | ||
| 480 | + logger.info("[{}] ====== 关闭原生RTMP推流器 ======", tag); | ||
| 481 | + | ||
| 482 | + running = false; | ||
| 483 | + connected = false; | ||
| 484 | + | ||
| 485 | + // 中断心跳线程 | ||
| 486 | + if (heartbeatThread != null) { | ||
| 487 | + heartbeatThread.interrupt(); | ||
| 488 | + try { | ||
| 489 | + heartbeatThread.join(2000); | ||
| 490 | + } catch (InterruptedException e) { | ||
| 491 | + Thread.currentThread().interrupt(); | ||
| 492 | + } | ||
| 493 | + } | ||
| 494 | + | ||
| 495 | + // 关闭连接 | ||
| 496 | + closeConnection(); | ||
| 497 | + | ||
| 498 | + // 中断当前线程 | ||
| 499 | + if (currentThread != null && currentThread.isAlive()) { | ||
| 500 | + currentThread.interrupt(); | ||
| 501 | + } | ||
| 502 | + | ||
| 503 | + logger.info("[{}] 原生RTMP推流器已关闭", tag); | ||
| 504 | + } | ||
| 505 | + | ||
| 506 | + @Override | ||
| 507 | + public boolean isConnected() { | ||
| 508 | + return connected && connection != null && connection.isConnected(); | ||
| 509 | + } | ||
| 510 | + | ||
| 511 | + @Override | ||
| 512 | + public String getType() { | ||
| 513 | + return PUBLISHER_TYPE; | ||
| 514 | + } | ||
| 515 | + | ||
| 516 | + @Override | ||
| 517 | + public String getStatus() { | ||
| 518 | + if (connected) { | ||
| 519 | + return String.format("原生RTMP推流器, 已连接, streamId=%d, 重连次数=%d", | ||
| 520 | + connection != null ? connection.getStreamId() : 0, | ||
| 521 | + reconnectAttempts.get()); | ||
| 522 | + } else { | ||
| 523 | + return String.format("原生RTMP推流器, 未连接, 重连次数=%d/%d", | ||
| 524 | + reconnectAttempts.get(), MAX_RECONNECT_ATTEMPTS); | ||
| 525 | + } | ||
| 526 | + } | ||
| 527 | + | ||
| 528 | + /** | ||
| 529 | + * 更新SPS/PPS缓存(当Channel检测到SPS变化时调用) | ||
| 530 | + */ | ||
| 531 | + public void updateSPSPPS(byte[] sps, byte[] pps) { | ||
| 532 | + this.cachedSPS = sps; | ||
| 533 | + this.cachedPPS = pps; | ||
| 534 | + this.needSendNewSequenceHeader = true; | ||
| 535 | + | ||
| 536 | + if (chunkWriter != null) { | ||
| 537 | + chunkWriter.updateSPSPPS(sps, pps); | ||
| 538 | + } | ||
| 539 | + } | ||
| 540 | + | ||
| 541 | + /** | ||
| 542 | + * 获取重连次数 | ||
| 543 | + */ | ||
| 544 | + public int getReconnectAttempts() { | ||
| 545 | + return reconnectAttempts.get(); | ||
| 546 | + } | ||
| 547 | +} |
src/main/java/com/genersoft/iot/vmp/jtt1078/publisher/NettyRtmpPublisher.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.publisher; | ||
| 2 | + | ||
| 3 | +import com.genersoft.iot.vmp.jtt1078.rtmp.NettyRtmpClient; | ||
| 4 | +import com.genersoft.iot.vmp.jtt1078.util.Configs; | ||
| 5 | +import io.netty.buffer.ByteBuf; | ||
| 6 | +import io.netty.buffer.Unpooled; | ||
| 7 | +import org.slf4j.Logger; | ||
| 8 | +import org.slf4j.LoggerFactory; | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * Netty RTMP 推流器 | ||
| 12 | + * | ||
| 13 | + * 使用 Netty 实现的非阻塞 RTMP 客户端 | ||
| 14 | + */ | ||
| 15 | +public class NettyRtmpPublisher implements IStreamPublisher { | ||
| 16 | + | ||
| 17 | + private static final Logger logger = LoggerFactory.getLogger(NettyRtmpPublisher.class); | ||
| 18 | + | ||
| 19 | + private String tag; | ||
| 20 | + private volatile boolean connected = false; | ||
| 21 | + private static final String PUBLISHER_TYPE = "netty"; | ||
| 22 | + | ||
| 23 | + // RTMP连接配置 | ||
| 24 | + private String host; | ||
| 25 | + private int port; | ||
| 26 | + private String app; | ||
| 27 | + private String tcUrl; | ||
| 28 | + private String streamName; | ||
| 29 | + | ||
| 30 | + // Netty RTMP 客户端 | ||
| 31 | + private NettyRtmpClient client; | ||
| 32 | + | ||
| 33 | + public NettyRtmpPublisher(String tag) { | ||
| 34 | + this.tag = tag; | ||
| 35 | + loadConfig(); | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + /** | ||
| 39 | + * 从配置加载RTMP连接参数 | ||
| 40 | + */ | ||
| 41 | + private void loadConfig() { | ||
| 42 | + // 优先使用原生RTMP配置 | ||
| 43 | + String configHost = Configs.get("rtmp.native.host"); | ||
| 44 | + String configPort = Configs.get("rtmp.native.port"); | ||
| 45 | + String configApp = Configs.get("rtmp.native.app"); | ||
| 46 | + String configTcUrl = Configs.get("rtmp.native.tcUrl"); | ||
| 47 | + | ||
| 48 | + // 如果有完整的tcUrl配置,直接使用 | ||
| 49 | + if (configTcUrl != null && !configTcUrl.isEmpty()) { | ||
| 50 | + this.tcUrl = configTcUrl.replace("{TAG}", tag); | ||
| 51 | + // 从tcUrl解析host和port | ||
| 52 | + parseTcUrl(configTcUrl); | ||
| 53 | + logger.info("[{}] 使用配置的tcUrl: {}", tag, this.tcUrl); | ||
| 54 | + return; | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + // 如果有分离配置,使用分离配置 | ||
| 58 | + if (configHost != null && !configHost.isEmpty()) { | ||
| 59 | + this.host = configHost; | ||
| 60 | + } else { | ||
| 61 | + this.host = "127.0.0.1"; | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + if (configPort != null && !configPort.isEmpty()) { | ||
| 65 | + this.port = Integer.parseInt(configPort); | ||
| 66 | + } else { | ||
| 67 | + this.port = 1935; | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + if (configApp != null && !configApp.isEmpty()) { | ||
| 71 | + this.app = configApp; | ||
| 72 | + } else { | ||
| 73 | + this.app = "schedule"; | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + // streamName = tag | ||
| 77 | + this.streamName = tag; | ||
| 78 | + | ||
| 79 | + // 从rtmp.url获取sign参数 | ||
| 80 | + String sign = "41db35390ddad33f83944f44b8b75ded"; | ||
| 81 | + String rtmpUrl = Configs.get("rtmp.url"); | ||
| 82 | + if (rtmpUrl != null && rtmpUrl.contains("sign=")) { | ||
| 83 | + int signStart = rtmpUrl.indexOf("sign=") + 5; | ||
| 84 | + int signEnd = rtmpUrl.indexOf("&", signStart); | ||
| 85 | + if (signEnd == -1) signEnd = rtmpUrl.length(); | ||
| 86 | + String extractedSign = rtmpUrl.substring(signStart, signEnd); | ||
| 87 | + if (!extractedSign.isEmpty() && !"{sign}".equals(extractedSign)) { | ||
| 88 | + sign = extractedSign; | ||
| 89 | + } | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + // 完整URL: rtmp://host:port/app/streamName?sign=xxx | ||
| 93 | + this.tcUrl = String.format("rtmp://%s:%d/%s/%s?sign=%s", host, port, app, tag, sign); | ||
| 94 | + | ||
| 95 | + logger.info("[{}] Netty RTMP推流器配置: host={}, port={}, app={}, stream={}, tcUrl={}", | ||
| 96 | + tag, host, port, app, streamName, tcUrl); | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + /** | ||
| 100 | + * 从tcUrl解析host和port | ||
| 101 | + */ | ||
| 102 | + private void parseTcUrl(String tcUrl) { | ||
| 103 | + try { | ||
| 104 | + // 移除sign参数 | ||
| 105 | + if (tcUrl.contains("?")) { | ||
| 106 | + tcUrl = tcUrl.substring(0, tcUrl.indexOf("?")); | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + // 支持 rtmp://, rtsp://, http:// 等协议 | ||
| 110 | + String prefix = "://"; | ||
| 111 | + int prefixIndex = tcUrl.indexOf(prefix); | ||
| 112 | + if (prefixIndex == -1) { | ||
| 113 | + logger.warn("[{}] tcUrl没有有效的协议前缀: {}", tag, tcUrl); | ||
| 114 | + return; | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + String afterPrefix = tcUrl.substring(prefixIndex + prefix.length()); | ||
| 118 | + | ||
| 119 | + // 分割host:port和path | ||
| 120 | + int slashIndex = afterPrefix.indexOf("/"); | ||
| 121 | + String hostPort; | ||
| 122 | + String path; | ||
| 123 | + if (slashIndex == -1) { | ||
| 124 | + hostPort = afterPrefix; | ||
| 125 | + path = ""; | ||
| 126 | + } else { | ||
| 127 | + hostPort = afterPrefix.substring(0, slashIndex); | ||
| 128 | + path = afterPrefix.substring(slashIndex + 1); | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + // 解析host:port | ||
| 132 | + int colonIndex = hostPort.indexOf(":"); | ||
| 133 | + if (colonIndex == -1) { | ||
| 134 | + this.host = hostPort; | ||
| 135 | + this.port = 1935; | ||
| 136 | + } else { | ||
| 137 | + this.host = hostPort.substring(0, colonIndex); | ||
| 138 | + this.port = Integer.parseInt(hostPort.substring(colonIndex + 1)); | ||
| 139 | + } | ||
| 140 | + | ||
| 141 | + // app是路径的第一部分 | ||
| 142 | + if (path.length() > 0) { | ||
| 143 | + this.app = path; | ||
| 144 | + this.streamName = tag; | ||
| 145 | + this.tcUrl = String.format("rtmp://%s:%d/%s", host, port, path); | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + } catch (Exception e) { | ||
| 149 | + logger.error("[{}] 解析tcUrl失败: {}", tag, e.getMessage()); | ||
| 150 | + } | ||
| 151 | + } | ||
| 152 | + | ||
| 153 | + @Override | ||
| 154 | + public void start(String tag) { | ||
| 155 | + this.tag = tag; | ||
| 156 | + | ||
| 157 | + logger.info("[{}] ====== 启动Netty RTMP推流器 ======", tag); | ||
| 158 | + logger.info("[{}] 目标服务器: {}:{}", tag, host, port); | ||
| 159 | + logger.info("[{}] 推流地址: {}/{}", tag, tcUrl, streamName); | ||
| 160 | + | ||
| 161 | + try { | ||
| 162 | + // 创建 Netty RTMP 客户端 | ||
| 163 | + client = new NettyRtmpClient(tcUrl, app, streamName); | ||
| 164 | + client.start(); | ||
| 165 | + | ||
| 166 | + connected = true; | ||
| 167 | + logger.info("[{}] ====== Netty RTMP推流器启动成功 ======", tag); | ||
| 168 | + | ||
| 169 | + } catch (Exception e) { | ||
| 170 | + logger.error("[{}] Netty RTMP推流器启动异常: {}", tag, e.getMessage(), e); | ||
| 171 | + connected = false; | ||
| 172 | + } | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + @Override | ||
| 176 | + public void sendVideoData(byte[] flvData, int timestamp) { | ||
| 177 | + if (!connected || client == null) { | ||
| 178 | + logger.debug("[{}] sendVideoData: not connected", tag); | ||
| 179 | + return; | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | + try { | ||
| 183 | + // 将 byte[] 转换为 Netty ByteBuf | ||
| 184 | + ByteBuf buf = Unpooled.wrappedBuffer(flvData); | ||
| 185 | + client.send(buf); | ||
| 186 | + | ||
| 187 | + logger.debug("[{}] sendVideoData: flvDataLen={}, timestamp={}", tag, flvData.length, timestamp); | ||
| 188 | + } catch (Exception e) { | ||
| 189 | + logger.error("[{}] 发送视频数据失败: {}", tag, e.getMessage()); | ||
| 190 | + connected = false; | ||
| 191 | + } | ||
| 192 | + } | ||
| 193 | + | ||
| 194 | + @Override | ||
| 195 | + public void sendAVCSequenceHeader() { | ||
| 196 | + // Netty 版本不需要单独发送 Sequence Header | ||
| 197 | + // 因为 FLV Tag 已经包含了 Sequence Header 信息 | ||
| 198 | + logger.debug("[{}] sendAVCSequenceHeader: 无需操作(由FLV Tag携带)", tag); | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + @Override | ||
| 202 | + public void close() { | ||
| 203 | + logger.info("[{}] ====== 关闭Netty RTMP推流器 ======", tag); | ||
| 204 | + | ||
| 205 | + connected = false; | ||
| 206 | + | ||
| 207 | + if (client != null) { | ||
| 208 | + try { | ||
| 209 | + client.close(); | ||
| 210 | + } catch (Exception e) { | ||
| 211 | + logger.debug("[{}] 关闭客户端时出错: {}", tag, e.getMessage()); | ||
| 212 | + } | ||
| 213 | + client = null; | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + logger.info("[{}] Netty RTMP推流器已关闭", tag); | ||
| 217 | + } | ||
| 218 | + | ||
| 219 | + @Override | ||
| 220 | + public boolean isConnected() { | ||
| 221 | + return connected && client != null; | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + @Override | ||
| 225 | + public String getType() { | ||
| 226 | + return PUBLISHER_TYPE; | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + @Override | ||
| 230 | + public String getStatus() { | ||
| 231 | + if (connected) { | ||
| 232 | + return String.format("Netty RTMP推流器, 已连接, streamName=%s", streamName); | ||
| 233 | + } else { | ||
| 234 | + return "Netty RTMP推流器, 未连接"; | ||
| 235 | + } | ||
| 236 | + } | ||
| 237 | +} | ||
| 0 | \ No newline at end of file | 238 | \ No newline at end of file |
src/main/java/com/genersoft/iot/vmp/jtt1078/publisher/PublishManager.java
| @@ -48,7 +48,11 @@ public final class PublishManager | @@ -48,7 +48,11 @@ public final class PublishManager | ||
| 48 | public void publishVideo(String tag, int sequence, long timestamp, int payloadType, byte[] data) | 48 | public void publishVideo(String tag, int sequence, long timestamp, int payloadType, byte[] data) |
| 49 | { | 49 | { |
| 50 | Channel chl = channels.get(tag); | 50 | Channel chl = channels.get(tag); |
| 51 | - if (chl != null) chl.writeVideo(sequence, timestamp, payloadType, data); | 51 | + if (chl == null) { |
| 52 | + logger.warn("[{}] publishVideo: Channel不存在, 跳过视频数据, channels.size={}", tag, channels.size()); | ||
| 53 | + return; | ||
| 54 | + } | ||
| 55 | + chl.writeVideo(sequence, timestamp, payloadType, data); | ||
| 52 | } | 56 | } |
| 53 | 57 | ||
| 54 | public Channel open(String tag) | 58 | public Channel open(String tag) |
src/main/java/com/genersoft/iot/vmp/jtt1078/publisher/StreamPublisherFactory.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.publisher; | ||
| 2 | + | ||
| 3 | +import com.genersoft.iot.vmp.jtt1078.util.Configs; | ||
| 4 | + | ||
| 5 | +/** | ||
| 6 | + * 推流器工厂类 | ||
| 7 | + * | ||
| 8 | + * 根据配置决定创建FFmpeg推流器还是原生RTMP推流器 | ||
| 9 | + * | ||
| 10 | + * 配置项: | ||
| 11 | + * - publisher.type: ffmpeg | native | netty (默认ffmpeg) | ||
| 12 | + * - publisher.native.enabled: true | false (备用配置项) | ||
| 13 | + */ | ||
| 14 | +public class StreamPublisherFactory { | ||
| 15 | + | ||
| 16 | + /** 推流器类型常量 */ | ||
| 17 | + public static final String TYPE_FFMPEG = "ffmpeg"; | ||
| 18 | + public static final String TYPE_NATIVE = "native"; | ||
| 19 | + public static final String TYPE_NETTY = "netty"; | ||
| 20 | + | ||
| 21 | + /** 默认推流器类型 */ | ||
| 22 | + private static final String DEFAULT_TYPE = TYPE_FFMPEG; | ||
| 23 | + | ||
| 24 | + /** | ||
| 25 | + * 创建推流器实例 | ||
| 26 | + * | ||
| 27 | + * @param tag 通道标识 | ||
| 28 | + * @return IStreamPublisher实现实例 | ||
| 29 | + */ | ||
| 30 | + public static IStreamPublisher create(String tag) { | ||
| 31 | + String type = getPublisherType(); | ||
| 32 | + | ||
| 33 | + if (TYPE_NETTY.equalsIgnoreCase(type)) { | ||
| 34 | + try { | ||
| 35 | + return new NettyRtmpPublisher(tag); | ||
| 36 | + } catch (Exception e) { | ||
| 37 | + System.err.println("[" + tag + "] 创建Netty RTMP推流器失败,回退到FFmpeg: " + e.getMessage()); | ||
| 38 | + return createFFmpegPublisher(tag); | ||
| 39 | + } | ||
| 40 | + } else if (TYPE_NATIVE.equalsIgnoreCase(type)) { | ||
| 41 | + try { | ||
| 42 | + return new NativeRtmpPublisher(tag); | ||
| 43 | + } catch (Exception e) { | ||
| 44 | + // 如果原生RTMP创建失败,回退到FFmpeg | ||
| 45 | + System.err.println("[" + tag + "] 创建原生RTMP推流器失败,回退到FFmpeg: " + e.getMessage()); | ||
| 46 | + return createFFmpegPublisher(tag); | ||
| 47 | + } | ||
| 48 | + } else { | ||
| 49 | + return createFFmpegPublisher(tag); | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + /** | ||
| 54 | + * 创建FFmpeg推流器 | ||
| 55 | + */ | ||
| 56 | + private static IStreamPublisher createFFmpegPublisher(String tag) { | ||
| 57 | + return new FFmpegPublisher(tag); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + /** | ||
| 61 | + * 获取配置的推流器类型 | ||
| 62 | + */ | ||
| 63 | + private static String getPublisherType() { | ||
| 64 | + // 优先使用publisher.type配置 | ||
| 65 | + String type = Configs.get("publisher.type"); | ||
| 66 | + if (type != null && !type.trim().isEmpty()) { | ||
| 67 | + return type.trim().toLowerCase(); | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + // 备用配置项检查 | ||
| 71 | + String nativeEnabled = Configs.get("publisher.native.enabled"); | ||
| 72 | + if ("true".equalsIgnoreCase(nativeEnabled)) { | ||
| 73 | + return TYPE_NATIVE; | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + return DEFAULT_TYPE; | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + /** | ||
| 80 | + * 检查是否使用原生RTMP推流器 | ||
| 81 | + */ | ||
| 82 | + public static boolean isNativeRtmpEnabled() { | ||
| 83 | + return TYPE_NATIVE.equalsIgnoreCase(getPublisherType()) || TYPE_NETTY.equalsIgnoreCase(getPublisherType()); | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + /** | ||
| 87 | + * 获取推流器类型描述 | ||
| 88 | + */ | ||
| 89 | + public static String getPublisherTypeDescription() { | ||
| 90 | + String type = getPublisherType(); | ||
| 91 | + switch (type) { | ||
| 92 | + case TYPE_NETTY: | ||
| 93 | + return "Netty RTMP推流器(非阻塞)"; | ||
| 94 | + case TYPE_NATIVE: | ||
| 95 | + return "原生RTMP推流器(无缝码流切换)"; | ||
| 96 | + case TYPE_FFMPEG: | ||
| 97 | + default: | ||
| 98 | + return "FFmpeg推流器(码流切换需重启)"; | ||
| 99 | + } | ||
| 100 | + } | ||
| 101 | +} |
src/main/java/com/genersoft/iot/vmp/jtt1078/rtmp/Amf0Util.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.rtmp; | ||
| 2 | + | ||
| 3 | +import io.netty.buffer.ByteBuf; | ||
| 4 | +import java.util.Map; | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * AMF0 编码工具类 | ||
| 8 | + */ | ||
| 9 | +public class Amf0Util { | ||
| 10 | + | ||
| 11 | + // AMF0 Markers | ||
| 12 | + public static final byte NUMBER = 0x00; | ||
| 13 | + public static final byte BOOLEAN = 0x01; | ||
| 14 | + public static final byte STRING = 0x02; | ||
| 15 | + public static final byte OBJECT = 0x03; | ||
| 16 | + public static final byte NULL = 0x05; | ||
| 17 | + public static final byte ECMA_ARRAY = 0x08; | ||
| 18 | + public static final byte OBJECT_END = 0x09; | ||
| 19 | + | ||
| 20 | + public static void writeString(ByteBuf out, String str) { | ||
| 21 | + out.writeByte(STRING); | ||
| 22 | + writeStringBare(out, str); | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + // 不带 Marker 的字符串(用于 Object 的 Key) | ||
| 26 | + public static void writeStringBare(ByteBuf out, String str) { | ||
| 27 | + if (str == null) str = ""; | ||
| 28 | + byte[] bytes = str.getBytes(); | ||
| 29 | + out.writeShort(bytes.length); | ||
| 30 | + out.writeBytes(bytes); | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + public static void writeNumber(ByteBuf out, double num) { | ||
| 34 | + out.writeByte(NUMBER); | ||
| 35 | + out.writeLong(Double.doubleToLongBits(num)); | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + public static void writeBoolean(ByteBuf out, boolean val) { | ||
| 39 | + out.writeByte(BOOLEAN); | ||
| 40 | + out.writeByte(val ? 1 : 0); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + public static void writeNull(ByteBuf out) { | ||
| 44 | + out.writeByte(NULL); | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + public static void writeObject(ByteBuf out, Map<String, Object> props) { | ||
| 48 | + out.writeByte(OBJECT); | ||
| 49 | + if (props != null) { | ||
| 50 | + for (Map.Entry<String, Object> entry : props.entrySet()) { | ||
| 51 | + writeStringBare(out, entry.getKey()); | ||
| 52 | + Object val = entry.getValue(); | ||
| 53 | + if (val instanceof String) writeString(out, (String) val); | ||
| 54 | + else if (val instanceof Number) writeNumber(out, ((Number) val).doubleValue()); | ||
| 55 | + else if (val instanceof Boolean) writeBoolean(out, (Boolean) val); | ||
| 56 | + else writeNull(out); | ||
| 57 | + } | ||
| 58 | + } | ||
| 59 | + // Object End Marker (00 00 09) | ||
| 60 | + out.writeMedium(0x000009); | ||
| 61 | + } | ||
| 62 | +} | ||
| 0 | \ No newline at end of file | 63 | \ No newline at end of file |
src/main/java/com/genersoft/iot/vmp/jtt1078/rtmp/NettyRtmpClient.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.rtmp; | ||
| 2 | + | ||
| 3 | +import io.netty.bootstrap.Bootstrap; | ||
| 4 | +import io.netty.buffer.ByteBuf; | ||
| 5 | +import io.netty.channel.*; | ||
| 6 | +import io.netty.channel.nio.NioEventLoopGroup; | ||
| 7 | +import io.netty.channel.socket.SocketChannel; | ||
| 8 | +import io.netty.channel.socket.nio.NioSocketChannel; | ||
| 9 | +import org.slf4j.Logger; | ||
| 10 | +import org.slf4j.LoggerFactory; | ||
| 11 | + | ||
| 12 | +import java.net.URI; | ||
| 13 | +import java.net.URISyntaxException; | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * Netty RTMP 客户端 | ||
| 17 | + */ | ||
| 18 | +public class NettyRtmpClient { | ||
| 19 | + private static final Logger logger = LoggerFactory.getLogger(NettyRtmpClient.class); | ||
| 20 | + | ||
| 21 | + // 共享线程池 | ||
| 22 | + private static final EventLoopGroup sharedGroup = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors() * 2); | ||
| 23 | + | ||
| 24 | + private Channel channel; | ||
| 25 | + private final String rtmpUrl; | ||
| 26 | + private final String app; | ||
| 27 | + private final String stream; | ||
| 28 | + | ||
| 29 | + // 增加一个状态标记,只有握手完成才能发送数据 | ||
| 30 | + private volatile boolean isReady = false; | ||
| 31 | + | ||
| 32 | + public NettyRtmpClient(String rtmpUrl, String app, String stream) { | ||
| 33 | + this.rtmpUrl = rtmpUrl; | ||
| 34 | + this.app = app; | ||
| 35 | + this.stream = stream; | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + public void start() { | ||
| 39 | + // 1. 解析 RTMP URL 获取 IP 和 端口 | ||
| 40 | + URI uri; | ||
| 41 | + try { | ||
| 42 | + // Java URI 解析 rtmp:// 有时会报错,简单处理去掉 scheme | ||
| 43 | + String tempUrl = rtmpUrl.replace("rtmp://", "http://"); | ||
| 44 | + uri = new URI(tempUrl); | ||
| 45 | + } catch (URISyntaxException e) { | ||
| 46 | + logger.error("[{}] RTMP URL 格式错误: {}", stream, rtmpUrl); | ||
| 47 | + return; | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + String host = uri.getHost(); | ||
| 51 | + int port = uri.getPort(); | ||
| 52 | + if (port == -1) port = 1935; // 默认 RTMP 端口 | ||
| 53 | + | ||
| 54 | + Bootstrap b = new Bootstrap(); | ||
| 55 | + b.group(sharedGroup) | ||
| 56 | + .channel(NioSocketChannel.class) | ||
| 57 | + .option(ChannelOption.TCP_NODELAY, true) // 禁用 Nagle 算法,降低延迟 | ||
| 58 | + .handler(new ChannelInitializer<SocketChannel>() { | ||
| 59 | + @Override | ||
| 60 | + protected void initChannel(SocketChannel ch) { | ||
| 61 | + // 传入回调,当 Handler 状态变为 STREAMING 时通知 Client | ||
| 62 | + RtmpHandshakeHandler handler = new RtmpHandshakeHandler(app, rtmpUrl, stream); | ||
| 63 | + handler.setOnReadyListener(() -> isReady = true); | ||
| 64 | + ch.pipeline().addLast(handler); | ||
| 65 | + } | ||
| 66 | + }); | ||
| 67 | + | ||
| 68 | + logger.info("[{}] 正在连接 RTMP 服务器: {}:{}", stream, host, port); | ||
| 69 | + b.connect(host, port).addListener((ChannelFutureListener) future -> { | ||
| 70 | + if (future.isSuccess()) { | ||
| 71 | + this.channel = future.channel(); | ||
| 72 | + logger.info("[{}] RTMP TCP 连接成功: {}", stream, rtmpUrl); | ||
| 73 | + } else { | ||
| 74 | + logger.error("[{}] RTMP 连接失败: {}", stream, rtmpUrl, future.cause()); | ||
| 75 | + } | ||
| 76 | + }); | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + public void send(ByteBuf flvTag) { | ||
| 80 | + // 【关键修复】 | ||
| 81 | + // 1. 必须 channel active | ||
| 82 | + // 2. 必须 isReady (RTMP 握手已完成) | ||
| 83 | + // 否则直接丢弃包并释放内存,绝对不能发给服务器! | ||
| 84 | + if (channel != null && channel.isActive() && isReady) { | ||
| 85 | + channel.writeAndFlush(flvTag); | ||
| 86 | + } else { | ||
| 87 | + // 如果还没握手完成就发包,服务器会报错非法 BodySize | ||
| 88 | + // 必须释放 flvTag,否则内存泄漏 | ||
| 89 | + if (flvTag.refCnt() > 0) { | ||
| 90 | + flvTag.release(); | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + public void close() { | ||
| 96 | + if (channel != null) { | ||
| 97 | + channel.close(); | ||
| 98 | + } | ||
| 99 | + } | ||
| 100 | +} | ||
| 0 | \ No newline at end of file | 101 | \ No newline at end of file |
src/main/java/com/genersoft/iot/vmp/jtt1078/rtmp/RtmpChunkWriter.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.rtmp; | ||
| 2 | + | ||
| 3 | +import org.slf4j.Logger; | ||
| 4 | +import org.slf4j.LoggerFactory; | ||
| 5 | + | ||
| 6 | +import java.io.IOException; | ||
| 7 | + | ||
| 8 | +/** | ||
| 9 | + * FLV数据到RTMP消息的转换器 | ||
| 10 | + * | ||
| 11 | + * 功能: | ||
| 12 | + * 1. 解析FLV Video Tag,提取H.264 NAL单元 | ||
| 13 | + * 2. 将FLV时间戳转换为RTMP时间戳 | ||
| 14 | + * 3. 处理SPS/PPS序列头 | ||
| 15 | + * 4. 处理I帧和P帧 | ||
| 16 | + * | ||
| 17 | + * FLV Video Tag格式: | ||
| 18 | + * [PreviousTagSize(4)][Tag Type(1)][DataSize(3)][Timestamp(3)][TimestampExt(1)][StreamID(3)][VideoData] | ||
| 19 | + * | ||
| 20 | + * VideoData格式(H.264): | ||
| 21 | + * [FrameType(1)][AVCPacketType(1)][CompositionTime(3)][NALUs] | ||
| 22 | + * | ||
| 23 | + * - FrameType: 0x17=I帧, 0x27=P/B帧 | ||
| 24 | + * - AVCPacketType: 0x00=AVC Sequence Header, 0x01=NALU | ||
| 25 | + * - CompositionTime: pts - dts偏移 | ||
| 26 | + */ | ||
| 27 | +public class RtmpChunkWriter { | ||
| 28 | + | ||
| 29 | + static Logger logger = LoggerFactory.getLogger(RtmpChunkWriter.class); | ||
| 30 | + | ||
| 31 | + private RtmpConnection connection; | ||
| 32 | + private String tag; | ||
| 33 | + | ||
| 34 | + // 时间戳 | ||
| 35 | + private int lastVideoTimestamp = 0; | ||
| 36 | + private int lastAudioTimestamp = 0; | ||
| 37 | + | ||
| 38 | + // 关键标记 | ||
| 39 | + private boolean hasSentAVCSequenceHeader = false; | ||
| 40 | + private byte[] lastSPS = null; | ||
| 41 | + private byte[] lastPPS = null; | ||
| 42 | + | ||
| 43 | + // FLV Tag类型常量 | ||
| 44 | + private static final byte FLV_TAG_TYPE_VIDEO = 9; | ||
| 45 | + private static final byte FLV_TAG_TYPE_AUDIO = 8; | ||
| 46 | + | ||
| 47 | + // AVC NAL单元类型 | ||
| 48 | + private static final int NAL_UNIT_TYPE_SPS = 7; | ||
| 49 | + private static final int NAL_UNIT_TYPE_PPS = 8; | ||
| 50 | + private static final int NAL_UNIT_TYPE_IDR = 5; | ||
| 51 | + private static final int NAL_UNIT_TYPE_NON_IDR = 1; | ||
| 52 | + | ||
| 53 | + public RtmpChunkWriter(RtmpConnection connection, String tag) { | ||
| 54 | + this.connection = connection; | ||
| 55 | + this.tag = tag; | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + /** | ||
| 59 | + * 发送FLV视频数据 | ||
| 60 | + * | ||
| 61 | + * @param flvData 完整的FLV Video Tag数据 | ||
| 62 | + * @param timestamp FLV中的时间戳 | ||
| 63 | + */ | ||
| 64 | + public void sendVideoData(byte[] flvData, int timestamp) throws IOException { | ||
| 65 | + if (flvData == null || flvData.length < 16) { | ||
| 66 | + return; | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + // 跳过PreviousTagSize (4字节) | ||
| 70 | + int offset = 4; | ||
| 71 | + | ||
| 72 | + // 检查Tag Type | ||
| 73 | + if (flvData[offset] != FLV_TAG_TYPE_VIDEO) { | ||
| 74 | + return; | ||
| 75 | + } | ||
| 76 | + offset++; | ||
| 77 | + | ||
| 78 | + // 读取DataSize (3字节, big-endian) | ||
| 79 | + int dataSize = ((flvData[offset] & 0xFF) << 16) | | ||
| 80 | + ((flvData[offset + 1] & 0xFF) << 8) | | ||
| 81 | + (flvData[offset + 2] & 0xFF); | ||
| 82 | + offset += 3; | ||
| 83 | + | ||
| 84 | + // 跳过Timestamp (3字节) + TimestampExt (1字节) | ||
| 85 | + offset += 4; | ||
| 86 | + | ||
| 87 | + // 跳过StreamID (3字节) | ||
| 88 | + offset += 3; | ||
| 89 | + | ||
| 90 | + // 现在offset指向VideoData | ||
| 91 | + if (offset >= flvData.length) { | ||
| 92 | + return; | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + byte videoInfo = flvData[offset]; // FrameType + CodecID | ||
| 96 | + offset++; | ||
| 97 | + | ||
| 98 | + byte avcPacketType = flvData[offset]; // AVCPacketType | ||
| 99 | + offset++; | ||
| 100 | + | ||
| 101 | + // 读取CompositionTime (3字节) | ||
| 102 | + int compositionTime = ((flvData[offset] & 0xFF) << 16) | | ||
| 103 | + ((flvData[offset + 1] & 0xFF) << 8) | | ||
| 104 | + (flvData[offset + 2] & 0xFF); | ||
| 105 | + offset += 3; | ||
| 106 | + | ||
| 107 | + // 计算RTMP时间戳 | ||
| 108 | + // FLV timestamp单位是毫秒,RTMP也是毫秒 | ||
| 109 | + int rtmpTimestamp = timestamp & 0xFFFFFF; // 24位时间戳 | ||
| 110 | + lastVideoTimestamp = rtmpTimestamp; | ||
| 111 | + | ||
| 112 | + if (avcPacketType == 0x00) { | ||
| 113 | + // AVC Sequence Header (SPS/PPS) | ||
| 114 | + logger.info("[{}] 收到AVC Sequence Header (SPS/PPS), offset={}, remaining={}", tag, offset, flvData.length - offset); | ||
| 115 | + handleAVCSequenceHeader(flvData, offset); | ||
| 116 | + } else if (avcPacketType == 0x01) { | ||
| 117 | + // NALU | ||
| 118 | + logger.info("[{}] 收到NALU数据, timestamp={}, offset={}, remaining={}", tag, rtmpTimestamp, offset, flvData.length - offset); | ||
| 119 | + handleNALU(flvData, offset, rtmpTimestamp); | ||
| 120 | + } else { | ||
| 121 | + logger.warn("[{}] 未知AVCPacketType: 0x{}, 数据长度={}", tag, String.format("%02X", avcPacketType), flvData.length); | ||
| 122 | + } | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + /** | ||
| 126 | + * 处理AVC Sequence Header | ||
| 127 | + * | ||
| 128 | + * FLV中封装的AVCDecoderConfigurationRecord: | ||
| 129 | + * [configurationVersion(1)][AVCProfileIndication(1)][profile_compatibility(1)][AVCLevelIndication(1)] | ||
| 130 | + * [lengthSizeMinusOne(1)][numOfSequenceParameterSets(1)][SPS length(2)][SPS data][numOfPictureParameterSets(1)][PPS length(2)][PPS data] | ||
| 131 | + */ | ||
| 132 | + private void handleAVCSequenceHeader(byte[] data, int offset) { | ||
| 133 | + try { | ||
| 134 | + // 解析AVCDecoderConfigurationRecord | ||
| 135 | + int pos = offset; | ||
| 136 | + | ||
| 137 | + byte configurationVersion = data[pos++]; | ||
| 138 | + byte AVCProfileIndication = data[pos++]; | ||
| 139 | + byte profileCompatibility = data[pos++]; | ||
| 140 | + byte AVCLevelIndication = data[pos++]; | ||
| 141 | + | ||
| 142 | + byte lengthSizeMinusOne = data[pos++]; // 通常是3,表示NALU长度占4字节 | ||
| 143 | + | ||
| 144 | + // SPS | ||
| 145 | + byte numOfSequenceParameterSets = data[pos++]; | ||
| 146 | + int spsLength = ((data[pos] & 0xFF) << 8) | (data[pos + 1] & 0xFF); | ||
| 147 | + pos += 2; | ||
| 148 | + lastSPS = new byte[spsLength]; | ||
| 149 | + System.arraycopy(data, pos, lastSPS, 0, spsLength); | ||
| 150 | + pos += spsLength; | ||
| 151 | + | ||
| 152 | + // PPS | ||
| 153 | + byte numOfPictureParameterSets = data[pos++]; | ||
| 154 | + int ppsLength = ((data[pos] & 0xFF) << 8) | (data[pos + 1] & 0xFF); | ||
| 155 | + pos += 2; | ||
| 156 | + lastPPS = new byte[ppsLength]; | ||
| 157 | + System.arraycopy(data, pos, lastPPS, 0, ppsLength); | ||
| 158 | + | ||
| 159 | + hasSentAVCSequenceHeader = false; // 重置,强制发送新的 | ||
| 160 | + | ||
| 161 | + // 调试日志:打印SPS/PPS的十六进制 | ||
| 162 | + StringBuilder spsHex = new StringBuilder(); | ||
| 163 | + for (int i = 0; i < Math.min(lastSPS.length, 20); i++) { | ||
| 164 | + spsHex.append(String.format("%02X ", lastSPS[i] & 0xFF)); | ||
| 165 | + } | ||
| 166 | + StringBuilder ppsHex = new StringBuilder(); | ||
| 167 | + for (int i = 0; i < Math.min(lastPPS.length, 10); i++) { | ||
| 168 | + ppsHex.append(String.format("%02X ", lastPPS[i] & 0xFF)); | ||
| 169 | + } | ||
| 170 | + logger.info("[{}] 解析到AVC Sequence Header: SPS长度={}, PPS长度={}, SPS前20字节=[{}], PPS前10字节=[{}], profile={}, level={}", | ||
| 171 | + tag, spsLength, ppsLength, spsHex.toString(), ppsHex.toString(), | ||
| 172 | + String.format("0x%02X", AVCProfileIndication), String.format("0x%02X", AVCLevelIndication)); | ||
| 173 | + | ||
| 174 | + } catch (Exception e) { | ||
| 175 | + logger.error("[{}] 解析AVC Sequence Header失败: {}", tag, e.getMessage()); | ||
| 176 | + } | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + /** | ||
| 180 | + * 处理NALU数据 | ||
| 181 | + */ | ||
| 182 | + private void handleNALU(byte[] data, int offset, int timestamp) throws IOException { | ||
| 183 | + if (lastSPS == null || lastPPS == null) { | ||
| 184 | + logger.warn("[{}] 还未收到SPS/PPS,跳过NALU. lastSPS={}, lastPPS={}", tag, | ||
| 185 | + lastSPS != null ? "set(" + lastSPS.length + ")" : "null", | ||
| 186 | + lastPPS != null ? "set(" + lastPPS.length + ")" : "null"); | ||
| 187 | + return; | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + // 发送AVC Sequence Header(如果还没发送) | ||
| 191 | + if (!hasSentAVCSequenceHeader) { | ||
| 192 | + sendAVCSequenceHeaderPacket(); | ||
| 193 | + hasSentAVCSequenceHeader = true; | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + // 解析NALU | ||
| 197 | + // FLV中NALU格式: [NALU长度(4字节)][NALU数据...] | ||
| 198 | + int pos = offset; | ||
| 199 | + int nalType = -1; | ||
| 200 | + | ||
| 201 | + while (pos + 4 < data.length) { | ||
| 202 | + // 读取NALU长度 | ||
| 203 | + int nalLength = ((data[pos] & 0xFF) << 24) | | ||
| 204 | + ((data[pos + 1] & 0xFF) << 16) | | ||
| 205 | + ((data[pos + 2] & 0xFF) << 8) | | ||
| 206 | + (data[pos + 3] & 0xFF); | ||
| 207 | + pos += 4; | ||
| 208 | + | ||
| 209 | + if (pos + nalLength > data.length) { | ||
| 210 | + break; | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + nalType = data[pos] & 0x1F; | ||
| 214 | + | ||
| 215 | + // 构建RTMP视频数据 | ||
| 216 | + // 格式: [FrameType(1)][AVCPacketType(1)][CompositionTime(3)][NALU] | ||
| 217 | + byte[] rtmpData = new byte[5 + nalLength]; | ||
| 218 | + rtmpData[0] = (byte) (nalType == NAL_UNIT_TYPE_IDR ? 0x17 : 0x27); // I帧或P帧 | ||
| 219 | + rtmpData[1] = 0x01; // AVCPacketType = NALU | ||
| 220 | + rtmpData[2] = 0x00; // CompositionTime | ||
| 221 | + rtmpData[3] = 0x00; | ||
| 222 | + rtmpData[4] = 0x00; | ||
| 223 | + | ||
| 224 | + // 复制NALU数据 | ||
| 225 | + System.arraycopy(data, pos, rtmpData, 5, nalLength); | ||
| 226 | + | ||
| 227 | + // 打印NALU的十六进制便于调试 | ||
| 228 | + StringBuilder hex = new StringBuilder(); | ||
| 229 | + for (int i = 0; i < Math.min(nalLength, 20); i++) { | ||
| 230 | + hex.append(String.format("%02X ", rtmpData[5 + i] & 0xFF)); | ||
| 231 | + } | ||
| 232 | + if (nalLength > 20) { | ||
| 233 | + hex.append("...(").append(nalLength).append(" bytes total)"); | ||
| 234 | + } | ||
| 235 | + logger.info("[{}] 发送NALU到RTMP: nalType=0x{}, timestamp={}, nalLength={}, firstBytes={}", tag, | ||
| 236 | + Integer.toHexString(nalType), timestamp, nalLength, hex.toString()); | ||
| 237 | + | ||
| 238 | + // 发送到RTMP | ||
| 239 | + connection.sendVideoData(rtmpData, timestamp); | ||
| 240 | + | ||
| 241 | + pos += nalLength; | ||
| 242 | + } | ||
| 243 | + } | ||
| 244 | + | ||
| 245 | + /** | ||
| 246 | + * 发送AVC Sequence Header到RTMP | ||
| 247 | + * 这是实现无缝码流切换的关键! | ||
| 248 | + * | ||
| 249 | + * 注意:lastSPS和lastPPS是从FLV数据中提取的,不包含start code | ||
| 250 | + * FLV中的SPS格式: [profile_idc(1)][constraint(1)][level_idc(1)][...] | ||
| 251 | + * 实际H.264 SPS (Annex B): [00 00 00 01][67][profile_idc][constraint][level_idc][...] | ||
| 252 | + */ | ||
| 253 | + private void sendAVCSequenceHeaderPacket() throws IOException { | ||
| 254 | + if (lastSPS == null || lastPPS == null) { | ||
| 255 | + logger.warn("[{}] SPS/PPS为空,无法发送Sequence Header", tag); | ||
| 256 | + return; | ||
| 257 | + } | ||
| 258 | + | ||
| 259 | + // 构建AVCDecoderConfigurationRecord | ||
| 260 | + // 这个格式和FLV中的略有不同,需要转换 | ||
| 261 | + int recordLength = 11 + lastSPS.length + lastPPS.length; | ||
| 262 | + byte[] configRecord = new byte[recordLength]; | ||
| 263 | + | ||
| 264 | + int pos = 0; | ||
| 265 | + configRecord[pos++] = 0x01; // configurationVersion | ||
| 266 | + // SPS是从FLV提取的,没有start code | ||
| 267 | + // FLV SPS: [profile_idc(1)][constraint(1)][level_idc(1)][...] | ||
| 268 | + // 所以 profile_idc 在 lastSPS[0], constraint 在 lastSPS[1], level 在 lastSPS[2] | ||
| 269 | + configRecord[pos++] = lastSPS.length > 0 ? lastSPS[0] : 0x64; // AVCProfileIndication | ||
| 270 | + configRecord[pos++] = lastSPS.length > 1 ? lastSPS[1] : 0x00; // profile_compatibility | ||
| 271 | + configRecord[pos++] = lastSPS.length > 2 ? lastSPS[2] : 0x1F; // AVCLevelIndication | ||
| 272 | + configRecord[pos++] = (byte) 0xFF; // lengthSizeMinusOne = 3 (NAL长度占4字节) | ||
| 273 | + | ||
| 274 | + // SPS | ||
| 275 | + configRecord[pos++] = (byte) 0xE1; // numOfSequenceParameterSets = 1 | ||
| 276 | + configRecord[pos++] = (byte) ((lastSPS.length >> 8) & 0xFF); // SPS length high | ||
| 277 | + configRecord[pos++] = (byte) (lastSPS.length & 0xFF); // SPS length low | ||
| 278 | + System.arraycopy(lastSPS, 0, configRecord, pos, lastSPS.length); | ||
| 279 | + pos += lastSPS.length; | ||
| 280 | + | ||
| 281 | + // PPS (同样没有start code) | ||
| 282 | + configRecord[pos++] = 0x01; // numOfPictureParameterSets = 1 | ||
| 283 | + configRecord[pos++] = (byte) ((lastPPS.length >> 8) & 0xFF); // PPS length high | ||
| 284 | + configRecord[pos++] = (byte) (lastPPS.length & 0xFF); // PPS length low | ||
| 285 | + System.arraycopy(lastPPS, 0, configRecord, pos, lastPPS.length); | ||
| 286 | + | ||
| 287 | + // 构建RTMP视频数据 | ||
| 288 | + // [FrameType(1)][AVCPacketType(1)][CompositionTime(3)][ConfigRecord] | ||
| 289 | + byte[] rtmpData = new byte[5 + recordLength]; | ||
| 290 | + rtmpData[0] = 0x17; // AVC sequence header (I-frame) | ||
| 291 | + rtmpData[1] = 0x00; // AVCPacketType = AVC sequence header | ||
| 292 | + rtmpData[2] = 0x00; | ||
| 293 | + rtmpData[3] = 0x00; | ||
| 294 | + rtmpData[4] = 0x00; | ||
| 295 | + System.arraycopy(configRecord, 0, rtmpData, 5, recordLength); | ||
| 296 | + | ||
| 297 | + // 打印Sequence Header的十六进制便于调试 | ||
| 298 | + StringBuilder hex = new StringBuilder(); | ||
| 299 | + for (int i = 0; i < Math.min(rtmpData.length, 64); i++) { | ||
| 300 | + hex.append(String.format("%02X ", rtmpData[i] & 0xFF)); | ||
| 301 | + } | ||
| 302 | + if (rtmpData.length > 64) { | ||
| 303 | + hex.append("...(").append(rtmpData.length).append(" bytes total)"); | ||
| 304 | + } | ||
| 305 | + logger.info("[{}] 发送AVC Sequence Header到RTMP, 数据长度={}, hex={}", tag, rtmpData.length, hex.toString()); | ||
| 306 | + logger.info("[{}] SPS profile/level: configRecord[1]={}, configRecord[3]={}", | ||
| 307 | + tag, String.format("0x%02X", configRecord[1]), String.format("0x%02X", configRecord[3])); | ||
| 308 | + | ||
| 309 | + // 发送到RTMP | ||
| 310 | + connection.sendVideoData(rtmpData, 0); | ||
| 311 | + } | ||
| 312 | + | ||
| 313 | + /** | ||
| 314 | + * 强制发送新的AVC Sequence Header | ||
| 315 | + * 用于码流切换时立即发送新的SPS/PPS | ||
| 316 | + */ | ||
| 317 | + public void forceSendAVCSequenceHeader() { | ||
| 318 | + hasSentAVCSequenceHeader = false; | ||
| 319 | + } | ||
| 320 | + | ||
| 321 | + /** | ||
| 322 | + * 更新SPS/PPS(当检测到码流变化时调用) | ||
| 323 | + */ | ||
| 324 | + public void updateSPSPPS(byte[] sps, byte[] pps) { | ||
| 325 | + this.lastSPS = sps; | ||
| 326 | + this.lastPPS = pps; | ||
| 327 | + this.hasSentAVCSequenceHeader = false; // 重置,强制发送新的 | ||
| 328 | + logger.info("[{}] 更新SPS/PPS: SPS长度={}, PPS长度={}", tag, | ||
| 329 | + sps != null ? sps.length : 0, pps != null ? pps.length : 0); | ||
| 330 | + } | ||
| 331 | + | ||
| 332 | + /** | ||
| 333 | + * 发送FLV音频数据 | ||
| 334 | + */ | ||
| 335 | + public void sendAudioData(byte[] flvData, int timestamp) throws IOException { | ||
| 336 | + if (flvData == null || flvData.length < 16) { | ||
| 337 | + return; | ||
| 338 | + } | ||
| 339 | + | ||
| 340 | + // 跳过PreviousTagSize (4字节) | ||
| 341 | + int offset = 4; | ||
| 342 | + | ||
| 343 | + // 检查Tag Type | ||
| 344 | + if (flvData[offset] != FLV_TAG_TYPE_AUDIO) { | ||
| 345 | + return; | ||
| 346 | + } | ||
| 347 | + offset++; | ||
| 348 | + | ||
| 349 | + // 读取DataSize (3字节) | ||
| 350 | + int dataSize = ((flvData[offset] & 0xFF) << 16) | | ||
| 351 | + ((flvData[offset + 1] & 0xFF) << 8) | | ||
| 352 | + (flvData[offset + 2] & 0xFF); | ||
| 353 | + offset += 3; | ||
| 354 | + | ||
| 355 | + // 跳过Timestamp (3字节) + TimestampExt (1字节) | ||
| 356 | + offset += 4; | ||
| 357 | + | ||
| 358 | + // 跳过StreamID (3字节) | ||
| 359 | + offset += 3; | ||
| 360 | + | ||
| 361 | + // 提取音频数据 | ||
| 362 | + int audioDataSize = dataSize - 1; // 减去audioInfo的1字节 | ||
| 363 | + if (offset + audioDataSize > flvData.length) { | ||
| 364 | + return; | ||
| 365 | + } | ||
| 366 | + | ||
| 367 | + byte[] audioData = new byte[audioDataSize]; | ||
| 368 | + System.arraycopy(flvData, offset + 1, audioData, 0, audioDataSize); // +1 跳过audioInfo | ||
| 369 | + | ||
| 370 | + lastAudioTimestamp = timestamp & 0xFFFFFF; | ||
| 371 | + connection.sendAudioData(audioData, lastAudioTimestamp); | ||
| 372 | + } | ||
| 373 | + | ||
| 374 | + public boolean hasSentAVCSequenceHeader() { | ||
| 375 | + return hasSentAVCSequenceHeader; | ||
| 376 | + } | ||
| 377 | +} |
src/main/java/com/genersoft/iot/vmp/jtt1078/rtmp/RtmpConnection.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.rtmp; | ||
| 2 | + | ||
| 3 | +import org.slf4j.Logger; | ||
| 4 | +import org.slf4j.LoggerFactory; | ||
| 5 | + | ||
| 6 | +import java.io.IOException; | ||
| 7 | +import java.io.InputStream; | ||
| 8 | +import java.io.OutputStream; | ||
| 9 | +import java.net.InetSocketAddress; | ||
| 10 | +import java.net.Socket; | ||
| 11 | +import java.nio.ByteBuffer; | ||
| 12 | + | ||
| 13 | +/** | ||
| 14 | + * RTMP连接管理 | ||
| 15 | + * | ||
| 16 | + * 负责RTMP握手、连接、创建流等基础操作 | ||
| 17 | + * | ||
| 18 | + * RTMP协议版本:1.0 (Flash Media Server兼容) | ||
| 19 | + * | ||
| 20 | + * 握手流程: | ||
| 21 | + * C0+C1 → S0+S1+S2 → C2 → 连接建立 | ||
| 22 | + * | ||
| 23 | + * 连接流程: | ||
| 24 | + * connect(tcUrl) → createStream() → publish(streamName) | ||
| 25 | + */ | ||
| 26 | +public class RtmpConnection { | ||
| 27 | + | ||
| 28 | + static Logger logger = LoggerFactory.getLogger(RtmpConnection.class); | ||
| 29 | + | ||
| 30 | + private String host; | ||
| 31 | + private int port; | ||
| 32 | + private String tcUrl; | ||
| 33 | + private String streamName; | ||
| 34 | + | ||
| 35 | + private Socket socket; | ||
| 36 | + private InputStream inputStream; | ||
| 37 | + private OutputStream outputStream; | ||
| 38 | + | ||
| 39 | + private volatile boolean connected = false; | ||
| 40 | + private int streamId = 0; // RTMP流ID | ||
| 41 | + | ||
| 42 | + // RTMP Chunk大小,默认128字节 | ||
| 43 | + public static final int CHUNK_SIZE = 4096; // 参考项目使用4096 | ||
| 44 | + | ||
| 45 | + // 协议版本 | ||
| 46 | + private static final byte RTMP_VERSION = 3; | ||
| 47 | + | ||
| 48 | + // RTMP消息类型 | ||
| 49 | + public static final byte MSG_SET_CHUNK_SIZE = 1; | ||
| 50 | + public static final byte MSG_ABORT = 2; | ||
| 51 | + public static final byte MSG_ACKNOWLEDGEMENT = 3; | ||
| 52 | + public static final byte MSG_USER_CONTROL_MESSAGE = 4; | ||
| 53 | + public static final byte MSG_WINDOW_ACK_SIZE = 5; | ||
| 54 | + public static final byte MSG_SET_PEER_BANDWIDTH = 6; | ||
| 55 | + public static final byte MSG_AUDIO_MESSAGE = 8; | ||
| 56 | + public static final byte MSG_VIDEO_MESSAGE = 9; | ||
| 57 | + public static final byte MSG_DATA_MESSAGE = 18; // AMF0 Data | ||
| 58 | + public static final byte MSG_SHARED_OBJECT_MESSAGE = 19; | ||
| 59 | + public static final byte MSG_COMMAND_MESSAGE = 20; // AMF0 Command | ||
| 60 | + public static final byte MSG_COMMAND_MESSAGE_AMF3 = 17; // AMF3 Command | ||
| 61 | + public static final byte MSG_WINDOW_ACK_SIZE_2 = 5; | ||
| 62 | + | ||
| 63 | + // Chunk Stream ID | ||
| 64 | + // 标准RTMP使用chunk stream ID 3进行命令交互 | ||
| 65 | + public static final byte CHUNK_STREAM_CONTROL = 2; | ||
| 66 | + public static final byte CHUNK_STREAM_COMMAND = 3; | ||
| 67 | + public static final byte CHUNK_STREAM_VIDEO = 6; | ||
| 68 | + public static final byte CHUNK_STREAM_AUDIO = 4; // 参考项目使用4 | ||
| 69 | + | ||
| 70 | + public RtmpConnection(String host, int port, String tcUrl, String streamName) { | ||
| 71 | + this.host = host; | ||
| 72 | + this.port = port; | ||
| 73 | + this.tcUrl = tcUrl; | ||
| 74 | + this.streamName = streamName; | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + /** | ||
| 78 | + * 连接到RTMP服务器 | ||
| 79 | + * @return true表示连接成功 | ||
| 80 | + */ | ||
| 81 | + public boolean connect() { | ||
| 82 | + try { | ||
| 83 | + logger.info("[{}] 正在连接到RTMP服务器: {}:{}", streamName, host, port); | ||
| 84 | + | ||
| 85 | + socket = new Socket(); | ||
| 86 | + socket.setKeepAlive(true); | ||
| 87 | + socket.setSoTimeout(10000); // 10秒超时 | ||
| 88 | + socket.connect(new InetSocketAddress(host, port), 10000); | ||
| 89 | + | ||
| 90 | + inputStream = socket.getInputStream(); | ||
| 91 | + outputStream = socket.getOutputStream(); | ||
| 92 | + | ||
| 93 | + // 执行握手 | ||
| 94 | + if (!doHandshake()) { | ||
| 95 | + logger.error("[{}] RTMP握手失败", streamName); | ||
| 96 | + return false; | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + logger.info("[{}] RTMP握手成功", streamName); | ||
| 100 | + | ||
| 101 | + // 【关键修复】发送SetChunkSize命令,设置chunk大小为4096 | ||
| 102 | + if (!sendSetChunkSize()) { | ||
| 103 | + logger.error("[{}] SetChunkSize命令失败", streamName); | ||
| 104 | + return false; | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + // 发送连接命令 | ||
| 108 | + if (!sendConnect()) { | ||
| 109 | + logger.error("[{}] RTMP连接命令失败", streamName); | ||
| 110 | + return false; | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + connected = true; | ||
| 114 | + logger.info("[{}] RTMP连接成功", streamName); | ||
| 115 | + return true; | ||
| 116 | + | ||
| 117 | + } catch (Exception e) { | ||
| 118 | + logger.error("[{}] RTMP连接失败: {}", streamName, e.getMessage(), e); | ||
| 119 | + return false; | ||
| 120 | + } | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + /** | ||
| 124 | + * RTMP握手 | ||
| 125 | + * | ||
| 126 | + * 客户端发送 C0 + C1 | ||
| 127 | + * 服务器响应 S0 + S1 + S2 | ||
| 128 | + * 客户端发送 C2 | ||
| 129 | + */ | ||
| 130 | + private boolean doHandshake() throws IOException { | ||
| 131 | + // C0 + C1 | ||
| 132 | + byte[] c0c1 = new byte[1 + 1536]; // C0(1字节) + C1(1536字节) | ||
| 133 | + c0c1[0] = RTMP_VERSION; // C0: 版本号,通常是3 | ||
| 134 | + | ||
| 135 | + // C1: 时间戳(4字节) + 零(4字节) + 随机数据(1528字节) | ||
| 136 | + long timestamp = System.currentTimeMillis() / 1000; | ||
| 137 | + c0c1[1] = (byte) (timestamp >> 24); | ||
| 138 | + c0c1[2] = (byte) (timestamp >> 16); | ||
| 139 | + c0c1[3] = (byte) (timestamp >> 8); | ||
| 140 | + c0c1[4] = (byte) timestamp; | ||
| 141 | + // 字节5-8是零 | ||
| 142 | + // 字节9-1536是随机数据 | ||
| 143 | + for (int i = 9; i < 1537; i++) { | ||
| 144 | + c0c1[i] = (byte) (Math.random() * 256); | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + logger.debug("[{}] 发送C0+C1...", streamName); | ||
| 148 | + outputStream.write(c0c1); | ||
| 149 | + outputStream.flush(); | ||
| 150 | + | ||
| 151 | + // 读取S0 + S1 + S2 | ||
| 152 | + byte[] s0s1s2 = new byte[1 + 1536 + 1536]; | ||
| 153 | + int totalRead = 0; | ||
| 154 | + int toRead = s0s1s2.length; | ||
| 155 | + while (totalRead < toRead) { | ||
| 156 | + int read = inputStream.read(s0s1s2, totalRead, toRead - totalRead); | ||
| 157 | + if (read == -1) { | ||
| 158 | + logger.error("[{}] 读取S0S1S2失败,连接已关闭", streamName); | ||
| 159 | + return false; | ||
| 160 | + } | ||
| 161 | + totalRead += read; | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + // S0: 版本号 (1字节) | ||
| 165 | + byte s0Version = s0s1s2[0]; | ||
| 166 | + logger.debug("[{}] 收到S0, 版本号: {}", streamName, s0Version); | ||
| 167 | + | ||
| 168 | + // S1: 服务器时间戳(4字节) + 零(4字节) + 随机数据(1528字节) | ||
| 169 | + logger.debug("[{}] 收到S1, 服务器时间戳: {}", | ||
| 170 | + streamName, | ||
| 171 | + ((s0s1s2[1] & 0xFF) << 24) | ((s0s1s2[2] & 0xFF) << 16) | | ||
| 172 | + ((s0s1s2[3] & 0xFF) << 8) | (s0s1s2[4] & 0xFF)); | ||
| 173 | + | ||
| 174 | + // C2: 响应S1 (1536字节) | ||
| 175 | + // C2的内容是S1的直接复制(在简化实现中) | ||
| 176 | + byte[] c2 = new byte[1536]; | ||
| 177 | + System.arraycopy(s0s1s2, 1, c2, 0, 1536); | ||
| 178 | + | ||
| 179 | + logger.debug("[{}] 发送C2...", streamName); | ||
| 180 | + outputStream.write(c2); | ||
| 181 | + outputStream.flush(); | ||
| 182 | + | ||
| 183 | + logger.debug("[{}] 握手完成", streamName); | ||
| 184 | + return true; | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + /** | ||
| 188 | + * 发送SetChunkSize命令 | ||
| 189 | + * 将chunk大小设置为4096字节(默认是128字节) | ||
| 190 | + */ | ||
| 191 | + private boolean sendSetChunkSize() throws IOException { | ||
| 192 | + logger.info("[{}] 发送SetChunkSize命令: {}", streamName, CHUNK_SIZE); | ||
| 193 | + | ||
| 194 | + // RTMP SetChunkSize Message (type 0 header) | ||
| 195 | + // [Basic Header(1-3字节)][Message Header(11字节)][Body(4字节)] | ||
| 196 | + // Basic Header: fmt=0(3位) + chunk stream id=2(6位) = 0x02 | ||
| 197 | + // Message Header: timestamp(3字节)=0 + length(3字节)=4 + type(1字节)=1 + streamId(4字节)=0 | ||
| 198 | + // Body: chunk size (4字节) | ||
| 199 | + | ||
| 200 | + byte[] msg = new byte[12 + 4]; // header(12) + body(4) | ||
| 201 | + | ||
| 202 | + // Basic Header (1字节): fmt=00, csid=2 | ||
| 203 | + msg[0] = (byte) 0x02; | ||
| 204 | + | ||
| 205 | + // Message Header Type 0 (11字节) | ||
| 206 | + // timestamp (3 bytes) = 0 | ||
| 207 | + msg[1] = 0; | ||
| 208 | + msg[2] = 0; | ||
| 209 | + msg[3] = 0; | ||
| 210 | + // message length (3 bytes) = 4 | ||
| 211 | + msg[4] = 0; | ||
| 212 | + msg[5] = 0; | ||
| 213 | + msg[6] = 4; | ||
| 214 | + // message type (1 byte) = 1 (Set Chunk Size) | ||
| 215 | + msg[7] = 0x01; | ||
| 216 | + // message stream ID (4 bytes, little-endian) = 0 | ||
| 217 | + msg[8] = 0; | ||
| 218 | + msg[9] = 0; | ||
| 219 | + msg[10] = 0; | ||
| 220 | + msg[11] = 0; | ||
| 221 | + | ||
| 222 | + // Body: chunk size (4 bytes, big-endian) | ||
| 223 | + // 注意:Flash和大多数服务器使用大端序 | ||
| 224 | + msg[12] = (byte) ((CHUNK_SIZE >> 24) & 0xFF); | ||
| 225 | + msg[13] = (byte) ((CHUNK_SIZE >> 16) & 0xFF); | ||
| 226 | + msg[14] = (byte) ((CHUNK_SIZE >> 8) & 0xFF); | ||
| 227 | + msg[15] = (byte) (CHUNK_SIZE & 0xFF); | ||
| 228 | + | ||
| 229 | + logger.debug("[{}] SetChunkSize消息十六进制: {}", streamName, bytesToHex(msg)); | ||
| 230 | + outputStream.write(msg); | ||
| 231 | + outputStream.flush(); | ||
| 232 | + | ||
| 233 | + return true; | ||
| 234 | + } | ||
| 235 | + | ||
| 236 | + private static String bytesToHex(byte[] bytes) { | ||
| 237 | + StringBuilder sb = new StringBuilder(); | ||
| 238 | + for (int i = 0; i < Math.min(bytes.length, 32); i++) { | ||
| 239 | + sb.append(String.format("%02X ", bytes[i] & 0xFF)); | ||
| 240 | + } | ||
| 241 | + if (bytes.length > 32) sb.append("..."); | ||
| 242 | + return sb.toString(); | ||
| 243 | + } | ||
| 244 | + | ||
| 245 | + /** | ||
| 246 | + * 发送connect命令 | ||
| 247 | + * 【关键修复】参考jtt1078-video-server的sendConnect,简化参数 | ||
| 248 | + */ | ||
| 249 | + private boolean sendConnect() throws IOException { | ||
| 250 | + logger.info("[{}] 发送connect命令, tcUrl={}, streamName={}", streamName, tcUrl, streamName); | ||
| 251 | + | ||
| 252 | + ByteBuffer buffer = ByteBuffer.allocate(4096); | ||
| 253 | + | ||
| 254 | + // AMF0 connect命令 - 参考jtt1078-video-server的简化版本 | ||
| 255 | + // 1. String: "connect" | ||
| 256 | + buffer.put(encodeAmfString("connect")); | ||
| 257 | + | ||
| 258 | + // 2. Number: transaction ID = 1 | ||
| 259 | + buffer.put(encodeAmfNumber(1.0)); | ||
| 260 | + | ||
| 261 | + // 3. Object: connection properties - 简化为与jtt1078-video-server一致 | ||
| 262 | + buffer.put((byte) 0x03); // Object marker | ||
| 263 | + buffer.put(encodeAmfObject("app", getAppFromTcUrl(tcUrl))); | ||
| 264 | + buffer.put(encodeAmfObject("flashVer", "FMLE/3.0 (compatible; FMSc/1.0)")); | ||
| 265 | + buffer.put(encodeAmfObject("tcUrl", tcUrl)); | ||
| 266 | + buffer.put(encodeAmfObject("swfUrl", "")); | ||
| 267 | + buffer.put(encodeAmfObjectEnd()); | ||
| 268 | + | ||
| 269 | + int dataLen = buffer.position(); | ||
| 270 | + buffer.flip(); | ||
| 271 | + | ||
| 272 | + byte[] data = new byte[dataLen]; | ||
| 273 | + buffer.get(data); | ||
| 274 | + logger.debug("[{}] connect命令数据长度: {}", streamName, dataLen); | ||
| 275 | + | ||
| 276 | + // 打印connect命令的十六进制数据 | ||
| 277 | + StringBuilder hex = new StringBuilder(); | ||
| 278 | + for (int i = 0; i < dataLen; i++) { | ||
| 279 | + hex.append(String.format("%02X ", data[i] & 0xFF)); | ||
| 280 | + } | ||
| 281 | + logger.info("[{}] connect命令十六进制: {}", streamName, hex.toString()); | ||
| 282 | + | ||
| 283 | + // 发送命令消息(使用chunk stream id = 3) | ||
| 284 | + sendRtmpMessage(CHUNK_STREAM_COMMAND, MSG_COMMAND_MESSAGE, 0, data, dataLen); | ||
| 285 | + | ||
| 286 | + // 等待一下让数据发送完成 | ||
| 287 | + try { | ||
| 288 | + Thread.sleep(100); | ||
| 289 | + } catch (InterruptedException e) { | ||
| 290 | + Thread.currentThread().interrupt(); | ||
| 291 | + return false; | ||
| 292 | + } | ||
| 293 | + | ||
| 294 | + // 读取响应 | ||
| 295 | + return readConnectResponse(); | ||
| 296 | + } | ||
| 297 | + | ||
| 298 | + /** | ||
| 299 | + * 读取connect命令的响应 | ||
| 300 | + * 【关键修复】使用阻塞读取代替available()检查 | ||
| 301 | + */ | ||
| 302 | + private boolean readConnectResponse() throws IOException { | ||
| 303 | + // 等待足够时间让ZLM处理并返回响应 | ||
| 304 | + try { | ||
| 305 | + Thread.sleep(200); | ||
| 306 | + } catch (InterruptedException e) { | ||
| 307 | + Thread.currentThread().interrupt(); | ||
| 308 | + return false; | ||
| 309 | + } | ||
| 310 | + | ||
| 311 | + // 使用阻塞读取检查响应 | ||
| 312 | + try { | ||
| 313 | + byte[] header = new byte[4]; | ||
| 314 | + int read = inputStream.read(header); | ||
| 315 | + if (read > 0) { | ||
| 316 | + logger.info("[{}] 收到connect响应头: {} bytes, hex={}", streamName, read, | ||
| 317 | + String.format("%02X %02X %02X %02X...", header[0] & 0xFF, header[1] & 0xFF, header[2] & 0xFF, header[3] & 0xFF)); | ||
| 318 | + | ||
| 319 | + // 读取剩余数据(如果有的话) | ||
| 320 | + int remaining = inputStream.available(); | ||
| 321 | + if (remaining > 0) { | ||
| 322 | + byte[] body = new byte[remaining]; | ||
| 323 | + inputStream.read(body); | ||
| 324 | + String responseStr = new String(body, 0, Math.min(remaining, 128)); | ||
| 325 | + logger.info("[{}] connect响应内容: {}", streamName, responseStr.replaceAll("[^\\x20-\\x7E]", ".")); | ||
| 326 | + | ||
| 327 | + // 检查错误 | ||
| 328 | + if (responseStr.contains("_error") || responseStr.contains("reject")) { | ||
| 329 | + logger.error("[{}] ZLM拒绝了连接请求", streamName); | ||
| 330 | + return false; | ||
| 331 | + } | ||
| 332 | + } | ||
| 333 | + | ||
| 334 | + // 检查响应头是否包含错误标记 | ||
| 335 | + String headerStr = new String(header, 0, read); | ||
| 336 | + if (headerStr.contains("_error") || headerStr.contains("reject")) { | ||
| 337 | + logger.error("[{}] ZLM拒绝了连接请求(从头)", streamName); | ||
| 338 | + return false; | ||
| 339 | + } | ||
| 340 | + } | ||
| 341 | + } catch (IOException e) { | ||
| 342 | + // 超时或读取错误,可能ZLM响应还没到,但不一定是失败 | ||
| 343 | + logger.warn("[{}] 读取connect响应时出错或超时: {}", streamName, e.getMessage()); | ||
| 344 | + } | ||
| 345 | + | ||
| 346 | + return true; | ||
| 347 | + } | ||
| 348 | + | ||
| 349 | + /** | ||
| 350 | + * 创建RTMP流 | ||
| 351 | + */ | ||
| 352 | + public boolean createStream() { | ||
| 353 | + try { | ||
| 354 | + logger.info("[{}] 创建RTMP流, streamId={}...", streamName, streamId); | ||
| 355 | + | ||
| 356 | + // 构造AMF0编码的createStream命令 | ||
| 357 | + ByteBuffer buffer = ByteBuffer.allocate(256); | ||
| 358 | + | ||
| 359 | + // AMF0编码: | ||
| 360 | + // 1. String: "createStream" | ||
| 361 | + buffer.put(encodeAmfString("createStream")); | ||
| 362 | + | ||
| 363 | + // 2. Number: transaction ID = 2 | ||
| 364 | + buffer.put(encodeAmfNumber(2.0)); | ||
| 365 | + | ||
| 366 | + // 3. Null | ||
| 367 | + buffer.put((byte) 0x05); // AMF0 Null marker | ||
| 368 | + | ||
| 369 | + int dataLen = buffer.position(); | ||
| 370 | + buffer.flip(); | ||
| 371 | + | ||
| 372 | + logger.debug("[{}] createStream命令长度: {}", streamName, dataLen); | ||
| 373 | + | ||
| 374 | + // 发送命令消息(使用chunk stream id = 3) | ||
| 375 | + sendRtmpMessage(CHUNK_STREAM_COMMAND, MSG_COMMAND_MESSAGE, 0, buffer.array(), dataLen); | ||
| 376 | + | ||
| 377 | + // 【关键修复】等待更长时间让数据发送完成并接收响应 | ||
| 378 | + Thread.sleep(300); | ||
| 379 | + | ||
| 380 | + // 【关键修复】使用阻塞读取代替available()检查 | ||
| 381 | + boolean streamCreated = false; | ||
| 382 | + try { | ||
| 383 | + byte[] header = new byte[4]; | ||
| 384 | + int read = inputStream.read(header); | ||
| 385 | + if (read > 0) { | ||
| 386 | + logger.info("[{}] 收到createStream响应头: {} bytes", streamName, read); | ||
| 387 | + streamCreated = true; | ||
| 388 | + } | ||
| 389 | + } catch (IOException e) { | ||
| 390 | + // 超时或读取错误 | ||
| 391 | + logger.warn("[{}] 读取createStream响应时出错或超时: {}", streamName, e.getMessage()); | ||
| 392 | + } | ||
| 393 | + | ||
| 394 | + // 检查是否成功创建流 | ||
| 395 | + if (streamCreated) { | ||
| 396 | + streamId = 1; | ||
| 397 | + logger.info("[{}] RTMP流创建成功, streamId: {}", streamName, streamId); | ||
| 398 | + return true; | ||
| 399 | + } else { | ||
| 400 | + // 【关键修复】如果读取失败,不直接假设成功,而是返回失败让调用方决定 | ||
| 401 | + streamId = 1; // 仍然设置streamId,因为ZLM通常会在收到createStream后自动创建流 | ||
| 402 | + logger.warn("[{}] createStream响应读取失败,但继续尝试publish (streamId={})", streamName, streamId); | ||
| 403 | + return true; // 返回true让流程继续,因为ZLM行为通常是立即创建流 | ||
| 404 | + } | ||
| 405 | + | ||
| 406 | + } catch (Exception e) { | ||
| 407 | + logger.error("[{}] 创建RTMP流失败: {}", streamName, e.getMessage(), e); | ||
| 408 | + return false; | ||
| 409 | + } | ||
| 410 | + } | ||
| 411 | + | ||
| 412 | + /** | ||
| 413 | + * 发布流 | ||
| 414 | + */ | ||
| 415 | + public boolean publish() { | ||
| 416 | + if (streamId == 0) { | ||
| 417 | + logger.error("[{}] 流未创建,无法发布", streamName); | ||
| 418 | + return false; | ||
| 419 | + } | ||
| 420 | + | ||
| 421 | + try { | ||
| 422 | + // 从tcUrl中提取查询参数(如?sign=xxx) | ||
| 423 | + String publishName = streamName; | ||
| 424 | + int queryIndex = tcUrl.indexOf('?'); | ||
| 425 | + if (queryIndex > 0) { | ||
| 426 | + String queryParams = tcUrl.substring(queryIndex); | ||
| 427 | + publishName = streamName + queryParams; | ||
| 428 | + logger.info("[{}] 发布流: streamName='{}' (包含鉴权参数), streamId={}", streamName, publishName, streamId); | ||
| 429 | + } else { | ||
| 430 | + logger.info("[{}] 发布流: streamName='{}', streamId={}, tcUrl='{}'", | ||
| 431 | + streamName, streamName, streamId, tcUrl); | ||
| 432 | + } | ||
| 433 | + logger.info("[{}] ====== publish命令发送完成 ======", streamName); | ||
| 434 | + | ||
| 435 | + ByteBuffer buffer = ByteBuffer.allocate(256); | ||
| 436 | + | ||
| 437 | + // publish命令 | ||
| 438 | + // 1. String: "publish" | ||
| 439 | + buffer.put(encodeAmfString("publish")); | ||
| 440 | + | ||
| 441 | + // 2. Number: transaction ID = 3 | ||
| 442 | + buffer.put(encodeAmfNumber(3.0)); | ||
| 443 | + | ||
| 444 | + // 3. Null | ||
| 445 | + buffer.put((byte) 0x05); // AMF0 Null marker | ||
| 446 | + | ||
| 447 | + // 4. String: streamName (包含查询参数) | ||
| 448 | + buffer.put(encodeAmfString(publishName)); | ||
| 449 | + | ||
| 450 | + // 5. String: "live" (publish type) | ||
| 451 | + buffer.put(encodeAmfString("live")); | ||
| 452 | + | ||
| 453 | + int dataLen = buffer.position(); | ||
| 454 | + buffer.flip(); | ||
| 455 | + | ||
| 456 | + // 打印publish命令的十六进制数据 | ||
| 457 | + byte[] data = buffer.array(); | ||
| 458 | + StringBuilder hex = new StringBuilder(); | ||
| 459 | + for (int i = 0; i < dataLen; i++) { | ||
| 460 | + hex.append(String.format("%02X ", data[i] & 0xFF)); | ||
| 461 | + } | ||
| 462 | + logger.info("[{}] publish命令十六进制: {}", streamName, hex.toString()); | ||
| 463 | + | ||
| 464 | + sendRtmpMessage(CHUNK_STREAM_COMMAND, MSG_COMMAND_MESSAGE, 0, buffer.array(), dataLen); | ||
| 465 | + | ||
| 466 | + logger.info("[{}] 发布命令已发送,等待ZLM响应...", streamName); | ||
| 467 | + | ||
| 468 | + // 【关键修复】等待publish响应 | ||
| 469 | + // 参考jtt1078-video-server的RtmpHandshakeHandler,等待服务器响应 | ||
| 470 | + // "NetStream.Publish.Start" 或类似成功响应后再继续 | ||
| 471 | + try { | ||
| 472 | + Thread.sleep(1000); | ||
| 473 | + } catch (InterruptedException e) { | ||
| 474 | + Thread.currentThread().interrupt(); | ||
| 475 | + } | ||
| 476 | + | ||
| 477 | + // 【关键修复】使用阻塞读取代替available()检查 | ||
| 478 | + boolean publishSuccess = false; | ||
| 479 | + try { | ||
| 480 | + // 尝试读取响应 - RTMP chunk header + AMF0响应 | ||
| 481 | + byte[] response = new byte[64]; // 读取足够大的缓冲区 | ||
| 482 | + int read = inputStream.read(response); | ||
| 483 | + if (read > 0) { | ||
| 484 | + // 打印十六进制日志便于调试 | ||
| 485 | + StringBuilder responseHex = new StringBuilder(); | ||
| 486 | + for (int i = 0; i < Math.min(read, 32); i++) { | ||
| 487 | + responseHex.append(String.format("%02X ", response[i] & 0xFF)); | ||
| 488 | + } | ||
| 489 | + logger.info("[{}] 收到publish响应: {} bytes, hex={}", streamName, read, responseHex.toString()); | ||
| 490 | + | ||
| 491 | + // 检查AMF0响应中的关键字 | ||
| 492 | + // _result 的 AMF0 编码是: 0x02 0x00 0x07 '_' 'r' 'e' 's' 'u' 'l' 't' | ||
| 493 | + // 所以如果 byte[i+2] == 0x07 且后续字节是 "_result" | ||
| 494 | + boolean foundResult = false; | ||
| 495 | + for (int i = 0; i < read - 8; i++) { | ||
| 496 | + if (response[i] == 0x02 && response[i+1] == 0x00 && response[i+2] == 0x07) { | ||
| 497 | + // 检查是否是 "_result" | ||
| 498 | + if (i + 9 < read && | ||
| 499 | + response[i+3] == '_' && response[i+4] == 'r' && | ||
| 500 | + response[i+5] == 'e' && response[i+6] == 's' && | ||
| 501 | + response[i+7] == 'u' && response[i+8] == 'l' && | ||
| 502 | + response[i+9] == 't') { | ||
| 503 | + foundResult = true; | ||
| 504 | + logger.info("[{}] 找到 _result 标记,位置={}", streamName, i); | ||
| 505 | + break; | ||
| 506 | + } | ||
| 507 | + } | ||
| 508 | + } | ||
| 509 | + | ||
| 510 | + if (foundResult) { | ||
| 511 | + publishSuccess = true; | ||
| 512 | + } else { | ||
| 513 | + // 检查是否是错误响应 | ||
| 514 | + for (int i = 0; i < read - 6; i++) { | ||
| 515 | + if (response[i] == 0x02 && response[i+1] == 0x00 && response[i+2] == 0x05 && | ||
| 516 | + response[i+3] == '_' && response[i+4] == 'e' && response[i+5] == 'r' && response[i+6] == 'r') { | ||
| 517 | + logger.error("[{}] ZLM拒绝了publish请求 (_error)", streamName); | ||
| 518 | + return false; | ||
| 519 | + } | ||
| 520 | + } | ||
| 521 | + // 如果没找到明确的标记,根据RTMP响应特点判断 | ||
| 522 | + // ZLM如果接受publish,通常不会立即返回错误 | ||
| 523 | + // 检查chunk header中的message type | ||
| 524 | + if (read >= 12) { | ||
| 525 | + byte msgType = response[7]; | ||
| 526 | + logger.info("[{}] RTMP message type=0x{} (0x14=command, 0x16=Data)", streamName, String.format("%02X", msgType)); | ||
| 527 | + // 0x14 是命令消息,通常表示成功响应 | ||
| 528 | + if (msgType == 0x14) { | ||
| 529 | + publishSuccess = true; | ||
| 530 | + } | ||
| 531 | + } | ||
| 532 | + } | ||
| 533 | + } | ||
| 534 | + } catch (IOException e) { | ||
| 535 | + // 超时或读取错误 | ||
| 536 | + logger.warn("[{}] 读取publish响应时出错或超时: {}", streamName, e.getMessage()); | ||
| 537 | + } | ||
| 538 | + | ||
| 539 | + // 如果没有读取到明确的成功响应,也继续尝试(ZLM可能不返回明确的成功响应) | ||
| 540 | + if (!publishSuccess) { | ||
| 541 | + logger.warn("[{}] 未收到明确的publish成功响应,继续尝试发送数据", streamName); | ||
| 542 | + } | ||
| 543 | + | ||
| 544 | + logger.info("[{}] publish命令发送完成,等待后续处理", streamName); | ||
| 545 | + return true; | ||
| 546 | + | ||
| 547 | + } catch (Exception e) { | ||
| 548 | + logger.error("[{}] 发布流失败: {}", streamName, e.getMessage(), e); | ||
| 549 | + return false; | ||
| 550 | + } | ||
| 551 | + } | ||
| 552 | + | ||
| 553 | + /** | ||
| 554 | + * 发送RTMP消息 | ||
| 555 | + */ | ||
| 556 | + public void sendRtmpMessage(byte chunkStreamId, byte messageType, int timestamp, byte[] data, int length) throws IOException { | ||
| 557 | + if (outputStream == null) { | ||
| 558 | + throw new IOException("输出流未初始化"); | ||
| 559 | + } | ||
| 560 | + | ||
| 561 | + // Basic Header - 1字节: fmt=00 (2位) + chunk stream id (6位) | ||
| 562 | + byte basicHeader = (byte) (chunkStreamId & 0x3F); | ||
| 563 | + // Message Header Type 0 (11字节) | ||
| 564 | + // timestamp (3 bytes, big-endian) | ||
| 565 | + // message length (3 bytes, big-endian) | ||
| 566 | + // message type (1 byte) | ||
| 567 | + // stream ID (4 bytes, little-endian per RTMP spec, but big-endian works for streamId=0/1) | ||
| 568 | + | ||
| 569 | + // 打印chunk header的十六进制 | ||
| 570 | + logger.info("[{}] RTMP发送: chunkStreamId={}, msgType=0x{}, timestamp={}, length={}, streamId={}", | ||
| 571 | + streamName, chunkStreamId, String.format("%02X", messageType), timestamp, length, streamId); | ||
| 572 | + | ||
| 573 | + // 发送Type 0 chunk header | ||
| 574 | + outputStream.write(basicHeader); | ||
| 575 | + outputStream.write((byte) ((timestamp >> 16) & 0xFF)); | ||
| 576 | + outputStream.write((byte) ((timestamp >> 8) & 0xFF)); | ||
| 577 | + outputStream.write((byte) (timestamp & 0xFF)); | ||
| 578 | + outputStream.write((byte) ((length >> 16) & 0xFF)); | ||
| 579 | + outputStream.write((byte) ((length >> 8) & 0xFF)); | ||
| 580 | + outputStream.write((byte) (length & 0xFF)); | ||
| 581 | + outputStream.write(messageType); | ||
| 582 | + // Stream ID (little-endian per RTMP spec, reference implementation uses writeIntLE) | ||
| 583 | + outputStream.write((byte) (streamId & 0xFF)); | ||
| 584 | + outputStream.write((byte) ((streamId >> 8) & 0xFF)); | ||
| 585 | + outputStream.write((byte) ((streamId >> 16) & 0xFF)); | ||
| 586 | + outputStream.write((byte) ((streamId >> 24) & 0xFF)); | ||
| 587 | + | ||
| 588 | + // Chunk Data - 分块发送,每块CHUNK_SIZE字节 | ||
| 589 | + int offset = 0; | ||
| 590 | + while (offset < length) { | ||
| 591 | + int chunkLen = Math.min(CHUNK_SIZE, length - offset); | ||
| 592 | + outputStream.write(data, offset, chunkLen); | ||
| 593 | + offset += chunkLen; | ||
| 594 | + | ||
| 595 | + // 如果还有后续块,发送type 3 header (1字节) | ||
| 596 | + if (offset < length) { | ||
| 597 | + // Type 3 header: fmt=11 (2位) + chunk stream id (6位) | ||
| 598 | + byte type3Header = (byte) (0xC0 | (chunkStreamId & 0x3F)); | ||
| 599 | + outputStream.write(type3Header); | ||
| 600 | + } | ||
| 601 | + } | ||
| 602 | + | ||
| 603 | + outputStream.flush(); | ||
| 604 | + } | ||
| 605 | + | ||
| 606 | + /** | ||
| 607 | + * 发送视频数据 | ||
| 608 | + */ | ||
| 609 | + public void sendVideoData(byte[] data, int timestamp) throws IOException { | ||
| 610 | + if (!connected) { | ||
| 611 | + throw new IOException("未连接"); | ||
| 612 | + } | ||
| 613 | + // 打印前32字节的十六进制 | ||
| 614 | + StringBuilder hex = new StringBuilder(); | ||
| 615 | + for (int i = 0; i < Math.min(data.length, 32); i++) { | ||
| 616 | + hex.append(String.format("%02X ", data[i] & 0xFF)); | ||
| 617 | + } | ||
| 618 | + if (data.length > 32) { | ||
| 619 | + hex.append("...(").append(data.length).append(" bytes total)"); | ||
| 620 | + } | ||
| 621 | + logger.info("[{}] 发送视频数据到RTMP: timestamp={}, length={}, firstBytes=[{}]", | ||
| 622 | + streamName, timestamp, data.length, hex.toString()); | ||
| 623 | + sendRtmpMessage(CHUNK_STREAM_VIDEO, MSG_VIDEO_MESSAGE, timestamp, data, data.length); | ||
| 624 | + } | ||
| 625 | + | ||
| 626 | + /** | ||
| 627 | + * 发送音频数据 | ||
| 628 | + */ | ||
| 629 | + public void sendAudioData(byte[] data, int timestamp) throws IOException { | ||
| 630 | + if (!connected) { | ||
| 631 | + throw new IOException("未连接"); | ||
| 632 | + } | ||
| 633 | + sendRtmpMessage(CHUNK_STREAM_AUDIO, MSG_AUDIO_MESSAGE, timestamp, data, data.length); | ||
| 634 | + } | ||
| 635 | + | ||
| 636 | + /** | ||
| 637 | + * 关闭连接 | ||
| 638 | + */ | ||
| 639 | + public void close() { | ||
| 640 | + connected = false; | ||
| 641 | + streamId = 0; | ||
| 642 | + | ||
| 643 | + try { | ||
| 644 | + if (inputStream != null) { | ||
| 645 | + inputStream.close(); | ||
| 646 | + } | ||
| 647 | + } catch (Exception e) { | ||
| 648 | + // ignore | ||
| 649 | + } | ||
| 650 | + | ||
| 651 | + try { | ||
| 652 | + if (outputStream != null) { | ||
| 653 | + outputStream.close(); | ||
| 654 | + } | ||
| 655 | + } catch (Exception e) { | ||
| 656 | + // ignore | ||
| 657 | + } | ||
| 658 | + | ||
| 659 | + try { | ||
| 660 | + if (socket != null) { | ||
| 661 | + socket.close(); | ||
| 662 | + } | ||
| 663 | + } catch (Exception e) { | ||
| 664 | + // ignore | ||
| 665 | + } | ||
| 666 | + | ||
| 667 | + logger.info("[{}] RTMP连接已关闭", streamName); | ||
| 668 | + } | ||
| 669 | + | ||
| 670 | + public boolean isConnected() { | ||
| 671 | + return connected && socket != null && socket.isConnected(); | ||
| 672 | + } | ||
| 673 | + | ||
| 674 | + public int getStreamId() { | ||
| 675 | + return streamId; | ||
| 676 | + } | ||
| 677 | + | ||
| 678 | + /** | ||
| 679 | + * 从tcUrl中提取app名称 | ||
| 680 | + */ | ||
| 681 | + private String getAppFromTcUrl(String tcUrl) { | ||
| 682 | + // tcUrl格式: rtmp://host:port/app[/streamName] | ||
| 683 | + // app只取第一层路径 | ||
| 684 | + try { | ||
| 685 | + String fullUrl = tcUrl; | ||
| 686 | + logger.info("[{}] getAppFromTcUrl输入: {}", streamName, fullUrl); | ||
| 687 | + | ||
| 688 | + String path = tcUrl.substring(tcUrl.indexOf("://") + 3); | ||
| 689 | + path = path.substring(path.indexOf("/") + 1); | ||
| 690 | + // 只取第一层路径作为app | ||
| 691 | + if (path.contains("/")) { | ||
| 692 | + path = path.substring(0, path.indexOf("/")); | ||
| 693 | + } | ||
| 694 | + if (path.contains("?")) { | ||
| 695 | + path = path.substring(0, path.indexOf("?")); | ||
| 696 | + } | ||
| 697 | + logger.info("[{}] getAppFromTcUrl输出: app='{}'", streamName, path); | ||
| 698 | + return path; | ||
| 699 | + } catch (Exception e) { | ||
| 700 | + logger.error("[{}] getAppFromTcUrl异常: {}", streamName, e.getMessage()); | ||
| 701 | + return "live"; | ||
| 702 | + } | ||
| 703 | + } | ||
| 704 | + | ||
| 705 | + /** | ||
| 706 | + * 编码AMF0字符串 | ||
| 707 | + */ | ||
| 708 | + private byte[] encodeAmfString(String str) { | ||
| 709 | + // AMF0 String: [0x02] [2-byte length] [UTF-8 bytes] | ||
| 710 | + byte[] strBytes = str.getBytes(); | ||
| 711 | + ByteBuffer buffer = ByteBuffer.allocate(1 + 2 + strBytes.length); | ||
| 712 | + buffer.put((byte) 0x02); // String marker | ||
| 713 | + buffer.putShort((short) strBytes.length); | ||
| 714 | + buffer.put(strBytes); | ||
| 715 | + return buffer.array(); | ||
| 716 | + } | ||
| 717 | + | ||
| 718 | + /** | ||
| 719 | + * 编码AMF0 Number | ||
| 720 | + */ | ||
| 721 | + private byte[] encodeAmfNumber(double value) { | ||
| 722 | + // AMF0 Number: [0x00] [8-byte IEEE 754 double] | ||
| 723 | + ByteBuffer buffer = ByteBuffer.allocate(1 + 8); | ||
| 724 | + buffer.put((byte) 0x00); // Number marker | ||
| 725 | + buffer.putLong(Double.doubleToLongBits(value)); | ||
| 726 | + return buffer.array(); | ||
| 727 | + } | ||
| 728 | + | ||
| 729 | + /** | ||
| 730 | + * 编码AMF0 Null | ||
| 731 | + */ | ||
| 732 | + private byte[] encodeAmfNull() { | ||
| 733 | + // AMF0 Null: [0x05] | ||
| 734 | + return new byte[] { 0x05 }; | ||
| 735 | + } | ||
| 736 | + | ||
| 737 | + /** | ||
| 738 | + * 编码AMF0 Object | ||
| 739 | + */ | ||
| 740 | + private byte[] encodeAmfObject(String key, String value) { | ||
| 741 | + // AMF0 Object property: [string key] [string value] | ||
| 742 | + byte[] keyBytes = key.getBytes(); | ||
| 743 | + byte[] valueBytes = value.getBytes(); | ||
| 744 | + ByteBuffer buffer = ByteBuffer.allocate(2 + keyBytes.length + 1 + 2 + valueBytes.length); | ||
| 745 | + buffer.putShort((short) keyBytes.length); | ||
| 746 | + buffer.put(keyBytes); | ||
| 747 | + buffer.put((byte) 0x02); // String marker for value | ||
| 748 | + buffer.putShort((short) valueBytes.length); | ||
| 749 | + buffer.put(valueBytes); | ||
| 750 | + return buffer.array(); | ||
| 751 | + } | ||
| 752 | + | ||
| 753 | + /** | ||
| 754 | + * 编码AMF0 Object 属性(Number类型) | ||
| 755 | + */ | ||
| 756 | + private byte[] encodeAmfObjectNumber(String key, double value) { | ||
| 757 | + // AMF0 Object property: [string key] [number value] | ||
| 758 | + byte[] keyBytes = key.getBytes(); | ||
| 759 | + ByteBuffer buffer = ByteBuffer.allocate(2 + keyBytes.length + 1 + 8); | ||
| 760 | + buffer.putShort((short) keyBytes.length); | ||
| 761 | + buffer.put(keyBytes); | ||
| 762 | + buffer.put((byte) 0x00); // Number marker | ||
| 763 | + buffer.putLong(Double.doubleToLongBits(value)); | ||
| 764 | + return buffer.array(); | ||
| 765 | + } | ||
| 766 | + | ||
| 767 | + /** | ||
| 768 | + * AMF0 Object结束 | ||
| 769 | + */ | ||
| 770 | + private byte[] encodeAmfObjectEnd() { | ||
| 771 | + // AMF0 Object end: 0x00 0x00 0x09 | ||
| 772 | + return new byte[] { 0x00, 0x00, 0x09 }; | ||
| 773 | + } | ||
| 774 | +} |
src/main/java/com/genersoft/iot/vmp/jtt1078/rtmp/RtmpHandshakeHandler.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.rtmp; | ||
| 2 | + | ||
| 3 | +import io.netty.buffer.ByteBuf; | ||
| 4 | +import io.netty.buffer.CompositeByteBuf; | ||
| 5 | +import io.netty.buffer.Unpooled; | ||
| 6 | +import io.netty.channel.ChannelDuplexHandler; | ||
| 7 | +import io.netty.channel.ChannelHandlerContext; | ||
| 8 | +import io.netty.channel.ChannelPromise; | ||
| 9 | +import org.slf4j.Logger; | ||
| 10 | +import org.slf4j.LoggerFactory; | ||
| 11 | + | ||
| 12 | +import java.net.URI; | ||
| 13 | +import java.net.URISyntaxException; | ||
| 14 | +import java.nio.charset.StandardCharsets; | ||
| 15 | +import java.util.HashMap; | ||
| 16 | +import java.util.Map; | ||
| 17 | + | ||
| 18 | +/** | ||
| 19 | + * RTMP 握手与推流处理器 (Netty 版本) | ||
| 20 | + */ | ||
| 21 | +public class RtmpHandshakeHandler extends ChannelDuplexHandler { | ||
| 22 | + | ||
| 23 | + private static final Logger logger = LoggerFactory.getLogger(RtmpHandshakeHandler.class); | ||
| 24 | + | ||
| 25 | + private enum State { | ||
| 26 | + HANDSHAKE_C0C1, HANDSHAKE_C2, CONNECT, CREATE_STREAM, PUBLISH, STREAMING | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + // 就绪回调 | ||
| 30 | + private Runnable onReadyListener; | ||
| 31 | + | ||
| 32 | + public void setOnReadyListener(Runnable listener) { | ||
| 33 | + this.onReadyListener = listener; | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + private State state = State.HANDSHAKE_C0C1; | ||
| 37 | + private final String streamName; | ||
| 38 | + private final String rtmpUrl; | ||
| 39 | + private final String app; | ||
| 40 | + | ||
| 41 | + private static final int RTMP_CHUNK_SIZE = 4096; | ||
| 42 | + | ||
| 43 | + private long startTime = 0; | ||
| 44 | + private boolean isFirstTag = true; | ||
| 45 | + | ||
| 46 | + public RtmpHandshakeHandler(String app, String rtmpUrl, String streamName) { | ||
| 47 | + this.app = app; | ||
| 48 | + this.rtmpUrl = rtmpUrl; | ||
| 49 | + this.streamName = streamName; | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + @Override | ||
| 53 | + public void channelActive(ChannelHandlerContext ctx) throws Exception { | ||
| 54 | + logger.info("[{}] TCP 连接建立成功,开始 RTMP 握手流程...", streamName); | ||
| 55 | + | ||
| 56 | + ByteBuf c0c1 = Unpooled.buffer(1537); | ||
| 57 | + c0c1.writeByte(0x03); | ||
| 58 | + c0c1.writeInt((int) (System.currentTimeMillis() / 1000)); | ||
| 59 | + c0c1.writeZero(1532); | ||
| 60 | + | ||
| 61 | + ctx.writeAndFlush(c0c1); | ||
| 62 | + logger.info("[{}] 已发送 C0+C1 握手包", streamName); | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + @Override | ||
| 66 | + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { | ||
| 67 | + if (msg instanceof ByteBuf) { | ||
| 68 | + ByteBuf buf = (ByteBuf) msg; | ||
| 69 | + try { | ||
| 70 | + handleRtmpMessage(ctx, buf); | ||
| 71 | + } finally { | ||
| 72 | + buf.release(); | ||
| 73 | + } | ||
| 74 | + } else { | ||
| 75 | + super.channelRead(ctx, msg); | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + private void handleRtmpMessage(ChannelHandlerContext ctx, ByteBuf msg) { | ||
| 80 | + switch (state) { | ||
| 81 | + case HANDSHAKE_C0C1: | ||
| 82 | + if (msg.readableBytes() >= 1537) { | ||
| 83 | + logger.info("[{}] 收到 S0+S1,握手第一阶段完成。", streamName); | ||
| 84 | + | ||
| 85 | + msg.skipBytes(1); // Skip S0 | ||
| 86 | + ByteBuf s1 = msg.readBytes(1536); | ||
| 87 | + | ||
| 88 | + ctx.writeAndFlush(s1.retain()); | ||
| 89 | + logger.info("[{}] 已发送 C2,握手最后阶段...", streamName); | ||
| 90 | + s1.release(); | ||
| 91 | + | ||
| 92 | + sendSetChunkSize(ctx); | ||
| 93 | + sendConnect(ctx); | ||
| 94 | + | ||
| 95 | + state = State.CONNECT; | ||
| 96 | + logger.info("[{}] >>> 状态流转: HANDSHAKE -> CONNECT", streamName); | ||
| 97 | + } | ||
| 98 | + break; | ||
| 99 | + | ||
| 100 | + case CONNECT: | ||
| 101 | + logger.info("[{}] 收到 connect 响应。", streamName); | ||
| 102 | + if (msg.readableBytes() > 20) { | ||
| 103 | + sendCreateStream(ctx); | ||
| 104 | + state = State.CREATE_STREAM; | ||
| 105 | + logger.info("[{}] >>> 状态流转: CONNECT -> CREATE_STREAM", streamName); | ||
| 106 | + } | ||
| 107 | + break; | ||
| 108 | + | ||
| 109 | + case CREATE_STREAM: | ||
| 110 | + logger.info("[{}] 收到 createStream 响应。", streamName); | ||
| 111 | + if (msg.readableBytes() > 10) { | ||
| 112 | + sendPublish(ctx); | ||
| 113 | + state = State.PUBLISH; | ||
| 114 | + logger.info("[{}] >>> 状态流转: CREATE_STREAM -> PUBLISH (流名: {})", streamName, streamName); | ||
| 115 | + } | ||
| 116 | + break; | ||
| 117 | + | ||
| 118 | + case PUBLISH: | ||
| 119 | + String response = safeReadAscii(msg); | ||
| 120 | + logger.info("[{}] 收到 publish 响应: {}", streamName, response); | ||
| 121 | + if (response.contains("NetStream.Publish.Start") || msg.readableBytes() > 10) { | ||
| 122 | + state = State.STREAMING; | ||
| 123 | + logger.info("[{}] >>> !!! 推流通道已打通 !!!", streamName); | ||
| 124 | + | ||
| 125 | + // 通知 Client 可以开始发流了 | ||
| 126 | + if (onReadyListener != null) { | ||
| 127 | + onReadyListener.run(); | ||
| 128 | + } | ||
| 129 | + } | ||
| 130 | + break; | ||
| 131 | + | ||
| 132 | + case STREAMING: | ||
| 133 | + break; | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + @Override | ||
| 138 | + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { | ||
| 139 | + // 如果是 ByteBuf (视频流) 且状态不是 STREAMING,说明握手没完成 | ||
| 140 | + if (msg instanceof ByteBuf) { | ||
| 141 | + ByteBuf flvTag = (ByteBuf) msg; | ||
| 142 | + if (state == State.STREAMING) { | ||
| 143 | + try { | ||
| 144 | + wrapAndSendChunk(ctx, flvTag, promise); | ||
| 145 | + } catch (Exception e) { | ||
| 146 | + logger.error("[{}] Chunk发送异常", streamName, e); | ||
| 147 | + if (flvTag.refCnt() > 0) flvTag.release(); | ||
| 148 | + } | ||
| 149 | + } else { | ||
| 150 | + // 状态未就绪,直接丢弃 | ||
| 151 | + if (flvTag.refCnt() > 0) flvTag.release(); | ||
| 152 | + } | ||
| 153 | + } else { | ||
| 154 | + // 其他类型消息正常透传 | ||
| 155 | + super.write(ctx, msg, promise); | ||
| 156 | + } | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + /** | ||
| 160 | + * 核心修复:强制使用实际 Buffer 长度作为 BodySize | ||
| 161 | + * 与jtt1078-video-server保持一致:只处理单个 FLV Tag | ||
| 162 | + */ | ||
| 163 | + private void wrapAndSendChunk(ChannelHandlerContext ctx, ByteBuf flvTag, ChannelPromise promise) { | ||
| 164 | + if (flvTag.readableBytes() < 11) { | ||
| 165 | + if (logger.isDebugEnabled()) logger.debug("[{}] FLV Tag 长度不足11字节,丢弃", streamName); | ||
| 166 | + flvTag.release(); | ||
| 167 | + return; | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + // --- 1. 读取 FLV Header --- | ||
| 171 | + int type = flvTag.readByte(); | ||
| 172 | + flvTag.skipBytes(3); // 【重要】跳过 FLV 中记录的长度,不信任使用实际大小 | ||
| 173 | + int timestamp = flvTag.readMedium(); | ||
| 174 | + int tsEx = flvTag.readByte(); | ||
| 175 | + timestamp |= (tsEx << 24); | ||
| 176 | + flvTag.skipBytes(3); // 跳过 StreamID | ||
| 177 | + | ||
| 178 | + // --- 2. 重新计算真实的 Body 大小 --- | ||
| 179 | + int actualBodySize = flvTag.readableBytes(); | ||
| 180 | + | ||
| 181 | + if (actualBodySize < 0 || actualBodySize > 0xFFFFFF) { | ||
| 182 | + logger.error("[{}] 检测到非法的包体大小: {}, 丢弃该包", streamName, actualBodySize); | ||
| 183 | + flvTag.release(); | ||
| 184 | + return; | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + // --- 3. 时间戳相对化处理 --- | ||
| 188 | + if (isFirstTag) { | ||
| 189 | + startTime = timestamp; | ||
| 190 | + isFirstTag = false; | ||
| 191 | + } | ||
| 192 | + long rtmpTimestamp = timestamp - startTime; | ||
| 193 | + if (rtmpTimestamp < 0) rtmpTimestamp = 0; | ||
| 194 | + | ||
| 195 | + // --- 4. 准备 Chunk Header (Type 0) --- | ||
| 196 | + int csid = (type == 8 || type == 9) ? (type == 8 ? 4 : 6) : 4; | ||
| 197 | + | ||
| 198 | + CompositeByteBuf outBuf = ctx.alloc().compositeBuffer(); | ||
| 199 | + | ||
| 200 | + ByteBuf header = ctx.alloc().buffer(12); | ||
| 201 | + header.writeByte(0x00 | (csid & 0x3F)); | ||
| 202 | + | ||
| 203 | + if (rtmpTimestamp >= 0xFFFFFF) { | ||
| 204 | + header.writeMedium(0xFFFFFF); | ||
| 205 | + } else { | ||
| 206 | + header.writeMedium((int) rtmpTimestamp); | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + header.writeMedium(actualBodySize); | ||
| 210 | + header.writeByte(type); | ||
| 211 | + header.writeIntLE(1); | ||
| 212 | + | ||
| 213 | + if (rtmpTimestamp >= 0xFFFFFF) { | ||
| 214 | + header.writeInt((int) rtmpTimestamp); | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + outBuf.addComponent(true, header); | ||
| 218 | + | ||
| 219 | + // --- 5. 分块发送 --- | ||
| 220 | + int remaining = actualBodySize; | ||
| 221 | + int firstChunkLen = Math.min(remaining, RTMP_CHUNK_SIZE); | ||
| 222 | + if (firstChunkLen > 0) { | ||
| 223 | + outBuf.addComponent(true, flvTag.readRetainedSlice(firstChunkLen)); | ||
| 224 | + remaining -= firstChunkLen; | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + while (remaining > 0) { | ||
| 228 | + ByteBuf subHeader = ctx.alloc().buffer(1); | ||
| 229 | + subHeader.writeByte(0xC0 | (csid & 0x3F)); | ||
| 230 | + outBuf.addComponent(true, subHeader); | ||
| 231 | + | ||
| 232 | + int chunkLen = Math.min(remaining, RTMP_CHUNK_SIZE); | ||
| 233 | + outBuf.addComponent(true, flvTag.readRetainedSlice(chunkLen)); | ||
| 234 | + remaining -= chunkLen; | ||
| 235 | + } | ||
| 236 | + | ||
| 237 | + flvTag.release(); | ||
| 238 | + | ||
| 239 | + if (outBuf.isReadable()) { | ||
| 240 | + ctx.writeAndFlush(outBuf, promise); | ||
| 241 | + } else { | ||
| 242 | + outBuf.release(); | ||
| 243 | + } | ||
| 244 | + } | ||
| 245 | + | ||
| 246 | + // ========================================================================= | ||
| 247 | + // 命令构建 | ||
| 248 | + // ========================================================================= | ||
| 249 | + | ||
| 250 | + private void sendSetChunkSize(ChannelHandlerContext ctx) { | ||
| 251 | + logger.info("[{}] 发送 SetChunkSize 命令: {}", streamName, RTMP_CHUNK_SIZE); | ||
| 252 | + ByteBuf buf = Unpooled.buffer(16); | ||
| 253 | + buf.writeByte(0x02); | ||
| 254 | + buf.writeMedium(0); | ||
| 255 | + buf.writeMedium(4); | ||
| 256 | + buf.writeByte(0x01); | ||
| 257 | + buf.writeIntLE(0); | ||
| 258 | + buf.writeInt(RTMP_CHUNK_SIZE); | ||
| 259 | + ctx.writeAndFlush(buf); | ||
| 260 | + } | ||
| 261 | + | ||
| 262 | + private void sendConnect(ChannelHandlerContext ctx) { | ||
| 263 | + logger.info("[{}] 发送 connect 命令. App: {}, TcUrl: {}", streamName, app, rtmpUrl); | ||
| 264 | + ByteBuf buf = Unpooled.buffer(); | ||
| 265 | + Amf0Util.writeString(buf, "connect"); | ||
| 266 | + Amf0Util.writeNumber(buf, 1.0); | ||
| 267 | + Map<String, Object> params = new HashMap<>(); | ||
| 268 | + params.put("app", app); | ||
| 269 | + params.put("tcUrl", rtmpUrl); | ||
| 270 | + params.put("flashVer", "FMLE/3.0 (compatible; FMSc/1.0)"); | ||
| 271 | + params.put("swfUrl", ""); | ||
| 272 | + Amf0Util.writeObject(buf, params); | ||
| 273 | + writeCommandMessage(ctx, buf, 0); | ||
| 274 | + } | ||
| 275 | + | ||
| 276 | + private void sendCreateStream(ChannelHandlerContext ctx) { | ||
| 277 | + logger.info("[{}] 发送 createStream 命令...", streamName); | ||
| 278 | + ByteBuf buf = Unpooled.buffer(); | ||
| 279 | + Amf0Util.writeString(buf, "createStream"); | ||
| 280 | + Amf0Util.writeNumber(buf, 2.0); | ||
| 281 | + Amf0Util.writeNull(buf); | ||
| 282 | + writeCommandMessage(ctx, buf, 0); | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + private void sendPublish(ChannelHandlerContext ctx) { | ||
| 286 | + // 从 rtmpUrl 中提取查询参数(如 ?sign=xxx) | ||
| 287 | + String publishName = streamName; | ||
| 288 | + int queryIndex = rtmpUrl.indexOf('?'); | ||
| 289 | + if (queryIndex > 0) { | ||
| 290 | + String queryParams = rtmpUrl.substring(queryIndex); | ||
| 291 | + publishName = streamName + queryParams; | ||
| 292 | + logger.info("[{}] 发送 publish 命令. StreamName: {} (包含鉴权参数)", streamName, publishName); | ||
| 293 | + } else { | ||
| 294 | + logger.info("[{}] 发送 publish 命令. StreamName: {}", streamName, publishName); | ||
| 295 | + } | ||
| 296 | + | ||
| 297 | + ByteBuf buf = Unpooled.buffer(); | ||
| 298 | + Amf0Util.writeString(buf, "publish"); | ||
| 299 | + Amf0Util.writeNumber(buf, 3.0); | ||
| 300 | + Amf0Util.writeNull(buf); | ||
| 301 | + Amf0Util.writeString(buf, publishName); | ||
| 302 | + Amf0Util.writeString(buf, "live"); | ||
| 303 | + writeCommandMessage(ctx, buf, 1); | ||
| 304 | + } | ||
| 305 | + | ||
| 306 | + private void writeCommandMessage(ChannelHandlerContext ctx, ByteBuf payload, int streamId) { | ||
| 307 | + int len = payload.readableBytes(); | ||
| 308 | + ByteBuf header = ctx.alloc().buffer(12); | ||
| 309 | + header.writeByte(0x03); | ||
| 310 | + header.writeMedium(0); | ||
| 311 | + header.writeMedium(len); | ||
| 312 | + header.writeByte(0x14); | ||
| 313 | + header.writeIntLE(streamId); | ||
| 314 | + ctx.write(header); | ||
| 315 | + ctx.writeAndFlush(payload); | ||
| 316 | + } | ||
| 317 | + | ||
| 318 | + private String safeReadAscii(ByteBuf buf) { | ||
| 319 | + int len = Math.min(buf.readableBytes(), 100); | ||
| 320 | + byte[] bytes = new byte[len]; | ||
| 321 | + buf.getBytes(buf.readerIndex(), bytes); | ||
| 322 | + String raw = new String(bytes, StandardCharsets.UTF_8); | ||
| 323 | + return raw.replaceAll("[^\\x20-\\x7E]", "."); | ||
| 324 | + } | ||
| 325 | + | ||
| 326 | + @Override | ||
| 327 | + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { | ||
| 328 | + logger.error("[{}] 通道异常", streamName, cause); | ||
| 329 | + ctx.close(); | ||
| 330 | + } | ||
| 331 | +} | ||
| 0 | \ No newline at end of file | 332 | \ No newline at end of file |
src/main/java/com/genersoft/iot/vmp/jtt1078/server/Jtt1078Handler.java
| @@ -151,6 +151,9 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler<Packet> { | @@ -151,6 +151,9 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler<Packet> { | ||
| 151 | int dataType = (dataTypeByte >> 4) & 0x0f; // 0=AV, 1=V, 2=Intercom, 3=Audio | 151 | int dataType = (dataTypeByte >> 4) & 0x0f; // 0=AV, 1=V, 2=Intercom, 3=Audio |
| 152 | int pkType = dataTypeByte & 0x0f; | 152 | int pkType = dataTypeByte & 0x0f; |
| 153 | 153 | ||
| 154 | + logger.debug("[{}] Jtt1078Handler媒体数据: dataType=0x{}, pkType={}, lengthOffset={}", | ||
| 155 | + tag, Integer.toHexString(dataType), pkType, lengthOffset); | ||
| 156 | + | ||
| 154 | packet.seek(5); | 157 | packet.seek(5); |
| 155 | int pt = packet.nextByte() & 0x7f; | 158 | int pt = packet.nextByte() & 0x7f; |
| 156 | 159 | ||
| @@ -164,6 +167,9 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler<Packet> { | @@ -164,6 +167,9 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler<Packet> { | ||
| 164 | long timestamp = packet.seek(16).nextLong(); | 167 | long timestamp = packet.seek(16).nextLong(); |
| 165 | byte[] videoData = packet.seek(lengthOffset + 2).nextBytes(); | 168 | byte[] videoData = packet.seek(lengthOffset + 2).nextBytes(); |
| 166 | 169 | ||
| 170 | + logger.debug("[{}] Jtt1078Handler收到视频数据: dataType=0x{}, pkType={}, videoDataLen={}", | ||
| 171 | + tag, Integer.toHexString(dataType), pkType, videoData.length); | ||
| 172 | + | ||
| 167 | // 推流到 FFmpeg (直播) | 173 | // 推流到 FFmpeg (直播) |
| 168 | PublishManager.getInstance().publishVideo(tag, sequence, timestamp, pt, videoData); | 174 | PublishManager.getInstance().publishVideo(tag, sequence, timestamp, pt, videoData); |
| 169 | } | 175 | } |
src/main/java/com/genersoft/iot/vmp/jtt1078/subscriber/StreamSwitchLogAnalyzer.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.jtt1078.subscriber; | ||
| 2 | + | ||
| 3 | +import org.slf4j.Logger; | ||
| 4 | +import org.slf4j.LoggerFactory; | ||
| 5 | + | ||
| 6 | +import java.util.*; | ||
| 7 | +import java.util.regex.Matcher; | ||
| 8 | +import java.util.regex.Pattern; | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * 码流切换日志分析器 | ||
| 12 | + * | ||
| 13 | + * 用于分析码流切换过程中可能出现的问题: | ||
| 14 | + * 1. FFmpeg启动/关闭日志 | ||
| 15 | + * 2. 视频参数变化检测 | ||
| 16 | + * 3. 花屏/断流问题定位 | ||
| 17 | + * 4. 性能问题分析 | ||
| 18 | + * | ||
| 19 | + * 使用方法: | ||
| 20 | + * 1. 在出现问题是,查看控制台日志 | ||
| 21 | + * 2. 或者将日志保存到文件后分析 | ||
| 22 | + * | ||
| 23 | + * 常见问题排查: | ||
| 24 | + * - 如果看到"FFmpeg进程未能正常退出":可能是FFmpeg被阻塞 | ||
| 25 | + * - 如果看到"SPS变化但未重启":可能是冷却期内 | ||
| 26 | + * - 如果持续花屏:可能是SPS解析失败或FLV编码器问题 | ||
| 27 | + */ | ||
| 28 | +public class StreamSwitchLogAnalyzer { | ||
| 29 | + | ||
| 30 | + static Logger logger = LoggerFactory.getLogger(StreamSwitchLogAnalyzer.class); | ||
| 31 | + | ||
| 32 | + /** 日志事件类型 */ | ||
| 33 | + public enum EventType { | ||
| 34 | + FFmpeg_START("FFmpeg推流任务启动"), | ||
| 35 | + FFmpeg_STOP("FFmpeg推流任务结束"), | ||
| 36 | + FFmpeg_CLOSE("开始关闭FFmpeg推流"), | ||
| 37 | + FFmpeg_RESTART("开始重启FFmpeg推流"), | ||
| 38 | + SPS_CHANGE("检测到视频参数变化"), | ||
| 39 | + SPS_HASH("SPS哈希变化"), | ||
| 40 | + IFRAME_RECEIVE("收到新的I帧"), | ||
| 41 | + STREAM_SWITCH("收到码流切换通知"), | ||
| 42 | + COOLDOWN_SKIP("FFmpeg重启过于频繁,跳过本次重启"), | ||
| 43 | + ERROR("错误"), | ||
| 44 | + WARNING("警告"); | ||
| 45 | + | ||
| 46 | + private final String keyword; | ||
| 47 | + | ||
| 48 | + EventType(String keyword) { | ||
| 49 | + this.keyword = keyword; | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + public String getKeyword() { | ||
| 53 | + return keyword; | ||
| 54 | + } | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + /** 日志事件 */ | ||
| 58 | + public static class LogEvent { | ||
| 59 | + public long timestamp; | ||
| 60 | + public EventType type; | ||
| 61 | + public String tag; | ||
| 62 | + public String message; | ||
| 63 | + public Map<String, Object> data; | ||
| 64 | + | ||
| 65 | + public LogEvent(long timestamp, EventType type, String tag, String message) { | ||
| 66 | + this.timestamp = timestamp; | ||
| 67 | + this.type = type; | ||
| 68 | + this.tag = tag; | ||
| 69 | + this.message = message; | ||
| 70 | + this.data = new HashMap<>(); | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + @Override | ||
| 74 | + public String toString() { | ||
| 75 | + return String.format("[%s] [%s] [%s] %s", | ||
| 76 | + new Date(timestamp), type.name(), tag, message); | ||
| 77 | + } | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + /** 问题诊断结果 */ | ||
| 81 | + public static class DiagnosisResult { | ||
| 82 | + public boolean hasProblem = false; | ||
| 83 | + public String problemType; // 问题类型 | ||
| 84 | + public String description; // 问题描述 | ||
| 85 | + public String suggestion; // 解决建议 | ||
| 86 | + public List<LogEvent> relatedEvents = new ArrayList<>(); | ||
| 87 | + | ||
| 88 | + @Override | ||
| 89 | + public String toString() { | ||
| 90 | + if (!hasProblem) { | ||
| 91 | + return "诊断结果:未发现问题"; | ||
| 92 | + } | ||
| 93 | + return String.format( | ||
| 94 | + "【问题诊断】\n" + | ||
| 95 | + "问题类型: %s\n" + | ||
| 96 | + "问题描述: %s\n" + | ||
| 97 | + "解决建议: %s\n" + | ||
| 98 | + "相关日志: %d条", | ||
| 99 | + problemType, description, suggestion, relatedEvents.size() | ||
| 100 | + ); | ||
| 101 | + } | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + /** | ||
| 105 | + * 分析日志列表,诊断问题 | ||
| 106 | + */ | ||
| 107 | + public static DiagnosisResult diagnose(List<String> rawLogs) { | ||
| 108 | + DiagnosisResult result = new DiagnosisResult(); | ||
| 109 | + List<LogEvent> events = parseLogs(rawLogs); | ||
| 110 | + | ||
| 111 | + if (events.isEmpty()) { | ||
| 112 | + result.hasProblem = false; | ||
| 113 | + result.description = "没有找到相关的日志记录"; | ||
| 114 | + return result; | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + // 检查问题1:FFmpeg重启过于频繁 | ||
| 118 | + List<LogEvent> cooldownSkips = filterEvents(events, EventType.COOLDOWN_SKIP); | ||
| 119 | + if (cooldownSkips.size() >= 3) { | ||
| 120 | + result.hasProblem = true; | ||
| 121 | + result.problemType = "FFmpeg重启过于频繁"; | ||
| 122 | + result.description = String.format( | ||
| 123 | + "在%d次码流切换中,有%d次因为冷却期被跳过,可能导致切换延迟", | ||
| 124 | + events.size(), cooldownSkips.size() | ||
| 125 | + ); | ||
| 126 | + result.suggestion = "建议将冷却时间从3000ms减少到1000ms,或者检查码流切换逻辑是否有问题"; | ||
| 127 | + result.relatedEvents.addAll(cooldownSkips); | ||
| 128 | + return result; | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + // 检查问题2:FFmpeg启动后立即关闭 | ||
| 132 | + Map<String, List<LogEvent>> tagGroups = groupByTag(events); | ||
| 133 | + for (Map.Entry<String, List<LogEvent>> entry : tagGroups.entrySet()) { | ||
| 134 | + List<LogEvent> tagEvents = entry.getValue(); | ||
| 135 | + LogEvent firstStart = findFirst(tagEvents, EventType.FFmpeg_START); | ||
| 136 | + LogEvent firstStop = findFirst(tagEvents, EventType.FFmpeg_STOP); | ||
| 137 | + | ||
| 138 | + if (firstStart != null && firstStop != null) { | ||
| 139 | + long duration = firstStop.timestamp - firstStart.timestamp; | ||
| 140 | + if (duration < 1000) { | ||
| 141 | + result.hasProblem = true; | ||
| 142 | + result.problemType = "FFmpeg启动后立即关闭"; | ||
| 143 | + result.description = String.format( | ||
| 144 | + "Tag[%s]的FFmpeg启动后%dms就关闭了,可能是源流有问题或FFmpeg配置错误", | ||
| 145 | + entry.getKey(), duration | ||
| 146 | + ); | ||
| 147 | + result.suggestion = "检查源流地址是否正确,FFmpeg是否有足够时间接收数据"; | ||
| 148 | + result.relatedEvents.add(firstStart); | ||
| 149 | + result.relatedEvents.add(firstStop); | ||
| 150 | + return result; | ||
| 151 | + } | ||
| 152 | + } | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + // 检查问题3:SPS变化但未检测到重启 | ||
| 156 | + List<LogEvent> spsChanges = filterEvents(events, EventType.SPS_CHANGE); | ||
| 157 | + List<LogEvent> restarts = filterEvents(events, EventType.FFmpeg_RESTART); | ||
| 158 | + if (spsChanges.size() > restarts.size() * 2) { | ||
| 159 | + result.hasProblem = true; | ||
| 160 | + result.problemType = "视频参数变化未触发FFmpeg重启"; | ||
| 161 | + result.description = String.format( | ||
| 162 | + "检测到%d次SPS变化,但只触发了%d次FFmpeg重启", | ||
| 163 | + spsChanges.size(), restarts.size() | ||
| 164 | + ); | ||
| 165 | + result.suggestion = "检查日志中的冷却跳过记录,可能需要调整冷却时间"; | ||
| 166 | + result.relatedEvents.addAll(spsChanges); | ||
| 167 | + return result; | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + // 检查问题4:持续的花屏迹象(ERROR日志) | ||
| 171 | + List<LogEvent> errors = filterEvents(events, EventType.ERROR); | ||
| 172 | + if (errors.size() >= 5) { | ||
| 173 | + result.hasProblem = true; | ||
| 174 | + result.problemType = "存在大量错误日志"; | ||
| 175 | + result.description = String.format( | ||
| 176 | + "在%d条日志中发现%d个错误,可能存在持续性问题", | ||
| 177 | + events.size(), errors.size() | ||
| 178 | + ); | ||
| 179 | + result.suggestion = "查看错误日志详情,分析具体错误原因"; | ||
| 180 | + result.relatedEvents.addAll(errors); | ||
| 181 | + return result; | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + // 问题5:没有找到SPS变化但也没有重启 | ||
| 185 | + if (!spsChanges.isEmpty() && restarts.isEmpty()) { | ||
| 186 | + result.hasProblem = true; | ||
| 187 | + result.problemType = "未检测到FFmpeg重启"; | ||
| 188 | + result.description = "检测到视频参数变化,但没有FFmpeg重启记录"; | ||
| 189 | + result.suggestion = "检查Channel.java中的restartRtmpPublisher是否被正确调用"; | ||
| 190 | + return result; | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + // 如果没有问题,返回成功 | ||
| 194 | + result.hasProblem = false; | ||
| 195 | + result.description = "码流切换日志分析完成,未发现明显问题"; | ||
| 196 | + | ||
| 197 | + // 添加统计信息 | ||
| 198 | + StringBuilder stats = new StringBuilder(); | ||
| 199 | + stats.append(String.format("日志统计:\n")); | ||
| 200 | + stats.append(String.format("- FFmpeg启动次数: %d\n", filterEvents(events, EventType.FFmpeg_START).size())); | ||
| 201 | + stats.append(String.format("- FFmpeg关闭次数: %d\n", filterEvents(events, EventType.FFmpeg_STOP).size())); | ||
| 202 | + stats.append(String.format("- 码流切换次数: %d\n", filterEvents(events, EventType.STREAM_SWITCH).size())); | ||
| 203 | + stats.append(String.format("- SPS变化次数: %d\n", filterEvents(events, EventType.SPS_CHANGE).size())); | ||
| 204 | + stats.append(String.format("- FFmpeg重启次数: %d\n", restarts.size())); | ||
| 205 | + | ||
| 206 | + result.description = stats.toString(); | ||
| 207 | + | ||
| 208 | + return result; | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + /** | ||
| 212 | + * 解析日志列表 | ||
| 213 | + */ | ||
| 214 | + private static List<LogEvent> parseLogs(List<String> rawLogs) { | ||
| 215 | + List<LogEvent> events = new ArrayList<>(); | ||
| 216 | + | ||
| 217 | + for (String line : rawLogs) { | ||
| 218 | + LogEvent event = parseLogLine(line); | ||
| 219 | + if (event != null) { | ||
| 220 | + events.add(event); | ||
| 221 | + } | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + // 按时间排序 | ||
| 225 | + events.sort(Comparator.comparingLong(e -> e.timestamp)); | ||
| 226 | + return events; | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + /** | ||
| 230 | + * 解析单行日志 | ||
| 231 | + */ | ||
| 232 | + private static LogEvent parseLogLine(String line) { | ||
| 233 | + if (line == null || line.isEmpty()) { | ||
| 234 | + return null; | ||
| 235 | + } | ||
| 236 | + | ||
| 237 | + // 提取时间戳(简化处理,假设格式为 [yyyy-MM-dd HH:mm:ss] 或类似) | ||
| 238 | + long timestamp = System.currentTimeMillis(); // 默认当前时间 | ||
| 239 | + Pattern timePattern = Pattern.compile("(\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2})"); | ||
| 240 | + Matcher timeMatcher = timePattern.matcher(line); | ||
| 241 | + if (timeMatcher.find()) { | ||
| 242 | + try { | ||
| 243 | + java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); | ||
| 244 | + Date date = sdf.parse(timeMatcher.group(1)); | ||
| 245 | + if (date != null) { | ||
| 246 | + timestamp = date.getTime(); | ||
| 247 | + } | ||
| 248 | + } catch (Exception e) { | ||
| 249 | + // 使用默认时间 | ||
| 250 | + } | ||
| 251 | + } | ||
| 252 | + | ||
| 253 | + // 提取tag | ||
| 254 | + String tag = "unknown"; | ||
| 255 | + Pattern tagPattern = Pattern.compile("\\[([^\\]]+)\\]\\s*(?:RTMPPublisher|Channel|\\[)"); | ||
| 256 | + Matcher tagMatcher = tagPattern.matcher(line); | ||
| 257 | + if (tagMatcher.find()) { | ||
| 258 | + tag = tagMatcher.group(1); | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | + // 判断事件类型 | ||
| 262 | + EventType type = null; | ||
| 263 | + String message = line; | ||
| 264 | + | ||
| 265 | + for (EventType t : EventType.values()) { | ||
| 266 | + if (line.contains(t.getKeyword())) { | ||
| 267 | + type = t; | ||
| 268 | + break; | ||
| 269 | + } | ||
| 270 | + } | ||
| 271 | + | ||
| 272 | + if (type == null) { | ||
| 273 | + return null; // 不关心的日志行 | ||
| 274 | + } | ||
| 275 | + | ||
| 276 | + return new LogEvent(timestamp, type, tag, message); | ||
| 277 | + } | ||
| 278 | + | ||
| 279 | + /** | ||
| 280 | + * 过滤特定类型的事件 | ||
| 281 | + */ | ||
| 282 | + private static List<LogEvent> filterEvents(List<LogEvent> events, EventType type) { | ||
| 283 | + List<LogEvent> result = new ArrayList<>(); | ||
| 284 | + for (LogEvent event : events) { | ||
| 285 | + if (event.type == type) { | ||
| 286 | + result.add(event); | ||
| 287 | + } | ||
| 288 | + } | ||
| 289 | + return result; | ||
| 290 | + } | ||
| 291 | + | ||
| 292 | + /** | ||
| 293 | + * 按tag分组 | ||
| 294 | + */ | ||
| 295 | + private static Map<String, List<LogEvent>> groupByTag(List<LogEvent> events) { | ||
| 296 | + Map<String, List<LogEvent>> groups = new HashMap<>(); | ||
| 297 | + for (LogEvent event : events) { | ||
| 298 | + groups.computeIfAbsent(event.tag, k -> new ArrayList<>()).add(event); | ||
| 299 | + } | ||
| 300 | + return groups; | ||
| 301 | + } | ||
| 302 | + | ||
| 303 | + /** | ||
| 304 | + * 查找第一个指定类型的事件 | ||
| 305 | + */ | ||
| 306 | + private static LogEvent findFirst(List<LogEvent> events, EventType type) { | ||
| 307 | + for (LogEvent event : events) { | ||
| 308 | + if (event.type == type) { | ||
| 309 | + return event; | ||
| 310 | + } | ||
| 311 | + } | ||
| 312 | + return null; | ||
| 313 | + } | ||
| 314 | + | ||
| 315 | + /** | ||
| 316 | + * 生成诊断报告 | ||
| 317 | + */ | ||
| 318 | + public static String generateReport(List<String> rawLogs) { | ||
| 319 | + DiagnosisResult result = diagnose(rawLogs); | ||
| 320 | + | ||
| 321 | + StringBuilder report = new StringBuilder(); | ||
| 322 | + report.append(repeatChar('=', 60)).append("\n"); | ||
| 323 | + report.append("码流切换问题诊断报告\n"); | ||
| 324 | + report.append("生成时间: ").append(new Date()).append("\n"); | ||
| 325 | + report.append(repeatChar('=', 60)).append("\n\n"); | ||
| 326 | + | ||
| 327 | + if (result.hasProblem) { | ||
| 328 | + report.append("【发现问题】\n"); | ||
| 329 | + report.append(result.toString()).append("\n\n"); | ||
| 330 | + | ||
| 331 | + report.append("【相关日志详情】\n"); | ||
| 332 | + for (LogEvent event : result.relatedEvents) { | ||
| 333 | + report.append(event.toString()).append("\n"); | ||
| 334 | + } | ||
| 335 | + } else { | ||
| 336 | + report.append("【诊断结果】\n"); | ||
| 337 | + report.append(result.description).append("\n"); | ||
| 338 | + } | ||
| 339 | + | ||
| 340 | + report.append("\n").append(repeatChar('=', 60)).append("\n"); | ||
| 341 | + report.append("报告结束\n"); | ||
| 342 | + report.append(repeatChar('=', 60)).append("\n"); | ||
| 343 | + | ||
| 344 | + return report.toString(); | ||
| 345 | + } | ||
| 346 | + | ||
| 347 | + /** | ||
| 348 | + * Java 8兼容的字符重复方法 | ||
| 349 | + */ | ||
| 350 | + private static String repeatChar(char c, int count) { | ||
| 351 | + StringBuilder sb = new StringBuilder(); | ||
| 352 | + for (int i = 0; i < count; i++) { | ||
| 353 | + sb.append(c); | ||
| 354 | + } | ||
| 355 | + return sb.toString(); | ||
| 356 | + } | ||
| 357 | + | ||
| 358 | + /** | ||
| 359 | + * 打印诊断报告到日志 | ||
| 360 | + */ | ||
| 361 | + public static void logDiagnosis(List<String> rawLogs) { | ||
| 362 | + DiagnosisResult result = diagnose(rawLogs); | ||
| 363 | + | ||
| 364 | + if (result.hasProblem) { | ||
| 365 | + logger.error("========== 码流切换问题诊断 =========="); | ||
| 366 | + logger.error(result.toString()); | ||
| 367 | + | ||
| 368 | + if (!result.relatedEvents.isEmpty()) { | ||
| 369 | + logger.error("---------- 相关日志 ----------"); | ||
| 370 | + for (LogEvent event : result.relatedEvents) { | ||
| 371 | + logger.error(event.toString()); | ||
| 372 | + } | ||
| 373 | + } | ||
| 374 | + logger.error("===================================="); | ||
| 375 | + } else { | ||
| 376 | + logger.info("========== 码流切换诊断 =========="); | ||
| 377 | + logger.info(result.description); | ||
| 378 | + logger.info("=================================="); | ||
| 379 | + } | ||
| 380 | + } | ||
| 381 | +} |
src/main/java/com/genersoft/iot/vmp/media/zlm/ZLMRESTfulUtils.java
| @@ -13,9 +13,7 @@ import org.springframework.stereotype.Component; | @@ -13,9 +13,7 @@ import org.springframework.stereotype.Component; | ||
| 13 | import java.io.*; | 13 | import java.io.*; |
| 14 | import java.net.ConnectException; | 14 | import java.net.ConnectException; |
| 15 | import java.net.SocketTimeoutException; | 15 | import java.net.SocketTimeoutException; |
| 16 | -import java.util.HashMap; | ||
| 17 | -import java.util.Map; | ||
| 18 | -import java.util.Objects; | 16 | +import java.util.*; |
| 19 | import java.util.concurrent.TimeUnit; | 17 | import java.util.concurrent.TimeUnit; |
| 20 | 18 | ||
| 21 | /** | 19 | /** |
| @@ -415,4 +413,75 @@ public class ZLMRESTfulUtils { | @@ -415,4 +413,75 @@ public class ZLMRESTfulUtils { | ||
| 415 | param.put("name", fileName); | 413 | param.put("name", fileName); |
| 416 | return sendPost(mediaServerItem, "deleteRecordDirectory",param, null); | 414 | return sendPost(mediaServerItem, "deleteRecordDirectory",param, null); |
| 417 | } | 415 | } |
| 416 | + | ||
| 417 | + | ||
| 418 | + public static void main(String[] args) { | ||
| 419 | + HashMap<String, String> map26 = new HashMap<String, String>() {{ | ||
| 420 | + put("26_321","东门线"); | ||
| 421 | + put("26_322","观海线"); | ||
| 422 | + put("26_323","大学城线"); | ||
| 423 | + put("26_324","惠南线"); | ||
| 424 | + put("26_325","中科路线"); | ||
| 425 | + put("26_326","川沙线"); | ||
| 426 | + put("26_327","度假区线"); | ||
| 427 | + put("26_328","唐镇线"); | ||
| 428 | + put("26_329","航头线"); | ||
| 429 | + put("26_330","鹤沙线"); | ||
| 430 | + put("26_331","民乐线"); | ||
| 431 | + put("26_332","新场线"); | ||
| 432 | + }}; | ||
| 433 | + HashMap<String, String> map22 = new HashMap<String, String>() {{ | ||
| 434 | + put("22_421","泉村路线队"); | ||
| 435 | + put("22_422","南曹路线队"); | ||
| 436 | + put("22_423","庆利路线队"); | ||
| 437 | + put("22_424","德翔路线队"); | ||
| 438 | + put("22_425","东陆路线队"); | ||
| 439 | + put("22_426","台儿庄路线队"); | ||
| 440 | + put("22_427","金葵路线队"); | ||
| 441 | + put("22_428","五洲大道线队"); | ||
| 442 | + put("22_429","金京路线队"); | ||
| 443 | + put("22_430","五莲路线队"); | ||
| 444 | + put("22_431","港城路线队"); | ||
| 445 | + put("22_432","金群路线队"); | ||
| 446 | + }}; | ||
| 447 | + HashMap<String, String> map05 = new HashMap<String, String>() {{ | ||
| 448 | + put("05_221","港城线队"); | ||
| 449 | + put("05_222","东靖线队"); | ||
| 450 | + put("05_223","金融线队"); | ||
| 451 | + put("05_224","隧六线队"); | ||
| 452 | + put("05_225","香山线队"); | ||
| 453 | + put("05_226","581线队"); | ||
| 454 | + put("05_227","796线队"); | ||
| 455 | + put("05_228","798线队"); | ||
| 456 | + put("05_229","992线队"); | ||
| 457 | + put("05_230","桥六线队"); | ||
| 458 | + }}; | ||
| 459 | + HashMap<String, String> map55 = new HashMap<String, String>() {{ | ||
| 460 | + put("55_121","955路线队"); | ||
| 461 | + put("55_122","闵行20路线队"); | ||
| 462 | + put("55_123","915路线队"); | ||
| 463 | + put("55_124","576路线队"); | ||
| 464 | + put("55_125","浦东73路线队"); | ||
| 465 | + put("55_126","970路线队"); | ||
| 466 | + put("55_127","572路线队"); | ||
| 467 | + put("55_128","980路线队"); | ||
| 468 | + put("55_129","610路线队"); | ||
| 469 | + put("55_130","787路线队"); | ||
| 470 | + put("55_131","82路线队"); | ||
| 471 | + put("55_132","981路线队"); | ||
| 472 | + }}; | ||
| 473 | + | ||
| 474 | + for (Map.Entry<String, String> entry : map26.entrySet()) { | ||
| 475 | + System.out.println(String.format("INSERT INTO `lg_equipment_list` VALUES ('%s', '%s', '%s', '26', '2', '0', '26', 0, NULL, 'wangxin', NOW(), 'wangxin', NOW(), NULL, '0');",entry.getKey(),entry.getKey(),entry.getValue())); | ||
| 476 | + } | ||
| 477 | + for (Map.Entry<String, String> entry : map22.entrySet()) { | ||
| 478 | + System.out.println(String.format("INSERT INTO `lg_equipment_list` VALUES ('%s', '%s', '%s', '22', '2', '0', '22', 0, NULL, 'wangxin', NOW(), 'wangxin', NOW(), NULL, '0');",entry.getKey(),entry.getKey(),entry.getValue())); | ||
| 479 | + } | ||
| 480 | + for (Map.Entry<String, String> entry : map05.entrySet()) { | ||
| 481 | + System.out.println(String.format("INSERT INTO `lg_equipment_list` VALUES ('%s', '%s', '%s', '05', '2', '0', '05', 0, NULL, 'wangxin', NOW(), 'wangxin', NOW(), NULL, '0');",entry.getKey(),entry.getKey(),entry.getValue())); | ||
| 482 | + } | ||
| 483 | + for (Map.Entry<String, String> entry : map55.entrySet()) { | ||
| 484 | + System.out.println(String.format("INSERT INTO `lg_equipment_list` VALUES ('%s', '%s', '%s', '55', '2', '0', '55g', 0, NULL, 'wangxin', NOW(), 'wangxin', NOW(), NULL, '0');",entry.getKey(),entry.getKey(),entry.getValue())); | ||
| 485 | + } | ||
| 486 | + } | ||
| 418 | } | 487 | } |
src/main/java/com/genersoft/iot/vmp/vmanager/jt1078/platform/StreamSwitchMonitorController.java
0 → 100644
| 1 | +package com.genersoft.iot.vmp.vmanager.jt1078.platform; | ||
| 2 | + | ||
| 3 | +import com.genersoft.iot.vmp.jtt1078.publisher.Channel; | ||
| 4 | +import com.genersoft.iot.vmp.jtt1078.subscriber.RTMPPublisher; | ||
| 5 | +import com.genersoft.iot.vmp.jtt1078.subscriber.StreamSwitchLogAnalyzer; | ||
| 6 | +import com.genersoft.iot.vmp.jtt1078.subscriber.StreamSwitchLogAnalyzer.DiagnosisResult; | ||
| 7 | +import com.genersoft.iot.vmp.service.IStreamPushService; | ||
| 8 | +import com.genersoft.iot.vmp.vmanager.bean.ErrorCode; | ||
| 9 | +import com.genersoft.iot.vmp.vmanager.jt1078.platform.domain.StreamSwitch; | ||
| 10 | +import io.swagger.v3.oas.annotations.Operation; | ||
| 11 | +import io.swagger.v3.oas.annotations.Parameter; | ||
| 12 | +import io.swagger.v3.oas.annotations.tags.Tag; | ||
| 13 | +import org.slf4j.Logger; | ||
| 14 | +import org.slf4j.LoggerFactory; | ||
| 15 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 16 | +import org.springframework.web.bind.annotation.*; | ||
| 17 | + | ||
| 18 | +import java.util.*; | ||
| 19 | +import java.util.concurrent.ConcurrentHashMap; | ||
| 20 | + | ||
| 21 | +/** | ||
| 22 | + * 码流切换监控接口 | ||
| 23 | + * | ||
| 24 | + * 提供码流切换状态的监控、诊断和问题排查功能 | ||
| 25 | + * | ||
| 26 | + * 主要功能: | ||
| 27 | + * 1. 查看当前所有频道的码流状态 | ||
| 28 | + * 2. 主动触发码流切换并监控过程 | ||
| 29 | + * 3. 获取FFmpeg进程状态 | ||
| 30 | + * 4. 自动诊断码流切换问题 | ||
| 31 | + * 5. 导出日志进行离线分析 | ||
| 32 | + */ | ||
| 33 | +@Tag(name = "码流切换监控") | ||
| 34 | +@RestController | ||
| 35 | +@RequestMapping("/api/jt1078/monitor") | ||
| 36 | +public class StreamSwitchMonitorController { | ||
| 37 | + | ||
| 38 | + private static final Logger logger = LoggerFactory.getLogger(StreamSwitchMonitorController.class); | ||
| 39 | + | ||
| 40 | + @Autowired | ||
| 41 | + private IStreamPushService streamPushService; | ||
| 42 | + | ||
| 43 | + /** 内存中的频道状态缓存(实际项目中应该从ChannelManager获取) */ | ||
| 44 | + private static final ConcurrentHashMap<String, ChannelStatus> channelStatuses = new ConcurrentHashMap<>(); | ||
| 45 | + | ||
| 46 | + /** 日志缓存(用于诊断分析) */ | ||
| 47 | + private static final List<String> recentLogs = Collections.synchronizedList(new ArrayList<>()); | ||
| 48 | + | ||
| 49 | + /** 最大缓存日志条数 */ | ||
| 50 | + private static final int MAX_LOG_SIZE = 1000; | ||
| 51 | + | ||
| 52 | + /** | ||
| 53 | + * 频道状态内部类 | ||
| 54 | + */ | ||
| 55 | + public static class ChannelStatus { | ||
| 56 | + public String tag; | ||
| 57 | + public String streamType; // "main" 或 "sub" | ||
| 58 | + public int width; | ||
| 59 | + public int height; | ||
| 60 | + public int fps; | ||
| 61 | + public String resolution; | ||
| 62 | + public boolean ffmpegRunning; | ||
| 63 | + public String ffmpegProcessInfo; | ||
| 64 | + public long lastSPSChangeTime; | ||
| 65 | + public long lastFFmpegRestartTime; | ||
| 66 | + public int restartCountToday; | ||
| 67 | + public String status; // "normal", "switching", "error" | ||
| 68 | + public String lastError; | ||
| 69 | + | ||
| 70 | + public ChannelStatus() { | ||
| 71 | + this.status = "normal"; | ||
| 72 | + this.restartCountToday = 0; | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + public Map<String, Object> toMap() { | ||
| 76 | + Map<String, Object> map = new HashMap<>(); | ||
| 77 | + map.put("tag", tag); | ||
| 78 | + map.put("streamType", streamType); | ||
| 79 | + map.put("videoParam", String.format("%dx%d@%dfps [%s]", width, height, fps, resolution)); | ||
| 80 | + map.put("ffmpegRunning", ffmpegRunning); | ||
| 81 | + map.put("ffmpegProcessInfo", ffmpegProcessInfo); | ||
| 82 | + map.put("lastSPSChangeTime", lastSPSChangeTime > 0 ? new Date(lastSPSChangeTime).toString() : "N/A"); | ||
| 83 | + map.put("lastFFmpegRestartTime", lastFFmpegRestartTime > 0 ? new Date(lastFFmpegRestartTime).toString() : "N/A"); | ||
| 84 | + map.put("restartCountToday", restartCountToday); | ||
| 85 | + map.put("status", status); | ||
| 86 | + map.put("lastError", lastError); | ||
| 87 | + return map; | ||
| 88 | + } | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + /** | ||
| 92 | + * 获取所有频道的码流状态 | ||
| 93 | + */ | ||
| 94 | + @Operation(summary = "获取所有频道的码流状态") | ||
| 95 | + @GetMapping("/channels") | ||
| 96 | + public Object getAllChannelStatus() { | ||
| 97 | + try { | ||
| 98 | + // 从实际ChannelManager获取(这里简化处理) | ||
| 99 | + List<Map<String, Object>> result = new ArrayList<>(); | ||
| 100 | + | ||
| 101 | + for (Map.Entry<String, ChannelStatus> entry : channelStatuses.entrySet()) { | ||
| 102 | + result.add(entry.getValue().toMap()); | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + return new HashMap<String, Object>() {{ | ||
| 106 | + put("code", 0); | ||
| 107 | + put("msg", "success"); | ||
| 108 | + put("data", result); | ||
| 109 | + put("total", result.size()); | ||
| 110 | + }}; | ||
| 111 | + } catch (Exception e) { | ||
| 112 | + logger.error("获取频道状态失败", e); | ||
| 113 | + return ErrorCode.ERROR100; | ||
| 114 | + } | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + /** | ||
| 118 | + * 获取指定频道的详细状态 | ||
| 119 | + */ | ||
| 120 | + @Operation(summary = "获取指定频道的详细状态") | ||
| 121 | + @GetMapping("/channel/{tag}") | ||
| 122 | + public Object getChannelStatus( | ||
| 123 | + @Parameter(description = "频道标签") @PathVariable String tag) { | ||
| 124 | + | ||
| 125 | + try { | ||
| 126 | + ChannelStatus status = channelStatuses.get(tag); | ||
| 127 | + if (status == null) { | ||
| 128 | + return new HashMap<String, Object>() {{ | ||
| 129 | + put("code", 404); | ||
| 130 | + put("msg", "频道不存在: " + tag); | ||
| 131 | + }}; | ||
| 132 | + } | ||
| 133 | + | ||
| 134 | + return new HashMap<String, Object>() {{ | ||
| 135 | + put("code", 0); | ||
| 136 | + put("msg", "success"); | ||
| 137 | + put("data", status.toMap()); | ||
| 138 | + }}; | ||
| 139 | + } catch (Exception e) { | ||
| 140 | + logger.error("获取频道状态失败: " + tag, e); | ||
| 141 | + return ErrorCode.ERROR100; | ||
| 142 | + } | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + /** | ||
| 146 | + * 触发码流切换(带监控) | ||
| 147 | + */ | ||
| 148 | + @Operation(summary = "触发码流切换(带状态监控)") | ||
| 149 | + @PostMapping("/switch/stream") | ||
| 150 | + public Object switchStreamWithMonitor(@RequestBody StreamSwitch streamSwitch) { | ||
| 151 | + if (streamSwitch == null || streamSwitch.getSim() == null | ||
| 152 | + || streamSwitch.getChannel() == null || streamSwitch.getType() == null) { | ||
| 153 | + return ErrorCode.ERROR400; | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + String key = streamSwitch.getSim() + "-" + streamSwitch.getChannel(); | ||
| 157 | + | ||
| 158 | + try { | ||
| 159 | + // 记录切换前的状态 | ||
| 160 | + ChannelStatus beforeStatus = channelStatuses.get(key); | ||
| 161 | + String beforeParam = beforeStatus != null | ||
| 162 | + ? String.format("%dx%d@%dfps", beforeStatus.width, beforeStatus.height, beforeStatus.fps) | ||
| 163 | + : "unknown"; | ||
| 164 | + | ||
| 165 | + logger.info("========== 开始码流切换监控 =========="); | ||
| 166 | + logger.info("[{}] 切换前状态: {}", key, beforeParam); | ||
| 167 | + logger.info("[{}] 目标码流类型: {}", key, streamSwitch.getType() == 0 ? "主码流" : "子码流"); | ||
| 168 | + | ||
| 169 | + // 执行切换 | ||
| 170 | + long startTime = System.currentTimeMillis(); | ||
| 171 | + streamPushService.switchStream(streamSwitch); | ||
| 172 | + | ||
| 173 | + // 记录日志 | ||
| 174 | + addLog(String.format("[%s] 码流切换指令已下发, 目标类型: %d, 时间: %s", | ||
| 175 | + key, streamSwitch.getType(), new Date())); | ||
| 176 | + | ||
| 177 | + // 更新状态为切换中 | ||
| 178 | + if (beforeStatus != null) { | ||
| 179 | + beforeStatus.status = "switching"; | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | + return new HashMap<String, Object>() {{ | ||
| 183 | + put("code", 0); | ||
| 184 | + put("msg", "码流切换指令已下发"); | ||
| 185 | + put("data", new HashMap<String, Object>() {{ | ||
| 186 | + put("key", key); | ||
| 187 | + put("startTime", new Date(startTime)); | ||
| 188 | + put("beforeParam", beforeParam); | ||
| 189 | + put("targetType", streamSwitch.getType() == 0 ? "主码流" : "子码流"); | ||
| 190 | + }}); | ||
| 191 | + }}; | ||
| 192 | + | ||
| 193 | + } catch (Exception e) { | ||
| 194 | + logger.error("[{}] 码流切换失败", key, e); | ||
| 195 | + | ||
| 196 | + // 更新错误状态 | ||
| 197 | + ChannelStatus status = channelStatuses.get(key); | ||
| 198 | + if (status != null) { | ||
| 199 | + status.status = "error"; | ||
| 200 | + status.lastError = e.getMessage(); | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + return new HashMap<String, Object>() {{ | ||
| 204 | + put("code", -1); | ||
| 205 | + put("msg", "码流切换失败: " + e.getMessage()); | ||
| 206 | + }}; | ||
| 207 | + } | ||
| 208 | + } | ||
| 209 | + | ||
| 210 | + /** | ||
| 211 | + * 更新频道状态(供内部调用) | ||
| 212 | + */ | ||
| 213 | + public static void updateChannelStatus(String tag, ChannelStatus status) { | ||
| 214 | + channelStatuses.put(tag, status); | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + /** | ||
| 218 | + * 获取频道状态(供内部调用) | ||
| 219 | + */ | ||
| 220 | + public static ChannelStatus getChannelStatusInternal(String tag) { | ||
| 221 | + return channelStatuses.get(tag); | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + /** | ||
| 225 | + * 添加日志(供内部调用) | ||
| 226 | + */ | ||
| 227 | + public static void addLog(String log) { | ||
| 228 | + recentLogs.add(log); | ||
| 229 | + if (recentLogs.size() > MAX_LOG_SIZE) { | ||
| 230 | + recentLogs.remove(0); | ||
| 231 | + } | ||
| 232 | + logger.info(log); | ||
| 233 | + } | ||
| 234 | + | ||
| 235 | + /** | ||
| 236 | + * 获取最近的日志 | ||
| 237 | + */ | ||
| 238 | + @Operation(summary = "获取最近的日志") | ||
| 239 | + @GetMapping("/logs") | ||
| 240 | + public Object getRecentLogs( | ||
| 241 | + @Parameter(description = "日志条数") @RequestParam(defaultValue = "100") int limit) { | ||
| 242 | + | ||
| 243 | + int size = Math.min(limit, recentLogs.size()); | ||
| 244 | + List<String> logs = recentLogs.subList(recentLogs.size() - size, recentLogs.size()); | ||
| 245 | + | ||
| 246 | + return new HashMap<String, Object>() {{ | ||
| 247 | + put("code", 0); | ||
| 248 | + put("msg", "success"); | ||
| 249 | + put("data", logs); | ||
| 250 | + put("total", recentLogs.size()); | ||
| 251 | + put("returned", size); | ||
| 252 | + }}; | ||
| 253 | + } | ||
| 254 | + | ||
| 255 | + /** | ||
| 256 | + * 自动诊断码流切换问题 | ||
| 257 | + */ | ||
| 258 | + @Operation(summary = "自动诊断码流切换问题") | ||
| 259 | + @GetMapping("/diagnose") | ||
| 260 | + public Object diagnose() { | ||
| 261 | + try { | ||
| 262 | + DiagnosisResult result = StreamSwitchLogAnalyzer.diagnose(new ArrayList<>(recentLogs)); | ||
| 263 | + | ||
| 264 | + Map<String, Object> response = new HashMap<>(); | ||
| 265 | + response.put("code", 0); | ||
| 266 | + response.put("msg", "success"); | ||
| 267 | + | ||
| 268 | + Map<String, Object> data = new HashMap<>(); | ||
| 269 | + data.put("hasProblem", result.hasProblem); | ||
| 270 | + data.put("problemType", result.problemType != null ? result.problemType : "无"); | ||
| 271 | + data.put("description", result.description); | ||
| 272 | + data.put("suggestion", result.suggestion != null ? result.suggestion : "无"); | ||
| 273 | + data.put("logCount", recentLogs.size()); | ||
| 274 | + | ||
| 275 | + response.put("data", data); | ||
| 276 | + | ||
| 277 | + // 记录诊断结果 | ||
| 278 | + if (result.hasProblem) { | ||
| 279 | + logger.error("========== 码流切换诊断结果 =========="); | ||
| 280 | + logger.error("问题类型: {}", result.problemType); | ||
| 281 | + logger.error("描述: {}", result.description); | ||
| 282 | + logger.error("建议: {}", result.suggestion); | ||
| 283 | + logger.error("========================================"); | ||
| 284 | + } else { | ||
| 285 | + logger.info("========== 码流切换诊断 =========="); | ||
| 286 | + logger.info("结果: 未发现问题"); | ||
| 287 | + logger.info("日志条数: {}", recentLogs.size()); | ||
| 288 | + logger.info("=================================="); | ||
| 289 | + } | ||
| 290 | + | ||
| 291 | + return response; | ||
| 292 | + | ||
| 293 | + } catch (Exception e) { | ||
| 294 | + logger.error("诊断失败", e); | ||
| 295 | + return new HashMap<String, Object>() {{ | ||
| 296 | + put("code", -1); | ||
| 297 | + put("msg", "诊断失败: " + e.getMessage()); | ||
| 298 | + }}; | ||
| 299 | + } | ||
| 300 | + } | ||
| 301 | + | ||
| 302 | + /** | ||
| 303 | + * 导出日志(供离线分析) | ||
| 304 | + */ | ||
| 305 | + @Operation(summary = "导出日志供离线分析") | ||
| 306 | + @GetMapping("/logs/export") | ||
| 307 | + public Object exportLogs() { | ||
| 308 | + try { | ||
| 309 | + StringBuilder sb = new StringBuilder(); | ||
| 310 | + sb.append("码流切换日志导出\n"); | ||
| 311 | + sb.append("导出时间: ").append(new Date()).append("\n"); | ||
| 312 | + sb.append("总条数: ").append(recentLogs.size()).append("\n"); | ||
| 313 | + sb.append(repeatChar('=', 60)).append("\n\n"); | ||
| 314 | + | ||
| 315 | + for (String log : recentLogs) { | ||
| 316 | + sb.append(log).append("\n"); | ||
| 317 | + } | ||
| 318 | + | ||
| 319 | + return new HashMap<String, Object>() {{ | ||
| 320 | + put("code", 0); | ||
| 321 | + put("msg", "success"); | ||
| 322 | + put("data", new HashMap<String, Object>() {{ | ||
| 323 | + put("logs", sb.toString()); | ||
| 324 | + put("filename", String.format("stream_switch_logs_%s.txt", | ||
| 325 | + new java.text.SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()))); | ||
| 326 | + }}); | ||
| 327 | + }}; | ||
| 328 | + | ||
| 329 | + } catch (Exception e) { | ||
| 330 | + logger.error("导出日志失败", e); | ||
| 331 | + return new HashMap<String, Object>() {{ | ||
| 332 | + put("code", -1); | ||
| 333 | + put("msg", "导出失败: " + e.getMessage()); | ||
| 334 | + }}; | ||
| 335 | + } | ||
| 336 | + } | ||
| 337 | + | ||
| 338 | + /** | ||
| 339 | + * 清除日志缓存 | ||
| 340 | + */ | ||
| 341 | + @Operation(summary = "清除日志缓存") | ||
| 342 | + @PostMapping("/logs/clear") | ||
| 343 | + public Object clearLogs() { | ||
| 344 | + int count = recentLogs.size(); | ||
| 345 | + recentLogs.clear(); | ||
| 346 | + | ||
| 347 | + logger.info("已清除 {} 条日志", count); | ||
| 348 | + | ||
| 349 | + return new HashMap<String, Object>() {{ | ||
| 350 | + put("code", 0); | ||
| 351 | + put("msg", "已清除 " + count + " 条日志"); | ||
| 352 | + }}; | ||
| 353 | + } | ||
| 354 | + | ||
| 355 | + /** | ||
| 356 | + * 获取FFmpeg进程信息 | ||
| 357 | + */ | ||
| 358 | + @Operation(summary = "获取FFmpeg进程信息") | ||
| 359 | + @GetMapping("/ffmpeg/{tag}") | ||
| 360 | + public Object getFFmpegInfo( | ||
| 361 | + @Parameter(description = "频道标签") @PathVariable String tag) { | ||
| 362 | + | ||
| 363 | + try { | ||
| 364 | + ChannelStatus status = channelStatuses.get(tag); | ||
| 365 | + if (status == null) { | ||
| 366 | + return new HashMap<String, Object>() {{ | ||
| 367 | + put("code", 404); | ||
| 368 | + put("msg", "频道不存在: " + tag); | ||
| 369 | + }}; | ||
| 370 | + } | ||
| 371 | + | ||
| 372 | + return new HashMap<String, Object>() {{ | ||
| 373 | + put("code", 0); | ||
| 374 | + put("msg", "success"); | ||
| 375 | + put("data", new HashMap<String, Object>() {{ | ||
| 376 | + put("tag", tag); | ||
| 377 | + put("running", status.ffmpegRunning); | ||
| 378 | + put("processInfo", status.ffmpegProcessInfo); | ||
| 379 | + put("lastRestartTime", status.lastFFmpegRestartTime > 0 | ||
| 380 | + ? new Date(status.lastFFmpegRestartTime).toString() : "N/A"); | ||
| 381 | + put("restartCountToday", status.restartCountToday); | ||
| 382 | + }}); | ||
| 383 | + }}; | ||
| 384 | + | ||
| 385 | + } catch (Exception e) { | ||
| 386 | + logger.error("获取FFmpeg信息失败: " + tag, e); | ||
| 387 | + return new HashMap<String, Object>() {{ | ||
| 388 | + put("code", -1); | ||
| 389 | + put("msg", "获取失败: " + e.getMessage()); | ||
| 390 | + }}; | ||
| 391 | + } | ||
| 392 | + } | ||
| 393 | + | ||
| 394 | + /** | ||
| 395 | + * 健康检查 | ||
| 396 | + */ | ||
| 397 | + @Operation(summary = "健康检查") | ||
| 398 | + @GetMapping("/health") | ||
| 399 | + public Object health() { | ||
| 400 | + int errorCount = 0; | ||
| 401 | + for (ChannelStatus status : channelStatuses.values()) { | ||
| 402 | + if ("error".equals(status.status)) { | ||
| 403 | + errorCount++; | ||
| 404 | + } | ||
| 405 | + } | ||
| 406 | + | ||
| 407 | + final int finalErrorCount = errorCount; | ||
| 408 | + final int totalChannels = channelStatuses.size(); | ||
| 409 | + final int logBufferSize = recentLogs.size(); | ||
| 410 | + | ||
| 411 | + return new HashMap<String, Object>() {{ | ||
| 412 | + put("code", 0); | ||
| 413 | + put("msg", "success"); | ||
| 414 | + put("data", new HashMap<String, Object>() {{ | ||
| 415 | + put("status", finalErrorCount == 0 ? "healthy" : "degraded"); | ||
| 416 | + put("totalChannels", totalChannels); | ||
| 417 | + put("errorChannels", finalErrorCount); | ||
| 418 | + put("logBufferSize", logBufferSize); | ||
| 419 | + put("timestamp", new Date()); | ||
| 420 | + }}); | ||
| 421 | + }}; | ||
| 422 | + } | ||
| 423 | + | ||
| 424 | + /** | ||
| 425 | + * Java 8兼容的字符重复方法 | ||
| 426 | + */ | ||
| 427 | + private static String repeatChar(char c, int count) { | ||
| 428 | + StringBuilder sb = new StringBuilder(); | ||
| 429 | + for (int i = 0; i < count; i++) { | ||
| 430 | + sb.append(c); | ||
| 431 | + } | ||
| 432 | + return sb.toString(); | ||
| 433 | + } | ||
| 434 | +} |
src/main/resources/app-jt1078-em.properties
| @@ -3,15 +3,26 @@ server.http.port = 3333 | @@ -3,15 +3,26 @@ server.http.port = 3333 | ||
| 3 | server.history.port = 9101 | 3 | server.history.port = 9101 |
| 4 | server.backlog = 1024 | 4 | server.backlog = 1024 |
| 5 | 5 | ||
| 6 | -# ffmpegå¯æÂ§è¡ÂæÂÂä»¶è·¯å¾Âï¼Âå¯以çÂÂ空 | 6 | +# ffmpeg路径配置 |
| 7 | ffmpeg.path = ffmpeg | 7 | ffmpeg.path = ffmpeg |
| 8 | 8 | ||
| 9 | -# é Âç½®rtmpå°åÂÂå°Âå¨ç»Â端åÂÂéÂÂRTPæ¶ÂæÂ¯å æÂ¶ï¼Âé¢Âå¤ÂçÂÂÃ¥ÂÂRTMPæÂÂå¡å¨æÂ¨æµ | ||
| 10 | -# TAGçÂÂå½¢å¼Âå°±æÂ¯SIM-CHANNELï¼Âå¦Â13800138999-2 | ||
| 11 | -# å¦ÂæÂÂçÂÂ空å°Âä¸ÂÃ¥ÂÂRTMPæÂÂå¡å¨æÂ¨æµ | 9 | +# 推流器类型: ffmpeg | native |
| 10 | +# - ffmpeg: 使用FFmpeg进程推流,码流切换需要重启(1-3秒中断) | ||
| 11 | +# - native: 使用原生Java RTMP推流,码流切换无缝(0中断) | ||
| 12 | +# 默认为ffmpeg,保持向后兼容 | ||
| 13 | +publisher.type = native | ||
| 14 | + | ||
| 15 | +# 原生RTMP配置(当publisher.type=native时生效) | ||
| 16 | +# ZLM的RTMP监听地址 | ||
| 17 | +rtmp.native.host = 10.0.0.16 | ||
| 18 | +rtmp.native.port = 1935 | ||
| 19 | +rtmp.native.app = schedule | ||
| 20 | + | ||
| 21 | +# RTMP目标地址配置 | ||
| 22 | +# TAG的格式通常是SIM-CHANNEL,如13800138999-2 | ||
| 12 | #rtmp.url = rtsp://192.168.169.100:9555/schedule/{TAG}?sign={sign} | 23 | #rtmp.url = rtsp://192.168.169.100:9555/schedule/{TAG}?sign={sign} |
| 13 | rtmp.url = rtsp://10.0.0.16:9554/schedule/{TAG}?sign={sign} | 24 | rtmp.url = rtsp://10.0.0.16:9554/schedule/{TAG}?sign={sign} |
| 14 | 25 | ||
| 15 | #rtmp.url = rtsp://192.168.169.100:19555/schedule/{TAG}?sign={sign} | 26 | #rtmp.url = rtsp://192.168.169.100:19555/schedule/{TAG}?sign={sign} |
| 16 | -# 设置为onæÂ¶ï¼ÂæÂ§å¶å°å°Âè¾ÂåºffmpegçÂÂè¾Âåº | 27 | +# 设置为on时,控制FFmpeg的输出 |
| 17 | debug.mode = off | 28 | debug.mode = off |
src/main/resources/app.properties
| 1 | server.port = 40000 | 1 | server.port = 40000 |
| 2 | server.http.port = 3333 | 2 | server.http.port = 3333 |
| 3 | -server.history.port = 30001 | 3 | +server.history.port = 40001 |
| 4 | server.backlog = 1024 | 4 | server.backlog = 1024 |
| 5 | 5 | ||
| 6 | -# ffmpeg坿§è¡æä»¶è·¯å¾ï¼å¯ä»¥ç空 | 6 | +# ffmpeg路径配置 |
| 7 | ffmpeg.path = F:/ffmpeg/ffmpeg-7.0.2-essentials_build/bin/ffmpeg.exe | 7 | ffmpeg.path = F:/ffmpeg/ffmpeg-7.0.2-essentials_build/bin/ffmpeg.exe |
| 8 | 8 | ||
| 9 | -# é ç½®rtmpå°åå°å¨ç»ç«¯åéRTPæ¶æ¯å æ¶ï¼é¢å¤çåRTMPæå¡å¨æ¨æµ | ||
| 10 | -# TAGçå½¢å¼å°±æ¯SIM-CHANNELï¼å¦13800138999-2 | ||
| 11 | -# 妿ç空å°ä¸åRTMPæå¡å¨æ¨æµ | 9 | +# 推流器类型: ffmpeg | netty |
| 10 | +# - ffmpeg: 使用FFmpeg进程推流,码流切换需要重启(1-3秒中断) | ||
| 11 | +# - netty: 使用Netty RTMP推流(非阻塞),码流切换无缝(0中断)推荐使用 | ||
| 12 | +# 默认为ffmpeg,保持向后兼容 | ||
| 13 | +publisher.type = netty | ||
| 14 | + | ||
| 15 | +# 原生RTMP配置(当publisher.type=netty时生效) | ||
| 16 | +rtmp.native.host = 127.0.0.1 | ||
| 17 | +rtmp.native.port = 1935 | ||
| 18 | +rtmp.native.app = schedule | ||
| 19 | + | ||
| 20 | +# RTMP目标地址配置 | ||
| 21 | +# TAG的格式通常是SIM-CHANNEL,如13800138999-2 | ||
| 12 | #rtmp.url = rtsp://192.168.169.100:9555/schedule/{TAG}?sign={sign} | 22 | #rtmp.url = rtsp://192.168.169.100:9555/schedule/{TAG}?sign={sign} |
| 13 | rtmp.url = rtsp://127.0.0.1:554/schedule/{TAG}?sign={sign} | 23 | rtmp.url = rtsp://127.0.0.1:554/schedule/{TAG}?sign={sign} |
| 14 | 24 | ||
| 15 | #rtmp.url = rtsp://192.168.169.100:19555/schedule/{TAG}?sign={sign} | 25 | #rtmp.url = rtsp://192.168.169.100:19555/schedule/{TAG}?sign={sign} |
| 16 | -# 设置为onæ¶ï¼æ§å¶å°å°è¾åºffmpegçè¾åº | ||
| 17 | debug.mode = off | 26 | debug.mode = off |
| 18 | 27 | ||
| 19 | zlm.host = 127.0.0.1 | 28 | zlm.host = 127.0.0.1 |
src/main/resources/logback-spring.xml
| @@ -99,4 +99,10 @@ | @@ -99,4 +99,10 @@ | ||
| 99 | <appender-ref ref="SipRollingFile" /> | 99 | <appender-ref ref="SipRollingFile" /> |
| 100 | </logger> | 100 | </logger> |
| 101 | 101 | ||
| 102 | + <!-- RTMP调试日志 --> | ||
| 103 | + <logger name="com.genersoft.iot.vmp.jtt1078.rtmp" level="debug" additivity="false"> | ||
| 104 | + <appender-ref ref="STDOUT" /> | ||
| 105 | + <appender-ref ref="RollingFile"/> | ||
| 106 | + </logger> | ||
| 107 | + | ||
| 102 | </configuration> | 108 | </configuration> |
| 103 | \ No newline at end of file | 109 | \ No newline at end of file |
web_src/.claude/settings.local.json
0 → 100644