kasaframe 0.1.1 → 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 (200) hide show
  1. package/NOTICE +10 -8
  2. package/README.md +16 -1
  3. package/app/server/engine.ts +133 -15
  4. package/app/server/main.ts +7 -3
  5. package/app/web/main.ts +218 -36
  6. package/app/web/style.css +37 -4
  7. package/bin/hooks.mjs +14 -1
  8. package/bin/kf.mjs +20 -2
  9. package/docs/AGENTS.md +64 -16
  10. package/music/README.md +31 -7
  11. package/package.json +2 -2
  12. package/packages/adapters/src/project.ts +1 -1
  13. package/packages/check/src/check.ts +10 -3
  14. package/packages/check/src/rules/assets.ts +4 -1
  15. package/packages/check/src/rules/frames.ts +2 -1
  16. package/packages/check/src/rules/layout.ts +30 -6
  17. package/packages/cli/package.json +2 -1
  18. package/packages/cli/src/blur.ts +15 -3
  19. package/packages/cli/src/commands/bench.ts +123 -0
  20. package/packages/cli/src/commands/blender.ts +72 -49
  21. package/packages/cli/src/commands/direct.ts +210 -0
  22. package/packages/cli/src/commands/doctor.ts +9 -1
  23. package/packages/cli/src/commands/look.ts +9 -2
  24. package/packages/cli/src/commands/lyrics.ts +47 -1
  25. package/packages/cli/src/commands/make.ts +729 -150
  26. package/packages/cli/src/commands/music.ts +78 -0
  27. package/packages/cli/src/commands/narrate.ts +238 -0
  28. package/packages/cli/src/commands/new.ts +14 -5
  29. package/packages/cli/src/commands/render.ts +94 -60
  30. package/packages/cli/src/commands/series.ts +110 -0
  31. package/packages/cli/src/commands/setup.ts +42 -3
  32. package/packages/cli/src/commands/theme.ts +89 -2
  33. package/packages/cli/src/deps.ts +6 -6
  34. package/packages/cli/src/kf.ts +14 -3
  35. package/packages/cli/src/mix.ts +131 -0
  36. package/packages/cli/src/pool.ts +34 -0
  37. package/packages/cli/src/segments.ts +402 -0
  38. package/packages/core/src/index.ts +1 -1
  39. package/packages/core/src/project.ts +11 -0
  40. package/packages/director/package.json +23 -0
  41. package/packages/director/src/assemble.ts +361 -0
  42. package/packages/director/src/brief.ts +122 -0
  43. package/packages/director/src/catalog.ts +49 -0
  44. package/packages/director/src/content.ts +343 -0
  45. package/packages/director/src/fallback.ts +58 -0
  46. package/packages/director/src/index.ts +13 -0
  47. package/packages/director/src/pattern.ts +90 -0
  48. package/packages/director/src/patterns/fill/cells.ts +115 -0
  49. package/packages/director/src/patterns/formula/stack.ts +93 -0
  50. package/packages/director/src/patterns/util.ts +58 -0
  51. package/packages/director/src/scene.ts +62 -0
  52. package/packages/director/src/select.ts +163 -0
  53. package/packages/director/src/transitions.ts +115 -0
  54. package/packages/gen/PROVIDERS.md +39 -4
  55. package/packages/gen/src/ask.ts +132 -0
  56. package/packages/gen/src/audio.ts +91 -0
  57. package/packages/gen/src/blender.ts +54 -0
  58. package/packages/gen/src/capabilities.ts +151 -0
  59. package/packages/gen/src/generate.ts +4 -2
  60. package/packages/gen/src/image/codex.ts +117 -0
  61. package/packages/gen/src/index.ts +10 -2
  62. package/packages/gen/src/keys.ts +26 -0
  63. package/packages/gen/src/music/elevenlabs.ts +41 -0
  64. package/packages/gen/src/music/index.ts +120 -0
  65. package/packages/gen/src/music/library.ts +73 -0
  66. package/packages/gen/src/music/lyria.ts +52 -0
  67. package/packages/gen/src/music/types.ts +28 -0
  68. package/packages/gen/src/registry.ts +6 -3
  69. package/packages/gen/src/tts/dashscope.ts +64 -0
  70. package/packages/gen/src/tts/elevenlabs.ts +84 -0
  71. package/packages/gen/src/tts/gemini.ts +73 -0
  72. package/packages/gen/src/tts/index.ts +241 -0
  73. package/packages/gen/src/tts/local.ts +42 -0
  74. package/packages/gen/src/tts/types.ts +44 -0
  75. package/packages/gen/src/types.ts +4 -0
  76. package/packages/gen/src/video/h3.ts +5 -0
  77. package/packages/gen/src/video/http.ts +1 -1
  78. package/packages/jizura/src/index.ts +2 -2
  79. package/packages/jizura/src/install.ts +198 -155
  80. package/packages/jizura/src/layer.ts +75 -32
  81. package/packages/jizura/src/pin.ts +4 -1
  82. package/packages/jizura/src/vendor.ts +140 -0
  83. package/packages/jizura/src/vite.ts +34 -7
  84. package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
  85. package/packages/jizura/vendor/NOTICE +43 -0
  86. package/packages/jizura/vendor/engine.json +47 -0
  87. package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
  88. package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
  89. package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
  90. package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
  91. package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  92. package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
  93. package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
  94. package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
  95. package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
  96. package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
  97. package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
  98. package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
  99. package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
  100. package/packages/jizura/vendor/fonts/fonts.json +52 -0
  101. package/packages/jizura/vendor/jizura_engine.js +35024 -0
  102. package/packages/motion/README.md +11 -0
  103. package/packages/motion/package.json +1 -0
  104. package/packages/motion/src/captions.ts +32 -24
  105. package/packages/motion/src/moves/brushWipe.ts +14 -1
  106. package/packages/motion/src/moves/dress.ts +191 -0
  107. package/packages/motion/src/moves/echoStack.ts +3 -3
  108. package/packages/motion/src/moves/fit.ts +36 -0
  109. package/packages/motion/src/moves/ghostStack.ts +14 -5
  110. package/packages/motion/src/moves/imageSwarm.ts +8 -4
  111. package/packages/motion/src/moves/index.ts +23 -4
  112. package/packages/motion/src/moves/karaokeLine.ts +17 -3
  113. package/packages/motion/src/moves/menu.ts +138 -0
  114. package/packages/motion/src/moves/neonSign.ts +2 -1
  115. package/packages/motion/src/moves/slamWords.ts +14 -7
  116. package/packages/motion/src/moves/sparkTrail.ts +16 -0
  117. package/packages/motion/src/moves/splitFlap.ts +4 -2
  118. package/packages/motion/src/moves/stepWords.ts +19 -8
  119. package/packages/motion/src/moves/tickerRows.ts +13 -9
  120. package/packages/motion/src/moves/tokenPicker.ts +4 -3
  121. package/packages/motion/src/moves/wordWall.ts +23 -21
  122. package/packages/motion/src/moves/worldRoll.ts +2 -1
  123. package/packages/motion/src/transition3d/index.ts +10 -3
  124. package/packages/motion/src/transition3d/rig.ts +107 -9
  125. package/packages/motion/src/transition3d/shatter.ts +5 -2
  126. package/packages/motion/src/transition3d/space.ts +173 -0
  127. package/packages/motion/src/transition3d/whip.ts +19 -6
  128. package/packages/motion/src/transition3d/world.ts +54 -0
  129. package/packages/motion/src/transitions2d/catalog.ts +1 -1
  130. package/packages/patterns/package.json +15 -0
  131. package/packages/patterns/src/bars/rank.ts +119 -0
  132. package/packages/patterns/src/before-after/shrink.ts +127 -0
  133. package/packages/patterns/src/before-after/strike.ts +125 -0
  134. package/packages/patterns/src/code/terminal.ts +133 -0
  135. package/packages/patterns/src/compare/bars.ts +142 -0
  136. package/packages/patterns/src/compare/duel.ts +103 -0
  137. package/packages/patterns/src/end/lockup.ts +85 -0
  138. package/packages/patterns/src/end/slam.ts +91 -0
  139. package/packages/patterns/src/flow/graph.ts +250 -0
  140. package/packages/patterns/src/hook-number/counter.ts +101 -0
  141. package/packages/patterns/src/hook-number/roll.ts +179 -0
  142. package/packages/patterns/src/hook-number/slam.ts +91 -0
  143. package/packages/patterns/src/index.ts +38 -0
  144. package/packages/patterns/src/kit.ts +449 -0
  145. package/packages/patterns/src/limit/gauge.ts +117 -0
  146. package/packages/patterns/src/list/cards.ts +131 -0
  147. package/packages/patterns/src/list/cycle.ts +22 -0
  148. package/packages/patterns/src/media/panels.ts +100 -0
  149. package/packages/patterns/src/objects.ts +234 -0
  150. package/packages/patterns/src/quote/karaoke.ts +126 -0
  151. package/packages/patterns/src/quote/slam.ts +22 -0
  152. package/packages/patterns/src/screen/focus.ts +85 -0
  153. package/packages/patterns/src/steps/path.ts +137 -0
  154. package/packages/patterns/src/steps/stack.ts +111 -0
  155. package/packages/patterns/src/term-reveal/chips.ts +92 -0
  156. package/packages/patterns/src/term-reveal/expand.ts +127 -0
  157. package/packages/patterns/src/timeline/axis.ts +144 -0
  158. package/packages/patterns/src/title/mask.ts +85 -0
  159. package/packages/patterns/src/title/slam.ts +23 -0
  160. package/packages/patterns/src/toggle-sim/switch.ts +92 -0
  161. package/packages/patterns/src/trend/columns.ts +108 -0
  162. package/packages/patterns/src/trend/draw.ts +140 -0
  163. package/packages/patterns/src/types.ts +67 -0
  164. package/packages/patterns/src/words/moves.ts +42 -0
  165. package/packages/patterns/src/words/slam.ts +4 -0
  166. package/packages/plan/src/asks.ts +256 -0
  167. package/packages/plan/src/brief.ts +136 -7
  168. package/packages/plan/src/captions.ts +5 -2
  169. package/packages/plan/src/index.ts +6 -2
  170. package/packages/plan/src/llm.ts +64 -2
  171. package/packages/plan/src/look.ts +3 -1
  172. package/packages/plan/src/plan.ts +67 -13
  173. package/packages/plan/src/series.ts +76 -0
  174. package/packages/plan/src/types.ts +53 -2
  175. package/packages/themes/package.json +5 -2
  176. package/packages/themes/src/index.ts +9 -0
  177. package/packages/themes/src/slotkit.ts +79 -0
  178. package/packages/themes/src/slots.ts +46 -0
  179. package/packages/themes/src/stage3d.ts +20 -0
  180. package/series/og-shorts.json +14 -0
  181. package/skills/kasaframe/SKILL.md +4 -3
  182. package/templates/_shared/project/lib/stage.ts +70 -50
  183. package/templates/_shared/scenes/default.ts +10 -7
  184. package/templates/_shared/scenes/drop.ts +11 -8
  185. package/templates/_shared/scenes/end.ts +9 -6
  186. package/templates/_shared/scenes/hook.ts +8 -6
  187. package/templates/_shared/scenes/step.ts +13 -4
  188. package/templates/ad/template.json +20 -1
  189. package/templates/demo/template.json +11 -0
  190. package/templates/explainer/template.json +54 -3
  191. package/templates/launch/template.json +34 -1
  192. package/templates/mv/scenes/default.ts +11 -8
  193. package/templates/mv/template.json +4 -2
  194. package/templates/promo/template.json +42 -1
  195. package/templates/shorts/scenes/hook.ts +8 -5
  196. package/templates/shorts/template.json +72 -3
  197. package/templates/square/template.json +21 -1
  198. package/themes/debug-hud/slots.ts +99 -0
  199. package/themes/og-measure/slots.ts +88 -0
  200. package/themes/riso-zine/slots.ts +81 -0
@@ -4,12 +4,12 @@ import type { Vec3 } from "../camera.ts";
4
4
  export type { Vec3 };
5
5
 
6
6
  /** 3D 일곱 가지 + gl-transitions(`gl:<이름>`) + SmoothUI 덮개(`smooth:<이름>`) */
7
- export type Transition3dName = "dive" | "portal" | "cards" | "shatter" | "zoomOut" | "fold" | "diveChain" | "whip" | `gl:${string}` | `smooth:${string}`;
7
+ export type Transition3dName = "dive" | "portal" | "cards" | "shatter" | "zoomOut" | "fold" | "diveChain" | "whip" | "rise" | "land" | `gl:${string}` | `smooth:${string}`;
8
8
 
9
- export const TRANSITIONS_3D = ["dive", "portal", "cards", "shatter", "zoomOut", "fold", "diveChain", "whip"] as const satisfies readonly Transition3dName[];
9
+ export const TRANSITIONS_3D = ["dive", "portal", "cards", "shatter", "zoomOut", "fold", "diveChain", "whip", "rise", "land"] as const satisfies readonly Transition3dName[];
10
10
 
11
11
  /** 혼자 쓸 때(장면 길이를 안 줄 때) 기본 길이 */
12
- export const DUR_3D: Record<(typeof TRANSITIONS_3D)[number], number> = { dive: 2.8, portal: 2.2, cards: 2.6, shatter: 2.2, zoomOut: 2.4, fold: 2.0, diveChain: 2.4, whip: 1.2 };
12
+ export const DUR_3D: Record<(typeof TRANSITIONS_3D)[number], number> = { dive: 2.8, portal: 2.2, cards: 2.6, shatter: 2.2, zoomOut: 2.4, fold: 2.0, diveChain: 2.4, whip: 1.2, rise: 1.8, land: 1.6 };
13
13
 
14
14
  /** 혼자 쓸 때 기본 길이 — 셰이더 전환(gl·smooth)은 짧게 */
15
15
  export const durOf = (kind: Transition3dName): number => (kind in DUR_3D ? DUR_3D[kind as keyof typeof DUR_3D] : 1.2);
@@ -156,15 +156,18 @@ export function diveRig(u: number, env: Env): DiveState {
156
156
  const fly = seg(u, 0.5, 1, "outCubic");
157
157
  pos = mixV([0, 0, -0.06], [0, 0, -DIVE_DEPTH + FIT], fly);
158
158
  look = mixV([0, 0, -1], [0, 0, -DIVE_DEPTH], Math.min(1, fly * 1.6));
159
- // 통로를 나는 동안 한 바퀴 통째로 돈다
160
- barrel = side * 2 * Math.PI * seg(u, 0.54, 0.9, "inOutCubic");
159
+ // 통로를 나는 동안 한 바퀴 통째로 돈다 — 끝 판이 화면의 25% 를 넘기 전(u 0.78)에 다 돈다. 커진 밝은 판이 어두운 통로 위에서 돌면
160
+ // 칸마다 밝음·어둠이 빠르게 오가 섬광 검사(frame.flash)에 걸린다
161
+ barrel = side * 2 * Math.PI * seg(u, 0.52, 0.78, "inOutCubic");
161
162
  }
162
163
  const roll = side * -0.14 * hump(seg(u, 0.28, 0.9)) + barrel;
163
164
  return {
164
165
  cam: { pos, look, roll: u <= 0 || u >= 1 ? 0 : roll },
165
- dissolve: seg(u, 0.33, 0.47, "inOutQuad"),
166
+ // 화면은 가장 멀리 물러난 동안 통로로 녹는다 — 다시 다가가 화면이 시야를 채울 때 밝은 앞 장면이 한 번 더 차오르면
167
+ // 밝음·어둠이 한 전환 안에서 두 번 오가 섬광 검사(frame.flash)에 걸린다
168
+ dissolve: seg(u, 0.18, 0.32, "inOutQuad"),
166
169
  inside: pos[2] < 0.04,
167
- glitch: hump(seg(u, 0.2, 0.36)),
170
+ glitch: hump(seg(u, 0.08, 0.26)),
168
171
  };
169
172
  }
170
173
 
@@ -480,14 +483,15 @@ export function arrivalsOn(beats: Beats, grid: BeatGrid, from: number, n: number
480
483
  // ── 휙 넘기기: 제자리에서 90° 를 휙 돈다 ──
481
484
 
482
485
  /**
483
- * 반대로 살짝 감았다가(예비 동작) 확 돌고, 4.5% 넘쳤다가 되돌아와 선다. 도는 동안 몸을 기울이고 화각을 넓힌다.
486
+ * 반대로 살짝 감았다가(예비 동작) 확 돌고, 1.5% 넘쳤다가 되돌아와 선다. 도는 동안 몸을 기울이고 화각을 넓힌다.
484
487
  * 양 끝은 기울기·화각 0 — 앞 판·뒤 판이 꽉 찬다
485
488
  */
486
489
  export function whipRig(u: number, env: Env): { cam: Pose; side: 1 | -1 } {
487
490
  const side = rand(env.seed, 13) < 0.5 ? -1 : 1;
488
491
  const edge = u <= 0 || u >= 1;
489
492
  const wind = -0.045 * Math.sin(Math.PI * seg(u, 0, 0.24));
490
- const turn = u < 0.8 ? 1.045 * seg(u, 0.14, 0.8, "inOutQuart") : 1 + 0.045 * (0.5 + 0.5 * Math.cos(Math.PI * seg(u, 0.8, 1)));
493
+ // 넘침은 1.5% — 밝은 판 가장자리가 되돌아오며 칸을 한 번 더 쓸면 섬광 검사(frame.flash)가 꺾임을 두 번 더 센다
494
+ const turn = u < 0.8 ? 1.015 * seg(u, 0.14, 0.8, "inOutQuart") : 1 + 0.015 * (0.5 + 0.5 * Math.cos(Math.PI * seg(u, 0.8, 1)));
491
495
  const p = u <= 0 ? 0 : u >= 1 ? 1 : turn + wind;
492
496
  const yaw = (side * Math.PI * p) / 2;
493
497
  return {
@@ -500,3 +504,97 @@ export function whipRig(u: number, env: Env): { cam: Pose; side: 1 | -1 } {
500
504
  },
501
505
  };
502
506
  }
507
+
508
+ // ── 공간 잇기: 2D 와 3D 사이를 같은 공간으로 ──
509
+
510
+ /** 판 위 주인공 상자 — 판 좌표(가로 aspect·세로 1, 가운데가 0, 위가 +). 앞 장면이 stage.hero 로 짚은 요소를 잰다 */
511
+ export interface HeroBox {
512
+ x: number;
513
+ y: number;
514
+ w: number;
515
+ h: number;
516
+ }
517
+
518
+ /** 주인공 블록이 서는 높이 — 2D 상자가 클수록 높게, 화면 높이의 1/3 안에서 */
519
+ export const liftOf = (hero: HeroBox): number => Math.min(0.22, 0.08 + 0.45 * Math.min(hero.w, hero.h));
520
+
521
+ /** rise — 뒤 장면 벽이 서는 깊이. 누운 앞 판(깊이 1) 끝보다 멀어 끝 구도에서 블록이 카메라 뒤로 빠진다 */
522
+ export const RISE_WALL = 1 + FIT + 0.35;
523
+
524
+ export interface RiseState {
525
+ cam: Pose;
526
+ /** 앞 판이 아래 가장자리를 축으로 뒤로 누운 각(0 → -π/2) — 판의 법선이 위를 보게 되어 블록이 바로 선다 */
527
+ lie: number;
528
+ /** 주인공 블록 높이(판 법선 방향) */
529
+ lift: number;
530
+ }
531
+
532
+ /**
533
+ * 2D → 3D. 처음엔 앞 장면 판이 화면을 꽉 채우고(경계 프레임 그대로), 주인공 상자가 같은 화면 자리에서 블록으로 솟는다.
534
+ * 판이 아래 가장자리를 축으로 뒤로 누워 바닥이 되면 블록은 그 위에 선 물체가 되고, 카메라가 깊이로 기울어 들어가
535
+ * 블록 위를 지나 바닥 끝 벽(뒤 장면 첫 화면)을 정면으로 꽉 채우며 선다
536
+ */
537
+ export function riseRig(u: number, env: Env, hero: HeroBox): RiseState {
538
+ const hx = hero.x;
539
+ const near = -(hero.y + 0.5);
540
+ // 블록 위를 높이 지난다 — 낮게 스치면 블록 옆면이 화면을 크게 쓸어 섬광 검사(frame.flash)에 걸린다
541
+ const keys: Vec3[] = [
542
+ [0, 0, FIT],
543
+ [hx * 0.2, 0.16, FIT * 0.85],
544
+ [hx * 0.1, 0.52, near - 0.35],
545
+ [0, 0, -RISE_WALL + FIT],
546
+ ];
547
+ const looks: Vec3[] = [
548
+ [0, 0, 0],
549
+ [hx * 0.45, -0.32, near * 0.7],
550
+ [0, -0.1, -RISE_WALL],
551
+ [0, 0, -RISE_WALL],
552
+ ];
553
+ const sway = rand(env.seed, 70) < 0.5 ? -1 : 1;
554
+ const edge = u <= 0 || u >= 1;
555
+ return {
556
+ cam: { pos: spline(keys, u), look: spline(looks, u), roll: edge ? 0 : sway * 0.06 * hump(seg(u, 0.15, 0.9)) },
557
+ lie: (-Math.PI / 2) * seg(u, 0.1, 0.6, "inOutCubic"),
558
+ lift: u <= 0 ? 0 : liftOf(hero) * seg(u, 0, 0.42, "outCubic"),
559
+ };
560
+ }
561
+
562
+ /** land — 뒤 장면 판의 크기와 자리. 작게 두어야 카메라가 다가가며(뒤로 물러나지 않고) 판이 화면을 꽉 채운다 */
563
+ export const LAND_CARD = 0.4;
564
+ export const LAND_Z = 0.3;
565
+
566
+ export interface LandState {
567
+ cam: Pose;
568
+ /** 뒤 장면 판이 누운 각(-π/2 누움 → 0 섰다) — 아래 가장자리가 축 */
569
+ lie: number;
570
+ /** 뒤 장면 주인공 블록 높이 — 판이 서며 평면으로 내려앉는다 */
571
+ lift: number;
572
+ /** 뒤 장면 판의 불투명도 — 처음 프레임은 앞 장면 그대로 */
573
+ alpha: number;
574
+ }
575
+
576
+ /**
577
+ * 3D → 2D. 앞 장면(3D 샷 마지막 화면)이 꽉 찬 채 시작해, 그 앞 공간에 누워 있던 판 하나가 일어서고 카메라가 그 판으로 다가간다.
578
+ * 판 위 주인공 블록은 평면으로 내려앉고, 판이 화면을 정면으로 꽉 채운 순간이 뒤 장면 첫 화면이다
579
+ */
580
+ export function landRig(u: number, env: Env, hero: HeroBox): LandState {
581
+ const z = LAND_Z;
582
+ const keys: Vec3[] = [
583
+ [0, 0, FIT],
584
+ [hero.x * LAND_CARD * 0.2, 0.08, FIT * 0.85],
585
+ [0, 0.04, z + FIT * LAND_CARD * 1.5],
586
+ [0, 0, z + FIT * LAND_CARD],
587
+ ];
588
+ const looks: Vec3[] = [
589
+ [0, 0, 0],
590
+ [0, -0.12, z - 0.2],
591
+ [0, -0.02, z],
592
+ [0, 0, z],
593
+ ];
594
+ return {
595
+ cam: { pos: spline(keys, u), look: spline(looks, u) },
596
+ lie: (-Math.PI / 2) * (1 - seg(u, 0.12, 0.85, "inOutCubic")),
597
+ lift: u >= 1 ? 0 : liftOf(hero) * LAND_CARD * (1 - seg(u, 0.45, 0.95, "inOutCubic")),
598
+ alpha: seg(u, 0, 0.14),
599
+ };
600
+ }
@@ -1,7 +1,7 @@
1
1
  import * as THREE from "three";
2
2
  import { clamp } from "@kasaframe/core";
3
3
  import { hump, rr, seg, shatterRig, tileAmount, tileGrid, tiles, type Tile } from "./rig.ts";
4
- import { aim, disposeTree, imageMaterial, raw, sky, type Kit } from "./world.ts";
4
+ import { aim, bridgeSky, disposeTree, imageMaterial, raw, sky, type Kit } from "./world.ts";
5
5
 
6
6
  // 칸 꼭짓점을 칸 번호의 같은 식으로 셈한다 — 이웃 칸의 맞닿은 모서리가 비트까지 같아야 조립된 그림에 금이 안 간다
7
7
  function tileGeometry(aspect: number, cols: number, rows: number, c: number, r: number): THREE.BufferGeometry {
@@ -20,7 +20,9 @@ export function shatter(k: Kit) {
20
20
  const { aspect, pal, seed } = k;
21
21
  const env = { aspect, seed };
22
22
  const scene = new THREE.Scene();
23
- scene.add(sky({ top: pal.deep, bottom: "#020308", glow: pal.accent, dir: [0, 0, -1], amt: 0.6, pow: 8 }));
23
+ // 깨진 틈으로 보이는 하늘은 앞 화면 → 뒤 화면 평균 밝기로 넘어간다(bridgeSky) — 밝은 화면 틈으로 어두운 하늘이 번쩍이면 섬광으로 잡힌다
24
+ const air = sky({ top: pal.deep, bottom: pal.deep, glow: pal.accent, dir: [0, 0, -1], amt: 0.3, pow: 8 });
25
+ scene.add(air);
24
26
  const { cols } = tileGrid(env, ROWS);
25
27
  const sides: { tile: Tile; mesh: THREE.Mesh; c: THREE.Vector3 }[][] = [[], []];
26
28
  for (const side of [0, 1] as const) {
@@ -58,6 +60,7 @@ export function shatter(k: Kit) {
58
60
  return {
59
61
  frame(u: number) {
60
62
  aim(k.cam, shatterRig(u, env).cam);
63
+ bridgeSky(k, air, u);
61
64
  for (const side of [0, 1] as const) {
62
65
  for (const { tile, mesh, c } of sides[side]) {
63
66
  const a = tileAmount(u, tile, side);
@@ -0,0 +1,173 @@
1
+ import * as THREE from "three";
2
+ import { LAND_CARD, LAND_Z, RISE_WALL, landRig, liftOf, riseRig, seg, type HeroBox } from "./rig.ts";
3
+ import { aim, bridgeSky, disposeTree, gridPlane, imageMaterial, sky, type ImageMaterial, type Kit, type KindRun, type Screen } from "./world.ts";
4
+
5
+ /** 화면 비율로 준 주인공 상자(왼쪽 위가 0, CSS 처럼) */
6
+ export interface HeroRect {
7
+ x: number;
8
+ y: number;
9
+ w: number;
10
+ h: number;
11
+ }
12
+
13
+ interface Hero {
14
+ box: HeroBox;
15
+ color: THREE.Color;
16
+ }
17
+
18
+ const CENTER: HeroRect = { x: 0.3, y: 0.38, w: 0.4, h: 0.24 };
19
+ const WHITE = new THREE.Color(1, 1, 1);
20
+
21
+ function toBox(r: HeroRect, aspect: number): HeroBox {
22
+ return { x: (r.x + r.w / 2 - 0.5) * aspect, y: 0.5 - (r.y + r.h / 2), w: r.w * aspect, h: r.h };
23
+ }
24
+
25
+ // 2D 에서 칠한 색 그대로 3D 블록 옆면에 — 같은 것은 2D·3D 에서 같은 색이어야 한 물체로 읽힌다
26
+ function colorOf(el: Element, fallback: string): THREE.Color {
27
+ const cs = getComputedStyle(el);
28
+ const solid = (c: string) => !!c && c !== "transparent" && !/rgba\([^)]*,\s*0\)$/.test(c);
29
+ const css = solid(cs.backgroundColor) ? cs.backgroundColor : solid(cs.color) ? cs.color : fallback;
30
+ return new THREE.Color().setStyle(css, THREE.LinearSRGBColorSpace);
31
+ }
32
+
33
+ /**
34
+ * 살아 있는 장면 화면에서 주인공 상자를 잰다 — 장면이 stage.hero 로 짚은 [data-kf-hero], 없으면 가장 넓은 글 덩어리.
35
+ * 그림만 있는 화면(정지 그림·영상)이면 준 상자(rect), 그것도 없으면 가운데
36
+ */
37
+ function heroOf(screen: Screen, aspect: number, rect: HeroRect | undefined, accent: string): Hero {
38
+ const box = screen.box;
39
+ if (rect || !box) return { box: toBox(rect ?? CENTER, aspect), color: new THREE.Color().setStyle(accent, THREE.LinearSRGBColorSpace) };
40
+ const br = box.getBoundingClientRect();
41
+ let el: Element | null = box.querySelector("[data-kf-hero]");
42
+ if (!el) {
43
+ let best = 0;
44
+ for (const cand of Array.from(box.querySelectorAll("div,span,h1,h2,p"))) {
45
+ if (!cand.textContent?.trim() || cand.children.length > 3) continue;
46
+ const r = cand.getBoundingClientRect();
47
+ const a = r.width * r.height;
48
+ if (a > best && a < br.width * br.height * 0.6) {
49
+ best = a;
50
+ el = cand;
51
+ }
52
+ }
53
+ }
54
+ const r = el?.getBoundingClientRect();
55
+ if (!el || !r || br.width < 1 || r.width < 1) return { box: toBox(CENTER, aspect), color: new THREE.Color().setStyle(accent, THREE.LinearSRGBColorSpace) };
56
+ const fx = (r.left - br.left) / br.width;
57
+ const fy = (r.top - br.top) / br.height;
58
+ const clip = (v: number) => Math.min(1, Math.max(0, v));
59
+ const x = clip(fx);
60
+ const y = clip(fy);
61
+ return { box: toBox({ x, y, w: clip(fx + r.width / br.width) - x, h: clip(fy + r.height / br.height) - y }, aspect), color: colorOf(el, accent) };
62
+ }
63
+
64
+ /**
65
+ * 장면 판 하나 + 그 위 주인공 블록. 원점은 판의 아래 가장자리 가운데(눕고 서는 축). size 는 판 높이
66
+ * — 블록 윗면은 판 그림의 같은 자리를 잘라 붙여, 솟기 전과 후가 같은 그림이다
67
+ */
68
+ function page(map: THREE.Texture, aspect: number, size: number) {
69
+ const group = new THREE.Group();
70
+ const mat = imageMaterial(map, { double: true });
71
+ const plane = new THREE.Mesh(new THREE.PlaneGeometry(aspect * size, size), mat);
72
+ plane.position.y = size / 2;
73
+ group.add(plane);
74
+ const top = imageMaterial(map);
75
+ const side = (k: number) => new THREE.MeshBasicMaterial({ color: new THREE.Color(), transparent: true, userData: { k } });
76
+ const sides = [side(0.78), side(0.78), side(0.9), side(0.6), side(0.5)];
77
+ const block = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), [sides[0], sides[1], sides[2], sides[3], top, sides[4]]);
78
+ group.add(block);
79
+ // 솟은 자리의 그늘 — 비스듬히 볼 때 판에 같은 그림이 두 번 보이지 않게 눌러 둔다
80
+ const socket = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0, depthWrite: false }));
81
+ group.add(socket);
82
+ return {
83
+ group,
84
+ mats: [mat, top] as ImageMaterial[],
85
+ /** wash — 판을 그 색(하늘) 쪽으로 물들이는 몫. 멀어지는 판 가장자리가 하늘 위에서 칸마다 쓸려 섬광으로 잡히지 않게 */
86
+ set(hero: Hero, lift: number, alpha: number, wash = 0, tone?: THREE.Color) {
87
+ const h = hero.box;
88
+ const e = Math.max(lift, 1e-4);
89
+ block.visible = lift > 1e-4;
90
+ block.position.set(h.x * size, (h.y + 0.5) * size, e / 2);
91
+ block.scale.set(h.w * size, h.h * size, e);
92
+ top.uniforms.uvRect.value.set((h.x - h.w / 2) / aspect + 0.5, h.y - h.h / 2 + 0.5, h.w / aspect, h.h);
93
+ // 옆면은 2D 색에 바탕을 조금 섞어 — 진한 옆면이 카메라 가까이 화면을 크게 쓸면 섬광 검사(frame.flash)에 걸린다
94
+ for (const s of sides) {
95
+ s.color.copy(hero.color).lerp(WHITE, 0.35).multiplyScalar(s.userData.k as number);
96
+ if (tone) s.color.lerp(tone, wash);
97
+ }
98
+ socket.visible = block.visible;
99
+ socket.position.set(h.x * size, (h.y + 0.5) * size, 0.0008);
100
+ socket.scale.set(h.w * size, h.h * size, 1);
101
+ (socket.material as THREE.MeshBasicMaterial).opacity = 0.45 * Math.min(1, lift / (liftOf(h) * size));
102
+ for (const m of [mat, top]) {
103
+ m.uniforms.opacity.value = alpha;
104
+ m.uniforms.tintAmt.value = wash;
105
+ if (tone) m.uniforms.tint.value.copy(tone);
106
+ }
107
+ for (const s of sides) s.opacity = alpha;
108
+ group.visible = alpha > 0;
109
+ },
110
+ };
111
+ }
112
+
113
+ /** rise(2D→3D) — 앞 장면 판이 바닥으로 눕고 주인공이 블록으로 서며, 카메라가 그 위를 지나 뒤 장면 벽 앞에 선다 */
114
+ export function rise(k: Kit, o: { hero?: HeroRect }): KindRun {
115
+ const { aspect, pal, seed } = k;
116
+ const env = { aspect, seed };
117
+ const scene = new THREE.Scene();
118
+ const air = sky({ top: pal.deep, bottom: pal.deep, glow: pal.accent, dir: [0, 0, -1], amt: 0.2, pow: 4 });
119
+ scene.add(air);
120
+ // 눕은 판에 이어지는 바닥 — 정지한 옅은 모눈이라 공간의 깊이만 알린다
121
+ scene.add(gridPlane({ color: pal.accent, y: -0.5, width: aspect * 3, depth: RISE_WALL + 1, z: -(RISE_WALL + 1) / 2, focus: -RISE_WALL }));
122
+ const a = page(k.from.texture, aspect, 1);
123
+ a.group.position.set(0, -0.5, 0);
124
+ scene.add(a.group);
125
+ const wall = new THREE.Mesh(new THREE.PlaneGeometry(aspect, 1), imageMaterial(k.to.texture));
126
+ wall.position.set(0, 0, -RISE_WALL);
127
+ scene.add(wall);
128
+ return {
129
+ frame(u) {
130
+ const hero = heroOf(k.from, aspect, o.hero, pal.accent);
131
+ const s = riseRig(u, env, hero.box);
132
+ a.group.rotation.x = s.lie;
133
+ aim(k.cam, s.cam);
134
+ bridgeSky(k, air, u);
135
+ // 블록이 다 선 뒤 카메라가 고개를 드는 동안 판·블록이 하늘 색으로 물든다 — 빠르게 쓸려 가는 판과 블록이 칸마다 밝음·어둠을 오가지 않게
136
+ a.set(hero, s.lift, 1, 0.92 * seg(u, 0.18, 0.5, "inOutCubic"), air.material.uniforms.top.value);
137
+ k.renderer.clear(true, true, true);
138
+ k.renderer.render(scene, k.cam);
139
+ },
140
+ dispose() {
141
+ disposeTree(scene);
142
+ },
143
+ };
144
+ }
145
+
146
+ /** land(3D→2D) — 앞 장면(3D 샷 끝 화면) 앞 공간에 누운 판이 일어서고, 카메라가 다가가 그 판이 뒤 장면 첫 화면이 된다 */
147
+ export function land(k: Kit, o: { hero?: HeroRect }): KindRun {
148
+ const { aspect, pal, seed } = k;
149
+ const env = { aspect, seed };
150
+ const scene = new THREE.Scene();
151
+ const air = sky({ top: pal.deep, bottom: pal.deep, glow: pal.accent, dir: [0, 0, -1], amt: 0.2, pow: 4 });
152
+ scene.add(air);
153
+ const back = new THREE.Mesh(new THREE.PlaneGeometry(aspect, 1), imageMaterial(k.from.texture));
154
+ scene.add(back);
155
+ const b = page(k.to.texture, aspect, LAND_CARD);
156
+ b.group.position.set(0, -LAND_CARD / 2, LAND_Z);
157
+ scene.add(b.group);
158
+ return {
159
+ frame(u) {
160
+ const hero = heroOf(k.to, aspect, o.hero, pal.accent);
161
+ const s = landRig(u, env, hero.box);
162
+ b.group.rotation.x = s.lie;
163
+ aim(k.cam, s.cam);
164
+ bridgeSky(k, air, u);
165
+ b.set(hero, s.lift, s.alpha, 0.85 * (1 - seg(u, 0.3, 0.85, "inOutCubic")), air.material.uniforms.top.value);
166
+ k.renderer.clear(true, true, true);
167
+ k.renderer.render(scene, k.cam);
168
+ },
169
+ dispose() {
170
+ disposeTree(scene);
171
+ },
172
+ };
173
+ }
@@ -1,6 +1,6 @@
1
1
  import * as THREE from "three";
2
- import { FIT, rr, whipRig } from "./rig.ts";
3
- import { aim, disposeTree, dust, frameRing, imageMaterial, raw, sky, type Kit, type KindRun } from "./world.ts";
2
+ import { FIT, hump, rr, seg, whipRig } from "./rig.ts";
3
+ import { aim, bridgeSky, disposeTree, dust, frameRing, imageMaterial, raw, sky, type Kit, type KindRun } from "./world.ts";
4
4
 
5
5
  /**
6
6
  * 휙 넘기기 — 카메라는 제자리에서 돌고, 앞 판은 정면·뒤 판은 옆(90°)에 선다. 둘레에 판과 빛줄을 둘러
@@ -11,7 +11,9 @@ export function whip(k: Kit): KindRun {
11
11
  const env = { aspect, seed };
12
12
  const side = whipRig(0, env).side;
13
13
  const scene = new THREE.Scene();
14
- scene.add(sky({ top: pal.deep, bottom: "#03050b", glow: pal.accent, dir: [-side, 0, -1], amt: 0.5, pow: 3 }));
14
+ // 하늘은 앞 화면 → 뒤 화면 평균 밝기로 서서히 넘어간다(bridgeSky) — 두 화면 사이로 다른 밝기의 하늘이 쓸려 지나가면 섬광으로 잡힌다
15
+ const air = sky({ top: pal.deep, bottom: pal.deep, glow: pal.accent, dir: [-side, 0, -1], amt: 0.2, pow: 3 });
16
+ scene.add(air);
15
17
  const at = (yaw: number, r: number, y = 0): THREE.Vector3 => new THREE.Vector3(-Math.sin(yaw) * r, y, -Math.cos(yaw) * r);
16
18
 
17
19
  const place = (mesh: THREE.Object3D, yaw: number, r: number, y = 0) => {
@@ -19,8 +21,10 @@ export function whip(k: Kit): KindRun {
19
21
  mesh.rotation.y = yaw;
20
22
  scene.add(mesh);
21
23
  };
22
- place(new THREE.Mesh(new THREE.PlaneGeometry(aspect, 1), imageMaterial(k.from.texture)), 0, FIT);
23
- place(new THREE.Mesh(new THREE.PlaneGeometry(aspect, 1), imageMaterial(k.to.texture)), (side * Math.PI) / 2, FIT);
24
+ const fromMat = imageMaterial(k.from.texture);
25
+ const toMat = imageMaterial(k.to.texture);
26
+ place(new THREE.Mesh(new THREE.PlaneGeometry(aspect, 1), fromMat), 0, FIT);
27
+ place(new THREE.Mesh(new THREE.PlaneGeometry(aspect, 1), toMat), (side * Math.PI) / 2, FIT);
24
28
 
25
29
  // 사이를 메우는 판 — 앞·뒤 그림을 흐리게 눌러 쓴다. 정면·옆 판 자리(꽉 찬 시야)는 비운다
26
30
  for (let i = 0; i < 7; i++) {
@@ -29,7 +33,8 @@ export function whip(k: Kit): KindRun {
29
33
  const s = rr(seed, 220 + i, 0.45, 0.8);
30
34
  const mat = imageMaterial((i % 2 ? k.from : k.to).soft(), { double: true, back: pal.deep });
31
35
  mat.uniforms.bias.value = 3;
32
- mat.uniforms.dim.value = 0.35;
36
+ // 하늘과 비슷한 밝기로 — 판과 하늘이 칸마다 밝음·어둠으로 엇갈리지 않게 눌러 둔다
37
+ mat.uniforms.opacity.value = 0.35;
33
38
  const g = new THREE.Group();
34
39
  g.add(new THREE.Mesh(new THREE.PlaneGeometry(aspect * s, s), mat));
35
40
  g.add(new THREE.Mesh(frameRing(aspect * s, s, 0.008), new THREE.MeshBasicMaterial({ color: raw(pal.glow), transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false })));
@@ -55,6 +60,14 @@ export function whip(k: Kit): KindRun {
55
60
  return {
56
61
  frame(u) {
57
62
  aim(k.cam, whipRig(u, env).cam);
63
+ bridgeSky(k, air, u);
64
+ // 가장 빠르게 쓸리는 동안 두 판의 대비를 하늘(두 화면 평균) 쪽으로 누른다 — 진짜 셔터가 번져 대비가 죽는 것처럼.
65
+ // 밝고 어두운 무늬가 섞인 화면이 빠르게 쓸리면 칸마다 밝음·어둠이 여러 번 오가 섬광으로 잡힌다. 양 끝은 0 이라 경계 프레임은 그대로
66
+ const wash = 0.65 * hump(seg(u, 0.12, 0.88));
67
+ for (const m of [fromMat, toMat]) {
68
+ m.uniforms.tint.value.copy(air.material.uniforms.top.value);
69
+ m.uniforms.tintAmt.value = wash;
70
+ }
58
71
  k.renderer.clear(true, true, true);
59
72
  k.renderer.render(scene, k.cam);
60
73
  },
@@ -584,6 +584,60 @@ export function sky(o: { top: string; bottom: string; glow: string; dir?: [numbe
584
584
  return m;
585
585
  }
586
586
 
587
+ const lumOf = (buf: Uint8Array, i: number): number => buf[i * 4] * 54 + buf[i * 4 + 1] * 183 + buf[i * 4 + 2] * 19;
588
+
589
+ // 화면 그림의 바탕 쪽 색 — 판이 화면에 찍는 값 그대로(imageMaterial 로 32×18 판에 그려 읽는다). 렌더러마다 판 하나.
590
+ // 그냥 평균은 흰 바탕 위 검은 글도 회색으로 섞어 판 가장자리가 하늘 위에서 도드라진다
591
+ const probes = new WeakMap<THREE.WebGLRenderer, { rt: THREE.WebGLRenderTarget; scene: THREE.Scene; cam: THREE.OrthographicCamera; mat: ImageMaterial; buf: Uint8Array }>();
592
+ export function screenTone(renderer: THREE.WebGLRenderer, map: THREE.Texture, out = new THREE.Color()): THREE.Color {
593
+ let p = probes.get(renderer);
594
+ if (!p) {
595
+ const mat = imageMaterial(map);
596
+ const scene = new THREE.Scene();
597
+ scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat));
598
+ p = { rt: new THREE.WebGLRenderTarget(32, 18, { depthBuffer: false }), scene, cam: new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1), mat, buf: new Uint8Array(32 * 18 * 4) };
599
+ probes.set(renderer, p);
600
+ }
601
+ p.mat.uniforms.map.value = map;
602
+ const prev = renderer.getRenderTarget();
603
+ renderer.setRenderTarget(p.rt);
604
+ renderer.clear(true, false, false);
605
+ renderer.render(p.scene, p.cam);
606
+ renderer.readRenderTargetPixels(p.rt, 0, 0, 32, 18, p.buf);
607
+ renderer.setRenderTarget(prev);
608
+ // 밝기 순으로 줄 세워 가운데 절반만 평균 — 흰 바탕 위 검은 글은 바탕색이 되고, 반반인 화면은 그 사이가 된다.
609
+ // 가장 많은 색(최빈값)은 비슷한 두 색 사이에서 프레임마다 뒤집혀 하늘이 한 프레임씩 번쩍였다 — 줄 세운 값의 평균은 이어진다
610
+ const n0 = p.buf.length / 4;
611
+ const order = Array.from({ length: n0 }, (_, i) => i).sort((x, y) => lumOf(p.buf, x) - lumOf(p.buf, y) || x - y);
612
+ let r = 0;
613
+ let g = 0;
614
+ let b = 0;
615
+ const lo = Math.floor(n0 * 0.25);
616
+ const hi = Math.ceil(n0 * 0.75);
617
+ for (let k = lo; k < hi; k++) {
618
+ const i = order[k] * 4;
619
+ r += p.buf[i];
620
+ g += p.buf[i + 1];
621
+ b += p.buf[i + 2];
622
+ }
623
+ const n = hi - lo;
624
+ return out.setRGB(r / n / 255, g / n / 255, b / n / 255);
625
+ }
626
+
627
+ /**
628
+ * 화면 사이로 보이는 하늘을 앞 화면 바탕색 → 뒤 화면 바탕색으로 서서히 넘긴다. 밝은 화면과 어두운 공간이 칸마다 엇갈려 오가면
629
+ * 섬광 검사(frame.flash)에 걸린다 — 밝기가 크게 다른 두 화면 사이에서도 하늘이 그 사이 밝기로 한 번만 넘어간다.
630
+ * 매 프레임 sky 의 top·bottom 을 고친다(살아 있는 화면은 프레임마다 바탕이 바뀐다)
631
+ */
632
+ export function bridgeSky(k: Kit, sky: THREE.Mesh<THREE.SphereGeometry, THREE.ShaderMaterial>, u: number, o: { shade?: number } = {}): void {
633
+ const a = screenTone(k.renderer, k.from.texture, new THREE.Color());
634
+ const b = screenTone(k.renderer, k.to.texture, new THREE.Color());
635
+ const p = u <= 0 ? 0 : u >= 1 ? 1 : u * u * (3 - 2 * u);
636
+ const c = a.lerp(b, p).multiplyScalar(1 - (o.shade ?? 0.04));
637
+ sky.material.uniforms.top.value.copy(c);
638
+ sky.material.uniforms.bottom.value.copy(c).multiplyScalar(0.94);
639
+ }
640
+
587
641
  /** 시드로 흩은 빛 점들 — 지나가는 속도감. 같은 시드면 같은 자리 */
588
642
  export function dust(positions: Float32Array, color: string, size: number): THREE.Points {
589
643
  const geo = new THREE.BufferGeometry();
@@ -27,7 +27,7 @@ export interface TransitionEntry {
27
27
 
28
28
  const SPDX: Record<string, string> = { MIT: "MIT", "BSD 2 Clause": "BSD-2-Clause", "BSD 3 Clause": "BSD-3-Clause" };
29
29
 
30
- const KASAFRAME_3D: TransitionEntry[] = ["dive", "portal", "cards", "shatter", "zoomOut", "fold", "diveChain", "whip"].map((name) => ({
30
+ const KASAFRAME_3D: TransitionEntry[] = ["dive", "portal", "cards", "shatter", "zoomOut", "fold", "diveChain", "whip", "rise", "land"].map((name) => ({
31
31
  name,
32
32
  source: "kasaframe",
33
33
  license: "kasaframe",
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@kasaframe/patterns",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./*": "./src/*.ts"
9
+ },
10
+ "dependencies": {
11
+ "@kasaframe/core": "0.0.0",
12
+ "@kasaframe/motion": "^0.0.0",
13
+ "@kasaframe/themes": "0.0.0"
14
+ }
15
+ }
@@ -0,0 +1,119 @@
1
+ import { applyStyle, clamp, combine } from "@kasaframe/core";
2
+ import { fitSize, textEm } from "@kasaframe/motion/moves";
3
+ import { area, beatStops, decimalsOf, factRoll, glider, node, slotsOf, spaced, moving } from "../kit.ts";
4
+ import { cssColor, objectFor } from "../objects.ts";
5
+ import type { Pattern } from "../types.ts";
6
+
7
+ export interface BarsContent {
8
+ items: { label: string; value: number; unit?: string }[];
9
+ unit?: string;
10
+ highlight?: string;
11
+ source?: string;
12
+ }
13
+
14
+ /**
15
+ * 막대가 순위대로 박마다 한 줄씩 자라고 값이 같은 진행도로 굴러 오른다(막대 길이 = 값). 다 자라면 짚개가 강조 줄과 다른 줄을 번갈아 짚는다.
16
+ * 단위·이름이 객체 사전의 막대 물체면(지연 ms) 그 색·모서리. 자라는 시각을 장면 끝까지 펴서 쉬지 않는 몫을 막대가 진다.
17
+ */
18
+ const pattern: Pattern<BarsContent> = {
19
+ id: "bars.rank",
20
+ type: "bars",
21
+ does: "막대가 순위대로 박마다 자라고 값이 굴러 오른다, 다 자라면 짚개가 강조 줄과 다른 줄을 번갈아 짚는다",
22
+ energy: ["mid"],
23
+ family: "grow",
24
+ camera: ["none"],
25
+ bg: "stage",
26
+ aspects: ["16:9", "9:16", "1:1"],
27
+ dur: { min: 2.5, max: 9 },
28
+ themes: { "og-measure": 0.6, "debug-hud": 0.4 },
29
+ fits(c) {
30
+ const items = c?.items;
31
+ if (!Array.isArray(items) || items.length < 2) return "items 가 둘보다 적다";
32
+ if (items.length > 8) return "items 가 여덟을 넘는다";
33
+ for (const [i, it] of items.entries()) {
34
+ if (typeof it?.label !== "string" || !it.label.trim()) return `items[${i}].label 이 비었다`;
35
+ if (!Number.isFinite(it.value) || it.value < 0) return `items[${i}].value 가 0 이상 수가 아니다`;
36
+ }
37
+ if (!items.some((it) => it.value > 0)) return "값이 다 0 이다";
38
+ return true;
39
+ },
40
+ build(root, ctx, c, kit) {
41
+ const slots = slotsOf(kit);
42
+ const { w, h, vertical, u } = area(ctx, kit);
43
+ const rows = [...c.items].sort((a, b) => b.value - a.value);
44
+ const n = rows.length;
45
+ const max = Math.max(...rows.map((r) => r.value));
46
+ const hot = c.highlight ? rows.findIndex((r) => r.label === c.highlight) : 0;
47
+ const obj = objectFor(c.unit) ?? objectFor(rows[0].unit) ?? null;
48
+ const barObj = obj?.shape2d.kind === "bar" ? obj : null;
49
+ const barColor = barObj ? cssColor(barObj.color) : "var(--kf-accent)";
50
+ const radius = barObj?.shape2d.radius === "pill" ? "999px" : "var(--kf-radius-sm)";
51
+
52
+ const gridH = h * (vertical ? 0.7 : 0.84);
53
+ const rowH = gridH / n;
54
+ const barH = Math.round(rowH * 0.56);
55
+ const fmtVal = (r: BarsContent["items"][number], text: string) => {
56
+ const unit = r.unit ?? c.unit ?? "";
57
+ return `${text}${unit ? ` ${unit}` : ""}`;
58
+ };
59
+ // 소수 자리는 줄끼리 같게 — 746 과 612.5 가 나란히면 746.0
60
+ const dec = Math.max(...rows.map((r) => decimalsOf(r.value)));
61
+ const finals = rows.map((r) => fmtVal(r, factRoll(Infinity, { from: 0, to: r.value, at: 0, dur: 1, decimals: dec }).text));
62
+ const labelPx = Math.round(Math.min(rowH * 0.36, fitSize(rows.reduce((a, r) => (textEm(r.label) > textEm(a) ? r.label : a), ""), w * (vertical ? 0.9 : 0.3), rowH, { hShare: 0.36, max: 72 * u, min: 30 * u })));
63
+ const valPx = Math.round(Math.min(barH * 0.62, labelPx * 1.15));
64
+ const labelW = vertical ? 0 : Math.round(Math.max(...rows.map((r) => textEm(r.label))) * labelPx + 32 * u);
65
+ const valW = Math.round(Math.max(...finals.map((f) => textEm(f))) * valPx * 1.05 + 24 * u);
66
+ const trackW = w * 0.94 - labelW - valW;
67
+ const x0 = w * 0.03;
68
+ const y0 = (h - gridH) / 2;
69
+
70
+ const host = node(root, "position:absolute;inset:0");
71
+ const hits = spaced(kit.hits.length ? kit.hits : [0], 0.3);
72
+ // 줄마다 자라기 시작하는 박 — 마지막 줄이 끝에서 강조할 틈(0.7초)을 남기고 다 자라게 편다
73
+ // 줄은 박마다 하나씩 0.75초에 자란다 — 장면 길이에 맞춰 늘이면 긴 장면에서 줄 사이가 멈춘 화면이 된다
74
+ const growDur = 0.75;
75
+ const starts: number[] = [];
76
+ for (const b of hits) if (starts.length < n && (!starts.length || b >= starts[starts.length - 1] + 0.32)) starts.push(b);
77
+ while (starts.length < n) starts.push((starts[starts.length - 1] ?? 0) + 0.4);
78
+ const hotAt = Math.max(...starts) + growDur;
79
+
80
+ const lines = rows.map((r, i) => {
81
+ const y = y0 + i * rowH;
82
+ const row = node(host, `position:absolute;left:${x0}px;top:${y}px;width:${w * 0.94}px;height:${rowH}px`);
83
+ const label = node(row, vertical
84
+ ? `position:absolute;left:0;top:0;font-family:var(--kf-font-sans);font-size:${labelPx}px;font-weight:600;color:var(--kf-ink);white-space:nowrap;line-height:1`
85
+ : `position:absolute;left:0;top:50%;width:${labelW - 16 * u}px;margin-top:-0.55em;text-align:right;font-family:var(--kf-font-sans);font-size:${labelPx}px;font-weight:600;color:var(--kf-ink);white-space:nowrap;line-height:1.1;min-height:1.1em`);
86
+ const barTop = vertical ? labelPx * 1.2 : (rowH - barH) / 2;
87
+ const track = node(row, `position:absolute;left:${labelW}px;top:${barTop}px;width:${trackW}px;height:${vertical ? Math.round(rowH - labelPx * 1.4) : barH}px`);
88
+ const bar = node(track, `position:absolute;left:0;top:0;bottom:0;width:${((r.value / max) * 100).toFixed(3)}%;background:${i === hot ? barColor : "var(--kf-dim)"};border-radius:${radius};transform-origin:0 50%`);
89
+ const val = node(row, `position:absolute;top:${barTop}px;height:${vertical ? Math.round(rowH - labelPx * 1.4) : barH}px;display:flex;align-items:center;font-family:var(--kf-font-mono);font-size:${valPx}px;font-weight:700;color:var(--kf-ink);font-variant-numeric:tabular-nums;white-space:nowrap`);
90
+ const writeLabel = slots.write(label, { ctx, text: r.label, at: starts[i], dur: 0.25, seed: `${kit.seed}:l${i}` });
91
+ const landed = slots.land(val, { ctx, at: starts[i] + growDur, seed: `${kit.seed}:v${i}` });
92
+ return { r, i, row, bar, val, writeLabel, landed };
93
+ });
94
+ const hotLine = lines[Math.max(0, hot)];
95
+ kit.stage?.hero(hotLine.val);
96
+ // 다 자라면 짚개가 강조 줄 ↔ 다른 줄을 박마다 번갈아 짚는다 — 강조 줄과 견주는 몫
97
+ const point = glider(host, [hotLine.row, ...lines.filter((L) => L !== hotLine).map((L) => L.row)], { hub: true, pad: 0.04 });
98
+ const hotLand = slots.land(hotLine.val, { ctx, at: hotAt, seed: `${kit.seed}:hot` });
99
+ const stops = beatStops(kit.hits, hotAt - 0.1, ctx.dur);
100
+
101
+ return (t: number) => {
102
+ moving("bars.rank", ctx);
103
+ for (const L of lines) {
104
+ const at = starts[L.i];
105
+ L.row.style.visibility = t >= at ? "visible" : "hidden";
106
+ if (t < at) continue;
107
+ L.writeLabel(t);
108
+ const roll = factRoll(t, { from: 0, to: L.r.value, at, dur: growDur, decimals: dec });
109
+ applyStyle(L.bar, { scaleX: Math.max(0.004, roll.p) });
110
+ L.val.textContent = fmtVal(L.r, roll.text);
111
+ L.val.style.left = `${labelW + trackW * (L.r.value / max) * roll.p + 16 * u}px`;
112
+ applyStyle(L.val, L.i === hotLine.i ? combine(L.landed(t), hotLand(t)) : L.landed(t));
113
+ }
114
+ point(t, stops);
115
+ };
116
+ },
117
+ };
118
+
119
+ export default pattern;