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.
@@ -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.37",
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/renderer": "0.1.37",
40
+ "@ossclip/core": "0.1.37",
41
+ "@ossclip/scenes": "0.1.37"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
package/src/analyze.ts CHANGED
@@ -117,6 +117,9 @@ export interface AnalyzeOptions {
117
117
  noiseDb?: number;
118
118
  whisperModel?: string;
119
119
  whisperLanguage?: string;
120
+ /** `--whisper-backend`, already zod-parsed by program.ts; undefined lets a
121
+ * configured `whisperUrl` decide (resolveWhisperBackend in produce). */
122
+ whisperBackend?: "local" | "remote";
120
123
  blooperMarker?: string;
121
124
  collapseRetakes?: boolean;
122
125
  sort?: "name" | "mtime";
@@ -157,6 +160,7 @@ export async function runAnalyze(
157
160
  noiseDb: opts.noiseDb,
158
161
  whisperModel: opts.whisperModel,
159
162
  whisperLanguage: opts.whisperLanguage,
163
+ whisperBackend: opts.whisperBackend,
160
164
  blooperMarker: opts.blooperMarker,
161
165
  collapseRetakes: opts.collapseRetakes,
162
166
  sort: opts.sort,
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/doctor.ts CHANGED
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import type { OssclipConfig } from "@ossclip/core";
4
4
  import { modelUrl, validModelSources, whisperModelPath } from "./setup/manifest";
5
+ import { WHISPER_API_KEY_ENV, resolveWhisperBackend } from "./whisper-backend";
5
6
 
6
7
  /**
7
8
  * `ossclip doctor` (R18 §90a): check every prerequisite and print the exact
@@ -106,12 +107,23 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
106
107
  }),
107
108
  });
108
109
 
110
+ // Remote transcription (2026-09-01 weak-CPU field report) makes the next
111
+ // two checks OPTIONAL rather than blocking: a machine that transcribes on
112
+ // Groq has no reason to own whisper.cpp or a 1.5 GB model, and doctor
113
+ // reporting two red lines on a working install is how a user concludes the
114
+ // tool is broken. The LLM-provider posture, one level up: pass with a
115
+ // detail that says why nothing is needed.
116
+ const remote = resolveWhisperBackend(undefined, cfg, p.env);
117
+ const remoteBackend = remote.ok && remote.backend.kind === "remote" ? remote.backend : null;
118
+ const notNeeded = (found: string): string =>
119
+ `${found} not found — not needed: remote transcription configured`;
120
+
109
121
  const whisperOk = await p.binRuns(cfg.whisperPath, "--help");
110
122
  checks.push({
111
123
  name: "whisper-cli",
112
- ok: whisperOk,
113
- detail: cfg.whisperPath,
114
- ...(whisperOk
124
+ ok: whisperOk || remoteBackend !== null,
125
+ detail: whisperOk ? cfg.whisperPath : remoteBackend !== null ? notNeeded(cfg.whisperPath) : cfg.whisperPath,
126
+ ...(whisperOk || remoteBackend !== null
115
127
  ? {}
116
128
  : {
117
129
  fix: viaSetup(
@@ -134,9 +146,9 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
134
146
  const modelOk = p.exists(modelPath);
135
147
  checks.push({
136
148
  name: `whisper model (${cfg.model})`,
137
- ok: modelOk,
138
- detail: modelPath,
139
- ...(modelOk
149
+ ok: modelOk || remoteBackend !== null,
150
+ detail: modelOk ? modelPath : remoteBackend !== null ? notNeeded(modelPath) : modelPath,
151
+ ...(modelOk || remoteBackend !== null
140
152
  ? {}
141
153
  : {
142
154
  fix: viaSetup(
@@ -146,6 +158,26 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
146
158
  }),
147
159
  });
148
160
 
161
+ // NO network call, unlike every other backend doctor could probe: a
162
+ // transcription request costs the user's metered free tier, and `doctor` is
163
+ // run repeatedly while fixing something else. This line reports the
164
+ // CONFIGURATION — the three things a 401 or a 404 would be about — and the
165
+ // provider's own status hints name the rest when a real run happens.
166
+ // Omitted entirely when remote is not configured: the local install is the
167
+ // default, and an extra "not configured" line for an opt-in feature is
168
+ // noise on every other machine.
169
+ if (remoteBackend !== null) {
170
+ checks.push({
171
+ name: "remote transcription",
172
+ ok: true,
173
+ detail:
174
+ `${remoteBackend.baseUrl} · model ${remoteBackend.model} · ` +
175
+ (remoteBackend.apiKey !== undefined
176
+ ? `${WHISPER_API_KEY_ENV} set`
177
+ : `no API key (fine for self-hosted; Groq needs ${WHISPER_API_KEY_ENV})`),
178
+ });
179
+ }
180
+
149
181
  // Provider, in the same order auto-detection uses (agy → claude CLI →
150
182
  // gemini key → anthropic key): subscription CLIs beat ambient env keys
151
183
  // since 2026-08 — a logged-in CLI is an explicit, already-paid choice
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,12 +56,25 @@ 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,
62
70
  portraitMimeType,
63
71
  readCoverProvenance,
64
72
  runWhisper,
73
+ // The remote transcription backend (2026-09-01) — the span re-decode goes
74
+ // through it whenever `whisperUrl` is configured.
75
+ createOpenAiCompatibleProvider,
76
+ type TranscribeRequest,
77
+ type Transcript,
65
78
  SegmentSchema,
66
79
  spliceTranscript,
67
80
  TranscriptSchema,
@@ -108,6 +121,7 @@ import { expandHome } from "./paths";
108
121
  // produce all resolve through — a second copy here would send a user to a
109
122
  // model file the rest of the tool never looks for.
110
123
  import { modelImpliedLanguage, whisperModelPath } from "./setup/manifest";
124
+ import { resolveWhisperBackend } from "./whisper-backend";
111
125
  import {
112
126
  PORTRAIT_OVERRIDE_BASENAME,
113
127
  portraitExtensionForMime,
@@ -119,6 +133,7 @@ import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
119
133
  import { captionRegenProvider } from "./caption-regen-panel";
120
134
  import { binOnPath } from "./llm-detect";
121
135
  import {
136
+ YOUTUBE_PRIVACIES,
122
137
  attachDeliveryMedia,
123
138
  buildPublishPosts,
124
139
  publishConfigured,
@@ -189,6 +204,37 @@ export interface RetranscribeConfig {
189
204
  modelDir?: string;
190
205
  language?: unknown;
191
206
  dictionary?: unknown;
207
+ /** The remote-backend pair (2026-09-01): a configured URL means this span
208
+ * is decoded by an OpenAI-compatible server instead of whisper.cpp, so the
209
+ * server must see the same two keys `resolveWhisperBackend` reads. */
210
+ whisperUrl?: string;
211
+ whisperRemoteModel?: string;
212
+ }
213
+
214
+ /**
215
+ * The half of a range re-decode that is the same on BOTH backends: the
216
+ * decoder bias. Extracted (2026-09-01) so the remote path shares these rules
217
+ * verbatim instead of growing a second copy that drifts — the validation is
218
+ * the load-bearing part, and it is unchanged from the local-only version.
219
+ */
220
+ function retranscribeBias(cfg: RetranscribeConfig): { language?: string; prompt?: string } {
221
+ const dict = Array.isArray(cfg.dictionary)
222
+ && cfg.dictionary.length > 0
223
+ && cfg.dictionary.every((t) => typeof t === "string" && t.trim().length > 0)
224
+ ? (cfg.dictionary as string[]).map((t) => t.trim())
225
+ : [];
226
+ // `cfg.model` can be absent on the remote backend (a machine that never
227
+ // installed a local model), and no model name implies no language.
228
+ const language = typeof cfg.language === "string" && cfg.language.trim().length > 0
229
+ ? cfg.language.trim()
230
+ : cfg.model !== undefined
231
+ ? modelImpliedLanguage(cfg.model)
232
+ : undefined;
233
+ const prompt = whisperPromptFor(dict);
234
+ return {
235
+ ...(language !== undefined ? { language } : {}),
236
+ ...(prompt !== undefined ? { prompt } : {}),
237
+ };
192
238
  }
193
239
 
194
240
  /**
@@ -229,21 +275,38 @@ export function retranscribeSettings(
229
275
  "then `ossclip setup` to install it.",
230
276
  };
231
277
  }
232
- const dict = Array.isArray(cfg.dictionary)
233
- && cfg.dictionary.length > 0
234
- && cfg.dictionary.every((t) => typeof t === "string" && t.trim().length > 0)
235
- ? (cfg.dictionary as string[]).map((t) => t.trim())
236
- : [];
237
- const language = typeof cfg.language === "string" && cfg.language.trim().length > 0
238
- ? cfg.language.trim()
239
- : modelImpliedLanguage(cfg.model);
240
- const prompt = whisperPromptFor(dict);
241
278
  return {
242
279
  tools: { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath },
243
280
  whisperPath: cfg.whisperPath,
244
281
  modelPath: whisperModelPath(cfg.model, cfg.modelDir),
245
- ...(language !== undefined ? { language } : {}),
246
- ...(prompt !== undefined ? { prompt } : {}),
282
+ ...retranscribeBias(cfg),
283
+ };
284
+ }
285
+
286
+ /**
287
+ * The same thing for the REMOTE backend (2026-09-01): no whisper binary and
288
+ * no model file, because the point of remote is that neither is installed —
289
+ * but ffmpeg still is, since the span is sliced out of `audio.wav` locally
290
+ * before it is uploaded.
291
+ *
292
+ * Pure, like its local twin: the whole "remote configured but ffmpeg isn't"
293
+ * corner is testable without a network.
294
+ */
295
+ export function retranscribeRemoteSettings(
296
+ cfg: RetranscribeConfig,
297
+ ):
298
+ | { tools: { ffmpegPath: string; ffprobePath: string }; language?: string; prompt?: string }
299
+ | { error: string } {
300
+ if (!cfg.ffmpegPath || !cfg.ffprobePath) {
301
+ return {
302
+ error:
303
+ "ffmpeg is not configured — run `ossclip doctor` to see what is missing, " +
304
+ "then `ossclip setup` to install it. (Remote transcription still slices the span locally.)",
305
+ };
306
+ }
307
+ return {
308
+ tools: { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath },
309
+ ...retranscribeBias(cfg),
247
310
  };
248
311
  }
249
312
 
@@ -436,6 +499,10 @@ export async function startEditServer(
436
499
  * `unknown` because it is file-only and typed at the consumer
437
500
  * (`resolveSfxBundledPack`), the `audience` rule. */
438
501
  sfxBundledPack?: unknown;
502
+ /** The config-level default grade the Color panel's "Default" entry
503
+ * names — `unknown` because it is file-only and typed at the consumer
504
+ * (`resolveColorGrade`), the `sfxBundledPack` rule. */
505
+ colorGrade?: unknown;
439
506
  } & RetranscribeConfig;
440
507
  /** Env seam for the publish endpoints — tests inject their own so the
441
508
  * runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
@@ -471,6 +538,13 @@ export async function startEditServer(
471
538
  */
472
539
  sliceAudio?: typeof extractAudioSpan;
473
540
  runWhisper?: typeof runWhisper;
541
+ /**
542
+ * The remote backend's half of the `runWhisper` seam (2026-09-01): with a
543
+ * `whisperUrl` configured the span goes to an OpenAI-compatible server
544
+ * instead of whisper.cpp, and a test must be able to observe that —
545
+ * including the failure sentence — without a network or an API key.
546
+ */
547
+ transcribeRemote?: (wavPath: string, req: TranscribeRequest) => Promise<Transcript>;
474
548
  /**
475
549
  * The sound library the SFX routes serve (`loadCfg`'s rule applied to the
476
550
  * pack loader): tests inject a hand-written library over a tmp dir, so the
@@ -481,6 +555,10 @@ export async function startEditServer(
481
555
  * loader rather than only that the routes serve what they were handed.
482
556
  */
483
557
  loadSfx?: typeof loadSfxLibrary;
558
+ /** The LUT library `/api/luts` serves — the `loadSfx` seam for grades:
559
+ * tests inject a hand-written library instead of depending on whatever
560
+ * the developer keeps in ~/.ossclip/luts. */
561
+ loadLuts?: typeof loadLutLibrary;
484
562
  } = {},
485
563
  ): Promise<EditServer> {
486
564
  // MUTABLE since R17 §83: the server can start with no project (the page
@@ -938,17 +1016,67 @@ export async function startEditServer(
938
1016
  error: "this workdir has no transcript.json to re-stamp — re-run `ossclip produce`.",
939
1017
  });
940
1018
  }
941
- const settings = retranscribeSettings((opts.loadCfg ?? loadConfig)());
942
- if ("error" in settings) return send(200, { ok: false, error: settings.error });
943
- if (!existsSync(settings.modelPath)) {
944
- // The `--transcript`-only install: whisper was never needed to
945
- // make this project, so say what to run rather than 500ing.
946
- return send(200, {
947
- ok: false,
948
- error:
949
- `whisper model not found at ${settings.modelPath} — run \`ossclip setup\` ` +
950
- `to download it.`,
1019
+ const cfg = (opts.loadCfg ?? loadConfig)();
1020
+ // Which engine re-decodes the span, resolved HERE rather than in
1021
+ // retranscribeSettings (2026-09-01): the flag is a CLI thing and
1022
+ // there is no CLI in this loop, so a configured `whisperUrl` is
1023
+ // the whole switch. `undefined` as the flag can only answer ok —
1024
+ // only an explicit `--whisper-backend remote` with nothing
1025
+ // configured fails — so this is a narrowing, not a live branch.
1026
+ const backendPick = resolveWhisperBackend(undefined, cfg, process.env);
1027
+ if (!backendPick.ok) return send(200, { ok: false, error: backendPick.message });
1028
+ const backend = backendPick.backend;
1029
+ // One plan, two shapes: the local one needs a binary and a model
1030
+ // file on disk, the remote one needs neither. Both need ffmpeg —
1031
+ // the span is always sliced here.
1032
+ let plan: {
1033
+ tools: { ffmpegPath: string; ffprobePath: string };
1034
+ language?: string;
1035
+ prompt?: string;
1036
+ decode: (wavPath: string, req: TranscribeRequest) => Promise<Transcript>;
1037
+ };
1038
+ if (backend.kind === "remote") {
1039
+ const settings = retranscribeRemoteSettings(cfg);
1040
+ if ("error" in settings) return send(200, { ok: false, error: settings.error });
1041
+ const provider = createOpenAiCompatibleProvider({
1042
+ baseUrl: backend.baseUrl,
1043
+ model: backend.model,
1044
+ ...(backend.apiKey !== undefined ? { apiKey: backend.apiKey } : {}),
951
1045
  });
1046
+ plan = {
1047
+ ...settings,
1048
+ // Uploaded AS-IS, no opus sidecar (produce's rule does not
1049
+ // apply): a span is seconds long, so its wav is far under any
1050
+ // upload cap and the encode would cost more than it saves.
1051
+ decode: opts.transcribeRemote ?? ((wavPath, r) => provider.transcribe(wavPath, r)),
1052
+ };
1053
+ } else {
1054
+ const settings = retranscribeSettings(cfg);
1055
+ if ("error" in settings) return send(200, { ok: false, error: settings.error });
1056
+ if (!existsSync(settings.modelPath)) {
1057
+ // The `--transcript`-only install: whisper was never needed to
1058
+ // make this project, so say what to run rather than 500ing.
1059
+ return send(200, {
1060
+ ok: false,
1061
+ error:
1062
+ `whisper model not found at ${settings.modelPath} — run \`ossclip setup\` ` +
1063
+ `to download it.`,
1064
+ });
1065
+ }
1066
+ plan = {
1067
+ ...settings,
1068
+ decode: (wavPath, r) =>
1069
+ (opts.runWhisper ?? runWhisper)(
1070
+ {
1071
+ whisperPath: settings.whisperPath,
1072
+ modelPath: settings.modelPath,
1073
+ outBase,
1074
+ ...(r.language !== undefined ? { language: r.language } : {}),
1075
+ ...(r.prompt !== undefined ? { prompt: r.prompt } : {}),
1076
+ },
1077
+ wavPath,
1078
+ ),
1079
+ };
952
1080
  }
953
1081
  // Parsed, not cast: this file is about to be rewritten, and a
954
1082
  // truncated one must fail loudly here rather than become the new
@@ -968,22 +1096,16 @@ export async function startEditServer(
968
1096
  });
969
1097
  }
970
1098
  await (opts.sliceAudio ?? extractAudioSpan)(
971
- settings.tools,
1099
+ plan.tools,
972
1100
  audio,
973
1101
  tmpWav,
974
1102
  srcIn,
975
1103
  srcOut - srcIn,
976
1104
  );
977
- const fresh = await (opts.runWhisper ?? runWhisper)(
978
- {
979
- whisperPath: settings.whisperPath,
980
- modelPath: settings.modelPath,
981
- outBase,
982
- ...(settings.language !== undefined ? { language: settings.language } : {}),
983
- ...(settings.prompt !== undefined ? { prompt: settings.prompt } : {}),
984
- },
985
- tmpWav,
986
- );
1105
+ const fresh = await plan.decode(tmpWav, {
1106
+ ...(plan.language !== undefined ? { language: plan.language } : {}),
1107
+ ...(plan.prompt !== undefined ? { prompt: plan.prompt } : {}),
1108
+ });
987
1109
  const restamped = alignRestamp(
988
1110
  transcript.words.slice(range.from, range.to),
989
1111
  fresh.words,
@@ -1971,6 +2093,14 @@ export async function startEditServer(
1971
2093
  // What uploads (the CLI's --delivery): auto (default) builds
1972
2094
  // the cached delivery encode, master sends the untouched render.
1973
2095
  delivery: z.enum(["auto", "master"]).optional(),
2096
+ // YouTube's privacy status (the CLI's --youtube-privacy),
2097
+ // spelled ONCE — the flag's own value list, so the panel and
2098
+ // the CLI can never accept different words. Absent leaves
2099
+ // buildPostsPayload's safe private default alone: until this
2100
+ // rode along, every panel publish landed private with no way
2101
+ // to say otherwise, and two videos the user believed were
2102
+ // published sat private on the channel (2026-08-29).
2103
+ youtubePrivacy: z.enum(YOUTUBE_PRIVACIES).optional(),
1974
2104
  })
1975
2105
  .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1976
2106
  if (!parsed.success) return send(400, { error: parsed.error.message });
@@ -2077,7 +2207,14 @@ export async function startEditServer(
2077
2207
  }
2078
2208
  capGroups = sizeCapGroups(picked);
2079
2209
  }
2080
- const posts = buildPublishPosts(pack, picked).map((p) => ({
2210
+ // Same options object the CLI's publish path passes — undefined
2211
+ // stays undefined so buildPostsPayload's safe private default is
2212
+ // still the ONE place that decides an absent privacy.
2213
+ const posts = buildPublishPosts(pack, picked, {
2214
+ ...(parsed.data.youtubePrivacy !== undefined
2215
+ ? { youtubePrivacy: parsed.data.youtubePrivacy }
2216
+ : {}),
2217
+ }).map((p) => ({
2081
2218
  ...p,
2082
2219
  caption: parsed.data.captions?.[p.target.id] ?? p.caption,
2083
2220
  }));
@@ -2624,6 +2761,39 @@ export async function startEditServer(
2624
2761
  });
2625
2762
  }
2626
2763
 
2764
+ if (url.pathname === "/api/luts" && req.method === "GET") {
2765
+ // The Color panel's .cube menu plus the config-level default grade.
2766
+ // NO workdir guard, /api/sfx/library's rule: both halves are
2767
+ // machine-global (~/.ossclip/luts and config.json), so the panel can
2768
+ // build its dropdown before a project is open. Read per request like
2769
+ // every other loadCfg consumer — a LUT dropped while the editor is
2770
+ // up appears on the next refresh, not on a restart.
2771
+ const library = (opts.loadLuts ?? loadLutLibrary)();
2772
+ // The config grade rides along VALIDATED, not raw: a malformed
2773
+ // config value is what produce ignores (`resolveProductionColorGrade`
2774
+ // warns and proceeds without it), so a "Default (…)" entry built
2775
+ // from it would offer an inherit that renders as nothing. Null means
2776
+ // the panel shows no Default entry, and the warning is dropped for
2777
+ // the sfxLibrary helper's reason — no console here, produce prints
2778
+ // it on the run that grades.
2779
+ const configGrade = resolveColorGrade(
2780
+ (opts.loadCfg ?? loadConfig)().colorGrade,
2781
+ "config",
2782
+ ).grade;
2783
+ return send(200, {
2784
+ // METADATA only, the sfx library rule: the absolute `path` stays
2785
+ // server-side. `file` (the basename, extension and all) is what an
2786
+ // editor-written override must carry — `ColorGrade.lut` documents
2787
+ // the basename, produce joins it against ~/.ossclip/luts verbatim,
2788
+ // and a stem-only id would drop the `.CUBE` an exporter spelled.
2789
+ items: library.items.map((l) => ({ id: l.id, title: l.title, file: basename(l.path) })),
2790
+ // A ~/.ossclip/luts author's only surface, like the sfx panel:
2791
+ // the loader degraded instead of throwing, so show the reason.
2792
+ issues: library.issues,
2793
+ configGrade: configGrade ?? null,
2794
+ });
2795
+ }
2796
+
2627
2797
  if (url.pathname === "/api/sfx/audio" && req.method === "GET") {
2628
2798
  // Click-to-preview. The path comes from the LOADED LIBRARY, never
2629
2799
  // 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-four 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,11 +18,22 @@ 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
24
30
  * multiselect entry is the OFF switch, and the force flag exists to beat a
25
- * future config-off), or
31
+ * future config-off),
32
+ * --whisper-backend (2026-09-01, the --color-grade shape: it selects machine
33
+ * INFRASTRUCTURE — which transcription engine this box owns, alongside
34
+ * whisperPath and modelDir — not a per-run editorial choice, and the durable
35
+ * spelling is `whisperUrl` in ~/.ossclip/config.json; an honest prompt would
36
+ * also have to explain a base URL and an API key at a menu), or
26
37
  * (final-review fix wave, Finding 1) --sort. A folder's clip order only means anything once the
27
38
  * folder has been enumerated, and that enumeration happens inside
28
39
  * `produce()` — after the wizard has already returned argv — so there is