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,241 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { type Choice, type Question, WAIT } from "../ask.ts";
4
+ import { findSilences, voicedSpan } from "../audio.ts";
5
+ import type { CapabilityProvider } from "../capabilities.ts";
6
+ import { cacheDir, sha256 } from "../generate.ts";
7
+ import { howToGet, whereToPut } from "../keys.ts";
8
+ import { dashscopeTts } from "./dashscope.ts";
9
+ import { elevenlabsTts } from "./elevenlabs.ts";
10
+ import { geminiTts } from "./gemini.ts";
11
+ import { localTts } from "./local.ts";
12
+ import type { SpeechRequest, TtsProvider, Word } from "./types.ts";
13
+
14
+ export type { SpeechRaw, SpeechRequest, TtsProvider, Voice, Word } from "./types.ts";
15
+ export { charsToWords } from "./elevenlabs.ts";
16
+
17
+ // 고르는 순서 — 낱말 시각을 직접 주는 ElevenLabs 먼저, 그다음 싸고 실호출로 확인한 Gemini
18
+ export function ttsProviders(): TtsProvider[] {
19
+ return [elevenlabsTts(), geminiTts(), dashscopeTts(), localTts()];
20
+ }
21
+
22
+ export function pickTts(engine: string): TtsProvider {
23
+ const p = ttsProviders().find((x) => x.id === engine);
24
+ if (!p) throw new Error(`목소리 엔진 없음: ${engine} (있는 것: ${ttsProviders().map((x) => x.id).join(" ")})`);
25
+ return p;
26
+ }
27
+
28
+ // "gemini:Charon" → 엔진·목소리. "none" 이면 목소리 없이 자막만. 목소리를 빼면 그 엔진 기본
29
+ export function parseVoice(v: string): { engine: string; voice: string } | null {
30
+ if (!v || v === "none") return null;
31
+ const i = v.indexOf(":");
32
+ const engine = i < 0 ? v : v.slice(0, i);
33
+ const p = pickTts(engine);
34
+ return { engine, voice: i < 0 ? p.defaultVoice : v.slice(i + 1) };
35
+ }
36
+
37
+ // 차분하고 낮은 낭독 — 용도 틀의 분위기(deep)와 같은 결
38
+ export const DEFAULT_STYLE = "차분하고 낮은 다큐멘터리 내레이션. 서두르지 않고 문장 끝을 내려 놓는다";
39
+
40
+ export interface Speech {
41
+ path: string;
42
+ words: Word[];
43
+ voiced: { from: number; to: number }; // 앞뒤 무음을 뺀 말 구간(조각 기준 초)
44
+ duration: number;
45
+ engine: string;
46
+ model: string;
47
+ voice: string;
48
+ costUsd: number;
49
+ costEstimated: boolean;
50
+ license: string;
51
+ cached: boolean;
52
+ }
53
+
54
+ // 문장 하나를 읽혀 outDir/<id>.wav|mp3 + <id>.json(낱말 시각). 같은 글·목소리·모델은 캐시에서 0원
55
+ export async function speak(id: string, req: SpeechRequest, ctx: { outDir: string; cacheDir?: string; log?: (l: string) => void }, engine: string): Promise<Speech> {
56
+ const p = pickTts(engine);
57
+ if (!p.ready()) throw new Error(`${p.label} 를 못 쓴다 — ${whereToPut(p.keyName)}`);
58
+ const key = sha256(JSON.stringify({ kind: "tts", provider: p.id, model: p.model, text: req.text, voice: req.voice, lang: req.lang ?? null, style: req.style ?? null }));
59
+ const dir = cacheDir(ctx);
60
+ mkdirSync(dir, { recursive: true });
61
+ mkdirSync(ctx.outDir, { recursive: true });
62
+ const metaPath = join(dir, `${key}.json`);
63
+ let cached = existsSync(metaPath);
64
+ let meta: Omit<Speech, "path" | "cached"> & { file: string };
65
+ if (cached) meta = JSON.parse(readFileSync(metaPath, "utf8"));
66
+ if (!cached || !existsSync(join(dir, meta!.file))) {
67
+ cached = false;
68
+ ctx.log?.(`목소리 ${id}: ${p.id} ${req.voice} 견적 $${p.estimateUsd(req.text).toFixed(4)}`);
69
+ const raw = await retryBusy(() => p.synth(req), { log: ctx.log });
70
+ const file = `${key}.${raw.ext}`;
71
+ writeFileSync(join(dir, file), raw.bytes);
72
+ const span = await voicedSpan(join(dir, file));
73
+ // 공급자가 준 시각만 캐시에 — 숨 자리로 맞춘 시각은 꺼낼 때마다 다시 맞춰 맞추는 법이 나아지면 캐시 소리에도 먹는다
74
+ meta = { file, words: raw.words ?? [], voiced: { from: span.from, to: span.to }, duration: span.total, engine: p.id, model: raw.model, voice: req.voice, costUsd: Number(raw.costUsd.toFixed(5)), costEstimated: raw.costEstimated, license: p.license };
75
+ writeFileSync(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
76
+ }
77
+ if (!meta!.words.length || !p.timestamps) meta!.words = await alignWords(req.text, join(dir, meta!.file), meta!.voiced);
78
+ const ext = meta!.file.split(".").pop()!;
79
+ const path = join(ctx.outDir, `${id}.${ext}`);
80
+ copyFileSync(join(dir, meta!.file), path);
81
+ const { file: _f, ...rest } = meta!;
82
+ writeFileSync(join(ctx.outDir, `${id}.json`), `${JSON.stringify({ id, text: req.text, ...rest, cached }, null, 2)}\n`);
83
+ return { ...rest, path, cached };
84
+ }
85
+
86
+ // kf narrate 는 줄을 동시에 읽힌다 — 분당 호출 수가 빡빡한 키(Gemini 무료 등급 등)는 429 를 낸다. 물러섰다 다시, 다른 실패는 바로
87
+ const BUSY = new Set([429, 503]);
88
+ export async function retryBusy<T>(fn: () => Promise<T>, o: { waits?: number[]; log?: (l: string) => void; sleep?: (ms: number) => Promise<void> } = {}): Promise<T> {
89
+ const waits = o.waits ?? [5000, 10000, 20000, 40000];
90
+ const sleep = o.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
91
+ for (let i = 0; ; i++) {
92
+ try {
93
+ return await fn();
94
+ } catch (e) {
95
+ const status = (e as { status?: number }).status;
96
+ if (!status || !BUSY.has(status) || i >= waits.length) throw e;
97
+ o.log?.(`목소리: 공급자가 바쁨(${status}) — ${waits[i] / 1000}초 뒤 다시`);
98
+ await sleep(waits[i]);
99
+ }
100
+ }
101
+ }
102
+
103
+ const spoken = (s: string) => Math.max(1, s.replace(/[\s,.?!…:;·"'“”‘’()\-]/g, "").length);
104
+
105
+ // 마디 경계(글자 비율로 어림한 자리)를 숨에 순서대로 짝짓는다 — 하나씩 가까운 숨을 고르면 앞에서 어긋난 어림이 뒤까지 끈다.
106
+ // 짝 비용 = 거리 - 긴 숨 덤, 짝 없는 경계는 SKIP(어림 자리 그대로). 경계에 안 쓰인 숨(마디 안 숨)은 비용 없다
107
+ const SKIP = 1.0;
108
+ export function matchPauses(expect: number[], pauses: { from: number; to: number }[]): number[] {
109
+ const k = expect.length;
110
+ const m = pauses.length;
111
+ const cost = Array.from({ length: k + 1 }, () => new Array<number>(m + 1).fill(Infinity));
112
+ const back = Array.from({ length: k + 1 }, () => new Array<0 | 1 | 2>(m + 1).fill(0));
113
+ for (let j = 0; j <= m; j++) cost[0][j] = 0;
114
+ for (let i = 1; i <= k; i++) {
115
+ for (let j = 0; j <= m; j++) {
116
+ // 0: 경계 i 를 짝 없이 · 1: 숨 j 를 안 쓰고 · 2: 경계 i ↔ 숨 j
117
+ let best = cost[i - 1][j] + SKIP;
118
+ let how: 0 | 1 | 2 = 0;
119
+ if (j > 0 && cost[i][j - 1] < best) {
120
+ best = cost[i][j - 1];
121
+ how = 1;
122
+ }
123
+ if (j > 0) {
124
+ const p = pauses[j - 1];
125
+ const c = cost[i - 1][j - 1] + Math.abs((p.from + p.to) / 2 - expect[i - 1]) - 0.5 * Math.min(p.to - p.from, 0.6);
126
+ if (c < best) {
127
+ best = c;
128
+ how = 2;
129
+ }
130
+ }
131
+ cost[i][j] = best;
132
+ back[i][j] = how;
133
+ }
134
+ }
135
+ const out = new Array<number>(k).fill(-1);
136
+ for (let i = k, j = m; i > 0; ) {
137
+ const how = back[i][j];
138
+ if (how === 2) out[--i] = --j;
139
+ else if (how === 1) j--;
140
+ else i--;
141
+ }
142
+ return out;
143
+ }
144
+
145
+ // 시각을 안 주는 공급자 — 쉼표·마침표 자리를 말 사이 숨(무음)에 붙이고, 마디 안 낱말은 글자 수 비율로 편다
146
+ export async function alignWords(text: string, path: string, span?: { from: number; to: number }): Promise<Word[]> {
147
+ const tokens = text.trim().split(/\s+/).filter(Boolean);
148
+ if (!tokens.length) return [];
149
+ const { from: t0, to: t1 } = span ?? (await voicedSpan(path));
150
+ const total = tokens.reduce((a, w) => a + spoken(w), 0);
151
+ // 마디 끝 = 문장부호로 끝나는 어절(마지막 어절은 늘 끝)
152
+ const ends = tokens.map((w, i) => i < tokens.length - 1 && /[,.?!…:;]$/.test(w)).flatMap((e, i) => (e ? [i] : []));
153
+ const pauses = (await findSilences(path, { db: -38, min: 0.12 })).filter((s) => s.from > t0 + 0.05 && s.to < t1 - 0.05);
154
+ // 마디 경계의 글자 비율 자리
155
+ const expect: number[] = [];
156
+ let acc = 0;
157
+ for (let i = 0, k = 0; i < tokens.length && k < ends.length; i++) {
158
+ acc += spoken(tokens[i]);
159
+ if (i === ends[k]) {
160
+ expect.push(t0 + ((t1 - t0) * acc) / total);
161
+ k++;
162
+ }
163
+ }
164
+ const bounds = matchPauses(expect, pauses).map((j, i) => ({ at: ends[i], end: j < 0 ? expect[i] : pauses[j].from, start: j < 0 ? expect[i] : pauses[j].to }));
165
+ const out: Word[] = [];
166
+ let start = t0;
167
+ let first = 0;
168
+ for (const b of [...bounds, { at: tokens.length - 1, end: t1, start: t1 }]) {
169
+ const seg = tokens.slice(first, b.at + 1);
170
+ const n = seg.reduce((a, w) => a + spoken(w), 0);
171
+ let t = start;
172
+ for (const w of seg) {
173
+ const d = ((b.end - start) * spoken(w)) / n;
174
+ out.push({ text: w, from: +t.toFixed(3), to: +(t + d).toFixed(3) });
175
+ t += d;
176
+ }
177
+ start = b.start;
178
+ first = b.at + 1;
179
+ }
180
+ return out;
181
+ }
182
+
183
+ // 묻기 틀(ask.ts)에 얹는 목소리 질문. 보기 차례는 로컬 Qwen3(KF_QWEN3_TTS_URL 이 있을 때) · ElevenLabs · 자막만 · 그 밖 엔진 — 첫째가 권장.
184
+ // 키 없는 ElevenLabs 는 넣는 법을 붙인 보기(wait)로 서고, 로컬도 ElevenLabs 도 없으면 그 밖 엔진으로 조용히 넘어가지 않게 missing.
185
+ // voices 는 틀(template.json narration.voices)이 엔진마다 박은 목소리 — 같은 틀이면 늘 같은 목소리. chars 는 견적용 대본 글자 수(모르면 쇼츠 한 편 300자)
186
+ // ready 는 쓸 수 있는 엔진 id(생성 능력 표에서) — 안 주면 키를 직접 본다
187
+ export function voiceQuestion(o: { chars?: number; voices?: Record<string, string>; ready?: string[] } = {}): Question {
188
+ const sample = "가".repeat(o.chars ?? 300);
189
+ const all = ttsProviders();
190
+ const ready = new Set(all.filter((p) => (o.ready ? o.ready.includes(p.id) : p.ready())).map((p) => p.id));
191
+ const note = (p: TtsProvider) => [p.verified ? "" : "실호출 미검증", p.timestamps ? "낱말 시각 정확" : "낱말 시각은 숨 자리로 맞춤"].filter(Boolean).join(" · ");
192
+ const pick = (p: TtsProvider): Choice => {
193
+ const v = p.voices.find((x) => x.id === (o.voices?.[p.id] ?? p.defaultVoice)) ?? { id: o.voices?.[p.id] ?? p.defaultVoice, label: "틀이 정한 목소리" };
194
+ return { value: `${p.id}:${v.id}`, label: `${p.label} ${v.id} — ${v.label}`, costUsd: p.estimateUsd(sample), note: note(p) };
195
+ };
196
+ const local = all.find((p) => p.id === "local")!;
197
+ const eleven = all.find((p) => p.id === "elevenlabs")!;
198
+ const head = ready.has("local") ? [pick(local)] : [];
199
+ const choices = [
200
+ ...head,
201
+ ready.has("elevenlabs") ? pick(eleven) : { value: WAIT, label: `${eleven.label} 키를 넣고 다시`, note: howToGet(eleven.keyName) },
202
+ { value: "none", label: "목소리 없이 — 자막만", costUsd: 0 },
203
+ ...all.filter((p) => p.id !== "local" && p.id !== "elevenlabs" && ready.has(p.id)).map(pick),
204
+ ].slice(0, 4);
205
+ const missing = !ready.has("local") && !ready.has("elevenlabs");
206
+ return {
207
+ id: "voice",
208
+ cap: "voice",
209
+ ask: missing ? "내레이션 목소리 — 로컬 Qwen3(KF_QWEN3_TTS_URL)도 ElevenLabs 키도 없다" : "내레이션 목소리는?",
210
+ header: "목소리",
211
+ choices,
212
+ default: choices[0].value,
213
+ skip: head[0]?.value ?? "none",
214
+ flag: "--voice",
215
+ ...(missing ? { missing: true } : {}),
216
+ accepts: (v: string) => {
217
+ try {
218
+ const engine = parseVoice(v)?.engine;
219
+ return !engine || (o.ready ? o.ready.includes(engine) : pickTts(engine).ready());
220
+ } catch {
221
+ return false;
222
+ }
223
+ },
224
+ };
225
+ }
226
+
227
+ // 생성 능력 표(capabilities)의 목소리 칸
228
+ export function voiceCapabilities(): CapabilityProvider[] {
229
+ const sample = "가".repeat(300);
230
+ return ttsProviders().map((p) => ({
231
+ cap: "voice",
232
+ id: p.id,
233
+ label: `${p.label} ${p.model}`,
234
+ ready: p.ready(),
235
+ detail: p.ready() ? `${p.id === "local" ? "주소 있음" : "키 있음"}(키만 봄)${p.verified ? "" : " · 실호출 미검증"} · 목소리 ${p.voices.map((v) => v.id).join("·")}` : p.id === "local" ? "KF_QWEN3_TTS_URL 없음" : "키 없음",
236
+ fix: p.ready() ? undefined : p.id === "local" ? "메모리 넉넉한 기계에 tools/tts/qwen3_tts_server.py → KF_QWEN3_TTS_URL=<주소>" : whereToPut(p.keyName),
237
+ unitUsd: p.estimateUsd(sample),
238
+ unit: "쇼츠 한 편 대본(300자)",
239
+ license: p.license,
240
+ }));
241
+ }
@@ -0,0 +1,42 @@
1
+ import { findKey } from "../keys.ts";
2
+ import type { TtsProvider } from "./types.ts";
3
+
4
+ // 로컬 Qwen3-TTS(공개 가중치 Apache-2.0)는 이 기계에서 바로 띄우지 않는다 — 1.7B 를 여러 칸이 같이 쓰는 노트북에 올리면 메모리가 넘친다.
5
+ // 메모리 넉넉한 기계에 tools/tts/qwen3_tts_server.py 를 띄우고 KF_QWEN3_TTS_URL 로 주소만 준다. 설정돼 있을 때만 보기에 나온다
6
+ const VOICES = [
7
+ { id: "Sohee", label: "여 · 한국어 화자" },
8
+ { id: "Ryan", label: "남 · 리듬감 있는" },
9
+ { id: "Serena", label: "여 · 부드러운" },
10
+ { id: "Aiden", label: "남 · 밝은" },
11
+ { id: "Vivian", label: "여 · 또렷한" },
12
+ ];
13
+ const LANG: Record<string, string> = { ko: "Korean", en: "English", ja: "Japanese", zh: "Chinese" };
14
+
15
+ export function localTts(): TtsProvider {
16
+ return {
17
+ id: "local",
18
+ label: "로컬 Qwen3-TTS",
19
+ model: process.env.KF_QWEN3_TTS_MODEL ?? "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
20
+ keyName: "qwen3-tts",
21
+ license: "Qwen3-TTS 가중치 Apache-2.0 — 출력물 상업 가능",
22
+ region: "자체 호스팅",
23
+ timestamps: false,
24
+ verified: false,
25
+ voices: VOICES,
26
+ defaultVoice: "Sohee",
27
+ ready: () => findKey("qwen3-tts") !== null,
28
+ estimateUsd: () => 0,
29
+
30
+ async synth(o) {
31
+ const url = findKey("qwen3-tts")!.replace(/\/+$/, "");
32
+ const r = await fetch(`${url}/tts`, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({ text: o.text, speaker: o.voice, language: LANG[o.lang ?? ""] ?? "Auto", instruct: o.style ?? "" }),
36
+ signal: AbortSignal.timeout(10 * 60_000),
37
+ });
38
+ if (!r.ok) throw new Error(`로컬 Qwen3-TTS ${r.status}: ${(await r.text()).slice(0, 200)}`);
39
+ return { bytes: new Uint8Array(await r.arrayBuffer()), ext: "wav", model: this.model, costUsd: 0, costEstimated: false, params: { voice: o.voice } };
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,44 @@
1
+ // 낱말 하나와 그 시각(조각 기준 초) — 자막이 이 시각 그대로 끊긴다
2
+ export interface Word {
3
+ text: string;
4
+ from: number;
5
+ to: number;
6
+ }
7
+
8
+ export interface Voice {
9
+ id: string; // --voice <엔진>:<id> 의 id
10
+ label: string; // 사람이 고르는 한 줄(성별·결)
11
+ }
12
+
13
+ export interface SpeechRequest {
14
+ text: string;
15
+ voice: string;
16
+ lang?: string; // "ko" 같은 ISO 639-1 — 공급자 꼴로 바꿔 보낸다
17
+ style?: string; // 「차분하고 낮게」 같은 연기 지시 — 받는 공급자만 쓴다
18
+ }
19
+
20
+ export interface SpeechRaw {
21
+ bytes: Uint8Array;
22
+ ext: "wav" | "mp3";
23
+ model: string;
24
+ words?: Word[]; // 공급자가 시각을 주면(ElevenLabs with-timestamps). 없으면 무음 자리로 맞춘다
25
+ costUsd: number;
26
+ costEstimated: boolean;
27
+ params: Record<string, unknown>;
28
+ }
29
+
30
+ export interface TtsProvider {
31
+ id: string; // --voice 의 엔진 이름
32
+ label: string;
33
+ model: string;
34
+ keyName: string;
35
+ license: string;
36
+ region: string;
37
+ timestamps: boolean; // 낱말 시각을 직접 주는지
38
+ verified: boolean; // 실호출로 확인했는지 — 아니면 고르는 글에 적는다
39
+ voices: Voice[];
40
+ defaultVoice: string;
41
+ ready(): boolean;
42
+ estimateUsd(text: string): number;
43
+ synth(o: SpeechRequest): Promise<SpeechRaw>;
44
+ }
@@ -16,6 +16,7 @@ export interface AssetMeta {
16
16
  elapsedMs: number;
17
17
  license: string;
18
18
  region: string;
19
+ restrict?: string; // 회사·바깥 결과물에 걸리는 제한(공급자의 restrict) — kf make --finish 가 결과 메타로 모은다
19
20
  cacheKey: string;
20
21
  createdAt: string;
21
22
  width?: number;
@@ -68,11 +69,14 @@ export interface ProviderInfo {
68
69
  license: string;
69
70
  region: string;
70
71
  keyName: string;
72
+ restrict?: string; // 회사·바깥 결과물에 걸리는 라이선스·지역 제한 — 묻는 글·자산 메타에 그대로 남는다
73
+ available?(): boolean; // 키 말고 로그인·실행 파일로 서는 공급자(codex) — 없으면 keyName 의 키 유무
71
74
  variant?: Record<string, unknown>; // 같은 모델이라도 결과를 바꾸는 서버 설정(걸음 수 등) — 캐시 키에 들어간다
72
75
  }
73
76
 
74
77
  export interface ImageProvider extends ProviderInfo {
75
78
  kind: "img";
79
+ estimateUsd?(o: ImageRequest): number; // 없으면 OG 토큰 단가표
76
80
  generate(o: ImageRequest): Promise<RawResult>;
77
81
  edit?(o: ImageRequest): Promise<RawResult>;
78
82
  }
@@ -45,6 +45,10 @@ export function h3Body(o: { prompt: string; firstFrame?: string; lastFrame?: str
45
45
  };
46
46
  }
47
47
 
48
+ // 공개 가중치 라이선스가 한국·미국·EU·영국을 제외 지역으로 두고 산출물에도 건다 — 사내 파드로 돌려도 같다(API 판은 전 세계 가능)
49
+ export const H3_RESTRICT =
50
+ "MiniMax H3 공개 가중치는 한국 제외 지역 — 회사·바깥 결과물엔 쓰지 않는다(시험·사내 보기만). 밖에 낼 영상은 minimax-h3 API(전 세계 가능)나 다른 공급자로";
51
+
48
52
  export function h3Video(): VideoProvider {
49
53
  const { steps, shortEdge, flowShift, extra } = settings();
50
54
  return {
@@ -54,6 +58,7 @@ export function h3Video(): VideoProvider {
54
58
  model: "MiniMaxAI/MiniMax-H3",
55
59
  license: "공개 가중치 — 지역 제한 확인 필요(MiniMax H3 커뮤니티 라이선스는 한국·미국·EU·영국을 제외 지역으로 둔다)",
56
60
  region: "사내 GPU 파드 자체 호스팅 — 한국 사용은 MiniMax 라이선스 신청 여부 확인 필요",
61
+ restrict: H3_RESTRICT,
57
62
  keyName: "h3",
58
63
  durations: DURATIONS,
59
64
  firstLast: true,
@@ -5,7 +5,7 @@ export async function json(url: string, init: RequestInit, who: string): Promise
5
5
  const text = await r.text();
6
6
  if (!r.ok) {
7
7
  const hint = r.status === 402 || r.status === 403 ? " (잔액·권한을 확인)" : "";
8
- throw new Error(`${who} ${r.status}${hint}: ${text.slice(0, 300)}`);
8
+ throw Object.assign(new Error(`${who} ${r.status}${hint}: ${text.slice(0, 300)}`), { status: r.status });
9
9
  }
10
10
  return text ? JSON.parse(text) : {};
11
11
  }
@@ -1,5 +1,5 @@
1
- export { jizuraLayer, jizuraPlans, jizuraReady, jizuraCuts, loadJizura } from "./layer.ts";
2
- export type { DrawMode, JizuraPaint, LayerOptions, PlanSettings } from "./layer.ts";
1
+ export { jizuraLayer, jizuraPlans, jizuraReady, jizuraCuts, loadJizura, lyricsMode } from "./layer.ts";
2
+ export type { DrawMode, JizuraPaint, LayerOptions, LyricsMode, PlanSettings } from "./layer.ts";
3
3
  export { DENY, partPool } from "./deny.ts";
4
4
  export type { PartGroup, PoolOptions } from "./deny.ts";
5
5
  export { toLrc, parseLyrics, wordsOf } from "./lyrics.ts";