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
@@ -0,0 +1,131 @@
1
+ import { applyStyle, clamp, combine, spring } from "@kasaframe/core";
2
+ import { fitSize, textEm } from "@kasaframe/motion/moves";
3
+ import { area, cadence, node, slotsOf, spaced, moving } from "../kit.ts";
4
+ import { objectCss, objectFor } from "../objects.ts";
5
+ import type { Pattern } from "../types.ts";
6
+
7
+ export interface ListContent {
8
+ items: { word: string; line?: string; icon?: string }[];
9
+ title?: string;
10
+ }
11
+
12
+ /**
13
+ * 낱말 카드가 박마다 하나씩 붙고, 그다음 차례마다 한 장씩 강조색으로 차오른다 — 차오름이 「지금 이 낱말」과 그 차례의 길이다.
14
+ * 낱말이 객체 사전의 물체면 그 모양을 카드 머리에 둔다(GPU 면 GPU 판). 장식은 두지 않는다 — 움직이는 것은 붙는 카드와 차오르는 차례뿐.
15
+ */
16
+ const pattern: Pattern<ListContent> = {
17
+ id: "list.cards",
18
+ type: "list",
19
+ does: "낱말 카드가 박마다 붙고, 차례마다 한 장씩 강조색으로 차오른다",
20
+ energy: ["mid", "high"],
21
+ family: "pop",
22
+ camera: ["none"],
23
+ bg: "stage",
24
+ aspects: ["16:9", "9:16", "1:1"],
25
+ dur: { min: 2.5, max: 9 },
26
+ themes: { "kawaii-sticker": 0.6, "riso-zine": 0.3 },
27
+ fits(c) {
28
+ const items = c?.items;
29
+ if (!Array.isArray(items) || items.length < 2) return "items 가 둘보다 적다";
30
+ if (items.length > 5) return "items 가 다섯을 넘는다";
31
+ for (const [i, it] of items.entries()) {
32
+ if (typeof it?.word !== "string" || !it.word.trim()) return `items[${i}].word 가 비었다`;
33
+ if ([...it.word].length > 12) return `items[${i}].word 가 12자를 넘는다`;
34
+ }
35
+ return true;
36
+ },
37
+ build(root, ctx, c, kit) {
38
+ const slots = slotsOf(kit);
39
+ const { w, h, vertical, u } = area(ctx, kit);
40
+ const items = c.items;
41
+ const n = items.length;
42
+ const showLine = kit.policy !== "terms-only" && kit.policy !== "none";
43
+
44
+ const titlePx = Math.round(46 * u);
45
+ const top = c.title ? Math.round(titlePx * 1.9) : 0;
46
+ const gap = Math.round(28 * u);
47
+ const cols = vertical ? 1 : n;
48
+ const rows = vertical ? n : 1;
49
+ const gridW = w * 0.92;
50
+ const gridH = (h - top) * (vertical ? 0.94 : 0.66);
51
+ const cardW = (gridW - gap * (cols - 1)) / cols;
52
+ const cardH = (gridH - gap * (rows - 1)) / rows;
53
+ const x0 = (w - gridW) / 2;
54
+ const y0 = top + (h - top - gridH) / 2;
55
+
56
+ const host = node(root, "position:absolute;inset:0");
57
+ const title = c.title ? node(host, `position:absolute;left:${x0}px;top:${Math.round(titlePx * 0.4)}px;font-family:var(--kf-font-sans);font-size:${titlePx}px;font-weight:700;color:var(--kf-ink);white-space:nowrap`) : null;
58
+
59
+ // 낱말은 띄어쓰기에서 두 줄까지 — 가장 긴 덩이가 카드 폭에 들게
60
+ const pieces = items.flatMap((it) => (vertical ? [it.word] : it.word.split(/\s+/)));
61
+ const longest = pieces.reduce((a, p) => (textEm(p) > textEm(a) ? p : a), "");
62
+ const lines = vertical ? 1 : Math.max(...items.map((it) => Math.min(2, it.word.split(/\s+/).length)));
63
+ const wordPx = fitSize(longest, cardW * (vertical ? 0.62 : 0.8), (cardH * (vertical ? 0.5 : 0.36)) / lines, { hShare: 1, max: Math.round(170 * u), min: Math.round(40 * u) });
64
+ const linePx = Math.round(Math.max(30 * u, wordPx * 0.26));
65
+ const glyphPx = Math.round(Math.min(cardH * (vertical ? 0.5 : 0.3), wordPx * 1.4));
66
+
67
+ const hits = spaced(kit.hits.length ? kit.hits : [0], 0.3);
68
+ const enters = cadence(hits, n);
69
+ const walkFrom = enters[n - 1] + 0.3;
70
+ const turn = Math.max(0.3, (ctx.dur - walkFrom) / n);
71
+ // 차례 시작은 박에 붙인다 — 붙일 박이 없으면 고르게
72
+ const turns = items.map((_, i) => {
73
+ const want = walkFrom + i * turn;
74
+ const near = hits.find((b) => Math.abs(b - want) < turn * 0.3);
75
+ return near ?? want;
76
+ });
77
+ const ends = turns.map((s, i) => (i + 1 < n ? turns[i + 1] : ctx.dur - 1 / ctx.meta.fps));
78
+
79
+ const cards = items.map((it, i) => {
80
+ const col = vertical ? 0 : i;
81
+ const row = vertical ? i : 0;
82
+ const card = node(host, `position:absolute;left:${x0 + col * (cardW + gap)}px;top:${y0 + row * (cardH + gap)}px;width:${cardW}px;height:${cardH}px;transform-origin:50% 60%`);
83
+ const panel = slots.panel(card, { ctx, at: enters[i], seed: `${kit.seed}:card${i}` });
84
+ const fill = node(card, "position:absolute;inset:0;background:var(--kf-accent);border-radius:var(--kf-radius-md)");
85
+ const obj = objectFor(it.word) ?? (it.icon ? objectFor(it.icon) : null);
86
+ // 같은 내용을 두 벌 — 아래는 바탕색 위 글, 위는 강조색 위 글. 차오른 만큼만 위 벌이 보인다
87
+ const face = (onAccent: boolean) => {
88
+ const f = node(card, `position:absolute;inset:0;display:flex;flex-direction:${vertical ? "row" : "column"};align-items:center;justify-content:center;gap:${Math.round(wordPx * 0.25)}px;padding:${Math.round(wordPx * 0.3)}px;box-sizing:border-box;color:${onAccent ? "var(--kf-on-accent)" : "var(--kf-ink)"}`);
89
+ const head = obj
90
+ ? node(f, objectCss(obj, glyphPx))
91
+ : node(f, `font-family:var(--kf-font-mono);font-size:${Math.round(glyphPx * 0.55)}px;font-weight:700;color:${onAccent ? "var(--kf-on-accent)" : "var(--kf-accent)"}`, String(i + 1).padStart(2, "0"));
92
+ if (obj && onAccent) head.style.setProperty(obj.shape2d.outline ? "border-color" : "background", "var(--kf-on-accent)");
93
+ const text = node(f, `display:flex;flex-direction:column;align-items:${vertical ? "flex-start" : "center"};gap:${Math.round(linePx * 0.3)}px;min-width:0`);
94
+ const word = node(text, `font-family:var(--kf-font-display);font-size:${wordPx}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};line-height:1.05;white-space:${vertical ? "nowrap" : "normal"};word-break:keep-all;text-align:${vertical ? "left" : "center"};min-height:${lines * 1.05}em`);
95
+ const line = showLine && it.line ? node(text, `font-family:var(--kf-font-sans);font-size:${linePx}px;line-height:1.25;opacity:.8;min-height:1.25em;text-align:${vertical ? "left" : "center"}`) : null;
96
+ return { f, word, line };
97
+ };
98
+ const base = face(false);
99
+ const lit = face(true);
100
+ const writes = [base, lit].flatMap((v) => [
101
+ slots.write(v.word, { ctx, text: it.word, at: enters[i] + 0.05, dur: 0.25, seed: `${kit.seed}:w${i}` }),
102
+ ...(v.line ? [slots.write(v.line, { ctx, text: it.line!, at: enters[i] + 0.2, dur: 0.35, seed: `${kit.seed}:l${i}` })] : []),
103
+ ]);
104
+ const landed = slots.land(card, { ctx, at: enters[i], seed: `${kit.seed}:land${i}` });
105
+ return { card, panel, fill, lit: lit.f, writes, landed };
106
+ });
107
+
108
+ const writeTitle = title ? slots.write(title, { ctx, text: c.title!, at: 0, dur: 0.35, seed: `${kit.seed}:title` }) : null;
109
+
110
+ return (t: number) => {
111
+ moving("list.cards", ctx);
112
+ cards.forEach((c2, i) => {
113
+ const at = enters[i];
114
+ const on = t >= at;
115
+ c2.card.style.visibility = on ? "visible" : "hidden";
116
+ if (!on) return;
117
+ const pop = spring(t - at, { from: 0.6, to: 1, stiffness: 300, damping: 15 });
118
+ applyStyle(c2.card, combine({ scale: pop }, c2.landed(t)));
119
+ c2.panel(t);
120
+ c2.writes.forEach((wr) => wr(t));
121
+ const p = clamp((t - turns[i]) / Math.max(0.05, ends[i] - turns[i]));
122
+ const clip = `inset(${((1 - p) * 100).toFixed(2)}% 0 0 0)`;
123
+ c2.fill.style.clipPath = clip;
124
+ c2.lit.style.clipPath = clip;
125
+ });
126
+ writeTitle?.(t);
127
+ };
128
+ },
129
+ };
130
+
131
+ export default pattern;
@@ -0,0 +1,22 @@
1
+ import { wordsSlam } from "../words/moves.ts";
2
+ import type { ListContent } from "./cards.ts";
3
+ import type { Pattern } from "../types.ts";
4
+
5
+ // 기능 낱말을 박마다 하나씩 띠를 꽉 채워 쾅 — 낱말 리듬(words.slam)과 같은 그림, 덧말은 소리·자막으로 간다
6
+ const pattern: Pattern<ListContent> = {
7
+ ...(wordsSlam as unknown as Pattern<ListContent>),
8
+ id: "list.cycle",
9
+ type: "list",
10
+ does: "기능 낱말이 박마다 하나씩 띠를 꽉 채우며 쾅 — 판과 글색이 뒤집힌다",
11
+ fits(c) {
12
+ const items = c?.items;
13
+ if (!Array.isArray(items) || items.length < 2 || items.length > 5) return "items 가 둘에서 다섯이 아니다";
14
+ if (items.some((it) => typeof it?.word !== "string" || !it.word.trim() || [...it.word].length > 10)) return "낱말이 비었거나 10자를 넘는다";
15
+ return true;
16
+ },
17
+ build(root, ctx, c, kit) {
18
+ return wordsSlam.build(root, ctx, { words: c.items.map((it) => it.word) }, kit);
19
+ },
20
+ };
21
+
22
+ export default pattern;
@@ -0,0 +1,100 @@
1
+ import { applyStyle, clamp, rand, spring } from "@kasaframe/core";
2
+ import { area, beatStops, cadence, moving, node, slotsOf } from "../kit.ts";
3
+ import type { Pattern } from "../types.ts";
4
+
5
+ export interface MediaContent {
6
+ /** assets 기준 그림 경로 */
7
+ assets: string[];
8
+ label?: string;
9
+ }
10
+
11
+ const grid = (n: number, vertical: boolean): [number, number] => {
12
+ const [c, r] = n <= 2 ? [2, 1] : n <= 4 ? [2, 2] : n <= 6 ? [3, 2] : n <= 9 ? [3, 3] : [4, 3];
13
+ return vertical ? [r, c] : [c, r];
14
+ };
15
+
16
+ /**
17
+ * 그림 여럿 — 칸이 박마다 하나씩 톡 붙어 격자를 채우고, 다 붙으면 「지금 볼 그림」이 박마다 다음 칸으로 옮겨 간다.
18
+ * 지금 칸은 또렷하고 나머지는 옅어진다 — 옮겨 가는 동안 내내 서서히 바뀌어 서지 않는다.
19
+ */
20
+ const pattern: Pattern<MediaContent> = {
21
+ id: "media.panels",
22
+ type: "media",
23
+ does: "그림 칸이 박마다 붙어 격자를 채우고, 지금 볼 칸이 박마다 옮겨 가며 또렷해진다",
24
+ energy: ["mid", "high"],
25
+ family: "pop",
26
+ camera: ["none"],
27
+ bg: "stage",
28
+ aspects: ["16:9", "9:16", "1:1"],
29
+ dur: { min: 2.4, max: 10 },
30
+ themes: { "kawaii-sticker": 0.5 },
31
+ fits(c) {
32
+ const a = c?.assets;
33
+ if (!Array.isArray(a) || a.length < 2) return "그림이 둘보다 적다";
34
+ if (a.length > 12) return "그림이 열둘을 넘는다";
35
+ if (a.some((x) => typeof x !== "string" || !x.trim())) return "그림 경로가 비었다";
36
+ if (c.label && [...c.label].length > 30) return "label 이 30자를 넘는다";
37
+ return true;
38
+ },
39
+ build(root, ctx, c, kit) {
40
+ const slots = slotsOf(kit);
41
+ const { w, h, vertical, u } = area(ctx, kit);
42
+ const n = c.assets.length;
43
+ const [cols, rows] = grid(n, vertical);
44
+ const labelPx = c.label ? Math.round(Math.max(44 * u, Math.min(72 * u, h * 0.05))) : 0;
45
+ const gap = Math.round(20 * u);
46
+ const gw = w * 0.94;
47
+ const gh = h * 0.92 - (labelPx ? labelPx * 1.8 : 0);
48
+ const cw = (gw - gap * (cols - 1)) / cols;
49
+ const ch = (gh - gap * (rows - 1)) / rows;
50
+ const x0 = (w - gw) / 2;
51
+ const y0 = (h - gh - (labelPx ? labelPx * 1.8 : 0)) / 2;
52
+
53
+ const host = node(root, "position:absolute;inset:0");
54
+ const enters = cadence(kit.hits, n);
55
+ const panels = c.assets.map((src, i) => {
56
+ const x = x0 + (i % cols) * (cw + gap);
57
+ const y = y0 + Math.floor(i / cols) * (ch + gap);
58
+ const box = node(host, `position:absolute;left:${x}px;top:${y}px;width:${cw}px;height:${ch}px;transform-origin:50% 50%`);
59
+ const panel = slots.panel(box, { ctx, at: enters[i], seed: `${kit.seed}:p${i}` });
60
+ const img = node(box, `position:absolute;inset:${Math.round(8 * u)}px;background:center/cover no-repeat url("${ctx.asset(src)}")`);
61
+ const tilt = (rand(kit.seed, i) - 0.5) * 3;
62
+ return { box, panel, img, tilt };
63
+ });
64
+ const label = c.label ? node(host, `position:absolute;left:0;right:0;bottom:${Math.round(h * 0.03)}px;text-align:center;font-family:var(--kf-font-sans);font-size:${labelPx}px;font-weight:700;color:var(--kf-ink);white-space:nowrap;line-height:1.3;height:1.3em`) : null;
65
+ kit.stage?.hero(host);
66
+
67
+ const doneAt = enters[n - 1] + 0.3;
68
+ const stops = beatStops(kit.hits, doneAt, ctx.dur, 0.5);
69
+ const writeLabel = label ? slots.write(label, { ctx, text: c.label!, at: 0.1, dur: 0.35, seed: `${kit.seed}:label` }) : null;
70
+
71
+ return (t: number) => {
72
+ moving("media.panels", ctx);
73
+ writeLabel?.(t);
74
+ // 지금 볼 칸의 자리(정수 사이를 내내 오간다) — 다 붙기 전엔 마지막으로 붙은 칸
75
+ let focus = -1;
76
+ if (t >= stops[0]) {
77
+ let j = 0;
78
+ while (j + 1 < stops.length && stops[j + 1] <= t) j++;
79
+ const p = clamp((t - stops[j]) / Math.max(1e-3, (stops[j + 1] ?? stops[j] + 1) - stops[j]));
80
+ const e = 0.7 * p + 0.3 * p * p * (3 - 2 * p);
81
+ focus = (j % n) + e;
82
+ }
83
+ panels.forEach((p, i) => {
84
+ const on = t >= enters[i];
85
+ p.box.style.visibility = on ? "visible" : "hidden";
86
+ if (!on) return;
87
+ p.panel(t);
88
+ const pop = spring(t - enters[i], { from: 0.5, to: 1, stiffness: 300, damping: 15 });
89
+ // 지금 칸과의 거리(되돌아 이어지는 고리) — 가까울수록 또렷하고 살짝 앞으로
90
+ const d = focus < 0 ? 0 : Math.min(Math.abs(i - focus), Math.abs(i + n - focus), Math.abs(i - n - focus));
91
+ const near = focus < 0 ? 1 : clamp(1 - d);
92
+ applyStyle(p.box, { scale: pop * (1 + 0.05 * near), rotate: p.tilt * (1 - near) });
93
+ p.img.style.opacity = (0.35 + 0.65 * near).toFixed(3);
94
+ p.box.style.zIndex = String(Math.round(near * 10));
95
+ });
96
+ };
97
+ },
98
+ };
99
+
100
+ export default pattern;
@@ -0,0 +1,234 @@
1
+ import type { KfStyle } from "@kasaframe/core";
2
+
3
+ // 객체 사전 — 내용 낱말 하나에 화면의 모양·색 하나. 「파란 칸 = KV 한 칸」처럼 같은 것은 2D 패턴·three·Blender 샷·공간 잇기 전환에서
4
+ // 같은 색·같은 모양으로 이어진다. 화면에서 움직이는 것은 이 사전의 무엇 하나여야 한다(뜻 없는 판·방울·입자는 두지 않는다).
5
+ // 색은 테마 토큰 이름으로만 — 테마가 바뀌면 같이 바뀌고, 한 영상 안에서는 늘 같은 색이다. DOM 을 만지지 않아 Node·Blender 내보내기에서도 읽는다.
6
+
7
+ export type ColorToken = "accent" | "ink" | "inkSoft" | "dim" | "success" | "warning" | "danger" | "raised" | "line";
8
+
9
+ /** 2D 모양 — 한 단위의 상자 비율(가로/세로)과 모서리 */
10
+ export interface Shape2d {
11
+ kind: "cell" | "chip" | "dot" | "bar" | "card" | "ring" | "line" | "stack";
12
+ /** 가로/세로 */
13
+ aspect: number;
14
+ radius: "none" | "sm" | "md" | "pill";
15
+ /** 테두리만(속 빈) — 아직 안 찬 자리·자리표 */
16
+ outline?: boolean;
17
+ }
18
+
19
+ /** 3D 모양 — 같은 물체가 일어설 때. size 는 2D 한 단위 가로를 1 로 본 [가로, 높이, 깊이] */
20
+ export interface Shape3d {
21
+ kind: "box" | "slab" | "sphere" | "cylinder" | "capsule" | "tile" | "torus";
22
+ size: readonly [number, number, number];
23
+ /** 모서리 둥글림(가로 대비 0..0.5) */
24
+ bevel: number;
25
+ /** 빛을 내나(발광 재질) */
26
+ glow?: boolean;
27
+ }
28
+
29
+ export interface VisualObject {
30
+ id: string;
31
+ /** 이 낱말이 나오면 이 물체 — 대소문자·띄어쓰기 무시 */
32
+ words: readonly string[];
33
+ /** 무엇을 뜻하나 한 줄 */
34
+ means: string;
35
+ color: ColorToken;
36
+ shape2d: Shape2d;
37
+ shape3d: Shape3d;
38
+ /** 수를 그릴 때 한 단위가 무엇인가(칸 하나 = 토큰 하나 따위) */
39
+ unit?: string;
40
+ }
41
+
42
+ export const OBJECTS: readonly VisualObject[] = [
43
+ {
44
+ id: "kv-cell",
45
+ words: ["KV", "KV 칸", "KV 캐시 칸", "kv cell", "key value", "키 값"],
46
+ means: "KV 캐시 한 칸 — 토큰 하나·층 하나가 남기는 키·값",
47
+ color: "accent",
48
+ shape2d: { kind: "cell", aspect: 0.58, radius: "none" },
49
+ shape3d: { kind: "box", size: [1, 1.72, 0.5], bevel: 0.04 },
50
+ unit: "토큰 하나의 키·값",
51
+ },
52
+ {
53
+ id: "kv-cache",
54
+ words: ["KV 캐시", "KV cache", "캐시", "cache", "prefix cache", "프리픽스 캐시"],
55
+ means: "KV 칸을 쌓은 더미 — 길어질수록 높아진다",
56
+ color: "accent",
57
+ shape2d: { kind: "stack", aspect: 0.58, radius: "none" },
58
+ shape3d: { kind: "box", size: [1, 1.72, 0.5], bevel: 0.04 },
59
+ unit: "KV 칸",
60
+ },
61
+ {
62
+ id: "latent",
63
+ words: ["latent", "잠재", "압축", "c_KV", "MLA", "low-rank", "저랭크"],
64
+ means: "눌러 담은 잠재 벡터 — KV 칸보다 얇다",
65
+ color: "accent",
66
+ shape2d: { kind: "cell", aspect: 0.3, radius: "none" },
67
+ shape3d: { kind: "slab", size: [0.5, 1.72, 0.5], bevel: 0.04 },
68
+ },
69
+ {
70
+ id: "rope",
71
+ words: ["RoPE", "로프", "위치", "position", "rotary"],
72
+ means: "위치 정보 반 칸 — 따로 들고 다니는 몫",
73
+ color: "warning",
74
+ shape2d: { kind: "cell", aspect: 0.29, radius: "none" },
75
+ shape3d: { kind: "slab", size: [0.5, 1.72, 0.5], bevel: 0.04 },
76
+ },
77
+ {
78
+ id: "token",
79
+ words: ["토큰", "token", "tokens", "낱말 조각"],
80
+ means: "글 조각 하나 — 줄지어 흐른다",
81
+ color: "ink",
82
+ shape2d: { kind: "chip", aspect: 1.8, radius: "sm" },
83
+ shape3d: { kind: "tile", size: [1.8, 1, 0.25], bevel: 0.12 },
84
+ unit: "토큰 하나",
85
+ },
86
+ {
87
+ id: "head",
88
+ words: ["head", "헤드", "어텐션 헤드", "attention head", "MHA", "GQA", "MQA"],
89
+ means: "어텐션 머리 하나 — 같은 줄의 칸을 따로 본다",
90
+ color: "inkSoft",
91
+ shape2d: { kind: "ring", aspect: 1, radius: "pill" },
92
+ shape3d: { kind: "torus", size: [1, 1, 0.25], bevel: 0 },
93
+ },
94
+ {
95
+ id: "layer",
96
+ words: ["층", "layer", "레이어", "transformer block", "블록"],
97
+ means: "모델 층 하나 — 얇은 판이 겹친다",
98
+ color: "dim",
99
+ shape2d: { kind: "bar", aspect: 6, radius: "sm" },
100
+ shape3d: { kind: "slab", size: [6, 0.2, 3], bevel: 0.02 },
101
+ },
102
+ {
103
+ id: "gpu",
104
+ words: ["GPU", "그래픽카드", "H100", "B200", "B300", "가속기", "accelerator"],
105
+ means: "계산하는 판 — 칸을 담는 그릇",
106
+ color: "ink",
107
+ shape2d: { kind: "card", aspect: 1.6, radius: "sm" },
108
+ shape3d: { kind: "slab", size: [3.2, 0.4, 2], bevel: 0.03 },
109
+ },
110
+ {
111
+ id: "memory",
112
+ words: ["메모리", "memory", "HBM", "VRAM", "GB", "용량"],
113
+ means: "GPU 메모리 — 칸이 차오르는 상자, 넘치면 위험색",
114
+ color: "line",
115
+ shape2d: { kind: "card", aspect: 0.7, radius: "sm", outline: true },
116
+ shape3d: { kind: "box", size: [3, 4.2, 1], bevel: 0.02 },
117
+ unit: "GB",
118
+ },
119
+ {
120
+ id: "request",
121
+ words: ["요청", "request", "호출", "call", "API 호출", "프롬프트", "prompt"],
122
+ means: "나가는 요청 하나 — 선을 따라 간다",
123
+ color: "accent",
124
+ shape2d: { kind: "dot", aspect: 1, radius: "pill" },
125
+ shape3d: { kind: "sphere", size: [1, 1, 1], bevel: 0.5, glow: true },
126
+ },
127
+ {
128
+ id: "response",
129
+ words: ["응답", "response", "답", "결과", "output", "출력"],
130
+ means: "돌아오는 응답 — 요청과 같은 모양, 성공색",
131
+ color: "success",
132
+ shape2d: { kind: "dot", aspect: 1, radius: "pill" },
133
+ shape3d: { kind: "sphere", size: [1, 1, 1], bevel: 0.5, glow: true },
134
+ },
135
+ {
136
+ id: "error",
137
+ words: ["오류", "에러", "error", "실패", "fail", "timeout", "429", "500"],
138
+ means: "실패한 요청 — 같은 점이 위험색으로",
139
+ color: "danger",
140
+ shape2d: { kind: "dot", aspect: 1, radius: "pill" },
141
+ shape3d: { kind: "sphere", size: [1, 1, 1], bevel: 0.5 },
142
+ },
143
+ {
144
+ id: "model",
145
+ words: ["모델", "model", "LLM", "엔진"],
146
+ means: "모델 하나 — 요청이 들어가 응답이 나오는 상자",
147
+ color: "ink",
148
+ shape2d: { kind: "card", aspect: 1, radius: "md" },
149
+ shape3d: { kind: "box", size: [2, 2, 2], bevel: 0.15 },
150
+ },
151
+ {
152
+ id: "server",
153
+ words: ["서버", "server", "노드", "node", "게이트웨이", "gateway", "라우터", "router"],
154
+ means: "요청이 지나는 마디",
155
+ color: "dim",
156
+ shape2d: { kind: "card", aspect: 0.8, radius: "sm" },
157
+ shape3d: { kind: "box", size: [1.6, 2, 1.2], bevel: 0.06 },
158
+ },
159
+ {
160
+ id: "latency",
161
+ words: ["지연", "latency", "TTFT", "ms", "속도", "시간"],
162
+ means: "걸린 시간 — 길이가 시간인 막대",
163
+ color: "warning",
164
+ shape2d: { kind: "bar", aspect: 8, radius: "pill" },
165
+ shape3d: { kind: "capsule", size: [8, 1, 1], bevel: 0.5 },
166
+ unit: "ms",
167
+ },
168
+ {
169
+ id: "cost",
170
+ words: ["비용", "cost", "가격", "price", "$", "원", "요금"],
171
+ means: "드는 돈 — 쌓이는 동전 기둥",
172
+ color: "warning",
173
+ shape2d: { kind: "dot", aspect: 1, radius: "pill", outline: true },
174
+ shape3d: { kind: "cylinder", size: [1, 0.18, 1], bevel: 0.02 },
175
+ unit: "동전 하나",
176
+ },
177
+ {
178
+ id: "user",
179
+ words: ["사용자", "user", "사람", "고객", "client", "클라이언트"],
180
+ means: "요청을 보내는 쪽",
181
+ color: "inkSoft",
182
+ shape2d: { kind: "dot", aspect: 1, radius: "pill", outline: true },
183
+ shape3d: { kind: "capsule", size: [1, 1.8, 1], bevel: 0.5 },
184
+ },
185
+ {
186
+ id: "document",
187
+ words: ["문서", "document", "파일", "file", "자료", "source", "출처"],
188
+ means: "읽어 들이는 글 묶음",
189
+ color: "raised",
190
+ shape2d: { kind: "card", aspect: 0.75, radius: "sm" },
191
+ shape3d: { kind: "slab", size: [0.75, 1, 0.06], bevel: 0.02 },
192
+ },
193
+ ];
194
+
195
+ const norm = (s: string): string => s.toLowerCase().replace(/\s+/g, "");
196
+
197
+ /** 낱말 → 물체. 가장 길게 맞은 낱말이 이긴다(「KV 캐시 칸」이 「KV 캐시」보다 먼저). 없으면 null — 패턴은 제 기본 모양으로 */
198
+ export function objectFor(word: string | undefined | null): VisualObject | null {
199
+ if (!word) return null;
200
+ const w = norm(word);
201
+ let best: { o: VisualObject; len: number } | null = null;
202
+ for (const o of OBJECTS) {
203
+ for (const k of o.words) {
204
+ const n = norm(k);
205
+ if (n && w.includes(n) && (!best || n.length > best.len)) best = { o, len: n.length };
206
+ }
207
+ }
208
+ return best?.o ?? null;
209
+ }
210
+
211
+ export const objectById = (id: string): VisualObject | null => OBJECTS.find((o) => o.id === id) ?? null;
212
+
213
+ /** 장면(브라우저 DOM)에서 — CSS 변수 이름 */
214
+ export const cssColor = (token: ColorToken): string => `var(--kf-${token.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)})`;
215
+
216
+ /** three·Blender 에서 — 그 테마 style 의 실제 색(#rrggbb). 토큰이 없으면 ink */
217
+ export const styleColor = (style: Pick<KfStyle, "colors">, token: ColorToken): string => {
218
+ const c = style.colors as Record<string, string | undefined>;
219
+ return c[token] ?? c.ink ?? "#ffffff";
220
+ };
221
+
222
+ /** 2D 한 단위의 CSS — px 은 한 단위 높이 */
223
+ export function objectCss(o: VisualObject, px: number): string {
224
+ const s = o.shape2d;
225
+ const w = Math.round(px * s.aspect);
226
+ const r = s.radius === "pill" ? "999px" : s.radius === "md" ? "var(--kf-radius-md)" : s.radius === "sm" ? "var(--kf-radius-sm)" : "0";
227
+ const c = cssColor(o.color);
228
+ return `width:${w}px;height:${Math.round(px)}px;border-radius:${r};box-sizing:border-box;` + (s.outline ? `border:${Math.max(2, Math.round(px * 0.06))}px solid ${c}` : `background:${c}`);
229
+ }
230
+
231
+ /** Blender·three 로 넘길 사전 한 벌 — 색은 그 테마 style 로 풀어 둔다. `node -e` 로 JSON 을 뽑아 kfb.py 가 읽는다 */
232
+ export function objectsJson(style: Pick<KfStyle, "colors">): { id: string; words: readonly string[]; color: string; shape2d: Shape2d; shape3d: Shape3d }[] {
233
+ return OBJECTS.map((o) => ({ id: o.id, words: o.words, color: styleColor(style, o.color), shape2d: o.shape2d, shape3d: o.shape3d }));
234
+ }
@@ -0,0 +1,126 @@
1
+ import { applyStyle, clamp, spring } from "@kasaframe/core";
2
+ import { fitSize, textEm } from "@kasaframe/motion/moves";
3
+ import { ACCENT_TEXT, area, beatStops, glider, moving, node, slotsOf } from "../kit.ts";
4
+ import type { Pattern, SpokenWord } from "../types.ts";
5
+
6
+ export interface QuoteContent {
7
+ text: string;
8
+ who?: string;
9
+ role?: string;
10
+ }
11
+
12
+ const norm = (s: string) => s.replace(/[^\p{L}\p{N}]/gu, "").toLowerCase();
13
+
14
+ // 들리는 낱말 시각을 문장 낱말에 차례로 짝짓는다 — 못 맞추면 글자 수 비율로 고르게
15
+ function timing(words: string[], say: SpokenWord[], from: number, to: number): [number, number][] {
16
+ const out: [number, number][] = [];
17
+ let k = 0;
18
+ for (const wd of words) {
19
+ const key = norm(wd);
20
+ let hit = -1;
21
+ for (let j = k; j < Math.min(say.length, k + 4); j++) if (key && norm(say[j].text).includes(key.slice(0, 2))) { hit = j; break; }
22
+ if (hit < 0) return even(words, from, to);
23
+ out.push([say[hit].from, say[hit].to]);
24
+ k = hit + 1;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ function even(words: string[], from: number, to: number): [number, number][] {
30
+ const total = words.reduce((a, w) => a + [...w].length, 0) || 1;
31
+ let at = from;
32
+ return words.map((w) => {
33
+ const d = ((to - from) * [...w].length) / total;
34
+ const r: [number, number] = [at, at + d];
35
+ at += d;
36
+ return r;
37
+ });
38
+ }
39
+
40
+ /**
41
+ * 인용 — 문장은 처음부터 다 떠 있고, 들리는 낱말(kit.say)마다 반전 칸이 그 낱말을 왼쪽부터 채운다(내레이션이 없으면 글자 수 비율로).
42
+ * 끝자락에 누가 말했는지가 찍힌다. 움직이는 것은 「지금 읽는 낱말」 하나.
43
+ */
44
+ const pattern: Pattern<QuoteContent> = {
45
+ id: "quote.karaoke",
46
+ type: "quote",
47
+ does: "문장이 떠 있고 들리는 낱말마다 반전 칸이 채워 간다, 끝에 누가 말했는지가 찍힌다",
48
+ energy: ["low", "mid"],
49
+ family: "type",
50
+ camera: ["none"],
51
+ bg: "stage",
52
+ aspects: ["16:9", "9:16", "1:1"],
53
+ dur: { min: 2.4, max: 10 },
54
+ fits(c) {
55
+ if (typeof c?.text !== "string" || !c.text.trim()) return "text 가 비었다";
56
+ if ([...c.text].length > 60) return "text 가 60자를 넘는다";
57
+ if (c.text.trim().split(/\s+/).length < 2) return "낱말이 하나뿐이다 — words 로";
58
+ return true;
59
+ },
60
+ build(root, ctx, c, kit) {
61
+ const slots = slotsOf(kit);
62
+ const { w, h, vertical, u } = area(ctx, kit);
63
+ const words = c.text.trim().split(/\s+/);
64
+ // 줄 수를 어림해 글 크기를 고른다 — 넓이에 맞춰 두세 줄
65
+ const em = textEm(c.text);
66
+ const boxW = w * (vertical ? 0.88 : 0.84);
67
+ const boxH = h * (vertical ? 0.5 : 0.56);
68
+ let px = 40 * u;
69
+ for (let s = Math.round(210 * u); s >= 40 * u; s -= 4) {
70
+ const lines = Math.ceil((em * s * 1.08) / boxW);
71
+ if (lines * s * 1.3 <= boxH && lines <= 4) {
72
+ px = s;
73
+ break;
74
+ }
75
+ }
76
+ const whoPx = Math.round(Math.max(36 * u, px * 0.34));
77
+
78
+ const host = node(root, "position:absolute;inset:0;display:flex;align-items:center;justify-content:center");
79
+ const col = node(host, `display:flex;flex-direction:column;align-items:flex-start;gap:${Math.round(px * 0.3)}px;width:${boxW}px`);
80
+ const mark = node(col, `font-family:var(--kf-font-display);font-size:${Math.round(px * 1.4)}px;line-height:.6;height:${Math.round(px * 0.6)}px;font-weight:900;color:${ACCENT_TEXT}`, "“");
81
+ const body = node(col, `font-family:var(--kf-font-display);font-size:${px}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};line-height:1.3;color:var(--kf-ink);word-break:keep-all`);
82
+ kit.stage?.hero(body);
83
+ const spans = words.map((wd, i) => {
84
+ const sp = body.ownerDocument.createElement("span");
85
+ sp.style.cssText = "position:relative;white-space:nowrap";
86
+ sp.textContent = wd;
87
+ const hi = node(sp, "position:absolute;left:-0.08em;right:-0.08em;top:0.02em;bottom:0.02em;padding:0 0.08em;background:var(--kf-accent);color:var(--kf-on-accent);white-space:nowrap;visibility:hidden", wd);
88
+ body.append(sp);
89
+ if (i < words.length - 1) body.append(body.ownerDocument.createTextNode(" "));
90
+ return { sp, hi };
91
+ });
92
+ const whoText = [c.who, c.role].filter(Boolean).join(" · ");
93
+ const who = whoText ? node(col, `align-self:flex-end;font-family:var(--kf-font-sans);font-size:${whoPx}px;font-weight:600;color:var(--kf-ink-soft, var(--kf-ink));white-space:nowrap;line-height:1.3;height:1.3em`) : null;
94
+
95
+ const whoDur = whoText ? clamp([...whoText].length * 0.04, 0.3, 0.7) : 0;
96
+ // 첫 프레임부터 읽기 시작한다 — 문장이 다 떠 있는 채로 서 있으면 멈춘 화면이다
97
+ const readFrom = 0;
98
+ // 목소리가 없으면 말하는 빠르기(글자당 0.13초)로 읽는다 — 장면 길이에 맞춰 늘이면 긴 장면에서 칸이 기어가 멈춘 화면이 된다
99
+ const chars = words.reduce((a, wd) => a + [...wd].length, 0);
100
+ const readTo = Math.min(ctx.dur - 0.05, Math.max(1.2, chars * 0.13));
101
+ const times = kit.say.length ? timing(words, kit.say, readFrom, ctx.dur - 0.05) : even(words, readFrom, readTo);
102
+ const readEnd = times[times.length - 1][1];
103
+ const whoAt = Math.min(Math.max(readFrom, ctx.dur - whoDur - 0.6), readEnd);
104
+ // 다 읽은 뒤엔 짚개가 문장 ↔ 누가 말했는지를 박마다 번갈아 짚는다(누가 없으면 낱말을 차례로)
105
+ const point = glider(host, who ? [body, who] : spans.map((x) => x.sp), { hub: !!who, pad: 0.06 });
106
+ const stops = beatStops(kit.hits, readEnd + 0.05, ctx.dur);
107
+ const writeWho = who ? slots.write(who, { ctx, text: whoText, at: whoAt, dur: whoDur, seed: `${kit.seed}:who` }) : null;
108
+
109
+ return (t: number) => {
110
+ moving("quote.karaoke", ctx);
111
+ applyStyle(mark, { scale: spring(t, { from: 0.4, to: 1, stiffness: 260, damping: 14 }) });
112
+ spans.forEach((s, i) => {
113
+ const [a, b] = times[i];
114
+ const k = clamp((t - a) / Math.max(0.05, b - a));
115
+ s.hi.style.visibility = k > 0 ? "visible" : "hidden";
116
+ s.hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
117
+ });
118
+ writeWho?.(t);
119
+ // 짚는 동안은 낱말 반전 칸을 걷는다 — 두 강조가 겹치면 무엇을 짚는지 흐려진다
120
+ if (t >= readEnd + 0.05) spans.forEach((x) => (x.hi.style.visibility = "hidden"));
121
+ point(t, stops);
122
+ };
123
+ },
124
+ };
125
+
126
+ export default pattern;
@@ -0,0 +1,22 @@
1
+ import { wordsSlam } from "../words/moves.ts";
2
+ import type { QuoteContent } from "./karaoke.ts";
3
+ import type { Pattern } from "../types.ts";
4
+
5
+ // 짧고 센 한 마디 — 낱말이 박마다 하나씩 띠를 꽉 채우며 쾅(낱말 리듬과 같은 그림). 누가 말했는지는 소리·자막으로 간다
6
+ const pattern: Pattern<QuoteContent> = {
7
+ ...(wordsSlam as unknown as Pattern<QuoteContent>),
8
+ id: "quote.slam",
9
+ type: "quote",
10
+ does: "짧은 한 마디의 낱말이 박마다 하나씩 띠를 꽉 채우며 쾅",
11
+ fits(c) {
12
+ const ws = (c?.text ?? "").trim().split(/\s+/).filter(Boolean);
13
+ if (ws.length < 2 || ws.length > 6) return "낱말이 둘에서 여섯이 아니다 — 긴 문장은 quote.karaoke";
14
+ if (ws.some((x) => [...x].length > 10)) return "낱말 하나가 10자를 넘는다";
15
+ return true;
16
+ },
17
+ build(root, ctx, c, kit) {
18
+ return wordsSlam.build(root, ctx, { words: c.text.trim().split(/\s+/) }, kit);
19
+ },
20
+ };
21
+
22
+ export default pattern;