ossclip 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/produce.ts CHANGED
@@ -124,6 +124,18 @@ import {
124
124
  makeMezzanine,
125
125
  mezzanineFileName,
126
126
  mezzanineScale,
127
+ // The color-grade pipeline (2026-08-30): validation, the preset/LUT split,
128
+ // the SVG filter spec preset grades ride render-props as, and the .cube
129
+ // bake+hash LUT grades ride the mezzanine as.
130
+ CONFIG_DIR,
131
+ resolveColorGrade,
132
+ resolveGradeToLook,
133
+ gradeToSvgFilterSpec,
134
+ parseCubeLut,
135
+ bakeCube,
136
+ lutHash,
137
+ type ColorGrade,
138
+ type SvgGradeFilterSpec,
127
139
  scaleContentTimeline,
128
140
  scaleFramingWindows,
129
141
  measureFace,
@@ -156,6 +168,8 @@ import {
156
168
  sliceTranscript,
157
169
  type Analysis,
158
170
  type AppliedRepair,
171
+ type BeatSheet,
172
+ BeatSheetSchema,
159
173
  type BeatsValidationIssue,
160
174
  type CleanupLevel,
161
175
  type ClipWindow,
@@ -170,6 +184,34 @@ import {
170
184
  RESOLUTION_CHOICES,
171
185
  smallestSource,
172
186
  ResolutionChoiceSchema,
187
+ // The --sfx path (2026-08-29): the library loader, the placement call, its
188
+ // deterministic gate, and the resolver that turns word anchors into cues.
189
+ loadSfxLibrary,
190
+ sfxLibraryHash,
191
+ // The `sfxBundledPack` config gate. It lives in core, not beside `resolveSfx`
192
+ // below, because the edit server has to resolve it identically — its own
193
+ // doc-comment has the reason.
194
+ resolveSfxBundledPack,
195
+ generateSfxPlan,
196
+ resolveSfxCues,
197
+ // Scene id → final start second, the scene-anchored placements' clock
198
+ // (2026-08-29). Built from `sceneCues`, never from the raw plan.
199
+ sceneStartSeconds,
200
+ // The user's layer over that plan (Phase 3), and the schema the carried
201
+ // forward plan is parsed back through.
202
+ applySfxOverrides,
203
+ ProductionSfxSchema,
204
+ sfxStagedFile,
205
+ formatSfxAccounting,
206
+ SfxLevelSchema,
207
+ SfxPlanSchema,
208
+ SfxValidationIssueSchema,
209
+ SFX_PROMPT_VERSION,
210
+ type LoadedSfxSound,
211
+ type SfxCue,
212
+ type SfxLevel,
213
+ type SfxPlacement,
214
+ type SfxValidationIssue,
173
215
  type ProviderName,
174
216
  type ResolutionChoice,
175
217
  type Scene,
@@ -417,6 +459,38 @@ export function existingProducerStamp(work: string): Production["producer"] | un
417
459
  }
418
460
  }
419
461
 
462
+ /**
463
+ * The `sfx` plan the workdir's LAST run wrote, or undefined (Phase 3).
464
+ *
465
+ * This is how a `--scenes` replay keeps its sound design. The editor's Render
466
+ * pins the reviewed plan with `--scenes` and drops `--produce`
467
+ * (render-replay-args.ts), so the run never reaches the placement call — and
468
+ * the placement call is not what should decide, because the SCENES-REVIEWED
469
+ * doctrine says a render started from the editor must reproduce what the user
470
+ * reviewed. For SFX that reviewed state is exactly `production.json`'s plan
471
+ * plus `overrides.json`'s edits on top of it: re-placing would hand the user a
472
+ * different set of effects than the ones they just dragged, and skipping would
473
+ * hand them silence.
474
+ *
475
+ * Tolerant like `existingProducerStamp` above — a missing, unreadable or
476
+ * sfx-less file all mean "no prior sound design", which is what a first run
477
+ * says too. Parsed through `ProductionSfxSchema`, never cast: production.json
478
+ * is as hand-editable as anything else in the workdir, and a level of "MEME"
479
+ * must not reach the report as a level.
480
+ */
481
+ export function priorSfxPlan(work: string): { level: SfxLevel; placements: SfxPlacement[] } | undefined {
482
+ try {
483
+ const raw = JSON.parse(readFileSync(join(work, "production.json"), "utf8")) as {
484
+ sfx?: unknown;
485
+ };
486
+ if (raw.sfx === undefined) return undefined;
487
+ const parsed = ProductionSfxSchema.safeParse(raw.sfx);
488
+ return parsed.success ? parsed.data : undefined;
489
+ } catch {
490
+ return undefined;
491
+ }
492
+ }
493
+
420
494
  export function beatCacheKeyCandidates(
421
495
  parts: Omit<Parameters<typeof beatSheetCacheKey>[0], "providerName">,
422
496
  providerName: string,
@@ -663,6 +737,16 @@ export interface ProduceOptions {
663
737
  * ignore an uploaded cover.
664
738
  */
665
739
  coverInVideo?: boolean;
740
+ /**
741
+ * `--color-grade <look>` / `--no-color-grade` — the watermark's tri-state
742
+ * carrying a VALUE: a string when typed (a preset id, or a `.cube`
743
+ * filename — `colorGradeFlagValue` classifies by extension), `false` for a
744
+ * typed --no-color-grade, undefined when neither so overrides.json and
745
+ * then the config's `colorGrade` decide (`resolveProductionColorGrade`).
746
+ * Deliberately unparsed in transit: validation warns-and-proceeds at the
747
+ * use site, because a grade typo must cost the look, never the run.
748
+ */
749
+ colorGrade?: string | false;
666
750
  /**
667
751
  * `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
668
752
  * true/false when TYPED, undefined when not — undefined lets the config's
@@ -715,6 +799,21 @@ export interface ProduceOptions {
715
799
  * a file input.
716
800
  */
717
801
  sort?: "name" | "mtime";
802
+ /**
803
+ * `--sfx` (2026-08-29): place sound effects from the loaded pack on the
804
+ * beats the producer planned. Tri-state like `watermark` — true/false when
805
+ * TYPED, undefined when not, so the config's `sfx` key can supply the
806
+ * default (`resolveSfx`). Requires a beat sheet, so it rides `--produce`;
807
+ * without one the step says so and skips.
808
+ */
809
+ sfx?: boolean;
810
+ /**
811
+ * `--sfx-level`: how much sound design (`subtle | normal | meme`). Already
812
+ * zod-parsed to the union by program.ts, which also folds the implication
813
+ * (`sfxFlag`: a typed level turns `sfx` on); the CONFIG's `sfxLevel` arrives
814
+ * separately as an unvalidated value and `resolveSfxLevel` arbitrates.
815
+ */
816
+ sfxLevel?: SfxLevel;
718
817
  /**
719
818
  * Whether `--sort` was TYPED, as opposed to commander filling in its
720
819
  * `"name"` default. `sort` alone can't tell those apart, and `--sort` does
@@ -784,6 +883,168 @@ export function resolveCoverInVideo(
784
883
  return flag ?? configValue === true;
785
884
  }
786
885
 
886
+ /**
887
+ * `--color-grade`'s value classified into the ColorGradeSchema shape: a value
888
+ * ending in `.cube` names a LUT file in `~/.ossclip/luts`, anything else
889
+ * names a preset. Sniffed by extension rather than split into two flags
890
+ * because the user already knows which they typed — `kodak.cube` cannot be a
891
+ * preset id (presets never carry a dot) and a preset id cannot be a LUT
892
+ * (`.cube` is the one format the parser reads), so the classification is
893
+ * lossless. Case-insensitive on the extension: `KODAK.CUBE` is the same file
894
+ * on the case-preserving filesystems the LUT dir lives on. Validation is NOT
895
+ * here — the shape goes through `resolveColorGrade` like every other layer.
896
+ */
897
+ export function colorGradeFlagValue(value: string): { preset?: string; lut?: string } {
898
+ return value.toLowerCase().endsWith(".cube") ? { lut: value } : { preset: value };
899
+ }
900
+
901
+ /**
902
+ * The effective color grade across all three surfaces — override > flag >
903
+ * config, `resolveWatermark`'s typed-beats-config precedence grown one layer:
904
+ * the overrides doc is the editor's per-project say, so it beats even a typed
905
+ * flag (the `resolveSrcTimingPins` rationale — a per-project decision made in
906
+ * the editor outranks a per-run flag, never merges with it). An explicit
907
+ * `false` at a switching layer (`colorGrade: false` in the doc, or a typed
908
+ * `--no-color-grade`) is OFF, not fall-through: "no grade" is a decision, and
909
+ * letting a lower layer overrule it would make the disable impossible to
910
+ * express.
911
+ *
912
+ * Every layer is validated through `resolveColorGrade`, and an INVALID layer
913
+ * is ignored — warned about by name, then the NEXT layer applies (decision
914
+ * 2026-08-30): the alternative, an invalid override going all the way to
915
+ * "off", would let one stale editor write silently strip the config grade a
916
+ * channel's whole look depends on. Warnings are RETURNED, not printed
917
+ * (`resolveSfxLevel`'s shape), so the whole matrix is testable without a TTY.
918
+ * `source` names the winning layer so the ▸ line can say where a grade came
919
+ * from — the watermark's "(from config; --no-… overrides)" visibility rule.
920
+ */
921
+ export function resolveProductionColorGrade(p: {
922
+ override: ColorGrade | false | undefined;
923
+ flag: string | false | undefined;
924
+ config: unknown;
925
+ }): { grade?: ColorGrade; source?: "override" | "flag" | "config"; warnings: string[] } {
926
+ const warnings: string[] = [];
927
+ if (p.override === false) return { warnings };
928
+ if (p.override !== undefined) {
929
+ // Schema-valid already (OverrideDocSchema parsed the doc), but the
930
+ // unknown-preset check lives in resolveColorGrade, not the schema — this
931
+ // is the layer where a preset the editor knew and this build doesn't
932
+ // falls through instead of failing the doc.
933
+ const r = resolveColorGrade(p.override, "overrides.json");
934
+ if (r.grade) return { grade: r.grade, source: "override", warnings };
935
+ if (r.warning) warnings.push(r.warning);
936
+ }
937
+ if (p.flag === false) return { warnings };
938
+ if (p.flag !== undefined) {
939
+ const r = resolveColorGrade(colorGradeFlagValue(p.flag), "--color-grade");
940
+ if (r.grade) return { grade: r.grade, source: "flag", warnings };
941
+ if (r.warning) warnings.push(r.warning);
942
+ }
943
+ // "config", not "config colorGrade": resolveColorGrade's warning already
944
+ // spells the key (`⚠ <source> colorGrade ignored — …`).
945
+ const r = resolveColorGrade(p.config, "config");
946
+ if (r.grade) return { grade: r.grade, source: "config", warnings };
947
+ if (r.warning) warnings.push(r.warning);
948
+ return { warnings };
949
+ }
950
+
951
+ /**
952
+ * `--sfx-level` implies `--sfx`: typing a level is asking for sound effects,
953
+ * and a run that quietly did nothing because the boolean was missing is the
954
+ * worst possible reading of it. Returns the tri-state `--sfx` carries —
955
+ * `undefined` when neither was typed, so the config's `sfx` key still gets its
956
+ * turn (`resolveSfx`). Pure, so the implication is testable without commander,
957
+ * the `jumpCutsFlag` posture.
958
+ */
959
+ export function sfxFlag(
960
+ sfx: boolean | undefined,
961
+ sfxLevel: SfxLevel | undefined,
962
+ ): boolean | undefined {
963
+ return sfxLevel !== undefined ? true : sfx;
964
+ }
965
+
966
+ /**
967
+ * The effective `--sfx` switch — `resolveCoverInVideo`'s semantics verbatim: a
968
+ * TYPED flag wins, and only then does the config's `sfx` supply the default,
969
+ * `=== true` rather than truthy so a hand-edited `"sfx": "yes"` cannot coerce
970
+ * sound effects onto every render.
971
+ */
972
+ export function resolveSfx(flag: boolean | undefined, configValue: unknown): boolean {
973
+ return flag ?? configValue === true;
974
+ }
975
+
976
+ /**
977
+ * The effective `--sfx-level`. `resolveLlmEffort`'s shape — the warning is
978
+ * RETURNED, not printed, so the resolution stays pure — and its precedence: a
979
+ * typed flag beats a config key, and a malformed config key costs a warning
980
+ * and the default rather than a coerced level. The default matters: `meme`
981
+ * unlocks the meme-tagged sounds, so a typo must never fall UP into it.
982
+ */
983
+ export function resolveSfxLevel(
984
+ flag: SfxLevel | undefined,
985
+ configValue: unknown,
986
+ ): { level: SfxLevel; warning?: string } {
987
+ if (flag !== undefined) return { level: flag };
988
+ if (configValue === undefined) return { level: "normal" };
989
+ const parsed = SfxLevelSchema.safeParse(configValue);
990
+ if (parsed.success) return { level: parsed.data };
991
+ return {
992
+ level: "normal",
993
+ warning: `⚠ config sfxLevel ignored — expected ${SfxLevelSchema.options.join("|")}, using normal`,
994
+ };
995
+ }
996
+
997
+ /**
998
+ * What `sfx-<key>.json` holds: the normalized plan plus the accounting a
999
+ * cached re-run would otherwise have to invent — `planned` is the count the
1000
+ * MODEL returned, which nothing on disk could re-derive, and `issues` are the
1001
+ * planning drops the report explains the shortfall with (the beat-sheet
1002
+ * cache's `graphics`/`issues` rule, §78).
1003
+ *
1004
+ * Parsed on read, never trusted: a workdir file is as hand-editable as
1005
+ * anything else here, and a mangled one must cost a re-plan, not a crash.
1006
+ */
1007
+ const SfxPlanCacheSchema = SfxPlanSchema.extend({
1008
+ planned: z.number().int().nonnegative().default(0),
1009
+ issues: z.array(SfxValidationIssueSchema).default([]),
1010
+ });
1011
+
1012
+ /**
1013
+ * The placement-plan cache key: everything that changes the PLAN — which
1014
+ * prompt asked, about which words, against which beat sheet, at what level,
1015
+ * over which library. The §78 posture the beat sheet's key carries, and pure
1016
+ * for the same reason: "a change that changes the answer must change the key"
1017
+ * has to be assertable without a workdir or an LLM.
1018
+ *
1019
+ * `beatKey` folds the whole beat-sheet key in (provider, model, intent,
1020
+ * framing, clip window…) rather than restating it: the graphics plan is IN the
1021
+ * placement prompt, so a re-plan of the beats is a different question about
1022
+ * the same words. `libraryHash` is pack METADATA only (`sfxLibraryHash`), so
1023
+ * dropping a user pack in `~/.ossclip/sfx` invalidates while re-encoding an
1024
+ * mp3 does not.
1025
+ */
1026
+ export function sfxCacheKey(parts: {
1027
+ promptVersion: number;
1028
+ beatKey: string;
1029
+ level: SfxLevel;
1030
+ libraryHash: string;
1031
+ /** The repaired transcript's TEXT — the beat key's rule, for the same reason. */
1032
+ words: readonly string[];
1033
+ }): string {
1034
+ return createHash("sha1")
1035
+ .update(
1036
+ JSON.stringify([
1037
+ parts.promptVersion,
1038
+ parts.beatKey,
1039
+ parts.level,
1040
+ parts.libraryHash,
1041
+ parts.words,
1042
+ ]),
1043
+ )
1044
+ .digest("hex")
1045
+ .slice(0, 8);
1046
+ }
1047
+
787
1048
  /**
788
1049
  * Where the cover image lives right now, most-specific first — the EDITOR
789
1050
  * panel's ladder (`currentCoverImage` in edit.ts), deliberately the same one:
@@ -2821,11 +3082,52 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2821
3082
  let scenes: Scene[] = [];
2822
3083
  /** Editorial output kept for the cover (§31): hook + its thumbnail form. */
2823
3084
  let beatSheet: { hook: string; coverText?: string } | undefined;
3085
+ /**
3086
+ * The FULL sheet, moments included — the graphics plan the SFX call needs in
3087
+ * context (a whoosh syncs with a graphic entrance). Separate from
3088
+ * `beatSheet` above, which is deliberately the cover's two fields: this one
3089
+ * exists only while a plan is in hand this run, and is undefined on the
3090
+ * paths that never had one (`--scenes`, a pre-`--sfx` scenes cache).
3091
+ */
3092
+ let fullSheet: BeatSheet | undefined;
2824
3093
  /** The graphics accounting line for report.txt (§118b), and the beat-sheet
2825
3094
  * issues that explain it. Cached alongside the beat sheet so a cached
2826
3095
  * re-run's report keeps the accounting instead of erasing it (§78). */
2827
3096
  let graphicsLine: string | undefined;
2828
3097
  let beatIssues: BeatsValidationIssue[] = [];
3098
+ /**
3099
+ * The `--sfx` plan: the level it was planned at and the placements that
3100
+ * survived `normalizeSfxPlan`, stored on production.json and resolved to
3101
+ * cues once the cut is final (`resolveSfxCues`). Undefined = this run has no
3102
+ * sound design at all, which is what an absent `sfx` field means.
3103
+ */
3104
+ let sfxPlan: { level: SfxLevel; placements: SfxPlacement[] } | undefined;
3105
+ /** The library the plan was made against — the resolver needs the same
3106
+ * sounds (gain, absPath) it planned over, and the stager needs their files. */
3107
+ let sfxSounds: LoadedSfxSound[] = [];
3108
+ /** Planning drops + the count the MODEL returned, carried to the one
3109
+ * accounting line the console and report.txt share (§118b's contract). */
3110
+ let sfxIssues: SfxValidationIssue[] = [];
3111
+ let sfxPlanned = 0;
3112
+ /** Whether the placement step actually ran — see the notice below the
3113
+ * scenes block for the runs where `--sfx` cannot reach it. */
3114
+ let sfxAttempted = false;
3115
+ // Resolved HERE, above the branch that uses it, so BOTH the placement step
3116
+ // and the "this run has no beat sheet" notice read one answer. Typed beats
3117
+ // config for the switch; the level is zod-parsed out of the config, never
3118
+ // coerced (`resolveSfxLevel`).
3119
+ const sfxOn = resolveSfx(opts.sfx, cfg.sfx);
3120
+ const sfxLevelResolved = resolveSfxLevel(opts.sfxLevel, cfg.sfxLevel);
3121
+ if (sfxOn && sfxLevelResolved.warning) console.log(sfxLevelResolved.warning);
3122
+ const sfxLevel = sfxLevelResolved.level;
3123
+ // Which packs this machine offers (`sfxBundledPack`) — resolved next to the
3124
+ // level, and for the same reason: BOTH library loads below (the placement
3125
+ // step and the carry-forward branch) must read one answer, or a run would
3126
+ // plan against one library and stage from another.
3127
+ const sfxBundled = resolveSfxBundledPack(cfg.sfxBundledPack);
3128
+ if (sfxOn && sfxBundled.warning) console.log(sfxBundled.warning);
3129
+ /** The loader's opts, shared by both library loads. */
3130
+ const sfxLoad = { includeBundled: sfxBundled.include };
2829
3131
  /** Who planned this run (R16 §78) — stamped into production.json below. */
2830
3132
  let producerStamp: Production["producer"];
2831
3133
  /** The resolved `--clip` window (R19 §93) — set only on a clip run; feeds
@@ -3008,6 +3310,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3008
3310
  // cache under the post-resolution key so re-runs and replays hit it.
3009
3311
  scenes = clipFresh.scenes;
3010
3312
  beatSheet = { hook: clipFresh.beatSheet.hook, coverText: clipFresh.beatSheet.coverText };
3313
+ fullSheet = clipFresh.beatSheet;
3011
3314
  console.log(`▸ hook: ${clipFresh.beatSheet.hook}`);
3012
3315
  console.log(
3013
3316
  `▸ planned ${clipFresh.beatSheet.moments.length} moments, ${scenes.length} scenes` +
@@ -3035,7 +3338,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3035
3338
  await writeFile(join(work, `scenes-${adoptKey}.json`), JSON.stringify(scenes, null, 2));
3036
3339
  await writeFile(
3037
3340
  join(work, `beatsheet-${adoptKey}.json`),
3038
- JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
3341
+ JSON.stringify(
3342
+ { ...beatSheet, moments: fullSheet.moments, graphics: graphicsLine, issues: beatIssues },
3343
+ null,
3344
+ 2,
3345
+ ),
3039
3346
  );
3040
3347
  } else if (existsSync(sceneCache)) {
3041
3348
  scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(sceneCache, "utf8")));
@@ -3054,12 +3361,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3054
3361
  const cached = JSON.parse(await readFile(beatCache, "utf8")) as {
3055
3362
  hook: string;
3056
3363
  coverText?: string;
3364
+ moments?: unknown;
3057
3365
  graphics?: string;
3058
3366
  issues?: BeatsValidationIssue[];
3059
3367
  };
3060
3368
  beatSheet = { hook: cached.hook, coverText: cached.coverText };
3061
3369
  graphicsLine = cached.graphics;
3062
3370
  beatIssues = cached.issues ?? [];
3371
+ // The MOMENTS ride the cache too (2026-08-29, --sfx): this file used to
3372
+ // keep only the cover's two fields, so a cached run had no graphics
3373
+ // plan to put in front of the placement call — and "produce once, add
3374
+ // --sfx later" is the normal way anyone reaches this feature. Parsed,
3375
+ // not trusted (a hand-edited or pre-`--sfx` file simply has no
3376
+ // `moments`), and its absence costs the SFX step alone: everything the
3377
+ // cache did before this is read above and unaffected.
3378
+ const sheet = BeatSheetSchema.safeParse({
3379
+ hook: cached.hook,
3380
+ coverText: cached.coverText,
3381
+ moments: cached.moments,
3382
+ });
3383
+ if (sheet.success) fullSheet = sheet.data;
3063
3384
  }
3064
3385
  } else {
3065
3386
  const aiAnim = isInteractive()
@@ -3106,6 +3427,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3106
3427
  if (aiAnim) aiAnim.stop();
3107
3428
  scenes = result.scenes;
3108
3429
  beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
3430
+ fullSheet = result.beatSheet;
3109
3431
  console.log(`▸ hook: ${result.beatSheet.hook}`);
3110
3432
  console.log(
3111
3433
  `▸ planned ${result.beatSheet.moments.length} moments, ${scenes.length} scenes` +
@@ -3132,9 +3454,137 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3132
3454
  await writeFile(join(work, `scenes-${freshKey}.json`), JSON.stringify(scenes, null, 2));
3133
3455
  await writeFile(
3134
3456
  join(work, `beatsheet-${freshKey}.json`),
3135
- JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
3457
+ JSON.stringify(
3458
+ { ...beatSheet, moments: fullSheet.moments, graphics: graphicsLine, issues: beatIssues },
3459
+ null,
3460
+ 2,
3461
+ ),
3136
3462
  );
3137
3463
  }
3464
+
3465
+ // ---- Sound effects (`--sfx`): the placement call -----------------------
3466
+ // HERE, inside the producer branch, because the placement prompt needs the
3467
+ // graphics plan in front of it (Approach A: a whoosh syncs with a graphic
3468
+ // entrance) — and because a beat sheet is the one thing this feature
3469
+ // cannot do without. `--scenes` and a plain run never reach this block;
3470
+ // the notice for that case is printed by the caller below.
3471
+ if (sfxOn) {
3472
+ sfxAttempted = true;
3473
+ const library = loadSfxLibrary(sfxLoad);
3474
+ for (const issue of library.issues) console.log(` ⚠ sfx pack ${issue.pack}: ${issue.issue}`);
3475
+ if (library.sounds.length === 0) {
3476
+ // Warn and continue, never kill: an empty library is a packaging or a
3477
+ // user-pack problem, and it costs sound effects, not the video.
3478
+ console.log(" ⚠ sfx: no usable sounds in the library — skipping sound effects");
3479
+ } else {
3480
+ sfxSounds = library.sounds;
3481
+ const sfxKey = sfxCacheKey({
3482
+ promptVersion: SFX_PROMPT_VERSION,
3483
+ // The beat key, not its parts: the graphics plan is IN this prompt,
3484
+ // so a different sheet is a different question (see `sfxCacheKey`).
3485
+ beatKey: hitKey,
3486
+ level: sfxLevel,
3487
+ libraryHash: sfxLibraryHash(library.sounds),
3488
+ words: beatKeyParts.words,
3489
+ });
3490
+ const sfxCache = join(work, `sfx-${sfxKey}.json`);
3491
+ if (existsSync(sfxCache)) {
3492
+ // Parsed, never trusted — a cache file is as hand-editable as any
3493
+ // other artefact in the workdir. A malformed one is a re-plan, not a
3494
+ // crash, so it falls through to the call below.
3495
+ const cached = SfxPlanCacheSchema.safeParse(
3496
+ JSON.parse(await readFile(sfxCache, "utf8")),
3497
+ );
3498
+ if (cached.success) {
3499
+ sfxPlan = { level: sfxLevel, placements: cached.data.placements };
3500
+ sfxPlanned = cached.data.planned;
3501
+ sfxIssues = cached.data.issues;
3502
+ console.log(`▸ sfx cached (${sfxPlan.placements.length} placement(s), level ${sfxLevel})`);
3503
+ }
3504
+ }
3505
+ if (!sfxPlan && fullSheet === undefined) {
3506
+ // The one case the cache cannot cover: a workdir planned before
3507
+ // `--sfx` existed (or by a pre-2026-08-29 build) has scenes but no
3508
+ // moments, so there is no graphics plan to place against. Say what
3509
+ // to do rather than silently rendering a mute video.
3510
+ console.log(
3511
+ " ⚠ sfx: this workdir's cached plan has no beat sheet to place against — " +
3512
+ "re-plan (delete its scenes-*.json) to get sound effects",
3513
+ );
3514
+ } else if (!sfxPlan) {
3515
+ console.log(`▸ placing sound effects (level ${sfxLevel})…`);
3516
+ const result = await phases.time("llm", () =>
3517
+ generateSfxPlan(provider!, transcript, fullSheet!, library.sounds, sfxLevel),
3518
+ );
3519
+ sfxPlan = { level: sfxLevel, placements: result.plan.placements };
3520
+ sfxPlanned = result.planned;
3521
+ sfxIssues = result.issues;
3522
+ await writeFile(
3523
+ sfxCache,
3524
+ // The accounting rides the cache for §78's reason, the same one
3525
+ // `graphics`/`issues` ride the beat-sheet cache: a cached re-run
3526
+ // must be able to print the SAME accounting line rather than
3527
+ // erasing it, and `planned` is a count only the call itself knew.
3528
+ JSON.stringify(
3529
+ { placements: sfxPlan.placements, planned: sfxPlanned, issues: sfxIssues },
3530
+ null,
3531
+ 2,
3532
+ ),
3533
+ );
3534
+ }
3535
+ for (const issue of sfxIssues) {
3536
+ console.log(` ⚠ sfx placement ${issue.placement}: ${issue.issue}`);
3537
+ }
3538
+ }
3539
+ }
3540
+ }
3541
+
3542
+ // `--sfx` on a run that never reached the placement call — `--scenes` (which
3543
+ // is what the editor's Render replays), or a plain cut with no producer at
3544
+ // all.
3545
+ //
3546
+ // The plan carries FORWARD from the workdir's last `production.json` here
3547
+ // (Phase 3), and that is the scenes-reviewed doctrine applied to sound: an
3548
+ // editor render replays the REVIEWED state, and for SFX the reviewed state
3549
+ // is the prior plan plus overrides.json's edits on top of it (`priorSfxPlan`
3550
+ // has the full argument). The level comes from that record too — the user
3551
+ // reviewed effects placed at THAT level, and re-reading `--sfx-level` here
3552
+ // would describe them with a number they were never planned under. It is
3553
+ // written back below like any other run's plan, so a chain of editor renders
3554
+ // never breaks.
3555
+ //
3556
+ // No prior record is the only case left with nothing to carry: never a
3557
+ // silent ignore, and the message names the missing half rather than the flag
3558
+ // the user typed.
3559
+ if (sfxOn && !sfxAttempted) {
3560
+ const prior = priorSfxPlan(work);
3561
+ if (prior === undefined) {
3562
+ console.log(
3563
+ " ⚠ sfx: sound effects are placed against the producer's beat sheet — " +
3564
+ "add --produce (the editor's re-plan) to get them",
3565
+ );
3566
+ } else {
3567
+ // The same library the producer branch loads, and for the same two
3568
+ // consumers: the resolver needs each sound's gain and path, the stager
3569
+ // needs its file. An empty library costs the effects, never the video.
3570
+ const library = loadSfxLibrary(sfxLoad);
3571
+ for (const issue of library.issues) console.log(` ⚠ sfx pack ${issue.pack}: ${issue.issue}`);
3572
+ if (library.sounds.length === 0) {
3573
+ console.log(" ⚠ sfx: no usable sounds in the library — skipping sound effects");
3574
+ } else {
3575
+ sfxSounds = library.sounds;
3576
+ sfxPlan = prior;
3577
+ // `planned` is the reviewed plan's own size: this run made no model
3578
+ // call, so the only honest denominator for "N of M planned placed" is
3579
+ // what the user approved (the cached-plan branch's rule — the count
3580
+ // belongs to the plan, not to the run).
3581
+ sfxPlanned = prior.placements.length;
3582
+ console.log(
3583
+ `▸ sfx carried forward from the reviewed plan ` +
3584
+ `(${prior.placements.length} placement(s), level ${prior.level})`,
3585
+ );
3586
+ }
3587
+ }
3138
3588
  }
3139
3589
 
3140
3590
  // Every LLM call is behind us — repair, beat sheet, one per scene — so this
@@ -3906,6 +4356,77 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3906
4356
  );
3907
4357
  }
3908
4358
 
4359
+ // ---- Sound effects: word anchors → cues, and the files they name --------
4360
+ // AFTER the cut is final (`applyUserCuts` above) and after the public dir is
4361
+ // known, because both are inputs: a placement anchored to a word the user's
4362
+ // own cut removed is dropped here, and the copies must land where
4363
+ // `staticFile()` will look.
4364
+ let sfxCues: SfxCue[] = [];
4365
+ /** Planning drops + render drops, counted together by the one accounting
4366
+ * line the console and report.txt share (`formatSfxAccounting`). */
4367
+ let sfxAllIssues: SfxValidationIssue[] = sfxIssues;
4368
+ let sfxLine: string | undefined;
4369
+ if (sfxPlan) {
4370
+ // The user's layer FIRST (Phase 3): retimes, swaps, gains, mutes and the
4371
+ // placements they added themselves, applied before any of the resolver's
4372
+ // arithmetic so a dragged effect is timed, cut-checked and mixed exactly
4373
+ // like a planned one. `sfxPlan` itself is left alone — production.json
4374
+ // stores the MODEL's plan, which is what these edit keys are derived from
4375
+ // (`sfxPlacementKey`), and folding them in would re-key the whole layer on
4376
+ // the next run.
4377
+ const edited = applySfxOverrides(sfxPlan.placements, overrideDoc.sfx);
4378
+ for (const d of edited.dropped) {
4379
+ console.log(
4380
+ d.reason === "stale key"
4381
+ ? ` ⚠ sfx edit "${d.key}" no longer matches any planned placement — ` +
4382
+ `the re-plan dropped it`
4383
+ : ` ⚠ sfx edit "${d.key}" matches more than one placement — applied to the first`,
4384
+ );
4385
+ }
4386
+ // The accounting's denominator moves with the user's layer, because the
4387
+ // layer changes the SIZE of the plan the resolver saw: a mute REMOVES a
4388
+ // placement (it was un-planned by the user, not dropped by the pipeline)
4389
+ // and an add appends one. Without this, muting an effect would print as a
4390
+ // reasonless "1 dropped" and adding one as "6 of 5 planned placed".
4391
+ sfxPlanned += edited.placements.length - sfxPlan.placements.length;
4392
+ const resolved = resolveSfxCues(edited.placements, transcript, map, sfxSounds, {
4393
+ // The real check, injected (the resolver stays pure): a pack deleted
4394
+ // between planning and this render must cost the cue here, not a
4395
+ // Remotion 404 after the render has spent its minutes.
4396
+ exists: existsSync,
4397
+ // The scene timing context, from `sceneCues` — the FINAL list, with the
4398
+ // user's moves, trims, splits, pins and deletes already applied. That is
4399
+ // the whole point of the scene link (2026-08-29): a whoosh placed "as
4400
+ // the TitleCard enters" fired at a word, so moving the card in the
4401
+ // editor left the sound behind. Reading the producer's raw `scenes`
4402
+ // here instead would rebuild that bug exactly.
4403
+ sceneStarts: sceneStartSeconds(sceneCues),
4404
+ });
4405
+ sfxCues = resolved.cues;
4406
+ sfxAllIssues = [...sfxIssues, ...resolved.dropped];
4407
+ for (const d of resolved.dropped) console.log(` ⚠ sfx: ${d.issue}`);
4408
+ // Staged into the render's public dir AND the workdir when they differ,
4409
+ // for the Nastaliq font's reason verbatim: the render bundles
4410
+ // `dirname(renderVideo)`, `ossclip edit` serves the workdir, and both
4411
+ // mounts fetch the same served name. Only the sounds actually CUED are
4412
+ // copied — the library is a menu, not a payload.
4413
+ const staged = new Set(sfxCues.map((c) => c.soundFile));
4414
+ for (const sound of sfxSounds) {
4415
+ const rel = sfxStagedFile(sound);
4416
+ if (!staged.has(rel)) continue;
4417
+ for (const dir of new Set([renderPublicDirPath, work])) {
4418
+ // `join` on the filesystem side where `rel` itself stays
4419
+ // POSIX-literal — it is a served URL, these are paths (the
4420
+ // `sideImageDestRel` split).
4421
+ const dest = join(dir, ...rel.split("/"));
4422
+ mkdirSync(dirname(dest), { recursive: true });
4423
+ copyFileSync(sound.absPath, dest);
4424
+ }
4425
+ }
4426
+ sfxLine = formatSfxAccounting(sfxCues.length, sfxPlanned, sfxPlan.level, sfxAllIssues);
4427
+ console.log(`▸ ${sfxLine}`);
4428
+ }
4429
+
3909
4430
  const production: Production = {
3910
4431
  version: 1,
3911
4432
  // `originalInput`, not `input`: for a folder run `input` is by now
@@ -3935,6 +4456,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3935
4456
  ? { clip: { targetSec: clipTargetSec, ...clipWindow } }
3936
4457
  : {}),
3937
4458
  scenes: scenes.length > 0 ? scenes : undefined,
4459
+ // The PLAN, not the cues: word anchors survive a re-cut and the editor
4460
+ // edits them (Phase 3), while `render-props.json` carries the resolved
4461
+ // instants. Absent when this run has no sound design, so a no-`--sfx`
4462
+ // production.json is byte-identical to a pre-feature one.
4463
+ sfx: sfxPlan,
3938
4464
  producer: producerStamp,
3939
4465
  theme,
3940
4466
  // The EFFECTIVE output size, not the base frame (`--resolution`): this is
@@ -4022,6 +4548,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4022
4548
  .map((i) => ` ⚠ moment ${i.moment}: ${i.issue}\n`)
4023
4549
  .join("");
4024
4550
  }
4551
+ // The SFX accounting, the graphics block's contract exactly (§118b): ONE
4552
+ // formatter feeds the console line printed above and this one, so the two
4553
+ // can never say different things about the same run. The drops are listed
4554
+ // under it for the same reason the beat issues are listed under the graphics
4555
+ // line — the number alone does not say WHICH effect went missing.
4556
+ if (sfxLine) {
4557
+ report +=
4558
+ `\n${sfxLine}\n` +
4559
+ sfxAllIssues.map((i) => ` ⚠ placement ${i.placement}: ${i.issue}\n`).join("");
4560
+ }
4025
4561
  if (provider) {
4026
4562
  report += formatUsageReport(provider.usage, cfg.pricing);
4027
4563
  // A cached run has no usage block to print, and used to leave the report
@@ -4265,6 +4801,88 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4265
4801
  // on a bar-free source): there is no re-encode to scale, the render plays
4266
4802
  // the source itself, and the window emissions below must then stay in true
4267
4803
  // source pixels — which the identity `mezzFactor` below guarantees.
4804
+ // ---- Color grade (`--color-grade` / config `colorGrade` / the editor's
4805
+ // overrides.json) ---------------------------------------------------------
4806
+ // Resolved HERE, before the mezzanine encode, because the feature's two
4807
+ // halves split at exactly this seam: a PRESET grade rides render-props as
4808
+ // an SVG filter spec (the props assembly below), while a LUT grade is baked
4809
+ // INTO the mezzanine (ingest.ts's `lut` option) — ffmpeg's lut3d on the
4810
+ // encode pass costs nothing per rendered frame, where a 33³ trilinear
4811
+ // lookup in the browser would. Precedence and validation live in
4812
+ // `resolveProductionColorGrade`; every warning it returns prints once here,
4813
+ // and every failure path proceeds UNGRADED — a grade must cost the look at
4814
+ // worst, never the run.
4815
+ const gradeResolution = resolveProductionColorGrade({
4816
+ override: overrideDoc.colorGrade,
4817
+ flag: opts.colorGrade,
4818
+ config: cfg.colorGrade,
4819
+ });
4820
+ for (const w of gradeResolution.warnings) console.log(w);
4821
+ /** Preset grades: the spec render-props carries (absent = no grade). */
4822
+ let colorGradeSpec: SvgGradeFilterSpec | undefined;
4823
+ /** LUT grades: the baked .cube the mezzanine encode applies (absent = none). */
4824
+ let gradeLut: { path: string; hash: string } | undefined;
4825
+ if (gradeResolution.grade !== undefined) {
4826
+ // The watermark's visibility rule: a grade sourced anywhere but the
4827
+ // typed flag says so, so a config- or editor-sourced look never
4828
+ // surprises the author on upload.
4829
+ const gradeFromNote =
4830
+ gradeResolution.source === "config"
4831
+ ? " (from config; --no-color-grade overrides)"
4832
+ : gradeResolution.source === "override"
4833
+ ? " (editor override)"
4834
+ : "";
4835
+ const resolvedLook = resolveGradeToLook(gradeResolution.grade);
4836
+ if (resolvedLook.kind === "preset") {
4837
+ colorGradeSpec = gradeToSvgFilterSpec(resolvedLook);
4838
+ console.log(`▸ color grade: ${gradeResolution.grade.preset}${gradeFromNote}`);
4839
+ } else if (!mezzanineWillBuild) {
4840
+ // --no-mezzanine on a bar-free source: the render plays the source
4841
+ // file itself, so there is no encode to bake the LUT into. Warn and
4842
+ // proceed ungraded rather than force a mezzanine the user refused.
4843
+ console.log(
4844
+ `⚠ color grade skipped — a .cube LUT is baked into the mezzanine, ` +
4845
+ `and --no-mezzanine means this run doesn't build one`,
4846
+ );
4847
+ } else {
4848
+ try {
4849
+ // Basename only, enforced before any path math: `lut` is a NAME the
4850
+ // schema documents as living in ~/.ossclip/luts, and resolving a
4851
+ // separator-carrying value would turn a config key into a file probe
4852
+ // (the SfxAddedPlacement id's "nothing may ever resolve a path
4853
+ // against it" rule, applied at the one place this name meets the
4854
+ // filesystem).
4855
+ if (basename(resolvedLook.lutRef) !== resolvedLook.lutRef) {
4856
+ throw new Error(
4857
+ `"${resolvedLook.lutRef}" is not a bare filename — LUTs live in ${join(CONFIG_DIR, "luts")}`,
4858
+ );
4859
+ }
4860
+ const lutPath = join(CONFIG_DIR, "luts", resolvedLook.lutRef);
4861
+ const baseLut = parseCubeLut(readFileSync(lutPath, "utf8"));
4862
+ // Tweaks + intensity are baked into the cube (bakeCube composes
4863
+ // `params` on top of the base sample), so the hash keys the WHOLE
4864
+ // grade: change the intensity and the mezzanine filename changes
4865
+ // with it (`mezzanineFileName`'s existence-keyed cache).
4866
+ const cubeText = bakeCube({
4867
+ base: baseLut,
4868
+ params: resolvedLook.tweaks,
4869
+ intensity: resolvedLook.intensity,
4870
+ });
4871
+ const hash = lutHash(cubeText);
4872
+ const bakedPath = join(work, `grade-${hash}.cube`);
4873
+ await writeFile(bakedPath, cubeText);
4874
+ gradeLut = { path: bakedPath, hash };
4875
+ console.log(`▸ color grade: LUT ${resolvedLook.lutRef}${gradeFromNote}`);
4876
+ } catch (err) {
4877
+ // ENOENT and a malformed .cube land here alike: name the problem,
4878
+ // proceed ungraded. parseCubeLut's errors already carry the line.
4879
+ console.log(
4880
+ `⚠ color grade skipped — ${err instanceof Error ? err.message : String(err)}`,
4881
+ );
4882
+ }
4883
+ }
4884
+ }
4885
+
4268
4886
  const mezzScale = mezzanineWillBuild
4269
4887
  ? mezzanineScale(
4270
4888
  { width: contentRect.w, height: contentRect.h, fps: sourceProbe.fps },
@@ -4277,7 +4895,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4277
4895
  // why): mezzanine caching is existence-keyed, so a pre-pass full-res
4278
4896
  // mezzanine.mp4 must not satisfy a run that emits mezzanine-sized
4279
4897
  // windows — the scaled file rebuilds once under its own name.
4280
- const mezz = join(work, mezzanineFileName(!contentRect.full, mezzScale));
4898
+ // `gradeLut?.hash` rides the name so a graded mezzanine can never satisfy
4899
+ // an ungraded run (or vice versa) — the LUT is pixels in the file, and
4900
+ // the cache is existence-keyed.
4901
+ const mezz = join(work, mezzanineFileName(!contentRect.full, mezzScale, gradeLut?.hash));
4281
4902
  if (!existsSync(mezz)) {
4282
4903
  const mezzAnim = isInteractive()
4283
4904
  ? new StageAnimator(
@@ -4298,6 +4919,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4298
4919
  await makeMezzanine(tools, input, mezz, {
4299
4920
  cropVf: cropVf || undefined,
4300
4921
  scale: mezzScale ?? undefined,
4922
+ // The LUT grade's whole delivery: baked into the encode, so the
4923
+ // render (and the editor's preview, which plays the same file) see
4924
+ // graded pixels with no per-frame cost.
4925
+ lut: gradeLut,
4301
4926
  });
4302
4927
  if (mezzAnim) mezzAnim.stop();
4303
4928
  }
@@ -4614,6 +5239,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4614
5239
  // actually render.
4615
5240
  ...(captionsHidden ? { captionsHidden: true } : {}),
4616
5241
  ...(opts.captions === false ? { captionsHiddenByFlag: true } : {}),
5242
+ // `--sfx`, written only when there is something to play (the watermark's
5243
+ // absent-means-off contract): a run without sound effects keeps a
5244
+ // render-props.json — and an audio graph — byte-identical to a pre-feature
5245
+ // one, and the composition reads an absent key as silence, not as an empty
5246
+ // track it still has to mount.
5247
+ ...(sfxCues.length > 0 ? { sfxCues } : {}),
5248
+ // `--color-grade` PRESET looks, written only when one resolved (the
5249
+ // watermark's absent-means-off contract): the two-stage SVG filter spec
5250
+ // ({tableR, tableG, tableB, colorMatrix}, gradeToSvgFilterSpec) the
5251
+ // composition mounts over the video. A LUT grade deliberately writes
5252
+ // NOTHING here — it was baked into the mezzanine above, so the pixels
5253
+ // the renderer plays already carry it, and a spec on top would grade
5254
+ // twice.
5255
+ ...(colorGradeSpec ? { colorGrade: colorGradeSpec } : {}),
4617
5256
  };
4618
5257
  await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
4619
5258
 
@@ -4785,6 +5424,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4785
5424
  portrait,
4786
5425
  audience,
4787
5426
  thumbnailBrief,
5427
+ // The SFX switch and level, RESOLVED (flag + config folded) — the
5428
+ // watermark's rationale on a flag whose default is config-dependent
5429
+ // (`sfx`/`sfxLevel` in ~/.ossclip/config.json). Unpinned, the editor's
5430
+ // Render would place a DIFFERENT amount of sound design the moment that
5431
+ // config is edited, or none at all on a machine without it — and since
5432
+ // the replay carries the reviewed plan forward rather than re-placing
5433
+ // (`priorSfxPlan`), an unpinned `--sfx` is the difference between the
5434
+ // reviewed sound design and a silent video.
5435
+ sfx: sfxOn,
5436
+ sfxLevel,
4788
5437
  });
4789
5438
  // produce is the ONLY command that may write command.json — edit.ts's §129
4790
5439
  // heal prepends the "produce" literal to any record that doesn't start