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,110 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { listSeries, loadSeries, type Purpose, type Series, seriesDir, writeSeries } from "@kasaframe/plan";
|
|
4
|
+
import { loadTheme } from "@kasaframe/themes";
|
|
5
|
+
import type { Command } from "../command.ts";
|
|
6
|
+
import { parseFlags } from "../command.ts";
|
|
7
|
+
|
|
8
|
+
const USAGE = `kf series list
|
|
9
|
+
kf series show <id>
|
|
10
|
+
kf series new <id> --label "OG 쇼츠" --match "OG 쇼츠,OG shorts" --purpose shorts [--theme og-measure] [--voice gemini:Charon]
|
|
11
|
+
[--mood deep | --music music/x.mp3] [--captions on|off|none] [--duration 45-60] [--end-title 이름] [--end-line 한 줄]
|
|
12
|
+
[--answers image=og-gpt-image-2.5-sunburst,video=none] [--from <잘 나온 프로젝트 폴더>] [--force]
|
|
13
|
+
|
|
14
|
+
시리즈 묶음(series/<id>.json, KF_SERIES_DIR 로 바꿈) — 요청이 match 의 말을 짚으면 kf make 가 묻지 않고 이 묶음대로 간다:
|
|
15
|
+
용도·테마·목소리·곡(파일이면 늘 그 곡, 분위기면 곡 모음의 같은 곡)·자막·길이·끝 카드·생성 질문 답. 용도 규칙은 그 용도 틀이 정한다.
|
|
16
|
+
--from 은 그 프로젝트의 plan.json·kf.json(용도·테마·목소리·곡·자막·길이·이름·고른 답)을 묶음으로 옮긴다 — 플래그가 이긴다.`;
|
|
17
|
+
|
|
18
|
+
function range(v: string): { min: number; max: number; default: number } {
|
|
19
|
+
const m = /^(\d+(?:\.\d+)?)(?:-(\d+(?:\.\d+)?))?$/.exec(v);
|
|
20
|
+
if (!m) throw new Error(`--duration 은 45 또는 45-60 — ${v}`);
|
|
21
|
+
const min = Number(m[1]);
|
|
22
|
+
const max = Number(m[2] ?? m[1]);
|
|
23
|
+
return { min, max, default: min };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fromProject(dir: string): Partial<Series> {
|
|
27
|
+
const plan = JSON.parse(readFileSync(join(dir, "plan.json"), "utf8"));
|
|
28
|
+
const kf = existsSync(join(dir, "kf.json")) ? JSON.parse(readFileSync(join(dir, "kf.json"), "utf8")) : {};
|
|
29
|
+
const answers: Record<string, string> = Object.fromEntries(Object.entries(plan.answers ?? {}).filter(([k, v]) => k !== "voice" && k !== "music" && typeof v === "string") as [string, string][]);
|
|
30
|
+
return {
|
|
31
|
+
purpose: plan.purpose,
|
|
32
|
+
...(plan.theme ? { theme: plan.theme } : { style: plan.style }),
|
|
33
|
+
...(plan.voice ? { voice: plan.voice } : {}),
|
|
34
|
+
...(plan.mood ? { music: { mood: plan.mood } } : {}),
|
|
35
|
+
captions: kf.captions ?? plan.captions,
|
|
36
|
+
duration: { min: plan.duration, max: plan.duration, default: plan.duration },
|
|
37
|
+
end: { title: plan.title },
|
|
38
|
+
...(Object.keys(answers).length ? { answers } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function show(s: Series): string {
|
|
43
|
+
const warn: string[] = [];
|
|
44
|
+
if (s.theme) {
|
|
45
|
+
try {
|
|
46
|
+
const t = loadTheme(s.theme);
|
|
47
|
+
if (t.status !== "approved") warn.push(`테마 ${s.theme} 는 ${t.status} — 승인 전`);
|
|
48
|
+
if (!t.purposes.includes(s.purpose)) warn.push(`테마 ${s.theme} 의 용도에 ${s.purpose} 가 없다`);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
warn.push(`테마 ${s.theme}: ${e instanceof Error ? e.message : e}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [JSON.stringify(s, null, 2), ...warn.map((w) => `주의: ${w}`)].join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cmd: Command = {
|
|
57
|
+
summary: "시리즈 묶음 — 짚으면 묻지 않고 매번 같은 용도·테마·목소리·곡·자막·길이·끝 카드",
|
|
58
|
+
usage: USAGE,
|
|
59
|
+
async run(argv) {
|
|
60
|
+
const [sub, ...rest] = argv;
|
|
61
|
+
const { args, opts } = parseFlags(rest);
|
|
62
|
+
const str = (k: string) => (typeof opts[k] === "string" ? (opts[k] as string) : undefined);
|
|
63
|
+
if (sub === "list") {
|
|
64
|
+
const all = listSeries();
|
|
65
|
+
if (!all.length) console.log(`시리즈 없음 — kf series new 로 (${seriesDir()})`);
|
|
66
|
+
for (const s of all) console.log(`${s.id.padEnd(16)} ${s.label} · ${s.purpose}${s.theme ? ` · 테마 ${s.theme}` : ""}${s.voice ? ` · ${s.voice}` : ""} — 「${s.match.join("」「")}」`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
if (sub === "show" && args[0]) {
|
|
70
|
+
console.log(show(loadSeries(args[0])));
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
if (sub === "new" && args[0]) {
|
|
74
|
+
const id = args[0];
|
|
75
|
+
if (existsSync(join(seriesDir(), `${id}.json`)) && !opts.force) {
|
|
76
|
+
console.error(`kf series new: ${id} 가 이미 있다 — 덮으려면 --force`);
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
const base: Partial<Series> = str("from") ? fromProject(resolve(str("from")!)) : {};
|
|
80
|
+
const answers = str("answers") ? Object.fromEntries(str("answers")!.split(",").map((kv) => kv.split("=").map((x) => x.trim()) as [string, string])) : undefined;
|
|
81
|
+
const s: Series = {
|
|
82
|
+
...base,
|
|
83
|
+
id,
|
|
84
|
+
label: str("label") ?? base.label ?? id,
|
|
85
|
+
match: str("match") ? str("match")!.split(",").map((x) => x.trim()).filter(Boolean) : (base.match ?? []),
|
|
86
|
+
purpose: (str("purpose") ?? base.purpose) as Purpose,
|
|
87
|
+
...(str("theme") ? { theme: str("theme") } : {}),
|
|
88
|
+
...(str("voice") ? { voice: str("voice") } : {}),
|
|
89
|
+
...(str("music") ? { music: { file: str("music") } } : str("mood") ? { music: { mood: str("mood") } } : {}),
|
|
90
|
+
...(str("captions") ? { captions: str("captions") as Series["captions"] } : {}),
|
|
91
|
+
...(str("duration") ? { duration: range(str("duration")!) } : {}),
|
|
92
|
+
...(str("end-title") || str("end-line") ? { end: { ...base.end, ...(str("end-title") ? { title: str("end-title") } : {}), ...(str("end-line") ? { line: str("end-line") } : {}) } } : {}),
|
|
93
|
+
...(answers ? { answers: { ...base.answers, ...answers } } : {}),
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
// 사람이 열어 볼 파일이라 이름·짚는 말·용도를 맨 위에
|
|
97
|
+
const { id: _, label, match, purpose, ...rest } = s;
|
|
98
|
+
const file = writeSeries({ id, label, match, purpose, ...rest });
|
|
99
|
+
console.log(`${relative(process.cwd(), file) || file}\n${show(s)}`);
|
|
100
|
+
return 0;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error(`kf series new: ${e instanceof Error ? e.message : e}`);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
console.error(USAGE);
|
|
107
|
+
return 2;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
export default cmd;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { keyFile } from "@kasaframe/gen";
|
|
3
6
|
import { platform } from "node:os";
|
|
4
7
|
import { fileURLToPath } from "node:url";
|
|
5
8
|
import { beatsVenv, checks, playwrightCli, report, requirements, venvPython } from "../deps.ts";
|
|
@@ -28,25 +31,61 @@ function beats(): boolean {
|
|
|
28
31
|
return step("librosa", venvPython(beatsVenv), ["-m", "pip", "install", "-r", requirements]);
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
//
|
|
34
|
+
// 엔진·자주 쓰는 한글 글꼴은 패키지에 실려 있어 받을 것은 나머지 얼굴뿐이다(처음 쓸 때도 받는다 — 오프라인으로 구울 기계면 미리). 일본어 가사는 node packages/jizura/src/install.ts --lang ko,ja
|
|
32
35
|
const jizura = () => step("JIZURA", process.execPath, [fileURLToPath(import.meta.resolve("@kasaframe/jizura/install")), "--lang", "ko"]);
|
|
33
36
|
|
|
34
37
|
// 이름 하나 = 깔 것 하나. 이름 없이 부르면 DEFAULT 만
|
|
35
38
|
const STEPS: Record<string, { what: string; run: () => boolean }> = {
|
|
36
39
|
chrome: { what: "Playwright 크롬(chromium) — 렌더·검사", run: chrome },
|
|
37
40
|
beats: { what: `librosa(${beatsVenv}) — 곡 분석 kf beats`, run: beats },
|
|
38
|
-
jizura: { what: "JIZURA 가사
|
|
41
|
+
jizura: { what: "JIZURA 가사 층의 나머지 한국어 글꼴 17MB(~/.cache/kasaframe/jizura) — 엔진·자주 쓰는 글꼴은 실려 있고 이것도 처음 쓸 때 받는다, 오프라인으로 구울 기계면 미리", run: jizura },
|
|
39
42
|
lyrics: { what: "demucs·stable-ts(~/.cache/kasaframe/py/lyrics, 2GB 안팎) — kf lyrics align", run: setupLyrics },
|
|
40
43
|
};
|
|
41
44
|
const DEFAULT = ["chrome", "beats"];
|
|
42
45
|
|
|
46
|
+
// 키는 화면·셸 기록에 남지 않게 — 터미널이면 입력을 가리고, 아니면 표준 입력 한 줄로 받는다. 값은 찍지 않는다
|
|
47
|
+
function readSecret(prompt: string): Promise<string> {
|
|
48
|
+
return new Promise((done) => {
|
|
49
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: !!process.stdin.isTTY });
|
|
50
|
+
if (process.stdin.isTTY) {
|
|
51
|
+
process.stderr.write(prompt);
|
|
52
|
+
(rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = () => {};
|
|
53
|
+
}
|
|
54
|
+
rl.once("line", (line) => {
|
|
55
|
+
rl.close();
|
|
56
|
+
if (process.stdin.isTTY) process.stderr.write("\n");
|
|
57
|
+
done(line.trim());
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function key(name: string | undefined): Promise<number> {
|
|
63
|
+
if (!name || !/^[a-z0-9-]+$/.test(name)) {
|
|
64
|
+
console.error("kf setup key <이름> — opengateway · fal · gemini · minimax · ltx · elevenlabs · dashscope (kf doctor 「생성 능력」이 무엇이 없는지 알려 준다)");
|
|
65
|
+
return 2;
|
|
66
|
+
}
|
|
67
|
+
const value = await readSecret(`${name} 키(입력은 안 보인다): `);
|
|
68
|
+
if (!value) {
|
|
69
|
+
console.error("kf setup key: 빈 값 — 아무것도 쓰지 않았다");
|
|
70
|
+
return 2;
|
|
71
|
+
}
|
|
72
|
+
const file = keyFile(name);
|
|
73
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
74
|
+
writeFileSync(file, `${value}\n`, { mode: 0o600 });
|
|
75
|
+
chmodSync(file, 0o600);
|
|
76
|
+
console.log(`${file} 에 썼다(600). 확인: kf doctor`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
43
80
|
const cmd: Command = {
|
|
44
81
|
summary: "바깥 도구 깔기 — Playwright 크롬·librosa. ffmpeg 는 시스템 패키지라 깔 명령만 알린다",
|
|
45
82
|
usage: `kf setup [이름…] 이름 없이: ${DEFAULT.join(" ")}
|
|
83
|
+
kf setup key <이름> 생성 공급자 키를 숨겨 받아 ~/.config/kasaframe/keys/<이름>(600)에 — 값은 찍지 않는다
|
|
46
84
|
${Object.entries(STEPS).map(([k, s]) => ` ${k.padEnd(8)} ${s.what}`).join("\n")}
|
|
47
85
|
kf beats 는 KF_PYTHON 이 없으면 위 venv 를 먼저 쓴다. 끝에 kf doctor 와 같은 표를 찍는다`,
|
|
48
86
|
async run(argv) {
|
|
49
87
|
const { args } = parseFlags(argv);
|
|
88
|
+
if (args[0] === "key") return key(args[1]);
|
|
50
89
|
const names = args.length ? args : DEFAULT;
|
|
51
90
|
const unknown = names.filter((n) => !STEPS[n]);
|
|
52
91
|
if (unknown.length) {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { listThemes, sheetDir, type Theme, themeDims, themesDir } from "@kasaframe/themes";
|
|
4
6
|
import { briefs, review, scaffold, sessionPrompt, setStatus, staticCheck } from "@kasaframe/themes/factory";
|
|
7
|
+
import { launch } from "../browser.ts";
|
|
5
8
|
import { type Command, num, parseFlags } from "../command.ts";
|
|
6
9
|
|
|
7
10
|
const kfBin = new URL("../kf.ts", import.meta.url).pathname;
|
|
@@ -13,6 +16,9 @@ kf theme new <id> --ref <그림·영상·폴더·URL> [--ref …] [--name 이름
|
|
|
13
16
|
kf theme check <id> [--no-page] 계약·빈칸 검사 + kf check sample
|
|
14
17
|
kf theme review <id> [--tabs 2] 굽기 → mp4 검사 → 장면 모음·대표 장면 → 공유 폴더 → status review
|
|
15
18
|
kf theme approve <id> 사람이 견본을 보고 OK 한 뒤에만
|
|
19
|
+
kf theme sheet [--out 파일.png] approved 테마마다 같은 문장 한 장면(쇼츠 9:16)을 감독 모드로 짓고 검사를 거쳐, 장면 끝 프레임을
|
|
20
|
+
격자 한 장으로 — 이름·2D/3D 딱지, 안전 영역 점선. 기본 themes/_sheet/sheet.png, 칸은 그 옆 <id>.png
|
|
21
|
+
(kf make 의 테마 질문 보기가 이 칸을 견본으로 짚는다). 검사 오류가 남은 칸은 표시하고 종료 1
|
|
16
22
|
|
|
17
23
|
new 는 themes/<id>/ 에 계약 파일 뼈대·refs/·브리프대로 자른 곡과 박·장면 넷 뼈대·THEME-MAKE.md(지시서)를 짓고,
|
|
18
24
|
그 지시서를 받을 세션을 여는 명령을 알려 준다. 테마는 그 세션이 만든다.`,
|
|
@@ -20,6 +26,7 @@ kf theme approve <id> 사람이 견본을 보고 OK 한 뒤에만
|
|
|
20
26
|
const [sub, ...rest] = argv;
|
|
21
27
|
const { args, opts } = parseFlags(rest);
|
|
22
28
|
const id = args[0];
|
|
29
|
+
if (sub === "sheet") return sheet(resolve(typeof opts.out === "string" ? opts.out : join(sheetDir, "sheet.png")));
|
|
23
30
|
if (sub === "list" || !sub) {
|
|
24
31
|
const all = listThemes();
|
|
25
32
|
if (!all.length) console.log("(테마 없음)");
|
|
@@ -83,3 +90,83 @@ kf theme approve <id> 사람이 견본을 보고 OK 한 뒤에만
|
|
|
83
90
|
};
|
|
84
91
|
|
|
85
92
|
export default cmd;
|
|
93
|
+
|
|
94
|
+
// 견본 시트 — 테마끼리 견주려면 내용이 같아야 한다. 감독 모드가 실제로 짓는 장면(쇼츠 첫 장면 hook-number)을 테마만 바꿔,
|
|
95
|
+
// 검사를 거쳐(떨어진 패턴은 감독이 다음 후보로 다시 짓는다) 사람이 실제로 받을 화면을 장면 끝 프레임에서 찍는다 — 움직임이 다 앉은 자리
|
|
96
|
+
const SAMPLE = {
|
|
97
|
+
title: "kasaframe",
|
|
98
|
+
request: "테마 견본 — 같은 문장을 테마마다",
|
|
99
|
+
scenes: [{ id: "hook", type: "hook-number", value: 57, unit: "×", label: "KV 캐시가 이만큼 작아진다", source: "DeepSeek-V2 기술 보고서", dur: 4 }],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
async function sheet(out: string): Promise<number> {
|
|
103
|
+
const themes = listThemes({ status: "approved" });
|
|
104
|
+
if (!themes.length) {
|
|
105
|
+
console.error("kf theme sheet: approved 테마가 없다");
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
109
|
+
const tmp = mkdtempSync(join(tmpdir(), "kf-theme-sheet-"));
|
|
110
|
+
const cells: { t: Theme; png: string; failed: boolean }[] = [];
|
|
111
|
+
let frame = { width: 1080, height: 1920, safe: { top: 0, bottom: 0, left: 0, right: 0 }, at: 0 };
|
|
112
|
+
try {
|
|
113
|
+
for (const t of themes) {
|
|
114
|
+
const dir = join(tmp, t.id);
|
|
115
|
+
const png = join(dirname(out), `${t.id}.png`);
|
|
116
|
+
const kf = (...a: string[]) => spawnSync(process.execPath, [kfBin, ...a], { encoding: "utf8" });
|
|
117
|
+
let r = kf("new", dir, "--template", "shorts", "--theme", t.id, "--title", SAMPLE.title, "--series", "none");
|
|
118
|
+
if (r.status === 0) {
|
|
119
|
+
writeFileSync(join(dir, "content.json"), `${JSON.stringify(SAMPLE, null, 2)}\n`);
|
|
120
|
+
r = kf("direct", dir, "--no-narrate", "--no-lock");
|
|
121
|
+
}
|
|
122
|
+
// 1 은 다시 지어도 검사 오류가 남았다는 뜻 — 찍되 칸에 표시한다
|
|
123
|
+
const failed = r.status === 1;
|
|
124
|
+
if (r.status === 0 || failed) {
|
|
125
|
+
const p = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8"));
|
|
126
|
+
const scene = p.scenes.find((x: { id: string }) => x.id === SAMPLE.scenes[0].id);
|
|
127
|
+
frame = { width: p.width, height: p.height, safe: p.safeArea, at: +(scene.from + scene.dur - 1 / p.fps).toFixed(4) };
|
|
128
|
+
r = kf("still", dir, "--t", String(frame.at), "--out", png);
|
|
129
|
+
}
|
|
130
|
+
if (r.status !== 0) {
|
|
131
|
+
console.error(`테마 ${t.id}: 못 찍음 — ${`${r.stderr ?? ""}${r.stdout ?? ""}`.trim().split("\n").slice(-3).join(" / ")}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
console.error(`테마 ${t.id}: ${png}${failed ? " — 검사 오류가 남았다(out/check.json)" : ""}`);
|
|
135
|
+
cells.push({ t, png, failed });
|
|
136
|
+
}
|
|
137
|
+
if (!cells.length) return 1;
|
|
138
|
+
const w = 360;
|
|
139
|
+
const h = Math.round((w * frame.height) / frame.width);
|
|
140
|
+
const k = w / frame.width;
|
|
141
|
+
const html = `<!doctype html><meta charset="utf-8"><style>
|
|
142
|
+
body { margin: 0; padding: 24px; background: #111; font: 16px/1.35 -apple-system, "Apple SD Gothic Neo", sans-serif; color: #ddd; }
|
|
143
|
+
h1 { margin: 0 0 16px; font-size: 18px; font-weight: 600; color: #fff; }
|
|
144
|
+
main { display: grid; grid-template-columns: repeat(${cells.length}, ${w}px); gap: 16px; }
|
|
145
|
+
figure { margin: 0; position: relative; }
|
|
146
|
+
.safe { position: absolute; left: 0; width: ${w}px; border-top: 2px dashed #ff4d4d; }
|
|
147
|
+
img { display: block; width: ${w}px; height: ${h}px; outline: 1px solid #333; }
|
|
148
|
+
figcaption { padding: 8px 2px 0; }
|
|
149
|
+
b { color: #fff; }
|
|
150
|
+
.d { display: inline-block; margin-left: 6px; padding: 0 6px; border: 1px solid #666; border-radius: 4px; font-size: 13px; }
|
|
151
|
+
small { display: block; color: #999; font-size: 13px; }
|
|
152
|
+
.bad { color: #ff6b6b; }
|
|
153
|
+
</style><h1>같은 문장 한 장면 — 「${SAMPLE.scenes[0].value}${SAMPLE.scenes[0].unit} ${SAMPLE.scenes[0].label}」 · 쇼츠 9:16 · 장면 끝 프레임(${frame.at}초) · 빨간 점선 밖은 플랫폼 UI 자리라 비운다</h1><main>${cells
|
|
154
|
+
.map(
|
|
155
|
+
({ t, png, failed }) =>
|
|
156
|
+
`<figure><img src="data:image/png;base64,${readFileSync(png).toString("base64")}"><div class="safe" style="top:${Math.round(frame.safe.top * k)}px"></div><div class="safe" style="top:${Math.round((frame.height - frame.safe.bottom) * k)}px"></div><figcaption><b>${t.name}</b><span class="d">${themeDims(t)}</span><small>${t.id}${failed ? ' · <span class="bad">검사 오류 남음</span>' : ""}</small></figcaption></figure>`,
|
|
157
|
+
)
|
|
158
|
+
.join("")}</main>`;
|
|
159
|
+
const browser = await launch();
|
|
160
|
+
try {
|
|
161
|
+
const page = await browser.newPage({ viewport: { width: 48 + cells.length * w + (cells.length - 1) * 16, height: 400 } });
|
|
162
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
163
|
+
writeFileSync(out, await page.screenshot({ fullPage: true, type: "png" }));
|
|
164
|
+
} finally {
|
|
165
|
+
await browser.close();
|
|
166
|
+
}
|
|
167
|
+
console.log(out);
|
|
168
|
+
return cells.length === themes.length && !cells.some((c) => c.failed) ? 0 : 1;
|
|
169
|
+
} finally {
|
|
170
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
}
|
package/packages/cli/src/deps.ts
CHANGED
|
@@ -43,8 +43,8 @@ function installHint(pkg: { brew: string; apt: string; url: string }): string {
|
|
|
43
43
|
|
|
44
44
|
function node(): Check {
|
|
45
45
|
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
46
|
-
const ok = major > 22 || (major === 22 && minor >=
|
|
47
|
-
return { name: "Node 22.
|
|
46
|
+
const ok = major > 22 || (major === 22 && minor >= 15);
|
|
47
|
+
return { name: "Node 22.15 이상", ok, required: true, detail: process.version, fix: ok ? undefined : "https://nodejs.org 에서 22 LTS 이상" };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
function ffmpeg(): Check {
|
|
@@ -56,7 +56,7 @@ function ffmpeg(): Check {
|
|
|
56
56
|
ok,
|
|
57
57
|
required: true,
|
|
58
58
|
detail: ok ? (/version (\S+)/.exec(r.stdout)?.[1] ?? "있음") : "없음 — MP4 로 굽기·검사에 쓴다",
|
|
59
|
-
fix: ok ? undefined : installHint({ brew: "ffmpeg", apt: "ffmpeg", url: "https://ffmpeg.org/download.html" })
|
|
59
|
+
fix: ok ? undefined : `${installHint({ brew: "ffmpeg", apt: "ffmpeg", url: "https://ffmpeg.org/download.html" })} — PATH 밖 정적 빌드면 KF_FFMPEG=<ffmpeg 경로> KF_FFPROBE=<ffprobe 경로>`,
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -72,7 +72,7 @@ async function chrome(): Promise<Check> {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
function librosa(): Check {
|
|
75
|
+
export function librosa(): Check {
|
|
76
76
|
const py = python();
|
|
77
77
|
const r = run(py, ["-c", "import librosa; print(librosa.__version__)"]);
|
|
78
78
|
const ok = r.status === 0;
|
|
@@ -110,8 +110,8 @@ export async function checks(): Promise<Check[]> {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
// 한글은 터미널에서 두 칸을 먹는다 — padEnd 로는 열이 어긋난다
|
|
113
|
-
const cols = (s: string) => [...s].reduce((n, ch) => n + (/[\u1100-\u11ff\u3000-\u9fff\uac00-\ud7af]/.test(ch) ? 2 : 1), 0);
|
|
114
|
-
const pad = (s: string, n: number) => s + " ".repeat(Math.max(0, n - cols(s)));
|
|
113
|
+
export const cols = (s: string) => [...s].reduce((n, ch) => n + (/[\u1100-\u11ff\u3000-\u9fff\uac00-\ud7af]/.test(ch) ? 2 : 1), 0);
|
|
114
|
+
export const pad = (s: string, n: number) => s + " ".repeat(Math.max(0, n - cols(s)));
|
|
115
115
|
|
|
116
116
|
export function report(list: Check[]): void {
|
|
117
117
|
const w = Math.max(...list.map((c) => cols(c.name))) + 2;
|
package/packages/cli/src/kf.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readdirSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { fontsMissing } from "@kasaframe/jizura/install";
|
|
4
5
|
import type { Command } from "./command.ts";
|
|
5
6
|
|
|
6
7
|
const commandsDir = fileURLToPath(new URL("./commands/", import.meta.url));
|
|
@@ -44,10 +45,20 @@ async function main(argv: string[]): Promise<number> {
|
|
|
44
45
|
return (await cmd.run(rest)) ?? 0;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// 가사 층 글꼴을 처음 쓰다 못 받았으면(서버가 받는다 — jizura/vite.ts) 다른 글꼴로 구운 결과 대신 고를 길을 찍는다.
|
|
49
|
+
// 명령이 실패했으면 종료 3, 그 글꼴을 쓰는 프레임 없이 끝났으면 그대로
|
|
50
|
+
async function finish(code: number): Promise<number> {
|
|
51
|
+
const miss = fontsMissing();
|
|
52
|
+
if (!miss.length) return code;
|
|
53
|
+
const [{ fontsQuestion }, { questionsText }] = await Promise.all([import("./commands/lyrics.ts"), import("@kasaframe/gen")]);
|
|
54
|
+
console.error(`\n${code ? "" : "이번 구간은 그 글꼴 없이 끝났다 — 그 플랜이 나오는 구간을 구우려면 먼저 고른다\n"}${questionsText([fontsQuestion(miss)])}`);
|
|
55
|
+
return code ? 3 : code;
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
main(process.argv.slice(2)).then(
|
|
48
|
-
(code) => process.exit(code),
|
|
49
|
-
(err) => {
|
|
59
|
+
async (code) => process.exit(await finish(code)),
|
|
60
|
+
async (err) => {
|
|
50
61
|
console.error(err instanceof Error ? (err.stack ?? err.message) : err);
|
|
51
|
-
process.exit(1);
|
|
62
|
+
process.exit(await finish(1));
|
|
52
63
|
},
|
|
53
64
|
);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { audioDuration, findSilences } from "@kasaframe/gen";
|
|
5
|
+
|
|
6
|
+
// 내레이션 + 곡 섞기 — kf.json 에 audio(섞기 값)가 있는 프로젝트만. 없으면 렌더는 예전처럼 곡만 얹는다
|
|
7
|
+
export interface AudioMix {
|
|
8
|
+
musicDb: number; // 말하는 동안 곡이 목소리보다 이만큼 아래(덕킹 포함)
|
|
9
|
+
duck: boolean;
|
|
10
|
+
lufs: number;
|
|
11
|
+
truePeak: number;
|
|
12
|
+
fadeOut: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 목소리를 먼저 이 크기로 맞춘다 — 덕킹 문턱이 목소리 크기에 묶여 있어서 공급자마다 다른 크기를 한 자리로
|
|
16
|
+
const VOICE_LUFS = -18;
|
|
17
|
+
// 덕킹 두 입력의 프레임 크기를 맞춘다 — 목소리 wav(1024)·곡 mp3(1152) 프레임이 도착 차례에 따라 sidechaincompress 결과가
|
|
18
|
+
// 실행마다 달라졌다(같은 요청이면 바이트까지 같은 영상이어야 한다). 두 갈래를 같은 크기로 잘라 넣으면 늘 같은 값이 나온다
|
|
19
|
+
const FRAMES = "asetnsamples=n=1024:p=0";
|
|
20
|
+
const SIDECHAIN = "sidechaincompress=threshold=0.025:ratio=3:attack=20:release=400:knee=4";
|
|
21
|
+
|
|
22
|
+
function ffmpeg(args: string[]): Promise<string> {
|
|
23
|
+
return new Promise((done, fail) => {
|
|
24
|
+
const p = spawn("ffmpeg", ["-hide_banner", "-nostats", "-y", ...args]);
|
|
25
|
+
let err = "";
|
|
26
|
+
p.stderr.on("data", (b: Buffer) => (err += b));
|
|
27
|
+
p.on("error", fail);
|
|
28
|
+
p.on("close", (code) => (code === 0 ? done(err) : fail(new Error(`ffmpeg 섞기 실패(${code}): ${err.trim().split("\n").slice(-3).join(" / ")}`))));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 통합 크기(LUFS) — 무음은 게이트가 빼서 말·곡이 실제로 울리는 곳만 잰다
|
|
33
|
+
async function loudness(input: string[], filter = ""): Promise<number> {
|
|
34
|
+
const err = await ffmpeg([...input, "-af", `${filter ? `${filter},` : ""}ebur128=framelog=quiet`, "-f", "null", "-"]);
|
|
35
|
+
const m = err.match(/I:\s+(-?[\d.]+|-inf) LUFS/g)?.at(-1)?.match(/(-?[\d.]+|-inf)/);
|
|
36
|
+
const v = m ? Number(m[1]) : NaN;
|
|
37
|
+
return Number.isFinite(v) ? v : -70;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 말하는 구간만 고르는 aselect 식 — 목소리 한 줄에서 0.3초 넘는 숨을 뺀 자리
|
|
41
|
+
async function speechSelect(voice: string): Promise<string | null> {
|
|
42
|
+
const total = await audioDuration(voice);
|
|
43
|
+
const spans: [number, number][] = [];
|
|
44
|
+
let t = 0;
|
|
45
|
+
for (const s of await findSilences(voice, { db: -40, min: 0.3 })) {
|
|
46
|
+
if (s.from > t + 0.2) spans.push([t, s.from]);
|
|
47
|
+
t = Math.min(s.to, total);
|
|
48
|
+
}
|
|
49
|
+
if (total > t + 0.2) spans.push([t, total]);
|
|
50
|
+
return spans.length ? spans.map(([a, b]) => `between(t,${a.toFixed(3)},${b.toFixed(3)})`).join("+") : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Sources {
|
|
54
|
+
voice?: string;
|
|
55
|
+
music?: string;
|
|
56
|
+
musicFrom: number; // 곡 시각 = musicFrom + 영상 시각
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function mixSettings(dir: string): { audio: AudioMix; voice?: string } | null {
|
|
60
|
+
const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8"));
|
|
61
|
+
if (!kf.audio) return null;
|
|
62
|
+
const voice = kf.narration ? resolve(dir, kf.narration) : undefined;
|
|
63
|
+
return { audio: kf.audio, voice: voice && existsSync(voice) ? voice : undefined };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 영상 [from, from+dur) 의 소리를 out(wav)으로. total 은 영상 전체 길이 — 끝 페이드는 영상 끝에 걸린다
|
|
67
|
+
export async function mixAudio(src: Sources, o: { audio: AudioMix; from: number; dur: number; total: number; out: string }): Promise<string> {
|
|
68
|
+
const { audio } = o;
|
|
69
|
+
const seg = (path: string, at: number) => ["-ss", at.toFixed(6), "-t", o.dur.toFixed(6), "-i", path];
|
|
70
|
+
const inputs: string[] = [];
|
|
71
|
+
const chains: string[] = [];
|
|
72
|
+
let k = 0;
|
|
73
|
+
let voiceLabel = "";
|
|
74
|
+
let musicLabel = "";
|
|
75
|
+
// 끝 페이드는 영상 끝 몇 초에만 — 구간만 굽을 때도 같은 자리
|
|
76
|
+
const fadeAt = o.total - audio.fadeOut - o.from;
|
|
77
|
+
const fade = audio.fadeOut > 0 && fadeAt < o.dur ? `,afade=t=out:st=${Math.max(0, fadeAt).toFixed(4)}:d=${audio.fadeOut.toFixed(4)}` : "";
|
|
78
|
+
let voiceGain = 0;
|
|
79
|
+
if (src.voice) {
|
|
80
|
+
// 크기는 영상 전체로 잰다 — 구간만 재면 구간마다 목소리 크기가 달라진다
|
|
81
|
+
voiceGain = VOICE_LUFS - (await loudness(["-i", src.voice]));
|
|
82
|
+
inputs.push(...seg(src.voice, o.from));
|
|
83
|
+
chains.push(`[${k}]aresample=48000,volume=${voiceGain.toFixed(2)}dB,pan=stereo|c0=c0|c1=c0,${FRAMES},asplit=2[v][vsc]`);
|
|
84
|
+
voiceLabel = "[v]";
|
|
85
|
+
k++;
|
|
86
|
+
}
|
|
87
|
+
if (src.music) {
|
|
88
|
+
// 말이 있으면 말하는 동안 곡이 목소리보다 |musicDb| 아래, 없으면 곡이 주인공이라 그대로 두고 정규화만
|
|
89
|
+
const gain = src.voice ? await musicGain(src as Required<Sources>, voiceGain, audio, o.total) : 0;
|
|
90
|
+
inputs.push("-stream_loop", "-1", ...seg(src.music, src.musicFrom + o.from));
|
|
91
|
+
chains.push(`[${k}]aresample=48000,aformat=channel_layouts=stereo,volume=${gain.toFixed(2)}dB${fade},${FRAMES}[m0]`);
|
|
92
|
+
musicLabel = "[m0]";
|
|
93
|
+
if (src.voice && audio.duck) {
|
|
94
|
+
chains.push(`[m0][vsc]${SIDECHAIN}[m1]`);
|
|
95
|
+
musicLabel = "[m1]";
|
|
96
|
+
}
|
|
97
|
+
k++;
|
|
98
|
+
}
|
|
99
|
+
if (!voiceLabel && !musicLabel) throw new Error("섞을 소리가 없다");
|
|
100
|
+
if (voiceLabel && !musicLabel) chains.push("[vsc]anullsink");
|
|
101
|
+
const parts = [voiceLabel, musicLabel].filter(Boolean);
|
|
102
|
+
chains.push(parts.length === 2 ? `${parts.join("")}amix=inputs=2:normalize=0:duration=longest,apad,atrim=0:${o.dur.toFixed(6)}[pre]` : `${parts[0]}apad,atrim=0:${o.dur.toFixed(6)}[pre]`);
|
|
103
|
+
const pre = o.out.replace(/\.wav$/, ".pre.wav");
|
|
104
|
+
await ffmpeg([...inputs, "-filter_complex", chains.join(";"), "-map", "[pre]", "-c:a", "pcm_s24le", "-ar", "48000", pre]);
|
|
105
|
+
// 두 번 잰다 — 한 번에 loudnorm 을 걸면 동적 모드로 들어가 덕킹 모양이 뭉개진다. 잰 값을 주고 linear 로 한 번에 올린다
|
|
106
|
+
const err = await ffmpeg(["-i", pre, "-af", `loudnorm=I=${audio.lufs}:TP=${audio.truePeak}:LRA=11:print_format=json`, "-f", "null", "-"]);
|
|
107
|
+
const j = JSON.parse(err.slice(err.lastIndexOf("{"), err.lastIndexOf("}") + 1));
|
|
108
|
+
const ln = `loudnorm=I=${audio.lufs}:TP=${audio.truePeak}:LRA=11:measured_I=${j.input_i}:measured_TP=${j.input_tp}:measured_LRA=${j.input_lra}:measured_thresh=${j.input_thresh}:offset=${j.target_offset}:linear=true`;
|
|
109
|
+
await ffmpeg(["-i", pre, "-af", `${ln},aresample=48000`, "-c:a", "pcm_s16le", o.out]);
|
|
110
|
+
return o.out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 곡 셈여림(조용한 도입·부푸는 가운데)과 덕킹 깊이(5–10dB)가 곡마다 달라 상수로는 안 맞는다 — 영상 전체에서 말하는 구간만 골라
|
|
114
|
+
// 목소리와 덕킹된 곡을 한 번 재고 그 차만큼 곡을 옮긴다. 덕킹은 목소리로만 걸려 곡 크기와 무관하니 한 번이면 된다
|
|
115
|
+
export async function musicGain(src: Required<Sources>, voiceGain: number, audio: AudioMix, total: number): Promise<number> {
|
|
116
|
+
const music = ["-stream_loop", "-1", "-ss", src.musicFrom.toFixed(6), "-t", total.toFixed(6), "-i", src.music];
|
|
117
|
+
const g0 = VOICE_LUFS + audio.musicDb - (await loudness(music));
|
|
118
|
+
const sel = await speechSelect(src.voice);
|
|
119
|
+
if (!sel) return g0;
|
|
120
|
+
const pick = `aselect='${sel}',asetpts=N/SR/TB`;
|
|
121
|
+
const voiceSpeech = await loudness(["-i", src.voice], `volume=${voiceGain.toFixed(2)}dB,${pick}`);
|
|
122
|
+
const duck = audio.duck ? `;[m0][vsc]${SIDECHAIN}[m1]` : "";
|
|
123
|
+
const err = await ffmpeg([
|
|
124
|
+
"-i", src.voice, ...music,
|
|
125
|
+
"-filter_complex", `[0]aresample=48000,volume=${voiceGain.toFixed(2)}dB,pan=stereo|c0=c0|c1=c0,${FRAMES}[vsc];[1]aresample=48000,aformat=channel_layouts=stereo,volume=${g0.toFixed(2)}dB,${FRAMES}[m0]${duck};[${audio.duck ? "m1" : "m0"}]${pick},ebur128=framelog=quiet[out]${audio.duck ? "" : ";[vsc]anullsink"}`,
|
|
126
|
+
"-map", "[out]", "-f", "null", "-",
|
|
127
|
+
]);
|
|
128
|
+
const m = err.match(/I:\s+(-?[\d.]+|-inf) LUFS/g)?.at(-1)?.match(/(-?[\d.]+|-inf)/);
|
|
129
|
+
const musicSpeech = m ? Number(m[1]) : NaN;
|
|
130
|
+
return Number.isFinite(musicSpeech) ? g0 + (voiceSpeech + audio.musicDb - musicSpeech) : g0;
|
|
131
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 장면별 일(장면 쓰기·그림·목소리)을 동시에 — 모델·공급자 호출이 대부분 기다림이라 겹치면 장면 수만큼 빨라진다.
|
|
2
|
+
// 동시 수는 KF_JOBS 로 누른다(요금 한도·속도 제한이 빡빡한 키). 돈 드는 호출의 묻기·예산은 부르는 쪽 그대로다
|
|
3
|
+
|
|
4
|
+
export const jobLimit = (fallback: number): number => Math.max(1, Math.floor(Number(process.env.KF_JOBS) || fallback));
|
|
5
|
+
|
|
6
|
+
/** n 개까지 동시에. 결과는 넣은 순서대로 */
|
|
7
|
+
export async function pool<T, R>(items: readonly T[], n: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
|
|
8
|
+
const out: R[] = new Array(items.length);
|
|
9
|
+
let next = 0;
|
|
10
|
+
await Promise.all(
|
|
11
|
+
Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
12
|
+
for (let i = next++; i < items.length; i = next++) out[i] = await fn(items[i], i);
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 한 번에 n 개만 지나가는 문 — 크롬을 띄우는 일처럼 기계가 감당할 만큼만 */
|
|
19
|
+
export function gate(n: number): <R>(fn: () => Promise<R>) => Promise<R> {
|
|
20
|
+
let busy = 0;
|
|
21
|
+
const wait: (() => void)[] = [];
|
|
22
|
+
return async (fn) => {
|
|
23
|
+
// 자리를 기다리던 쪽에 바로 넘긴다 — 풀었다 다시 잡는 사이에 새로 온 쪽이 끼어들면 n 을 넘는다
|
|
24
|
+
if (busy < n) busy++;
|
|
25
|
+
else await new Promise<void>((r) => wait.push(r));
|
|
26
|
+
try {
|
|
27
|
+
return await fn();
|
|
28
|
+
} finally {
|
|
29
|
+
const w = wait.shift();
|
|
30
|
+
if (w) w();
|
|
31
|
+
else busy--;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|