ossclip 0.1.9 → 0.1.11
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/package.json +4 -4
- package/src/edit.ts +12 -1
- package/src/interactive/produce-wizard.ts +27 -20
- package/src/produce.ts +14 -7
- package/src/program.ts +92 -26
- package/src/replay-argv.ts +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ossclip",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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/
|
|
40
|
-
"@ossclip/
|
|
41
|
-
"@ossclip/scenes": "0.1.
|
|
39
|
+
"@ossclip/core": "0.1.11",
|
|
40
|
+
"@ossclip/renderer": "0.1.11",
|
|
41
|
+
"@ossclip/scenes": "0.1.11"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
44
44
|
"bugs": {
|
package/src/edit.ts
CHANGED
|
@@ -390,11 +390,22 @@ export async function startEditServer(
|
|
|
390
390
|
);
|
|
391
391
|
if (!parsed.success) return send(500, { error: `command.json is not valid: ${parsed.error.message}` });
|
|
392
392
|
const cmd = parsed.data;
|
|
393
|
+
// §129: heal legacy records at replay. Before the fix, wizard and
|
|
394
|
+
// bare-path runs recorded process.argv — the ORIGINAL invocation,
|
|
395
|
+
// missing the `produce` literal the re-entered parse actually ran —
|
|
396
|
+
// so replaying them verbatim dies at commander's front door with
|
|
397
|
+
// "error: unknown option '--llm'". produce is the ONLY command that
|
|
398
|
+
// ever writes command.json, so an args array not starting with
|
|
399
|
+
// "produce" can only be that bug: prepend the literal to
|
|
400
|
+
// reconstruct the command that ran. A modern record — and a legacy
|
|
401
|
+
// directly-typed `ossclip produce …` — already starts with it and
|
|
402
|
+
// is untouched.
|
|
403
|
+
const args = cmd.args[0] === "produce" ? cmd.args : ["produce", ...cmd.args];
|
|
393
404
|
renderLines = [];
|
|
394
405
|
renderExit = null;
|
|
395
406
|
renderStartedAt = Date.now();
|
|
396
407
|
renderCancelled = false;
|
|
397
|
-
const child = spawn(cmd.execPath, [...cmd.execArgv, cmd.script, ...
|
|
408
|
+
const child = spawn(cmd.execPath, [...cmd.execArgv, cmd.script, ...args], {
|
|
398
409
|
cwd: cmd.cwd,
|
|
399
410
|
stdio: ["ignore", "pipe", "pipe"],
|
|
400
411
|
});
|
|
@@ -50,29 +50,36 @@ export function extrasFor(graphics: boolean): (typeof EXTRAS)[number][] {
|
|
|
50
50
|
return graphics ? [...EXTRAS] : EXTRAS.filter((e) => e.value !== "graphicsClip");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export async function produceWizard(cfg: { speaker?: string } = {}): Promise<string[]> {
|
|
53
|
+
export async function produceWizard(cfg: { speaker?: string; input?: string } = {}): Promise<string[]> {
|
|
54
54
|
assertInteractive("produce wizard");
|
|
55
55
|
intro("ossclip produce");
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
57
|
+
// Pre-supplied by bare `ossclip <path>` (0.1.9 first-contact, 2026-08-05):
|
|
58
|
+
// the user already TYPED the input on the command line, and the old flow
|
|
59
|
+
// dropped it and asked again — the re-ask is where "./Anyhropic c Compiler"
|
|
60
|
+
// became "./" (all of ~/Downloads). The router checks existence before the
|
|
61
|
+
// wizard ever opens, so a prefilled path skips the prompt entirely.
|
|
62
|
+
const input =
|
|
63
|
+
cfg.input ??
|
|
64
|
+
(unwrap(
|
|
65
|
+
await text({
|
|
66
|
+
// Finding 1 (final-review fix wave): `ossclip produce <folder>` shipped
|
|
67
|
+
// (folder-input-brief.md) but this prompt still rejected a directory —
|
|
68
|
+
// the wizard was the only way in that couldn't do what the CLI could.
|
|
69
|
+
// A folder is concatenated by name (codepoint order, like `ls`); --sort
|
|
70
|
+
// mtime reorders it but stays a typed flag, not a wizard question (see
|
|
71
|
+
// the file-level comment above for why).
|
|
72
|
+
message: "Video file, or a folder of clips to concatenate (by name; --sort mtime is a typed flag)",
|
|
73
|
+
placeholder: "./raw/take1.mp4",
|
|
74
|
+
validate: (v) => {
|
|
75
|
+
if (!v) return "a path is required";
|
|
76
|
+
if (!existsSync(v)) return `no such path: ${v}`;
|
|
77
|
+
const st = statSync(v);
|
|
78
|
+
if (!st.isFile() && !st.isDirectory()) return `${v} is neither a video file nor a folder`;
|
|
79
|
+
return undefined;
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
) as string);
|
|
76
83
|
|
|
77
84
|
const aspect = unwrap(
|
|
78
85
|
await select({
|
package/src/produce.ts
CHANGED
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
} from "@ossclip/core";
|
|
99
99
|
import { recordRecentProject } from "./edit";
|
|
100
100
|
import { editHint } from "./interactive/edit-hint";
|
|
101
|
+
import { recordedProduceArgs } from "./replay-argv";
|
|
101
102
|
import { renderCover, renderProduction } from "@ossclip/renderer";
|
|
102
103
|
import {
|
|
103
104
|
coverTextRect,
|
|
@@ -2092,18 +2093,24 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2092
2093
|
// provider (R16 §75). Pin the RESOLVED choice into the recorded args —
|
|
2093
2094
|
// never the key itself; secrets stay out of the workdir — so a replay
|
|
2094
2095
|
// uses the same configuration or fails loudly asking for it.
|
|
2095
|
-
const recordedArgs = process.argv.slice(2);
|
|
2096
|
-
if (provider && !recordedArgs.includes("--llm")) {
|
|
2097
|
-
recordedArgs.push("--llm", providerName);
|
|
2098
|
-
}
|
|
2099
2096
|
// §93g: pin the RESOLVED window, exactly as §75 pinned the provider. The
|
|
2100
2097
|
// editor's Render replays this argv; if replay re-asked the model and got a
|
|
2101
2098
|
// slightly different window, every saved override — anchored to scene ids
|
|
2102
2099
|
// and word indices — would land on the wrong words. The word range, not
|
|
2103
2100
|
// just `--clip 60`, is what makes replay deterministic with zero LLM calls.
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2101
|
+
//
|
|
2102
|
+
// §129: NOT process.argv. A wizard or bare-path run re-enters commander
|
|
2103
|
+
// with a BUILT argv while process.argv still holds the original invocation
|
|
2104
|
+
// (`ossclip <path>`, no `produce` literal, none of the wizard's answers) —
|
|
2105
|
+
// recording process.argv shipped a command that replays as
|
|
2106
|
+
// `ossclip <path> --llm …` and dies on "unknown option '--llm'".
|
|
2107
|
+
// recordedProduceArgs prefers the argv the re-entry stashed and falls back
|
|
2108
|
+
// to process.argv for a directly typed `ossclip produce …`, which stays
|
|
2109
|
+
// byte-identical to what was always recorded.
|
|
2110
|
+
const recordedArgs = recordedProduceArgs({
|
|
2111
|
+
llm: provider ? providerName : undefined,
|
|
2112
|
+
clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
|
|
2113
|
+
});
|
|
2107
2114
|
await writeFile(
|
|
2108
2115
|
join(work, "command.json"),
|
|
2109
2116
|
JSON.stringify(
|
package/src/program.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { Command, InvalidArgumentError } from "commander";
|
|
5
5
|
import { z } from "zod/v4";
|
|
@@ -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.
|
|
@@ -51,31 +52,95 @@ export function buildProgram(): Command {
|
|
|
51
52
|
// Bare `ossclip` at a TTY opens the menu. Piped or in CI it prints help,
|
|
52
53
|
// byte for byte what it printed before — a front door must not become a
|
|
53
54
|
// hang for a script.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
55
|
+
//
|
|
56
|
+
// Bare `ossclip <path>` ROUTES (0.1.9 first-contact, 2026-08-05): with no
|
|
57
|
+
// argument declared here, commander's default allow-excess-args silently
|
|
58
|
+
// DROPPED the positional — `ossclip "./Anyhropic c Compiler"` opened the
|
|
59
|
+
// menu as if nothing had been typed, and the user re-answered the wizard's
|
|
60
|
+
// input prompt by hand, wrongly, with all of ~/Downloads. Commander
|
|
61
|
+
// dispatches registered subcommand names before this action ever runs, so
|
|
62
|
+
// `produce`/`edit`/… always win over path interpretation (a folder that
|
|
63
|
+
// happens to be NAMED `doctor` goes to the subcommand — acceptable);
|
|
64
|
+
// anything that lands here is a path or a typo, and a typo must be a loud
|
|
65
|
+
// error naming what was tried, never the menu.
|
|
66
|
+
program
|
|
67
|
+
.argument(
|
|
68
|
+
"[path]",
|
|
69
|
+
"an existing video file or clips folder — shorthand for `ossclip produce <path>`, " +
|
|
70
|
+
"with the wizard asking the remaining questions",
|
|
71
|
+
)
|
|
72
|
+
// Commander 12 allows excess args by default, which is the SECOND half of
|
|
73
|
+
// the field failure (review, Important): the report's path was typed
|
|
74
|
+
// UNQUOTED — `ossclip ./Anyhropic c Compiler` — and with excess args
|
|
75
|
+
// allowed, `c` and `Compiler` would vanish wordlessly the moment
|
|
76
|
+
// `./Anyhropic` happened to exist, producing the wrong scope. An unquoted
|
|
77
|
+
// multi-word path must be a loud error, never a partial run.
|
|
78
|
+
.allowExcessArguments(false)
|
|
79
|
+
.action(async (path: string | undefined) => {
|
|
80
|
+
const { isInteractive } = await import("./interactive/tty");
|
|
81
|
+
if (path !== undefined) {
|
|
82
|
+
if (!existsSync(path)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`no such file or directory: ${path}\n` +
|
|
85
|
+
" bare `ossclip <path>` produces an existing video file or clips folder;\n" +
|
|
86
|
+
" run `ossclip --help` for the commands.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const { renderCommand } = await import("./interactive/render");
|
|
90
|
+
// Both branches hand argv back through `program.parseAsync` — the same
|
|
91
|
+
// re-entry shape as the wizard paths below, for the same reason: the
|
|
92
|
+
// zod checks in the produce action must run on this input exactly as
|
|
93
|
+
// they do on a typed command line.
|
|
94
|
+
if (!isInteractive()) {
|
|
95
|
+
// No TTY means no wizard for the remaining questions, but the input
|
|
96
|
+
// IS supplied — a piped `ossclip <path>` is `ossclip produce <path>`.
|
|
97
|
+
const direct = ["produce", path];
|
|
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);
|
|
104
|
+
await program.parseAsync(["node", "ossclip", ...direct]);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const { produceWizard } = await import("./interactive/produce-wizard");
|
|
108
|
+
const { loadConfig } = await import("@ossclip/core");
|
|
109
|
+
const argv = await produceWizard({ speaker: loadConfig().speaker, input: path });
|
|
110
|
+
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
111
|
+
setReplayArgv(argv); // §129
|
|
112
|
+
await program.parseAsync(["node", "ossclip", ...argv]);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!isInteractive()) {
|
|
116
|
+
program.outputHelp();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const { chooseFromMenu, menuArgv } = await import("./interactive/menu");
|
|
120
|
+
const choice = await chooseFromMenu();
|
|
121
|
+
const direct = menuArgv(choice);
|
|
122
|
+
const { renderCommand } = await import("./interactive/render");
|
|
123
|
+
if (direct !== null) {
|
|
124
|
+
// Echoed for the same reason the wizard echoes: the README promises
|
|
125
|
+
// "every choice prints the equivalent command before it runs, so the
|
|
126
|
+
// menu is also how you learn the flags", and three of the four entries
|
|
127
|
+
// printed nothing. The menu's whole pedagogical point is this line.
|
|
128
|
+
console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
|
|
129
|
+
// §129: stashed even for non-produce choices — the invariant is that
|
|
130
|
+
// the stash always mirrors the parse being entered, and only
|
|
131
|
+
// produce's recording ever reads it (consume-on-read keeps a
|
|
132
|
+
// non-produce stash from leaking past this parse).
|
|
133
|
+
setReplayArgv(direct);
|
|
134
|
+
await program.parseAsync(["node", "ossclip", ...direct]);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const { produceWizard } = await import("./interactive/produce-wizard");
|
|
138
|
+
const { loadConfig } = await import("@ossclip/core");
|
|
139
|
+
const argv = await produceWizard({ speaker: loadConfig().speaker });
|
|
140
|
+
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
141
|
+
setReplayArgv(argv); // §129
|
|
142
|
+
await program.parseAsync(["node", "ossclip", ...argv]);
|
|
143
|
+
});
|
|
79
144
|
|
|
80
145
|
program
|
|
81
146
|
.command("produce")
|
|
@@ -211,6 +276,7 @@ export function buildProgram(): Command {
|
|
|
211
276
|
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
212
277
|
// Re-entering the SAME parse the flags take: the zod checks below run
|
|
213
278
|
// on wizard output exactly as they do on a typed command line.
|
|
279
|
+
setReplayArgv(argv); // §129
|
|
214
280
|
await program.parseAsync(["node", "ossclip", ...argv]);
|
|
215
281
|
return;
|
|
216
282
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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: { llm?: string; clipWindow?: string }): string[] {
|
|
52
|
+
const args = consumeReplayArgv() ?? process.argv.slice(2);
|
|
53
|
+
if (pins.llm !== undefined && !args.includes("--llm")) {
|
|
54
|
+
args.push("--llm", pins.llm);
|
|
55
|
+
}
|
|
56
|
+
if (pins.clipWindow !== undefined && !args.includes("--clip-window")) {
|
|
57
|
+
args.push("--clip-window", pins.clipWindow);
|
|
58
|
+
}
|
|
59
|
+
return args;
|
|
60
|
+
}
|