ossclip 0.1.35 → 0.1.37

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
@@ -66,6 +66,10 @@ import {
66
66
  splitThenDropHidden,
67
67
  emptyOverrideDoc,
68
68
  extractAudio,
69
+ encodeUploadAudio,
70
+ REMOTE_UPLOAD_MAX_BYTES,
71
+ createOpenAiCompatibleProvider,
72
+ openaiTranscriptionsUrl,
69
73
  fillPlainCues,
70
74
  splitCues,
71
75
  landscapeLayout,
@@ -124,6 +128,18 @@ import {
124
128
  makeMezzanine,
125
129
  mezzanineFileName,
126
130
  mezzanineScale,
131
+ // The color-grade pipeline (2026-08-30): validation, the preset/LUT split,
132
+ // the SVG filter spec preset grades ride render-props as, and the .cube
133
+ // bake+hash LUT grades ride the mezzanine as.
134
+ CONFIG_DIR,
135
+ resolveColorGrade,
136
+ resolveGradeToLook,
137
+ gradeToSvgFilterSpec,
138
+ parseCubeLut,
139
+ bakeCube,
140
+ lutHash,
141
+ type ColorGrade,
142
+ type SvgGradeFilterSpec,
127
143
  scaleContentTimeline,
128
144
  scaleFramingWindows,
129
145
  measureFace,
@@ -237,6 +253,7 @@ import { RenderTimelineHUD, StageAnimator, printProductionCompleteBanner } from
237
253
  import { reconcileCaptionEdits } from "./caption-report";
238
254
  import { overridesWriteLine, writeOverrideDoc } from "./overrides-write";
239
255
  import { recordedProduceArgs } from "./replay-argv";
256
+ import { remoteWhisperHost, resolveWhisperBackend } from "./whisper-backend";
240
257
  import { makeCancelSignal, renderCover, renderProduction } from "@ossclip/renderer";
241
258
  import type { RenderPhase } from "@ossclip/renderer";
242
259
  import {
@@ -299,6 +316,17 @@ export const TranscriptKeySchema = z.object({
299
316
  * files) means "no translation", the `dictionary` contract.
300
317
  */
301
318
  translate: z.boolean().optional(),
319
+ /**
320
+ * Which BACKEND decoded it (2026-09-01): `remote:<normalized endpoint>`, or
321
+ * absent for local whisper.cpp — the `dictionary`/`translate` contract, so
322
+ * every key file written before remote existed still reads as local. Two
323
+ * engines on the same audio produce different words, so without this a warm
324
+ * workdir serves the local transcript to a remote run (and vice versa) —
325
+ * the exact staleness `language` and `translate` were added for. The
326
+ * remote MODEL name rides in `model` above, so switching Groq models
327
+ * re-keys through the existing field.
328
+ */
329
+ backend: z.string().optional(),
302
330
  });
303
331
  export type TranscriptKey = z.infer<typeof TranscriptKeySchema>;
304
332
 
@@ -326,6 +354,10 @@ export function transcriptCacheReusable(
326
354
  // Absent and false are the same "no translation", so pre-flag key
327
355
  // files reuse under a non-translate request.
328
356
  (effective.translate ?? false) === (requested.translate ?? false) &&
357
+ // Absent means LOCAL on both sides, so every pre-2026-09-01 key file
358
+ // still reuses under a local request — and a remote request against
359
+ // one of them re-transcribes, which is the point.
360
+ (effective.backend ?? "") === (requested.backend ?? "") &&
329
361
  // ORDER-SENSITIVE by choice: the dictionary becomes whisper's --prompt
330
362
  // text verbatim, so a reordered list genuinely is a different decoder
331
363
  // input — treating it as equal would serve a transcript biased by a
@@ -622,6 +654,13 @@ export interface ProduceOptions {
622
654
  * together (whisper decodes better knowing the source language).
623
655
  */
624
656
  whisperTranslate?: boolean;
657
+ /**
658
+ * `--whisper-backend`, already zod-parsed to the union by program.ts
659
+ * (2026-09-01 weak-CPU field report). Undefined means "not typed", which
660
+ * is what lets a configured `whisperUrl` select remote — the flag is
661
+ * mainly `local`, the per-run opt-out.
662
+ */
663
+ whisperBackend?: "local" | "remote";
625
664
  /**
626
665
  * Vocabulary terms for this run (`--dictionary`, F4 2026-08-16), already
627
666
  * split/trimmed by the action. Wholesale beats the config's `dictionary`
@@ -725,6 +764,16 @@ export interface ProduceOptions {
725
764
  * ignore an uploaded cover.
726
765
  */
727
766
  coverInVideo?: boolean;
767
+ /**
768
+ * `--color-grade <look>` / `--no-color-grade` — the watermark's tri-state
769
+ * carrying a VALUE: a string when typed (a preset id, or a `.cube`
770
+ * filename — `colorGradeFlagValue` classifies by extension), `false` for a
771
+ * typed --no-color-grade, undefined when neither so overrides.json and
772
+ * then the config's `colorGrade` decide (`resolveProductionColorGrade`).
773
+ * Deliberately unparsed in transit: validation warns-and-proceeds at the
774
+ * use site, because a grade typo must cost the look, never the run.
775
+ */
776
+ colorGrade?: string | false;
728
777
  /**
729
778
  * `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
730
779
  * true/false when TYPED, undefined when not — undefined lets the config's
@@ -861,6 +910,71 @@ export function resolveCoverInVideo(
861
910
  return flag ?? configValue === true;
862
911
  }
863
912
 
913
+ /**
914
+ * `--color-grade`'s value classified into the ColorGradeSchema shape: a value
915
+ * ending in `.cube` names a LUT file in `~/.ossclip/luts`, anything else
916
+ * names a preset. Sniffed by extension rather than split into two flags
917
+ * because the user already knows which they typed — `kodak.cube` cannot be a
918
+ * preset id (presets never carry a dot) and a preset id cannot be a LUT
919
+ * (`.cube` is the one format the parser reads), so the classification is
920
+ * lossless. Case-insensitive on the extension: `KODAK.CUBE` is the same file
921
+ * on the case-preserving filesystems the LUT dir lives on. Validation is NOT
922
+ * here — the shape goes through `resolveColorGrade` like every other layer.
923
+ */
924
+ export function colorGradeFlagValue(value: string): { preset?: string; lut?: string } {
925
+ return value.toLowerCase().endsWith(".cube") ? { lut: value } : { preset: value };
926
+ }
927
+
928
+ /**
929
+ * The effective color grade across all three surfaces — override > flag >
930
+ * config, `resolveWatermark`'s typed-beats-config precedence grown one layer:
931
+ * the overrides doc is the editor's per-project say, so it beats even a typed
932
+ * flag (the `resolveSrcTimingPins` rationale — a per-project decision made in
933
+ * the editor outranks a per-run flag, never merges with it). An explicit
934
+ * `false` at a switching layer (`colorGrade: false` in the doc, or a typed
935
+ * `--no-color-grade`) is OFF, not fall-through: "no grade" is a decision, and
936
+ * letting a lower layer overrule it would make the disable impossible to
937
+ * express.
938
+ *
939
+ * Every layer is validated through `resolveColorGrade`, and an INVALID layer
940
+ * is ignored — warned about by name, then the NEXT layer applies (decision
941
+ * 2026-08-30): the alternative, an invalid override going all the way to
942
+ * "off", would let one stale editor write silently strip the config grade a
943
+ * channel's whole look depends on. Warnings are RETURNED, not printed
944
+ * (`resolveSfxLevel`'s shape), so the whole matrix is testable without a TTY.
945
+ * `source` names the winning layer so the ▸ line can say where a grade came
946
+ * from — the watermark's "(from config; --no-… overrides)" visibility rule.
947
+ */
948
+ export function resolveProductionColorGrade(p: {
949
+ override: ColorGrade | false | undefined;
950
+ flag: string | false | undefined;
951
+ config: unknown;
952
+ }): { grade?: ColorGrade; source?: "override" | "flag" | "config"; warnings: string[] } {
953
+ const warnings: string[] = [];
954
+ if (p.override === false) return { warnings };
955
+ if (p.override !== undefined) {
956
+ // Schema-valid already (OverrideDocSchema parsed the doc), but the
957
+ // unknown-preset check lives in resolveColorGrade, not the schema — this
958
+ // is the layer where a preset the editor knew and this build doesn't
959
+ // falls through instead of failing the doc.
960
+ const r = resolveColorGrade(p.override, "overrides.json");
961
+ if (r.grade) return { grade: r.grade, source: "override", warnings };
962
+ if (r.warning) warnings.push(r.warning);
963
+ }
964
+ if (p.flag === false) return { warnings };
965
+ if (p.flag !== undefined) {
966
+ const r = resolveColorGrade(colorGradeFlagValue(p.flag), "--color-grade");
967
+ if (r.grade) return { grade: r.grade, source: "flag", warnings };
968
+ if (r.warning) warnings.push(r.warning);
969
+ }
970
+ // "config", not "config colorGrade": resolveColorGrade's warning already
971
+ // spells the key (`⚠ <source> colorGrade ignored — …`).
972
+ const r = resolveColorGrade(p.config, "config");
973
+ if (r.grade) return { grade: r.grade, source: "config", warnings };
974
+ if (r.warning) warnings.push(r.warning);
975
+ return { warnings };
976
+ }
977
+
864
978
  /**
865
979
  * `--sfx-level` implies `--sfx`: typing a level is asking for sound effects,
866
980
  * and a run that quietly did nothing because the boolean was missing is the
@@ -2577,9 +2691,34 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2577
2691
  `--whisper-language overrides)`,
2578
2692
  );
2579
2693
  }
2694
+ // Local whisper.cpp or an OpenAI-compatible server (2026-09-01 weak-CPU
2695
+ // field report). Resolved BEFORE the key, like the language, so what
2696
+ // actually decodes is what the cache is keyed on.
2697
+ const backendPick = resolveWhisperBackend(opts.whisperBackend, cfg, process.env);
2698
+ if (!backendPick.ok) throw new Error(backendPick.message);
2699
+ const backend = backendPick.backend;
2700
+ // BEFORE the key is built, so a translate request can never cross the
2701
+ // cache with a remote one: the OpenAI-compatible API translates on a
2702
+ // DIFFERENT endpoint with a DIFFERENT default model, and swapping both
2703
+ // behind one flag would be a surprise rather than a convenience.
2704
+ if (backend.kind === "remote" && opts.whisperTranslate === true) {
2705
+ throw new Error(
2706
+ "--whisper-translate needs the local backend (the OpenAI-compatible API translates on a " +
2707
+ "different endpoint and model) — use --whisper-backend local, or drop the flag.",
2708
+ );
2709
+ }
2580
2710
  const requestedKey: TranscriptKey = {
2581
- model: requestedModel,
2711
+ // The REMOTE model name when remote — one field, both engines, so an
2712
+ // A/B between two Groq models re-keys the cache exactly like a local one.
2713
+ model: backend.kind === "remote" ? backend.model : requestedModel,
2582
2714
  ...(whisperLang.language !== undefined ? { language: whisperLang.language } : {}),
2715
+ // Spread-omitted on local so local key files stay byte-identical to
2716
+ // every one written before remote existed (the translate posture). The
2717
+ // URL goes through openaiTranscriptionsUrl so ".../v1" and ".../v1/"
2718
+ // key identically — a trailing slash is not a different server.
2719
+ ...(backend.kind === "remote"
2720
+ ? { backend: `remote:${openaiTranscriptionsUrl(backend.baseUrl)}` }
2721
+ : {}),
2583
2722
  // Omitted when off, so a non-translate run's key stays byte-identical to
2584
2723
  // every pre-flag key file (the dictionary posture).
2585
2724
  ...(opts.whisperTranslate === true ? { translate: true } : {}),
@@ -2617,51 +2756,100 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2617
2756
  `re-transcribing with ${fmt(requestedKey)}`,
2618
2757
  );
2619
2758
  }
2620
- await preflight(
2621
- cfg.whisperPath,
2622
- "Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
2623
- );
2624
- const model = requestedKey.model;
2625
- // whisperModelPath/modelUrl are THE resolution and URL sources (shared
2626
- // with doctor and setup) — this error used to hold its own copy of the
2627
- // ggerganov URL, which 404'd for curated/custom names and the suggested
2628
- // `curl -L` then saved the 404 HTML as a fake model.
2629
- const modelPath = whisperModelPath(model, cfg.modelDir);
2630
- if (!existsSync(modelPath)) {
2631
- throw new Error(
2632
- `whisper model not found at ${modelPath}.\n` +
2633
- `Run \`ossclip setup${model === cfg.model ? "" : ` --model ${model}`}\` to download it — or manually:\n` +
2634
- ` curl -L -o ${modelPath} ${modelUrl(model, validModelSources(cfg.modelSources))}`,
2759
+ if (backend.kind === "remote") {
2760
+ // No whisper binary and no model file on this branch — the whole point
2761
+ // of remote is that neither is installed (2026-09-01 field report).
2762
+ // ffmpeg still is: the upload sidecar is an encode.
2763
+ const host = remoteWhisperHost(backend.baseUrl);
2764
+ const uploadPath = join(work, "audio-upload.ogg");
2765
+ transcript = await phases.time("transcribe", async () => {
2766
+ await encodeUploadAudio(tools, audioPath, uploadPath);
2767
+ const bytes = statSync(uploadPath).size;
2768
+ if (bytes > REMOTE_UPLOAD_MAX_BYTES) {
2769
+ // Named here rather than paid for as somebody else's 413 after the
2770
+ // whole upload: chunking is out of scope for v1, so the error has
2771
+ // to carry both escape hatches itself.
2772
+ throw new Error(
2773
+ `the compressed audio is ${(bytes / 1_000_000).toFixed(1)} MB, over the ` +
2774
+ `${(REMOTE_UPLOAD_MAX_BYTES / 1_000_000).toFixed(0)} MB single-file limit for remote ` +
2775
+ `transcription (about 100 minutes of speech at this bitrate).\n` +
2776
+ `Transcribe locally with --whisper-backend local, split the take, or point ` +
2777
+ `OSSCLIP_WHISPER_URL at a server with a larger cap (Groq's dev tier allows 100 MB).`,
2778
+ );
2779
+ }
2780
+ const anim = isInteractive()
2781
+ ? new StageAnimator(
2782
+ "REMOTE ASR",
2783
+ `Transcribing via ${host} (${backend.model})...`,
2784
+ "whisper",
2785
+ ).start()
2786
+ : null;
2787
+ if (!anim) console.log(`▸ transcribing remotely (${host}, ${backend.model})…`);
2788
+ try {
2789
+ return await createOpenAiCompatibleProvider({
2790
+ baseUrl: backend.baseUrl,
2791
+ model: backend.model,
2792
+ ...(backend.apiKey !== undefined ? { apiKey: backend.apiKey } : {}),
2793
+ }).transcribe(uploadPath, {
2794
+ // From the KEY, like the local branch: whatever re-keys the cache
2795
+ // is what actually decoded, so the two can never disagree.
2796
+ language: requestedKey.language,
2797
+ prompt: whisperPromptFor(dictionary),
2798
+ });
2799
+ } finally {
2800
+ // In a finally, unlike the local branch's trailing stop(): an HTTP
2801
+ // failure here is EXPECTED (a wrong key, a rate limit), and a
2802
+ // spinner still animating would overwrite the hint the user needs.
2803
+ anim?.stop();
2804
+ }
2805
+ });
2806
+ } else {
2807
+ await preflight(
2808
+ cfg.whisperPath,
2809
+ "Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
2635
2810
  );
2811
+ const model = requestedKey.model;
2812
+ // whisperModelPath/modelUrl are THE resolution and URL sources (shared
2813
+ // with doctor and setup) — this error used to hold its own copy of the
2814
+ // ggerganov URL, which 404'd for curated/custom names and the suggested
2815
+ // `curl -L` then saved the 404 HTML as a fake model.
2816
+ const modelPath = whisperModelPath(model, cfg.modelDir);
2817
+ if (!existsSync(modelPath)) {
2818
+ throw new Error(
2819
+ `whisper model not found at ${modelPath}.\n` +
2820
+ `Run \`ossclip setup${model === cfg.model ? "" : ` --model ${model}`}\` to download it — or manually:\n` +
2821
+ ` curl -L -o ${modelPath} ${modelUrl(model, validModelSources(cfg.modelSources))}`,
2822
+ );
2823
+ }
2824
+ const whisperAnim = isInteractive()
2825
+ ? new StageAnimator(
2826
+ "WHISPER ASR",
2827
+ `Transcribing audio stream with ${basename(modelPath)}...`,
2828
+ "whisper",
2829
+ ).start()
2830
+ : null;
2831
+ if (!whisperAnim) console.log(`▸ transcribing (${basename(modelPath)})…`);
2832
+ transcript = await phases.time("transcribe", () =>
2833
+ runWhisper(
2834
+ {
2835
+ whisperPath: cfg.whisperPath,
2836
+ modelPath,
2837
+ outBase: join(work, "whisper"),
2838
+ // The RESOLVED language, not the raw flag — a config/model-implied
2839
+ // code must reach the spawn exactly as it reached the cache key.
2840
+ language: requestedKey.language,
2841
+ // Vocabulary biasing (F4) — undefined for an empty dictionary, so
2842
+ // the spawned args stay byte-identical to every pre-dictionary run.
2843
+ // From the KEY, like the language: whatever re-keys the cache is
2844
+ // what actually ran, so the two can never disagree.
2845
+ ...(requestedKey.translate === true ? { translate: true } : {}),
2846
+ prompt: whisperPromptFor(dictionary),
2847
+ },
2848
+ audioPath,
2849
+ ),
2850
+ );
2851
+ if (whisperAnim) whisperAnim.stop();
2636
2852
  }
2637
- const whisperAnim = isInteractive()
2638
- ? new StageAnimator(
2639
- "WHISPER ASR",
2640
- `Transcribing audio stream with ${basename(modelPath)}...`,
2641
- "whisper",
2642
- ).start()
2643
- : null;
2644
- if (!whisperAnim) console.log(`▸ transcribing (${basename(modelPath)})…`);
2645
- transcript = await phases.time("transcribe", () =>
2646
- runWhisper(
2647
- {
2648
- whisperPath: cfg.whisperPath,
2649
- modelPath,
2650
- outBase: join(work, "whisper"),
2651
- // The RESOLVED language, not the raw flag — a config/model-implied
2652
- // code must reach the spawn exactly as it reached the cache key.
2653
- language: requestedKey.language,
2654
- // Vocabulary biasing (F4) — undefined for an empty dictionary, so
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 } : {}),
2659
- prompt: whisperPromptFor(dictionary),
2660
- },
2661
- audioPath,
2662
- ),
2663
- );
2664
- if (whisperAnim) whisperAnim.stop();
2665
2853
  console.log(`▸ transcribed ${transcript.words.length} words`);
2666
2854
  await writeFile(transcriptKeyPath, JSON.stringify(requestedKey, null, 2));
2667
2855
  }
@@ -4714,6 +4902,88 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4714
4902
  // on a bar-free source): there is no re-encode to scale, the render plays
4715
4903
  // the source itself, and the window emissions below must then stay in true
4716
4904
  // source pixels — which the identity `mezzFactor` below guarantees.
4905
+ // ---- Color grade (`--color-grade` / config `colorGrade` / the editor's
4906
+ // overrides.json) ---------------------------------------------------------
4907
+ // Resolved HERE, before the mezzanine encode, because the feature's two
4908
+ // halves split at exactly this seam: a PRESET grade rides render-props as
4909
+ // an SVG filter spec (the props assembly below), while a LUT grade is baked
4910
+ // INTO the mezzanine (ingest.ts's `lut` option) — ffmpeg's lut3d on the
4911
+ // encode pass costs nothing per rendered frame, where a 33³ trilinear
4912
+ // lookup in the browser would. Precedence and validation live in
4913
+ // `resolveProductionColorGrade`; every warning it returns prints once here,
4914
+ // and every failure path proceeds UNGRADED — a grade must cost the look at
4915
+ // worst, never the run.
4916
+ const gradeResolution = resolveProductionColorGrade({
4917
+ override: overrideDoc.colorGrade,
4918
+ flag: opts.colorGrade,
4919
+ config: cfg.colorGrade,
4920
+ });
4921
+ for (const w of gradeResolution.warnings) console.log(w);
4922
+ /** Preset grades: the spec render-props carries (absent = no grade). */
4923
+ let colorGradeSpec: SvgGradeFilterSpec | undefined;
4924
+ /** LUT grades: the baked .cube the mezzanine encode applies (absent = none). */
4925
+ let gradeLut: { path: string; hash: string } | undefined;
4926
+ if (gradeResolution.grade !== undefined) {
4927
+ // The watermark's visibility rule: a grade sourced anywhere but the
4928
+ // typed flag says so, so a config- or editor-sourced look never
4929
+ // surprises the author on upload.
4930
+ const gradeFromNote =
4931
+ gradeResolution.source === "config"
4932
+ ? " (from config; --no-color-grade overrides)"
4933
+ : gradeResolution.source === "override"
4934
+ ? " (editor override)"
4935
+ : "";
4936
+ const resolvedLook = resolveGradeToLook(gradeResolution.grade);
4937
+ if (resolvedLook.kind === "preset") {
4938
+ colorGradeSpec = gradeToSvgFilterSpec(resolvedLook);
4939
+ console.log(`▸ color grade: ${gradeResolution.grade.preset}${gradeFromNote}`);
4940
+ } else if (!mezzanineWillBuild) {
4941
+ // --no-mezzanine on a bar-free source: the render plays the source
4942
+ // file itself, so there is no encode to bake the LUT into. Warn and
4943
+ // proceed ungraded rather than force a mezzanine the user refused.
4944
+ console.log(
4945
+ `⚠ color grade skipped — a .cube LUT is baked into the mezzanine, ` +
4946
+ `and --no-mezzanine means this run doesn't build one`,
4947
+ );
4948
+ } else {
4949
+ try {
4950
+ // Basename only, enforced before any path math: `lut` is a NAME the
4951
+ // schema documents as living in ~/.ossclip/luts, and resolving a
4952
+ // separator-carrying value would turn a config key into a file probe
4953
+ // (the SfxAddedPlacement id's "nothing may ever resolve a path
4954
+ // against it" rule, applied at the one place this name meets the
4955
+ // filesystem).
4956
+ if (basename(resolvedLook.lutRef) !== resolvedLook.lutRef) {
4957
+ throw new Error(
4958
+ `"${resolvedLook.lutRef}" is not a bare filename — LUTs live in ${join(CONFIG_DIR, "luts")}`,
4959
+ );
4960
+ }
4961
+ const lutPath = join(CONFIG_DIR, "luts", resolvedLook.lutRef);
4962
+ const baseLut = parseCubeLut(readFileSync(lutPath, "utf8"));
4963
+ // Tweaks + intensity are baked into the cube (bakeCube composes
4964
+ // `params` on top of the base sample), so the hash keys the WHOLE
4965
+ // grade: change the intensity and the mezzanine filename changes
4966
+ // with it (`mezzanineFileName`'s existence-keyed cache).
4967
+ const cubeText = bakeCube({
4968
+ base: baseLut,
4969
+ params: resolvedLook.tweaks,
4970
+ intensity: resolvedLook.intensity,
4971
+ });
4972
+ const hash = lutHash(cubeText);
4973
+ const bakedPath = join(work, `grade-${hash}.cube`);
4974
+ await writeFile(bakedPath, cubeText);
4975
+ gradeLut = { path: bakedPath, hash };
4976
+ console.log(`▸ color grade: LUT ${resolvedLook.lutRef}${gradeFromNote}`);
4977
+ } catch (err) {
4978
+ // ENOENT and a malformed .cube land here alike: name the problem,
4979
+ // proceed ungraded. parseCubeLut's errors already carry the line.
4980
+ console.log(
4981
+ `⚠ color grade skipped — ${err instanceof Error ? err.message : String(err)}`,
4982
+ );
4983
+ }
4984
+ }
4985
+ }
4986
+
4717
4987
  const mezzScale = mezzanineWillBuild
4718
4988
  ? mezzanineScale(
4719
4989
  { width: contentRect.w, height: contentRect.h, fps: sourceProbe.fps },
@@ -4726,7 +4996,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4726
4996
  // why): mezzanine caching is existence-keyed, so a pre-pass full-res
4727
4997
  // mezzanine.mp4 must not satisfy a run that emits mezzanine-sized
4728
4998
  // windows — the scaled file rebuilds once under its own name.
4729
- const mezz = join(work, mezzanineFileName(!contentRect.full, mezzScale));
4999
+ // `gradeLut?.hash` rides the name so a graded mezzanine can never satisfy
5000
+ // an ungraded run (or vice versa) — the LUT is pixels in the file, and
5001
+ // the cache is existence-keyed.
5002
+ const mezz = join(work, mezzanineFileName(!contentRect.full, mezzScale, gradeLut?.hash));
4730
5003
  if (!existsSync(mezz)) {
4731
5004
  const mezzAnim = isInteractive()
4732
5005
  ? new StageAnimator(
@@ -4747,6 +5020,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4747
5020
  await makeMezzanine(tools, input, mezz, {
4748
5021
  cropVf: cropVf || undefined,
4749
5022
  scale: mezzScale ?? undefined,
5023
+ // The LUT grade's whole delivery: baked into the encode, so the
5024
+ // render (and the editor's preview, which plays the same file) see
5025
+ // graded pixels with no per-frame cost.
5026
+ lut: gradeLut,
4750
5027
  });
4751
5028
  if (mezzAnim) mezzAnim.stop();
4752
5029
  }
@@ -5069,6 +5346,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
5069
5346
  // one, and the composition reads an absent key as silence, not as an empty
5070
5347
  // track it still has to mount.
5071
5348
  ...(sfxCues.length > 0 ? { sfxCues } : {}),
5349
+ // `--color-grade` PRESET looks, written only when one resolved (the
5350
+ // watermark's absent-means-off contract): the two-stage SVG filter spec
5351
+ // ({tableR, tableG, tableB, colorMatrix}, gradeToSvgFilterSpec) the
5352
+ // composition mounts over the video. A LUT grade deliberately writes
5353
+ // NOTHING here — it was baked into the mezzanine above, so the pixels
5354
+ // the renderer plays already carry it, and a spec on top would grade
5355
+ // twice.
5356
+ ...(colorGradeSpec ? { colorGrade: colorGradeSpec } : {}),
5072
5357
  };
5073
5358
  await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
5074
5359
 
package/src/program.ts CHANGED
@@ -411,6 +411,12 @@ export function buildProgram(): Command {
411
411
  "(whisper's -tr; pair with --whisper-language for the SOURCE language)",
412
412
  false,
413
413
  )
414
+ .option(
415
+ "--whisper-backend <backend>",
416
+ "local | remote. local (default) runs whisper.cpp on this machine; remote posts the " +
417
+ "audio to the OpenAI-compatible server in OSSCLIP_WHISPER_URL (config: whisperUrl) — " +
418
+ "configuring that URL already implies remote, so this flag is mainly `local` to opt out",
419
+ )
414
420
  // COMMA-SEPARATED in one value, not variadic: a variadic option swallows
415
421
  // the optional positional [input] whenever the flag precedes the path,
416
422
  // and commander offers no way to give the positional priority.
@@ -472,6 +478,21 @@ export function buildProgram(): Command {
472
478
  "(set it once with coverInVideo: true in ~/.ossclip/config.json)",
473
479
  )
474
480
  .option("--no-cover-in-video", "no cover overlay, even when the config turns it on")
481
+ // The watermark's tri-state carrying a VALUE: commander folds the pair
482
+ // onto one key (string when typed, false for --no-color-grade, undefined
483
+ // when neither — which is what lets overrides.json and then the config's
484
+ // `colorGrade` decide). The value is NOT parsed here, unlike --sfx-level:
485
+ // it may be a preset id OR a .cube filename, so an enum parse can't hold
486
+ // it — classification and validation live at the consumer
487
+ // (colorGradeFlagValue / resolveProductionColorGrade in produce.ts),
488
+ // where a typo warns and the run proceeds ungraded rather than dying.
489
+ .option(
490
+ "--color-grade <look>",
491
+ "color grade the footage: a preset (talking-head | teal-orange | filmic-fade | " +
492
+ "cwa | punchy | mono) or a .cube LUT filename from ~/.ossclip/luts " +
493
+ '(set it once with colorGrade: {"preset": "..."} in ~/.ossclip/config.json)',
494
+ )
495
+ .option("--no-color-grade", "no color grade, even when the config sets one")
475
496
  // Same tri-state shape as --watermark above (positive declared first so
476
497
  // commander's default stays undefined = "not typed"): the config's
477
498
  // `youtube` key supplies the default (resolveYoutube), and a typed
@@ -646,6 +667,14 @@ export function buildProgram(): Command {
646
667
  opts.whisperLanguage !== undefined
647
668
  ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
648
669
  : undefined;
670
+ // An enum, unlike --whisper-language: there are exactly two backends,
671
+ // and a typo'd `--whisper-backend groq` silently running local whisper
672
+ // on the weak CPU the flag exists to spare is the --source-fit crop
673
+ // all over again.
674
+ const whisperBackend =
675
+ opts.whisperBackend !== undefined
676
+ ? z.enum(["local", "remote"]).parse(opts.whisperBackend)
677
+ : undefined;
649
678
  // --add-jump-cuts / --no-jump-cuts land on DIFFERENT commander keys
650
679
  // (see the option declarations for why the pair can't share one);
651
680
  // jumpCutsFlag reunites them into the tri-state ProduceOptions
@@ -705,6 +734,9 @@ export function buildProgram(): Command {
705
734
  whisperModel: opts.whisperModel,
706
735
  whisperLanguage,
707
736
  whisperTranslate: opts.whisperTranslate === true,
737
+ // undefined = "not typed", so a configured whisperUrl decides
738
+ // (resolveWhisperBackend at the use site).
739
+ whisperBackend,
708
740
  // Split/trim/drop-empties (dictionaryFlag) — undefined stays
709
741
  // undefined so the config's dictionary can supply the default.
710
742
  dictionary: dictionaryFlag(opts.dictionary),
@@ -722,6 +754,12 @@ export function buildProgram(): Command {
722
754
  // The watermark's tri-state again, resolved by resolveCoverInVideo
723
755
  // at the use site against the config's `coverInVideo`.
724
756
  coverInVideo: opts.coverInVideo,
757
+ // string | false | undefined straight through: undefined = "not
758
+ // typed" lets overrides.json and then the config's `colorGrade`
759
+ // decide, and the value itself is classified and validated at the
760
+ // use site (resolveProductionColorGrade) — see the option's own
761
+ // comment for why no parse happens here.
762
+ colorGrade: opts.colorGrade,
725
763
  // The same tri-state contract as watermark, resolved by
726
764
  // resolveYoutube at the use site; --portrait rides along untyped =
727
765
  // undefined so the config's path can supply it.
@@ -834,6 +872,15 @@ export function buildProgram(): Command {
834
872
  "(whisper's -tr; pair with --whisper-language for the SOURCE language)",
835
873
  false,
836
874
  )
875
+ .option(
876
+ "--whisper-backend <backend>",
877
+ // Same sentence as produce's, deliberately: `transcribe` is the command
878
+ // a user drives while SETTING remote transcription up, so the half that
879
+ // says the URL is the real switch cannot be the half that is dropped here.
880
+ "local | remote. local (default) runs whisper.cpp on this machine; remote posts the " +
881
+ "audio to the OpenAI-compatible server in OSSCLIP_WHISPER_URL (config: whisperUrl) — " +
882
+ "configuring that URL already implies remote, so this flag is mainly `local` to opt out",
883
+ )
837
884
  .action(async (input: string, opts) => {
838
885
  const cleanup = CleanupLevelSchema.parse(opts.cleanup);
839
886
  const result = await produce(input, {
@@ -850,6 +897,12 @@ export function buildProgram(): Command {
850
897
  opts.whisperLanguage !== undefined
851
898
  ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
852
899
  : undefined,
900
+ // Parsed, not coerced — produce's reasoning: a typo must error, not
901
+ // fall back to the local backend the flag exists to avoid.
902
+ whisperBackend:
903
+ opts.whisperBackend !== undefined
904
+ ? z.enum(["local", "remote"]).parse(opts.whisperBackend)
905
+ : undefined,
853
906
  });
854
907
  telemetry.record("transcribe_completed", {
855
908
  cleanup_level: cleanup,
@@ -886,6 +939,15 @@ export function buildProgram(): Command {
886
939
  "--whisper-language <code>",
887
940
  "transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
888
941
  )
942
+ .option(
943
+ "--whisper-backend <backend>",
944
+ // Same sentence as produce's, deliberately: `transcribe` is the command
945
+ // a user drives while SETTING remote transcription up, so the half that
946
+ // says the URL is the real switch cannot be the half that is dropped here.
947
+ "local | remote. local (default) runs whisper.cpp on this machine; remote posts the " +
948
+ "audio to the OpenAI-compatible server in OSSCLIP_WHISPER_URL (config: whisperUrl) — " +
949
+ "configuring that URL already implies remote, so this flag is mainly `local` to opt out",
950
+ )
889
951
  .option(
890
952
  "--blooper-marker <word>",
891
953
  "mark the flubbed take wherever you say this word out loud (e.g. blooper). Off unless given",
@@ -914,6 +976,12 @@ export function buildProgram(): Command {
914
976
  opts.whisperLanguage !== undefined
915
977
  ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
916
978
  : undefined,
979
+ // Parsed, not coerced — produce's reasoning: a typo must error, not
980
+ // fall back to the local backend the flag exists to avoid.
981
+ whisperBackend:
982
+ opts.whisperBackend !== undefined
983
+ ? z.enum(["local", "remote"]).parse(opts.whisperBackend)
984
+ : undefined,
917
985
  blooperMarker: opts.blooperMarker,
918
986
  collapseRetakes: opts.collapseRetakes,
919
987
  sort: opts.sort === "mtime" ? "mtime" : "name",
package/src/setup/plan.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  whisperAsset,
10
10
  whisperModelPath,
11
11
  } from "./manifest";
12
+ import { resolveWhisperBackend } from "../whisper-backend";
12
13
 
13
14
  /**
14
15
  * The planning half of `ossclip setup` — pure over injected probes, like
@@ -111,10 +112,25 @@ export async function planSetup(
111
112
  }
112
113
  }
113
114
 
115
+ // Remote transcription (2026-09-01 weak-CPU field report) makes whisper.cpp
116
+ // and the model OPTIONAL: on the machine the report came from, downloading
117
+ // a 1.5 GB model to run an engine that is too slow to use is exactly the
118
+ // cliff remote exists to remove. Reported as `satisfied` with the reason,
119
+ // never as a silent skip — and a local install that ALREADY works still
120
+ // reports itself (ground rule one: setup never uninstalls, and a user who
121
+ // has both keeps the `--whisper-backend local` escape hatch working).
122
+ const remote = resolveWhisperBackend(undefined, cfg, p.env);
123
+ const remoteDetail =
124
+ remote.ok && remote.backend.kind === "remote"
125
+ ? `remote transcription configured (${remote.backend.baseUrl}) — local whisper not needed`
126
+ : null;
127
+
114
128
  const whisperOk = await p.binRuns(cfg.whisperPath, "--help");
115
129
  const whisperForceable = isManaged(cfg.whisperPath, opts.configDir);
116
130
  if (whisperOk && !(opts.force && whisperForceable)) {
117
131
  steps.push({ kind: "whisper", status: "satisfied", detail: cfg.whisperPath });
132
+ } else if (remoteDetail !== null) {
133
+ steps.push({ kind: "whisper", status: "satisfied", detail: remoteDetail });
118
134
  } else {
119
135
  const asset = whisperAsset(p.platform, p.arch);
120
136
  if (asset) {
@@ -153,6 +169,8 @@ export async function planSetup(
153
169
  const known = MODELS[model];
154
170
  if (p.exists(modelPath)) {
155
171
  steps.push({ kind: "model", status: "satisfied", detail: modelPath });
172
+ } else if (remoteDetail !== null) {
173
+ steps.push({ kind: "model", status: "satisfied", detail: remoteDetail });
156
174
  } else if (isAbsolute(model)) {
157
175
  steps.push({
158
176
  kind: "model",