ossclip 0.1.24 → 0.1.26

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/program.ts CHANGED
@@ -7,8 +7,9 @@ import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
7
7
  import { STUDIO_ENTRY } from "@ossclip/renderer";
8
8
  import { loadEnvFiles } from "./env";
9
9
  import { ExportFormatSchema, runAnalyze } from "./analyze";
10
+ import { expandHome } from "./paths";
10
11
  import { phaseBucketProps } from "./phase-timing";
11
- import { produce } from "./produce";
12
+ import { dictionaryFlag, jumpCutsFlag, produce } from "./produce";
12
13
  // The one interactive import that is STATIC rather than `await import()`: the
13
14
  // `resetInputSource()` run boundary in `buildProgram` has to run synchronously
14
15
  // while the program is being built, and `buildProgram` cannot await. The graph
@@ -38,6 +39,27 @@ import {
38
39
  // order in `defaultProviderName`, which decides which model runs.
39
40
  const envFiles = loadEnvFiles();
40
41
 
42
+ /**
43
+ * `--concurrency <n>` → a positive whole number of browser tabs (§93a: reject
44
+ * rather than coerce, the `--clip` idiom). A typo'd `--concurrency 4x` must
45
+ * not become NaN and reach Remotion as "however many you like" — the flag
46
+ * exists precisely because the automatic count killed a browser (2026-08-19
47
+ * field case; `resolveRenderConcurrency` has it). `Number`, not `parseInt`,
48
+ * so "4.5" and "" are errors rather than a silent 4 and a silent 0.
49
+ *
50
+ * Exported so the rejection matrix is testable without commander's exit
51
+ * behaviour in the way.
52
+ */
53
+ export function concurrencyFlag(v: string): number {
54
+ const n = Number(v);
55
+ if (!Number.isInteger(n) || n <= 0) {
56
+ throw new InvalidArgumentError(
57
+ `--concurrency wants a positive whole number of browser tabs, got "${v}"`,
58
+ );
59
+ }
60
+ return n;
61
+ }
62
+
41
63
  /**
42
64
  * Every command this CLI has, built onto a fresh instance.
43
65
  *
@@ -160,6 +182,11 @@ export function buildProgram(): Command {
160
182
  modelDir: cfg.modelDir,
161
183
  input: path,
162
184
  watermark: cfg.watermark,
185
+ // The youtube follow-ups skip what the config already supplies
186
+ // (youtubeFollowups) — same reason speaker prefills above.
187
+ audience: cfg.audience,
188
+ portrait: cfg.portrait,
189
+ thumbnailBrief: cfg.thumbnailBrief,
163
190
  });
164
191
  console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
165
192
  setReplayArgv(argv); // §129
@@ -195,6 +222,9 @@ export function buildProgram(): Command {
195
222
  speaker: cfg.speaker,
196
223
  modelDir: cfg.modelDir,
197
224
  watermark: cfg.watermark,
225
+ audience: cfg.audience,
226
+ portrait: cfg.portrait,
227
+ thumbnailBrief: cfg.thumbnailBrief,
198
228
  });
199
229
  console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
200
230
  setReplayArgv(argv); // §129
@@ -286,6 +316,17 @@ export function buildProgram(): Command {
286
316
  "--whisper-language <code>",
287
317
  "transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
288
318
  )
319
+ // COMMA-SEPARATED in one value, not variadic: a variadic option swallows
320
+ // the optional positional [input] whenever the flag precedes the path,
321
+ // and commander offers no way to give the positional priority.
322
+ .option(
323
+ "--dictionary <terms>",
324
+ 'comma-separated terms of art the speaker uses, e.g. "JSON, ossclip, Genkit" — ' +
325
+ "biases transcription toward these spellings, vouches them for repair, and " +
326
+ "canonicalizes their casing in captions. Replaces the config's dictionary for " +
327
+ "this run. Needs a whisper-cli new enough to know --prompt (older builds " +
328
+ "reject it with their own error)",
329
+ )
289
330
  .option(
290
331
  "--force-component <id>",
291
332
  "debug: render every graphic with this component (e.g. FlowDiagram) to exercise it on real copy",
@@ -308,8 +349,9 @@ export function buildProgram(): Command {
308
349
  )
309
350
  .option(
310
351
  "--collapse-retakes",
311
- "deterministically collapse consecutive near-identical sentences, keeping only " +
312
- "the last complete attempt — the flub the speaker did NOT mark. Off by default",
352
+ "legacy no-op: retake collapsing runs automatically with --blooper-marker " +
353
+ "(bloopers and retakes go hand-in-hand — no marker, no retake cuts). " +
354
+ "Kept parseable so recorded command.json replays don't error",
313
355
  false,
314
356
  )
315
357
  // Declared as the same tri-state pair as --open-editor/--no-open-editor:
@@ -323,6 +365,35 @@ export function buildProgram(): Command {
323
365
  )
324
366
  .option("--no-watermark", "no wordmark, even when the config turns it on")
325
367
  // Same tri-state shape as --watermark above (positive declared first so
368
+ // commander's default stays undefined = "not typed"): the config's
369
+ // `youtube` key supplies the default (resolveYoutube), and a typed
370
+ // --no-youtube still beats a config-on.
371
+ .option(
372
+ "--youtube",
373
+ "write a YouTube pack beside the video: SEO title options, description, hashtags " +
374
+ "and comma-separated tags (<out>.youtube.md), plus an AI thumbnail " +
375
+ "(set it once with youtube: true in ~/.ossclip/config.json)",
376
+ )
377
+ .option("--no-youtube", "no YouTube pack, even when the config turns it on")
378
+ .option(
379
+ "--portrait <path>",
380
+ "your portrait photo, the likeness reference for the --youtube AI thumbnail " +
381
+ "(default: `portrait` in ~/.ossclip/config.json; without one the frame-grab " +
382
+ "cover stands)",
383
+ )
384
+ .option(
385
+ "--audience <text>",
386
+ 'who watches the channel, e.g. "junior web devs learning AI tooling" — steers ' +
387
+ "the --youtube pack's titles/tags and the AI thumbnail's concept " +
388
+ "(default: `audience` in ~/.ossclip/config.json)",
389
+ )
390
+ .option(
391
+ "--thumbnail-brief <text>",
392
+ "a standing instruction the AI thumbnail concept must honor, e.g. " +
393
+ '"always show the terminal, never stock imagery" ' +
394
+ "(default: `thumbnailBrief` in ~/.ossclip/config.json)",
395
+ )
396
+ // Same tri-state shape as --watermark above (positive declared first so
326
397
  // commander's default stays undefined = "not typed"), though captions
327
398
  // have no config key to fill the gap: the tri-state exists so
328
399
  // command.json can pin the resolved flag state for replay determinism
@@ -336,6 +407,24 @@ export function buildProgram(): Command {
336
407
  "--no-captions",
337
408
  "no burned-in captions. The CTA keyword styling rides the caption track, so it goes too",
338
409
  )
410
+ // A tri-state like --watermark/--captions above, but on TWO commander
411
+ // keys instead of one: the positive is spelled --add-jump-cuts (bare
412
+ // "--jump-cuts" would read as adding CUTS, not the concealing zooms), so
413
+ // commander cannot fold the pair onto one key — --no-jump-cuts creates
414
+ // `jumpCuts` defaulting TRUE, --add-jump-cuts lands on `addJumpCuts`,
415
+ // and the action below reunites them via jumpCutsFlag, reading
416
+ // getOptionValueSource to tell a typed --no-jump-cuts from the default.
417
+ .option(
418
+ "--add-jump-cuts",
419
+ "force the subtle punch-in zooms that conceal jump cuts (already the default). " +
420
+ "The face-only guard still applies — a screen share is never punched, because " +
421
+ "the zoom slides its content — this only beats a config that turns them off",
422
+ )
423
+ .option(
424
+ "--no-jump-cuts",
425
+ "no punch-in zooms at cut boundaries. Narrower than --no-zoom, which kills ALL " +
426
+ "camera motion (the idle push included), not just the cut punch-in",
427
+ )
339
428
  .option("--no-cover", "skip the cover image written beside the video")
340
429
  .option(
341
430
  "--no-zoom",
@@ -350,6 +439,20 @@ export function buildProgram(): Command {
350
439
  )
351
440
  .option("--editor-port <n>", "port for the editor started by --open-editor",
352
441
  (v) => Number.parseInt(v, 10), 5174)
442
+ // No default: undefined = "not typed" is what lets the config's
443
+ // renderConcurrency (and then the cpus-2 guess) supply the value —
444
+ // resolveRenderConcurrency owns the precedence. Recorded runs need nothing
445
+ // special to replay it: command.json stores the argv verbatim
446
+ // (recordedProduceArgs), so a typed --concurrency is already in there, and
447
+ // the editor's Render replays it through THIS parse.
448
+ .option(
449
+ "--concurrency <n>",
450
+ "how many browser tabs render frames in parallel (default: CPU cores - 2, " +
451
+ "floor 2). Turn it DOWN if the render logs 'The browser crashed while " +
452
+ "rendering frame N' — that is the whole browser running out of memory, " +
453
+ "not one frame failing",
454
+ concurrencyFlag,
455
+ )
353
456
  .action(async (input: string | undefined, opts, command: Command) => {
354
457
  if (input === undefined) {
355
458
  // commander 12's parseAsync does not reset option state between calls,
@@ -377,6 +480,9 @@ export function buildProgram(): Command {
377
480
  speaker: cfg.speaker,
378
481
  modelDir: cfg.modelDir,
379
482
  watermark: cfg.watermark,
483
+ audience: cfg.audience,
484
+ portrait: cfg.portrait,
485
+ thumbnailBrief: cfg.thumbnailBrief,
380
486
  });
381
487
  console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
382
488
  // Re-entering the SAME parse the flags take: the zod checks below run
@@ -413,6 +519,15 @@ export function buildProgram(): Command {
413
519
  opts.whisperLanguage !== undefined
414
520
  ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
415
521
  : undefined;
522
+ // --add-jump-cuts / --no-jump-cuts land on DIFFERENT commander keys
523
+ // (see the option declarations for why the pair can't share one);
524
+ // jumpCutsFlag reunites them into the tri-state ProduceOptions
525
+ // carries, and throws on the contradiction of typing both — the same
526
+ // loud-error posture as every parse above.
527
+ const jumpCuts = jumpCutsFlag(
528
+ opts.addJumpCuts,
529
+ command.getOptionValueSource("jumpCuts") === "cli",
530
+ );
416
531
  // Wall clock around produce() only — the editor offer below can sit at
417
532
  // an interactive prompt for as long as the user thinks, and think-time
418
533
  // would poison the duration metric (FINDINGS §134).
@@ -439,6 +554,9 @@ export function buildProgram(): Command {
439
554
  repair: opts.repair,
440
555
  whisperModel: opts.whisperModel,
441
556
  whisperLanguage,
557
+ // Split/trim/drop-empties (dictionaryFlag) — undefined stays
558
+ // undefined so the config's dictionary can supply the default.
559
+ dictionary: dictionaryFlag(opts.dictionary),
442
560
  forceComponent,
443
561
  // commander gives `--no-cover` as cover:false and `--cover <path>` as a
444
562
  // string on the same key.
@@ -450,13 +568,29 @@ export function buildProgram(): Command {
450
568
  zoom: opts.zoom,
451
569
  // undefined = "not typed", so produce can let the config decide.
452
570
  watermark: opts.watermark,
571
+ // The same tri-state contract as watermark, resolved by
572
+ // resolveYoutube at the use site; --portrait rides along untyped =
573
+ // undefined so the config's path can supply it.
574
+ youtube: opts.youtube,
575
+ portrait: opts.portrait,
576
+ // Typed-beats-config strings like --portrait: untyped = undefined
577
+ // lets the config's `audience`/`thumbnailBrief` supply them; the
578
+ // `typeof === "string"` validation lives at the use site.
579
+ audience: opts.audience,
580
+ thumbnailBrief: opts.thumbnailBrief,
453
581
  // undefined = "not typed" here too — the default (ON) is applied at
454
582
  // the pin site, not coerced in transit.
455
583
  captions: opts.captions,
584
+ // The reunited tri-state (jumpCutsFlag above): undefined = "not
585
+ // typed" = auto, the face-only default.
586
+ jumpCuts,
456
587
  cover: opts.cover !== false,
457
588
  coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
458
589
  clip: opts.clip,
459
590
  clipWindow: opts.clipWindow,
591
+ // Validated by concurrencyFlag at parse time; undefined = "not
592
+ // typed", which is what lets the config supply it.
593
+ concurrency: opts.concurrency,
460
594
  });
461
595
  // Counts, buckets and names only — the duration crosses the wire as a
462
596
  // bucket, and nothing here can carry a path (assertSafeProps enforces
@@ -576,7 +710,7 @@ export function buildProgram(): Command {
576
710
  )
577
711
  .option(
578
712
  "--collapse-retakes",
579
- "also mark consecutive near-identical sentences, keeping only the last complete attempt",
713
+ "legacy no-op: retake marking runs automatically with --blooper-marker",
580
714
  false,
581
715
  )
582
716
  .option("--sort <order>", "folder input: clip order, name | mtime", "name")
@@ -630,8 +764,10 @@ export function buildProgram(): Command {
630
764
  .argument("<renderProps>", "path to a work dir's render-props.json")
631
765
  .option("--video-dir <dir>", "directory containing the source video (public dir)")
632
766
  .action(async (renderProps: string, opts) => {
633
- const propsPath = resolve(renderProps);
634
- const publicDir = opts.videoDir ? resolve(opts.videoDir) : dirname(propsPath);
767
+ // expandHome before resolve on both user-typed paths (2026-08-16 rule,
768
+ // paths.ts) — a `~/` here must not resolve against cwd.
769
+ const propsPath = resolve(expandHome(renderProps));
770
+ const publicDir = opts.videoDir ? resolve(expandHome(opts.videoDir)) : dirname(propsPath);
635
771
  // Resolve Remotion's CLI through module resolution instead of spawning
636
772
  // `pnpm` — a global `npm i -g ossclip` has no pnpm and no workspace, and
637
773
  // Windows would need the .cmd shim. `@remotion/cli` is a dependency of
@@ -17,6 +17,10 @@
17
17
  * the direct path byte-identical to what it always wrote.
18
18
  */
19
19
 
20
+ // Type-only, so no runtime edge back into produce.ts (which imports this
21
+ // module): the tri-state's vocabulary belongs to its resolver.
22
+ import type { JumpCutsMode } from "./produce";
23
+
20
24
  let stashed: string[] | null = null;
21
25
 
22
26
  /**
@@ -53,6 +57,16 @@ export function recordedProduceArgs(pins: {
53
57
  clipWindow?: string;
54
58
  watermark?: boolean;
55
59
  captions?: boolean;
60
+ jumpCuts?: JumpCutsMode;
61
+ /** The RESOLVED dictionary terms — pinned only when non-empty. */
62
+ dictionary?: string[];
63
+ youtube?: boolean;
64
+ /** The RESOLVED portrait path — a path, never a secret. */
65
+ portrait?: string;
66
+ /** The RESOLVED audience text — pinned only when non-empty. */
67
+ audience?: string;
68
+ /** The RESOLVED thumbnail brief — pinned only when non-empty. */
69
+ thumbnailBrief?: string;
56
70
  }): string[] {
57
71
  const args = consumeReplayArgv() ?? process.argv.slice(2);
58
72
  if (pins.llm !== undefined && !args.includes("--llm")) {
@@ -89,5 +103,66 @@ export function recordedProduceArgs(pins: {
89
103
  if (pins.captions !== undefined && !args.includes("--captions") && !args.includes("--no-captions")) {
90
104
  args.push(pins.captions ? "--captions" : "--no-captions");
91
105
  }
106
+ // The jump-cuts pin covers the two TYPED states only — force spells
107
+ // --add-jump-cuts, off spells --no-jump-cuts, and either typed flag
108
+ // settles the tri-state, so the includes-guard checks BOTH spellings
109
+ // before appending either. "auto" stays UNPINNED, which is the captions
110
+ // rationale run in reverse: there is no flag that SPELLS auto to pin
111
+ // with, and with no config input today an argv carrying neither flag
112
+ // replays as auto identically everywhere. The captions comment's warning
113
+ // still applies — the day a jumpCuts config key lands, auto records made
114
+ // after it must pin their resolved on/off like the watermark's, and the
115
+ // old unpinned auto records are the accepted cost of a flag pair that
116
+ // reserves both spellings for the typed states.
117
+ if (
118
+ pins.jumpCuts !== undefined &&
119
+ pins.jumpCuts !== "auto" &&
120
+ !args.includes("--add-jump-cuts") &&
121
+ !args.includes("--no-jump-cuts")
122
+ ) {
123
+ args.push(pins.jumpCuts === "force" ? "--add-jump-cuts" : "--no-jump-cuts");
124
+ }
125
+ // The dictionary pin (review finding, F4 follow-up): the resolved terms may
126
+ // have come from ~/.ossclip/config.json, and the dictionary feeds the
127
+ // whisper prompt, the repair vouched set and caption casing — so an
128
+ // unpinned record replays a DIFFERENT transcript the moment that config is
129
+ // edited. Comma-joined into one value, the exact spelling `--dictionary`
130
+ // takes (dictionaryFlag re-splits and trims on replay). Empty stays
131
+ // unpinned: there is no flag spelling for "no terms", and `--dictionary ""`
132
+ // would split to [] anyway — an argv without the flag replays as "config
133
+ // decides", the accepted cost mirroring jump-cuts' unpinnable auto.
134
+ if (pins.dictionary !== undefined && pins.dictionary.length > 0 && !args.includes("--dictionary")) {
135
+ args.push("--dictionary", pins.dictionary.join(", "));
136
+ }
137
+ // The youtube pin — the watermark's rationale verbatim: its effective
138
+ // default is config-dependent (`youtube: true` in ~/.ossclip/config.json),
139
+ // so every record carries the RESOLVED state in BOTH directions, or a
140
+ // later config edit silently changes what the editor's Render writes
141
+ // beside the replayed video.
142
+ if (pins.youtube !== undefined && !args.includes("--youtube") && !args.includes("--no-youtube")) {
143
+ args.push(pins.youtube ? "--youtube" : "--no-youtube");
144
+ }
145
+ // The portrait pin: the resolved PATH (never a secret — the API key stays
146
+ // in the environment), so a replay renders the thumbnail from the same
147
+ // face the run did even after the config's `portrait` moves.
148
+ if (pins.portrait !== undefined && !args.includes("--portrait")) {
149
+ args.push("--portrait", pins.portrait);
150
+ }
151
+ // Audience and thumbnail-brief pins, the portrait's rationale exactly: the
152
+ // resolved values may have come from ~/.ossclip/config.json, and both steer
153
+ // LLM prompts (the youtube pack, the thumbnail concept) — an unpinned
154
+ // record would replay different metadata after a config edit. Empty stays
155
+ // unpinned, the dictionary's rule: there is no flag spelling for "no
156
+ // steer", and an argv without the flag replays as "config decides".
157
+ if (pins.audience !== undefined && pins.audience.length > 0 && !args.includes("--audience")) {
158
+ args.push("--audience", pins.audience);
159
+ }
160
+ if (
161
+ pins.thumbnailBrief !== undefined &&
162
+ pins.thumbnailBrief.length > 0 &&
163
+ !args.includes("--thumbnail-brief")
164
+ ) {
165
+ args.push("--thumbnail-brief", pins.thumbnailBrief);
166
+ }
92
167
  return args;
93
168
  }
@@ -13,6 +13,9 @@
13
13
  * platform×arch resolves to either an asset or an explicit manual hint.
14
14
  */
15
15
 
16
+ import { basename, isAbsolute, join } from "node:path";
17
+ import { z } from "zod/v4";
18
+
16
19
  export interface BinaryAsset {
17
20
  url: string;
18
21
  sha256: string;
@@ -135,10 +138,24 @@ export function whisperAsset(platform: NodeJS.Platform, arch: string): BinaryAss
135
138
  * models/README.md — upstream publishes SHA-1, so that's what we verify;
136
139
  * it's an integrity check against truncated downloads, not a security
137
140
  * boundary (the download is already pinned to a host and path over HTTPS).
141
+ *
142
+ * Curated fine-tunes ride the same table: `url` points at their own host
143
+ * (ggerganov's mirror only carries the stock models — the old hardcoded URL
144
+ * 404'd for every custom name, and the suggested `curl -L` then saved the
145
+ * 404 HTML as a fake model), and `language` records what the fine-tune
146
+ * decodes so produce can imply `-l` when nothing else sets one (an Urdu
147
+ * fine-tune without `-l ur` silently decodes English garbage — Urdu field
148
+ * test 2026-08-05).
138
149
  */
139
150
  export interface ModelInfo {
140
- sizeMB: number;
141
- sha1: string;
151
+ sizeMB?: number;
152
+ sha1?: string;
153
+ /** Direct download URL for models the ggerganov mirror doesn't host. */
154
+ url?: string;
155
+ /** The language the fine-tune decodes — implied `-l` when neither flag nor config sets one. */
156
+ language?: string;
157
+ /** Provenance, printed by setup at download time and shown as the wizard hint. */
158
+ note?: string;
142
159
  }
143
160
 
144
161
  export const MODELS: Record<string, ModelInfo> = {
@@ -146,10 +163,82 @@ export const MODELS: Record<string, ModelInfo> = {
146
163
  "base.en": { sizeMB: 142, sha1: "137c40403d78fd54d454da0f9bd998f78703390c" },
147
164
  "small.en": { sizeMB: 466, sha1: "db8a495a91d927739e50b3fc1cc4c6b8f6c2d022" },
148
165
  "medium.en": { sizeMB: 1536, sha1: "8c30f0e44ce9560643ebd10bbe50cd20eafd3723" },
166
+ // URL pending the author's upload (2026-08-17) — sha1 added when the file
167
+ // is published; setup already warns-and-continues without a checksum.
168
+ "medium-urdu": {
169
+ sizeMB: 1463,
170
+ // sha1 computed 2026-08-17 from the author's converted file BEFORE the HF
171
+ // upload — same bytes, so the pin is valid the moment the file publishes,
172
+ // and a corrupted/tampered mirror download fails the checksum loudly.
173
+ sha1: "59769d590f62eeeb3bc3f5b82ce8c03b6e96831e",
174
+ language: "ur",
175
+ note: "community Urdu fine-tune (Abdul145/whisper-medium-urdu-custom, Apache-2.0), converted to GGML",
176
+ url: "https://huggingface.co/CodeWithAhsan/whisper-medium-urdu-ggml/resolve/main/ggml-medium-urdu.bin",
177
+ },
149
178
  };
150
179
 
151
- export function modelUrl(name: string): string {
152
- return `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${name}.bin`;
180
+ /**
181
+ * A model pick reduced to its bare name: basename, minus the optional ggml-
182
+ * prefix and .bin suffix. Exists because classifying on the raw value
183
+ * misreads paths — an absolute /x/ggml-small.en.bin ends in ".bin", so the
184
+ * wizard's `.endsWith(".en")` language heuristic prefilled `auto` for an
185
+ * ENGLISH model (review fix, Urdu field test 2026-08-05) — and the MODELS
186
+ * lookups below must find `medium-urdu` inside either spelling.
187
+ */
188
+ export function bareWhisperModelName(nameOrPath: string): string {
189
+ const base = basename(nameOrPath);
190
+ const m = /^(?:ggml-)?(.+?)(?:\.bin)?$/.exec(base);
191
+ return m?.[1] ?? base;
192
+ }
193
+
194
+ /**
195
+ * The language a model pick implies when the user set none: the curated
196
+ * table's `language`, keyed on the bare name so `--whisper-model medium-urdu`
197
+ * and an absolute /x/ggml-medium-urdu.bin both resolve it. Undefined for
198
+ * stock models and unknown fine-tunes — whisper's own en default stands.
199
+ */
200
+ export function modelImpliedLanguage(nameOrPath: string): string | undefined {
201
+ return MODELS[bareWhisperModelName(nameOrPath)]?.language;
202
+ }
203
+
204
+ /**
205
+ * THE model-download URL — the single source produce's missing-model error,
206
+ * doctor's fix line, and setup's download all read (they used to hold string
207
+ * dupes of the ggerganov URL, which 404'd for any custom name). Precedence:
208
+ * the config's `modelSources` entry (a user's own fine-tune is one config
209
+ * line) > the curated table's `url` > the ggerganov default mirror.
210
+ */
211
+ export function modelUrl(name: string, sources?: Record<string, string>): string {
212
+ return (
213
+ sources?.[name] ??
214
+ MODELS[name]?.url ??
215
+ `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${name}.bin`
216
+ );
217
+ }
218
+
219
+ /**
220
+ * The one model-path resolution rule, extracted from its three duplicated
221
+ * sites (produce's transcription step, doctor's model check, setup's plan):
222
+ * an absolute model is a file path used verbatim, a bare name lives in
223
+ * modelDir as ggml-<name>.bin.
224
+ */
225
+ export function whisperModelPath(model: string, modelDir: string): string {
226
+ return isAbsolute(model) ? model : join(modelDir, `ggml-${model}.bin`);
227
+ }
228
+
229
+ /**
230
+ * Consumer-side vetting for the config's `modelSources` key — the
231
+ * `validDictionary` posture (produce.ts) applied to a record: the value
232
+ * comes from a hand-editable JSON file loadConfig doesn't zod-parse, so a
233
+ * non-object, a non-string URL, or a URL that trims to nothing means the
234
+ * whole key is ignored (`undefined`) and the call site warns once.
235
+ * All-or-nothing on purpose: half a typo'd map would download some models
236
+ * from a source the user never reviewed.
237
+ */
238
+ export function validModelSources(value: unknown): Record<string, string> | undefined {
239
+ const parsed = z.record(z.string(), z.string().trim().min(1)).safeParse(value);
240
+ if (!parsed.success || Object.keys(parsed.data).length === 0) return undefined;
241
+ return parsed.data;
153
242
  }
154
243
 
155
244
  /** The exact build recipe printed when no prebuilt fits — one copy, not four. */
package/src/setup/plan.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { isAbsolute, join } from "node:path";
2
2
  import type { OssclipConfig } from "@ossclip/core";
3
- import { type BinaryAsset, MODELS, ffmpegAsset, modelUrl, whisperAsset } from "./manifest";
3
+ import {
4
+ type BinaryAsset,
5
+ MODELS,
6
+ ffmpegAsset,
7
+ modelUrl,
8
+ validModelSources,
9
+ whisperAsset,
10
+ whisperModelPath,
11
+ } from "./manifest";
4
12
 
5
13
  /**
6
14
  * The planning half of `ossclip setup` — pure over injected probes, like
@@ -137,11 +145,11 @@ export async function planSetup(
137
145
  }
138
146
  }
139
147
 
140
- // The model: same resolution produce and doctor use — absolute is a file
141
- // path, a bare name lives in modelDir as ggml-<name>.bin. `--force` never
142
- // re-downloads a present model; a corrupt one is deleted by hand.
148
+ // The model: same resolution produce and doctor use (whisperModelPath).
149
+ // `--force` never re-downloads a present model; a corrupt one is deleted
150
+ // by hand.
143
151
  const model = opts.model;
144
- const modelPath = isAbsolute(model) ? model : join(cfg.modelDir, `ggml-${model}.bin`);
152
+ const modelPath = whisperModelPath(model, cfg.modelDir);
145
153
  const known = MODELS[model];
146
154
  if (p.exists(modelPath)) {
147
155
  steps.push({ kind: "model", status: "satisfied", detail: modelPath });
@@ -156,7 +164,10 @@ export async function planSetup(
156
164
  steps.push({
157
165
  kind: "model",
158
166
  status: "download",
159
- detail: `${modelUrl(model)} → ${modelPath}`,
167
+ // The config's modelSources beats the curated/default hosts here for
168
+ // the same reason it does at download time — the plan must name the
169
+ // URL setup will actually fetch.
170
+ detail: `${modelUrl(model, validModelSources(cfg.modelSources))} → ${modelPath}`,
160
171
  sizeMB: known?.sizeMB,
161
172
  });
162
173
  }
@@ -1,9 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, rmSync } from "node:fs";
3
- import { basename, isAbsolute, join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { CONFIG_DIR, loadConfig, saveConfigPatch, type OssclipConfig } from "@ossclip/core";
6
- import { MODELS, WHISPER_BUILD_HINT, modelUrl, type BinaryAsset } from "./manifest";
6
+ import {
7
+ MODELS,
8
+ WHISPER_BUILD_HINT,
9
+ modelUrl,
10
+ validModelSources,
11
+ whisperModelPath,
12
+ type BinaryAsset,
13
+ } from "./manifest";
7
14
  import { formatPlan, managedBinDir, planSetup, type SetupProbes, type SetupStep } from "./plan";
8
15
  import { download, progressLine } from "./download";
9
16
  import { extractArchive, findFile, markExecutable } from "./extract";
@@ -130,17 +137,31 @@ export async function setup(
130
137
  break;
131
138
  case "model":
132
139
  if (step.status === "download") {
133
- const modelPath = isAbsolute(model)
134
- ? model
135
- : join(cfg.modelDir, `ggml-${model}.bin`);
140
+ const modelPath = whisperModelPath(model, cfg.modelDir);
136
141
  const info = MODELS[model];
142
+ // Provenance out loud for a curated fine-tune — the user is about
143
+ // to fetch a community model, and the note says whose.
144
+ if (info?.note) console.log(`▸ ${model}: ${info.note}`);
137
145
  if (!info) {
138
146
  console.log(
139
147
  `▸ ${model} isn't in the pinned table — downloading without a checksum.`,
140
148
  );
149
+ } else if (!info.sha1) {
150
+ // A curated entry can predate its published checksum (the URL
151
+ // is pinned before the upstream upload settles) — disclose the
152
+ // unverified download the same way an off-table name does.
153
+ console.log(`▸ ${model} has no pinned checksum yet — downloading without one.`);
141
154
  }
142
- console.log(`▸ downloading ggml-${model}.bin${info ? ` (~${info.sizeMB} MB)` : ""}…`);
143
- await download(modelUrl(model), modelPath, {
155
+ const sources = validModelSources(cfg.modelSources);
156
+ if (cfg.modelSources !== undefined && sources === undefined) {
157
+ console.log(
158
+ "⚠ config modelSources ignored — expected an object of name → URL strings",
159
+ );
160
+ }
161
+ console.log(
162
+ `▸ downloading ggml-${model}.bin${info?.sizeMB ? ` (~${info.sizeMB} MB)` : ""}…`,
163
+ );
164
+ await download(modelUrl(model, sources), modelPath, {
144
165
  sha1: info?.sha1,
145
166
  onProgress: progressLine(`ggml-${model}.bin`),
146
167
  });