@vizij/runtime-react 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -460,6 +460,7 @@ See:
460
460
 
461
461
  - [`apps/tutorial-fullscreen-face/tutorial.md`](../../../apps/tutorial-fullscreen-face/tutorial.md)
462
462
  - [`apps/tutorial-agent-face/tutorial.md`](../../../apps/tutorial-agent-face/tutorial.md)
463
+ - [`apps/vizij-standalone`](../../../apps/vizij-standalone/README.md) — the face page with a voice agent, on `@vizij/runtime` directly
463
464
 
464
465
  ### Runtime-truthful authoring
465
466
 
package/dist/index.cjs CHANGED
@@ -31,11 +31,14 @@ __export(index_exports, {
31
31
  buildPoseWeightRelativePath: () => buildPoseWeightRelativePath,
32
32
  buildRigInputPath: () => buildRigInputPath,
33
33
  buildSemanticPoseWeightPathMap: () => buildSemanticPoseWeightPathMap,
34
+ collectInputPathMap: () => collectInputPathMap,
35
+ composeGraphSpecs: () => composeGraphSpecs,
34
36
  filterPosesBySemanticKind: () => filterPosesBySemanticKind,
35
37
  getPoseSemanticKey: () => getPoseSemanticKey,
36
38
  mapNormalizedControlValue: () => mapNormalizedControlValue,
37
39
  mapUnitControlValue: () => mapUnitControlValue,
38
40
  normalizePoseSemanticKey: () => normalizePoseSemanticKey,
41
+ resolveAnimationBridgeOutputPaths: () => resolveAnimationBridgeOutputPaths,
39
42
  resolveFaceControls: () => resolveFaceControls,
40
43
  resolvePoseMembership: () => resolvePoseMembership,
41
44
  resolvePoseSemantics: () => resolvePoseSemantics,
@@ -1475,7 +1478,7 @@ function fieldNumber(value) {
1475
1478
  if (!value || typeof value !== "object") {
1476
1479
  return null;
1477
1480
  }
1478
- for (const key of ["u64", "u32", "f32", "f64"]) {
1481
+ for (const key of ["u64", "u32", "f32", "f64", "float"]) {
1479
1482
  if (key in value) {
1480
1483
  const parsed = Number(value[key]);
1481
1484
  return Number.isFinite(parsed) ? parsed : null;
@@ -1483,38 +1486,90 @@ function fieldNumber(value) {
1483
1486
  }
1484
1487
  return null;
1485
1488
  }
1489
+ function fieldText(value) {
1490
+ if (!value || typeof value !== "object") {
1491
+ return "";
1492
+ }
1493
+ const record = value;
1494
+ for (const key of ["str", "text"]) {
1495
+ if (typeof record[key] === "string") {
1496
+ return record[key];
1497
+ }
1498
+ }
1499
+ return "";
1500
+ }
1501
+ function playerStateFromFields(byId) {
1502
+ const player = fieldNumber(byId.get(ANIMATION_MODULE_FIELD.statePlayer));
1503
+ if (player === null) {
1504
+ return null;
1505
+ }
1506
+ return {
1507
+ player,
1508
+ state: fieldText(byId.get(ANIMATION_MODULE_FIELD.stateState)),
1509
+ time: (fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateTimeNs)) ?? 0) / 1e9,
1510
+ duration: (fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateDurationNs)) ?? 0) / 1e9,
1511
+ speed: fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateSpeed)) ?? 1
1512
+ };
1513
+ }
1514
+ function fieldsToMap(fields) {
1515
+ if (!Array.isArray(fields)) {
1516
+ return null;
1517
+ }
1518
+ const byId = /* @__PURE__ */ new Map();
1519
+ for (const entry of fields) {
1520
+ const id = entry?.id;
1521
+ const value = entry?.value;
1522
+ if (typeof id === "string" && value && typeof value === "object") {
1523
+ byId.set(id, value);
1524
+ }
1525
+ }
1526
+ return byId;
1527
+ }
1528
+ function recordToMap(element) {
1529
+ if (!element || typeof element !== "object") {
1530
+ return null;
1531
+ }
1532
+ const record = element.record;
1533
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
1534
+ return null;
1535
+ }
1536
+ const byId = /* @__PURE__ */ new Map();
1537
+ for (const [id, value] of Object.entries(record)) {
1538
+ if (value && typeof value === "object") {
1539
+ byId.set(id, value);
1540
+ }
1541
+ }
1542
+ return byId;
1543
+ }
1486
1544
  function decodePlayerStates(raw) {
1487
- if (!raw || typeof raw !== "object" || !("structs" in raw)) {
1545
+ if (!raw || typeof raw !== "object") {
1488
1546
  return [];
1489
1547
  }
1490
- const structs = raw.structs;
1491
- const elements = Array.isArray(structs?.elements) ? structs.elements : [];
1548
+ const container = raw;
1492
1549
  const states = [];
1493
- for (const element of elements) {
1494
- const fields = element?.fields;
1495
- if (!Array.isArray(fields)) {
1496
- continue;
1497
- }
1498
- const byId = /* @__PURE__ */ new Map();
1499
- for (const entry of fields) {
1500
- const id = entry?.id;
1501
- const value = entry?.value;
1502
- if (typeof id === "string" && value && typeof value === "object") {
1503
- byId.set(id, value);
1550
+ const structs = container.structs;
1551
+ if (structs && Array.isArray(structs.elements)) {
1552
+ for (const element of structs.elements) {
1553
+ const byId = fieldsToMap(element?.fields);
1554
+ const state = byId ? playerStateFromFields(byId) : null;
1555
+ if (state) {
1556
+ states.push(state);
1504
1557
  }
1505
1558
  }
1506
- const player = fieldNumber(byId.get(ANIMATION_MODULE_FIELD.statePlayer));
1507
- if (player === null) {
1508
- continue;
1559
+ return states;
1560
+ }
1561
+ const elements = Array.isArray(container.list) ? container.list : Array.isArray(container.array) ? container.array : null;
1562
+ if (!elements) {
1563
+ return states;
1564
+ }
1565
+ for (const element of elements) {
1566
+ const byId = recordToMap(element) ?? fieldsToMap(
1567
+ element?.struct?.fields ?? element?.fields
1568
+ );
1569
+ const state = byId ? playerStateFromFields(byId) : null;
1570
+ if (state) {
1571
+ states.push(state);
1509
1572
  }
1510
- const stateValue = byId.get(ANIMATION_MODULE_FIELD.stateState);
1511
- states.push({
1512
- player,
1513
- state: stateValue && "str" in stateValue ? String(stateValue.str) : "",
1514
- time: (fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateTimeNs)) ?? 0) / 1e9,
1515
- duration: (fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateDurationNs)) ?? 0) / 1e9,
1516
- speed: fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateSpeed)) ?? 1
1517
- });
1518
1573
  }
1519
1574
  return states;
1520
1575
  }
@@ -1584,6 +1639,7 @@ var AnimationModuleHost = class {
1584
1639
  speed: 1,
1585
1640
  loop: "loop",
1586
1641
  weight: 1,
1642
+ pendingSeekSeconds: null,
1587
1643
  animId: null,
1588
1644
  playerId: null,
1589
1645
  instId: null
@@ -1622,6 +1678,8 @@ var AnimationModuleHost = class {
1622
1678
  }
1623
1679
  entry.playing = false;
1624
1680
  this.dispatch(
1681
+ "pause",
1682
+ clipId,
1625
1683
  (device) => entry.playerId !== null ? device.call(pauseCall(entry.playerId)) : null
1626
1684
  );
1627
1685
  }
@@ -1637,6 +1695,8 @@ var AnimationModuleHost = class {
1637
1695
  }
1638
1696
  entry.playing = false;
1639
1697
  this.dispatch(
1698
+ "stop",
1699
+ clipId,
1640
1700
  (device) => entry.playerId !== null ? device.call(stopCall(entry.playerId)) : null
1641
1701
  );
1642
1702
  }
@@ -1646,7 +1706,14 @@ var AnimationModuleHost = class {
1646
1706
  if (!entry) {
1647
1707
  return;
1648
1708
  }
1709
+ if (entry.playerId === null) {
1710
+ entry.pendingSeekSeconds = seconds;
1711
+ return;
1712
+ }
1713
+ entry.pendingSeekSeconds = null;
1649
1714
  this.dispatch(
1715
+ "seek",
1716
+ clipId,
1650
1717
  (device) => entry.playerId !== null ? device.call(seekCall(entry.playerId, seconds * 1e9)) : null
1651
1718
  );
1652
1719
  }
@@ -1658,6 +1725,8 @@ var AnimationModuleHost = class {
1658
1725
  }
1659
1726
  entry.speed = speed;
1660
1727
  this.dispatch(
1728
+ "set speed",
1729
+ clipId,
1661
1730
  (device) => entry.playerId !== null ? device.call(setSpeedCall(entry.playerId, speed)) : null
1662
1731
  );
1663
1732
  }
@@ -1669,6 +1738,8 @@ var AnimationModuleHost = class {
1669
1738
  }
1670
1739
  entry.loop = mode;
1671
1740
  this.dispatch(
1741
+ "set loop",
1742
+ clipId,
1672
1743
  (device) => entry.playerId !== null ? device.call(setLoopCall(entry.playerId, mode)) : null
1673
1744
  );
1674
1745
  }
@@ -1680,6 +1751,8 @@ var AnimationModuleHost = class {
1680
1751
  }
1681
1752
  entry.weight = weight;
1682
1753
  this.dispatch(
1754
+ "set weight",
1755
+ clipId,
1683
1756
  (device) => entry.playerId !== null && entry.instId !== null ? device.call(setWeightCall(entry.playerId, entry.instId, weight)) : null
1684
1757
  );
1685
1758
  }
@@ -1687,6 +1760,9 @@ var AnimationModuleHost = class {
1687
1760
  * Re-issue setup and transport for every playing clip against a freshly
1688
1761
  * started device (module guest state did not survive the rebuild). Voids
1689
1762
  * stale ids first. Called from `DeviceSlot.onDeviceStarted`.
1763
+ *
1764
+ * A freshly loaded player advances without an explicit `play` — asserted by
1765
+ * the rebuild case in `animationPipeline.device.test.ts`.
1690
1766
  */
1691
1767
  replayInto(device) {
1692
1768
  for (const entry of this.clips.values()) {
@@ -1699,12 +1775,35 @@ var AnimationModuleHost = class {
1699
1775
  }
1700
1776
  }
1701
1777
  /** Fire-and-forget a transport call against the live device, if any. */
1702
- dispatch(issue) {
1778
+ /**
1779
+ * Issue a transport call, reporting rather than swallowing a rejection.
1780
+ *
1781
+ * This used to discard the error on the assumption that the only cause was
1782
+ * a rebuild racing the call, which `replayInto` would repair. When that is
1783
+ * not the cause — the module missing from the live device, say — the call
1784
+ * fails, the player keeps running, and the next frame of `player_states`
1785
+ * feedback tells the transport UI it is still playing. The button flips
1786
+ * back and the pause looks like it did nothing, with nothing anywhere
1787
+ * saying why. Whatever the cause, a dropped transport command is worth a
1788
+ * line in the console.
1789
+ */
1790
+ dispatch(what, clipId, issue) {
1703
1791
  const device = this.getDevice();
1704
1792
  if (!device) {
1705
1793
  return;
1706
1794
  }
1707
- void issue(device)?.catch(() => {
1795
+ const issued = issue(device);
1796
+ if (!issued) {
1797
+ console.warn(
1798
+ `[vizij-runtime] animation ${what} had no player to act on for "${clipId}"`
1799
+ );
1800
+ return;
1801
+ }
1802
+ void issued.catch((err) => {
1803
+ console.warn(
1804
+ `[vizij-runtime] animation ${what} was dropped for "${clipId}"`,
1805
+ err
1806
+ );
1708
1807
  });
1709
1808
  }
1710
1809
  async ensureLoaded(device, entry) {
@@ -1742,6 +1841,12 @@ var AnimationModuleHost = class {
1742
1841
  setWeightCall(entry.playerId, entry.instId, entry.weight)
1743
1842
  );
1744
1843
  }
1844
+ if (entry.pendingSeekSeconds !== null) {
1845
+ followups.push(
1846
+ seekCall(entry.playerId, entry.pendingSeekSeconds * 1e9)
1847
+ );
1848
+ entry.pendingSeekSeconds = null;
1849
+ }
1745
1850
  }
1746
1851
  for (const call of followups) {
1747
1852
  await device.call(call);
@@ -2322,6 +2427,23 @@ function convertBundlePrograms(entries) {
2322
2427
  };
2323
2428
  }).filter(Boolean);
2324
2429
  }
2430
+ function convertBundleStarred(section) {
2431
+ const items = section?.items;
2432
+ if (!Array.isArray(items) || items.length === 0) {
2433
+ return void 0;
2434
+ }
2435
+ const resolved = [];
2436
+ for (const item of items) {
2437
+ if (!item || typeof item !== "object") {
2438
+ continue;
2439
+ }
2440
+ const { kind, id } = item;
2441
+ if ((kind === "driver" || kind === "pose") && typeof id === "string" && id) {
2442
+ resolved.push({ kind, id });
2443
+ }
2444
+ }
2445
+ return resolved.length > 0 ? resolved : void 0;
2446
+ }
2325
2447
  function resolveConstraintDefault(path, namespace, inputConstraints) {
2326
2448
  const trimmed = path.trim();
2327
2449
  if (!trimmed) {
@@ -2479,6 +2601,12 @@ function mergeAssetBundle(base, extracted, extractedAnimations) {
2479
2601
  hasBaseProgramsOverride,
2480
2602
  convertBundlePrograms(resolvedBundle?.graphs)
2481
2603
  );
2604
+ const hasBaseStarredOverride = Object.prototype.hasOwnProperty.call(
2605
+ base,
2606
+ "starred"
2607
+ );
2608
+ const starredFromBundle = convertBundleStarred(resolvedBundle?.starred);
2609
+ const resolvedStarred = hasBaseStarredOverride ? base.starred : base.starred ?? starredFromBundle;
2482
2610
  const merged = {
2483
2611
  ...base
2484
2612
  };
@@ -2490,6 +2618,7 @@ function mergeAssetBundle(base, extracted, extractedAnimations) {
2490
2618
  merged.pose = resolvedPose;
2491
2619
  merged.animations = resolvedAnimations;
2492
2620
  merged.programs = programsFromBundle;
2621
+ merged.starred = resolvedStarred;
2493
2622
  merged.bundle = resolvedBundle;
2494
2623
  return merged;
2495
2624
  }
@@ -2778,6 +2907,8 @@ function VizijRuntimeProviderInner({
2778
2907
  const animationModuleRef = (0, import_react2.useRef)(null);
2779
2908
  const animationHostRef = (0, import_react2.useRef)(null);
2780
2909
  const animationsSourceRegisteredRef = (0, import_react2.useRef)(false);
2910
+ const lastRegisteredModuleRevisionRef = (0, import_react2.useRef)(0);
2911
+ const lastBehaviorErrorRef = (0, import_react2.useRef)(void 0);
2781
2912
  const animationGapWarnedRef = (0, import_react2.useRef)(/* @__PURE__ */ new Set());
2782
2913
  const animationSystemActiveRef = (0, import_react2.useRef)(true);
2783
2914
  const stagedInputsRef = (0, import_react2.useRef)(/* @__PURE__ */ new Map());
@@ -3004,14 +3135,15 @@ function VizijRuntimeProviderInner({
3004
3135
  return null;
3005
3136
  }
3006
3137
  if (!animationHostRef.current) {
3007
- animationHostRef.current = new AnimationModuleHost(
3008
- getDevice,
3009
- (key) => resolveAnimationBridgeOutputPaths(
3138
+ animationHostRef.current = new AnimationModuleHost(getDevice, (key) => {
3139
+ const namespace2 = namespaceRef.current;
3140
+ const resolved = resolveAnimationBridgeOutputPaths(
3010
3141
  key,
3011
3142
  faceIdRef.current ?? void 0,
3012
3143
  rigInputMapRef.current
3013
- )
3014
- );
3144
+ ).map((path) => namespaceTypedPath(path, namespace2));
3145
+ return [...new Set(resolved)];
3146
+ });
3015
3147
  }
3016
3148
  return animationHostRef.current;
3017
3149
  }, [getDevice]);
@@ -3040,6 +3172,7 @@ function VizijRuntimeProviderInner({
3040
3172
  setReady(true);
3041
3173
  }, []);
3042
3174
  const hasBundleAnimations = (assetBundle.animations?.length ?? 0) > 0;
3175
+ const [animationModuleReadyRevision, setAnimationModuleReadyRevision] = (0, import_react2.useState)(0);
3043
3176
  (0, import_react2.useEffect)(() => {
3044
3177
  if (!hasBundleAnimations || animationModuleRef.current) {
3045
3178
  return;
@@ -3055,6 +3188,7 @@ function VizijRuntimeProviderInner({
3055
3188
  animationHostRef.current?.replayInto(handle.device);
3056
3189
  };
3057
3190
  ensureAnimationHost();
3191
+ setAnimationModuleReadyRevision((previous) => previous + 1);
3058
3192
  }).catch((err) => {
3059
3193
  pushError({
3060
3194
  message: "Failed to load the animation module",
@@ -3187,6 +3321,17 @@ function VizijRuntimeProviderInner({
3187
3321
  if (!handle.device.running) {
3188
3322
  handle.device.step(dt * 1e3);
3189
3323
  }
3324
+ const behaviorError = handle.device.behaviorError;
3325
+ if (behaviorError !== lastBehaviorErrorRef.current) {
3326
+ lastBehaviorErrorRef.current = behaviorError;
3327
+ if (behaviorError) {
3328
+ pushError({
3329
+ message: `Graph tick failed: ${behaviorError}`,
3330
+ phase: "engine",
3331
+ timestamp: performance.now()
3332
+ });
3333
+ }
3334
+ }
3190
3335
  const changes = handle.device.drainChanges();
3191
3336
  applyEngineChangesRef.current(changes);
3192
3337
  if (storeChangeListenersRef.current.size > 0) {
@@ -3318,7 +3463,6 @@ function VizijRuntimeProviderInner({
3318
3463
  if (glbAsset.kind === "url") {
3319
3464
  const loaded = await (0, import_render.loadGLTFWithBundle)(
3320
3465
  glbAsset.src,
3321
- [namespace],
3322
3466
  glbAsset.aggressiveImport ?? false,
3323
3467
  glbAsset.rootBounds
3324
3468
  );
@@ -3329,7 +3473,6 @@ function VizijRuntimeProviderInner({
3329
3473
  } else if (glbAsset.kind === "blob") {
3330
3474
  const loaded = await (0, import_render.loadGLTFFromBlobWithBundle)(
3331
3475
  glbAsset.blob,
3332
- [namespace],
3333
3476
  glbAsset.aggressiveImport ?? false,
3334
3477
  glbAsset.rootBounds
3335
3478
  );
@@ -3516,11 +3659,9 @@ function VizijRuntimeProviderInner({
3516
3659
  rigInputMapRef.current
3517
3660
  );
3518
3661
  if (isRuntimeDebugEnabled()) {
3519
- console.log("[vizij-runtime] animation output routing", {
3520
- animationId: animation.id,
3521
- bridgeOutputs,
3522
- bridgeOutputsText: bridgeOutputs.join(" | ")
3523
- });
3662
+ console.log(
3663
+ `[vizij-runtime] animation output routing ${animation.id} (${bridgeOutputs.length}) | ` + bridgeOutputs.join(" | ")
3664
+ );
3524
3665
  }
3525
3666
  recordOutputs(bridgeOutputs);
3526
3667
  }
@@ -3592,7 +3733,35 @@ function VizijRuntimeProviderInner({
3592
3733
  });
3593
3734
  }
3594
3735
  }
3595
- ensureAnimationHost()?.setClips(hostClips);
3736
+ const host = ensureAnimationHost();
3737
+ host?.setClips(hostClips);
3738
+ if (host) {
3739
+ let resumed = false;
3740
+ clipPlaybackRef.current.forEach((clipState, clipId) => {
3741
+ if (!clipState.playing || !host.has(clipId)) {
3742
+ return;
3743
+ }
3744
+ resumed = true;
3745
+ host.setSpeed(clipId, clipState.speed);
3746
+ host.setWeight(clipId, clipState.weight);
3747
+ host.setLoop(clipId, clipState.loop ? "loop" : "once");
3748
+ if (clipState.playhead > 0) {
3749
+ host.seek(clipId, clipState.playhead);
3750
+ }
3751
+ void host.play(clipId).catch((err) => {
3752
+ pushError({
3753
+ message: `Failed to resume animation ${clipId} after the module became ready`,
3754
+ cause: err,
3755
+ phase: "animation",
3756
+ timestamp: performance.now()
3757
+ });
3758
+ });
3759
+ });
3760
+ if (resumed) {
3761
+ setAnimationsSourceRegistered(host.hasPlaying());
3762
+ updateLoopMode();
3763
+ }
3764
+ }
3596
3765
  registeredAnimationsRef.current = animationIds;
3597
3766
  if (assetBundle.initialInputs) {
3598
3767
  Object.entries(assetBundle.initialInputs).forEach(([path, value]) => {
@@ -3637,7 +3806,9 @@ function VizijRuntimeProviderInner({
3637
3806
  registerMergedGraph,
3638
3807
  reportStatus,
3639
3808
  resolvedProgramAssets,
3640
- setInput
3809
+ setInput,
3810
+ setAnimationsSourceRegistered,
3811
+ updateLoopMode
3641
3812
  ]);
3642
3813
  (0, import_react2.useEffect)(() => {
3643
3814
  if (!ready || status.loading) {
@@ -3645,7 +3816,9 @@ function VizijRuntimeProviderInner({
3645
3816
  }
3646
3817
  const plan = pendingPlanRef.current;
3647
3818
  const hasRegistered = registeredGraphsRef.current.length > 0 || registeredAnimationsRef.current.length > 0 || programControllerIdsRef.current.size > 0;
3648
- if (plan && !plan.reregisterGraphs && hasRegistered) {
3819
+ const forcedByModuleReady = animationModuleReadyRevision !== lastRegisteredModuleRevisionRef.current;
3820
+ lastRegisteredModuleRevisionRef.current = animationModuleReadyRevision;
3821
+ if (plan && !plan.reregisterGraphs && hasRegistered && !forcedByModuleReady) {
3649
3822
  return;
3650
3823
  }
3651
3824
  registerControllers().catch((err) => {
@@ -3656,7 +3829,14 @@ function VizijRuntimeProviderInner({
3656
3829
  timestamp: performance.now()
3657
3830
  });
3658
3831
  });
3659
- }, [ready, status.loading, graphUpdateToken, registerControllers, pushError]);
3832
+ }, [
3833
+ ready,
3834
+ status.loading,
3835
+ graphUpdateToken,
3836
+ registerControllers,
3837
+ pushError,
3838
+ animationModuleReadyRevision
3839
+ ]);
3660
3840
  (0, import_react2.useEffect)(() => {
3661
3841
  applyEngineChangesRef.current = (changes) => {
3662
3842
  const entries = Object.entries(changes);
@@ -3774,6 +3954,23 @@ function VizijRuntimeProviderInner({
3774
3954
  entry?.resolve();
3775
3955
  }
3776
3956
  }, []);
3957
+ const advanceClipPlayheads = (0, import_react2.useCallback)((dt) => {
3958
+ if (!(dt > 0) || !Number.isFinite(dt)) {
3959
+ return;
3960
+ }
3961
+ clipPlaybackRef.current.forEach((state) => {
3962
+ if (!state.playing) {
3963
+ return;
3964
+ }
3965
+ const speed = Number.isFinite(state.speed) && state.speed > 0 ? state.speed : 1;
3966
+ const next = state.playhead + dt * speed;
3967
+ if (!(state.duration > 0)) {
3968
+ state.playhead = next;
3969
+ return;
3970
+ }
3971
+ state.playhead = state.loop ? next % state.duration : Math.min(next, state.duration);
3972
+ });
3973
+ }, []);
3777
3974
  const advanceAnimationTweens = (0, import_react2.useCallback)(
3778
3975
  (dt) => {
3779
3976
  if (animationTweensRef.current.size === 0) {
@@ -3838,6 +4035,7 @@ function VizijRuntimeProviderInner({
3838
4035
  weight: 1,
3839
4036
  loop: true,
3840
4037
  playing: false,
4038
+ playhead: 0,
3841
4039
  resolve: null,
3842
4040
  completion: null
3843
4041
  };
@@ -3936,8 +4134,17 @@ function VizijRuntimeProviderInner({
3936
4134
  return [];
3937
4135
  }
3938
4136
  const raw = device.readValues([ANIMATION_PLAYERS_PATH])[ANIMATION_PLAYERS_PATH];
3939
- return decodePlayerStates(raw);
3940
- }, [deviceSlot]);
4137
+ const states = decodePlayerStates(raw);
4138
+ if (DEV_MODE && states.length === 0 && raw && typeof raw === "object") {
4139
+ warnAnimationGap(
4140
+ "player-states-undecodable",
4141
+ `Animation player_states feedback at "${ANIMATION_PLAYERS_PATH}" could not be decoded (top-level keys: ${Object.keys(
4142
+ raw
4143
+ ).join(", ")}). The transport falls back to a host-side playhead.`
4144
+ );
4145
+ }
4146
+ return states;
4147
+ }, [deviceSlot, warnAnimationGap]);
3941
4148
  const settleFinishedClips = (0, import_react2.useCallback)(() => {
3942
4149
  let pending = false;
3943
4150
  clipPlaybackRef.current.forEach((state) => {
@@ -3993,6 +4200,7 @@ function VizijRuntimeProviderInner({
3993
4200
  if (host) {
3994
4201
  if (options?.reset === true) {
3995
4202
  host.seek(id, 0);
4203
+ state.playhead = 0;
3996
4204
  }
3997
4205
  host.setSpeed(id, state.speed);
3998
4206
  host.setWeight(id, state.weight);
@@ -4029,6 +4237,9 @@ function VizijRuntimeProviderInner({
4029
4237
  (id) => {
4030
4238
  const state = clipPlaybackRef.current.get(id);
4031
4239
  if (!state || !state.playing) {
4240
+ console.warn(
4241
+ `[vizij-runtime] pause ignored for "${id}": ${state ? "the clip is not marked playing" : "no clip state"}`
4242
+ );
4032
4243
  return;
4033
4244
  }
4034
4245
  state.playing = false;
@@ -4038,10 +4249,13 @@ function VizijRuntimeProviderInner({
4038
4249
  [syncAnimationsSource]
4039
4250
  );
4040
4251
  const seekAnimation = (0, import_react2.useCallback)((id, timeSeconds) => {
4041
- if (!clipPlaybackRef.current.has(id)) {
4252
+ const state = clipPlaybackRef.current.get(id);
4253
+ if (!state) {
4042
4254
  return;
4043
4255
  }
4044
- animationHostRef.current?.seek(id, Math.max(0, timeSeconds));
4256
+ const target = Math.max(0, timeSeconds);
4257
+ state.playhead = state.duration > 0 ? Math.min(target, state.duration) : target;
4258
+ animationHostRef.current?.seek(id, target);
4045
4259
  }, []);
4046
4260
  const setAnimationLoop = (0, import_react2.useCallback)(
4047
4261
  (id, enabled) => {
@@ -4064,9 +4278,23 @@ function VizijRuntimeProviderInner({
4064
4278
  const playerId = animationHostRef.current?.playerIdOf(id) ?? null;
4065
4279
  const feedback = playerId !== null ? readPlayerStates().find((entry) => entry.player === playerId) : void 0;
4066
4280
  return {
4067
- time: feedback?.time ?? 0,
4281
+ // Feedback is authoritative; the host-side playhead stands in when it
4282
+ // is unavailable. Never fall back to a hard 0 — that reads as "the
4283
+ // animation is parked at the start" rather than "no telemetry", and it
4284
+ // pins the transport clock and defeats seeking.
4285
+ time: feedback?.time ?? state.playhead,
4068
4286
  duration: feedback && feedback.duration > 0 ? feedback.duration : state.duration,
4069
- playing: feedback ? feedback.state === "playing" : state.playing,
4287
+ // A clip commanded to pause is paused, whatever the feedback says.
4288
+ //
4289
+ // Feedback goes stale by construction: pausing the last playing clip
4290
+ // unregisters the animations graph source, so the module stops being
4291
+ // stepped and `player_states` keeps reporting whatever it last said —
4292
+ // `state: "playing"`, with the playhead frozen. Treating that as
4293
+ // authoritative meant the transport reverted to playing a frame after
4294
+ // every pause, leaving a Pause button over a clip that was not
4295
+ // advancing. Feedback still decides while the clip is *meant* to be
4296
+ // playing, which is what notices a clip reaching its end.
4297
+ playing: state.playing && (feedback ? feedback.state === "playing" : true),
4070
4298
  loop: state.loop,
4071
4299
  speed: feedback?.speed ?? state.speed
4072
4300
  };
@@ -4087,6 +4315,9 @@ function VizijRuntimeProviderInner({
4087
4315
  syncAnimationsSource();
4088
4316
  } else {
4089
4317
  host?.stop(id);
4318
+ if (state) {
4319
+ state.playhead = 0;
4320
+ }
4090
4321
  const unsubscribe = subscribeToStep(() => {
4091
4322
  unsubscribe();
4092
4323
  syncAnimationsSource();
@@ -4421,6 +4652,13 @@ function VizijRuntimeProviderInner({
4421
4652
  },
4422
4653
  [faceId, namespace, pushError, setInput, setRendererValue]
4423
4654
  );
4655
+ const reconcileAnimationsSource = (0, import_react2.useCallback)(() => {
4656
+ const host = animationHostRef.current;
4657
+ if (!host || !host.hasPlaying()) {
4658
+ return;
4659
+ }
4660
+ setAnimationsSourceRegistered(true);
4661
+ }, [setAnimationsSourceRegistered]);
4424
4662
  const flushStagedInputs = (0, import_react2.useCallback)(() => {
4425
4663
  if (stagedInputsRef.current.size === 0) {
4426
4664
  return;
@@ -4438,6 +4676,8 @@ function VizijRuntimeProviderInner({
4438
4676
  avgStepDtRef.current = prev * (1 - alpha) + dt * alpha;
4439
4677
  }
4440
4678
  advanceAnimationTweens(dt);
4679
+ advanceClipPlayheads(dt);
4680
+ reconcileAnimationsSource();
4441
4681
  flushStagedInputs();
4442
4682
  if (driveRuntimeRef.current || opts?.forceRuntime) {
4443
4683
  stepRuntime(dt);
@@ -4446,7 +4686,9 @@ function VizijRuntimeProviderInner({
4446
4686
  },
4447
4687
  [
4448
4688
  advanceAnimationTweens,
4689
+ advanceClipPlayheads,
4449
4690
  flushStagedInputs,
4691
+ reconcileAnimationsSource,
4450
4692
  settleFinishedClips,
4451
4693
  stepRuntime
4452
4694
  ]
@@ -5103,11 +5345,14 @@ function mapUnitControlValue(control, unitValue) {
5103
5345
  buildPoseWeightRelativePath,
5104
5346
  buildRigInputPath,
5105
5347
  buildSemanticPoseWeightPathMap,
5348
+ collectInputPathMap,
5349
+ composeGraphSpecs,
5106
5350
  filterPosesBySemanticKind,
5107
5351
  getPoseSemanticKey,
5108
5352
  mapNormalizedControlValue,
5109
5353
  mapUnitControlValue,
5110
5354
  normalizePoseSemanticKey,
5355
+ resolveAnimationBridgeOutputPaths,
5111
5356
  resolveFaceControls,
5112
5357
  resolvePoseMembership,
5113
5358
  resolvePoseSemantics,