ossclip 0.1.23 → 0.1.25

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
@@ -160,6 +161,11 @@ export function buildProgram(): Command {
160
161
  modelDir: cfg.modelDir,
161
162
  input: path,
162
163
  watermark: cfg.watermark,
164
+ // The youtube follow-ups skip what the config already supplies
165
+ // (youtubeFollowups) — same reason speaker prefills above.
166
+ audience: cfg.audience,
167
+ portrait: cfg.portrait,
168
+ thumbnailBrief: cfg.thumbnailBrief,
163
169
  });
164
170
  console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
165
171
  setReplayArgv(argv); // §129
@@ -195,6 +201,9 @@ export function buildProgram(): Command {
195
201
  speaker: cfg.speaker,
196
202
  modelDir: cfg.modelDir,
197
203
  watermark: cfg.watermark,
204
+ audience: cfg.audience,
205
+ portrait: cfg.portrait,
206
+ thumbnailBrief: cfg.thumbnailBrief,
198
207
  });
199
208
  console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
200
209
  setReplayArgv(argv); // §129
@@ -286,6 +295,17 @@ export function buildProgram(): Command {
286
295
  "--whisper-language <code>",
287
296
  "transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
288
297
  )
298
+ // COMMA-SEPARATED in one value, not variadic: a variadic option swallows
299
+ // the optional positional [input] whenever the flag precedes the path,
300
+ // and commander offers no way to give the positional priority.
301
+ .option(
302
+ "--dictionary <terms>",
303
+ 'comma-separated terms of art the speaker uses, e.g. "JSON, ossclip, Genkit" — ' +
304
+ "biases transcription toward these spellings, vouches them for repair, and " +
305
+ "canonicalizes their casing in captions. Replaces the config's dictionary for " +
306
+ "this run. Needs a whisper-cli new enough to know --prompt (older builds " +
307
+ "reject it with their own error)",
308
+ )
289
309
  .option(
290
310
  "--force-component <id>",
291
311
  "debug: render every graphic with this component (e.g. FlowDiagram) to exercise it on real copy",
@@ -308,8 +328,9 @@ export function buildProgram(): Command {
308
328
  )
309
329
  .option(
310
330
  "--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",
331
+ "legacy no-op: retake collapsing runs automatically with --blooper-marker " +
332
+ "(bloopers and retakes go hand-in-hand — no marker, no retake cuts). " +
333
+ "Kept parseable so recorded command.json replays don't error",
313
334
  false,
314
335
  )
315
336
  // Declared as the same tri-state pair as --open-editor/--no-open-editor:
@@ -323,6 +344,35 @@ export function buildProgram(): Command {
323
344
  )
324
345
  .option("--no-watermark", "no wordmark, even when the config turns it on")
325
346
  // Same tri-state shape as --watermark above (positive declared first so
347
+ // commander's default stays undefined = "not typed"): the config's
348
+ // `youtube` key supplies the default (resolveYoutube), and a typed
349
+ // --no-youtube still beats a config-on.
350
+ .option(
351
+ "--youtube",
352
+ "write a YouTube pack beside the video: SEO title options, description, hashtags " +
353
+ "and comma-separated tags (<out>.youtube.md), plus an AI thumbnail " +
354
+ "(set it once with youtube: true in ~/.ossclip/config.json)",
355
+ )
356
+ .option("--no-youtube", "no YouTube pack, even when the config turns it on")
357
+ .option(
358
+ "--portrait <path>",
359
+ "your portrait photo, the likeness reference for the --youtube AI thumbnail " +
360
+ "(default: `portrait` in ~/.ossclip/config.json; without one the frame-grab " +
361
+ "cover stands)",
362
+ )
363
+ .option(
364
+ "--audience <text>",
365
+ 'who watches the channel, e.g. "junior web devs learning AI tooling" — steers ' +
366
+ "the --youtube pack's titles/tags and the AI thumbnail's concept " +
367
+ "(default: `audience` in ~/.ossclip/config.json)",
368
+ )
369
+ .option(
370
+ "--thumbnail-brief <text>",
371
+ "a standing instruction the AI thumbnail concept must honor, e.g. " +
372
+ '"always show the terminal, never stock imagery" ' +
373
+ "(default: `thumbnailBrief` in ~/.ossclip/config.json)",
374
+ )
375
+ // Same tri-state shape as --watermark above (positive declared first so
326
376
  // commander's default stays undefined = "not typed"), though captions
327
377
  // have no config key to fill the gap: the tri-state exists so
328
378
  // command.json can pin the resolved flag state for replay determinism
@@ -336,6 +386,24 @@ export function buildProgram(): Command {
336
386
  "--no-captions",
337
387
  "no burned-in captions. The CTA keyword styling rides the caption track, so it goes too",
338
388
  )
389
+ // A tri-state like --watermark/--captions above, but on TWO commander
390
+ // keys instead of one: the positive is spelled --add-jump-cuts (bare
391
+ // "--jump-cuts" would read as adding CUTS, not the concealing zooms), so
392
+ // commander cannot fold the pair onto one key — --no-jump-cuts creates
393
+ // `jumpCuts` defaulting TRUE, --add-jump-cuts lands on `addJumpCuts`,
394
+ // and the action below reunites them via jumpCutsFlag, reading
395
+ // getOptionValueSource to tell a typed --no-jump-cuts from the default.
396
+ .option(
397
+ "--add-jump-cuts",
398
+ "force the subtle punch-in zooms that conceal jump cuts (already the default). " +
399
+ "The face-only guard still applies — a screen share is never punched, because " +
400
+ "the zoom slides its content — this only beats a config that turns them off",
401
+ )
402
+ .option(
403
+ "--no-jump-cuts",
404
+ "no punch-in zooms at cut boundaries. Narrower than --no-zoom, which kills ALL " +
405
+ "camera motion (the idle push included), not just the cut punch-in",
406
+ )
339
407
  .option("--no-cover", "skip the cover image written beside the video")
340
408
  .option(
341
409
  "--no-zoom",
@@ -377,6 +445,9 @@ export function buildProgram(): Command {
377
445
  speaker: cfg.speaker,
378
446
  modelDir: cfg.modelDir,
379
447
  watermark: cfg.watermark,
448
+ audience: cfg.audience,
449
+ portrait: cfg.portrait,
450
+ thumbnailBrief: cfg.thumbnailBrief,
380
451
  });
381
452
  console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
382
453
  // Re-entering the SAME parse the flags take: the zod checks below run
@@ -413,6 +484,15 @@ export function buildProgram(): Command {
413
484
  opts.whisperLanguage !== undefined
414
485
  ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
415
486
  : undefined;
487
+ // --add-jump-cuts / --no-jump-cuts land on DIFFERENT commander keys
488
+ // (see the option declarations for why the pair can't share one);
489
+ // jumpCutsFlag reunites them into the tri-state ProduceOptions
490
+ // carries, and throws on the contradiction of typing both — the same
491
+ // loud-error posture as every parse above.
492
+ const jumpCuts = jumpCutsFlag(
493
+ opts.addJumpCuts,
494
+ command.getOptionValueSource("jumpCuts") === "cli",
495
+ );
416
496
  // Wall clock around produce() only — the editor offer below can sit at
417
497
  // an interactive prompt for as long as the user thinks, and think-time
418
498
  // would poison the duration metric (FINDINGS §134).
@@ -439,6 +519,9 @@ export function buildProgram(): Command {
439
519
  repair: opts.repair,
440
520
  whisperModel: opts.whisperModel,
441
521
  whisperLanguage,
522
+ // Split/trim/drop-empties (dictionaryFlag) — undefined stays
523
+ // undefined so the config's dictionary can supply the default.
524
+ dictionary: dictionaryFlag(opts.dictionary),
442
525
  forceComponent,
443
526
  // commander gives `--no-cover` as cover:false and `--cover <path>` as a
444
527
  // string on the same key.
@@ -450,9 +533,22 @@ export function buildProgram(): Command {
450
533
  zoom: opts.zoom,
451
534
  // undefined = "not typed", so produce can let the config decide.
452
535
  watermark: opts.watermark,
536
+ // The same tri-state contract as watermark, resolved by
537
+ // resolveYoutube at the use site; --portrait rides along untyped =
538
+ // undefined so the config's path can supply it.
539
+ youtube: opts.youtube,
540
+ portrait: opts.portrait,
541
+ // Typed-beats-config strings like --portrait: untyped = undefined
542
+ // lets the config's `audience`/`thumbnailBrief` supply them; the
543
+ // `typeof === "string"` validation lives at the use site.
544
+ audience: opts.audience,
545
+ thumbnailBrief: opts.thumbnailBrief,
453
546
  // undefined = "not typed" here too — the default (ON) is applied at
454
547
  // the pin site, not coerced in transit.
455
548
  captions: opts.captions,
549
+ // The reunited tri-state (jumpCutsFlag above): undefined = "not
550
+ // typed" = auto, the face-only default.
551
+ jumpCuts,
456
552
  cover: opts.cover !== false,
457
553
  coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
458
554
  clip: opts.clip,
@@ -576,7 +672,7 @@ export function buildProgram(): Command {
576
672
  )
577
673
  .option(
578
674
  "--collapse-retakes",
579
- "also mark consecutive near-identical sentences, keeping only the last complete attempt",
675
+ "legacy no-op: retake marking runs automatically with --blooper-marker",
580
676
  false,
581
677
  )
582
678
  .option("--sort <order>", "folder input: clip order, name | mtime", "name")
@@ -630,8 +726,10 @@ export function buildProgram(): Command {
630
726
  .argument("<renderProps>", "path to a work dir's render-props.json")
631
727
  .option("--video-dir <dir>", "directory containing the source video (public dir)")
632
728
  .action(async (renderProps: string, opts) => {
633
- const propsPath = resolve(renderProps);
634
- const publicDir = opts.videoDir ? resolve(opts.videoDir) : dirname(propsPath);
729
+ // expandHome before resolve on both user-typed paths (2026-08-16 rule,
730
+ // paths.ts) — a `~/` here must not resolve against cwd.
731
+ const propsPath = resolve(expandHome(renderProps));
732
+ const publicDir = opts.videoDir ? resolve(expandHome(opts.videoDir)) : dirname(propsPath);
635
733
  // Resolve Remotion's CLI through module resolution instead of spawning
636
734
  // `pnpm` — a global `npm i -g ossclip` has no pnpm and no workspace, and
637
735
  // 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
  });