@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
@@ -1,15 +1,155 @@
1
- import { Alert } from "react-native";
1
+ import { Alert, AppState } from "react-native";
2
2
  import { isQuest } from "../../Utilities/ViroPlatform";
3
- import { StudioAnimation, StudioSceneFunction, StudioSceneResponse } from "../types";
3
+ import {
4
+ StudioAnimation,
5
+ StudioApiRequestExecutor,
6
+ StudioApiRequestOutcome,
7
+ StudioSceneFunction,
8
+ StudioSceneResponse,
9
+ StudioSequenceStep,
10
+ } from "../types";
4
11
  import { VRTStudioModule } from "../VRTStudioModule";
12
+ import { applyBindings, interpolateDisplayTemplate } from "./apiRequestHelpers";
13
+ import {
14
+ evaluate,
15
+ evaluateBranchCondition,
16
+ parseExpression,
17
+ valueMatchesType,
18
+ } from "./expressionEvaluator";
19
+ import { StudioSoundManager } from "./soundManager";
20
+ import { StudioVariableStore } from "./variableStore";
21
+ import { StudioVisibilityStore } from "./visibilityStore";
5
22
 
6
23
  type SceneNavigator = any; // ViroARSceneNavigator navigator object passed to AR scenes
7
24
 
8
25
  const ANIMATION_CHAIN_MAX_DEPTH = 10;
26
+ // The proxy enforces the authored timeout server-side; the client backstop
27
+ // only covers an unreachable/unresponsive proxy.
28
+ const API_REQUEST_CLIENT_GRACE_MS = 5000;
9
29
 
10
30
  /**
11
- * Resolves a scene function by ID from a flat list.
31
+ * Non-blocking, cancellable timer pool driving Sequence WAIT steps.
32
+ *
33
+ * One scheduler is owned per scene (see StudioARScene). cancelAll() must run on
34
+ * scene unmount and before navigating away so a pending WAIT never fires into a
35
+ * torn-down or replaced scene.
36
+ *
37
+ * Backgrounding is PAUSE-AND-RESUME: a WAIT freezes while the app is backgrounded
38
+ * and resumes its remaining time on foreground. On mobile this falls out of the
39
+ * suspended JS thread for free; the AppState handling below covers surfaces that
40
+ * keep the JS thread alive in background (e.g. headsets).
41
+ */
42
+ type ScheduledTimer = {
43
+ callback: () => void;
44
+ remainingMs: number;
45
+ startedAt: number;
46
+ handle: ReturnType<typeof setTimeout> | null;
47
+ };
48
+
49
+ export class SequenceScheduler {
50
+ private timers = new Set<ScheduledTimer>();
51
+ private appStateSub: { remove: () => void } | null = null;
52
+ private backgrounded = false;
53
+ // Sequence ids currently mid-run. A re-trigger of an in-flight sequence is
54
+ // ignored (no stacked/overlapping runs); single actions are unaffected.
55
+ private activeSequences = new Set<string>();
56
+ // Bumped by cancelAll(). Async work (API requests) captures the value when
57
+ // it starts and drops its continuation if it changed — a late response can
58
+ // never fire into a torn-down or replaced scene.
59
+ private generationCounter = 0;
60
+
61
+ get generation(): number {
62
+ return this.generationCounter;
63
+ }
64
+
65
+ constructor() {
66
+ this.appStateSub = AppState.addEventListener("change", (state) => {
67
+ if (state === "active") this.resumeAll();
68
+ else this.pauseAll();
69
+ });
70
+ }
71
+
72
+ // Returns false if the sequence is already running (caller should skip).
73
+ beginSequence(id: string): boolean {
74
+ if (this.activeSequences.has(id)) return false;
75
+ this.activeSequences.add(id);
76
+ return true;
77
+ }
78
+
79
+ endSequence(id: string): void {
80
+ this.activeSequences.delete(id);
81
+ }
82
+
83
+ schedule(callback: () => void, ms: number): void {
84
+ const timer: ScheduledTimer = {
85
+ callback,
86
+ remainingMs: Math.max(0, ms),
87
+ startedAt: Date.now(),
88
+ handle: null,
89
+ };
90
+ this.timers.add(timer);
91
+ if (!this.backgrounded) this.arm(timer);
92
+ }
93
+
94
+ private arm(timer: ScheduledTimer): void {
95
+ timer.startedAt = Date.now();
96
+ timer.handle = setTimeout(() => {
97
+ this.timers.delete(timer);
98
+ timer.callback();
99
+ }, timer.remainingMs);
100
+ }
101
+
102
+ private pauseAll(): void {
103
+ if (this.backgrounded) return;
104
+ this.backgrounded = true;
105
+ const now = Date.now();
106
+ for (const timer of this.timers) {
107
+ if (timer.handle === null) continue;
108
+ clearTimeout(timer.handle);
109
+ timer.handle = null;
110
+ timer.remainingMs = Math.max(
111
+ 0,
112
+ timer.remainingMs - (now - timer.startedAt)
113
+ );
114
+ }
115
+ }
116
+
117
+ private resumeAll(): void {
118
+ if (!this.backgrounded) return;
119
+ this.backgrounded = false;
120
+ for (const timer of this.timers) this.arm(timer);
121
+ }
122
+
123
+ cancelAll(): void {
124
+ for (const timer of this.timers) {
125
+ if (timer.handle !== null) clearTimeout(timer.handle);
126
+ }
127
+ this.timers.clear();
128
+ this.activeSequences.clear();
129
+ this.generationCounter++;
130
+ }
131
+
132
+ dispose(): void {
133
+ this.cancelAll();
134
+ this.appStateSub?.remove();
135
+ this.appStateSub = null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Runtime context threaded through executeFunctionWithRelations: the Sequence
141
+ * scheduler plus the per-session variable store and API-request transport
142
+ * (optional so dispatch sites without them keep working).
12
143
  */
144
+ export type SequenceRuntimeContext = {
145
+ scheduler: SequenceScheduler;
146
+ variableStore?: StudioVariableStore;
147
+ apiRequestExecutor?: StudioApiRequestExecutor;
148
+ visibilityStore?: StudioVisibilityStore;
149
+ soundManager?: StudioSoundManager;
150
+ getAssetPosition?: (assetId: string) => [number, number, number] | undefined;
151
+ };
152
+
13
153
  function resolveById(
14
154
  id: string,
15
155
  fns: StudioSceneFunction[]
@@ -17,6 +157,454 @@ function resolveById(
17
157
  return fns.find((f) => f.id === id);
18
158
  }
19
159
 
160
+ /** Everything the step walker threads through; depth is the owning list's chain depth. */
161
+ type StepRunnerDeps = {
162
+ sceneNavigator: SceneNavigator | undefined;
163
+ animations: StudioAnimation[];
164
+ onAnimationTrigger?: (targetAssetId: string, animationKey: string) => void;
165
+ onSceneChange?: (sceneId: string, sceneName: string) => void;
166
+ runtimeCtx: SequenceRuntimeContext;
167
+ depth: number;
168
+ // Set true by an aborting sibling lane (STOP/NAV) so mid-flight pending work
169
+ // in OTHER lanes of the same group (WAIT timers, waitable-sound onFinish, API
170
+ // proceed) short-circuits instead of running into the halted sequence.
171
+ isCancelled?: () => boolean;
172
+ };
173
+
174
+ /**
175
+ * Walks an ordered step list with two continuations: onDone when the list
176
+ * completes, onAbort on early termination (NAVIGATION leaves the scene, STOP
177
+ * halts in place; the top caller releases its beginSequence guard either way,
178
+ * so a failed async navigation can't leave a sequence permanently blocked).
179
+ */
180
+ function runSteps(
181
+ steps: StudioSequenceStep[],
182
+ deps: StepRunnerDeps,
183
+ onDone: () => void,
184
+ onAbort: () => void
185
+ ): void {
186
+ const ordered = [...steps].sort((a, b) => a.step_order - b.step_order);
187
+ const runStep = (i: number): void => {
188
+ // A sibling lane aborting (STOP/NAV) cancels this lane too: bail before
189
+ // running any further step, including from a pending timer/async callback.
190
+ if (deps.isCancelled?.()) return;
191
+ if (i >= ordered.length) {
192
+ onDone();
193
+ return;
194
+ }
195
+ const step = ordered[i];
196
+ if (step.step_type === "WAIT") {
197
+ // Non-blocking: the rest of the list continues after the timer.
198
+ deps.runtimeCtx.scheduler.schedule(() => {
199
+ if (deps.isCancelled?.()) return;
200
+ runStep(i + 1);
201
+ }, step.duration_ms ?? 0);
202
+ return;
203
+ }
204
+ if (step.step_type === "STOP") {
205
+ // Explicit terminal: halt the whole run. onAbort is threaded unchanged
206
+ // through every nested arm (branch/api/run-sequence/group), so it skips
207
+ // the outer continuations straight to the top beginSequence guard release
208
+ // — a STOP inside an arm ends the entire sequence, not just that arm.
209
+ onAbort();
210
+ return;
211
+ }
212
+
213
+ // ACTION: dispatch the effect, then decide advance. Wait-by-default; the
214
+ // per-step advance_immediately flag opts out (fire-and-forget / overlap).
215
+ if (step.function) {
216
+ const fn = step.function;
217
+ const immediate = step.advance_immediately === true; // false = wait
218
+ let advanced = false;
219
+ const advance = (): void => {
220
+ // Single-fire guard: the outer continuation runs at most once even if
221
+ // a detached arm and the immediate path both try to advance.
222
+ if (advanced) return;
223
+ advanced = true;
224
+ runStep(i + 1);
225
+ };
226
+
227
+ // Control-flow + Group: gate on completion by default, or run detached
228
+ // (own no-op onDone, swallow its abort) and advance now when immediate.
229
+ const gateOrDetach = (
230
+ run: (onDone: () => void, onAbort: () => void) => void
231
+ ): void => {
232
+ if (immediate) {
233
+ run(
234
+ () => {},
235
+ () => {}
236
+ );
237
+ advance();
238
+ } else {
239
+ // Gate: outer resumes on the arm/lane onDone; a STOP/NAV inside still
240
+ // bubbles to the top via onAbort.
241
+ run(advance, onAbort);
242
+ }
243
+ };
244
+
245
+ if (fn.function_type === "BRANCH") {
246
+ gateOrDetach((d, a) => runBranch(fn, deps, d, a));
247
+ return;
248
+ }
249
+ if (fn.function_type === "API_REQUEST") {
250
+ gateOrDetach((d, a) => runApiRequest(fn, deps, d, a));
251
+ return;
252
+ }
253
+ if (fn.function_type === "SEQUENCE") {
254
+ gateOrDetach((d, a) => runReferencedSequence(fn, deps, d, a));
255
+ return;
256
+ }
257
+ if (fn.function_type === "GROUP") {
258
+ gateOrDetach((d, a) => runGroup(fn, deps, d, a));
259
+ return;
260
+ }
261
+
262
+ // SOUND: a non-looping PLAY is waitable (the clip ends); looping PLAY and
263
+ // STOP are always instant. Dispatch the waitable case inline so the
264
+ // manager's onFinish can resume the walk; everything else goes through
265
+ // the single dispatcher.
266
+ if (fn.function_type === "SOUND") {
267
+ const s = fn.scene_sound;
268
+ const manager = deps.runtimeCtx.soundManager;
269
+ const waitable =
270
+ !!s && s.action === "PLAY" && !s.loop && !immediate && !!manager;
271
+ if (waitable && s && manager) {
272
+ if (!s.audio_url) {
273
+ // Missing/cross-org clip (resolve RPC nulls the url org-guarded):
274
+ // nothing to play or wait on; continue.
275
+ console.warn(
276
+ `[Studio] SOUND function ${fn.id}: PLAY has no audio_url (missing/cross-org clip); skipping.`
277
+ );
278
+ advance();
279
+ return;
280
+ }
281
+ const position = s.target_asset_id
282
+ ? deps.runtimeCtx.getAssetPosition?.(s.target_asset_id)
283
+ : undefined;
284
+ const scheduler = deps.runtimeCtx.scheduler;
285
+ const generation = scheduler.generation;
286
+ manager.play(
287
+ {
288
+ audioAssetId: s.audio_asset_id ?? "",
289
+ url: s.audio_url,
290
+ position,
291
+ volume: s.volume,
292
+ loop: s.loop,
293
+ stopOthers: s.stop_other_sounds,
294
+ },
295
+ () => {
296
+ // Drop a stale advance after scene-change/abort, like runApiRequest.
297
+ if (scheduler.generation !== generation) return;
298
+ // A sibling lane aborted while this clip was playing: don't advance.
299
+ if (deps.isCancelled?.()) return;
300
+ advance();
301
+ }
302
+ );
303
+ return;
304
+ }
305
+ // Loop PLAY / STOP / no manager: fire-and-forget through the dispatcher.
306
+ executeFunctionWithRelations(
307
+ fn,
308
+ deps.sceneNavigator,
309
+ deps.animations,
310
+ deps.onAnimationTrigger,
311
+ deps.depth + 1,
312
+ deps.onSceneChange,
313
+ deps.runtimeCtx
314
+ );
315
+ advance();
316
+ return;
317
+ }
318
+
319
+ // All remaining types dispatch synchronously through the single dispatcher.
320
+ executeFunctionWithRelations(
321
+ fn,
322
+ deps.sceneNavigator,
323
+ deps.animations,
324
+ deps.onAnimationTrigger,
325
+ deps.depth + 1,
326
+ deps.onSceneChange,
327
+ deps.runtimeCtx
328
+ );
329
+
330
+ // A step list is scoped to one scene. NAVIGATION leaves it, so the walk
331
+ // ends here; remaining steps belong to the scene we just left.
332
+ // Author follow-on steps as the target scene's on_load sequence.
333
+ if (fn.function_type === "NAVIGATION") {
334
+ onAbort();
335
+ return;
336
+ }
337
+
338
+ // ANIMATION: hold the walk for the animation's run time so later steps
339
+ // begin when it finishes, not when it starts (unless opted out).
340
+ if (fn.function_type === "ANIMATION") {
341
+ if (immediate) {
342
+ advance();
343
+ return;
344
+ }
345
+ const anim = fn.scene_animation;
346
+ const runMs = (anim?.delay_ms ?? 0) + (anim?.duration_ms ?? 0);
347
+ deps.runtimeCtx.scheduler.schedule(advance, runMs);
348
+ return;
349
+ }
350
+
351
+ // SET_VARIABLE / SET_VISIBILITY / ALERT: instant.
352
+ advance();
353
+ return;
354
+ }
355
+ runStep(i + 1);
356
+ };
357
+ runStep(0);
358
+ }
359
+
360
+ /**
361
+ * Evaluates a BRANCH's conditions in eval order (first match wins) and runs the
362
+ * matched arm, or the no-match arm if none match, like a nested sequence.
363
+ * Failure policy: a condition that fails to evaluate warns and is treated as
364
+ * not matched (fall through to the next condition); never throws.
365
+ */
366
+ function runBranch(
367
+ fn: StudioSceneFunction,
368
+ deps: StepRunnerDeps,
369
+ onDone: () => void,
370
+ onAbort: () => void
371
+ ): void {
372
+ const branch = fn.scene_branch;
373
+ if (!branch) {
374
+ onDone();
375
+ return;
376
+ }
377
+ const branchDepth = deps.depth + 1;
378
+ if (branchDepth > ANIMATION_CHAIN_MAX_DEPTH) {
379
+ console.warn(
380
+ `[Studio] Max chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for branch ${branch.id}.`
381
+ );
382
+ onDone();
383
+ return;
384
+ }
385
+ const store = deps.runtimeCtx.variableStore;
386
+ if (!store) {
387
+ console.warn(
388
+ `[Studio] BRANCH function ${fn.id} needs a runtime context (variable store); skipping.`
389
+ );
390
+ onDone();
391
+ return;
392
+ }
393
+ const conditions = [...branch.conditions].sort(
394
+ (a, b) => a.eval_order - b.eval_order
395
+ );
396
+ for (const condition of conditions) {
397
+ const result = evaluateBranchCondition(
398
+ {
399
+ comparison: condition.comparison,
400
+ variable_name: condition.variable_name,
401
+ compare_literal: condition.compare_literal,
402
+ compare_variable_name: condition.compare_variable_name,
403
+ },
404
+ (name) => store.get(name)
405
+ );
406
+ if (!result.ok) {
407
+ console.warn(
408
+ `[Studio] BRANCH ${branch.id} condition ${condition.eval_order}: ${result.error}; treating as not matched.`
409
+ );
410
+ continue;
411
+ }
412
+ if (result.value) {
413
+ runSteps(
414
+ condition.sequence.steps,
415
+ { ...deps, depth: branchDepth },
416
+ onDone,
417
+ onAbort
418
+ );
419
+ return;
420
+ }
421
+ }
422
+ // No condition matched: run the no-match arm if present, else continue.
423
+ const arm = branch.no_match_sequence;
424
+ if (!arm) {
425
+ onDone();
426
+ return;
427
+ }
428
+ runSteps(arm.steps, { ...deps, depth: branchDepth }, onDone, onAbort);
429
+ }
430
+
431
+ /**
432
+ * Runs a named SEQUENCE function's steps inline as a Run Sequence step. Unlike
433
+ * a trigger-dispatched sequence it is NOT begin-guarded (it composes like a
434
+ * branch arm); the depth guard bounds reference chains the editor's cycle
435
+ * filter and the resolve RPC also defend against.
436
+ */
437
+ function runReferencedSequence(
438
+ fn: StudioSceneFunction,
439
+ deps: StepRunnerDeps,
440
+ onDone: () => void,
441
+ onAbort: () => void
442
+ ): void {
443
+ const seq = fn.scene_sequence;
444
+ if (!seq) {
445
+ onDone();
446
+ return;
447
+ }
448
+ const seqDepth = deps.depth + 1;
449
+ if (seqDepth > ANIMATION_CHAIN_MAX_DEPTH) {
450
+ console.warn(
451
+ `[Studio] Max chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for sequence ${seq.id}.`
452
+ );
453
+ onDone();
454
+ return;
455
+ }
456
+ runSteps(seq.steps, { ...deps, depth: seqDepth }, onDone, onAbort);
457
+ }
458
+
459
+ /**
460
+ * Runs a GROUP's lanes concurrently and completes when ALL lanes complete
461
+ * (barrier join). Each lane is an owned headless sequence run like a nested
462
+ * sequence. A NAVIGATION/STOP inside any lane ends the whole walk via onAbort;
463
+ * the aborted flag stops a late laneDone from also firing onDone afterwards.
464
+ * Fire-and-forget lanes (all-immediate steps) call laneDone synchronously, so a
465
+ * single-instant-step lane never deadlocks.
466
+ */
467
+ function runGroup(
468
+ fn: StudioSceneFunction,
469
+ deps: StepRunnerDeps,
470
+ onDone: () => void,
471
+ onAbort: () => void
472
+ ): void {
473
+ const group = fn.scene_group;
474
+ if (!group || group.lanes.length === 0) {
475
+ onDone();
476
+ return;
477
+ }
478
+ const groupDepth = deps.depth + 1;
479
+ if (groupDepth > ANIMATION_CHAIN_MAX_DEPTH) {
480
+ console.warn(
481
+ `[Studio] Max chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for group ${group.id}.`
482
+ );
483
+ onDone();
484
+ return;
485
+ }
486
+ let remaining = group.lanes.length;
487
+ let aborted = false;
488
+ // Threaded into every lane's walk so an abort in one lane also halts the
489
+ // pending async work (timers, sound onFinish, API proceed) of its siblings,
490
+ // not just the join below. Also honours an outer cancel (nested groups).
491
+ let cancelled = false;
492
+ const isCancelled = (): boolean => cancelled || !!deps.isCancelled?.();
493
+ const laneDone = (): void => {
494
+ if (aborted) return;
495
+ remaining -= 1;
496
+ if (remaining === 0) onDone();
497
+ };
498
+ const laneAbort = (): void => {
499
+ if (aborted) return;
500
+ aborted = true;
501
+ cancelled = true;
502
+ onAbort();
503
+ };
504
+ for (const lane of group.lanes) {
505
+ runSteps(
506
+ lane.sequence.steps,
507
+ { ...deps, depth: groupDepth, isCancelled },
508
+ laneDone,
509
+ laneAbort
510
+ );
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Executes an API_REQUEST through the injected executor and runs the matching
516
+ * outcome arm like a nested sequence. The proxy enforces the real timeout; a
517
+ * scheduler backstop covers an unreachable proxy. Failure policy mirrors
518
+ * SET_VARIABLE/BRANCH: warn + degrade, never throw. A scheduler generation
519
+ * captured at start drops the continuation if the scene is torn down or
520
+ * replaced while the request is in flight.
521
+ */
522
+ function runApiRequest(
523
+ fn: StudioSceneFunction,
524
+ deps: StepRunnerDeps,
525
+ onDone: () => void,
526
+ onAbort: () => void
527
+ ): void {
528
+ const apiRequest = fn.scene_api_request;
529
+ if (!apiRequest) {
530
+ onDone();
531
+ return;
532
+ }
533
+ const chainDepth = deps.depth + 1;
534
+ if (chainDepth > ANIMATION_CHAIN_MAX_DEPTH) {
535
+ console.warn(
536
+ `[Studio] Max chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for API request ${apiRequest.id}.`
537
+ );
538
+ onDone();
539
+ return;
540
+ }
541
+ const executor = deps.runtimeCtx.apiRequestExecutor;
542
+ if (!executor) {
543
+ console.warn(
544
+ `[Studio] API_REQUEST function ${fn.id} needs a runtime context (executor); skipping.`
545
+ );
546
+ onDone();
547
+ return;
548
+ }
549
+ const scheduler = deps.runtimeCtx.scheduler;
550
+ const store = deps.runtimeCtx.variableStore;
551
+ const generation = scheduler.generation;
552
+ let settled = false;
553
+
554
+ const proceed = (outcome: StudioApiRequestOutcome): void => {
555
+ if (settled || scheduler.generation !== generation) return;
556
+ // A sibling lane aborted while the request was in flight: drop the arm.
557
+ if (deps.isCancelled?.()) return;
558
+ settled = true;
559
+ if (store) {
560
+ const { writes, warnings } = applyBindings(
561
+ apiRequest.bindings ?? [],
562
+ outcome
563
+ );
564
+ for (const warning of warnings) {
565
+ console.warn(`[Studio] API_REQUEST ${apiRequest.id}: ${warning}`);
566
+ }
567
+ for (const write of writes) {
568
+ store.set(write.name, write.value);
569
+ }
570
+ } else if ((apiRequest.bindings ?? []).length > 0) {
571
+ console.warn(
572
+ `[Studio] API_REQUEST ${apiRequest.id}: no variable store; bindings skipped.`
573
+ );
574
+ }
575
+ const arm = outcome.ok
576
+ ? apiRequest.success_sequence
577
+ : apiRequest.failure_sequence;
578
+ if (!arm) {
579
+ onDone();
580
+ return;
581
+ }
582
+ runSteps(arm.steps, { ...deps, depth: chainDepth }, onDone, onAbort);
583
+ };
584
+
585
+ scheduler.schedule(() => {
586
+ proceed({
587
+ ok: false,
588
+ status: null,
589
+ error_code: "TIMEOUT",
590
+ error_message: "Request timed out",
591
+ });
592
+ }, apiRequest.timeout_ms + API_REQUEST_CLIENT_GRACE_MS);
593
+
594
+ const variables = store ? store.snapshot() : {};
595
+ executor(fn.id, variables)
596
+ .then((outcome) => proceed(outcome))
597
+ .catch((error: unknown) => {
598
+ proceed({
599
+ ok: false,
600
+ status: null,
601
+ error_code: "NETWORK_ERROR",
602
+ error_message:
603
+ error instanceof Error ? error.message : "Request failed",
604
+ });
605
+ });
606
+ }
607
+
20
608
  /**
21
609
  * Looks up target_asset_id for an ANIMATION-type scene function.
22
610
  * The inline scene_animation only has the animation UUID — we resolve it
@@ -29,10 +617,7 @@ function resolveAnimationTargetAssetId(
29
617
  return animations.find((a) => a.id === animationId)?.target_asset_id;
30
618
  }
31
619
 
32
- /**
33
- * Single dispatcher for all scene function types.
34
- * Used by onClick, onCollision, and on_load_function triggers.
35
- */
620
+ /** Used by onClick, onCollision, and on_load_function triggers. */
36
621
  export function executeFunctionWithRelations(
37
622
  fn: StudioSceneFunction,
38
623
  sceneNavigator: SceneNavigator | undefined,
@@ -40,10 +625,53 @@ export function executeFunctionWithRelations(
40
625
  onAnimationTrigger?: (targetAssetId: string, animationKey: string) => void,
41
626
  depth = 0,
42
627
  onSceneChange?: (sceneId: string, sceneName: string) => void,
628
+ runtimeCtx?: SequenceRuntimeContext
43
629
  ): void {
44
630
  if (depth > ANIMATION_CHAIN_MAX_DEPTH) {
45
631
  console.warn(
46
- `[Studio] Max animation chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for function ${fn.id}.`
632
+ `[Studio] Max chain depth (${ANIMATION_CHAIN_MAX_DEPTH}) exceeded for function ${fn.id}.`
633
+ );
634
+ return;
635
+ }
636
+
637
+ // Shared direct-trigger dispatch for SEQUENCE/BRANCH/GROUP/API_REQUEST.
638
+ const runBeginGuarded = <T extends { id: string }>(
639
+ label: string,
640
+ payload: T | null | undefined,
641
+ run: (
642
+ payload: T,
643
+ deps: StepRunnerDeps,
644
+ onDone: () => void,
645
+ onAbort: () => void
646
+ ) => void
647
+ ): void => {
648
+ if (!payload) return;
649
+ if (!runtimeCtx) {
650
+ console.warn(
651
+ `[Studio] ${label} function ${fn.id} needs a runtime context (scheduler); skipping.`
652
+ );
653
+ return;
654
+ }
655
+ if (!runtimeCtx.scheduler.beginSequence(payload.id)) return;
656
+ const finish = () => runtimeCtx.scheduler.endSequence(payload.id);
657
+ run(
658
+ payload,
659
+ {
660
+ sceneNavigator,
661
+ animations,
662
+ onAnimationTrigger,
663
+ onSceneChange,
664
+ runtimeCtx,
665
+ depth,
666
+ },
667
+ finish,
668
+ finish
669
+ );
670
+ };
671
+
672
+ if (fn.function_type === "SEQUENCE") {
673
+ runBeginGuarded("SEQUENCE", fn.scene_sequence, (seq, deps, onDone, onAbort) =>
674
+ runSteps(seq.steps, deps, onDone, onAbort)
47
675
  );
48
676
  return;
49
677
  }
@@ -51,27 +679,41 @@ export function executeFunctionWithRelations(
51
679
  if (fn.function_type === "NAVIGATION") {
52
680
  const nav = fn.scene_navigation;
53
681
  if (!nav?.navigate_to || !sceneNavigator) return;
54
- void navigateToScene(sceneNavigator, nav.navigate_to, animations, onSceneChange);
682
+ void navigateToScene(
683
+ sceneNavigator,
684
+ nav.navigate_to,
685
+ animations,
686
+ onSceneChange,
687
+ runtimeCtx?.variableStore
688
+ );
55
689
  } else if (fn.function_type === "ALERT") {
56
- const alrt = fn.scene_alert;
57
- if (!alrt) return;
690
+ const alert = fn.scene_alert;
691
+ if (!alert) return;
692
+ // Fail-soft {{variable}} interpolation: unresolved names stay literal so a
693
+ // stale reference never blanks or suppresses the alert.
694
+ const store = runtimeCtx?.variableStore;
695
+ const fill = (s: string | null): string =>
696
+ s ? interpolateDisplayTemplate(s, (name) => store?.get(name)) : "";
697
+ const title = fill(alert.alert_title);
698
+ const message = fill(alert.alert_message);
58
699
  if (isQuest) {
59
700
  // Alert.alert shows a 2D panel dialog — invisible in the VR compositor.
60
701
  // Log it so it's not silently swallowed; in-scene VR alert UI is a TODO.
61
702
  console.warn(
62
- `[Studio] Alert (Quest — not shown in VR): "${alrt.alert_title}" — ${alrt.alert_message}`
703
+ `[Studio] Alert (Quest — not shown in VR): "${title}" — ${message}`
63
704
  );
64
705
  return;
65
706
  }
66
- Alert.alert(alrt.alert_title ?? "Alert", alrt.alert_message ?? "", [
67
- { text: "OK", style: "default" },
68
- ]);
707
+ Alert.alert(title || "Alert", message, [{ text: "OK", style: "default" }]);
69
708
  } else if (fn.function_type === "ANIMATION") {
70
709
  const anim = fn.scene_animation;
71
710
  if (!anim || !onAnimationTrigger) return;
72
711
 
73
712
  const animLookupId = fn.animation ?? anim.id;
74
- const targetAssetId = resolveAnimationTargetAssetId(animLookupId, animations);
713
+ const targetAssetId = resolveAnimationTargetAssetId(
714
+ animLookupId,
715
+ animations
716
+ );
75
717
  if (!targetAssetId) {
76
718
  console.warn(
77
719
  `[Studio] ANIMATION function ${fn.id}: could not resolve target_asset_id for animation ${anim.id}`
@@ -79,12 +721,107 @@ export function executeFunctionWithRelations(
79
721
  return;
80
722
  }
81
723
  onAnimationTrigger(targetAssetId, anim.animation_key);
724
+ } else if (fn.function_type === "SET_VARIABLE") {
725
+ // Failure policy: warn + skip the write, never throw — the sequence continues.
726
+ const sv = fn.scene_set_variable;
727
+ const store = runtimeCtx?.variableStore;
728
+ if (!sv) return;
729
+ if (!store) {
730
+ console.warn(
731
+ `[Studio] SET_VARIABLE function ${fn.id} needs a runtime context (variable store); skipping.`
732
+ );
733
+ return;
734
+ }
735
+ const parsed = parseExpression(sv.expression);
736
+ if (!parsed.ok) {
737
+ console.warn(
738
+ `[Studio] SET_VARIABLE "${sv.name}": ${parsed.error}; skipping.`
739
+ );
740
+ return;
741
+ }
742
+ const result = evaluate(parsed.ast, (name) => store.get(name));
743
+ if (!result.ok) {
744
+ console.warn(
745
+ `[Studio] SET_VARIABLE "${sv.name}": ${result.error}; skipping.`
746
+ );
747
+ return;
748
+ }
749
+ if (!valueMatchesType(result.value, sv.type)) {
750
+ console.warn(
751
+ `[Studio] SET_VARIABLE "${sv.name}": result is a ${typeof result.value}, expected ${sv.type}; skipping.`
752
+ );
753
+ return;
754
+ }
755
+ store.set(sv.name, result.value);
756
+ } else if (fn.function_type === "BRANCH") {
757
+ runBeginGuarded("BRANCH", fn.scene_branch, (_branch, deps, onDone, onAbort) =>
758
+ runBranch(fn, deps, onDone, onAbort)
759
+ );
760
+ } else if (fn.function_type === "GROUP") {
761
+ runBeginGuarded("GROUP", fn.scene_group, (_group, deps, onDone, onAbort) =>
762
+ runGroup(fn, deps, onDone, onAbort)
763
+ );
764
+ } else if (fn.function_type === "API_REQUEST") {
765
+ runBeginGuarded(
766
+ "API_REQUEST",
767
+ fn.scene_api_request,
768
+ (_request, deps, onDone, onAbort) =>
769
+ runApiRequest(fn, deps, onDone, onAbort)
770
+ );
771
+ } else if (fn.function_type === "SET_VISIBILITY") {
772
+ // Instant show / hide / toggle. Fire-and-forget: as a sequence step it
773
+ // dispatches and the walk advances immediately (no duration to wait on).
774
+ // TOGGLE reads the live runtime value from the store, never the author
775
+ // default. Failure policy: warn + skip, never throw.
776
+ const sv = fn.scene_set_visibility;
777
+ const store = runtimeCtx?.visibilityStore;
778
+ if (!sv) return;
779
+ if (!store) {
780
+ console.warn(
781
+ `[Studio] SET_VISIBILITY function ${fn.id} needs a runtime context (visibility store); skipping.`
782
+ );
783
+ return;
784
+ }
785
+ store.apply(sv.target_asset_id, sv.state);
786
+ } else if (fn.function_type === "SOUND") {
787
+ // Non-blocking ACTION: PLAY adds a sound (spatial when a target asset gives
788
+ // a position), STOP removes by clip (null = all). Fire-and-forget like
789
+ // SET_VISIBILITY; the walk advances immediately. Failure policy: warn + skip.
790
+ const s = fn.scene_sound;
791
+ const manager = runtimeCtx?.soundManager;
792
+ if (!s) return;
793
+ if (!manager) {
794
+ console.warn(
795
+ `[Studio] SOUND function ${fn.id} needs a runtime context (sound manager); skipping.`
796
+ );
797
+ return;
798
+ }
799
+ if (s.action === "PLAY") {
800
+ if (!s.audio_url) {
801
+ // Missing or cross-org clip: the resolve RPC nulls the url org-guarded.
802
+ console.warn(
803
+ `[Studio] SOUND function ${fn.id}: PLAY has no audio_url (missing/cross-org clip); skipping.`
804
+ );
805
+ return;
806
+ }
807
+ const position = s.target_asset_id
808
+ ? runtimeCtx?.getAssetPosition?.(s.target_asset_id)
809
+ : undefined;
810
+ manager.play({
811
+ // PLAY always has audio_asset_id per the scene_sounds CHECK constraint.
812
+ audioAssetId: s.audio_asset_id ?? "",
813
+ url: s.audio_url,
814
+ position,
815
+ volume: s.volume,
816
+ loop: s.loop,
817
+ stopOthers: s.stop_other_sounds,
818
+ });
819
+ } else {
820
+ manager.stop(s.audio_asset_id ?? null); // null = all sounds
821
+ }
82
822
  }
83
823
  }
84
824
 
85
- /**
86
- * Executes the scene's on_load_function if set.
87
- */
88
825
  export function executeOnLoadFunction(
89
826
  functionId: string,
90
827
  functions: StudioSceneFunction[],
@@ -92,13 +829,22 @@ export function executeOnLoadFunction(
92
829
  animations: StudioAnimation[],
93
830
  onAnimationTrigger?: (targetAssetId: string, animationKey: string) => void,
94
831
  onSceneChange?: (sceneId: string, sceneName: string) => void,
832
+ runtimeCtx?: SequenceRuntimeContext
95
833
  ): void {
96
834
  const fn = resolveById(functionId, functions);
97
835
  if (!fn) {
98
836
  console.warn(`[Studio] on_load_function ${functionId} not found.`);
99
837
  return;
100
838
  }
101
- executeFunctionWithRelations(fn, sceneNavigator, animations, onAnimationTrigger, 0, onSceneChange);
839
+ executeFunctionWithRelations(
840
+ fn,
841
+ sceneNavigator,
842
+ animations,
843
+ onAnimationTrigger,
844
+ 0,
845
+ onSceneChange,
846
+ runtimeCtx
847
+ );
102
848
  }
103
849
 
104
850
  /**
@@ -113,6 +859,7 @@ async function navigateToScene(
113
859
  targetSceneId: string,
114
860
  currentAnimations: StudioAnimation[],
115
861
  onSceneChange?: (sceneId: string, sceneName: string) => void,
862
+ variableStore?: StudioVariableStore
116
863
  ): Promise<void> {
117
864
  if (!sceneNavigator) {
118
865
  console.error("[Studio] SceneNavigator not available for navigation");
@@ -138,6 +885,9 @@ async function navigateToScene(
138
885
  passProps: {
139
886
  sceneData,
140
887
  onSceneChange,
888
+ // The session store rides along on every push so values survive scene
889
+ // transitions for the navigator's whole lifetime.
890
+ variableStore,
141
891
  },
142
892
  });
143
893