elanous 0.2.5 → 0.2.6

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 (300) hide show
  1. package/README.md +5 -4
  2. package/apps/pwa/out/404/index.html +1 -1
  3. package/apps/pwa/out/404.html +1 -1
  4. package/apps/pwa/out/_next/static/chunks/1169-696263ac7af8c4d4.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/{7527.943753b6ed829f09.js → 1883.c91a2648af4842fd.js} +1 -1
  6. package/apps/pwa/out/_next/static/chunks/1947-9d64872eeb9cfdec.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/2808-dc06482994b40f70.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/{8918-482b30ce2a8ebf4c.js → 2984-977cfa339fcd65a5.js} +3 -3
  9. package/apps/pwa/out/_next/static/chunks/{4853-35fdbe6bdbcdc0fc.js → 3165-95515ca5f299a120.js} +1 -1
  10. package/apps/pwa/out/_next/static/chunks/3190-e9282753e7ebde85.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/{5391-8793f88b03b30161.js → 3995-bee512a7063036ce.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/{9704-3221c102623c8d73.js → 4240-7153881b0b377306.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/4259-99614149577e77cb.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/4283-329eabeaf233e6cd.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/4328-cb5058b1acc2de5e.js +25 -0
  16. package/apps/pwa/out/_next/static/chunks/4801-ecee6ef62b349b18.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/5110-8058791dd25a2e14.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/5329.8b7e4bca684b6eec.js +5 -0
  19. package/apps/pwa/out/_next/static/chunks/5330-c41e9670f594c38f.js +1 -0
  20. package/apps/pwa/out/_next/static/chunks/5423-e95fd125d1794349.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/5478-b5eae07dc79ea619.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/5787-96ee6642b9a661e5.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/6448-79d72368589f98bc.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/6522-9866c002b5531836.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/6729-b4a558b7aa891e0c.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/6969-cb03f9410b7d2e03.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/7007-3b9861091bb8dd21.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/{5348-bbf838e119661eee.js → 763-7d0e1913b5074e98.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/{8617.465ca22a319c95b3.js → 7633.0654fe6ea259c1ee.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/7930-8dc7e20fc6fc19ca.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/8071-187edbc22a86741f.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/8198-d4101ad70cb9a579.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/{9238.2cd5f47f4bf7a2c3.js → 8250.4a2c5553a07cdf47.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/8293-123b91e7b0330dc0.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/8296-d6d5d87dfa911d41.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/8305-603963c33ee8eec3.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/8612-4a0066f20951b8fd.js +1 -0
  38. package/apps/pwa/out/_next/static/chunks/{4daa2798-1363d725c79f3593.js → 8d0d9faf-97887ff99713b84e.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/9181-cf4e1b61c4783f92.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/{f6f3850d-7f324c6566002113.js → 91ff6dda-3b33d42d20657429.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/9262-26c2ecbd5a67e4a2.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/9328-45abdebce4fbecb7.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/9355-dbc67205adf8745f.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/9763.255b90dd69875689.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/9933-66d31ec25c0231af.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-bd06fb55f4da0381.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/approvals/page-4e586142da455a25.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-889ad5cddcf45b0d.js +1 -0
  49. package/apps/pwa/out/_next/static/chunks/app/board/page-47ba401d56572c27.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/botlab/page-0b54f9c83458e038.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/bots/page-4fcc296ef6c8ed90.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/chat/page-fe9fda494a87f4e5.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/control/page-f9642e25ce129590.js +1 -0
  54. package/apps/pwa/out/_next/static/chunks/app/design-check/page-e1063e8ba5f3e2e7.js +1 -0
  55. package/apps/pwa/out/_next/static/chunks/app/editor/page-9d0df30027d8aa9d.js +1 -0
  56. package/apps/pwa/out/_next/static/chunks/app/healthz/route-bd06fb55f4da0381.js +1 -0
  57. package/apps/pwa/out/_next/static/chunks/app/intake/page-d97adf2c9455b8d0.js +1 -0
  58. package/apps/pwa/out/_next/static/chunks/app/layout-236b80d202110362.js +1 -0
  59. package/apps/pwa/out/_next/static/chunks/app/live/page-21a30f35f1a276ad.js +1 -0
  60. package/apps/pwa/out/_next/static/chunks/app/market/page-b1a5be357c4fa25d.js +1 -0
  61. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-1705a60303f7f573.js +1 -0
  62. package/apps/pwa/out/_next/static/chunks/app/morning/page-76b337c11ad0e237.js +1 -0
  63. package/apps/pwa/out/_next/static/chunks/app/not-found-7c0f85fbb56f64c6.js +1 -0
  64. package/apps/pwa/out/_next/static/chunks/app/observatory/page-65ca16b20617b44d.js +1 -0
  65. package/apps/pwa/out/_next/static/chunks/app/page-e7f9b8fc29411c1d.js +1 -0
  66. package/apps/pwa/out/_next/static/chunks/app/reflection/page-92ea6213d1aeec04.js +1 -0
  67. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a913a005aacb5476.js +1 -0
  68. package/apps/pwa/out/_next/static/chunks/app/sessions/page-2d78804a40dfdef6.js +1 -0
  69. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-08a99148e780ac00.js +1 -0
  70. package/apps/pwa/out/_next/static/chunks/app/settings/page-deb674e7ed36f815.js +1 -0
  71. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-49f8de0534d192d7.js +1 -0
  72. package/apps/pwa/out/_next/static/chunks/app/setup/layout-bd06fb55f4da0381.js +1 -0
  73. package/apps/pwa/out/_next/static/chunks/app/setup/page-ba162a28d2176e18.js +1 -0
  74. package/apps/pwa/out/_next/static/chunks/app/share/page-1ff1a37b550ab41a.js +1 -0
  75. package/apps/pwa/out/_next/static/chunks/app/showroom/page-ccaa698f56c0fbdb.js +1 -0
  76. package/apps/pwa/out/_next/static/chunks/app/tasks/page-53b944082de1d7db.js +1 -0
  77. package/apps/pwa/out/_next/static/chunks/app/term/page-9b70adf1552789bf.js +1 -0
  78. package/apps/pwa/out/_next/static/chunks/app/trace/page-30d482aa63241fe8.js +1 -0
  79. package/apps/pwa/out/_next/static/chunks/app/vault/page-aecf786f54651d8a.js +1 -0
  80. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-6ab9b62c7b6e08f2.js +1 -0
  81. package/apps/pwa/out/_next/static/chunks/app/workflows/page-08949735455e0c9a.js +1 -0
  82. package/apps/pwa/out/_next/static/chunks/app/workspace/page-a1479005234a46c2.js +1 -0
  83. package/apps/pwa/out/_next/static/chunks/app/worktrees/{page-84cd419494e16b0f.js → page-c3890e90bd232732.js} +1 -1
  84. package/apps/pwa/out/_next/static/chunks/{06ef2eba-d1b99ac6d2ea663c.js → beb5cf10-9adcc10b83930090.js} +1 -1
  85. package/apps/pwa/out/_next/static/chunks/framework-d68d41c88e05c801.js +1 -0
  86. package/apps/pwa/out/_next/static/chunks/main-app-11c63971b7a9593b.js +1 -0
  87. package/apps/pwa/out/_next/static/chunks/main-d09452353bd0d9cc.js +1 -0
  88. package/apps/pwa/out/_next/static/chunks/pages/_app-184d727cebeca767.js +1 -0
  89. package/apps/pwa/out/_next/static/chunks/pages/_error-21bbbbac99b720b1.js +1 -0
  90. package/apps/pwa/out/_next/static/chunks/{webpack-2bf59c720fe9542c.js → webpack-37b8bf78a3c6eec5.js} +1 -1
  91. package/apps/pwa/out/_next/static/{ktMqfrBbf8BMGac0jqLrb → qMI0jnlZaBjjm4aIDYNGM}/_buildManifest.js +1 -1
  92. package/apps/pwa/out/approvals/index.html +1 -1
  93. package/apps/pwa/out/approvals/index.txt +21 -21
  94. package/apps/pwa/out/autopilot/index.html +1 -1
  95. package/apps/pwa/out/autopilot/index.txt +21 -21
  96. package/apps/pwa/out/board/index.html +1 -1
  97. package/apps/pwa/out/board/index.txt +21 -21
  98. package/apps/pwa/out/botlab/index.html +1 -1
  99. package/apps/pwa/out/botlab/index.txt +20 -20
  100. package/apps/pwa/out/bots/index.html +1 -1
  101. package/apps/pwa/out/bots/index.txt +20 -20
  102. package/apps/pwa/out/chat/index.html +1 -1
  103. package/apps/pwa/out/chat/index.txt +20 -20
  104. package/apps/pwa/out/control/index.html +1 -1
  105. package/apps/pwa/out/control/index.txt +21 -21
  106. package/apps/pwa/out/design-check/index.html +1 -1
  107. package/apps/pwa/out/design-check/index.txt +21 -21
  108. package/apps/pwa/out/editor/index.html +1 -1
  109. package/apps/pwa/out/editor/index.txt +21 -21
  110. package/apps/pwa/out/healthz +1 -1
  111. package/apps/pwa/out/icons/elanous-16.png +0 -0
  112. package/apps/pwa/out/icons/elanous-180.png +0 -0
  113. package/apps/pwa/out/icons/elanous-192.png +0 -0
  114. package/apps/pwa/out/icons/elanous-32.png +0 -0
  115. package/apps/pwa/out/icons/elanous-512.png +0 -0
  116. package/apps/pwa/out/index.html +1 -1
  117. package/apps/pwa/out/index.txt +20 -20
  118. package/apps/pwa/out/intake/index.html +1 -1
  119. package/apps/pwa/out/intake/index.txt +21 -21
  120. package/apps/pwa/out/live/index.html +1 -1
  121. package/apps/pwa/out/live/index.txt +21 -21
  122. package/apps/pwa/out/market/index.html +1 -1
  123. package/apps/pwa/out/market/index.txt +20 -20
  124. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  125. package/apps/pwa/out/missions/placeholder/index.txt +20 -20
  126. package/apps/pwa/out/morning/index.html +1 -1
  127. package/apps/pwa/out/morning/index.txt +21 -21
  128. package/apps/pwa/out/observatory/index.html +1 -1
  129. package/apps/pwa/out/observatory/index.txt +21 -21
  130. package/apps/pwa/out/reflection/index.html +1 -1
  131. package/apps/pwa/out/reflection/index.txt +21 -21
  132. package/apps/pwa/out/scheduler/index.html +1 -1
  133. package/apps/pwa/out/scheduler/index.txt +21 -21
  134. package/apps/pwa/out/sessions/index.html +1 -1
  135. package/apps/pwa/out/sessions/index.txt +20 -20
  136. package/apps/pwa/out/settings/devices/index.html +1 -1
  137. package/apps/pwa/out/settings/devices/index.txt +21 -21
  138. package/apps/pwa/out/settings/index.html +1 -1
  139. package/apps/pwa/out/settings/index.txt +21 -21
  140. package/apps/pwa/out/setup/done/index.html +1 -1
  141. package/apps/pwa/out/setup/done/index.txt +21 -21
  142. package/apps/pwa/out/setup/index.html +1 -1
  143. package/apps/pwa/out/setup/index.txt +21 -21
  144. package/apps/pwa/out/share/index.html +1 -1
  145. package/apps/pwa/out/share/index.txt +21 -21
  146. package/apps/pwa/out/showroom/index.html +1 -1
  147. package/apps/pwa/out/showroom/index.txt +20 -20
  148. package/apps/pwa/out/tasks/index.html +1 -1
  149. package/apps/pwa/out/tasks/index.txt +21 -21
  150. package/apps/pwa/out/term/index.html +1 -1
  151. package/apps/pwa/out/term/index.txt +21 -21
  152. package/apps/pwa/out/trace/index.html +1 -1
  153. package/apps/pwa/out/trace/index.txt +21 -21
  154. package/apps/pwa/out/vault/index.html +1 -1
  155. package/apps/pwa/out/vault/index.txt +21 -21
  156. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  157. package/apps/pwa/out/workflows/chat-ui/index.txt +21 -21
  158. package/apps/pwa/out/workflows/index.html +1 -1
  159. package/apps/pwa/out/workflows/index.txt +21 -21
  160. package/apps/pwa/out/workspace/index.html +1 -1
  161. package/apps/pwa/out/workspace/index.txt +21 -21
  162. package/apps/pwa/out/worktrees/index.html +1 -1
  163. package/apps/pwa/out/worktrees/index.txt +21 -21
  164. package/graphs/release/recipes.yaml +3 -0
  165. package/graphs/release/release-loop.yaml +3 -1
  166. package/package.json +1 -1
  167. package/plugins/card-followup/README.md +33 -0
  168. package/plugins/card-followup/examples/card-sample.json +7 -0
  169. package/plugins/card-followup/graphs/card-followup.yaml +16 -0
  170. package/plugins/card-followup/graphs/recipes.yaml +12 -0
  171. package/plugins/card-followup/graphs/run-step.ts +172 -0
  172. package/plugins/card-followup/plugin.json +25 -0
  173. package/plugins/geo-check/README.md +13 -0
  174. package/plugins/geo-check/examples/brand-sample.json +8 -0
  175. package/plugins/geo-check/graphs/geo-check.yaml +20 -0
  176. package/plugins/geo-check/graphs/recipes.yaml +18 -0
  177. package/plugins/geo-check/graphs/run-step.ts +177 -0
  178. package/plugins/geo-check/plugin.json +24 -0
  179. package/plugins/job-coach/README.md +30 -0
  180. package/scripts/coord-tracks.json +2 -2
  181. package/scripts/daemon-port-baseline.txt +1 -2
  182. package/scripts/lib/android-test-device.ts +32 -0
  183. package/scripts/release-loop/checklist-gate-node.ts +35 -0
  184. package/scripts/release-loop/gate-diff.ts +29 -0
  185. package/scripts/release-loop/gate-node.ts +46 -13
  186. package/scripts/release-loop/shard-plan.ts +47 -0
  187. package/scripts/ux/record-page.ts +4 -1
  188. package/skills/explainer-video/SKILL.md +18 -0
  189. package/skills/explainer-video/engine/brand/elanous-mark-on-dark.svg +1 -1
  190. package/skills/explainer-video/engine/build.mjs +1 -1
  191. package/skills/explainer-video/engine/reel.mjs +128 -0
  192. package/skills/explainer-video/engine/reel.sh +8 -0
  193. package/skills/explainer-video/engine/vo.ts +58 -16
  194. package/src/autopilot/mission-origin.ts +20 -4
  195. package/src/autopilot/mission-phase-track.ts +11 -1
  196. package/src/autopilot/track-agent.ts +3 -3
  197. package/src/autopilot/track-courier.ts +2 -2
  198. package/src/cli/nexus-ios-bind.ts +25 -8
  199. package/src/cli/phone-link.ts +117 -0
  200. package/src/cli/plugin-cli.ts +27 -0
  201. package/src/cli/private-ledger-commands.ts +31 -0
  202. package/src/cli/release-cli.ts +14 -11
  203. package/src/domains/outbound-alert.ts +5 -5
  204. package/src/field/field-media.ts +245 -0
  205. package/src/index.ts +18 -24
  206. package/src/nexus/api/field-uploads.ts +143 -0
  207. package/src/nexus/api/graph-approvals.ts +50 -1
  208. package/src/nexus/api/harness-api.ts +219 -5
  209. package/src/nexus/api/http-server.ts +9 -0
  210. package/src/notify/llm-budget-alert.ts +119 -0
  211. package/src/plugins/maker/plugin-maker.ts +208 -0
  212. package/src/relay/channel.ts +184 -0
  213. package/src/relay/mux.ts +182 -0
  214. package/src/relay/test-vectors.json +29 -0
  215. package/src/release-loop/checklist.ts +28 -2
  216. package/src/seat-address/seat-address.ts +34 -0
  217. package/src/self-implement/run-ledger-compact.ts +1 -1
  218. package/src/session/index.ts +23 -10
  219. package/src/task-orchestrator/surfaces/pod-bun-cache.ts +18 -0
  220. package/src/task-orchestrator/surfaces/pod-command-job.ts +12 -3
  221. package/src/telegram.ts +108 -1
  222. package/src/user-config.ts +4 -0
  223. package/src/version/packed-revision.json +1 -1
  224. package/src/video-pipeline/recipes/explainer.ts +213 -0
  225. package/src/video-pipeline/walk-line.ts +3 -2
  226. package/apps/pwa/out/_next/static/chunks/1413-7179a49332db5a80.js +0 -1
  227. package/apps/pwa/out/_next/static/chunks/150-25c277137a9638ab.js +0 -1
  228. package/apps/pwa/out/_next/static/chunks/1640-bc99ec23bbe29d12.js +0 -1
  229. package/apps/pwa/out/_next/static/chunks/1751.50b96ea9c37b49b6.js +0 -1
  230. package/apps/pwa/out/_next/static/chunks/2300-de059d0ab2a16630.js +0 -1
  231. package/apps/pwa/out/_next/static/chunks/2684-05b0a81e75298c16.js +0 -1
  232. package/apps/pwa/out/_next/static/chunks/3026-2ed7e317d9b02102.js +0 -25
  233. package/apps/pwa/out/_next/static/chunks/3611-77496ada38cd6358.js +0 -1
  234. package/apps/pwa/out/_next/static/chunks/3717-68957c4dc9ceec90.js +0 -1
  235. package/apps/pwa/out/_next/static/chunks/3929-46ba9af6f9d895cc.js +0 -1
  236. package/apps/pwa/out/_next/static/chunks/4015-62c5a3744698a8da.js +0 -1
  237. package/apps/pwa/out/_next/static/chunks/4217-3c4317a7905f9eeb.js +0 -1
  238. package/apps/pwa/out/_next/static/chunks/4240-3230f230fae2d7d5.js +0 -1
  239. package/apps/pwa/out/_next/static/chunks/4271-e53e075ebd48593b.js +0 -1
  240. package/apps/pwa/out/_next/static/chunks/4377.3b125fef79576dfd.js +0 -5
  241. package/apps/pwa/out/_next/static/chunks/4440-18bb160d95819f4e.js +0 -1
  242. package/apps/pwa/out/_next/static/chunks/5007-6a3b30e23ff68a6e.js +0 -1
  243. package/apps/pwa/out/_next/static/chunks/5244-12b424c42070f5ff.js +0 -1
  244. package/apps/pwa/out/_next/static/chunks/5611-df00af3e4e4a61bb.js +0 -1
  245. package/apps/pwa/out/_next/static/chunks/5890-dca2321773be829a.js +0 -1
  246. package/apps/pwa/out/_next/static/chunks/6123-37c9dc57ddaa7ab0.js +0 -1
  247. package/apps/pwa/out/_next/static/chunks/6588-b7975304a302ab91.js +0 -1
  248. package/apps/pwa/out/_next/static/chunks/6604-c03961962b6273b6.js +0 -1
  249. package/apps/pwa/out/_next/static/chunks/7109-cf8c87e968e83a65.js +0 -1
  250. package/apps/pwa/out/_next/static/chunks/7178-30f77877525a2efb.js +0 -1
  251. package/apps/pwa/out/_next/static/chunks/7262-871563fc9663730b.js +0 -1
  252. package/apps/pwa/out/_next/static/chunks/7294-f0eeebb92aa93459.js +0 -1
  253. package/apps/pwa/out/_next/static/chunks/7429-881af44deed6958f.js +0 -1
  254. package/apps/pwa/out/_next/static/chunks/764-a01e858525a190c0.js +0 -1
  255. package/apps/pwa/out/_next/static/chunks/7868-e35d5f7985ecd3f1.js +0 -1
  256. package/apps/pwa/out/_next/static/chunks/8320-51484af7b01fdfa5.js +0 -1
  257. package/apps/pwa/out/_next/static/chunks/9131-52f5fad51cceca04.js +0 -1
  258. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-70c404c7bb6d7f0f.js +0 -1
  259. package/apps/pwa/out/_next/static/chunks/app/approvals/page-3ffd80166b1050aa.js +0 -1
  260. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-f89e9a303515a033.js +0 -1
  261. package/apps/pwa/out/_next/static/chunks/app/board/page-4f403cd060442c95.js +0 -1
  262. package/apps/pwa/out/_next/static/chunks/app/botlab/page-0ac5d053f0444d21.js +0 -1
  263. package/apps/pwa/out/_next/static/chunks/app/bots/page-4bcc63e272ae8205.js +0 -1
  264. package/apps/pwa/out/_next/static/chunks/app/chat/page-880734bd07d62882.js +0 -1
  265. package/apps/pwa/out/_next/static/chunks/app/control/page-72bd384898d88955.js +0 -1
  266. package/apps/pwa/out/_next/static/chunks/app/design-check/page-1018bbc9586ecae5.js +0 -1
  267. package/apps/pwa/out/_next/static/chunks/app/editor/page-de86f8c38076dd7b.js +0 -1
  268. package/apps/pwa/out/_next/static/chunks/app/healthz/route-70c404c7bb6d7f0f.js +0 -1
  269. package/apps/pwa/out/_next/static/chunks/app/intake/page-cd6447196d8bafe6.js +0 -1
  270. package/apps/pwa/out/_next/static/chunks/app/layout-e733630c47fa102b.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/app/live/page-d1c1a8357b161875.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/market/page-60c514128e81d787.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-8b637b7e325e13c1.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/morning/page-bccce0babceae30f.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/not-found-63301d11dbe73836.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/observatory/page-a8cf0dc472060db2.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/page-02fba54881ccfa49.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/reflection/page-d5e8e90f0038f8de.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-b1ef6824f494466a.js +0 -1
  280. package/apps/pwa/out/_next/static/chunks/app/sessions/page-d589bb0f233b1009.js +0 -1
  281. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-c9bbaebef4cda059.js +0 -1
  282. package/apps/pwa/out/_next/static/chunks/app/settings/page-3639ce7916b92fed.js +0 -1
  283. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-7450ae7478955653.js +0 -1
  284. package/apps/pwa/out/_next/static/chunks/app/setup/layout-70c404c7bb6d7f0f.js +0 -1
  285. package/apps/pwa/out/_next/static/chunks/app/setup/page-b41ca4abfd618452.js +0 -1
  286. package/apps/pwa/out/_next/static/chunks/app/share/page-b333dd79ebee3fa9.js +0 -1
  287. package/apps/pwa/out/_next/static/chunks/app/showroom/page-8be1227492793655.js +0 -1
  288. package/apps/pwa/out/_next/static/chunks/app/tasks/page-6e03e9371b552e0d.js +0 -1
  289. package/apps/pwa/out/_next/static/chunks/app/term/page-911411e9fca18d03.js +0 -1
  290. package/apps/pwa/out/_next/static/chunks/app/trace/page-792170e649b37088.js +0 -1
  291. package/apps/pwa/out/_next/static/chunks/app/vault/page-c40463466937bc91.js +0 -1
  292. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-509c798b80384536.js +0 -1
  293. package/apps/pwa/out/_next/static/chunks/app/workflows/page-1cac2aa21d77c417.js +0 -1
  294. package/apps/pwa/out/_next/static/chunks/app/workspace/page-9626799d9b4a2cd1.js +0 -1
  295. package/apps/pwa/out/_next/static/chunks/framework-361487d2da603917.js +0 -1
  296. package/apps/pwa/out/_next/static/chunks/main-app-607f5beb94cb2e07.js +0 -1
  297. package/apps/pwa/out/_next/static/chunks/main-d0222d219e29dddf.js +0 -1
  298. package/apps/pwa/out/_next/static/chunks/pages/_app-a26980a31126decc.js +0 -1
  299. package/apps/pwa/out/_next/static/chunks/pages/_error-edb2b37decf23e4d.js +0 -1
  300. /package/apps/pwa/out/_next/static/{ktMqfrBbf8BMGac0jqLrb → qMI0jnlZaBjjm4aIDYNGM}/_ssgManifest.js +0 -0
@@ -18,6 +18,35 @@ export function parseFailures(output: string): string[] {
18
18
  return [...failures].sort();
19
19
  }
20
20
 
21
+ const xmlText = (value: string): string => value
22
+ .replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
23
+ .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code))).replace(/&amp;/g, '&');
24
+
25
+ /** Failed test names from a bun junit report, in the same `<file> > <describe> > <name>` shape as {@link parseFailures}.
26
+ * Used when the console summary counts a failure but prints no `(fail)` line for it (09-30 0.2.6 gate: `--dots`). */
27
+ export function junitFailures(xml: string): string[] {
28
+ const failures = new Set<string>();
29
+ const suites: string[] = [];
30
+ let file: string | undefined;
31
+ let open: string | undefined;
32
+ for (const match of xml.matchAll(/<(\/?)(testsuite|testcase|failure)\b([^>]*?)(\/?)>/g)) {
33
+ const [, closing, tag, attrs, selfClosing] = match;
34
+ const attr = (name: string) => { const m = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs!); return m ? xmlText(m[1]!) : undefined; };
35
+ if (tag === 'testsuite') {
36
+ if (closing) { suites.pop(); continue; }
37
+ suites.push(attr('name') ?? '');
38
+ if (suites.length === 1) file = attr('file') ?? attr('name');
39
+ } else if (tag === 'testcase') {
40
+ if (closing) { open = undefined; continue; }
41
+ const name = [...suites.slice(1), attr('name') ?? ''].join(' > ');
42
+ open = selfClosing ? undefined : `${attr('file') ?? file} > ${name}`;
43
+ } else if (tag === 'failure' && !closing && open) {
44
+ failures.add(open);
45
+ }
46
+ }
47
+ return [...failures].sort();
48
+ }
49
+
21
50
  export function diffFailures(cut: Iterable<string>, baseline: Iterable<string>): FailureDiff {
22
51
  const c = new Set(cut);
23
52
  const b = new Set(baseline);
@@ -7,10 +7,12 @@ import { dirname, join, resolve } from 'node:path';
7
7
  import { debug } from '../../src/debug/log.js';
8
8
  import { effectiveInstanceRoot, releaseLedgerRoot } from '../../src/instance/resolve.js';
9
9
  import { getElanousConfigDirOverride } from '../../src/elanous-config-dir.js';
10
- import { diffFailures, parseFailures } from './gate-diff';
10
+ import { diffFailures, junitFailures, parseFailures } from './gate-diff';
11
+ import { planShards, readFileDurations } from './shard-plan';
11
12
  import { deriveCdpTestPatterns } from '../test-deterministic';
12
13
  import { emitNodeResult, readGraphContext } from './node-verdict.js';
13
14
  import { runPodCommand, type RunPodCommandOptions, type PodCommandResult } from '../../src/task-orchestrator/surfaces/pod-command-job.js';
15
+ import { POD_BUN_CACHE_HOST_PATH, parseInstallSeconds, podBunCacheVolume } from '../../src/task-orchestrator/surfaces/pod-bun-cache.js';
14
16
  import { PodPoolScheduler, parsePodPool, checkPodPool } from '../../src/task-orchestrator/surfaces/pod-pool.js';
15
17
  import { defaultKubectl } from '../../src/task-orchestrator/surfaces/self-implement-pod.js';
16
18
 
@@ -36,7 +38,7 @@ export interface GateOptions {
36
38
  instanceRoot?: string;
37
39
  ledgerRoot?: string;
38
40
  repo?: string;
39
- pod?: { pool: string; shards?: number; shardTimeoutSeconds?: number };
41
+ pod?: { pool: string; shards?: number; shardTimeoutSeconds?: number; durationSource?: string };
40
42
  }
41
43
  export interface GateResult {
42
44
  outcome: 'ok' | 'regression' | 'error';
@@ -134,7 +136,7 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
134
136
  }
135
137
  : localCommand);
136
138
  const podSweep = async (tree: string, logDir: string | undefined, pod: NonNullable<GateOptions['pod']>): Promise<CommandResult> => {
137
- const shardCount = pod.shards ?? 8;
139
+ const shardCount = pod.shards ?? 24;
138
140
  const deadlineSeconds = pod.shardTimeoutSeconds ?? 1200;
139
141
  if (!pod.pool || !Number.isSafeInteger(shardCount) || shardCount < 1
140
142
  || !Number.isSafeInteger(deadlineSeconds) || deadlineSeconds < 1) throw new Error('invalid pod sweep options');
@@ -156,11 +158,19 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
156
158
  // bun 의 탐색은 숨은 디렉터리(`.x/`)를 안 연다 — 사람의 `bun test` 가 한 번도 안 돌리는 빈 픽스처가 조각에 혼자 남아 «Ran 0 tests» 를 «불완전»으로 읽었다(09-30 G1e 5번 조각).
157
159
  const assignable = files.filter((file) => !cdpPatterns.includes(file) && !file.split('/').some((part) => part.startsWith('.')));
158
160
  if (!assignable.length) throw new Error('sweep incomplete: no tests ran');
159
- const shards = Array.from({ length: Math.min(shardCount, assignable.length) }, () => [] as string[]);
160
- assignable.forEach((file, index) => shards[index % shards.length]!.push(file));
161
+ const durations = pod.durationSource ? readFileDurations(pod.durationSource) : new Map<string, number>();
162
+ const shards = planShards(assignable, durations, shardCount);
163
+ const known = assignable.filter((file) => durations.has(file)).length;
164
+ debug.log('release-loop.gate', 'pod-shard-plan', {
165
+ shards: shards.length, known, unknown: assignable.length - known,
166
+ maxPlannedSeconds: Math.max(...shards.map((shard) => shard.plannedSeconds)),
167
+ source: durations.size ? pod.durationSource : 'none',
168
+ });
161
169
  const ready = poolOverride ? undefined : checkPodPool(parsePodPool(pod.pool), defaultKubectl);
162
170
  if (ready && !ready.ok) throw new Error('pod command: 풀의 노드가 하나도 준비되지 않았다');
163
171
  const poolScheduler = poolOverride ?? new PodPoolScheduler(ready!.ready);
172
+ const bunCache = process.env[POD_BUN_CACHE_HOST_PATH]?.trim() || undefined;
173
+ const cachePrefix = bunCache ? `${podBunCacheVolume(bunCache).shellPrefix} ` : '';
164
174
  const jobLogTail = (job: PodCommandResult): string => {
165
175
  if (podLogTail) return podLogTail(job);
166
176
  for (const member of poolScheduler.members) {
@@ -172,7 +182,13 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
172
182
  type ShardRun = { output: string; rc: number; pass: number; fail: number; errors: number; ran: number; files: number };
173
183
  type ShardReason = NonNullable<GateResult['stalledShards']>[number]['reason'];
174
184
  // 깊이 끝 OOM 조각은 «조각 전체»를 파일 하나씩 격리한다 — 상한 40 이면 나머지가 못 잰 채 남았다(09-30 ① 7번 조각 110 파일).
175
- const isolationRemaining = shards.map((paths) => paths.length);
185
+ const isolationRemaining = shards.map((item) => item.files.length);
186
+ const estimated = (paths: string[]) => paths.reduce((sum, file) => {
187
+ const value = durations.get(file);
188
+ return sum + (value !== undefined && Number.isFinite(value) && value >= 0 ? value : unknownSeconds);
189
+ }, 0);
190
+ const knownTimes = assignable.map((file) => durations.get(file)).filter((value): value is number => value !== undefined && Number.isFinite(value) && value >= 0).sort((a, b) => a - b);
191
+ const unknownSeconds = knownTimes.length ? (knownTimes[Math.floor((knownTimes.length - 1) / 2)]! + knownTimes[Math.floor(knownTimes.length / 2)]!) / 2 : 1;
176
192
  const runShard = async (paths: string[], shard: number, depth = 0, branch = ''): Promise<{ runs: ShardRun[]; stalled: NonNullable<GateResult['stalledShards']> }> => {
177
193
  const ignores = cdpPatterns.filter((pattern) => paths.includes(pattern))
178
194
  .flatMap((pattern) => ['--path-ignore-patterns', pattern]);
@@ -189,10 +205,11 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
189
205
  try {
190
206
  const job = await podCommand({
191
207
  pool: pod.pool, poolScheduler, clone: true, source: { kind: 'commit', sha: commit }, deadlineSeconds,
208
+ ...(bunCache ? { bunCache } : {}),
192
209
  // 실패 뒤 다시 도는 파일 하나짜리 Job 은 메모리 한도를 올린다 — 16Gi 에선 무거운 한 파일이 혼자서도 OOM 이었다(09-30 `unwired-exports`).
193
210
  ...(paths.length === 1 && depth >= 1 ? { memoryLimit: POD_ISOLATION_MEMORY_LIMIT } : {}),
194
211
  name: `gate-${randomUUID()}`,
195
- command: ['bash', '-lc', `mkdir -p "$HOME/outbox"; (cd .. && cd repo && bun install && (cd apps/pwa && bun install) && ${args}) 2>&1 | tee "$HOME/outbox/shard.log"; echo \${PIPESTATUS[0]} > "$HOME/outbox/shard.rc"`],
212
+ command: ['bash', '-lc', `mkdir -p "$HOME/outbox"; ${cachePrefix}(cd .. && cd repo && bun install && (cd apps/pwa && bun install) && ${args}) 2>&1 | tee "$HOME/outbox/shard.log"; echo \${PIPESTATUS[0]} > "$HOME/outbox/shard.rc"`],
196
213
  });
197
214
  jobExitCode = job.exitCode;
198
215
  const logPath = join(job.artifactsDir, 'shard.log');
@@ -216,13 +233,13 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
216
233
  const destination = join(logDir, `pod-${shard}${branch}.log`);
217
234
  if (output !== undefined) writeFileSync(destination, output, { mode: 0o600 });
218
235
  else rmSync(destination, { force: true });
219
- writeFileSync(join(logDir, `pod-${shard}${branch}.json`), JSON.stringify({ durationMs, rc: rc ?? null, files: paths }) + '\n', { mode: 0o600 });
236
+ writeFileSync(join(logDir, `pod-${shard}${branch}.json`), JSON.stringify({ durationMs, rc: rc ?? null, files: paths, plannedSeconds: estimated(paths) }) + '\n', { mode: 0o600 });
220
237
  const junitDestination = join(logDir, `pod-${shard}${branch}.junit.xml`);
221
238
  if (junit !== undefined) writeFileSync(junitDestination, junit, { mode: 0o600 });
222
239
  else rmSync(junitDestination, { force: true });
223
240
  }
224
- debug.log('release-loop.gate', 'pod-shard', { shard, files: paths, durationMs, rc: rc ?? null, attempt: depth + 1 });
225
- const clean = output?.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
241
+ debug.log('release-loop.gate', 'pod-shard', { shard, files: paths, durationMs, rc: rc ?? null, attempt: depth + 1, installSeconds: parseInstallSeconds(output ?? '') });
242
+ let clean = output?.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
226
243
  const ran = clean && lastMatch(clean, /Ran (\d+) tests? across (\d+) files?/);
227
244
  const passes = clean === undefined ? NaN : summaryCount(clean, 'pass');
228
245
  const fails = clean === undefined ? NaN : summaryCount(clean, 'fail');
@@ -243,6 +260,21 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
243
260
  unattributedDetail = (error instanceof Error ? error.message : String(error)).split('\n', 1)[0]!.slice(0, 300);
244
261
  }
245
262
  if (reason === 'unattributed') namedFailures = [...clean.matchAll(/\(fail\)\s+.+?(?:\s+\[[\d.]+(?:ms|s)\])?\s*$/gm)].length;
263
+ // 콘솔 요약은 실패를 세는데 `(fail)` 이름 줄이 하나도 없으면 같은 조각의 junit 에서 이름을 가져온다
264
+ // (10-01 0.2.6 게이트: `--dots` 로 도는 한 파일 조각이 이름 없이 끝나 게이트 전체가 error 로 멈췄다).
265
+ if (reason === 'unattributed' && namedFailures === 0 && junit) {
266
+ const named = junitFailures(junit);
267
+ if (named.length === fails) {
268
+ const patched = `${clean}\n${named.map((id) => { const cut = id.indexOf(' > '); return `${id.slice(0, cut)}:\n(fail) ${id.slice(cut + 3)}`; }).join('\n')}\n`;
269
+ try {
270
+ if (failuresOf({ rc, output: patched }, `pod-${shard} shard`).failures.length === fails) {
271
+ clean = patched;
272
+ reason = undefined;
273
+ debug.log('release-loop.gate', 'pod-shard-junit-attribution', { shard, files: paths, failures: named });
274
+ }
275
+ } catch { /* 그래도 못 맞추면 원래대로 멈춘다 */ }
276
+ }
277
+ }
246
278
  }
247
279
  if (reason) {
248
280
  // 깊이 2 에서 «이름 없는 실패»·«불완전»도 파일 단위로 가른다 — 아니면 149파일 조각의 실패 하나가 끝까지 주인 없이 남는다(09-30 G1e 7번 조각: 요약 28 · 이름 27).
@@ -279,7 +311,7 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
279
311
  ran: Number(ran![1]), files: Number(ran![2]) }], stalled: [] };
280
312
  };
281
313
  // 모든 조각이 끝난 뒤 판정한다 — 한 조각의 예외로 먼저 돌아가면 다른 Pod 가 도는 채로 정리가 시작된다(#22002 리뷰 R3).
282
- const settled = await Promise.allSettled(shards.map((paths, shard) => runShard(paths, shard)));
314
+ const settled = await Promise.allSettled(shards.map((item, shard) => runShard(item.files, shard)));
283
315
  const rejected = settled.find((result): result is PromiseRejectedResult => result.status === 'rejected');
284
316
  if (rejected) throw rejected.reason;
285
317
  const results = settled.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []);
@@ -433,7 +465,7 @@ export async function judgeGate(opts: GateOptions, runner: GateRunner = createGa
433
465
  || (opts.baselineVersion && !versionPattern.test(opts.baselineVersion))
434
466
  || (opts.baselineCommit && !sha.test(opts.baselineCommit))) throw new Error('invalid commit or version');
435
467
  if (!opts.baselineVersion) throw new Error('previous release version required (--baseline-version)');
436
- if (opts.pod && (!opts.pod.pool || !Number.isSafeInteger(opts.pod.shards ?? 8) || (opts.pod.shards ?? 8) < 1
468
+ if (opts.pod && (!opts.pod.pool || !Number.isSafeInteger(opts.pod.shards ?? 24) || (opts.pod.shards ?? 24) < 1
437
469
  || !Number.isSafeInteger(opts.pod.shardTimeoutSeconds ?? 1200) || (opts.pod.shardTimeoutSeconds ?? 1200) < 1)) throw new Error('invalid pod sweep options');
438
470
  if (opts.remote && !/^(?:[\w.-]+@)?[\w.-]+$/.test(opts.remote)) throw new Error('invalid ssh host');
439
471
  if (opts.remote) {
@@ -472,7 +504,8 @@ export async function judgeGate(opts: GateOptions, runner: GateRunner = createGa
472
504
  trees.push(cutTree);
473
505
  await runner.add(cutTree, opts.commit);
474
506
  for (const dir of [cutTree, join(cutTree, 'apps/pwa')]) check(await runner.command('bun', ['install'], dir), `bun install ${dir}`);
475
- const cutRun = await runner.sweep(cutTree, join(root, 'release', opts.version, 'gate-logs', 'cut'), opts.pod);
507
+ const cutRun = await runner.sweep(cutTree, join(root, 'release', opts.version, 'gate-logs', 'cut'),
508
+ opts.pod ? { ...opts.pod, durationSource: join(ledger, 'release', opts.baselineVersion, 'gate-logs', 'cut') } : undefined);
476
509
  const cut = failuresOf(cutRun, 'cut sweep');
477
510
  cutFailures = cut;
478
511
  const baseSha = opts.baselineCommit ?? baselineReleaseCommit(opts.baselineVersion);
@@ -0,0 +1,47 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /** Read per-file seconds from previous cut Pod reports; missing or damaged reports are not estimates. */
5
+ export function readFileDurations(dir: string): Map<string, number> {
6
+ const durations = new Map<string, number>();
7
+ let names: string[];
8
+ const entities: Record<string, string> = { '&amp;': '&', '&quot;': '"', '&apos;': "'", '&lt;': '<', '&gt;': '>' };
9
+ try { names = readdirSync(dir).filter((name) => name.endsWith('.junit.xml')).sort(); }
10
+ catch { return durations; }
11
+ for (const name of names) {
12
+ try {
13
+ const xml = readFileSync(join(dir, name), 'utf8');
14
+ for (const match of xml.matchAll(/<testsuite\b([^>]*)>/g)) {
15
+ const attrs = match[1]!;
16
+ const file = /\bfile="([^"]+)"/.exec(attrs)?.[1];
17
+ const rawTime = /\btime="([^"]+)"/.exec(attrs)?.[1];
18
+ if (!file || rawTime === undefined) continue;
19
+ const seconds = Number(rawTime);
20
+ if (!Number.isFinite(seconds) || seconds < 0) continue;
21
+ const path = file.replace(/&(?:amp|quot|apos|lt|gt);/g, (entity) => entities[entity]!);
22
+ durations.set(path, Math.max(durations.get(path) ?? 0, seconds));
23
+ }
24
+ } catch { /* One unreadable report must not discard the others. */ }
25
+ }
26
+ return durations;
27
+ }
28
+
29
+ /** Longest processing time first; tie-breaks use lexical paths, then the least populated lightest shard. */
30
+ export function planShards(files: string[], durations: Map<string, number>, count: number): Array<{ files: string[]; plannedSeconds: number }> {
31
+ if (!Number.isSafeInteger(count) || count < 1) throw new Error('invalid shard count');
32
+ const known = files.map((file) => durations.get(file)).filter((value): value is number => value !== undefined && Number.isFinite(value) && value >= 0).sort((a, b) => a - b);
33
+ const median = known.length ? (known[Math.floor((known.length - 1) / 2)]! + known[Math.floor(known.length / 2)]!) / 2 : 1;
34
+ const weight = (file: string) => {
35
+ const value = durations.get(file);
36
+ return value !== undefined && Number.isFinite(value) && value >= 0 ? value : median;
37
+ };
38
+ const shards = Array.from({ length: Math.min(count, files.length) }, () => ({ files: [] as string[], plannedSeconds: 0 }));
39
+ for (const file of [...files].sort((a, b) => weight(b) - weight(a) || (a < b ? -1 : a > b ? 1 : 0))) {
40
+ let lightest = shards[0]!;
41
+ for (const shard of shards) if (shard.plannedSeconds < lightest.plannedSeconds
42
+ || (shard.plannedSeconds === lightest.plannedSeconds && shard.files.length < lightest.files.length)) lightest = shard;
43
+ lightest.files.push(file);
44
+ lightest.plannedSeconds += weight(file);
45
+ }
46
+ return shards.filter((shard) => shard.files.length > 0);
47
+ }
@@ -12,7 +12,7 @@ import { Frames, launch, PUBLIC_LEAK_CHECK, sleep } from './lib/cdp.js';
12
12
  const arg = (k: string) => { const i = process.argv.indexOf(`--${k}`); return i > 0 ? process.argv[i + 1] : undefined; };
13
13
  const flag = (k: string) => process.argv.includes(`--${k}`);
14
14
  const rawUrl = arg('url'); const out = arg('out');
15
- if (!rawUrl || !out) { console.error('usage: --url <url> --out <dir> [--secs N] [--size WxH] [--mobile] [--type text] [--shot] [--token-stdin]'); process.exit(64); }
15
+ if (!rawUrl || !out) { console.error('usage: --url <url> --out <dir> [--secs N] [--size WxH] [--mobile] [--type text] [--shot] [--token-stdin] [--eval <js>]'); process.exit(64); }
16
16
  const mobile = flag('mobile');
17
17
  const [W, H] = mobile ? [390, 844] : (arg('size') ?? '1920x1080').split('x').map(Number);
18
18
  const DPR = mobile ? 3 : 1; const SECS = Number(arg('secs') ?? 20);
@@ -26,6 +26,9 @@ if (flag('token-stdin')) {
26
26
  if (temp) { await cdp.send('Page.navigate', { url: `${u.origin}/app/` }); await sleep(3000); await cdp.ev(`localStorage.setItem('elanous.daemon.token', ${JSON.stringify(temp)})`); }
27
27
  }
28
28
  await cdp.send('Page.navigate', { url: u.toString() }); await sleep(8000);
29
+ // --eval <js>: 녹화 전에 화면 상태를 맞춘다(출처 선택·토글·배너 닫기) — 기록에 남도록 그대로 찍는다.
30
+ const pre = arg('eval');
31
+ if (pre) { console.log('eval', await cdp.ev<string>(`(()=>{try{${pre};return 'ok'}catch(e){return 'error: '+e}})()`)); await sleep(4000); }
29
32
  const check = async (tag: string) => console.log(tag, await cdp.ev<string>(PUBLIC_LEAK_CHECK));
30
33
  await check('before');
31
34
  if (flag('shot')) { const f = join(out, `shot-${W}x${H}.png`); await cdp.png(f); console.log('png', f); cdp.close(); process.exit(0); }
@@ -42,6 +42,7 @@ The outro gives one line that sums everything up, then the CTA.
42
42
  - Makes one `/with-timestamps` call per beat and writes `source/vo/<key>.mp3` and `<key>.json`, where the JSON holds per-character times.
43
43
  - A beat whose text hash is unchanged is skipped, so you can edit one line and re-run.
44
44
  - The key comes from `ELEVENLABS_API_KEY`, which the plugin connector sets (`elanous plugin credentials video-explainer`). Never print it.
45
+ - **No key** → a draft voice from macOS `say` (a Korean system voice; `EXPLAINER_SAY_VOICE` picks one). Character times are then spread over the measured length — captions still land per line, keyword timing is approximate. Use it for unattended runs and rehearsals; use ElevenLabs for anything public.
45
46
 
46
47
  ## 3. Compose — `node $SKILL/engine/build.mjs <project>/script.json`
47
48
 
@@ -86,6 +87,23 @@ The reference supplies the **grammar** (components and staging), never its colou
86
87
 
87
88
  Why these choices, what the reference does, and what we can and cannot reproduce: `내부 문서 `RESEARCH-explainer-video-reference-decomposition-2026-09-30``.
88
89
 
90
+ ## Field reel — photos from the venue → a vertical video in about a minute
91
+
92
+ ```bash
93
+ zsh $SKILL/engine/reel.sh <folder> --title "마케터의 밤" --sub "2026.10.02 · 서울"
94
+ ```
95
+
96
+ - **Input**: a folder of phone photos (jpg · png · heic) and short clips (mp4 · mov). The default upload folder is `~/.elanous/field/<event>/`.
97
+ - **Order**: by capture time (`mdls` creation date), then by file name.
98
+ - **Captions**: optional `<folder>/captions.txt`, one line per item as `파일명 | 자막`. Items without a line get no caption.
99
+ - **Output**:
100
+ - `<folder>/reel/reel-9x16.mp4` at 1080×1920, 30–60 s.
101
+ - A black title card, then each item: photos for 3.5 s with a slow zoom, clips for their first 5 s.
102
+ - One caption line per item, the V6 header, an Icarus Red progress bar, and an end card with elanous.ai.
103
+ - **Fitting to length**: over 60 s drops items from the middle, keeping the first and last. Under 30 s stretches the photos.
104
+ - **Measured**: 7 items → 31.2 s video in 56–83 s end to end (M-series Mac, `hyperframes render`).
105
+ - **Before posting**: people's faces need their consent. Run the same 1 fps OCR check as any public video.
106
+
89
107
  ## Graph
90
108
 
91
109
  `graphs/video/explainer-line.yaml`:
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="150 150 724 724"><path d="M842 512 L838.6 517.2 L835.4 522.4 L832.2 527.5 L828.9 532.5 L825.7 537.4 L822.4 542.2 L819 547 L815.7 551.6 L812.2 556.1 L808.8 560.5 L805.3 564.8 L801.7 569 L798.2 573.1 L794.5 577.1 L790.9 581 L787.2 584.8 L783.5 588.5 L779.7 592.1 L776 595.6 L772.2 599 L768.4 602.3 L764.5 605.5 L760.7 608.6 L756.8 611.6 L752.9 614.5 L749 617.4 L745.1 620.1 L741.2 622.7 L737.3 625.2 L733.4 627.7 L729.5 630 L725.6 632.3 L721.7 634.5 L717.8 636.6 L713.9 638.6 L710 640.5 L706.1 642.3 L702.2 644.1 L698.3 645.8 L694.5 647.4 L690.7 648.9 L686.9 650.3 L683.1 651.7 L679.3 653 L675.5 654.2 L671.8 655.4 L668.1 656.5 L664.4 657.5 L660.7 658.5 L657.1 659.3 L653.5 660.2 L649.9 661 L646.3 661.7 L642.8 662.3 L639.3 662.9 L635.8 663.5 L632.4 664 L629 664.4 L625.6 664.8 L622.2 665.2 L618.9 665.5 L615.6 665.7 L612.4 665.9 L609.1 666.1 L605.9 666.2 L602.8 666.3 L599.6 666.4 L596.5 666.4 L593.4 666.4 L590.4 666.3 L587.4 666.2 L584.4 666.1 L581.4 666 L578.5 665.8 L575.6 665.6 L572.7 665.3 L569.9 665.1 L567 664.8 L564.2 664.5 L561.5 664.1 L558.7 663.7 L556 663.4 L553.3 663 L550.6 662.5 L548 662.1 L545.4 661.6 L542.8 661.1 L540.2 660.6 L537.6 660.1 L535.1 659.5 L532.5 658.9 L530 658.3 L527.5 657.7 L525.1 657.1 L522.6 656.5 L520.2 655.8 L517.7 655.1 L515.3 654.4 L512.9 653.7 L510.5 653 L508.2 652.2 L505.8 651.4 L503.5 650.6 L501.1 649.8 L498.8 649 L496.5 648.1 L494.2 647.3 L491.9 646.4 L489.6 645.5 L487.3 644.5 L485.1 643.6 L482.8 642.6 L480.6 641.6 L478.3 640.6 L476.1 639.5 L473.9 638.4 L471.7 637.3 L469.5 636.2 L467.3 635 L465.1 633.9 L462.9 632.6 L460.7 631.4 L458.6 630.1 L456.4 628.8 L454.3 627.5 L452.2 626.1 L450 624.7 L447.9 623.3 L445.8 621.9 L443.8 620.4 L441.7 618.8 L439.6 617.3 L437.6 615.7 L435.6 614 L433.6 612.3 L431.6 610.6 L429.6 608.9 L427.6 607.1 L425.7 605.2 L423.8 603.4 L421.9 601.4 L420 599.5 L418.1 597.5 L416.3 595.4 L414.5 593.3 L412.7 591.2 L410.9 589 L409.2 586.8 L407.5 584.6 L405.8 582.3 L404.1 579.9 L402.5 577.5 L400.9 575.1 L399.3 572.6 L397.8 570 L396.2 567.4 L394.7 564.8 L393.2 562.1 L391.6 559.3 L389.8 556.5 L389.8 556.5 L387.3 558.7 L385.1 561.2 L383 563.7 L381.1 566.4 L379.3 569.1 L377.5 572 L375.8 574.9 L374.3 577.9 L372.8 581.1 L371.4 584.2 L370.1 587.5 L368.9 590.9 L367.8 594.3 L366.8 597.7 L365.8 601.3 L365 604.9 L364.3 608.6 L363.7 612.3 L363.2 616.1 L362.8 619.9 L362.5 623.8 L362.3 627.7 L362.2 631.7 L362.3 635.7 L362.4 639.7 L362.7 643.8 L363.1 647.9 L363.6 652 L364.2 656.1 L364.9 660.3 L365.8 664.4 L366.7 668.6 L367.8 672.8 L369 677 L370.4 681.1 L371.8 685.3 L373.4 689.5 L375.1 693.6 L377 697.8 L378.9 701.9 L381 706 L383.2 710.1 L385.5 714.1 L387.9 718.1 L390.5 722.1 L393.1 726 L395.9 729.9 L398.8 733.7 L401.8 737.5 L405 741.3 L408.2 744.9 L411.6 748.5 L415 752.1 L418.6 755.6 L422.3 759 L426.1 762.3 L430 765.5 L434 768.7 L438.1 771.8 L442.3 774.8 L446.5 777.7 L450.9 780.5 L455.4 783.3 L459.9 785.9 L464.6 788.4 L469.3 790.9 L474.1 793.2 L479 795.4 L483.9 797.5 L488.9 799.5 L494 801.4 L499.2 803.1 L504.4 804.8 L509.7 806.3 L515 807.7 L520.4 809 L525.8 810.2 L531.3 811.2 L536.8 812.1 L542.4 812.9 L548 813.5 L553.6 814.1 L559.3 814.5 L565 814.7 L570.7 814.8 L576.4 814.8 L582.2 814.7 L587.9 814.4 L593.7 814 L599.5 813.4 L605.3 812.8 L611 811.9 L616.8 811 L622.6 809.9 L628.3 808.7 L634 807.3 L639.8 805.8 L645.5 804.2 L651.1 802.4 L656.8 800.5 L662.4 798.5 L667.9 796.3 L673.5 794 L678.9 791.6 L684.4 789.1 L689.8 786.4 L695.1 783.6 L700.4 780.7 L705.6 777.6 L710.8 774.5 L715.9 771.2 L720.9 767.8 L725.9 764.3 L730.8 760.7 L735.6 756.9 L740.4 753.1 L745 749.1 L749.6 745 L754.1 740.9 L758.5 736.6 L762.8 732.2 L767 727.8 L771.2 723.2 L775.2 718.5 L779.1 713.8 L782.9 709 L786.7 704.1 L790.3 699.1 L793.8 694 L797.2 688.8 L800.5 683.6 L803.7 678.3 L806.8 672.9 L809.7 667.5 L812.6 662 L815.3 656.4 L817.9 650.8 L820.4 645.1 L822.8 639.4 L825 633.6 L827.2 627.8 L829.2 621.9 L831 616 L832.8 610.1 L834.4 604.1 L835.9 598.1 L837.3 592.1 L838.5 586 L839.6 579.9 L840.6 573.8 L841.4 567.7 L842.1 561.5 L842.7 555.4 L843.1 549.2 L843.4 543 L843.5 536.8 L843.5 530.6 L843.2 524.4 L842.8 518.2 L842 512.0Z" fill="#F2F1EE"/><path d="M347 797.8 L344.2 792.2 L341.3 786.8 L338.5 781.5 L335.8 776.2 L333.1 770.9 L330.6 765.7 L328.2 760.4 L325.9 755.2 L323.7 750 L321.6 744.8 L319.6 739.6 L317.8 734.4 L316 729.3 L314.4 724.1 L312.8 719 L311.4 713.9 L310 708.9 L308.8 703.8 L307.6 698.8 L306.6 693.8 L305.6 688.9 L304.8 683.9 L304 679 L303.3 674.2 L302.7 669.4 L302.2 664.6 L301.8 659.9 L301.5 655.2 L301.3 650.5 L301.1 645.9 L301 641.3 L301 636.8 L301.1 632.3 L301.2 627.9 L301.5 623.5 L301.7 619.2 L302.1 614.9 L302.5 610.7 L303 606.5 L303.5 602.4 L304.1 598.3 L304.8 594.3 L305.5 590.3 L306.2 586.4 L307.1 582.5 L307.9 578.7 L308.8 574.9 L309.8 571.2 L310.8 567.6 L311.8 564 L312.9 560.4 L314.1 556.9 L315.2 553.5 L316.4 550.1 L317.6 546.8 L318.9 543.5 L320.2 540.3 L321.5 537.1 L322.9 534 L324.2 530.9 L325.6 527.9 L327.1 524.9 L328.5 522 L330 519.1 L331.5 516.2 L333 513.4 L334.5 510.7 L336 508 L337.6 505.3 L339.2 502.7 L340.7 500.2 L342.3 497.6 L344 495.1 L345.6 492.7 L347.2 490.3 L348.9 487.9 L350.5 485.6 L352.2 483.3 L353.8 481 L355.5 478.8 L357.2 476.6 L358.9 474.4 L360.6 472.3 L362.3 470.2 L364 468.1 L365.8 466.1 L367.5 464.1 L369.2 462.1 L371 460.2 L372.7 458.2 L374.5 456.3 L376.3 454.4 L378 452.6 L379.8 450.8 L381.6 449 L383.4 447.2 L385.2 445.4 L387 443.7 L388.8 442 L390.7 440.3 L392.5 438.6 L394.3 436.9 L396.2 435.3 L398.1 433.7 L400 432.1 L401.8 430.5 L403.8 428.9 L405.7 427.4 L407.6 425.9 L409.6 424.4 L411.5 422.9 L413.5 421.4 L415.5 420 L417.5 418.6 L419.5 417.2 L421.6 415.8 L423.6 414.4 L425.7 413.1 L427.8 411.7 L429.9 410.4 L432.1 409.2 L434.2 407.9 L436.4 406.7 L438.6 405.5 L440.8 404.3 L443.1 403.1 L445.3 402 L447.6 400.9 L449.9 399.8 L452.3 398.7 L454.6 397.7 L457 396.7 L459.4 395.7 L461.9 394.8 L464.3 393.9 L466.8 393 L469.3 392.2 L471.9 391.4 L474.4 390.6 L477 389.9 L479.6 389.2 L482.3 388.6 L484.9 388 L487.6 387.4 L490.3 386.9 L493.1 386.4 L495.8 385.9 L498.6 385.5 L501.4 385.2 L504.3 384.9 L507.1 384.6 L510 384.4 L512.9 384.3 L515.9 384.1 L518.9 384.1 L521.9 384 L524.9 384 L528 384.1 L531.2 384.1 L534.6 384 L534.6 384 L533.9 380.6 L532.9 377.5 L531.7 374.5 L530.4 371.4 L528.9 368.5 L527.3 365.5 L525.6 362.6 L523.8 359.7 L521.8 356.9 L519.7 354.1 L517.6 351.3 L515.3 348.6 L512.9 346 L510.4 343.3 L507.8 340.8 L505 338.3 L502.2 335.8 L499.3 333.4 L496.3 331.1 L493.2 328.8 L489.9 326.6 L486.6 324.5 L483.3 322.4 L479.8 320.5 L476.2 318.6 L472.5 316.8 L468.8 315.1 L465 313.5 L461.1 311.9 L457.1 310.5 L453.1 309.1 L449 307.9 L444.8 306.8 L440.6 305.7 L436.3 304.8 L432 304 L427.6 303.2 L423.1 302.6 L418.6 302.2 L414.1 301.8 L409.5 301.5 L404.9 301.4 L400.2 301.4 L395.5 301.5 L390.8 301.7 L386.1 302 L381.3 302.5 L376.6 303.1 L371.8 303.8 L367 304.7 L362.2 305.7 L357.4 306.8 L352.6 308 L347.8 309.4 L343 310.8 L338.2 312.5 L333.4 314.2 L328.7 316.1 L324 318.1 L319.3 320.2 L314.6 322.4 L310 324.8 L305.4 327.3 L300.8 330 L296.3 332.7 L291.9 335.6 L287.4 338.6 L283.1 341.7 L278.8 344.9 L274.5 348.3 L270.4 351.7 L266.3 355.3 L262.2 359 L258.3 362.8 L254.4 366.7 L250.6 370.8 L246.9 374.9 L243.2 379.1 L239.7 383.5 L236.2 387.9 L232.9 392.4 L229.6 397 L226.4 401.7 L223.3 406.5 L220.4 411.4 L217.5 416.4 L214.8 421.4 L212.1 426.6 L209.6 431.8 L207.2 437 L204.9 442.4 L202.7 447.8 L200.7 453.3 L198.7 458.8 L196.9 464.4 L195.2 470 L193.7 475.7 L192.2 481.5 L190.9 487.3 L189.8 493.1 L188.7 499 L187.8 504.9 L187 510.8 L186.4 516.8 L185.9 522.7 L185.5 528.8 L185.2 534.8 L185.1 540.8 L185.1 546.9 L185.3 552.9 L185.6 559 L186 565 L186.6 571.1 L187.3 577.2 L188.1 583.2 L189.1 589.2 L190.2 595.2 L191.4 601.2 L192.8 607.2 L194.2 613.2 L195.9 619.1 L197.6 625 L199.5 630.8 L201.5 636.7 L203.7 642.4 L205.9 648.2 L208.3 653.8 L210.9 659.5 L213.5 665.1 L216.3 670.6 L219.1 676.1 L222.1 681.5 L225.2 686.8 L228.5 692.1 L231.8 697.3 L235.3 702.5 L238.8 707.5 L242.5 712.5 L246.3 717.5 L250.1 722.3 L254.1 727 L258.2 731.7 L262.4 736.3 L266.7 740.8 L271 745.2 L275.5 749.5 L280 753.7 L284.7 757.8 L289.4 761.8 L294.2 765.7 L299.1 769.4 L304.1 773.1 L309.1 776.7 L314.3 780.1 L319.5 783.5 L324.8 786.7 L330.1 789.7 L335.6 792.6 L341.2 795.4 L347 797.8Z" fill="#F2F1EE"/><path d="M347 226.2 L353.2 226.6 L359.3 226.8 L365.4 227 L371.3 227.3 L377.2 227.6 L383 228.1 L388.7 228.6 L394.4 229.2 L400 229.9 L405.6 230.7 L411.1 231.6 L416.5 232.6 L421.8 233.6 L427.1 234.8 L432.3 236 L437.5 237.3 L442.5 238.6 L447.5 240.1 L452.4 241.6 L457.3 243.2 L462 244.8 L466.7 246.6 L471.3 248.3 L475.9 250.2 L480.3 252.1 L484.7 254 L489 256.1 L493.3 258.1 L497.4 260.3 L501.5 262.4 L505.5 264.6 L509.4 266.9 L513.2 269.2 L517 271.5 L520.7 273.9 L524.3 276.3 L527.8 278.8 L531.3 281.2 L534.7 283.7 L538 286.3 L541.2 288.8 L544.4 291.4 L547.5 294 L550.5 296.6 L553.4 299.3 L556.3 301.9 L559.1 304.6 L561.8 307.3 L564.5 310 L567.1 312.7 L569.6 315.4 L572 318.1 L574.4 320.8 L576.8 323.6 L579.1 326.3 L581.3 329 L583.4 331.7 L585.5 334.5 L587.5 337.2 L589.5 339.9 L591.4 342.7 L593.3 345.4 L595.1 348.1 L596.9 350.8 L598.6 353.5 L600.3 356.2 L601.9 358.9 L603.4 361.6 L605 364.3 L606.4 367 L607.9 369.6 L609.3 372.3 L610.6 374.9 L611.9 377.5 L613.2 380.1 L614.4 382.8 L615.6 385.4 L616.8 387.9 L617.9 390.5 L619 393.1 L620.1 395.7 L621.1 398.2 L622.1 400.7 L623 403.3 L624 405.8 L624.9 408.3 L625.7 410.8 L626.6 413.3 L627.4 415.8 L628.2 418.3 L629 420.8 L629.7 423.2 L630.4 425.7 L631.1 428.1 L631.8 430.6 L632.4 433 L633.1 435.5 L633.7 437.9 L634.2 440.3 L634.8 442.8 L635.3 445.2 L635.8 447.6 L636.3 450.1 L636.8 452.5 L637.2 454.9 L637.7 457.4 L638.1 459.8 L638.4 462.2 L638.8 464.7 L639.1 467.1 L639.4 469.5 L639.7 472 L639.9 474.4 L640.2 476.9 L640.4 479.3 L640.5 481.8 L640.7 484.3 L640.8 486.7 L640.9 489.2 L641 491.7 L641 494.2 L641 496.7 L641 499.2 L641 501.7 L640.9 504.2 L640.8 506.7 L640.6 509.3 L640.4 511.8 L640.2 514.4 L640 516.9 L639.7 519.5 L639.3 522 L639 524.6 L638.6 527.2 L638.1 529.8 L637.6 532.4 L637.1 534.9 L636.5 537.5 L635.9 540.1 L635.2 542.7 L634.5 545.4 L633.8 548 L633 550.6 L632.1 553.2 L631.2 555.8 L630.3 558.4 L629.3 561 L628.2 563.6 L627.1 566.2 L625.9 568.9 L624.7 571.5 L623.5 574.1 L622.2 576.7 L620.8 579.3 L619.4 581.9 L617.9 584.5 L616.4 587.2 L614.8 589.8 L613.2 592.6 L611.6 595.6 L611.6 595.6 L614.8 596.6 L618 597.3 L621.3 597.8 L624.5 598.2 L627.9 598.4 L631.2 598.5 L634.6 598.5 L638 598.3 L641.4 598 L644.9 597.6 L648.3 597.1 L651.8 596.5 L655.4 595.8 L658.9 594.9 L662.4 593.9 L665.9 592.8 L669.5 591.6 L673 590.3 L676.5 588.8 L680.1 587.3 L683.6 585.6 L687.1 583.8 L690.5 581.9 L694 579.8 L697.4 577.7 L700.8 575.4 L704.1 573.1 L707.4 570.6 L710.7 568 L713.9 565.3 L717.1 562.4 L720.3 559.5 L723.3 556.5 L726.3 553.3 L729.3 550.1 L732.2 546.7 L735 543.3 L737.7 539.7 L740.4 536.1 L743 532.3 L745.5 528.5 L748 524.5 L750.3 520.5 L752.6 516.4 L754.7 512.2 L756.8 507.9 L758.7 503.6 L760.6 499.1 L762.4 494.6 L764.1 490.1 L765.6 485.4 L767.1 480.7 L768.4 475.9 L769.6 471.1 L770.7 466.2 L771.7 461.2 L772.6 456.3 L773.3 451.2 L774 446.1 L774.5 441 L774.9 435.8 L775.1 430.6 L775.2 425.4 L775.2 420.1 L775.1 414.9 L774.9 409.6 L774.5 404.2 L773.9 398.9 L773.3 393.6 L772.5 388.2 L771.6 382.9 L770.5 377.5 L769.4 372.2 L768.1 366.9 L766.6 361.5 L765 356.2 L763.3 350.9 L761.5 345.7 L759.5 340.4 L757.4 335.2 L755.1 330 L752.8 324.9 L750.3 319.8 L747.7 314.8 L744.9 309.7 L742 304.8 L739 299.9 L735.9 295 L732.7 290.2 L729.3 285.5 L725.8 280.9 L722.2 276.3 L718.5 271.7 L714.7 267.3 L710.8 262.9 L706.7 258.7 L702.6 254.5 L698.3 250.3 L693.9 246.3 L689.5 242.4 L684.9 238.5 L680.3 234.8 L675.5 231.2 L670.7 227.6 L665.8 224.2 L660.8 220.8 L655.7 217.6 L650.5 214.5 L645.2 211.5 L639.9 208.6 L634.5 205.8 L629.1 203.2 L623.5 200.6 L617.9 198.2 L612.3 195.9 L606.6 193.7 L600.8 191.7 L595 189.7 L589.2 187.9 L583.3 186.2 L577.3 184.7 L571.3 183.3 L565.3 182 L559.3 180.8 L553.2 179.8 L547.1 178.9 L541 178.1 L534.8 177.5 L528.7 177 L522.5 176.6 L516.3 176.3 L510.2 176.2 L504 176.2 L497.8 176.4 L491.6 176.7 L485.4 177.1 L479.2 177.7 L473.1 178.3 L466.9 179.1 L460.8 180.1 L454.7 181.2 L448.6 182.4 L442.6 183.7 L436.6 185.1 L430.6 186.7 L424.6 188.4 L418.7 190.3 L412.8 192.2 L407 194.3 L401.2 196.6 L395.5 198.9 L389.8 201.4 L384.2 204 L378.7 206.7 L373.2 209.5 L367.7 212.5 L362.4 215.6 L357.1 218.9 L352 222.4 L347 226.2Z" fill="#F2F1EE"/><circle cx="512" cy="512" r="62" fill="#E95047"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="150 150 724 724"><path d="M842 512 L838.6 517.2 L835.4 522.4 L832.2 527.5 L828.9 532.5 L825.7 537.4 L822.4 542.2 L819 547 L815.7 551.6 L812.2 556.1 L808.8 560.5 L805.3 564.8 L801.7 569 L798.2 573.1 L794.5 577.1 L790.9 581 L787.2 584.8 L783.5 588.5 L779.7 592.1 L776 595.6 L772.2 599 L768.4 602.3 L764.5 605.5 L760.7 608.6 L756.8 611.6 L752.9 614.5 L749 617.4 L745.1 620.1 L741.2 622.7 L737.3 625.2 L733.4 627.7 L729.5 630 L725.6 632.3 L721.7 634.5 L717.8 636.6 L713.9 638.6 L710 640.5 L706.1 642.3 L702.2 644.1 L698.3 645.8 L694.5 647.4 L690.7 648.9 L686.9 650.3 L683.1 651.7 L679.3 653 L675.5 654.2 L671.8 655.4 L668.1 656.5 L664.4 657.5 L660.7 658.5 L657.1 659.3 L653.5 660.2 L649.9 661 L646.3 661.7 L642.8 662.3 L639.3 662.9 L635.8 663.5 L632.4 664 L629 664.4 L625.6 664.8 L622.2 665.2 L618.9 665.5 L615.6 665.7 L612.4 665.9 L609.1 666.1 L605.9 666.2 L602.8 666.3 L599.6 666.4 L596.5 666.4 L593.4 666.4 L590.4 666.3 L587.4 666.2 L584.4 666.1 L581.4 666 L578.5 665.8 L575.6 665.6 L572.7 665.3 L569.9 665.1 L567 664.8 L564.2 664.5 L561.5 664.1 L558.7 663.7 L556 663.4 L553.3 663 L550.6 662.5 L548 662.1 L545.4 661.6 L542.8 661.1 L540.2 660.6 L537.6 660.1 L535.1 659.5 L532.5 658.9 L530 658.3 L527.5 657.7 L525.1 657.1 L522.6 656.5 L520.2 655.8 L517.7 655.1 L515.3 654.4 L512.9 653.7 L510.5 653 L508.2 652.2 L505.8 651.4 L503.5 650.6 L501.1 649.8 L498.8 649 L496.5 648.1 L494.2 647.3 L491.9 646.4 L489.6 645.5 L487.3 644.5 L485.1 643.6 L482.8 642.6 L480.6 641.6 L478.3 640.6 L476.1 639.5 L473.9 638.4 L471.7 637.3 L469.5 636.2 L467.3 635 L465.1 633.9 L462.9 632.6 L460.7 631.4 L458.6 630.1 L456.4 628.8 L454.3 627.5 L452.2 626.1 L450 624.7 L447.9 623.3 L445.8 621.9 L443.8 620.4 L441.7 618.8 L439.6 617.3 L437.6 615.7 L435.6 614 L433.6 612.3 L431.6 610.6 L429.6 608.9 L427.6 607.1 L425.7 605.2 L423.8 603.4 L421.9 601.4 L420 599.5 L418.1 597.5 L416.3 595.4 L414.5 593.3 L412.7 591.2 L410.9 589 L409.2 586.8 L407.5 584.6 L405.8 582.3 L404.1 579.9 L402.5 577.5 L400.9 575.1 L399.3 572.6 L397.8 570 L396.2 567.4 L394.7 564.8 L393.2 562.1 L391.6 559.3 L389.8 556.5 L389.8 556.5 L387.3 558.7 L385.1 561.2 L383 563.7 L381.1 566.4 L379.3 569.1 L377.5 572 L375.8 574.9 L374.3 577.9 L372.8 581.1 L371.4 584.2 L370.1 587.5 L368.9 590.9 L367.8 594.3 L366.8 597.7 L365.8 601.3 L365 604.9 L364.3 608.6 L363.7 612.3 L363.2 616.1 L362.8 619.9 L362.5 623.8 L362.3 627.7 L362.2 631.7 L362.3 635.7 L362.4 639.7 L362.7 643.8 L363.1 647.9 L363.6 652 L364.2 656.1 L364.9 660.3 L365.8 664.4 L366.7 668.6 L367.8 672.8 L369 677 L370.4 681.1 L371.8 685.3 L373.4 689.5 L375.1 693.6 L377 697.8 L378.9 701.9 L381 706 L383.2 710.1 L385.5 714.1 L387.9 718.1 L390.5 722.1 L393.1 726 L395.9 729.9 L398.8 733.7 L401.8 737.5 L405 741.3 L408.2 744.9 L411.6 748.5 L415 752.1 L418.6 755.6 L422.3 759 L426.1 762.3 L430 765.5 L434 768.7 L438.1 771.8 L442.3 774.8 L446.5 777.7 L450.9 780.5 L455.4 783.3 L459.9 785.9 L464.6 788.4 L469.3 790.9 L474.1 793.2 L479 795.4 L483.9 797.5 L488.9 799.5 L494 801.4 L499.2 803.1 L504.4 804.8 L509.7 806.3 L515 807.7 L520.4 809 L525.8 810.2 L531.3 811.2 L536.8 812.1 L542.4 812.9 L548 813.5 L553.6 814.1 L559.3 814.5 L565 814.7 L570.7 814.8 L576.4 814.8 L582.2 814.7 L587.9 814.4 L593.7 814 L599.5 813.4 L605.3 812.8 L611 811.9 L616.8 811 L622.6 809.9 L628.3 808.7 L634 807.3 L639.8 805.8 L645.5 804.2 L651.1 802.4 L656.8 800.5 L662.4 798.5 L667.9 796.3 L673.5 794 L678.9 791.6 L684.4 789.1 L689.8 786.4 L695.1 783.6 L700.4 780.7 L705.6 777.6 L710.8 774.5 L715.9 771.2 L720.9 767.8 L725.9 764.3 L730.8 760.7 L735.6 756.9 L740.4 753.1 L745 749.1 L749.6 745 L754.1 740.9 L758.5 736.6 L762.8 732.2 L767 727.8 L771.2 723.2 L775.2 718.5 L779.1 713.8 L782.9 709 L786.7 704.1 L790.3 699.1 L793.8 694 L797.2 688.8 L800.5 683.6 L803.7 678.3 L806.8 672.9 L809.7 667.5 L812.6 662 L815.3 656.4 L817.9 650.8 L820.4 645.1 L822.8 639.4 L825 633.6 L827.2 627.8 L829.2 621.9 L831 616 L832.8 610.1 L834.4 604.1 L835.9 598.1 L837.3 592.1 L838.5 586 L839.6 579.9 L840.6 573.8 L841.4 567.7 L842.1 561.5 L842.7 555.4 L843.1 549.2 L843.4 543 L843.5 536.8 L843.5 530.6 L843.2 524.4 L842.8 518.2 L842 512.0Z" fill="#F2F1EE"/><path d="M347 797.8 L344.2 792.2 L341.3 786.8 L338.5 781.5 L335.8 776.2 L333.1 770.9 L330.6 765.7 L328.2 760.4 L325.9 755.2 L323.7 750 L321.6 744.8 L319.6 739.6 L317.8 734.4 L316 729.3 L314.4 724.1 L312.8 719 L311.4 713.9 L310 708.9 L308.8 703.8 L307.6 698.8 L306.6 693.8 L305.6 688.9 L304.8 683.9 L304 679 L303.3 674.2 L302.7 669.4 L302.2 664.6 L301.8 659.9 L301.5 655.2 L301.3 650.5 L301.1 645.9 L301 641.3 L301 636.8 L301.1 632.3 L301.2 627.9 L301.5 623.5 L301.7 619.2 L302.1 614.9 L302.5 610.7 L303 606.5 L303.5 602.4 L304.1 598.3 L304.8 594.3 L305.5 590.3 L306.2 586.4 L307.1 582.5 L307.9 578.7 L308.8 574.9 L309.8 571.2 L310.8 567.6 L311.8 564 L312.9 560.4 L314.1 556.9 L315.2 553.5 L316.4 550.1 L317.6 546.8 L318.9 543.5 L320.2 540.3 L321.5 537.1 L322.9 534 L324.2 530.9 L325.6 527.9 L327.1 524.9 L328.5 522 L330 519.1 L331.5 516.2 L333 513.4 L334.5 510.7 L336 508 L337.6 505.3 L339.2 502.7 L340.7 500.2 L342.3 497.6 L344 495.1 L345.6 492.7 L347.2 490.3 L348.9 487.9 L350.5 485.6 L352.2 483.3 L353.8 481 L355.5 478.8 L357.2 476.6 L358.9 474.4 L360.6 472.3 L362.3 470.2 L364 468.1 L365.8 466.1 L367.5 464.1 L369.2 462.1 L371 460.2 L372.7 458.2 L374.5 456.3 L376.3 454.4 L378 452.6 L379.8 450.8 L381.6 449 L383.4 447.2 L385.2 445.4 L387 443.7 L388.8 442 L390.7 440.3 L392.5 438.6 L394.3 436.9 L396.2 435.3 L398.1 433.7 L400 432.1 L401.8 430.5 L403.8 428.9 L405.7 427.4 L407.6 425.9 L409.6 424.4 L411.5 422.9 L413.5 421.4 L415.5 420 L417.5 418.6 L419.5 417.2 L421.6 415.8 L423.6 414.4 L425.7 413.1 L427.8 411.7 L429.9 410.4 L432.1 409.2 L434.2 407.9 L436.4 406.7 L438.6 405.5 L440.8 404.3 L443.1 403.1 L445.3 402 L447.6 400.9 L449.9 399.8 L452.3 398.7 L454.6 397.7 L457 396.7 L459.4 395.7 L461.9 394.8 L464.3 393.9 L466.8 393 L469.3 392.2 L471.9 391.4 L474.4 390.6 L477 389.9 L479.6 389.2 L482.3 388.6 L484.9 388 L487.6 387.4 L490.3 386.9 L493.1 386.4 L495.8 385.9 L498.6 385.5 L501.4 385.2 L504.3 384.9 L507.1 384.6 L510 384.4 L512.9 384.3 L515.9 384.1 L518.9 384.1 L521.9 384 L524.9 384 L528 384.1 L531.2 384.1 L534.6 384 L534.6 384 L533.9 380.6 L532.9 377.5 L531.7 374.5 L530.4 371.4 L528.9 368.5 L527.3 365.5 L525.6 362.6 L523.8 359.7 L521.8 356.9 L519.7 354.1 L517.6 351.3 L515.3 348.6 L512.9 346 L510.4 343.3 L507.8 340.8 L505 338.3 L502.2 335.8 L499.3 333.4 L496.3 331.1 L493.2 328.8 L489.9 326.6 L486.6 324.5 L483.3 322.4 L479.8 320.5 L476.2 318.6 L472.5 316.8 L468.8 315.1 L465 313.5 L461.1 311.9 L457.1 310.5 L453.1 309.1 L449 307.9 L444.8 306.8 L440.6 305.7 L436.3 304.8 L432 304 L427.6 303.2 L423.1 302.6 L418.6 302.2 L414.1 301.8 L409.5 301.5 L404.9 301.4 L400.2 301.4 L395.5 301.5 L390.8 301.7 L386.1 302 L381.3 302.5 L376.6 303.1 L371.8 303.8 L367 304.7 L362.2 305.7 L357.4 306.8 L352.6 308 L347.8 309.4 L343 310.8 L338.2 312.5 L333.4 314.2 L328.7 316.1 L324 318.1 L319.3 320.2 L314.6 322.4 L310 324.8 L305.4 327.3 L300.8 330 L296.3 332.7 L291.9 335.6 L287.4 338.6 L283.1 341.7 L278.8 344.9 L274.5 348.3 L270.4 351.7 L266.3 355.3 L262.2 359 L258.3 362.8 L254.4 366.7 L250.6 370.8 L246.9 374.9 L243.2 379.1 L239.7 383.5 L236.2 387.9 L232.9 392.4 L229.6 397 L226.4 401.7 L223.3 406.5 L220.4 411.4 L217.5 416.4 L214.8 421.4 L212.1 426.6 L209.6 431.8 L207.2 437 L204.9 442.4 L202.7 447.8 L200.7 453.3 L198.7 458.8 L196.9 464.4 L195.2 470 L193.7 475.7 L192.2 481.5 L190.9 487.3 L189.8 493.1 L188.7 499 L187.8 504.9 L187 510.8 L186.4 516.8 L185.9 522.7 L185.5 528.8 L185.2 534.8 L185.1 540.8 L185.1 546.9 L185.3 552.9 L185.6 559 L186 565 L186.6 571.1 L187.3 577.2 L188.1 583.2 L189.1 589.2 L190.2 595.2 L191.4 601.2 L192.8 607.2 L194.2 613.2 L195.9 619.1 L197.6 625 L199.5 630.8 L201.5 636.7 L203.7 642.4 L205.9 648.2 L208.3 653.8 L210.9 659.5 L213.5 665.1 L216.3 670.6 L219.1 676.1 L222.1 681.5 L225.2 686.8 L228.5 692.1 L231.8 697.3 L235.3 702.5 L238.8 707.5 L242.5 712.5 L246.3 717.5 L250.1 722.3 L254.1 727 L258.2 731.7 L262.4 736.3 L266.7 740.8 L271 745.2 L275.5 749.5 L280 753.7 L284.7 757.8 L289.4 761.8 L294.2 765.7 L299.1 769.4 L304.1 773.1 L309.1 776.7 L314.3 780.1 L319.5 783.5 L324.8 786.7 L330.1 789.7 L335.6 792.6 L341.2 795.4 L347 797.8Z" fill="#F2F1EE"/><path d="M347 226.2 L353.2 226.6 L359.3 226.8 L365.4 227 L371.3 227.3 L377.2 227.6 L383 228.1 L388.7 228.6 L394.4 229.2 L400 229.9 L405.6 230.7 L411.1 231.6 L416.5 232.6 L421.8 233.6 L427.1 234.8 L432.3 236 L437.5 237.3 L442.5 238.6 L447.5 240.1 L452.4 241.6 L457.3 243.2 L462 244.8 L466.7 246.6 L471.3 248.3 L475.9 250.2 L480.3 252.1 L484.7 254 L489 256.1 L493.3 258.1 L497.4 260.3 L501.5 262.4 L505.5 264.6 L509.4 266.9 L513.2 269.2 L517 271.5 L520.7 273.9 L524.3 276.3 L527.8 278.8 L531.3 281.2 L534.7 283.7 L538 286.3 L541.2 288.8 L544.4 291.4 L547.5 294 L550.5 296.6 L553.4 299.3 L556.3 301.9 L559.1 304.6 L561.8 307.3 L564.5 310 L567.1 312.7 L569.6 315.4 L572 318.1 L574.4 320.8 L576.8 323.6 L579.1 326.3 L581.3 329 L583.4 331.7 L585.5 334.5 L587.5 337.2 L589.5 339.9 L591.4 342.7 L593.3 345.4 L595.1 348.1 L596.9 350.8 L598.6 353.5 L600.3 356.2 L601.9 358.9 L603.4 361.6 L605 364.3 L606.4 367 L607.9 369.6 L609.3 372.3 L610.6 374.9 L611.9 377.5 L613.2 380.1 L614.4 382.8 L615.6 385.4 L616.8 387.9 L617.9 390.5 L619 393.1 L620.1 395.7 L621.1 398.2 L622.1 400.7 L623 403.3 L624 405.8 L624.9 408.3 L625.7 410.8 L626.6 413.3 L627.4 415.8 L628.2 418.3 L629 420.8 L629.7 423.2 L630.4 425.7 L631.1 428.1 L631.8 430.6 L632.4 433 L633.1 435.5 L633.7 437.9 L634.2 440.3 L634.8 442.8 L635.3 445.2 L635.8 447.6 L636.3 450.1 L636.8 452.5 L637.2 454.9 L637.7 457.4 L638.1 459.8 L638.4 462.2 L638.8 464.7 L639.1 467.1 L639.4 469.5 L639.7 472 L639.9 474.4 L640.2 476.9 L640.4 479.3 L640.5 481.8 L640.7 484.3 L640.8 486.7 L640.9 489.2 L641 491.7 L641 494.2 L641 496.7 L641 499.2 L641 501.7 L640.9 504.2 L640.8 506.7 L640.6 509.3 L640.4 511.8 L640.2 514.4 L640 516.9 L639.7 519.5 L639.3 522 L639 524.6 L638.6 527.2 L638.1 529.8 L637.6 532.4 L637.1 534.9 L636.5 537.5 L635.9 540.1 L635.2 542.7 L634.5 545.4 L633.8 548 L633 550.6 L632.1 553.2 L631.2 555.8 L630.3 558.4 L629.3 561 L628.2 563.6 L627.1 566.2 L625.9 568.9 L624.7 571.5 L623.5 574.1 L622.2 576.7 L620.8 579.3 L619.4 581.9 L617.9 584.5 L616.4 587.2 L614.8 589.8 L613.2 592.6 L611.6 595.6 L611.6 595.6 L614.8 596.6 L618 597.3 L621.3 597.8 L624.5 598.2 L627.9 598.4 L631.2 598.5 L634.6 598.5 L638 598.3 L641.4 598 L644.9 597.6 L648.3 597.1 L651.8 596.5 L655.4 595.8 L658.9 594.9 L662.4 593.9 L665.9 592.8 L669.5 591.6 L673 590.3 L676.5 588.8 L680.1 587.3 L683.6 585.6 L687.1 583.8 L690.5 581.9 L694 579.8 L697.4 577.7 L700.8 575.4 L704.1 573.1 L707.4 570.6 L710.7 568 L713.9 565.3 L717.1 562.4 L720.3 559.5 L723.3 556.5 L726.3 553.3 L729.3 550.1 L732.2 546.7 L735 543.3 L737.7 539.7 L740.4 536.1 L743 532.3 L745.5 528.5 L748 524.5 L750.3 520.5 L752.6 516.4 L754.7 512.2 L756.8 507.9 L758.7 503.6 L760.6 499.1 L762.4 494.6 L764.1 490.1 L765.6 485.4 L767.1 480.7 L768.4 475.9 L769.6 471.1 L770.7 466.2 L771.7 461.2 L772.6 456.3 L773.3 451.2 L774 446.1 L774.5 441 L774.9 435.8 L775.1 430.6 L775.2 425.4 L775.2 420.1 L775.1 414.9 L774.9 409.6 L774.5 404.2 L773.9 398.9 L773.3 393.6 L772.5 388.2 L771.6 382.9 L770.5 377.5 L769.4 372.2 L768.1 366.9 L766.6 361.5 L765 356.2 L763.3 350.9 L761.5 345.7 L759.5 340.4 L757.4 335.2 L755.1 330 L752.8 324.9 L750.3 319.8 L747.7 314.8 L744.9 309.7 L742 304.8 L739 299.9 L735.9 295 L732.7 290.2 L729.3 285.5 L725.8 280.9 L722.2 276.3 L718.5 271.7 L714.7 267.3 L710.8 262.9 L706.7 258.7 L702.6 254.5 L698.3 250.3 L693.9 246.3 L689.5 242.4 L684.9 238.5 L680.3 234.8 L675.5 231.2 L670.7 227.6 L665.8 224.2 L660.8 220.8 L655.7 217.6 L650.5 214.5 L645.2 211.5 L639.9 208.6 L634.5 205.8 L629.1 203.2 L623.5 200.6 L617.9 198.2 L612.3 195.9 L606.6 193.7 L600.8 191.7 L595 189.7 L589.2 187.9 L583.3 186.2 L577.3 184.7 L571.3 183.3 L565.3 182 L559.3 180.8 L553.2 179.8 L547.1 178.9 L541 178.1 L534.8 177.5 L528.7 177 L522.5 176.6 L516.3 176.3 L510.2 176.2 L504 176.2 L497.8 176.4 L491.6 176.7 L485.4 177.1 L479.2 177.7 L473.1 178.3 L466.9 179.1 L460.8 180.1 L454.7 181.2 L448.6 182.4 L442.6 183.7 L436.6 185.1 L430.6 186.7 L424.6 188.4 L418.7 190.3 L412.8 192.2 L407 194.3 L401.2 196.6 L395.5 198.9 L389.8 201.4 L384.2 204 L378.7 206.7 L373.2 209.5 L367.7 212.5 L362.4 215.6 L357.1 218.9 L352 222.4 L347 226.2Z" fill="#F2F1EE"/><circle cx="512" cy="512" r="76" fill="#E95047"/></svg>
@@ -26,7 +26,7 @@ const C = { deep: "#1D2751", deep2: "#141C3D", black: "#000000", ink: "#F2F1EE",
26
26
  const markSrc = readFileSync(process.env.ELANOUS_MARK ?? join(here, "brand", "elanous-mark-on-dark.svg"), "utf8");
27
27
  const markPaths = [...markSrc.matchAll(/<path d="([^"]*)"/g)].map((m) => m[1]);
28
28
  const mark = (size, blade = C.ink, dot = C.red, cls = "") =>
29
- `<svg class="${cls}" width="${size}" height="${size}" viewBox="150 150 724 724">${markPaths.map((d) => `<path d="${d}" fill="${blade}"/>`).join("")}<circle cx="512" cy="512" r="62" fill="${dot}"/></svg>`;
29
+ `<svg class="${cls}" width="${size}" height="${size}" viewBox="150 150 724 724">${markPaths.map((d) => `<path d="${d}" fill="${blade}"/>`).join("")}<circle cx="512" cy="512" r="76" fill="${dot}"/></svg>`;
30
30
 
31
31
  // ── timing from alignment ───────────────────────────────────────────────────────
32
32
  const vo = (key) => JSON.parse(readFileSync(join(proj, "source", "vo", `${key}.json`), "utf8"));
@@ -0,0 +1,128 @@
1
+ // Field reel — a folder of phone photos / short clips → one vertical 30–60 s HyperFrames composition (EV10).
2
+ // Usage: node reel.mjs <folder> [--title "마케터의 밤"] [--sub "2026.10.02 · 서울"] [--max 60] [--min 30]
3
+ // <folder>/captions.txt (optional): one line per item — `파일명 | 자막` (a line without « | » is ignored).
4
+ // Output: <folder>/reel/hf/ (HyperFrames project) ⊕ reel/timeline.json. Render: npx hyperframes render --fps 30 in reel/hf.
5
+ // Look = Elanvital CI: Real Black ground · Icarus Red single accent · V6 mark · Pretendard. Order = capture time (then name).
6
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs";
7
+ import { join, dirname, resolve, extname, basename } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { spawnSync } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ const argv = process.argv.slice(2);
14
+ const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; };
15
+ const folder = resolve(argv.find((a, i) => !a.startsWith("--") && !(i > 0 && argv[i - 1].startsWith("--"))) ?? ".");
16
+ const TITLE = opt("title", "현장 스케치"), SUB = opt("sub", ""), MAX = Number(opt("max", 60)), MIN = Number(opt("min", 30));
17
+ const W = 1080, H = 1920, INTRO = 2.2, END = 3.0, PHOTO = 3.5, CLIP_MAX = 5, XF = 0.35;
18
+ const out = join(folder, "reel"), hf = join(out, "hf"), media = join(hf, "assets", "media");
19
+ mkdirSync(media, { recursive: true }); mkdirSync(join(hf, "assets", "fonts"), { recursive: true });
20
+
21
+ // ── collect & order ────────────────────────────────────────────────────────────
22
+ const IMG = [".jpg", ".jpeg", ".png", ".heic", ".webp"], VID = [".mp4", ".mov", ".m4v"];
23
+ const run = (bin, args) => spawnSync(bin, args, { encoding: "utf8" });
24
+ // Uploads from the field endpoint are named `20261002T193012Z-<device>-<name>` (capture time, UTC) — trust that first.
25
+ const stamped = (f) => { const m = /^(\d{8})T(\d{6})Z-/.exec(f); return m ? Date.parse(`${m[1].slice(0, 4)}-${m[1].slice(4, 6)}-${m[1].slice(6)}T${m[2].slice(0, 2)}:${m[2].slice(2, 4)}:${m[2].slice(4)}Z`) : NaN; };
26
+ const created = (p) => {
27
+ const st = stamped(basename(p)); if (Number.isFinite(st)) return st;
28
+ const m = run("mdls", ["-raw", "-name", "kMDItemContentCreationDate", p]).stdout?.trim();
29
+ const t = m && m !== "(null)" ? Date.parse(m.replace(" +0000", "Z").replace(" ", "T")) : NaN;
30
+ return Number.isFinite(t) ? t : statSync(p).mtimeMs;
31
+ };
32
+ const probeDur = (p) => Number(run("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", p]).stdout.trim()) || 0;
33
+ const caps = Object.fromEntries((existsSync(join(folder, "captions.txt")) ? readFileSync(join(folder, "captions.txt"), "utf8") : "")
34
+ .split("\n").filter((l) => l.includes("|")).map((l) => l.split("|").map((s) => s.trim())));
35
+ let items = readdirSync(folder).filter((f) => [...IMG, ...VID].includes(extname(f).toLowerCase()))
36
+ .map((f) => ({ f, p: join(folder, f), video: VID.includes(extname(f).toLowerCase()), t: created(join(folder, f)) }))
37
+ .sort((a, b) => a.t - b.t || a.f.localeCompare(b.f));
38
+ if (!items.length) throw new Error(`no photos or clips in ${folder}`);
39
+
40
+ // ── durations: fit into [MIN, MAX] ─────────────────────────────────────────────
41
+ for (const it of items) it.dur = it.video ? Math.min(CLIP_MAX, Math.max(1.5, probeDur(it.p))) : PHOTO;
42
+ const body = () => items.reduce((a, it) => a + it.dur, 0);
43
+ while (INTRO + body() + END > MAX && items.length > 1) items.splice(Math.floor(items.length / 2), 1); // drop from the middle, keep first & last
44
+ if (INTRO + body() + END < MIN) { const photos = items.filter((i) => !i.video); const need = MIN - (INTRO + body() + END); for (const p of photos) p.dur += need / Math.max(1, photos.length); }
45
+
46
+ // ── media prep: HEIC → jpg · big photos down to 2160 · clips re-encoded short & muted ──
47
+ let t = INTRO;
48
+ items.forEach((it, i) => {
49
+ it.start = +t.toFixed(3); t += it.dur;
50
+ if (it.video) {
51
+ it.src = `m${i}.mp4`;
52
+ const r = run("ffmpeg", ["-v", "error", "-y", "-i", it.p, "-t", String(it.dur), "-an", "-vf", `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},fps=30`, "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", join(media, it.src)]);
53
+ if (r.status !== 0) throw new Error(`clip ${it.f}: ${r.stderr.slice(0, 200)}`);
54
+ } else {
55
+ it.src = `m${i}.jpg`;
56
+ const r = run("sips", ["-s", "format", "jpeg", "-Z", "2160", it.p, "--out", join(media, it.src)]);
57
+ if (r.status !== 0) throw new Error(`photo ${it.f}: ${r.stderr.slice(0, 200)}`);
58
+ }
59
+ it.cap = caps[it.f] ?? "";
60
+ });
61
+ const TOTAL = +(t + END).toFixed(3);
62
+
63
+ // ── assets (same cache as build.mjs) ───────────────────────────────────────────
64
+ const CACHE = process.env.EXPLAINER_CACHE ?? join(homedir(), ".cache", "elanous-explainer");
65
+ const PD = "https://cdn.jsdelivr.net/npm/pretendard@1.3.9/dist/web/static/woff2/";
66
+ const ASSETS = { "fonts/Pretendard-Bold.woff2": PD + "Pretendard-Bold.woff2", "fonts/Pretendard-ExtraBold.woff2": PD + "Pretendard-ExtraBold.woff2", "fonts/Pretendard-Black.woff2": PD + "Pretendard-Black.woff2", "gsap.min.js": "https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" };
67
+ for (const [rel, url] of Object.entries(ASSETS)) {
68
+ const c = join(CACHE, rel);
69
+ if (!existsSync(c)) { const res = await fetch(url); if (!res.ok) throw new Error(`asset ${rel}: HTTP ${res.status}`); mkdirSync(dirname(c), { recursive: true }); writeFileSync(c, Buffer.from(await res.arrayBuffer())); }
70
+ copyFileSync(c, join(hf, "assets", rel));
71
+ }
72
+ const markPaths = [...readFileSync(join(here, "brand", "elanous-mark-on-dark.svg"), "utf8").matchAll(/<path d="([^"]*)"/g)].map((m) => m[1]);
73
+ const mark = (s, cls = "") => `<svg class="${cls}" width="${s}" height="${s}" viewBox="150 150 724 724">${markPaths.map((d) => `<path d="${d}" fill="#F2F1EE"/>`).join("")}<circle cx="512" cy="512" r="76" fill="#E95047"/></svg>`;
74
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
75
+ const f = (x) => +x.toFixed(3);
76
+
77
+ // ── composition ────────────────────────────────────────────────────────────────
78
+ const tw = [];
79
+ const clips = items.map((it, i) => {
80
+ const vis = it.video
81
+ ? `<video id="v${i}" class="clip med" src="assets/media/${it.src}" muted playsinline data-start="${f(it.start)}" data-duration="${f(it.dur)}" data-track-index="1"></video>`
82
+ : `<div class="clip med" data-start="${f(it.start)}" data-duration="${f(it.dur)}" data-track-index="1"><img class="kb k${i}" src="assets/media/${it.src}"/></div>`;
83
+ if (!it.video) tw.push(`tl.fromTo(".k${i}", {scale:1.0, x:0}, {scale:1.09, x:${i % 2 ? -24 : 24}, duration:${f(it.dur)}, ease:"none"}, ${f(it.start)});`);
84
+ const cap = it.cap ? `<div class="clip capw" data-start="${f(it.start)}" data-duration="${f(it.dur)}" data-track-index="3"><div class="cap c${i}">${esc(it.cap)}</div></div>` : "";
85
+ if (it.cap) tw.push(`tl.fromTo(".c${i}", {opacity:0, y:20}, {opacity:1, y:0, duration:0.4, ease:"power3.out"}, ${f(it.start + 0.15)});`);
86
+ return vis + cap;
87
+ }).join("\n");
88
+ tw.push(`tl.fromTo(".it", {opacity:0, y:30}, {opacity:1, y:0, duration:0.5, stagger:0.15, ease:"power3.out"}, 0.1);`);
89
+ tw.push(`tl.fromTo(".im", {rotation:-160, scale:0.6}, {rotation:0, scale:1, duration:1.0, ease:"power3.out"}, 0.0);`);
90
+ tw.push(`tl.fromTo(".em", {rotation:-200, scale:0.5}, {rotation:0, scale:1, duration:1.2, ease:"power3.out"}, ${f(t)});`);
91
+ tw.push(`tl.fromTo(".et", {opacity:0, y:20}, {opacity:1, y:0, duration:0.5, stagger:0.12}, ${f(t + 0.5)});`);
92
+ tw.push(`tl.fromTo(".bar i", {scaleX:0}, {scaleX:1, duration:${TOTAL}, ease:"none"}, 0);`);
93
+ const html = `<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=${W}, height=${H}"/><title>${esc(TITLE)}</title><script src="assets/gsap.min.js"></script><style>
94
+ @font-face{font-family:"Pretendard";src:url("assets/fonts/Pretendard-Bold.woff2") format("woff2");font-weight:700}
95
+ @font-face{font-family:"Pretendard";src:url("assets/fonts/Pretendard-ExtraBold.woff2") format("woff2");font-weight:800}
96
+ @font-face{font-family:"Pretendard";src:url("assets/fonts/Pretendard-Black.woff2") format("woff2");font-weight:900}
97
+ *{margin:0;padding:0;box-sizing:border-box}
98
+ #root{position:relative;width:100%;height:100%;overflow:hidden;background:#000;font-family:"Pretendard",sans-serif;color:#F2F1EE;word-break:keep-all}
99
+ .med{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;overflow:hidden}
100
+ .kb{width:100%;height:100%;object-fit:cover;display:block}
101
+ .shade{z-index:2;position:absolute;left:0;right:0;bottom:0;height:46%;background:linear-gradient(to top,rgba(0,0,0,.78),rgba(0,0,0,0));pointer-events:none}
102
+ .top{z-index:5;position:absolute;left:56px;right:56px;top:72px;display:flex;align-items:center;gap:18px;font-weight:800;font-size:34px;text-shadow:0 2px 12px rgba(0,0,0,.6)}
103
+ .top b{color:#E95047;font-weight:800}
104
+ .capw{z-index:4;position:absolute;left:64px;right:64px;bottom:250px}
105
+ .cap{font-weight:800;font-size:64px;line-height:1.25;letter-spacing:-0.02em;text-shadow:0 3px 16px rgba(0,0,0,.7)}
106
+ .card{z-index:6;position:absolute;inset:0;background:#000;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:0 80px}
107
+ .card h1{margin-top:60px;font-weight:900;font-size:104px;line-height:1.1;letter-spacing:-0.03em}
108
+ .card p{margin-top:28px;font-weight:700;font-size:40px;color:rgba(242,241,238,.62)}
109
+ .card .red{color:#E95047}
110
+ .bar{z-index:7;position:absolute;left:0;right:0;bottom:0;height:10px;background:rgba(242,241,238,.12)}
111
+ .bar i{position:absolute;inset:0;background:#E95047;transform-origin:0 50%;display:block}
112
+ </style></head><body>
113
+ <div id="root" data-composition-id="main" data-start="0" data-width="${W}" data-height="${H}" data-duration="${TOTAL}">
114
+ <section class="clip card" data-start="0" data-duration="${INTRO}" data-track-index="5">${mark(220, "im")}<h1 class="it">${esc(TITLE)}</h1>${SUB ? `<p class="it">${esc(SUB)}</p>` : ""}</section>
115
+ ${clips}
116
+ <div class="shade"></div>
117
+ <div class="top">${mark(60)}<span>Elanous <b>·</b> ${esc(TITLE)}</span></div>
118
+ <section class="clip card" data-start="${f(t)}" data-duration="${END}" data-track-index="5">${mark(240, "em")}<h1 class="et">Elanous</h1><p class="et">현장에서 바로 만든 영상</p><p class="et red">elanous.ai</p></section>
119
+ <div class="bar"><i></i></div>
120
+ </div>
121
+ <script>const tl = gsap.timeline({ paused: true });
122
+ ${tw.join("\n")}
123
+ window.__timelines["main"] = tl;</script></body></html>`;
124
+ writeFileSync(join(hf, "index.html"), html);
125
+ writeFileSync(join(hf, "hyperframes.json"), JSON.stringify({ $schema: "https://hyperframes.heygen.com/schema/hyperframes.json", paths: { assets: "assets" }, media: { autoProxy: true } }, null, 2) + "\n");
126
+ writeFileSync(join(hf, "meta.json"), JSON.stringify({ id: "reel", name: "reel" }, null, 2) + "\n");
127
+ writeFileSync(join(out, "timeline.json"), JSON.stringify({ total: TOTAL, items: items.map(({ f: file, video, start, dur, cap }) => ({ file, video, start, dur: f(dur), cap })) }, null, 2));
128
+ console.log(`reel: ${items.length} items · ${TOTAL}s · ${items.filter((i) => i.cap).length} captions → ${hf}`);
@@ -0,0 +1,8 @@
1
+ #!/bin/zsh
2
+ # Field reel in one command: folder of phone photos/clips → <folder>/reel/<name>-9x16.mp4 · prints the elapsed seconds.
3
+ # Usage: zsh reel.sh <folder> [--title "…"] [--sub "…"] (captions: <folder>/captions.txt — `파일 | 자막`)
4
+ E=${0:A:h}; F=${1:A}; shift
5
+ [[ -d $F ]] || { print -u2 "reel: no folder $F"; exit 2 }
6
+ T0=$(date +%s)
7
+ rm -rf $F/reel && node $E/reel.mjs $F "$@" && (cd $F/reel/hf && npx -y hyperframes@${HYPERFRAMES_VERSION:-0.8.95} check >../render.log 2>&1 && npx -y hyperframes@${HYPERFRAMES_VERSION:-0.8.95} render --fps 30 --workers 4 --output ../reel-9x16.mp4 >>../render.log 2>&1) || { print -u2 "reel: FAILED — see $F/reel/render.log"; exit 1 }
8
+ print "reel: $F/reel/reel-9x16.mp4 · $(( $(date +%s) - T0 ))s"
@@ -1,12 +1,13 @@
1
- // Explainer VO — one ElevenLabs /with-timestamps call per beat; the alignment drives caption & highlight timing.
2
- // Usage: ELEVENLABS_API_KEY=… bun vo.ts <project>/script.json
3
- // Writes source/vo/<key>.mp3 ⊕ <key>.json (alignment + text hash). Skips a beat whose text hash is unchanged. Never prints the key.
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ // Explainer VO — one voice file per beat plus per-character times; the times drive caption & highlight timing.
2
+ // Usage: ELEVENLABS_API_KEY=… bun vo.ts <project>/script.json (ElevenLabs /with-timestamps — exact word times)
3
+ // bun vo.ts <project>/script.json (no key → macOS `say`, times spread evenly — a draft voice)
4
+ // Writes source/vo/<key>.mp3 ⊕ <key>.json (alignment ⊕ text hash ⊕ engine). Skips a beat whose hash is unchanged. Never prints the key.
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
6
  import { createHash } from 'node:crypto';
7
+ import { spawnSync } from 'node:child_process';
6
8
  import { join, dirname, resolve } from 'node:path';
7
9
 
8
- const KEY = process.env.ELEVENLABS_API_KEY;
9
- if (!KEY) throw new Error('ELEVENLABS_API_KEY missing — set it from the plugin connector (elanous plugin credentials video-explainer)');
10
+ const KEY = process.env.ELEVENLABS_API_KEY?.trim();
10
11
  const scriptPath = resolve(process.argv[2] ?? 'script.json');
11
12
  const script = JSON.parse(readFileSync(scriptPath, 'utf8'));
12
13
  const dir = join(dirname(scriptPath), 'source', 'vo');
@@ -20,18 +21,59 @@ export function beatKeys(s: any): { key: string; vo: string }[] {
20
21
  return out;
21
22
  }
22
23
 
23
- for (const { key, vo } of beatKeys(script)) {
24
- const hash = createHash('sha256').update(`${script.voice.id}|${script.voice.model}|${vo}`).digest('hex').slice(0, 16);
25
- const jf = join(dir, `${key}.json`);
26
- if (existsSync(jf) && JSON.parse(readFileSync(jf, 'utf8')).hash === hash) { console.log(`${key} cached`); continue; }
24
+ type Alignment = { characters: string[]; character_start_times_seconds: number[]; character_end_times_seconds: number[] };
25
+
26
+ async function elevenlabs(vo: string): Promise<{ mp3: Buffer; alignment: Alignment }> {
27
27
  const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${script.voice.id}/with-timestamps?output_format=mp3_44100_192`, {
28
28
  method: 'POST',
29
- headers: { 'xi-api-key': KEY, 'content-type': 'application/json' },
29
+ headers: { 'xi-api-key': KEY!, 'content-type': 'application/json' },
30
30
  body: JSON.stringify({ text: vo, model_id: script.voice.model, language_code: 'ko' }),
31
31
  });
32
- if (!res.ok) throw new Error(`${key}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
33
- const data = await res.json() as { audio_base64: string; alignment: { characters: string[]; character_start_times_seconds: number[]; character_end_times_seconds: number[] } };
34
- writeFileSync(join(dir, `${key}.mp3`), Buffer.from(data.audio_base64, 'base64'));
35
- writeFileSync(jf, JSON.stringify({ hash, vo, alignment: data.alignment }));
36
- console.log(`${key} ${(data.alignment.character_end_times_seconds.at(-1) ?? 0).toFixed(2)}s ${vo}`);
32
+ if (!res.ok) throw new Error(`ElevenLabs HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
33
+ const data = await res.json() as { audio_base64: string; alignment: Alignment };
34
+ return { mp3: Buffer.from(data.audio_base64, 'base64'), alignment: data.alignment };
35
+ }
36
+
37
+ /** Keyless draft voice: macOS `say` → mp3 · character times spread in proportion over the measured length. */
38
+ function sayVoice(): string {
39
+ const want = process.env.EXPLAINER_SAY_VOICE;
40
+ if (want) return want;
41
+ const list = spawnSync('say', ['-v', '?'], { encoding: 'utf8' });
42
+ if (list.status !== 0) throw new Error('no ELEVENLABS_API_KEY and `say` is unavailable (macOS only) — set the key via the plugin connector');
43
+ const ko = list.stdout.split('\n').filter((l) => /\bko_KR\b/.test(l)).map((l) => l.split(/\s{2,}|\s+\(/)[0].trim());
44
+ const pick = ko.find((v) => /^Yuna/.test(v)) ?? ko[0];
45
+ if (!pick) throw new Error('no Korean `say` voice installed — System Settings › Accessibility › Spoken Content, or set ELEVENLABS_API_KEY');
46
+ return pick;
47
+ }
48
+ function say(vo: string, key: string): { mp3: Buffer; alignment: Alignment } {
49
+ const voice = sayVoice();
50
+ const aiff = join(dir, `${key}.tmp.aiff`), mp3 = join(dir, `${key}.tmp.mp3`);
51
+ const r = spawnSync('say', ['-v', voice, '-o', aiff, vo], { encoding: 'utf8' });
52
+ if (r.status !== 0) throw new Error(`say failed: ${r.stderr.slice(0, 200)}`);
53
+ const f = spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', aiff, '-ar', '44100', '-b:a', '192k', mp3], { encoding: 'utf8' });
54
+ if (f.status !== 0) throw new Error(`ffmpeg failed: ${f.stderr.slice(0, 200)}`);
55
+ const p = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', mp3], { encoding: 'utf8' });
56
+ const dur = Number(p.stdout.trim());
57
+ if (!(dur > 0)) throw new Error('ffprobe could not measure the say output');
58
+ const chars = [...vo];
59
+ const weight = chars.map((c) => (/\s/.test(c) ? 0.3 : /[,.·!?]/.test(c) ? 0.8 : 1));
60
+ const total = weight.reduce((a, b) => a + b, 0);
61
+ const starts: number[] = [], ends: number[] = [];
62
+ let t = 0;
63
+ for (const w of weight) { starts.push(+t.toFixed(3)); t += (w / total) * dur; ends.push(+t.toFixed(3)); }
64
+ const out = readFileSync(mp3);
65
+ rmSync(aiff, { force: true }); rmSync(mp3, { force: true });
66
+ return { mp3: out, alignment: { characters: chars, character_start_times_seconds: starts, character_end_times_seconds: ends } };
67
+ }
68
+
69
+ const engine = KEY ? 'elevenlabs' : 'say';
70
+ if (!KEY) console.log('no ELEVENLABS_API_KEY — draft voice with macOS `say` (caption times are estimates)');
71
+ for (const { key, vo } of beatKeys(script)) {
72
+ const hash = createHash('sha256').update(`${engine === 'say' ? 'say|' : ''}${script.voice.id}|${script.voice.model}|${vo}`).digest('hex').slice(0, 16);
73
+ const jf = join(dir, `${key}.json`);
74
+ if (existsSync(jf) && JSON.parse(readFileSync(jf, 'utf8')).hash === hash) { console.log(`${key} cached`); continue; }
75
+ const { mp3, alignment } = KEY ? await elevenlabs(vo) : say(vo, key);
76
+ writeFileSync(join(dir, `${key}.mp3`), mp3);
77
+ writeFileSync(jf, JSON.stringify({ hash, engine, vo, alignment }));
78
+ console.log(`${key} ${(alignment.character_end_times_seconds.at(-1) ?? 0).toFixed(2)}s ${engine} ${vo}`);
37
79
  }