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,131 @@
1
+ import { rand, safeTime } from "@kasaframe/core";
2
+ import { typingFrames } from "./hangul.ts";
3
+
4
+ export interface TypeOpts {
5
+ /** 첫 키를 누르는 시각(초) */
6
+ at?: number;
7
+ /** 초당 키 수. 한글은 자모 하나가 키 하나 */
8
+ cps?: number;
9
+ /** 키 간격을 시드 난수로 ±비율만큼 흔든다 — 사람 손 같게. 0 이면 고르게 */
10
+ jitter?: number;
11
+ seed?: number | string;
12
+ /** 띄어쓰기·문장부호 뒤에 쉬는 배수 */
13
+ pause?: { space?: number; comma?: number; stop?: number; newline?: number };
14
+ ime?: boolean;
15
+ /** 커서 깜빡임 주기(초). 0 이면 늘 보인다 */
16
+ blink?: number;
17
+ }
18
+
19
+ export interface TypeState {
20
+ /** 지금 화면에 보일 글 — 언제나 문자열 */
21
+ text: string;
22
+ keys: number;
23
+ total: number;
24
+ done: boolean;
25
+ /** 0..1 */
26
+ p: number;
27
+ /** 커서를 그릴지 */
28
+ caret: boolean;
29
+ /** 마지막 키를 누른 뒤 지난 초 — 키 누름 튐 연출에 */
30
+ sinceKey: number;
31
+ /** 다 치는 데 걸리는 초 */
32
+ duration: number;
33
+ }
34
+
35
+ interface Plan {
36
+ frames: string[];
37
+ times: number[];
38
+ duration: number;
39
+ }
40
+
41
+ const cache = new Map<string, Plan>();
42
+
43
+ function planOf(text: string, o: Required<Omit<TypeOpts, "at" | "blink">>): Plan {
44
+ const key = JSON.stringify([text, o.cps, o.jitter, o.seed, o.pause, o.ime]);
45
+ const hit = cache.get(key);
46
+ if (hit) return hit;
47
+ const frames = typingFrames(text, { ime: o.ime });
48
+ const base = 1 / Math.max(0.1, o.cps);
49
+ const times: number[] = [0];
50
+ let t = 0;
51
+ for (let k = 1; k < frames.length; k++) {
52
+ if (k > 1) {
53
+ const typed = frames[k - 1];
54
+ const last = typed.slice(-1);
55
+ let mul = 1;
56
+ if (last === "\n") mul = o.pause.newline ?? 6;
57
+ else if (/[.!?…。]/.test(last)) mul = o.pause.stop ?? 5;
58
+ else if (/[,、·:;]/.test(last)) mul = o.pause.comma ?? 3;
59
+ else if (last === " ") mul = o.pause.space ?? 1.6;
60
+ const j = o.jitter > 0 ? 1 + (rand(o.seed, k) * 2 - 1) * o.jitter : 1;
61
+ t += base * mul * j;
62
+ }
63
+ times.push(t);
64
+ }
65
+ const plan = { frames, times, duration: t };
66
+ if (cache.size > 256) cache.clear();
67
+ cache.set(key, plan);
68
+ return plan;
69
+ }
70
+
71
+ /** 첫 키 시각 at 부터 한 키씩 친다. times[k] 는 at 기준으로 k 번째 키를 누르는 때 */
72
+ export function typeText(text: string, t: number, opts: TypeOpts = {}): TypeState {
73
+ t = safeTime(t);
74
+ // 글이 아닌 값이 오면 빈 글 — String(NaN)·String(undefined) 가 그대로 화면에 찍히던 사고를 막는다
75
+ const src = typeof text === "string" ? text : typeof text === "number" && Number.isFinite(text) ? String(text) : "";
76
+ const o = {
77
+ cps: opts.cps ?? 16,
78
+ jitter: opts.jitter ?? 0.35,
79
+ seed: opts.seed ?? src,
80
+ pause: opts.pause ?? {},
81
+ ime: opts.ime ?? true,
82
+ };
83
+ const plan = planOf(src, o);
84
+ const local = t - (opts.at ?? 0);
85
+ const total = plan.frames.length - 1;
86
+ let k = 0;
87
+ if (local >= 0) {
88
+ let lo = 1;
89
+ let hi = total;
90
+ k = 0;
91
+ while (lo <= hi) {
92
+ const mid = (lo + hi) >> 1;
93
+ if (plan.times[mid] <= local + 1e-9) {
94
+ k = mid;
95
+ lo = mid + 1;
96
+ } else hi = mid - 1;
97
+ }
98
+ }
99
+ const done = k >= total;
100
+ const sinceKey = k > 0 ? local - plan.times[k] : Math.max(0, local);
101
+ const blink = opts.blink ?? 1.06;
102
+ // 치는 동안은 켜 두고 멈춰 있을 때만 깜빡인다 — 칠 때 깜빡이면 글자가 끊겨 보인다
103
+ const phase = (x: number) => Number.isFinite(x) && ((x % blink) + blink) % blink < blink / 2;
104
+ const caret = blink <= 0 || (k > 0 && !done) || phase(done ? local - plan.duration : local);
105
+ return {
106
+ text: plan.frames[k] ?? "",
107
+ keys: k,
108
+ total,
109
+ done,
110
+ p: total === 0 ? 1 : k / total,
111
+ caret,
112
+ sinceKey,
113
+ duration: plan.duration,
114
+ };
115
+ }
116
+
117
+ /** 여러 줄을 차례로 — 앞 줄이 끝나고 gap 초 뒤에 다음 줄 */
118
+ export function typeLines(lines: string[], t: number, opts: TypeOpts & { gap?: number } = {}): (TypeState & { line: number })[] {
119
+ t = safeTime(t);
120
+ const out: (TypeState & { line: number })[] = [];
121
+ let at = opts.at ?? 0;
122
+ for (let i = 0; i < lines.length; i++) {
123
+ const s = typeText(lines[i], t, { ...opts, at, seed: `${opts.seed ?? ""}${i}` });
124
+ out.push({ ...s, line: i });
125
+ at += s.duration + (opts.gap ?? 0.35);
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /** 다 치는 데 걸리는 초 — 장면 길이를 맞출 때 */
131
+ export const typeDuration = (text: string, opts: TypeOpts = {}): number => typeText(text, Infinity, opts).duration;
@@ -0,0 +1,172 @@
1
+ import { progress, toEase, type Ease, type EaseName, safeTime } from "@kasaframe/core";
2
+ import { typeText, type TypeOpts } from "./type.ts";
3
+
4
+ export interface CursorKey {
5
+ /** 이 자리에 닿는 시각(초) */
6
+ t: number;
7
+ x: number;
8
+ y: number;
9
+ /** 닿은 뒤 누른다 */
10
+ click?: boolean;
11
+ }
12
+
13
+ export interface CursorState {
14
+ x: number;
15
+ y: number;
16
+ /** 누르고 있는 중 — 커서를 살짝 줄인다 */
17
+ pressed: boolean;
18
+ /** 누른 자리에서 퍼지는 물결. 없으면 null */
19
+ ripple: { x: number; y: number; scale: number; opacity: number } | null;
20
+ }
21
+
22
+ /** 키 사이를 감속 곡선으로 옮긴다. 사람 손처럼 살짝 휘게 가운데를 옆으로 민다 */
23
+ export function cursor(t: number, keys: CursorKey[], opts: { ease?: EaseName | Ease; arc?: number; pressDur?: number } = {}): CursorState {
24
+ t = safeTime(t);
25
+ if (keys.length === 0) return { x: 0, y: 0, pressed: false, ripple: null };
26
+ const ks = keys.slice().sort((a, b) => a.t - b.t);
27
+ const ease = toEase(opts.ease, toEase("inOutCubic"));
28
+ let x = ks[0].x;
29
+ let y = ks[0].y;
30
+ for (let i = 1; i < ks.length; i++) {
31
+ const a = ks[i - 1];
32
+ const b = ks[i];
33
+ if (t <= a.t) break;
34
+ // 앞 키에서 누른 시간만큼 기다렸다가 떠난다
35
+ const leave = a.t + (a.click ? (opts.pressDur ?? 0.14) + 0.08 : 0);
36
+ const p = ease(progress(t, leave, Math.max(0.05, b.t - leave)));
37
+ const bend = Math.sin(Math.PI * p) * (opts.arc ?? 0.12);
38
+ const dx = b.x - a.x;
39
+ const dy = b.y - a.y;
40
+ x = a.x + dx * p - dy * bend;
41
+ y = a.y + dy * p + dx * bend;
42
+ }
43
+ const press = opts.pressDur ?? 0.14;
44
+ let pressed = false;
45
+ let ripple: CursorState["ripple"] = null;
46
+ for (const k of ks) {
47
+ if (!k.click) continue;
48
+ const d = t - k.t;
49
+ if (d >= 0 && d < press) pressed = true;
50
+ if (d >= 0 && d < 0.6) {
51
+ const q = toEase("outCubic")(d / 0.6);
52
+ ripple = { x: k.x, y: k.y, scale: 0.2 + q, opacity: (1 - q) * 0.6 };
53
+ }
54
+ }
55
+ return { x, y, pressed, ripple };
56
+ }
57
+
58
+ /** 막대·수치가 자란다. values 마다 stagger 만큼 늦게 */
59
+ export function grow(t: number, values: number[], opts: { at?: number; dur?: number; stagger?: number; ease?: EaseName | Ease; from?: number } = {}): number[] {
60
+ t = safeTime(t);
61
+ const ease = toEase(opts.ease, toEase("outCubic"));
62
+ return values.map((v, i) => {
63
+ const target = Number.isFinite(v) ? v : 0;
64
+ const from = opts.from ?? 0;
65
+ return from + (target - from) * ease(progress(t, (opts.at ?? 0) + i * (opts.stagger ?? 0.08), opts.dur ?? 0.9));
66
+ });
67
+ }
68
+
69
+ /** 선 그리기 진행도 — SVG stroke-dashoffset = 길이 × (1 - p) */
70
+ export const draw = (t: number, opts: { at?: number; dur?: number; ease?: EaseName | Ease } = {}): number =>
71
+ toEase(opts.ease, toEase("inOutCubic"))(progress(t, opts.at ?? 0, opts.dur ?? 1));
72
+
73
+ export interface CodeState {
74
+ lines: string[];
75
+ /** 지금 쓰는 줄(강조용). 다 나왔으면 -1 */
76
+ active: number;
77
+ done: boolean;
78
+ }
79
+
80
+ /** 코드 블록 — 줄 단위로 나타나거나(line) 타자로 친다(type) */
81
+ export function codeReveal(code: string, t: number, opts: { at?: number; mode?: "line" | "type"; lps?: number } & TypeOpts = {}): CodeState {
82
+ t = safeTime(t);
83
+ const src = typeof code === "string" ? code : "";
84
+ const all = src.split("\n");
85
+ if ((opts.mode ?? "line") === "type") {
86
+ const st = typeText(src, t, { cps: 40, jitter: 0.2, pause: { newline: 3 }, ...opts });
87
+ const lines = st.text.split("\n");
88
+ return { lines, active: st.done ? -1 : lines.length - 1, done: st.done };
89
+ }
90
+ const local = t - (opts.at ?? 0);
91
+ const n = local < 0 ? 0 : Math.min(all.length, Math.floor(local * (opts.lps ?? 8)) + 1);
92
+ return { lines: all.slice(0, n), active: n < all.length ? n - 1 : -1, done: n >= all.length };
93
+ }
94
+
95
+ export interface TermStep {
96
+ cmd: string;
97
+ /** 명령 뒤에 나오는 출력 줄 */
98
+ out?: string | string[];
99
+ prompt?: string;
100
+ /** 앞 단계가 끝나고 이 명령을 치기 전에 쉬는 초 */
101
+ wait?: number;
102
+ }
103
+
104
+ export interface TermLine {
105
+ kind: "cmd" | "out";
106
+ prompt: string;
107
+ text: string;
108
+ /** 커서가 이 줄 끝에 있다 */
109
+ caret: boolean;
110
+ }
111
+
112
+ export interface TermState {
113
+ lines: TermLine[];
114
+ done: boolean;
115
+ /** 마지막 키를 누른 뒤 지난 초 */
116
+ sinceKey: number;
117
+ }
118
+
119
+ /**
120
+ * 터미널 창 + 입력. 명령은 타자로, 출력은 outDelay 뒤 줄마다 lineDelay 간격으로 나온다.
121
+ * 아직 안 온 줄은 아예 만들지 않는다 — 빈 줄을 그려 두면 자리가 비어 보인다.
122
+ */
123
+ export function terminal(
124
+ t: number,
125
+ steps: TermStep[],
126
+ opts: { at?: number; prompt?: string; outDelay?: number; lineDelay?: number; maxLines?: number } & TypeOpts = {},
127
+ ): TermState {
128
+ t = safeTime(t);
129
+ const lines: TermLine[] = [];
130
+ let at = opts.at ?? 0;
131
+ let sinceKey = Infinity;
132
+ let done = true;
133
+ for (let i = 0; i < steps.length; i++) {
134
+ const s = steps[i];
135
+ at += s.wait ?? (i === 0 ? 0 : 0.5);
136
+ if (t < at) {
137
+ done = false;
138
+ lines.push({ kind: "cmd", prompt: s.prompt ?? opts.prompt ?? "$", text: "", caret: true });
139
+ break;
140
+ }
141
+ const st = typeText(s.cmd, t, { ...opts, at, seed: `${opts.seed ?? "term"}${i}` });
142
+ lines.push({ kind: "cmd", prompt: s.prompt ?? opts.prompt ?? "$", text: st.text, caret: !st.done });
143
+ sinceKey = Math.min(sinceKey, st.sinceKey);
144
+ if (!st.done) {
145
+ done = false;
146
+ break;
147
+ }
148
+ at += st.duration + (opts.outDelay ?? 0.25);
149
+ const outs = s.out === undefined ? [] : Array.isArray(s.out) ? s.out : s.out.split("\n");
150
+ for (let j = 0; j < outs.length; j++) {
151
+ const show = at + j * (opts.lineDelay ?? 0.06);
152
+ if (t < show) {
153
+ done = false;
154
+ break;
155
+ }
156
+ lines.push({ kind: "out", prompt: "", text: typeof outs[j] === "string" ? outs[j] : "", caret: false });
157
+ }
158
+ if (!done) break;
159
+ at += outs.length * (opts.lineDelay ?? 0.06);
160
+ }
161
+ const last = lines[lines.length - 1];
162
+ if (done && last) lines.push({ kind: "cmd", prompt: opts.prompt ?? "$", text: "", caret: true });
163
+ const max = opts.maxLines ?? 0;
164
+ return { lines: max > 0 ? lines.slice(-max) : lines, done, sinceKey: Number.isFinite(sinceKey) ? sinceKey : 0 };
165
+ }
166
+
167
+ /** 문자열이 화면에 찍히기 전 마지막 거름 — 이상한 값이면 빈 글 */
168
+ export function safeText(v: unknown): string {
169
+ if (typeof v === "string") return v;
170
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
171
+ return "";
172
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@kasaframe/plan",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "dependencies": {
10
+ "@kasaframe/core": "*",
11
+ "@kasaframe/gen": "*",
12
+ "@kasaframe/motion": "*",
13
+ "@kasaframe/themes": "*"
14
+ }
15
+ }
@@ -0,0 +1,254 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { captionMode } from "@kasaframe/motion/captions";
3
+ import { loadTheme } from "@kasaframe/themes";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const repo = fileURLToPath(new URL("../../../", import.meta.url));
8
+
9
+ export interface ProjectScene {
10
+ id: string;
11
+ from: number;
12
+ dur: number;
13
+ file: string;
14
+ role: string;
15
+ idea: string;
16
+ assets?: string[];
17
+ }
18
+
19
+ export interface ProjectFile {
20
+ template: string;
21
+ theme?: string;
22
+ title: string;
23
+ fps: number;
24
+ width: number;
25
+ height: number;
26
+ duration: number;
27
+ music: string | null;
28
+ beats: string | null;
29
+ captions: string;
30
+ safeArea: { top: number; bottom: number; left: number; right: number };
31
+ text: { policy: string; maxOnScreen: number; minPx: number };
32
+ scenes: ProjectScene[];
33
+ }
34
+
35
+ export function readProject(dir: string): ProjectFile {
36
+ return JSON.parse(readFileSync(join(dir, "kf.json"), "utf8"));
37
+ }
38
+
39
+ // 모델이 부품 이름을 지어내지 않게 실제로 내보낸 함수의 첫 줄(인자·반환 타입)만 모은다
40
+ function signatures(pkg: string): string[] {
41
+ const src = join(repo, "packages", pkg, "src");
42
+ if (!existsSync(src)) return [];
43
+ const out: string[] = [];
44
+ for (const f of readdirSync(src).filter((x) => x.endsWith(".ts") && !x.endsWith(".test.ts") && x !== "index.ts" && x !== "captions.ts")) {
45
+ const text = readFileSync(join(src, f), "utf8");
46
+ for (const m of text.matchAll(/^export (?:function|const) (\w+)[^\n]*/gm)) {
47
+ const line = m[0].replace(/\s*\{\s*$/, "").replace(/^export (function|const) /, "");
48
+ if (line.length < 220) out.push(line);
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ const TEXT_POLICY: Record<string, string> = {
55
+ none: "화면에 문장을 쓰지 않는다. 기능 낱말 하나·제품 이름·끝 카드만.",
56
+ "terms-only": "화면 글은 전문 낱말과 숫자만(한 번에 몇 개까지). 말은 자막 트랙으로 따로 간다.",
57
+ captions: "소리 없이도 읽히게 — 들릴 말은 captions.json 에 쓰면 엔진이 화면 아래 자막 판에 얹는다. 장면 글은 핵심 낱말만.",
58
+ lyrics: "가사는 키네틱 타이포로 — 부른 낱말이 부른 시각에 뜨고, 뜻을 그림·움직임으로 받친다. 슬로건 금지는 가사에 걸지 않는다.",
59
+ };
60
+
61
+ function sceneBeats(dir: string, kf: ProjectFile, s: ProjectScene): string {
62
+ if (!kf.beats || !existsSync(join(dir, kf.beats))) return "곡 없음";
63
+ const b = JSON.parse(readFileSync(join(dir, kf.beats), "utf8"));
64
+ const inside = (xs: number[] = []) => xs.filter((t) => t >= s.from && t < s.from + s.dur).map((t) => +(t - s.from).toFixed(2));
65
+ const drops = inside(b.drops);
66
+ const bars = inside(b.bars ?? b.downbeats);
67
+ return `${b.bpm?.toFixed?.(1) ?? "?"} BPM · 장면 안 드롭 ${drops.length ? drops.join(", ") + "초" : "없음"} · 마디 ${bars.slice(0, 12).join(", ")}${bars.length > 12 ? " …" : ""}초`;
68
+ }
69
+
70
+ interface PurposeRules {
71
+ staticSec: { warn: number; error: number };
72
+ beatMove: "every-beat" | "drop-sections";
73
+ maxFreezeSec: number;
74
+ }
75
+
76
+ // 틀(templates/<용도>/template.json motion)이 기본값을 덮는다 — kf check 도 같은 값을 읽는다
77
+ function purposeRules(id: string): PurposeRules {
78
+ const file = join(repo, "templates", id, "template.json");
79
+ const m = existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")).motion ?? {}) : {};
80
+ return { staticSec: m.staticSec ?? { warn: 1.5, error: 3 }, beatMove: m.beatMove ?? "every-beat", maxFreezeSec: m.maxFreezeSec ?? 0.1 };
81
+ }
82
+
83
+ // 용도별 표 — 같은 값을 쓰는 틀끼리 한 줄로
84
+ export function purposeTable(): string[] {
85
+ const dir = join(repo, "templates");
86
+ const ids = existsSync(dir) ? readdirSync(dir).filter((d) => existsSync(join(dir, d, "template.json"))) : [];
87
+ const groups = new Map<string, string[]>();
88
+ for (const id of ids) {
89
+ const r = purposeRules(id);
90
+ const key = `같은 구도 ${r.staticSec.warn}초 경고·${r.staticSec.error}초 오류 · 큰 움직임 ${r.beatMove === "every-beat" ? "박마다" : "후렴·드롭 구간만(벌스는 화면 안 움직임으로 충분)"} · 정지 ${r.maxFreezeSec}초 금지`;
91
+ groups.set(key, [...(groups.get(key) ?? []), id]);
92
+ }
93
+ return [...groups].map(([k, v]) => `- ${v.join("·")}: ${k}`);
94
+ }
95
+
96
+ function dropSections(dir: string, kf: ProjectFile): string {
97
+ if (!kf.beats || !existsSync(join(dir, kf.beats))) return "곡이 없어 드롭 구간도 없다 — 화면 안 움직임으로 간다";
98
+ const b = JSON.parse(readFileSync(join(dir, kf.beats), "utf8"));
99
+ const secs: { t: number; kind: string }[] = b.sections ?? [];
100
+ const spans: { t: number; to: number }[] = [];
101
+ secs.forEach((x, i) => {
102
+ if (x.kind !== "drop") return;
103
+ const to = secs[i + 1]?.t ?? kf.duration;
104
+ const last = spans[spans.length - 1];
105
+ if (last && Math.abs(last.to - x.t) < 0.05) last.to = to;
106
+ else spans.push({ t: x.t, to });
107
+ });
108
+ return spans.length ? spans.map((x) => `${x.t.toFixed(2)}–${x.to.toFixed(2)}초`).join(", ") : "드롭 구간이 잡히지 않았다";
109
+ }
110
+
111
+ // 장면을 채우는 모델(Claude Code 든 OG 의 아무 모델이든)이 읽는 한 장. 사용서(docs/AGENTS.md)가 있으면 뒤에 붙인다
112
+ export function projectBrief(dir: string): string {
113
+ const kf = readProject(dir);
114
+ const style = JSON.parse(readFileSync(join(dir, "style.json"), "utf8"));
115
+ const plan = existsSync(join(dir, "plan.json")) ? JSON.parse(readFileSync(join(dir, "plan.json"), "utf8")) : {};
116
+ const rules = purposeRules(kf.template);
117
+ const tplFile = join(repo, "templates", kf.template, "template.json");
118
+ const tplMotion = existsSync(tplFile) ? (JSON.parse(readFileSync(tplFile, "utf8")).motion ?? {}) : {};
119
+ const colors = Object.keys(style.colors ?? {}).map((k: string) => `--kf-${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`);
120
+ const core = signatures("core");
121
+ const motion = signatures("motion");
122
+ const agents = join(repo, "docs", "AGENTS.md");
123
+ const lines = [
124
+ `# ${kf.title || plan.request || "kasaframe 프로젝트"} — 장면 작성 지시서`,
125
+ "",
126
+ `**이 영상의 규칙 — 용도 ${kf.template}(틀이 기본값을 덮는다, kf check 가 같은 값으로 잡는다)**`,
127
+ `1. 화면은 ${rules.maxFreezeSec}초(60fps 6프레임) 넘게 멈추지 않는다 — 끝 카드·큰 숫자·로고도 예외 없이 화면 안 움직임(숫자 굴러가기·칸 채우기·반짝임·글자 흐름·바탕 흐름)으로 계속 살아 있게.`,
128
+ `2. 같은 구도는 ${rules.staticSec.warn}초 넘기지 않는다(${rules.staticSec.error}초 넘으면 오류) — 새 요소·칸 채우기·숫자 키우기·컷·전환으로 바꾼다.`,
129
+ rules.beatMove === "every-beat"
130
+ ? "3. 미세하게 흔들리는 것만으로 때우지 않는다 — 눈에 보이는 큰 움직임이 박마다 하나 이상."
131
+ : `3. 눈에 보이는 큰 움직임을 박마다 요구하는 건 후렴·드롭 구간(beats.json sections 의 drop — ${dropSections(dir, kf)})뿐이다. 벌스·브레이크는 화면 안 움직임(가사·숫자·바탕 흐름)으로 충분하다 — 절제했다가 드롭에서 터뜨린다.`,
132
+ "4. **카메라는 이유가 있을 때만** 움직인다 — 기본은 정지, 주인공은 안전 영역 가운데. 드러냄에 다가가기(`stage.camera.pushIn`)·비교에 물러나기(`pullOut`)·주인공 따라가기(`follow`)·드롭에 한 번 툭(`impact`), 끝나면 선다. 화면 전체를 사인으로 흔들거나 박마다 자세를 바꾸지 않는다(kf check `camera.wander`·`layout.edge`). 회전·원근 기울기는 0.",
133
+ "5. **섬광은 1초에 3번까지**(kf check frame.flash — 빛 민감 발작). 화면 25% 넘게 번쩍이는 건 두 박·한 마디에 한 번, 빨강 전체 깜빡임·대비 큰 줄무늬 연타·어둠↔흰 화면 컷 이어 붙이기는 쓰지 않는다.",
134
+ "",
135
+ "용도별 기본값(templates/<용도>/template.json motion):",
136
+ ...purposeTable(),
137
+ "",
138
+ "그림 보기: `npx kf look <폴더>` 가 찍는 장면 모음(out/look-sheet.png)을 Read 로 직접 보고 장면별 확인표(out/look.md)대로 고친다. **Read 로 그림이 안 보이면(눈 없는 모델)만** `npx kf look <폴더> --vision-model google/gemini-3.5-flash [--ask \"<볼 것>\"]` — 그림 보는 모델이 장면마다 글로 알려 준다.",
139
+ "",
140
+ `요청: ${plan.request ?? "(없음)"}`,
141
+ `용도 ${kf.template} · ${kf.width}×${kf.height} · ${kf.fps}fps · ${kf.duration}초 · 스타일 ${style.name}(${style.scheme}) · 곡 ${kf.music ?? "없음"} · 자막 ${kf.captions}`,
142
+ `안전 영역(글은 이 안에): 위 ${kf.safeArea.top}px · 아래 ${kf.safeArea.bottom}px · 왼쪽 ${kf.safeArea.left}px · 오른쪽 ${kf.safeArea.right}px`,
143
+ `화면 글: ${TEXT_POLICY[kf.text?.policy] ?? kf.text?.policy} 글자 크기 최소 ${kf.text?.minPx}px.`,
144
+ `움직임 규칙(style.json motion 위에 틀 motion): ${JSON.stringify({ ...style.motion, ...tplMotion })}`,
145
+ ...(style.notes?.length ? ["디자인 메모: " + style.notes.join(" / ")] : []),
146
+ "",
147
+ "## 장면 파일 꼴 — 이 꼴만 쓴다",
148
+ "",
149
+ "```ts",
150
+ 'import { applyStyle, clip, ease, type SceneCtx } from "@kasaframe/core";',
151
+ "export default {",
152
+ " build(root: HTMLElement, ctx: SceneCtx) {",
153
+ " const box = document.createElement(\"div\"); // root.style.display 는 엔진이 켜고 끈다 — 배치는 box 에",
154
+ " box.style.cssText = \"position:absolute;inset:0;overflow:hidden;background:var(--kf-bg)\";",
155
+ " root.append(box);",
156
+ " // DOM 은 여기서 한 번만 짓는다",
157
+ " return (t: number) => { /* t = 장면 안 초(0..ctx.dur). 같은 t 면 같은 화면 */ };",
158
+ " },",
159
+ "};",
160
+ "```",
161
+ "",
162
+ "- 시각 함수 밖에서 상태를 쌓지 않는다(앞 프레임 값을 기억하지 않는다). 탭 여러 개가 아무 시각이나 찍는다.",
163
+ "- `Math.random`·`Date.now`·`performance.now`·`setTimeout` 금지 — 난수는 `rand(seed, i)`, 시간은 `t` 만.",
164
+ "- 글자에 `undefined`·`NaN`·`null` 이 찍히지 않게 값은 미리 문자열로 만든다. 이모지 금지(아이콘은 인라인 SVG).",
165
+ "- 장면이 1초 넘게 멈추지 않게 한다(아주 느린 다가가기라도). 빈 화면으로 시작·끝내지 않는다.",
166
+ "- **화면을 채운다**(kf check layout.fill): 주 요소가 안전 영역을 크게 차지한다 — 세로 화면은 폭의 70% 넘게, 글은 큰 글자 단계(hero·title)로. 가운데 작은 그림 하나에 나머지가 흰 여백이면 실패다.",
167
+ "- 구도 판정(kf check layout.static)은 칸 평균 밝기와 화면 안 내용(글·칸의 센 가장자리)으로 본다 — 숫자가 구르고 칸이 차면 바뀐 것이고, 부드럽게 흐르는 바탕만으로는 안 바뀐다. 카메라 샷으로 메우지 않는다.",
168
+ "- `../lib/stage.ts` 의 `makeStage(root, ctx)`(늘 흐르는 빛 방울 바탕·컷마다 모양을 바꾸는 바탕 판·정지 카메라 `stage.camera`)·`makeCamera(layer, ctx, { frame })`(자기 카메라 층에)·`bigWord`·`fitPx`·`cutTimes`·`lastT` 를 써도 된다 — 위 규칙을 처음부터 넘게 짜 둔 도구다. 카메라가 요소를 겨누면 `stage.tick(t)` 은 내용을 칠한 뒤 맨 끝에.",
169
+ "- 박에 붙이는 움직임은 `ctx.beats`(장면 시작 0 기준: `ctx.beats.drops`, `ctx.beats.nearestBeat(t)`, `ctx.beats.pulse(t, {...})`). 시각을 BPM 으로 곱해 만들지 않는다.",
170
+ "- 자산은 `ctx.asset(\"gen/<id>.png\")` 처럼 assets/ 기준 경로로. 없는 파일을 가리키지 않는다.",
171
+ "- 색·글꼴·크기는 CSS 변수로: " + [...colors, "--kf-font-sans", "--kf-font-display", "--kf-font-mono", "--kf-size-hero", "--kf-size-title", "--kf-size-body", "--kf-size-caption", "--kf-radius-md", "--kf-shadow-soft"].join(" "),
172
+ "",
173
+ "## 쓸 수 있는 함수",
174
+ "",
175
+ "`@kasaframe/core`:",
176
+ ...core.map((s) => `- ${s}`),
177
+ "",
178
+ ...(motion.length ? ["`@kasaframe/motion`(움직임 부품 — 숫자만 넣으면 Style 이나 상태를 돌려준다. applyStyle 로 붙인다):", ...motion.map((s) => `- ${s}`), ""] : []),
179
+ "## 장면",
180
+ "",
181
+ ...kf.scenes.flatMap((s) => [
182
+ `### ${s.id} — ${s.file} (${s.from.toFixed(2)}–${(s.from + s.dur).toFixed(2)}초, ${s.dur.toFixed(2)}초, 역할 ${s.role})`,
183
+ `- 할 일: ${s.idea}`,
184
+ ...(s.role === "seam" ? ["- 이 장면 파일은 고치지 않는다 — 3D 전환(transition3d)이 앞뒤 화면을 이어 준다"] : []),
185
+ `- 박: ${sceneBeats(dir, kf, s)}`,
186
+ ...(s.assets?.length ? [`- 자산: ${s.assets.map((a) => a.replace(/^gen:(img|vid):(.+)$/, (_, k, id) => `assets/gen/${id}.${k === "img" ? "png" : "mp4"}`)).join(", ")}`] : []),
187
+ "",
188
+ ]),
189
+ ...genSection(dir, kf, plan),
190
+ ...themeSection(kf.theme, plan),
191
+ ...(existsSync(join(dir, "lyrics.json"))
192
+ ? ["## 가사(lyrics.json — 낱말 시각까지)", "", '`import.meta.glob("../lyrics.json", { eager: true, import: "default" })` 로 읽는다(꼴 `{ lines: [{ text, start, end, words?: [{ w, start, end }] }] }`, 시각은 영상 전체 기준 초 — 장면 안 시각은 `start - ctx.from`). 낱말이 없으면 줄을 글자 수 비율로 나눈다.', ""]
193
+ : []),
194
+ ...(existsSync(join(dir, "assets", "in"))
195
+ ? ["## 받은 자산(사람이 준 것 — 쓸 수 있으면 쓴다)", "", ...readdirSync(join(dir, "assets", "in")).map((f) => `- \`ctx.asset("in/${f}")\``), ""]
196
+ : []),
197
+ ...(captionMode(kf.captions) !== "none"
198
+ ? [
199
+ "## 자막",
200
+ "",
201
+ '들릴 말은 프로젝트 `captions.json` 에 `[{"scene": "장면 id", "text": "짧은 마디를 쉼표로"}]` 로 쓴다 — `kf make --finish` 가 out/captions.ko.srt·vtt 로 만든다. 장면 파일에는 문장을 쓰지 않는다.',
202
+ captionMode(kf.captions) === "on"
203
+ ? `이 영상은 자막을 화면에도 얹는다 — 엔진이 장면 위에 판을 깐다(안전 영역 아래쪽 가운데, 두 줄까지). 그 자리(아래 가장자리에서 ${kf.safeArea.bottom}px 위 언저리)에 장면의 큰 글을 두지 않는다.`
204
+ : "화면에는 얹지 않는다(kf render --captions on 이면 그 판만 얹는다).",
205
+ "",
206
+ ]
207
+ : []),
208
+ ...(existsSync(agents) ? ["## 사용서(docs/AGENTS.md)", "", readFileSync(agents, "utf8")] : []),
209
+ ];
210
+ return lines.join("\n");
211
+ }
212
+
213
+ // 테마를 골랐으면 그 비유·금지·효과 쓰는 꼴을 그대로 싣는다(SPEC 9). auto 인데 맞는 테마가 없으면 강한 모델이 새 테마를 draft 로 써 넣을 수 있다
214
+ function themeSection(id: string | undefined, plan: { themeCandidates?: { id: string; why: string }[]; request?: string }): string[] {
215
+ if (id) {
216
+ const t = loadTheme(id);
217
+ const pick = plan.themeCandidates?.find((c) => c.id === id);
218
+ return [
219
+ `## 테마 ${t.name}(${t.id}) — ${t.metaphor}`,
220
+ "",
221
+ ...(pick ? [`고른 이유: ${pick.why}`, ""] : []),
222
+ `효과는 복사하지 말고 가져다 쓴다: \`import { fx } from "@kasaframe/themes/fx"; const { ${t.effects.map((e) => e.name).join(", ")} } = fx("${t.id}");\``,
223
+ "",
224
+ ...t.effects.map((e) => `- \`${e.name}\`(${e.kind}) — ${e.does} · \`${e.use}\``),
225
+ "",
226
+ ...(plan.themeCandidates && plan.themeCandidates.length > 1 ? [`다른 후보: ${plan.themeCandidates.slice(1).map((c) => c.id).join(" · ")} — 첫 장면을 각 테마로 찍은 그림은 out/theme-<id>.png`, ""] : []),
227
+ t.guide,
228
+ "",
229
+ ];
230
+ }
231
+ if (plan.themeCandidates && !plan.themeCandidates.length) {
232
+ return [
233
+ "## 테마 — 맞는 approved 테마가 없다",
234
+ "",
235
+ "프리셋 style 로 간다. 이 내용에 맞는 비유가 뚜렷하면 SPEC 계약 9 대로 `themes/<새 id>/` 에 테마 팩(theme.json status draft · THEME.md · style.json · effects.ts 5–10개)을 써 넣어도 된다 — 사람이 견본을 보고 OK 하기 전까지는 draft 라 이 프로젝트에는 쓰지 않는다.",
236
+ "",
237
+ ];
238
+ }
239
+ return [];
240
+ }
241
+
242
+ // 그림 생성은 돈이 들고, 막연한 자동 프롬프트는 엉뚱한 그림을 냈다 — 아직 없는 그림은 목록으로만 내고 세션이 프롬프트를 써서 만든다
243
+ function genSection(dir: string, kf: ProjectFile, plan: { gen?: { id: string; kind: string; scene?: string; prompt: string; size?: string }[] }): string[] {
244
+ const todo = (plan.gen ?? []).filter((g) => g.kind === "img" && !existsSync(join(dir, "assets", "gen", `${g.id}.png`)));
245
+ if (!todo.length) return [];
246
+ return [
247
+ "## 만들 그림 — 아직 안 만들었다(묻지 않고 돈을 쓰지 않는다)",
248
+ "",
249
+ "받은 자산·실제 화면 오리기·도형으로 되면 만들지 않는다. 만들 땐 요청 내용으로 **구체적인** 프롬프트를 써서(무엇이·어떤 구도로·어떤 색·글자 없음):",
250
+ "",
251
+ ...todo.map((g) => `- \`${g.id}\`(장면 ${g.scene ?? "-"}) — 틀이 제안한 뜻: ${g.prompt}\n \`npx kf gen img "<구체적 프롬프트>" --id ${g.id} --size ${g.size ?? `${kf.width}x${kf.height}`} --project ${dir}\` → \`ctx.asset("gen/${g.id}.png")\``),
252
+ "",
253
+ ];
254
+ }
@@ -0,0 +1,75 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { captionMetrics, captionMode, toCues, type CaptionLine, type CaptionMode, type Cue } from "@kasaframe/motion/captions";
4
+ import { readProject } from "./brief.ts";
5
+
6
+ // 자막 트랙 — srt(유튜브)·vtt(사이트). OG 쇼츠 scripts/srt3d.py 의 관례를 따른다.
7
+ // 마디 나누기는 화면 자막 판(@kasaframe/motion/captions)과 같은 toCues 라 트랙과 화면이 한 마디도 어긋나지 않는다.
8
+
9
+ export { toCues };
10
+ export type { CaptionLine, Cue };
11
+
12
+ function ts(s: number, sep: string): string {
13
+ const ms = Math.round(s * 1000);
14
+ const p = (n: number, w = 2) => String(n).padStart(w, "0");
15
+ return `${p(Math.floor(ms / 3600000))}:${p(Math.floor(ms / 60000) % 60)}:${p(Math.floor(ms / 1000) % 60)}${sep}${p(ms % 1000, 3)}`;
16
+ }
17
+
18
+ export function srt(cues: Cue[]): string {
19
+ return cues.map((c, i) => `${i + 1}\n${ts(c.from, ",")} --> ${ts(c.to, ",")}\n${c.text}\n`).join("\n");
20
+ }
21
+
22
+ export function vtt(cues: Cue[]): string {
23
+ return `WEBVTT\n\n${cues.map((c) => `${ts(c.from, ".")} --> ${ts(c.to, ".")}\n${c.text}\n`).join("\n")}`;
24
+ }
25
+
26
+ // 뮤비 가사. 시각이 붙은 lyrics.json(natsu_mv align_lyrics.py 꼴: {lines:[{text,start,end}]})이 있으면 그대로,
27
+ // 시각 없는 lyrics.txt 뿐이면 인트로·아웃트로를 뺀 구간에 줄 길이 비율로 대충 편다 — 정확한 시각은 보컬 분리(demucs) + 강제 정렬로(큰 모델이라 GPU 있는 기계에서)
28
+ function lyricLines(dir: string, scenes: { id: string; role: string; from: number; dur: number }[]): CaptionLine[] | null {
29
+ const find = (f: string) => [join(dir, f), join(dir, "assets", "in", f)].find((p) => existsSync(p));
30
+ const timed = find("lyrics.json");
31
+ if (timed) {
32
+ const j = JSON.parse(readFileSync(timed, "utf8")) as { lines?: { text: string; start: number; end: number; words?: { w: string; start: number; end: number }[] }[] };
33
+ return (j.lines ?? []).map((l) => ({ from: l.start, to: l.end, text: l.text, words: l.words?.map((w) => ({ text: w.w, from: w.start, to: w.end })) }));
34
+ }
35
+ const plain = find("lyrics.txt");
36
+ if (!plain) return null;
37
+ const lines = readFileSync(plain, "utf8").split("\n").map((l) => l.trim()).filter(Boolean);
38
+ const sung = scenes.filter((s) => !/intro|outro/.test(s.role));
39
+ const total = sung.reduce((a, s) => a + s.dur, 0);
40
+ const chars = lines.reduce((a, l) => a + l.length, 0);
41
+ const out: CaptionLine[] = [];
42
+ let k = 0;
43
+ let acc = 0;
44
+ for (const l of lines) {
45
+ let at = (acc / chars) * total;
46
+ while (k < sung.length - 1 && at > sung[k].dur) {
47
+ at -= sung[k].dur;
48
+ acc -= (sung[k].dur / total) * chars;
49
+ k++;
50
+ }
51
+ const from = sung[k].from + at;
52
+ out.push({ from, to: from + (l.length / chars) * total, text: l });
53
+ acc += l.length;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ // 프로젝트의 captions.json(또는 가사) → out/captions.ko.srt·vtt. 자막 none 이거나 들릴 말이 없으면 빈 배열. mode 는 kf.json 을 그 판만 덮는다
59
+ export function writeCaptions(dir: string, mode?: CaptionMode): string[] {
60
+ const kf = readProject(dir);
61
+ // captions 칸이 없는 옛 프로젝트는 들릴 말이 있으면 트랙만 낸다
62
+ if ((mode ?? (kf.captions === undefined ? "off" : captionMode(kf.captions))) === "none") return [];
63
+ const scenes = kf.scenes;
64
+ const file = join(dir, "captions.json");
65
+ const lines = existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as CaptionLine[]) : lyricLines(dir, scenes);
66
+ if (!lines) return [];
67
+ const maxEm = kf.width && kf.height ? captionMetrics({ width: kf.width, height: kf.height, safe: kf.safeArea, minPx: kf.text?.minPx }).maxEm : undefined;
68
+ const cues = toCues(lines, scenes, { maxEm, duration: kf.duration });
69
+ if (!cues.length) return [];
70
+ mkdirSync(join(dir, "out"), { recursive: true });
71
+ const out = [join(dir, "out", "captions.ko.srt"), join(dir, "out", "captions.ko.vtt")];
72
+ writeFileSync(out[0], srt(cues));
73
+ writeFileSync(out[1], vtt(cues));
74
+ return out;
75
+ }