ossclip 0.1.10 → 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/src/program.ts CHANGED
@@ -7,6 +7,7 @@ import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
7
7
  import { STUDIO_ENTRY } from "@ossclip/renderer";
8
8
  import { loadEnvFiles } from "./env";
9
9
  import { produce } from "./produce";
10
+ import { setReplayArgv } from "./replay-argv";
10
11
 
11
12
  // Before anything reads a provider key (R16 §77) — including the auto-detect
12
13
  // order in `defaultProviderName`, which decides which model runs.
@@ -95,13 +96,29 @@ export function buildProgram(): Command {
95
96
  // IS supplied — a piped `ossclip <path>` is `ossclip produce <path>`.
96
97
  const direct = ["produce", path];
97
98
  console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
99
+ // §129: THIS argv — not process.argv, which still says `ossclip
100
+ // <path>` with no `produce` literal — is the invocation
101
+ // command.json must record for the editor's Render to replay.
102
+ // Every parseAsync re-entry below stashes for the same reason.
103
+ setReplayArgv(direct);
98
104
  await program.parseAsync(["node", "ossclip", ...direct]);
99
105
  return;
100
106
  }
101
107
  const { produceWizard } = await import("./interactive/produce-wizard");
102
108
  const { loadConfig } = await import("@ossclip/core");
103
- const argv = await produceWizard({ speaker: loadConfig().speaker, input: path });
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
+ });
104
120
  console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
121
+ setReplayArgv(argv); // §129
105
122
  await program.parseAsync(["node", "ossclip", ...argv]);
106
123
  return;
107
124
  }
@@ -119,13 +136,24 @@ export function buildProgram(): Command {
119
136
  // menu is also how you learn the flags", and three of the four entries
120
137
  // printed nothing. The menu's whole pedagogical point is this line.
121
138
  console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
139
+ // §129: stashed even for non-produce choices — the invariant is that
140
+ // the stash always mirrors the parse being entered, and only
141
+ // produce's recording ever reads it (consume-on-read keeps a
142
+ // non-produce stash from leaking past this parse).
143
+ setReplayArgv(direct);
122
144
  await program.parseAsync(["node", "ossclip", ...direct]);
123
145
  return;
124
146
  }
125
147
  const { produceWizard } = await import("./interactive/produce-wizard");
126
148
  const { loadConfig } = await import("@ossclip/core");
127
- const argv = await produceWizard({ speaker: loadConfig().speaker });
149
+ const cfg = loadConfig();
150
+ const argv = await produceWizard({
151
+ speaker: cfg.speaker,
152
+ modelDir: cfg.modelDir,
153
+ watermark: cfg.watermark,
154
+ });
128
155
  console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
156
+ setReplayArgv(argv); // §129
129
157
  await program.parseAsync(["node", "ossclip", ...argv]);
130
158
  });
131
159
 
@@ -202,6 +230,10 @@ export function buildProgram(): Command {
202
230
  "skip the ASR mishearing repair pass (captions then show the raw transcription)",
203
231
  )
204
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
+ )
205
237
  .option(
206
238
  "--force-component <id>",
207
239
  "debug: render every graphic with this component (e.g. FlowDiagram) to exercise it on real copy",
@@ -228,6 +260,16 @@ export function buildProgram(): Command {
228
260
  "the last complete attempt — the flub the speaker did NOT mark. Off by default",
229
261
  false,
230
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")
231
273
  .option("--no-cover", "skip the cover image written beside the video")
232
274
  .option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
233
275
  .option("--open-editor", "open the editor when the run finishes")
@@ -259,10 +301,16 @@ export function buildProgram(): Command {
259
301
  const { produceWizard } = await import("./interactive/produce-wizard");
260
302
  const { renderCommand } = await import("./interactive/render");
261
303
  const { loadConfig } = await import("@ossclip/core");
262
- const argv = await produceWizard({ speaker: loadConfig().speaker });
304
+ const cfg = loadConfig();
305
+ const argv = await produceWizard({
306
+ speaker: cfg.speaker,
307
+ modelDir: cfg.modelDir,
308
+ watermark: cfg.watermark,
309
+ });
263
310
  console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
264
311
  // Re-entering the SAME parse the flags take: the zod checks below run
265
312
  // on wizard output exactly as they do on a typed command line.
313
+ setReplayArgv(argv); // §129
266
314
  await program.parseAsync(["node", "ossclip", ...argv]);
267
315
  return;
268
316
  }
@@ -286,6 +334,14 @@ export function buildProgram(): Command {
286
334
  // --sort" from "commander's own default filled it in" so produce() can
287
335
  // say something when --sort is given for a file, where it does nothing.
288
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;
289
345
  const result = await produce(input, {
290
346
  out: opts.out,
291
347
  cleanup,
@@ -306,6 +362,7 @@ export function buildProgram(): Command {
306
362
  scenes: opts.scenes,
307
363
  repair: opts.repair,
308
364
  whisperModel: opts.whisperModel,
365
+ whisperLanguage,
309
366
  forceComponent,
310
367
  // commander gives `--no-cover` as cover:false and `--cover <path>` as a
311
368
  // string on the same key.
@@ -313,6 +370,8 @@ export function buildProgram(): Command {
313
370
  blooperMarker: opts.blooperMarker,
314
371
  collapseRetakes: opts.collapseRetakes,
315
372
  sourceFit,
373
+ // undefined = "not typed", so produce can let the config decide.
374
+ watermark: opts.watermark,
316
375
  cover: opts.cover !== false,
317
376
  coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
318
377
  clip: opts.clip,
@@ -331,6 +390,10 @@ export function buildProgram(): Command {
331
390
  .option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
332
391
  .option("--workdir <dir>", "cache/work directory")
333
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
+ )
334
397
  .action(async (input: string, opts) => {
335
398
  const cleanup = CleanupLevelSchema.parse(opts.cleanup);
336
399
  await produce(input, {
@@ -341,6 +404,11 @@ export function buildProgram(): Command {
341
404
  workdir: opts.workdir,
342
405
  noiseDb: opts.noiseDb,
343
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,
344
412
  });
345
413
  });
346
414
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What command.json must record: the argv of the parse that ACTUALLY ran
3
+ * (§129).
4
+ *
5
+ * `produce` records its invocation into the workdir so the editor's Render
6
+ * button can replay it byte for byte. It used to record `process.argv`,
7
+ * which is the truth for exactly one entry point — a directly typed
8
+ * `ossclip produce …`. The wizard has always BUILT a produce argv and
9
+ * re-entered `program.parseAsync(["node", "ossclip", ...argv])`, and the
10
+ * bare-path route does the same; in both, process.argv still holds the
11
+ * ORIGINAL invocation — no `produce` literal, none of the wizard's answers —
12
+ * so every re-entered run recorded a command that replays as
13
+ * `ossclip <path> --llm …` and dies at commander's front door with
14
+ * "error: unknown option '--llm'" (§129's field artifact). The re-entry
15
+ * sites in program.ts stash the argv they are about to parse here; the
16
+ * recording prefers the stash and falls back to process.argv, which keeps
17
+ * the direct path byte-identical to what it always wrote.
18
+ */
19
+
20
+ let stashed: string[] | null = null;
21
+
22
+ /**
23
+ * Called by every parseAsync re-entry in program.ts, immediately before the
24
+ * parse, with the argv minus its ["node", "ossclip"] prefix. Copied so a
25
+ * caller reusing its array cannot retroactively edit the record.
26
+ */
27
+ export function setReplayArgv(argv: string[]): void {
28
+ stashed = [...argv];
29
+ }
30
+
31
+ /**
32
+ * Consume-on-read (§129): commander 12 keeps option state across parseAsync
33
+ * calls (see the bare-`produce` refusal in program.ts), and a stash kept
34
+ * across parses would be the same trap one layer up — a menu choice that
35
+ * never reaches `produce` must not leave its argv behind for a later
36
+ * recording in the same process to mistake for its own.
37
+ */
38
+ export function consumeReplayArgv(): string[] | null {
39
+ const argv = stashed;
40
+ stashed = null;
41
+ return argv;
42
+ }
43
+
44
+ /**
45
+ * The args `produce` writes into command.json: the argv of the parse that
46
+ * ran (stash for a re-entered wizard/bare-path run, process.argv for a
47
+ * directly typed one), plus the §75/§93g pins. The pins guard on
48
+ * `includes` so a flag the user actually typed — or a pin recorded by the
49
+ * run a replay is re-running — is never appended twice.
50
+ */
51
+ export function recordedProduceArgs(pins: {
52
+ llm?: string;
53
+ clipWindow?: string;
54
+ watermark?: boolean;
55
+ }): string[] {
56
+ const args = consumeReplayArgv() ?? process.argv.slice(2);
57
+ if (pins.llm !== undefined && !args.includes("--llm")) {
58
+ args.push("--llm", pins.llm);
59
+ }
60
+ if (pins.clipWindow !== undefined && !args.includes("--clip-window")) {
61
+ args.push("--clip-window", pins.clipWindow);
62
+ }
63
+ // The watermark, pinned like §75 pinned the provider — in BOTH directions
64
+ // (review, Important): the effective default is config-dependent, so "no
65
+ // flag in the argv" does not replay identically everywhere. An off-record
66
+ // left unpinned would silently GAIN a watermark the moment the replay runs
67
+ // under a config-on (`~/.ossclip/config.json` edited later, or the editor's
68
+ // Render on another machine) — the exact drift §75 exists to prevent, just
69
+ // mirrored. So every record carries the RESOLVED state: `--watermark` when
70
+ // on, `--no-watermark` when off, and a typed flag (already in the argv,
71
+ // caught by the includes-guard) is never doubled. One flag per command.json
72
+ // is the price; determinism is the contract, byte-identity of off-records
73
+ // was only ever a nicety.
74
+ if (pins.watermark !== undefined && !args.includes("--watermark") && !args.includes("--no-watermark")) {
75
+ args.push(pins.watermark ? "--watermark" : "--no-watermark");
76
+ }
77
+ return args;
78
+ }
@@ -0,0 +1,77 @@
1
+ import { basename } from "node:path";
2
+
3
+ /**
4
+ * §131's residue: renaming/adding/removing clips in a folder input re-keys
5
+ * the content hash, so produce derives a FRESH workdir — correct for every
6
+ * cache (a stale transcript against a different edit is the bug §131's
7
+ * manifest hashing exists to prevent), but the OLD workdir's editor edits
8
+ * (`overrides.json`) are user-owned work that silently stops being found.
9
+ * The user sees a clean produce and never learns their edits live one
10
+ * sibling directory over. This module is the pure half of the pointer that
11
+ * closes that gap: given the sibling directory listing, decide WHICH
12
+ * directories to point at. All filesystem reads stay in produce.ts so the
13
+ * cross-folder matching rules below are testable without a disk.
14
+ */
15
+
16
+ /**
17
+ * The `<basename>-<hash8>` naming's basename half, shared with
18
+ * `deriveWorkdir` (produce.ts) so the sibling scan can never disagree with
19
+ * the naming scheme it scans for — a drift between the two would make every
20
+ * pointer silently miss.
21
+ */
22
+ export function workdirBaseName(identity: string): string {
23
+ return basename(identity).replace(/\.[^.]+$/, "");
24
+ }
25
+
26
+ export interface SiblingWorkdirEntry {
27
+ /** Directory entry name inside the workdir root (not a full path). */
28
+ name: string;
29
+ hasOverrides: boolean;
30
+ /** mtime of the entry's overrides.json — 0 when it has none. */
31
+ mtimeMs: number;
32
+ }
33
+
34
+ /** Cap on printed pointers — a folder edited across many re-keys must not flood the run log. */
35
+ export const MAX_STRANDED_POINTERS = 3;
36
+
37
+ /**
38
+ * Which sibling workdirs hold stranded editor edits for this folder, newest
39
+ * edit first. Matching is prefix + exactly-8-lowercase-hex (sha1's own
40
+ * alphabet), NOT a loose startsWith: `MyClips2-bbbbbbbb` must never match
41
+ * base `MyClips` (a different folder entirely), and requiring the hash shape
42
+ * after the `-` is what rules it out — `2-bbbbbbbb` is not 8 hex. The
43
+ * optional `-16x9` tail mirrors deriveWorkdir's landscape suffix; edits
44
+ * stranded in either aspect's workdir are still this folder's edits. Same
45
+ * hash in the other aspect is excluded: that workdir is reachable by
46
+ * re-running with the other --aspect, not stranded by a re-key.
47
+ */
48
+ export function strandedOverrideSiblings(p: {
49
+ base: string;
50
+ /** This run's 8-hex content hash — its own workdirs are not "previous". */
51
+ currentHash: string;
52
+ entries: SiblingWorkdirEntry[];
53
+ }): string[] {
54
+ const prefix = `${p.base}-`;
55
+ return p.entries
56
+ .filter((e) => {
57
+ if (!e.hasOverrides || !e.name.startsWith(prefix)) return false;
58
+ const m = /^([0-9a-f]{8})(-16x9)?$/.exec(e.name.slice(prefix.length));
59
+ return m !== null && m[1] !== p.currentHash;
60
+ })
61
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
62
+ .slice(0, MAX_STRANDED_POINTERS)
63
+ .map((e) => e.name);
64
+ }
65
+
66
+ /**
67
+ * The printed pointer. Says "for this folder" deliberately loosely — two
68
+ * different folders sharing a basename under one --workdir root can collide
69
+ * into this list, and the wording stays honest for that case rather than
70
+ * claiming a provenance the name alone can't prove (§131 brief).
71
+ */
72
+ export function strandedPointerLine(path: string): string {
73
+ return (
74
+ `▸ previous project for this folder: ${path} ` +
75
+ `(has editor edits — they don't carry over; open it with: ossclip edit '${path}')`
76
+ );
77
+ }