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
@@ -1,4 +1,4 @@
1
- import { clamp, ease, safeTime } from "@kasaframe/core";
1
+ import { clamp, ease, lerp, safeTime } from "@kasaframe/core";
2
2
  import { div, mark } from "./fit.ts";
3
3
 
4
4
  export interface KaraokeLine {
@@ -53,17 +53,22 @@ export function karaokeLine(el: HTMLElement, o: KaraokeLineOpts): (t: number) =>
53
53
  const band = div(
54
54
  doc,
55
55
  `position:absolute;left:50%;top:${y}px;display:flex;gap:.3em;white-space:nowrap;padding:.22em .6em;border-radius:.5em;` +
56
- `background:rgba(10,10,14,.72);font-family:var(--kf-font-display);font-weight:800;font-size:${size}px;line-height:1.1;color:rgba(242,243,245,.55)`,
56
+ `background:rgba(10,10,14,.72);font-family:var(--kf-font-display);font-weight:800;font-size:${size}px;line-height:1.1;color:rgba(242,243,245,.42)`,
57
57
  );
58
+ // 지금 부르는 낱말 뒤 알약 — 낱말에서 낱말로 옮겨 가며 부르는 자리를 짚는다. 안 부른 글은 흐리게 둬 차오름이 밝기로도 보인다
59
+ const pill = div(doc, "position:absolute;left:0;top:.12em;height:1.3em;border-radius:.4em;background:color-mix(in srgb, var(--kf-accent) 30%, transparent)");
60
+ band.append(pill);
61
+ const spans: HTMLDivElement[] = [];
58
62
  const words = l.words.map((w) => {
59
63
  const span = div(doc, "position:relative;display:inline-block", w);
60
64
  const fill = div(doc, "position:absolute;left:0;top:0;color:var(--kf-accent)", w);
61
65
  span.append(fill);
62
66
  band.append(span);
67
+ spans.push(span);
63
68
  return fill;
64
69
  });
65
70
  layer.append(band);
66
- return { band, words };
71
+ return { band, words, pill, spans };
67
72
  });
68
73
 
69
74
  return (t: number) => {
@@ -81,13 +86,22 @@ export function karaokeLine(el: HTMLElement, o: KaraokeLineOpts): (t: number) =>
81
86
  if (inP < 1 || outP > 0) moving = true;
82
87
  r.band.style.opacity = (inP * (1 - outP)).toFixed(3);
83
88
  r.band.style.transform = `translate(-50%, ${(-50 + 40 * (1 - inP) - 60 * outP).toFixed(1)}%)`;
89
+ let k = 0;
84
90
  r.words.forEach((f, j) => {
85
91
  const a = l.times[j];
86
92
  const b = Math.min(l.times[j + 1] ?? l.end, a + 0.6);
87
93
  const p = clamp((t - a) / Math.max(0.05, b - a));
88
94
  if (p > 0 && p < 1) moving = true;
95
+ if (t >= a) k = j;
89
96
  f.style.clipPath = `inset(0 ${((1 - p) * 100).toFixed(2)}% 0 0)`;
90
97
  });
98
+ // 알약은 부르는 낱말 자리로 미끄러져 간다 — 앞 낱말 자리에서 0.18초에 걸쳐
99
+ const cur = r.spans[k];
100
+ const prev = r.spans[Math.max(0, k - 1)];
101
+ const m = k > 0 ? ease.outCubic(clamp((t - l.times[k]) / 0.18)) : 1;
102
+ const pad = size * 0.18;
103
+ r.pill.style.transform = `translateX(${(lerp(prev.offsetLeft, cur.offsetLeft, m) - pad).toFixed(1)}px)`;
104
+ r.pill.style.width = `${(lerp(prev.offsetWidth, cur.offsetWidth, m) + pad * 2).toFixed(1)}px`;
91
105
  });
92
106
  mark("karaokeLine", o.scene, moving);
93
107
  };
@@ -0,0 +1,138 @@
1
+ import { rand } from "@kasaframe/core";
2
+
3
+ // 뼈대 장면이 바로 거는 연출 수 메뉴. 「대충 한 요청」에서도 좋은 수가 나오게 kf new 가 장면마다 고르고(같은 요청이면 같은 수),
4
+ // 지시서(MAKE.md)가 같은 메뉴를 보여 준다. DOM 을 안 만져 Node(계획·지시서)에서도 읽는다 — 실제로 거는 것은 dress.ts.
5
+
6
+ export type DressKind = "bg" | "text" | "wipe";
7
+
8
+ export interface MenuItem {
9
+ name: string;
10
+ kind: DressKind;
11
+ does: string;
12
+ /** examples/moves/<example>/ — 수만 따로 본 3–5초 예시 */
13
+ example?: string;
14
+ }
15
+
16
+ // 바탕(bg)은 장면 전체 뒤 — 정지만 둔다. 뜻 없이 떠도는 장식(빛 방울·도는 줄무늬·흐르는 모눈)은 보는 사람이 무엇인지 묻게 만든다.
17
+ // 움직이는 바탕 수(sunburst·speedLines·gridFloor·truchet)는 장면이 이름으로 부를 때만 dressBg 가 건다.
18
+ // 글(text)은 안전 영역 안, 덮기(wipe)는 장면 경계를 걸쳐 앞 장면 끝을 덮고 뒤 장면 처음에 걷힌다
19
+ export const MENU: readonly MenuItem[] = [
20
+ { name: "still", kind: "bg", does: "정지 바탕 — 바탕색 위 아주 옅은 점 질감. 쉬지 않기는 글·숫자 수가 진다" },
21
+ { name: "bigWord", kind: "text", does: "큰 낱말 하나 + 등장 움직임(무대 기본)" },
22
+ { name: "slamWords", kind: "text", does: "박마다 낱말이 화면을 꽉 채우며 쾅 — 판과 글색이 뒤집힌다", example: "slamWords" },
23
+ { name: "echoStack", kind: "text", does: "같은 낱말이 박마다 한 벌씩 메아리치며 쌓인다", example: "echoStack" },
24
+ { name: "splitFlap", kind: "text", does: "판 넘김 글자판 — 칸마다 접히며 글자가 넘어가 앉는다", example: "splitFlap" },
25
+ { name: "ghostStack", kind: "text", does: "외곽선 유령 줄이 먼저 깔리고 박에 채워지며 위로 밀린다", example: "ghostStack" },
26
+ { name: "ransomLetters", kind: "text", does: "오려 붙인 글자 — 박마다 글자 모양을 다시 뽑는다", example: "ransomLetters" },
27
+ { name: "neonSign", kind: "text", does: "네온 관 글자가 시동 걸리듯 깜빡이다 켜진다", example: "neonSign" },
28
+ { name: "ledBoard", kind: "text", does: "LED 점 글판에 한 칸씩 찍힌다", example: "ledBoard" },
29
+ { name: "none", kind: "wipe", does: "덮지 않고 바로 컷" },
30
+ { name: "brushWipe", kind: "wipe", does: "붓 획 띠가 번갈아 덮었다가 꼬리가 빠지며 걷힌다", example: "brushWipe" },
31
+ { name: "irisBlades", kind: "wipe", does: "여섯 날 조리개가 닫혔다 열린다", example: "irisBlades" },
32
+ { name: "flood", kind: "wipe", does: "가운데 점이 화면을 넘치게 덮었다가 오므라든다", example: "flood" },
33
+ ];
34
+
35
+ export const menuItem = (name: string): MenuItem | undefined => MENU.find((m) => m.name === name);
36
+
37
+ export interface Pool {
38
+ bg?: string[];
39
+ text?: string[];
40
+ wipe?: string[];
41
+ }
42
+
43
+ /** 용도 틀(template.json moves)·테마가 주는 추천 묶음. roles 는 장면 역할(hook·drop·end …)마다 덮어쓴다 */
44
+ export interface Bundle extends Pool {
45
+ roles?: Record<string, Pool>;
46
+ }
47
+
48
+ // 틀에 moves 가 없을 때 — 어느 용도에도 무난한 묶음
49
+ const BASE: Required<Pool> = { bg: ["still"], text: ["bigWord", "echoStack", "splitFlap", "ghostStack"], wipe: ["brushWipe", "irisBlades", "flood"] };
50
+
51
+ // 테마마다 비유에 맞는 수 — 테마를 고르면 용도 묶음보다 앞에 둔다(테마 효과와 결이 같은 것만)
52
+ export const THEME_POOLS: Readonly<Record<string, Pool>> = {
53
+ "debug-hud": { text: ["splitFlap", "ledBoard", "ghostStack"], wipe: ["irisBlades", "flood"] },
54
+ "og-measure": { text: ["splitFlap", "ghostStack", "echoStack"], wipe: ["irisBlades", "flood"] },
55
+ "kasaterm-cells": { text: ["ledBoard", "splitFlap", "slamWords"], wipe: ["flood", "irisBlades"] },
56
+ "kawaii-sticker": { text: ["ransomLetters", "slamWords", "echoStack"], wipe: ["brushWipe", "flood"] },
57
+ "riso-zine": { text: ["ransomLetters", "echoStack", "slamWords"], wipe: ["brushWipe", "flood"] },
58
+ "depth-flight": { text: ["neonSign", "ghostStack", "splitFlap"], wipe: ["irisBlades", "flood"] },
59
+ "iso-diorama": { text: ["splitFlap", "echoStack", "ghostStack"], wipe: ["irisBlades", "brushWipe"] },
60
+ "glass-light": { text: ["ghostStack", "echoStack", "splitFlap"], wipe: ["irisBlades", "flood"] },
61
+ };
62
+
63
+ const known = (kind: DressKind) => (n: string) => menuItem(n)?.kind === kind;
64
+
65
+ /** 틀 묶음 + 테마 묶음 → 한 벌. 테마 수를 앞에, 메뉴에 없는 이름은 버린다 */
66
+ export function bundleFor(tpl?: Bundle, theme?: string): Required<Bundle> {
67
+ const th = theme ? THEME_POOLS[theme] : undefined;
68
+ const merge = (kind: DressKind, ...lists: (string[] | undefined)[]) => [...new Set(lists.flatMap((l) => l ?? []))].filter(known(kind));
69
+ const roles: Record<string, Pool> = {};
70
+ for (const [role, p] of Object.entries(tpl?.roles ?? {})) {
71
+ // 역할 몫은 틀이 이유를 대고 고른 것(훅은 첫 프레임부터 꽉 차는 글) — 테마 수는 그 뒤에 섞지 않는다
72
+ roles[role] = { bg: p.bg?.filter(known("bg")), text: p.text?.filter(known("text")), wipe: p.wipe?.filter(known("wipe")) };
73
+ }
74
+ const bg = merge("bg", th?.bg, tpl?.bg ?? BASE.bg);
75
+ return {
76
+ bg: bg.length ? bg : BASE.bg,
77
+ text: merge("text", th?.text, tpl?.text ?? BASE.text),
78
+ wipe: merge("wipe", th?.wipe, tpl?.wipe ?? BASE.wipe),
79
+ roles,
80
+ };
81
+ }
82
+
83
+ export interface Dress {
84
+ bg: string;
85
+ text: string;
86
+ /** 앞 장면 경계에서 걷히는 덮기 — 앞 장면의 wipeOut 과 같은 이름이라 이어진다 */
87
+ wipeIn: string;
88
+ wipeOut: string;
89
+ }
90
+
91
+ export const NO_DRESS: Dress = { bg: "still", text: "bigWord", wipeIn: "none", wipeOut: "none" };
92
+
93
+ /** 같은 요청·용도·테마면 같은 글이 나오게 공백만 고른다 */
94
+ export const dressSeed = (...parts: (string | undefined | null)[]): string =>
95
+ parts
96
+ .map((p) => (p ?? "").trim().replace(/\s+/g, " "))
97
+ .join("|");
98
+
99
+ // 이어지는 장면이 같은 수를 쓰지 않게 — 고를 게 하나뿐이면 그대로
100
+ function choose(seed: string, i: number, list: readonly string[], avoid: string | undefined): string {
101
+ const pool = list.length > 1 && avoid ? list.filter((x) => x !== avoid) : list;
102
+ return pool[Math.min(pool.length - 1, Math.floor(rand(seed, i) * pool.length))];
103
+ }
104
+
105
+ /**
106
+ * 장면마다 바탕·글·경계 덮기를 고른다. 시드(요청 글·용도·테마)가 같으면 늘 같은 고름이다.
107
+ * - 이음매(seam)는 3D 전환이 맡아 아무것도 걸지 않고, 그 앞뒤 경계도 덮지 않는다(전환이 앞뒤 화면을 그대로 잡아야 한다).
108
+ * - 드롭으로 들어가는 경계는 덮지 않는다 — 드롭 첫 박은 맨 화면으로 터져야 한다. 첫 장면은 첫 프레임부터 내용이 있어야 해서 덮고 시작하지 않는다.
109
+ * - 되돌이(loop)면 마지막 장면 끝도 덮지 않는다(끝 화면이 첫 화면으로 이어진다).
110
+ */
111
+ export function pickDress(seed: string, scenes: readonly { id: string; role: string; dur: number }[], bundle: Required<Bundle>): Dress[] {
112
+ const out: Dress[] = [];
113
+ for (const [i, s] of scenes.entries()) {
114
+ if (s.role === "seam") {
115
+ out.push({ ...NO_DRESS });
116
+ continue;
117
+ }
118
+ const role = bundle.roles[s.role] ?? {};
119
+ const prev = out[i - 1];
120
+ const bg = choose(seed, i * 4, role.bg?.length ? role.bg : bundle.bg, prev?.bg);
121
+ const text = choose(seed, i * 4 + 1, role.text?.length ? role.text : bundle.text, prev?.text);
122
+ out.push({ bg, text, wipeIn: "none", wipeOut: "none" });
123
+ }
124
+ let last = "";
125
+ for (let i = 0; i + 1 < scenes.length; i++) {
126
+ const a = scenes[i];
127
+ const b = scenes[i + 1];
128
+ if (a.role === "seam" || b.role === "seam" || b.role === "drop" || a.dur < 1.2 || b.dur < 1.2) continue;
129
+ const list = (bundle.roles[b.role]?.wipe?.length ? bundle.roles[b.role].wipe : bundle.wipe) ?? [];
130
+ if (!list.length) continue;
131
+ const w = choose(seed, i * 4 + 2, list, last);
132
+ if (w === "none") continue;
133
+ out[i].wipeOut = w;
134
+ out[i + 1].wipeIn = w;
135
+ last = w;
136
+ }
137
+ return out;
138
+ }
@@ -64,7 +64,8 @@ export function neonSign(el: HTMLElement, o: NeonSignOpts): (t: number) => void
64
64
  if (cyc > 0.9 && cyc < 0.98) on = false;
65
65
  }
66
66
  if (on) lit++;
67
- l.s.style.color = on ? "#FFF3F6" : "rgba(120,60,80,.55)";
67
+ // 꺼진 관도 읽혀야 한다 — 어두운 벽 위 3:1 넘는 흐린 유리색(검사 text.contrast)
68
+ l.s.style.color = on ? "#FFF3F6" : "#9A6478";
68
69
  l.s.style.textShadow = on ? glow : "none";
69
70
  });
70
71
  // 벽 번짐은 켜진 관 수만큼, 미세하게 웅웅거린다
@@ -13,13 +13,14 @@ export interface SlamWordsOpts {
13
13
  scene?: string;
14
14
  }
15
15
 
16
+ // 바탕 밝기는 거의 그대로 두고 글색만 뒤집는다 — 박마다 화면 전체가 강조색 바탕으로 번쩍이면 1초에 섬광 세 번을 넘는다(frame.flash)
16
17
  const PALETTES: [string, string][] = [
17
18
  ["var(--kf-bg)", "var(--kf-ink)"],
18
- ["var(--kf-accent)", "var(--kf-on-accent)"],
19
+ ["var(--kf-raised)", "var(--kf-accent)"],
19
20
  ];
20
21
 
21
22
  /**
22
- * 박마다 낱말 하나가 화면을 꽉 채우며 쾅 — 1.22배에서 내려앉고, 글자는 바닥에서 꼬리를 끌며 차례로 솟고, 바탕색이 뒤집힌다.
23
+ * 박마다 낱말 하나가 화면을 꽉 채우며 쾅 — 1.22배에서 내려앉고, 글자는 바닥에서 꼬리를 끌며 차례로 솟고, 판과 글색이 뒤집힌다.
23
24
  * 다음 박까지는 다가가며 옆으로 흘러 멈추지 않는다. (mexicat 「I'm Upping My P(doom)」 59–60초의 훅)
24
25
  */
25
26
  export function slamWords(el: HTMLElement, o: SlamWordsOpts): (t: number) => void {
@@ -30,7 +31,8 @@ export function slamWords(el: HTMLElement, o: SlamWordsOpts): (t: number) => voi
30
31
  const layer = div(doc, "position:absolute;inset:0;overflow:hidden");
31
32
  el.append(layer);
32
33
  const words = o.words.slice(0, o.hits.length).map((text) => {
33
- const px = fitSize(text, W, H, { share: 0.9, hShare: 0.72 });
34
+ // 폭 80% — 1.22배로 내려앉는 순간에도 화면 안(글자가 화면 끝을 넘나들면 잘림·섬광 검사에 걸린다)
35
+ const px = fitSize(text, W, H, { share: 0.8, hShare: 0.72 });
34
36
  const box = div(
35
37
  doc,
36
38
  `position:absolute;left:50%;top:50%;display:flex;white-space:nowrap;font-family:var(--kf-font-display);font-size:${px}px;` +
@@ -70,9 +72,13 @@ export function slamWords(el: HTMLElement, o: SlamWordsOpts): (t: number) => voi
70
72
  wd.box.style.color = ink;
71
73
  const land = ease.outCubic(clamp(local / 0.2));
72
74
  const after = Math.max(0, local - 0.2);
73
- // 박 사이에도 다가가며 옆으로 흘러야 0.1초 멈춤에 안 걸린다(짧은 낱말일수록 움직임이 작게 보인다)
74
- const scale = (1.22 - 0.22 * land) * (1 + 0.16 * after);
75
- const dx = (i % 2 ? -1 : 1) * W * 0.05 * after;
75
+ // 박 사이에도 다가가며 옆으로 흘러야 0.1초 멈춤에 안 걸린다. 얼마나 다가가나는 다음 쾅까지의 몫으로 —
76
+ // 쾅 사이가 길어도 낱말이 화면 밖으로 커지지 않는다(마지막 낱말은 1초 몫, 그 뒤로는 아주 천천히)
77
+ const hold = Math.max(0.3, (o.hits[i + 1] ?? o.hits[i] + 1) - o.hits[i] - 0.2);
78
+ const q = after / hold;
79
+ const grow = 0.12 * Math.min(q, 1) + 0.03 * Math.max(0, q - 1);
80
+ const scale = (1.22 - 0.22 * land) * (1 + grow);
81
+ const dx = (i % 2 ? -1 : 1) * W * 0.035 * Math.min(q, 1.3);
76
82
  wd.box.style.transform = `translate(calc(-50% + ${dx.toFixed(1)}px),-50%) scale(${scale.toFixed(4)})`;
77
83
  const each = Math.min(0.035, 0.12 / wd.letters.length);
78
84
  wd.letters.forEach((l, j) => {
@@ -81,7 +87,8 @@ export function slamWords(el: HTMLElement, o: SlamWordsOpts): (t: number) => voi
81
87
  l.span.style.opacity = p > 0 ? "1" : "0";
82
88
  l.span.style.transform = `translateY(${((1 - e) * wd.px * 0.7).toFixed(1)}px)`;
83
89
  l.tail.style.transform = `scaleY(${(1 - e).toFixed(3)})`;
84
- l.tail.style.opacity = (0.8 * (1 - e)).toFixed(3);
90
+ // 꼬리 줄무늬는 옅게 — 진한 줄무늬가 솟으며 칸마다 밝음·어둠을 오가 섬광 검사(frame.flash)에 걸렸다
91
+ l.tail.style.opacity = (0.35 * (1 - e)).toFixed(3);
85
92
  });
86
93
  mark("slamWords", o.scene, local < 0.25);
87
94
  });
@@ -14,6 +14,8 @@ export interface SparkTrailOpts {
14
14
  color?: string;
15
15
  width?: number;
16
16
  sparks?: number;
17
+ /** 선 아래(이 y 까지)를 옅게 채운다 — 값의 흐름을 그린 선이면 그래프 면적이 머리를 따라 차오른다 */
18
+ area?: { to: number; color?: string };
17
19
  w?: number;
18
20
  h?: number;
19
21
  seed?: string | number;
@@ -53,6 +55,20 @@ export function sparkTrail(el: HTMLElement, o: SparkTrailOpts): (t: number) => v
53
55
  if (!color) color = getComputedStyle(el.ownerDocument.documentElement).getPropertyValue("--kf-accent").trim() || "#ff6b2c";
54
56
  const head = sAt(t);
55
57
  const from = o.tail !== undefined ? Math.max(0, sAt(t - o.tail)) : 0;
58
+ if (o.area && head > 0) {
59
+ g.beginPath();
60
+ const p0 = pointAt(pts, cum, 0);
61
+ g.moveTo(p0.x, o.area.to);
62
+ g.lineTo(p0.x, p0.y);
63
+ for (let i = 0; i < pts.length && cum[i] < head; i++) g.lineTo(pts[i][0], pts[i][1]);
64
+ const ph = pointAt(pts, cum, head);
65
+ g.lineTo(ph.x, ph.y);
66
+ g.lineTo(ph.x, o.area.to);
67
+ g.closePath();
68
+ g.globalAlpha = 1;
69
+ g.fillStyle = o.area.color ?? `color-mix(in srgb, ${color} 22%, transparent)`;
70
+ g.fill();
71
+ }
56
72
  g.lineCap = "round";
57
73
  g.lineJoin = "round";
58
74
  g.strokeStyle = color;
@@ -139,9 +139,11 @@ export function splitFlap(el: HTMLElement, o: SplitFlapOpts): (t: number) => voi
139
139
  flapBottom.box.style.transform = `rotateX(${(90 * (1 - b)).toFixed(2)}deg)`;
140
140
  flapBottom.box.style.filter = `brightness(${(0.6 + 0.4 * b).toFixed(3)})`;
141
141
  });
142
- // 판 위로 빛이 늘 지나가고 판이 조금씩 다가가 — 글자가 다 앉은 뒤에도 멈추지 않는다
142
+ // 판 위로 빛이 늘 지나가고, 넘김 사이에는 판이 다가가다 다음 넘김에 제자리로 — 글자가 다 앉은 뒤에도 멈추지 않는다.
143
+ // 밝은 바탕에서 판 넘김·빛만으로는 160px 썸네일의 평균 밝기 차 0.35 를 못 넘어 멈춤(frame.freeze)으로 잡혔다
143
144
  gloss.style.backgroundPosition = `${(100 - ((t * 45) % 200)).toFixed(1)}% 0`;
144
- board.style.transform = `scale(${(1 + 0.02 * t).toFixed(4)})`;
145
+ const since = k < 0 ? t : t - o.times[k];
146
+ board.style.transform = `scale(${(1 + 0.06 * Math.min(since, 1.5)).toFixed(4)})`;
145
147
  mark("splitFlap", o.scene, moving);
146
148
  };
147
149
  }
@@ -99,19 +99,30 @@ export function stepWords(el: HTMLElement, o: StepWordsOpts): (t: number) => voi
99
99
  const hp = pointAt(corners, cum, reach);
100
100
  dot.setAttribute("cx", hp.x.toFixed(1));
101
101
  dot.setAttribute("cy", hp.y.toFixed(1));
102
- tags.forEach((g, i) => {
103
- const local = t - o.steps[i].at;
104
- const p = clamp(local / 0.22);
105
- const [cx, cy] = corners[ends[i]];
106
- g.tag.style.opacity = local >= 0 ? "1" : "0";
107
- g.tag.style.transform = `translate(${(cx + 28).toFixed(1)}px,${(cy - size * 0.62).toFixed(1)}px) rotate(${g.tilt.toFixed(2)}deg) scale(${ease.outBack(p).toFixed(4)})`;
108
- });
109
102
  // 카메라: 그리는 머리를 화면 가운데 쪽으로 천천히 따라간다
110
103
  const fx = W * 0.5 - hp.x;
111
104
  const fy = H * 0.5 - hp.y;
112
105
  const follow = 0.85;
113
106
  // 마지막 칸 뒤에도 떠다녀 멈추지 않는다
114
- cam.style.transform = `translate(${(fx * follow + 24 * Math.sin(t * 1.4)).toFixed(1)}px,${(fy * follow + 14 * Math.sin(t * 1.9)).toFixed(1)}px) scale(${(1 + 0.03 * t).toFixed(4)})`;
107
+ const camX = fx * follow + 24 * Math.sin(t * 1.4);
108
+ const camY = fy * follow + 14 * Math.sin(t * 1.9);
109
+ const zoom = 1 + 0.03 * t;
110
+ cam.style.transform = `translate(${camX.toFixed(1)}px,${camY.toFixed(1)}px) scale(${zoom.toFixed(4)})`;
111
+ const toX = (x: number) => W / 2 + (x - W / 2) * zoom + camX;
112
+ const toY = (y: number) => H / 2 + (y - H / 2) * zoom + camY;
113
+ tags.forEach((g, i) => {
114
+ const local = t - o.steps[i].at;
115
+ const p = clamp(local / 0.22);
116
+ const [cx, cy] = corners[ends[i]];
117
+ const x = cx + 28;
118
+ const y = cy - size * 0.62;
119
+ // 카메라가 앞으로 가면 지난 칸은 화면 밖으로 빠진다 — 끝에 걸리기 전에 흐려진다(잘린 글이 남지 않게)
120
+ const tw = g.tag.offsetWidth || size * 3;
121
+ const th = g.tag.offsetHeight || size * 1.2;
122
+ const edge = Math.min(toX(x), toY(y), W - toX(x + tw), H - toY(y + th));
123
+ g.tag.style.opacity = local >= 0 ? clamp(edge / (size * 0.5)).toFixed(3) : "0";
124
+ g.tag.style.transform = `translate(${x.toFixed(1)}px,${y.toFixed(1)}px) rotate(${g.tilt.toFixed(2)}deg) scale(${ease.outBack(p).toFixed(4)})`;
125
+ });
115
126
  mark("stepWords", o.scene, k < n - 1 || t - (times[n - 1] ?? 0) < 0.22);
116
127
  };
117
128
  }
@@ -1,5 +1,5 @@
1
1
  import { clamp, ease, safeTime } from "@kasaframe/core";
2
- import { div, mark, textEm } from "./fit.ts";
2
+ import { div, mark, strip, textEm } from "./fit.ts";
3
3
 
4
4
  export interface TickerRow {
5
5
  text: string;
@@ -24,7 +24,6 @@ export interface TickerRowsOpts {
24
24
  at?: number;
25
25
  w?: number;
26
26
  h?: number;
27
- dur?: number;
28
27
  scene?: string;
29
28
  }
30
29
 
@@ -40,7 +39,6 @@ export function tickerRows(el: HTMLElement, o: TickerRowsOpts): (t: number) => v
40
39
  const gap = o.gap ?? 22;
41
40
  const top = o.top ?? (H - o.rows.length * (rowH + gap) + gap) / 2;
42
41
  const at = o.at ?? 0;
43
- const dur = o.dur ?? 30;
44
42
  const fs = rowH * 0.46;
45
43
  const layer = div(doc, "position:absolute;inset:0;overflow:hidden");
46
44
  el.append(layer);
@@ -48,14 +46,16 @@ export function tickerRows(el: HTMLElement, o: TickerRowsOpts): (t: number) => v
48
46
  const speed = r.speed ?? (i % 2 ? -1 : 1) * (150 + 40 * i);
49
47
  const unit = `${r.text}\u00a0\u00a0\u2022\u00a0\u00a0`;
50
48
  const unitW = textEm(unit) * fs;
51
- const reps = Math.ceil((W * 1.6 + Math.abs(speed) * dur) / unitW) + 2;
52
49
  const band = div(
53
50
  doc,
54
51
  `position:absolute;left:${-W * 0.1}px;width:${W * 1.2}px;top:${top + i * (rowH + gap)}px;height:${rowH}px;overflow:hidden;` +
55
52
  `background:${r.band ?? "var(--kf-surface)"};color:${r.ink ?? "var(--kf-ink)"};border-top:3px solid var(--kf-ink);border-bottom:3px solid var(--kf-ink);transform-origin:50% 50%`,
56
53
  );
57
- const strip = div(doc, `position:absolute;left:0;top:0;height:100%;display:flex;align-items:center;white-space:nowrap;font-family:var(--kf-font-mono);font-weight:600;font-size:${fs}px`, unit.repeat(reps));
58
- band.append(strip);
54
+ const line = div(doc, `position:absolute;left:0;top:0;width:100%;height:100%;font-family:var(--kf-font-mono);font-weight:600;font-size:${fs}px`);
55
+ band.append(line);
56
+ // 낱말마다 조각 — 띠 끝에서 낱말 하나씩 흐려진다
57
+ const pieces = unit.split(/(?<=\s)(?=\S)/);
58
+ const flow = strip(line, pieces, Math.ceil((W * 1.2) / unitW) + 2, (x) => textEm(x) * fs);
59
59
  let chip: HTMLDivElement | null = null;
60
60
  if (r.label) {
61
61
  chip = div(
@@ -66,9 +66,13 @@ export function tickerRows(el: HTMLElement, o: TickerRowsOpts): (t: number) => v
66
66
  band.append(chip);
67
67
  }
68
68
  layer.append(band);
69
- return { band, strip, speed, reps, unitW, angle: r.angle ?? 0 };
69
+ return { band, flow, chip, speed, angle: r.angle ?? 0 };
70
70
  });
71
71
 
72
+ // 칩이 덮은 띠 머리 — 그 밑으로 지나는 조각도 화면 끝처럼 흐린다(칩 뒤에 반쯤 가린 글이 남지 않게)
73
+ const chipEnd = (i: number) => (o.rows[i].label ? Math.max(0, chips[i]?.offsetWidth ?? 0) : 0);
74
+ const chips = rows.map((r) => r.chip);
75
+
72
76
  return (t: number) => {
73
77
  t = safeTime(t);
74
78
  let moving = false;
@@ -79,8 +83,8 @@ export function tickerRows(el: HTMLElement, o: TickerRowsOpts): (t: number) => v
79
83
  // 띠 전체가 옆에서 밀려 들어오고, 안의 글은 그 뒤로도 늘 흐른다
80
84
  const enterX = (1 - p) * (i % 2 ? W : -W) * 1.2;
81
85
  r.band.style.transform = `translateX(${enterX.toFixed(1)}px) rotate(${r.angle}deg)`;
82
- const x = -(r.reps * r.unitW) / 2 + r.speed * t;
83
- r.strip.style.transform = `translateX(${x.toFixed(1)}px)`;
86
+ // 띠는 화면보다 양옆 10% 넓다 — 띠 안 x 를 화면 x 로 옮겨 화면 끝에 걸친 조각을 흐린다(칩에 가린 왼쪽 끝도)
87
+ r.flow(r.speed * t, (x) => x - W * 0.1 + enterX, [chipEnd(i), W], fs * 1.6);
84
88
  });
85
89
  mark("tickerRows", o.scene, moving);
86
90
  };
@@ -80,14 +80,15 @@ export function tokenPicker(el: HTMLElement, o: TokenPickerOpts): (t: number) =>
80
80
  const text = o.steps.slice(0, k + 1).map((s) => s.token).join("");
81
81
  typed.textContent = text;
82
82
  caret.style.opacity = Math.floor(t * 2.2) % 2 === 0 || t - (o.times[k] ?? -9) < 0.4 ? "1" : "0.15";
83
- // 다음 토큰 — 붙기 0.32초 전부터 후보가 끓고, 0.08초 전에 하나가 켜진다
83
+ // 다음 토큰 — 앞 토큰이 붙은 뒤부터 다음 토큰까지 후보 확률이 끓고(모델이 고르는 동안 숫자가 구른다), 0.08초 전에 하나가 켜진다
84
84
  const j = k + 1 < n ? k + 1 : -1;
85
- const local = j >= 0 ? t - (o.times[j] - 0.32) : -1;
85
+ const from = j >= 0 ? Math.min(o.times[j] - 0.32, k >= 0 ? o.times[k] + 0.12 : o.times[j] - 0.6) : 0;
86
+ const local = j >= 0 ? t - from : -1;
86
87
  const show = j >= 0 && local >= 0;
87
88
  panel.style.display = show ? "block" : "none";
88
89
  if (show) {
89
90
  const step = o.steps[j];
90
- const pick = ease.outCubic(clamp((local - 0.24) / 0.08));
91
+ const pick = ease.outCubic(clamp((t - (o.times[j] - 0.08)) / 0.08));
91
92
  const x = Math.min(W - size * 7.6, Math.max(W * 0.04, caretX(text) - size * 1.2));
92
93
  panel.style.left = `${x.toFixed(1)}px`;
93
94
  panel.style.transform = `translateY(${((1 - ease.outCubic(clamp(local / 0.1))) * 18).toFixed(1)}px)`;
@@ -1,5 +1,5 @@
1
1
  import { clamp, ease, lerp, safeTime } from "@kasaframe/core";
2
- import { div, lastIndex, mark, textEm } from "./fit.ts";
2
+ import { div, lastIndex, mark, strip, textEm } from "./fit.ts";
3
3
 
4
4
  export interface WordWallOpts {
5
5
  word: string;
@@ -13,8 +13,6 @@ export interface WordWallOpts {
13
13
  speed?: number;
14
14
  w?: number;
15
15
  h?: number;
16
- /** 장면 길이 — 띠가 끝까지 모자라지 않게 */
17
- dur?: number;
18
16
  scene?: string;
19
17
  }
20
18
 
@@ -31,23 +29,23 @@ export function wordWall(el: HTMLElement, o: WordWallOpts): (t: number) => void
31
29
  const base = H / counts[0];
32
30
  const F0 = base * 0.84;
33
31
  const speed = (o.speed ?? 90) * (H / 1080);
34
- const dur = o.dur ?? 30;
35
32
  const unit = `${o.word}\u00a0\u00a0`;
36
33
  const minScale = counts[0] / maxRows;
37
- const reps = Math.ceil((W / minScale + speed * dur * 2) / (textEm(unit) * F0)) + 2;
34
+ const unitGuess = textEm(unit) * F0;
35
+ // 가장 작아졌을 때(줄 수가 가장 많을 때)도 화면 폭을 덮는 조각 수
36
+ const count = Math.ceil(W / minScale / unitGuess) + 2;
37
+ const guess = (x: string) => textEm(x) * F0;
38
38
  const layer = div(doc, "position:absolute;inset:0;overflow:hidden;color:var(--kf-ink)");
39
39
  el.append(layer);
40
- const strips = Array.from({ length: maxRows }, (_, i) => {
41
- const s = div(
42
- doc,
43
- `position:absolute;left:0;top:0;white-space:nowrap;font-family:var(--kf-font-display);font-weight:900;font-size:${F0}px;line-height:1;letter-spacing:-.03em;transform-origin:0 50%`,
44
- unit.repeat(reps),
45
- );
40
+ const rowCss = `position:absolute;left:0;top:0;height:${F0}px;font-family:var(--kf-font-display);font-weight:900;font-size:${F0}px;line-height:1;letter-spacing:-.03em;transform-origin:0 50%`;
41
+ const strips = Array.from({ length: maxRows }, () => {
42
+ const s = div(doc, rowCss);
46
43
  layer.append(s);
47
- return s;
44
+ return { s, flow: strip(s, [unit], count, guess) };
48
45
  });
49
- const hotStrip = div(doc, `position:absolute;left:0;top:0;white-space:nowrap;font-family:var(--kf-font-display);font-weight:900;font-size:${F0}px;line-height:1;letter-spacing:-.03em;transform-origin:0 50%;color:var(--kf-accent)`, `${o.hot ?? o.word}\u00a0\u00a0`.repeat(reps));
50
- layer.append(hotStrip);
46
+ const hot = div(doc, `${rowCss};color:var(--kf-accent)`);
47
+ layer.append(hot);
48
+ const hotFlow = strip(hot, [`${o.hot ?? o.word}\u00a0\u00a0`], count, guess);
51
49
  const yOf = (i: number, n: number) => (i + 0.5) * (H / n);
52
50
 
53
51
  return (t: number) => {
@@ -58,18 +56,22 @@ export function wordWall(el: HTMLElement, o: WordWallOpts): (t: number) => void
58
56
  const p = k > 0 || t >= (o.hits[0] ?? 0) ? ease.outCubic(clamp((t - (o.hits[k] ?? 0)) / 0.28)) : 1;
59
57
  const scale = lerp(n0 > 0 ? counts[0] / n0 : 1, counts[0] / n1, p);
60
58
  const hotRow = Math.floor(n1 / 2);
61
- strips.forEach((s, i) => {
59
+ const toScreen = (x: number) => x * scale;
60
+ const fade = W * 0.06;
61
+ strips.forEach(({ s, flow }, i) => {
62
62
  const y = i < n0 ? lerp(yOf(i, n0), yOf(i, n1), p) : yOf(i, n1);
63
63
  const show = i < n1 ? (i < n0 ? 1 : p) : 0;
64
64
  const dir = i % 2 ? 1 : -1;
65
- // 시작 자리를 줄마다 어긋나게 — 흐름이 띠 끝에 닿지 않도록 가운데쯤에서 시작
66
- const x = -(reps * textEm(unit) * F0 * scale) / 2 + dir * speed * t + i * 137;
67
- s.style.transform = `translate(${x.toFixed(1)}px,${(y - (F0 * scale) / 2).toFixed(1)}px) scale(${scale.toFixed(4)})`;
65
+ // 시작 자리를 줄마다 어긋나게
66
+ const offset = (dir * speed * t + i * 137) / scale;
67
+ // 크기 기준점이 줄 판의 세로 가운데(transform-origin 0 50%)라 판 높이 절반만 빼면 줄 가운데가 y 에 선다
68
+ s.style.transform = `translate(0px,${(y - F0 / 2).toFixed(1)}px) scale(${scale.toFixed(4)})`;
69
+ s.style.display = show > 0 || i === hotRow ? "block" : "none";
68
70
  s.style.opacity = (i === hotRow ? 0 : show * 0.32).toFixed(3);
69
71
  if (i === hotRow) {
70
- hotStrip.style.transform = s.style.transform;
71
- hotStrip.style.opacity = "1";
72
- }
72
+ hot.style.transform = s.style.transform;
73
+ hotFlow(offset, toScreen, [0, W], fade);
74
+ } else if (show > 0) flow(offset, toScreen, [0, W], fade);
73
75
  });
74
76
  mark("wordWall", o.scene, p < 1);
75
77
  };
@@ -24,7 +24,8 @@ export function worldRoll(t: number, o: WorldRollOpts): Style {
24
24
  if (local <= 0) continue;
25
25
  rotate += turn * spring(local, { from: 0, to: 1, stiffness: 120, damping: 13 });
26
26
  dip = Math.max(dip, Math.sin(Math.PI * clamp(local / 0.45)));
27
- if (local < 0.6) moving = true;
27
+ // 스프링 꼬리(넘쳤다 돌아오기)까지 굴림이다 — 그동안도 카메라 이벤트를 남겨 이유 없는 이동(camera.wander)으로 안 센다
28
+ if (local < 1.1) moving = true;
28
29
  }
29
30
  if (moving) emit({ kind: "camera", source: "worldRoll", scene: o.scene, value: 1 });
30
31
  return { rotate, scale: 1 - 0.16 * dip };
@@ -11,6 +11,7 @@ import { portal } from "./portal.ts";
11
11
  import { shatter } from "./shatter.ts";
12
12
  import { zoomOut } from "./zoomOut.ts";
13
13
  import { whip } from "./whip.ts";
14
+ import { land, rise, type HeroRect } from "./space.ts";
14
15
  import { GL_NAMES, SMOOTH_NAMES, glKind, smoothKind, type GlValue } from "./gl.ts";
15
16
 
16
17
  export { TRANSITIONS_3D, DUR_3D, FIT, FOV, CHAIN_GAP, durOf, resolveTiming, rawProgress, arrivalsOn } from "./rig.ts";
@@ -22,6 +23,7 @@ export type { Painter, Source, Palette, LiveDom, VideoSource } from "./world.ts"
22
23
  export { elementTextures } from "./world.ts";
23
24
  export { sceneSource, liveNeighbors } from "./live.ts";
24
25
  export type { LiveProject } from "./live.ts";
26
+ export type { HeroRect } from "./space.ts";
25
27
 
26
28
  export interface Transition3dOpts extends TimingOpts {
27
29
  /** 기본 dive */
@@ -46,6 +48,8 @@ export interface Transition3dOpts extends TimingOpts {
46
48
  window?: WindowRect;
47
49
  /** fold — accordion 접기 · roll 말기 */
48
50
  fold?: FoldStyle;
51
+ /** rise·land — 3D 로 서거나 내려앉을 주인공 상자(화면 비율, 왼쪽 위 0). 안 주면 살아 있는 장면의 [data-kf-hero] 를 프레임마다 잰다 */
52
+ hero?: HeroRect;
49
53
  /** gl:<이름> — 셰이더 매개변수 덮기(uniform 이름 → 값) */
50
54
  params?: Record<string, GlValue>;
51
55
  /** 빠르기 곡선 — ramp(기본): 천천히 들어가다 확 빨라진다 · linear: 종류가 정한 그대로 */
@@ -64,7 +68,7 @@ export interface Transition3dOpts extends TimingOpts {
64
68
  export type Transition3dDraw = ((t: number) => void) & { timing: Timing; kind: Transition3dName; dispose(): void };
65
69
 
66
70
  type Build = (k: Kit, o: Transition3dOpts & { arrivals: number[] }) => KindRun & { visible?(t: number): boolean[] };
67
- const KINDS: Record<(typeof TRANSITIONS_3D)[number], Build> = { dive, portal, cards, shatter, zoomOut, fold, diveChain: chain, whip };
71
+ const KINDS: Record<(typeof TRANSITIONS_3D)[number], Build> = { dive, portal, cards, shatter, zoomOut, fold, diveChain: chain, whip, rise, land };
68
72
 
69
73
  function builderOf(kind: Transition3dName): Build | undefined {
70
74
  if (kind.startsWith("gl:")) return glKind(kind.slice(3));
@@ -155,20 +159,23 @@ export function transition3d(root: HTMLElement, ctxOrOpts?: SceneCtx | Transitio
155
159
  fx.dolly = land && inside ? 0.07 * FIT * Math.sin(Math.PI * s) ** 2 * (1 - s) : 0;
156
160
  let sx = 0;
157
161
  let sy = 0;
162
+ let amp = 0;
158
163
  let punch = 0;
159
164
  for (const [i, h] of hits.entries()) {
160
165
  const dt = t - h;
161
166
  if (!inside || dt < 0 || dt > 0.5) continue;
162
167
  const env = Math.exp(-dt / 0.11);
168
+ amp += 0.01 * env;
163
169
  sx += 0.01 * env * Math.sin(2 * Math.PI * 17 * dt + rand(seed, 40 + i) * 6.283);
164
170
  sy += 0.01 * env * Math.sin(2 * Math.PI * 13 * dt + rand(seed, 50 + i) * 6.283);
165
171
  punch -= 7 * Math.exp(-dt / 0.16) * (1 - Math.exp(-dt / 0.02));
166
172
  }
167
173
  fx.shake = [sx, sy];
168
174
  fx.fov = punch;
169
- // 화면 높이 1 = 꽉 찬 화면이라 흔들림 0.01 은 높이의 1% — 검사가 px 로 센다
175
+ // 화면 높이 1 = 꽉 찬 화면이라 흔들림 0.01 은 높이의 1% — 검사가 px 로 센다. 흔들림이 0 을 지나는 프레임에도 남겨야
176
+ // 한 번 친 흔들림이 끊긴 여러 번으로 세이지 않는다 — 잦아드는 폭(amp)이 0.5px 넘는 동안 이어서 남긴다
170
177
  const px = Math.hypot(sx, sy) * h;
171
- if (px > 0.5 && o.emit !== false) emit({ kind: "shake", source: `${source}:hit`, scene, value: Math.round(px * 10) / 10 });
178
+ if (amp * h > 0.5 && o.emit !== false) emit({ kind: "shake", source: `${source}:hit`, scene, value: Math.round(px * 10) / 10 });
172
179
  const smp = frameSample();
173
180
  if (aperture > 0 && inside && smp.count > 1) {
174
181
  // 황금각 나선 — 하위 프레임들이 조리개 원판을 고르게 덮는다