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.
- package/editor-dist/assets/index-C9n6EfII.js +163 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/analyze.ts +17 -1
- package/src/doctor.ts +7 -5
- package/src/edit.ts +496 -4
- package/src/interactive/ask-input.ts +11 -3
- package/src/interactive/pick-save-path.ts +214 -0
- package/src/interactive/produce-argv.ts +39 -2
- package/src/interactive/produce-wizard.ts +236 -35
- package/src/interactive/thumbnail-approve.ts +217 -0
- package/src/open.ts +26 -8
- package/src/paths.ts +88 -0
- package/src/portrait-override.ts +86 -0
- package/src/produce.ts +1607 -133
- package/src/program.ts +104 -6
- package/src/replay-argv.ts +75 -0
- package/src/setup/manifest.ts +93 -4
- package/src/setup/plan.ts +17 -6
- package/src/setup/setup.ts +28 -7
- package/src/thumbnail-panel.ts +167 -0
- package/src/ui/animation.ts +18 -4
- package/editor-dist/assets/index-MLGz89mM.js +0 -163
|
@@ -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.
|
|
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) —
|
|
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),
|
|
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
|
-
/**
|
|
78
|
-
|
|
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
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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: {
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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).
|
|
272
|
-
//
|
|
273
|
-
// Empty keeps whisper's en default, and produceArgv's
|
|
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:
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
14
|
+
target: string,
|
|
13
15
|
platform: NodeJS.Platform,
|
|
14
16
|
): { bin: string; args: string[] } {
|
|
15
|
-
if (platform === "darwin") return { bin: "open", args: [
|
|
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
|
|
19
|
-
|
|
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: [
|
|
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
|
+
}
|