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.
- package/editor-dist/assets/index-Bx2VQLP8.css +1 -0
- package/editor-dist/assets/index-DVI51_2u.js +163 -0
- package/editor-dist/index.html +2 -2
- package/package.json +4 -4
- package/src/analyze.ts +17 -1
- package/src/caption-report.ts +141 -3
- package/src/doctor.ts +7 -5
- package/src/edit.ts +571 -3
- 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 +64 -8
- package/src/paths.ts +88 -0
- package/src/portrait-override.ts +86 -0
- package/src/produce.ts +1971 -161
- package/src/program.ts +142 -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 +10 -0
- package/editor-dist/assets/index-ChRVBVLj.css +0 -1
- package/editor-dist/assets/index-MLGz89mM.js +0 -163
|
@@ -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.
|
|
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
|
}
|