NOTES.md 16.2 KB

PixVerse "Live Dress" 直播间 — 本地留存资料

来源: https://world.pixverse.video/live/dress/?from=gallery&presetId=189197 抓取时间: 2026-09-09

已保存文件

  • index.html — 首次请求返回的原始 HTML(Next.js SSR shell + bootstrap 脚本)
  • _next/static/css/3ccb6200cb199234.css — 主样式表(~198KB,未压缩美化)
  • _next/static/chunks/app/live/dress/page-*.js — 该页面的组件逻辑(已打包压缩)
  • _next/static/chunks/app/layout-*.js — 全局布局
  • _next/static/chunks/1373-*.js / 6136-*.js / 9571-*.js — 公共 chunk(可能含直播间/聊天组件)
  • _next/static/chunks/vendor-ui-*.js / vendor-utils-*.js / vendor-axios-*.js — 依赖库
  • screenshots/01-initial-view.jpg — 页面截图

技术栈: Next.js (App Router),静态资源走 _next/static/chunks,构建 ID build-1788873789615。

关键接口 / 架构线索(从 bootstrap 脚本 + 网络请求观察到)

  • realtime-api.pixverse.ai
    • POST /api/presets/plaza — 页面首屏预取(在 head 内联脚本里通过 fetch 提前发起,配合 window.__PREFETCH__PROMISE__ 做请求去重/复用)
    • POST /rtg/live-sessions — 疑似创建/加入直播会话
    • POST /rtg/outfit-queen/command — 疑似"换装"指令接口
    • POST /live/room/message — 实际观察到的请求,点击换装缩略图的"Send"后触发,应该是统一的直播间消息通道(聊天 + 换装指令都走这里)
  • world-media.pixverse.ai/admin/uploads/images/... — 后台上传的素材图(换装缩略图、观众头像等),统一走 ?x-oss-process=style/cover-webp-small 之类的阿里云 OSS 图片处理参数做压缩/裁剪
  • media.pixverse.ai/pixverse/t2i/ori/<uuid>.jpg — AI 生成图(t2i = text-to-image),?x-oss-process=image/resize,h_128 做缩略图
  • 鉴权: cookie _ai_share_token,作为 Authorization: Bearer <token> 发送(仅当路径是 /live/dress 且不在 iframe 内时才带)
  • 实时视频/连麦用的是 声网 Agora(statscollector-1.agora.io, logservice.agora.io),不是自研 WebSocket/RTC
  • 埋点: report.pixverse.ai/sa.gif(神策式埋点)、阿里云 SLS RUM (*.log.aliyuncs.com/rum/web/v2)

页面布局(截图观察)

  • 顶部居中: 「Now Active | @用户名」胶囊 + 头像,代表当前正在被"换装"的观众
  • 顶部胶囊下方一排小气泡: 排队中的观众头像 + 他们提交的换装缩略图(等待被采用)
  • 中间: 竖版 9:16 视频/图片区域,AI 主播模特展示当前装扮,右下角有一个圆形分享/操作按钮
  • 左下: 实时弹幕聊天流(用户名: 消息),随新消息滚动,有"N new messages"提示条
  • 底部: "Upload an outfit or pick one below" + 一排换装缩略图卡片 + 上传/DIY 卡片;hover 缩略图会出现「Send」按钮
  • 右上角: 观看人数 + 分享按钮,点击后弹出「Share this highlight」侧边浮层(含竖版海报预览 + Instagram/TikTok/X/Facebook/Copy link)
  • 首次进入有「Play live audio?」浏览器自动播放拦截提示(Keep muted / Play sound)

交互逻辑深入分析(2026-09-09,基于反混淆后的 chunk 源码)

已用 js-beautify 格式化以下关键 chunk 到 beautified/: 4042(Agora Provider / 消息总线)、5855(LivePageClient 主容器 & 路由分发)、265(换装选择/上传 UI)、8905(outfit 状态绑定)、6136(realtime 埋点事件字典,间接印证架构)等。补充下载了实际运行时真正加载的 chunk:vendor-agora、vendor-ogl、vendor-upload 等(通过 performance.getEntriesByType('resource') 在真实页面里拿到的加载清单,比静态解析 webpack chunk-id→文件名映射函数更可靠,因为很多 numeric chunk id 在构建期被去重/内联,静态推算会得到 undefined hash)。

1. 总体架构:REST 拉取初始状态 + Agora 承载实时推送,没有自建 WebSocket

之前 NOTES 里因为 bootstrap 脚本和埋点事件名里出现大量 realtime_ws_* 字样,猜测有自建 WebSocket。深入代码后确认:"直播换装/看播"这个场景(liveType=dress)完全不用 WebSocket,realtime_ws_* 那套埋点是同一份 SDK 里给"文生视频实时生成"(创建 World 的场景)复用的,两个场景共享一个 RumSDK 埋点封装,字典是通用的,不代表这个页面真的建立了 WS 连接。

真实链路:

  1. 进房时一次性调用 GET /rtg/preset/{presetId}/live-info(或 /rtg/session/{sessionId}/live-info),拿到一个 liveInfo 快照对象,字段包括: { db_session_id, agora_app_id, channel_name, agora_token, agora_uid, server_uid, online_count, started_at, is_interrupted, end, preset_info: { preset_id, ... }, extra: { extraType: "outfit_queen" | ..., // "dress" 场景标识 nowActive: { uid, queueId, outfitId, isDefault, ... } | null, queue: { items: [...], total } } } 这一次 REST 调用之后不会轮询刷新 live-info(useRequest 是 manual:false 一次性请求,refreshDeps 只在 sessionId/presetId 变化时才重新拉取一次)。
  2. 用拿到的 agora_app_id/channel_name/agora_token/agora_uid 去 加入声网 Agora RTC 频道,client.setClientRole("audience")(纯观众角色,不推流,只拉流)。
  3. 之后所有的"状态更新"全部通过 Agora 的 stream-message(数据通道,俗称 SEI/RTM-over-RTC) 事件推送下来,前端监听:
    • chat-send(弹幕消息)
    • outfit_activated / outfit_queued / outfit_cancelled / generation_failed(换装队列生命周期)
    • user-num-change(在线人数变化,{nums})
    • prompt-send(悬浮飘字/弹幕特效用,见下)
    • session_block / session_resume / end / session_error(会话级状态机)
    • Agora 原生的 user-published / user-unpublished(视频/音频轨道发布状态,用来判断黑屏/失联风险)

消息包结构(数据通道原始 payload,TextDecoder 解码后 JSON.parse):{ event: string, data: any, msg_id: string }。前端用一个 Map(容量上限 5000,超出淘汰最早的)对 msg_id 去重,防止 Agora 数据通道重复投递导致消息重复处理。

  1. 客户端 → 服务端的动作全部是普通 REST POST,不走 Agora 上行(因为观众是 audience 角色,本来就不能推流/发数据通道): POST /live/room/message body: { type: "chat" | "prompt" | "outfit_selection" | "cancel_outfit" | "join" | "leave", session_id, agora_uid, content?, // type 为 chat/prompt 时用这个字段装文本 payload? // 其他 type 用这个字段装结构化数据 } 例如取消排队:payload:{queueId};提交换装:payload:{isDefault, outfitId} 或 payload:{isDefault:false, imageUrl, thumbnailUrl}(上传图片场景,字段是 OSS 的 image_key,不是完整 URL)。
  2. 进房/离房各发一次 type:"join" / type:"leave"(受一个开关函数 x3() 控制是否真的发送,猜测是某种节流/去重/灰度开关)。

结论:这是"REST 建会话 + Agora 音视频通道复用做实时信令"的轻量架构,没有自研 WS 网关,弹幕/换装指令的实时性完全依赖 Agora 数据通道。 如果我们要照抄这套体感但不想接入 Agora,最简单的替代是用一条 WebSocket(或 SSE)承载同样的 event 语义(chat-send/outfit_activated/user-num-change/end 等),客户端发送动作仍走 REST POST,架构完全可以对等平移。

2. 换装排队机制(这是这个直播间最核心、最值得抄的交互)

状态由 DressContext(useDress())维护:

{ active, queue, queuePosition, queueItem, cancelQueue }
  • active:当前"正在上身"的换装请求(对应顶部 "Now Active | @user" 胶囊)。
  • queue:排队中的请求数组(对应顶部一排小头像 + 缩略图角标),每项含 {uid, queueId, timestamp, outfitId/imageUrl, ...}。
  • queuePosition/queueItem:只查找"当前登录用户自己"在队列里的那一条(用 accountId 匹配 uid),不是队列总长度。
  • 一个用户同时只能有一个排队中的请求:canSend = queuePosition === -1。发送按钮在自己已有一条排队请求时会被禁用(sendDisabled = !canSend || loading),必须等它被 outfit_activated/generation_failed/cancel_outfit 清掉之后才能再发新的。
  • 生命周期事件对本地 state 的处理(纯前端乐观更新,服务端是唯一真源):
    • outfit_queued → queue.push(item)
    • outfit_activated(item) → active = item;同时把 queue 里 该 queueId 本身,以及所有 timestamp 更早的条目 一并清掉(说明服务端是严格 FIFO 处理,客户端据此裁掉"过期"的队列头,防止界面卡着已经处理完的旧请求)
    • generation_failed(item) → 只把该 queueId 从 queue 里移除(不改 active)
    • outfit_cancelled(item) → 从 queue 移除;如果正是"自己刚点了取消、乐观标记为 cancelling 的那条",清掉本地 cancelling 标记
  • cancelQueue():找到自己那条排队项,POST /live/room/message {type:"cancel_outfit", payload:{queueId}},并立刻本地标记这个 queueId 为"取消中"(避免服务端确认前 UI 抖动)。

3. 发起换装(Send)的两条路径

统一入口 handleSend(e)(被 sendDisabled 卫语句挡住重复点击),内部按参数二选一:

A. 点选预置服装缩略图(截图里底部那一排):

D("outfit_selection", { isDefault: true, outfitId })

最轻量,服务端已经有这张图,不用上传。

B. 上传自定义图片 / DIY:

  1. 校验 MIME 类型,非图片直接报错埋点 + toast。
  2. 并行做两件事:生成一份 ≤50KB 的压缩缩略图(maxSizeMB:.05)+ 原图预处理;然后并行把原图(分片上传 multipartUpload:"dynamic",有真实进度回调)和缩略图(无进度回调,走小文件直传)都传到 OSS。
  3. 进度条是"伪进度 + 真进度"混合:先按文件大小估算一个时长(clamp(size/262144*1000, 6000, 25000) 毫秒),用 setInterval 每 120ms 用缓动函数把进度推到 88%;一旦分片上传真实进度回调开始返回数字,就用真实值覆盖(B(real) 只在真实值更大时才更新,见 t <= R.current || ...),最后上传全部完成后手动跳到 94% → 100%。这是很典型的"防止大图上传时进度条卡住不动"的体验优化,值得直接抄。
  4. 上传拿到 OSS image_key/thumbnail_key 后:D("outfit_selection", {isDefault:false, imageUrl:image_key, thumbnailUrl:thumbnail_key})。
  5. 错误处理区分"内容审核拒绝"(UPLOAD_AUDIT_REJECTED)和"审核服务不可用"(UPLOAD_AUDIT_FAILED)两种文案,其余走通用错误 toast——说明上传链路后端会做内容安全审核,这是我们做类似"用户上传素材直接进直播间"功能时必须补的一环,不能省略。
  6. 每张缩略图卡片(组件 77769)默认只显示图,interactive 时 hover 才淡入一层黑色蒙层 + 居中的圆角"Send"按钮(截图里看到的效果);卡片左上角可选一个 badgeLabel 角标(用来在"排队中头像"旁边叠加一个迷你缩略图角标)。

4. 会话健康状态机(防黑屏/防假死)

sessionState ∈ {"connecting","playing","block","end","error"},来源有两路,互为兜底:

  • Agora 原生事件:监听 server_uid(主播/服务端推流账号)的 user-published/user-unpublished(mediaType==="video"),发布→playing,取消发布→block(除非当前已经是 end/error/block)。
  • 服务端下发的语义事件:session_block/session_resume/end/session_error 直接置状态。
  • 轮询兜底:只在 connecting/playing(且检测到曾经短暂掉线过)/block 状态下才启动定时器,block 时 60s 一次、其余 20s 一次,调用 GET /rtg/live-sessions 列表接口反查这个 db_session_id 是否还在、is_interrupted 是否为真,用来防止 Agora 事件丢失导致前端卡在"假黑屏"状态。
  • sessionState==="end" 时会触发一次性清理:Agora client 依次 unpublish 本地轨道 → 关闭轨道 → massUnsubscribe 远端音视频 → 取消订阅所有数据通道 → leave() → removeAllListeners(),并且用一个 ref 保证这套清理只执行一次。

5. 聊天(弹幕列表,屏幕左下)

  • 进房先 GET /live/room/{session_id}/chat-history?limit=100 拉历史,本地只保留最新 200 条(messages.slice(-200)),超出滚动裁剪,避免长时间挂着直播间导致内存/DOM 无限增长。
  • 之后新消息全部来自 Agora 数据通道的 chat-send 事件,直接 append。
  • 发送聊天走同一个 /live/room/message 接口,type:"chat", content:文本。

6. "prompt-send"(飘字/弹幕特效,非普通聊天)

单独维护一个 danmakuItems 队列(不是 chat 消息),每条有 {id, prompt_id, timestamp, prompt, avatar, name, uid},并暴露 completeDanmakuScroll(item) / subscribeDanmakuScrollComplete(cb) 一套"播放完通知机制"——典型用途是视频画面上飘过一条文字气泡,动画播完后由消费方回调把它从队列里摘掉,而不是用定时器强制超时删除。这套和普通聊天分离,大概率对应"某用户发的一句话被临时渲染成画面上的动效字幕"这种效果(比如观众喊话被做成弹幕特效叠在视频上,而不只是出现在左下角文字列表)。

7. 首次自动播放音频拦截

进房时用 navigator 相关的自动播放策略探测(页面里叫 y(),实测行为等价于 document.getAutoplayPolicy 一类 API 的封装)判断浏览器是否允许带声自动播放;不允许时才弹出截图里看到的「Play live audio? / Keep muted / Play sound」对话框,允许多路复用(db_session_id 或 preset:presetId 变化时重新探测一次)。

8. 关键 REST 接口汇总(补充/修正第一版 NOTES)

接口 方法 用途
/rtg/preset/{presetId}/live-info 或 /rtg/session/{sessionId}/live-info GET 进房一次性拉取 Agora 加入信息 + 当前换装队列快照
/live/room/message POST 统一的客户端动作出口:chat / prompt / outfit_selection / cancel_outfit / join / leave
/live/room/{session_id}/chat-history?limit=100 GET 拉取历史聊天
/rtg/live-sessions GET 轮询兜底,判断某个 session 是否仍存活/被打断
/rtg/outfit-queen/command POST 之前 bootstrap 脚本里看到,推测是"主播/管理端"下发换装指令的接口(观众端未见调用,可能是后台/预设配置用)

9. 对照我们自己实现时的取舍建议

  • 我们不需要接 Agora;把"Agora 数据通道事件"整体替换成一条 WebSocket(或轮询更简单但体验差一截),事件语义原样照抄即可(chat-send/outfit_activated/outfit_queued/outfit_cancelled/generation_failed/user-num-change/end)。
  • "每人同时只能有一条排队请求" 这个约束非常关键,直接决定了 UI 的禁用逻辑和后端队列模型(FIFO,按 queueId+timestamp 排序,activate 时清掉自己及更早的过期项)。
  • 上传进度条的"伪进度+真进度混合"和"缩略图并行小图上传"两个细节体验很好,值得照抄,不需要额外依赖。
  • 内容审核失败要区分"明确拒绝"和"服务不可用"两种文案,不要合并成一种报错。
  • 聊天记录客户端限制 200 条滚动窗口,直播间这种长连接页面必须做,否则内存会一直涨。

待分析 / 下一步

  • 还没有抓到 /rtg/outfit-queen/command 的真实调用方(可能是主播端后台工具,非本页面职责范围内,可以先不管)。
  • 还没确认 queue 数组的最大展示数量/整体队列上限(截图里同时只看到 2~3 个排队头像,需要再观察一段时间人多的场次)。
  • 需要对照我们自己的 dance.html / dance.css / live.html / live.css,逐项列出布局 + 交互差异清单,作为下一步改造的任务列表。