# 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/.jpg` — AI 生成图(t2i = text-to-image),`?x-oss-process=image/resize,h_128` 做缩略图 - 鉴权: cookie `_ai_share_token`,作为 `Authorization: Bearer ` 发送(仅当路径是 `/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 数据通道重复投递导致消息重复处理。 4. **客户端 → 服务端的动作全部是普通 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)。 5. 进房/离房各发一次 `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`,逐项列出布局 + 交互差异清单,作为下一步改造的任务列表。