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,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
+
import { expandHome } from "../paths";
|
|
3
4
|
import { livePickerDeps, pickPath, pickerAvailable, type PickMode } from "./picker";
|
|
4
5
|
import { assertInteractive, select, text, unwrap } from "./prompts";
|
|
5
6
|
import { rankSuggestions, scanLikelyDirs, type Suggestion } from "./suggest-inputs";
|
|
@@ -122,8 +123,8 @@ export const liveAskInputDeps = (): AskInputDeps => ({
|
|
|
122
123
|
assertInteractive: () => assertInteractive("input prompt"),
|
|
123
124
|
});
|
|
124
125
|
|
|
125
|
-
const typePath = async (deps: AskInputDeps): Promise<string> =>
|
|
126
|
-
unwrap(
|
|
126
|
+
const typePath = async (deps: AskInputDeps): Promise<string> => {
|
|
127
|
+
const typed = unwrap(
|
|
127
128
|
await deps.text({
|
|
128
129
|
// Finding 1 (final-review fix wave): `ossclip produce <folder>` shipped
|
|
129
130
|
// (folder-input-brief.md) but this prompt still rejected a directory —
|
|
@@ -133,9 +134,16 @@ const typePath = async (deps: AskInputDeps): Promise<string> =>
|
|
|
133
134
|
// the file-level comment in produce-wizard.ts for why).
|
|
134
135
|
message: "Video file, or a folder of clips to concatenate (by name; --sort mtime is a typed flag)",
|
|
135
136
|
placeholder: "./raw/take1.mp4",
|
|
136
|
-
|
|
137
|
+
// expandHome BEFORE the exists check (2026-08-16 incident, paths.ts):
|
|
138
|
+
// no shell expands a wizard text input, so a typed `~/Videos/x.mov`
|
|
139
|
+
// must validate as the home path, not fail as `<cwd>/~/...`.
|
|
140
|
+
validate: (v) => validateInputPath(v === undefined ? v : expandHome(v)),
|
|
137
141
|
}),
|
|
138
142
|
) as string;
|
|
143
|
+
// The expanded path is also what the wizard emits into argv — downstream
|
|
144
|
+
// (produce, replay recording) must never see the literal `~`.
|
|
145
|
+
return expandHome(typed);
|
|
146
|
+
};
|
|
139
147
|
|
|
140
148
|
export async function askInput(deps: AskInputDeps = liveAskInputDeps()): Promise<string> {
|
|
141
149
|
deps.assertInteractive();
|
|
@@ -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;
|