kasaframe 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (442) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +148 -0
  3. package/README.md +92 -2
  4. package/app/package.json +12 -0
  5. package/app/server/engine.ts +533 -0
  6. package/app/server/main.ts +209 -0
  7. package/app/web/env.d.ts +2 -0
  8. package/app/web/icons.ts +31 -0
  9. package/app/web/index.html +21 -0
  10. package/app/web/main.ts +1195 -0
  11. package/app/web/style.css +463 -0
  12. package/bin/hooks.mjs +64 -0
  13. package/bin/kf.mjs +32 -0
  14. package/docs/AGENTS.md +286 -0
  15. package/music/README.md +37 -0
  16. package/package.json +44 -4
  17. package/packages/adapters/package.json +36 -0
  18. package/packages/adapters/src/css.ts +11 -0
  19. package/packages/adapters/src/gsap.ts +26 -0
  20. package/packages/adapters/src/index.ts +9 -0
  21. package/packages/adapters/src/lottie.ts +22 -0
  22. package/packages/adapters/src/media.ts +59 -0
  23. package/packages/adapters/src/page.ts +229 -0
  24. package/packages/adapters/src/project.ts +214 -0
  25. package/packages/adapters/src/react.ts +36 -0
  26. package/packages/adapters/src/svelte.ts +22 -0
  27. package/packages/adapters/src/three.ts +18 -0
  28. package/packages/adapters/src/vue.ts +27 -0
  29. package/packages/check/package.json +19 -0
  30. package/packages/check/src/check.ts +280 -0
  31. package/packages/check/src/cli.ts +70 -0
  32. package/packages/check/src/image.ts +146 -0
  33. package/packages/check/src/index.ts +9 -0
  34. package/packages/check/src/ocr.swift +37 -0
  35. package/packages/check/src/probe.ts +328 -0
  36. package/packages/check/src/report.ts +44 -0
  37. package/packages/check/src/rules/assets.ts +45 -0
  38. package/packages/check/src/rules/beats.ts +212 -0
  39. package/packages/check/src/rules/camera.ts +217 -0
  40. package/packages/check/src/rules/captions.ts +49 -0
  41. package/packages/check/src/rules/flash.ts +235 -0
  42. package/packages/check/src/rules/frames.ts +136 -0
  43. package/packages/check/src/rules/layout.ts +281 -0
  44. package/packages/check/src/rules/page.ts +87 -0
  45. package/packages/check/src/rules/runs.ts +20 -0
  46. package/packages/check/src/rules/text.ts +326 -0
  47. package/packages/check/src/scan.ts +240 -0
  48. package/packages/check/src/serve.ts +67 -0
  49. package/packages/check/src/types.ts +80 -0
  50. package/packages/check/src/video.ts +137 -0
  51. package/packages/cli/package.json +30 -0
  52. package/packages/cli/src/blur.ts +80 -0
  53. package/packages/cli/src/browser.ts +203 -0
  54. package/packages/cli/src/command.ts +44 -0
  55. package/packages/cli/src/commands/app.ts +25 -0
  56. package/packages/cli/src/commands/beats.ts +36 -0
  57. package/packages/cli/src/commands/bench.ts +123 -0
  58. package/packages/cli/src/commands/blender.ts +293 -0
  59. package/packages/cli/src/commands/check.ts +25 -0
  60. package/packages/cli/src/commands/direct.ts +210 -0
  61. package/packages/cli/src/commands/doctor.ts +30 -0
  62. package/packages/cli/src/commands/gen.ts +173 -0
  63. package/packages/cli/src/commands/look.ts +46 -0
  64. package/packages/cli/src/commands/lyrics.ts +157 -0
  65. package/packages/cli/src/commands/make.ts +1121 -0
  66. package/packages/cli/src/commands/music.ts +78 -0
  67. package/packages/cli/src/commands/narrate.ts +238 -0
  68. package/packages/cli/src/commands/new.ts +185 -0
  69. package/packages/cli/src/commands/preview.ts +43 -0
  70. package/packages/cli/src/commands/render.ts +250 -0
  71. package/packages/cli/src/commands/series.ts +110 -0
  72. package/packages/cli/src/commands/setup.ts +102 -0
  73. package/packages/cli/src/commands/sheet.ts +114 -0
  74. package/packages/cli/src/commands/skill.ts +43 -0
  75. package/packages/cli/src/commands/still.ts +40 -0
  76. package/packages/cli/src/commands/theme.ts +172 -0
  77. package/packages/cli/src/commands/transitions.ts +40 -0
  78. package/packages/cli/src/deps.ts +122 -0
  79. package/packages/cli/src/ffmpeg.ts +81 -0
  80. package/packages/cli/src/index.ts +8 -0
  81. package/packages/cli/src/kf.ts +64 -0
  82. package/packages/cli/src/lock.ts +72 -0
  83. package/packages/cli/src/mix.ts +131 -0
  84. package/packages/cli/src/out.ts +13 -0
  85. package/packages/cli/src/pool.ts +34 -0
  86. package/packages/cli/src/segments.ts +402 -0
  87. package/packages/cli/src/serve.ts +124 -0
  88. package/packages/clock/package.json +14 -0
  89. package/packages/clock/src/index.ts +25 -0
  90. package/packages/clock/src/install.ts +351 -0
  91. package/packages/clock/src/types.ts +45 -0
  92. package/packages/core/package.json +9 -0
  93. package/packages/core/src/beats.ts +283 -0
  94. package/packages/core/src/ease.ts +87 -0
  95. package/packages/core/src/frame.ts +99 -0
  96. package/packages/core/src/index.ts +27 -0
  97. package/packages/core/src/project.ts +157 -0
  98. package/packages/core/src/rand.ts +56 -0
  99. package/packages/core/src/spring.ts +52 -0
  100. package/packages/core/src/style.ts +100 -0
  101. package/packages/core/src/time.ts +88 -0
  102. package/packages/director/package.json +23 -0
  103. package/packages/director/src/assemble.ts +361 -0
  104. package/packages/director/src/brief.ts +122 -0
  105. package/packages/director/src/catalog.ts +49 -0
  106. package/packages/director/src/content.ts +343 -0
  107. package/packages/director/src/fallback.ts +58 -0
  108. package/packages/director/src/index.ts +13 -0
  109. package/packages/director/src/pattern.ts +90 -0
  110. package/packages/director/src/patterns/fill/cells.ts +115 -0
  111. package/packages/director/src/patterns/formula/stack.ts +93 -0
  112. package/packages/director/src/patterns/util.ts +58 -0
  113. package/packages/director/src/scene.ts +62 -0
  114. package/packages/director/src/select.ts +163 -0
  115. package/packages/director/src/transitions.ts +115 -0
  116. package/packages/gen/PROVIDERS.md +90 -0
  117. package/packages/gen/package.json +9 -0
  118. package/packages/gen/src/ask.ts +132 -0
  119. package/packages/gen/src/audio.ts +91 -0
  120. package/packages/gen/src/blender.ts +54 -0
  121. package/packages/gen/src/capabilities.ts +151 -0
  122. package/packages/gen/src/generate.ts +206 -0
  123. package/packages/gen/src/image/codex.ts +117 -0
  124. package/packages/gen/src/image/og.ts +133 -0
  125. package/packages/gen/src/index.ts +26 -0
  126. package/packages/gen/src/keys.ts +86 -0
  127. package/packages/gen/src/media.ts +150 -0
  128. package/packages/gen/src/music/elevenlabs.ts +41 -0
  129. package/packages/gen/src/music/index.ts +120 -0
  130. package/packages/gen/src/music/library.ts +73 -0
  131. package/packages/gen/src/music/lyria.ts +52 -0
  132. package/packages/gen/src/music/types.ts +28 -0
  133. package/packages/gen/src/plan.ts +106 -0
  134. package/packages/gen/src/registry.ts +54 -0
  135. package/packages/gen/src/seam.ts +103 -0
  136. package/packages/gen/src/tts/dashscope.ts +64 -0
  137. package/packages/gen/src/tts/elevenlabs.ts +84 -0
  138. package/packages/gen/src/tts/gemini.ts +73 -0
  139. package/packages/gen/src/tts/index.ts +241 -0
  140. package/packages/gen/src/tts/local.ts +42 -0
  141. package/packages/gen/src/tts/types.ts +44 -0
  142. package/packages/gen/src/types.ts +90 -0
  143. package/packages/gen/src/video/fal.ts +140 -0
  144. package/packages/gen/src/video/h3.ts +112 -0
  145. package/packages/gen/src/video/http.ts +44 -0
  146. package/packages/gen/src/video/ltx.ts +60 -0
  147. package/packages/gen/src/video/minimax.ts +55 -0
  148. package/packages/gen/src/video/veo.ts +71 -0
  149. package/packages/jizura/package.json +16 -0
  150. package/packages/jizura/src/deny.ts +73 -0
  151. package/packages/jizura/src/index.ts +7 -0
  152. package/packages/jizura/src/install.ts +269 -0
  153. package/packages/jizura/src/layer.ts +325 -0
  154. package/packages/jizura/src/lyrics.ts +81 -0
  155. package/packages/jizura/src/pin.ts +8 -0
  156. package/packages/jizura/src/vendor.ts +140 -0
  157. package/packages/jizura/src/vite.ts +63 -0
  158. package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
  159. package/packages/jizura/vendor/NOTICE +43 -0
  160. package/packages/jizura/vendor/engine.json +47 -0
  161. package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
  162. package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
  163. package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
  164. package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
  165. package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  166. package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
  167. package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
  168. package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
  169. package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
  170. package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
  171. package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
  172. package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
  173. package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
  174. package/packages/jizura/vendor/fonts/fonts.json +52 -0
  175. package/packages/jizura/vendor/jizura_engine.js +35024 -0
  176. package/packages/motion/README.md +153 -0
  177. package/packages/motion/package.json +30 -0
  178. package/packages/motion/src/camera.ts +97 -0
  179. package/packages/motion/src/captions.ts +487 -0
  180. package/packages/motion/src/enter.ts +128 -0
  181. package/packages/motion/src/hangul.ts +129 -0
  182. package/packages/motion/src/impact.ts +100 -0
  183. package/packages/motion/src/index.ts +17 -0
  184. package/packages/motion/src/moves/brushWipe.ts +103 -0
  185. package/packages/motion/src/moves/cloneArray.ts +111 -0
  186. package/packages/motion/src/moves/curtains.ts +81 -0
  187. package/packages/motion/src/moves/dress.ts +191 -0
  188. package/packages/motion/src/moves/echoStack.ts +70 -0
  189. package/packages/motion/src/moves/fit.ts +70 -0
  190. package/packages/motion/src/moves/flood.ts +61 -0
  191. package/packages/motion/src/moves/ghostStack.ts +88 -0
  192. package/packages/motion/src/moves/gridFloor.ts +100 -0
  193. package/packages/motion/src/moves/imageSwarm.ts +113 -0
  194. package/packages/motion/src/moves/index.ts +38 -0
  195. package/packages/motion/src/moves/irisBlades.ts +113 -0
  196. package/packages/motion/src/moves/karaokeLine.ts +108 -0
  197. package/packages/motion/src/moves/ledBoard.ts +94 -0
  198. package/packages/motion/src/moves/liquidTab.ts +72 -0
  199. package/packages/motion/src/moves/markerWrite.ts +63 -0
  200. package/packages/motion/src/moves/menu.ts +138 -0
  201. package/packages/motion/src/moves/morphBox.ts +96 -0
  202. package/packages/motion/src/moves/neonSign.ts +78 -0
  203. package/packages/motion/src/moves/panelPop.ts +78 -0
  204. package/packages/motion/src/moves/paperTear.ts +75 -0
  205. package/packages/motion/src/moves/path.ts +64 -0
  206. package/packages/motion/src/moves/pathText.ts +107 -0
  207. package/packages/motion/src/moves/pointSphere.ts +109 -0
  208. package/packages/motion/src/moves/ransomLetters.ts +73 -0
  209. package/packages/motion/src/moves/shapeMorph.ts +143 -0
  210. package/packages/motion/src/moves/slamWords.ts +96 -0
  211. package/packages/motion/src/moves/sparkTrail.ts +130 -0
  212. package/packages/motion/src/moves/speedLines.ts +91 -0
  213. package/packages/motion/src/moves/splitFlap.ts +149 -0
  214. package/packages/motion/src/moves/stepWords.ts +128 -0
  215. package/packages/motion/src/moves/strikeOut.ts +98 -0
  216. package/packages/motion/src/moves/sunburst.ts +58 -0
  217. package/packages/motion/src/moves/tickerRows.ts +91 -0
  218. package/packages/motion/src/moves/tokenPicker.ts +115 -0
  219. package/packages/motion/src/moves/truchet.ts +85 -0
  220. package/packages/motion/src/moves/wordWall.ts +78 -0
  221. package/packages/motion/src/moves/worldRoll.ts +32 -0
  222. package/packages/motion/src/particles.ts +102 -0
  223. package/packages/motion/src/react/index.tsx +21 -0
  224. package/packages/motion/src/react/parts.tsx +431 -0
  225. package/packages/motion/src/react/time.tsx +97 -0
  226. package/packages/motion/src/text.ts +103 -0
  227. package/packages/motion/src/transition.ts +82 -0
  228. package/packages/motion/src/transition3d/cards.ts +65 -0
  229. package/packages/motion/src/transition3d/chain.ts +84 -0
  230. package/packages/motion/src/transition3d/dive.ts +181 -0
  231. package/packages/motion/src/transition3d/fold.ts +110 -0
  232. package/packages/motion/src/transition3d/gl.ts +219 -0
  233. package/packages/motion/src/transition3d/index.ts +235 -0
  234. package/packages/motion/src/transition3d/live.ts +70 -0
  235. package/packages/motion/src/transition3d/portal.ts +115 -0
  236. package/packages/motion/src/transition3d/react.tsx +29 -0
  237. package/packages/motion/src/transition3d/rig.ts +600 -0
  238. package/packages/motion/src/transition3d/shatter.ts +104 -0
  239. package/packages/motion/src/transition3d/space.ts +173 -0
  240. package/packages/motion/src/transition3d/whip.ts +78 -0
  241. package/packages/motion/src/transition3d/world.ts +737 -0
  242. package/packages/motion/src/transition3d/zoomOut.ts +114 -0
  243. package/packages/motion/src/transitions2d/catalog.ts +127 -0
  244. package/packages/motion/src/transitions2d/css.d.ts +2 -0
  245. package/packages/motion/src/transitions2d/cult-ui/LICENSE.md +9 -0
  246. package/packages/motion/src/transitions2d/cult-ui/RollingNumber.tsx +43 -0
  247. package/packages/motion/src/transitions2d/gl-transitions/LICENSE +25 -0
  248. package/packages/motion/src/transitions2d/gl-transitions/shaders.ts +1931 -0
  249. package/packages/motion/src/transitions2d/index.ts +2 -0
  250. package/packages/motion/src/transitions2d/kokonutui/LICENSE +21 -0
  251. package/packages/motion/src/transitions2d/kokonutui/MatrixText.css +16 -0
  252. package/packages/motion/src/transitions2d/kokonutui/MatrixText.tsx +94 -0
  253. package/packages/motion/src/transitions2d/magicui/HyperText.css +15 -0
  254. package/packages/motion/src/transitions2d/magicui/HyperText.tsx +115 -0
  255. package/packages/motion/src/transitions2d/magicui/LICENSE.md +21 -0
  256. package/packages/motion/src/transitions2d/magicui/MorphingText.css +25 -0
  257. package/packages/motion/src/transitions2d/magicui/MorphingText.tsx +130 -0
  258. package/packages/motion/src/transitions2d/magicui/WordRotate.tsx +42 -0
  259. package/packages/motion/src/transitions2d/react.ts +13 -0
  260. package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.css +39 -0
  261. package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.tsx +32 -0
  262. package/packages/motion/src/transitions2d/smoothui/LICENSE +21 -0
  263. package/packages/motion/src/transitions2d/smoothui/SharedAxisY.tsx +50 -0
  264. package/packages/motion/src/transitions2d/smoothui/TextMorph.tsx +90 -0
  265. package/packages/motion/src/transitions2d/smoothui/shaders.ts +223 -0
  266. package/packages/motion/src/transitions2d/unlumen/LICENSE +21 -0
  267. package/packages/motion/src/transitions2d/unlumen/ScrambleText.tsx +114 -0
  268. package/packages/motion/src/transitions2d/vengeance-ui/LICENSE +21 -0
  269. package/packages/motion/src/transitions2d/vengeance-ui/MorphText.css +69 -0
  270. package/packages/motion/src/transitions2d/vengeance-ui/MorphText.tsx +45 -0
  271. package/packages/motion/src/type.ts +131 -0
  272. package/packages/motion/src/ui.ts +172 -0
  273. package/packages/patterns/package.json +15 -0
  274. package/packages/patterns/src/bars/rank.ts +119 -0
  275. package/packages/patterns/src/before-after/shrink.ts +127 -0
  276. package/packages/patterns/src/before-after/strike.ts +125 -0
  277. package/packages/patterns/src/code/terminal.ts +133 -0
  278. package/packages/patterns/src/compare/bars.ts +142 -0
  279. package/packages/patterns/src/compare/duel.ts +103 -0
  280. package/packages/patterns/src/end/lockup.ts +85 -0
  281. package/packages/patterns/src/end/slam.ts +91 -0
  282. package/packages/patterns/src/flow/graph.ts +250 -0
  283. package/packages/patterns/src/hook-number/counter.ts +101 -0
  284. package/packages/patterns/src/hook-number/roll.ts +179 -0
  285. package/packages/patterns/src/hook-number/slam.ts +91 -0
  286. package/packages/patterns/src/index.ts +38 -0
  287. package/packages/patterns/src/kit.ts +449 -0
  288. package/packages/patterns/src/limit/gauge.ts +117 -0
  289. package/packages/patterns/src/list/cards.ts +131 -0
  290. package/packages/patterns/src/list/cycle.ts +22 -0
  291. package/packages/patterns/src/media/panels.ts +100 -0
  292. package/packages/patterns/src/objects.ts +234 -0
  293. package/packages/patterns/src/quote/karaoke.ts +126 -0
  294. package/packages/patterns/src/quote/slam.ts +22 -0
  295. package/packages/patterns/src/screen/focus.ts +85 -0
  296. package/packages/patterns/src/steps/path.ts +137 -0
  297. package/packages/patterns/src/steps/stack.ts +111 -0
  298. package/packages/patterns/src/term-reveal/chips.ts +92 -0
  299. package/packages/patterns/src/term-reveal/expand.ts +127 -0
  300. package/packages/patterns/src/timeline/axis.ts +144 -0
  301. package/packages/patterns/src/title/mask.ts +85 -0
  302. package/packages/patterns/src/title/slam.ts +23 -0
  303. package/packages/patterns/src/toggle-sim/switch.ts +92 -0
  304. package/packages/patterns/src/trend/columns.ts +108 -0
  305. package/packages/patterns/src/trend/draw.ts +140 -0
  306. package/packages/patterns/src/types.ts +67 -0
  307. package/packages/patterns/src/words/moves.ts +42 -0
  308. package/packages/patterns/src/words/slam.ts +4 -0
  309. package/packages/plan/package.json +15 -0
  310. package/packages/plan/src/asks.ts +256 -0
  311. package/packages/plan/src/brief.ts +383 -0
  312. package/packages/plan/src/captions.ts +78 -0
  313. package/packages/plan/src/detect.ts +93 -0
  314. package/packages/plan/src/index.ts +27 -0
  315. package/packages/plan/src/layout.ts +176 -0
  316. package/packages/plan/src/llm.ts +172 -0
  317. package/packages/plan/src/look.ts +222 -0
  318. package/packages/plan/src/plan.ts +179 -0
  319. package/packages/plan/src/request.ts +55 -0
  320. package/packages/plan/src/series.ts +76 -0
  321. package/packages/plan/src/studio.ts +53 -0
  322. package/packages/plan/src/templates.ts +42 -0
  323. package/packages/plan/src/themes.ts +48 -0
  324. package/packages/plan/src/types.ts +157 -0
  325. package/packages/studio/package.json +12 -0
  326. package/packages/studio/src/index.ts +1 -0
  327. package/packages/studio/src/plugin.ts +236 -0
  328. package/packages/studio/src/ui/main.ts +503 -0
  329. package/packages/studio/src/ui/studio.css +165 -0
  330. package/packages/studio/src/ui/timeline.ts +317 -0
  331. package/packages/themes/kit/THEME-MAKE.md +68 -0
  332. package/packages/themes/kit/THEME.md +30 -0
  333. package/packages/themes/kit/briefs/og-intro-15s/brief.json +16 -0
  334. package/packages/themes/kit/briefs/og-intro-15s/facts.ts +29 -0
  335. package/packages/themes/kit/briefs/og-intro-15s/logo.svg +12 -0
  336. package/packages/themes/kit/effects.ts +5 -0
  337. package/packages/themes/kit/sample/index.html +11 -0
  338. package/packages/themes/kit/sample/main.ts +9 -0
  339. package/packages/themes/kit/sample/scenes/scene.ts +15 -0
  340. package/packages/themes/package.json +19 -0
  341. package/packages/themes/src/factory.ts +296 -0
  342. package/packages/themes/src/fx.ts +14 -0
  343. package/packages/themes/src/index.ts +114 -0
  344. package/packages/themes/src/slotkit.ts +79 -0
  345. package/packages/themes/src/slots.ts +46 -0
  346. package/packages/themes/src/stage3d.ts +197 -0
  347. package/packages/themes/src/types.ts +58 -0
  348. package/presets/kawaii-pastel.json +94 -0
  349. package/presets/neutral-dark.json +82 -0
  350. package/presets/og.json +98 -0
  351. package/series/og-shorts.json +14 -0
  352. package/skills/kasaframe/SKILL.md +33 -0
  353. package/templates/_shared/project/index.html +11 -0
  354. package/templates/_shared/project/lib/react.ts +22 -0
  355. package/templates/_shared/project/lib/stage.ts +292 -0
  356. package/templates/_shared/project/main.ts +9 -0
  357. package/templates/_shared/scenes/default.ts +21 -0
  358. package/templates/_shared/scenes/drop.ts +33 -0
  359. package/templates/_shared/scenes/end.ts +26 -0
  360. package/templates/_shared/scenes/hook.ts +18 -0
  361. package/templates/_shared/scenes/seam.ts +41 -0
  362. package/templates/_shared/scenes/step.ts +49 -0
  363. package/templates/ad/preview.png +0 -0
  364. package/templates/ad/template.json +151 -0
  365. package/templates/demo/preview.png +0 -0
  366. package/templates/demo/template.json +141 -0
  367. package/templates/explainer/preview.png +0 -0
  368. package/templates/explainer/template.json +193 -0
  369. package/templates/launch/preview.png +0 -0
  370. package/templates/launch/template.json +163 -0
  371. package/templates/mv/preview.png +0 -0
  372. package/templates/mv/scenes/default.ts +98 -0
  373. package/templates/mv/template.json +178 -0
  374. package/templates/promo/preview.png +0 -0
  375. package/templates/promo/template.json +185 -0
  376. package/templates/shorts/preview.png +0 -0
  377. package/templates/shorts/scenes/hook.ts +37 -0
  378. package/templates/shorts/template.json +196 -0
  379. package/templates/square/preview.png +0 -0
  380. package/templates/square/template.json +133 -0
  381. package/themes/debug-hud/THEME.md +39 -0
  382. package/themes/debug-hud/effects.ts +389 -0
  383. package/themes/debug-hud/sample/assets/og/logo.svg +12 -0
  384. package/themes/debug-hud/sample/beats.json +1 -0
  385. package/themes/debug-hud/sample/index.html +11 -0
  386. package/themes/debug-hud/sample/kf.json +59 -0
  387. package/themes/debug-hud/sample/lib/hudkit.ts +49 -0
  388. package/themes/debug-hud/sample/lib/og.ts +29 -0
  389. package/themes/debug-hud/sample/main.ts +9 -0
  390. package/themes/debug-hud/sample/scenes/end.ts +62 -0
  391. package/themes/debug-hud/sample/scenes/gather.ts +67 -0
  392. package/themes/debug-hud/sample/scenes/models.ts +70 -0
  393. package/themes/debug-hud/sample/scenes/price.ts +79 -0
  394. package/themes/debug-hud/sample/style.json +91 -0
  395. package/themes/debug-hud/slots.ts +99 -0
  396. package/themes/debug-hud/still.png +0 -0
  397. package/themes/debug-hud/style.json +91 -0
  398. package/themes/debug-hud/theme.json +68 -0
  399. package/themes/og-measure/THEME.md +47 -0
  400. package/themes/og-measure/effects.ts +432 -0
  401. package/themes/og-measure/sample/assets/brand/og-logo-full.svg +12 -0
  402. package/themes/og-measure/sample/assets/brand/og-logo.svg +12 -0
  403. package/themes/og-measure/sample/assets/data/measure.json +353 -0
  404. package/themes/og-measure/sample/beats.json +1 -0
  405. package/themes/og-measure/sample/index.html +11 -0
  406. package/themes/og-measure/sample/kf.json +66 -0
  407. package/themes/og-measure/sample/lib/data.ts +62 -0
  408. package/themes/og-measure/sample/main.ts +9 -0
  409. package/themes/og-measure/sample/measure.ts +78 -0
  410. package/themes/og-measure/sample/scenes/end.ts +47 -0
  411. package/themes/og-measure/sample/scenes/gather.ts +51 -0
  412. package/themes/og-measure/sample/scenes/models.ts +34 -0
  413. package/themes/og-measure/sample/scenes/one.ts +39 -0
  414. package/themes/og-measure/sample/scenes/price.ts +56 -0
  415. package/themes/og-measure/sample/style.json +97 -0
  416. package/themes/og-measure/slots.ts +88 -0
  417. package/themes/og-measure/still.png +0 -0
  418. package/themes/og-measure/style.json +97 -0
  419. package/themes/og-measure/theme.json +79 -0
  420. package/themes/riso-zine/THEME.md +43 -0
  421. package/themes/riso-zine/effects.ts +321 -0
  422. package/themes/riso-zine/sample/assets/og/logo.svg +12 -0
  423. package/themes/riso-zine/sample/beats.json +1 -0
  424. package/themes/riso-zine/sample/index.html +11 -0
  425. package/themes/riso-zine/sample/kf.json +59 -0
  426. package/themes/riso-zine/sample/lib/og.ts +29 -0
  427. package/themes/riso-zine/sample/lib/shot.ts +86 -0
  428. package/themes/riso-zine/sample/lib/zine.ts +42 -0
  429. package/themes/riso-zine/sample/main.ts +9 -0
  430. package/themes/riso-zine/sample/scenes/end.ts +61 -0
  431. package/themes/riso-zine/sample/scenes/gather.ts +62 -0
  432. package/themes/riso-zine/sample/scenes/models.ts +52 -0
  433. package/themes/riso-zine/sample/scenes/price.ts +82 -0
  434. package/themes/riso-zine/sample/style.json +99 -0
  435. package/themes/riso-zine/slots.ts +81 -0
  436. package/themes/riso-zine/still.png +0 -0
  437. package/themes/riso-zine/style.json +99 -0
  438. package/themes/riso-zine/theme.json +75 -0
  439. package/tools/beats/beats.py +372 -0
  440. package/tools/beats/requirements.txt +3 -0
  441. package/tools/lyrics/align.py +164 -0
  442. package/tools/lyrics/requirements.txt +9 -0
@@ -0,0 +1,487 @@
1
+ import type { CaptionLook, KfStyle, SafeArea } from "@kasaframe/core";
2
+
3
+ // 자막 — 들릴 말(captions.json)을 장면 시각에 나눠 화면 아래 판에 얹는다.
4
+ // srt·vtt(plan 의 writeCaptions)와 같은 toCues 를 써서 트랙과 화면 자막이 한 마디도 어긋나지 않는다.
5
+ // 이 파일은 Node(트랙 쓰기)와 페이지 양쪽에서 불리므로 DOM 은 mountCaptions 안에서만 만진다.
6
+
7
+ /** kf.json captions — off: 화면엔 없이 srt·vtt 만 · on: 화면에 얹고 srt·vtt 도 · none: 아무것도 */
8
+ export type CaptionMode = "on" | "off" | "none";
9
+
10
+ /** 옛 이름(track·burned)도 받는다. fit 은 on 과 같고 장면을 띠 위로 줄여 올리는 방식만 다르다(captionsPlan) */
11
+ export function captionMode(v: unknown): CaptionMode {
12
+ if (v === "on" || v === "fit" || v === "burned" || v === true) return "on";
13
+ if (v === "off" || v === "track") return "off";
14
+ return "none";
15
+ }
16
+
17
+ /** 낱말 하나와 그 시각(영상 전체 기준 초). lyrics.json 꼴({ w, start, end })도 받는다 */
18
+ export interface CaptionWord {
19
+ text?: string;
20
+ from?: number;
21
+ to?: number;
22
+ w?: string;
23
+ start?: number;
24
+ end?: number;
25
+ }
26
+
27
+ export interface CaptionLine {
28
+ scene?: string;
29
+ from?: number;
30
+ to?: number;
31
+ text: string;
32
+ /** 낱말마다 잰 시각이 있으면 마디를 그 시각 그대로 끊는다 */
33
+ words?: CaptionWord[];
34
+ }
35
+
36
+ export interface Cue {
37
+ from: number;
38
+ to: number;
39
+ text: string;
40
+ }
41
+
42
+ interface Span {
43
+ id: string;
44
+ from: number;
45
+ dur: number;
46
+ }
47
+
48
+ // 화면 자막 판이 놓일 자리와 크기. 페이지에서 재기 전에(Node 의 srt 쓰기에서도) 같은 값을 내야 해서 글꼴 없이 화면 크기만으로 정한다
49
+ export interface CaptionMetrics {
50
+ fontPx: number;
51
+ lineHeight: number;
52
+ padX: number;
53
+ padY: number;
54
+ /** 판 안 글 한 줄의 최대 폭(px) */
55
+ textMaxPx: number;
56
+ /** 한 줄에 들어가는 폭을 글자 크기 단위(em)로 */
57
+ maxEm: number;
58
+ /** 판 아래 끝의 y(px) — 안전 영역 아래 가장자리에서 gap 만큼 띄운다 */
59
+ bottom: number;
60
+ /** 판 가운데의 x(px) — 안전 영역 가운데 */
61
+ centerX: number;
62
+ /** 자막 띠 위아래 여백(px) */
63
+ gap: number;
64
+ /** 한 마디 줄 수 */
65
+ maxLines: number;
66
+ /** style.json captions.position — 판 가운데 y(px). 있으면 bottom 대신 이 자리 */
67
+ centerY?: number;
68
+ }
69
+
70
+ /** 판 높이(px) — 줄 수만큼 */
71
+ export function plateHeight(m: CaptionMetrics, lines: number): number {
72
+ return Math.round(m.fontPx * m.lineHeight * lines + m.padY * 2);
73
+ }
74
+
75
+ /** 자막 띠 — 안전 영역 아래쪽에서 장면이 비워 줄 높이(px). 판 + 위아래 여백 */
76
+ export function captionBand(m: CaptionMetrics, lines = m.maxLines): number {
77
+ if (m.centerY === undefined) return plateHeight(m, lines) + m.gap * 2;
78
+ // 판을 정한 자리에 두면 띠는 판 윗변부터 안전 영역 아래 끝까지 — 판이 그 아래(플랫폼 UI 자리)에 있으면 장면은 비켜 줄 것이 없다
79
+ return Math.max(0, Math.round(m.bottom + m.gap - (m.centerY - plateHeight(m, lines) / 2) + m.gap));
80
+ }
81
+
82
+ export function captionMetrics(o: { width: number; height: number; safe?: SafeArea | null; minPx?: number; look?: CaptionLook | null }): CaptionMetrics {
83
+ const safe = o.safe ?? { top: 0, bottom: 0, left: 0, right: 0 };
84
+ const short = Math.min(o.width, o.height);
85
+ const vertical = o.height > o.width;
86
+ // 세로 쇼츠는 폰 한 손 거리에서 읽혀야 한다 — 폭의 8%(1080 폭이면 86px). 가로는 높이의 5%
87
+ const fontPx = Math.max(o.minPx ?? 0, Math.round(o.look?.size ?? (vertical ? o.width * 0.08 : short * 0.05)));
88
+ const padX = Math.round(fontPx * 0.5);
89
+ const padY = Math.round(fontPx * 0.2);
90
+ const gap = Math.round(short * 0.02);
91
+ const room = o.width - safe.left - safe.right - Math.round(short * 0.04);
92
+ // 가로 화면은 한 줄이 너무 길면 눈이 따라가지 못한다 — 화면 폭 2/3 까지
93
+ const plateMax = vertical ? room : Math.min(room, Math.round(o.width * 0.66));
94
+ const textMaxPx = plateMax - padX * 2;
95
+ return {
96
+ fontPx,
97
+ lineHeight: 1.28,
98
+ padX,
99
+ padY,
100
+ textMaxPx,
101
+ maxEm: textMaxPx / fontPx,
102
+ bottom: o.height - safe.bottom - gap,
103
+ centerX: safe.left + (o.width - safe.left - safe.right) / 2,
104
+ gap,
105
+ maxLines: Math.max(1, Math.round(o.look?.maxLines ?? 2)),
106
+ ...(o.look?.position !== undefined ? { centerY: o.look.position } : {}),
107
+ };
108
+ }
109
+
110
+ // 글꼴 없이 어림하는 폭(em). 한글·한자는 정사각에 가깝고 라틴은 좁다. 어림이 실제보다 넓게 나와야 페이지에서 잰 줄바꿈이 두 줄을 넘지 않는다
111
+ export function emWidth(s: string): number {
112
+ let w = 0;
113
+ for (const ch of s) {
114
+ const c = ch.codePointAt(0)!;
115
+ if (ch === " ") w += 0.3;
116
+ else if ((c >= 0x1100 && c <= 0x11ff) || (c >= 0x3000 && c <= 0x9fff) || (c >= 0xac00 && c <= 0xd7af) || (c >= 0xff00 && c <= 0xffef)) w += 1;
117
+ else if (/[0-9]/.test(ch)) w += 0.62;
118
+ else if (/[A-Z]/.test(ch)) w += 0.72;
119
+ else if (/[a-z]/.test(ch)) w += 0.58;
120
+ else if (/[.,:;'"!?·]/.test(ch)) w += 0.34;
121
+ else w += 0.62;
122
+ }
123
+ return w;
124
+ }
125
+
126
+ // 한글로 끝나는데 조사·어미가 아닌 어절(「잠재」「512차원」) — 그 뒤에서 끊으면 말이 갈린다
127
+ const ENDINGS = new Set([..."은는이가을를에서로와과도만의께랑엔고며면니데해게지요다까죠라여야듯록나서"]);
128
+ function looseEnd(word: string): boolean {
129
+ const w = word.replace(/[^\p{L}\p{N}]+$/u, "");
130
+ const last = [...w].pop() ?? "";
131
+ // 「3만 2768」「57분의 1」 — 수를 세는 말 뒤는 조사처럼 보여도 수가 이어진다
132
+ if (/\d[만천억조]$|\d분의$/.test(w)) return true;
133
+ return /[가-힣]/.test(last) && !ENDINGS.has(last);
134
+ }
135
+
136
+ /**
137
+ * 어절(띄어쓰기) 단위로 두 줄까지 나눈다. 한 줄에 들면 한 줄, 아니면 두 줄 폭이 가장 고른 자리에서 끊는다.
138
+ * 두 줄로도 안 들면 null — 마디를 더 잘게 나눠야 한다. measure 는 글 폭(px 든 em 이든 maxWidth 와 같은 단위).
139
+ */
140
+ export function wrapCaption(text: string, maxWidth: number, measure: (s: string) => number = emWidth, maxLines = 2): string[] | null {
141
+ const t = text.trim().replace(/\s+/g, " ");
142
+ if (measure(t) <= maxWidth) return [t];
143
+ const words = t.split(" ");
144
+ if (maxLines < 2 || words.length < 2) return null;
145
+ let best: string[] | null = null;
146
+ let bestW = Infinity;
147
+ for (let i = 1; i < words.length; i++) {
148
+ const a = words.slice(0, i).join(" ");
149
+ const b = words.slice(i).join(" ");
150
+ const wa = measure(a);
151
+ const wb = measure(b);
152
+ if (wa > maxWidth) break;
153
+ if (wb > maxWidth) continue;
154
+ // 위 줄이 조금 짧은 쪽(역삼각형)을 같은 폭보다 살짝 낫게 본다 — 한국 방송 자막 관례.
155
+ // 조사·어미로 끝난 어절 뒤에서 끊는다 — 「잠재 / 공간」처럼 한 덩어리 말을 가르면 폭이 고르더라도 읽다가 걸린다
156
+ const score = Math.max(wa, wb) + (wa > wb ? 0.01 * maxWidth : 0) + (looseEnd(words[i - 1]) ? 0.12 * maxWidth : 0);
157
+ if (score < bestW) {
158
+ bestW = score;
159
+ best = [a, b];
160
+ }
161
+ }
162
+ return best;
163
+ }
164
+
165
+ // 쉼표·마침표 뒤에서 끊는다. 숫자 사이 쉼표·점(1,000 · 3.5)은 끊지 않는다. 쉼표 뒤 띄어쓰기가 없어도 끊는다
166
+ export function splitPhrases(text: string): string[] {
167
+ const out: string[] = [];
168
+ let cur = "";
169
+ const chars = [...text];
170
+ for (let i = 0; i < chars.length; i++) {
171
+ const ch = chars[i];
172
+ cur += ch;
173
+ if (!/[,.?!。、]/.test(ch)) continue;
174
+ if ((ch === "," || ch === ".") && /\d/.test(chars[i - 1] ?? "") && /\d/.test(chars[i + 1] ?? "")) continue;
175
+ if (/[.?!。]/.test(chars[i + 1] ?? "")) continue;
176
+ out.push(cur);
177
+ cur = "";
178
+ }
179
+ if (cur.trim()) out.push(cur);
180
+ return out.map((p) => p.trim().replace(/[,、]$/, "").replace(/(?<!\.)\.$/, "").trim()).filter(Boolean);
181
+ }
182
+
183
+ // 두 줄에 안 드는 마디는 어절 경계에서 몇 토막으로 나눈다 — 토막 길이는 고르게, 끝은 조사·어미 뒤에서(「흡수해 잠재 / 공간에서」처럼 덩어리를 가르지 않게)
184
+ function chunk(text: string, maxEm: number, lines = 2): string[] {
185
+ if (wrapCaption(text, maxEm, emWidth, lines)) return [text];
186
+ const words = text.split(/\s+/).filter(Boolean);
187
+ const n = words.length;
188
+ const part = (i: number, j: number) => words.slice(i, j).join(" ");
189
+ for (let k = 2; k <= n; k++) {
190
+ const ideal = emWidth(text) / k;
191
+ // best[j][c] = 앞 j 어절을 c 토막으로 나눈 가장 싼 값
192
+ const best = Array.from({ length: n + 1 }, () => new Array<number>(k + 1).fill(Infinity));
193
+ const from = Array.from({ length: n + 1 }, () => new Array<number>(k + 1).fill(-1));
194
+ best[0][0] = 0;
195
+ for (let c = 1; c <= k; c++)
196
+ for (let j = 1; j <= n; j++)
197
+ for (let i = c - 1; i < j; i++) {
198
+ if (!Number.isFinite(best[i][c - 1])) continue;
199
+ const p = part(i, j);
200
+ if (!wrapCaption(p, maxEm, emWidth, lines)) continue;
201
+ const cost = best[i][c - 1] + Math.abs(emWidth(p) - ideal) / ideal + (j < n && looseEnd(words[j - 1]) ? 0.6 : 0);
202
+ if (cost < best[j][c]) {
203
+ best[j][c] = cost;
204
+ from[j][c] = i;
205
+ }
206
+ }
207
+ if (!Number.isFinite(best[n][k])) continue;
208
+ const parts: string[] = [];
209
+ for (let j = n, c = k; c > 0; c--) {
210
+ const i = from[j][c];
211
+ parts.unshift(part(i, j));
212
+ j = i;
213
+ }
214
+ return parts;
215
+ }
216
+ return words;
217
+ }
218
+
219
+ // 한 줄 반에 못 미치는 짧은 마디(「MLA,」)는 다음 마디에 붙인다 — 0.5초짜리 자막은 읽기 전에 사라진다
220
+ function merge(parts: string[], maxEm: number, lines = 2): string[] {
221
+ const out: string[] = [];
222
+ for (const p of parts) {
223
+ const prev = out[out.length - 1];
224
+ if (prev !== undefined && emWidth(prev) < 4 && wrapCaption(`${prev}, ${p}`, maxEm, emWidth, lines)) out[out.length - 1] = `${prev}, ${p}`;
225
+ else out.push(p);
226
+ }
227
+ return out;
228
+ }
229
+
230
+ // 소리 길이는 띄어쓰기 뺀 글자 수에 가깝다
231
+ const spoken = (s: string) => Math.max(1, s.replace(/\s/g, "").length);
232
+
233
+ function spread(texts: string[], from: number, to: number, cues: Cue[]): void {
234
+ const total = texts.reduce((a, p) => a + spoken(p), 0);
235
+ let t = from;
236
+ for (const p of texts) {
237
+ const d = ((to - from) * spoken(p)) / total;
238
+ cues.push({ from: t, to: t + d, text: p });
239
+ t += d;
240
+ }
241
+ }
242
+
243
+ function fromWords(raw: CaptionWord[], maxEm: number, cues: Cue[], lines = 2): number {
244
+ const before = cues.length;
245
+ const words = raw
246
+ .map((w) => ({ text: (w.text ?? w.w ?? "").trim(), from: w.from ?? w.start ?? NaN, to: w.to ?? w.end ?? NaN }))
247
+ .filter((w) => w.text && Number.isFinite(w.from) && Number.isFinite(w.to));
248
+ let cur: typeof words = [];
249
+ const flush = () => {
250
+ if (!cur.length) return;
251
+ cues.push({ from: cur[0].from, to: cur[cur.length - 1].to, text: splitPhrases(cur.map((w) => w.text).join(" ")).join(", ") });
252
+ cur = [];
253
+ };
254
+ for (const w of words) {
255
+ if (cur.length && !wrapCaption([...cur, w].map((x) => x.text).join(" "), maxEm, emWidth, lines)) flush();
256
+ cur.push(w);
257
+ if (/[.?!。]$/.test(w.text)) flush();
258
+ }
259
+ flush();
260
+ return cues.length - before;
261
+ }
262
+
263
+ /**
264
+ * captions.json 줄 → 시각 붙은 자막 마디. 장면에만 묶인 줄은 쉼표·마침표 마디로 나눠 글자 수 비율로 장면 시간에 펴고,
265
+ * from·to 가 있으면 그 안에, words 가 있으면 낱말 시각 그대로. maxEm(captionMetrics) 을 주면 마디마다 두 줄 안에 들게 더 나눈다.
266
+ */
267
+ export function toCues(lines: CaptionLine[], scenes: Span[], o: { maxEm?: number; duration?: number; maxLines?: number } = {}): Cue[] {
268
+ const maxEm = o.maxEm ?? Infinity;
269
+ const rows = o.maxLines ?? 2;
270
+ const cues: Cue[] = [];
271
+ for (const l of lines) {
272
+ const text = (l.text ?? "").trim();
273
+ if (l.words?.length && fromWords(l.words, maxEm, cues, rows) > 0) continue;
274
+ if (!text) continue;
275
+ let from: number;
276
+ let to: number;
277
+ if (l.from !== undefined && l.to !== undefined) {
278
+ from = l.from;
279
+ to = l.to;
280
+ } else {
281
+ const s = scenes.find((x) => x.id === l.scene);
282
+ if (!s) continue;
283
+ const pad = Math.min(0.25, s.dur * 0.05);
284
+ from = s.from + pad;
285
+ to = s.from + s.dur - pad;
286
+ }
287
+ const parts = merge(splitPhrases(text).flatMap((p) => chunk(p, maxEm, rows)), maxEm, rows);
288
+ spread(parts, from, to, cues);
289
+ }
290
+ cues.sort((a, b) => a.from - b.from);
291
+ for (let i = 0; i < cues.length; i++) {
292
+ const c = cues[i];
293
+ const next = cues[i + 1];
294
+ // 말 사이 짧은 숨에 자막이 깜빡이지 않게 다음 자막까지 붙여 남긴다 — 틈이 한 프레임이라도 있으면 판이 꺼졌다 켜진다
295
+ if (next && next.from - c.to < 0.6) c.to = next.from;
296
+ // 한두 프레임짜리 자막은 읽히지도 않고 검사에 깜빡임(frame.spike)으로 잡힌다
297
+ if (c.to - c.from < 0.3) c.to = next ? Math.min(next.from, c.from + 0.3) : c.from + 0.3;
298
+ if (o.duration !== undefined) c.to = Math.min(c.to, o.duration);
299
+ }
300
+ return cues.filter((c) => c.to > c.from);
301
+ }
302
+
303
+ export interface CaptionLayerOptions {
304
+ cues: Cue[];
305
+ width: number;
306
+ height: number;
307
+ safe?: SafeArea | null;
308
+ style?: KfStyle | null;
309
+ minPx?: number;
310
+ }
311
+
312
+ interface Laid {
313
+ cue: Cue;
314
+ lines: string[];
315
+ fontPx: number;
316
+ }
317
+
318
+ const FADE = 0.12;
319
+
320
+ // 판 색은 테마의 어두운 쪽, 글은 밝은 쪽 — 어느 장면 위에서도 대비 3:1 을 넘게 판을 거의 불투명하게
321
+ function palette(style: KfStyle | null | undefined): { plate: string; ink: string; edge: string } {
322
+ const c = style?.colors;
323
+ if (!c) return { plate: "rgba(8, 10, 14, 0.86)", ink: "#ffffff", edge: "transparent" };
324
+ const [dark, light] = style!.scheme === "light" ? [c.ink, c.bg] : [c.bg, c.ink];
325
+ return { plate: `color-mix(in srgb, ${dark} 86%, transparent)`, ink: light, edge: c.accent };
326
+ }
327
+
328
+ /**
329
+ * 화면 자막 판을 짓고 t 마다 칠하는 함수(같은 t 면 같은 화면)와 판이 차지할 띠 높이를 돌려준다. 글꼴이 다 받아진 뒤에 불러야 줄바꿈 폭이 맞는다.
330
+ * 판은 안전 영역 아래쪽 가운데, 한 마디는 두 줄까지 — 넘치면 글자를 줄인다.
331
+ */
332
+ export async function captionLayer(host: HTMLElement, o: CaptionLayerOptions): Promise<{ draw: (t: number) => void; band: number }> {
333
+ const m = captionMetrics({ ...o, look: o.style?.captions });
334
+ const fam = o.style?.fonts?.sans ?? '"Pretendard Variable", Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", sans-serif';
335
+ const weight = 700;
336
+ const font = (px: number) => `${weight} ${px}px ${fam}`;
337
+ // unicode-range 로 쪼갠 글꼴이면 처음 나오는 글자 조각을 그 프레임에 받느라 투명하게 찍힌다 — 쓸 글자를 미리 다 받는다
338
+ const all = [...new Set(o.cues.map((c) => c.text).join(""))].join("");
339
+ if (all) await document.fonts.load(font(m.fontPx), all).catch(() => {});
340
+ const ctx2d = document.createElement("canvas").getContext("2d")!;
341
+ const measure = (px: number) => (s: string) => {
342
+ ctx2d.font = font(px);
343
+ return ctx2d.measureText(s).width;
344
+ };
345
+ const laid: Laid[] = o.cues.map((cue) => {
346
+ for (let px = m.fontPx; px >= Math.round(m.fontPx * 0.8); px--) {
347
+ const lines = wrapCaption(cue.text, m.textMaxPx, measure(px), m.maxLines);
348
+ if (lines) return { cue, lines, fontPx: px };
349
+ }
350
+ const px = Math.round(m.fontPx * 0.8);
351
+ return { cue, lines: wrapCaption(cue.text, m.textMaxPx, measure(px), m.maxLines + 1) ?? [cue.text], fontPx: px };
352
+ });
353
+
354
+ const { plate: plateBg, ink, edge } = palette(o.style);
355
+ const layer = document.createElement("div");
356
+ layer.dataset.kfCaptions = "on";
357
+ layer.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:50;";
358
+ const plate = document.createElement("div");
359
+ const radius = Math.min(o.style?.radius?.md ?? 10, Math.round(m.fontPx * 0.35));
360
+ plate.style.cssText = [
361
+ "position:absolute",
362
+ `left:${m.centerX}px`,
363
+ ...(m.centerY !== undefined ? [`top:${m.centerY}px`, "transform:translate(-50%, -50%)"] : [`bottom:${o.height - m.bottom}px`, "transform:translateX(-50%)"]),
364
+ `max-width:${m.textMaxPx + m.padX * 2}px`,
365
+ "box-sizing:border-box",
366
+ `padding:${m.padY}px ${m.padX}px`,
367
+ `background:${plateBg}`,
368
+ `color:${ink}`,
369
+ `border-radius:${radius}px`,
370
+ `box-shadow:inset 0 -${Math.max(2, Math.round(m.fontPx * 0.06))}px 0 ${edge}`,
371
+ `font-family:${fam}`,
372
+ `font-weight:${weight}`,
373
+ `line-height:${m.lineHeight}`,
374
+ "letter-spacing:-0.01em",
375
+ "text-align:center",
376
+ "white-space:nowrap",
377
+ "word-break:keep-all",
378
+ ].join(";");
379
+ plate.hidden = true;
380
+ layer.append(plate);
381
+ host.append(layer);
382
+
383
+ let shown = -1;
384
+ const band = captionBand(m, Math.max(1, ...laid.map((l) => l.lines.length)));
385
+ const draw = (t: number) => {
386
+ let i = -1;
387
+ for (let k = 0; k < laid.length; k++) {
388
+ if (laid[k].cue.from > t) break;
389
+ if (t < laid[k].cue.to) i = k;
390
+ }
391
+ if (i < 0) {
392
+ plate.hidden = true;
393
+ shown = -1;
394
+ return;
395
+ }
396
+ const { cue, lines, fontPx } = laid[i];
397
+ if (i !== shown) {
398
+ shown = i;
399
+ plate.style.fontSize = `${fontPx}px`;
400
+ plate.replaceChildren(
401
+ ...lines.map((l) => {
402
+ const s = document.createElement("div");
403
+ s.textContent = l;
404
+ return s;
405
+ }),
406
+ );
407
+ }
408
+ plate.hidden = false;
409
+ // 바로 이어지는 마디끼리는 글만 바꾼다. 앞뒤가 빈 자리에서만 서서히 켜고 끈다
410
+ const joinedIn = i > 0 && laid[i - 1].cue.to >= cue.from - 1e-6;
411
+ const joinedOut = i < laid.length - 1 && laid[i + 1].cue.from <= cue.to + 1e-6;
412
+ const a = Math.min(joinedIn ? 1 : (t - cue.from) / FADE, joinedOut ? 1 : (cue.to - t) / FADE, 1);
413
+ plate.style.opacity = String(Math.max(0, a));
414
+ };
415
+ return { draw, band };
416
+ }
417
+
418
+ /**
419
+ * 자막 띠 자리를 비운다 — 장면을 하나도 고치지 않고, 안전 영역(위 끝 ~ 아래 끝)에 있던 내용을 (위 끝 ~ 띠 위)로 한 배율로 줄여 올린다.
420
+ * 장면이 ctx.safe 를 쓰든 절대 좌표로 놓든, React·캔버스든 똑같이 올라간다. 줄어 남는 가장자리는 테마 바탕색.
421
+ */
422
+ export function liftScenes(stage: HTMLElement, o: { width: number; height: number; safe?: SafeArea | null; band: number }): number {
423
+ const safe = o.safe ?? { top: 0, bottom: 0, left: 0, right: 0 };
424
+ const top = safe.top;
425
+ const room = o.height - safe.bottom - top;
426
+ const k = Math.max(0.5, (room - o.band) / room);
427
+ stage.style.background = "var(--kf-bg, #000)";
428
+ // 줄어든 장면의 가장자리(모눈·바탕 무늬)가 칼로 자른 듯 끊기지 않게 바탕색으로 녹인다 — 녹는 폭은 안전 영역 밖이라 글은 안 닿는다
429
+ const fade = Math.round(Math.min(o.width, o.height) * 0.06 / k);
430
+ const edge = (dir: string) => `linear-gradient(${dir}, transparent, #000 ${fade}px, #000 calc(100% - ${fade}px), transparent)`;
431
+ for (const r of stage.querySelectorAll<HTMLElement>(":scope > [data-kf-scene]")) {
432
+ r.style.transformOrigin = `${o.width / 2}px ${top}px`;
433
+ r.style.transform = `scale(${k})`;
434
+ r.style.maskImage = `${edge("to right")}, ${edge("to bottom")}`;
435
+ r.style.maskComposite = "intersect";
436
+ }
437
+ return k;
438
+ }
439
+
440
+ interface ProjectLike {
441
+ width: number;
442
+ height: number;
443
+ duration?: number;
444
+ captions?: unknown;
445
+ safeArea?: SafeArea;
446
+ text?: { minPx?: number };
447
+ scenes: Span[];
448
+ }
449
+
450
+ export interface CaptionsPlan {
451
+ /** 장면에 줄 안전 영역 — on 이면 아래쪽에 자막 띠를 더 뺀다(무대가 그 위로 배치한다). fit 이면 그대로 */
452
+ safe: SafeArea;
453
+ /** 장면을 다 지은 뒤 부른다 — 판을 얹고(fit 이면 장면을 띠 위로 줄여 올리고) t 마다 칠하는 함수를 돌려준다 */
454
+ mount(stage: HTMLElement, style?: KfStyle | null): Promise<(t: number) => void>;
455
+ }
456
+
457
+ /**
458
+ * mountProject 가 장면을 짓기 전에 부른다 — 주소 ?captions=on|off|fit 가 kf.json 의 captions 를 그 판만 덮는다.
459
+ * 자막이 켜져 있고 captions.json 에 들릴 말이 있으면 띠를 뺀 안전 영역과 판 짓기를, 아니면 null.
460
+ * on: 무대(ctx.safe 로 배치하는 장면)가 띠 위로 제 크기 그대로 올라간다. 픽셀을 박아 둔 장면은 안 움직여 kf check 가 caption.overlap 으로 잡는다.
461
+ * fit: 장면 코드와 안전 영역은 그대로 두고 장면 뿌리를 띠 위로 줄여 올린다 — 어떤 장면이든 안 겹치지만 내용이 작아지고 움직이는 면적도 준다.
462
+ */
463
+ export async function captionsPlan(o: { kf: ProjectLike; mode?: string | null; style?: KfStyle | null }): Promise<CaptionsPlan | null> {
464
+ const raw = o.mode ?? o.kf.captions;
465
+ if (captionMode(raw) !== "on") return null;
466
+ const res = await fetch(new URL("captions.json", document.baseURI)).catch(() => null);
467
+ if (!res?.ok) return null;
468
+ const lines = (await res.json().catch(() => null)) as CaptionLine[] | null;
469
+ if (!Array.isArray(lines)) return null;
470
+ const { kf } = o;
471
+ const minPx = kf.text?.minPx;
472
+ const base = kf.safeArea ?? { top: 0, bottom: 0, left: 0, right: 0 };
473
+ const m = captionMetrics({ width: kf.width, height: kf.height, safe: base, minPx, look: o.style?.captions });
474
+ const cues = toCues(lines, kf.scenes, { maxEm: m.maxEm, duration: kf.duration, maxLines: m.maxLines });
475
+ if (!cues.length) return null;
476
+ const fit = raw === "fit";
477
+ // 띠는 판이 실제로 쓸 줄 수만큼(어림) — 장면을 짓기 전이라 아직 글꼴로 잴 수 없다
478
+ const band = captionBand(m, cues.some((c) => !wrapCaption(c.text, m.maxEm, emWidth, 1)) ? m.maxLines : 1);
479
+ return {
480
+ safe: fit ? base : { ...base, bottom: base.bottom + band },
481
+ async mount(stage, style) {
482
+ const { draw, band: used } = await captionLayer(stage, { cues, width: kf.width, height: kf.height, safe: base, style, minPx });
483
+ if (fit) liftScenes(stage, { width: kf.width, height: kf.height, safe: base, band: used });
484
+ return draw;
485
+ },
486
+ };
487
+ }
@@ -0,0 +1,128 @@
1
+ import { clamp, emit, progress, rand, spring, toEase, type Ease, type EaseName, type Style, safeTime } from "@kasaframe/core";
2
+
3
+ export type MoveName = "fade" | "rise" | "drop" | "pop" | "blurIn" | "scaleIn" | "maskWipe" | "slide";
4
+ export type Side = "left" | "right" | "top" | "bottom";
5
+
6
+ export interface MoveOpts {
7
+ /** 시작 시각(초, 장면 안) */
8
+ at?: number;
9
+ /** 길이(초) */
10
+ dur?: number;
11
+ ease?: EaseName | Ease;
12
+ /** 움직이는 거리(px) — rise·drop·slide */
13
+ distance?: number;
14
+ /** slide·maskWipe 가 들어오는 쪽 */
15
+ from?: Side;
16
+ /** 이벤트 기록 — 같은 등장 움직임 되풀이를 검사가 센다 */
17
+ source?: string;
18
+ scene?: string;
19
+ emit?: boolean;
20
+ }
21
+
22
+ export const MOVES: readonly MoveName[] = ["fade", "rise", "drop", "pop", "blurIn", "scaleIn", "maskWipe", "slide"];
23
+
24
+ const DEFAULT_DUR: Record<MoveName, number> = {
25
+ fade: 0.4,
26
+ rise: 0.5,
27
+ drop: 0.5,
28
+ pop: 0.55,
29
+ blurIn: 0.6,
30
+ scaleIn: 0.5,
31
+ maskWipe: 0.6,
32
+ slide: 0.5,
33
+ };
34
+
35
+ /** p 는 0(안 보임) → 1(제자리). 등장과 퇴장이 같은 모양을 반대로 쓴다 */
36
+ function shape(name: MoveName, p: number, e: number, local: number, o: MoveOpts): Style {
37
+ const d = o.distance;
38
+ switch (name) {
39
+ case "fade":
40
+ return { opacity: e };
41
+ case "rise":
42
+ return { opacity: clamp(p * 1.6), y: (1 - e) * (d ?? 28) };
43
+ case "drop":
44
+ return { opacity: clamp(p * 1.6), y: -(1 - e) * (d ?? 28) };
45
+ case "pop": {
46
+ // 스프링이 닫힌 식이라 퇴장(시간을 거꾸로)에도 같은 값이 나온다
47
+ const s = local <= 0 ? 0.6 : spring(local, { from: 0.6, to: 1, stiffness: 320, damping: 17 });
48
+ return { opacity: clamp(p * 3), scale: p >= 1 ? 1 : s };
49
+ }
50
+ case "blurIn":
51
+ return { opacity: e, blur: (1 - e) * 18, scale: 1.04 - 0.04 * e };
52
+ case "scaleIn":
53
+ return { opacity: e, scale: 0.86 + 0.14 * e };
54
+ case "maskWipe": {
55
+ const side = o.from ?? "left";
56
+ const hide = 1 - e;
57
+ const opposite = { left: "right", right: "left", top: "bottom", bottom: "top" } as const;
58
+ return { clip: { [opposite[side]]: hide } };
59
+ }
60
+ case "slide": {
61
+ const side = o.from ?? "left";
62
+ const dist = d ?? 120;
63
+ const dx = side === "left" ? -dist : side === "right" ? dist : 0;
64
+ const dy = side === "top" ? -dist : side === "bottom" ? dist : 0;
65
+ return { opacity: clamp(p * 2), x: (1 - e) * dx, y: (1 - e) * dy };
66
+ }
67
+ }
68
+ }
69
+
70
+ function check(name: string): MoveName {
71
+ if (!(MOVES as readonly string[]).includes(name)) throw new Error(`모르는 움직임: ${name} (있는 것: ${MOVES.join(" · ")})`);
72
+ return name as MoveName;
73
+ }
74
+
75
+ export function enter(name: MoveName, t: number, opts: MoveOpts = {}): Style {
76
+ t = safeTime(t);
77
+ const n = check(name);
78
+ const at = opts.at ?? 0;
79
+ const dur = opts.dur ?? DEFAULT_DUR[n];
80
+ const p = progress(t, at, dur);
81
+ const e = toEase(opts.ease, n === "pop" ? (x) => x : undefined)(p);
82
+ if (opts.emit !== false && p > 0 && p < 1) emit({ kind: "move", source: opts.source ?? n, scene: opts.scene, name: `enter:${n}`, value: 1 });
83
+ return shape(n, p, e, t - at, opts);
84
+ }
85
+
86
+ /** 퇴장 — at 에서 시작해 dur 뒤에 사라져 있다 */
87
+ export function exit(name: MoveName, t: number, opts: MoveOpts = {}): Style {
88
+ t = safeTime(t);
89
+ const n = check(name);
90
+ const at = opts.at ?? 0;
91
+ const dur = opts.dur ?? DEFAULT_DUR[n] * 0.7;
92
+ const p = 1 - progress(t, at, dur);
93
+ const e = toEase(opts.ease, (x) => x * x)(p);
94
+ // 스프링 튐은 들어올 때만 — 나갈 때 튀면 떨림으로 보인다
95
+ return shape(n === "pop" ? "scaleIn" : n, p, e, 0, opts);
96
+ }
97
+
98
+ /** 들어와 머물다 나간다. out 이 없으면 나가지 않는다 */
99
+ export function inOut(name: MoveName, t: number, opts: MoveOpts & { out?: number; exitAs?: MoveName; outDur?: number } = {}): Style {
100
+ t = safeTime(t);
101
+ const a = enter(name, t, opts);
102
+ if (opts.out === undefined || t < opts.out) return a;
103
+ return exit(opts.exitAs ?? name, t, { ...opts, at: opts.out, dur: opts.outDur, emit: false });
104
+ }
105
+
106
+ export interface StaggerOpts {
107
+ each?: number;
108
+ /** 어디서부터 번지나 — MoveOpts.from(들어오는 쪽)과 이름이 겹치지 않게 origin */
109
+ origin?: "start" | "end" | "center" | "random";
110
+ count?: number;
111
+ seed?: number | string;
112
+ }
113
+
114
+ /** i 번째 항목의 늦춤(초) */
115
+ export function stagger(i: number, opts: StaggerOpts = {}): number {
116
+ const each = opts.each ?? 0.06;
117
+ const n = opts.count ?? i + 1;
118
+ switch (opts.origin ?? "start") {
119
+ case "start":
120
+ return i * each;
121
+ case "end":
122
+ return (n - 1 - i) * each;
123
+ case "center":
124
+ return Math.abs(i - (n - 1) / 2) * each;
125
+ case "random":
126
+ return rand(opts.seed ?? "stagger", i) * (n - 1) * each;
127
+ }
128
+ }