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
@@ -0,0 +1,429 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { SFX_ROLES, type SfxRole } from "@kasaframe/core";
5
+ import { loadTheme } from "@kasaframe/themes";
6
+
7
+ // 효과음 — 장면 움직임이 남긴 소리 자리(__kf.events 의 sfx)를 뜻 있는 것만 적게 고르고, 테마가 고른 결의 파일을 곡 둘레 크기에 맞춰 깔고
8
+ // 그 순간 곡을 짧게 눌러 자리를 낸다. 같은 자리·같은 설정이면 같은 소리 — 고르기·음높이 흔들기는 시각과 역할의 해시로만 정한다
9
+
10
+ const SR = 48000;
11
+
12
+ export interface SfxSettings {
13
+ /** 소리 결 — sfx/tags.json 의 sets */
14
+ set: string;
15
+ /** 둘레 곡(소리 자리 앞뒤 0.2초) 크기 대비 dB — 0 이면 곡과 비슷하게 */
16
+ db: number;
17
+ /** 1초에 몇 개까지(2초 창으로 센다 — 0.5 면 2초에 하나) */
18
+ perSec: number;
19
+ only: SfxRole[] | null;
20
+ /** 목소리가 있으면 더 낮춘다 */
21
+ voiceDb: number;
22
+ /** 잔소리(낱낱이 나타남·숫자 째깍·타자)도 깐다 — 기본은 뜻 있는 자리만 */
23
+ chatter: boolean;
24
+ /** 소리마다 곡을 이만큼(dB) 짧게 눌러 자리를 낸다 */
25
+ duck: number;
26
+ }
27
+
28
+ const DEFAULT: SfxSettings = { set: "ui", db: -2, perSec: 1, only: null, voiceDb: -4, chatter: false, duck: 3 };
29
+
30
+ // 역할마다 결 크기 위아래(db)·밀도가 넘칠 때 남기는 차례(prio 높은 것부터)·저역을 걷는 곳(Hz — 킥·베이스와 안 겹치게, 드롭 쿵만 낮게 둔다)
31
+ export const ROLE: Record<SfxRole, { db: number; prio: number; cut: number }> = {
32
+ drop: { db: 2, prio: 10, cut: 60 },
33
+ end: { db: 1, prio: 9, cut: 250 },
34
+ whoosh: { db: -1, prio: 8, cut: 250 },
35
+ land: { db: 0, prio: 7, cut: 150 },
36
+ stick: { db: 0, prio: 6, cut: 200 },
37
+ burst: { db: 0, prio: 6, cut: 250 },
38
+ hop: { db: -1, prio: 5, cut: 200 },
39
+ select: { db: -1, prio: 4, cut: 250 },
40
+ appear: { db: -2, prio: 3, cut: 250 },
41
+ tick: { db: -4, prio: 2, cut: 300 },
42
+ type: { db: -4, prio: 1, cut: 300 },
43
+ };
44
+
45
+ /** 같은 역할은 이 안에 하나 */
46
+ const SAME_ROLE = 0.1;
47
+ /** 아무 소리끼리 이만큼 띄운다 — 붙어 나면 하나로 뭉개진다 */
48
+ const MIN_GAP = 0.25;
49
+ /** 드롭 앞뒤 이 안은 드롭만 */
50
+ const DROP_CLEAR = 0.35;
51
+ /** 밀도를 세는 창(초) */
52
+ const WINDOW = 2;
53
+ /** 가려짐 목표 — 소리 대역에서 소리 순간에 효과음이 곡보다 이만큼(dB) 위. 모자라면 BOOST 까지 더 키운다 */
54
+ const SMR_TARGET = 4;
55
+ const BOOST = 6;
56
+ /**
57
+ * 소리가 차지하는 대역 후보(Hz) — 소리마다 제 가장 센 대역에서 10dB 안쪽인 대역들(그 소리가 실제로 차지하는 곳)을 잡고, 그중 곡을 가장 넘는 대역에서
58
+ * 가려짐을 잰다(귀는 소리가 곡을 넘는 대역에서 그 소리를 듣는다). 낮은 몸에 2–6kHz 어택을 얹은 소리는 어택 대역이 잡힌다
59
+ */
60
+ export const BANDS: readonly [number, number][] = [
61
+ [40, 150],
62
+ [150, 600],
63
+ [600, 2000],
64
+ [2000, 6000],
65
+ [6000, 12000],
66
+ ];
67
+
68
+ export const sfxDir = (): string => process.env.KF_SFX_DIR || fileURLToPath(new URL("../../../sfx/", import.meta.url));
69
+
70
+ interface BankFile {
71
+ path: string;
72
+ lead: number;
73
+ level: number;
74
+ }
75
+
76
+ export interface SfxBank {
77
+ dir: string;
78
+ sets: string[];
79
+ file(set: string, role: SfxRole): BankFile | null;
80
+ }
81
+
82
+ export function loadBank(dir = sfxDir()): SfxBank {
83
+ const tags = JSON.parse(readFileSync(join(dir, "tags.json"), "utf8")) as { sets: Record<string, unknown>; files: Record<string, { set: string; role: string; lead: number; level: number }> };
84
+ const by = new Map(Object.entries(tags.files).map(([rel, f]) => [`${f.set}/${f.role}`, { path: join(dir, rel), lead: f.lead, level: f.level }]));
85
+ return { dir, sets: Object.keys(tags.sets), file: (set, role) => by.get(`${set}/${role}`) ?? null };
86
+ }
87
+
88
+ const isRole = (r: unknown): r is SfxRole => (SFX_ROLES as readonly unknown[]).includes(r);
89
+
90
+ /**
91
+ * 이 프로젝트의 효과음 설정 — 기본 ← theme.json sfx ← kf.json sfx. 끄면 null.
92
+ * flag 는 kf render|make --sfx: off 면 끈다, on 이면 kf.json sfx: false 도 이 판만 켠다
93
+ */
94
+ export function sfxSettings(dir: string, flag?: string): SfxSettings | null {
95
+ if (flag === "off") return null;
96
+ type Loose = Partial<Omit<SfxSettings, "only">> & { only?: string[] | null };
97
+ const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8")) as { theme?: string; sfx?: false | Loose };
98
+ if (kf.sfx === false && flag !== "on") return null;
99
+ let theme: Loose = {};
100
+ if (kf.theme) {
101
+ try {
102
+ theme = loadTheme(kf.theme).sfx ?? {};
103
+ } catch {}
104
+ }
105
+ const over = kf.sfx && typeof kf.sfx === "object" ? kf.sfx : {};
106
+ const s: Loose & Omit<SfxSettings, "only"> = { ...DEFAULT, ...theme, ...over };
107
+ const only = s.only ? s.only.filter(isRole) : null;
108
+ return {
109
+ set: s.set,
110
+ db: Number(s.db),
111
+ perSec: Math.max(0.1, Number(s.perSec)),
112
+ only: only?.length ? only : null,
113
+ voiceDb: Number(s.voiceDb),
114
+ chatter: !!s.chatter,
115
+ duck: Math.max(0, Number(s.duck)),
116
+ };
117
+ }
118
+
119
+ export interface SfxCue {
120
+ t: number;
121
+ role: SfxRole;
122
+ source: string;
123
+ weight: number;
124
+ /** 잔소리 — chatter 가 아니면 안 깐다 */
125
+ minor?: boolean;
126
+ }
127
+
128
+ /** 페이지가 남긴 기록에서 소리 자리를, 곡의 드롭(beats.json — 영상 0초 기준)은 드롭 쿵으로 */
129
+ export function cuesOf(events: readonly { t: number; kind: string; source: string; name?: string; value?: number; minor?: boolean }[], drops: readonly number[] = []): SfxCue[] {
130
+ const out: SfxCue[] = [];
131
+ for (const e of events)
132
+ if (e.kind === "sfx" && isRole(e.name)) out.push({ t: e.t, role: e.name, source: e.source, weight: Math.min(1, Math.max(0.05, e.value ?? 1)), ...(e.minor ? { minor: true } : {}) });
133
+ for (const d of drops) out.push({ t: Math.round(d * 1000) / 1000, role: "drop", source: "beats", weight: 1 });
134
+ return out;
135
+ }
136
+
137
+ export function projectDrops(dir: string): number[] {
138
+ try {
139
+ const kf = JSON.parse(readFileSync(join(dir, "kf.json"), "utf8")) as { beats?: string };
140
+ if (!kf.beats) return [];
141
+ return (JSON.parse(readFileSync(resolve(dir, kf.beats), "utf8")) as { drops?: number[] }).drops ?? [];
142
+ } catch {
143
+ return [];
144
+ }
145
+ }
146
+
147
+ export interface SfxHit {
148
+ t: number;
149
+ role: SfxRole;
150
+ /** 결 안 역할 파일 — 섞을 때 bank 에서 찾는다 */
151
+ set: string;
152
+ /** 역할 크기 + 무게 + 흔들기(dB) */
153
+ db: number;
154
+ /** 음높이 배수 — 같은 소리가 줄줄이 나도 기계음처럼 안 들리게 */
155
+ rate: number;
156
+ }
157
+
158
+ // 시각·역할 → 0..1 두 개(fnv1a). 같은 자리면 어느 기계에서나 같은 흔들기
159
+ function hash2(key: string): [number, number] {
160
+ let h = 0x811c9dc5;
161
+ for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 0x01000193) >>> 0;
162
+ const a = (h & 0xffff) / 0x10000;
163
+ const b = (h >>> 16) / 0x10000;
164
+ return [a, b];
165
+ }
166
+
167
+ /**
168
+ * 소리 자리를 깔 소리로 — 잔소리(chatter 가 아니면)·결에 없는 역할·only 밖 역할은 빼고, 같은 역할은 SAME_ROLE 안에 하나, 차례(ROLE.prio)대로
169
+ * 드롭 앞뒤를 비우고 MIN_GAP 을 띄우고 아무 2초 창에 perSec×2 개(적어도 하나)까지 남긴다. 결과는 시각 차례
170
+ */
171
+ export function placeSfx(cues: readonly SfxCue[], s: SfxSettings, bank: Pick<SfxBank, "file">, o: { from: number; to: number }): SfxHit[] {
172
+ const ok = cues
173
+ .filter((c) => c.t >= o.from - 1e-9 && c.t < o.to - 1e-9 && (s.chatter || !c.minor) && (!s.only || s.only.includes(c.role)) && bank.file(s.set, c.role))
174
+ .sort((a, b) => a.t - b.t || ROLE[b.role].prio - ROLE[a.role].prio || (a.source < b.source ? -1 : a.source > b.source ? 1 : 0));
175
+ const merged: SfxCue[] = [];
176
+ const last = new Map<SfxRole, SfxCue>();
177
+ for (const c of ok) {
178
+ const prev = last.get(c.role);
179
+ if (prev && c.t - prev.t < SAME_ROLE) {
180
+ prev.weight = Math.max(prev.weight, c.weight);
181
+ continue;
182
+ }
183
+ const m = { ...c };
184
+ merged.push(m);
185
+ last.set(c.role, m);
186
+ }
187
+ const order = [...merged].sort((a, b) => ROLE[b.role].prio - ROLE[a.role].prio || a.t - b.t);
188
+ const kept: SfxCue[] = [];
189
+ const most = Math.max(1, Math.round(s.perSec * WINDOW));
190
+ // t 를 품은 어느 창 [w, w+WINDOW) 이 most 를 넘나 — 가장 붐비는 창은 t 앞 WINDOW 안의 소리(t 포함)에서 시작한다
191
+ const crowded = (t: number) => {
192
+ const xs = [...kept.map((k) => k.t), t];
193
+ return xs.filter((w) => w <= t && w > t - WINDOW).some((w) => xs.filter((x) => x >= w && x < w + WINDOW).length > most);
194
+ };
195
+ for (const c of order) {
196
+ if (kept.some((k) => Math.abs(k.t - c.t) < MIN_GAP)) continue;
197
+ if (c.role !== "drop" && kept.some((k) => k.role === "drop" && Math.abs(k.t - c.t) < DROP_CLEAR)) continue;
198
+ if (crowded(c.t)) continue;
199
+ kept.push(c);
200
+ }
201
+ return kept
202
+ .sort((a, b) => a.t - b.t)
203
+ .map((c) => {
204
+ const [h1, h2] = hash2(`${c.role}|${c.t.toFixed(3)}`);
205
+ return { t: c.t, role: c.role, set: s.set, db: Math.round((ROLE[c.role].db + 20 * Math.log10(c.weight) + (h1 - 0.5) * 2) * 100) / 100, rate: Math.round((1 + (h2 - 0.5) * 0.06) * 10000) / 10000 };
206
+ });
207
+ }
208
+
209
+ // --- 소리 다루기: 2차 버터워스 거르개(RBJ) — 같은 입력이면 같은 출력
210
+
211
+ type Biquad = [number, number, number, number, number];
212
+
213
+ function biquad(kind: "lp" | "hp", f: number): Biquad {
214
+ const w = (2 * Math.PI * f) / SR;
215
+ const c = Math.cos(w);
216
+ const a = Math.sin(w) / Math.SQRT2;
217
+ const b0 = kind === "lp" ? (1 - c) / 2 : (1 + c) / 2;
218
+ const b1 = kind === "lp" ? 1 - c : -(1 + c);
219
+ const a0 = 1 + a;
220
+ return [b0 / a0, b1 / a0, b0 / a0, (-2 * c) / a0, (1 - a) / a0];
221
+ }
222
+
223
+ function filter(x: Float32Array, q: Biquad): Float32Array {
224
+ const [b0, b1, b2, a1, a2] = q;
225
+ const y = new Float32Array(x.length);
226
+ let x1 = 0;
227
+ let x2 = 0;
228
+ let y1 = 0;
229
+ let y2 = 0;
230
+ for (let i = 0; i < x.length; i++) {
231
+ const v = b0 * x[i] + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
232
+ x2 = x1;
233
+ x1 = x[i];
234
+ y2 = y1;
235
+ y1 = v;
236
+ y[i] = v;
237
+ }
238
+ return y;
239
+ }
240
+
241
+ /** 대역 [lo, hi] 만 — 높은·낮은 거르개를 두 번씩(4차) */
242
+ function band(x: Float32Array, [lo, hi]: readonly [number, number]): Float32Array {
243
+ const h = biquad("hp", lo);
244
+ const l = biquad("lp", Math.min(hi, SR * 0.45));
245
+ return filter(filter(filter(filter(x, h), h), l), l);
246
+ }
247
+
248
+ // [a, b) 창 평균 에너지 — 창이 소리 끝·앞을 넘으면 넘은 자리는 0 으로 친다(잘린 길이로 나누면 짧은 소리가 5dB 넘게 부풀었다)
249
+ const energy = (x: Float32Array, a: number, b: number): number => {
250
+ let e = 0;
251
+ for (let i = Math.max(0, a); i < Math.min(x.length, b); i++) e += x[i] * x[i];
252
+ return b > a ? e / (b - a) : 0;
253
+ };
254
+ const db = (e: number): number => 10 * Math.log10(e + 1e-15);
255
+
256
+ // 100ms 창 RMS 최댓값(dBFS) — tools/sfx/build.ts 의 level 과 같은 잣대
257
+ function peak100(y: Float32Array): number {
258
+ const win = Math.round(SR * 0.1);
259
+ let best = 0;
260
+ for (let i = 0; i < Math.max(1, y.length - win + 1); i += Math.round(SR * 0.01)) best = Math.max(best, energy(y, i, i + win));
261
+ return db(best);
262
+ }
263
+
264
+ // sfx/ 의 16비트 모노 wav — tools/sfx/build.ts 가 쓴 꼴만
265
+ const raw = new Map<string, Float32Array>();
266
+ function samples(path: string): Float32Array {
267
+ const hit = raw.get(path);
268
+ if (hit) return hit;
269
+ const b = readFileSync(path);
270
+ let p = 12;
271
+ let data: Buffer | null = null;
272
+ let bits = 16;
273
+ let channels = 1;
274
+ while (p + 8 <= b.length) {
275
+ const id = b.toString("ascii", p, p + 4);
276
+ const size = b.readUInt32LE(p + 4);
277
+ if (id === "fmt ") {
278
+ channels = b.readUInt16LE(p + 10);
279
+ bits = b.readUInt16LE(p + 22);
280
+ } else if (id === "data") data = b.subarray(p + 8, p + 8 + size);
281
+ p += 8 + size + (size & 1);
282
+ }
283
+ if (!data || bits !== 16 || channels !== 1) throw new Error(`효과음 파일 꼴이 아니다(16비트 모노 wav): ${path}`);
284
+ const y = new Float32Array(data.length / 2);
285
+ for (let i = 0; i < y.length; i++) y[i] = data.readInt16LE(i * 2) / 32768;
286
+ raw.set(path, y);
287
+ return y;
288
+ }
289
+
290
+ interface Voiced {
291
+ y: Float32Array;
292
+ /** 파일 시작 → 꼭대기(샘플) */
293
+ lead: number;
294
+ level: number;
295
+ /** 소리가 차지하는 대역들과 그 대역으로 거른 소리 */
296
+ bands: { band: readonly [number, number]; y: Float32Array }[];
297
+ }
298
+
299
+ // 음높이를 옮기고(선형 보간) 저역을 걷은 소리 — 크기·대역은 걷은 뒤에 잰다
300
+ const voiced = new Map<string, Voiced>();
301
+ function voice(f: BankFile, rate: number, cut: number): Voiced {
302
+ const key = `${f.path}|${rate}|${cut}`;
303
+ const hit = voiced.get(key);
304
+ if (hit) return hit;
305
+ const src = samples(f.path);
306
+ const len = Math.floor((src.length - 1) / rate);
307
+ const moved = new Float32Array(len);
308
+ for (let i = 0; i < len; i++) {
309
+ const x = i * rate;
310
+ const k = Math.floor(x);
311
+ moved[i] = src[k] + (src[k + 1] - src[k]) * (x - k);
312
+ }
313
+ const hp = biquad("hp", cut);
314
+ const y = filter(filter(moved, hp), hp);
315
+ const split = BANDS.map((b) => ({ band: b, y: band(y, b) }));
316
+ const loud = split.map((x) => db(energy(x.y, 0, x.y.length)));
317
+ const top = Math.max(...loud);
318
+ const v = { y, lead: Math.round((f.lead / rate) * SR), level: peak100(y), bands: split.filter((_, i) => loud[i] >= top - 10) };
319
+ voiced.set(key, v);
320
+ return v;
321
+ }
322
+
323
+ export interface SfxPlaced extends SfxHit {
324
+ /** 둘레 곡 크기(앞뒤 0.2초 RMS, dBFS) */
325
+ local: number;
326
+ /** 소리 크기(100ms 창, dBFS) − 둘레 곡 */
327
+ over: number;
328
+ /** 가려짐을 잰 대역(Hz) */
329
+ band: readonly [number, number];
330
+ /** 그 대역에서 소리 순간(꼭대기 앞 10ms·뒤 90ms) 효과음 ÷ 눌린 곡(dB) */
331
+ smr: number;
332
+ /** 가려져서 더 키운 dB */
333
+ boost: number;
334
+ }
335
+
336
+ /**
337
+ * 곡(·목소리)을 섞은 판 bed(스테레오, 영상 from 초가 0)에 효과음을 깐다 — 소리마다
338
+ * ① 크기: 둘레 곡 크기 + s.db + 역할 크기(목소리가 있으면 voiceDb 더) ② 그 순간 곡을 s.duck dB 짧게(소리 시작 10ms 앞부터 꼭대기 100ms 뒤까지) 눌러 자리를 내고
339
+ * ③ 소리 대역에서 효과음이 눌린 곡보다 SMR_TARGET 위가 안 되면 BOOST 까지 더 키운다. bed 를 바꿔 쓰고, 깐 소리와 잰 값을 돌려준다
340
+ */
341
+ export function laySfx(bed: [Float32Array, Float32Array], hits: readonly SfxHit[], bank: Pick<SfxBank, "file">, s: SfxSettings, o: { from: number; voice: boolean }): { stem: Float32Array; placed: SfxPlaced[] } {
342
+ const [L, R] = bed;
343
+ const n = L.length;
344
+ const mono = new Float32Array(n);
345
+ for (let i = 0; i < n; i++) mono[i] = (L[i] + R[i]) / 2;
346
+ // 곡이 비었거나 아주 조용한 곳은 판 전체(무음 뺀 400ms 창 평균)에서 12dB 아래를 바닥으로
347
+ const blocks: number[] = [];
348
+ for (let i = 0; i + 0.4 * SR <= n; i += 0.1 * SR) {
349
+ const e = energy(mono, i, i + 0.4 * SR);
350
+ if (e > 1e-7) blocks.push(e);
351
+ }
352
+ const floor = blocks.length ? db(blocks.reduce((x, y) => x + y, 0) / blocks.length) - 12 : -32;
353
+ const at = (t: number) => Math.round((t - o.from) * SR);
354
+ const items = hits.flatMap((h) => {
355
+ const f = bank.file(h.set, h.role);
356
+ if (!f) return [];
357
+ const v = voice(f, h.rate, ROLE[h.role].cut);
358
+ const peak = at(h.t);
359
+ return [{ h, v, peak, start: peak - v.lead, local: Math.max(floor, db(energy(mono, peak - 0.2 * SR, peak + 0.2 * SR))) }];
360
+ });
361
+ // 누르기 — 소리마다 작은 골짜기, 겹치면 깊은 쪽
362
+ const env = new Float32Array(n).fill(1);
363
+ const dip = 10 ** (-s.duck / 20);
364
+ const rampIn = Math.round(0.01 * SR);
365
+ const rampOut = Math.round(0.04 * SR);
366
+ for (const it of items) {
367
+ const a = it.start - rampIn;
368
+ const b = it.peak + Math.round(0.1 * SR);
369
+ for (let i = Math.max(0, a - rampIn); i < Math.min(n, b + rampOut); i++) {
370
+ const k = i < a ? (i - (a - rampIn)) / rampIn : i > b ? 1 - (i - b) / rampOut : 1;
371
+ env[i] = Math.min(env[i], 1 - (1 - dip) * Math.max(0, Math.min(1, k)));
372
+ }
373
+ }
374
+ const ducked = new Float32Array(n);
375
+ for (let i = 0; i < n; i++) ducked[i] = mono[i] * env[i];
376
+ const stem = new Float32Array(n);
377
+ const placed: SfxPlaced[] = [];
378
+ const pre = Math.round(0.2 * SR);
379
+ for (const it of items) {
380
+ const { h, v } = it;
381
+ const target = it.local + s.db + h.db + (o.voice ? s.voiceDb : 0);
382
+ let gain = target - v.level;
383
+ // 가려짐 — 소리 꼭대기 앞 10ms·뒤 90ms 를 그 소리 대역마다. 곡 쪽은 거르개가 자리 잡게 앞 0.2초부터 걸러 뒤만 잰다
384
+ const a = it.peak - Math.round(0.01 * SR);
385
+ const b = it.peak + Math.round(0.09 * SR);
386
+ const ref = ducked.subarray(Math.max(0, a - pre), Math.min(n, b));
387
+ const per = v.bands.map((x) => ({
388
+ band: x.band,
389
+ smr: db(energy(x.y, v.lead - Math.round(0.01 * SR), v.lead + Math.round(0.09 * SR)) * 10 ** (gain / 10)) - db(energy(band(ref, x.band), a - Math.max(0, a - pre), ref.length)),
390
+ }));
391
+ const best = per.reduce((p, q) => (q.smr > p.smr ? q : p));
392
+ const smr0 = best.smr;
393
+ const boost = Math.min(BOOST, Math.max(0, SMR_TARGET - smr0));
394
+ gain += boost;
395
+ const k = 10 ** (gain / 20);
396
+ for (let i = Math.max(0, -it.start); i < v.y.length && it.start + i < n; i++) stem[it.start + i] += k * v.y[i];
397
+ const r = (x: number) => Math.round(x * 10) / 10;
398
+ placed.push({ ...h, local: r(it.local), over: r(v.level + gain - it.local), band: best.band, smr: r(smr0 + boost), boost: r(boost) });
399
+ }
400
+ for (let i = 0; i < n; i++) {
401
+ L[i] = L[i] * env[i] + stem[i];
402
+ R[i] = R[i] * env[i] + stem[i];
403
+ }
404
+ return { stem, placed };
405
+ }
406
+
407
+ /** 32비트 실수 wav — 채널을 받은 차례로 엇갈려 쓴다 */
408
+ export function writeFloatWav(path: string, channels: Float32Array[]): string {
409
+ const c = channels.length;
410
+ const n = channels[0].length;
411
+ const body = new Float32Array(n * c);
412
+ for (let i = 0; i < n; i++) for (let k = 0; k < c; k++) body[i * c + k] = channels[k][i];
413
+ const data = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
414
+ const head = Buffer.alloc(44);
415
+ head.write("RIFF", 0);
416
+ head.writeUInt32LE(36 + data.length, 4);
417
+ head.write("WAVEfmt ", 8);
418
+ head.writeUInt32LE(16, 16);
419
+ head.writeUInt16LE(3, 20);
420
+ head.writeUInt16LE(c, 22);
421
+ head.writeUInt32LE(SR, 24);
422
+ head.writeUInt32LE(SR * 4 * c, 28);
423
+ head.writeUInt16LE(4 * c, 32);
424
+ head.writeUInt16LE(32, 34);
425
+ head.write("data", 36);
426
+ head.writeUInt32LE(data.length, 40);
427
+ writeFileSync(path, Buffer.concat([head, data]));
428
+ return path;
429
+ }
@@ -39,20 +39,22 @@ export function onFrame(fn: (t: number) => void): () => void {
39
39
  };
40
40
  }
41
41
 
42
- export type MotionEventKind = "pulse" | "shake" | "camera" | "flash" | "move";
42
+ export type MotionEventKind = "pulse" | "shake" | "camera" | "flash" | "move" | "sfx";
43
43
 
44
44
  /** 검사(kf check)가 style.json 움직임 규칙 위반을 세는 기록. 효과가 0 이 아닌 프레임마다 한 줄 */
45
45
  export interface KfMotionEvent {
46
- /** 전체 시각(초) — 그 프레임 */
46
+ /** 전체 시각(초) — 그 프레임. sfx 는 그 소리가 날 시각 */
47
47
  t: number;
48
48
  kind: MotionEventKind;
49
49
  /** 부품 id */
50
50
  source: string;
51
- /** pulse 0..1, shake 는 px, camera 는 이동량(px 또는 배율 차), flash 0..1, move 1 */
51
+ /** pulse 0..1, shake 는 px, camera 는 이동량(px 또는 배율 차), flash 0..1, move 1, sfx 는 크기(0..1) */
52
52
  value: number;
53
- /** move 일 때 움직임 이름(fade·rise·whip …) — 같은 움직임 되풀이를 센다 */
53
+ /** move 일 때 움직임 이름(fade·rise·whip …) — 같은 움직임 되풀이를 센다. sfx 면 효과음 역할 */
54
54
  name?: string;
55
55
  scene?: string;
56
+ /** sfx 만 — 낱낱이 나타남·숫자 째깍·타자 같은 잔소리. 렌더는 기본으로 안 깐다(테마 sfx.chatter 면 깐다) */
57
+ minor?: boolean;
56
58
  }
57
59
 
58
60
  interface KfEventHost {
@@ -83,6 +85,7 @@ export function emit(e: Omit<KfMotionEvent, "t"> & { t?: number }): void {
83
85
  const row: KfMotionEvent = { t, kind: e.kind, source: e.source, value: Math.round(e.value * 1000) / 1000 };
84
86
  if (e.name !== undefined) row.name = e.name;
85
87
  if (e.scene !== undefined) row.scene = e.scene;
88
+ if (e.minor) row.minor = true;
86
89
  sink().push(row);
87
90
  }
88
91
 
@@ -23,5 +23,7 @@ export { combine, toCss, applyStyle } from "./style.ts";
23
23
  export type { Style, CssStyle } from "./style.ts";
24
24
  export { setFrameTime, frameTime, onFrame, emit, motionEvents, resetMotionEvents, setFrameSample, frameSample } from "./frame.ts";
25
25
  export type { KfMotionEvent, MotionEventKind, FrameSample } from "./frame.ts";
26
- export { silentBeats, scaleOf, cssVars, fontCss } from "./project.ts";
26
+ export { SFX_ROLES, SFX_LABEL, cue, cueAt, withoutSfx } from "./sfx.ts";
27
+ export type { SfxRole, CueOpts } from "./sfx.ts";
28
+ export { silentBeats, scaleOf, cssVars, fontCss, MAX_FREEZE_SEC, freezeLimits } from "./project.ts";
27
29
  export type { CaptionLook, KfMeta, KfStyle, MotionRules, SafeArea, SceneCtx, FontFace, StyleLook } from "./project.ts";
@@ -1,4 +1,5 @@
1
1
  import { createBeats, type Beats } from "./beats.ts";
2
+ import type { CueOpts, SfxRole } from "./sfx.ts";
2
3
 
3
4
  export interface KfMeta {
4
5
  fps: number;
@@ -36,6 +37,19 @@ export interface MotionRules {
36
37
  durations?: { fast: number; base: number; slow: number };
37
38
  }
38
39
 
40
+ /** 멈춤 기본 기준 — 단 0.1초(60fps 6프레임)도 쉬지 않는다 */
41
+ export const MAX_FREEZE_SEC = 0.1;
42
+
43
+ /**
44
+ * 멈춤 기준 — 경고는 틀·style.json 값(틀이 이긴다, 없으면 0.1초), 오류는 그 값과 테마 값(theme.json motion.maxFreezeSec) 중 큰 쪽.
45
+ * 테마는 늘리기만 한다 — 짚음이 요점에 머물러 화면이 쉬는 세계(OG 측정 화면)가 그 쉼으로 장면을 떨어뜨리지 않게 하되,
46
+ * 고정 관찰 화면 틀·스타일이 늘린 값(0.5)을 조이지는 못하고, 늘린 만큼도 경고로는 보인다
47
+ */
48
+ export function freezeLimits(base: number | undefined, theme?: number | null): { warn: number; error: number } {
49
+ const warn = base ?? MAX_FREEZE_SEC;
50
+ return { warn, error: Math.max(warn, theme ?? 0) };
51
+ }
52
+
39
53
  export interface FontFace {
40
54
  family: string;
41
55
  src: string;
@@ -131,6 +145,11 @@ export interface SceneCtx {
131
145
  safe: SafeArea;
132
146
  /** 프로젝트 assets/ 기준 경로 → 페이지에서 쓸 주소 */
133
147
  asset(path: string): string;
148
+ /**
149
+ * 효과음 자리 — 장면 시각 at(초)에 그 역할 소리. build 에서 불러도 draw 에서 불러도 된다(같은 자리는 한 번).
150
+ * 라이브러리 부품(테마 효과·패턴·전환·드롭)은 저절로 남기니, 부품 밖에서 손으로 지은 움직임에만 쓴다
151
+ */
152
+ sfx(role: SfxRole, at: number, o?: Omit<CueOpts, "scene">): void;
134
153
  }
135
154
 
136
155
  /** 곡 없는 프로젝트용. pulse 는 늘 0(드롭이 없다), drop(i) 는 던진다 */
@@ -0,0 +1,70 @@
1
+ import { emit, frameTime } from "./frame.ts";
2
+
3
+ /**
4
+ * 효과음 역할 — 소리 결(테마가 고른 묶음)마다 역할 하나에 파일 하나가 있다. 렌더가 이 이름으로 파일을 고르고, 밀도 상한을 넘으면
5
+ * 앞 역할(드롭·끝·휙…)부터 남긴다
6
+ */
7
+ export const SFX_ROLES = ["appear", "stick", "land", "hop", "whoosh", "tick", "select", "type", "drop", "burst", "end"] as const;
8
+
9
+ export type SfxRole = (typeof SFX_ROLES)[number];
10
+
11
+ export const SFX_LABEL: Record<SfxRole, string> = {
12
+ appear: "나타남",
13
+ stick: "톡 붙기",
14
+ land: "착지",
15
+ hop: "통 뛰기",
16
+ whoosh: "휙 전환",
17
+ tick: "숫자 째깍",
18
+ select: "짚기 딸깍",
19
+ type: "타자",
20
+ drop: "드롭 쿵",
21
+ burst: "팡 터짐",
22
+ end: "끝 마무리",
23
+ };
24
+
25
+ export interface CueOpts {
26
+ /** 부품 id — 같은 시각·같은 부품은 한 번만 남는다 */
27
+ source?: string;
28
+ scene?: string;
29
+ /** 0..1 — 역할 크기에 곱한다(작은 칸은 작게) */
30
+ weight?: number;
31
+ /**
32
+ * 잔소리 — 낱낱이 나타나는 칸·숫자 째깍·타자처럼 자주 나는 것. 렌더가 기본으로 안 깐다(다 깔면 곡에 묻히고 시끄럽다).
33
+ * 장면 바뀜·드롭·주인공 착지·짚개가 핵심 값에 닿는 순간만 minor 없이 남긴다
34
+ */
35
+ minor?: boolean;
36
+ }
37
+
38
+ let muted = 0;
39
+
40
+ /**
41
+ * 이 안에서 그린 것은 소리 자리를 안 남긴다 — 이음매가 앞뒤 장면을 한 벌 더 지어 다른 시각으로 그릴 때(진짜 장면이 이미 남겼고,
42
+ * 첫 프레임에 서 있는 뒤 장면은 시작 직후로 보이는 부품이 프레임마다 새 자리를 남긴다)
43
+ */
44
+ export function withoutSfx<T>(fn: () => T): T {
45
+ muted++;
46
+ try {
47
+ return fn();
48
+ } finally {
49
+ muted--;
50
+ }
51
+ }
52
+
53
+ // 움직임이 시작하고 이만큼 안에 그린 프레임이 그 자리를 남긴다 — 검사(30fps)·렌더(60fps)·모션 블러 하위 프레임 어느 쪽도 한 번은 이 안에 든다
54
+ const WINDOW = 0.12;
55
+
56
+ /** 효과음 자리를 전체 시각 t(초)에 남긴다 — 장면 작성자의 ctx.sfx 가 부르는 자리 */
57
+ export function cueAt(role: SfxRole, t: number, o: CueOpts = {}): void {
58
+ if (muted || !Number.isFinite(t) || t < 0) return;
59
+ emit({ kind: "sfx", t, name: role, source: o.source ?? role, scene: o.scene, value: o.weight ?? 1, ...(o.minor ? { minor: true } : {}) });
60
+ }
61
+
62
+ /**
63
+ * 움직임 부품이 부른다 — since 는 그 움직임이 시작하고 지난 초(그 프레임의 부품 시각 − 시작 시각). 시작 직후 프레임에서
64
+ * 「지금 프레임 시각 − since」를 소리 자리로 남긴다. 부품이 장면 시각을 얼마나 밀어 받았든 시작 순간의 전체 시각이 나오고,
65
+ * 어느 프레임이 남기든 같은 값이라 탭을 나눠 찍어도 같은 소리다
66
+ */
67
+ export function cue(role: SfxRole, since: number, o: CueOpts = {}): void {
68
+ if (!(since >= 0 && since < WINDOW)) return;
69
+ cueAt(role, frameTime() - since, o);
70
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { themeTransitions } from "@kasaframe/themes";
4
5
  import { worldOf } from "@kasaframe/themes/name";
5
6
  import { dressSeed, type Dress } from "@kasaframe/motion/menu";
6
7
  import { loadTemplate, sceneSkeleton, type Template } from "@kasaframe/plan";
@@ -8,7 +9,7 @@ import { catalog } from "./catalog.ts";
8
9
  import { TYPES, type Content, type Scene, type SceneType } from "./content.ts";
9
10
  import type { AspectName, Energy, TextPolicy } from "./pattern.ts";
10
11
  import { type Entry, type Pick, repick, selectAll, type Slot } from "./select.ts";
11
- import { type BeatsLite, cyclingText, dressAll, energyOf, seamKinds, seamSpots, type Spot } from "./transitions.ts";
12
+ import { type BeatsLite, cyclingText, dressAll, energyOf, seamKinds, seamsAllowed, seamSpots, type Spot } from "./transitions.ts";
12
13
 
13
14
  // content.json → 장면 시간표·장면 파일. 두 단계로 나뉜다 — 그 사이에 kf narrate 가 목소리로 장면 길이를 정한다.
14
15
  // 1. layout: 내용 장면 + 이음매를 kf.json 에 놓고 들릴 말을 captions.json 에(길이는 어림)
@@ -79,7 +80,8 @@ export function layout(dir: string, content: Content, o: { keepTiming?: boolean
79
80
  const seamSec = tplSeams[0]?.sec ?? 1.2;
80
81
  // 투영을 맞춰 구운 3D 샷(kf blender 가 kf.json 장면에 남긴 matched)은 다시 놓아도 잃지 않는다
81
82
  const matched = new Set<string>((kf.scenes ?? []).filter((s: KfScene) => s.matched).map((s: KfScene) => s.id));
82
- const spots = seamSpots(content.scenes.map((s) => s.type), content.scenes.map(spaceOf), tplSeams.length, content.scenes.map((s) => matched.has(s.id)));
83
+ // 테마가 이음매 전환을 하나도 허용하지 않으면(컷·덮기만) 이음매 장면을 안 넣는다
84
+ const spots = seamsAllowed(themeTransitions(kf.theme)) ? seamSpots(content.scenes.map((s) => s.type), content.scenes.map(spaceOf), tplSeams.length, content.scenes.map((s) => matched.has(s.id))) : [];
83
85
  const taken = new Set(content.scenes.map((s) => s.id));
84
86
  const seamIds: string[] = [];
85
87
  for (let k = 0; seamIds.length < spots.length; k++) {
@@ -131,6 +133,8 @@ export interface BuildOpts {
131
133
  only?: string[];
132
134
  /** 사람이 고른 것 — 장면 id → 패턴 id·장면 씨앗. director.json 의 pinned 에 남아 다음 kf direct 가 덮지 않는다 */
133
135
  pins?: Record<string, Pin>;
136
+ /** 이번 짓기에만 — 장면 id → 패턴 id. pins 와 달리 director.json 에 안 남는다(다시 짓기를 다 쓰고 감독 기본으로 박을 때) */
137
+ force?: Record<string, string>;
134
138
  /** 장면마다 진행 줄(「장면 <id>: 쓰는 중|씀|실패 <까닭>」 — 굽기·검사까지 끝난 「끝」은 작업실·render 몫) */
135
139
  log?: (line: string) => void;
136
140
  }
@@ -256,10 +260,14 @@ export async function build(dir: string, content: Content, o: BuildOpts = {}): P
256
260
  if (!r) throw new Error(`장면 ${s.id}: 남은 패턴 후보가 없다 — 떨어진 것 ${(failed[s.id] ?? []).join(", ")}`);
257
261
  picks[i] = r;
258
262
  }
259
- } else picks = selectAll(slots, entries, ctx, failed);
263
+ } else {
264
+ // 박을 장면은 떨어진 기록 없이 고른다 — 후보가 다 떨어졌으면 고르기가 「고를 패턴이 없다」로 멈춰 박기까지 못 간다
265
+ const left = o.force ? Object.fromEntries(Object.entries(failed).filter(([id]) => !(id in o.force!))) : failed;
266
+ picks = selectAll(slots, entries, ctx, left);
267
+ }
260
268
  // 사람이 고른 패턴이 이긴다 — 그 type 의 패턴이어야 한다
261
269
  for (const [i, s] of slots.entries()) {
262
- const want = pinned[s.id]?.pattern;
270
+ const want = o.force?.[s.id] ?? pinned[s.id]?.pattern;
263
271
  if (!want || picks[i].pattern === want) continue;
264
272
  const e = entryOf.get(want);
265
273
  if (!e || (!e.fallback && e.meta.type !== s.type)) throw new Error(`--pattern ${s.id}=${want}: ${e ? `${s.type} 장면에 ${e.meta.type} 패턴` : "없는 패턴"} — 후보는 director.json 의 order`);
@@ -274,9 +282,10 @@ export async function build(dir: string, content: Content, o: BuildOpts = {}): P
274
282
  const b = spaceOf(scenes[i + 1] ?? {});
275
283
  return { at: i, bridge: a === b ? false : b === "3d" ? "up" : "down" };
276
284
  });
277
- const kinds = seamKinds(spots, key, tpl.scenes.find((s) => s.role === "seam")?.transition ?? "dive", Object.fromEntries(seamIdx.map((i, k) => [k, seamFailed[scenes[i].id] ?? []])));
285
+ const allow = themeTransitions(theme);
286
+ const kinds = seamKinds(spots, key, tpl.scenes.find((s) => s.role === "seam")?.transition ?? "dive", Object.fromEntries(seamIdx.map((i, k) => [k, seamFailed[scenes[i].id] ?? []])), allow);
278
287
 
279
- const dress = dressAll(key, scenes, tpl.moves, world);
288
+ const dress = dressAll(key, scenes, tpl.moves, world, allow);
280
289
  const scenesDir = join(dir, "scenes");
281
290
  mkdirSync(scenesDir, { recursive: true });
282
291
  const report: SceneReport[] = [];