ossclip 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/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,
@@ -166,7 +168,40 @@ import {
166
168
  AGY_PRINT_TIMEOUT,
167
169
  type Production,
168
170
  ossclipOutputPathFor,
171
+ resolveOutputFrame,
172
+ RESOLUTION_CHOICES,
173
+ smallestSource,
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,
169
203
  type ProviderName,
204
+ type ResolutionChoice,
170
205
  type Scene,
171
206
  type SceneComponentId,
172
207
  type Segment,
@@ -256,6 +291,14 @@ export const TranscriptKeySchema = z.object({
256
291
  * runs) means "no biasing".
257
292
  */
258
293
  dictionary: z.array(z.string()).optional(),
294
+ /**
295
+ * Whether whisper ran its TRANSLATE task (`-tr`, 2026-08-29). It changes
296
+ * the decoded TEXT — Urdu speech comes back as English words — so it
297
+ * re-keys the cache exactly like the language does, or a warm workdir
298
+ * serves the Urdu-script transcript to a translate run. Absent (old key
299
+ * files) means "no translation", the `dictionary` contract.
300
+ */
301
+ translate: z.boolean().optional(),
259
302
  });
260
303
  export type TranscriptKey = z.infer<typeof TranscriptKeySchema>;
261
304
 
@@ -280,6 +323,9 @@ export function transcriptCacheReusable(
280
323
  // "" and absent both mean whisper's en default — program.ts rejects an
281
324
  // empty code, but a key file predating that guard must not wedge.
282
325
  (effective.language ?? "") === (requested.language ?? "") &&
326
+ // Absent and false are the same "no translation", so pre-flag key
327
+ // files reuse under a non-translate request.
328
+ (effective.translate ?? false) === (requested.translate ?? false) &&
283
329
  // ORDER-SENSITIVE by choice: the dictionary becomes whisper's --prompt
284
330
  // text verbatim, so a reordered list genuinely is a different decoder
285
331
  // input — treating it as equal would serve a transcript biased by a
@@ -401,6 +447,38 @@ export function existingProducerStamp(work: string): Production["producer"] | un
401
447
  }
402
448
  }
403
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
+
404
482
  export function beatCacheKeyCandidates(
405
483
  parts: Omit<Parameters<typeof beatSheetCacheKey>[0], "providerName">,
406
484
  providerName: string,
@@ -537,6 +615,13 @@ export interface ProduceOptions {
537
615
  * decodes garbage (Urdu field test 2026-08-05).
538
616
  */
539
617
  whisperLanguage?: string;
618
+ /**
619
+ * `--whisper-translate`: whisper's TRANSLATE task (`-tr`) — non-English
620
+ * speech decoded straight to ENGLISH text. Distinct from
621
+ * `whisperLanguage`, which says what is SPOKEN; the two are passed
622
+ * together (whisper decodes better knowing the source language).
623
+ */
624
+ whisperTranslate?: boolean;
540
625
  /**
541
626
  * Vocabulary terms for this run (`--dictionary`, F4 2026-08-16), already
542
627
  * split/trimmed by the action. Wholesale beats the config's `dictionary`
@@ -584,6 +669,16 @@ export interface ProduceOptions {
584
669
  * platform chrome to dodge and a landscape source needs no cropping at all.
585
670
  */
586
671
  aspect?: "9:16" | "16:9";
672
+ /**
673
+ * `--resolution <height>`: how big the output actually renders. `auto`
674
+ * keeps what the source has (capped at 2160); an explicit height scales the
675
+ * 1080-wide composition to it. Already validated by commander (or by
676
+ * `ResolutionChoiceSchema` when it comes from the config), so this is a
677
+ * choice, never a raw string. `resolveOutputFrame` owns the math and the
678
+ * why; the value reaches THREE stages that would otherwise each pin 1080p:
679
+ * the folder-concat target, the mezzanine, and the render's own scale.
680
+ */
681
+ resolution?: ResolutionChoice;
587
682
  /**
588
683
  * `--concurrency <n>`: how many browser tabs the render opens at once,
589
684
  * beating the config's `renderConcurrency` and the cpus-2 default
@@ -682,6 +777,21 @@ export interface ProduceOptions {
682
777
  * a file input.
683
778
  */
684
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;
685
795
  /**
686
796
  * Whether `--sort` was TYPED, as opposed to commander filling in its
687
797
  * `"name"` default. `sort` alone can't tell those apart, and `--sort` does
@@ -709,6 +819,32 @@ export function resolveWatermark(
709
819
  return flag ?? configValue === true;
710
820
  }
711
821
 
822
+ /**
823
+ * The effective `--resolution` — `resolveWatermark`'s precedence verbatim: a
824
+ * TYPED flag always wins, and only then does the config supply the default.
825
+ *
826
+ * The config side is ZOD-PARSED rather than trusted: `loadConfig` hands back
827
+ * whatever the hand-editable JSON held, and an unparsed `"4k"` would reach
828
+ * `Number("4k")` inside `resolveOutputFrame` as NaN and size the whole render
829
+ * off it. A malformed value earns one warning naming the key and falls back
830
+ * to 1080 — the value every existing run already produces, so a typo costs a
831
+ * message rather than a surprise 4K render (CLAUDE.md: parse, never coerce).
832
+ * Pure, so the flag × config matrix is testable without a config file.
833
+ */
834
+ export function resolveResolution(
835
+ flag: ResolutionChoice | undefined,
836
+ configValue: unknown,
837
+ ): ResolutionChoice {
838
+ if (flag !== undefined) return flag;
839
+ if (configValue === undefined) return "1080";
840
+ const parsed = ResolutionChoiceSchema.safeParse(configValue);
841
+ if (parsed.success) return parsed.data;
842
+ console.log(
843
+ `⚠ config resolution ignored — expected one of ${RESOLUTION_CHOICES.join(", ")}, using 1080`,
844
+ );
845
+ return "1080";
846
+ }
847
+
712
848
  /**
713
849
  * The effective `--cover-in-video` switch — resolveWatermark's semantics
714
850
  * verbatim: a TYPED flag always wins (so `--no-cover-in-video` beats a
@@ -725,6 +861,103 @@ export function resolveCoverInVideo(
725
861
  return flag ?? configValue === true;
726
862
  }
727
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
+
728
961
  /**
729
962
  * Where the cover image lives right now, most-specific first — the EDITOR
730
963
  * panel's ladder (`currentCoverImage` in edit.ts), deliberately the same one:
@@ -2078,6 +2311,46 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2078
2311
 
2079
2312
  const tools = { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath };
2080
2313
 
2314
+ // `--resolution` (2026-08-27), resolved before ANY stage that sizes pixels:
2315
+ // the folder concat, the mezzanine and the render each used to pin 1080p
2316
+ // independently, so a 4K take lost three quarters of its pixels before
2317
+ // anything looked at it. `auto` needs the SOURCE's size, which for a folder
2318
+ // means probing the clips here — the concat target is chosen before the
2319
+ // concatenated file (and its probe) exists.
2320
+ const resolution = resolveResolution(opts.resolution, cfg.resolution);
2321
+ const autoSource = async (): Promise<{ width: number; height: number } | null> => {
2322
+ if (resolution !== "auto") return null;
2323
+ if (isFolder && folderListing) {
2324
+ // Metadata-only probes, and only under `auto`: the default path adds no
2325
+ // ffprobe calls at all (the 4m32s probe-storm lesson, concat.ts:272).
2326
+ const sizes: Array<{ width: number; height: number }> = [];
2327
+ for (const entry of folderListing.entries) {
2328
+ try {
2329
+ const p = await probe(tools, join(input, entry.name));
2330
+ sizes.push({ width: p.width, height: p.height });
2331
+ } catch {
2332
+ // A clip that will not probe is the concat guard's problem, not
2333
+ // this sizing pass's — skip it rather than fail the run here.
2334
+ }
2335
+ }
2336
+ return smallestSource(sizes);
2337
+ }
2338
+ try {
2339
+ const p = await probe(tools, input);
2340
+ return { width: p.width, height: p.height };
2341
+ } catch {
2342
+ return null;
2343
+ }
2344
+ };
2345
+ const output = resolveOutputFrame({
2346
+ frame,
2347
+ source: (await autoSource()) ?? { width: 0, height: 0 },
2348
+ resolution,
2349
+ });
2350
+ if (output.scale !== 1) {
2351
+ console.log(`▸ resolution: ${output.width}x${output.height} (${resolution})`);
2352
+ }
2353
+
2081
2354
  // Resolved ONCE for the whole run — whisper biasing, repair vouching and
2082
2355
  // caption casing must all see the same list, or the passes disagree about
2083
2356
  // what a term is spelled like. A typed --dictionary wholesale beats the
@@ -2182,9 +2455,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2182
2455
  if (isFolder && folderListing) {
2183
2456
  const sort = opts.sort ?? "name";
2184
2457
  const result = await phases.time("ffmpeg", () =>
2458
+ // The concat target carries the RESOLVED size, not the base frame:
2459
+ // letterboxing every take into 1080p here would throw the pixels away
2460
+ // before the mezzanine or the render ever saw them (`--resolution`).
2185
2461
  concatFolder(tools, input, folderListing!, work, sort, {
2186
- w: frame.width,
2187
- h: frame.height,
2462
+ w: output.width,
2463
+ h: output.height,
2188
2464
  }),
2189
2465
  );
2190
2466
  console.log(
@@ -2304,6 +2580,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2304
2580
  const requestedKey: TranscriptKey = {
2305
2581
  model: requestedModel,
2306
2582
  ...(whisperLang.language !== undefined ? { language: whisperLang.language } : {}),
2583
+ // Omitted when off, so a non-translate run's key stays byte-identical to
2584
+ // every pre-flag key file (the dictionary posture).
2585
+ ...(opts.whisperTranslate === true ? { translate: true } : {}),
2307
2586
  // Omitted when empty, not written as [] — pre-dictionary key files have
2308
2587
  // no `dictionary` at all, and transcriptCacheReusable reads absent and
2309
2588
  // empty as the same "no biasing", so old workdirs must not re-transcribe.
@@ -2374,6 +2653,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2374
2653
  language: requestedKey.language,
2375
2654
  // Vocabulary biasing (F4) — undefined for an empty dictionary, so
2376
2655
  // the spawned args stay byte-identical to every pre-dictionary run.
2656
+ // From the KEY, like the language: whatever re-keys the cache is
2657
+ // what actually ran, so the two can never disagree.
2658
+ ...(requestedKey.translate === true ? { translate: true } : {}),
2377
2659
  prompt: whisperPromptFor(dictionary),
2378
2660
  },
2379
2661
  audioPath,
@@ -2713,11 +2995,52 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2713
2995
  let scenes: Scene[] = [];
2714
2996
  /** Editorial output kept for the cover (§31): hook + its thumbnail form. */
2715
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;
2716
3006
  /** The graphics accounting line for report.txt (§118b), and the beat-sheet
2717
3007
  * issues that explain it. Cached alongside the beat sheet so a cached
2718
3008
  * re-run's report keeps the accounting instead of erasing it (§78). */
2719
3009
  let graphicsLine: string | undefined;
2720
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 };
2721
3044
  /** Who planned this run (R16 §78) — stamped into production.json below. */
2722
3045
  let producerStamp: Production["producer"];
2723
3046
  /** The resolved `--clip` window (R19 §93) — set only on a clip run; feeds
@@ -2900,6 +3223,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2900
3223
  // cache under the post-resolution key so re-runs and replays hit it.
2901
3224
  scenes = clipFresh.scenes;
2902
3225
  beatSheet = { hook: clipFresh.beatSheet.hook, coverText: clipFresh.beatSheet.coverText };
3226
+ fullSheet = clipFresh.beatSheet;
2903
3227
  console.log(`▸ hook: ${clipFresh.beatSheet.hook}`);
2904
3228
  console.log(
2905
3229
  `▸ planned ${clipFresh.beatSheet.moments.length} moments, ${scenes.length} scenes` +
@@ -2927,7 +3251,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2927
3251
  await writeFile(join(work, `scenes-${adoptKey}.json`), JSON.stringify(scenes, null, 2));
2928
3252
  await writeFile(
2929
3253
  join(work, `beatsheet-${adoptKey}.json`),
2930
- 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
+ ),
2931
3259
  );
2932
3260
  } else if (existsSync(sceneCache)) {
2933
3261
  scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(sceneCache, "utf8")));
@@ -2946,12 +3274,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2946
3274
  const cached = JSON.parse(await readFile(beatCache, "utf8")) as {
2947
3275
  hook: string;
2948
3276
  coverText?: string;
3277
+ moments?: unknown;
2949
3278
  graphics?: string;
2950
3279
  issues?: BeatsValidationIssue[];
2951
3280
  };
2952
3281
  beatSheet = { hook: cached.hook, coverText: cached.coverText };
2953
3282
  graphicsLine = cached.graphics;
2954
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;
2955
3297
  }
2956
3298
  } else {
2957
3299
  const aiAnim = isInteractive()
@@ -2998,6 +3340,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2998
3340
  if (aiAnim) aiAnim.stop();
2999
3341
  scenes = result.scenes;
3000
3342
  beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
3343
+ fullSheet = result.beatSheet;
3001
3344
  console.log(`▸ hook: ${result.beatSheet.hook}`);
3002
3345
  console.log(
3003
3346
  `▸ planned ${result.beatSheet.moments.length} moments, ${scenes.length} scenes` +
@@ -3024,8 +3367,136 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3024
3367
  await writeFile(join(work, `scenes-${freshKey}.json`), JSON.stringify(scenes, null, 2));
3025
3368
  await writeFile(
3026
3369
  join(work, `beatsheet-${freshKey}.json`),
3027
- 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
+ ),
3375
+ );
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",
3028
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
+ }
3029
3500
  }
3030
3501
  }
3031
3502
 
@@ -3798,6 +4269,77 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3798
4269
  );
3799
4270
  }
3800
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
+
3801
4343
  const production: Production = {
3802
4344
  version: 1,
3803
4345
  // `originalInput`, not `input`: for a folder run `input` is by now
@@ -3827,9 +4369,18 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3827
4369
  ? { clip: { targetSec: clipTargetSec, ...clipWindow } }
3828
4370
  : {}),
3829
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,
3830
4377
  producer: producerStamp,
3831
4378
  theme,
3832
- render: { ...frame, fps: 30 },
4379
+ // The EFFECTIVE output size, not the base frame (`--resolution`): this is
4380
+ // what the file on disk actually is, and it is read downstream by the
4381
+ // mezzanine's scale decision, the NLE exports and the editor — all of
4382
+ // which would otherwise describe a 1080p file that isn't there.
4383
+ render: { width: output.width, height: output.height, fps: 30 },
3833
4384
  };
3834
4385
  await writeFile(join(work, "production.json"), JSON.stringify(production, null, 2));
3835
4386
 
@@ -3910,6 +4461,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3910
4461
  .map((i) => ` ⚠ moment ${i.moment}: ${i.issue}\n`)
3911
4462
  .join("");
3912
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
+ }
3913
4474
  if (provider) {
3914
4475
  report += formatUsageReport(provider.usage, cfg.pricing);
3915
4476
  // A cached run has no usage block to print, and used to leave the report
@@ -4383,7 +4944,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4383
4944
  // ~/.ossclip/config.json's theme the first time anyone touched a color.
4384
4945
  baseTheme: configBaseTheme,
4385
4946
  baseCaptionLines,
4386
- settings: production.render,
4947
+ // The COMPOSITION's size, which is the BASE frame — never
4948
+ // `production.render` (2026-08-27). `production.render` describes the
4949
+ // FILE, and under `--resolution` those differ by the render's `scale`:
4950
+ // Remotion enlarges this composition by that factor, so sizing the
4951
+ // composition from the scaled dims applies it TWICE (2160×3840 became
4952
+ // 4320×7680 frames, which h264_videotoolbox refuses outright, at stitch
4953
+ // time, after every frame had been paid for). The Player reads these too,
4954
+ // and previewing at the base size is what keeps the editor cheap.
4955
+ settings: { ...frame, fps: production.render.fps },
4387
4956
  outputDurationSec: map.outputDuration,
4388
4957
  // The aspect travels with the measurement because the crop math needs it:
4389
4958
  // `object-fit: cover` spills vertically for a portrait source and
@@ -4494,6 +5063,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4494
5063
  // actually render.
4495
5064
  ...(captionsHidden ? { captionsHidden: true } : {}),
4496
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 } : {}),
4497
5072
  };
4498
5073
  await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
4499
5074
 
@@ -4665,6 +5240,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4665
5240
  portrait,
4666
5241
  audience,
4667
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,
4668
5253
  });
4669
5254
  // produce is the ONLY command that may write command.json — edit.ts's §129
4670
5255
  // heal prepends the "produce" literal to any record that doesn't start
@@ -4890,6 +5475,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4890
5475
  outPath: rawPath,
4891
5476
  browserExecutable: cfg.browserExecutable,
4892
5477
  concurrency: renderConcurrency.concurrency,
5478
+ // The composition stays 1080-wide and Remotion renders it larger
5479
+ // (`--resolution`) — rebuilding the stage at 2160 would keep
5480
+ // `captionFontSizeFor`'s absolute 64px and draw quarter-size captions.
5481
+ scale: output.scale,
4893
5482
  cancelSignal: renderCancel.cancelSignal,
4894
5483
  onPhase: (phase: RenderPhase) => {
4895
5484
  signalPhase = renderSignalPhaseOf(phase);