ossclip 0.1.16 → 0.1.18

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/README.md CHANGED
@@ -31,6 +31,8 @@ ossclip produce podcast.mp4 --produce --clip 60 -o clip.mp4
31
31
  ossclip edit "<work directory>"
32
32
  ```
33
33
 
34
+ **Not sure what to run?** `ossclip` with no arguments opens a menu, and every choice prints the equivalent command before it runs. Choose **produce a video** and the wizard's first question offers the newest videos in your working directory, Downloads and Movies (Videos on Linux and Windows); a **Browse…** row that opens your operating system's own file picker; and typing a path. Over SSH on macOS or Windows, or on a Linux box with no display or no `zenity`/`kdialog`, the Browse rows are simply not shown — there is no window to open. `OSSCLIP_NO_PICKER` set to a non-empty value hides them too, leaving suggestions and typing — truthiness, not presence, so an empty `OSSCLIP_NO_PICKER=` leaves the picker on.
35
+
34
36
  **Scope, honestly:** ossclip is at its best polishing a take you have already cut down. `--clip` selects a single strongest window from long-form input — one clip, not N.
35
37
 
36
38
  AI can make mistakes: the cut, the captions and every graphic are generated — review the output before publishing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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/core": "0.1.16",
40
- "@ossclip/renderer": "0.1.16",
41
- "@ossclip/scenes": "0.1.16"
39
+ "@ossclip/core": "0.1.18",
40
+ "@ossclip/renderer": "0.1.18",
41
+ "@ossclip/scenes": "0.1.18"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
@@ -0,0 +1,191 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { livePickerDeps, pickPath, pickerAvailable, type PickMode } from "./picker";
4
+ import { assertInteractive, select, text, unwrap } from "./prompts";
5
+ import { rankSuggestions, scanLikelyDirs, type Suggestion } from "./suggest-inputs";
6
+
7
+ /**
8
+ * "Which video?" — the first prompt of the wizard, and until §136 the one
9
+ * step a non-technical user could not get past: it demanded a typed path.
10
+ * Now it is a menu of the newest videos on the machine, a native picker, and
11
+ * typing, in that order.
12
+ *
13
+ * Every branch converges on `validateInputPath` rather than trusting its
14
+ * source. A picker result is not more trustworthy than typed text: the file
15
+ * can be deleted between the dialog closing and the path arriving, and
16
+ * "Browse for a folder" happily returns a folder with no video in it.
17
+ */
18
+
19
+ export const BROWSE_FILE = "__browse_file__";
20
+ export const BROWSE_FOLDER = "__browse_folder__";
21
+ export const TYPE_PATH = "__type_path__";
22
+
23
+ export type InputSource = "suggestion" | "picker" | "typed";
24
+
25
+ /**
26
+ * Not a zod schema, deliberately: the house rule parses user values with zod
27
+ * because a typo must not silently coerce, and there is nothing to coerce
28
+ * here — this is a filesystem predicate (does it exist, is it a file or a
29
+ * folder), which zod cannot answer. The wording is carried over verbatim
30
+ * from the prompt this replaced.
31
+ *
32
+ * There is deliberately no extension whitelist, on any branch: typing a path
33
+ * was never extension-checked either, and ossclip accepts whatever ffmpeg can
34
+ * read — a list would reject legitimate containers.
35
+ */
36
+ export function validateInputPath(v: string | undefined): string | undefined {
37
+ if (!v) return "a path is required";
38
+ if (!existsSync(v)) return `no such path: ${v}`;
39
+ const st = statSync(v);
40
+ if (!st.isFile() && !st.isDirectory()) return `${v} is neither a video file nor a folder`;
41
+ return undefined;
42
+ }
43
+
44
+ export function inputChoices(
45
+ suggestions: Suggestion[],
46
+ canBrowse: boolean,
47
+ ): { value: string; label: string; hint?: string }[] {
48
+ const rows = suggestions.map((s) => ({ value: s.path, label: s.label, hint: s.hint }));
49
+ // Offering a "Browse…" row on a machine with no dialog to open is worse
50
+ // than not offering it — `pickerAvailable` has already probed, so the row
51
+ // simply does not exist when it cannot work.
52
+ if (canBrowse) {
53
+ rows.push(
54
+ { value: BROWSE_FILE, label: "Browse…", hint: "opens a file picker" },
55
+ { value: BROWSE_FOLDER, label: "Browse for a folder of clips", hint: "concatenated by name" },
56
+ );
57
+ }
58
+ rows.push({ value: TYPE_PATH, label: "Type a path", hint: "if you already know it" });
59
+ return rows;
60
+ }
61
+
62
+ let lastInputSource: InputSource | "argv" = "argv";
63
+
64
+ /** Which branch produced the input, for the produce_completed event (§136). */
65
+ export function noteInputSource(s: InputSource): void {
66
+ lastInputSource = s;
67
+ }
68
+
69
+ export function inputSourceUsed(): InputSource | "argv" {
70
+ return lastInputSource;
71
+ }
72
+
73
+ /**
74
+ * Module state outlives a single wizard, and "argv" is a real answer — the
75
+ * value the telemetry reads when `ossclip <path>` prefilled the input and
76
+ * `askInput` never ran. So a second run in the same process (a batch or REPL
77
+ * caller, and every test file, where module state persists across `it`s)
78
+ * would report the PREVIOUS run's branch with nothing to signal the staleness.
79
+ * Reset at the start of a run — or of a test — rather than trusting one
80
+ * invocation per process.
81
+ */
82
+ export function resetInputSource(): void {
83
+ lastInputSource = "argv";
84
+ }
85
+
86
+ /**
87
+ * The seam. Same shape and same reason as `PickerDeps` (picker.ts) and
88
+ * `TtyDeps` (tty.ts): the branch logic is what has rules worth pinning — a
89
+ * cancelled dialog re-asks, every branch validates, each sets its own source —
90
+ * and none of that is reachable through a real `select` blocking on a human.
91
+ * `pickPath` is tested the same way through its own injected deps.
92
+ *
93
+ * Structural signatures rather than `typeof select`: clack's are generic, and
94
+ * a fake cannot satisfy an unresolved type parameter. Narrowing here is what
95
+ * makes the fakes writable, exactly as `PickerDeps` declares `hasBin` rather
96
+ * than `typeof binOnPath`.
97
+ */
98
+ export interface AskInputDeps {
99
+ /** Probed once per run, not per loop — no dialog appears mid-prompt. */
100
+ canBrowse: boolean;
101
+ suggest: () => Promise<Suggestion[]>;
102
+ pick: (mode: PickMode) => Promise<string | undefined>;
103
+ select: (opts: {
104
+ message: string;
105
+ options: { value: string; label: string; hint?: string }[];
106
+ }) => Promise<string | symbol>;
107
+ text: (opts: {
108
+ message: string;
109
+ placeholder?: string;
110
+ validate?: (v: string | undefined) => string | undefined;
111
+ }) => Promise<string | symbol>;
112
+ /** Injected because the guard itself takes an injected check (tty.ts:58). */
113
+ assertInteractive: () => void;
114
+ }
115
+
116
+ export const liveAskInputDeps = (): AskInputDeps => ({
117
+ canBrowse: pickerAvailable(livePickerDeps()),
118
+ suggest: async () => rankSuggestions(await scanLikelyDirs(), Date.now(), homedir()),
119
+ pick: (mode) => pickPath(mode),
120
+ select,
121
+ text,
122
+ assertInteractive: () => assertInteractive("input prompt"),
123
+ });
124
+
125
+ const typePath = async (deps: AskInputDeps): Promise<string> =>
126
+ unwrap(
127
+ await deps.text({
128
+ // Finding 1 (final-review fix wave): `ossclip produce <folder>` shipped
129
+ // (folder-input-brief.md) but this prompt still rejected a directory —
130
+ // the wizard was the only way in that couldn't do what the CLI could.
131
+ // A folder is concatenated by name (codepoint order, like `ls`); --sort
132
+ // mtime reorders it but stays a typed flag, not a wizard question (see
133
+ // the file-level comment in produce-wizard.ts for why).
134
+ message: "Video file, or a folder of clips to concatenate (by name; --sort mtime is a typed flag)",
135
+ placeholder: "./raw/take1.mp4",
136
+ validate: validateInputPath,
137
+ }),
138
+ ) as string;
139
+
140
+ export async function askInput(deps: AskInputDeps = liveAskInputDeps()): Promise<string> {
141
+ deps.assertInteractive();
142
+ const canBrowse = deps.canBrowse;
143
+ const suggestions = await deps.suggest();
144
+
145
+ // Nothing found and nowhere to browse: a one-row menu is pure noise, so go
146
+ // straight to the prompt this whole unit replaced.
147
+ if (suggestions.length === 0 && !canBrowse) {
148
+ noteInputSource("typed");
149
+ return typePath(deps);
150
+ }
151
+
152
+ // Loops rather than returns, because cancelling the OS dialog must land
153
+ // back on the menu — Escape in a Finder window means "not that one", not
154
+ // "abandon the run". Ctrl-C at the menu still exits via `unwrap`.
155
+ for (;;) {
156
+ const choice = unwrap(
157
+ await deps.select({ message: "Which video?", options: inputChoices(suggestions, canBrowse) }),
158
+ ) as string;
159
+
160
+ if (choice === TYPE_PATH) {
161
+ noteInputSource("typed");
162
+ return typePath(deps);
163
+ }
164
+
165
+ if (choice === BROWSE_FILE || choice === BROWSE_FOLDER) {
166
+ const picked = await deps.pick(choice === BROWSE_FOLDER ? "folder" : "file");
167
+ // An empty result conflates "cancelled" with "the backend failed
168
+ // silently" — no backend distinguishes them reliably (picker.ts), and
169
+ // `pickPath` has already printed the fallback notice in the failure
170
+ // case. Re-asking is the right next step either way.
171
+ if (picked === undefined) continue;
172
+ const problem = validateInputPath(picked);
173
+ if (problem !== undefined) {
174
+ console.log(`▸ ${problem}`);
175
+ continue;
176
+ }
177
+ noteInputSource("picker");
178
+ return picked;
179
+ }
180
+
181
+ // A suggestion. Still validated: the listing is a snapshot, and a file
182
+ // can be moved between the scan and the keypress.
183
+ const problem = validateInputPath(choice);
184
+ if (problem !== undefined) {
185
+ console.log(`▸ ${problem}`);
186
+ continue;
187
+ }
188
+ noteInputSource("suggestion");
189
+ return choice;
190
+ }
191
+ }
@@ -0,0 +1,286 @@
1
+ import { VIDEO_EXTENSIONS, run } from "@ossclip/core";
2
+ import { binOnPath } from "../llm-detect";
3
+
4
+ /**
5
+ * The native file/folder dialog (§136). Typing a path was the one step a
6
+ * non-technical user could not get past — the blocker was never the flags,
7
+ * it was the very first prompt.
8
+ *
9
+ * Split the way `open.ts` is split, and for the same reason: the platform
10
+ * matrix is decided by pure functions with a table test, so a Linux or
11
+ * Windows user is not the one who discovers the command was wrong. The
12
+ * `ossclip edit` crash that shipped in 0.1.4 for every non-macOS user is what
13
+ * this shape exists to prevent.
14
+ */
15
+
16
+ export type PickMode = "file" | "folder";
17
+
18
+ export interface PickerDeps {
19
+ platform: NodeJS.Platform;
20
+ env: NodeJS.ProcessEnv;
21
+ hasBin: (bin: string) => boolean;
22
+ }
23
+
24
+ export const livePickerDeps = (): PickerDeps => ({
25
+ platform: process.platform,
26
+ env: process.env,
27
+ hasBin: (bin) => binOnPath(bin),
28
+ });
29
+
30
+ /** `*.mov *.mp4 …` — the shape zenity and kdialog both want, space-joined. */
31
+ const globs = (): string => VIDEO_EXTENSIONS.map((e) => `*.${e}`).join(" ");
32
+
33
+ /**
34
+ * A PowerShell single-quoted string has exactly one escape: a literal `'` is
35
+ * written `''`. Nothing else is special in there — not `$`, not a backtick,
36
+ * not the `\` that fills every Windows path — which is why single quotes are
37
+ * what the script uses and why this is the whole escape rather than a table.
38
+ * `C:\Users\Ahsan's videos` is the case that makes it necessary.
39
+ */
40
+ const psQuote = (s: string): string => s.replace(/'/g, "''");
41
+
42
+ /**
43
+ * Is there a dialog to open at all? Probed rather than attempted, because
44
+ * offering a "Browse…" row that cannot work is worse than not offering it —
45
+ * the menu drops the row entirely when this is false.
46
+ */
47
+ export function pickerAvailable(d: PickerDeps): boolean {
48
+ // Truthiness not presence, matching `isInteractive`'s treatment of CI.
49
+ if (d.env.OSSCLIP_NO_PICKER) return false;
50
+ // "Am I over SSH" is a PROXY for a signal these two platforms don't have
51
+ // (§136): neither macOS nor Windows exposes anything that says whether a
52
+ // reachable window server exists, so the remote-session hint is the best
53
+ // available stand-in. It has to be applied here, not just on darwin: macOS
54
+ // fails loudly — `choose file` returns -1743 (not authorized) — but Windows
55
+ // fails the dangerous way. It ships an in-box OpenSSH server, and
56
+ // ShowDialog() on a non-interactive window station blocks forever on a
57
+ // window nobody can see. `run()` has no timeout, so that is a CLI hung
58
+ // until Ctrl-C, the exact failure the -STA comment below exists to prevent.
59
+ //
60
+ // Note this deliberately does NOT cover Linux, which is handled below.
61
+ // osascript and WinForms are always installed, so there is nothing else to
62
+ // detect on either.
63
+ if (d.platform === "darwin" || d.platform === "win32") {
64
+ return !d.env.SSH_CONNECTION && !d.env.SSH_TTY;
65
+ }
66
+ // Linux is exempt from the SSH proxy above because it HAS the real signal.
67
+ // `ssh -X` sets DISPLAY=localhost:10.0 and zenity genuinely draws on the
68
+ // caller's local screen through the tunnel — a configuration that works.
69
+ // Layering the proxy on top of the evidence would override that evidence
70
+ // with a guess and break it.
71
+ //
72
+ // Both halves are required, and the display check is also what covers WSL
73
+ // without WSLg: zenity may well be installed there and still draw nowhere.
74
+ if (!d.env.DISPLAY && !d.env.WAYLAND_DISPLAY) return false;
75
+ return d.hasBin("zenity") || d.hasBin("kdialog");
76
+ }
77
+
78
+ export function pickerCommand(
79
+ d: PickerDeps,
80
+ mode: PickMode,
81
+ startDir?: string,
82
+ ): { bin: string; args: string[] } {
83
+ if (d.platform === "darwin") {
84
+ // Bare extensions, verified on macOS 26.3 — matching files stay
85
+ // selectable and the rest are dimmed. The UTI form {"public.movie"} was
86
+ // the alternative and is NOT used: its mkv coverage is unconfirmed, and
87
+ // mkv is in VIDEO_EXTENSIONS.
88
+ const types = VIDEO_EXTENSIONS.map((e) => `"${e}"`).join(",");
89
+ const clause =
90
+ mode === "folder"
91
+ ? 'choose folder with prompt "Pick a folder of clips"'
92
+ : `choose file with prompt "Pick your video" of type {${types}}`;
93
+ // startDir is always process.cwd(), never user text — but JSON.stringify
94
+ // still escapes it, because AppleScript string syntax is close enough to
95
+ // JSON's that this is the cheap correct thing rather than concatenation.
96
+ const loc = startDir === undefined ? "" : ` default location POSIX file ${JSON.stringify(startDir)}`;
97
+ return { bin: "osascript", args: ["-e", `POSIX path of (${clause}${loc})`] };
98
+ }
99
+
100
+ if (d.platform === "win32") {
101
+ // -STA is load-bearing: WinForms dialogs deadlock on the MTA thread that
102
+ // `powershell -Command` uses by default, and a deadlock here looks
103
+ // exactly like a hung CLI. `powershell` (5.1, in-box) rather than
104
+ // `pwsh`, which is not installed by default.
105
+ // startDir is honoured here too, under different property names on the
106
+ // two dialogs. Without it a Windows user standing in D:\shoots\raw opens
107
+ // at Desktop while darwin, zenity and kdialog all land in the project
108
+ // folder — the same flag, three platforms agreeing and one not.
109
+ const start =
110
+ startDir === undefined
111
+ ? ""
112
+ : mode === "folder"
113
+ ? // FolderBrowserDialog seeds its start folder from SelectedPath —
114
+ // the same property it hands the answer back in.
115
+ `$d.SelectedPath = '${psQuote(startDir)}'; `
116
+ : `$d.InitialDirectory = '${psQuote(startDir)}'; `;
117
+ const script =
118
+ mode === "folder"
119
+ ? "Add-Type -AssemblyName System.Windows.Forms; " +
120
+ "$d = New-Object System.Windows.Forms.FolderBrowserDialog; " +
121
+ start +
122
+ "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }"
123
+ : "Add-Type -AssemblyName System.Windows.Forms; " +
124
+ "$d = New-Object System.Windows.Forms.OpenFileDialog; " +
125
+ `$d.Filter = 'Video|${VIDEO_EXTENSIONS.map((e) => `*.${e}`).join(";")}|All files|*.*'; ` +
126
+ start +
127
+ "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.FileName }";
128
+ return { bin: "powershell", args: ["-NoProfile", "-STA", "-Command", script] };
129
+ }
130
+
131
+ // Linux. zenity first because it is the GTK/GNOME default and far more
132
+ // widely installed; kdialog is the KDE equivalent and its filter syntax is
133
+ // a DIFFERENT shape — `Name(*.ext)` parentheses, not zenity's pipe.
134
+ if (d.hasBin("zenity")) {
135
+ const args = [
136
+ "--file-selection",
137
+ mode === "folder" ? "--title=Pick a folder of clips" : "--title=Pick your video",
138
+ ];
139
+ if (mode === "folder") args.push("--directory");
140
+ else args.push(`--file-filter=Video | ${globs()}`, "--file-filter=All files | *");
141
+ // The trailing slash is what makes zenity read this as a starting
142
+ // DIRECTORY rather than a pre-filled filename.
143
+ if (startDir !== undefined) args.push(`--filename=${startDir}/`);
144
+ return { bin: "zenity", args };
145
+ }
146
+
147
+ if (mode === "folder") {
148
+ // --getexistingdirectory accepts a start dir and nothing else; passing a
149
+ // filter here is an error, not an ignored argument.
150
+ return { bin: "kdialog", args: ["--getexistingdirectory", startDir ?? "."] };
151
+ }
152
+ return {
153
+ bin: "kdialog",
154
+ args: ["--getopenfilename", startDir ?? ".", `Video files(${globs()})`],
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Emptiness is the signal: no path came back. Exit codes cannot carry it —
160
+ * zenity and kdialog exit non-zero when dismissed, but PowerShell exits 0 and
161
+ * simply emits nothing, because the `if` guarding ShowDialog() just doesn't
162
+ * fire. So the caller passes `allowNonZero` and reads stdout, on every
163
+ * platform.
164
+ *
165
+ * What "no path came back" does NOT distinguish is a cancel from a silent
166
+ * backend failure — osascript hitting -1743 in a launchd or tmux context that
167
+ * sets neither SSH var, or Add-Type failing on a Windows box. That conflation
168
+ * is deliberate: no backend gives us a reliable way to tell the two apart
169
+ * (stderr is prose that varies by version and locale), and the caller's
170
+ * fallback is the typed-path prompt, which is the right next step either way.
171
+ */
172
+ export function parsePickerResult(stdout: string): string | undefined {
173
+ const picked = stdout.trim();
174
+ return picked === "" ? undefined : picked;
175
+ }
176
+
177
+ /**
178
+ * How much of stderr a user is shown. A zenity or osascript failure is one
179
+ * short sentence, but a PowerShell one is a multi-line exception with a
180
+ * banner, and pasting that above a wizard prompt is its own kind of unusable.
181
+ */
182
+ const MAX_STDERR_CHARS = 160;
183
+
184
+ /**
185
+ * AppleScript's user-cancelled error. Escape in `choose file` raises -128,
186
+ * which osascript writes to STDERR and exits 1 — verified on darwin 25.3.0
187
+ * (§136): `osascript -e 'error "User canceled." number -128'` prints
188
+ * `6:22: execution error: User canceled. (-128)`. So on the most common
189
+ * platform a cancel is the LOUDEST empty-handed return, not the quietest.
190
+ */
191
+ const OSASCRIPT_CANCEL = /\(-128\)/;
192
+
193
+ /**
194
+ * Toolkit chatter, not failure. GTK and Qt print these on a perfectly good
195
+ * dialog — `Gtk-Message: … Failed to load module "canberra-gtk-module"`,
196
+ * `Gdk-CRITICAL`, `qt.qpa.wayland:`, `kf.kio…` — and they are still sitting in
197
+ * stderr when the user cancels. Matched per LINE rather than against the whole
198
+ * blob, so a real error arriving alongside chatter still surfaces (§136).
199
+ */
200
+ const BENIGN_STDERR_PREFIXES = ["Gtk-Message:", "Gdk-", "qt.", "kf."];
201
+
202
+ /**
203
+ * The one line to print when the dialog came back with nothing and stderr says
204
+ * something went wrong. `parsePickerResult` above explains why stdout alone
205
+ * cannot tell a cancel from a silent backend failure; stderr can, but only
206
+ * after the noise is stripped, and the noise is backend-specific (§136):
207
+ *
208
+ * - **osascript** is NOT silent on a cancel — it emits error -128 (above).
209
+ * This was got wrong once, and the bug was that every Escape in the Finder
210
+ * dialog told a user whose picker works perfectly that it was broken.
211
+ * - **zenity / kdialog** exit 1 with empty stdout and *usually* empty stderr,
212
+ * but GTK and Qt leave launch chatter there routinely, cancel or not.
213
+ * - **PowerShell** is the only one the naive reading holds for: exit 0, empty
214
+ * stdout, empty stderr, because the `if` guarding ShowDialog() just does not
215
+ * fire.
216
+ *
217
+ * Deliberately NOT keyed on the exit code: this function is not given one, and
218
+ * PowerShell shows why it would be wrong anyway — it exits 0 on a cancel and
219
+ * would exit 0 on some failures too.
220
+ *
221
+ * Without a notice at all the user is in a feedback-free loop (§136, final
222
+ * review): a plain `ssh` into a box whose `~/.bashrc` exports `DISPLAY=:0`
223
+ * passes `pickerAvailable`, so `Browse…` is offered, prints "look for a new
224
+ * window", and returns to the menu with no window and no output — identically,
225
+ * forever, on every retry. The return value stays `undefined` either way; it
226
+ * was only ever the silence that was wrong.
227
+ *
228
+ * Pure so the wording is pinned without a dialog, which blocks on a human.
229
+ */
230
+ export function pickerFailureNotice(
231
+ bin: string,
232
+ stdout: string,
233
+ stderr: string,
234
+ ): string | undefined {
235
+ if (parsePickerResult(stdout) !== undefined) return undefined;
236
+ if (OSASCRIPT_CANCEL.test(stderr)) return undefined;
237
+ const real = stderr
238
+ .split("\n")
239
+ .map((l) => l.trim())
240
+ .filter((l) => l !== "" && !BENIGN_STDERR_PREFIXES.some((p) => l.startsWith(p)));
241
+ // First real line only: the rest is a stack or a usage dump on every backend
242
+ // that produces more than one.
243
+ const first = real[0];
244
+ if (first === undefined) return undefined; // a genuine cancel
245
+ const detail = first.length > MAX_STDERR_CHARS ? `${first.slice(0, MAX_STDERR_CHARS)}…` : first;
246
+ return `▸ ${bin} could not open a picker: ${detail} — type the path instead`;
247
+ }
248
+
249
+ /**
250
+ * Open the dialog and wait. The ONLY I/O in this module.
251
+ *
252
+ * The notice before the spawn is not decoration: a dialog that opens behind
253
+ * the terminal is indistinguishable from a hung CLI, and the wait here is
254
+ * unbounded by design — a human is deciding.
255
+ *
256
+ * The returned path is deliberately unvalidated (§136); `validateInputPath`
257
+ * owns existence and extension for the typed branch and this one alike, so
258
+ * the two cannot drift apart.
259
+ */
260
+ export async function pickPath(
261
+ mode: PickMode,
262
+ deps: PickerDeps = livePickerDeps(),
263
+ startDir: string = process.cwd(),
264
+ ): Promise<string | undefined> {
265
+ const { bin, args } = pickerCommand(deps, mode, startDir);
266
+ console.log("▸ file picker open — look for a new window");
267
+ try {
268
+ // allowNonZero: a cancel exits non-zero on zenity and kdialog — and 0 with
269
+ // empty stdout on PowerShell (see parsePickerResult above, and §136). The
270
+ // flag covers the first shape; emptiness is what actually decides.
271
+ const { stdout, stderr } = await run(bin, args, { allowNonZero: true });
272
+ // A backend that started and then failed says so on stderr and nowhere
273
+ // else. Say it back, or the user is told to look for a window that will
274
+ // never appear and is given nothing to act on (§136).
275
+ const notice = pickerFailureNotice(bin, stdout, stderr);
276
+ if (notice !== undefined) console.log(notice);
277
+ return parsePickerResult(stdout);
278
+ } catch {
279
+ // `run` only rejects when the binary would not start. pickerAvailable
280
+ // said yes, so this is a broken install or a PATH that changed under us —
281
+ // fall through to typing rather than failing the whole wizard, the same
282
+ // call openInBrowser makes about a box with no browser on it.
283
+ console.log("▸ couldn't open a file picker here — type the path instead");
284
+ return undefined;
285
+ }
286
+ }
@@ -1,5 +1,6 @@
1
1
  import { basename } from "node:path";
2
- import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { askInput } from "./ask-input";
3
4
  import { produceArgv, type ProduceAnswers, type ProduceExtras } from "./produce-argv";
4
5
  import { assertInteractive, confirm, intro, multiselect, select, text, unwrap } from "./prompts";
5
6
 
@@ -145,27 +146,10 @@ export async function produceWizard(
145
146
  // dropped it and asked again — the re-ask is where "./Anyhropic c Compiler"
146
147
  // became "./" (all of ~/Downloads). The router checks existence before the
147
148
  // wizard ever opens, so a prefilled path skips the prompt entirely.
148
- const input =
149
- cfg.input ??
150
- (unwrap(
151
- await text({
152
- // Finding 1 (final-review fix wave): `ossclip produce <folder>` shipped
153
- // (folder-input-brief.md) but this prompt still rejected a directory —
154
- // the wizard was the only way in that couldn't do what the CLI could.
155
- // A folder is concatenated by name (codepoint order, like `ls`); --sort
156
- // mtime reorders it but stays a typed flag, not a wizard question (see
157
- // the file-level comment above for why).
158
- message: "Video file, or a folder of clips to concatenate (by name; --sort mtime is a typed flag)",
159
- placeholder: "./raw/take1.mp4",
160
- validate: (v) => {
161
- if (!v) return "a path is required";
162
- if (!existsSync(v)) return `no such path: ${v}`;
163
- const st = statSync(v);
164
- if (!st.isFile() && !st.isDirectory()) return `${v} is neither a video file nor a folder`;
165
- return undefined;
166
- },
167
- }),
168
- ) as string);
149
+ //
150
+ // Everything else now lives in ask-input.ts (§136): suggestions, the native
151
+ // picker, and typing, all converging on one validator.
152
+ const input = cfg.input ?? (await askInput());
169
153
 
170
154
  const aspect = unwrap(
171
155
  await select({
@@ -0,0 +1,164 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, extname, join } from "node:path";
4
+ import { VIDEO_EXTENSIONS } from "@ossclip/core";
5
+
6
+ /**
7
+ * The rows above "Browse…" in the input prompt (§136). A non-technical user
8
+ * has just hit record and wants the file they made thirty seconds ago; it is
9
+ * nearly always the newest video in the working directory, Downloads, or
10
+ * Movies. Offering it by name beats any picker.
11
+ *
12
+ * Deliberately stateless: no recents file to keep, migrate or privacy-audit,
13
+ * and — the reason that mattered — a recents list is EMPTY on the very first
14
+ * run, which is the exact run a new user needs the help on.
15
+ */
16
+
17
+ export interface CandidateFile {
18
+ path: string;
19
+ mtimeMs: number;
20
+ size: number;
21
+ }
22
+
23
+ export interface Suggestion {
24
+ path: string;
25
+ label: string;
26
+ hint: string;
27
+ }
28
+
29
+ const VIDEO_EXT_SET = new Set<string>(VIDEO_EXTENSIONS);
30
+
31
+ /**
32
+ * Base-1000 like Finder and Explorer report it, not base-1024.
33
+ *
34
+ * The thresholds are the ROUNDING boundary (999_500), not the unit boundary
35
+ * (1_000_000): `Math.round` carries into the next unit while a unit-boundary
36
+ * check has already committed to the current one, so a 999.7 MB screen
37
+ * recording printed "1000 MB" next to a sibling's "1.1 GB".
38
+ */
39
+ export function humanSize(bytes: number): string {
40
+ if (bytes < 1_000) return `${bytes} B`;
41
+ if (bytes < 999_500) return `${Math.round(bytes / 1_000)} kB`;
42
+ if (bytes < 999_500_000) return `${Math.round(bytes / 1_000_000)} MB`;
43
+ return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
44
+ }
45
+
46
+ export function relativeAge(msInput: number): string {
47
+ // A file copied off a camera with an unset clock carries an mtime years
48
+ // ahead, which reaches here as a NEGATIVE age. Clamped rather than special
49
+ // cased: "just now" is the honest label for "not older than now". Such
50
+ // files are deliberately still ranked (highest, by mtime) rather than
51
+ // filtered — a few seconds of clock skew is ordinary and the file is real.
52
+ const ms = Math.max(0, msInput);
53
+ if (ms < 60_000) return "just now";
54
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
55
+ if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`;
56
+ return `${Math.floor(ms / 86_400_000)}d ago`;
57
+ }
58
+
59
+ export function tildeify(path: string, home: string): string {
60
+ return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
61
+ }
62
+
63
+ export function likelyDirs(d: {
64
+ platform: NodeJS.Platform;
65
+ cwd: string;
66
+ home: string;
67
+ }): string[] {
68
+ // Movies on macOS, Videos everywhere else — the OS's own recording default.
69
+ const media = join(d.home, d.platform === "darwin" ? "Movies" : "Videos");
70
+ return [...new Set([d.cwd, join(d.home, "Downloads"), media])];
71
+ }
72
+
73
+ export function rankSuggestions(
74
+ files: CandidateFile[],
75
+ nowMs: number,
76
+ home: string,
77
+ limit = 3,
78
+ ): Suggestion[] {
79
+ return files
80
+ .filter((file) => {
81
+ const name = basename(file.path);
82
+ if (name.startsWith(".")) return false;
83
+ // ossclip's own output. Cutting an already-cut video compounds the
84
+ // trims and is never what somebody means to do from this menu.
85
+ if (name.toLowerCase().endsWith(".ossclip.mp4")) return false;
86
+ // Case-folded before the lookup because VIDEO_EXTENSIONS is lowercase
87
+ // and cameras and screen recorders write `.MP4`/`.MOV` — the same fold
88
+ // `listFolderVideos` does, for the same reason.
89
+ return VIDEO_EXT_SET.has(extname(name).slice(1).toLowerCase());
90
+ })
91
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
92
+ .slice(0, limit)
93
+ .map((file) => ({
94
+ path: file.path,
95
+ label: tildeify(file.path, home),
96
+ hint: `${humanSize(file.size)} · ${relativeAge(nowMs - file.mtimeMs)}`,
97
+ }));
98
+ }
99
+
100
+ /** How many files to `stat` per directory. See the placement note below. */
101
+ const MAX_STATS_PER_DIR = 2_000;
102
+
103
+ /**
104
+ * The only filesystem in this module, and non-recursive on purpose: this
105
+ * runs before the first prompt paints, so a deep walk of somebody's
106
+ * Downloads would show up as the CLI hanging on startup.
107
+ */
108
+ export async function scanLikelyDirs(
109
+ dirs: string[] = likelyDirs({ platform: process.platform, cwd: process.cwd(), home: homedir() }),
110
+ ): Promise<CandidateFile[]> {
111
+ const out: CandidateFile[] = [];
112
+ for (const dir of dirs) {
113
+ let names: string[];
114
+ try {
115
+ names = (await readdir(dir, { withFileTypes: true }))
116
+ // Symlinks are followed, matching `listFolderVideos` in concat.ts: a
117
+ // folder of symlinks into another drive is a normal way to stage
118
+ // takes. The `stat` below resolves the target, and a dangling link
119
+ // throws into the per-file catch there.
120
+ .filter((e) => e.isFile() || e.isSymbolicLink())
121
+ .map((e) => e.name)
122
+ // Case-folded because VIDEO_EXTENSIONS is lowercase and recorders
123
+ // write `.MP4`/`.MOV`, the same fold `listFolderVideos` does.
124
+ .filter((name) => VIDEO_EXT_SET.has(extname(name).slice(1).toLowerCase()))
125
+ // The cap sits AFTER the filter on purpose: `readdir` has already
126
+ // materialised every dirent, so capping before it saves no I/O and
127
+ // throws away videos at random — readdir order is filesystem order,
128
+ // not mtime, so the take recorded 30 seconds ago is as likely to land
129
+ // in the discarded tail as anything else. Here it bounds the only
130
+ // real cost, the per-file stat.
131
+ .slice(0, MAX_STATS_PER_DIR);
132
+ } catch {
133
+ // A missing ~/Movies or an unreadable directory is ordinary. The
134
+ // suggestions are a convenience; nothing here may fail the wizard.
135
+ continue;
136
+ }
137
+ // Stat'd in parallel: awaited one at a time, a directory of 400 clips on
138
+ // an SMB share or an iCloud "Optimize Storage" ~/Movies is seconds of
139
+ // dead terminal before the first prompt paints, which is the startup hang
140
+ // non-recursion alone does not prevent. try/catch inside each task rather
141
+ // than allSettled because the recovery is identical for every failure —
142
+ // drop that one file — and this keeps the settled result already typed.
143
+ const stats = await Promise.all(
144
+ names.map(async (name): Promise<CandidateFile | undefined> => {
145
+ const path = join(dir, name);
146
+ try {
147
+ const st = await stat(path);
148
+ // A symlink to a DIRECTORY named `takes.mp4` passes the dirent
149
+ // check above and stats fine; without this it would be offered
150
+ // carrying the directory's size and mtime. concat.ts:346-353 —
151
+ // the parity this module claims — resolves and drops it too.
152
+ if (!st.isFile()) return undefined;
153
+ return { path, mtimeMs: st.mtimeMs, size: st.size };
154
+ } catch {
155
+ // Raced with a delete between readdir and stat, or a dangling
156
+ // symlink — skips this file only, never the whole directory.
157
+ return undefined;
158
+ }
159
+ }),
160
+ );
161
+ for (const file of stats) if (file !== undefined) out.push(file);
162
+ }
163
+ return out;
164
+ }
package/src/program.ts CHANGED
@@ -7,6 +7,21 @@ 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
+ // The one interactive import that is STATIC rather than `await import()`: the
11
+ // `resetInputSource()` run boundary in `buildProgram` has to run synchronously
12
+ // while the program is being built, and `buildProgram` cannot await. The graph
13
+ // already loads @ossclip/renderer and @ossclip/scenes eagerly through
14
+ // produce.ts, so clack riding along costs ~14ms on a ~320ms startup — cheap
15
+ // enough not to trade for a racy fire-and-forget import (§136).
16
+ //
17
+ // Consequence worth stating for whoever edits ask-input.ts next: this module,
18
+ // and everything it imports (picker.ts, prompts.ts → @clack/prompts,
19
+ // suggest-inputs.ts), is now EAGER on every ossclip invocation — `--version`
20
+ // and `doctor` included. A heavy dependency added there is no longer free.
21
+ // Removing this line to "restore laziness" deletes the run boundary with it:
22
+ // `input_source` would then report the PREVIOUS run's branch, and the only
23
+ // test that notices is the buildProgram case in telemetry.test.ts.
24
+ import { inputSourceUsed, resetInputSource } from "./interactive/ask-input";
10
25
  import { setReplayArgv } from "./replay-argv";
11
26
  import {
12
27
  bootstrapTelemetry,
@@ -38,6 +53,19 @@ const envFiles = loadEnvFiles();
38
53
  export function buildProgram(): Command {
39
54
  const program = new Command();
40
55
 
56
+ // §136: the input-source telemetry is module state, so something has to say
57
+ // when a RUN begins. Not the produce action — every wizard route re-enters
58
+ // that same action through `program.parseAsync` (§129), so a reset there
59
+ // fires on the re-entered parse, AFTER `askInput` recorded the branch, and
60
+ // `input_source` would read "argv" for every wizard run: the feature would
61
+ // measure exactly nothing. Nor is the process the boundary — a batch or REPL
62
+ // driver runs produce more than once, and the second run would report the
63
+ // first one's branch. The PROGRAM CONSTRUCTION is the boundary: commander 12
64
+ // keeps option state across parseAsync calls (see the bare-`produce` refusal
65
+ // below), so any batch has to rebuild the program per run anyway, which makes
66
+ // one reset per `buildProgram` exactly one per run in both cases.
67
+ resetInputSource();
68
+
41
69
  // Before dispatch, so the one-time first-run notice precedes any command's
42
70
  // own output. Inert in this repo's tests by construction: while POSTHOG_KEY
43
71
  // is the placeholder, bootstrap touches no disk and sends nothing (FINDINGS
@@ -433,6 +461,10 @@ export function buildProgram(): Command {
433
461
  render: opts.render !== false,
434
462
  source_duration_bucket: durationBucket(result.sourceDurationSec),
435
463
  scenes: result.sceneCount,
464
+ // Which branch of the input prompt was used — a branch name, never
465
+ // the path itself (§136). The picker exists because typing a path
466
+ // blocked non-technical users; this is how we find out if it helped.
467
+ input_source: inputSourceUsed(),
436
468
  });
437
469
  if (!telemetry.disabled) {
438
470
  telemetry.state.produceCount += 1;