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,12 +1,13 @@
1
- import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
1
+ import { spawn } from "node:child_process";
2
+ import { blenderLibDir, findBlender, hasBlenderLib } from "@kasaframe/gen";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { basename, dirname, join, relative, resolve } from "node:path";
4
5
  import type { Command } from "../command.ts";
5
6
  import { num, parseFlags } from "../command.ts";
7
+ import { openSession } from "../browser.ts";
6
8
  import { encoder } from "../ffmpeg.ts";
7
9
  import { acquireRenderLock } from "../lock.ts";
8
10
  import { fmtTime } from "../out.ts";
9
- import { repoRoot } from "../serve.ts";
10
11
 
11
12
  // 이 파일은 Blender 를 바깥 프로그램으로 띄우기만 한다(bpy 를 가져오지 않는다). bpy 를 쓰는 헬퍼·샷은 GPL 로 갈라 둔
12
13
  // tools/blender 에 있고 npm 묶음에는 없다 — 근거는 tools/blender/README.md.
@@ -20,11 +21,9 @@ interface KfJson {
20
21
  scenes?: { id: string; from: number; dur: number }[];
21
22
  }
22
23
 
23
- const MIN = [4, 3]; // Grease Pencil v3(라인아트가 일반 모디파이어)가 4.3 부터
24
-
25
24
  function blenderLib(): string {
26
- const dir = process.env.KF_BLENDER_LIB ? resolve(process.env.KF_BLENDER_LIB) : join(repoRoot, "tools", "blender");
27
- if (!existsSync(join(dir, "run.py")) || !existsSync(join(dir, "kfb.py"))) {
25
+ const dir = blenderLibDir();
26
+ if (!hasBlenderLib(dir)) {
28
27
  throw new Error(
29
28
  `Blender 헬퍼(kfb.py·run.py)가 없다: ${dir}\n` +
30
29
  "헬퍼는 bpy 를 써서 GPL-3.0 으로 갈라 두었고 npm 묶음에는 넣지 않았다 — 레포의 tools/blender 를 받아 KF_BLENDER_LIB=<그 폴더> 로 가리켜라.",
@@ -33,46 +32,6 @@ function blenderLib(): string {
33
32
  return dir;
34
33
  }
35
34
 
36
- function candidates(given?: string): string[] {
37
- const out: string[] = [];
38
- if (given) out.push(given);
39
- if (process.env.BLENDER) out.push(process.env.BLENDER);
40
- const which = spawnSync(process.platform === "win32" ? "where" : "which", ["blender"], { encoding: "utf8" });
41
- if (which.status === 0) out.push(...which.stdout.split(/\r?\n/).filter(Boolean));
42
- if (process.platform === "darwin") {
43
- const apps = existsSync("/Applications") ? readdirSync("/Applications").filter((n) => /^Blender.*\.app$/.test(n)) : [];
44
- // 이름에 판 번호를 붙여 여러 개 깔아 둔 기계면 높은 판부터
45
- apps.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
46
- out.push(...apps.map((a) => `/Applications/${a}/Contents/MacOS/Blender`));
47
- }
48
- if (process.platform === "win32") {
49
- const base = "C:\\Program Files\\Blender Foundation";
50
- if (existsSync(base)) {
51
- const dirs = readdirSync(base).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
52
- out.push(...dirs.map((d) => join(base, d, "blender.exe")));
53
- }
54
- }
55
- return out;
56
- }
57
-
58
- function findBlender(given?: string): { exe: string; version: number[] } {
59
- const tried: string[] = [];
60
- for (const exe of candidates(given)) {
61
- if (tried.includes(exe)) continue;
62
- tried.push(exe);
63
- const r = spawnSync(exe, ["--version"], { encoding: "utf8", timeout: 30_000 });
64
- const m = /Blender (\d+)\.(\d+)(?:\.(\d+))?/.exec(r.stdout ?? "");
65
- if (r.status !== 0 || !m) continue;
66
- const version = [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
67
- if (version[0] < MIN[0] || (version[0] === MIN[0] && version[1] < MIN[1])) {
68
- console.error(`kf blender: ${exe} 는 ${version.join(".")} — ${MIN.join(".")} 이상이 필요해 건너뛴다`);
69
- continue;
70
- }
71
- return { exe, version };
72
- }
73
- throw new Error(`Blender ${MIN.join(".")} 이상을 못 찾았다 — --blender <실행 파일> 또는 BLENDER=<경로>. 본 곳: ${tried.join(", ") || "(없음)"}`);
74
- }
75
-
76
35
  function findProject(given: string | undefined, shot: string): string {
77
36
  if (given) {
78
37
  if (!existsSync(join(resolve(given), "kf.json"))) throw new Error(`kf.json 이 없다: ${resolve(given)}`);
@@ -98,6 +57,8 @@ const cmd: Command = {
98
57
  --step 1 N 프레임마다 한 장만 찍고 그 사이는 붙든다(2 = 애니메이션 「2코마」, 시간 반)
99
58
  --still 1.2,4.5 그 시각들만 PNG 로(out/blender/<id>/still-<t>.png) — 굽기 전에 그림부터 본다
100
59
  --blend 만든 장면을 out/blender/<id>.blend 로 저장(Blender 에서 열어 보기)
60
+ --match 앞 장면 마지막 화면·뒤 장면 첫 화면과 그 주인공 상자를 찍어 샷에 넘긴다 — 샷이 kfb.match_start·match_end 로
61
+ 첫·끝 프레임을 그 2D 화면과 같은 구도로 맞추면(투영 일치) 경계가 컷이어도 공간이 이어진다. --scene 과 함께
101
62
  --blender <실행 파일> 기본: BLENDER 환경 변수 → PATH 의 blender → /Applications/Blender*.app (4.3 이상)
102
63
  --keep 다 묶은 뒤에도 PNG 를 out/blender/<id>/ 에 남긴다
103
64
  --no-lock · --lock-wait 900 렌더 잠금(kf render 와 같은 것 — 무거운 일은 한 번에 하나)
@@ -160,12 +121,13 @@ const cmd: Command = {
160
121
  blend: opts.blend ? join(proj, "out", "blender", `${id}.blend`) : null,
161
122
  };
162
123
  const cfgPath = join(proj, "out", "blender", `${id}.cfg.json`);
163
- writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
164
124
 
165
- const { exe, version } = findBlender(typeof opts.blender === "string" ? opts.blender : undefined);
125
+ const { exe, version } = findBlender(typeof opts.blender === "string" ? opts.blender : undefined, (l) => console.error(`kf blender: ${l}`));
166
126
  const release = opts["no-lock"] ? () => {} : await acquireRenderLock(proj, num(opts["lock-wait"], 900));
167
127
  const started = Date.now();
168
128
  try {
129
+ const match = opts.match && typeof opts.scene === "string" ? await matchFrames(proj, kf, opts.scene, work) : null;
130
+ writeFileSync(cfgPath, JSON.stringify({ ...cfg, match }, null, 2));
169
131
  console.error(
170
132
  `Blender ${version.join(".")} · ${relative(process.cwd(), shot)} · ${fmtTime(t0)}–${fmtTime(endF / fps)}초 · ` +
171
133
  `${stills ? `스틸 ${frames.length}장` : `${count}프레임${step > 1 ? ` (${step}장마다 찍음 ${frames.length}장)` : ""}`} · ${Math.round((kf.width * res) / 100)}x${Math.round((kf.height * res) / 100)}`,
@@ -176,6 +138,8 @@ const cmd: Command = {
176
138
  for (const n of names) console.log(join(work, `${n}.png`));
177
139
  return 0;
178
140
  }
141
+ const usedFile = join(work, "match-used.json");
142
+ const used: string[] = existsSync(usedFile) ? JSON.parse(readFileSync(usedFile, "utf8")) : [];
179
143
 
180
144
  const outDir = join(proj, "assets", "blender");
181
145
  mkdirSync(outDir, { recursive: true });
@@ -205,9 +169,20 @@ const cmd: Command = {
205
169
  secPerFrame: Number(perFrame.toFixed(3)),
206
170
  elapsedSec: Number(elapsed.toFixed(1)),
207
171
  dataKfFrom: kfFrom,
172
+ ...(used.length ? { match: { in: used.includes("in"), out: used.includes("out") } } : {}),
208
173
  renderedAt: new Date().toISOString(),
209
174
  };
210
175
  writeFileSync(join(outDir, `${id}.json`), `${JSON.stringify(meta, null, 2)}\n`);
176
+ // 첫·끝 프레임을 앞뒤 2D 화면과 투영 일치로 맞췄으면 그 장면에 적는다 — 감독은 matched 장면 경계에 이음매를 넣지 않는다(컷으로 이어진다)
177
+ if (used.length && typeof opts.scene === "string") {
178
+ const file = join(proj, "kf.json");
179
+ const doc = JSON.parse(readFileSync(file, "utf8"));
180
+ const sc = doc.scenes?.find((x: { id: string }) => x.id === opts.scene);
181
+ if (sc) {
182
+ sc.matched = true;
183
+ writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
184
+ }
185
+ }
211
186
  if (!opts.keep) rmSync(work, { recursive: true, force: true });
212
187
  console.error(`${count}프레임 · 한 장 ${perFrame.toFixed(2)}초 · 모두 ${Math.round(elapsed)}초`);
213
188
  console.error(`장면에서: <video src="\${ctx.asset("blender/${id}.mp4")}" data-kf-from="${kfFrom}" muted playsinline>`);
@@ -220,6 +195,54 @@ const cmd: Command = {
220
195
  };
221
196
  export default cmd;
222
197
 
198
+ interface Rect {
199
+ x: number;
200
+ y: number;
201
+ w: number;
202
+ h: number;
203
+ }
204
+
205
+ // 샷 앞 장면의 마지막 프레임·뒤 장면의 첫 프레임과 그 장면이 짚은 주인공([data-kf-hero]) 상자(화면 비율, 왼쪽 위 0).
206
+ // 샷 자리의 장면은 찍지 않으니 샷 영상이 아직 없어도 된다
207
+ async function matchFrames(proj: string, kf: KfJson, sceneId: string, work: string): Promise<{ in?: string; inHero?: Rect; out?: string; outHero?: Rect } | null> {
208
+ const list = kf.scenes ?? [];
209
+ const i = list.findIndex((s) => s.id === sceneId);
210
+ const prev = list[i - 1];
211
+ const next = list[i + 1];
212
+ if (i < 0 || (!prev && !next)) return null;
213
+ const s = await openSession(proj);
214
+ try {
215
+ const grab = async (t: number, id: string, file: string) => {
216
+ await s.first.seek(t);
217
+ writeFileSync(file, await s.first.capture());
218
+ const hero = await s.first.page.evaluate((sid) => {
219
+ const stage = document.getElementById("stage") ?? document.body;
220
+ const el = document.querySelector(`[data-kf-scene="${sid}"] [data-kf-hero]`);
221
+ if (!el) return null;
222
+ const b = stage.getBoundingClientRect();
223
+ const r = el.getBoundingClientRect();
224
+ return { x: (r.left - b.left) / b.width, y: (r.top - b.top) / b.height, w: r.width / b.width, h: r.height / b.height };
225
+ }, id);
226
+ return { file, hero: hero ?? undefined };
227
+ };
228
+ const out: { in?: string; inHero?: Rect; out?: string; outHero?: Rect } = {};
229
+ if (prev) {
230
+ const g = await grab(prev.from + prev.dur - 1 / kf.fps, prev.id, join(work, "match-in.png"));
231
+ out.in = g.file;
232
+ out.inHero = g.hero;
233
+ }
234
+ if (next) {
235
+ const g = await grab(next.from, next.id, join(work, "match-out.png"));
236
+ out.out = g.file;
237
+ out.outHero = g.hero;
238
+ }
239
+ console.error(`kf blender: 맞출 화면 — ${prev ? `앞 ${prev.id} 끝` : ""}${prev && next ? " · " : ""}${next ? `뒤 ${next.id} 처음` : ""}`);
240
+ return out;
241
+ } finally {
242
+ await s.close();
243
+ }
244
+ }
245
+
223
246
  // Blender 를 헤드리스로 돌리고 KFB 줄로 진행을 센다. 평균 한 장 시간(첫 장의 셰이더 컴파일 빼고)을 돌려준다.
224
247
  function runBlender(exe: string, lib: string, cfgPath: string): Promise<number> {
225
248
  return new Promise((resolveRun, reject) => {
@@ -0,0 +1,210 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, readFileSync, rmSync } from "node:fs";
3
+ import { basename, dirname, extname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { build, checkContent, type Content, layout, type Problem, type Report } from "@kasaframe/director";
6
+ import { questionsText } from "@kasaframe/gen";
7
+ import { parseDuration } from "@kasaframe/plan";
8
+ import { type Command, num, parseFlags } from "../command.ts";
9
+
10
+ const kfBin = fileURLToPath(new URL("../kf.ts", import.meta.url));
11
+ // 검사가 떨어뜨려도 패턴 탓이 아닌 규칙 — 장면 경계(시간표)·생성 자산·자막 판
12
+ const NOT_PATTERN = /^(beat\.|asset\.|caption\.)/;
13
+ // 생성 능력 질문의 답 — kf make 로 그대로 넘긴다
14
+ const PASS = ["image", "video", "blender", "voice", "music", "allow", "series", "theme", "purpose"];
15
+
16
+ const cmd: Command = {
17
+ summary: "감독 모드 — content.json(무엇을 보일지)에서 장면 문법·전환·바탕을 골라 장면 파일을 조립(LLM 없이)",
18
+ usage: `kf direct <content.json|프로젝트 폴더> [--out 폴더] [--seed 글] [--series id] [--voice …] [--music …]
19
+ content.json 의 장면마다(type·칸·say·source) 감독이 검증된 장면 문법(패턴) 후보에 점수를 매겨 — 테마 궁합·용도·곡 구간 에너지(벌스·후렴·드롭)·
20
+ 앞뒤 장면과 계열·카메라 안 겹치기 — 씨앗 고정 난수로 고르고, 패턴 매개변수를 내용으로 채워 장면 파일을 쓴다. 장면 사이 이음매(3D 전환)도
21
+ 감독이 고른다(기본 dive, 같은 전환 연속 금지, 2D↔3D 경계는 공간 잇기). 같은 content.json·시리즈·씨앗이면 장면 파일이 바이트까지 같다.
22
+
23
+ 폴더가 아직 없으면 content.json 의 request(없으면 title)로 kf make 뼈대(용도·테마·목소리·곡 묻기 포함)를 먼저 세운다 — 기본 폴더는 content.json 옆 같은 이름.
24
+ 순서: 칸 검사 → 시간표(이음매 포함) → kf narrate(say 를 읽혀 장면 길이를 목소리에) → 패턴 고르기·장면 파일 → kf check(떨어진 장면만 다음 후보로, --retries 번)
25
+
26
+ --seed 다른 고름을 본다(같은 씨앗이면 늘 같은 그림). 기본 content.json 의 seed, 없으면 0
27
+ --scenes a,b 그 장면만 다시 짓는다 — 시간표·다른 장면 파일·이음매는 바이트까지 그대로(조각 렌더가 바뀐 장면만 다시 굽게)
28
+ --pattern a=<패턴 id>[,b=…] 사람이 고른 후보(director.json 의 order) · --scene-seed a=<글>[,…] 그 장면만 다른 그림
29
+ 고른 값은 director.json 의 pinned 에 남아 다음 kf direct 도 지킨다
30
+ 진행 줄(stderr): 「장면 <id>: 쓰는 중」·「장면 <id>: 씀」·「장면 <id>: 실패 <까닭>」(굽기·검사까지 끝난 「끝」은 작업실·render 몫)
31
+ --no-narrate 목소리 없이 어림 길이로 · --no-check 검사 없이 · --retries N(기본 2) · --finish 다 되면 kf make --finish(렌더·표지·자막)
32
+ --no-lock 검사 고리만 렌더 잠금 없이(남의 긴 굽기 뒤에서 기다리지 않게) — --finish 의 굽기는 그대로 잠금
33
+ --image · --video · --blender · --voice · --music · --allow · --yes · --no-gen 뼈대를 세울 때 묻기 답(kf make 와 같다)
34
+ 결과: kf.json(장면·패턴·전환) · scenes/*.ts · captions.json · director.json(장면마다 후보 점수·까닭·떨어진 것)`,
35
+ async run(argv) {
36
+ const { args, opts } = parseFlags(argv);
37
+ const str = (k: string) => (typeof opts[k] === "string" ? (opts[k] as string) : undefined);
38
+ const target = args[0] ? resolve(args[0]) : undefined;
39
+ if (!target || !existsSync(target)) {
40
+ console.error("kf direct: content.json 이나 그것이 든 프로젝트 폴더가 필요하다 — kf direct --help");
41
+ return 2;
42
+ }
43
+ const isDir = !extname(target) || existsSync(join(target, "kf.json"));
44
+ const file = isDir ? join(target, "content.json") : target;
45
+ if (!existsSync(file)) {
46
+ console.error(`kf direct: ${file} 가 없다 — 지시서(MAKE.md)의 「content.json 꼴」대로 먼저 쓴다`);
47
+ return 2;
48
+ }
49
+ const dir = isDir ? target : existsSync(join(dirname(target), "kf.json")) ? dirname(target) : resolve(str("out") ?? join(dirname(target), basename(target, extname(target))));
50
+
51
+ let raw: unknown;
52
+ try {
53
+ raw = JSON.parse(readFileSync(file, "utf8"));
54
+ } catch (e) {
55
+ console.error(`kf direct: ${file} 가 JSON 이 아니다 — ${e instanceof Error ? e.message : e}`);
56
+ return 1;
57
+ }
58
+ const checked = checkContent(raw);
59
+ const say = (p: Problem) => ` ${p.at}: ${p.msg}`;
60
+ if (checked.warnings.length) console.error(`content.json 고친 곳·볼 곳 ${checked.warnings.length}:\n${checked.warnings.map(say).join("\n")}`);
61
+ if (checked.errors.length) {
62
+ console.error(`kf direct: content.json 칸이 틀렸다 ${checked.errors.length}곳 — 고치고 다시:\n${checked.errors.map(say).join("\n")}`);
63
+ return 1;
64
+ }
65
+ const content: Content = checked.content;
66
+
67
+ // 뼈대 — 용도·테마·목소리·곡 묻기는 kf make 와 같은 길을 지난다
68
+ if (!existsSync(join(dir, "kf.json"))) {
69
+ const req = content.request ?? content.title;
70
+ const request = content.duration && parseDuration(req) === undefined ? `${req} ${content.duration}초` : req;
71
+ const make = [kfBin, "make", request, "--out", dir, "--model", "claude-code", "--title", content.title];
72
+ for (const [k, v] of [["series", content.series], ["purpose", content.purpose], ["theme", content.theme], ["voice", content.voice], ["music", content.music]] as const) if (v && str(k) === undefined) make.push(`--${k}`, v);
73
+ for (const k of PASS) if (str(k)) make.push(`--${k}`, str(k)!);
74
+ for (const k of ["yes", "no-gen"]) if (opts[k]) make.push(`--${k}`);
75
+ const r = spawnSync(process.execPath, make, { stdio: ["inherit", "inherit", "inherit"] });
76
+ if (r.status !== 0) return r.status ?? 1;
77
+ } else if (PASS.some((k) => str(k)) && planAsks(dir).length) {
78
+ const flags = PASS.filter((k) => str(k)).flatMap((k) => [`--${k}`, str(k)!]);
79
+ const r = spawnSync(process.execPath, [kfBin, "make", "--answer", dir, ...flags], { stdio: "inherit" });
80
+ if (r.status !== 0) return r.status ?? 1;
81
+ }
82
+ const open = planAsks(dir);
83
+ if (open.length) {
84
+ console.error(`kf direct: 만들기 전에 정할 것이 ${open.length}가지 — 사람에게 묻고 플래그로 답해 다시 부른다\n\n${questionsText(open as never)}\n\n kf direct ${args[0]} ${open.map((q) => `${q.flag} <값>`).join(" ")}`);
85
+ return 3;
86
+ }
87
+ if (resolve(file) !== join(dir, "content.json")) copyFileSync(file, join(dir, "content.json"));
88
+
89
+ const only = str("scenes")?.split(",").map((x) => x.trim()).filter(Boolean);
90
+ const pins: Record<string, { pattern?: string; seed?: string }> = {};
91
+ for (const [flag, k] of [["pattern", "pattern"], ["scene-seed", "seed"]] as const)
92
+ for (const pair of (str(flag) ?? "").split(",").filter(Boolean)) {
93
+ const eq = pair.indexOf("=");
94
+ if (eq < 1) {
95
+ console.error(`kf direct: --${flag} 는 <장면>=<값> — "${pair}"`);
96
+ return 2;
97
+ }
98
+ (pins[pair.slice(0, eq)] ??= {})[k] = pair.slice(eq + 1);
99
+ }
100
+ // 고른 장면만이면 시간표를 다시 놓지 않는다 — 다른 장면의 시각이 한 프레임도 안 움직이게
101
+ if (!only) {
102
+ const { seams } = layout(dir, content, { keepTiming: !!opts["no-narrate"] });
103
+ console.error(`시간표: 장면 ${content.scenes.length} + 이음매 ${seams}`);
104
+ }
105
+ if (!opts["no-narrate"]) {
106
+ const hasSay = content.scenes.some((s) => s.say?.trim());
107
+ const n = [kfBin, "narrate", dir];
108
+ const voice = str("voice") ?? content.voice ?? (hasSay ? undefined : "none");
109
+ if (voice) n.push("--voice", voice);
110
+ if (str("music") ?? content.music) n.push("--music", (str("music") ?? content.music)!);
111
+ const r = spawnSync(process.execPath, n, { stdio: ["inherit", "inherit", "inherit"] });
112
+ if (r.status !== 0) {
113
+ console.error("kf direct: 내레이션(kf narrate)에서 멈췄다 — 위 보기대로 답하거나 --no-narrate 로 어림 길이");
114
+ return r.status ?? 1;
115
+ }
116
+ }
117
+
118
+ const seed = str("seed");
119
+ const log = (l: string) => console.error(l);
120
+ let report: Report;
121
+ try {
122
+ report = await build(dir, content, { seed, only, pins, log });
123
+ } catch (e) {
124
+ const msg = e instanceof Error ? e.message : String(e);
125
+ for (const id of only ?? []) console.error(`장면 ${id}: 실패 ${msg}`);
126
+ console.error(`kf direct: ${msg}`);
127
+ return 1;
128
+ }
129
+ print(dir, report);
130
+ let code = 0;
131
+ if (!opts["no-check"]) {
132
+ const retries = num(opts.retries, 2);
133
+ for (let round = 0; ; round++) {
134
+ // 옛 결과를 치우고 돌린다 — 검사가 돌지 못하고 끝나면 옛 check.json 을 이번 결과로 읽게 된다
135
+ const report_ = join(dir, "out", "check.json");
136
+ const fresh = () => {
137
+ rmSync(report_, { force: true });
138
+ return spawnSync(process.execPath, [kfBin, "check", dir, ...(opts["no-lock"] ? ["--no-lock"] : [])], { encoding: "utf8" });
139
+ };
140
+ let r = fresh();
141
+ // 남의 긴 굽기가 잠금을 쥐면 검사가 15분 기다리다 물러난다 — 한 시간까지 다시 줄을 선다
142
+ for (let wait = 0; /잠금을 못 얻었다/.test(r.stderr ?? "") && wait < 4; wait++) {
143
+ console.error("kf check: 다른 굽기가 잠금을 쥐고 있어 다시 기다린다");
144
+ r = fresh();
145
+ }
146
+ code = r.status ?? 1;
147
+ if (code === 0) {
148
+ console.log("kf check 통과");
149
+ break;
150
+ }
151
+ if (code !== 1 || !existsSync(report_)) {
152
+ console.error(`kf check 를 못 돌렸다(종료 ${code}) — ${`${r.stderr ?? ""}`.trim().split("\n").slice(-3).join(" / ")}`);
153
+ break;
154
+ }
155
+ const blame = blameScenes(dir, report);
156
+ console.error(`kf check 오류 — ${blame.lines.join(" · ")}`);
157
+ if (round >= retries || (!Object.keys(blame.failed).length && !Object.keys(blame.seamFailed).length)) break;
158
+ try {
159
+ report = await build(dir, content, { seed, failed: blame.failed, seamFailed: blame.seamFailed, log });
160
+ } catch (e) {
161
+ console.error(`kf direct: ${e instanceof Error ? e.message : e}`);
162
+ break;
163
+ }
164
+ console.error(`다시 지음(${round + 1}/${retries}) — ${[...Object.entries(blame.failed), ...Object.entries(blame.seamFailed)].map(([id]) => `${id} → ${report.scenes.find((s) => s.id === id)?.pattern ?? report.scenes.find((s) => s.id === id)?.transition}`).join(" · ")}`);
165
+ }
166
+ if (code !== 0) console.error(`kf check 가 아직 떨어진다 — ${dir}/out/check.json 을 보고 content.json 을 고치거나 --seed 를 바꿔 다시`);
167
+ }
168
+ if (opts.finish && code === 0) {
169
+ const r = spawnSync(process.execPath, [kfBin, "make", "--finish", dir], { stdio: "inherit" });
170
+ return r.status ?? 1;
171
+ }
172
+ if (!opts.finish) console.log(`\n다음: kf look ${dir} 로 장면 모음을 보고 → kf make --finish ${dir}`);
173
+ return code;
174
+ },
175
+ };
176
+ export default cmd;
177
+
178
+ function planAsks(dir: string): { id: string; flag: string }[] {
179
+ const p = join(dir, "plan.json");
180
+ return existsSync(p) ? (JSON.parse(readFileSync(p, "utf8")).asks ?? []) : [];
181
+ }
182
+
183
+ function print(dir: string, r: Report): void {
184
+ console.log(`${dir} — 씨앗 ${r.seed} · ${r.purpose} ${r.aspect}${r.theme ? ` · 테마 ${r.theme}` : ""}`);
185
+ for (const s of r.scenes) {
186
+ const what = s.transition ? `이음매 ${s.transition}` : s.custom ? `custom(${s.custom === "kept" ? "있는 파일" : "뼈대 — 직접 쓴다"})` : `${s.pattern}${s.energy ? ` · ${s.energy}` : ""}${s.attempt ? ` · ${s.attempt + 1}번째 후보` : ""}`;
187
+ console.log(` ${s.from.toFixed(2).padStart(6)}–${(s.from + s.dur).toFixed(2).padEnd(6)} ${s.id.padEnd(10)} ${(s.type ?? "").padEnd(12)} ${what}`);
188
+ }
189
+ }
190
+
191
+ // out/check.json 의 오류를 시각으로 장면에 묶는다 — 패턴 장면은 그 패턴을, 이음매는 그 전환을 떨어뜨린다
192
+ function blameScenes(dir: string, r: Report): { failed: Record<string, string[]>; seamFailed: Record<string, string[]>; lines: string[] } {
193
+ const p = join(dir, "out", "check.json");
194
+ const failed: Record<string, string[]> = {};
195
+ const seamFailed: Record<string, string[]> = {};
196
+ const lines: string[] = [];
197
+ if (!existsSync(p)) return { failed, seamFailed, lines };
198
+ const findings = (JSON.parse(readFileSync(p, "utf8")).findings ?? []) as { rule: string; severity: string; from: number }[];
199
+ for (const f of findings) {
200
+ if (f.severity !== "error") continue;
201
+ const s = r.scenes.find((x) => f.from >= x.from - 1e-6 && f.from < x.from + x.dur) ?? r.scenes.at(-1)!;
202
+ lines.push(`${s.id} ${f.rule}`);
203
+ if (NOT_PATTERN.test(f.rule)) continue;
204
+ if (s.pattern) failed[s.id] = [...new Set([...(failed[s.id] ?? []), s.pattern])];
205
+ else if (s.transition) seamFailed[s.id] = [...new Set([...(seamFailed[s.id] ?? []), s.transition])];
206
+ }
207
+ const count = new Map<string, number>();
208
+ for (const l of lines) count.set(l, (count.get(l) ?? 0) + 1);
209
+ return { failed, seamFailed, lines: [...count].map(([l, n]) => (n > 1 ? `${l}×${n}` : l)) };
210
+ }
@@ -1,8 +1,9 @@
1
+ import { capabilities } from "@kasaframe/gen";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { arch, platform } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import type { Command } from "../command.ts";
5
- import { checks, report } from "../deps.ts";
6
+ import { checks, pad, report } from "../deps.ts";
6
7
  import { repoRoot } from "../serve.ts";
7
8
 
8
9
  const cmd: Command = {
@@ -15,6 +16,13 @@ const cmd: Command = {
15
16
  const list = await checks();
16
17
  report(list);
17
18
  const missing = list.filter((c) => c.required && !c.ok);
19
+ console.log("\n생성 능력(kf make 가 쓰는 것 — 없으면 kf make 가 만들기 전에 묻는다. 키는 값 없이 유무만)");
20
+ for (const c of await capabilities({ probe: true })) {
21
+ const ok = c.providers.filter((p) => p.ready);
22
+ console.log(` ${pad(ok.length ? "ok" : "없음", 4)} ${pad(c.label, 30)}${c.best ? `권장 ${c.best.id}${c.best.unitUsd ? ` $${c.best.unitUsd.toFixed(3)}/${c.best.unit}` : " 0원"}` : "쓸 수 있는 것 없음"}${ok.length > 1 ? ` · 그 밖 ${ok.filter((p) => p !== c.best).map((p) => p.id).join(" ")}` : ""}`);
23
+ for (const p of c.providers.filter((x) => !x.ready && x.fix)) console.log(` ${pad("", 36)}${p.id}: ${p.detail} → ${p.fix}`);
24
+ for (const p of ok.filter((x) => x.restrict)) console.log(` ${pad("", 36)}${p.id} 제한: ${p.restrict}`);
25
+ }
18
26
  console.log(missing.length ? `\n렌더에 필요한 것이 ${missing.length}개 없다 — 위 → 대로 깔고 다시 kf doctor` : "\n렌더 준비 됨 — kf new <폴더> --template shorts 부터");
19
27
  return missing.length ? 1 : 0;
20
28
  },
@@ -1,6 +1,6 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { DEFAULT_EYES, formatLook, look, lookSheet } from "@kasaframe/plan";
3
+ import { DEFAULT_EYES, formatLook, look, lookSheet, premiumBlock } from "@kasaframe/plan";
4
4
  import { type Command, parseFlags } from "../command.ts";
5
5
 
6
6
  const cmd: Command = {
@@ -14,7 +14,8 @@ const cmd: Command = {
14
14
  --vision-model 그림을 못 보는 세션(Claude Code 를 OG 로 딥시크 같은 모델에 붙이면 OG /v1/messages 가 그림을 버린다)용.
15
15
  그 모델이 OG chat/completions 로 장면 모음을 보고 장면별 글 보고(한 번에 1센트 안팎) — JSON 은 out/look.json,
16
16
  문제 장면이 있으면 종료 코드 1
17
- --ask --vision-model 에게 따로 물을 것`,
17
+ --ask --vision-model 에게 따로 물을 것
18
+ --allow-premium Opus·GPT 계열 모델을 푼다(KF_ALLOW_PREMIUM=1 과 같다) — 없으면 부르기 전에 막는다`,
18
19
  async run(argv) {
19
20
  const { args, opts } = parseFlags(argv);
20
21
  if (!args[0]) {
@@ -29,6 +30,12 @@ const cmd: Command = {
29
30
  console.log(opts.json ? JSON.stringify(s, null, 2) : `장면 모음: ${s.sheet}\n확인표: ${s.checklist}\n— 장면 ${s.scenes}개. 그림을 Read 로 직접 보고 확인표대로 고친다`);
30
31
  return 0;
31
32
  }
33
+ if (opts["allow-premium"]) process.env.KF_ALLOW_PREMIUM = "1";
34
+ const why = premiumBlock(model);
35
+ if (why) {
36
+ console.error(`kf look: ${why}`);
37
+ return 2;
38
+ }
32
39
  const r = await look(target, { ask: typeof opts.ask === "string" ? opts.ask : undefined, model });
33
40
  const out = /\.(mp4|mov|webm|m4v)$/i.test(target) ? join(dirname(target), "look.json") : join(target, "out", "look.json");
34
41
  writeFileSync(out, `${JSON.stringify(r, null, 2)}\n`);
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { type Question, WAIT } from "@kasaframe/gen";
6
+ import { install, type Missing } from "@kasaframe/jizura/install";
5
7
  import { parseLyrics, toLrc } from "@kasaframe/jizura/lyrics";
6
8
  import { type Command, num, parseFlags } from "../command.ts";
7
9
  import { pyVenv, venvPython } from "../deps.ts";
@@ -47,6 +49,46 @@ export function setupLyrics(): boolean {
47
49
  return (existsSync(venvPython(dir)) || step(py, ["-m", "venv", dir])) && step(venvPython(dir), ["-m", "pip", "install", "-r", requirements]);
48
50
  }
49
51
 
52
+ /** 가사 층이 처음 쓰는 글꼴을 못 받았을 때 — 다른 글꼴로 대신 그리지 않고 묻는다. 답은 kf lyrics use 가 kf.json 에 적는다 */
53
+ export function fontsQuestion(missing: Missing[]): Question {
54
+ const stand = missing.every((m) => m.stand);
55
+ return {
56
+ id: "lyrics", cap: "lyrics", header: "가사 글꼴", flag: `kf lyrics use ${missing[0].project ?? "<폴더>"}`, missing: true, default: WAIT, skip: "words",
57
+ ask: `가사 층이 처음 쓰는 글꼴 ${missing.map((m) => m.family).join("·")} 을 못 받았다(${missing[0].reason}) — 다른 글꼴로 대신 그리지 않고 멈췄다`,
58
+ choices: [
59
+ { value: WAIT, label: "받고 다시", note: "지금 다시 받아 본다(kf setup jizura 와 같다 — 막힌 망이면 KF_JIZURA_FONTS_URL 에 google/fonts 거울) → 같은 명령을 다시" },
60
+ ...(stand ? [{ value: "builtin", label: "내장 글꼴로", note: `${missing.map((m) => `${m.family} → ${m.stand}`).join(", ")} — 플랜·컷은 그대로, 글자 모양만 바뀐다` }] : []),
61
+ { value: "words", label: "가사 층 없이 낱말로", note: "JIZURA 레이아웃·효과 없이 부른 낱말을 DOM 글로" },
62
+ ],
63
+ };
64
+ }
65
+
66
+ // kf.json "lyrics" — 가사 층이 읽는다(layer.ts lyricsMode). wait 는 지금 받아 보고 되면 원래대로(jizura)
67
+ async function use(dir: string | undefined, value: string | undefined): Promise<number> {
68
+ const kfPath = dir ? join(resolve(dir), "kf.json") : "";
69
+ if (!dir || !existsSync(kfPath) || !value || ![WAIT, "jizura", "builtin", "words"].includes(value)) {
70
+ console.error("kf lyrics use <폴더> wait|builtin|words|jizura");
71
+ return 2;
72
+ }
73
+ if (value === WAIT) {
74
+ const lyricsPath = join(resolve(dir), "lyrics.json");
75
+ const lang = existsSync(lyricsPath) && JSON.parse(readFileSync(lyricsPath, "utf8")).lang === "ja" ? "ja" : "ko";
76
+ try {
77
+ await install([lang]);
78
+ } catch (err) {
79
+ console.error(`kf lyrics use: 아직 못 받는다 — ${err instanceof Error ? err.message : err}`);
80
+ return 1;
81
+ }
82
+ value = "jizura";
83
+ }
84
+ const kf = JSON.parse(readFileSync(kfPath, "utf8"));
85
+ if (value === "jizura") delete kf.lyrics;
86
+ else kf.lyrics = value;
87
+ writeFileSync(kfPath, `${JSON.stringify(kf, null, 2)}\n`);
88
+ console.log(`${kfPath} lyrics: ${value} — 같은 명령을 다시`);
89
+ return 0;
90
+ }
91
+
50
92
  function writeLrc(json: string, lrcPath: string): void {
51
93
  const lyrics = parseLyrics(JSON.parse(readFileSync(json, "utf8")));
52
94
  writeFileSync(lrcPath, toLrc(lyrics));
@@ -56,15 +98,19 @@ const cmd: Command = {
56
98
  summary: "가사 — 공식 가사에 줄·어절 시각을 붙여 lyrics.json + LRC (보컬 분리 + whisper 강제 정렬)",
57
99
  usage: `kf lyrics align <곡> --text 공식가사.txt [--lang ko|ja] [--out lyrics.json] [--from 초 --duration 초] [--model large-v3-turbo] [--vocals]
58
100
  kf lyrics lrc <lyrics.json> [--out x.lrc]
101
+ kf lyrics use <폴더> wait|builtin|words|jizura
59
102
 
60
103
  align 글자는 공식 가사(한 줄 = 가사 한 줄)가 정본이고 시각만 붙인다. 곡의 일부만 쓰면 --from·--duration(시각은 --from 을 0 으로 —
61
104
  beats.json 과 같다). 보컬만 있는 소리면 --vocals(분리를 건너뛴다). 옆에 같은 이름 .lrc 도 쓴다(2.5초 넘는 빈 자리는 [間奏]).
62
105
  MMS 강제 정렬은 가중치가 비상업(CC-BY-NC)이라 쓰지 않는다. 깔기: kf setup lyrics (KF_LYRICS_PYTHON 으로 바꿀 수 있다)
63
106
  모델·장치: --model(기본 large-v3-turbo) · WHISPER_DEVICE(기본 cpu — mps 는 정렬에서 깨질 때가 있다)
64
- lrc lyrics.json → LRC(JIZURA·노래방 형식)`,
107
+ lrc lyrics.json → LRC(JIZURA·노래방 형식)
108
+ use 가사 층 글꼴을 처음 쓰다 못 받았을 때 고른 답을 kf.json 에 — wait(지금 다시 받기) · builtin(못 받은 얼굴을 내장 글꼴로)
109
+ · words(가사 층 없이 낱말로) · jizura(원래대로). 페이지 주소 ?lyrics= 가 그 판만 덮는다`,
65
110
  async run(argv) {
66
111
  const { args, opts } = parseFlags(argv);
67
112
  const [sub, input] = args;
113
+ if (sub === "use") return use(input, args[2]);
68
114
  if (sub === "lrc") {
69
115
  if (!input || !existsSync(input)) {
70
116
  console.error("kf lyrics lrc: lyrics.json 경로가 필요하다");