ossclip 0.1.26 → 0.1.28

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
@@ -3,13 +3,13 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { Command, InvalidArgumentError } from "commander";
5
5
  import { z } from "zod/v4";
6
- import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
6
+ import { CleanupLevelSchema, COVER_MAX_WORDS, 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
10
  import { expandHome } from "./paths";
11
11
  import { phaseBucketProps } from "./phase-timing";
12
- import { dictionaryFlag, jumpCutsFlag, produce } from "./produce";
12
+ import { dictionaryFlag, jumpCutsFlag, produce, reviewFlag } from "./produce";
13
13
  // The one interactive import that is STATIC rather than `await import()`: the
14
14
  // `resetInputSource()` run boundary in `buildProgram` has to run synchronously
15
15
  // while the program is being built, and `buildProgram` cannot await. The graph
@@ -60,6 +60,42 @@ export function concurrencyFlag(v: string): number {
60
60
  return n;
61
61
  }
62
62
 
63
+ /**
64
+ * `<command> [workdir]` → the workdir the user meant.
65
+ *
66
+ * ONE spelling of the probe → resolve → pick ladder, because `edit` and
67
+ * `cover` both need it and two copies drift: the reported failure it exists
68
+ * for is `ossclip edit <video folder>` when produce wrote into
69
+ * `<video folder>/.ossclip/<name>/`, and a `cover` that resolved differently
70
+ * would rebuild a cover for a run the user is not looking at.
71
+ *
72
+ * `command` is threaded so the no-TTY candidate list names the command that
73
+ * was actually run, not always `edit`.
74
+ *
75
+ * Every import here is dynamic on purpose: the interactive stack is not
76
+ * loaded on invocations that never reach a picker, which is what keeps CLI
77
+ * startup cheap.
78
+ */
79
+ async function resolveWorkdirArgument(typed: string, command: string): Promise<string> {
80
+ const { probeWorkdir } = await import("./interactive/workdir-probe");
81
+ const { resolveWorkdir, candidateListMessage } = await import("./interactive/resolve-workdir");
82
+ const { isInteractive } = await import("./interactive/tty");
83
+ const { dir, probe } = await probeWorkdir(typed);
84
+ const resolution = resolveWorkdir(dir, probe);
85
+ if (resolution.kind === "none") throw new Error(resolution.message);
86
+ if (resolution.kind === "choose") {
87
+ if (!isInteractive()) {
88
+ throw new Error(candidateListMessage(dir, resolution.candidates, command));
89
+ }
90
+ const { pickWorkdir } = await import("./interactive/pick-workdir");
91
+ return await pickWorkdir(resolution.candidates);
92
+ }
93
+ // Say so when the path was not the one typed — a silent redirect leaves the
94
+ // user with the wrong mental model of where things live.
95
+ if (resolution.via === "nested") console.log(`▸ resolved ${typed} → ${resolution.workdir}`);
96
+ return resolution.workdir;
97
+ }
98
+
63
99
  /**
64
100
  * Every command this CLI has, built onto a fresh instance.
65
101
  *
@@ -298,6 +334,10 @@ export function buildProgram(): Command {
298
334
  "if ANTHROPIC_API_KEY is set, else claude-cli",
299
335
  )
300
336
  .option("--llm-model <id>", "override the provider's default model")
337
+ .option(
338
+ "--llm-effort <level>",
339
+ "reasoning effort for the antigravity provider (low|medium|high)",
340
+ )
301
341
  .option(
302
342
  "--llm-fast-model <id>",
303
343
  "model for mechanical calls (repair, scene props); 'same' disables tiering",
@@ -432,11 +472,23 @@ export function buildProgram(): Command {
432
472
  "any motion crops the head. Per-scene control stays in the editor (autoZoom)",
433
473
  )
434
474
  .option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
475
+ .option(
476
+ "--cover-text-reset",
477
+ "use this run's generated cover headline even if `ossclip cover --text` set one — " +
478
+ "that headline is user-owned and kept by default (deleting cover.json does the same)",
479
+ )
435
480
  .option("--open-editor", "open the editor when the run finishes")
436
481
  .option(
437
482
  "--no-open-editor",
438
483
  "don't open the editor, and don't ask (overrides openEditorAfterProduce)",
439
484
  )
485
+ // Implies --no-render and forces the editor open — resolved by reviewFlag
486
+ // in the action, where typing --no-render alongside it is agreement and
487
+ // --no-open-editor is the loud contradiction.
488
+ .option(
489
+ "--review",
490
+ "produce without rendering, then open the editor to review the cut before rendering once",
491
+ )
440
492
  .option("--editor-port <n>", "port for the editor started by --open-editor",
441
493
  (v) => Number.parseInt(v, 10), 5174)
442
494
  // No default: undefined = "not typed" is what lets the config's
@@ -498,6 +550,13 @@ export function buildProgram(): Command {
498
550
  const provider = opts.llm
499
551
  ? z.enum(["antigravity", "claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
500
552
  : undefined;
553
+ // Parsed like --llm above: a typo'd `--llm-effort hgh` must die here
554
+ // naming the allowed values, not silently run at agy's default — the
555
+ // knob exists because of a hang (§143), and a user reaching for it is
556
+ // mid-investigation.
557
+ const llmEffort = opts.llmEffort
558
+ ? z.enum(["low", "medium", "high"]).parse(opts.llmEffort)
559
+ : undefined;
501
560
  const forceComponent = opts.forceComponent
502
561
  ? SceneComponentIdSchema.parse(opts.forceComponent)
503
562
  : undefined;
@@ -528,6 +587,15 @@ export function buildProgram(): Command {
528
587
  opts.addJumpCuts,
529
588
  command.getOptionValueSource("jumpCuts") === "cli",
530
589
  );
590
+ // --review resolved BEFORE produce runs: it implies --no-render (typed
591
+ // alongside is agreement) and forces the end-of-run editor open, so the
592
+ // one render happens from the editor's Render button. reviewFlag throws
593
+ // on --no-open-editor, the jumpCutsFlag contradiction posture.
594
+ const { render, openEditor } = reviewFlag(
595
+ opts.review === true,
596
+ opts.render,
597
+ opts.openEditor,
598
+ );
531
599
  // Wall clock around produce() only — the editor offer below can sit at
532
600
  // an interactive prompt for as long as the user thinks, and think-time
533
601
  // would poison the duration metric (FINDINGS §134).
@@ -537,7 +605,12 @@ export function buildProgram(): Command {
537
605
  out: opts.out,
538
606
  cleanup,
539
607
  transcript: opts.transcript,
540
- render: opts.render,
608
+ // The reviewFlag resolution, not opts.render: --review implies off.
609
+ render,
610
+ // Only phrases the no-render exit ("the editor is opening" instead
611
+ // of the --no-render skip + edit hint) — the render/openEditor
612
+ // consequences are already resolved above.
613
+ review: opts.review === true,
541
614
  mezzanine: opts.mezzanine,
542
615
  workdir: opts.workdir,
543
616
  sort,
@@ -549,6 +622,9 @@ export function buildProgram(): Command {
549
622
  provider,
550
623
  llmModel: opts.llmModel,
551
624
  llmFastModel: opts.llmFastModel,
625
+ // The zod-parsed union above; untyped = undefined lets the config's
626
+ // `llmEffort` supply it (resolveLlmEffort at the use site).
627
+ llmEffort,
552
628
  speaker: opts.speaker,
553
629
  scenes: opts.scenes,
554
630
  repair: opts.repair,
@@ -586,6 +662,9 @@ export function buildProgram(): Command {
586
662
  jumpCuts,
587
663
  cover: opts.cover !== false,
588
664
  coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
665
+ // A separate key from --cover: this one is about the TEXT, and the
666
+ // cover/coverPath pair already shares one.
667
+ coverTextReset: opts.coverTextReset === true,
589
668
  clip: opts.clip,
590
669
  clipWindow: opts.clipWindow,
591
670
  // Validated by concurrencyFlag at parse time; undefined = "not
@@ -601,7 +680,10 @@ export function buildProgram(): Command {
601
680
  produced: opts.produce === true,
602
681
  aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
603
682
  clip: opts.clip !== undefined,
604
- render: opts.render !== false,
683
+ // The reviewFlag resolution, spelled `key:` and not shorthand — a
684
+ // --review run must report render: false, and telemetry.test.ts's
685
+ // source-drift scan (§136) only sees `key:`-form props.
686
+ render: render,
605
687
  source_duration_bucket: durationBucket(result.sourceDurationSec),
606
688
  scenes: result.sceneCount,
607
689
  // Per-phase buckets (§140), one `<phase>_bucket` per phase that
@@ -624,7 +706,13 @@ export function buildProgram(): Command {
624
706
  }
625
707
  }
626
708
  const { offerEditor } = await import("./interactive/offer-editor");
627
- await offerEditor(result, { flag: opts.openEditor, port: opts.editorPort });
709
+ // The reviewFlag resolution: --review forces flag=true, and
710
+ // decideOpenEditor already documents that an explicit flag beats
711
+ // `rendered` — the editor reads render-props.json, which a no-render
712
+ // run does write. Reusing this path is what gives --review the edit
713
+ // command's whole posture (resolveEditorPageDir's loud "run pnpm
714
+ // build" degrade, startEditServer, openInBrowser) for free.
715
+ await offerEditor(result, { flag: openEditor, port: opts.editorPort });
628
716
  // Deliberately LAST — after the render summary and the editor offer —
629
717
  // so the one question ossclip ever asks is the last thing on screen.
630
718
  await maybeAskRating(telemetry);
@@ -829,27 +917,8 @@ export function buildProgram(): Command {
829
917
  // With no argument the editor opens on its own project picker (R17 §83).
830
918
  // With one, resolve what the user MEANT: `ossclip edit <video folder>`
831
919
  // was the reported failure, and produce's output lives one level down.
832
- let target: string | undefined = workdir;
833
- if (workdir !== undefined) {
834
- const { probeWorkdir } = await import("./interactive/workdir-probe");
835
- const { resolveWorkdir, candidateListMessage } = await import("./interactive/resolve-workdir");
836
- const { isInteractive } = await import("./interactive/tty");
837
- const { dir, probe } = await probeWorkdir(workdir);
838
- const resolution = resolveWorkdir(dir, probe);
839
- if (resolution.kind === "none") throw new Error(resolution.message);
840
- if (resolution.kind === "choose") {
841
- if (!isInteractive()) {
842
- throw new Error(candidateListMessage(dir, resolution.candidates));
843
- }
844
- const { pickWorkdir } = await import("./interactive/pick-workdir");
845
- target = await pickWorkdir(resolution.candidates);
846
- } else {
847
- target = resolution.workdir;
848
- // Say so when the path was not the one typed — a silent redirect
849
- // leaves the user with the wrong mental model of where things live.
850
- if (resolution.via === "nested") console.log(`▸ resolved ${workdir} → ${target}`);
851
- }
852
- }
920
+ const target =
921
+ workdir === undefined ? undefined : await resolveWorkdirArgument(workdir, "edit");
853
922
 
854
923
  const server = await startEditServer(target, { port: opts.port, pageDir });
855
924
  console.log(`▸ editor at ${server.url}`);
@@ -864,6 +933,64 @@ export function buildProgram(): Command {
864
933
  void telemetry.flush();
865
934
  });
866
935
 
936
+ program
937
+ .command("cover")
938
+ .description(
939
+ "regenerate a produced workdir's cover image — a new headline or a new frame, " +
940
+ "in seconds, with no video re-render",
941
+ )
942
+ // Optional, like `edit`'s: with no argument this resolves the run under
943
+ // the CURRENT directory, so `cd`-ing to the video's folder is enough.
944
+ .argument("[workdir]", "a work directory, or the folder you produced in")
945
+ .option(
946
+ "--text <headline>",
947
+ `banner headline. Capped at ${COVER_MAX_WORDS} words like produce's (§35), and the ` +
948
+ "trimmed result is printed. Omitted keeps the headline this cover already has",
949
+ )
950
+ .option(
951
+ "--at <seconds>",
952
+ "take the frame from this timestamp. Omitted re-uses the still the last cover was " +
953
+ "built from — the cheap path, which runs no ffmpeg at all",
954
+ )
955
+ .option(
956
+ "--from <video>",
957
+ "which video --at reads: `final` (default) is the FINISHED render, so the frame " +
958
+ "carries the burned-in captions, graphics and watermark; `source` is the original " +
959
+ "take, framed the way produce framed it",
960
+ "final",
961
+ )
962
+ .option(
963
+ "--out <path>",
964
+ "write the JPEG here for THIS run only; a one-off destination that does not change " +
965
+ "where this project's cover lives (that stays where this workdir's last cover went, " +
966
+ "else <out>.cover.jpg)",
967
+ )
968
+ .action(async (workdir: string | undefined, opts) => {
969
+ const { parseCoverFlags, regenerateCover } = await import("./cover");
970
+ // Parsed before anything touches disk: a typo'd `--from finall` must be
971
+ // an error naming the flag, not a cover quietly rebuilt from the wrong
972
+ // video (the --source-fit rule above).
973
+ const flags = parseCoverFlags(opts);
974
+
975
+ // The `edit` action's ladder, literally the same one: produce writes
976
+ // into <video's folder>/.ossclip/<name>/, and `ossclip cover
977
+ // ~/Downloads/MyClips` has to find that nested run exactly as `edit`
978
+ // does. With no argument, the current directory is the target.
979
+ const target = await resolveWorkdirArgument(workdir ?? ".", "cover");
980
+
981
+ // A thin shell by design: every decision lives in regenerateCover, so
982
+ // the editor's /api/cover/regenerate is the same code and not a second
983
+ // spelling of it.
984
+ await regenerateCover(target, {
985
+ text: flags.text,
986
+ atSec: flags.atSec,
987
+ from: flags.from,
988
+ // Raw: `coverDestination` owns the tilde expansion and the cwd
989
+ // anchor, so there is one site applying `expandHome` to the user half.
990
+ outPath: flags.outPath,
991
+ });
992
+ });
993
+
867
994
  program
868
995
  .command("setup")
869
996
  .description(
@@ -54,6 +54,8 @@ export function consumeReplayArgv(): string[] | null {
54
54
  */
55
55
  export function recordedProduceArgs(pins: {
56
56
  llm?: string;
57
+ /** The RESOLVED §143 effort — flag or valid config, never a raw config string. */
58
+ llmEffort?: "low" | "medium" | "high";
57
59
  clipWindow?: string;
58
60
  watermark?: boolean;
59
61
  captions?: boolean;
@@ -68,10 +70,29 @@ export function recordedProduceArgs(pins: {
68
70
  /** The RESOLVED thumbnail brief — pinned only when non-empty. */
69
71
  thumbnailBrief?: string;
70
72
  }): string[] {
71
- const args = consumeReplayArgv() ?? process.argv.slice(2);
73
+ // --review and --no-render are stripped at record (cut-review step 1):
74
+ // command.json exists for exactly one consumer — the editor's Render
75
+ // button, which replays this argv to produce the video — and a record
76
+ // carrying --no-render would replay as a run that skips the render again,
77
+ // while a recorded --review would ALSO spawn a second editor from inside
78
+ // the replay child. Record the invocation the user wants Render to run.
79
+ // Both are bare boolean flags, so a value-free filter cannot orphan an
80
+ // option's argument.
81
+ const args = (consumeReplayArgv() ?? process.argv.slice(2)).filter(
82
+ (a) => a !== "--review" && a !== "--no-render",
83
+ );
72
84
  if (pins.llm !== undefined && !args.includes("--llm")) {
73
85
  args.push("--llm", pins.llm);
74
86
  }
87
+ // The effort pin (§143), the dictionary's rationale: the resolved level may
88
+ // have come from ~/.ossclip/config.json's `llmEffort`, and it steers the
89
+ // editorial call — an unpinned record would replay a DIFFERENT plan the
90
+ // moment that config is edited. Unset stays unpinned: there is no flag
91
+ // spelling for "agy's own default", and an argv without the flag replays as
92
+ // "config decides" — the same accepted cost as the dictionary's empty case.
93
+ if (pins.llmEffort !== undefined && !args.includes("--llm-effort")) {
94
+ args.push("--llm-effort", pins.llmEffort);
95
+ }
75
96
  if (pins.clipWindow !== undefined && !args.includes("--clip-window")) {
76
97
  args.push("--clip-window", pins.clipWindow);
77
98
  }
@@ -12,6 +12,28 @@ import { dirname } from "node:path";
12
12
  * before renaming into place — a `.part` never becomes a `dest` unverified.
13
13
  */
14
14
 
15
+ /**
16
+ * A pinned asset is gone from the host — distinguished from every other HTTP
17
+ * failure because it is OURS, not the user's: the manifest names an exact
18
+ * upstream release, and hosts rotate releases out from under a pin (BtbN
19
+ * prunes daily autobuilds after ~2 weeks — #6, §145). A stale pin reported as
20
+ * a bare "download failed: HTTP 404" reads as a broken network and leaves the
21
+ * user nowhere; the caller catches this type and adds the manual install for
22
+ * their platform, which turns a dead end into a two-minute detour.
23
+ *
24
+ * Deliberately generic: this module downloads models and whisper builds too,
25
+ * so it names the problem and lets the caller name the remedy.
26
+ */
27
+ export class PinnedAssetGoneError extends Error {
28
+ constructor(readonly url: string) {
29
+ super(
30
+ `the pinned download is gone upstream (HTTP 404)\n ${url}\n` +
31
+ " This is a stale pin in ossclip's manifest, not a problem on your machine.",
32
+ );
33
+ this.name = "PinnedAssetGoneError";
34
+ }
35
+ }
36
+
15
37
  export interface DownloadOptions {
16
38
  /** hex digest; algorithm chosen by which field is set */
17
39
  sha256?: string;
@@ -39,6 +61,8 @@ export async function download(url: string, dest: string, opts: DownloadOptions
39
61
  const res = await fetchImpl(url, { headers, redirect: "follow" });
40
62
  if (res.status === 200) {
41
63
  offset = 0; // server ignored the range — start over
64
+ } else if (res.status === 404) {
65
+ throw new PinnedAssetGoneError(url);
42
66
  } else if (res.status !== 206) {
43
67
  throw new Error(`download failed: HTTP ${res.status} for ${url}`);
44
68
  }
@@ -11,6 +11,10 @@
11
11
  * `checksums.sha256` per release; ggml-org assets are hashed by hand), and
12
12
  * re-run the setup-e2e workflow. The manifest test asserts every supported
13
13
  * platform×arch resolves to either an asset or an explicit manual hint.
14
+ *
15
+ * A pinned URL can also rot without anything here changing, so the `manifest
16
+ * urls` CI job HEADs every one of them on every setup PR and weekly (§145,
17
+ * §146) — a shape assertion in the unit suite cannot see a 404.
14
18
  */
15
19
 
16
20
  import { basename, isAbsolute, join } from "node:path";
@@ -29,9 +33,23 @@ export interface BinaryAsset {
29
33
  version: string;
30
34
  }
31
35
 
36
+ /**
37
+ * PIN A MONTH-END TAG ONLY. BtbN's `autobuild-*` releases are not all equal:
38
+ * the dailies are pruned after roughly two weeks, but the LAST autobuild of
39
+ * each month is retained indefinitely — verified 2026-08-22 against tags going
40
+ * back to `autobuild-2024-09-30-15-36`, still serving all 48 assets.
41
+ *
42
+ * The previous pin here was a daily (`autobuild-2026-07-29-13-36`) and took
43
+ * the whole tag with it when it rotated, so `ossclip setup` could not install
44
+ * ffmpeg on ANY platform it provisions for — every non-darwin branch below
45
+ * interpolates this one base (#6, §145). Picking the month-end tag costs
46
+ * nothing and cannot rot the same way; picking `latest` would fix the 404 but
47
+ * forfeit the checksum guarantee this whole file exists for, since the asset
48
+ * behind it changes daily.
49
+ */
32
50
  const BTBN =
33
- "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-29-13-36";
34
- const FFMPEG_VER = "n8.1.2-31-g8c9502e9b0";
51
+ "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-31-14-10";
52
+ const FFMPEG_VER = "n8.1.2-34-g9b6c8969e0";
35
53
 
36
54
  const WHISPER =
37
55
  "https://github.com/ggml-org/whisper.cpp/releases/download/v1.9.1";
@@ -50,7 +68,7 @@ export function ffmpegAsset(platform: NodeJS.Platform, arch: string): BinaryAsse
50
68
  return {
51
69
  ...common,
52
70
  url: `${BTBN}/ffmpeg-${FFMPEG_VER}-win64-gpl-8.1.zip`,
53
- sha256: "106d3f8e72b70e29f83983dbaa65efdfc5355716a5df675dc846e441929f7890",
71
+ sha256: "cc4156d51387566ea8ba653fc3a04897bdf812fddf652428d9030bbf7ae24835",
54
72
  archive: "zip",
55
73
  sizeMB: 160,
56
74
  };
@@ -59,7 +77,7 @@ export function ffmpegAsset(platform: NodeJS.Platform, arch: string): BinaryAsse
59
77
  return {
60
78
  ...common,
61
79
  url: `${BTBN}/ffmpeg-${FFMPEG_VER}-winarm64-gpl-8.1.zip`,
62
- sha256: "ac46bdb0c9c619b107c7281a0cc6932a9419c4d6c3c8c36a259550f7fcee1a1a",
80
+ sha256: "abf3b41c200ce5346b9bb5be6fe634c4720d891778d8921f7b36b76d002b3c96",
63
81
  archive: "zip",
64
82
  sizeMB: 107,
65
83
  };
@@ -68,16 +86,16 @@ export function ffmpegAsset(platform: NodeJS.Platform, arch: string): BinaryAsse
68
86
  return {
69
87
  ...common,
70
88
  url: `${BTBN}/ffmpeg-${FFMPEG_VER}-linux64-gpl-8.1.tar.xz`,
71
- sha256: "9fb60ff01e6574258dc76efdf94f901a651582da67b8edcfd10e8860233b7ef4",
89
+ sha256: "09fc77be269c7053e438b7e96548e4af97604faf96a42c4a3c56a1ad74c22c0a",
72
90
  archive: "tar.xz",
73
- sizeMB: 120,
91
+ sizeMB: 119,
74
92
  };
75
93
  }
76
94
  if (platform === "linux" && arch === "arm64") {
77
95
  return {
78
96
  ...common,
79
97
  url: `${BTBN}/ffmpeg-${FFMPEG_VER}-linuxarm64-gpl-8.1.tar.xz`,
80
- sha256: "d8f9598a885db3deabd06af7f0f70c8565af27d29fadbcf746598c9306a0c3fa",
98
+ sha256: "177e40c91564dec3840096f3bf1ffe696b94330585972462cfc739fa29fe0e1a",
81
99
  archive: "tar.xz",
82
100
  sizeMB: 102,
83
101
  };
@@ -163,13 +181,13 @@ export const MODELS: Record<string, ModelInfo> = {
163
181
  "base.en": { sizeMB: 142, sha1: "137c40403d78fd54d454da0f9bd998f78703390c" },
164
182
  "small.en": { sizeMB: 466, sha1: "db8a495a91d927739e50b3fc1cc4c6b8f6c2d022" },
165
183
  "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
184
  "medium-urdu": {
169
185
  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.
186
+ // sha1 was computed 2026-08-17 from the author's converted file before the
187
+ // HF upload, then the published file was checked byte-identical to it
188
+ // (exact size + head/mid/tail ranges) rather than trusted — so a corrupted
189
+ // or tampered mirror download fails the checksum loudly. Live since that
190
+ // date; `pnpm check:urls` re-probes this URL with the rest of the table.
173
191
  sha1: "59769d590f62eeeb3bc3f5b82ce8c03b6e96831e",
174
192
  language: "ur",
175
193
  note: "community Urdu fine-tune (Abdul145/whisper-medium-urdu-custom, Apache-2.0), converted to GGML",
@@ -11,8 +11,15 @@ import {
11
11
  whisperModelPath,
12
12
  type BinaryAsset,
13
13
  } from "./manifest";
14
- import { formatPlan, managedBinDir, planSetup, type SetupProbes, type SetupStep } from "./plan";
15
- import { download, progressLine } from "./download";
14
+ import {
15
+ formatPlan,
16
+ managedBinDir,
17
+ planSetup,
18
+ type SetupProbes,
19
+ type SetupStep,
20
+ type StepKind,
21
+ } from "./plan";
22
+ import { PinnedAssetGoneError, download, progressLine } from "./download";
16
23
  import { extractArchive, findFile, markExecutable } from "./extract";
17
24
  import { promptForProvider } from "./provider";
18
25
 
@@ -37,6 +44,35 @@ export interface SetupCliOptions {
37
44
  yes: boolean;
38
45
  }
39
46
 
47
+ /**
48
+ * The hand-install to fall back to when setup's own pinned download is gone
49
+ * (§145). Pure, and exported so the platform matrix is assertable — the whole
50
+ * point is that a Windows or Linux user is never the one who finds out the
51
+ * command was wrong (§136).
52
+ *
53
+ * Deliberately NOT doctor's `installHint`: doctor imports from this directory,
54
+ * and pointing setup back at doctor would invert that. These are the same
55
+ * commands doctor prints, and doctor.test.ts pins those independently.
56
+ */
57
+ export function manualInstall(kind: StepKind, platform: NodeJS.Platform): string {
58
+ if (kind === "model") return "download it by hand — `ossclip doctor` prints the exact curl";
59
+ if (kind === "provider") return "set an API key, or install the agy or claude CLI";
60
+ const pkg = kind === "ffmpeg" ? "ffmpeg" : "whisper-cpp";
61
+ if (platform === "darwin") return `brew install ${pkg}`;
62
+ if (platform === "linux") {
63
+ // apt has no whisper.cpp package; building it is the documented path.
64
+ return kind === "ffmpeg" ? "sudo apt install ffmpeg" : WHISPER_BUILD_HINT;
65
+ }
66
+ if (platform === "win32") {
67
+ return kind === "ffmpeg"
68
+ ? "winget install ffmpeg — then point OSSCLIP_FFMPEG/OSSCLIP_FFPROBE (or config.json) at the binaries"
69
+ : WHISPER_BUILD_HINT;
70
+ }
71
+ return kind === "ffmpeg"
72
+ ? "install ffmpeg from https://ffmpeg.org and set OSSCLIP_FFMPEG"
73
+ : WHISPER_BUILD_HINT;
74
+ }
75
+
40
76
  const probeBin = (bin: string, arg: string): Promise<boolean> =>
41
77
  new Promise((resolve) => {
42
78
  // Existence is the question, not exit code (same contract as doctor).
@@ -92,8 +128,15 @@ export async function setup(
92
128
  await runStep(step);
93
129
  } catch (err) {
94
130
  const msg = err instanceof Error ? err.message : String(err);
95
- failures.push(`${step.kind}: ${msg}`);
96
- console.error(`✗ ${step.kind} failed: ${msg}`);
131
+ // A rotated-away pin is ossclip's bug, and the user still needs a
132
+ // working toolchain today — so name the hand-install for their
133
+ // platform instead of leaving them on a bare 404 (#6, §145).
134
+ const detour =
135
+ err instanceof PinnedAssetGoneError
136
+ ? `\n Until it is re-pinned, install it yourself: ${manualInstall(step.kind, probes.platform)}`
137
+ : "";
138
+ failures.push(`${step.kind}: ${msg}${detour}`);
139
+ console.error(`✗ ${step.kind} failed: ${msg}${detour}`);
97
140
  }
98
141
  }
99
142