kasaframe 0.1.5 → 0.1.7
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 +13 -0
- package/README.md +2 -2
- package/docs/AGENTS.md +1 -0
- package/package.json +1 -1
- package/packages/adapters/src/project.ts +2 -0
- package/packages/check/package.json +1 -0
- package/packages/check/src/check.ts +8 -2
- package/packages/check/src/rules/frames.ts +4 -5
- package/packages/cli/src/commands/direct.ts +24 -4
- package/packages/cli/src/commands/make.ts +15 -5
- package/packages/cli/src/commands/render.ts +34 -7
- package/packages/cli/src/mix.ts +66 -4
- package/packages/cli/src/segments.ts +1 -1
- package/packages/cli/src/sfx.ts +429 -0
- package/packages/core/src/frame.ts +7 -4
- package/packages/core/src/index.ts +3 -1
- package/packages/core/src/project.ts +19 -0
- package/packages/core/src/sfx.ts +70 -0
- package/packages/director/src/assemble.ts +15 -6
- package/packages/director/src/fallback.ts +14 -2
- package/packages/director/src/index.ts +1 -1
- package/packages/director/src/pattern.ts +2 -0
- package/packages/director/src/patterns/fill/cells.ts +5 -2
- package/packages/director/src/scene.ts +17 -5
- package/packages/director/src/transitions.ts +21 -8
- package/packages/motion/src/enter.ts +7 -1
- package/packages/motion/src/impact.ts +5 -2
- package/packages/motion/src/moves/dress.ts +15 -2
- package/packages/motion/src/moves/menu.ts +11 -5
- package/packages/motion/src/transition.ts +3 -1
- package/packages/motion/src/transition3d/index.ts +3 -1
- package/packages/motion/src/transition3d/live.ts +5 -3
- package/packages/patterns/src/bars/rank.ts +6 -7
- package/packages/patterns/src/before-after/shrink.ts +3 -4
- package/packages/patterns/src/before-after/strike.ts +4 -5
- package/packages/patterns/src/code/terminal.ts +25 -16
- package/packages/patterns/src/end/lockup.ts +13 -10
- package/packages/patterns/src/end/slam.ts +14 -8
- package/packages/patterns/src/hook-number/counter.ts +15 -9
- package/packages/patterns/src/hook-number/slam.ts +6 -8
- package/packages/patterns/src/kit.ts +364 -59
- package/packages/patterns/src/limit/gauge.ts +19 -13
- package/packages/patterns/src/quote/karaoke.ts +17 -12
- package/packages/patterns/src/sfx.ts +80 -0
- package/packages/patterns/src/steps/path.ts +15 -7
- package/packages/patterns/src/term-reveal/chips.ts +24 -10
- package/packages/patterns/src/term-reveal/expand.ts +17 -13
- package/packages/patterns/src/timeline/axis.ts +79 -25
- package/packages/patterns/src/title/mask.ts +15 -10
- package/packages/patterns/src/title/slam.ts +1 -1
- package/packages/patterns/src/types.ts +1 -0
- package/packages/plan/src/brief.ts +5 -2
- package/packages/themes/src/index.ts +22 -0
- package/packages/themes/src/slots.ts +32 -2
- package/packages/themes/src/types.ts +28 -0
- package/sfx/README.md +82 -0
- package/sfx/click/appear.wav +0 -0
- package/sfx/click/burst.wav +0 -0
- package/sfx/click/drop.wav +0 -0
- package/sfx/click/end.wav +0 -0
- package/sfx/click/hop.wav +0 -0
- package/sfx/click/land.wav +0 -0
- package/sfx/click/select.wav +0 -0
- package/sfx/click/stick.wav +0 -0
- package/sfx/click/tick.wav +0 -0
- package/sfx/click/type.wav +0 -0
- package/sfx/click/whoosh.wav +0 -0
- package/sfx/pop/appear.wav +0 -0
- package/sfx/pop/burst.wav +0 -0
- package/sfx/pop/drop.wav +0 -0
- package/sfx/pop/end.wav +0 -0
- package/sfx/pop/hop.wav +0 -0
- package/sfx/pop/land.wav +0 -0
- package/sfx/pop/select.wav +0 -0
- package/sfx/pop/stick.wav +0 -0
- package/sfx/pop/tick.wav +0 -0
- package/sfx/pop/type.wav +0 -0
- package/sfx/pop/whoosh.wav +0 -0
- package/sfx/tags.json +545 -0
- package/sfx/ui/appear.wav +0 -0
- package/sfx/ui/burst.wav +0 -0
- package/sfx/ui/drop.wav +0 -0
- package/sfx/ui/end.wav +0 -0
- package/sfx/ui/hop.wav +0 -0
- package/sfx/ui/land.wav +0 -0
- package/sfx/ui/select.wav +0 -0
- package/sfx/ui/stick.wav +0 -0
- package/sfx/ui/tick.wav +0 -0
- package/sfx/ui/type.wav +0 -0
- package/sfx/ui/whoosh.wav +0 -0
- package/themes/kawaii-sticker/effects.ts +15 -3
- package/themes/kawaii-sticker/theme.json +1 -0
- package/themes/og-measure/THEME.md +13 -2
- package/themes/og-measure/effects.ts +76 -59
- package/themes/og-measure/sample/scenes/end.ts +2 -2
- package/themes/og-measure/sample/scenes/gather.ts +2 -10
- package/themes/og-measure/sample/scenes/models.ts +2 -10
- package/themes/og-measure/sample/scenes/one.ts +2 -1
- package/themes/og-measure/sample/scenes/price.ts +4 -16
- package/themes/og-measure/slots.ts +29 -0
- package/themes/og-measure/still.png +0 -0
- package/themes/og-measure/style.json +3 -0
- package/themes/og-measure/theme.json +15 -1
- package/themes/og-measure/variants/snow-blue.json +3 -0
package/NOTICE
CHANGED
|
@@ -147,6 +147,19 @@ MIT — Copyright (c) 2026 NikusonP
|
|
|
147
147
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
148
148
|
SOFTWARE.
|
|
149
149
|
|
|
150
|
+
----------------------------------------------------------------------
|
|
151
|
+
효과음 — sfx/<결>/<역할>.wav
|
|
152
|
+
Kenney(www.kenney.nl) 묶음 여섯에서 골라 다듬었다(몇은 두 소리를 꼭대기에 맞춰 겹침, 앞 무음·꼬리 자르기, 길이 상한, 페이드, 꼭대기 -1dBFS).
|
|
153
|
+
모두 CC0 1.0 — 묶음마다 받은 zip 의 License.txt 로 확인했다(tools/sfx/build.ts 가 다시 지을 때도 본다). 출처 표기 의무는 없다.
|
|
154
|
+
파일마다 원본 파일 이름은 sfx/tags.json·sfx/README.md.
|
|
155
|
+
Kenney UI SFX Set https://kenney.nl/assets/ui-audio
|
|
156
|
+
Kenney Interface Sounds 1.0 https://kenney.nl/assets/interface-sounds
|
|
157
|
+
Kenney Impact Sounds 1.0 https://kenney.nl/assets/impact-sounds
|
|
158
|
+
Kenney Digital Audio https://kenney.nl/assets/digital-audio
|
|
159
|
+
Kenney RPG Audio https://kenney.nl/assets/rpg-audio
|
|
160
|
+
Kenney Casino Audio 1.1 https://kenney.nl/assets/casino-audio
|
|
161
|
+
CC0 1.0 원문: https://creativecommons.org/publicdomain/zero/1.0/
|
|
162
|
+
|
|
150
163
|
----------------------------------------------------------------------
|
|
151
164
|
글꼴 짝 — packages/themes/kit/fonts.json · kit/fonts/<무리>.json (kf fonts notice --write 가 이 절을 짓는다)
|
|
152
165
|
글꼴 파일은 싣지 않는다 — 영상을 구울 때 판을 박은 주소(jsdelivr gh@커밋·npm@판)에서 받는다. 모두 상업 이용·영상·
|
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ kf skill install # ~/.claude/skills/kasaframe 에 스킬 링
|
|
|
67
67
|
|
|
68
68
|
## 목소리·곡
|
|
69
69
|
|
|
70
|
-
쇼츠·설명은 차분한 TTS 내레이션과 깊은 앰비언트 곡(`deep`)이 기본이다. 목소리가 장면 길이를 정하고, 렌더가 곡을 말 아래로 덕킹해 -14 LUFS 로 섞는다. 곡은 틀이 박은 분위기(`deep`)대로 매번 같고, 목소리는 만들기 전에 묻는다(로컬 Qwen3·ElevenLabs·자막만·그 밖 차례). 엔진·곡이 없으면 다른 것으로 넘어가지 않고 키 넣기·다른 엔진·빼기를 묻는다.
|
|
70
|
+
쇼츠·설명은 차분한 TTS 내레이션과 깊은 앰비언트 곡(`deep`)이 기본이다. 목소리가 장면 길이를 정하고, 렌더가 곡을 말 아래로 덕킹해 -14 LUFS 로 섞는다. 효과음도 기본으로 깐다 — 뜻 있는 자리(장면 바뀜·드롭·주인공 착지·짚개가 요점에 닿는 순간)에만 테마가 고른 결(`sfx/` 의 CC0 소리)을 둘레 곡과 비슷한 크기로, 그 순간 곡을 살짝 눌러 들리게(목소리가 있으면 더 낮게). 끄기는 `kf.json` 의 `sfx: false` 나 `kf render|make --sfx off`. 곡은 틀이 박은 분위기(`deep`)대로 매번 같고, 목소리는 만들기 전에 묻는다(로컬 Qwen3·ElevenLabs·자막만·그 밖 차례). 엔진·곡이 없으면 다른 것으로 넘어가지 않고 키 넣기·다른 엔진·빼기를 묻는다.
|
|
71
71
|
|
|
72
72
|
| 쓰는 것 | 키 | 상태 |
|
|
73
73
|
|---|---|---|
|
|
@@ -85,7 +85,7 @@ kf skill install # ~/.claude/skills/kasaframe 에 스킬 링
|
|
|
85
85
|
| `kf new` | 용도 틀에서 프로젝트 뼈대 — 곡 분석·박에 맞춘 장면 시간표·장면 파일 |
|
|
86
86
|
| `kf make` | 요청 한 줄로 끝까지 — 용도 판별·계획·장면·렌더·검사 |
|
|
87
87
|
| `kf preview` | 미리보기 — 곡이 기준 시계, 타임라인·박·드롭·프레임 이동 |
|
|
88
|
-
| `kf render` | MP4(또는 ProRes·PNG 묶음)로 굽기 — 탭 병렬,
|
|
88
|
+
| `kf render` | MP4(또는 ProRes·PNG 묶음)로 굽기 — 탭 병렬, 곡·목소리·효과음 얹기, 자막 |
|
|
89
89
|
| `kf check` | 검사 — 멈춤·빈 화면·글 깨짐·화면 밖 글·대비·섬광·박 경계·움직임 규칙 |
|
|
90
90
|
| `kf still` · `kf sheet` · `kf look` | 한 장 · 장면 모음 · 장면별 확인표 |
|
|
91
91
|
| `kf beats` | 곡을 재서 `beats.json` — 박·마디·프레이즈·구간·드롭·대역 에너지 |
|
package/docs/AGENTS.md
CHANGED
|
@@ -101,6 +101,7 @@ export default {
|
|
|
101
101
|
- DOM 은 `build` 에서 한 번 짓고, `(t) => …` 에서는 글·스타일만 바꾼다.
|
|
102
102
|
- 박자는 `ctx.beats`(`drop(0)`·`nearestBeat`·`phrase`·`inDrop`), 그림은 `ctx.asset("x.png")`, 글은 `ctx.safe` 안에.
|
|
103
103
|
- 움직임은 `@kasaframe/motion` 부품을 쓴다 — 손으로 짠 흔들림·펄스는 검사가 셀 수 없다. 부품 표와 Remotion 대응표: [`packages/motion/README.md`](../packages/motion/README.md).
|
|
104
|
+
- 효과음은 렌더가 깐다 — 부품(등장·드롭·전환·테마 효과·패턴)이 움직임 시작 자리를 저절로 남긴다. 손으로 지은 움직임만 `ctx.sfx("land", 1.2)`(역할: appear·stick·land·hop·whoosh·tick·select·type·drop·burst·end, 시각은 장면 초). 끄기는 `kf.json` `sfx: false`.
|
|
104
105
|
- React 장면: `export default { react: Component }`, `Component({ t, ctx })`.
|
|
105
106
|
|
|
106
107
|
하지 말 것:
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createBeats,
|
|
3
3
|
cssVars,
|
|
4
|
+
cueAt,
|
|
4
5
|
fontCss,
|
|
5
6
|
setBeats,
|
|
6
7
|
silentBeats,
|
|
@@ -169,6 +170,7 @@ export function mountProject(o: MountOptions): KfPage {
|
|
|
169
170
|
style: style as KfStyle,
|
|
170
171
|
safe: caps?.safe ?? kf.safeArea ?? ZERO,
|
|
171
172
|
asset: assetUrl,
|
|
173
|
+
sfx: (role, at, so) => cueAt(role, entry.from + at, { ...so, source: so?.source ?? `ctx:${role}`, scene: entry.id }),
|
|
172
174
|
};
|
|
173
175
|
let draw: SceneDraw;
|
|
174
176
|
if (mod.react) {
|
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
3
3
|
import { clockScript } from "@kasaframe/clock";
|
|
4
4
|
import { openProject, repoRoot } from "@kasaframe/cli";
|
|
5
|
+
import { freezeLimits } from "@kasaframe/core";
|
|
6
|
+
import { themeFreezeSec } from "@kasaframe/themes";
|
|
5
7
|
import { scanPage } from "./scan.ts";
|
|
6
8
|
import { serveStatic } from "./serve.ts";
|
|
7
9
|
import { probeVideo, videoText, videoThumbs } from "./video.ts";
|
|
@@ -41,6 +43,8 @@ interface Project {
|
|
|
41
43
|
kf: any | null;
|
|
42
44
|
beats: Beats | null;
|
|
43
45
|
rules: MotionRules;
|
|
46
|
+
/** 멈춤 경고·오류 기준(초) — 틀·style.json 값과 테마가 늘린 값(freezeLimits) */
|
|
47
|
+
freeze: { warn: number; error: number };
|
|
44
48
|
safe?: SafeArea;
|
|
45
49
|
text: TextPolicy;
|
|
46
50
|
allowText: string[];
|
|
@@ -75,12 +79,14 @@ function loadProject(dir: string | null, o: CheckOptions): Project {
|
|
|
75
79
|
// 틀의 motion 이 style.json 위에 덮인다(SPEC 계약 7).
|
|
76
80
|
const template = kf?.template ? readJson(join(repoRoot, "templates", kf.template, "template.json")) : null;
|
|
77
81
|
const rules: MotionRules = { ...(style?.motion ?? {}), ...(template?.motion ?? {}) };
|
|
82
|
+
// 테마는 멈춤 오류 기준을 늘리기만 한다 — 테마 없는 프로젝트·기준을 안 정한 테마는 틀·style.json 값 그대로
|
|
78
83
|
const scenes: number[] = Array.isArray(kf?.scenes) ? kf.scenes.map((s: any) => Number(s.from)).filter((t: number) => Number.isFinite(t)) : [];
|
|
79
84
|
return {
|
|
80
85
|
dir,
|
|
81
86
|
kf,
|
|
82
87
|
beats: beats && Array.isArray(beats.beats) ? beats : null,
|
|
83
88
|
rules,
|
|
89
|
+
freeze: freezeLimits(rules.maxFreezeSec, themeFreezeSec(kf?.theme)),
|
|
84
90
|
safe: kf?.safeArea,
|
|
85
91
|
text: { ...(template?.text ?? {}), ...(kf?.text ?? {}) },
|
|
86
92
|
allowText: kf?.check?.allowText ?? [],
|
|
@@ -184,7 +190,7 @@ async function checkPage(o: CheckOptions): Promise<Report> {
|
|
|
184
190
|
f.push(...contrast(r.contrast, { shotFps: o.shotFps ?? 2 }));
|
|
185
191
|
if (proj.text.minPx || proj.text.maxOnScreen) f.push(...textPolicy(r.texts, proj.text, { fps: meta.fps }));
|
|
186
192
|
if (projDir) f.push(...genAssets(projDir));
|
|
187
|
-
f.push(...freezes(r.thumbs, { errorSec: proj.
|
|
193
|
+
f.push(...freezes(r.thumbs, { errorSec: proj.freeze.error, warnSec: proj.freeze.warn }));
|
|
188
194
|
f.push(...blanks(r.thumbs, meta.duration));
|
|
189
195
|
f.push(...spikes(r.thumbs, { exclude: [...sceneSpans(proj.kf).map((s) => s.from), ...(proj.beats?.drops ?? []), ...r.markers.filter((m) => m.kind === "scene" || m.kind === "drop").map((m) => m.t)] }));
|
|
190
196
|
f.push(...flashes(r.thumbs, { maxPerSec: proj.rules.maxFlashPerSec, cuts: [...sceneSpans(proj.kf).map((s) => s.from), ...cuts(r.thumbs)] }));
|
|
@@ -236,7 +242,7 @@ async function checkVideo(o: CheckOptions): Promise<Report> {
|
|
|
236
242
|
|
|
237
243
|
log(`프레임 읽기: ${from.toFixed(2)}–${to.toFixed(2)}초 · ${info.width}×${info.height} · ${info.fps.toFixed(2)}fps`);
|
|
238
244
|
const thumbs = await videoThumbs(file, info, from, to);
|
|
239
|
-
f.push(...freezes(thumbs, { errorSec: proj.
|
|
245
|
+
f.push(...freezes(thumbs, { errorSec: proj.freeze.error, warnSec: proj.freeze.warn }));
|
|
240
246
|
f.push(...blanks(thumbs, info.duration));
|
|
241
247
|
f.push(...spikes(thumbs, { exclude: [...sceneSpans(proj.kf).map((s) => s.from), ...(proj.beats?.drops ?? [])] }));
|
|
242
248
|
f.push(...flashes(thumbs, { maxPerSec: proj.rules.maxFlashPerSec, cuts: [...sceneSpans(proj.kf).map((s) => s.from), ...cuts(thumbs)] }));
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { MAX_FREEZE_SEC } from "@kasaframe/core";
|
|
1
2
|
import { meanAbsDiff, type Thumb } from "../image.ts";
|
|
2
3
|
import type { Finding } from "../types.ts";
|
|
3
4
|
|
|
4
5
|
export interface FreezeOptions {
|
|
5
6
|
// 160×90 회색조 평균 절대차(0..255). 커서 깜빡임 한 번이면 넘는 정도.
|
|
6
7
|
threshold?: number;
|
|
7
|
-
// 틀·style.json 의 motion.maxFreezeSec(틀이 이긴다) — 고정 관찰
|
|
8
|
+
// 틀·style.json 의 motion.maxFreezeSec(틀이 이긴다)와 테마 값 중 큰 쪽(freezeLimits) — 고정 관찰 화면은 늘려 허용한다. 없으면 0.1초
|
|
8
9
|
errorSec?: number;
|
|
9
10
|
warnSec?: number;
|
|
10
11
|
}
|
|
@@ -24,9 +25,7 @@ export function diffs(thumbs: Thumb[]): number[] {
|
|
|
24
25
|
|
|
25
26
|
// 이웃 프레임끼리 비교하면 60fps 의 느린 움직임이 멈춤으로 보인다. 멈춤이 시작된 프레임(닻)과 비교해
|
|
26
27
|
// 닻에서 얼마나 멀어졌는지로 잰다 — 10fps 로 찍든 60fps 로 읽든 같은 답이 나온다.
|
|
27
|
-
// 공통 규칙: 단 0.1초도 쉬지 않는다 — 끝 카드·큰 숫자·로고도 예외
|
|
28
|
-
export const MAX_FREEZE_SEC = 0.1;
|
|
29
|
-
|
|
28
|
+
// 공통 규칙: 단 0.1초도 쉬지 않는다(MAX_FREEZE_SEC) — 끝 카드·큰 숫자·로고도 예외 없이. 테마가 늘린 만큼만 경고로 남긴다
|
|
30
29
|
export function freezes(thumbs: Thumb[], o: FreezeOptions = {}): Finding[] {
|
|
31
30
|
const thr = o.threshold ?? 0.35;
|
|
32
31
|
const errorSec = o.errorSec ?? MAX_FREEZE_SEC;
|
|
@@ -47,7 +46,7 @@ export function freezes(thumbs: Thumb[], o: FreezeOptions = {}): Finding[] {
|
|
|
47
46
|
severity,
|
|
48
47
|
from: thumbs[a].t,
|
|
49
48
|
to: thumbs[end].t,
|
|
50
|
-
message: `화면이 ${len.toFixed(2)}초 ${area > 0 ? `거의 멈췄다(바뀐 면적 ${(area * 100).toFixed(1)}%)` : "멈췄다"}${severity === "warn" ? ` — 경고 기준 ${warnSec}초 넘음` : ` — 기준 ${errorSec}초(틀·style.json maxFreezeSec)`} — 그 사이에 눈에 띄는 움직임 하나를 둔다`,
|
|
49
|
+
message: `화면이 ${len.toFixed(2)}초 ${area > 0 ? `거의 멈췄다(바뀐 면적 ${(area * 100).toFixed(1)}%)` : "멈췄다"}${severity === "warn" ? ` — 경고 기준 ${warnSec}초 넘음` : ` — 기준 ${errorSec}초(틀·style.json·테마 maxFreezeSec)`} — 그 사이에 눈에 띄는 움직임 하나를 둔다`,
|
|
51
50
|
detail: { seconds: Math.round(len * 100) / 100, changedArea: Math.round(area * 10000) / 10000 },
|
|
52
51
|
});
|
|
53
52
|
}
|
|
@@ -8,6 +8,7 @@ import { parseDuration } from "@kasaframe/plan";
|
|
|
8
8
|
import { type Command, num, parseFlags } from "../command.ts";
|
|
9
9
|
|
|
10
10
|
const kfBin = fileURLToPath(new URL("../kf.ts", import.meta.url));
|
|
11
|
+
const FALLBACK = "any.words";
|
|
11
12
|
// 검사가 떨어뜨려도 패턴 탓이 아닌 규칙 — 장면 경계(시간표)·생성 자산·자막 판
|
|
12
13
|
const NOT_PATTERN = /^(beat\.|asset\.|caption\.)/;
|
|
13
14
|
// 생성 능력 질문의 답 — kf make 로 그대로 넘긴다
|
|
@@ -130,6 +131,7 @@ const cmd: Command = {
|
|
|
130
131
|
let code = 0;
|
|
131
132
|
if (!opts["no-check"]) {
|
|
132
133
|
const retries = num(opts.retries, 2);
|
|
134
|
+
let forced = false;
|
|
133
135
|
for (let round = 0; ; round++) {
|
|
134
136
|
// 옛 결과를 치우고 돌린다 — 검사가 돌지 못하고 끝나면 옛 check.json 을 이번 결과로 읽게 된다
|
|
135
137
|
const report_ = join(dir, "out", "check.json");
|
|
@@ -154,14 +156,32 @@ const cmd: Command = {
|
|
|
154
156
|
}
|
|
155
157
|
const blame = blameScenes(dir, report);
|
|
156
158
|
console.error(`kf check 오류 — ${blame.lines.join(" · ")}`);
|
|
157
|
-
if (
|
|
159
|
+
if (!Object.keys(blame.failed).length && !Object.keys(blame.seamFailed).length) break;
|
|
160
|
+
// 다시 짓기를 다 써도 떨어지는 장면은 감독 기본(any.words)으로 한 번 더 — 막혀도 대체로는 끝까지 간다. 후보가 많은 type 은
|
|
161
|
+
// 다시 짓기 두 번 안에 기본까지 내려오지 못했다. 기본도 떨어지면 멈춘다
|
|
162
|
+
const left = Object.keys(blame.failed).filter((id) => report.scenes.find((s) => s.id === id)?.pattern !== FALLBACK);
|
|
163
|
+
const fallback = (): Record<string, string> | null => {
|
|
164
|
+
if (forced || !left.length) return null;
|
|
165
|
+
forced = true;
|
|
166
|
+
return Object.fromEntries(left.map((id) => [id, FALLBACK]));
|
|
167
|
+
};
|
|
168
|
+
let force = round >= retries ? fallback() : undefined;
|
|
169
|
+
if (force === null) break;
|
|
158
170
|
try {
|
|
159
|
-
report = await build(dir, content, { seed, failed: blame.failed, seamFailed: blame.seamFailed, log });
|
|
171
|
+
report = await build(dir, content, { seed, failed: blame.failed, seamFailed: blame.seamFailed, force, log });
|
|
160
172
|
} catch (e) {
|
|
173
|
+
// 남은 후보가 없어 고르기가 멈췄으면 그 장면들을 감독 기본으로
|
|
161
174
|
console.error(`kf direct: ${e instanceof Error ? e.message : e}`);
|
|
162
|
-
|
|
175
|
+
force = fallback();
|
|
176
|
+
if (!force) break;
|
|
177
|
+
try {
|
|
178
|
+
report = await build(dir, content, { seed, failed: blame.failed, seamFailed: blame.seamFailed, force, log });
|
|
179
|
+
} catch (e2) {
|
|
180
|
+
console.error(`kf direct: ${e2 instanceof Error ? e2.message : e2}`);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
163
183
|
}
|
|
164
|
-
console.error(`다시 지음(${round + 1}/${retries}) — ${[...Object.entries(blame.failed), ...Object.entries(blame.seamFailed)].map(([id]) => `${id} → ${report.scenes.find((s) => s.id === id)?.pattern ?? report.scenes.find((s) => s.id === id)?.transition}`).join(" · ")}`);
|
|
184
|
+
console.error(`${force ? "감독 기본으로" : `다시 지음(${round + 1}/${retries})`} — ${[...Object.entries(blame.failed), ...Object.entries(blame.seamFailed)].map(([id]) => `${id} → ${report.scenes.find((s) => s.id === id)?.pattern ?? report.scenes.find((s) => s.id === id)?.transition}`).join(" · ")}`);
|
|
165
185
|
}
|
|
166
186
|
if (code !== 0) console.error(`kf check 가 아직 떨어진다 — ${dir}/out/check.json 을 보고 content.json 을 고치거나 --seed 를 바꿔 다시`);
|
|
167
187
|
}
|
|
@@ -103,6 +103,7 @@ kf make --answer <폴더> --image <값> --video <값> … Claude Code 모드
|
|
|
103
103
|
--video-provider 영상 공급자를 못 박는다(예: h3-pod — KF_H3_URL·KF_H3_STEPS 필요). 기본은 KF_VIDEO_PROVIDER, 없으면 생성 담당 기본
|
|
104
104
|
--fix 검사가 떨어졌을 때 고치기 횟수(기본 2)
|
|
105
105
|
--captions on|off|fit 화면 자막(fit 은 장면을 자막 띠 위로 줄여 올린다) — 새로 만들 땐 kf.json 에 적고, --finish 에선 그 판만 덮는다(다시 만들지 않고 켜고 끄기). srt·vtt 는 둘 다 낸다
|
|
106
|
+
--sfx on|off 효과음(기본 켬 — 테마가 고른 결로 장면 움직임 자리에 곡 아래 작게) — 새로 만들 땐 off 를 kf.json 에 sfx: false 로 적고, --finish 에선 그 판만 덮는다
|
|
106
107
|
--suffix [글] --finish 결과 이름 뒤에 붙인다(값 없이면 -자막 · -자막없음) — 원본 video.mp4 를 덮지 않고 둘 다 남긴다
|
|
107
108
|
--no-gen 생성 모델을 부르지 않는다 — 묻지 않고 그림·영상 모델은 빼고, Blender 가 없으면 three 로 대신한다
|
|
108
109
|
|
|
@@ -171,6 +172,11 @@ async function make(argv: string[], at: Track): Promise<number> {
|
|
|
171
172
|
return 2;
|
|
172
173
|
}
|
|
173
174
|
const suffix = str("suffix") ?? (opts.suffix ? (captions === "off" ? "-자막없음" : "-자막") : "");
|
|
175
|
+
const sfx = str("sfx");
|
|
176
|
+
if (sfx !== undefined && sfx !== "on" && sfx !== "off") {
|
|
177
|
+
console.error(`kf make: --sfx 는 on·off — ${sfx}`);
|
|
178
|
+
return 2;
|
|
179
|
+
}
|
|
174
180
|
|
|
175
181
|
if (opts.answer) {
|
|
176
182
|
at.quiet = true;
|
|
@@ -192,7 +198,7 @@ async function make(argv: string[], at: Track): Promise<number> {
|
|
|
192
198
|
const narrate = sayChanged(dir, ids) ? [] : ["--no-narrate"];
|
|
193
199
|
const d = await relay(["direct", dir, "--scenes", ids.join(","), ...narrate, "--no-check", ...pick]);
|
|
194
200
|
if (d !== 0) return d;
|
|
195
|
-
return finish(dir, { model: "direct", vision, fixes: 0, scenes: ids });
|
|
201
|
+
return finish(dir, { model: "direct", vision, fixes: 0, scenes: ids, sfx });
|
|
196
202
|
}
|
|
197
203
|
// 다시 쓰기는 그 일을 만든 모델로 — 기본 모델로 몰래 바꾸지 않는다
|
|
198
204
|
const own = str("model") ?? readPlan(dir).model;
|
|
@@ -208,7 +214,7 @@ async function make(argv: string[], at: Track): Promise<number> {
|
|
|
208
214
|
}
|
|
209
215
|
writeFileSync(join(dir, "MAKE.md"), projectBrief(dir));
|
|
210
216
|
await writeScenes(dir, own, ids);
|
|
211
|
-
return finish(dir, { model: own, vision, fixes, scenes: ids });
|
|
217
|
+
return finish(dir, { model: own, vision, fixes, scenes: ids, sfx });
|
|
212
218
|
}
|
|
213
219
|
if (opts.finish) {
|
|
214
220
|
const dir = resolve(typeof opts.finish === "string" ? opts.finish : (args[0] ?? "."));
|
|
@@ -220,7 +226,7 @@ async function make(argv: string[], at: Track): Promise<number> {
|
|
|
220
226
|
if (studio) console.log(`작업실: ${studio.url}`);
|
|
221
227
|
await studio?.send({ phase: "render" });
|
|
222
228
|
const scenes = str("scenes")?.split(",").filter(Boolean);
|
|
223
|
-
const code = await finish(dir, { model, vision, fixes, gen: opts["no-gen"] ? undefined : gen, captions, suffix, ...(scenes?.length ? { scenes } : {}) });
|
|
229
|
+
const code = await finish(dir, { model, vision, fixes, gen: opts["no-gen"] ? undefined : gen, captions, suffix, sfx, ...(scenes?.length ? { scenes } : {}) });
|
|
224
230
|
await unmirror?.();
|
|
225
231
|
await studio?.send({ done: true, ok: code === 0 });
|
|
226
232
|
return code;
|
|
@@ -352,6 +358,10 @@ async function make(argv: string[], at: Track): Promise<number> {
|
|
|
352
358
|
writeJson(kfPath, { ...JSON.parse(readFileSync(kfPath, "utf8")), captions });
|
|
353
359
|
plan.captions = captionMode(captions);
|
|
354
360
|
}
|
|
361
|
+
if (sfx === "off") {
|
|
362
|
+
const kfPath = join(dir, "kf.json");
|
|
363
|
+
writeJson(kfPath, { ...JSON.parse(readFileSync(kfPath, "utf8")), sfx: false });
|
|
364
|
+
}
|
|
355
365
|
if (!str("purpose") && !series) {
|
|
356
366
|
plan.confidence = chosen?.p ?? det.confidence;
|
|
357
367
|
plan.alternatives = det.ranked.filter((r) => r.purpose !== purpose).slice(0, 2).map((r) => ({ purpose: r.purpose, p: r.p }));
|
|
@@ -901,7 +911,7 @@ async function seamStills(dir: string, only?: string[]): Promise<void> {
|
|
|
901
911
|
|
|
902
912
|
// captions 를 주면 그 판만 화면 자막을 켜고 끈다(kf.json 은 그대로) — 굽기·검사·자막 트랙이 같은 값을 본다
|
|
903
913
|
// scenes 를 주면 그 장면 둘레(그 장면 + 붙은 이음매)만 — 이음 화면·검사를 그 구간에서. 굽기는 kf render 가 바뀐 조각만 다시 찍는다
|
|
904
|
-
async function finish(dir: string, o: { model: string; vision: string; fixes: number; gen?: GenOpts; captions?: "on" | "off" | "fit"; suffix?: string; scenes?: string[] }): Promise<number> {
|
|
914
|
+
async function finish(dir: string, o: { model: string; vision: string; fixes: number; gen?: GenOpts; captions?: "on" | "off" | "fit"; suffix?: string; scenes?: string[]; sfx?: string }): Promise<number> {
|
|
905
915
|
if (!existsSync(join(dir, "kf.json"))) {
|
|
906
916
|
console.error(`kf make: ${dir} 에 kf.json 이 없다`);
|
|
907
917
|
return 2;
|
|
@@ -921,7 +931,7 @@ async function finish(dir: string, o: { model: string; vision: string; fixes: nu
|
|
|
921
931
|
if (round > 0) await seamStills(dir, touched);
|
|
922
932
|
// 굽기와 검사 사이에 잠금을 놓으면 기다리던 남의 긴 렌더가 끼어든다 — 둘을 한 번에 쥐고 자식은 잠금 없이
|
|
923
933
|
const release = await acquireRenderLock(dir, 3600);
|
|
924
|
-
const r = await relay(["render", dir, "--out", out, "--tabs", "2", "--no-lock", ...capFlag]);
|
|
934
|
+
const r = await relay(["render", dir, "--out", out, "--tabs", "2", "--no-lock", ...capFlag, ...(o.sfx ? ["--sfx", o.sfx] : [])]);
|
|
925
935
|
if (r !== 0) {
|
|
926
936
|
release();
|
|
927
937
|
console.error("kf make: 렌더가 실패했다");
|
|
@@ -8,7 +8,8 @@ import { acquireRenderLock } from "../lock.ts";
|
|
|
8
8
|
import { blurOpts, captureBlurred, projectBlur, rgbaToPng } from "../blur.ts";
|
|
9
9
|
import { outPath } from "../out.ts";
|
|
10
10
|
import { cachedSegment, collectEvents, type KeyOpts, type MotionEvent, pruneSegments, renderSegments, sceneKeys, sceneSpans, type SegmentReport, type SegmentWork, variantOf } from "../segments.ts";
|
|
11
|
-
import { mixAudio, mixSettings } from "../mix.ts";
|
|
11
|
+
import { mixAudio, mixSettings, SFX_AUDIO, type SfxMix } from "../mix.ts";
|
|
12
|
+
import { cuesOf, loadBank, placeSfx, projectDrops, sfxSettings } from "../sfx.ts";
|
|
12
13
|
|
|
13
14
|
const MAX_TABS = 3;
|
|
14
15
|
|
|
@@ -61,7 +62,7 @@ function withSuffix(p: string, suffix: string): string {
|
|
|
61
62
|
|
|
62
63
|
const cmd: Command = {
|
|
63
64
|
summary: "MP4(또는 ProRes·PNG 묶음)로 굽기 — 탭 병렬, 곡 얹기, 잠금",
|
|
64
|
-
usage: `kf render <폴더> [--out x.mp4] [--scenes a,b] [--from 초 --to 초] [--tabs 3] [--prores | --png] [--music 곡 | --no-music] [--captions on|off|fit [--suffix]]
|
|
65
|
+
usage: `kf render <폴더> [--out x.mp4] [--scenes a,b] [--from 초 --to 초] [--tabs 3] [--prores | --png] [--music 곡 | --no-music] [--sfx on|off] [--captions on|off|fit [--suffix]]
|
|
65
66
|
--out 기본 <폴더>/out/render.mp4 (--prores 면 .mov, --png 면 폴더)
|
|
66
67
|
seek 모드는 장면 조각으로 굽는다 — 장면마다 out/segments/<장면>-<해시>.mp4(해시: 그 장면이 실제로 불러온 모듈·kf.json 그 장면 칸·
|
|
67
68
|
자산·자막 줄·굽기 설정, 이음매는 앞뒤 장면까지). 해시가 같은 조각은 다시 찍지 않고 이어 붙인 뒤 곡·목소리를 전체로 얹는다
|
|
@@ -71,6 +72,8 @@ const cmd: Command = {
|
|
|
71
72
|
--tabs 크롬 탭 수(최대 ${MAX_TABS}). seek 모드는 구간을 나눠 찍고, step 모드는 탭마다 구간 앞까지 빨리 돌린 뒤 찍는다
|
|
72
73
|
--music 기본은 페이지 meta.music(kf.json 의 music). 곡은 beats.json 의 from 초부터 얹는다
|
|
73
74
|
--music-from 곡에서 시작할 초를 직접 준다(--music 으로 다른 곡을 줄 때)
|
|
75
|
+
--sfx on|off 효과음(기본 켬) — 장면 움직임이 남긴 소리 자리(__kf.events 의 sfx)와 곡의 드롭에 테마가 고른 결(theme.json sfx)의 소리를
|
|
76
|
+
곡 아래로 작게 깐다. off 면 이 판만 끄고(효과음 이전과 같은 소리), on 이면 kf.json sfx: false 도 이 판만 켠다. --no-music 이면 없다
|
|
74
77
|
--crf H.264 화질(기본 16, 낮을수록 좋다)
|
|
75
78
|
--motion-blur N 한 프레임을 하위 프레임 N 장(예 8)으로 찍어 평균 — 빠른 것만 번진다. 3D 전환은 같은 평균으로 피사계 심도도. seek 모드만, N 배 느리다.
|
|
76
79
|
안 주면 kf.json 의 render.motionBlur(쇼츠·홍보 틀은 켜 둔다), 0 이면 끈다
|
|
@@ -91,6 +94,8 @@ const cmd: Command = {
|
|
|
91
94
|
const format: VideoFormat | "png" = opts.png ? "png" : opts.prores ? "prores" : "h264";
|
|
92
95
|
const ext = format === "prores" ? ".mov" : format === "png" ? "" : ".mp4";
|
|
93
96
|
const captions = opts.captions === undefined ? undefined : String(opts.captions);
|
|
97
|
+
const sfxFlag = opts.sfx === undefined ? undefined : String(opts.sfx);
|
|
98
|
+
if (sfxFlag !== undefined && sfxFlag !== "on" && sfxFlag !== "off") throw new Error(`--sfx 는 on·off: ${sfxFlag}`);
|
|
94
99
|
if (captions !== undefined && captions !== "on" && captions !== "off" && captions !== "fit") throw new Error(`--captions 는 on·off·fit: ${captions}`);
|
|
95
100
|
const suffix = typeof opts.suffix === "string" ? opts.suffix : opts.suffix ? (captions === "off" ? "-자막없음" : "-자막") : "";
|
|
96
101
|
const out = withSuffix(outPath(dir, opts.out, format === "png" ? "frames/x" : `render${ext}`).replace(/\/x$/, ""), suffix);
|
|
@@ -139,6 +144,8 @@ const cmd: Command = {
|
|
|
139
144
|
console.error(` ${done}/${todo} (${((done / Math.max(1, todo)) * 100).toFixed(0)}%) ${(done / sec).toFixed(1)} 장/초`);
|
|
140
145
|
}, 5000);
|
|
141
146
|
|
|
147
|
+
let sfx: SfxMix | undefined;
|
|
148
|
+
let sfxCues = 0;
|
|
142
149
|
let segFiles: string[] = [];
|
|
143
150
|
let events: MotionEvent[] = [];
|
|
144
151
|
let segments: { rendered: string[]; reused: string[] } | undefined;
|
|
@@ -209,13 +216,24 @@ const cmd: Command = {
|
|
|
209
216
|
if (format !== "png") {
|
|
210
217
|
const list = join(tmp, "list.txt");
|
|
211
218
|
writeFileSync(list, segFiles.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n") + "\n");
|
|
212
|
-
//
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
// 효과음 — 찍는 동안 페이지가 남긴 소리 자리를 밀도대로 걸러 깐다. 끄면(--sfx off·kf.json sfx: false) 아래는 효과음 이전과 같은 길
|
|
220
|
+
const settings = opts["no-music"] ? null : sfxSettings(resolve(dir), sfxFlag);
|
|
221
|
+
if (settings) {
|
|
222
|
+
const bank = loadBank();
|
|
223
|
+
if (!bank.sets.includes(settings.set)) throw new Error(`효과음 결 "${settings.set}" 이 없다 — 있는 결: ${bank.sets.join(" · ")}`);
|
|
224
|
+
const cues = cuesOf(events, settings.only && !settings.only.includes("drop") ? [] : projectDrops(resolve(dir)));
|
|
225
|
+
sfx = { hits: placeSfx(cues, settings, bank, { from: startF / fps, to: endF / fps }), settings, bank };
|
|
226
|
+
sfxCues = cues.filter((c) => c.t >= startF / fps && c.t < endF / fps).length;
|
|
227
|
+
}
|
|
228
|
+
// kf.json 에 audio(섞기 값)가 있으면 내레이션·곡을 섞은 소리를 얹는다(덕킹·끝 페이드·LUFS) — 없으면 곡만. 효과음을 깔면 audio 가 없어도 섞는다
|
|
229
|
+
const found = opts["no-music"] ? null : mixSettings(resolve(dir));
|
|
230
|
+
const mix = found ?? (sfx?.hits.length ? { audio: SFX_AUDIO, voice: undefined } : null);
|
|
231
|
+
const mixed = mix && (mix.voice || music || sfx?.hits.length)
|
|
232
|
+
? await mixAudio({ voice: mix.voice, music, musicFrom: songFrom }, { audio: mix.audio, from: startF / fps, dur: frames / fps, total: meta.duration, out: join(tmp, "mix.wav"), sfx })
|
|
216
233
|
: undefined;
|
|
217
234
|
await concat({ list, out, format, music: mixed ?? music, from: mixed ? 0 : songFrom + startF / fps, dur: frames / fps });
|
|
218
|
-
|
|
235
|
+
const layers = [mix?.voice ? "내레이션" : "", music ? "곡" : "", sfx?.hits.length ? `효과음 ${sfx.hits.length}(${sfx.settings.set})` : ""].filter(Boolean);
|
|
236
|
+
if (mixed) console.error(`소리: ${layers.join(" + ")} → ${mix!.audio.lufs} LUFS`);
|
|
219
237
|
}
|
|
220
238
|
const sec = (Date.now() - started) / 1000;
|
|
221
239
|
const report = {
|
|
@@ -230,6 +248,15 @@ const cmd: Command = {
|
|
|
230
248
|
motionBlur: blur,
|
|
231
249
|
captions: captions ?? null,
|
|
232
250
|
music: music ?? null,
|
|
251
|
+
sfx: sfx
|
|
252
|
+
? {
|
|
253
|
+
...sfx.settings,
|
|
254
|
+
cues: sfxCues,
|
|
255
|
+
placed: sfx.hits.length,
|
|
256
|
+
...(sfx.measured ? { lufs: { bed: sfx.measured.bed, sfx: sfx.measured.sfx, lift: sfx.measured.lift } } : {}),
|
|
257
|
+
hits: sfx.measured?.hits ?? sfx.hits,
|
|
258
|
+
}
|
|
259
|
+
: null,
|
|
233
260
|
seconds: Math.round(sec * 10) / 10,
|
|
234
261
|
...(segments ? { segments } : {}),
|
|
235
262
|
markers: s.info.markers,
|
package/packages/cli/src/mix.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, renameSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { audioDuration, findSilences } from "@kasaframe/gen";
|
|
5
|
+
import { laySfx, writeFloatWav, type SfxBank, type SfxHit, type SfxPlaced, type SfxSettings } from "./sfx.ts";
|
|
5
6
|
|
|
6
7
|
// 내레이션 + 곡 섞기 — kf.json 에 audio(섞기 값)가 있는 프로젝트만. 없으면 렌더는 예전처럼 곡만 얹는다
|
|
7
8
|
export interface AudioMix {
|
|
@@ -12,6 +13,17 @@ export interface AudioMix {
|
|
|
12
13
|
fadeOut: number;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
// kf.json 에 audio 가 없는데 효과음을 깔 때 — 곡은 그대로(끝 페이드 없이) 두고 전체만 쇼츠·설명과 같은 크기로
|
|
17
|
+
export const SFX_AUDIO: AudioMix = { musicDb: -20, duck: true, lufs: -14, truePeak: -1.5, fadeOut: 0 };
|
|
18
|
+
|
|
19
|
+
/** 효과음 층 — 깔 소리와 결. 섞은 뒤 measured 에 잰 크기·깐 소리마다 가려짐을 적는다 */
|
|
20
|
+
export interface SfxMix {
|
|
21
|
+
hits: SfxHit[];
|
|
22
|
+
settings: SfxSettings;
|
|
23
|
+
bank: Pick<SfxBank, "file">;
|
|
24
|
+
measured?: { bed: number | null; sfx: number; lift: number; hits: SfxPlaced[] };
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
// 목소리를 먼저 이 크기로 맞춘다 — 덕킹 문턱이 목소리 크기에 묶여 있어서 공급자마다 다른 크기를 한 자리로
|
|
16
28
|
const VOICE_LUFS = -18;
|
|
17
29
|
// 덕킹 두 입력의 프레임 크기를 맞춘다 — 목소리 wav(1024)·곡 mp3(1152) 프레임이 도착 차례에 따라 sidechaincompress 결과가
|
|
@@ -29,6 +41,25 @@ function ffmpeg(args: string[]): Promise<string> {
|
|
|
29
41
|
});
|
|
30
42
|
}
|
|
31
43
|
|
|
44
|
+
// 섞은 판을 왼·오 실수 둘로 — 효과음이 둘레 곡 크기를 재고, 그 순간 곡을 누르고, 그 위에 얹는다
|
|
45
|
+
async function stereo(path: string): Promise<[Float32Array, Float32Array]> {
|
|
46
|
+
return new Promise((done, fail) => {
|
|
47
|
+
const p = spawn("ffmpeg", ["-v", "error", "-i", path, "-ac", "2", "-ar", "48000", "-f", "f32le", "-"]);
|
|
48
|
+
const parts: Buffer[] = [];
|
|
49
|
+
p.stdout.on("data", (b: Buffer) => parts.push(b));
|
|
50
|
+
p.on("error", fail);
|
|
51
|
+
p.on("close", (code) => {
|
|
52
|
+
if (code !== 0) return fail(new Error(`ffmpeg 풀기 실패(${code}): ${path}`));
|
|
53
|
+
const b = Buffer.concat(parts);
|
|
54
|
+
const x = new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
|
55
|
+
const L = new Float32Array(x.length >> 1);
|
|
56
|
+
const R = new Float32Array(x.length >> 1);
|
|
57
|
+
for (let i = 0; i < L.length; i++) [L[i], R[i]] = [x[2 * i], x[2 * i + 1]];
|
|
58
|
+
done([L, R]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
32
63
|
// 통합 크기(LUFS) — 무음은 게이트가 빼서 말·곡이 실제로 울리는 곳만 잰다
|
|
33
64
|
async function loudness(input: string[], filter = ""): Promise<number> {
|
|
34
65
|
const err = await ffmpeg([...input, "-af", `${filter ? `${filter},` : ""}ebur128=framelog=quiet`, "-f", "null", "-"]);
|
|
@@ -64,8 +95,19 @@ export function mixSettings(dir: string): { audio: AudioMix; voice?: string } |
|
|
|
64
95
|
}
|
|
65
96
|
|
|
66
97
|
// 영상 [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> {
|
|
98
|
+
export async function mixAudio(src: Sources, o: { audio: AudioMix; from: number; dur: number; total: number; out: string; sfx?: SfxMix }): Promise<string> {
|
|
68
99
|
const { audio } = o;
|
|
100
|
+
const sfx = o.sfx?.hits.length ? o.sfx : undefined;
|
|
101
|
+
// 곡도 목소리도 없으면 효과음만 — 빈 판 위에(둘레 곡이 없으니 크기는 바닥 기준, 누를 것도 없다). 정규화 없이
|
|
102
|
+
if (!src.voice && !src.music && sfx) {
|
|
103
|
+
const n = Math.round(o.dur * 48000);
|
|
104
|
+
const bed: [Float32Array, Float32Array] = [new Float32Array(n), new Float32Array(n)];
|
|
105
|
+
const { stem, placed } = laySfx(bed, sfx.hits, sfx.bank, sfx.settings, { from: o.from, voice: false });
|
|
106
|
+
const raw = writeFloatWav(o.out.replace(/\.wav$/, ".with.wav"), bed);
|
|
107
|
+
await ffmpeg(["-i", raw, "-c:a", "pcm_s16le", o.out]);
|
|
108
|
+
sfx.measured = { bed: null, sfx: await loudness(["-i", writeFloatWav(o.out.replace(/\.wav$/, ".sfx.wav"), [stem])]), lift: 0, hits: placed };
|
|
109
|
+
return o.out;
|
|
110
|
+
}
|
|
69
111
|
const seg = (path: string, at: number) => ["-ss", at.toFixed(6), "-t", o.dur.toFixed(6), "-i", path];
|
|
70
112
|
const inputs: string[] = [];
|
|
71
113
|
const chains: string[] = [];
|
|
@@ -102,11 +144,31 @@ export async function mixAudio(src: Sources, o: { audio: AudioMix; from: number;
|
|
|
102
144
|
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
145
|
const pre = o.out.replace(/\.wav$/, ".pre.wav");
|
|
104
146
|
await ffmpeg([...inputs, "-filter_complex", chains.join(";"), "-map", "[pre]", "-c:a", "pcm_s24le", "-ar", "48000", pre]);
|
|
147
|
+
// 효과음은 곡·목소리를 다 섞은 판 위에 — 소리마다 둘레 곡 크기에 맞추고 그 순간 곡을 짧게 눌러 자리를 낸다(목소리가 있으면 더 낮게)
|
|
148
|
+
let norm = pre;
|
|
149
|
+
if (sfx) {
|
|
150
|
+
const bedLufs = await loudness(["-i", pre]);
|
|
151
|
+
const bed = await stereo(pre);
|
|
152
|
+
const { stem, placed } = laySfx(bed, sfx.hits, sfx.bank, sfx.settings, { from: o.from, voice: !!src.voice });
|
|
153
|
+
norm = writeFloatWav(o.out.replace(/\.wav$/, ".with.wav"), bed);
|
|
154
|
+
const stemFile = writeFloatWav(o.out.replace(/\.wav$/, ".sfx.wav"), [stem]);
|
|
155
|
+
sfx.measured = { bed: bedLufs, sfx: await loudness(["-i", stemFile]), lift: (await loudness(["-i", norm])) - bedLufs, hits: placed };
|
|
156
|
+
}
|
|
105
157
|
// 두 번 잰다 — 한 번에 loudnorm 을 걸면 동적 모드로 들어가 덕킹 모양이 뭉개진다. 잰 값을 주고 linear 로 한 번에 올린다
|
|
106
|
-
const err = await ffmpeg(["-i",
|
|
158
|
+
const err = await ffmpeg(["-i", norm, "-af", `loudnorm=I=${audio.lufs}:TP=${audio.truePeak}:LRA=11:print_format=json`, "-f", "null", "-"]);
|
|
107
159
|
const j = JSON.parse(err.slice(err.lastIndexOf("{"), err.lastIndexOf("}") + 1));
|
|
108
160
|
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",
|
|
161
|
+
await ffmpeg(["-i", norm, "-af", `${ln},aresample=48000`, "-c:a", "pcm_s16le", o.out]);
|
|
162
|
+
// 꼭대기가 0dBTP 가까운 곡은 linear 로 못 올려 동적 모드로 빠지고 목표보다 0.5LU 넘게 커졌다 — 효과음을 깐 판만 넘친 만큼 내린다
|
|
163
|
+
// (내리면 꼭대기도 내려가 truePeak 를 안 넘는다. 끈 판은 예전과 같은 소리로 둔다)
|
|
164
|
+
if (sfx) {
|
|
165
|
+
const got = await loudness(["-i", o.out]);
|
|
166
|
+
if (got - audio.lufs > 0.3) {
|
|
167
|
+
const fit = o.out.replace(/\.wav$/, ".fit.wav");
|
|
168
|
+
await ffmpeg(["-i", o.out, "-af", `volume=${(audio.lufs - got).toFixed(2)}dB`, "-c:a", "pcm_s16le", fit]);
|
|
169
|
+
renameSync(fit, o.out);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
110
172
|
return o.out;
|
|
111
173
|
}
|
|
112
174
|
|
|
@@ -14,7 +14,7 @@ import { repoRoot } from "./serve.ts";
|
|
|
14
14
|
// 조각 꼴(인코딩·해시 재료)이 바뀌면 올린다 — 옛 조각을 다시 쓰지 않게
|
|
15
15
|
const VERSION = "seg-1";
|
|
16
16
|
// 화면을 안 바꾸는 kf.json 칸 — 소리·굽기 기본값·검사 예외. 굽기 값(모션 블러)은 KeyOpts 로 따로 들어간다
|
|
17
|
-
const NOT_FRAMES = new Set(["scenes", "music", "audio", "voice", "narration", "render", "check"]);
|
|
17
|
+
const NOT_FRAMES = new Set(["scenes", "music", "audio", "sfx", "voice", "narration", "render", "check"]);
|
|
18
18
|
const AUDIO = /\.(mp3|wav|m4a|aac|flac|ogg)$/i;
|
|
19
19
|
|
|
20
20
|
export interface SceneEntry {
|