kasaframe 0.0.0-stage → 0.1.1

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 (338) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +146 -0
  3. package/README.md +77 -2
  4. package/app/package.json +12 -0
  5. package/app/server/engine.ts +415 -0
  6. package/app/server/main.ts +205 -0
  7. package/app/web/env.d.ts +2 -0
  8. package/app/web/icons.ts +31 -0
  9. package/app/web/index.html +21 -0
  10. package/app/web/main.ts +1013 -0
  11. package/app/web/style.css +430 -0
  12. package/bin/hooks.mjs +51 -0
  13. package/bin/kf.mjs +14 -0
  14. package/docs/AGENTS.md +238 -0
  15. package/music/README.md +13 -0
  16. package/package.json +44 -4
  17. package/packages/adapters/package.json +36 -0
  18. package/packages/adapters/src/css.ts +11 -0
  19. package/packages/adapters/src/gsap.ts +26 -0
  20. package/packages/adapters/src/index.ts +9 -0
  21. package/packages/adapters/src/lottie.ts +22 -0
  22. package/packages/adapters/src/media.ts +59 -0
  23. package/packages/adapters/src/page.ts +229 -0
  24. package/packages/adapters/src/project.ts +214 -0
  25. package/packages/adapters/src/react.ts +36 -0
  26. package/packages/adapters/src/svelte.ts +22 -0
  27. package/packages/adapters/src/three.ts +18 -0
  28. package/packages/adapters/src/vue.ts +27 -0
  29. package/packages/check/package.json +19 -0
  30. package/packages/check/src/check.ts +273 -0
  31. package/packages/check/src/cli.ts +70 -0
  32. package/packages/check/src/image.ts +146 -0
  33. package/packages/check/src/index.ts +9 -0
  34. package/packages/check/src/ocr.swift +37 -0
  35. package/packages/check/src/probe.ts +328 -0
  36. package/packages/check/src/report.ts +44 -0
  37. package/packages/check/src/rules/assets.ts +42 -0
  38. package/packages/check/src/rules/beats.ts +212 -0
  39. package/packages/check/src/rules/camera.ts +217 -0
  40. package/packages/check/src/rules/captions.ts +49 -0
  41. package/packages/check/src/rules/flash.ts +235 -0
  42. package/packages/check/src/rules/frames.ts +135 -0
  43. package/packages/check/src/rules/layout.ts +257 -0
  44. package/packages/check/src/rules/page.ts +87 -0
  45. package/packages/check/src/rules/runs.ts +20 -0
  46. package/packages/check/src/rules/text.ts +326 -0
  47. package/packages/check/src/scan.ts +240 -0
  48. package/packages/check/src/serve.ts +67 -0
  49. package/packages/check/src/types.ts +80 -0
  50. package/packages/check/src/video.ts +137 -0
  51. package/packages/cli/package.json +29 -0
  52. package/packages/cli/src/blur.ts +68 -0
  53. package/packages/cli/src/browser.ts +203 -0
  54. package/packages/cli/src/command.ts +44 -0
  55. package/packages/cli/src/commands/app.ts +25 -0
  56. package/packages/cli/src/commands/beats.ts +36 -0
  57. package/packages/cli/src/commands/blender.ts +270 -0
  58. package/packages/cli/src/commands/check.ts +25 -0
  59. package/packages/cli/src/commands/doctor.ts +22 -0
  60. package/packages/cli/src/commands/gen.ts +173 -0
  61. package/packages/cli/src/commands/look.ts +39 -0
  62. package/packages/cli/src/commands/lyrics.ts +111 -0
  63. package/packages/cli/src/commands/make.ts +542 -0
  64. package/packages/cli/src/commands/new.ts +176 -0
  65. package/packages/cli/src/commands/preview.ts +43 -0
  66. package/packages/cli/src/commands/render.ts +216 -0
  67. package/packages/cli/src/commands/setup.ts +63 -0
  68. package/packages/cli/src/commands/sheet.ts +114 -0
  69. package/packages/cli/src/commands/skill.ts +43 -0
  70. package/packages/cli/src/commands/still.ts +40 -0
  71. package/packages/cli/src/commands/theme.ts +85 -0
  72. package/packages/cli/src/commands/transitions.ts +40 -0
  73. package/packages/cli/src/deps.ts +122 -0
  74. package/packages/cli/src/ffmpeg.ts +81 -0
  75. package/packages/cli/src/index.ts +8 -0
  76. package/packages/cli/src/kf.ts +53 -0
  77. package/packages/cli/src/lock.ts +72 -0
  78. package/packages/cli/src/out.ts +13 -0
  79. package/packages/cli/src/serve.ts +124 -0
  80. package/packages/clock/package.json +14 -0
  81. package/packages/clock/src/index.ts +25 -0
  82. package/packages/clock/src/install.ts +351 -0
  83. package/packages/clock/src/types.ts +45 -0
  84. package/packages/core/package.json +9 -0
  85. package/packages/core/src/beats.ts +283 -0
  86. package/packages/core/src/ease.ts +87 -0
  87. package/packages/core/src/frame.ts +99 -0
  88. package/packages/core/src/index.ts +27 -0
  89. package/packages/core/src/project.ts +146 -0
  90. package/packages/core/src/rand.ts +56 -0
  91. package/packages/core/src/spring.ts +52 -0
  92. package/packages/core/src/style.ts +100 -0
  93. package/packages/core/src/time.ts +88 -0
  94. package/packages/gen/PROVIDERS.md +55 -0
  95. package/packages/gen/package.json +9 -0
  96. package/packages/gen/src/generate.ts +204 -0
  97. package/packages/gen/src/image/og.ts +133 -0
  98. package/packages/gen/src/index.ts +18 -0
  99. package/packages/gen/src/keys.ts +60 -0
  100. package/packages/gen/src/media.ts +150 -0
  101. package/packages/gen/src/plan.ts +106 -0
  102. package/packages/gen/src/registry.ts +51 -0
  103. package/packages/gen/src/seam.ts +103 -0
  104. package/packages/gen/src/types.ts +86 -0
  105. package/packages/gen/src/video/fal.ts +140 -0
  106. package/packages/gen/src/video/h3.ts +107 -0
  107. package/packages/gen/src/video/http.ts +44 -0
  108. package/packages/gen/src/video/ltx.ts +60 -0
  109. package/packages/gen/src/video/minimax.ts +55 -0
  110. package/packages/gen/src/video/veo.ts +71 -0
  111. package/packages/jizura/package.json +16 -0
  112. package/packages/jizura/src/deny.ts +73 -0
  113. package/packages/jizura/src/index.ts +7 -0
  114. package/packages/jizura/src/install.ts +226 -0
  115. package/packages/jizura/src/layer.ts +282 -0
  116. package/packages/jizura/src/lyrics.ts +81 -0
  117. package/packages/jizura/src/pin.ts +5 -0
  118. package/packages/jizura/src/vite.ts +36 -0
  119. package/packages/motion/README.md +142 -0
  120. package/packages/motion/package.json +29 -0
  121. package/packages/motion/src/camera.ts +97 -0
  122. package/packages/motion/src/captions.ts +479 -0
  123. package/packages/motion/src/enter.ts +128 -0
  124. package/packages/motion/src/hangul.ts +129 -0
  125. package/packages/motion/src/impact.ts +100 -0
  126. package/packages/motion/src/index.ts +17 -0
  127. package/packages/motion/src/moves/brushWipe.ts +90 -0
  128. package/packages/motion/src/moves/cloneArray.ts +111 -0
  129. package/packages/motion/src/moves/curtains.ts +81 -0
  130. package/packages/motion/src/moves/echoStack.ts +70 -0
  131. package/packages/motion/src/moves/fit.ts +34 -0
  132. package/packages/motion/src/moves/flood.ts +61 -0
  133. package/packages/motion/src/moves/ghostStack.ts +79 -0
  134. package/packages/motion/src/moves/gridFloor.ts +100 -0
  135. package/packages/motion/src/moves/imageSwarm.ts +109 -0
  136. package/packages/motion/src/moves/index.ts +19 -0
  137. package/packages/motion/src/moves/irisBlades.ts +113 -0
  138. package/packages/motion/src/moves/karaokeLine.ts +94 -0
  139. package/packages/motion/src/moves/ledBoard.ts +94 -0
  140. package/packages/motion/src/moves/liquidTab.ts +72 -0
  141. package/packages/motion/src/moves/markerWrite.ts +63 -0
  142. package/packages/motion/src/moves/morphBox.ts +96 -0
  143. package/packages/motion/src/moves/neonSign.ts +77 -0
  144. package/packages/motion/src/moves/panelPop.ts +78 -0
  145. package/packages/motion/src/moves/paperTear.ts +75 -0
  146. package/packages/motion/src/moves/path.ts +64 -0
  147. package/packages/motion/src/moves/pathText.ts +107 -0
  148. package/packages/motion/src/moves/pointSphere.ts +109 -0
  149. package/packages/motion/src/moves/ransomLetters.ts +73 -0
  150. package/packages/motion/src/moves/shapeMorph.ts +143 -0
  151. package/packages/motion/src/moves/slamWords.ts +89 -0
  152. package/packages/motion/src/moves/sparkTrail.ts +114 -0
  153. package/packages/motion/src/moves/speedLines.ts +91 -0
  154. package/packages/motion/src/moves/splitFlap.ts +147 -0
  155. package/packages/motion/src/moves/stepWords.ts +117 -0
  156. package/packages/motion/src/moves/strikeOut.ts +98 -0
  157. package/packages/motion/src/moves/sunburst.ts +58 -0
  158. package/packages/motion/src/moves/tickerRows.ts +87 -0
  159. package/packages/motion/src/moves/tokenPicker.ts +114 -0
  160. package/packages/motion/src/moves/truchet.ts +85 -0
  161. package/packages/motion/src/moves/wordWall.ts +76 -0
  162. package/packages/motion/src/moves/worldRoll.ts +31 -0
  163. package/packages/motion/src/particles.ts +102 -0
  164. package/packages/motion/src/react/index.tsx +21 -0
  165. package/packages/motion/src/react/parts.tsx +431 -0
  166. package/packages/motion/src/react/time.tsx +97 -0
  167. package/packages/motion/src/text.ts +103 -0
  168. package/packages/motion/src/transition.ts +82 -0
  169. package/packages/motion/src/transition3d/cards.ts +65 -0
  170. package/packages/motion/src/transition3d/chain.ts +84 -0
  171. package/packages/motion/src/transition3d/dive.ts +181 -0
  172. package/packages/motion/src/transition3d/fold.ts +110 -0
  173. package/packages/motion/src/transition3d/gl.ts +219 -0
  174. package/packages/motion/src/transition3d/index.ts +228 -0
  175. package/packages/motion/src/transition3d/live.ts +70 -0
  176. package/packages/motion/src/transition3d/portal.ts +115 -0
  177. package/packages/motion/src/transition3d/react.tsx +29 -0
  178. package/packages/motion/src/transition3d/rig.ts +502 -0
  179. package/packages/motion/src/transition3d/shatter.ts +101 -0
  180. package/packages/motion/src/transition3d/whip.ts +65 -0
  181. package/packages/motion/src/transition3d/world.ts +683 -0
  182. package/packages/motion/src/transition3d/zoomOut.ts +114 -0
  183. package/packages/motion/src/transitions2d/catalog.ts +127 -0
  184. package/packages/motion/src/transitions2d/css.d.ts +2 -0
  185. package/packages/motion/src/transitions2d/cult-ui/LICENSE.md +9 -0
  186. package/packages/motion/src/transitions2d/cult-ui/RollingNumber.tsx +43 -0
  187. package/packages/motion/src/transitions2d/gl-transitions/LICENSE +25 -0
  188. package/packages/motion/src/transitions2d/gl-transitions/shaders.ts +1931 -0
  189. package/packages/motion/src/transitions2d/index.ts +2 -0
  190. package/packages/motion/src/transitions2d/kokonutui/LICENSE +21 -0
  191. package/packages/motion/src/transitions2d/kokonutui/MatrixText.css +16 -0
  192. package/packages/motion/src/transitions2d/kokonutui/MatrixText.tsx +94 -0
  193. package/packages/motion/src/transitions2d/magicui/HyperText.css +15 -0
  194. package/packages/motion/src/transitions2d/magicui/HyperText.tsx +115 -0
  195. package/packages/motion/src/transitions2d/magicui/LICENSE.md +21 -0
  196. package/packages/motion/src/transitions2d/magicui/MorphingText.css +25 -0
  197. package/packages/motion/src/transitions2d/magicui/MorphingText.tsx +130 -0
  198. package/packages/motion/src/transitions2d/magicui/WordRotate.tsx +42 -0
  199. package/packages/motion/src/transitions2d/react.ts +13 -0
  200. package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.css +39 -0
  201. package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.tsx +32 -0
  202. package/packages/motion/src/transitions2d/smoothui/LICENSE +21 -0
  203. package/packages/motion/src/transitions2d/smoothui/SharedAxisY.tsx +50 -0
  204. package/packages/motion/src/transitions2d/smoothui/TextMorph.tsx +90 -0
  205. package/packages/motion/src/transitions2d/smoothui/shaders.ts +223 -0
  206. package/packages/motion/src/transitions2d/unlumen/LICENSE +21 -0
  207. package/packages/motion/src/transitions2d/unlumen/ScrambleText.tsx +114 -0
  208. package/packages/motion/src/transitions2d/vengeance-ui/LICENSE +21 -0
  209. package/packages/motion/src/transitions2d/vengeance-ui/MorphText.css +69 -0
  210. package/packages/motion/src/transitions2d/vengeance-ui/MorphText.tsx +45 -0
  211. package/packages/motion/src/type.ts +131 -0
  212. package/packages/motion/src/ui.ts +172 -0
  213. package/packages/plan/package.json +15 -0
  214. package/packages/plan/src/brief.ts +254 -0
  215. package/packages/plan/src/captions.ts +75 -0
  216. package/packages/plan/src/detect.ts +93 -0
  217. package/packages/plan/src/index.ts +23 -0
  218. package/packages/plan/src/layout.ts +176 -0
  219. package/packages/plan/src/llm.ts +110 -0
  220. package/packages/plan/src/look.ts +220 -0
  221. package/packages/plan/src/plan.ts +125 -0
  222. package/packages/plan/src/request.ts +55 -0
  223. package/packages/plan/src/studio.ts +53 -0
  224. package/packages/plan/src/templates.ts +42 -0
  225. package/packages/plan/src/themes.ts +48 -0
  226. package/packages/plan/src/types.ts +106 -0
  227. package/packages/studio/package.json +12 -0
  228. package/packages/studio/src/index.ts +1 -0
  229. package/packages/studio/src/plugin.ts +236 -0
  230. package/packages/studio/src/ui/main.ts +503 -0
  231. package/packages/studio/src/ui/studio.css +165 -0
  232. package/packages/studio/src/ui/timeline.ts +317 -0
  233. package/packages/themes/kit/THEME-MAKE.md +68 -0
  234. package/packages/themes/kit/THEME.md +30 -0
  235. package/packages/themes/kit/briefs/og-intro-15s/brief.json +16 -0
  236. package/packages/themes/kit/briefs/og-intro-15s/facts.ts +29 -0
  237. package/packages/themes/kit/briefs/og-intro-15s/logo.svg +12 -0
  238. package/packages/themes/kit/effects.ts +5 -0
  239. package/packages/themes/kit/sample/index.html +11 -0
  240. package/packages/themes/kit/sample/main.ts +9 -0
  241. package/packages/themes/kit/sample/scenes/scene.ts +15 -0
  242. package/packages/themes/package.json +16 -0
  243. package/packages/themes/src/factory.ts +296 -0
  244. package/packages/themes/src/fx.ts +14 -0
  245. package/packages/themes/src/index.ts +105 -0
  246. package/packages/themes/src/stage3d.ts +177 -0
  247. package/packages/themes/src/types.ts +58 -0
  248. package/presets/kawaii-pastel.json +94 -0
  249. package/presets/neutral-dark.json +82 -0
  250. package/presets/og.json +98 -0
  251. package/skills/kasaframe/SKILL.md +32 -0
  252. package/templates/_shared/project/index.html +11 -0
  253. package/templates/_shared/project/lib/react.ts +22 -0
  254. package/templates/_shared/project/lib/stage.ts +272 -0
  255. package/templates/_shared/project/main.ts +9 -0
  256. package/templates/_shared/scenes/default.ts +18 -0
  257. package/templates/_shared/scenes/drop.ts +30 -0
  258. package/templates/_shared/scenes/end.ts +23 -0
  259. package/templates/_shared/scenes/hook.ts +16 -0
  260. package/templates/_shared/scenes/seam.ts +41 -0
  261. package/templates/_shared/scenes/step.ts +40 -0
  262. package/templates/ad/preview.png +0 -0
  263. package/templates/ad/template.json +132 -0
  264. package/templates/demo/preview.png +0 -0
  265. package/templates/demo/template.json +130 -0
  266. package/templates/explainer/preview.png +0 -0
  267. package/templates/explainer/template.json +142 -0
  268. package/templates/launch/preview.png +0 -0
  269. package/templates/launch/template.json +130 -0
  270. package/templates/mv/preview.png +0 -0
  271. package/templates/mv/scenes/default.ts +95 -0
  272. package/templates/mv/template.json +176 -0
  273. package/templates/promo/preview.png +0 -0
  274. package/templates/promo/template.json +144 -0
  275. package/templates/shorts/preview.png +0 -0
  276. package/templates/shorts/scenes/hook.ts +34 -0
  277. package/templates/shorts/template.json +127 -0
  278. package/templates/square/preview.png +0 -0
  279. package/templates/square/template.json +113 -0
  280. package/themes/debug-hud/THEME.md +39 -0
  281. package/themes/debug-hud/effects.ts +389 -0
  282. package/themes/debug-hud/sample/assets/og/logo.svg +12 -0
  283. package/themes/debug-hud/sample/beats.json +1 -0
  284. package/themes/debug-hud/sample/index.html +11 -0
  285. package/themes/debug-hud/sample/kf.json +59 -0
  286. package/themes/debug-hud/sample/lib/hudkit.ts +49 -0
  287. package/themes/debug-hud/sample/lib/og.ts +29 -0
  288. package/themes/debug-hud/sample/main.ts +9 -0
  289. package/themes/debug-hud/sample/scenes/end.ts +62 -0
  290. package/themes/debug-hud/sample/scenes/gather.ts +67 -0
  291. package/themes/debug-hud/sample/scenes/models.ts +70 -0
  292. package/themes/debug-hud/sample/scenes/price.ts +79 -0
  293. package/themes/debug-hud/sample/style.json +91 -0
  294. package/themes/debug-hud/still.png +0 -0
  295. package/themes/debug-hud/style.json +91 -0
  296. package/themes/debug-hud/theme.json +68 -0
  297. package/themes/og-measure/THEME.md +47 -0
  298. package/themes/og-measure/effects.ts +432 -0
  299. package/themes/og-measure/sample/assets/brand/og-logo-full.svg +12 -0
  300. package/themes/og-measure/sample/assets/brand/og-logo.svg +12 -0
  301. package/themes/og-measure/sample/assets/data/measure.json +353 -0
  302. package/themes/og-measure/sample/beats.json +1 -0
  303. package/themes/og-measure/sample/index.html +11 -0
  304. package/themes/og-measure/sample/kf.json +66 -0
  305. package/themes/og-measure/sample/lib/data.ts +62 -0
  306. package/themes/og-measure/sample/main.ts +9 -0
  307. package/themes/og-measure/sample/measure.ts +78 -0
  308. package/themes/og-measure/sample/scenes/end.ts +47 -0
  309. package/themes/og-measure/sample/scenes/gather.ts +51 -0
  310. package/themes/og-measure/sample/scenes/models.ts +34 -0
  311. package/themes/og-measure/sample/scenes/one.ts +39 -0
  312. package/themes/og-measure/sample/scenes/price.ts +56 -0
  313. package/themes/og-measure/sample/style.json +97 -0
  314. package/themes/og-measure/still.png +0 -0
  315. package/themes/og-measure/style.json +97 -0
  316. package/themes/og-measure/theme.json +79 -0
  317. package/themes/riso-zine/THEME.md +43 -0
  318. package/themes/riso-zine/effects.ts +321 -0
  319. package/themes/riso-zine/sample/assets/og/logo.svg +12 -0
  320. package/themes/riso-zine/sample/beats.json +1 -0
  321. package/themes/riso-zine/sample/index.html +11 -0
  322. package/themes/riso-zine/sample/kf.json +59 -0
  323. package/themes/riso-zine/sample/lib/og.ts +29 -0
  324. package/themes/riso-zine/sample/lib/shot.ts +86 -0
  325. package/themes/riso-zine/sample/lib/zine.ts +42 -0
  326. package/themes/riso-zine/sample/main.ts +9 -0
  327. package/themes/riso-zine/sample/scenes/end.ts +61 -0
  328. package/themes/riso-zine/sample/scenes/gather.ts +62 -0
  329. package/themes/riso-zine/sample/scenes/models.ts +52 -0
  330. package/themes/riso-zine/sample/scenes/price.ts +82 -0
  331. package/themes/riso-zine/sample/style.json +99 -0
  332. package/themes/riso-zine/still.png +0 -0
  333. package/themes/riso-zine/style.json +99 -0
  334. package/themes/riso-zine/theme.json +75 -0
  335. package/tools/beats/beats.py +372 -0
  336. package/tools/beats/requirements.txt +3 -0
  337. package/tools/lyrics/align.py +164 -0
  338. package/tools/lyrics/requirements.txt +9 -0
@@ -0,0 +1,40 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import type { Command } from "../command.ts";
3
+ import { num, parseFlags } from "../command.ts";
4
+ import { openSession } from "../browser.ts";
5
+ import { fmtTime, outPath } from "../out.ts";
6
+ import { blurOpts, captureBlurred, rgbaToPng } from "../blur.ts";
7
+
8
+ const cmd: Command = {
9
+ summary: "한 시각을 PNG 한 장으로",
10
+ usage: `kf still <폴더> --t <초> [--out 파일.png]
11
+ --t 찍을 시각(초). --frame N 으로 프레임 번호를 줘도 된다
12
+ --out 기본 <폴더>/out/still-<t>.png
13
+ --motion-blur N --shutter 180 렌더와 같은 모션 블러(하위 프레임 N 장 평균)로 한 장
14
+ step 모드 페이지는 0 초부터 그 시각까지 돌린 뒤 찍는다.`,
15
+ async run(argv) {
16
+ const { args, opts } = parseFlags(argv);
17
+ const dir = args[0];
18
+ if (!dir) {
19
+ console.error("kf still: 폴더를 줘라 — kf still <폴더> --t 12.3");
20
+ return 2;
21
+ }
22
+ const s = await openSession(dir);
23
+ try {
24
+ const { fps } = s.info.meta;
25
+ const t = opts.frame !== undefined ? num(opts.frame, 0) / fps : num(opts.t, 0);
26
+ const file = outPath(dir, opts.out, `still-${fmtTime(t)}.png`);
27
+ const blur = blurOpts(opts);
28
+ if (blur && s.info.mode === "seek") writeFileSync(file, rgbaToPng(await captureBlurred(s.first, s.info, t, blur)));
29
+ else {
30
+ await s.first.seek(t);
31
+ writeFileSync(file, await s.first.capture());
32
+ }
33
+ console.log(file);
34
+ return 0;
35
+ } finally {
36
+ await s.close();
37
+ }
38
+ },
39
+ };
40
+ export default cmd;
@@ -0,0 +1,85 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { listThemes, themesDir } from "@kasaframe/themes";
4
+ import { briefs, review, scaffold, sessionPrompt, setStatus, staticCheck } from "@kasaframe/themes/factory";
5
+ import { type Command, num, parseFlags } from "../command.ts";
6
+
7
+ const kfBin = new URL("../kf.ts", import.meta.url).pathname;
8
+
9
+ const cmd: Command = {
10
+ summary: "테마 공장 — 참고 자료로 테마 팩 뼈대·지시서, 계약 검사, 견본 review, 사람 OK 뒤 approve",
11
+ usage: `kf theme list
12
+ kf theme new <id> --ref <그림·영상·폴더·URL> [--ref …] [--name 이름] [--like <기존 테마>] [--3d] [--brief ${briefs().join("|")}] [--force]
13
+ kf theme check <id> [--no-page] 계약·빈칸 검사 + kf check sample
14
+ kf theme review <id> [--tabs 2] 굽기 → mp4 검사 → 장면 모음·대표 장면 → 공유 폴더 → status review
15
+ kf theme approve <id> 사람이 견본을 보고 OK 한 뒤에만
16
+
17
+ new 는 themes/<id>/ 에 계약 파일 뼈대·refs/·브리프대로 자른 곡과 박·장면 넷 뼈대·THEME-MAKE.md(지시서)를 짓고,
18
+ 그 지시서를 받을 세션을 여는 명령을 알려 준다. 테마는 그 세션이 만든다.`,
19
+ async run(argv) {
20
+ const [sub, ...rest] = argv;
21
+ const { args, opts } = parseFlags(rest);
22
+ const id = args[0];
23
+ if (sub === "list" || !sub) {
24
+ const all = listThemes();
25
+ if (!all.length) console.log("(테마 없음)");
26
+ for (const t of all) console.log(`${t.id.padEnd(18)} ${t.status.padEnd(9)} ${String(t.approvedAt ?? "").padEnd(11)} 효과 ${String(t.effects.length).padStart(2)} · ${t.metaphor}`);
27
+ return 0;
28
+ }
29
+ if (!id) {
30
+ console.error(`kf theme ${sub}: 테마 id 가 필요하다. kf theme --help`);
31
+ return 2;
32
+ }
33
+ if (sub === "new") {
34
+ // --ref 는 여러 번 — parseFlags 는 마지막 하나만 남기니 따로 모은다
35
+ const refs = rest.flatMap((a, i) => (a === "--ref" && rest[i + 1] ? [rest[i + 1]] : a.startsWith("--ref=") ? [a.slice(6)] : []));
36
+ try {
37
+ const r = scaffold(id, {
38
+ refs,
39
+ name: typeof opts.name === "string" ? opts.name : undefined,
40
+ like: typeof opts.like === "string" ? opts.like : undefined,
41
+ brief: typeof opts.brief === "string" ? opts.brief : undefined,
42
+ kind: opts["3d"] ? "3d" : "flat",
43
+ force: !!opts.force,
44
+ });
45
+ console.log(`${r.dir}/ — 참고 자료 ${r.refs.length}개${r.urls.length ? ` · 주소 ${r.urls.length}개` : ""}`);
46
+ console.log(`지시서: ${r.make}`);
47
+ console.log(`\n세션 열기(레포 뿌리에서):\n claude --model opus "${sessionPrompt(id)}"`);
48
+ return 0;
49
+ } catch (e) {
50
+ console.error(`kf theme new: ${(e as Error).message}`);
51
+ return 2;
52
+ }
53
+ }
54
+ if (sub === "check") {
55
+ const bad = await staticCheck(id);
56
+ for (const b of bad) console.log(`[오류] ${b}`);
57
+ if (!bad.length) console.log("계약·빈칸: 통과");
58
+ let page = 0;
59
+ if (!opts["no-page"]) page = spawnSync(process.execPath, [kfBin, "check", join(themesDir, id, "sample"), "--tabs", String(num(opts.tabs, 2))], { stdio: "inherit" }).status ?? 1;
60
+ return bad.length || page ? 1 : 0;
61
+ }
62
+ if (sub === "review") {
63
+ const bad = await staticCheck(id);
64
+ if (bad.length) {
65
+ for (const b of bad) console.log(`[오류] ${b}`);
66
+ console.error("kf theme review: 계약·빈칸 오류부터 — kf theme check");
67
+ return 1;
68
+ }
69
+ const r = review(id, { tabs: num(opts.tabs, 2), brief: typeof opts.brief === "string" ? opts.brief : undefined });
70
+ for (const n of r.notes) console.log(n);
71
+ if (!r.ok) return 1;
72
+ console.log(`status review · 공유 폴더 ${r.share}`);
73
+ return 0;
74
+ }
75
+ if (sub === "approve") {
76
+ const m = setStatus(id, "approved");
77
+ console.log(`${id} approved ${m.approvedAt} — 사람이 견본을 보고 OK 한 테마만`);
78
+ return 0;
79
+ }
80
+ console.error(`kf theme: 모르는 하위 명령 "${sub}". kf theme --help`);
81
+ return 2;
82
+ },
83
+ };
84
+
85
+ export default cmd;
@@ -0,0 +1,40 @@
1
+ import type { Command } from "../command.ts";
2
+ import { parseFlags } from "../command.ts";
3
+ import { CATALOG, type TransitionEntry } from "@kasaframe/motion/transitions2d";
4
+
5
+ const USE: Record<TransitionEntry["use"], string> = { transition3d: "이름으로", react: "React 부품", vendor: "원본만" };
6
+
7
+ const cmd: Command = {
8
+ summary: "전환 목록 — 이름·출처·라이선스·seek/step",
9
+ usage: `kf transitions [찾을 말] [--source 출처] [--clock seek|step] [--use transition3d|react|vendor] [--json]
10
+ 이름으로(transition3d) 인 것은 장면에서 transition3d(root, ctx, { kind: "<이름>" }) 로 부른다 — 예 gl:crosswarp · smooth:sdf-circle · dive
11
+ React 부품은 @kasaframe/motion/transitions2d/react, 원본만은 geono-hub vendor/transitions/ 에 있다
12
+ step 은 자기 시계(rAF·타이머)라 kf.json 의 mode 를 "step" 으로`,
13
+ async run(argv) {
14
+ const { args, opts } = parseFlags(argv);
15
+ const has = (v: string, q: unknown) => typeof q !== "string" || v.toLowerCase().includes(q.toLowerCase());
16
+ const list = CATALOG.filter(
17
+ (e) =>
18
+ has(e.source, opts.source) &&
19
+ (typeof opts.clock !== "string" || e.clock === opts.clock) &&
20
+ (typeof opts.use !== "string" || e.use === opts.use) &&
21
+ args.every((q) => has(`${e.name} ${e.note ?? ""} ${e.author ?? ""}`, q)),
22
+ );
23
+ if (opts.json) {
24
+ console.log(JSON.stringify(list, null, 2));
25
+ return 0;
26
+ }
27
+ const count = (k: (e: TransitionEntry) => string) => [...list.reduce((m, e) => m.set(k(e), (m.get(k(e)) ?? 0) + 1), new Map<string, number>())].map(([k, n]) => `${k} ${n}`).join(" · ");
28
+ console.log(`${list.length}개 — ${count((e) => e.source)}`);
29
+ console.log(`시계 ${count((e) => e.clock)} · ${count((e) => USE[e.use])}\n`);
30
+ const w = Math.max(...list.map((e) => e.name.length), 4) + 2;
31
+ const sw = Math.max(...list.map((e) => e.source.length), 4) + 2;
32
+ const lw = Math.max(...list.map((e) => e.license.length), 4) + 2;
33
+ for (const e of list) {
34
+ const extra = [e.component && `<${e.component}>`, e.author, e.note].filter(Boolean).join(" — ");
35
+ console.log(`${e.name.padEnd(w)}${e.source.padEnd(sw)}${e.license.padEnd(lw)}${e.clock.padEnd(6)}${USE[e.use].padEnd(9)}${extra}`);
36
+ }
37
+ return 0;
38
+ },
39
+ };
40
+ export default cmd;
@@ -0,0 +1,122 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { homedir, platform } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { hasKey, whereToPut } from "@kasaframe/gen";
8
+ import { jizuraCheck } from "@kasaframe/jizura/install";
9
+ import { launch } from "./browser.ts";
10
+ import { lyricsCheck } from "./commands/lyrics.ts";
11
+
12
+ // kf doctor·kf setup 이 같이 보는 바깥 도구들 — npm 이 못 까는 것(ffmpeg·파이썬 꾸러미·크롬 본체)만.
13
+
14
+ export const requirements = fileURLToPath(new URL("../../../tools/beats/requirements.txt", import.meta.url));
15
+ // 요즘 파이썬(Homebrew·데비안)은 시스템에 pip 설치를 막는다(PEP 668) — kf setup 은 쓰임마다 venv 를 따로 둔다
16
+ export const pyVenv = (name: string): string => join(homedir(), ".cache", "kasaframe", "py", name);
17
+ export const venvPython = (dir: string): string => join(dir, platform() === "win32" ? "Scripts/python.exe" : "bin/python");
18
+ export const beatsVenv = pyVenv("beats");
19
+
20
+ export function python(): string {
21
+ return process.env.KF_PYTHON ?? (existsSync(venvPython(beatsVenv)) ? venvPython(beatsVenv) : "python3");
22
+ }
23
+
24
+ // npx playwright 는 다른 판의 playwright 를 집을 수 있다 — 크롬 판이 이 패키지가 쓰는 playwright 와 맞아야 뜬다
25
+ export const playwrightCli = (): string => join(dirname(createRequire(import.meta.url).resolve("playwright/package.json")), "cli.js");
26
+
27
+ export interface Check {
28
+ name: string;
29
+ ok: boolean;
30
+ /** 없으면 렌더가 안 된다 */
31
+ required: boolean;
32
+ detail: string;
33
+ fix?: string;
34
+ }
35
+
36
+ const run = (cmd: string, args: string[]) => spawnSync(cmd, args, { encoding: "utf8" });
37
+
38
+ function installHint(pkg: { brew: string; apt: string; url: string }): string {
39
+ if (platform() === "darwin") return `brew install ${pkg.brew}`;
40
+ if (platform() === "linux") return `sudo apt-get install -y ${pkg.apt} (데비안·우분투 — 다른 배포판은 그 패키지 관리자로)`;
41
+ return pkg.url;
42
+ }
43
+
44
+ function node(): Check {
45
+ const [major, minor] = process.versions.node.split(".").map(Number);
46
+ const ok = major > 22 || (major === 22 && minor >= 18);
47
+ return { name: "Node 22.18 이상", ok, required: true, detail: process.version, fix: ok ? undefined : "https://nodejs.org 에서 22 LTS 이상" };
48
+ }
49
+
50
+ function ffmpeg(): Check {
51
+ const r = run("ffmpeg", ["-hide_banner", "-version"]);
52
+ const p = run("ffprobe", ["-hide_banner", "-version"]);
53
+ const ok = r.status === 0 && p.status === 0;
54
+ return {
55
+ name: "ffmpeg·ffprobe",
56
+ ok,
57
+ required: true,
58
+ detail: ok ? (/version (\S+)/.exec(r.stdout)?.[1] ?? "있음") : "없음 — MP4 로 굽기·검사에 쓴다",
59
+ fix: ok ? undefined : installHint({ brew: "ffmpeg", apt: "ffmpeg", url: "https://ffmpeg.org/download.html" }),
60
+ };
61
+ }
62
+
63
+ async function chrome(): Promise<Check> {
64
+ try {
65
+ const b = await launch();
66
+ const v = b.version();
67
+ await b.close();
68
+ return { name: "크롬(Playwright)", ok: true, required: true, detail: v };
69
+ } catch (err) {
70
+ const first = String(err instanceof Error ? err.message : err).split("\n")[0];
71
+ return { name: "크롬(Playwright)", ok: false, required: true, detail: `못 띄웠다 — ${first.slice(0, 120)}`, fix: `kf setup chrome (같은 일: node ${playwrightCli()} install chromium${platform() === "linux" ? " · 리눅스는 sudo 로 install-deps chromium 도" : ""})` };
72
+ }
73
+ }
74
+
75
+ function librosa(): Check {
76
+ const py = python();
77
+ const r = run(py, ["-c", "import librosa; print(librosa.__version__)"]);
78
+ const ok = r.status === 0;
79
+ return {
80
+ name: "python3 + librosa",
81
+ ok,
82
+ required: false,
83
+ detail: ok ? `${py} · librosa ${r.stdout.trim()}` : `${py} 에 없다 — 곡에 박자를 맞출 때(kf beats·--music)만 쓴다`,
84
+ fix: ok ? undefined : `kf setup beats (같은 일: python3 -m venv ${beatsVenv} && ${venvPython(beatsVenv)} -m pip install -r ${requirements})`,
85
+ };
86
+ }
87
+
88
+ function ogKey(): Check {
89
+ const ok = hasKey("opengateway");
90
+ return {
91
+ name: "OG 키",
92
+ ok,
93
+ required: false,
94
+ detail: ok ? "있음 — 그림 생성(kf gen)·kf make OG 모드" : "없음 — 그림 생성(kf gen)과 kf make OG 모드만 못 쓴다. 장면·렌더·검사는 키 없이 돈다",
95
+ fix: ok ? undefined : whereToPut("opengateway"),
96
+ };
97
+ }
98
+
99
+ // 글꼴은 CDN(jsdelivr)에서 받지만, 망이 막힌 리눅스 상자에서는 한글이 두부로 나온다
100
+ function koreanFont(): Check | null {
101
+ if (platform() !== "linux") return null;
102
+ const r = run("fc-list", [":lang=ko", "family"]);
103
+ const ok = r.status === 0 && r.stdout.trim().length > 0;
104
+ return { name: "한글 글꼴", ok, required: false, detail: ok ? r.stdout.trim().split("\n")[0] : "없음 — 글꼴 CDN 이 막히면 한글이 두부로 나온다", fix: ok ? undefined : "sudo apt-get install -y fonts-noto-cjk" };
105
+ }
106
+
107
+ // 점검을 더할 땐 여기 한 줄 — kf doctor 와 kf setup 끝 표가 같이 쓴다
108
+ export async function checks(): Promise<Check[]> {
109
+ return [node(), ffmpeg(), await chrome(), librosa(), jizuraCheck(), lyricsCheck(), ogKey(), koreanFont()].filter((c): c is Check => c !== null);
110
+ }
111
+
112
+ // 한글은 터미널에서 두 칸을 먹는다 — padEnd 로는 열이 어긋난다
113
+ const cols = (s: string) => [...s].reduce((n, ch) => n + (/[\u1100-\u11ff\u3000-\u9fff\uac00-\ud7af]/.test(ch) ? 2 : 1), 0);
114
+ const pad = (s: string, n: number) => s + " ".repeat(Math.max(0, n - cols(s)));
115
+
116
+ export function report(list: Check[]): void {
117
+ const w = Math.max(...list.map((c) => cols(c.name))) + 2;
118
+ for (const c of list) {
119
+ console.log(` ${pad(c.ok ? "ok" : c.required ? "없음" : "선택", 4)} ${pad(c.name, w)}${c.detail}`);
120
+ if (c.fix) console.log(` ${pad("", 4 + 2 + w)}→ ${c.fix}`);
121
+ }
122
+ }
@@ -0,0 +1,81 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { once } from "node:events";
3
+
4
+ export type VideoFormat = "h264" | "prores";
5
+
6
+ export interface Encoder {
7
+ /** PNG, raw 로 만들었으면 RGBA 날것 */
8
+ write(frame: Buffer): Promise<void>;
9
+ end(): Promise<void>;
10
+ }
11
+
12
+ function run(args: string[], label: string): { proc: ChildProcess; done: Promise<void> } {
13
+ const proc = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...args], {
14
+ stdio: ["pipe", "ignore", "pipe"],
15
+ });
16
+ let err = "";
17
+ proc.stderr!.on("data", (d) => (err += d));
18
+ const done = new Promise<void>((resolve, reject) => {
19
+ proc.on("error", (e) => reject(new Error(`${label}: ffmpeg 를 못 띄웠다 (${e.message})`)));
20
+ proc.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`${label}: ffmpeg 종료 ${code}\n${err.trim()}`))));
21
+ });
22
+ return { proc, done };
23
+ }
24
+
25
+ // RGB→YUV 를 BT.709 로 바꾸고 그렇다고 적는다. 안 적으면 ffmpeg 는 BT.601 로 바꾸는데 브라우저·유튜브는 HD 를 709 로 풀어
26
+ // 색이 어긋난다(#20C040 이 (20,168,61) 로 — 초록이 눌린다)
27
+ const BT709 = ["-vf", "scale=out_color_matrix=bt709:out_range=tv,setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"];
28
+
29
+ const CODEC: Record<VideoFormat, string[]> = {
30
+ h264: ["-c:v", "libx264", "-preset", "medium", "-crf", "16", "-pix_fmt", "yuv420p", ...BT709, "-movflags", "+faststart"],
31
+ prores: ["-c:v", "prores_ks", "-profile:v", "3", "-pix_fmt", "yuv422p10le", ...BT709, "-vendor", "apl0"],
32
+ };
33
+
34
+ // PNG(또는 raw 를 주면 RGBA 날것 — 모션 블러가 평균한 프레임)를 stdin 으로 흘려 구간 파일 하나를 만든다. 쓰기가 밀리면 기다려 메모리가 차지 않게 한다.
35
+ export function encoder(out: string, fps: number, format: VideoFormat, crf?: number, raw?: { width: number; height: number }): Encoder {
36
+ const codec = [...CODEC[format]];
37
+ if (crf !== undefined && format === "h264") codec[codec.indexOf("-crf") + 1] = String(crf);
38
+ const input = raw
39
+ ? ["-f", "rawvideo", "-pix_fmt", "rgba", "-s", `${raw.width}x${raw.height}`, "-framerate", String(fps), "-i", "-"]
40
+ : ["-f", "image2pipe", "-framerate", String(fps), "-c:v", "png", "-i", "-"];
41
+ const { proc, done } = run([...input, ...codec, "-r", String(fps), out], out);
42
+ const stdin = proc.stdin!;
43
+ let failed: Error | null = null;
44
+ done.catch((e) => (failed = e));
45
+ return {
46
+ async write(frame) {
47
+ if (failed) throw failed;
48
+ if (!stdin.write(frame)) await Promise.race([once(stdin, "drain"), done]);
49
+ },
50
+ async end() {
51
+ stdin.end();
52
+ await done;
53
+ },
54
+ };
55
+ }
56
+
57
+ // 구간들을 다시 인코딩하지 않고 잇고, 곡이 있으면 그 구간만큼 잘라 얹는다.
58
+ export async function concat(o: {
59
+ list: string;
60
+ out: string;
61
+ format: VideoFormat;
62
+ music?: string;
63
+ from: number;
64
+ dur: number;
65
+ }): Promise<void> {
66
+ const args = ["-f", "concat", "-safe", "0", "-i", o.list];
67
+ if (o.music) args.push("-ss", o.from.toFixed(6), "-i", o.music);
68
+ args.push("-map", "0:v:0");
69
+ if (o.music) {
70
+ args.push("-map", "1:a:0", ...(o.format === "prores" ? ["-c:a", "pcm_s16le"] : ["-c:a", "aac", "-b:a", "256k"]));
71
+ args.push("-af", `apad`, "-t", o.dur.toFixed(6));
72
+ }
73
+ args.push("-c:v", "copy");
74
+ // concat 이 구간 파일의 primaries·trc 태그를 떨군다 — 다시 인코딩 없이 비트스트림에 709 를 다시 적는다
75
+ args.push("-bsf:v", o.format === "h264" ? "h264_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1" : "prores_metadata=color_primaries=bt709:color_trc=bt709:colorspace=bt709");
76
+ args.push("-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709");
77
+ if (o.format === "h264") args.push("-movflags", "+faststart");
78
+ args.push(o.out);
79
+ const { done } = run(args, o.out);
80
+ await done;
81
+ }
@@ -0,0 +1,8 @@
1
+ export type { Command, Flags } from "./command.ts";
2
+ export { parseFlags, num } from "./command.ts";
3
+ export { openProject, repoRoot } from "./serve.ts";
4
+ export type { OpenProject, OpenProjectOptions } from "./serve.ts";
5
+ export { openPage, openSession, launch, Tab } from "./browser.ts";
6
+ export type { OpenPage, PageInfo, PageMeta, Session, SessionOptions } from "./browser.ts";
7
+ export { blurOpts, captureBlurred, rgbaToPng, subTimes } from "./blur.ts";
8
+ export type { BlurOpts } from "./blur.ts";
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ import { readdirSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { Command } from "./command.ts";
5
+
6
+ const commandsDir = fileURLToPath(new URL("./commands/", import.meta.url));
7
+
8
+ function available(): string[] {
9
+ return readdirSync(commandsDir)
10
+ .filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"))
11
+ .map((f) => f.slice(0, -3))
12
+ .sort();
13
+ }
14
+
15
+ async function load(name: string): Promise<Command> {
16
+ const mod = await import(new URL(`./commands/${name}.ts`, import.meta.url).href);
17
+ return mod.default as Command;
18
+ }
19
+
20
+ async function help(): Promise<void> {
21
+ console.log("kf <명령> [인자]\n");
22
+ for (const name of available()) {
23
+ const cmd = await load(name).catch(() => null);
24
+ console.log(` ${name.padEnd(10)} ${cmd?.summary ?? ""}`);
25
+ }
26
+ console.log("\nkf <명령> --help 로 인자를 본다.");
27
+ }
28
+
29
+ async function main(argv: string[]): Promise<number> {
30
+ const [name, ...rest] = argv;
31
+ if (!name || name === "help" || name === "--help" || name === "-h") {
32
+ await help();
33
+ return 0;
34
+ }
35
+ if (!available().includes(name)) {
36
+ console.error(`kf: 모르는 명령 "${name}". 있는 명령: ${available().join(" ")}`);
37
+ return 2;
38
+ }
39
+ const cmd = await load(name);
40
+ if (rest.includes("--help") || rest.includes("-h")) {
41
+ console.log(`kf ${name} — ${cmd.summary}\n\n${cmd.usage ?? ""}`.trimEnd());
42
+ return 0;
43
+ }
44
+ return (await cmd.run(rest)) ?? 0;
45
+ }
46
+
47
+ main(process.argv.slice(2)).then(
48
+ (code) => process.exit(code),
49
+ (err) => {
50
+ console.error(err instanceof Error ? (err.stack ?? err.message) : err);
51
+ process.exit(1);
52
+ },
53
+ );
@@ -0,0 +1,72 @@
1
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { installed, repoRoot } from "./serve.ts";
5
+
6
+ const LOCK = installed ? join(tmpdir(), "kasaframe-render-lock") : join(repoRoot, ".render-lock");
7
+
8
+ function alive(pid: number): boolean {
9
+ try {
10
+ process.kill(pid, 0);
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ function owner(): { pid: number; dir: string; started: string } | null {
18
+ try {
19
+ return JSON.parse(readFileSync(join(LOCK, "owner.json"), "utf8"));
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ // 최종 렌더는 레포 전체에서 하나씩 — mkdir 이 원자적이라 먼저 만든 쪽만 이긴다.
26
+ export async function acquireRenderLock(dir: string, waitSec: number): Promise<() => void> {
27
+ const until = Date.now() + waitSec * 1000;
28
+ let told = false;
29
+ for (;;) {
30
+ try {
31
+ mkdirSync(LOCK);
32
+ writeFileSync(join(LOCK, "owner.json"), JSON.stringify({ pid: process.pid, dir, started: new Date().toISOString() }));
33
+ let held = true;
34
+ const release = () => {
35
+ if (!held) return;
36
+ held = false;
37
+ if (owner()?.pid === process.pid) rmSync(LOCK, { recursive: true, force: true });
38
+ };
39
+ process.once("exit", release);
40
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
41
+ process.once(sig, () => {
42
+ release();
43
+ process.exit(130);
44
+ });
45
+ }
46
+ return release;
47
+ } catch (err) {
48
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
49
+ }
50
+ const o = owner();
51
+ // 남이 mkdir 하고 owner.json 을 쓰기 전 찰나일 수 있으니, 주인 없는 잠금은 10초 지난 것만 치운다.
52
+ const age = (() => {
53
+ try {
54
+ return Date.now() - statSync(LOCK).mtimeMs;
55
+ } catch {
56
+ return 0;
57
+ }
58
+ })();
59
+ if ((o && !alive(o.pid)) || (!o && age > 10_000)) {
60
+ rmSync(LOCK, { recursive: true, force: true });
61
+ continue;
62
+ }
63
+ if (Date.now() > until) {
64
+ throw new Error(`렌더 잠금을 못 얻었다 — ${o ? `pid ${o.pid} 가 ${o.dir} 를 ${o.started} 부터 굽는 중` : LOCK}`);
65
+ }
66
+ if (!told) {
67
+ console.error(`렌더 잠금 기다리는 중${o ? ` (pid ${o.pid}: ${o.dir})` : ""} — 최대 ${waitSec}초`);
68
+ told = true;
69
+ }
70
+ await new Promise((r) => setTimeout(r, 2000));
71
+ }
72
+ }
@@ -0,0 +1,13 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ // 결과물은 기본으로 프로젝트의 out/ 에 둔다.
5
+ export function outPath(dir: string, given: string | boolean | undefined, fallback: string): string {
6
+ const p = typeof given === "string" ? resolve(given) : join(resolve(dir), "out", fallback);
7
+ mkdirSync(dirname(p), { recursive: true });
8
+ return p;
9
+ }
10
+
11
+ export function fmtTime(t: number): string {
12
+ return t.toFixed(3).replace(/\.?0+$/, "") || "0";
13
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync, readdirSync, realpathSync } from "node:fs";
2
+ import { resolve, join, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createServer, type Plugin, type ViteDevServer } from "vite";
5
+ import { clockScript, type ClockOptions } from "@kasaframe/clock";
6
+ import { jizuraServe } from "@kasaframe/jizura/vite";
7
+
8
+ export const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
9
+ // npm 으로 깐 패키지 안에서 도는가 — 그 자리는 쓸 수 없을 수 있어 잠금·작업실 폴더를 밖에 둔다
10
+ export const installed = repoRoot.includes(`${sep}node_modules${sep}`);
11
+
12
+ export interface OpenProjectOptions {
13
+ // true 면 index.html 맨 앞에 가상 시계를 넣는다. Playwright 로 열 땐 addInitScript 가 따로 넣으니 끈다.
14
+ clock?: boolean | ClockOptions;
15
+ port?: number;
16
+ // 미리보기처럼 저장하면 반영돼야 하는 쪽만 켠다. 렌더·검사는 끈다.
17
+ hmr?: boolean;
18
+ // 엔진 플러그인 뒤에 붙는다. 미리보기가 studio 화면을 / 에 내는 데 쓴다.
19
+ plugins?: Plugin[];
20
+ }
21
+
22
+ export interface OpenProject {
23
+ url: string;
24
+ dir: string;
25
+ server: ViteDevServer;
26
+ close(): Promise<void>;
27
+ }
28
+
29
+ // 레포 밖 프로젝트가 import 하는 @kasaframe/*·react 같은 맨 이름을 프로젝트에서 못 찾으면 이 레포에서 찾는다.
30
+ function repoFallback(): Plugin {
31
+ const anchor = join(repoRoot, "package.json");
32
+ return {
33
+ name: "kf-repo-fallback",
34
+ enforce: "pre",
35
+ async resolveId(id, importer, opts) {
36
+ if (/^[./\0]/.test(id) || id.includes(":")) return null;
37
+ // 설치된 패키지에는 워크스페이스 링크가 없다 — Node 가 푸는 자리(레포는 링크, 패키지는 bin/hooks.mjs)로 보낸다
38
+ if (id.startsWith("@kasaframe/")) {
39
+ const file = kasaframeFile(id);
40
+ return file ? this.resolve(file, importer, { ...opts, skipSelf: true }) : null;
41
+ }
42
+ if (!importer || importer.startsWith(repoRoot)) return null;
43
+ const own = await this.resolve(id, importer, { ...opts, skipSelf: true });
44
+ if (own) return own;
45
+ return this.resolve(id, anchor, { ...opts, skipSelf: true });
46
+ },
47
+ };
48
+ }
49
+
50
+ function kasaframeFile(id: string): string | null {
51
+ try {
52
+ return fileURLToPath(import.meta.resolve(id));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ const kasaframePackages = (): string[] =>
59
+ readdirSync(join(repoRoot, "packages")).filter((p) => existsSync(join(repoRoot, "packages", p, "package.json"))).map((p) => `@kasaframe/${p}`);
60
+
61
+ function browserEntries(): string[] {
62
+ return [
63
+ "packages/adapters/src/react.ts",
64
+ "packages/motion/src/**/*.{ts,tsx}",
65
+ "packages/themes/src/fx.ts",
66
+ "packages/themes/src/stage3d.ts",
67
+ "themes/*/effects.ts",
68
+ ].map((p) => join(repoRoot, p));
69
+ }
70
+
71
+ function injectClock(opts: ClockOptions): Plugin {
72
+ return {
73
+ name: "kf-clock",
74
+ async transformIndexHtml() {
75
+ return [{ tag: "script", children: await clockScript(opts), injectTo: "head-prepend" }];
76
+ },
77
+ };
78
+ }
79
+
80
+ export async function openProject(dir: string, o: OpenProjectOptions = {}): Promise<OpenProject> {
81
+ if (!existsSync(join(resolve(dir), "index.html"))) {
82
+ throw new Error(`${resolve(dir)} 에 index.html 이 없다 — kf new 로 만든 프로젝트 폴더를 줘라`);
83
+ }
84
+ // Vite 는 importer 를 실제 경로로 준다(/tmp → /private/tmp). 경로 비교가 어긋나지 않게 처음부터 실제 경로로.
85
+ const root = realpathSync(resolve(dir));
86
+ const plugins: Plugin[] = [repoFallback(), jizuraServe()];
87
+ if (o.clock) plugins.push(injectClock(o.clock === true ? {} : o.clock));
88
+ plugins.push(...(o.plugins ?? []));
89
+ const server = await createServer({
90
+ root,
91
+ configFile: existsSync(join(root, "vite.config.ts")) ? undefined : false,
92
+ logLevel: "warn",
93
+ clearScreen: false,
94
+ plugins,
95
+ esbuild: { jsx: "automatic" },
96
+ define: {
97
+ __VUE_OPTIONS_API__: "true",
98
+ __VUE_PROD_DEVTOOLS__: "false",
99
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
100
+ },
101
+ // 레포 밖 컴포넌트가 자기 node_modules 의 React 를 끌고 오면 훅이 깨진다 — 한 벌로 묶는다.
102
+ resolve: { dedupe: ["react", "react-dom"] },
103
+ // 렌더는 HMR 이 꺼져 있어, 실행 중에 새 의존성이 나오면 다시 묶은 판으로 갈아탈 수 없다(CJS 가 그대로 가서 named export 가 빠진다).
104
+ // 그래서 처음 훑을 때 packages/ 의 브라우저 쪽 소스까지 같이 훑게 한다. 이 스캔은 repoFallback 을 거쳐 레포 밖 루트에서도 풀린다.
105
+ // @kasaframe/* 는 미리 묶지 않고 소스 그대로 낸다 — 설치된 패키지에선 node_modules 안이라 안 빼면 따로 묶여 한 모듈이 두 벌 된다.
106
+ // 빼면 훑기가 그 안으로 안 들어가므로 위 진입점이 그 몫을 한다.
107
+ optimizeDeps: {
108
+ entries: ["**/*.html", ...browserEntries()],
109
+ exclude: kasaframePackages(),
110
+ },
111
+ server: {
112
+ host: "127.0.0.1",
113
+ port: o.port ?? 5100,
114
+ strictPort: false,
115
+ hmr: o.hmr ?? false,
116
+ // 이 맥에서 폴더 단위 fs.watch(FSEvents)가 이벤트를 하나도 안 준다(파일 단위 kqueue 만 산다). 프로젝트는 작으니 폴링이 확실하다.
117
+ watch: o.hmr ? { usePolling: true, interval: 150 } : null,
118
+ fs: { allow: [root, repoRoot] },
119
+ },
120
+ });
121
+ await server.listen();
122
+ const url = server.resolvedUrls?.local[0] ?? `http://127.0.0.1:${server.config.server.port}/`;
123
+ return { url, dir: root, server, close: () => server.close() };
124
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@kasaframe/clock",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./types": "./src/types.ts",
9
+ "./install": "./src/install.ts"
10
+ },
11
+ "dependencies": {
12
+ "esbuild": "^0.28.2"
13
+ }
14
+ }