@ossclip/core 0.1.34 → 0.1.36

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/src/ingest.ts CHANGED
@@ -190,11 +190,56 @@ export function mezzanineScale(
190
190
  * the legacy names so existing workdir caches stay valid; a scaled run
191
191
  * rebuilds once under its own name and old workdirs' render-props keep
192
192
  * referencing (and rendering from) the file they were emitted against.
193
+ *
194
+ * The LUT hash is in the name for the same reason: grading is baked into the
195
+ * mezzanine at build time, so a warm workdir keyed only on crop/scale would
196
+ * satisfy a graded run with UNGRADED frames (or a re-graded run with the old
197
+ * look). No LUT keeps today's names byte-for-byte, so existing warm workdirs
198
+ * stay valid.
193
199
  */
194
- export function mezzanineFileName(cropped: boolean, scale: MezzanineScale | null): string {
200
+ export function mezzanineFileName(cropped: boolean, scale: MezzanineScale | null, lutHash?: string): string {
195
201
  const base = cropped ? "mezzanine-content" : "mezzanine";
196
- if (!scale) return `${base}.mp4`;
197
- return `${base}-${scale.width}x${scale.height}@${Math.round(scale.fps)}.mp4`;
202
+ const scaleSeg = scale ? `-${scale.width}x${scale.height}@${Math.round(scale.fps)}` : "";
203
+ const lutSeg = lutHash ? `-lut${lutHash}` : "";
204
+ return `${base}${scaleSeg}${lutSeg}.mp4`;
205
+ }
206
+
207
+ /** A 3D LUT to bake into the mezzanine; `hash` keys the cache (see `mezzanineFileName`). */
208
+ export interface MezzanineLut {
209
+ path: string;
210
+ hash: string;
211
+ }
212
+
213
+ /**
214
+ * Escape a filesystem path for use as an ffmpeg filter option value.
215
+ *
216
+ * A `-vf` string is parsed twice: once as a filtergraph (where `\` `'` `[`
217
+ * `]` `,` `;` are special) and once as the filter's option value (where `:`
218
+ * `\` `'` are special — `:` is the option separator, so an unescaped drive
219
+ * letter like `C:` truncates the path there). Each level strips one layer of
220
+ * backslashes, so the option-level escapes must themselves be escaped for
221
+ * the graph level: `:` → `\\:`, `'` → `\\\'`, `\` → `\\\\`. Spaces need
222
+ * nothing — the argv goes straight to ffmpeg, no shell in between.
223
+ */
224
+ export function escapeFilterPath(p: string): string {
225
+ // Level 1: filter option value — `:` `\` `'` are special.
226
+ const option = p.replace(/[\\':]/g, (c) => `\\${c}`);
227
+ // Level 2: filtergraph — escape again so level-1 backslashes survive.
228
+ return option.replace(/[\\'[\],;]/g, (c) => `\\${c}`);
229
+ }
230
+
231
+ /**
232
+ * The mezzanine's `-vf` chain, pure so the ordering contract is testable:
233
+ * LUT strictly AFTER crop/scale — grading the letterbox bars would be
234
+ * wasted math, and grading pre-scale pixels the render never sees changes
235
+ * nothing but costs full-res per-pixel lookups.
236
+ */
237
+ export function mezzanineVf(opts: { cropVf?: string; scale?: MezzanineScale; lut?: MezzanineLut }): string {
238
+ return [
239
+ ...(opts.cropVf ? [opts.cropVf] : []),
240
+ ...(opts.scale ? [`scale=${opts.scale.width}:${opts.scale.height}`] : []),
241
+ ...(opts.lut ? [`lut3d=file=${escapeFilterPath(opts.lut.path)}:interp=tetrahedral`] : []),
242
+ ].join(",");
198
243
  }
199
244
 
200
245
  /**
@@ -206,17 +251,15 @@ export function mezzanineFileName(cropped: boolean, scale: MezzanineScale | null
206
251
  *
207
252
  * `scale` (from `mezzanineScale`) downsizes to display size in the SAME
208
253
  * pass, crop first — the scale dims are computed on the post-crop picture.
254
+ * `lut` bakes a 3D grade in last, on exactly the pixels the render will see.
209
255
  */
210
256
  export async function makeMezzanine(
211
257
  tools: IngestTools,
212
258
  src: string,
213
259
  out: string,
214
- opts: { cropVf?: string; scale?: MezzanineScale } = {},
260
+ opts: { cropVf?: string; scale?: MezzanineScale; lut?: MezzanineLut } = {},
215
261
  ): Promise<void> {
216
- const vf = [
217
- ...(opts.cropVf ? [opts.cropVf] : []),
218
- ...(opts.scale ? [`scale=${opts.scale.width}:${opts.scale.height}`] : []),
219
- ].join(",");
262
+ const vf = mezzanineVf(opts);
220
263
  await run(tools.ffmpegPath, [
221
264
  "-y", "-i", src,
222
265
  ...(vf ? ["-vf", vf] : []),
@@ -0,0 +1,81 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { basename, extname, join } from "node:path";
3
+ import { CONFIG_DIR } from "./config";
4
+ import { parseCubeLut } from "./color-grade";
5
+
6
+ /**
7
+ * The user's .cube LUT directory, discovered — the `sfx-pack.ts` shape for
8
+ * color grades. `parseCubeLut` stays pure; this module is the thin fs layer
9
+ * that walks `~/.ossclip/luts` and reports, per file, either a usable LUT or
10
+ * the reason it is not one. Nothing here throws: a hand-dropped .cube is user
11
+ * input, and a broken one must cost that one menu entry, not the editor.
12
+ */
13
+
14
+ /** Where user LUTs live: `~/.ossclip/luts/<name>.cube`. */
15
+ export function userLutDir(): string {
16
+ return join(CONFIG_DIR, "luts");
17
+ }
18
+
19
+ /** One LUT the menu can offer. `path` stays server-side, like SFX `absPath`. */
20
+ export interface LutLibraryItem {
21
+ /** The filename stem — what `ColorGrade.lut` (basename) resolves against. */
22
+ id: string;
23
+ /** The .cube's own TITLE when it has one, else the stem. */
24
+ title: string;
25
+ /** Absolute path — the caller's I/O concern, never sent to a client. */
26
+ path: string;
27
+ }
28
+
29
+ /** Why one file is not in the library — `SfxPackIssue`'s shape, per file. */
30
+ export interface LutLibraryIssue {
31
+ /** The .cube filename (basename) that failed. */
32
+ file: string;
33
+ message: string;
34
+ }
35
+
36
+ export interface LutLibrary {
37
+ items: LutLibraryItem[];
38
+ issues: LutLibraryIssue[];
39
+ }
40
+
41
+ /**
42
+ * Every parseable `.cube` under `dir`, plus an issue per file that is not one.
43
+ * Each file is fully parsed here — not just listed — because the menu is the
44
+ * ONLY surface where a broken LUT can be reported before a render silently
45
+ * drops it: `parseCubeLut` is strict about content on purpose, and offering a
46
+ * file the bake will refuse is the exact mismatch the SFX library gate exists
47
+ * to avoid.
48
+ *
49
+ * A missing directory is the normal case (most users never drop a LUT), not
50
+ * an issue. `dir` is a parameter with a default rather than a `homedir()`
51
+ * read inside, so tests point it at a tmp dir and never touch a real home
52
+ * (`loadSfxLibrary`'s rule).
53
+ */
54
+ export function loadLutLibrary(dir: string = userLutDir()): LutLibrary {
55
+ let names: string[] = [];
56
+ try {
57
+ names = readdirSync(dir, { withFileTypes: true })
58
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".cube"))
59
+ .map((e) => e.name)
60
+ // Sorted so the menu reads the same on every machine — readdir order is
61
+ // not a promise (loadSfxLibrary's merge-order rule).
62
+ .sort();
63
+ } catch {
64
+ return { items: [], issues: [] };
65
+ }
66
+ const items: LutLibraryItem[] = [];
67
+ const issues: LutLibraryIssue[] = [];
68
+ for (const name of names) {
69
+ const path = join(dir, name);
70
+ const stem = basename(name, extname(name));
71
+ try {
72
+ const lut = parseCubeLut(readFileSync(path, "utf8"));
73
+ // TITLE when the exporter wrote one — a human-readable label the stem
74
+ // (often `Vendor_Look_33pt_v2`) cannot match. Empty titles fall back.
75
+ items.push({ id: stem, title: lut.title?.trim() || stem, path });
76
+ } catch (e) {
77
+ issues.push({ file: name, message: e instanceof Error ? e.message : String(e) });
78
+ }
79
+ }
80
+ return { items, issues };
81
+ }
package/src/overrides.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod/v4";
2
+ import { ColorGradeSchema } from "./color-grade";
2
3
  import {
3
4
  LayoutSchema,
4
5
  SceneAnchorSchema,
@@ -9,7 +10,7 @@ import {
9
10
  type SceneComponentId,
10
11
  type Theme,
11
12
  } from "./scene-schema";
12
- import { RemovalReasonSchema } from "./schema";
13
+ import { RemovalReasonSchema, type ProductionSfx } from "./schema";
13
14
  import type { TimeMap } from "./timemap";
14
15
  import { resolveSceneProps } from "./scene-registry";
15
16
  import type { CaptionLine, CaptionWord } from "./captions";
@@ -142,6 +143,19 @@ export const SceneOverrideSchema = z.object({
142
143
  dx: z.number().optional(),
143
144
  /** `false` switches the automatic idle-zoom layer off for this scene. */
144
145
  autoZoom: z.boolean().optional(),
146
+ /**
147
+ * Audio gain for this window's footage, 1 = as recorded (field report
148
+ * 2026-08-31: one concatenated clip was recorded quieter than the
149
+ * rest). Lives inside `video` on purpose — it is a property of this
150
+ * window's playback, and the key already merges per scene, inherits
151
+ * across split halves, and has patch/clear plumbing. 0 mutes. Above 1
152
+ * amplifies — in the render via allowAmplificationDuringRender, and in
153
+ * the preview via the Player's own WebAudio gain (Remotion ≥4.0.5xx,
154
+ * use-amplification). Max 4: a field clip arrived quiet enough that 2x
155
+ * did not reach its neighbours (2026-08-31); past 4x you are boosting
156
+ * noise floor, not speech.
157
+ */
158
+ volume: z.number().min(0).max(4).optional(),
145
159
  })
146
160
  .optional(),
147
161
  /**
@@ -443,9 +457,108 @@ export function mintSplitId(at: number, existing: readonly Split[]): string {
443
457
  }
444
458
  }
445
459
 
460
+ /**
461
+ * The key an SFX edit is stored under: `${soundId}@${word}` of the PLANNED
462
+ * placement it edits.
463
+ *
464
+ * Content-derived, not positional, for §137's reason applied to a track of
465
+ * instants: an index into `production.json`'s `sfx.placements` is exactly the
466
+ * key a re-plan renumbers, and the placement a user muted would come back as
467
+ * the placement after it. The pair (which sound, which word) IS the identity
468
+ * of a placement — the plan cannot hold two of them (`normalizeSfxPlan`'s
469
+ * spacing pass drops the second effect within 1.5s, and two placements on one
470
+ * word are 0s apart) — so the key survives a re-plan whenever the placement
471
+ * itself does, and stales exactly when it does not.
472
+ *
473
+ * `@` is the separator the split-half namespace already uses (`splitCues`),
474
+ * and it is why an ADDED placement's id may not contain one (see
475
+ * `SfxAddedPlacementSchema`): the two namespaces share this record's
476
+ * vocabulary in the editor, and one spelling must never read as the other.
477
+ */
478
+ export function sfxPlacementKey(placement: { soundId: string; word: number }): string {
479
+ return `${placement.soundId}@${placement.word}`;
480
+ }
481
+
482
+ /**
483
+ * One edited planned placement. Every field is optional and ABSENT MEANS
484
+ * "as planned" — this is a patch over the plan, not a replacement for it, so
485
+ * a user who only dragged a marker stores `{word}` and still inherits a later
486
+ * re-plan's gain for that sound.
487
+ *
488
+ * `muted` NEGATES a planned placement rather than deleting the entry (the
489
+ * plan's own record is in production.json and produce rewrites it every run,
490
+ * so there is nothing to delete there): the placement drops out of the render
491
+ * and the editor still has something to show as a restorable ghost, the
492
+ * `SceneOverrideSchema.hidden` contract. Restore DELETES the key rather than
493
+ * writing `muted: false` — an override with nothing to say (the
494
+ * clearVideo/restoreScene rule).
495
+ *
496
+ * `gain` shares the sound library's own 0–2 range (`SfxSoundSchema.gain`), and
497
+ * the two multiply at resolve time (`resolveSfxCues` does the ONE
498
+ * multiplication).
499
+ */
500
+ export const SfxPlacementEditSchema = z.object({
501
+ /** Retimed to another transcript word — word indices, never seconds. */
502
+ word: z.number().int().nonnegative().optional(),
503
+ /** Swapped for another sound in the library. */
504
+ soundId: z.string().min(1).optional(),
505
+ gain: z.number().min(0).max(2).optional(),
506
+ muted: z.boolean().optional(),
507
+ });
508
+ export type SfxPlacementEdit = z.infer<typeof SfxPlacementEditSchema>;
509
+
510
+ /**
511
+ * A placement the USER added, which the model never planned.
512
+ *
513
+ * It carries its own `id` because it has no plan entry to be keyed against:
514
+ * `${soundId}@${word}` would re-key itself the moment the user dragged or
515
+ * swapped it, and an array index would renumber on every delete —
516
+ * `mintSplitId`'s reasoning, and the id is a persisted name for the same
517
+ * reason (it must be reproducible from the doc alone, so it is minted once and
518
+ * never recomputed).
519
+ *
520
+ * The pattern forbids `@` deliberately: `sfxPlacementKey` builds planned keys
521
+ * with it, and an added id that could spell one would let a stale-key report
522
+ * name something that is not a plan key at all — the kept-takes rule ("minting
523
+ * `@` names here would collide with the split-id namespace"). It also forbids
524
+ * `/`, `.` and everything else that could read as a path: this id is a NAME,
525
+ * and nothing may ever resolve a file against it.
526
+ *
527
+ * Uniqueness is the minter's job, not the schema's: nothing in this module
528
+ * indexes by the id (an added placement is a plain array entry here), so a
529
+ * duplicate costs the editor a confused selection, and REFUSING the document
530
+ * would cost the user their whole edit layer — produce throws on an invalid
531
+ * overrides.json by design.
532
+ */
533
+ export const SfxAddedPlacementSchema = z.object({
534
+ id: z.string().regex(/^[A-Za-z0-9_-]+$/),
535
+ soundId: z.string().min(1),
536
+ word: z.number().int().nonnegative(),
537
+ gain: z.number().min(0).max(2).optional(),
538
+ });
539
+ export type SfxAddedPlacement = z.infer<typeof SfxAddedPlacementSchema>;
540
+
446
541
  export const OverrideDocSchema = z.object({
447
542
  /** Global style tokens — the look is a system, so these are not per-element. */
448
543
  theme: ThemeSchema.partial().default({}),
544
+ /**
545
+ * Doc-global color grade — the `ColorGradeSchema` shape, or `false` for
546
+ * "explicitly no grade on this project". Doc-global like `theme`: a grade
547
+ * is one decision about the whole output, not a per-scene key. Optional
548
+ * with NO default (the `captionsHidden` rule) so every overrides.json
549
+ * written before the key existed parses byte-identically — but UNLIKE
550
+ * `captionsHidden`, an explicit `false` is meaningful and kept: it
551
+ * disables a config-level default grade for this one project, which
552
+ * deleting the key cannot express (absent means "let the flag, then the
553
+ * config, decide" — `resolveProductionColorGrade` in produce.ts owns that
554
+ * precedence). Schema-valid is not yet USABLE: an unknown preset id passes
555
+ * here (the schema cannot list what exists without going stale) and is
556
+ * caught by `resolveColorGrade` at the consumer, where it warns and falls
557
+ * through to the next layer instead of failing the whole doc. Timeless —
558
+ * no seconds anywhere — so `remapOverridesThroughRecut`'s `...doc` spreads
559
+ * carry it through a recut untouched.
560
+ */
561
+ colorGrade: z.union([ColorGradeSchema, z.literal(false)]).optional(),
449
562
  /**
450
563
  * Captions OFF for the whole video. Doc-global like `theme`, deliberately
451
564
  * NOT a per-scene key: visibility is one decision about the output —
@@ -729,6 +842,37 @@ export const OverrideDocSchema = z.object({
729
842
  .default([]),
730
843
  })
731
844
  .default({ reasons: {}, kept: [], dismissed: [] }),
845
+ /**
846
+ * The user's layer over the `--sfx` placement plan (2026-08-29): retime,
847
+ * swap, gain and mute on what the model planned, plus placements of the
848
+ * user's own. Applied by `applySfxOverrides` in produce, between loading the
849
+ * plan and resolving it to cues.
850
+ *
851
+ * ONE record, in overrides.json, and deliberately NOT a `sfx-reviewed.json`
852
+ * beside `scenes-reviewed.json`: scene review needed its own file because it
853
+ * predates the override doc, and SFX has this doc from day one. So there is
854
+ * exactly one write path (the editor's `PUT /api/overrides`) and exactly one
855
+ * thing a render replays.
856
+ *
857
+ * Optional with NO default, the `captionsHidden` rule: every overrides.json
858
+ * written before this key existed parses byte-identically, and a project
859
+ * that never touched a sound effect grows no key. Absent means "the plan, as
860
+ * planned".
861
+ *
862
+ * RECUT-IMMUNE BY CONSTRUCTION, so it deliberately has no entry in
863
+ * `remapOverridesThroughRecut` (the `cleanup.kept`/`captionLineWindows`
864
+ * property): every value in here is a WORD INDEX or an id, and
865
+ * `transcript.words` is never spliced — a re-cut moves output seconds, which
866
+ * this record does not hold. The output instant is re-derived through the
867
+ * new TimeMap on every run (`resolveSfxCues`).
868
+ */
869
+ sfx: z
870
+ .object({
871
+ /** Keyed by `sfxPlacementKey` — see `SfxPlacementEditSchema`. */
872
+ edits: z.record(z.string(), SfxPlacementEditSchema).default({}),
873
+ added: z.array(SfxAddedPlacementSchema).default([]),
874
+ })
875
+ .optional(),
732
876
  });
733
877
  export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
734
878
 
@@ -2602,3 +2746,138 @@ export function reclampPinnedTiming(cues: readonly SceneCue[]): ReclampResult {
2602
2746
  }
2603
2747
  return { cues: out, adjusted };
2604
2748
  }
2749
+
2750
+ /** A placement as `production.json` stores it — the editor-safe shape
2751
+ * (`ProductionSfxSchema`), never the producer's: this module is in the
2752
+ * EDITOR's runtime graph and `producer/sfx.ts` reaches node:child_process
2753
+ * (schema.ts's ProductionSfxSchema docstring has the whole argument). */
2754
+ export type SfxPlannedPlacement = ProductionSfx["placements"][number];
2755
+
2756
+ export interface AppliedSfxOverrides {
2757
+ /** The plan the resolver should place — edits applied, mutes removed, the
2758
+ * user's own placements appended. */
2759
+ placements: SfxPlannedPlacement[];
2760
+ /**
2761
+ * Edit keys that matched no planned placement. `"stale key"` is the re-plan
2762
+ * case: the placement the user edited is not in the plan any more, so their
2763
+ * work on it is lost and saying so is the whole point (the `was`-guard
2764
+ * posture — `applyCaptionEdits` reports rather than guessing at a nearby
2765
+ * word, and the field failure §137 pins was a DROP nobody printed).
2766
+ * `"duplicate key"` is a second placement answering to a key an earlier one
2767
+ * already claimed: the edit applied, to the first, and the later one is left
2768
+ * as planned rather than edited twice (`applyCaptionEdits`'
2769
+ * `duplicate-anchor` rule).
2770
+ *
2771
+ * A plain TS union rather than a zod enum, unlike `SfxDropReasonSchema`:
2772
+ * these reasons are computed here and printed, never written to a file and
2773
+ * read back, so there is no boundary for a parse to guard.
2774
+ */
2775
+ dropped: Array<{ key: string; reason: "stale key" | "duplicate key" }>;
2776
+ }
2777
+
2778
+ /**
2779
+ * The user's SFX layer over a placement plan (`OverrideDocSchema.sfx`).
2780
+ *
2781
+ * Applied in produce between loading the plan and `resolveSfxCues`, so the
2782
+ * resolver's word→output arithmetic, its cut-word drops and its gain product
2783
+ * all run over what the USER approved rather than what the model wrote. The
2784
+ * plan itself is never rewritten: `production.json` keeps the model's
2785
+ * placements, which is what the edit keys are derived from — folding the edits
2786
+ * into the stored plan would re-key every one of them on the next run and
2787
+ * stale the user's whole layer (the same reason `cuts[].startSec` is left as
2788
+ * the user drew it).
2789
+ *
2790
+ * Deliberately does NOT re-run the spacing or density passes
2791
+ * (`normalizeSfxPlan`): those price the MODEL's plan, and a user who drags two
2792
+ * effects together or adds a ninth to a `subtle` video has said what they want
2793
+ * — an explicit user action outranks a deterministic budget, exactly as a user
2794
+ * cut outranks a cleanup veto in produce. The resolver's own drops (unknown
2795
+ * sound, missing file, cut word) still apply, because those are about whether
2796
+ * the effect can be PLAYED at all.
2797
+ *
2798
+ * Pure: no filesystem, no library — an edit naming a sound that does not exist
2799
+ * is the resolver's "unknown sound", reported there with every other one.
2800
+ */
2801
+ export function applySfxOverrides(
2802
+ planned: readonly SfxPlannedPlacement[],
2803
+ sfx: OverrideDoc["sfx"],
2804
+ ): AppliedSfxOverrides {
2805
+ const dropped: AppliedSfxOverrides["dropped"] = [];
2806
+ if (sfx === undefined) return { placements: [...planned], dropped };
2807
+
2808
+ const claimed = new Set<string>();
2809
+ const placements: SfxPlannedPlacement[] = [];
2810
+ for (const p of planned) {
2811
+ const key = sfxPlacementKey(p);
2812
+ const edit = sfx.edits[key];
2813
+ if (edit === undefined) {
2814
+ placements.push(p);
2815
+ continue;
2816
+ }
2817
+ if (claimed.has(key)) {
2818
+ dropped.push({ key, reason: "duplicate key" });
2819
+ placements.push(p);
2820
+ continue;
2821
+ }
2822
+ claimed.add(key);
2823
+ // A mute keeps the ENTRY (the editor draws a restorable ghost from it) and
2824
+ // removes the PLACEMENT — `SceneOverrideSchema.hidden`'s contract for a
2825
+ // track of instants.
2826
+ if (edit.muted === true) continue;
2827
+ const retimed = edit.word !== undefined;
2828
+ const next: SfxPlannedPlacement = {
2829
+ ...p,
2830
+ ...(edit.soundId !== undefined ? { soundId: edit.soundId } : {}),
2831
+ ...(retimed ? { word: edit.word! } : {}),
2832
+ ...(edit.gain !== undefined ? { gain: edit.gain } : {}),
2833
+ };
2834
+ // An explicit retime BREAKS the scene link (2026-08-29). `sceneId` says
2835
+ // "fire when this graphic enters" — an intent the MODEL inferred — and a
2836
+ // user dragging the marker onto a word says "fire HERE". An explicit user
2837
+ // position outranks an inferred sync, the same doctrine that lets a user
2838
+ // cut outrank a cleanup veto and a user's own placement outrank the
2839
+ // density budget above. Without this the marker would snap back to the
2840
+ // graphic on the next render and the drag would look like it never
2841
+ // happened.
2842
+ //
2843
+ // It is also why `sfxPlacementKey` stays `${soundId}@${word}` and never
2844
+ // takes `sceneId` into it: the key has to survive the link being cut, and
2845
+ // a re-plan that keeps the sound and the word keeps the edit regardless of
2846
+ // whether it re-linked the scene.
2847
+ if (retimed) delete next.sceneId;
2848
+ placements.push(next);
2849
+ }
2850
+ for (const key of Object.keys(sfx.edits)) {
2851
+ if (!claimed.has(key)) dropped.push({ key, reason: "stale key" });
2852
+ }
2853
+
2854
+ // The user's own placements, appended as plain placements: past this
2855
+ // function an added effect is indistinguishable from a planned one, which is
2856
+ // what makes the resolver, the stager and the accounting need no idea the
2857
+ // override layer exists. The `id` does not travel — it names the doc entry
2858
+ // the editor edits, and nothing downstream addresses a placement by name.
2859
+ //
2860
+ // No `sceneId` either, and `SfxAddedPlacementSchema` has no field for one:
2861
+ // the user chose this word themselves, which is the same explicit-position
2862
+ // fact the retime above cuts the link for.
2863
+ for (const add of sfx.added) {
2864
+ placements.push({
2865
+ soundId: add.soundId,
2866
+ word: add.word,
2867
+ ...(add.gain !== undefined ? { gain: add.gain } : {}),
2868
+ });
2869
+ }
2870
+
2871
+ // Word order, stable: a retimed or added placement would otherwise sit where
2872
+ // the model happened to put it, and the plan handed to the resolver is also
2873
+ // what the accounting indexes into (`SfxValidationIssue.placement`) — a
2874
+ // human reading a warning against production.json should meet the same order
2875
+ // `normalizeSfxPlan` left the plan in.
2876
+ return {
2877
+ placements: placements
2878
+ .map((p, i) => ({ p, i }))
2879
+ .sort((a, b) => a.p.word - b.p.word || a.i - b.i)
2880
+ .map((e) => e.p),
2881
+ dropped,
2882
+ };
2883
+ }
@@ -27,6 +27,7 @@ export * from "./provider";
27
27
  export * from "./usage";
28
28
  export * from "./beats";
29
29
  export * from "./youtube";
30
+ export * from "./sfx";
30
31
  export * from "./caption-regen";
31
32
  export * from "./scene-props";
32
33
  export * from "./repair";
@@ -26,7 +26,9 @@ export class MockProvider implements LlmProvider {
26
26
  ? // A deterministic no-op: the offline path must exercise the repair
27
27
  // call without inventing corrections a real provider would justify.
28
28
  req.schema.parse({ repairs: [] })
29
- : this.sceneProps(req.user, req.schema, req.schemaName);
29
+ : req.schemaName === "sfx_plan"
30
+ ? this.sfxPlan(req.user, req.schema)
31
+ : this.sceneProps(req.user, req.schema, req.schemaName);
30
32
  // Estimated, and costing exactly nothing — but recorded, because the
31
33
  // offline path is first-class and "how big are the prompts this pipeline
32
34
  // sends" is worth answering without spending anything to find out.
@@ -77,6 +79,37 @@ export class MockProvider implements LlmProvider {
77
79
  return schema.parse(sheet);
78
80
  }
79
81
 
82
+ /**
83
+ * A scripted SFX plan (`--sfx`): the budget the prompt states, spread evenly
84
+ * across the take, cycling through the menu the prompt offered.
85
+ *
86
+ * Reads the MENU rather than naming sounds, so the fixture pipeline keeps
87
+ * working when the starter pack changes and so a `--sfx-level` below `meme`
88
+ * can never be handed a meme sound the menu withheld. Evenly spread because
89
+ * the deterministic gate is the point of the offline path: bunched
90
+ * placements would be eaten by the 1.5s spacing pass and the fixture would
91
+ * assert whatever survived rather than what was planned.
92
+ */
93
+ private sfxPlan<T>(user: string, schema: z.ZodType<T>): T {
94
+ // The `- <id>: <whenToUse>` menu lines only — the graphics plan's bullets
95
+ // read `- words [3..7] …`, which has no `<slug>:` head.
96
+ const ids = [...user.matchAll(/^- ([a-z0-9-]+): /gm)].map((m) => m[1]!);
97
+ const wordCount = (user.match(/\[\d+\]/g) ?? []).length;
98
+ const max = Number.parseInt(/Place AT MOST (\d+) sound effects/.exec(user)?.[1] ?? "0", 10);
99
+ const n = Math.min(Number.isFinite(max) ? max : 0, ids.length > 0 ? wordCount : 0);
100
+ const placements = [];
101
+ for (let k = 0; k < n; k++) {
102
+ placements.push({
103
+ soundId: ids[k % ids.length]!,
104
+ // Interior anchors (k+1 of n+1): word 0 is the hook's first syllable,
105
+ // and an effect on it fires before the viewer has heard anything.
106
+ word: Math.min(wordCount - 1, Math.floor(((k + 1) * wordCount) / (n + 1))),
107
+ rationale: `mock: evenly spaced placement ${k + 1} of ${n}`,
108
+ });
109
+ }
110
+ return schema.parse({ placements });
111
+ }
112
+
80
113
  private sceneProps<T>(_user: string, schema: z.ZodType<T>, schemaName: string): T {
81
114
  const canned: Record<string, unknown> = {
82
115
  TitleCard_props: { eyebrow: "MOCK", title: "THE RAW TAKE", emphasis: "861%", sub: "becomes a clean edit" },
@@ -11,6 +11,24 @@ import {
11
11
  type FramingContext,
12
12
  } from "../framing";
13
13
 
14
+ /**
15
+ * The id `generateScenes` mints for the scene a moment becomes.
16
+ *
17
+ * Exported because a SECOND caller depends on the formula now (2026-08-29):
18
+ * the SFX placement prompt offers these ids to the model so a whoosh can
19
+ * anchor to a graphic's ENTRANCE (`buildSfxUserPrompt`), and
20
+ * `normalizeSfxPlan` checks the ids that come back against them. Two copies
21
+ * of `scene-${i}` is exactly §154's two-copies failure — the prompt would
22
+ * offer ids the plan never mints, and every scene link would strip on
23
+ * arrival.
24
+ *
25
+ * The index is the MOMENT's, not a running scene counter: talking-head
26
+ * moments mint no scene, so scene ids are sparse by design.
27
+ */
28
+ export function momentSceneId(momentIndex: number): string {
29
+ return `scene-${momentIndex}`;
30
+ }
31
+
14
32
  export interface ScenePropsFailure {
15
33
  momentIndex: number;
16
34
  component: SceneComponentId;
@@ -186,7 +204,7 @@ export async function generateScenes(
186
204
  const fallbackTitle = moment.onScreenCopy.slice(0, 48) || "—";
187
205
  failures.push({ momentIndex: i, component, error: lastError, fellBackTo: "TitleCard" });
188
206
  scenes.push({
189
- id: `scene-${i}`,
207
+ id: momentSceneId(i),
190
208
  anchor: { startWord: moment.startWord, endWord: moment.endWord },
191
209
  layout: SCENE_REGISTRY.TitleCard.defaultLayout,
192
210
  component: "TitleCard",
@@ -198,7 +216,7 @@ export async function generateScenes(
198
216
  }
199
217
 
200
218
  scenes.push({
201
- id: `scene-${i}`,
219
+ id: momentSceneId(i),
202
220
  anchor: { startWord: moment.startWord, endWord: moment.endWord },
203
221
  layout,
204
222
  component,