Commit 487953271aaf5f6ca3aa89f5d919f3aeed00872c

Authored by 王鑫
1 parent f375c1c2

fix():添加RTMP推流(未完成)

Too many changes to show.

To preserve performance only 21 of 25 files are displayed.

.claude/settings.local.json 0 → 100644
  1 +{
  2 + "permissions": {
  3 + "allow": [
  4 + "Bash(mvn compile:*)"
  5 + ]
  6 + }
  7 +}
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&lt;Packet&gt; { @@ -151,6 +151,9 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
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&lt;Packet&gt; { @@ -164,6 +167,9 @@ public class Jtt1078Handler extends SimpleChannelInboundHandler&lt;Packet&gt; {
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
  1 +{
  2 + "permissions": {
  3 + "allow": [
  4 + "Bash(netstat:*)",
  5 + "Bash(findstr:*)",
  6 + "Bash(powershell -Command:*)",
  7 + "Bash(taskkill:*)",
  8 + "Bash(start python:*)",
  9 + "Bash(timeout:*)",
  10 + "Bash(tasklist:*)",
  11 + "Bash(start:*)"
  12 + ]
  13 + }
  14 +}