android-midscene-automation 0.2.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (525) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +63 -1
  3. package/artemis-main/.dockerignore +15 -0
  4. package/artemis-main/.env.example +34 -0
  5. package/artemis-main/CONTRIBUTING.md +35 -0
  6. package/artemis-main/Dockerfile +68 -0
  7. package/artemis-main/LICENSE +202 -0
  8. package/artemis-main/Makefile +115 -0
  9. package/artemis-main/README.md +322 -0
  10. package/artemis-main/README_CN.md +320 -0
  11. package/artemis-main/apps/__init__.py +15 -0
  12. package/artemis-main/apps/admin_console/__init__.py +19 -0
  13. package/artemis-main/apps/admin_console/core/__init__.py +13 -0
  14. package/artemis-main/apps/admin_console/core/config.py +43 -0
  15. package/artemis-main/apps/admin_console/core/security.py +178 -0
  16. package/artemis-main/apps/admin_console/core/state.py +243 -0
  17. package/artemis-main/apps/admin_console/database/__init__.py +13 -0
  18. package/artemis-main/apps/admin_console/database/connection.py +60 -0
  19. package/artemis-main/apps/admin_console/database/repositories/__init__.py +13 -0
  20. package/artemis-main/apps/admin_console/database/repositories/session_repository.py +564 -0
  21. package/artemis-main/apps/admin_console/database/repositories/step_repository.py +512 -0
  22. package/artemis-main/apps/admin_console/database/repositories/trace_repository.py +133 -0
  23. package/artemis-main/apps/admin_console/index.html +3787 -0
  24. package/artemis-main/apps/admin_console/replay_manager.py +2585 -0
  25. package/artemis-main/apps/admin_console/routers/__init__.py +13 -0
  26. package/artemis-main/apps/admin_console/routers/media.py +198 -0
  27. package/artemis-main/apps/admin_console/routers/replay.py +91 -0
  28. package/artemis-main/apps/admin_console/routers/sessions.py +239 -0
  29. package/artemis-main/apps/admin_console/routers/steps.py +109 -0
  30. package/artemis-main/apps/admin_console/routers/stream.py +56 -0
  31. package/artemis-main/apps/admin_console/routers/system.py +761 -0
  32. package/artemis-main/apps/admin_console/routers/tasks.py +517 -0
  33. package/artemis-main/apps/admin_console/schemas/__init__.py +13 -0
  34. package/artemis-main/apps/admin_console/schemas/task_schema.py +47 -0
  35. package/artemis-main/apps/admin_console/server.py +508 -0
  36. package/artemis-main/apps/admin_console/services/__init__.py +13 -0
  37. package/artemis-main/apps/admin_console/services/device_stream_service.py +137 -0
  38. package/artemis-main/apps/admin_console/services/ipc_service.py +262 -0
  39. package/artemis-main/apps/admin_console/services/media_service.py +623 -0
  40. package/artemis-main/apps/admin_console/services/model_service.py +148 -0
  41. package/artemis-main/apps/admin_console/services/task_preset_catalog.py +514 -0
  42. package/artemis-main/apps/admin_console/services/task_queue_service.py +1719 -0
  43. package/artemis-main/apps/admin_console/services/worker_process_io.py +144 -0
  44. package/artemis-main/apps/showcase_ui/README.md +31 -0
  45. package/artemis-main/apps/showcase_ui/angular.json +132 -0
  46. package/artemis-main/apps/showcase_ui/package-lock.json +9472 -0
  47. package/artemis-main/apps/showcase_ui/package.json +39 -0
  48. package/artemis-main/apps/showcase_ui/proxy.conf.json +23 -0
  49. package/artemis-main/apps/showcase_ui/public/favicon.ico +0 -0
  50. package/artemis-main/apps/showcase_ui/public/logo.png +0 -0
  51. package/artemis-main/apps/showcase_ui/public/logo.svg +56 -0
  52. package/artemis-main/apps/showcase_ui/src/app/app.component.html +24 -0
  53. package/artemis-main/apps/showcase_ui/src/app/app.component.scss +49 -0
  54. package/artemis-main/apps/showcase_ui/src/app/app.component.spec.ts +42 -0
  55. package/artemis-main/apps/showcase_ui/src/app/app.component.ts +32 -0
  56. package/artemis-main/apps/showcase_ui/src/app/app.config.ts +29 -0
  57. package/artemis-main/apps/showcase_ui/src/app/app.routes.ts +27 -0
  58. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +108 -0
  59. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +1436 -0
  60. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +3973 -0
  61. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +2235 -0
  62. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +380 -0
  63. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +1171 -0
  64. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +270 -0
  65. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +433 -0
  66. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +987 -0
  67. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +632 -0
  68. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +130 -0
  69. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +406 -0
  70. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +74 -0
  71. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +115 -0
  72. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +52 -0
  73. package/artemis-main/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +334 -0
  74. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.spec.ts +55 -0
  75. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.ts +61 -0
  76. package/artemis-main/apps/showcase_ui/src/app/core/i18n/translate.pipe.ts +11 -0
  77. package/artemis-main/apps/showcase_ui/src/app/core/i18n/zh-cn.ts +651 -0
  78. package/artemis-main/apps/showcase_ui/src/app/core/models/markdown.model.ts +43 -0
  79. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +47 -0
  80. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +165 -0
  81. package/artemis-main/apps/showcase_ui/src/app/core/models/session.model.ts +85 -0
  82. package/artemis-main/apps/showcase_ui/src/app/core/models/stream.model.ts +246 -0
  83. package/artemis-main/apps/showcase_ui/src/app/core/models/system.model.ts +118 -0
  84. package/artemis-main/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +111 -0
  85. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.html +2130 -0
  86. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.scss +5204 -0
  87. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.ts +1189 -0
  88. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +39 -0
  89. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +151 -0
  90. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +58 -0
  91. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +201 -0
  92. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +886 -0
  93. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +324 -0
  94. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.spec.ts +418 -0
  95. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.ts +2012 -0
  96. package/artemis-main/apps/showcase_ui/src/app/services/system.service.spec.ts +173 -0
  97. package/artemis-main/apps/showcase_ui/src/app/services/system.service.ts +620 -0
  98. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +513 -0
  99. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.ts +1015 -0
  100. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +85 -0
  101. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.ts +662 -0
  102. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +70 -0
  103. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +383 -0
  104. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +107 -0
  105. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +123 -0
  106. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +92 -0
  107. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.ts +116 -0
  108. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +640 -0
  109. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +1021 -0
  110. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +375 -0
  111. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +1419 -0
  112. package/artemis-main/apps/showcase_ui/src/index.html +37 -0
  113. package/artemis-main/apps/showcase_ui/src/main.ts +22 -0
  114. package/artemis-main/apps/showcase_ui/src/styles.scss +60 -0
  115. package/artemis-main/apps/showcase_ui/tsconfig.app.json +23 -0
  116. package/artemis-main/apps/showcase_ui/tsconfig.json +27 -0
  117. package/artemis-main/apps/showcase_ui/tsconfig.spec.json +23 -0
  118. package/artemis-main/artemis/__init__.py +44 -0
  119. package/artemis-main/artemis/__main__.py +18 -0
  120. package/artemis-main/artemis/_legacy_imports.py +94 -0
  121. package/artemis-main/artemis/_version.py +30 -0
  122. package/artemis-main/artemis/agents/__init__.py +17 -0
  123. package/artemis-main/artemis/agents/checker/checker.json +9 -0
  124. package/artemis-main/artemis/agents/checker/checker.py +859 -0
  125. package/artemis-main/artemis/agents/diagnoser/diagnoser.json +23 -0
  126. package/artemis-main/artemis/agents/diagnoser/diagnoser.py +608 -0
  127. package/artemis-main/artemis/agents/explorer/constants.py +52 -0
  128. package/artemis-main/artemis/agents/explorer/explorer.json +57 -0
  129. package/artemis-main/artemis/agents/explorer/explorer.py +473 -0
  130. package/artemis-main/artemis/agents/explorer/geometry.py +73 -0
  131. package/artemis-main/artemis/agents/explorer/native_runner.py +878 -0
  132. package/artemis-main/artemis/agents/explorer/perception_tools.py +632 -0
  133. package/artemis-main/artemis/agents/explorer/run_setup.py +403 -0
  134. package/artemis-main/artemis/agents/explorer/screen_index.py +266 -0
  135. package/artemis-main/artemis/agents/explorer/tiers.py +110 -0
  136. package/artemis-main/artemis/agents/explorer/tool_declarations.py +394 -0
  137. package/artemis-main/artemis/agents/explorer/universal_runner.py +317 -0
  138. package/artemis-main/artemis/agents/flash/context_compressor.py +452 -0
  139. package/artemis-main/artemis/agents/flash/flash_runner.md +51 -0
  140. package/artemis-main/artemis/agents/flash/flash_summarizer.md +53 -0
  141. package/artemis-main/artemis/agents/flash/flash_summarizer_single.md +54 -0
  142. package/artemis-main/artemis/agents/flash/runner.py +1167 -0
  143. package/artemis-main/artemis/agents/flash/summarizer.py +511 -0
  144. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.md +8 -0
  145. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.py +182 -0
  146. package/artemis-main/artemis/agents/image_processor/image_processor.md +74 -0
  147. package/artemis-main/artemis/agents/image_processor/image_processor.py +322 -0
  148. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.md +15 -0
  149. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.py +403 -0
  150. package/artemis-main/artemis/agents/log_analyzer/log_reader_sub_agent.md +8 -0
  151. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.md +11 -0
  152. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.py +163 -0
  153. package/artemis-main/artemis/agents/object_detector/object_detector.json +9 -0
  154. package/artemis-main/artemis/agents/object_detector/object_detector.py +211 -0
  155. package/artemis-main/artemis/agents/operator/operator.json +3 -0
  156. package/artemis-main/artemis/agents/operator/operator.py +1394 -0
  157. package/artemis-main/artemis/agents/operator/prompts.py +1064 -0
  158. package/artemis-main/artemis/agents/outputter/outputter.md +36 -0
  159. package/artemis-main/artemis/agents/outputter/outputter.py +552 -0
  160. package/artemis-main/artemis/agents/planner/planner.json +32 -0
  161. package/artemis-main/artemis/agents/planner/planner.py +436 -0
  162. package/artemis-main/artemis/agents/prompt_assembly.py +79 -0
  163. package/artemis-main/artemis/agents/summarizer/summarizer.py +190 -0
  164. package/artemis-main/artemis/agents/validator/action_execution.py +98 -0
  165. package/artemis-main/artemis/agents/validator/categories.py +33 -0
  166. package/artemis-main/artemis/agents/validator/execution_loop.py +488 -0
  167. package/artemis-main/artemis/agents/validator/incidents.py +120 -0
  168. package/artemis-main/artemis/agents/validator/pixel_safety_net.md +15 -0
  169. package/artemis-main/artemis/agents/validator/precondition_pixel.py +320 -0
  170. package/artemis-main/artemis/agents/validator/precondition_xml.py +672 -0
  171. package/artemis-main/artemis/agents/validator/tool_declarations.py +332 -0
  172. package/artemis-main/artemis/agents/validator/validator.py +188 -0
  173. package/artemis-main/artemis/agents/video_analyzer/__init__.py +15 -0
  174. package/artemis-main/artemis/agents/video_analyzer/audio_native.py +543 -0
  175. package/artemis-main/artemis/agents/video_analyzer/blackboard.py +821 -0
  176. package/artemis-main/artemis/agents/video_analyzer/chunk_agentic.py +455 -0
  177. package/artemis-main/artemis/agents/video_analyzer/chunk_conversation.py +491 -0
  178. package/artemis-main/artemis/agents/video_analyzer/chunk_native.py +437 -0
  179. package/artemis-main/artemis/agents/video_analyzer/conflict_resolution.py +137 -0
  180. package/artemis-main/artemis/agents/video_analyzer/gemini_files.py +162 -0
  181. package/artemis-main/artemis/agents/video_analyzer/native_coordinator.py +541 -0
  182. package/artemis-main/artemis/agents/video_analyzer/reliability.py +128 -0
  183. package/artemis-main/artemis/agents/video_analyzer/universal_engine.py +487 -0
  184. package/artemis-main/artemis/agents/video_analyzer/universal_tools.py +323 -0
  185. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.md +51 -0
  186. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.py +875 -0
  187. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent.md +15 -0
  188. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent_audio.md +12 -0
  189. package/artemis-main/artemis/clients/accessibility_client.py +438 -0
  190. package/artemis-main/artemis/clients/screen_client_factory.py +403 -0
  191. package/artemis-main/artemis/config/__init__.py +309 -0
  192. package/artemis-main/artemis/config/agent.py +1098 -0
  193. package/artemis-main/artemis/config/constants.py +151 -0
  194. package/artemis-main/artemis/config/core/__init__.py +24 -0
  195. package/artemis-main/artemis/config/core/engine.py +118 -0
  196. package/artemis-main/artemis/config/core/interpolator.py +78 -0
  197. package/artemis-main/artemis/config/llm.py +267 -0
  198. package/artemis-main/artemis/config/paths.py +244 -0
  199. package/artemis-main/artemis/config/runtime.py +197 -0
  200. package/artemis-main/artemis/config/settings.py +315 -0
  201. package/artemis-main/artemis/constants.py +47 -0
  202. package/artemis-main/artemis/context.py +289 -0
  203. package/artemis-main/artemis/controllers/__init__.py +41 -0
  204. package/artemis-main/artemis/controllers/controller_factory.py +41 -0
  205. package/artemis-main/artemis/controllers/device_controller.py +29 -0
  206. package/artemis-main/artemis/controllers/types.py +37 -0
  207. package/artemis-main/artemis/controllers/unified_controller.py +649 -0
  208. package/artemis-main/artemis/core/__init__.py +21 -0
  209. package/artemis-main/artemis/core/diagnostics/__init__.py +90 -0
  210. package/artemis-main/artemis/core/diagnostics/adb_keys.py +270 -0
  211. package/artemis-main/artemis/core/diagnostics/adb_server_connection.py +330 -0
  212. package/artemis-main/artemis/core/diagnostics/device_smoke.py +364 -0
  213. package/artemis-main/artemis/core/diagnostics/emulator_manager.py +501 -0
  214. package/artemis-main/artemis/core/diagnostics/engine.py +438 -0
  215. package/artemis-main/artemis/core/diagnostics/hierarchy_parity.py +216 -0
  216. package/artemis-main/artemis/core/diagnostics/probes/adb_probe.py +887 -0
  217. package/artemis-main/artemis/core/diagnostics/probes/base.py +45 -0
  218. package/artemis-main/artemis/core/diagnostics/probes/credentials_probe.py +296 -0
  219. package/artemis-main/artemis/core/diagnostics/probes/host_probe.py +427 -0
  220. package/artemis-main/artemis/core/diagnostics/probes/runtime_probe.py +169 -0
  221. package/artemis-main/artemis/core/diagnostics/probes/toolchain_probe.py +139 -0
  222. package/artemis-main/artemis/core/diagnostics/readiness.py +83 -0
  223. package/artemis-main/artemis/core/diagnostics/schema.py +118 -0
  224. package/artemis-main/artemis/core/tool_declaration.py +43 -0
  225. package/artemis-main/artemis/core/tool_failure.py +56 -0
  226. package/artemis-main/artemis/data_engine/context_vars.py +24 -0
  227. package/artemis-main/artemis/data_engine/engine.py +1615 -0
  228. package/artemis-main/artemis/data_engine/history_reader.py +142 -0
  229. package/artemis-main/artemis/data_engine/models.py +131 -0
  230. package/artemis-main/artemis/data_engine/storage.py +1245 -0
  231. package/artemis-main/artemis/data_engine/trace.py +727 -0
  232. package/artemis-main/artemis/drivers/__init__.py +48 -0
  233. package/artemis-main/artemis/drivers/android/adb_driver.py +507 -0
  234. package/artemis-main/artemis/drivers/android/hierarchy.py +64 -0
  235. package/artemis-main/artemis/drivers/android/input_ime.py +120 -0
  236. package/artemis-main/artemis/drivers/android/recorder.py +57 -0
  237. package/artemis-main/artemis/drivers/base.py +290 -0
  238. package/artemis-main/artemis/drivers/cloud/cloud_driver.py +117 -0
  239. package/artemis-main/artemis/drivers/factory.py +80 -0
  240. package/artemis-main/artemis/drivers/mock/mock_driver.py +198 -0
  241. package/artemis-main/artemis/drivers/types.py +67 -0
  242. package/artemis-main/artemis/graph/checkpoints.py +1243 -0
  243. package/artemis-main/artemis/graph/graph.py +1055 -0
  244. package/artemis-main/artemis/graph/perception.py +234 -0
  245. package/artemis-main/artemis/graph/state.py +151 -0
  246. package/artemis-main/artemis/graph/visibility.py +238 -0
  247. package/artemis-main/artemis/interfaces/cli/__init__.py +19 -0
  248. package/artemis-main/artemis/interfaces/cli/commands/__init__.py +15 -0
  249. package/artemis-main/artemis/interfaces/cli/commands/batch.py +278 -0
  250. package/artemis-main/artemis/interfaces/cli/commands/doctor.py +468 -0
  251. package/artemis-main/artemis/interfaces/cli/commands/helper.py +226 -0
  252. package/artemis-main/artemis/interfaces/cli/commands/init.py +193 -0
  253. package/artemis-main/artemis/interfaces/cli/commands/mcp.py +862 -0
  254. package/artemis-main/artemis/interfaces/cli/commands/run.py +463 -0
  255. package/artemis-main/artemis/interfaces/cli/commands/server.py +151 -0
  256. package/artemis-main/artemis/interfaces/cli/commands/server_lifecycle.py +328 -0
  257. package/artemis-main/artemis/interfaces/cli/commands/trace.py +111 -0
  258. package/artemis-main/artemis/interfaces/cli/commands/ui.py +404 -0
  259. package/artemis-main/artemis/interfaces/cli/main.py +109 -0
  260. package/artemis-main/artemis/interfaces/sdk/__init__.py +31 -0
  261. package/artemis-main/artemis/interfaces/sdk/client.py +19 -0
  262. package/artemis-main/artemis/interfaces/sdk/task.py +72 -0
  263. package/artemis-main/artemis/llm/__init__.py +25 -0
  264. package/artemis-main/artemis/llm/fake_model.py +179 -0
  265. package/artemis-main/artemis/llm/google/__init__.py +57 -0
  266. package/artemis-main/artemis/llm/google/provider.py +185 -0
  267. package/artemis-main/artemis/llm/google/usage.py +148 -0
  268. package/artemis-main/artemis/llm/parser.py +32 -0
  269. package/artemis-main/artemis/llm/reliability.py +267 -0
  270. package/artemis-main/artemis/llm/router.py +389 -0
  271. package/artemis-main/artemis/llm/schemas.py +36 -0
  272. package/artemis-main/artemis/llm/structured.py +196 -0
  273. package/artemis-main/artemis/main.py +58 -0
  274. package/artemis-main/artemis/mcp/__init__.py +20 -0
  275. package/artemis-main/artemis/mcp/action_executor.py +760 -0
  276. package/artemis-main/artemis/mcp/action_manifest.py +320 -0
  277. package/artemis-main/artemis/mcp/action_names.py +172 -0
  278. package/artemis-main/artemis/mcp/action_server.py +202 -0
  279. package/artemis-main/artemis/mcp/action_session.py +245 -0
  280. package/artemis-main/artemis/mcp/action_specs.py +868 -0
  281. package/artemis-main/artemis/mcp/action_types.py +86 -0
  282. package/artemis-main/artemis/mcp/actuators/__init__.py +21 -0
  283. package/artemis-main/artemis/mcp/actuators/adb.py +414 -0
  284. package/artemis-main/artemis/mcp/actuators/base.py +113 -0
  285. package/artemis-main/artemis/mcp/actuators/mock.py +70 -0
  286. package/artemis-main/artemis/mcp/adb_server.py +490 -0
  287. package/artemis-main/artemis/mcp/observation.py +133 -0
  288. package/artemis-main/artemis/mcp/xml_search_server.py +297 -0
  289. package/artemis-main/artemis/memory/__init__.py +90 -0
  290. package/artemis-main/artemis/memory/chunking.py +1843 -0
  291. package/artemis-main/artemis/memory/context_policy.py +226 -0
  292. package/artemis-main/artemis/memory/step_capsule.md +73 -0
  293. package/artemis-main/artemis/memory/step_memory.py +281 -0
  294. package/artemis-main/artemis/memory/transcript.py +798 -0
  295. package/artemis-main/artemis/platform/__init__.py +61 -0
  296. package/artemis-main/artemis/platform/base.py +104 -0
  297. package/artemis-main/artemis/platform/darwin.py +184 -0
  298. package/artemis-main/artemis/platform/linux.py +207 -0
  299. package/artemis-main/artemis/platform/windows.py +193 -0
  300. package/artemis-main/artemis/resources/__init__.py +46 -0
  301. package/artemis-main/artemis/runtime/__init__.py +108 -0
  302. package/artemis-main/artemis/runtime/adb_endpoint.py +173 -0
  303. package/artemis-main/artemis/runtime/awake_lease.py +283 -0
  304. package/artemis-main/artemis/runtime/awake_service.py +343 -0
  305. package/artemis-main/artemis/runtime/cancel_requests.py +254 -0
  306. package/artemis-main/artemis/runtime/daemon_client.py +422 -0
  307. package/artemis-main/artemis/runtime/device_lock.py +1013 -0
  308. package/artemis-main/artemis/runtime/device_pool.py +478 -0
  309. package/artemis-main/artemis/runtime/helper_manager.py +1030 -0
  310. package/artemis-main/artemis/runtime/process_probe.py +98 -0
  311. package/artemis-main/artemis/runtime/server_lifecycle.py +435 -0
  312. package/artemis-main/artemis/runtime/supervisor.py +121 -0
  313. package/artemis-main/artemis/runtime/trace_store.py +375 -0
  314. package/artemis-main/artemis/sdk/__init__.py +26 -0
  315. package/artemis-main/artemis/sdk/agent.py +485 -0
  316. package/artemis-main/artemis/sdk/builders/__init__.py +29 -0
  317. package/artemis-main/artemis/sdk/builders/agent_config_builder.py +474 -0
  318. package/artemis-main/artemis/sdk/constants.py +15 -0
  319. package/artemis-main/artemis/sdk/run_outcome.py +58 -0
  320. package/artemis-main/artemis/sdk/types/__init__.py +61 -0
  321. package/artemis-main/artemis/sdk/types/agent.py +89 -0
  322. package/artemis-main/artemis/sdk/utils.py +28 -0
  323. package/artemis-main/artemis/services/llm.py +1040 -0
  324. package/artemis-main/artemis/services/token_meter.py +267 -0
  325. package/artemis-main/artemis/telemetry/__init__.py +35 -0
  326. package/artemis-main/artemis/telemetry/models.py +64 -0
  327. package/artemis-main/artemis/telemetry/replay.py +34 -0
  328. package/artemis-main/artemis/telemetry/storage/json_storage.py +47 -0
  329. package/artemis-main/artemis/telemetry/tracer.py +85 -0
  330. package/artemis-main/artemis/toolchain/__init__.py +52 -0
  331. package/artemis-main/artemis/toolchain/descriptors.py +92 -0
  332. package/artemis-main/artemis/toolchain/resolver.py +185 -0
  333. package/artemis-main/artemis/tools/base.py +232 -0
  334. package/artemis-main/artemis/tools/command_tool.py +1240 -0
  335. package/artemis-main/artemis/tools/committee_prompts.json +5 -0
  336. package/artemis-main/artemis/tools/committee_tool.py +422 -0
  337. package/artemis-main/artemis/tools/diagnoser_submit_answer_tool.py +87 -0
  338. package/artemis-main/artemis/tools/diagnostic_tool.py +120 -0
  339. package/artemis-main/artemis/tools/explorer_tool.py +581 -0
  340. package/artemis-main/artemis/tools/history/__init__.py +398 -0
  341. package/artemis-main/artemis/tools/history/replay.py +120 -0
  342. package/artemis-main/artemis/tools/history/screenshot.py +139 -0
  343. package/artemis-main/artemis/tools/history/search.py +563 -0
  344. package/artemis-main/artemis/tools/image_processor_tool.py +124 -0
  345. package/artemis-main/artemis/tools/index.py +47 -0
  346. package/artemis-main/artemis/tools/log_tool.py +108 -0
  347. package/artemis-main/artemis/tools/mobile/launch_app.py +132 -0
  348. package/artemis-main/artemis/tools/mobile/log_utils.py +138 -0
  349. package/artemis-main/artemis/tools/mobile/ocr.py +202 -0
  350. package/artemis-main/artemis/tools/mobile/read_hierarchy.py +108 -0
  351. package/artemis-main/artemis/tools/mobile/read_logs.py +116 -0
  352. package/artemis-main/artemis/tools/mobile/search_logs.py +260 -0
  353. package/artemis-main/artemis/tools/object_detection_tool.py +244 -0
  354. package/artemis-main/artemis/tools/scratchpad.py +819 -0
  355. package/artemis-main/artemis/tools/system/adb_shell.py +31 -0
  356. package/artemis-main/artemis/tools/tool_wrapper.py +270 -0
  357. package/artemis-main/artemis/tools/video_tool.py +261 -0
  358. package/artemis-main/artemis/tools/wait_tool.py +97 -0
  359. package/artemis-main/artemis/utils/app_launch_utils.py +207 -0
  360. package/artemis-main/artemis/utils/coordinates.py +606 -0
  361. package/artemis-main/artemis/utils/credentials_validator.py +201 -0
  362. package/artemis-main/artemis/utils/cv_canvas.py +189 -0
  363. package/artemis-main/artemis/utils/cython_compat.py +45 -0
  364. package/artemis-main/artemis/utils/element_hit_test.py +80 -0
  365. package/artemis-main/artemis/utils/image_diff.py +335 -0
  366. package/artemis-main/artemis/utils/image_hash.py +80 -0
  367. package/artemis-main/artemis/utils/notes.py +470 -0
  368. package/artemis-main/artemis/utils/ocr_api.py +105 -0
  369. package/artemis-main/artemis/utils/ocr_xml_fusion.py +318 -0
  370. package/artemis-main/artemis/utils/plan_grammar.py +513 -0
  371. package/artemis-main/artemis/utils/python_executor.py +213 -0
  372. package/artemis-main/artemis/utils/startup_progress.py +60 -0
  373. package/artemis-main/artemis/utils/task_tree.py +1174 -0
  374. package/artemis-main/artemis/utils/text.py +37 -0
  375. package/artemis-main/artemis/utils/ui_filter.py +651 -0
  376. package/artemis-main/artemis/utils/video.py +685 -0
  377. package/artemis-main/artemis/utils/visualization.py +802 -0
  378. package/artemis-main/config/artemis.example.jsonc +12 -0
  379. package/artemis-main/mcp_server/README.md +111 -0
  380. package/artemis-main/mcp_server/__init__.py +21 -0
  381. package/artemis-main/mcp_server/__main__.py +20 -0
  382. package/artemis-main/mcp_server/background/__init__.py +15 -0
  383. package/artemis-main/mcp_server/background/task_runner.py +453 -0
  384. package/artemis-main/mcp_server/base.py +49 -0
  385. package/artemis-main/mcp_server/notifiers/__init__.py +74 -0
  386. package/artemis-main/mcp_server/notifiers/agentapi.py +289 -0
  387. package/artemis-main/mcp_server/notifiers/base.py +45 -0
  388. package/artemis-main/mcp_server/notifiers/composite.py +87 -0
  389. package/artemis-main/mcp_server/notifiers/desktop.py +108 -0
  390. package/artemis-main/mcp_server/notifiers/file.py +69 -0
  391. package/artemis-main/mcp_server/notifiers/script.py +90 -0
  392. package/artemis-main/mcp_server/notifiers/webhook.py +87 -0
  393. package/artemis-main/mcp_server/rules.md +68 -0
  394. package/artemis-main/mcp_server/server.py +106 -0
  395. package/artemis-main/mcp_server/tools/__init__.py +29 -0
  396. package/artemis-main/mcp_server/tools/device_state.py +107 -0
  397. package/artemis-main/mcp_server/tools/diagnose.py +1249 -0
  398. package/artemis-main/mcp_server/tools/inspect_trace.py +394 -0
  399. package/artemis-main/mcp_server/tools/task_manager.py +627 -0
  400. package/artemis-main/mcp_server/tools/task_runner.py +548 -0
  401. package/artemis-main/mcp_server/utils/__init__.py +15 -0
  402. package/artemis-main/mcp_server/utils/device_utils.py +180 -0
  403. package/artemis-main/mcp_server/utils/env_utils.py +65 -0
  404. package/artemis-main/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk +0 -0
  405. package/artemis-main/packages/artemis-accessibility-helper/app/build.gradle.kts +35 -0
  406. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +49 -0
  407. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +274 -0
  408. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +227 -0
  409. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +463 -0
  410. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java +111 -0
  411. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +232 -0
  412. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +832 -0
  413. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java +36 -0
  414. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java +38 -0
  415. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java +102 -0
  416. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml +4 -0
  417. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +10 -0
  418. package/artemis-main/packages/artemis-accessibility-helper/build.gradle.kts +3 -0
  419. package/artemis-main/packages/artemis-accessibility-helper/build_apk.sh +215 -0
  420. package/artemis-main/packages/artemis-accessibility-helper/debug.keystore +0 -0
  421. package/artemis-main/packages/artemis-accessibility-helper/gradle.properties +2 -0
  422. package/artemis-main/packages/artemis-accessibility-helper/helper_manifest.json +7 -0
  423. package/artemis-main/packages/artemis-accessibility-helper/settings.gradle.kts +17 -0
  424. package/artemis-main/packages/artemis-client/DESIGN.md +89 -0
  425. package/artemis-main/packages/artemis-client/README.md +112 -0
  426. package/artemis-main/packages/artemis-client/examples/basic.py +30 -0
  427. package/artemis-main/packages/artemis-client/pyproject.toml +36 -0
  428. package/artemis-main/packages/artemis-client/src/artemis_client/__init__.py +45 -0
  429. package/artemis-main/packages/artemis-client/src/artemis_client/client.py +401 -0
  430. package/artemis-main/packages/artemis-client/src/artemis_client/errors.py +66 -0
  431. package/artemis-main/packages/artemis-client/src/artemis_client/models.py +212 -0
  432. package/artemis-main/packages/artemis-client/src/artemis_client/py.typed +1 -0
  433. package/artemis-main/packages/artemis-client/src/artemis_client/transport.py +134 -0
  434. package/artemis-main/packages/artemis-client/tests/test_client.py +289 -0
  435. package/artemis-main/packages/artemis-client/tests/test_transport.py +85 -0
  436. package/artemis-main/pyproject.toml +259 -0
  437. package/artemis-main/pyright-core.json +17 -0
  438. package/artemis-main/scripts/check_dependency_sources.py +68 -0
  439. package/artemis-main/scripts/install_deps.ps1 +520 -0
  440. package/artemis-main/scripts/install_deps.sh +533 -0
  441. package/artemis-main/scripts/quality_ratchet.py +97 -0
  442. package/artemis-main/scripts/real_device_video_acceptance.py +483 -0
  443. package/artemis-main/scripts/run_edge_case_part.py +327 -0
  444. package/artemis-main/scripts/run_ui.py +242 -0
  445. package/artemis-main/scripts/start.ps1 +500 -0
  446. package/artemis-main/scripts/test_all_edge_cases.py +269 -0
  447. package/artemis-main/scripts/verify_concurrency_and_devices.py +385 -0
  448. package/artemis-main/setup.py +164 -0
  449. package/artemis-main/start.bat +35 -0
  450. package/artemis-main/start.sh +423 -0
  451. package/artemis-main/third_party/mobile_use/LICENSE +190 -0
  452. package/artemis-main/third_party/mobile_use/METADATA +27 -0
  453. package/artemis-main/third_party/mobile_use/NOTICE +12 -0
  454. package/artemis-main/third_party/mobile_use/__init__.py +19 -0
  455. package/artemis-main/third_party/mobile_use/agents/__init__.py +13 -0
  456. package/artemis-main/third_party/mobile_use/agents/hopper/__init__.py +13 -0
  457. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.md +22 -0
  458. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.py +80 -0
  459. package/artemis-main/third_party/mobile_use/clients/__init__.py +13 -0
  460. package/artemis-main/third_party/mobile_use/clients/ui_automator_client.py +482 -0
  461. package/artemis-main/third_party/mobile_use/config/__init__.py +13 -0
  462. package/artemis-main/third_party/mobile_use/config/llm.py +183 -0
  463. package/artemis-main/third_party/mobile_use/config/output.py +184 -0
  464. package/artemis-main/third_party/mobile_use/context.py +119 -0
  465. package/artemis-main/third_party/mobile_use/controllers/__init__.py +13 -0
  466. package/artemis-main/third_party/mobile_use/controllers/platform_specific_commands_controller.py +151 -0
  467. package/artemis-main/third_party/mobile_use/controllers/types.py +92 -0
  468. package/artemis-main/third_party/mobile_use/controllers/unified_controller.py +183 -0
  469. package/artemis-main/third_party/mobile_use/graph.py +54 -0
  470. package/artemis-main/third_party/mobile_use/llm_service.py +193 -0
  471. package/artemis-main/third_party/mobile_use/main.py +130 -0
  472. package/artemis-main/third_party/mobile_use/sdk/__init__.py +13 -0
  473. package/artemis-main/third_party/mobile_use/sdk/agent.py +1060 -0
  474. package/artemis-main/third_party/mobile_use/sdk/builders/__init__.py +13 -0
  475. package/artemis-main/third_party/mobile_use/sdk/builders/agent_config_builder.py +225 -0
  476. package/artemis-main/third_party/mobile_use/sdk/builders/index.py +33 -0
  477. package/artemis-main/third_party/mobile_use/sdk/builders/task_request_builder.py +243 -0
  478. package/artemis-main/third_party/mobile_use/sdk/types/__init__.py +13 -0
  479. package/artemis-main/third_party/mobile_use/sdk/types/agent.py +98 -0
  480. package/artemis-main/third_party/mobile_use/sdk/types/exceptions.py +109 -0
  481. package/artemis-main/third_party/mobile_use/sdk/types/task.py +234 -0
  482. package/artemis-main/third_party/mobile_use/testing.py +59 -0
  483. package/artemis-main/third_party/mobile_use/tools/__init__.py +13 -0
  484. package/artemis-main/third_party/mobile_use/tools/index.py +44 -0
  485. package/artemis-main/third_party/mobile_use/tools/mobile/__init__.py +13 -0
  486. package/artemis-main/third_party/mobile_use/tools/mobile/launch_app.py +89 -0
  487. package/artemis-main/third_party/mobile_use/tools/tool_wrapper.py +52 -0
  488. package/artemis-main/third_party/mobile_use/utils/__init__.py +13 -0
  489. package/artemis-main/third_party/mobile_use/utils/app_launch_utils.py +217 -0
  490. package/artemis-main/third_party/mobile_use/utils/cli_helpers.py +60 -0
  491. package/artemis-main/third_party/mobile_use/utils/decorators.py +158 -0
  492. package/artemis-main/third_party/mobile_use/utils/file.py +31 -0
  493. package/artemis-main/third_party/mobile_use/utils/logger.py +216 -0
  494. package/artemis-main/third_party/mobile_use/utils/media.py +213 -0
  495. package/artemis-main/third_party/mobile_use/utils/shell_utils.py +39 -0
  496. package/artemis-main/third_party/mobile_use/utils/video.py +398 -0
  497. package/artemis-main/uv.lock +4908 -0
  498. package/bin/android-midscene-automation.js +1 -5
  499. package/bin/artemis-config.mjs +15 -0
  500. package/bin/artemis-embedded.py +56 -0
  501. package/bin/start-artemis.mjs +30 -0
  502. package/bin/start-dev.mjs +112 -0
  503. package/bin/test_artemis_embedded.py +44 -0
  504. package/package.json +10 -8
  505. package/server/artemis.ts +34 -0
  506. package/server/http-api.ts +13 -1
  507. package/src/App.vue +72 -23
  508. package/src/api.ts +5 -0
  509. package/src/appium-recorder/AppiumPage.vue +1 -1
  510. package/src/appium-recorder/components/ComponentInspector.vue +1 -1
  511. package/src/appium-recorder/components/FlowNodeSearch.vue +3 -3
  512. package/src/appium-recorder/components/ImageCheckDialog.vue +3 -3
  513. package/src/appium-recorder/components/RecorderWorkspace.vue +4 -4
  514. package/src/appium-recorder/flow-appearance.ts +4 -4
  515. package/src/components/AppSidebar.vue +8 -7
  516. package/src/components/MidsceneMaintenanceNotice.vue +17 -0
  517. package/src/components/analytics/AnalysisCharts.vue +4 -4
  518. package/src/components/analytics/ReportAnalysis.vue +1 -1
  519. package/src/components/generator/TestCaseFileUpload.vue +1 -1
  520. package/src/components/help/HelpCenter.vue +56 -37
  521. package/src/pages/AiGeneratorPage.vue +1 -1
  522. package/src/pages/ArtemisPage.vue +72 -0
  523. package/src/pages/ConfigPage.vue +18 -19
  524. package/src/style.css +369 -186
  525. package/src/types.ts +1 -1
@@ -0,0 +1,685 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """scrcpy recording, remuxing, timeline rendering and frame extraction.
16
+
17
+ Recording session types and the session registry live in
18
+ ``third_party.mobile_use.utils.video``.
19
+ """
20
+
21
+ import asyncio
22
+ import importlib.util
23
+ import json
24
+ from pathlib import Path
25
+ import re
26
+ import shutil
27
+ import time
28
+ import subprocess
29
+ from typing import Any
30
+
31
+ import cv2
32
+
33
+ from artemis.utils.cython_compat import CyFunctionDetector
34
+ from third_party.mobile_use.utils.logger import get_logger
35
+
36
+ logger = get_logger(__name__)
37
+
38
+ ANDROID_RECORDING_SEGMENT_SECONDS = 1800
39
+ # The recording marker closely tracks the first frame; startup measurements
40
+ # put the fallback near three quarters of a second after process creation.
41
+ SCRCPY_RECORDING_STARTED_MARKER = "Recording started"
42
+ SCRCPY_STARTUP_FALLBACK_SECONDS = 0.75
43
+ SCRCPY_STARTUP_TIMEOUT_SECONDS = 6.0
44
+ # Ignore small timing differences at segment boundaries.
45
+ TIMELINE_GAP_EPSILON_SECONDS = 0.05
46
+
47
+
48
+ def build_scrcpy_record_command(
49
+ scrcpy_executable: str,
50
+ device_id: str,
51
+ output_path: Path,
52
+ video_bit_rate: str = "2M",
53
+ lock_capture_orientation: bool = True,
54
+ ) -> list[str]:
55
+ """Build the shared scrcpy command used by all recording paths.
56
+
57
+ Each recording segment locks the orientation present when scrcpy starts.
58
+ The recording supervisor starts a new segment when Android rotates, so a
59
+ segment never contains multiple coded sizes while still displaying the app
60
+ in its natural orientation.
61
+ """
62
+ command = [
63
+ scrcpy_executable,
64
+ "--serial",
65
+ device_id,
66
+ "--no-window",
67
+ "--record",
68
+ str(output_path),
69
+ "--record-format",
70
+ "mkv",
71
+ "--video-bit-rate",
72
+ video_bit_rate,
73
+ ]
74
+ if lock_capture_orientation:
75
+ command.append("--capture-orientation=@")
76
+ return command
77
+
78
+
79
+ async def await_scrcpy_first_frame(
80
+ process: Any,
81
+ spawned_at: float,
82
+ *,
83
+ timeout: float = SCRCPY_STARTUP_TIMEOUT_SECONDS,
84
+ ) -> float:
85
+ """Estimate when the first frame of a scrcpy recording reached the host."""
86
+ fallback = spawned_at + SCRCPY_STARTUP_FALLBACK_SECONDS
87
+ reader = getattr(process, "stdout", None)
88
+ if not isinstance(reader, asyncio.StreamReader):
89
+ await asyncio.sleep(0.8)
90
+ return fallback
91
+
92
+ deadline = time.monotonic() + timeout
93
+ while True:
94
+ remaining = deadline - time.monotonic()
95
+ if remaining <= 0:
96
+ logger.warning(
97
+ f"scrcpy did not report '{SCRCPY_RECORDING_STARTED_MARKER}' within "
98
+ f"{timeout:.1f}s; assuming the first frame at spawn + "
99
+ f"{SCRCPY_STARTUP_FALLBACK_SECONDS:.2f}s"
100
+ )
101
+ return fallback
102
+ try:
103
+ line = await asyncio.wait_for(reader.readline(), timeout=remaining)
104
+ except TimeoutError:
105
+ continue
106
+ if not line:
107
+ try:
108
+ await asyncio.wait_for(process.wait(), timeout=1.0)
109
+ except (AttributeError, ProcessLookupError, TimeoutError):
110
+ pass
111
+ return fallback
112
+ if SCRCPY_RECORDING_STARTED_MARKER in line.decode(errors="replace"):
113
+ return max(spawned_at, time.time())
114
+
115
+
116
+ def is_ffmpeg_installed() -> bool:
117
+ """Check if ffmpeg is available via imageio_ffmpeg or system PATH."""
118
+
119
+ if importlib.util.find_spec("imageio_ffmpeg") is not None:
120
+ return True
121
+ return shutil.which("ffmpeg") is not None
122
+
123
+
124
+ def get_ffmpeg_path() -> str:
125
+ """Get the path to the ffmpeg executable."""
126
+ try:
127
+ import imageio_ffmpeg
128
+
129
+ return imageio_ffmpeg.get_ffmpeg_exe()
130
+ except ImportError:
131
+ return "ffmpeg"
132
+
133
+
134
+ def get_ffprobe_path() -> str:
135
+ """Get ffprobe for lightweight segment metadata inspection."""
136
+ return shutil.which("ffprobe") or "ffprobe"
137
+
138
+
139
+ async def normalize_recording_to_mp4(
140
+ source_path: Path,
141
+ output_path: Path,
142
+ capture_width: int | None,
143
+ capture_height: int | None,
144
+ ) -> bool:
145
+ """Re-encode a recording onto one fixed canvas for browser playback."""
146
+ if not source_path.exists():
147
+ return False
148
+
149
+ width = max(2, int(capture_width or 1080)) // 2 * 2
150
+ height = max(2, int(capture_height or 1920)) // 2 * 2
151
+ video_filter = (
152
+ f"scale={width}:{height}:force_original_aspect_ratio=decrease:"
153
+ f"force_divisible_by=2,pad={width}:{height}:"
154
+ "(ow-iw)/2:(oh-ih)/2:color=black,setsar=1"
155
+ )
156
+
157
+ try:
158
+ process = await asyncio.create_subprocess_exec(
159
+ get_ffmpeg_path(),
160
+ "-y",
161
+ "-fflags",
162
+ "+genpts",
163
+ "-i",
164
+ str(source_path),
165
+ "-map",
166
+ "0:v:0",
167
+ "-map",
168
+ "0:a?",
169
+ "-vf",
170
+ video_filter,
171
+ "-r",
172
+ "30",
173
+ "-fps_mode",
174
+ "cfr",
175
+ "-c:v",
176
+ "libx264",
177
+ "-preset",
178
+ "veryfast",
179
+ "-crf",
180
+ "23",
181
+ "-pix_fmt",
182
+ "yuv420p",
183
+ "-c:a",
184
+ "aac",
185
+ "-b:a",
186
+ "128k",
187
+ "-movflags",
188
+ "+faststart",
189
+ str(output_path),
190
+ stdout=asyncio.subprocess.PIPE,
191
+ stderr=asyncio.subprocess.PIPE,
192
+ )
193
+ _stdout, stderr = await process.communicate()
194
+ if process.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
195
+ return True
196
+
197
+ error_detail = stderr.decode(errors="replace")[-4000:]
198
+ logger.error(
199
+ f"Failed to normalize recording to MP4 (code {process.returncode}): {error_detail}"
200
+ )
201
+ if output_path.exists():
202
+ output_path.unlink()
203
+ return False
204
+ except Exception as e:
205
+ logger.error(f"Failed to normalize recording to MP4: {e}")
206
+ if output_path.exists():
207
+ output_path.unlink()
208
+ return False
209
+
210
+
211
+ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
212
+ """Atomically publish a fixed-dimension recording segment as MP4."""
213
+ if not source_path.exists() or source_path.stat().st_size == 0:
214
+ return False
215
+ temporary_path = output_path.with_name(f"{output_path.stem}.part{output_path.suffix}")
216
+ if temporary_path.exists():
217
+ temporary_path.unlink()
218
+ try:
219
+ process = await asyncio.create_subprocess_exec(
220
+ get_ffmpeg_path(),
221
+ "-y",
222
+ "-fflags",
223
+ "+genpts",
224
+ "-i",
225
+ str(source_path),
226
+ "-map",
227
+ "0:v:0",
228
+ "-map",
229
+ "0:a?",
230
+ "-c",
231
+ "copy",
232
+ "-movflags",
233
+ "+faststart",
234
+ str(temporary_path),
235
+ stdout=asyncio.subprocess.PIPE,
236
+ stderr=asyncio.subprocess.PIPE,
237
+ )
238
+ _stdout, stderr = await process.communicate()
239
+ valid = (
240
+ process.returncode == 0
241
+ and temporary_path.exists()
242
+ and temporary_path.stat().st_size > 0
243
+ )
244
+ if valid:
245
+ temporary_path.replace(output_path)
246
+ return True
247
+
248
+ logger.warning(
249
+ f"Direct remux failed for {source_path} (code {process.returncode}), "
250
+ "attempting transcode fallback with ffmpeg..."
251
+ )
252
+ # Fallback to ultrafast re-encoding in case MKV container was truncated or has timestamp irregularities
253
+ fallback_proc = await asyncio.create_subprocess_exec(
254
+ get_ffmpeg_path(),
255
+ "-y",
256
+ "-fflags",
257
+ "+genpts+discardcorrupt",
258
+ "-i",
259
+ str(source_path),
260
+ "-c:v",
261
+ "libx264",
262
+ "-preset",
263
+ "ultrafast",
264
+ "-pix_fmt",
265
+ "yuv420p",
266
+ "-movflags",
267
+ "+faststart",
268
+ str(temporary_path),
269
+ stdout=asyncio.subprocess.PIPE,
270
+ stderr=asyncio.subprocess.PIPE,
271
+ )
272
+ _fout, _ferr = await fallback_proc.communicate()
273
+ if (
274
+ fallback_proc.returncode == 0
275
+ and temporary_path.exists()
276
+ and temporary_path.stat().st_size > 0
277
+ ):
278
+ temporary_path.replace(output_path)
279
+ return True
280
+
281
+ logger.error(
282
+ f"Failed to remux recording segment (code {process.returncode}): "
283
+ f"{stderr.decode(errors='replace')[-2000:]}"
284
+ )
285
+ except Exception as e:
286
+ logger.error(f"Failed to remux recording segment: {e}")
287
+ if temporary_path.exists():
288
+ temporary_path.unlink()
289
+ return False
290
+
291
+
292
+ async def probe_video_segment(video_path: Path) -> dict[str, float | int]:
293
+ """Read duration and coded dimensions for a finalized segment."""
294
+ process = await asyncio.create_subprocess_exec(
295
+ get_ffprobe_path(),
296
+ "-v",
297
+ "error",
298
+ "-show_entries",
299
+ "stream=width,height,duration,codec_type:format=duration",
300
+ "-of",
301
+ "json",
302
+ str(video_path),
303
+ stdout=asyncio.subprocess.PIPE,
304
+ stderr=asyncio.subprocess.PIPE,
305
+ )
306
+ stdout, _stderr = await process.communicate()
307
+ if process.returncode != 0:
308
+ return {}
309
+ try:
310
+ payload = json.loads(stdout)
311
+ streams = payload.get("streams") or []
312
+ video_stream = next(
313
+ (s for s in streams if s.get("width") and s.get("height")),
314
+ next(
315
+ (s for s in streams if s.get("codec_type") == "video"),
316
+ streams[0] if streams else {},
317
+ ),
318
+ )
319
+ duration = float(
320
+ (payload.get("format") or {}).get("duration") or video_stream.get("duration") or 0
321
+ )
322
+ return {
323
+ "duration": duration,
324
+ "width": int(video_stream.get("width") or 0),
325
+ "height": int(video_stream.get("height") or 0),
326
+ }
327
+ except (TypeError, ValueError, json.JSONDecodeError):
328
+ return {}
329
+
330
+
331
+ async def get_android_display_state(device_id: str) -> tuple[int, int, int] | None:
332
+ """Return Android's current (rotation, width, height)."""
333
+ try:
334
+ process = await asyncio.create_subprocess_exec(
335
+ "adb",
336
+ "-s",
337
+ device_id,
338
+ "shell",
339
+ "dumpsys",
340
+ "window",
341
+ "displays",
342
+ stdout=asyncio.subprocess.PIPE,
343
+ stderr=asyncio.subprocess.DEVNULL,
344
+ )
345
+ stdout, _stderr = await asyncio.wait_for(process.communicate(), timeout=3.0)
346
+ text = stdout.decode(errors="replace")
347
+ rotation_match = re.search(r"\bmRotation=(\d+)", text)
348
+ size_match = re.search(r"\bcur=(\d+)x(\d+)", text)
349
+ if rotation_match and size_match:
350
+ return (
351
+ int(rotation_match.group(1)),
352
+ int(size_match.group(1)),
353
+ int(size_match.group(2)),
354
+ )
355
+ except (OSError, TimeoutError):
356
+ pass
357
+ return None
358
+
359
+
360
+ RECORDING_MANIFEST_VERSION = 2
361
+
362
+
363
+ async def write_recording_manifest(
364
+ output_dir: Path,
365
+ mp4_paths: list[Path],
366
+ segment_offsets: dict[Path, float] | None = None,
367
+ ) -> Path | None:
368
+ """Write the browser playlist used for orientation-aware playback.
369
+
370
+ ``segment_offsets`` maps each MP4 path to the wall-clock offset (seconds)
371
+ of its first frame relative to the DataEngine session start. Segments are
372
+ still listed back to back via ``start``/``duration`` for the legacy player
373
+ timeline, and additionally carry ``offset_ms``/``duration_ms`` so the UI
374
+ can map a session-relative step time onto the right segment even though
375
+ scrcpy restarts (rotation, crash recovery) leave gaps between segments.
376
+ A segment without a known offset is assumed to follow the previous one
377
+ without a gap.
378
+ """
379
+ segments = []
380
+ timeline = 0.0
381
+ session_cursor_ms = 0
382
+ offsets = {
383
+ Path(path).resolve(): float(offset) for path, offset in (segment_offsets or {}).items()
384
+ }
385
+ for path in mp4_paths:
386
+ if not path.exists():
387
+ continue
388
+ metadata = await probe_video_segment(path)
389
+ duration = float(metadata.get("duration", 0))
390
+ width = int(metadata.get("width", 0))
391
+ height = int(metadata.get("height", 0))
392
+ if duration <= 0 or width <= 0 or height <= 0:
393
+ logger.warning(f"Recording segment skipped due to invalid metadata: {path}")
394
+ continue
395
+ duration_ms = int(round(duration * 1000))
396
+ known_offset = offsets.get(path.resolve())
397
+ offset_ms = (
398
+ max(0, int(round(known_offset * 1000)))
399
+ if known_offset is not None
400
+ else session_cursor_ms
401
+ )
402
+ segments.append(
403
+ {
404
+ "file": path.name,
405
+ "start": round(timeline, 3),
406
+ "duration": round(duration, 3),
407
+ "offset_ms": offset_ms,
408
+ "duration_ms": duration_ms,
409
+ "width": width,
410
+ "height": height,
411
+ }
412
+ )
413
+ timeline += duration
414
+ session_cursor_ms = offset_ms + duration_ms
415
+ if not segments:
416
+ return None
417
+ manifest_path = output_dir / "recording.json"
418
+ temporary_manifest_path = output_dir / "recording.part.json"
419
+ temporary_manifest_path.write_text(
420
+ json.dumps(
421
+ {
422
+ "version": RECORDING_MANIFEST_VERSION,
423
+ "duration": round(timeline, 3),
424
+ "session_offset_ms": segments[0]["offset_ms"],
425
+ "session_end_ms": session_cursor_ms,
426
+ "segments": segments,
427
+ },
428
+ indent=2,
429
+ ),
430
+ encoding="utf-8",
431
+ )
432
+ temporary_manifest_path.replace(manifest_path)
433
+ return manifest_path
434
+
435
+
436
+ def plan_timeline_pieces(
437
+ segments: list[dict[str, Any]], start_time: float, end_time: float
438
+ ) -> list[tuple[Any, ...]]:
439
+ """Split a recording window into file ranges and gaps between segments.
440
+
441
+ File pieces are ``("file", path, local_start, local_end)``; gaps are
442
+ ``("gap", seconds)``. Include leading and trailing gaps to preserve timeline
443
+ offsets, ignoring gaps below the timing tolerance. Return an empty list if
444
+ no existing segment overlaps the window.
445
+ """
446
+ pieces: list[tuple[Any, ...]] = []
447
+ cursor = start_time
448
+ for segment in sorted(segments, key=lambda item: float(item["start"])):
449
+ path = Path(segment["path"])
450
+ seg_start = float(segment["start"])
451
+ seg_end = float(segment["end"])
452
+ overlap_start = max(start_time, seg_start)
453
+ overlap_end = min(end_time, seg_end)
454
+ if not path.exists() or overlap_end <= overlap_start:
455
+ continue
456
+ if overlap_start - cursor > TIMELINE_GAP_EPSILON_SECONDS:
457
+ pieces.append(("gap", overlap_start - cursor))
458
+ pieces.append(("file", path, overlap_start - seg_start, overlap_end - seg_start))
459
+ cursor = max(cursor, overlap_end)
460
+ if not any(piece[0] == "file" for piece in pieces):
461
+ return []
462
+ if end_time - cursor > TIMELINE_GAP_EPSILON_SECONDS:
463
+ pieces.append(("gap", end_time - cursor))
464
+ return pieces
465
+
466
+
467
+ async def render_timeline_clip(
468
+ segments: list[dict[str, Any]],
469
+ start_time: float,
470
+ end_time: float,
471
+ output_path: Path,
472
+ canvas_width: int = 720,
473
+ canvas_height: int = 1280,
474
+ fps: int = 15,
475
+ ) -> bool:
476
+ """Render one continuous analyzer clip from orientation-aware segments.
477
+
478
+ Decode only the requested window and fill recording gaps with black frames
479
+ to keep clip timestamps aligned with the recording timeline.
480
+ """
481
+ pieces = plan_timeline_pieces(segments, start_time, end_time)
482
+ if not pieces:
483
+ return False
484
+
485
+ command = [get_ffmpeg_path(), "-y", "-fflags", "+genpts"]
486
+ filter_parts = []
487
+ labels = []
488
+ for index, piece in enumerate(pieces):
489
+ label = f"v{index}"
490
+ if piece[0] == "gap":
491
+ duration = float(piece[1])
492
+ command.extend(
493
+ [
494
+ "-f",
495
+ "lavfi",
496
+ "-i",
497
+ f"color=c=black:s={canvas_width}x{canvas_height}:r={fps}:d={duration:.3f}",
498
+ ]
499
+ )
500
+ filter_parts.append(
501
+ f"[{index}:v:0]trim=duration={duration:.6f},setpts=PTS-STARTPTS,"
502
+ f"setsar=1,format=yuv420p[{label}]"
503
+ )
504
+ else:
505
+ _kind, path, local_start, local_end = piece
506
+ command.extend(["-i", str(path)])
507
+ filter_parts.append(
508
+ f"[{index}:v:0]trim=start={local_start:.6f}:end={local_end:.6f},"
509
+ "setpts=PTS-STARTPTS,"
510
+ f"scale={canvas_width}:{canvas_height}:force_original_aspect_ratio=decrease:"
511
+ "force_divisible_by=2,"
512
+ f"pad={canvas_width}:{canvas_height}:(ow-iw)/2:(oh-ih)/2:color=black,"
513
+ f"setsar=1,fps={fps},format=yuv420p[{label}]"
514
+ )
515
+ labels.append(f"[{label}]")
516
+ # ``concat`` does not advertise a frame rate on its output link, so without an
517
+ # explicit rate ffmpeg falls back to 25 fps CFR and duplicates frames, breaking
518
+ # the ``start_time + frame_index / fps`` mapping the analyzer relies on.
519
+ if len(labels) == 1:
520
+ filter_parts.append(f"{labels[0]}fps={fps}[outv]")
521
+ else:
522
+ filter_parts.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0,fps={fps}[outv]")
523
+
524
+ command.extend(
525
+ [
526
+ "-filter_complex",
527
+ ";".join(filter_parts),
528
+ "-map",
529
+ "[outv]",
530
+ "-c:v",
531
+ "libx264",
532
+ "-preset",
533
+ "veryfast",
534
+ "-crf",
535
+ "23",
536
+ "-movflags",
537
+ "+faststart",
538
+ str(output_path),
539
+ ]
540
+ )
541
+ try:
542
+ process = await asyncio.create_subprocess_exec(
543
+ *command,
544
+ stdout=asyncio.subprocess.PIPE,
545
+ stderr=asyncio.subprocess.PIPE,
546
+ )
547
+ _stdout, stderr = await process.communicate()
548
+ if process.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
549
+ return True
550
+ logger.error(
551
+ f"Failed to render analyzer timeline clip (code {process.returncode}): "
552
+ f"{stderr.decode(errors='replace')[-3000:]}"
553
+ )
554
+ except Exception as e:
555
+ logger.error(f"Failed to render analyzer timeline clip: {e}")
556
+ if output_path.exists():
557
+ output_path.unlink()
558
+ return False
559
+
560
+
561
+ def is_scrcpy_installed() -> bool:
562
+ """Check if scrcpy is available in the system PATH."""
563
+
564
+ return shutil.which("scrcpy") is not None
565
+
566
+
567
+ def detect_video_tools_enabled() -> bool:
568
+ """Check if both scrcpy and ffmpeg are available to enable automated video features."""
569
+ return is_ffmpeg_installed() and is_scrcpy_installed()
570
+
571
+
572
+ _drawtext_supported: bool | None = None
573
+
574
+
575
+ def is_ffmpeg_drawtext_supported() -> bool:
576
+ """Check if ffmpeg supports the drawtext filter (requires libfreetype/libharfbuzz)."""
577
+ global _drawtext_supported
578
+ if _drawtext_supported is not None:
579
+ return _drawtext_supported
580
+ try:
581
+ res = subprocess.run(
582
+ [get_ffmpeg_path(), "-filters"],
583
+ capture_output=True,
584
+ text=True,
585
+ timeout=3,
586
+ )
587
+ _drawtext_supported = " drawtext " in res.stdout
588
+ except Exception:
589
+ _drawtext_supported = False
590
+ return _drawtext_supported
591
+
592
+
593
+ def extract_keyframes_from_video(
594
+ video_path: Path | str,
595
+ fps: float = 1.0,
596
+ max_frames: int = 45,
597
+ max_dimension: int = 1080,
598
+ ) -> list[tuple[float, bytes]]:
599
+ """Extracts keyframes sampled at `fps` intervals.
600
+
601
+ Returns:
602
+ List of tuples: (timestamp_seconds, jpeg_bytes).
603
+ """
604
+
605
+ cap = cv2.VideoCapture(str(video_path))
606
+ if not cap.isOpened():
607
+ logger.error(f"Failed to open video file for keyframe extraction: {video_path}")
608
+ return []
609
+
610
+ video_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
611
+ step = max(1, int(round(video_fps / fps)))
612
+
613
+ frames: list[tuple[float, bytes]] = []
614
+ frame_idx = 0
615
+
616
+ while cap.isOpened() and len(frames) < max_frames:
617
+ ret, frame = cap.read()
618
+ if not ret:
619
+ break
620
+
621
+ if frame_idx % step == 0:
622
+ timestamp_sec = frame_idx / video_fps
623
+ h, w = frame.shape[:2]
624
+ if max(h, w) > max_dimension:
625
+ scale = max_dimension / max(h, w)
626
+ frame = cv2.resize(
627
+ frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
628
+ )
629
+
630
+ _, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
631
+ frames.append((timestamp_sec, buf.tobytes()))
632
+
633
+ frame_idx += 1
634
+
635
+ cap.release()
636
+ return frames
637
+
638
+
639
+ def extract_frames_at_timestamps(
640
+ video_path: Path | str,
641
+ timestamps: list[float],
642
+ *,
643
+ max_frames: int = 30,
644
+ max_dimension: int = 1080,
645
+ ) -> list[tuple[float, bytes]]:
646
+ """Seek to exact video-relative timestamps and return JPEG evidence frames."""
647
+
648
+ if max_frames <= 0:
649
+ return []
650
+ requested = sorted(
651
+ {
652
+ round(float(timestamp), 3)
653
+ for timestamp in timestamps
654
+ if isinstance(timestamp, (int, float)) and float(timestamp) >= 0.0
655
+ }
656
+ )[:max_frames]
657
+ if not requested:
658
+ return []
659
+
660
+ cap = cv2.VideoCapture(str(video_path))
661
+ if not cap.isOpened():
662
+ logger.error(f"Failed to open video file for targeted frame extraction: {video_path}")
663
+ return []
664
+
665
+ frames: list[tuple[float, bytes]] = []
666
+ try:
667
+ for timestamp in requested:
668
+ cap.set(cv2.CAP_PROP_POS_MSEC, timestamp * 1000.0)
669
+ ret, frame = cap.read()
670
+ if not ret:
671
+ continue
672
+ h, w = frame.shape[:2]
673
+ if max(h, w) > max_dimension:
674
+ scale = max_dimension / max(h, w)
675
+ frame = cv2.resize(
676
+ frame,
677
+ (max(1, int(w * scale)), max(1, int(h * scale))),
678
+ interpolation=cv2.INTER_AREA,
679
+ )
680
+ encoded, buffer = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 85])
681
+ if encoded:
682
+ frames.append((timestamp, buffer.tobytes()))
683
+ finally:
684
+ cap.release()
685
+ return frames