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.aiPOST /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 连接。
真实链路:
- 进房时一次性调用
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 变化时才重新拉取一次)。 - 用拿到的
agora_app_id/channel_name/agora_token/agora_uid去 加入声网 Agora RTC 频道,client.setClientRole("audience")(纯观众角色,不推流,只拉流)。 - 之后所有的"状态更新"全部通过 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 数据通道重复投递导致消息重复处理。
- 客户端 → 服务端的动作全部是普通 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)。 - 进房/离房各发一次
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:
- 校验 MIME 类型,非图片直接报错埋点 + toast。
- 并行做两件事:生成一份 ≤50KB 的压缩缩略图(
maxSizeMB:.05)+ 原图预处理;然后并行把原图(分片上传multipartUpload:"dynamic",有真实进度回调)和缩略图(无进度回调,走小文件直传)都传到 OSS。 - 进度条是"伪进度 + 真进度"混合:先按文件大小估算一个时长(
clamp(size/262144*1000, 6000, 25000)毫秒),用setInterval每 120ms 用缓动函数把进度推到 88%;一旦分片上传真实进度回调开始返回数字,就用真实值覆盖(B(real)只在真实值更大时才更新,见t <= R.current || ...),最后上传全部完成后手动跳到 94% → 100%。这是很典型的"防止大图上传时进度条卡住不动"的体验优化,值得直接抄。 - 上传拿到 OSS
image_key/thumbnail_key后:D("outfit_selection", {isDefault:false, imageUrl:image_key, thumbnailUrl:thumbnail_key})。 - 错误处理区分"内容审核拒绝"(
UPLOAD_AUDIT_REJECTED)和"审核服务不可用"(UPLOAD_AUDIT_FAILED)两种文案,其余走通用错误 toast——说明上传链路后端会做内容安全审核,这是我们做类似"用户上传素材直接进直播间"功能时必须补的一环,不能省略。 - 每张缩略图卡片(组件
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,逐项列出布局 + 交互差异清单,作为下一步改造的任务列表。