ossclip 0.1.24 → 0.1.26

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.
@@ -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;
@@ -1,19 +1,27 @@
1
- import { basename } from "node:path";
1
+ import { basename, dirname, resolve } from "node:path";
2
2
  import { existsSync, readdirSync } from "node:fs";
3
+ import { saveConfigPatch, type OssclipConfig } from "@ossclip/core";
4
+ import { MODELS, bareWhisperModelName, modelImpliedLanguage } from "../setup/manifest";
5
+ import { defaultOutPath } from "../produce";
6
+ import { expandHome } from "../paths";
3
7
  import { askInput } from "./ask-input";
8
+ import { pickSavePath } from "./pick-save-path";
4
9
  import { produceArgv, type ProduceAnswers, type ProduceExtras } from "./produce-argv";
5
10
  import { assertInteractive, confirm, intro, multiselect, select, text, unwrap } from "./prompts";
6
11
 
7
12
  /**
8
- * The produce wizard. Thirty-four flags (plus the positional input path)
13
+ * The produce wizard. Forty-one flags (plus the positional input path)
9
14
  * sorted into three tiers: six prompts asked directly — the input path, plus
10
- * five flags (--out, --cleanup, --aspect, --produce, --intent) — ten behind
11
- * one "anything else?" multiselect, and the remaining stay flags-only:
15
+ * five flags (--out, --cleanup, --aspect, --produce, --intent) — eleven
16
+ * behind one "anything else?" multiselect, and the remaining stay flags-only:
12
17
  * debug/internal surfaces, replay-only fields, --no-watermark (the
13
18
  * multiselect only turns the credit ON; off is already the default),
19
+ * --no-youtube (the same shape: the pack entry only turns it ON),
14
20
  * --captions (the mirror case: ON is already the default, so the
15
21
  * multiselect entry is the OFF switch and the positive flag exists only for
16
- * replay pinning), or
22
+ * replay pinning), --add-jump-cuts (same mirror: auto already punches, the
23
+ * multiselect entry is the OFF switch, and the force flag exists to beat a
24
+ * future config-off), or
17
25
  * (final-review fix wave, Finding 1) --sort. A folder's clip order only means anything once the
18
26
  * folder has been enumerated, and that enumeration happens inside
19
27
  * `produce()` — after the wizard has already returned argv — so there is
@@ -32,15 +40,23 @@ const EXTRAS = [
32
40
  { value: "sourceFit", label: "Show the whole frame instead of cropping", hint: "--source-fit contain" },
33
41
  { value: "speaker", label: "Say who is on camera", hint: "--speaker" },
34
42
  { value: "whisperModel", label: "Pick a transcription model", hint: "--whisper-model" },
43
+ // Retake collapse is deliberately NOT an entry (2026-08-16): it runs
44
+ // automatically with --blooper-marker and never otherwise
45
+ // (inferredRetakesEnabled, produce.ts), and the user asked for it not to
46
+ // be exposed as its own knob — the marker entry above IS the switch.
35
47
  { value: "blooperMarker", label: "Cut flubbed takes on a spoken word", hint: "--blooper-marker" },
36
- {
37
- value: "collapseRetakes",
38
- label: "Collapse repeated takes automatically",
39
- hint: "--collapse-retakes",
40
- },
41
48
  { value: "sourceIsEdited", label: "Source already has burned-in text", hint: "--source-is-edited" },
42
49
  { value: "captionsOff", label: "Turn the burned-in captions off", hint: "--no-captions" },
50
+ { value: "jumpCutsOff", label: "No punch-in zooms at cuts", hint: "--no-jump-cuts" },
43
51
  { value: "watermark", label: 'Credit the tool with a small "made with ossclip"', hint: "--watermark" },
52
+ // The hint says the approval part out loud (thumbnail UX, 2026-08-16):
53
+ // ticking this adds an interactive stop before the render, and a surprise
54
+ // prompt mid-run reads as a hang to someone who didn't expect it.
55
+ {
56
+ value: "youtube",
57
+ label: "YouTube pack: SEO metadata + AI thumbnail",
58
+ hint: "--youtube · you approve the thumbnail concept before render",
59
+ },
44
60
  { value: "llm", label: "Choose the LLM provider", hint: "--llm" },
45
61
  ] as const;
46
62
 
@@ -74,22 +90,78 @@ export function extrasFor(
74
90
  );
75
91
  }
76
92
 
77
- /** Select value that routes to the free-text model prompt instead of a name. */
78
- export const CUSTOM_MODEL = "__custom__";
93
+ /**
94
+ * Which follow-up prompts the youtube extra asks, given what the config
95
+ * already supplies — `watermarkFromConfig`'s gating idea applied to the
96
+ * follow-up tier: a question whose answer is already in
97
+ * ~/.ossclip/config.json is noise, not a prompt (the flag still overrides
98
+ * the config for a one-off; that is a typed-flags surface, not a wizard
99
+ * one). `typeof`+trim, not truthiness: config.json is hand-edited and
100
+ * unparsed, and a `"audience": true` typo must mean "still ask", never a
101
+ * skipped question over a bogus value. Pure so the gating matrix is
102
+ * testable without a TTY.
103
+ */
104
+ export function youtubeFollowups(cfg: {
105
+ audience?: string;
106
+ portrait?: string;
107
+ thumbnailBrief?: string;
108
+ }): Array<"audience" | "portrait" | "brief"> {
109
+ const asks: Array<"audience" | "portrait" | "brief"> = [];
110
+ if (typeof cfg.audience !== "string" || cfg.audience.trim() === "") asks.push("audience");
111
+ if (typeof cfg.portrait !== "string" || cfg.portrait.trim() === "") asks.push("portrait");
112
+ if (typeof cfg.thumbnailBrief !== "string" || cfg.thumbnailBrief.trim() === "") {
113
+ asks.push("brief");
114
+ }
115
+ return asks;
116
+ }
79
117
 
80
118
  /**
81
- * A model pick reduced to its bare name: basename, minus the optional ggml-
82
- * prefix and .bin suffix. Exists because the language prefill classifies on
83
- * `.endsWith(".en")`, and an absolute path like /x/ggml-small.en.bin ends in
84
- * ".bin" — an English model would have been prefilled `auto` (review fix,
85
- * Urdu field test 2026-08-05).
119
+ * The config patch a yes to "remember these for future runs?" writes, or
120
+ * null when nothing was freshly typed — null means the offer never appears.
121
+ * Only answers TYPED into this run's follow-ups qualify: a key the config
122
+ * already supplies was never asked (youtubeFollowups gates the prompts on
123
+ * exactly that), and the same gate here keeps a future re-ask from letting a
124
+ * wizard answer silently clobber a hand-edited config.json. Portrait is
125
+ * stored as the expandHome-expanded absolute path — a `~` string in
126
+ * config.json would work today (produce.ts expands the config value too, see
127
+ * its own comment at the thumbnail step), but an absolute path in a
128
+ * hand-edited file is self-documenting about which home it meant. Pure, with
129
+ * `home` injectable like expandHome's own, so the matrix is testable without
130
+ * a TTY or the real homedir.
131
+ *
132
+ * Wizard-only by placement: flag-driven runs never reach this — power users
133
+ * have config, and an interactive prompt at the end of a scripted run would
134
+ * break the script.
86
135
  */
87
- export function bareWhisperModelName(nameOrPath: string): string {
88
- const base = basename(nameOrPath);
89
- const m = /^(?:ggml-)?(.+?)(?:\.bin)?$/.exec(base);
90
- return m?.[1] ?? base;
136
+ export function rememberPatch(
137
+ typed: { audience?: string; portrait?: string; thumbnailBrief?: string },
138
+ cfg: { audience?: string; portrait?: string; thumbnailBrief?: string },
139
+ home?: string,
140
+ ): Partial<OssclipConfig> | null {
141
+ const asked = new Set(youtubeFollowups(cfg));
142
+ // Same typeof+trim rule as youtubeFollowups: a whitespace answer was
143
+ // already dropped by the prompts, but a durable config write deserves the
144
+ // same parse-don't-coerce guard as the read side.
145
+ const fresh = (v: string | undefined): v is string =>
146
+ typeof v === "string" && v.trim() !== "";
147
+ const patch: Partial<OssclipConfig> = {};
148
+ if (asked.has("audience") && fresh(typed.audience)) patch.audience = typed.audience;
149
+ if (asked.has("portrait") && fresh(typed.portrait)) {
150
+ patch.portrait = resolve(expandHome(typed.portrait, home));
151
+ }
152
+ if (asked.has("brief") && fresh(typed.thumbnailBrief)) {
153
+ patch.thumbnailBrief = typed.thumbnailBrief;
154
+ }
155
+ return Object.keys(patch).length === 0 ? null : patch;
91
156
  }
92
157
 
158
+ /** Select value that routes to the free-text model prompt instead of a name. */
159
+ export const CUSTOM_MODEL = "__custom__";
160
+
161
+ // Moved to the setup manifest (its language/URL tables need the same
162
+ // stripping); re-exported so this module's callers and tests keep one home.
163
+ export { bareWhisperModelName };
164
+
93
165
  /** The three names `ossclip setup` knows how to download, with their hints. */
94
166
  const CANONICAL_MODELS = [
95
167
  { value: "base.en", hint: "fastest, least accurate" },
@@ -123,8 +195,24 @@ export function whisperModelChoices(
123
195
  label: c.value,
124
196
  hint: installed.has(c.value) ? c.hint : `${c.hint} · will need download`,
125
197
  }));
126
- const canonical = new Set<string>(CANONICAL_MODELS.map((c) => c.value));
127
- for (const name of [...installed].filter((n) => !canonical.has(n)).sort()) {
198
+ // Curated fine-tunes (a manifest `url` marks one): listed like the
199
+ // canonicals whether or not downloaded — setup can fetch them now, so the
200
+ // wizard must be able to name them (the one-command experience the curated
201
+ // table exists for), with the provenance note as the hint.
202
+ const curated = Object.entries(MODELS).filter(
203
+ ([name, info]) => info.url !== undefined && !CANONICAL_MODELS.some((c) => c.value === name),
204
+ );
205
+ for (const [name, info] of curated) {
206
+ choices.push({
207
+ value: name,
208
+ label: name,
209
+ hint:
210
+ (info.note ?? "curated fine-tune") +
211
+ (installed.has(name) ? "" : " · will need download"),
212
+ });
213
+ }
214
+ const listed = new Set(choices.map((c) => c.value));
215
+ for (const name of [...installed].filter((n) => !listed.has(n)).sort()) {
128
216
  choices.push({ value: name, label: name, hint: "installed" });
129
217
  }
130
218
  choices.push({
@@ -135,8 +223,30 @@ export function whisperModelChoices(
135
223
  return choices;
136
224
  }
137
225
 
226
+ /**
227
+ * The language follow-up's prefill for a picked model. The curated table's
228
+ * own language wins — `medium-urdu` prefills `ur`, so plain Enter runs the
229
+ * fine-tune with the code it was trained for instead of the `auto` detect
230
+ * gamble. Otherwise the standing heuristic: a non-.en pick is multilingual
231
+ * by construction, so `auto` lets whisper detect; `.en` keeps whisper's en
232
+ * default (empty = no flag, produceArgv's default-elision rule).
233
+ */
234
+ export function whisperLanguagePrefill(model: string): string {
235
+ return modelImpliedLanguage(model) ?? (bareWhisperModelName(model).endsWith(".en") ? "" : "auto");
236
+ }
237
+
138
238
  export async function produceWizard(
139
- cfg: { speaker?: string; modelDir?: string; input?: string; watermark?: boolean } = {},
239
+ cfg: {
240
+ speaker?: string;
241
+ modelDir?: string;
242
+ input?: string;
243
+ watermark?: boolean;
244
+ /** Gate the youtube follow-ups (youtubeFollowups): ask only what the
245
+ * config doesn't already answer. */
246
+ audience?: string;
247
+ portrait?: string;
248
+ thumbnailBrief?: string;
249
+ } = {},
140
250
  ): Promise<string[]> {
141
251
  assertInteractive("produce wizard");
142
252
  intro("ossclip produce");
@@ -190,10 +300,18 @@ export async function produceWizard(
190
300
  ) as string)
191
301
  : undefined;
192
302
 
193
- const defaultOut = `${basename(input).replace(/\.[^.]+$/, "")}.ossclip.mp4`;
194
- const out = unwrap(
195
- await text({ message: "Output file", placeholder: defaultOut, defaultValue: "" }),
196
- ) as string;
303
+ // Folder walk instead of the raw text prompt (2026-08-16 `~`-path
304
+ // incident, pick-save-path.ts). The default name comes from produce's own
305
+ // `defaultOutPath`, not this file's old duplicate regex, so the fast-path
306
+ // row names exactly the file a flag-less run writes; picking that row
307
+ // returns undefined and the elision rule below emits no --out at all.
308
+ // Resolved first because a typed relative input must anchor the walk (and
309
+ // the default's folder) to cwd, not to wherever `dirname` lands.
310
+ const resolvedInput = resolve(input);
311
+ const out = await pickSavePath({
312
+ startDir: dirname(resolvedInput),
313
+ defaultName: basename(defaultOutPath(resolvedInput)),
314
+ });
197
315
 
198
316
  const chosen = unwrap(
199
317
  await multiselect({
@@ -221,12 +339,93 @@ export async function produceWizard(
221
339
  );
222
340
  }
223
341
  if (chosen.includes("sourceFit")) extras.sourceFit = "contain";
224
- if (chosen.includes("collapseRetakes")) extras.collapseRetakes = true;
225
342
  if (chosen.includes("sourceIsEdited")) extras.sourceIsEdited = true;
226
343
  // The entry is the OFF switch (captions default ON — see EXTRAS), so a
227
344
  // tick maps to `captions: false` and produceArgv emits `--no-captions`.
228
345
  if (chosen.includes("captionsOff")) extras.captions = false;
346
+ // Same OFF-switch shape (the punch defaults ON, face-only): a tick maps
347
+ // to `jumpCuts: false` and produceArgv emits `--no-jump-cuts`.
348
+ if (chosen.includes("jumpCutsOff")) extras.jumpCuts = false;
229
349
  if (chosen.includes("watermark")) extras.watermark = true;
350
+ if (chosen.includes("youtube")) {
351
+ extras.youtube = true;
352
+ // Follow-ups under the same extra, like --clip's seconds prompt — but
353
+ // gated on the config (youtubeFollowups): a question whose answer is
354
+ // already in ~/.ossclip/config.json is never re-asked. All three trim,
355
+ // like the language follow-up: a whitespace answer must not become a
356
+ // bogus flag value, and an empty answer means "no flag" (the config, or
357
+ // nothing, decides).
358
+ const followups = youtubeFollowups(cfg);
359
+ if (followups.includes("audience")) {
360
+ const audience = (
361
+ unwrap(
362
+ await text({
363
+ message: "Who is this channel for?",
364
+ placeholder: "junior web devs learning AI tooling",
365
+ defaultValue: "",
366
+ }),
367
+ ) as string
368
+ ).trim();
369
+ if (audience) extras.audience = audience;
370
+ }
371
+ if (followups.includes("portrait")) {
372
+ // The portrait only means anything to the pack's AI thumbnail.
373
+ // Optional — empty skips the flag, and the thumbnail falls back to
374
+ // the frame-grab cover.
375
+ const portrait = (
376
+ unwrap(
377
+ await text({
378
+ message: "Portrait photo for the AI thumbnail (empty = use the frame-grab cover)",
379
+ placeholder: "~/Pictures/me.jpg",
380
+ defaultValue: "",
381
+ }),
382
+ ) as string
383
+ ).trim();
384
+ if (portrait) extras.portrait = portrait;
385
+ }
386
+ if (followups.includes("brief")) {
387
+ const brief = (
388
+ unwrap(
389
+ await text({
390
+ message: "Anything the thumbnail must get right? (optional)",
391
+ placeholder: "always show the terminal, never stock imagery",
392
+ defaultValue: "",
393
+ }),
394
+ ) as string
395
+ ).trim();
396
+ if (brief) extras.thumbnailBrief = brief;
397
+ }
398
+ // Offer to persist the fresh answers (UX completion, 2026-08-17): all
399
+ // three are durable channel facts, and before this the wizard re-asked
400
+ // them every run until the user hand-edited ~/.ossclip/config.json. The
401
+ // write is an ADDITION for the NEXT run (loadConfig picks it up), never a
402
+ // substitute for the flags: this run's argv below still carries the typed
403
+ // values, so the printed command stays replayable on a machine without
404
+ // the config. The decision itself lives in rememberPatch, tested without
405
+ // a TTY — this block is only the I/O around it, offer-editor's split.
406
+ const patch = rememberPatch(
407
+ {
408
+ audience: extras.audience,
409
+ portrait: extras.portrait,
410
+ thumbnailBrief: extras.thumbnailBrief,
411
+ },
412
+ cfg,
413
+ );
414
+ if (patch !== null) {
415
+ const remember = unwrap(
416
+ await confirm({
417
+ message: "Remember these for future runs? (saves to ~/.ossclip/config.json)",
418
+ initialValue: true,
419
+ }),
420
+ ) as boolean;
421
+ if (remember) {
422
+ const path = saveConfigPatch(patch);
423
+ // Say where the answers went — offer-editor's rule: a preference
424
+ // saved silently is one the user cannot find again to take back.
425
+ console.log(`▸ saved ${Object.keys(patch).join(", ")} to ${path}`);
426
+ }
427
+ }
428
+ }
230
429
  if (chosen.includes("speaker")) {
231
430
  extras.speaker = unwrap(
232
431
  await text({
@@ -268,16 +467,16 @@ export async function produceWizard(
268
467
  // Follow-up under the same extra, like --clip's seconds prompt: a language
269
468
  // only means anything once a model is being picked, and a multilingual
270
469
  // fine-tune silently decodes English without it (Urdu field test
271
- // 2026-08-05). A non-.en pick is multilingual by construction, so the
272
- // prefill makes plain Enter the safe answer — `auto` lets whisper detect.
273
- // Empty keeps whisper's en default, and produceArgv's default-elision
274
- // rule then emits no flag at all.
470
+ // 2026-08-05). The prefill (whisperLanguagePrefill) makes plain Enter the
471
+ // safe answer: a curated fine-tune's own language, else `auto` for a
472
+ // non-.en pick. Empty keeps whisper's en default, and produceArgv's
473
+ // default-elision rule then emits no flag at all.
275
474
  const lang = (
276
475
  unwrap(
277
476
  await text({
278
477
  message: "Transcription language code (empty = default en)",
279
478
  placeholder: "ur",
280
- initialValue: bareWhisperModelName(model).endsWith(".en") ? "" : "auto",
479
+ initialValue: whisperLanguagePrefill(model),
281
480
  defaultValue: "",
282
481
  }),
283
482
  ) as string
@@ -312,7 +511,9 @@ export async function produceWizard(
312
511
  cleanup,
313
512
  graphics,
314
513
  intent,
315
- out: out || undefined,
514
+ // Already `string | undefined`: pickSavePath's use-default row IS the
515
+ // old empty answer — no --out, produce derives its own default.
516
+ out,
316
517
  extras,
317
518
  });
318
519
  }