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,67 @@
1
+ import type { SceneCtx, Style } from "@kasaframe/core";
2
+ import type { ThemeSlots } from "@kasaframe/themes/slots";
3
+
4
+ // 감독 packages/director/src/pattern.ts 와 같은 꼴(구조로 맞춘다). 감독이 패키지로 서면 그쪽 꼴을 import type 으로 받는다 — slots 만 테마 슬롯 꼴로 좁힌다.
5
+
6
+ export type Energy = "low" | "mid" | "high";
7
+ export type Family = "slam" | "flap" | "grow" | "draw" | "type" | "pop" | "mask" | "flow" | "stack" | "morph";
8
+ export type CameraKind = "none" | "pushIn" | "pullOut" | "follow" | "impact";
9
+ export type AspectName = "16:9" | "9:16" | "1:1";
10
+ export type TextPolicy = "none" | "terms-only" | "captions" | "lyrics";
11
+
12
+ export interface FitInfo {
13
+ aspect: AspectName;
14
+ dur: number;
15
+ policy: TextPolicy;
16
+ }
17
+
18
+ export interface StageLike {
19
+ box: HTMLDivElement;
20
+ cam: HTMLDivElement;
21
+ safeW: number;
22
+ safeH: number;
23
+ vertical: boolean;
24
+ camera: {
25
+ pushIn(at: number, o?: { to?: unknown; scale?: number; dur?: number }): unknown;
26
+ pullOut(at: number, o?: { to?: unknown; scale?: number; dur?: number }): unknown;
27
+ follow(target: unknown, from: number, to: number, o?: { scale?: number }): unknown;
28
+ impact(at: number, o?: { amount?: number; dur?: number }): unknown;
29
+ };
30
+ hero(el: HTMLElement): HTMLElement;
31
+ }
32
+
33
+ export interface SpokenWord {
34
+ text: string;
35
+ from: number;
36
+ to: number;
37
+ }
38
+
39
+ export interface Kit {
40
+ seed: string;
41
+ hits: number[];
42
+ say: SpokenWord[];
43
+ slots: ThemeSlots;
44
+ stage: StageLike;
45
+ policy: TextPolicy;
46
+ first: boolean;
47
+ last: boolean;
48
+ loop: boolean;
49
+ }
50
+
51
+ export interface Pattern<C = any> {
52
+ id: string;
53
+ type: string;
54
+ does: string;
55
+ energy: Energy[];
56
+ family: Family;
57
+ camera: CameraKind[];
58
+ bg: "stage" | "own";
59
+ aspects: AspectName[];
60
+ dur: { min: number; max: number };
61
+ themes?: Record<string, number>;
62
+ purposes?: { prefer?: string[]; avoid?: string[] };
63
+ fits?(content: C, info: FitInfo): true | string;
64
+ build(root: HTMLElement, ctx: SceneCtx, content: C, kit: Kit): (t: number) => void;
65
+ }
66
+
67
+ export type { SceneCtx, Style, ThemeSlots };
@@ -0,0 +1,42 @@
1
+ import { dressText } from "@kasaframe/motion/moves";
2
+ import { area, moving, node } from "../kit.ts";
3
+ import type { Energy, Family, Pattern } from "../types.ts";
4
+
5
+ export interface WordsContent {
6
+ words: string[];
7
+ }
8
+
9
+ // 낱말 리듬은 연출 수 메뉴(@kasaframe/motion/moves dress)의 글 수가 이미 박에 맞춰 검사를 넘긴 값으로 그린다 — 패턴은 고를 수 있는 이름과
10
+ // 고르는 표(에너지·계열)만 붙인다. 수마다 낱말 수·길이 한계가 다르다. ghostStack·splitFlap 은 세로 밝은 테마에서 멈춤 검사를 못 넘어 아직 안 건다
11
+ function wordsPattern(o: { name: string; move: string; does: string; energy: Energy[]; family: Family; max: number; maxLen: number }): Pattern<WordsContent> {
12
+ return {
13
+ id: `words.${o.name}`,
14
+ type: "words",
15
+ does: o.does,
16
+ energy: o.energy,
17
+ family: o.family,
18
+ camera: ["none"],
19
+ bg: "stage",
20
+ aspects: ["16:9", "9:16", "1:1"],
21
+ dur: { min: 1.2, max: 9 },
22
+ fits(c) {
23
+ const ws = (c?.words ?? []).filter((x) => typeof x === "string" && x.trim());
24
+ if (!ws.length) return "words 가 비었다";
25
+ if (ws.length > o.max) return `낱말이 ${o.max}개를 넘는다`;
26
+ if (ws.some((x) => [...x].length > o.maxLen)) return `낱말 하나가 ${o.maxLen}자를 넘는다`;
27
+ return true;
28
+ },
29
+ build(root, ctx, c, kit) {
30
+ const { w, h } = area(ctx, kit);
31
+ const host = node(root, "position:absolute;inset:0");
32
+ const paint = dressText(o.move, host, ctx, { words: c.words, w, h, ready: kit.first }) ?? (() => {});
33
+ kit.stage?.hero(host);
34
+ return (t: number) => {
35
+ moving(`words.${o.name}`, ctx);
36
+ paint(t);
37
+ };
38
+ },
39
+ };
40
+ }
41
+
42
+ export const wordsSlam = wordsPattern({ name: "slam", move: "slamWords", does: "박마다 낱말 하나가 화면 띠를 꽉 채우며 쾅 — 판과 글색이 뒤집힌다", energy: ["high"], family: "slam", max: 6, maxLen: 10 });
@@ -0,0 +1,4 @@
1
+ import { wordsSlam } from "./moves.ts";
2
+
3
+ // 장면 파일이 @kasaframe/patterns/words/slam 로 가져간다
4
+ export default wordsSlam;
@@ -0,0 +1,256 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { byMood, estimateImageUsd, howToGet, listLibrary, musicQuestion, ttsProviders, videoProviders, voiceQuestion, WAIT, type CapabilityReport, type Choice, type Question, type VideoRequest } from "@kasaframe/gen";
5
+ import { listThemes, sheetDir, themeDims, themeSample } from "@kasaframe/themes";
6
+ import { loadSeries } from "./series.ts";
7
+ import { loadTemplate } from "./templates.ts";
8
+ import { suggestThemes, themeFromRequest } from "./themes.ts";
9
+ import type { Plan } from "./types.ts";
10
+
11
+ // 계획이 쓰려는 능력을 지금 있는 능력에 맞춘다 — 돈이 들거나 없거나 제한이 걸린 것만 묻고, 나머지는 바로 정한다(auto)
12
+ export interface PlanQuestions {
13
+ questions: Question[];
14
+ auto: Record<string, string>; // 물을 것 없이 정해진 답(공짜로 있는 능력)
15
+ }
16
+
17
+ export { WAIT };
18
+
19
+ // 없는 능력 — 조용히 대체하지 않는다. ① 넣는 법(권장) ② 지금 쓸 수 있는 다른 엔진 ③ 빼기를 나란히 두고 사람이 고른다
20
+ function missing(q: Omit<Question, "default" | "skip" | "missing" | "choices">, install: Choice, others: Choice[], drop: Choice): Question {
21
+ return { ...q, choices: [install, ...others, drop], default: WAIT, skip: drop.value, missing: true };
22
+ }
23
+
24
+ const money = (n: number) => (n < 0.1 ? `$${n.toFixed(3)}` : `$${n.toFixed(2)}`);
25
+ const ids = (xs: { id: string }[]) => xs.map((x) => x.id).join("·");
26
+
27
+ function imageQuestion(plan: Plan, caps: CapabilityReport): { q?: Question; auto?: string } {
28
+ const imgs = plan.gen.filter((g) => g.kind === "img");
29
+ if (!imgs.length) return {};
30
+ const ready = caps.providers.filter((p) => p.ready);
31
+ const og = ready.find((p) => p.id.startsWith("og-"));
32
+ const codex = ready.find((p) => p.id === "codex-imagegen");
33
+ const none: Choice = { value: "none", label: "그림 없이", note: "받은 자산·도형·실제 화면 오리기로 장면을 채운다" };
34
+ if (og) {
35
+ const usd = imgs.reduce((n, g) => n + estimateImageUsd({ prompt: "", size: g.size ?? `${plan.width}x${plan.height}`, quality: g.quality ?? "high", refs: g.refs }), 0);
36
+ return {
37
+ q: {
38
+ id: "image",
39
+ cap: "image",
40
+ header: "그림 생성",
41
+ ask: `그림 ${imgs.length}장(${ids(imgs)})을 OG gpt-image 로 만들까? 견적 최대 ${money(usd)}`,
42
+ choices: [
43
+ { value: og.id, label: `OG ${og.id.replace(/^og-/, "")}`, costUsd: usd, note: "장당 20–30초" },
44
+ ...(codex ? [{ value: codex.id, label: "codex imagegen", costUsd: 0, note: "ChatGPT 요금제 안 — 장당 1–2분" }] : []),
45
+ none,
46
+ ],
47
+ default: og.id,
48
+ skip: "none",
49
+ flag: "--image",
50
+ },
51
+ };
52
+ }
53
+ // 기본 그림 모델(OG)이 없다 — codex 가 있어도 조용히 넘어가지 않고 다른 엔진 보기로 둔다
54
+ return {
55
+ q: missing(
56
+ { id: "image", cap: "image", header: "그림 모델", ask: `그림 ${imgs.length}장(${ids(imgs)})을 만들 기본 그림 모델(OG gpt-image)이 없다`, flag: "--image" },
57
+ { value: WAIT, label: "키를 넣고 다시", note: `${howToGet("opengateway")}${codex ? "" : " · 또는 0원 codex: npm i -g @openai/codex && codex login(ChatGPT 계정)"}` },
58
+ codex ? [{ value: codex.id, label: "codex imagegen 으로", costUsd: 0, note: "ChatGPT 로그인 — 0원, 장당 1–2분" }] : [],
59
+ none,
60
+ ),
61
+ };
62
+ }
63
+
64
+ function videoQuestion(plan: Plan, caps: CapabilityReport): { q?: Question; auto?: string } {
65
+ const vids = plan.gen.filter((g) => g.kind === "vid");
66
+ if (!vids.length) return {};
67
+ const scene = (id?: string) => plan.scenes.find((s) => s.id === id);
68
+ const reqs: VideoRequest[] = vids.map((g) => ({
69
+ prompt: g.prompt,
70
+ duration: g.seam ? (scene(g.scene)?.dur ?? 5) : (g.dur ?? 5),
71
+ aspect: plan.aspect,
72
+ ...(g.seam || g.first ? { firstFrame: "x" } : {}),
73
+ ...(g.seam || g.last ? { lastFrame: "y" } : {}),
74
+ }));
75
+ const ready = new Set(caps.providers.filter((p) => p.ready).map((p) => p.id));
76
+ const restrict = new Map(caps.providers.map((p) => [p.id, p.restrict]));
77
+ const options = videoProviders()
78
+ .filter((p) => ready.has(p.id))
79
+ .filter((p) => reqs.every((r) => !r.lastFrame || p.firstLast))
80
+ .map((p) => ({ p, usd: reqs.reduce((n, r) => n + p.estimateUsd(r), 0), restrict: restrict.get(p.id) }))
81
+ .sort((a, b) => a.usd - b.usd);
82
+ const open = options.filter((o) => !o.restrict);
83
+ const limited = options.filter((o) => o.restrict);
84
+ const what = `영상 모델 컷 ${vids.length}개(${vids.map((g) => `${g.id}${g.seam ? " 이음매" : ""}`).join("·")})`;
85
+ const none: Choice = { value: "none", label: "영상 모델 없이", note: "이음매는 three 3D 전환, 움직임 컷은 그림·모션그래픽으로" };
86
+ if (!options.length) {
87
+ return {
88
+ q: missing(
89
+ { id: "video", cap: "video", header: "영상 모델", ask: `${what}를 만들 영상 모델이 없다`, flag: "--video" },
90
+ { value: WAIT, label: "키를 넣고 다시", note: `${howToGet("fal")} · 또는 ${howToGet("gemini")} · 사내 ${howToGet("h3")}` },
91
+ [],
92
+ none,
93
+ ),
94
+ };
95
+ }
96
+ // 돈도 제한도 없는 길이 있으면 묻지 않는다(아직 그런 공급자는 없다 — H3 는 0원이지만 지역 제한이 걸린다)
97
+ if (open[0] && open[0].usd === 0) return { auto: open[0].p.id };
98
+ const choices: Choice[] = [
99
+ ...open.slice(0, 2).map((o) => ({ value: o.p.id, label: o.p.id, costUsd: o.usd })),
100
+ ...limited.slice(0, 1).map((o) => ({ value: o.p.id, label: `${o.p.id}(사내 파드)`, costUsd: o.usd, note: o.restrict })),
101
+ none,
102
+ ];
103
+ return {
104
+ q: {
105
+ id: "video",
106
+ cap: "video",
107
+ header: "영상 모델",
108
+ ask: open.length ? `${what} — ${open[0].p.id} 로 만들까? 견적 ${money(open[0].usd)}` : `${what} — 쓸 수 있는 건 지역 제한이 걸린 것뿐이다`,
109
+ choices: choices.slice(0, 4),
110
+ // 제한 걸린 길은 사람이 짚어야만 — --yes 로 회사·바깥 결과물에 섞이지 않게
111
+ default: open[0]?.p.id ?? "none",
112
+ skip: "none",
113
+ flag: "--video",
114
+ },
115
+ };
116
+ }
117
+
118
+ function blenderQuestion(plan: Plan, caps: CapabilityReport): { q?: Question; auto?: string } {
119
+ if (!plan.shots?.length) return {};
120
+ const b = caps.providers.find((p) => p.id === "blender");
121
+ if (b?.ready) return { auto: "blender" };
122
+ return {
123
+ q: missing(
124
+ { id: "blender", cap: "blender", header: "3D 샷", ask: `주인공 3D 장면(${plan.shots.map((s) => s.scene).join("·")})을 구울 Blender 가 없다 — ${b?.detail ?? "못 찾음"}`, flag: "--blender" },
125
+ { value: WAIT, label: "Blender 를 깔고 다시", note: `${b?.fix ?? "blender.org 에서 4.3 이상"} — 확인은 kf doctor 「생성 능력」` },
126
+ [{ value: "three", label: "three.js 로", note: "장면 안에서 stage3d 로 3D 를 짓는다(툰 셰이딩·라인아트는 없다)" }],
127
+ { value: "none", label: "3D 없이", note: "2D 모션그래픽 장면으로" },
128
+ ),
129
+ };
130
+ }
131
+
132
+ // 목소리 — 틀이 내레이션을 켰을 때만. 시리즈가 박은 목소리는 사람이 미리 준 답이라 그 엔진이 있으면 묻지 않는다.
133
+ // 그 밖엔 늘 묻는다(목소리는 결이라 돈이 안 들어도) — 보기 차례는 voiceQuestion(로컬 Qwen3·ElevenLabs·자막만·그 밖)
134
+ function voicePart(plan: Plan, caps: CapabilityReport): { q?: Question; auto?: string } {
135
+ const n = loadTemplate(plan.purpose).narration;
136
+ if (!n?.on || plan.voice) return {};
137
+ const ready = caps.providers.filter((p) => p.ready).map((p) => p.id);
138
+ const want = plan.series ? loadSeries(plan.series).voice : undefined;
139
+ if (!want) return n.voice === "none" ? { auto: "none" } : { q: voiceQuestion({ voices: n.voices, ready }) };
140
+ if (want === "none") return { auto: "none" };
141
+ const engine = want.split(":")[0];
142
+ if (ready.includes(engine)) return { auto: want };
143
+ const p = ttsProviders().find((x) => x.id === engine);
144
+ const others = ttsProviders().filter((x) => ready.includes(x.id)).slice(0, 2);
145
+ return {
146
+ q: missing(
147
+ { id: "voice", cap: "voice", header: "목소리", ask: `시리즈 목소리 ${want}(${p?.label ?? engine})을 이 기계에서 못 쓴다`, flag: "--voice", accepts: (v: string) => v === "none" || ready.includes(v.split(":")[0]) },
148
+ { value: WAIT, label: `${p?.label ?? engine} 키를 넣고 다시`, note: howToGet(p?.keyName ?? engine) },
149
+ others.map((x) => {
150
+ const v = n.voices[x.id] ?? x.defaultVoice;
151
+ return { value: `${x.id}:${v}`, label: `${x.label} ${v} 로`, costUsd: x.estimateUsd("가".repeat(300)), note: `시리즈 목소리(${want})와 다르다 — 같은 시리즈의 다른 영상과 목소리가 달라진다` };
152
+ }),
153
+ { value: "none", label: "목소리 없이 — 자막만", costUsd: 0 },
154
+ ),
155
+ };
156
+ }
157
+
158
+ // 테마 — 시리즈가 정했거나 요청이 「리소 느낌」처럼 짚었으면 묻지 않는다(kf new 의 auto 가 그대로 짚은 테마를 쓴다).
159
+ // 보기는 「알아서」(= auto, 권장)와 이 용도·화면비의 approved 테마 전부(요청에 맞는 차례) — theme.json status 만 approved 로 바꾸면 들어온다
160
+ function themePart(plan: Plan): { q?: Question; auto?: string } {
161
+ if ((plan.series && loadSeries(plan.series).theme) || themeFromRequest(plan.request, plan.purpose)) return {};
162
+ const ranked = suggestThemes(plan.request, plan.purpose, plan.aspect, Infinity);
163
+ if (!ranked.length) return {};
164
+ const byId = new Map(listThemes({ status: "approved" }).map((t) => [t.id, t]));
165
+ const grid = join(sheetDir, "sheet.png");
166
+ const first = byId.get(ranked[0].id)!;
167
+ return {
168
+ q: {
169
+ id: "theme",
170
+ cap: "theme",
171
+ header: "테마",
172
+ ask: `테마는? ${existsSync(grid) ? `같은 문장 한 장면을 테마마다 찍은 견본 시트 ${grid}` : "보기마다 견본 그림 경로"}`,
173
+ choices: [
174
+ { value: "auto", label: "알아서", note: `요청 낱말에 맞는 approved 테마를 고른다 — 지금이면 ${first.name}` },
175
+ ...ranked.map(({ id }) => {
176
+ const t = byId.get(id)!;
177
+ const sample = themeSample(t);
178
+ return { value: t.id, label: `${t.name} · ${themeDims(t)}`, note: `${t.metaphor}${sample ? ` — 견본 ${sample}` : ""}` };
179
+ }),
180
+ ],
181
+ default: "auto",
182
+ skip: "auto",
183
+ flag: "--theme",
184
+ // 보기 밖이라도 사람이 짚은 review 테마·none(프리셋만)은 받는다 — kf new --theme 과 같은 범위
185
+ accepts: (v: string) => v === "none" || listThemes({ status: ["approved", "review"] }).some((t) => t.id === v),
186
+ },
187
+ };
188
+ }
189
+
190
+ // 곡 — 틀이 분위기를 정한 용도만. 시리즈 곡·분위기 맞는 곡 모음 곡(0원·늘 같은 곡)은 묻지 않는다.
191
+ // 없으면 생성(돈)·곡 넣기·없음을 묻고, 생성 키도 없으면 다른 분위기 곡·「곡 없이」로 조용히 넘어가지 않고 missing 으로 묻는다
192
+ function musicPart(plan: Plan, caps: CapabilityReport): { q?: Question; auto?: string } {
193
+ // 시리즈가 정한 분위기가 틀 분위기를 덮는다 — 같은 시리즈면 같은 곡
194
+ const mood = plan.mood ?? loadTemplate(plan.purpose).mood;
195
+ if (!mood || plan.music) return {};
196
+ const file = plan.series ? loadSeries(plan.series).music?.file : undefined;
197
+ if (file) return { auto: fileURLToPath(new URL(`../../../${file}`, import.meta.url)) };
198
+ const lib = byMood(mood);
199
+ if (lib.length) return { auto: lib[0].path };
200
+ const ready = caps.providers.filter((p) => p.ready && p.id !== "library").map((p) => p.id);
201
+ const put: Choice = { value: WAIT, label: "곡을 넣고 다시", note: `kf music add <곡> --mood ${mood} --source <원본> --license "<라이선스>" 로 곡 모음에 넣거나 --music <곡 파일>` };
202
+ const q = musicQuestion({ mood, duration: plan.duration, ready });
203
+ if (ready.length) return { q: { ...q, choices: [q.choices[0], put, ...q.choices.slice(1)].slice(0, 4) } };
204
+ const { default: _d, skip: _s, choices: _c, ...head } = q;
205
+ return {
206
+ q: missing(
207
+ { ...head, ask: `분위기 ${mood} 에 맞는 곡이 곡 모음에 없고 음악 생성 키도 없다` },
208
+ { ...put, label: "곡이나 생성 키를 넣고 다시", note: `${put.note} · 생성은 ${howToGet("gemini")}(Lyria 3.5, 유료 등급) 또는 ${howToGet("elevenlabs")}(Eleven Music)` },
209
+ listLibrary().filter((t) => t.license && t.mood.length).slice(0, 1).map((t) => ({ value: t.path, label: `곡 모음 「${t.title}」로`, costUsd: 0, note: `틀 분위기(${mood})와 다른 곡(${t.mood.join("·")}) — ${t.license}` })),
210
+ { value: "none", label: "곡 없이", costUsd: 0 },
211
+ ),
212
+ };
213
+ }
214
+
215
+ export function planQuestions(plan: Plan, caps: CapabilityReport[]): PlanQuestions {
216
+ const by = (c: string) => caps.find((x) => x.cap === c) ?? { cap: c, label: c, providers: [], best: null };
217
+ const parts: [string, { q?: Question; auto?: string }][] = [
218
+ ["theme", themePart(plan)],
219
+ ["image", imageQuestion(plan, by("image") as CapabilityReport)],
220
+ ["video", videoQuestion(plan, by("video") as CapabilityReport)],
221
+ ["blender", blenderQuestion(plan, by("blender") as CapabilityReport)],
222
+ ["voice", voicePart(plan, by("voice") as CapabilityReport)],
223
+ ["music", musicPart(plan, by("music") as CapabilityReport)],
224
+ ];
225
+ const out: PlanQuestions = { questions: [], auto: {} };
226
+ for (const [id, r] of parts) {
227
+ if (r.q) out.questions.push(r.q);
228
+ else if (r.auto) out.auto[id] = r.auto;
229
+ }
230
+ return out;
231
+ }
232
+
233
+ // 답대로 계획을 고친다 — 빼기로 한 능력의 만들 거리를 지우고, 고른 공급자를 적는다. wait 를 고른 질문은 stop 으로 돌려준다
234
+ export function applyAnswers(plan: Plan, answers: Record<string, string>, questions: Question[] = []): { plan: Plan; stop: string[] } {
235
+ const stop = questions.filter((q) => answers[q.id] === WAIT).map((q) => `${q.ask} — ${q.choices.find((c) => c.value === WAIT)?.note ?? ""}`);
236
+ const p: Plan = { ...plan, answers: { ...(plan.answers ?? {}), ...answers } };
237
+ const drop = (kind: "img" | "vid") => {
238
+ const gone = new Set(p.gen.filter((g) => g.kind === kind).map((g) => g.id));
239
+ p.gen = p.gen.filter((g) => g.kind !== kind);
240
+ p.scenes = p.scenes.map((s) => ({ ...s, assets: s.assets.filter((a) => !gone.has(a.replace(/^gen:(img|vid):/, ""))) }));
241
+ // 지운 그림을 첫 프레임으로 기다리던 영상은 글만으로 간다
242
+ if (kind === "img") p.gen = p.gen.map((g) => (g.first && gone.has(g.first.replace(/^assets\/gen\/|\.png$/g, "")) ? { ...g, first: undefined } : g));
243
+ };
244
+ const pick = (kind: "img" | "vid", v?: string) => {
245
+ if (!v || v === WAIT) return;
246
+ if (v === "none") drop(kind);
247
+ else p.gen = p.gen.map((g) => (g.kind === kind ? { ...g, provider: v } : g));
248
+ };
249
+ pick("img", answers.image);
250
+ pick("vid", answers.video);
251
+ // 목소리·곡은 kf narrate 가 읽는 칸으로 — 곡 파일 경로는 kf new 가 곡으로 깔고, gen:·none 은 narrate 가 처리한다
252
+ if (answers.voice && answers.voice !== WAIT) p.voice = answers.voice;
253
+ if (answers.blender === "none") p.shots = [];
254
+ else if (answers.blender === "three") p.shots = (p.shots ?? []).map((s) => ({ ...s, tool: "three" }));
255
+ return { plan: p, stop };
256
+ }
@@ -1,6 +1,9 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { captionMode } from "@kasaframe/motion/captions";
3
+ import { bundleFor, MENU, type Dress } from "@kasaframe/motion/menu";
4
+ import { questionsMarkdown, type Question } from "@kasaframe/gen";
3
5
  import { loadTheme } from "@kasaframe/themes";
6
+ import type { GenSpec, ShotSpec } from "./types.ts";
4
7
  import { join } from "node:path";
5
8
  import { fileURLToPath } from "node:url";
6
9
 
@@ -14,6 +17,8 @@ export interface ProjectScene {
14
17
  role: string;
15
18
  idea: string;
16
19
  assets?: string[];
20
+ // kf new 가 고른 연출 수 — 뼈대 장면 파일의 DRESS 와 같다
21
+ dress?: Dress;
17
22
  }
18
23
 
19
24
  export interface ProjectFile {
@@ -27,6 +32,10 @@ export interface ProjectFile {
27
32
  music: string | null;
28
33
  beats: string | null;
29
34
  captions: string;
35
+ render?: { motionBlur?: number; shutter?: number };
36
+ // kf narrate 가 적는다 — voice 가 있으면 내레이션 단계를 지났다
37
+ voice?: string;
38
+ narration?: string | null;
30
39
  safeArea: { top: number; bottom: number; left: number; right: number };
31
40
  text: { policy: string; maxOnScreen: number; minPx: number };
32
41
  scenes: ProjectScene[];
@@ -123,6 +132,8 @@ export function projectBrief(dir: string): string {
123
132
  const lines = [
124
133
  `# ${kf.title || plan.request || "kasaframe 프로젝트"} — 장면 작성 지시서`,
125
134
  "",
135
+ ...questionsMarkdown(plan.asks ?? [], (flags) => `npx kf make --answer ${dir} ${flags}`),
136
+ ...narrationSection(dir, kf, plan),
126
137
  `**이 영상의 규칙 — 용도 ${kf.template}(틀이 기본값을 덮는다, kf check 가 같은 값으로 잡는다)**`,
127
138
  `1. 화면은 ${rules.maxFreezeSec}초(60fps 6프레임) 넘게 멈추지 않는다 — 끝 카드·큰 숫자·로고도 예외 없이 화면 안 움직임(숫자 굴러가기·칸 채우기·반짝임·글자 흐름·바탕 흐름)으로 계속 살아 있게.`,
128
139
  `2. 같은 구도는 ${rules.staticSec.warn}초 넘기지 않는다(${rules.staticSec.error}초 넘으면 오류) — 새 요소·칸 채우기·숫자 키우기·컷·전환으로 바꾼다.`,
@@ -164,8 +175,8 @@ export function projectBrief(dir: string): string {
164
175
  "- 글자에 `undefined`·`NaN`·`null` 이 찍히지 않게 값은 미리 문자열로 만든다. 이모지 금지(아이콘은 인라인 SVG).",
165
176
  "- 장면이 1초 넘게 멈추지 않게 한다(아주 느린 다가가기라도). 빈 화면으로 시작·끝내지 않는다.",
166
177
  "- **화면을 채운다**(kf check layout.fill): 주 요소가 안전 영역을 크게 차지한다 — 세로 화면은 폭의 70% 넘게, 글은 큰 글자 단계(hero·title)로. 가운데 작은 그림 하나에 나머지가 흰 여백이면 실패다.",
167
- "- 구도 판정(kf check layout.static)은 칸 평균 밝기와 화면 안 내용(글·칸의 센 가장자리)으로 본다 — 숫자가 구르고 칸이 차면 바뀐 것이고, 부드럽게 흐르는 바탕만으로는 안 바뀐다. 카메라 샷으로 메우지 않는다.",
168
- "- `../lib/stage.ts` 의 `makeStage(root, ctx)`(늘 흐르는 빛 방울 바탕·컷마다 모양을 바꾸는 바탕 판·정지 카메라 `stage.camera`)·`makeCamera(layer, ctx, { frame })`(자기 카메라 층에)·`bigWord`·`fitPx`·`cutTimes`·`lastT` 를 써도 된다 — 위 규칙을 처음부터 넘게 짜 둔 도구다. 카메라가 요소를 겨누면 `stage.tick(t)` 은 내용을 칠한 뒤 맨 끝에.",
178
+ "- 구도 판정(kf check layout.static)은 칸 평균 밝기와 화면 안 내용(글·칸의 센 가장자리)으로 본다 — 숫자가 구르고 칸이 차면 바뀐 것이다. 카메라 샷이나 떠다니는 장식으로 메우지 않는다.",
179
+ "- `../lib/stage.ts` 의 `makeStage(root, ctx, { dress })`(정지 바탕·연출 수·정지 카메라 `stage.camera`)·`stage.say(낱말)`(글 수 + 박마다 차는 강조 띠)·`makeCamera(layer, ctx, { frame })`(자기 카메라 층에)·`bigWord`·`fitPx`·`cutTimes`·`lastT` 를 써도 된다 — 위 규칙을 처음부터 넘게 짜 둔 도구다. 카메라가 요소를 겨누면 `stage.tick(t)` 은 내용을 칠한 뒤 맨 끝에.",
169
180
  "- 박에 붙이는 움직임은 `ctx.beats`(장면 시작 0 기준: `ctx.beats.drops`, `ctx.beats.nearestBeat(t)`, `ctx.beats.pulse(t, {...})`). 시각을 BPM 으로 곱해 만들지 않는다.",
170
181
  "- 자산은 `ctx.asset(\"gen/<id>.png\")` 처럼 assets/ 기준 경로로. 없는 파일을 가리키지 않는다.",
171
182
  "- 색·글꼴·크기는 CSS 변수로: " + [...colors, "--kf-font-sans", "--kf-font-display", "--kf-font-mono", "--kf-size-hero", "--kf-size-title", "--kf-size-body", "--kf-size-caption", "--kf-radius-md", "--kf-shadow-soft"].join(" "),
@@ -176,17 +187,21 @@ export function projectBrief(dir: string): string {
176
187
  ...core.map((s) => `- ${s}`),
177
188
  "",
178
189
  ...(motion.length ? ["`@kasaframe/motion`(움직임 부품 — 숫자만 넣으면 Style 이나 상태를 돌려준다. applyStyle 로 붙인다):", ...motion.map((s) => `- ${s}`), ""] : []),
190
+ ...dressSection(kf),
179
191
  "## 장면",
180
192
  "",
181
193
  ...kf.scenes.flatMap((s) => [
182
194
  `### ${s.id} — ${s.file} (${s.from.toFixed(2)}–${(s.from + s.dur).toFixed(2)}초, ${s.dur.toFixed(2)}초, 역할 ${s.role})`,
183
195
  `- 할 일: ${s.idea}`,
184
196
  ...(s.role === "seam" ? ["- 이 장면 파일은 고치지 않는다 — 3D 전환(transition3d)이 앞뒤 화면을 이어 준다"] : []),
197
+ ...(s.dress ? [`- 연출 수(뼈대에 걸려 있다): ${dressLine(s.dress)}`] : []),
185
198
  `- 박: ${sceneBeats(dir, kf, s)}`,
186
199
  ...(s.assets?.length ? [`- 자산: ${s.assets.map((a) => a.replace(/^gen:(img|vid):(.+)$/, (_, k, id) => `assets/gen/${id}.${k === "img" ? "png" : "mp4"}`)).join(", ")}`] : []),
187
200
  "",
188
201
  ]),
189
202
  ...genSection(dir, kf, plan),
203
+ ...videoSection(dir, kf, plan),
204
+ ...shotSection(dir, kf, plan),
190
205
  ...themeSection(kf.theme, plan),
191
206
  ...(existsSync(join(dir, "lyrics.json"))
192
207
  ? ["## 가사(lyrics.json — 낱말 시각까지)", "", '`import.meta.glob("../lyrics.json", { eager: true, import: "default" })` 로 읽는다(꼴 `{ lines: [{ text, start, end, words?: [{ w, start, end }] }] }`, 시각은 영상 전체 기준 초 — 장면 안 시각은 `start - ctx.from`). 낱말이 없으면 줄을 글자 수 비율로 나눈다.', ""]
@@ -210,14 +225,81 @@ export function projectBrief(dir: string): string {
210
225
  return lines.join("\n");
211
226
  }
212
227
 
228
+ // 내레이션을 켠 틀(쇼츠·설명) — 장면보다 말이 먼저다. 목소리 길이가 장면 길이를 정하니 장면은 kf narrate 뒤에 쓴다
229
+ function narrationSection(dir: string, kf: ProjectFile, plan: { voice?: string; answers?: Record<string, string>; asks?: { id: string }[] }): string[] {
230
+ const tplFile = join(repo, "templates", kf.template, "template.json");
231
+ const tpl = existsSync(tplFile) ? JSON.parse(readFileSync(tplFile, "utf8")) : {};
232
+ if (!tpl.narration?.on) return [];
233
+ const asked = (id: string) => plan.asks?.some((q) => q.id === id);
234
+ const voice = kf.voice ?? plan.voice ?? plan.answers?.voice;
235
+ const music = kf.music ?? plan.answers?.music;
236
+ const done = kf.voice !== undefined;
237
+ const mix = tpl.audio ? `말할 때 곡은 목소리보다 ${-tpl.audio.musicDb}dB 안팎 아래(덕킹), 끝 ${tpl.audio.fadeOut}초 페이드, ${tpl.audio.lufs} LUFS — kf render 가 섞는다` : "";
238
+ return [
239
+ `## 내레이션 먼저 — 장면보다 앞${done ? "(됐다)" : ""}`,
240
+ "",
241
+ `목소리 ${asked("voice") ? "아직 — 맨 위 질문의 답" : (voice ?? "틀 기본")} · 곡 ${asked("music") ? "아직 — 맨 위 질문의 답" : (music ?? "없음")}${tpl.mood ? `(분위기 ${tpl.mood})` : ""}${mix ? ` · ${mix}` : ""}`,
242
+ "",
243
+ ...(done
244
+ ? [
245
+ `kf narrate 를 지났다 — 장면 시각(kf.json)이 목소리 길이에 맞춰졌고 captions.json 에 낱말 시각(\`words\`, 영상 기준 초)이 있다. 문장을 고치면 \`npx kf narrate ${dir}\` 를 다시 부른다(같은 문장은 캐시라 0원).`,
246
+ ]
247
+ : [
248
+ `1. \`captions.json\` 에 장면마다 들릴 말을 쓴다 — \`[{"scene": "hook", "text": "..."}]\`. 차분한 설명체, 짧은 마디는 쉼표로, 문장은 마침표로. 한국어 차분한 낭독은 1초에 4–5글자(띄어쓰기·문장부호 빼고) — 지금 장면 길이에 가깝게, 훅은 한 마디(2초 안). 끝 장면·이음매는 비워도 된다. 말은 소리·자막으로 가고 화면 글 정책은 그대로다.`,
249
+ `2. \`npx kf narrate ${dir}\` — 장면마다 읽혀 장면 길이 = 앞 숨 ${tpl.narration.lead}초 + 말 + 뒤 숨 ${tpl.narration.tail}초 로 맞추고(곡이 있으면 다음 박에 붙인다), 자막 시각을 목소리 시각으로 적는다. 요청에 길이가 있으면 빠르기를 0.9–1.1 안에서만 맞춘다.`,
250
+ "3. 그다음 장면을 쓴다 — kf.json 장면 시각이 바뀌었으니 다시 읽는다(장면은 `ctx.dur` 로 짠다).",
251
+ ]),
252
+ "- 말과 화면을 맞출 때: captions.json 의 `words` 가 영상 기준 초다 — 그 낱말이 들리는 순간에 같은 낱말·도식이 서게(`w.from - ctx.from` 이 장면 안 시각). `import.meta.glob(\"../captions.json\", { eager: true, import: \"default\" })` 로 읽는다.",
253
+ "",
254
+ ];
255
+ }
256
+
257
+ function dressLine(d: Dress): string {
258
+ const parts = [`바탕 \`${d.bg}\``, `글 \`${d.text}\``];
259
+ if (d.wipeIn !== "none") parts.push(`들어올 때 \`${d.wipeIn}\`가 걷힌다`);
260
+ if (d.wipeOut !== "none") parts.push(`나갈 때 \`${d.wipeOut}\`로 덮는다`);
261
+ return parts.join(" · ");
262
+ }
263
+
264
+ // 대충 한 요청에서도 연출이 밋밋하지 않게 — 뼈대가 이미 건 수를 알리고, 바꿀 때 고를 메뉴(이 용도·테마 묶음 먼저)를 준다
265
+ function dressSection(kf: ProjectFile): string[] {
266
+ const tplFile = join(repo, "templates", kf.template, "template.json");
267
+ const tpl = existsSync(tplFile) ? JSON.parse(readFileSync(tplFile, "utf8")) : {};
268
+ if (!tpl.moves) return [];
269
+ const b = bundleFor(tpl.moves, kf.theme);
270
+ const blur = kf.render?.motionBlur ?? 0;
271
+ const kinds = [
272
+ ["bg", "바탕 — 정지만(뜻 없이 떠도는 장식은 쓰지 않는다)", b.bg],
273
+ ["text", "글 — 안전 영역 안, 박마다 내용이 바뀐다(낱말을 WORDS 에)", b.text],
274
+ ["wipe", "경계 덮기 — 앞 장면 끝을 덮고 뒤 장면 처음에 걷힌다. 두 장면 파일의 wipeOut·wipeIn 을 같은 이름으로", b.wipe],
275
+ ] as const;
276
+ return [
277
+ "## 연출 수 — 뼈대에 이미 걸려 있다",
278
+ "",
279
+ `\`kf new\` 가 장면마다 바탕·글·경계 덮기를 이 용도${kf.theme ? `·테마(${kf.theme})` : ""} 묶음에서 골라 장면 파일 \`DRESS\` 에 넣었다(요청 글이 씨앗 — 같은 요청이면 같은 수). 장면을 채울 때 **수는 두고 낱말(\`WORDS\`)과 내용을 바꾼다** — 수가 쉬지 않기·같은 구도·화면 채움 검사를 이미 넘는다. **화면에서 움직이는 것은 모두 내용 하나를 뜻한다** — 떠다니는 방울·판·큐브·흩뿌린 입자 같은 뜻 없는 장식은 넣지 않고, 쉬지 않기는 숫자 굴러가기·칸 채우기·연결선·강조로 채운다. 바꾸려면 아래 이름으로, 이음매(seam)는 3D 전환(dive 2판 — 화면 속으로 빠르게 다이브)이 맡는다.`,
280
+ ...(blur > 1 ? [`굽기: 모션 블러 ${blur}장·셔터 ${kf.render?.shutter ?? 180}°(kf.json \`render\`) — 빠르게 움직이는 것만 번지고 서 있는 글은 그대로다. 손으로 끌 땐 \`kf render --motion-blur 0\`.`] : []),
281
+ "",
282
+ ...kinds.flatMap(([kind, head, mine]) => [
283
+ `- ${head}: ${MENU.filter((m) => m.kind === kind)
284
+ .sort((x, y) => Number(!mine.includes(x.name)) - Number(!mine.includes(y.name)))
285
+ .map((m) => `${mine.includes(m.name) ? "**" : ""}\`${m.name}\`${mine.includes(m.name) ? "**" : ""} ${m.does}`)
286
+ .join(" · ")}`,
287
+ ]),
288
+ "",
289
+ "굵은 이름이 이 영상의 묶음이다. 수만 따로 보려면 `examples/moves/<이름>/`, 메뉴 밖 수(곡선 글·도형 변신·만화 칸·찢기…)까지 전체는 `examples/moves/README.md`. 장면을 직접 짤 때도 `import { slamWords, sunburst, … } from \"@kasaframe/motion/moves\"` 로 같은 수를 쓴다.",
290
+ "",
291
+ ];
292
+ }
293
+
213
294
  // 테마를 골랐으면 그 비유·금지·효과 쓰는 꼴을 그대로 싣는다(SPEC 9). auto 인데 맞는 테마가 없으면 강한 모델이 새 테마를 draft 로 써 넣을 수 있다
214
- function themeSection(id: string | undefined, plan: { themeCandidates?: { id: string; why: string }[]; request?: string }): string[] {
295
+ function themeSection(id: string | undefined, plan: { themeCandidates?: { id: string; why: string }[]; request?: string; asks?: { id: string }[] }): string[] {
215
296
  if (id) {
216
297
  const t = loadTheme(id);
217
298
  const pick = plan.themeCandidates?.find((c) => c.id === id);
218
299
  return [
219
300
  `## 테마 ${t.name}(${t.id}) — ${t.metaphor}`,
220
301
  "",
302
+ ...(plan.asks?.some((q) => q.id === "theme") ? ["지금은 auto 로 깔아 둔 테마다 — 맨 위 질문의 답을 넣으면(`kf make --answer … --theme <id>`) 테마와 이 지시서가 바뀐다. 장면은 그다음에 쓴다.", ""] : []),
221
303
  ...(pick ? [`고른 이유: ${pick.why}`, ""] : []),
222
304
  `효과는 복사하지 말고 가져다 쓴다: \`import { fx } from "@kasaframe/themes/fx"; const { ${t.effects.map((e) => e.name).join(", ")} } = fx("${t.id}");\``,
223
305
  "",
@@ -239,16 +321,63 @@ function themeSection(id: string | undefined, plan: { themeCandidates?: { id: st
239
321
  return [];
240
322
  }
241
323
 
242
- // 그림 생성은 돈이 들고, 막연한 자동 프롬프트는 엉뚱한 그림을 냈다 — 아직 없는 그림은 목록으로만 내고 세션이 프롬프트를 써서 만든다
243
- function genSection(dir: string, kf: ProjectFile, plan: { gen?: { id: string; kind: string; scene?: string; prompt: string; size?: string }[] }): string[] {
324
+ type BriefPlan = { gen?: GenSpec[]; shots?: ShotSpec[]; asks?: Question[]; answers?: Record<string, string>; aspect?: string };
325
+
326
+ const pending = (plan: BriefPlan, id: string) => (plan.asks ?? []).some((q) => q.id === id);
327
+
328
+ // 막연한 자동 프롬프트는 엉뚱한 그림을 냈다 — 아직 없는 그림은 목록으로 내고 세션이 구체적인 프롬프트를 써서 고른 공급자로 만든다
329
+ function genSection(dir: string, kf: ProjectFile, plan: BriefPlan): string[] {
244
330
  const todo = (plan.gen ?? []).filter((g) => g.kind === "img" && !existsSync(join(dir, "assets", "gen", `${g.id}.png`)));
245
331
  if (!todo.length) return [];
332
+ const provider = plan.answers?.image;
246
333
  return [
247
- "## 만들 그림 — 아직 안 만들었다(묻지 않고 돈을 쓰지 않는다)",
334
+ pending(plan, "image") ? "## 만들 그림 — 사람이 그림 생성을 고른 뒤에(맨 위 「사용자에게 먼저 물을 것」)" : `## 만들 그림 — ${provider ?? "기본 공급자"}로(고른 답)`,
248
335
  "",
249
336
  "받은 자산·실제 화면 오리기·도형으로 되면 만들지 않는다. 만들 땐 요청 내용으로 **구체적인** 프롬프트를 써서(무엇이·어떤 구도로·어떤 색·글자 없음):",
250
337
  "",
251
- ...todo.map((g) => `- \`${g.id}\`(장면 ${g.scene ?? "-"}) — 틀이 제안한 뜻: ${g.prompt}\n \`npx kf gen img "<구체적 프롬프트>" --id ${g.id} --size ${g.size ?? `${kf.width}x${kf.height}`} --project ${dir}\` → \`ctx.asset("gen/${g.id}.png")\``),
338
+ ...todo.map((g) => `- \`${g.id}\`(장면 ${g.scene ?? "-"}) — 틀이 제안한 뜻: ${g.prompt}\n \`npx kf gen img "<구체적 프롬프트>" --id ${g.id} --size ${g.size ?? `${kf.width}x${kf.height}`}${provider ? ` --provider ${provider}` : ""} --project ${dir}\` → \`ctx.asset("gen/${g.id}.png")\``),
252
339
  "",
253
340
  ];
254
341
  }
342
+
343
+ // 캐릭터가 실제로 움직여야 하는 컷 — 영상 모델은 돈이 들어 고른 답이 있을 때만 부른다. 이음매 영상은 kf make --finish 가 앞뒤 화면으로 만든다
344
+ function videoSection(dir: string, kf: ProjectFile, plan: BriefPlan): string[] {
345
+ const cuts = (plan.gen ?? []).filter((g) => g.kind === "vid" && !g.seam && !existsSync(join(dir, "assets", "gen", `${g.id}.mp4`)));
346
+ if (!cuts.length) return [];
347
+ const provider = plan.answers?.video;
348
+ const aspect = plan.aspect ?? (kf.width > kf.height ? "16:9" : kf.width < kf.height ? "9:16" : "1:1");
349
+ return [
350
+ pending(plan, "video") ? "## 영상 모델 컷 — 사람이 영상 모델을 고른 뒤에(맨 위 「사용자에게 먼저 물을 것」)" : `## 영상 모델 컷 — ${provider}로(고른 답, 돈이 든다)`,
351
+ "",
352
+ "캐릭터가 실제로 움직여야 하는 컷이다. 그림을 먼저 만들었으면 그걸 첫 프레임으로 준다. 받은 클립은 장면에 `<video data-kf-from=\"<장면 시작 초>\" muted playsinline>` 로 둔다(장면이 영상보다 길면 다른 화면 안 움직임으로 잇는다).",
353
+ "",
354
+ ...cuts.map((g) => `- \`${g.id}\`(장면 ${g.scene}) — ${g.prompt}\n \`npx kf gen vid "<구체적 프롬프트>" --id ${g.id} --dur ${g.dur ?? 5} --aspect ${aspect}${g.first ? ` --first ${dir}/${g.first}` : ""}${provider ? ` --provider ${provider}` : ""} --project ${dir} --yes\` → \`ctx.asset("gen/${g.id}.mp4")\``),
355
+ "",
356
+ ];
357
+ }
358
+
359
+ // 주인공 3D 장면 — Blender 가 있으면 툰 샷을 굽고, 없어서 three 로 대신하기로 했으면 장면 안에서 stage3d 로 짓는다
360
+ function shotSection(dir: string, kf: ProjectFile, plan: BriefPlan): string[] {
361
+ const shots = plan.shots ?? [];
362
+ if (!shots.length) return [];
363
+ const out = ["## 주인공 3D 장면", ""];
364
+ for (const s of shots) {
365
+ const sc = kf.scenes.find((x) => x.id === s.scene);
366
+ const span = sc ? `${sc.from.toFixed(2)}–${(sc.from + sc.dur).toFixed(2)}초` : "";
367
+ const meta = join(dir, "assets", "blender", `${s.id}.json`);
368
+ if (s.tool === "blender" && existsSync(meta)) {
369
+ const m = JSON.parse(readFileSync(meta, "utf8"));
370
+ out.push(`- \`${s.scene}\`(${span}) — 구워 둠: \`<video src="\${ctx.asset("blender/${s.id}.mp4")}" data-kf-from="${m.dataKfFrom}" muted playsinline>\` 를 장면 바탕(또는 주인공 판)으로 두고 그 위에 낱말·숫자를 얹는다`);
371
+ } else if (s.tool === "blender") {
372
+ out.push(
373
+ `- \`${s.scene}\`(${span}) — ${s.idea}`,
374
+ ` 1. \`${dir}/${s.file}\` 를 \`tools/blender/kfb.py\` 헬퍼로 짠다 — 쓰는 법 \`tools/blender/README.md\`, 예 \`tools/blender/shots/lighthouse.py\`. 물체는 한 번 짓고 \`update(t)\` 는 t 만 본다, 카메라 열쇠는 \`S.downbeats\`·\`S.drops\` 에(도착하면 선다), 쉬지 않는 몫은 박에 뛰는 물체가 진다. 색은 style.json 잉크.`,
375
+ ` 2. \`npx kf blender ${dir}/${s.file} --project ${dir} --scene ${s.scene} --id ${s.id} --still <장면 안 두세 시각>\` 로 몇 장 보고 고친다`,
376
+ ` 3. \`npx kf blender ${dir}/${s.file} --project ${dir} --scene ${s.scene} --id ${s.id} --res 50 --step 2\` 로 굽는다(무겁다 — 한 번에) → 명령이 찍어 준 \`<video data-kf-from>\` 값으로 장면에 건다`,
377
+ );
378
+ } else {
379
+ out.push(`- \`${s.scene}\`(${span}) — ${s.idea}. Blender 가 없어 three.js 로 — \`import { stage3d, THREE } from "@kasaframe/themes/stage3d"\`: \`const st = stage3d(root, { ctx, shot: (t, cam) => { /* 카메라는 이유 있을 때만 */ } }); st.add((t) => { /* 물체 자세 */ });\` → \`(t) => st.paint(t)\``);
380
+ }
381
+ }
382
+ return [...out, ""];
383
+ }
@@ -64,8 +64,11 @@ export function writeCaptions(dir: string, mode?: CaptionMode): string[] {
64
64
  const file = join(dir, "captions.json");
65
65
  const lines = existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as CaptionLine[]) : lyricLines(dir, scenes);
66
66
  if (!lines) return [];
67
- const maxEm = kf.width && kf.height ? captionMetrics({ width: kf.width, height: kf.height, safe: kf.safeArea, minPx: kf.text?.minPx }).maxEm : undefined;
68
- const cues = toCues(lines, scenes, { maxEm, duration: kf.duration });
67
+ // 화면 판과 같은 마디로 — style.json captions(글자 크기·줄 수)가 있으면 그 값으로 나눈다
68
+ const stylePath = join(dir, (kf as { style?: string }).style ?? "style.json");
69
+ const look = existsSync(stylePath) ? JSON.parse(readFileSync(stylePath, "utf8")).captions : undefined;
70
+ const m = kf.width && kf.height ? captionMetrics({ width: kf.width, height: kf.height, safe: kf.safeArea, minPx: kf.text?.minPx, look }) : undefined;
71
+ const cues = toCues(lines, scenes, { maxEm: m?.maxEm, duration: kf.duration, maxLines: m?.maxLines });
69
72
  if (!cues.length) return [];
70
73
  mkdirSync(join(dir, "out"), { recursive: true });
71
74
  const out = [join(dir, "out", "captions.ko.srt"), join(dir, "out", "captions.ko.vtt")];
@@ -6,9 +6,11 @@ export { detect, ASK_BELOW } from "./detect.ts";
6
6
  export type { Detection, Ranked, DetectInput } from "./detect.ts";
7
7
  export { pickDuration, layoutScenes } from "./layout.ts";
8
8
  export type { Layout, LaidScene } from "./layout.ts";
9
- export { makePlan, wantsVideo } from "./plan.ts";
9
+ export { makePlan, refuses3d, wantsMotionCut, wantsVideo } from "./plan.ts";
10
+ export { applyAnswers, planQuestions, WAIT } from "./asks.ts";
11
+ export type { PlanQuestions } from "./asks.ts";
10
12
  export type { PlanInput } from "./plan.ts";
11
- export { chat, codeBlock, jsonBlock, ledger } from "./llm.ts";
13
+ export { chat, codeBlock, jsonBlock, ledger, premiumBlock, spend, spentLine, writeStats } from "./llm.ts";
12
14
  export type { Message, Part, ChatResult, Spent } from "./llm.ts";
13
15
  export { projectBrief, readProject } from "./brief.ts";
14
16
  export type { ProjectFile, ProjectScene } from "./brief.ts";
@@ -21,3 +23,5 @@ export type { LookSheet } from "./look.ts";
21
23
  export type { LookReport, LookScene } from "./look.ts";
22
24
  export { attachStudio } from "./studio.ts";
23
25
  export type { StudioLink } from "./studio.ts";
26
+ export { checkSeries, listSeries, loadSeries, seriesDir, seriesFromRequest, writeSeries } from "./series.ts";
27
+ export type { Series } from "./series.ts";