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,78 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { addToLibrary, byMood, genMusic, listLibrary, MOOD_PROMPT, MOOD_TAGS, musicDir, musicProviders } from "@kasaframe/gen";
4
+ import { type Command, num, parseFlags } from "../command.ts";
5
+
6
+ const cmd: Command = {
7
+ summary: "곡 모음 — 분위기 태그로 보기·넣기, 음악 생성(Lyria·Eleven Music)",
8
+ usage: `kf music list [--mood deep]
9
+ kf music add <곡 파일> --mood deep,ambient --source <원본 주소> --license "<라이선스>" [--title 이름] [--genre 장르] [--bpm 70]
10
+ kf music gen --mood deep --dur 45 [--provider lyria-3.5|elevenlabs-music|lyria-3-clip-preview] [--out 폴더] [--add]
11
+
12
+ 곡 모음은 ${"KF_MUSIC_DIR"}(없으면 레포 music/). 곡마다 분위기·원본·라이선스는 그 폴더 tags.json, 사람용 표는 README.md.
13
+ 분위기: ${Object.keys(MOOD_PROMPT).join(" · ")} — 틀(template.json mood)이 고른다. 원본·라이선스를 모르는 곡은 넣지 않는다.
14
+ gen 은 분위기·길이 칸(30·45·60·90·120…초)이 같으면 캐시에서 같은 곡을 0원으로 꺼낸다. --add 면 곡 모음에 태그와 함께 넣는다.`,
15
+ async run(argv) {
16
+ const [sub, ...rest] = argv;
17
+ const { args, opts } = parseFlags(rest);
18
+ const str = (k: string) => (typeof opts[k] === "string" ? (opts[k] as string) : undefined);
19
+
20
+ if (sub === "list" || !sub) {
21
+ const tracks = str("mood") ? byMood(str("mood")!) : listLibrary();
22
+ console.log(`곡 모음 ${musicDir()}${str("mood") ? ` — 분위기 ${str("mood")}` : ""}`);
23
+ for (const t of tracks) console.log(` ${t.file.padEnd(34)} ${(t.mood.join("·") || "태그 없음").padEnd(32)} ${t.license ? t.license.slice(0, 60) : "라이선스 모름 — 고르는 데서 빠진다"}`);
24
+ if (!tracks.length) console.log(" (없음)");
25
+ console.log(`\n생성: ${musicProviders().map((p) => `${p.id}(${p.ready() ? "키 있음" : "키 없음"})`).join(" · ")}`);
26
+ return 0;
27
+ }
28
+
29
+ if (sub === "add") {
30
+ const src = args[0] ? resolve(args[0]) : undefined;
31
+ if (!src || !existsSync(src) || !str("mood") || !str("source") || !str("license")) {
32
+ console.error("kf music add: <곡 파일> --mood --source --license 가 필요하다");
33
+ return 2;
34
+ }
35
+ const t = addToLibrary(src, {
36
+ title: str("title") ?? basename(src).replace(/\.[^.]+$/, ""),
37
+ mood: str("mood")!.split(",").map((m) => m.trim()).filter(Boolean),
38
+ ...(str("genre") ? { genre: str("genre") } : {}),
39
+ ...(str("bpm") ? { bpm: num(opts.bpm, 0) } : {}),
40
+ source: str("source")!,
41
+ license: str("license")!,
42
+ });
43
+ readmeRow(t.file, t.title, t.mood, t.source, t.license);
44
+ console.log(t.path);
45
+ return 0;
46
+ }
47
+
48
+ if (sub === "gen") {
49
+ const mood = str("mood") ?? "deep";
50
+ const dur = num(opts.dur, 45);
51
+ const outDir = resolve(str("out") ?? ".");
52
+ const m = await genMusic(`${mood}-${dur}`, { mood, duration: dur }, { outDir, log: (l) => console.error(l) }, str("provider"));
53
+ console.error(`${m.provider} · ${m.seconds}초 · ${m.cached ? "캐시(0)" : `$${m.costUsd.toFixed(3)}`} · ${m.license}`);
54
+ if (opts.add) {
55
+ const file = `${mood}-${m.provider}-${Math.round(m.seconds)}s.${m.path.split(".").pop()}`;
56
+ const t = addToLibrary(m.path, { title: `${mood} ${Math.round(m.seconds)}초 (${m.provider} 생성)`, mood: [...new Set([mood, ...(MOOD_TAGS[mood] ?? []).slice(0, 3)])], source: `${m.provider} 생성(MOOD_PROMPT.${mood}, ${new Date().toISOString().slice(0, 10)})`, license: m.license }, musicDir(), file);
57
+ readmeRow(t.file, t.title, t.mood, t.source, t.license);
58
+ console.log(t.path);
59
+ } else console.log(m.path);
60
+ return 0;
61
+ }
62
+
63
+ console.error(this.usage);
64
+ return 2;
65
+ },
66
+ };
67
+ export default cmd;
68
+
69
+ // README 표에 한 줄 — 같은 파일 줄이 있으면 두지 않는다. 표 끝(마지막 표 줄 다음)에 끼운다
70
+ function readmeRow(file: string, title: string, mood: string[], source: string, license: string): void {
71
+ const readme = join(musicDir(), "README.md");
72
+ if (!existsSync(readme)) return;
73
+ const lines = readFileSync(readme, "utf8").split("\n");
74
+ if (lines.some((l) => l.startsWith(`| \`${file}\` |`))) return;
75
+ const last = lines.map((l, i) => (l.startsWith("| `") ? i : -1)).filter((i) => i >= 0).at(-1) ?? lines.length - 1;
76
+ lines.splice(last + 1, 0, `| \`${file}\` | ${title} | ${mood.join("·")} | ${source} · ${license} |`);
77
+ writeFileSync(readme, lines.join("\n"));
78
+ }
@@ -0,0 +1,238 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { cutVoice, DEFAULT_STYLE, genMusic, howToGet, layVoices, parseVoice, pickTts, speak, ttsProviders, type Speech } from "@kasaframe/gen";
6
+ import { loadTemplate, parseDuration, type Template } from "@kasaframe/plan";
7
+ import { type Command, parseFlags } from "../command.ts";
8
+ import { jobLimit, pool } from "../pool.ts";
9
+
10
+ const kfBin = fileURLToPath(new URL("../kf.ts", import.meta.url));
11
+ // 한 장면에 문장이 둘 넘으면 그 사이 숨
12
+ const GAP = 0.35;
13
+ const MIN_SCENE = 0.75;
14
+ // 박이 이보다 멀면 붙이지 않는다(느린 곡의 마디 사이에서 장면이 늘어지지 않게)
15
+ const SNAP_REACH = 0.8;
16
+
17
+ const NARRATION_DEFAULT: NonNullable<Template["narration"]> = { on: true, lang: "ko", style: DEFAULT_STYLE, voice: "elevenlabs:George", voices: {}, lead: 0.25, tail: 0.5 };
18
+
19
+ interface Line {
20
+ scene: string;
21
+ text: string;
22
+ from?: number;
23
+ to?: number;
24
+ words?: { text: string; from: number; to: number }[];
25
+ }
26
+
27
+ const cmd: Command = {
28
+ summary: "내레이션 먼저 — 장면별 문장을 읽혀 장면 길이를 목소리에 맞추고(곡이 있으면 박에), 자막 시각을 목소리 시각으로",
29
+ usage: `kf narrate <폴더> [--voice <엔진>:<목소리>|none] [--music <곡 파일>|gen:<공급자>|none]
30
+ kf narrate --voices 키가 있는 목소리 엔진·목소리 목록
31
+ kf narrate --say "<글>" --voice gemini:Charon [--out x.wav] 한 문장만 읽혀 본다
32
+
33
+ captions.json([{"scene": "<장면 id>", "text": "그 장면에서 들릴 말"}])을 장면마다 읽힌다 → 장면 길이 = 앞 숨 + 말 + 뒤 숨(틀 narration.lead·tail).
34
+ 곡을 주면(또는 plan.json 답) 곡을 깔고 kf beats 로 재서 장면 경계를 다음 박에 붙인다. gen:<공급자> 면 틀 분위기(mood)로 영상 길이에 맞춘 곡을 만든다.
35
+ 요청 글에 길이(「45초」)가 있으면 목소리 빠르기를 0.9–1.1 안에서만 맞추고 남는 시간은 장면 뒤 숨으로 편다. 그 밖엔 빠르기를 건드리지 않는다.
36
+ 결과: assets/voice/<장면>-<n>.wav · assets/voice/narration.wav(영상 길이 한 줄) · captions.json 의 낱말 시각 · kf.json 의 장면 시각·narration·audio(섞기 값).
37
+ kf render 가 narration·곡을 섞는다 — 곡은 말할 때 덕킹, 끝 페이드, 틀의 LUFS 로 정규화.
38
+ --voice·--music 을 안 주면 plan.json 의 voice·music(또는 answers)을, 그것도 없으면 틀이 엔진마다 박은 목소리를 쓴다.`,
39
+ async run(argv) {
40
+ const { args, opts } = parseFlags(argv);
41
+ const str = (k: string) => (typeof opts[k] === "string" ? (opts[k] as string) : undefined);
42
+ if (opts.voices) return voices();
43
+ if (str("say")) return say(str("say")!, str("voice") ?? "gemini", str("out"));
44
+ const dir = args[0] ? resolve(args[0]) : undefined;
45
+ if (!dir || !existsSync(join(dir, "kf.json"))) {
46
+ console.error("kf narrate: kf.json 이 있는 프로젝트 폴더가 필요하다 — kf narrate --help");
47
+ return 2;
48
+ }
49
+ try {
50
+ return await narrate(dir, { voice: str("voice"), music: str("music") });
51
+ } catch (e) {
52
+ console.error(`kf narrate: ${e instanceof Error ? e.message : e}`);
53
+ return 1;
54
+ }
55
+ },
56
+ };
57
+ export default cmd;
58
+
59
+ function voices(): number {
60
+ for (const p of ttsProviders()) {
61
+ const state = p.ready() ? "쓸 수 있음" : "키 없음";
62
+ console.log(`${p.id.padEnd(11)} ${p.label} · ${p.model} · ${state}${p.verified ? "" : " · 실호출 미검증"}${p.timestamps ? " · 낱말 시각 정확" : ""}`);
63
+ for (const v of p.voices) console.log(` --voice ${`${p.id}:${v.id}`.padEnd(26)} ${v.label}`);
64
+ }
65
+ return 0;
66
+ }
67
+
68
+ async function say(text: string, voice: string, out?: string): Promise<number> {
69
+ const v = parseVoice(voice);
70
+ if (!v) return 2;
71
+ const dst = resolve(out ?? "say.wav");
72
+ const s = await speak("say", { text, voice: v.voice, lang: "ko", style: DEFAULT_STYLE }, { outDir: join(dst, ".."), log: (l) => console.error(l) }, v.engine);
73
+ await cutVoice(s.path, dst, { from: s.voiced.from, to: s.voiced.to });
74
+ console.error(`${s.engine} ${s.voice} · ${(s.voiced.to - s.voiced.from).toFixed(2)}초 · ${s.cached ? "캐시(0)" : `$${s.costUsd.toFixed(4)}`}`);
75
+ console.log(dst);
76
+ return 0;
77
+ }
78
+
79
+ const readJson = (p: string) => JSON.parse(readFileSync(p, "utf8"));
80
+ const writeJson = (p: string, v: unknown) => writeFileSync(p, `${JSON.stringify(v, null, 2)}\n`);
81
+
82
+ // 답 고르는 순서: 명령 줄 > plan.json 의 voice·answers > 묻기의 권장과 같은 것(로컬 Qwen3 주소가 있으면 그 목소리, 없으면 틀이 못 박은 목소리).
83
+ // 다른 엔진·「자막만」으로 조용히 넘어가지 않는다
84
+ function pickVoice(given: string | undefined, plan: Record<string, any>, n: Template["narration"]): string {
85
+ const v = given ?? plan.voice ?? plan.answers?.voice;
86
+ if (v) return v;
87
+ if (!n?.on) return "none";
88
+ return pickTts("local").ready() ? `local:${n.voices.local ?? pickTts("local").defaultVoice}` : n.voice;
89
+ }
90
+
91
+ export async function narrate(dir: string, o: { voice?: string; music?: string } = {}): Promise<number> {
92
+ const kfPath = join(dir, "kf.json");
93
+ const planPath = join(dir, "plan.json");
94
+ const kf = readJson(kfPath);
95
+ const plan = existsSync(planPath) ? readJson(planPath) : {};
96
+ const tpl = loadTemplate(kf.template);
97
+ const n = { ...NARRATION_DEFAULT, ...tpl.narration };
98
+ const fps: number = kf.fps;
99
+ const f = (x: number) => Math.round(x * fps) / fps;
100
+ const log = (l: string) => console.error(l);
101
+
102
+ // 1. 목소리 — 장면마다 문장을 읽힌다
103
+ const voice = pickVoice(o.voice, plan, tpl.narration);
104
+ const v = parseVoice(voice);
105
+ if (v && !pickTts(v.engine).ready()) {
106
+ console.error(`kf narrate: 목소리 ${voice} 의 엔진 ${v.engine} 을 이 기계에서 못 쓴다 — 고르고 다시:\n 키 넣기 — ${howToGet(pickTts(v.engine).keyName)}\n 다른 엔진: ${ttsProviders().filter((p) => p.ready()).map((p) => `--voice ${p.id}:${n.voices[p.id] ?? p.defaultVoice}`).join(" · ") || "(쓸 수 있는 엔진 없음)"}\n 목소리 빼기: --voice none(자막만)`);
107
+ return 3;
108
+ }
109
+ const capPath = join(dir, "captions.json");
110
+ const lines: Line[] = existsSync(capPath) ? readJson(capPath) : [];
111
+ if (v && !lines.length) throw new Error("captions.json 에 장면별 문장이 없다 — [{\"scene\": \"hook\", \"text\": \"...\"}] 로 먼저 쓴다");
112
+ const spoken = new Map<string, { line: Line; s: Speech }[]>();
113
+ let costUsd = 0;
114
+ if (v) {
115
+ mkdirSync(join(dir, "assets", "voice", "raw"), { recursive: true });
116
+ const count: Record<string, number> = {};
117
+ const todo = lines.filter((line) => line.text?.trim() && kf.scenes.some((s: { id: string }) => s.id === line.scene)).map((line) => ({ line, k: (count[line.scene] = (count[line.scene] ?? 0) + 1) }));
118
+ // 줄마다 따로 읽힌다 — 동시에 부르고 장면 차례대로 모은다
119
+ const said = await pool(todo, jobLimit(4), ({ line, k }) => speak(`${line.scene}-${k}`, { text: line.text.trim(), voice: v.voice, lang: n.lang, style: n.style }, { outDir: join(dir, "assets", "voice", "raw"), log }, v.engine));
120
+ for (const [i, { line }] of todo.entries()) {
121
+ const s = said[i];
122
+ if (!s.cached) costUsd += s.costUsd;
123
+ spoken.set(line.scene, [...(spoken.get(line.scene) ?? []), { line, s }]);
124
+ }
125
+ }
126
+ const talk = (id: string) => (spoken.get(id) ?? []).reduce((a, x, i) => a + (x.s.voiced.to - x.s.voiced.from) + (i ? GAP : 0), 0);
127
+ const leadOf = (s: { role: string }) => (s.role === "hook" ? Math.min(0.1, n.lead) : n.lead);
128
+
129
+ // 2. 빠르기 — 요청이 길이를 말했으면 그 길이에, 아니면 틀 상한을 넘을 때만. 늘 0.9–1.1 안
130
+ const voiceSec = kf.scenes.reduce((a: number, s: { id: string }) => a + talk(s.id), 0);
131
+ const restSec = kf.scenes.reduce((a: number, s: { id: string; role: string; dur: number }) => a + (spoken.has(s.id) ? leadOf(s) + n.tail : s.dur), 0);
132
+ const target = parseDuration(plan.request ?? "");
133
+ const clamp = (x: number) => Math.min(1.1, Math.max(0.9, x));
134
+ let tempo = 1;
135
+ if (voiceSec > 0) {
136
+ if (target !== undefined && target > restSec) tempo = clamp(voiceSec / (target - restSec));
137
+ else if (voiceSec + restSec > tpl.duration.max) tempo = clamp(voiceSec / Math.max(1, tpl.duration.max - restSec));
138
+ if (Math.abs(tempo - 1) < 0.02) tempo = 1;
139
+ }
140
+ const need = kf.scenes.map((s: { id: string; role: string; dur: number }) => (spoken.has(s.id) ? Math.max(MIN_SCENE, leadOf(s) + talk(s.id) / tempo + n.tail) : s.dur));
141
+ // 남는 시간은 말한 장면 뒤 숨으로 고르게(요청 길이가 있을 때만)
142
+ const short = target !== undefined ? target - need.reduce((a: number, b: number) => a + b, 0) : 0;
143
+ const voiced = kf.scenes.filter((s: { id: string }) => spoken.has(s.id)).length;
144
+ if (short > 0 && voiced) kf.scenes.forEach((s: { id: string }, i: number) => spoken.has(s.id) && (need[i] += short / voiced));
145
+ let total = need.reduce((a: number, b: number) => a + b, 0);
146
+
147
+ // 3. 곡 — 곡 모음 파일·생성·없음. 바뀌었으면 kf beats 로 다시 잰다
148
+ const music = o.music ?? plan.answers?.music ?? undefined;
149
+ if (music === "none") {
150
+ kf.music = null;
151
+ kf.beats = null;
152
+ } else if (music?.startsWith("gen:")) {
153
+ // 시리즈 묶음의 분위기가 틀 분위기를 덮는다
154
+ const m = await genMusic("score", { mood: plan.mood ?? tpl.mood ?? "deep", duration: total }, { outDir: join(dir, "assets", "music"), log }, music.slice(4));
155
+ if (!m.cached) costUsd += m.costUsd;
156
+ kf.music = `assets/music/${basename(m.path)}`;
157
+ log(`곡 ${m.provider} ${m.seconds}초 ${m.cached ? "캐시(0)" : `$${m.costUsd.toFixed(3)}`} — ${m.license}`);
158
+ } else if (music) {
159
+ const src = resolve(music);
160
+ if (!existsSync(src)) throw new Error(`곡이 없다: ${src}`);
161
+ mkdirSync(join(dir, "assets", "music"), { recursive: true });
162
+ const rel = `assets/music/${basename(src)}`;
163
+ if (resolve(dir, rel) !== src) copyFileSync(src, join(dir, rel));
164
+ kf.music = rel;
165
+ }
166
+ if (kf.music && (music !== undefined || !kf.beats || !existsSync(join(dir, kf.beats)))) {
167
+ const r = spawnSync(process.execPath, [kfBin, "beats", join(dir, kf.music), "--out", join(dir, "beats.json")], { stdio: ["ignore", "ignore", "inherit"] });
168
+ if (r.status !== 0) throw new Error("곡 분석(kf beats)이 실패했다");
169
+ kf.beats = "beats.json";
170
+ }
171
+
172
+ // 4. 경계 — 말이 끝난 뒤 다음 박에(곡이 있으면), 그다음 프레임에
173
+ const beats: number[] = kf.beats && existsSync(join(dir, kf.beats)) ? (readJson(join(dir, kf.beats)).beats ?? []) : [];
174
+ let t = 0;
175
+ for (const [i, s] of kf.scenes.entries()) {
176
+ s.from = +f(t).toFixed(4);
177
+ let end = t + need[i];
178
+ if (i < kf.scenes.length - 1) {
179
+ const next = beats.find((b) => b >= end - 0.02);
180
+ if (next !== undefined && next - end <= SNAP_REACH) end = next;
181
+ }
182
+ end = f(end);
183
+ s.dur = +(end - s.from).toFixed(4);
184
+ t = end;
185
+ }
186
+ total = f(t);
187
+ kf.duration = +total.toFixed(4);
188
+
189
+ // 5. 목소리 한 줄 — 조각을 장면 시각에 깔고, 자막에 낱말 시각을 영상 기준으로 적는다
190
+ const clips: { file: string; at: number }[] = [];
191
+ const outLines: Line[] = [];
192
+ for (const s of kf.scenes) {
193
+ let at = s.from + leadOf(s);
194
+ for (const [k, { line, s: sp }] of (spoken.get(s.id) ?? []).entries()) {
195
+ const file = join(dir, "assets", "voice", `${s.id}-${k + 1}.wav`);
196
+ await cutVoice(sp.path, file, { from: sp.voiced.from, to: sp.voiced.to, tempo });
197
+ clips.push({ file, at });
198
+ const words = sp.words.map((w) => ({ text: w.text, from: +(at + (w.from - sp.voiced.from) / tempo).toFixed(3), to: +(at + (w.to - sp.voiced.from) / tempo).toFixed(3) }));
199
+ const len = (sp.voiced.to - sp.voiced.from) / tempo;
200
+ outLines.push({ scene: s.id, text: line.text, from: +at.toFixed(3), to: +(at + len).toFixed(3), words });
201
+ at += len + GAP;
202
+ }
203
+ }
204
+ if (v) {
205
+ await layVoices(clips, total, join(dir, "assets", "voice", "narration.wav"));
206
+ // 목소리를 안 읽힌 줄(장면 id 가 없던 줄)도 버리지 않는다
207
+ for (const l of lines) if (!outLines.some((x) => x.scene === l.scene && x.text === l.text)) outLines.push({ scene: l.scene, text: l.text });
208
+ writeJson(capPath, outLines);
209
+ writeJson(join(dir, "assets", "voice", "narration.json"), {
210
+ voice,
211
+ engine: v.engine,
212
+ model: pickTts(v.engine).model,
213
+ license: pickTts(v.engine).license,
214
+ tempo,
215
+ clips: clips.map((c, i) => ({ file: basename(c.file), at: +c.at.toFixed(3), text: outLines[i]?.text })),
216
+ });
217
+ }
218
+ kf.narration = v ? "assets/voice/narration.wav" : null;
219
+ kf.voice = voice;
220
+ if (tpl.audio) kf.audio = tpl.audio;
221
+ writeJson(kfPath, kf);
222
+ if (existsSync(planPath)) {
223
+ plan.voice = voice;
224
+ plan.music = kf.music;
225
+ plan.duration = kf.duration;
226
+ for (const s of plan.scenes ?? []) {
227
+ const k = kf.scenes.find((x: { id: string }) => x.id === s.id);
228
+ if (k) Object.assign(s, { from: k.from, dur: k.dur });
229
+ }
230
+ writeJson(planPath, plan);
231
+ }
232
+
233
+ console.log(`${dir} — 목소리 ${voice}${tempo !== 1 ? ` · 빠르기 ${tempo.toFixed(3)}` : ""} · 곡 ${kf.music ?? "없음"}${beats.length ? "(박에 붙임)" : ""} · ${kf.duration}초 · 이번에 쓴 돈 $${costUsd.toFixed(4)}`);
234
+ for (const s of kf.scenes) console.log(` ${s.from.toFixed(2).padStart(6)}–${(s.from + s.dur).toFixed(2).padEnd(6)} ${s.id.padEnd(9)} ${spoken.has(s.id) ? `말 ${(talk(s.id) / tempo).toFixed(2)}초` : "말 없음"}`);
235
+ if (target !== undefined && Math.abs(kf.duration - target) > 2) console.log(` · 요청 ${target}초와 ${(kf.duration - target).toFixed(1)}초 차이 — 빠르기는 0.9–1.1 까지만 바꾼다. 대본을 ${kf.duration > target ? "줄이면" : "늘리면"} 맞는다`);
236
+ else if (target === undefined && kf.duration > tpl.duration.max) console.log(` · 틀 상한 ${tpl.duration.max}초를 넘는다 — 대본을 줄인다`);
237
+ return 0;
238
+ }
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { basename, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { bundleFor, dressSeed, NO_DRESS, pickDress } from "@kasaframe/motion/menu";
5
6
  import { themeStyle } from "@kasaframe/themes";
6
7
  import { type Aspect, type BeatsLike, type Plan, type Purpose, listTemplates, loadTemplate, makePlan, parseTitle, PURPOSES, sceneSkeleton, templatesDir } from "@kasaframe/plan";
7
8
  import { type Command, num, parseFlags } from "../command.ts";
@@ -16,7 +17,8 @@ kf new <폴더> --request "요청 글" [--music 곡.mp3] 용도를 요청 글
16
17
  kf new <폴더> --plan plan.json kf make 가 세운 계획 그대로
17
18
 
18
19
  용도: ${PURPOSES.join(" · ")}
19
- --theme 테마 id(themes/<id>, approved·review) 또는 auto(내용에 맞는 approved 후보 셋 중 첫째) — 고르면 그 테마 style 을 쓴다
20
+ --theme 테마 id(themes/<id>, approved·review) · auto(내용에 맞는 approved 후보 셋 중 첫째) · none(프리셋만) — 고르면 그 테마 style 을 쓴다
21
+ --series 시리즈 묶음 id · none. 안 주면 요청 글이 짚은 시리즈
20
22
  --force 비어 있지 않은 폴더에도 쓴다(있는 장면 파일은 그대로 둔다)
21
23
  --preview 다 만들고 kf preview 를 띄운다
22
24
 
@@ -82,6 +84,7 @@ kf new <폴더> --plan plan.json kf make 가 세운 계
82
84
  style: str("style"),
83
85
  title: str("title") ?? parseTitle(request ?? "") ?? basename(dir),
84
86
  theme: str("theme"),
87
+ series: str("series"),
85
88
  });
86
89
  const purposeQ = plan.questions?.find((q) => q.id === "purpose");
87
90
  if (purposeQ && !template) {
@@ -111,6 +114,10 @@ kf new <폴더> --plan plan.json kf make 가 세운 계
111
114
  // 틀의 움직임 규칙이 프리셋을 이긴다 — 쇼츠에 박 펄스가 켜진 프리셋을 골라도 쇼츠 규칙대로
112
115
  style.motion = { ...style.motion, ...tpl.motion };
113
116
 
117
+ // 장면마다 연출 수(바탕·글·경계 덮기) — 용도 틀·테마 묶음에서 요청 글을 씨앗으로 고른다. 같은 요청이면 같은 수가 걸린다
118
+ // 틀에 묶음이 없으면(뮤비 — 가사 층이 화면을 맡는다) 무대 기본 그대로
119
+ const dress = tpl.moves ? pickDress(dressSeed(plan.request || plan.title, plan.purpose, plan.theme ?? plan.style), plan.scenes, bundleFor(tpl.moves, plan.theme ?? undefined)) : plan.scenes.map(() => ({ ...NO_DRESS }));
120
+
114
121
  const frame = { width: plan.width, height: plan.height };
115
122
  const safeArea = plan.aspect === tpl.aspect ? tpl.safeArea : (tpl.variants?.[plan.aspect]?.safeArea ?? tpl.safeArea);
116
123
  const kf = {
@@ -125,9 +132,10 @@ kf new <폴더> --plan plan.json kf make 가 세운 계
125
132
  music,
126
133
  beats: beats ? "beats.json" : null,
127
134
  captions: plan.captions,
135
+ ...(tpl.render ? { render: tpl.render } : {}),
128
136
  safeArea,
129
137
  text: tpl.text,
130
- scenes: plan.scenes.map((s) => ({ id: s.id, from: s.from, dur: s.dur, file: `scenes/${s.id}.ts`, role: s.role, idea: s.idea, assets: s.assets })),
138
+ scenes: plan.scenes.map((s, i) => ({ id: s.id, from: s.from, dur: s.dur, file: `scenes/${s.id}.ts`, role: s.role, idea: s.idea, assets: s.assets, ...(s.role === "seam" || !tpl.moves ? {} : { dress: dress[i] }) })),
131
139
  };
132
140
 
133
141
  const write = (rel: string, body: string) => writeFileSync(join(dir, rel), body);
@@ -152,15 +160,16 @@ kf new <폴더> --plan plan.json kf make 가 세운 계
152
160
  }
153
161
  const step = plan.scenes.slice(0, i + 1).filter((x) => x.role === "step").length;
154
162
  const word = s.role === "end" || s.role === "hook" || s.role === "brand" ? plan.title : s.role === "step" ? `${step}단계` : s.id.replace(/\d+$/, "").toUpperCase();
155
- write(rel, fill(sceneSkeleton(plan.purpose, s.id, s.role), { word, title: plan.title, line: str("line") ?? "", move: MOVES[i % MOVES.length], transition: tpl.scenes.find((x) => x.id === s.id)?.transition ?? "dive" }));
163
+ write(rel, fill(sceneSkeleton(plan.purpose, s.id, s.role), { word, title: plan.title, line: str("line") ?? "", move: MOVES[i % MOVES.length], transition: tpl.scenes.find((x) => x.id === s.id)?.transition ?? "dive", ...dress[i] }));
156
164
  }
157
165
 
158
166
  const near = relative(process.cwd(), dir);
159
167
  const rel = !near ? "." : near.startsWith("..") ? dir : near;
160
168
  console.log(`${rel}/ — ${tpl.label}(${plan.purpose}) ${plan.aspect} ${plan.width}×${plan.height} · ${+plan.duration.toFixed(3)}초 · ${plan.theme ? `테마 ${plan.theme}` : `스타일 ${plan.style}`}${music ? ` · 곡 ${music}` : ""}`);
161
169
  if (plan.themeCandidates?.length) console.log(` 테마 후보: ${plan.themeCandidates.map((c) => c.id).join(" · ")}`);
162
- else if (str("theme") === "auto") console.log(" 맞는 approved 테마가 없어 프리셋으로 간다");
163
- for (const s of kf.scenes) console.log(` ${s.from.toFixed(2).padStart(6)}–${(s.from + s.dur).toFixed(2).padEnd(6)} ${s.id.padEnd(9)} ${s.idea}`);
170
+ else if (str("theme") === "auto" && !plan.theme) console.log(" 맞는 approved 테마가 없어 프리셋으로 간다");
171
+ for (const s of kf.scenes) console.log(` ${s.from.toFixed(2).padStart(6)}–${(s.from + s.dur).toFixed(2).padEnd(6)} ${s.id.padEnd(9)} ${s.idea}${s.dress ? ` [${s.dress.bg}·${s.dress.text}${s.dress.wipeOut !== "none" ? `→${s.dress.wipeOut}` : ""}]` : ""}`);
172
+ if (tpl.render?.motionBlur) console.log(` 굽기: 모션 블러 ${tpl.render.motionBlur}장·셔터 ${tpl.render.shutter ?? 180}° (kf.json render — 끄려면 kf render --motion-blur 0)`);
164
173
  for (const r of plan.reasons.filter((x) => !x.startsWith('"'))) console.log(` · ${r}`);
165
174
  if (kept.length) console.log(` 있던 장면 파일은 그대로 뒀다: ${kept.join(" ")}`);
166
175
  if (plan.gen?.length) console.log(` 만들 수 있는 자산(아직 안 만듦, 돈이 든다): ${plan.gen.map((g) => `gen:${g.kind}:${g.id}`).join(" ")} — 구체적인 프롬프트로 kf gen img --id <id> --project ${rel}`);
@@ -1,12 +1,14 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { extname, join, resolve } from "node:path";
2
+ import { basename, extname, join, resolve } from "node:path";
3
3
  import type { Command } from "../command.ts";
4
4
  import { num, parseFlags } from "../command.ts";
5
5
  import { openSession, type Tab } from "../browser.ts";
6
6
  import { concat, encoder, type VideoFormat } from "../ffmpeg.ts";
7
7
  import { acquireRenderLock } from "../lock.ts";
8
- import { blurOpts, captureBlurred, rgbaToPng } from "../blur.ts";
8
+ import { blurOpts, captureBlurred, projectBlur, rgbaToPng } from "../blur.ts";
9
9
  import { outPath } from "../out.ts";
10
+ import { cachedSegment, collectEvents, type KeyOpts, type MotionEvent, pruneSegments, renderSegments, sceneKeys, sceneSpans, type SegmentReport, type SegmentWork, variantOf } from "../segments.ts";
11
+ import { mixAudio, mixSettings } from "../mix.ts";
10
12
 
11
13
  const MAX_TABS = 3;
12
14
 
@@ -50,29 +52,6 @@ function musicFrom(dir: string): number {
50
52
  }
51
53
  }
52
54
 
53
- interface MotionEvent {
54
- t: number;
55
- kind: string;
56
- source: string;
57
- scene?: string;
58
- }
59
-
60
- // 움직임 부품이 쌓은 기록을 탭마다 모아 하나로 — step 모드는 pre-roll 구간이 겹쳐 같은 줄이 여러 탭에 있다.
61
- async function collectEvents(tabs: Tab[]): Promise<MotionEvent[]> {
62
- const seen = new Set<string>();
63
- const out: MotionEvent[] = [];
64
- for (const tab of tabs) {
65
- const rows = await tab.page.evaluate(() => (window.__kf?.events ?? []) as unknown[]).catch(() => []);
66
- for (const r of rows as MotionEvent[]) {
67
- const key = `${r.t}|${r.kind}|${r.source}|${r.scene ?? ""}`;
68
- if (seen.has(key)) continue;
69
- seen.add(key);
70
- out.push(r);
71
- }
72
- }
73
- return out.sort((a, b) => a.t - b.t);
74
- }
75
-
76
55
  // x.mp4 → x-자막.mp4 · frames → frames-자막
77
56
  function withSuffix(p: string, suffix: string): string {
78
57
  if (!suffix) return p;
@@ -82,14 +61,19 @@ function withSuffix(p: string, suffix: string): string {
82
61
 
83
62
  const cmd: Command = {
84
63
  summary: "MP4(또는 ProRes·PNG 묶음)로 굽기 — 탭 병렬, 곡 얹기, 잠금",
85
- usage: `kf render <폴더> [--out x.mp4] [--from 초 --to 초] [--tabs 3] [--prores | --png] [--music 곡 | --no-music] [--captions on|off|fit [--suffix]]
64
+ usage: `kf render <폴더> [--out x.mp4] [--scenes a,b] [--from 초 --to 초] [--tabs 3] [--prores | --png] [--music 곡 | --no-music] [--captions on|off|fit [--suffix]]
86
65
  --out 기본 <폴더>/out/render.mp4 (--prores 면 .mov, --png 면 폴더)
87
- --from/--to 구간만 굽는다. 곡도 그 구간만 잘라 얹는다
66
+ seek 모드는 장면 조각으로 굽는다 — 장면마다 out/segments/<장면>-<해시>.mp4(해시: 그 장면이 실제로 불러온 모듈·kf.json 그 장면 칸·
67
+ 자산·자막 줄·굽기 설정, 이음매는 앞뒤 장면까지). 해시가 같은 조각은 다시 찍지 않고 이어 붙인 뒤 곡·목소리를 전체로 얹는다
68
+ --scenes a,b 그 장면 조각만 굽고 잇지 않는다(조각 경로를 찍는다). 다음 kf render 가 그 조각을 그대로 쓴다
69
+ --fresh 조각 캐시를 안 보고 다시 찍는다 · --no-segments 조각 없이 탭 몫으로 나눠 한 번에(예전 길)
70
+ --from/--to 구간만 굽는다. 곡도 그 구간만 잘라 얹는다(조각 없이)
88
71
  --tabs 크롬 탭 수(최대 ${MAX_TABS}). seek 모드는 구간을 나눠 찍고, step 모드는 탭마다 구간 앞까지 빨리 돌린 뒤 찍는다
89
72
  --music 기본은 페이지 meta.music(kf.json 의 music). 곡은 beats.json 의 from 초부터 얹는다
90
73
  --music-from 곡에서 시작할 초를 직접 준다(--music 으로 다른 곡을 줄 때)
91
74
  --crf H.264 화질(기본 16, 낮을수록 좋다)
92
- --motion-blur N 한 프레임을 하위 프레임 N 장(예 8)으로 찍어 평균 — 빠른 것만 번진다. 3D 전환은 같은 평균으로 피사계 심도도. seek 모드만, N 배 느리다
75
+ --motion-blur N 한 프레임을 하위 프레임 N 장(예 8)으로 찍어 평균 — 빠른 것만 번진다. 3D 전환은 같은 평균으로 피사계 심도도. seek 모드만, N 배 느리다.
76
+ 안 주면 kf.json 의 render.motionBlur(쇼츠·홍보 틀은 켜 둔다), 0 이면 끈다
93
77
  --shutter 모션 블러 셔터 각도(기본 180 — 프레임 간격의 절반). 360 이면 다음 프레임까지 이어 번진다
94
78
  --captions on|off|fit 화면 자막을 이 판만 켜거나 끈다(kf.json 의 captions 는 그대로). on 이면 captions.json 을 화면 아래 판에 얹고
95
79
  안전 영역 아래쪽 자막 띠를 비운다(무대가 그 위로 배치) · fit 은 장면을 띠 위로 줄여 올린다(픽셀을 박아 둔 장면용)
@@ -127,15 +111,21 @@ const cmd: Command = {
127
111
  const tabs = Math.max(1, Math.min(MAX_TABS, num(opts.tabs, MAX_TABS), Math.ceil(frames / fps)));
128
112
  const chunks = split(startF, endF, tabs);
129
113
  const music = format === "png" ? undefined : findMusic(dir, opts["no-music"] ? false : opts.music, meta.music);
130
- let blur = blurOpts(opts);
114
+ let blur = blurOpts(opts, projectBlur(resolve(dir)));
131
115
  if (blur && mode === "step") {
132
116
  console.error("모션 블러는 seek 모드만 — step 모드는 뒤로 못 가서 끈다");
133
117
  blur = null;
134
118
  }
135
119
  const songFrom = opts["music-from"] !== undefined ? num(opts["music-from"], 0) : opts.music ? 0 : musicFrom(resolve(dir));
136
120
 
121
+ // seek 모드 통짜 굽기는 장면 조각으로 — 바뀐 장면만 다시 찍고 잇는다. --no-segments 면 예전처럼 탭 몫으로 나눠 한 번에
122
+ const segmented = !opts["no-segments"] && mode === "seek" && format !== "png" && opts.from === undefined && opts.to === undefined;
123
+ const only = typeof opts.scenes === "string" ? opts.scenes.split(",").map((x) => x.trim()).filter(Boolean) : null;
124
+ if (only && !segmented) throw new Error("--scenes 는 seek 모드 영상 굽기에서만 — --from/--to·--png·--no-segments·step 모드와 같이 못 쓴다");
125
+ const crf = opts.crf ? num(opts.crf, 16) : undefined;
126
+
137
127
  console.error(
138
- `${meta.width}x${meta.height} ${fps}fps ${mode} 모드 · 프레임 ${startF}..${endF - 1} (${frames}장) · 탭 ${chunks.length}` +
128
+ `${meta.width}x${meta.height} ${fps}fps ${mode} 모드 · 프레임 ${startF}..${endF - 1} (${frames}장) · 탭 ${segmented ? tabs : chunks.length}` +
139
129
  (music ? ` · 곡 ${music}${songFrom ? ` (${songFrom}초부터)` : ""}` : "") +
140
130
  (blur ? ` · 모션 블러 ${blur.count}장·셔터 ${blur.shutter}°` : ""),
141
131
  );
@@ -143,38 +133,75 @@ const cmd: Command = {
143
133
  if (format === "png") mkdirSync(out, { recursive: true });
144
134
 
145
135
  let done = 0;
136
+ let todo = frames;
146
137
  const progress = setInterval(() => {
147
138
  const sec = (Date.now() - started) / 1000;
148
- console.error(` ${done}/${frames} (${((done / frames) * 100).toFixed(0)}%) ${(done / sec).toFixed(1)} 장/초`);
139
+ console.error(` ${done}/${todo} (${((done / Math.max(1, todo)) * 100).toFixed(0)}%) ${(done / sec).toFixed(1)} 장/초`);
149
140
  }, 5000);
150
141
 
151
- const segFiles: string[] = [];
152
- const usedTabs: Tab[] = [];
142
+ let segFiles: string[] = [];
143
+ let events: MotionEvent[] = [];
144
+ let segments: { rendered: string[]; reused: string[] } | undefined;
153
145
  try {
154
- await Promise.all(
155
- chunks.map(async (c) => {
156
- const tab: Tab = c.index === 0 ? s.first : await s.tab();
157
- usedTabs.push(tab);
158
- const seg = join(tmp, `seg-${String(c.index).padStart(2, "0")}${ext}`);
159
- segFiles[c.index] = seg;
160
- const raw = blur ? { width: meta.width, height: meta.height } : undefined;
161
- const enc = format === "png" ? null : encoder(seg, fps, format, opts.crf ? num(opts.crf, 16) : undefined, raw);
162
- for (let f = c.start; f < c.end; f++) {
163
- let frame: Buffer;
164
- if (blur) {
165
- const img = await captureBlurred(tab, s.info, f / fps, blur);
166
- frame = enc ? img.data : rgbaToPng(img);
167
- } else {
168
- await tab.seek(f / fps);
169
- frame = await tab.capture();
170
- }
171
- if (enc) await enc.write(frame);
172
- else writeFileSync(join(out, `${String(f).padStart(6, "0")}.png`), frame);
173
- done++;
146
+ if (segmented) {
147
+ const root = s.project.dir;
148
+ const keyOpts: KeyOpts = { width: meta.width, height: meta.height, fps, format, crf, blur, captions, browser: s.browser.version() };
149
+ const keys = sceneKeys(s.project.server, root, keyOpts);
150
+ const spans = sceneSpans(JSON.parse(readFileSync(join(root, "kf.json"), "utf8")).scenes, fps, total);
151
+ const missing = only?.filter((id) => !spans.some((sp) => sp.id === id)) ?? [];
152
+ if (missing.length) throw new Error(`kf.json 에 없는 장면: ${missing.join(", ")}`);
153
+ const reused = new Map<string, SegmentReport>();
154
+ const work: SegmentWork[] = [];
155
+ for (const span of only ? spans.filter((sp) => only.includes(sp.id)) : spans) {
156
+ const { key, inputs } = keys.get(span.id)!;
157
+ const hit = opts.fresh ? null : cachedSegment(root, span.id, key, ext);
158
+ if (!hit) work.push({ span, key, inputs });
159
+ else {
160
+ reused.set(span.id, hit);
161
+ console.error(`장면 ${span.id}: 그대로 — ${basename(hit.file)}`);
174
162
  }
175
- await enc?.end();
176
- }),
177
- );
163
+ }
164
+ todo = work.reduce((n, w) => n + w.span.end - w.span.start, 0);
165
+ console.error(`조각 ${work.length + reused.size} — 다시 찍기 ${work.length}(${todo}장) · 그대로 ${reused.size}`);
166
+ const made = work.length ? await renderSegments({ session: s, root, work, tabs, format, ext, crf, blur, tmp, onFrame: () => done++ }) : [];
167
+ const all = new Map<string, SegmentReport>([...reused, ...made.map((m) => [m.id, m] as const)]);
168
+ if (only) {
169
+ for (const id of only) console.log(all.get(id)!.file);
170
+ console.error(`조각만 ${((Date.now() - started) / 1000).toFixed(1)}초 — 잇기는 kf render ${dir}`);
171
+ return 0;
172
+ }
173
+ segFiles = spans.map((sp) => all.get(sp.id)!.file);
174
+ events = spans.flatMap((sp) => all.get(sp.id)!.events);
175
+ segments = { rendered: made.map((m) => m.id), reused: [...reused.keys()] };
176
+ pruneSegments(root, variantOf(keyOpts), Object.fromEntries(spans.map((sp) => [sp.id, keys.get(sp.id)!.key])));
177
+ } else {
178
+ const usedTabs: Tab[] = [];
179
+ await Promise.all(
180
+ chunks.map(async (c) => {
181
+ const tab: Tab = c.index === 0 ? s.first : await s.tab();
182
+ usedTabs.push(tab);
183
+ const seg = join(tmp, `seg-${String(c.index).padStart(2, "0")}${ext}`);
184
+ segFiles[c.index] = seg;
185
+ const raw = blur ? { width: meta.width, height: meta.height } : undefined;
186
+ const enc = format === "png" ? null : encoder(seg, fps, format, crf, raw);
187
+ for (let f = c.start; f < c.end; f++) {
188
+ let frame: Buffer;
189
+ if (blur) {
190
+ const img = await captureBlurred(tab, s.info, f / fps, blur);
191
+ frame = enc ? img.data : rgbaToPng(img);
192
+ } else {
193
+ await tab.seek(f / fps);
194
+ frame = await tab.capture();
195
+ }
196
+ if (enc) await enc.write(frame);
197
+ else writeFileSync(join(out, `${String(f).padStart(6, "0")}.png`), frame);
198
+ done++;
199
+ }
200
+ await enc?.end();
201
+ }),
202
+ );
203
+ events = await collectEvents(usedTabs);
204
+ }
178
205
  } finally {
179
206
  clearInterval(progress);
180
207
  }
@@ -182,7 +209,13 @@ const cmd: Command = {
182
209
  if (format !== "png") {
183
210
  const list = join(tmp, "list.txt");
184
211
  writeFileSync(list, segFiles.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n") + "\n");
185
- await concat({ list, out, format, music, from: songFrom + startF / fps, dur: frames / fps });
212
+ // kf.json 에 audio(섞기 값)가 있으면 내레이션·곡을 섞은 소리를 얹는다(덕킹·끝 페이드·LUFS) — 없으면 곡만
213
+ const mix = opts["no-music"] ? null : mixSettings(resolve(dir));
214
+ const mixed = mix && (mix.voice || music)
215
+ ? await mixAudio({ voice: mix.voice, music, musicFrom: songFrom }, { audio: mix.audio, from: startF / fps, dur: frames / fps, total: meta.duration, out: join(tmp, "mix.wav") })
216
+ : undefined;
217
+ await concat({ list, out, format, music: mixed ?? music, from: mixed ? 0 : songFrom + startF / fps, dur: frames / fps });
218
+ if (mixed) console.error(`소리: ${mix!.voice ? "내레이션" : ""}${mix!.voice && music ? " + " : ""}${music ? "곡" : ""} → ${mix!.audio.lufs} LUFS`);
186
219
  }
187
220
  const sec = (Date.now() - started) / 1000;
188
221
  const report = {
@@ -192,18 +225,19 @@ const cmd: Command = {
192
225
  from: startF / fps,
193
226
  to: endF / fps,
194
227
  frames,
195
- tabs: chunks.length,
228
+ tabs: segmented ? tabs : chunks.length,
196
229
  format,
197
230
  motionBlur: blur,
198
231
  captions: captions ?? null,
199
232
  music: music ?? null,
200
233
  seconds: Math.round(sec * 10) / 10,
234
+ ...(segments ? { segments } : {}),
201
235
  markers: s.info.markers,
202
- events: await collectEvents(usedTabs),
236
+ events,
203
237
  };
204
238
  const reportFile = format === "png" ? join(out, "render.json") : out.replace(/\.[^.]+$/, ".json");
205
239
  writeFileSync(reportFile, JSON.stringify(report, null, 1));
206
- console.error(`${frames}장 ${sec.toFixed(1)}초 (${(frames / sec).toFixed(1)} 장/초) · 보고서 ${reportFile}`);
240
+ console.error(`${todo}장 ${sec.toFixed(1)}초 (${(todo / sec).toFixed(1)} 장/초)${segments ? ` · 조각 다시 ${segments.rendered.length}·그대로 ${segments.reused.length}` : ""} · 보고서 ${reportFile}`);
207
241
  console.log(out);
208
242
  return 0;
209
243
  } finally {