news 2026/9/18 15:46:22

Serial Studio 脚本解析器类型化单元格通道(Spec 0086)设计与实现解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Serial Studio 脚本解析器类型化单元格通道(Spec 0086)设计与实现解析

Serial Studio 脚本解析器类型化单元格通道(Spec 0086)设计与实现解析

【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio

导读

本文围绕 Serial Studio 仓库中 0086-script-parser-typed-results/plan.md 这一技术设计文档,深入讲解其核心方案:在脚本解析器(Lua / JavaScript)与帧构建器(FrameBuilder)之间引入一条类型化单元格通道(cell lane),让解析结果以"数字 + 文本字节视图"的单元格形式直达数据流水线,从而消除每帧"数字→文本→再解析回数字"的往返开销,并使数据集表格捕获(table capture)只在脚本真正引用表格 API 时才开启。读完本文,你将理解 Serial Studio 热路径(hotpath)上的这一关键优化:数据结构设计、两条引擎收集器、数字文本格式化规则、捕获作用域判定、表存储零分配写入,以及对应的测试与验证体系。

背景:为什么需要一条新的解析通道

Serial Studio 的脚本解析器允许用户用 Lua 或 JavaScript 编写解析脚本,将一帧原始数据转换为多个数据集(dataset)值。在引入本方案之前,解析结果通过QList<QStringList>传递:脚本返回的数字先被格式化成字符串(Lua 走luaValueToString,JS 走QJSValue::toString()),随后帧构建器再对这些文本做数值探测(numeric detection)并解析回double

这条路径在功能上正确,但在热路径上存在明显浪费:

  • 数字被"格式化→再解析",每帧产生两次不必要的转换;
  • QList<QStringList>与逐字符串复制带来堆分配;
  • 数据集表格捕获(把 dataset 值写入DataTable供表格视图/API 读取)在"存在 Lua 解析引擎"时无条件开启,即使脚本根本没有引用表格 API,也会为每帧付出写入代价;
  • 表格存储使用 share-assign 写字符串,既多一次分配,又会让dataset.value的缓冲区被钉住(pinning),下一次原地写入被迫重新分配。

Spec 0086 正是针对这些问题提出的四阶段计划中的第二阶段(the HOW),其完整需求与验收标准记录在同目录的 spec.md 中。

总体架构:span 车道、cell 车道与列表回退

新方案在原有两条路径之间插入第三条解析通道,FrameBuilder::parseProjectFrameFor的执行顺序变为:

parseProjectFrameFor trySpanLane ........... Native/PlainText,保持不变 tryCellLane ........... PlainText 解码器 && parser.parseCellsUtf8(bytes, sourceId, m_cellRows) for each row (rowStarts): captureLatestChannelSpans (views,与 span 车道相同) [m_captureLatestFrame] applyDatasetValuesCells(frame, cells, count, info) m_stager.stage(sourceId, frame, ts + step * row) list path ............. decodeProjectChannels → applyDatasetValues(不变的回退)

对应源码中的实际实现位于 core/Pipeline/DataModel/FrameBuilder.cpp:

  • tryCellLane(约 L1799)先检查播放器未打开、帧含分组,且解码器为PlainText;随后调用FrameParser::parseCellsUtf8(约 L497)。只有 PlainText 解码器进入 cell 车道,Binary/Hex/Base64 脚本继续走列表路径,作为后续明确跟进项;
  • 对每一行,按data->timestamp + step * row打时间戳(与列表路径完全一致),依次做 latest-frame 捕获、applyDatasetValuesCells写数据集、m_stager.stage入队;
  • 若引擎返回false(混合形状、JS 非数组结果等),则回退到原有的decodeProjectChannels → applyDatasetValues列表路径,行为与旧版本一致。

IScriptEngine接口(core/Pipeline/DataModel/Scripting/IScriptEngine.h)新增了两个虚方法:

// 类型化单元格通道(spec 0086):true = 已填充 rows;false = 列表结果留在 fallback 中 [[nodiscard]] virtual bool parseUtf8Cells(const QByteArray& frame, ScriptCellRows& rows, QList<QStringList>& fallback) { Q_UNUSED(rows) fallback = parseUtf8(frame); return false; } // 已加载脚本是否命名了表格 API 辅助函数(spec 0086):用于开启按数据集捕获 [[nodiscard]] virtual bool referencesTableApi() const noexcept { return false; }

默认实现直接回退到列表路径,因此不支持 cell 车道的引擎(如 Native 解析器)无需任何改动。值得注意的实现细节是:parseUtf8Cells在失败时会把列表结果写回fallback,这样引擎不会在拒绝后把脚本跑两遍

数据结构:ScriptCell 与可复用的 ScriptCellRows

cell 车道的数据结构定义在 core/Pipeline/DataModel/Scripting/ScriptCells.h:

enum class CellKind : quint8 { Text, Number, }; struct ScriptCell { qsizetype offset; // 在 scratch 中的字节偏移 qsizetype length; // 字节长度 double number; // 数字单元格的数值 CellKind kind; // 类型 }; class ScriptCellRows { public: static constexpr qsizetype kMaxCellsPerResult = 10000; static constexpr qsizetype kNumberTextCapacity = 32; static constexpr qsizetype kBytesPerCellGuess = 16; // ... void clear() noexcept; void reserve(qsizetype cells, qsizetype bytes); void beginRow(); void appendText(const char* bytes, qsizetype length); void appendUtf16(QStringView text); void appendNumber(double value, const char* text, qsizetype length); };

几个关键设计点(对应需求 R1 / R2):

  • 单元格是"偏移 + 长度"而非视图指针ScriptCell保存offset/length指向所属ScriptCellRowsQByteArray m_scratch。这样即使某一帧更宽导致 scratch 重新分配,旧单元格也不会失效;
  • 引擎归属 + 跨帧复用ScriptCellRowsFrameBuilder持有(一个 builder 一个,成员m_cellRows),引擎向其中写入;clear()resize(0)不释放缓冲区(ScriptCells.cpp),因此稳定的帧形状在稳态下零堆分配(R2)。初始容量为 64 个单元格 / 1024 字节,首次见到更宽的帧时一次性扩容;
  • 防钉住clear()在容量超过高水位线(单元格 2^18、字节 16 MiB)时释放回初始容量,避免单个病态结果把内存钉住整个会话;
  • 元素上限kMaxCellsPerResult = 10000,沿用既有元素上限,不改变每帧/每结果的数量约束。

引擎写入时的规则:Lua 字符串只在 Lua 栈上存活到弹出为止,因此必须复制进 scratch(memcpy到预留字节);JS 文本则以 UTF-16 直接编码进 scratch 尾部(appendUtf16,用QStringEncoder预留最坏情况空间再裁剪,避免临时QByteArray)。

两条引擎收集器:LuaCellCollector 与 JsCellCollector

计划文档将"在引擎内部收集单元格"定义为两处实现,而实际落地(见"Implementation deviations"一节)改为两个与引擎状态无关的收集器类,这样单元测试可以直接驱动一个裸lua_State/QJSEngine,无需链接整个流水线:

  • core/Pipeline/DataModel/Scripting/LuaCellCollector.h:LuaCellCollector::collect(lua_State*, ScriptCellRows&, maxElements)。标量或扁平 table → 一行;table 的 table(二维表)→ 每个内层 table 一行;混合形状返回false走列表路径;
  • core/Pipeline/DataModel/Scripting/JsCellCollector.h:JsCellCollector::collect(const QJSValue&, ScriptCellRows&, maxElements)。扁平数组 → 一行;二维数组 → 每内层数组一行;JS 中true/null等非数字非字符串值通过toString()作为文本单元格处理(与今天"true"/"null"的字符串化结果保持一致);非数组或混合结果返回false

两个收集器都只遍历结果一次:首个元素决定扁平还是嵌套,不匹配时在同一次遍历中直接拒绝,避免二次扫描。

Lua 引擎侧的接线在 core/Pipeline/DataModel/Scripting/LuaScriptEngine.cpp:parseUtf8Cells(约 L821)运行parseLuaText的 pcall 后调用收集器,m_referencesTableApiloadScript中通过ScriptApiCall::referencesTableApi(script)设置(约 L595)。

数字文本格式化规则(R5):与旧路径逐字节一致

计划中最重要的兼容性约束是 R5:当一个数字单元格必须变成显示文本时(仪表盘值、API 帧、导出),文本必须与今天产生的完全一致。两种引擎各有一套规则(见 plan 的 "Formatting rules (R5)" 一节),均已在 ScriptCells.cpp 中实现为formatLuaNumberformatJsNumber

Lua 规则(formatLuaNumber

  • 整数值 →QString::number(lua_tointeger)%lld
  • 其他 →QString::number(v, 'g', 15)即 C locale 下的%.15g
  • 非有限值沿用 Qt 拼写:nan(无符号)、inf-inf
  • 常规路径用std::to_chars(out, out + capacity, value, std::chars_format::general, 15)复现%.15g(Apple 旧平台回退到snprintf_l)。

注意整数检测复用 LuaJIT 兼容层的lua_isintegershim(LuaJIT 用 double 表示整数),保证整数值不带小数部分输出。

JavaScript 规则(formatJsNumber

目标是精确复现 ECMAScriptNumber::toString(即QJSValue::toString()对数字的输出):

  • std::to_chars取**最短往返(shortest round-trip)**数字;
  • 布局规则按 ECMA-262 6.1.6.1.20:1e-6 ≤ |x| < 1e21用十进制形式(0.000001而不是1e-06),范围外用指数形式(1e+211e-7);
  • 特殊值:NaNInfinity-Infinity-0 → "0"

实现里对应常量kJsFixedUpperExponent = 21kJsFixedLowerExponent = -6,指数形式写作d[.ddd]e[+-]N且无零填充。选择这套方案的原因记录在 plan 的 Tradeoffs 表中:QJSValue::toString()本身就是被移除的分配点;而 Qt 的FloatingPointShortest在指数边界(如0.00001vs1e-05)与 JS 不一致,会破坏 R5。formatJsNumber还包含一个针对 Apple 13.3 之前平台的零分配回退实现(snprintf_l精度搜索 +strtod_l往返校验)。

格式化产生的文本写入 scratch,因此数字单元格同时携带double值与显示文本,单元格携带的文本与旧QStringList完全相同,而帧构建器的 writer 直接从单元格取数字,不再把文本解析回去。

捕获作用域(R6 / R7):只在被引用时开启表格捕获

这是本计划中用户可见规则变化最大的部分。表格捕获(m_captureDatasetValues)的输入从"存在 Lua 解析引擎"变为"某个引擎的源码引用了表格 API 名称",判定方式是在loadScript/ 变换编译时做编译期词扫描,由头文件实现 core/Pipeline/DataModel/Scripting/TableApiScan.h:

static const QRegularExpression s_tableApiName( QStringLiteral("\\b(?:tableGet|tableSet|tableHandle|tableHandleMany|tableGetH|tableSetH|" "datasetGetRaw|datasetGetFinal|__ss)\\b")); return !source.isEmpty() && s_tableApiName.match(source).hasMatch();

对九个名称(八个辅助函数 + JS 桥接对象__ss)做词边界匹配。它是刻意保守的:注释或字符串字面量里的名称也会开启捕获(每帧多付出几个百分点),而备选的"运行时首次触达标志"会丢掉第一帧的值。

各输入源的变化(plan 的 Capture scoping 表):

输入之前之后
解析引擎m_hasLuaEngine(存在任何 Lua 解析器)任何引擎源码引用表格 API 名称(FrameParser::refreshEngineCaches,epoch 照常递增)
变换引擎hasScriptEngines()任何变换(或共享库)源码引用表格 API 名称
流变换注入即开启,从不关闭被引用才开启,worker 拆除时关闭
外部用户粘性bool(任何injectTableApi*设置,项目加载时清除)int计数:控制脚本启停、API 服务器启停、表格视图开关、发送环境

外部用户侧,FrameBuilderm_externalTableUsers计数(core/Pipeline/DataModel/FrameBuilder.cpp),配合armExternalTableUser()/disarmExternalTableUser()(约 L2832 / L2888)与TableApiUserLeaseRAII 封装。选择计数而非布尔的原因记录在 Tradeoffs 表:R7 要求"关闭一个用户后停止捕获",布尔无法表达两个用户同时存在。实际落地的命名是injectTableApi*(++)/releaseTableApiUser()(--)。

新鲜度保证(R7):epoch 与计数都汇入既有的m_captureFlagsDirty → refreshDatasetCaptureFlag()重新推导路径,在 builder 线程上执行(遵守 spec-0051 的双线程刷新规则:刷新槽是 pipeline-affine 的,由 GUI 发射器排队),因此捕获标志永远不会过期——脚本编辑使引用开始/停止后,下一帧即生效。GUI 侧用户的 arm/disarm 通过injectTableApi*已使用的invokeOnBuilderThreadBlocking封送执行,不引入任何新的跨线程信号/槽

表存储写入(R8):原地赋值与槽位缓存

当捕获开启时,把 dataset 值写入表格必须零分配(R8)。计划针对DataTable的两处改动:

  1. setDatasetRawAt/setDatasetFinalAt:用assign_string_in_place(rv.stringValue, str)替代 share-assign。这同时解决两个问题:移除 profile 中可见的分配;并且不再把dataset.value的缓冲区钉住——这正是 doc/claude/common-mistakes.md 中"share-assign 重新链接缓冲区"的反模式(下一次原地写会先 detach 再重新分配)。原地写之前仍保留等值检查,因此依赖"值未变则不写"的 change-driven 变换跳过(spec-0083 时代)语义不变;
  2. 槽位缓存:每 dataset 的槽位对缓存在FrameBuilder::m_datasetTableSlots(按 dataset 序号索引,initializeTableStore时重建),替换每帧每 dataset 两次QHash::constFind。实际落地按 review 修正简化为"每帧每 dataset 一次datasetSlots()查找"。

帧级 writer 的共享尾部是applyDatasetToken(评审修正后不再是策略模板),applyDatasetValueCell(约 L2188)与 span 车道的applyDatasetValueSpan共享从原始值复制、捕获、变换、表达式发布到最终捕获的全部后续逻辑。数值取用规则:numericValue = cell.kind == Number ? cell.number : SerialStudio::toDouble(cell.text, &isNumeric),数字单元格的isNumeric恒为true

热路径与线程影响

计划明确标注了该改动触及热路径,并列出必须遵守的规则:

  • 一切在pipeline 线程执行;引擎只在该线程使用;ScriptCellRows是 builder 成员,永不跨线程
  • 稳态下 cell 车道对纯数字结果(Lua)零分配;JS 保留每单元格QJSValue临时值(见下);列表路径原样保留为回退;
  • dataset.value保持assign_utf8_in_place;存储端字符串复制改为原地,移除拖累 span 车道的 share-assign;
  • 路由 lambda 内不允许出现lua_*QJSValue调用:扫描在loadScript于引擎自身线程执行,单元格收集在既有 parse 调用内部执行;
  • structureGeneration戳记不变(cell 车道与列表路径一样经由m_stager.stage入队);
  • 时间戳归属不变:行以data->timestamp + step * row打戳。

FrameParser侧新增parseCellsUtf8镜像parseMultiFrameUtf8的引擎 0 缓存路径,且Native 引擎在不运行的情况下直接拒绝,因此被拒的 Native 帧只需走一次列表路径,而不是三次(review 修正)。

基准计划(--benchmark-hotpath前后对比):Lua 数值、JS 数值、Lua 混合、JS 混合的 FPS;spec-0084 的每帧分配列(Lua 数值目标 0,JS 记录其下限)。

权衡与替代方案(Tradeoffs 摘要)

计划用一张决策表记录了每个关键取舍:

决策备选选择及理由
结果表示(a) 字节视图单元格;(b)QVariantList;(c)std::vector<std::variant<double, QString>>(a):复用 span writer 及其原地 UTF-8 赋值;稳态零分配;文本单元格正是 Native 车道已消费的形式。(b)/(c) 每单元格或每字符串分配
数字显示文本惰性按需 vs 急切写入 scratch急切:保持 spec 0055 D6 契约不变,使 R5 成为纯格式化等价性测试;惰性属于未来的 display-text 规范
JS 数字文本(a)QJSValue::toString();(b) QtFloatingPointShortest;(c) ECMAScript 兼容格式化器(c):a 正是被移除的分配;b 在指数边界与 JS 不一致(0.00001vs1e-05),破坏 R5
JS 每单元格临时值(a) 接受并单独记录 JS 下限;(b) 用 typed array 打包一次读回(a)QJSEngine无零拷贝 typed-array 读取,b 仍要经过QJSValue;R2 对 Lua 满足,JS 的收益是移除格式化/解析往返
表格 API 引用检测编译期词扫描 vs 运行时首触标志扫描:保守、无首帧缺口,且与发送环境区分ArmCapture/NamesOnly的方式一致
外部用户粘性布尔(现状)vs 带 disarm 的计数计数:R7 要求关闭用户即停止捕获,布尔无法表达两个用户
存储字符串写share-assign(现状)vsassign_string_in_place原地:移除分配与缓冲区钉住;等值 no-op 检查不变
cell 车道解码器覆盖仅 PlainText vs 全部解码器PlainText 先行:门控层级是 PlainText;Binary/Hex/Base64 脚本保持列表路径,作为命名后续项

风险与缓解

  • 格式化漂移:Lua 与 JS 数字文本必须与现状逐字节一致,否则导出与仪表盘会无声变化。对策是两套语料测试(Lua:同一测试内用QString::number对照;JS:Node 生成的 fixture);
  • QJSValue::isNumber与旧toString的差异:JS 的true/null今天会字符串化为"true"/"null",cell 车道通过把非数字非字符串值按toString()处理保持相同行为;
  • 扫描漏报:通过local g = tableGet别名访问仍含名称;用_G字符串拼接构建的脚本检测不到(已在 transform_lua.md / transform_js.md 中说明),运行时桥接在首次调用时也会开启捕获作为安全网(仅病态场景有一帧缺口);
  • 漏掉 disarm 点:后果只是捕获保持开启(即现状行为),绝不会在需要时关闭;任务阶段逐一枚举每个injectTableApi*/noteGuiUser调用点配对;
  • 共享的可静默破坏类别common-mistakes.md):缓存标志输入缺刷新线路(两个输入都已接线并由 AC5/AC6 测试)、span 车道上的 share-assign(正在移除而非新增)、新模板产生的QList<QStringList>(cell 车道即替代品,列表路径仅作回退)、JS 的guardedCall(不变)。

评审阶段(qt-cpp-review)还修复了一批具体缺陷:applyProjectSnapshot不再清零m_externalTableUsers(租约应活得比一次库编辑更久);阻塞式 arm 在 worker 循环被QThread::quit()展开时会被runOnObjectThread跳过,导致后续 release 击穿零值触发 debug 断言——因此两个 arm 与 release 都改为排队投递;splitScientific对指数用有界解析避免越界读;ScriptCellRows::clear()释放高水位以上的容量;~Output::Base仅在 surface 已准备(m_sourceId >= 0)时释放租约。

测试与验证体系

计划的验证分为四层,测试文件均已落在仓库中:

  • 单元测试(ctest):app/tests/tst_script_cells.cpp 直接驱动裸lua_State/QJSEngine上的收集器(链接集只含 LuaJIT 与QJSEngine,不拉入整个流水线),覆盖:
    • AC3:Lua/JS 混合行{1, 2.5, "x", "7"}的每单元格kindnumbertext与列表路径产物相等,且与等价分隔文本喂给 Native span 车道的数值探测一致;
    • AC4:二维结果的行数、顺序、值等于列表路径;混合标量/向量结果回退(parseUtf8Cells返回false);
    • R2:Lua 数值结果连续解析 1000 次,用 TU 局部operator new计数器在首次解析后武装,断言零分配(分配探测还检查 scratch、单元格数组与存储注册缓冲区的地址稳定性,因为 Qt 容器与 LuaJIT 在operator new之外分配);
    • R5:formatJsNumber对照 tests/fixtures/js-number-format.json 语料;Lua 格式化用例在同测试内对照QString::number
  • 语料生成:tests/scripts/gen_js_number_corpus.py(Node 的String(x)对边界值与随机 double 生成)与 tests/scripts/test_js_number_corpus.py(pytest 断言提交的 fixture 与生成器输出一致);
  • 集成测试(API 服务器运行中)test_table_capture_scoping.py覆盖 AC5/AC6——仅解析器的项目在 10 秒内datatables写入时钟保持初值;加入调用tableGet的控制脚本后首帧即开始推进;变换中增删tableGet在一帧内反映/停止。实际可观察点是project.dataTable.getValue__datasets__/raw:<id>的读取(非武装读者),而非不存在的写时钟 verb(实现偏差一节);
  • 静态与维护工具python scripts/code-verify.py --check覆盖所有改动文件(热路径 TU 阻塞违规)、qt-cpp-review审引擎与FrameBuilder.cpp/DataTable.cpp改动块、提交前sanitize-commit.py、文档编辑后claim-verify.py。AC1 由--benchmark-hotpath前后对比把关,AC7 由导出保真与回放集成测试把关。

结语

Spec 0086 是 Serial Studio 脚本解析热路径的一次系统性优化:类型化单元格(R1)、稳态零分配(R2)、文本等价(R3/R5)、多帧结果兼容(R4)、按引用开启的表格捕获(R6/R7)与零分配捕获写入(R8)八项需求,通过一条位于 span 车道与列表回退之间的新通道落地。从设计文档到 ScriptCells.h、TableApiScan.h、FrameBuilder.cpp 等实现,再到 tst_script_cells.cpp 与集成测试,方案的每一处取舍都有源码级证据可循。对希望深入 Serial Studio 数据流水线或在其上扩展解析器的开发者而言,这条 cell 车道是理解项目"性能与正确性并重"工程风格的最佳入口之一。

【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 15:44:03

C++ shared_ptr 智能指针:引用计数、循环引用与线程安全实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 15:42:56

机房温湿度采集协议选型白皮书:TCP、UDP、SNMP如何抉择?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 15:42:27

【ComfyUI】Wan ATI Trajectory 控制视频生成

今天演示的案例是一个基于 Wan2.1 ComfyUI 的视频生成工作流。该流程以静态图像为起点,通过文本提示与轨迹控制,将角色镜头转化为动态视频。 工作流整体结合了扩散模型、文本编码器、视觉编码器和轨迹控制节点,配合解码与采样环节,使静态的画面呈现出自然流畅的运动感。这…

作者头像 李华
网站建设 2026/9/18 15:42:12

软件工程术语库:从语义漂移到可执行契约的工程化实践

1. 这不是词典&#xff0c;而是一套可落地的工程语言操作系统“软件工程术语库系统与工程化篇”——这个名字听起来像教科书附录&#xff0c;但实际用起来&#xff0c;它根本不是查词用的静态文档。我带过6个不同规模的交付团队&#xff0c;从20人初创SaaS产品线到300人金融中台…

作者头像 李华
网站建设 2026/9/18 15:41:01

Vue3接入大华摄像头的正确路径:RTSP转HTTP流实战指南

1. 为什么Vue3项目里接入大华摄像头不是“加个组件”那么简单你刚接手一个安防监控类后台系统&#xff0c;需求文档写着“前端页面嵌入大华摄像头实时画面”&#xff0c;心里想着&#xff1a;“不就是<video>标签RTSP地址&#xff1f;Vue3里用ref绑个src&#xff0c;再加…

作者头像 李华