ossclip 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ import { basename } from "node:path";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { produceArgv, type ProduceAnswers, type ProduceExtras } from "./produce-argv";
4
+ import { assertInteractive, confirm, intro, multiselect, select, text, unwrap } from "./prompts";
5
+
6
+ /**
7
+ * The produce wizard. Twenty-five flags sorted into three tiers: six always
8
+ * asked, seven behind one "anything else?" multiselect, and twelve that stay
9
+ * flags-only because they are debug or internal surfaces.
10
+ *
11
+ * --clip-window is deliberately NOT offered: --clip runs write it into
12
+ * command.json so the editor's Render replays the same window without an LLM
13
+ * call. A human picking it from a menu is a corrupted replay, not a
14
+ * preference.
15
+ */
16
+
17
+ const EXTRAS = [
18
+ { value: "graphicsClip", label: "Only the strongest N seconds of a long take", hint: "--clip" },
19
+ { value: "sourceFit", label: "Show the whole frame instead of cropping", hint: "--source-fit contain" },
20
+ { value: "speaker", label: "Say who is on camera", hint: "--speaker" },
21
+ { value: "whisperModel", label: "Pick a transcription model", hint: "--whisper-model" },
22
+ { value: "blooperMarker", label: "Cut flubbed takes on a spoken word", hint: "--blooper-marker" },
23
+ { value: "sourceIsEdited", label: "Source already has burned-in text", hint: "--source-is-edited" },
24
+ { value: "llm", label: "Choose the LLM provider", hint: "--llm" },
25
+ ] as const;
26
+
27
+ /**
28
+ * `produce.ts`'s own §93b guard throws "--clip needs the producer's
29
+ * editorial judgement: add --produce" whenever `--clip` shows up without
30
+ * `--produce`. Offering "Only the strongest N seconds" to someone who just
31
+ * answered "no" to graphics is offering a menu item that is a guaranteed
32
+ * error nine prompts later — so the clip extra is only ever listed once
33
+ * graphics is already on. Exported and kept pure so this can be asserted
34
+ * without a TTY.
35
+ */
36
+ export function extrasFor(graphics: boolean): (typeof EXTRAS)[number][] {
37
+ return graphics ? [...EXTRAS] : EXTRAS.filter((e) => e.value !== "graphicsClip");
38
+ }
39
+
40
+ export async function produceWizard(cfg: { speaker?: string } = {}): Promise<string[]> {
41
+ assertInteractive("produce wizard");
42
+ intro("ossclip produce");
43
+
44
+ const input = unwrap(
45
+ await text({
46
+ message: "Video file",
47
+ placeholder: "./raw/take1.mp4",
48
+ validate: (v) => {
49
+ if (!v) return "a path is required";
50
+ if (!existsSync(v)) return `no such file: ${v}`;
51
+ if (!statSync(v).isFile()) return `${v} is a directory, not a video file`;
52
+ return undefined;
53
+ },
54
+ }),
55
+ ) as string;
56
+
57
+ const aspect = unwrap(
58
+ await select({
59
+ message: "Shape",
60
+ initialValue: "9:16",
61
+ options: [
62
+ { value: "9:16", label: "Vertical 9:16", hint: "shorts, reels" },
63
+ { value: "16:9", label: "Landscape 16:9", hint: "1920x1080" },
64
+ ],
65
+ }),
66
+ ) as ProduceAnswers["aspect"];
67
+
68
+ const cleanup = unwrap(
69
+ await select({
70
+ message: "How hard should it cut?",
71
+ initialValue: "standard",
72
+ options: [
73
+ { value: "exact", label: "exact", hint: "no cuts at all" },
74
+ { value: "light", label: "light" },
75
+ { value: "standard", label: "standard", hint: "recommended" },
76
+ { value: "aggressive", label: "aggressive" },
77
+ ],
78
+ }),
79
+ ) as ProduceAnswers["cleanup"];
80
+
81
+ const graphics = unwrap(
82
+ await confirm({ message: "Plan title cards and graphics with an LLM?", initialValue: false }),
83
+ ) as boolean;
84
+
85
+ // Only asked under graphics: the intent feeds the producer brain, which
86
+ // does not run otherwise.
87
+ const intent = graphics
88
+ ? (unwrap(
89
+ await text({
90
+ message: "What is the video about?",
91
+ placeholder: "educational video about agents",
92
+ }),
93
+ ) as string)
94
+ : undefined;
95
+
96
+ const defaultOut = `${basename(input).replace(/\.[^.]+$/, "")}.ossclip.mp4`;
97
+ const out = unwrap(
98
+ await text({ message: "Output file", placeholder: defaultOut, defaultValue: "" }),
99
+ ) as string;
100
+
101
+ const chosen = unwrap(
102
+ await multiselect({
103
+ message: "Anything else? (space to toggle, enter to accept)",
104
+ options: extrasFor(graphics),
105
+ required: false,
106
+ }),
107
+ ) as string[];
108
+
109
+ const extras: ProduceExtras = {};
110
+ if (chosen.includes("graphicsClip")) {
111
+ extras.clip = Number.parseFloat(
112
+ unwrap(
113
+ await text({
114
+ message: "How many seconds?",
115
+ placeholder: "60",
116
+ validate: (v) => {
117
+ const n = Number.parseFloat(v ?? "");
118
+ // Mirrors the CLI's own §93a guard: a zero or a typo must be
119
+ // rejected here rather than coerced into a NaN-length window.
120
+ return Number.isFinite(n) && n > 0 ? undefined : "a positive number of seconds";
121
+ },
122
+ }),
123
+ ) as string,
124
+ );
125
+ }
126
+ if (chosen.includes("sourceFit")) extras.sourceFit = "contain";
127
+ if (chosen.includes("sourceIsEdited")) extras.sourceIsEdited = true;
128
+ if (chosen.includes("speaker")) {
129
+ extras.speaker = unwrap(
130
+ await text({
131
+ message: "Who is on camera?",
132
+ placeholder: "Ahsan, host of Code with Ahsan",
133
+ // Prefilled from ~/.ossclip/config.json where set, so this answer
134
+ // persists through the config that already exists.
135
+ initialValue: cfg.speaker ?? "",
136
+ }),
137
+ ) as string;
138
+ }
139
+ if (chosen.includes("whisperModel")) {
140
+ extras.whisperModel = unwrap(
141
+ await select({
142
+ message: "Transcription model",
143
+ initialValue: "small.en",
144
+ options: [
145
+ { value: "base.en", label: "base.en", hint: "fastest, least accurate" },
146
+ { value: "small.en", label: "small.en", hint: "default" },
147
+ { value: "medium.en", label: "medium.en", hint: "slowest, most accurate" },
148
+ ],
149
+ }),
150
+ ) as string;
151
+ }
152
+ if (chosen.includes("blooperMarker")) {
153
+ extras.blooperMarker = unwrap(
154
+ await text({ message: "Which word marks a flubbed take?", placeholder: "blooper" }),
155
+ ) as string;
156
+ }
157
+ if (chosen.includes("llm")) {
158
+ extras.llm = unwrap(
159
+ await select({
160
+ message: "LLM provider",
161
+ options: [
162
+ { value: "claude-cli", label: "claude-cli", hint: "your logged-in Claude Code, no API charges" },
163
+ { value: "claude", label: "claude", hint: "needs ANTHROPIC_API_KEY" },
164
+ { value: "gemini", label: "gemini", hint: "needs GEMINI_API_KEY" },
165
+ { value: "mock", label: "mock", hint: "no LLM at all" },
166
+ ],
167
+ }),
168
+ ) as ProduceExtras["llm"];
169
+ }
170
+
171
+ return produceArgv({
172
+ input,
173
+ aspect,
174
+ cleanup,
175
+ graphics,
176
+ intent,
177
+ out: out || undefined,
178
+ extras,
179
+ });
180
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Re-export surface for clack, so every interactive module imports prompts
3
+ * from one place and the dependency can be swapped without touching wizards.
4
+ */
5
+ export { confirm, intro, isCancel, log, multiselect, outro, select, text } from "@clack/prompts";
6
+ export { assertInteractive, isInteractive, unwrap } from "./tty";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Renders an argv into the command line a user could have typed. Printed as
3
+ * `▸ running:` before every wizard run, so the wizard is also a flags lesson.
4
+ *
5
+ * This takes the SAME array that gets executed. A wizard that teaches one
6
+ * command and runs another is worse than no wizard, and rendering from the
7
+ * executed array makes that failure unrepresentable rather than unlikely.
8
+ */
9
+
10
+ // Deliberately an allowlist, not a "needs quoting" denylist: a character
11
+ // nobody thought about ends up quoted (harmless) instead of unquoted (wrong).
12
+ // Backslash and colon are in it so `D:\CWA\TiDB` — the exact shape of path
13
+ // this feature exists for — renders bare rather than wrapped in quotes.
14
+ const SAFE = /^[A-Za-z0-9._\-/\\:=@+,]+$/;
15
+
16
+ export function quoteArg(arg: string, platform: NodeJS.Platform = process.platform): string {
17
+ if (SAFE.test(arg)) return arg;
18
+ if (platform === "win32") {
19
+ // cmd doubles an embedded quote. Backslashes are left alone — escaping
20
+ // them would corrupt every Windows path this prints.
21
+ return `"${arg.replace(/"/g, '""')}"`;
22
+ }
23
+ // POSIX single quotes have no escape character: close, emit an escaped
24
+ // quote, reopen.
25
+ return `'${arg.replace(/'/g, "'\\''")}'`;
26
+ }
27
+
28
+ export function renderCommand(argv: string[], platform: NodeJS.Platform = process.platform): string {
29
+ return ["ossclip", ...argv].map((a) => quoteArg(a, platform)).join(" ");
30
+ }
@@ -0,0 +1,115 @@
1
+ import { sep as hostSep } from "node:path";
2
+ import { renderCommand } from "./render";
3
+
4
+ /**
5
+ * Resolving what a user meant by `ossclip edit <path>`.
6
+ *
7
+ * The bug this exists for: `produce` writes into
8
+ * `<input dir>/.ossclip/<name>/`, `edit` wants that nested directory, and the
9
+ * old failure — "no render-props.json in <dir> — run `ossclip produce` there
10
+ * first" — named a fix the user had already performed. It also pointed away
11
+ * from `ossclip edit` with no argument, which has opened a picker over recent
12
+ * runs since R17 §83 and would have solved it instantly.
13
+ *
14
+ * Pure by construction: the caller passes a probe record and this decides.
15
+ * That keeps every rung testable without a filesystem.
16
+ */
17
+
18
+ export interface Candidate {
19
+ path: string;
20
+ mtimeMs: number;
21
+ }
22
+
23
+ /**
24
+ * Why the probe came back empty, when it was not simply "nothing produced
25
+ * here yet". `missing` is ENOENT, `unreadable` is EACCES/EPERM — or any
26
+ * other errno, which is reported with its `code` rather than silently
27
+ * becoming "no output".
28
+ */
29
+ export interface ProbeFailure {
30
+ reason: "missing" | "unreadable";
31
+ code?: string;
32
+ }
33
+
34
+ export interface WorkdirProbe {
35
+ /** Does `dir` itself hold a render-props.json? */
36
+ isWorkdir: boolean;
37
+ /** Directories under `dir/.ossclip/` that hold a render-props.json. */
38
+ candidates: Candidate[];
39
+ /**
40
+ * Absent when the path was read fine. The probe carries the fact; the
41
+ * words are this module's job — the alternative was a second channel of
42
+ * error state that only the caller could see.
43
+ */
44
+ reason?: ProbeFailure["reason"];
45
+ code?: string;
46
+ }
47
+
48
+ export type Resolution =
49
+ | { kind: "resolved"; workdir: string; via: "direct" | "nested" }
50
+ | { kind: "choose"; candidates: Candidate[] }
51
+ | { kind: "none"; message: string };
52
+
53
+ export function resolveWorkdir(
54
+ dir: string,
55
+ probe: WorkdirProbe,
56
+ sep: string = hostSep,
57
+ ): Resolution {
58
+ // Checked BEFORE descending: a workdir that happens to contain its own
59
+ // .ossclip must not be skipped in favour of its children.
60
+ if (probe.isWorkdir) return { kind: "resolved", workdir: dir, via: "direct" };
61
+
62
+ const newestFirst = [...probe.candidates].sort((a, b) => b.mtimeMs - a.mtimeMs);
63
+ const only = newestFirst[0];
64
+ if (only && newestFirst.length === 1) {
65
+ return { kind: "resolved", workdir: only.path, via: "nested" };
66
+ }
67
+ if (newestFirst.length > 1) return { kind: "choose", candidates: newestFirst };
68
+
69
+ // Before the layout explanation, because "no ossclip output under X — run
70
+ // produce" is a lie in both of these cases, and it is the exact lie this
71
+ // branch exists to kill. A typo'd path was told to produce into
72
+ // `<the typo>/your-video.mp4`, a path that can never exist; an unreadable
73
+ // one was told to redo a run that is sitting right there.
74
+ if (probe.reason === "missing") {
75
+ return {
76
+ kind: "none",
77
+ message:
78
+ `no such path: ${dir}\n\n` +
79
+ ` Nothing of yours is there to edit — check the spelling.\n\n` +
80
+ ` Or pick from recent runs: ossclip edit`,
81
+ };
82
+ }
83
+ if (probe.reason === "unreadable") {
84
+ return {
85
+ kind: "none",
86
+ message:
87
+ `can't read ${dir}${probe.code === undefined ? "" : ` (${probe.code})`}\n\n` +
88
+ ` The path is there, but this user can't read it —\n` +
89
+ ` check its permissions and try again.`,
90
+ };
91
+ }
92
+
93
+ return {
94
+ kind: "none",
95
+ message:
96
+ `no ossclip output under ${dir}\n\n` +
97
+ ` produce writes into <video's folder>${sep}.ossclip${sep}<name>${sep} —\n` +
98
+ ` that nested folder is what \`edit\` wants, not the folder you ran produce in.\n\n` +
99
+ ` Produce one: ossclip produce ${dir}${sep}your-video.mp4\n` +
100
+ ` Or pick from recent runs: ossclip edit`,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The several-candidates message for a session with no TTY, where the
106
+ * interactive picker cannot run. Each line is rendered through
107
+ * renderCommand so a path containing a space pastes into a shell as ONE
108
+ * argument — an unquoted list defeats the only thing this branch is for.
109
+ */
110
+ export function candidateListMessage(dir: string, candidates: Candidate[]): string {
111
+ return (
112
+ `several produce runs under ${dir} — name one:\n` +
113
+ candidates.map((c) => ` ${renderCommand(["edit", c.path])}`).join("\n")
114
+ );
115
+ }
@@ -0,0 +1,62 @@
1
+ import { cancel, isCancel } from "@clack/prompts";
2
+
3
+ /**
4
+ * The ONE interactivity check in the codebase. Everything that might prompt
5
+ * asks this; nothing else sniffs `isTTY` directly. A second, subtly different
6
+ * check is how a CLI ends up hanging in somebody's CI waiting on an answer
7
+ * nobody can give.
8
+ */
9
+ export interface TtyDeps {
10
+ env: NodeJS.ProcessEnv;
11
+ stdinIsTty: boolean;
12
+ stdoutIsTty: boolean;
13
+ }
14
+
15
+ const liveDeps = (): TtyDeps => ({
16
+ env: process.env,
17
+ // `isTTY` is `undefined` rather than `false` on a pipe — compare explicitly.
18
+ stdinIsTty: process.stdin.isTTY === true,
19
+ stdoutIsTty: process.stdout.isTTY === true,
20
+ });
21
+
22
+ export function isInteractive(deps: TtyDeps = liveDeps()): boolean {
23
+ // Truthiness, not presence: some shells export CI= (empty) merely because
24
+ // the variable is declared, and that must not silence prompts on a real
25
+ // terminal.
26
+ if (deps.env.OSSCLIP_NO_INTERACTIVE) return false;
27
+ if (deps.env.CI) return false;
28
+ return deps.stdinIsTty && deps.stdoutIsTty;
29
+ }
30
+
31
+ /**
32
+ * Cancelling is not a failure. Ctrl-C or Esc at any prompt exits 0 with the
33
+ * same wording `ossclip setup` already uses for the same situation — never a
34
+ * stack trace, never a half-run.
35
+ */
36
+ const exitOnCancel = (): never => {
37
+ cancel("nothing changed.");
38
+ process.exit(0);
39
+ };
40
+
41
+ export function unwrap<T>(
42
+ value: T | symbol,
43
+ onCancel: () => never = exitOnCancel,
44
+ // Injected because clack's sentinel is a module-local Symbol("clack:cancel")
45
+ // that is never exported — a test cannot construct one, and Symbol.for()
46
+ // produces a look-alike with a different identity that isCancel rejects.
47
+ cancelled: (v: unknown) => boolean = isCancel,
48
+ ): T {
49
+ if (cancelled(value)) return onCancel();
50
+ return value as T;
51
+ }
52
+
53
+ /**
54
+ * Guards the prompt helpers themselves. Reaching a prompt without a TTY means
55
+ * a caller forgot to check `isInteractive()` — a programming error that should
56
+ * fail loudly in the test suite, not silently block a pipeline.
57
+ */
58
+ export function assertInteractive(what: string, check: () => boolean = () => isInteractive()): void {
59
+ if (!check()) {
60
+ throw new Error(`internal: ${what} tried to prompt without a TTY`);
61
+ }
62
+ }
@@ -0,0 +1,70 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import type { Candidate, ProbeFailure, WorkdirProbe } from "./resolve-workdir";
5
+
6
+ /** The errno of a filesystem rejection, when it has one. */
7
+ const errnoOf = (err: unknown): string | undefined => {
8
+ const code = (err as { code?: unknown } | null)?.code;
9
+ return typeof code === "string" ? code : undefined;
10
+ };
11
+
12
+ /**
13
+ * Which of the three situations a failed read was. Catching them as one is
14
+ * the founding bug of this branch in miniature: an unreadable directory got
15
+ * reported as "no ossclip output — run produce", which tells a user to redo
16
+ * work that is sitting right there.
17
+ *
18
+ * An unexpected class (ELOOP, ENOTDIR, …) is reported as unreadable rather
19
+ * than swallowed, and carries its code so the message names it instead of
20
+ * inventing a cause.
21
+ */
22
+ const failureFor = (err: unknown): ProbeFailure => {
23
+ const code = errnoOf(err);
24
+ if (code === "ENOENT") return { reason: "missing", code };
25
+ return { reason: "unreadable", code };
26
+ };
27
+
28
+ /**
29
+ * The only filesystem in the workdir ladder. Kept deliberately thin so
30
+ * `resolveWorkdir` stays pure and every rung of the decision is tested
31
+ * without a temp directory.
32
+ */
33
+ export async function probeWorkdir(target: string): Promise<{ dir: string; probe: WorkdirProbe }> {
34
+ const abs = resolve(target);
35
+
36
+ // Pointing at the video rather than its folder is a reasonable guess, and
37
+ // the runs live beside it — so a file target resolves to its parent.
38
+ let dir = abs;
39
+ try {
40
+ if ((await stat(abs)).isFile()) dir = dirname(abs);
41
+ } catch (err) {
42
+ // Not thrown: the "none" rung's message is a better error than a raw
43
+ // ENOENT — but it needs to know WHICH failure this was to say so.
44
+ return { dir: abs, probe: { isWorkdir: false, candidates: [], ...failureFor(err) } };
45
+ }
46
+
47
+ const isWorkdir = existsSync(join(dir, "render-props.json"));
48
+
49
+ const candidates: Candidate[] = [];
50
+ const nest = join(dir, ".ossclip");
51
+ let failure: ProbeFailure | undefined;
52
+ try {
53
+ for (const entry of await readdir(nest, { withFileTypes: true })) {
54
+ if (!entry.isDirectory()) continue;
55
+ const path = join(nest, entry.name);
56
+ // A workdir is exactly "a directory a produce run wrote its props
57
+ // into" — the same definition edit.ts uses. A run killed mid-flight
58
+ // leaves a directory with no props, and offering it would 404 the page.
59
+ if (!existsSync(join(path, "render-props.json"))) continue;
60
+ candidates.push({ path, mtimeMs: (await stat(path)).mtimeMs });
61
+ }
62
+ } catch (err) {
63
+ // ENOENT is the ordinary "no .ossclip here" — an empty candidate list,
64
+ // not an error. A directory that exists and cannot be read is NOT that,
65
+ // and must not be described as if it were.
66
+ if (errnoOf(err) !== "ENOENT") failure = failureFor(err);
67
+ }
68
+
69
+ return { dir, probe: { isWorkdir, candidates, ...failure } };
70
+ }
package/src/produce.ts CHANGED
@@ -88,6 +88,7 @@ import {
88
88
  type Transcript,
89
89
  } from "@ossclip/core";
90
90
  import { recordRecentProject } from "./edit";
91
+ import { editHint } from "./interactive/edit-hint";
91
92
  import { renderCover, renderProduction } from "@ossclip/renderer";
92
93
  import {
93
94
  coverTextRect,
@@ -96,6 +97,17 @@ import {
96
97
  routeAroundSourceText,
97
98
  } from "@ossclip/scenes/geometry";
98
99
 
100
+ /**
101
+ * What a finished run tells its caller. The workdir is what the post-produce
102
+ * editor offer opens; `rendered` is false for a --no-render run, which has
103
+ * props but no video.
104
+ */
105
+ export interface ProduceResult {
106
+ workdir: string;
107
+ out?: string;
108
+ rendered: boolean;
109
+ }
110
+
99
111
  export interface ProduceOptions {
100
112
  out?: string;
101
113
  cleanup: CleanupLevel;
@@ -190,7 +202,7 @@ async function preflight(bin: string, hint: string): Promise<void> {
190
202
  }
191
203
  }
192
204
 
193
- export async function produce(inputArg: string, opts: ProduceOptions): Promise<void> {
205
+ export async function produce(inputArg: string, opts: ProduceOptions): Promise<ProduceResult> {
194
206
  const cfg = loadConfig();
195
207
  const input = resolve(inputArg);
196
208
  if (!existsSync(input)) throw new Error(`input not found: ${input}`);
@@ -1392,7 +1404,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
1392
1404
 
1393
1405
  if (!opts.render) {
1394
1406
  console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
1395
- return;
1407
+ console.log(editHint(work));
1408
+ return { workdir: work, rendered: false };
1396
1409
  }
1397
1410
 
1398
1411
  const outPath = resolve(opts.out ?? input.replace(/(\.[^.]+)?$/, ".ossclip.mp4"));
@@ -1536,4 +1549,6 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
1536
1549
  // best-effort, so a read-only home dir never fails the render.
1537
1550
  await recordRecentProject(work);
1538
1551
  console.log(`✓ done → ${outPath}`);
1552
+ console.log(editHint(work));
1553
+ return { workdir: work, out: outPath, rendered: true };
1539
1554
  }