rnxsim 0.1.312 → 0.1.314

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 (299) hide show
  1. package/LICENSE +11 -10
  2. package/README.md +5 -0
  3. package/cli/app-config.ts +65 -0
  4. package/cli/app-fonts.ts +408 -0
  5. package/cli/app-project.ts +231 -0
  6. package/cli/app-splash.ts +185 -0
  7. package/cli/app-state-reset.ts +24 -0
  8. package/cli/auth.ts +155 -0
  9. package/cli/bin.ts +594 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2830 -0
  12. package/cli/browser-evals.ts +96 -0
  13. package/cli/commands/agent-wrapper.ts +986 -0
  14. package/cli/commands/agent.ts +423 -0
  15. package/cli/commands/app-fonts.ts +98 -0
  16. package/cli/commands/assert.ts +541 -0
  17. package/cli/commands/auth.ts +59 -0
  18. package/cli/commands/camera.ts +266 -0
  19. package/cli/commands/cleanup.ts +169 -0
  20. package/cli/commands/compat.ts +87 -0
  21. package/cli/commands/config.ts +32 -0
  22. package/cli/commands/control.ts +2142 -0
  23. package/cli/commands/cpu-profile.ts +269 -0
  24. package/cli/commands/daemon-mac-app.ts +169 -0
  25. package/cli/commands/daemon.ts +874 -0
  26. package/cli/commands/debug.ts +719 -0
  27. package/cli/commands/desktop.ts +39 -0
  28. package/cli/commands/detect.ts +197 -0
  29. package/cli/commands/detox.ts +385 -0
  30. package/cli/commands/device.ts +133 -0
  31. package/cli/commands/diagnose.ts +589 -0
  32. package/cli/commands/electron.ts +95 -0
  33. package/cli/commands/film.ts +379 -0
  34. package/cli/commands/flow.ts +1124 -0
  35. package/cli/commands/inspect/actions.ts +622 -0
  36. package/cli/commands/inspect/core.ts +2405 -0
  37. package/cli/commands/inspect/count.ts +17 -0
  38. package/cli/commands/inspect/describe.ts +192 -0
  39. package/cli/commands/inspect/env.ts +23 -0
  40. package/cli/commands/inspect/find.ts +171 -0
  41. package/cli/commands/inspect/get-layout.ts +39 -0
  42. package/cli/commands/inspect/keyboard.ts +52 -0
  43. package/cli/commands/inspect/list.ts +58 -0
  44. package/cli/commands/inspect/memory.ts +215 -0
  45. package/cli/commands/inspect/redaction.ts +39 -0
  46. package/cli/commands/inspect/resolve-target.ts +82 -0
  47. package/cli/commands/inspect/screens.ts +78 -0
  48. package/cli/commands/inspect/settle.ts +22 -0
  49. package/cli/commands/inspect/settling.ts +158 -0
  50. package/cli/commands/inspect/shared.ts +353 -0
  51. package/cli/commands/inspect/sleep.ts +14 -0
  52. package/cli/commands/inspect/tree.ts +32 -0
  53. package/cli/commands/inspect/url.ts +17 -0
  54. package/cli/commands/inspect/wait-event.ts +210 -0
  55. package/cli/commands/inspect/wait-idle.ts +24 -0
  56. package/cli/commands/inspect/wait-ready.ts +74 -0
  57. package/cli/commands/inspect/wait-selector.ts +54 -0
  58. package/cli/commands/inspect/wait.ts +31 -0
  59. package/cli/commands/inspect.ts +4519 -0
  60. package/cli/commands/install-desktop.ts +351 -0
  61. package/cli/commands/login.ts +331 -0
  62. package/cli/commands/logout.ts +31 -0
  63. package/cli/commands/maestro-generate.ts +361 -0
  64. package/cli/commands/maestro.ts +453 -0
  65. package/cli/commands/mode.ts +57 -0
  66. package/cli/commands/no-bridge-hint.ts +80 -0
  67. package/cli/commands/perf.ts +66 -0
  68. package/cli/commands/permissions.ts +203 -0
  69. package/cli/commands/profile.ts +108 -0
  70. package/cli/commands/react.ts +353 -0
  71. package/cli/commands/record.ts +1434 -0
  72. package/cli/commands/report-issue.ts +305 -0
  73. package/cli/commands/reset.ts +85 -0
  74. package/cli/commands/runtime.ts +351 -0
  75. package/cli/commands/screenshot-command.ts +106 -0
  76. package/cli/commands/screenshot-layers.ts +143 -0
  77. package/cli/commands/screenshot-mode.ts +37 -0
  78. package/cli/commands/screenshot.ts +488 -0
  79. package/cli/commands/screenshots-capture.ts +607 -0
  80. package/cli/commands/screenshots.ts +127 -0
  81. package/cli/commands/serve.ts +168 -0
  82. package/cli/commands/setup.ts +545 -0
  83. package/cli/commands/shell-boolean-mode.ts +81 -0
  84. package/cli/commands/skills.ts +467 -0
  85. package/cli/commands/slides.ts +361 -0
  86. package/cli/commands/state.ts +87 -0
  87. package/cli/commands/storage.ts +58 -0
  88. package/cli/commands/telemetry.ts +54 -0
  89. package/cli/commands/three-mode.ts +763 -0
  90. package/cli/commands/timeline.ts +122 -0
  91. package/cli/commands/upgrade.ts +208 -0
  92. package/cli/commands/upload.ts +1225 -0
  93. package/cli/commands/version.ts +54 -0
  94. package/cli/commands/what-happened.ts +327 -0
  95. package/cli/current-sim.ts +204 -0
  96. package/cli/desktop-companion.ts +300 -0
  97. package/cli/drivers/electron.ts +70 -0
  98. package/cli/drivers/index.ts +20 -0
  99. package/cli/drivers/playwright-provisioning.ts +180 -0
  100. package/cli/drivers/playwright.ts +698 -0
  101. package/cli/drivers/registry.ts +65 -0
  102. package/cli/drivers/types.ts +102 -0
  103. package/cli/flow-file.ts +142 -0
  104. package/cli/flow-live-status.ts +120 -0
  105. package/cli/flow-session.ts +187 -0
  106. package/cli/help.ts +80 -0
  107. package/cli/hidden-runtime-alias.ts +19 -0
  108. package/cli/hints.ts +216 -0
  109. package/cli/inspect-notice-state.ts +114 -0
  110. package/cli/maestro-js.ts +334 -0
  111. package/cli/open-url.ts +8 -0
  112. package/cli/parent-pid.ts +204 -0
  113. package/cli/parse-args.ts +211 -0
  114. package/cli/prompt.ts +51 -0
  115. package/cli/recording-access.ts +107 -0
  116. package/cli/registry.ts +1 -0
  117. package/cli/resolve-assets.ts +63 -0
  118. package/cli/run-registry.ts +226 -0
  119. package/cli/runtime-notes.ts +66 -0
  120. package/cli/runtime-summary.ts +25 -0
  121. package/cli/setup-repository.ts +187 -0
  122. package/cli/telemetry.ts +187 -0
  123. package/cli/ws-bridge.ts +798 -0
  124. package/dist-cli/bin.js +5 -5
  125. package/dist-cli/chunks/{agent-NXVDDCFG.js → agent-7YBDCYMA.js} +2 -2
  126. package/dist-cli/chunks/{agent-wrapper-DXJCFKL3.js → agent-wrapper-2GHFBHCR.js} +2 -2
  127. package/dist-cli/chunks/{app-fonts-W5AJZQJF.js → app-fonts-RSNVPQSU.js} +2 -2
  128. package/dist-cli/chunks/{assert-3LOJEUDT.js → assert-XCMX3XJX.js} +2 -2
  129. package/dist-cli/chunks/{auth-MFTYYPNX.js → auth-B442HRAX.js} +2 -2
  130. package/dist-cli/chunks/{beta-GT3NN67B.js → beta-XJ55JK3M.js} +2 -2
  131. package/dist-cli/chunks/camera-UGYSSLIK.js +33 -0
  132. package/dist-cli/chunks/{chunk-EUAVBTKU.js → chunk-277AEQZX.js} +2 -2
  133. package/dist-cli/chunks/{chunk-5OX7ANRA.js → chunk-2JNSK774.js} +2 -2
  134. package/dist-cli/chunks/{chunk-L24Z3CRS.js → chunk-32WOTSTR.js} +3 -3
  135. package/dist-cli/chunks/{chunk-BKNOLP3J.js → chunk-3S753SNQ.js} +2 -2
  136. package/dist-cli/chunks/{chunk-N6A3SXMR.js → chunk-46ZOLOYA.js} +2 -2
  137. package/dist-cli/chunks/{chunk-GZDHEONW.js → chunk-5L7ELDQL.js} +8 -9
  138. package/dist-cli/chunks/{chunk-YZRFF4YA.js → chunk-7OOPFSQS.js} +2 -2
  139. package/dist-cli/chunks/{chunk-TBMVBI26.js → chunk-7SV3RPRW.js} +2 -2
  140. package/dist-cli/chunks/{chunk-JG6S4RSH.js → chunk-7ZC35MOU.js} +1 -1
  141. package/dist-cli/chunks/chunk-AMG5E6CC.js +9 -0
  142. package/dist-cli/chunks/{chunk-MENV7VLD.js → chunk-APWNH3A4.js} +1 -1
  143. package/dist-cli/chunks/chunk-B57XUKY3.js +4 -0
  144. package/dist-cli/chunks/{chunk-3D57IBPC.js → chunk-C2NL26TD.js} +1 -1
  145. package/dist-cli/chunks/chunk-D2FNUWAB.js +15 -0
  146. package/dist-cli/chunks/{chunk-4BXCZHAU.js → chunk-E5T4XSJ3.js} +2 -2
  147. package/dist-cli/chunks/{chunk-FTC4M26Q.js → chunk-EAC34EQS.js} +1 -1
  148. package/dist-cli/chunks/{chunk-NPVGMH2N.js → chunk-EG32ML36.js} +2 -2
  149. package/dist-cli/chunks/{chunk-DM6KUAOZ.js → chunk-FXUAC6D5.js} +1 -1
  150. package/dist-cli/chunks/chunk-GQSL4USA.js +6 -0
  151. package/dist-cli/chunks/{chunk-U4PN6JOT.js → chunk-HAXW27SS.js} +2 -2
  152. package/dist-cli/chunks/{chunk-ZMZTHU7V.js → chunk-IZAHPAN6.js} +1 -1
  153. package/dist-cli/chunks/{chunk-SI3BUTQR.js → chunk-J62KM5TB.js} +2 -2
  154. package/dist-cli/chunks/{chunk-Y2FSZSO7.js → chunk-JEMCD5E4.js} +1 -1
  155. package/dist-cli/chunks/{chunk-T4FT6CM7.js → chunk-JZS3Q37N.js} +2 -2
  156. package/dist-cli/chunks/{chunk-2SA2VTGP.js → chunk-KR4JON7D.js} +1 -1
  157. package/dist-cli/chunks/chunk-KWCYKQIQ.js +4 -0
  158. package/dist-cli/chunks/{chunk-HG7ORJMT.js → chunk-MCWPL644.js} +2 -2
  159. package/dist-cli/chunks/chunk-MJYK3N2I.js +4 -0
  160. package/dist-cli/chunks/{chunk-YRV7HR3P.js → chunk-O6TRIZNS.js} +2 -2
  161. package/dist-cli/chunks/{chunk-WBT32R6N.js → chunk-P7XL2E73.js} +3 -3
  162. package/dist-cli/chunks/{chunk-MNGQ42QE.js → chunk-PFQTUKQ4.js} +62 -87
  163. package/dist-cli/chunks/{chunk-Q3RGLERJ.js → chunk-PG5RZCTN.js} +2 -2
  164. package/dist-cli/chunks/{chunk-OR3XTXEB.js → chunk-QGRI2Z4M.js} +2 -2
  165. package/dist-cli/chunks/chunk-QLXXE7GE.js +125 -0
  166. package/dist-cli/chunks/{chunk-KZW5WBGU.js → chunk-QOJJJWJE.js} +89 -133
  167. package/dist-cli/chunks/{chunk-BRIZOR4K.js → chunk-RWZY5427.js} +2 -2
  168. package/dist-cli/chunks/{chunk-TYE3WBSR.js → chunk-RZKU2K3J.js} +2 -2
  169. package/dist-cli/chunks/{chunk-7VWOSTD5.js → chunk-SBV4IK4H.js} +1 -1
  170. package/dist-cli/chunks/{chunk-FWYYCUAZ.js → chunk-SGMVFFMK.js} +1 -1
  171. package/dist-cli/chunks/{chunk-LDQ7HVCW.js → chunk-TAX4UT2N.js} +1 -1
  172. package/dist-cli/chunks/{chunk-G74YIEVS.js → chunk-TUOFAWXT.js} +1 -1
  173. package/dist-cli/chunks/{chunk-274UD5FD.js → chunk-UHZLOHGP.js} +1 -1
  174. package/dist-cli/chunks/{chunk-37I5AIS7.js → chunk-VC7V76U3.js} +1 -1
  175. package/dist-cli/chunks/{chunk-B7KMNHX7.js → chunk-VQVMLW4U.js} +1 -1
  176. package/dist-cli/chunks/{chunk-VZBLFMYC.js → chunk-VUKKYPZN.js} +2 -2
  177. package/dist-cli/chunks/{chunk-VUFRB347.js → chunk-WGGRJDRE.js} +1 -1
  178. package/dist-cli/chunks/chunk-X6H76EKP.js +15 -0
  179. package/dist-cli/chunks/chunk-XLZ5FNRT.js +27 -0
  180. package/dist-cli/chunks/{chunk-TCWQJILS.js → chunk-XULEACM4.js} +2 -2
  181. package/dist-cli/chunks/{chunk-XRGEEPJ5.js → chunk-YFSDM7AX.js} +4 -4
  182. package/dist-cli/chunks/chunk-YWI3UEVX.js +5 -0
  183. package/dist-cli/chunks/{chunk-TGQNQ6XT.js → chunk-ZBSJO4NB.js} +10 -9
  184. package/dist-cli/chunks/{cleanup-H3GWQB63.js → cleanup-EYLGCA6Z.js} +2 -2
  185. package/dist-cli/chunks/cli-version-LL2UGIHE.js +4 -0
  186. package/dist-cli/chunks/{compat-5WG2XJV7.js → compat-TLJYHB4E.js} +2 -2
  187. package/dist-cli/chunks/{config-ANMBVVLL.js → config-4JWUOEXK.js} +2 -2
  188. package/dist-cli/chunks/{control-NSGNQFJG.js → control-HPAOYF4N.js} +2 -2
  189. package/dist-cli/chunks/daemon-OYLASXLE.js +4 -0
  190. package/dist-cli/chunks/{debug-CHV2O2KF.js → debug-4BKXF6KI.js} +5 -5
  191. package/dist-cli/chunks/{desktop-FIPIK4CH.js → desktop-ZQ6ZD2S6.js} +3 -3
  192. package/dist-cli/chunks/{detox-CVUJP7K6.js → detox-WNPASSS3.js} +2 -2
  193. package/dist-cli/chunks/{device-6WCUM4W6.js → device-BXLXVG3V.js} +2 -2
  194. package/dist-cli/chunks/{diagnose-I6EMWEII.js → diagnose-3QX7W5RW.js} +2 -2
  195. package/dist-cli/chunks/{disk-cleanup-P27CYFTN.js → disk-cleanup-KSWKI7WB.js} +2 -2
  196. package/dist-cli/chunks/drivers-EPIEFF7P.js +4 -0
  197. package/dist-cli/chunks/{film-UOQFFYNF.js → film-GDMR33VO.js} +3 -3
  198. package/dist-cli/chunks/flow-NKMPBYCJ.js +4 -0
  199. package/dist-cli/chunks/help-OGCBHOKA.js +4 -0
  200. package/dist-cli/chunks/{hidden-runtime-alias-NJYFSJKZ.js → hidden-runtime-alias-S2GTDX3T.js} +2 -2
  201. package/dist-cli/chunks/home-paths-QRCDLTTV.js +4 -0
  202. package/dist-cli/chunks/inspect-FUYMOZPY.js +4 -0
  203. package/dist-cli/chunks/install-desktop-FR6YKW7Y.js +4 -0
  204. package/dist-cli/chunks/{login-BPFQENCX.js → login-IJAPUZHI.js} +4 -4
  205. package/dist-cli/chunks/{logout-5A45ZGY7.js → logout-QMXDFU2X.js} +2 -2
  206. package/dist-cli/chunks/{maestro-E4CSDMLM.js → maestro-ZXU3YCVX.js} +3 -3
  207. package/dist-cli/chunks/{maestro-generate-CKMOJQX2.js → maestro-generate-PYB5QY7K.js} +3 -3
  208. package/dist-cli/chunks/{mode-SZYNMIQF.js → mode-WGUCL5FZ.js} +2 -2
  209. package/dist-cli/chunks/{optional-demo-registry-FRH4SFVG.js → optional-demo-registry-WH2O6H36.js} +2 -2
  210. package/dist-cli/chunks/{perf-VXU2XJAX.js → perf-TOD3UFAH.js} +2 -2
  211. package/dist-cli/chunks/{permissions-IRIAOVND.js → permissions-I5BRJGTB.js} +2 -2
  212. package/dist-cli/chunks/{record-RY7FPLLH.js → record-ZYL2FYSK.js} +3 -3
  213. package/dist-cli/chunks/{report-issue-KNWXOE53.js → report-issue-TAI6DYZO.js} +2 -2
  214. package/dist-cli/chunks/reset-7YGYKQPQ.js +4 -0
  215. package/dist-cli/chunks/runtime-B4JO6QRI.js +4 -0
  216. package/dist-cli/chunks/{screenshot-command-QLALR6GY.js → screenshot-command-7ANLODZY.js} +7 -7
  217. package/dist-cli/chunks/{screenshot-layers-OQFMFVRL.js → screenshot-layers-A7FYXSVU.js} +3 -3
  218. package/dist-cli/chunks/{screenshots-capture-6TSRJFGB.js → screenshots-capture-WT2ZY6CB.js} +2 -2
  219. package/dist-cli/chunks/serve-TG5WKA4V.js +44 -0
  220. package/dist-cli/chunks/{setup-7V6UKX4U.js → setup-PCWLD22V.js} +2 -2
  221. package/dist-cli/chunks/{skills-QQBOSETH.js → skills-27ZHWCQS.js} +2 -2
  222. package/dist-cli/chunks/state-ZQVY46ZO.js +14 -0
  223. package/dist-cli/chunks/{storage-TAPMKK5O.js → storage-OYC57C4X.js} +6 -6
  224. package/dist-cli/chunks/store-32HSPZHI.js +4 -0
  225. package/dist-cli/chunks/telemetry-PBJR7XHR.js +4 -0
  226. package/dist-cli/chunks/{timeline-IAM564NA.js → timeline-RAJJFQT6.js} +2 -2
  227. package/dist-cli/chunks/{upgrade-CD55QXIX.js → upgrade-OEFNZRIP.js} +2 -2
  228. package/dist-cli/chunks/upload-LQJITLKK.js +4 -0
  229. package/dist-cli/chunks/version-NIXTY6PL.js +6 -0
  230. package/dist-cli/chunks/{web-6NW6YLPI.js → web-XBUTBVGR.js} +2 -2
  231. package/dist-cli/chunks/{what-happened-CZZDOOVG.js → what-happened-YNHRFUDX.js} +3 -3
  232. package/dist-lib/agent-daemon-client.cjs +1 -1
  233. package/dist-lib/agent-events.cjs +1 -1
  234. package/dist-lib/agent-identity.cjs +1 -1
  235. package/dist-lib/agent-sessions.cjs +1 -1
  236. package/dist-lib/attached-projects.cjs +1 -1
  237. package/dist-lib/auth/shared-session.cjs +1 -1
  238. package/dist-lib/backend-origin.cjs +1 -1
  239. package/dist-lib/beta.cjs +1 -1
  240. package/dist-lib/beta.mjs +1 -1
  241. package/dist-lib/bridge-constants.cjs +1 -1
  242. package/dist-lib/bridge-contract.cjs +20 -0
  243. package/dist-lib/cli-constants.cjs +1 -1
  244. package/dist-lib/config.cjs +1 -1
  245. package/dist-lib/detox/index.cjs +1 -1
  246. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  247. package/dist-lib/home-paths.cjs +67 -28
  248. package/dist-lib/host/bridge-host.cjs +140 -12
  249. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  250. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  251. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  252. package/dist-lib/host/websocket-proxy.cjs +1 -1
  253. package/dist-lib/index.cjs +2815 -40
  254. package/dist-lib/jump-to-source-babel.cjs +1 -1
  255. package/dist-lib/menu.cjs +1 -1
  256. package/dist-lib/menu.mjs +1 -1
  257. package/dist-lib/metro.cjs +1 -1
  258. package/dist-lib/profiles.cjs +1 -1
  259. package/dist-lib/public-brand.cjs +1 -1
  260. package/dist-lib/render-mode.cjs +1 -1
  261. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  262. package/dist-lib/sdk.cjs +2549 -2061
  263. package/dist-lib/sdk.mjs +2543 -2061
  264. package/dist-lib/skills.cjs +493 -282
  265. package/dist-lib/vite.cjs +1 -1
  266. package/package.json +8 -2
  267. package/src/bridge-constants.ts +3 -4
  268. package/src/bridge-contract.ts +251 -0
  269. package/src/connect.ts +83 -0
  270. package/src/disk-cleanup.ts +30 -0
  271. package/src/home-paths.ts +81 -38
  272. package/src/host/bridge-host.ts +134 -6
  273. package/src/index.ts +27 -1
  274. package/src/sdk.ts +8 -0
  275. package/src/sim-client.ts +660 -0
  276. package/dist-cli/chunks/camera-6EAK7CUL.js +0 -22
  277. package/dist-cli/chunks/chunk-33FKQH54.js +0 -5
  278. package/dist-cli/chunks/chunk-66NF2KNG.js +0 -9
  279. package/dist-cli/chunks/chunk-6UKJB2VC.js +0 -125
  280. package/dist-cli/chunks/chunk-7INBNA3U.js +0 -26
  281. package/dist-cli/chunks/chunk-AY47WKS5.js +0 -23
  282. package/dist-cli/chunks/chunk-HMDOLCKG.js +0 -15
  283. package/dist-cli/chunks/chunk-KR5BGMBZ.js +0 -4
  284. package/dist-cli/chunks/chunk-NLEMQ4PY.js +0 -6
  285. package/dist-cli/chunks/chunk-PEPUGJVI.js +0 -4
  286. package/dist-cli/chunks/cli-version-3QPAJ3LC.js +0 -4
  287. package/dist-cli/chunks/daemon-JHF4BJ3U.js +0 -4
  288. package/dist-cli/chunks/drivers-HQ6AKZT6.js +0 -4
  289. package/dist-cli/chunks/flow-N27MRRNL.js +0 -4
  290. package/dist-cli/chunks/help-SF6FEGEE.js +0 -4
  291. package/dist-cli/chunks/home-paths-XOV44JEF.js +0 -4
  292. package/dist-cli/chunks/inspect-CWFRFQTY.js +0 -4
  293. package/dist-cli/chunks/install-desktop-KLQDNXZQ.js +0 -4
  294. package/dist-cli/chunks/runtime-AR3GJTGK.js +0 -4
  295. package/dist-cli/chunks/serve-F54J2NQO.js +0 -44
  296. package/dist-cli/chunks/store-7NX3S43H.js +0 -4
  297. package/dist-cli/chunks/telemetry-E7BUK6RA.js +0 -4
  298. package/dist-cli/chunks/upload-GZ6LIMYM.js +0 -4
  299. package/dist-cli/chunks/version-3DYJ5AS3.js +0 -6
@@ -0,0 +1,763 @@
1
+ // sootsim mode three — toggle the live browser 3d device stage.
2
+ //
3
+ // wraps the existing `toggle-three-mode` shell action so CLI, rail, and
4
+ // menu all use the same plugin-owned state path.
5
+
6
+ import { createHash } from 'node:crypto'
7
+ import { mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
8
+ import { dirname, extname, resolve } from 'node:path'
9
+ import {
10
+ callInBridgeWrite,
11
+ createBridgeFromParsed,
12
+ parseBridgeCliArgs,
13
+ type WsBridge,
14
+ } from '../ws-bridge'
15
+ import { runShellBooleanMode, type ShellBooleanModeOptions } from './shell-boolean-mode'
16
+
17
+ type ThreeModeConfig = Record<string, unknown>
18
+ type ThreeModeState = Record<string, unknown> & {
19
+ ready?: boolean
20
+ disabled?: boolean
21
+ }
22
+
23
+ const THREE_MODE_READY_TIMEOUT_MS = 12_000
24
+ // a supersampled path-traced frame (soft-orbs export) legitimately spends
25
+ // minutes on shader compile + sample convergence on slower GPUs; raster
26
+ // timeline batches stay far under this either way.
27
+ const THREE_MODE_RENDER_TIMEOUT_MS = 600_000
28
+ const TIMELINE_ACTIONS = new Set(['load', 'seek', 'play', 'pause', 'render', 'measure'])
29
+
30
+ export async function runThreeMode(args: string[], opts: ShellBooleanModeOptions) {
31
+ if (args.includes('--help') || args.includes('-h')) {
32
+ console.log(`
33
+ rnxsim mode three — toggle the 3d device stage
34
+
35
+ usage:
36
+ rnxsim mode three [on|off|toggle]
37
+ rnxsim mode three configure [options]
38
+ rnxsim mode three load <timeline.json>
39
+ rnxsim mode three seek <time-ms>
40
+ rnxsim mode three play|pause
41
+ rnxsim mode three render <timeline.json> --output <dir> [options]
42
+ rnxsim mode three measure <timeline.json> [options]
43
+
44
+ arguments:
45
+ on enable 3d mode
46
+ off disable 3d mode
47
+ toggle flip the current state (default)
48
+
49
+ configure options:
50
+ --device <id> set the phone body: iphone-17-air | iphone-17-pro |
51
+ pixel-10 (a film authors its own via initial.device)
52
+ --background <id> set the 3d background
53
+ --gradient <stops> custom gradient bg: comma-separated css hex stops,
54
+ e.g. "#0a0e1a,#16243f,#05080f" (overrides --background;
55
+ evenly spaced, or use "color@offset" for explicit %)
56
+ --gradient-angle <deg> custom gradient angle (default 180 = top→bottom)
57
+ --colorway <id> set the phone colorway
58
+ --script <id> apply a camera preset pose
59
+ --progress <0..1> script progress to sample (default: 1)
60
+ --animate run the script instead of sampling one frame
61
+ --slow run the script slowly when --animate is set
62
+ --environment on|off enable or disable environment objects
63
+ --env-shape <id> orb | pill | diamond | pane
64
+ --env-spread <id> tight | medium | wide
65
+ --env-size <id> small | medium | large
66
+ --env-color <id> peach | gold | mint | sky | violet
67
+ --env-glow on|off enable or disable environment glow
68
+ --env-plastic on|off enable or disable environment plastic material
69
+ --setting <id> none | room | white-backdrop
70
+ --focus on|off enable or disable Focus depth of field
71
+ --focus-amount <id> subtle | medium | strong | intense | extreme
72
+ --focus-range <id> tight | balanced | wide
73
+ --reset-pose return to the default hero pose before other changes
74
+
75
+ render options:
76
+ --output <dir> write frame-%05d.png + timeline-render.json
77
+ --fps <number> frames per second (default: 30)
78
+ --width <px> exact output width (default: 1920)
79
+ --height <px> exact output height (default: 1080)
80
+ --from <ms> first timeline time (default: 0)
81
+ --to <ms> exclusive end time (default: timeline duration)
82
+ --overwrite replace this command's existing frames in the dir
83
+
84
+ measure options (projected bounds only — no pixels rendered):
85
+ --fps <number> sample rate (default: 30)
86
+ --width <px> output width the bounds project into (default: 1920)
87
+ --height <px> output height the bounds project into (default: 1080)
88
+ --from <ms> first timeline time (default: 0)
89
+ --to <ms> exclusive end time (default: timeline duration)
90
+ --count <n> sample n frames from --from at the --fps spacing
91
+ --at <ms[,ms...]> sample exact timeline times (overrides from/to/count)
92
+ --output <file.json> write the measurement JSON here instead of stdout
93
+
94
+ examples:
95
+ rnxsim mode three
96
+ rnxsim mode three on
97
+ rnxsim mode three configure --script hero-arc --background gradient-tide
98
+ rnxsim mode three configure --environment on --env-shape pane --setting room
99
+ rnxsim mode three render ./cinematic.json --output ./frames --fps 30
100
+ rnxsim mode three measure ./cinematic.json --width 450 --height 900 --at 0,1200
101
+ rnxsim mode three off
102
+ `)
103
+ process.exit(0)
104
+ }
105
+
106
+ const timelineAction = findTimelineAction(args)
107
+ if (timelineAction) {
108
+ await runThreeModeTimelineAction(timelineAction, args, opts)
109
+ return
110
+ }
111
+
112
+ if (shouldConfigure(args)) {
113
+ await runThreeModeConfigure(args, opts)
114
+ return
115
+ }
116
+
117
+ const result = await runShellBooleanMode(args, opts, {
118
+ modeKey: 'threeMode',
119
+ displayName: 'three-mode',
120
+ actionId: 'toggle-three-mode',
121
+ })
122
+ if (result.target) {
123
+ await waitForThreeModeRuntimeReady(args, opts)
124
+ }
125
+ }
126
+
127
+ function shouldConfigure(args: string[]) {
128
+ return (
129
+ args[0] === 'configure' ||
130
+ args.some((arg) =>
131
+ [
132
+ '--device',
133
+ '--background',
134
+ '--gradient',
135
+ '--gradient-angle',
136
+ '--colorway',
137
+ '--script',
138
+ '--progress',
139
+ '--animate',
140
+ '--slow',
141
+ '--environment',
142
+ '--env-shape',
143
+ '--env-spread',
144
+ '--env-size',
145
+ '--env-color',
146
+ '--env-glow',
147
+ '--env-plastic',
148
+ '--setting',
149
+ '--focus',
150
+ '--focus-amount',
151
+ '--focus-range',
152
+ '--reset-pose',
153
+ ].includes(arg),
154
+ )
155
+ )
156
+ }
157
+
158
+ async function runThreeModeConfigure(args: string[], opts: ShellBooleanModeOptions) {
159
+ const parsed = parseBridgeCliArgs(args, {
160
+ port: opts.port,
161
+ stripBooleanFlags: ['--animate', '--slow', '--reset-pose'],
162
+ stripValueFlags: [
163
+ '--device',
164
+ '--background',
165
+ '--gradient',
166
+ '--gradient-angle',
167
+ '--colorway',
168
+ '--script',
169
+ '--progress',
170
+ '--environment',
171
+ '--env-shape',
172
+ '--env-spread',
173
+ '--env-size',
174
+ '--env-color',
175
+ '--env-glow',
176
+ '--env-plastic',
177
+ '--setting',
178
+ '--focus',
179
+ '--focus-amount',
180
+ '--focus-range',
181
+ ],
182
+ })
183
+ const options = buildConfigureOptions(args)
184
+ const bridge = createBridgeFromParsed({
185
+ ...parsed,
186
+ commandTimeoutMs: THREE_MODE_READY_TIMEOUT_MS + 1000,
187
+ })
188
+ try {
189
+ await waitForThreeModeRuntimeReadyOnBridge(bridge)
190
+ const state = await callInBridgeWrite<Record<string, unknown>>(
191
+ bridge,
192
+ 'SootSim.bridges.threeMode.configure',
193
+ options,
194
+ )
195
+ console.log(` three-mode: configured (${summarizeState(state)})`)
196
+ } finally {
197
+ bridge.close()
198
+ }
199
+ }
200
+
201
+ async function waitForThreeModeRuntimeReady(
202
+ args: string[],
203
+ opts: ShellBooleanModeOptions,
204
+ ) {
205
+ const parsed = parseBridgeCliArgs(args, { port: opts.port })
206
+ const bridge = createBridgeFromParsed({
207
+ ...parsed,
208
+ commandTimeoutMs: THREE_MODE_READY_TIMEOUT_MS + 1000,
209
+ })
210
+ try {
211
+ await waitForThreeModeRuntimeReadyOnBridge(bridge)
212
+ } finally {
213
+ bridge.close()
214
+ }
215
+ }
216
+
217
+ export async function waitForThreeModeRuntimeReadyOnBridge(bridge: WsBridge) {
218
+ const state = (await bridge.send({
219
+ type: 'evaluate',
220
+ code: `(async () => {
221
+ const deadline = Date.now() + ${THREE_MODE_READY_TIMEOUT_MS}
222
+ while (Date.now() < deadline) {
223
+ const settings = window.SootSim?.bridges?.settings?.get?.()
224
+ if (settings && settings.threeMode !== true) {
225
+ return { ready: false, disabled: true }
226
+ }
227
+ const bridge = window.SootSim?.bridges?.threeMode
228
+ const state = typeof bridge?.getState === 'function' ? bridge.getState() : null
229
+ if (state?.ready === true) return state
230
+ await new Promise((resolve) => setTimeout(resolve, 50))
231
+ }
232
+ const bridge = window.SootSim?.bridges?.threeMode
233
+ const state = typeof bridge?.getState === 'function' ? bridge.getState() : null
234
+ return state || { ready: false }
235
+ })()`,
236
+ })) as ThreeModeState | null
237
+
238
+ if (state?.ready === true) return state
239
+ if (state?.disabled) {
240
+ throw new Error('3d mode is off; run `rnxsim mode three on` before configure')
241
+ }
242
+ throw new Error('3d mode runtime did not become ready')
243
+ }
244
+
245
+ type TimelineSampleResult = {
246
+ durationMs: number
247
+ timeMs: number
248
+ shotId: string | null
249
+ }
250
+
251
+ type TimelineRenderedFrame = {
252
+ index: number
253
+ timeMs: number
254
+ width: number
255
+ height: number
256
+ dataUrl: string
257
+ alpha: {
258
+ min: number
259
+ max: number
260
+ transparentPixels: number
261
+ }
262
+ phoneCollision: {
263
+ hull: Array<{ x: number; y: number }>
264
+ }
265
+ }
266
+
267
+ type TimelineRenderedFrames = {
268
+ frames: TimelineRenderedFrame[]
269
+ }
270
+
271
+ type TimelineMeasuredFrame = {
272
+ index: number
273
+ timeMs: number
274
+ camera: {
275
+ position: [number, number, number]
276
+ target: [number, number, number]
277
+ fov: number
278
+ }
279
+ targets: Record<
280
+ string,
281
+ {
282
+ hull: Array<{ x: number; y: number }>
283
+ bounds: { left: number; right: number; top: number; bottom: number }
284
+ }
285
+ >
286
+ }
287
+
288
+ type TimelineMeasuredFrames = {
289
+ fps: number
290
+ from: number
291
+ to: number
292
+ width: number
293
+ height: number
294
+ frames: TimelineMeasuredFrame[]
295
+ }
296
+
297
+ function findTimelineAction(args: string[]): string | null {
298
+ for (let index = 0; index < args.length; index += 1) {
299
+ const arg = args[index]
300
+ if (arg === '--sim' || arg === '--port' || arg === '-p') {
301
+ index += 1
302
+ continue
303
+ }
304
+ if (arg.startsWith('-')) continue
305
+ return TIMELINE_ACTIONS.has(arg) ? arg : null
306
+ }
307
+ return null
308
+ }
309
+
310
+ function readFlagValue(args: string[], flag: string) {
311
+ const index = args.indexOf(flag)
312
+ return index >= 0 ? args[index + 1] : undefined
313
+ }
314
+
315
+ function readNumberFlag(args: string[], flag: string, fallback: number, minimum = 0) {
316
+ const raw = readFlagValue(args, flag)
317
+ if (raw === undefined) return fallback
318
+ const value = Number.parseFloat(raw)
319
+ if (!Number.isFinite(value) || value < minimum) {
320
+ throw new Error(`${flag} expects a number greater than or equal to ${minimum}`)
321
+ }
322
+ return value
323
+ }
324
+
325
+ function isRecord(value: unknown): value is Record<string, unknown> {
326
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
327
+ }
328
+
329
+ function mimeTypeForAsset(path: string, contentType: unknown) {
330
+ const extension = extname(path).toLowerCase()
331
+ if (contentType === 'recording') {
332
+ if (extension === '.mp4' || extension === '.m4v') return 'video/mp4'
333
+ return 'video/webm'
334
+ }
335
+ if (extension === '.jpg' || extension === '.jpeg') return 'image/jpeg'
336
+ if (extension === '.webp') return 'image/webp'
337
+ return 'image/png'
338
+ }
339
+
340
+ function inlineTimelineAssets(value: unknown, baseDirectory: string): unknown {
341
+ if (Array.isArray(value)) {
342
+ return value.map((entry) => inlineTimelineAssets(entry, baseDirectory))
343
+ }
344
+ if (!isRecord(value)) return value
345
+
346
+ const result: Record<string, unknown> = {}
347
+ for (const [key, entry] of Object.entries(value)) {
348
+ result[key] = inlineTimelineAssets(entry, baseDirectory)
349
+ }
350
+ if (
351
+ (result.type === 'recording' ||
352
+ result.type === 'screenshot' ||
353
+ result.kind === 'screen') &&
354
+ typeof result.src === 'string' &&
355
+ !/^(?:blob:|data:|https?:)/i.test(result.src)
356
+ ) {
357
+ const assetPath = resolve(baseDirectory, result.src)
358
+ const bytes = readFileSync(assetPath)
359
+ result.src = `data:${mimeTypeForAsset(assetPath, result.type === 'recording' ? 'recording' : 'screenshot')};base64,${bytes.toString('base64')}`
360
+ }
361
+ return result
362
+ }
363
+
364
+ function readTimelineFile(path: string) {
365
+ const absolutePath = resolve(path)
366
+ const parsed: unknown = JSON.parse(readFileSync(absolutePath, 'utf8'))
367
+ return {
368
+ absolutePath,
369
+ timeline: inlineTimelineAssets(parsed, dirname(absolutePath)),
370
+ }
371
+ }
372
+
373
+ async function ensureThreeModeRuntimeOnBridge(bridge: WsBridge) {
374
+ await callInBridgeWrite(bridge, 'SootSim.bridges.settings.set', 'threeMode', true)
375
+ await waitForThreeModeRuntimeReadyOnBridge(bridge)
376
+ }
377
+
378
+ async function runThreeModeTimelineAction(
379
+ action: string,
380
+ args: string[],
381
+ opts: ShellBooleanModeOptions,
382
+ ) {
383
+ const parsed = parseBridgeCliArgs(args, {
384
+ port: opts.port,
385
+ commandTimeoutMs: THREE_MODE_RENDER_TIMEOUT_MS,
386
+ stripBooleanFlags: ['--overwrite'],
387
+ stripValueFlags: [
388
+ '--output',
389
+ '--fps',
390
+ '--width',
391
+ '--height',
392
+ '--from',
393
+ '--to',
394
+ '--count',
395
+ '--at',
396
+ ],
397
+ })
398
+ const bridge = createBridgeFromParsed(parsed)
399
+ try {
400
+ await ensureThreeModeRuntimeOnBridge(bridge)
401
+ if (action === 'seek') {
402
+ const rawTime = parsed.positional[1]
403
+ const timeMs = rawTime === undefined ? Number.NaN : Number.parseFloat(rawTime)
404
+ if (!Number.isFinite(timeMs)) throw new Error('three-mode seek expects time in ms')
405
+ const sample = await callInBridgeWrite<TimelineSampleResult>(
406
+ bridge,
407
+ 'SootSim.bridges.threeMode.seek',
408
+ timeMs,
409
+ )
410
+ console.log(
411
+ ` three-mode: ${Math.round(sample.timeMs)}ms / ${Math.round(sample.durationMs)}ms (${sample.shotId ?? 'no shot'})`,
412
+ )
413
+ return
414
+ }
415
+ if (action === 'play' || action === 'pause') {
416
+ const state = await callInBridgeWrite<Record<string, unknown>>(
417
+ bridge,
418
+ `SootSim.bridges.threeMode.${action}`,
419
+ )
420
+ const timeline = isRecord(state.timeline) ? state.timeline : {}
421
+ console.log(
422
+ ` three-mode: ${action} (${Math.round(Number(timeline.timeMs ?? 0))}ms)`,
423
+ )
424
+ return
425
+ }
426
+
427
+ const timelinePath = parsed.positional[1]
428
+ if (!timelinePath)
429
+ throw new Error(`three-mode ${action} expects a timeline JSON path`)
430
+ const loadedFile = readTimelineFile(timelinePath)
431
+ const loaded = await callInBridgeWrite<TimelineSampleResult>(
432
+ bridge,
433
+ 'SootSim.bridges.threeMode.loadTimeline',
434
+ loadedFile.timeline,
435
+ )
436
+ if (action === 'load') {
437
+ console.log(
438
+ ` three-mode: loaded ${loadedFile.absolutePath} (${Math.round(loaded.durationMs)}ms)`,
439
+ )
440
+ return
441
+ }
442
+
443
+ if (action === 'measure') {
444
+ const fps = readNumberFlag(args, '--fps', 30, 0.001)
445
+ const width = Math.round(readNumberFlag(args, '--width', 1_920, 1))
446
+ const height = Math.round(readNumberFlag(args, '--height', 1_080, 1))
447
+ const atValue = readFlagValue(args, '--at')
448
+ let measured: TimelineMeasuredFrames
449
+ if (atValue !== undefined) {
450
+ const times = atValue.split(',').map((token) => {
451
+ const value = Number.parseFloat(token)
452
+ if (!Number.isFinite(value) || value < 0) {
453
+ throw new Error(`--at expects comma-separated times in ms, got "${token}"`)
454
+ }
455
+ return Math.min(value, loaded.durationMs)
456
+ })
457
+ const frames: TimelineMeasuredFrame[] = []
458
+ for (const timeMs of times) {
459
+ const single = await callInBridgeWrite<TimelineMeasuredFrames>(
460
+ bridge,
461
+ 'SootSim.bridges.threeMode.measure',
462
+ { fps, width, height, from: timeMs, count: 1 },
463
+ )
464
+ frames.push({ ...single.frames[0], index: frames.length })
465
+ }
466
+ measured = {
467
+ fps,
468
+ from: times[0],
469
+ to: times[times.length - 1],
470
+ width,
471
+ height,
472
+ frames,
473
+ }
474
+ } else {
475
+ const from = readNumberFlag(args, '--from', 0, 0)
476
+ const countValue = readFlagValue(args, '--count')
477
+ const options: {
478
+ fps: number
479
+ width: number
480
+ height: number
481
+ from: number
482
+ to?: number
483
+ count?: number
484
+ } = { fps, width, height, from }
485
+ if (countValue === undefined) {
486
+ const to = readNumberFlag(args, '--to', loaded.durationMs, 0)
487
+ if (to <= from || to > loaded.durationMs) {
488
+ throw new Error(
489
+ `--to must be greater than --from and at most ${loaded.durationMs}`,
490
+ )
491
+ }
492
+ options.to = to
493
+ } else {
494
+ options.count = Math.round(readNumberFlag(args, '--count', 1, 1))
495
+ }
496
+ measured = await callInBridgeWrite<TimelineMeasuredFrames>(
497
+ bridge,
498
+ 'SootSim.bridges.threeMode.measure',
499
+ options,
500
+ )
501
+ }
502
+ const json = `${JSON.stringify(measured, null, 2)}\n`
503
+ const outputValue = readFlagValue(args, '--output')
504
+ if (outputValue) {
505
+ const outputPath = resolve(outputValue)
506
+ mkdirSync(dirname(outputPath), { recursive: true })
507
+ writeFileSync(outputPath, json)
508
+ // stderr keeps a caller piping stdout (phone-film bounds) JSON-clean.
509
+ process.stderr.write(
510
+ ` three-mode: measured ${measured.frames.length} frames → ${outputPath}\n`,
511
+ )
512
+ } else {
513
+ process.stdout.write(json)
514
+ }
515
+ return
516
+ }
517
+
518
+ const outputValue = readFlagValue(args, '--output')
519
+ if (!outputValue) throw new Error('three-mode render requires --output <dir>')
520
+ const outputDirectory = resolve(outputValue)
521
+ const fps = readNumberFlag(args, '--fps', 30, 0.001)
522
+ const width = Math.round(readNumberFlag(args, '--width', 1_920, 1))
523
+ const height = Math.round(readNumberFlag(args, '--height', 1_080, 1))
524
+ const from = readNumberFlag(args, '--from', 0, 0)
525
+ const to = readNumberFlag(args, '--to', loaded.durationMs, 0)
526
+ if (to <= from || to > loaded.durationMs) {
527
+ throw new Error(`--to must be greater than --from and at most ${loaded.durationMs}`)
528
+ }
529
+ const frameDurationMs = 1_000 / fps
530
+ const frameCount = Math.ceil((to - from) / frameDurationMs)
531
+ mkdirSync(outputDirectory, { recursive: true })
532
+ const ownedOutputs = readdirSync(outputDirectory).filter(
533
+ (name) => /^frame-\d{5}\.png$/.test(name) || name === 'timeline-render.json',
534
+ )
535
+ if (ownedOutputs.length > 0 && !args.includes('--overwrite')) {
536
+ throw new Error(
537
+ `${outputDirectory} already has timeline frames; pass --overwrite to replace them`,
538
+ )
539
+ }
540
+ for (const name of ownedOutputs) unlinkSync(resolve(outputDirectory, name))
541
+
542
+ const manifestFrames: Array<{
543
+ file: string
544
+ timeMs: number
545
+ sha256: string
546
+ alpha: TimelineRenderedFrame['alpha']
547
+ phoneCollision: TimelineRenderedFrame['phoneCollision']
548
+ }> = []
549
+ const batchSize = 4
550
+ for (let frameIndex = 0; frameIndex < frameCount; frameIndex += batchSize) {
551
+ const count = Math.min(batchSize, frameCount - frameIndex)
552
+ const batchFrom = from + frameIndex * frameDurationMs
553
+ const batchTo = Math.min(to, batchFrom + count * frameDurationMs)
554
+ const rendered = await callInBridgeWrite<TimelineRenderedFrames>(
555
+ bridge,
556
+ 'SootSim.bridges.threeMode.renderFrames',
557
+ { fps, from: batchFrom, to: batchTo, width, height, count },
558
+ )
559
+ if (rendered.frames.length !== count) {
560
+ throw new Error(
561
+ `3d renderer returned ${rendered.frames.length} frames for a ${count}-frame batch`,
562
+ )
563
+ }
564
+ for (const frame of rendered.frames) {
565
+ const globalIndex = frameIndex + frame.index
566
+ const file = `frame-${String(globalIndex).padStart(5, '0')}.png`
567
+ const separator = frame.dataUrl.indexOf(',')
568
+ if (
569
+ separator < 0 ||
570
+ !frame.dataUrl.slice(0, separator).includes('image/png;base64')
571
+ ) {
572
+ throw new Error(`3d renderer returned a non-PNG frame at ${frame.timeMs}ms`)
573
+ }
574
+ const bytes = Buffer.from(frame.dataUrl.slice(separator + 1), 'base64')
575
+ writeFileSync(resolve(outputDirectory, file), bytes)
576
+ manifestFrames.push({
577
+ file,
578
+ timeMs: frame.timeMs,
579
+ sha256: createHash('sha256').update(bytes).digest('hex'),
580
+ alpha: frame.alpha,
581
+ phoneCollision: frame.phoneCollision,
582
+ })
583
+ }
584
+ process.stderr.write(
585
+ `\r three-mode: rendered ${Math.min(frameIndex + count, frameCount)}/${frameCount}`,
586
+ )
587
+ }
588
+ process.stderr.write('\n')
589
+
590
+ const manifest = {
591
+ version: 2,
592
+ timeline: loadedFile.absolutePath,
593
+ fps,
594
+ from,
595
+ to,
596
+ width,
597
+ height,
598
+ frameCount,
599
+ transparent: manifestFrames.every((frame) => frame.alpha.transparentPixels > 0),
600
+ frames: manifestFrames,
601
+ }
602
+ writeFileSync(
603
+ resolve(outputDirectory, 'timeline-render.json'),
604
+ `${JSON.stringify(manifest, null, 2)}\n`,
605
+ )
606
+ console.log(
607
+ ` three-mode: wrote ${frameCount} deterministic RGBA frames to ${outputDirectory}`,
608
+ )
609
+ } finally {
610
+ bridge.close()
611
+ }
612
+ }
613
+
614
+ export function buildConfigureOptions(args: string[]): ThreeModeConfig {
615
+ const value = (flag: string) => args.find((_, i) => args[i - 1] === flag)
616
+ const options: ThreeModeConfig = {}
617
+
618
+ // the body itself. a timeline authors this through initial.device; this
619
+ // flag is for driving the live stage by hand.
620
+ const device = value('--device')
621
+ if (device) {
622
+ if (
623
+ device !== 'iphone-17-air' &&
624
+ device !== 'iphone-17-pro' &&
625
+ device !== 'pixel-10'
626
+ ) {
627
+ throw new Error(
628
+ `--device expects iphone-17-air, iphone-17-pro, or pixel-10, got "${device}"`,
629
+ )
630
+ }
631
+ options.device = device
632
+ }
633
+
634
+ const colorway = value('--colorway')
635
+ if (colorway) options.colorway = colorway
636
+
637
+ const background = value('--background')
638
+ if (background) options.background = background
639
+
640
+ // custom gradient: "color[@offset],color[@offset],…". offsets are
641
+ // 0..1 or 0..100 (auto-detected); omitted offsets spread evenly.
642
+ const gradient = value('--gradient')
643
+ if (gradient) {
644
+ const raw = gradient
645
+ .split(',')
646
+ .map((s) => s.trim())
647
+ .filter(Boolean)
648
+ const stops = raw.map((token, i) => {
649
+ const [color, offsetStr] = token.split('@')
650
+ let offset =
651
+ offsetStr !== undefined
652
+ ? Number.parseFloat(offsetStr)
653
+ : raw.length > 1
654
+ ? i / (raw.length - 1)
655
+ : 0
656
+ if (offset > 1) offset = offset / 100
657
+ if (!Number.isFinite(offset)) offset = raw.length > 1 ? i / (raw.length - 1) : 0
658
+ return { color: color.trim(), offset }
659
+ })
660
+ const angleStr = value('--gradient-angle')
661
+ const angleDeg = angleStr === undefined ? undefined : Number.parseFloat(angleStr)
662
+ if (angleStr !== undefined && !Number.isFinite(angleDeg)) {
663
+ throw new Error(`--gradient-angle expects a number, got "${angleStr}"`)
664
+ }
665
+ options.customGradient = { stops, ...(angleDeg !== undefined ? { angleDeg } : {}) }
666
+ }
667
+
668
+ const setting = value('--setting')
669
+ if (setting) {
670
+ if (setting !== 'none' && setting !== 'room' && setting !== 'white-backdrop') {
671
+ throw new Error(`--setting expects none, room, or white-backdrop, got "${setting}"`)
672
+ }
673
+ options.setting = setting
674
+ }
675
+
676
+ const focus: Record<string, unknown> = {}
677
+ const focusEnabled = parseBooleanValue(value('--focus'), '--focus')
678
+ if (focusEnabled !== undefined) focus.enabled = focusEnabled
679
+ for (const [flag, key] of [
680
+ ['--focus-amount', 'amount'],
681
+ ['--focus-range', 'range'],
682
+ ] as const) {
683
+ const setting = value(flag)
684
+ if (setting) focus[key] = setting
685
+ }
686
+ if (Object.keys(focus).length > 0) options.focus = focus
687
+
688
+ if (args.includes('--reset-pose')) options.resetPose = true
689
+
690
+ const environment = parseBooleanValue(value('--environment'), '--environment')
691
+ const pins: Record<string, string> = {}
692
+ const effects: Record<string, boolean> = {}
693
+ for (const [flag, key] of [
694
+ ['--env-shape', 'shape'],
695
+ ['--env-spread', 'spread'],
696
+ ['--env-size', 'size'],
697
+ ['--env-color', 'color'],
698
+ ] as const) {
699
+ const pin = value(flag)
700
+ if (pin) pins[key] = pin
701
+ }
702
+ const glow = parseBooleanValue(value('--env-glow'), '--env-glow')
703
+ if (glow !== undefined) effects.glow = glow
704
+ const plastic = parseBooleanValue(value('--env-plastic'), '--env-plastic')
705
+ if (plastic !== undefined) effects.plastic = plastic
706
+
707
+ if (
708
+ environment !== undefined ||
709
+ Object.keys(pins).length > 0 ||
710
+ Object.keys(effects).length > 0
711
+ ) {
712
+ options.environment =
713
+ environment === false
714
+ ? false
715
+ : {
716
+ pins,
717
+ effects,
718
+ }
719
+ }
720
+
721
+ const scriptId = value('--script')
722
+ if (scriptId) {
723
+ const progressValue = value('--progress')
724
+ const progress = progressValue === undefined ? 1 : Number.parseFloat(progressValue)
725
+ if (!Number.isFinite(progress)) {
726
+ throw new Error(`--progress expects a number from 0 to 1, got "${progressValue}"`)
727
+ }
728
+ options.script = {
729
+ id: scriptId,
730
+ progress,
731
+ animate: args.includes('--animate'),
732
+ slow: args.includes('--slow'),
733
+ }
734
+ }
735
+
736
+ return options
737
+ }
738
+
739
+ function parseBooleanValue(value: string | undefined, flag: string): boolean | undefined {
740
+ if (value === undefined) return undefined
741
+ const normalized = value.toLowerCase()
742
+ if (['on', 'true', 'yes', '1'].includes(normalized)) return true
743
+ if (['off', 'false', 'no', '0'].includes(normalized)) return false
744
+ throw new Error(`${flag} expects on or off, got "${value}"`)
745
+ }
746
+
747
+ function summarizeState(state: Record<string, unknown>) {
748
+ const parts = [
749
+ `ready=${Boolean(state.ready)}`,
750
+ `background=${String(state.background ?? 'unknown')}`,
751
+ `colorway=${String(state.colorway ?? 'unknown')}`,
752
+ `environment=${state.environmentActive ? 'on' : 'off'}`,
753
+ `setting=${String(state.setting ?? 'none')}`,
754
+ `focus=${
755
+ state.focus &&
756
+ typeof state.focus === 'object' &&
757
+ (state.focus as { enabled?: boolean }).enabled
758
+ ? 'on'
759
+ : 'off'
760
+ }`,
761
+ ]
762
+ return parts.join(', ')
763
+ }