@firetable/project-xiaochun 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-CN.md +144 -18
- package/README.md +144 -18
- package/dist/avatar-element.d.ts +23 -6
- package/dist/chunks/{chunk-UB2HFUBS.js → chunk-KKRZ3SUD.js} +50 -2
- package/dist/chunks/chunk-KKRZ3SUD.js.map +7 -0
- package/dist/chunks/chunk-KQSKHPVF.js +957 -0
- package/dist/chunks/chunk-KQSKHPVF.js.map +7 -0
- package/dist/chunks/{chunk-NKR4JG3C.js → chunk-T6EHFEEZ.js} +83 -9
- package/dist/chunks/chunk-T6EHFEEZ.js.map +7 -0
- package/dist/client.d.ts +134 -6
- package/dist/element.cjs +572 -71
- package/dist/element.cjs.map +4 -4
- package/dist/element.js +4 -4
- package/dist/gesture-box.d.ts +72 -0
- package/dist/index.cjs +599 -71
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +22 -4
- package/dist/index.js.map +1 -1
- package/dist/loader.global.js +2 -2
- package/dist/loader.global.js.map +4 -4
- package/dist/protocol.cjs +49 -1
- package/dist/protocol.cjs.map +2 -2
- package/dist/protocol.d.ts +180 -9
- package/dist/protocol.js +20 -2
- package/dist/react.cjs +540 -77
- package/dist/react.cjs.map +4 -4
- package/dist/react.d.ts +17 -1
- package/dist/react.js +50 -15
- package/dist/react.js.map +2 -2
- package/package.json +5 -3
- package/dist/chunks/chunk-NKR4JG3C.js.map +0 -7
- package/dist/chunks/chunk-UB2HFUBS.js.map +0 -7
- package/dist/chunks/chunk-XE2YKXRA.js +0 -544
- package/dist/chunks/chunk-XE2YKXRA.js.map +0 -7
package/README-CN.md
CHANGED
|
@@ -137,9 +137,9 @@ const { containerRef, client, ready, state } = useXiaochun({ width: 280, height:
|
|
|
137
137
|
// <div ref={containerRef} style={{ width: 280, height: 420 }} />
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
-
* **Props**:`createXiaochun` 除 `container` 外的全部选项,加上 `onHandshake / onReady / onProgress / onState / onStt / onUtterance / onHitRegion / onError / onDestroy`、`className`、`style`、`paused`、`mic`。
|
|
141
|
-
* **重建与热更新**:创建期选项(`src`、`lazy`、`transparent`、`width`、`height`、`
|
|
142
|
-
* **ref 方法**:`say`、`speakAudio`、`speakAudioStream`、`motion`、`expression`、`lookAt`、`setModel`、`setConfig`、`startListening`、`stopListening`、`mic`、`pause`、`resume`、`activate`、`destroy`,以及 `ready` 与 `instance`。组件挂载前,返回 Promise 的方法会 reject。
|
|
140
|
+
* **Props**:`createXiaochun` 除 `container` 外的全部选项,加上 `onHandshake / onReady / onProgress / onState / onStt / onUtterance / onHitRegion / onMove / onResize / onError / onDestroy`、`className`、`style`、`paused`、`mic`。
|
|
141
|
+
* **重建与热更新**:创建期选项(`src`、`lazy`、`transparent`、`position` 等)变化会重建实例,所以不要在每次渲染里传新值。`width`、`height`、`draggable`、`resizable`(effect 调 `setSize` / `setDraggable` / `setResizable`)、`lang`、`outfit`、`scene`(以及已弃用的 `model`)、`paused`、`mic` 与回调会原地更新,不重建 iframe(由 effect 调 `setOutfit` / `setScene` / `setConfig`)。另有回调 `onOutfitChanged`、`onSceneChanged`。
|
|
142
|
+
* **ref 方法**:`say`、`speakAudio`、`speakAudioStream`、`motion`、`expression`、`lookAt`、`setOutfit`、`setScene`、`getOutfits`、`getScenes`、`prefetch`、`setModel`、`setConfig`、`startListening`、`stopListening`、`mic`、`pause`、`resume`、`activate`、`destroy`,以及 `ready` 与 `instance`。组件挂载前,返回 Promise 的方法会 reject。
|
|
143
143
|
|
|
144
144
|
---
|
|
145
145
|
|
|
@@ -165,13 +165,62 @@ s.write(int16Chunk); s.write(next); s.end(); await s.done; // s.abort() 立即
|
|
|
165
165
|
|
|
166
166
|
---
|
|
167
167
|
|
|
168
|
+
## 👗 换装、换场景与预取
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const xc = createXiaochun({ container: '#avatar', outfit: 'xiaochun_maid', scene: 'light', persist: 'host' });
|
|
172
|
+
await xc.ready;
|
|
173
|
+
|
|
174
|
+
const outfits = await xc.getOutfits(); // [{ id, name }](裸模永远不会出现在列表里)
|
|
175
|
+
const scenes = await xc.getScenes(); // [{ id: 'light' | 'dark' | 'transparent', transparent }]
|
|
176
|
+
|
|
177
|
+
await xc.setOutfit('xiaochun_cheongsam'); // 新服装生效后才 resolve
|
|
178
|
+
await xc.setScene('transparent'); // 外壳背景和穿透开关自动跟随
|
|
179
|
+
xc.on('outfit-changed', (p) => console.log(p.id, p.previous));
|
|
180
|
+
xc.on('scene-changed', (p) => console.log(p.id));
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
* **id 严格校验**(`/^[a-z][a-z0-9_]{0,63}$/` + 自有属性白名单)。格式不对本地直接 reject `[bad_request]`(不发消息);格式合法但不存在(`constructor`、`base` 等)reject `[unknown_id]`,iframe 一侧会独立再校验。
|
|
184
|
+
* **并发**:换装**串行 + last-wins**。正在加载的不会被中止;排队中的请求被更新的调用顶掉时 reject `[busy]`(可忽略)。同目标请求合并;请求当前服装直接 resolve。**说话不会被打断**:正在说话时新服装在后台加载,好了再换上。
|
|
185
|
+
* **场景**:只有 3 个内置主题。运行时切换会同步外壳背景、开关指针穿透监听、非透明场景强制 `pointer-events: auto`、重置命中缓存。
|
|
186
|
+
* **能力协商**:协议仍是 v1。对旧版 `/embed`(`xc.ready` 里没有 `capabilities.outfits` / `scenes` / `prefetch`),新方法 reject `[unsupported]`,`getOutfits()` / `getScenes()` 返回 `[]`。
|
|
187
|
+
* **偏好保存**:iframe 用**自己的** localStorage 记住用户最近的服装 / 场景(键 `xiaochun_wearing_outfit` / `xiaochun_scene_theme`,与主站一致)。优先级:显式的 `outfit` / `scene`(URL 或 SDK 选项)> 已保存 > 默认;存的 id 不在白名单会被忽略并清掉;存储被拦截 / 分区时静默回退默认。第三方存储按顶层站点分区,每个宿主站各一份。
|
|
188
|
+
* **`persist`**(可选)另外把它们存在宿主页 localStorage,并作为显式值传回 iframe,因此会盖过 iframe 自己存的。默认 `false`。
|
|
189
|
+
* **`prefetch`**(默认关)只把服装文件下载进 iframe 的 IndexedDB(不解压不合成),串行、排在 EMAGE 加载之后,除非你点名否则不含婚纱。`await xc.prefetch(['xiaochun_cheongsam'])` 任何模式都能用;自动预取需要 `heavy: 'eager'`。用户开了省流量模式则跳过。之后 `setOutfit` 不再走网络(解压合成约 0.9 秒仍在)。
|
|
190
|
+
* **内置按钮**:见下一节(`ui: ['outfit', 'scene']`)。
|
|
191
|
+
* **自定义模型**:`setModel({ url })` **默认关闭**,需要 `allowCustomModel: true` 显式开启。
|
|
192
|
+
* **迁移**:选项 `model` → `outfit`;旧的 `setModel('base')` 不再可用(列表请用 `getOutfits()`)。
|
|
193
|
+
|
|
194
|
+
### 🔘 内置换装 / 换场景按钮(`ui`)
|
|
195
|
+
|
|
196
|
+
`ui` 是**部件名数组**,不写就什么都不显示。
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
createXiaochun({ container: '#avatar', ui: ['outfit', 'scene'] }); // 也可以 ['chat', 'bubble', 'outfit', 'scene']
|
|
200
|
+
```
|
|
201
|
+
```html
|
|
202
|
+
<xiaochun-avatar ui="outfit,scene"></xiaochun-avatar> <!-- URL 写法: /embed?ui=chat,outfit,scene -->
|
|
203
|
+
```
|
|
204
|
+
React:`<Xiaochun ui={['outfit', 'scene']} />`。运行时:`xc.setConfig({ ui: ['outfit'] })`。
|
|
205
|
+
|
|
206
|
+
* **和主站 TopHeader 同一套组件**:玻璃质感按钮(触屏 44×44,桌面 36×36)、下拉菜单、`Shirt` / `MountainSnow` / `Check` / `Loader2` 图标、同一批 i18n 文案(随 `lang`)。放在右上角,不遮角色。服装列表来自 `capabilities.outfits`(不含裸模、没有自定义 URL),每行只显示名字(菜单里不显示文件体积)、加载中转圈、当前穿着打 ✓。
|
|
207
|
+
* **与 `setOutfit` / `setScene` 同一条路径**:同样的白名单、同一个串行 last-wins 队列,连点会出现轻提示"busy",并照常发 `outfit-changed` / `scene-changed`。宿主用 SDK 调用时按钮状态同步。点按钮不会触发转身/拖动手势。
|
|
208
|
+
* **透明场景**:按钮参与穿透命中(点按钮不会漏到宿主页,空白处仍穿透)。触屏 + 透明场景下,第一次轻触只用来唤醒命中检测(落在宿主页上),第二次才落到按钮。
|
|
209
|
+
* **偏好保存**:iframe 把按钮 / `setOutfit` / `setScene` 引起的变化存在自己的 localStorage,刷新后自动恢复(显式 `outfit` / `scene` 仍然优先)。想自己留一份(例如跨站),监听事件并把值作为显式选项传回:
|
|
210
|
+
```ts
|
|
211
|
+
xc.on('outfit-changed', (p) => { if (!p.initial) localStorage.setItem('my-outfit', p.id); });
|
|
212
|
+
xc.on('scene-changed', (p) => { if (!p.initial) localStorage.setItem('my-scene', p.id); });
|
|
213
|
+
// 或直接 persist: 'host'
|
|
214
|
+
```
|
|
215
|
+
* **滚轮缩放**(`controls`,默认开)见上面的选项表,包括"不透明且铺满时会吞该区域页面滚动"的副作用;想保持旧行为用 `controls: false`。
|
|
216
|
+
|
|
168
217
|
## 🎨 样式 (CSS 变量与 `::part`)
|
|
169
218
|
|
|
170
219
|
宿主只能调整**外壳**。角色在跨域 iframe 里,你的 CSS 无法影响 iframe 内部。
|
|
171
220
|
|
|
172
221
|
| 变量 | 默认值 | 作用 |
|
|
173
222
|
| :--- | :--- | :--- |
|
|
174
|
-
| `--xc-radius` | `0` | 圆角(任意 CSS 长度,如 `24px`;`50%` 为圆形头像框)。调大更圆,过大会裁掉头和脚 |
|
|
223
|
+
| `--xc-radius` | 非透明 `20px` / 透明 `0` | 圆角(任意 CSS 长度,如 `24px`;`50%` 为圆形头像框)。调大更圆,过大会裁掉头和脚 |
|
|
175
224
|
| `--xc-shadow` | `none` | `box-shadow` 简写。透明悬浮头像请保持 `none` |
|
|
176
225
|
| `--xc-z-index` | `2147483000` | 仅悬浮模式。调小可以让你的弹窗和导航盖在头像上面 |
|
|
177
226
|
| `--xc-offset-x` / `--xc-offset-y` | `16px` | 仅悬浮模式:距屏幕侧边 / 底边的距离 |
|
|
@@ -235,10 +284,46 @@ SDK 会自动设置 `allow="microphone; autoplay"`。如果你手写 iframe,**
|
|
|
235
284
|
|
|
236
285
|
* **你的 CSP**:允许 `frame-src https://xiaochun.firetable.tech`(如果用 CDN loader,还要允许 `script-src https://cdn.jsdelivr.net`)。
|
|
237
286
|
* **`/embed` 的响应头**(由小蠢部署端设置):没有 `X-Frame-Options`;默认 `Content-Security-Policy: frame-ancestors *`(自建部署可以收紧);`Permissions-Policy: microphone=(self)`;`Cross-Origin-Resource-Policy: cross-origin`。
|
|
238
|
-
* **你的 COOP/COEP**:宿主页设置 `COEP: require-corp` 也能正常嵌入(embed 会发送 CORP)。要让 iframe 自身跨源隔离,需要宿主页也隔离**并且** `allow="cross-origin-isolated"
|
|
287
|
+
* **你的 COOP/COEP**:宿主页设置 `COEP: require-corp` 也能正常嵌入(embed 会发送 CORP)。要让 iframe 自身跨源隔离,需要宿主页也隔离**并且** `allow="cross-origin-isolated"`(即下面可选的 `crossOriginIsolated` 开关);否则端上 ONNX 以单线程运行(更慢但可用)。
|
|
239
288
|
* **第三方存储分区**:iframe 内缓存的模型按顶层站点分区,所以每个宿主站点都会各自下载一份。因此 embed 默认**不会**预加载端上 LLM / EMAGE 模型(`heavy: 'lazy'`)。
|
|
240
289
|
* **安全性**:握手校验 origin 之后,消息走 `MessageChannel`;从不使用 `'*'` 作为 targetOrigin;通配的 `origin` / `allowedOrigins` 会被拒绝。
|
|
241
290
|
|
|
291
|
+
|
|
292
|
+
### 可选:跨源隔离,让 EMAGE 用多线程
|
|
293
|
+
|
|
294
|
+
默认 iframe 内 `crossOriginIsolated` 为 `false`,onnxruntime-web 使用**单线程** wasm。要启用多线程(`SharedArrayBuffer`),下面三点缺一不可:
|
|
295
|
+
|
|
296
|
+
1. **宿主页自己跨源隔离**:页面响应头同时带 `Cross-Origin-Opener-Policy: same-origin` 和 `Cross-Origin-Embedder-Policy: credentialless`(或 `require-corp`),此时宿主页里 `window.crossOriginIsolated === true`。
|
|
297
|
+
2. **embed 文档自己也带 COEP**:`/embed` 已发送 `COEP: credentialless` + `CORP: cross-origin`,无需处理(COOP 在 iframe 内被忽略)。
|
|
298
|
+
3. **宿主向 iframe 委派**:跨源 iframe 不会自动继承。打开选项即可(默认关闭,默认 `allow` 仍是 `'microphone; autoplay'`):
|
|
299
|
+
|
|
300
|
+
```js
|
|
301
|
+
createXiaochun({ container: '#stage', crossOriginIsolated: true }); // allow="microphone; autoplay; cross-origin-isolated"
|
|
302
|
+
```
|
|
303
|
+
```tsx
|
|
304
|
+
<Xiaochun crossOriginIsolated /> // React
|
|
305
|
+
```
|
|
306
|
+
```html
|
|
307
|
+
<xiaochun-avatar cross-origin-isolated></xiaochun-avatar>
|
|
308
|
+
<!-- 手写 iframe:allow="microphone; autoplay; cross-origin-isolated" -->
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```nginx
|
|
312
|
+
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
313
|
+
add_header Cross-Origin-Embedder-Policy "credentialless" always; # 或 require-corp
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**收益**:EMAGE 推理线程数最高 `min(hardwareConcurrency, 桌面 8 / 手机 4)`。Node 实测同一段推理:**1 线程 274 ms → 4 线程 79 ms(约 3.5 倍)**。
|
|
317
|
+
|
|
318
|
+
**副作用(隔离的是*你的*页面)**:
|
|
319
|
+
|
|
320
|
+
* 页面上所有跨源子资源都必须满足 COEP。`credentialless` 下 no-cors 跨源请求会**不带** cookie/凭据(依赖凭据的第三方图片/脚本可能出问题);`require-corp` 下则必须有 `CORP: cross-origin` 或 CORS,否则被拦截。
|
|
321
|
+
* 页面里其他第三方 **iframe**(广告、地图、视频、支付、评论等)自己也必须发 COEP,否则被拦截;`COOP: same-origin` 还会切断 `window.opener`(依赖回传的 OAuth / 支付弹窗可能失效)。
|
|
322
|
+
* Safari 不支持 `credentialless`,请用 `require-corp`;不支持隔离的浏览器上该选项无效,自动退回单线程。
|
|
323
|
+
* 宿主页未隔离时开这个选项没有任何效果。请先在预发环境验证。
|
|
324
|
+
|
|
325
|
+
**验证**:`xc.ready` 握手里带 `capabilities.crossOriginIsolated`(应为 `true`);或在 iframe 的控制台上下文执行 `crossOriginIsolated`;EMAGE worker 上报的 `numThreads > 1`。本地:`node examples/serve-isolated.mjs`,然后打开 `http://localhost:8081/examples/embed-host.html?isolated=1`。
|
|
326
|
+
|
|
242
327
|
---
|
|
243
328
|
|
|
244
329
|
## ⚡ 对 Lighthouse 友好的用法
|
|
@@ -270,45 +355,86 @@ createXiaochun({
|
|
|
270
355
|
| `lazy` | `true` | `true` / `'idle'`:进入视口且空闲 · `'click'`:点击或首次调用 API · `false`:立即创建 |
|
|
271
356
|
| `lazyMargin` | `200` | 可见性触发的 rootMargin(px)。调大更早加载、更耗流量 |
|
|
272
357
|
| `placeholder` | 内置 SVG | 图片 URL、元素或 `false` |
|
|
273
|
-
| `transparent` | `false` | 背景透明叠在页面上(同时开启指针穿透) |
|
|
358
|
+
| `transparent` | `false` | 背景透明叠在页面上(同时开启指针穿透),等价于 `scene: 'transparent'` |
|
|
359
|
+
| `scene` | — | 初始场景:`'light' \| 'dark' \| 'transparent'`(见 `getScenes()`)。未知 id 会被忽略并触发 `error { code: 'unknown_id' }` |
|
|
274
360
|
| `width`、`height` | `320`、`480` | px 或任意 CSS 长度,务必设置 |
|
|
275
361
|
| `position` | `'inline'` | `'inline' \| 'bottom-right' \| 'bottom-left'` |
|
|
276
|
-
| `draggable` | `false` |
|
|
277
|
-
| `
|
|
278
|
-
| `
|
|
362
|
+
| `draggable` | `false` | 手势拖动:按住角色(不要压在内置按钮上)拖 = 移动 iframe,限制在视口内;内联 / 悬浮模式都生效。见下文「手势」 |
|
|
363
|
+
| `borderRadius` | 非透明 `20px` / 透明 `0` | 外壳圆角(数字 = px 或任意 CSS 长度)。light / dark 场景默认与桌面版窗口圆角同值(20px),透明场景不裁角。运行时用 `setBorderRadius()`;`--xc-radius` CSS 变量优先;`0` = 方角 |
|
|
364
|
+
| `resizable` | `false` | 拖四个角缩放 iframe(与桌面版同一套 40px 热区 / 光标 / 圆弧),运行时状态,**不重建 iframe**。可传 `true` 或 `{ minWidth, minHeight, maxWidth, maxHeight }`(默认最小 120×180,最大 = 视口) |
|
|
365
|
+
| `lang` | — | `'zh-CN' \| 'en' \| 'ja'` |
|
|
366
|
+
| `outfit` | 默认服装 | 初始服装 id(如 `xiaochun_maid`,见 `getOutfits()`)。未知 id 回退默认服装并触发 `error { code: 'unknown_id' }` |
|
|
367
|
+
| `model` | — | **已弃用**,请改用 `outfit`(会打印 `console.warn`)。不再接受 https URL,见 `allowCustomModel` |
|
|
368
|
+
| `allowCustomModel` | `false` | 显式开启后才允许 `setModel({ url })` 加载任意 https `.vrm` / `.vrmaddon` / `.vrmbase`。第三方文件会在 iframe 里解析,仅对可信 URL 打开 |
|
|
369
|
+
| `persist` | `false` | 可选:另把服装 + 场景偏好保存在**宿主页**的 localStorage(iframe 本来就会存一份自己的):`false` \| `'host'`(`xiaochun:prefs`)\| 自定义 key。显式的 `outfit` / `scene` 选项优先于已保存的偏好;宿主保存的优先于 iframe 自己存的 |
|
|
370
|
+
| `prefetch` | `false` | `true`(全部服装,婚纱 13.9 MB 除外)或 `string[]`。首次加载完成后自动发一次,**仅在 `heavy: 'eager'` 时**;否则请自己调用 `prefetch()` |
|
|
371
|
+
| `ui` | `[]` | iframe 内要显示的内置界面部件,数组,可选 `'chat'`(聊天栏)· `'bubble'`(头顶气泡)· `'outfit'`(换装按钮)· `'scene'`(换场景按钮)。不写/空 = 都不显示;未知名字被忽略并 `console.warn`。见下文「内置换装 / 换场景按钮」 |
|
|
279
372
|
| `heavy` | `'lazy'` | `'lazy'`:首次使用才加载 WebLLM / EMAGE · `'eager'`:预加载 |
|
|
280
|
-
| `controls` | `
|
|
373
|
+
| `controls` | `true` | iframe 内滚轮缩放,默认开(与主站一致)。透明场景:只有指针在角色上才缩放,其余位置滚轮仍滚动宿主页;不透明场景:iframe 铺满,该区域内滚轮 = 缩放,**会吞掉该区域的页面滚动**。`false` 锁定(`?controls=0`) |
|
|
281
374
|
| `autoPause` | `true` | 滚出视口自动暂停 |
|
|
282
375
|
| `passthrough` | = `transparent` | 按"鼠标是否在角色上"切换 iframe 的 pointer-events |
|
|
283
376
|
| `sandbox` | scripts + same-origin + popups | iframe `sandbox`;`false` = 不加。去掉 `allow-same-origin` 会让 IndexedDB 和麦克风失效 |
|
|
284
377
|
| `handshakeTimeout` | `20000` | 毫秒;超时会触发 `error { code: 'timeout' }` |
|
|
378
|
+
| `crossOriginIsolated` | `false` | 给 iframe 的 `allow` 追加 `cross-origin-isolated`(默认仍是 `microphone; autoplay`)。要求宿主页自己已跨源隔离,见 [可选:跨源隔离](#可选跨源隔离让-emage-用多线程) |
|
|
285
379
|
| `zIndex` | `2147483000` | 悬浮模式层级(`--xc-z-index` 变量优先) |
|
|
286
380
|
|
|
287
|
-
**实例**:`ready` · `say(text, { mode: 'speak' \| 'chat' })` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setModel(outfitOrUrl)` · `setConfig(cfg)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(协议已预留,目前返回 `unsupported`)*。
|
|
381
|
+
**实例**:`ready` · `say(text, { mode: 'speak' \| 'chat' })` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setOutfit(id)` · `setScene(id)` · `getOutfits()` · `getScenes()` · `prefetch(ids?)` · `outfit` / `scene`(只读)· `setModel(outfitOrUrl)` *(旧)* · `setConfig(cfg)` · `setSize(w, h)` · `getBox()` · `setDraggable(on)` · `setResizable(on | limits)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(协议已预留,目前返回 `unsupported`)*。
|
|
288
382
|
|
|
289
|
-
**事件**:`handshake` · `ready` · `progress` · `state` · `stt` · `utterance`(`phase: 'start' | 'end'`,`kind: 'text' | 'audio'`)· `hit-region` · `error` · `destroy`。
|
|
383
|
+
**事件**:`handshake` · `ready` · `progress` · `state` · `stt` · `utterance`(`phase: 'start' | 'end'`,`kind: 'text' | 'audio'`)· `hit-region` · `outfit-changed` · `scene-changed` · `move` / `resize`(`{ phase: 'start' | 'move' | 'end', left, top, width, height }`)· `error`(新增 `busy`、`unknown_id`;旧 iframe 上开手势会收到一次 `unsupported`)· `destroy`。`progress.phase` 为 `'model' | 'outfit' | 'prefetch'`。
|
|
290
384
|
|
|
291
385
|
### `<xiaochun-avatar>`
|
|
292
386
|
|
|
293
387
|
| 属性 | 默认值 | 说明 |
|
|
294
388
|
| :--- | :--- | :--- |
|
|
295
389
|
| `src` | 官方 `/embed` | 修改会重建 iframe |
|
|
296
|
-
| `
|
|
390
|
+
| `outfit` | — | 服装 id;运行时修改 = `setOutfit`(**热更新,不重建 iframe**) |
|
|
391
|
+
| `scene` | — | `light` · `dark` · `transparent`;运行时修改 = `setScene`(热更新) |
|
|
392
|
+
| `model` | — | `outfit` 的**已弃用**别名(`outfit` 优先) |
|
|
393
|
+
| `persist` / `prefetch` / `allow-custom-model` | — | 同对应选项;修改会重建 |
|
|
297
394
|
| `lang` | — | `zh-CN` · `en` · `ja`;运行时修改 = `setConfig` |
|
|
298
395
|
| `mic` | `false` | 开关听写(模型加载完成后生效) |
|
|
299
396
|
| `transparent` | `true` | `"false"` 关闭 |
|
|
300
|
-
| `draggable` | `false` |
|
|
397
|
+
| `draggable` | `false` | 手势拖动(内联 / 悬浮都行);运行时修改 = `setDraggable`(热更新) |
|
|
398
|
+
| `resizable` | `false` | 拖角缩放;运行时修改 = `setResizable`(热更新) |
|
|
399
|
+
| `border-radius` | 非透明 `20px` / 透明 `0` | 外壳圆角(数字 = px 或 CSS 长度);运行时修改 = `setBorderRadius`(热更新) |
|
|
400
|
+
| `min-size` / `max-size` | — | 缩放限幅,格式同 `size`(如 `min-size="160x240"`) |
|
|
301
401
|
| `position` | `inline` | `inline` · `bottom-right` · `bottom-left` |
|
|
302
|
-
| `size` | `320x480` | `"280"`(高 = 宽 × 1.5)、`"320x480"`、`"100%x480px"` |
|
|
402
|
+
| `size` | `320x480` | `"280"`(高 = 宽 × 1.5)、`"320x480"`、`"100%x480px"`;运行时修改 = `setSize`(**热更新,不重建 iframe**) |
|
|
303
403
|
| `lazy` | 空闲 + 视口 | `"click"` 仅点击;`"false"` 立即创建 |
|
|
304
404
|
| `paused` | `false` | `pause()` / `resume()` |
|
|
305
|
-
| `
|
|
405
|
+
| `ui` | — | 逗号分隔的部件名,如 `ui="outfit,scene"`(不写 = 都不显示;未知项忽略并 warn)。改它会重建;运行时用 `setConfig({ ui })` |
|
|
406
|
+
| `controls` | 开 | `controls="false"` 锁定 iframe 内滚轮缩放 |
|
|
407
|
+
| `placeholder` / `heavy` / `allowed-origins` | — | 同 `createXiaochun` |
|
|
408
|
+
| `cross-origin-isolated` | `false` | 同 `createXiaochun({ crossOriginIsolated })`;修改会重建 iframe |
|
|
306
409
|
|
|
307
|
-
**事件**(`CustomEvent`,`composed`,`detail` = 协议 payload):`xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-error`。
|
|
308
|
-
**方法**:`say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `destroy`;`el.client` 可拿到完整的 SDK 实例。
|
|
410
|
+
**事件**(`CustomEvent`,`composed`,`detail` = 协议 payload):`xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-outfit-changed` · `xc-scene-changed` · `xc-move` · `xc-resize` · `xc-error`。
|
|
411
|
+
**方法**:`say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `setOutfit` · `setScene` · `getOutfits` · `getScenes` · `prefetch` · `destroy`;`el.client` 可拿到完整的 SDK 实例。
|
|
309
412
|
|
|
310
413
|
---
|
|
311
414
|
|
|
415
|
+
### 手势(拖动与角落缩放)
|
|
416
|
+
|
|
417
|
+
iframe 可以拥有和桌面版一样的体验:**按住角色拖动 = 移动,拖四个角 = 缩放**。两者**默认关闭**并按实例协商:宿主没开时,iframe 不识别手势、不拦截指针事件、也不画角落圆弧。
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
const xc = createXiaochun({
|
|
421
|
+
container: '#avatar', width: 320, height: 480,
|
|
422
|
+
draggable: true, // 移动:拖角色
|
|
423
|
+
resizable: { minWidth: 160, minHeight: 240, maxWidth: 640, maxHeight: 960 }, // 也可直接 true(最小 120×180,最大 = 视口)
|
|
424
|
+
});
|
|
425
|
+
xc.on('move', (b) => console.log(b.phase, b.left, b.top));
|
|
426
|
+
xc.on('resize', (b) => console.log(b.phase, b.width, b.height));
|
|
427
|
+
xc.setResizable(false); // 运行时开关,不重建 iframe
|
|
428
|
+
xc.setSize(240, 360); // 程序化改尺寸(同样热更新)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
* 识别逻辑复用应用共用的手势状态机(`src/core/gesture/`);iframe 只发 `xc.gesture-move` / `xc.gesture-resize` 增量(`gesture`、`seq`、`phase: start | move | end`、`dx/dy`、累计 `totalDx/totalDy`、缩放带 `corner`),**由宿主 SDK 执行**:校验 origin / 端口,丢弃伪造、重放、乱序的消息,并按最小 / 最大尺寸与视口限幅(内联模式用 CSS `translate` 移动,悬浮模式改 `left/top`)。
|
|
432
|
+
* 缩放是运行时状态:`width` / `height` / `size` / `draggable` / `resizable` 变化都**不会重建 iframe**(模型、动画、对话状态保留)。
|
|
433
|
+
* 透明场景 + 穿透:只有角色、(开了 `resizable` 时)四角 40px 热区会接管指针,其余位置仍穿透到你的页面;内置按钮不会成为拖动 / 缩放起点。
|
|
434
|
+
* 拖动时的选中蓝框两侧都已抑制(`user-select: none`、阻止 `selectstart` / `dragstart`、缩放时用 pointer capture)。
|
|
435
|
+
* 旧版 `/embed`(没有 `capabilities.gestures`)上开内联 `draggable` 或 `resizable`,会触发一次 `error { code: 'unsupported', command: 'gestures' }`。
|
|
436
|
+
* 试玩:`examples/embed-host.html?draggable=1&resizable=1`。
|
|
437
|
+
|
|
312
438
|
## 🧪 本地试玩 (Try It Locally)
|
|
313
439
|
|
|
314
440
|
打开 [`examples/embed-host.html`](./examples/embed-host.html),文件头部注释写了启动步骤。要连本地的小蠢开发服务器,传 `src: 'https://localhost:5185/embed'`。
|
package/README.md
CHANGED
|
@@ -137,9 +137,9 @@ const { containerRef, client, ready, state } = useXiaochun({ width: 280, height:
|
|
|
137
137
|
// <div ref={containerRef} style={{ width: 280, height: 420 }} />
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
-
* **Props**: every `createXiaochun` option except `container`, plus `onHandshake / onReady / onProgress / onState / onStt / onUtterance / onHitRegion / onError / onDestroy`, `className`, `style`, `paused`, and `mic`.
|
|
141
|
-
* **Rebuild vs. live update**: creation-time options (`src`, `lazy`, `transparent`, `
|
|
142
|
-
* **Ref methods**: `say`, `speakAudio`, `speakAudioStream`, `motion`, `expression`, `lookAt`, `setModel`, `setConfig`, `startListening`, `stopListening`, `mic`, `pause`, `resume`, `activate`, `destroy`, plus `ready` and `instance`. Methods that return a Promise reject until the component is mounted.
|
|
140
|
+
* **Props**: every `createXiaochun` option except `container`, plus `onHandshake / onReady / onProgress / onState / onStt / onUtterance / onHitRegion / onMove / onResize / onError / onDestroy`, `className`, `style`, `paused`, and `mic`.
|
|
141
|
+
* **Rebuild vs. live update**: creation-time options (`src`, `lazy`, `transparent`, `position`, …) rebuild the instance when they change, so avoid passing fresh values on every render. `width`, `height`, `draggable`, `resizable` (effects call `setSize` / `setDraggable` / `setResizable`), `lang`, `outfit`, `scene` (and the deprecated `model`), `paused`, `mic` and the callbacks update in place without recreating the iframe (effects call `setOutfit` / `setScene` / `setConfig`). Also available: `onOutfitChanged`, `onSceneChanged`.
|
|
142
|
+
* **Ref methods**: `say`, `speakAudio`, `speakAudioStream`, `motion`, `expression`, `lookAt`, `setOutfit`, `setScene`, `getOutfits`, `getScenes`, `prefetch`, `setModel`, `setConfig`, `startListening`, `stopListening`, `mic`, `pause`, `resume`, `activate`, `destroy`, plus `ready` and `instance`. Methods that return a Promise reject until the component is mounted.
|
|
143
143
|
|
|
144
144
|
---
|
|
145
145
|
|
|
@@ -165,13 +165,62 @@ s.write(int16Chunk); s.write(next); s.end(); await s.done; // s.abort() stops
|
|
|
165
165
|
|
|
166
166
|
---
|
|
167
167
|
|
|
168
|
+
## 👗 Outfits, Scenes & Prefetch
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const xc = createXiaochun({ container: '#avatar', outfit: 'xiaochun_maid', scene: 'light', persist: 'host' });
|
|
172
|
+
await xc.ready;
|
|
173
|
+
|
|
174
|
+
const outfits = await xc.getOutfits(); // [{ id, name }] (the bare base model is never listed)
|
|
175
|
+
const scenes = await xc.getScenes(); // [{ id: 'light' | 'dark' | 'transparent', transparent }]
|
|
176
|
+
|
|
177
|
+
await xc.setOutfit('xiaochun_cheongsam'); // resolves when the new outfit is live
|
|
178
|
+
await xc.setScene('transparent'); // wrapper background + pass-through follow automatically
|
|
179
|
+
xc.on('outfit-changed', (p) => console.log(p.id, p.previous));
|
|
180
|
+
xc.on('scene-changed', (p) => console.log(p.id));
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
* **Ids are validated** (`/^[a-z][a-z0-9_]{0,63}$/` + an own-property whitelist). A malformed id rejects locally with `[bad_request]`; a well-formed unknown id (`constructor`, `base`, ...) rejects with `[unknown_id]`. Nothing is sent for the former, and the iframe re-validates the latter on its own.
|
|
184
|
+
* **Concurrency**: swaps are serial and **last-wins**. A swap that is loading is never aborted; a queued swap superseded by a newer call rejects with `[busy]` (safe to ignore). Same-target requests are merged; asking for the current outfit resolves immediately. **Speech is never interrupted**: if the avatar is talking, the new outfit loads in the background and appears when ready.
|
|
185
|
+
* **Scenes**: only the three built-in themes. Switching at runtime syncs the wrapper background, enables/disables the pointer pass-through listeners, forces `pointer-events: auto` for opaque scenes and resets the hit cache.
|
|
186
|
+
* **Capability negotiation**: protocol stays v1. Against an older `/embed` (no `capabilities.outfits` / `scenes` / `prefetch` in `xc.ready`), the new methods reject with `[unsupported]` and `getOutfits()` / `getScenes()` return `[]`.
|
|
187
|
+
* **Preferences**: the iframe remembers the user's last outfit/scene in **its own** localStorage (`xiaochun_wearing_outfit` / `xiaochun_scene_theme`, the same keys as the main site). Priority: explicit `outfit` / `scene` (URL or SDK options) > saved > default; unknown saved ids are ignored and cleared; if storage is blocked or partitioned it silently falls back to the defaults. Third-party storage is partitioned per top-level site, so each host site has its own copy.
|
|
188
|
+
* **`persist`** (optional) additionally keeps them in the host page's localStorage and passes them back as explicit values, so it overrides the iframe's own copy. Default `false`.
|
|
189
|
+
* **`prefetch`** (off by default) downloads outfit files into the iframe's IndexedDB only (no decode/compose), serially, after EMAGE is loaded, never the wedding outfit unless you name it. `await xc.prefetch(['xiaochun_cheongsam'])` works in any mode; the automatic variant needs `heavy: 'eager'`. Skipped when the user enabled Data Saver. A later `setOutfit` then skips the network (the ~0.9 s decode/compose remains).
|
|
190
|
+
* **Built-in buttons**: see the next section (`ui: ['outfit', 'scene']`).
|
|
191
|
+
* **Custom models**: `setModel({ url })` is **off by default**; pass `allowCustomModel: true` to opt in.
|
|
192
|
+
* **Migrating**: option `model` → `outfit`; the legacy `setModel('base')` no longer works (use `getOutfits()` for the list).
|
|
193
|
+
|
|
194
|
+
### 🔘 Built-in outfit / scene buttons (`ui`)
|
|
195
|
+
|
|
196
|
+
`ui` is an **array of part names**; nothing is shown unless you list it.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
createXiaochun({ container: '#avatar', ui: ['outfit', 'scene'] }); // or ['chat', 'bubble', 'outfit', 'scene']
|
|
200
|
+
```
|
|
201
|
+
```html
|
|
202
|
+
<xiaochun-avatar ui="outfit,scene"></xiaochun-avatar> <!-- URL form: /embed?ui=chat,outfit,scene -->
|
|
203
|
+
```
|
|
204
|
+
React: `<Xiaochun ui={['outfit', 'scene']} />`. At runtime: `xc.setConfig({ ui: ['outfit'] })`.
|
|
205
|
+
|
|
206
|
+
* **Same components as the main site's TopHeader**: glass buttons (44×44 on touch, 36×36 on desktop), the dropdown menu, the `Shirt` / `MountainSnow` / `Check` / `Loader2` icons and the same i18n strings (follows `lang`). Placed top-right so the character stays uncovered. The list comes from `capabilities.outfits` (no bare model, no custom URLs) and shows just the outfit names (no file sizes in the menu; `capabilities.outfits[].sizeMB` is still provided for your own UI), a spinner on the loading row and a ✓ on the worn one.
|
|
207
|
+
* **Same path as `setOutfit` / `setScene`**: same whitelist, same serial last-wins queue, a light "busy" hint when you click fast, and the usual `outfit-changed` / `scene-changed` events. Calls you make through the SDK keep the buttons in sync. Clicking a button never triggers the body-turn / drag gestures.
|
|
208
|
+
* **Transparent scene**: the buttons take part in the pass-through hit test (clicks on them do not fall through to your page; blank space still does). On touch + transparent, the first tap only wakes hit detection (it lands on your page), the second tap reaches the button.
|
|
209
|
+
* **Persistence**: the iframe saves button / `setOutfit` / `setScene` changes in its own localStorage and restores them on reload (explicit `outfit` / `scene` still win). To keep your own copy (e.g. across sites), listen to the events and pass the values back as explicit options:
|
|
210
|
+
```ts
|
|
211
|
+
xc.on('outfit-changed', (p) => { if (!p.initial) localStorage.setItem('my-outfit', p.id); });
|
|
212
|
+
xc.on('scene-changed', (p) => { if (!p.initial) localStorage.setItem('my-scene', p.id); });
|
|
213
|
+
// or simply persist: 'host'
|
|
214
|
+
```
|
|
215
|
+
* **Wheel zoom** (`controls`, default on) is described in the options table above, including the "swallows page scrolling over an opaque iframe" side effect; use `controls: false` to keep the old behaviour.
|
|
216
|
+
|
|
168
217
|
## 🎨 Styling (CSS Variables & `::part`)
|
|
169
218
|
|
|
170
219
|
The host can restyle the **shell** only. The character lives in a cross-origin iframe, so your CSS cannot reach inside it.
|
|
171
220
|
|
|
172
221
|
| Variable | Default | Effect |
|
|
173
222
|
| :--- | :--- | :--- |
|
|
174
|
-
| `--xc-radius` | `0` | Corner radius (any CSS length, e.g. `24px`; `50%` makes a round frame). Larger is rounder; too large clips the head and feet |
|
|
223
|
+
| `--xc-radius` | opaque `20px` / transparent `0` | Corner radius (any CSS length, e.g. `24px`; `50%` makes a round frame). Larger is rounder; too large clips the head and feet |
|
|
175
224
|
| `--xc-shadow` | `none` | `box-shadow` shorthand. Keep `none` for transparent floating avatars |
|
|
176
225
|
| `--xc-z-index` | `2147483000` | Floating mode only. Lower it so your modals and nav sit above the avatar |
|
|
177
226
|
| `--xc-offset-x` / `--xc-offset-y` | `16px` | Floating mode only: distance from the side / bottom edge |
|
|
@@ -235,10 +284,46 @@ The SDK sets `allow="microphone; autoplay"` for you. If you write the iframe by
|
|
|
235
284
|
|
|
236
285
|
* **Your CSP**: allow `frame-src https://xiaochun.firetable.tech` (and `script-src https://cdn.jsdelivr.net` if you use the CDN loader).
|
|
237
286
|
* **`/embed` response headers** (set by the XiaoChun deployment): no `X-Frame-Options`; `Content-Security-Policy: frame-ancestors *` by default (self-hosters can restrict it); `Permissions-Policy: microphone=(self)`; `Cross-Origin-Resource-Policy: cross-origin`.
|
|
238
|
-
* **Your COOP/COEP**: an embedding page with `COEP: require-corp` works (the embed sends CORP). Cross-origin-isolating the iframe itself needs both the host page isolated *and* `allow="cross-origin-isolated"
|
|
287
|
+
* **Your COOP/COEP**: an embedding page with `COEP: require-corp` works (the embed sends CORP). Cross-origin-isolating the iframe itself needs both the host page isolated *and* `allow="cross-origin-isolated"` (the opt-in `crossOriginIsolated` option below); otherwise on-device ONNX runs single-threaded (slower but functional).
|
|
239
288
|
* **Third-party storage partitioning**: models cached inside the iframe are keyed per top-level site, so each host site downloads its own copy. The embed therefore does **not** preload the on-device LLM / EMAGE models by default (`heavy: 'lazy'`).
|
|
240
289
|
* **Security**: messages travel over a `MessageChannel` after an origin-checked handshake; `'*'` is never used as a target origin; wildcard `origin` / `allowedOrigins` are rejected.
|
|
241
290
|
|
|
291
|
+
|
|
292
|
+
### Opt-in: cross-origin isolation (multi-threaded EMAGE)
|
|
293
|
+
|
|
294
|
+
By default `crossOriginIsolated` is `false` inside the iframe and onnxruntime-web runs **single-threaded** wasm. For multi-threading (`SharedArrayBuffer`) all three must hold:
|
|
295
|
+
|
|
296
|
+
1. **Your page is cross-origin isolated** — it is served with `Cross-Origin-Opener-Policy: same-origin` **and** `Cross-Origin-Embedder-Policy: credentialless` (or `require-corp`). `window.crossOriginIsolated` is `true` on your page.
|
|
297
|
+
2. **The embed document sets COEP too** — `/embed` already sends `COEP: credentialless` + `CORP: cross-origin`. Nothing to do. (COOP is ignored inside an iframe.)
|
|
298
|
+
3. **You delegate it to the iframe** — cross-origin iframes do not inherit it. Turn the option on (default off; default `allow` stays `'microphone; autoplay'`):
|
|
299
|
+
|
|
300
|
+
```js
|
|
301
|
+
createXiaochun({ container: '#stage', crossOriginIsolated: true }); // allow="microphone; autoplay; cross-origin-isolated"
|
|
302
|
+
```
|
|
303
|
+
```tsx
|
|
304
|
+
<Xiaochun crossOriginIsolated /> // React
|
|
305
|
+
```
|
|
306
|
+
```html
|
|
307
|
+
<xiaochun-avatar cross-origin-isolated></xiaochun-avatar>
|
|
308
|
+
<!-- hand-written iframe: allow="microphone; autoplay; cross-origin-isolated" -->
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```nginx
|
|
312
|
+
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
313
|
+
add_header Cross-Origin-Embedder-Policy "credentialless" always; # or require-corp
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Benefit**: EMAGE inference uses up to `min(hardwareConcurrency, 8 desktop / 4 mobile)` threads. Measured in Node on the same inference: **1 thread 274 ms → 4 threads 79 ms (~3.5×)**.
|
|
317
|
+
|
|
318
|
+
**Side effects (the isolation is on *your* page)**:
|
|
319
|
+
|
|
320
|
+
* Every cross-origin subresource on your page must satisfy COEP. With `credentialless`, no-cors cross-origin requests are sent *without* cookies/credentials (credentialed third-party images/scripts may break); with `require-corp` they need `CORP: cross-origin` or CORS or they are blocked.
|
|
321
|
+
* Other third-party **iframes** on your page (ads, maps, video, payments, comments…) must send COEP themselves or they are blocked. `COOP: same-origin` also severs `window.opener` (OAuth / payment popups that report back may stop working).
|
|
322
|
+
* Safari has no `credentialless`; use `require-corp` there. On browsers without isolation support the option is a harmless no-op (single-threaded fallback).
|
|
323
|
+
* Enabling the option on a non-isolated host does nothing. Verify on staging first.
|
|
324
|
+
|
|
325
|
+
**Verify**: the `xc.ready` handshake carries `capabilities.crossOriginIsolated` (should be `true`); or run `crossOriginIsolated` in the iframe's console context; the EMAGE worker reports `numThreads > 1`. Locally: `node examples/serve-isolated.mjs`, then open `http://localhost:8081/examples/embed-host.html?isolated=1`.
|
|
326
|
+
|
|
242
327
|
---
|
|
243
328
|
|
|
244
329
|
## ⚡ Lighthouse-Friendly Usage
|
|
@@ -270,45 +355,86 @@ createXiaochun({
|
|
|
270
355
|
| `lazy` | `true` | `true` / `'idle'`: visible and idle · `'click'`: on click or first API call · `false`: immediately |
|
|
271
356
|
| `lazyMargin` | `200` | rootMargin in px for the visibility trigger. Larger loads earlier and uses more data |
|
|
272
357
|
| `placeholder` | built-in SVG | Image URL, element, or `false` |
|
|
273
|
-
| `transparent` | `false` | Transparent background over your page (with pointer pass-through) |
|
|
358
|
+
| `transparent` | `false` | Transparent background over your page (with pointer pass-through). Same as `scene: 'transparent'` |
|
|
359
|
+
| `scene` | — | Initial scene: `'light' \| 'dark' \| 'transparent'` (see `getScenes()`). Unknown id → ignored + `error { code: 'unknown_id' }` |
|
|
274
360
|
| `width`, `height` | `320`, `480` | px or any CSS length. Always set them |
|
|
275
361
|
| `position` | `'inline'` | `'inline' \| 'bottom-right' \| 'bottom-left'` |
|
|
276
|
-
| `draggable` | `false` |
|
|
277
|
-
| `
|
|
278
|
-
| `
|
|
362
|
+
| `draggable` | `false` | Gesture drag: press and drag the character (not a built-in button) to move the iframe, clamped to the viewport. Works in inline and floating modes. See [Gestures](#gestures-drag-and-corner-resize) |
|
|
363
|
+
| `borderRadius` | opaque `20px` / transparent `0` | Shell corner radius (number = px, or any CSS length). The default for light / dark scenes equals the desktop app's window radius (20 px); transparent scenes are not clipped. Live via `setBorderRadius()`; the `--xc-radius` CSS variable wins; `0` = square corners |
|
|
364
|
+
| `resizable` | `false` | Drag a corner (same 40 px hot zone / cursors / arcs as the desktop app) to resize the iframe at runtime, **without rebuilding it**. `true` or `{ minWidth, minHeight, maxWidth, maxHeight }` (default min 120×180, max = viewport) |
|
|
365
|
+
| `lang` | — | `'zh-CN' \| 'en' \| 'ja'` |
|
|
366
|
+
| `outfit` | default outfit | Initial outfit id (e.g. `xiaochun_maid`, see `getOutfits()`). Unknown id → default outfit + `error { code: 'unknown_id' }` |
|
|
367
|
+
| `model` | — | **Deprecated**, use `outfit` (a `console.warn` is printed). An https URL is no longer accepted here; see `allowCustomModel` |
|
|
368
|
+
| `allowCustomModel` | `false` | Opt in to `setModel({ url })` for arbitrary https `.vrm` / `.vrmaddon` / `.vrmbase`. Third-party files are parsed inside the iframe, so enable it only for URLs you trust |
|
|
369
|
+
| `persist` | `false` | Optionally also remember outfit + scene in **the host page's** localStorage (the iframe already keeps its own copy): `false` \| `'host'` (`xiaochun:prefs`) \| a custom key. Explicit `outfit` / `scene` options win over saved values; the saved copy wins over the iframe's own |
|
|
370
|
+
| `prefetch` | `false` | `true` (all outfits except the 13.9 MB wedding) or `string[]`. Auto-sent once after the first load, **only with `heavy: 'eager'`**; otherwise call `prefetch()` yourself |
|
|
371
|
+
| `ui` | `[]` | Built-in UI parts to show inside the iframe: an array of `'chat'` (chat bar) · `'bubble'` (speech bubble) · `'outfit'` (outfit button) · `'scene'` (scene button). Unset / empty = none. Unknown names are ignored with a `console.warn`. See "Built-in outfit / scene buttons" below |
|
|
279
372
|
| `heavy` | `'lazy'` | `'lazy'`: load WebLLM / EMAGE on first use · `'eager'`: preload |
|
|
280
|
-
| `controls` | `
|
|
373
|
+
| `controls` | `true` | Wheel zoom inside the iframe, on by default like the main site. Transparent scene: zooms only while the pointer is on the character, everywhere else the wheel scrolls your page. Opaque scenes: the iframe fills its area, so the wheel zooms there and **swallows page scrolling over that area**. `false` locks it (`?controls=0`) |
|
|
281
374
|
| `autoPause` | `true` | Pause when out of the viewport |
|
|
282
375
|
| `passthrough` | = `transparent` | Toggle the iframe's pointer-events depending on whether the cursor is over the character |
|
|
283
376
|
| `sandbox` | scripts + same-origin + popups | iframe `sandbox`; `false` = none. Dropping `allow-same-origin` breaks IndexedDB and the mic |
|
|
284
377
|
| `handshakeTimeout` | `20000` | ms; on timeout an `error { code: 'timeout' }` is emitted |
|
|
378
|
+
| `crossOriginIsolated` | `false` | Append `cross-origin-isolated` to the iframe `allow` (default stays `microphone; autoplay`). Needs a host page that is itself isolated; see [Opt-in: cross-origin isolation](#opt-in-cross-origin-isolation-multi-threaded-emage) |
|
|
285
379
|
| `zIndex` | `2147483000` | Floating mode layer (the `--xc-z-index` variable takes precedence) |
|
|
286
380
|
|
|
287
|
-
**Instance**: `ready` · `say(text, { mode: 'speak' \| 'chat' })` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setModel(outfitOrUrl)` · `setConfig(cfg)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(reserved in the protocol; currently returns `unsupported`)*.
|
|
381
|
+
**Instance**: `ready` · `say(text, { mode: 'speak' \| 'chat' })` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setOutfit(id)` · `setScene(id)` · `getOutfits()` · `getScenes()` · `prefetch(ids?)` · `outfit` / `scene` (getters) · `setModel(outfitOrUrl)` *(legacy)* · `setConfig(cfg)` · `setSize(w, h)` · `getBox()` · `setDraggable(on)` · `setResizable(on | limits)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(reserved in the protocol; currently returns `unsupported`)*.
|
|
288
382
|
|
|
289
|
-
**Events**: `handshake` · `ready` · `progress` · `state` · `stt` · `utterance` (`phase: 'start' | 'end'`, `kind: 'text' | 'audio'`) · `hit-region` · `error` · `destroy`.
|
|
383
|
+
**Events**: `handshake` · `ready` · `progress` · `state` · `stt` · `utterance` (`phase: 'start' | 'end'`, `kind: 'text' | 'audio'`) · `hit-region` · `outfit-changed` · `scene-changed` · `move` / `resize` (`{ phase: 'start' | 'move' | 'end', left, top, width, height }`) · `error` (now also `busy`, `unknown_id`; one `unsupported` if gestures are enabled on an old iframe) · `destroy`. `progress.phase` is `'model' | 'outfit' | 'prefetch'`.
|
|
290
384
|
|
|
291
385
|
### `<xiaochun-avatar>`
|
|
292
386
|
|
|
293
387
|
| Attribute | Default | Description |
|
|
294
388
|
| :--- | :--- | :--- |
|
|
295
389
|
| `src` | official `/embed` | Changing it rebuilds the iframe |
|
|
296
|
-
| `
|
|
390
|
+
| `outfit` | — | Outfit id; changing it at runtime = `setOutfit` (**live update, no iframe rebuild**) |
|
|
391
|
+
| `scene` | — | `light` · `dark` · `transparent`; runtime change = `setScene` (live) |
|
|
392
|
+
| `model` | — | **Deprecated** alias of `outfit` (`outfit` wins) |
|
|
393
|
+
| `persist` / `prefetch` / `allow-custom-model` | — | Same as the options; changing them rebuilds |
|
|
297
394
|
| `lang` | — | `zh-CN` · `en` · `ja`; runtime change = `setConfig` |
|
|
298
395
|
| `mic` | `false` | Toggle dictation (takes effect once the model is loaded) |
|
|
299
396
|
| `transparent` | `true` | `"false"` turns it off |
|
|
300
|
-
| `draggable` | `false` |
|
|
397
|
+
| `draggable` | `false` | Gesture drag (inline and floating). Runtime change = `setDraggable` (live) |
|
|
398
|
+
| `resizable` | `false` | Corner drag resize; runtime change = `setResizable` (live) |
|
|
399
|
+
| `border-radius` | opaque `20px` / transparent `0` | Shell corner radius (number = px or CSS length); runtime change = `setBorderRadius` (live) |
|
|
400
|
+
| `min-size` / `max-size` | — | Resize limits, same format as `size` (e.g. `min-size="160x240"`) |
|
|
301
401
|
| `position` | `inline` | `inline` · `bottom-right` · `bottom-left` |
|
|
302
|
-
| `size` | `320x480` | `"280"` (height = width × 1.5), `"320x480"`, `"100%x480px"` |
|
|
402
|
+
| `size` | `320x480` | `"280"` (height = width × 1.5), `"320x480"`, `"100%x480px"`; runtime change = `setSize` (**live, no iframe rebuild**) |
|
|
303
403
|
| `lazy` | idle + viewport | `"click"` for click only; `"false"` for immediate |
|
|
304
404
|
| `paused` | `false` | `pause()` / `resume()` |
|
|
305
|
-
| `
|
|
405
|
+
| `ui` | — | Comma-separated parts, e.g. `ui="outfit,scene"` (unset = none; unknown names ignored with a warn). Changing it rebuilds; use `setConfig({ ui })` at runtime |
|
|
406
|
+
| `controls` | on | `controls="false"` locks wheel zoom inside the iframe |
|
|
407
|
+
| `placeholder` / `heavy` / `allowed-origins` | — | Same as `createXiaochun` |
|
|
408
|
+
| `cross-origin-isolated` | `false` | Same as `createXiaochun({ crossOriginIsolated })`; changing it rebuilds the iframe |
|
|
306
409
|
|
|
307
|
-
**Events** (`CustomEvent`, `composed`, `detail` = protocol payload): `xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-error`.
|
|
308
|
-
**Methods**: `say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `destroy`; `el.client` gives you the full SDK instance.
|
|
410
|
+
**Events** (`CustomEvent`, `composed`, `detail` = protocol payload): `xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-outfit-changed` · `xc-scene-changed` · `xc-move` · `xc-resize` · `xc-error`.
|
|
411
|
+
**Methods**: `say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `setOutfit` · `setScene` · `getOutfits` · `getScenes` · `prefetch` · `destroy`; `el.client` gives you the full SDK instance.
|
|
309
412
|
|
|
310
413
|
---
|
|
311
414
|
|
|
415
|
+
### Gestures (drag and corner resize)
|
|
416
|
+
|
|
417
|
+
The iframe can behave like the desktop app: **press and drag the character to move it, drag a corner to resize it**. Both are **off by default** and negotiated per instance, so an iframe whose host did not opt in does not recognise gestures, does not intercept pointer events, and draws no corner arcs.
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
const xc = createXiaochun({
|
|
421
|
+
container: '#avatar', width: 320, height: 480,
|
|
422
|
+
draggable: true, // move: drag the character
|
|
423
|
+
resizable: { minWidth: 160, minHeight: 240, maxWidth: 640, maxHeight: 960 }, // or just `true` (min 120×180, max = viewport)
|
|
424
|
+
});
|
|
425
|
+
xc.on('move', (b) => console.log(b.phase, b.left, b.top));
|
|
426
|
+
xc.on('resize', (b) => console.log(b.phase, b.width, b.height));
|
|
427
|
+
xc.setResizable(false); // runtime switches, no iframe rebuild
|
|
428
|
+
xc.setSize(240, 360); // programmatic resize (also live)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
* Recognition reuses the app's shared gesture state machine (`src/core/gesture/`); the iframe only posts `xc.gesture-move` / `xc.gesture-resize` increments (`gesture`, `seq`, `phase: start | move | end`, `dx/dy`, cumulative `totalDx/totalDy`, `corner` for resize). **The host SDK executes them**: it checks the origin/port, rejects forged, replayed or out-of-order messages, and clamps to the min/max size and the viewport (inline mode moves with a CSS `translate`; floating mode moves `left/top`).
|
|
432
|
+
* Resizing is runtime state: `width` / `height` / `size` / `draggable` / `resizable` changes never rebuild the iframe (the model, animation and chat state survive).
|
|
433
|
+
* Transparent scene + pass-through: only the character and (when `resizable`) the four 40 px corner zones take over the pointer; everywhere else still falls through to your page. Built-in buttons are never a drag / resize start.
|
|
434
|
+
* The blue text-selection box during a drag is suppressed on both sides (`user-select: none`, `selectstart` / `dragstart` blocked, pointer capture while resizing).
|
|
435
|
+
* Against an older `/embed` without `capabilities.gestures`, enabling inline `draggable` or `resizable` emits one `error { code: 'unsupported', command: 'gestures' }`.
|
|
436
|
+
* Try it: `examples/embed-host.html?draggable=1&resizable=1`.
|
|
437
|
+
|
|
312
438
|
## 🧪 Try It Locally
|
|
313
439
|
|
|
314
440
|
Open [`examples/embed-host.html`](./examples/embed-host.html); the header comment lists the steps. To point it at a local XiaoChun dev server, pass `src: 'https://localhost:5185/embed'`.
|
package/dist/avatar-element.d.ts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* avatar-element.ts — `<xiaochun-avatar>` Web Component (Shadow DOM 内包 iframe)。
|
|
3
3
|
*
|
|
4
|
-
* 属性: src model lang mic transparent draggable position size lazy paused placeholder heavy ui controls allowed-origins
|
|
4
|
+
* 属性: src outfit scene model(deprecated) lang mic transparent draggable resizable min-size max-size border-radius position size lazy paused placeholder heavy ui controls allowed-origins cross-origin-isolated persist allow-custom-model
|
|
5
5
|
* - 布尔属性: 缺省取默认值; "" / "true" = true; "false" = false。
|
|
6
|
-
* - size="320x480" 或 size="320" (高 = 宽 × 1.5); 也可写 CSS 长度 "100%x480px"。
|
|
7
|
-
* -
|
|
6
|
+
* - size="320x480" 或 size="320" (高 = 宽 × 1.5); 也可写 CSS 长度 "100%x480px"。改 size **热更新** (setSize, 不重建 iframe)。
|
|
7
|
+
* - draggable: 手势拖动 (角色上按住拖, 内联 / 悬浮都行); resizable: 四角缩放热区 (对角固定, 不重建 iframe); 两者默认关, 改属性热更新。
|
|
8
|
+
* min-size / max-size="120x180": resizable 的最小 / 最大尺寸 (px, 缺省最小 120x180、最大只受视口限制)。
|
|
9
|
+
* - border-radius: 外壳圆角 (数字 = px 或 CSS 长度); 缺省: 非透明场景 20px (与 Tauri 桌宠窗口同值) / 透明 0; 改属性热更新 (setBorderRadius)。
|
|
10
|
+
* - outfit: 内置服装 id (xiaochun_maid); 改属性**热更新** (setOutfit, 不重建 iframe)。model 是 outfit 的旧别名 (deprecated)。
|
|
11
|
+
* - scene: light | dark | transparent; 改属性**热更新** (setScene, 不重建 iframe)。
|
|
12
|
+
* - ui: 要显示的 iframe 内置界面部件, 逗号分隔: chat,bubble,outfit,scene (例 ui="outfit,scene"); 缺省 = 都不显示; 改它会重建。
|
|
13
|
+
* 旧写法 ui / ui="true" 已弃用 (= chat,bubble, 会 console.warn)。
|
|
14
|
+
* - persist: "host" 或自定义 localStorage key; 另把服装/场景偏好存在宿主页 (可选; 默认不存, iframe 自己的 localStorage 仍会记住)。
|
|
15
|
+
* - prefetch: "" / "true" = 预取全部内置服装 (婚纱除外), 或逗号分隔的 id 列表; 只在 heavy="eager" 时自动触发 (默认关)。
|
|
16
|
+
* - allow-custom-model: 允许 setModel({url}) 加载任意 https 模型 (默认关闭)。
|
|
8
17
|
* 样式 (CSS 自定义属性, 可写在 <xiaochun-avatar> 上或任意祖先上; 取值范围/效果见 client.ts 里的注释与 docs/EMBED.md §样式):
|
|
9
18
|
* --xc-radius --xc-shadow --xc-z-index --xc-offset-x --xc-offset-y --xc-bg
|
|
10
19
|
* 可用 ::part() 定制外壳: ::part(mount) ::part(wrapper) ::part(iframe) ::part(placeholder) (iframe 内部不可被宿主 CSS 影响)
|
|
11
|
-
* 事件 (CustomEvent, composed, detail = 协议 payload): xc-ready(模型加载完) xc-progress xc-state xc-stt xc-utterance xc-error
|
|
12
|
-
*
|
|
20
|
+
* 事件 (CustomEvent, composed, detail = 协议 payload): xc-ready(模型加载完) xc-progress xc-state xc-stt xc-utterance xc-error xc-outfit-changed xc-scene-changed
|
|
21
|
+
* xc-move / xc-resize (用户拖动 / 缩放, detail = {phase, left, top, width, height})
|
|
22
|
+
* 方法: say(text) speakAudio(source, opts) speakAudioStream(opts) motion(m) expression(name) setOutfit(id) setScene(id) getOutfits() getScenes() destroy() (+ client 属性拿到完整 SDK 实例)
|
|
13
23
|
*/
|
|
14
24
|
import { type XiaochunAudioOptions, type XiaochunAudioSource, type XiaochunAudioStream, type XiaochunInstance } from './client';
|
|
15
|
-
import type { XcExpressionPayload, XcMotionPayload } from './protocol';
|
|
25
|
+
import type { XcExpressionPayload, XcMotionPayload, XcOutfitInfo, XcPrefetchedPayload, XcSceneInfo } from './protocol';
|
|
16
26
|
/** 公开类型 (类本身在浏览器里才创建, 这样 SSR / Node 下 import 本包不会因 HTMLElement 缺失而抛错)。 */
|
|
17
27
|
export interface XiaochunAvatarElement extends HTMLElement {
|
|
18
28
|
client: XiaochunInstance | null;
|
|
@@ -26,6 +36,13 @@ export interface XiaochunAvatarElement extends HTMLElement {
|
|
|
26
36
|
}): XiaochunAudioStream;
|
|
27
37
|
motion(m: XcMotionPayload | string): Promise<void>;
|
|
28
38
|
expression(name: XcExpressionPayload['name']): Promise<void>;
|
|
39
|
+
/** 换内置服装 (串行 + last-wins, 见 XiaochunInstance.setOutfit)。 */
|
|
40
|
+
setOutfit(id: string): Promise<void>;
|
|
41
|
+
setScene(id: string): Promise<void>;
|
|
42
|
+
getOutfits(): Promise<XcOutfitInfo[]>;
|
|
43
|
+
getScenes(): Promise<XcSceneInfo[]>;
|
|
44
|
+
/** 预取服装资源到 iframe 的 IndexedDB; ids 省略 = 全部 (婚纱除外)。 */
|
|
45
|
+
prefetch(ids?: string[]): Promise<XcPrefetchedPayload>;
|
|
29
46
|
destroy(): void;
|
|
30
47
|
}
|
|
31
48
|
export declare const XIAOCHUN_ELEMENT_TAG = "xiaochun-avatar";
|