@vizij/runtime-react 0.3.0 → 0.4.0

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.d.ts CHANGED
@@ -4,7 +4,7 @@ import { ReactNode, PropsWithChildren } from 'react';
4
4
  import { ValueJSON } from '@vizij/value-json';
5
5
  import { IrGraph } from '@vizij/node-graph-authoring';
6
6
  import { RawValue, AnimatableValue } from '@vizij/utils';
7
- import { World, VizijBundleExtension, VizijProps } from '@vizij/render';
7
+ import { World, VizijBundleExtension, VizijStarredItem, VizijProps } from '@vizij/render';
8
8
 
9
9
  /** Value shape hint. Accepted through the public surface, unused by the device. */
10
10
  type ShapeJSON = Record<string, unknown>;
@@ -156,6 +156,12 @@ type VizijAssetBundle = {
156
156
  };
157
157
  animations?: VizijAnimationAsset[];
158
158
  programs?: VizijProgramAsset[];
159
+ /**
160
+ * Designer-curated set of starred drivers/poses, resolved from the loaded
161
+ * bundle's `starred` section. References real functionality by stable id
162
+ * (driver = standard-input id, pose = pose id).
163
+ */
164
+ starred?: VizijStarredItem[];
159
165
  initialInputs?: Record<string, ValueJSON>;
160
166
  metadata?: Record<string, unknown>;
161
167
  bundle?: VizijBundleExtension | null;
@@ -394,4 +400,43 @@ declare function resolveFaceControls(assetBundle: VizijAssetBundle, runtimeFaceI
394
400
  declare function mapNormalizedControlValue(control: FaceScalarControl, normalizedValue: number): number;
395
401
  declare function mapUnitControlValue(control: FaceScalarControl, unitValue: number): number;
396
402
 
397
- export { type AnimateValueOptions, type AnimationClipLike, type AnimationKeyframeLike, type AnimationPlaybackState, type AnimationTrackLike, EMOTION_POSE_KEYS, EXPRESSIVE_EMOTION_POSE_KEYS, type FaceScalarControl, type InputDriverContext, type InputDriverFactory, type InputDriverLifecycle, POSE_WEIGHT_INPUT_PATH_PREFIX, type PlayAnimationOptions, type PoseDefinition, type PoseGroupDefinition, type PoseRigConfig, type ProgramPlaybackState, type RootBounds, type RuntimeGraphBundle, type RuntimeOutputWrite, type RuntimeUpdatePlan, type RuntimeUpdateTier, type StopProgramOptions, VISEME_POSE_KEYS, type VizijAnimationAsset, type VizijAssetBundle, type VizijGlbAsset, type VizijGraphAsset, type VizijProgramAsset, VizijRuntimeFace, type VizijRuntimeFaceProps, VizijRuntimeProvider, type VizijRuntimeProviderProps, type VizijRuntimeStatus, buildPoseWeightInputPathSegment, buildPoseWeightPathMap, buildPoseWeightRelativePath, buildRigInputPath, buildSemanticPoseWeightPathMap, filterPosesBySemanticKind, getPoseSemanticKey, mapNormalizedControlValue, mapUnitControlValue, normalizePoseSemanticKey, resolveFaceControls, resolvePoseMembership, resolvePoseSemantics, resolveRuntimeUpdatePlan, useOptionalVizijRuntime, useRigInput, useVizijOutputs, useVizijRuntime };
403
+ /**
404
+ * Compose several Vizij graph specs into the ONE graph an Arora device runs.
405
+ *
406
+ * The device runs a single ProcessingGraph as its behavior, so the separate
407
+ * graph sources become a union of nodes and edges here. Sources and their
408
+ * provenance (see `graphSourcesRef` in VizijRuntimeProvider for details):
409
+ * the **rig** graph shipped in the loaded GLB/asset bundle, the bundle's
410
+ * **pose-driver** graph, and one source per **playing program** (bundle
411
+ * programs and the authoring motiongraph, which publishes the editor's
412
+ * graph as a program). Node ids
413
+ * are prefixed `${sourceId}::` so sources can't collide; `params.path` is
414
+ * deliberately NOT prefixed — path identity on the device's shared store is
415
+ * the cross-graph contract (a pose output written to a store path is read by
416
+ * a rig input node with the same path on the next tick).
417
+ *
418
+ * Output-path collisions across sources (two `output` nodes writing one
419
+ * path) are warned and resolved last-writer-wins by evaluation order; there
420
+ * is no additive merge for that case yet, and current bundles don't collide.
421
+ * If a real bundle does, insert an explicit combiner node here instead of
422
+ * widening this warning.
423
+ */
424
+ type SpecRecord = Record<string, unknown>;
425
+ /** A graph spec treated structurally (same tolerance as `utils/graph.ts`). */
426
+ type ComposableSpec = SpecRecord;
427
+ interface GraphSource {
428
+ /** Stable id of the source graph (e.g. "rig", "pose", a program id). */
429
+ sourceId: string;
430
+ spec: ComposableSpec;
431
+ }
432
+ /**
433
+ * Union `sources` into one spec. Later sources evaluate after earlier ones,
434
+ * so on an output-path collision the last source wins (warned).
435
+ */
436
+ declare function composeGraphSpecs(sources: GraphSource[]): ComposableSpec;
437
+
438
+ declare function collectInputPathMap(spec: GraphRegistrationConfig["spec"]): Record<string, string>;
439
+
440
+ declare function resolveAnimationBridgeOutputPaths(channel: string, faceId?: string, rigInputMap?: Record<string, string>): string[];
441
+
442
+ export { type AnimateValueOptions, type AnimationClipLike, type AnimationKeyframeLike, type AnimationPlaybackState, type AnimationTrackLike, type ComposableSpec, EMOTION_POSE_KEYS, EXPRESSIVE_EMOTION_POSE_KEYS, type FaceScalarControl, type GraphSource, type InputDriverContext, type InputDriverFactory, type InputDriverLifecycle, POSE_WEIGHT_INPUT_PATH_PREFIX, type PlayAnimationOptions, type PoseDefinition, type PoseGroupDefinition, type PoseRigConfig, type ProgramPlaybackState, type RootBounds, type RuntimeGraphBundle, type RuntimeOutputWrite, type RuntimeUpdatePlan, type RuntimeUpdateTier, type StopProgramOptions, VISEME_POSE_KEYS, type VizijAnimationAsset, type VizijAssetBundle, type VizijGlbAsset, type VizijGraphAsset, type VizijProgramAsset, VizijRuntimeFace, type VizijRuntimeFaceProps, VizijRuntimeProvider, type VizijRuntimeProviderProps, type VizijRuntimeStatus, buildPoseWeightInputPathSegment, buildPoseWeightPathMap, buildPoseWeightRelativePath, buildRigInputPath, buildSemanticPoseWeightPathMap, collectInputPathMap, composeGraphSpecs, filterPosesBySemanticKind, getPoseSemanticKey, mapNormalizedControlValue, mapUnitControlValue, normalizePoseSemanticKey, resolveAnimationBridgeOutputPaths, resolveFaceControls, resolvePoseMembership, resolvePoseSemantics, resolveRuntimeUpdatePlan, useOptionalVizijRuntime, useRigInput, useVizijOutputs, useVizijRuntime };
package/dist/index.js CHANGED
@@ -1443,7 +1443,7 @@ function fieldNumber(value) {
1443
1443
  if (!value || typeof value !== "object") {
1444
1444
  return null;
1445
1445
  }
1446
- for (const key of ["u64", "u32", "f32", "f64"]) {
1446
+ for (const key of ["u64", "u32", "f32", "f64", "float"]) {
1447
1447
  if (key in value) {
1448
1448
  const parsed = Number(value[key]);
1449
1449
  return Number.isFinite(parsed) ? parsed : null;
@@ -1451,38 +1451,90 @@ function fieldNumber(value) {
1451
1451
  }
1452
1452
  return null;
1453
1453
  }
1454
+ function fieldText(value) {
1455
+ if (!value || typeof value !== "object") {
1456
+ return "";
1457
+ }
1458
+ const record = value;
1459
+ for (const key of ["str", "text"]) {
1460
+ if (typeof record[key] === "string") {
1461
+ return record[key];
1462
+ }
1463
+ }
1464
+ return "";
1465
+ }
1466
+ function playerStateFromFields(byId) {
1467
+ const player = fieldNumber(byId.get(ANIMATION_MODULE_FIELD.statePlayer));
1468
+ if (player === null) {
1469
+ return null;
1470
+ }
1471
+ return {
1472
+ player,
1473
+ state: fieldText(byId.get(ANIMATION_MODULE_FIELD.stateState)),
1474
+ time: (fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateTimeNs)) ?? 0) / 1e9,
1475
+ duration: (fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateDurationNs)) ?? 0) / 1e9,
1476
+ speed: fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateSpeed)) ?? 1
1477
+ };
1478
+ }
1479
+ function fieldsToMap(fields) {
1480
+ if (!Array.isArray(fields)) {
1481
+ return null;
1482
+ }
1483
+ const byId = /* @__PURE__ */ new Map();
1484
+ for (const entry of fields) {
1485
+ const id = entry?.id;
1486
+ const value = entry?.value;
1487
+ if (typeof id === "string" && value && typeof value === "object") {
1488
+ byId.set(id, value);
1489
+ }
1490
+ }
1491
+ return byId;
1492
+ }
1493
+ function recordToMap(element) {
1494
+ if (!element || typeof element !== "object") {
1495
+ return null;
1496
+ }
1497
+ const record = element.record;
1498
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
1499
+ return null;
1500
+ }
1501
+ const byId = /* @__PURE__ */ new Map();
1502
+ for (const [id, value] of Object.entries(record)) {
1503
+ if (value && typeof value === "object") {
1504
+ byId.set(id, value);
1505
+ }
1506
+ }
1507
+ return byId;
1508
+ }
1454
1509
  function decodePlayerStates(raw) {
1455
- if (!raw || typeof raw !== "object" || !("structs" in raw)) {
1510
+ if (!raw || typeof raw !== "object") {
1456
1511
  return [];
1457
1512
  }
1458
- const structs = raw.structs;
1459
- const elements = Array.isArray(structs?.elements) ? structs.elements : [];
1513
+ const container = raw;
1460
1514
  const states = [];
1461
- for (const element of elements) {
1462
- const fields = element?.fields;
1463
- if (!Array.isArray(fields)) {
1464
- continue;
1465
- }
1466
- const byId = /* @__PURE__ */ new Map();
1467
- for (const entry of fields) {
1468
- const id = entry?.id;
1469
- const value = entry?.value;
1470
- if (typeof id === "string" && value && typeof value === "object") {
1471
- byId.set(id, value);
1515
+ const structs = container.structs;
1516
+ if (structs && Array.isArray(structs.elements)) {
1517
+ for (const element of structs.elements) {
1518
+ const byId = fieldsToMap(element?.fields);
1519
+ const state = byId ? playerStateFromFields(byId) : null;
1520
+ if (state) {
1521
+ states.push(state);
1472
1522
  }
1473
1523
  }
1474
- const player = fieldNumber(byId.get(ANIMATION_MODULE_FIELD.statePlayer));
1475
- if (player === null) {
1476
- continue;
1524
+ return states;
1525
+ }
1526
+ const elements = Array.isArray(container.list) ? container.list : Array.isArray(container.array) ? container.array : null;
1527
+ if (!elements) {
1528
+ return states;
1529
+ }
1530
+ for (const element of elements) {
1531
+ const byId = recordToMap(element) ?? fieldsToMap(
1532
+ element?.struct?.fields ?? element?.fields
1533
+ );
1534
+ const state = byId ? playerStateFromFields(byId) : null;
1535
+ if (state) {
1536
+ states.push(state);
1477
1537
  }
1478
- const stateValue = byId.get(ANIMATION_MODULE_FIELD.stateState);
1479
- states.push({
1480
- player,
1481
- state: stateValue && "str" in stateValue ? String(stateValue.str) : "",
1482
- time: (fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateTimeNs)) ?? 0) / 1e9,
1483
- duration: (fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateDurationNs)) ?? 0) / 1e9,
1484
- speed: fieldNumber(byId.get(ANIMATION_MODULE_FIELD.stateSpeed)) ?? 1
1485
- });
1486
1538
  }
1487
1539
  return states;
1488
1540
  }
@@ -1552,6 +1604,7 @@ var AnimationModuleHost = class {
1552
1604
  speed: 1,
1553
1605
  loop: "loop",
1554
1606
  weight: 1,
1607
+ pendingSeekSeconds: null,
1555
1608
  animId: null,
1556
1609
  playerId: null,
1557
1610
  instId: null
@@ -1590,6 +1643,8 @@ var AnimationModuleHost = class {
1590
1643
  }
1591
1644
  entry.playing = false;
1592
1645
  this.dispatch(
1646
+ "pause",
1647
+ clipId,
1593
1648
  (device) => entry.playerId !== null ? device.call(pauseCall(entry.playerId)) : null
1594
1649
  );
1595
1650
  }
@@ -1605,6 +1660,8 @@ var AnimationModuleHost = class {
1605
1660
  }
1606
1661
  entry.playing = false;
1607
1662
  this.dispatch(
1663
+ "stop",
1664
+ clipId,
1608
1665
  (device) => entry.playerId !== null ? device.call(stopCall(entry.playerId)) : null
1609
1666
  );
1610
1667
  }
@@ -1614,7 +1671,14 @@ var AnimationModuleHost = class {
1614
1671
  if (!entry) {
1615
1672
  return;
1616
1673
  }
1674
+ if (entry.playerId === null) {
1675
+ entry.pendingSeekSeconds = seconds;
1676
+ return;
1677
+ }
1678
+ entry.pendingSeekSeconds = null;
1617
1679
  this.dispatch(
1680
+ "seek",
1681
+ clipId,
1618
1682
  (device) => entry.playerId !== null ? device.call(seekCall(entry.playerId, seconds * 1e9)) : null
1619
1683
  );
1620
1684
  }
@@ -1626,6 +1690,8 @@ var AnimationModuleHost = class {
1626
1690
  }
1627
1691
  entry.speed = speed;
1628
1692
  this.dispatch(
1693
+ "set speed",
1694
+ clipId,
1629
1695
  (device) => entry.playerId !== null ? device.call(setSpeedCall(entry.playerId, speed)) : null
1630
1696
  );
1631
1697
  }
@@ -1637,6 +1703,8 @@ var AnimationModuleHost = class {
1637
1703
  }
1638
1704
  entry.loop = mode;
1639
1705
  this.dispatch(
1706
+ "set loop",
1707
+ clipId,
1640
1708
  (device) => entry.playerId !== null ? device.call(setLoopCall(entry.playerId, mode)) : null
1641
1709
  );
1642
1710
  }
@@ -1648,6 +1716,8 @@ var AnimationModuleHost = class {
1648
1716
  }
1649
1717
  entry.weight = weight;
1650
1718
  this.dispatch(
1719
+ "set weight",
1720
+ clipId,
1651
1721
  (device) => entry.playerId !== null && entry.instId !== null ? device.call(setWeightCall(entry.playerId, entry.instId, weight)) : null
1652
1722
  );
1653
1723
  }
@@ -1655,6 +1725,9 @@ var AnimationModuleHost = class {
1655
1725
  * Re-issue setup and transport for every playing clip against a freshly
1656
1726
  * started device (module guest state did not survive the rebuild). Voids
1657
1727
  * stale ids first. Called from `DeviceSlot.onDeviceStarted`.
1728
+ *
1729
+ * A freshly loaded player advances without an explicit `play` — asserted by
1730
+ * the rebuild case in `animationPipeline.device.test.ts`.
1658
1731
  */
1659
1732
  replayInto(device) {
1660
1733
  for (const entry of this.clips.values()) {
@@ -1667,12 +1740,35 @@ var AnimationModuleHost = class {
1667
1740
  }
1668
1741
  }
1669
1742
  /** Fire-and-forget a transport call against the live device, if any. */
1670
- dispatch(issue) {
1743
+ /**
1744
+ * Issue a transport call, reporting rather than swallowing a rejection.
1745
+ *
1746
+ * This used to discard the error on the assumption that the only cause was
1747
+ * a rebuild racing the call, which `replayInto` would repair. When that is
1748
+ * not the cause — the module missing from the live device, say — the call
1749
+ * fails, the player keeps running, and the next frame of `player_states`
1750
+ * feedback tells the transport UI it is still playing. The button flips
1751
+ * back and the pause looks like it did nothing, with nothing anywhere
1752
+ * saying why. Whatever the cause, a dropped transport command is worth a
1753
+ * line in the console.
1754
+ */
1755
+ dispatch(what, clipId, issue) {
1671
1756
  const device = this.getDevice();
1672
1757
  if (!device) {
1673
1758
  return;
1674
1759
  }
1675
- void issue(device)?.catch(() => {
1760
+ const issued = issue(device);
1761
+ if (!issued) {
1762
+ console.warn(
1763
+ `[vizij-runtime] animation ${what} had no player to act on for "${clipId}"`
1764
+ );
1765
+ return;
1766
+ }
1767
+ void issued.catch((err) => {
1768
+ console.warn(
1769
+ `[vizij-runtime] animation ${what} was dropped for "${clipId}"`,
1770
+ err
1771
+ );
1676
1772
  });
1677
1773
  }
1678
1774
  async ensureLoaded(device, entry) {
@@ -1710,6 +1806,12 @@ var AnimationModuleHost = class {
1710
1806
  setWeightCall(entry.playerId, entry.instId, entry.weight)
1711
1807
  );
1712
1808
  }
1809
+ if (entry.pendingSeekSeconds !== null) {
1810
+ followups.push(
1811
+ seekCall(entry.playerId, entry.pendingSeekSeconds * 1e9)
1812
+ );
1813
+ entry.pendingSeekSeconds = null;
1814
+ }
1713
1815
  }
1714
1816
  for (const call of followups) {
1715
1817
  await device.call(call);
@@ -2290,6 +2392,23 @@ function convertBundlePrograms(entries) {
2290
2392
  };
2291
2393
  }).filter(Boolean);
2292
2394
  }
2395
+ function convertBundleStarred(section) {
2396
+ const items = section?.items;
2397
+ if (!Array.isArray(items) || items.length === 0) {
2398
+ return void 0;
2399
+ }
2400
+ const resolved = [];
2401
+ for (const item of items) {
2402
+ if (!item || typeof item !== "object") {
2403
+ continue;
2404
+ }
2405
+ const { kind, id } = item;
2406
+ if ((kind === "driver" || kind === "pose") && typeof id === "string" && id) {
2407
+ resolved.push({ kind, id });
2408
+ }
2409
+ }
2410
+ return resolved.length > 0 ? resolved : void 0;
2411
+ }
2293
2412
  function resolveConstraintDefault(path, namespace, inputConstraints) {
2294
2413
  const trimmed = path.trim();
2295
2414
  if (!trimmed) {
@@ -2447,6 +2566,12 @@ function mergeAssetBundle(base, extracted, extractedAnimations) {
2447
2566
  hasBaseProgramsOverride,
2448
2567
  convertBundlePrograms(resolvedBundle?.graphs)
2449
2568
  );
2569
+ const hasBaseStarredOverride = Object.prototype.hasOwnProperty.call(
2570
+ base,
2571
+ "starred"
2572
+ );
2573
+ const starredFromBundle = convertBundleStarred(resolvedBundle?.starred);
2574
+ const resolvedStarred = hasBaseStarredOverride ? base.starred : base.starred ?? starredFromBundle;
2450
2575
  const merged = {
2451
2576
  ...base
2452
2577
  };
@@ -2458,6 +2583,7 @@ function mergeAssetBundle(base, extracted, extractedAnimations) {
2458
2583
  merged.pose = resolvedPose;
2459
2584
  merged.animations = resolvedAnimations;
2460
2585
  merged.programs = programsFromBundle;
2586
+ merged.starred = resolvedStarred;
2461
2587
  merged.bundle = resolvedBundle;
2462
2588
  return merged;
2463
2589
  }
@@ -2746,6 +2872,8 @@ function VizijRuntimeProviderInner({
2746
2872
  const animationModuleRef = useRef(null);
2747
2873
  const animationHostRef = useRef(null);
2748
2874
  const animationsSourceRegisteredRef = useRef(false);
2875
+ const lastRegisteredModuleRevisionRef = useRef(0);
2876
+ const lastBehaviorErrorRef = useRef(void 0);
2749
2877
  const animationGapWarnedRef = useRef(/* @__PURE__ */ new Set());
2750
2878
  const animationSystemActiveRef = useRef(true);
2751
2879
  const stagedInputsRef = useRef(/* @__PURE__ */ new Map());
@@ -2972,14 +3100,15 @@ function VizijRuntimeProviderInner({
2972
3100
  return null;
2973
3101
  }
2974
3102
  if (!animationHostRef.current) {
2975
- animationHostRef.current = new AnimationModuleHost(
2976
- getDevice,
2977
- (key) => resolveAnimationBridgeOutputPaths(
3103
+ animationHostRef.current = new AnimationModuleHost(getDevice, (key) => {
3104
+ const namespace2 = namespaceRef.current;
3105
+ const resolved = resolveAnimationBridgeOutputPaths(
2978
3106
  key,
2979
3107
  faceIdRef.current ?? void 0,
2980
3108
  rigInputMapRef.current
2981
- )
2982
- );
3109
+ ).map((path) => namespaceTypedPath(path, namespace2));
3110
+ return [...new Set(resolved)];
3111
+ });
2983
3112
  }
2984
3113
  return animationHostRef.current;
2985
3114
  }, [getDevice]);
@@ -3008,6 +3137,7 @@ function VizijRuntimeProviderInner({
3008
3137
  setReady(true);
3009
3138
  }, []);
3010
3139
  const hasBundleAnimations = (assetBundle.animations?.length ?? 0) > 0;
3140
+ const [animationModuleReadyRevision, setAnimationModuleReadyRevision] = useState(0);
3011
3141
  useEffect(() => {
3012
3142
  if (!hasBundleAnimations || animationModuleRef.current) {
3013
3143
  return;
@@ -3023,6 +3153,7 @@ function VizijRuntimeProviderInner({
3023
3153
  animationHostRef.current?.replayInto(handle.device);
3024
3154
  };
3025
3155
  ensureAnimationHost();
3156
+ setAnimationModuleReadyRevision((previous) => previous + 1);
3026
3157
  }).catch((err) => {
3027
3158
  pushError({
3028
3159
  message: "Failed to load the animation module",
@@ -3155,6 +3286,17 @@ function VizijRuntimeProviderInner({
3155
3286
  if (!handle.device.running) {
3156
3287
  handle.device.step(dt * 1e3);
3157
3288
  }
3289
+ const behaviorError = handle.device.behaviorError;
3290
+ if (behaviorError !== lastBehaviorErrorRef.current) {
3291
+ lastBehaviorErrorRef.current = behaviorError;
3292
+ if (behaviorError) {
3293
+ pushError({
3294
+ message: `Graph tick failed: ${behaviorError}`,
3295
+ phase: "engine",
3296
+ timestamp: performance.now()
3297
+ });
3298
+ }
3299
+ }
3158
3300
  const changes = handle.device.drainChanges();
3159
3301
  applyEngineChangesRef.current(changes);
3160
3302
  if (storeChangeListenersRef.current.size > 0) {
@@ -3484,11 +3626,9 @@ function VizijRuntimeProviderInner({
3484
3626
  rigInputMapRef.current
3485
3627
  );
3486
3628
  if (isRuntimeDebugEnabled()) {
3487
- console.log("[vizij-runtime] animation output routing", {
3488
- animationId: animation.id,
3489
- bridgeOutputs,
3490
- bridgeOutputsText: bridgeOutputs.join(" | ")
3491
- });
3629
+ console.log(
3630
+ `[vizij-runtime] animation output routing ${animation.id} (${bridgeOutputs.length}) | ` + bridgeOutputs.join(" | ")
3631
+ );
3492
3632
  }
3493
3633
  recordOutputs(bridgeOutputs);
3494
3634
  }
@@ -3560,7 +3700,35 @@ function VizijRuntimeProviderInner({
3560
3700
  });
3561
3701
  }
3562
3702
  }
3563
- ensureAnimationHost()?.setClips(hostClips);
3703
+ const host = ensureAnimationHost();
3704
+ host?.setClips(hostClips);
3705
+ if (host) {
3706
+ let resumed = false;
3707
+ clipPlaybackRef.current.forEach((clipState, clipId) => {
3708
+ if (!clipState.playing || !host.has(clipId)) {
3709
+ return;
3710
+ }
3711
+ resumed = true;
3712
+ host.setSpeed(clipId, clipState.speed);
3713
+ host.setWeight(clipId, clipState.weight);
3714
+ host.setLoop(clipId, clipState.loop ? "loop" : "once");
3715
+ if (clipState.playhead > 0) {
3716
+ host.seek(clipId, clipState.playhead);
3717
+ }
3718
+ void host.play(clipId).catch((err) => {
3719
+ pushError({
3720
+ message: `Failed to resume animation ${clipId} after the module became ready`,
3721
+ cause: err,
3722
+ phase: "animation",
3723
+ timestamp: performance.now()
3724
+ });
3725
+ });
3726
+ });
3727
+ if (resumed) {
3728
+ setAnimationsSourceRegistered(host.hasPlaying());
3729
+ updateLoopMode();
3730
+ }
3731
+ }
3564
3732
  registeredAnimationsRef.current = animationIds;
3565
3733
  if (assetBundle.initialInputs) {
3566
3734
  Object.entries(assetBundle.initialInputs).forEach(([path, value]) => {
@@ -3605,7 +3773,9 @@ function VizijRuntimeProviderInner({
3605
3773
  registerMergedGraph,
3606
3774
  reportStatus,
3607
3775
  resolvedProgramAssets,
3608
- setInput
3776
+ setInput,
3777
+ setAnimationsSourceRegistered,
3778
+ updateLoopMode
3609
3779
  ]);
3610
3780
  useEffect(() => {
3611
3781
  if (!ready || status.loading) {
@@ -3613,7 +3783,9 @@ function VizijRuntimeProviderInner({
3613
3783
  }
3614
3784
  const plan = pendingPlanRef.current;
3615
3785
  const hasRegistered = registeredGraphsRef.current.length > 0 || registeredAnimationsRef.current.length > 0 || programControllerIdsRef.current.size > 0;
3616
- if (plan && !plan.reregisterGraphs && hasRegistered) {
3786
+ const forcedByModuleReady = animationModuleReadyRevision !== lastRegisteredModuleRevisionRef.current;
3787
+ lastRegisteredModuleRevisionRef.current = animationModuleReadyRevision;
3788
+ if (plan && !plan.reregisterGraphs && hasRegistered && !forcedByModuleReady) {
3617
3789
  return;
3618
3790
  }
3619
3791
  registerControllers().catch((err) => {
@@ -3624,7 +3796,14 @@ function VizijRuntimeProviderInner({
3624
3796
  timestamp: performance.now()
3625
3797
  });
3626
3798
  });
3627
- }, [ready, status.loading, graphUpdateToken, registerControllers, pushError]);
3799
+ }, [
3800
+ ready,
3801
+ status.loading,
3802
+ graphUpdateToken,
3803
+ registerControllers,
3804
+ pushError,
3805
+ animationModuleReadyRevision
3806
+ ]);
3628
3807
  useEffect(() => {
3629
3808
  applyEngineChangesRef.current = (changes) => {
3630
3809
  const entries = Object.entries(changes);
@@ -3742,6 +3921,23 @@ function VizijRuntimeProviderInner({
3742
3921
  entry?.resolve();
3743
3922
  }
3744
3923
  }, []);
3924
+ const advanceClipPlayheads = useCallback((dt) => {
3925
+ if (!(dt > 0) || !Number.isFinite(dt)) {
3926
+ return;
3927
+ }
3928
+ clipPlaybackRef.current.forEach((state) => {
3929
+ if (!state.playing) {
3930
+ return;
3931
+ }
3932
+ const speed = Number.isFinite(state.speed) && state.speed > 0 ? state.speed : 1;
3933
+ const next = state.playhead + dt * speed;
3934
+ if (!(state.duration > 0)) {
3935
+ state.playhead = next;
3936
+ return;
3937
+ }
3938
+ state.playhead = state.loop ? next % state.duration : Math.min(next, state.duration);
3939
+ });
3940
+ }, []);
3745
3941
  const advanceAnimationTweens = useCallback(
3746
3942
  (dt) => {
3747
3943
  if (animationTweensRef.current.size === 0) {
@@ -3806,6 +4002,7 @@ function VizijRuntimeProviderInner({
3806
4002
  weight: 1,
3807
4003
  loop: true,
3808
4004
  playing: false,
4005
+ playhead: 0,
3809
4006
  resolve: null,
3810
4007
  completion: null
3811
4008
  };
@@ -3904,8 +4101,17 @@ function VizijRuntimeProviderInner({
3904
4101
  return [];
3905
4102
  }
3906
4103
  const raw = device.readValues([ANIMATION_PLAYERS_PATH])[ANIMATION_PLAYERS_PATH];
3907
- return decodePlayerStates(raw);
3908
- }, [deviceSlot]);
4104
+ const states = decodePlayerStates(raw);
4105
+ if (DEV_MODE && states.length === 0 && raw && typeof raw === "object") {
4106
+ warnAnimationGap(
4107
+ "player-states-undecodable",
4108
+ `Animation player_states feedback at "${ANIMATION_PLAYERS_PATH}" could not be decoded (top-level keys: ${Object.keys(
4109
+ raw
4110
+ ).join(", ")}). The transport falls back to a host-side playhead.`
4111
+ );
4112
+ }
4113
+ return states;
4114
+ }, [deviceSlot, warnAnimationGap]);
3909
4115
  const settleFinishedClips = useCallback(() => {
3910
4116
  let pending = false;
3911
4117
  clipPlaybackRef.current.forEach((state) => {
@@ -3961,6 +4167,7 @@ function VizijRuntimeProviderInner({
3961
4167
  if (host) {
3962
4168
  if (options?.reset === true) {
3963
4169
  host.seek(id, 0);
4170
+ state.playhead = 0;
3964
4171
  }
3965
4172
  host.setSpeed(id, state.speed);
3966
4173
  host.setWeight(id, state.weight);
@@ -3997,6 +4204,9 @@ function VizijRuntimeProviderInner({
3997
4204
  (id) => {
3998
4205
  const state = clipPlaybackRef.current.get(id);
3999
4206
  if (!state || !state.playing) {
4207
+ console.warn(
4208
+ `[vizij-runtime] pause ignored for "${id}": ${state ? "the clip is not marked playing" : "no clip state"}`
4209
+ );
4000
4210
  return;
4001
4211
  }
4002
4212
  state.playing = false;
@@ -4006,10 +4216,13 @@ function VizijRuntimeProviderInner({
4006
4216
  [syncAnimationsSource]
4007
4217
  );
4008
4218
  const seekAnimation = useCallback((id, timeSeconds) => {
4009
- if (!clipPlaybackRef.current.has(id)) {
4219
+ const state = clipPlaybackRef.current.get(id);
4220
+ if (!state) {
4010
4221
  return;
4011
4222
  }
4012
- animationHostRef.current?.seek(id, Math.max(0, timeSeconds));
4223
+ const target = Math.max(0, timeSeconds);
4224
+ state.playhead = state.duration > 0 ? Math.min(target, state.duration) : target;
4225
+ animationHostRef.current?.seek(id, target);
4013
4226
  }, []);
4014
4227
  const setAnimationLoop = useCallback(
4015
4228
  (id, enabled) => {
@@ -4032,9 +4245,23 @@ function VizijRuntimeProviderInner({
4032
4245
  const playerId = animationHostRef.current?.playerIdOf(id) ?? null;
4033
4246
  const feedback = playerId !== null ? readPlayerStates().find((entry) => entry.player === playerId) : void 0;
4034
4247
  return {
4035
- time: feedback?.time ?? 0,
4248
+ // Feedback is authoritative; the host-side playhead stands in when it
4249
+ // is unavailable. Never fall back to a hard 0 — that reads as "the
4250
+ // animation is parked at the start" rather than "no telemetry", and it
4251
+ // pins the transport clock and defeats seeking.
4252
+ time: feedback?.time ?? state.playhead,
4036
4253
  duration: feedback && feedback.duration > 0 ? feedback.duration : state.duration,
4037
- playing: feedback ? feedback.state === "playing" : state.playing,
4254
+ // A clip commanded to pause is paused, whatever the feedback says.
4255
+ //
4256
+ // Feedback goes stale by construction: pausing the last playing clip
4257
+ // unregisters the animations graph source, so the module stops being
4258
+ // stepped and `player_states` keeps reporting whatever it last said —
4259
+ // `state: "playing"`, with the playhead frozen. Treating that as
4260
+ // authoritative meant the transport reverted to playing a frame after
4261
+ // every pause, leaving a Pause button over a clip that was not
4262
+ // advancing. Feedback still decides while the clip is *meant* to be
4263
+ // playing, which is what notices a clip reaching its end.
4264
+ playing: state.playing && (feedback ? feedback.state === "playing" : true),
4038
4265
  loop: state.loop,
4039
4266
  speed: feedback?.speed ?? state.speed
4040
4267
  };
@@ -4055,6 +4282,9 @@ function VizijRuntimeProviderInner({
4055
4282
  syncAnimationsSource();
4056
4283
  } else {
4057
4284
  host?.stop(id);
4285
+ if (state) {
4286
+ state.playhead = 0;
4287
+ }
4058
4288
  const unsubscribe = subscribeToStep(() => {
4059
4289
  unsubscribe();
4060
4290
  syncAnimationsSource();
@@ -4389,6 +4619,13 @@ function VizijRuntimeProviderInner({
4389
4619
  },
4390
4620
  [faceId, namespace, pushError, setInput, setRendererValue]
4391
4621
  );
4622
+ const reconcileAnimationsSource = useCallback(() => {
4623
+ const host = animationHostRef.current;
4624
+ if (!host || !host.hasPlaying()) {
4625
+ return;
4626
+ }
4627
+ setAnimationsSourceRegistered(true);
4628
+ }, [setAnimationsSourceRegistered]);
4392
4629
  const flushStagedInputs = useCallback(() => {
4393
4630
  if (stagedInputsRef.current.size === 0) {
4394
4631
  return;
@@ -4406,6 +4643,8 @@ function VizijRuntimeProviderInner({
4406
4643
  avgStepDtRef.current = prev * (1 - alpha) + dt * alpha;
4407
4644
  }
4408
4645
  advanceAnimationTweens(dt);
4646
+ advanceClipPlayheads(dt);
4647
+ reconcileAnimationsSource();
4409
4648
  flushStagedInputs();
4410
4649
  if (driveRuntimeRef.current || opts?.forceRuntime) {
4411
4650
  stepRuntime(dt);
@@ -4414,7 +4653,9 @@ function VizijRuntimeProviderInner({
4414
4653
  },
4415
4654
  [
4416
4655
  advanceAnimationTweens,
4656
+ advanceClipPlayheads,
4417
4657
  flushStagedInputs,
4658
+ reconcileAnimationsSource,
4418
4659
  settleFinishedClips,
4419
4660
  stepRuntime
4420
4661
  ]
@@ -5074,11 +5315,14 @@ export {
5074
5315
  buildPoseWeightRelativePath,
5075
5316
  buildRigInputPath,
5076
5317
  buildSemanticPoseWeightPathMap,
5318
+ collectInputPathMap,
5319
+ composeGraphSpecs,
5077
5320
  filterPosesBySemanticKind,
5078
5321
  getPoseSemanticKey,
5079
5322
  mapNormalizedControlValue,
5080
5323
  mapUnitControlValue,
5081
5324
  normalizePoseSemanticKey,
5325
+ resolveAnimationBridgeOutputPaths,
5082
5326
  resolveFaceControls,
5083
5327
  resolvePoseMembership,
5084
5328
  resolvePoseSemantics,