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
@@ -86,6 +86,10 @@ export default {
86
86
  | `fold` | 병풍처럼 접혀 옆으로 빠진다. `fold: "roll"` 이면 아래부터 말려 올라간다 |
87
87
  | `diveChain` | 화면 속 화면 — `screens` 를 깊이로 늘어놓고 끊김 없이 뚫고 들어간다. `arrivals: "bar"`·`"beat"`(박마다 한 겹) 또는 시각 배열, `settle` 0 흘러가기 … 1 화면마다 멈추기 |
88
88
  | `whip` | 휙 넘기기 — 제자리에서 90° 를 휙 돈다. 반대로 살짝 감았다가 확 돌고 넘쳤다 선다, 도는 동안 기울고 화각이 넓어진다 |
89
+ | `rise` | 2D → 3D 공간 잇기 — 앞 장면 판이 바닥으로 눕고, 그 주인공 상자(`[data-kf-hero]`, 없으면 가장 넓은 글·`hero` 로 직접)가 같은 화면 자리에서 블록으로 선다(옆면은 그 요소의 2D 색). 카메라는 깊이로 기울어 들어가 블록 위를 지나 뒤 장면 벽 앞에 정면으로 선다 |
90
+ | `land` | 3D → 2D 공간 잇기 — 앞 장면(3D 샷 끝 화면) 앞 공간에 누운 판이 일어서고 카메라가 그 판으로 다가간다. 판 위 주인공 블록은 평면으로 내려앉고, 판이 정면으로 꽉 찬 순간이 뒤 장면 첫 화면 |
91
+
92
+ - **섬광 안전** — 화면 사이로 보이는 하늘은 앞 화면 바탕색 → 뒤 화면 바탕색으로 서서히 넘어간다(`bridgeSky` — 밝기 순 가운데 절반 평균). 밝은 화면과 어두운 공간이 칸마다 엇갈려 오가면 `kf check` 의 `frame.flash` 에 걸린다. `whip` 은 가장 빠를 때 두 판의 대비를 그 색 쪽으로 누른다(셔터가 번진 것처럼, 양 끝은 0).
89
93
 
90
94
  - **살아 있는 화면** — `live: { kf, scenes: import.meta.glob("./*.ts", { eager: true }) }` 를 주면 앞 장면은 자기 끝을 지나 계속 돌고(타자·카운터가 전환 중에도 이어진다) 뒤 장면은 첫 프레임에서 기다린다. 다른 장면 하나는 `sceneSource(모듈, ctx, { id, from, dur }, (t) => 그 장면 시각)`, 영상은 `{ video: 주소, from: 영상 전체 기준 0 초 }`, 아무 DOM 은 `{ dom(root) { …; return (t) => … }, still? }`. DOM 은 크롬 HTML-in-Canvas(`--enable-features=CanvasDrawElement`, 렌더·검사 브라우저는 켜져 있다)로 seek 마다 paint 이벤트 안에서 텍스처에 올리고 `onSeek` 이 그 페인트를 기다린다 — 같은 t 두 번이 바이트까지 같다. 플래그 없는 브라우저는 `still`(이음매는 `assets/seam` 그림)로 물러난다.
91
95
  - **그림** — `from`·`to` 에 주소(`kf still` PNG 등)·`<img>`·`<canvas>`·그리기 함수 `(g, t, w, h) => void`(같은 t 같은 그림). ctx 만 주면 `assets/seam/<장면id>-from.png`(앞 장면 마지막 프레임)·`-to.png`(뒤 장면 첫 프레임)을 쓰고, 없으면 스타일 색으로 그린 대체 화면(없는 파일을 부르지 않아 검사가 깨끗하다). 찍는 곳은 `kf make`(장면을 다 쓴 뒤 `@kasaframe/gen` 의 `seamFrames` 프레임으로).
@@ -115,6 +119,13 @@ transition3d(root, ctx, { kind: "smooth:sdf-merge" }); // SmoothUI 덮개
115
119
  - 들이지 않은 것 — React Bits·Animate UI(MIT + Commons Clause), Aceternity UI(재배포 금지). 출처·라이선스 원문은 레포 루트 `NOTICE`, 폴더마다 원본 `LICENSE`.
116
120
  - 갤러리 — `packages/motion/demo/transitions-gallery`(이름으로 부르는 145개 + CSS 부품, seek), `transitions-gallery-step`(rAF 부품 8개, step). `npx kf sheet <폴더> --per-scene 1` 이 전환마다 가운데 프레임 한 장.
117
121
 
122
+ ## 연출 수 — `@kasaframe/motion/moves`
123
+
124
+ 영상에서 모은 수(글·선·그림 바탕·전환·UI) — 수마다 3–5초 예시와 갤러리는 [`examples/moves/`](../../examples/moves/README.md). 꼴은 거의 다 `수(el, { w, h, hits, scene, … }) → (t) => void`.
125
+
126
+ - `MENU`·`bundleFor(틀 moves, 테마)`·`pickDress(씨앗, 장면, 묶음)`(`@kasaframe/motion/menu` — DOM 없이 Node 에서도): 뼈대가 바로 거는 메뉴와 장면마다 시드로 고르기. 같은 요청이면 같은 수, 이어지는 장면은 다른 수, 경계 덮기는 이음매·드롭 앞에 안 건다.
127
+ - `dressBg`·`dressText`·`dressWipe`: 메뉴 이름을 장면에 거는 쪽 — 예시에서 검사를 넘긴 값을 기본으로. 덮기는 경계의 영상 시각을 씨앗으로 써 앞 장면 끝과 뒤 장면 처음이 같은 덮개로 이어진다. 무대(`lib/stage.ts` `makeStage(root, ctx, { dress })`)가 부른다.
128
+
118
129
  ## 규칙(style.json `motion`)과 검사
119
130
 
120
131
  - 박 펄스·흔들림·섬광·카메라·등장 움직임은 효과가 0 이 아닌 프레임마다 `window.__kf.events` 에 한 줄 남긴다 — `kf check` 가 규칙 위반을 센다. **손으로 짠 흔들림은 안 잡힌다** — 위 부품을 쓴다.
@@ -11,6 +11,7 @@
11
11
  "./transitions2d": "./src/transitions2d/index.ts",
12
12
  "./transitions2d/react": "./src/transitions2d/react.ts",
13
13
  "./moves": "./src/moves/index.ts",
14
+ "./menu": "./src/moves/menu.ts",
14
15
  "./captions": "./src/captions.ts"
15
16
  },
16
17
  "dependencies": {
@@ -1,4 +1,4 @@
1
- import type { KfStyle, SafeArea } from "@kasaframe/core";
1
+ import type { CaptionLook, KfStyle, SafeArea } from "@kasaframe/core";
2
2
 
3
3
  // 자막 — 들릴 말(captions.json)을 장면 시각에 나눠 화면 아래 판에 얹는다.
4
4
  // srt·vtt(plan 의 writeCaptions)와 같은 toCues 를 써서 트랙과 화면 자막이 한 마디도 어긋나지 않는다.
@@ -61,6 +61,10 @@ export interface CaptionMetrics {
61
61
  centerX: number;
62
62
  /** 자막 띠 위아래 여백(px) */
63
63
  gap: number;
64
+ /** 한 마디 줄 수 */
65
+ maxLines: number;
66
+ /** style.json captions.position — 판 가운데 y(px). 있으면 bottom 대신 이 자리 */
67
+ centerY?: number;
64
68
  }
65
69
 
66
70
  /** 판 높이(px) — 줄 수만큼 */
@@ -69,16 +73,18 @@ export function plateHeight(m: CaptionMetrics, lines: number): number {
69
73
  }
70
74
 
71
75
  /** 자막 띠 — 안전 영역 아래쪽에서 장면이 비워 줄 높이(px). 판 + 위아래 여백 */
72
- export function captionBand(m: CaptionMetrics, lines = 2): number {
73
- return plateHeight(m, lines) + m.gap * 2;
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));
74
80
  }
75
81
 
76
- export function captionMetrics(o: { width: number; height: number; safe?: SafeArea | null; minPx?: number }): CaptionMetrics {
82
+ export function captionMetrics(o: { width: number; height: number; safe?: SafeArea | null; minPx?: number; look?: CaptionLook | null }): CaptionMetrics {
77
83
  const safe = o.safe ?? { top: 0, bottom: 0, left: 0, right: 0 };
78
84
  const short = Math.min(o.width, o.height);
79
85
  const vertical = o.height > o.width;
80
86
  // 세로 쇼츠는 폰 한 손 거리에서 읽혀야 한다 — 폭의 8%(1080 폭이면 86px). 가로는 높이의 5%
81
- const fontPx = Math.max(o.minPx ?? 0, Math.round(vertical ? o.width * 0.08 : short * 0.05));
87
+ const fontPx = Math.max(o.minPx ?? 0, Math.round(o.look?.size ?? (vertical ? o.width * 0.08 : short * 0.05)));
82
88
  const padX = Math.round(fontPx * 0.5);
83
89
  const padY = Math.round(fontPx * 0.2);
84
90
  const gap = Math.round(short * 0.02);
@@ -96,6 +102,8 @@ export function captionMetrics(o: { width: number; height: number; safe?: SafeAr
96
102
  bottom: o.height - safe.bottom - gap,
97
103
  centerX: safe.left + (o.width - safe.left - safe.right) / 2,
98
104
  gap,
105
+ maxLines: Math.max(1, Math.round(o.look?.maxLines ?? 2)),
106
+ ...(o.look?.position !== undefined ? { centerY: o.look.position } : {}),
99
107
  };
100
108
  }
101
109
 
@@ -173,8 +181,8 @@ export function splitPhrases(text: string): string[] {
173
181
  }
174
182
 
175
183
  // 두 줄에 안 드는 마디는 어절 경계에서 몇 토막으로 나눈다 — 토막 길이는 고르게, 끝은 조사·어미 뒤에서(「흡수해 잠재 / 공간에서」처럼 덩어리를 가르지 않게)
176
- function chunk(text: string, maxEm: number): string[] {
177
- if (wrapCaption(text, maxEm)) return [text];
184
+ function chunk(text: string, maxEm: number, lines = 2): string[] {
185
+ if (wrapCaption(text, maxEm, emWidth, lines)) return [text];
178
186
  const words = text.split(/\s+/).filter(Boolean);
179
187
  const n = words.length;
180
188
  const part = (i: number, j: number) => words.slice(i, j).join(" ");
@@ -189,7 +197,7 @@ function chunk(text: string, maxEm: number): string[] {
189
197
  for (let i = c - 1; i < j; i++) {
190
198
  if (!Number.isFinite(best[i][c - 1])) continue;
191
199
  const p = part(i, j);
192
- if (!wrapCaption(p, maxEm)) continue;
200
+ if (!wrapCaption(p, maxEm, emWidth, lines)) continue;
193
201
  const cost = best[i][c - 1] + Math.abs(emWidth(p) - ideal) / ideal + (j < n && looseEnd(words[j - 1]) ? 0.6 : 0);
194
202
  if (cost < best[j][c]) {
195
203
  best[j][c] = cost;
@@ -209,11 +217,11 @@ function chunk(text: string, maxEm: number): string[] {
209
217
  }
210
218
 
211
219
  // 한 줄 반에 못 미치는 짧은 마디(「MLA,」)는 다음 마디에 붙인다 — 0.5초짜리 자막은 읽기 전에 사라진다
212
- function merge(parts: string[], maxEm: number): string[] {
220
+ function merge(parts: string[], maxEm: number, lines = 2): string[] {
213
221
  const out: string[] = [];
214
222
  for (const p of parts) {
215
223
  const prev = out[out.length - 1];
216
- if (prev !== undefined && emWidth(prev) < 4 && wrapCaption(`${prev}, ${p}`, maxEm)) out[out.length - 1] = `${prev}, ${p}`;
224
+ if (prev !== undefined && emWidth(prev) < 4 && wrapCaption(`${prev}, ${p}`, maxEm, emWidth, lines)) out[out.length - 1] = `${prev}, ${p}`;
217
225
  else out.push(p);
218
226
  }
219
227
  return out;
@@ -232,7 +240,7 @@ function spread(texts: string[], from: number, to: number, cues: Cue[]): void {
232
240
  }
233
241
  }
234
242
 
235
- function fromWords(raw: CaptionWord[], maxEm: number, cues: Cue[]): number {
243
+ function fromWords(raw: CaptionWord[], maxEm: number, cues: Cue[], lines = 2): number {
236
244
  const before = cues.length;
237
245
  const words = raw
238
246
  .map((w) => ({ text: (w.text ?? w.w ?? "").trim(), from: w.from ?? w.start ?? NaN, to: w.to ?? w.end ?? NaN }))
@@ -244,7 +252,7 @@ function fromWords(raw: CaptionWord[], maxEm: number, cues: Cue[]): number {
244
252
  cur = [];
245
253
  };
246
254
  for (const w of words) {
247
- if (cur.length && !wrapCaption([...cur, w].map((x) => x.text).join(" "), maxEm)) flush();
255
+ if (cur.length && !wrapCaption([...cur, w].map((x) => x.text).join(" "), maxEm, emWidth, lines)) flush();
248
256
  cur.push(w);
249
257
  if (/[.?!。]$/.test(w.text)) flush();
250
258
  }
@@ -256,12 +264,13 @@ function fromWords(raw: CaptionWord[], maxEm: number, cues: Cue[]): number {
256
264
  * captions.json 줄 → 시각 붙은 자막 마디. 장면에만 묶인 줄은 쉼표·마침표 마디로 나눠 글자 수 비율로 장면 시간에 펴고,
257
265
  * from·to 가 있으면 그 안에, words 가 있으면 낱말 시각 그대로. maxEm(captionMetrics) 을 주면 마디마다 두 줄 안에 들게 더 나눈다.
258
266
  */
259
- export function toCues(lines: CaptionLine[], scenes: Span[], o: { maxEm?: number; duration?: number } = {}): Cue[] {
267
+ export function toCues(lines: CaptionLine[], scenes: Span[], o: { maxEm?: number; duration?: number; maxLines?: number } = {}): Cue[] {
260
268
  const maxEm = o.maxEm ?? Infinity;
269
+ const rows = o.maxLines ?? 2;
261
270
  const cues: Cue[] = [];
262
271
  for (const l of lines) {
263
272
  const text = (l.text ?? "").trim();
264
- if (l.words?.length && fromWords(l.words, maxEm, cues) > 0) continue;
273
+ if (l.words?.length && fromWords(l.words, maxEm, cues, rows) > 0) continue;
265
274
  if (!text) continue;
266
275
  let from: number;
267
276
  let to: number;
@@ -275,7 +284,7 @@ export function toCues(lines: CaptionLine[], scenes: Span[], o: { maxEm?: number
275
284
  from = s.from + pad;
276
285
  to = s.from + s.dur - pad;
277
286
  }
278
- const parts = merge(splitPhrases(text).flatMap((p) => chunk(p, maxEm)), maxEm);
287
+ const parts = merge(splitPhrases(text).flatMap((p) => chunk(p, maxEm, rows)), maxEm, rows);
279
288
  spread(parts, from, to, cues);
280
289
  }
281
290
  cues.sort((a, b) => a.from - b.from);
@@ -321,7 +330,7 @@ function palette(style: KfStyle | null | undefined): { plate: string; ink: strin
321
330
  * 판은 안전 영역 아래쪽 가운데, 한 마디는 두 줄까지 — 넘치면 글자를 줄인다.
322
331
  */
323
332
  export async function captionLayer(host: HTMLElement, o: CaptionLayerOptions): Promise<{ draw: (t: number) => void; band: number }> {
324
- const m = captionMetrics(o);
333
+ const m = captionMetrics({ ...o, look: o.style?.captions });
325
334
  const fam = o.style?.fonts?.sans ?? '"Pretendard Variable", Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", sans-serif';
326
335
  const weight = 700;
327
336
  const font = (px: number) => `${weight} ${px}px ${fam}`;
@@ -335,11 +344,11 @@ export async function captionLayer(host: HTMLElement, o: CaptionLayerOptions): P
335
344
  };
336
345
  const laid: Laid[] = o.cues.map((cue) => {
337
346
  for (let px = m.fontPx; px >= Math.round(m.fontPx * 0.8); px--) {
338
- const lines = wrapCaption(cue.text, m.textMaxPx, measure(px));
347
+ const lines = wrapCaption(cue.text, m.textMaxPx, measure(px), m.maxLines);
339
348
  if (lines) return { cue, lines, fontPx: px };
340
349
  }
341
350
  const px = Math.round(m.fontPx * 0.8);
342
- return { cue, lines: wrapCaption(cue.text, m.textMaxPx, measure(px), 3) ?? [cue.text], fontPx: px };
351
+ return { cue, lines: wrapCaption(cue.text, m.textMaxPx, measure(px), m.maxLines + 1) ?? [cue.text], fontPx: px };
343
352
  });
344
353
 
345
354
  const { plate: plateBg, ink, edge } = palette(o.style);
@@ -351,8 +360,7 @@ export async function captionLayer(host: HTMLElement, o: CaptionLayerOptions): P
351
360
  plate.style.cssText = [
352
361
  "position:absolute",
353
362
  `left:${m.centerX}px`,
354
- `bottom:${o.height - m.bottom}px`,
355
- "transform:translateX(-50%)",
363
+ ...(m.centerY !== undefined ? [`top:${m.centerY}px`, "transform:translate(-50%, -50%)"] : [`bottom:${o.height - m.bottom}px`, "transform:translateX(-50%)"]),
356
364
  `max-width:${m.textMaxPx + m.padX * 2}px`,
357
365
  "box-sizing:border-box",
358
366
  `padding:${m.padY}px ${m.padX}px`,
@@ -452,7 +460,7 @@ export interface CaptionsPlan {
452
460
  * on: 무대(ctx.safe 로 배치하는 장면)가 띠 위로 제 크기 그대로 올라간다. 픽셀을 박아 둔 장면은 안 움직여 kf check 가 caption.overlap 으로 잡는다.
453
461
  * fit: 장면 코드와 안전 영역은 그대로 두고 장면 뿌리를 띠 위로 줄여 올린다 — 어떤 장면이든 안 겹치지만 내용이 작아지고 움직이는 면적도 준다.
454
462
  */
455
- export async function captionsPlan(o: { kf: ProjectLike; mode?: string | null }): Promise<CaptionsPlan | null> {
463
+ export async function captionsPlan(o: { kf: ProjectLike; mode?: string | null; style?: KfStyle | null }): Promise<CaptionsPlan | null> {
456
464
  const raw = o.mode ?? o.kf.captions;
457
465
  if (captionMode(raw) !== "on") return null;
458
466
  const res = await fetch(new URL("captions.json", document.baseURI)).catch(() => null);
@@ -462,12 +470,12 @@ export async function captionsPlan(o: { kf: ProjectLike; mode?: string | null })
462
470
  const { kf } = o;
463
471
  const minPx = kf.text?.minPx;
464
472
  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 });
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 });
467
475
  if (!cues.length) return null;
468
476
  const fit = raw === "fit";
469
477
  // 띠는 판이 실제로 쓸 줄 수만큼(어림) — 장면을 짓기 전이라 아직 글꼴로 잴 수 없다
470
- const band = captionBand(m, cues.some((c) => !wrapCaption(c.text, m.maxEm, emWidth, 1)) ? 2 : 1);
478
+ const band = captionBand(m, cues.some((c) => !wrapCaption(c.text, m.maxEm, emWidth, 1)) ? m.maxLines : 1);
471
479
  return {
472
480
  safe: fit ? base : { ...base, bottom: base.bottom + band },
473
481
  async mount(stage, style) {
@@ -71,7 +71,8 @@ export function brushWipe(el: HTMLElement, o: BrushWipeOpts): (t: number) => voi
71
71
  g.closePath();
72
72
  g.fill();
73
73
  // 마른 붓 줄무늬 — 띠 안에서 밝은 결 몇 줄
74
- g.globalAlpha = 0.18;
74
+ // 결은 옅게 — 진하면 띠가 지나는 동안 칸마다 밝음·어둠이 오가 섬광 검사(frame.flash)에 걸린다
75
+ g.globalAlpha = 0.07;
75
76
  g.fillStyle = "#ffffff";
76
77
  const a = Math.min(edge(head, 0, 12), edge(tail, 1, 12));
77
78
  const b = Math.max(edge(head, 0, 12), edge(tail, 1, 12));
@@ -83,6 +84,18 @@ export function brushWipe(el: HTMLElement, o: BrushWipeOpts): (t: number) => voi
83
84
  g.fillRect(Math.min(x0, x1), y, Math.abs(x1 - x0), 2 + 5 * r(3));
84
85
  }
85
86
  }
87
+ // 다 덮인 동안 광택 한 줄이 띠를 따라 휙 지난다 — 한 색으로 덮인 화면이 0.1초 넘게 서 있으면 멈춤(frame.freeze)으로 잡힌다
88
+ const sp = clamp((t - o.at - dur * 0.22) / (dur * 0.56));
89
+ if (sp > 0 && sp < 1) {
90
+ const x = -D / 2 + D * sp;
91
+ const gr = g.createLinearGradient(x - D * 0.12, 0, x + D * 0.12, 0);
92
+ gr.addColorStop(0, "rgba(255,255,255,0)");
93
+ gr.addColorStop(0.5, "rgba(255,255,255,.14)");
94
+ gr.addColorStop(1, "rgba(255,255,255,0)");
95
+ g.globalAlpha = 1;
96
+ g.fillStyle = gr;
97
+ g.fillRect(x - D * 0.12, -D / 2, D * 0.24, D);
98
+ }
86
99
  g.restore();
87
100
  g.globalAlpha = 1;
88
101
  mark("brushWipe", o.scene, true);
@@ -0,0 +1,191 @@
1
+ import type { SceneCtx } from "@kasaframe/core";
2
+ import { brushWipe } from "./brushWipe.ts";
3
+ import { echoStack } from "./echoStack.ts";
4
+ import { div } from "./fit.ts";
5
+ import { flood } from "./flood.ts";
6
+ import { ghostStack } from "./ghostStack.ts";
7
+ import { gridFloor } from "./gridFloor.ts";
8
+ import { irisBlades } from "./irisBlades.ts";
9
+ import { ledBoard } from "./ledBoard.ts";
10
+ import { neonSign } from "./neonSign.ts";
11
+ import { ransomLetters } from "./ransomLetters.ts";
12
+ import { slamWords } from "./slamWords.ts";
13
+ import { speedLines } from "./speedLines.ts";
14
+ import { splitFlap } from "./splitFlap.ts";
15
+ import { sunburst } from "./sunburst.ts";
16
+ import { truchet } from "./truchet.ts";
17
+
18
+ // 메뉴(menu.ts)의 이름을 장면에 실제로 건다 — 무대(lib/stage.ts)가 부른다. 수마다 예시 장면에서 검사를 넘긴 값을 기본으로 박아 두어,
19
+ // 뼈대가 고른 수를 그대로 둬도 kf check 를 넘는다. 낱말만 바꿔 쓰면 된다.
20
+
21
+ type Paint = (t: number) => void;
22
+
23
+ const color = (ctx: SceneCtx, key: string, fallback: string): string => {
24
+ const v = ctx.style?.colors?.[key];
25
+ return typeof v === "string" && v ? v : fallback;
26
+ };
27
+
28
+ // 상대휘도(WCAG) — #rgb·#rrggbb 만. 못 읽으면 NaN
29
+ function lum(hex: string): number {
30
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
31
+ if (!m) return NaN;
32
+ const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
33
+ const ch = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
34
+ return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
35
+ }
36
+
37
+ // 오려 붙인 글자는 한 색이 글자색(바탕 위)도 되고 색종이(검은 글자 밑)도 된다 — 둘 다 3:1 을 넘는 가운데 밝기(0.12–0.3)만 쓴다.
38
+ // 밝은 테마·어두운 테마 어디서나 읽힌다. 테마 강조색이 그 안이면 앞에 둔다
39
+ const RANSOM = ["#E4572E", "#2E86DE", "#16A085", "#8E44AD", "#C0392B", "#D35400"];
40
+
41
+ /** 장면 안 박 — 빠른 곡은 박을 묶어 간격 gap 넘게. 잰 시각 그대로(BPM 을 곱하지 않는다) */
42
+ export function hitTimes(ctx: SceneCtx, gap = 0.4): number[] {
43
+ const b = ctx.beats.beats.filter((x) => x >= 0 && x < ctx.dur);
44
+ if (b.length < 2) return b.length ? b : [0];
45
+ const step = (b[b.length - 1] - b[0]) / (b.length - 1);
46
+ const every = Math.max(1, Math.ceil(gap / Math.max(step, 1e-3)));
47
+ const out = b.filter((_, i) => i % every === 0);
48
+ // 첫 박이 늦으면 첫 화면이 빈다 — 0 에 하나 세운다
49
+ if (out[0] > 0.12) out.unshift(0);
50
+ return out;
51
+ }
52
+
53
+ const cycle = (words: readonly string[], n: number): string[] => Array.from({ length: n }, (_, i) => words[i % words.length]);
54
+
55
+ // 한 수를 창(박 n 개)마다 새로 세운다 — 박이 쌓일수록 작아지는 수(메아리·네온)를 긴 장면에서도 같은 크기로 되풀이한다
56
+ function windows(host: HTMLElement, hits: readonly number[], per: number, make: (el: HTMLElement, hits: number[], k: number) => Paint): Paint {
57
+ const starts: number[] = [];
58
+ const paints: { el: HTMLElement; paint: Paint }[] = [];
59
+ for (let i = 0, k = 0; i < hits.length; i += per, k++) {
60
+ const el = div(host.ownerDocument, "position:absolute;inset:0");
61
+ host.append(el);
62
+ starts.push(hits[i]);
63
+ paints.push({ el, paint: make(el, hits.slice(i, i + per), k) });
64
+ }
65
+ return (t) => {
66
+ let k = 0;
67
+ while (k + 1 < starts.length && starts[k + 1] <= t) k++;
68
+ paints.forEach((p, i) => {
69
+ p.el.style.display = i === k ? "block" : "none";
70
+ if (i === k) p.paint(t);
71
+ });
72
+ };
73
+ }
74
+
75
+ /** 바탕 수 — el 은 화면 전체. still(기본)은 정지라 그리는 함수가 없다(null). 모르는 이름도 still */
76
+ export function dressBg(name: string, el: HTMLElement, ctx: SceneCtx): Paint | null {
77
+ const { width: w, height: h } = ctx.meta;
78
+ const hits = hitTimes(ctx);
79
+ const bg = color(ctx, "bg", "#0b0b0f");
80
+ const accent = color(ctx, "accent", "#ff5a1f");
81
+ const soft = color(ctx, "accentSoft", accent);
82
+ const line = color(ctx, "line", "#888");
83
+ el.style.background = bg;
84
+ switch (name) {
85
+ case "sunburst":
86
+ // 글 뒤에 깔리니 바탕에 강조색을 조금만 섞은 두 색 — 박마다 툭 도는 줄무늬가 진하면 화면 대부분이 한꺼번에 꺾여 섬광 검사(frame.flash)에 걸린다
87
+ return sunburst(el, { w, h, hits, rays: h > w ? 16 : 20, colors: [`color-mix(in srgb, ${accent} 14%, ${bg})`, bg], glow: "transparent", speed: 10, scene: ctx.id });
88
+ case "speedLines":
89
+ return speedLines(el, { w, h, kind: "radial", color: soft, inner: 0.36, boil: 8, scene: ctx.id });
90
+ case "gridFloor":
91
+ return gridFloor(el, { w, h, color: soft, speed: 1.4, glow: 0, scene: ctx.id });
92
+ case "truchet":
93
+ return truchet(el, { w, h, hits, cell: Math.round(Math.min(w, h) / 9), bg, line, accent: soft, share: 0.3, scene: ctx.id });
94
+ default:
95
+ // 바탕색 위 아주 옅은 점 — 서 있는 질감이라 화면이 비어 보이지 않을 뿐 아무것도 뜻하지 않으니 움직이지 않는다.
96
+ // 바탕색은 무대 상자에 맡긴다(장면이 마디마다 바탕색을 바꿀 수 있게)
97
+ el.style.background = "";
98
+ el.style.backgroundImage = `radial-gradient(circle, color-mix(in srgb, ${line} 55%, transparent) 1.5px, transparent 2px)`;
99
+ el.style.backgroundSize = `${Math.round(Math.min(w, h) / 24)}px ${Math.round(Math.min(w, h) / 24)}px`;
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * 글 수 — el 은 안전 영역 상자(w×h). words 를 차례로 돌려 쓴다. bigWord(무대 기본)거나 모르면 null — 무대가 큰 낱말을 세운다.
106
+ * 박마다 내용이 바뀌어(새 낱말·새 벌·판 넘김) 같은 구도 검사(layout.static)를 수가 스스로 넘는다
107
+ */
108
+ export function dressText(name: string, el: HTMLElement, ctx: SceneCtx, o: { words: readonly string[]; w: number; h: number; ready?: boolean }): Paint | null {
109
+ const given = [...new Set(o.words.map((x) => x.trim()).filter(Boolean))];
110
+ const words = given.length ? given : ["kasaframe"];
111
+ const { w, h } = o;
112
+ const hits = hitTimes(ctx);
113
+ // 첫 프레임부터 글이 서 있어야 하는 장면(훅) — 첫 박을 장면 앞으로 당겨 이미 내려앉은 낱말로 시작한다
114
+ if (o.ready && hits[0] <= 0.15) hits[0] = -0.3;
115
+ const scene = ctx.id;
116
+ switch (name) {
117
+ case "slamWords": {
118
+ // 화면 폭을 채운 낱말이 1초에 두 번 바뀌면 섬광 세 번을 넘는다(frame.flash) — 1.2초 넘는 간격마다
119
+ const slams = hitTimes(ctx, 1.2);
120
+ if (o.ready && slams[0] <= 0.15) slams[0] = -0.3;
121
+ // 낱말 판은 가운데 띠(높이 55%)에 옅은 강조색으로 — 화면 전체를 뒤집으면 판이 바뀌는 박마다 화면 대부분이 한꺼번에
122
+ // 밝기가 꺾여 섬광 검사(frame.flash)에 걸린다. 띠가 보여야 긴 낱말 하나뿐인 세로 화면도 비어 보이지 않는다(layout.fill)
123
+ const bh = Math.round(h * 0.55);
124
+ const band = div(el.ownerDocument, `position:absolute;left:0;top:${Math.round((h - bh) / 2)}px;width:${w}px;height:${bh}px`);
125
+ el.append(band);
126
+ return slamWords(band, { words: cycle(words, slams.length), hits: slams, w, h: bh, palettes: [["color-mix(in srgb, var(--kf-accent) 10%, transparent)", "var(--kf-ink)"], ["color-mix(in srgb, var(--kf-accent) 24%, transparent)", "var(--kf-ink)"]], scene });
127
+ }
128
+ case "echoStack":
129
+ return windows(el, hits, 4, (box, hs, k) => echoStack(box, { word: words[k % words.length], hits: hs, dir: k % 2 ? "back" : "down", w, h, scene }));
130
+ case "splitFlap": {
131
+ const times = hits.filter((_, i) => i % 2 === 0);
132
+ const texts = cycle(words, times.length);
133
+ // 판에 같은 글이 이어 오르면 넘어가지 않는다 — 낱말이 하나면 판 넘김 대신 글자판을 비웠다 채운다
134
+ if (words.length === 1) texts.forEach((_, i) => i % 2 && (texts[i] = " ".repeat([...words[0]].length)));
135
+ return splitFlap(el, { texts, times, w, h, scene });
136
+ }
137
+ case "ghostStack": {
138
+ const times = hits.filter((_, i) => i % 2 === 0);
139
+ const lines = cycle(words, Math.max(4, times.length + 2)).map((text, i) => ({ text, at: times[i] ?? ctx.dur + i, hot: [0] }));
140
+ return ghostStack(el, { lines, visible: 3, w, h, scene });
141
+ }
142
+ case "ransomLetters": {
143
+ const accent = color(ctx, "accent", "");
144
+ const l = lum(accent);
145
+ const palette = l >= 0.12 && l <= 0.3 ? [accent, ...RANSOM] : RANSOM;
146
+ return windows(el, hits, 6, (box, hs, k) => ransomLetters(box, { text: words[k % words.length], at: Math.max(0, hs[0] - 0.02), hits: hs, palette, w, h, seed: `${scene}:${k}`, scene }));
147
+ }
148
+ case "neonSign": {
149
+ // 네온은 어두운 판 위에서 산다 — 밝은 테마에서도 간판 판을 깐다
150
+ const plate = div(el.ownerDocument, `position:absolute;inset:0;border-radius:${Math.round(Math.min(w, h) * 0.06)}px;background:#0a090d`);
151
+ el.append(plate);
152
+ const neon = color(ctx, "accent", "#FF4F7A");
153
+ return windows(plate, hits, 6, (box, hs, k) => neonSign(box, { text: words[k % words.length], at: hs[0] + 0.02, color: neon, w, h, seed: `${scene}:${k}`, scene }));
154
+ }
155
+ case "ledBoard": {
156
+ const text = cycle(words, 3).join(" ");
157
+ // 글이 장면 끝에서야 다 지나가는 빠르기 — 일찍 다 지나가면 빈 판이 멈춘 화면이 된다. 판 칸 수 = 폭 ÷ (판 높이/18), 글 폭은 16점 글꼴 0.6em 어림
158
+ const cols = Math.floor((w * 18) / (h * 0.4));
159
+ const speed = (cols + text.length * 16 * 0.6) / (ctx.dur + 0.3);
160
+ return ledBoard(el, { text, mode: "scroll", speed, at: 0, x: 0, y: h * 0.3, w, h: h * 0.4, stageW: w, stageH: h, color: color(ctx, "accent", "#FFB23E"), scene });
161
+ }
162
+ default:
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * 장면 경계를 걸치는 덮기 — out 은 장면 끝에서 덮고, in 은 다음 장면 처음에 걷힌다. 다 덮인 채 서 있는 시간은 0.05초 안팎 —
169
+ * 한 색 화면이 0.1초를 넘으면 멈춤 검사(frame.freeze)에 걸린다. 조리개는 끝까지 닫지 않고 바늘구멍에서 되돌아 연다. 두 장면이 경계의 영상 시각을 같은 씨앗으로 써서
170
+ * 앞 장면 마지막 프레임과 뒤 장면 첫 프레임이 같은 덮개로 이어진다. el 은 화면 전체, 맨 위 층
171
+ */
172
+ export function dressWipe(name: string, el: HTMLElement, ctx: SceneCtx, side: "in" | "out"): Paint | null {
173
+ const { width: w, height: h } = ctx.meta;
174
+ const B = side === "out" ? ctx.dur : 0;
175
+ const seed = `${name}@${(ctx.from + B).toFixed(3)}`;
176
+ const accent = color(ctx, "accent", "#5b3fd9");
177
+ const ink = color(ctx, "ink", "#111111");
178
+ switch (name) {
179
+ case "brushWipe":
180
+ return brushWipe(el, { w, h, at: B - 0.35, dur: 0.7, strokes: 5, color: accent, seed, scene: ctx.id });
181
+ case "irisBlades":
182
+ // 다 열린 육각 구멍이 화면 모서리까지 덮게 — 변까지 거리가 대각선 반을 넘어야 모서리에 날이 안 남는다
183
+ return irisBlades(el, { w, h, color: ink, seam: "rgba(255,255,255,.3)", radius: (Math.hypot(w, h) / 2 / Math.cos(Math.PI / 6)) * 1.04, stiffness: 260, damping: 32, steps: [{ at: B - 100, open: 1 }, { at: B - 0.32, open: 0 }, { at: B - 0.02, open: 1 }], scene: ctx.id });
184
+ case "flood": {
185
+ const zero = { x: w / 2, y: h / 2, w: 0, h: 0, radius: 0 };
186
+ return flood(el, { w, h, x: w / 2, y: h / 2, color: accent, steps: [{ at: B - 100, shape: zero }, { at: B - 0.28, shape: "cover" }, { at: B - 0.02, shape: zero }], scene: ctx.id });
187
+ }
188
+ default:
189
+ return null;
190
+ }
191
+ }
@@ -23,7 +23,7 @@ export function echoStack(el: HTMLElement, o: EchoStackOpts): (t: number) => voi
23
23
  const H = o.h ?? 1080;
24
24
  const n = o.hits.length;
25
25
  const back = o.dir === "back";
26
- const px = o.size ?? fitSize(o.word, W, H, back ? { share: 0.7, hShare: 0.5 } : { share: 0.62, hShare: 1.12 / Math.max(1, n) });
26
+ const px = o.size ?? fitSize(o.word, W, H, back ? { share: 0.7, hShare: 0.44 } : { share: 0.62, hShare: 1.12 / Math.max(1, n) });
27
27
  // 벌 사이를 촘촘히 — 메아리가 한 덩어리로 읽히게
28
28
  const lh = px * 0.74;
29
29
  const layer = div(doc, "position:absolute;inset:0;overflow:hidden");
@@ -63,8 +63,8 @@ export function echoStack(el: HTMLElement, o: EchoStackOpts): (t: number) => voi
63
63
  c.style.transform = `translate(calc(-50% + ${x.toFixed(1)}px),${(y - lh / 2).toFixed(1)}px)`;
64
64
  }
65
65
  });
66
- // 묶음 전체가 옆으로 흐르고 조금씩 다가가 멈추지 않는다
67
- block.style.transform = `translate(${(W * 0.03 * Math.sin(t * 1.7)).toFixed(1)}px,0) scale(${(1 + 0.02 * t).toFixed(4)})`;
66
+ // 묶음 전체가 옆으로 흔들리며 흘러 다음 박까지 서지 않는다 — 위아래로 퍼지거나 커지면 끝 벌이 화면 밖으로 잘린다
67
+ block.style.transform = `translate(${(W * 0.07 * Math.sin(t * 1.9)).toFixed(1)}px,0) scale(${(1 + 0.02 * t).toFixed(4)})`;
68
68
  mark("echoStack", o.scene, moving);
69
69
  };
70
70
  }
@@ -32,3 +32,39 @@ export function div(doc: Document, css: string, text?: string): HTMLDivElement {
32
32
  export function mark(name: string, scene: string | undefined, active: boolean): void {
33
33
  if (active) emit({ kind: "move", source: name, scene, name: `move:${name}`, value: 1 });
34
34
  }
35
+
36
+ /**
37
+ * 띠 글 — 낱말 조각들(pieces, 한 바퀴)을 이어 붙여 흐르게 하되, 화면 끝에 걸친 조각은 끝에 닿기 전에 흐려져 사라진다(양 끝 흐림).
38
+ * 글이 잘린 채 화면 끝에 머물면 검사가 text.offscreen 으로 잡는다 — 그래서 한 줄 대신 낱말마다 따로 둔다(긴 줄 하나면 늘 끝에 걸려 안 보인다).
39
+ * cycles 는 화면을 덮는 바퀴 수, em 은 조각 폭 어림(px, 처음 보일 때 잰 폭으로 바뀐다).
40
+ * 반환 함수: offset(띠 안 px, 흐른 거리) · toScreen(띠 안 x → 화면 x) · view(화면에서 보이는 [왼쪽, 오른쪽]) · fade(흐려지는 폭 px)
41
+ */
42
+ export function strip(host: HTMLElement, pieces: readonly string[], cycles: number, guess: (piece: string) => number): (offset: number, toScreen: (x: number) => number, view: [number, number], fade: number) => void {
43
+ const doc = host.ownerDocument;
44
+ const spans = Array.from({ length: cycles * pieces.length }, (_, k) => {
45
+ const s = doc.createElement("span");
46
+ s.textContent = pieces[k % pieces.length];
47
+ s.style.cssText = "position:absolute;left:0;top:50%;transform:translateY(-50%);white-space:pre";
48
+ host.append(s);
49
+ return s;
50
+ });
51
+ let widths: number[] | null = null;
52
+ return (offset, toScreen, view, fade) => {
53
+ // 조각 폭은 글꼴이 걸린 뒤 처음 보일 때 잰다(build 때는 장면이 숨어 있다)
54
+ if (!widths && spans[0].offsetWidth) widths = pieces.map((_, i) => spans[i].offsetWidth);
55
+ const ws = widths ?? pieces.map(guess);
56
+ const lap = ws.reduce((a, b) => a + b, 0) || 1;
57
+ const total = lap * cycles;
58
+ let acc = 0;
59
+ spans.forEach((s, k) => {
60
+ const w = ws[k % pieces.length];
61
+ const x = ((((offset + acc) % total) + total) % total) - lap;
62
+ acc += w;
63
+ const a = toScreen(x);
64
+ const b = toScreen(x + w);
65
+ const edge = Math.min(Math.min(a, b) - view[0], view[1] - Math.max(a, b));
66
+ s.style.left = `${x.toFixed(1)}px`;
67
+ s.style.opacity = Math.max(0, Math.min(1, edge / fade)).toFixed(3);
68
+ });
69
+ };
70
+ }
@@ -13,7 +13,7 @@ export interface GhostStackOpts {
13
13
  lines: GhostLine[];
14
14
  /** 한 화면에 보이는 줄 수 */
15
15
  visible?: number;
16
- /** 한 줄이 왼쪽에서 오른쪽으로 다 채워지는 데 드는 초 */
16
+ /** 한 줄을 채우는 가장 짧은 초 — 다음 줄 박이 있으면 그 박까지 같은 빠르기로 채운다 */
17
17
  fill?: number;
18
18
  w?: number;
19
19
  h?: number;
@@ -39,7 +39,8 @@ export function ghostStack(el: HTMLElement, o: GhostStackOpts): (t: number) => v
39
39
  el.append(layer);
40
40
  const rows = o.lines.map((l, i) => {
41
41
  const row = div(doc, `position:absolute;left:0;top:${i * lh}px;height:${lh}px`);
42
- const ghost = div(doc, "position:absolute;left:0;top:0;color:transparent;-webkit-text-stroke:.022em var(--kf-ink);opacity:.5");
42
+ // 유령 줄도 읽혀야 한다 — 가는 반투명 외곽선은 밝은 바탕에서 대비 1.2:1 로 검사(text.contrast)에 걸렸다
43
+ const ghost = div(doc, "position:absolute;left:0;top:0;color:transparent;-webkit-text-stroke:.03em var(--kf-ink);opacity:.85");
43
44
  const full = div(doc, "position:absolute;left:0;top:0;color:var(--kf-ink)");
44
45
  l.text.split(" ").forEach((w, j, all) => {
45
46
  const span = doc.createElement("span");
@@ -53,6 +54,8 @@ export function ghostStack(el: HTMLElement, o: GhostStackOpts): (t: number) => v
53
54
  return { row, ghost, full };
54
55
  });
55
56
  const times = o.lines.map((l) => l.at);
57
+ // 마지막 줄은 다음 박이 없다 — 앞 줄들의 박 간격만큼 채운다
58
+ const gap = times.length > 1 ? (times[times.length - 1] - times[0]) / (times.length - 1) : fillDur;
56
59
 
57
60
  return (t: number) => {
58
61
  t = safeTime(t);
@@ -65,14 +68,20 @@ export function ghostStack(el: HTMLElement, o: GhostStackOpts): (t: number) => v
65
68
  const scroll = prev + (cur - prev) * p;
66
69
  // 박 사이에도 묶음이 천천히 떠올라 멈추지 않는다
67
70
  const drift = 14 * t;
68
- stack.style.transform = `translateY(${(H / 2 - (vis * lh) / 2 - scroll * lh - drift).toFixed(1)}px)`;
71
+ const top = H / 2 - (vis * lh) / 2 - scroll * lh - drift;
72
+ stack.style.transform = `translateY(${top.toFixed(1)}px)`;
69
73
  rows.forEach((r, i) => {
70
74
  // 유령은 앞 줄이 채워질 때 나타난다
71
75
  const ghostAt = i === 0 ? times[0] - 0.3 : times[i - 1];
72
- r.ghost.style.opacity = (0.5 * clamp((t - ghostAt) / 0.15)).toFixed(3);
73
- const f = ease.outCubic(clamp((t - times[i]) / fillDur));
76
+ r.ghost.style.opacity = (0.85 * clamp((t - ghostAt) / 0.15)).toFixed(3);
77
+ // 다음 줄 박까지 같은 빠르기로 채운다 — 읽어 나가는 빠르기. 0.2초 만에 다 채우면 다음 박까지 화면이 선다
78
+ const span = Math.max(fillDur, times[i + 1] !== undefined ? times[i + 1] - times[i] : gap);
79
+ const f = clamp((t - times[i]) / span);
74
80
  r.full.style.clipPath = `inset(0 ${((1 - f) * 100).toFixed(2)}% 0 0)`;
75
81
  r.row.style.display = i < cur - 1 || i > k + 1 ? "none" : "block";
82
+ // 위로 밀려 나가는 줄은 화면 끝에 닿기 전에 흐려진다 — 잘린 글이 남지 않게(검사 text.offscreen)
83
+ const y = top + i * lh;
84
+ r.row.style.opacity = Math.max(0, Math.min(1, y / (lh * 0.3), (H - y - lh) / (lh * 0.3))).toFixed(3);
76
85
  });
77
86
  mark("ghostStack", o.scene, k >= 0 && t - times[k] < fillDur);
78
87
  };
@@ -64,8 +64,9 @@ export function imageSwarm(el: HTMLElement, o: ImageSwarmOpts): (t: number) => v
64
64
  return {
65
65
  card,
66
66
  home,
67
- cloud: { x: W * (-0.1 + 1.2 * r(1)) - cw / 2, y: H * (-0.1 + 1.2 * r(2)) - ch / 2, rot: (r(3) - 0.5) * 50, s: 0.55 + 0.75 * depth, blur: (1 - depth) * 4 },
68
- drift: { x: (r(4) - 0.5) * 120, y: (r(5) - 0.5) * 80, phase: r(6) * 6.28 },
67
+ // 구름 자리도 화면 안 — 화면 끝에 걸친 카드의 글이 잘린 채 떠 있지 않게(검사 text.offscreen)
68
+ cloud: { x: 60 + (W - cw * 1.3 - 120) * r(1), y: 40 + (H - ch * 1.3 - 80) * r(2), rot: (r(3) - 0.5) * 50, s: 0.55 + 0.75 * depth, blur: (1 - depth) * 4 },
69
+ drift: { x: (r(4) - 0.5) * 100, y: (r(5) - 0.5) * 70, phase: r(6) * 6.28 },
69
70
  order: r(7),
70
71
  };
71
72
  });
@@ -78,8 +79,9 @@ export function imageSwarm(el: HTMLElement, o: ImageSwarmOpts): (t: number) => v
78
79
  const fx = c.cloud.x + c.drift.x * Math.sin(t * 0.9 + c.drift.phase);
79
80
  const fy = c.cloud.y + c.drift.y * Math.cos(t * 0.7 + c.drift.phase);
80
81
  const start = o.gatherAt + c.order * stagger * n;
81
- let g = spring(t - start, { stiffness: 140, damping: 17 });
82
- if (o.scatterAt !== undefined) g -= spring(t - o.scatterAt - c.order * stagger * n * 0.5, { stiffness: 120, damping: 15 });
82
+ // 거의 출렁이지 않게 — 여러 색 카드가 출렁이며 칸을 오가면 섬광 검사(frame.flash)에 걸린다
83
+ let g = spring(t - start, { stiffness: 140, damping: 22 });
84
+ if (o.scatterAt !== undefined) g -= spring(t - o.scatterAt - c.order * stagger * n * 0.5, { stiffness: 120, damping: 21 });
83
85
  const x = fx + (c.home.x - fx) * g;
84
86
  const y = fy + (c.home.y - fy) * g;
85
87
  let rot = c.cloud.rot * (1 - g);
@@ -100,6 +102,8 @@ export function imageSwarm(el: HTMLElement, o: ImageSwarmOpts): (t: number) => v
100
102
  } else opacity = 1 - clamp(k * 1.4);
101
103
  }
102
104
  c.card.style.opacity = opacity.toFixed(3);
105
+ // 격자에 앉았는가(0·1) — 카드 안 글은 앉았을 때만 보이게 쓸 수 있다(흐리게 떠도는 카드 위 글은 대비 검사에 걸린다)
106
+ c.card.style.setProperty("--kf-seated", g > 0.92 ? "1" : "0");
103
107
  c.card.style.filter = g < 0.98 ? `blur(${(c.cloud.blur * (1 - g)).toFixed(2)}px)` : "";
104
108
  c.card.style.transform = `translate(${tx.toFixed(1)}px, ${ty.toFixed(1)}px) rotate(${rot.toFixed(2)}deg) scale(${s.toFixed(4)})`;
105
109
  if (Math.abs(1 - g) > 0.01 && g > 0.01) moving = true;
@@ -1,6 +1,27 @@
1
1
  // 영상에서 모은 연출 수 — docs/references/opus-videos.md. 수마다 3–5초 예시: examples/moves/<이름>/
2
2
  export * from "./fit.ts";
3
3
  export * from "./slamWords.ts";
4
+ export * from "./path.ts";
5
+ export * from "./sparkTrail.ts";
6
+ export * from "./pathText.ts";
7
+ export * from "./ghostStack.ts";
8
+ export * from "./echoStack.ts";
9
+ export * from "./wordWall.ts";
10
+ export * from "./tokenPicker.ts";
11
+ export * from "./splitFlap.ts";
12
+ export * from "./karaokeLine.ts";
13
+ export * from "./stepWords.ts";
14
+ export * from "./ransomLetters.ts";
15
+ export * from "./tickerRows.ts";
16
+ export * from "./strikeOut.ts";
17
+ export * from "./worldRoll.ts";
18
+ export * from "./markerWrite.ts";
19
+ export * from "./neonSign.ts";
20
+ export * from "./ledBoard.ts";
21
+ export * from "./brushWipe.ts";
22
+ export * from "./paperTear.ts";
23
+ export * from "./irisBlades.ts";
24
+ export * from "./flood.ts";
4
25
  export * from "./sunburst.ts";
5
26
  export * from "./speedLines.ts";
6
27
  export * from "./gridFloor.ts";
@@ -10,10 +31,8 @@ export * from "./shapeMorph.ts";
10
31
  export * from "./panelPop.ts";
11
32
  export * from "./imageSwarm.ts";
12
33
  export * from "./liquidTab.ts";
13
- export * from "./brushWipe.ts";
14
- export * from "./paperTear.ts";
15
- export * from "./irisBlades.ts";
16
- export * from "./flood.ts";
17
34
  export * from "./cloneArray.ts";
18
35
  export * from "./curtains.ts";
19
36
  export * from "./morphBox.ts";
37
+ export * from "./menu.ts";
38
+ export * from "./dress.ts";