ossclip 0.1.11 → 0.1.12
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-Bo0WME7k.js → index-BBVLCP-N.js} +24 -24
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/interactive/produce-argv.ts +9 -0
- package/src/interactive/produce-wizard.ts +133 -17
- package/src/produce.ts +170 -9
- package/src/program.ts +57 -3
- package/src/replay-argv.ts +19 -1
- package/src/stranded-overrides.ts +77 -0
package/editor-dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>ossclip editor</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-BBVLCP-N.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-C8IPo60X.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ossclip",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"commander": "^12.1.0",
|
|
37
37
|
"tsx": "^4.19.0",
|
|
38
38
|
"zod": "^3.25.76",
|
|
39
|
-
"@ossclip/core": "0.1.
|
|
40
|
-
"@ossclip/renderer": "0.1.
|
|
41
|
-
"@ossclip/scenes": "0.1.
|
|
39
|
+
"@ossclip/core": "0.1.12",
|
|
40
|
+
"@ossclip/renderer": "0.1.12",
|
|
41
|
+
"@ossclip/scenes": "0.1.12"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
44
44
|
"bugs": {
|
|
@@ -11,9 +11,14 @@ export interface ProduceExtras {
|
|
|
11
11
|
sourceFit?: "cover" | "contain";
|
|
12
12
|
speaker?: string;
|
|
13
13
|
whisperModel?: string;
|
|
14
|
+
whisperLanguage?: string;
|
|
14
15
|
blooperMarker?: string;
|
|
15
16
|
collapseRetakes?: boolean;
|
|
16
17
|
sourceIsEdited?: boolean;
|
|
18
|
+
/** Opt-in "made with ossclip" credit. The wizard only ever turns it ON —
|
|
19
|
+
* off is the default, and a config-on user who wants it off for one run
|
|
20
|
+
* types `--no-watermark`, a flags-only surface like --sort. */
|
|
21
|
+
watermark?: boolean;
|
|
17
22
|
llm?: "claude" | "claude-cli" | "gemini" | "mock";
|
|
18
23
|
}
|
|
19
24
|
|
|
@@ -50,9 +55,13 @@ export function produceArgv(a: ProduceAnswers): string[] {
|
|
|
50
55
|
if (e.sourceFit === "contain") argv.push("--source-fit", "contain");
|
|
51
56
|
if (e.speaker) argv.push("--speaker", e.speaker);
|
|
52
57
|
if (e.whisperModel) argv.push("--whisper-model", e.whisperModel);
|
|
58
|
+
// Empty answer means "whisper's default (en)" — the elision rule above:
|
|
59
|
+
// no flag whose value equals the default.
|
|
60
|
+
if (e.whisperLanguage) argv.push("--whisper-language", e.whisperLanguage);
|
|
53
61
|
if (e.blooperMarker) argv.push("--blooper-marker", e.blooperMarker);
|
|
54
62
|
if (e.collapseRetakes === true) argv.push("--collapse-retakes");
|
|
55
63
|
if (e.sourceIsEdited === true) argv.push("--source-is-edited");
|
|
64
|
+
if (e.watermark === true) argv.push("--watermark");
|
|
56
65
|
if (e.llm) argv.push("--llm", e.llm);
|
|
57
66
|
|
|
58
67
|
return argv;
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import { produceArgv, type ProduceAnswers, type ProduceExtras } from "./produce-argv";
|
|
4
4
|
import { assertInteractive, confirm, intro, multiselect, select, text, unwrap } from "./prompts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The produce wizard. Thirty flags (plus the positional input path)
|
|
8
|
-
* into three tiers: six prompts asked directly — the input path, plus
|
|
9
|
-
* flags (--out, --cleanup, --aspect, --produce, --intent) —
|
|
10
|
-
* "anything else?" multiselect, and the remaining
|
|
11
|
-
* debug/internal surfaces, replay-only fields,
|
|
12
|
-
*
|
|
7
|
+
* The produce wizard. Thirty-two flags (plus the positional input path)
|
|
8
|
+
* sorted into three tiers: six prompts asked directly — the input path, plus
|
|
9
|
+
* five flags (--out, --cleanup, --aspect, --produce, --intent) — nine behind
|
|
10
|
+
* one "anything else?" multiselect, and the remaining stay flags-only:
|
|
11
|
+
* debug/internal surfaces, replay-only fields, --no-watermark (the
|
|
12
|
+
* multiselect only turns the credit ON; off is already the default), or
|
|
13
|
+
* (final-review fix wave, Finding 1) --sort. A folder's clip order only means anything once the
|
|
13
14
|
* folder has been enumerated, and that enumeration happens inside
|
|
14
15
|
* `produce()` — after the wizard has already returned argv — so there is
|
|
15
16
|
* nothing for a prompt to offer a choice about beforehand. --sort stays
|
|
@@ -34,6 +35,7 @@ const EXTRAS = [
|
|
|
34
35
|
hint: "--collapse-retakes",
|
|
35
36
|
},
|
|
36
37
|
{ value: "sourceIsEdited", label: "Source already has burned-in text", hint: "--source-is-edited" },
|
|
38
|
+
{ value: "watermark", label: 'Credit the tool with a small "made with ossclip"', hint: "--watermark" },
|
|
37
39
|
{ value: "llm", label: "Choose the LLM provider", hint: "--llm" },
|
|
38
40
|
] as const;
|
|
39
41
|
|
|
@@ -45,12 +47,92 @@ const EXTRAS = [
|
|
|
45
47
|
* error nine prompts later — so the clip extra is only ever listed once
|
|
46
48
|
* graphics is already on. Exported and kept pure so this can be asserted
|
|
47
49
|
* without a TTY.
|
|
50
|
+
*
|
|
51
|
+
* `watermarkFromConfig` (review, minor a): on a config-on machine the
|
|
52
|
+
* watermark entry sits UNCHECKED while the credit will render anyway —
|
|
53
|
+
* unchecked is "don't emit the flag", not "off", and the multiselect has no
|
|
54
|
+
* way to say the second thing. The honest cheap fix is to say so in the
|
|
55
|
+
* entry's own hint rather than pre-checking it (a pre-check would emit a
|
|
56
|
+
* redundant --watermark and teach a command line longer than the run needs,
|
|
57
|
+
* against produceArgv's default-elision rule).
|
|
58
|
+
*/
|
|
59
|
+
export function extrasFor(
|
|
60
|
+
graphics: boolean,
|
|
61
|
+
opts: { watermarkFromConfig?: boolean } = {},
|
|
62
|
+
): { value: (typeof EXTRAS)[number]["value"]; label: string; hint: string }[] {
|
|
63
|
+
const list = graphics ? [...EXTRAS] : EXTRAS.filter((e) => e.value !== "graphicsClip");
|
|
64
|
+
if (opts.watermarkFromConfig !== true) return [...list];
|
|
65
|
+
return list.map((e) =>
|
|
66
|
+
e.value === "watermark"
|
|
67
|
+
? { ...e, hint: "already on via your config — unticking does not disable it; --no-watermark does" }
|
|
68
|
+
: e,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Select value that routes to the free-text model prompt instead of a name. */
|
|
73
|
+
export const CUSTOM_MODEL = "__custom__";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A model pick reduced to its bare name: basename, minus the optional ggml-
|
|
77
|
+
* prefix and .bin suffix. Exists because the language prefill classifies on
|
|
78
|
+
* `.endsWith(".en")`, and an absolute path like /x/ggml-small.en.bin ends in
|
|
79
|
+
* ".bin" — an English model would have been prefilled `auto` (review fix,
|
|
80
|
+
* Urdu field test 2026-08-05).
|
|
81
|
+
*/
|
|
82
|
+
export function bareWhisperModelName(nameOrPath: string): string {
|
|
83
|
+
const base = basename(nameOrPath);
|
|
84
|
+
const m = /^(?:ggml-)?(.+?)(?:\.bin)?$/.exec(base);
|
|
85
|
+
return m?.[1] ?? base;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The three names `ossclip setup` knows how to download, with their hints. */
|
|
89
|
+
const CANONICAL_MODELS = [
|
|
90
|
+
{ value: "base.en", hint: "fastest, least accurate" },
|
|
91
|
+
{ value: "small.en", hint: "default" },
|
|
92
|
+
{ value: "medium.en", hint: "slowest, most accurate" },
|
|
93
|
+
] as const;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* modelDir listing → select choices. Pure so the enumeration rules —
|
|
97
|
+
* `ggml-*.bin` stripped to bare names, everything else ignored — are testable
|
|
98
|
+
* without a TTY or a real ~/.ossclip. Exists because the fixed .en-only list
|
|
99
|
+
* made a downloaded fine-tune unpickable from the wizard (Urdu field test
|
|
100
|
+
* 2026-08-05: ggml-medium-urdu.bin was installed and working via
|
|
101
|
+
* `--whisper-model medium-urdu`, and the wizard could not name it).
|
|
48
102
|
*/
|
|
49
|
-
export function
|
|
50
|
-
|
|
103
|
+
export function whisperModelChoices(
|
|
104
|
+
modelDirFiles: string[],
|
|
105
|
+
): { value: string; label: string; hint: string }[] {
|
|
106
|
+
const installed = new Set<string>();
|
|
107
|
+
for (const f of modelDirFiles) {
|
|
108
|
+
// Bare name is what --whisper-model takes: produce.ts joins it back into
|
|
109
|
+
// `ggml-<name>.bin`, so the round trip is exact by construction.
|
|
110
|
+
const m = /^ggml-(.+)\.bin$/.exec(f);
|
|
111
|
+
if (m?.[1] !== undefined) installed.add(m[1]);
|
|
112
|
+
}
|
|
113
|
+
// Canonicals stay listed whether or not downloaded: produce.ts already
|
|
114
|
+
// errors helpfully (naming the setup/curl fix) on a missing model, so the
|
|
115
|
+
// pick works — but the download should not be a surprise, hence the marker.
|
|
116
|
+
const choices: { value: string; label: string; hint: string }[] = CANONICAL_MODELS.map((c) => ({
|
|
117
|
+
value: c.value,
|
|
118
|
+
label: c.value,
|
|
119
|
+
hint: installed.has(c.value) ? c.hint : `${c.hint} · will need download`,
|
|
120
|
+
}));
|
|
121
|
+
const canonical = new Set<string>(CANONICAL_MODELS.map((c) => c.value));
|
|
122
|
+
for (const name of [...installed].filter((n) => !canonical.has(n)).sort()) {
|
|
123
|
+
choices.push({ value: name, label: name, hint: "installed" });
|
|
124
|
+
}
|
|
125
|
+
choices.push({
|
|
126
|
+
value: CUSTOM_MODEL,
|
|
127
|
+
label: "type a name or absolute path",
|
|
128
|
+
hint: "anything whisper.cpp can load",
|
|
129
|
+
});
|
|
130
|
+
return choices;
|
|
51
131
|
}
|
|
52
132
|
|
|
53
|
-
export async function produceWizard(
|
|
133
|
+
export async function produceWizard(
|
|
134
|
+
cfg: { speaker?: string; modelDir?: string; input?: string; watermark?: boolean } = {},
|
|
135
|
+
): Promise<string[]> {
|
|
54
136
|
assertInteractive("produce wizard");
|
|
55
137
|
intro("ossclip produce");
|
|
56
138
|
|
|
@@ -128,7 +210,7 @@ export async function produceWizard(cfg: { speaker?: string; input?: string } =
|
|
|
128
210
|
const chosen = unwrap(
|
|
129
211
|
await multiselect({
|
|
130
212
|
message: "Anything else? (space to toggle, enter to accept)",
|
|
131
|
-
options: extrasFor(graphics),
|
|
213
|
+
options: extrasFor(graphics, { watermarkFromConfig: cfg.watermark === true }),
|
|
132
214
|
required: false,
|
|
133
215
|
}),
|
|
134
216
|
) as string[];
|
|
@@ -153,6 +235,7 @@ export async function produceWizard(cfg: { speaker?: string; input?: string } =
|
|
|
153
235
|
if (chosen.includes("sourceFit")) extras.sourceFit = "contain";
|
|
154
236
|
if (chosen.includes("collapseRetakes")) extras.collapseRetakes = true;
|
|
155
237
|
if (chosen.includes("sourceIsEdited")) extras.sourceIsEdited = true;
|
|
238
|
+
if (chosen.includes("watermark")) extras.watermark = true;
|
|
156
239
|
if (chosen.includes("speaker")) {
|
|
157
240
|
extras.speaker = unwrap(
|
|
158
241
|
await text({
|
|
@@ -165,17 +248,50 @@ export async function produceWizard(cfg: { speaker?: string; input?: string } =
|
|
|
165
248
|
) as string;
|
|
166
249
|
}
|
|
167
250
|
if (chosen.includes("whisperModel")) {
|
|
168
|
-
|
|
251
|
+
// Enumerated from disk, not hardcoded (Urdu field test 2026-08-05): a
|
|
252
|
+
// fine-tune the user already installed must be pickable here, not
|
|
253
|
+
// flags-only. A missing/unset modelDir just means nothing extra to list.
|
|
254
|
+
const modelFiles =
|
|
255
|
+
cfg.modelDir !== undefined && existsSync(cfg.modelDir) ? readdirSync(cfg.modelDir) : [];
|
|
256
|
+
let model = unwrap(
|
|
169
257
|
await select({
|
|
170
258
|
message: "Transcription model",
|
|
171
259
|
initialValue: "small.en",
|
|
172
|
-
options:
|
|
173
|
-
{ value: "base.en", label: "base.en", hint: "fastest, least accurate" },
|
|
174
|
-
{ value: "small.en", label: "small.en", hint: "default" },
|
|
175
|
-
{ value: "medium.en", label: "medium.en", hint: "slowest, most accurate" },
|
|
176
|
-
],
|
|
260
|
+
options: whisperModelChoices(modelFiles),
|
|
177
261
|
}),
|
|
178
262
|
) as string;
|
|
263
|
+
if (model === CUSTOM_MODEL) {
|
|
264
|
+
// Trimmed before the guard: " " passing as truthy would emit a
|
|
265
|
+
// whitespace --whisper-model that produce.ts then fails to resolve.
|
|
266
|
+
model = (
|
|
267
|
+
unwrap(
|
|
268
|
+
await text({
|
|
269
|
+
message: "Model name or absolute path to a ggml .bin",
|
|
270
|
+
placeholder: "medium-urdu",
|
|
271
|
+
validate: (v) => (v?.trim() ? undefined : "a model name or path is required"),
|
|
272
|
+
}),
|
|
273
|
+
) as string
|
|
274
|
+
).trim();
|
|
275
|
+
}
|
|
276
|
+
extras.whisperModel = model;
|
|
277
|
+
// Follow-up under the same extra, like --clip's seconds prompt: a language
|
|
278
|
+
// only means anything once a model is being picked, and a multilingual
|
|
279
|
+
// fine-tune silently decodes English without it (Urdu field test
|
|
280
|
+
// 2026-08-05). A non-.en pick is multilingual by construction, so the
|
|
281
|
+
// prefill makes plain Enter the safe answer — `auto` lets whisper detect.
|
|
282
|
+
// Empty keeps whisper's en default, and produceArgv's default-elision
|
|
283
|
+
// rule then emits no flag at all.
|
|
284
|
+
const lang = (
|
|
285
|
+
unwrap(
|
|
286
|
+
await text({
|
|
287
|
+
message: "Transcription language code (empty = default en)",
|
|
288
|
+
placeholder: "ur",
|
|
289
|
+
initialValue: bareWhisperModelName(model).endsWith(".en") ? "" : "auto",
|
|
290
|
+
defaultValue: "",
|
|
291
|
+
}),
|
|
292
|
+
) as string
|
|
293
|
+
).trim(); // a whitespace answer means "default", not a bogus -l " "
|
|
294
|
+
if (lang) extras.whisperLanguage = lang;
|
|
179
295
|
}
|
|
180
296
|
if (chosen.includes("blooperMarker")) {
|
|
181
297
|
extras.blooperMarker = unwrap(
|
package/src/produce.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
|
|
4
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import { z } from "zod/v4";
|
|
7
7
|
import {
|
|
@@ -97,6 +97,11 @@ import {
|
|
|
97
97
|
type Transcript,
|
|
98
98
|
} from "@ossclip/core";
|
|
99
99
|
import { recordRecentProject } from "./edit";
|
|
100
|
+
import {
|
|
101
|
+
strandedOverrideSiblings,
|
|
102
|
+
strandedPointerLine,
|
|
103
|
+
workdirBaseName,
|
|
104
|
+
} from "./stranded-overrides";
|
|
100
105
|
import { editHint } from "./interactive/edit-hint";
|
|
101
106
|
import { recordedProduceArgs } from "./replay-argv";
|
|
102
107
|
import { renderCover, renderProduction } from "@ossclip/renderer";
|
|
@@ -118,6 +123,43 @@ export interface ProduceResult {
|
|
|
118
123
|
rendered: boolean;
|
|
119
124
|
}
|
|
120
125
|
|
|
126
|
+
/**
|
|
127
|
+
* What produced the workdir's transcript.json — written beside it as
|
|
128
|
+
* transcript-key.json (review fix, Urdu field test 2026-08-05). Parsed with
|
|
129
|
+
* zod like the transcript itself: a hand-edited or truncated key must error,
|
|
130
|
+
* not silently decide whether a cache is reused.
|
|
131
|
+
*/
|
|
132
|
+
export const TranscriptKeySchema = z.object({
|
|
133
|
+
model: z.string(),
|
|
134
|
+
language: z.string().optional(),
|
|
135
|
+
});
|
|
136
|
+
export type TranscriptKey = z.infer<typeof TranscriptKeySchema>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether the cached transcript answers the current request. Pure so all
|
|
140
|
+
* four corners (matching key, differing key, keyless + default, keyless +
|
|
141
|
+
* non-default) are testable without a workdir. A missing key means a workdir
|
|
142
|
+
* from before the key existed; every one of those was transcribed with the
|
|
143
|
+
* config-default model and no -l, so it is treated as exactly that — old
|
|
144
|
+
* workdirs must not all re-transcribe spuriously, but a non-default request
|
|
145
|
+
* against a keyless cache must.
|
|
146
|
+
*/
|
|
147
|
+
export function transcriptCacheReusable(
|
|
148
|
+
recorded: TranscriptKey | null,
|
|
149
|
+
requested: TranscriptKey,
|
|
150
|
+
defaultModel: string,
|
|
151
|
+
): { reuse: boolean; recorded: TranscriptKey } {
|
|
152
|
+
const effective = recorded ?? { model: defaultModel };
|
|
153
|
+
return {
|
|
154
|
+
reuse:
|
|
155
|
+
effective.model === requested.model &&
|
|
156
|
+
// "" and absent both mean whisper's en default — program.ts rejects an
|
|
157
|
+
// empty code, but a key file predating that guard must not wedge.
|
|
158
|
+
(effective.language ?? "") === (requested.language ?? ""),
|
|
159
|
+
recorded: effective,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
121
163
|
export interface ProduceOptions {
|
|
122
164
|
out?: string;
|
|
123
165
|
cleanup: CleanupLevel;
|
|
@@ -143,6 +185,12 @@ export interface ProduceOptions {
|
|
|
143
185
|
repair?: boolean;
|
|
144
186
|
/** Override the whisper model for this run (A/B base.en vs small.en). */
|
|
145
187
|
whisperModel?: string;
|
|
188
|
+
/**
|
|
189
|
+
* `-l` language code for whisper (e.g. "ur", "auto"). Unset keeps whisper's
|
|
190
|
+
* English default — required for a non-English fine-tune, which otherwise
|
|
191
|
+
* decodes garbage (Urdu field test 2026-08-05).
|
|
192
|
+
*/
|
|
193
|
+
whisperLanguage?: string;
|
|
146
194
|
/** Debug: force every graphic moment to this component. */
|
|
147
195
|
forceComponent?: SceneComponentId;
|
|
148
196
|
/** Write a cover image beside the video (default on). */
|
|
@@ -190,6 +238,14 @@ export interface ProduceOptions {
|
|
|
190
238
|
* window with zero LLM calls. Written by clip runs; not for hand use.
|
|
191
239
|
*/
|
|
192
240
|
clipWindow?: string;
|
|
241
|
+
/**
|
|
242
|
+
* `--watermark` / `--no-watermark` tri-state: true/false when TYPED,
|
|
243
|
+
* undefined when not — undefined lets the config's `watermark` key supply
|
|
244
|
+
* the default (`resolveWatermark`). Opt-in by design: the default is off
|
|
245
|
+
* for everyone, because a forced watermark on an open-source tool reads as
|
|
246
|
+
* a free-tier limitation; this is voluntary attribution.
|
|
247
|
+
*/
|
|
248
|
+
watermark?: boolean;
|
|
193
249
|
/**
|
|
194
250
|
* `<input>` a DIRECTORY: order its clips before concatenating them into the
|
|
195
251
|
* source produce runs on (folder-input-brief.md). `name` (default) is a
|
|
@@ -207,6 +263,23 @@ export interface ProduceOptions {
|
|
|
207
263
|
sortExplicit?: boolean;
|
|
208
264
|
}
|
|
209
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The effective watermark switch: a TYPED flag always wins (so
|
|
268
|
+
* `--no-watermark` beats a config-on), and only then does the config supply
|
|
269
|
+
* the default. The config side is `=== true`, never truthiness — the value
|
|
270
|
+
* comes from a hand-editable JSON file loadConfig doesn't zod-parse, and a
|
|
271
|
+
* typo'd `"watermark": "no"` coercing a credit ON is exactly the
|
|
272
|
+
* parse-don't-coerce failure CLAUDE.md forbids; for an opt-in credit, off is
|
|
273
|
+
* the only safe reading of anything malformed. Pure so the whole
|
|
274
|
+
* flag × config matrix is testable without a config file on disk.
|
|
275
|
+
*/
|
|
276
|
+
export function resolveWatermark(
|
|
277
|
+
flag: boolean | undefined,
|
|
278
|
+
configValue: boolean | undefined,
|
|
279
|
+
): boolean {
|
|
280
|
+
return flag ?? configValue === true;
|
|
281
|
+
}
|
|
282
|
+
|
|
210
283
|
function sha1File(path: string): Promise<string> {
|
|
211
284
|
return new Promise((res, rej) => {
|
|
212
285
|
const h = createHash("sha1");
|
|
@@ -246,10 +319,9 @@ function deriveWorkdir(
|
|
|
246
319
|
landscape: boolean,
|
|
247
320
|
): string {
|
|
248
321
|
const workRoot = workdirOpt ? resolve(workdirOpt) : join(dirname(identity), ".ossclip");
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
);
|
|
322
|
+
// The basename half is shared with the §131 stranded-edits scan so the
|
|
323
|
+
// scan's matching can never drift from the naming it scans for.
|
|
324
|
+
return join(workRoot, `${workdirBaseName(identity)}-${hash}${landscape ? "-16x9" : ""}`);
|
|
253
325
|
}
|
|
254
326
|
|
|
255
327
|
/**
|
|
@@ -495,6 +567,36 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
495
567
|
await mkdir(work, { recursive: true });
|
|
496
568
|
console.log(`▸ workdir ${work}`);
|
|
497
569
|
|
|
570
|
+
// §131 residue: a folder re-key (clips renamed/added/removed → new content
|
|
571
|
+
// hash) correctly lands in a fresh workdir, but any editor edits saved in
|
|
572
|
+
// the PREVIOUS workdir's overrides.json don't carry over — and without this
|
|
573
|
+
// pointer the user sees a clean produce and never learns where those edits
|
|
574
|
+
// went. Gated on the current workdir lacking overrides.json: a warm re-run
|
|
575
|
+
// that already has edits needs no pointer. Print-only by design — no
|
|
576
|
+
// migration, no prompt — and best-effort: a courtesy line must never be the
|
|
577
|
+
// reason a produce fails.
|
|
578
|
+
if (isFolder && !existsSync(join(work, "overrides.json"))) {
|
|
579
|
+
try {
|
|
580
|
+
const workRoot = dirname(work);
|
|
581
|
+
const entries = readdirSync(workRoot, { withFileTypes: true })
|
|
582
|
+
.filter((d) => d.isDirectory())
|
|
583
|
+
.map((d) => {
|
|
584
|
+
const overrides = join(workRoot, d.name, "overrides.json");
|
|
585
|
+
const st = existsSync(overrides) ? statSync(overrides) : null;
|
|
586
|
+
return { name: d.name, hasOverrides: st !== null, mtimeMs: st?.mtimeMs ?? 0 };
|
|
587
|
+
});
|
|
588
|
+
const stranded = strandedOverrideSiblings({
|
|
589
|
+
base: workdirBaseName(input),
|
|
590
|
+
currentHash: hash,
|
|
591
|
+
entries,
|
|
592
|
+
});
|
|
593
|
+
for (const name of stranded) console.log(strandedPointerLine(join(workRoot, name)));
|
|
594
|
+
} catch {
|
|
595
|
+
// Racing deletes/permissions while scanning siblings: drop the hint,
|
|
596
|
+
// keep the run (§131 — the pointer is a courtesy, not a dependency).
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
498
600
|
if (isFolder && folderListing) {
|
|
499
601
|
const sort = opts.sort ?? "name";
|
|
500
602
|
const result = await concatFolder(tools, input, folderListing, work, sort, {
|
|
@@ -563,18 +665,48 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
563
665
|
|
|
564
666
|
let transcript: Transcript;
|
|
565
667
|
const transcriptCache = join(work, "transcript.json");
|
|
668
|
+
// Which model/language WROTE transcript.json, recorded beside it (review
|
|
669
|
+
// fix, Urdu field test 2026-08-05): the cache used to be reused on
|
|
670
|
+
// existence alone, so a warm workdir silently served the stale English
|
|
671
|
+
// transcript on the first `--whisper-language ur` retry — the exact run
|
|
672
|
+
// the flag exists for — and equally defeated the model A/B the
|
|
673
|
+
// --whisper-model help text advertises.
|
|
674
|
+
const transcriptKeyPath = join(work, "transcript-key.json");
|
|
675
|
+
const requestedKey: TranscriptKey = {
|
|
676
|
+
model: opts.whisperModel ?? cfg.model,
|
|
677
|
+
...(opts.whisperLanguage !== undefined ? { language: opts.whisperLanguage } : {}),
|
|
678
|
+
};
|
|
679
|
+
let cacheVerdict: ReturnType<typeof transcriptCacheReusable> | null = null;
|
|
680
|
+
if (!opts.transcript && existsSync(transcriptCache)) {
|
|
681
|
+
const recorded = existsSync(transcriptKeyPath)
|
|
682
|
+
? TranscriptKeySchema.parse(JSON.parse(await readFile(transcriptKeyPath, "utf8")))
|
|
683
|
+
: null;
|
|
684
|
+
cacheVerdict = transcriptCacheReusable(recorded, requestedKey, cfg.model);
|
|
685
|
+
}
|
|
566
686
|
if (opts.transcript) {
|
|
567
687
|
transcript = TranscriptSchema.parse(JSON.parse(await readFile(resolve(opts.transcript), "utf8")));
|
|
568
688
|
console.log(`▸ transcript injected from ${opts.transcript} (${transcript.words.length} words)`);
|
|
569
|
-
|
|
689
|
+
// An injected transcript came from no whisper run at all, so any key left
|
|
690
|
+
// by an earlier one would mislabel the cache this branch overwrites below.
|
|
691
|
+
// Keyless-as-default keeps today's behavior: later default runs reuse it,
|
|
692
|
+
// and only an explicit model/language request re-transcribes over it.
|
|
693
|
+
await rm(transcriptKeyPath, { force: true });
|
|
694
|
+
} else if (cacheVerdict?.reuse) {
|
|
570
695
|
transcript = TranscriptSchema.parse(JSON.parse(await readFile(transcriptCache, "utf8")));
|
|
571
696
|
console.log(`▸ transcript cached (${transcript.words.length} words)`);
|
|
572
697
|
} else {
|
|
698
|
+
if (cacheVerdict !== null) {
|
|
699
|
+
const fmt = (k: TranscriptKey) => `${k.model}/lang ${k.language ?? "default"}`;
|
|
700
|
+
console.log(
|
|
701
|
+
`▸ transcript cache is for model ${fmt(cacheVerdict.recorded)} — ` +
|
|
702
|
+
`re-transcribing with ${fmt(requestedKey)}`,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
573
705
|
await preflight(
|
|
574
706
|
cfg.whisperPath,
|
|
575
707
|
"Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
|
|
576
708
|
);
|
|
577
|
-
const model =
|
|
709
|
+
const model = requestedKey.model;
|
|
578
710
|
const modelPath = isAbsolute(model) ? model : join(cfg.modelDir, `ggml-${model}.bin`);
|
|
579
711
|
if (!existsSync(modelPath)) {
|
|
580
712
|
throw new Error(
|
|
@@ -585,10 +717,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
585
717
|
}
|
|
586
718
|
console.log(`▸ transcribing (${basename(modelPath)})…`);
|
|
587
719
|
transcript = await runWhisper(
|
|
588
|
-
{
|
|
720
|
+
{
|
|
721
|
+
whisperPath: cfg.whisperPath,
|
|
722
|
+
modelPath,
|
|
723
|
+
outBase: join(work, "whisper"),
|
|
724
|
+
language: opts.whisperLanguage,
|
|
725
|
+
},
|
|
589
726
|
audioPath,
|
|
590
727
|
);
|
|
591
728
|
console.log(`▸ transcribed ${transcript.words.length} words`);
|
|
729
|
+
await writeFile(transcriptKeyPath, JSON.stringify(requestedKey, null, 2));
|
|
592
730
|
}
|
|
593
731
|
await writeFile(transcriptCache, JSON.stringify(transcript, null, 2));
|
|
594
732
|
|
|
@@ -1882,6 +2020,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1882
2020
|
);
|
|
1883
2021
|
}
|
|
1884
2022
|
|
|
2023
|
+
// Resolved HERE, next to the props it feeds, and announced when on — the
|
|
2024
|
+
// one ▸ line is how a config-sourced credit stays visible per run instead
|
|
2025
|
+
// of surprising the author on upload.
|
|
2026
|
+
const watermark = resolveWatermark(opts.watermark, cfg.watermark);
|
|
2027
|
+
if (watermark) {
|
|
2028
|
+
console.log(
|
|
2029
|
+
`▸ watermark: "made with ossclip" in the top-left safe area` +
|
|
2030
|
+
`${opts.watermark === undefined ? " (from config; --no-watermark overrides)" : ""}`,
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1885
2034
|
const props = {
|
|
1886
2035
|
videoFileName: basename(renderVideo),
|
|
1887
2036
|
spans: [...map.spans],
|
|
@@ -1938,6 +2087,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1938
2087
|
...(opts.sourceFit === "contain"
|
|
1939
2088
|
? { sourceFit: "contain" as const, sourceSize: content }
|
|
1940
2089
|
: {}),
|
|
2090
|
+
// Written only when ON, matching the field's absent-means-off contract:
|
|
2091
|
+
// an off run's render-props.json stays byte-identical to a pre-watermark
|
|
2092
|
+
// one, so nothing downstream can tell the feature ever shipped.
|
|
2093
|
+
...(watermark ? { watermark: true } : {}),
|
|
1941
2094
|
};
|
|
1942
2095
|
await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
|
|
1943
2096
|
|
|
@@ -2107,9 +2260,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2107
2260
|
// recordedProduceArgs prefers the argv the re-entry stashed and falls back
|
|
2108
2261
|
// to process.argv for a directly typed `ossclip produce …`, which stays
|
|
2109
2262
|
// byte-identical to what was always recorded.
|
|
2263
|
+
// Watermark pin, same §75 shape, and in BOTH directions (review,
|
|
2264
|
+
// Important): the effective default comes from THIS machine's
|
|
2265
|
+
// ~/.ossclip/config.json, so an unpinned record replays differently
|
|
2266
|
+
// wherever that config differs — an off-run would silently gain a credit
|
|
2267
|
+
// under a later/foreign config-on, an on-run would silently lose it. The
|
|
2268
|
+
// RESOLVED state is always pinned; a typed flag is already in the argv and
|
|
2269
|
+
// the includes-guard leaves it alone.
|
|
2110
2270
|
const recordedArgs = recordedProduceArgs({
|
|
2111
2271
|
llm: provider ? providerName : undefined,
|
|
2112
2272
|
clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
|
|
2273
|
+
watermark,
|
|
2113
2274
|
});
|
|
2114
2275
|
await writeFile(
|
|
2115
2276
|
join(work, "command.json"),
|
package/src/program.ts
CHANGED
|
@@ -106,7 +106,17 @@ export function buildProgram(): Command {
|
|
|
106
106
|
}
|
|
107
107
|
const { produceWizard } = await import("./interactive/produce-wizard");
|
|
108
108
|
const { loadConfig } = await import("@ossclip/core");
|
|
109
|
-
const
|
|
109
|
+
const cfg = loadConfig();
|
|
110
|
+
// modelDir so the wizard can enumerate installed whisper models
|
|
111
|
+
// (Urdu field test 2026-08-05) — same resolution produce.ts uses.
|
|
112
|
+
// watermark so the extras entry can say when the config already has
|
|
113
|
+
// the credit on (unchecked ≠ off there — review, minor a).
|
|
114
|
+
const argv = await produceWizard({
|
|
115
|
+
speaker: cfg.speaker,
|
|
116
|
+
modelDir: cfg.modelDir,
|
|
117
|
+
input: path,
|
|
118
|
+
watermark: cfg.watermark,
|
|
119
|
+
});
|
|
110
120
|
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
111
121
|
setReplayArgv(argv); // §129
|
|
112
122
|
await program.parseAsync(["node", "ossclip", ...argv]);
|
|
@@ -136,7 +146,12 @@ export function buildProgram(): Command {
|
|
|
136
146
|
}
|
|
137
147
|
const { produceWizard } = await import("./interactive/produce-wizard");
|
|
138
148
|
const { loadConfig } = await import("@ossclip/core");
|
|
139
|
-
const
|
|
149
|
+
const cfg = loadConfig();
|
|
150
|
+
const argv = await produceWizard({
|
|
151
|
+
speaker: cfg.speaker,
|
|
152
|
+
modelDir: cfg.modelDir,
|
|
153
|
+
watermark: cfg.watermark,
|
|
154
|
+
});
|
|
140
155
|
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
141
156
|
setReplayArgv(argv); // §129
|
|
142
157
|
await program.parseAsync(["node", "ossclip", ...argv]);
|
|
@@ -215,6 +230,10 @@ export function buildProgram(): Command {
|
|
|
215
230
|
"skip the ASR mishearing repair pass (captions then show the raw transcription)",
|
|
216
231
|
)
|
|
217
232
|
.option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
|
|
233
|
+
.option(
|
|
234
|
+
"--whisper-language <code>",
|
|
235
|
+
"transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
|
|
236
|
+
)
|
|
218
237
|
.option(
|
|
219
238
|
"--force-component <id>",
|
|
220
239
|
"debug: render every graphic with this component (e.g. FlowDiagram) to exercise it on real copy",
|
|
@@ -241,6 +260,16 @@ export function buildProgram(): Command {
|
|
|
241
260
|
"the last complete attempt — the flub the speaker did NOT mark. Off by default",
|
|
242
261
|
false,
|
|
243
262
|
)
|
|
263
|
+
// Declared as the same tri-state pair as --open-editor/--no-open-editor:
|
|
264
|
+
// positive first so commander's default stays undefined ("not typed"),
|
|
265
|
+
// which is what lets the config supply the default while a typed
|
|
266
|
+
// --no-watermark still beats a config-on.
|
|
267
|
+
.option(
|
|
268
|
+
"--watermark",
|
|
269
|
+
'credit the tool: a small "made with ossclip" wordmark in the top-left safe area ' +
|
|
270
|
+
"(set it once with watermark: true in ~/.ossclip/config.json)",
|
|
271
|
+
)
|
|
272
|
+
.option("--no-watermark", "no wordmark, even when the config turns it on")
|
|
244
273
|
.option("--no-cover", "skip the cover image written beside the video")
|
|
245
274
|
.option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
|
|
246
275
|
.option("--open-editor", "open the editor when the run finishes")
|
|
@@ -272,7 +301,12 @@ export function buildProgram(): Command {
|
|
|
272
301
|
const { produceWizard } = await import("./interactive/produce-wizard");
|
|
273
302
|
const { renderCommand } = await import("./interactive/render");
|
|
274
303
|
const { loadConfig } = await import("@ossclip/core");
|
|
275
|
-
const
|
|
304
|
+
const cfg = loadConfig();
|
|
305
|
+
const argv = await produceWizard({
|
|
306
|
+
speaker: cfg.speaker,
|
|
307
|
+
modelDir: cfg.modelDir,
|
|
308
|
+
watermark: cfg.watermark,
|
|
309
|
+
});
|
|
276
310
|
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
277
311
|
// Re-entering the SAME parse the flags take: the zod checks below run
|
|
278
312
|
// on wizard output exactly as they do on a typed command line.
|
|
@@ -300,6 +334,14 @@ export function buildProgram(): Command {
|
|
|
300
334
|
// --sort" from "commander's own default filled it in" so produce() can
|
|
301
335
|
// say something when --sort is given for a file, where it does nothing.
|
|
302
336
|
const sortExplicit = command.getOptionValueSource("sort") === "cli";
|
|
337
|
+
// Not an enum — whisper accepts dozens of codes plus "auto" and the list
|
|
338
|
+
// grows with fine-tunes — but an empty string would reach whisper as a
|
|
339
|
+
// bare `-l` and must be an error naming the flag, not a silent English
|
|
340
|
+
// run over an Urdu model (Urdu field test 2026-08-05).
|
|
341
|
+
const whisperLanguage =
|
|
342
|
+
opts.whisperLanguage !== undefined
|
|
343
|
+
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
344
|
+
: undefined;
|
|
303
345
|
const result = await produce(input, {
|
|
304
346
|
out: opts.out,
|
|
305
347
|
cleanup,
|
|
@@ -320,6 +362,7 @@ export function buildProgram(): Command {
|
|
|
320
362
|
scenes: opts.scenes,
|
|
321
363
|
repair: opts.repair,
|
|
322
364
|
whisperModel: opts.whisperModel,
|
|
365
|
+
whisperLanguage,
|
|
323
366
|
forceComponent,
|
|
324
367
|
// commander gives `--no-cover` as cover:false and `--cover <path>` as a
|
|
325
368
|
// string on the same key.
|
|
@@ -327,6 +370,8 @@ export function buildProgram(): Command {
|
|
|
327
370
|
blooperMarker: opts.blooperMarker,
|
|
328
371
|
collapseRetakes: opts.collapseRetakes,
|
|
329
372
|
sourceFit,
|
|
373
|
+
// undefined = "not typed", so produce can let the config decide.
|
|
374
|
+
watermark: opts.watermark,
|
|
330
375
|
cover: opts.cover !== false,
|
|
331
376
|
coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
|
|
332
377
|
clip: opts.clip,
|
|
@@ -345,6 +390,10 @@ export function buildProgram(): Command {
|
|
|
345
390
|
.option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
|
|
346
391
|
.option("--workdir <dir>", "cache/work directory")
|
|
347
392
|
.option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
|
|
393
|
+
.option(
|
|
394
|
+
"--whisper-language <code>",
|
|
395
|
+
"transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
|
|
396
|
+
)
|
|
348
397
|
.action(async (input: string, opts) => {
|
|
349
398
|
const cleanup = CleanupLevelSchema.parse(opts.cleanup);
|
|
350
399
|
await produce(input, {
|
|
@@ -355,6 +404,11 @@ export function buildProgram(): Command {
|
|
|
355
404
|
workdir: opts.workdir,
|
|
356
405
|
noiseDb: opts.noiseDb,
|
|
357
406
|
whisperModel: opts.whisperModel,
|
|
407
|
+
// Same guard as produce's: empty must error, not become a bare `-l`.
|
|
408
|
+
whisperLanguage:
|
|
409
|
+
opts.whisperLanguage !== undefined
|
|
410
|
+
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
411
|
+
: undefined,
|
|
358
412
|
});
|
|
359
413
|
});
|
|
360
414
|
|