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,15 +1,27 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { readKey } from "@kasaframe/gen";
2
4
 
3
5
  // OG chat/completions 하나로 어떤 모델이든 부른다. 그림 보는 검사도 여기로 — OG /v1/messages 는 딥시크에 그림을 넘기지 않는다
4
6
  const BASE = process.env.KF_OG_BASE ?? "https://apis.opengateway.ai/v1";
5
7
 
8
+ // Opus·GPT 계열은 한 번에 값이 크게 뛴다 — 장면 쓰기·고치기·그림 검사가 모두 이 문을 지나니 여기서 한 번만 막는다.
9
+ // 짚은 사람이 풀어야만(--allow-premium 이 KF_ALLOW_PREMIUM=1 로 넘긴다 — 자식 kf 까지 같은 값)
10
+ export function premiumBlock(model: string): string | null {
11
+ if (process.env.KF_ALLOW_PREMIUM === "1") return null;
12
+ const name = model.toLowerCase().split("/").pop() ?? "";
13
+ if (!/^claude.*opus|^opus/.test(name) && !/^(gpt|chatgpt|o\d+)([-.]|$)/.test(name)) return null;
14
+ return `${model} 은 비싼 모델(Opus·GPT 계열)이라 부르기 전에 막았다 — 꼭 써야 하면 --allow-premium(또는 KF_ALLOW_PREMIUM=1)`;
15
+ }
16
+
6
17
  export type Part = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } };
7
18
  export interface Message {
8
19
  role: "system" | "user" | "assistant";
9
20
  content: string | Part[];
10
21
  }
11
22
 
12
- // 모델별로 쓴 만큼 — kf make 가 끝에 out/make-stats.json 으로 남겨 모델끼리 시간·토큰·비용을 견준다
23
+ // 모델별로 쓴 만큼 — kf make 가 끝날 때(성공·실패·중단) <작업 폴더>/make-stats.json 에 남겨 모델끼리 시간·토큰·비용을 견준다.
24
+ // og 는 OG 를 거친 호출(chat·OG 그림) — 그 밖의 생성(fal 영상 따위)도 같은 장부에 두되 「OG 호출」 줄에는 안 센다
13
25
  export interface Spent {
14
26
  model: string;
15
27
  stage: string;
@@ -18,9 +30,57 @@ export interface Spent {
18
30
  completion: number;
19
31
  cached: number;
20
32
  usd: number | null;
33
+ og: boolean;
21
34
  }
22
35
  const spent: Spent[] = [];
23
36
  export const ledger = (): Spent[] => [...spent];
37
+ export const spend = (s: Spent): void => void spent.push(s);
38
+
39
+ interface Total {
40
+ calls: number;
41
+ prompt: number;
42
+ completion: number;
43
+ cached: number;
44
+ usd: number;
45
+ ms: number;
46
+ // 가격표를 못 받아 값을 모르는 호출 수 — usd 합에 안 들어갔다
47
+ unpriced: number;
48
+ }
49
+
50
+ function sum(xs: Spent[]): Total {
51
+ const t: Total = { calls: xs.length, prompt: 0, completion: 0, cached: 0, usd: 0, ms: 0, unpriced: 0 };
52
+ for (const x of xs) {
53
+ t.prompt += x.prompt;
54
+ t.completion += x.completion;
55
+ t.cached += x.cached;
56
+ t.ms += x.ms;
57
+ if (x.usd === null) t.unpriced++;
58
+ else t.usd += x.usd;
59
+ }
60
+ t.usd = Number(t.usd.toFixed(6));
61
+ return t;
62
+ }
63
+
64
+ export function spentLine(xs: Spent[] = spent): string {
65
+ const og = sum(xs.filter((x) => x.og));
66
+ const other = sum(xs.filter((x) => !x.og));
67
+ const head = og.calls ? `OG 호출 ${og.calls}번 · 입력 ${og.prompt.toLocaleString("en-US")}/출력 ${og.completion.toLocaleString("en-US")} 토큰 · $${og.usd.toFixed(4)}${og.unpriced ? `(값 모름 ${og.unpriced}번)` : ""}` : "OG 호출 0";
68
+ return other.calls ? `${head} · 그 밖 생성 ${other.calls}번 $${other.usd.toFixed(4)}` : head;
69
+ }
70
+
71
+ // 한 kf make 실행이 한 판 — --finish·--rewrite 가 같은 폴더에 다시 돌아도 앞 판을 지우지 않고 뒤에 붙인다
72
+ export function writeStats(dir: string, run: { command: string; model: string; code: number | string }): string {
73
+ const file = join(dir, "make-stats.json");
74
+ let runs: unknown[] = [];
75
+ try {
76
+ runs = (JSON.parse(readFileSync(file, "utf8")) as { runs?: unknown[] }).runs ?? [];
77
+ } catch {}
78
+ const stages = ledger();
79
+ runs.push({ at: new Date().toISOString(), ...run, stages, total: sum(stages), og: sum(stages.filter((x) => x.og)), line: spentLine(stages) });
80
+ const all = (runs as { stages?: Spent[] }[]).flatMap((r) => r.stages ?? []);
81
+ writeFileSync(file, `${JSON.stringify({ runs, total: sum(all), og: sum(all.filter((x) => x.og)), line: spentLine(all) }, null, 2)}\n`);
82
+ return file;
83
+ }
24
84
 
25
85
  let prices: Record<string, { inputCostPerToken?: number; outputCostPerToken?: number; cacheReadInputTokenCost?: number }> | null = null;
26
86
  // 공개 가격표(키 없이). 못 받으면 비용은 null 로 두고 토큰만 남긴다
@@ -48,6 +108,8 @@ export async function chat(
48
108
  messages: Message[],
49
109
  o: { temperature?: number; maxTokens?: number; json?: boolean; timeoutSec?: number; effort?: "low" | "medium" | "high"; stage?: string } = {},
50
110
  ): Promise<ChatResult> {
111
+ const blocked = premiumBlock(model);
112
+ if (blocked) throw new Error(blocked);
51
113
  const t0 = Date.now();
52
114
  const body: Record<string, unknown> = { model, messages, max_tokens: o.maxTokens ?? 8000 };
53
115
  // 추론을 켠 Claude 는 temperature 를 받지 않는다
@@ -76,7 +138,7 @@ export async function chat(
76
138
  const prompt = u.prompt_tokens ?? 0;
77
139
  const completion = u.completion_tokens ?? 0;
78
140
  const usd = p?.inputCostPerToken !== undefined ? (prompt - cached) * p.inputCostPerToken + cached * (p.cacheReadInputTokenCost ?? p.inputCostPerToken) + completion * (p.outputCostPerToken ?? 0) : null;
79
- spent.push({ model, stage: o.stage ?? "", ms, prompt, completion, cached, usd });
141
+ spent.push({ model, stage: o.stage ?? "", ms, prompt, completion, cached, usd, og: true });
80
142
  return { text: j.choices?.[0]?.message?.content ?? "", finish: j.choices?.[0]?.finish_reason, usage: j.usage, ms };
81
143
  } catch (e) {
82
144
  if ((e as any)?.fatal) throw e;
@@ -149,6 +149,8 @@ function checklistText(kf: KfLike | null, sheet: string): string {
149
149
  "글이 읽힌다 — 깨진 글자(빈 네모·undefined·NaN·null) 없음, 바탕과 대비가 선다, 잘림·겹침 없음",
150
150
  "주인공이 안전 영역 가운데에 선다 — 가장자리로 밀리거나 잘리지 않았다",
151
151
  `화면 글 정책(${kf?.text?.policy ?? "모름"})을 지킨다`,
152
+ "뜻 없는 장식이 없다 — 움직이거나 놓인 것은 다 내용 하나를 뜻한다(떠다니는 판·빛 방울·큐브·흩뿌린 입자 없음, 바탕은 정지거나 아주 옅은 질감)",
153
+ "3D↔2D 가 끊기지 않는다 — 3D 샷과 2D 장면 사이는 공간 잇기 이음매(rise·land)나 투영을 맞춘 컷으로, 같은 물체·자리가 이어진다",
152
154
  ...(theme ? [`테마 ${theme.name}(${theme.metaphor})의 생김새·금지를 지킨다 — themes/${kf?.theme}/THEME.md`] : []),
153
155
  ];
154
156
  return [
@@ -183,7 +185,7 @@ export async function look(target: string, o: { ask?: string; model?: string } =
183
185
  장면: ${JSON.stringify(scenes.map((s) => ({ id: s.id, from: +s.from.toFixed(2), to: +(s.from + s.dur).toFixed(2), role: s.role, idea: s.idea })))}
184
186
  화면 글 정책: ${kf?.text?.policy ?? "모름"}${theme}
185
187
 
186
- 장면마다 그림만 보고 문제를 짧게 적는다 — 빈 화면·화면을 못 채운 작은 요소·글자 깨짐(빈 네모·undefined·NaN)·겹침·잘림·대비 낮은 글·할 일(idea)과 다른 화면·테마 위반. 정지 그림이라 박·움직임은 판단하지 않는다. 문제가 없으면 problems 를 비운다.${o.ask ? `\n그리고 이것에 답한다: ${o.ask}` : ""}
188
+ 장면마다 그림만 보고 문제를 짧게 적는다 — 빈 화면·화면을 못 채운 작은 요소·글자 깨짐(빈 네모·undefined·NaN)·겹침·잘림·대비 낮은 글·할 일(idea)과 다른 화면·테마 위반·뜻 없는 장식(내용과 상관없이 떠다니는 판·빛 방울·큐브·입자)·3D 장면과 2D 장면 사이가 끊김(앞뒤가 다른 세계로 튐). 정지 그림이라 박·움직임은 판단하지 않는다. 문제가 없으면 problems 를 비운다.${o.ask ? `\n그리고 이것에 답한다: ${o.ask}` : ""}
187
189
  JSON 하나로만: {"scenes":[{"id":"…","problems":["…"]}],"summary":"전체 한두 문장"${o.ask ? ',"answer":"…"' : ""}}`;
188
190
  const ask = (m: string) => chat(m, [{ role: "user", content: [...parts, { type: "text", text: prompt }] }], { json: true, temperature: 0.2, maxTokens: 6000, stage: "look" });
189
191
  let used = model;
@@ -2,9 +2,10 @@ import { detect, type Detection } from "./detect.ts";
2
2
  import { layoutScenes, pickDuration } from "./layout.ts";
3
3
  import { parseAspect, parseDuration, parseStyle, parseTitle } from "./request.ts";
4
4
  import { frameFor, listTemplates, loadTemplate } from "./templates.ts";
5
+ import { loadSeries, type Series, seriesFromRequest } from "./series.ts";
5
6
  import { pickTheme, suggestThemes, themeFromRequest } from "./themes.ts";
6
7
  import { basename } from "node:path";
7
- import type { Aspect, BeatsLike, GenSpec, Plan, PlannedScene, Purpose, Template } from "./types.ts";
8
+ import type { Aspect, BeatsLike, GenSpec, Plan, PlannedScene, Purpose, ShotSpec, Template } from "./types.ts";
8
9
 
9
10
  const IMAGE = /\.(png|jpe?g|webp)$/i;
10
11
 
@@ -13,6 +14,24 @@ export function wantsVideo(request: string): boolean {
13
14
  return /영상\s*생성|생성\s*영상|AI\s*영상|영상\s*모델|video\s*(gen|model)|generated\s*video/i.test(request);
14
15
  }
15
16
 
17
+ // 캐릭터가 실제로 움직여야 하는 컷 — 모션그래픽·3D 툰으로는 몸짓이 안 서서 영상 모델이 맡는다
18
+ const CHARACTER = /캐릭터|마스코트|인물|사람|소녀|소년|아이돌|댄서|동물|고양이|강아지|새|로봇|주인공|character|mascot|person|girl|boy|dancer|animal|cat|dog|robot/i;
19
+ const ACTION = /움직|춤|걷|달리|뛰|날아|날갯|헤엄|싸우|연기|몸짓|손짓|표정|돌아보|dance|walk|run|jump|fly|swim|wave|move|gesture/i;
20
+ export function wantsMotionCut(request: string): boolean {
21
+ return CHARACTER.test(request) && ACTION.test(request);
22
+ }
23
+
24
+ // 「2D 로」「3D 없이」라고 하면 주인공 3D 샷을 넣지 않는다
25
+ export function refuses3d(request: string): boolean {
26
+ return /3D\s*(없이|빼|말고)|2D\s*(로|만)|평면(으로|만)|no\s*3d|flat\s*only/i.test(request);
27
+ }
28
+
29
+ // 틀의 자리 이름은 장면 id 먼저, 없으면 그 역할의 첫 장면(뮤비처럼 곡 구간으로 id 가 바뀌는 틀)
30
+ function slot<T extends { id: string; role: string }>(scenes: T[], name?: string): T | undefined {
31
+ if (!name) return undefined;
32
+ return scenes.find((s) => s.id === name) ?? scenes.find((s) => s.role === name);
33
+ }
34
+
16
35
  export interface PlanInput {
17
36
  request: string;
18
37
  purpose?: Purpose; // 사람이 못 박으면 판별을 건너뛴다
@@ -23,27 +42,47 @@ export interface PlanInput {
23
42
  aspect?: Aspect;
24
43
  style?: string;
25
44
  title?: string;
26
- theme?: string; // 테마 id 나 "auto"
45
+ theme?: string; // 테마 id · "auto" · "none"(프리셋만)
46
+ series?: string; // 시리즈 id · "none". 안 주면 요청 글이 짚은 시리즈(series/<id>.json 의 match)
27
47
  }
28
48
 
29
49
  export function makePlan(input: PlanInput): Plan {
30
50
  const templates = listTemplates();
51
+ // 시리즈가 먼저 — 짚었으면 용도·테마·길이·자막·끝 카드를 그 묶음이 정한다(사람이 따로 준 값은 그대로 이긴다)
52
+ const series: Series | null = input.series === "none" ? null : input.series ? loadSeries(input.series) : seriesFromRequest(input.request);
31
53
  const det: Detection = input.purpose
32
54
  ? { purpose: input.purpose, confidence: 1, ranked: [], ambiguous: false }
33
- : detect({ request: input.request, assets: input.assets, music: input.music }, templates);
34
- const tpl = loadTemplate(det.purpose);
55
+ : series
56
+ ? { purpose: series.purpose, confidence: 1, ranked: [], ambiguous: false }
57
+ : detect({ request: input.request, assets: input.assets, music: input.music }, templates);
58
+ const base = loadTemplate(det.purpose);
59
+ const tpl: Template = series?.duration ? { ...base, duration: { ...base.duration, ...series.duration } } : base;
35
60
  const aspect = input.aspect ?? parseAspect(input.request);
36
61
  const frame = frameFor(tpl, aspect && (aspect === tpl.aspect || tpl.variants?.[aspect]) ? aspect : undefined);
37
62
  const { duration, notes } = pickDuration(tpl, { requested: input.duration ?? parseDuration(input.request), beats: input.beats });
38
63
  const laid = layoutScenes(tpl, duration, input.beats);
39
64
  // auto: 요청이 테마를 말로 짚었으면 그 테마, 아니면 내용에 맞는 approved 후보 셋 중 첫째
40
- const named = input.theme === "auto" ? themeFromRequest(input.request, tpl.id) : null;
41
- const themeCandidates = input.theme === "auto" ? [...(named ? [named] : []), ...suggestThemes(input.request, tpl.id, frame.aspect).filter((c) => c.id !== named?.id)].slice(0, 3) : undefined;
42
- const theme = input.theme === "auto" ? (themeCandidates?.[0]?.id ?? null) : input.theme ? pickTheme(input.theme).id : null;
65
+ // none 은 프리셋만 — 시리즈 테마도 쓰지 않는다
66
+ const themeIn = input.theme === "none" ? undefined : (input.theme === undefined || input.theme === "auto") && series?.theme ? series.theme : input.theme;
67
+ const named = themeIn === "auto" ? themeFromRequest(input.request, tpl.id) : null;
68
+ const themeCandidates = themeIn === "auto" ? [...(named ? [named] : []), ...suggestThemes(input.request, tpl.id, frame.aspect).filter((c) => c.id !== named?.id)].slice(0, 3) : undefined;
69
+ const theme = themeIn === "auto" ? (themeCandidates?.[0]?.id ?? null) : themeIn ? pickTheme(themeIn).id : null;
43
70
  // 테마를 고르면 그 테마의 style 이 프리셋을 대신한다(kf new 가 themeStyle 로 쓴다)
44
- const style = theme ?? input.style ?? parseStyle(input.request) ?? tpl.style;
45
- const title = input.title ?? parseTitle(input.request) ?? "";
46
- const gen = genSpecs(tpl, laid.scenes, { subject: title || input.request, style, width: frame.width, height: frame.height, video: wantsVideo(input.request), refs: (input.assets ?? []).filter((a) => IMAGE.test(a)).map((a) => `assets/in/${basename(a)}`) });
71
+ const style = theme ?? input.style ?? series?.style ?? parseStyle(input.request) ?? tpl.style;
72
+ const title = input.title ?? series?.end?.title ?? parseTitle(input.request) ?? "";
73
+ const gen = genSpecs(tpl, laid.scenes, {
74
+ subject: title || input.request,
75
+ style,
76
+ width: frame.width,
77
+ height: frame.height,
78
+ video: wantsVideo(input.request),
79
+ act: tpl.id === "mv" || wantsMotionCut(input.request),
80
+ refs: (input.assets ?? []).filter((a) => IMAGE.test(a)).map((a) => `assets/in/${basename(a)}`),
81
+ });
82
+ const hero = refuses3d(input.request) ? undefined : slot(laid.scenes, tpl.assets.shot3d);
83
+ const shots: ShotSpec[] = hero
84
+ ? [{ id: `${hero.id}-3d`, scene: hero.id, tool: "blender", idea: `${title || input.request} — 「${hero.idea}」을 주인공 3D 장면으로(툰 셰이딩·라인아트, 카메라 열쇠는 첫박·드롭에)`, file: `shots/${hero.id}-3d.py` }]
85
+ : [];
47
86
 
48
87
  const scenes: PlannedScene[] = laid.scenes.map((s) => ({
49
88
  id: s.id,
@@ -60,7 +99,7 @@ export function makePlan(input: PlanInput): Plan {
60
99
  purpose: det.purpose,
61
100
  confidence: det.confidence,
62
101
  alternatives: det.ranked.slice(1, 3).map((r) => ({ purpose: r.purpose, p: r.p })),
63
- reasons: [...(top?.reasons ?? (input.purpose ? ["사람이 정함"] : [])), ...notes, ...laid.notes],
102
+ reasons: [...(series ? [`시리즈 ${series.id}(${series.label})`] : []), ...(top?.reasons ?? (input.purpose ? ["사람이 정함"] : [])), ...notes, ...laid.notes],
64
103
  title,
65
104
  aspect: frame.aspect,
66
105
  width: frame.width,
@@ -69,12 +108,14 @@ export function makePlan(input: PlanInput): Plan {
69
108
  duration: laid.duration,
70
109
  style,
71
110
  music: input.music ?? null,
72
- captions: tpl.captions,
111
+ captions: series?.captions ?? tpl.captions,
73
112
  deliverables: tpl.deliverables,
74
113
  scenes,
75
114
  gen,
115
+ shots,
76
116
  theme,
77
117
  ...(themeCandidates ? { themeCandidates } : {}),
118
+ ...(series ? { series: series.id, ...(series.music?.mood ? { mood: series.music.mood } : {}) } : {}),
78
119
  };
79
120
  if (det.ambiguous) {
80
121
  plan.questions = [
@@ -95,7 +136,7 @@ export function makePlan(input: PlanInput): Plan {
95
136
  function genSpecs(
96
137
  tpl: Template,
97
138
  scenes: { id: string; role: string; idea: string }[],
98
- o: { subject: string; style: string; width: number; height: number; video: boolean; refs: string[] },
139
+ o: { subject: string; style: string; width: number; height: number; video: boolean; act: boolean; refs: string[] },
99
140
  ): GenSpec[] {
100
141
  const gen: GenSpec[] = [];
101
142
  for (const spec of [...tpl.assets.gen, ...(o.video ? (tpl.assets.genVideo ?? []) : [])]) {
@@ -121,5 +162,18 @@ function genSpecs(
121
162
  const refs = gen.some((g) => g.id === "character") ? { refs: ["assets/gen/character.png"] } : {};
122
163
  gen.push({ id, kind, scene: scene.id, prompt: `${o.subject} ${tpl.label} 영상 「${scene.idea}」 장면의 바탕 그림. ${o.style} 결, 글자·로고 없음.`, size: `${o.width}x${o.height}`, ...refs });
123
164
  }
165
+ const act = o.act ? slot(scenes, tpl.assets.act) : undefined;
166
+ if (act) {
167
+ // 주인공 그림이 있으면 그 얼굴로 시작한다(첫 프레임) — 그림은 영상보다 먼저 만든다(resolvePlan 순서)
168
+ const face = gen.find((g) => g.id === "character") ?? gen.find((g) => g.kind === "img" && g.scene === act.id);
169
+ gen.push({
170
+ id: `${act.id}-act`,
171
+ kind: "vid",
172
+ scene: act.id,
173
+ dur: 5,
174
+ prompt: `${o.subject} — 「${act.idea}」 장면에서 캐릭터가 실제로 움직이는 컷(몸짓·걸음·표정). 카메라는 거의 고정, ${o.style} 결, 글자 없음.`,
175
+ ...(face ? { first: `assets/gen/${face.id}.png` } : {}),
176
+ });
177
+ }
124
178
  return gen;
125
179
  }
@@ -0,0 +1,76 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { PURPOSES, type Purpose } from "./types.ts";
5
+
6
+ // 시리즈 묶음 — 같은 시리즈면 생김새·목소리·곡·자막·길이·끝 카드·생성 답이 매번 같다. 요청이 시리즈를 짚으면 묻지 않고 이 묶음을 쓴다
7
+ export interface Series {
8
+ id: string;
9
+ label: string;
10
+ summary?: string;
11
+ match: string[]; // 요청 글에 이 말이 있으면 이 시리즈(대소문자·띄어쓰기 무시)
12
+ purpose: Purpose;
13
+ theme?: string;
14
+ style?: string; // 테마 없이 프리셋으로 갈 때
15
+ voice?: string; // "<엔진>:<목소리>" · "none"
16
+ music?: { mood?: string; file?: string }; // file 은 레포 기준 곡 — 있으면 늘 그 곡, 없으면 분위기로 곡 모음에서(같은 분위기면 같은 곡)
17
+ captions?: "on" | "off" | "none";
18
+ duration?: { min?: number; max?: number; default?: number };
19
+ end?: { title?: string; line?: string }; // 끝 카드
20
+ answers?: Record<string, string>; // 생성 질문에 미리 박은 답(image·video·blender·voice·music) — 묻지 않는다
21
+ }
22
+
23
+ export const seriesDir = (): string => process.env.KF_SERIES_DIR || fileURLToPath(new URL("../../../series/", import.meta.url));
24
+
25
+ export function checkSeries(s: Partial<Series>): string[] {
26
+ const bad: string[] = [];
27
+ if (!s.id || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) bad.push("id 는 소문자·숫자·- 로");
28
+ if (!s.label) bad.push("label 이 없다");
29
+ if (!s.match?.length) bad.push("match 가 비었다 — 요청에서 짚을 말을 하나 이상");
30
+ if (!s.purpose || !PURPOSES.includes(s.purpose)) bad.push(`purpose 는 ${PURPOSES.join("·")} 중 하나`);
31
+ if (s.captions && !["on", "off", "none"].includes(s.captions)) bad.push("captions 는 on·off·none");
32
+ return bad;
33
+ }
34
+
35
+ export function listSeries(): Series[] {
36
+ const dir = seriesDir();
37
+ if (!existsSync(dir)) return [];
38
+ return readdirSync(dir)
39
+ .filter((f) => f.endsWith(".json"))
40
+ .map((f) => JSON.parse(readFileSync(join(dir, f), "utf8")) as Series)
41
+ .filter((s) => !checkSeries(s).length)
42
+ .sort((a, b) => a.id.localeCompare(b.id));
43
+ }
44
+
45
+ export function loadSeries(id: string): Series {
46
+ const file = join(seriesDir(), `${id}.json`);
47
+ if (!existsSync(file)) throw new Error(`시리즈 없음: ${id} (있는 것: ${listSeries().map((s) => s.id).join(" ") || "없음"})`);
48
+ const s = JSON.parse(readFileSync(file, "utf8")) as Series;
49
+ const bad = checkSeries(s);
50
+ if (bad.length) throw new Error(`시리즈 ${id} 가 깨졌다 — ${bad.join(" · ")}`);
51
+ return s;
52
+ }
53
+
54
+ const squash = (x: string) => x.toLowerCase().replace(/\s+/g, "");
55
+
56
+ // 가장 길게 맞은 말의 시리즈 — 「OG 쇼츠」와 「OG 쇼츠 영어판」이 같이 있으면 긴 쪽
57
+ export function seriesFromRequest(request: string): Series | null {
58
+ const r = squash(request);
59
+ let best: { s: Series; n: number } | null = null;
60
+ for (const s of listSeries()) {
61
+ for (const m of s.match) {
62
+ const k = squash(m);
63
+ if (k && r.includes(k) && (!best || k.length > best.n)) best = { s, n: k.length };
64
+ }
65
+ }
66
+ return best?.s ?? null;
67
+ }
68
+
69
+ export function writeSeries(s: Series): string {
70
+ const bad = checkSeries(s);
71
+ if (bad.length) throw new Error(bad.join(" · "));
72
+ mkdirSync(seriesDir(), { recursive: true });
73
+ const file = join(seriesDir(), `${s.id}.json`);
74
+ writeFileSync(file, `${JSON.stringify(s, null, 2)}\n`);
75
+ return file;
76
+ }
@@ -1,3 +1,5 @@
1
+ import type { Question } from "@kasaframe/gen";
2
+ import type { Bundle } from "@kasaframe/motion/menu";
1
3
  export type Aspect = "16:9" | "9:16" | "1:1";
2
4
  export type Purpose = "promo" | "shorts" | "explainer" | "launch" | "demo" | "ad" | "square" | "mv";
3
5
  export const PURPOSES: Purpose[] = ["promo", "shorts", "explainer", "launch", "demo", "ad", "square", "mv"];
@@ -26,6 +28,12 @@ export interface Template {
26
28
  variants?: Partial<Record<Aspect, { width: number; height: number; safeArea?: SafeArea }>>;
27
29
  duration: { min: number; max: number; default: number; allowed?: number[] };
28
30
  music: "required" | "optional" | "none";
31
+ // 곡 모음 태그·생성 곡 프롬프트를 고르는 분위기(@kasaframe/gen MOOD_TAGS·MOOD_PROMPT) — 같은 틀이면 같은 결의 곡
32
+ mood?: string;
33
+ // 내레이션 먼저 — 장면별 문장을 읽혀 길이를 재고 장면을 목소리에 맞춘다. voices 는 엔진마다 박은 목소리(같은 틀이면 같은 목소리)
34
+ narration?: Narration;
35
+ // 섞기 — 내레이션 아래 곡 musicDb, 말할 때 덕킹, 끝 페이드, 정규화 LUFS
36
+ audio?: AudioMix;
29
37
  // off: srt·vtt 만 · on: 화면에도 얹는다 · none: 없음 (옛 이름 track·burned 도 읽는다 — @kasaframe/motion/captions 의 captionMode)
30
38
  captions: "off" | "on" | "none";
31
39
  loop: boolean;
@@ -35,12 +43,17 @@ export interface Template {
35
43
  // 뮤비: kinetic 이면 부른 낱말이 그 시각에 화면에 뜬다(lyrics.json 낱말 시각), track 이면 자막 트랙만
36
44
  lyrics?: { mode: "kinetic" | "track" | "none"; align?: string; lipsync?: string };
37
45
  motion: Record<string, unknown>;
46
+ // 장면마다 거는 연출 수 묶음(@kasaframe/motion/menu) — kf new 가 요청 글을 씨앗으로 장면마다 고른다. roles 는 역할별(hook·drop·end …)
47
+ moves?: Bundle;
48
+ // kf render 기본값 — kf new 가 kf.json render 로 옮긴다. motionBlur 하위 프레임 수(0 이면 끔)·shutter 셔터 각도
49
+ render?: { motionBlur?: number; shutter?: number };
38
50
  scenes: TemplateScene[];
39
51
  // sections: 곡 구간(beats.json sections)마다 장면 하나 — scenes 는 곡이 없을 때 쓰는 기본, sectionIdeas 는 구간 종류별 할 일
40
52
  layout?: "fixed" | "sections";
41
53
  sectionIdeas?: Record<string, string>;
42
- // gen 은 늘 만드는 그림, genVideo 는 요청에 「영상 생성」을 분명히 적었을 때만(이음매 기본은 모션그래픽 전환)
43
- assets: { need: string[]; want: string[]; gen: string[]; genVideo?: string[] };
54
+ // gen 은 늘 만드는 그림, genVideo 는 요청에 「영상 생성」을 분명히 적었을 때만(이음매 기본은 모션그래픽 전환).
55
+ // shot3d 는 주인공 3D 장면(Blender) 자리, act 는 캐릭터가 실제로 움직여야 할 때 영상 모델 컷 자리 — 장면 id, 없으면 역할로 찾는다
56
+ assets: { need: string[]; want: string[]; gen: string[]; genVideo?: string[]; shot3d?: string; act?: string };
44
57
  deliverables: string[];
45
58
  detect: { words: string[]; hints: string[]; aspect: Aspect; assetKinds?: string[] };
46
59
  }
@@ -81,6 +94,35 @@ export interface GenSpec {
81
94
  provider?: string;
82
95
  }
83
96
 
97
+ // 주인공 3D 장면 — blender 면 shots/<id>.py 를 kf blender 로 굽고, three 면 그 장면이 stage3d 로 3D 를 짓는다(Blender 가 없을 때의 대신)
98
+ export interface ShotSpec {
99
+ id: string;
100
+ scene: string;
101
+ tool: "blender" | "three";
102
+ idea: string;
103
+ file: string; // 프로젝트 기준 샷 스크립트 — blender 일 때만 쓴다
104
+ }
105
+
106
+ export interface Narration {
107
+ on: boolean;
108
+ lang: string;
109
+ style: string;
110
+ // 이 틀의 목소리 하나 — 같은 틀이면 어느 기계에서든 이 목소리. 그 엔진이 없으면 다른 엔진으로 넘어가지 않고 묻는다
111
+ voice: string;
112
+ // 사람이 다른 엔진을 고를 때 그 엔진의 목소리
113
+ voices: Record<string, string>;
114
+ lead: number; // 장면 시작에서 말이 들어오기까지(초)
115
+ tail: number; // 말이 끝나고 장면이 끝나기까지(초)
116
+ }
117
+
118
+ export interface AudioMix {
119
+ musicDb: number;
120
+ duck: boolean;
121
+ lufs: number;
122
+ truePeak: number;
123
+ fadeOut: number;
124
+ }
125
+
84
126
  export interface Plan {
85
127
  request: string;
86
128
  purpose: Purpose;
@@ -95,6 +137,8 @@ export interface Plan {
95
137
  duration: number;
96
138
  style: string;
97
139
  music: string | null;
140
+ // 목소리 "<엔진>:<목소리>" · "none"(자막만). 정하기 전에는 없다
141
+ voice?: string;
98
142
  captions: Template["captions"];
99
143
  deliverables: string[];
100
144
  scenes: PlannedScene[];
@@ -103,4 +147,11 @@ export interface Plan {
103
147
  theme?: string | null;
104
148
  themeCandidates?: { id: string; why: string }[];
105
149
  questions?: { id: string; ask: string; choices: string[] }[];
150
+ shots?: ShotSpec[];
151
+ // 시리즈 묶음(series/<id>.json)으로 세운 계획이면 그 id, 곡 분위기는 시리즈가 틀 mood 를 덮는다
152
+ series?: string;
153
+ mood?: string;
154
+ // 만들기 전에 사람이 고를 것(@kasaframe/gen Question) — 아직 안 고른 것만. 고른 답은 answers 에 { 질문 id: 값 }
155
+ asks?: Question[];
156
+ answers?: Record<string, string>;
106
157
  }
@@ -7,10 +7,13 @@
7
7
  ".": "./src/index.ts",
8
8
  "./fx": "./src/fx.ts",
9
9
  "./factory": "./src/factory.ts",
10
- "./stage3d": "./src/stage3d.ts"
10
+ "./stage3d": "./src/stage3d.ts",
11
+ "./slots": "./src/slots.ts",
12
+ "./slotkit": "./src/slotkit.ts"
11
13
  },
12
14
  "dependencies": {
13
15
  "@kasaframe/core": "0.0.0",
14
- "three": "^0.186.1"
16
+ "three": "^0.186.1",
17
+ "@kasaframe/motion": "^0.0.0"
15
18
  }
16
19
  }
@@ -80,6 +80,15 @@ export function listThemes(filter: ThemeFilter = {}): Theme[] {
80
80
  return out;
81
81
  }
82
82
 
83
+ /** 3D 무대(rig 효과)를 쓰는 테마인가 — 묻기 보기·견본 시트의 2D/3D 표시 */
84
+ export const themeDims = (t: Pick<ThemeManifest, "effects">): "2D" | "3D" => (t.effects.some((e) => e.kind === "rig") ? "3D" : "2D");
85
+
86
+ /** kf theme sheet 가 찍는 견본 — 같은 문장 한 장면을 테마마다 <id>.png, 한 장에 모은 격자 sheet.png */
87
+ export const sheetDir = join(themesDir, "_sheet");
88
+
89
+ /** 묻기 보기에 붙일 견본 — 견본 시트의 그 테마 칸, 아직 안 찍었으면 테마 대표 장면 */
90
+ export const themeSample = (t: Theme): string | null => (existsSync(join(sheetDir, `${t.id}.png`)) ? join(sheetDir, `${t.id}.png`) : t.files.still);
91
+
83
92
  export function themeCards(filter: ThemeFilter = {}): ThemeCard[] {
84
93
  return listThemes(filter).map((t) => ({
85
94
  id: t.id,
@@ -0,0 +1,79 @@
1
+ import { clamp } from "@kasaframe/core";
2
+
3
+ // 테마 slots.ts 들이 같이 쓰는 작은 도구. 슬롯은 build 때(장면이 숨어 있어 크기 0) 불리므로 크기를 재지 않는 길만 둔다 —
4
+ // 판은 % 로, 선은 점 좌표를 감싸는 캔버스로.
5
+
6
+ export function layer(parent: HTMLElement, css: string, first = false): HTMLDivElement {
7
+ const d = parent.ownerDocument.createElement("div");
8
+ d.style.cssText = `position:absolute;pointer-events:none;${css}`;
9
+ if (first) parent.prepend(d);
10
+ else parent.append(d);
11
+ return d;
12
+ }
13
+
14
+ /** 글자마다 span — 배치를 바꾸지 않게 inline 그대로. 빈칸은 글자로 남긴다 */
15
+ export function letters(el: HTMLElement, text: string): HTMLSpanElement[] {
16
+ el.textContent = "";
17
+ return Array.from(text).map((ch) => {
18
+ const s = el.ownerDocument.createElement("span");
19
+ s.textContent = ch;
20
+ el.append(s);
21
+ return s;
22
+ });
23
+ }
24
+
25
+ /** at 부터 dur 동안 앞에서부터 몇 글자가 찍혔나 — at+dur 부터는 전부 */
26
+ export const shownCount = (n: number, t: number, at: number, dur: number): number =>
27
+ t >= at + dur ? n : t < at ? 0 : Math.min(n, Math.floor(clamp((t - at) / Math.max(1e-3, dur)) * n) + 1);
28
+
29
+ export type Pt = readonly [number, number];
30
+
31
+ /** 꺾은선 길이 비율 p 까지의 점들 — 마지막 점은 선분 안에서 끊는다 */
32
+ export function partial(points: readonly Pt[], p: number): [number, number][] {
33
+ if (points.length < 2) return points.map((q) => [q[0], q[1]]);
34
+ const seg = points.slice(1).map((q, i) => Math.hypot(q[0] - points[i][0], q[1] - points[i][1]));
35
+ const total = seg.reduce((a, b) => a + b, 0) || 1;
36
+ let left = clamp(p) * total;
37
+ const out: [number, number][] = [[points[0][0], points[0][1]]];
38
+ for (let i = 0; i < seg.length; i++) {
39
+ if (left >= seg[i]) {
40
+ out.push([points[i + 1][0], points[i + 1][1]]);
41
+ left -= seg[i];
42
+ continue;
43
+ }
44
+ const k = seg[i] ? left / seg[i] : 0;
45
+ out.push([points[i][0] + (points[i + 1][0] - points[i][0]) * k, points[i][1] + (points[i + 1][1] - points[i][1]) * k]);
46
+ break;
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** 점들을 감싸는 캔버스(여백 pad) — 그리기는 층 좌표 그대로 */
52
+ export function traceCanvas(el: HTMLElement, points: readonly Pt[], pad = 40): { g: CanvasRenderingContext2D; clear(): void } {
53
+ const xs = points.map((q) => q[0]);
54
+ const ys = points.map((q) => q[1]);
55
+ const x = Math.floor(Math.min(...xs) - pad);
56
+ const y = Math.floor(Math.min(...ys) - pad);
57
+ const w = Math.ceil(Math.max(...xs) + pad) - x;
58
+ const h = Math.ceil(Math.max(...ys) + pad) - y;
59
+ const c = el.ownerDocument.createElement("canvas");
60
+ c.width = Math.max(1, w);
61
+ c.height = Math.max(1, h);
62
+ c.style.cssText = `position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px;pointer-events:none`;
63
+ el.append(c);
64
+ const g = c.getContext("2d")!;
65
+ g.translate(-x, -y);
66
+ return { g, clear: () => g.clearRect(x, y, w, h) };
67
+ }
68
+
69
+ export function strokePath(g: CanvasRenderingContext2D, pts: readonly Pt[]): void {
70
+ if (pts.length < 2) return;
71
+ g.beginPath();
72
+ g.moveTo(pts[0][0], pts[0][1]);
73
+ for (const q of pts.slice(1)) g.lineTo(q[0], q[1]);
74
+ g.stroke();
75
+ }
76
+
77
+ /** CSS 변수 색 — 슬롯은 테마 토큰만 쓴다 */
78
+ export const cssVar = (el: HTMLElement, name: string, fallback: string): string =>
79
+ getComputedStyle(el).getPropertyValue(`--kf-${name}`).trim() || fallback;
@@ -0,0 +1,46 @@
1
+ /// <reference types="vite/client" />
2
+ // 페이지(Vite) 쪽. 장면 문법 패턴은 테마 이름을 모른다 — 일반 자리 여섯을 부르고, 테마가 themes/<id>/slots.ts 에서 자기 효과로 채운다.
3
+ // effects.ts 와 따로 두는 까닭: effects.ts 내보내기는 theme.json 효과 목록과 같아야 한다(테마 시험).
4
+ import type { SceneCtx, Style } from "@kasaframe/core";
5
+ import type { DropBurstState } from "@kasaframe/motion";
6
+
7
+ export type Paint = (t: number) => void;
8
+
9
+ /**
10
+ * 모든 슬롯은 같은 t 면 같은 화면이고, 난수는 seed 로만. 비운 슬롯은 패턴이 제 기본으로 그린다.
11
+ * 화면 전체를 덮는 3D(three)는 surface 에서만 — 크롬이 WebGL 문맥을 몇 개만 살려 두므로 나머지 슬롯은 DOM·캔버스 2D 로.
12
+ */
13
+ export interface ThemeSlots {
14
+ /** 화면 전체 바탕 질감. el = 장면 맨 뒤 inset:0 층. 장면마다 한 번 짓는다. 글을 두지 않는다 */
15
+ surface?(el: HTMLElement, o: { ctx: SceneCtx; seed: string }): Paint;
16
+ /**
17
+ * 카드·상자 판. el = 패턴이 자리·크기를 잡은 상자(position:absolute). 판은 el 안 맨 앞 자식으로 깔고 내용은 패턴이 그 위에 둔다.
18
+ * at 에 선다(그 전엔 안 보인다). 상자 크기·자리는 바꾸지 않는다 — 배치는 패턴 몫. tone 은 accent 면 강조 판
19
+ */
20
+ panel?(el: HTMLElement, o: { ctx: SceneCtx; at: number; seed: string; tone?: "base" | "accent" }): Paint;
21
+ /**
22
+ * 앉는 순간 한 번 — 숫자·이름·칸이 at 에 앉을 때. el 둘레에 곁 그림(빛·그림자·도장)을 지어도 되고, 돌려준 Style 은 패턴이 el 에 combine 한다.
23
+ * 섬광·흔들림은 motion 부품(dropBurst·shake)을 거쳐 __kf.events 에 남게. at 에서 0.6초 안에 끝나 선다
24
+ */
25
+ land?(el: HTMLElement, o: { ctx: SceneCtx; at: number; seed: string }): (t: number) => Style;
26
+ /**
27
+ * 글 찍기 — el(패턴이 글꼴 크기·자리를 잡은 글 상자)에 text 를 at 부터 찍는다. at + dur 부터는 el.textContent 가 text 그대로.
28
+ * 글꼴 크기·줄 수는 바꾸지 않는다(배치가 흔들린다) — 색·그림자·글자 굴림만
29
+ */
30
+ write?(el: HTMLElement, o: { ctx: SceneCtx; text: string; at: number; dur: number; seed: string }): Paint;
31
+ /** 선 긋기 — el = inset:0 층, points 는 그 층 px. at 부터 dur 동안 그어지고, 다 그은 뒤에도 선 안이 흐른다 */
32
+ trace?(el: HTMLElement, o: { ctx: SceneCtx; points: readonly (readonly [number, number])[]; at: number; dur: number; seed: string; tone?: "base" | "accent" }): Paint;
33
+ /** 드롭 터짐 — 그 테마의 드롭 연출. 돌려준 shake 는 패턴이 화면 틀에, punch 는 주인공에 준다. 드롭 번호로만 */
34
+ drop?(el: HTMLElement, o: { ctx: SceneCtx; drop: number }): (t: number) => DropBurstState;
35
+ }
36
+
37
+ export const SLOT_NAMES = ["surface", "panel", "land", "write", "trace", "drop"] as const satisfies readonly (keyof ThemeSlots)[];
38
+
39
+ const mods = import.meta.glob("../../../themes/*/slots.ts", { eager: true }) as Record<string, { slots?: ThemeSlots }>;
40
+
41
+ const byId = new Map(Object.entries(mods).map(([path, mod]) => [path.split("/").at(-2)!, mod.slots ?? {}]));
42
+
43
+ /** 테마 슬롯 — 없는 테마(npm 묶음에 안 든 테마 포함)나 슬롯을 안 채운 테마는 빈 객체 */
44
+ export const themeSlots = (id: string | undefined | null): ThemeSlots => (id ? byId.get(id) ?? {} : {});
45
+
46
+ export const slotThemeIds = (): string[] => [...byId.keys()].sort();
@@ -100,6 +100,26 @@ export function stage3d(root: HTMLElement, o: StageOpts): Stage3D {
100
100
  };
101
101
  }
102
102
 
103
+ /**
104
+ * 서 있는 무대 — 처음 한 번만 그려 2D 캔버스로 옮겨 두고 그 뒤로는 다시 그리지 않는다.
105
+ * 움직이지 않는 3D 바탕(테마 슬롯 surface)을 프레임마다 SwiftShader 로 그릴 까닭이 없고, 렌더러 캔버스는 다른 무대가 가져가도 된다
106
+ */
107
+ export function stillStage(stage: Stage3D, paint: (t: number) => void = (t) => stage.paint(t)): (t: number) => void {
108
+ let done = false;
109
+ return () => {
110
+ if (done) return;
111
+ paint(0);
112
+ const src = stage.renderer.domElement;
113
+ const c = document.createElement("canvas");
114
+ c.width = src.width;
115
+ c.height = src.height;
116
+ c.style.cssText = src.style.cssText;
117
+ c.getContext("2d")!.drawImage(src, 0, 0);
118
+ src.replaceWith(c);
119
+ done = true;
120
+ };
121
+ }
122
+
103
123
  export interface TextPlaneOpts {
104
124
  text: string;
105
125
  /** CSS font 단축 꼴의 앞부분 없이 글꼴 스택만 — 굵기·크기는 따로 */
@@ -0,0 +1,14 @@
1
+ {
2
+ "id": "og-shorts",
3
+ "label": "OG 쇼츠",
4
+ "summary": "OpenGateway 기술 쇼츠 — 측정 테마·차분한 남성 내레이션·깊은 앰비언트 곡·화면 자막, 45–60초, 끝 카드 OpenGateway",
5
+ "match": ["OG 쇼츠", "OG 숏츠", "OG 숏폼", "OG shorts", "오픈게이트웨이 쇼츠", "OpenGateway 쇼츠", "OpenGateway shorts"],
6
+ "purpose": "shorts",
7
+ "theme": "og-measure",
8
+ "voice": "gemini:Charon",
9
+ "music": { "mood": "deep" },
10
+ "captions": "on",
11
+ "duration": { "min": 45, "max": 60, "default": 45 },
12
+ "end": { "title": "OpenGateway", "line": "opengateway.ai" },
13
+ "answers": { "image": "og-gpt-image-2.5-sunburst", "video": "none" }
14
+ }