@reactvision/react-viro 2.57.0 → 2.57.3

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.
Files changed (101) hide show
  1. package/README.md +8 -0
  2. package/android/react_viro/react_viro-release.aar +0 -0
  3. package/android/viro_renderer/viro_renderer-release.aar +0 -0
  4. package/components/AR/ViroARPlane.tsx +1 -4
  5. package/components/AR/ViroARPlaneSelector.tsx +10 -5
  6. package/components/AR/ViroARScene.tsx +2 -4
  7. package/components/AR/ViroARSceneNavigator.tsx +11 -3
  8. package/components/Studio/StudioARScene.tsx +243 -48
  9. package/components/Studio/StudioSceneNavigator.tsx +15 -0
  10. package/components/Studio/VRTStudioModule.ts +10 -0
  11. package/components/Studio/domain/StudioSounds.tsx +46 -0
  12. package/components/Studio/domain/apiRequestHelpers.ts +612 -0
  13. package/components/Studio/domain/collisionBindingsRuntime.ts +17 -2
  14. package/components/Studio/domain/defaultApiRequestExecutor.ts +61 -0
  15. package/components/Studio/domain/expressionEvaluator.ts +603 -0
  16. package/components/Studio/domain/sceneNavigationHandler.ts +770 -20
  17. package/components/Studio/domain/soundManager.ts +141 -0
  18. package/components/Studio/domain/utils.ts +50 -0
  19. package/components/Studio/domain/variableStore.ts +74 -0
  20. package/components/Studio/domain/viroNodeFactory.tsx +175 -44
  21. package/components/Studio/domain/visibilityStore.ts +66 -0
  22. package/components/Studio/index.ts +1 -0
  23. package/components/Studio/types.ts +178 -1
  24. package/components/Utilities/VRModuleOpenXR.ts +35 -0
  25. package/components/Utilities/ViroVersion.ts +1 -1
  26. package/components/ViroAnimatedImage.tsx +1 -0
  27. package/components/ViroFlexView.tsx +1 -0
  28. package/components/ViroImage.tsx +1 -0
  29. package/components/ViroObjectDetector.tsx +13 -5
  30. package/components/ViroParticleEmitter.tsx +2 -0
  31. package/components/ViroPolygon.tsx +2 -1
  32. package/components/ViroQuad.tsx +2 -1
  33. package/components/ViroSurface.tsx +1 -0
  34. package/components/ViroVideo.tsx +1 -0
  35. package/components/ViroXRSceneNavigator.tsx +8 -0
  36. package/dist/components/AR/ViroARPlane.d.ts +1 -1
  37. package/dist/components/AR/ViroARPlane.js +1 -5
  38. package/dist/components/AR/ViroARPlaneSelector.d.ts +1 -1
  39. package/dist/components/AR/ViroARPlaneSelector.js +10 -5
  40. package/dist/components/AR/ViroARScene.d.ts +1 -1
  41. package/dist/components/AR/ViroARScene.js +2 -5
  42. package/dist/components/AR/ViroARSceneNavigator.d.ts +11 -3
  43. package/dist/components/Studio/StudioARScene.d.ts +3 -0
  44. package/dist/components/Studio/StudioARScene.js +142 -15
  45. package/dist/components/Studio/StudioSceneNavigator.js +14 -0
  46. package/dist/components/Studio/VRTStudioModule.d.ts +6 -0
  47. package/dist/components/Studio/VRTStudioModule.js +10 -0
  48. package/dist/components/Studio/domain/StudioSounds.d.ts +11 -0
  49. package/dist/components/Studio/domain/StudioSounds.js +69 -0
  50. package/dist/components/Studio/domain/apiRequestHelpers.d.ts +180 -0
  51. package/dist/components/Studio/domain/apiRequestHelpers.js +496 -0
  52. package/dist/components/Studio/domain/collisionBindingsRuntime.d.ts +3 -1
  53. package/dist/components/Studio/domain/collisionBindingsRuntime.js +4 -3
  54. package/dist/components/Studio/domain/defaultApiRequestExecutor.d.ts +14 -0
  55. package/dist/components/Studio/domain/defaultApiRequestExecutor.js +58 -0
  56. package/dist/components/Studio/domain/expressionEvaluator.d.ts +108 -0
  57. package/dist/components/Studio/domain/expressionEvaluator.js +520 -0
  58. package/dist/components/Studio/domain/sceneNavigationHandler.d.ts +35 -8
  59. package/dist/components/Studio/domain/sceneNavigationHandler.js +564 -22
  60. package/dist/components/Studio/domain/soundManager.d.ts +52 -0
  61. package/dist/components/Studio/domain/soundManager.js +118 -0
  62. package/dist/components/Studio/domain/utils.d.ts +16 -0
  63. package/dist/components/Studio/domain/utils.js +48 -0
  64. package/dist/components/Studio/domain/variableStore.d.ts +21 -0
  65. package/dist/components/Studio/domain/variableStore.js +65 -0
  66. package/dist/components/Studio/domain/viroNodeFactory.d.ts +4 -9
  67. package/dist/components/Studio/domain/viroNodeFactory.js +84 -25
  68. package/dist/components/Studio/domain/visibilityStore.d.ts +33 -0
  69. package/dist/components/Studio/domain/visibilityStore.js +66 -0
  70. package/dist/components/Studio/index.d.ts +1 -1
  71. package/dist/components/Studio/types.d.ts +150 -1
  72. package/dist/components/Utilities/VRModuleOpenXR.d.ts +19 -0
  73. package/dist/components/Utilities/VRModuleOpenXR.js +16 -0
  74. package/dist/components/Utilities/ViroVersion.d.ts +1 -1
  75. package/dist/components/Utilities/ViroVersion.js +1 -1
  76. package/dist/components/ViroAnimatedImage.js +1 -0
  77. package/dist/components/ViroFlexView.js +1 -0
  78. package/dist/components/ViroImage.js +1 -0
  79. package/dist/components/ViroObjectDetector.d.ts +13 -5
  80. package/dist/components/ViroObjectDetector.js +13 -5
  81. package/dist/components/ViroParticleEmitter.js +2 -0
  82. package/dist/components/ViroPolygon.js +1 -0
  83. package/dist/components/ViroQuad.js +1 -0
  84. package/dist/components/ViroSurface.js +1 -0
  85. package/dist/components/ViroVideo.js +1 -0
  86. package/dist/components/ViroXRSceneNavigator.d.ts +8 -0
  87. package/dist/index.d.ts +4 -4
  88. package/dist/index.js +2 -1
  89. package/dist/plugins/withViroAndroid.js +18 -0
  90. package/index.ts +4 -3
  91. package/ios/dist/ViroRenderer/ViroKit.framework/ARCoreCoreMLSemanticsResources.bundle/Info.plist +0 -0
  92. package/ios/dist/ViroRenderer/ViroKit.framework/ARCoreResources.bundle/Info.plist +0 -0
  93. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARFrameiOS.h +8 -0
  94. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARSessioniOS.h +14 -3
  95. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROFrontCameraProvider.h +34 -0
  96. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROViewAR.h +3 -1
  97. package/ios/dist/ViroRenderer/ViroKit.framework/Info.plist +0 -0
  98. package/ios/dist/ViroRenderer/ViroKit.framework/Shaders.dat +1 -1
  99. package/ios/dist/ViroRenderer/ViroKit.framework/ViroKit +0 -0
  100. package/ios/dist/lib/libViroReact.a +0 -0
  101. package/package.json +11 -11
@@ -0,0 +1,141 @@
1
+ import { GlobalListeners, isDev } from "./utils";
2
+
3
+ /**
4
+ * Last-resort cap on how long a step waits for a non-looping PLAY to finish. A
5
+ * clip that fails to load fires onError (released at once); a clip whose native
6
+ * finish/error event is dropped or never arrives would otherwise stall the walk
7
+ * forever, so the manager force-releases the waiter after this many ms.
8
+ * Generous on purpose: it must outlast any realistic single clip so it never
9
+ * cuts one short, only catches a genuine stall.
10
+ */
11
+ export const SOUND_WAIT_BACKSTOP_MS = 5 * 60 * 1000;
12
+
13
+ /** One actively-playing sound, keyed in the manager by its monotonic playId. */
14
+ export type StudioSoundEntry = {
15
+ playId: number;
16
+ audioAssetId: string;
17
+ url: string;
18
+ position?: [number, number, number];
19
+ volume: number;
20
+ loop: boolean;
21
+ };
22
+
23
+ /**
24
+ * Per-scene sound store. PLAY adds an entry under a fresh playId; STOP removes
25
+ * by audio asset id (null = all). The whole <StudioSounds> list re-renders on
26
+ * any change, so subscribers are GLOBAL like StudioVariableStore (not per-key).
27
+ */
28
+ export class StudioSoundManager {
29
+ private sounds = new Map<number, StudioSoundEntry>();
30
+ private listeners = new GlobalListeners();
31
+ private nextPlayId = 1;
32
+ // Per-playId completion callback for a step waiting on a non-looping PLAY.
33
+ // Fired on natural finish (via remove) AND when the sound is cut short by a
34
+ // stop/stopOthers/reset, so a waited-on sound never stalls the walk.
35
+ private finishCallbacks = new Map<number, () => void>();
36
+ // Backstop timer per waited playId; cleared whenever its callback fires so a
37
+ // sound whose native finish/error event never arrives can't stall the walk.
38
+ private finishTimers = new Map<number, ReturnType<typeof setTimeout>>();
39
+
40
+ /** Subscribe to any add/remove; returns an unsubscribe fn. */
41
+ subscribe(listener: () => void): () => void {
42
+ return this.listeners.subscribe(listener);
43
+ }
44
+
45
+ getActive(): StudioSoundEntry[] {
46
+ return [...this.sounds.values()];
47
+ }
48
+
49
+ /** Pull and invoke the stored completion callback (if any) for a playId. */
50
+ private fire(playId: number): void {
51
+ const timer = this.finishTimers.get(playId);
52
+ if (timer !== undefined) {
53
+ clearTimeout(timer);
54
+ this.finishTimers.delete(playId);
55
+ }
56
+ const cb = this.finishCallbacks.get(playId);
57
+ if (!cb) return;
58
+ this.finishCallbacks.delete(playId);
59
+ cb();
60
+ }
61
+
62
+ /**
63
+ * Adds a sound and returns its playId. onFinish (when given) resolves a step
64
+ * waiting on a non-looping PLAY; it fires on natural finish or early stop.
65
+ */
66
+ play(
67
+ entry: {
68
+ audioAssetId: string;
69
+ url: string;
70
+ position?: [number, number, number];
71
+ volume: number;
72
+ loop: boolean;
73
+ stopOthers: boolean;
74
+ },
75
+ onFinish?: () => void
76
+ ): number {
77
+ // stopOthers clears the live list; fire any pending waiters for the cleared
78
+ // entries so a displaced waited-on sound resolves instead of stalling.
79
+ if (entry.stopOthers) {
80
+ const cleared = [...this.sounds.keys()];
81
+ this.sounds.clear();
82
+ for (const id of cleared) this.fire(id);
83
+ }
84
+ const playId = this.nextPlayId++;
85
+ this.sounds.set(playId, {
86
+ playId,
87
+ audioAssetId: entry.audioAssetId,
88
+ url: entry.url,
89
+ position: entry.position,
90
+ volume: entry.volume,
91
+ loop: entry.loop,
92
+ });
93
+ if (onFinish) {
94
+ this.finishCallbacks.set(playId, onFinish);
95
+ // Non-looping waited sound: arm the stall backstop. A looping sound is
96
+ // never awaited, so it gets no timer.
97
+ if (!entry.loop) {
98
+ this.finishTimers.set(
99
+ playId,
100
+ setTimeout(() => this.remove(playId), SOUND_WAIT_BACKSTOP_MS)
101
+ );
102
+ }
103
+ }
104
+ if (isDev()) {
105
+ console.log(`[Studio] Sound play "${entry.audioAssetId}" (#${playId})`);
106
+ }
107
+ this.listeners.notify();
108
+ return playId;
109
+ }
110
+
111
+ /** null = stop all sounds; otherwise stop every entry for this audio asset. */
112
+ stop(audioAssetId: string | null): void {
113
+ const removed: number[] = [];
114
+ for (const [id, e] of this.sounds) {
115
+ if (audioAssetId === null || e.audioAssetId === audioAssetId) {
116
+ this.sounds.delete(id);
117
+ removed.push(id);
118
+ }
119
+ }
120
+ this.listeners.notify();
121
+ // Resolve any waiters cut short by the stop.
122
+ for (const id of removed) this.fire(id);
123
+ }
124
+
125
+ /** Drop one entry; onFinish calls this for non-looping sounds. */
126
+ remove(playId: number): void {
127
+ if (this.sounds.delete(playId)) this.listeners.notify();
128
+ // Fire the stored callback whether or not the entry was still present
129
+ // (natural finish path: resolves the waiting step).
130
+ this.fire(playId);
131
+ }
132
+
133
+ reset(): void {
134
+ const pending = [...this.finishCallbacks.keys()];
135
+ this.sounds.clear();
136
+ this.listeners.notify();
137
+ // Belt-and-suspenders: the runtime's generation guard no-ops these after a
138
+ // scene-change cancelAll, but fire so nothing leaks on bare resets.
139
+ for (const id of pending) this.fire(id);
140
+ }
141
+ }
@@ -0,0 +1,50 @@
1
+ // Small shared helpers for the Studio runtime domain.
2
+
3
+ /** True only in a dev build; gates verbose runtime logging in the Studio stores. */
4
+ export const isDev = (): boolean =>
5
+ typeof __DEV__ !== "undefined" && __DEV__;
6
+
7
+ /** A set of subscribers notified together (variable + sound stores). */
8
+ export class GlobalListeners {
9
+ private listeners = new Set<() => void>();
10
+
11
+ subscribe(listener: () => void): () => void {
12
+ this.listeners.add(listener);
13
+ return () => {
14
+ this.listeners.delete(listener);
15
+ };
16
+ }
17
+
18
+ notify(): void {
19
+ // Snapshot first: a listener may (un)subscribe during its own callback.
20
+ [...this.listeners].forEach((fn) => fn());
21
+ }
22
+ }
23
+
24
+ /** Subscribers partitioned by key; notify(key) wakes only that key's set
25
+ * (the visibility store's per-asset variant). */
26
+ export class KeyedListeners {
27
+ private listeners = new Map<string, Set<() => void>>();
28
+
29
+ subscribe(key: string, listener: () => void): () => void {
30
+ let set = this.listeners.get(key);
31
+ if (!set) {
32
+ set = new Set();
33
+ this.listeners.set(key, set);
34
+ }
35
+ set.add(listener);
36
+ return () => {
37
+ set?.delete(listener);
38
+ };
39
+ }
40
+
41
+ notify(key: string): void {
42
+ // Snapshot first: a listener may (un)subscribe during its own callback.
43
+ const set = this.listeners.get(key);
44
+ if (set) [...set].forEach((fn) => fn());
45
+ }
46
+
47
+ notifyAll(): void {
48
+ this.listeners.forEach((set) => [...set].forEach((fn) => fn()));
49
+ }
50
+ }
@@ -0,0 +1,74 @@
1
+ import { StudioSceneVariable } from "../types";
2
+ import {
3
+ StudioVariableValue,
4
+ valueMatchesType,
5
+ } from "./expressionEvaluator";
6
+ import { GlobalListeners, isDev } from "./utils";
7
+
8
+ /**
9
+ * Per-session variable store. One instance is owned by the navigator and
10
+ * survives scene pushes; it resets only when the AR/VR session ends.
11
+ *
12
+ * Identity is by NAME: scenes declaring the same name share one value, and
13
+ * seed() only initialises names not already present (initialize-if-absent),
14
+ * so values carry across scene transitions.
15
+ */
16
+ export class StudioVariableStore {
17
+ private values = new Map<string, StudioVariableValue>();
18
+ private listeners = new GlobalListeners();
19
+
20
+ get(name: string): StudioVariableValue | undefined {
21
+ return this.values.get(name);
22
+ }
23
+
24
+ /** Subscribe to value changes (set/reset); returns an unsubscribe fn. */
25
+ subscribe(listener: () => void): () => void {
26
+ return this.listeners.subscribe(listener);
27
+ }
28
+
29
+ set(name: string, value: StudioVariableValue): void {
30
+ // Values are primitives; skip the no-op write so unchanged values don't
31
+ // log or wake subscribers.
32
+ if (Object.is(this.values.get(name), value)) return;
33
+ this.values.set(name, value);
34
+ if (isDev()) {
35
+ console.log(`[Studio] Variable "${name}" =`, value);
36
+ }
37
+ this.listeners.notify();
38
+ }
39
+
40
+ seed(declarations: StudioSceneVariable[]): void {
41
+ for (const decl of declarations) {
42
+ if (!decl?.name) continue;
43
+ if (!valueMatchesType(decl.initial_value, decl.type)) {
44
+ console.warn(
45
+ `[Studio] Variable "${decl.name}": initial value does not match type ${decl.type}; skipping seed.`
46
+ );
47
+ continue;
48
+ }
49
+ const existing = this.values.get(decl.name);
50
+ if (existing !== undefined) {
51
+ if (!valueMatchesType(existing, decl.type)) {
52
+ console.warn(
53
+ `[Studio] Variable "${decl.name}" already holds a ${typeof existing} this session, but this scene declares ${decl.type}; keeping the existing value.`
54
+ );
55
+ }
56
+ continue;
57
+ }
58
+ this.values.set(decl.name, decl.initial_value);
59
+ }
60
+ }
61
+
62
+ reset(): void {
63
+ this.values.clear();
64
+ this.listeners.notify();
65
+ }
66
+
67
+ snapshot(): Record<string, StudioVariableValue> {
68
+ const out: Record<string, StudioVariableValue> = {};
69
+ this.values.forEach((value, name) => {
70
+ out[name] = value;
71
+ });
72
+ return out;
73
+ }
74
+ }
@@ -4,11 +4,25 @@ import { Viro3DObject } from "../../Viro3DObject";
4
4
  import { ViroImage } from "../../ViroImage";
5
5
  import { ViroText } from "../../ViroText";
6
6
  import { ViroVideo } from "../../ViroVideo";
7
- import { StudioAnimation, StudioAsset, StudioSceneMeta, ViroAnimationProp } from "../types";
8
- import { executeFunctionWithRelations } from "./sceneNavigationHandler";
7
+ import {
8
+ StudioAnimation,
9
+ StudioAsset,
10
+ StudioSceneMeta,
11
+ ViroAnimationProp,
12
+ } from "../types";
13
+ import {
14
+ executeFunctionWithRelations,
15
+ SequenceRuntimeContext,
16
+ } from "./sceneNavigationHandler";
17
+ import {
18
+ extractPlaceholders,
19
+ interpolateDisplayTemplate,
20
+ } from "./apiRequestHelpers";
9
21
  import { parseMaterialConfig, studioMaterialName } from "./materialConfig";
10
22
  import { DragConfiguration } from "./dragConfiguration";
11
23
  import { buildViroPhysicsBody, parsePhysicsBodyConfig } from "./physicsConfig";
24
+ import { StudioVariableStore } from "./variableStore";
25
+ import { StudioVisibilityStore } from "./visibilityStore";
12
26
 
13
27
  type SceneNavigator = any;
14
28
 
@@ -16,18 +30,23 @@ export type NodeConfig = {
16
30
  position: [number, number, number];
17
31
  rotation: [number, number, number];
18
32
  scale: [number, number, number];
19
- dragType?: "FixedDistance" | "FixedDistanceOrigin" | "FixedToWorld" | "FixedToPlane";
20
- dragPlane?: { planePoint: [number, number, number]; planeNormal: [number, number, number]; maxDistance: number };
33
+ dragType?:
34
+ | "FixedDistance"
35
+ | "FixedDistanceOrigin"
36
+ | "FixedToWorld"
37
+ | "FixedToPlane";
38
+ dragPlane?: {
39
+ planePoint: [number, number, number];
40
+ planeNormal: [number, number, number];
41
+ maxDistance: number;
42
+ };
21
43
  physicsBody?: Record<string, unknown>;
22
44
  viroTag?: string;
23
45
  onClick?: () => void;
24
46
  animation?: ViroAnimationProp;
25
47
  };
26
48
 
27
- /**
28
- * Derives the transform config for an asset.
29
- * Clamps Z to -2 for non-trigger assets to guarantee visibility.
30
- */
49
+ /** Clamps Z to -2 for non-trigger assets to guarantee visibility. */
31
50
  export function createNodeConfig(
32
51
  asset: StudioAsset,
33
52
  sceneNavigator: SceneNavigator | undefined,
@@ -35,7 +54,8 @@ export function createNodeConfig(
35
54
  scene: StudioSceneMeta | null,
36
55
  onAnimationTrigger?: (targetAssetId: string, animKey: string) => void,
37
56
  animationStates?: Record<string, ViroAnimationProp>,
38
- onSceneChange?: (sceneId: string, sceneName: string) => void
57
+ onSceneChange?: (sceneId: string, sceneName: string) => void,
58
+ runtimeCtx?: SequenceRuntimeContext
39
59
  ): NodeConfig {
40
60
  const hasTriggerImage = !!asset.trigger_image_url;
41
61
 
@@ -82,12 +102,14 @@ export function createNodeConfig(
82
102
  if (dragType === "FixedToPlane") {
83
103
  dragPlane = DragConfiguration.getDragPlane(
84
104
  scene?.plane_direction ?? "Horizontal",
85
- position,
105
+ position
86
106
  );
87
107
  }
88
108
 
89
109
  const parsedPhysics = parsePhysicsBodyConfig(asset.physics_config);
90
- const physicsBody = parsedPhysics ? buildViroPhysicsBody(parsedPhysics) : undefined;
110
+ const physicsBody = parsedPhysics
111
+ ? buildViroPhysicsBody(parsedPhysics)
112
+ : undefined;
91
113
  const viroTag = parsedPhysics ? asset.id : undefined;
92
114
 
93
115
  const onClick = createOnClickHandler(
@@ -95,12 +117,23 @@ export function createNodeConfig(
95
117
  sceneNavigator,
96
118
  animations,
97
119
  onAnimationTrigger,
98
- onSceneChange
120
+ onSceneChange,
121
+ runtimeCtx
99
122
  );
100
123
 
101
124
  const animation = animationStates?.[asset.id];
102
125
 
103
- return { position, rotation, scale, dragType, dragPlane, physicsBody, viroTag, onClick, animation };
126
+ return {
127
+ position,
128
+ rotation,
129
+ scale,
130
+ dragType,
131
+ dragPlane,
132
+ physicsBody,
133
+ viroTag,
134
+ onClick,
135
+ animation,
136
+ };
104
137
  }
105
138
 
106
139
  function createOnClickHandler(
@@ -108,13 +141,16 @@ function createOnClickHandler(
108
141
  sceneNavigator: SceneNavigator | undefined,
109
142
  animations: StudioAnimation[],
110
143
  onAnimationTrigger?: (targetAssetId: string, animKey: string) => void,
111
- onSceneChange?: (sceneId: string, sceneName: string) => void
144
+ onSceneChange?: (sceneId: string, sceneName: string) => void,
145
+ runtimeCtx?: SequenceRuntimeContext
112
146
  ): (() => void) | undefined {
113
147
  const fn = asset.scene_function;
114
148
  if (!fn) return undefined;
115
149
 
116
150
  if (fn.function_type === "NAVIGATION" && !fn.scene_navigation?.navigate_to) {
117
- console.warn(`[Studio] Asset "${asset.name}" has NAVIGATION but no target scene`);
151
+ console.warn(
152
+ `[Studio] Asset "${asset.name}" has NAVIGATION but no target scene`
153
+ );
118
154
  return undefined;
119
155
  }
120
156
  if (fn.function_type === "ALERT" && !fn.scene_alert) {
@@ -122,22 +158,30 @@ function createOnClickHandler(
122
158
  return undefined;
123
159
  }
124
160
  if (fn.function_type === "ANIMATION" && !fn.scene_animation) {
125
- console.warn(`[Studio] Asset "${asset.name}" has ANIMATION but no animation data`);
161
+ console.warn(
162
+ `[Studio] Asset "${asset.name}" has ANIMATION but no animation data`
163
+ );
126
164
  return undefined;
127
165
  }
128
166
 
129
167
  return () =>
130
- executeFunctionWithRelations(fn, sceneNavigator, animations, onAnimationTrigger, 0, onSceneChange);
168
+ executeFunctionWithRelations(
169
+ fn,
170
+ sceneNavigator,
171
+ animations,
172
+ onAnimationTrigger,
173
+ 0,
174
+ onSceneChange,
175
+ runtimeCtx
176
+ );
131
177
  }
132
178
 
133
- /** Resolves asset type from asset_type_name. */
134
179
  function resolveType(
135
180
  asset: StudioAsset
136
181
  ): "3D-MODEL" | "TEXT" | "IMAGE" | "VIDEO" | null {
137
182
  return asset.asset_type_name ?? null;
138
183
  }
139
184
 
140
- /** Infers 3D model format from file extension. */
141
185
  function inferModelType(url: string): "GLB" | "GLTF" | "OBJ" | "VRX" {
142
186
  const ext = url.toLowerCase().split(".").pop();
143
187
  if (ext === "gltf") return "GLTF";
@@ -150,7 +194,11 @@ function create3DObject(
150
194
  asset: StudioAsset,
151
195
  config: NodeConfig,
152
196
  onAssetLoaded?: (id: string) => void,
153
- onCollision?: (viroTag: string, collidedPoint: [number, number, number], collidedNormal: [number, number, number]) => void
197
+ onCollision?: (
198
+ viroTag: string,
199
+ collidedPoint: [number, number, number],
200
+ collidedNormal: [number, number, number]
201
+ ) => void
154
202
  ): React.ReactElement | null {
155
203
  if (!asset.file_url) {
156
204
  console.warn(`[Studio] 3D model "${asset.name}" has no file_url`);
@@ -170,7 +218,9 @@ function create3DObject(
170
218
  : config.scale;
171
219
 
172
220
  const hasMaterialConfig = parseMaterialConfig(asset.material_config) !== null;
173
- const shaderOverrides = hasMaterialConfig ? [studioMaterialName(asset.id)] : undefined;
221
+ const shaderOverrides = hasMaterialConfig
222
+ ? [studioMaterialName(asset.id)]
223
+ : undefined;
174
224
 
175
225
  return (
176
226
  <Viro3DObject
@@ -193,7 +243,9 @@ function create3DObject(
193
243
  // the drag recognizer is never attached, even when dragType is set.
194
244
  {...(config.dragType ? { onDrag: () => {} } : {})}
195
245
  {...(shaderOverrides ? { shaderOverrides } : {})}
196
- {...(config.physicsBody ? { physicsBody: config.physicsBody as any, viroTag: config.viroTag } : {})}
246
+ {...(config.physicsBody
247
+ ? { physicsBody: config.physicsBody as any, viroTag: config.viroTag }
248
+ : {})}
197
249
  {...(onCollision ? { onCollision: onCollision as any } : {})}
198
250
  />
199
251
  );
@@ -220,28 +272,51 @@ function createImage(
220
272
  animation={config.animation as any}
221
273
  onClick={config.onClick}
222
274
  onLoadEnd={() => onAssetLoaded?.(asset.id)}
223
- onError={(e) =>
224
- console.error(`[Studio] Image "${asset.name}" error:`, e)
225
- }
275
+ onError={(e) => console.error(`[Studio] Image "${asset.name}" error:`, e)}
226
276
  {...(config.dragType ? { onDrag: () => {} } : {})}
227
277
  />
228
278
  );
229
279
  }
230
280
 
231
- function createText(
232
- asset: StudioAsset,
233
- config: NodeConfig
234
- ): React.ReactElement {
281
+ /**
282
+ * TEXT node whose content is a {{variable}} template (the asset name). It
283
+ * re-interpolates and repaints whenever a referenced variable changes (and only
284
+ * subscribes when the template actually has placeholders). Resolution is
285
+ * fail-soft: unknown names stay literal.
286
+ */
287
+ const VariableText: React.FC<{
288
+ asset: StudioAsset;
289
+ config: NodeConfig;
290
+ store?: StudioVariableStore;
291
+ // Injected by VisibleNode via cloneElement; TEXT is the only node type that
292
+ // is a component wrapper, so it forwards visibility to its ViroText.
293
+ visible?: boolean;
294
+ }> = ({ asset, config, store, visible }) => {
295
+ const template = asset.name ?? "";
296
+ const compute = () =>
297
+ store
298
+ ? interpolateDisplayTemplate(template, (n) => store.get(n))
299
+ : template;
300
+ const [text, setText] = React.useState(compute);
301
+
302
+ React.useEffect(() => {
303
+ if (!store || extractPlaceholders(template).length === 0) return;
304
+ // Resync any write that landed between first render and subscribe.
305
+ setText(compute());
306
+ return store.subscribe(() => setText(compute()));
307
+ // eslint-disable-next-line react-hooks/exhaustive-deps
308
+ }, [store, template]);
309
+
235
310
  return (
236
311
  <ViroText
237
- key={asset.id}
238
- text={asset.name ?? ""}
312
+ text={text}
239
313
  position={config.position}
240
314
  rotation={config.rotation}
241
315
  scale={config.scale}
242
316
  dragType={config.dragType}
243
317
  animation={config.animation as any}
244
318
  onClick={config.onClick}
319
+ {...(visible === undefined ? {} : { visible })}
245
320
  style={{
246
321
  fontFamily: "Arial",
247
322
  fontSize: 20,
@@ -251,6 +326,42 @@ function createText(
251
326
  {...(config.dragType ? { onDrag: () => {} } : {})}
252
327
  />
253
328
  );
329
+ };
330
+
331
+ /**
332
+ * Wraps a created node and drives its `visible` prop from the per-scene
333
+ * visibility store, subscribing to its own asset so a Set Visibility action
334
+ * repaints only this node. Without a store, the node stays visible.
335
+ */
336
+ const VisibleNode: React.FC<{
337
+ assetId: string;
338
+ store?: StudioVisibilityStore;
339
+ // Props typed loosely so cloneElement can inject `visible` (all Viro node
340
+ // types accept it via ViroCommonProps).
341
+ children: React.ReactElement<any>;
342
+ }> = ({ assetId, store, children }) => {
343
+ const [visible, setVisible] = React.useState(
344
+ () => store?.isVisible(assetId) ?? true
345
+ );
346
+
347
+ React.useEffect(() => {
348
+ if (!store) return;
349
+ // Resync any write that landed between first render and subscribe.
350
+ setVisible(store.isVisible(assetId));
351
+ return store.subscribe(assetId, () => setVisible(store.isVisible(assetId)));
352
+ }, [store, assetId]);
353
+
354
+ return React.cloneElement(children, { visible });
355
+ };
356
+
357
+ function createText(
358
+ asset: StudioAsset,
359
+ config: NodeConfig,
360
+ store?: StudioVariableStore
361
+ ): React.ReactElement {
362
+ return (
363
+ <VariableText key={asset.id} asset={asset} config={config} store={store} />
364
+ );
254
365
  }
255
366
 
256
367
  function createVideo(
@@ -274,17 +385,12 @@ function createVideo(
274
385
  onClick={config.onClick}
275
386
  loop={true}
276
387
  muted={false}
277
- onError={(e) =>
278
- console.error(`[Studio] Video "${asset.name}" error:`, e)
279
- }
388
+ onError={(e) => console.error(`[Studio] Video "${asset.name}" error:`, e)}
280
389
  {...(config.dragType ? { onDrag: () => {} } : {})}
281
390
  />
282
391
  );
283
392
  }
284
393
 
285
- /**
286
- * Creates the appropriate Viro component for a StudioAsset.
287
- */
288
394
  export function createNode(
289
395
  asset: StudioAsset,
290
396
  sceneNavigator: SceneNavigator | undefined,
@@ -293,8 +399,13 @@ export function createNode(
293
399
  onAnimationTrigger?: (targetAssetId: string, animKey: string) => void,
294
400
  animationStates?: Record<string, ViroAnimationProp>,
295
401
  onAssetLoaded?: (id: string) => void,
296
- onCollision?: (viroTag: string, collidedPoint: [number, number, number], collidedNormal: [number, number, number]) => void,
297
- onSceneChange?: (sceneId: string, sceneName: string) => void
402
+ onCollision?: (
403
+ viroTag: string,
404
+ collidedPoint: [number, number, number],
405
+ collidedNormal: [number, number, number]
406
+ ) => void,
407
+ onSceneChange?: (sceneId: string, sceneName: string) => void,
408
+ runtimeCtx?: SequenceRuntimeContext
298
409
  ): React.ReactElement | null {
299
410
  const type = resolveType(asset);
300
411
  const config = createNodeConfig(
@@ -304,20 +415,40 @@ export function createNode(
304
415
  scene,
305
416
  onAnimationTrigger,
306
417
  animationStates,
307
- onSceneChange
418
+ onSceneChange,
419
+ runtimeCtx
308
420
  );
309
421
 
422
+ let node: React.ReactElement | null;
310
423
  switch (type) {
311
424
  case "3D-MODEL":
312
- return create3DObject(asset, config, onAssetLoaded, onCollision);
425
+ node = create3DObject(asset, config, onAssetLoaded, onCollision);
426
+ break;
313
427
  case "IMAGE":
314
- return createImage(asset, config, onAssetLoaded);
428
+ node = createImage(asset, config, onAssetLoaded);
429
+ break;
315
430
  case "TEXT":
316
- return createText(asset, config);
431
+ node = createText(asset, config, runtimeCtx?.variableStore);
432
+ break;
317
433
  case "VIDEO":
318
- return createVideo(asset, config);
434
+ node = createVideo(asset, config);
435
+ break;
319
436
  default:
320
437
  console.warn(`[Studio] Unknown asset type "${type}" for "${asset.name}"`);
321
438
  return null;
322
439
  }
440
+
441
+ if (!node) return null;
442
+
443
+ // Drive show/hide/toggle from the visibility store (Set Visibility actions);
444
+ // seeded from the asset's author-time hidden_on_load default.
445
+ return (
446
+ <VisibleNode
447
+ key={asset.id}
448
+ assetId={asset.id}
449
+ store={runtimeCtx?.visibilityStore}
450
+ >
451
+ {node}
452
+ </VisibleNode>
453
+ );
323
454
  }