ossclip 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/src/produce.ts CHANGED
@@ -156,6 +156,8 @@ import {
156
156
  sliceTranscript,
157
157
  type Analysis,
158
158
  type AppliedRepair,
159
+ type BeatSheet,
160
+ BeatSheetSchema,
159
161
  type BeatsValidationIssue,
160
162
  type CleanupLevel,
161
163
  type ClipWindow,
@@ -170,6 +172,34 @@ import {
170
172
  RESOLUTION_CHOICES,
171
173
  smallestSource,
172
174
  ResolutionChoiceSchema,
175
+ // The --sfx path (2026-08-29): the library loader, the placement call, its
176
+ // deterministic gate, and the resolver that turns word anchors into cues.
177
+ loadSfxLibrary,
178
+ sfxLibraryHash,
179
+ // The `sfxBundledPack` config gate. It lives in core, not beside `resolveSfx`
180
+ // below, because the edit server has to resolve it identically — its own
181
+ // doc-comment has the reason.
182
+ resolveSfxBundledPack,
183
+ generateSfxPlan,
184
+ resolveSfxCues,
185
+ // Scene id → final start second, the scene-anchored placements' clock
186
+ // (2026-08-29). Built from `sceneCues`, never from the raw plan.
187
+ sceneStartSeconds,
188
+ // The user's layer over that plan (Phase 3), and the schema the carried
189
+ // forward plan is parsed back through.
190
+ applySfxOverrides,
191
+ ProductionSfxSchema,
192
+ sfxStagedFile,
193
+ formatSfxAccounting,
194
+ SfxLevelSchema,
195
+ SfxPlanSchema,
196
+ SfxValidationIssueSchema,
197
+ SFX_PROMPT_VERSION,
198
+ type LoadedSfxSound,
199
+ type SfxCue,
200
+ type SfxLevel,
201
+ type SfxPlacement,
202
+ type SfxValidationIssue,
173
203
  type ProviderName,
174
204
  type ResolutionChoice,
175
205
  type Scene,
@@ -417,6 +447,38 @@ export function existingProducerStamp(work: string): Production["producer"] | un
417
447
  }
418
448
  }
419
449
 
450
+ /**
451
+ * The `sfx` plan the workdir's LAST run wrote, or undefined (Phase 3).
452
+ *
453
+ * This is how a `--scenes` replay keeps its sound design. The editor's Render
454
+ * pins the reviewed plan with `--scenes` and drops `--produce`
455
+ * (render-replay-args.ts), so the run never reaches the placement call — and
456
+ * the placement call is not what should decide, because the SCENES-REVIEWED
457
+ * doctrine says a render started from the editor must reproduce what the user
458
+ * reviewed. For SFX that reviewed state is exactly `production.json`'s plan
459
+ * plus `overrides.json`'s edits on top of it: re-placing would hand the user a
460
+ * different set of effects than the ones they just dragged, and skipping would
461
+ * hand them silence.
462
+ *
463
+ * Tolerant like `existingProducerStamp` above — a missing, unreadable or
464
+ * sfx-less file all mean "no prior sound design", which is what a first run
465
+ * says too. Parsed through `ProductionSfxSchema`, never cast: production.json
466
+ * is as hand-editable as anything else in the workdir, and a level of "MEME"
467
+ * must not reach the report as a level.
468
+ */
469
+ export function priorSfxPlan(work: string): { level: SfxLevel; placements: SfxPlacement[] } | undefined {
470
+ try {
471
+ const raw = JSON.parse(readFileSync(join(work, "production.json"), "utf8")) as {
472
+ sfx?: unknown;
473
+ };
474
+ if (raw.sfx === undefined) return undefined;
475
+ const parsed = ProductionSfxSchema.safeParse(raw.sfx);
476
+ return parsed.success ? parsed.data : undefined;
477
+ } catch {
478
+ return undefined;
479
+ }
480
+ }
481
+
420
482
  export function beatCacheKeyCandidates(
421
483
  parts: Omit<Parameters<typeof beatSheetCacheKey>[0], "providerName">,
422
484
  providerName: string,
@@ -715,6 +777,21 @@ export interface ProduceOptions {
715
777
  * a file input.
716
778
  */
717
779
  sort?: "name" | "mtime";
780
+ /**
781
+ * `--sfx` (2026-08-29): place sound effects from the loaded pack on the
782
+ * beats the producer planned. Tri-state like `watermark` — true/false when
783
+ * TYPED, undefined when not, so the config's `sfx` key can supply the
784
+ * default (`resolveSfx`). Requires a beat sheet, so it rides `--produce`;
785
+ * without one the step says so and skips.
786
+ */
787
+ sfx?: boolean;
788
+ /**
789
+ * `--sfx-level`: how much sound design (`subtle | normal | meme`). Already
790
+ * zod-parsed to the union by program.ts, which also folds the implication
791
+ * (`sfxFlag`: a typed level turns `sfx` on); the CONFIG's `sfxLevel` arrives
792
+ * separately as an unvalidated value and `resolveSfxLevel` arbitrates.
793
+ */
794
+ sfxLevel?: SfxLevel;
718
795
  /**
719
796
  * Whether `--sort` was TYPED, as opposed to commander filling in its
720
797
  * `"name"` default. `sort` alone can't tell those apart, and `--sort` does
@@ -784,6 +861,103 @@ export function resolveCoverInVideo(
784
861
  return flag ?? configValue === true;
785
862
  }
786
863
 
864
+ /**
865
+ * `--sfx-level` implies `--sfx`: typing a level is asking for sound effects,
866
+ * and a run that quietly did nothing because the boolean was missing is the
867
+ * worst possible reading of it. Returns the tri-state `--sfx` carries —
868
+ * `undefined` when neither was typed, so the config's `sfx` key still gets its
869
+ * turn (`resolveSfx`). Pure, so the implication is testable without commander,
870
+ * the `jumpCutsFlag` posture.
871
+ */
872
+ export function sfxFlag(
873
+ sfx: boolean | undefined,
874
+ sfxLevel: SfxLevel | undefined,
875
+ ): boolean | undefined {
876
+ return sfxLevel !== undefined ? true : sfx;
877
+ }
878
+
879
+ /**
880
+ * The effective `--sfx` switch — `resolveCoverInVideo`'s semantics verbatim: a
881
+ * TYPED flag wins, and only then does the config's `sfx` supply the default,
882
+ * `=== true` rather than truthy so a hand-edited `"sfx": "yes"` cannot coerce
883
+ * sound effects onto every render.
884
+ */
885
+ export function resolveSfx(flag: boolean | undefined, configValue: unknown): boolean {
886
+ return flag ?? configValue === true;
887
+ }
888
+
889
+ /**
890
+ * The effective `--sfx-level`. `resolveLlmEffort`'s shape — the warning is
891
+ * RETURNED, not printed, so the resolution stays pure — and its precedence: a
892
+ * typed flag beats a config key, and a malformed config key costs a warning
893
+ * and the default rather than a coerced level. The default matters: `meme`
894
+ * unlocks the meme-tagged sounds, so a typo must never fall UP into it.
895
+ */
896
+ export function resolveSfxLevel(
897
+ flag: SfxLevel | undefined,
898
+ configValue: unknown,
899
+ ): { level: SfxLevel; warning?: string } {
900
+ if (flag !== undefined) return { level: flag };
901
+ if (configValue === undefined) return { level: "normal" };
902
+ const parsed = SfxLevelSchema.safeParse(configValue);
903
+ if (parsed.success) return { level: parsed.data };
904
+ return {
905
+ level: "normal",
906
+ warning: `⚠ config sfxLevel ignored — expected ${SfxLevelSchema.options.join("|")}, using normal`,
907
+ };
908
+ }
909
+
910
+ /**
911
+ * What `sfx-<key>.json` holds: the normalized plan plus the accounting a
912
+ * cached re-run would otherwise have to invent — `planned` is the count the
913
+ * MODEL returned, which nothing on disk could re-derive, and `issues` are the
914
+ * planning drops the report explains the shortfall with (the beat-sheet
915
+ * cache's `graphics`/`issues` rule, §78).
916
+ *
917
+ * Parsed on read, never trusted: a workdir file is as hand-editable as
918
+ * anything else here, and a mangled one must cost a re-plan, not a crash.
919
+ */
920
+ const SfxPlanCacheSchema = SfxPlanSchema.extend({
921
+ planned: z.number().int().nonnegative().default(0),
922
+ issues: z.array(SfxValidationIssueSchema).default([]),
923
+ });
924
+
925
+ /**
926
+ * The placement-plan cache key: everything that changes the PLAN — which
927
+ * prompt asked, about which words, against which beat sheet, at what level,
928
+ * over which library. The §78 posture the beat sheet's key carries, and pure
929
+ * for the same reason: "a change that changes the answer must change the key"
930
+ * has to be assertable without a workdir or an LLM.
931
+ *
932
+ * `beatKey` folds the whole beat-sheet key in (provider, model, intent,
933
+ * framing, clip window…) rather than restating it: the graphics plan is IN the
934
+ * placement prompt, so a re-plan of the beats is a different question about
935
+ * the same words. `libraryHash` is pack METADATA only (`sfxLibraryHash`), so
936
+ * dropping a user pack in `~/.ossclip/sfx` invalidates while re-encoding an
937
+ * mp3 does not.
938
+ */
939
+ export function sfxCacheKey(parts: {
940
+ promptVersion: number;
941
+ beatKey: string;
942
+ level: SfxLevel;
943
+ libraryHash: string;
944
+ /** The repaired transcript's TEXT — the beat key's rule, for the same reason. */
945
+ words: readonly string[];
946
+ }): string {
947
+ return createHash("sha1")
948
+ .update(
949
+ JSON.stringify([
950
+ parts.promptVersion,
951
+ parts.beatKey,
952
+ parts.level,
953
+ parts.libraryHash,
954
+ parts.words,
955
+ ]),
956
+ )
957
+ .digest("hex")
958
+ .slice(0, 8);
959
+ }
960
+
787
961
  /**
788
962
  * Where the cover image lives right now, most-specific first — the EDITOR
789
963
  * panel's ladder (`currentCoverImage` in edit.ts), deliberately the same one:
@@ -2821,11 +2995,52 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2821
2995
  let scenes: Scene[] = [];
2822
2996
  /** Editorial output kept for the cover (§31): hook + its thumbnail form. */
2823
2997
  let beatSheet: { hook: string; coverText?: string } | undefined;
2998
+ /**
2999
+ * The FULL sheet, moments included — the graphics plan the SFX call needs in
3000
+ * context (a whoosh syncs with a graphic entrance). Separate from
3001
+ * `beatSheet` above, which is deliberately the cover's two fields: this one
3002
+ * exists only while a plan is in hand this run, and is undefined on the
3003
+ * paths that never had one (`--scenes`, a pre-`--sfx` scenes cache).
3004
+ */
3005
+ let fullSheet: BeatSheet | undefined;
2824
3006
  /** The graphics accounting line for report.txt (§118b), and the beat-sheet
2825
3007
  * issues that explain it. Cached alongside the beat sheet so a cached
2826
3008
  * re-run's report keeps the accounting instead of erasing it (§78). */
2827
3009
  let graphicsLine: string | undefined;
2828
3010
  let beatIssues: BeatsValidationIssue[] = [];
3011
+ /**
3012
+ * The `--sfx` plan: the level it was planned at and the placements that
3013
+ * survived `normalizeSfxPlan`, stored on production.json and resolved to
3014
+ * cues once the cut is final (`resolveSfxCues`). Undefined = this run has no
3015
+ * sound design at all, which is what an absent `sfx` field means.
3016
+ */
3017
+ let sfxPlan: { level: SfxLevel; placements: SfxPlacement[] } | undefined;
3018
+ /** The library the plan was made against — the resolver needs the same
3019
+ * sounds (gain, absPath) it planned over, and the stager needs their files. */
3020
+ let sfxSounds: LoadedSfxSound[] = [];
3021
+ /** Planning drops + the count the MODEL returned, carried to the one
3022
+ * accounting line the console and report.txt share (§118b's contract). */
3023
+ let sfxIssues: SfxValidationIssue[] = [];
3024
+ let sfxPlanned = 0;
3025
+ /** Whether the placement step actually ran — see the notice below the
3026
+ * scenes block for the runs where `--sfx` cannot reach it. */
3027
+ let sfxAttempted = false;
3028
+ // Resolved HERE, above the branch that uses it, so BOTH the placement step
3029
+ // and the "this run has no beat sheet" notice read one answer. Typed beats
3030
+ // config for the switch; the level is zod-parsed out of the config, never
3031
+ // coerced (`resolveSfxLevel`).
3032
+ const sfxOn = resolveSfx(opts.sfx, cfg.sfx);
3033
+ const sfxLevelResolved = resolveSfxLevel(opts.sfxLevel, cfg.sfxLevel);
3034
+ if (sfxOn && sfxLevelResolved.warning) console.log(sfxLevelResolved.warning);
3035
+ const sfxLevel = sfxLevelResolved.level;
3036
+ // Which packs this machine offers (`sfxBundledPack`) — resolved next to the
3037
+ // level, and for the same reason: BOTH library loads below (the placement
3038
+ // step and the carry-forward branch) must read one answer, or a run would
3039
+ // plan against one library and stage from another.
3040
+ const sfxBundled = resolveSfxBundledPack(cfg.sfxBundledPack);
3041
+ if (sfxOn && sfxBundled.warning) console.log(sfxBundled.warning);
3042
+ /** The loader's opts, shared by both library loads. */
3043
+ const sfxLoad = { includeBundled: sfxBundled.include };
2829
3044
  /** Who planned this run (R16 §78) — stamped into production.json below. */
2830
3045
  let producerStamp: Production["producer"];
2831
3046
  /** The resolved `--clip` window (R19 §93) — set only on a clip run; feeds
@@ -3008,6 +3223,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3008
3223
  // cache under the post-resolution key so re-runs and replays hit it.
3009
3224
  scenes = clipFresh.scenes;
3010
3225
  beatSheet = { hook: clipFresh.beatSheet.hook, coverText: clipFresh.beatSheet.coverText };
3226
+ fullSheet = clipFresh.beatSheet;
3011
3227
  console.log(`▸ hook: ${clipFresh.beatSheet.hook}`);
3012
3228
  console.log(
3013
3229
  `▸ planned ${clipFresh.beatSheet.moments.length} moments, ${scenes.length} scenes` +
@@ -3035,7 +3251,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3035
3251
  await writeFile(join(work, `scenes-${adoptKey}.json`), JSON.stringify(scenes, null, 2));
3036
3252
  await writeFile(
3037
3253
  join(work, `beatsheet-${adoptKey}.json`),
3038
- JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
3254
+ JSON.stringify(
3255
+ { ...beatSheet, moments: fullSheet.moments, graphics: graphicsLine, issues: beatIssues },
3256
+ null,
3257
+ 2,
3258
+ ),
3039
3259
  );
3040
3260
  } else if (existsSync(sceneCache)) {
3041
3261
  scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(sceneCache, "utf8")));
@@ -3054,12 +3274,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3054
3274
  const cached = JSON.parse(await readFile(beatCache, "utf8")) as {
3055
3275
  hook: string;
3056
3276
  coverText?: string;
3277
+ moments?: unknown;
3057
3278
  graphics?: string;
3058
3279
  issues?: BeatsValidationIssue[];
3059
3280
  };
3060
3281
  beatSheet = { hook: cached.hook, coverText: cached.coverText };
3061
3282
  graphicsLine = cached.graphics;
3062
3283
  beatIssues = cached.issues ?? [];
3284
+ // The MOMENTS ride the cache too (2026-08-29, --sfx): this file used to
3285
+ // keep only the cover's two fields, so a cached run had no graphics
3286
+ // plan to put in front of the placement call — and "produce once, add
3287
+ // --sfx later" is the normal way anyone reaches this feature. Parsed,
3288
+ // not trusted (a hand-edited or pre-`--sfx` file simply has no
3289
+ // `moments`), and its absence costs the SFX step alone: everything the
3290
+ // cache did before this is read above and unaffected.
3291
+ const sheet = BeatSheetSchema.safeParse({
3292
+ hook: cached.hook,
3293
+ coverText: cached.coverText,
3294
+ moments: cached.moments,
3295
+ });
3296
+ if (sheet.success) fullSheet = sheet.data;
3063
3297
  }
3064
3298
  } else {
3065
3299
  const aiAnim = isInteractive()
@@ -3106,6 +3340,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3106
3340
  if (aiAnim) aiAnim.stop();
3107
3341
  scenes = result.scenes;
3108
3342
  beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
3343
+ fullSheet = result.beatSheet;
3109
3344
  console.log(`▸ hook: ${result.beatSheet.hook}`);
3110
3345
  console.log(
3111
3346
  `▸ planned ${result.beatSheet.moments.length} moments, ${scenes.length} scenes` +
@@ -3132,9 +3367,137 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3132
3367
  await writeFile(join(work, `scenes-${freshKey}.json`), JSON.stringify(scenes, null, 2));
3133
3368
  await writeFile(
3134
3369
  join(work, `beatsheet-${freshKey}.json`),
3135
- JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
3370
+ JSON.stringify(
3371
+ { ...beatSheet, moments: fullSheet.moments, graphics: graphicsLine, issues: beatIssues },
3372
+ null,
3373
+ 2,
3374
+ ),
3136
3375
  );
3137
3376
  }
3377
+
3378
+ // ---- Sound effects (`--sfx`): the placement call -----------------------
3379
+ // HERE, inside the producer branch, because the placement prompt needs the
3380
+ // graphics plan in front of it (Approach A: a whoosh syncs with a graphic
3381
+ // entrance) — and because a beat sheet is the one thing this feature
3382
+ // cannot do without. `--scenes` and a plain run never reach this block;
3383
+ // the notice for that case is printed by the caller below.
3384
+ if (sfxOn) {
3385
+ sfxAttempted = true;
3386
+ const library = loadSfxLibrary(sfxLoad);
3387
+ for (const issue of library.issues) console.log(` ⚠ sfx pack ${issue.pack}: ${issue.issue}`);
3388
+ if (library.sounds.length === 0) {
3389
+ // Warn and continue, never kill: an empty library is a packaging or a
3390
+ // user-pack problem, and it costs sound effects, not the video.
3391
+ console.log(" ⚠ sfx: no usable sounds in the library — skipping sound effects");
3392
+ } else {
3393
+ sfxSounds = library.sounds;
3394
+ const sfxKey = sfxCacheKey({
3395
+ promptVersion: SFX_PROMPT_VERSION,
3396
+ // The beat key, not its parts: the graphics plan is IN this prompt,
3397
+ // so a different sheet is a different question (see `sfxCacheKey`).
3398
+ beatKey: hitKey,
3399
+ level: sfxLevel,
3400
+ libraryHash: sfxLibraryHash(library.sounds),
3401
+ words: beatKeyParts.words,
3402
+ });
3403
+ const sfxCache = join(work, `sfx-${sfxKey}.json`);
3404
+ if (existsSync(sfxCache)) {
3405
+ // Parsed, never trusted — a cache file is as hand-editable as any
3406
+ // other artefact in the workdir. A malformed one is a re-plan, not a
3407
+ // crash, so it falls through to the call below.
3408
+ const cached = SfxPlanCacheSchema.safeParse(
3409
+ JSON.parse(await readFile(sfxCache, "utf8")),
3410
+ );
3411
+ if (cached.success) {
3412
+ sfxPlan = { level: sfxLevel, placements: cached.data.placements };
3413
+ sfxPlanned = cached.data.planned;
3414
+ sfxIssues = cached.data.issues;
3415
+ console.log(`▸ sfx cached (${sfxPlan.placements.length} placement(s), level ${sfxLevel})`);
3416
+ }
3417
+ }
3418
+ if (!sfxPlan && fullSheet === undefined) {
3419
+ // The one case the cache cannot cover: a workdir planned before
3420
+ // `--sfx` existed (or by a pre-2026-08-29 build) has scenes but no
3421
+ // moments, so there is no graphics plan to place against. Say what
3422
+ // to do rather than silently rendering a mute video.
3423
+ console.log(
3424
+ " ⚠ sfx: this workdir's cached plan has no beat sheet to place against — " +
3425
+ "re-plan (delete its scenes-*.json) to get sound effects",
3426
+ );
3427
+ } else if (!sfxPlan) {
3428
+ console.log(`▸ placing sound effects (level ${sfxLevel})…`);
3429
+ const result = await phases.time("llm", () =>
3430
+ generateSfxPlan(provider!, transcript, fullSheet!, library.sounds, sfxLevel),
3431
+ );
3432
+ sfxPlan = { level: sfxLevel, placements: result.plan.placements };
3433
+ sfxPlanned = result.planned;
3434
+ sfxIssues = result.issues;
3435
+ await writeFile(
3436
+ sfxCache,
3437
+ // The accounting rides the cache for §78's reason, the same one
3438
+ // `graphics`/`issues` ride the beat-sheet cache: a cached re-run
3439
+ // must be able to print the SAME accounting line rather than
3440
+ // erasing it, and `planned` is a count only the call itself knew.
3441
+ JSON.stringify(
3442
+ { placements: sfxPlan.placements, planned: sfxPlanned, issues: sfxIssues },
3443
+ null,
3444
+ 2,
3445
+ ),
3446
+ );
3447
+ }
3448
+ for (const issue of sfxIssues) {
3449
+ console.log(` ⚠ sfx placement ${issue.placement}: ${issue.issue}`);
3450
+ }
3451
+ }
3452
+ }
3453
+ }
3454
+
3455
+ // `--sfx` on a run that never reached the placement call — `--scenes` (which
3456
+ // is what the editor's Render replays), or a plain cut with no producer at
3457
+ // all.
3458
+ //
3459
+ // The plan carries FORWARD from the workdir's last `production.json` here
3460
+ // (Phase 3), and that is the scenes-reviewed doctrine applied to sound: an
3461
+ // editor render replays the REVIEWED state, and for SFX the reviewed state
3462
+ // is the prior plan plus overrides.json's edits on top of it (`priorSfxPlan`
3463
+ // has the full argument). The level comes from that record too — the user
3464
+ // reviewed effects placed at THAT level, and re-reading `--sfx-level` here
3465
+ // would describe them with a number they were never planned under. It is
3466
+ // written back below like any other run's plan, so a chain of editor renders
3467
+ // never breaks.
3468
+ //
3469
+ // No prior record is the only case left with nothing to carry: never a
3470
+ // silent ignore, and the message names the missing half rather than the flag
3471
+ // the user typed.
3472
+ if (sfxOn && !sfxAttempted) {
3473
+ const prior = priorSfxPlan(work);
3474
+ if (prior === undefined) {
3475
+ console.log(
3476
+ " ⚠ sfx: sound effects are placed against the producer's beat sheet — " +
3477
+ "add --produce (the editor's re-plan) to get them",
3478
+ );
3479
+ } else {
3480
+ // The same library the producer branch loads, and for the same two
3481
+ // consumers: the resolver needs each sound's gain and path, the stager
3482
+ // needs its file. An empty library costs the effects, never the video.
3483
+ const library = loadSfxLibrary(sfxLoad);
3484
+ for (const issue of library.issues) console.log(` ⚠ sfx pack ${issue.pack}: ${issue.issue}`);
3485
+ if (library.sounds.length === 0) {
3486
+ console.log(" ⚠ sfx: no usable sounds in the library — skipping sound effects");
3487
+ } else {
3488
+ sfxSounds = library.sounds;
3489
+ sfxPlan = prior;
3490
+ // `planned` is the reviewed plan's own size: this run made no model
3491
+ // call, so the only honest denominator for "N of M planned placed" is
3492
+ // what the user approved (the cached-plan branch's rule — the count
3493
+ // belongs to the plan, not to the run).
3494
+ sfxPlanned = prior.placements.length;
3495
+ console.log(
3496
+ `▸ sfx carried forward from the reviewed plan ` +
3497
+ `(${prior.placements.length} placement(s), level ${prior.level})`,
3498
+ );
3499
+ }
3500
+ }
3138
3501
  }
3139
3502
 
3140
3503
  // Every LLM call is behind us — repair, beat sheet, one per scene — so this
@@ -3906,6 +4269,77 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3906
4269
  );
3907
4270
  }
3908
4271
 
4272
+ // ---- Sound effects: word anchors → cues, and the files they name --------
4273
+ // AFTER the cut is final (`applyUserCuts` above) and after the public dir is
4274
+ // known, because both are inputs: a placement anchored to a word the user's
4275
+ // own cut removed is dropped here, and the copies must land where
4276
+ // `staticFile()` will look.
4277
+ let sfxCues: SfxCue[] = [];
4278
+ /** Planning drops + render drops, counted together by the one accounting
4279
+ * line the console and report.txt share (`formatSfxAccounting`). */
4280
+ let sfxAllIssues: SfxValidationIssue[] = sfxIssues;
4281
+ let sfxLine: string | undefined;
4282
+ if (sfxPlan) {
4283
+ // The user's layer FIRST (Phase 3): retimes, swaps, gains, mutes and the
4284
+ // placements they added themselves, applied before any of the resolver's
4285
+ // arithmetic so a dragged effect is timed, cut-checked and mixed exactly
4286
+ // like a planned one. `sfxPlan` itself is left alone — production.json
4287
+ // stores the MODEL's plan, which is what these edit keys are derived from
4288
+ // (`sfxPlacementKey`), and folding them in would re-key the whole layer on
4289
+ // the next run.
4290
+ const edited = applySfxOverrides(sfxPlan.placements, overrideDoc.sfx);
4291
+ for (const d of edited.dropped) {
4292
+ console.log(
4293
+ d.reason === "stale key"
4294
+ ? ` ⚠ sfx edit "${d.key}" no longer matches any planned placement — ` +
4295
+ `the re-plan dropped it`
4296
+ : ` ⚠ sfx edit "${d.key}" matches more than one placement — applied to the first`,
4297
+ );
4298
+ }
4299
+ // The accounting's denominator moves with the user's layer, because the
4300
+ // layer changes the SIZE of the plan the resolver saw: a mute REMOVES a
4301
+ // placement (it was un-planned by the user, not dropped by the pipeline)
4302
+ // and an add appends one. Without this, muting an effect would print as a
4303
+ // reasonless "1 dropped" and adding one as "6 of 5 planned placed".
4304
+ sfxPlanned += edited.placements.length - sfxPlan.placements.length;
4305
+ const resolved = resolveSfxCues(edited.placements, transcript, map, sfxSounds, {
4306
+ // The real check, injected (the resolver stays pure): a pack deleted
4307
+ // between planning and this render must cost the cue here, not a
4308
+ // Remotion 404 after the render has spent its minutes.
4309
+ exists: existsSync,
4310
+ // The scene timing context, from `sceneCues` — the FINAL list, with the
4311
+ // user's moves, trims, splits, pins and deletes already applied. That is
4312
+ // the whole point of the scene link (2026-08-29): a whoosh placed "as
4313
+ // the TitleCard enters" fired at a word, so moving the card in the
4314
+ // editor left the sound behind. Reading the producer's raw `scenes`
4315
+ // here instead would rebuild that bug exactly.
4316
+ sceneStarts: sceneStartSeconds(sceneCues),
4317
+ });
4318
+ sfxCues = resolved.cues;
4319
+ sfxAllIssues = [...sfxIssues, ...resolved.dropped];
4320
+ for (const d of resolved.dropped) console.log(` ⚠ sfx: ${d.issue}`);
4321
+ // Staged into the render's public dir AND the workdir when they differ,
4322
+ // for the Nastaliq font's reason verbatim: the render bundles
4323
+ // `dirname(renderVideo)`, `ossclip edit` serves the workdir, and both
4324
+ // mounts fetch the same served name. Only the sounds actually CUED are
4325
+ // copied — the library is a menu, not a payload.
4326
+ const staged = new Set(sfxCues.map((c) => c.soundFile));
4327
+ for (const sound of sfxSounds) {
4328
+ const rel = sfxStagedFile(sound);
4329
+ if (!staged.has(rel)) continue;
4330
+ for (const dir of new Set([renderPublicDirPath, work])) {
4331
+ // `join` on the filesystem side where `rel` itself stays
4332
+ // POSIX-literal — it is a served URL, these are paths (the
4333
+ // `sideImageDestRel` split).
4334
+ const dest = join(dir, ...rel.split("/"));
4335
+ mkdirSync(dirname(dest), { recursive: true });
4336
+ copyFileSync(sound.absPath, dest);
4337
+ }
4338
+ }
4339
+ sfxLine = formatSfxAccounting(sfxCues.length, sfxPlanned, sfxPlan.level, sfxAllIssues);
4340
+ console.log(`▸ ${sfxLine}`);
4341
+ }
4342
+
3909
4343
  const production: Production = {
3910
4344
  version: 1,
3911
4345
  // `originalInput`, not `input`: for a folder run `input` is by now
@@ -3935,6 +4369,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3935
4369
  ? { clip: { targetSec: clipTargetSec, ...clipWindow } }
3936
4370
  : {}),
3937
4371
  scenes: scenes.length > 0 ? scenes : undefined,
4372
+ // The PLAN, not the cues: word anchors survive a re-cut and the editor
4373
+ // edits them (Phase 3), while `render-props.json` carries the resolved
4374
+ // instants. Absent when this run has no sound design, so a no-`--sfx`
4375
+ // production.json is byte-identical to a pre-feature one.
4376
+ sfx: sfxPlan,
3938
4377
  producer: producerStamp,
3939
4378
  theme,
3940
4379
  // The EFFECTIVE output size, not the base frame (`--resolution`): this is
@@ -4022,6 +4461,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4022
4461
  .map((i) => ` ⚠ moment ${i.moment}: ${i.issue}\n`)
4023
4462
  .join("");
4024
4463
  }
4464
+ // The SFX accounting, the graphics block's contract exactly (§118b): ONE
4465
+ // formatter feeds the console line printed above and this one, so the two
4466
+ // can never say different things about the same run. The drops are listed
4467
+ // under it for the same reason the beat issues are listed under the graphics
4468
+ // line — the number alone does not say WHICH effect went missing.
4469
+ if (sfxLine) {
4470
+ report +=
4471
+ `\n${sfxLine}\n` +
4472
+ sfxAllIssues.map((i) => ` ⚠ placement ${i.placement}: ${i.issue}\n`).join("");
4473
+ }
4025
4474
  if (provider) {
4026
4475
  report += formatUsageReport(provider.usage, cfg.pricing);
4027
4476
  // A cached run has no usage block to print, and used to leave the report
@@ -4614,6 +5063,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4614
5063
  // actually render.
4615
5064
  ...(captionsHidden ? { captionsHidden: true } : {}),
4616
5065
  ...(opts.captions === false ? { captionsHiddenByFlag: true } : {}),
5066
+ // `--sfx`, written only when there is something to play (the watermark's
5067
+ // absent-means-off contract): a run without sound effects keeps a
5068
+ // render-props.json — and an audio graph — byte-identical to a pre-feature
5069
+ // one, and the composition reads an absent key as silence, not as an empty
5070
+ // track it still has to mount.
5071
+ ...(sfxCues.length > 0 ? { sfxCues } : {}),
4617
5072
  };
4618
5073
  await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
4619
5074
 
@@ -4785,6 +5240,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4785
5240
  portrait,
4786
5241
  audience,
4787
5242
  thumbnailBrief,
5243
+ // The SFX switch and level, RESOLVED (flag + config folded) — the
5244
+ // watermark's rationale on a flag whose default is config-dependent
5245
+ // (`sfx`/`sfxLevel` in ~/.ossclip/config.json). Unpinned, the editor's
5246
+ // Render would place a DIFFERENT amount of sound design the moment that
5247
+ // config is edited, or none at all on a machine without it — and since
5248
+ // the replay carries the reviewed plan forward rather than re-placing
5249
+ // (`priorSfxPlan`), an unpinned `--sfx` is the difference between the
5250
+ // reviewed sound design and a silent video.
5251
+ sfx: sfxOn,
5252
+ sfxLevel,
4788
5253
  });
4789
5254
  // produce is the ONLY command that may write command.json — edit.ts's §129
4790
5255
  // heal prepends the "produce" literal to any record that doesn't start