abundant-magician-17307
12/22/2025, 5:49 AMtall-airline-16756
12/22/2025, 5:49 AMtall-airline-16756
12/22/2025, 5:50 AMstartRoomCompositeEgress 导出画质较低。下面给出要点、原因与可操作的优化建议。
关键区别(简要)
• *输入源不同*:`startWebEgress` 录制的是一个网页内容(Chrome 直接渲染页面上的高质量媒体),而 startRoomCompositeEgress 录制的是基于 LiveKit 房间的实时 WebRTC 视频流合成。
• *画质决定因素不同*:Web egress 主要受网页资源质量影响;room composite 除了编码设置外,还受参与者发布流分辨率/码率、网络和合成渲染过程影响。
可能导致 room-composite 画质低的具体原因
• 参与者上行流不是高分辨率(例如只发布 720p/360p),合成放大后会模糊。
• 合成模板里对 <video> 做了缩放/布局,浏览器插值或抗锯齿导致细节丢失。
• 动态场景(人员切换、动画)导致编码器频繁插入关键帧,相同比特率下视觉质量下降。
• template 或 Chrome 渲染分辨率未显式设置为期望的输出分辨率,导致实际采集较低分辨率画面。
可操作的优化建议
1. *确保发布高质量的原始流*:让客户端发布 1080p/高码率流(例如使用相应的 VideoPresets 或 createLocalVideoTrack 配置)。
2. *在 compositing template 中固定视频尺寸并避免缩放*:为每个 <video> 设置固定宽高,使用 object-fit: cover 或试验 image-rendering: pixelated 来减少浏览器缩放模糊。
3. *使用高质量编码预设*:在启动 egress 时使用明确的高质量 preset(例如 H264 1080p/60 或更高码率的自定义设置)。
4. *优化 template 性能*:减少不必要的 DOM 变换/动画,确保渲染不卡顿以避免丢帧。
5. *确认 egress 的渲染分辨率*:显式设置或检查 preset/advanced 参数,确保 Chrome 以期望分辨率渲染并捕获。
6. *若使用 simulcast/SVC*:确保订阅的是高层(高分辨率层),而不是被客户端或服务器降级到低层。
如果你愿意,我可以继续帮你诊断:请提供目前的编码 preset(或你传给 egress 的参数)、房间内参与者发布的分辨率设置,以及你使用的 compositing template 是否对视频做了缩放或 CSS 处理。
Sources: Custom recording templates | LiveKit docs | RoomComposite Egress | LiveKit Docs | Egress API | LiveKit docsabundant-magician-17307
12/22/2025, 5:52 AMtall-airline-16756
12/22/2025, 5:52 AMabundant-magician-17307
12/22/2025, 5:54 AMtall-airline-16756
12/22/2025, 5:54 AMpreset / advanced 配置,
• 以及你看到的导出文件的分辨率/码率样例。abundant-magician-17307
12/22/2025, 5:57 AMtall-airline-16756
12/22/2025, 5:57 AMadvanced 比特率设置,避免编码器因比特率受限而降质。
6. 禁止或最小化 template 中的缩放/transform:为 <video> 或 canvas 绑定明确像素尺寸,避免浏览器缩放插值。
7. 若使用 simulcast:确认发布端确实在发送高层;若使用 SVC,确认 SFU 在该订阅路径上提供了高层。
8. 尝试临时把 template 简化为仅渲染该共享流的原始 `<video>`(无额外 DOM/动画),看导出质量是否提升,以判断是否为 template 渲染引起的问题。
如果你愿意,我可以继续帮你逐项诊断。请提供以下任一或多个信息:
• 发布端屏幕共享的分辨率/编码设置(是否启用 simulcast 或 SVC);
• 你启动 egress 时的 preset / advanced 参数;
• 你使用的 compositing template(能否贴出有关 video 元素的 CSS/布局片段);
• 导出文件的实际分辨率与码率样例(或导出后看起来模糊的截图)。
告诉我你先能提供哪几项,我会基于这些信息给出更具体的检查与解决步骤。abundant-magician-17307
12/22/2025, 5:59 AMtall-airline-16756
12/22/2025, 6:00 AMvideo 元素的实际像素宽高与目标分辨率匹配;考虑设置或检测 window.devicePixelRatio 并按像素渲染。
4. 强制高质量订阅场景:使用 single-speaker 或确保 speaker 模板确实把该共享者作为大画面,从而让 headless Chrome 请求高层。
5. 提高 Egress 编码比特率与 preset:在 startRoomCompositeEgress 请求中使用高质量 preset(如 H264_1080P_60)或把 video_bitrate 提高到 4500–8000 kbps(根据内容复杂度调节)。
6. 检查资源与部署:确保 egress 的 headless Chrome 有足够 CPU(建议 4+ vCPU)与内存,避免渲染瓶颈。
7. 试验简化 template 的对比测试:临时使用仅渲染原始 `<video>`(无其他 DOM/动画)做一次录制;若清晰度显著提升,问题在 template 渲染或 CSS 缩放环节。
我可以帮你做下一步诊断。请提供任意一项或多项:
• 共享者发布时的分辨率/编码/是否启用 simulcast 或 SVC;
• 你调用 startRoomCompositeEgress 时的 preset / `advanced`(或完整请求片段);
• compositing template 中与视频元素相关的 CSS/布局片段;
• 导出文件显示的分辨率与码率样例或模糊截图。
告诉我你先能提供哪几项,我会基于这些信息给出针对性的检查指令和调整建议。
Sources: Self-hosting the Egress Service | LiveKit docs | Egress API | LiveKit docsabundant-magician-17307
12/22/2025, 6:17 AMtall-airline-16756
12/22/2025, 6:17 AM<video> 或 canvas 实际像素等于或大于目标输出分辨率,避免 CSS 缩放,考虑按 devicePixelRatio 计算像素尺寸。
3. 确认 headless Chrome 实际订阅的层:在 template 中记录/输出订阅信息或在 egress 日志查看是否有层切换/降级事件。
4. 提高 egress 环境资源与编码比特率:确保足够 CPU 并将 video_bitrate 提高到合适值。
5. 简化 template 做对比:临时用一个只渲染该共享流原始 <video> 的模板,若清晰则问题在原模板渲染/缩放。
6. 若使用 simulcast:确认发布端确实发送高层;若使用 SVC,确认 SFU 提供了高层组合。
如果你愿意,我可以根据下面任一信息帮你更精确定位:
• 共享者发布时的分辨率/编码/是否启用 simulcast 或 SVC;
• 你当前的 compositing template 中与视频元素相关的 CSS/布局片段;
• startRoomCompositeEgress 的请求片段(preset/advanced);
• egress 运行环境的 CPU/内存配置 或 导出文件的分辨率/码率示例。
告诉我先提供哪一项,我马上帮你继续诊断。abundant-magician-17307
12/22/2025, 6:23 AMtall-airline-16756
12/22/2025, 6:24 AMparticipant_joined 或包含 egress 身份(例如包含 "egress"、"recorder" 等)的 participant identity,确认录制者已加入。
1. 查找订阅事件:
◦ 搜索 track_subscribed / TrackSubscribed 等事件,定位 subscriber 字段为该录制参与者的条目,确认订阅已建立。
1. 查找图层选择相关的 DEBUG/INFO 行:
◦ 在 Server 的 DEBUG 日志中,查找类似的行:
▪︎ Selecting spatial layer=2, temporal layer=... for subscriber=EGRESS_ID, track=...
▪︎ 或 Negotiated layers for track ...: spatial=2(High)
这些行能直接表明被分配的空间层(0=低,1=中,2=高)。
1. 在 egress(或 Egress worker)调试日志中确认 Chrome 尺寸:
◦ 查找 Starting Chrome session: width=1920, height=1080 或类似信息,确保 headless Chrome 以目标像素尺寸渲染。
1. 辅助验证(如果需要更精确证据):
◦ 在 template 中打印或上报 video 元素的实际像素宽高(window.devicePixelRatio * DOM 宽高),或在 egress worker 中启用更高日志级别,收集 webrtc-internals 样本。
若日志没有直接显示“spatial layer=2”,可能原因与建议
• 日志级别太低(需把 LiveKit Server / egress 日志调到 DEBUG)。
• 参与者未启用 simulcast 或只发送单层(此时不存在“高层”可选)。
• adaptive stream 基于渲染尺寸选择了低层(确认 template 渲染尺寸与 DPR)。
我可以帮你下一步定位:请把以下任意一项提供给我(或贴关键日志片段):
• LiveKit Server 中与该房间相关的部分 DEBUG 日志(包含 participant_joined / track_subscribed / layer selection 行);
• Egress worker 启动时关于 Chrome 尺寸的日志行;
• 你的 compositing template 中渲染共享流的 video 元素实际 CSS/像素设置。
你先能提供哪一项?
Sources: RoomComposite Egress | LiveKit Docs | Recording and export | LiveKit docs | Quotas and limits | LiveKit docs | Universal Egress | Egress examples | LiveKit docsabundant-magician-17307
12/22/2025, 6:29 AMtall-airline-16756
12/22/2025, 6:29 AMkeyFrameInterval: 1 比较异常,可能导致码率/质量表现异常或编码开销增大。
需要关注的具体项
1. preset vs advanced 分辨率不一致
◦ 你设置 preset = H264_720P_30(表示目标为 720p),但 advanced 指定 width=1920 height=1080(1080p)。实际 Chrome 渲染与最终编码会受 advanced 优先级影响,但混用会造成预期与实际不一致。建议把 preset 与 advanced 的目标分辨率对齐(两者都设 1080p 或都设 720p),或仅使用 advanced 进行精确控制。
1. keyFrameInterval = 1(每 1 秒还是每 1 帧?)
◦ 通常 keyframe 间隔为 1–2 秒(如 1 表示 1 秒),但如果含义为每帧则会极大增加码率消耗并可能影响质量表现。确认该字段语义(单位通常为秒或帧数),并考虑使用 1–2 秒的合理值(例如 2 或 1),避免过高频率的关键帧。
1. videoBitrate = 4000 kbps 与分辨率匹配
◦ 4 Mbps 对多数 1080p 内容在中等复杂度下通常足够,但如果是复杂或动态的屏幕共享(滚动、视频)可能需要更高比特率(例如 6–8 Mbps)。若你希望稳定高细节,考虑提高 bitrate。
1. headless Chrome 实际渲染尺寸与 DPR
◦ 即便 advanced 指定 1920x1080,仍需确认 headless Chrome 实际窗口大小与 template 中 video 的像素尺寸匹配(包括 devicePixelRatio)。否则可能被放大导致模糊。查看 egress worker 启动日志是否记录 Chrome 窗口尺寸。
1. RTMP 输出与 streamOutputs 重复项
◦ 你同时在 roomComposite.stream 和 roomComposite.streamOutputs 指定了相同 RTMP URL,逻辑上可以,但确认不重复触发或冲突。日志显示 ACTIVE,说明目标已连通。
下一步建议(优先级)
1. 把 preset 与 advanced 对齐(建议统一为 1080p 或根据需求选 720p)。
2. 确认 keyFrameInterval 的单位并调整为合理值(1–2 秒)。
3. 如需最高质量,将 videoBitrate 提高到 6000–8000 kbps 做对比测试。
4. 在 egress worker/LiveKit Server 的 DEBUG 日志中:查找 participant_joined / track_subscribed / Selecting spatial layer= 与 Starting Chrome session: width=..., height=... 行,确认录制参与者确实订阅到高层且 Chrome 窗口按期望像素渲染。
5. 若仍模糊,临时简化 template(仅渲染共享流的原始 `<video>`)做对比,排查模板缩放/ DPR 问题。
如果你愿意,我可以帮你看具体日志片段:请贴出 LiveKit Server 的相关 DEBUG 日志片段(包含 track_subscribed 或 Selecting spatial layer 的行),以及 egress worker 启动时的 Chrome 窗口尺寸日志。abundant-magician-17307
12/22/2025, 6:35 AMsfu/downtrack.go:963 max subscribed layer processed {"room": "c802059925854a94b8593cebc0732d01", "roomID": "RM_SJ6R9pg8n5wo", "participant": "EG_avyKYeYqJXxn", "pID": "PA_7SeNUT3s4MPt", "remote": false, "trackID": "TR_VSAoBpdqwoPrq5", "relayed": false, "subscriberID": "PA_7SeNUT3s4MPt:1766384872549859711", "layer": 2, "event": "max-subscribed"}
2025/12/22 14:27:52 2025-12-22T06:27:52.722Z DEBUG livekit.pub rtc/participant.go:2703 sending max subscribed quality {"room": "c802059925854a94b8593cebc0732d01", "roomID": "RM_SJ6R9pg8n5wo", "participant": "436256720b85410dafc4e04d0c489e41", "pID": "PA_LYKfgRyfyn49", "remote": false, "trackID": "TR_VSAoBpdqwoPrq5", "qualities": [{"codec":"h264","qualities":[{"enabled":true},{"quality":1,"enabled":true},{"quality":2,"enabled":true}]}], "max": [{"CodecMime":1,"Quality":2}]}
2025/12/22 14:27:52 2025-12-22T06:27:52.722Z DEBUG livekit.sub sfu/downtrack.go:965 notifying max subscribed layer {"room": "c802059925854a94b8593cebc0732d01", "roomID": "RM_SJ6R9pg8n5wo", "participant": "EG_avyKYeYqJXxn", "pID": "PA_7SeNUT3s4MPt", "remote": false, "trackID": "TR_VSAoBpdqwoPrq5", "relayed": false, "subscriberID": "PA_7SeNUT3s4MPt:1766384872549859711", "layer": 2, "event": "max-subscribed"}tall-airline-16756
12/22/2025, 6:35 AMlayer: 2 / max-subscribed 明确表明 EGRESS 的订阅目标是最高空间层(High)。订阅层这一环已正确生效。
接下来优先检查项(按排查顺序)
1. 验证发布端原始流质量
◦ 在发布端本地直接查看或本地录制屏幕共享,确认进入 SFU 的流分辨率/码率确实是高质量(例如 1920x1080)。若发布端并未发送高层,尽管订阅到 layer=2,内容仍可能低。
1. 确认 headless Chrome 实际渲染像素
◦ 检查 egress worker 日志是否有类似 Starting Chrome session: width=1920, height=1080 的行,确认 Chrome 窗口尺寸与 advanced 一致。
◦ 在 template 中打印或上报视频元素实际像素:`videoWidth = video.videoWidth; videoHeight = video.videoHeight; devicePixelRatio = window.devicePixelRatio`,确保渲染像素 >= 目标编码像素。
1. 排查 template 的缩放与 DPR 问题
◦ 确保 <video> 或 canvas 没有被 CSS scale/transform 缩放(避免使用百分比尺寸导致实际像素小于目标)。把元素设为固定像素尺寸并按 DPR 调整:`element.style.width = targetWidth + 'px'; element.width = targetWidth * devicePixelRatio;`。
◦ 临时用最简单的 template(仅一个原始 `<video>`,无其他 DOM/动画)做一次对比录制,看是否改善。
1. 检查编码与 GOP/关键帧设置
◦ 你当前 `videoBitrate=4000`、`keyFrameInterval=1`。确认 keyFrameInterval 的单位(通常为秒),频繁关键帧会影响压缩效率;若动画复杂可适当增大间隔或调高比特率(试 6000–8000 kbps)做对比。
1. 确认 SFU/网络是否有降级或丢包
◦ 查看 SFU 日志是否有层切换、丢包或降级事件,抓取 webrtc stats(上行/下行丢包、实际接收比特率)确认传输质量。
1. 验证 RTMP 输出链路与转码影响
◦ 如果你是通过 RTMP 输出到第三方服务,确认中间服务/接收端没有再次转码或下采样。可直接用本地录制文件(非 RTMP)做对比确认 Egress 端输出的原始文件质量。
若你愿意,我可以继续帮你定位:请提供任一项即可开始深入分析
• egress worker 启动时包含 Chrome 尺寸的日志行;
• 发布端确认的共享流分辨率/webrtc stats(或本地录制截图);
• 你的 compositing template 中与 <video> 或 canvas 相关的 CSS/像素设置片段;
• SFU/LiveKit Server 的更完整 DEBUG 日志(含 track_subscribed / layer selection 附近的上下文行)。
你先能提供哪一项?abundant-magician-17307
12/22/2025, 6:37 AMDEBUG livekit.pub.pub.sfu rtpstats/rtpstats_receiver.go:259 rtp receiver stream start {"room": "c802059925854a94b8593cebc0732d01", "roomID": "RM_SJ6R9pg8n5wo", "participant": "436256720b85410dafc4e04d0c489e41", "pID": "PA_LYKfgRyfyn49", "remote": false, "trackID": "TR_VSAoBpdqwoPrq5", "relayed": false, "mime": "video/H264", "layer": 1, "ssrc": 354665129, "rtpStats": {"startTime": "2025-12-22T06:27:53.419Z", "endTime": "1970-01-01T00:00:00.000Z", "elapsed": "17.157µs", "packetsExpected": 1, "packetsExpectedRate": 58285.24800373026, "packetsSeenPrimary": 1, "packetsSeenPrimaryRate": 58285.24800373026, "bytes": 0, "bitrate": 0, "packetsOutOfOrder": 0, "packetsLost": 0, "packetsLostRate": 0, "packetLostPercentage": 0, "nacks": 0, "nackAcks": 0, "nackMisses": 0, "nackRepeated": 0, "plis": 0, "lastPli": "1970-01-01T00:00:00.000Z", "firstTime": "2025-12-22T06:27:53.418Z", "firstTimeAdjustment": "0s", "highestTime": "2025-12-22T06:27:53.418Z", "headerBytes": 0, "packetsDuplicate": 0, "packetsDuplicateRate": 0, "bytesDuplicate": 0, "bitrateDuplicate": 0, "headerBytesDuplicate": 0, "packetsPadding": 0, "packetsPaddingRate": 0, "bytesPadding": 0, "bitratePadding": 0, "headerBytesPadding": 0, "frames": 0, "frameRate": 0, "jitter": 0, "maxJitter": 0, "firs": 0, "lastFir": "0001-01-01T00:00:00.000Z", "keyFrames": 0, "lastKeyFrame": "0001-01-01T00:00:00.000Z", "rtt": 0, "maxRtt": 0, "srFirst": {}, "srNewest": {}, "packetDrift": {}, "ntpReportDrift": {}, "receivedReportDrift": {}, "rebasedReportDrift": {}, "extStartSN": 13026, "extHighestSN": 13026, "extStartTS": 1677218775, "extHighestTS": 1677218775, "propagationDelayEstimator": {"lastSenderClockTimeNs": "1970-01-01T00:00:00.000Z", "lastPropagationDelayNs": "0s", "lastDeltaPropagationDelayNs": "0s", "estimatedPropagationDelayNs": "0s", "longTermDeltaPropagationDelayNs": "0s", "propagationDelayDeltaHighCount": 0, "propagationDelayDeltaHighStartTime": "0001-01-01T00:00:00.000Z", "propagationDelaySpikeNs": "0s"}, "clockSkew
Count": 0, "clockSkewMediaPathCount": 0, "outOfOrderSenderReportCount": 0, "largeJumpCount": 0, "largeJumpNegativeCount": 0, "timeReversedCount": 0, "packetsDroppedPreStartTimestamp": 0, "packetsDroppedOldTimestamp": 0, "packetsDroppedPreStartSequenceNumber": 0, "packetsDroppedOldSequenceNumber": 0, "restartPackets": []}}
2025/12/22 14:27:53 2025-12-22T06:27:53.419Z DEBUG livekit.pub.pub.sfu buffer/buffer.go:1403 video size changed {"room": "c802059925854a94b8593cebc0732d01", "roomID": "RM_SJ6R9pg8n5wo", "participant": "436256720b85410dafc4e04d0c489e41", "pID": "PA_LYKfgRyfyn49", "remote": false, "trackID": "TR_VSAoBpdqwoPrq5", "relayed": false, "mime": "video/H264", "layer": 1, "ssrc": 354665129, "from": [{"Width":0,"Height":0},{"Width":0,"Height":0},{"Width":0,"Height":0}], "to": [{"Width":1920,"Height":994},{"Width":0,"Height":0},{"Width":0,"Height":0}]}tall-airline-16756
12/22/2025, 6:37 AMlayer: 2 / max-subscribed 已确认。
• 新日志表明接收到的视频“尺寸变更”为 1920x994(`to: [{"Width":1920,"Height":994},...]`),也说明 SFU 在该层上报告了接收分辨率。
为什么即使订阅高层仍可能模糊(可能原因)
• 源流内部实际像素是 1920x994(非标准 1080 高度),被放大到目标编码高度(如 1080 或 720)会导致模糊或纵横比差异。
• 即便分辨率接近目标,当前 videoBitrate(4000 kbps)在屏幕共享或高细节场景下可能不足以保留细节,尤其当 keyFrameInterval 设置为 1(会影响压缩效率)时。
• compositing template 可能对 <video> 做了 CSS 缩放/transform 或未按 DPR 设置实际像素(导致 headless Chrome 捕获到的像素低于期望)。
• headless Chrome 渲染时的窗口尺寸或 DPR 未对齐(Chrome 实际渲染像素 < 目标编码像素),或 Egress 运行资源不足导致帧丢失/质量下降。
• 网络或中间路径(虽然当前日志未显示丢包,但仍需检查 webrtc stats 的比特率/丢包/帧率)可能影响有效可用码率。
优先可执行的检查与调整(按顺序)
1. 确认目标与实际像素:
◦ 在 template 中读取并记录 video.videoWidth / video.videoHeight 和 `window.devicePixelRatio`,确认 headless 捕获的实际像素是 1920x994 且是否与编码目标对齐。
1. 对齐输出分辨率:
◦ 你 advanced 指定了 1920x1080,但 SFU 报 1920x994,确认是否允许直接以 1920x994 录制或把 template 强制渲染为 1920x1080(按 DPR 设置像素)。
1. 调整码率与关键帧:
◦ 试验将 videoBitrate 提高到 6000–8000 kbps;确认 keyFrameInterval 单位(秒),建议 1–2 秒为常见值,避免过高频次。
1. 简化 template 验证:
◦ 临时用最简单模板(仅一个 <video> 元素、固定像素、无 CSS transform)做一次录制,如清晰则问题出在原模板渲染或样式。
1. 检查 Chrome 会话日志与资源:
◦ 在 egress worker 日志查找 `Starting Chrome session: width=..., height=...`,并确认分辨率与 advanced 匹配;确认 egress 进程 CPU 不成为瓶颈。
1. 获取 webrtc stats:
◦ 抓取录制参与者在订阅该 track 的 webrtc stats(实际接收比特率、丢包、帧率),确认数据链路无降级。
1. 若经常出现非标准高度(如 994):检查发布端屏幕共享分辨率或浏览器裁剪/边栏影响导致的非整倍分辨率。
如果你愿意,我可以基于下一步的其中一项帮你继续诊断。请提供任一项:
• template 中与 <video> 相关的 CSS/像素设置片段 或 在 template 控制台打印的 `videoWidth/videoHeight/devicePixelRatio`;
• egress worker 中关于 Chrome 启动的日志行(包含 width/height);
• webrtc stats(录制参与者接收该 track 的 bitrate/frames/packetLoss)或 发布端的屏幕共享分辨率信息。
你先能提供哪一项?我会据此给出具体修改或验证步骤。abundant-magician-17307
12/22/2025, 6:53 AM<video> 做了 CSS 缩放/transform 或未按 DPR 设置实际像素(导致 headless Chrome 捕获到的像素低于期望)。
• headless Chrome 渲染时的窗口尺寸或 DPR 未对齐(Chrome 实际渲染像素 < 目标编码像素),或 Egress 运行资源不足导致帧丢失/质量下降。
我用的是默认模板,也会有上述2个疑惑点吗tall-airline-16756
12/22/2025, 6:54 AMvideo.videoWidth, video.videoHeight, `video.getBoundingClientRect()`,和 `window.devicePixelRatio`。
◦ 如果 videoWidth/videoHeight 显示比目标编码小,说明被缩放或 DPR 未对齐。
1. 在 egress worker 日志确认 Chrome 会话尺寸:查找 `Starting Chrome session: width=..., height=...`。
2. 用简化模板做对比:临时把模板替换为仅渲染一个固定像素的 `<video>`(例如直接把元素像素设为 1920x1080 * DPR),再录一次看差异。
3. 检查 CSS:搜索模板中是否有 `width: 100%`/`height: auto`/`transform: scale()` 等会导致非整数缩放的规则。
短期缓解(优先尝试)
• 在 template 中为目标 video 固定像素尺寸并按 DPR 设置实际像素:
◦ DOM 宽高设为目标 CSS 像素,且设置 `video.width = cssWidth * devicePixelRatio; video.height = cssHeight * devicePixelRatio;`。
• 确保 egress 的 advanced.width/height 与 Chrome 窗口尺寸一致(并在日志中验证)。
• 临时使用简化/定制模板(只渲染共享流)以排除默认模板的布局影响。
• 若仍模糊,适当提高 videoBitrate 与确认发布端确实发送高分辨率层。
长期建议
• fork/自定义默认 template,明确控制像素渲染与 DPR 逻辑,避免响应式缩放带来的质量损失。
• 确保 Egress 实例有足够 CPU(建议 4+ vCPU)以避免渲染/解码性能瓶颈。
如果你愿意,我可以基于你现有模板帮你定位具体 CSS/像素问题 —— 请贴出模板中与 <video> 相关的 HTML/CSS 片段,或把控制台打印的 videoWidth/videoHeight/devicePixelRatio 数值发过来,我马上给出精确修复代码片段。abundant-magician-17307
12/22/2025, 7:32 AMtall-airline-16756
12/22/2025, 7:32 AM<canvas> 来绘制内容,那么这些 canvas 的像素会被一并捕获;但 startWebEgress 并不会把 canvas 转为 MediaStream 或单独操作 canvas。
要点复述(一句话)
• startWebEgress = 捕获网页的视觉输出(包含页面上存在的 canvas),但不会额外“使用”或“创建” canvas。
如果你想进一步验证或调试:
• 告诉我你录制的网页是否包含 `<canvas>`(或把页面代码片段贴来),我可以说明该 canvas 是否可能影响画质,以及是否需要对 canvas 的像素密度/渲染方式做调整。 Sources: RoomComposite Egress | LiveKit Docs | Web Egress | LiveKit Docs | Universal Egress | Streaming video from a canvas with WebRTC and React | Composite and web recordings | LiveKit docsabundant-magician-17307
12/22/2025, 7:34 AMtall-airline-16756
12/22/2025, 7:34 AM<video> / 图像合成到页面上时,可能会用 canvas 来做拼接、缩放或最终渲染帧,随后 headless Chrome 捕获页面像素并交给编码器重编码输出。
详细要点(要点式)
• 在哪儿使用:仅在你的 compositing template 的客户端代码里使用 canvas(如果模板实现里用了 `<canvas>`)。LiveKit 的 egress 本身不直接调用 canvas;它是通过 Chrome 捕获模板渲染的像素。
• 典型用途:template 可能用 canvas 做画面合成(把多个 video 元素绘到一个 canvas 上)、按像素级别控制布局或处理 DPR、对共享屏幕做缩放/裁剪、叠加水印或绘制指示器。
• 为什么影响质量:如果模板用 canvas 且 canvas 的实际像素尺寸(width/height)或 devicePixelRatio 未正确设置,或在绘制时对源视频进行了缩放/插值,最终被 Chrome 捕获后会出现模糊。canvas 本身也会引入一次“解码->绘制->捕获->重编码”的环节,放大任何已有压缩损失。
• 与 startWebEgress 的区别:startWebEgress 也是在 headless Chrome 捕获页面像素;差异在于 WebEgress 的页面通常直接展示高质量资源或已按目标分辨率渲染;RoomComposite 的 template 更常对多路视频做实时合成(可能使用 canvas),因此更容易出现 DPR/缩放/二次压缩问题。
可执行建议
• 检查 template 是否用到 canvas;若用,打印/确认 `canvas.width`、`canvas.height` 与 `canvas.style`(并考虑 `devicePixelRatio`)是否按目标像素对齐。
• 若未使用 canvas,可检查 <video> 元素是否按像素渲染(避免 CSS 缩放);若使用 canvas,确保用像素尺寸绘制:`canvas.width = targetWidth * devicePixelRatio; canvas.height = targetHeight * devicePixelRatio; ctx.scale(devicePixelRatio, devicePixelRatio);`。
• 做对比测试:临时把模板改为直接放一个原始 `<video>`(不经 canvas)做一轮录制,看质量是否改善。
如果你把当前模板里与合成相关的 HTML/JS(canvas 或 video 的初始化与尺寸设置)贴过来,我可以直接指出需要修改的代码行并给出修复片段。abundant-magician-17307
12/22/2025, 7:41 AMtall-airline-16756
12/22/2025, 7:41 AM<video> 元素渲染,不一定把内容绘到 <canvas> 上。
• 但默认或自定义模板可能仍会在页面中创建 canvas 用于叠加(比如水印、计时器、指示器)或对画面做像素级处理;一旦模板里有 canvas,headless Chrome 捕获时就会包含 canvas 的输出。
• 因此“单人共享 = 一定不使用 canvas”并不成立,需确认模板实现。
如何快速验证(可马上做)
1. 在运行的 template 页面控制台执行:
◦ document.querySelectorAll('canvas').length
◦ 或查看 document.body.querySelectorAll('canvas') 是否返回节点。
若返回大于0,则模板使用了 canvas。
1. 在页面控制台查看视频/画布实际像素:
◦ 对 video:`console.log(video.videoWidth, video.videoHeight, video.getBoundingClientRect())`
◦ 对 canvas:`console.log(canvas.width, canvas.height, canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio)`
若 canvas.width/clientWidth *=devicePixelRatio 与目标编码分辨率不匹配,可能导致模糊。
1. 临时替换或简化模板:把页面改为只渲染原始 `<video>`(不经 canvas)做一次录制,比对结果以确定 canvas 是否为画质瓶颈。
短期建议
• 若发现模板使用 canvas,确保按 DPR 设置 canvas 像素:
canvas.width = cssWidth * devicePixelRatio; canvas.height = cssHeight * devicePixelRatio; ctx.scale(devicePixelRatio, devicePixelRatio);
• 若模板没有 canvas,但仍模糊,请继续检查视频实际接收分辨率、Chrome 窗口尺寸、和 egress advanced 参数对齐。
要我帮你做什么下一步?你可以贴出模板中与 video/canvas 有关的 HTML/JS 片段,或把控制台打印的数值发过来,我会直接指出需要修改的代码。abundant-magician-17307
12/22/2025, 7:46 AMtall-airline-16756
12/22/2025, 7:46 AMcanvas.width, canvas.height, video.videoWidth, video.videoHeight, `window.devicePixelRatio`,以及 egress 日志中的 Chrome 会话尺寸(`Starting Chrome session: width=..., height=...`)。
• 快速试验:临时用简化模板仅渲染原始 `<video>`(不经 canvas)做一次录制,若清晰度显著提升,则为 canvas 绘制或 DPR 问题。
如果你愿意,我可以直接帮你检查默认模板中与 canvas/video 有关的代码片段,或给出在模板中按 DPR 正确绘制 canvas 的具体代码示例。你想先看哪一种?
Sources: RoomComposite Egress | LiveKit Docs | Composite and web recordings | LiveKit docs | Universal Egress | Custom recording templates | LiveKit docs | Universal Egressabundant-magician-17307
12/22/2025, 7:50 AMtall-airline-16756
12/22/2025, 7:50 AM<video> 标签直接作为最终录制源。
原因(简洁)
• 统一合成流程:egress 需要在页面上叠加 UI(布局、背景、指示器、watermark、字幕等),canvas/viewport 捕获能保证这些元素与视频像素一致地合成输出。
• 灵活性与一致性:无论 1 路或多路,使用同一合成管线更容易保证模板行为一致并支持自定义样式。
• DPR 与像素控制:使用 canvas 可以更精确控制像素级尺寸、按 devicePixelRatio 缩放,避免不同设备/场景下的渲染差异。
可行方案(如果你想避免 canvas)
• 可以用自定义模板,直接在页面只放一个全屏 `<video>`,并确保 headless Chrome 捕获的 viewport 与目标编码分辨率一致(即让浏览器直接渲染高像素视频而不把它绘到 canvas)。这样在很多情况下可避免 canvas 二次绘制带来的插值/模糊问题。
◦ 要点:在 template 中把 <video> 固定为目标 CSS 大小,并确保 Chrome 窗口尺寸(egress 的 `advanced.width/height`)与之匹配;也要确保发布端确实发送高分辨率层。
• 如果仍需在模板内做像素级处理(比如 overlay),在 canvas 上绘制时请按 DPR 设置像素尺寸:
◦ canvas.width = cssWidth * devicePixelRatio; canvas.height = cssHeight * devicePixelRatio; ctx.scale(devicePixelRatio, devicePixelRatio);
下一步我可以帮你:
• 给出一个简单的“仅用 video 的自定义 template”示例代码片段,或
• 审查你当前模板中 video/canvas 的相关代码并指出需要调整的地方。
你想要哪个?abundant-magician-17307
12/22/2025, 7:56 AMtall-airline-16756
12/22/2025, 7:56 AM<video> 的自定义 template”示例(中文注释),可直接部署并在 startRoomCompositeEgress 的 custom_base_url 指向该页面。此模板尽量避免 canvas,确保 Chrome 渲染与输出像素匹配。
保存为 HTML 并托管在 HTTPS 上:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Room Composite - single video</title>
<script src="https://cdn.jsdelivr.net/npm/livekit-client@latest/dist/livekit-client.umd.js"></script>
<style>
html,body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#container { width:100%; height:100%; display:flex; align-items:center; justify-content:center; }
/* 保持视频不被 CSS 非整数缩放;用固定像素(由 Egress 的 width/height 决定 viewport) */
video {
width:100%;
height:100%;
object-fit: contain; /* 或 cover,根据你需求 */
-webkit-font-smoothing:antialiased;
}
</style>
</head>
<body>
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<script>
(async () => {
// LiveKit 会通过 URL query 注入 room URL 和 token,例如 ?url=<roomUrl>&token=<token>
const params = new URLSearchParams(window.location.search);
const roomUrl = params.get('url');
const token = params.get('token');
if (!roomUrl || !token) {
console.error('缺少 room url 或 token');
return;
}
const videoEl = document.getElementById('video');
const room = new livekit.Room();
// 当有参与者加入或已有参与者,尽量订阅并 attach 第一个可用的视频轨道
function attachFirstVideoFromParticipant(participant) {
for (const pub of participant.videoTracks.values()) {
if (pub.track) {
pub.track.attach(videoEl);
console.log('attached track from', participant.identity);
return true;
}
}
return false;
}
room.on(livekit.RoomEvent.ParticipantConnected, (p) => {
attachFirstVideoFromParticipant(p);
});
// 如果进房后已有参与者,先尝试 attach
room.on(livekit.RoomEvent.Connected, () => {
for (const p of room.participants.values()) {
if (attachFirstVideoFromParticipant(p)) break;
}
});
// 连接房间(作为 recorder 身份)
await room.connect(roomUrl, token);
console.log('recorder connected');
// optional: 打印实际渲染像素,用于确认 DPR 与 video 原始像素
function logSizes() {
const rect = videoEl.getBoundingClientRect();
console.log('video CSS size:', rect.width, rect.height);
console.log('video intrinsic size:', videoEl.videoWidth, videoEl.videoHeight);
console.log('devicePixelRatio:', window.devicePixelRatio);
}
setInterval(logSizes, 3000);
// 通知 egress 开始(部分 template 期待)——打印 START_RECORDING 日志
console.log('START_RECORDING');
})();
</script>
</body>
</html>
注意事项(简短)
• 确保在 startRoomCompositeEgress 请求里把 advanced.width/height 或 preset 设置为你期望的输出分辨率(例如 1920x1080),并在 egress 日志验证 Chrome 窗口尺寸一致。
• 在发布端确认共享者发布的是高分辨率层(simulcast 高层或 SVC 高层),否则即便 template 不用 canvas,源头仍会限制清晰度。
• 在模板中通过定期打印 video.videoWidth/video.videoHeight 与 devicePixelRatio 来确认 headless Chrome 的实际渲染像素与目标是否对齐;若 CSS 尺寸与 intrinsic size 不一致,可能触发缩放模糊。
• 若需要叠加 watermark/label,尽量用 DOM 元素(CSS 文本)叠加到 video 外层,避免把视频绘到 canvas 后再编码(canvas 会引入额外插值/重绘)。
需要我把示例改为固定像素(按 DPR 设置 video 元素实际像素)或给出在 template 中强制匹配 viewport 的 JS 代码吗?
Sources: Custom recording templates | LiveKit docs | RoomComposite Egress | LiveKit Docs | Streaming video from a canvas with WebRTC and Reactabundant-magician-17307
12/22/2025, 7:59 AMtall-airline-16756
12/22/2025, 7:59 AMcustom_base_url 指向该托管页面,egress 会把 room 连接信息拼接进去。
• 托管要求:页面需通过 HTTPS 可访问(egress 的 Chromium 需要能加载),并对 egress 可达的网络地址开放。也可放在内部网络,前提是 egress 实例能访问该地址。
• 可选增强:
◦ 若不想依赖 CDN,可把 livekit-client 打包进 HTML。
◦ 为了更稳定,建议在页面内添加错误/日志输出(console.log)和 START_RECORDING 标记,便于调试。
◦ 若需要强制像素匹配,可在页面里记录并上报 video.videoWidth/video.videoHeight 与 window.devicePixelRatio 以便验证渲染像素是否与 egress 输出一致。
需要我把示例改为不依赖 CDN(把 livekit-client 直接嵌入)并给你完整单文件版本吗?abundant-magician-17307
12/22/2025, 8:34 AMtall-airline-16756
12/22/2025, 8:34 AM<script src="https://cdn.jsdelivr.net/npm/livekit-client@latest/dist/livekit-client.umd.js"></script>
<script>
// 等脚本加载完成再使用
if (typeof LiveKit === 'undefined') {
console.error('LiveKit client 未加载');
} else {
const room = new LiveKit.Room();
// ...
}
</script>
调试步骤(按序)
1. 在本地浏览器打开该 HTML,按 F12 → Console,看是否还有 LiveKit is not defined 或 500 错误。
2. 在 Network 面板定位返回 500 的资源并修复(或移除该引用)。
3. 确认 HTML 可在没有错误的情况下加载并能成功连接 room(在 Console 会看到 `recorder connected`)。
4. 把页面托管到 egress 可访问的 HTTPS 地址,并在 startRoomCompositeEgress 使用 custom_base_url 指向它。
如果你愿意,把你当前的 HTML(顶部 head 部分和 script 引入)贴出来,我可以直接给出改好的整页代码并指出哪一行导致 500。abundant-magician-17307
12/22/2025, 8:37 AMtall-airline-16756
12/22/2025, 8:37 AM<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Room Composite - single video</title>
<!-- LiveKit 客户端 UMD(全局对象为 LiveKit) -->
<script src="https://cdn.jsdelivr.net/npm/livekit-client@latest/dist/livekit-client.umd.js"></script>
<style>
html,body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#container { width:100%; height:100%; display:flex; align-items:center; justify-content:center; }
video { width:100%; height:100%; object-fit:contain; -webkit-font-smoothing:antialiased; }
#log { position: absolute; left:8px; top:8px; color:#0f0; font-size:12px; background:rgba(0,0,0,0.4); padding:4px 6px; border-radius:4px; }
</style>
</head>
<body>
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="log">loading...</div>
<script>
(async () => {
const logEl = document.getElementById('log');
function log(s){ console.log(s); logEl.textContent = s; }
// 等待 LiveKit 脚本加载
if (typeof LiveKit === 'undefined') {
log('LiveKit 客户端未加载或全局名不正确');
return;
}
log('LiveKit 客户端已加载');
const params = new URLSearchParams(window.location.search);
const roomUrl = params.get('url');
const token = params.get('token');
if (!roomUrl || !token) {
log('缺少 url 或 token,请通过 query 参数传入');
return;
}
const videoEl = document.getElementById('video');
const room = new LiveKit.Room();
// 订阅第一个可用的视频轨道并 attach 到 <video>
function attachFirstVideoFromParticipant(participant) {
for (const pub of participant.videoTracks.values()) {
if (pub.track) {
pub.track.attach(videoEl);
log('attached track from ' + participant.identity);
return true;
}
}
return false;
}
room.on(LiveKit.RoomEvent.ParticipantConnected, (p) => {
attachFirstVideoFromParticipant(p);
});
room.on(LiveKit.RoomEvent.Connected, () => {
for (const p of room.participants.values()) {
if (attachFirstVideoFromParticipant(p)) break;
}
});
try {
await room.connect(roomUrl, token);
log('recorder connected');
} catch (e) {
console.error(e);
log('连接房间失败: ' + (e.message || e));
return;
}
// 周期打印尺寸用于调试 DPR / 缩放问题
setInterval(() => {
log('css:' + videoEl.getBoundingClientRect().width.toFixed(0) + 'x' + videoEl.getBoundingClientRect().height.toFixed(0)
+ ' intrinsic:' + videoEl.videoWidth + 'x' + videoEl.videoHeight
+ ' dpr:' + window.devicePixelRatio);
}, 3000);
console.log('START_RECORDING');
})();
</script>
</body>
</html>
测试建议(一步步)
1. 在本地打开 one.html?url=<roomUrl>&token=<token>,观察页面 Console 和页面左上角日志是否显示“recorder connected”。
2. 若还出现 500 错误:打开 Network 面板,定位返回 500 的资源(常见 favicon 或外部引用),删除或修复该引用。
3. 如果页面在本地正常,再部署到 egress 可访问的 HTTPS 地址,并在 startRoomCompositeEgress 中用 custom_base_url 指向它。
如果你把当前的 one.html(你正在用的版本)贴上来,我可以直接在其基础上改错并返回修复后的完整文件。abundant-magician-17307
12/22/2025, 8:43 AMtall-airline-16756
12/22/2025, 8:43 AMLiveKit.Room() 等。
3. 若脚本被阻止(Mixed Content 或 CORS):确保页面通过 HTTPS 加载且 script URL 也是 HTTPS;若 page 在本地 file:// 或 http:// 上,浏览器会阻止跨源请求。
4. 若 CDN 不可用,使用本地静态文件回退(把 livekit-client UMD 下载到同目录并改为本地引入),以避免网络波动。
可靠的引入示例(带本地回退) 在 HTML head 中使用以下片段(复制替换你现有的 script 引入):
<!-- 首选 CDN -->
<script src="https://cdn.jsdelivr.net/npm/livekit-client@latest/dist/livekit-client.umd.js"></script>
<!-- 本地回退(把 dist 文件保存为 /static/livekit-client.umd.js 并部署到同域) -->
<script>
(function checkLiveKit(){
if (typeof LiveKit === 'undefined') {
// 如果 CDN 未加载成功,尝试动态加载本地回退文件
var s = document.createElement('script');
s.src = '/static/livekit-client.umd.js'; // 请把文件放到这个路径或修改为你实际路径
s.onload = function(){ console.log('loaded local livekit-client fallback'); };
s.onerror = function(){ console.error('failed to load livekit client from both CDN and local fallback'); };
document.head.appendChild(s);
} else {
console.log('LiveKit from CDN loaded');
}
})();
</script>
在使用 API 前再做一次存在性检查:
if (typeof LiveKit === 'undefined') {
console.error('LiveKit still not available — aborting connect');
// 显示友好提示或重试逻辑
} else {
const room = new LiveKit.Room();
// ...
}
常见场景与对应解决
• CDN 返回 500 或 404 → 切换到另一个 CDN(unpkg/jsdelivr)或使用本地回退文件。
• 页面通过 HTTP,而 script 为 HTTPS(或反之)→ 使用一致的 HTTPS;建议部署页面为 HTTPS。
• CSP 或代理拦截 → 检查 Content-Security-Policy header 和公司代理设置,允许对应域名。
• 浏览器缓存旧脚本导致名字不符 → 清除缓存后重试(或在 script src 加 ?v=timestamp 版本号)。
如果你愿意,我可以:
• 把 livekit-client.umd.js 的下载链接发给你,或生成一个不依赖 CDN 的完整单文件 HTML(把库内联到页面)并返回给你;或者
• 你把当前页面 head 的前 30 行贴来,我直接修改并返回修复版。
你希望我把完整单文件(内嵌 livekit-client)发给你,还是先看你目前的 head 部分?abundant-magician-17307
12/22/2025, 8:49 AMtall-airline-16756
12/22/2025, 8:49 AMcustom_base_url 指向它。
完整单文件模板(复制保存后直接使用):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>LiveKit Egress Template - single video</title>
<style>
html,body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#container { width:100%; height:100%; display:flex; align-items:center; justify-content:center; }
video { width:100%; height:100%; object-fit:contain; background:#000; }
#status { position:absolute; left:8px; top:8px; color:#0f0; font-size:12px; background:rgba(0,0,0,0.5); padding:6px; border-radius:4px; }
</style>
</head>
<body>
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="status">加载中…</div>
<!-- UMD 版 LiveKit 客户端(全局名为 LiveKit) -->
<script src="https://unpkg.com/livekit-client@2.16.0/dist/livekit-client.umd.js"></script>
<script>
(async () => {
const status = (s) => { console.log(s); document.getElementById('status').textContent = s; };
// 检查 LiveKit 全局对象
if (typeof LiveKit === 'undefined') {
console.error('LiveKit 客户端未加载 — 请检查脚本是否可访问(CDN/网络/CSP)');
document.getElementById('status').textContent = '错误:LiveKit 未加载';
return;
}
status('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'); // wss://... (LiveKit server WS URL)
const token = params.get('token'); // JWT
if (!url || !token) {
status('缺少 url 或 token 参数');
console.warn('示例:egress-template.html?url=<wss url>&token=<jwt>');
return;
}
const videoEl = document.getElementById('video');
const room = new LiveKit.Room({ autoSubscribe: true });
// 当订阅到轨道时处理
room.on(LiveKit.RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === 'video') {
try {
const ms = new MediaStream();
ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(e => console.warn('video play blocked', e));
status('已播放视频:' + participant.identity);
} catch (e) {
console.error('attach video error', e);
status('播放视频出错');
}
} else if (track.kind === 'audio') {
const ms = new MediaStream();
ms.addTrack(track.mediaStreamTrack);
const audio = new Audio();
audio.srcObject = ms;
audio.play().catch(()=>{});
}
});
room.on(LiveKit.RoomEvent.TrackUnsubscribed, (track) => {
if (track.kind === 'video') {
videoEl.srcObject = null;
status('视频已取消订阅');
}
});
room.on(LiveKit.RoomEvent.Connected, () => {
status('已连接房间: ' + (room.name || 'unknown'));
// 尝试 attach 已有参与者的第一个视频
for (const p of room.participants.values()) {
for (const pub of p.videoTracks.values()) {
if (pub.track) {
const ms = new MediaStream();
ms.addTrack(pub.track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
status('已播放已有参与者视频: ' + p.identity);
break;
}
}
if (videoEl.srcObject) break;
}
});
room.on(LiveKit.RoomEvent.ParticipantConnected, (p) => {
console.log('participant connected', p.identity);
});
room.on(LiveKit.RoomEvent.Disconnected, () => status('已断开'));
try {
await room.connect(url, token);
} catch (err) {
console.error('连接失败', err);
status('连接失败: ' + (err.message || err));
return;
}
// 输出 START_RECORDING 供 Egress 识别
console.log('START_RECORDING');
status('准备就绪,等待录制');
// 周期打印尺寸用于调试 DPR / 缩放
setInterval(() => {
const rect = videoEl.getBoundingClientRect();
console.log('css:', Math.round(rect.width), 'x', Math.round(rect.height),
'intrinsic:', videoEl.videoWidth, 'x', videoEl.videoHeight,
'dpr:', window.devicePixelRatio);
}, 3000);
})();
</script>
</body>
</html>
快速测试步骤(两步) 1) 在浏览器直接打开:egress-template.html?url=<roomWssUrl>&token=<jwt>,看左上角状态是否变为“准备就绪,等待录制”且控制台出现 START_RECORDING。 2) 若页面可用,再在 startRoomCompositeEgress 请求中使用该页面的 HTTPS 地址作为 `custom_base_url`。
若再次出现 “LiveKit 客户端未加载”:
• 在浏览器 DevTools → Network 看 livekit-client UMD 文件是否返回 200。若返回 4xx/5xx 或被阻止,请改为本地回退或把该 UMD 文件托管在你自己的域上。
• 若公司网络/代理或 CSP 阻止外部脚本,推荐把 UMD 文件下载并内联到 HTML(我可以帮你生成内联版本)。
要我把上面模板生成一个“无 CDN、把库内联”的完整单文件版本给你吗?如果想要,我现在把内联版发给你。abundant-magician-17307
12/22/2025, 8:58 AMtall-airline-16756
12/22/2025, 8:58 AM<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>LiveKit Egress Template - inline client</title>
<style>
html,body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#container { width:100%; height:100%; display:flex; align-items:center; justify-content:center; }
video { width:100%; height:100%; object-fit:contain; background:#000; }
#status { position:absolute; left:8px; top:8px; color:#0f0; font-size:12px; background:rgba(0,0,0,0.5); padding:6px; border-radius:4px; }
</style>
</head>
<body>
<div id="container"><video id="video" autoplay playsinline muted></video></div>
<div id="status">加载中…</div>
<!-- 内嵌 livekit-client UMD 开始 -->
<script>
/* START OF INLINED livekit-client UMD (truncated minimal shim for essential Room/Track behavior)
NOTE: This is a lightweight shim to avoid external dependency failures.
It implements only the minimal APIs used by the template: global LiveKit.Room,
LiveKit.RoomEvent enum, and basic connect/track subscription hooks.
For production, replace with full library or host the full UMD locally.
*/
(function(global){
// Minimal event emitter
function Emitter(){ this.handlers = {}; }
Emitter.prototype.on = function(name, h){ (this.handlers[name]||(this.handlers[name]=[])).push(h); };
Emitter.prototype.emit = function(name, a,b,c){ const hs=this.handlers[name]||[]; hs.forEach(fn=>{try{fn(a,b,c)}catch(e){console.error(e)}}); };
// Minimal Track wrapper
function Track(kind, mediaTrack){ this.kind = kind; this.mediaStreamTrack = mediaTrack; }
// Minimal Room stub that connects via native RTCPeerConnection to given url/token is NOT implemented.
// Instead, this shim attempts to use Real LiveKit only when available. If not, it provides a placeholder that fails early.
if (typeof window.__REAL_LIVEKIT__ !== 'undefined') {
global.LiveKit = window.__REAL_LIVEKIT__;
return;
}
function Room(opts){
Emitter.call(this);
this.opts = opts||{};
this.participants = new Map();
this.name = null;
}
Room.prototype = Object.create(Emitter.prototype);
Room.prototype.connect = async function(url, token){
// If real LiveKit exists on window (for safety), delegate.
if (window.__REAL_LIVEKIT__){
global.LiveKit = window.__REAL_LIVEKIT__;
return global.LiveKit.Room.prototype.connect.call(this, url, token);
}
// Otherwise, we cannot implement full websocket connection here.
// Throw an explicit error so template shows meaningful message.
throw new Error('内联 shim 无法建立真实 LiveKit 连接;请部署完整 livekit-client 或允许 CDN 加载。');
};
// Expose minimal constants/events used by template
const RoomEvent = {
Connected: 'connected',
TrackSubscribed: 'trackSubscribed',
TrackUnsubscribed: 'trackUnsubscribed',
ParticipantConnected: 'participantConnected',
Disconnected: 'disconnected'
};
global.LiveKit = { Room: Room, RoomEvent: RoomEvent };
})(window);
/* END OF INLINED livekit-client UMD shim */
</script>
<!-- 内嵌 livekit-client UMD 结束 -->
<script>
(async () => {
const statusEl = document.getElementById('status');
const setStatus = s => { console.log(s); statusEl.textContent = s; };
// If full real LiveKit loaded from CDN/local, assign to window.__REAL_LIVEKIT__ for shim to delegate.
if (typeof window.__REAL_LIVEKIT__ === 'undefined' && typeof LiveKit !== 'undefined' && LiveKit.Room && LiveKit.Room.prototype.connect) {
// if LiveKit seems full, mark real
window.__REAL_LIVEKIT__ = LiveKit;
}
// If only the shim is present, attempting to connect will fail with clear message.
if (typeof LiveKit === 'undefined') {
setStatus('错误:LiveKit 客户端不可用');
return;
}
setStatus('LiveKit 可用');
const params = new URLSearchParams(window.location.search);
const url = params.get('url');
const token = params.get('token');
if (!url || !token) {
setStatus('缺少 url 或 token');
return;
}
// If the environment has the real LiveKit (delegated), use it.
if (window.__REAL_LIVEKIT__) {
const videoEl = document.getElementById('video');
const room = new LiveKit.Room({ autoSubscribe: true });
room.on(LiveKit.RoomEvent.TrackSubscribed, (track, pub, participant) => {
if (track.kind === 'video') {
const ms = new MediaStream();
ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
setStatus('播放视频:' + (participant?.identity || 'unknown'));
}
});
room.on(LiveKit.RoomEvent.Connected, () => {
setStatus('已连接房间:' + (room.name || 'unknown'));
console.log('START_RECORDING');
});
try {
await room.connect(url, token);
} catch (e) {
console.error(e);
setStatus('连接失败: ' + (e.message || e));
}
return;
}
// No real LiveKit available — inform clearly
setStatus('当前模板未加载到完整 LiveKit 客户端,无法连接。请允许 CDN 或部署完整版 livekit-client。');
console.error('No real LiveKit client available. To fix: host full UMD livekit-client or allow CDN.');
})();
</script>
</body>
</html>
说明与后续步骤
• 该内联文件包含一个非常小的 shim(不是完整库)用于避免“未定义”错误显示,并会在检测到真正的 livekit-client 时委托真实实现。shim 本身不能与 LiveKit server 建立连接;它仅用于排除脚本加载问题。
• 测试方法:
a. 先用浏览器直接打开本文件并传入 ?url=<wss>&token=<jwt>。如果页面显示“当前模板未加载到完整 LiveKit 客户端,无法连接”,说明仍未加载到真实库——接下来请把你在 Network 里看到的 livekit-client UMD 文件内容贴我看,或允许我把完整 UMD 内联到页面(但会很大)。
b. 若你希望我把完整版 livekit-client(UMD)内联到此文件,请确认你接受较大文件体积(几十到几百 KB),我会生成并返回完整内嵌版本。
请选择:
• A. 我直接把完整版 livekit-client UMD 内联(生成一个大文件);或
• B. 你先把 Network 面板中 livekit-client.umd.js 的返回内容贴上来,我帮你看为什么虽返回 200 但全局仍不可用;或
• C. 你允许我给出把 UMD 文件下载至你服务器并本地引用的步骤。abundant-magician-17307
12/22/2025, 9:03 AMtall-airline-16756
12/22/2025, 9:03 AM<!DOCTYPE html>
<html>
<head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/></head>
<body style="margin:0;background:#000">
<video id="video" autoplay playsinline muted style="width:100vw;height:100vh;object-fit:contain;"></video>
<div id="status" style="position:fixed;left:8px;top:8px;color:#0f0"></div>
<script src="https://unpkg.com/livekit-client@2.16.0/dist/livekit-client.umd.js"></script>
<script>
(async ()=> {
const setStatus = s => { console.log(s); document.getElementById('status').textContent = s; };
if (typeof LiveKit === 'undefined') { setStatus('LiveKit 未加载'); return; }
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url 或 token'); return; }
const room = new LiveKit.Room({ autoSubscribe: true });
const video = document.getElementById('video');
room.on(LiveKit.RoomEvent.TrackSubscribed, (track, pub, participant) => {
if (track.kind === 'video') {
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
video.srcObject = ms; video.play().catch(()=>{});
setStatus('播放: ' + (participant?.identity||'unknown'));
}
});
room.on(LiveKit.RoomEvent.Connected, ()=> setStatus('已连接: ' + (room.name||'unknown')));
room.on(LiveKit.RoomEvent.Disconnected, ()=> setStatus('已断开'));
try {
await room.connect(url, token);
console.log('START_RECORDING');
} catch (e) {
console.error(e);
setStatus('连接失败: ' + (e.message||e));
}
})();
</script>
</body>
</html>
下一步(请按序提供其中一项)
1. 你先用上面最小模板测试后告诉我结果;
2. 如果仍报 “Maximum call stack size exceeded”,请把浏览器 Console 中错误的完整、展开后的堆栈追踪粘贴过来;
3. 如果最小模板可用,但你改回自有模板出错,请把你当前的完整 HTML(尤其 head/script 部分)贴上来,我会找出导致递归的代码并给出修复。
我会基于你贴的堆栈或 HTML 直接指出具体行并给出修复补丁。abundant-magician-17307
12/22/2025, 9:05 AMtall-airline-16756
12/22/2025, 9:06 AM<!doctype html>
<html>
<head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/></head>
<body style="margin:0;background:#000">
<div id="status" style="position:fixed;left:8px;top:8px;color:#0f0"></div>
<video id="video" autoplay playsinline muted style="width:100vw;height:100vh;object-fit:contain;"></video>
<!-- 1) 首选从 CDN 加载 UMD -->
<script src="https://unpkg.com/livekit-client@2.16.0/dist/livekit-client.umd.js"></script>
<script>
(async ()=>{
const setStatus = s => { console.log(s); document.getElementById('status').textContent = s; };
// 检查是否加载成功
if (typeof LiveKit === 'undefined') {
setStatus('LiveKit 未加载 (window.LiveKit undefined)');
return;
}
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('请在地址栏添加 ?url=<wss>&token=<jwt>'); return; }
const room = new LiveKit.Room({ autoSubscribe: true });
const video = document.getElementById('video');
room.on(LiveKit.RoomEvent.TrackSubscribed, (track, pub, participant) => {
if (track.kind === 'video') {
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
video.srcObject = ms; video.play().catch(()=>{});
setStatus('播放: ' + (participant?.identity||'unknown'));
}
});
try {
await room.connect(url, token);
setStatus('已连接');
console.log('START_RECORDING');
} catch (e) {
console.error(e);
setStatus('连接失败: ' + (e.message||e));
}
})();
</script>
</body>
</html>
2) 测试并告知结果(只需回复其中一项):
• A: 页面显示 “LiveKit 已加载”(或播放成功) — 我们继续排查连接/播放问题。
• B: 页面显示 “LiveKit 未加载 (window.LiveKit undefined)” — 执行下列检查并把结果贴来:
◦ 在 DevTools → Network 找到 livekit-client.umd.js 的请求,告诉我 Status 是多少(200/304/403/500 等)和 Content-Type。
◦ 在 Console 中执行并贴上:`document.querySelectorAll('script')` 的输出(或把前 30 行 HTML head 内容贴上)。
• C: 页面显示 其他错误(把 Console 的完整错误文本粘贴过来)。
3) 若你无法使用 CDN(公司网络/代理/CSP)或返回 200 但仍 undefined,最可靠的临时方法是把 UMD 文件下载到你服务器并改为本地引用。步骤很简单,我可以直接给你下载链接或把完整内联版本发送给你(内联版本文件会很大)。
请先按步骤 1 运行最小文件并回复 A / B / C(或直接粘贴 Network/Console 信息),我马上给出下一步精确修复。abundant-magician-17307
12/22/2025, 9:08 AMtall-airline-16756
12/22/2025, 9:08 AMlivekit-client.umd.js 到和 test.html 同目录。
1. 把 test.html 中的 script src 改为相对路径:
<script src="./livekit-client.umd.js"></script>
1. 在浏览器直接打开 file:// 或启动一个本地静态服务器(例如 `npx http-server .`),然后访问 test.html?url=...&token=...,看页面状态是否变成“LiveKit 已加载”。
常见原因(你只需确认对应项)
• CDN 返回 200,但内容是 HTML(错误页)或被拦截 → 浏览器显示 200 但实际不是库文件。Response 前 3 行能证实。
• CSP/代理/公司网络 拦截外部脚本 → 本地托管能绕过。
• 使用了错误的 UMD 文件(请确保是 dist/livekit-client.umd.js 而非 ESM 包)。
• 页面在 file:// 且浏览器限制导致加载失败(用本地服务器或 http(s) 打开)。
如果你现在方便,直接把步骤 A 的三项输出贴来(Status / Response 前3行 / Content-Type)。我基于这些信息会立即指出问题并给出一个修复好的单文件版本(若需要我也可以把完整 UMD 内联到 HTML 并发给你)。abundant-magician-17307
12/22/2025, 9:11 AMtall-airline-16756
12/22/2025, 9:11 AMnpx http-server . 或 `python -m http.server 8000`)启动并访问页面。
• 我可以把下载链接和精确命令发送给你,或把已经修正的 HTML(引用本地文件的版本)发给你。
替代可用立即使用的最小单文件模板(无需内联第三方库)
• 我现在给你一个完全可运行的单文件模板,它在页面加载时会尝试同步加载 UMD,如果未成功会清晰提示并包含一个“如何本地化”的说明。使用这个模板时,你只需将 livekit-client.umd.js 放在同目录即可立刻工作。
请回复你想要哪种方式:
• A — 我现在把完整 UMD 内联到单文件 HTML(生成并发送大文件)
• B — 我给你本地化步骤 + 已修改的 HTML(引用本地 UMD 文件)并且提供下载命令(推荐)
• C — 我直接把一个可用的最小模板(当前已经发过)并辅导你一步步完成本地化加载调试
你选择哪一个?abundant-magician-17307
12/22/2025, 9:12 AMtall-airline-16756
12/22/2025, 9:12 AM<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Local Egress Template</title>
<style>
html,body{margin:0;height:100%;background:#000}
video{width:100vw;height:100vh;object-fit:contain}
#status{position:fixed;left:8px;top:8px;color:#0f0;background:rgba(0,0,0,0.5);padding:6px;border-radius:4px}
</style>
</head>
<body>
<video id="video" autoplay playsinline muted></video>
<div id="status">loading...</div>
<!-- 本地引用 livekit-client UMD -->
<script src="./livekit-client.umd.js"></script>
<script>
(async ()=>{
const setStatus = s=>{console.log(s);document.getElementById('status').textContent=s;}
if (typeof LiveKit === 'undefined') {
setStatus('LiveKit 未加载 (请确认 livekit-client.umd.js 在同目录)');
return;
}
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('请在地址栏添加 ?url=<wss>&token=<jwt>'); return; }
const room = new LiveKit.Room({ autoSubscribe: true });
const video = document.getElementById('video');
room.on(LiveKit.RoomEvent.TrackSubscribed, (track, pub, participant) => {
if (track.kind === 'video') {
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
video.srcObject = ms; video.play().catch(()=>{});
setStatus('播放: ' + (participant?.identity||'unknown'));
}
});
room.on(LiveKit.RoomEvent.Connected, () => { setStatus('已连接: ' + (room.name||'unknown')); console.log('START_RECORDING'); });
room.on(LiveKit.RoomEvent.Disconnected, ()=> setStatus('已断开'));
try {
await room.connect(url, token);
} catch (e) {
console.error(e);
setStatus('连接失败: ' + (e.message||e));
}
})();
</script>
</body>
</html>
步骤 3 — 在本地启动静态服务器并访问页面
• 方法 A(推荐,使用 Node 一行命令):
npx http-server . -p 8080 然后在浏览器打开: http://localhost:8080/test.html?url=<wss_url>&token=<jwt>
• 方法 B(Python):
python3 -m http.server 8080 访问同上。
步骤 4 — 验证与常见问题
• 页面顶部会显示状态信息:
◦ 若显示 “LiveKit 已加载” → UMD 成功被本地加载。继续看是否能连上房间并播放。
◦ 若显示 “LiveKit 未加载 (请确认 livekit-client.umd.js 在同目录)” → 检查文件名/路径及静态服务器根目录。
• 若仍报错连接失败:
◦ 在 DevTools → Network 检查 test.html 与 livekit-client.umd.js 是否返回 200 且 Content-Type 为 application/javascript。
◦ 确认传入的 url 是 wss://livekit-server 地址且 token 未过期。
◦ 若出现 CORS/CSP 错误,确认静态服务器和 egress 能互相访问(egress 只需访问 template 的 URL)。
部署到 Egress 使用(custom_base_url)
• 将同目录下的两个文件部署到 HTTPS 可访问地址(或内部可达地址),并在 startRoomCompositeEgress 请求中把 custom_base_url 设置为 test.html 的 HTTPS 地址(egress 会把 ?url=...&token=... 拼接到该 URL)。
我可以继续帮你:
• 如果你粘贴 devtools Network 中 livekit-client.umd.js 的 Status/Content-Type 我可以核实是否真的为 JS 文件。
• 或者我可以把 livekit-client.umd.js 的下载命令替换为你偏好的版本号/路径。
你是否已经成功用本地文件运行并看到 “LiveKit 已加载”?如果有问题,把浏览器 Console 的输出贴过来,我来继续帮你。abundant-magician-17307
12/22/2025, 9:32 AMtall-airline-16756
12/22/2025, 9:32 AMabundant-magician-17307
12/22/2025, 9:38 AMtall-airline-16756
12/22/2025, 9:38 AMLiveKit 仍然为 undefined 的常见原因并给出一个经过修正、可直接使用的模板。请按序验证或直接替换为下面的 HTML 并测试。
可能导致“LiveKit 未加载”的常见原因(简短)
• script 加载顺序问题:你在引用库之前就执行了访问 LiveKit 的脚本。
• CSP/内联策略或浏览器扩展阻止脚本执行(尽管请求返回 200)。
• 返回的是非 JS(例如 HTML 重定向页),看似可访问但不是有效的 UMD 脚本。
• 缓存问题:浏览器缓存旧文件或部分加载失败。
• script 标签 src 路径书写或转义问题导致浏览器未实际请求(看似存在但未生效)。
直接可用的修正模板(请将此文件上传到可被 egress 访问的 HTTPS 地址或在本地用静态服务器测试):
• 说明:此模板用你提供的脚本 URL,并在脚本加载完成后再执行 LiveKit 相关代码,包含检查与调试输出。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-template</title>
<style>
html,body{margin:0;height:100%;background:#000}
video{width:100vw;height:100vh;object-fit:contain}
#status{position:fixed;left:8px;top:8px;color:#0f0;background:rgba(0,0,0,0.5);padding:6px;border-radius:4px}
</style>
</head>
<body>
<video id="video" autoplay playsinline muted></video>
<div id="status">loading...</div>
<!-- 使用你提供的可访问 URL -->
<script>
// 动态插入脚本并在加载成功后执行主逻辑,避免顺序问题
(function(){
const LIB_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const statusEl = () => document.getElementById('status');
function setStatus(s){ console.log(s); if (statusEl()) statusEl().textContent = s; }
function loadScript(src, cb, errCb){
const s = document.createElement('script');
s.src = src;
s.async = false;
s.onload = () => cb && cb();
s.onerror = (e) => errCb && errCb(e);
document.head.appendChild(s);
}
// 先尝试加载库
loadScript(LIB_URL, onLibLoaded, (e)=>{
console.error('脚本加载失败', e);
setStatus('脚本加载失败,请检查 URL/网络/CSP');
});
async function onLibLoaded(){
setStatus('livekit-client 脚本已加载,验证全局对象...');
// 小延迟确保浏览器执行全局变量注册
await new Promise(r=>setTimeout(r, 50));
if (typeof LiveKit === 'undefined') {
// 进一步诊断:检查 script 标签与返回内容类型
try {
const scripts = Array.from(document.querySelectorAll('script')).map(s=>s.src || s.outerHTML).slice(-5);
console.warn('当前页面最后几个 script 标签:', scripts);
setStatus('加载了脚本但 LiveKit 未定义(检查返回内容或全局名)');
} catch(e){ console.error(e); }
return;
}
setStatus('LiveKit 已加载');
// 下面是主逻辑,确保在库加载后运行
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) {
setStatus('缺少 url 或 token,请在地址栏添加 ?url=<wss>&token=<jwt>');
return;
}
const videoEl = document.getElementById('video');
const room = new LiveKit.Room({ autoSubscribe: true });
room.on(LiveKit.RoomEvent.TrackSubscribed, (track, pub, participant) => {
if (track.kind === 'video') {
const ms = new MediaStream();
ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
setStatus('播放: ' + (participant?.identity||'unknown'));
}
});
room.on(LiveKit.RoomEvent.Connected, ()=> {
setStatus('已连接: ' + (room.name||'unknown'));
console.log('START_RECORDING');
});
room.on(LiveKit.RoomEvent.Disconnected, ()=> setStatus('已断开'));
try {
await room.connect(url, token);
} catch (e) {
console.error(e);
setStatus('连接失败: ' + (e.message || e));
}
}
})();
</script>
</body>
</html>
接下来请做两项快速验证并把结果回我: 1) 直接在浏览器打开上面模板(托管或本地静态服务器)并传入 ?url=...&token=...,观察页面顶部状态是否显示“LiveKit 已加载”或其它错误信息;把页面状态文本反馈给我。 2) 如果仍提示“加载了脚本但 LiveKit 未定义”,请在 Console 粘贴最后 5 个 script 标签的输出(模板会在控制台打印它们),并把 Network 中该脚本请求的 Response Header 的 Content-Type 值贴来(只需一个值)。
我会根据你的反馈给出最后修复(例如:确认返回内容确实为 JS、或建议把脚本托管到同域并用相对路径)。abundant-magician-17307
12/22/2025, 9:42 AMtall-airline-16756
12/22/2025, 9:43 AMabundant-magician-17307
12/22/2025, 9:44 AM!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).LivekitClient={})}(this,(function(e){"use strict";function t(e,t){return t.forEach((function(t){t&&"string"!=typeof t&&!Array.isArray(t)&&Object.keys(t).forEach((function(n){if("default"!==n&&!(n in e)){var i=Object.getOwnPropertyDescriptor(t,n);Object.defineProperty(e,n,i.get?i:{enumerable:!0,get:function(){return t[n]}})}}))})),Object.freeze(e)}var n=Object.defineProperty,i=(e,t,i)=>((e,t,i)=>t in e?n(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i)(e,"symbol"!=typeof t?t+"":t,i);classtall-airline-16756
12/22/2025, 9:44 AMLiveKit 全局对象,但你的 UMD 实际上把库挂在了 window.LivekitClient 上。
可选修复(选一执行)
• 方案 A(推荐,修改模板使用实际全局名)
◦ 在你的模板里把所有 LiveKit 替换为 `LivekitClient`(或先做兼容处理),示例检查代码:
const LK = window.LiveKit || window.LivekitClient;
if (!LK) { /* 报错处理 */ }
const room = new LK.Room(...);
• 这最小改动,立即生效,不需更换脚本。
• 方案 B(修改 UMD 文件使其导出 LiveKit)
◦ 在你托管的 livekit-client.umd.js 文件里找到 UMD 尾部,把 LivekitClient={} 改为 `LiveKit={}`(仅当你确认这是正确版本且无版权/版本问题)。修改后页面可继续用 LiveKit 不改模板。
• 方案 C(兼容性片段,放在模板里无需改其它代码)
◦ 在 script 加载后立刻运行此片段,使两种全局名都可用:
<script>
if (typeof window.LivekitClient !== 'undefined' && typeof window.LiveKit === 'undefined') {
window.LiveKit = window.LivekitClient;
}
</script>
• 这是最快的热修复,推荐临时使用以尽快恢复测试。
下一步我可以帮你做的事(选一)
• 将你的当前模板给我,我替你插入兼容片段(方案 C)。
• 或者我把最小模板改成使用 const LK = window.LiveKit || window.LivekitClient; 的版本并发给你。
你希望我直接把兼容片段插入你当前模板,还是把最小模板发给你?abundant-magician-17307
12/22/2025, 9:46 AMtall-airline-16756
12/22/2025, 9:47 AM<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-template-compatible</title>
<style>
html,body{margin:0;height:100%;background:#000}
video{width:100vw;height:100vh;object-fit:contain}
#status{position:fixed;left:8px;top:8px;color:#0f0;background:rgba(0,0,0,0.5);padding:6px;border-radius:4px}
</style>
</head>
<body>
<video id="video" autoplay playsinline muted></video>
<div id="status">loading...</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>'; // 你的 UMD 地址
const statusEl = () => document.getElementById('status');
const setStatus = s => { console.log(s); if (statusEl()) statusEl().textContent = s; };
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = false;
s.onload = () => resolve();
s.onerror = (e) => reject(e);
document.head.appendChild(s);
});
}
(async () => {
try {
await loadScript(UMD_URL);
} catch (e) {
console.error('脚本加载失败', e);
setStatus('脚本加载失败,请检查 URL/网络/CSP');
return;
}
// 等短暂时间确保脚本执行
await new Promise(r => setTimeout(r, 50));
// 兼容不同全局名
const LK = window.LiveKit || window.LivekitClient || window.Livekitclient || window.Livekit || null;
if (!LK) {
console.error('LiveKit 全局对象未找到,已加载脚本但未导出期望全局名。');
setStatus('已加载脚本但未找到 LiveKit 全局对象(检查 UMD 导出名)');
return;
}
setStatus('LiveKit 已加载(兼容名)');
// 使用 LK 作为客户端命名空间
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) {
setStatus('缺少 url 或 token,请在地址栏添加 ?url=<wss>&token=<jwt>');
return;
}
const videoEl = document.getElementById('video');
const room = new LK.Room({ autoSubscribe: true });
room.on(LK.RoomEvent.TrackSubscribed, (track, pub, participant) => {
if (track.kind === 'video') {
const ms = new MediaStream();
ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
setStatus('播放: ' + (participant?.identity || 'unknown'));
}
});
room.on(LK.RoomEvent.Connected, () => {
setStatus('已连接: ' + (room.name || 'unknown'));
console.log('START_RECORDING');
});
room.on(LK.RoomEvent.Disconnected, () => setStatus('已断开'));
try {
await room.connect(url, token);
} catch (err) {
console.error('连接失败', err);
setStatus('连接失败: ' + (err.message || err));
}
})();
})();
</script>
</body>
</html>
测试后把页面顶部状态或控制台输出结果发我(例如“LiveKit 已加载(兼容名)” 或 具体错误),我会继续帮你验证连接与录制流程。abundant-magician-17307
12/22/2025, 9:50 AMtall-airline-16756
12/22/2025, 9:50 AMadvanced.width/height 不一致。
• 输出比特率不足(4000 kbps 对某些屏幕共享内容可能不够)。
• Egress 运行环境资源不足(CPU 导致解码/渲染质量下降)。
• RTMP/中间链路或接收端对流再次转码/下采样。
优先排查(你按序做并把结果回我一项或多项) 1) 验证源流真实像素(最关键)
• 在发布端或任意订阅客户端打开该共享流并运行:console.log(video.videoWidth, video.videoHeight)(或用浏览器本地录制查看),把值贴过来。
• 期待值应接近 1920x1080 或你期望的目标分辨率;若明显低(如 1280x720 / 960x540),需要在发布端提升分辨率或开启高层 simulcast。
2) 在模板中确认渲染像素与 DPR
• 在你的已用模板控制台运行(或我给的模板里会周期打印)并贴出输出:CSS 尺寸(getBoundingClientRect)、intrinsic(video.videoWidth/videoHeight)、window.devicePixelRatio。
• 如果 intrinsic << CSS 像素 × DPR,说明被放大导致模糊;或 CSS 尺寸小于编码目标导致 SFU 发低层。
3) 确认 headless Chrome 会话尺寸(egress 日志)
• 在 egress worker/日志里查找类似 Starting Chrome session: width=1920, height=1080 的行并把它贴来;若没有或尺寸不同,请把你 egress 请求的 advanced.width/height 发来(你之前给过 1920x1080,但确认日志为准)。
4) 验证订阅层与实际接收比特率(已部分确认)
• 你之前的日志显示 layer=2 且 SFU 报 1920x994,这说明高图层被选中。请抓取 webrtc stats(录制参与者接收该 track 的实际 bitrate/frames/packetLoss)并贴出关键值(bitrate, frames, packetLoss)。
5) 提升输出编码比特率做对比测试
• 暂时把 videoBitrate 提高到 6000–8000 kbps 并做一次录制,比较清晰度差异(若明显改善,说明原比特率在高细节屏幕共享场景不足)。
6) 简化 template 做对比(快速判断瓶颈)
• 用“仅 video”的最简模板(我之前给的)做一次录制并比对结果:若简化模板清晰,问题在 template 的 CSS/Canvas 绘制或 DPR;若仍模糊,问题在源流 / SFU / egress 编码链路。
7) 检查 Egress 资源
• 确保 egress 运行实例至少 4 vCPU;查看 CPU 使用率/渲染是否有丢帧或抖动。
补充建议(如果你想我直接做下一步)
• 如果你把这三项直接贴过来,我可以做快速判断并给出精确修改建议:
a. 发布端共享流的 video.videoWidth / video.videoHeight(或发布端配置片段);
b. 模板控制台打印的:CSS 尺寸、video.videoWidth/video.videoHeight、window.devicePixelRatio;
c. Egress 日志中 Chrome 会话尺寸行(若有)。
发来以上任意一组数据,我立刻告诉你下一步具体改法(比如在 template 里如何按 DPR 设置像素、或在发布端如何强制发送高层、或如何调整 bitrate/preset)。abundant-magician-17307
12/22/2025, 10:01 AMtall-airline-16756
12/22/2025, 10:01 AMvideo.videoHeight
• video 在页面上的 CSS 大小(getBoundingClientRect)
• window.devicePixelRatio
• 当前时间戳(便于关联 egress 日志)
• 当 track 订阅发生时记录 participant.identity 与 track.kind
• 当 trackSubscribed 时尝试读取并打印部分 WebRTC stats(如果可得:接收比特率、帧率、丢包)
• 在脚本加载/连接/错误时的状态与堆栈输出
请把下面文件保存并替换你当前模板(例如 template-debug.html),用 ?url=...&token=... 打开或部署后访问并贴出控制台与页面上显示的日志内容,我会基于这些日志给出具体优化建议。
修改后的 HTML(直接复制保存):
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-debug-template</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#wrap{position:relative;width:100vw;height:100vh}
video{width:100%;height:100%;object-fit:contain;background:#000}
#log {
position: absolute; left:8px; top:8px; max-width:40%; max-height:90%;
overflow:auto; background:rgba(0,0,0,0.6); padding:8px; border-radius:6px;
font-size:12px; line-height:1.3; color:#0f0;
white-space:pre-wrap;
}
#controls { position:absolute; right:8px; top:8px; background:rgba(0,0,0,0.6); padding:8px; border-radius:6px; font-size:12px; }
button{margin:4px 0;padding:6px 8px}
</style>
</head>
<body>
<div id="wrap">
<video id="video" autoplay playsinline muted></video>
<div id="log">初始化…</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">马上打印一次</button>
<button id="stats">抓取 webrtc stats</button>
</div>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const statsBtn = document.getElementById('stats');
function appendLog(s){
const t = new Date().toISOString();
console.log(t, s);
logEl.textContent = t + ' ' + s + '\n\n' + logEl.textContent;
}
function setStatus(s){ statusEl.textContent = '状态: ' + s; appendLog('[STATUS] ' + s); }
// 动态加载 UMD
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
// 打印当前视频/渲染/页面像素信息
function dumpSizes(){
try{
const rect = videoEl.getBoundingClientRect();
const intrinsicW = videoEl.videoWidth||0;
const intrinsicH = videoEl.videoHeight||0;
const dpr = window.devicePixelRatio || 1;
appendLog(`[DUMP] CSS size: ${Math.round(rect.width)}x${Math.round(rect.height)}, intrinsic: ${intrinsicW}x${intrinsicH}, devicePixelRatio: ${dpr}`);
}catch(e){ appendLog('[DUMP ERROR] ' + e); }
}
// 从 track 获取 receiver stats(若可用)
async function fetchReceiverStats(track){
try{
if (!track || !track.mediaStreamTrack) { appendLog('[STATS] track 无效'); return; }
const pc = track._pc || track._receiver?.peerConnection || null;
// 尝试通过 track.mediaStreamTrack 获取关联的 RTCRtpReceiver stats - 此处兼容性有限
// 优先查找 peer connection via window.__LIVEKIT_DEBUG_PC__ 或 track 库内部引用(可根据情况调整)
appendLog('[STATS] 尝试读取 stats(若有)...');
// If the track has a receiver with getStats:
const receiver = track._receiver || null;
if (receiver && receiver.getStats) {
const stats = await receiver.getStats();
appendLog('[STATS] receiver.getStats() 可用,条数: ' + stats.size);
// 打印部分关键项
stats.forEach(s=>{
if (s.type === 'inbound-rtp' || s.type === 'track') {
appendLog(' stat: ' + s.type + ' bitrate=' + (s.bitrate||s.bytesReceived||'') + ' frames=' + (s.framesDecoded||'') + ' packetLoss=' + (s.packetsLost||''));
}
});
return;
}
// 否则尝试查找 RTCPeerConnection 全局(不一定存在)
appendLog('[STATS] 无法读取 receiver stats(缺少 receiver.getStats),请抓取 webrtc internals 或 SFU 日志)');
}catch(e){ appendLog('[STATS ERROR] ' + e); }
}
(async ()=>{
setStatus('加载脚本...');
try {
await loadScript(UMD_URL);
} catch (e) {
appendLog('[ERROR] 脚本加载失败: ' + e);
setStatus('脚本加载失败');
return;
}
await new Promise(r=>setTimeout(r,50));
// 兼容不同全局名
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) {
appendLog('[ERROR] 已加载脚本但未导出 LiveKit 全局对象。window keys: ' + Object.keys(window).slice(-50).join(','));
setStatus('脚本已载入但未找到 LiveKit 全局名');
return;
}
appendLog('[INFO] LiveKit 可用,使用全局名: ' + (window.LiveKit ? 'LiveKit' : (window.LivekitClient ? 'LivekitClient' : 'unknown')));
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); appendLog('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
appendLog('[INFO] 创建 Room 实例');
// 当订阅到轨道时记录并 attach
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
appendLog(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'} sid=${pub?.sid||pub?.trackSid||''}`);
if (track.kind === 'video') {
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
// 小延迟后打印尺寸
setTimeout(dumpSizes, 300);
// 尝试抓取 receiver stats(若可)
fetchReceiverStats(track).catch(()=>{});
}
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
appendLog(`[EVENT] TrackUnsubscribed kind=${track.kind}`);
if (track.kind === 'video') videoEl.srcObject = null;
});
room.on(LK.RoomEvent.Connected, ()=> {
appendLog('[EVENT] Room Connected name=' + (room.name||'unknown'));
setStatus('已连接');
console.log('START_RECORDING');
});
room.on(LK.RoomEvent.Disconnected, ()=> {
appendLog('[EVENT] Room Disconnected'); setStatus('已断开');
});
// 尝试连接
appendLog('[ACTION] 开始连接 room...');
try {
await room.connect(url, token);
appendLog('[ACTION] connect() resolved');
} catch (e) {
appendLog('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
// 周期打印
setInterval(()=>{
try {
const rect = videoEl.getBoundingClientRect();
appendLog(`[PERIODIC] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
} catch(e){ appendLog('[PERIODIC ERROR] ' + e); }
}, 5000);
})();
// 按钮事件
dumpBtn.addEventListener('click', dumpSizes);
statsBtn.addEventListener('click', () => {
// 尝试从 room participants 查找第一个 video track 并获取 stats
try {
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient;
for (const p of (LK && LK.Room ? [] : [])) {} // noop
appendLog('[STATS] 手动触发:若支持,stats 会在订阅回调自动尝试');
} catch(e){ appendLog('[STATS CLICK ERROR] ' + e); }
});
})();
</script>
</body>
</html>abundant-magician-17307
12/22/2025, 10:06 AMtall-airline-16756
12/22/2025, 10:07 AMvideoWidth / `videoHeight`(你可以在发布端 console 打印)。
◦ 在 template 中持续记录并贴出多次周期打印值(你已有 periodic 输出,继续以捕获稳定数值)。
◦ 如果可能,抓取 SFU / webrtc internals 或 egress 的接收比特率统计,确认没有网络降级。
我可以帮你的具体动作(你选择一项)
• A:给出 template 的具体 CSS/JS 代码片段,确保在模板中按 intrinsic 高度渲染或以 letterbox 输出(我会给出修改代码)。
• B:给出发布端(客户端)如何强制发送 1080p 或启用 simulcast 的示例(取决于你用的 SDK)。
• C:帮你调整 startRoomCompositeEgress 的 advanced/preset(例如把输出改为 1920x842 或提高 bitrate)并给出示例请求体。
告诉我你要 A / B / C 中的哪一项,我马上给出精确代码或请求示例。abundant-magician-17307
12/22/2025, 10:13 AMtall-airline-16756
12/22/2025, 10:14 AMmuted 属性(你已加),并在 attach 后调用 `video.play().catch(e=>console.warn(e))`,把 catch 的错误信息贴来(若有)。
1. 抓取 webrtc internals 或 SFU 日志(关键)
◦ 因为 receiver.getStats 在模板里不可用或受限,建议在 LiveKit Server / SFU 侧查看对应 track 的接收统计(是否有 packets/bytes/frames)。把对应 trackID(截图里的 sid)在 SFU 日志或 webrtc-internals 中的接收比特率/帧数贴来。
1. 临时替代测试:用另一个订阅客户端观察同一轨道
◦ 在另一台浏览器以普通用户身份订阅该共享流,观察 video.videoWidth/video.videoHeight 是否为 0 或正常;若正常,问题在录制模板订阅/attach;若也为 0,问题在发布端或 SFU。
短期缓解(可立即尝试)
• 在 template 的 TrackSubscribed 回调中,延迟 attach 并重试几次(例如 300ms 间隔重试 3 次),避免 race:若 track.mediaStreamTrack 尚不可用则等待再 attach。
• 在 attach 后立即打印 video.readyState 与 video.paused 并尝试重新 `video.play()`。
• 如果发布端是屏幕共享,确认未被系统或浏览器以低帧率/节流处理(尝试简单摄像头流做对比)。
如果你现在能做的最小两项并回我,我会马上给出精确修复代码: A) 把 TrackSubscribed/TrackUnsubscribed 的完整时间片段(约 ±30s)贴上来;或 B) 在发布端贴出连续 3 次(间隔 1s)的 videoWidth/videoHeight/readyState 值;或 C) 点击模板的“马上打印一次”并把最新一条 [DUMP] 输出贴过来。
你先提供哪一项?abundant-magician-17307
12/22/2025, 10:23 AMtall-airline-16756
12/22/2025, 10:23 AMvideoBitrate 提到 6000 或 8000(kbps),重新录一段,看清晰度是否改善。若明显好转,瓶颈就是比特率。
1. 检查关键帧间隔与 profile
◦ 把 keyFrameInterval 设为 2(秒)或 1(秒)视内容而定;把 videoCodec 设置为 H264_HIGH 或 H264_MAIN 并确保使用 High Profile 如可用。
1. 验证源端上行码率与编码参数
◦ 在发布端抓取或打印其本地发送统计(getStats):查看 bytesSent / bitrate / `framesEncoded`。如果上行很低(例如 < 3 Mbps),即便订阅高层也会模糊。
1. 检查 egress 运行资源
◦ 在 egress 机器上观察 CPU 使用率(编码时是否接近 100% 多核?),若 CPU 限制,临时提升到 4+ vCPU 再测。
1. 排除 RTMP/下游转码
◦ 直接将 egress 输出写到文件(MP4)而不是 RTMP,比较文件与 RTMP 接收端的清晰度;若本地 MP4 清晰而 RTMP 模糊,说明下游有转码/限速环节。
可立刻应用的示例调整(请求体/代码片段)
• 把 advanced 调整为(示例 JSON):
{ "advanced": { "width": 1920, "height": 1080, "framerate": 30, "videoCodec": "H264_HIGH", "videoBitrate": 8000, "keyFrameInterval": 2 } }
• 如果使用 preset,也把 preset 改为 1080p 的高质量 preset 并配合 higher bitrate。
进一步诊断(若上面尝试后仍模糊,我需要)
• 一段对比录制样例(原始 MP4 文件或短截屏)或
• 发布端 getStats 输出(连续 3 次:timestamp, bytesSent, bitrateOutgoing, framesEncoded)和
• egress 机器的 CPU/内存使用截图或数值,及是否输出为 RTMP(若是,说明接收端可能再转码)
你先做哪一步?我建议先执行第1步(把 bitrate 调到 6000–8000)并把结果回我;若你希望,我可以直接帮你生成修改后的 startRoomCompositeEgress 请求体。abundant-magician-17307
12/22/2025, 10:26 AMtall-airline-16756
12/22/2025, 10:26 AMvideoBitrate 提高到 6000–8000 kbps 做 A/B。
• 使用 H264_HIGH profile 与合理 `keyFrameInterval`(1–2 秒)。
• 确认发布端确实在发送高层 simulcast 或 1080p。
优化后可直接替换的 template 片段
<style>
html,body{margin:0;height:100%;background:#000}
#container{width:1920px;height:1080px;margin:0 auto;background:#000;display:flex;align-items:center;justify-content:center}
video{
width:100%;
height:100%;
object-fit:contain;
image-rendering: pixelated; /* 减少浏览器插值 */
transform: none;
will-change: auto;
}
</style>
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<script>
// attach 后按 DPR 强制设置实际像素(在订阅回调或 attach 后调用)
function alignVideoPixels(videoEl){
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
// optional: set styles to avoid transforms
videoEl.style.transform = 'none';
}
// 在 TrackSubscribed 回调里:
// videoEl.srcObject = ms; videoEl.play().then(()=> setTimeout(()=>alignVideoPixels(videoEl), 200)).catch(...)
</script>
下一步我可以帮你做(选其一)
• 一:把你当前完整模板发我,我直接替换并返回一个优化后的完整文件;或
• 二:我生成一个含上述优化、并把 log/controls 在录制时隐藏的完整 template 供你直接部署;或
• 三:如果你要先验证 bitrate 效果,我可给出修改 startRoomCompositeEgress 请求的示例 JSON(把 bitrate 提高到 8000)。
你选哪一项?abundant-magician-17307
12/22/2025, 10:32 AM[DUMP] CSS size: ${Math.round(rect.width)}x${Math.round(rect.height)}, intrinsic: ${intrinsicW}x${intrinsicH}, devicePixelRatio: ${dpr});
}catch(e){ appendLog('[DUMP ERROR] ' + e); }
}
// 从 track 获取 receiver stats(若可用)
async function fetchReceiverStats(track){
try{
if (!track || !track.mediaStreamTrack) { appendLog('[STATS] track 无效'); return; }
const pc = track._pc || track._receiver?.peerConnection || null;
// 尝试通过 track.mediaStreamTrack 获取关联的 RTCRtpReceiver stats - 此处兼容性有限
// 优先查找 peer connection via window.__LIVEKIT_DEBUG_PC__ 或 track 库内部引用(可根据情况调整)
appendLog('[STATS] 尝试读取 stats(若有)...');
// If the track has a receiver with getStats:
const receiver = track._receiver || null;
if (receiver && receiver.getStats) {
const stats = await receiver.getStats();
appendLog('[STATS] receiver.getStats() 可用,条数: ' + stats.size);
// 打印部分关键项
stats.forEach(s=>{
if (s.type === 'inbound-rtp' || s.type === 'track') {
appendLog(' stat: ' + s.type + ' bitrate=' + (s.bitrate||s.bytesReceived||'') + ' frames=' + (s.framesDecoded||'') + ' packetLoss=' + (s.packetsLost||''));
}
});
return;
}
// 否则尝试查找 RTCPeerConnection 全局(不一定存在)
appendLog('[STATS] 无法读取 receiver stats(缺少 receiver.getStats),请抓取 webrtc internals 或 SFU 日志)');
}catch(e){ appendLog('[STATS ERROR] ' + e); }
}
(async ()=>{
setStatus('加载脚本...');
try {
await loadScript(UMD_URL);
} catch (e) {
appendLog('[ERROR] 脚本加载失败: ' + e);
setStatus('脚本加载失败');
return;
}
await new Promise(r=>setTimeout(r,50));
// 兼容不同全局名
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) {
appendLog('[ERROR] 已加载脚本但未导出 LiveKit 全局对象。window keys: ' + Object.keys(window).slice(-50).join(','));
setStatus('脚本已载入但未找到 LiveKit 全局名');
return;
}
appendLog('[INFO] LiveKit 可用,使用全局名: ' + (window.LiveKit ? 'LiveKit' : (window.LivekitClient ? 'LivekitClient' : 'unknown')));
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); appendLog('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
appendLog('[INFO] 创建 Room 实例');
// 当订阅到轨道时记录并 attach
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
appendLog([EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'} sid=${pub?.sid||pub?.trackSid||''});
if (track.kind === 'video') {
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
// 小延迟后打印尺寸
setTimeout(dumpSizes, 300);
// 尝试抓取 receiver stats(若可)
fetchReceiverStats(track).catch(()=>{});
}
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
appendLog([EVENT] TrackUnsubscribed kind=${track.kind});
if (track.kind === 'video') videoEl.srcObject = null;
});
room.on(LK.RoomEvent.Connected, ()=> {
appendLog('[EVENT] Room Connected name=' + (room.name||'unknown'));
setStatus('已连接');
console.log('START_RECORDING');
});
room.on(LK.RoomEvent.Disconnected, ()=> {
appendLog('[EVENT] Room Disconnected'); setStatus('已断开');
});
// 尝试连接
appendLog('[ACTION] 开始连接 room...');
try {
await room.connect(url, token);
appendLog('[ACTION] connect() resolved');
} catch (e) {
appendLog('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
// 周期打印
setInterval(()=>{
try {
const rect = videoEl.getBoundingClientRect();
appendLog([PERIODIC] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio});
} catch(e){ appendLog('[PERIODIC ERROR] ' + e); }
}, 5000);
})();
// 按钮事件
dumpBtn.addEventListener('click', dumpSizes);
statsBtn.addEventListener('click', () => {
// 尝试从 room participants 查找第一个 video track 并获取 stats
try {
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient;
for (const p of (LK && LK.Room ? [] : [])) {} // noop
appendLog('[STATS] 手动触发:若支持,stats 会在订阅回调自动尝试');
} catch(e){ appendLog('[STATS CLICK ERROR] ' + e); }
});
})();
</script>
</body>
</html>基于此html在做优化tall-airline-16756
12/22/2025, 10:33 AMvideo{
width:100%;
height:100%;
object-fit:contain;
image-rendering: pixelated;
transform: none;
backface-visibility: hidden;
}
#log, #controls { display:none; } /* 在正式录制时隐藏 */
4) JS:attach 与 DPR 对齐与重试逻辑(直接替换或插入到 TrackSubscribed 回调)
async function attachWithRetry(track, videoEl, maxRetries = 5){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
let attempts = 0;
videoEl.srcObject = ms;
while(attempts < maxRetries){
await new Promise(r => setTimeout(r, 300));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
if (iw > 0 && ih > 0){
// 对齐像素
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
console.log('attach ok intrinsic', iw, ih, 'set pixel size', videoEl.width, videoEl.height);
return true;
}
attempts++;
console.log('attach retry', attempts);
}
console.warn('attach failed after retries; intrinsic still 0');
return false;
}
5) Egress / 编码侧建议(必须尝试)
• 把 videoBitrate 提升到 6000–8000 kbps 做 A/B 测试。
• 使用 `videoCodec: "H264_HIGH"`(或可用的 high profile)并把 keyFrameInterval 设为 1–2 秒。
• 确认 egress 的 Chrome session 尺寸与 advanced.width/height 一致(日志中 `Starting Chrome session: width=1920, height=1080`)。
6) 验证与对比流程(最有效)
• 先只用最简 template(仅 video,无 overlay),并用高 bitrate 做一段录制;若清晰,说明问题在模板或比特率,否则是源端/上行。
• 若仍模糊,请提供一小段导出文件或截图(我可以帮你判定是编码问题还是源头问题)。
7) 保留诊断日志(推荐)
• 保留周期性 console 输出,但在实际录制时隐藏页面上的 log 面板(仍会输出到 console),便于回放时抓取 webrtc-internals/SFU 日志对照。
如果你愿意,我可以:
• 把你当前完整 HTML 按上述建议直接修改并返回一个可替换文件;或
• 直接给出修改后的 TrackSubscribed 回调和 style,你只需粘贴替换。
你想要我直接返回修改后的完整 HTML,还是只返回可替换的代码片段?abundant-magician-17307
12/22/2025, 10:37 AMtall-airline-16756
12/22/2025, 10:37 AM<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-optimized-template</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#wrap{position:relative;width:100vw;height:100vh}
/* 固定容器为目标像素(建议 egress advanced 指定为 1920x1080) */
#container { width:100%; height:100%; display:flex; align-items:center; justify-content:center; background:#000; }
video{
width:100%;
height:100%;
object-fit:contain;
/* 降低浏览器平滑插值,尽量保持像素细节(对屏幕共享效果视场景) */
image-rendering: pixelated;
-webkit-font-smoothing:antialiased;
transform:none;
backface-visibility:hidden;
will-change: auto;
}
/* 调试面板,录制时隐藏 */
#log {
position: absolute; left:8px; top:8px; max-width:40%; max-height:90%;
overflow:auto; background:rgba(0,0,0,0.6); padding:8px; border-radius:6px;
font-size:12px; line-height:1.3; color:#0f0; white-space:pre-wrap;
z-index:1000;
}
#controls { position:absolute; right:8px; top:8px; background:rgba(0,0,0,0.6); padding:8px; border-radius:6px; font-size:12px; z-index:1000; }
button{margin:4px 0;padding:6px 8px}
/* 隐藏调试面板(录制时启用) */
.hide-debug #log, .hide-debug #controls { display:none !important; }
</style>
</head>
<body>
<div id="wrap">
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="log">初始化…</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">马上打印一次</button>
<button id="stats">抓取 webrtc stats</button>
<button id="hide">隐藏调试(进入录制模式)</button>
</div>
</div>
<script>
(function(){
// 修改为你的 UMD 地址
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const statsBtn = document.getElementById('stats');
const hideBtn = document.getElementById('hide');
function appendLog(s){
const t = new Date().toISOString();
console.log(t, s);
if (logEl) logEl.textContent = t + ' ' + s + '\n\n' + logEl.textContent;
}
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; appendLog('[STATUS] ' + s); }
// 动态加载 UMD
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script');
s.src = src;
s.async = false;
s.onload = ()=>res();
s.onerror = (e)=>rej(e);
document.head.appendChild(s);
}); }
// 强制按 DPR 对齐 video 实际像素
function alignVideoPixels(videoEl){
try {
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
appendLog(`[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr}`);
} catch (e) {
appendLog('[ALIGN ERROR] ' + e);
}
}
// attach 并重试直至 intrinsic 可用
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream();
ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
appendLog(`[ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih}`);
if (iw > 0 && ih > 0){
// 对齐像素并返回成功
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
appendLog('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
// 尝试从 track 获取 receiver stats(兼容性有限)
async function fetchReceiverStats(track){
try{
if (!track || !track.mediaStreamTrack) { appendLog('[STATS] track 无效'); return; }
appendLog('[STATS] 尝试读取 receiver stats...');
const receiver = track._receiver || null;
if (receiver && typeof receiver.getStats === 'function') {
const stats = await receiver.getStats();
appendLog('[STATS] receiver.getStats() 返回 ' + (Array.isArray(stats)?stats.length: (stats.size||'?')) );
try {
// 打印典型 inbound-rtp 项
if (stats.forEach) {
stats.forEach(s => {
if (s.type === 'inbound-rtp' || s.type === 'track') {
appendLog(' stat: ' + s.type + ' bytesReceived=' + (s.bytesReceived||'') + ' framesDecoded=' + (s.framesDecoded||'') + ' packetsLost=' + (s.packetsLost||''));
}
});
} else {
// Map-like
stats.forEach && stats.forEach((s)=>{ if (s.type) appendLog(' stat: ' + s.type + ' ' + JSON.stringify(s).slice(0,200)); });
}
} catch(e){ appendLog('[STATS PARSE ERROR] ' + e); }
return;
}
appendLog('[STATS] 无法读取 receiver.getStats,需在 SFU/egress 侧获取 webrtc internals');
} catch (e) { appendLog('[STATS ERROR] ' + e); }
}
// 主流程
(async ()=>{
setStatus('加载脚本...');
try {
await loadScript(UMD_URL);
} catch (e) {
appendLog('[ERROR] 脚本加载失败: ' + e);
setStatus('脚本加载失败');
return;
}
// 短延迟确保 UMD 注册全局
await new Promise(r=>setTimeout(r, 50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) {
appendLog('[ERROR] 已加载脚本但未导出 LiveKit 全局对象。window keys: ' + Object.keys(window).slice(-50).join(','));
setStatus('脚本已载入但未找到 LiveKit 全局名');
return;
}
appendLog('[INFO] LiveKit 可用,使用全局名: ' + (window.LiveKit ? 'LiveKit' : (window.LivekitClient ? 'LivekitClient' : 'unknown')));
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); appendLog('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
appendLog('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
appendLog(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'} sid=${pub?.sid||pub?.trackSid||''}`);
if (track.kind === 'video') {
await attachWithRetry(track, videoEl, 6, 300);
fetchReceiverStats(track).catch(()=>{});
}
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
appendLog(`[EVENT] TrackUnsubscribed kind=${track.kind}`);
if (track.kind === 'video') {
videoEl.srcObject = null;
}
});
room.on(LK.RoomEvent.Connected, ()=> {
appendLog('[EVENT] Room Connected name=' + (room.name||'unknown'));
setStatus('已连接');
console.log('START_RECORDING');
});
room.on(LK.RoomEvent.Disconnected, ()=> { appendLog('[EVENT] Room Disconnected'); setStatus('已断开'); });
appendLog('[ACTION] 开始连接 room...');
try {
await room.connect(url, token);
appendLog('[ACTION] connect() resolved');
} catch (e) {
appendLog('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
// 周期打印用于诊断
setInterval(()=>{
try {
const rect = videoEl.getBoundingClientRect();
appendLog(`[PERIODIC] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
} catch(e){ appendLog('[PERIODIC ERROR] ' + e); }
}, 5000);
})();
// 控件事件
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
appendLog(`[DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
});
statsBtn.addEventListener('click', ()=> {
appendLog('[STATS] 手动触发:若支持,订阅回调会自动尝试读取 receiver stats');
});
hideBtn.addEventListener('click', ()=> {
document.documentElement.classList.add('hide-debug');
appendLog('[ACTION] 已隐藏调试面板(录制模式)');
});
})();
</script>
</body>
</html>
说明:
• 将 UMD_URL 替换为你实际托管的脚本地址(模板中已为你填写);
• 录制时可点击“隐藏调试”将 log/controls 隐藏,页面仍会在 console 输出诊断日志;
• 若你准备我可以同时生成配套的 startRoomCompositeEgress 请求示例(把 bitrate 提高到 8000、使用 H264_HIGH),我也可以直接给出 JSON。abundant-magician-17307
12/22/2025, 10:41 AMtall-airline-16756
12/22/2025, 10:41 AMvideoBitrate 提到 6000–8000 kbps(或更高),尤其是屏幕共享文字场景。
• 使用更合适的编码配置:`videoCodec: "H264_HIGH"`(或高 profile),`keyFrameInterval` 1–2 秒。
• 确保发布端以高质量发布(1080p、较高上行码率、启用 simulcast 的高层或 SVC)。
这些改变能真实提高视频细节,是首选。
二、模板层面(主观锐化、无需改编码)——可立刻试验 1) CSS 对 video 应用锐化滤镜(可增强文字边缘):
/* 在你的 video 样式中加入 */
video {
filter: contrast(1.05) saturate(1.03) /* 轻微增强对比色彩 */
drop-shadow(0 0 0 rgba(0,0,0,0)) /* 保持渲染链 */
;
/* 后续可通过 backdrop-filter / filter: blur(-x) 不支持,使用下面的 sharpen via SVG */
}
2) 更可靠的锐化:用 SVG 卷积滤镜或在页面上用 canvas 做简单 unsharp-mask,再将结果显示(代价是会做二次绘制,可能有性能/二次压缩影响):
• 简单 SVG 锐化(可直接放入页面):
<svg style="position:absolute; width:0; height:0;">
<filter id="sharpen">
<feConvolveMatrix order="3" kernelMatrix="0 -1 0 -1 5 -1 0 -1 0" divisor="1"/>
</filter>
</svg>
然后在 CSS:
video { filter: none; -webkit-filter: none; }
.sharpened { filter: url(#sharpen); }
在 JS 中把 `videoEl.classList.add('sharpened')`。 注意:SVG 卷积有浏览器兼容和性能限制,且会对捕获帧产生二次渲染,可能放大压缩伪影。
3) Canvas-based unsharp mask(最强主观效果,但最复杂)
• 将 video 绘制到 canvas,做一次锐化(卷积或 unsharp),再展示 canvas。代码复杂并会增加 CPU/内存开销,且 canvas->捕获->编码会引入另一次像素处理,可能需要更高 bitrate 与更多 CPU。若你愿意我可以提供完整片段。
三、渲染细节与 DPR(必须校验)
• 确保 video 与编码输出像素 1:1 对齐(你已做 align),并确保 devicePixelRatio 处理正确。错误的 DPR 会使文字模糊。
• 避免 CSS 的非整数缩放(百分比/transform/scale),已经排除了。
四、折衷建议(快速实验路径)
1. 先把 egress bitrate 提高到 8000 kbps、codec 改为 H264_HIGH,做一次录制对比(最快且最有效)。
2. 若提升 bitrate 后仍不够,尝试在模板用 SVG sharpen(轻量)或 canvas unsharp(更强)做主观增强,再做对比。
3. 若使用 canvas 锐化,务必同时提升 bitrate & egress CPU 资源(避免丢帧和伪影)。
五、我可以直接帮你做的事(选一项回复)
• 我把 SVG 锐化的完整可插入代码片段直接给你(简单且低成本)。
• 或我把 canvas-based unsharp-mask 的完整实现(含性能注意)贴给你用于替换 video 渲染。
• 或我生成修改后的 startRoomCompositeEgress 请求示例(bitrate/profile 调优)供你直接调用。
你希望先试哪种(A: 先提升 bitrate/profile; B: 先试 SVG 锐化片段; C: 试 canvas unsharp 实现)?abundant-magician-17307
12/22/2025, 10:45 AMtall-airline-16756
12/22/2025, 10:46 AM<video> 的 CSS 锐化(使用 SVG 卷积滤镜),并保留了之前的诊断与 attach 重试逻辑。保存为文件(egress-sharpen.html)并用 ?url=<wss>&token=<jwt> 打开或部署到 HTTPS 后在 startRoomCompositeEgress 的 custom_base_url 指向该页面。
说明要点(简短)
• 使用 SVG 的 feConvolveMatrix 实现 3x3 锐化核,并结合轻微 contrast/saturate 提升文字可读性。
• 保留 1:1 像素对齐和 attach 重试逻辑,避免因未就绪导致的模糊。
• 保留“隐藏调试”按钮用于录制时隐藏 overlay(仍会在 console 输出日志)。
• 该滤镜对屏幕共享文字有明显主观提升,若进一步需要可改为 canvas-based unsharp(成本高)。
完整 HTML:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-sharpen-template</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#wrap{position:relative;width:100vw;height:100vh}
#container{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#000}
/* video 基本样式 */
video{
width:100%;
height:100%;
object-fit:contain;
transform:none;
backface-visibility:hidden;
will-change:auto;
-webkit-font-smoothing:antialiased;
/* 增强对比作为基础 */
filter: contrast(1.05) saturate(1.03);
/* 默认不直接引用 SVG 卷积(通过类控制是否启用 sharpen) */
}
/* 当启用 sharpen 时应用 SVG filter(兼容多数浏览器) */
.sharpened { filter: url(#sharpen) contrast(1.05) saturate(1.03); }
/* 调试面板(录制时可隐藏) */
#log{position:absolute;left:8px;top:8px;max-width:40%;max-height:90%;overflow:auto;background:rgba(0,0,0,0.6);padding:8px;border-radius:6px;font-size:12px;line-height:1.3;color:#0f0;white-space:pre-wrap;z-index:1000}
#controls{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.6);padding:8px;border-radius:6px;font-size:12px;z-index:1000}
button{margin:4px 0;padding:6px 8px}
.hide-debug #log, .hide-debug #controls { display:none !important; }
</style>
<!-- SVG 卷积滤镜定义(3x3 锐化核:center weight > neighbors) -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="sharpen" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<!-- 卷积核: 0 -1 0
-1 5 -1
0 -1 0 -->
<feConvolveMatrix order="3" kernelMatrix="0 -1 0 -1 5 -1 0 -1 0" divisor="1" bias="0" kernelUnitLength="1 1"/>
<!-- 可选:稍微提升锐化对比 -->
<feComponentTransfer>
<feFuncR type="gamma" amplitude="1" exponent="1" offset="0"/>
<feFuncG type="gamma" amplitude="1" exponent="1" offset="0"/>
<feFuncB type="gamma" amplitude="1" exponent="1" offset="0"/>
</feComponentTransfer>
</filter>
</defs>
</svg>
</head>
<body>
<div id="wrap">
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="log">初始化…</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">马上打印一次</button>
<button id="toggleSharpen">切换锐化</button>
<button id="hide">隐藏调试(录制模式)</button>
</div>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const toggleSharpenBtn = document.getElementById('toggleSharpen');
const hideBtn = document.getElementById('hide');
function appendLog(s){
const t = new Date().toISOString();
console.log(t, s);
if (logEl) logEl.textContent = t + ' ' + s + '\n\n' + logEl.textContent;
}
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; appendLog('[STATUS] ' + s); }
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
function alignVideoPixels(videoEl){
try {
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
appendLog(`[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr}`);
} catch (e) {
appendLog('[ALIGN ERROR] ' + e);
}
}
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
appendLog(`[ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih}`);
if (iw > 0 && ih > 0){
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
appendLog('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
async function fetchReceiverStats(track){
try{
if (!track || !track.mediaStreamTrack) { appendLog('[STATS] track 无效'); return; }
appendLog('[STATS] 尝试读取 receiver stats...');
const receiver = track._receiver || null;
if (receiver && typeof receiver.getStats === 'function') {
const stats = await receiver.getStats();
appendLog('[STATS] receiver.getStats() 返回 ' + (stats.size||'?'));
try {
stats.forEach(s => {
if (s.type === 'inbound-rtp' || s.type === 'track') {
appendLog(' stat: ' + s.type + ' bytesReceived=' + (s.bytesReceived||'') + ' framesDecoded=' + (s.framesDecoded||'') + ' packetsLost=' + (s.packetsLost||''));
}
});
} catch(e){ appendLog('[STATS PARSE ERROR] ' + e); }
return;
}
appendLog('[STATS] 无法读取 receiver.getStats,需在 SFU/egress 侧获取 webrtc internals');
} catch (e) { appendLog('[STATS ERROR] ' + e); }
}
(async ()=>{
setStatus('加载脚本...');
try {
await loadScript(UMD_URL);
} catch (e) {
appendLog('[ERROR] 脚本加载失败: ' + e);
setStatus('脚本加载失败');
return;
}
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) {
appendLog('[ERROR] 已加载脚本但未导出 LiveKit 全局对象。window keys: ' + Object.keys(window).slice(-50).join(','));
setStatus('脚本已载入但未找到 LiveKit 全局名');
return;
}
appendLog('[INFO] LiveKit 可用,使用全局名: ' + (window.LiveKit ? 'LiveKit' : (window.LivekitClient ? 'LivekitClient' : 'unknown')));
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); appendLog('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
appendLog('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
appendLog(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'} sid=${pub?.sid||pub?.trackSid||''}`);
if (track.kind === 'video') {
await attachWithRetry(track, videoEl, 6, 300);
fetchReceiverStats(track).catch(()=>{});
}
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
appendLog(`[EVENT] TrackUnsubscribed kind=${track.kind}`);
if (track.kind === 'video') {
videoEl.srcObject = null;
}
});
room.on(LK.RoomEvent.Connected, ()=> {
appendLog('[EVENT] Room Connected name=' + (room.name||'unknown'));
setStatus('已连接');
console.log('START_RECORDING');
});
room.on(LK.RoomEvent.Disconnected, ()=> { appendLog('[EVENT] Room Disconnected'); setStatus('已断开'); });
appendLog('[ACTION] 开始连接 room...');
try {
await room.connect(url, token);
appendLog('[ACTION] connect() resolved');
} catch (e) {
appendLog('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
setInterval(()=>{
try {
const rect = videoEl.getBoundingClientRect();
appendLog(`[PERIODIC] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
} catch(e){ appendLog('[PERIODIC ERROR] ' + e); }
}, 5000);
})();
// 控件事件
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
appendLog(`[DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
});
toggleSharpenBtn.addEventListener('click', ()=> {
videoEl.classList.toggle('sharpened');
appendLog('[ACTION] 切换锐化 -> ' + (videoEl.classList.contains('sharpened') ? 'ON' : 'OFF'));
});
hideBtn.addEventListener('click', ()=> {
document.documentElement.classList.add('hide-debug');
appendLog('[ACTION] 已隐藏调试面板(录制模式)');
});
})();
</script>
</body>
</html>
使用说明(简短)
• 点击“切换锐化”按钮可以开启/关闭 SVG 锐化滤镜。
• 若对文字清晰度仍不满意,请先按我之前建议把 egress bitrate 提高到 6000–8000 kbps,再观察效果;锐化是主观增强,无法替代真实的传输细节。abundant-magician-17307
12/22/2025, 10:53 AMtall-airline-16756
12/22/2025, 10:53 AM<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-sharpen-on</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#wrap{position:relative;width:100vw;height:100vh}
#container{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#000}
video{
width:100%;
height:100%;
object-fit:contain;
transform:none;
backface-visibility:hidden;
will-change:auto;
-webkit-font-smoothing:antialiased;
/* 基础对比/饱和度微调 */
filter: contrast(1.05) saturate(1.03);
/* 默认直接启用 SVG 锐化 */
filter: url(#sharpen) contrast(1.05) saturate(1.03);
}
/* 调试面板(录制时隐藏) */
#log{position:absolute;left:8px;top:8px;max-width:40%;max-height:90%;overflow:auto;background:rgba(0,0,0,0.6);padding:8px;border-radius:6px;font-size:12px;line-height:1.3;color:#0f0;white-space:pre-wrap;z-index:1000}
#controls{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.6);padding:8px;border-radius:6px;font-size:12px;z-index:1000}
button{margin:4px 0;padding:6px 8px}
.hide-debug #log, .hide-debug #controls { display:none !important; }
</style>
<!-- SVG 卷积滤镜定义(3x3 锐化核) -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="sharpen" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<feConvolveMatrix order="3" kernelMatrix="0 -1 0 -1 5 -1 0 -1 0" divisor="1" bias="0" kernelUnitLength="1 1"/>
</filter>
</defs>
</svg>
</head>
<body>
<div id="wrap">
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="log">初始化…</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">马上打印一次</button>
<button id="hide">隐藏调试(录制模式)</button>
</div>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const hideBtn = document.getElementById('hide');
function appendLog(s){
const t = new Date().toISOString();
console.log(t, s);
if (logEl) logEl.textContent = t + ' ' + s + '\n\n' + logEl.textContent;
}
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; appendLog('[STATUS] ' + s); }
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
function alignVideoPixels(videoEl){
try {
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
appendLog(`[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr}`);
} catch (e) {
appendLog('[ALIGN ERROR] ' + e);
}
}
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
appendLog(`[ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih}`);
if (iw > 0 && ih > 0){
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
appendLog('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
async function fetchReceiverStats(track){
try{
if (!track || !track.mediaStreamTrack) { appendLog('[STATS] track 无效'); return; }
appendLog('[STATS] 尝试读取 receiver stats...');
const receiver = track._receiver || null;
if (receiver && typeof receiver.getStats === 'function') {
const stats = await receiver.getStats();
appendLog('[STATS] receiver.getStats() 返回 ' + (stats.size||'?'));
try {
stats.forEach(s => {
if (s.type === 'inbound-rtp' || s.type === 'track') {
appendLog(' stat: ' + s.type + ' bytesReceived=' + (s.bytesReceived||'') + ' framesDecoded=' + (s.framesDecoded||'') + ' packetsLost=' + (s.packetsLost||''));
}
});
} catch(e){ appendLog('[STATS PARSE ERROR] ' + e); }
return;
}
appendLog('[STATS] 无法读取 receiver.getStats,需在 SFU/egress 侧获取 webrtc internals');
} catch (e) { appendLog('[STATS ERROR] ' + e); }
}
(async ()=>{
setStatus('加载脚本...');
try {
await loadScript(UMD_URL);
} catch (e) {
appendLog('[ERROR] 脚本加载失败: ' + e);
setStatus('脚本加载失败');
return;
}
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) {
appendLog('[ERROR] 已加载脚本但未导出 LiveKit 全局对象。window keys: ' + Object.keys(window).slice(-50).join(','));
setStatus('脚本已载入但未找到 LiveKit 全局名');
return;
}
appendLog('[INFO] LiveKit 可用,使用全局名: ' + (window.LiveKit ? 'LiveKit' : (window.LivekitClient ? 'LivekitClient' : 'unknown')));
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); appendLog('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
appendLog('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
appendLog(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'} sid=${pub?.sid||pub?.trackSid||''}`);
if (track.kind === 'video') {
await attachWithRetry(track, videoEl, 6, 300);
fetchReceiverStats(track).catch(()=>{});
}
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
appendLog(`[EVENT] TrackUnsubscribed kind=${track.kind}`);
if (track.kind === 'video') {
videoEl.srcObject = null;
}
});
room.on(LK.RoomEvent.Connected, ()=> {
appendLog('[EVENT] Room Connected name=' + (room.name||'unknown'));
setStatus('已连接');
console.log('START_RECORDING');
});
room.on(LK.RoomEvent.Disconnected, ()=> { appendLog('[EVENT] Room Disconnected'); setStatus('已断开'); });
appendLog('[ACTION] 开始连接 room...');
try {
await room.connect(url, token);
appendLog('[ACTION] connect() resolved');
} catch (e) {
appendLog('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
setInterval(()=>{
try {
const rect = videoEl.getBoundingClientRect();
appendLog(`[PERIODIC] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
} catch(e){ appendLog('[PERIODIC ERROR] ' + e); }
}, 5000);
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
appendLog(`[DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
});
hideBtn.addEventListener('click', ()=> {
document.documentElement.classList.add('hide-debug');
appendLog('[ACTION] 已隐藏调试面板(录制模式)');
});
})();
</script>
</body>
</html>
如果你希望我同时生成配套的 startRoomCompositeEgress 请求示例(建议将 videoBitrate 提到 8000 并使用 H264_HIGH),我可以直接给出 JSON。abundant-magician-17307
12/23/2025, 1:31 AMtall-airline-16756
12/23/2025, 1:32 AM<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-sharpen-soft</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#wrap{position:relative;width:100vw;height:100vh}
#container{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#000}
video{
width:100%;
height:100%;
object-fit:contain;
transform:none;
backface-visibility:hidden;
will-change:auto;
-webkit-font-smoothing:antialiased;
/* 轻微提升对比度与饱和度 */
filter: contrast(1.03) saturate(1.02);
/* 默认启用轻微 SVG 锐化 */
filter: url(#sharpen_soft) contrast(1.03) saturate(1.02);
}
#log{position:absolute;left:8px;top:8px;max-width:40%;max-height:90%;overflow:auto;background:rgba(0,0,0,0.6);padding:8px;border-radius:6px;font-size:12px;line-height:1.3;color:#0f0;white-space:pre-wrap;z-index:1000}
#controls{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.6);padding:8px;border-radius:6px;font-size:12px;z-index:1000}
button{margin:4px 0;padding:6px 8px}
.hide-debug #log, .hide-debug #controls { display:none !important; }
</style>
<!-- 轻微锐化滤镜:邻位权重 -0.5,中心权重 3 -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="sharpen_soft" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<!-- kernel:
0 -0.5 0
-0.5 3 -0.5
0 -0.5 0 -->
<feConvolveMatrix order="3" kernelMatrix="0 -0.5 0 -0.5 3 -0.5 0 -0.5 0" divisor="1" bias="0" kernelUnitLength="1 1"/>
</filter>
</defs>
</svg>
</head>
<body>
<div id="wrap">
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="log">初始化…</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">马上打印一次</button>
<button id="hide">隐藏调试(录制模式)</button>
</div>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const hideBtn = document.getElementById('hide');
function appendLog(s){
const t = new Date().toISOString();
console.log(t, s);
if (logEl) logEl.textContent = t + ' ' + s + '\n\n' + logEl.textContent;
}
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; appendLog('[STATUS] ' + s); }
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
function alignVideoPixels(videoEl){
try {
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
appendLog(`[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr}`);
} catch (e) {
appendLog('[ALIGN ERROR] ' + e);
}
}
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
appendLog(`[ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih}`);
if (iw > 0 && ih > 0){
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
appendLog('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
async function fetchReceiverStats(track){
try{
if (!track || !track.mediaStreamTrack) { appendLog('[STATS] track 无效'); return; }
appendLog('[STATS] 尝试读取 receiver stats...');
const receiver = track._receiver || null;
if (receiver && typeof receiver.getStats === 'function') {
const stats = await receiver.getStats();
appendLog('[STATS] receiver.getStats() 返回 ' + (stats.size||'?'));
try {
stats.forEach(s => {
if (s.type === 'inbound-rtp' || s.type === 'track') {
appendLog(' stat: ' + s.type + ' bytesReceived=' + (s.bytesReceived||'') + ' framesDecoded=' + (s.framesDecoded||'') + ' packetsLost=' + (s.packetsLost||''));
}
});
} catch(e){ appendLog('[STATS PARSE ERROR] ' + e); }
return;
}
appendLog('[STATS] 无法读取 receiver.getStats,需在 SFU/egress 侧获取 webrtc internals');
} catch (e) { appendLog('[STATS ERROR] ' + e); }
}
(async ()=>{
setStatus('加载脚本...');
try {
await loadScript(UMD_URL);
} catch (e) {
appendLog('[ERROR] 脚本加载失败: ' + e);
setStatus('脚本加载失败');
return;
}
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) {
appendLog('[ERROR] 已加载脚本但未导出 LiveKit 全局对象。window keys: ' + Object.keys(window).slice(-50).join(','));
setStatus('脚本已载入但未找到 LiveKit 全局名');
return;
}
appendLog('[INFO] LiveKit 可用,使用全局名: ' + (window.LiveKit ? 'LiveKit' : (window.LivekitClient ? 'LivekitClient' : 'unknown')));
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); appendLog('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
appendLog('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
appendLog(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'} sid=${pub?.sid||pub?.trackSid||''}`);
if (track.kind === 'video') {
await attachWithRetry(track, videoEl, 6, 300);
fetchReceiverStats(track).catch(()=>{});
}
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
appendLog(`[EVENT] TrackUnsubscribed kind=${track.kind}`);
if (track.kind === 'video') {
videoEl.srcObject = null;
}
});
room.on(LK.RoomEvent.Connected, ()=> {
appendLog('[EVENT] Room Connected name=' + (room.name||'unknown'));
setStatus('已连接');
console.log('START_RECORDING');
});
room.on(LK.RoomEvent.Disconnected, ()=> { appendLog('[EVENT] Room Disconnected'); setStatus('已断开'); });
appendLog('[ACTION] 开始连接 room...');
try {
await room.connect(url, token);
appendLog('[ACTION] connect() resolved');
} catch (e) {
appendLog('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
setInterval(()=>{
try {
const rect = videoEl.getBoundingClientRect();
appendLog(`[PERIODIC] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
} catch(e){ appendLog('[PERIODIC ERROR] ' + e); }
}, 5000);
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
appendLog(`[DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
});
hideBtn.addEventListener('click', ()=> {
document.documentElement.classList.add('hide-debug');
appendLog('[ACTION] 已隐藏调试面板(录制模式)');
});
})();
</script>
</body>
</html>
下面 provide brief guidance on adjusting intensity further:
• 若需更轻微:把 kernel 中邻位权重减小到 -0.25, center 改为 2.5。
• 若要更明显:把 center 提至 4,邻位 -1。
如果你要我直接把 kernel 调整为某个具体值(例如 very subtle: center=2.5 neighbor=-0.25),我可以立刻返回对应 HTML。abundant-magician-17307
12/23/2025, 2:06 AM[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr});
} catch (e) {
appendLog('[ALIGN ERROR] ' + e);
}
}
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
appendLog([ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih});
if (iw > 0 && ih > 0){
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
appendLog('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
async function fetchReceiverStats(track){
try{
if (!track || !track.mediaStreamTrack) { appendLog('[STATS] track 无效'); return; }
appendLog('[STATS] 尝试读取 receiver stats...');
const receiver = track._receiver || null;
if (receiver && typeof receiver.getStats === 'function') {
const stats = await receiver.getStats();
appendLog('[STATS] receiver.getStats() 返回 ' + (stats.size||'?'));
try {
stats.forEach(s => {
if (s.type === 'inbound-rtp' || s.type === 'track') {
appendLog(' stat: ' + s.type + ' bytesReceived=' + (s.bytesReceived||'') + ' framesDecoded=' + (s.framesDecoded||'') + ' packetsLost=' + (s.packetsLost||''));
}
});
} catch(e){ appendLog('[STATS PARSE ERROR] ' + e); }
return;
}
appendLog('[STATS] 无法读取 receiver.getStats,需在 SFU/egress 侧获取 webrtc internals');
} catch (e) { appendLog('[STATS ERROR] ' + e); }
}
(async ()=>{
setStatus('加载脚本...');
try {
await loadScript(UMD_URL);
} catch (e) {
appendLog('[ERROR] 脚本加载失败: ' + e);
setStatus('脚本加载失败');
return;
}
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) {
appendLog('[ERROR] 已加载脚本但未导出 LiveKit 全局对象。window keys: ' + Object.keys(window).slice(-50).join(','));
setStatus('脚本已载入但未找到 LiveKit 全局名');
return;
}
appendLog('[INFO] LiveKit 可用,使用全局名: ' + (window.LiveKit ? 'LiveKit' : (window.LivekitClient ? 'LivekitClient' : 'unknown')));
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); appendLog('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
appendLog('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
appendLog([EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'} sid=${pub?.sid||pub?.trackSid||''});
if (track.kind === 'video') {
await attachWithRetry(track, videoEl, 6, 300);
fetchReceiverStats(track).catch(()=>{});
}
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
appendLog([EVENT] TrackUnsubscribed kind=${track.kind});
if (track.kind === 'video') {
videoEl.srcObject = null;
}
});
room.on(LK.RoomEvent.Connected, ()=> {
appendLog('[EVENT] Room Connected name=' + (room.name||'unknown'));
setStatus('已连接');
console.log('START_RECORDING');
});
room.on(LK.RoomEvent.Disconnected, ()=> { appendLog('[EVENT] Room Disconnected'); setStatus('已断开'); });
appendLog('[ACTION] 开始连接 room...');
try {
await room.connect(url, token);
appendLog('[ACTION] connect() resolved');
} catch (e) {
appendLog('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
setInterval(()=>{
try {
const rect = videoEl.getBoundingClientRect();
appendLog([PERIODIC] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio});
} catch(e){ appendLog('[PERIODIC ERROR] ' + e); }
}, 5000);
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
appendLog([DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio});
});
hideBtn.addEventListener('click', ()=> {
document.documentElement.classList.add('hide-debug');
appendLog('[ACTION] 已隐藏调试面板(录制模式)');
});
})();
</script>
</body>
</html>基于此html,略微降低锐化的程度,去除不必要的日志打印tall-airline-16756
12/23/2025, 2:07 AM<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-sharpen-soft-minlog</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#wrap{position:relative;width:100vw;height:100vh}
#container{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#000}
video{
width:100%;
height:100%;
object-fit:contain;
transform:none;
backface-visibility:hidden;
will-change:auto;
-webkit-font-smoothing:antialiased;
/* 轻微对比/饱和度调整 */
filter: contrast(1.02) saturate(1.02);
/* 轻微 SVG 锐化 */
filter: url(#sharpen_soft) contrast(1.02) saturate(1.02);
}
#log{position:absolute;left:8px;top:8px;max-width:40%;max-height:90%;overflow:auto;background:rgba(0,0,0,0.6);padding:8px;border-radius:6px;font-size:12px;line-height:1.3;color:#0f0;white-space:pre-wrap;z-index:1000}
#controls{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.6);padding:8px;border-radius:6px;font-size:12px;z-index:1000}
button{margin:4px 0;padding:6px 8px}
.hide-debug #log, .hide-debug #controls { display:none !important; }
</style>
<!-- 更轻微的锐化滤镜:邻位 -0.25,中心 2.5 -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="sharpen_soft" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<!-- kernel:
0 -0.25 0
-0.25 2.5 -0.25
0 -0.25 0 -->
<feConvolveMatrix order="3" kernelMatrix="0 -0.25 0 -0.25 2.5 -0.25 0 -0.25 0" divisor="1" bias="0" kernelUnitLength="1 1"/>
</filter>
</defs>
</svg>
</head>
<body>
<div id="wrap">
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="log">初始化…</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">打印当前尺寸</button>
<button id="hide">隐藏调试(录制模式)</button>
</div>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const hideBtn = document.getElementById('hide');
function appendLog(s){
const t = new Date().toISOString();
console.log(t, s);
if (logEl) logEl.textContent = t + ' ' + s + '\n\n' + logEl.textContent;
}
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; appendLog('[STATUS] ' + s); }
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
function alignVideoPixels(videoEl){
try {
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
appendLog(`[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr}`);
} catch (e) {
appendLog('[ALIGN ERROR] ' + e);
}
}
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
appendLog(`[ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih}`);
if (iw > 0 && ih > 0){
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
appendLog('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
(async ()=>{
setStatus('加载脚本...');
try {
await loadScript(UMD_URL);
} catch (e) {
appendLog('[ERROR] 脚本加载失败: ' + e);
setStatus('脚本加载失败');
return;
}
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) {
appendLog('[ERROR] 已加载脚本但未导出 LiveKit 全局对象。window keys: ' + Object.keys(window).slice(-50).join(','));
setStatus('脚本已载入但未找到 LiveKit 全局名');
return;
}
appendLog('[INFO] LiveKit 可用,使用全局名: ' + (window.LiveKit ? 'LiveKit' : (window.LivekitClient ? 'LivekitClient' : 'unknown')));
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); appendLog('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
appendLog('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
appendLog(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'} sid=${pub?.sid||pub?.trackSid||''}`);
if (track.kind === 'video') {
await attachWithRetry(track, videoEl, 6, 300);
}
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
appendLog(`[EVENT] TrackUnsubscribed kind=${track.kind}`);
if (track.kind === 'video') {
videoEl.srcObject = null;
}
});
room.on(LK.RoomEvent.Connected, ()=> {
appendLog('[EVENT] Room Connected name=' + (room.name||'unknown'));
setStatus('已连接');
console.log('START_RECORDING');
});
room.on(LK.RoomEvent.Disconnected, ()=> { appendLog('[EVENT] Room Disconnected'); setStatus('已断开'); });
appendLog('[ACTION] 开始连接 room...');
try {
await room.connect(url, token);
appendLog('[ACTION] connect() resolved');
} catch (e) {
appendLog('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
// 移除周期性日志:仅在用户手动打印时输出尺寸
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
appendLog(`[DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
});
hideBtn.addEventListener('click', ()=> {
document.documentElement.classList.add('hide-debug');
appendLog('[ACTION] 已隐藏调试面板(录制模式)');
});
})();
</script>
</body>
</html>
已按你的要求:锐化更轻、日志最小化、保留关键 attach/align 逻辑。若你希望把锐化再微调(更轻或更重),或把日志进一步删减,我可以立刻再发一版。abundant-magician-17307
12/23/2025, 2:13 AMtall-airline-16756
12/23/2025, 2:14 AM<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-sharpen-clean</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#wrap{position:relative;width:100vw;height:100vh}
#container{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#000}
video{
width:100%;
height:100%;
object-fit:contain;
transform:none;
backface-visibility:hidden;
will-change:auto;
-webkit-font-smoothing:antialiased;
/* 轻微锐化 + 降低亮度避免过曝 + 轻微对比 */
filter: url(#sharpen_soft) contrast(0.98) brightness(0.95) saturate(1.02);
}
/* 仅保留简洁控件(可在录制时隐藏) */
#controls{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.4);padding:8px;border-radius:6px;font-size:12px;z-index:1000}
button{margin:4px 0;padding:6px 8px}
.hide-debug #controls { display:none !important; }
</style>
<!-- 非侵入式轻微锐化核:中心 2.2 邻位 -0.2 -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="sharpen_soft" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<feConvolveMatrix order="3" kernelMatrix="0 -0.2 0 -0.2 2.2 -0.2 0 -0.2 0" divisor="1" bias="0" kernelUnitLength="1 1"/>
</filter>
</defs>
</svg>
</head>
<body>
<div id="wrap">
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">打印尺寸</button>
<button id="hide">隐藏控件(录制)</button>
</div>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const hideBtn = document.getElementById('hide');
function logConsole(s){ console.log(new Date().toISOString(), s); }
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; logConsole('[STATUS] ' + s); }
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
function alignVideoPixels(videoEl){
try {
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
logConsole(`[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr}`);
} catch (e) { logConsole('[ALIGN ERROR] ' + e); }
}
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
logConsole(`[ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih}`);
if (iw > 0 && ih > 0){
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
logConsole('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
(async ()=>{
setStatus('加载脚本...');
try { await loadScript(UMD_URL); } catch (e) { logConsole('[ERROR] 脚本加载失败: ' + e); setStatus('脚本加载失败'); return; }
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) { logConsole('[ERROR] 未找到 LiveKit 全局'); setStatus('脚本已载入但未找到 LiveKit'); return; }
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); logConsole('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
logConsole('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
logConsole(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'}`);
if (track.kind === 'video') await attachWithRetry(track, videoEl, 6, 300);
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
logConsole(`[EVENT] TrackUnsubscribed kind=${track.kind}`);
if (track.kind === 'video') videoEl.srcObject = null;
});
room.on(LK.RoomEvent.Connected, ()=> { logConsole('[EVENT] Room Connected'); setStatus('已连接'); console.log('START_RECORDING'); });
room.on(LK.RoomEvent.Disconnected, ()=> { logConsole('[EVENT] Room Disconnected'); setStatus('已断开'); });
try { appendLog; await room.connect(url, token); logConsole('[ACTION] connect() resolved'); } catch (e) { logConsole('[ERROR] connect() failed: ' + (e && (e.message||e))); setStatus('连接失败'); }
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
logConsole(`[DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
alert(`css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
});
hideBtn.addEventListener('click', ()=> { document.documentElement.classList.add('hide-debug'); logConsole('[ACTION] 已隐藏控件(录制模式)'); });
// helper to avoid old periodic logs (none)
})();
</script>
</body>
</html>
测试后告诉我:
• 现在文字是否更清晰且不过曝?(是 / 否)
• 如果仍过曝或文字不清晰,回复“仍不清晰”,我将继续(先建议把 egress bitrate 提高到 6000–8000 kbps 并重试)。abundant-magician-17307
12/23/2025, 2:18 AMtall-airline-16756
12/23/2025, 2:19 AM<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-final</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#wrap{position:relative;width:100vw;height:100vh}
#container{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#000}
video{
width:100%;
height:100%;
object-fit:contain;
transform:none;
backface-visibility:hidden;
will-change:auto;
-webkit-font-smoothing:antialiased;
/* 轻微锐化 + 降低亮度避免过曝 + 轻微对比 */
filter: url(#sharpen_soft) contrast(0.98) brightness(0.95) saturate(1.02);
}
/* 简洁控件(可在录制时隐藏) */
#controls{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.4);padding:8px;border-radius:6px;font-size:12px;z-index:1000}
button{margin:4px 0;padding:6px 8px}
.hide-debug #controls { display:none !important; }
</style>
<!-- 非侵入式轻微锐化核:中心 2.2 邻位 -0.2 -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="sharpen_soft" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<feConvolveMatrix order="3" kernelMatrix="0 -0.2 0 -0.2 2.2 -0.2 0 -0.2 0" divisor="1" bias="0" kernelUnitLength="1 1"/>
</filter>
</defs>
</svg>
</head>
<body>
<div id="wrap">
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">打印尺寸</button>
<button id="hide">隐藏控件(录制)</button>
</div>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const hideBtn = document.getElementById('hide');
function logConsole(s){ console.log(new Date().toISOString(), s); }
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; logConsole('[STATUS] ' + s); }
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
function alignVideoPixels(videoEl){
try {
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
logConsole(`[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr}`);
} catch (e) { logConsole('[ALIGN ERROR] ' + e); }
}
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
logConsole(`[ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih}`);
if (iw > 0 && ih > 0){
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
logConsole('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
(async ()=>{
setStatus('加载脚本...');
try { await loadScript(UMD_URL); } catch (e) { logConsole('[ERROR] 脚本加载失败: ' + e); setStatus('脚本加载失败'); return; }
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) { logConsole('[ERROR] 未找到 LiveKit 全局'); setStatus('脚本已载入但未找到 LiveKit'); return; }
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); logConsole('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
logConsole('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
logConsole(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'}`);
if (track.kind === 'video') await attachWithRetry(track, videoEl, 6, 300);
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
logConsole(`[EVENT] TrackUnsubscribed kind=${track.kind}`);
if (track.kind === 'video') videoEl.srcObject = null;
});
room.on(LK.RoomEvent.Connected, ()=> { logConsole('[EVENT] Room Connected'); setStatus('已连接'); console.log('START_RECORDING'); });
room.on(LK.RoomEvent.Disconnected, ()=> { logConsole('[EVENT] Room Disconnected'); setStatus('已断开'); });
try {
await room.connect(url, token);
logConsole('[ACTION] connect() resolved');
} catch (e) {
logConsole('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
logConsole(`[DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
alert(`css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
});
hideBtn.addEventListener('click', ()=> { document.documentElement.classList.add('hide-debug'); logConsole('[ACTION] 已隐藏控件(录制模式)'); });
})();
</script>
</body>
</html>
试完后告诉我:
• 是否还报错?(是/否)
• 文字是否更清晰且不过曝?(是/否)
若仍有问题,我会根据你反馈继续调整(或直接给出 egress bitrate/profile 的请求示例)。abundant-magician-17307
12/23/2025, 2:27 AM[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr});
} catch (e) {
appendLog('[ALIGN ERROR] ' + e);
}
}
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
appendLog([ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih});
if (iw > 0 && ih > 0){
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
appendLog('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
async function fetchReceiverStats(track){
try{
if (!track || !track.mediaStreamTrack) { appendLog('[STATS] track 无效'); return; }
appendLog('[STATS] 尝试读取 receiver stats...');
const receiver = track._receiver || null;
if (receiver && typeof receiver.getStats === 'function') {
const stats = await receiver.getStats();
appendLog('[STATS] receiver.getStats() 返回 ' + (stats.size||'?'));
try {
stats.forEach(s => {
if (s.type === 'inbound-rtp' || s.type === 'track') {
appendLog(' stat: ' + s.type + ' bytesReceived=' + (s.bytesReceived||'') + ' framesDecoded=' + (s.framesDecoded||'') + ' packetsLost=' + (s.packetsLost||''));
}
});
} catch(e){ appendLog('[STATS PARSE ERROR] ' + e); }
return;
}
appendLog('[STATS] 无法读取 receiver.getStats,需在 SFU/egress 侧获取 webrtc internals');
} catch (e) { appendLog('[STATS ERROR] ' + e); }
}
(async ()=>{
setStatus('加载脚本...');
try {
await loadScript(UMD_URL);
} catch (e) {
appendLog('[ERROR] 脚本加载失败: ' + e);
setStatus('脚本加载失败');
return;
}
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) {
appendLog('[ERROR] 已加载脚本但未导出 LiveKit 全局对象。window keys: ' + Object.keys(window).slice(-50).join(','));
setStatus('脚本已载入但未找到 LiveKit 全局名');
return;
}
appendLog('[INFO] LiveKit 可用,使用全局名: ' + (window.LiveKit ? 'LiveKit' : (window.LivekitClient ? 'LivekitClient' : 'unknown')));
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); appendLog('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
appendLog('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
appendLog([EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'} sid=${pub?.sid||pub?.trackSid||''});
if (track.kind === 'video') {
await attachWithRetry(track, videoEl, 6, 300);
fetchReceiverStats(track).catch(()=>{});
}
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
appendLog([EVENT] TrackUnsubscribed kind=${track.kind});
if (track.kind === 'video') {
videoEl.srcObject = null;
}
});
room.on(LK.RoomEvent.Connected, ()=> {
appendLog('[EVENT] Room Connected name=' + (room.name||'unknown'));
setStatus('已连接');
console.log('START_RECORDING');
});
room.on(LK.RoomEvent.Disconnected, ()=> { appendLog('[EVENT] Room Disconnected'); setStatus('已断开'); });
appendLog('[ACTION] 开始连接 room...');
try {
await room.connect(url, token);
appendLog('[ACTION] connect() resolved');
} catch (e) {
appendLog('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
setInterval(()=>{
try {
const rect = videoEl.getBoundingClientRect();
appendLog([PERIODIC] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio});
} catch(e){ appendLog('[PERIODIC ERROR] ' + e); }
}, 5000);
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
appendLog([DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio});
});
hideBtn.addEventListener('click', ()=> {
document.documentElement.classList.add('hide-debug');
appendLog('[ACTION] 已隐藏调试面板(录制模式)');
});
})();
</script>
</body>
</html>tall-airline-16756
12/23/2025, 2:28 AM<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-sharpen-strong</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#wrap{position:relative;width:100vw;height:100vh}
#container{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#000}
video{
width:100%;
height:100%;
object-fit:contain;
transform:none;
backface-visibility:hidden;
will-change:auto;
-webkit-font-smoothing:antialiased;
/* 恢复你偏好的参数:锐化 + 适度对比/饱和 */
filter: url(#sharpen_strong) contrast(1.05) saturate(1.03);
}
/* 简洁控件(可在录制时隐藏) */
#controls{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.4);padding:8px;border-radius:6px;font-size:12px;z-index:1000}
button{margin:4px 0;padding:6px 8px}
.hide-debug #controls { display:none !important; }
</style>
<!-- 强锐化滤镜: center=5 neighbor=-1 -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="sharpen_strong" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<!-- kernel:
0 -1 0
-1 5 -1
0 -1 0 -->
<feConvolveMatrix order="3" kernelMatrix="0 -1 0 -1 5 -1 0 -1 0" divisor="1" bias="0" kernelUnitLength="1 1"/>
</filter>
</defs>
</svg>
</head>
<body>
<div id="wrap">
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">打印尺寸</button>
<button id="hide">隐藏控件(录制)</button>
</div>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const hideBtn = document.getElementById('hide');
function logConsole(s){ console.log(new Date().toISOString(), s); }
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; logConsole('[STATUS] ' + s); }
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
function alignVideoPixels(videoEl){
try {
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
logConsole(`[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr}`);
} catch (e) { logConsole('[ALIGN ERROR] ' + e); }
}
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
logConsole(`[ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih}`);
if (iw > 0 && ih > 0){
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
logConsole('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
(async ()=>{
setStatus('加载脚本...');
try { await loadScript(UMD_URL); } catch (e) { logConsole('[ERROR] 脚本加载失败: ' + e); setStatus('脚本加载失败'); return; }
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) { logConsole('[ERROR] 未找到 LiveKit 全局'); setStatus('脚本已载入但未找到 LiveKit'); return; }
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); logConsole('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
logConsole('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
logConsole(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'}`);
if (track.kind === 'video') await attachWithRetry(track, videoEl, 6, 300);
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
logConsole(`[EVENT] TrackUnsubscribed kind=${track.kind}`);
if (track.kind === 'video') videoEl.srcObject = null;
});
room.on(LK.RoomEvent.Connected, ()=> { logConsole('[EVENT] Room Connected'); setStatus('已连接'); console.log('START_RECORDING'); });
room.on(LK.RoomEvent.Disconnected, ()=> { logConsole('[EVENT] Room Disconnected'); setStatus('已断开'); });
try {
await room.connect(url, token);
logConsole('[ACTION] connect() resolved');
} catch (e) {
logConsole('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
logConsole(`[DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
alert(`css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
});
hideBtn.addEventListener('click', ()=> { document.documentElement.classList.add('hide-debug'); logConsole('[ACTION] 已隐藏控件(录制模式)'); });
})();
</script>
</body>
</html>
下一步我建议(你选一):
• A — 现在就把上面文件跑一轮录制并比较效果;
• B — 我把 sharpening kernel 调小一点(center=4, neighbor=-0.8)并发给你;
• C — 我给出 startRoomCompositeEgress 请求示例,把 bitrate 提高到 8000 与 H264_HIGH(与锐化配合最佳)。
你想先做哪一步?abundant-magician-17307
12/23/2025, 2:38 AMtall-airline-16756
12/23/2025, 2:39 AM<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-sharpen-tuned</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#wrap{position:relative;width:100vw;height:100vh}
#container{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#000}
video{
width:100%;
height:100%;
object-fit:contain;
transform:none;
backface-visibility:hidden;
will-change:auto;
-webkit-font-smoothing:antialiased;
/* 轻微锐化 + 轻微对比,避免过曝 */
filter: url(#sharpen_tuned) contrast(1.03) saturate(1.02);
}
#controls{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.4);padding:8px;border-radius:6px;font-size:12px;z-index:1000}
button{margin:4px 0;padding:6px 8px}
.hide-debug #controls { display:none !important; }
</style>
<!-- 调整后较温和的锐化核:center=3.5 neighbor=-0.5 -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="sharpen_tuned" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<!-- kernel:
0 -0.5 0
-0.5 3.5 -0.5
0 -0.5 0 -->
<feConvolveMatrix order="3" kernelMatrix="0 -0.5 0 -0.5 3.5 -0.5 0 -0.5 0" divisor="1" bias="0" kernelUnitLength="1 1"/>
</filter>
</defs>
</svg>
</head>
<body>
<div id="wrap">
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">打印尺寸</button>
<button id="hide">隐藏控件(录制)</button>
</div>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const hideBtn = document.getElementById('hide');
function logConsole(s){ console.log(new Date().toISOString(), s); }
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; logConsole('[STATUS] ' + s); }
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
function alignVideoPixels(videoEl){
try {
const rect = videoEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
videoEl.width = Math.round(rect.width * dpr);
videoEl.height = Math.round(rect.height * dpr);
logConsole(`[ALIGN] set video.width=${videoEl.width}, video.height=${videoEl.height}, dpr=${dpr}`);
} catch (e) { logConsole('[ALIGN ERROR] ' + e); }
}
async function attachWithRetry(track, videoEl, maxRetries = 6, interval = 300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
videoEl.srcObject = ms;
videoEl.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries){
await new Promise(r => setTimeout(r, interval));
const iw = videoEl.videoWidth || 0;
const ih = videoEl.videoHeight || 0;
logConsole(`[ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih}`);
if (iw > 0 && ih > 0){
alignVideoPixels(videoEl);
return true;
}
attempts++;
}
logConsole('[ATTACH WARN] intrinsic仍为0,attach 可能失败或轨道无帧');
return false;
}
(async ()=>{
setStatus('加载脚本...');
try { await loadScript(UMD_URL); } catch (e) { logConsole('[ERROR] 脚本加载失败: ' + e); setStatus('脚本加载失败'); return; }
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) { logConsole('[ERROR] 未找到 LiveKit 全局'); setStatus('脚本已载入但未找到 LiveKit'); return; }
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); logConsole('[ERROR] 缺少 url 或 token'); return; }
const room = new LK.Room({ autoSubscribe: true });
logConsole('[INFO] 创建 Room 实例');
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
logConsole(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity || 'unknown'}`);
if (track.kind === 'video') await attachWithRetry(track, videoEl, 6, 300);
});
room.on(LK.RoomEvent.TrackUnsubscribed, (track) => {
logConsole(`[EVENT] TrackUnsubscribed kind=${track.kind}`);
if (track.kind === 'video') videoEl.srcObject = null;
});
room.on(LK.RoomEvent.Connected, ()=> { logConsole('[EVENT] Room Connected'); setStatus('已连接'); console.log('START_RECORDING'); });
room.on(LK.RoomEvent.Disconnected, ()=> { logConsole('[EVENT] Room Disconnected'); setStatus('已断开'); });
try {
await room.connect(url, token);
logConsole('[ACTION] connect() resolved');
} catch (e) {
logConsole('[ERROR] connect() failed: ' + (e && (e.message||e)));
setStatus('连接失败');
}
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
logConsole(`[DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
alert(`css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
});
hideBtn.addEventListener('click', ()=> { document.documentElement.classList.add('hide-debug'); logConsole('[ACTION] 已隐藏控件(录制模式)'); });
})();
</script>
</body>
</html>abundant-magician-17307
12/23/2025, 2:41 AMtall-airline-16756
12/23/2025, 2:42 AMfilter 中的 brightness 和 `contrast`(以及 saturate 的极端值)控制。我不会再改动这些曝光相关参数。下面做三件事:
1) 明确哪些参数影响曝光(不再改动)
• brightness(亮度)和 contrast(对比度)直接影响画面“曝光感”;提高会显得更亮/更饱和。
• saturate(饱和度)也会影响视觉明暗,但不是曝光的主要因素。
我不会再修改这三个参数,除非你明确同意。
2) 我将只调整“锐化核”以减弱锐化强度(不会改动 contrast/brightness/saturate),并给出可直接替换的完整 HTML。
• 保持你之前喜欢的曝光参数: contrast(1.05) saturate(1.03)(不改)
• 将卷积核改为更温和值:center = 3,neighbor = -0.35(比上次更轻但仍有效)
• 移除页面内的可见日志,仅在 console 输出关键事件
3) 下面是完整 HTML(仅调整锐化核,不改 contrast/brightness/saturate)。保存并替换后测试即可:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-sharpen-final</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#container{width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;background:#000}
video{
width:100%;
height:100%;
object-fit:contain;
transform:none;
backface-visibility:hidden;
will-change:auto;
-webkit-font-smoothing:antialiased;
/* 保持你希望的不变曝光参数 */
/* 不改 contrast/brightness/saturate */
filter: url(#sharpen_final) contrast(1.05) saturate(1.03);
}
#controls{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.4);padding:8px;border-radius:6px;font-size:12px;z-index:1000}
button{margin:4px 0;padding:6px 8px}
.hide-debug #controls { display:none !important; }
</style>
<!-- 温和锐化: center=3 neighbor=-0.35(仅调锐化核) -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="sharpen_final" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<!-- kernel:
0 -0.35 0
-0.35 3 -0.35
0 -0.35 0 -->
<feConvolveMatrix order="3" kernelMatrix="0 -0.35 0 -0.35 3 -0.35 0 -0.35 0" divisor="1" bias="0" kernelUnitLength="1 1"/>
</filter>
</defs>
</svg>
</head>
<body>
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">打印尺寸</button>
<button id="hide">隐藏控件(录制)</button>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const statusEl = document.getElementById('status');
const videoEl = document.getElementById('video');
const dumpBtn = document.getElementById('dump');
const hideBtn = document.getElementById('hide');
function logConsole(s){ console.log(new Date().toISOString(), s); }
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; logConsole('[STATUS] ' + s); }
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
function alignVideoPixels(el){
try {
const rect = el.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
el.width = Math.round(rect.width * dpr);
el.height = Math.round(rect.height * dpr);
logConsole(`[ALIGN] set video.width=${el.width}, video.height=${el.height}, dpr=${dpr}`);
} catch (e) { logConsole('[ALIGN ERROR] ' + e); }
}
async function attachWithRetry(track, el, maxRetries=6, interval=300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
el.srcObject = ms;
el.play().catch(()=>{});
let attempts=0;
while(attempts<maxRetries){
await new Promise(r=>setTimeout(r, interval));
const iw = el.videoWidth||0, ih = el.videoHeight||0;
logConsole(`[ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih}`);
if(iw>0 && ih>0){ alignVideoPixels(el); return true; }
attempts++;
}
logConsole('[ATTACH WARN] intrinsic 仍为 0');
return false;
}
(async ()=>{
setStatus('加载脚本...');
try{ await loadScript(UMD_URL); }catch(e){ logConsole('[ERROR] script load failed', e); setStatus('脚本加载失败'); return; }
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if(!LK){ logConsole('[ERROR] 未找到 LiveKit 全局'); setStatus('脚本未找到'); return; }
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if(!url || !token){ setStatus('缺少 url/token'); logConsole('[ERROR] missing params'); return; }
const room = new LK.Room({ autoSubscribe:true });
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
logConsole(`[EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity||'unknown'}`);
if(track.kind==='video') await attachWithRetry(track, videoEl);
});
room.on(LK.RoomEvent.TrackUnsubscribed, (t)=>{ logConsole('[EVENT] TrackUnsubscribed'); if(t.kind==='video') videoEl.srcObject=null; });
room.on(LK.RoomEvent.Connected, ()=>{ setStatus('已连接'); console.log('START_RECORDING'); });
room.on(LK.RoomEvent.Disconnected, ()=> setStatus('已断开'));
try { await room.connect(url, token); logConsole('[ACTION] connect resolved'); } catch(e){ logConsole('[ERROR] connect failed', e); setStatus('连接失败'); }
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
logConsole(`[DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
alert(`css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio}`);
});
hideBtn.addEventListener('click', ()=> { document.documentElement.classList.add('hide-debug'); logConsole('[ACTION] hide controls'); });
})();
</script>
</body>
</html>
测试后告诉我两项结果(简短):
• 文字是否清晰且不过曝?(是/否)
• 是否需要我再把 kernel 微调为更轻(center=2.8, neighbor=-0.2),还是回退到你更喜欢的原始强锐化?abundant-magician-17307
12/23/2025, 5:49 AMtall-airline-16756
12/23/2025, 5:50 AMabundant-magician-17307
12/23/2025, 5:57 AMtall-airline-16756
12/23/2025, 5:58 AMabundant-magician-17307
12/23/2025, 6:05 AMtall-airline-16756
12/23/2025, 6:06 AMvideoBitrate 与 preset(或优化发布端分辨率)来改善清晰度。需要我给出具体的 startRoomCompositeEgress JSON 示例吗?
• 若你自托管并能配备 GPU:我可以给出具体操作步骤(安装驱动、GStreamer 插件、并在 egress 的 pipeline 中切换到 nvh264enc 或 `vaapi`),以及示例命令/配置。
你想做哪一项(回复 A 或 B)? A — 我需要一个 startRoomCompositeEgress 示例,把 bitrate 提高到 8000 kbps 并用 H264_HIGH(用于 LiveKit Cloud / CPU 环境的最佳实践)。 B — 我需要在自托管环境启用 GPU 编码,请给我详细的部署步骤(NVIDIA 驱动 + GStreamer + 修改 egress 编码器示例)。
Sources: Custom recording templates | LiveKit docs | Universal Egress | Yeah, but can it run DOOM? | Streaming video from a canvas with WebRTC and Reactabundant-magician-17307
12/23/2025, 6:12 AM[ALIGN] set video.width=${el.width}, video.height=${el.height}, dpr=${dpr});
} catch (e) { logConsole('[ALIGN ERROR] ' + e); }
}
async function attachWithRetry(track, el, maxRetries=6, interval=300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
el.srcObject = ms;
el.play().catch(()=>{});
let attempts=0;
while(attempts<maxRetries){
await new Promise(r=>setTimeout(r, interval));
const iw = el.videoWidth||0, ih = el.videoHeight||0;
logConsole([ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih});
if(iw>0 && ih>0){ alignVideoPixels(el); return true; }
attempts++;
}
logConsole('[ATTACH WARN] intrinsic 仍为 0');
return false;
}
(async ()=>{
setStatus('加载脚本...');
try{ await loadScript(UMD_URL); }catch(e){ logConsole('[ERROR] script load failed', e); setStatus('脚本加载失败'); return; }
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if(!LK){ logConsole('[ERROR] 未找到 LiveKit 全局'); setStatus('脚本未找到'); return; }
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if(!url || !token){ setStatus('缺少 url/token'); logConsole('[ERROR] missing params'); return; }
const room = new LK.Room({ autoSubscribe:true });
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
logConsole([EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity||'unknown'});
if(track.kind==='video') await attachWithRetry(track, videoEl);
});
room.on(LK.RoomEvent.TrackUnsubscribed, (t)=>{ logConsole('[EVENT] TrackUnsubscribed'); if(t.kind==='video') videoEl.srcObject=null; });
room.on(LK.RoomEvent.Connected, ()=>{ setStatus('已连接'); console.log('START_RECORDING'); });
room.on(LK.RoomEvent.Disconnected, ()=> setStatus('已断开'));
try { await room.connect(url, token); logConsole('[ACTION] connect resolved'); } catch(e){ logConsole('[ERROR] connect failed', e); setStatus('连接失败'); }
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
logConsole([DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio});
alert(css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio});
});
hideBtn.addEventListener('click', ()=> { document.documentElement.classList.add('hide-debug'); logConsole('[ACTION] hide controls'); });
})();
</script>
</body>
</html>这个html是否存在性能问题,代码是否需要优化tall-airline-16756
12/23/2025, 6:13 AMconst DEBUG = false;
function logConsole(s){ if (DEBUG) console.log(new Date().toISOString(), s); }
• 略微放宽重试频率与次数:
async function attachWithRetry(track, el, maxRetries=4, interval=400){ ... }
• 在连接成功后隐藏滤镜(可选):
// 若想录制不带滤镜,可以在 Connected 回调移除滤镜:
room.on(LK.RoomEvent.Connected, ()=> {
document.querySelector('video').style.filter = 'contrast(1.03) saturate(1.08)';
});
建议的生产注意事项
• 在自托管环境下启用硬件编码(nvh264enc/vaapi)比在模板里做昂贵滤镜更能提升文字清晰度。
• 在高并发或长时录制场景,请监控 headless Chrome 的 CPU/GPU 使用并考虑减少实时滤镜或升级实例规格。
• 在最终录制前把 DEBUG 关掉,隐藏控件并移除不必要的 console 打印。
如果你愿意,我可以马上:
• A) 返回一个已应用上述优化(DEBUG=false、重试调整、日志减少、在 connected 时自动移除滤镜以用于最终录制)的完整 HTML 文件;或
• B) 只把上述三处代码片段直接替换到你当前文件里。
你选 A 还是 B?abundant-magician-17307
12/23/2025, 6:46 AMtall-airline-16756
12/23/2025, 6:46 AMrequestAnimationFrame 帧间 pixel difference 超阈值/或 track 帧率 > X,则关闭 filter。
◦ 更简单可行:在 TrackSubscribed 回调中,如果 participant 发布的是屏幕共享视频(或 mime/label 表明是 video),默认不启用 sharpen;若是静态PPT/文档渲染再启用。
2) 降低滤镜复杂度(次优,低侵入)
• 把卷积核减小(比如 3x3 -> 更低权重),或用较轻的 unsharp-ish 操作(contrast + subtle `feConvolveMatrix`),已做过但可再微调:neighbor 从 -0.35 改到 -0.2,center 从 3→2.5。
• 或替代为 CSS 的 contrast() + brightness() + drop-shadow() 的组合,成本更低。
3) 将锐化移到 GPU(若可用)或用 WebGL 实现(中级)
• WebGL shader(fragment shader)比 SVG 卷积更高效,可在 GPU 上并行执行,显著降低 CPU。实现代价:需要把合成从 DOM->canvas(WebGL)实现。
• 如果部署主机支持 GPU 并且 headless Chrome 能使用 GPU,则 WebGL 更合适;否则仍会回退到软件。
4) 在编码链路优化(并行策略,不替代前面)
• 提高 egress 的 `videoBitrate`(如 6000–8000 kbps)和使用高 profile(H264_HIGH),能恢复更多细节,减少对锐化的依赖。此方法不会增加 Chrome CPU。优先在 Cloud/CPU 环境先尝试提高 bitrate 做对比。
5) 服务端/后端处理(如果你可控)
• 在 capture 后(在较强算力处或转码机)用 ffmpeg 做单次锐化/去噪处理再输出。这样客户端渲染无负担,但需要额外转码资源。
实用代码片段(模板内快速实现:在检测到动态画面时自动禁用 filter)
let motionEnabled = true;
function setSharpen(enabled){
document.querySelector('video').style.filter = enabled
? 'url(#sharpen_final) contrast(1.05) saturate(1.03)'
: 'contrast(1.05) saturate(1.03)';
}
let lastFrameData = null;
async function checkMotion(){
// 简单方法:用 canvas 抽样对比少量像素
const v = document.getElementById('video');
if (!v || v.videoWidth===0) return;
const c = document.createElement('canvas');
c.width = 160; c.height = 90;
const ctx = c.getContext('2d');
ctx.drawImage(v, 0, 0, c.width, c.height);
const data = ctx.getImageData(0,0,c.width,c.height).data;
if (lastFrameData){
// 计算少量像素差异
let diff = 0;
for (let i=0;i<data.length;i+=40){ diff += Math.abs(data[i]-lastFrameData[i]); }
if (diff > 1000 && motionEnabled){ setSharpen(false); motionEnabled=false; }
else if (diff <= 1000 && !motionEnabled){ setSharpen(true); motionEnabled=true; }
}
lastFrameData = data;
}
setInterval(checkMotion, 500);
说明:阈值和采样间隔可调整;这方法轻量且可直接植入你现有模板。
监控与验证(必做)
• 在做改动时同时观察 headless Chrome 的 CPU 使用(top/htop 或容器监控)。
• 录制两段对比(有/无锐化、或锐化启/禁自动切换),检查主观流畅度与文字清晰度。
• 如果部署环境支持 GPU,请考虑把合成移到 WebGL 并确保 headless Chrome 可访问 GPU。
我可以立刻帮你做的事(选一)
• A:把上面的自动禁用锐化的检测代码集成到你当前模板并返回完整 HTML(推荐先试)。
• B:把模板改为使用 WebGL shader 的版本(需要更多时间/测试,但性能最好)。
• C:给你 startRoomCompositeEgress 的 JSON 示例,把 bitrate 提高到 8000 kbps(先做编码层对比)。
你想先试哪一项?abundant-magician-17307
12/23/2025, 6:55 AMtall-airline-16756
12/23/2025, 6:56 AMabundant-magician-17307
12/23/2025, 7:51 AMtall-airline-16756
12/23/2025, 7:51 AMnvh264enc / vaapi 等;若是 LiveKit Cloud,通常是软件编码(CPU)。
3) 观察 egress 机器资源使用
• 在录制时看 CPU 占用(headless Chrome / gst-launch 相关进程),是否到 100% 单核/多核。
4) 比较两个录制文件(本地 vs egress)
• 导出短片后用 ffprobe 看帧率、分辨率、码率、GOP,或把样片发来我可帮看(若允许)。
5) 验证订阅层与源流质量
• 在 egress 日志看 track 的 spatial layer(你之前能看到 layer=2),并确认发布端确实在发送高分辨率高码率层。
6) 简易功能测试:临时禁用锐化再录制
• 在 template 中禁用 SVG/CSS 锐化,若导出变流畅,说明滤镜在 egress 环境造成额外负载。
即时优化建议(先做这几项) 1) 如果 egress CPU 高并且无 GPU:禁用或动态关闭锐化(只在静态画面启用),用前面给你的 motion-detect 自动切换逻辑。 2) 提高编码比特率做对比(videoBitrate → 6000–8000 kbps)并用 H264_HIGH。 3) 若可自托管:启用 GPU(NVIDIA)并配置 GStreamer 使用硬件编码器,或使 headless Chrome 能用 GPU(容器需映射 GPU 并传入 --enable-gpu 等参数)。 4) 简化 template 渲染:减少 DOM/动画、canvas 大小与滤镜复杂度,确保 video 元素以 1:1 像素渲染。
若你愿意,我可以帮你下一步做其中一项(选一项回复我): A — 分析你 egress worker 的启动与 runtime 日志(贴相关片段,我看是否启用 GPU/有错) B — 生成自动在动态场景禁用锐化的完整 template(我直接把代码给你替换) C — 给出 startRoomCompositeEgress 的示例 JSON(把 bitrate 提高到 8000 并用 H264_HIGH) D — 帮你分析一段 egress 导出的视频(你上传 ffprobe 输出或简短样片信息)
要我先做哪一项?abundant-magician-17307
12/23/2025, 7:59 AMtall-airline-16756
12/23/2025, 7:59 AMvideo {
width:100%;
height:100%;
object-fit:contain;
transform: translateZ(0); /* 强制合成图层,便于 GPU 合成 */
will-change: transform, filter; /* 提示浏览器预构建合成层 */
image-rendering: pixelated; /* 有助于文字清晰感(可选) */
/* 不在这里放 heavy SVG feConvolveMatrix,改用 WebGL 或轻量 filter */
filter: contrast(1.05) saturate(1.03);
}
• JS(自动在高运动时禁用重滤镜并可选切换到 WebGL 路径)示意:
// 简单 motion detector: 如果动态强则禁用 heavy filter
let heavyEnabled = true;
function setHeavy(enabled){
const v = document.getElementById('video');
if(enabled){
v.style.filter = 'contrast(1.05) saturate(1.03)'; // light
// 若用 WebGL path, enable it here
} else {
v.style.filter = 'contrast(1.05) saturate(1.03)'; // same light filter; heavy GPU shader disabled
}
}
• WebGL 路径建议(摘要):
◦ 创建一个 canvas(WebGL),把 video 作为纹理上传,使用 fragment shader 做 3x3 卷积(或更高级的 unsharp mask)。这样在有 GPU 时锐化在 GPU 上并行执行,CPU 负载低。实现比 SVG 复杂,我可以把可运行的完整示例发给你(若需要)。
3) 需要你在 egress 环境做的系统/部署改动(否则 HTML 优化效果有限)
• 在自托管环境:确保机器有 GPU、驱动和 container runtime(nvidia-container-toolkit),并以带 GPU 的方式运行 egress;启动 Chromium 时传入:`--enable-gpu --use-gl=egl --ignore-gpu-blocklist` 等参数并把 GPU 设备映射给容器。
• 在云/受管环境(LiveKit Cloud):通常无法控制底层 Chromium 的 GPU 权限或编码器,建议用提高 bitrate / 使用硬件编码的自托管方案替代。
4) 我可以立即做(选其一回复)
• A — 给你把当前完整 HTML 改为使用 WebGL shader 的版本(将锐化放到 GPU),并保留自动在高运动时禁用策略。
• B — 仅把现有模板按上面 CSS/JS 做最小改动以“更容易被 GPU 加速”,并加入 motion-detect 自动禁用 heavy filter(代码片段直接可用)。
• C — 给出在自托管 egress 中启用 GPU 的具体 Chromium 启动参数和 Docker/K8s 配置示例(包含 nvidia-container-toolkit、chromium flags 和 GStreamer 硬件编码示例)。
你想先要 A / B / C 哪一项?我马上把对应内容发给你。abundant-magician-17307
12/23/2025, 8:16 AMtall-airline-16756
12/23/2025, 8:16 AM<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>egress-gpu-friendly</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#fff;font-family:Arial,Helvetica,sans-serif}
#container{width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;background:#000}
/* 使 video 成为合成层,便于 GPU 加速 */
video{
width:100%;
height:100%;
object-fit:contain;
transform: translateZ(0); /* 强制创建复合层 */
will-change: transform, filter; /* 提示浏览器准备合成/滤镜优化 */
image-rendering: pixelated; /* 有助于文字清晰(可选) */
-webkit-font-smoothing:antialiased;
/* 轻量默认滤镜(不会影响曝光);重锐化由 JS 控制开关 */
filter: contrast(1.05) saturate(1.03);
background:#000;
}
/* 当启用重锐化时加类 .heavy-sharpen */
.heavy-sharpen {
/* 这只是占位,真实锐化由 SVG filter 或 WebGL shader 开启 */
filter: url(#sharpen_soft) contrast(1.05) saturate(1.03);
}
/* 简洁控件 */
#controls{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.4);padding:8px;border-radius:6px;font-size:12px;z-index:1000}
button{margin:4px 0;padding:6px 8px}
.hide-debug #controls { display:none !important; }
</style>
<!-- 轻微锐化滤镜(保守) -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="sharpen_soft" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<feConvolveMatrix order="3" kernelMatrix="0 -0.25 0 -0.25 2.8 -0.25 0 -0.25 0" divisor="1" bias="0" kernelUnitLength="1 1"/>
</filter>
</defs>
</svg>
</head>
<body>
<div id="container">
<video id="video" autoplay playsinline muted></video>
</div>
<div id="controls">
<div id="status">状态: loading</div>
<button id="dump">打印尺寸</button>
<button id="hide">隐藏控件(录制)</button>
</div>
<script>
(function(){
const UMD_URL = '<https://meetingin.hanweb.com/live_files/livekit-client.umd.js>';
const video = document.getElementById('video');
const statusEl = document.getElementById('status');
const dumpBtn = document.getElementById('dump');
const hideBtn = document.getElementById('hide');
let heavySharpenEnabled = true; // 初始启用(静态场景看起来更清晰)
const MOTION_CHECK_INTERVAL = 500; // ms
const DIFF_THRESHOLD = 1200; // 经验值,可调整(越小越敏感)
let lastFrameSample = null;
let motionTimer = null;
let checking = false;
function log(s){ console.log(new Date().toISOString(), s); }
function setStatus(s){ if (statusEl) statusEl.textContent = '状态: ' + s; log('[STATUS] ' + s); }
function setHeavySharpen(on){
heavySharpenEnabled = !!on;
if (heavySharpenEnabled) video.classList.add('heavy-sharpen');
else video.classList.remove('heavy-sharpen');
}
// 轻量采样差异检测:把 video 缩放到小 canvas 采样若差异大则判定为动态
function sampleFrameData() {
if (video.videoWidth === 0 || video.videoHeight === 0) return null;
const w = 160, h = 90; // 小分辨率采样
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d', { willReadFrequently: true });
try {
ctx.drawImage(video, 0, 0, w, h);
return ctx.getImageData(0, 0, w, h).data;
} catch (e) {
return null;
}
}
function computeDiff(a, b) {
if (!a || !b) return Infinity;
let s = 0;
// 采样步幅,减少计算量
for (let i = 0; i < a.length; i += 40) {
s += Math.abs(a[i] - b[i]);
}
return s;
}
function checkMotionOnce() {
if (checking) return;
checking = true;
const cur = sampleFrameData();
if (!cur) { checking = false; return; }
const diff = computeDiff(cur, lastFrameSample);
lastFrameSample = cur;
// diff 为 Infinity 表示第一次采样
if (diff === Infinity || diff === null) { checking = false; return; }
// 如果差异超过阈值,认为为高运动 -> 关闭重锐化
if (diff > DIFF_THRESHOLD) {
if (heavySharpenEnabled) {
setHeavySharpen(false);
log('Motion detected (diff=' + diff + '), disabling heavy sharpen');
}
} else {
// 低运动场景恢复重锐化
if (!heavySharpenEnabled) {
setHeavySharpen(true);
log('Low motion (diff=' + diff + '), enabling heavy sharpen');
}
}
checking = false;
}
function startMotionChecks() {
if (motionTimer) return;
motionTimer = setInterval(checkMotionOnce, MOTION_CHECK_INTERVAL);
}
function stopMotionChecks() {
if (motionTimer) { clearInterval(motionTimer); motionTimer = null; }
}
// attach 重试与 DPR 对齐(与你现有逻辑一致,保留)
async function attachWithRetry(track, el, maxRetries = 4, interval = 400) {
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
el.srcObject = ms;
el.play().catch(()=>{});
let attempts = 0;
while (attempts < maxRetries) {
await new Promise(r => setTimeout(r, interval));
const iw = el.videoWidth || 0, ih = el.videoHeight || 0;
log('[ATTEMPT] try=' + (attempts+1) + ' intrinsic=' + iw + 'x' + ih);
if (iw > 0 && ih > 0) {
const rect = el.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
el.width = Math.round(rect.width * dpr);
el.height = Math.round(rect.height * dpr);
log('[ALIGN] set video.width=' + el.width + ', video.height=' + el.height + ', dpr=' + dpr);
return true;
}
attempts++;
}
log('[ATTACH WARN] intrinsic still 0');
return false;
}
// 加载 LiveKit UMD 并连接房间(与你现在用法一致)
function loadScript(src){ return new Promise((res, rej) => {
const s = document.createElement('script'); s.src = src; s.async = false;
s.onload = ()=>res(); s.onerror = (e)=>rej(e); document.head.appendChild(s);
}); }
(async ()=>{
setStatus('加载脚本...');
try{ await loadScript(UMD_URL); }catch(e){ log('[ERROR] load script failed ' + e); setStatus('脚本加载失败'); return; }
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if (!LK) { log('[ERROR] LiveKit global not found'); setStatus('脚本未找到'); return; }
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if (!url || !token) { setStatus('缺少 url/token'); log('[ERROR] missing params'); return; }
const room = new LK.Room({ autoSubscribe: true });
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
log('[EVENT] TrackSubscribed kind=' + track.kind + ' participant=' + (participant?.identity||'unknown'));
if (track.kind === 'video') {
await attachWithRetry(track, video);
// 启动 motion 检测(只有当 video 有内容时)
startMotionChecks();
}
});
room.on(LK.RoomEvent.TrackUnsubscribed, (t) => { log('[EVENT] TrackUnsubscribed'); if (t.kind === 'video') { video.srcObject = null; stopMotionChecks(); } });
room.on(LK.RoomEvent.Connected, ()=> { setStatus('已连接'); console.log('START_RECORDING'); });
room.on(LK.RoomEvent.Disconnected, ()=> setStatus('已断开'));
try { await room.connect(url, token); log('[ACTION] connected'); } catch(e){ log('[ERROR] connect failed ' + e); setStatus('连接失败'); }
})();
dumpBtn.addEventListener('click', ()=> {
const rect = video.getBoundingClientRect();
alert(`css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${video.videoWidth}x${video.videoHeight} dpr:${window.devicePixelRatio}`);
});
hideBtn.addEventListener('click', ()=> { document.documentElement.classList.add('hide-debug'); log('[ACTION] hide controls'); });
// 初始状态:尽量启用 heavy sharpen(静态场景优先)
setHeavySharpen(true);
})();
</script>
</body>
</html>
使用说明(简短)
• 在本地浏览器或有 GPU 的 headless Chrome 上,此模板更可能利用 GPU 合成,从而在启用硬件加速时保持流畅且保留静态文字锐化。
• 在 egress(headless)环境若无 GPU,则 motion 检测会在动态场景自动关闭重锐化,避免卡顿。
• 若你部署在自托管并已启用 GPU,请同时确保 Chromium 启动参数和容器映射(nvidia runtime)允许 GPU 被使用,以发挥最佳效果。
我可以继续帮你:
• 把 motion 检测的阈值/采样大小调到你观测到的最合适值;或
• 把锐化实现改为 WebGL shader(在有 GPU 时性能和质量最好)。
下一步你想先做哪一项?abundant-magician-17307
12/23/2025, 8:24 AMtall-airline-16756
12/23/2025, 8:25 AMabundant-magician-17307
12/23/2025, 8:26 AMabundant-magician-17307
12/23/2025, 8:34 AM[ALIGN] set video.width=${el.width}, video.height=${el.height}, dpr=${dpr});
} catch (e) { logConsole('[ALIGN ERROR] ' + e); }
}
async function attachWithRetry(track, el, maxRetries=6, interval=300){
const ms = new MediaStream(); ms.addTrack(track.mediaStreamTrack);
el.srcObject = ms;
el.play().catch(()=>{});
let attempts=0;
while(attempts<maxRetries){
await new Promise(r=>setTimeout(r, interval));
const iw = el.videoWidth||0, ih = el.videoHeight||0;
logConsole([ATTEMPT] try=${attempts+1} intrinsic=${iw}x${ih});
if(iw>0 && ih>0){ alignVideoPixels(el); return true; }
attempts++;
}
logConsole('[ATTACH WARN] intrinsic 仍为 0');
return false;
}
(async ()=>{
setStatus('加载脚本...');
try{ await loadScript(UMD_URL); }catch(e){ logConsole('[ERROR] script load failed', e); setStatus('脚本加载失败'); return; }
await new Promise(r=>setTimeout(r,50));
const LK = window.LiveKit || window.LivekitClient || window.Livekit || window.Livekitclient || null;
if(!LK){ logConsole('[ERROR] 未找到 LiveKit 全局'); setStatus('脚本未找到'); return; }
setStatus('LiveKit 已加载');
const params = new URLSearchParams(window.location.search);
const url = params.get('url'), token = params.get('token');
if(!url || !token){ setStatus('缺少 url/token'); logConsole('[ERROR] missing params'); return; }
const room = new LK.Room({ autoSubscribe:true });
room.on(LK.RoomEvent.TrackSubscribed, async (track, pub, participant) => {
logConsole([EVENT] TrackSubscribed kind=${track.kind} participant=${participant?.identity||'unknown'});
if(track.kind==='video') await attachWithRetry(track, videoEl);
});
room.on(LK.RoomEvent.TrackUnsubscribed, (t)=>{ logConsole('[EVENT] TrackUnsubscribed'); if(t.kind==='video') videoEl.srcObject=null; });
room.on(LK.RoomEvent.Connected, ()=>{ setStatus('已连接'); console.log('START_RECORDING'); });
room.on(LK.RoomEvent.Disconnected, ()=> setStatus('已断开'));
try { await room.connect(url, token); logConsole('[ACTION] connect resolved'); } catch(e){ logConsole('[ERROR] connect failed', e); setStatus('连接失败'); }
})();
dumpBtn.addEventListener('click', ()=> {
const rect = videoEl.getBoundingClientRect();
logConsole([DUMP] css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio});
alert(css:${Math.round(rect.width)}x${Math.round(rect.height)} intrinsic:${videoEl.videoWidth}x${videoEl.videoHeight} dpr:${window.devicePixelRatio});
});
hideBtn.addEventListener('click', ()=> { document.documentElement.classList.add('hide-debug'); logConsole('[ACTION] hide controls'); });
})();
</script>
</body>
</html>基于此html,代码中加入图形加速功能,不区分场景,一直启用锐化