kasaframe 0.1.4 → 0.1.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 (374) hide show
  1. package/NOTICE +311 -0
  2. package/README.md +18 -5
  3. package/docs/AGENTS.md +2 -1
  4. package/package.json +1 -1
  5. package/packages/adapters/src/project.ts +2 -0
  6. package/packages/check/package.json +1 -0
  7. package/packages/check/src/check.ts +8 -2
  8. package/packages/check/src/rules/frames.ts +4 -5
  9. package/packages/cli/src/commands/doctor.ts +15 -2
  10. package/packages/cli/src/commands/fonts.ts +118 -0
  11. package/packages/cli/src/commands/make.ts +15 -5
  12. package/packages/cli/src/commands/render.ts +34 -7
  13. package/packages/cli/src/commands/update.ts +66 -0
  14. package/packages/cli/src/fonts.ts +405 -0
  15. package/packages/cli/src/kf.ts +5 -0
  16. package/packages/cli/src/mix.ts +66 -4
  17. package/packages/cli/src/segments.ts +1 -1
  18. package/packages/cli/src/sfx.ts +429 -0
  19. package/packages/cli/src/update.ts +250 -0
  20. package/packages/core/src/frame.ts +7 -4
  21. package/packages/core/src/index.ts +3 -1
  22. package/packages/core/src/project.ts +19 -0
  23. package/packages/core/src/sfx.ts +70 -0
  24. package/packages/director/src/assemble.ts +7 -4
  25. package/packages/director/src/index.ts +1 -1
  26. package/packages/director/src/pattern.ts +2 -0
  27. package/packages/director/src/scene.ts +14 -5
  28. package/packages/director/src/transitions.ts +21 -8
  29. package/packages/motion/src/enter.ts +7 -1
  30. package/packages/motion/src/impact.ts +5 -2
  31. package/packages/motion/src/moves/dress.ts +15 -2
  32. package/packages/motion/src/moves/menu.ts +11 -5
  33. package/packages/motion/src/transition.ts +3 -1
  34. package/packages/motion/src/transition3d/index.ts +3 -1
  35. package/packages/motion/src/transition3d/live.ts +5 -3
  36. package/packages/patterns/src/bars/rank.ts +6 -7
  37. package/packages/patterns/src/before-after/shrink.ts +3 -4
  38. package/packages/patterns/src/before-after/strike.ts +4 -5
  39. package/packages/patterns/src/code/terminal.ts +25 -16
  40. package/packages/patterns/src/end/lockup.ts +13 -10
  41. package/packages/patterns/src/end/slam.ts +7 -5
  42. package/packages/patterns/src/hook-number/counter.ts +15 -9
  43. package/packages/patterns/src/hook-number/slam.ts +6 -8
  44. package/packages/patterns/src/kit.ts +281 -58
  45. package/packages/patterns/src/limit/gauge.ts +16 -11
  46. package/packages/patterns/src/quote/karaoke.ts +17 -12
  47. package/packages/patterns/src/sfx.ts +80 -0
  48. package/packages/patterns/src/term-reveal/chips.ts +24 -10
  49. package/packages/patterns/src/term-reveal/expand.ts +17 -13
  50. package/packages/patterns/src/title/mask.ts +9 -8
  51. package/packages/patterns/src/title/slam.ts +1 -1
  52. package/packages/patterns/src/types.ts +1 -0
  53. package/packages/plan/src/asks.ts +1 -1
  54. package/packages/plan/src/brief.ts +5 -2
  55. package/packages/plan/src/themes.ts +8 -2
  56. package/packages/themes/kit/THEME-MAKE.md +2 -2
  57. package/packages/themes/kit/fonts/display.json +3517 -0
  58. package/packages/themes/kit/fonts/sans.json +5155 -0
  59. package/packages/themes/kit/fonts/serif.json +5229 -0
  60. package/packages/themes/kit/fonts.json +243 -79
  61. package/packages/themes/package.json +3 -1
  62. package/packages/themes/src/fontfile.ts +183 -0
  63. package/packages/themes/src/fonts.ts +296 -0
  64. package/packages/themes/src/index.ts +32 -2
  65. package/packages/themes/src/look.ts +34 -8
  66. package/packages/themes/src/slots.ts +32 -2
  67. package/packages/themes/src/types.ts +33 -0
  68. package/packages/themes/src/vary.ts +35 -34
  69. package/presets/kawaii-pastel.json +2 -2
  70. package/sfx/README.md +82 -0
  71. package/sfx/click/appear.wav +0 -0
  72. package/sfx/click/burst.wav +0 -0
  73. package/sfx/click/drop.wav +0 -0
  74. package/sfx/click/end.wav +0 -0
  75. package/sfx/click/hop.wav +0 -0
  76. package/sfx/click/land.wav +0 -0
  77. package/sfx/click/select.wav +0 -0
  78. package/sfx/click/stick.wav +0 -0
  79. package/sfx/click/tick.wav +0 -0
  80. package/sfx/click/type.wav +0 -0
  81. package/sfx/click/whoosh.wav +0 -0
  82. package/sfx/pop/appear.wav +0 -0
  83. package/sfx/pop/burst.wav +0 -0
  84. package/sfx/pop/drop.wav +0 -0
  85. package/sfx/pop/end.wav +0 -0
  86. package/sfx/pop/hop.wav +0 -0
  87. package/sfx/pop/land.wav +0 -0
  88. package/sfx/pop/select.wav +0 -0
  89. package/sfx/pop/stick.wav +0 -0
  90. package/sfx/pop/tick.wav +0 -0
  91. package/sfx/pop/type.wav +0 -0
  92. package/sfx/pop/whoosh.wav +0 -0
  93. package/sfx/tags.json +545 -0
  94. package/sfx/ui/appear.wav +0 -0
  95. package/sfx/ui/burst.wav +0 -0
  96. package/sfx/ui/drop.wav +0 -0
  97. package/sfx/ui/end.wav +0 -0
  98. package/sfx/ui/hop.wav +0 -0
  99. package/sfx/ui/land.wav +0 -0
  100. package/sfx/ui/select.wav +0 -0
  101. package/sfx/ui/stick.wav +0 -0
  102. package/sfx/ui/tick.wav +0 -0
  103. package/sfx/ui/type.wav +0 -0
  104. package/sfx/ui/whoosh.wav +0 -0
  105. package/skills/kasaframe/SKILL.md +1 -0
  106. package/themes/blueprint/sample/style.json +5 -5
  107. package/themes/blueprint/style.json +5 -5
  108. package/themes/blueprint/theme.json +3 -1
  109. package/themes/blueprint/variants/deep-rose-suit.json +1 -1
  110. package/themes/blueprint/variants/navy-coral-2.json +1 -1
  111. package/themes/blueprint/variants/navy-coral-suit.json +1 -1
  112. package/themes/blueprint/variants/navy-orange-2.json +1 -1
  113. package/themes/blueprint/variants/navy-orange-suit.json +1 -1
  114. package/themes/cartography/sample/style.json +2 -2
  115. package/themes/cartography/style.json +2 -2
  116. package/themes/cartography/theme.json +3 -1
  117. package/themes/cartography/variants/deep-plum.json +1 -1
  118. package/themes/cartography/variants/frost-teal.json +1 -1
  119. package/themes/cartography/variants/navy-green.json +1 -1
  120. package/themes/circuit-board/sample/style.json +2 -2
  121. package/themes/circuit-board/style.json +2 -2
  122. package/themes/circuit-board/theme.json +3 -1
  123. package/themes/circuit-board/variants/dusk-gold-pretendard.json +1 -1
  124. package/themes/circuit-board/variants/navy-gold-grain.json +1 -1
  125. package/themes/circuit-board/variants/wine-gold-pretendard.json +1 -1
  126. package/themes/clay-toy/sample/style.json +2 -2
  127. package/themes/clay-toy/style.json +2 -2
  128. package/themes/clay-toy/theme.json +3 -1
  129. package/themes/clay-toy/variants/blush-coral-grain.json +1 -1
  130. package/themes/clay-toy/variants/chalk-teal.json +1 -1
  131. package/themes/clay-toy/variants/frost-sky.json +1 -1
  132. package/themes/clay-toy/variants/frost-teal.json +1 -1
  133. package/themes/clay-toy/variants/lilac-plum-round.json +1 -1
  134. package/themes/clay-toy/variants/lilac-plum.json +1 -1
  135. package/themes/clay-toy/variants/sage-indigo.json +1 -1
  136. package/themes/conveyor-factory/sample/style.json +3 -3
  137. package/themes/conveyor-factory/style.json +3 -3
  138. package/themes/conveyor-factory/theme.json +3 -1
  139. package/themes/conveyor-factory/variants/cocoa-gold.json +1 -1
  140. package/themes/conveyor-factory/variants/navy-gold.json +1 -1
  141. package/themes/conveyor-factory/variants/slate-amber-2.json +1 -1
  142. package/themes/conveyor-factory/variants/slate-amber-suit.json +1 -1
  143. package/themes/conveyor-factory/variants/slate-gold-bold.json +1 -1
  144. package/themes/data-journal/theme.json +3 -1
  145. package/themes/data-journal/variants/deep-coral.json +1 -1
  146. package/themes/data-journal/variants/navy-lime.json +1 -1
  147. package/themes/data-journal/variants/wine-indigo.json +1 -1
  148. package/themes/debug-hud/sample/style.json +1 -1
  149. package/themes/debug-hud/style.json +1 -1
  150. package/themes/debug-hud/theme.json +2 -1
  151. package/themes/debug-hud/variants/cocoa-teal.json +2 -2
  152. package/themes/debug-hud/variants/deep-teal-scan.json +2 -2
  153. package/themes/debug-hud/variants/dusk-plum.json +2 -2
  154. package/themes/debug-hud/variants/forest-lime.json +2 -2
  155. package/themes/debug-hud/variants/slate-green.json +2 -2
  156. package/themes/debug-hud/variants/wine-gold.json +2 -2
  157. package/themes/depth-flight/sample/style.json +1 -1
  158. package/themes/depth-flight/style.json +1 -1
  159. package/themes/depth-flight/theme.json +3 -1
  160. package/themes/depth-flight/variants/cocoa-cyan.json +1 -1
  161. package/themes/depth-flight/variants/cocoa-violet.json +1 -1
  162. package/themes/depth-flight/variants/deep-sky.json +1 -1
  163. package/themes/depth-flight/variants/forest-blue.json +1 -1
  164. package/themes/depth-flight/variants/navy-sky-grain.json +1 -1
  165. package/themes/depth-flight/variants/navy-sky.json +1 -1
  166. package/themes/depth-flight/variants/night-teal.json +1 -1
  167. package/themes/depth-flight/variants/slate-indigo.json +1 -1
  168. package/themes/depth-flight/variants/slate-violet.json +1 -1
  169. package/themes/film-reel/sample/style.json +2 -2
  170. package/themes/film-reel/style.json +2 -2
  171. package/themes/film-reel/theme.json +3 -1
  172. package/themes/film-reel/variants/chalk-pink.json +1 -1
  173. package/themes/film-reel/variants/lilac-gold.json +1 -1
  174. package/themes/film-reel/variants/lilac-violet.json +1 -1
  175. package/themes/glass-light/sample/style.json +1 -1
  176. package/themes/glass-light/style.json +1 -1
  177. package/themes/glass-light/theme.json +3 -1
  178. package/themes/glass-light/variants/blush-pink.json +1 -1
  179. package/themes/glass-light/variants/blush-plum.json +1 -1
  180. package/themes/glass-light/variants/cream-violet.json +1 -1
  181. package/themes/glass-light/variants/frost-sky-suit.json +1 -1
  182. package/themes/glass-light/variants/frost-sky.json +1 -1
  183. package/themes/glass-light/variants/sage-green.json +1 -1
  184. package/themes/ink-wash/sample/style.json +3 -3
  185. package/themes/ink-wash/style.json +3 -3
  186. package/themes/ink-wash/theme.json +3 -1
  187. package/themes/iso-diorama/sample/style.json +1 -1
  188. package/themes/iso-diorama/style.json +1 -1
  189. package/themes/iso-diorama/theme.json +3 -1
  190. package/themes/iso-diorama/variants/chalk-plum.json +1 -1
  191. package/themes/iso-diorama/variants/cocoa-indigo.json +1 -1
  192. package/themes/iso-diorama/variants/dusk-blue.json +1 -1
  193. package/themes/iso-diorama/variants/dusk-coral.json +1 -1
  194. package/themes/iso-diorama/variants/dusk-jade.json +1 -1
  195. package/themes/iso-diorama/variants/forest-cyan.json +1 -1
  196. package/themes/iso-diorama/variants/frost-green.json +1 -1
  197. package/themes/iso-diorama/variants/frost-teal.json +1 -1
  198. package/themes/iso-diorama/variants/navy-amber.json +1 -1
  199. package/themes/isotype/sample/style.json +2 -2
  200. package/themes/isotype/style.json +2 -2
  201. package/themes/isotype/theme.json +2 -1
  202. package/themes/isotype/variants/blush-rose.json +1 -1
  203. package/themes/isotype/variants/chalk-coral.json +1 -1
  204. package/themes/isotype/variants/chalk-orange.json +1 -1
  205. package/themes/isotype/variants/frost-amber.json +1 -1
  206. package/themes/isotype/variants/frost-coral.json +1 -1
  207. package/themes/isotype/variants/frost-orange.json +1 -1
  208. package/themes/isotype/variants/sand-amber.json +1 -1
  209. package/themes/kawaii-sticker/effects.ts +15 -3
  210. package/themes/kawaii-sticker/sample/style.json +2 -2
  211. package/themes/kawaii-sticker/style.json +2 -2
  212. package/themes/kawaii-sticker/theme.json +4 -1
  213. package/themes/kawaii-sticker/variants/cream-gold.json +1 -1
  214. package/themes/kawaii-sticker/variants/dusk-green.json +1 -1
  215. package/themes/kawaii-sticker/variants/dusk-indigo.json +1 -1
  216. package/themes/kawaii-sticker/variants/dusk-sky.json +1 -1
  217. package/themes/kawaii-sticker/variants/dusk-teal.json +1 -1
  218. package/themes/kawaii-sticker/variants/frost-blue.json +1 -1
  219. package/themes/kawaii-sticker/variants/frost-plum.json +1 -1
  220. package/themes/kawaii-sticker/variants/lilac-violet.json +1 -1
  221. package/themes/kawaii-sticker/variants/navy-gold.json +1 -1
  222. package/themes/kawaii-sticker/variants/navy-plum.json +1 -1
  223. package/themes/lab-notebook/sample/style.json +3 -3
  224. package/themes/lab-notebook/style.json +3 -3
  225. package/themes/lab-notebook/theme.json +2 -1
  226. package/themes/lab-notebook/variants/blush-orange.json +1 -1
  227. package/themes/lab-notebook/variants/chalk-coral.json +1 -1
  228. package/themes/lab-notebook/variants/chalk-orange.json +1 -1
  229. package/themes/lab-notebook/variants/chalk-rose.json +1 -1
  230. package/themes/lab-notebook/variants/lilac-rose.json +1 -1
  231. package/themes/lab-notebook/variants/sage-rose.json +1 -1
  232. package/themes/lab-notebook/variants/sand-orange.json +1 -1
  233. package/themes/lab-notebook/variants/snow-orange.json +1 -1
  234. package/themes/lab-notebook/variants/snow-rose.json +1 -1
  235. package/themes/manga-panel/sample/style.json +3 -3
  236. package/themes/manga-panel/style.json +3 -3
  237. package/themes/manga-panel/theme.json +3 -1
  238. package/themes/manga-panel/variants/chalk-cyan-dots.json +1 -1
  239. package/themes/manga-panel/variants/chalk-rose.json +1 -1
  240. package/themes/manga-panel/variants/snow-cyan.json +1 -1
  241. package/themes/manga-panel/variants/snow-magenta-dots.json +1 -1
  242. package/themes/marble-run/sample/style.json +2 -2
  243. package/themes/marble-run/style.json +2 -2
  244. package/themes/marble-run/theme.json +3 -1
  245. package/themes/marble-run/variants/slate-orange.json +1 -1
  246. package/themes/neon-sign/sample/style.json +2 -2
  247. package/themes/neon-sign/style.json +2 -2
  248. package/themes/neon-sign/theme.json +2 -1
  249. package/themes/neon-sign/variants/dusk-plum.json +1 -1
  250. package/themes/neon-sign/variants/forest-magenta.json +1 -1
  251. package/themes/neon-sign/variants/night-pink.json +1 -1
  252. package/themes/neon-sign/variants/night-violet.json +1 -1
  253. package/themes/neon-sign/variants/slate-violet.json +1 -1
  254. package/themes/newspaper/sample/style.json +2 -2
  255. package/themes/newspaper/style.json +2 -2
  256. package/themes/newspaper/theme.json +2 -1
  257. package/themes/newspaper/variants/blush-amber.json +1 -1
  258. package/themes/newspaper/variants/chalk-magenta.json +1 -1
  259. package/themes/newspaper/variants/frost-sky.json +1 -1
  260. package/themes/newspaper/variants/lilac-green.json +1 -1
  261. package/themes/newspaper/variants/sage-lime.json +1 -1
  262. package/themes/og-measure/THEME.md +41 -6
  263. package/themes/og-measure/effects.ts +236 -115
  264. package/themes/og-measure/sample/assets/brand/og-logo.svg +11 -11
  265. package/themes/og-measure/sample/lib/data.ts +6 -0
  266. package/themes/og-measure/sample/scenes/end.ts +14 -10
  267. package/themes/og-measure/sample/scenes/gather.ts +4 -1
  268. package/themes/og-measure/sample/scenes/models.ts +5 -2
  269. package/themes/og-measure/sample/scenes/one.ts +9 -6
  270. package/themes/og-measure/sample/scenes/price.ts +8 -3
  271. package/themes/og-measure/sample/style.json +30 -35
  272. package/themes/og-measure/slots.ts +54 -30
  273. package/themes/og-measure/still.png +0 -0
  274. package/themes/og-measure/style.json +30 -35
  275. package/themes/og-measure/theme.json +72 -49
  276. package/themes/og-measure/variants/snow-blue.json +90 -0
  277. package/themes/orbit-system/sample/style.json +3 -3
  278. package/themes/orbit-system/style.json +3 -3
  279. package/themes/orbit-system/theme.json +3 -1
  280. package/themes/orbit-system/variants/cocoa-amber.json +1 -1
  281. package/themes/orbit-system/variants/cocoa-cyan.json +1 -1
  282. package/themes/orbit-system/variants/dusk-magenta.json +1 -1
  283. package/themes/orbit-system/variants/dusk-violet.json +1 -1
  284. package/themes/orbit-system/variants/forest-coral.json +1 -1
  285. package/themes/orbit-system/variants/wine-amber.json +1 -1
  286. package/themes/orbit-system/variants/wine-magenta.json +1 -1
  287. package/themes/origami-fold/sample/style.json +1 -1
  288. package/themes/origami-fold/style.json +1 -1
  289. package/themes/origami-fold/theme.json +3 -1
  290. package/themes/origami-fold/variants/deep-amber-sunflower.json +1 -1
  291. package/themes/origami-fold/variants/deep-orange.json +1 -1
  292. package/themes/origami-fold/variants/forest-coral.json +1 -1
  293. package/themes/origami-fold/variants/forest-pink.json +1 -1
  294. package/themes/origami-fold/variants/frost-amber-paper.json +1 -1
  295. package/themes/origami-fold/variants/sage-coral-sunflower.json +1 -1
  296. package/themes/origami-fold/variants/sage-coral.json +1 -1
  297. package/themes/origami-fold/variants/sage-pink-round.json +1 -1
  298. package/themes/origami-fold/variants/sage-pink.json +1 -1
  299. package/themes/paper-cutout/sample/style.json +2 -2
  300. package/themes/paper-cutout/style.json +2 -2
  301. package/themes/paper-cutout/theme.json +3 -1
  302. package/themes/paper-cutout/variants/blush-violet.json +1 -1
  303. package/themes/paper-cutout/variants/frost-lime.json +1 -1
  304. package/themes/paper-cutout/variants/lilac-gold.json +1 -1
  305. package/themes/paper-cutout/variants/sage-cyan.json +1 -1
  306. package/themes/paper-cutout/variants/sage-green.json +1 -1
  307. package/themes/paper-cutout/variants/sage-pink.json +1 -1
  308. package/themes/paper-cutout/variants/sand-blue.json +1 -1
  309. package/themes/pixel-quest/theme.json +2 -1
  310. package/themes/receipt/sample/style.json +2 -2
  311. package/themes/receipt/style.json +2 -2
  312. package/themes/riso-zine/sample/style.json +2 -2
  313. package/themes/riso-zine/style.json +2 -2
  314. package/themes/riso-zine/theme.json +3 -1
  315. package/themes/riso-zine/variants/chalk-lime.json +1 -1
  316. package/themes/riso-zine/variants/frost-green.json +1 -1
  317. package/themes/riso-zine/variants/lilac-plum.json +1 -1
  318. package/themes/riso-zine/variants/lilac-violet.json +1 -1
  319. package/themes/server-hall/sample/style.json +2 -2
  320. package/themes/server-hall/style.json +2 -2
  321. package/themes/server-hall/theme.json +3 -1
  322. package/themes/server-hall/variants/night-indigo.json +1 -1
  323. package/themes/server-hall/variants/wine-plum.json +1 -1
  324. package/themes/subway-map/sample/style.json +2 -2
  325. package/themes/subway-map/style.json +2 -2
  326. package/themes/subway-map/theme.json +3 -1
  327. package/themes/subway-map/variants/deep-green.json +1 -1
  328. package/themes/subway-map/variants/deep-teal.json +1 -1
  329. package/themes/subway-map/variants/frost-jade.json +1 -1
  330. package/themes/subway-map/variants/frost-teal.json +1 -1
  331. package/themes/subway-map/variants/slate-green.json +1 -1
  332. package/themes/subway-map/variants/snow-jade.json +1 -1
  333. package/themes/swiss-poster/sample/style.json +2 -2
  334. package/themes/swiss-poster/style.json +2 -2
  335. package/themes/swiss-poster/theme.json +3 -1
  336. package/themes/swiss-poster/variants/chalk-plum.json +1 -1
  337. package/themes/swiss-poster/variants/cocoa-cyan.json +1 -1
  338. package/themes/swiss-poster/variants/dusk-magenta.json +1 -1
  339. package/themes/swiss-poster/variants/frost-indigo.json +1 -1
  340. package/themes/swiss-poster/variants/slate-orange.json +1 -1
  341. package/themes/water-works/sample/style.json +1 -1
  342. package/themes/water-works/style.json +1 -1
  343. package/themes/water-works/theme.json +3 -1
  344. package/themes/water-works/variants/chalk-cyan.json +1 -1
  345. package/themes/water-works/variants/frost-indigo.json +1 -1
  346. package/themes/water-works/variants/sand-amber.json +1 -1
  347. package/themes/water-works/variants/sand-plum.json +1 -1
  348. package/themes/whiteboard/sample/style.json +2 -2
  349. package/themes/whiteboard/style.json +2 -2
  350. package/themes/whiteboard/theme.json +3 -1
  351. package/themes/whiteboard/variants/chalk-coral.json +1 -1
  352. package/themes/whiteboard/variants/cream-lime.json +1 -1
  353. package/themes/whiteboard/variants/frost-teal.json +1 -1
  354. package/themes/whiteboard/variants/lilac-indigo.json +1 -1
  355. package/themes/whiteboard/variants/sage-plum.json +1 -1
  356. package/themes/whiteboard/variants/snow-cyan.json +1 -1
  357. package/themes/whiteboard/variants/snow-green.json +1 -1
  358. package/themes/whiteboard/variants/snow-magenta.json +1 -1
  359. package/themes/og-measure/sample/assets/brand/og-logo-full.svg +0 -12
  360. package/themes/og-measure/variants/chalk-orange.json +0 -102
  361. package/themes/og-measure/variants/cocoa-amber.json +0 -138
  362. package/themes/og-measure/variants/cocoa-cyan.json +0 -138
  363. package/themes/og-measure/variants/cocoa-gold.json +0 -129
  364. package/themes/og-measure/variants/cream-lime.json +0 -137
  365. package/themes/og-measure/variants/deep-jade.json +0 -100
  366. package/themes/og-measure/variants/dusk-violet.json +0 -133
  367. package/themes/og-measure/variants/forest-cyan.json +0 -123
  368. package/themes/og-measure/variants/frost-amber.json +0 -129
  369. package/themes/og-measure/variants/frost-cyan.json +0 -133
  370. package/themes/og-measure/variants/lilac-plum.json +0 -127
  371. package/themes/og-measure/variants/lilac-violet.json +0 -139
  372. package/themes/og-measure/variants/sage-green.json +0 -137
  373. package/themes/og-measure/variants/wine-jade.json +0 -127
  374. package/themes/og-measure/variants/wine-plum.json +0 -127
@@ -0,0 +1,429 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { SFX_ROLES, type SfxRole } from "@kasaframe/core";
5
+ import { loadTheme } from "@kasaframe/themes";
6
+
7
+ // 효과음 — 장면 움직임이 남긴 소리 자리(__kf.events 의 sfx)를 뜻 있는 것만 적게 고르고, 테마가 고른 결의 파일을 곡 둘레 크기에 맞춰 깔고
8
+ // 그 순간 곡을 짧게 눌러 자리를 낸다. 같은 자리·같은 설정이면 같은 소리 — 고르기·음높이 흔들기는 시각과 역할의 해시로만 정한다
9
+
10
+ const SR = 48000;
11
+
12
+ export interface SfxSettings {
13
+ /** 소리 결 — sfx/tags.json 의 sets */
14
+ set: string;
15
+ /** 둘레 곡(소리 자리 앞뒤 0.2초) 크기 대비 dB — 0 이면 곡과 비슷하게 */
16
+ db: number;
17
+ /** 1초에 몇 개까지(2초 창으로 센다 — 0.5 면 2초에 하나) */
18
+ perSec: number;
19
+ only: SfxRole[] | null;
20
+ /** 목소리가 있으면 더 낮춘다 */
21
+ voiceDb: number;
22
+ /** 잔소리(낱낱이 나타남·숫자 째깍·타자)도 깐다 — 기본은 뜻 있는 자리만 */
23
+ chatter: boolean;
24
+ /** 소리마다 곡을 이만큼(dB) 짧게 눌러 자리를 낸다 */
25
+ duck: number;
26
+ }
27
+
28
+ const DEFAULT: SfxSettings = { set: "ui", db: -2, perSec: 1, only: null, voiceDb: -4, chatter: false, duck: 3 };
29
+
30
+ // 역할마다 결 크기 위아래(db)·밀도가 넘칠 때 남기는 차례(prio 높은 것부터)·저역을 걷는 곳(Hz — 킥·베이스와 안 겹치게, 드롭 쿵만 낮게 둔다)
31
+ export const ROLE: Record<SfxRole, { db: number; prio: number; cut: number }> = {
32
+ drop: { db: 2, prio: 10, cut: 60 },
33
+ end: { db: 1, prio: 9, cut: 250 },
34
+ whoosh: { db: -1, prio: 8, cut: 250 },
35
+ land: { db: 0, prio: 7, cut: 150 },
36
+ stick: { db: 0, prio: 6, cut: 200 },
37
+ burst: { db: 0, prio: 6, cut: 250 },
38
+ hop: { db: -1, prio: 5, cut: 200 },
39
+ select: { db: -1, prio: 4, cut: 250 },
40
+ appear: { db: -2, prio: 3, cut: 250 },
41
+ tick: { db: -4, prio: 2, cut: 300 },
42
+ type: { db: -4, prio: 1, cut: 300 },
43
+ };
44
+
45
+ /** 같은 역할은 이 안에 하나 */
46
+ const SAME_ROLE = 0.1;
47
+ /** 아무 소리끼리 이만큼 띄운다 — 붙어 나면 하나로 뭉개진다 */
48
+ const MIN_GAP = 0.25;
49
+ /** 드롭 앞뒤 이 안은 드롭만 */
50
+ const DROP_CLEAR = 0.35;
51
+ /** 밀도를 세는 창(초) */
52
+ const WINDOW = 2;
53
+ /** 가려짐 목표 — 소리 대역에서 소리 순간에 효과음이 곡보다 이만큼(dB) 위. 모자라면 BOOST 까지 더 키운다 */
54
+ const SMR_TARGET = 4;
55
+ const BOOST = 6;
56
+ /**
57
+ * 소리가 차지하는 대역 후보(Hz) — 소리마다 제 가장 센 대역에서 10dB 안쪽인 대역들(그 소리가 실제로 차지하는 곳)을 잡고, 그중 곡을 가장 넘는 대역에서
58
+ * 가려짐을 잰다(귀는 소리가 곡을 넘는 대역에서 그 소리를 듣는다). 낮은 몸에 2–6kHz 어택을 얹은 소리는 어택 대역이 잡힌다
59
+ */
60
+ export const BANDS: readonly [number, number][] = [
61
+ [40, 150],
62
+ [150, 600],
63
+ [600, 2000],
64
+ [2000, 6000],
65
+ [6000, 12000],
66
+ ];
67
+
68
+ export const sfxDir = (): string => process.env.KF_SFX_DIR || fileURLToPath(new URL("../../../sfx/", import.meta.url));
69
+
70
+ interface BankFile {
71
+ path: string;
72
+ lead: number;
73
+ level: number;
74
+ }
75
+
76
+ export interface SfxBank {
77
+ dir: string;
78
+ sets: string[];
79
+ file(set: string, role: SfxRole): BankFile | null;
80
+ }
81
+
82
+ export function loadBank(dir = sfxDir()): SfxBank {
83
+ const tags = JSON.parse(readFileSync(join(dir, "tags.json"), "utf8")) as { sets: Record<string, unknown>; files: Record<string, { set: string; role: string; lead: number; level: number }> };
84
+ const by = new Map(Object.entries(tags.files).map(([rel, f]) => [`${f.set}/${f.role}`, { path: join(dir, rel), lead: f.lead, level: f.level }]));
85
+ return { dir, sets: Object.keys(tags.sets), file: (set, role) => by.get(`${set}/${role}`) ?? null };
86
+ }
87
+
88
+ const isRole = (r: unknown): r is SfxRole => (SFX_ROLES as readonly unknown[]).includes(r);
89
+
90
+ /**
91
+ * 이 프로젝트의 효과음 설정 — 기본 ← theme.json sfx ← kf.json sfx. 끄면 null.
92
+ * flag 는 kf render|make --sfx: off 면 끈다, on 이면 kf.json sfx: false 도 이 판만 켠다
93
+ */
94
+ export function sfxSettings(dir: string, flag?: string): SfxSettings | null {
95
+ if (flag === "off") return null;
96
+ type Loose = Partial<Omit<SfxSettings, "only">> & { only?: string[] | null };
97
+ const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8")) as { theme?: string; sfx?: false | Loose };
98
+ if (kf.sfx === false && flag !== "on") return null;
99
+ let theme: Loose = {};
100
+ if (kf.theme) {
101
+ try {
102
+ theme = loadTheme(kf.theme).sfx ?? {};
103
+ } catch {}
104
+ }
105
+ const over = kf.sfx && typeof kf.sfx === "object" ? kf.sfx : {};
106
+ const s: Loose & Omit<SfxSettings, "only"> = { ...DEFAULT, ...theme, ...over };
107
+ const only = s.only ? s.only.filter(isRole) : null;
108
+ return {
109
+ set: s.set,
110
+ db: Number(s.db),
111
+ perSec: Math.max(0.1, Number(s.perSec)),
112
+ only: only?.length ? only : null,
113
+ voiceDb: Number(s.voiceDb),
114
+ chatter: !!s.chatter,
115
+ duck: Math.max(0, Number(s.duck)),
116
+ };
117
+ }
118
+
119
+ export interface SfxCue {
120
+ t: number;
121
+ role: SfxRole;
122
+ source: string;
123
+ weight: number;
124
+ /** 잔소리 — chatter 가 아니면 안 깐다 */
125
+ minor?: boolean;
126
+ }
127
+
128
+ /** 페이지가 남긴 기록에서 소리 자리를, 곡의 드롭(beats.json — 영상 0초 기준)은 드롭 쿵으로 */
129
+ export function cuesOf(events: readonly { t: number; kind: string; source: string; name?: string; value?: number; minor?: boolean }[], drops: readonly number[] = []): SfxCue[] {
130
+ const out: SfxCue[] = [];
131
+ for (const e of events)
132
+ if (e.kind === "sfx" && isRole(e.name)) out.push({ t: e.t, role: e.name, source: e.source, weight: Math.min(1, Math.max(0.05, e.value ?? 1)), ...(e.minor ? { minor: true } : {}) });
133
+ for (const d of drops) out.push({ t: Math.round(d * 1000) / 1000, role: "drop", source: "beats", weight: 1 });
134
+ return out;
135
+ }
136
+
137
+ export function projectDrops(dir: string): number[] {
138
+ try {
139
+ const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8")) as { beats?: string };
140
+ if (!kf.beats) return [];
141
+ return (JSON.parse(readFileSync(resolve(dir, kf.beats), "utf8")) as { drops?: number[] }).drops ?? [];
142
+ } catch {
143
+ return [];
144
+ }
145
+ }
146
+
147
+ export interface SfxHit {
148
+ t: number;
149
+ role: SfxRole;
150
+ /** 결 안 역할 파일 — 섞을 때 bank 에서 찾는다 */
151
+ set: string;
152
+ /** 역할 크기 + 무게 + 흔들기(dB) */
153
+ db: number;
154
+ /** 음높이 배수 — 같은 소리가 줄줄이 나도 기계음처럼 안 들리게 */
155
+ rate: number;
156
+ }
157
+
158
+ // 시각·역할 → 0..1 두 개(fnv1a). 같은 자리면 어느 기계에서나 같은 흔들기
159
+ function hash2(key: string): [number, number] {
160
+ let h = 0x811c9dc5;
161
+ for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 0x01000193) >>> 0;
162
+ const a = (h & 0xffff) / 0x10000;
163
+ const b = (h >>> 16) / 0x10000;
164
+ return [a, b];
165
+ }
166
+
167
+ /**
168
+ * 소리 자리를 깔 소리로 — 잔소리(chatter 가 아니면)·결에 없는 역할·only 밖 역할은 빼고, 같은 역할은 SAME_ROLE 안에 하나, 차례(ROLE.prio)대로
169
+ * 드롭 앞뒤를 비우고 MIN_GAP 을 띄우고 아무 2초 창에 perSec×2 개(적어도 하나)까지 남긴다. 결과는 시각 차례
170
+ */
171
+ export function placeSfx(cues: readonly SfxCue[], s: SfxSettings, bank: Pick<SfxBank, "file">, o: { from: number; to: number }): SfxHit[] {
172
+ const ok = cues
173
+ .filter((c) => c.t >= o.from - 1e-9 && c.t < o.to - 1e-9 && (s.chatter || !c.minor) && (!s.only || s.only.includes(c.role)) && bank.file(s.set, c.role))
174
+ .sort((a, b) => a.t - b.t || ROLE[b.role].prio - ROLE[a.role].prio || (a.source < b.source ? -1 : a.source > b.source ? 1 : 0));
175
+ const merged: SfxCue[] = [];
176
+ const last = new Map<SfxRole, SfxCue>();
177
+ for (const c of ok) {
178
+ const prev = last.get(c.role);
179
+ if (prev && c.t - prev.t < SAME_ROLE) {
180
+ prev.weight = Math.max(prev.weight, c.weight);
181
+ continue;
182
+ }
183
+ const m = { ...c };
184
+ merged.push(m);
185
+ last.set(c.role, m);
186
+ }
187
+ const order = [...merged].sort((a, b) => ROLE[b.role].prio - ROLE[a.role].prio || a.t - b.t);
188
+ const kept: SfxCue[] = [];
189
+ const most = Math.max(1, Math.round(s.perSec * WINDOW));
190
+ // t 를 품은 어느 창 [w, w+WINDOW) 이 most 를 넘나 — 가장 붐비는 창은 t 앞 WINDOW 안의 소리(t 포함)에서 시작한다
191
+ const crowded = (t: number) => {
192
+ const xs = [...kept.map((k) => k.t), t];
193
+ return xs.filter((w) => w <= t && w > t - WINDOW).some((w) => xs.filter((x) => x >= w && x < w + WINDOW).length > most);
194
+ };
195
+ for (const c of order) {
196
+ if (kept.some((k) => Math.abs(k.t - c.t) < MIN_GAP)) continue;
197
+ if (c.role !== "drop" && kept.some((k) => k.role === "drop" && Math.abs(k.t - c.t) < DROP_CLEAR)) continue;
198
+ if (crowded(c.t)) continue;
199
+ kept.push(c);
200
+ }
201
+ return kept
202
+ .sort((a, b) => a.t - b.t)
203
+ .map((c) => {
204
+ const [h1, h2] = hash2(`${c.role}|${c.t.toFixed(3)}`);
205
+ return { t: c.t, role: c.role, set: s.set, db: Math.round((ROLE[c.role].db + 20 * Math.log10(c.weight) + (h1 - 0.5) * 2) * 100) / 100, rate: Math.round((1 + (h2 - 0.5) * 0.06) * 10000) / 10000 };
206
+ });
207
+ }
208
+
209
+ // --- 소리 다루기: 2차 버터워스 거르개(RBJ) — 같은 입력이면 같은 출력
210
+
211
+ type Biquad = [number, number, number, number, number];
212
+
213
+ function biquad(kind: "lp" | "hp", f: number): Biquad {
214
+ const w = (2 * Math.PI * f) / SR;
215
+ const c = Math.cos(w);
216
+ const a = Math.sin(w) / Math.SQRT2;
217
+ const b0 = kind === "lp" ? (1 - c) / 2 : (1 + c) / 2;
218
+ const b1 = kind === "lp" ? 1 - c : -(1 + c);
219
+ const a0 = 1 + a;
220
+ return [b0 / a0, b1 / a0, b0 / a0, (-2 * c) / a0, (1 - a) / a0];
221
+ }
222
+
223
+ function filter(x: Float32Array, q: Biquad): Float32Array {
224
+ const [b0, b1, b2, a1, a2] = q;
225
+ const y = new Float32Array(x.length);
226
+ let x1 = 0;
227
+ let x2 = 0;
228
+ let y1 = 0;
229
+ let y2 = 0;
230
+ for (let i = 0; i < x.length; i++) {
231
+ const v = b0 * x[i] + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
232
+ x2 = x1;
233
+ x1 = x[i];
234
+ y2 = y1;
235
+ y1 = v;
236
+ y[i] = v;
237
+ }
238
+ return y;
239
+ }
240
+
241
+ /** 대역 [lo, hi] 만 — 높은·낮은 거르개를 두 번씩(4차) */
242
+ function band(x: Float32Array, [lo, hi]: readonly [number, number]): Float32Array {
243
+ const h = biquad("hp", lo);
244
+ const l = biquad("lp", Math.min(hi, SR * 0.45));
245
+ return filter(filter(filter(filter(x, h), h), l), l);
246
+ }
247
+
248
+ // [a, b) 창 평균 에너지 — 창이 소리 끝·앞을 넘으면 넘은 자리는 0 으로 친다(잘린 길이로 나누면 짧은 소리가 5dB 넘게 부풀었다)
249
+ const energy = (x: Float32Array, a: number, b: number): number => {
250
+ let e = 0;
251
+ for (let i = Math.max(0, a); i < Math.min(x.length, b); i++) e += x[i] * x[i];
252
+ return b > a ? e / (b - a) : 0;
253
+ };
254
+ const db = (e: number): number => 10 * Math.log10(e + 1e-15);
255
+
256
+ // 100ms 창 RMS 최댓값(dBFS) — tools/sfx/build.ts 의 level 과 같은 잣대
257
+ function peak100(y: Float32Array): number {
258
+ const win = Math.round(SR * 0.1);
259
+ let best = 0;
260
+ for (let i = 0; i < Math.max(1, y.length - win + 1); i += Math.round(SR * 0.01)) best = Math.max(best, energy(y, i, i + win));
261
+ return db(best);
262
+ }
263
+
264
+ // sfx/ 의 16비트 모노 wav — tools/sfx/build.ts 가 쓴 꼴만
265
+ const raw = new Map<string, Float32Array>();
266
+ function samples(path: string): Float32Array {
267
+ const hit = raw.get(path);
268
+ if (hit) return hit;
269
+ const b = readFileSync(path);
270
+ let p = 12;
271
+ let data: Buffer | null = null;
272
+ let bits = 16;
273
+ let channels = 1;
274
+ while (p + 8 <= b.length) {
275
+ const id = b.toString("ascii", p, p + 4);
276
+ const size = b.readUInt32LE(p + 4);
277
+ if (id === "fmt ") {
278
+ channels = b.readUInt16LE(p + 10);
279
+ bits = b.readUInt16LE(p + 22);
280
+ } else if (id === "data") data = b.subarray(p + 8, p + 8 + size);
281
+ p += 8 + size + (size & 1);
282
+ }
283
+ if (!data || bits !== 16 || channels !== 1) throw new Error(`효과음 파일 꼴이 아니다(16비트 모노 wav): ${path}`);
284
+ const y = new Float32Array(data.length / 2);
285
+ for (let i = 0; i < y.length; i++) y[i] = data.readInt16LE(i * 2) / 32768;
286
+ raw.set(path, y);
287
+ return y;
288
+ }
289
+
290
+ interface Voiced {
291
+ y: Float32Array;
292
+ /** 파일 시작 → 꼭대기(샘플) */
293
+ lead: number;
294
+ level: number;
295
+ /** 소리가 차지하는 대역들과 그 대역으로 거른 소리 */
296
+ bands: { band: readonly [number, number]; y: Float32Array }[];
297
+ }
298
+
299
+ // 음높이를 옮기고(선형 보간) 저역을 걷은 소리 — 크기·대역은 걷은 뒤에 잰다
300
+ const voiced = new Map<string, Voiced>();
301
+ function voice(f: BankFile, rate: number, cut: number): Voiced {
302
+ const key = `${f.path}|${rate}|${cut}`;
303
+ const hit = voiced.get(key);
304
+ if (hit) return hit;
305
+ const src = samples(f.path);
306
+ const len = Math.floor((src.length - 1) / rate);
307
+ const moved = new Float32Array(len);
308
+ for (let i = 0; i < len; i++) {
309
+ const x = i * rate;
310
+ const k = Math.floor(x);
311
+ moved[i] = src[k] + (src[k + 1] - src[k]) * (x - k);
312
+ }
313
+ const hp = biquad("hp", cut);
314
+ const y = filter(filter(moved, hp), hp);
315
+ const split = BANDS.map((b) => ({ band: b, y: band(y, b) }));
316
+ const loud = split.map((x) => db(energy(x.y, 0, x.y.length)));
317
+ const top = Math.max(...loud);
318
+ const v = { y, lead: Math.round((f.lead / rate) * SR), level: peak100(y), bands: split.filter((_, i) => loud[i] >= top - 10) };
319
+ voiced.set(key, v);
320
+ return v;
321
+ }
322
+
323
+ export interface SfxPlaced extends SfxHit {
324
+ /** 둘레 곡 크기(앞뒤 0.2초 RMS, dBFS) */
325
+ local: number;
326
+ /** 소리 크기(100ms 창, dBFS) − 둘레 곡 */
327
+ over: number;
328
+ /** 가려짐을 잰 대역(Hz) */
329
+ band: readonly [number, number];
330
+ /** 그 대역에서 소리 순간(꼭대기 앞 10ms·뒤 90ms) 효과음 ÷ 눌린 곡(dB) */
331
+ smr: number;
332
+ /** 가려져서 더 키운 dB */
333
+ boost: number;
334
+ }
335
+
336
+ /**
337
+ * 곡(·목소리)을 섞은 판 bed(스테레오, 영상 from 초가 0)에 효과음을 깐다 — 소리마다
338
+ * ① 크기: 둘레 곡 크기 + s.db + 역할 크기(목소리가 있으면 voiceDb 더) ② 그 순간 곡을 s.duck dB 짧게(소리 시작 10ms 앞부터 꼭대기 100ms 뒤까지) 눌러 자리를 내고
339
+ * ③ 소리 대역에서 효과음이 눌린 곡보다 SMR_TARGET 위가 안 되면 BOOST 까지 더 키운다. bed 를 바꿔 쓰고, 깐 소리와 잰 값을 돌려준다
340
+ */
341
+ export function laySfx(bed: [Float32Array, Float32Array], hits: readonly SfxHit[], bank: Pick<SfxBank, "file">, s: SfxSettings, o: { from: number; voice: boolean }): { stem: Float32Array; placed: SfxPlaced[] } {
342
+ const [L, R] = bed;
343
+ const n = L.length;
344
+ const mono = new Float32Array(n);
345
+ for (let i = 0; i < n; i++) mono[i] = (L[i] + R[i]) / 2;
346
+ // 곡이 비었거나 아주 조용한 곳은 판 전체(무음 뺀 400ms 창 평균)에서 12dB 아래를 바닥으로
347
+ const blocks: number[] = [];
348
+ for (let i = 0; i + 0.4 * SR <= n; i += 0.1 * SR) {
349
+ const e = energy(mono, i, i + 0.4 * SR);
350
+ if (e > 1e-7) blocks.push(e);
351
+ }
352
+ const floor = blocks.length ? db(blocks.reduce((x, y) => x + y, 0) / blocks.length) - 12 : -32;
353
+ const at = (t: number) => Math.round((t - o.from) * SR);
354
+ const items = hits.flatMap((h) => {
355
+ const f = bank.file(h.set, h.role);
356
+ if (!f) return [];
357
+ const v = voice(f, h.rate, ROLE[h.role].cut);
358
+ const peak = at(h.t);
359
+ return [{ h, v, peak, start: peak - v.lead, local: Math.max(floor, db(energy(mono, peak - 0.2 * SR, peak + 0.2 * SR))) }];
360
+ });
361
+ // 누르기 — 소리마다 작은 골짜기, 겹치면 깊은 쪽
362
+ const env = new Float32Array(n).fill(1);
363
+ const dip = 10 ** (-s.duck / 20);
364
+ const rampIn = Math.round(0.01 * SR);
365
+ const rampOut = Math.round(0.04 * SR);
366
+ for (const it of items) {
367
+ const a = it.start - rampIn;
368
+ const b = it.peak + Math.round(0.1 * SR);
369
+ for (let i = Math.max(0, a - rampIn); i < Math.min(n, b + rampOut); i++) {
370
+ const k = i < a ? (i - (a - rampIn)) / rampIn : i > b ? 1 - (i - b) / rampOut : 1;
371
+ env[i] = Math.min(env[i], 1 - (1 - dip) * Math.max(0, Math.min(1, k)));
372
+ }
373
+ }
374
+ const ducked = new Float32Array(n);
375
+ for (let i = 0; i < n; i++) ducked[i] = mono[i] * env[i];
376
+ const stem = new Float32Array(n);
377
+ const placed: SfxPlaced[] = [];
378
+ const pre = Math.round(0.2 * SR);
379
+ for (const it of items) {
380
+ const { h, v } = it;
381
+ const target = it.local + s.db + h.db + (o.voice ? s.voiceDb : 0);
382
+ let gain = target - v.level;
383
+ // 가려짐 — 소리 꼭대기 앞 10ms·뒤 90ms 를 그 소리 대역마다. 곡 쪽은 거르개가 자리 잡게 앞 0.2초부터 걸러 뒤만 잰다
384
+ const a = it.peak - Math.round(0.01 * SR);
385
+ const b = it.peak + Math.round(0.09 * SR);
386
+ const ref = ducked.subarray(Math.max(0, a - pre), Math.min(n, b));
387
+ const per = v.bands.map((x) => ({
388
+ band: x.band,
389
+ smr: db(energy(x.y, v.lead - Math.round(0.01 * SR), v.lead + Math.round(0.09 * SR)) * 10 ** (gain / 10)) - db(energy(band(ref, x.band), a - Math.max(0, a - pre), ref.length)),
390
+ }));
391
+ const best = per.reduce((p, q) => (q.smr > p.smr ? q : p));
392
+ const smr0 = best.smr;
393
+ const boost = Math.min(BOOST, Math.max(0, SMR_TARGET - smr0));
394
+ gain += boost;
395
+ const k = 10 ** (gain / 20);
396
+ for (let i = Math.max(0, -it.start); i < v.y.length && it.start + i < n; i++) stem[it.start + i] += k * v.y[i];
397
+ const r = (x: number) => Math.round(x * 10) / 10;
398
+ placed.push({ ...h, local: r(it.local), over: r(v.level + gain - it.local), band: best.band, smr: r(smr0 + boost), boost: r(boost) });
399
+ }
400
+ for (let i = 0; i < n; i++) {
401
+ L[i] = L[i] * env[i] + stem[i];
402
+ R[i] = R[i] * env[i] + stem[i];
403
+ }
404
+ return { stem, placed };
405
+ }
406
+
407
+ /** 32비트 실수 wav — 채널을 받은 차례로 엇갈려 쓴다 */
408
+ export function writeFloatWav(path: string, channels: Float32Array[]): string {
409
+ const c = channels.length;
410
+ const n = channels[0].length;
411
+ const body = new Float32Array(n * c);
412
+ for (let i = 0; i < n; i++) for (let k = 0; k < c; k++) body[i * c + k] = channels[k][i];
413
+ const data = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
414
+ const head = Buffer.alloc(44);
415
+ head.write("RIFF", 0);
416
+ head.writeUInt32LE(36 + data.length, 4);
417
+ head.write("WAVEfmt ", 8);
418
+ head.writeUInt32LE(16, 16);
419
+ head.writeUInt16LE(3, 20);
420
+ head.writeUInt16LE(c, 22);
421
+ head.writeUInt32LE(SR, 24);
422
+ head.writeUInt32LE(SR * 4 * c, 28);
423
+ head.writeUInt16LE(4 * c, 32);
424
+ head.writeUInt16LE(32, 34);
425
+ head.write("data", 36);
426
+ head.writeUInt32LE(data.length, 40);
427
+ writeFileSync(path, Buffer.concat([head, data]));
428
+ return path;
429
+ }
@@ -0,0 +1,250 @@
1
+ // 새 판 알림 — Codex CLI 처럼 확인은 떨어진 자식이 뒤에서 하고 결과를 캐시에 둔다. kf 는 네트워크를 기다리지 않고 지난 확인 결과로 묻거나 알린다.
2
+ // 여기는 모든 kf 명령 앞에서 불리니 무거운 것을 import 하지 않는다.
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export const PACKAGE = "kasaframe";
11
+ const root = fileURLToPath(new URL("../../../", import.meta.url));
12
+ const versionAt = (dir: string): string => JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
13
+ export const current = versionAt(root);
14
+ // 몇 시간에 한 번 — 하루에 몇 번 켜도 레지스트리는 한두 번만 본다
15
+ export const CHECK_EVERY_MS = 6 * 3600_000;
16
+
17
+ export interface UpdateCache {
18
+ latest?: string;
19
+ checkedAt?: string;
20
+ /** 「이 판은 다시 묻지 않기」로 넘긴 판 — 더 새 판이 나오면 다시 묻는다 */
21
+ dismissed?: string;
22
+ }
23
+
24
+ export const cacheFile = (): string => join(process.env.KF_CACHE_DIR ?? join(homedir(), ".cache", "kasaframe"), "update.json");
25
+
26
+ export function readCache(): UpdateCache {
27
+ try {
28
+ return JSON.parse(readFileSync(cacheFile(), "utf8"));
29
+ } catch {
30
+ return {};
31
+ }
32
+ }
33
+
34
+ // kf 여럿이 한꺼번에 써도 반쯤 쓴 파일을 읽지 않게 다른 이름에 쓰고 옮긴다
35
+ export function writeCache(c: UpdateCache): void {
36
+ const file = cacheFile();
37
+ mkdirSync(dirname(file), { recursive: true });
38
+ const tmp = `${file}.${process.pid}`;
39
+ writeFileSync(tmp, `${JSON.stringify(c)}\n`);
40
+ renameSync(tmp, file);
41
+ }
42
+
43
+ const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
44
+
45
+ /** latest 가 now 보다 새 정식 판인가 — 미리보기 판(prerelease)은 권하지 않는다 */
46
+ export function isNewer(latest: string, now: string): boolean {
47
+ const l = SEMVER.exec(latest.trim());
48
+ const c = SEMVER.exec(now.trim());
49
+ if (!l || !c || l[4]) return false;
50
+ for (let i = 1; i <= 3; i++) {
51
+ const d = Number(l[i]) - Number(c[i]);
52
+ if (d) return d > 0;
53
+ }
54
+ return !!c[4];
55
+ }
56
+
57
+ export type Manager = "npm" | "pnpm" | "bun" | "yarn";
58
+ export interface Install {
59
+ manager: Manager;
60
+ /** npm 전역 자리 — 기본 자리가 아니면 올릴 때도 거기에 깔아야 이 kf 가 바뀐다 */
61
+ prefix?: string;
62
+ }
63
+
64
+ /** 패키지가 놓인 자리로 어느 관리자의 전역 설치인가. 레포 체크아웃·npx 가 잠깐 받은 것·프로젝트 의존성은 null — 판은 그쪽이 정한다 */
65
+ export function installOf(dir: string, win = process.platform === "win32"): Install | null {
66
+ const p = dir.replace(/\\/g, "/").replace(/\/+$/, "");
67
+ if (!p.includes("/node_modules/")) return null;
68
+ if (p.includes("/_npx/")) return null;
69
+ if (p.includes("/.bun/install/global/")) return { manager: "bun" };
70
+ if (/\/pnpm\/global\//i.test(p)) return { manager: "pnpm" };
71
+ if (/\/yarn\/(data\/)?global\/node_modules\//i.test(p)) return { manager: "yarn" };
72
+ const npm = (win ? /^(.+)\/node_modules\/kasaframe$/ : /^(.+)\/lib\/node_modules\/kasaframe$/).exec(p);
73
+ return npm ? { manager: "npm", prefix: win ? npm[1].replace(/\//g, "\\") : npm[1] } : null;
74
+ }
75
+
76
+ export function updateCommand(inst: Install, version: string, npmDefaultPrefix?: string): string[] {
77
+ const spec = `${PACKAGE}@${version}`;
78
+ if (inst.manager === "pnpm") return ["pnpm", "add", "-g", spec];
79
+ if (inst.manager === "bun") return ["bun", "add", "-g", spec];
80
+ if (inst.manager === "yarn") return ["yarn", "global", "add", spec];
81
+ const custom = inst.prefix && npmDefaultPrefix !== undefined && !samePath(inst.prefix, npmDefaultPrefix);
82
+ return ["npm", "i", "-g", spec, ...(custom ? ["--prefix", inst.prefix!] : [])];
83
+ }
84
+
85
+ function samePath(a: string, b: string): boolean {
86
+ const real = (p: string) => {
87
+ try {
88
+ return realpathSync(p);
89
+ } catch {
90
+ return p;
91
+ }
92
+ };
93
+ return real(a) === real(b);
94
+ }
95
+
96
+ const shell = process.platform === "win32";
97
+
98
+ function npmPrefix(): string | undefined {
99
+ const r = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8", shell, timeout: 10_000 });
100
+ return r.status === 0 ? r.stdout.trim() : undefined;
101
+ }
102
+
103
+ /** 이 kf 를 올리는 명령 — npm 이면 기본 전역 자리와 비교해야 해서 npm 을 한 번 부른다(묻거나 올릴 때만) */
104
+ export function commandFor(version: string): string[] | null {
105
+ const inst = installOf(root);
106
+ return inst && updateCommand(inst, version, inst.manager === "npm" && inst.prefix ? npmPrefix() : undefined);
107
+ }
108
+
109
+ // 환경 변수 → npm 설정(사용자·전역·프로젝트 .npmrc) — 올리는 명령도 같은 npm 설정을 따른다
110
+ export function registry(env: NodeJS.ProcessEnv = process.env): string {
111
+ const set = env.npm_config_registry ?? env.NPM_CONFIG_REGISTRY;
112
+ if (set) return set;
113
+ const r = spawnSync("npm", ["config", "get", "registry"], { encoding: "utf8", shell, timeout: 10_000 });
114
+ const got = r.status === 0 ? r.stdout.trim() : "";
115
+ return /^https?:\/\//.test(got) ? got : "https://registry.npmjs.org/";
116
+ }
117
+
118
+ export async function fetchLatest(timeoutMs: number): Promise<string> {
119
+ const url = `${registry().replace(/\/+$/, "")}/${PACKAGE}`;
120
+ const r = await fetch(url, {
121
+ headers: { accept: "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8" },
122
+ signal: AbortSignal.timeout(timeoutMs),
123
+ });
124
+ if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
125
+ const latest = ((await r.json()) as { "dist-tags"?: { latest?: unknown } })["dist-tags"]?.latest;
126
+ if (typeof latest !== "string") throw new Error(`${url} 에 dist-tags.latest 가 없다`);
127
+ return latest;
128
+ }
129
+
130
+ /** 레지스트리를 지금 보고 캐시에 적는다 — kf update·kf doctor 와 뒤에서 도는 자식이 쓴다 */
131
+ export async function refresh(timeoutMs = 8000): Promise<string> {
132
+ const latest = await fetchLatest(timeoutMs);
133
+ writeCache({ ...readCache(), latest, checkedAt: new Date().toISOString() });
134
+ return latest;
135
+ }
136
+
137
+ const on = (v: string | undefined) => !!v && v !== "0" && v.toLowerCase() !== "false";
138
+
139
+ export interface Decision {
140
+ show: "prompt" | "notice" | "none";
141
+ refresh: boolean;
142
+ latest?: string;
143
+ }
144
+
145
+ export function decide(i: { current: string; cache: UpdateCache; install: Install | null; tty: boolean; env: NodeJS.ProcessEnv; now: number }): Decision {
146
+ if (!i.install || on(i.env.KF_NO_UPDATE_CHECK) || on(i.env.CI)) return { show: "none", refresh: false };
147
+ // 시각이 없거나 미래(시계가 돌아갔다)면 지금 본다
148
+ const age = i.now - Date.parse(i.cache.checkedAt ?? "");
149
+ const refresh = !(age >= 0 && age < CHECK_EVERY_MS);
150
+ const latest = i.cache.latest;
151
+ if (!latest || !isNewer(latest, i.current) || i.cache.dismissed === latest) return { show: "none", refresh };
152
+ return { show: i.tty ? "prompt" : "notice", refresh, latest };
153
+ }
154
+
155
+ // 뜻이 고정된 한 줄 — 에이전트가 이 꼴을 보고 kf update --yes 를 돌린다(skills/kasaframe/SKILL.md)
156
+ export const noticeLine = (latest: string, now = current): string => `[kasaframe] 새 판 ${latest} (지금 ${now}) — kf update`;
157
+
158
+ // 확인이 안 돼도(캐시 자리를 못 쓰거나 오프라인) kf 명령은 그대로 돈다
159
+ function refreshInBackground(): void {
160
+ try {
161
+ // 같이 뜬 kf 들이 저마다 확인을 띄우지 않게 시각부터 적는다 — 실패하면 다음 주기에 다시 본다
162
+ writeCache({ ...readCache(), checkedAt: new Date().toISOString() });
163
+ const code = `const m = await import(${JSON.stringify(import.meta.url)}); await m.refresh(15000).catch(() => {});`;
164
+ spawn(process.execPath, ["--input-type=module", "-e", code], { detached: true, stdio: "ignore", windowsHide: true })
165
+ .on("error", () => {})
166
+ .unref();
167
+ } catch {}
168
+ }
169
+
170
+ // 설치본 입구가 NODE_OPTIONS 에 넣은 형 지우기 훅 — 새 판으로 다시 띄울 땐 빼야 새 입구가 제 훅을 넣는다(pnpm 은 옛 판 자리를 지운다)
171
+ const hooksArg = `--import=${new URL("../../../bin/hooks.mjs", import.meta.url).href}`;
172
+
173
+ function install(cmd: string[]): { ok: boolean; why: string } {
174
+ const r = spawnSync(cmd[0], cmd.slice(1), { stdio: "inherit", shell });
175
+ if (r.error) return { ok: false, why: r.error.message };
176
+ return { ok: r.status === 0, why: r.signal ? `신호 ${r.signal}` : `종료 ${r.status}` };
177
+ }
178
+
179
+ /** 사람이 친 kf 입구(심볼릭 링크 그대로)가 이제 가리키는 판 */
180
+ function versionBehind(entry: string): string | null {
181
+ try {
182
+ return versionAt(join(dirname(realpathSync(entry)), ".."));
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ /** 올리고, 이 kf 입구가 실제로 새 판을 가리키는지까지 본다 */
189
+ export function upgrade(cmd: string[], latest: string, entry = process.argv[1]): boolean {
190
+ process.stderr.write(`\n${cmd.join(" ")}\n`);
191
+ const r = install(cmd);
192
+ if (!r.ok) {
193
+ process.stderr.write(`\n올리지 못했다(${r.why}). 직접 올리려면: ${cmd.join(" ")}\n`);
194
+ return false;
195
+ }
196
+ const now = versionBehind(entry);
197
+ if (now !== latest) {
198
+ process.stderr.write(`\n설치는 끝났는데 이 kf(${entry})는 ${now ?? "?"} 그대로다 — 다른 자리에 깔렸다. 직접: ${cmd.join(" ")}\n`);
199
+ return false;
200
+ }
201
+ return true;
202
+ }
203
+
204
+ async function ask(latest: string, cmd: string[]): Promise<"update" | "skip" | "dismiss"> {
205
+ // 묻는 동안 표준 출력이 터미널임은 확인했다 — 표준 오류가 파일로 가 있어도 보이게 표준 출력에 묻는다
206
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
207
+ rl.on("SIGINT", () => {
208
+ rl.close();
209
+ process.exit(130);
210
+ });
211
+ try {
212
+ const a = (
213
+ await rl.question(
214
+ `\n새 판이 있다 · kasaframe ${current} → ${latest}\n 1) 지금 업데이트 (${cmd.join(" ")})\n 2) 건너뛰기\n 3) 이 판은 다시 묻지 않기\n번호(엔터 = 1): `,
215
+ )
216
+ ).trim();
217
+ return a === "" || a === "1" ? "update" : a === "3" ? "dismiss" : "skip";
218
+ } catch {
219
+ return "skip";
220
+ } finally {
221
+ rl.close();
222
+ }
223
+ }
224
+
225
+ /**
226
+ * 명령 앞 관문. 터미널이면 보기 셋을 묻고, 아니면 표준 오류에 한 줄(표준 출력은 --json 을 읽는 도구가 있어 건드리지 않는다).
227
+ * 「지금 업데이트」가 되면 원래 명령을 새 판으로 다시 띄우고 그 종료 코드를, 아니면 null(지금 판으로 이어 간다)
228
+ */
229
+ export async function updateGate(argv: string[]): Promise<number | null> {
230
+ const env = process.env;
231
+ const d = decide({ current, cache: readCache(), install: installOf(root), tty: !!(process.stdin.isTTY && process.stdout.isTTY), env, now: Date.now() });
232
+ // kf 가 띄우는 kf(make → render 따위)는 다시 묻거나 찍지 않는다
233
+ env.KF_NO_UPDATE_CHECK = "1";
234
+ if (d.refresh) refreshInBackground();
235
+ if (d.show === "notice") process.stderr.write(`${noticeLine(d.latest!)}\n`);
236
+ if (d.show !== "prompt") return null;
237
+
238
+ const cmd = commandFor(d.latest!)!;
239
+ const pick = await ask(d.latest!, cmd);
240
+ if (pick === "dismiss") writeCache({ ...readCache(), dismissed: d.latest });
241
+ if (pick !== "update") return null;
242
+ if (!upgrade(cmd, d.latest!)) {
243
+ process.stderr.write(`${current} 로 이어 간다\n\n`);
244
+ return null;
245
+ }
246
+ process.stderr.write(`\nkasaframe ${d.latest} 로 올렸다 — 이어서 kf ${argv.join(" ")}\n\n`);
247
+ const nodeOptions = (env.NODE_OPTIONS ?? "").split(" ").filter((t) => t && t !== hooksArg).join(" ");
248
+ const r = spawnSync(process.execPath, [process.argv[1], ...argv], { stdio: "inherit", env: { ...env, NODE_OPTIONS: nodeOptions } });
249
+ return r.status ?? (r.signal === "SIGINT" ? 130 : 1);
250
+ }