From f2935085990c71df5810487f9d3dae0fc5f46cef Mon Sep 17 00:00:00 2001 From: pingan Date: Mon, 10 Aug 2026 20:56:50 +0800 Subject: [PATCH 1/2] Add OBS RTMP interoperability gate --- docker/README.md | 17 +- docs/handoff.md | 29 +-- docs/reports/interop.md | 13 +- docs/reports/rtmp.md | 31 +++- docs/roadmap.md | 6 +- docs/support-matrix.md | 4 +- tools/interop.ps1 | 386 ++++++++++++++++++++++++++++++++++++++-- tools/obs.py | 143 +++++++++++++-- 8 files changed, 571 insertions(+), 58 deletions(-) diff --git a/docker/README.md b/docker/README.md index f6807fe..1a8686c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -122,6 +122,21 @@ pwsh ./tools/interop.ps1 ` [OBS 官方 SRT 指南](https://obsproject.com/kb/srt-protocol-streaming-guide)。测试 OBS 的 WebSocket 鉴权只在未暴露端口的临时容器内关闭,不是生产配置示例。 +OBS 的 RTMP 发布和接收可以单独运行。脚本临时启动固定 digest 的 MediaMTX:发布侧 +要求 ffprobe 读到 H.264/AAC、首帧 keyframe 与单调时间戳;接收侧由 OBS Media Source +读取 FLV 并截图。截图门槛会解码 PNG、还原行过滤器并检查像素颜色多样性,不以文件 +大小猜测是否渲染成功: + +```powershell +pwsh ./tools/interop.ps1 ` + -EngineImage aimedia:gpu ` + -PeerImage aimedia:test-tools ` + -DesktopImage aimedia:desktop-tools ` + -Suite rtmp-obs ` + -DurationSeconds 20 ` + -SkipToolBuild +``` + VLC 的 SRT input module 位于 Ubuntu 的 `vlc-plugin-access-extra`,不能只根据 `vlc --version` 判断 SRT 可用。脚本先检查插件文件,再通过 raw dump 保存 aimedia 原始 TS;这样 ffprobe 检查的是引擎时间戳,而不是 VLC 重新 mux 后的时间戳。 @@ -187,4 +202,4 @@ pwsh ./tools/rtmp.ps1 ` 报告验证 H.264/AAC profile、两段媒体的逐包时间戳、输出恢复后的首个 keyframe、节目 时钟连续性、输入/输出重连计数、p95 延迟、RSS、GPU surface、队列水位和运行镜像依赖。 -短门禁不代替 OBS、真实 RTMPS 平台或两小时 soak。 +短门禁和 OBS 门禁都不代替真实 RTMPS 平台或两小时 soak。 diff --git a/docs/handoff.md b/docs/handoff.md index 9995706..e157cb5 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -1,10 +1,10 @@ # 暂停交接:v0.3 Normalize & Bridge -更新时间:2026-08-06 +更新时间:2026-08-10 -暂停点:V3-03F1 的 RTMP 长连接 ACK 修复与 420 秒故障回归完成后 +暂停点:V3-03F2 的 OBS RTMP 发布、接收和实际渲染门槛完成后 -Git 基线:`main` 的 `acc06d2`,交付分支 `codex/feat/rtmp-soak` +Git 基线:`main` 的 `6b86a7b`,交付分支 `codex/feat/rtmp-obs` ## 先读结论 @@ -26,7 +26,8 @@ Git 基线:`main` 的 `acc06d2`,交付分支 `codex/feat/rtmp-soak` | RTMP/FLV 契约、会话、AVC/AAC 转换、listener、RTMP/RTMPS publisher | 已合并 | PR #32—#36 | | RTMP FFmpeg -> aimedia -> MediaMTX 180/420 秒故障门禁 | 本交付 | PR #38;`docs/reports/rtmp.md` | | RTMP publisher ACK 竞态修复 | 本交付 | PR #38;`anvsk/rtmp-rs@00e97a6` | -| RTMP OBS、真实平台、完整两小时 soak | 未完成 | 保持 `experimental` | +| OBS RTMP publisher/consumer 与实际渲染 | 本交付 | `tools/interop.ps1 -Suite rtmp-obs`;`docs/reports/rtmp.md` | +| RTMP 真实平台、完整两小时 soak | 未完成 | 保持 `experimental` | | v0.4 多输出与 AI Tap | 未开始 | 暂停期间不要开发 | V3-03F 短门禁在 RTX 5060 Laptop + 577.12 上运行 180 秒。修复 ACK 竞态后又运行 @@ -34,6 +35,12 @@ V3-03F 短门禁在 RTX 5060 Laptop + 577.12 上运行 180 秒。修复 ACK 竞 p95 141ms,音视频 PTS 单调,恢复片段首视频包为 keyframe,队列最高 1/1、surface 最高 3/4,运行镜像无 FFmpeg/libav。 +V3-03F2 使用 OBS 30.0.2.1 完成两条 20 秒独立链路。OBS publisher 经 aimedia GPU +转码和 MediaMTX 后,ffprobe 读到 581 个 H.264 视频包与 948 个 AAC 音频包,首视频包 +为 keyframe,PTS/DTS 单调。反向链路由 OBS Media Source 实际渲染 1280x720 彩条, +PNG 解码后检测到 13 种颜色;OBS、aimedia 均正常退出。运行镜像再次确认没有 +FFmpeg/ffprobe 或 `libav*`。 + 两小时测试使用相同镜像启动,按用户要求在 2,911 秒主动停止,容器与网络已清理。停止 前输入/输出仍连接,重连 1/1,p95 72ms,RSS 约 229 MiB,隔离设备显存 180 MiB;因为 没有跑满且没有生成最终 `summary.json`,V3-03F4 必须保持未完成。 @@ -76,13 +83,11 @@ FFmpeg 和 MediaMTX 只属于测试端,不进入运行镜像。 按以下顺序关闭门槛: -1. 复用 `tools/obs.py` 的隔离 HOME 和 obs-websocket 方式,增加 OBS RTMP publisher 与 - consumer 门禁;必须检查实际渲染帧和 ffprobe,不只看连接状态。 -2. 用户提供测试直播账号或临时 stream key 后,验证至少两个 endpoint。建议一条国内 +1. 用户提供测试直播账号或临时 stream key 后,验证至少两个 endpoint。建议一条国内 通用 RTMP/RTMPS、一条海外 RTMPS;凭证只通过环境变量或挂载文件传入,日志不得输出。 -3. 如果上述互操作带来代码变更,先重新跑 420 秒 ACK 与故障回归;输入/输出重连必须 +2. 如果上述互操作带来代码变更,先重新跑 420 秒 ACK 与故障回归;输入/输出重连必须 仍严格等于计划次数。 -4. 用户允许继续长稳测试后,重新跑满 1080p30 两小时门禁并保存 summary/samples 到 +3. 用户允许继续长稳测试后,重新跑满 1080p30 两小时门禁并保存 summary/samples 到 GitHub Release。不要把本次 2,911 秒的中止运行拼接或折算为通过: ```powershell @@ -99,7 +104,7 @@ pwsh ./tools/rtmp.ps1 ` -SampleIntervalSeconds 30 ``` -5. 全部通过后才勾选 V3-03F、更新支持矩阵并考虑 v0.3 Release。 +4. 全部通过后才勾选 V3-03F、更新支持矩阵并考虑 v0.3 Release。 本轮新增的 ACK 修复已经完成,无需下次重新定位: @@ -183,5 +188,5 @@ pwsh ./tools/rtmp.ps1 -EngineImage aimedia:rtmp-next -DurationSeconds 420 ` ## 暂停原则 本交付合并后按用户要求停止开发,不创建 v0.4 分支,不继续后台测试,也不发布“RTMP -已 supported”的社区广告。下次恢复时依次完成 V3-03F2 OBS、V3-03F3 真实平台和 -V3-03F4 完整两小时长稳;优先补外部证据,不继续增加新协议或格式。 +已 supported”的社区广告。下次恢复时依次完成 V3-03F3 真实平台和 V3-03F4 完整 +两小时长稳;优先补外部证据,不继续增加新协议或格式。 diff --git a/docs/reports/interop.md b/docs/reports/interop.md index d4ff000..300edf6 100644 --- a/docs/reports/interop.md +++ b/docs/reports/interop.md @@ -65,14 +65,11 @@ Media Source 读取 aimedia SRT 并截取实际渲染帧。 | 角色 | 运行时间 | 结果 | |---|---:|---| | OBS 作为输入 | 8s | 输出 300 个视频包、469 个音频包;首视频包为 keyframe;PTS/DTS 单调 | -| OBS 作为输出 | 8s | SRT 输入/输出均连接;渲染出 1,416 字节彩条 PNG;aimedia 退出码 0 | +| OBS 作为输出 | 8s | SRT 输入/输出均连接;渲染出 1,424 字节彩条 PNG,解码后检测到 16 种颜色;aimedia 退出码 0 | -两条场景的 obs-websocket controller 和 aimedia 均正常退出。当前本机临时桌面工具 -镜像中的 OBS 在日志已经报告 `Number of memory leaks: 0` 后,于测试 teardown 阶段 -出现 segmentation fault,因此报告中的 `cleanShutdown=false`。这不改变媒体互操作 -结果,但也不把测试工具退出质量伪装为通过。仓库中的 `desktop` target 已改用固定 -Ubuntu 基础镜像,避免继承 FFmpeg 工具镜像的第二套 libav/libsrt;本机受镜像源下载 -速度影响,尚未完成该纯净 target 的全量重建。 +两条场景的 obs-websocket controller、OBS 和 aimedia 均正常退出。桌面工具镜像使用 +固定 Ubuntu 基础镜像,避免继承 FFmpeg 工具镜像的第二套 libav/libsrt。渲染门槛会 +解码 PNG 像素并检查颜色多样性,不再以压缩后文件大小推断画面是否有效。 ## VLC 输出 @@ -114,8 +111,6 @@ SRT 突发交付时替换仍然有效的帧,因此 V2-09 将它收紧为容量 - 延迟 output listener 的 NVDEC 205 已有失败对照、容量 1 背压和零丢帧修复回归; V2-09 的两小时 1080p30 soak、延迟 p95 和 RSS/GPU 内存趋势由独立性能报告记录。 -- OBS 的 `cleanShutdown=false` 是当前本机测试工具镜像的独立已知问题;它不会改写 - aimedia 的退出码或媒体结果。 - 本报告证明 v0.2 支持范围内的外部互操作,不证明 RTMP、RTSP、H.265、缩放、变帧率 或双路导播能力。 diff --git a/docs/reports/rtmp.md b/docs/reports/rtmp.md index 27b16df..a42a5a3 100644 --- a/docs/reports/rtmp.md +++ b/docs/reports/rtmp.md @@ -8,8 +8,9 @@ 测试包含输入 publisher 中断、输出 MediaMTX 中断,以及 40ms RTT、20ms 抖动、1% 丢包。全部自动 gate 通过。420 秒回归还跨过了多个 RTMP ACK 窗口,输入和输出各只有 -一次计划内重连。它证明 FFmpeg、MediaMTX 和明文 RTMP 的短时互操作及故障恢复,不替代 -OBS、RTMPS 真实平台或两小时稳定性验证,因此状态仍是 `experimental`。 +一次计划内重连。2026-08-10 又完成 OBS 作为 publisher 和 consumer 的双向门槛, +包含实际渲染与 ffprobe。它证明 FFmpeg、OBS、MediaMTX 和明文 RTMP 的短时互操作及 +故障恢复,不替代 RTMPS 真实平台或两小时稳定性验证,因此状态仍是 `experimental`。 ## 可复现环境 @@ -23,6 +24,10 @@ OBS、RTMPS 真实平台或两小时稳定性验证,因此状态仍是 `experi | 视频 | H.264 Main、1920x1080、30fps、6Mbps、无 B 帧、1 秒 GOP | | 音频 | AAC-LC、48kHz、双声道、128kbps | +OBS 门槛额外使用 OBS Studio 30.0.2.1 和桌面工具镜像 +`sha256:e5c63696b818430e389765a15c09d8d6340228464c94bea6bc95b2356d6215f0`;引擎镜像为 +`sha256:ea041dba0836edd9d9d6774553ee49f5a55579cf185df793f022936f42b3435d`。 + 运行命令: ```powershell @@ -56,6 +61,27 @@ pwsh ./tools/rtmp.ps1 ` 网络损伤在 110—130 秒注入。结束时输入和输出均为连接状态,输出断线期间没有建立 历史媒体队列;重连后从新的 SPS/PPS 与 IDR 恢复,节目时钟没有回退。 +### OBS publisher 与 consumer + +`tools/interop.ps1 -Suite rtmp-obs` 启动两条隔离场景,每条使用独立 OBS HOME、 +obs-websocket 控制器和 aimedia 管线: + +| 场景 | 自动验收结果 | +|---|---| +| OBS publisher -> aimedia -> MediaMTX -> ffprobe | OBS/aimedia 正常退出;输入/输出 connected;581 个 H.264 视频包、948 个 AAC 音频包;首视频包为 keyframe;PTS/DTS 单调 | +| FFmpeg 测试源 -> aimedia -> MediaMTX -> OBS consumer | OBS/aimedia 正常退出;H.264 1280x720;AAC 48kHz 双声道;渲染 PNG 869 bytes,解码后 13 种颜色 | + +第二条场景先由 ffprobe 确认 MediaMTX 已发布 H.264/AAC,再要求 OBS Media Source +截取真实画面。PNG 文件大小不作为通过依据;控制器使用标准库解压像素、还原 PNG +filter,并要求至少 4 种颜色,从而拒绝黑帧、空帧和无效占位图。 + +本机汇总位于 +`C:\Users\anvsk\AppData\Local\Temp\aimedia-interop-ca8b327d\summary.json`,SHA-256 为 +`A33EE13209444F303C4F12FD7A06124713C9BCE31BF5FA4243CACBBB1B8D5E65`。同一版本还跑了 +8 秒 SRT/OBS 回归:publisher 输出 300/469 个视频/音频包,consumer 截图检测到 16 种 +颜色;汇总 SHA-256 为 +`B2C3FF6ECD305CB3958D1A7FE85089EEB9F13249E2FC614C3A710CEAB147173C`。 + ### 420 秒 ACK 与重连回归 修复后镜像 `sha256:ea041dba0836edd9d9d6774553ee49f5a55579cf185df793f022936f42b3435d` @@ -126,7 +152,6 @@ Release 附件保存并在报告中记录下载地址与哈希。 ## 尚未完成 -- OBS 作为 RTMP publisher 和 consumer 的真实互操作; - 至少两个真实直播平台 endpoint,包括 RTMPS 证书与鉴权错误路径; - 1080p30 两小时 GPU soak; - 上述门槛完成前不把 RTMP/RTMPS 从 `experimental` 升为 `supported`。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 5fd8862..6fe9e0c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -210,7 +210,11 @@ v0.3 工程。只有 V2-11 也完成后,总览才把 v0.2 标记为完全完 和 [RTMP 互操作报告](reports/rtmp.md)。后续 420 秒回归跨过多个 ACK 窗口,输入/ 输出重连严格为计划内 1/1,p95 141ms;两小时测试按用户要求在 2,911 秒停止, 不计入 F4。 - - [ ] V3-03F2:OBS 作为 RTMP publisher 与 consumer,包含实际渲染和 ffprobe。 + - [x] V3-03F2:OBS 30.0.2.1 作为 RTMP publisher 与 consumer。发布链由 ffprobe + 确认 H.264/AAC、首帧 keyframe 和 PTS/DTS 单调;接收链由 OBS Media Source + 渲染 1280x720 彩条,并通过 PNG 解码后的颜色多样性确认不是黑帧或空图。两条链路 + 的 OBS、aimedia 均正常退出;证据见 `tools/interop.ps1` 的 `rtmp-obs` suite 和 + [RTMP 互操作报告](reports/rtmp.md)。 - [ ] V3-03F3:至少两个真实平台 endpoint,覆盖 RTMPS 证书和鉴权路径。 - [ ] V3-03F4:1080p30 两小时 GPU soak、资源趋势和原始证据附件。2026-08-06 的 一次运行在 2,911 秒按用户要求主动停止;容器已清理,部分样本已记录,但该门槛 diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 8ac99f6..fa2f69c 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -23,8 +23,8 @@ | H.264 Annex-B / AAC ADTS parser | supported | NAL/frame、关键帧和 Alpha profile header | | BS.1770/4x true-peak DSP | foundation | 响度、增益、淡化和 limiter 已实现;AAC 帧级 codec 已就绪,待接运行时音频链 | | SRT caller/listener | supported | libsrt 1.5.5 adapter、epoll、指数退避和断开后 native 重连已测;caller/listener 四种组合、断流恢复、1% 丢包/20ms 抖动/40ms RTT 及两小时长稳已验证 | -| RTMP listener input + FLV | experimental | 明文 `rtmp://` listener、H.264 Annex-B/AVCC 与 AAC ADTS/raw 转换已接入单路 `aimedia run`。FFmpeg 8.1.2 -> aimedia -> MediaMTX 的 180 秒门禁和 420 秒故障回归已通过;后者输入/输出重连严格为计划内 1/1。已修复握手前控制输出和长连接 ACK 竞态。OBS、真实硬件编码器和两小时 soak 未完成 | -| RTMP/RTMPS publisher output | experimental | 编码后的 H.264 Annex-B/AAC ADTS 直接进入有界 publisher;RTMPS 使用 rustls 与公开 WebPKI 信任根校验证书和主机名。MediaMTX 回归验证了输出断线、无历史积压、重连后配置/IDR、节目 PTS 连续和 p95 141ms;OBS、至少两个真实平台 endpoint、RTMPS 实站和两小时 soak 未完成,不能标记为 supported | +| RTMP listener input + FLV | experimental | 明文 `rtmp://` listener、H.264 Annex-B/AVCC 与 AAC ADTS/raw 转换已接入单路 `aimedia run`。FFmpeg 8.1.2 -> aimedia -> MediaMTX 的 180 秒门禁和 420 秒故障回归已通过;后者输入/输出重连严格为计划内 1/1。OBS 30.0.2.1 发布链也已由 ffprobe 验证 H.264/AAC、首帧 keyframe 和单调时间戳。真实硬件编码器、真实平台和两小时 soak 未完成 | +| RTMP/RTMPS publisher output | experimental | 编码后的 H.264 Annex-B/AAC ADTS 直接进入有界 publisher;RTMPS 使用 rustls 与公开 WebPKI 信任根校验证书和主机名。MediaMTX 回归验证了输出断线、无历史积压、重连后配置/IDR、节目 PTS 连续和 p95 141ms;OBS Media Source 已实际渲染 1280x720 输出。至少两个真实平台 endpoint、RTMPS 实站和两小时 soak 未完成,不能标记为 supported | | RTSP/RTP input | experimental | `aimedia run` 已接 TCP interleaved 单路数据面:H.264/AAC-LC/G.711 直接进入有界 codec 队列,G.711 8kHz 单声道归一到 48kHz 双声道;MediaMTX 1.20.0 + FFmpeg 发布端已通过 180 秒 GPU 闭环、发布源 404 重连和 40ms RTT/20ms 抖动/1% 丢包短门禁,p95 90ms 且输出时间戳零回退。H.265 可重组为 Annex-B access unit,但 HEVC bridge 仍返回 `videoBridgePending`;UDP、非 48kHz 双声道 AAC、物理摄像机认证和两小时长稳未完成,不代表 GB28181 支持 | | 多输出与 Analyzer Tap | planned | v0.4;每个支路独立有界并与媒体主链隔离 | | WHIP output | planned | v0.6 以后按真实服务采用证据评估;H.264/Opus | diff --git a/tools/interop.ps1 b/tools/interop.ps1 index 155cd0c..84aa5a6 100644 --- a/tools/interop.ps1 +++ b/tools/interop.ps1 @@ -3,10 +3,11 @@ param( [string]$EngineImage = "aimedia:gpu", [string]$PeerImage = "aimedia:test-tools", [string]$DesktopImage = "aimedia:desktop-tools", + [string]$MediaImage = "bluenviron/mediamtx:1.20.0@sha256:86e63af28616d5e5a18540d7b031b6510bd4cbf1a3c7d224f9e2976f02aefbfb", [string]$AptMirror = "", [ValidateRange(4, 120)] [int]$DurationSeconds = 8, - [ValidateSet("all", "matrix", "ll", "lc", "cl", "cc", "netem", "corrupt", "backlog", "desktop", "obs", "obs-input", "obs-output", "vlc")] + [ValidateSet("all", "matrix", "ll", "lc", "cl", "cc", "netem", "corrupt", "backlog", "desktop", "obs", "obs-input", "obs-output", "vlc", "rtmp-obs", "rtmp-obs-input", "rtmp-obs-output")] [string]$Suite = "all", [switch]$SkipToolBuild, [switch]$KeepContainers @@ -18,13 +19,19 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot $runId = [Guid]::NewGuid().ToString("N").Substring(0, 8) $network = "am-io-$runId" +$rtmpMedia = "am-$runId-rtmp-media" $runRoot = Join-Path ([IO.Path]::GetTempPath()) "aimedia-interop-$runId" $containers = [Collections.Generic.List[string]]::new() $results = [Collections.Generic.List[object]]::new() $desktopResults = [Collections.Generic.List[object]]::new() $corruptResult = $null $toolVersions = [ordered]@{} -$needsDesktop = $Suite -in @("all", "desktop", "obs", "obs-input", "obs-output", "vlc") +$needsDesktop = $Suite -in @( + "all", "desktop", "obs", "obs-input", "obs-output", "vlc", + "rtmp-obs", "rtmp-obs-input", "rtmp-obs-output" +) +$needsVlc = $Suite -in @("all", "desktop", "vlc") +$needsRtmpMedia = $Suite -in @("rtmp-obs", "rtmp-obs-input", "rtmp-obs-output") function Invoke-Docker { param( @@ -108,6 +115,40 @@ function Wait-ControlState { throw "control socket for $Engine was not ready:`n$($logs -join [Environment]::NewLine)" } +function Wait-RtmpControlState { + param( + [Parameter(Mandatory)][string]$Engine, + [int]$TimeoutSeconds = 40 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $stateText = & docker exec $Engine aimedia control state --json 2>$null + $stateExit = $LASTEXITCODE + if ($stateExit -eq 0) { + try { + $state = (($stateText -join [Environment]::NewLine) | ConvertFrom-Json).state + if ( + $state.running -and + $state.inputs[0].rtmp.connected -and + $state.inputs[0].rtmp.packetsReceived -gt 0 -and + $state.output.rtmp.connected -and + $state.output.rtmp.packetsSent -gt 0 + ) { + return $state + } + } + catch { + # The control socket can become readable just before one complete JSON line arrives. + } + } + Start-Sleep -Milliseconds 500 + } while ((Get-Date) -lt $deadline) + + $logs = Invoke-Docker -Arguments @("logs", "--tail", "120", $Engine) -Capture + throw "RTMP control state for $Engine was not ready:`n$($logs -join [Environment]::NewLine)" +} + function Set-Netem { param([Parameter(Mandatory)][string[]]$Targets) @@ -149,7 +190,7 @@ function Read-OutputProbe { $probeText = Invoke-Docker -Arguments @( "run", "--rm", "-v", "${ScenarioRoot}:/work:ro", "--entrypoint", "ffprobe", - $PeerImage, "-v", "error", "-show_streams", "-show_packets", "-of", "json", + $PeerImage, "-v", "quiet", "-show_streams", "-show_packets", "-of", "json", "/work/$FileName" ) -Capture $probe = ($probeText -join [Environment]::NewLine) | ConvertFrom-Json @@ -245,6 +286,71 @@ control: [IO.File]::WriteAllText($Path, $config, [Text.UTF8Encoding]::new($false)) } +function Write-RtmpJobConfig { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Name + ) + + $config = @" +apiVersion: aimedia/v1alpha2 +kind: MediaJob +metadata: + name: $Name +inputs: + - name: obs + role: custom + uri: rtmp://0.0.0.0:1935/live + rtmp: + mode: listen + streamName: camera + connectTimeoutMs: 3000 + handshakeTimeoutMs: 5000 + readTimeoutMs: 5000 + maxMessageBytes: 8388608 + reconnect: + enabled: true + initialBackoffMs: 250 + maxBackoffMs: 1000 +processing: + video: + width: 1280 + height: 720 + fps: 30 + bitrateKbps: 3000 + gopMs: 1000 + profile: main + bFrames: 0 + audio: + sampleRate: 48000 + channels: 2 + bitrateKbps: 128 + timing: + masterInput: 0 + bufferMs: 120 + maxSkewMs: 80 +outputs: + - name: program + uri: rtmp://${rtmpMedia}:1935/live + rtmp: + mode: publish + streamName: program + connectTimeoutMs: 3000 + handshakeTimeoutMs: 5000 + readTimeoutMs: 5000 + maxMessageBytes: 8388608 + reconnect: + enabled: true + initialBackoffMs: 250 + maxBackoffMs: 1000 +taps: [] +control: + socketPath: /run/aimedia/aimedia.sock + socketMode: "0660" +"@ + [IO.File]::WriteAllText($Path, $config, [Text.UTF8Encoding]::new($false)) +} + function Start-Producer { param( [Parameter(Mandatory)][string]$Name, @@ -516,7 +622,8 @@ function Invoke-CorruptProbe { function Write-ObsConfig { param( [Parameter(Mandatory)][string]$ScenarioRoot, - [string]$Destination = "" + [string]$Destination = "", + [string]$DestinationKey = "" ) $configRoot = Join-Path $ScenarioRoot "home/.config/obs-studio" @@ -589,7 +696,7 @@ ChannelSetup=Stereo [IO.File]::WriteAllText((Join-Path $profileRoot "basic.ini"), $profile, [Text.UTF8Encoding]::new($false)) $service = @{ type = "rtmp_custom" - settings = @{ server = $Destination; key = "" } + settings = @{ server = $Destination; key = $DestinationKey } } | ConvertTo-Json -Compress [IO.File]::WriteAllText((Join-Path $profileRoot "service.json"), $service, [Text.UTF8Encoding]::new($false)) } @@ -605,10 +712,12 @@ function Start-Obs { [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$ScenarioRoot, [Parameter(Mandatory)][string[]]$ControllerArguments, - [string]$Destination = "" + [string]$Destination = "", + [string]$DestinationKey = "" ) - Write-ObsConfig -ScenarioRoot $ScenarioRoot -Destination $Destination + Write-ObsConfig -ScenarioRoot $ScenarioRoot -Destination $Destination ` + -DestinationKey $DestinationKey $controller = ($ControllerArguments | ForEach-Object { ConvertTo-ShellArgument $_ }) -join " " $command = @" dbus-run-session -- xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX' obs --disable-shutdown-check --multi > /work/obs.log 2>&1 & @@ -648,6 +757,31 @@ function Test-ObsCleanShutdown { return $log -notmatch "(?i)segmentation fault|core dumped" } +function Read-ObsControllerResult { + param([Parameter(Mandatory)][string]$ScenarioRoot) + + $path = Join-Path $ScenarioRoot "controller.log" + if (!(Test-Path $path)) { + throw "OBS did not produce controller.log" + } + $lastLine = Get-Content -LiteralPath $path | + Where-Object { $_.Trim().Length -gt 0 } | + Select-Object -Last 1 + try { + $result = $lastLine | ConvertFrom-Json + } + catch { + throw "OBS controller did not finish with JSON: $lastLine" + } + if ($null -eq $result.screenshotBytes -or $null -eq $result.distinctColors) { + throw "OBS controller result omitted screenshot evidence: $lastLine" + } + if ($result.distinctColors -lt 4) { + throw "OBS rendered fewer than four distinct colors: $lastLine" + } + return $result +} + function Start-Engine { param( [Parameter(Mandatory)][string]$Name, @@ -689,10 +823,11 @@ function Invoke-ObsReceive { $obsExit = Wait-ContainerExit -Name $obs -TimeoutSeconds ($DurationSeconds + 45) $obsCleanShutdown = Test-ObsCleanShutdown -ScenarioRoot $scenarioRoot $framePath = Join-Path $scenarioRoot "frame.png" - if (!(Test-Path $framePath) -or (Get-Item $framePath).Length -lt 1000) { + if (!(Test-Path $framePath)) { $logs = Invoke-Docker -Arguments @("logs", "--tail", "120", $obs) -Capture throw "OBS did not render the aimedia output:`n$($logs -join [Environment]::NewLine)" } + $controller = Read-ObsControllerResult -ScenarioRoot $scenarioRoot $engineExit = Stop-Engine -Name $engine $null = Invoke-Docker -Arguments @("rm", "-f", $source) -Capture $desktopResults.Add([pscustomobject]@{ @@ -703,7 +838,8 @@ function Invoke-ObsReceive { engineExit = $engineExit inputConnected = $state.inputs[0].srt.connected outputConnected = $state.output.srt.connected - screenshotBytes = (Get-Item $framePath).Length + screenshotBytes = $controller.screenshotBytes + distinctColors = $controller.distinctColors }) } @@ -758,6 +894,213 @@ function Invoke-ObsSend { }) } +function Wait-RtmpMedia { + param([int]$TimeoutSeconds = 20) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $logs = Invoke-Docker -Arguments @("logs", $rtmpMedia) -Capture + if ( + @($logs | Where-Object { + $_ -match "\[RTMP\].*(started with listener|listener opened).*:1935" + }).Count -gt 0 + ) { + return + } + Start-Sleep -Milliseconds 250 + } while ((Get-Date) -lt $deadline) + + throw "MediaMTX RTMP listener did not start" +} + +function Start-RtmpSource { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Engine + ) + + Start-TestContainer -Name $Name -Arguments @( + "--network", $network, $PeerImage, "ffmpeg", + "-hide_banner", "-loglevel", "warning", "-re", + "-f", "lavfi", "-i", "smptebars=size=1280x720:rate=30", + "-f", "lavfi", "-i", "sine=frequency=1700:sample_rate=48000", + "-t", "$($DurationSeconds + 30)", + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", + "-profile:v", "main", "-pix_fmt", "yuv420p", "-bf", "0", + "-g", "30", "-keyint_min", "30", "-sc_threshold", "0", "-b:v", "3000k", + "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", + "-f", "flv", "rtmp://${Engine}:1935/live/camera" + ) +} + +function Wait-RtmpPlayable { + param([int]$TimeoutSeconds = 30) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $probeText = & docker @( + "run", "--rm", "--network", $network, "--entrypoint", "ffprobe", + $PeerImage, "-v", "quiet", "-rw_timeout", "2000000", + "-show_entries", "stream=codec_type,codec_name,width,height,sample_rate,channels", + "-of", "json", "rtmp://${rtmpMedia}:1935/live/program" + ) 2>$null + $probeExit = $LASTEXITCODE + if ($probeExit -eq 0) { + try { + $probe = (($probeText -join [Environment]::NewLine) | ConvertFrom-Json) + $video = $probe.streams | + Where-Object codec_type -eq "video" | + Select-Object -First 1 + $audio = $probe.streams | + Where-Object codec_type -eq "audio" | + Select-Object -First 1 + if ($video.codec_name -eq "h264" -and $audio.codec_name -eq "aac") { + return [pscustomobject]@{ + videoCodec = $video.codec_name + width = [int]$video.width + height = [int]$video.height + audioCodec = $audio.codec_name + sampleRate = [int]$audio.sample_rate + channels = [int]$audio.channels + } + } + } + catch { + # Retry until ffprobe observes complete audio and video stream metadata. + } + } + Start-Sleep -Milliseconds 500 + } while ((Get-Date) -lt $deadline) + + throw "MediaMTX RTMP program did not become playable" +} + +function Start-RtmpCapture { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$ScenarioRoot, + [int]$Seconds = $DurationSeconds + ) + + Start-TestContainer -Name $Name -Arguments @( + "--network", $network, "-v", "${ScenarioRoot}:/work", + "--entrypoint", "timeout", $PeerImage, + "--signal=INT", "--kill-after=5", "${Seconds}s", "ffmpeg", + "-hide_banner", "-loglevel", "warning", "-y", "-copyts", + "-i", "rtmp://${rtmpMedia}:1935/live/program", + "-map", "0", "-c", "copy", "-copytb", "1", + "-avoid_negative_ts", "disabled", "-f", "flv", "/work/output.flv" + ) +} + +function Invoke-RtmpObsSend { + $scenarioRoot = Join-Path $runRoot "rtmp-obs-input" + $null = New-Item -ItemType Directory -Path $scenarioRoot + $engine = "am-$runId-rtmp-obs-input-engine" + $obs = "am-$runId-rtmp-obs-input" + $capture = "am-$runId-rtmp-obs-input-probe" + $configPath = Join-Path $scenarioRoot "job.yaml" + Write-RtmpJobConfig -Path $configPath -Name "rtmp-obs-input" + + Invoke-Docker -Arguments @( + "run", "--rm", "-v", "${scenarioRoot}:/work", $PeerImage, "ffmpeg", + "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "smptebars=size=1280x720:rate=30", + "-f", "lavfi", "-i", "sine=frequency=1500:sample_rate=48000", + "-t", "$($DurationSeconds + 20)", "-c:v", "libx264", "-preset", "ultrafast", + "-tune", "zerolatency", "-profile:v", "main", "-pix_fmt", "yuv420p", + "-bf", "0", "-g", "30", "-c:a", "aac", "-b:a", "128k", + "-ar", "48000", "-ac", "2", "-f", "mpegts", "/work/source.ts" + ) + + Start-Engine -Name $engine -ConfigPath $configPath + $null = Wait-ControlState -Engine $engine -TimeoutSeconds 20 + Start-Obs -Name $obs -ScenarioRoot $scenarioRoot ` + -Destination "rtmp://${engine}:1935/live" -DestinationKey "camera" ` + -ControllerArguments @( + "produce", "--source", "/work/source.ts", + "--destination", "rtmp://${engine}:1935/live/camera", + "--duration", "$($DurationSeconds + 10)" + ) + $state = Wait-RtmpControlState -Engine $engine + $stream = Wait-RtmpPlayable + Start-RtmpCapture -Name $capture -ScenarioRoot $scenarioRoot + $captureExit = Wait-ContainerExit -Name $capture ` + -TimeoutSeconds ($DurationSeconds + 20) -AllowFailure + $obsExit = Wait-ContainerExit -Name $obs -TimeoutSeconds ($DurationSeconds + 45) + $obsCleanShutdown = Test-ObsCleanShutdown -ScenarioRoot $scenarioRoot + $engineExit = Stop-Engine -Name $engine + $probe = Read-OutputProbe -ScenarioRoot $scenarioRoot -FileName "output.flv" + + $desktopResults.Add([pscustomobject]@{ + scenario = "rtmp-obs-input" + role = "producer" + obsExit = $obsExit + cleanShutdown = $obsCleanShutdown + engineExit = $engineExit + captureExit = $captureExit + inputConnected = $state.inputs[0].rtmp.connected + outputConnected = $state.output.rtmp.connected + inputPackets = $state.inputs[0].rtmp.packetsReceived + outputPackets = $state.output.rtmp.packetsSent + videoCodec = $stream.videoCodec + audioCodec = $stream.audioCodec + videoPackets = $probe.videoPackets + audioPackets = $probe.audioPackets + firstVideoKeyframe = $probe.firstVideoKeyframe + monotonicPtsDts = $probe.monotonicPtsDts + }) +} + +function Invoke-RtmpObsReceive { + $scenarioRoot = Join-Path $runRoot "rtmp-obs-output" + $null = New-Item -ItemType Directory -Path $scenarioRoot + $engine = "am-$runId-rtmp-obs-output-engine" + $source = "am-$runId-rtmp-obs-output-source" + $obs = "am-$runId-rtmp-obs-output" + $configPath = Join-Path $scenarioRoot "job.yaml" + Write-RtmpJobConfig -Path $configPath -Name "rtmp-obs-output" + + Start-Engine -Name $engine -ConfigPath $configPath + $null = Wait-ControlState -Engine $engine -TimeoutSeconds 20 + Start-RtmpSource -Name $source -Engine $engine + $state = Wait-RtmpControlState -Engine $engine + $stream = Wait-RtmpPlayable + Start-Obs -Name $obs -ScenarioRoot $scenarioRoot -ControllerArguments @( + "consume", "--source", "rtmp://${rtmpMedia}:1935/live/program", + "--input-format", "flv", "--screenshot", "/work/frame.png", + "--duration", "$($DurationSeconds + 10)" + ) + $obsExit = Wait-ContainerExit -Name $obs -TimeoutSeconds ($DurationSeconds + 45) + $obsCleanShutdown = Test-ObsCleanShutdown -ScenarioRoot $scenarioRoot + $framePath = Join-Path $scenarioRoot "frame.png" + if (!(Test-Path $framePath)) { + $logs = Invoke-Docker -Arguments @("logs", "--tail", "120", $obs) -Capture + throw "OBS did not render the RTMP output:`n$($logs -join [Environment]::NewLine)" + } + $controller = Read-ObsControllerResult -ScenarioRoot $scenarioRoot + $engineExit = Stop-Engine -Name $engine + $null = Invoke-Docker -Arguments @("rm", "-f", $source) -Capture + + $desktopResults.Add([pscustomobject]@{ + scenario = "rtmp-obs-output" + role = "consumer" + obsExit = $obsExit + cleanShutdown = $obsCleanShutdown + engineExit = $engineExit + inputConnected = $state.inputs[0].rtmp.connected + outputConnected = $state.output.rtmp.connected + videoCodec = $stream.videoCodec + width = $stream.width + height = $stream.height + audioCodec = $stream.audioCodec + sampleRate = $stream.sampleRate + channels = $stream.channels + screenshotBytes = $controller.screenshotBytes + distinctColors = $controller.distinctColors + }) +} + function Invoke-VlcReceive { $scenarioRoot = Join-Path $runRoot "vlc-output" $null = New-Item -ItemType Directory -Path $scenarioRoot @@ -834,22 +1177,34 @@ try { } } $null = Invoke-Docker -Arguments @("image", "inspect", $EngineImage) -Capture + if ($needsRtmpMedia) { + $null = Invoke-Docker -Arguments @("image", "inspect", $MediaImage) -Capture + } $toolVersions.ffmpeg = (Invoke-Docker -Arguments @( "run", "--rm", $PeerImage, "ffmpeg", "-version" ) -Capture | Select-Object -First 1) if ($needsDesktop) { + $toolVersions.obs = (Invoke-Docker -Arguments @( + "run", "--rm", $DesktopImage, "obs", "--version" + ) -Capture | Select-Object -First 1) + } + if ($needsVlc) { $null = Invoke-Docker -Arguments @( "run", "--rm", "-e", "LD_LIBRARY_PATH=", $DesktopImage, "sh", "-c", "test -f /usr/lib/x86_64-linux-gnu/vlc/plugins/access/libaccess_srt_plugin.so" ) -Capture - $toolVersions.obs = (Invoke-Docker -Arguments @( - "run", "--rm", $DesktopImage, "obs", "--version" - ) -Capture | Select-Object -First 1) $toolVersions.vlc = (Invoke-Docker -Arguments @( "run", "--rm", "--user", "911:1001", "-e", "HOME=/tmp", $DesktopImage, "cvlc", "--version" ) -Capture | Select-Object -First 1) } + if ($needsRtmpMedia) { + $toolVersions.mediaMtx = (Invoke-Docker -Arguments @( + "run", "--rm", $MediaImage, "--version" + ) -Capture | Select-Object -First 1) + Start-TestContainer -Name $rtmpMedia -Arguments @("--network", $network, $MediaImage) + Wait-RtmpMedia + } if ($Suite -in @("all", "matrix", "netem")) { Invoke-Scenario -Name "ll-netem" -InputMode listener -OutputMode listener -Impaired @@ -878,6 +1233,12 @@ try { if ($Suite -in @("all", "desktop", "obs", "obs-output")) { Invoke-ObsReceive } + if ($Suite -in @("rtmp-obs", "rtmp-obs-input")) { + Invoke-RtmpObsSend + } + if ($Suite -in @("rtmp-obs", "rtmp-obs-output")) { + Invoke-RtmpObsReceive + } if ($Suite -in @("all", "desktop", "vlc")) { Invoke-VlcReceive } @@ -888,6 +1249,7 @@ try { engineImage = $EngineImage peerImage = $PeerImage desktopImage = $DesktopImage + mediaImage = if ($needsRtmpMedia) { $MediaImage } else { $null } durationSeconds = $DurationSeconds suite = $Suite toolVersions = $toolVersions diff --git a/tools/obs.py b/tools/obs.py index 0d35fbf..dc98e26 100644 --- a/tools/obs.py +++ b/tools/obs.py @@ -6,12 +6,101 @@ import base64 import json import pathlib +import struct import sys import uuid +import zlib import websockets +def png_distinct_colors(image: bytes, limit: int = 16) -> int: + """Return sampled RGB diversity for an 8-bit, non-interlaced PNG.""" + if not image.startswith(b"\x89PNG\r\n\x1a\n"): + return 0 + offset = 8 + width = height = bit_depth = color_type = interlace = None + compressed = bytearray() + while offset + 12 <= len(image): + length = struct.unpack(">I", image[offset : offset + 4])[0] + chunk_type = image[offset + 4 : offset + 8] + data_start = offset + 8 + data_end = data_start + length + if data_end + 4 > len(image): + return 0 + data = image[data_start:data_end] + if chunk_type == b"IHDR": + width, height, bit_depth, color_type, _, _, interlace = struct.unpack( + ">IIBBBBB", data + ) + elif chunk_type == b"IDAT": + compressed.extend(data) + elif chunk_type == b"IEND": + break + offset = data_end + 4 + if bit_depth != 8 or interlace != 0 or color_type not in {0, 2, 4, 6}: + return 0 + channels = {0: 1, 2: 3, 4: 2, 6: 4}[color_type] + try: + raw = zlib.decompress(bytes(compressed)) + except zlib.error: + return 0 + stride = width * channels + expected = height * (stride + 1) + if len(raw) != expected: + return 0 + + def paeth(left: int, up: int, upper_left: int) -> int: + estimate = left + up - upper_left + left_distance = abs(estimate - left) + up_distance = abs(estimate - up) + upper_left_distance = abs(estimate - upper_left) + if left_distance <= up_distance and left_distance <= upper_left_distance: + return left + if up_distance <= upper_left_distance: + return up + return upper_left + + previous = bytearray(stride) + colors = set() + row_step = max(1, height // 64) + column_step = max(1, width // 64) + for row_index in range(height): + row_start = row_index * (stride + 1) + filter_type = raw[row_start] + filtered = raw[row_start + 1 : row_start + 1 + stride] + row = bytearray(stride) + for index, value in enumerate(filtered): + left = row[index - channels] if index >= channels else 0 + up = previous[index] + upper_left = previous[index - channels] if index >= channels else 0 + if filter_type == 0: + predictor = 0 + elif filter_type == 1: + predictor = left + elif filter_type == 2: + predictor = up + elif filter_type == 3: + predictor = (left + up) // 2 + elif filter_type == 4: + predictor = paeth(left, up, upper_left) + else: + return 0 + row[index] = (value + predictor) & 0xFF + if row_index % row_step == 0: + for column in range(0, width, column_step): + pixel = column * channels + if color_type in {0, 4}: + rgb = (row[pixel],) * 3 + else: + rgb = tuple(row[pixel : pixel + 3]) + colors.add(rgb) + if len(colors) >= limit: + return len(colors) + previous = row + return len(colors) + + class ObsClient: def __init__(self, socket): self.socket = socket @@ -72,7 +161,18 @@ async def request(self, request_type: str, request_data=None): return message["d"].get("responseData", {}) -async def create_media_scene(client: ObsClient, source: str, local: bool): +async def create_media_scene( + client: ObsClient, source: str, local: bool, input_format: str +): + input_settings = { + ("local_file" if local else "input"): source, + "is_local_file": local, + "looping": local, + "restart_on_activate": True, + "buffering_mb": 2, + } + if input_format: + input_settings["input_format"] = input_format await client.request("CreateScene", {"sceneName": "Program"}) await client.request("SetCurrentProgramScene", {"sceneName": "Program"}) await client.request( @@ -81,14 +181,7 @@ async def create_media_scene(client: ObsClient, source: str, local: bool): "sceneName": "Program", "inputName": "Media", "inputKind": "ffmpeg_source", - "inputSettings": { - ("local_file" if local else "input"): source, - "input_format": "mpegts", - "is_local_file": local, - "looping": local, - "restart_on_activate": True, - "buffering_mb": 2, - }, + "inputSettings": input_settings, "sceneItemEnabled": True, }, ) @@ -113,10 +206,13 @@ async def wait_for( async def consume(args): client = await ObsClient.connect(args.websocket) - await create_media_scene(client, args.source, local=False) + await create_media_scene( + client, args.source, local=False, input_format=args.input_format + ) deadline = asyncio.get_running_loop().time() + args.duration media_state = None image = b"" + distinct_colors = 0 while asyncio.get_running_loop().time() < deadline: media_state = await client.request("GetMediaInputStatus", {"inputName": "Media"}) if media_state.get("mediaState") in { @@ -135,17 +231,27 @@ async def consume(args): ) encoded = screenshot["imageData"].split(",", 1)[-1] image = base64.b64decode(encoded) - # A blank 320x180 scene compresses to only a few hundred bytes. - # The SMPTE bars used by the harness are safely above this bound. - if image.startswith(b"\x89PNG\r\n\x1a\n") and len(image) >= 1000: + distinct_colors = png_distinct_colors(image) + if distinct_colors >= 4: break await asyncio.sleep(0.5) - if len(image) < 1000: + if image: + pathlib.Path(args.screenshot).write_bytes(image) + if distinct_colors < 4: raise RuntimeError( - f"OBS media source did not render a non-blank frame: {media_state}" + "OBS media source did not render a non-blank frame: " + f"state={media_state} screenshotBytes={len(image)} " + f"distinctColors={distinct_colors}" ) - pathlib.Path(args.screenshot).write_bytes(image) - print(json.dumps({"mediaState": media_state["mediaState"], "screenshotBytes": len(image)})) + print( + json.dumps( + { + "mediaState": media_state["mediaState"], + "screenshotBytes": len(image), + "distinctColors": distinct_colors, + } + ) + ) async def produce(args): @@ -153,7 +259,7 @@ async def produce(args): service = await client.request("GetStreamServiceSettings") outputs = await client.request("GetOutputList") print(json.dumps({"service": service, "outputs": outputs}, separators=(",", ":"))) - await create_media_scene(client, args.source, local=True) + await create_media_scene(client, args.source, local=True, input_format="mpegts") await wait_for( client, "GetMediaInputStatus", @@ -178,6 +284,7 @@ def parse_args(): consume_parser = subparsers.add_parser("consume") consume_parser.add_argument("--source", required=True) + consume_parser.add_argument("--input-format", default="mpegts") consume_parser.add_argument("--screenshot", required=True) consume_parser.add_argument("--duration", type=float, default=5) From 325455fe097886606df31100631f422fb6f70bf0 Mon Sep 17 00:00:00 2001 From: pingan Date: Mon, 10 Aug 2026 20:57:24 +0800 Subject: [PATCH 2/2] Link OBS RTMP delivery --- docs/handoff.md | 2 +- docs/roadmap.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/handoff.md b/docs/handoff.md index e157cb5..686fe8c 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -26,7 +26,7 @@ Git 基线:`main` 的 `6b86a7b`,交付分支 `codex/feat/rtmp-obs` | RTMP/FLV 契约、会话、AVC/AAC 转换、listener、RTMP/RTMPS publisher | 已合并 | PR #32—#36 | | RTMP FFmpeg -> aimedia -> MediaMTX 180/420 秒故障门禁 | 本交付 | PR #38;`docs/reports/rtmp.md` | | RTMP publisher ACK 竞态修复 | 本交付 | PR #38;`anvsk/rtmp-rs@00e97a6` | -| OBS RTMP publisher/consumer 与实际渲染 | 本交付 | `tools/interop.ps1 -Suite rtmp-obs`;`docs/reports/rtmp.md` | +| OBS RTMP publisher/consumer 与实际渲染 | 本交付 | PR #39;`tools/interop.ps1 -Suite rtmp-obs`;`docs/reports/rtmp.md` | | RTMP 真实平台、完整两小时 soak | 未完成 | 保持 `experimental` | | v0.4 多输出与 AI Tap | 未开始 | 暂停期间不要开发 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 6fe9e0c..d417428 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -214,7 +214,8 @@ v0.3 工程。只有 V2-11 也完成后,总览才把 v0.2 标记为完全完 确认 H.264/AAC、首帧 keyframe 和 PTS/DTS 单调;接收链由 OBS Media Source 渲染 1280x720 彩条,并通过 PNG 解码后的颜色多样性确认不是黑帧或空图。两条链路 的 OBS、aimedia 均正常退出;证据见 `tools/interop.ps1` 的 `rtmp-obs` suite 和 - [RTMP 互操作报告](reports/rtmp.md)。 + [RTMP 互操作报告](reports/rtmp.md)。交付: + [PR #39](https://github.com/anvsk/aimedia/pull/39)。 - [ ] V3-03F3:至少两个真实平台 endpoint,覆盖 RTMPS 证书和鉴权路径。 - [ ] V3-03F4:1080p30 两小时 GPU soak、资源趋势和原始证据附件。2026-08-06 的 一次运行在 2,911 秒按用户要求主动停止;容器已清理,部分样本已记录,但该门槛