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,405 @@
1
+ // kf fonts — 글꼴 짝을 받아 재고(주소·판·라이선스 글·한글 덮음·굵기) 크롬으로 견본을 찍어 글이 실제로 서는지 본다.
2
+ // 받은 파일은 <캐시>/fonts/files 에 두고, 크롬에는 그 파일을 같은 주소로 내준다 — 두 번째부터는 주소가 살았는지만 묻는다.
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { BrowserContext, Page } from "playwright";
8
+ import { readFontFile } from "@kasaframe/themes/fontfile";
9
+ import {
10
+ type FaceFile,
11
+ faceForChar,
12
+ FONT_SAMPLE,
13
+ type FontPair,
14
+ licenseVerdict,
15
+ measurePair,
16
+ type PairMeasure,
17
+ pairProblems,
18
+ type SampleLine,
19
+ stackFamilies,
20
+ stackUses,
21
+ } from "@kasaframe/themes/fonts";
22
+ import { launch } from "./browser.ts";
23
+ import { pool } from "./pool.ts";
24
+
25
+ export const fontCacheDir = (): string => join(process.env.KF_CACHE_DIR ?? join(homedir(), ".cache", "kasaframe"), "fonts");
26
+ export const cachedFontFile = (url: string) => join(fontCacheDir(), "files", `${createHash("sha1").update(url).digest("hex").slice(0, 20)}${/\.\w+$/.exec(new URL(url).pathname)?.[0] ?? ""}`);
27
+
28
+ interface Fetched {
29
+ /** HTTP 상태, 못 닿으면 까닭 */
30
+ status: number | string;
31
+ text?: string;
32
+ }
33
+
34
+ async function get(url: string, how: "head" | "file" | "text"): Promise<Fetched> {
35
+ for (let tries = 0; ; tries++) {
36
+ try {
37
+ const r = await fetch(url, { method: how === "head" ? "HEAD" : "GET", redirect: "follow", signal: AbortSignal.timeout(60_000) });
38
+ if (r.status >= 500 && tries < 2) continue;
39
+ if (!r.ok || how === "head") return { status: r.status };
40
+ if (how === "text") return { status: r.status, text: await r.text() };
41
+ const path = cachedFontFile(url);
42
+ mkdirSync(join(fontCacheDir(), "files"), { recursive: true });
43
+ writeFileSync(`${path}.tmp`, Buffer.from(await r.arrayBuffer()));
44
+ renameSync(`${path}.tmp`, path);
45
+ return { status: r.status };
46
+ } catch (err) {
47
+ if (tries < 2) continue;
48
+ return { status: (err as Error).message };
49
+ }
50
+ }
51
+ }
52
+
53
+ export interface RenderLine {
54
+ stack: string;
55
+ weight: number;
56
+ line: SampleLine;
57
+ fonts: { family: string; custom: boolean; glyphs: number }[];
58
+ ink: number;
59
+ tofu: string[];
60
+ }
61
+
62
+ export interface PairReport {
63
+ id: string;
64
+ group: string;
65
+ name: string;
66
+ ok: boolean;
67
+ hangul?: PairMeasure["hangul"];
68
+ syllables?: number;
69
+ boldest?: number;
70
+ licenses: string[];
71
+ problems: string[];
72
+ notes: string[];
73
+ }
74
+
75
+ export interface CheckOptions {
76
+ /** 주소를 묻지 않고 캐시만 — 처음 보는 파일은 문제로 남는다 */
77
+ offline?: boolean;
78
+ render?: boolean;
79
+ log?: (s: string) => void;
80
+ }
81
+
82
+ /** 짝들을 재고 찍어 본다 — 받기는 주소마다 한 번 */
83
+ export async function checkFonts(list: { group: string; pair: FontPair }[], o: CheckOptions = {}): Promise<PairReport[]> {
84
+ const log = o.log ?? (() => {});
85
+ const srcs = [...new Set(list.flatMap(({ pair }) => (pair.faces ?? []).map((f) => f.src)))];
86
+ const sources = o.offline ? [] : [...new Set(list.flatMap(({ pair }) => (pair.faces ?? []).map((f) => f.source).filter(Boolean)))];
87
+ // 같은 주소가 파일로도 라이선스 글로도 쓰일 수 있어 둘을 따로 둔다
88
+ const net = { file: new Map<string, Fetched>(), text: new Map<string, Fetched>() };
89
+ let done = 0;
90
+ await pool([...srcs.map((u) => ({ u, kind: "file" as const })), ...sources.map((u) => ({ u, kind: "text" as const }))], 8, async ({ u, kind }) => {
91
+ let r: Fetched;
92
+ try {
93
+ new URL(u);
94
+ } catch {
95
+ net[kind].set(u, { status: "주소가 아니다" });
96
+ return;
97
+ }
98
+ const cached = kind === "file" && existsSync(cachedFontFile(u));
99
+ if (o.offline) r = { status: cached ? 200 : "캐시에 없다(--offline)" };
100
+ else r = await get(u, cached ? "head" : kind);
101
+ net[kind].set(u, r);
102
+ if (++done % 25 === 0) log(` 주소 ${done}/${srcs.length + sources.length}`);
103
+ });
104
+
105
+ const files = new Map<string, FaceFile>();
106
+ const broken = new Map<string, string>();
107
+ for (const u of srcs) {
108
+ if (!existsSync(cachedFontFile(u))) continue;
109
+ try {
110
+ const f = readFontFile(readFileSync(cachedFontFile(u)));
111
+ files.set(u, { cps: f.cps, weight: f.weight, wght: f.wght, names: f.families, licenseText: f.licenseText });
112
+ } catch (err) {
113
+ broken.set(u, (err as Error).message);
114
+ }
115
+ }
116
+
117
+ const reports = list.map(({ group, pair }): PairReport => {
118
+ const problems = pairProblems(pair);
119
+ const notes: string[] = [];
120
+ for (const f of pair.faces ?? []) {
121
+ const where = `${f.family}(${f.weight})`;
122
+ const s = net.file.get(f.src)?.status;
123
+ if (s !== 200) problems.push(`${where} 주소 ${s} — ${f.src}`);
124
+ if (broken.has(f.src)) problems.push(`${where} 파일을 못 읽는다 — ${broken.get(f.src)}`);
125
+ if (!f.source || o.offline) continue;
126
+ const lic = net.text.get(f.source);
127
+ if (lic?.status !== 200) {
128
+ problems.push(`${where} 라이선스 글 주소 ${lic?.status} — ${f.source}`);
129
+ continue;
130
+ }
131
+ const v = licenseVerdict(f, lic.text ?? "", files.get(f.src)?.licenseText ?? "");
132
+ if (v.problem) problems.push(v.problem);
133
+ if (v.note) notes.push(v.note);
134
+ }
135
+ const ready = (pair.faces ?? []).length > 0 && pair.faces.every((f) => files.has(f.src));
136
+ const m = ready ? measurePair(pair, files) : null;
137
+ return {
138
+ id: pair.id,
139
+ group,
140
+ name: pair.name,
141
+ ok: false,
142
+ ...(m ? { hangul: m.hangul, syllables: m.syllables, boldest: m.boldest } : {}),
143
+ licenses: [...new Set((pair.faces ?? []).map((f) => f.license))],
144
+ problems: [...problems, ...(m?.problems ?? [])],
145
+ notes: [...notes, ...(m?.notes ?? [])],
146
+ };
147
+ });
148
+
149
+ if (o.render !== false) {
150
+ const todo = list.filter(({ pair }) => pair.faces?.length && pair.faces.every((f) => files.has(f.src)));
151
+ log(` 크롬으로 견본 찍기 — 짝 ${todo.length}`);
152
+ const browser = await launch();
153
+ try {
154
+ const context = await browser.newContext({ deviceScaleFactor: 1 });
155
+ await serveCached(context);
156
+ const queue = [...todo];
157
+ let n = 0;
158
+ await Promise.all(
159
+ Array.from({ length: Math.min(4, todo.length) }, async () => {
160
+ const page = await context.newPage();
161
+ for (let x = queue.shift(); x; x = queue.shift()) {
162
+ const r = reports.find((y) => y.id === x.pair.id)!;
163
+ r.problems.push(...judgeRender(x.pair, files, await renderPair(page, x.pair)));
164
+ if (++n % 20 === 0) log(` 찍음 ${n}/${todo.length}`);
165
+ }
166
+ }),
167
+ );
168
+ } finally {
169
+ await browser.close();
170
+ }
171
+ }
172
+ for (const r of reports) r.ok = !r.problems.length;
173
+ return reports;
174
+ }
175
+
176
+ const ORIGIN = "https://kf-fonts.local/";
177
+ const MIME: Record<string, string> = { woff2: "font/woff2", woff: "font/woff", ttf: "font/ttf", otf: "font/otf" };
178
+
179
+ /** 글꼴 주소는 캐시 파일로, 견본 쪽은 빈 문서로 — 크롬이 따로 받지 않아 같은 파일을 본다 */
180
+ async function serveCached(context: BrowserContext): Promise<void> {
181
+ await context.route("**/*", (route) => {
182
+ const url = route.request().url();
183
+ if (url.startsWith(ORIGIN)) return route.fulfill({ contentType: "text/html; charset=utf-8", body: "<!doctype html><meta charset=utf-8><body></body>" });
184
+ const path = cachedFontFile(url);
185
+ if (!existsSync(path)) return route.abort();
186
+ const ext = /\.(\w+)$/.exec(path)?.[1] ?? "woff2";
187
+ return route.fulfill({ body: readFileSync(path), headers: { "content-type": MIME[ext] ?? "application/octet-stream", "access-control-allow-origin": "*" } });
188
+ });
189
+ }
190
+
191
+ export interface Rendered {
192
+ loadErrors: { family: string; weight: string; error: string }[];
193
+ lines: RenderLine[];
194
+ }
195
+
196
+ /** 짝 하나를 빈 문서에 — face 를 하나씩 받아 실패를 그 face 에 붙이고, 줄마다 크롬이 고른 글꼴(CDP)·먹 양·두부 글자를 잰다 */
197
+ async function renderPair(page: Page, p: FontPair): Promise<Rendered> {
198
+ await page.goto(ORIGIN);
199
+ const uses = stackUses(p);
200
+ const spans = uses.flatMap((u) => (Object.keys(FONT_SAMPLE) as SampleLine[]).map((line) => ({ stack: u.stack, css: u.css, weight: u.weight, line, text: FONT_SAMPLE[line] })));
201
+ const inPage = await page.evaluate(
202
+ async ({ faces, spans }) => {
203
+ const loadErrors: { family: string; weight: string; error: string }[] = [];
204
+ await Promise.all(
205
+ faces.map(async (f) => {
206
+ const ff = new FontFace(f.family, `url("${f.src}")`, { weight: f.weight ?? "100 900", display: "block" });
207
+ document.fonts.add(ff);
208
+ await ff.load().catch((e: Error) => loadErrors.push({ family: f.family, weight: f.weight ?? "", error: String(e?.message ?? e) }));
209
+ }),
210
+ );
211
+ const canvas = document.createElement("canvas");
212
+ const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
213
+ const paint = (font: string, text: string, w: number, h: number): Uint8ClampedArray => {
214
+ canvas.width = w;
215
+ canvas.height = h;
216
+ ctx.font = font;
217
+ ctx.textBaseline = "middle";
218
+ ctx.fillText(text, 4, h / 2);
219
+ return ctx.getImageData(0, 0, w, h).data;
220
+ };
221
+ const inkOf = (d: Uint8ClampedArray) => {
222
+ let n = 0;
223
+ for (let i = 3; i < d.length; i += 4) if (d[i] > 40) n++;
224
+ return n;
225
+ };
226
+ const measured = spans.map((s, i) => {
227
+ const el = document.createElement("div");
228
+ el.dataset.k = String(i);
229
+ el.style.cssText = `font-family: ${s.css}; font-weight: ${s.weight}; font-size: 48px; white-space: pre; line-height: 1.4`;
230
+ el.textContent = s.text;
231
+ document.body.append(el);
232
+ const font = `${s.weight} 48px ${s.css}`;
233
+ const notdef = paint(font, "\u{10FFFD}", 80, 80).join(",");
234
+ const tofu = [...new Set([...s.text].filter((ch) => ch !== " "))].filter((ch) => {
235
+ const d = paint(font, ch, 80, 80);
236
+ return inkOf(d) > 0 && d.join(",") === notdef;
237
+ });
238
+ return { ink: inkOf(paint(font, s.text, 1600, 90)), tofu };
239
+ });
240
+ return { loadErrors, measured };
241
+ },
242
+ { faces: p.faces.map(({ family, src, weight }) => ({ family, src, weight })), spans: spans.map(({ css, weight, text }) => ({ css, weight, text })) },
243
+ );
244
+ const cdp = await page.context().newCDPSession(page);
245
+ try {
246
+ await cdp.send("DOM.enable");
247
+ await cdp.send("CSS.enable");
248
+ const { root } = await cdp.send("DOM.getDocument");
249
+ const lines: RenderLine[] = [];
250
+ for (const [i, s] of spans.entries()) {
251
+ const { nodeId } = await cdp.send("DOM.querySelector", { nodeId: root.nodeId, selector: `[data-k="${i}"]` });
252
+ const { fonts } = await cdp.send("CSS.getPlatformFontsForNode", { nodeId });
253
+ lines.push({ stack: s.stack, weight: s.weight, line: s.line, fonts: fonts.map((f) => ({ family: f.familyName, custom: f.isCustomFont, glyphs: f.glyphCount })), ...inPage.measured[i] });
254
+ }
255
+ return { loadErrors: inPage.loadErrors, lines };
256
+ } finally {
257
+ await cdp.detach();
258
+ }
259
+ }
260
+
261
+ /** 찍은 것을 cmap 으로 예상한 것과 댄다 — 투명·두부·시스템으로 떨어짐·다른 face 로 떨어짐 */
262
+ export function judgeRender(p: FontPair, files: Map<string, FaceFile>, r: Rendered): string[] {
263
+ const bad = r.loadErrors.map((e) => `${e.family}(${e.weight}) 를 크롬이 못 읽는다 — ${e.error}`);
264
+ if (bad.length) return bad;
265
+ for (const l of r.lines) {
266
+ const where = `${l.stack}@${l.weight} ${l.line} 줄`;
267
+ const text = FONT_SAMPLE[l.line];
268
+ const css = p[l.stack as "display"];
269
+ const namesOf = (ch: string) => {
270
+ const f = faceForChar(p, css, l.weight, ch.codePointAt(0)!, files);
271
+ return f ? (files.get(f.src)?.names ?? []) : [];
272
+ };
273
+ const expected = [...new Set([...text].filter((ch) => ch !== " "))].map((ch) => namesOf(ch));
274
+ const allowed = new Set([...expected.flat(), ...namesOf(" "), ...stackFamilies(css).slice(0, 1).flatMap((fam) => p.faces.filter((f) => f.family === fam).flatMap((f) => files.get(f.src)?.names ?? []))]);
275
+ if (!l.ink) bad.push(`${where} 이 투명하게 찍힌다`);
276
+ if (l.tofu.length) bad.push(`${where} ${l.tofu.join("")} 가 두부로 찍힌다`);
277
+ for (const f of l.fonts) {
278
+ if (!f.custom) bad.push(`${where} 이 시스템 글꼴 ${f.family} 로 떨어진다(글리프 ${f.glyphs})`);
279
+ else if (!allowed.has(f.family)) bad.push(`${where} 이 예상 밖 글꼴 ${f.family} 로 찍힌다(글리프 ${f.glyphs})`);
280
+ }
281
+ const used = new Set(l.fonts.filter((f) => f.custom).map((f) => f.family));
282
+ const missed = [...new Set(expected.filter((names) => names.length && !names.some((n) => used.has(n))).map((names) => names[0]))];
283
+ if (missed.length) bad.push(`${where} 에서 ${missed.join("·")} 가 안 쓰였다 — 다른 글꼴로 떨어졌다`);
284
+ }
285
+ return bad;
286
+ }
287
+
288
+ const GENERIC = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "emoji", "math", "fangsong"]);
289
+ const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&${{ "&": "amp", "<": "lt", ">": "gt", '"': "quot" }[c]};`);
290
+
291
+ /** 짝 견본 격자 한 장 — 사람이 눈으로 고르는 용. 같은 이름인데 파일이 다른 face 는 짝끼리 섞이지 않게 이름을 갈라 싣는다 */
292
+ export async function fontSheet(list: { group: string; pair: FontPair }[], out: string, o: { log?: (s: string) => void } = {}): Promise<void> {
293
+ const log = o.log ?? (() => {});
294
+ const missing = [...new Set(list.flatMap(({ pair }) => pair.faces.map((f) => f.src)))].filter((u) => !existsSync(cachedFontFile(u)));
295
+ if (missing.length) {
296
+ log(` 글꼴 받기 ${missing.length}`);
297
+ await pool(missing, 8, (u) => get(u, "file"));
298
+ }
299
+ const alias = new Map<string, string>();
300
+ const sigs = new Map<string, string[]>();
301
+ const aliasOf = (p: FontPair, family: string) => {
302
+ const sig = p.faces.filter((f) => f.family === family).map((f) => `${f.weight}=${f.src}`).sort().join("|");
303
+ const seen = sigs.get(family) ?? [];
304
+ if (!seen.includes(sig)) sigs.set(family, [...seen, sig]);
305
+ const k = sigs.get(family)!.indexOf(sig);
306
+ const name = k ? `${family} #${k + 1}` : family;
307
+ for (const f of p.faces.filter((x) => x.family === family)) alias.set(`${name}\n${f.src}\n${f.weight}`, name);
308
+ return name;
309
+ };
310
+ const stackOf = (p: FontPair, css: string) =>
311
+ stackFamilies(css)
312
+ .map((fam) => (p.faces.some((f) => f.family === fam) ? `"${aliasOf(p, fam)}"` : GENERIC.has(fam) ? fam : `"${fam}"`))
313
+ .join(", ");
314
+ const cards = list.map(({ group, pair: p }) => {
315
+ const display = stackOf(p, p.monoAll ? p.mono : p.display);
316
+ const sans = stackOf(p, p.monoAll ? p.mono : p.sans);
317
+ const mono = stackOf(p, p.mono);
318
+ const meta = `${p.hangul ?? "?"} · ${[...new Set(p.faces.map((f) => f.license))].join(",")} · ${p.tags.join(" ")}`;
319
+ return `<section><header><b>${esc(p.id)}</b><span>${esc(group)}</span></header>
320
+ <p class=t style='font-family:${display};font-weight:${p.weightDisplay};letter-spacing:${p.tracking}'>오늘의 제목 Title 2026</p>
321
+ <p class=b style='font-family:${sans};font-weight:${p.weightBody}'>${esc(p.ko)} — Sphinx of black quartz ₩12,000 · 45% → 0123456789</p>
322
+ <p class=m style='font-family:${mono};font-weight:${p.weightBody}'>const 값 = 42; // 결과 → 1,234</p>
323
+ <footer>${esc(p.name)} · ${esc(meta)}</footer></section>`;
324
+ });
325
+ const faces = [...alias.entries()].map(([k, name]) => {
326
+ const [, src, weight] = k.split("\n");
327
+ return { family: name, src, weight };
328
+ });
329
+ const cols = Math.min(8, Math.max(3, Math.ceil(list.length / 40)));
330
+ const html = `<!doctype html><meta charset=utf-8><style>
331
+ body { margin: 0; padding: 28px; background: #efece6; color: #171717; font: 14px/1.35 -apple-system, "Apple SD Gothic Neo", sans-serif; }
332
+ h1 { font-size: 20px; margin: 0 0 18px; font-weight: 600; }
333
+ main { display: grid; grid-template-columns: repeat(${cols}, 520px); gap: 14px; }
334
+ section { background: #fff; border: 1px solid #d9d4ca; padding: 14px 18px 12px; overflow: hidden; }
335
+ header { display: flex; justify-content: space-between; font: 12px/1 ui-monospace, monospace; color: #6b655c; }
336
+ header b { color: #171717; }
337
+ p { margin: 10px 0 0; white-space: nowrap; overflow: hidden; text-overflow: clip; }
338
+ .t { font-size: 44px; line-height: 1.15; }
339
+ .b { font-size: 19px; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
340
+ .m { font-size: 17px; color: #3a3631; }
341
+ footer { margin-top: 10px; font-size: 12px; color: #8a8378; white-space: nowrap; overflow: hidden; }
342
+ </style><h1>kasaframe 글꼴 짝 ${list.length} — ${esc([...new Set(list.map((x) => x.group))].join(" · "))}</h1><main>${cards.join("")}</main>`;
343
+ const browser = await launch();
344
+ try {
345
+ const context = await browser.newContext({ deviceScaleFactor: 1, viewport: { width: cols * 534 + 56, height: 900 } });
346
+ await serveCached(context);
347
+ const page = await context.newPage();
348
+ await page.goto(ORIGIN);
349
+ const failed = await page.evaluate(
350
+ async ({ html, faces }) => {
351
+ document.open();
352
+ document.write(html);
353
+ document.close();
354
+ const bad: string[] = [];
355
+ await Promise.all(
356
+ faces.map(async (f) => {
357
+ const ff = new FontFace(f.family, `url("${f.src}")`, { weight: f.weight, display: "block" });
358
+ document.fonts.add(ff);
359
+ await ff.load().catch(() => bad.push(`${f.family}(${f.weight})`));
360
+ }),
361
+ );
362
+ await document.fonts.ready;
363
+ return bad;
364
+ },
365
+ { html, faces },
366
+ );
367
+ if (failed.length) log(` 못 실은 face: ${failed.join(", ")}`);
368
+ mkdirSync(join(out, ".."), { recursive: true });
369
+ await page.screenshot({ path: out, fullPage: true });
370
+ } finally {
371
+ await browser.close();
372
+ }
373
+ }
374
+
375
+ const NOTICE_HEAD = "글꼴 짝 — packages/themes/kit/fonts.json · kit/fonts/<무리>.json (kf fonts notice --write 가 이 절을 짓는다)";
376
+ const RULE = "-".repeat(70);
377
+
378
+ /** NOTICE 의 글꼴 절 — 글꼴 이름마다 라이선스와 라이선스 글 한 줄 */
379
+ export function noticeSection(pairs: FontPair[]): string {
380
+ const rows = new Map<string, [string, string, string]>();
381
+ for (const f of pairs.flatMap((p) => p.faces)) rows.set(`${f.family}\n${f.license}\n${f.source}`, [f.family, f.license, f.source]);
382
+ const list = [...rows.values()].sort((a, b) => a[0].localeCompare(b[0]) || a[2].localeCompare(b[2]));
383
+ const w = Math.max(...list.map((r) => r[0].length));
384
+ const lw = Math.max(...list.map((r) => r[1].length));
385
+ return [
386
+ RULE,
387
+ NOTICE_HEAD,
388
+ "글꼴 파일은 싣지 않는다 — 영상을 구울 때 판을 박은 주소(jsdelivr gh@커밋·npm@판)에서 받는다. 모두 상업 이용·영상·",
389
+ "웹 임베딩이 허용된 라이선스이고 출처 표기 의무가 없다. kf fonts check 가 라이선스 글이나 파일 name 13 에서 그 문구를 대 본다.",
390
+ `짝 ${pairs.length} · 글꼴 ${new Set(list.map((r) => r[0])).size} — 이름 · 라이선스 · 라이선스 글`,
391
+ "",
392
+ ...list.map(([fam, lic, src]) => ` ${fam.padEnd(w)} ${lic.padEnd(lw)} ${src}`),
393
+ "",
394
+ ].join("\n");
395
+ }
396
+
397
+ /** NOTICE 글에서 글꼴 절을 갈아 끼운다 — 없으면 끝에 붙인다 */
398
+ export function replaceNotice(notice: string, section: string): string {
399
+ const lines = notice.split("\n");
400
+ const head = lines.indexOf(NOTICE_HEAD);
401
+ if (head < 0) return `${notice.trimEnd()}\n\n${section}`;
402
+ const from = lines[head - 1] === RULE ? head - 1 : head;
403
+ const next = lines.findIndex((l, i) => i > head && l === RULE);
404
+ return [...lines.slice(0, from), ...section.split("\n").slice(0, -1), ...(next < 0 ? [""] : lines.slice(next))].join("\n");
405
+ }
@@ -3,6 +3,7 @@ import { readdirSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { fontsMissing } from "@kasaframe/jizura/install";
5
5
  import type { Command } from "./command.ts";
6
+ import { updateGate } from "./update.ts";
6
7
 
7
8
  const commandsDir = fileURLToPath(new URL("./commands/", import.meta.url));
8
9
 
@@ -42,6 +43,10 @@ async function main(argv: string[]): Promise<number> {
42
43
  console.log(`kf ${name} — ${cmd.summary}\n\n${cmd.usage ?? ""}`.trimEnd());
43
44
  return 0;
44
45
  }
46
+ if (name !== "update") {
47
+ const relaunched = await updateGate(argv).catch(() => null);
48
+ if (relaunched !== null) return relaunched;
49
+ }
45
50
  return (await cmd.run(rest)) ?? 0;
46
51
  }
47
52
 
@@ -1,7 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, renameSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { audioDuration, findSilences } from "@kasaframe/gen";
5
+ import { laySfx, writeFloatWav, type SfxBank, type SfxHit, type SfxPlaced, type SfxSettings } from "./sfx.ts";
5
6
 
6
7
  // 내레이션 + 곡 섞기 — kf.json 에 audio(섞기 값)가 있는 프로젝트만. 없으면 렌더는 예전처럼 곡만 얹는다
7
8
  export interface AudioMix {
@@ -12,6 +13,17 @@ export interface AudioMix {
12
13
  fadeOut: number;
13
14
  }
14
15
 
16
+ // kf.json 에 audio 가 없는데 효과음을 깔 때 — 곡은 그대로(끝 페이드 없이) 두고 전체만 쇼츠·설명과 같은 크기로
17
+ export const SFX_AUDIO: AudioMix = { musicDb: -20, duck: true, lufs: -14, truePeak: -1.5, fadeOut: 0 };
18
+
19
+ /** 효과음 층 — 깔 소리와 결. 섞은 뒤 measured 에 잰 크기·깐 소리마다 가려짐을 적는다 */
20
+ export interface SfxMix {
21
+ hits: SfxHit[];
22
+ settings: SfxSettings;
23
+ bank: Pick<SfxBank, "file">;
24
+ measured?: { bed: number | null; sfx: number; lift: number; hits: SfxPlaced[] };
25
+ }
26
+
15
27
  // 목소리를 먼저 이 크기로 맞춘다 — 덕킹 문턱이 목소리 크기에 묶여 있어서 공급자마다 다른 크기를 한 자리로
16
28
  const VOICE_LUFS = -18;
17
29
  // 덕킹 두 입력의 프레임 크기를 맞춘다 — 목소리 wav(1024)·곡 mp3(1152) 프레임이 도착 차례에 따라 sidechaincompress 결과가
@@ -29,6 +41,25 @@ function ffmpeg(args: string[]): Promise<string> {
29
41
  });
30
42
  }
31
43
 
44
+ // 섞은 판을 왼·오 실수 둘로 — 효과음이 둘레 곡 크기를 재고, 그 순간 곡을 누르고, 그 위에 얹는다
45
+ async function stereo(path: string): Promise<[Float32Array, Float32Array]> {
46
+ return new Promise((done, fail) => {
47
+ const p = spawn("ffmpeg", ["-v", "error", "-i", path, "-ac", "2", "-ar", "48000", "-f", "f32le", "-"]);
48
+ const parts: Buffer[] = [];
49
+ p.stdout.on("data", (b: Buffer) => parts.push(b));
50
+ p.on("error", fail);
51
+ p.on("close", (code) => {
52
+ if (code !== 0) return fail(new Error(`ffmpeg 풀기 실패(${code}): ${path}`));
53
+ const b = Buffer.concat(parts);
54
+ const x = new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
55
+ const L = new Float32Array(x.length >> 1);
56
+ const R = new Float32Array(x.length >> 1);
57
+ for (let i = 0; i < L.length; i++) [L[i], R[i]] = [x[2 * i], x[2 * i + 1]];
58
+ done([L, R]);
59
+ });
60
+ });
61
+ }
62
+
32
63
  // 통합 크기(LUFS) — 무음은 게이트가 빼서 말·곡이 실제로 울리는 곳만 잰다
33
64
  async function loudness(input: string[], filter = ""): Promise<number> {
34
65
  const err = await ffmpeg([...input, "-af", `${filter ? `${filter},` : ""}ebur128=framelog=quiet`, "-f", "null", "-"]);
@@ -64,8 +95,19 @@ export function mixSettings(dir: string): { audio: AudioMix; voice?: string } |
64
95
  }
65
96
 
66
97
  // 영상 [from, from+dur) 의 소리를 out(wav)으로. total 은 영상 전체 길이 — 끝 페이드는 영상 끝에 걸린다
67
- export async function mixAudio(src: Sources, o: { audio: AudioMix; from: number; dur: number; total: number; out: string }): Promise<string> {
98
+ export async function mixAudio(src: Sources, o: { audio: AudioMix; from: number; dur: number; total: number; out: string; sfx?: SfxMix }): Promise<string> {
68
99
  const { audio } = o;
100
+ const sfx = o.sfx?.hits.length ? o.sfx : undefined;
101
+ // 곡도 목소리도 없으면 효과음만 — 빈 판 위에(둘레 곡이 없으니 크기는 바닥 기준, 누를 것도 없다). 정규화 없이
102
+ if (!src.voice && !src.music && sfx) {
103
+ const n = Math.round(o.dur * 48000);
104
+ const bed: [Float32Array, Float32Array] = [new Float32Array(n), new Float32Array(n)];
105
+ const { stem, placed } = laySfx(bed, sfx.hits, sfx.bank, sfx.settings, { from: o.from, voice: false });
106
+ const raw = writeFloatWav(o.out.replace(/\.wav$/, ".with.wav"), bed);
107
+ await ffmpeg(["-i", raw, "-c:a", "pcm_s16le", o.out]);
108
+ sfx.measured = { bed: null, sfx: await loudness(["-i", writeFloatWav(o.out.replace(/\.wav$/, ".sfx.wav"), [stem])]), lift: 0, hits: placed };
109
+ return o.out;
110
+ }
69
111
  const seg = (path: string, at: number) => ["-ss", at.toFixed(6), "-t", o.dur.toFixed(6), "-i", path];
70
112
  const inputs: string[] = [];
71
113
  const chains: string[] = [];
@@ -102,11 +144,31 @@ export async function mixAudio(src: Sources, o: { audio: AudioMix; from: number;
102
144
  chains.push(parts.length === 2 ? `${parts.join("")}amix=inputs=2:normalize=0:duration=longest,apad,atrim=0:${o.dur.toFixed(6)}[pre]` : `${parts[0]}apad,atrim=0:${o.dur.toFixed(6)}[pre]`);
103
145
  const pre = o.out.replace(/\.wav$/, ".pre.wav");
104
146
  await ffmpeg([...inputs, "-filter_complex", chains.join(";"), "-map", "[pre]", "-c:a", "pcm_s24le", "-ar", "48000", pre]);
147
+ // 효과음은 곡·목소리를 다 섞은 판 위에 — 소리마다 둘레 곡 크기에 맞추고 그 순간 곡을 짧게 눌러 자리를 낸다(목소리가 있으면 더 낮게)
148
+ let norm = pre;
149
+ if (sfx) {
150
+ const bedLufs = await loudness(["-i", pre]);
151
+ const bed = await stereo(pre);
152
+ const { stem, placed } = laySfx(bed, sfx.hits, sfx.bank, sfx.settings, { from: o.from, voice: !!src.voice });
153
+ norm = writeFloatWav(o.out.replace(/\.wav$/, ".with.wav"), bed);
154
+ const stemFile = writeFloatWav(o.out.replace(/\.wav$/, ".sfx.wav"), [stem]);
155
+ sfx.measured = { bed: bedLufs, sfx: await loudness(["-i", stemFile]), lift: (await loudness(["-i", norm])) - bedLufs, hits: placed };
156
+ }
105
157
  // 두 번 잰다 — 한 번에 loudnorm 을 걸면 동적 모드로 들어가 덕킹 모양이 뭉개진다. 잰 값을 주고 linear 로 한 번에 올린다
106
- const err = await ffmpeg(["-i", pre, "-af", `loudnorm=I=${audio.lufs}:TP=${audio.truePeak}:LRA=11:print_format=json`, "-f", "null", "-"]);
158
+ const err = await ffmpeg(["-i", norm, "-af", `loudnorm=I=${audio.lufs}:TP=${audio.truePeak}:LRA=11:print_format=json`, "-f", "null", "-"]);
107
159
  const j = JSON.parse(err.slice(err.lastIndexOf("{"), err.lastIndexOf("}") + 1));
108
160
  const ln = `loudnorm=I=${audio.lufs}:TP=${audio.truePeak}:LRA=11:measured_I=${j.input_i}:measured_TP=${j.input_tp}:measured_LRA=${j.input_lra}:measured_thresh=${j.input_thresh}:offset=${j.target_offset}:linear=true`;
109
- await ffmpeg(["-i", pre, "-af", `${ln},aresample=48000`, "-c:a", "pcm_s16le", o.out]);
161
+ await ffmpeg(["-i", norm, "-af", `${ln},aresample=48000`, "-c:a", "pcm_s16le", o.out]);
162
+ // 꼭대기가 0dBTP 가까운 곡은 linear 로 못 올려 동적 모드로 빠지고 목표보다 0.5LU 넘게 커졌다 — 효과음을 깐 판만 넘친 만큼 내린다
163
+ // (내리면 꼭대기도 내려가 truePeak 를 안 넘는다. 끈 판은 예전과 같은 소리로 둔다)
164
+ if (sfx) {
165
+ const got = await loudness(["-i", o.out]);
166
+ if (got - audio.lufs > 0.3) {
167
+ const fit = o.out.replace(/\.wav$/, ".fit.wav");
168
+ await ffmpeg(["-i", o.out, "-af", `volume=${(audio.lufs - got).toFixed(2)}dB`, "-c:a", "pcm_s16le", fit]);
169
+ renameSync(fit, o.out);
170
+ }
171
+ }
110
172
  return o.out;
111
173
  }
112
174
 
@@ -14,7 +14,7 @@ import { repoRoot } from "./serve.ts";
14
14
  // 조각 꼴(인코딩·해시 재료)이 바뀌면 올린다 — 옛 조각을 다시 쓰지 않게
15
15
  const VERSION = "seg-1";
16
16
  // 화면을 안 바꾸는 kf.json 칸 — 소리·굽기 기본값·검사 예외. 굽기 값(모션 블러)은 KeyOpts 로 따로 들어간다
17
- const NOT_FRAMES = new Set(["scenes", "music", "audio", "voice", "narration", "render", "check"]);
17
+ const NOT_FRAMES = new Set(["scenes", "music", "audio", "sfx", "voice", "narration", "render", "check"]);
18
18
  const AUDIO = /\.(mp3|wav|m4a|aac|flac|ogg)$/i;
19
19
 
20
20
  export interface SceneEntry {