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/editor-dist/assets/index-BBJKC2Z2.js +164 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/cover.ts +846 -0
- package/src/edit.ts +204 -45
- package/src/interactive/resolve-workdir.ts +11 -2
- package/src/llm-detect.ts +11 -0
- package/src/produce.ts +706 -187
- package/src/program.ts +153 -26
- package/src/replay-argv.ts +22 -1
- package/src/setup/download.ts +24 -0
- package/src/setup/manifest.ts +30 -12
- package/src/setup/setup.ts +47 -4
- package/editor-dist/assets/index-DVI51_2u.js +0 -163
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
833
|
-
|
|
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(
|
package/src/replay-argv.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/setup/download.ts
CHANGED
|
@@ -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
|
}
|
package/src/setup/manifest.ts
CHANGED
|
@@ -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-
|
|
34
|
-
const FFMPEG_VER = "n8.1.2-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
89
|
+
sha256: "09fc77be269c7053e438b7e96548e4af97604faf96a42c4a3c56a1ad74c22c0a",
|
|
72
90
|
archive: "tar.xz",
|
|
73
|
-
sizeMB:
|
|
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: "
|
|
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
|
|
171
|
-
// upload
|
|
172
|
-
//
|
|
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",
|
package/src/setup/setup.ts
CHANGED
|
@@ -11,8 +11,15 @@ import {
|
|
|
11
11
|
whisperModelPath,
|
|
12
12
|
type BinaryAsset,
|
|
13
13
|
} from "./manifest";
|
|
14
|
-
import {
|
|
15
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|