ossclip 0.1.27 → 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
@@ -9,7 +9,7 @@ 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
@@ -334,6 +334,10 @@ export function buildProgram(): Command {
334
334
  "if ANTHROPIC_API_KEY is set, else claude-cli",
335
335
  )
336
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
+ )
337
341
  .option(
338
342
  "--llm-fast-model <id>",
339
343
  "model for mechanical calls (repair, scene props); 'same' disables tiering",
@@ -478,6 +482,13 @@ export function buildProgram(): Command {
478
482
  "--no-open-editor",
479
483
  "don't open the editor, and don't ask (overrides openEditorAfterProduce)",
480
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
+ )
481
492
  .option("--editor-port <n>", "port for the editor started by --open-editor",
482
493
  (v) => Number.parseInt(v, 10), 5174)
483
494
  // No default: undefined = "not typed" is what lets the config's
@@ -539,6 +550,13 @@ export function buildProgram(): Command {
539
550
  const provider = opts.llm
540
551
  ? z.enum(["antigravity", "claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
541
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;
542
560
  const forceComponent = opts.forceComponent
543
561
  ? SceneComponentIdSchema.parse(opts.forceComponent)
544
562
  : undefined;
@@ -569,6 +587,15 @@ export function buildProgram(): Command {
569
587
  opts.addJumpCuts,
570
588
  command.getOptionValueSource("jumpCuts") === "cli",
571
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
+ );
572
599
  // Wall clock around produce() only — the editor offer below can sit at
573
600
  // an interactive prompt for as long as the user thinks, and think-time
574
601
  // would poison the duration metric (FINDINGS §134).
@@ -578,7 +605,12 @@ export function buildProgram(): Command {
578
605
  out: opts.out,
579
606
  cleanup,
580
607
  transcript: opts.transcript,
581
- 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,
582
614
  mezzanine: opts.mezzanine,
583
615
  workdir: opts.workdir,
584
616
  sort,
@@ -590,6 +622,9 @@ export function buildProgram(): Command {
590
622
  provider,
591
623
  llmModel: opts.llmModel,
592
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,
593
628
  speaker: opts.speaker,
594
629
  scenes: opts.scenes,
595
630
  repair: opts.repair,
@@ -645,7 +680,10 @@ export function buildProgram(): Command {
645
680
  produced: opts.produce === true,
646
681
  aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
647
682
  clip: opts.clip !== undefined,
648
- 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,
649
687
  source_duration_bucket: durationBucket(result.sourceDurationSec),
650
688
  scenes: result.sceneCount,
651
689
  // Per-phase buckets (§140), one `<phase>_bucket` per phase that
@@ -668,7 +706,13 @@ export function buildProgram(): Command {
668
706
  }
669
707
  }
670
708
  const { offerEditor } = await import("./interactive/offer-editor");
671
- 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 });
672
716
  // Deliberately LAST — after the render summary and the editor offer —
673
717
  // so the one question ossclip ever asks is the last thing on screen.
674
718
  await maybeAskRating(telemetry);
@@ -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