ossclip 0.1.11 → 0.1.13

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.
@@ -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-Bo0WME7k.js"></script>
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.11",
3
+ "version": "0.1.13",
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.11",
40
- "@ossclip/renderer": "0.1.11",
41
- "@ossclip/scenes": "0.1.11"
39
+ "@ossclip/core": "0.1.13",
40
+ "@ossclip/renderer": "0.1.13",
41
+ "@ossclip/scenes": "0.1.13"
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) sorted
8
- * into three tiers: six prompts asked directly — the input path, plus five
9
- * flags (--out, --cleanup, --aspect, --produce, --intent) — eight behind one
10
- * "anything else?" multiselect, and the remaining seventeen stay flags-only:
11
- * debug/internal surfaces, replay-only fields, or (final-review fix wave,
12
- * Finding 1) --sort. A folder's clip order only means anything once the
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 extrasFor(graphics: boolean): (typeof EXTRAS)[number][] {
50
- return graphics ? [...EXTRAS] : EXTRAS.filter((e) => e.value !== "graphicsClip");
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(cfg: { speaker?: string; input?: string } = {}): Promise<string[]> {
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
- extras.whisperModel = unwrap(
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 {
@@ -24,6 +24,7 @@ import {
24
24
  concatFolder,
25
25
  folderManifestKey,
26
26
  listFolderVideos,
27
+ coverDecision,
27
28
  coverHeadline,
28
29
  cropFilter,
29
30
  detectContentRect,
@@ -97,6 +98,11 @@ import {
97
98
  type Transcript,
98
99
  } from "@ossclip/core";
99
100
  import { recordRecentProject } from "./edit";
101
+ import {
102
+ strandedOverrideSiblings,
103
+ strandedPointerLine,
104
+ workdirBaseName,
105
+ } from "./stranded-overrides";
100
106
  import { editHint } from "./interactive/edit-hint";
101
107
  import { recordedProduceArgs } from "./replay-argv";
102
108
  import { renderCover, renderProduction } from "@ossclip/renderer";
@@ -118,6 +124,43 @@ export interface ProduceResult {
118
124
  rendered: boolean;
119
125
  }
120
126
 
127
+ /**
128
+ * What produced the workdir's transcript.json — written beside it as
129
+ * transcript-key.json (review fix, Urdu field test 2026-08-05). Parsed with
130
+ * zod like the transcript itself: a hand-edited or truncated key must error,
131
+ * not silently decide whether a cache is reused.
132
+ */
133
+ export const TranscriptKeySchema = z.object({
134
+ model: z.string(),
135
+ language: z.string().optional(),
136
+ });
137
+ export type TranscriptKey = z.infer<typeof TranscriptKeySchema>;
138
+
139
+ /**
140
+ * Whether the cached transcript answers the current request. Pure so all
141
+ * four corners (matching key, differing key, keyless + default, keyless +
142
+ * non-default) are testable without a workdir. A missing key means a workdir
143
+ * from before the key existed; every one of those was transcribed with the
144
+ * config-default model and no -l, so it is treated as exactly that — old
145
+ * workdirs must not all re-transcribe spuriously, but a non-default request
146
+ * against a keyless cache must.
147
+ */
148
+ export function transcriptCacheReusable(
149
+ recorded: TranscriptKey | null,
150
+ requested: TranscriptKey,
151
+ defaultModel: string,
152
+ ): { reuse: boolean; recorded: TranscriptKey } {
153
+ const effective = recorded ?? { model: defaultModel };
154
+ return {
155
+ reuse:
156
+ effective.model === requested.model &&
157
+ // "" and absent both mean whisper's en default — program.ts rejects an
158
+ // empty code, but a key file predating that guard must not wedge.
159
+ (effective.language ?? "") === (requested.language ?? ""),
160
+ recorded: effective,
161
+ };
162
+ }
163
+
121
164
  export interface ProduceOptions {
122
165
  out?: string;
123
166
  cleanup: CleanupLevel;
@@ -143,6 +186,12 @@ export interface ProduceOptions {
143
186
  repair?: boolean;
144
187
  /** Override the whisper model for this run (A/B base.en vs small.en). */
145
188
  whisperModel?: string;
189
+ /**
190
+ * `-l` language code for whisper (e.g. "ur", "auto"). Unset keeps whisper's
191
+ * English default — required for a non-English fine-tune, which otherwise
192
+ * decodes garbage (Urdu field test 2026-08-05).
193
+ */
194
+ whisperLanguage?: string;
146
195
  /** Debug: force every graphic moment to this component. */
147
196
  forceComponent?: SceneComponentId;
148
197
  /** Write a cover image beside the video (default on). */
@@ -190,6 +239,14 @@ export interface ProduceOptions {
190
239
  * window with zero LLM calls. Written by clip runs; not for hand use.
191
240
  */
192
241
  clipWindow?: string;
242
+ /**
243
+ * `--watermark` / `--no-watermark` tri-state: true/false when TYPED,
244
+ * undefined when not — undefined lets the config's `watermark` key supply
245
+ * the default (`resolveWatermark`). Opt-in by design: the default is off
246
+ * for everyone, because a forced watermark on an open-source tool reads as
247
+ * a free-tier limitation; this is voluntary attribution.
248
+ */
249
+ watermark?: boolean;
193
250
  /**
194
251
  * `<input>` a DIRECTORY: order its clips before concatenating them into the
195
252
  * source produce runs on (folder-input-brief.md). `name` (default) is a
@@ -207,6 +264,23 @@ export interface ProduceOptions {
207
264
  sortExplicit?: boolean;
208
265
  }
209
266
 
267
+ /**
268
+ * The effective watermark switch: a TYPED flag always wins (so
269
+ * `--no-watermark` beats a config-on), and only then does the config supply
270
+ * the default. The config side is `=== true`, never truthiness — the value
271
+ * comes from a hand-editable JSON file loadConfig doesn't zod-parse, and a
272
+ * typo'd `"watermark": "no"` coercing a credit ON is exactly the
273
+ * parse-don't-coerce failure CLAUDE.md forbids; for an opt-in credit, off is
274
+ * the only safe reading of anything malformed. Pure so the whole
275
+ * flag × config matrix is testable without a config file on disk.
276
+ */
277
+ export function resolveWatermark(
278
+ flag: boolean | undefined,
279
+ configValue: boolean | undefined,
280
+ ): boolean {
281
+ return flag ?? configValue === true;
282
+ }
283
+
210
284
  function sha1File(path: string): Promise<string> {
211
285
  return new Promise((res, rej) => {
212
286
  const h = createHash("sha1");
@@ -246,10 +320,9 @@ function deriveWorkdir(
246
320
  landscape: boolean,
247
321
  ): string {
248
322
  const workRoot = workdirOpt ? resolve(workdirOpt) : join(dirname(identity), ".ossclip");
249
- return join(
250
- workRoot,
251
- `${basename(identity).replace(/\.[^.]+$/, "")}-${hash}${landscape ? "-16x9" : ""}`,
252
- );
323
+ // The basename half is shared with the §131 stranded-edits scan so the
324
+ // scan's matching can never drift from the naming it scans for.
325
+ return join(workRoot, `${workdirBaseName(identity)}-${hash}${landscape ? "-16x9" : ""}`);
253
326
  }
254
327
 
255
328
  /**
@@ -495,6 +568,36 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
495
568
  await mkdir(work, { recursive: true });
496
569
  console.log(`▸ workdir ${work}`);
497
570
 
571
+ // §131 residue: a folder re-key (clips renamed/added/removed → new content
572
+ // hash) correctly lands in a fresh workdir, but any editor edits saved in
573
+ // the PREVIOUS workdir's overrides.json don't carry over — and without this
574
+ // pointer the user sees a clean produce and never learns where those edits
575
+ // went. Gated on the current workdir lacking overrides.json: a warm re-run
576
+ // that already has edits needs no pointer. Print-only by design — no
577
+ // migration, no prompt — and best-effort: a courtesy line must never be the
578
+ // reason a produce fails.
579
+ if (isFolder && !existsSync(join(work, "overrides.json"))) {
580
+ try {
581
+ const workRoot = dirname(work);
582
+ const entries = readdirSync(workRoot, { withFileTypes: true })
583
+ .filter((d) => d.isDirectory())
584
+ .map((d) => {
585
+ const overrides = join(workRoot, d.name, "overrides.json");
586
+ const st = existsSync(overrides) ? statSync(overrides) : null;
587
+ return { name: d.name, hasOverrides: st !== null, mtimeMs: st?.mtimeMs ?? 0 };
588
+ });
589
+ const stranded = strandedOverrideSiblings({
590
+ base: workdirBaseName(input),
591
+ currentHash: hash,
592
+ entries,
593
+ });
594
+ for (const name of stranded) console.log(strandedPointerLine(join(workRoot, name)));
595
+ } catch {
596
+ // Racing deletes/permissions while scanning siblings: drop the hint,
597
+ // keep the run (§131 — the pointer is a courtesy, not a dependency).
598
+ }
599
+ }
600
+
498
601
  if (isFolder && folderListing) {
499
602
  const sort = opts.sort ?? "name";
500
603
  const result = await concatFolder(tools, input, folderListing, work, sort, {
@@ -563,18 +666,48 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
563
666
 
564
667
  let transcript: Transcript;
565
668
  const transcriptCache = join(work, "transcript.json");
669
+ // Which model/language WROTE transcript.json, recorded beside it (review
670
+ // fix, Urdu field test 2026-08-05): the cache used to be reused on
671
+ // existence alone, so a warm workdir silently served the stale English
672
+ // transcript on the first `--whisper-language ur` retry — the exact run
673
+ // the flag exists for — and equally defeated the model A/B the
674
+ // --whisper-model help text advertises.
675
+ const transcriptKeyPath = join(work, "transcript-key.json");
676
+ const requestedKey: TranscriptKey = {
677
+ model: opts.whisperModel ?? cfg.model,
678
+ ...(opts.whisperLanguage !== undefined ? { language: opts.whisperLanguage } : {}),
679
+ };
680
+ let cacheVerdict: ReturnType<typeof transcriptCacheReusable> | null = null;
681
+ if (!opts.transcript && existsSync(transcriptCache)) {
682
+ const recorded = existsSync(transcriptKeyPath)
683
+ ? TranscriptKeySchema.parse(JSON.parse(await readFile(transcriptKeyPath, "utf8")))
684
+ : null;
685
+ cacheVerdict = transcriptCacheReusable(recorded, requestedKey, cfg.model);
686
+ }
566
687
  if (opts.transcript) {
567
688
  transcript = TranscriptSchema.parse(JSON.parse(await readFile(resolve(opts.transcript), "utf8")));
568
689
  console.log(`▸ transcript injected from ${opts.transcript} (${transcript.words.length} words)`);
569
- } else if (existsSync(transcriptCache)) {
690
+ // An injected transcript came from no whisper run at all, so any key left
691
+ // by an earlier one would mislabel the cache this branch overwrites below.
692
+ // Keyless-as-default keeps today's behavior: later default runs reuse it,
693
+ // and only an explicit model/language request re-transcribes over it.
694
+ await rm(transcriptKeyPath, { force: true });
695
+ } else if (cacheVerdict?.reuse) {
570
696
  transcript = TranscriptSchema.parse(JSON.parse(await readFile(transcriptCache, "utf8")));
571
697
  console.log(`▸ transcript cached (${transcript.words.length} words)`);
572
698
  } else {
699
+ if (cacheVerdict !== null) {
700
+ const fmt = (k: TranscriptKey) => `${k.model}/lang ${k.language ?? "default"}`;
701
+ console.log(
702
+ `▸ transcript cache is for model ${fmt(cacheVerdict.recorded)} — ` +
703
+ `re-transcribing with ${fmt(requestedKey)}`,
704
+ );
705
+ }
573
706
  await preflight(
574
707
  cfg.whisperPath,
575
708
  "Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
576
709
  );
577
- const model = opts.whisperModel ?? cfg.model;
710
+ const model = requestedKey.model;
578
711
  const modelPath = isAbsolute(model) ? model : join(cfg.modelDir, `ggml-${model}.bin`);
579
712
  if (!existsSync(modelPath)) {
580
713
  throw new Error(
@@ -585,10 +718,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
585
718
  }
586
719
  console.log(`▸ transcribing (${basename(modelPath)})…`);
587
720
  transcript = await runWhisper(
588
- { whisperPath: cfg.whisperPath, modelPath, outBase: join(work, "whisper") },
721
+ {
722
+ whisperPath: cfg.whisperPath,
723
+ modelPath,
724
+ outBase: join(work, "whisper"),
725
+ language: opts.whisperLanguage,
726
+ },
589
727
  audioPath,
590
728
  );
591
729
  console.log(`▸ transcribed ${transcript.words.length} words`);
730
+ await writeFile(transcriptKeyPath, JSON.stringify(requestedKey, null, 2));
592
731
  }
593
732
  await writeFile(transcriptCache, JSON.stringify(transcript, null, 2));
594
733
 
@@ -1882,6 +2021,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1882
2021
  );
1883
2022
  }
1884
2023
 
2024
+ // Resolved HERE, next to the props it feeds, and announced when on — the
2025
+ // one ▸ line is how a config-sourced credit stays visible per run instead
2026
+ // of surprising the author on upload.
2027
+ const watermark = resolveWatermark(opts.watermark, cfg.watermark);
2028
+ if (watermark) {
2029
+ console.log(
2030
+ `▸ watermark: "made with ossclip" in the top-left safe area` +
2031
+ `${opts.watermark === undefined ? " (from config; --no-watermark overrides)" : ""}`,
2032
+ );
2033
+ }
2034
+
1885
2035
  const props = {
1886
2036
  videoFileName: basename(renderVideo),
1887
2037
  spans: [...map.spans],
@@ -1938,6 +2088,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1938
2088
  ...(opts.sourceFit === "contain"
1939
2089
  ? { sourceFit: "contain" as const, sourceSize: content }
1940
2090
  : {}),
2091
+ // Written only when ON, matching the field's absent-means-off contract:
2092
+ // an off run's render-props.json stays byte-identical to a pre-watermark
2093
+ // one, so nothing downstream can tell the feature ever shipped.
2094
+ ...(watermark ? { watermark: true } : {}),
1941
2095
  };
1942
2096
  await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
1943
2097
 
@@ -2008,13 +2162,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2008
2162
  // A separate file, not a burned-in intro: both platforms accept a custom
2009
2163
  // cover, so nothing has to be pickable from the video — and spending the
2010
2164
  // opening seconds on a title card fights the hook-in-2s policy directly.
2011
- if (opts.cover !== false) {
2165
+ {
2012
2166
  // §35's cap applies here too: a cached beat sheet from before the fix, or
2013
2167
  // the hook fallback, must not slip a 13-word paragraph onto a thumbnail.
2014
2168
  const coverText = coverHeadline(beatSheet?.coverText ?? beatSheet?.hook ?? "");
2015
- if (!coverText) {
2016
- console.log("▸ no cover text (run --produce for one) — skipping cover");
2017
- } else {
2169
+ // Urdu field run 2026-08-05: a run without --produce has no hook text,
2170
+ // and skipping the cover for that threw away the part that never needed
2171
+ // text — the sharpness-scored face frame. No headline now means a bare
2172
+ // frame, not no cover; see `coverDecision`'s doc comment.
2173
+ const cover = coverDecision(opts.cover !== false, coverText);
2174
+ if (cover !== "none") {
2018
2175
  const detector = await createFaceDetector();
2019
2176
  const pick = await pickCoverFrame(tools, analysisInput, analysisProbe.duration, {
2020
2177
  cacheDir: work,
@@ -2042,31 +2199,45 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2042
2199
  const coverPath = resolve(
2043
2200
  opts.coverPath ?? outPath.replace(/(\.[^.]+)?$/, ".cover.jpg"),
2044
2201
  );
2045
- // §34: if the source's own title is up at this instant, the frame
2046
- // already has a headline. Adding ours states the same claim twice in
2047
- // one image — a cover with one title beats a cover with two.
2048
- const sourceTitled = regionsDuring(
2049
- sourceText.regions,
2050
- pick.timeSec - 0.5,
2051
- pick.timeSec + 0.5,
2052
- ).length > 0;
2053
- console.log(
2054
- `▸ cover from ${pick.timeSec.toFixed(1)}s ` +
2055
- `(${pick.hasFace ? "face" : "no face"}, sharpness ${pick.sharpness.toFixed(0)})…`,
2056
- );
2057
- if (sourceTitled) {
2058
- console.log(" ▸ source already has a title in this frame — shipping it without a banner");
2059
- } else if (pick.face) {
2060
- const band = coverTextRect(pick.face, frame);
2202
+ // The §34 dedupe check and the band-placement log exist only to
2203
+ // route a banner around the frame's contents — a textless cover
2204
+ // (Urdu field run 2026-08-05) has no banner to place, so both are
2205
+ // skipped rather than run against text that isn't there.
2206
+ let bannerText = "";
2207
+ if (cover === "banner") {
2208
+ // §34: if the source's own title is up at this instant, the frame
2209
+ // already has a headline. Adding ours states the same claim twice in
2210
+ // one image — a cover with one title beats a cover with two.
2211
+ const sourceTitled = regionsDuring(
2212
+ sourceText.regions,
2213
+ pick.timeSec - 0.5,
2214
+ pick.timeSec + 0.5,
2215
+ ).length > 0;
2216
+ console.log(
2217
+ `▸ cover from ${pick.timeSec.toFixed(1)}s ` +
2218
+ `(${pick.hasFace ? "face" : "no face"}, sharpness ${pick.sharpness.toFixed(0)})…`,
2219
+ );
2220
+ if (sourceTitled) {
2221
+ console.log(" ▸ source already has a title in this frame — shipping it without a banner");
2222
+ } else if (pick.face) {
2223
+ const band = coverTextRect(pick.face, frame);
2224
+ console.log(
2225
+ ` ▸ banner in the ${band.y + band.h / 2 < pick.face.centerYFrac ? "band above" : "band below"} ` +
2226
+ `the face (${(band.y * 100).toFixed(0)}-${((band.y + band.h) * 100).toFixed(0)}%)`,
2227
+ );
2228
+ }
2229
+ bannerText = sourceTitled ? "" : coverText;
2230
+ } else {
2061
2231
  console.log(
2062
- ` ▸ banner in the ${band.y + band.h / 2 < pick.face.centerYFrac ? "band above" : "band below"} ` +
2063
- `the face (${(band.y * 100).toFixed(0)}-${((band.y + band.h) * 100).toFixed(0)}%)`,
2232
+ `▸ cover from ${pick.timeSec.toFixed(1)}s ` +
2233
+ `(${pick.hasFace ? "face" : "no face"}, sharpness ${pick.sharpness.toFixed(0)}) ` +
2234
+ `— no banner text (run --produce for one)`,
2064
2235
  );
2065
2236
  }
2066
2237
  await renderCover(
2067
2238
  {
2068
2239
  frameFileName: frameName,
2069
- text: sourceTitled ? "" : coverText,
2240
+ text: bannerText,
2070
2241
  theme,
2071
2242
  face: pick.face,
2072
2243
  // The cover is the OUTPUT's thumbnail — a landscape render gets a
@@ -2107,9 +2278,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2107
2278
  // recordedProduceArgs prefers the argv the re-entry stashed and falls back
2108
2279
  // to process.argv for a directly typed `ossclip produce …`, which stays
2109
2280
  // byte-identical to what was always recorded.
2281
+ // Watermark pin, same §75 shape, and in BOTH directions (review,
2282
+ // Important): the effective default comes from THIS machine's
2283
+ // ~/.ossclip/config.json, so an unpinned record replays differently
2284
+ // wherever that config differs — an off-run would silently gain a credit
2285
+ // under a later/foreign config-on, an on-run would silently lose it. The
2286
+ // RESOLVED state is always pinned; a typed flag is already in the argv and
2287
+ // the includes-guard leaves it alone.
2110
2288
  const recordedArgs = recordedProduceArgs({
2111
2289
  llm: provider ? providerName : undefined,
2112
2290
  clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
2291
+ watermark,
2113
2292
  });
2114
2293
  await writeFile(
2115
2294
  join(work, "command.json"),