ossclip 0.1.7 → 0.1.10

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
@@ -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";
@@ -51,31 +51,83 @@ export function buildProgram(): Command {
51
51
  // Bare `ossclip` at a TTY opens the menu. Piped or in CI it prints help,
52
52
  // byte for byte what it printed before — a front door must not become a
53
53
  // hang for a script.
54
- program.action(async () => {
55
- const { isInteractive } = await import("./interactive/tty");
56
- if (!isInteractive()) {
57
- program.outputHelp();
58
- return;
59
- }
60
- const { chooseFromMenu, menuArgv } = await import("./interactive/menu");
61
- const choice = await chooseFromMenu();
62
- const direct = menuArgv(choice);
63
- const { renderCommand } = await import("./interactive/render");
64
- if (direct !== null) {
65
- // Echoed for the same reason the wizard echoes: the README promises
66
- // "every choice prints the equivalent command before it runs, so the
67
- // menu is also how you learn the flags", and three of the four entries
68
- // printed nothing. The menu's whole pedagogical point is this line.
69
- console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
70
- await program.parseAsync(["node", "ossclip", ...direct]);
71
- return;
72
- }
73
- const { produceWizard } = await import("./interactive/produce-wizard");
74
- const { loadConfig } = await import("@ossclip/core");
75
- const argv = await produceWizard({ speaker: loadConfig().speaker });
76
- console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
77
- await program.parseAsync(["node", "ossclip", ...argv]);
78
- });
54
+ //
55
+ // Bare `ossclip <path>` ROUTES (0.1.9 first-contact, 2026-08-05): with no
56
+ // argument declared here, commander's default allow-excess-args silently
57
+ // DROPPED the positional — `ossclip "./Anyhropic c Compiler"` opened the
58
+ // menu as if nothing had been typed, and the user re-answered the wizard's
59
+ // input prompt by hand, wrongly, with all of ~/Downloads. Commander
60
+ // dispatches registered subcommand names before this action ever runs, so
61
+ // `produce`/`edit`/… always win over path interpretation (a folder that
62
+ // happens to be NAMED `doctor` goes to the subcommand — acceptable);
63
+ // anything that lands here is a path or a typo, and a typo must be a loud
64
+ // error naming what was tried, never the menu.
65
+ program
66
+ .argument(
67
+ "[path]",
68
+ "an existing video file or clips folder — shorthand for `ossclip produce <path>`, " +
69
+ "with the wizard asking the remaining questions",
70
+ )
71
+ // Commander 12 allows excess args by default, which is the SECOND half of
72
+ // the field failure (review, Important): the report's path was typed
73
+ // UNQUOTED — `ossclip ./Anyhropic c Compiler` — and with excess args
74
+ // allowed, `c` and `Compiler` would vanish wordlessly the moment
75
+ // `./Anyhropic` happened to exist, producing the wrong scope. An unquoted
76
+ // multi-word path must be a loud error, never a partial run.
77
+ .allowExcessArguments(false)
78
+ .action(async (path: string | undefined) => {
79
+ const { isInteractive } = await import("./interactive/tty");
80
+ if (path !== undefined) {
81
+ if (!existsSync(path)) {
82
+ throw new Error(
83
+ `no such file or directory: ${path}\n` +
84
+ " bare `ossclip <path>` produces an existing video file or clips folder;\n" +
85
+ " run `ossclip --help` for the commands.",
86
+ );
87
+ }
88
+ const { renderCommand } = await import("./interactive/render");
89
+ // Both branches hand argv back through `program.parseAsync` — the same
90
+ // re-entry shape as the wizard paths below, for the same reason: the
91
+ // zod checks in the produce action must run on this input exactly as
92
+ // they do on a typed command line.
93
+ if (!isInteractive()) {
94
+ // No TTY means no wizard for the remaining questions, but the input
95
+ // IS supplied — a piped `ossclip <path>` is `ossclip produce <path>`.
96
+ const direct = ["produce", path];
97
+ console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
98
+ await program.parseAsync(["node", "ossclip", ...direct]);
99
+ return;
100
+ }
101
+ const { produceWizard } = await import("./interactive/produce-wizard");
102
+ const { loadConfig } = await import("@ossclip/core");
103
+ const argv = await produceWizard({ speaker: loadConfig().speaker, input: path });
104
+ console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
105
+ await program.parseAsync(["node", "ossclip", ...argv]);
106
+ return;
107
+ }
108
+ if (!isInteractive()) {
109
+ program.outputHelp();
110
+ return;
111
+ }
112
+ const { chooseFromMenu, menuArgv } = await import("./interactive/menu");
113
+ const choice = await chooseFromMenu();
114
+ const direct = menuArgv(choice);
115
+ const { renderCommand } = await import("./interactive/render");
116
+ if (direct !== null) {
117
+ // Echoed for the same reason the wizard echoes: the README promises
118
+ // "every choice prints the equivalent command before it runs, so the
119
+ // menu is also how you learn the flags", and three of the four entries
120
+ // printed nothing. The menu's whole pedagogical point is this line.
121
+ console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
122
+ await program.parseAsync(["node", "ossclip", ...direct]);
123
+ return;
124
+ }
125
+ const { produceWizard } = await import("./interactive/produce-wizard");
126
+ const { loadConfig } = await import("@ossclip/core");
127
+ const argv = await produceWizard({ speaker: loadConfig().speaker });
128
+ console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
129
+ await program.parseAsync(["node", "ossclip", ...argv]);
130
+ });
79
131
 
80
132
  program
81
133
  .command("produce")
@@ -83,7 +135,10 @@ export function buildProgram(): Command {
83
135
  // OPTIONAL so a bare `ossclip produce` at a TTY opens the wizard instead of
84
136
  // printing a usage error at somebody who does not yet know the flags. A
85
137
  // non-interactive run still gets commander's "missing required argument".
86
- .argument("[input]", "input video file")
138
+ .argument(
139
+ "[input]",
140
+ "input video file, or a folder of clips to concatenate (by name; see --sort)",
141
+ )
87
142
  .option("-o, --out <path>", "output video path (default: <input>.ossclip.mp4)")
88
143
  .option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
89
144
  .option("--transcript <path>", "inject a transcript JSON instead of running whisper")
@@ -95,6 +150,12 @@ export function buildProgram(): Command {
95
150
  )
96
151
  .option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
97
152
  .option("--workdir <dir>", "cache/work directory (default: <input dir>/.ossclip)")
153
+ .option(
154
+ "--sort <order>",
155
+ "when <input> is a folder: order its clips before concatenating them — " +
156
+ "name (default, plain codepoint sort, matches `ls`) or mtime (oldest first)",
157
+ "name",
158
+ )
98
159
  .option(
99
160
  "--aspect <ratio>",
100
161
  "output shape: 9:16 (vertical, default) or 16:9 (landscape, 1920x1080)",
@@ -161,6 +222,12 @@ export function buildProgram(): Command {
161
222
  "cut the flubbed take whenever you say this word out loud (e.g. blooper): " +
162
223
  "removal runs back to the start of the sentence it spoiled. Off unless given",
163
224
  )
225
+ .option(
226
+ "--collapse-retakes",
227
+ "deterministically collapse consecutive near-identical sentences, keeping only " +
228
+ "the last complete attempt — the flub the speaker did NOT mark. Off by default",
229
+ false,
230
+ )
164
231
  .option("--no-cover", "skip the cover image written beside the video")
165
232
  .option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
166
233
  .option("--open-editor", "open the editor when the run finishes")
@@ -212,6 +279,13 @@ export function buildProgram(): Command {
212
279
  // Parsed, not coerced: a typo'd `--source-fit containn` silently falling
213
280
  // back to cover is exactly the crop the flag exists to prevent.
214
281
  const sourceFit = z.enum(["cover", "contain"]).parse(opts.sourceFit);
282
+ // Same reasoning as --source-fit: a typo'd --sort dat must not silently
283
+ // become the default rather than an error naming the mistake.
284
+ const sort = z.enum(["name", "mtime"]).parse(opts.sort);
285
+ // Final-review fix wave, cheap minor c: distinguishes "the user typed
286
+ // --sort" from "commander's own default filled it in" so produce() can
287
+ // say something when --sort is given for a file, where it does nothing.
288
+ const sortExplicit = command.getOptionValueSource("sort") === "cli";
215
289
  const result = await produce(input, {
216
290
  out: opts.out,
217
291
  cleanup,
@@ -219,6 +293,8 @@ export function buildProgram(): Command {
219
293
  render: opts.render,
220
294
  mezzanine: opts.mezzanine,
221
295
  workdir: opts.workdir,
296
+ sort,
297
+ sortExplicit,
222
298
  aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
223
299
  noiseDb: opts.noiseDb,
224
300
  produce: opts.produce,
@@ -235,6 +311,7 @@ export function buildProgram(): Command {
235
311
  // string on the same key.
236
312
  sourceIsEdited: opts.sourceIsEdited === true,
237
313
  blooperMarker: opts.blooperMarker,
314
+ collapseRetakes: opts.collapseRetakes,
238
315
  sourceFit,
239
316
  cover: opts.cover !== false,
240
317
  coverPath: typeof opts.cover === "string" ? opts.cover : undefined,