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/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/edit.ts +12 -1
- package/src/interactive/produce-argv.ts +9 -0
- package/src/interactive/produce-wizard.ts +133 -17
- package/src/produce.ts +184 -16
- package/src/program.ts +71 -3
- package/src/replay-argv.ts +78 -0
- package/src/stranded-overrides.ts +77 -0
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
|
|
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
|
|
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
|
|
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
|
+
}
|