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,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
  }
@@ -0,0 +1,217 @@
1
+ import { copyFile, writeFile } from "node:fs/promises";
2
+ import {
3
+ ThumbnailConceptSchema,
4
+ approvedOverlayText,
5
+ buildThumbnailPrompt,
6
+ type GenerateThumbnailImageOptions,
7
+ type ThumbnailConcept,
8
+ type ThumbnailConceptApproved,
9
+ } from "@ossclip/core";
10
+ import { openInViewer } from "../open";
11
+ import { select, text, unwrap } from "./prompts";
12
+
13
+ /**
14
+ * The pre-render concept approval and the post-generation retry loop
15
+ * (thumbnail UX, 2026-08-16). Both exist for the same field incident class:
16
+ * the concept/image models make a judgement call the user only discovers
17
+ * AFTER a multi-minute render — approval moves the concept judgement before
18
+ * the render, the retry loop makes the image judgement cheap to redo.
19
+ *
20
+ * Every prompt goes through the injectable `ApprovePrompts` seam so the loop
21
+ * logic is testable with a scripted object — no TTY, no clack, the tty.ts
22
+ * doctrine applied one layer up. The default implementation wraps clack via
23
+ * ./prompts and inherits its cancel-exits-cleanly behavior (unwrap).
24
+ */
25
+
26
+ export interface ApprovePrompts {
27
+ /** Returns the chosen option's value — already unwrapped, never a cancel symbol. */
28
+ select(opts: {
29
+ message: string;
30
+ options: { value: string; label: string; hint?: string }[];
31
+ }): Promise<string>;
32
+ /** Returns the typed text — already unwrapped. */
33
+ text(opts: { message: string; initialValue?: string; placeholder?: string }): Promise<string>;
34
+ }
35
+
36
+ /** The live clack-backed prompts; tests inject a scripted replacement. */
37
+ export function clackApprovePrompts(): ApprovePrompts {
38
+ return {
39
+ select: async (opts) => unwrap(await select(opts)) as string,
40
+ text: async (opts) => unwrap(await text({ ...opts, defaultValue: "" })) as string,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * The concept as the three lines the approval prompt displays. Pure so the
46
+ * formatting (overlay first — it is the one thing the viewer will read) is
47
+ * pinned without a TTY.
48
+ */
49
+ export function formatConceptLines(concept: ThumbnailConcept): string[] {
50
+ return [
51
+ ` overlay: ${concept.overlayText}`,
52
+ ` scene: ${concept.scene}`,
53
+ ` style: ${concept.styleNotes}`,
54
+ ];
55
+ }
56
+
57
+ /**
58
+ * Re-validate a hand-edited concept: through the SAME schema the LLM's
59
+ * output takes (an edit is user input, parsed not coerced), plus
60
+ * `approvedOverlayText`'s WORD cap on the overlay — the schema caps
61
+ * characters, but overlay text at thumbnail size keeps a cover banner's 4-9
62
+ * word ceiling (§35), and an edit must not smuggle a paragraph past the cap
63
+ * the generated path enforces. The helper is thumbnailStep's exact
64
+ * treatment, shared so the image cache key never sees two spellings of one
65
+ * concept.
66
+ */
67
+ export function editedConcept(fields: {
68
+ scene: string;
69
+ overlayText: string;
70
+ styleNotes: string;
71
+ }): ThumbnailConcept {
72
+ const parsed = ThumbnailConceptSchema.parse(fields);
73
+ return { ...parsed, overlayText: approvedOverlayText(parsed.overlayText) };
74
+ }
75
+
76
+ export interface ApproveConceptArgs {
77
+ /**
78
+ * One concept call, note optional — produce injects the provider-backed
79
+ * call (with audience/brief steer and phase timing); tests inject a stub.
80
+ */
81
+ generateConcept: (note?: string) => Promise<ThumbnailConcept>;
82
+ /** A concept to present first (the workdir cache) — skips the initial call. */
83
+ initial?: ThumbnailConcept;
84
+ prompts?: ApprovePrompts;
85
+ log?: (line: string) => void;
86
+ }
87
+
88
+ /**
89
+ * The approval loop: display → use / edit / regenerate-with-note / skip.
90
+ * Returns what the caller writes into `thumbnail-concept-approved.json` —
91
+ * the approved concept, or `{skip: true}` so the post-render step (and every
92
+ * non-TTY replay) skips loudly instead of silently regenerating.
93
+ */
94
+ export async function approveThumbnailConcept(
95
+ args: ApproveConceptArgs,
96
+ ): Promise<ThumbnailConceptApproved> {
97
+ const { prompts = clackApprovePrompts(), log = console.log } = args;
98
+ let concept = args.initial ?? (await args.generateConcept());
99
+ for (;;) {
100
+ log("▸ thumbnail concept:");
101
+ for (const line of formatConceptLines(concept)) log(line);
102
+ const choice = await prompts.select({
103
+ message: "Use this thumbnail concept?",
104
+ options: [
105
+ { value: "use", label: "use it" },
106
+ { value: "edit", label: "edit fields" },
107
+ { value: "regenerate", label: "regenerate with a note" },
108
+ { value: "skip", label: "skip thumbnail", hint: "the frame-grab cover stands" },
109
+ ],
110
+ });
111
+ if (choice === "use") return concept;
112
+ if (choice === "skip") return { skip: true };
113
+ if (choice === "edit") {
114
+ // Prefilled per field so an edit is a tweak, not a retype; the result
115
+ // loops back to the display so the user confirms what they typed.
116
+ concept = editedConcept({
117
+ overlayText: await prompts.text({
118
+ message: "Overlay text (3-6 punchy words)",
119
+ initialValue: concept.overlayText,
120
+ }),
121
+ scene: await prompts.text({ message: "Scene", initialValue: concept.scene }),
122
+ styleNotes: await prompts.text({ message: "Style notes", initialValue: concept.styleNotes }),
123
+ });
124
+ continue;
125
+ }
126
+ // regenerate: one note, one fresh concept call, back to the display.
127
+ const note = (
128
+ await prompts.text({
129
+ message: "What should the concept do differently?",
130
+ placeholder: "less abstract — show the actual terminal output",
131
+ })
132
+ ).trim();
133
+ concept = await args.generateConcept(note || undefined);
134
+ }
135
+ }
136
+
137
+ export interface ThumbnailRetryArgs {
138
+ /** The `<out>.thumbnail.png` the step wrote — overwritten on each retry. */
139
+ imagePath: string;
140
+ /** The workdir cache — overwritten too, or a warm re-run would revert the retry. */
141
+ imageCachePath: string;
142
+ /** The approved/generated concept — UNCHANGED across retries by design. */
143
+ concept: ThumbnailConcept;
144
+ apiKey: string;
145
+ model: string;
146
+ portrait: { data: string; mimeType: string };
147
+ /** The injected-generate seam (thumbnailStep's exactly) — tests never touch the SDK. */
148
+ generate: (opts: GenerateThumbnailImageOptions) => Promise<Uint8Array>;
149
+ /** The injected viewer seam (openInViewer's shape) — tests never spawn a viewer. */
150
+ open?: (path: string) => void;
151
+ prompts?: ApprovePrompts;
152
+ log?: (line: string) => void;
153
+ }
154
+
155
+ /**
156
+ * The post-generation retry loop: keep, or regenerate with a note. Each
157
+ * retry is ONE image call — the concept stays fixed and the note rides the
158
+ * image prompt as a must-honor revision (buildThumbnailPrompt's
159
+ * `revisionNote`) — and the loop asks again after every result, so the user
160
+ * can iterate until "keep". A failed retry keeps the previous image on disk
161
+ * (both files were only overwritten on success) and says so.
162
+ */
163
+ export async function thumbnailRetryLoop(args: ThumbnailRetryArgs): Promise<void> {
164
+ const { prompts = clackApprovePrompts(), log = console.log, open = openInViewer } = args;
165
+ for (;;) {
166
+ // Show the image before EVERY keep/regenerate prompt — top of the loop,
167
+ // so each regeneration reopens the NEW file. The user is confirming what
168
+ // they see, not a path (thumbnail UX, 2026-08-17).
169
+ try {
170
+ open(args.imagePath);
171
+ } catch {
172
+ // A headless-ish env or a missing xdg-open must not kill an
173
+ // interactive confirm that can proceed on the printed path — same
174
+ // posture as openInBrowser's error handler.
175
+ log(`▸ could not open viewer — ${args.imagePath}`);
176
+ }
177
+ const choice = await prompts.select({
178
+ message: `Thumbnail written → ${args.imagePath}. Keep it?`,
179
+ options: [
180
+ { value: "keep", label: "keep" },
181
+ { value: "regenerate", label: "regenerate with a note" },
182
+ ],
183
+ });
184
+ if (choice === "keep") return;
185
+ const note = (
186
+ await prompts.text({
187
+ message: "What should change in the image?",
188
+ placeholder: "warmer lighting, less clutter behind me",
189
+ })
190
+ ).trim();
191
+ // An empty note would regenerate with zero new information — the same
192
+ // dice re-rolled at API cost. Re-ask instead of guessing what changed.
193
+ if (!note) continue;
194
+ try {
195
+ const bytes = await args.generate({
196
+ apiKey: args.apiKey,
197
+ model: args.model,
198
+ prompt: buildThumbnailPrompt(args.concept, true, note),
199
+ portrait: args.portrait,
200
+ });
201
+ // Cache first, then the destination — the same overwrite order a crash
202
+ // between the two degrades safest under: a stale destination beside a
203
+ // fresh cache self-heals on the next run's copy, the reverse would
204
+ // revert the user's retry on every warm re-run.
205
+ await writeFile(args.imageCachePath, bytes);
206
+ await copyFile(args.imageCachePath, args.imagePath);
207
+ log(`✓ thumbnail regenerated → ${args.imagePath}`);
208
+ } catch (err) {
209
+ // §132 posture verbatim from thumbnailStep: surface the API's message,
210
+ // never retry silently, and the previous image stands.
211
+ log(
212
+ `▸ thumbnail: regeneration failed (${err instanceof Error ? err.message : String(err)}) ` +
213
+ "— keeping the previous image",
214
+ );
215
+ }
216
+ }
217
+ }
package/src/open.ts CHANGED
@@ -1,24 +1,28 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
3
  /**
4
- * Open a URL in the default browser, per platform. The old code spawned
5
- * macOS's `open` unconditionally — on Linux and Windows that ENOENT became
6
- * an unhandled 'error' event and took the whole edit server down with it.
4
+ * Open a target — URL or file path, every platform's opener treats them
5
+ * identically — in the OS default handler, per platform. The old code
6
+ * spawned macOS's `open` unconditionally — on Linux and Windows that ENOENT
7
+ * became an unhandled 'error' event and took the whole edit server down
8
+ * with it.
7
9
  *
8
10
  * Failure here is not an error: a headless box or WSL without a browser is
9
11
  * a normal place to run `ossclip edit` — print the URL and move on.
10
12
  */
11
13
  export function openCommand(
12
- url: string,
14
+ target: string,
13
15
  platform: NodeJS.Platform,
14
16
  ): { bin: string; args: string[] } {
15
- if (platform === "darwin") return { bin: "open", args: [url] };
17
+ if (platform === "darwin") return { bin: "open", args: [target] };
16
18
  if (platform === "win32") {
17
19
  // `start` is a cmd built-in, not an executable; the empty string is
18
- // start's window-title slot so the URL isn't eaten as the title.
19
- return { bin: "cmd", args: ["/c", "start", "", url] };
20
+ // start's window-title slot so the target isn't eaten as the title —
21
+ // load-bearing for file paths with spaces, which spawn quotes and
22
+ // `start` would otherwise read as its title argument.
23
+ return { bin: "cmd", args: ["/c", "start", "", target] };
20
24
  }
21
- return { bin: "xdg-open", args: [url] };
25
+ return { bin: "xdg-open", args: [target] };
22
26
  }
23
27
 
24
28
  export function openInBrowser(url: string, platform: NodeJS.Platform = process.platform): void {
@@ -28,3 +32,17 @@ export function openInBrowser(url: string, platform: NodeJS.Platform = process.p
28
32
  console.log(`▸ couldn't open a browser here — open ${url} yourself`);
29
33
  });
30
34
  }
35
+
36
+ /**
37
+ * Open a file in the OS default viewer — the thumbnail confirm needs the
38
+ * user to SEE the image before answering keep/regenerate, not squint at a
39
+ * path. Same posture as openInBrowser: a missing xdg-open on a headless-ish
40
+ * box logs one line and the interactive flow proceeds on the printed path.
41
+ */
42
+ export function openInViewer(path: string, platform: NodeJS.Platform = process.platform): void {
43
+ const { bin, args } = openCommand(path, platform);
44
+ const child = spawn(bin, args, { stdio: "ignore", detached: false });
45
+ child.on("error", () => {
46
+ console.log(`▸ could not open viewer — ${path}`);
47
+ });
48
+ }