kasaframe 0.0.0-stage → 0.1.3

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 (442) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +148 -0
  3. package/README.md +92 -2
  4. package/app/package.json +12 -0
  5. package/app/server/engine.ts +533 -0
  6. package/app/server/main.ts +209 -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 +1195 -0
  11. package/app/web/style.css +463 -0
  12. package/bin/hooks.mjs +64 -0
  13. package/bin/kf.mjs +32 -0
  14. package/docs/AGENTS.md +286 -0
  15. package/music/README.md +37 -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 +280 -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 +45 -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 +136 -0
  43. package/packages/check/src/rules/layout.ts +281 -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 +30 -0
  52. package/packages/cli/src/blur.ts +80 -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/bench.ts +123 -0
  58. package/packages/cli/src/commands/blender.ts +293 -0
  59. package/packages/cli/src/commands/check.ts +25 -0
  60. package/packages/cli/src/commands/direct.ts +210 -0
  61. package/packages/cli/src/commands/doctor.ts +30 -0
  62. package/packages/cli/src/commands/gen.ts +173 -0
  63. package/packages/cli/src/commands/look.ts +46 -0
  64. package/packages/cli/src/commands/lyrics.ts +157 -0
  65. package/packages/cli/src/commands/make.ts +1121 -0
  66. package/packages/cli/src/commands/music.ts +78 -0
  67. package/packages/cli/src/commands/narrate.ts +238 -0
  68. package/packages/cli/src/commands/new.ts +185 -0
  69. package/packages/cli/src/commands/preview.ts +43 -0
  70. package/packages/cli/src/commands/render.ts +250 -0
  71. package/packages/cli/src/commands/series.ts +110 -0
  72. package/packages/cli/src/commands/setup.ts +102 -0
  73. package/packages/cli/src/commands/sheet.ts +114 -0
  74. package/packages/cli/src/commands/skill.ts +43 -0
  75. package/packages/cli/src/commands/still.ts +40 -0
  76. package/packages/cli/src/commands/theme.ts +172 -0
  77. package/packages/cli/src/commands/transitions.ts +40 -0
  78. package/packages/cli/src/deps.ts +122 -0
  79. package/packages/cli/src/ffmpeg.ts +81 -0
  80. package/packages/cli/src/index.ts +8 -0
  81. package/packages/cli/src/kf.ts +64 -0
  82. package/packages/cli/src/lock.ts +72 -0
  83. package/packages/cli/src/mix.ts +131 -0
  84. package/packages/cli/src/out.ts +13 -0
  85. package/packages/cli/src/pool.ts +34 -0
  86. package/packages/cli/src/segments.ts +402 -0
  87. package/packages/cli/src/serve.ts +124 -0
  88. package/packages/clock/package.json +14 -0
  89. package/packages/clock/src/index.ts +25 -0
  90. package/packages/clock/src/install.ts +351 -0
  91. package/packages/clock/src/types.ts +45 -0
  92. package/packages/core/package.json +9 -0
  93. package/packages/core/src/beats.ts +283 -0
  94. package/packages/core/src/ease.ts +87 -0
  95. package/packages/core/src/frame.ts +99 -0
  96. package/packages/core/src/index.ts +27 -0
  97. package/packages/core/src/project.ts +157 -0
  98. package/packages/core/src/rand.ts +56 -0
  99. package/packages/core/src/spring.ts +52 -0
  100. package/packages/core/src/style.ts +100 -0
  101. package/packages/core/src/time.ts +88 -0
  102. package/packages/director/package.json +23 -0
  103. package/packages/director/src/assemble.ts +361 -0
  104. package/packages/director/src/brief.ts +122 -0
  105. package/packages/director/src/catalog.ts +49 -0
  106. package/packages/director/src/content.ts +343 -0
  107. package/packages/director/src/fallback.ts +58 -0
  108. package/packages/director/src/index.ts +13 -0
  109. package/packages/director/src/pattern.ts +90 -0
  110. package/packages/director/src/patterns/fill/cells.ts +115 -0
  111. package/packages/director/src/patterns/formula/stack.ts +93 -0
  112. package/packages/director/src/patterns/util.ts +58 -0
  113. package/packages/director/src/scene.ts +62 -0
  114. package/packages/director/src/select.ts +163 -0
  115. package/packages/director/src/transitions.ts +115 -0
  116. package/packages/gen/PROVIDERS.md +90 -0
  117. package/packages/gen/package.json +9 -0
  118. package/packages/gen/src/ask.ts +132 -0
  119. package/packages/gen/src/audio.ts +91 -0
  120. package/packages/gen/src/blender.ts +54 -0
  121. package/packages/gen/src/capabilities.ts +151 -0
  122. package/packages/gen/src/generate.ts +206 -0
  123. package/packages/gen/src/image/codex.ts +117 -0
  124. package/packages/gen/src/image/og.ts +133 -0
  125. package/packages/gen/src/index.ts +26 -0
  126. package/packages/gen/src/keys.ts +86 -0
  127. package/packages/gen/src/media.ts +150 -0
  128. package/packages/gen/src/music/elevenlabs.ts +41 -0
  129. package/packages/gen/src/music/index.ts +120 -0
  130. package/packages/gen/src/music/library.ts +73 -0
  131. package/packages/gen/src/music/lyria.ts +52 -0
  132. package/packages/gen/src/music/types.ts +28 -0
  133. package/packages/gen/src/plan.ts +106 -0
  134. package/packages/gen/src/registry.ts +54 -0
  135. package/packages/gen/src/seam.ts +103 -0
  136. package/packages/gen/src/tts/dashscope.ts +64 -0
  137. package/packages/gen/src/tts/elevenlabs.ts +84 -0
  138. package/packages/gen/src/tts/gemini.ts +73 -0
  139. package/packages/gen/src/tts/index.ts +241 -0
  140. package/packages/gen/src/tts/local.ts +42 -0
  141. package/packages/gen/src/tts/types.ts +44 -0
  142. package/packages/gen/src/types.ts +90 -0
  143. package/packages/gen/src/video/fal.ts +140 -0
  144. package/packages/gen/src/video/h3.ts +112 -0
  145. package/packages/gen/src/video/http.ts +44 -0
  146. package/packages/gen/src/video/ltx.ts +60 -0
  147. package/packages/gen/src/video/minimax.ts +55 -0
  148. package/packages/gen/src/video/veo.ts +71 -0
  149. package/packages/jizura/package.json +16 -0
  150. package/packages/jizura/src/deny.ts +73 -0
  151. package/packages/jizura/src/index.ts +7 -0
  152. package/packages/jizura/src/install.ts +269 -0
  153. package/packages/jizura/src/layer.ts +325 -0
  154. package/packages/jizura/src/lyrics.ts +81 -0
  155. package/packages/jizura/src/pin.ts +8 -0
  156. package/packages/jizura/src/vendor.ts +140 -0
  157. package/packages/jizura/src/vite.ts +63 -0
  158. package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
  159. package/packages/jizura/vendor/NOTICE +43 -0
  160. package/packages/jizura/vendor/engine.json +47 -0
  161. package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
  162. package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
  163. package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
  164. package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
  165. package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  166. package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
  167. package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
  168. package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
  169. package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
  170. package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
  171. package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
  172. package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
  173. package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
  174. package/packages/jizura/vendor/fonts/fonts.json +52 -0
  175. package/packages/jizura/vendor/jizura_engine.js +35024 -0
  176. package/packages/motion/README.md +153 -0
  177. package/packages/motion/package.json +30 -0
  178. package/packages/motion/src/camera.ts +97 -0
  179. package/packages/motion/src/captions.ts +487 -0
  180. package/packages/motion/src/enter.ts +128 -0
  181. package/packages/motion/src/hangul.ts +129 -0
  182. package/packages/motion/src/impact.ts +100 -0
  183. package/packages/motion/src/index.ts +17 -0
  184. package/packages/motion/src/moves/brushWipe.ts +103 -0
  185. package/packages/motion/src/moves/cloneArray.ts +111 -0
  186. package/packages/motion/src/moves/curtains.ts +81 -0
  187. package/packages/motion/src/moves/dress.ts +191 -0
  188. package/packages/motion/src/moves/echoStack.ts +70 -0
  189. package/packages/motion/src/moves/fit.ts +70 -0
  190. package/packages/motion/src/moves/flood.ts +61 -0
  191. package/packages/motion/src/moves/ghostStack.ts +88 -0
  192. package/packages/motion/src/moves/gridFloor.ts +100 -0
  193. package/packages/motion/src/moves/imageSwarm.ts +113 -0
  194. package/packages/motion/src/moves/index.ts +38 -0
  195. package/packages/motion/src/moves/irisBlades.ts +113 -0
  196. package/packages/motion/src/moves/karaokeLine.ts +108 -0
  197. package/packages/motion/src/moves/ledBoard.ts +94 -0
  198. package/packages/motion/src/moves/liquidTab.ts +72 -0
  199. package/packages/motion/src/moves/markerWrite.ts +63 -0
  200. package/packages/motion/src/moves/menu.ts +138 -0
  201. package/packages/motion/src/moves/morphBox.ts +96 -0
  202. package/packages/motion/src/moves/neonSign.ts +78 -0
  203. package/packages/motion/src/moves/panelPop.ts +78 -0
  204. package/packages/motion/src/moves/paperTear.ts +75 -0
  205. package/packages/motion/src/moves/path.ts +64 -0
  206. package/packages/motion/src/moves/pathText.ts +107 -0
  207. package/packages/motion/src/moves/pointSphere.ts +109 -0
  208. package/packages/motion/src/moves/ransomLetters.ts +73 -0
  209. package/packages/motion/src/moves/shapeMorph.ts +143 -0
  210. package/packages/motion/src/moves/slamWords.ts +96 -0
  211. package/packages/motion/src/moves/sparkTrail.ts +130 -0
  212. package/packages/motion/src/moves/speedLines.ts +91 -0
  213. package/packages/motion/src/moves/splitFlap.ts +149 -0
  214. package/packages/motion/src/moves/stepWords.ts +128 -0
  215. package/packages/motion/src/moves/strikeOut.ts +98 -0
  216. package/packages/motion/src/moves/sunburst.ts +58 -0
  217. package/packages/motion/src/moves/tickerRows.ts +91 -0
  218. package/packages/motion/src/moves/tokenPicker.ts +115 -0
  219. package/packages/motion/src/moves/truchet.ts +85 -0
  220. package/packages/motion/src/moves/wordWall.ts +78 -0
  221. package/packages/motion/src/moves/worldRoll.ts +32 -0
  222. package/packages/motion/src/particles.ts +102 -0
  223. package/packages/motion/src/react/index.tsx +21 -0
  224. package/packages/motion/src/react/parts.tsx +431 -0
  225. package/packages/motion/src/react/time.tsx +97 -0
  226. package/packages/motion/src/text.ts +103 -0
  227. package/packages/motion/src/transition.ts +82 -0
  228. package/packages/motion/src/transition3d/cards.ts +65 -0
  229. package/packages/motion/src/transition3d/chain.ts +84 -0
  230. package/packages/motion/src/transition3d/dive.ts +181 -0
  231. package/packages/motion/src/transition3d/fold.ts +110 -0
  232. package/packages/motion/src/transition3d/gl.ts +219 -0
  233. package/packages/motion/src/transition3d/index.ts +235 -0
  234. package/packages/motion/src/transition3d/live.ts +70 -0
  235. package/packages/motion/src/transition3d/portal.ts +115 -0
  236. package/packages/motion/src/transition3d/react.tsx +29 -0
  237. package/packages/motion/src/transition3d/rig.ts +600 -0
  238. package/packages/motion/src/transition3d/shatter.ts +104 -0
  239. package/packages/motion/src/transition3d/space.ts +173 -0
  240. package/packages/motion/src/transition3d/whip.ts +78 -0
  241. package/packages/motion/src/transition3d/world.ts +737 -0
  242. package/packages/motion/src/transition3d/zoomOut.ts +114 -0
  243. package/packages/motion/src/transitions2d/catalog.ts +127 -0
  244. package/packages/motion/src/transitions2d/css.d.ts +2 -0
  245. package/packages/motion/src/transitions2d/cult-ui/LICENSE.md +9 -0
  246. package/packages/motion/src/transitions2d/cult-ui/RollingNumber.tsx +43 -0
  247. package/packages/motion/src/transitions2d/gl-transitions/LICENSE +25 -0
  248. package/packages/motion/src/transitions2d/gl-transitions/shaders.ts +1931 -0
  249. package/packages/motion/src/transitions2d/index.ts +2 -0
  250. package/packages/motion/src/transitions2d/kokonutui/LICENSE +21 -0
  251. package/packages/motion/src/transitions2d/kokonutui/MatrixText.css +16 -0
  252. package/packages/motion/src/transitions2d/kokonutui/MatrixText.tsx +94 -0
  253. package/packages/motion/src/transitions2d/magicui/HyperText.css +15 -0
  254. package/packages/motion/src/transitions2d/magicui/HyperText.tsx +115 -0
  255. package/packages/motion/src/transitions2d/magicui/LICENSE.md +21 -0
  256. package/packages/motion/src/transitions2d/magicui/MorphingText.css +25 -0
  257. package/packages/motion/src/transitions2d/magicui/MorphingText.tsx +130 -0
  258. package/packages/motion/src/transitions2d/magicui/WordRotate.tsx +42 -0
  259. package/packages/motion/src/transitions2d/react.ts +13 -0
  260. package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.css +39 -0
  261. package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.tsx +32 -0
  262. package/packages/motion/src/transitions2d/smoothui/LICENSE +21 -0
  263. package/packages/motion/src/transitions2d/smoothui/SharedAxisY.tsx +50 -0
  264. package/packages/motion/src/transitions2d/smoothui/TextMorph.tsx +90 -0
  265. package/packages/motion/src/transitions2d/smoothui/shaders.ts +223 -0
  266. package/packages/motion/src/transitions2d/unlumen/LICENSE +21 -0
  267. package/packages/motion/src/transitions2d/unlumen/ScrambleText.tsx +114 -0
  268. package/packages/motion/src/transitions2d/vengeance-ui/LICENSE +21 -0
  269. package/packages/motion/src/transitions2d/vengeance-ui/MorphText.css +69 -0
  270. package/packages/motion/src/transitions2d/vengeance-ui/MorphText.tsx +45 -0
  271. package/packages/motion/src/type.ts +131 -0
  272. package/packages/motion/src/ui.ts +172 -0
  273. package/packages/patterns/package.json +15 -0
  274. package/packages/patterns/src/bars/rank.ts +119 -0
  275. package/packages/patterns/src/before-after/shrink.ts +127 -0
  276. package/packages/patterns/src/before-after/strike.ts +125 -0
  277. package/packages/patterns/src/code/terminal.ts +133 -0
  278. package/packages/patterns/src/compare/bars.ts +142 -0
  279. package/packages/patterns/src/compare/duel.ts +103 -0
  280. package/packages/patterns/src/end/lockup.ts +85 -0
  281. package/packages/patterns/src/end/slam.ts +91 -0
  282. package/packages/patterns/src/flow/graph.ts +250 -0
  283. package/packages/patterns/src/hook-number/counter.ts +101 -0
  284. package/packages/patterns/src/hook-number/roll.ts +179 -0
  285. package/packages/patterns/src/hook-number/slam.ts +91 -0
  286. package/packages/patterns/src/index.ts +38 -0
  287. package/packages/patterns/src/kit.ts +449 -0
  288. package/packages/patterns/src/limit/gauge.ts +117 -0
  289. package/packages/patterns/src/list/cards.ts +131 -0
  290. package/packages/patterns/src/list/cycle.ts +22 -0
  291. package/packages/patterns/src/media/panels.ts +100 -0
  292. package/packages/patterns/src/objects.ts +234 -0
  293. package/packages/patterns/src/quote/karaoke.ts +126 -0
  294. package/packages/patterns/src/quote/slam.ts +22 -0
  295. package/packages/patterns/src/screen/focus.ts +85 -0
  296. package/packages/patterns/src/steps/path.ts +137 -0
  297. package/packages/patterns/src/steps/stack.ts +111 -0
  298. package/packages/patterns/src/term-reveal/chips.ts +92 -0
  299. package/packages/patterns/src/term-reveal/expand.ts +127 -0
  300. package/packages/patterns/src/timeline/axis.ts +144 -0
  301. package/packages/patterns/src/title/mask.ts +85 -0
  302. package/packages/patterns/src/title/slam.ts +23 -0
  303. package/packages/patterns/src/toggle-sim/switch.ts +92 -0
  304. package/packages/patterns/src/trend/columns.ts +108 -0
  305. package/packages/patterns/src/trend/draw.ts +140 -0
  306. package/packages/patterns/src/types.ts +67 -0
  307. package/packages/patterns/src/words/moves.ts +42 -0
  308. package/packages/patterns/src/words/slam.ts +4 -0
  309. package/packages/plan/package.json +15 -0
  310. package/packages/plan/src/asks.ts +256 -0
  311. package/packages/plan/src/brief.ts +383 -0
  312. package/packages/plan/src/captions.ts +78 -0
  313. package/packages/plan/src/detect.ts +93 -0
  314. package/packages/plan/src/index.ts +27 -0
  315. package/packages/plan/src/layout.ts +176 -0
  316. package/packages/plan/src/llm.ts +172 -0
  317. package/packages/plan/src/look.ts +222 -0
  318. package/packages/plan/src/plan.ts +179 -0
  319. package/packages/plan/src/request.ts +55 -0
  320. package/packages/plan/src/series.ts +76 -0
  321. package/packages/plan/src/studio.ts +53 -0
  322. package/packages/plan/src/templates.ts +42 -0
  323. package/packages/plan/src/themes.ts +48 -0
  324. package/packages/plan/src/types.ts +157 -0
  325. package/packages/studio/package.json +12 -0
  326. package/packages/studio/src/index.ts +1 -0
  327. package/packages/studio/src/plugin.ts +236 -0
  328. package/packages/studio/src/ui/main.ts +503 -0
  329. package/packages/studio/src/ui/studio.css +165 -0
  330. package/packages/studio/src/ui/timeline.ts +317 -0
  331. package/packages/themes/kit/THEME-MAKE.md +68 -0
  332. package/packages/themes/kit/THEME.md +30 -0
  333. package/packages/themes/kit/briefs/og-intro-15s/brief.json +16 -0
  334. package/packages/themes/kit/briefs/og-intro-15s/facts.ts +29 -0
  335. package/packages/themes/kit/briefs/og-intro-15s/logo.svg +12 -0
  336. package/packages/themes/kit/effects.ts +5 -0
  337. package/packages/themes/kit/sample/index.html +11 -0
  338. package/packages/themes/kit/sample/main.ts +9 -0
  339. package/packages/themes/kit/sample/scenes/scene.ts +15 -0
  340. package/packages/themes/package.json +19 -0
  341. package/packages/themes/src/factory.ts +296 -0
  342. package/packages/themes/src/fx.ts +14 -0
  343. package/packages/themes/src/index.ts +114 -0
  344. package/packages/themes/src/slotkit.ts +79 -0
  345. package/packages/themes/src/slots.ts +46 -0
  346. package/packages/themes/src/stage3d.ts +197 -0
  347. package/packages/themes/src/types.ts +58 -0
  348. package/presets/kawaii-pastel.json +94 -0
  349. package/presets/neutral-dark.json +82 -0
  350. package/presets/og.json +98 -0
  351. package/series/og-shorts.json +14 -0
  352. package/skills/kasaframe/SKILL.md +33 -0
  353. package/templates/_shared/project/index.html +11 -0
  354. package/templates/_shared/project/lib/react.ts +22 -0
  355. package/templates/_shared/project/lib/stage.ts +292 -0
  356. package/templates/_shared/project/main.ts +9 -0
  357. package/templates/_shared/scenes/default.ts +21 -0
  358. package/templates/_shared/scenes/drop.ts +33 -0
  359. package/templates/_shared/scenes/end.ts +26 -0
  360. package/templates/_shared/scenes/hook.ts +18 -0
  361. package/templates/_shared/scenes/seam.ts +41 -0
  362. package/templates/_shared/scenes/step.ts +49 -0
  363. package/templates/ad/preview.png +0 -0
  364. package/templates/ad/template.json +151 -0
  365. package/templates/demo/preview.png +0 -0
  366. package/templates/demo/template.json +141 -0
  367. package/templates/explainer/preview.png +0 -0
  368. package/templates/explainer/template.json +193 -0
  369. package/templates/launch/preview.png +0 -0
  370. package/templates/launch/template.json +163 -0
  371. package/templates/mv/preview.png +0 -0
  372. package/templates/mv/scenes/default.ts +98 -0
  373. package/templates/mv/template.json +178 -0
  374. package/templates/promo/preview.png +0 -0
  375. package/templates/promo/template.json +185 -0
  376. package/templates/shorts/preview.png +0 -0
  377. package/templates/shorts/scenes/hook.ts +37 -0
  378. package/templates/shorts/template.json +196 -0
  379. package/templates/square/preview.png +0 -0
  380. package/templates/square/template.json +133 -0
  381. package/themes/debug-hud/THEME.md +39 -0
  382. package/themes/debug-hud/effects.ts +389 -0
  383. package/themes/debug-hud/sample/assets/og/logo.svg +12 -0
  384. package/themes/debug-hud/sample/beats.json +1 -0
  385. package/themes/debug-hud/sample/index.html +11 -0
  386. package/themes/debug-hud/sample/kf.json +59 -0
  387. package/themes/debug-hud/sample/lib/hudkit.ts +49 -0
  388. package/themes/debug-hud/sample/lib/og.ts +29 -0
  389. package/themes/debug-hud/sample/main.ts +9 -0
  390. package/themes/debug-hud/sample/scenes/end.ts +62 -0
  391. package/themes/debug-hud/sample/scenes/gather.ts +67 -0
  392. package/themes/debug-hud/sample/scenes/models.ts +70 -0
  393. package/themes/debug-hud/sample/scenes/price.ts +79 -0
  394. package/themes/debug-hud/sample/style.json +91 -0
  395. package/themes/debug-hud/slots.ts +99 -0
  396. package/themes/debug-hud/still.png +0 -0
  397. package/themes/debug-hud/style.json +91 -0
  398. package/themes/debug-hud/theme.json +68 -0
  399. package/themes/og-measure/THEME.md +47 -0
  400. package/themes/og-measure/effects.ts +432 -0
  401. package/themes/og-measure/sample/assets/brand/og-logo-full.svg +12 -0
  402. package/themes/og-measure/sample/assets/brand/og-logo.svg +12 -0
  403. package/themes/og-measure/sample/assets/data/measure.json +353 -0
  404. package/themes/og-measure/sample/beats.json +1 -0
  405. package/themes/og-measure/sample/index.html +11 -0
  406. package/themes/og-measure/sample/kf.json +66 -0
  407. package/themes/og-measure/sample/lib/data.ts +62 -0
  408. package/themes/og-measure/sample/main.ts +9 -0
  409. package/themes/og-measure/sample/measure.ts +78 -0
  410. package/themes/og-measure/sample/scenes/end.ts +47 -0
  411. package/themes/og-measure/sample/scenes/gather.ts +51 -0
  412. package/themes/og-measure/sample/scenes/models.ts +34 -0
  413. package/themes/og-measure/sample/scenes/one.ts +39 -0
  414. package/themes/og-measure/sample/scenes/price.ts +56 -0
  415. package/themes/og-measure/sample/style.json +97 -0
  416. package/themes/og-measure/slots.ts +88 -0
  417. package/themes/og-measure/still.png +0 -0
  418. package/themes/og-measure/style.json +97 -0
  419. package/themes/og-measure/theme.json +79 -0
  420. package/themes/riso-zine/THEME.md +43 -0
  421. package/themes/riso-zine/effects.ts +321 -0
  422. package/themes/riso-zine/sample/assets/og/logo.svg +12 -0
  423. package/themes/riso-zine/sample/beats.json +1 -0
  424. package/themes/riso-zine/sample/index.html +11 -0
  425. package/themes/riso-zine/sample/kf.json +59 -0
  426. package/themes/riso-zine/sample/lib/og.ts +29 -0
  427. package/themes/riso-zine/sample/lib/shot.ts +86 -0
  428. package/themes/riso-zine/sample/lib/zine.ts +42 -0
  429. package/themes/riso-zine/sample/main.ts +9 -0
  430. package/themes/riso-zine/sample/scenes/end.ts +61 -0
  431. package/themes/riso-zine/sample/scenes/gather.ts +62 -0
  432. package/themes/riso-zine/sample/scenes/models.ts +52 -0
  433. package/themes/riso-zine/sample/scenes/price.ts +82 -0
  434. package/themes/riso-zine/sample/style.json +99 -0
  435. package/themes/riso-zine/slots.ts +81 -0
  436. package/themes/riso-zine/still.png +0 -0
  437. package/themes/riso-zine/style.json +99 -0
  438. package/themes/riso-zine/theme.json +75 -0
  439. package/tools/beats/beats.py +372 -0
  440. package/tools/beats/requirements.txt +3 -0
  441. package/tools/lyrics/align.py +164 -0
  442. package/tools/lyrics/requirements.txt +9 -0
@@ -0,0 +1,1121 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, extname, join, resolve } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { askTerminal, blenderLibDir, capabilities, genImage, genVideo, hasKey, NeedsApproval, parseAllow, type Question, questionsText, seam, seamFrames, settle, still, whereToPut } from "@kasaframe/gen";
7
+ import { applyAnswers, attachStudio, loadSeries, type Series, seriesFromRequest, chat, codeBlock, detect, formatLook, jsonBlock, listTemplates, loadTemplate, look, lookSheet, makePlan, type Message, type Plan, planQuestions, premiumBlock, projectBrief, PURPOSES, type Purpose, readProject, spend, spentLine, WAIT, writeCaptions, writeStats } from "@kasaframe/plan";
8
+ import { checkContent, contentPrompt, directBrief } from "@kasaframe/director";
9
+ import { transform } from "esbuild";
10
+ import { bundleFor, dressSeed, pickDress } from "@kasaframe/motion/menu";
11
+ import { themeStyle } from "@kasaframe/themes";
12
+ import { captionMode } from "@kasaframe/motion/captions";
13
+ import { jizuraCheck } from "@kasaframe/jizura/install";
14
+ import { openPage } from "../browser.ts";
15
+ import { librosa } from "../deps.ts";
16
+ import { lyricsCheck } from "./lyrics.ts";
17
+ import { type Command, num, parseFlags } from "../command.ts";
18
+ import { acquireRenderLock } from "../lock.ts";
19
+ import { gate, jobLimit, pool } from "../pool.ts";
20
+
21
+ const kfBin = fileURLToPath(new URL("../kf.ts", import.meta.url));
22
+ const repo = fileURLToPath(new URL("../../../../", import.meta.url));
23
+
24
+ // 박자(librosa)·뮤비 가사(JIZURA·정렬) — 없으면 넣는 법·지금 되는 다른 길·빼기를 묻는다
25
+ function toolQuestions(purpose: Purpose, willSong: boolean): Question[] {
26
+ const out: Question[] = [];
27
+ const b = librosa();
28
+ if (willSong && !b.ok)
29
+ out.push({
30
+ id: "beats", cap: "beats", header: "박자 분석", flag: "--beats", missing: true, default: WAIT, skip: "none",
31
+ ask: `곡에 장면 경계·움직임을 맞출 박자 분석(librosa)이 없다 — ${b.detail}`,
32
+ choices: [{ value: WAIT, label: "깔고 다시", note: b.fix ?? "kf setup beats" }, { value: "none", label: "곡 없이", note: "곡을 빼고 화면 안 움직임으로만 — 박 없이 곡만 까는 길은 없다" }],
33
+ });
34
+ if (purpose === "mv") {
35
+ const j = jizuraCheck();
36
+ const l = lyricsCheck();
37
+ if (!j.ok || !l.ok)
38
+ out.push({
39
+ id: "lyrics", cap: "lyrics", header: "가사", flag: "--lyrics", missing: true, default: WAIT, skip: "none",
40
+ ask: `뮤비 가사에 쓸 ${[!j.ok ? "가사 층 엔진(JIZURA)" : "", !l.ok ? "가사 정렬(demucs·stable-ts)" : ""].filter(Boolean).join("·")} 이 없다`,
41
+ choices: [
42
+ { value: WAIT, label: "깔고 다시", note: `kf setup ${[!j.ok ? "jizura" : "", !l.ok ? "lyrics" : ""].filter(Boolean).join(" ")}` },
43
+ ...(l.ok && !j.ok ? [{ value: "words", label: "가사 층 없이 낱말로", note: "정렬한 낱말을 DOM 글로 띄운다 — JIZURA 레이아웃·효과는 없다" }] : []),
44
+ { value: "none", label: "가사 없이", note: "곡과 화면만" },
45
+ ],
46
+ });
47
+ }
48
+ return out;
49
+ }
50
+
51
+ // 시리즈가 박은 답(목소리·곡 파일·생성 답) — 질문 플래그 값으로 넣어 묻지 않는다
52
+ function seriesAnswers(s: Series | null): Record<string, string> {
53
+ if (!s) return {};
54
+ return Object.fromEntries(Object.entries({ ...s.answers, voice: s.voice, music: s.music?.file && resolve(repo, s.music.file) }).filter((e): e is [string, string] => typeof e[1] === "string"));
55
+ }
56
+
57
+ // Claude Code 를 OG 로 다른 모델(딥시크 등)에 붙이면 OG /v1/messages 가 그림을 버린다 — 세션이 kf sheet 를 Read 해도 못 본다.
58
+ // 그런 「눈 없는 세션」이면 --finish 끝에 --vision-model 을 알려 준다(그림 보는 모델은 기본으로 부르지 않는다)
59
+ export const eyelessSession = (): boolean =>
60
+ /opengateway/i.test(process.env.ANTHROPIC_BASE_URL ?? "") && !/^anthropic\//.test(process.env.ANTHROPIC_MODEL ?? "anthropic/");
61
+ const AUDIO = /\.(mp3|wav|m4a|aac|flac|ogg)$/i;
62
+ const DEFAULT_VISION = "google/gemini-3.5-flash";
63
+ // 화면에 찍히면 안 되는 글 — 지난 실험에서 실제로 나온 것들
64
+ const BAD_TEXT = /\bundefined\b|\bNaN\b|\[object Object\]|\bnull\b|�/;
65
+
66
+ const cmd: Command = {
67
+ summary: "요청 한 줄로 끝까지 — 용도 판별·계획·생성 자산·장면 작성·렌더·검사·고치기",
68
+ usage: `kf make "<요청>" [--out 폴더] [--assets 폴더] [--music 곡] [--model claude-code|<OG 모델>] [--purpose 용도] [--theme id|auto] [--title 이름] [--yes]
69
+ kf make --finish <폴더> [--model ...] 장면을 다 채운 뒤 렌더·검사·표지(Claude Code 모드의 둘째 단계)
70
+ kf make --rewrite <폴더> --scenes a,b [--model <OG 모델>] 고른 장면만 다시 쓰고 그 조각만 다시 굽고 그 구간만 검사(작업실 장면 카드)
71
+ 감독 프로젝트는 모델 없이 kf direct --scenes 로 다시 짓는다(--pattern a=<패턴>·--scene-seed a=<글> 로 다른 후보)
72
+ 모델을 안 주면 그 일을 만든 모델(plan.json 의 model) — 장면 코드를 쓰는 길인데 그것도 없으면 멈춘다
73
+ kf make --finish <폴더> --scenes a,b 장면 파일을 손으로 고친 뒤 — 바뀐 조각만 다시 굽고 그 구간만 검사
74
+ kf make --answer <폴더> --image <값> --video <값> … Claude Code 모드에서 MAKE.md 맨 위 질문의 답을 넣는다(계획·지시서를 고쳐 쓴다)
75
+
76
+ 생성 능력(kf doctor 「생성 능력」): 계획은 그림 컷·주인공 3D 샷(Blender)·이음매 3D 전환(three)·캐릭터가 움직이는 컷(영상 모델)을
77
+ 기본으로 넣는다. 돈이 들거나 없는 능력은 만들기 전에 한 번에 묻는다 — Claude Code 모드는 MAKE.md 맨 위, OG 모드는 터미널에서
78
+ (터미널이 아니면 보기와 다시 부를 플래그를 찍고 종료 코드 3)
79
+ --image og-…|codex-imagegen|none · --video <공급자>|none · --blender three|none 그 질문에 미리 답한다
80
+ --voice <엔진>:<목소리>|none 내레이션 목소리(쇼츠·설명 — 보기는 로컬 Qwen3(KF_QWEN3_TTS_URL 있을 때)·ElevenLabs·자막만·그 밖 순, 키 없는 보기는 넣는 법. kf narrate --voices)
81
+ --music <곡 파일>|gen:<공급자>|none 곡(쇼츠·설명은 분위기 맞는 곡 모음 곡을 묻지 않고 고르고, 없을 때만 생성·곡 넣기·없음을 묻는다)
82
+ --allow image,video,… 그 능력은 권장 답으로, 목록에 없는 능력은 돈 안 드는 길로 — 묻지 않는다
83
+
84
+ 감독 모드(기본, 뮤비 빼고): 모델은 content.json(장면마다 type·칸·들릴 말·출처)만 쓰고, kf direct 가 검증된 장면 문법을
85
+ 골라 장면 파일을 조립한다(LLM 이 장면 코드를 안 쓴다 — 어떤 모델이든 content.json 만 맞으면 화면 품질이 같다)
86
+ --no-direct 감독 없이 모델이 장면 코드를 직접 쓰는 길(뮤비는 늘 이 길)
87
+ --model claude-code(기본, 어디서 부르든): 지시서(MAKE.md)까지 만들고 멈춘다 — 감독 모드면 세션이 content.json 을 쓰고
88
+ kf direct → --finish, --no-direct 면 세션이 장면을 채우고 --finish. OG 를 한 번도 부르지 않는다
89
+ <OG 모델>: 짚을 때만 — OG chat/completions 로 content.json(--no-direct 면 장면)을 쓰고 렌더·검사까지
90
+ --vision-model 그림 보는 검사 모델(기본 none — 세션이 kf look 의 장면 모음·확인표를 직접 본다). 그림을 못 보는 세션만 ${DEFAULT_VISION} 같은 모델을 준다
91
+ --allow-premium Opus·GPT 계열 OG 모델(--model·--vision-model·--rewrite)을 푼다(KF_ALLOW_PREMIUM=1 과 같다). 없으면 부르기 전에 막는다
92
+ 끝날 때(성공·실패·중단) <작업 폴더>/make-stats.json 에 단계별 모델·ms·토큰·usd 와 합계를 남기고 「OG 호출 N번 · 토큰 · $」 한 줄을 찍는다
93
+ --series 시리즈 묶음(series/<id>.json) · none. 안 주면 요청이 짚은 시리즈(「OG 쇼츠」처럼) — 용도·테마·목소리·곡·자막·길이·끝 카드·생성 답을 묻지 않고 그 묶음대로
94
+ --purpose 용도를 못 박는다: ${PURPOSES.join(" · ")}
95
+ --theme 테마 id · auto(요청이 「리소 느낌」처럼 짚은 테마, 아니면 내용에 맞는 approved 후보 셋 중 첫째. 첫 장면을 셋 다 찍어 out/theme-<id>.png) · none(프리셋만)
96
+ 안 주면(시리즈가 정하지도 않았으면) 묻는다 — 보기는 approved 테마 전부(생김새 한 줄·2D/3D·견본)와 「알아서」(= auto). kf theme sheet
97
+ --yes 묻지 않고 권장 답으로 — 돈이 드는 그림·영상도 승인한다(지역 제한이 걸린 H3 는 --video h3-pod 로 짚어야만)
98
+ --max-usd 영상 하나 상한(기본 1)
99
+ --video-provider 영상 공급자를 못 박는다(예: h3-pod — KF_H3_URL·KF_H3_STEPS 필요). 기본은 KF_VIDEO_PROVIDER, 없으면 생성 담당 기본
100
+ --fix 검사가 떨어졌을 때 고치기 횟수(기본 2)
101
+ --captions on|off|fit 화면 자막(fit 은 장면을 자막 띠 위로 줄여 올린다) — 새로 만들 땐 kf.json 에 적고, --finish 에선 그 판만 덮는다(다시 만들지 않고 켜고 끄기). srt·vtt 는 둘 다 낸다
102
+ --suffix [글] --finish 결과 이름 뒤에 붙인다(값 없이면 -자막 · -자막없음) — 원본 video.mp4 를 덮지 않고 둘 다 남긴다
103
+ --no-gen 생성 모델을 부르지 않는다 — 묻지 않고 그림·영상 모델은 빼고, Blender 가 없으면 three 로 대신한다
104
+
105
+ 용도를 못 정하면 터미널에서 묻고, 터미널이 아니면 보기를 찍고 종료 코드 3.`,
106
+ async run(argv) {
107
+ // 장부는 어느 길로 끝나든(성공·실패·throw·Ctrl-C) 한 번 — 작업 폴더가 서기 전에 끝났으면 줄만 찍는다
108
+ const at: Track = {};
109
+ let code: number | string = "오류";
110
+ let closed = false;
111
+ const close = () => {
112
+ if (closed || at.quiet) return;
113
+ closed = true;
114
+ if (at.dir && existsSync(at.dir)) writeStats(at.dir, { command: at.command ?? "make", model: at.model ?? "", code });
115
+ console.log(`\n${spentLine()}`);
116
+ };
117
+ const stop = (sig: NodeJS.Signals) => {
118
+ code = sig;
119
+ close();
120
+ process.exit(sig === "SIGINT" ? 130 : 143);
121
+ };
122
+ process.once("SIGINT", stop);
123
+ process.once("SIGTERM", stop);
124
+ try {
125
+ code = await make(argv, at);
126
+ return code;
127
+ } finally {
128
+ process.off("SIGINT", stop);
129
+ process.off("SIGTERM", stop);
130
+ close();
131
+ }
132
+ },
133
+ };
134
+ export default cmd;
135
+
136
+ interface Track {
137
+ dir?: string;
138
+ model?: string;
139
+ command?: string;
140
+ quiet?: boolean;
141
+ }
142
+
143
+ async function make(argv: string[], at: Track): Promise<number> {
144
+ const { args, opts } = parseFlags(argv);
145
+ const str = (k: string) => (typeof opts[k] === "string" ? (opts[k] as string) : undefined);
146
+ // OG 모델은 짚을 때만 — 어디서 부르든(Claude Code 안팎·작업실) 기본은 지시서까지 만들고 세션이 잇는 claude-code
147
+ const model = str("model") ?? "claude-code";
148
+ const vision = str("vision-model") ?? "none";
149
+ at.model = model;
150
+ if (opts["allow-premium"]) process.env.KF_ALLOW_PREMIUM = "1";
151
+ for (const m of [model, vision]) {
152
+ const why = m !== "claude-code" && m !== "none" ? premiumBlock(m) : null;
153
+ if (why) {
154
+ console.error(`kf make: ${why}`);
155
+ return 2;
156
+ }
157
+ }
158
+ // OG 모드는 장면 쓰기·그림 보기까지 OG 를 부른다 — 뼈대만 만들고 죽지 않게 처음에 막는다
159
+ if (model !== "claude-code" && !hasKey("opengateway")) {
160
+ console.error(`kf make: OG 키가 없다 — ${whereToPut("opengateway")}. 키 없이는 --model 을 빼고(claude-code) — 장면은 세션이 쓰고 그림 생성은 건너뛴다`);
161
+ return 2;
162
+ }
163
+ const fixes = num(opts.fix, 2);
164
+ const captions = str("captions");
165
+ if (captions !== undefined && captions !== "on" && captions !== "off" && captions !== "fit") {
166
+ console.error(`kf make: --captions 는 on·off·fit — ${captions}`);
167
+ return 2;
168
+ }
169
+ const suffix = str("suffix") ?? (opts.suffix ? (captions === "off" ? "-자막없음" : "-자막") : "");
170
+
171
+ if (opts.answer) {
172
+ at.quiet = true;
173
+ const dir = resolve(typeof opts.answer === "string" ? opts.answer : (args[0] ?? "."));
174
+ return answerProject(dir, opts);
175
+ }
176
+ if (opts.rewrite) {
177
+ const dir = resolve(typeof opts.rewrite === "string" ? opts.rewrite : (args[0] ?? "."));
178
+ Object.assign(at, { dir, command: "rewrite" });
179
+ const ids = (str("scenes") ?? "").split(",").filter(Boolean);
180
+ if (!ids.length) {
181
+ console.error("kf make --rewrite: --scenes a,b 가 필요하다");
182
+ return 2;
183
+ }
184
+ // 감독 프로젝트 — 모델 없이 그 장면만 다시 짓는다(다른 장면 파일은 바이트 그대로라 그 조각은 다시 안 굽는다)
185
+ if (isDirect(dir)) {
186
+ const pick = ["pattern", "scene-seed"].flatMap((k) => (str(k) ? [`--${k}`, str(k)!] : []));
187
+ // 들릴 말을 고쳤을 때만 다시 읽힌다 — 목소리가 장면 길이를 정해서 뒤 장면 시각이 움직인다(그 조각들도 다시 굽힌다)
188
+ const narrate = sayChanged(dir, ids) ? [] : ["--no-narrate"];
189
+ const d = await relay(["direct", dir, "--scenes", ids.join(","), ...narrate, "--no-check", ...pick]);
190
+ if (d !== 0) return d;
191
+ return finish(dir, { model: "direct", vision, fixes: 0, scenes: ids });
192
+ }
193
+ // 다시 쓰기는 그 일을 만든 모델로 — 기본 모델로 몰래 바꾸지 않는다
194
+ const own = str("model") ?? readPlan(dir).model;
195
+ if (!own || own === "claude-code") {
196
+ console.error(`kf make --rewrite: 장면 코드를 다시 쓸 OG 모델이 없다 — --model <OG 모델>로 주거나, 세션이 ${ids.join(",")} 장면 파일을 고치고 kf make --finish ${dir} --scenes ${ids.join(",")}`);
197
+ return 2;
198
+ }
199
+ at.model = own;
200
+ // 그 일을 만든 모델이 비싼 계열이면 다시 쓰기도 같은 문에서 막는다
201
+ if (premiumBlock(own)) {
202
+ console.error(`kf make --rewrite: ${premiumBlock(own)}`);
203
+ return 2;
204
+ }
205
+ writeFileSync(join(dir, "MAKE.md"), projectBrief(dir));
206
+ await writeScenes(dir, own, ids);
207
+ return finish(dir, { model: own, vision, fixes, scenes: ids });
208
+ }
209
+ if (opts.finish) {
210
+ const dir = resolve(typeof opts.finish === "string" ? opts.finish : (args[0] ?? "."));
211
+ Object.assign(at, { dir, command: "finish" });
212
+ const plan = existsSync(join(dir, "plan.json")) ? JSON.parse(readFileSync(join(dir, "plan.json"), "utf8")) : {};
213
+ const gen = { approve: !!opts.yes || videoApproved(plan), maxUsd: num(opts["max-usd"], 1), videoProvider: str("video-provider") ?? process.env.KF_VIDEO_PROVIDER };
214
+ const studio = await attachStudio({ dir, request: plan.request ?? dir, model, purpose: plan.purpose });
215
+ const unmirror = studio?.mirror();
216
+ if (studio) console.log(`작업실: ${studio.url}`);
217
+ await studio?.send({ phase: "render" });
218
+ const scenes = str("scenes")?.split(",").filter(Boolean);
219
+ const code = await finish(dir, { model, vision, fixes, gen: opts["no-gen"] ? undefined : gen, captions, suffix, ...(scenes?.length ? { scenes } : {}) });
220
+ await unmirror?.();
221
+ await studio?.send({ done: true, ok: code === 0 });
222
+ return code;
223
+ }
224
+
225
+ const request = args.join(" ").trim();
226
+ if (!request) {
227
+ console.error('kf make: 요청 글이 필요하다 — kf make "카사텀 홍보 영상 1분"');
228
+ return 2;
229
+ }
230
+ const assetFiles = str("assets") ? listFiles(resolve(str("assets")!)) : [];
231
+ // 시리즈가 먼저 — 같은 시리즈면 매번 같은 묶음. 사람이 따로 준 플래그는 그대로 이긴다
232
+ const series: Series | null = str("series") === "none" ? null : str("series") ? loadSeries(str("series")!) : seriesFromRequest(request);
233
+ if (series) console.error(`시리즈 ${series.id}(${series.label}) — 용도·테마·목소리·곡·자막·길이·끝 카드를 묶음대로`);
234
+ // --music 은 곡 파일, 아니면 음악 질문의 답(gen:<공급자>·none) — 답은 파일로 읽지 않는다
235
+ const musicFlag = str("music");
236
+ const musicAnswer = musicFlag !== undefined && /^(gen:|none$)/.test(musicFlag);
237
+ const given = musicFlag && !musicAnswer ? resolve(musicFlag) : (assetFiles.find((f) => AUDIO.test(f)) ?? (series?.music?.file ? resolve(repo, series.music.file) : undefined));
238
+
239
+ let purpose = (str("purpose") ?? series?.purpose) as Purpose | undefined;
240
+ const det = detect({ request, assets: assetFiles, music: given }, listTemplates());
241
+ if (!purpose) {
242
+ if (det.ambiguous) {
243
+ purpose = await ask(det.ranked.slice(0, 4).map((r) => r.purpose));
244
+ if (!purpose) {
245
+ console.error(`kf make: 용도를 못 정했다(확신 ${det.confidence}). --purpose 로 골라 다시: ${det.ranked.slice(0, 4).map((r) => `${r.purpose}(${r.p})`).join(" · ")}`);
246
+ return 3;
247
+ }
248
+ } else purpose = det.purpose;
249
+ }
250
+ const chosen = det.ranked.find((r) => r.purpose === purpose);
251
+ // 곡을 안 줬으면 곡이 끌고 가는 용도(홍보·공개)일 때만 곡 모음에서 고른다 — 쇼츠·설명에 아무 곡이나 깔리지 않게
252
+ // 분위기를 정한 틀(쇼츠·설명)은 곡을 묻기 틀이 고른다(분위기 맞는 곡 모음 곡 → 생성 → 없음)
253
+ const tplOf = loadTemplate(purpose);
254
+ const music = given ?? (!musicAnswer && tplOf.music === "required" && !tplOf.mood ? findLibraryMusic(request) : undefined);
255
+ console.error(`용도 ${purpose} (${series && !str("purpose") ? `시리즈 ${series.id}` : `확신 ${chosen?.p ?? 1}${chosen?.reasons.length ? ` — ${chosen.reasons.join(", ")}` : ""}`})`);
256
+
257
+ // 만들기 전에 묻는다 — 뼈대를 만들고 나서 멈추면 반쯤 만든 폴더가 남는다
258
+ const caps = await capabilities({ probe: true });
259
+ const draft = makePlan({ request, purpose, assets: assetFiles, music: given ?? null, series: series?.id ?? "none" });
260
+ const { questions, auto } = planQuestions(draft, caps);
261
+ const flags = { ...seriesAnswers(series), ...opts };
262
+ const settled = settle(questions, { given: flags, yes: !!opts.yes, noGen: !!opts["no-gen"], allow: parseAllow(opts.allow) });
263
+ // 묻지 않고 정한 답(있는 공짜 능력)도 사람이 준 플래그·시리즈 답이 이긴다. --no-gen 이면 공짜라도 생성 모델(그림·영상)은 뺀다
264
+ const forced = Object.fromEntries(
265
+ Object.keys(auto).flatMap((id) => (typeof flags[id] === "string" ? [[id, flags[id] as string]] : opts["no-gen"] && (id === "image" || id === "video") ? [[id, "none"]] : [])),
266
+ );
267
+ let answers: Record<string, string> = { ...auto, ...forced, ...settled.answers };
268
+ let open = settled.open;
269
+ // 박자·가사 도구는 뼈대를 세울 때 쓴다 — 없으면 조용히 박 없이·낱말로 가지 않고, 모드와 상관없이 뼈대 전에 답을 받는다
270
+ const willSong = !!(music ?? (answers.music && !/^(gen:|none$|wait$)/.test(answers.music) ? answers.music : undefined));
271
+ const tools = toolQuestions(purpose, willSong);
272
+ const toolSettled = settle(tools, { given: flags, yes: !!opts.yes, noGen: !!opts["no-gen"], allow: parseAllow(opts.allow) });
273
+ questions.push(...tools);
274
+ answers = { ...answers, ...toolSettled.answers };
275
+ Object.assign(settled.how, toolSettled.how);
276
+ if (toolSettled.open.length) {
277
+ const asked = await askTerminal(toolSettled.open);
278
+ if (!asked) {
279
+ console.error(`kf make: 뼈대 전에 정할 것이 ${toolSettled.open.length}가지 — 사람에게 묻고 플래그로 답해 다시 부른다\n\n${questionsText(toolSettled.open)}`);
280
+ return 3;
281
+ }
282
+ answers = { ...answers, ...asked };
283
+ }
284
+ if (answers.beats === "none" && answers.music && !/^(gen:|none$)/.test(answers.music)) answers.music = "none";
285
+ if (open.length && model !== "claude-code") {
286
+ const asked = await askTerminal(open);
287
+ if (!asked) {
288
+ console.error(`kf make: 만들기 전에 정할 것이 ${open.length}가지 — 플래그로 답해 다시 부른다(--yes 면 권장, --no-gen 이면 돈 안 드는 길)\n\n${questionsText(open)}`);
289
+ return 3;
290
+ }
291
+ answers = { ...answers, ...asked };
292
+ open = [];
293
+ }
294
+ for (const [id, v] of Object.entries(answers)) console.error(`능력 ${id}: ${v}(${settled.how[id] === "flag" || forced[id] ? "플래그" : settled.how[id] ? `--${settled.how[id]}` : auto[id] ? "있어서 바로" : "물어서"})`);
295
+ const stops = questions.filter((q) => answers[q.id] === WAIT);
296
+ if (stops.length) {
297
+ console.error(`kf make: 켜고 다시 부른다 —\n${stops.map((q) => ` ${q.ask}: ${q.choices.find((c) => c.value === WAIT)?.note}`).join("\n")}`);
298
+ return 3;
299
+ }
300
+
301
+ const dir = resolve(str("out") ?? `${purpose}-${stamp()}`);
302
+ Object.assign(at, { dir, command: "make" });
303
+ const newArgs = [kfBin, "new", dir, "--template", purpose, "--request", request, "--series", series?.id ?? "none"];
304
+ if (series?.end?.line) newArgs.push("--line", series.end.line);
305
+ // 박자 분석을 빼기로 했으면 곡도 깔지 않는다 — 박 없이 곡만 까는 길은 없다
306
+ const song = answers.beats === "none" ? undefined : (music ?? (answers.music && !/^(gen:|none$|wait$)/.test(answers.music) ? answers.music : undefined));
307
+ if (song) newArgs.push("--music", song);
308
+ if (str("title")) newArgs.push("--title", str("title")!);
309
+ // 테마 질문의 답(플래그·터미널) → 아직 묻는 중이거나 묻지 않은 테마(시리즈·요청이 짚은 것)는 auto. 테마 없이 프리셋만 쓰려면 --theme none
310
+ newArgs.push("--theme", answers.theme && answers.theme !== WAIT ? answers.theme : (str("theme") ?? "auto"));
311
+ if (opts.force) newArgs.push("--force");
312
+ const made = spawnSync(process.execPath, newArgs, { stdio: ["ignore", "inherit", "inherit"] });
313
+ if (made.status !== 0) return made.status ?? 1;
314
+
315
+ const studio = await attachStudio({ dir, request, model, purpose });
316
+ const unmirror = studio?.mirror();
317
+ if (studio) console.log(`작업실: ${studio.url}`);
318
+ const plan: Plan = { ...applyAnswers(JSON.parse(readFileSync(join(dir, "plan.json"), "utf8")), answers).plan, asks: open };
319
+ // 새 프로젝트는 이 영상의 기본으로 적는다 — 계획 다듬기가 내레이션을 쓸지도 이걸 본다
320
+ if (captions) {
321
+ const kfPath = join(dir, "kf.json");
322
+ writeJson(kfPath, { ...JSON.parse(readFileSync(kfPath, "utf8")), captions });
323
+ plan.captions = captionMode(captions);
324
+ }
325
+ if (!str("purpose") && !series) {
326
+ plan.confidence = chosen?.p ?? det.confidence;
327
+ plan.alternatives = det.ranked.filter((r) => r.purpose !== purpose).slice(0, 2).map((r) => ({ purpose: r.purpose, p: r.p }));
328
+ plan.reasons = [...(chosen?.reasons ?? []), ...plan.reasons.filter((r) => r !== "사람이 정함")];
329
+ }
330
+ // 장면 다시 쓰기(--rewrite)가 같은 모델을 부른다
331
+ (plan as Plan & { model?: string }).model = model;
332
+ if (assetFiles.length) {
333
+ mkdirSync(join(dir, "assets", "in"), { recursive: true });
334
+ for (const f of assetFiles) if (!AUDIO.test(f)) copyFileSync(f, join(dir, "assets", "in", basename(f)));
335
+ }
336
+ writeJson(join(dir, "plan.json"), plan);
337
+ if (plan.theme) {
338
+ const pick = plan.themeCandidates?.find((c) => c.id === plan.theme);
339
+ console.error(`테마 ${plan.theme}${pick ? ` — ${pick.why}` : ""}${plan.themeCandidates && plan.themeCandidates.length > 1 ? ` (다른 후보: ${plan.themeCandidates.filter((c) => c.id !== plan.theme).map((c) => c.id).join(" · ")})` : ""}`);
340
+ }
341
+ if (plan.themeCandidates && plan.themeCandidates.length > 1) themeStills(dir, plan.themeCandidates.map((c) => c.id));
342
+
343
+ // 감독 모드 — 뮤비는 가사 층이 화면을 맡아 장면 문법을 안 쓴다
344
+ const direct = !opts["no-direct"] && purpose !== "mv";
345
+ if (direct) {
346
+ writeJson(join(dir, "plan.json"), { ...plan, direct: true });
347
+ writeFileSync(join(dir, "MAKE.md"), directBrief(dir));
348
+ if (model === "claude-code") {
349
+ if (open.length) console.log(`\n먼저 사람에게 물을 것 ${open.length}가지 — ${dir}/MAKE.md 맨 위 「사용자에게 먼저 물을 것」(AskUserQuestion), 답은 kf make --answer ${dir} ${open.map((q) => `${q.flag} <값>`).join(" ")}`);
350
+ console.log(`\n감독 모드 — ${dir}/MAKE.md 를 읽고 ${dir}/content.json 을 쓴다(장면 코드는 안 쓴다). 그다음:\n kf direct ${dir}\n kf look ${dir}\n kf make --finish ${dir}`);
351
+ await unmirror?.();
352
+ await studio?.send({ phase: "scenes" });
353
+ return 0;
354
+ }
355
+ if (!(await writeContent(dir, model))) {
356
+ console.error(`kf make: ${model} 이 맞는 content.json 을 못 냈다 — ${dir}/content.json 을 손보고 kf direct ${dir}`);
357
+ return 1;
358
+ }
359
+ const d = spawnSync(process.execPath, [kfBin, "direct", dir], { stdio: ["ignore", "inherit", "inherit"] });
360
+ if (d.status !== 0 && d.status !== 1) return d.status ?? 1;
361
+ const gen = { approve: !!opts.yes || videoApproved(plan), maxUsd: num(opts["max-usd"], 1), videoProvider: str("video-provider") ?? process.env.KF_VIDEO_PROVIDER };
362
+ const code = await finish(dir, { model, vision, fixes, gen: opts["no-gen"] ? undefined : gen });
363
+ await unmirror?.();
364
+ await studio?.send({ done: true, ok: code === 0 });
365
+ return code;
366
+ }
367
+
368
+ if (model !== "claude-code") {
369
+ await refinePlan(dir, model, request);
370
+ // 내레이션 먼저 — 계획 다듬기가 쓴 장면별 문장을 읽혀 장면 길이를 목소리에 맞춘 뒤 장면을 쓴다
371
+ const code = narrateStep(dir);
372
+ if (code !== 0) return code;
373
+ }
374
+
375
+ const gen = { approve: !!opts.yes || videoApproved(plan), maxUsd: num(opts["max-usd"], 1), videoProvider: str("video-provider") ?? process.env.KF_VIDEO_PROVIDER };
376
+ // claude-code 모드는 그림·3D 샷을 세션이 만든다 — 막연한 자동 프롬프트로 엉뚱한 그림이 나왔다. 지시서에 목록과 고른 공급자로 낸다
377
+ if (model !== "claude-code") {
378
+ await genImages(dir);
379
+ await writeShots(dir, model);
380
+ }
381
+
382
+ writeFileSync(join(dir, "MAKE.md"), projectBrief(dir));
383
+ if (model === "claude-code") {
384
+ const flags = [opts.yes ? "--yes" : "", gen.videoProvider ? `--video-provider ${gen.videoProvider}` : ""].filter(Boolean).join(" ");
385
+ if (open.length) console.log(`\n먼저 사람에게 물을 것 ${open.length}가지 — ${dir}/MAKE.md 맨 위 「사용자에게 먼저 물을 것」(AskUserQuestion), 답은 kf make --answer ${dir} ${open.map((q) => `${q.flag} <값>`).join(" ")}`);
386
+ console.log(`\n${dir}/MAKE.md 를 읽고 scenes/*.ts 를 채운다. 다 채우면(이음매 영상은 그때 앞뒤 장면 프레임으로 만든다):\n kf make --finish ${dir}${flags ? ` ${flags}` : ""}`);
387
+ // 장면은 세션이 쓴다 — 작업실 미리보기가 저장할 때마다 따라온다
388
+ console.error("장면 쓰는 중(세션) — 저장할 때마다 작업실 미리보기에 바로 보인다");
389
+ await unmirror?.();
390
+ await studio?.send({ phase: "scenes" });
391
+ return 0;
392
+ }
393
+ // 이음매는 틀의 3D 전환이 맡는다 — 모델이 다시 쓰지 않는다
394
+ await writeScenes(dir, model, readProject(dir).scenes.filter((s) => s.role !== "seam").map((s) => s.id));
395
+ const code = await finish(dir, { model, vision, fixes, gen: opts["no-gen"] ? undefined : gen });
396
+ await unmirror?.();
397
+ await studio?.send({ done: true, ok: code === 0 });
398
+ return code;
399
+ }
400
+
401
+ // --- 감독 모드: 모델은 content.json 하나만 — 칸이 틀리면 어느 칸인지 돌려줘 두 번까지 다시
402
+
403
+ function isDirect(dir: string): boolean {
404
+ const p = join(dir, "plan.json");
405
+ return existsSync(p) && JSON.parse(readFileSync(p, "utf8")).direct === true;
406
+ }
407
+
408
+ async function writeContent(dir: string, model: string): Promise<boolean> {
409
+ const plan = JSON.parse(readFileSync(join(dir, "plan.json"), "utf8"));
410
+ let fix: string | undefined;
411
+ for (let attempt = 0; attempt < 3; attempt++) {
412
+ // 생각하는 모델은 생각에 토큰을 먼저 쓴다 — 상한이 낮으면 답 JSON 이 잘린다
413
+ const r = await chat(model, contentPrompt(dir, fix), { json: true, temperature: 0.5, maxTokens: 16000, stage: "content" });
414
+ const j = jsonBlock<Record<string, unknown>>(r.text);
415
+ if (!j) {
416
+ fix = "JSON 을 못 읽었다 — JSON 하나로만 답한다";
417
+ continue;
418
+ }
419
+ const c = checkContent({ ...j, request: plan.request });
420
+ console.error(`content.json ${attempt + 1}번째(${model}, ${(r.ms / 1000).toFixed(1)}초) — ${c.errors.length ? `칸 오류 ${c.errors.length}` : `장면 ${c.content.scenes.length}`}`);
421
+ if (c.errors.length) {
422
+ fix = c.errors.map((e) => `${e.at}: ${e.msg}`).join("\n");
423
+ continue;
424
+ }
425
+ writeJson(join(dir, "content.json"), { ...j, request: plan.request });
426
+ return true;
427
+ }
428
+ return false;
429
+ }
430
+
431
+ // --- 계획 다듬기: 틀이 정한 뼈대(시각·역할)는 그대로, 이름·끝 한 줄·장면 할 일·생성 프롬프트만 요청에 맞춘다
432
+
433
+ async function refinePlan(dir: string, model: string, request: string): Promise<void> {
434
+ const plan: Plan = JSON.parse(readFileSync(join(dir, "plan.json"), "utf8"));
435
+ const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8"));
436
+ const ask: Message[] = [
437
+ {
438
+ role: "system",
439
+ content:
440
+ "너는 짧은 영상의 연출 계획을 다듬는다. 장면 id·순서·시각은 바꾸지 않는다. 화면에 쓸 글 정책을 지킨다. JSON 하나로만 답한다.",
441
+ },
442
+ {
443
+ role: "user",
444
+ content: `요청: ${request}
445
+ 용도: ${plan.purpose} · ${plan.duration}초 · ${plan.aspect} · 스타일 ${plan.style} · 화면 글 정책 ${kf.text?.policy}
446
+ 장면: ${JSON.stringify(plan.scenes.map((s) => ({ id: s.id, role: s.role, dur: s.dur, idea: s.idea })))}
447
+ 생성 자산: ${JSON.stringify(plan.gen.map((g) => ({ id: g.id, kind: g.kind, scene: g.scene, prompt: g.prompt })))}
448
+
449
+ 다음 꼴로: {"title": "제품·주제 이름(짧게)", "line": "끝 카드 한 줄(없으면 빈 글)", "scenes": [{"id": "...", "idea": "이 요청에 맞춘 구체적인 화면·움직임 한두 문장"}], "gen": [{"id": "...", "prompt": "영어 생성 프롬프트, 글자·로고 없이"}]${captionMode(kf.captions) !== "none" ? ', "captions": [{"scene": "장면 id", "text": "그 장면에서 들릴 한국어 내레이션 — 차분한 설명체, 짧은 마디를 쉼표로, 문장은 마침표로. 목소리가 장면 길이를 정하니 1초에 4–5글자(차분한 낭독)로 장면 길이에 가깝게, 훅은 한 마디(끝 장면·이음매는 비워도 된다)"}]' : ""}}`,
450
+ },
451
+ ];
452
+ try {
453
+ // 생각하는 모델은 생각에 토큰을 먼저 쓴다 — 상한이 낮으면 답 JSON 이 잘린다
454
+ const r = await chat(model, ask, { json: true, temperature: 0.5, maxTokens: 16000, stage: "plan" });
455
+ const j = jsonBlock<{ title?: string; line?: string; scenes?: { id: string; idea: string }[]; gen?: { id: string; prompt: string }[]; captions?: { scene: string; text: string }[] }>(r.text);
456
+ if (!j) throw new Error(`JSON 을 못 읽었다(${r.text.length}자: ${r.text.slice(0, 160).replace(/\s+/g, " ")})`);
457
+ if (j.title) plan.title = kf.title = j.title;
458
+ for (const s of j.scenes ?? []) {
459
+ const p = plan.scenes.find((x) => x.id === s.id);
460
+ const k = kf.scenes.find((x: { id: string }) => x.id === s.id);
461
+ if (p && s.idea) p.idea = k.idea = s.idea;
462
+ }
463
+ for (const g of j.gen ?? []) {
464
+ const p = plan.gen.find((x) => x.id === g.id);
465
+ if (p && g.prompt) p.prompt = g.prompt;
466
+ }
467
+ (plan as Plan & { line?: string }).line = j.line ?? "";
468
+ if (captionMode(kf.captions) !== "none" && j.captions?.length) writeJson(join(dir, "captions.json"), j.captions);
469
+ writeJson(join(dir, "plan.json"), plan);
470
+ writeJson(join(dir, "kf.json"), kf);
471
+ console.error(`계획 다듬음(${model}, ${(r.ms / 1000).toFixed(1)}초): ${plan.title}`);
472
+ } catch (e) {
473
+ console.error(`계획 다듬기 건너뜀 — ${e instanceof Error ? e.message : e}`);
474
+ }
475
+ }
476
+
477
+ // --- 장면 쓰기: 장면마다 파일 하나를 받아 놓고, 실제로 열어 오류·이상한 글이 없을 때까지 돌려준다.
478
+ // 장면끼리는 동시에 쓴다(모델 호출이 대부분 기다림). 열어 보기는 한 번에 하나 — 페이지는 모든 장면을 같이 싣는다.
479
+ // 그래서 떨어진 판은 열어 본 뒤 바로 앞 파일로 되돌린다: 옆 장면의 열어 보기가 남의 깨진 판에 걸리지 않게
480
+
481
+ const probing = gate(1);
482
+
483
+ async function writeScenes(dir: string, model: string, ids: string[], feedback: Record<string, string> = {}): Promise<void> {
484
+ const brief = readFileSync(join(dir, "MAKE.md"), "utf8");
485
+ const kf = readProject(dir);
486
+ const scenes = ids.map((id) => kf.scenes.find((x) => x.id === id)).filter((s): s is NonNullable<typeof s> => !!s);
487
+ await pool(scenes, jobLimit(3), async (s) => {
488
+ const file = join(dir, s.file);
489
+ const before = existsSync(file) ? readFileSync(file, "utf8") : "";
490
+ let note = feedback[s.id] ?? "";
491
+ let tried = "";
492
+ console.error(`장면 ${s.id}: 쓰는 중 (${model})`);
493
+ for (let attempt = 0; attempt < 3; attempt++) {
494
+ const current = tried || before;
495
+ const messages: Message[] = [
496
+ { role: "system", content: `너는 kasaframe 장면 파일을 쓴다. 아래 지시서를 지킨다. 답은 TypeScript 코드 블록 하나뿐.\n\n${brief}` },
497
+ {
498
+ role: "user",
499
+ content: `장면 ${s.id}(${s.file}, ${s.dur.toFixed(2)}초, 역할 ${s.role})의 파일 전체를 써라.\n할 일: ${s.idea}\n\n지금 파일:\n\`\`\`ts\n${current}\`\`\`${note ? `\n\n고칠 것:\n${note}` : ""}`,
500
+ },
501
+ ];
502
+ const r = await chat(model, messages, { temperature: 0.6, maxTokens: 32000, stage: `scene:${s.id}` });
503
+ const code = r.finish === "length" ? null : codeBlock(r.text);
504
+ if (r.finish === "length") {
505
+ note = "답이 길이 상한에서 잘렸다. 군더더기 없이 더 짧게, ```ts 블록 하나로 파일 전체를 답하라.";
506
+ console.error(` ${s.id} ${attempt + 1}번째 — 답이 잘림`);
507
+ continue;
508
+ }
509
+ if (!code) {
510
+ note = "코드 블록을 못 찾았다. ```ts 블록 하나로 파일 전체를 답하라.";
511
+ continue;
512
+ }
513
+ tried = code;
514
+ const problems = await probing(async () => {
515
+ writeFileSync(file, code);
516
+ const p = await probeScene(dir, s.id);
517
+ if (p.length) writeFileSync(file, before);
518
+ return p;
519
+ });
520
+ console.error(` ${s.id} ${attempt + 1}번째 ${(r.ms / 1000).toFixed(1)}초 — ${problems.length ? problems.join(" / ") : "통과"}`);
521
+ if (!problems.length) {
522
+ console.error(`장면 ${s.id}: 씀`);
523
+ return;
524
+ }
525
+ note = problems.join("\n");
526
+ }
527
+ // 세 번 다 떨어지면 앞 파일 그대로 — 깨진 판이 통째 렌더를 멈추지 않게. 마지막 판은 out/failed 에 남긴다
528
+ mkdirSync(join(dir, "out", "failed"), { recursive: true });
529
+ if (tried) writeFileSync(join(dir, "out", "failed", `${s.id}.ts`), tried);
530
+ console.error(`장면 ${s.id}: 실패 세 번 다 검사에 걸림 — 앞 파일 그대로(마지막 판 out/failed/${s.id}.ts)`);
531
+ });
532
+ }
533
+
534
+ // 렌더 전에 장면 하나를 세 시각에서 열어 본다 — 통짜 렌더 1분을 쓰기 전에 잡을 것을 잡는다
535
+ async function probeScene(dir: string, id: string): Promise<string[]> {
536
+ const kf = readProject(dir);
537
+ const s = kf.scenes.find((x) => x.id === id)!;
538
+ const problems: string[] = [];
539
+ // 문법이 깨지면 페이지는 「__kf 를 안 걸었다」밖에 말해 주지 않는다 — 줄·칸이 붙은 오류를 먼저 돌려준다
540
+ try {
541
+ await transform(readFileSync(join(dir, s.file), "utf8"), { loader: "ts", sourcefile: s.file });
542
+ } catch (e) {
543
+ const errs = (e as { errors?: { text: string; location?: { line: number; column: number; lineText: string } }[] }).errors ?? [];
544
+ return errs.length ? errs.slice(0, 3).map((x) => `문법 오류 ${s.file}:${x.location?.line}:${x.location?.column} ${x.text} — ${x.location?.lineText.trim().slice(0, 120)}`) : [`문법 오류: ${e instanceof Error ? e.message : e}`];
545
+ }
546
+ let p: Awaited<ReturnType<typeof openPage>>;
547
+ try {
548
+ p = await openPage(dir);
549
+ } catch (e) {
550
+ return [`페이지가 안 열린다: ${(e instanceof Error ? e.message : String(e)).slice(0, 600)}`];
551
+ }
552
+ const errors: string[] = [];
553
+ p.page.on("pageerror", (e) => errors.push(e.message));
554
+ try {
555
+ for (const t of [s.from + 0.05, s.from + s.dur / 2, s.from + s.dur - 0.05]) {
556
+ await p.seek(t);
557
+ const text: string = await p.page.evaluate(() => (document.getElementById("stage") ?? document.body).innerText);
558
+ const bad = text.match(BAD_TEXT);
559
+ if (bad) problems.push(`${(t - s.from).toFixed(2)}초 화면에 "${bad[0]}" 글자가 찍힌다`);
560
+ const box = await p.page.evaluate(() => {
561
+ const out: string[] = [];
562
+ const W = innerWidth;
563
+ const H = innerHeight;
564
+ for (const el of Array.from(document.querySelectorAll("#stage *"))) {
565
+ if (!(el as HTMLElement).innerText?.trim() || el.children.length) continue;
566
+ const r = el.getBoundingClientRect();
567
+ const cs = getComputedStyle(el);
568
+ if (cs.opacity === "0" || cs.visibility === "hidden" || r.width === 0) continue;
569
+ if (r.right < 0 || r.bottom < 0 || r.left > W || r.top > H) continue;
570
+ if (r.left < -2 || r.top < -2 || r.right > W + 2 || r.bottom > H + 2) out.push((el as HTMLElement).innerText.trim().slice(0, 20));
571
+ }
572
+ return out;
573
+ });
574
+ if (box.length) problems.push(`${(t - s.from).toFixed(2)}초에 글이 화면 밖으로 나간다: ${box.slice(0, 3).join(", ")}`);
575
+ }
576
+ } catch (e) {
577
+ problems.push(`seek 중 오류: ${e instanceof Error ? e.message : e}`);
578
+ } finally {
579
+ await p.close();
580
+ }
581
+ for (const e of new Set(errors)) problems.push(`페이지 오류: ${e}`);
582
+ return [...new Set(problems)];
583
+ }
584
+
585
+ // 테마 후보마다 첫 장면을 그 테마로 한 장 — 사람이 셋을 나란히 보고 고른다. style.json 만 잠깐 바꿔 찍고 되돌린다
586
+ function themeStills(dir: string, ids: string[]): void {
587
+ const stylePath = join(dir, "style.json");
588
+ const keep = readFileSync(stylePath, "utf8");
589
+ const motion = JSON.parse(keep).motion;
590
+ const kf = readProject(dir);
591
+ const first = kf.scenes[0];
592
+ const t = +(first.from + Math.min(first.dur * 0.6, 1.2)).toFixed(3);
593
+ try {
594
+ for (const id of ids) {
595
+ writeJson(stylePath, { ...themeStyle(id), motion });
596
+ const r = spawnSync(process.execPath, [kfBin, "still", dir, "--t", String(t), "--out", join(dir, "out", `theme-${id}.png`)], { stdio: "ignore" });
597
+ console.error(`테마 후보 ${id}: ${r.status === 0 ? `out/theme-${id}.png` : "못 찍음"}`);
598
+ }
599
+ } finally {
600
+ writeFileSync(stylePath, keep);
601
+ }
602
+ }
603
+
604
+ // --- 생성 자산: 그림은 장면을 쓰기 전에(장면이 가져다 쓴다), 영상은 장면을 다 쓴 뒤에(이음매는 앞뒤 장면의 진짜 프레임에서 뽑는다)
605
+
606
+ interface GenOpts {
607
+ approve: boolean;
608
+ maxUsd: number;
609
+ videoProvider?: string;
610
+ }
611
+
612
+ const genLog = (l: string) => console.error(` ${l}`);
613
+
614
+ // 생성 자산도 make-stats.json 장부에 — 캐시에서 꺼낸 것은 이번에 돈을 안 썼다
615
+ function genSpent(a: Awaited<ReturnType<typeof genImage>>, stage: string): void {
616
+ if (a.cached) return;
617
+ spend({ model: `${a.meta.provider}/${a.meta.model}`, stage, ms: a.meta.elapsedMs, prompt: 0, completion: 0, cached: 0, usd: a.meta.costUsd, og: a.meta.provider.startsWith("og-") });
618
+ }
619
+
620
+ async function genImages(dir: string): Promise<void> {
621
+ const plan: Plan = JSON.parse(readFileSync(join(dir, "plan.json"), "utf8"));
622
+ const imgs = plan.gen.filter((g) => g.kind === "img");
623
+ if (!imgs.length) return;
624
+ console.error(`생성 자산 그림 ${imgs.length}개…`);
625
+ // 장당 상한·고른 공급자는 그대로, 부르기만 겹친다
626
+ await pool(imgs, jobLimit(3), async (g) => {
627
+ if (g.scene) console.error(`장면 ${g.scene}: 재료 — 그림 ${g.id}`);
628
+ try {
629
+ const size = g.size ?? (plan.aspect === "9:16" ? "1080x1920" : plan.aspect === "1:1" ? "1080x1080" : "1920x1080");
630
+ const a = await genImage(g.id, { prompt: g.prompt, size, transparent: g.transparent, quality: g.quality, refs: g.refs?.map((r) => join(dir, r)) }, { outDir: join(dir, "assets", "gen"), log: genLog }, g.provider);
631
+ genSpent(a, `gen:img:${g.id}`);
632
+ console.error(` gen:img:${g.id} ${a.cached ? "cached" : "made"} ${a.path} $${(a.cached ? 0 : a.meta.costUsd).toFixed(3)}`);
633
+ } catch (e) {
634
+ console.error(` gen:img:${g.id} ${e instanceof NeedsApproval ? "needs-approval" : "error"} ${e instanceof Error ? e.message : e}`);
635
+ }
636
+ });
637
+ }
638
+
639
+ async function genVideos(dir: string, o: GenOpts): Promise<void> {
640
+ const plan: Plan = JSON.parse(readFileSync(join(dir, "plan.json"), "utf8"));
641
+ const vids = plan.gen.filter((g) => g.kind === "vid");
642
+ if (!vids.length) return;
643
+ console.error(`생성 자산 영상 ${vids.length}개…`);
644
+ const ctx = { outDir: join(dir, "assets", "gen"), approve: o.approve, maxUsd: o.maxUsd, log: genLog };
645
+ // 영상 하나마다 승인·상한(maxUsd)을 따로 본다 — 겹쳐 불러도 묻기 틀은 그대로
646
+ await pool(vids, jobLimit(2), async (g) => {
647
+ const provider = o.videoProvider ?? g.provider;
648
+ console.error(`장면 ${g.scene ?? g.id}: 재료 — 영상 ${g.id}`);
649
+ try {
650
+ const a = g.seam
651
+ ? await seam({ project: dir, scene: g.id, prompt: g.prompt, provider }, ctx)
652
+ : await genVideo(g.id, { prompt: g.prompt, duration: g.dur ?? 5, aspect: plan.aspect, firstFrame: g.first && join(dir, g.first), lastFrame: g.last && join(dir, g.last) }, ctx, provider);
653
+ genSpent(a, `gen:vid:${g.id}`);
654
+ console.error(` gen:vid:${g.id} ${a.cached ? "cached" : "made"} ${a.path} $${(a.cached ? 0 : a.meta.costUsd).toFixed(3)}`);
655
+ } catch (e) {
656
+ const tail = e instanceof NeedsApproval ? `needs-approval 견적 $${e.estimateUsd.toFixed(3)}(${e.provider}) — --yes 로 승인하면 만든다, 지금은 대체 화면` : `error ${e instanceof Error ? e.message : e}`;
657
+ console.error(` gen:vid:${g.id} ${tail}`);
658
+ }
659
+ });
660
+ }
661
+
662
+ // 영상 모델은 사람이 공급자를 골랐을 때(또는 --yes)만 돈을 쓴다 — 아직 묻는 중이면 견적만
663
+ function videoApproved(plan: Partial<Plan>): boolean {
664
+ const v = plan.answers?.video;
665
+ return !!v && v !== "none" && v !== WAIT && !(plan.asks ?? []).some((q) => q.id === "video");
666
+ }
667
+
668
+ // --- 주인공 3D 샷(OG 모드): 모델이 kfb 헬퍼로 샷 스크립트를 쓰고, 몇 장 찍어 보아 돌면 굽는다. 세 번 안에 못 서면 three 로 물러난다
669
+
670
+ function shotGuide(): string {
671
+ const lib = blenderLibDir();
672
+ const readme = readFileSync(join(lib, "README.md"), "utf8");
673
+ const how = readme.slice(readme.indexOf("## 샷 스크립트 짜기"), readme.indexOf("## 판마다 다른 곳"));
674
+ const api = readFileSync(join(lib, "kfb.py"), "utf8").split("\n").filter((l) => /^(def [a-z]\w*\(|class [A-Z])/.test(l)).join("\n");
675
+ return `${how}\n\nkfb 함수(이것만 쓴다):\n${api}\n\n예(등대 섬):\n\`\`\`python\n${readFileSync(join(lib, "shots", "lighthouse.py"), "utf8")}\n\`\`\``;
676
+ }
677
+
678
+ function blenderRun(dir: string, file: string, scene: string, id: string, extra: string[]): { ok: boolean; tail: string } {
679
+ const r = spawnSync(process.execPath, [kfBin, "blender", join(dir, file), "--project", dir, "--scene", scene, "--id", id, ...extra], { encoding: "utf8" });
680
+ const tail = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim().split("\n").slice(-25).join("\n");
681
+ return { ok: r.status === 0, tail };
682
+ }
683
+
684
+ async function writeShots(dir: string, model: string): Promise<void> {
685
+ const plan: Plan = JSON.parse(readFileSync(join(dir, "plan.json"), "utf8"));
686
+ const shots = (plan.shots ?? []).filter((s) => s.tool === "blender");
687
+ if (!shots.length) return;
688
+ const kf = readProject(dir);
689
+ const style = JSON.parse(readFileSync(join(dir, "style.json"), "utf8"));
690
+ const guide = shotGuide();
691
+ for (const shot of shots) {
692
+ const sc = kf.scenes.find((x) => x.id === shot.scene);
693
+ if (!sc) continue;
694
+ mkdirSync(join(dir, "shots"), { recursive: true });
695
+ const peek = [0.15, 0.5, 0.9].map((k) => +(sc.from + sc.dur * k).toFixed(2)).join(",");
696
+ let note = "";
697
+ let ok = false;
698
+ console.error(`3D 샷 ${shot.id} 쓰는 중(${model}) — 장면 ${sc.id} ${sc.dur.toFixed(2)}초`);
699
+ for (let attempt = 0; attempt < 3 && !ok; attempt++) {
700
+ const r = await chat(model, [
701
+ { role: "system", content: `너는 Blender 툰 3D 샷 스크립트(파이썬, tools/blender/kfb.py 헬퍼)를 쓴다. 답은 python 코드 블록 하나뿐.\n\n${guide}` },
702
+ {
703
+ role: "user",
704
+ content: `샷: ${shot.idea}\n장면 ${sc.id}: 프로젝트 ${sc.from.toFixed(2)}–${(sc.from + sc.dur).toFixed(2)}초(update(t) 의 t 는 프로젝트 초), 화면 ${kf.width}x${kf.height}\n색(style.json): ${JSON.stringify(style.colors ?? {})}\n글자는 넣지 않는다(장면이 위에 얹는다). 주인공 하나를 화면 가운데 크게, 쉬지 않게 박에 뛰거나 도는 물체, 카메라 열쇠는 S.downbeats·S.drops 에 두고 도착하면 선다.${note ? `\n\n고칠 것:\n${note}` : ""}`,
705
+ },
706
+ ], { temperature: 0.5, maxTokens: 16000, stage: `shot:${shot.id}` });
707
+ const code = codeBlock(r.text, "python|py");
708
+ if (!code) {
709
+ note = "코드 블록을 못 찾았다. ```python 블록 하나로 파일 전체를 답하라.";
710
+ continue;
711
+ }
712
+ writeFileSync(join(dir, shot.file), code);
713
+ // 스틸 몇 장은 kf still 처럼 잠금 없이 — 남의 긴 렌더 뒤에서 기다리지 않는다(굽기는 잠금을 쓴다)
714
+ const t = blenderRun(dir, shot.file, sc.id, shot.id, ["--still", peek, "--res", "50", "--no-lock"]);
715
+ ok = t.ok;
716
+ note = ok ? "" : `kf blender --still 이 실패했다:\n${t.tail}`;
717
+ console.error(` ${shot.id} ${attempt + 1}번째 ${(r.ms / 1000).toFixed(1)}초 — ${ok ? "스틸 통과" : "실패"}`);
718
+ }
719
+ // 절반 크기·두 장에 한 장 — 무거운 렌더를 짧게(페이지가 늘려 보인다)
720
+ const baked = ok && blenderRun(dir, shot.file, sc.id, shot.id, ["--res", "50", "--step", "2"]).ok;
721
+ if (!baked) {
722
+ console.error(` ${shot.id} 를 못 구웠다 — 장면 ${sc.id} 는 three.js(stage3d)로 3D 를 짓는다`);
723
+ plan.shots = (plan.shots ?? []).map((s) => (s.id === shot.id ? { ...s, tool: "three" } : s));
724
+ writeJson(join(dir, "plan.json"), plan);
725
+ } else console.error(` ${shot.id} → assets/blender/${shot.id}.mp4`);
726
+ }
727
+ }
728
+
729
+ // --- 묻기: Claude Code 모드에서 세션이 사람에게 물은 답을 넣는다 — 질문은 plan.json 의 asks 를 지금 능력으로 다시 만들어(함수 칸까지) 고른다
730
+ async function answerProject(dir: string, opts: Record<string, string | boolean | undefined>): Promise<number> {
731
+ const file = join(dir, "plan.json");
732
+ if (!existsSync(file)) {
733
+ console.error(`kf make --answer: ${dir} 에 plan.json 이 없다`);
734
+ return 2;
735
+ }
736
+ const plan: Plan = JSON.parse(readFileSync(file, "utf8"));
737
+ const waiting = new Set((plan.asks ?? []).map((q) => q.id));
738
+ const fresh = planQuestions(plan, await capabilities({ probe: true })).questions.filter((q) => waiting.has(q.id));
739
+ const settled = settle(fresh, { given: opts, yes: !!opts.yes, noGen: !!opts["no-gen"], allow: parseAllow(opts.allow) });
740
+ const { plan: next, stop } = applyAnswers(plan, settled.answers, fresh);
741
+ next.asks = (plan.asks ?? []).filter((q) => !(q.id in settled.answers));
742
+ writeJson(file, next);
743
+ if (settled.answers.theme) {
744
+ const was = readProject(dir).theme ?? null;
745
+ const now = retheme(dir, settled.answers.theme);
746
+ console.log(now === was ? `테마 ${now ?? "없음(프리셋)"} 그대로` : `테마 ${was ?? "없음"} → ${now ?? "없음(프리셋)"} — style.json·kf.json·장면 뼈대 DRESS 를 바꿨다`);
747
+ }
748
+ writeFileSync(join(dir, "MAKE.md"), isDirect(dir) ? directBrief(dir) : projectBrief(dir));
749
+ for (const [id, v] of Object.entries(settled.answers)) console.log(`${id}: ${v}`);
750
+ if (stop.length) {
751
+ console.error(`켜고 다시 부른다 —\n${stop.map((x) => ` ${x}`).join("\n")}`);
752
+ return 3;
753
+ }
754
+ if (next.asks.length) {
755
+ console.error(`아직 답이 없다: ${next.asks.map((q) => `${q.flag} <${q.choices.map((c) => c.value).join("|")}>`).join(" ")}`);
756
+ return 3;
757
+ }
758
+ console.log(`${dir}/MAKE.md 를 고쳐 썼다 — 답대로 그림·3D 샷·영상 컷을 만들고 장면을 채운다`);
759
+ return 0;
760
+ }
761
+
762
+ // 맨 위 질문의 테마 답 — kf new 가 auto 로 깐 테마를 바꾼다(plan.json·kf.json·style.json, 장면 뼈대의 DRESS 한 줄).
763
+ // 감독 모드는 kf direct 가 kf.json 의 테마로 장면을 지으니 여기까지면 된다. 바뀐 테마 id(프리셋이면 null)를 돌려준다
764
+ function retheme(dir: string, answer: string): string | null {
765
+ const plan = readPlan(dir) as Plan;
766
+ const kfPath = join(dir, "kf.json");
767
+ const kf = JSON.parse(readFileSync(kfPath, "utf8"));
768
+ const id: string | null = answer === "auto" ? (plan.themeCandidates?.[0]?.id ?? kf.theme ?? null) : answer === "none" ? null : answer;
769
+ if ((kf.theme ?? null) === id) return id;
770
+ const tpl = loadTemplate(plan.purpose);
771
+ // 프리셋으로 돌아가면 kf new 가 테마 없이 골랐을 프리셋 — 시리즈·요청 낱말·틀 차례
772
+ const preset = id ? null : makePlan({ request: plan.request, purpose: plan.purpose, theme: "none", series: plan.series ?? "none" }).style;
773
+ const style = id ? themeStyle(id) : JSON.parse(readFileSync(join(repo, "presets", `${existsSync(join(repo, "presets", `${preset}.json`)) ? preset : "neutral-dark"}.json`), "utf8"));
774
+ style.motion = { ...style.motion, ...tpl.motion };
775
+ writeJson(join(dir, "style.json"), style);
776
+ if (tpl.moves) {
777
+ const dress = pickDress(dressSeed(plan.request || plan.title, plan.purpose, id ?? preset!), plan.scenes, bundleFor(tpl.moves, id ?? undefined));
778
+ const byId = new Map(plan.scenes.map((s, i) => [s.id, dress[i]]));
779
+ for (const s of kf.scenes as { id: string; file: string; dress?: Record<string, string> }[]) {
780
+ const next = byId.get(s.id);
781
+ if (!s.dress || !next) continue;
782
+ const file = join(dir, s.file);
783
+ // 뼈대가 채운 값만 갈아 끼운다 — 세션이 손댄 줄·뼈대에 박힌 글자 값은 그대로
784
+ if (existsSync(file))
785
+ writeFileSync(file, readFileSync(file, "utf8").replace(/^const DRESS: Dress = \{.*\};$/m, (line) => Object.entries(next).reduce((l, [k, v]) => l.replace(`${k}: "${s.dress![k]}"`, `${k}: "${v}"`), line)));
786
+ s.dress = { ...next };
787
+ }
788
+ }
789
+ if (id) kf.theme = id;
790
+ else delete kf.theme;
791
+ writeJson(kfPath, kf);
792
+ writeJson(join(dir, "plan.json"), { ...readPlan(dir), theme: id, style: id ?? preset });
793
+ return id;
794
+ }
795
+
796
+ // 결과 메타 — 이 영상에 들어간 생성 자산과 회사·바깥 결과물에 걸리는 제한(H3 공개 가중치 등)을 한 파일에
797
+ function writeCredits(dir: string, video: string): void {
798
+ const read = (sub: string) =>
799
+ existsSync(join(dir, "assets", sub))
800
+ ? readdirSync(join(dir, "assets", sub)).filter((f) => f.endsWith(".json")).map((f) => ({ file: join("assets", sub, f), ...JSON.parse(readFileSync(join(dir, "assets", sub, f), "utf8")) }))
801
+ : [];
802
+ const gen = read("gen").map((m) => ({ id: m.id, kind: m.kind, provider: m.provider, model: m.model, costUsd: m.costUsd, license: m.license, region: m.region, ...(m.restrict ? { restrict: m.restrict } : {}) }));
803
+ const blender = read("blender").map((m) => ({ id: m.file.replace(/^.*\/|\.json$/g, ""), kind: "3d", provider: "blender", model: `Blender ${m.blender}`, costUsd: 0, license: "출력물은 만든 사람 것(Blender GPL 은 출력물에 걸리지 않는다)" }));
804
+ const plan = existsSync(join(dir, "plan.json")) ? JSON.parse(readFileSync(join(dir, "plan.json"), "utf8")) : {};
805
+ const restricted = gen.filter((g) => g.restrict);
806
+ const credits = { video: basename(video), answers: plan.answers ?? {}, assets: [...gen, ...blender], costUsd: Number(gen.reduce((n, g) => n + (g.costUsd ?? 0), 0).toFixed(4)), restricted: restricted.map((g) => ({ id: g.id, provider: g.provider, restrict: g.restrict })) };
807
+ const path = join(dir, "out", `${basename(video, ".mp4")}.credits.json`);
808
+ writeJson(path, credits);
809
+ if (restricted.length) console.log(`\n제한 있는 자산 ${restricted.length}개 — 회사·바깥 결과물에 내기 전에 바꾼다:\n${restricted.map((g) => ` ${g.id}(${g.provider}): ${g.restrict}`).join("\n")}`);
810
+ console.log(`결과 메타: ${path}`);
811
+ }
812
+
813
+ // 이음매 장면의 모션그래픽 전환(transition3d)은 앞 장면 마지막 화면·뒤 장면 첫 화면 두 장을 받아 그 사이를 잇는다.
814
+ // 장면을 다 쓴 뒤에 찍어야 진짜 화면이 된다 — 그림이 없으면 전환이 style 색으로 대체 화면을 그린다
815
+ async function seamStills(dir: string, only?: string[]): Promise<void> {
816
+ const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8"));
817
+ const seams = (kf.scenes as { id: string; role: string }[]).filter((s) => s.role === "seam" && (!only || only.includes(s.id)));
818
+ if (!seams.length) return;
819
+ mkdirSync(join(dir, "assets", "seam"), { recursive: true });
820
+ for (const s of seams) {
821
+ const { firstFrame, lastFrame } = seamFrames(kf, s.id);
822
+ try {
823
+ if (firstFrame !== undefined) await still(dir, firstFrame, join(dir, "assets", "seam", `${s.id}-from.png`));
824
+ if (lastFrame !== undefined) await still(dir, lastFrame, join(dir, "assets", "seam", `${s.id}-to.png`));
825
+ console.error(`이음매 ${s.id}: 앞뒤 화면 두 장 찍음`);
826
+ } catch (e) {
827
+ console.error(`이음매 ${s.id}: 앞뒤 화면을 못 찍었다 — 대체 화면으로 간다(${e instanceof Error ? e.message : e})`);
828
+ }
829
+ }
830
+ }
831
+
832
+ // --- 마무리: 렌더 → 검사 → (OG 모드면) 고치기 → 표지
833
+
834
+ // captions 를 주면 그 판만 화면 자막을 켜고 끈다(kf.json 은 그대로) — 굽기·검사·자막 트랙이 같은 값을 본다
835
+ // scenes 를 주면 그 장면 둘레(그 장면 + 붙은 이음매)만 — 이음 화면·검사를 그 구간에서. 굽기는 kf render 가 바뀐 조각만 다시 찍는다
836
+ async function finish(dir: string, o: { model: string; vision: string; fixes: number; gen?: GenOpts; captions?: "on" | "off" | "fit"; suffix?: string; scenes?: string[] }): Promise<number> {
837
+ if (!existsSync(join(dir, "kf.json"))) {
838
+ console.error(`kf make: ${dir} 에 kf.json 이 없다`);
839
+ return 2;
840
+ }
841
+ // 세션이 kf narrate 를 건너뛰었으면 여기서 — 목소리·곡을 깔고 장면 길이를 맞춘 뒤 굽는다
842
+ if (readProject(dir).voice === undefined) {
843
+ const code = narrateStep(dir);
844
+ if (code !== 0) return code;
845
+ }
846
+ if (o.gen) await genVideos(dir, o.gen);
847
+ const touched = o.scenes ? around(dir, o.scenes) : undefined;
848
+ await seamStills(dir, touched);
849
+ const out = join(dir, "out", `video${o.suffix ?? ""}.mp4`);
850
+ const capFlag = o.captions ? ["--captions", o.captions] : [];
851
+ let verdict = { code: 0, report: "" };
852
+ for (let round = 0; ; round++) {
853
+ if (round > 0) await seamStills(dir, touched);
854
+ // 굽기와 검사 사이에 잠금을 놓으면 기다리던 남의 긴 렌더가 끼어든다 — 둘을 한 번에 쥐고 자식은 잠금 없이
855
+ const release = await acquireRenderLock(dir, 3600);
856
+ const r = await relay(["render", dir, "--out", out, "--tabs", "2", "--no-lock", ...capFlag]);
857
+ if (r !== 0) {
858
+ release();
859
+ console.error("kf make: 렌더가 실패했다");
860
+ return r;
861
+ }
862
+ // 고친 둘레 + 실제로 다시 구운 조각(라이브러리·테마가 바뀌면 안 고친 장면도 다시 굽힌다) — 다 다시 구웠으면 통째 검사
863
+ const scope = touched && redone(out, touched);
864
+ for (const id of scope ?? readProject(dir).scenes.map((x) => x.id)) console.error(`장면 ${id}: 검사`);
865
+ verdict = scope ? checkPart(dir, scope, [...capFlag, "--no-lock"]) : check(dir, [...capFlag, "--no-lock"]);
866
+ release();
867
+ sceneVerdicts(dir, scope);
868
+ const review = o.vision !== "none" ? await visionReview(dir, o.vision) : {};
869
+ const toFix: Record<string, string> = { ...review };
870
+ for (const [id, note] of Object.entries(findingsByScene(dir))) toFix[id] = [toFix[id], note].filter(Boolean).join("\n");
871
+ // 고친 장면만 다시 굽는 길이면 고치기도 그 둘레 안에서 — 안 건드린 장면의 옛 지적으로 남의 장면을 다시 쓰지 않는다
872
+ if (touched) for (const id of Object.keys(toFix)) if (!touched.includes(id)) delete toFix[id];
873
+ // 감독 프로젝트는 kf direct 가 떨어진 장면을 다음 후보로 이미 다시 지었다 — 모델이 장면 코드를 덮어쓰지 않는다
874
+ if ((verdict.code === 0 && !Object.keys(review).length) || o.model === "claude-code" || isDirect(dir) || round >= o.fixes) break;
875
+ if (!Object.keys(toFix).length) break;
876
+ console.error(`고치기 ${round + 1}/${o.fixes}: ${Object.keys(toFix).join(" ")}`);
877
+ writeFileSync(join(dir, "MAKE.md"), projectBrief(dir));
878
+ await writeScenes(dir, o.model, Object.keys(toFix), toFix);
879
+ }
880
+ const subs = writeCaptions(dir, o.captions && captionMode(o.captions));
881
+ const cover = coverTime(dir);
882
+ if (!touched || touched.includes(cover.scene) || !existsSync(join(dir, "out", "cover.png")))
883
+ spawnSync(process.execPath, [kfBin, "still", dir, "--t", String(cover.t), "--out", join(dir, "out", "cover.png")], { stdio: "ignore" });
884
+ console.log(`\n${[out, join(dir, "out", "cover.png"), ...subs].join("\n")}`);
885
+ writeCredits(dir, out);
886
+ if (o.vision === "none" && !touched) lookHint(dir);
887
+ else if (o.vision === "none") console.log(`\n장면 모음: kf look ${dir}`);
888
+ if (verdict.report) console.log(`\n검사:\n${verdict.report}`);
889
+ return verdict.code;
890
+ }
891
+
892
+ // 틀이 내레이션·분위기 곡을 정한 용도(쇼츠·설명)만. 장면별 문장(captions.json)이 없으면 곡만 깐다
893
+ function narrateStep(dir: string): number {
894
+ const tpl = loadTemplate(readProject(dir).template);
895
+ if (!tpl.narration?.on && !tpl.mood) return 0;
896
+ const lines = existsSync(join(dir, "captions.json")) ? JSON.parse(readFileSync(join(dir, "captions.json"), "utf8")) : [];
897
+ const plan = existsSync(join(dir, "plan.json")) ? JSON.parse(readFileSync(join(dir, "plan.json"), "utf8")) : {};
898
+ const voice = plan.voice ?? plan.answers?.voice;
899
+ // 문장이 없다고 목소리를 조용히 빼지 않는다 — 사람이 목소리를 none 으로 고른 때만 곡만 깐다
900
+ if (!lines.length && tpl.narration?.on && voice !== "none") {
901
+ console.error(`kf make: 내레이션 문장(captions.json)이 없다 — 장면별 문장을 쓰고 kf narrate ${dir}, 목소리를 빼려면 kf make --answer ${dir} --voice none`);
902
+ return 3;
903
+ }
904
+ const r = spawnSync(process.execPath, [kfBin, "narrate", dir], { stdio: ["ignore", "inherit", "inherit"] });
905
+ if (r.status !== 0) console.error("kf make: kf narrate 가 실패했다 — 위 메시지를 보고 고친 뒤 kf narrate <폴더>");
906
+ return r.status ?? 1;
907
+ }
908
+
909
+ // 자식 kf 의 줄을 이 프로세스 console 로 다시 찍는다 — 작업실 거울(attachStudio.mirror)은 console.error 만 봐서, 물려준 출력(inherit)은 작업실에 안 닿는다
910
+ function relay(args: string[]): Promise<number> {
911
+ return new Promise((done) => {
912
+ const p = spawn(process.execPath, [kfBin, ...args], { stdio: ["ignore", "pipe", "pipe"] });
913
+ for (const [stream, out] of [[p.stdout, console.log], [p.stderr, console.error]] as const) {
914
+ let buf = "";
915
+ stream.setEncoding("utf8");
916
+ stream.on("data", (chunk: string) => {
917
+ buf += chunk;
918
+ const parts = buf.split(/\r?\n/);
919
+ buf = parts.pop() ?? "";
920
+ for (const l of parts) out(l);
921
+ });
922
+ stream.on("end", () => buf && out(buf));
923
+ }
924
+ p.on("close", (code) => done(code ?? 1));
925
+ });
926
+ }
927
+
928
+ // 폴더를 주면 kf check 가 페이지를 열어 프레임마다 훑는다 — mp4 를 OCR 하는 것보다 정확하다
929
+ function check(dir: string, extra: string[] = []): { code: number; report: string } {
930
+ const r = spawnSync(process.execPath, [kfBin, "check", dir, ...extra], { encoding: "utf8" });
931
+ return { code: r.status ?? 1, report: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
932
+ }
933
+
934
+ // 굽기 보고서(kf render 가 영상 옆에 남긴 .json)의 다시 구운 조각을 더한다. 전부면 undefined — 통째로
935
+ function redone(video: string, touched: string[]): string[] | undefined {
936
+ const all = readProject(dirname(dirname(video))).scenes.map((s) => s.id);
937
+ let rendered: string[] = [];
938
+ try {
939
+ rendered = JSON.parse(readFileSync(video.replace(/\.[^.]+$/, ".json"), "utf8")).segments?.rendered ?? [];
940
+ } catch {}
941
+ const scope = all.filter((id) => touched.includes(id) || rendered.includes(id));
942
+ return scope.length === all.length ? undefined : scope;
943
+ }
944
+
945
+ // 고친 장면과 거기 붙은 이음매 — 이음매는 앞뒤 장면의 화면을 잇는다
946
+ function around(dir: string, ids: string[]): string[] {
947
+ const scenes = readProject(dir).scenes;
948
+ const out = new Set(ids);
949
+ for (const [i, s] of scenes.entries()) {
950
+ if (!ids.includes(s.id)) continue;
951
+ for (const n of [scenes[i - 1], scenes[i + 1]]) if (n?.role === "seam") out.add(n.id);
952
+ }
953
+ return scenes.filter((s) => out.has(s.id)).map((s) => s.id);
954
+ }
955
+
956
+ interface CheckJson {
957
+ ok: boolean;
958
+ counts: { error: number; warn: number };
959
+ findings: { rule: string; severity: string; from: number; to?: number; message: string }[];
960
+ [k: string]: unknown;
961
+ }
962
+
963
+ // 고친 구간만 훑고 out/check.json 의 그 구간 지적만 갈아 끼운다 — 나머지 장면의 앞 검사 결과는 그대로 둔다
964
+ function checkPart(dir: string, ids: string[], extra: string[] = []): { code: number; report: string } {
965
+ const scenes = readProject(dir).scenes;
966
+ const file = join(dir, "out", "check.json");
967
+ const before: CheckJson | null = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
968
+ // 붙어 있는 장면끼리 한 구간으로 — 페이지를 여는 값이 커서 장면마다 따로 열지 않는다
969
+ const ranges: [number, number][] = [];
970
+ for (const s of scenes) {
971
+ if (!ids.includes(s.id)) continue;
972
+ const last = ranges.at(-1);
973
+ if (last && Math.abs(last[1] - s.from) < 1e-6) last[1] = s.from + s.dur;
974
+ else ranges.push([s.from, s.from + s.dur]);
975
+ }
976
+ const found: CheckJson["findings"] = [];
977
+ const reports: string[] = [];
978
+ let failed = 0;
979
+ let last: CheckJson | null = null;
980
+ for (const [a, b] of ranges) {
981
+ const part = join(dir, "out", "check-part.json");
982
+ const r = spawnSync(process.execPath, [kfBin, "check", dir, "--from", a.toFixed(4), "--to", b.toFixed(4), "--json", part, ...extra], { encoding: "utf8" });
983
+ reports.push(`${r.stdout ?? ""}${r.stderr ?? ""}`.trim());
984
+ if (r.status === 2 || !existsSync(part)) failed = 2;
985
+ else {
986
+ last = JSON.parse(readFileSync(part, "utf8"));
987
+ found.push(...last!.findings);
988
+ rmSync(part, { force: true });
989
+ }
990
+ }
991
+ if (failed) return { code: 2, report: reports.join("\n\n") };
992
+ const inside = (t: number) => ranges.some(([a, b]) => t >= a - 1e-6 && t < b - 1e-6);
993
+ const findings = [...(before?.findings ?? []).filter((f) => !inside(f.from)), ...found].sort((x, y) => x.from - y.from);
994
+ const counts = { error: findings.filter((f) => f.severity === "error").length, warn: findings.filter((f) => f.severity === "warn").length };
995
+ writeJson(file, { ...(before ?? last), findings, counts, ok: counts.error === 0, part: ranges });
996
+ return { code: counts.error ? 1 : 0, report: reports.join("\n\n") };
997
+ }
998
+
999
+ // 장면마다 검사 결과 한 줄 — 작업실 타임라인이 「끝」·「실패」로 읽는다
1000
+ function sceneVerdicts(dir: string, only?: string[]): void {
1001
+ const file = join(dir, "out", "check.json");
1002
+ const findings: CheckJson["findings"] = existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")).findings ?? []) : [];
1003
+ for (const s of readProject(dir).scenes) {
1004
+ if (only && !only.includes(s.id)) continue;
1005
+ const errs = findings.filter((f) => f.severity === "error" && f.from >= s.from - 1e-6 && f.from < s.from + s.dur);
1006
+ console.error(errs.length ? `장면 ${s.id}: 실패 검사 오류 ${errs.length} — ${[...new Set(errs.map((f) => f.rule))].join(", ")}` : `장면 ${s.id}: 끝`);
1007
+ }
1008
+ }
1009
+
1010
+ // out/check.json 의 오류를 시각으로 장면에 묶는다 — 장면마다 자기 몫의 지적만 받아 다시 쓴다
1011
+ function findingsByScene(dir: string): Record<string, string> {
1012
+ const file = join(dir, "out", "check.json");
1013
+ if (!existsSync(file)) return {};
1014
+ const kf = readProject(dir);
1015
+ const report = JSON.parse(readFileSync(file, "utf8")) as { findings?: { rule: string; severity: string; from: number; to?: number; message: string }[] };
1016
+ const out: Record<string, string[]> = {};
1017
+ for (const f of report.findings ?? []) {
1018
+ if (f.severity !== "error") continue;
1019
+ const s = kf.scenes.find((x) => f.from >= x.from && f.from < x.from + x.dur) ?? kf.scenes.at(-1)!;
1020
+ (out[s.id] ??= []).push(`[${f.rule}] 장면 안 ${(f.from - s.from).toFixed(2)}초${f.to !== undefined ? `–${(f.to - s.from).toFixed(2)}초` : ""}: ${f.message}`);
1021
+ }
1022
+ return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, `검사(kf check):\n${v.join("\n")}`]));
1023
+ }
1024
+
1025
+ // 그림 보는 모델을 안 부를 때 — 세션이 직접 볼 장면 모음·확인표를 만들어 경로를 찍는다
1026
+ function lookHint(dir: string): void {
1027
+ try {
1028
+ const s = lookSheet(dir);
1029
+ console.log(`\n직접 볼 것: ${s.sheet}\n확인표: ${s.checklist}`);
1030
+ } catch (e) {
1031
+ console.error(`장면 모음을 못 만들었다 — ${e instanceof Error ? e.message : e}`);
1032
+ }
1033
+ if (eyelessSession()) console.log(`그림이 안 보이는 세션이면: kf look ${dir} --vision-model ${DEFAULT_VISION}`);
1034
+ }
1035
+
1036
+ // 장면 모음을 그림 보는 모델에 보여 주고(kf look --vision-model), 고칠 장면만 받아 온다
1037
+ async function visionReview(dir: string, model: string): Promise<Record<string, string>> {
1038
+ try {
1039
+ const r = await look(dir, { model });
1040
+ console.error(formatLook(r));
1041
+ return Object.fromEntries(r.scenes.filter((s) => !s.ok).map((s) => [s.id, `그림 검사(${model}): ${s.problems.join(" / ")}`]));
1042
+ } catch (e) {
1043
+ console.error(`그림 검사 건너뜀 — ${e instanceof Error ? e.message : e}`);
1044
+ return {};
1045
+ }
1046
+ }
1047
+
1048
+ function coverTime(dir: string): { scene: string; t: number } {
1049
+ const kf = readProject(dir);
1050
+ const pick = kf.scenes.find((s) => s.role === "drop") ?? kf.scenes.find((s) => s.role === "hook" || s.role === "reveal") ?? kf.scenes[0];
1051
+ return { scene: pick.id, t: +(pick.from + Math.min(0.8, pick.dur / 2)).toFixed(3) };
1052
+ }
1053
+
1054
+ // --- 그 밖
1055
+
1056
+ async function ask(choices: Purpose[]): Promise<Purpose | undefined> {
1057
+ if (!process.stdin.isTTY) return undefined;
1058
+ const labels = listTemplates();
1059
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1060
+ const lines = choices.map((c, i) => ` ${i + 1}) ${c} — ${labels.find((t) => t.id === c)?.label}`).join("\n");
1061
+ const a = await rl.question(`어떤 용도의 영상인가?\n${lines}\n번호: `);
1062
+ rl.close();
1063
+ return choices[Number(a) - 1];
1064
+ }
1065
+
1066
+ // 곡을 안 줬을 때 KF_MUSIC_DIR(저작권 걱정 없는 곡 모음)에서 요청 낱말과 이름이 맞는 곡을 고른다.
1067
+ // 곡 파일 이름은 대개 영어라 한국어 장르 낱말을 영어로도 찾는다
1068
+ const GENRE: Record<string, string[]> = {
1069
+ 카와이: ["kawaii", "cute"],
1070
+ 귀여운: ["kawaii", "cute"],
1071
+ 퓨처베이스: ["future", "bass"],
1072
+ 칠: ["chill", "lofi"],
1073
+ 로파이: ["lofi", "lo-fi"],
1074
+ 몽환: ["dream", "ambient"],
1075
+ 신나는: ["upbeat", "energetic", "edm"],
1076
+ 잔잔한: ["calm", "ambient", "piano"],
1077
+ 애니: ["anime"],
1078
+ 시네마틱: ["cinematic", "epic"],
1079
+ };
1080
+
1081
+ // 기본 곡 모음은 레포의 music/(README 에 곡마다 원본·라이선스). KF_MUSIC_DIR 이 있으면 그쪽
1082
+ const musicDir = (): string => process.env.KF_MUSIC_DIR || fileURLToPath(new URL("../../../../music/", import.meta.url));
1083
+
1084
+ function findLibraryMusic(request: string): string | undefined {
1085
+ const lib = musicDir();
1086
+ if (!existsSync(lib)) return undefined;
1087
+ const songs = listFiles(lib).filter((f) => AUDIO.test(f));
1088
+ const words = request.toLowerCase().split(/[\s,]+/).filter((w) => w.length > 1);
1089
+ const wanted = [...words, ...Object.entries(GENRE).filter(([k]) => request.includes(k)).flatMap(([, v]) => v)];
1090
+ const score = (f: string) => wanted.filter((w) => basename(f, extname(f)).toLowerCase().includes(w)).length;
1091
+ return songs.sort((a, b) => score(b) - score(a))[0];
1092
+ }
1093
+
1094
+ function listFiles(dir: string): string[] {
1095
+ if (!existsSync(dir)) return [];
1096
+ if (statSync(dir).isFile()) return [dir];
1097
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? readdirSync(join(dir, e.name)).map((f) => join(dir, e.name, f)) : [join(dir, e.name)]));
1098
+ }
1099
+
1100
+ function stamp(): string {
1101
+ const d = new Date();
1102
+ const p = (n: number) => String(n).padStart(2, "0");
1103
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
1104
+ }
1105
+
1106
+ // content.json 의 say 와 지금 깔린 목소리 문장(captions.json)이 다른 장면이 있나
1107
+ function sayChanged(dir: string, ids: string[]): boolean {
1108
+ const read = (f: string) => (existsSync(join(dir, f)) ? JSON.parse(readFileSync(join(dir, f), "utf8")) : null);
1109
+ const content = read("content.json") as { scenes?: { id: string; say?: string }[] } | null;
1110
+ const lines = (read("captions.json") ?? []) as { scene?: string; text?: string }[];
1111
+ return ids.some((id) => (content?.scenes?.find((s) => s.id === id)?.say ?? "").trim() !== lines.filter((l) => l.scene === id).map((l) => l.text?.trim() ?? "").join(" ").trim());
1112
+ }
1113
+
1114
+ function readPlan(dir: string): Partial<Plan> & { model?: string } {
1115
+ const p = join(dir, "plan.json");
1116
+ return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : {};
1117
+ }
1118
+
1119
+ function writeJson(path: string, v: unknown): void {
1120
+ writeFileSync(path, `${JSON.stringify(v, null, 2)}\n`);
1121
+ }