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.
- package/NOTICE +10 -8
- package/README.md +16 -1
- package/app/server/engine.ts +133 -15
- package/app/server/main.ts +7 -3
- package/app/web/main.ts +218 -36
- package/app/web/style.css +37 -4
- package/bin/hooks.mjs +14 -1
- package/bin/kf.mjs +20 -2
- package/docs/AGENTS.md +64 -16
- package/music/README.md +31 -7
- package/package.json +2 -2
- package/packages/adapters/src/project.ts +1 -1
- package/packages/check/src/check.ts +10 -3
- package/packages/check/src/rules/assets.ts +4 -1
- package/packages/check/src/rules/frames.ts +2 -1
- package/packages/check/src/rules/layout.ts +30 -6
- package/packages/cli/package.json +2 -1
- package/packages/cli/src/blur.ts +15 -3
- package/packages/cli/src/commands/bench.ts +123 -0
- package/packages/cli/src/commands/blender.ts +72 -49
- package/packages/cli/src/commands/direct.ts +210 -0
- package/packages/cli/src/commands/doctor.ts +9 -1
- package/packages/cli/src/commands/look.ts +9 -2
- package/packages/cli/src/commands/lyrics.ts +47 -1
- package/packages/cli/src/commands/make.ts +729 -150
- package/packages/cli/src/commands/music.ts +78 -0
- package/packages/cli/src/commands/narrate.ts +238 -0
- package/packages/cli/src/commands/new.ts +14 -5
- package/packages/cli/src/commands/render.ts +94 -60
- package/packages/cli/src/commands/series.ts +110 -0
- package/packages/cli/src/commands/setup.ts +42 -3
- package/packages/cli/src/commands/theme.ts +89 -2
- package/packages/cli/src/deps.ts +6 -6
- package/packages/cli/src/kf.ts +14 -3
- package/packages/cli/src/mix.ts +131 -0
- package/packages/cli/src/pool.ts +34 -0
- package/packages/cli/src/segments.ts +402 -0
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/project.ts +11 -0
- package/packages/director/package.json +23 -0
- package/packages/director/src/assemble.ts +361 -0
- package/packages/director/src/brief.ts +122 -0
- package/packages/director/src/catalog.ts +49 -0
- package/packages/director/src/content.ts +343 -0
- package/packages/director/src/fallback.ts +58 -0
- package/packages/director/src/index.ts +13 -0
- package/packages/director/src/pattern.ts +90 -0
- package/packages/director/src/patterns/fill/cells.ts +115 -0
- package/packages/director/src/patterns/formula/stack.ts +93 -0
- package/packages/director/src/patterns/util.ts +58 -0
- package/packages/director/src/scene.ts +62 -0
- package/packages/director/src/select.ts +163 -0
- package/packages/director/src/transitions.ts +115 -0
- package/packages/gen/PROVIDERS.md +39 -4
- package/packages/gen/src/ask.ts +132 -0
- package/packages/gen/src/audio.ts +91 -0
- package/packages/gen/src/blender.ts +54 -0
- package/packages/gen/src/capabilities.ts +151 -0
- package/packages/gen/src/generate.ts +4 -2
- package/packages/gen/src/image/codex.ts +117 -0
- package/packages/gen/src/index.ts +10 -2
- package/packages/gen/src/keys.ts +26 -0
- package/packages/gen/src/music/elevenlabs.ts +41 -0
- package/packages/gen/src/music/index.ts +120 -0
- package/packages/gen/src/music/library.ts +73 -0
- package/packages/gen/src/music/lyria.ts +52 -0
- package/packages/gen/src/music/types.ts +28 -0
- package/packages/gen/src/registry.ts +6 -3
- package/packages/gen/src/tts/dashscope.ts +64 -0
- package/packages/gen/src/tts/elevenlabs.ts +84 -0
- package/packages/gen/src/tts/gemini.ts +73 -0
- package/packages/gen/src/tts/index.ts +241 -0
- package/packages/gen/src/tts/local.ts +42 -0
- package/packages/gen/src/tts/types.ts +44 -0
- package/packages/gen/src/types.ts +4 -0
- package/packages/gen/src/video/h3.ts +5 -0
- package/packages/gen/src/video/http.ts +1 -1
- package/packages/jizura/src/index.ts +2 -2
- package/packages/jizura/src/install.ts +198 -155
- package/packages/jizura/src/layer.ts +75 -32
- package/packages/jizura/src/pin.ts +4 -1
- package/packages/jizura/src/vendor.ts +140 -0
- package/packages/jizura/src/vite.ts +34 -7
- package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
- package/packages/jizura/vendor/NOTICE +43 -0
- package/packages/jizura/vendor/engine.json +47 -0
- package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
- package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
- package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
- package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
- package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
- package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
- package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
- package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
- package/packages/jizura/vendor/fonts/fonts.json +52 -0
- package/packages/jizura/vendor/jizura_engine.js +35024 -0
- package/packages/motion/README.md +11 -0
- package/packages/motion/package.json +1 -0
- package/packages/motion/src/captions.ts +32 -24
- package/packages/motion/src/moves/brushWipe.ts +14 -1
- package/packages/motion/src/moves/dress.ts +191 -0
- package/packages/motion/src/moves/echoStack.ts +3 -3
- package/packages/motion/src/moves/fit.ts +36 -0
- package/packages/motion/src/moves/ghostStack.ts +14 -5
- package/packages/motion/src/moves/imageSwarm.ts +8 -4
- package/packages/motion/src/moves/index.ts +23 -4
- package/packages/motion/src/moves/karaokeLine.ts +17 -3
- package/packages/motion/src/moves/menu.ts +138 -0
- package/packages/motion/src/moves/neonSign.ts +2 -1
- package/packages/motion/src/moves/slamWords.ts +14 -7
- package/packages/motion/src/moves/sparkTrail.ts +16 -0
- package/packages/motion/src/moves/splitFlap.ts +4 -2
- package/packages/motion/src/moves/stepWords.ts +19 -8
- package/packages/motion/src/moves/tickerRows.ts +13 -9
- package/packages/motion/src/moves/tokenPicker.ts +4 -3
- package/packages/motion/src/moves/wordWall.ts +23 -21
- package/packages/motion/src/moves/worldRoll.ts +2 -1
- package/packages/motion/src/transition3d/index.ts +10 -3
- package/packages/motion/src/transition3d/rig.ts +107 -9
- package/packages/motion/src/transition3d/shatter.ts +5 -2
- package/packages/motion/src/transition3d/space.ts +173 -0
- package/packages/motion/src/transition3d/whip.ts +19 -6
- package/packages/motion/src/transition3d/world.ts +54 -0
- package/packages/motion/src/transitions2d/catalog.ts +1 -1
- package/packages/patterns/package.json +15 -0
- package/packages/patterns/src/bars/rank.ts +119 -0
- package/packages/patterns/src/before-after/shrink.ts +127 -0
- package/packages/patterns/src/before-after/strike.ts +125 -0
- package/packages/patterns/src/code/terminal.ts +133 -0
- package/packages/patterns/src/compare/bars.ts +142 -0
- package/packages/patterns/src/compare/duel.ts +103 -0
- package/packages/patterns/src/end/lockup.ts +85 -0
- package/packages/patterns/src/end/slam.ts +91 -0
- package/packages/patterns/src/flow/graph.ts +250 -0
- package/packages/patterns/src/hook-number/counter.ts +101 -0
- package/packages/patterns/src/hook-number/roll.ts +179 -0
- package/packages/patterns/src/hook-number/slam.ts +91 -0
- package/packages/patterns/src/index.ts +38 -0
- package/packages/patterns/src/kit.ts +449 -0
- package/packages/patterns/src/limit/gauge.ts +117 -0
- package/packages/patterns/src/list/cards.ts +131 -0
- package/packages/patterns/src/list/cycle.ts +22 -0
- package/packages/patterns/src/media/panels.ts +100 -0
- package/packages/patterns/src/objects.ts +234 -0
- package/packages/patterns/src/quote/karaoke.ts +126 -0
- package/packages/patterns/src/quote/slam.ts +22 -0
- package/packages/patterns/src/screen/focus.ts +85 -0
- package/packages/patterns/src/steps/path.ts +137 -0
- package/packages/patterns/src/steps/stack.ts +111 -0
- package/packages/patterns/src/term-reveal/chips.ts +92 -0
- package/packages/patterns/src/term-reveal/expand.ts +127 -0
- package/packages/patterns/src/timeline/axis.ts +144 -0
- package/packages/patterns/src/title/mask.ts +85 -0
- package/packages/patterns/src/title/slam.ts +23 -0
- package/packages/patterns/src/toggle-sim/switch.ts +92 -0
- package/packages/patterns/src/trend/columns.ts +108 -0
- package/packages/patterns/src/trend/draw.ts +140 -0
- package/packages/patterns/src/types.ts +67 -0
- package/packages/patterns/src/words/moves.ts +42 -0
- package/packages/patterns/src/words/slam.ts +4 -0
- package/packages/plan/src/asks.ts +256 -0
- package/packages/plan/src/brief.ts +136 -7
- package/packages/plan/src/captions.ts +5 -2
- package/packages/plan/src/index.ts +6 -2
- package/packages/plan/src/llm.ts +64 -2
- package/packages/plan/src/look.ts +3 -1
- package/packages/plan/src/plan.ts +67 -13
- package/packages/plan/src/series.ts +76 -0
- package/packages/plan/src/types.ts +53 -2
- package/packages/themes/package.json +5 -2
- package/packages/themes/src/index.ts +9 -0
- package/packages/themes/src/slotkit.ts +79 -0
- package/packages/themes/src/slots.ts +46 -0
- package/packages/themes/src/stage3d.ts +20 -0
- package/series/og-shorts.json +14 -0
- package/skills/kasaframe/SKILL.md +4 -3
- package/templates/_shared/project/lib/stage.ts +70 -50
- package/templates/_shared/scenes/default.ts +10 -7
- package/templates/_shared/scenes/drop.ts +11 -8
- package/templates/_shared/scenes/end.ts +9 -6
- package/templates/_shared/scenes/hook.ts +8 -6
- package/templates/_shared/scenes/step.ts +13 -4
- package/templates/ad/template.json +20 -1
- package/templates/demo/template.json +11 -0
- package/templates/explainer/template.json +54 -3
- package/templates/launch/template.json +34 -1
- package/templates/mv/scenes/default.ts +11 -8
- package/templates/mv/template.json +4 -2
- package/templates/promo/template.json +42 -1
- package/templates/shorts/scenes/hook.ts +8 -5
- package/templates/shorts/template.json +72 -3
- package/templates/square/template.json +21 -1
- package/themes/debug-hud/slots.ts +99 -0
- package/themes/og-measure/slots.ts +88 -0
- package/themes/riso-zine/slots.ts +81 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
|
|
3
|
+
// 돈이 드는 호출과 없는 능력은 kf make 가 생성 전에 한 번에 모아 묻는다 — 그림·영상·3D·음악·목소리가 같은 꼴로 들어온다
|
|
4
|
+
// theme 은 돈도 능력도 아닌 고르기라 --allow 목록엔 없다 — --yes·--no-gen·--allow 는 그 질문의 「알아서」로 간다
|
|
5
|
+
export type Capability = "image" | "video" | "blender" | "three" | "music" | "voice" | "beats" | "lyrics" | "theme";
|
|
6
|
+
export const CAPABILITIES: Capability[] = ["image", "video", "blender", "three", "music", "voice", "beats", "lyrics"];
|
|
7
|
+
// --no-gen 이 「빼기」로 답해 줄 수 있는 생성 능력 — 3D·박자·가사 도구는 생성 모델이 아니라 --no-gen 으로 넘기지 않는다
|
|
8
|
+
const GENERATIVE = new Set<Capability>(["image", "video", "music", "voice"]);
|
|
9
|
+
// 「키·도구를 넣고 다시」 — 고르면 kf make 가 켜는 법을 찍고 종료 3
|
|
10
|
+
export const WAIT = "wait";
|
|
11
|
+
|
|
12
|
+
export interface Choice {
|
|
13
|
+
value: string; // --<flag> 에 그대로 넣는 값
|
|
14
|
+
label: string;
|
|
15
|
+
costUsd?: number;
|
|
16
|
+
note?: string; // 라이선스·지역 경고는 여기 — 묻는 글과 결과 메타에 같이 남는다
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Question {
|
|
20
|
+
id: string;
|
|
21
|
+
cap: Capability; // --allow 로 묻지 않고 허락하는 단위
|
|
22
|
+
ask: string;
|
|
23
|
+
header: string; // 12자 이내 — Claude Code AskUserQuestion 칩
|
|
24
|
+
choices: Choice[]; // 첫째가 권장
|
|
25
|
+
default: string; // --yes · --allow <cap> · 터미널 엔터
|
|
26
|
+
skip: string; // --no-gen · --allow 에 cap 이 없을 때 — 돈 안 드는 길
|
|
27
|
+
flag: string;
|
|
28
|
+
// 필요한 능력이 없다 — 조용히 대체하지 않는다. 보기는 ① 넣는 법(wait, 권장) ② 지금 쓸 수 있는 다른 엔진 ③ 빼기.
|
|
29
|
+
// --yes·--allow 로 넘어가지 않고 사람이 준 답(플래그·--answer·시리즈)만, 생성 능력이면 --no-gen 의 빼기만 받는다
|
|
30
|
+
missing?: boolean;
|
|
31
|
+
accepts?: (v: string) => boolean; // 보기 밖 값도 받는 질문(다른 목소리·곡 파일 경로) — 함수라 plan.json 에는 답만 남는다
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AnswerInput {
|
|
35
|
+
given?: Record<string, string | boolean | undefined>; // 명령 줄 opts 그대로 — --<flag> 값이 있으면 그 답
|
|
36
|
+
yes?: boolean;
|
|
37
|
+
noGen?: boolean;
|
|
38
|
+
allow?: Capability[] | null; // null 이면 --allow 를 안 줬다
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Settled {
|
|
42
|
+
answers: Record<string, string>;
|
|
43
|
+
open: Question[]; // 아직 사람이 골라야 하는 것
|
|
44
|
+
how: Record<string, "flag" | "no-gen" | "allow" | "yes">;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseAllow(v: string | boolean | undefined): Capability[] | null {
|
|
48
|
+
if (typeof v !== "string") return null;
|
|
49
|
+
const list = v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
50
|
+
const bad = list.filter((c) => !CAPABILITIES.includes(c as Capability));
|
|
51
|
+
if (bad.length) throw new Error(`--allow 는 ${CAPABILITIES.join("·")} 중에서 — ${bad.join(", ")}`);
|
|
52
|
+
return list as Capability[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 고르는 순서: 사람이 준 플래그 값 > --no-gen > --allow > --yes. 나머지는 묻는다 — 없는 능력(missing)은 플래그·생성 빼기만
|
|
56
|
+
export function settle(questions: Question[], o: AnswerInput): Settled {
|
|
57
|
+
const out: Settled = { answers: {}, open: [], how: {} };
|
|
58
|
+
for (const q of questions) {
|
|
59
|
+
const given = o.given?.[q.flag.replace(/^--/, "")];
|
|
60
|
+
if (typeof given === "string" && (q.choices.some((c) => c.value === given) || q.accepts?.(given))) {
|
|
61
|
+
out.answers[q.id] = given;
|
|
62
|
+
out.how[q.id] = "flag";
|
|
63
|
+
} else if (q.missing) {
|
|
64
|
+
if (o.noGen && GENERATIVE.has(q.cap) && q.choices.some((c) => c.value === "none")) {
|
|
65
|
+
out.answers[q.id] = "none";
|
|
66
|
+
out.how[q.id] = "no-gen";
|
|
67
|
+
} else out.open.push(q);
|
|
68
|
+
} else if (o.noGen) {
|
|
69
|
+
out.answers[q.id] = q.skip;
|
|
70
|
+
out.how[q.id] = "no-gen";
|
|
71
|
+
} else if (o.allow) {
|
|
72
|
+
out.answers[q.id] = o.allow.includes(q.cap) ? q.default : q.skip;
|
|
73
|
+
out.how[q.id] = "allow";
|
|
74
|
+
} else if (o.yes) {
|
|
75
|
+
out.answers[q.id] = q.default;
|
|
76
|
+
out.how[q.id] = "yes";
|
|
77
|
+
} else out.open.push(q);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const usd = (n?: number) => (n === undefined ? "" : n === 0 ? " · 0원" : ` · 견적 $${n.toFixed(n < 0.1 ? 3 : 2)}`);
|
|
83
|
+
|
|
84
|
+
function choiceLine(c: Choice, q: Question): string {
|
|
85
|
+
const tags = [c.value === q.default ? "권장" : "", c.value === q.skip && !q.missing ? "돈 안 듦" : ""].filter(Boolean);
|
|
86
|
+
return `${c.label}${usd(c.costUsd)}${tags.length ? ` (${tags.join("·")})` : ""}${c.note ? ` — ${c.note}` : ""}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 터미널에서 한 번에 — 엔터만 치면 권장
|
|
90
|
+
export async function askTerminal(questions: Question[]): Promise<Record<string, string> | null> {
|
|
91
|
+
if (!process.stdin.isTTY || !questions.length) return questions.length ? null : {};
|
|
92
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
93
|
+
const out: Record<string, string> = {};
|
|
94
|
+
try {
|
|
95
|
+
process.stderr.write(`\n생성 전에 ${questions.length}가지를 정한다(엔터 = 권장)\n`);
|
|
96
|
+
for (const q of questions) {
|
|
97
|
+
const lines = q.choices.map((c, i) => ` ${i + 1}) ${choiceLine(c, q)}`).join("\n");
|
|
98
|
+
const a = (await rl.question(`\n${q.ask}\n${lines}\n번호: `)).trim();
|
|
99
|
+
out[q.id] = a === "" ? q.default : (q.choices[Number(a) - 1]?.value ?? q.default);
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
rl.close();
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 터미널이 아닐 때 — 보기와 다시 부를 플래그를 찍는다(종료 코드 3 은 부르는 쪽이). 없는 능력은 자동 답 없이 여기서 멈춘다
|
|
108
|
+
export function questionsText(questions: Question[]): string {
|
|
109
|
+
return questions
|
|
110
|
+
.map((q) => [`${q.ask}`, ...q.choices.map((c) => ` ${q.flag} ${c.value.padEnd(22)} ${choiceLine(c, q)}`)].join("\n"))
|
|
111
|
+
.join("\n\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// MAKE.md 맨 위 — AskUserQuestion 보기로 옮기기 좋게(질문·칩·보기 라벨·설명) + 답을 넣는 명령
|
|
115
|
+
export function questionsMarkdown(questions: Question[], rerun: (flags: string) => string): string[] {
|
|
116
|
+
if (!questions.length) return [];
|
|
117
|
+
return [
|
|
118
|
+
"## 사용자에게 먼저 물을 것",
|
|
119
|
+
"",
|
|
120
|
+
"장면을 쓰기 전에 아래를 한 번에 묻는다(AskUserQuestion 한 번에 질문 여럿 — `header` 는 칩, 보기 첫째가 권장). 답은 같은 줄의 플래그 값으로 되돌려 준다:",
|
|
121
|
+
// AskUserQuestion 은 보기를 넷까지만 받는다 — 테마처럼 보기가 많은 질문은 나머지를 글로 보이고 Other 로 받는다
|
|
122
|
+
...(questions.some((q) => q.choices.length > 4) ? ["보기가 넷을 넘는 질문은 첫 넷만 보기로 올리고, 나머지 보기 이름은 질문 글에 적어 「Other」로 받는다(보기 밖 값도 그 플래그로 받는다)."] : []),
|
|
123
|
+
"",
|
|
124
|
+
...questions.flatMap((q) => [
|
|
125
|
+
`- **${q.ask}** (header: \`${q.header}\`, 플래그 \`${q.flag}\`)`,
|
|
126
|
+
...q.choices.map((c) => ` - \`${c.value}\` — ${choiceLine(c, q)}`),
|
|
127
|
+
]),
|
|
128
|
+
"",
|
|
129
|
+
`답을 받으면: \`${rerun(questions.map((q) => `${q.flag} <값>`).join(" "))}\``,
|
|
130
|
+
"",
|
|
131
|
+
];
|
|
132
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { run } from "./media.ts";
|
|
3
|
+
|
|
4
|
+
// 소리 다루기 — 목소리·곡 둘 다 여기 ffmpeg 한 벌로. 결과는 늘 48kHz 로 맞춰 섞을 때 다시 바꾸지 않는다
|
|
5
|
+
const ff = (args: string[]) => run("ffmpeg", ["-v", "error", "-y", ...args]);
|
|
6
|
+
|
|
7
|
+
export async function duration(path: string): Promise<number> {
|
|
8
|
+
const out = await run("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", path]);
|
|
9
|
+
const d = Number(out.toString().trim());
|
|
10
|
+
if (!Number.isFinite(d)) throw new Error(`길이를 못 읽음: ${path}`);
|
|
11
|
+
return d;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 머리 없는 PCM(Gemini l16·DashScope 일부)을 wav 로 감싼다
|
|
15
|
+
export function pcmToWav(pcm: Uint8Array, rate = 24000, channels = 1): Uint8Array {
|
|
16
|
+
const h = Buffer.alloc(44);
|
|
17
|
+
h.write("RIFF", 0);
|
|
18
|
+
h.writeUInt32LE(36 + pcm.length, 4);
|
|
19
|
+
h.write("WAVEfmt ", 8);
|
|
20
|
+
h.writeUInt32LE(16, 16);
|
|
21
|
+
h.writeUInt16LE(1, 20);
|
|
22
|
+
h.writeUInt16LE(channels, 22);
|
|
23
|
+
h.writeUInt32LE(rate, 24);
|
|
24
|
+
h.writeUInt32LE(rate * channels * 2, 28);
|
|
25
|
+
h.writeUInt16LE(channels * 2, 32);
|
|
26
|
+
h.writeUInt16LE(16, 34);
|
|
27
|
+
h.write("data", 36);
|
|
28
|
+
h.writeUInt32LE(pcm.length, 40);
|
|
29
|
+
return Buffer.concat([h, Buffer.from(pcm)]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Silence {
|
|
33
|
+
from: number;
|
|
34
|
+
to: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 말 사이 숨 — 자막 마디를 이 자리에 붙인다(기본 -38dB 아래 0.12초 넘게).
|
|
38
|
+
// silencedetect 는 결과를 stderr 로만 낸다 — run 은 stderr 를 버리니 따로 띄운다
|
|
39
|
+
export async function findSilences(path: string, o: { db?: number; min?: number } = {}): Promise<Silence[]> {
|
|
40
|
+
const text = await new Promise<string>((resolve, reject) => {
|
|
41
|
+
const p = spawn("ffmpeg", ["-hide_banner", "-nostats", "-i", path, "-af", `silencedetect=noise=${o.db ?? -38}dB:d=${o.min ?? 0.12}`, "-f", "null", "-"]);
|
|
42
|
+
let err = "";
|
|
43
|
+
p.stderr.on("data", (b: Buffer) => (err += b));
|
|
44
|
+
p.on("error", reject);
|
|
45
|
+
p.on("close", (code) => (code === 0 ? resolve(err) : reject(new Error(`무음 찾기 실패(${code})`))));
|
|
46
|
+
});
|
|
47
|
+
const out: Silence[] = [];
|
|
48
|
+
let open: number | null = null;
|
|
49
|
+
for (const line of text.split("\n")) {
|
|
50
|
+
const s = line.match(/silence_start: (-?[\d.]+)/);
|
|
51
|
+
if (s) open = Math.max(0, Number(s[1]));
|
|
52
|
+
const e = line.match(/silence_end: ([\d.]+)/);
|
|
53
|
+
if (e && open !== null) {
|
|
54
|
+
out.push({ from: open, to: Number(e[1]) });
|
|
55
|
+
open = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (open !== null) out.push({ from: open, to: Infinity });
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 앞뒤 무음을 깎은 구간 — 공급자마다 머리·꼬리 숨 길이가 달라 장면 길이가 흔들린다
|
|
63
|
+
export async function voicedSpan(path: string): Promise<{ from: number; to: number; total: number }> {
|
|
64
|
+
const total = await duration(path);
|
|
65
|
+
const sil = await findSilences(path, { db: -45, min: 0.05 });
|
|
66
|
+
const head = sil.find((s) => s.from <= 0.01);
|
|
67
|
+
const tail = sil.find((s) => s.to === Infinity || s.to >= total - 0.01);
|
|
68
|
+
const from = head ? Math.min(head.to, total) : 0;
|
|
69
|
+
const to = tail && tail.from > from ? tail.from : total;
|
|
70
|
+
return { from, to, total };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 48kHz 모노 wav 로 — 자르고(from..to) 빠르기(tempo, 0.9–1.1)를 바꾼다. 빠르기는 높이를 지키는 atempo
|
|
74
|
+
export async function cutVoice(src: string, dst: string, o: { from: number; to: number; tempo?: number }): Promise<void> {
|
|
75
|
+
const tempo = o.tempo ?? 1;
|
|
76
|
+
if (tempo < 0.9 - 1e-6 || tempo > 1.1 + 1e-6) throw new Error(`목소리 빠르기는 0.9–1.1 안에서만: ${tempo}`);
|
|
77
|
+
const af = [`atrim=start=${o.from.toFixed(4)}:end=${o.to.toFixed(4)}`, "asetpts=PTS-STARTPTS", ...(Math.abs(tempo - 1) > 1e-4 ? [`atempo=${tempo.toFixed(4)}`] : []), "aresample=48000"].join(",");
|
|
78
|
+
await ff(["-i", src, "-af", af, "-ac", "1", "-c:a", "pcm_s16le", dst]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 장면 시각에 목소리 조각을 깔아 영상 길이 한 줄로
|
|
82
|
+
export async function layVoices(clips: { file: string; at: number }[], total: number, dst: string): Promise<void> {
|
|
83
|
+
if (!clips.length) {
|
|
84
|
+
await ff(["-f", "lavfi", "-i", `anullsrc=r=48000:cl=mono`, "-t", total.toFixed(4), "-c:a", "pcm_s16le", dst]);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const inputs = clips.flatMap((c) => ["-i", c.file]);
|
|
88
|
+
const chains = clips.map((c, i) => `[${i}]adelay=${Math.round(c.at * 1000)}:all=1[v${i}]`);
|
|
89
|
+
const mix = `${clips.map((_, i) => `[v${i}]`).join("")}amix=inputs=${clips.length}:normalize=0:dropout_transition=0,apad,atrim=0:${total.toFixed(4)}[out]`;
|
|
90
|
+
await ff([...inputs, "-filter_complex", [...chains, mix].join(";"), "-map", "[out]", "-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", dst]);
|
|
91
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
// Blender 를 바깥 프로그램으로 찾기만 한다(bpy 를 가져오지 않는다) — kf blender 와 생성 능력 목록이 같은 답을 본다
|
|
7
|
+
export const BLENDER_MIN = [4, 3]; // Grease Pencil v3(라인아트가 일반 모디파이어)가 4.3 부터
|
|
8
|
+
|
|
9
|
+
// bpy 를 쓰는 헬퍼는 GPL 로 갈라 둔 tools/blender — npm 묶음에는 없어 KF_BLENDER_LIB 로 가리킨다
|
|
10
|
+
export function blenderLibDir(): string {
|
|
11
|
+
return process.env.KF_BLENDER_LIB ? resolve(process.env.KF_BLENDER_LIB) : fileURLToPath(new URL("../../../tools/blender/", import.meta.url));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const hasBlenderLib = (dir = blenderLibDir()): boolean => existsSync(join(dir, "run.py")) && existsSync(join(dir, "kfb.py"));
|
|
15
|
+
|
|
16
|
+
function candidates(given?: string): string[] {
|
|
17
|
+
const out: string[] = [];
|
|
18
|
+
if (given) out.push(given);
|
|
19
|
+
if (process.env.BLENDER) out.push(process.env.BLENDER);
|
|
20
|
+
const which = spawnSync(process.platform === "win32" ? "where" : "which", ["blender"], { encoding: "utf8" });
|
|
21
|
+
if (which.status === 0) out.push(...which.stdout.split(/\r?\n/).filter(Boolean));
|
|
22
|
+
if (process.platform === "darwin") {
|
|
23
|
+
const apps = existsSync("/Applications") ? readdirSync("/Applications").filter((n) => /^Blender.*\.app$/.test(n)) : [];
|
|
24
|
+
// 이름에 판 번호를 붙여 여러 개 깔아 둔 기계면 높은 판부터
|
|
25
|
+
apps.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
26
|
+
out.push(...apps.map((a) => `/Applications/${a}/Contents/MacOS/Blender`));
|
|
27
|
+
}
|
|
28
|
+
if (process.platform === "win32") {
|
|
29
|
+
const base = "C:\\Program Files\\Blender Foundation";
|
|
30
|
+
if (existsSync(base)) {
|
|
31
|
+
const dirs = readdirSync(base).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
32
|
+
out.push(...dirs.map((d) => join(base, d, "blender.exe")));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function findBlender(given?: string, warn: (line: string) => void = () => {}): { exe: string; version: number[] } {
|
|
39
|
+
const tried: string[] = [];
|
|
40
|
+
for (const exe of candidates(given)) {
|
|
41
|
+
if (tried.includes(exe)) continue;
|
|
42
|
+
tried.push(exe);
|
|
43
|
+
const r = spawnSync(exe, ["--version"], { encoding: "utf8", timeout: 30_000 });
|
|
44
|
+
const m = /Blender (\d+)\.(\d+)(?:\.(\d+))?/.exec(r.stdout ?? "");
|
|
45
|
+
if (r.status !== 0 || !m) continue;
|
|
46
|
+
const version = [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
|
|
47
|
+
if (version[0] < BLENDER_MIN[0] || (version[0] === BLENDER_MIN[0] && version[1] < BLENDER_MIN[1])) {
|
|
48
|
+
warn(`${exe} 는 ${version.join(".")} — ${BLENDER_MIN.join(".")} 이상이 필요해 건너뛴다`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
return { exe, version };
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Blender ${BLENDER_MIN.join(".")} 이상을 못 찾았다 — --blender <실행 파일> 또는 BLENDER=<경로>. 본 곳: ${tried.join(", ") || "(없음)"}`);
|
|
54
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { Capability } from "./ask.ts";
|
|
4
|
+
import { blenderLibDir, findBlender, hasBlenderLib } from "./blender.ts";
|
|
5
|
+
import { estimateImageUsd } from "./generate.ts";
|
|
6
|
+
import { codexBin } from "./image/codex.ts";
|
|
7
|
+
import { findKey, hasKey, whereToPut } from "./keys.ts";
|
|
8
|
+
import { musicGenCapabilities } from "./music/index.ts";
|
|
9
|
+
import { imageProviders, providerReady, videoProviders } from "./registry.ts";
|
|
10
|
+
import { voiceCapabilities } from "./tts/index.ts";
|
|
11
|
+
|
|
12
|
+
// 생성 능력 한 칸 — 무엇으로 만드는지·지금 쓸 수 있는지·한 번에 얼마인지·어디에 써도 되는지
|
|
13
|
+
export interface CapabilityProvider {
|
|
14
|
+
cap: Capability;
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
ready: boolean;
|
|
18
|
+
detail: string; // 왜 되는지·안 되는지
|
|
19
|
+
fix?: string; // 안 될 때 켜는 법
|
|
20
|
+
unitUsd: number; // 한 번(그림 한 장·5초 영상 하나·샷 하나) 견적
|
|
21
|
+
unit: string;
|
|
22
|
+
license: string;
|
|
23
|
+
// 회사·바깥 결과물에 걸리는 제한 — 묻는 글과 자산 메타·결과 메타에 같이 남긴다
|
|
24
|
+
restrict?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CapabilityReport {
|
|
28
|
+
cap: Capability;
|
|
29
|
+
label: string;
|
|
30
|
+
providers: CapabilityProvider[];
|
|
31
|
+
best: CapabilityProvider | null; // 쓸 수 있는 것 중 권장(싼 것 먼저, 제한 없는 것 먼저)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LABEL: Record<Capability, string> = {
|
|
35
|
+
image: "그림",
|
|
36
|
+
video: "영상 모델",
|
|
37
|
+
blender: "3D 샷(Blender)",
|
|
38
|
+
three: "3D 전환·화면 속 3D(three.js)",
|
|
39
|
+
music: "음악",
|
|
40
|
+
voice: "목소리(TTS)",
|
|
41
|
+
beats: "박자 분석(librosa)",
|
|
42
|
+
lyrics: "가사(JIZURA 가사 층·정렬)",
|
|
43
|
+
theme: "테마",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
async function reach(url: string, init: RequestInit = {}, ms = 3000): Promise<number | string> {
|
|
47
|
+
try {
|
|
48
|
+
const r = await fetch(url, { ...init, signal: AbortSignal.timeout(ms) });
|
|
49
|
+
return r.status;
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return e instanceof Error ? e.name === "TimeoutError" ? "응답 없음" : e.message.slice(0, 60) : String(e);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// probe 면 키·주소가 실제로 사는지 가볍게 두드린다(OG 모델 목록·H3 서버) — 돈이 드는 호출은 하지 않는다
|
|
56
|
+
async function images(probe: boolean): Promise<CapabilityProvider[]> {
|
|
57
|
+
const est = estimateImageUsd({ prompt: "", size: "1920x1080", quality: "high" });
|
|
58
|
+
let og: { ok: boolean; why: string } = { ok: hasKey("opengateway"), why: hasKey("opengateway") ? "키 있음" : "키 없음" };
|
|
59
|
+
if (og.ok && probe) {
|
|
60
|
+
const s = await reach(`${process.env.KF_OG_HOST ?? "https://apis.opengateway.ai"}/v1/models`, { headers: { Authorization: `Bearer ${findKey("opengateway")}` } });
|
|
61
|
+
og = s === 200 ? { ok: true, why: "키 살아 있음(모델 목록 200)" } : { ok: false, why: `키는 있는데 OG 가 ${s}${s === 401 ? "(키가 틀렸다)" : s === 402 ? "(잔액 부족)" : ""}` };
|
|
62
|
+
}
|
|
63
|
+
const out: CapabilityProvider[] = [];
|
|
64
|
+
for (const p of imageProviders()) {
|
|
65
|
+
if (p.id === "codex-imagegen") {
|
|
66
|
+
const bin = codexBin();
|
|
67
|
+
const ready = providerReady(p);
|
|
68
|
+
out.push({
|
|
69
|
+
cap: "image", id: p.id, label: "codex imagegen(ChatGPT 요금제)", ready,
|
|
70
|
+
detail: !bin ? "codex CLI 없음" : ready ? "로그인 됨 — 한 장 1–2분" : "codex 는 있는데 로그인 안 됨",
|
|
71
|
+
fix: !bin ? "npm i -g @openai/codex && codex login" : ready ? undefined : "codex login",
|
|
72
|
+
unitUsd: 0, unit: "장", license: p.license,
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// 같은 키·같은 단가인 OG 모델 셋은 기본만 앞에 세우고 나머지는 같은 판정으로 둔다
|
|
77
|
+
out.push({ cap: "image", id: p.id, label: `OG ${p.model}`, ready: og.ok, detail: og.why, fix: og.ok ? undefined : whereToPut("opengateway"), unitUsd: est, unit: "1920x1080 high 한 장", license: p.license });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function videos(probe: boolean): Promise<CapabilityProvider[]> {
|
|
83
|
+
const sample = { prompt: "", duration: 5, aspect: "16:9", firstFrame: "x", lastFrame: "y", resolution: "720p" };
|
|
84
|
+
const out: CapabilityProvider[] = [];
|
|
85
|
+
for (const p of videoProviders()) {
|
|
86
|
+
let ready = providerReady(p);
|
|
87
|
+
let detail = ready ? "키 있음(키만 봄)" : "키 없음";
|
|
88
|
+
if (p.id === "h3-pod") {
|
|
89
|
+
const url = findKey("h3");
|
|
90
|
+
if (!url) detail = "KF_H3_URL 없음";
|
|
91
|
+
else if (!process.env.KF_H3_STEPS) {
|
|
92
|
+
ready = false;
|
|
93
|
+
detail = "KF_H3_STEPS 없음(터보 LoRA 면 9, 없으면 50 — 파드 운영자에게)";
|
|
94
|
+
} else if (probe) {
|
|
95
|
+
const s = await reach(`${url.replace(/\/+$/, "")}/v1/models`);
|
|
96
|
+
ready = s === 200;
|
|
97
|
+
detail = ready ? "서버 살아 있음" : `서버가 ${s}`;
|
|
98
|
+
} else detail = "주소 있음(두드려 보지 않음)";
|
|
99
|
+
}
|
|
100
|
+
out.push({
|
|
101
|
+
cap: "video", id: p.id, label: p.model, ready, detail,
|
|
102
|
+
fix: ready ? undefined : p.id === "h3-pod" ? "KF_H3_URL=<SGLang 주소> KF_H3_STEPS=<9|50>" : whereToPut(p.keyName),
|
|
103
|
+
unitUsd: p.estimateUsd(sample), unit: "5초 720p 첫+끝 프레임", license: p.license, restrict: p.restrict,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function blender(): CapabilityProvider {
|
|
110
|
+
const lib = blenderLibDir();
|
|
111
|
+
const base = { cap: "blender" as const, id: "blender", label: "Blender 툰 3D(kf blender)", unitUsd: 0, unit: "샷 하나(로컬 렌더)", license: "Blender GPL — 굽힌 그림·영상은 만든 사람 것, 헬퍼(tools/blender)는 GPL-3.0 로 npm 묶음 밖" };
|
|
112
|
+
if (!hasBlenderLib(lib)) return { ...base, ready: false, detail: `헬퍼 없음: ${lib}`, fix: "레포의 tools/blender 를 받아 KF_BLENDER_LIB=<그 폴더>" };
|
|
113
|
+
try {
|
|
114
|
+
const b = findBlender();
|
|
115
|
+
return { ...base, ready: true, detail: `Blender ${b.version.join(".")} — 1920x1080 한 장 0.3–0.4초(M 맥)` };
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return { ...base, ready: false, detail: (e instanceof Error ? e.message : String(e)).split(" — ")[0], fix: process.platform === "darwin" ? "brew install --cask blender (4.3 이상)" : "blender.org 에서 4.3 이상" };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// three.js 는 레포 안 의존성이라 늘 있다 — 렌더 크롬이 CanvasDrawElement 로 살아 있는 장면을 텍스처로 올린다
|
|
122
|
+
function three(): CapabilityProvider[] {
|
|
123
|
+
const license = "three.js MIT";
|
|
124
|
+
return [
|
|
125
|
+
{ cap: "three", id: "transition3d", label: "3D 전환(이음매 장면 — dive·portal·cards·shatter·zoomOut·fold)", ready: true, detail: "레포 안", unitUsd: 0, unit: "전환 하나", license },
|
|
126
|
+
{ cap: "three", id: "stage3d", label: "화면 속 3D(입체 테마 무대)", ready: true, detail: "레포 안", unitUsd: 0, unit: "장면 하나", license },
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const musicDir = (): string => process.env.KF_MUSIC_DIR || fileURLToPath(new URL("../../../music/", import.meta.url));
|
|
131
|
+
|
|
132
|
+
// 곡 모음만 여기서 센다 — 생성 음악·목소리 공급자는 목소리·음악 담당(src/music·src/tts)이 같은 꼴로 더한다
|
|
133
|
+
function music(): CapabilityProvider[] {
|
|
134
|
+
const dir = musicDir();
|
|
135
|
+
const songs = existsSync(dir) ? readdirSync(dir).filter((f) => /\.(mp3|wav|m4a|aac|flac|ogg)$/i.test(f)) : [];
|
|
136
|
+
return [{ cap: "music", id: "library", label: "곡 모음", ready: songs.length > 0, detail: songs.length ? `${songs.length}곡(${dir})` : `비었다: ${dir}`, fix: songs.length ? undefined : "KF_MUSIC_DIR=<곡 폴더> 또는 --music 곡", unitUsd: 0, unit: "곡", license: "곡마다 music/README.md" }];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function capabilities(o: { probe?: boolean } = {}): Promise<CapabilityReport[]> {
|
|
140
|
+
const probe = o.probe ?? false;
|
|
141
|
+
const [img, vid] = await Promise.all([images(probe), videos(probe)]);
|
|
142
|
+
const all: CapabilityProvider[] = [...img, ...vid, blender(), ...three(), ...music(), ...voiceCapabilities(), ...musicGenCapabilities()];
|
|
143
|
+
return (["image", "video", "blender", "three", "music", "voice"] as Capability[]).map((cap) => {
|
|
144
|
+
const providers = all.filter((p) => p.cap === cap);
|
|
145
|
+
// 그림은 목록 순서(OG 기본 — 빠르고 실측된 길)가 권장, codex 는 0원 대안. 나머지는 제한 없는 것·싼 것 먼저
|
|
146
|
+
const usable = providers
|
|
147
|
+
.filter((p) => p.ready)
|
|
148
|
+
.sort((a, b) => Number(!!a.restrict) - Number(!!b.restrict) || (cap === "image" ? providers.indexOf(a) - providers.indexOf(b) : a.unitUsd - b.unitUsd));
|
|
149
|
+
return { cap, label: LABEL[cap], providers, best: usable[0] ?? null };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -128,6 +128,7 @@ async function store(
|
|
|
128
128
|
elapsedMs,
|
|
129
129
|
license: p.license,
|
|
130
130
|
region: p.region,
|
|
131
|
+
...(p.restrict ? { restrict: p.restrict } : {}),
|
|
131
132
|
cacheKey: key,
|
|
132
133
|
createdAt: new Date().toISOString(),
|
|
133
134
|
width: pr.width,
|
|
@@ -154,7 +155,8 @@ async function makeImage(p: ImageProvider, req: ImageRequest, tmp: string): Prom
|
|
|
154
155
|
raw = { ...again, costUsd: raw.costUsd + again.costUsd, notes: [...(again.notes ?? []), "alpha-failed", "chroma-key"] };
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
|
-
|
|
158
|
+
// 공급자마다 그리는 크기가 다르다(OG 는 16의 배수, codex 는 세 꼴) — 실제로 나온 크기를 재서 맞춘다
|
|
159
|
+
const { width: w, height: h } = await probe(tmp);
|
|
158
160
|
const [rw, rh] = req.size.split("x").map(Number);
|
|
159
161
|
if (w !== rw || h !== rh) {
|
|
160
162
|
await fitImage(tmp, tmp, rw, rh);
|
|
@@ -169,7 +171,7 @@ export async function genImage(id: string, req: ImageRequest, ctx: GenContext, p
|
|
|
169
171
|
const key = cacheKey("img", p, req, inputs);
|
|
170
172
|
const hit = fromCache(id, key, ctx);
|
|
171
173
|
if (hit) return hit;
|
|
172
|
-
const est = estimateImageUsd(req);
|
|
174
|
+
const est = p.estimateUsd ? p.estimateUsd(req) : estimateImageUsd(req);
|
|
173
175
|
const cap = ctx.imageMaxUsd ?? DEFAULT_IMAGE_MAX_USD;
|
|
174
176
|
if (est > cap) throw new NeedsApproval(p.id, est, `그림 한 장 상한 $${cap} 초과`);
|
|
175
177
|
mkdirSync(cacheDir(ctx), { recursive: true });
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { KEY_BG } from "../media.ts";
|
|
6
|
+
import type { ImageProvider, ImageRequest, RawResult } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
// ChatGPT 로그인으로 도는 codex CLI 의 내장 imagegen — API 키·요금 없이 구독 안에서 그린다(한 장 1–2분).
|
|
9
|
+
// kasaterm pane 의 codex 는 pane 전용 홈을 세우는 래퍼라 PATH 에서 그 shim 을 빼고 순정 codex 를 찾는다
|
|
10
|
+
export function codexBin(): string | null {
|
|
11
|
+
if (process.env.KF_CODEX) return existsSync(process.env.KF_CODEX) ? process.env.KF_CODEX : null;
|
|
12
|
+
const path = (process.env.PATH ?? "").split(":").filter((p) => !p.includes("kasaterm-shim-")).join(":");
|
|
13
|
+
const r = spawnSync("sh", ["-c", "command -v codex"], { encoding: "utf8", env: { ...process.env, PATH: path } });
|
|
14
|
+
const bin = r.stdout?.trim();
|
|
15
|
+
return r.status === 0 && bin ? bin : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function codexLoggedIn(bin = codexBin()): boolean {
|
|
19
|
+
if (!bin) return false;
|
|
20
|
+
const r = spawnSync(bin, ["login", "status"], { encoding: "utf8", timeout: 10_000 });
|
|
21
|
+
return r.status === 0 && /logged in/i.test(`${r.stdout}${r.stderr}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// imagegen 이 받는 세 꼴 — 요청 크기와 비율이 가장 가까운 것으로 그린 뒤 generate.ts 가 잘라 맞춘다
|
|
25
|
+
function orientation(size: string): string {
|
|
26
|
+
const [w, h] = size.split("x").map(Number);
|
|
27
|
+
const r = w / h;
|
|
28
|
+
return r > 1.2 ? "landscape 1536x1024" : r < 0.83 ? "portrait 1024x1536" : "square 1024x1024";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function run(bin: string, args: string[], cwd: string, timeoutMs: number): Promise<string> {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const p = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
34
|
+
const out: Buffer[] = [];
|
|
35
|
+
p.stdout.on("data", (b: Buffer) => out.push(b));
|
|
36
|
+
p.stderr.on("data", (b: Buffer) => out.push(b));
|
|
37
|
+
// codex 가 가끔 멈춘다 — 끊고 실패로 돌린다
|
|
38
|
+
const timer = setTimeout(() => p.kill("SIGKILL"), timeoutMs);
|
|
39
|
+
p.on("error", (e) => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
reject(e);
|
|
42
|
+
});
|
|
43
|
+
p.on("close", (code) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
const log = Buffer.concat(out).toString();
|
|
46
|
+
if (code === 0) resolve(log);
|
|
47
|
+
else reject(new Error(`codex 실패(${code ?? "시간 초과"}): ${log.trim().split("\n").slice(-4).join(" / ").slice(0, 400)}`));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 저장 지시를 안 따르면 codex 가 늘 쓰는 자리에서 이번에 생긴 그림을 찾는다
|
|
53
|
+
function newestGenerated(since: number): string | null {
|
|
54
|
+
const root = join(homedir(), ".codex", "generated_images");
|
|
55
|
+
if (!existsSync(root)) return null;
|
|
56
|
+
let best: { p: string; t: number } | null = null;
|
|
57
|
+
const walk = (d: string) => {
|
|
58
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
59
|
+
const p = join(d, e.name);
|
|
60
|
+
if (e.isDirectory()) walk(p);
|
|
61
|
+
else if (/\.png$/i.test(e.name)) {
|
|
62
|
+
const t = statSync(p).mtimeMs;
|
|
63
|
+
if (t >= since && (!best || t > best.t)) best = { p, t };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
walk(root);
|
|
68
|
+
return (best as { p: string } | null)?.p ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function codexImage(): ImageProvider {
|
|
72
|
+
return {
|
|
73
|
+
id: "codex-imagegen",
|
|
74
|
+
kind: "img",
|
|
75
|
+
model: "codex imagegen (gpt-image, ChatGPT 요금제)",
|
|
76
|
+
license: "OpenAI 이용약관 — 출력물 권리는 사용자에게, 상업 사용 가능(ChatGPT 요금제 안, 추가 요금 없음)",
|
|
77
|
+
region: "ChatGPT 로그인(codex CLI) — 한국 사용 가능",
|
|
78
|
+
keyName: "codex",
|
|
79
|
+
available: () => codexLoggedIn(),
|
|
80
|
+
estimateUsd: () => 0,
|
|
81
|
+
|
|
82
|
+
async generate(o: ImageRequest): Promise<RawResult> {
|
|
83
|
+
const bin = codexBin();
|
|
84
|
+
if (!bin) throw new Error("codex CLI 가 없다 — npm i -g @openai/codex 뒤 codex login(ChatGPT 계정)");
|
|
85
|
+
const work = mkdtempSync(join(tmpdir(), "kf-codex-img-"));
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
try {
|
|
88
|
+
const keyed = !!o.transparent;
|
|
89
|
+
const prompt = [
|
|
90
|
+
`Use $imagegen to create exactly one image, ${orientation(o.size)}, and save it as ./image.png in the current directory.`,
|
|
91
|
+
o.refs?.length ? "Use the attached image(s) as the reference — keep the same character, shapes and colors." : "",
|
|
92
|
+
o.prompt,
|
|
93
|
+
keyed ? KEY_BG : "",
|
|
94
|
+
"No text, letters, watermarks or logos unless the description above asks for them. Do not ask for confirmation. Do not create any other file.",
|
|
95
|
+
].filter(Boolean).join("\n\n");
|
|
96
|
+
// -i 는 가변 인자라 프롬프트 앞에 두면 프롬프트까지 그림 파일로 먹는다 — 프롬프트 뒤에
|
|
97
|
+
const args = ["-c", "features.daemon_auto_start=false", "exec", "-s", "workspace-write", "--skip-git-repo-check", "-C", work, prompt];
|
|
98
|
+
if (o.refs?.length) args.push("-i", ...o.refs);
|
|
99
|
+
await run(bin, args, work, Number(process.env.KF_CODEX_TIMEOUT_MS ?? 420_000));
|
|
100
|
+
const file = existsSync(join(work, "image.png")) ? join(work, "image.png") : newestGenerated(started);
|
|
101
|
+
if (!file) throw new Error("codex 가 그림을 남기지 않았다(./image.png·~/.codex/generated_images 둘 다 없음)");
|
|
102
|
+
return {
|
|
103
|
+
bytes: readFileSync(file),
|
|
104
|
+
ext: "png",
|
|
105
|
+
model: "codex imagegen",
|
|
106
|
+
seed: null,
|
|
107
|
+
costUsd: 0,
|
|
108
|
+
costEstimated: false,
|
|
109
|
+
params: { size: orientation(o.size), refs: o.refs?.length ?? 0 },
|
|
110
|
+
notes: ["codex", ...(keyed ? ["chroma-key"] : [])],
|
|
111
|
+
};
|
|
112
|
+
} finally {
|
|
113
|
+
rmSync(work, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export type { Asset, AssetKind, AssetMeta, ImageProvider, ImageRequest, ProviderInfo, RawResult, VideoProvider, VideoRequest } from "./types.ts";
|
|
2
|
-
export { findKey, hasKey, MissingKeyError, readKey, whereToPut } from "./keys.ts";
|
|
3
|
-
export { DEFAULT_IMAGE, imageProviders, pickImage, pickVideo, videoProviders } from "./registry.ts";
|
|
2
|
+
export { findKey, hasKey, howToGet, keyFile, MissingKeyError, readKey, whereToPut } from "./keys.ts";
|
|
3
|
+
export { DEFAULT_IMAGE, imageProviders, pickImage, pickVideo, providerReady, videoProviders } from "./registry.ts";
|
|
4
|
+
export { askTerminal, CAPABILITIES, parseAllow, WAIT, questionsMarkdown, questionsText, settle, type AnswerInput, type Capability, type Choice, type Question, type Settled } from "./ask.ts";
|
|
5
|
+
export { capabilities, type CapabilityProvider, type CapabilityReport } from "./capabilities.ts";
|
|
6
|
+
export { BLENDER_MIN, blenderLibDir, findBlender, hasBlenderLib } from "./blender.ts";
|
|
7
|
+
export { codexBin, codexLoggedIn } from "./image/codex.ts";
|
|
8
|
+
export { H3_RESTRICT } from "./video/h3.ts";
|
|
4
9
|
export {
|
|
5
10
|
cacheDir,
|
|
6
11
|
cacheKey,
|
|
@@ -16,3 +21,6 @@ export {
|
|
|
16
21
|
export { aspectOf, seam, seamFrames, still, type SeamOptions } from "./seam.ts";
|
|
17
22
|
export { genSpecs, referencedIds, resolvePlan, type GenReport, type GenSpec } from "./plan.ts";
|
|
18
23
|
export { retime } from "./media.ts";
|
|
24
|
+
export { cutVoice, duration as audioDuration, findSilences, layVoices, voicedSpan } from "./audio.ts";
|
|
25
|
+
export { alignWords, DEFAULT_STYLE, parseVoice, pickTts, speak, ttsProviders, voiceCapabilities, voiceQuestion, type Speech, type SpeechRequest, type TtsProvider, type Voice, type Word } from "./tts/index.ts";
|
|
26
|
+
export { addToLibrary, byMood, genMusic, listLibrary, MOOD_PROMPT, MOOD_TAGS, musicDir, musicGenCapabilities, musicLength, musicProviders, musicQuestion, pickMusic, type MusicAsset, type MusicProvider, type Track } from "./music/index.ts";
|
package/packages/gen/src/keys.ts
CHANGED
|
@@ -10,8 +10,34 @@ const SOURCES: Record<string, { env: string[]; files: string[] }> = {
|
|
|
10
10
|
gemini: { env: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], files: ["~/.config/gemini.key"] },
|
|
11
11
|
ltx: { env: ["LTX_API_KEY"], files: ["~/.config/ltx.key"] },
|
|
12
12
|
h3: { env: ["KF_H3_URL"], files: [] },
|
|
13
|
+
elevenlabs: { env: ["ELEVENLABS_API_KEY", "XI_API_KEY"], files: ["~/.config/elevenlabs.key"] },
|
|
14
|
+
dashscope: { env: ["DASHSCOPE_API_KEY"], files: ["~/.config/dashscope.key"] },
|
|
15
|
+
"qwen3-tts": { env: ["KF_QWEN3_TTS_URL"], files: [] },
|
|
13
16
|
};
|
|
14
17
|
|
|
18
|
+
// 없는 키를 물을 때 「어디서 받아 어디에 넣나」를 같이 보인다 — 받는 곳은 공급자 콘솔, 넣기는 kf setup key 가 값을 숨겨 받아 파일로
|
|
19
|
+
const HELP: Record<string, { label: string; get: string }> = {
|
|
20
|
+
opengateway: { label: "OpenGateway 키", get: "OpenGateway 콘솔(opengateway.ai)에서 API 키" },
|
|
21
|
+
fal: { label: "fal 키", get: "fal.ai/dashboard/keys" },
|
|
22
|
+
minimax: { label: "MiniMax 키", get: "platform.minimax.io 의 API 키" },
|
|
23
|
+
gemini: { label: "Gemini 키", get: "aistudio.google.com/apikey" },
|
|
24
|
+
ltx: { label: "LTX 키", get: "ltx.io 콘솔의 API 키" },
|
|
25
|
+
h3: { label: "사내 H3 주소", get: "파드 운영자에게 SGLang 주소(닫힌 망이면 터널) — 걸음 수 KF_H3_STEPS(터보 LoRA 9·없으면 50)도 같이" },
|
|
26
|
+
elevenlabs: { label: "ElevenLabs 키", get: "elevenlabs.io/app/settings/api-keys" },
|
|
27
|
+
dashscope: { label: "DashScope 키", get: "Alibaba Cloud Model Studio(dashscope) 콘솔의 API 키" },
|
|
28
|
+
"qwen3-tts": { label: "로컬 Qwen3-TTS 서버 주소", get: "tools/tts 의 서버를 띄운 주소" },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// 주소(h3·로컬 서버)는 비밀이 아니라 환경 변수로, 나머지는 kf setup key 로
|
|
32
|
+
export function howToGet(name: string): string {
|
|
33
|
+
const h = HELP[name];
|
|
34
|
+
const env = sources(name).env;
|
|
35
|
+
const put = name === "h3" || name.endsWith("-tts") ? `${env.join("·")}=<주소>` : `kf setup key ${name}(값을 숨겨 받아 ~/.config/kasaframe/keys/${name} 에) 또는 ${whereToPut(name)}`;
|
|
36
|
+
return `${h?.label ?? name}: ${h?.get ?? "공급자 콘솔"} → ${put}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const keyFile = (name: string): string => expand(`~/.config/kasaframe/keys/${name}`);
|
|
40
|
+
|
|
15
41
|
export class MissingKeyError extends Error {
|
|
16
42
|
keyName: string;
|
|
17
43
|
constructor(keyName: string) {
|