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,56 @@
1
+ /**
2
+ * 시드 고정 난수. 상태가 없어서 (seed, i) 만 같으면 어느 탭·어느 순서로 불러도 같은 값이다.
3
+ * Math.random 은 쓰지 않는다 — 탭 나눠 찍기에서 탭마다 다른 그림이 나온다.
4
+ */
5
+ function hash32(n: number): number {
6
+ let h = n | 0;
7
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
8
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
9
+ return (h ^ (h >>> 16)) >>> 0;
10
+ }
11
+
12
+ function seedOf(seed: number | string): number {
13
+ if (typeof seed === "number") return Number.isFinite(seed) ? Math.floor(seed * 1000003) | 0 : 0;
14
+ let h = 0x811c9dc5;
15
+ for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 0x01000193);
16
+ return h | 0;
17
+ }
18
+
19
+ /** [0, 1) */
20
+ export function rand(seed: number | string, i = 0): number {
21
+ const s = seedOf(seed);
22
+ return hash32(hash32(s) ^ hash32(Math.imul((i | 0) + 0x9e3779b9, 0x27d4eb2d))) / 4294967296;
23
+ }
24
+
25
+ export const randRange = (seed: number | string, i: number, lo: number, hi: number): number => lo + (hi - lo) * rand(seed, i);
26
+
27
+ export const randInt = (seed: number | string, i: number, lo: number, hi: number): number =>
28
+ Math.min(hi, lo + Math.floor((hi - lo + 1) * rand(seed, i)));
29
+
30
+ export const pick = <T>(seed: number | string, i: number, items: readonly T[]): T => items[randInt(seed, i, 0, items.length - 1)];
31
+
32
+ /** 평균 0 표준편차 1 근사(박스-뮐러) */
33
+ export function randNormal(seed: number | string, i: number): number {
34
+ const u = Math.max(rand(seed, i * 2), 1e-12);
35
+ const v = rand(seed, i * 2 + 1);
36
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
37
+ }
38
+
39
+ export function shuffle<T>(seed: number | string, items: readonly T[]): T[] {
40
+ const a = items.slice();
41
+ for (let i = a.length - 1; i > 0; i--) {
42
+ const j = randInt(seed, i, 0, i);
43
+ [a[i], a[j]] = [a[j], a[i]];
44
+ }
45
+ return a;
46
+ }
47
+
48
+ /** 부드럽게 흔들리는 1차원 잡음 [-1, 1]. 정수 격자의 시드 난수를 코사인으로 잇는다 */
49
+ export function noise1(seed: number | string, x: number): number {
50
+ const i = Math.floor(x);
51
+ const f = x - i;
52
+ const a = rand(seed, i) * 2 - 1;
53
+ const b = rand(seed, i + 1) * 2 - 1;
54
+ const s = (1 - Math.cos(f * Math.PI)) / 2;
55
+ return a + (b - a) * s;
56
+ }
@@ -0,0 +1,52 @@
1
+ export interface SpringOpts {
2
+ from?: number;
3
+ to?: number;
4
+ stiffness?: number;
5
+ damping?: number;
6
+ mass?: number;
7
+ /** t=0 에서 값이 변하는 속도(단위/초) */
8
+ velocity?: number;
9
+ }
10
+
11
+ const DEFAULTS = { from: 0, to: 1, stiffness: 170, damping: 26, mass: 1, velocity: 0 };
12
+
13
+ /**
14
+ * 감쇠 조화 진동의 닫힌 해. 앞 프레임 상태를 이어 받지 않으므로 아무 t 로 건너뛰어도 같은 값이 나온다 —
15
+ * seek 모드와 탭 나눠 찍기가 이것에 기댄다.
16
+ */
17
+ export function spring(t: number, opts: SpringOpts = {}): number {
18
+ const { from, to, stiffness, damping, mass, velocity } = { ...DEFAULTS, ...opts };
19
+ if (!(t > 0)) return from;
20
+ if (stiffness <= 0 || mass <= 0) return to;
21
+ const x0 = from - to;
22
+ const v0 = velocity;
23
+ const w0 = Math.sqrt(stiffness / mass);
24
+ const zeta = damping / (2 * Math.sqrt(stiffness * mass));
25
+ let x: number;
26
+ if (zeta < 1) {
27
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
28
+ x = Math.exp(-zeta * w0 * t) * (x0 * Math.cos(wd * t) + ((v0 + zeta * w0 * x0) / wd) * Math.sin(wd * t));
29
+ } else if (zeta === 1) {
30
+ x = Math.exp(-w0 * t) * (x0 + (v0 + w0 * x0) * t);
31
+ } else {
32
+ const s = Math.sqrt(zeta * zeta - 1);
33
+ const r1 = -w0 * (zeta - s);
34
+ const r2 = -w0 * (zeta + s);
35
+ const c2 = (v0 - r1 * x0) / (r2 - r1);
36
+ const c1 = x0 - c2;
37
+ x = c1 * Math.exp(r1 * t) + c2 * Math.exp(r2 * t);
38
+ }
39
+ return to + x;
40
+ }
41
+
42
+ /** 값이 목표에서 eps 안으로 들어와 다시 나가지 않는 첫 시각(초). 1/240 초 간격으로 훑는다 */
43
+ export function springDuration(opts: SpringOpts = {}, eps = 0.001, max = 10): number {
44
+ const to = opts.to ?? DEFAULTS.to;
45
+ const span = Math.abs((opts.from ?? DEFAULTS.from) - to) || 1;
46
+ const dt = 1 / 240;
47
+ let settled = 0;
48
+ for (let t = 0; t <= max; t += dt) {
49
+ if (Math.abs(spring(t, opts) - to) / span > eps) settled = t + dt;
50
+ }
51
+ return Math.min(settled, max);
52
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 움직임 원형이 돌려주는 값. CSS 도 three 도 아닌 일반 객체라 어댑터가 제 값으로 바꾼다.
3
+ * 없는 칸은 「바꾸지 않음」이다 — 0 이나 기본값을 채워 넣지 않는다.
4
+ */
5
+ export interface Style {
6
+ opacity?: number;
7
+ /** px */
8
+ x?: number;
9
+ y?: number;
10
+ z?: number;
11
+ scale?: number;
12
+ scaleX?: number;
13
+ scaleY?: number;
14
+ /** 도 */
15
+ rotate?: number;
16
+ rotateX?: number;
17
+ rotateY?: number;
18
+ skewX?: number;
19
+ /** px */
20
+ blur?: number;
21
+ brightness?: number;
22
+ /** 가려 보이는 비율 0..1 — 위·오른쪽·아래·왼쪽에서 얼마나 깎는지 */
23
+ clip?: { top?: number; right?: number; bottom?: number; left?: number };
24
+ }
25
+
26
+ const num = (v: number | undefined, fallback: number): number => (v !== undefined && Number.isFinite(v) ? v : fallback);
27
+
28
+ /** 겹쳐 쓰기: 투명도·배율은 곱하고, 이동·회전·흐림은 더하고, 가림은 큰 쪽을 쓴다 */
29
+ export function combine(...styles: (Style | undefined | null | false)[]): Style {
30
+ const out: Style = {};
31
+ for (const s of styles) {
32
+ if (!s) continue;
33
+ if (s.opacity !== undefined) out.opacity = num(out.opacity, 1) * num(s.opacity, 1);
34
+ for (const k of ["x", "y", "z", "rotate", "rotateX", "rotateY", "skewX", "blur"] as const) {
35
+ if (s[k] !== undefined) out[k] = num(out[k], 0) + num(s[k], 0);
36
+ }
37
+ for (const k of ["scale", "scaleX", "scaleY", "brightness"] as const) {
38
+ if (s[k] !== undefined) out[k] = num(out[k], 1) * num(s[k], 1);
39
+ }
40
+ if (s.clip) {
41
+ const c = (out.clip ??= {});
42
+ for (const side of ["top", "right", "bottom", "left"] as const) {
43
+ const v = s.clip[side];
44
+ if (v !== undefined) c[side] = Math.max(num(c[side], 0), num(v, 0));
45
+ }
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ const r = (v: number, d = 3): number => {
52
+ const m = 10 ** d;
53
+ return Math.round(v * m) / m;
54
+ };
55
+
56
+ export interface CssStyle {
57
+ opacity?: number;
58
+ transform?: string;
59
+ filter?: string;
60
+ clipPath?: string;
61
+ }
62
+
63
+ /** React style 객체로 바로 넘길 수 있는 꼴. 유한하지 않은 값은 버린다 — NaN 이 CSS 로 새면 요소가 통째로 사라진다 */
64
+ export function toCss(s: Style): CssStyle {
65
+ const out: CssStyle = {};
66
+ if (s.opacity !== undefined && Number.isFinite(s.opacity)) out.opacity = r(Math.min(1, Math.max(0, s.opacity)));
67
+ const tf: string[] = [];
68
+ const x = num(s.x, 0);
69
+ const y = num(s.y, 0);
70
+ const z = num(s.z, 0);
71
+ if (x || y || z) tf.push(z ? `translate3d(${r(x)}px, ${r(y)}px, ${r(z)}px)` : `translate(${r(x)}px, ${r(y)}px)`);
72
+ if (s.rotate !== undefined && Number.isFinite(s.rotate) && s.rotate !== 0) tf.push(`rotate(${r(s.rotate)}deg)`);
73
+ if (s.rotateX !== undefined && Number.isFinite(s.rotateX) && s.rotateX !== 0) tf.push(`rotateX(${r(s.rotateX)}deg)`);
74
+ if (s.rotateY !== undefined && Number.isFinite(s.rotateY) && s.rotateY !== 0) tf.push(`rotateY(${r(s.rotateY)}deg)`);
75
+ if (s.skewX !== undefined && Number.isFinite(s.skewX) && s.skewX !== 0) tf.push(`skewX(${r(s.skewX)}deg)`);
76
+ const sx = num(s.scale, 1) * num(s.scaleX, 1);
77
+ const sy = num(s.scale, 1) * num(s.scaleY, 1);
78
+ if (sx !== 1 || sy !== 1) tf.push(sx === sy ? `scale(${r(sx, 4)})` : `scale(${r(sx, 4)}, ${r(sy, 4)})`);
79
+ if (tf.length) out.transform = tf.join(" ");
80
+ const fl: string[] = [];
81
+ if (s.blur !== undefined && Number.isFinite(s.blur) && s.blur > 0.01) fl.push(`blur(${r(s.blur, 2)}px)`);
82
+ if (s.brightness !== undefined && Number.isFinite(s.brightness) && s.brightness !== 1) fl.push(`brightness(${r(s.brightness)})`);
83
+ if (fl.length) out.filter = fl.join(" ");
84
+ if (s.clip) {
85
+ const pc = (v: number | undefined) => `${r(Math.min(1, Math.max(0, num(v, 0))) * 100, 2)}%`;
86
+ out.clipPath = `inset(${pc(s.clip.top)} ${pc(s.clip.right)} ${pc(s.clip.bottom)} ${pc(s.clip.left)})`;
87
+ }
88
+ return out;
89
+ }
90
+
91
+ const kebab = (k: string): string => k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
92
+
93
+ /** 바닐라 DOM 용. 이전 프레임에 있던 칸이 이번에 빠져도 남지 않게 네 칸을 늘 다시 쓴다 */
94
+ export function applyStyle(el: { style: CSSStyleDeclaration }, s: Style): void {
95
+ const css = toCss(s);
96
+ for (const k of ["opacity", "transform", "filter", "clipPath"] as const) {
97
+ const v = css[k];
98
+ el.style.setProperty(kebab(k), v === undefined ? "" : String(v));
99
+ }
100
+ }
@@ -0,0 +1,88 @@
1
+ export interface Clip {
2
+ /** 구간 시작에서 잰 시각(초). 구간 밖이어도 그대로 — 앞이면 음수 */
3
+ local: number;
4
+ /** 0..1 로 자른 진행도 */
5
+ p: number;
6
+ active: boolean;
7
+ }
8
+
9
+ export const clamp = (v: number, lo = 0, hi = 1): number => (v < lo ? lo : v > hi ? hi : v === v ? v : lo);
10
+
11
+ /** NaN 시각은 0 으로 — 움직임 입구에서 한 번 걸러 화면 값에 NaN 이 번지지 않게 */
12
+ export const safeTime = (t: number): number => (typeof t === "number" && t === t ? t : 0);
13
+
14
+ export const lerp = (a: number, b: number, p: number): number => a + (b - a) * p;
15
+
16
+ /** 0 길이 구간에서 0 으로 나누지 않게 — dur 0 은 시작하는 순간 끝난 것으로 본다 */
17
+ export const progress = (t: number, from: number, dur: number): number =>
18
+ dur > 0 ? clamp((t - from) / dur) : t >= from ? 1 : 0;
19
+
20
+ export function clip(t: number, from: number, dur: number): Clip {
21
+ const local = t - from;
22
+ return { local, p: progress(t, from, dur), active: local >= 0 && local < dur };
23
+ }
24
+
25
+ export interface SeqItem {
26
+ id?: string;
27
+ dur: number;
28
+ /** 앞 항목과 겹치는 길이(초). 전환에 쓴다 */
29
+ overlap?: number;
30
+ }
31
+
32
+ export interface SeqSlot {
33
+ id: string;
34
+ index: number;
35
+ from: number;
36
+ to: number;
37
+ dur: number;
38
+ }
39
+
40
+ export interface Seq {
41
+ items: SeqSlot[];
42
+ duration: number;
43
+ /** t 에서 그려야 할 항목들 — 겹치는 구간이면 둘 */
44
+ at(t: number): (SeqSlot & Clip)[];
45
+ }
46
+
47
+ export function seq(items: SeqItem[], start = 0): Seq {
48
+ let cursor = start;
49
+ const slots: SeqSlot[] = items.map((it, index) => {
50
+ const from = index === 0 ? cursor : cursor - (it.overlap ?? 0);
51
+ cursor = from + it.dur;
52
+ return { id: it.id ?? String(index), index, from, to: from + it.dur, dur: it.dur };
53
+ });
54
+ return {
55
+ items: slots,
56
+ duration: cursor - start,
57
+ at: (t) => slots.filter((s) => t >= s.from && t < s.to).map((s) => ({ ...s, ...clip(t, s.from, s.dur) })),
58
+ };
59
+ }
60
+
61
+ type Easing = (p: number) => number;
62
+
63
+ /**
64
+ * 값 구간 사상. 입력은 오름차순이어야 하고, 밖은 기본으로 끝값에 붙인다(clamp).
65
+ * NaN 이 들어오면 첫 출력값을 돌려 화면에 NaN 이 새지 않게 한다.
66
+ */
67
+ export function interpolate(
68
+ v: number,
69
+ input: readonly number[],
70
+ output: readonly number[],
71
+ opts: { ease?: Easing; extrapolate?: "clamp" | "extend" } = {},
72
+ ): number {
73
+ if (input.length !== output.length || input.length < 2) throw new Error("interpolate: input·output 길이가 같고 2 이상이어야 한다");
74
+ if (!Number.isFinite(v)) return output[0];
75
+ const ext = opts.extrapolate ?? "clamp";
76
+ let i = 1;
77
+ while (i < input.length - 1 && v > input[i]) i++;
78
+ const a = input[i - 1];
79
+ const b = input[i];
80
+ let p = b === a ? (v >= b ? 1 : 0) : (v - a) / (b - a);
81
+ if (ext === "clamp") p = clamp(p);
82
+ if (opts.ease && p >= 0 && p <= 1) p = opts.ease(p);
83
+ return lerp(output[i - 1], output[i], p);
84
+ }
85
+
86
+ /** 초 ↔ 프레임. 프레임 경계 오차로 같은 프레임을 두 번 찍지 않게 반올림한다 */
87
+ export const toFrame = (t: number, fps: number): number => Math.round(t * fps);
88
+ export const toTime = (frame: number, fps: number): number => frame / fps;
@@ -0,0 +1,55 @@
1
+ # 생성 모델 — 비용·속도·라이선스·지역 (2026-10-08 조사)
2
+
3
+ `kf gen models` 가 같은 표를 키 유무와 함께 찍는다. 값이 바뀌면 `src/video/*.ts`·`src/image/og.ts` 의 단가와 이 표를 같이 고친다.
4
+
5
+ ## 그림
6
+
7
+ | 공급자 id | 모델 | 단가 | 1920x1080 한 장 | 걸린 시간(실측) | 라이선스 · 지역 |
8
+ |---|---|---|---|---|---|
9
+ | `og-gpt-image-2.5-sunburst` (기본) | OG `openai/gpt-image-2.5-sunburst` | 1M 토큰당 글 입력 $5 · 그림 입력 $8 · 그림 출력 $30 | medium $0.011(실측) · high 약 $0.08 | 생성 20–28초 · 편집 20초 | OpenAI 약관 — 출력물 권리 사용자, 상업 가능 · OG live 전 세계 |
10
+ | `og-gpt-image-2.5-flare` | OG `openai/gpt-image-2.5-flare` | 같음 | 같음 | 더 빠르다고 함(미측정) | 같음 |
11
+ | `og-gpt-image-2` | OG `openai/gpt-image-2` | 같음 | 같은 품질값에서 토큰 더 씀 | — | 같음 |
12
+
13
+ - 투명 배경: 2.5 는 `background: transparent` 를 직접 받는다(실측 알파 61%). 안 되면 한 장 더 써서 초록 바탕 + ffmpeg 키잉으로 간다(`notes` 에 `alpha-failed`).
14
+ - 참조 편집은 `/v1/images/edits` multipart. 참조는 흰 바탕 512px JPEG 로 보낸다(OG 업로드 413 한도). 참조가 먹었는지는 `usage.input_tokens_details.image_tokens > 0` 으로만 안다 — 0 이면 `notes` 에 `refs-ignored`.
15
+ - 크기: 변이 16의 배수여야 해서 1080 은 1088 로 만든 뒤 잘라 맞춘다(`notes` 에 `fit`).
16
+
17
+ ## 영상
18
+
19
+ OG `/v1/models` 에는 영상 모델이 없다(2026-10-08 — 출력 모달리티는 text·image·embedding·decision 뿐). 영상은 공급자에 직접 붙는다.
20
+
21
+ | 공급자 id | 단가 | 5초 720p | 길이 | 첫+끝 프레임 | 속도 | 라이선스 | 지역 | 키 |
22
+ |---|---|---|---|---|---|---|---|---|
23
+ | `h3-pod` (설정돼 있으면 기본) | GPU 자체 호스팅 — API 요금 없음, GPU 한 장이라 줄 선다 | $0 | 4–15초(서버 검증, 짧은 이음매는 4초로 만들어 맞춰 감는다) | 됨(fl2va 키프레임 0·-1) | L40S 1344x768·5초: 터보 LoRA 9걸음 170초, LoRA 없이 50걸음 940초(파드 운영자 실측) | **공개 가중치 — 지역 제한 확인 필요**: MiniMax H3 커뮤니티 라이선스가 한국을 제외 지역으로 두고 산출물에도 걸린다. 밖에 낼 결과물에는 쓰기 전에 확인 | 사내 | `KF_H3_URL`(SGLang 주소, 터널이면 `http://127.0.0.1:<포트>`) |
24
+ | `fal-wan-2.2-turbo` (기본) | 클립당 720p $0.10 · 580p $0.075 · 480p $0.05 | **$0.10** | 5초 고정(81프레임@16fps) | 됨 | 빠름(수십 초) | 가중치 Apache-2.0 — 출력 상업 가능 | 제한 없음 · fal(미국) API 한국 결제 가능 | `FAL_KEY` |
25
+ | `fal-wan-2.2` | 초당 720p $0.08 · 580p $0.06 · 480p $0.04 (16fps 기준 초) | $0.41 | 1–10초 | 됨 | 1분 안팎 | Apache-2.0 | 제한 없음 | `FAL_KEY` |
26
+ | `fal-kling-3-std` | 초당 $0.084(소리 끔) · $0.126(소리) | $0.42 | 3–15초 | 됨 | 약 2–2.5분 | Kling API 약관 — 유료 생성물 상업 가능 | fal 경유 한국 가능 | `FAL_KEY` |
27
+ | `fal-kling-3-pro` | 초당 $0.112(소리 끔) · $0.168(소리) | $0.56 | 3–15초 | 됨 | 약 2–2.5분 | 같음 | 같음 | `FAL_KEY` |
28
+ | `minimax-h3` | 초당 768P $0.08 · 2K $0.13, 참조 그림 6장째부터 $0.04 | $0.40 | 4–15초 | 됨 | 느림(OpenRouter 기준 약 6분) | API 약관 상업 가능. **공개 가중치는 한국·미국·EU·영국 제외**(신청제), 2천만 달러 넘는 매출·UI 표기·증류 금지 조건 | API 전 세계(한국 포함) | `MINIMAX_API_KEY` |
29
+ | `veo-3.1-lite` | 초당 720p $0.05 · 1080p $0.08 | $0.30(6초로 올림) | 4·6·8초 (1080p 는 8초만) | 됨 | 1–2분(미측정) | Google 약관, SynthID 워터마크 | Gemini API 한국 제공, 사람은 `allow_adult` 만 | `GEMINI_API_KEY` |
30
+ | `veo-3.1-fast` | 초당 720p $0.10 · 1080p $0.12 | $0.60(6초) | 4·6·8초 | 됨 | 1–2분(미측정) | 같음 | 같음 | `GEMINI_API_KEY` |
31
+ | `ltx-2-3-fast` | 초당 1080p $0.06 · 1440p $0.12 · 4K $0.24 | $0.36(6초부터) | 6–20초(1080p) | 됨 | 8초 첫+끝 약 33초(Runware 기준) | LTX 커뮤니티 — **연 매출 1천만 달러 미만 무료**, 넘으면 계약 | 전 세계 | `LTX_API_KEY` |
32
+
33
+ 나중 후보(v0 에서 안 감쌌다):
34
+
35
+ - **Wan 2.2 를 Modal 에 직접 띄우기** — 클립당 비용은 GPU 초 단위라 많이 뽑을수록 싸지만 띄우는 일과 콜드 스타트가 든다. 많이 뽑는 단계에서.
36
+ - **Kling 직접 API** — 키(`~/.secrets/gcp-sm/kling-api-credentials`)는 있으나 잔액 충전이 따로다. fal 경유가 같은 값이라 v0 은 fal 로.
37
+ - **MiniMax H3 Max**(fal 후학습 고속판, 480P $0.05/초) — 끝 프레임을 받는지 확인 전.
38
+
39
+ ## 고르는 법
40
+
41
+ - 이름을 안 주면 **키가 있는 것 중 견적이 가장 싼 것**(끝 프레임이 필요하면 그걸 받는 것만). `KF_H3_URL` 이 있으면 `h3-pod`(0원), 없으면 `fal-wan-2.2-turbo`.
42
+ - `h3-pod` 걸음 수는 `KF_H3_STEPS` 로 꼭 준다(기본값 없음). 서버에 터보 LoRA 가 걸려 있으면 9, 없으면 50 — 터보가 걸린 채 50 을 주면 학습과 다른 스케줄이라 그림이 틀어진다. LoRA 는 서버 전체 상태라 kasaframe 이 걸거나 풀지 않고 파드 운영자에게 상태를 묻는다. 같은 시드라도 걸음 수가 다르면 다른 그림이다(캐시 키에도 들어간다). `quality` 는 보내지 않는다(터보와 `high` 를 같이 쓰면 안 된다). 서버마다 다른 필드는 `KF_H3_EXTRA`(JSON)로 얹는다.
43
+ - 짧은 이음매(1–2초)도 5초로 만들어 장면 길이에 맞춰 빨리 감는다(`retime`). 움직임이 빨라 보이면 `fal-wan-2.2` 로 길이를 맞춰 뽑는다.
44
+ - 소리 없이 쓴다 — 곡이 소리를 쥔다. Kling·LTX 는 `generate_audio:false` 로 부른다.
45
+ - 이음매 실측(h3-pod, 터보 9걸음, 4초→2초): H3 는 16:9 프레임을 1344x768 로 **잘라 내지 않고 늘려** 받는다 — 프로젝트 크기로 되늘려야 자리가 겹친다. 첫 키프레임은 꽉 붙잡고(PSNR 37.8 dB) 끝 키프레임은 몇 픽셀 밀린다(24.6 dB). 양 끝 0.15초를 진짜 앞뒤 프레임으로 녹이면 48.8·44.8 dB, 렌더한 영상에서 경계 이웃 프레임 차이(39 dB)가 장면 안 이웃 프레임(33–36 dB)보다 작아 컷이 안 보인다.
46
+ - 검증 상태: OG 그림은 실호출로 확인. 영상은 h3-pod 로 시험한다(fal·Veo 실호출은 하지 않기로 함). fal·MiniMax·Veo·LTX 는 문서대로 짰고 아직 안 불렀다.
47
+
48
+ ## 출처
49
+
50
+ - MiniMax H3: platform.minimax.io/docs/guides/video-generation · 가중치 라이선스 huggingface.co/MiniMaxAI/MiniMax-H3 (Excluded Territories: EU·UK·Republic of Korea·USA)
51
+ - fal Wan 2.2: fal.ai/models/fal-ai/wan/v2.2-a14b/image-to-video · /turbo
52
+ - fal Kling 3: fal.ai/models/fal-ai/kling-video/v3/standard/image-to-video · /pro
53
+ - Veo 3.1: ai.google.dev/gemini-api/docs/veo · 단가 ai.google.dev/gemini-api/docs/pricing (2026-09-24 갱신분)
54
+ - LTX: docs.ltx.io/pricing · ltx.io/model/license
55
+ - gpt-image-2.5: developers.openai.com/api/docs/pricing
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@kasaframe/gen",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ }
9
+ }
@@ -0,0 +1,204 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { chromaKey, fitImage, KEY_BG, probe, transparentRatio } from "./media.ts";
6
+ import { apiSize } from "./image/og.ts";
7
+ import { pickImage, pickVideo } from "./registry.ts";
8
+ import type { Asset, AssetKind, AssetMeta, ImageProvider, ImageRequest, ProviderInfo, RawResult, VideoRequest } from "./types.ts";
9
+
10
+ export interface GenContext {
11
+ outDir: string; // 보통 <proj>/assets/gen
12
+ cacheDir?: string;
13
+ maxUsd?: number; // 영상 하나 상한
14
+ imageMaxUsd?: number; // 그림 한 장 상한
15
+ approve?: boolean; // 영상은 이게 true 일 때만 돈을 쓴다
16
+ log?: (line: string) => void;
17
+ }
18
+
19
+ export const DEFAULT_MAX_USD = 1;
20
+ export const DEFAULT_IMAGE_MAX_USD = 0.25;
21
+
22
+ // 돈을 쓰기 전에 멈춘 이유. CLI 는 이걸 보고 종료 코드 3 과 견적을 낸다.
23
+ export class NeedsApproval extends Error {
24
+ estimateUsd: number;
25
+ provider: string;
26
+ constructor(provider: string, estimateUsd: number, why: string) {
27
+ super(`${why} — ${provider} 견적 $${estimateUsd.toFixed(3)}`);
28
+ this.provider = provider;
29
+ this.estimateUsd = estimateUsd;
30
+ }
31
+ }
32
+
33
+ export function cacheDir(ctx: GenContext): string {
34
+ return ctx.cacheDir ?? process.env.KF_GEN_CACHE ?? join(homedir(), ".cache", "kasaframe", "gen");
35
+ }
36
+
37
+ function stable(v: unknown): unknown {
38
+ if (Array.isArray(v)) return v.map(stable);
39
+ if (v && typeof v === "object") {
40
+ return Object.fromEntries(
41
+ Object.keys(v)
42
+ .filter((k) => (v as any)[k] !== undefined)
43
+ .sort()
44
+ .map((k) => [k, stable((v as any)[k])]),
45
+ );
46
+ }
47
+ return v;
48
+ }
49
+
50
+ export function sha256(data: Uint8Array | string): string {
51
+ return createHash("sha256").update(data).digest("hex");
52
+ }
53
+
54
+ // 입력 파일은 경로가 아니라 내용으로 센다 — 같은 그림을 다른 곳에 두어도 같은 결과를 다시 쓴다.
55
+ export function cacheKey(kind: AssetKind, p: ProviderInfo, req: object, inputs: { role: string; sha256: string }[]): string {
56
+ const r: Record<string, unknown> = { ...req };
57
+ for (const k of ["refs", "firstFrame", "lastFrame"]) delete r[k];
58
+ return sha256(JSON.stringify(stable({ kind, provider: p.id, model: p.model, variant: p.variant, req: r, inputs })));
59
+ }
60
+
61
+ function inputsOf(req: { refs?: string[]; firstFrame?: string; lastFrame?: string }): { role: string; sha256: string }[] {
62
+ const list: { role: string; path: string }[] = [
63
+ ...(req.refs ?? []).map((path, i) => ({ role: `ref${i}`, path })),
64
+ ...(req.firstFrame ? [{ role: "first", path: req.firstFrame }] : []),
65
+ ...(req.lastFrame ? [{ role: "last", path: req.lastFrame }] : []),
66
+ ];
67
+ return list.map(({ role, path }) => {
68
+ if (!existsSync(path)) throw new Error(`입력 파일 없음: ${path}`);
69
+ return { role, sha256: sha256(readFileSync(path)) };
70
+ });
71
+ }
72
+
73
+ // 1024x1024 출력 토큰(OpenAI 계산기, 2.5 계열). 실제 비용은 응답 usage 로 다시 적는다.
74
+ const IMAGE_TOKENS: Record<string, number> = { low: 196, medium: 439, high: 1756, xhigh: 3122, max: 7024, auto: 1756 };
75
+
76
+ export function estimateImageUsd(req: ImageRequest): number {
77
+ const { w, h } = apiSize(req.size);
78
+ const out = (IMAGE_TOKENS[req.quality ?? "high"] ?? 1756) * ((w * h) / 1_048_576) * 30e-6;
79
+ const refs = (req.refs?.length ?? 0) * 1024 * 8e-6;
80
+ return out + refs;
81
+ }
82
+
83
+ function fromCache(id: string, key: string, ctx: GenContext): Asset | null {
84
+ const dir = cacheDir(ctx);
85
+ const metaPath = join(dir, `${key}.json`);
86
+ if (!existsSync(metaPath)) return null;
87
+ const meta = JSON.parse(readFileSync(metaPath, "utf8")) as AssetMeta;
88
+ const src = join(dir, meta.file);
89
+ if (!existsSync(src)) return null;
90
+ mkdirSync(ctx.outDir, { recursive: true });
91
+ const file = `${id}.${meta.file.split(".").pop()}`;
92
+ const path = join(ctx.outDir, file);
93
+ if (!existsSync(path) || sha256(readFileSync(path)) !== sha256(readFileSync(src))) copyFileSync(src, path);
94
+ const out = { ...meta, id, file };
95
+ writeFileSync(join(ctx.outDir, `${id}.json`), JSON.stringify(out, null, 2) + "\n");
96
+ return { path, meta: out, cached: true };
97
+ }
98
+
99
+ async function store(
100
+ id: string,
101
+ kind: AssetKind,
102
+ key: string,
103
+ p: ProviderInfo,
104
+ prompt: string,
105
+ raw: RawResult,
106
+ tmpPath: string,
107
+ inputs: AssetMeta["inputs"],
108
+ elapsedMs: number,
109
+ ctx: GenContext,
110
+ ): Promise<Asset> {
111
+ const dir = cacheDir(ctx);
112
+ const ext = tmpPath.split(".").pop()!;
113
+ const cached = join(dir, `${key}.${ext}`);
114
+ renameSync(tmpPath, cached);
115
+ const pr = await probe(cached);
116
+ const meta: AssetMeta = {
117
+ id,
118
+ kind,
119
+ file: basename(cached),
120
+ provider: p.id,
121
+ model: raw.model,
122
+ prompt,
123
+ seed: raw.seed,
124
+ params: raw.params,
125
+ inputs,
126
+ costUsd: Number(raw.costUsd.toFixed(5)),
127
+ costEstimated: raw.costEstimated,
128
+ elapsedMs,
129
+ license: p.license,
130
+ region: p.region,
131
+ cacheKey: key,
132
+ createdAt: new Date().toISOString(),
133
+ width: pr.width,
134
+ height: pr.height,
135
+ ...(kind === "vid" ? { duration: pr.duration, fps: pr.fps } : {}),
136
+ ...(raw.notes?.length ? { notes: raw.notes } : {}),
137
+ };
138
+ writeFileSync(join(dir, `${key}.json`), JSON.stringify(meta, null, 2) + "\n");
139
+ return { ...fromCache(id, key, ctx)!, cached: false };
140
+ }
141
+
142
+ async function makeImage(p: ImageProvider, req: ImageRequest, tmp: string): Promise<RawResult> {
143
+ const call = (r: ImageRequest) => (r.refs?.length && p.edit ? p.edit(r) : p.generate(r));
144
+ let raw = await call(req);
145
+ writeFileSync(tmp, raw.bytes);
146
+ if (req.transparent) {
147
+ if (raw.notes?.includes("chroma-key")) {
148
+ await chromaKey(tmp, tmp);
149
+ } else if ((await transparentRatio(tmp)) < 0.005) {
150
+ // 투명을 받는다던 모델이 불투명하게 돌려줬다 — 한 장 더 써서 단색 바탕 + 키잉으로 간다.
151
+ const again = await call({ ...req, prompt: `${req.prompt}\n\n${KEY_BG}`, transparent: false });
152
+ writeFileSync(tmp, again.bytes);
153
+ await chromaKey(tmp, tmp);
154
+ raw = { ...again, costUsd: raw.costUsd + again.costUsd, notes: [...(again.notes ?? []), "alpha-failed", "chroma-key"] };
155
+ }
156
+ }
157
+ const { w, h } = apiSize(req.size);
158
+ const [rw, rh] = req.size.split("x").map(Number);
159
+ if (w !== rw || h !== rh) {
160
+ await fitImage(tmp, tmp, rw, rh);
161
+ raw.notes = [...(raw.notes ?? []), `fit ${w}x${h}->${rw}x${rh}`];
162
+ }
163
+ return raw;
164
+ }
165
+
166
+ export async function genImage(id: string, req: ImageRequest, ctx: GenContext, providerId?: string): Promise<Asset> {
167
+ const p = pickImage(providerId);
168
+ const inputs = inputsOf(req);
169
+ const key = cacheKey("img", p, req, inputs);
170
+ const hit = fromCache(id, key, ctx);
171
+ if (hit) return hit;
172
+ const est = estimateImageUsd(req);
173
+ const cap = ctx.imageMaxUsd ?? DEFAULT_IMAGE_MAX_USD;
174
+ if (est > cap) throw new NeedsApproval(p.id, est, `그림 한 장 상한 $${cap} 초과`);
175
+ mkdirSync(cacheDir(ctx), { recursive: true });
176
+ const tmp = join(cacheDir(ctx), `.${key}.tmp.png`);
177
+ ctx.log?.(`그림 ${id}: ${p.id} 견적 $${est.toFixed(3)}`);
178
+ const t0 = Date.now();
179
+ const raw = await makeImage(p, req, tmp);
180
+ return store(id, "img", key, p, req.prompt, raw, tmp, inputs, Date.now() - t0, ctx);
181
+ }
182
+
183
+ export function planVideo(req: VideoRequest, ctx: GenContext, providerId?: string) {
184
+ const p = pickVideo(providerId, req);
185
+ const inputs = inputsOf(req);
186
+ const key = cacheKey("vid", p, req, inputs);
187
+ return { provider: p, inputs, key, estimateUsd: p.estimateUsd(req), cached: existsSync(join(cacheDir(ctx), `${key}.json`)) };
188
+ }
189
+
190
+ export async function genVideo(id: string, req: VideoRequest, ctx: GenContext, providerId?: string): Promise<Asset> {
191
+ const { provider: p, inputs, key, estimateUsd: est } = planVideo(req, ctx, providerId);
192
+ const hit = fromCache(id, key, ctx);
193
+ if (hit) return hit;
194
+ const cap = ctx.maxUsd ?? DEFAULT_MAX_USD;
195
+ if (est > cap) throw new NeedsApproval(p.id, est, `영상 하나 상한 $${cap} 초과`);
196
+ if (!ctx.approve) throw new NeedsApproval(p.id, est, "영상 생성은 승인(--yes)이 있어야 돌린다");
197
+ mkdirSync(cacheDir(ctx), { recursive: true });
198
+ ctx.log?.(`영상 ${id}: ${p.id} 견적 $${est.toFixed(3)} — 생성 중(몇십 초~몇 분)`);
199
+ const t0 = Date.now();
200
+ const raw = await p.generate(req);
201
+ const tmp = join(cacheDir(ctx), `.${key}.tmp.${raw.ext}`);
202
+ writeFileSync(tmp, raw.bytes);
203
+ return store(id, "vid", key, p, req.prompt, raw, tmp, inputs, Date.now() - t0, ctx);
204
+ }