@ossclip/core 0.1.34 → 0.1.35
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/assets/sfx/ATTRIBUTION.md +29 -0
- package/assets/sfx/boom-dramatic.mp3 +0 -0
- package/assets/sfx/click.mp3 +0 -0
- package/assets/sfx/ding.mp3 +0 -0
- package/assets/sfx/error-buzz.mp3 +0 -0
- package/assets/sfx/pack.json +16 -0
- package/assets/sfx/pop.mp3 +0 -0
- package/assets/sfx/riser-short.mp3 +0 -0
- package/assets/sfx/scratch.mp3 +0 -0
- package/assets/sfx/swoosh-exit.mp3 +0 -0
- package/assets/sfx/synthesize.sh +19 -0
- package/assets/sfx/tape-stop.mp3 +0 -0
- package/assets/sfx/whoosh-fast.mp3 +0 -0
- package/assets/sfx/whoosh-soft.mp3 +0 -0
- package/package.json +1 -1
- package/src/assemble.ts +198 -0
- package/src/browser.ts +7 -0
- package/src/config.ts +44 -0
- package/src/index.ts +1 -0
- package/src/overrides.ts +248 -1
- package/src/producer/index.ts +1 -0
- package/src/producer/mock.ts +34 -1
- package/src/producer/scene-props.ts +20 -2
- package/src/producer/sfx.ts +465 -0
- package/src/schema.ts +49 -0
- package/src/sfx-pack.ts +281 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import type { Transcript } from "../schema";
|
|
3
|
+
import { SFX_MEME_TAG, type LoadedSfxSound } from "../sfx-pack";
|
|
4
|
+
import { cappedText, type BeatSheet } from "./beats";
|
|
5
|
+
// The scene-id formula, imported rather than restated: the ids this prompt
|
|
6
|
+
// offers must be the ones `generateScenes` actually mints (see
|
|
7
|
+
// `momentSceneId`).
|
|
8
|
+
import { momentSceneId } from "./scene-props";
|
|
9
|
+
import type { LlmProvider } from "./provider";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Call 3 — sound-effect placement (SFX plan, Approach A): a SEPARATE call
|
|
13
|
+
* after the beat sheet, with the graphics plan in context, so a whoosh can
|
|
14
|
+
* land on a graphic entrance. Mirrors beats.ts on purpose — schema, prompt
|
|
15
|
+
* builder, deterministic normalize pass, generate wrapper — because the
|
|
16
|
+
* deterministic pass, not the model, is what makes the output shippable.
|
|
17
|
+
*
|
|
18
|
+
* Nothing in this module touches the filesystem: the library arrives already
|
|
19
|
+
* loaded (`loadSfxLibrary`, the only fs toucher), so the prompt/normalize
|
|
20
|
+
* matrix is testable with a hand-written array of sounds.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const SfxLevelSchema = z.enum(["subtle", "normal", "meme"]);
|
|
24
|
+
export type SfxLevel = z.infer<typeof SfxLevelSchema>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One sound at one instant. A single `word` anchor and no `endWord`: an SFX
|
|
28
|
+
* is an instant, not a span — it plays for its own duration wherever it is
|
|
29
|
+
* triggered, so a range would be a number the renderer has to ignore.
|
|
30
|
+
*/
|
|
31
|
+
export const SfxPlacementSchema = z.object({
|
|
32
|
+
soundId: z.string(),
|
|
33
|
+
word: z.number().int().nonnegative(),
|
|
34
|
+
/**
|
|
35
|
+
* The scene whose ENTRANCE this sound marks (field report, 2026-08-29).
|
|
36
|
+
*
|
|
37
|
+
* The failure it fixes: the model placed whooshes rationalised "as the
|
|
38
|
+
* TitleCard enters" but could only anchor them to WORDS, so the moment the
|
|
39
|
+
* user moved or trimmed that scene in the editor the graphic started
|
|
40
|
+
* somewhere else and the whoosh played over nothing. With the link stored,
|
|
41
|
+
* `resolveSfxCues` takes the scene's FINAL start — user timing overrides
|
|
42
|
+
* included — as the instant.
|
|
43
|
+
*
|
|
44
|
+
* `word` stays REQUIRED beside it, and that is the whole compatibility
|
|
45
|
+
* story: it is the speech-sync anchor for the sounds that have no scene,
|
|
46
|
+
* and the FALLBACK for the ones whose scene is deleted or re-planned away.
|
|
47
|
+
* A placement can therefore never be orphaned by a scene disappearing —
|
|
48
|
+
* `resolveSfxCues` reports "scene gone" and still fires it on its word.
|
|
49
|
+
* Optional, so every plan written before this field parses unchanged.
|
|
50
|
+
*/
|
|
51
|
+
sceneId: z.string().optional(),
|
|
52
|
+
/** Per-placement trim, multiplied by the sound's own gain at render time. */
|
|
53
|
+
gain: z.number().min(0).max(2).optional(),
|
|
54
|
+
rationale: cappedText(120).optional(),
|
|
55
|
+
});
|
|
56
|
+
export type SfxPlacement = z.infer<typeof SfxPlacementSchema>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 64 is a ceiling on the RESPONSE, not the policy: the density budget below
|
|
60
|
+
* is what actually decides how many survive. It exists so a runaway
|
|
61
|
+
* generation cannot hand the normalize pass thousands of entries.
|
|
62
|
+
*/
|
|
63
|
+
export const SfxPlanSchema = z.object({
|
|
64
|
+
placements: z.array(SfxPlacementSchema).max(64),
|
|
65
|
+
});
|
|
66
|
+
export type SfxPlan = z.infer<typeof SfxPlanSchema>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bump whenever `SFX_SYSTEM`/`buildSfxUserPrompt` change what they ask for —
|
|
70
|
+
* the placement cache key carries it, the same §78 posture as
|
|
71
|
+
* PRODUCER_PROMPT_VERSION and YOUTUBE_PROMPT_VERSION. An old cached plan must
|
|
72
|
+
* not survive a new prompt.
|
|
73
|
+
*/
|
|
74
|
+
export const SFX_PROMPT_VERSION = 2;
|
|
75
|
+
|
|
76
|
+
/** Placements per minute of runtime, by level. */
|
|
77
|
+
export const SFX_PER_MIN: Record<SfxLevel, number> = { subtle: 2, normal: 4, meme: 8 };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Floors, so a 20-second take is not silently sound-designed to zero. Same
|
|
81
|
+
* shape as the §29 short-take graphics floor: under a threshold, a COUNT
|
|
82
|
+
* beats a rate — a per-minute budget on a short take rounds to nothing, and
|
|
83
|
+
* short is exactly where the user asked for sound design.
|
|
84
|
+
*/
|
|
85
|
+
export const SFX_MIN_PLACEMENTS: Record<SfxLevel, number> = { subtle: 1, normal: 2, meme: 3 };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Two effects inside 1.5s read as one glitch rather than two beats, and the
|
|
89
|
+
* tails overlap for most of the starter pack (durations run 0.2–1.6s).
|
|
90
|
+
* Enforced here rather than asked for, because spacing is arithmetic.
|
|
91
|
+
*/
|
|
92
|
+
export const SFX_MIN_SPACING_SEC = 1.5;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Why a placement is not in the final plan.
|
|
96
|
+
*
|
|
97
|
+
* A zod enum rather than a bare TS union because these reasons come BACK from
|
|
98
|
+
* disk: produce caches the accounting beside the plan (`sfx-<key>.json`) so a
|
|
99
|
+
* cached re-run can print the same line, and a value read from a file is
|
|
100
|
+
* parsed, never coerced (CLAUDE.md). The last two are the RESOLVER's
|
|
101
|
+
* (`resolveSfxCues`), emitted long after planning — named here so
|
|
102
|
+
* `formatSfxAccounting`, shared by the console and report.txt, counts them
|
|
103
|
+
* alongside the planning drops:
|
|
104
|
+
* - "cut word": the anchor word was removed by the cut.
|
|
105
|
+
* - "missing file": the library still knows the sound, but its file is gone
|
|
106
|
+
* (a user pack deleted between planning and a re-render) — distinct from
|
|
107
|
+
* "unknown sound", which is an id the library no longer has at all.
|
|
108
|
+
*
|
|
109
|
+
* "scene gone" is the ODD ONE OUT and the name is kept honest below: it is an
|
|
110
|
+
* ISSUE, never a drop. A placement whose `sceneId` names no scene keeps its
|
|
111
|
+
* required `word` anchor and still fires — the sync intent is lost, the sound
|
|
112
|
+
* is not. It lives in this enum because it travels the same cached
|
|
113
|
+
* `SfxValidationIssue[]` to disk, and is deliberately absent from
|
|
114
|
+
* `DROP_REASONS` so `formatSfxAccounting` never counts it as a casualty.
|
|
115
|
+
*/
|
|
116
|
+
export const SfxDropReasonSchema = z.enum([
|
|
117
|
+
"unknown sound",
|
|
118
|
+
"meme level",
|
|
119
|
+
"outside transcript",
|
|
120
|
+
"too close",
|
|
121
|
+
"over budget",
|
|
122
|
+
"invalid",
|
|
123
|
+
"cut word",
|
|
124
|
+
"missing file",
|
|
125
|
+
"scene gone",
|
|
126
|
+
]);
|
|
127
|
+
export type SfxDropReason = z.infer<typeof SfxDropReasonSchema>;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Fixed print order, so the accounting line is stable run to run — and the
|
|
131
|
+
* BREAKDOWN vocabulary: a reason absent from this list costs no placement and
|
|
132
|
+
* so has no business in "N dropped: …". "scene gone" is the one such reason
|
|
133
|
+
* (see `SfxDropReasonSchema`); it still prints as its own warning line.
|
|
134
|
+
*/
|
|
135
|
+
const DROP_REASONS: SfxDropReason[] = [
|
|
136
|
+
"cut word",
|
|
137
|
+
"missing file",
|
|
138
|
+
"unknown sound",
|
|
139
|
+
"meme level",
|
|
140
|
+
"outside transcript",
|
|
141
|
+
"too close",
|
|
142
|
+
"over budget",
|
|
143
|
+
"invalid",
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
export const SfxValidationIssueSchema = z.object({
|
|
147
|
+
/** Index of the offending placement in the model's plan, or -1 plan-wide. */
|
|
148
|
+
placement: z.number().int(),
|
|
149
|
+
reason: SfxDropReasonSchema,
|
|
150
|
+
issue: z.string(),
|
|
151
|
+
});
|
|
152
|
+
export type SfxValidationIssue = z.infer<typeof SfxValidationIssueSchema>;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The sounds a level may use. Meme-tagged sounds are omitted from the menu
|
|
156
|
+
* entirely below `meme`, rather than asked-not-to-use: a sound the model
|
|
157
|
+
* cannot see is a sound it cannot pick, and `normalizeSfxPlan` re-checks
|
|
158
|
+
* anyway (a cached plan or a hallucinated id never passed through this menu).
|
|
159
|
+
*/
|
|
160
|
+
export function eligibleSfxSounds(
|
|
161
|
+
sounds: readonly LoadedSfxSound[],
|
|
162
|
+
level: SfxLevel,
|
|
163
|
+
): LoadedSfxSound[] {
|
|
164
|
+
if (level === "meme") return [...sounds];
|
|
165
|
+
return sounds.filter((s) => !s.tags.includes(SFX_MEME_TAG));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The scene ids a placement may anchor to: one per GRAPHIC moment, in the
|
|
170
|
+
* exact form `generateScenes` will mint (`momentSceneId`). Talking-head
|
|
171
|
+
* moments (`sceneKind === "none"`) become no scene at all, so an id for one
|
|
172
|
+
* would name nothing in production.json.
|
|
173
|
+
*
|
|
174
|
+
* ONE implementation, called by the prompt (which OFFERS the ids) and
|
|
175
|
+
* `normalizeSfxPlan` (which ENFORCES them) — `sfxBudget`'s rule: two copies
|
|
176
|
+
* is how the model gets shown a menu the deterministic pass then rejects.
|
|
177
|
+
*/
|
|
178
|
+
export function sfxSceneIds(sheet: BeatSheet): string[] {
|
|
179
|
+
return sheet.moments.flatMap((m, i) => (m.sceneKind === "none" ? [] : [momentSceneId(i)]));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Runtime in seconds, measured like beats.ts: first word start → last word end. */
|
|
183
|
+
function transcriptRuntime(transcript: Transcript): number {
|
|
184
|
+
const words = transcript.words;
|
|
185
|
+
if (words.length === 0) return 0;
|
|
186
|
+
return Math.max(0, words[words.length - 1]!.end - words[0]!.start);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* How many placements this take may carry. ONE implementation, called by both
|
|
191
|
+
* the prompt (which states the number) and the normalize pass (which enforces
|
|
192
|
+
* it) — two copies of this arithmetic is how the model gets asked for a
|
|
193
|
+
* budget the deterministic pass then contradicts (§154's two-copies lesson).
|
|
194
|
+
*/
|
|
195
|
+
export function sfxBudget(
|
|
196
|
+
transcript: Transcript,
|
|
197
|
+
level: SfxLevel,
|
|
198
|
+
): { max: number; runtimeSec: number } {
|
|
199
|
+
const runtimeSec = transcriptRuntime(transcript);
|
|
200
|
+
// The epsilon is beats.ts's float posture: a runtime that is 60s to the
|
|
201
|
+
// eye can be 59.999999s in the stamps, and the boundary case is exactly
|
|
202
|
+
// the one a user counts.
|
|
203
|
+
const byRate = Math.floor((SFX_PER_MIN[level] * runtimeSec) / 60 + 1e-6);
|
|
204
|
+
return { max: Math.max(SFX_MIN_PLACEMENTS[level], byRate), runtimeSec };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** How much sound design each level wants, in the model's own terms. */
|
|
208
|
+
const LEVEL_GUIDANCE: Record<SfxLevel, string> = {
|
|
209
|
+
subtle:
|
|
210
|
+
"SUBTLE: sound design you notice only if you look for it. Transitions and the single " +
|
|
211
|
+
"biggest payoff, nothing more. When in doubt, place nothing.",
|
|
212
|
+
normal:
|
|
213
|
+
"NORMAL: tasteful punctuation. Graphic entrances, list items landing, the key takeaway. " +
|
|
214
|
+
"Silence is still the default — an effect earns its place or it is noise.",
|
|
215
|
+
meme:
|
|
216
|
+
"MEME: comedic timing is allowed and meme sounds are on the menu. Still one effect per " +
|
|
217
|
+
"beat, never a stack — a meme sound lands because the moment before it was quiet.",
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const SFX_SYSTEM = `You are the sound designer for a short video that has already been cut and storyboarded. You receive the sound library you may use, the graphics plan, and a word-indexed transcript.
|
|
221
|
+
|
|
222
|
+
Your job is PLACEMENT, not authorship: choose sounds FROM THE LIBRARY and anchor each one to the transcript word it should fire on.
|
|
223
|
+
|
|
224
|
+
Rules:
|
|
225
|
+
- Only ids from the library below. Never invent a sound.
|
|
226
|
+
- One anchor word per placement. The effect fires at that word and plays for its own length.
|
|
227
|
+
- Effects punctuate; they do not score. Long stretches with no effect are correct.
|
|
228
|
+
- Never two effects on top of each other — leave at least ${SFX_MIN_SPACING_SEC} seconds of speech between placements.
|
|
229
|
+
- Sync with the graphics plan where it helps: a whoosh on the word a graphic enters on reads as one gesture.
|
|
230
|
+
- When a sound marks a GRAPHIC APPEARING, also set "sceneId" to that moment's scene id (shown in brackets beside it). The effect then FOLLOWS that graphic if it is later moved or trimmed. Omit "sceneId" for speech-synced sounds — a sound that lands on what is being said is not a scene's sound. The anchor word is required either way.
|
|
231
|
+
- Match the sound to what is being SAID, using its "when to use" line. A confirmation sound on a failure is worse than silence.
|
|
232
|
+
- Respect the placement budget stated in the prompt. Fewer, better-placed effects beat hitting the number.`;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The user half of the placement call.
|
|
236
|
+
*
|
|
237
|
+
* The transcript numbering is beats.ts's `[i]word` shape (beats.ts:198-200),
|
|
238
|
+
* restated rather than imported because the two prompts must agree on what a
|
|
239
|
+
* "word index" means — every downstream index in this pipeline is a position
|
|
240
|
+
* in THIS list.
|
|
241
|
+
*/
|
|
242
|
+
export function buildSfxUserPrompt(
|
|
243
|
+
transcript: Transcript,
|
|
244
|
+
sheet: BeatSheet,
|
|
245
|
+
sounds: readonly LoadedSfxSound[],
|
|
246
|
+
level: SfxLevel,
|
|
247
|
+
): string {
|
|
248
|
+
const eligible = eligibleSfxSounds(sounds, level);
|
|
249
|
+
// The scene-registry menu shape (beats.ts:201-203): the library is a list of
|
|
250
|
+
// items with a whenToUse line, which is the format the producer prompt has
|
|
251
|
+
// already been tuned against.
|
|
252
|
+
const menu = eligible.map((s) => `- ${s.id}: ${s.whenToUse}`).join("\n");
|
|
253
|
+
const words = transcript.words.map((w, i) => `[${i}]${w.text}`).join(" ");
|
|
254
|
+
const { max, runtimeSec } = sfxBudget(transcript, level);
|
|
255
|
+
const moments = sheet.moments
|
|
256
|
+
.map(
|
|
257
|
+
(m, i) =>
|
|
258
|
+
`- words [${m.startWord}..${m.endWord}] ${m.sceneKind === "none" ? "talking head" : m.sceneKind}` +
|
|
259
|
+
// The scene id, shown only for GRAPHIC moments because only they mint
|
|
260
|
+
// a scene (`sfxSceneIds`): offering an id beside "talking head" is
|
|
261
|
+
// offering an id `normalizeSfxPlan` would strip straight back off.
|
|
262
|
+
(m.sceneKind === "none" ? "" : ` [${momentSceneId(i)}]: "${m.onScreenCopy}"`) +
|
|
263
|
+
` — ${m.purpose}`,
|
|
264
|
+
)
|
|
265
|
+
.join("\n");
|
|
266
|
+
return (
|
|
267
|
+
`Level: ${level}\n${LEVEL_GUIDANCE[level]}\n\n` +
|
|
268
|
+
`Runtime: ${runtimeSec.toFixed(1)}s. Place AT MOST ${max} sound effects.\n\n` +
|
|
269
|
+
`Sound library (use these ids and nothing else):\n${menu}\n\n` +
|
|
270
|
+
// The graphics plan sits above the transcript for the same reason the
|
|
271
|
+
// framing brief does in beats.ts: the constraint is read before the
|
|
272
|
+
// content it constrains.
|
|
273
|
+
`Graphics plan (hook: "${sheet.hook}") — a graphic ENTERS at its first word, ` +
|
|
274
|
+
`and [scene-N] is the id to put in "sceneId" when a sound marks that entrance:\n${moments}\n\n` +
|
|
275
|
+
`Word-indexed transcript (word indices refer to THIS list):\n${words}`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Everything between the model and the render. Passes run in a fixed order,
|
|
281
|
+
* each dropping rather than repairing, because a placement is one sound at
|
|
282
|
+
* one word: there is nothing to salvage in a wrong one, unlike a beat-sheet
|
|
283
|
+
* moment whose end can be clamped back into range.
|
|
284
|
+
*
|
|
285
|
+
* Order matters: identity first (unknown id, meme gate), then position, then
|
|
286
|
+
* the relational passes (spacing, budget) which only make sense once the
|
|
287
|
+
* survivors are known and sorted.
|
|
288
|
+
*
|
|
289
|
+
* `sheet` is here for ONE pass — the scene link (`sceneId`) is checked against
|
|
290
|
+
* the ids the prompt actually offered (`sfxSceneIds`). Required rather than
|
|
291
|
+
* optional because every caller has the sheet in hand: an optional "no scene
|
|
292
|
+
* context" mode would silently pass hallucinated ids straight through to the
|
|
293
|
+
* resolver, which is the drift this gate exists to stop.
|
|
294
|
+
*/
|
|
295
|
+
export function normalizeSfxPlan(
|
|
296
|
+
plan: SfxPlan,
|
|
297
|
+
transcript: Transcript,
|
|
298
|
+
sounds: readonly LoadedSfxSound[],
|
|
299
|
+
level: SfxLevel,
|
|
300
|
+
sheet: BeatSheet,
|
|
301
|
+
): { plan: SfxPlan; issues: SfxValidationIssue[] } {
|
|
302
|
+
const issues: SfxValidationIssue[] = [];
|
|
303
|
+
const byId = new Map(sounds.map((s) => [s.id, s]));
|
|
304
|
+
// The ids the prompt offered, so an id the model invented (or one left over
|
|
305
|
+
// from a sheet that has since changed) is caught here rather than reaching
|
|
306
|
+
// the resolver as a scene that was never planned.
|
|
307
|
+
const knownScenes = new Set(sfxSceneIds(sheet));
|
|
308
|
+
const maxIndex = transcript.words.length - 1;
|
|
309
|
+
const drop = (placement: number, reason: SfxDropReason, issue: string): void => {
|
|
310
|
+
issues.push({ placement, reason, issue });
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Carries the model's own index so every issue names the placement the
|
|
314
|
+
// model wrote, not a position in some intermediate array.
|
|
315
|
+
let kept: Array<{ index: number; placement: SfxPlacement }> = [];
|
|
316
|
+
for (let i = 0; i < plan.placements.length; i++) {
|
|
317
|
+
const p = plan.placements[i]!;
|
|
318
|
+
const sound = byId.get(p.soundId);
|
|
319
|
+
if (!sound) {
|
|
320
|
+
drop(i, "unknown sound", `unknown soundId "${p.soundId}"`);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
// Belt and braces over the menu gate in `buildSfxUserPrompt`: a plan
|
|
324
|
+
// restored from cache was built against a different level, and a model
|
|
325
|
+
// can name a sound it was never shown.
|
|
326
|
+
if (level !== "meme" && sound.tags.includes(SFX_MEME_TAG)) {
|
|
327
|
+
drop(i, "meme level", `"${p.soundId}" is meme-tagged; level is ${level}`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// beats.ts:394's posture for the START anchor: out of range is a DROP,
|
|
331
|
+
// never a clamp. beats clamps `endWord` because the moment survives with
|
|
332
|
+
// a shorter span; an SFX anchor IS the placement, so clamping it would
|
|
333
|
+
// fire a sound on a word nobody chose.
|
|
334
|
+
if (!Number.isInteger(p.word) || p.word < 0 || p.word > maxIndex) {
|
|
335
|
+
drop(i, "outside transcript", `word ${p.word} beyond transcript (${Math.max(0, maxIndex)})`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
// The scene link, checked LAST because it is the only thing here that is
|
|
339
|
+
// not a drop: a `sceneId` naming no graphic moment is STRIPPED and the
|
|
340
|
+
// placement kept on its word. The word anchor is required precisely so a
|
|
341
|
+
// broken link costs the sync intent rather than the sound — the same
|
|
342
|
+
// fallback `resolveSfxCues` takes when a scene is gone by render time,
|
|
343
|
+
// and the reason this reason is not in `DROP_REASONS`.
|
|
344
|
+
let placement = p;
|
|
345
|
+
if (p.sceneId !== undefined && !knownScenes.has(p.sceneId)) {
|
|
346
|
+
const { sceneId: _unknown, ...withoutScene } = p;
|
|
347
|
+
placement = withoutScene;
|
|
348
|
+
issues.push({
|
|
349
|
+
placement: i,
|
|
350
|
+
reason: "scene gone",
|
|
351
|
+
issue:
|
|
352
|
+
`"${p.soundId}" names scene ${p.sceneId}, which this beat sheet has no graphic for — ` +
|
|
353
|
+
`keeping it on word ${p.word}`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
kept.push({ index: i, placement });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Stable sort by anchor: two placements on the same word keep the model's
|
|
360
|
+
// order, so the spacing pass drops the LATER-written one deterministically.
|
|
361
|
+
kept.sort((a, b) => a.placement.word - b.placement.word || a.index - b.index);
|
|
362
|
+
|
|
363
|
+
const at = (p: SfxPlacement): number => transcript.words[p.word]!.start;
|
|
364
|
+
const spaced: typeof kept = [];
|
|
365
|
+
for (const entry of kept) {
|
|
366
|
+
const prev = spaced[spaced.length - 1];
|
|
367
|
+
if (prev && at(entry.placement) - at(prev.placement) < SFX_MIN_SPACING_SEC - 1e-6) {
|
|
368
|
+
drop(
|
|
369
|
+
entry.index,
|
|
370
|
+
"too close",
|
|
371
|
+
`"${entry.placement.soundId}" at word ${entry.placement.word} is ` +
|
|
372
|
+
`${(at(entry.placement) - at(prev.placement)).toFixed(2)}s after "${prev.placement.soundId}" ` +
|
|
373
|
+
`(min ${SFX_MIN_SPACING_SEC}s)`,
|
|
374
|
+
);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
spaced.push(entry);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Keep the EARLIEST N. Not "the best N": nothing here can rank placements,
|
|
381
|
+
// and dropping from the front would strip the hook — the one moment the
|
|
382
|
+
// whole grammar says must land.
|
|
383
|
+
const { max } = sfxBudget(transcript, level);
|
|
384
|
+
for (const over of spaced.slice(max)) {
|
|
385
|
+
drop(
|
|
386
|
+
over.index,
|
|
387
|
+
"over budget",
|
|
388
|
+
`"${over.placement.soundId}" at word ${over.placement.word} over the ${max} placement budget (level ${level})`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
kept = spaced.slice(0, max);
|
|
392
|
+
|
|
393
|
+
// Last gate, fail-soft per entry (the scene-props batch posture): callers
|
|
394
|
+
// hand us plans reloaded from a cache file as well as fresh model output,
|
|
395
|
+
// and one malformed entry must cost that entry only. It also applies the
|
|
396
|
+
// schema's defaults to whatever survives.
|
|
397
|
+
const placements: SfxPlacement[] = [];
|
|
398
|
+
for (const entry of kept) {
|
|
399
|
+
const parsed = SfxPlacementSchema.safeParse(entry.placement);
|
|
400
|
+
if (!parsed.success) {
|
|
401
|
+
drop(entry.index, "invalid", `placement failed validation: ${parsed.error.message}`);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
placements.push(parsed.data);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { plan: { placements }, issues };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The one-line SFX accounting, shared by the console and report.txt so the
|
|
412
|
+
* two can never say different things about the same run (the
|
|
413
|
+
* `formatGraphicsAccounting` contract, §118b).
|
|
414
|
+
*
|
|
415
|
+
* `issues` may include the resolver's "cut word" drops, which happen after
|
|
416
|
+
* planning — this formatter is the only place the two sets are counted
|
|
417
|
+
* together.
|
|
418
|
+
*/
|
|
419
|
+
export function formatSfxAccounting(
|
|
420
|
+
placed: number,
|
|
421
|
+
planned: number,
|
|
422
|
+
level: SfxLevel,
|
|
423
|
+
issues: readonly SfxValidationIssue[],
|
|
424
|
+
): string {
|
|
425
|
+
const counts = new Map<SfxDropReason, number>();
|
|
426
|
+
for (const i of issues) counts.set(i.reason, (counts.get(i.reason) ?? 0) + 1);
|
|
427
|
+
const breakdown = DROP_REASONS.filter((r) => counts.has(r))
|
|
428
|
+
.map((r) => `${counts.get(r)} ${r}`)
|
|
429
|
+
.join(", ");
|
|
430
|
+
const dropped = Math.max(0, planned - placed);
|
|
431
|
+
return (
|
|
432
|
+
`sfx: ${placed} of ${planned} planned placed (level ${level}` +
|
|
433
|
+
(dropped > 0 ? `, ${dropped} dropped${breakdown ? `: ${breakdown}` : ""}` : "") +
|
|
434
|
+
")"
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The placement call. `planned` is the count the MODEL returned, before the
|
|
440
|
+
* deterministic passes — what "of N planned" means in the accounting line.
|
|
441
|
+
*/
|
|
442
|
+
export async function generateSfxPlan(
|
|
443
|
+
provider: LlmProvider,
|
|
444
|
+
transcript: Transcript,
|
|
445
|
+
sheet: BeatSheet,
|
|
446
|
+
sounds: readonly LoadedSfxSound[],
|
|
447
|
+
level: SfxLevel,
|
|
448
|
+
): Promise<{ plan: SfxPlan; issues: SfxValidationIssue[]; planned: number }> {
|
|
449
|
+
const raw = await provider.complete({
|
|
450
|
+
system: SFX_SYSTEM,
|
|
451
|
+
user: buildSfxUserPrompt(transcript, sheet, sounds, level),
|
|
452
|
+
schema: SfxPlanSchema,
|
|
453
|
+
schemaName: "sfx_plan",
|
|
454
|
+
// Mechanical, not editorial (the §37 tiering rule): the editorial
|
|
455
|
+
// judgement — what this video is about, where its beats are — was already
|
|
456
|
+
// bought by the beat sheet, and this call picks from a fixed menu against
|
|
457
|
+
// it. `normalizeSfxPlan` is the real gate on the answer, so a small model
|
|
458
|
+
// getting it wrong costs a dropped placement, not a bad video.
|
|
459
|
+
tier: "mechanical",
|
|
460
|
+
});
|
|
461
|
+
return {
|
|
462
|
+
...normalizeSfxPlan(raw, transcript, sounds, level, sheet),
|
|
463
|
+
planned: raw.placements.length,
|
|
464
|
+
};
|
|
465
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -99,6 +99,43 @@ export type RenderSettings = z.infer<typeof RenderSettingsSchema>;
|
|
|
99
99
|
|
|
100
100
|
import { SceneSchema, ThemeSchema } from "./scene-schema";
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The sound-effect plan as `production.json` STORES it: the level it was
|
|
104
|
+
* planned at plus the surviving placements.
|
|
105
|
+
*
|
|
106
|
+
* Restated here rather than imported from `producer/sfx.ts` on purpose, and
|
|
107
|
+
* the duplication is load-bearing: this module is in the EDITOR's runtime
|
|
108
|
+
* graph (browser.ts → overrides.ts → `RemovalReasonSchema`), while
|
|
109
|
+
* `producer/sfx.ts` reaches `beats.ts` → `cover.ts` → `node:child_process`.
|
|
110
|
+
* Importing the model-facing schema here would drag the whole producer — and
|
|
111
|
+
* node — into the Remotion/editor bundle. The two shapes are pinned equal by
|
|
112
|
+
* a test (`sfx-production-slot.test.ts`), which is what keeps this from
|
|
113
|
+
* becoming a silent second truth: the model-facing one caps `rationale` on the
|
|
114
|
+
* way IN (R27 §123's `cappedText`), and by the time a placement is stored the
|
|
115
|
+
* cap has already been applied.
|
|
116
|
+
*/
|
|
117
|
+
export const ProductionSfxSchema = z.object({
|
|
118
|
+
level: z.enum(["subtle", "normal", "meme"]),
|
|
119
|
+
placements: z.array(
|
|
120
|
+
z.object({
|
|
121
|
+
soundId: z.string(),
|
|
122
|
+
/** Index into the REPAIRED transcript — word indices, never seconds. */
|
|
123
|
+
word: z.number().int().nonnegative(),
|
|
124
|
+
/**
|
|
125
|
+
* The scene whose ENTRANCE this sound marks, when it has one
|
|
126
|
+
* (2026-08-29). Optional and absent-means-speech-synced, so every plan
|
|
127
|
+
* written before the field parses byte-identically. `word` above stays
|
|
128
|
+
* required beside it — it is the fallback when the scene is deleted or
|
|
129
|
+
* re-planned away (`SfxPlacementSchema` owns the full argument).
|
|
130
|
+
*/
|
|
131
|
+
sceneId: z.string().optional(),
|
|
132
|
+
gain: z.number().min(0).max(2).optional(),
|
|
133
|
+
rationale: z.string().optional(),
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
});
|
|
137
|
+
export type ProductionSfx = z.infer<typeof ProductionSfxSchema>;
|
|
138
|
+
|
|
102
139
|
/** The single source of truth for a production. Every pipeline stage is a pure function over this. */
|
|
103
140
|
export const ProductionSchema = z.object({
|
|
104
141
|
version: z.literal(1),
|
|
@@ -184,6 +221,18 @@ export const ProductionSchema = z.object({
|
|
|
184
221
|
})
|
|
185
222
|
.optional(),
|
|
186
223
|
scenes: z.array(SceneSchema).optional(),
|
|
224
|
+
/**
|
|
225
|
+
* The `--sfx` placement plan (level + word-anchored placements), post
|
|
226
|
+
* `normalizeSfxPlan`. Optional and absent-means-no-sound-design, so every
|
|
227
|
+
* pre-feature `production.json` parses unchanged — and so a run without the
|
|
228
|
+
* flag writes a file byte-identical to what it always wrote.
|
|
229
|
+
*
|
|
230
|
+
* WORDS, not seconds, like every other anchor in this file: the cutlist can
|
|
231
|
+
* change under a re-render, and a second-stamped placement would drift off
|
|
232
|
+
* the moment it does (the resolver re-derives output time through the
|
|
233
|
+
* TimeMap, `resolveSfxCues`).
|
|
234
|
+
*/
|
|
235
|
+
sfx: ProductionSfxSchema.optional(),
|
|
187
236
|
/**
|
|
188
237
|
* WHO planned this production (R16 §78). `usage.json` answers "what did
|
|
189
238
|
* that cost", but it describes one run and a fully-cached re-run makes no
|