ossclip 0.1.23 → 0.1.25

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.
@@ -1,5 +1,6 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
+ import { expandHome } from "../paths";
3
4
  import { livePickerDeps, pickPath, pickerAvailable, type PickMode } from "./picker";
4
5
  import { assertInteractive, select, text, unwrap } from "./prompts";
5
6
  import { rankSuggestions, scanLikelyDirs, type Suggestion } from "./suggest-inputs";
@@ -122,8 +123,8 @@ export const liveAskInputDeps = (): AskInputDeps => ({
122
123
  assertInteractive: () => assertInteractive("input prompt"),
123
124
  });
124
125
 
125
- const typePath = async (deps: AskInputDeps): Promise<string> =>
126
- unwrap(
126
+ const typePath = async (deps: AskInputDeps): Promise<string> => {
127
+ const typed = unwrap(
127
128
  await deps.text({
128
129
  // Finding 1 (final-review fix wave): `ossclip produce <folder>` shipped
129
130
  // (folder-input-brief.md) but this prompt still rejected a directory —
@@ -133,9 +134,16 @@ const typePath = async (deps: AskInputDeps): Promise<string> =>
133
134
  // the file-level comment in produce-wizard.ts for why).
134
135
  message: "Video file, or a folder of clips to concatenate (by name; --sort mtime is a typed flag)",
135
136
  placeholder: "./raw/take1.mp4",
136
- validate: validateInputPath,
137
+ // expandHome BEFORE the exists check (2026-08-16 incident, paths.ts):
138
+ // no shell expands a wizard text input, so a typed `~/Videos/x.mov`
139
+ // must validate as the home path, not fail as `<cwd>/~/...`.
140
+ validate: (v) => validateInputPath(v === undefined ? v : expandHome(v)),
137
141
  }),
138
142
  ) as string;
143
+ // The expanded path is also what the wizard emits into argv — downstream
144
+ // (produce, replay recording) must never see the literal `~`.
145
+ return expandHome(typed);
146
+ };
139
147
 
140
148
  export async function askInput(deps: AskInputDeps = liveAskInputDeps()): Promise<string> {
141
149
  deps.assertInteractive();
@@ -0,0 +1,214 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { expandHome } from "../paths";
4
+ import { select, text, unwrap } from "./prompts";
5
+
6
+ /**
7
+ * "Where should the video go?" — the output-file question, rebuilt as a
8
+ * folder walk (2026-08-16, the `~`-path incident's second defense). The raw
9
+ * text prompt this replaces is where `~/Downloads/x.mp4` got typed and — no
10
+ * shell expands a wizard text input (paths.ts) — resolved against cwd.
11
+ * expandHome now heals the typed form, but a picker removes the typing: the
12
+ * same navigate-a-listing interaction the editor's switch-project browser
13
+ * uses (ProjectPicker.tsx — folders only, hidden omitted, ".." to go up),
14
+ * rendered as a clack select loop.
15
+ *
16
+ * Same doctrine split as ask-input.ts: option shaping and filename
17
+ * normalization are pure and tested without a TTY; the loop takes an
18
+ * injectable prompts/fs seam shaped like thumbnail-approve's ApprovePrompts.
19
+ */
20
+
21
+ export const SAVE_DEFAULT = "__save_default__";
22
+ export const SAVE_HERE = "__save_here__";
23
+ export const SAVE_UP = "__save_up__";
24
+ export const SAVE_TYPE_PATH = "__save_type_path__";
25
+
26
+ /** ~20 folders on screen; past that the escape hatch is typing the path. */
27
+ export const FOLDER_CAP = 20;
28
+
29
+ /** One readdir row, reduced to what the shaping rules need. */
30
+ export interface DirEntry {
31
+ name: string;
32
+ isDir: boolean;
33
+ }
34
+
35
+ /**
36
+ * Directory entries → the select rows. Pure so the rules — folders only,
37
+ * hidden omitted, codepoint-sorted, capped at FOLDER_CAP with the type-a-path
38
+ * row as the overflow escape — are pinned without a filesystem.
39
+ *
40
+ * Folder row values are ABSOLUTE paths, not names: a folder could be named
41
+ * `__save_here__`, and a name-valued row would then collide with the
42
+ * sentinel. An absolute path never starts with `__`, so the dunder idiom
43
+ * (ask-input's BROWSE_FILE et al) stays collision-free.
44
+ */
45
+ export function savePathOptions(
46
+ entries: DirEntry[],
47
+ opts: { dir: string; atRoot: boolean; defaultName?: string },
48
+ ): { value: string; label: string; hint?: string }[] {
49
+ const rows: { value: string; label: string; hint?: string }[] = [];
50
+ // The fast path: today's speed was "press Enter, get no --out". This row is
51
+ // that keystroke — offered only where produce's own default would land
52
+ // (the caller gates it to the input's folder).
53
+ if (opts.defaultName !== undefined) {
54
+ rows.push({
55
+ value: SAVE_DEFAULT,
56
+ label: `use default: ${opts.defaultName}`,
57
+ hint: "in this folder",
58
+ });
59
+ }
60
+ rows.push({ value: SAVE_HERE, label: "✓ save here", hint: "pick the file name next" });
61
+ if (!opts.atRoot) rows.push({ value: SAVE_UP, label: "↑ ..", hint: "up one folder" });
62
+
63
+ const folders = entries
64
+ .filter((e) => e.isDir && !e.name.startsWith("."))
65
+ .map((e) => e.name)
66
+ .sort();
67
+ for (const name of folders.slice(0, FOLDER_CAP)) {
68
+ rows.push({ value: join(opts.dir, name), label: `${name}/` });
69
+ }
70
+ const hidden = folders.length - FOLDER_CAP;
71
+ rows.push({
72
+ value: SAVE_TYPE_PATH,
73
+ label: "type a path instead",
74
+ hint: hidden > 0 ? `${hidden} more folders not shown` : "if you already know it",
75
+ });
76
+ return rows;
77
+ }
78
+
79
+ /**
80
+ * The typed file name, parsed not coerced: empty refused, a path separator
81
+ * refused (this prompt names a file IN the folder the walk just chose — a
82
+ * `sub/name` here would silently override that choice), and `.mp4` appended
83
+ * only when there is NO dot-extension at all. A typo'd `.mp5` stays and is
84
+ * the user's — "fixing" it is the exact coercion --source-fit's zod parse
85
+ * exists to prevent.
86
+ */
87
+ export function normalizeOutName(
88
+ raw: string | undefined,
89
+ ): { ok: true; name: string } | { ok: false; problem: string } {
90
+ const name = (raw ?? "").trim();
91
+ if (name === "") return { ok: false, problem: "a file name is required" };
92
+ if (name.includes("/") || name.includes("\\")) {
93
+ return { ok: false, problem: "just the file name — the folder was picked above" };
94
+ }
95
+ return { ok: true, name: /\.[^.]+$/.test(name) ? name : `${name}.mp4` };
96
+ }
97
+
98
+ /**
99
+ * The seam, ApprovePrompts' shape (thumbnail-approve.ts): already unwrapped,
100
+ * never a cancel symbol, so the loop is drivable by a scripted object.
101
+ * `validate` rides through because the live re-ask-on-bad-input behavior is
102
+ * clack's; a scripted fake asserts its answers pass the same predicate.
103
+ */
104
+ export interface SavePathPrompts {
105
+ select(opts: {
106
+ message: string;
107
+ options: { value: string; label: string; hint?: string }[];
108
+ }): Promise<string>;
109
+ text(opts: {
110
+ message: string;
111
+ initialValue?: string;
112
+ placeholder?: string;
113
+ validate?: (v: string | undefined) => string | undefined;
114
+ }): Promise<string>;
115
+ }
116
+
117
+ /** The live clack-backed prompts; tests inject a scripted replacement. */
118
+ export function clackSavePathPrompts(): SavePathPrompts {
119
+ return {
120
+ select: async (opts) => unwrap(await select(opts)) as string,
121
+ text: async (opts) => unwrap(await text({ ...opts, defaultValue: "" })) as string,
122
+ };
123
+ }
124
+
125
+ /** The fs seam beside the prompts seam, so the walk is testable dry. */
126
+ const liveListEntries = (dir: string): DirEntry[] => {
127
+ try {
128
+ return readdirSync(dir, { withFileTypes: true }).map((d) => ({
129
+ name: d.name,
130
+ isDir: d.isDirectory(),
131
+ }));
132
+ } catch {
133
+ // An unreadable folder (permissions, vanished between screens) lists as
134
+ // empty rather than crashing the wizard — "↑ .." and "type a path
135
+ // instead" remain on offer, which is every exit the user needs.
136
+ return [];
137
+ }
138
+ };
139
+
140
+ export interface PickSavePathArgs {
141
+ /**
142
+ * The input video's own folder: it is where creators keep a take's outputs,
143
+ * and it is where produce's flag-less default lands anyway — so the walk
144
+ * starts where the answer almost always is, and ~/Downloads is a few ".."
145
+ * and descents away when it isn't.
146
+ */
147
+ startDir: string;
148
+ /** `basename(defaultOutPath(input))` — the fast-path row's file name AND
149
+ * the file-name prompt's prefill, so plain Enter twice matches a flag-less
150
+ * run exactly. */
151
+ defaultName: string;
152
+ prompts?: SavePathPrompts;
153
+ listEntries?: (dir: string) => DirEntry[];
154
+ }
155
+
156
+ /**
157
+ * The walk. Returns the ABSOLUTE out path — or undefined for the use-default
158
+ * row, so the caller emits no --out at all: the picked file would equal
159
+ * produce's own default, and produceArgv's elision rule (a flag whose value
160
+ * equals the default is NEVER emitted) must survive the picker.
161
+ */
162
+ export async function pickSavePath(args: PickSavePathArgs): Promise<string | undefined> {
163
+ const { prompts = clackSavePathPrompts(), listEntries = liveListEntries } = args;
164
+ const startDir = resolve(expandHome(args.startDir));
165
+ let dir = startDir;
166
+ for (;;) {
167
+ const options = savePathOptions(listEntries(dir), {
168
+ dir,
169
+ // `dirname` at the root returns the root itself, on every platform —
170
+ // the same fixed point the editor's browser keys its ".." row off.
171
+ atRoot: dirname(dir) === dir,
172
+ // The default row exists wherever the default would actually land —
173
+ // navigate away and it goes, come back and it returns.
174
+ defaultName: dir === startDir ? args.defaultName : undefined,
175
+ });
176
+ const choice = await prompts.select({ message: `Save the video where? — ${dir}`, options });
177
+
178
+ if (choice === SAVE_DEFAULT) return undefined;
179
+ if (choice === SAVE_UP) {
180
+ dir = dirname(dir);
181
+ continue;
182
+ }
183
+ if (choice === SAVE_TYPE_PATH) {
184
+ const typed = await prompts.text({
185
+ message: "Output file path",
186
+ placeholder: "~/Downloads/final.mp4",
187
+ validate: (v) => (v?.trim() ? undefined : "a path is required"),
188
+ });
189
+ // expandHome BEFORE resolve (2026-08-16 incident, paths.ts): a typed
190
+ // `~/Downloads/x.mp4` must become the home path, never `<cwd>/~/...`.
191
+ // resolve() then makes the emitted argv absolute, matching ask-input's
192
+ // expanded-argv behavior.
193
+ return resolve(expandHome(typed.trim()));
194
+ }
195
+ if (choice === SAVE_HERE) {
196
+ const name = await prompts.text({
197
+ message: "File name",
198
+ initialValue: args.defaultName,
199
+ validate: (v) => {
200
+ const r = normalizeOutName(v);
201
+ return r.ok ? undefined : r.problem;
202
+ },
203
+ });
204
+ const r = normalizeOutName(name);
205
+ // The live prompt re-asks through `validate`, so not-ok here means a
206
+ // scripted fake skipped its own predicate — re-asking is still what a
207
+ // real user would see, so loop rather than crash.
208
+ if (!r.ok) continue;
209
+ return join(dir, r.name);
210
+ }
211
+ // A folder row: its value is already the absolute path — descend.
212
+ dir = choice;
213
+ }
214
+ }
@@ -14,8 +14,11 @@ export interface ProduceExtras {
14
14
  speaker?: string;
15
15
  whisperModel?: string;
16
16
  whisperLanguage?: string;
17
+ // No `collapseRetakes` (2026-08-16): retake collapse runs automatically
18
+ // with --blooper-marker (inferredRetakesEnabled, produce.ts), so the
19
+ // wizard never emits the legacy --collapse-retakes flag — it survives only
20
+ // as a parseable no-op for recorded command.json replays.
17
21
  blooperMarker?: string;
18
- collapseRetakes?: boolean;
19
22
  sourceIsEdited?: boolean;
20
23
  /** Opt-in "made with ossclip" credit. The wizard only ever turns it ON —
21
24
  * off is the default, and a config-on user who wants it off for one run
@@ -26,6 +29,27 @@ export interface ProduceExtras {
26
29
  * the positive `--captions` stays flags-only — it exists for replay
27
30
  * pinning, and emitting it here would restate the default. */
28
31
  captions?: boolean;
32
+ /** Jump-cut punch tri-state, captions' twin polarity: auto (unset) already
33
+ * punches face-only takes, so the wizard only ever turns it OFF (`false` →
34
+ * `--no-jump-cuts`) and the positive `--add-jump-cuts` stays flags-only —
35
+ * it exists to beat a future config-off, and emitting it here would
36
+ * restate the default. */
37
+ jumpCuts?: boolean;
38
+ /** The YouTube pack (Y2): the wizard only ever turns it ON — off is the
39
+ * default, and a config-on user who wants it off for one run types
40
+ * `--no-youtube`, flags-only like `--no-watermark`. */
41
+ youtube?: boolean;
42
+ /** Portrait photo path for the pack's AI thumbnail — the youtube entry's
43
+ * follow-up prompt; empty answers never reach here (the wizard skips the
44
+ * flag and the frame-grab cover stands). */
45
+ portrait?: string;
46
+ /** Who the channel is for — the youtube entry's first follow-up, skipped
47
+ * when the config's `audience` already answers it. Empty answers never
48
+ * reach here (default-elision: the config decides). */
49
+ audience?: string;
50
+ /** The durable thumbnail steer — the youtube entry's optional follow-up;
51
+ * empty answers never reach here, same elision as `audience`. */
52
+ thumbnailBrief?: string;
29
53
  /** core's ProviderName, not an inline union — a provider added there must
30
54
  * not be silently unofferable here (the pre-§132 union had already
31
55
  * drifted: it never listed "antigravity"). */
@@ -69,12 +93,25 @@ export function produceArgv(a: ProduceAnswers): string[] {
69
93
  // no flag whose value equals the default.
70
94
  if (e.whisperLanguage) argv.push("--whisper-language", e.whisperLanguage);
71
95
  if (e.blooperMarker) argv.push("--blooper-marker", e.blooperMarker);
72
- if (e.collapseRetakes === true) argv.push("--collapse-retakes");
73
96
  if (e.sourceIsEdited === true) argv.push("--source-is-edited");
74
97
  if (e.watermark === true) argv.push("--watermark");
75
98
  // Strict `=== false` (never `!e.captions`): undefined means "the default,
76
99
  // on" and must emit nothing per the elision rule above.
77
100
  if (e.captions === false) argv.push("--no-captions");
101
+ // Same strict rule: only the wizard's OFF tick emits, and only the
102
+ // negative spelling — auto must stay an ABSENT flag, or the taught
103
+ // command line restates a default.
104
+ if (e.jumpCuts === false) argv.push("--no-jump-cuts");
105
+ // Watermark's shape: only the ON tick emits (off is the default, elided),
106
+ // and the portrait only rides along with a value — the wizard already
107
+ // dropped empty answers.
108
+ if (e.youtube === true) argv.push("--youtube");
109
+ // The youtube follow-ups ride only with a value, portrait's exact rule:
110
+ // the wizard already dropped empty answers, and an unset field means "the
111
+ // config decides" — emitting a bare flag would be a commander error.
112
+ if (e.audience) argv.push("--audience", e.audience);
113
+ if (e.portrait) argv.push("--portrait", e.portrait);
114
+ if (e.thumbnailBrief) argv.push("--thumbnail-brief", e.thumbnailBrief);
78
115
  if (e.llm) argv.push("--llm", e.llm);
79
116
 
80
117
  return argv;