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,203 @@
1
+ // window.__kf 전역 타입은 adapters 의 페이지 계약에 있다. cli 만 따로 타입 검사해도 보이게 끌어온다.
2
+ import type {} from "@kasaframe/adapters";
3
+ import { chromium, type Browser, type BrowserContext, type CDPSession, type Page } from "playwright";
4
+ import { clockScript, type ClockOptions } from "@kasaframe/clock";
5
+ import { openProject, type OpenProject } from "./serve.ts";
6
+
7
+ export interface PageMeta {
8
+ fps: number;
9
+ width: number;
10
+ height: number;
11
+ duration: number;
12
+ music?: string;
13
+ }
14
+
15
+ export interface PageInfo {
16
+ meta: PageMeta;
17
+ mode: "seek" | "step";
18
+ markers: { t: number; label: string; kind: string }[];
19
+ }
20
+
21
+ // 같은 프레임을 두 번 찍어 같아야 하므로 GPU 래스터·LCD 글자 다듬기처럼 흔들리는 것을 끈다.
22
+ // partial raster 는 바뀐 조각만 다시 그려, 앞서 몇 번 찍었느냐에 따라 안티에일리어싱 가장자리가 갈린다(탭 이음매가 어긋남).
23
+ const ARGS = [
24
+ "--disable-partial-raster",
25
+ // 합성기 쓰레드가 opacity·transform 애니메이션을 따로 돌리면, 일시정지가 닿기 전 자기 진행값으로 한 번 그린다.
26
+ "--disable-threaded-animation",
27
+ "--font-render-hinting=none",
28
+ "--disable-lcd-text",
29
+ "--disable-skia-runtime-opts",
30
+ "--force-color-profile=srgb",
31
+ "--hide-scrollbars",
32
+ "--mute-audio",
33
+ "--autoplay-policy=no-user-gesture-required",
34
+ // HTML-in-Canvas — 3D 전환이 살아 있는 장면 DOM 을 시각마다 WebGL 텍스처로 올린다(texElementImage2D)
35
+ "--enable-features=CanvasDrawElement",
36
+ "--disable-background-timer-throttling",
37
+ "--disable-renderer-backgrounding",
38
+ "--disable-backgrounding-occluded-windows",
39
+ ];
40
+
41
+ export async function launch(): Promise<Browser> {
42
+ try {
43
+ return await chromium.launch({ headless: true, args: ARGS });
44
+ } catch (err) {
45
+ if (!String(err).includes("Executable doesn't exist")) throw err;
46
+ return chromium.launch({ headless: true, channel: "chrome", args: ARGS });
47
+ }
48
+ }
49
+
50
+ export class Tab {
51
+ readonly page: Page;
52
+ private cdp: CDPSession;
53
+ info!: PageInfo;
54
+
55
+ private constructor(page: Page, cdp: CDPSession) {
56
+ this.page = page;
57
+ this.cdp = cdp;
58
+ }
59
+
60
+ static async open(context: BrowserContext, url: string): Promise<Tab> {
61
+ const page = await context.newPage();
62
+ const errors: string[] = [];
63
+ page.on("pageerror", (e) => errors.push(e.message));
64
+ page.on("console", (m) => {
65
+ if (m.type() === "error") errors.push(m.text());
66
+ });
67
+ const tab = new Tab(page, await context.newCDPSession(page));
68
+ await page.goto(url, { waitUntil: "load" });
69
+ try {
70
+ await page.waitForFunction(() => typeof window.__kf?.seek === "function", null, { timeout: 15000 });
71
+ } catch {
72
+ throw new Error(`페이지가 window.__kf 를 걸지 않았다 (${url})${errors.length ? `\n 페이지 오류: ${errors.join("\n ")}` : ""}`);
73
+ }
74
+ await page.evaluate(() => window.__kf!.ready!());
75
+ tab.info = await page.evaluate(() => {
76
+ const k = window.__kf!;
77
+ return {
78
+ meta: { ...k.meta! },
79
+ mode: k.mode ?? "seek",
80
+ markers: (k.markers ?? []).map((m) => ({ t: m.t, label: m.label, kind: m.kind })),
81
+ };
82
+ });
83
+ const { width, height } = tab.info.meta;
84
+ const vp = page.viewportSize();
85
+ if (!vp || vp.width !== width || vp.height !== height) await page.setViewportSize({ width, height });
86
+ return tab;
87
+ }
88
+
89
+ async seek(t: number, sample?: { index: number; count: number }): Promise<void> {
90
+ await this.page.evaluate(([t, s]) => window.__kf!.seek!(t, s ?? undefined), [t, sample ?? null] as const);
91
+ }
92
+
93
+ // Playwright screenshot 보다 빠르다. optimizeForSpeed 는 PNG 압축만 줄이고 픽셀은 같다.
94
+ private async shot(): Promise<Buffer> {
95
+ const { data } = await this.cdp.send("Page.captureScreenshot", {
96
+ format: "png",
97
+ optimizeForSpeed: true,
98
+ captureBeyondViewport: false,
99
+ });
100
+ return Buffer.from(data, "base64");
101
+ }
102
+
103
+ // 이번 seek 에서 <video> 를 옮겼으면 합성기가 새 프레임을 받을 때까지 두 번 연속 같은 그림이 나오게 다시 찍는다.
104
+ async capture(): Promise<Buffer> {
105
+ let png = await this.shot();
106
+ const moved = await this.page.evaluate(() => {
107
+ const m = !!window.__kfMediaMoved;
108
+ window.__kfMediaMoved = false;
109
+ return m;
110
+ });
111
+ if (!moved) return png;
112
+ for (let i = 0; i < 8; i++) {
113
+ await this.page.evaluate(
114
+ () => new Promise<void>((r) => (window.__kfClock?.real.requestAnimationFrame ?? requestAnimationFrame)(() => r())),
115
+ );
116
+ const again = await this.shot();
117
+ if (again.equals(png)) return png;
118
+ png = again;
119
+ }
120
+ return png;
121
+ }
122
+
123
+ async close(): Promise<void> {
124
+ await this.page.close().catch(() => {});
125
+ }
126
+ }
127
+
128
+ export interface Session {
129
+ project: OpenProject;
130
+ browser: Browser;
131
+ context: BrowserContext;
132
+ first: Tab;
133
+ info: PageInfo;
134
+ tab(): Promise<Tab>;
135
+ close(): Promise<void>;
136
+ }
137
+
138
+ export interface SessionOptions {
139
+ clock?: boolean | ClockOptions;
140
+ width?: number;
141
+ height?: number;
142
+ // 페이지 주소 뒤에 붙일 질의 — captions=on 처럼 kf.json 을 그 판만 덮는 값
143
+ query?: string;
144
+ }
145
+
146
+ // 프로젝트를 띄우고 탭 하나를 준비한다. 렌더는 tab() 으로 더 연다.
147
+ export async function openSession(dir: string, o: SessionOptions = {}): Promise<Session> {
148
+ const project = await openProject(dir);
149
+ const browser = await launch();
150
+ try {
151
+ const context = await browser.newContext({
152
+ viewport: { width: o.width ?? 1920, height: o.height ?? 1080 },
153
+ deviceScaleFactor: 1,
154
+ });
155
+ if (o.clock !== false) {
156
+ await context.addInitScript({ content: await clockScript(o.clock === true || !o.clock ? {} : o.clock) });
157
+ }
158
+ const url = o.query ? `${project.url}${project.url.includes("?") ? "&" : "?"}${o.query.replace(/^\?/, "")}` : project.url;
159
+ const first = await Tab.open(context, url);
160
+ return {
161
+ project,
162
+ browser,
163
+ context,
164
+ first,
165
+ info: first.info,
166
+ tab: () => Tab.open(context, url),
167
+ async close() {
168
+ await browser.close().catch(() => {});
169
+ await project.close();
170
+ },
171
+ };
172
+ } catch (err) {
173
+ await browser.close().catch(() => {});
174
+ await project.close();
175
+ throw err;
176
+ }
177
+ }
178
+
179
+ export interface OpenPage {
180
+ page: Page;
181
+ meta: PageMeta;
182
+ mode: "seek" | "step";
183
+ markers: PageInfo["markers"];
184
+ url: string;
185
+ seek(t: number): Promise<void>;
186
+ capture(): Promise<Buffer>;
187
+ close(): Promise<void>;
188
+ }
189
+
190
+ // 검사·생성처럼 엔진 밖에서 렌더와 같은 길로 페이지를 열 때.
191
+ export async function openPage(dir: string, o: SessionOptions = {}): Promise<OpenPage> {
192
+ const s = await openSession(dir, o);
193
+ return {
194
+ page: s.first.page,
195
+ meta: s.info.meta,
196
+ mode: s.info.mode,
197
+ markers: s.info.markers,
198
+ url: s.project.url,
199
+ seek: (t) => s.first.seek(t),
200
+ capture: () => s.first.capture(),
201
+ close: () => s.close(),
202
+ };
203
+ }
@@ -0,0 +1,44 @@
1
+ // 명령 파일 하나 = commands/<이름>.ts 의 default export.
2
+ export interface Command {
3
+ summary: string;
4
+ usage?: string;
5
+ // 반환값이 종료 코드. 없으면 0.
6
+ run(argv: string[]): Promise<number | void>;
7
+ }
8
+
9
+ export interface Flags {
10
+ args: string[];
11
+ opts: Record<string, string | boolean | undefined>;
12
+ }
13
+
14
+ // --key value · --key=value · --flag(값 없으면 true). 짧은 옵션은 쓰지 않는다.
15
+ export function parseFlags(argv: string[]): Flags {
16
+ const out: Flags = { args: [], opts: {} };
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (!a.startsWith("--")) {
20
+ out.args.push(a);
21
+ continue;
22
+ }
23
+ const eq = a.indexOf("=");
24
+ if (eq > 0) {
25
+ out.opts[a.slice(2, eq)] = a.slice(eq + 1);
26
+ continue;
27
+ }
28
+ const next = argv[i + 1];
29
+ if (next !== undefined && !next.startsWith("--")) {
30
+ out.opts[a.slice(2)] = next;
31
+ i++;
32
+ } else {
33
+ out.opts[a.slice(2)] = true;
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export function num(v: string | boolean | undefined, fallback: number): number {
40
+ if (typeof v !== "string") return fallback;
41
+ const n = Number(v);
42
+ if (!Number.isFinite(n)) throw new Error(`숫자가 아니다: ${v}`);
43
+ return n;
44
+ }
@@ -0,0 +1,25 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { type Command, parseFlags } from "../command.ts";
4
+ import { installed } from "../serve.ts";
5
+
6
+ const cmd: Command = {
7
+ summary: "작업실 — 요청 칸·실시간 미리보기·렌더·검사 보고를 브라우저 화면으로",
8
+ usage: `kf app [--port 5150] [--host 127.0.0.1] [--dir 일 폴더]
9
+ --host 0.0.0.0 이면 같은 망의 폰에서도 연다. 일 폴더 기본은 레포 out/app, 설치된 패키지는 ~/.kasaframe/app
10
+ 곡 모음은 KF_MUSIC_DIR. 말로 시작한 kf make 도 작업실이 떠 있으면 스스로 붙는다(KF_APP_URL)`,
11
+ async run(argv) {
12
+ const { opts } = parseFlags(argv);
13
+ const set = (flag: string, env: string) => {
14
+ if (typeof opts[flag] === "string") process.env[env] = opts[flag] as string;
15
+ };
16
+ set("port", "KF_APP_PORT");
17
+ set("host", "KF_APP_HOST");
18
+ set("dir", "KF_APP_DIR");
19
+ if (installed) process.env.KF_APP_DIR ??= join(homedir(), ".kasaframe", "app");
20
+ await import("../../../../app/server/main.ts");
21
+ // 서버가 멈출 때까지 돌려 둔다 — 끝내기는 main.ts 의 SIGINT 처리가 맡는다
22
+ return new Promise<never>(() => {});
23
+ },
24
+ };
25
+ export default cmd;
@@ -0,0 +1,36 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { type Command, parseFlags } from "../command.ts";
5
+ import { python as pythonBin, requirements } from "../deps.ts";
6
+
7
+ const script = fileURLToPath(new URL("../../../../tools/beats/beats.py", import.meta.url));
8
+
9
+ const cmd: Command = {
10
+ summary: "곡을 재서 beats.json — 박·마디·프레이즈·구간·드롭·대역 에너지",
11
+ usage: `kf beats <곡> [--out beats.json] [--from 초] [--duration 초] [--hz 20] [--bpm 힌트]
12
+
13
+ --from·--duration 곡의 일부만 쓸 때. 모든 시각은 --from 을 0 으로 센다
14
+ --bpm 반박·두 배 박으로 잡힐 때만 박 추적 시작값으로 준다(시각을 곱해 만들지 않는다)
15
+ 요약은 stderr 로 나온다. python3 + librosa 가 필요하다 — kf setup beats 가 ~/.cache/kasaframe/py/beats 에 깐다(KF_PYTHON 으로 바꿀 수 있다).`,
16
+ async run(argv) {
17
+ const { args } = parseFlags(argv);
18
+ if (args.length === 0) {
19
+ console.error("kf beats: 곡 경로가 필요하다. kf beats --help");
20
+ return 2;
21
+ }
22
+ if (!existsSync(args[0])) {
23
+ console.error(`kf beats: 곡이 없다: ${args[0]}`);
24
+ return 2;
25
+ }
26
+ const python = pythonBin();
27
+ const probe = spawnSync(python, ["-c", "import librosa"], { stdio: "ignore" });
28
+ if (probe.status !== 0) {
29
+ console.error(`kf beats: ${python} 에 librosa 가 없다 — kf setup beats (또는 ${python} -m pip install -r ${requirements})`);
30
+ return 1;
31
+ }
32
+ const r = spawnSync(python, [script, ...argv], { stdio: "inherit" });
33
+ return r.status ?? 1;
34
+ },
35
+ };
36
+ export default cmd;
@@ -0,0 +1,270 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import type { Command } from "../command.ts";
5
+ import { num, parseFlags } from "../command.ts";
6
+ import { encoder } from "../ffmpeg.ts";
7
+ import { acquireRenderLock } from "../lock.ts";
8
+ import { fmtTime } from "../out.ts";
9
+ import { repoRoot } from "../serve.ts";
10
+
11
+ // 이 파일은 Blender 를 바깥 프로그램으로 띄우기만 한다(bpy 를 가져오지 않는다). bpy 를 쓰는 헬퍼·샷은 GPL 로 갈라 둔
12
+ // tools/blender 에 있고 npm 묶음에는 없다 — 근거는 tools/blender/README.md.
13
+
14
+ interface KfJson {
15
+ fps: number;
16
+ width: number;
17
+ height: number;
18
+ duration: number;
19
+ beats?: string | null;
20
+ scenes?: { id: string; from: number; dur: number }[];
21
+ }
22
+
23
+ const MIN = [4, 3]; // Grease Pencil v3(라인아트가 일반 모디파이어)가 4.3 부터
24
+
25
+ function blenderLib(): string {
26
+ const dir = process.env.KF_BLENDER_LIB ? resolve(process.env.KF_BLENDER_LIB) : join(repoRoot, "tools", "blender");
27
+ if (!existsSync(join(dir, "run.py")) || !existsSync(join(dir, "kfb.py"))) {
28
+ throw new Error(
29
+ `Blender 헬퍼(kfb.py·run.py)가 없다: ${dir}\n` +
30
+ "헬퍼는 bpy 를 써서 GPL-3.0 으로 갈라 두었고 npm 묶음에는 넣지 않았다 — 레포의 tools/blender 를 받아 KF_BLENDER_LIB=<그 폴더> 로 가리켜라.",
31
+ );
32
+ }
33
+ return dir;
34
+ }
35
+
36
+ function candidates(given?: string): string[] {
37
+ const out: string[] = [];
38
+ if (given) out.push(given);
39
+ if (process.env.BLENDER) out.push(process.env.BLENDER);
40
+ const which = spawnSync(process.platform === "win32" ? "where" : "which", ["blender"], { encoding: "utf8" });
41
+ if (which.status === 0) out.push(...which.stdout.split(/\r?\n/).filter(Boolean));
42
+ if (process.platform === "darwin") {
43
+ const apps = existsSync("/Applications") ? readdirSync("/Applications").filter((n) => /^Blender.*\.app$/.test(n)) : [];
44
+ // 이름에 판 번호를 붙여 여러 개 깔아 둔 기계면 높은 판부터
45
+ apps.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
46
+ out.push(...apps.map((a) => `/Applications/${a}/Contents/MacOS/Blender`));
47
+ }
48
+ if (process.platform === "win32") {
49
+ const base = "C:\\Program Files\\Blender Foundation";
50
+ if (existsSync(base)) {
51
+ const dirs = readdirSync(base).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
52
+ out.push(...dirs.map((d) => join(base, d, "blender.exe")));
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function findBlender(given?: string): { exe: string; version: number[] } {
59
+ const tried: string[] = [];
60
+ for (const exe of candidates(given)) {
61
+ if (tried.includes(exe)) continue;
62
+ tried.push(exe);
63
+ const r = spawnSync(exe, ["--version"], { encoding: "utf8", timeout: 30_000 });
64
+ const m = /Blender (\d+)\.(\d+)(?:\.(\d+))?/.exec(r.stdout ?? "");
65
+ if (r.status !== 0 || !m) continue;
66
+ const version = [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
67
+ if (version[0] < MIN[0] || (version[0] === MIN[0] && version[1] < MIN[1])) {
68
+ console.error(`kf blender: ${exe} 는 ${version.join(".")} — ${MIN.join(".")} 이상이 필요해 건너뛴다`);
69
+ continue;
70
+ }
71
+ return { exe, version };
72
+ }
73
+ throw new Error(`Blender ${MIN.join(".")} 이상을 못 찾았다 — --blender <실행 파일> 또는 BLENDER=<경로>. 본 곳: ${tried.join(", ") || "(없음)"}`);
74
+ }
75
+
76
+ function findProject(given: string | undefined, shot: string): string {
77
+ if (given) {
78
+ if (!existsSync(join(resolve(given), "kf.json"))) throw new Error(`kf.json 이 없다: ${resolve(given)}`);
79
+ return resolve(given);
80
+ }
81
+ for (const start of [dirname(shot), process.cwd()]) {
82
+ for (let d = start; ; d = dirname(d)) {
83
+ if (existsSync(join(d, "kf.json"))) return d;
84
+ if (dirname(d) === d) break;
85
+ }
86
+ }
87
+ throw new Error("프로젝트(kf.json)를 못 찾았다 — --project <폴더>. fps·크기·beats.json 을 거기서 읽는다");
88
+ }
89
+
90
+ const cmd: Command = {
91
+ summary: "Blender 툰 3D 샷을 프로젝트 fps mp4 로 — 장면엔 <video data-kf-from>",
92
+ usage: `kf blender <샷.py> [--project <폴더>] [--scene <id> | --from 초 --to 초] [--id 이름]
93
+ --project kf.json 이 있는 폴더(기본: 샷 파일이나 지금 폴더에서 위로 찾는다) — fps·크기·beats.json 을 읽는다
94
+ --scene 그 장면의 시작·끝을 구간으로. 아니면 --from·--to(프로젝트 초, 기본 0..duration)
95
+ --id 결과 이름(기본 샷 파일 이름) → <프로젝트>/assets/blender/<id>.mp4 + <id>.json
96
+ --res 100 해상도 %(50 이면 절반 크기로 빨리 — 페이지가 늘려 보인다)
97
+ --samples 16 EEVEE 샘플
98
+ --step 1 N 프레임마다 한 장만 찍고 그 사이는 붙든다(2 = 애니메이션 「2코마」, 시간 반)
99
+ --still 1.2,4.5 그 시각들만 PNG 로(out/blender/<id>/still-<t>.png) — 굽기 전에 그림부터 본다
100
+ --blend 만든 장면을 out/blender/<id>.blend 로 저장(Blender 에서 열어 보기)
101
+ --blender <실행 파일> 기본: BLENDER 환경 변수 → PATH 의 blender → /Applications/Blender*.app (4.3 이상)
102
+ --keep 다 묶은 뒤에도 PNG 를 out/blender/<id>/ 에 남긴다
103
+ --no-lock · --lock-wait 900 렌더 잠금(kf render 와 같은 것 — 무거운 일은 한 번에 하나)
104
+
105
+ 샷 스크립트는 tools/blender/kfb.py 헬퍼로 짠다(예: tools/blender/shots/lighthouse.py). update(t) 가 t(프로젝트 초)만 보고 자세를 정하고,
106
+ 카메라 열쇠는 S.downbeats·S.drops 같은 beats.json 박에 둔다. 헬퍼 위치는 KF_BLENDER_LIB 로 바꾼다.`,
107
+ async run(argv) {
108
+ const { args, opts } = parseFlags(argv);
109
+ if (!args[0]) {
110
+ console.error("kf blender: 샷 스크립트를 줘라 — kf blender <샷.py> --project <폴더>");
111
+ return 2;
112
+ }
113
+ const shot = resolve(args[0]);
114
+ if (!existsSync(shot)) throw new Error(`샷 스크립트가 없다: ${shot}`);
115
+ const lib = blenderLib();
116
+ const proj = findProject(typeof opts.project === "string" ? opts.project : undefined, shot);
117
+ const kf = JSON.parse(readFileSync(join(proj, "kf.json"), "utf8")) as KfJson;
118
+ const fps = kf.fps;
119
+ const id = typeof opts.id === "string" ? opts.id : basename(shot).replace(/\.py$/, "");
120
+
121
+ let from = num(opts.from, 0);
122
+ let to = num(opts.to, kf.duration);
123
+ if (typeof opts.scene === "string") {
124
+ const sc = kf.scenes?.find((s) => s.id === opts.scene);
125
+ if (!sc) throw new Error(`장면 ${opts.scene} 이 kf.json 에 없다 — ${(kf.scenes ?? []).map((s) => s.id).join(", ")}`);
126
+ from = sc.from;
127
+ to = sc.from + sc.dur;
128
+ }
129
+ // 프레임 시각은 어디서나 f / fps — kf render 처럼 [from, to) 에 드는 프레임을 센다. 클립 0 번은 전체 영상의 startF 번 프레임
130
+ const startF = Math.ceil(from * fps - 1e-6);
131
+ const endF = Math.min(Math.round(kf.duration * fps), Math.ceil(to * fps - 1e-6));
132
+ const count = endF - startF;
133
+ if (count <= 0) throw new Error(`구간이 비었다 (${from}..${to})`);
134
+ const t0 = startF / fps;
135
+ const step = Math.max(1, Math.round(num(opts.step, 1)));
136
+ const res = Math.max(1, Math.min(100, Math.round(num(opts.res, 100))));
137
+
138
+ const stills = typeof opts.still === "string" ? opts.still.split(",").map((s) => num(s.trim(), 0)) : null;
139
+ const frames = stills ? stills.map((t) => Math.max(0, Math.min(count - 1, Math.round((t - t0) * fps)))) : [];
140
+ if (!stills) for (let f = 0; f < count; f += step) frames.push(f);
141
+ const names = stills ? stills.map((t) => `still-${fmtTime(t)}`) : frames.map((f) => `f_${String(f).padStart(5, "0")}`);
142
+
143
+ const work = join(proj, "out", "blender", id);
144
+ rmSync(work, { recursive: true, force: true });
145
+ mkdirSync(work, { recursive: true });
146
+ const beats = kf.beats ? resolve(proj, kf.beats) : null;
147
+ const cfg = {
148
+ shot,
149
+ from: t0,
150
+ to: endF / fps,
151
+ fps,
152
+ width: kf.width,
153
+ height: kf.height,
154
+ res,
155
+ samples: Math.round(num(opts.samples, 16)),
156
+ frames,
157
+ names,
158
+ out: work,
159
+ beats: beats && existsSync(beats) ? beats : null,
160
+ blend: opts.blend ? join(proj, "out", "blender", `${id}.blend`) : null,
161
+ };
162
+ const cfgPath = join(proj, "out", "blender", `${id}.cfg.json`);
163
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
164
+
165
+ const { exe, version } = findBlender(typeof opts.blender === "string" ? opts.blender : undefined);
166
+ const release = opts["no-lock"] ? () => {} : await acquireRenderLock(proj, num(opts["lock-wait"], 900));
167
+ const started = Date.now();
168
+ try {
169
+ console.error(
170
+ `Blender ${version.join(".")} · ${relative(process.cwd(), shot)} · ${fmtTime(t0)}–${fmtTime(endF / fps)}초 · ` +
171
+ `${stills ? `스틸 ${frames.length}장` : `${count}프레임${step > 1 ? ` (${step}장마다 찍음 ${frames.length}장)` : ""}`} · ${Math.round((kf.width * res) / 100)}x${Math.round((kf.height * res) / 100)}`,
172
+ );
173
+ const perFrame = await runBlender(exe, lib, cfgPath);
174
+
175
+ if (stills) {
176
+ for (const n of names) console.log(join(work, `${n}.png`));
177
+ return 0;
178
+ }
179
+
180
+ const outDir = join(proj, "assets", "blender");
181
+ mkdirSync(outDir, { recursive: true });
182
+ const mp4 = join(outDir, `${id}.mp4`);
183
+ const enc = encoder(mp4, fps, "h264");
184
+ for (let f = 0; f < count; f++) {
185
+ const held = Math.floor(f / step) * step;
186
+ await enc.write(readFileSync(join(work, `f_${String(held).padStart(5, "0")}.png`)));
187
+ }
188
+ await enc.end();
189
+
190
+ // 디코더가 프레임 경계에서 앞 프레임을 집지 않게 반 프레임 앞을 0 으로 잡는다
191
+ const kfFrom = Number((t0 - 0.5 / fps).toFixed(6));
192
+ const elapsed = (Date.now() - started) / 1000;
193
+ const meta = {
194
+ shot: relative(proj, shot),
195
+ blender: version.join("."),
196
+ from: t0,
197
+ to: endF / fps,
198
+ fps,
199
+ frames: count,
200
+ step,
201
+ res,
202
+ samples: cfg.samples,
203
+ width: Math.round((kf.width * res) / 100),
204
+ height: Math.round((kf.height * res) / 100),
205
+ secPerFrame: Number(perFrame.toFixed(3)),
206
+ elapsedSec: Number(elapsed.toFixed(1)),
207
+ dataKfFrom: kfFrom,
208
+ renderedAt: new Date().toISOString(),
209
+ };
210
+ writeFileSync(join(outDir, `${id}.json`), `${JSON.stringify(meta, null, 2)}\n`);
211
+ if (!opts.keep) rmSync(work, { recursive: true, force: true });
212
+ console.error(`${count}프레임 · 한 장 ${perFrame.toFixed(2)}초 · 모두 ${Math.round(elapsed)}초`);
213
+ console.error(`장면에서: <video src="\${ctx.asset("blender/${id}.mp4")}" data-kf-from="${kfFrom}" muted playsinline>`);
214
+ console.log(mp4);
215
+ return 0;
216
+ } finally {
217
+ release();
218
+ }
219
+ },
220
+ };
221
+ export default cmd;
222
+
223
+ // Blender 를 헤드리스로 돌리고 KFB 줄로 진행을 센다. 평균 한 장 시간(첫 장의 셰이더 컴파일 빼고)을 돌려준다.
224
+ function runBlender(exe: string, lib: string, cfgPath: string): Promise<number> {
225
+ return new Promise((resolveRun, reject) => {
226
+ const proc = spawn(exe, ["-b", "--factory-startup", "--python-exit-code", "1", "-P", join(lib, "run.py"), "--", cfgPath], {
227
+ stdio: ["ignore", "pipe", "pipe"],
228
+ });
229
+ const tail: string[] = [];
230
+ const times: number[] = [];
231
+ let total = 0;
232
+ let lastShown = 0;
233
+ let buf = "";
234
+ const onData = (d: Buffer) => {
235
+ buf += d.toString();
236
+ let nl: number;
237
+ while ((nl = buf.indexOf("\n")) >= 0) {
238
+ const line = buf.slice(0, nl).trimEnd();
239
+ buf = buf.slice(nl + 1);
240
+ tail.push(line);
241
+ if (tail.length > 60) tail.shift();
242
+ const m = /^KFB (start|frame) (\d+)(?: (\d+) ([\d.]+))?/.exec(line);
243
+ if (!m) continue;
244
+ if (m[1] === "start") {
245
+ total = Number(m[2]);
246
+ continue;
247
+ }
248
+ times.push(Number(m[4]));
249
+ const i = Number(m[2]);
250
+ if (i === total || Date.now() - lastShown > 5000) {
251
+ lastShown = Date.now();
252
+ const steady = times.length > 1 ? times.slice(1) : times;
253
+ const avg = steady.reduce((a, b) => a + b, 0) / steady.length;
254
+ console.error(` ${i}/${total} · 한 장 ${avg.toFixed(2)}초 · 남은 ~${Math.ceil(((total - i) * avg) / 60)}분`);
255
+ }
256
+ }
257
+ };
258
+ proc.stdout.on("data", onData);
259
+ proc.stderr.on("data", onData);
260
+ proc.on("error", (e) => reject(new Error(`Blender 를 못 띄웠다: ${e.message}`)));
261
+ proc.on("close", (code) => {
262
+ if (code === 0 && times.length === total && total > 0) {
263
+ const steady = times.length > 1 ? times.slice(1) : times;
264
+ resolveRun(steady.reduce((a, b) => a + b, 0) / steady.length);
265
+ } else {
266
+ reject(new Error(`Blender 가 실패했다 (종료 ${code}, ${times.length}/${total}장)\n${tail.join("\n")}`));
267
+ }
268
+ });
269
+ });
270
+ }
@@ -0,0 +1,25 @@
1
+ import { resolve } from "node:path";
2
+ import type { Command } from "../command.ts";
3
+ import { parseFlags } from "../command.ts";
4
+ import { acquireRenderLock } from "../lock.ts";
5
+ import { run, usage } from "@kasaframe/check";
6
+
7
+ // 검사도 크롬으로 영상 전체를 훑는 무거운 일이라 렌더와 같은 잠금을 잡는다 — 한 기계에서 하나씩
8
+ const cmd: Command = {
9
+ summary: "검사 — 멈춤·빈 화면·화면 글(undefined·두부)·화면 밖 글·대비·결정성·박 경계·움직임 규칙",
10
+ usage: `${usage}\n --no-lock 렌더 잠금(.render-lock) 없이 — 짧은 구간 시험용\n --captions on|off|fit 화면 자막을 켜고(끄고) 훑는다 — kf render --captions 로 구운 판과 같은 화면`,
11
+ async run(argv) {
12
+ // --captions 는 페이지 질의로 넘긴다(mountProject 가 읽는다)
13
+ const at = argv.indexOf("--captions");
14
+ if (at >= 0) argv = [...argv.slice(0, at), "--query", `captions=${argv[at + 1]}`, ...argv.slice(at + 2)];
15
+ const { args, opts } = parseFlags(argv);
16
+ if (opts["no-lock"] || !args[0]) return run(argv.filter((a) => a !== "--no-lock"));
17
+ const release = await acquireRenderLock(resolve(args[0]), 900);
18
+ try {
19
+ return await run(argv);
20
+ } finally {
21
+ release();
22
+ }
23
+ },
24
+ };
25
+ export default cmd;
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { arch, platform } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { Command } from "../command.ts";
5
+ import { checks, report } from "../deps.ts";
6
+ import { repoRoot } from "../serve.ts";
7
+
8
+ const cmd: Command = {
9
+ summary: "바깥 도구 점검 — ffmpeg·크롬(Playwright)·python3+librosa·OG 키, 없으면 까는 명령",
10
+ usage: `kf doctor
11
+ 없음 은 렌더가 안 되는 것, 선택 은 그 기능만 빠지는 것. 크롬·librosa 는 kf setup 이 깐다. 종료 코드: 없음 이 있으면 1`,
12
+ async run() {
13
+ const version = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version ?? "?";
14
+ console.log(`kasaframe ${version} · Node ${process.version} · ${platform()} ${arch()}\n`);
15
+ const list = await checks();
16
+ report(list);
17
+ const missing = list.filter((c) => c.required && !c.ok);
18
+ console.log(missing.length ? `\n렌더에 필요한 것이 ${missing.length}개 없다 — 위 → 대로 깔고 다시 kf doctor` : "\n렌더 준비 됨 — kf new <폴더> --template shorts 부터");
19
+ return missing.length ? 1 : 0;
20
+ },
21
+ };
22
+ export default cmd;