@firetable/project-xiaochun 0.1.14 → 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/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @firetable/project-xiaochun v0.1.14 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.15 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -25,18 +25,27 @@ __export(src_exports, {
25
25
  XC_DEFAULT_SRC: () => XC_DEFAULT_SRC,
26
26
  XC_FRAME_TO_HOST: () => XC_FRAME_TO_HOST,
27
27
  XC_HOST_TO_FRAME: () => XC_HOST_TO_FRAME,
28
+ XC_ID_RE: () => XC_ID_RE,
28
29
  XC_IMPLEMENTED_COMMANDS: () => XC_IMPLEMENTED_COMMANDS,
29
30
  XC_PROTOCOL_MAPPING: () => XC_PROTOCOL_MAPPING,
30
31
  XC_PROTOCOL_VERSION: () => XC_PROTOCOL_VERSION,
32
+ XC_UI_LEGACY_PARTS: () => XC_UI_LEGACY_PARTS,
33
+ XC_UI_PARTS: () => XC_UI_PARTS,
31
34
  XC_UNSUPPORTED_COMMANDS: () => XC_UNSUPPORTED_COMMANDS,
35
+ XC_WINDOW_CORNER_RADIUS: () => XC_WINDOW_CORNER_RADIUS,
32
36
  XIAOCHUN_ELEMENT_TAG: () => XIAOCHUN_ELEMENT_TAG,
33
37
  XIAOCHUN_PROTOCOL_SCHEME: () => XIAOCHUN_PROTOCOL_SCHEME,
34
38
  createXiaochun: () => createXiaochun,
35
39
  defineXiaochunElement: () => defineXiaochunElement,
36
40
  isXcEnvelope: () => isXcEnvelope,
41
+ isXcId: () => isXcId,
37
42
  normalizeOrigin: () => normalizeOrigin,
43
+ normalizeXcUiOption: () => normalizeXcUiOption,
38
44
  parseProtocolUrl: () => parseProtocolUrl,
45
+ parseXcUiList: () => parseXcUiList,
46
+ parseXcUiParam: () => parseXcUiParam,
39
47
  toProtocolUrl: () => toProtocolUrl,
48
+ xcHasOwn: () => xcHasOwn,
40
49
  xcMessage: () => xcMessage
41
50
  });
42
51
  module.exports = __toCommonJS(src_exports);
@@ -56,6 +65,9 @@ var XC_HOST_TO_FRAME = [
56
65
  "xc.lookAt",
57
66
  "xc.pointer",
58
67
  "xc.setModel",
68
+ "xc.setOutfit",
69
+ "xc.setScene",
70
+ "xc.prefetch",
59
71
  "xc.setConfig",
60
72
  "xc.mic",
61
73
  "xc.pause",
@@ -66,10 +78,15 @@ var XC_FRAME_TO_HOST = [
66
78
  "xc.ready",
67
79
  "xc.load.progress",
68
80
  "xc.loaded",
81
+ "xc.outfit-changed",
82
+ "xc.scene-changed",
83
+ "xc.prefetched",
69
84
  "xc.state",
70
85
  "xc.stt",
71
86
  "xc.utterance",
72
87
  "xc.hit-region",
88
+ "xc.gesture-move",
89
+ "xc.gesture-resize",
73
90
  "xc.error"
74
91
  ];
75
92
  var XC_IMPLEMENTED_COMMANDS = [
@@ -81,6 +98,9 @@ var XC_IMPLEMENTED_COMMANDS = [
81
98
  "xc.expression",
82
99
  "xc.pointer",
83
100
  "xc.setModel",
101
+ "xc.setOutfit",
102
+ "xc.setScene",
103
+ "xc.prefetch",
84
104
  "xc.setConfig",
85
105
  "xc.mic",
86
106
  "xc.pause",
@@ -95,6 +115,34 @@ var XC_PROTOCOL_MAPPING = [
95
115
  { xc: "xc.audio", note: "source \u4E3A ArrayBuffer / Blob", action: "audio", url: null },
96
116
  { xc: "xc.audio.chunk / xc.audio.end", note: "\u6D41\u5F0F PCM", action: "audio", url: null }
97
117
  ];
118
+ var XC_WINDOW_CORNER_RADIUS = 20;
119
+ var XC_UI_PARTS = ["chat", "bubble", "outfit", "scene"];
120
+ var XC_UI_PART_SET = new Set(XC_UI_PARTS);
121
+ var XC_UI_LEGACY_PARTS = ["chat", "bubble"];
122
+ function parseXcUiList(items) {
123
+ const seen = /* @__PURE__ */ new Set();
124
+ const unknown = [];
125
+ for (const it of items) {
126
+ const name = typeof it === "string" ? it.trim().toLowerCase() : "";
127
+ if (XC_UI_PART_SET.has(name)) seen.add(name);
128
+ else unknown.push(typeof it === "string" ? it.slice(0, 40) : String(typeof it));
129
+ }
130
+ return { parts: XC_UI_PARTS.filter((p) => seen.has(p)), unknown, legacy: false };
131
+ }
132
+ function parseXcUiParam(raw) {
133
+ const v = (raw ?? "").trim().toLowerCase();
134
+ if (v === "" || v === "0" || v === "false") return { parts: [], unknown: [], legacy: false };
135
+ if (v === "1" || v === "true") return { parts: [...XC_UI_LEGACY_PARTS], unknown: [], legacy: true };
136
+ return parseXcUiList(v.split(",").filter((x) => x.trim() !== ""));
137
+ }
138
+ function normalizeXcUiOption(v) {
139
+ if (v === true) return { parts: [...XC_UI_LEGACY_PARTS], unknown: [], legacy: true };
140
+ if (Array.isArray(v)) return parseXcUiList(v);
141
+ return { parts: [], unknown: [], legacy: false };
142
+ }
143
+ var XC_ID_RE = /^[a-z][a-z0-9_]{0,63}$/;
144
+ var isXcId = (v) => typeof v === "string" && XC_ID_RE.test(v);
145
+ var xcHasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
98
146
  function xcMessage(type, payload, id) {
99
147
  const m = { type, v: XC_PROTOCOL_VERSION, payload };
100
148
  if (id !== void 0) m.id = id;
@@ -116,6 +164,95 @@ function normalizeOrigin(input) {
116
164
  }
117
165
  }
118
166
 
167
+ // src/gesture-box.ts
168
+ var DEFAULT_RESIZE_LIMITS = Object.freeze({
169
+ minWidth: 120,
170
+ minHeight: 180,
171
+ maxWidth: Infinity,
172
+ maxHeight: Infinity
173
+ });
174
+ var finitePositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
175
+ function normalizeResizable(v) {
176
+ if (!v) return null;
177
+ const o = v === true ? {} : v;
178
+ const minWidth = finitePositive(o.minWidth) ? o.minWidth : DEFAULT_RESIZE_LIMITS.minWidth;
179
+ const minHeight = finitePositive(o.minHeight) ? o.minHeight : DEFAULT_RESIZE_LIMITS.minHeight;
180
+ const maxWidth = finitePositive(o.maxWidth) ? Math.max(o.maxWidth, minWidth) : Infinity;
181
+ const maxHeight = finitePositive(o.maxHeight) ? Math.max(o.maxHeight, minHeight) : Infinity;
182
+ return { minWidth, minHeight, maxWidth, maxHeight };
183
+ }
184
+ var clamp = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
185
+ function moveBox(start, totalDx, totalDy, vp) {
186
+ return {
187
+ left: clamp(start.left + totalDx, 0, vp.width - start.width),
188
+ top: clamp(start.top + totalDy, 0, vp.height - start.height),
189
+ width: start.width,
190
+ height: start.height
191
+ };
192
+ }
193
+ function resizeBox(start, corner, totalDx, totalDy, limits, vp) {
194
+ const west = corner === "NW" || corner === "SW";
195
+ const north = corner === "NW" || corner === "NE";
196
+ const right = start.left + start.width, bottom = start.top + start.height;
197
+ const wantW = west ? start.width - totalDx : start.width + totalDx;
198
+ const wantH = north ? start.height - totalDy : start.height + totalDy;
199
+ const roomW = west ? right : vp.width - start.left;
200
+ const roomH = north ? bottom : vp.height - start.top;
201
+ const width = clamp(wantW, limits.minWidth, Math.min(limits.maxWidth, roomW));
202
+ const height = clamp(wantH, limits.minHeight, Math.min(limits.maxHeight, roomH));
203
+ return { left: west ? right - width : start.left, top: north ? bottom - height : start.top, width, height };
204
+ }
205
+ function fitBox(b, vp) {
206
+ const width = Math.min(b.width, Math.max(1, vp.width)), height = Math.min(b.height, Math.max(1, vp.height));
207
+ return { left: clamp(b.left, 0, vp.width - width), top: clamp(b.top, 0, vp.height - height), width, height };
208
+ }
209
+ var CORNERS = /* @__PURE__ */ new Set(["NW", "NE", "SW", "SE"]);
210
+ var PHASES = /* @__PURE__ */ new Set(["start", "move", "end"]);
211
+ var REASONS = /* @__PURE__ */ new Set(["up", "cancel", "blur"]);
212
+ var MAX_GESTURE_DELTA = 2e4;
213
+ var delta = (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= MAX_GESTURE_DELTA;
214
+ var counter = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
215
+ function parseGesturePayload(kind, raw) {
216
+ if (raw === null || typeof raw !== "object") return null;
217
+ const p = raw;
218
+ if (!counter(p.gesture) || !counter(p.seq) || typeof p.phase !== "string" || !PHASES.has(p.phase)) return null;
219
+ if (!delta(p.dx) || !delta(p.dy) || !delta(p.totalDx) || !delta(p.totalDy)) return null;
220
+ if (p.reason !== void 0 && (typeof p.reason !== "string" || !REASONS.has(p.reason))) return null;
221
+ const out = { gesture: p.gesture, seq: p.seq, phase: p.phase, totalDx: p.totalDx, totalDy: p.totalDy };
222
+ if (kind === "resize") {
223
+ if (typeof p.corner !== "string" || !CORNERS.has(p.corner)) return null;
224
+ out.corner = p.corner;
225
+ }
226
+ return out;
227
+ }
228
+ var GestureGate = class {
229
+ constructor() {
230
+ this.lastGesture = 0;
231
+ this.lastSeq = -1;
232
+ this.open = false;
233
+ }
234
+ get active() {
235
+ return this.open;
236
+ }
237
+ accept(p) {
238
+ if (p.phase === "start") {
239
+ if (p.gesture <= this.lastGesture || p.seq !== 0) return false;
240
+ this.lastGesture = p.gesture;
241
+ this.lastSeq = 0;
242
+ this.open = true;
243
+ return true;
244
+ }
245
+ if (!this.open || p.gesture !== this.lastGesture || p.seq <= this.lastSeq) return false;
246
+ this.lastSeq = p.seq;
247
+ if (p.phase === "end") this.open = false;
248
+ return true;
249
+ }
250
+ /** 宿主主动结束 (关闭手势 / 销毁): 之后同一手势的迟到消息被拒绝。 */
251
+ close() {
252
+ this.open = false;
253
+ }
254
+ };
255
+
119
256
  // src/client.ts
120
257
  var FALLBACK_PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
121
258
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 180"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f6b8ae"/><stop offset="1" stop-color="#ea8377"/></linearGradient></defs><ellipse cx="60" cy="170" rx="34" ry="6" fill="#000" opacity=".12"/><circle cx="60" cy="48" r="26" fill="url(#g)" opacity=".75"/><path d="M26 160c2-40 18-62 34-62s32 22 34 62z" fill="url(#g)" opacity=".6"/></svg>'
@@ -142,8 +279,46 @@ function createXiaochun(options) {
142
279
  if (!n) throw new Error(`[project-xiaochun] invalid allowedOrigins entry: ${o} (wildcards are not allowed)`);
143
280
  trusted.add(n);
144
281
  }
145
- const transparent = options.transparent ?? false;
146
- const passthrough = options.passthrough ?? transparent;
282
+ const prefsKey = options.persist === "host" ? "xiaochun:prefs" : typeof options.persist === "string" && options.persist ? options.persist : null;
283
+ const readPrefs = () => {
284
+ if (!prefsKey) return {};
285
+ try {
286
+ const raw = JSON.parse(window.localStorage.getItem(prefsKey) ?? "null");
287
+ const out = {};
288
+ if (raw && typeof raw === "object") {
289
+ if (isXcId(raw.outfit)) out.outfit = raw.outfit;
290
+ if (isXcId(raw.scene)) out.scene = raw.scene;
291
+ }
292
+ return out;
293
+ } catch {
294
+ return {};
295
+ }
296
+ };
297
+ const writePrefs = (patch) => {
298
+ if (!prefsKey) return;
299
+ try {
300
+ const next = { ...readPrefs() };
301
+ for (const [k, v] of Object.entries(patch)) {
302
+ if (v) next[k] = v;
303
+ else delete next[k];
304
+ }
305
+ window.localStorage.setItem(prefsKey, JSON.stringify(next));
306
+ } catch {
307
+ }
308
+ };
309
+ const prefs = readPrefs();
310
+ if (options.model !== void 0 && options.outfit === void 0) {
311
+ console.warn('[project-xiaochun] option "model" is deprecated, use "outfit" (built-in outfit id).');
312
+ }
313
+ const uiParse = normalizeXcUiOption(options.ui);
314
+ const uiParts = uiParse.parts;
315
+ if (uiParse.legacy) console.warn(`[project-xiaochun] option "ui: true" is deprecated, use an array of parts, e.g. ui: ['chat', 'bubble', 'outfit', 'scene'].`);
316
+ if (uiParse.unknown.length) console.warn(`[project-xiaochun] ignoring unknown ui part(s): ${uiParse.unknown.join(", ")} (known: ${XC_UI_PARTS.join(", ")})`);
317
+ const outfitOpt = options.outfit ?? (isXcId(options.model) ? options.model : void 0);
318
+ const initialOutfit = outfitOpt ?? prefs.outfit;
319
+ const initialScene = options.scene ?? prefs.scene ?? (options.transparent ? "transparent" : void 0);
320
+ let transparent = initialScene === "transparent" || initialScene !== "light" && initialScene !== "dark" && (options.transparent ?? false);
321
+ let passthrough = options.passthrough ?? transparent;
147
322
  const lazy = options.lazy ?? true;
148
323
  const position = options.position ?? "inline";
149
324
  const handshakeTimeout = options.handshakeTimeout ?? 2e4;
@@ -156,9 +331,20 @@ function createXiaochun(options) {
156
331
  ws.maxWidth = "100%";
157
332
  ws.overflow = "visible";
158
333
  ws.contain = "layout style";
159
- ws.borderRadius = "var(--xc-radius, 0)";
160
334
  ws.boxShadow = "var(--xc-shadow, none)";
161
- if (!transparent) ws.background = "var(--xc-bg, transparent)";
335
+ const applyWrapperBg = () => {
336
+ ws.background = transparent ? "" : "var(--xc-bg, transparent)";
337
+ };
338
+ applyWrapperBg();
339
+ let radiusOpt = options.borderRadius;
340
+ const radiusValue = () => radiusOpt !== void 0 ? css(radiusOpt, "0px") : transparent ? "0px" : `${XC_WINDOW_CORNER_RADIUS}px`;
341
+ const applyRadius = () => {
342
+ const v = `var(--xc-radius, ${radiusValue()})`;
343
+ ws.borderRadius = v;
344
+ if (placeholderEl) placeholderEl.style.borderRadius = v;
345
+ if (iframe) iframe.style.borderRadius = v;
346
+ };
347
+ ws.borderRadius = `var(--xc-radius, ${radiusValue()})`;
162
348
  if (position === "bottom-right") {
163
349
  ws.right = "var(--xc-offset-x, 16px)";
164
350
  ws.bottom = "var(--xc-offset-y, 16px)";
@@ -185,7 +371,7 @@ function createXiaochun(options) {
185
371
  }
186
372
  const ps = placeholderEl.style;
187
373
  placeholderEl.setAttribute("part", "placeholder");
188
- ps.borderRadius = "var(--xc-radius, 0)";
374
+ ps.borderRadius = `var(--xc-radius, ${radiusValue()})`;
189
375
  ps.position = "absolute";
190
376
  ps.inset = "0";
191
377
  ps.width = "100%";
@@ -222,25 +408,45 @@ function createXiaochun(options) {
222
408
  let autoPaused = false;
223
409
  let loaded = false;
224
410
  let lastHit = false;
411
+ let caps = null;
412
+ let currentOutfit = null;
413
+ let currentScene = null;
225
414
  const queue = [];
226
415
  const pending = /* @__PURE__ */ new Map();
227
416
  let idSeq = 0;
228
417
  let handshakeTimer = null;
229
418
  let io = null;
230
419
  let idleHandle = null;
231
- const postToFrame = (type, payload, id, transfer) => {
420
+ let resolveHandshake;
421
+ const handshaked = new Promise((res) => {
422
+ resolveHandshake = res;
423
+ });
424
+ const failLocal = (type, id, code, message) => {
425
+ emit("error", { code, message, command: type });
426
+ const p = id ? pending.get(id) : void 0;
427
+ if (p && id) {
428
+ pending.delete(id);
429
+ p.reject(new Error(`[${code}] ${message}`));
430
+ }
431
+ };
432
+ const unsupportedByFrame = (type, id) => failLocal(type, id, "unsupported", `${type} is not supported by this /embed deployment (older version; its xc.ready has no capabilities for it)`);
433
+ const postToFrame = (type, payload, id, transfer, requires) => {
232
434
  if (!port) {
233
- queue.push({ type, payload, id, transfer });
435
+ queue.push({ type, payload, id, transfer, requires });
436
+ return;
437
+ }
438
+ if (requires && !caps?.[requires]) {
439
+ unsupportedByFrame(type, id);
234
440
  return;
235
441
  }
236
442
  port.postMessage(xcMessage(type, payload, id), transfer ?? []);
237
443
  };
238
- const send = (type, payload, wait = false, transfer, fixedId) => {
444
+ const send = (type, payload, wait = false, transfer, fixedId, requires) => {
239
445
  if (destroyed) return Promise.reject(new Error("[project-xiaochun] instance destroyed"));
240
446
  if (!iframe) activate();
241
447
  const id = fixedId ?? `c${++idSeq}`;
242
448
  const p = wait ? new Promise((resolve, reject) => pending.set(id, { resolve, reject, sawEnd: false })) : Promise.resolve();
243
- postToFrame(type, payload, id, transfer);
449
+ postToFrame(type, payload, id, transfer, requires);
244
450
  return p;
245
451
  };
246
452
  const toBuffer = (d) => d instanceof ArrayBuffer ? d : d.buffer.slice(d.byteOffset, d.byteOffset + d.byteLength);
@@ -290,7 +496,7 @@ function createXiaochun(options) {
290
496
  let ended = false;
291
497
  let fmt = rest.format === "pcm16" || rest.format === "float32" ? rest.format : null;
292
498
  let done = Promise.resolve();
293
- const handle2 = {
499
+ const handle = {
294
500
  write(data) {
295
501
  if (ended) throw new Error("[project-xiaochun] audio stream already ended");
296
502
  if (destroyed) throw new Error("[project-xiaochun] instance destroyed");
@@ -324,17 +530,18 @@ function createXiaochun(options) {
324
530
  return done;
325
531
  }
326
532
  };
327
- return handle2;
533
+ return handle;
328
534
  };
329
535
  function buildUrl() {
330
536
  const u = new URL(srcUrl.href);
331
537
  u.searchParams.set("host", window.location.origin);
332
538
  if (transparent) u.searchParams.set("transparent", "1");
333
- if (options.ui) u.searchParams.set("ui", "1");
539
+ if (uiParts.length) u.searchParams.set("ui", uiParts.join(","));
334
540
  if (options.lang) u.searchParams.set("lang", options.lang);
335
541
  if (options.heavy) u.searchParams.set("heavy", options.heavy);
336
- if (options.controls) u.searchParams.set("controls", "1");
337
- if (options.model) u.searchParams.set("outfit", options.model);
542
+ if (options.controls === false) u.searchParams.set("controls", "0");
543
+ if (initialScene && isXcId(initialScene)) u.searchParams.set("scene", initialScene);
544
+ if (initialOutfit) u.searchParams.set("outfit", initialOutfit);
338
545
  return u.href;
339
546
  }
340
547
  function activate() {
@@ -360,7 +567,7 @@ function createXiaochun(options) {
360
567
  s.border = "0";
361
568
  s.background = "transparent";
362
569
  s.colorScheme = "normal";
363
- s.borderRadius = "var(--xc-radius, 0)";
570
+ s.borderRadius = `var(--xc-radius, ${radiusValue()})`;
364
571
  f.setAttribute("part", "iframe");
365
572
  s.opacity = "0";
366
573
  s.transition = "opacity .25s ease";
@@ -373,7 +580,7 @@ function createXiaochun(options) {
373
580
  if (!port) emit("error", { code: "timeout", message: `no xc.ready from ${frameOrigin} within ${handshakeTimeout}ms` });
374
581
  }, handshakeTimeout);
375
582
  setupAutoPause();
376
- setupPassthrough();
583
+ if (passthrough) enablePassthrough();
377
584
  }
378
585
  const onWindowMessage = (ev) => {
379
586
  if (destroyed || !iframe || ev.source !== iframe.contentWindow) return;
@@ -386,20 +593,39 @@ function createXiaochun(options) {
386
593
  const ch = new MessageChannel();
387
594
  port = ch.port1;
388
595
  port.onmessage = onPortMessage;
596
+ caps = data.payload?.capabilities ?? null;
597
+ const gWant = wantGestures();
598
+ sentGestures = gWant;
599
+ const initConfig = {
600
+ ...options.allowCustomModel ? { allowCustomModel: true } : {},
601
+ ...gWant.move || gWant.resize ? { gestures: gWant } : {}
602
+ };
389
603
  iframe.contentWindow.postMessage(
390
- xcMessage("xc.init", { hostOrigin: window.location.origin }),
604
+ xcMessage("xc.init", { hostOrigin: window.location.origin, ...Object.keys(initConfig).length ? { config: initConfig } : {} }),
391
605
  ev.origin,
392
606
  [ch.port2]
393
607
  );
608
+ warnGestureUnsupported();
394
609
  if (handshakeTimer) {
395
610
  clearTimeout(handshakeTimer);
396
611
  handshakeTimer = null;
397
612
  }
398
613
  emit("handshake", data.payload);
399
- for (const m of queue.splice(0)) port.postMessage(xcMessage(m.type, m.payload, m.id), m.transfer ?? []);
614
+ resolveHandshake();
615
+ for (const m of queue.splice(0)) {
616
+ if (m.requires && !caps?.[m.requires]) unsupportedByFrame(m.type, m.id);
617
+ else port.postMessage(xcMessage(m.type, m.payload, m.id), m.transfer ?? []);
618
+ }
400
619
  syncPause();
401
620
  };
402
621
  window.addEventListener("message", onWindowMessage);
622
+ function ack(id, value) {
623
+ const p = id ? pending.get(id) : void 0;
624
+ if (p && id) {
625
+ pending.delete(id);
626
+ p.resolve(value);
627
+ }
628
+ }
403
629
  function onPortMessage(ev) {
404
630
  const data = ev.data;
405
631
  if (!isXcEnvelope(data)) return;
@@ -410,6 +636,11 @@ function createXiaochun(options) {
410
636
  case "xc.loaded":
411
637
  if (!loaded) {
412
638
  loaded = true;
639
+ if (options.prefetch && options.heavy === "eager" && caps?.prefetch) {
640
+ const ids = Array.isArray(options.prefetch) ? options.prefetch : void 0;
641
+ void api.prefetch(ids).catch(() => {
642
+ });
643
+ }
413
644
  if (iframe) iframe.style.opacity = "1";
414
645
  if (placeholderEl) {
415
646
  const el = placeholderEl;
@@ -422,6 +653,30 @@ function createXiaochun(options) {
422
653
  }
423
654
  emit("ready", data.payload);
424
655
  break;
656
+ case "xc.outfit-changed": {
657
+ const pl = data.payload;
658
+ if (!pl.noop) {
659
+ currentOutfit = pl.id;
660
+ emit("outfit-changed", pl);
661
+ if (!pl.initial && pl.id) writePrefs({ outfit: pl.id });
662
+ }
663
+ ack(data.id);
664
+ break;
665
+ }
666
+ case "xc.scene-changed": {
667
+ const pl = data.payload;
668
+ currentScene = pl.id;
669
+ if (pl.transparent !== transparent) applyTransparency(pl.transparent);
670
+ if (!pl.noop) {
671
+ emit("scene-changed", pl);
672
+ if (!pl.initial) writePrefs({ scene: pl.id });
673
+ }
674
+ ack(data.id);
675
+ break;
676
+ }
677
+ case "xc.prefetched":
678
+ ack(data.id, data.payload);
679
+ break;
425
680
  case "xc.state":
426
681
  emit("state", data.payload);
427
682
  break;
@@ -446,9 +701,16 @@ function createXiaochun(options) {
446
701
  }
447
702
  break;
448
703
  }
704
+ case "xc.gesture-move":
705
+ onGesture("move", data.payload);
706
+ break;
707
+ case "xc.gesture-resize":
708
+ onGesture("resize", data.payload);
709
+ break;
449
710
  case "xc.error": {
450
711
  const { code, message, command } = data.payload;
451
712
  emit("error", { code, message, command });
713
+ if (code === "unknown_id" && (command === "outfit" || command === "scene")) writePrefs({ [command]: null });
452
714
  const p = data.id ? pending.get(data.id) : void 0;
453
715
  if (p && data.id) {
454
716
  pending.delete(data.id);
@@ -505,16 +767,46 @@ function createXiaochun(options) {
505
767
  port.postMessage(xcMessage("xc.pointer", { x, y }));
506
768
  });
507
769
  };
508
- function setupPassthrough() {
509
- if (!passthrough) return;
770
+ let passthroughActive = false;
771
+ function enablePassthrough() {
772
+ if (passthroughActive || !iframe) return;
773
+ passthroughActive = true;
510
774
  window.addEventListener("pointermove", onHostPointer, { passive: true });
775
+ window.addEventListener("pointerdown", onHostPointer, { passive: true });
511
776
  window.addEventListener("scroll", invalidateRect, { passive: true, capture: true });
512
777
  window.addEventListener("resize", invalidateRect, { passive: true });
513
- if (typeof ResizeObserver !== "undefined" && iframe) {
778
+ if (typeof ResizeObserver !== "undefined") {
514
779
  rectRo = new ResizeObserver(invalidateRect);
515
780
  rectRo.observe(iframe);
516
781
  }
517
782
  }
783
+ function disablePassthrough() {
784
+ if (!passthroughActive) return;
785
+ passthroughActive = false;
786
+ window.removeEventListener("pointermove", onHostPointer);
787
+ window.removeEventListener("pointerdown", onHostPointer);
788
+ window.removeEventListener("scroll", invalidateRect, true);
789
+ window.removeEventListener("resize", invalidateRect);
790
+ rectRo?.disconnect();
791
+ rectRo = null;
792
+ if (pointerRaf) {
793
+ cancelAnimationFrame(pointerRaf);
794
+ pointerRaf = 0;
795
+ }
796
+ lastPtr = null;
797
+ }
798
+ function applyTransparency(next) {
799
+ transparent = next;
800
+ applyWrapperBg();
801
+ applyRadius();
802
+ passthrough = next ? options.passthrough ?? true : false;
803
+ lastHit = false;
804
+ lastPosted = null;
805
+ rectCache = null;
806
+ if (passthrough) enablePassthrough();
807
+ else disablePassthrough();
808
+ if (iframe) iframe.style.pointerEvents = passthrough ? "none" : "auto";
809
+ }
518
810
  let pauseIo = null;
519
811
  function setupAutoPause() {
520
812
  if (options.autoPause === false || typeof IntersectionObserver === "undefined") return;
@@ -566,50 +858,123 @@ function createXiaochun(options) {
566
858
  }, { rootMargin: `${options.lazyMargin ?? 200}px` });
567
859
  io.observe(wrapper);
568
860
  }
569
- let handle = null;
570
- if (options.draggable && position !== "inline") {
571
- handle = document.createElement("div");
572
- handle.setAttribute("aria-label", "drag");
573
- Object.assign(handle.style, {
574
- position: "absolute",
575
- left: "0",
576
- top: "0",
577
- width: "28px",
578
- height: "28px",
579
- cursor: "grab",
580
- pointerEvents: "auto",
581
- borderRadius: "8px",
582
- background: "rgba(0,0,0,.25)",
583
- color: "#fff",
584
- font: "14px/28px system-ui",
585
- textAlign: "center",
586
- userSelect: "none",
587
- touchAction: "none",
588
- zIndex: "2",
589
- opacity: "0.5"
590
- });
591
- handle.textContent = "\u283F";
592
- let drag = null;
593
- handle.addEventListener("pointerdown", (e) => {
594
- const r = wrapper.getBoundingClientRect();
595
- drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
596
- handle.setPointerCapture(e.pointerId);
597
- });
598
- handle.addEventListener("pointermove", (e) => {
599
- if (!drag) return;
600
- ws.left = `${Math.min(Math.max(0, e.clientX - drag.dx), window.innerWidth - 40)}px`;
601
- ws.top = `${Math.min(Math.max(0, e.clientY - drag.dy), window.innerHeight - 40)}px`;
861
+ let draggableOn = options.draggable === true;
862
+ let resizeLimits = normalizeResizable(options.resizable);
863
+ const gates = { move: new GestureGate(), resize: new GestureGate() };
864
+ let activeGesture = null;
865
+ let inlineShift = { x: 0, y: 0 };
866
+ let userMoved = false;
867
+ let sentGestures = null;
868
+ let warnedGesture = false;
869
+ const viewport = () => ({
870
+ width: document.documentElement.clientWidth || window.innerWidth,
871
+ height: document.documentElement.clientHeight || window.innerHeight
872
+ });
873
+ const readBox = () => {
874
+ const r = wrapper.getBoundingClientRect();
875
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
876
+ };
877
+ const wantGestures = () => ({ move: draggableOn && caps?.gestures?.move === true, resize: resizeLimits !== null && caps?.gestures?.resize === true });
878
+ function applyBox(b, inlineBase, withSize) {
879
+ if (withSize) {
880
+ ws.width = `${b.width}px`;
881
+ ws.height = `${b.height}px`;
882
+ ws.maxWidth = "none";
883
+ }
884
+ if (position === "inline") {
885
+ const base = inlineBase ?? { left: b.left - inlineShift.x, top: b.top - inlineShift.y };
886
+ inlineShift = { x: b.left - base.left, y: b.top - base.top };
887
+ ws.setProperty("translate", `${inlineShift.x}px ${inlineShift.y}px`);
888
+ } else {
889
+ ws.left = `${b.left}px`;
890
+ ws.top = `${b.top}px`;
602
891
  ws.right = "auto";
603
892
  ws.bottom = "auto";
604
- invalidateRect();
605
- });
606
- const end = () => {
607
- drag = null;
608
- };
609
- handle.addEventListener("pointerup", end);
610
- handle.addEventListener("pointercancel", end);
611
- wrapper.appendChild(handle);
893
+ }
894
+ userMoved = true;
895
+ invalidateRect();
896
+ }
897
+ let selectionLock = null;
898
+ function lockSelection(on) {
899
+ const de = document.documentElement;
900
+ if (on && !selectionLock) {
901
+ selectionLock = { us: de.style.getPropertyValue("user-select"), wus: de.style.getPropertyValue("-webkit-user-select") };
902
+ de.style.setProperty("user-select", "none");
903
+ de.style.setProperty("-webkit-user-select", "none");
904
+ try {
905
+ window.getSelection()?.removeAllRanges();
906
+ } catch {
907
+ }
908
+ } else if (!on && selectionLock) {
909
+ de.style.setProperty("user-select", selectionLock.us);
910
+ de.style.setProperty("-webkit-user-select", selectionLock.wus);
911
+ selectionLock = null;
912
+ }
913
+ }
914
+ function endActiveGesture(kind) {
915
+ if (!activeGesture || kind && activeGesture.kind !== kind) return;
916
+ gates[activeGesture.kind].close();
917
+ const b = readBox();
918
+ emit(activeGesture.kind, { phase: "end", ...b });
919
+ activeGesture = null;
920
+ lockSelection(false);
612
921
  }
922
+ function onGesture(kind, raw) {
923
+ if (destroyed || !caps?.gestures) return;
924
+ if (kind === "move" ? !draggableOn : resizeLimits === null) return;
925
+ const p = parseGesturePayload(kind, raw);
926
+ if (!p) return;
927
+ if (p.phase === "start" ? activeGesture !== null : activeGesture?.kind !== kind) return;
928
+ if (!gates[kind].accept(p)) return;
929
+ if (p.phase === "start") {
930
+ const start = readBox();
931
+ activeGesture = {
932
+ kind,
933
+ start,
934
+ corner: p.corner,
935
+ inlineBase: position === "inline" ? { left: start.left - inlineShift.x, top: start.top - inlineShift.y } : null
936
+ };
937
+ lockSelection(true);
938
+ emit(kind, { phase: "start", ...start });
939
+ return;
940
+ }
941
+ const a = activeGesture;
942
+ const vp = viewport();
943
+ const next = kind === "move" ? moveBox(a.start, p.totalDx, p.totalDy, vp) : resizeBox(a.start, a.corner, p.totalDx, p.totalDy, resizeLimits, vp);
944
+ applyBox(next, a.inlineBase, kind === "resize");
945
+ emit(kind, { phase: p.phase, ...next });
946
+ if (p.phase === "end") {
947
+ activeGesture = null;
948
+ lockSelection(false);
949
+ }
950
+ }
951
+ function syncGestures() {
952
+ if (destroyed) return;
953
+ const want = wantGestures();
954
+ if (!want.move) endActiveGesture("move");
955
+ if (!want.resize) endActiveGesture("resize");
956
+ if (!port || !caps) return;
957
+ warnGestureUnsupported();
958
+ if (sentGestures && sentGestures.move === want.move && sentGestures.resize === want.resize) return;
959
+ sentGestures = want;
960
+ if (caps.gestures) port.postMessage(xcMessage("xc.setConfig", { gestures: want }));
961
+ }
962
+ function warnGestureUnsupported() {
963
+ if (warnedGesture || !caps || caps.gestures) return;
964
+ const needMove = draggableOn;
965
+ if (!needMove && resizeLimits === null) return;
966
+ warnedGesture = true;
967
+ const what = [needMove ? "draggable" : "", resizeLimits !== null ? "resizable" : ""].filter(Boolean).join(" / ");
968
+ emit("error", { code: "unsupported", message: `${what} needs gesture support (capabilities.gestures), which this /embed deployment does not advertise (older version)`, command: "gestures" });
969
+ }
970
+ const onWindowResize = () => {
971
+ if (!userMoved || activeGesture || destroyed) return;
972
+ const cur = readBox();
973
+ const fit = fitBox(cur, viewport());
974
+ if (fit.left === cur.left && fit.top === cur.top && fit.width === cur.width && fit.height === cur.height) return;
975
+ applyBox(fit, position === "inline" ? { left: cur.left - inlineShift.x, top: cur.top - inlineShift.y } : null, fit.width !== cur.width || fit.height !== cur.height);
976
+ };
977
+ window.addEventListener("resize", onWindowResize, { passive: true });
613
978
  scheduleLazy();
614
979
  const api = {
615
980
  element: wrapper,
@@ -624,8 +989,65 @@ function createXiaochun(options) {
624
989
  motion: (m) => send("xc.motion", typeof m === "string" ? m.startsWith("http") || m.startsWith("/") ? { url: m } : { name: m } : m),
625
990
  expression: (name) => send("xc.expression", { name }),
626
991
  lookAt: (x, y) => send("xc.lookAt", { x, y }),
627
- setModel: (m) => send("xc.setModel", typeof m === "string" ? /^(https?:)?\/|\.vrm/i.test(m) ? { url: m } : { outfit: m } : m),
992
+ setOutfit: (id) => {
993
+ if (!isXcId(id)) return Promise.reject(new Error("[bad_request] invalid outfit id"));
994
+ return send("xc.setOutfit", { id }, true, void 0, void 0, "outfits");
995
+ },
996
+ setScene: (id) => {
997
+ if (!isXcId(id)) return Promise.reject(new Error("[bad_request] invalid scene id"));
998
+ return send("xc.setScene", { id }, true, void 0, void 0, "scenes");
999
+ },
1000
+ prefetch: (ids) => {
1001
+ if (ids !== void 0 && (!Array.isArray(ids) || ids.some((x) => !isXcId(x)))) {
1002
+ return Promise.reject(new Error("[bad_request] prefetch ids must be an array of outfit ids"));
1003
+ }
1004
+ return send("xc.prefetch", ids ? { ids } : {}, true, void 0, void 0, "prefetch");
1005
+ },
1006
+ getOutfits: async () => {
1007
+ if (!iframe) activate();
1008
+ await handshaked;
1009
+ return caps?.outfits ? caps.outfits.slice() : [];
1010
+ },
1011
+ getScenes: async () => {
1012
+ if (!iframe) activate();
1013
+ await handshaked;
1014
+ return caps?.scenes ? caps.scenes.slice() : [];
1015
+ },
1016
+ get outfit() {
1017
+ return currentOutfit;
1018
+ },
1019
+ get scene() {
1020
+ return currentScene;
1021
+ },
1022
+ setModel: (m) => {
1023
+ const msg = typeof m === "string" ? /^(https?:)?\/|\.vrm/i.test(m) ? { url: m } : { outfit: m } : m;
1024
+ if (msg.url !== void 0 && msg.outfit === void 0 && !options.allowCustomModel) {
1025
+ const message = "custom model URLs are disabled; pass allowCustomModel: true to createXiaochun() (only for URLs you trust) or use setOutfit() with a built-in id";
1026
+ emit("error", { code: "unsupported", message, command: "xc.setModel" });
1027
+ return Promise.reject(new Error(`[unsupported] ${message}`));
1028
+ }
1029
+ return send("xc.setModel", msg);
1030
+ },
628
1031
  setConfig: (cfg) => send("xc.setConfig", cfg),
1032
+ setSize(width, height) {
1033
+ if (destroyed) return;
1034
+ ws.width = css(width, "320px");
1035
+ ws.height = css(height, "480px");
1036
+ invalidateRect();
1037
+ },
1038
+ getBox: () => readBox(),
1039
+ setBorderRadius(radius) {
1040
+ radiusOpt = radius;
1041
+ applyRadius();
1042
+ },
1043
+ setDraggable(on) {
1044
+ draggableOn = on === true;
1045
+ syncGestures();
1046
+ },
1047
+ setResizable(on) {
1048
+ resizeLimits = normalizeResizable(on);
1049
+ syncGestures();
1050
+ },
629
1051
  startListening: () => send("xc.mic", { enabled: true }),
630
1052
  stopListening: () => send("xc.mic", { enabled: false }),
631
1053
  mic: (enabled) => send("xc.mic", { enabled }),
@@ -650,10 +1072,10 @@ function createXiaochun(options) {
650
1072
  if (handshakeTimer) clearTimeout(handshakeTimer);
651
1073
  if (pointerRaf) cancelAnimationFrame(pointerRaf);
652
1074
  window.removeEventListener("message", onWindowMessage);
653
- window.removeEventListener("pointermove", onHostPointer);
654
- window.removeEventListener("scroll", invalidateRect, true);
655
- window.removeEventListener("resize", invalidateRect);
656
- rectRo?.disconnect();
1075
+ window.removeEventListener("resize", onWindowResize);
1076
+ lockSelection(false);
1077
+ disablePassthrough();
1078
+ resolveHandshake();
657
1079
  try {
658
1080
  port?.close();
659
1081
  } catch {
@@ -686,11 +1108,17 @@ function createXiaochun(options) {
686
1108
  // src/avatar-element.ts
687
1109
  var OBSERVED = [
688
1110
  "src",
1111
+ "outfit",
1112
+ "scene",
689
1113
  "model",
690
1114
  "lang",
691
1115
  "mic",
692
1116
  "transparent",
693
1117
  "draggable",
1118
+ "resizable",
1119
+ "min-size",
1120
+ "max-size",
1121
+ "border-radius",
694
1122
  "position",
695
1123
  "size",
696
1124
  "lazy",
@@ -700,14 +1128,36 @@ var OBSERVED = [
700
1128
  "ui",
701
1129
  "controls",
702
1130
  "allowed-origins",
703
- "cross-origin-isolated"
1131
+ "cross-origin-isolated",
1132
+ "persist",
1133
+ "allow-custom-model",
1134
+ "prefetch"
704
1135
  ];
705
- var REBUILD = /* @__PURE__ */ new Set(["src", "draggable", "position", "size", "lazy", "placeholder", "heavy", "ui", "controls", "allowed-origins", "transparent", "cross-origin-isolated"]);
1136
+ var REBUILD = /* @__PURE__ */ new Set(["src", "position", "lazy", "placeholder", "heavy", "ui", "controls", "allowed-origins", "transparent", "cross-origin-isolated", "persist", "allow-custom-model", "prefetch"]);
1137
+ function radiusAttr(v) {
1138
+ if (v === null || v.trim() === "") return void 0;
1139
+ const t = v.trim();
1140
+ return /^\d+(\.\d+)?$/.test(t) ? Number(t) : t;
1141
+ }
706
1142
  function boolAttr(el, name, dflt) {
707
1143
  const v = el.getAttribute(name);
708
1144
  if (v === null) return dflt;
709
1145
  return v !== "false";
710
1146
  }
1147
+ function prefetchAttr(v) {
1148
+ if (v === null || v === "false") return false;
1149
+ if (v === "" || v === "true") return true;
1150
+ return v.split(",").map((s) => s.trim()).filter(Boolean);
1151
+ }
1152
+ function uiAttr(v) {
1153
+ if (v === null || v === "false" || v === "0") return void 0;
1154
+ if (v === "" || v === "true" || v === "1") return true;
1155
+ return v.split(",").map((s) => s.trim()).filter(Boolean);
1156
+ }
1157
+ function parsePx(v) {
1158
+ const m = v?.trim().match(/^(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)$/i);
1159
+ return m ? { w: Number(m[1]), h: Number(m[2]) } : void 0;
1160
+ }
711
1161
  function parseSize(v) {
712
1162
  if (!v) return { width: "320px", height: "480px" };
713
1163
  const m = v.trim().split(/x/i);
@@ -755,10 +1205,20 @@ function createElementClass() {
755
1205
  return;
756
1206
  }
757
1207
  const c = this.client;
758
- if (name === "model" && newV) void c.setModel(newV).catch(() => {
1208
+ if ((name === "outfit" || name === "model") && newV) {
1209
+ const want = name === "model" && this.hasAttribute("outfit") ? null : newV;
1210
+ if (want) void (isXcId(want) ? c.setOutfit(want) : c.setModel(want)).catch(() => {
1211
+ });
1212
+ } else if (name === "scene" && newV) void c.setScene(newV).catch(() => {
759
1213
  });
760
1214
  else if (name === "lang" && newV) void c.setConfig({ lang: newV }).catch(() => {
761
1215
  });
1216
+ else if (name === "size") {
1217
+ const { width, height } = parseSize(newV);
1218
+ c.setSize(width, height);
1219
+ } else if (name === "border-radius") c.setBorderRadius(radiusAttr(newV));
1220
+ else if (name === "draggable") c.setDraggable(boolAttr(this, "draggable", false));
1221
+ else if (name === "resizable" || name === "min-size" || name === "max-size") c.setResizable(this.resizableOption());
762
1222
  else if (name === "mic") void c.mic(boolAttr(this, "mic", false)).catch(() => {
763
1223
  });
764
1224
  else if (name === "paused") {
@@ -785,11 +1245,17 @@ function createElementClass() {
785
1245
  height,
786
1246
  position: this.getAttribute("position") ?? "inline",
787
1247
  draggable: boolAttr(this, "draggable", false),
1248
+ resizable: this.resizableOption(),
1249
+ borderRadius: radiusAttr(this.getAttribute("border-radius")),
788
1250
  lang: this.getAttribute("lang") ?? void 0,
789
- model: this.getAttribute("model") ?? void 0,
1251
+ outfit: this.getAttribute("outfit") ?? this.getAttribute("model") ?? void 0,
1252
+ scene: this.getAttribute("scene") ?? void 0,
1253
+ persist: this.getAttribute("persist") || void 0,
1254
+ allowCustomModel: boolAttr(this, "allow-custom-model", false),
1255
+ prefetch: prefetchAttr(this.getAttribute("prefetch")),
790
1256
  heavy: this.getAttribute("heavy") ?? void 0,
791
- ui: boolAttr(this, "ui", false),
792
- controls: boolAttr(this, "controls", false),
1257
+ ui: uiAttr(this.getAttribute("ui")),
1258
+ controls: boolAttr(this, "controls", true),
793
1259
  crossOriginIsolated: boolAttr(this, "cross-origin-isolated", false)
794
1260
  });
795
1261
  this.client = c;
@@ -800,10 +1266,20 @@ function createElementClass() {
800
1266
  fwd("stt", "xc-stt");
801
1267
  fwd("utterance", "xc-utterance");
802
1268
  fwd("error", "xc-error");
1269
+ fwd("outfit-changed", "xc-outfit-changed");
1270
+ fwd("scene-changed", "xc-scene-changed");
1271
+ fwd("move", "xc-move");
1272
+ fwd("resize", "xc-resize");
803
1273
  if (boolAttr(this, "paused", false)) c.pause();
804
1274
  if (boolAttr(this, "mic", false)) void c.ready.then(() => c.mic(true)).catch(() => {
805
1275
  });
806
1276
  }
1277
+ /** resizable / min-size / max-size 属性 → SDK 的 resizable 选项 (缺省 / "false" = 关)。 */
1278
+ resizableOption() {
1279
+ if (!boolAttr(this, "resizable", false)) return false;
1280
+ const min = parsePx(this.getAttribute("min-size")), max = parsePx(this.getAttribute("max-size"));
1281
+ return { minWidth: min?.w, minHeight: min?.h, maxWidth: max?.w, maxHeight: max?.h };
1282
+ }
807
1283
  say(text, opts) {
808
1284
  return this.requireClient().say(text, opts);
809
1285
  }
@@ -819,6 +1295,21 @@ function createElementClass() {
819
1295
  expression(name) {
820
1296
  return this.requireClient().expression(name);
821
1297
  }
1298
+ setOutfit(id) {
1299
+ return this.requireClient().setOutfit(id);
1300
+ }
1301
+ setScene(id) {
1302
+ return this.requireClient().setScene(id);
1303
+ }
1304
+ getOutfits() {
1305
+ return this.requireClient().getOutfits();
1306
+ }
1307
+ getScenes() {
1308
+ return this.requireClient().getScenes();
1309
+ }
1310
+ prefetch(ids) {
1311
+ return this.requireClient().prefetch(ids);
1312
+ }
822
1313
  destroy() {
823
1314
  this.client?.destroy();
824
1315
  this.client = null;