@spatius/avatarkit 1.1.0-beta.1 → 1.2.0-beta.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0-beta.2] - 2026-06-23
9
+
10
+ ### Removed
11
+ - Post-processing support has been removed from the SDK. The `PostProcessingConfig` type and `AvatarController.setPostProcessingConfig()` are gone; the SDK now renders frames exactly as provided. No effect on standard playback or RTC.
12
+
13
+ ## [1.2.0-beta.1] - 2026-06-19
14
+
15
+ ### Added
16
+ - Configurable frame-starvation handling. New public API:
17
+ - `FrameStarvationMode` enum with `audioIndependent` (default) and `strictSync`.
18
+ - `AvatarController.frameStarvationMode: FrameStarvationMode` — choose how playback behaves when animation frames can't keep up with audio. `audioIndependent` keeps audio playing while animation catches up (previous behavior); `strictSync` pauses audio until frames arrive, keeping audio and animation strictly in sync.
19
+ - `AvatarController.onPlaybackStall: ((stalled: boolean) => void) | null` — fires when audio is paused/resumed due to frame starvation (only in `strictSync`).
20
+
21
+ ### Changed
22
+ - Avatars now load via the latest avatar asset format.
23
+ - CDN selection now adapts to the configured region.
24
+
8
25
  ## [1.1.0-beta.1] - 2026-06-08
9
26
 
10
27
  First 1.1 pre-release. Includes breaking changes — see below.
@@ -1,7 +1,7 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
- import { A as APP_CONFIG, l as logger, e as errorToMessage, a as logEvent } from "./index-CVQn5uEB.js";
4
+ import { A as APP_CONFIG, l as logger, e as errorToMessage, a as logEvent } from "./index-BnubRaUR.js";
5
5
  class StreamingAudioPlayer {
6
6
  // Mark if AudioContext is being resumed, avoid concurrent resume requests
7
7
  constructor(options) {
@@ -1,5 +1,5 @@
1
1
  import { Avatar } from './Avatar';
2
- import { ConnectionState, AvatarError, DrivingServiceMode, ConversationState, AnimationType, PostProcessingConfig } from '../types';
2
+ import { ConnectionState, AvatarError, DrivingServiceMode, FrameStarvationMode, ConversationState, AnimationType } from '../types';
3
3
  import { FrameRateInfo } from '../performance/FrameRateMonitor';
4
4
  export declare class AvatarController {
5
5
  private networkLayer?;
@@ -12,6 +12,21 @@ export declare class AvatarController {
12
12
  onError: ((error: AvatarError) => void) | null;
13
13
  /** Callback for animation type changes (e.g., idle → mono in fallback mode). Aligned with iOS/Android AvatarController.onAnimationState. */
14
14
  onAnimationState: ((type: AnimationType) => void) | null;
15
+ /**
16
+ * Strategy for handling animation-frame starvation. Default is
17
+ * {@link FrameStarvationMode.audioIndependent} (audio keeps playing, starvation only
18
+ * reported as telemetry — historical behavior). Set to {@link FrameStarvationMode.strictSync}
19
+ * to pause audio when frames run out and resume on new frames, notified via {@link onPlaybackStall}.
20
+ * Aligned with iOS/Android AvatarController.frameStarvationMode.
21
+ */
22
+ frameStarvationMode: FrameStarvationMode;
23
+ /**
24
+ * Fires when audio is paused/resumed due to frame starvation. Only invoked in
25
+ * {@link FrameStarvationMode.strictSync}. `stalled=true` — frames ran out, audio paused;
26
+ * `stalled=false` — new frames arrived, audio resumed (or conversation ended / fell back).
27
+ * Aligned with iOS/Android AvatarController.onPlaybackStall.
28
+ */
29
+ onPlaybackStall: ((stalled: boolean) => void) | null;
15
30
  private eventListeners;
16
31
  private readonly frameRateMonitor;
17
32
  /** Frame rate monitoring callback. Fires with aggregated metrics from a 2-second sliding window. */
@@ -22,7 +37,6 @@ export declare class AvatarController {
22
37
  set frameRateMonitorEnabled(value: boolean);
23
38
  private renderCallback?;
24
39
  private characterHandle;
25
- private postProcessingConfig;
26
40
  private lastRenderedFrameIndex;
27
41
  private keyframesOffset;
28
42
  private readonly MAX_KEYFRAMES;
@@ -32,6 +46,18 @@ export declare class AvatarController {
32
46
  private isFallbackMode;
33
47
  private frameStarvationEvents;
34
48
  private isFrameStarved;
49
+ /**
50
+ * Whether this round's final animation batch (ServerResponseAnimation.end) has arrived.
51
+ * Frame starvation is only possible BEFORE this — once all frames are in, any remaining
52
+ * audio tail is normal end-of-round, not starvation, so audio must keep playing to idle
53
+ * and must never be paused (matters most in strictSync). Reset per conversation.
54
+ */
55
+ private animationEnded;
56
+ /**
57
+ * Whether audio is currently paused because of frame starvation (strictSync only).
58
+ * Orthogonal to user pause — user pause/resume does not change it; only frame arrival does.
59
+ */
60
+ private isAudioStalledForStarvation;
35
61
  private playbackStuckCheckState;
36
62
  private readonly MAX_AUDIO_TIME_ZERO_COUNT;
37
63
  private readonly MAX_AUDIO_TIME_STUCK_COUNT;
@@ -113,12 +139,6 @@ export declare class AvatarController {
113
139
  * The point count of current avatar, or null if avatar is not loaded.
114
140
  */
115
141
  get pointCount(): number | null;
116
- /**
117
- * Set post-processing configuration
118
- * These parameters will be applied in real-time to animation parameters returned by the server
119
- * @param config Post-processing configuration, or null to clear
120
- */
121
- setPostProcessingConfig(config: PostProcessingConfig | null): void;
122
142
  /**
123
143
  * Set audio playback volume
124
144
  * Note: This only controls the avatar audio player volume, not the system volume
@@ -1257,6 +1257,7 @@ function messageTypeToJSON(object) {
1257
1257
  }
1258
1258
  var AudioFormat = /* @__PURE__ */ ((AudioFormat2) => {
1259
1259
  AudioFormat2[AudioFormat2["AUDIO_FORMAT_PCM_S16LE"] = 0] = "AUDIO_FORMAT_PCM_S16LE";
1260
+ AudioFormat2[AudioFormat2["AUDIO_FORMAT_OGG_OPUS"] = 1] = "AUDIO_FORMAT_OGG_OPUS";
1260
1261
  AudioFormat2[AudioFormat2["UNRECOGNIZED"] = -1] = "UNRECOGNIZED";
1261
1262
  return AudioFormat2;
1262
1263
  })(AudioFormat || {});
@@ -1265,6 +1266,9 @@ function audioFormatFromJSON(object) {
1265
1266
  case 0:
1266
1267
  case "AUDIO_FORMAT_PCM_S16LE":
1267
1268
  return 0;
1269
+ case 1:
1270
+ case "AUDIO_FORMAT_OGG_OPUS":
1271
+ return 1;
1268
1272
  case -1:
1269
1273
  case "UNRECOGNIZED":
1270
1274
  default:
@@ -1275,6 +1279,8 @@ function audioFormatToJSON(object) {
1275
1279
  switch (object) {
1276
1280
  case 0:
1277
1281
  return "AUDIO_FORMAT_PCM_S16LE";
1282
+ case 1:
1283
+ return "AUDIO_FORMAT_OGG_OPUS";
1278
1284
  case -1:
1279
1285
  default:
1280
1286
  return "UNRECOGNIZED";
@@ -1343,7 +1349,16 @@ function egressTypeToJSON(object) {
1343
1349
  }
1344
1350
  }
1345
1351
  function createBaseLiveKitEgressConfig() {
1346
- return { url: "", apiKey: "", apiSecret: "", roomName: "", publisherId: "" };
1352
+ return {
1353
+ url: "",
1354
+ apiKey: "",
1355
+ apiSecret: "",
1356
+ roomName: "",
1357
+ publisherId: "",
1358
+ extraAttributes: {},
1359
+ idleTimeout: 0,
1360
+ apiToken: ""
1361
+ };
1347
1362
  }
1348
1363
  const LiveKitEgressConfig = {
1349
1364
  encode(message, writer = new BinaryWriter()) {
@@ -1362,6 +1377,15 @@ const LiveKitEgressConfig = {
1362
1377
  if (message.publisherId !== "") {
1363
1378
  writer.uint32(42).string(message.publisherId);
1364
1379
  }
1380
+ globalThis.Object.entries(message.extraAttributes).forEach(([key, value]) => {
1381
+ LiveKitEgressConfig_ExtraAttributesEntry.encode({ key, value }, writer.uint32(50).fork()).join();
1382
+ });
1383
+ if (message.idleTimeout !== 0) {
1384
+ writer.uint32(56).int32(message.idleTimeout);
1385
+ }
1386
+ if (message.apiToken !== "") {
1387
+ writer.uint32(66).string(message.apiToken);
1388
+ }
1365
1389
  return writer;
1366
1390
  },
1367
1391
  decode(input, length) {
@@ -1406,6 +1430,30 @@ const LiveKitEgressConfig = {
1406
1430
  message.publisherId = reader.string();
1407
1431
  continue;
1408
1432
  }
1433
+ case 6: {
1434
+ if (tag !== 50) {
1435
+ break;
1436
+ }
1437
+ const entry6 = LiveKitEgressConfig_ExtraAttributesEntry.decode(reader, reader.uint32());
1438
+ if (entry6.value !== void 0) {
1439
+ message.extraAttributes[entry6.key] = entry6.value;
1440
+ }
1441
+ continue;
1442
+ }
1443
+ case 7: {
1444
+ if (tag !== 56) {
1445
+ break;
1446
+ }
1447
+ message.idleTimeout = reader.int32();
1448
+ continue;
1449
+ }
1450
+ case 8: {
1451
+ if (tag !== 66) {
1452
+ break;
1453
+ }
1454
+ message.apiToken = reader.string();
1455
+ continue;
1456
+ }
1409
1457
  }
1410
1458
  if ((tag & 7) === 4 || tag === 0) {
1411
1459
  break;
@@ -1420,7 +1468,22 @@ const LiveKitEgressConfig = {
1420
1468
  apiKey: isSet(object.apiKey) ? globalThis.String(object.apiKey) : isSet(object.api_key) ? globalThis.String(object.api_key) : "",
1421
1469
  apiSecret: isSet(object.apiSecret) ? globalThis.String(object.apiSecret) : isSet(object.api_secret) ? globalThis.String(object.api_secret) : "",
1422
1470
  roomName: isSet(object.roomName) ? globalThis.String(object.roomName) : isSet(object.room_name) ? globalThis.String(object.room_name) : "",
1423
- publisherId: isSet(object.publisherId) ? globalThis.String(object.publisherId) : isSet(object.publisher_id) ? globalThis.String(object.publisher_id) : ""
1471
+ publisherId: isSet(object.publisherId) ? globalThis.String(object.publisherId) : isSet(object.publisher_id) ? globalThis.String(object.publisher_id) : "",
1472
+ extraAttributes: isObject(object.extraAttributes) ? globalThis.Object.entries(object.extraAttributes).reduce(
1473
+ (acc, [key, value]) => {
1474
+ acc[key] = globalThis.String(value);
1475
+ return acc;
1476
+ },
1477
+ {}
1478
+ ) : isObject(object.extra_attributes) ? globalThis.Object.entries(object.extra_attributes).reduce(
1479
+ (acc, [key, value]) => {
1480
+ acc[key] = globalThis.String(value);
1481
+ return acc;
1482
+ },
1483
+ {}
1484
+ ) : {},
1485
+ idleTimeout: isSet(object.idleTimeout) ? globalThis.Number(object.idleTimeout) : isSet(object.idle_timeout) ? globalThis.Number(object.idle_timeout) : 0,
1486
+ apiToken: isSet(object.apiToken) ? globalThis.String(object.apiToken) : isSet(object.api_token) ? globalThis.String(object.api_token) : ""
1424
1487
  };
1425
1488
  },
1426
1489
  toJSON(message) {
@@ -1440,6 +1503,21 @@ const LiveKitEgressConfig = {
1440
1503
  if (message.publisherId !== "") {
1441
1504
  obj.publisherId = message.publisherId;
1442
1505
  }
1506
+ if (message.extraAttributes) {
1507
+ const entries = globalThis.Object.entries(message.extraAttributes);
1508
+ if (entries.length > 0) {
1509
+ obj.extraAttributes = {};
1510
+ entries.forEach(([k2, v2]) => {
1511
+ obj.extraAttributes[k2] = v2;
1512
+ });
1513
+ }
1514
+ }
1515
+ if (message.idleTimeout !== 0) {
1516
+ obj.idleTimeout = Math.round(message.idleTimeout);
1517
+ }
1518
+ if (message.apiToken !== "") {
1519
+ obj.apiToken = message.apiToken;
1520
+ }
1443
1521
  return obj;
1444
1522
  },
1445
1523
  create(base) {
@@ -1452,6 +1530,85 @@ const LiveKitEgressConfig = {
1452
1530
  message.apiSecret = object.apiSecret ?? "";
1453
1531
  message.roomName = object.roomName ?? "";
1454
1532
  message.publisherId = object.publisherId ?? "";
1533
+ message.extraAttributes = globalThis.Object.entries(object.extraAttributes ?? {}).reduce(
1534
+ (acc, [key, value]) => {
1535
+ if (value !== void 0) {
1536
+ acc[key] = globalThis.String(value);
1537
+ }
1538
+ return acc;
1539
+ },
1540
+ {}
1541
+ );
1542
+ message.idleTimeout = object.idleTimeout ?? 0;
1543
+ message.apiToken = object.apiToken ?? "";
1544
+ return message;
1545
+ }
1546
+ };
1547
+ function createBaseLiveKitEgressConfig_ExtraAttributesEntry() {
1548
+ return { key: "", value: "" };
1549
+ }
1550
+ const LiveKitEgressConfig_ExtraAttributesEntry = {
1551
+ encode(message, writer = new BinaryWriter()) {
1552
+ if (message.key !== "") {
1553
+ writer.uint32(10).string(message.key);
1554
+ }
1555
+ if (message.value !== "") {
1556
+ writer.uint32(18).string(message.value);
1557
+ }
1558
+ return writer;
1559
+ },
1560
+ decode(input, length) {
1561
+ const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
1562
+ const end = length === void 0 ? reader.len : reader.pos + length;
1563
+ const message = createBaseLiveKitEgressConfig_ExtraAttributesEntry();
1564
+ while (reader.pos < end) {
1565
+ const tag = reader.uint32();
1566
+ switch (tag >>> 3) {
1567
+ case 1: {
1568
+ if (tag !== 10) {
1569
+ break;
1570
+ }
1571
+ message.key = reader.string();
1572
+ continue;
1573
+ }
1574
+ case 2: {
1575
+ if (tag !== 18) {
1576
+ break;
1577
+ }
1578
+ message.value = reader.string();
1579
+ continue;
1580
+ }
1581
+ }
1582
+ if ((tag & 7) === 4 || tag === 0) {
1583
+ break;
1584
+ }
1585
+ reader.skip(tag & 7);
1586
+ }
1587
+ return message;
1588
+ },
1589
+ fromJSON(object) {
1590
+ return {
1591
+ key: isSet(object.key) ? globalThis.String(object.key) : "",
1592
+ value: isSet(object.value) ? globalThis.String(object.value) : ""
1593
+ };
1594
+ },
1595
+ toJSON(message) {
1596
+ const obj = {};
1597
+ if (message.key !== "") {
1598
+ obj.key = message.key;
1599
+ }
1600
+ if (message.value !== "") {
1601
+ obj.value = message.value;
1602
+ }
1603
+ return obj;
1604
+ },
1605
+ create(base) {
1606
+ return LiveKitEgressConfig_ExtraAttributesEntry.fromPartial(base ?? {});
1607
+ },
1608
+ fromPartial(object) {
1609
+ const message = createBaseLiveKitEgressConfig_ExtraAttributesEntry();
1610
+ message.key = object.key ?? "";
1611
+ message.value = object.value ?? "";
1455
1612
  return message;
1456
1613
  }
1457
1614
  };
@@ -3999,6 +4156,11 @@ var DrivingServiceMode = /* @__PURE__ */ ((DrivingServiceMode2) => {
3999
4156
  DrivingServiceMode2["backend"] = "backend";
4000
4157
  return DrivingServiceMode2;
4001
4158
  })(DrivingServiceMode || {});
4159
+ var FrameStarvationMode = /* @__PURE__ */ ((FrameStarvationMode2) => {
4160
+ FrameStarvationMode2["audioIndependent"] = "audioIndependent";
4161
+ FrameStarvationMode2["strictSync"] = "strictSync";
4162
+ return FrameStarvationMode2;
4163
+ })(FrameStarvationMode || {});
4002
4164
  var LogLevel = /* @__PURE__ */ ((LogLevel2) => {
4003
4165
  LogLevel2["off"] = "off";
4004
4166
  LogLevel2["error"] = "error";
@@ -12349,6 +12511,11 @@ function convertWasmParamsToProtoFlame(wasmParams) {
12349
12511
  expression: wasmParams.expr_params || []
12350
12512
  };
12351
12513
  }
12514
+ const GLOBAL_FLAME_CDN_BASE = "https://cdn.spatialwalk.cloud/public";
12515
+ const CN_FLAME_CDN_BASE = "https://cdn.spatialwalk.top/public";
12516
+ function getFlameCdnBase(region) {
12517
+ return region.startsWith("cn-") ? CN_FLAME_CDN_BASE : GLOBAL_FLAME_CDN_BASE;
12518
+ }
12352
12519
  const APP_CONFIG = {
12353
12520
  // Dynamic debug mode check (includes URL parameter)
12354
12521
  get debug() {
@@ -12371,7 +12538,6 @@ const APP_CONFIG = {
12371
12538
  },
12372
12539
  // Unified template model CDN (single compressed model shared by all characters)
12373
12540
  flame: {
12374
- cdnBase: "https://cdn.spatialwalk.cloud/public",
12375
12541
  unifiedModelPath: "base_model.pb.gz"
12376
12542
  }
12377
12543
  };
@@ -12465,7 +12631,7 @@ const _AnimationPlayer = class _AnimationPlayer {
12465
12631
  if (this.streamingPlayer) {
12466
12632
  return;
12467
12633
  }
12468
- const { StreamingAudioPlayer } = await import("./StreamingAudioPlayer-DrHXn-YA.js");
12634
+ const { StreamingAudioPlayer } = await import("./StreamingAudioPlayer-CqzPGdy5.js");
12469
12635
  const { AvatarSDK: AvatarSDK2 } = await Promise.resolve().then(() => AvatarSDK$1);
12470
12636
  const audioFormat = AvatarSDK2.getAudioFormat();
12471
12637
  this.streamingPlayer = new StreamingAudioPlayer({
@@ -14525,7 +14691,7 @@ class AvatarSDK {
14525
14691
  __publicField(AvatarSDK, "_initializationState", "uninitialized");
14526
14692
  __publicField(AvatarSDK, "_initializingPromise", null);
14527
14693
  __publicField(AvatarSDK, "_configuration", null);
14528
- __publicField(AvatarSDK, "_version", "1.1.0-beta.1");
14694
+ __publicField(AvatarSDK, "_version", "1.2.0-beta.2");
14529
14695
  __publicField(AvatarSDK, "_avatarCore", null);
14530
14696
  __publicField(AvatarSDK, "_cachedDeviceScore", null);
14531
14697
  __publicField(AvatarSDK, "_rendererBackend", null);
@@ -14576,6 +14742,40 @@ class EventEmitter {
14576
14742
  }
14577
14743
  }
14578
14744
  }
14745
+ class PendingAudioBuffer {
14746
+ constructor() {
14747
+ __publicField(this, "items", []);
14748
+ }
14749
+ get isEmpty() {
14750
+ return this.items.length === 0;
14751
+ }
14752
+ get count() {
14753
+ return this.items.length;
14754
+ }
14755
+ /** Buffer a chunk sent while not yet able to reach the server. */
14756
+ enqueue(item) {
14757
+ this.items.push(item);
14758
+ }
14759
+ /**
14760
+ * Drain the buffer, returning only the latest conversation's chunks (in
14761
+ * arrival order) for replay and dropping anything from earlier conversations.
14762
+ * The buffer is empty afterwards.
14763
+ */
14764
+ flush() {
14765
+ const buffered = this.items;
14766
+ this.items = [];
14767
+ if (buffered.length === 0) {
14768
+ return { toReplay: [], dropped: 0 };
14769
+ }
14770
+ const currentId = buffered[buffered.length - 1].conversationId;
14771
+ const toReplay = buffered.filter((it2) => it2.conversationId === currentId);
14772
+ return { toReplay, dropped: buffered.length - toReplay.length };
14773
+ }
14774
+ /** Drop everything (conversation ended / explicit disconnect). */
14775
+ clear() {
14776
+ this.items = [];
14777
+ }
14778
+ }
14579
14779
  class AnimationWebSocketClient extends EventEmitter {
14580
14780
  constructor(options) {
14581
14781
  super();
@@ -14593,6 +14793,13 @@ class AnimationWebSocketClient extends EventEmitter {
14593
14793
  __publicField(this, "sessionConfigured", false);
14594
14794
  // v2 protocol: mark if session is configured
14595
14795
  __publicField(this, "connectionStartTime", 0);
14796
+ /**
14797
+ * Direct mode: audio sent before the session is confirmed is buffered here and
14798
+ * replayed (in order) the moment the session is confirmed, so the server's
14799
+ * audio stream stays identical to what played locally. See PendingAudioBuffer
14800
+ * for the rationale and conversation-boundary handling.
14801
+ */
14802
+ __publicField(this, "pendingAudioBuffer", new PendingAudioBuffer());
14596
14803
  this.wsUrl = options.wsUrl;
14597
14804
  this.reconnectAttempts = options.reconnectAttempts ?? 5;
14598
14805
  this.jwtToken = options.jwtToken;
@@ -14637,6 +14844,7 @@ class AnimationWebSocketClient extends EventEmitter {
14637
14844
  this.ws.close(1e3, "Normal closure");
14638
14845
  this.ws = null;
14639
14846
  }
14847
+ this.pendingAudioBuffer.clear();
14640
14848
  idManager.clearConnectionId();
14641
14849
  this.removeAllListeners();
14642
14850
  this.currentRetryCount = 0;
@@ -14655,14 +14863,18 @@ class AnimationWebSocketClient extends EventEmitter {
14655
14863
  * @internal
14656
14864
  */
14657
14865
  sendAudioData(conversationId, audioData, end) {
14658
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
14659
- logger.warn("[AnimationWebSocketClient] WebSocket not connected, skipping audio send");
14660
- return false;
14661
- }
14662
- if (!this.sessionConfigured) {
14663
- logger.warn("[AnimationWebSocketClient] Session not configured yet, skipping audio send");
14866
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.sessionConfigured) {
14867
+ logger.warn("[AnimationWebSocketClient] Not ready (socket/session), buffering audio for replay on connect");
14868
+ this.pendingAudioBuffer.enqueue({ conversationId, audioData, end });
14664
14869
  return false;
14665
14870
  }
14871
+ return this.encodeAndSendAudio(conversationId, audioData, end);
14872
+ }
14873
+ /**
14874
+ * Encode and write one audio chunk to the open socket. Caller must ensure the
14875
+ * socket is open and the session is configured.
14876
+ */
14877
+ encodeAndSendAudio(conversationId, audioData, end) {
14666
14878
  try {
14667
14879
  const message = {
14668
14880
  type: MessageType.MESSAGE_CLIENT_AUDIO_INPUT,
@@ -14689,6 +14901,23 @@ class AnimationWebSocketClient extends EventEmitter {
14689
14901
  return false;
14690
14902
  }
14691
14903
  }
14904
+ /**
14905
+ * Replay audio buffered before the session was confirmed, in arrival order,
14906
+ * now that it's confirmed. Only the latest conversation's audio is replayed so
14907
+ * the server's audio stream matches what played locally; earlier conversations
14908
+ * are dropped. Aligned with iOS / Android flushPendingAudioMessages.
14909
+ */
14910
+ flushPendingAudio() {
14911
+ if (this.pendingAudioBuffer.isEmpty) return;
14912
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.sessionConfigured) return;
14913
+ const { toReplay, dropped } = this.pendingAudioBuffer.flush();
14914
+ logger.log(
14915
+ `[AnimationWebSocketClient] Flushing ${toReplay.length} buffered audio chunk(s) to driving service.` + (dropped > 0 ? ` Dropped ${dropped} from earlier conversation(s).` : "")
14916
+ );
14917
+ for (const item of toReplay) {
14918
+ this.encodeAndSendAudio(item.conversationId, item.audioData, item.end);
14919
+ }
14920
+ }
14692
14921
  /**
14693
14922
  * Generate conversation ID
14694
14923
  * Uses unified conversation ID generation rule: YYYYMMDDHHmmss_nanoid
@@ -14937,6 +15166,7 @@ class AnimationWebSocketClient extends EventEmitter {
14937
15166
  } else {
14938
15167
  logger.log("[AnimationWebSocketClient] Session confirmed by server");
14939
15168
  }
15169
+ this.flushPendingAudio();
14940
15170
  this.emit("sessionConfirmed", connectionId);
14941
15171
  return;
14942
15172
  }
@@ -15212,7 +15442,7 @@ class NetworkLayer {
15212
15442
  logger.error("[NetworkLayer] Invalid animation message");
15213
15443
  return;
15214
15444
  }
15215
- const { reqId, animation, avatarId } = message.serverResponseAnimation;
15445
+ const { reqId, animation, avatarId, end } = message.serverResponseAnimation;
15216
15446
  const conversationId = reqId;
15217
15447
  if (avatarId && avatarId !== this.dataController.getAvatarId()) {
15218
15448
  logger.error(`[NetworkLayer] Ignoring animation data for mismatched avatar - expected: ${this.dataController.getAvatarId()}, received: ${avatarId}`);
@@ -15239,7 +15469,7 @@ class NetworkLayer {
15239
15469
  if ((animation == null ? void 0 : animation.keyframes) && animation.keyframes.length > 0) {
15240
15470
  const keyframes = animation.keyframes;
15241
15471
  try {
15242
- this.dataController.yieldKeyframes(keyframes, conversationId);
15472
+ this.dataController.yieldKeyframes(keyframes, conversationId, end);
15243
15473
  } catch (error) {
15244
15474
  const errorMessage = error instanceof Error ? error.message : String(error);
15245
15475
  logger.error(`[NetworkLayer] Failed to yield animation data: ${errorMessage}`);
@@ -15606,6 +15836,21 @@ class AvatarController {
15606
15836
  __publicField(this, "onError", null);
15607
15837
  /** Callback for animation type changes (e.g., idle → mono in fallback mode). Aligned with iOS/Android AvatarController.onAnimationState. */
15608
15838
  __publicField(this, "onAnimationState", null);
15839
+ /**
15840
+ * Strategy for handling animation-frame starvation. Default is
15841
+ * {@link FrameStarvationMode.audioIndependent} (audio keeps playing, starvation only
15842
+ * reported as telemetry — historical behavior). Set to {@link FrameStarvationMode.strictSync}
15843
+ * to pause audio when frames run out and resume on new frames, notified via {@link onPlaybackStall}.
15844
+ * Aligned with iOS/Android AvatarController.frameStarvationMode.
15845
+ */
15846
+ __publicField(this, "frameStarvationMode", FrameStarvationMode.audioIndependent);
15847
+ /**
15848
+ * Fires when audio is paused/resumed due to frame starvation. Only invoked in
15849
+ * {@link FrameStarvationMode.strictSync}. `stalled=true` — frames ran out, audio paused;
15850
+ * `stalled=false` — new frames arrived, audio resumed (or conversation ended / fell back).
15851
+ * Aligned with iOS/Android AvatarController.onPlaybackStall.
15852
+ */
15853
+ __publicField(this, "onPlaybackStall", null);
15609
15854
  __publicField(this, "eventListeners", /* @__PURE__ */ new Map());
15610
15855
  // ========== Performance Monitoring ==========
15611
15856
  __publicField(this, "frameRateMonitor", new FrameRateMonitor());
@@ -15614,7 +15859,6 @@ class AvatarController {
15614
15859
  __publicField(this, "characterHandle", null);
15615
15860
  // Character handle for multi-character support
15616
15861
  // ========== Post-processing Configuration ==========
15617
- __publicField(this, "postProcessingConfig", null);
15618
15862
  // ========== Playback Loop ==========
15619
15863
  __publicField(this, "lastRenderedFrameIndex", -1);
15620
15864
  __publicField(this, "keyframesOffset", 0);
@@ -15634,6 +15878,18 @@ class AvatarController {
15634
15878
  // ========== Frame Starvation Tracking ==========
15635
15879
  __publicField(this, "frameStarvationEvents", []);
15636
15880
  __publicField(this, "isFrameStarved", false);
15881
+ /**
15882
+ * Whether this round's final animation batch (ServerResponseAnimation.end) has arrived.
15883
+ * Frame starvation is only possible BEFORE this — once all frames are in, any remaining
15884
+ * audio tail is normal end-of-round, not starvation, so audio must keep playing to idle
15885
+ * and must never be paused (matters most in strictSync). Reset per conversation.
15886
+ */
15887
+ __publicField(this, "animationEnded", false);
15888
+ /**
15889
+ * Whether audio is currently paused because of frame starvation (strictSync only).
15890
+ * Orthogonal to user pause — user pause/resume does not change it; only frame arrival does.
15891
+ */
15892
+ __publicField(this, "isAudioStalledForStarvation", false);
15637
15893
  // ========== Playback Stuck Detection ==========
15638
15894
  __publicField(this, "playbackStuckCheckState", {
15639
15895
  audioTimeZeroCount: 0,
@@ -16151,7 +16407,7 @@ class AvatarController {
16151
16407
  if (allKeyframes.length === 0) {
16152
16408
  logger.warn(`[AvatarController] No keyframes decoded from ${keyframesDataArray.length} message chunks`);
16153
16409
  }
16154
- this.yieldKeyframes(allKeyframes, conversationId);
16410
+ this.yieldKeyframes(allKeyframes, conversationId, isEnd);
16155
16411
  return isEnd;
16156
16412
  }
16157
16413
  /**
@@ -16159,12 +16415,15 @@ class AvatarController {
16159
16415
  * External consumers should use `yieldFramesData()` instead.
16160
16416
  * @internal
16161
16417
  */
16162
- yieldKeyframes(keyframes, conversationId) {
16163
- var _a;
16418
+ yieldKeyframes(keyframes, conversationId, isEnd = false) {
16419
+ var _a, _b;
16164
16420
  if (!conversationId || typeof conversationId !== "string") {
16165
16421
  logger.error(`[AvatarController] yieldKeyframes requires a valid conversationId. The conversationId is returned by yieldAudioData().`);
16166
16422
  return;
16167
16423
  }
16424
+ if (isEnd) {
16425
+ this.animationEnded = true;
16426
+ }
16168
16427
  const expectedConversationId = this.getEffectiveConversationId();
16169
16428
  if (!expectedConversationId || conversationId !== expectedConversationId) {
16170
16429
  logger.warn(`[AvatarController] Ignoring mismatched animation data - expected conversationId: ${expectedConversationId}, received conversationId: ${conversationId}`);
@@ -16194,6 +16453,14 @@ class AvatarController {
16194
16453
  this.currentKeyframes.push(...flameKeyframes);
16195
16454
  }
16196
16455
  this.emit("keyframesUpdate", this.currentKeyframes);
16456
+ if (this.isAudioStalledForStarvation) {
16457
+ const audioTime = ((_b = this.animationPlayer) == null ? void 0 : _b.getCurrentTime()) ?? 0;
16458
+ const frameIndex = Math.round(audioTime * FLAME_FRAME_RATE);
16459
+ const arrayIndex = frameIndex - this.keyframesOffset;
16460
+ if (arrayIndex >= 0 && arrayIndex < this.currentKeyframes.length) {
16461
+ this.resumeAudioFromStarvation();
16462
+ }
16463
+ }
16197
16464
  if (!this.isPlaying && !this.isStartingPlayback && this.pendingAudioChunks.length > 0 && this.currentKeyframes.length > 0) {
16198
16465
  this.startStreamingPlayback().catch((error) => {
16199
16466
  var _a2;
@@ -16230,7 +16497,9 @@ class AvatarController {
16230
16497
  return;
16231
16498
  }
16232
16499
  logger.log("[AvatarController] Resuming playback");
16233
- await ((_a = this.animationPlayer) == null ? void 0 : _a.resume());
16500
+ if (!this.isAudioStalledForStarvation) {
16501
+ await ((_a = this.animationPlayer) == null ? void 0 : _a.resume());
16502
+ }
16234
16503
  this.currentState = AvatarState.playing;
16235
16504
  this.notifyConversationState(AvatarState.playing);
16236
16505
  logger.log("[AvatarController] Playback resumed");
@@ -16305,6 +16574,7 @@ class AvatarController {
16305
16574
  * @internal
16306
16575
  */
16307
16576
  clearPlaybackData() {
16577
+ var _a;
16308
16578
  this.currentKeyframes = [];
16309
16579
  this.pendingAudioChunks = [];
16310
16580
  this.lastRenderedFrameIndex = -1;
@@ -16312,6 +16582,11 @@ class AvatarController {
16312
16582
  this.isFallbackMode = false;
16313
16583
  this.lastSyncLogTime = 0;
16314
16584
  this.lastOutOfBoundsState = false;
16585
+ this.animationEnded = false;
16586
+ if (this.isAudioStalledForStarvation) {
16587
+ this.isAudioStalledForStarvation = false;
16588
+ (_a = this.onPlaybackStall) == null ? void 0 : _a.call(this, false);
16589
+ }
16315
16590
  if (this.playbackMode === DrivingServiceMode.backend) {
16316
16591
  this.hostModeMetrics = {
16317
16592
  accumulatedBytes: 0,
@@ -16387,18 +16662,7 @@ class AvatarController {
16387
16662
  return avatarCore.getPointCount(this.characterHandle);
16388
16663
  }
16389
16664
  /**
16390
- * Set post-processing configuration
16391
- * These parameters will be applied in real-time to animation parameters returned by the server
16392
- * @param config Post-processing configuration, or null to clear
16393
- */
16394
- setPostProcessingConfig(config) {
16395
- this.postProcessingConfig = config;
16396
- if (this.currentState === AvatarState.paused && this.isPlaying) {
16397
- this.rerenderCurrentFrame();
16398
- }
16399
- }
16400
- /**
16401
- * 重新渲染当前帧(用于暂停状态下更新后处理参数或相机配置)
16665
+ * 重新渲染当前帧(用于暂停状态下更新相机配置)
16402
16666
  * @internal
16403
16667
  */
16404
16668
  async rerenderCurrentFrameIfPaused() {
@@ -16418,10 +16682,7 @@ class AvatarController {
16418
16682
  }
16419
16683
  try {
16420
16684
  const currentFrame = this.currentKeyframes[arrayIndex];
16421
- let wasmParams = convertProtoFlameToWasmParams(currentFrame);
16422
- if (this.postProcessingConfig) {
16423
- wasmParams = this.applyPostProcessingToParams(wasmParams);
16424
- }
16685
+ const wasmParams = convertProtoFlameToWasmParams(currentFrame);
16425
16686
  const avatarCore = AvatarSDK.getAvatarCore();
16426
16687
  if (avatarCore) {
16427
16688
  const splatData = await avatarCore.computeFrameFlatFromParams(wasmParams, this.characterHandle ?? void 0);
@@ -16574,6 +16835,13 @@ class AvatarController {
16574
16835
  const hasAnimationData = this.currentKeyframes.length > 0;
16575
16836
  const hasAudioData = ((_b = streamingPlayer.audioChunks) == null ? void 0 : _b.length) > 0;
16576
16837
  const isNotPaused = this.currentState !== AvatarState.paused;
16838
+ if (this.isAudioStalledForStarvation) {
16839
+ state.audioTimeZeroCount = 0;
16840
+ state.audioTimeStuckCount = 0;
16841
+ state.lastAudioTime = 0;
16842
+ state.reported = false;
16843
+ return false;
16844
+ }
16577
16845
  if (!hasAnimationData || !hasAudioData || this.currentState === AvatarState.paused) {
16578
16846
  state.audioTimeZeroCount = 0;
16579
16847
  state.audioTimeStuckCount = 0;
@@ -16677,13 +16945,49 @@ class AvatarController {
16677
16945
  this.frameStarvationEvents.push({ audioTime, reqEnd: this.reqEnd });
16678
16946
  }
16679
16947
  }
16948
+ if (!this.animationEnded) {
16949
+ this.pauseAudioForStarvation();
16950
+ }
16680
16951
  } else {
16681
16952
  this.isFrameStarved = false;
16682
16953
  if (isOutOfBounds !== this.lastOutOfBoundsState) {
16683
16954
  this.lastOutOfBoundsState = isOutOfBounds;
16684
16955
  }
16956
+ this.resumeAudioFromStarvation();
16685
16957
  }
16686
16958
  }
16959
+ /**
16960
+ * strictSync: pause audio on frame starvation, waiting for new frames.
16961
+ *
16962
+ * The default (audioIndependent) keeps audio running and lets animation catch up;
16963
+ * strictSync instead pauses audio so audio/video stay strictly in sync. Idempotent;
16964
+ * only fires onPlaybackStall once. No-op unless mode is strictSync.
16965
+ * @internal
16966
+ */
16967
+ pauseAudioForStarvation() {
16968
+ var _a, _b;
16969
+ if (this.frameStarvationMode !== FrameStarvationMode.strictSync) return;
16970
+ if (this.isAudioStalledForStarvation) return;
16971
+ if (!this.isPlaying) return;
16972
+ (_a = this.animationPlayer) == null ? void 0 : _a.pause();
16973
+ this.isAudioStalledForStarvation = true;
16974
+ logger.log("[AvatarController] Frame starvation: paused audio, waiting for frames.");
16975
+ (_b = this.onPlaybackStall) == null ? void 0 : _b.call(this, true);
16976
+ }
16977
+ /**
16978
+ * strictSync: frame starvation resolved (new frames arrived). Resumes audio and fires
16979
+ * onPlaybackStall(false). Idempotent — returns immediately if not stalled (so it is a
16980
+ * no-op in audioIndependent, where isAudioStalledForStarvation stays false).
16981
+ * @internal
16982
+ */
16983
+ resumeAudioFromStarvation() {
16984
+ var _a, _b;
16985
+ if (!this.isAudioStalledForStarvation) return;
16986
+ this.isAudioStalledForStarvation = false;
16987
+ void ((_a = this.animationPlayer) == null ? void 0 : _a.resume());
16988
+ logger.log("[AvatarController] Frame starvation recovered: resumed audio.");
16989
+ (_b = this.onPlaybackStall) == null ? void 0 : _b.call(this, false);
16990
+ }
16687
16991
  /**
16688
16992
  * Start playback loop
16689
16993
  * @internal
@@ -16729,6 +17033,7 @@ class AvatarController {
16729
17033
  }
16730
17034
  logger.warn("[AvatarController] Enabling fallback mode");
16731
17035
  this.isFallbackMode = true;
17036
+ this.resumeAudioFromStarvation();
16732
17037
  logEvent("fallback_mode_entered", "warning", {
16733
17038
  avatar_id: this.avatar.id,
16734
17039
  reason,
@@ -16852,97 +17157,6 @@ class AvatarController {
16852
17157
  listeners.forEach((callback) => callback(data));
16853
17158
  }
16854
17159
  }
16855
- /**
16856
- * Apply post-processing parameters to a Flame (proto format)
16857
- * Used for transition animations
16858
- * @internal
16859
- */
16860
- applyPostProcessingToFlame(flame) {
16861
- if (!this.postProcessingConfig) {
16862
- return flame;
16863
- }
16864
- let wasmParams = convertProtoFlameToWasmParams(flame);
16865
- wasmParams = this.applyPostProcessingToParams(wasmParams);
16866
- return convertWasmParamsToProtoFlame(wasmParams);
16867
- }
16868
- /**
16869
- * Apply post-processing parameters to animation parameters
16870
- * @internal
16871
- */
16872
- /** @internal */
16873
- applyPostProcessingToParams(baseParams) {
16874
- if (!this.postProcessingConfig) {
16875
- return baseParams;
16876
- }
16877
- const result2 = {
16878
- shape_params: baseParams.shape_params || Array.from({ length: 300 }, () => 0),
16879
- expr_params: baseParams.expr_params ? [...baseParams.expr_params] : Array.from({ length: 100 }, () => 0),
16880
- rotation: baseParams.rotation ? [...baseParams.rotation] : [0, 0, 0],
16881
- translation: baseParams.translation ? [...baseParams.translation] : [0, 0, 0],
16882
- neck_pose: baseParams.neck_pose ? [...baseParams.neck_pose] : [0, 0, 0],
16883
- jaw_pose: baseParams.jaw_pose ? [...baseParams.jaw_pose] : [0, 0, 0],
16884
- eyes_pose: baseParams.eyes_pose ? [...baseParams.eyes_pose] : [0, 0, 0, 0, 0, 0],
16885
- eyelid: baseParams.eyelid ? [...baseParams.eyelid] : [0, 0],
16886
- has_eyelid: baseParams.has_eyelid || false
16887
- };
16888
- if (this.postProcessingConfig.rotation && result2.rotation) {
16889
- result2.rotation[0] += this.postProcessingConfig.rotation.x ?? 0;
16890
- result2.rotation[1] += this.postProcessingConfig.rotation.y ?? 0;
16891
- result2.rotation[2] += this.postProcessingConfig.rotation.z ?? 0;
16892
- }
16893
- if (this.postProcessingConfig.translation && result2.translation) {
16894
- result2.translation[0] += this.postProcessingConfig.translation.x ?? 0;
16895
- result2.translation[1] += this.postProcessingConfig.translation.y ?? 0;
16896
- result2.translation[2] += this.postProcessingConfig.translation.z ?? 0;
16897
- }
16898
- if (this.postProcessingConfig.neckPose && result2.neck_pose) {
16899
- const biasX = this.postProcessingConfig.neckPose.x ?? 0;
16900
- const biasY = this.postProcessingConfig.neckPose.y ?? 0;
16901
- const biasZ = this.postProcessingConfig.neckPose.z ?? 0;
16902
- const weightX = this.postProcessingConfig.neckPose.weightX ?? 1;
16903
- const weightY = this.postProcessingConfig.neckPose.weightY ?? 1;
16904
- const weightZ = this.postProcessingConfig.neckPose.weightZ ?? 1;
16905
- result2.neck_pose[0] = result2.neck_pose[0] * weightX + biasX;
16906
- result2.neck_pose[1] = result2.neck_pose[1] * weightY + biasY;
16907
- result2.neck_pose[2] = result2.neck_pose[2] * weightZ + biasZ;
16908
- }
16909
- if (this.postProcessingConfig.jawPose && result2.jaw_pose) {
16910
- const biasX = this.postProcessingConfig.jawPose.x ?? 0;
16911
- const biasY = this.postProcessingConfig.jawPose.y ?? 0;
16912
- const biasZ = this.postProcessingConfig.jawPose.z ?? 0;
16913
- const weight = this.postProcessingConfig.jawPose.weight ?? 1;
16914
- result2.jaw_pose[0] = result2.jaw_pose[0] * weight + biasX;
16915
- result2.jaw_pose[1] = result2.jaw_pose[1] * weight + biasY;
16916
- result2.jaw_pose[2] = result2.jaw_pose[2] * weight + biasZ;
16917
- }
16918
- if (this.postProcessingConfig.eyePose && result2.eyes_pose) {
16919
- const biasX = this.postProcessingConfig.eyePose.x ?? 0;
16920
- const biasY = this.postProcessingConfig.eyePose.y ?? 0;
16921
- const biasZ = this.postProcessingConfig.eyePose.z ?? 0;
16922
- const weightX = this.postProcessingConfig.eyePose.weightX ?? 1;
16923
- const weightY = this.postProcessingConfig.eyePose.weightY ?? 1;
16924
- const weightZ = this.postProcessingConfig.eyePose.weightZ ?? 1;
16925
- result2.eyes_pose[0] = result2.eyes_pose[0] * weightX + biasX;
16926
- result2.eyes_pose[1] = result2.eyes_pose[1] * weightY + biasY;
16927
- result2.eyes_pose[2] = result2.eyes_pose[2] * weightZ + biasZ;
16928
- result2.eyes_pose[3] = result2.eyes_pose[3] * weightX + biasX;
16929
- result2.eyes_pose[4] = result2.eyes_pose[4] * weightY + biasY;
16930
- result2.eyes_pose[5] = result2.eyes_pose[5] * weightZ + biasZ;
16931
- }
16932
- if (this.postProcessingConfig.eyeBlink !== void 0 && result2.eyelid) {
16933
- const blinkValue = this.postProcessingConfig.eyeBlink;
16934
- result2.eyelid[0] = blinkValue;
16935
- result2.eyelid[1] = blinkValue;
16936
- result2.has_eyelid = true;
16937
- }
16938
- if (this.postProcessingConfig.expressionWeight !== void 0 && result2.expr_params) {
16939
- const weight = this.postProcessingConfig.expressionWeight;
16940
- for (let i2 = 0; i2 < result2.expr_params.length; i2++) {
16941
- result2.expr_params[i2] *= weight;
16942
- }
16943
- }
16944
- return result2;
16945
- }
16946
17160
  }
16947
17161
  function errorToMessage(err) {
16948
17162
  if (err instanceof Error) {
@@ -17253,9 +17467,12 @@ class AvatarDownloader {
17253
17467
  * @internal
17254
17468
  */
17255
17469
  async loadUnifiedTemplate() {
17470
+ var _a;
17256
17471
  await PwaCacheManager.checkTemplateCacheVersion();
17257
17472
  const startTime = Date.now();
17258
- const { cdnBase, unifiedModelPath } = APP_CONFIG.flame;
17473
+ const region = ((_a = AvatarSDK.configuration) == null ? void 0 : _a.region) || DEFAULT_REGION;
17474
+ const cdnBase = getFlameCdnBase(region);
17475
+ const { unifiedModelPath } = APP_CONFIG.flame;
17259
17476
  const url = `${cdnBase}/${unifiedModelPath}`;
17260
17477
  logger.log(`📥 Loading unified template from: ${url}`);
17261
17478
  const cached = await PwaCacheManager.getTemplateResource(url);
@@ -17526,7 +17743,7 @@ class AvatarDownloader {
17526
17743
  }
17527
17744
  let error;
17528
17745
  if (response.status === 404) {
17529
- const urlMatch = url.match(/\/v2\/character\/([^/?]+)/);
17746
+ const urlMatch = url.match(/\/v2\/(?:character|avatar)\/([^/?]+)/);
17530
17747
  const extractedCharacterId = urlMatch ? urlMatch[1] : "unknown";
17531
17748
  const callerHeaders = options.headers || {};
17532
17749
  const callerTraceId = callerHeaders["x-sp-trace-id"];
@@ -17562,10 +17779,50 @@ class AvatarDownloader {
17562
17779
  };
17563
17780
  }
17564
17781
  /**
17565
- * Get single character by ID from AvatarKit SDK API (v2, iOS compatible)
17782
+ * Map the new `/v2/avatar/{id}` `AvatarAsset` payload onto the internal
17783
+ * `CharacterMeta` shape used by the existing download / render pipeline.
17784
+ *
17785
+ * The backend (grpc-gateway) serialises proto fields as camelCase JSON, so the
17786
+ * runtime object is loosely shaped like the generated `AvatarAsset`. We:
17787
+ * - lift `models.gs` into `models.gsStandard` (downloader/asset-count read gsStandard)
17788
+ * - rename `animations.frameFallback` → `animations.frameMono`
17789
+ * - fold the inline `camera` / `transform` into `characterSettings` so the
17790
+ * renderer's `resolveCameraConfig` reads structured values and no camera
17791
+ * resource is downloaded (top-level `camera` is intentionally left unset)
17792
+ * @internal
17793
+ */
17794
+ mapAvatarAssetToCharacterMeta(asset, avatarId) {
17795
+ var _a, _b, _c, _d, _e2;
17796
+ const characterSettings = {
17797
+ ...asset.camera ? { camera: { ...asset.camera } } : {},
17798
+ ...asset.transform ? { transform: { ...asset.transform } } : {}
17799
+ };
17800
+ return {
17801
+ characterId: avatarId,
17802
+ version: asset.version ?? "",
17803
+ updatedAt: asset.updatedAt,
17804
+ models: {
17805
+ shape: (_a = asset.models) == null ? void 0 : _a.shape,
17806
+ gsStandard: (_b = asset.models) == null ? void 0 : _b.gs
17807
+ },
17808
+ animations: {
17809
+ frameIdle: (_c = asset.animations) == null ? void 0 : _c.frameIdle,
17810
+ frameMono: (_d = asset.animations) == null ? void 0 : _d.frameFallback
17811
+ },
17812
+ customAnimations: ((_e2 = asset.animations) == null ? void 0 : _e2.customAnimations) ?? [],
17813
+ characterSettings
17814
+ };
17815
+ }
17816
+ /**
17817
+ * Get single avatar by ID from AvatarKit SDK API (v2 driven-ingress avatar API).
17566
17818
  * Domain: composed from region as api.${region}.spatius.ai
17567
17819
  * Auth: Public endpoint, no authentication required
17568
- * Returns CharacterMeta with nested resource structure
17820
+ * Fetches the new `AvatarAsset` payload from `/v2/avatar/{id}` and maps it onto
17821
+ * the internal `CharacterMeta` shape consumed by the download / render pipeline:
17822
+ * - `models.gs` → `models.gsStandard`
17823
+ * - `animations.frameFallback` → `animations.frameMono`
17824
+ * - inline `camera` / `transform` → `characterSettings.{camera,transform}`
17825
+ * (so the renderer reads structured values directly and no camera resource is downloaded)
17569
17826
  * @internal
17570
17827
  */
17571
17828
  async getCharacterById(characterId, options) {
@@ -17578,7 +17835,7 @@ class AvatarDownloader {
17578
17835
  throw new Error("Request cancelled");
17579
17836
  }
17580
17837
  const client = this.getSdkApiClient();
17581
- const response = await client.request(`/v2/character/${characterId}`, {
17838
+ const response = await client.request(`/v2/avatar/${characterId}`, {
17582
17839
  method: "GET",
17583
17840
  headers: { "x-sp-trace-id": traceId },
17584
17841
  signal
@@ -17597,7 +17854,7 @@ class AvatarDownloader {
17597
17854
  duration,
17598
17855
  trace_id: traceId
17599
17856
  });
17600
- return response;
17857
+ return this.mapAvatarAssetToCharacterMeta(response, characterId);
17601
17858
  } catch (error) {
17602
17859
  if (error instanceof Error && (error.name === "AbortError" || error.message === "Request cancelled")) {
17603
17860
  logEvent("fetch_avatar_metadata_cancelled", "info", {
@@ -20519,8 +20776,7 @@ class AvatarView {
20519
20776
  if (arrayIndex >= 0 && arrayIndex < keyframes.length) {
20520
20777
  const currentFrame = keyframes[arrayIndex];
20521
20778
  this.currentFrame = currentFrame;
20522
- let wasmParams = convertProtoFlameToWasmParams(currentFrame);
20523
- wasmParams = this.avatarController.applyPostProcessingToParams(wasmParams);
20779
+ const wasmParams = convertProtoFlameToWasmParams(currentFrame);
20524
20780
  computeInFlight = true;
20525
20781
  avatarCore.computeFrameFlatFromParams(wasmParams, this.characterHandle ?? void 0).then((splatData) => {
20526
20782
  if (splatData && !this.isPureRenderingMode) {
@@ -20547,7 +20803,7 @@ class AvatarView {
20547
20803
  const offset = this.avatarController.getKeyframesOffset();
20548
20804
  const targetArrayIdx = targetIdx - offset;
20549
20805
  if (targetArrayIdx >= 0 && targetArrayIdx < keyframes.length) {
20550
- const targetFrame = this.avatarController.applyPostProcessingToFlame(keyframes[targetArrayIdx]);
20806
+ const targetFrame = keyframes[targetArrayIdx];
20551
20807
  this.transitionFrames = this.generateAndAlignTransitionFrames(
20552
20808
  this.currentFrame,
20553
20809
  targetFrame,
@@ -20793,6 +21049,10 @@ class AvatarView {
20793
21049
  },
20794
21050
  onPlayFallback: () => {
20795
21051
  this.playAnimation(AnimationType.idle, true);
21052
+ if (!this.cachedIdleFirstFrame) {
21053
+ this.getCachedIdleFirstFrame().catch(() => {
21054
+ });
21055
+ }
20796
21056
  this.isConversationActive = true;
20797
21057
  this.lastRenderedFrameIndex = -1;
20798
21058
  },
@@ -20819,10 +21079,9 @@ class AvatarView {
20819
21079
  const fromFrame = this.currentFrame;
20820
21080
  if (fromFrame) {
20821
21081
  const firstSpeaking = keyframes[0];
20822
- const firstSpeakingWithPP = this.avatarController.applyPostProcessingToFlame(firstSpeaking);
20823
21082
  this.transitionFrames = this.generateAndAlignTransitionFrames(
20824
21083
  fromFrame,
20825
- firstSpeakingWithPP,
21084
+ firstSpeaking,
20826
21085
  START_TRANSITION_DURATION_MS,
20827
21086
  true
20828
21087
  );
@@ -20854,9 +21113,8 @@ class AvatarView {
20854
21113
  const fromFrame = this.currentFrame;
20855
21114
  if (fromFrame && this.cachedIdleFirstFrame) {
20856
21115
  try {
20857
- const fromFrameWithPP = this.avatarController.applyPostProcessingToFlame(fromFrame);
20858
21116
  this.transitionFrames = this.generateAndAlignTransitionFrames(
20859
- fromFrameWithPP,
21117
+ fromFrame,
20860
21118
  this.cachedIdleFirstFrame,
20861
21119
  END_TRANSITION_DURATION_MS,
20862
21120
  false
@@ -20974,8 +21232,7 @@ class AvatarView {
20974
21232
  try {
20975
21233
  const flame = keyframeData;
20976
21234
  this.currentFrame = flame;
20977
- const processedFlame = this.avatarController.applyPostProcessingToFlame(flame);
20978
- const wasmParams = convertProtoFlameToWasmParams(processedFlame);
21235
+ const wasmParams = convertProtoFlameToWasmParams(flame);
20979
21236
  const avatarCore = AvatarSDK.getAvatarCore();
20980
21237
  if (!avatarCore) {
20981
21238
  throw new Error("AvatarCore not available");
@@ -21017,7 +21274,7 @@ class AvatarView {
21017
21274
  }
21018
21275
  let toFrame;
21019
21276
  if (to2) {
21020
- toFrame = this.avatarController.applyPostProcessingToFlame(to2);
21277
+ toFrame = to2;
21021
21278
  } else {
21022
21279
  const idleParams = await avatarCore.getCurrentFrameParams(this.activeAnimationState.frameIndex, this.characterId);
21023
21280
  toFrame = convertWasmParamsToProtoFlame(idleParams);
@@ -21465,6 +21722,7 @@ export {
21465
21722
  ConnectionState as C,
21466
21723
  DrivingServiceMode as D,
21467
21724
  ErrorCode as E,
21725
+ FrameStarvationMode as F,
21468
21726
  LogLevel as L,
21469
21727
  RenderQuality as R,
21470
21728
  TransitionType as T,
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { k, b, c, o, f, d, n, g, C, m, i, D, E, j, L, h, R, p, T, q } from "./index-CVQn5uEB.js";
1
+ import { k, b, c, o, f, d, n, g, C, m, i, D, E, F, j, L, h, R, p, T, q } from "./index-BnubRaUR.js";
2
2
  export {
3
3
  k as AnimationType,
4
4
  b as Avatar,
@@ -13,6 +13,7 @@ export {
13
13
  i as DEFAULT_REGION,
14
14
  D as DrivingServiceMode,
15
15
  E as ErrorCode,
16
+ F as FrameStarvationMode,
16
17
  j as LoadProgress,
17
18
  L as LogLevel,
18
19
  h as RENDER_QUALITY_PARAMS,
@@ -7,6 +7,19 @@ export declare enum DrivingServiceMode {
7
7
  /** Driven by host application */
8
8
  backend = "backend"
9
9
  }
10
+ /**
11
+ * Strategy for handling animation-frame starvation (animation frames can't keep up
12
+ * with the audio clock).
13
+ *
14
+ * - `audioIndependent` (default): audio keeps playing, animation catches up; starvation
15
+ * is only reported as telemetry. This is the historical default behavior.
16
+ * - `strictSync`: pause audio and wait when frames run out, resume once new frames
17
+ * arrive, notifying via `AvatarController.onPlaybackStall`.
18
+ */
19
+ export declare enum FrameStarvationMode {
20
+ audioIndependent = "audioIndependent",
21
+ strictSync = "strictSync"
22
+ }
10
23
  export declare enum LogLevel {
11
24
  /** Disable all logs */
12
25
  off = "off",
@@ -134,53 +147,6 @@ export interface CameraConfig {
134
147
  up?: [number, number, number];
135
148
  aspect?: number;
136
149
  }
137
- /**
138
- * Post-processing parameter configuration
139
- * Used to overlay in real-time on animation parameters returned by the server
140
- */
141
- export interface PostProcessingConfig {
142
- /** Rotation (Euler angles, in radians) */
143
- rotation?: {
144
- x?: number;
145
- y?: number;
146
- z?: number;
147
- };
148
- /** Translation (position offset) */
149
- translation?: {
150
- x?: number;
151
- y?: number;
152
- z?: number;
153
- };
154
- /** Neck pose offset (in radians) */
155
- neckPose?: {
156
- x?: number;
157
- y?: number;
158
- z?: number;
159
- weightX?: number;
160
- weightY?: number;
161
- weightZ?: number;
162
- };
163
- /** Jaw pose offset (in radians) */
164
- jawPose?: {
165
- x?: number;
166
- y?: number;
167
- z?: number;
168
- weight?: number;
169
- };
170
- /** Eye pose offset (in radians) */
171
- eyePose?: {
172
- x?: number;
173
- y?: number;
174
- z?: number;
175
- weightX?: number;
176
- weightY?: number;
177
- weightZ?: number;
178
- };
179
- /** Eye blink value (-1.0 to 1.0, larger value means more closed) */
180
- eyeBlink?: number;
181
- /** Expression weight (0.0-2.0, 0=no expression, 1=normal, >1=enhanced) */
182
- expressionWeight?: number;
183
- }
184
150
  export interface CharacterInfo {
185
151
  pointCount: number;
186
152
  hasAnimation: boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spatius/avatarkit",
3
3
  "type": "module",
4
- "version": "1.1.0-beta.1",
4
+ "version": "1.2.0-beta.2",
5
5
  "packageManager": "pnpm@10.18.2",
6
6
  "description": "AvatarKit SDK - 3D Gaussian Splatting Avatar Rendering SDK",
7
7
  "author": "AvatarKit Team",
@@ -48,6 +48,7 @@
48
48
  "build": "SDK_BUILD=true vite build --mode library && npm run build:vite-plugin && npm run build:next-plugin",
49
49
  "build:vite-plugin": "tsc vite.ts --outDir . --module esnext --target es2020 --moduleResolution bundler --esModuleInterop --skipLibCheck --declaration --declarationMap",
50
50
  "build:next-plugin": "tsc next.ts --outDir . --module esnext --target es2020 --moduleResolution bundler --esModuleInterop --skipLibCheck --declaration --declarationMap",
51
+ "prepare": "npm run build",
51
52
  "dev": "vite build --mode library --watch",
52
53
  "demo": "vite --config demo/vite.config.mjs",
53
54
  "demo:benchmark": "vite --config benchmark-demo/vite.config.mjs",