ossclip 0.1.36 → 0.1.38
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/README.md +11 -1
- package/editor-dist/assets/{index-pWbFr8vc.js → index-DpySfDtS.js} +37 -37
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/analyze.ts +4 -0
- package/src/doctor.ts +38 -6
- package/src/edit.ts +137 -32
- package/src/interactive/produce-wizard.ts +7 -2
- package/src/produce.ts +145 -44
- package/src/program.ts +47 -0
- package/src/setup/plan.ts +18 -0
- package/src/whisper-backend.ts +103 -0
package/src/program.ts
CHANGED
|
@@ -411,6 +411,12 @@ export function buildProgram(): Command {
|
|
|
411
411
|
"(whisper's -tr; pair with --whisper-language for the SOURCE language)",
|
|
412
412
|
false,
|
|
413
413
|
)
|
|
414
|
+
.option(
|
|
415
|
+
"--whisper-backend <backend>",
|
|
416
|
+
"local | remote. local (default) runs whisper.cpp on this machine; remote posts the " +
|
|
417
|
+
"audio to the OpenAI-compatible server in OSSCLIP_WHISPER_URL (config: whisperUrl) — " +
|
|
418
|
+
"configuring that URL already implies remote, so this flag is mainly `local` to opt out",
|
|
419
|
+
)
|
|
414
420
|
// COMMA-SEPARATED in one value, not variadic: a variadic option swallows
|
|
415
421
|
// the optional positional [input] whenever the flag precedes the path,
|
|
416
422
|
// and commander offers no way to give the positional priority.
|
|
@@ -661,6 +667,14 @@ export function buildProgram(): Command {
|
|
|
661
667
|
opts.whisperLanguage !== undefined
|
|
662
668
|
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
663
669
|
: undefined;
|
|
670
|
+
// An enum, unlike --whisper-language: there are exactly two backends,
|
|
671
|
+
// and a typo'd `--whisper-backend groq` silently running local whisper
|
|
672
|
+
// on the weak CPU the flag exists to spare is the --source-fit crop
|
|
673
|
+
// all over again.
|
|
674
|
+
const whisperBackend =
|
|
675
|
+
opts.whisperBackend !== undefined
|
|
676
|
+
? z.enum(["local", "remote"]).parse(opts.whisperBackend)
|
|
677
|
+
: undefined;
|
|
664
678
|
// --add-jump-cuts / --no-jump-cuts land on DIFFERENT commander keys
|
|
665
679
|
// (see the option declarations for why the pair can't share one);
|
|
666
680
|
// jumpCutsFlag reunites them into the tri-state ProduceOptions
|
|
@@ -720,6 +734,9 @@ export function buildProgram(): Command {
|
|
|
720
734
|
whisperModel: opts.whisperModel,
|
|
721
735
|
whisperLanguage,
|
|
722
736
|
whisperTranslate: opts.whisperTranslate === true,
|
|
737
|
+
// undefined = "not typed", so a configured whisperUrl decides
|
|
738
|
+
// (resolveWhisperBackend at the use site).
|
|
739
|
+
whisperBackend,
|
|
723
740
|
// Split/trim/drop-empties (dictionaryFlag) — undefined stays
|
|
724
741
|
// undefined so the config's dictionary can supply the default.
|
|
725
742
|
dictionary: dictionaryFlag(opts.dictionary),
|
|
@@ -855,6 +872,15 @@ export function buildProgram(): Command {
|
|
|
855
872
|
"(whisper's -tr; pair with --whisper-language for the SOURCE language)",
|
|
856
873
|
false,
|
|
857
874
|
)
|
|
875
|
+
.option(
|
|
876
|
+
"--whisper-backend <backend>",
|
|
877
|
+
// Same sentence as produce's, deliberately: `transcribe` is the command
|
|
878
|
+
// a user drives while SETTING remote transcription up, so the half that
|
|
879
|
+
// says the URL is the real switch cannot be the half that is dropped here.
|
|
880
|
+
"local | remote. local (default) runs whisper.cpp on this machine; remote posts the " +
|
|
881
|
+
"audio to the OpenAI-compatible server in OSSCLIP_WHISPER_URL (config: whisperUrl) — " +
|
|
882
|
+
"configuring that URL already implies remote, so this flag is mainly `local` to opt out",
|
|
883
|
+
)
|
|
858
884
|
.action(async (input: string, opts) => {
|
|
859
885
|
const cleanup = CleanupLevelSchema.parse(opts.cleanup);
|
|
860
886
|
const result = await produce(input, {
|
|
@@ -871,6 +897,12 @@ export function buildProgram(): Command {
|
|
|
871
897
|
opts.whisperLanguage !== undefined
|
|
872
898
|
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
873
899
|
: undefined,
|
|
900
|
+
// Parsed, not coerced — produce's reasoning: a typo must error, not
|
|
901
|
+
// fall back to the local backend the flag exists to avoid.
|
|
902
|
+
whisperBackend:
|
|
903
|
+
opts.whisperBackend !== undefined
|
|
904
|
+
? z.enum(["local", "remote"]).parse(opts.whisperBackend)
|
|
905
|
+
: undefined,
|
|
874
906
|
});
|
|
875
907
|
telemetry.record("transcribe_completed", {
|
|
876
908
|
cleanup_level: cleanup,
|
|
@@ -907,6 +939,15 @@ export function buildProgram(): Command {
|
|
|
907
939
|
"--whisper-language <code>",
|
|
908
940
|
"transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
|
|
909
941
|
)
|
|
942
|
+
.option(
|
|
943
|
+
"--whisper-backend <backend>",
|
|
944
|
+
// Same sentence as produce's, deliberately: `transcribe` is the command
|
|
945
|
+
// a user drives while SETTING remote transcription up, so the half that
|
|
946
|
+
// says the URL is the real switch cannot be the half that is dropped here.
|
|
947
|
+
"local | remote. local (default) runs whisper.cpp on this machine; remote posts the " +
|
|
948
|
+
"audio to the OpenAI-compatible server in OSSCLIP_WHISPER_URL (config: whisperUrl) — " +
|
|
949
|
+
"configuring that URL already implies remote, so this flag is mainly `local` to opt out",
|
|
950
|
+
)
|
|
910
951
|
.option(
|
|
911
952
|
"--blooper-marker <word>",
|
|
912
953
|
"mark the flubbed take wherever you say this word out loud (e.g. blooper). Off unless given",
|
|
@@ -935,6 +976,12 @@ export function buildProgram(): Command {
|
|
|
935
976
|
opts.whisperLanguage !== undefined
|
|
936
977
|
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
937
978
|
: undefined,
|
|
979
|
+
// Parsed, not coerced — produce's reasoning: a typo must error, not
|
|
980
|
+
// fall back to the local backend the flag exists to avoid.
|
|
981
|
+
whisperBackend:
|
|
982
|
+
opts.whisperBackend !== undefined
|
|
983
|
+
? z.enum(["local", "remote"]).parse(opts.whisperBackend)
|
|
984
|
+
: undefined,
|
|
938
985
|
blooperMarker: opts.blooperMarker,
|
|
939
986
|
collapseRetakes: opts.collapseRetakes,
|
|
940
987
|
sort: opts.sort === "mtime" ? "mtime" : "name",
|
package/src/setup/plan.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
whisperAsset,
|
|
10
10
|
whisperModelPath,
|
|
11
11
|
} from "./manifest";
|
|
12
|
+
import { resolveWhisperBackend } from "../whisper-backend";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The planning half of `ossclip setup` — pure over injected probes, like
|
|
@@ -111,10 +112,25 @@ export async function planSetup(
|
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
// Remote transcription (2026-09-01 weak-CPU field report) makes whisper.cpp
|
|
116
|
+
// and the model OPTIONAL: on the machine the report came from, downloading
|
|
117
|
+
// a 1.5 GB model to run an engine that is too slow to use is exactly the
|
|
118
|
+
// cliff remote exists to remove. Reported as `satisfied` with the reason,
|
|
119
|
+
// never as a silent skip — and a local install that ALREADY works still
|
|
120
|
+
// reports itself (ground rule one: setup never uninstalls, and a user who
|
|
121
|
+
// has both keeps the `--whisper-backend local` escape hatch working).
|
|
122
|
+
const remote = resolveWhisperBackend(undefined, cfg, p.env);
|
|
123
|
+
const remoteDetail =
|
|
124
|
+
remote.ok && remote.backend.kind === "remote"
|
|
125
|
+
? `remote transcription configured (${remote.backend.baseUrl}) — local whisper not needed`
|
|
126
|
+
: null;
|
|
127
|
+
|
|
114
128
|
const whisperOk = await p.binRuns(cfg.whisperPath, "--help");
|
|
115
129
|
const whisperForceable = isManaged(cfg.whisperPath, opts.configDir);
|
|
116
130
|
if (whisperOk && !(opts.force && whisperForceable)) {
|
|
117
131
|
steps.push({ kind: "whisper", status: "satisfied", detail: cfg.whisperPath });
|
|
132
|
+
} else if (remoteDetail !== null) {
|
|
133
|
+
steps.push({ kind: "whisper", status: "satisfied", detail: remoteDetail });
|
|
118
134
|
} else {
|
|
119
135
|
const asset = whisperAsset(p.platform, p.arch);
|
|
120
136
|
if (asset) {
|
|
@@ -153,6 +169,8 @@ export async function planSetup(
|
|
|
153
169
|
const known = MODELS[model];
|
|
154
170
|
if (p.exists(modelPath)) {
|
|
155
171
|
steps.push({ kind: "model", status: "satisfied", detail: modelPath });
|
|
172
|
+
} else if (remoteDetail !== null) {
|
|
173
|
+
steps.push({ kind: "model", status: "satisfied", detail: remoteDetail });
|
|
156
174
|
} else if (isAbsolute(model)) {
|
|
157
175
|
steps.push({
|
|
158
176
|
kind: "model",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { OssclipConfig } from "@ossclip/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which transcription backend a run uses — local whisper.cpp (the default,
|
|
5
|
+
* forever) or an OpenAI-compatible `/v1/audio/transcriptions` server.
|
|
6
|
+
*
|
|
7
|
+
* Why (2026-09-01 field report): on a weak CPU — an i3 2nd gen — whisper is
|
|
8
|
+
* the dominant cost of a produce run, and Groq's free tier makes it seconds.
|
|
9
|
+
* Remote is OPT-IN: presence of a URL is the switch, so nobody's existing
|
|
10
|
+
* install changes behavior by upgrading.
|
|
11
|
+
*
|
|
12
|
+
* `publishConfigured`'s mould (publish.ts), with one deliberate difference:
|
|
13
|
+
* the API key is OPTIONAL. Self-hosted servers (speaches, whisper.cpp
|
|
14
|
+
* server) run keyless, so requiring a key the way publish does would lock
|
|
15
|
+
* out the privacy-minded half of the audience.
|
|
16
|
+
*
|
|
17
|
+
* Pure over (flag, config, env) so the whole matrix is testable without a
|
|
18
|
+
* config file or a poked process.env.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Env-only, like every other secret (env.ts's rule): keys never live in config.json. */
|
|
22
|
+
export const WHISPER_API_KEY_ENV = "OSSCLIP_WHISPER_API_KEY";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Groq's word-timestamped turbo model — the one the quickstart in the README
|
|
26
|
+
* points at. The default lives HERE rather than in config.ts's DEFAULTS
|
|
27
|
+
* because it is only meaningful once a remote URL exists, and a self-hosted
|
|
28
|
+
* box (whose model names look like "Systran/faster-whisper-large-v3") sets
|
|
29
|
+
* `whisperRemoteModel` anyway.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_REMOTE_WHISPER_MODEL = "whisper-large-v3-turbo";
|
|
32
|
+
|
|
33
|
+
export type WhisperBackend =
|
|
34
|
+
| { kind: "local" }
|
|
35
|
+
| { kind: "remote"; baseUrl: string; model: string; apiKey?: string };
|
|
36
|
+
|
|
37
|
+
export type WhisperBackendResult =
|
|
38
|
+
| { ok: true; backend: WhisperBackend }
|
|
39
|
+
| { ok: false; message: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `--whisper-backend` (already zod-parsed by program.ts) beats the config,
|
|
43
|
+
* and "local" ALWAYS wins — it is the escape hatch a user reaches for when
|
|
44
|
+
* the remote server is down or the audio must not leave the machine, so it
|
|
45
|
+
* can never be overridden by a URL sitting in config.json.
|
|
46
|
+
*
|
|
47
|
+
* A typed `--whisper-backend remote` with nothing configured is an ERROR
|
|
48
|
+
* naming both spellings, not a silent fall back to local: the user asked for
|
|
49
|
+
* remote precisely because local is what they are trying to avoid.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveWhisperBackend(
|
|
52
|
+
flag: "local" | "remote" | undefined,
|
|
53
|
+
cfg: Pick<OssclipConfig, "whisperUrl" | "whisperRemoteModel">,
|
|
54
|
+
env: NodeJS.ProcessEnv,
|
|
55
|
+
): WhisperBackendResult {
|
|
56
|
+
if (flag === "local") return { ok: true, backend: { kind: "local" } };
|
|
57
|
+
// typeof + trim, never truthiness: `whisperUrl: " "` in a hand-edited
|
|
58
|
+
// config.json must read as "not configured", not as a URL we then POST to.
|
|
59
|
+
const url = typeof cfg.whisperUrl === "string" ? cfg.whisperUrl.trim() : "";
|
|
60
|
+
if (url.length === 0) {
|
|
61
|
+
if (flag === "remote") {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
message:
|
|
65
|
+
"--whisper-backend remote needs a transcription server: set OSSCLIP_WHISPER_URL in the " +
|
|
66
|
+
'environment (or ~/.ossclip/.env), or "whisperUrl" in ~/.ossclip/config.json — the ' +
|
|
67
|
+
"OpenAI-compatible base ending in /v1, e.g. https://api.groq.com/openai/v1.",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, backend: { kind: "local" } };
|
|
71
|
+
}
|
|
72
|
+
const model =
|
|
73
|
+
typeof cfg.whisperRemoteModel === "string" && cfg.whisperRemoteModel.trim().length > 0
|
|
74
|
+
? cfg.whisperRemoteModel.trim()
|
|
75
|
+
: DEFAULT_REMOTE_WHISPER_MODEL;
|
|
76
|
+
const key = env[WHISPER_API_KEY_ENV]?.trim() ?? "";
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
backend: {
|
|
80
|
+
kind: "remote",
|
|
81
|
+
baseUrl: url,
|
|
82
|
+
model,
|
|
83
|
+
// Omitted rather than "" when unset, so the provider sends NO
|
|
84
|
+
// Authorization header at all — a keyless self-hosted server is a
|
|
85
|
+
// supported configuration, not a missing key.
|
|
86
|
+
...(key.length > 0 ? { apiKey: key } : {}),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The host for a one-line stage/status label. Falls back to the raw string
|
|
93
|
+
* when `new URL` refuses it: the value is user-typed config, and a stage line
|
|
94
|
+
* must never be the thing that throws — the POST that follows will report a
|
|
95
|
+
* bad URL with far better context.
|
|
96
|
+
*/
|
|
97
|
+
export function remoteWhisperHost(baseUrl: string): string {
|
|
98
|
+
try {
|
|
99
|
+
return new URL(baseUrl).host;
|
|
100
|
+
} catch {
|
|
101
|
+
return baseUrl;
|
|
102
|
+
}
|
|
103
|
+
}
|