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,176 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, join, relative, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { themeStyle } from "@kasaframe/themes";
6
+ import { type Aspect, type BeatsLike, type Plan, type Purpose, listTemplates, loadTemplate, makePlan, parseTitle, PURPOSES, sceneSkeleton, templatesDir } from "@kasaframe/plan";
7
+ import { type Command, num, parseFlags } from "../command.ts";
8
+
9
+ const repo = fileURLToPath(new URL("../../../../", import.meta.url));
10
+ const kfBin = fileURLToPath(new URL("../kf.ts", import.meta.url));
11
+
12
+ const cmd: Command = {
13
+ summary: "용도 틀에서 프로젝트 뼈대 — 곡 분석·박에 맞춘 장면 시간표·장면 파일",
14
+ usage: `kf new <폴더> --template <용도>(또는 --purpose) [--music 곡.mp3] [--style 프리셋] [--duration 초] [--aspect 9:16] [--title 이름] [--line 끝 카드 한 줄]
15
+ kf new <폴더> --request "요청 글" [--music 곡.mp3] 용도를 요청 글로 고른다
16
+ kf new <폴더> --plan plan.json kf make 가 세운 계획 그대로
17
+
18
+ 용도: ${PURPOSES.join(" · ")}
19
+ --theme 테마 id(themes/<id>, approved·review) 또는 auto(내용에 맞는 approved 후보 셋 중 첫째) — 고르면 그 테마 style 을 쓴다
20
+ --force 비어 있지 않은 폴더에도 쓴다(있는 장면 파일은 그대로 둔다)
21
+ --preview 다 만들고 kf preview 를 띄운다
22
+
23
+ 만든 뒤: scenes/<id>.ts 를 kf.json 의 idea 대로 채운다 → kf preview <폴더> → kf render <폴더> → kf check <폴더>`,
24
+ async run(argv) {
25
+ const { args, opts } = parseFlags(argv);
26
+ const dir = args[0] ? resolve(args[0]) : undefined;
27
+ if (!dir) {
28
+ console.error("kf new: 폴더가 필요하다. kf new --help");
29
+ return 2;
30
+ }
31
+ if (existsSync(dir) && readdirSync(dir).length > 0 && !opts.force) {
32
+ console.error(`kf new: ${dir} 가 비어 있지 않다. 덮어쓰려면 --force`);
33
+ return 2;
34
+ }
35
+ const str = (k: string) => (typeof opts[k] === "string" ? (opts[k] as string) : undefined);
36
+ // --purpose 는 kf make 와 같은 이름 — 둘 다 받는다
37
+ const template = str("template") ?? str("purpose");
38
+ if (template && !PURPOSES.includes(template as Purpose)) {
39
+ console.error(`kf new: 모르는 용도 "${template}". 있는 용도: ${listTemplates().map((t) => `${t.id}(${t.label})`).join(" ")}`);
40
+ return 2;
41
+ }
42
+ const planFile = str("plan");
43
+ const request = str("request");
44
+ if (!template && !planFile && !request) {
45
+ console.error(`kf new: --template · --request · --plan 중 하나가 필요하다. 용도: ${PURPOSES.join(" · ")}`);
46
+ return 2;
47
+ }
48
+
49
+ mkdirSync(join(dir, "scenes"), { recursive: true });
50
+ mkdirSync(join(dir, "assets", "gen"), { recursive: true });
51
+ mkdirSync(join(dir, "out"), { recursive: true });
52
+
53
+ const given: Plan | null = planFile ? JSON.parse(readFileSync(resolve(planFile), "utf8")) : null;
54
+ const musicSrc = str("music") ?? (given?.music && planFile ? resolve(planFile, "..", given.music) : undefined);
55
+ let music: string | null = null;
56
+ let beats: BeatsLike | null = null;
57
+ if (musicSrc) {
58
+ if (!existsSync(musicSrc)) {
59
+ console.error(`kf new: 곡이 없다: ${musicSrc}`);
60
+ return 2;
61
+ }
62
+ music = `assets/music/${basename(musicSrc)}`;
63
+ mkdirSync(join(dir, "assets", "music"), { recursive: true });
64
+ if (resolve(musicSrc) !== join(dir, music)) copyFileSync(musicSrc, join(dir, music));
65
+ const r = spawnSync(process.execPath, [kfBin, "beats", join(dir, music), "--out", join(dir, "beats.json")], { stdio: ["ignore", "inherit", "inherit"] });
66
+ if (r.status !== 0) {
67
+ console.error("kf new: 곡 분석(kf beats)이 실패했다 — 위 메시지를 보고 고친 뒤 다시");
68
+ return r.status ?? 1;
69
+ }
70
+ beats = JSON.parse(readFileSync(join(dir, "beats.json"), "utf8"));
71
+ }
72
+
73
+ const plan: Plan = given
74
+ ? { ...given, music }
75
+ : makePlan({
76
+ request: request ?? "",
77
+ purpose: template as Purpose | undefined,
78
+ music,
79
+ beats,
80
+ duration: opts.duration !== undefined ? num(opts.duration, 0) : undefined,
81
+ aspect: str("aspect") as Aspect | undefined,
82
+ style: str("style"),
83
+ title: str("title") ?? parseTitle(request ?? "") ?? basename(dir),
84
+ theme: str("theme"),
85
+ });
86
+ const purposeQ = plan.questions?.find((q) => q.id === "purpose");
87
+ if (purposeQ && !template) {
88
+ console.error(`kf new: 용도를 못 정했다(확신 ${plan.confidence}). --template 으로 골라 다시:\n ${purposeQ.choices.join("\n ")}`);
89
+ return 3;
90
+ }
91
+ if (!music && plan.questions?.some((q) => q.id === "music")) console.error(`kf new: 주의 — ${plan.questions.find((q) => q.id === "music")!.ask} (--music 으로 주면 박에 맞춘다)`);
92
+
93
+ const tpl = loadTemplate(plan.purpose);
94
+ let style;
95
+ if (plan.theme) style = themeStyle(plan.theme);
96
+ else {
97
+ let preset = join(repo, "presets", `${plan.style}.json`);
98
+ // 틀·요청 낱말이 고른 프리셋이 이 설치에 없을 수 있다(npm 패키지는 개인 프리셋을 안 싣는다) — 사람이 고른 게 아니면 기본으로
99
+ if (!existsSync(preset) && !str("style")) {
100
+ console.error(`kf new: 프리셋 "${plan.style}" 가 없어 neutral-dark 로`);
101
+ plan.style = "neutral-dark";
102
+ preset = join(repo, "presets", "neutral-dark.json");
103
+ }
104
+ if (!existsSync(preset)) {
105
+ const have = readdirSync(join(repo, "presets")).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5));
106
+ console.error(`kf new: 프리셋 "${plan.style}" 가 없다. 있는 프리셋: ${have.join(" ")}`);
107
+ return 2;
108
+ }
109
+ style = JSON.parse(readFileSync(preset, "utf8"));
110
+ }
111
+ // 틀의 움직임 규칙이 프리셋을 이긴다 — 쇼츠에 박 펄스가 켜진 프리셋을 골라도 쇼츠 규칙대로
112
+ style.motion = { ...style.motion, ...tpl.motion };
113
+
114
+ const frame = { width: plan.width, height: plan.height };
115
+ const safeArea = plan.aspect === tpl.aspect ? tpl.safeArea : (tpl.variants?.[plan.aspect]?.safeArea ?? tpl.safeArea);
116
+ const kf = {
117
+ template: plan.purpose,
118
+ title: plan.title,
119
+ ...(plan.theme ? { theme: plan.theme } : {}),
120
+ fps: plan.fps,
121
+ ...frame,
122
+ duration: plan.duration,
123
+ mode: "seek",
124
+ style: "style.json",
125
+ music,
126
+ beats: beats ? "beats.json" : null,
127
+ captions: plan.captions,
128
+ safeArea,
129
+ text: tpl.text,
130
+ scenes: plan.scenes.map((s) => ({ id: s.id, from: s.from, dur: s.dur, file: `scenes/${s.id}.ts`, role: s.role, idea: s.idea, assets: s.assets })),
131
+ };
132
+
133
+ const write = (rel: string, body: string) => writeFileSync(join(dir, rel), body);
134
+ write("kf.json", `${JSON.stringify(kf, null, 2)}\n`);
135
+ write("plan.json", `${JSON.stringify(plan, null, 2)}\n`);
136
+ write("style.json", `${JSON.stringify(style, null, 2)}\n`);
137
+ const fill = (src: string, vars: Record<string, string>) => src.replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] ?? "").replace(/["\\]/g, "\\$&"));
138
+ const shared = join(templatesDir, "_shared", "project");
139
+ for (const f of ["index.html", "main.ts"]) write(f, fill(readFileSync(join(shared, f), "utf8"), { title: plan.title }));
140
+ // 뼈대가 같이 쓰는 무대(화면 채움·흐르는 바탕·이유 있을 때만 움직이는 카메라) — 장면 파일이 ../lib/ 로 가져간다
141
+ mkdirSync(join(dir, "lib"), { recursive: true });
142
+ for (const f of readdirSync(join(shared, "lib"))) if (!existsSync(join(dir, "lib", f))) copyFileSync(join(shared, "lib", f), join(dir, "lib", f));
143
+
144
+ const kept: string[] = [];
145
+ // 이어지는 장면이 같은 등장을 쓰지 않게 순번으로 돌린다(style.json repeatSameMove)
146
+ const MOVES = ["rise", "pop", "blurIn", "scaleIn", "maskWipe", "drop"];
147
+ for (const [i, s] of plan.scenes.entries()) {
148
+ const rel = `scenes/${s.id}.ts`;
149
+ if (existsSync(join(dir, rel))) {
150
+ kept.push(rel);
151
+ continue;
152
+ }
153
+ const step = plan.scenes.slice(0, i + 1).filter((x) => x.role === "step").length;
154
+ const word = s.role === "end" || s.role === "hook" || s.role === "brand" ? plan.title : s.role === "step" ? `${step}단계` : s.id.replace(/\d+$/, "").toUpperCase();
155
+ write(rel, fill(sceneSkeleton(plan.purpose, s.id, s.role), { word, title: plan.title, line: str("line") ?? "", move: MOVES[i % MOVES.length], transition: tpl.scenes.find((x) => x.id === s.id)?.transition ?? "dive" }));
156
+ }
157
+
158
+ const near = relative(process.cwd(), dir);
159
+ const rel = !near ? "." : near.startsWith("..") ? dir : near;
160
+ console.log(`${rel}/ — ${tpl.label}(${plan.purpose}) ${plan.aspect} ${plan.width}×${plan.height} · ${+plan.duration.toFixed(3)}초 · ${plan.theme ? `테마 ${plan.theme}` : `스타일 ${plan.style}`}${music ? ` · 곡 ${music}` : ""}`);
161
+ if (plan.themeCandidates?.length) console.log(` 테마 후보: ${plan.themeCandidates.map((c) => c.id).join(" · ")}`);
162
+ else if (str("theme") === "auto") console.log(" 맞는 approved 테마가 없어 프리셋으로 간다");
163
+ for (const s of kf.scenes) console.log(` ${s.from.toFixed(2).padStart(6)}–${(s.from + s.dur).toFixed(2).padEnd(6)} ${s.id.padEnd(9)} ${s.idea}`);
164
+ for (const r of plan.reasons.filter((x) => !x.startsWith('"'))) console.log(` · ${r}`);
165
+ if (kept.length) console.log(` 있던 장면 파일은 그대로 뒀다: ${kept.join(" ")}`);
166
+ if (plan.gen?.length) console.log(` 만들 수 있는 자산(아직 안 만듦, 돈이 든다): ${plan.gen.map((g) => `gen:${g.kind}:${g.id}`).join(" ")} — 구체적인 프롬프트로 kf gen img --id <id> --project ${rel}`);
167
+ console.log(`\n다음: scenes/*.ts 를 kf.json 의 idea 대로 채운다 → kf preview ${rel} → kf render ${rel} → kf check ${rel}`);
168
+
169
+ if (opts.preview) {
170
+ const r = spawnSync(process.execPath, [kfBin, "preview", dir], { stdio: "inherit" });
171
+ return r.status ?? 0;
172
+ }
173
+ return 0;
174
+ },
175
+ };
176
+ export default cmd;
@@ -0,0 +1,43 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { studioPlugin } from "@kasaframe/studio";
4
+ import type { Command } from "../command.ts";
5
+ import { num, parseFlags } from "../command.ts";
6
+ import { openProject } from "../serve.ts";
7
+
8
+ const cmd: Command = {
9
+ summary: "미리보기 — 곡이 기준 시계, 타임라인·장면/박/드롭·프레임 이동, 저장하면 반영",
10
+ usage: `kf preview <폴더> [--port 5120] [--t 초] [--captions on|off]
11
+
12
+ 주소 / 가 미리보기, /index.html 이 프로젝트 페이지 그대로다. ?t=12.3 으로 그 시각에서 연다.
13
+ scenes/*.ts 를 저장하면 프로젝트 페이지만 새로 고치고 보던 시각으로 돌아간다(kf.json·beats.json·style.json 도).
14
+ --captions 화면 자막을 켜고(끄고) 연다 — kf.json 은 그대로. 미리보기 단추(C)로도 바꾼다
15
+ 조작: Space 재생 · ←→ 한 프레임 · Shift+←→ 1초 · Alt+←→ 박 · ↑↓ 장면·구간·드롭 경계 · L 반복
16
+ 타임라인 끌기(Shift 누르면 박·마커에 붙음) · 세로 휠 확대 · 가로 휠 이동 · 두 번 눌러 전체
17
+ 창은 띄우지 않는다. 끝내려면 Ctrl+C.`,
18
+ async run(argv) {
19
+ const { args, opts } = parseFlags(argv);
20
+ const dir = args[0];
21
+ if (!dir) {
22
+ console.error("kf preview: 폴더를 줘라 — kf preview <폴더>");
23
+ return 2;
24
+ }
25
+ if (!existsSync(join(resolve(dir), "index.html"))) {
26
+ console.error(`kf preview: ${resolve(dir)} 에 index.html 이 없다 — kf new 로 만든 프로젝트 폴더를 줘라`);
27
+ return 2;
28
+ }
29
+ const p = await openProject(dir, { port: num(opts.port, 5120), hmr: true, plugins: [studioPlugin(dir)] });
30
+ const q = new URLSearchParams();
31
+ if (opts.t !== undefined) q.set("t", String(num(opts.t, 0)));
32
+ if (opts.captions === "on" || opts.captions === "off" || opts.captions === "fit") q.set("captions", opts.captions);
33
+ const at = q.size ? `?${q}` : "";
34
+ console.log(`미리보기: ${p.url}${at}`);
35
+ await new Promise<void>((done) => {
36
+ const stop = () => void p.close().then(done, done);
37
+ process.once("SIGINT", stop);
38
+ process.once("SIGTERM", stop);
39
+ });
40
+ return 0;
41
+ },
42
+ };
43
+ export default cmd;
@@ -0,0 +1,216 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { extname, join, resolve } from "node:path";
3
+ import type { Command } from "../command.ts";
4
+ import { num, parseFlags } from "../command.ts";
5
+ import { openSession, type Tab } from "../browser.ts";
6
+ import { concat, encoder, type VideoFormat } from "../ffmpeg.ts";
7
+ import { acquireRenderLock } from "../lock.ts";
8
+ import { blurOpts, captureBlurred, rgbaToPng } from "../blur.ts";
9
+ import { outPath } from "../out.ts";
10
+
11
+ const MAX_TABS = 3;
12
+
13
+ interface Chunk {
14
+ index: number;
15
+ start: number;
16
+ end: number;
17
+ }
18
+
19
+ function split(start: number, end: number, n: number): Chunk[] {
20
+ const total = end - start;
21
+ const out: Chunk[] = [];
22
+ for (let i = 0; i < n; i++) {
23
+ const a = start + Math.floor((total * i) / n);
24
+ const b = start + Math.floor((total * (i + 1)) / n);
25
+ if (b > a) out.push({ index: i, start: a, end: b });
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function findMusic(dir: string, given: string | boolean | undefined, metaMusic?: string): string | undefined {
31
+ if (given === false || given === "none") return undefined;
32
+ if (typeof given === "string") return resolve(given);
33
+ if (metaMusic) {
34
+ const p = resolve(dir, metaMusic);
35
+ if (existsSync(p)) return p;
36
+ console.error(`곡을 못 찾았다: ${p} — 소리 없이 굽는다`);
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ // beats.json 의 from = 곡에서 잘라 쓴 시작. 영상 0초가 곡의 from 초라서, 곡을 얹을 때 그만큼 더 건너뛴다.
42
+ function musicFrom(dir: string): number {
43
+ try {
44
+ const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8")) as { beats?: string };
45
+ if (!kf.beats) return 0;
46
+ const beats = JSON.parse(readFileSync(resolve(dir, kf.beats), "utf8")) as { from?: number };
47
+ return Number(beats.from) || 0;
48
+ } catch {
49
+ return 0;
50
+ }
51
+ }
52
+
53
+ interface MotionEvent {
54
+ t: number;
55
+ kind: string;
56
+ source: string;
57
+ scene?: string;
58
+ }
59
+
60
+ // 움직임 부품이 쌓은 기록을 탭마다 모아 하나로 — step 모드는 pre-roll 구간이 겹쳐 같은 줄이 여러 탭에 있다.
61
+ async function collectEvents(tabs: Tab[]): Promise<MotionEvent[]> {
62
+ const seen = new Set<string>();
63
+ const out: MotionEvent[] = [];
64
+ for (const tab of tabs) {
65
+ const rows = await tab.page.evaluate(() => (window.__kf?.events ?? []) as unknown[]).catch(() => []);
66
+ for (const r of rows as MotionEvent[]) {
67
+ const key = `${r.t}|${r.kind}|${r.source}|${r.scene ?? ""}`;
68
+ if (seen.has(key)) continue;
69
+ seen.add(key);
70
+ out.push(r);
71
+ }
72
+ }
73
+ return out.sort((a, b) => a.t - b.t);
74
+ }
75
+
76
+ // x.mp4 → x-자막.mp4 · frames → frames-자막
77
+ function withSuffix(p: string, suffix: string): string {
78
+ if (!suffix) return p;
79
+ const ext = extname(p);
80
+ return ext ? `${p.slice(0, -ext.length)}${suffix}${ext}` : `${p}${suffix}`;
81
+ }
82
+
83
+ const cmd: Command = {
84
+ summary: "MP4(또는 ProRes·PNG 묶음)로 굽기 — 탭 병렬, 곡 얹기, 잠금",
85
+ usage: `kf render <폴더> [--out x.mp4] [--from 초 --to 초] [--tabs 3] [--prores | --png] [--music 곡 | --no-music] [--captions on|off|fit [--suffix]]
86
+ --out 기본 <폴더>/out/render.mp4 (--prores 면 .mov, --png 면 폴더)
87
+ --from/--to 구간만 굽는다. 곡도 그 구간만 잘라 얹는다
88
+ --tabs 크롬 탭 수(최대 ${MAX_TABS}). seek 모드는 구간을 나눠 찍고, step 모드는 탭마다 구간 앞까지 빨리 돌린 뒤 찍는다
89
+ --music 기본은 페이지 meta.music(kf.json 의 music). 곡은 beats.json 의 from 초부터 얹는다
90
+ --music-from 곡에서 시작할 초를 직접 준다(--music 으로 다른 곡을 줄 때)
91
+ --crf H.264 화질(기본 16, 낮을수록 좋다)
92
+ --motion-blur N 한 프레임을 하위 프레임 N 장(예 8)으로 찍어 평균 — 빠른 것만 번진다. 3D 전환은 같은 평균으로 피사계 심도도. seek 모드만, N 배 느리다
93
+ --shutter 모션 블러 셔터 각도(기본 180 — 프레임 간격의 절반). 360 이면 다음 프레임까지 이어 번진다
94
+ --captions on|off|fit 화면 자막을 이 판만 켜거나 끈다(kf.json 의 captions 는 그대로). on 이면 captions.json 을 화면 아래 판에 얹고
95
+ 안전 영역 아래쪽 자막 띠를 비운다(무대가 그 위로 배치) · fit 은 장면을 띠 위로 줄여 올린다(픽셀을 박아 둔 장면용)
96
+ --suffix [글] 결과 이름 뒤에 붙인다 — 값 없이 주면 --captions 에 따라 -자막 · -자막없음. 원본을 덮지 않고 둘 다 남길 때
97
+ --lock-wait 다른 렌더가 잠금을 쥐고 있을 때 기다릴 초(기본 900)
98
+ --no-lock 잠금 없이(시험용 짧은 구간)
99
+ 끝나면 같은 이름의 .json(png 는 폴더 안 render.json)에 meta·구간·걸린 시간·markers·움직임 events 를 남긴다.`,
100
+ async run(argv) {
101
+ const { args, opts } = parseFlags(argv);
102
+ const dir = args[0];
103
+ if (!dir) {
104
+ console.error("kf render: 폴더를 줘라 — kf render <폴더> --out x.mp4");
105
+ return 2;
106
+ }
107
+ const format: VideoFormat | "png" = opts.png ? "png" : opts.prores ? "prores" : "h264";
108
+ const ext = format === "prores" ? ".mov" : format === "png" ? "" : ".mp4";
109
+ const captions = opts.captions === undefined ? undefined : String(opts.captions);
110
+ if (captions !== undefined && captions !== "on" && captions !== "off" && captions !== "fit") throw new Error(`--captions 는 on·off·fit: ${captions}`);
111
+ const suffix = typeof opts.suffix === "string" ? opts.suffix : opts.suffix ? (captions === "off" ? "-자막없음" : "-자막") : "";
112
+ const out = withSuffix(outPath(dir, opts.out, format === "png" ? "frames/x" : `render${ext}`).replace(/\/x$/, ""), suffix);
113
+ if (format !== "png" && extname(out) === "") throw new Error(`--out 에 확장자가 없다: ${out}`);
114
+
115
+ const release = opts["no-lock"] ? () => {} : await acquireRenderLock(resolve(dir), num(opts["lock-wait"], 900));
116
+ const started = Date.now();
117
+ const s = await openSession(dir, { query: captions ? `captions=${captions}` : undefined });
118
+ const tmp = join(resolve(dir), "out", `.kf-render-${process.pid}`);
119
+ try {
120
+ const { meta, mode } = s.info;
121
+ const { fps } = meta;
122
+ const total = Math.round(meta.duration * fps);
123
+ const startF = Math.max(0, Math.floor(num(opts.from, 0) * fps + 1e-6));
124
+ const endF = Math.min(total, Math.ceil(num(opts.to, meta.duration) * fps - 1e-6));
125
+ if (endF <= startF) throw new Error(`굽을 프레임이 없다 (${startF}..${endF})`);
126
+ const frames = endF - startF;
127
+ const tabs = Math.max(1, Math.min(MAX_TABS, num(opts.tabs, MAX_TABS), Math.ceil(frames / fps)));
128
+ const chunks = split(startF, endF, tabs);
129
+ const music = format === "png" ? undefined : findMusic(dir, opts["no-music"] ? false : opts.music, meta.music);
130
+ let blur = blurOpts(opts);
131
+ if (blur && mode === "step") {
132
+ console.error("모션 블러는 seek 모드만 — step 모드는 뒤로 못 가서 끈다");
133
+ blur = null;
134
+ }
135
+ const songFrom = opts["music-from"] !== undefined ? num(opts["music-from"], 0) : opts.music ? 0 : musicFrom(resolve(dir));
136
+
137
+ console.error(
138
+ `${meta.width}x${meta.height} ${fps}fps ${mode} 모드 · 프레임 ${startF}..${endF - 1} (${frames}장) · 탭 ${chunks.length}` +
139
+ (music ? ` · 곡 ${music}${songFrom ? ` (${songFrom}초부터)` : ""}` : "") +
140
+ (blur ? ` · 모션 블러 ${blur.count}장·셔터 ${blur.shutter}°` : ""),
141
+ );
142
+ mkdirSync(tmp, { recursive: true });
143
+ if (format === "png") mkdirSync(out, { recursive: true });
144
+
145
+ let done = 0;
146
+ const progress = setInterval(() => {
147
+ const sec = (Date.now() - started) / 1000;
148
+ console.error(` ${done}/${frames} (${((done / frames) * 100).toFixed(0)}%) ${(done / sec).toFixed(1)} 장/초`);
149
+ }, 5000);
150
+
151
+ const segFiles: string[] = [];
152
+ const usedTabs: Tab[] = [];
153
+ try {
154
+ await Promise.all(
155
+ chunks.map(async (c) => {
156
+ const tab: Tab = c.index === 0 ? s.first : await s.tab();
157
+ usedTabs.push(tab);
158
+ const seg = join(tmp, `seg-${String(c.index).padStart(2, "0")}${ext}`);
159
+ segFiles[c.index] = seg;
160
+ const raw = blur ? { width: meta.width, height: meta.height } : undefined;
161
+ const enc = format === "png" ? null : encoder(seg, fps, format, opts.crf ? num(opts.crf, 16) : undefined, raw);
162
+ for (let f = c.start; f < c.end; f++) {
163
+ let frame: Buffer;
164
+ if (blur) {
165
+ const img = await captureBlurred(tab, s.info, f / fps, blur);
166
+ frame = enc ? img.data : rgbaToPng(img);
167
+ } else {
168
+ await tab.seek(f / fps);
169
+ frame = await tab.capture();
170
+ }
171
+ if (enc) await enc.write(frame);
172
+ else writeFileSync(join(out, `${String(f).padStart(6, "0")}.png`), frame);
173
+ done++;
174
+ }
175
+ await enc?.end();
176
+ }),
177
+ );
178
+ } finally {
179
+ clearInterval(progress);
180
+ }
181
+
182
+ if (format !== "png") {
183
+ const list = join(tmp, "list.txt");
184
+ writeFileSync(list, segFiles.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n") + "\n");
185
+ await concat({ list, out, format, music, from: songFrom + startF / fps, dur: frames / fps });
186
+ }
187
+ const sec = (Date.now() - started) / 1000;
188
+ const report = {
189
+ out,
190
+ meta,
191
+ mode,
192
+ from: startF / fps,
193
+ to: endF / fps,
194
+ frames,
195
+ tabs: chunks.length,
196
+ format,
197
+ motionBlur: blur,
198
+ captions: captions ?? null,
199
+ music: music ?? null,
200
+ seconds: Math.round(sec * 10) / 10,
201
+ markers: s.info.markers,
202
+ events: await collectEvents(usedTabs),
203
+ };
204
+ const reportFile = format === "png" ? join(out, "render.json") : out.replace(/\.[^.]+$/, ".json");
205
+ writeFileSync(reportFile, JSON.stringify(report, null, 1));
206
+ console.error(`${frames}장 ${sec.toFixed(1)}초 (${(frames / sec).toFixed(1)} 장/초) · 보고서 ${reportFile}`);
207
+ console.log(out);
208
+ return 0;
209
+ } finally {
210
+ await s.close();
211
+ if (existsSync(tmp) && !opts["keep-tmp"]) rmSync(tmp, { recursive: true, force: true });
212
+ release();
213
+ }
214
+ },
215
+ };
216
+ export default cmd;
@@ -0,0 +1,63 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { platform } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import { beatsVenv, checks, playwrightCli, report, requirements, venvPython } from "../deps.ts";
6
+ import { type Command, parseFlags } from "../command.ts";
7
+ import { setupLyrics } from "./lyrics.ts";
8
+
9
+ function step(label: string, cmd: string, args: string[]): boolean {
10
+ console.log(`\n${label}: ${cmd} ${args.join(" ")}`);
11
+ const r = spawnSync(cmd, args, { stdio: "inherit" });
12
+ if (r.status === 0) return true;
13
+ console.error(`${label} 실패(${r.error?.message ?? `종료 ${r.status}`})`);
14
+ return false;
15
+ }
16
+
17
+ function chrome(): boolean {
18
+ // 리눅스 root 면 크롬이 쓰는 시스템 라이브러리까지 — 아니면 sudo 가 필요해 명령만 알린다
19
+ const withDeps = platform() === "linux" && process.getuid?.() === 0;
20
+ const ok = step("크롬", process.execPath, [playwrightCli(), "install", ...(withDeps ? ["--with-deps"] : []), "chromium"]);
21
+ if (platform() === "linux" && !withDeps) console.log(` 라이브러리가 없어 크롬이 안 뜨면: sudo ${process.execPath} ${playwrightCli()} install-deps chromium`);
22
+ return ok;
23
+ }
24
+
25
+ function beats(): boolean {
26
+ if (process.env.KF_PYTHON) return step("librosa", process.env.KF_PYTHON, ["-m", "pip", "install", "-r", requirements]);
27
+ if (!existsSync(beatsVenv) && !step("파이썬 venv", "python3", ["-m", "venv", beatsVenv])) return false;
28
+ return step("librosa", venvPython(beatsVenv), ["-m", "pip", "install", "-r", requirements]);
29
+ }
30
+
31
+ // 고정 커밋을 받아 묶고 한글 글꼴을 받는다(비동기라 따로 돌린다). 일본어 가사는 node packages/jizura/src/install.ts --lang ko,ja
32
+ const jizura = () => step("JIZURA", process.execPath, [fileURLToPath(import.meta.resolve("@kasaframe/jizura/install")), "--lang", "ko"]);
33
+
34
+ // 이름 하나 = 깔 것 하나. 이름 없이 부르면 DEFAULT 만
35
+ const STEPS: Record<string, { what: string; run: () => boolean }> = {
36
+ chrome: { what: "Playwright 크롬(chromium) — 렌더·검사", run: chrome },
37
+ beats: { what: `librosa(${beatsVenv}) — 곡 분석 kf beats`, run: beats },
38
+ jizura: { what: "JIZURA 가사 층 엔진(고정 커밋)·한글 글꼴 60MB(~/.cache/kasaframe/jizura) — 뮤비 가사 키네틱", run: jizura },
39
+ lyrics: { what: "demucs·stable-ts(~/.cache/kasaframe/py/lyrics, 2GB 안팎) — kf lyrics align", run: setupLyrics },
40
+ };
41
+ const DEFAULT = ["chrome", "beats"];
42
+
43
+ const cmd: Command = {
44
+ summary: "바깥 도구 깔기 — Playwright 크롬·librosa. ffmpeg 는 시스템 패키지라 깔 명령만 알린다",
45
+ usage: `kf setup [이름…] 이름 없이: ${DEFAULT.join(" ")}
46
+ ${Object.entries(STEPS).map(([k, s]) => ` ${k.padEnd(8)} ${s.what}`).join("\n")}
47
+ kf beats 는 KF_PYTHON 이 없으면 위 venv 를 먼저 쓴다. 끝에 kf doctor 와 같은 표를 찍는다`,
48
+ async run(argv) {
49
+ const { args } = parseFlags(argv);
50
+ const names = args.length ? args : DEFAULT;
51
+ const unknown = names.filter((n) => !STEPS[n]);
52
+ if (unknown.length) {
53
+ console.error(`kf setup: 모르는 이름 ${unknown.join(" ")} — 있는 것: ${Object.keys(STEPS).join(" ")}`);
54
+ return 2;
55
+ }
56
+ const failed = names.filter((n) => !STEPS[n].run());
57
+ console.log("");
58
+ const list = await checks();
59
+ report(list);
60
+ return failed.length || list.some((c) => c.required && !c.ok) ? 1 : 0;
61
+ },
62
+ };
63
+ export default cmd;
@@ -0,0 +1,114 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { Command } from "../command.ts";
4
+ import { num, parseFlags } from "../command.ts";
5
+ import { openSession, type PageInfo } from "../browser.ts";
6
+ import { fmtTime, outPath } from "../out.ts";
7
+ import { acquireRenderLock } from "../lock.ts";
8
+
9
+ interface Shot {
10
+ t: number;
11
+ label: string;
12
+ }
13
+
14
+ function scenesOf(info: PageInfo): { label: string; from: number; to: number }[] {
15
+ const starts = info.markers.filter((m) => m.kind === "scene").sort((a, b) => a.t - b.t);
16
+ return starts.map((m, i) => ({ label: m.label, from: m.t, to: starts[i + 1]?.t ?? info.meta.duration }));
17
+ }
18
+
19
+ function pickTimes(info: PageInfo, opts: Record<string, string | boolean | undefined>): Shot[] {
20
+ const { duration, fps } = info.meta;
21
+ const last = (Math.round(duration * fps) - 1) / fps;
22
+ const scenes = scenesOf(info);
23
+ const sceneAt = (t: number) => scenes.findLast((s) => s.from <= t)?.label ?? "";
24
+ if (typeof opts.at === "string") {
25
+ return opts.at.split(",").map((x) => {
26
+ const t = Math.min(last, Number(x));
27
+ return { t, label: sceneAt(t) };
28
+ });
29
+ }
30
+ const per = num(opts["per-scene"], 1);
31
+ if (scenes.length && opts.count === undefined) {
32
+ return scenes.flatMap((s) =>
33
+ Array.from({ length: per }, (_, i) => {
34
+ const t = Math.min(last, s.from + ((s.to - s.from) * (i + 1)) / (per + 1));
35
+ return { t: Math.round(t * fps) / fps, label: s.label };
36
+ }),
37
+ );
38
+ }
39
+ const n = num(opts.count, 12);
40
+ return Array.from({ length: n }, (_, i) => {
41
+ const t = Math.round(((last * (i + 0.5)) / n) * fps) / fps;
42
+ return { t, label: sceneAt(t) };
43
+ });
44
+ }
45
+
46
+ function sheetHtml(shots: { png: Buffer; t: number; label: string }[], cols: number, w: number, h: number): string {
47
+ const cells = shots
48
+ .map(
49
+ (s) =>
50
+ `<figure><img src="data:image/png;base64,${s.png.toString("base64")}"><figcaption><b>${fmtTime(s.t)}s</b>${s.label ? ` · ${s.label}` : ""}</figcaption></figure>`,
51
+ )
52
+ .join("");
53
+ return `<!doctype html><meta charset="utf-8"><style>
54
+ body { margin: 0; padding: 16px; background: #111; font: 15px/1.3 -apple-system, "Apple SD Gothic Neo", sans-serif; color: #ddd; }
55
+ main { display: grid; grid-template-columns: repeat(${cols}, ${w}px); gap: 12px; }
56
+ figure { margin: 0; }
57
+ img { display: block; width: ${w}px; height: ${h}px; outline: 1px solid #333; }
58
+ figcaption { padding: 4px 2px 0; }
59
+ </style><main>${cells}</main>`;
60
+ }
61
+
62
+ const cmd: Command = {
63
+ summary: "장면 모음 한 장(PNG) — 그림 보는 모델에 넘길 것",
64
+ usage: `kf sheet <폴더> [--out 파일.png] [--per-scene 1] [--count N | --at 1,5.5,12] [--cols 4] [--width 480]
65
+ 기본은 장면마다 가운데 한 장. 장면 표시가 없으면 고르게 12 장.
66
+ --per-scene 장면마다 몇 장(장면을 n+1 등분한 자리)
67
+ --count 장면과 상관없이 고르게 N 장
68
+ --at 찍을 시각(초)을 쉼표로
69
+ --lock-wait 다른 렌더가 잠금을 쥐고 있을 때 기다릴 초(기본 900)
70
+ --no-lock 잠금 없이(한두 장 시험용)`,
71
+ async run(argv) {
72
+ const { args, opts } = parseFlags(argv);
73
+ const dir = args[0];
74
+ if (!dir) {
75
+ console.error("kf sheet: 폴더를 줘라 — kf sheet <폴더>");
76
+ return 2;
77
+ }
78
+ // 여러 장 찍는 무거운 일이라 render·check 와 같이 레포 전체에서 하나씩
79
+ const release = opts["no-lock"] ? () => {} : await acquireRenderLock(resolve(dir), num(opts["lock-wait"], 900));
80
+ try {
81
+ return await shoot(dir, opts);
82
+ } finally {
83
+ release();
84
+ }
85
+ },
86
+ };
87
+ export default cmd;
88
+
89
+ async function shoot(dir: string, opts: Record<string, string | boolean | undefined>): Promise<number> {
90
+ const s = await openSession(dir);
91
+ try {
92
+ const shots = pickTimes(s.info, opts);
93
+ // step 모드는 뒤로 못 가므로 시각 순서로 찍는다.
94
+ const order = [...shots].sort((a, b) => a.t - b.t);
95
+ const taken: { png: Buffer; t: number; label: string }[] = [];
96
+ for (const shot of order) {
97
+ await s.first.seek(shot.t);
98
+ taken.push({ ...shot, png: await s.first.capture() });
99
+ }
100
+ const { width, height } = s.info.meta;
101
+ const w = num(opts.width, height > width ? 270 : 480);
102
+ const h = Math.round((w * height) / width);
103
+ const cols = num(opts.cols, height > width ? 6 : 4);
104
+ const page = await s.context.newPage();
105
+ await page.setViewportSize({ width: 32 + cols * w + (cols - 1) * 12, height: 400 });
106
+ await page.setContent(sheetHtml(taken, cols, w, h), { waitUntil: "load" });
107
+ const file = outPath(dir, opts.out, "sheet.png");
108
+ writeFileSync(file, await page.screenshot({ fullPage: true, type: "png" }));
109
+ console.log(file);
110
+ return 0;
111
+ } finally {
112
+ await s.close();
113
+ }
114
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { type Command, parseFlags } from "../command.ts";
6
+
7
+ const source = fileURLToPath(new URL("../../../../skills/kasaframe", import.meta.url));
8
+
9
+ const cmd: Command = {
10
+ summary: "Claude Code 스킬 — 말로 「영상 만들어」 하면 kasaframe 으로 가게 ~/.claude/skills/kasaframe 에 링크",
11
+ usage: `kf skill install [--to <스킬 폴더>] ~/.claude/skills/kasaframe → 레포 skills/kasaframe 링크(이미 있으면 덮지 않고 알린다)
12
+ kf skill path 스킬 원본 자리`,
13
+ async run(argv) {
14
+ const { args, opts } = parseFlags(argv);
15
+ if (args[0] === "path") {
16
+ console.log(source);
17
+ return 0;
18
+ }
19
+ if (args[0] !== "install") {
20
+ console.error("kf skill install | path — kf skill --help");
21
+ return 2;
22
+ }
23
+ const to = typeof opts.to === "string" ? opts.to : join(homedir(), ".claude", "skills", "kasaframe");
24
+ if (existsSync(to) || isLink(to)) {
25
+ const what = isLink(to) ? `링크(${readlinkSync(to)})` : "폴더";
26
+ console.error(`${to} 가 이미 있다 — ${what}. 덮지 않는다. 바꾸려면 지우고 다시 kf skill install`);
27
+ return isLink(to) && readlinkSync(to) === source ? 0 : 1;
28
+ }
29
+ mkdirSync(dirname(to), { recursive: true });
30
+ symlinkSync(source, to);
31
+ console.log(`${to} → ${source}\n새 Claude Code 세션부터 「영상 만들어」 같은 말에 kasaframe 스킬이 뜬다.`);
32
+ return 0;
33
+ },
34
+ };
35
+
36
+ function isLink(p: string): boolean {
37
+ try {
38
+ return lstatSync(p).isSymbolicLink();
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+ export default cmd;