ossclip 0.1.35 → 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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ossclip editor</title>
7
- <script type="module" crossorigin src="/assets/index-DSB_SCmp.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-pWbFr8vc.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-Bx2VQLP8.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,9 @@
36
36
  "commander": "^12.1.0",
37
37
  "tsx": "^4.19.0",
38
38
  "zod": "^3.25.76",
39
- "@ossclip/core": "0.1.35",
40
- "@ossclip/renderer": "0.1.35",
41
- "@ossclip/scenes": "0.1.35"
39
+ "@ossclip/core": "0.1.36",
40
+ "@ossclip/renderer": "0.1.36",
41
+ "@ossclip/scenes": "0.1.36"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
package/src/cover.ts CHANGED
@@ -468,6 +468,24 @@ export function coverTextHold(args: {
468
468
  `(--cover-text-reset, or deleting ${COVER_PROVENANCE_BASENAME}, goes back to the generated one)`,
469
469
  };
470
470
  }
471
+ // The replay-render gap (field report 2026-08-31): a run without --produce
472
+ // generates NO headline, and letting that empty string win silently
473
+ // downgraded a banner cover to a bare frame on every render-from-the-
474
+ // editor. A persisted headline of ANY source outranks an empty generation
475
+ // — there is no fresher text to prefer. `reset` still goes bare: that is
476
+ // the user explicitly asking for the generated (here: no) headline.
477
+ if (
478
+ !args.reset &&
479
+ args.generated.trim() === "" &&
480
+ args.persisted !== null &&
481
+ args.persisted.text.trim() !== ""
482
+ ) {
483
+ return {
484
+ text: args.persisted.text,
485
+ textSource: args.persisted.textSource,
486
+ message: `▸ cover: keeping the previous headline "${args.persisted.text}" (this run generated none)`,
487
+ };
488
+ }
471
489
  return { text: args.generated, textSource: "beatsheet" };
472
490
  }
473
491
 
package/src/edit.ts CHANGED
@@ -4,7 +4,7 @@ import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
4
4
  import { copyFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
5
5
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
6
6
  import { homedir } from "node:os";
7
- import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { z } from "zod/v4";
10
10
  import {
@@ -56,6 +56,14 @@ import {
56
56
  // Resolved here too, not just in produce: an editor that offered stock sounds
57
57
  // the config excludes would let a user pick one the next render drops.
58
58
  resolveSfxBundledPack,
59
+ // The Color panel's .cube menu (2026-08-30): the same loader produce's LUT
60
+ // bake resolves against, the loadSfxLibrary rule for grades.
61
+ loadLutLibrary,
62
+ // …and the validator every grade layer goes through — the /api/luts payload
63
+ // carries the CONFIG grade so the panel can label its "Default" entry, and
64
+ // a malformed config value must read as "no default" there exactly as it
65
+ // reads in produce (`resolveProductionColorGrade` falls through to off).
66
+ resolveColorGrade,
59
67
  outInsideInputFolderMessage,
60
68
  outPathInsideInput,
61
69
  PORTRAIT_MIME_TYPES,
@@ -119,6 +127,7 @@ import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
119
127
  import { captionRegenProvider } from "./caption-regen-panel";
120
128
  import { binOnPath } from "./llm-detect";
121
129
  import {
130
+ YOUTUBE_PRIVACIES,
122
131
  attachDeliveryMedia,
123
132
  buildPublishPosts,
124
133
  publishConfigured,
@@ -436,6 +445,10 @@ export async function startEditServer(
436
445
  * `unknown` because it is file-only and typed at the consumer
437
446
  * (`resolveSfxBundledPack`), the `audience` rule. */
438
447
  sfxBundledPack?: unknown;
448
+ /** The config-level default grade the Color panel's "Default" entry
449
+ * names — `unknown` because it is file-only and typed at the consumer
450
+ * (`resolveColorGrade`), the `sfxBundledPack` rule. */
451
+ colorGrade?: unknown;
439
452
  } & RetranscribeConfig;
440
453
  /** Env seam for the publish endpoints — tests inject their own so the
441
454
  * runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
@@ -481,6 +494,10 @@ export async function startEditServer(
481
494
  * loader rather than only that the routes serve what they were handed.
482
495
  */
483
496
  loadSfx?: typeof loadSfxLibrary;
497
+ /** The LUT library `/api/luts` serves — the `loadSfx` seam for grades:
498
+ * tests inject a hand-written library instead of depending on whatever
499
+ * the developer keeps in ~/.ossclip/luts. */
500
+ loadLuts?: typeof loadLutLibrary;
484
501
  } = {},
485
502
  ): Promise<EditServer> {
486
503
  // MUTABLE since R17 §83: the server can start with no project (the page
@@ -1971,6 +1988,14 @@ export async function startEditServer(
1971
1988
  // What uploads (the CLI's --delivery): auto (default) builds
1972
1989
  // the cached delivery encode, master sends the untouched render.
1973
1990
  delivery: z.enum(["auto", "master"]).optional(),
1991
+ // YouTube's privacy status (the CLI's --youtube-privacy),
1992
+ // spelled ONCE — the flag's own value list, so the panel and
1993
+ // the CLI can never accept different words. Absent leaves
1994
+ // buildPostsPayload's safe private default alone: until this
1995
+ // rode along, every panel publish landed private with no way
1996
+ // to say otherwise, and two videos the user believed were
1997
+ // published sat private on the channel (2026-08-29).
1998
+ youtubePrivacy: z.enum(YOUTUBE_PRIVACIES).optional(),
1974
1999
  })
1975
2000
  .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1976
2001
  if (!parsed.success) return send(400, { error: parsed.error.message });
@@ -2077,7 +2102,14 @@ export async function startEditServer(
2077
2102
  }
2078
2103
  capGroups = sizeCapGroups(picked);
2079
2104
  }
2080
- const posts = buildPublishPosts(pack, picked).map((p) => ({
2105
+ // Same options object the CLI's publish path passes — undefined
2106
+ // stays undefined so buildPostsPayload's safe private default is
2107
+ // still the ONE place that decides an absent privacy.
2108
+ const posts = buildPublishPosts(pack, picked, {
2109
+ ...(parsed.data.youtubePrivacy !== undefined
2110
+ ? { youtubePrivacy: parsed.data.youtubePrivacy }
2111
+ : {}),
2112
+ }).map((p) => ({
2081
2113
  ...p,
2082
2114
  caption: parsed.data.captions?.[p.target.id] ?? p.caption,
2083
2115
  }));
@@ -2624,6 +2656,39 @@ export async function startEditServer(
2624
2656
  });
2625
2657
  }
2626
2658
 
2659
+ if (url.pathname === "/api/luts" && req.method === "GET") {
2660
+ // The Color panel's .cube menu plus the config-level default grade.
2661
+ // NO workdir guard, /api/sfx/library's rule: both halves are
2662
+ // machine-global (~/.ossclip/luts and config.json), so the panel can
2663
+ // build its dropdown before a project is open. Read per request like
2664
+ // every other loadCfg consumer — a LUT dropped while the editor is
2665
+ // up appears on the next refresh, not on a restart.
2666
+ const library = (opts.loadLuts ?? loadLutLibrary)();
2667
+ // The config grade rides along VALIDATED, not raw: a malformed
2668
+ // config value is what produce ignores (`resolveProductionColorGrade`
2669
+ // warns and proceeds without it), so a "Default (…)" entry built
2670
+ // from it would offer an inherit that renders as nothing. Null means
2671
+ // the panel shows no Default entry, and the warning is dropped for
2672
+ // the sfxLibrary helper's reason — no console here, produce prints
2673
+ // it on the run that grades.
2674
+ const configGrade = resolveColorGrade(
2675
+ (opts.loadCfg ?? loadConfig)().colorGrade,
2676
+ "config",
2677
+ ).grade;
2678
+ return send(200, {
2679
+ // METADATA only, the sfx library rule: the absolute `path` stays
2680
+ // server-side. `file` (the basename, extension and all) is what an
2681
+ // editor-written override must carry — `ColorGrade.lut` documents
2682
+ // the basename, produce joins it against ~/.ossclip/luts verbatim,
2683
+ // and a stem-only id would drop the `.CUBE` an exporter spelled.
2684
+ items: library.items.map((l) => ({ id: l.id, title: l.title, file: basename(l.path) })),
2685
+ // A ~/.ossclip/luts author's only surface, like the sfx panel:
2686
+ // the loader degraded instead of throwing, so show the reason.
2687
+ issues: library.issues,
2688
+ configGrade: configGrade ?? null,
2689
+ });
2690
+ }
2691
+
2627
2692
  if (url.pathname === "/api/sfx/audio" && req.method === "GET") {
2628
2693
  // Click-to-preview. The path comes from the LOADED LIBRARY, never
2629
2694
  // from the client: the query carries an id, the id is looked up, and
@@ -10,7 +10,7 @@ import { produceArgv, type ProduceAnswers, type ProduceExtras } from "./produce-
10
10
  import { assertInteractive, confirm, intro, multiselect, select, text, unwrap } from "./prompts";
11
11
 
12
12
  /**
13
- * The produce wizard. Forty-one flags (plus the positional input path)
13
+ * The produce wizard. Forty-three flags (plus the positional input path)
14
14
  * sorted into three tiers: six prompts asked directly — the input path, plus
15
15
  * five flags (--out, --cleanup, --aspect, --produce, --intent) — twelve
16
16
  * behind one "anything else?" multiselect (--sfx being the twelfth, with
@@ -18,6 +18,12 @@ import { assertInteractive, confirm, intro, multiselect, select, text, unwrap }
18
18
  * debug/internal surfaces, replay-only fields, --no-watermark (the
19
19
  * multiselect only turns the credit ON; off is already the default),
20
20
  * --no-youtube (the same shape: the pack entry only turns it ON),
21
+ * --color-grade (2026-08-30, the --resolution shape: a channel's look is a
22
+ * durable machine preference, set once as `colorGrade` in
23
+ * ~/.ossclip/config.json rather than re-picked per wizard run — and an
24
+ * honest prompt would need to enumerate ~/.ossclip/luts and preview five
25
+ * presets, a design nobody has made; --no-color-grade then mirrors
26
+ * --no-watermark's tier for the same off-is-default reason),
21
27
  * --captions (the mirror case: ON is already the default, so the
22
28
  * multiselect entry is the OFF switch and the positive flag exists only for
23
29
  * replay pinning), --add-jump-cuts (same mirror: auto already punches, the
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,
@@ -725,6 +737,16 @@ export interface ProduceOptions {
725
737
  * ignore an uploaded cover.
726
738
  */
727
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;
728
750
  /**
729
751
  * `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
730
752
  * true/false when TYPED, undefined when not — undefined lets the config's
@@ -861,6 +883,71 @@ export function resolveCoverInVideo(
861
883
  return flag ?? configValue === true;
862
884
  }
863
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
+
864
951
  /**
865
952
  * `--sfx-level` implies `--sfx`: typing a level is asking for sound effects,
866
953
  * and a run that quietly did nothing because the boolean was missing is the
@@ -4714,6 +4801,88 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4714
4801
  // on a bar-free source): there is no re-encode to scale, the render plays
4715
4802
  // the source itself, and the window emissions below must then stay in true
4716
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
+
4717
4886
  const mezzScale = mezzanineWillBuild
4718
4887
  ? mezzanineScale(
4719
4888
  { width: contentRect.w, height: contentRect.h, fps: sourceProbe.fps },
@@ -4726,7 +4895,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4726
4895
  // why): mezzanine caching is existence-keyed, so a pre-pass full-res
4727
4896
  // mezzanine.mp4 must not satisfy a run that emits mezzanine-sized
4728
4897
  // windows — the scaled file rebuilds once under its own name.
4729
- 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));
4730
4902
  if (!existsSync(mezz)) {
4731
4903
  const mezzAnim = isInteractive()
4732
4904
  ? new StageAnimator(
@@ -4747,6 +4919,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4747
4919
  await makeMezzanine(tools, input, mezz, {
4748
4920
  cropVf: cropVf || undefined,
4749
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,
4750
4926
  });
4751
4927
  if (mezzAnim) mezzAnim.stop();
4752
4928
  }
@@ -5069,6 +5245,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
5069
5245
  // one, and the composition reads an absent key as silence, not as an empty
5070
5246
  // track it still has to mount.
5071
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 } : {}),
5072
5256
  };
5073
5257
  await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
5074
5258
 
package/src/program.ts CHANGED
@@ -472,6 +472,21 @@ export function buildProgram(): Command {
472
472
  "(set it once with coverInVideo: true in ~/.ossclip/config.json)",
473
473
  )
474
474
  .option("--no-cover-in-video", "no cover overlay, even when the config turns it on")
475
+ // The watermark's tri-state carrying a VALUE: commander folds the pair
476
+ // onto one key (string when typed, false for --no-color-grade, undefined
477
+ // when neither — which is what lets overrides.json and then the config's
478
+ // `colorGrade` decide). The value is NOT parsed here, unlike --sfx-level:
479
+ // it may be a preset id OR a .cube filename, so an enum parse can't hold
480
+ // it — classification and validation live at the consumer
481
+ // (colorGradeFlagValue / resolveProductionColorGrade in produce.ts),
482
+ // where a typo warns and the run proceeds ungraded rather than dying.
483
+ .option(
484
+ "--color-grade <look>",
485
+ "color grade the footage: a preset (talking-head | teal-orange | filmic-fade | " +
486
+ "cwa | punchy | mono) or a .cube LUT filename from ~/.ossclip/luts " +
487
+ '(set it once with colorGrade: {"preset": "..."} in ~/.ossclip/config.json)',
488
+ )
489
+ .option("--no-color-grade", "no color grade, even when the config sets one")
475
490
  // Same tri-state shape as --watermark above (positive declared first so
476
491
  // commander's default stays undefined = "not typed"): the config's
477
492
  // `youtube` key supplies the default (resolveYoutube), and a typed
@@ -722,6 +737,12 @@ export function buildProgram(): Command {
722
737
  // The watermark's tri-state again, resolved by resolveCoverInVideo
723
738
  // at the use site against the config's `coverInVideo`.
724
739
  coverInVideo: opts.coverInVideo,
740
+ // string | false | undefined straight through: undefined = "not
741
+ // typed" lets overrides.json and then the config's `colorGrade`
742
+ // decide, and the value itself is classified and validated at the
743
+ // use site (resolveProductionColorGrade) — see the option's own
744
+ // comment for why no parse happens here.
745
+ colorGrade: opts.colorGrade,
725
746
  // The same tri-state contract as watermark, resolved by
726
747
  // resolveYoutube at the use site; --portrait rides along untyped =
727
748
  // undefined so the config's path can supply it.