@ossclip/core 0.1.33 → 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/src/overrides.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  type SceneComponentId,
10
10
  type Theme,
11
11
  } from "./scene-schema";
12
- import { RemovalReasonSchema } from "./schema";
12
+ import { RemovalReasonSchema, type ProductionSfx } from "./schema";
13
13
  import type { TimeMap } from "./timemap";
14
14
  import { resolveSceneProps } from "./scene-registry";
15
15
  import type { CaptionLine, CaptionWord } from "./captions";
@@ -443,6 +443,87 @@ export function mintSplitId(at: number, existing: readonly Split[]): string {
443
443
  }
444
444
  }
445
445
 
446
+ /**
447
+ * The key an SFX edit is stored under: `${soundId}@${word}` of the PLANNED
448
+ * placement it edits.
449
+ *
450
+ * Content-derived, not positional, for §137's reason applied to a track of
451
+ * instants: an index into `production.json`'s `sfx.placements` is exactly the
452
+ * key a re-plan renumbers, and the placement a user muted would come back as
453
+ * the placement after it. The pair (which sound, which word) IS the identity
454
+ * of a placement — the plan cannot hold two of them (`normalizeSfxPlan`'s
455
+ * spacing pass drops the second effect within 1.5s, and two placements on one
456
+ * word are 0s apart) — so the key survives a re-plan whenever the placement
457
+ * itself does, and stales exactly when it does not.
458
+ *
459
+ * `@` is the separator the split-half namespace already uses (`splitCues`),
460
+ * and it is why an ADDED placement's id may not contain one (see
461
+ * `SfxAddedPlacementSchema`): the two namespaces share this record's
462
+ * vocabulary in the editor, and one spelling must never read as the other.
463
+ */
464
+ export function sfxPlacementKey(placement: { soundId: string; word: number }): string {
465
+ return `${placement.soundId}@${placement.word}`;
466
+ }
467
+
468
+ /**
469
+ * One edited planned placement. Every field is optional and ABSENT MEANS
470
+ * "as planned" — this is a patch over the plan, not a replacement for it, so
471
+ * a user who only dragged a marker stores `{word}` and still inherits a later
472
+ * re-plan's gain for that sound.
473
+ *
474
+ * `muted` NEGATES a planned placement rather than deleting the entry (the
475
+ * plan's own record is in production.json and produce rewrites it every run,
476
+ * so there is nothing to delete there): the placement drops out of the render
477
+ * and the editor still has something to show as a restorable ghost, the
478
+ * `SceneOverrideSchema.hidden` contract. Restore DELETES the key rather than
479
+ * writing `muted: false` — an override with nothing to say (the
480
+ * clearVideo/restoreScene rule).
481
+ *
482
+ * `gain` shares the sound library's own 0–2 range (`SfxSoundSchema.gain`), and
483
+ * the two multiply at resolve time (`resolveSfxCues` does the ONE
484
+ * multiplication).
485
+ */
486
+ export const SfxPlacementEditSchema = z.object({
487
+ /** Retimed to another transcript word — word indices, never seconds. */
488
+ word: z.number().int().nonnegative().optional(),
489
+ /** Swapped for another sound in the library. */
490
+ soundId: z.string().min(1).optional(),
491
+ gain: z.number().min(0).max(2).optional(),
492
+ muted: z.boolean().optional(),
493
+ });
494
+ export type SfxPlacementEdit = z.infer<typeof SfxPlacementEditSchema>;
495
+
496
+ /**
497
+ * A placement the USER added, which the model never planned.
498
+ *
499
+ * It carries its own `id` because it has no plan entry to be keyed against:
500
+ * `${soundId}@${word}` would re-key itself the moment the user dragged or
501
+ * swapped it, and an array index would renumber on every delete —
502
+ * `mintSplitId`'s reasoning, and the id is a persisted name for the same
503
+ * reason (it must be reproducible from the doc alone, so it is minted once and
504
+ * never recomputed).
505
+ *
506
+ * The pattern forbids `@` deliberately: `sfxPlacementKey` builds planned keys
507
+ * with it, and an added id that could spell one would let a stale-key report
508
+ * name something that is not a plan key at all — the kept-takes rule ("minting
509
+ * `@` names here would collide with the split-id namespace"). It also forbids
510
+ * `/`, `.` and everything else that could read as a path: this id is a NAME,
511
+ * and nothing may ever resolve a file against it.
512
+ *
513
+ * Uniqueness is the minter's job, not the schema's: nothing in this module
514
+ * indexes by the id (an added placement is a plain array entry here), so a
515
+ * duplicate costs the editor a confused selection, and REFUSING the document
516
+ * would cost the user their whole edit layer — produce throws on an invalid
517
+ * overrides.json by design.
518
+ */
519
+ export const SfxAddedPlacementSchema = z.object({
520
+ id: z.string().regex(/^[A-Za-z0-9_-]+$/),
521
+ soundId: z.string().min(1),
522
+ word: z.number().int().nonnegative(),
523
+ gain: z.number().min(0).max(2).optional(),
524
+ });
525
+ export type SfxAddedPlacement = z.infer<typeof SfxAddedPlacementSchema>;
526
+
446
527
  export const OverrideDocSchema = z.object({
447
528
  /** Global style tokens — the look is a system, so these are not per-element. */
448
529
  theme: ThemeSchema.partial().default({}),
@@ -729,6 +810,37 @@ export const OverrideDocSchema = z.object({
729
810
  .default([]),
730
811
  })
731
812
  .default({ reasons: {}, kept: [], dismissed: [] }),
813
+ /**
814
+ * The user's layer over the `--sfx` placement plan (2026-08-29): retime,
815
+ * swap, gain and mute on what the model planned, plus placements of the
816
+ * user's own. Applied by `applySfxOverrides` in produce, between loading the
817
+ * plan and resolving it to cues.
818
+ *
819
+ * ONE record, in overrides.json, and deliberately NOT a `sfx-reviewed.json`
820
+ * beside `scenes-reviewed.json`: scene review needed its own file because it
821
+ * predates the override doc, and SFX has this doc from day one. So there is
822
+ * exactly one write path (the editor's `PUT /api/overrides`) and exactly one
823
+ * thing a render replays.
824
+ *
825
+ * Optional with NO default, the `captionsHidden` rule: every overrides.json
826
+ * written before this key existed parses byte-identically, and a project
827
+ * that never touched a sound effect grows no key. Absent means "the plan, as
828
+ * planned".
829
+ *
830
+ * RECUT-IMMUNE BY CONSTRUCTION, so it deliberately has no entry in
831
+ * `remapOverridesThroughRecut` (the `cleanup.kept`/`captionLineWindows`
832
+ * property): every value in here is a WORD INDEX or an id, and
833
+ * `transcript.words` is never spliced — a re-cut moves output seconds, which
834
+ * this record does not hold. The output instant is re-derived through the
835
+ * new TimeMap on every run (`resolveSfxCues`).
836
+ */
837
+ sfx: z
838
+ .object({
839
+ /** Keyed by `sfxPlacementKey` — see `SfxPlacementEditSchema`. */
840
+ edits: z.record(z.string(), SfxPlacementEditSchema).default({}),
841
+ added: z.array(SfxAddedPlacementSchema).default([]),
842
+ })
843
+ .optional(),
732
844
  });
733
845
  export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
734
846
 
@@ -2602,3 +2714,138 @@ export function reclampPinnedTiming(cues: readonly SceneCue[]): ReclampResult {
2602
2714
  }
2603
2715
  return { cues: out, adjusted };
2604
2716
  }
2717
+
2718
+ /** A placement as `production.json` stores it — the editor-safe shape
2719
+ * (`ProductionSfxSchema`), never the producer's: this module is in the
2720
+ * EDITOR's runtime graph and `producer/sfx.ts` reaches node:child_process
2721
+ * (schema.ts's ProductionSfxSchema docstring has the whole argument). */
2722
+ export type SfxPlannedPlacement = ProductionSfx["placements"][number];
2723
+
2724
+ export interface AppliedSfxOverrides {
2725
+ /** The plan the resolver should place — edits applied, mutes removed, the
2726
+ * user's own placements appended. */
2727
+ placements: SfxPlannedPlacement[];
2728
+ /**
2729
+ * Edit keys that matched no planned placement. `"stale key"` is the re-plan
2730
+ * case: the placement the user edited is not in the plan any more, so their
2731
+ * work on it is lost and saying so is the whole point (the `was`-guard
2732
+ * posture — `applyCaptionEdits` reports rather than guessing at a nearby
2733
+ * word, and the field failure §137 pins was a DROP nobody printed).
2734
+ * `"duplicate key"` is a second placement answering to a key an earlier one
2735
+ * already claimed: the edit applied, to the first, and the later one is left
2736
+ * as planned rather than edited twice (`applyCaptionEdits`'
2737
+ * `duplicate-anchor` rule).
2738
+ *
2739
+ * A plain TS union rather than a zod enum, unlike `SfxDropReasonSchema`:
2740
+ * these reasons are computed here and printed, never written to a file and
2741
+ * read back, so there is no boundary for a parse to guard.
2742
+ */
2743
+ dropped: Array<{ key: string; reason: "stale key" | "duplicate key" }>;
2744
+ }
2745
+
2746
+ /**
2747
+ * The user's SFX layer over a placement plan (`OverrideDocSchema.sfx`).
2748
+ *
2749
+ * Applied in produce between loading the plan and `resolveSfxCues`, so the
2750
+ * resolver's word→output arithmetic, its cut-word drops and its gain product
2751
+ * all run over what the USER approved rather than what the model wrote. The
2752
+ * plan itself is never rewritten: `production.json` keeps the model's
2753
+ * placements, which is what the edit keys are derived from — folding the edits
2754
+ * into the stored plan would re-key every one of them on the next run and
2755
+ * stale the user's whole layer (the same reason `cuts[].startSec` is left as
2756
+ * the user drew it).
2757
+ *
2758
+ * Deliberately does NOT re-run the spacing or density passes
2759
+ * (`normalizeSfxPlan`): those price the MODEL's plan, and a user who drags two
2760
+ * effects together or adds a ninth to a `subtle` video has said what they want
2761
+ * — an explicit user action outranks a deterministic budget, exactly as a user
2762
+ * cut outranks a cleanup veto in produce. The resolver's own drops (unknown
2763
+ * sound, missing file, cut word) still apply, because those are about whether
2764
+ * the effect can be PLAYED at all.
2765
+ *
2766
+ * Pure: no filesystem, no library — an edit naming a sound that does not exist
2767
+ * is the resolver's "unknown sound", reported there with every other one.
2768
+ */
2769
+ export function applySfxOverrides(
2770
+ planned: readonly SfxPlannedPlacement[],
2771
+ sfx: OverrideDoc["sfx"],
2772
+ ): AppliedSfxOverrides {
2773
+ const dropped: AppliedSfxOverrides["dropped"] = [];
2774
+ if (sfx === undefined) return { placements: [...planned], dropped };
2775
+
2776
+ const claimed = new Set<string>();
2777
+ const placements: SfxPlannedPlacement[] = [];
2778
+ for (const p of planned) {
2779
+ const key = sfxPlacementKey(p);
2780
+ const edit = sfx.edits[key];
2781
+ if (edit === undefined) {
2782
+ placements.push(p);
2783
+ continue;
2784
+ }
2785
+ if (claimed.has(key)) {
2786
+ dropped.push({ key, reason: "duplicate key" });
2787
+ placements.push(p);
2788
+ continue;
2789
+ }
2790
+ claimed.add(key);
2791
+ // A mute keeps the ENTRY (the editor draws a restorable ghost from it) and
2792
+ // removes the PLACEMENT — `SceneOverrideSchema.hidden`'s contract for a
2793
+ // track of instants.
2794
+ if (edit.muted === true) continue;
2795
+ const retimed = edit.word !== undefined;
2796
+ const next: SfxPlannedPlacement = {
2797
+ ...p,
2798
+ ...(edit.soundId !== undefined ? { soundId: edit.soundId } : {}),
2799
+ ...(retimed ? { word: edit.word! } : {}),
2800
+ ...(edit.gain !== undefined ? { gain: edit.gain } : {}),
2801
+ };
2802
+ // An explicit retime BREAKS the scene link (2026-08-29). `sceneId` says
2803
+ // "fire when this graphic enters" — an intent the MODEL inferred — and a
2804
+ // user dragging the marker onto a word says "fire HERE". An explicit user
2805
+ // position outranks an inferred sync, the same doctrine that lets a user
2806
+ // cut outrank a cleanup veto and a user's own placement outrank the
2807
+ // density budget above. Without this the marker would snap back to the
2808
+ // graphic on the next render and the drag would look like it never
2809
+ // happened.
2810
+ //
2811
+ // It is also why `sfxPlacementKey` stays `${soundId}@${word}` and never
2812
+ // takes `sceneId` into it: the key has to survive the link being cut, and
2813
+ // a re-plan that keeps the sound and the word keeps the edit regardless of
2814
+ // whether it re-linked the scene.
2815
+ if (retimed) delete next.sceneId;
2816
+ placements.push(next);
2817
+ }
2818
+ for (const key of Object.keys(sfx.edits)) {
2819
+ if (!claimed.has(key)) dropped.push({ key, reason: "stale key" });
2820
+ }
2821
+
2822
+ // The user's own placements, appended as plain placements: past this
2823
+ // function an added effect is indistinguishable from a planned one, which is
2824
+ // what makes the resolver, the stager and the accounting need no idea the
2825
+ // override layer exists. The `id` does not travel — it names the doc entry
2826
+ // the editor edits, and nothing downstream addresses a placement by name.
2827
+ //
2828
+ // No `sceneId` either, and `SfxAddedPlacementSchema` has no field for one:
2829
+ // the user chose this word themselves, which is the same explicit-position
2830
+ // fact the retime above cuts the link for.
2831
+ for (const add of sfx.added) {
2832
+ placements.push({
2833
+ soundId: add.soundId,
2834
+ word: add.word,
2835
+ ...(add.gain !== undefined ? { gain: add.gain } : {}),
2836
+ });
2837
+ }
2838
+
2839
+ // Word order, stable: a retimed or added placement would otherwise sit where
2840
+ // the model happened to put it, and the plan handed to the resolver is also
2841
+ // what the accounting indexes into (`SfxValidationIssue.placement`) — a
2842
+ // human reading a warning against production.json should meet the same order
2843
+ // `normalizeSfxPlan` left the plan in.
2844
+ return {
2845
+ placements: placements
2846
+ .map((p, i) => ({ p, i }))
2847
+ .sort((a, b) => a.p.word - b.p.word || a.i - b.i)
2848
+ .map((e) => e.p),
2849
+ dropped,
2850
+ };
2851
+ }
@@ -0,0 +1,132 @@
1
+ import { z } from "zod/v4";
2
+ import type { LlmProvider } from "./provider";
3
+ import { YOUTUBE_TRANSCRIPT_CHAR_CAP } from "./youtube";
4
+ import { truncateAtWordBoundary } from "../publish/captions";
5
+
6
+ /**
7
+ * Regenerate ONE network's caption from the editor's publish panel
8
+ * (handoff 2026-08-29 item 4). The prompt carries the transcript, the
9
+ * caption AS THE PANEL HOLDS IT (the user's manual edits included — the
10
+ * model must see what the user sees) and the user's correction, and returns
11
+ * replacement text only: nothing here writes to the pack or sends anything.
12
+ *
13
+ * Pure prompt builder separated from the provider call, the
14
+ * `buildYoutubePrompt` split, so the include/cap matrix is testable without
15
+ * an LLM.
16
+ */
17
+
18
+ export const CaptionRegenSchema = z.object({ caption: z.string() });
19
+
20
+ export interface CaptionRegenArgs {
21
+ /** The network the caption posts to ("linkedin", "x", …) — named in the
22
+ * prompt so the rewrite keeps that platform's idiom. */
23
+ network: string;
24
+ /** What the panel's box holds right now, manual edits and all. */
25
+ currentCaption: string;
26
+ /** The user's correction — the whole reason this call exists. */
27
+ instruction: string;
28
+ /** The transcript as plain text — the only source of factual claims. */
29
+ transcriptText: string;
30
+ /** The platform's caption cap (publish/captions.ts's captionCap). */
31
+ charCap: number;
32
+ }
33
+
34
+ /** What a truncated transcript ends with — the model must know it is reading
35
+ * an excerpt (buildYoutubePrompt's TRUNCATION_NOTE rule, restated because
36
+ * that constant is module-private and this note's wording is its own). */
37
+ const TRUNCATION_NOTE = "[transcript truncated — the video continues]";
38
+
39
+ /**
40
+ * Platform idiom the model is told to write toward — the author's own
41
+ * per-platform shapes (his voice guide), not generic social-media advice.
42
+ * Keyed by publish provider name; absence falls through to nothing extra.
43
+ */
44
+ const NETWORK_PRACTICES: Record<string, string> = {
45
+ linkedin:
46
+ "LinkedIn: the fullest version — the reader's gap first, then what was built, one " +
47
+ "technical detail worth defending, the honest limitation inline, warm low-key close. " +
48
+ "Short paragraphs, but never one-line 'broetry' stacked for the algorithm.",
49
+ "linkedin-page":
50
+ "LinkedIn page: same shape as a personal LinkedIn post — gap first, specifics over " +
51
+ "adjectives, honest limitation inline, no broetry.",
52
+ youtube:
53
+ "YouTube description: plain what-it-does in the first two lines (that is all most " +
54
+ "viewers see), keep any existing timestamps/chapters and links intact.",
55
+ facebook:
56
+ "Facebook: conversational, front-load the reader's problem in the first sentence, " +
57
+ "shorter than LinkedIn.",
58
+ instagram:
59
+ "Instagram: short — the visual leads, the caption carries ONE specific. Links do not " +
60
+ "work in captions, so 'link in bio' phrasing, never a raw URL. Keep existing hashtags " +
61
+ "unless instructed.",
62
+ threads:
63
+ "Threads: short and conversational like Instagram; one specific, no hashtag walls.",
64
+ x: "X: the gap and the one surprising detail — nothing padded.",
65
+ tiktok: "TikTok: short hook line plus existing hashtags.",
66
+ };
67
+
68
+ export function buildCaptionRegenPrompt(args: CaptionRegenArgs): { system: string; user: string } {
69
+ const system =
70
+ "You rewrite ONE social media caption for a finished video, applying the user's " +
71
+ "instruction. Hard rules:\n" +
72
+ "- Every factual claim in the caption must be supported by the transcript. If the video " +
73
+ "uses a number or story as an EXAMPLE or hypothetical, never state it as a fact — this " +
74
+ "exact failure has shipped: a video said \"imagine 50 teams applied\" as an example, and " +
75
+ "the published caption stated \"50 teams applied\" as fact.\n" +
76
+ "- NEVER use an em-dash (—) or en-dash (–) anywhere in the caption. Where a pause or " +
77
+ "soft pivot is needed, use an ellipsis (\"...\") — that is the author's voice, not a typo " +
78
+ "to clean up.\n" +
79
+ "- Specifics over adjectives: no \"powerful\", \"seamless\", \"game-changing\". Name the " +
80
+ "actual number or detail, and only numbers the transcript supports.\n" +
81
+ "- No engagement bait (\"thoughts?\", \"drop a comment\", \"who else...\"), no emoji " +
82
+ "bullets or rocket emoji; at most a single \":)\".\n" +
83
+ "- Respect the character cap given for this network — the platform truncates or rejects " +
84
+ "anything longer.\n" +
85
+ "- Keep the author's voice and structure from the current caption unless the instruction " +
86
+ "says otherwise: this is a correction, not a rewrite from scratch.\n" +
87
+ "- Output only the caption text, nothing else.";
88
+ const practice = NETWORK_PRACTICES[args.network];
89
+ // The same cap buildYoutubePrompt applies, imported rather than restated:
90
+ // slice + say so, so the model knows it is reading an excerpt.
91
+ const capped =
92
+ args.transcriptText.length > YOUTUBE_TRANSCRIPT_CHAR_CAP
93
+ ? `${args.transcriptText.slice(0, YOUTUBE_TRANSCRIPT_CHAR_CAP)}\n${TRUNCATION_NOTE}`
94
+ : args.transcriptText;
95
+ const user =
96
+ `Network: ${args.network} (character cap: ${args.charCap})\n` +
97
+ (practice ? `Platform practice: ${practice}\n` : "") +
98
+ "\n" +
99
+ `Current caption:\n${args.currentCaption}\n\n` +
100
+ `Instruction from the author:\n${args.instruction}\n\n` +
101
+ `Transcript:\n${capped}`;
102
+ return { system, user };
103
+ }
104
+
105
+ /**
106
+ * The em/en-dash ban enforced mechanically — the prompt asks, this
107
+ * guarantees. Ellipsis replaces a mid-sentence dash (the author's own pause
108
+ * idiom); a dash already followed by ellipsis-like punctuation just drops.
109
+ */
110
+ export function stripDashes(text: string): string {
111
+ return text.replace(/\s*[—–]\s*/g, "... ").replace(/\.\.\.\s+(?=[.…])/g, "");
112
+ }
113
+
114
+ /** One editorial call → the replacement caption, capped at a word boundary
115
+ * as the belt-and-braces backstop (the schema cannot express a length cap
116
+ * the model is guaranteed to honor). */
117
+ export async function generateCaptionRegen(
118
+ provider: LlmProvider,
119
+ args: CaptionRegenArgs,
120
+ ): Promise<string> {
121
+ const { system, user } = buildCaptionRegenPrompt(args);
122
+ const { caption } = await provider.complete({
123
+ system,
124
+ user,
125
+ schema: CaptionRegenSchema,
126
+ schemaName: "caption_regen",
127
+ // Editorial on purpose: this rewrites the copy real accounts publish,
128
+ // which is exactly the judgement tier the beat sheet buys.
129
+ tier: "editorial",
130
+ });
131
+ return truncateAtWordBoundary(stripDashes(caption), args.charCap);
132
+ }
@@ -27,6 +27,8 @@ export * from "./provider";
27
27
  export * from "./usage";
28
28
  export * from "./beats";
29
29
  export * from "./youtube";
30
+ export * from "./sfx";
31
+ export * from "./caption-regen";
30
32
  export * from "./scene-props";
31
33
  export * from "./repair";
32
34
  export { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
@@ -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,