增加了通过网络主动上报图像的支持

增加了指令维护通道的支持
This commit is contained in:
2026-01-07 10:59:03 +08:00
parent a697aab3e0
commit 3d47c8f009
47 changed files with 1613 additions and 1734 deletions

View File

@@ -1,4 +1,5 @@
using SHH.CameraDashboard.Services;
using SHH.Contracts;
using SHH.ProcessLaunchers;
using System.Collections.ObjectModel;
using System.Windows;
@@ -31,6 +32,7 @@ namespace SHH.CameraDashboard
// 启动指令服务 (Port 6001)
CommandServer.Instance.Start(6001);
CommandServer.Instance.OnClientRegistered += SetupAutomaticConfiguration;
// 现在我们来配置启动
@@ -57,7 +59,9 @@ namespace SHH.CameraDashboard
string serviceArgs = $"" +
$"--pid {myPid} " +
$"--appid \"CameraApp_01\" " +
$"--uris \"127.0.0.1,6002&6001;\" " +
$"--uris \"127.0.0.1,6002,video,PC;\" " +
$"--uris \"127.0.0.1,6001,command,PC;\" " +
$"--uris \"192.168.1.100,6002,video,;\" " +
$"--mode 1 " +
$"--ports \"5000,100\"";
@@ -86,6 +90,78 @@ namespace SHH.CameraDashboard
mainWin.Show();
}
/// <summary>
/// 在程序启动时订阅事件
/// </summary>
/// <param name="obj"></param>
private void SetupAutomaticConfiguration(ConnectedClient obj)
{
// 监听注册事件:每当有 Service (CommandClientWorker) 连上来注册成功
CommandServer.Instance.OnClientRegistered += (client) =>
{
Console.WriteLine($"[自动化] 检测到新服务上线: {client.ServiceId} ({client.Ip})");
// 放到线程池去执行,避免阻塞 UI 或网络接收线程
Task.Run(async () =>
{
// 1. 稍微延时一点点 (500ms),给 Service 一点喘息时间准备接收指令
await Task.Delay(500);
// 2. 构造您指定的“206摄像头”配置
var cameraConfig = new CameraConfigDto
{
Id = 17798,
Name = "206摄像头",
Location = "404办公室",
IpAddress = "172.16.41.88",
Username = "admin",
Password = "abcd1234",
Port = 8000,
ChannelIndex = 1,
StreamType = 0,
Brand = DeviceBrand.HikVision.GetHashCode(), // 对应 DeviceBrand 枚举
RenderHandle = 0, // 初始化为0
MainboardIp = "", // 留空
MainboardPort = 0,
RtspPath = ""
};
// ★ 新增:一并带上订阅要求 ★
cameraConfig.AutoSubscriptions = new List<CameraConfigSubscribeDto>
{
// 第一条:显示帧,要求 8 帧
new CameraConfigSubscribeDto {
AppId = "UI_Display",
Type = 0,
TargetFps = 8,
Memo = "显示帧"
},
// 第二条:分析帧,要求 1 帧
new CameraConfigSubscribeDto {
AppId = "AI_Analysis",
Type = 0,
Memo = "分析帧",
TargetFps = 1
}
};
// 3. 封装协议包
var commandPacket = new
{
Action = "SyncCamera", // 告诉 Service 执行什么动作
Payload = cameraConfig, // 数据载荷
Time = DateTime.Now
};
// 4. 定向发送
// client.ServiceId 就是那个 "CameraApp_01"
CommandServer.Instance.SendCommand(client.ServiceId, commandPacket);
Console.WriteLine($"[自动化] 已向 {client.ServiceId} 下发配置: 206摄像头");
});
};
}
/// <summary>
/// 全局统一退出入口
/// </summary>

View File

@@ -1,40 +1,48 @@
using NetMQ;
using NetMQ.Sockets;
using Newtonsoft.Json;
using System;
using Newtonsoft.Json.Linq;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text;
using System.Threading.Tasks;
namespace SHH.CameraDashboard.Services;
namespace SHH.CameraDashboard;
/// <summary>
/// [Dashboard端] 指令控制服务
/// 职责:双向通信通道。接收 Service 心跳/响应,向 Service 发送控制指令。
/// 核心模式ROUTER (Dashboard) <--> DEALER (Service)
/// 职责:监听 6001 端口,接收 CameraService 的注册/心跳,并下发控制指令。
/// </summary>
public class CommandServer : IDisposable
{
// 单例模式
public static CommandServer Instance { get; } = new CommandServer();
// 事件:收到消息时触发 (ServiceId, MessageContent)
// =================================================================
// 事件定义
// =================================================================
// 当有新设备注册成功时触发 (UI 可以订阅这个来刷新列表)
public event Action<ConnectedClient>? OnClientRegistered;
// 当收到通用业务消息时触发
public event Action<string, string>? OnMessageReceived;
// =================================================================
// 内部成员
// =================================================================
private RouterSocket? _routerSocket;
private NetMQPoller? _poller;
// 【关键新增】发送队列:用于解决跨线程发送的安全问题
// UI线程 -> Enqueue -> Poller线程 -> Socket.Send
private NetMQQueue<CommandPacket>? _sendQueue;
// 在线设备表 (Key: Identity/AppId)
// 线程安全字典,存储客户端的详细信息(包括视频地址)
private readonly ConcurrentDictionary<string, ConnectedClient> _clients = new();
public int ListenPort { get; private set; }
public bool IsRunning => _poller != null && _poller.IsRunning;
// 在线设备表 (可选,用于记录谁在线)
// Key: ServiceId (Identity字符串)
private readonly ConcurrentDictionary<string, DateTime> _onlineClients = new();
// 获取当前所有在线客户端的副本
public List<ConnectedClient> GetClients() => _clients.Values.ToList();
private CommandServer() { }
@@ -47,17 +55,15 @@ public class CommandServer : IDisposable
{
// 1. 初始化 Router Socket
_routerSocket = new RouterSocket();
_routerSocket.Bind($"tcp://*:{ListenPort}");
_routerSocket.Bind($"tcp://*:{ListenPort}"); // 监听所有网卡
_routerSocket.ReceiveReady += OnSocketReady;
// 2. 初始化发送队列
// 2. 初始化发送队列 (确保 UI 线程可以安全发送)
_sendQueue = new NetMQQueue<CommandPacket>();
_sendQueue.ReceiveReady += OnQueueReady;
// 3. 启动 Poller (同时监听 Socket 接收 和 队列发送)
// 3. 启动 Poller
_poller = new NetMQPoller { _routerSocket, _sendQueue };
// RunAsync 会自动开启后台线程
_poller.RunAsync();
Console.WriteLine($"[Dashboard] 指令服务启动,监听: tcp://*:{ListenPort}");
@@ -65,33 +71,37 @@ public class CommandServer : IDisposable
catch (Exception ex)
{
Console.WriteLine($"[Dashboard] 指令端口绑定失败: {ex.Message}");
throw; // 必须抛出,让 App 感知
throw;
}
}
/// <summary>
/// 处理来自 Service 的网络消息 (运行在 Poller 线程)
/// [Poller线程] 处理网络接收
/// </summary>
private void OnSocketReady(object? sender, NetMQSocketEventArgs e)
{
try
{
// 1. 读取身份帧 (Identity)
// 只要 Service 端 DealerSocket 设置了 Identity这里收到就是那个 ID
// Router 接收逻辑:
// Frame 1: 发送者的 Identity (NetMQ 自动处理)
// Frame 2: 真实数据
// 1. 读取身份 (Identity)
var identityBytes = e.Socket.ReceiveFrameBytes();
string serviceId = Encoding.UTF8.GetString(identityBytes);
string serviceId = Encoding.UTF8.GetString(identityBytes); // e.g., "CameraApp_01"
// 2. 读取内容帧 (假设 Dealer 直接发内容,中间无空帧)
// 如果你使用了 REQ/REP 模式,中间可能会空帧,需注意兼容
// 2. 读取消息内容
// 兼容性处理:有些 Dealer 实现可能会空帧,这里做个简单尝试
// 如果发现在 Identity 后紧跟的是空帧,则再读一帧
// 但在我们目前的 Dealer 实现中,是直接发的 JSON
string message = e.Socket.ReceiveFrameString();
if (string.IsNullOrWhiteSpace(message))
{
if (e.Socket.HasIn) message = e.Socket.ReceiveFrameString();
}
// 3. 简单的心跳保活逻辑
_onlineClients[serviceId] = DateTime.Now;
// 4. 触发业务事件
// 注意:这依然在 Poller 线程UI 处理时需 Invoke
Console.WriteLine($"[指令] From {serviceId}: {message}");
OnMessageReceived?.Invoke(serviceId, message);
// 3. 协议解析与业务分发
ProcessMessage(serviceId, message, identityBytes);
}
catch (Exception ex)
{
@@ -100,37 +110,99 @@ public class CommandServer : IDisposable
}
/// <summary>
/// 处理发送队列 (运行在 Poller 线程)
/// 核心业务逻辑处理
/// </summary>
private void OnQueueReady(object? sender, NetMQQueueEventArgs<CommandPacket> e)
private void ProcessMessage(string serviceId, string json, byte[] identityBytes)
{
try
{
if (_routerSocket == null) return;
// 尝试解析基础结构
var jObj = JObject.Parse(json);
string action = jObj["Action"]?.ToString() ?? "Unknown";
// 从队列取出一个包
if (e.Queue.TryDequeue(out var packet, TimeSpan.Zero))
// 更新最后心跳时间 (如果已存在)
if (_clients.TryGetValue(serviceId, out var existingClient))
{
// Router 发送标准三步走:
// 1. 发送目标 Identity (More = true)
// 2. 发送空帧 (可选取决于协议约定Router-Dealer 直连通常不需要空帧)
// 3. 发送数据 (More = false)
existingClient.LastHeartbeat = DateTime.Now;
}
// 这里我们采用最简协议:[Identity][Data]
_routerSocket.SendMoreFrame(packet.TargetId)
.SendFrame(packet.JsonData);
Console.WriteLine($"[指令] To {packet.TargetId}: {packet.JsonData}");
// ★★★ 处理注册握手 ★★★
if (action == "Register")
{
HandleRegistration(serviceId, jObj, identityBytes);
}
else
{
// 其他业务消息,透传给上层
Console.WriteLine($"[指令] From {serviceId}: {json}");
OnMessageReceived?.Invoke(serviceId, json);
}
}
catch (Exception ex)
catch (JsonException)
{
Debug.WriteLine($"[Command Send Error] {ex.Message}");
Console.WriteLine($"[指令] 收到非 JSON 消息 From {serviceId}: {json}");
}
}
/// <summary>
/// 发送指令 (线程安全,可由 UI 线程调用)
/// 处理注册逻辑
/// </summary>
private void HandleRegistration(string serviceId, JObject jObj, byte[] identityBytes)
{
var payload = jObj["Payload"];
if (payload == null) return;
// 1. 提取客户端信息
var client = new ConnectedClient
{
ServiceId = serviceId,
Ip = payload["Ip"]?.ToString() ?? "Unknown",
// ★★★ 解析新字段 ★★★
WebPort = payload["WebPort"]?.Value<int>() ?? 5000,
Version = payload["Version"]?.ToString() ?? "Unknown",
Pid = payload["Pid"]?.Value<int>() ?? 0,
TargetVideoNodes = payload["TargetVideoNodes"]?.ToObject<List<string>>() ?? new List<string>(),
LastHeartbeat = DateTime.Now
};
// 2. 存入内存表 (Add or Update)
_clients.AddOrUpdate(serviceId, client, (key, old) => client);
Console.WriteLine($"[注册成功] {serviceId}");
// 3. 回复 ACK (握手确认)
// 告诉客户端:我收到你的注册了,连接建立成功
var ackPacket = new { Action = "ACK", Message = $"Registered {serviceId}", Time = DateTime.Now };
string ackJson = JsonConvert.SerializeObject(ackPacket);
// 直接在 Poller 线程发回,不需要走 Queue (因为拥有 Socket 所有权)
_routerSocket?.SendMoreFrame(identityBytes).SendFrame(ackJson);
// 4. 通知 UI 更新列表
OnClientRegistered?.Invoke(client);
}
/// <summary>
/// [Poller线程] 处理发送队列
/// </summary>
private void OnQueueReady(object? sender, NetMQQueueEventArgs<CommandPacket> e)
{
if (_routerSocket == null) return;
if (e.Queue.TryDequeue(out var packet, TimeSpan.Zero))
{
// Router 发送:[Identity] [Data]
_routerSocket.SendMoreFrame(packet.TargetId)
.SendFrame(packet.JsonData);
Console.WriteLine($"[发送] To {packet.TargetId}: {packet.JsonData}");
}
}
/// <summary>
/// [公共API] 向指定 Service 发送指令
/// </summary>
public void SendCommand(string targetServiceId, object commandData)
{
@@ -138,7 +210,6 @@ public class CommandServer : IDisposable
var json = JsonConvert.SerializeObject(commandData);
// ★★★ 核心修复:不直接操作 Socket而是入队 ★★★
_sendQueue.Enqueue(new CommandPacket
{
TargetId = targetServiceId,
@@ -152,13 +223,12 @@ public class CommandServer : IDisposable
_poller?.Dispose();
_routerSocket?.Dispose();
_sendQueue?.Dispose();
_poller = null;
_routerSocket = null;
_sendQueue = null;
}
// 内部数据包结构
// =============================================================
// 数据模型
// =============================================================
private class CommandPacket
{
public string TargetId { get; set; } = "";

View File

@@ -0,0 +1,30 @@
namespace SHH.CameraDashboard;
/// <summary>
/// 在线客户端信息模型 (已更新)
/// </summary>
public class ConnectedClient
{
/// <summary> 唯一标识 (AppId) </summary>
public string ServiceId { get; set; } = string.Empty;
/// <summary> 版本号 </summary>
public string Version { get; set; } = "1.0.0";
/// <summary> 远程进程 ID </summary>
public int Pid { get; set; }
/// <summary> 客户端 IP </summary>
public string Ip { get; set; } = string.Empty;
/// <summary> WebAPI 端口 (Dashboard 调用 REST 接口用) </summary>
public int WebPort { get; set; }
/// <summary> 该客户端正在推流的目标地址 </summary>
public List<string> TargetVideoNodes { get; set; } = new List<string>();
public DateTime LastHeartbeat { get; set; }
// 辅助属性:拼接出完整的 API BaseUrl
public string WebApiUrl => $"http://{Ip}:{WebPort}";
}

View File

@@ -1,67 +1,54 @@
using NetMQ;
using NetMQ.Sockets;
using System.Diagnostics; // 用于 Debug 输出
using Newtonsoft.Json;
using SHH.Contracts; // ★★★ 必须引用契约库 ★★★
using System.Diagnostics;
namespace SHH.CameraDashboard.Services;
namespace SHH.CameraDashboard;
public class StreamReceiverService : IDisposable
{
// 单例模式
public static StreamReceiverService Instance { get; } = new StreamReceiverService();
public event Action<string, byte[]>? OnFrameReceived;
// ★★★ 核心变更:使用强类型契约载体 ★★★
public event Action<VideoPayload>? OnPayloadReceived;
private SubscriberSocket? _subSocket;
private Task? _receiveTask;
// 【修复1】不要在这里初始化改为在 Start 中初始化
private CancellationTokenSource? _cts;
public int ListenPort { get; private set; }
// 增加运行状态标记
// 运行状态检查
public bool IsRunning => _receiveTask != null && !_receiveTask.IsCompleted;
private StreamReceiverService() { }
public void Start(int port = 6000)
{
// 1. 防止重复启动
if (IsRunning) return;
ListenPort = port;
// 【修复1】每次启动时创建新的 TokenSource
_cts = new CancellationTokenSource();
try
{
// 2. 初始化 Socket
_subSocket = new SubscriberSocket();
// 【优化】设置高水位限制 (HWM)
// 如果 UI 处理不过来,积压超过 1000 帧直接丢弃,防止内存爆炸
// 设置高水位,防止 UI 卡顿时内存溢出
_subSocket.Options.ReceiveHighWatermark = 1000;
_subSocket.Bind($"tcp://*:{ListenPort}");
_subSocket.Subscribe(""); // 订阅所有内容(这是 Dealer-Router/Pub-Sub 的基础)
string bindAddr = $"tcp://*:{ListenPort}";
_subSocket.Bind(bindAddr);
_subSocket.Subscribe("");
Console.WriteLine($"[Dashboard] 视频流接收服务启动: {bindAddr}");
Console.WriteLine($"[Dashboard] 视频流接收服务启动: tcp://*:{ListenPort}");
}
catch (Exception ex)
{
Console.WriteLine($"[Dashboard] 致命错误 - 端口绑定失败: {ex.Message}");
// 清理资源
// 明确抛出异常,让 App.xaml.cs 知道启动失败了
_subSocket?.Dispose();
_subSocket = null;
// 【修复4】抛出异常让上层知道启动失败了
throw new Exception($"端口 {port} 绑定失败,可能被占用。", ex);
}
// 3. 启动任务
_receiveTask = Task.Run(ReceiveLoop, _cts.Token);
}
@@ -73,61 +60,55 @@ public class StreamReceiverService : IDisposable
{
try
{
// 【修复2】线程安全检查
if (_subSocket == null) break;
// 接收 Topic
if (!_subSocket.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out string cameraId))
// =========================================================
// 核心解析逻辑:适配 Service 端的 4 帧复合协议
// =========================================================
NetMQMessage msg = new NetMQMessage();
// 1. 非阻塞接收多帧消息
if (!_subSocket.TryReceiveMultipartMessage(TimeSpan.FromMilliseconds(500), ref msg))
continue;
// 接收 Payload
if (!_subSocket.TryReceiveFrameBytes(TimeSpan.FromMilliseconds(100), out byte[] jpgBytes))
continue;
// 2. 协议完整性检查
if (msg.FrameCount < 4) continue;
// 触发事件
OnFrameReceived?.Invoke(cameraId, jpgBytes);
}
catch (ObjectDisposedException)
{
// 【修复2】这是正常的退出流程Socket被Dispose了优雅退出循环
break;
// 3. 协议头校验 (Frame 0)
if (msg[0].ConvertToString() != "SHH_V1") continue;
// 4. 反序列化元数据 (Frame 1)
string json = msg[1].ConvertToString();
var payload = JsonConvert.DeserializeObject<VideoPayload>(json);
if (payload == null) continue;
// 5. 填充二进制图像数据 (Frame 2 & 3)
// 注意NetMQ 的 msg 数据是非托管内存,转为 byte[] 实现了拷贝,安全供 UI 使用
if (payload.HasOriginalImage)
payload.OriginalImageBytes = msg[2].ToByteArray();
if (payload.HasTargetImage)
payload.TargetImageBytes = msg[3].ToByteArray();
// 6. 触发事件
OnPayloadReceived?.Invoke(payload);
}
catch (Exception ex)
{
// 记录日志,但不崩溃
Debug.WriteLine($"[ReceiverLoop Error] {ex.Message}");
Debug.WriteLine($"[Receiver Error] {ex.Message}");
}
}
Console.WriteLine("[Dashboard] 接收循环已停止");
}
/// <summary>
/// 停止服务(支持停止后重新 Start
/// </summary>
public void Stop()
{
// 1. 发出取消信号
if (_cts != null && !_cts.IsCancellationRequested)
{
_cts.Cancel();
}
// 2. 销毁 Socket (这会触发 ReceiveLoop 中的 ObjectDisposedException 从而退出循环)
if (_subSocket != null)
{
try { _subSocket.Dispose(); } catch { }
_subSocket = null;
}
// 3. 清理 Token
_cts?.Dispose();
_cts = null;
_cts?.Cancel();
try { _subSocket?.Dispose(); } catch { }
_subSocket = null;
_receiveTask = null;
}
public void Dispose()
{
Stop();
}
public void Dispose() => Stop();
}

View File

@@ -1,6 +1,6 @@
using System.Windows;
using System.Windows.Media.Imaging;
using SHH.CameraDashboard.Services; // 引用服务命名空间
using SHH.Contracts; // ★★★ 引用契约库 (VideoPayload) ★★★
namespace SHH.CameraDashboard;
@@ -8,7 +8,7 @@ public class VideoTileViewModel : ViewModelBase
{
private readonly string _boundCameraId;
// --- 属性定义 ---
// --- 属性定义 (保持不变) ---
private string _cameraName;
public string CameraName
{
@@ -37,38 +37,59 @@ public class VideoTileViewModel : ViewModelBase
CameraName = name;
StatusInfo = "等待信号...";
// 【修正 1】直接订阅单例服务
// 不需要判断 null因为 Instance 是静态初始化的,永远存在
StreamReceiverService.Instance.OnFrameReceived += OnGlobalFrameReceived;
// ★★★ 变更 1: 订阅新的 OnPayloadReceived 事件 ★★★
// 旧的 OnFrameReceived(string, byte[]) 已经无法满足需求
StreamReceiverService.Instance.OnPayloadReceived += OnPayloadReceived;
}
// --- 事件回调 (后台线程) ---
private void OnGlobalFrameReceived(string cameraId, byte[] jpgData)
// ★★★ 变更 2: 参数变为 VideoPayload 实体对象 ★★★
private void OnPayloadReceived(VideoPayload payload)
{
// 1. 过滤:不是我的画面,直接忽略
if (cameraId != _boundCameraId) return;
// 1. 过滤:校验 Payload 中的 CameraId
if (payload.CameraId != _boundCameraId) return;
// 2. 解码:耗时操作在后台完成
var bitmap = BitmapHelper.ToBitmapImage(jpgData);
// 2. ★★★ 智能选图策略 ★★★
// 优先显示 AI 处理后的图 (TargetImageBytes)
// 如果没有处理图,则降级显示原始图 (OriginalImageBytes)
byte[] dataToShow = null;
if (payload.HasTargetImage && payload.TargetImageBytes != null)
{
dataToShow = payload.TargetImageBytes;
}
else if (payload.HasOriginalImage && payload.OriginalImageBytes != null)
{
dataToShow = payload.OriginalImageBytes;
}
// 如果两张图都没有,直接返回
if (dataToShow == null || dataToShow.Length == 0) return;
// 3. 解码图片 (耗时操作在后台完成)
var bitmap = BitmapHelper.ToBitmapImage(dataToShow);
if (bitmap == null) return;
// 3. 【修正 2】恢复 UI 更新逻辑
// 必须使用 Dispatcher因为 VideoSource 绑定在界面上,只能在主线程修改
// 4. ★★★ 计算端到端延迟 ★★★
// 当前时间(接收端) - 采集时间(发送端) = 真实的网络+处理延迟
long latency = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - payload.CaptureTimestamp;
// 5. UI 更新
Application.Current.Dispatcher.InvokeAsync(() =>
{
VideoSource = bitmap;
// 更新状态信息 (例如显示当前时间和数据大小)
StatusInfo = $"{DateTime.Now:HH:mm:ss} | {jpgData.Length / 1024} KB";
// 显示更丰富的信息:延迟毫秒数、数据量、当前时间
// 工业监控中,"延迟(ms)" 是比 "当前时间" 更重要的指标
StatusInfo = $"延迟: {latency}ms | {dataToShow.Length / 1024} KB | {DateTime.Now:HH:mm:ss}";
});
}
// --- 资源清理 ---
public void Unload()
{
// 【修正 3】从单例服务取消订阅
// 这一步至关重要,否则切换页面时会内存泄漏
StreamReceiverService.Instance.OnFrameReceived -= OnGlobalFrameReceived;
// ★★★ 变更 3: 取消订阅新的事件 ★★★
StreamReceiverService.Instance.OnPayloadReceived -= OnPayloadReceived;
// 清空图片引用,帮助 GC 回收内存
VideoSource = null;

View File

@@ -1,14 +1,10 @@
using SHH.Contracts;
using System.Collections.ObjectModel;
using System.Collections.ObjectModel;
using System.Windows.Input;
namespace SHH.CameraDashboard
{
public class VideoWallViewModel : ViewModelBase
{
// 引用推流接收服务
private readonly VideoPushServer _pushServer;
// 视频列表
public ObservableCollection<VideoTileViewModel> VideoTiles { get; } = new ObservableCollection<VideoTileViewModel>();
@@ -27,35 +23,11 @@ namespace SHH.CameraDashboard
{
SetLayoutCommand = new RelayCommand<string>(ExecuteSetLayout);
// 1. 初始化并启动接收服务
_pushServer = new VideoPushServer();
_pushServer.OnFrameReceived += OnGlobalFrameReceived;
// 2. 启动监听端口 (比如 6000)
// 之后你的采集端 ForwarderClient 需要 Connect("tcp://你的IP:6000")
_pushServer.Start(6000);
// 3. 初始化格子 (不再需要传入 IP/Port 去主动连接了)
// 我们用 CameraId 或 Name 来作为匹配标识
InitVideoTiles();
}
/// <summary>
/// 全局接收回调:收到任何一路视频都会进这里
/// </summary>
private void OnGlobalFrameReceived(VideoPayload payload)
{
// 1. 在 VideoTiles 集合中找到对应的格子
// 假设 payload.CameraId 与我们 VideoTileViewModel 中的 ID 对应
//var targetTile = VideoTiles.FirstOrDefault(t => t.id == payload.CameraId);
//if (targetTile != null)
//{
// // 2. 将数据交给格子去渲染
// targetTile.UpdateFrame(payload);
//}
}
private void InitVideoTiles()
{
// 假设我们预设 4 个格子,分别对应不同的摄像头 ID