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,41 @@
1
+ import { hasKey, readKey } from "../keys.ts";
2
+ import type { MusicProvider } from "./types.ts";
3
+
4
+ // 키가 없어 실호출로 검증하지 못했다 — 모양은 ElevenLabs API 문서(2026-10, music/compose) 그대로. 200 이면 소리 파일 날것이 온다
5
+ const BASE = process.env.KF_ELEVENLABS_BASE ?? "https://api.elevenlabs.io";
6
+ const MODEL = process.env.KF_ELEVENLABS_MUSIC_MODEL ?? "music_v2_5";
7
+ // 1분에 $0.15 (elevenlabs.io/pricing/api)
8
+ const PER_MIN = 0.15;
9
+
10
+ export function elevenlabsMusic(): MusicProvider {
11
+ return {
12
+ id: "elevenlabs-music",
13
+ label: "Eleven Music",
14
+ model: MODEL,
15
+ keyName: "elevenlabs",
16
+ license: "Eleven Music 약관 — 유료 요금제는 온·오프라인 상업 가능(영화·TV·라디오는 Enterprise Music 만), 무료는 출처 표기·내려받기 불가",
17
+ region: "전 세계 API(유료 요금제)",
18
+ verified: false,
19
+ maxSec: 300,
20
+ ready: () => hasKey("elevenlabs"),
21
+ estimateUsd: (sec) => (sec / 60) * PER_MIN,
22
+
23
+ async compose(o) {
24
+ const ms = Math.min(600_000, Math.max(3000, Math.ceil(o.duration * 1000)));
25
+ const r = await fetch(`${BASE}/v1/music?output_format=mp3_44100_128`, {
26
+ method: "POST",
27
+ headers: { "xi-api-key": readKey("elevenlabs"), "Content-Type": "application/json" },
28
+ body: JSON.stringify({ prompt: o.prompt, music_length_ms: ms, model_id: MODEL, force_instrumental: true }),
29
+ });
30
+ if (!r.ok) throw new Error(`Eleven Music ${r.status}: ${(await r.text()).slice(0, 300)}`);
31
+ return {
32
+ bytes: new Uint8Array(await r.arrayBuffer()),
33
+ ext: "mp3",
34
+ model: MODEL,
35
+ costUsd: (ms / 60000) * PER_MIN,
36
+ costEstimated: true,
37
+ params: { music_length_ms: ms, force_instrumental: true },
38
+ };
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,120 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Question } from "../ask.ts";
4
+ import { duration as audioDuration } from "../audio.ts";
5
+ import type { CapabilityProvider } from "../capabilities.ts";
6
+ import { cacheDir, sha256 } from "../generate.ts";
7
+ import { whereToPut } from "../keys.ts";
8
+ import { elevenlabsMusic } from "./elevenlabs.ts";
9
+ import { byMood, MOOD_PROMPT } from "./library.ts";
10
+ import { lyriaMusic } from "./lyria.ts";
11
+ import type { MusicProvider } from "./types.ts";
12
+
13
+ export type { MusicProvider, MusicRaw, MusicRequest } from "./types.ts";
14
+ export { addToLibrary, byMood, listLibrary, MOOD_PROMPT, MOOD_TAGS, musicDir, type Track } from "./library.ts";
15
+
16
+ // 고르는 순서 — 이미 쓰는 Gemini 키 하나로 되는 Lyria 3.5 먼저(곡 하나 $0.08), 그다음 Eleven Music
17
+ export function musicProviders(): MusicProvider[] {
18
+ return [lyriaMusic("lyria-3.5"), elevenlabsMusic(), lyriaMusic("lyria-3-clip-preview")];
19
+ }
20
+
21
+ export function pickMusic(id?: string): MusicProvider {
22
+ const all = musicProviders();
23
+ if (id) {
24
+ const p = all.find((x) => x.id === id);
25
+ if (!p) throw new Error(`음악 공급자 없음: ${id} (있는 것: ${all.map((x) => x.id).join(" ")})`);
26
+ return p;
27
+ }
28
+ const p = all.find((x) => x.ready());
29
+ if (!p) throw new Error("키가 있는 음악 공급자가 없다 — Gemini 키(Lyria) 또는 ElevenLabs 키");
30
+ return p;
31
+ }
32
+
33
+ // 길이를 몇 칸으로 묶는다 — 내레이션이 1–2초 달라도 같은 프롬프트·같은 캐시라 시리즈마다 같은 곡이 깔린다
34
+ const BUCKETS = [30, 45, 60, 90, 120, 180, 240];
35
+ export function musicLength(sec: number): number {
36
+ return BUCKETS.find((b) => b >= sec + 2) ?? Math.ceil((sec + 2) / 30) * 30;
37
+ }
38
+
39
+ export interface MusicAsset {
40
+ path: string;
41
+ provider: string;
42
+ model: string;
43
+ mood: string;
44
+ seconds: number;
45
+ prompt: string;
46
+ costUsd: number;
47
+ license: string;
48
+ notes: string[];
49
+ cached: boolean;
50
+ }
51
+
52
+ // 분위기·길이로 곡 하나 → outDir/<id>.mp3 + <id>.json. 같은 분위기·같은 길이 칸·같은 공급자면 캐시에서 0원(같은 곡)
53
+ export async function genMusic(id: string, o: { mood: string; duration: number; prompt?: string }, ctx: { outDir: string; cacheDir?: string; log?: (l: string) => void }, providerId?: string): Promise<MusicAsset> {
54
+ const p = pickMusic(providerId);
55
+ if (!p.ready()) throw new Error(`${p.label} 를 못 쓴다 — ${whereToPut(p.keyName)}`);
56
+ const seconds = Math.min(p.maxSec, musicLength(o.duration));
57
+ const prompt = o.prompt ?? MOOD_PROMPT[o.mood] ?? MOOD_PROMPT.deep;
58
+ const key = sha256(JSON.stringify({ kind: "music", provider: p.id, model: p.model, prompt, seconds }));
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 meta: Omit<MusicAsset, "path" | "cached"> & { file: string };
64
+ let cached = existsSync(metaPath);
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} ${seconds}초 견적 $${p.estimateUsd(seconds).toFixed(3)} — 생성 중(수십 초)`);
69
+ const raw = await p.compose({ prompt, duration: seconds });
70
+ const file = `${key}.${raw.ext}`;
71
+ writeFileSync(join(dir, file), raw.bytes);
72
+ const got = await audioDuration(join(dir, file));
73
+ meta = { file, provider: p.id, model: raw.model, mood: o.mood, seconds: +got.toFixed(2), prompt, costUsd: raw.costUsd, license: p.license, notes: raw.notes ?? [] };
74
+ writeFileSync(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
75
+ }
76
+ const ext = meta!.file.split(".").pop()!;
77
+ const path = join(ctx.outDir, `${id}.${ext}`);
78
+ copyFileSync(join(dir, meta!.file), path);
79
+ const { file: _f, ...rest } = meta!;
80
+ writeFileSync(join(ctx.outDir, `${id}.json`), `${JSON.stringify({ id, ...rest, cached }, null, 2)}\n`);
81
+ return { ...rest, path, cached };
82
+ }
83
+
84
+ // 묻기 틀(ask.ts)에 얹는 곡 질문. 분위기 맞는 곡 모음 곡(0원) → 생성 → 없음. 곡 모음 곡이 있으면 그게 권장이고 돈 안 드는 길
85
+ // ready 는 쓸 수 있는 생성 공급자 id(생성 능력 표에서) — 안 주면 키를 직접 본다
86
+ export function musicQuestion(o: { mood: string; duration: number; ready?: string[] }): Question {
87
+ const lib = byMood(o.mood).slice(0, 2);
88
+ const gen = musicProviders().find((p) => (o.ready ? o.ready.includes(p.id) : p.ready()) && p.id !== "lyria-3-clip-preview");
89
+ const seconds = musicLength(o.duration);
90
+ const choices: Question["choices"] = lib.map((t) => ({ value: t.path, label: `곡 모음 「${t.title}」 — ${t.mood.join("·")}`, costUsd: 0, note: t.license }));
91
+ if (gen) choices.push({ value: `gen:${gen.id}`, label: `${gen.label} 로 「${o.mood}」 곡 ${seconds}초 생성`, costUsd: gen.estimateUsd(seconds), note: `${gen.license}${gen.verified ? "" : " · 실호출 미검증"}` });
92
+ choices.push({ value: "none", label: "음악 없이", costUsd: 0 });
93
+ const free = lib[0]?.path ?? "none";
94
+ return {
95
+ id: "music",
96
+ cap: "music",
97
+ ask: lib.length || gen ? `깔 음악은?(분위기 ${o.mood} — 직접 넣으려면 --music <파일>)` : `분위기 ${o.mood} 에 맞는 곡이 곡 모음에 없고 음악 생성 키도 없다 — 음악 없이 갈까?(직접 넣으려면 --music <파일>)`,
98
+ header: "음악",
99
+ choices: choices.slice(0, 4),
100
+ default: choices[0].value,
101
+ skip: free,
102
+ flag: "--music",
103
+ accepts: (v: string) => v === "none" || (v.startsWith("gen:") ? musicProviders().some((p) => p.id === v.slice(4) && p.ready()) : existsSync(v)),
104
+ };
105
+ }
106
+
107
+ // 생성 능력 표의 음악 칸(생성 공급자만 — 곡 모음 줄은 capabilities.ts 가 센다)
108
+ export function musicGenCapabilities(): CapabilityProvider[] {
109
+ return musicProviders().map((p) => ({
110
+ cap: "music",
111
+ id: p.id,
112
+ label: `${p.label}(${p.model})`,
113
+ ready: p.ready(),
114
+ detail: p.ready() ? `키 있음(키만 봄)${p.verified ? "" : " · 실호출 미검증"}` : "키 없음",
115
+ fix: p.ready() ? undefined : whereToPut(p.keyName),
116
+ unitUsd: p.estimateUsd(60),
117
+ unit: "곡 하나(60초)",
118
+ license: p.license,
119
+ }));
120
+ }
@@ -0,0 +1,73 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // 곡 모음 — 파일 옆 tags.json 이 곡마다 분위기·원본·라이선스를 쥔다. 사람용 표는 README.md 에 같은 줄로
6
+ export interface Track {
7
+ file: string;
8
+ path: string;
9
+ title: string;
10
+ mood: string[];
11
+ genre?: string;
12
+ bpm?: number;
13
+ source: string;
14
+ license: string;
15
+ }
16
+
17
+ const AUDIO = /\.(mp3|wav|m4a|aac|flac|ogg)$/i;
18
+
19
+ export const musicDir = (): string => process.env.KF_MUSIC_DIR || fileURLToPath(new URL("../../../../music/", import.meta.url));
20
+
21
+ // 손으로 쓴 tags.json 은 칸이 빠질 수 있다 — 빠진 칸은 목록에서 빈 값으로 채운다
22
+ function readTags(dir: string): Record<string, Partial<Omit<Track, "file" | "path">>> {
23
+ const f = join(dir, "tags.json");
24
+ return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : {};
25
+ }
26
+
27
+ // 태그 없는 곡도 목록에는 나온다(분위기는 빈 채) — 출처를 모르는 곡은 고르는 쪽이 뒤로 민다
28
+ export function listLibrary(dir = musicDir()): Track[] {
29
+ if (!existsSync(dir)) return [];
30
+ const tags = readTags(dir);
31
+ return readdirSync(dir)
32
+ .filter((f) => AUDIO.test(f))
33
+ .sort()
34
+ .map((file) => ({ title: file, mood: [], source: "", license: "", ...tags[file], file, path: join(dir, file) }));
35
+ }
36
+
37
+ // 분위기가 맞는 곡 — 맞은 태그가 많은 순, 같으면 파일 이름 순(같은 요청이면 늘 같은 곡)
38
+ export function byMood(mood: string, dir = musicDir()): Track[] {
39
+ const want = MOOD_TAGS[mood] ?? [mood];
40
+ const score = (t: Track) => t.mood.filter((m) => want.includes(m)).length;
41
+ return listLibrary(dir)
42
+ .filter((t) => score(t) > 0 && t.license)
43
+ .sort((a, b) => score(b) - score(a) || a.file.localeCompare(b.file));
44
+ }
45
+
46
+ // name 을 주면 그 이름으로 넣는다(생성 곡은 캐시 키 이름이라 사람이 읽을 이름으로)
47
+ export function addToLibrary(src: string, info: Omit<Track, "file" | "path">, dir = musicDir(), name?: string): Track {
48
+ if (!info.source || !info.license) throw new Error("곡 모음에는 원본(--source)과 라이선스(--license)가 있는 곡만 넣는다");
49
+ mkdirSync(dir, { recursive: true });
50
+ const file = name ?? basename(src);
51
+ const path = join(dir, file);
52
+ if (src !== path) copyFileSync(src, path);
53
+ const tags = readTags(dir);
54
+ tags[file] = info;
55
+ writeFileSync(join(dir, "tags.json"), `${JSON.stringify(Object.fromEntries(Object.entries(tags).sort(([a], [b]) => a.localeCompare(b))), null, 2)}\n`);
56
+ return { file, path, ...info };
57
+ }
58
+
59
+ // 틀이 적는 분위기 이름 → 곡 태그. 같은 분위기면 곡 모음 고르기도 생성 프롬프트도 같은 결로 간다
60
+ export const MOOD_TAGS: Record<string, string[]> = {
61
+ deep: ["deep", "ambient", "cinematic", "minimal", "piano", "calm", "dark"],
62
+ bright: ["upbeat", "bright", "pop", "kawaii", "future-bass"],
63
+ calm: ["calm", "lofi", "chill", "piano", "ambient"],
64
+ epic: ["epic", "cinematic", "orchestral", "trailer"],
65
+ };
66
+
67
+ // 생성 모델에 줄 분위기 글 — 요청 글을 섞지 않는다(같은 분위기·길이면 같은 프롬프트라 캐시가 같은 곡을 돌려준다)
68
+ export const MOOD_PROMPT: Record<string, string> = {
69
+ deep: "Deep ambient, minimal cinematic underscore for a calm explanatory narration. Low warm analog pads, slow sparse felt piano notes, soft sub-bass drone, distant airy textures, very light soft percussion far in the background. Around 70 BPM, D minor, contemplative and spacious with long reverb tails. Leaves the mid range open for a spoken voice.",
70
+ bright: "Bright uplifting electronic pop underscore, plucky synths, light future-bass chords, steady soft kick. Around 110 BPM, major key, positive and clean, leaves room for a spoken voice.",
71
+ calm: "Calm lo-fi underscore, mellow electric piano, soft vinyl texture, gentle brushed drums. Around 80 BPM, relaxed and warm, leaves room for a spoken voice.",
72
+ epic: "Cinematic epic underscore, slow building strings and low brass, deep taiko hits only at peaks. Around 90 BPM, minor key, grand but restrained under a spoken voice.",
73
+ };
@@ -0,0 +1,52 @@
1
+ import { findKey, readKey } from "../keys.ts";
2
+ import { json } from "../video/http.ts";
3
+ import type { MusicProvider } from "./types.ts";
4
+
5
+ // Gemini API Interactions(2026-10, ai.google.dev/gemini-api/docs/music-generation). 길이 칸이 없다 —
6
+ // Clip 은 늘 30초, 3.5 는 프롬프트의 길이·시각 구간([0:00 - 0:20])으로 끌고 받은 뒤 잘라 맞춘다
7
+ const BASE = "https://generativelanguage.googleapis.com/v1beta";
8
+ // 곡 하나 값(유료 등급만, 무료 등급 없음 — ai.google.dev/gemini-api/docs/pricing)
9
+ const PRICE: Record<string, number> = { "lyria-3-clip-preview": 0.04, "lyria-3.5": 0.08 };
10
+
11
+ export function lyriaMusic(model: "lyria-3.5" | "lyria-3-clip-preview"): MusicProvider {
12
+ const clip = model === "lyria-3-clip-preview";
13
+ return {
14
+ id: model,
15
+ label: clip ? "Lyria 3 Clip(30초)" : "Lyria 3.5",
16
+ model,
17
+ keyName: "gemini",
18
+ license: "Google 생성형 AI 약관 — 출력물 소유권 주장 없음, 상업 가능 · SynthID 소리 워터마크(안 들림)",
19
+ region: "Gemini API 유료 등급",
20
+ verified: false,
21
+ maxSec: clip ? 30 : 240,
22
+ ready: () => findKey("gemini") !== null,
23
+ estimateUsd: () => PRICE[model],
24
+
25
+ async compose(o) {
26
+ const sec = Math.ceil(o.duration);
27
+ const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
28
+ // 3.5 는 길이를 말로만 받는다 — 시작·한가운데·끝 구간을 시각으로 적어 길이와 흐름을 붙잡는다
29
+ const shape = clip
30
+ ? ""
31
+ : ` Total length exactly ${sec} seconds. [0:00 - ${mmss(Math.round(sec * 0.25))}] sparse intro. [${mmss(Math.round(sec * 0.25))} - ${mmss(Math.round(sec * 0.8))}] slow build, same calm tempo. [${mmss(Math.round(sec * 0.8))} - ${mmss(sec)}] gentle resolve and fade.`;
32
+ const body = { model, input: `${o.prompt}${shape} Instrumental only, no vocals.`, response_format: { type: "audio" } };
33
+ const r = await json(`${BASE}/interactions`, { method: "POST", headers: { "x-goog-api-key": readKey("gemini"), "Content-Type": "application/json" }, body: JSON.stringify(body) }, "Lyria");
34
+ const audio = (r.steps ?? [])
35
+ .filter((s: { type: string }) => s.type === "model_output")
36
+ .flatMap((s: { content?: { type: string; data?: string; mime_type?: string }[] }) => s.content ?? [])
37
+ .filter((c: { type: string }) => c.type === "audio")
38
+ .at(-1);
39
+ if (!audio?.data) throw new Error(`Lyria 응답에 소리가 없다: ${JSON.stringify(r).slice(0, 200)}`);
40
+ const bytes = Buffer.from(audio.data, "base64");
41
+ return {
42
+ bytes,
43
+ ext: bytes.subarray(0, 4).toString() === "RIFF" ? "wav" : "mp3",
44
+ model,
45
+ costUsd: PRICE[model],
46
+ costEstimated: true,
47
+ params: { seconds: sec },
48
+ notes: ["synthid"],
49
+ };
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,28 @@
1
+ export interface MusicRequest {
2
+ prompt: string;
3
+ duration: number; // 초 — 이만큼은 넘게 받아 영상 길이에 잘라 맞춘다
4
+ }
5
+
6
+ export interface MusicRaw {
7
+ bytes: Uint8Array;
8
+ ext: "mp3" | "wav";
9
+ model: string;
10
+ costUsd: number;
11
+ costEstimated: boolean;
12
+ params: Record<string, unknown>;
13
+ notes?: string[];
14
+ }
15
+
16
+ export interface MusicProvider {
17
+ id: string; // --music gen:<id>
18
+ label: string;
19
+ model: string;
20
+ keyName: string;
21
+ license: string;
22
+ region: string;
23
+ verified: boolean;
24
+ maxSec: number;
25
+ ready(): boolean;
26
+ estimateUsd(sec: number): number;
27
+ compose(o: MusicRequest): Promise<MusicRaw>;
28
+ }
@@ -1,6 +1,7 @@
1
+ import { codexImage } from "./image/codex.ts";
1
2
  import { ogImage } from "./image/og.ts";
2
3
  import { hasKey } from "./keys.ts";
3
- import type { ImageProvider, VideoProvider, VideoRequest } from "./types.ts";
4
+ import type { ImageProvider, ProviderInfo, VideoProvider, VideoRequest } from "./types.ts";
4
5
  import { FAL_MODELS, falVideo } from "./video/fal.ts";
5
6
  import { h3Video } from "./video/h3.ts";
6
7
  import { ltxVideo } from "./video/ltx.ts";
@@ -10,9 +11,11 @@ import { veoVideo } from "./video/veo.ts";
10
11
  export const DEFAULT_IMAGE = "og-gpt-image-2.5-sunburst";
11
12
 
12
13
  export function imageProviders(): ImageProvider[] {
13
- return [ogImage("openai/gpt-image-2.5-sunburst"), ogImage("openai/gpt-image-2.5-flare"), ogImage("openai/gpt-image-2")];
14
+ return [ogImage("openai/gpt-image-2.5-sunburst"), ogImage("openai/gpt-image-2.5-flare"), ogImage("openai/gpt-image-2"), codexImage()];
14
15
  }
15
16
 
17
+ export const providerReady = (p: ProviderInfo): boolean => (p.available ? p.available() : hasKey(p.keyName));
18
+
16
19
  export function videoProviders(): VideoProvider[] {
17
20
  return [
18
21
  h3Video(),
@@ -44,7 +47,7 @@ export function pickVideo(id: string | undefined, req: VideoRequest): VideoProvi
44
47
  return p;
45
48
  }
46
49
  const usable = all
47
- .filter((p) => hasKey(p.keyName) && (!req.lastFrame || p.firstLast))
50
+ .filter((p) => providerReady(p) && (!req.lastFrame || p.firstLast))
48
51
  .sort((a, b) => a.estimateUsd(req) - b.estimateUsd(req));
49
52
  if (!usable.length) throw new Error("키가 있는 영상 공급자가 없다 — kf gen models 로 무엇이 필요한지 본다");
50
53
  return usable[0];
@@ -0,0 +1,64 @@
1
+ import { hasKey, readKey } from "../keys.ts";
2
+ import { download, json } from "../video/http.ts";
3
+ import type { TtsProvider } from "./types.ts";
4
+
5
+ // 키가 없어 실호출로 검증하지 못했다 — 모양은 Alibaba Model Studio qwen-tts-api 문서(2026-10) 그대로.
6
+ // 싱가포르(국제)가 기본, 베이징 키면 KF_DASHSCOPE_BASE=https://dashscope.aliyuncs.com (두 지역 키는 서로 다르다)
7
+ const BASE = process.env.KF_DASHSCOPE_BASE ?? "https://dashscope-intl.aliyuncs.com";
8
+ const MODEL = process.env.KF_DASHSCOPE_TTS_MODEL ?? "qwen3-tts-flash";
9
+ // 입력 글자 1만 자당 $0.1(instruct $0.115), 중국어 한 글자는 둘로 센다 — 한글도 둘로 잡아 넉넉히
10
+ const PER_10K = /instruct/.test(MODEL) ? 0.115 : 0.1;
11
+ const LANG: Record<string, string> = { ko: "Korean", en: "English", ja: "Japanese", zh: "Chinese", de: "German", fr: "French", es: "Spanish", it: "Italian", pt: "Portuguese", ru: "Russian" };
12
+ // 한 번에 600자까지(문서) — 장면 한 줄은 이보다 짧다
13
+ const MAX_CHARS = 600;
14
+
15
+ // 48개 모두 한국어를 받는다(문서 목록). 차분한 내레이션에 맞는 것만 앞에
16
+ const VOICES = [
17
+ { id: "Neil", label: "남 · 고른 뉴스 앵커" },
18
+ { id: "Elias", label: "여 · 학자풍 이야기꾼" },
19
+ { id: "Eldric Sage", label: "남 · 차분하고 지혜로운 노인" },
20
+ { id: "Serena", label: "여 · 부드럽고 조용한" },
21
+ { id: "Kai", label: "남 · 편안한" },
22
+ { id: "Sohee", label: "여 · 한국어 화자, 표정이 큰" },
23
+ ];
24
+
25
+ export function dashscopeTts(): TtsProvider {
26
+ return {
27
+ id: "dashscope",
28
+ label: "Qwen TTS(DashScope)",
29
+ model: MODEL,
30
+ keyName: "dashscope",
31
+ license: "Alibaba Cloud Model Studio 약관 — 출력물 상업 가능",
32
+ region: "싱가포르 국제 지역(베이징은 키가 따로)",
33
+ timestamps: false,
34
+ verified: false,
35
+ voices: VOICES,
36
+ defaultVoice: "Neil",
37
+ ready: () => hasKey("dashscope"),
38
+ estimateUsd: (text) => ((text.length * 2) / 10000) * PER_10K,
39
+
40
+ async synth(o) {
41
+ if (o.text.length > MAX_CHARS) throw new Error(`Qwen TTS 는 한 번에 ${MAX_CHARS}자까지 — ${o.text.length}자`);
42
+ const input: Record<string, unknown> = { text: o.text, voice: o.voice, language_type: LANG[o.lang ?? ""] ?? "Auto" };
43
+ const body: Record<string, unknown> = { model: MODEL, input };
44
+ // 연기 지시는 instruct 모델만, 중국어·영어로만 받는다
45
+ if (o.style && /instruct/.test(MODEL)) input.instructions = o.style;
46
+ const r = await json(
47
+ `${BASE}/api/v1/services/aigc/multimodal-generation/generation`,
48
+ { method: "POST", headers: { Authorization: `Bearer ${readKey("dashscope")}`, "Content-Type": "application/json" }, body: JSON.stringify(body) },
49
+ "Qwen TTS",
50
+ );
51
+ const url = r.output?.audio?.url;
52
+ if (!url) throw new Error(`Qwen TTS 응답에 소리 주소가 없다: ${JSON.stringify(r).slice(0, 200)}`);
53
+ const chars = r.usage?.characters;
54
+ return {
55
+ bytes: await download(url),
56
+ ext: "wav",
57
+ model: MODEL,
58
+ costUsd: ((typeof chars === "number" ? chars : o.text.length * 2) / 10000) * PER_10K,
59
+ costEstimated: typeof chars !== "number",
60
+ params: { voice: o.voice, language_type: input.language_type },
61
+ };
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,84 @@
1
+ import { hasKey, readKey } from "../keys.ts";
2
+ import { json } from "../video/http.ts";
3
+ import type { TtsProvider, Word } from "./types.ts";
4
+
5
+ // 키가 없어 실호출로 검증하지 못했다 — 요청·응답 모양은 ElevenLabs API 문서(2026-10, text-to-speech/convert-with-timestamps) 그대로.
6
+ // eleven_v4 는 Text to Dialogue 로만 열려 있어 낱말 시각을 주는 with-timestamps 에는 multilingual_v2(문서 기본, 긴 글에 가장 안정)를 쓴다
7
+ const BASE = process.env.KF_ELEVENLABS_BASE ?? "https://api.elevenlabs.io";
8
+ const MODEL = process.env.KF_ELEVENLABS_MODEL ?? "eleven_multilingual_v2";
9
+ // 1천 글자당 — multilingual·v3 $0.08, flash·turbo $0.04 (elevenlabs.io/pricing/api)
10
+ const PER_1K = /flash|turbo/.test(MODEL) ? 0.04 : 0.08;
11
+
12
+ // 기본 제공 목소리 — 이름으로 고르면 id 로 바꿔 보낸다. 계정에서 만든 목소리는 id 를 그대로 준다
13
+ const VOICES: { id: string; name: string; label: string }[] = [
14
+ { id: "JBFqnCBsd6RMkjVDRZzb", name: "George", label: "남 · 따뜻하고 낮은 이야기꾼" },
15
+ { id: "onwK4e9ZLuTAKqWW03F9", name: "Daniel", label: "남 · 깊고 단단한 낭독" },
16
+ { id: "nPczCjzI2devNBz1zQrb", name: "Brian", label: "남 · 깊고 차분한 내레이션" },
17
+ { id: "XrExE9yKIg1WjnnlVkGX", name: "Matilda", label: "여 · 차분하고 따뜻한 설명" },
18
+ { id: "Xb7hH8MSUJpSbSDYk0k2", name: "Alice", label: "여 · 또렷한 설명" },
19
+ { id: "pFZP5JQG7iQjIQuC4Bku", name: "Lily", label: "여 · 부드럽고 낮은 목소리" },
20
+ ];
21
+
22
+ // 글자마다 온 시각을 띄어쓰기로 묶어 낱말로 — 자막 판은 어절 단위로 끊는다
23
+ export function charsToWords(chars: string[], starts: number[], ends: number[]): Word[] {
24
+ const out: Word[] = [];
25
+ let cur: Word | null = null;
26
+ chars.forEach((c, i) => {
27
+ if (/\s/.test(c)) {
28
+ if (cur) out.push(cur);
29
+ cur = null;
30
+ return;
31
+ }
32
+ if (!cur) cur = { text: c, from: starts[i], to: ends[i] };
33
+ else {
34
+ cur.text += c;
35
+ cur.to = ends[i];
36
+ }
37
+ });
38
+ if (cur) out.push(cur);
39
+ return out;
40
+ }
41
+
42
+ export function elevenlabsTts(): TtsProvider {
43
+ return {
44
+ id: "elevenlabs",
45
+ label: "ElevenLabs",
46
+ model: MODEL,
47
+ keyName: "elevenlabs",
48
+ license: "ElevenLabs 약관 — 유료 요금제 출력물 상업 가능(무료는 출처 표기)",
49
+ region: "전 세계 API",
50
+ timestamps: true,
51
+ verified: false,
52
+ voices: VOICES.map((v) => ({ id: v.name, label: v.label })),
53
+ defaultVoice: "George",
54
+ ready: () => hasKey("elevenlabs"),
55
+ estimateUsd: (text) => (text.length / 1000) * PER_1K,
56
+
57
+ async synth(o) {
58
+ const id = VOICES.find((v) => v.name.toLowerCase() === o.voice.toLowerCase())?.id ?? o.voice;
59
+ const body: Record<string, unknown> = {
60
+ text: o.text,
61
+ model_id: MODEL,
62
+ voice_settings: { stability: 0.55, similarity_boost: 0.75, style: 0, speed: 1, use_speaker_boost: true },
63
+ };
64
+ // multilingual_v2 는 language_code 를 받지 않는다(문서)
65
+ if (o.lang && !/multilingual_v2/.test(MODEL)) body.language_code = o.lang;
66
+ const r = await json(
67
+ `${BASE}/v1/text-to-speech/${encodeURIComponent(id)}/with-timestamps?output_format=mp3_44100_128`,
68
+ { method: "POST", headers: { "xi-api-key": readKey("elevenlabs"), "Content-Type": "application/json" }, body: JSON.stringify(body) },
69
+ "ElevenLabs",
70
+ );
71
+ const a = r.alignment ?? r.normalized_alignment;
72
+ if (!r.audio_base64) throw new Error(`ElevenLabs 응답에 소리가 없다: ${JSON.stringify(r).slice(0, 200)}`);
73
+ return {
74
+ bytes: Buffer.from(r.audio_base64, "base64"),
75
+ ext: "mp3",
76
+ model: MODEL,
77
+ words: a ? charsToWords(a.characters, a.character_start_times_seconds, a.character_end_times_seconds) : undefined,
78
+ costUsd: (o.text.length / 1000) * PER_1K,
79
+ costEstimated: true,
80
+ params: { voice: id, model_id: MODEL },
81
+ };
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,73 @@
1
+ import { findKey, readKey } from "../keys.ts";
2
+ import { pcmToWav } from "../audio.ts";
3
+ import { json } from "../video/http.ts";
4
+ import type { TtsProvider } from "./types.ts";
5
+
6
+ // Gemini API Interactions(2026-10, ai.google.dev/gemini-api/docs/speech-generation). 낱말 시각은 주지 않는다 — 무음 자리로 맞춘다
7
+ const BASE = "https://generativelanguage.googleapis.com/v1beta";
8
+ const MODEL = process.env.KF_GEMINI_TTS_MODEL ?? "gemini-3.8-flash-tts";
9
+ // 1M 토큰당 — 소리 출력은 1초에 25토큰(2026-12-31 까지 값). lite 는 출력 $6
10
+ const PRICE = /lite/.test(MODEL) ? { in: 0.5, out: 6 } : { in: 0.5, out: 9 };
11
+ // 한국어 낭독은 1초에 6글자 안팎
12
+ const CHARS_PER_SEC = 6;
13
+
14
+ const VOICES = [
15
+ { id: "Charon", label: "남 · 차분한 정보 전달(Informative)" },
16
+ { id: "Gacrux", label: "여 · 성숙하고 낮은(Mature)" },
17
+ { id: "Algenib", label: "남 · 거칠고 낮은(Gravelly)" },
18
+ { id: "Sulafat", label: "여 · 따뜻한(Warm)" },
19
+ { id: "Schedar", label: "남 · 고른(Even)" },
20
+ { id: "Vindemiatrix", label: "여 · 부드러운(Gentle)" },
21
+ { id: "Kore", label: "여 · 단단한(Firm)" },
22
+ { id: "Orus", label: "남 · 단단한(Firm)" },
23
+ ];
24
+
25
+ export function geminiTts(): TtsProvider {
26
+ return {
27
+ id: "gemini",
28
+ label: "Gemini TTS",
29
+ model: MODEL,
30
+ keyName: "gemini",
31
+ license: "Google 생성형 AI 약관 — 출력물 소유권 주장 없음, 상업 가능",
32
+ region: "Gemini API 제공 지역(한국 포함)",
33
+ timestamps: false,
34
+ verified: true,
35
+ voices: VOICES,
36
+ defaultVoice: "Charon",
37
+ ready: () => findKey("gemini") !== null,
38
+ estimateUsd: (text) => ((text.length / CHARS_PER_SEC) * 25 * PRICE.out + text.length * PRICE.in) / 1e6,
39
+
40
+ async synth(o) {
41
+ const content: Record<string, unknown> = { type: "text", text: o.text };
42
+ if (o.style) content.annotations = [{ type: "speech_metadata", style: o.style }];
43
+ const body = {
44
+ model: MODEL,
45
+ input: [{ type: "user_input", content: [content] }],
46
+ response_format: { type: "audio", mime_type: "audio/l16", sample_rate: 24000 },
47
+ generation_config: { speech_config: [{ voice: o.voice }] },
48
+ };
49
+ const r = await json(`${BASE}/interactions`, { method: "POST", headers: { "x-goog-api-key": readKey("gemini"), "Content-Type": "application/json" }, body: JSON.stringify(body) }, "Gemini TTS");
50
+ const audio = (r.steps ?? [])
51
+ .filter((s: { type: string }) => s.type === "model_output")
52
+ .flatMap((s: { content?: { type: string; data?: string; mime_type?: string }[] }) => s.content ?? [])
53
+ .filter((c: { type: string }) => c.type === "audio")
54
+ .at(-1);
55
+ if (!audio?.data) throw new Error(`Gemini TTS 응답에 소리가 없다: ${JSON.stringify(r).slice(0, 200)}`);
56
+ const raw = Buffer.from(audio.data, "base64");
57
+ // l16 을 달라고 했지만 머리 붙은 wav 가 오면 그대로
58
+ const bytes = raw.subarray(0, 4).toString() === "RIFF" ? raw : pcmToWav(raw, 24000, 1);
59
+ const out = r.usage?.total_output_tokens ?? r.usage?.output_tokens;
60
+ const inp = r.usage?.total_input_tokens ?? r.usage?.input_tokens;
61
+ const real = typeof out === "number";
62
+ const sec = (bytes.length - 44) / (24000 * 2);
63
+ return {
64
+ bytes,
65
+ ext: "wav",
66
+ model: MODEL,
67
+ costUsd: real ? (out * PRICE.out + (inp ?? 0) * PRICE.in) / 1e6 : (sec * 25 * PRICE.out + o.text.length * PRICE.in) / 1e6,
68
+ costEstimated: !real,
69
+ params: { voice: o.voice, style: o.style ?? null },
70
+ };
71
+ },
72
+ };
73
+ }