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,542 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, extname, join, resolve } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { genImage, genVideo, hasKey, NeedsApproval, seam, seamFrames, still, whereToPut } from "@kasaframe/gen";
7
+ import { attachStudio, chat, codeBlock, detect, formatLook, jsonBlock, listTemplates, loadTemplate, look, lookSheet, type Message, type Plan, projectBrief, PURPOSES, type Purpose, readProject, writeCaptions } from "@kasaframe/plan";
8
+ import { transform } from "esbuild";
9
+ import { themeStyle } from "@kasaframe/themes";
10
+ import { captionMode } from "@kasaframe/motion/captions";
11
+ import { openPage } from "../browser.ts";
12
+ import { type Command, num, parseFlags } from "../command.ts";
13
+
14
+ const kfBin = fileURLToPath(new URL("../kf.ts", import.meta.url));
15
+
16
+ // Claude Code 를 OG 로 다른 모델(딥시크 등)에 붙이면 OG /v1/messages 가 그림을 버린다 — 세션이 kf sheet 를 Read 해도 못 본다.
17
+ // 그런 「눈 없는 세션」이면 --finish 끝에 --vision-model 을 알려 준다(그림 보는 모델은 기본으로 부르지 않는다)
18
+ export const eyelessSession = (): boolean =>
19
+ /opengateway/i.test(process.env.ANTHROPIC_BASE_URL ?? "") && !/^anthropic\//.test(process.env.ANTHROPIC_MODEL ?? "anthropic/");
20
+ const AUDIO = /\.(mp3|wav|m4a|aac|flac|ogg)$/i;
21
+ const DEFAULT_MODEL = "deepseek/deepseek-v4-pro";
22
+ const DEFAULT_VISION = "google/gemini-3.5-flash";
23
+ // 화면에 찍히면 안 되는 글 — 지난 실험에서 실제로 나온 것들
24
+ const BAD_TEXT = /\bundefined\b|\bNaN\b|\[object Object\]|\bnull\b|�/;
25
+
26
+ const cmd: Command = {
27
+ summary: "요청 한 줄로 끝까지 — 용도 판별·계획·생성 자산·장면 작성·렌더·검사·고치기",
28
+ usage: `kf make "<요청>" [--out 폴더] [--assets 폴더] [--music 곡] [--model claude-code|<OG 모델>] [--purpose 용도] [--title 이름] [--yes]
29
+ kf make --finish <폴더> [--model ...] 장면을 다 채운 뒤 렌더·검사·표지(Claude Code 모드의 둘째 단계)
30
+ kf make --rewrite <폴더> --scenes a,b [--model <OG 모델>] 고른 장면만 다시 쓰고 렌더·검사(작업실 장면 카드)
31
+
32
+ --model claude-code: 장면 지시서(MAKE.md)까지 만들고 멈춘다 — Claude Code 가 장면을 채우고 --finish
33
+ <OG 모델>: OG chat/completions 로 장면을 쓰고 렌더·검사·고치기까지(기본 ${DEFAULT_MODEL})
34
+ Claude Code 안(CLAUDECODE=1)에서 부르면 기본이 claude-code
35
+ --vision-model 그림 보는 검사 모델(기본 none — 세션이 kf look 의 장면 모음·확인표를 직접 본다). 그림을 못 보는 세션만 ${DEFAULT_VISION} 같은 모델을 준다
36
+ --purpose 용도를 못 박는다: ${PURPOSES.join(" · ")}
37
+ --theme 테마 id · auto(기본 — 요청이 「리소 느낌」처럼 짚은 테마, 아니면 내용에 맞는 approved 후보 셋 중 첫째. 첫 장면을 셋 다 찍어 out/theme-<id>.png) · none(프리셋만)
38
+ --yes 영상 생성처럼 돈이 큰 호출을 승인한다(없으면 견적만 보이고 이음매는 대체 화면으로 간다)
39
+ --max-usd 영상 하나 상한(기본 1)
40
+ --video-provider 영상 공급자를 못 박는다(예: h3-pod — KF_H3_URL·KF_H3_STEPS 필요). 기본은 KF_VIDEO_PROVIDER, 없으면 생성 담당 기본
41
+ --fix 검사가 떨어졌을 때 고치기 횟수(기본 2)
42
+ --captions on|off|fit 화면 자막(fit 은 장면을 자막 띠 위로 줄여 올린다) — 새로 만들 땐 kf.json 에 적고, --finish 에선 그 판만 덮는다(다시 만들지 않고 켜고 끄기). srt·vtt 는 둘 다 낸다
43
+ --suffix [글] --finish 결과 이름 뒤에 붙인다(값 없이면 -자막 · -자막없음) — 원본 video.mp4 를 덮지 않고 둘 다 남긴다
44
+ --no-gen 생성 모델을 부르지 않는다. claude-code 모드는 묻지 않고 그림을 만들지 않는다 — 지시서의 「만들 그림」을 보고 세션이 프롬프트를 써서 kf gen img 로 만든다
45
+
46
+ 용도를 못 정하면 터미널에서 묻고, 터미널이 아니면 보기를 찍고 종료 코드 3.`,
47
+ async run(argv) {
48
+ const { args, opts } = parseFlags(argv);
49
+ const str = (k: string) => (typeof opts[k] === "string" ? (opts[k] as string) : undefined);
50
+ // --finish 는 Claude Code 모드의 둘째 단계라 모델을 안 주면 claude-code — 세션이 직접 그림을 보니 그림 검사 모델도 부르지 않는다
51
+ const model = str("model") ?? (process.env.CLAUDECODE || opts.finish ? "claude-code" : DEFAULT_MODEL);
52
+ const vision = str("vision-model") ?? "none";
53
+ // OG 모드는 장면 쓰기·그림 보기까지 OG 를 부른다 — 뼈대만 만들고 죽지 않게 처음에 막는다
54
+ if (model !== "claude-code" && !hasKey("opengateway")) {
55
+ console.error(`kf make: OG 키가 없다 — ${whereToPut("opengateway")}. 키 없이는 Claude Code 안에서(또는 --model claude-code) — 장면은 세션이 쓰고 그림 생성은 건너뛴다`);
56
+ return 2;
57
+ }
58
+ const fixes = num(opts.fix, 2);
59
+ const captions = str("captions");
60
+ if (captions !== undefined && captions !== "on" && captions !== "off" && captions !== "fit") {
61
+ console.error(`kf make: --captions 는 on·off·fit — ${captions}`);
62
+ return 2;
63
+ }
64
+ const suffix = str("suffix") ?? (opts.suffix ? (captions === "off" ? "-자막없음" : "-자막") : "");
65
+
66
+ if (opts.rewrite) {
67
+ const dir = resolve(typeof opts.rewrite === "string" ? opts.rewrite : (args[0] ?? "."));
68
+ const ids = (str("scenes") ?? "").split(",").filter(Boolean);
69
+ if (!ids.length || model === "claude-code") {
70
+ console.error("kf make --rewrite: --scenes 와 OG 모델(--model)이 필요하다");
71
+ return 2;
72
+ }
73
+ writeFileSync(join(dir, "MAKE.md"), projectBrief(dir));
74
+ await writeScenes(dir, model, ids);
75
+ return finish(dir, { model, vision, fixes });
76
+ }
77
+ if (opts.finish) {
78
+ const dir = resolve(typeof opts.finish === "string" ? opts.finish : (args[0] ?? "."));
79
+ const gen = { approve: !!opts.yes, maxUsd: num(opts["max-usd"], 1), videoProvider: str("video-provider") ?? process.env.KF_VIDEO_PROVIDER };
80
+ const plan = existsSync(join(dir, "plan.json")) ? JSON.parse(readFileSync(join(dir, "plan.json"), "utf8")) : {};
81
+ const studio = await attachStudio({ dir, request: plan.request ?? dir, model, purpose: plan.purpose });
82
+ const unmirror = studio?.mirror();
83
+ if (studio) console.log(`작업실: ${studio.url}`);
84
+ await studio?.send({ phase: "render" });
85
+ const code = await finish(dir, { model, vision, fixes, gen: opts["no-gen"] ? undefined : gen, captions, suffix });
86
+ await unmirror?.();
87
+ await studio?.send({ done: true, ok: code === 0 });
88
+ return code;
89
+ }
90
+
91
+ const request = args.join(" ").trim();
92
+ if (!request) {
93
+ console.error('kf make: 요청 글이 필요하다 — kf make "카사텀 홍보 영상 1분"');
94
+ return 2;
95
+ }
96
+ const assetFiles = str("assets") ? listFiles(resolve(str("assets")!)) : [];
97
+ const given = str("music") ? resolve(str("music")!) : assetFiles.find((f) => AUDIO.test(f));
98
+
99
+ let purpose = str("purpose") as Purpose | undefined;
100
+ const det = detect({ request, assets: assetFiles, music: given }, listTemplates());
101
+ if (!purpose) {
102
+ if (det.ambiguous) {
103
+ purpose = await ask(det.ranked.slice(0, 4).map((r) => r.purpose));
104
+ if (!purpose) {
105
+ console.error(`kf make: 용도를 못 정했다(확신 ${det.confidence}). --purpose 로 골라 다시: ${det.ranked.slice(0, 4).map((r) => `${r.purpose}(${r.p})`).join(" · ")}`);
106
+ return 3;
107
+ }
108
+ } else purpose = det.purpose;
109
+ }
110
+ const chosen = det.ranked.find((r) => r.purpose === purpose);
111
+ // 곡을 안 줬으면 곡이 끌고 가는 용도(홍보·공개)일 때만 곡 모음에서 고른다 — 쇼츠·설명에 아무 곡이나 깔리지 않게
112
+ const music = given ?? (loadTemplate(purpose).music === "required" ? findLibraryMusic(request) : undefined);
113
+ console.error(`용도 ${purpose} (확신 ${chosen?.p ?? 1}${chosen?.reasons.length ? ` — ${chosen.reasons.join(", ")}` : ""})`);
114
+
115
+ const dir = resolve(str("out") ?? `${purpose}-${stamp()}`);
116
+ const newArgs = [kfBin, "new", dir, "--template", purpose, "--request", request];
117
+ if (music) newArgs.push("--music", music);
118
+ if (str("title")) newArgs.push("--title", str("title")!);
119
+ // 말로만 시작해도 테마까지 알아서 — 안 주면 auto(요청이 짚은 테마 → 내용에 맞는 approved 후보). 테마 없이 프리셋만 쓰려면 --theme none
120
+ const theme = str("theme") ?? "auto";
121
+ if (theme !== "none") newArgs.push("--theme", theme);
122
+ if (opts.force) newArgs.push("--force");
123
+ const made = spawnSync(process.execPath, newArgs, { stdio: ["ignore", "inherit", "inherit"] });
124
+ if (made.status !== 0) return made.status ?? 1;
125
+
126
+ const studio = await attachStudio({ dir, request, model, purpose });
127
+ const unmirror = studio?.mirror();
128
+ if (studio) console.log(`작업실: ${studio.url}`);
129
+ const plan: Plan = JSON.parse(readFileSync(join(dir, "plan.json"), "utf8"));
130
+ // 새 프로젝트는 이 영상의 기본으로 적는다 — 계획 다듬기가 내레이션을 쓸지도 이걸 본다
131
+ if (captions) {
132
+ const kfPath = join(dir, "kf.json");
133
+ writeJson(kfPath, { ...JSON.parse(readFileSync(kfPath, "utf8")), captions });
134
+ plan.captions = captionMode(captions);
135
+ }
136
+ if (!str("purpose")) {
137
+ plan.confidence = chosen?.p ?? det.confidence;
138
+ plan.alternatives = det.ranked.filter((r) => r.purpose !== purpose).slice(0, 2).map((r) => ({ purpose: r.purpose, p: r.p }));
139
+ plan.reasons = [...(chosen?.reasons ?? []), ...plan.reasons.filter((r) => r !== "사람이 정함")];
140
+ }
141
+ if (assetFiles.length) {
142
+ mkdirSync(join(dir, "assets", "in"), { recursive: true });
143
+ for (const f of assetFiles) if (!AUDIO.test(f)) copyFileSync(f, join(dir, "assets", "in", basename(f)));
144
+ }
145
+ writeJson(join(dir, "plan.json"), plan);
146
+ if (plan.theme) {
147
+ const pick = plan.themeCandidates?.find((c) => c.id === plan.theme);
148
+ 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(" · ")})` : ""}`);
149
+ }
150
+ if (plan.themeCandidates && plan.themeCandidates.length > 1) themeStills(dir, plan.themeCandidates.map((c) => c.id));
151
+
152
+ if (model !== "claude-code") await refinePlan(dir, model, request);
153
+
154
+ const gen = { approve: !!opts.yes, maxUsd: num(opts["max-usd"], 1), videoProvider: str("video-provider") ?? process.env.KF_VIDEO_PROVIDER };
155
+ // claude-code 모드는 돈이 드는 그림을 묻지 않고 만들지 않는다 — 막연한 자동 프롬프트로 엉뚱한 그림이 나왔다. 지시서에 목록으로 낸다
156
+ if (!opts["no-gen"] && model !== "claude-code") await genImages(dir);
157
+
158
+ writeFileSync(join(dir, "MAKE.md"), projectBrief(dir));
159
+ if (model === "claude-code") {
160
+ const flags = [gen.approve ? "--yes" : "", gen.videoProvider ? `--video-provider ${gen.videoProvider}` : ""].filter(Boolean).join(" ");
161
+ console.log(`\n${dir}/MAKE.md 를 읽고 scenes/*.ts 를 채운다. 다 채우면(이음매 영상은 그때 앞뒤 장면 프레임으로 만든다):\n kf make --finish ${dir}${flags ? ` ${flags}` : ""}`);
162
+ // 장면은 세션이 쓴다 — 작업실 미리보기가 저장할 때마다 따라온다
163
+ console.error("장면 쓰는 중(세션) — 저장할 때마다 작업실 미리보기에 바로 보인다");
164
+ await unmirror?.();
165
+ await studio?.send({ phase: "scenes" });
166
+ return 0;
167
+ }
168
+ // 이음매는 틀의 3D 전환이 맡는다 — 모델이 다시 쓰지 않는다
169
+ await writeScenes(dir, model, readProject(dir).scenes.filter((s) => s.role !== "seam").map((s) => s.id));
170
+ const code = await finish(dir, { model, vision, fixes, gen: opts["no-gen"] ? undefined : gen });
171
+ await unmirror?.();
172
+ await studio?.send({ done: true, ok: code === 0 });
173
+ return code;
174
+ },
175
+ };
176
+ export default cmd;
177
+
178
+ // --- 계획 다듬기: 틀이 정한 뼈대(시각·역할)는 그대로, 이름·끝 한 줄·장면 할 일·생성 프롬프트만 요청에 맞춘다
179
+
180
+ async function refinePlan(dir: string, model: string, request: string): Promise<void> {
181
+ const plan: Plan = JSON.parse(readFileSync(join(dir, "plan.json"), "utf8"));
182
+ const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8"));
183
+ const ask: Message[] = [
184
+ {
185
+ role: "system",
186
+ content:
187
+ "너는 짧은 영상의 연출 계획을 다듬는다. 장면 id·순서·시각은 바꾸지 않는다. 화면에 쓸 글 정책을 지킨다. JSON 하나로만 답한다.",
188
+ },
189
+ {
190
+ role: "user",
191
+ content: `요청: ${request}
192
+ 용도: ${plan.purpose} · ${plan.duration}초 · ${plan.aspect} · 스타일 ${plan.style} · 화면 글 정책 ${kf.text?.policy}
193
+ 장면: ${JSON.stringify(plan.scenes.map((s) => ({ id: s.id, role: s.role, dur: s.dur, idea: s.idea })))}
194
+ 생성 자산: ${JSON.stringify(plan.gen.map((g) => ({ id: g.id, kind: g.kind, scene: g.scene, prompt: g.prompt })))}
195
+
196
+ 다음 꼴로: {"title": "제품·주제 이름(짧게)", "line": "끝 카드 한 줄(없으면 빈 글)", "scenes": [{"id": "...", "idea": "이 요청에 맞춘 구체적인 화면·움직임 한두 문장"}], "gen": [{"id": "...", "prompt": "영어 생성 프롬프트, 글자·로고 없이"}]${captionMode(kf.captions) !== "none" ? ', "captions": [{"scene": "장면 id", "text": "그 장면에서 들릴 한국어 내레이션 — 짧은 마디를 쉼표로 이어 장면 길이에 맞게(1초에 4~5글자)"}]' : ""}}`,
197
+ },
198
+ ];
199
+ try {
200
+ // 생각하는 모델은 생각에 토큰을 먼저 쓴다 — 상한이 낮으면 답 JSON 이 잘린다
201
+ const r = await chat(model, ask, { json: true, temperature: 0.5, maxTokens: 16000 });
202
+ const j = jsonBlock<{ title?: string; line?: string; scenes?: { id: string; idea: string }[]; gen?: { id: string; prompt: string }[]; captions?: { scene: string; text: string }[] }>(r.text);
203
+ if (!j) throw new Error(`JSON 을 못 읽었다(${r.text.length}자: ${r.text.slice(0, 160).replace(/\s+/g, " ")})`);
204
+ if (j.title) plan.title = kf.title = j.title;
205
+ for (const s of j.scenes ?? []) {
206
+ const p = plan.scenes.find((x) => x.id === s.id);
207
+ const k = kf.scenes.find((x: { id: string }) => x.id === s.id);
208
+ if (p && s.idea) p.idea = k.idea = s.idea;
209
+ }
210
+ for (const g of j.gen ?? []) {
211
+ const p = plan.gen.find((x) => x.id === g.id);
212
+ if (p && g.prompt) p.prompt = g.prompt;
213
+ }
214
+ (plan as Plan & { line?: string }).line = j.line ?? "";
215
+ if (captionMode(kf.captions) !== "none" && j.captions?.length) writeJson(join(dir, "captions.json"), j.captions);
216
+ writeJson(join(dir, "plan.json"), plan);
217
+ writeJson(join(dir, "kf.json"), kf);
218
+ console.error(`계획 다듬음(${model}, ${(r.ms / 1000).toFixed(1)}초): ${plan.title}`);
219
+ } catch (e) {
220
+ console.error(`계획 다듬기 건너뜀 — ${e instanceof Error ? e.message : e}`);
221
+ }
222
+ }
223
+
224
+ // --- 장면 쓰기: 장면마다 파일 하나를 받아 놓고, 실제로 열어 오류·이상한 글이 없을 때까지 돌려준다
225
+
226
+ async function writeScenes(dir: string, model: string, ids: string[], feedback: Record<string, string> = {}): Promise<void> {
227
+ const brief = readFileSync(join(dir, "MAKE.md"), "utf8");
228
+ const kf = readProject(dir);
229
+ for (const id of ids) {
230
+ const s = kf.scenes.find((x) => x.id === id);
231
+ if (!s) continue;
232
+ const file = join(dir, s.file);
233
+ let note = feedback[id] ?? "";
234
+ console.error(`장면 ${s.id} 쓰는 중(${model})`);
235
+ for (let attempt = 0; attempt < 3; attempt++) {
236
+ const current = existsSync(file) ? readFileSync(file, "utf8") : "";
237
+ const messages: Message[] = [
238
+ { role: "system", content: `너는 kasaframe 장면 파일을 쓴다. 아래 지시서를 지킨다. 답은 TypeScript 코드 블록 하나뿐.\n\n${brief}` },
239
+ {
240
+ role: "user",
241
+ 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}` : ""}`,
242
+ },
243
+ ];
244
+ const r = await chat(model, messages, { temperature: 0.6, maxTokens: 32000 });
245
+ const code = r.finish === "length" ? null : codeBlock(r.text);
246
+ if (r.finish === "length") {
247
+ note = "답이 길이 상한에서 잘렸다. 군더더기 없이 더 짧게, ```ts 블록 하나로 파일 전체를 답하라.";
248
+ console.error(` ${s.id} ${attempt + 1}번째 — 답이 잘림`);
249
+ continue;
250
+ }
251
+ if (!code) {
252
+ note = "코드 블록을 못 찾았다. ```ts 블록 하나로 파일 전체를 답하라.";
253
+ continue;
254
+ }
255
+ writeFileSync(file, code);
256
+ const problems = await probeScene(dir, s.id);
257
+ console.error(` ${s.id} ${attempt + 1}번째 ${(r.ms / 1000).toFixed(1)}초 — ${problems.length ? problems.join(" / ") : "통과"}`);
258
+ if (!problems.length) break;
259
+ note = problems.join("\n");
260
+ }
261
+ }
262
+ }
263
+
264
+ // 렌더 전에 장면 하나를 세 시각에서 열어 본다 — 통짜 렌더 1분을 쓰기 전에 잡을 것을 잡는다
265
+ async function probeScene(dir: string, id: string): Promise<string[]> {
266
+ const kf = readProject(dir);
267
+ const s = kf.scenes.find((x) => x.id === id)!;
268
+ const problems: string[] = [];
269
+ // 문법이 깨지면 페이지는 「__kf 를 안 걸었다」밖에 말해 주지 않는다 — 줄·칸이 붙은 오류를 먼저 돌려준다
270
+ try {
271
+ await transform(readFileSync(join(dir, s.file), "utf8"), { loader: "ts", sourcefile: s.file });
272
+ } catch (e) {
273
+ const errs = (e as { errors?: { text: string; location?: { line: number; column: number; lineText: string } }[] }).errors ?? [];
274
+ 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}`];
275
+ }
276
+ let p: Awaited<ReturnType<typeof openPage>>;
277
+ try {
278
+ p = await openPage(dir);
279
+ } catch (e) {
280
+ return [`페이지가 안 열린다: ${(e instanceof Error ? e.message : String(e)).slice(0, 600)}`];
281
+ }
282
+ const errors: string[] = [];
283
+ p.page.on("pageerror", (e) => errors.push(e.message));
284
+ try {
285
+ for (const t of [s.from + 0.05, s.from + s.dur / 2, s.from + s.dur - 0.05]) {
286
+ await p.seek(t);
287
+ const text: string = await p.page.evaluate(() => (document.getElementById("stage") ?? document.body).innerText);
288
+ const bad = text.match(BAD_TEXT);
289
+ if (bad) problems.push(`${(t - s.from).toFixed(2)}초 화면에 "${bad[0]}" 글자가 찍힌다`);
290
+ const box = await p.page.evaluate(() => {
291
+ const out: string[] = [];
292
+ const W = innerWidth;
293
+ const H = innerHeight;
294
+ for (const el of Array.from(document.querySelectorAll("#stage *"))) {
295
+ if (!(el as HTMLElement).innerText?.trim() || el.children.length) continue;
296
+ const r = el.getBoundingClientRect();
297
+ const cs = getComputedStyle(el);
298
+ if (cs.opacity === "0" || cs.visibility === "hidden" || r.width === 0) continue;
299
+ if (r.right < 0 || r.bottom < 0 || r.left > W || r.top > H) continue;
300
+ 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));
301
+ }
302
+ return out;
303
+ });
304
+ if (box.length) problems.push(`${(t - s.from).toFixed(2)}초에 글이 화면 밖으로 나간다: ${box.slice(0, 3).join(", ")}`);
305
+ }
306
+ } catch (e) {
307
+ problems.push(`seek 중 오류: ${e instanceof Error ? e.message : e}`);
308
+ } finally {
309
+ await p.close();
310
+ }
311
+ for (const e of new Set(errors)) problems.push(`페이지 오류: ${e}`);
312
+ return [...new Set(problems)];
313
+ }
314
+
315
+ // 테마 후보마다 첫 장면을 그 테마로 한 장 — 사람이 셋을 나란히 보고 고른다. style.json 만 잠깐 바꿔 찍고 되돌린다
316
+ function themeStills(dir: string, ids: string[]): void {
317
+ const stylePath = join(dir, "style.json");
318
+ const keep = readFileSync(stylePath, "utf8");
319
+ const motion = JSON.parse(keep).motion;
320
+ const kf = readProject(dir);
321
+ const first = kf.scenes[0];
322
+ const t = +(first.from + Math.min(first.dur * 0.6, 1.2)).toFixed(3);
323
+ try {
324
+ for (const id of ids) {
325
+ writeJson(stylePath, { ...themeStyle(id), motion });
326
+ const r = spawnSync(process.execPath, [kfBin, "still", dir, "--t", String(t), "--out", join(dir, "out", `theme-${id}.png`)], { stdio: "ignore" });
327
+ console.error(`테마 후보 ${id}: ${r.status === 0 ? `out/theme-${id}.png` : "못 찍음"}`);
328
+ }
329
+ } finally {
330
+ writeFileSync(stylePath, keep);
331
+ }
332
+ }
333
+
334
+ // --- 생성 자산: 그림은 장면을 쓰기 전에(장면이 가져다 쓴다), 영상은 장면을 다 쓴 뒤에(이음매는 앞뒤 장면의 진짜 프레임에서 뽑는다)
335
+
336
+ interface GenOpts {
337
+ approve: boolean;
338
+ maxUsd: number;
339
+ videoProvider?: string;
340
+ }
341
+
342
+ const genLog = (l: string) => console.error(` ${l}`);
343
+
344
+ async function genImages(dir: string): Promise<void> {
345
+ const plan: Plan = JSON.parse(readFileSync(join(dir, "plan.json"), "utf8"));
346
+ const imgs = plan.gen.filter((g) => g.kind === "img");
347
+ if (!imgs.length) return;
348
+ console.error(`생성 자산 그림 ${imgs.length}개…`);
349
+ for (const g of imgs) {
350
+ try {
351
+ const size = g.size ?? (plan.aspect === "9:16" ? "1080x1920" : plan.aspect === "1:1" ? "1080x1080" : "1920x1080");
352
+ 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);
353
+ console.error(` gen:img:${g.id} ${a.cached ? "cached" : "made"} ${a.path} $${(a.cached ? 0 : a.meta.costUsd).toFixed(3)}`);
354
+ } catch (e) {
355
+ console.error(` gen:img:${g.id} ${e instanceof NeedsApproval ? "needs-approval" : "error"} ${e instanceof Error ? e.message : e}`);
356
+ }
357
+ }
358
+ }
359
+
360
+ async function genVideos(dir: string, o: GenOpts): Promise<void> {
361
+ const plan: Plan = JSON.parse(readFileSync(join(dir, "plan.json"), "utf8"));
362
+ const vids = plan.gen.filter((g) => g.kind === "vid");
363
+ if (!vids.length) return;
364
+ console.error(`생성 자산 영상 ${vids.length}개…`);
365
+ const ctx = { outDir: join(dir, "assets", "gen"), approve: o.approve, maxUsd: o.maxUsd, log: genLog };
366
+ for (const g of vids) {
367
+ const provider = o.videoProvider ?? g.provider;
368
+ try {
369
+ const a = g.seam
370
+ ? await seam({ project: dir, scene: g.id, prompt: g.prompt, provider }, ctx)
371
+ : 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);
372
+ console.error(` gen:vid:${g.id} ${a.cached ? "cached" : "made"} ${a.path} $${(a.cached ? 0 : a.meta.costUsd).toFixed(3)}`);
373
+ } catch (e) {
374
+ const tail = e instanceof NeedsApproval ? `needs-approval 견적 $${e.estimateUsd.toFixed(3)}(${e.provider}) — --yes 로 승인하면 만든다, 지금은 대체 화면` : `error ${e instanceof Error ? e.message : e}`;
375
+ console.error(` gen:vid:${g.id} ${tail}`);
376
+ }
377
+ }
378
+ }
379
+
380
+ // 이음매 장면의 모션그래픽 전환(transition3d)은 앞 장면 마지막 화면·뒤 장면 첫 화면 두 장을 받아 그 사이를 잇는다.
381
+ // 장면을 다 쓴 뒤에 찍어야 진짜 화면이 된다 — 그림이 없으면 전환이 style 색으로 대체 화면을 그린다
382
+ async function seamStills(dir: string): Promise<void> {
383
+ const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8"));
384
+ const seams = (kf.scenes as { id: string; role: string }[]).filter((s) => s.role === "seam");
385
+ if (!seams.length) return;
386
+ mkdirSync(join(dir, "assets", "seam"), { recursive: true });
387
+ for (const s of seams) {
388
+ const { firstFrame, lastFrame } = seamFrames(kf, s.id);
389
+ try {
390
+ if (firstFrame !== undefined) await still(dir, firstFrame, join(dir, "assets", "seam", `${s.id}-from.png`));
391
+ if (lastFrame !== undefined) await still(dir, lastFrame, join(dir, "assets", "seam", `${s.id}-to.png`));
392
+ console.error(`이음매 ${s.id}: 앞뒤 화면 두 장 찍음`);
393
+ } catch (e) {
394
+ console.error(`이음매 ${s.id}: 앞뒤 화면을 못 찍었다 — 대체 화면으로 간다(${e instanceof Error ? e.message : e})`);
395
+ }
396
+ }
397
+ }
398
+
399
+ // --- 마무리: 렌더 → 검사 → (OG 모드면) 고치기 → 표지
400
+
401
+ // captions 를 주면 그 판만 화면 자막을 켜고 끈다(kf.json 은 그대로) — 굽기·검사·자막 트랙이 같은 값을 본다
402
+ async function finish(dir: string, o: { model: string; vision: string; fixes: number; gen?: GenOpts; captions?: "on" | "off" | "fit"; suffix?: string }): Promise<number> {
403
+ if (!existsSync(join(dir, "kf.json"))) {
404
+ console.error(`kf make: ${dir} 에 kf.json 이 없다`);
405
+ return 2;
406
+ }
407
+ if (o.gen) await genVideos(dir, o.gen);
408
+ await seamStills(dir);
409
+ const out = join(dir, "out", `video${o.suffix ?? ""}.mp4`);
410
+ const capFlag = o.captions ? ["--captions", o.captions] : [];
411
+ let verdict = { code: 0, report: "" };
412
+ for (let round = 0; ; round++) {
413
+ if (round > 0) await seamStills(dir);
414
+ const r = spawnSync(process.execPath, [kfBin, "render", dir, "--out", out, "--tabs", "2", ...capFlag], { stdio: ["ignore", "inherit", "inherit"] });
415
+ if (r.status !== 0) {
416
+ console.error("kf make: 렌더가 실패했다");
417
+ return r.status ?? 1;
418
+ }
419
+ verdict = check(dir, capFlag);
420
+ const review = o.vision !== "none" ? await visionReview(dir, o.vision) : {};
421
+ const toFix: Record<string, string> = { ...review };
422
+ for (const [id, note] of Object.entries(findingsByScene(dir))) toFix[id] = [toFix[id], note].filter(Boolean).join("\n");
423
+ if ((verdict.code === 0 && !Object.keys(review).length) || o.model === "claude-code" || round >= o.fixes) break;
424
+ if (!Object.keys(toFix).length) break;
425
+ console.error(`고치기 ${round + 1}/${o.fixes}: ${Object.keys(toFix).join(" ")}`);
426
+ writeFileSync(join(dir, "MAKE.md"), projectBrief(dir));
427
+ await writeScenes(dir, o.model, Object.keys(toFix), toFix);
428
+ }
429
+ const subs = writeCaptions(dir, o.captions && captionMode(o.captions));
430
+ const cover = coverTime(dir);
431
+ spawnSync(process.execPath, [kfBin, "still", dir, "--t", String(cover), "--out", join(dir, "out", "cover.png")], { stdio: "ignore" });
432
+ console.log(`\n${[out, join(dir, "out", "cover.png"), ...subs].join("\n")}`);
433
+ if (o.vision === "none") lookHint(dir);
434
+ if (verdict.report) console.log(`\n검사:\n${verdict.report}`);
435
+ return verdict.code;
436
+ }
437
+
438
+ // 폴더를 주면 kf check 가 페이지를 열어 프레임마다 훑는다 — mp4 를 OCR 하는 것보다 정확하다
439
+ function check(dir: string, extra: string[] = []): { code: number; report: string } {
440
+ const r = spawnSync(process.execPath, [kfBin, "check", dir, ...extra], { encoding: "utf8" });
441
+ return { code: r.status ?? 1, report: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
442
+ }
443
+
444
+ // out/check.json 의 오류를 시각으로 장면에 묶는다 — 장면마다 자기 몫의 지적만 받아 다시 쓴다
445
+ function findingsByScene(dir: string): Record<string, string> {
446
+ const file = join(dir, "out", "check.json");
447
+ if (!existsSync(file)) return {};
448
+ const kf = readProject(dir);
449
+ const report = JSON.parse(readFileSync(file, "utf8")) as { findings?: { rule: string; severity: string; from: number; to?: number; message: string }[] };
450
+ const out: Record<string, string[]> = {};
451
+ for (const f of report.findings ?? []) {
452
+ if (f.severity !== "error") continue;
453
+ const s = kf.scenes.find((x) => f.from >= x.from && f.from < x.from + x.dur) ?? kf.scenes.at(-1)!;
454
+ (out[s.id] ??= []).push(`[${f.rule}] 장면 안 ${(f.from - s.from).toFixed(2)}초${f.to !== undefined ? `–${(f.to - s.from).toFixed(2)}초` : ""}: ${f.message}`);
455
+ }
456
+ return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, `검사(kf check):\n${v.join("\n")}`]));
457
+ }
458
+
459
+ // 그림 보는 모델을 안 부를 때 — 세션이 직접 볼 장면 모음·확인표를 만들어 경로를 찍는다
460
+ function lookHint(dir: string): void {
461
+ try {
462
+ const s = lookSheet(dir);
463
+ console.log(`\n직접 볼 것: ${s.sheet}\n확인표: ${s.checklist}`);
464
+ } catch (e) {
465
+ console.error(`장면 모음을 못 만들었다 — ${e instanceof Error ? e.message : e}`);
466
+ }
467
+ if (eyelessSession()) console.log(`그림이 안 보이는 세션이면: kf look ${dir} --vision-model ${DEFAULT_VISION}`);
468
+ }
469
+
470
+ // 장면 모음을 그림 보는 모델에 보여 주고(kf look --vision-model), 고칠 장면만 받아 온다
471
+ async function visionReview(dir: string, model: string): Promise<Record<string, string>> {
472
+ try {
473
+ const r = await look(dir, { model });
474
+ console.error(formatLook(r));
475
+ return Object.fromEntries(r.scenes.filter((s) => !s.ok).map((s) => [s.id, `그림 검사(${model}): ${s.problems.join(" / ")}`]));
476
+ } catch (e) {
477
+ console.error(`그림 검사 건너뜀 — ${e instanceof Error ? e.message : e}`);
478
+ return {};
479
+ }
480
+ }
481
+
482
+ function coverTime(dir: string): number {
483
+ const kf = readProject(dir);
484
+ const pick = kf.scenes.find((s) => s.role === "drop") ?? kf.scenes.find((s) => s.role === "hook" || s.role === "reveal") ?? kf.scenes[0];
485
+ return +(pick.from + Math.min(0.8, pick.dur / 2)).toFixed(3);
486
+ }
487
+
488
+ // --- 그 밖
489
+
490
+ async function ask(choices: Purpose[]): Promise<Purpose | undefined> {
491
+ if (!process.stdin.isTTY) return undefined;
492
+ const labels = listTemplates();
493
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
494
+ const lines = choices.map((c, i) => ` ${i + 1}) ${c} — ${labels.find((t) => t.id === c)?.label}`).join("\n");
495
+ const a = await rl.question(`어떤 용도의 영상인가?\n${lines}\n번호: `);
496
+ rl.close();
497
+ return choices[Number(a) - 1];
498
+ }
499
+
500
+ // 곡을 안 줬을 때 KF_MUSIC_DIR(저작권 걱정 없는 곡 모음)에서 요청 낱말과 이름이 맞는 곡을 고른다.
501
+ // 곡 파일 이름은 대개 영어라 한국어 장르 낱말을 영어로도 찾는다
502
+ const GENRE: Record<string, string[]> = {
503
+ 카와이: ["kawaii", "cute"],
504
+ 귀여운: ["kawaii", "cute"],
505
+ 퓨처베이스: ["future", "bass"],
506
+ 칠: ["chill", "lofi"],
507
+ 로파이: ["lofi", "lo-fi"],
508
+ 몽환: ["dream", "ambient"],
509
+ 신나는: ["upbeat", "energetic", "edm"],
510
+ 잔잔한: ["calm", "ambient", "piano"],
511
+ 애니: ["anime"],
512
+ 시네마틱: ["cinematic", "epic"],
513
+ };
514
+
515
+ // 기본 곡 모음은 레포의 music/(README 에 곡마다 원본·라이선스). KF_MUSIC_DIR 이 있으면 그쪽
516
+ const musicDir = (): string => process.env.KF_MUSIC_DIR || fileURLToPath(new URL("../../../../music/", import.meta.url));
517
+
518
+ function findLibraryMusic(request: string): string | undefined {
519
+ const lib = musicDir();
520
+ if (!existsSync(lib)) return undefined;
521
+ const songs = listFiles(lib).filter((f) => AUDIO.test(f));
522
+ const words = request.toLowerCase().split(/[\s,]+/).filter((w) => w.length > 1);
523
+ const wanted = [...words, ...Object.entries(GENRE).filter(([k]) => request.includes(k)).flatMap(([, v]) => v)];
524
+ const score = (f: string) => wanted.filter((w) => basename(f, extname(f)).toLowerCase().includes(w)).length;
525
+ return songs.sort((a, b) => score(b) - score(a))[0];
526
+ }
527
+
528
+ function listFiles(dir: string): string[] {
529
+ if (!existsSync(dir)) return [];
530
+ if (statSync(dir).isFile()) return [dir];
531
+ 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)]));
532
+ }
533
+
534
+ function stamp(): string {
535
+ const d = new Date();
536
+ const p = (n: number) => String(n).padStart(2, "0");
537
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
538
+ }
539
+
540
+ function writeJson(path: string, v: unknown): void {
541
+ writeFileSync(path, `${JSON.stringify(v, null, 2)}\n`);
542
+ }