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,479 @@
1
+ import type { 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
+
66
+ /** 판 높이(px) — 줄 수만큼 */
67
+ export function plateHeight(m: CaptionMetrics, lines: number): number {
68
+ return Math.round(m.fontPx * m.lineHeight * lines + m.padY * 2);
69
+ }
70
+
71
+ /** 자막 띠 — 안전 영역 아래쪽에서 장면이 비워 줄 높이(px). 판 + 위아래 여백 */
72
+ export function captionBand(m: CaptionMetrics, lines = 2): number {
73
+ return plateHeight(m, lines) + m.gap * 2;
74
+ }
75
+
76
+ export function captionMetrics(o: { width: number; height: number; safe?: SafeArea | null; minPx?: number }): CaptionMetrics {
77
+ const safe = o.safe ?? { top: 0, bottom: 0, left: 0, right: 0 };
78
+ const short = Math.min(o.width, o.height);
79
+ const vertical = o.height > o.width;
80
+ // 세로 쇼츠는 폰 한 손 거리에서 읽혀야 한다 — 폭의 8%(1080 폭이면 86px). 가로는 높이의 5%
81
+ const fontPx = Math.max(o.minPx ?? 0, Math.round(vertical ? o.width * 0.08 : short * 0.05));
82
+ const padX = Math.round(fontPx * 0.5);
83
+ const padY = Math.round(fontPx * 0.2);
84
+ const gap = Math.round(short * 0.02);
85
+ const room = o.width - safe.left - safe.right - Math.round(short * 0.04);
86
+ // 가로 화면은 한 줄이 너무 길면 눈이 따라가지 못한다 — 화면 폭 2/3 까지
87
+ const plateMax = vertical ? room : Math.min(room, Math.round(o.width * 0.66));
88
+ const textMaxPx = plateMax - padX * 2;
89
+ return {
90
+ fontPx,
91
+ lineHeight: 1.28,
92
+ padX,
93
+ padY,
94
+ textMaxPx,
95
+ maxEm: textMaxPx / fontPx,
96
+ bottom: o.height - safe.bottom - gap,
97
+ centerX: safe.left + (o.width - safe.left - safe.right) / 2,
98
+ gap,
99
+ };
100
+ }
101
+
102
+ // 글꼴 없이 어림하는 폭(em). 한글·한자는 정사각에 가깝고 라틴은 좁다. 어림이 실제보다 넓게 나와야 페이지에서 잰 줄바꿈이 두 줄을 넘지 않는다
103
+ export function emWidth(s: string): number {
104
+ let w = 0;
105
+ for (const ch of s) {
106
+ const c = ch.codePointAt(0)!;
107
+ if (ch === " ") w += 0.3;
108
+ else if ((c >= 0x1100 && c <= 0x11ff) || (c >= 0x3000 && c <= 0x9fff) || (c >= 0xac00 && c <= 0xd7af) || (c >= 0xff00 && c <= 0xffef)) w += 1;
109
+ else if (/[0-9]/.test(ch)) w += 0.62;
110
+ else if (/[A-Z]/.test(ch)) w += 0.72;
111
+ else if (/[a-z]/.test(ch)) w += 0.58;
112
+ else if (/[.,:;'"!?·]/.test(ch)) w += 0.34;
113
+ else w += 0.62;
114
+ }
115
+ return w;
116
+ }
117
+
118
+ // 한글로 끝나는데 조사·어미가 아닌 어절(「잠재」「512차원」) — 그 뒤에서 끊으면 말이 갈린다
119
+ const ENDINGS = new Set([..."은는이가을를에서로와과도만의께랑엔고며면니데해게지요다까죠라여야듯록나서"]);
120
+ function looseEnd(word: string): boolean {
121
+ const w = word.replace(/[^\p{L}\p{N}]+$/u, "");
122
+ const last = [...w].pop() ?? "";
123
+ // 「3만 2768」「57분의 1」 — 수를 세는 말 뒤는 조사처럼 보여도 수가 이어진다
124
+ if (/\d[만천억조]$|\d분의$/.test(w)) return true;
125
+ return /[가-힣]/.test(last) && !ENDINGS.has(last);
126
+ }
127
+
128
+ /**
129
+ * 어절(띄어쓰기) 단위로 두 줄까지 나눈다. 한 줄에 들면 한 줄, 아니면 두 줄 폭이 가장 고른 자리에서 끊는다.
130
+ * 두 줄로도 안 들면 null — 마디를 더 잘게 나눠야 한다. measure 는 글 폭(px 든 em 이든 maxWidth 와 같은 단위).
131
+ */
132
+ export function wrapCaption(text: string, maxWidth: number, measure: (s: string) => number = emWidth, maxLines = 2): string[] | null {
133
+ const t = text.trim().replace(/\s+/g, " ");
134
+ if (measure(t) <= maxWidth) return [t];
135
+ const words = t.split(" ");
136
+ if (maxLines < 2 || words.length < 2) return null;
137
+ let best: string[] | null = null;
138
+ let bestW = Infinity;
139
+ for (let i = 1; i < words.length; i++) {
140
+ const a = words.slice(0, i).join(" ");
141
+ const b = words.slice(i).join(" ");
142
+ const wa = measure(a);
143
+ const wb = measure(b);
144
+ if (wa > maxWidth) break;
145
+ if (wb > maxWidth) continue;
146
+ // 위 줄이 조금 짧은 쪽(역삼각형)을 같은 폭보다 살짝 낫게 본다 — 한국 방송 자막 관례.
147
+ // 조사·어미로 끝난 어절 뒤에서 끊는다 — 「잠재 / 공간」처럼 한 덩어리 말을 가르면 폭이 고르더라도 읽다가 걸린다
148
+ const score = Math.max(wa, wb) + (wa > wb ? 0.01 * maxWidth : 0) + (looseEnd(words[i - 1]) ? 0.12 * maxWidth : 0);
149
+ if (score < bestW) {
150
+ bestW = score;
151
+ best = [a, b];
152
+ }
153
+ }
154
+ return best;
155
+ }
156
+
157
+ // 쉼표·마침표 뒤에서 끊는다. 숫자 사이 쉼표·점(1,000 · 3.5)은 끊지 않는다. 쉼표 뒤 띄어쓰기가 없어도 끊는다
158
+ export function splitPhrases(text: string): string[] {
159
+ const out: string[] = [];
160
+ let cur = "";
161
+ const chars = [...text];
162
+ for (let i = 0; i < chars.length; i++) {
163
+ const ch = chars[i];
164
+ cur += ch;
165
+ if (!/[,.?!。、]/.test(ch)) continue;
166
+ if ((ch === "," || ch === ".") && /\d/.test(chars[i - 1] ?? "") && /\d/.test(chars[i + 1] ?? "")) continue;
167
+ if (/[.?!。]/.test(chars[i + 1] ?? "")) continue;
168
+ out.push(cur);
169
+ cur = "";
170
+ }
171
+ if (cur.trim()) out.push(cur);
172
+ return out.map((p) => p.trim().replace(/[,、]$/, "").replace(/(?<!\.)\.$/, "").trim()).filter(Boolean);
173
+ }
174
+
175
+ // 두 줄에 안 드는 마디는 어절 경계에서 몇 토막으로 나눈다 — 토막 길이는 고르게, 끝은 조사·어미 뒤에서(「흡수해 잠재 / 공간에서」처럼 덩어리를 가르지 않게)
176
+ function chunk(text: string, maxEm: number): string[] {
177
+ if (wrapCaption(text, maxEm)) return [text];
178
+ const words = text.split(/\s+/).filter(Boolean);
179
+ const n = words.length;
180
+ const part = (i: number, j: number) => words.slice(i, j).join(" ");
181
+ for (let k = 2; k <= n; k++) {
182
+ const ideal = emWidth(text) / k;
183
+ // best[j][c] = 앞 j 어절을 c 토막으로 나눈 가장 싼 값
184
+ const best = Array.from({ length: n + 1 }, () => new Array<number>(k + 1).fill(Infinity));
185
+ const from = Array.from({ length: n + 1 }, () => new Array<number>(k + 1).fill(-1));
186
+ best[0][0] = 0;
187
+ for (let c = 1; c <= k; c++)
188
+ for (let j = 1; j <= n; j++)
189
+ for (let i = c - 1; i < j; i++) {
190
+ if (!Number.isFinite(best[i][c - 1])) continue;
191
+ const p = part(i, j);
192
+ if (!wrapCaption(p, maxEm)) continue;
193
+ const cost = best[i][c - 1] + Math.abs(emWidth(p) - ideal) / ideal + (j < n && looseEnd(words[j - 1]) ? 0.6 : 0);
194
+ if (cost < best[j][c]) {
195
+ best[j][c] = cost;
196
+ from[j][c] = i;
197
+ }
198
+ }
199
+ if (!Number.isFinite(best[n][k])) continue;
200
+ const parts: string[] = [];
201
+ for (let j = n, c = k; c > 0; c--) {
202
+ const i = from[j][c];
203
+ parts.unshift(part(i, j));
204
+ j = i;
205
+ }
206
+ return parts;
207
+ }
208
+ return words;
209
+ }
210
+
211
+ // 한 줄 반에 못 미치는 짧은 마디(「MLA,」)는 다음 마디에 붙인다 — 0.5초짜리 자막은 읽기 전에 사라진다
212
+ function merge(parts: string[], maxEm: number): string[] {
213
+ const out: string[] = [];
214
+ for (const p of parts) {
215
+ const prev = out[out.length - 1];
216
+ if (prev !== undefined && emWidth(prev) < 4 && wrapCaption(`${prev}, ${p}`, maxEm)) out[out.length - 1] = `${prev}, ${p}`;
217
+ else out.push(p);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ // 소리 길이는 띄어쓰기 뺀 글자 수에 가깝다
223
+ const spoken = (s: string) => Math.max(1, s.replace(/\s/g, "").length);
224
+
225
+ function spread(texts: string[], from: number, to: number, cues: Cue[]): void {
226
+ const total = texts.reduce((a, p) => a + spoken(p), 0);
227
+ let t = from;
228
+ for (const p of texts) {
229
+ const d = ((to - from) * spoken(p)) / total;
230
+ cues.push({ from: t, to: t + d, text: p });
231
+ t += d;
232
+ }
233
+ }
234
+
235
+ function fromWords(raw: CaptionWord[], maxEm: number, cues: Cue[]): number {
236
+ const before = cues.length;
237
+ const words = raw
238
+ .map((w) => ({ text: (w.text ?? w.w ?? "").trim(), from: w.from ?? w.start ?? NaN, to: w.to ?? w.end ?? NaN }))
239
+ .filter((w) => w.text && Number.isFinite(w.from) && Number.isFinite(w.to));
240
+ let cur: typeof words = [];
241
+ const flush = () => {
242
+ if (!cur.length) return;
243
+ cues.push({ from: cur[0].from, to: cur[cur.length - 1].to, text: splitPhrases(cur.map((w) => w.text).join(" ")).join(", ") });
244
+ cur = [];
245
+ };
246
+ for (const w of words) {
247
+ if (cur.length && !wrapCaption([...cur, w].map((x) => x.text).join(" "), maxEm)) flush();
248
+ cur.push(w);
249
+ if (/[.?!。]$/.test(w.text)) flush();
250
+ }
251
+ flush();
252
+ return cues.length - before;
253
+ }
254
+
255
+ /**
256
+ * captions.json 줄 → 시각 붙은 자막 마디. 장면에만 묶인 줄은 쉼표·마침표 마디로 나눠 글자 수 비율로 장면 시간에 펴고,
257
+ * from·to 가 있으면 그 안에, words 가 있으면 낱말 시각 그대로. maxEm(captionMetrics) 을 주면 마디마다 두 줄 안에 들게 더 나눈다.
258
+ */
259
+ export function toCues(lines: CaptionLine[], scenes: Span[], o: { maxEm?: number; duration?: number } = {}): Cue[] {
260
+ const maxEm = o.maxEm ?? Infinity;
261
+ const cues: Cue[] = [];
262
+ for (const l of lines) {
263
+ const text = (l.text ?? "").trim();
264
+ if (l.words?.length && fromWords(l.words, maxEm, cues) > 0) continue;
265
+ if (!text) continue;
266
+ let from: number;
267
+ let to: number;
268
+ if (l.from !== undefined && l.to !== undefined) {
269
+ from = l.from;
270
+ to = l.to;
271
+ } else {
272
+ const s = scenes.find((x) => x.id === l.scene);
273
+ if (!s) continue;
274
+ const pad = Math.min(0.25, s.dur * 0.05);
275
+ from = s.from + pad;
276
+ to = s.from + s.dur - pad;
277
+ }
278
+ const parts = merge(splitPhrases(text).flatMap((p) => chunk(p, maxEm)), maxEm);
279
+ spread(parts, from, to, cues);
280
+ }
281
+ cues.sort((a, b) => a.from - b.from);
282
+ for (let i = 0; i < cues.length; i++) {
283
+ const c = cues[i];
284
+ const next = cues[i + 1];
285
+ // 말 사이 짧은 숨에 자막이 깜빡이지 않게 다음 자막까지 붙여 남긴다 — 틈이 한 프레임이라도 있으면 판이 꺼졌다 켜진다
286
+ if (next && next.from - c.to < 0.6) c.to = next.from;
287
+ // 한두 프레임짜리 자막은 읽히지도 않고 검사에 깜빡임(frame.spike)으로 잡힌다
288
+ if (c.to - c.from < 0.3) c.to = next ? Math.min(next.from, c.from + 0.3) : c.from + 0.3;
289
+ if (o.duration !== undefined) c.to = Math.min(c.to, o.duration);
290
+ }
291
+ return cues.filter((c) => c.to > c.from);
292
+ }
293
+
294
+ export interface CaptionLayerOptions {
295
+ cues: Cue[];
296
+ width: number;
297
+ height: number;
298
+ safe?: SafeArea | null;
299
+ style?: KfStyle | null;
300
+ minPx?: number;
301
+ }
302
+
303
+ interface Laid {
304
+ cue: Cue;
305
+ lines: string[];
306
+ fontPx: number;
307
+ }
308
+
309
+ const FADE = 0.12;
310
+
311
+ // 판 색은 테마의 어두운 쪽, 글은 밝은 쪽 — 어느 장면 위에서도 대비 3:1 을 넘게 판을 거의 불투명하게
312
+ function palette(style: KfStyle | null | undefined): { plate: string; ink: string; edge: string } {
313
+ const c = style?.colors;
314
+ if (!c) return { plate: "rgba(8, 10, 14, 0.86)", ink: "#ffffff", edge: "transparent" };
315
+ const [dark, light] = style!.scheme === "light" ? [c.ink, c.bg] : [c.bg, c.ink];
316
+ return { plate: `color-mix(in srgb, ${dark} 86%, transparent)`, ink: light, edge: c.accent };
317
+ }
318
+
319
+ /**
320
+ * 화면 자막 판을 짓고 t 마다 칠하는 함수(같은 t 면 같은 화면)와 판이 차지할 띠 높이를 돌려준다. 글꼴이 다 받아진 뒤에 불러야 줄바꿈 폭이 맞는다.
321
+ * 판은 안전 영역 아래쪽 가운데, 한 마디는 두 줄까지 — 넘치면 글자를 줄인다.
322
+ */
323
+ export async function captionLayer(host: HTMLElement, o: CaptionLayerOptions): Promise<{ draw: (t: number) => void; band: number }> {
324
+ const m = captionMetrics(o);
325
+ const fam = o.style?.fonts?.sans ?? '"Pretendard Variable", Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", sans-serif';
326
+ const weight = 700;
327
+ const font = (px: number) => `${weight} ${px}px ${fam}`;
328
+ // unicode-range 로 쪼갠 글꼴이면 처음 나오는 글자 조각을 그 프레임에 받느라 투명하게 찍힌다 — 쓸 글자를 미리 다 받는다
329
+ const all = [...new Set(o.cues.map((c) => c.text).join(""))].join("");
330
+ if (all) await document.fonts.load(font(m.fontPx), all).catch(() => {});
331
+ const ctx2d = document.createElement("canvas").getContext("2d")!;
332
+ const measure = (px: number) => (s: string) => {
333
+ ctx2d.font = font(px);
334
+ return ctx2d.measureText(s).width;
335
+ };
336
+ const laid: Laid[] = o.cues.map((cue) => {
337
+ for (let px = m.fontPx; px >= Math.round(m.fontPx * 0.8); px--) {
338
+ const lines = wrapCaption(cue.text, m.textMaxPx, measure(px));
339
+ if (lines) return { cue, lines, fontPx: px };
340
+ }
341
+ const px = Math.round(m.fontPx * 0.8);
342
+ return { cue, lines: wrapCaption(cue.text, m.textMaxPx, measure(px), 3) ?? [cue.text], fontPx: px };
343
+ });
344
+
345
+ const { plate: plateBg, ink, edge } = palette(o.style);
346
+ const layer = document.createElement("div");
347
+ layer.dataset.kfCaptions = "on";
348
+ layer.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:50;";
349
+ const plate = document.createElement("div");
350
+ const radius = Math.min(o.style?.radius?.md ?? 10, Math.round(m.fontPx * 0.35));
351
+ plate.style.cssText = [
352
+ "position:absolute",
353
+ `left:${m.centerX}px`,
354
+ `bottom:${o.height - m.bottom}px`,
355
+ "transform:translateX(-50%)",
356
+ `max-width:${m.textMaxPx + m.padX * 2}px`,
357
+ "box-sizing:border-box",
358
+ `padding:${m.padY}px ${m.padX}px`,
359
+ `background:${plateBg}`,
360
+ `color:${ink}`,
361
+ `border-radius:${radius}px`,
362
+ `box-shadow:inset 0 -${Math.max(2, Math.round(m.fontPx * 0.06))}px 0 ${edge}`,
363
+ `font-family:${fam}`,
364
+ `font-weight:${weight}`,
365
+ `line-height:${m.lineHeight}`,
366
+ "letter-spacing:-0.01em",
367
+ "text-align:center",
368
+ "white-space:nowrap",
369
+ "word-break:keep-all",
370
+ ].join(";");
371
+ plate.hidden = true;
372
+ layer.append(plate);
373
+ host.append(layer);
374
+
375
+ let shown = -1;
376
+ const band = captionBand(m, Math.max(1, ...laid.map((l) => l.lines.length)));
377
+ const draw = (t: number) => {
378
+ let i = -1;
379
+ for (let k = 0; k < laid.length; k++) {
380
+ if (laid[k].cue.from > t) break;
381
+ if (t < laid[k].cue.to) i = k;
382
+ }
383
+ if (i < 0) {
384
+ plate.hidden = true;
385
+ shown = -1;
386
+ return;
387
+ }
388
+ const { cue, lines, fontPx } = laid[i];
389
+ if (i !== shown) {
390
+ shown = i;
391
+ plate.style.fontSize = `${fontPx}px`;
392
+ plate.replaceChildren(
393
+ ...lines.map((l) => {
394
+ const s = document.createElement("div");
395
+ s.textContent = l;
396
+ return s;
397
+ }),
398
+ );
399
+ }
400
+ plate.hidden = false;
401
+ // 바로 이어지는 마디끼리는 글만 바꾼다. 앞뒤가 빈 자리에서만 서서히 켜고 끈다
402
+ const joinedIn = i > 0 && laid[i - 1].cue.to >= cue.from - 1e-6;
403
+ const joinedOut = i < laid.length - 1 && laid[i + 1].cue.from <= cue.to + 1e-6;
404
+ const a = Math.min(joinedIn ? 1 : (t - cue.from) / FADE, joinedOut ? 1 : (cue.to - t) / FADE, 1);
405
+ plate.style.opacity = String(Math.max(0, a));
406
+ };
407
+ return { draw, band };
408
+ }
409
+
410
+ /**
411
+ * 자막 띠 자리를 비운다 — 장면을 하나도 고치지 않고, 안전 영역(위 끝 ~ 아래 끝)에 있던 내용을 (위 끝 ~ 띠 위)로 한 배율로 줄여 올린다.
412
+ * 장면이 ctx.safe 를 쓰든 절대 좌표로 놓든, React·캔버스든 똑같이 올라간다. 줄어 남는 가장자리는 테마 바탕색.
413
+ */
414
+ export function liftScenes(stage: HTMLElement, o: { width: number; height: number; safe?: SafeArea | null; band: number }): number {
415
+ const safe = o.safe ?? { top: 0, bottom: 0, left: 0, right: 0 };
416
+ const top = safe.top;
417
+ const room = o.height - safe.bottom - top;
418
+ const k = Math.max(0.5, (room - o.band) / room);
419
+ stage.style.background = "var(--kf-bg, #000)";
420
+ // 줄어든 장면의 가장자리(모눈·바탕 무늬)가 칼로 자른 듯 끊기지 않게 바탕색으로 녹인다 — 녹는 폭은 안전 영역 밖이라 글은 안 닿는다
421
+ const fade = Math.round(Math.min(o.width, o.height) * 0.06 / k);
422
+ const edge = (dir: string) => `linear-gradient(${dir}, transparent, #000 ${fade}px, #000 calc(100% - ${fade}px), transparent)`;
423
+ for (const r of stage.querySelectorAll<HTMLElement>(":scope > [data-kf-scene]")) {
424
+ r.style.transformOrigin = `${o.width / 2}px ${top}px`;
425
+ r.style.transform = `scale(${k})`;
426
+ r.style.maskImage = `${edge("to right")}, ${edge("to bottom")}`;
427
+ r.style.maskComposite = "intersect";
428
+ }
429
+ return k;
430
+ }
431
+
432
+ interface ProjectLike {
433
+ width: number;
434
+ height: number;
435
+ duration?: number;
436
+ captions?: unknown;
437
+ safeArea?: SafeArea;
438
+ text?: { minPx?: number };
439
+ scenes: Span[];
440
+ }
441
+
442
+ export interface CaptionsPlan {
443
+ /** 장면에 줄 안전 영역 — on 이면 아래쪽에 자막 띠를 더 뺀다(무대가 그 위로 배치한다). fit 이면 그대로 */
444
+ safe: SafeArea;
445
+ /** 장면을 다 지은 뒤 부른다 — 판을 얹고(fit 이면 장면을 띠 위로 줄여 올리고) t 마다 칠하는 함수를 돌려준다 */
446
+ mount(stage: HTMLElement, style?: KfStyle | null): Promise<(t: number) => void>;
447
+ }
448
+
449
+ /**
450
+ * mountProject 가 장면을 짓기 전에 부른다 — 주소 ?captions=on|off|fit 가 kf.json 의 captions 를 그 판만 덮는다.
451
+ * 자막이 켜져 있고 captions.json 에 들릴 말이 있으면 띠를 뺀 안전 영역과 판 짓기를, 아니면 null.
452
+ * on: 무대(ctx.safe 로 배치하는 장면)가 띠 위로 제 크기 그대로 올라간다. 픽셀을 박아 둔 장면은 안 움직여 kf check 가 caption.overlap 으로 잡는다.
453
+ * fit: 장면 코드와 안전 영역은 그대로 두고 장면 뿌리를 띠 위로 줄여 올린다 — 어떤 장면이든 안 겹치지만 내용이 작아지고 움직이는 면적도 준다.
454
+ */
455
+ export async function captionsPlan(o: { kf: ProjectLike; mode?: string | null }): Promise<CaptionsPlan | null> {
456
+ const raw = o.mode ?? o.kf.captions;
457
+ if (captionMode(raw) !== "on") return null;
458
+ const res = await fetch(new URL("captions.json", document.baseURI)).catch(() => null);
459
+ if (!res?.ok) return null;
460
+ const lines = (await res.json().catch(() => null)) as CaptionLine[] | null;
461
+ if (!Array.isArray(lines)) return null;
462
+ const { kf } = o;
463
+ const minPx = kf.text?.minPx;
464
+ const base = kf.safeArea ?? { top: 0, bottom: 0, left: 0, right: 0 };
465
+ const m = captionMetrics({ width: kf.width, height: kf.height, safe: base, minPx });
466
+ const cues = toCues(lines, kf.scenes, { maxEm: m.maxEm, duration: kf.duration });
467
+ if (!cues.length) return null;
468
+ const fit = raw === "fit";
469
+ // 띠는 판이 실제로 쓸 줄 수만큼(어림) — 장면을 짓기 전이라 아직 글꼴로 잴 수 없다
470
+ const band = captionBand(m, cues.some((c) => !wrapCaption(c.text, m.maxEm, emWidth, 1)) ? 2 : 1);
471
+ return {
472
+ safe: fit ? base : { ...base, bottom: base.bottom + band },
473
+ async mount(stage, style) {
474
+ const { draw, band: used } = await captionLayer(stage, { cues, width: kf.width, height: kf.height, safe: base, style, minPx });
475
+ if (fit) liftScenes(stage, { width: kf.width, height: kf.height, safe: base, band: used });
476
+ return draw;
477
+ },
478
+ };
479
+ }
@@ -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
+ }