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.
Files changed (104) hide show
  1. package/NOTICE +13 -0
  2. package/README.md +2 -2
  3. package/docs/AGENTS.md +1 -0
  4. package/package.json +1 -1
  5. package/packages/adapters/src/project.ts +2 -0
  6. package/packages/check/package.json +1 -0
  7. package/packages/check/src/check.ts +8 -2
  8. package/packages/check/src/rules/frames.ts +4 -5
  9. package/packages/cli/src/commands/direct.ts +24 -4
  10. package/packages/cli/src/commands/make.ts +15 -5
  11. package/packages/cli/src/commands/render.ts +34 -7
  12. package/packages/cli/src/mix.ts +66 -4
  13. package/packages/cli/src/segments.ts +1 -1
  14. package/packages/cli/src/sfx.ts +429 -0
  15. package/packages/core/src/frame.ts +7 -4
  16. package/packages/core/src/index.ts +3 -1
  17. package/packages/core/src/project.ts +19 -0
  18. package/packages/core/src/sfx.ts +70 -0
  19. package/packages/director/src/assemble.ts +15 -6
  20. package/packages/director/src/fallback.ts +14 -2
  21. package/packages/director/src/index.ts +1 -1
  22. package/packages/director/src/pattern.ts +2 -0
  23. package/packages/director/src/patterns/fill/cells.ts +5 -2
  24. package/packages/director/src/scene.ts +17 -5
  25. package/packages/director/src/transitions.ts +21 -8
  26. package/packages/motion/src/enter.ts +7 -1
  27. package/packages/motion/src/impact.ts +5 -2
  28. package/packages/motion/src/moves/dress.ts +15 -2
  29. package/packages/motion/src/moves/menu.ts +11 -5
  30. package/packages/motion/src/transition.ts +3 -1
  31. package/packages/motion/src/transition3d/index.ts +3 -1
  32. package/packages/motion/src/transition3d/live.ts +5 -3
  33. package/packages/patterns/src/bars/rank.ts +6 -7
  34. package/packages/patterns/src/before-after/shrink.ts +3 -4
  35. package/packages/patterns/src/before-after/strike.ts +4 -5
  36. package/packages/patterns/src/code/terminal.ts +25 -16
  37. package/packages/patterns/src/end/lockup.ts +13 -10
  38. package/packages/patterns/src/end/slam.ts +14 -8
  39. package/packages/patterns/src/hook-number/counter.ts +15 -9
  40. package/packages/patterns/src/hook-number/slam.ts +6 -8
  41. package/packages/patterns/src/kit.ts +364 -59
  42. package/packages/patterns/src/limit/gauge.ts +19 -13
  43. package/packages/patterns/src/quote/karaoke.ts +17 -12
  44. package/packages/patterns/src/sfx.ts +80 -0
  45. package/packages/patterns/src/steps/path.ts +15 -7
  46. package/packages/patterns/src/term-reveal/chips.ts +24 -10
  47. package/packages/patterns/src/term-reveal/expand.ts +17 -13
  48. package/packages/patterns/src/timeline/axis.ts +79 -25
  49. package/packages/patterns/src/title/mask.ts +15 -10
  50. package/packages/patterns/src/title/slam.ts +1 -1
  51. package/packages/patterns/src/types.ts +1 -0
  52. package/packages/plan/src/brief.ts +5 -2
  53. package/packages/themes/src/index.ts +22 -0
  54. package/packages/themes/src/slots.ts +32 -2
  55. package/packages/themes/src/types.ts +28 -0
  56. package/sfx/README.md +82 -0
  57. package/sfx/click/appear.wav +0 -0
  58. package/sfx/click/burst.wav +0 -0
  59. package/sfx/click/drop.wav +0 -0
  60. package/sfx/click/end.wav +0 -0
  61. package/sfx/click/hop.wav +0 -0
  62. package/sfx/click/land.wav +0 -0
  63. package/sfx/click/select.wav +0 -0
  64. package/sfx/click/stick.wav +0 -0
  65. package/sfx/click/tick.wav +0 -0
  66. package/sfx/click/type.wav +0 -0
  67. package/sfx/click/whoosh.wav +0 -0
  68. package/sfx/pop/appear.wav +0 -0
  69. package/sfx/pop/burst.wav +0 -0
  70. package/sfx/pop/drop.wav +0 -0
  71. package/sfx/pop/end.wav +0 -0
  72. package/sfx/pop/hop.wav +0 -0
  73. package/sfx/pop/land.wav +0 -0
  74. package/sfx/pop/select.wav +0 -0
  75. package/sfx/pop/stick.wav +0 -0
  76. package/sfx/pop/tick.wav +0 -0
  77. package/sfx/pop/type.wav +0 -0
  78. package/sfx/pop/whoosh.wav +0 -0
  79. package/sfx/tags.json +545 -0
  80. package/sfx/ui/appear.wav +0 -0
  81. package/sfx/ui/burst.wav +0 -0
  82. package/sfx/ui/drop.wav +0 -0
  83. package/sfx/ui/end.wav +0 -0
  84. package/sfx/ui/hop.wav +0 -0
  85. package/sfx/ui/land.wav +0 -0
  86. package/sfx/ui/select.wav +0 -0
  87. package/sfx/ui/stick.wav +0 -0
  88. package/sfx/ui/tick.wav +0 -0
  89. package/sfx/ui/type.wav +0 -0
  90. package/sfx/ui/whoosh.wav +0 -0
  91. package/themes/kawaii-sticker/effects.ts +15 -3
  92. package/themes/kawaii-sticker/theme.json +1 -0
  93. package/themes/og-measure/THEME.md +13 -2
  94. package/themes/og-measure/effects.ts +76 -59
  95. package/themes/og-measure/sample/scenes/end.ts +2 -2
  96. package/themes/og-measure/sample/scenes/gather.ts +2 -10
  97. package/themes/og-measure/sample/scenes/models.ts +2 -10
  98. package/themes/og-measure/sample/scenes/one.ts +2 -1
  99. package/themes/og-measure/sample/scenes/price.ts +4 -16
  100. package/themes/og-measure/slots.ts +29 -0
  101. package/themes/og-measure/still.png +0 -0
  102. package/themes/og-measure/style.json +3 -0
  103. package/themes/og-measure/theme.json +15 -1
  104. 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,6 @@
1
1
  {
2
2
  "name": "kasaframe",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "말로 시키는 코드 영상 엔진 — 쇼츠·홍보·설명·뮤비를 장면 파일로 짜고 박에 맞춰 MP4 로 굽고 검사한다",
5
5
  "keywords": [
6
6
  "video",
@@ -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) {
@@ -13,6 +13,7 @@
13
13
  "@kasaframe/cli": "*",
14
14
  "@kasaframe/clock": "*",
15
15
  "@kasaframe/core": "*",
16
+ "@kasaframe/themes": "*",
16
17
  "playwright": "^1.64.0",
17
18
  "pngjs": "^7.0.0"
18
19
  }
@@ -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.rules.maxFreezeSec }));
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.rules.maxFreezeSec }));
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(틀이 이긴다) — 고정 관찰 화면(설명·뮤비)은 틀에서 늘려 허용한다. 없으면 0.1초
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초도 쉬지 않는다 — 끝 카드·큰 숫자·로고도 예외 없이 0.1초(60fps 6프레임) 넘게 멈추면 오류
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 (round >= retries || (!Object.keys(blame.failed).length && !Object.keys(blame.seamFailed).length)) break;
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
- break;
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
- // kf.json 에 audio(섞기 값)가 있으면 내레이션·곡을 섞은 소리를 얹는다(덕킹·끝 페이드·LUFS) — 없으면 곡만
213
- const mix = opts["no-music"] ? null : mixSettings(resolve(dir));
214
- const mixed = mix && (mix.voice || music)
215
- ? 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") })
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
- if (mixed) console.error(`소리: ${mix!.voice ? "내레이션" : ""}${mix!.voice && music ? " + " : ""}${music ? "곡" : ""} → ${mix!.audio.lufs} LUFS`);
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,
@@ -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", pre, "-af", `loudnorm=I=${audio.lufs}:TP=${audio.truePeak}:LRA=11:print_format=json`, "-f", "null", "-"]);
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", pre, "-af", `${ln},aresample=48000`, "-c:a", "pcm_s16le", o.out]);
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 {