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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,12 +32,13 @@
32
32
  "editor-dist"
33
33
  ],
34
34
  "dependencies": {
35
+ "@clack/prompts": "^1.7.0",
35
36
  "commander": "^12.1.0",
36
37
  "tsx": "^4.19.0",
37
38
  "zod": "^3.25.76",
38
- "@ossclip/core": "0.1.5",
39
- "@ossclip/scenes": "0.1.5",
40
- "@ossclip/renderer": "0.1.5"
39
+ "@ossclip/core": "0.1.6",
40
+ "@ossclip/renderer": "0.1.6",
41
+ "@ossclip/scenes": "0.1.6"
41
42
  },
42
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
43
44
  "bugs": {
package/src/doctor.ts CHANGED
@@ -18,7 +18,7 @@ import type { OssclipConfig } from "@ossclip/core";
18
18
  * Checks are pure over injected probes so the table is unit-testable; the
19
19
  * CLI wires the real spawn/existsSync in. The provider check MUST run after
20
20
  * `loadEnvFiles` (R16 §77) or a key living in `.env` reports a false
21
- * negative — `index.ts` loads env at module top, before any command runs.
21
+ * negative — `program.ts` loads env at module top, before any command runs.
22
22
  */
23
23
 
24
24
  export interface DoctorCheck {
package/src/edit.ts CHANGED
@@ -3,7 +3,7 @@ import { createReadStream, existsSync, statSync } from "node:fs";
3
3
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
4
4
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
5
5
  import { homedir } from "node:os";
6
- import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { z } from "zod/v4";
9
9
  import { OverrideDocSchema, emptyOverrideDoc } from "@ossclip/core";
@@ -194,7 +194,17 @@ export async function startEditServer(
194
194
  const openWorkdir = async (dirArg: string): Promise<void> => {
195
195
  const dir = resolve(dirArg);
196
196
  if (!isWorkdir(dir)) {
197
- throw new Error(`no render-props.json in ${dir} — run \`ossclip produce\` there first`);
197
+ // Not "run produce there first" — the reported failure said that to a
198
+ // user who HAD, because produce writes one level down into
199
+ // .ossclip/<name>/ and this wanted that nested directory.
200
+ // The layout is spelled with the host separator, as resolve-workdir.ts
201
+ // does: hardcoded forward slashes here meant a Windows user met both
202
+ // conventions from one product depending on which entry point they hit.
203
+ throw new Error(
204
+ `no render-props.json in ${dir} — produce writes into ` +
205
+ `<video's folder>${sep}.ossclip${sep}<name>${sep}, and that nested folder ` +
206
+ `is what edit opens`,
207
+ );
198
208
  }
199
209
  workdir = dir;
200
210
  await recordRecentProject(dir, opts.recentDir);
package/src/index.ts CHANGED
@@ -1,294 +1,11 @@
1
1
  #!/usr/bin/env tsx
2
- import { spawn } from "node:child_process";
3
- import { readFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
5
- import { Command, InvalidArgumentError } from "commander";
6
- import { z } from "zod/v4";
7
- import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
8
- import { STUDIO_ENTRY } from "@ossclip/renderer";
9
- import { loadEnvFiles } from "./env";
10
- import { produce } from "./produce";
2
+ import { buildProgram } from "./program";
11
3
 
12
- // Before anything reads a provider key (R16 §77) — including the auto-detect
13
- // order in `defaultProviderName`, which decides which model runs.
14
- const envFiles = loadEnvFiles();
15
-
16
- const program = new Command();
17
-
18
- program
19
- .name("ossclip")
20
- .description(
21
- "local-first video producer: cuts silence and fillers, word-timed captions, " +
22
- "face-aware framing, LLM-planned code-rendered graphics",
23
- )
24
- // Read from the manifest, never hardcoded (R22 §113): a literal here said
25
- // "0.1.0" for every release after it, so `--version` reported the number a
26
- // developer typed rather than the one npm installed — the exact field a
27
- // bug report is judged by. npm always packs package.json regardless of
28
- // `files`, so this resolves in a published install too.
29
- .version(
30
- (
31
- JSON.parse(
32
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
33
- ) as { version: string }
34
- ).version,
35
- );
36
-
37
- program
38
- .command("produce")
39
- .description("transcribe → analyze → cut → captions → render")
40
- .argument("<input>", "input video file")
41
- .option("-o, --out <path>", "output video path (default: <input>.ossclip.mp4)")
42
- .option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
43
- .option("--transcript <path>", "inject a transcript JSON instead of running whisper")
44
- .option("--no-render", "stop after writing production.json / render props")
45
- .option(
46
- "--no-mezzanine",
47
- "render straight from the source instead of a dense-keyframe mezzanine " +
48
- "(also makes the source's folder the render server's public dir)",
49
- )
50
- .option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
51
- .option("--workdir <dir>", "cache/work directory (default: <input dir>/.ossclip)")
52
- .option(
53
- "--aspect <ratio>",
54
- "output shape: 9:16 (vertical, default) or 16:9 (landscape, 1920x1080)",
55
- "9:16",
56
- )
57
- .option("--produce", "run the LLM producer brain to plan title cards & graphics", false)
58
- .option(
59
- "--clip <seconds>",
60
- "produce only the strongest ~N-second window of a long take (requires --produce; " +
61
- "a source already at or under the target is produced whole)",
62
- (v: string) => {
63
- // §93a: reject rather than coerce — `--clip 0`, negatives and typos must
64
- // not silently become "no clip" or NaN-length windows.
65
- const n = Number.parseFloat(v);
66
- if (!Number.isFinite(n) || n <= 0) {
67
- throw new InvalidArgumentError(`--clip wants a positive number of seconds, got "${v}"`);
68
- }
69
- return n;
70
- },
71
- )
72
- .option(
73
- "--clip-window <start:end>",
74
- "internal: the resolved highlight's word range, recorded into command.json by --clip " +
75
- "runs so the editor's Render replays the same window without an LLM call",
76
- )
77
- .option("--intent <text>", "what the video should be ('educational video about agents…')")
78
- .option(
79
- "--llm <provider>",
80
- "claude | claude-cli | gemini | mock. Default: claude if ANTHROPIC_API_KEY is set, " +
81
- "else claude-cli (your logged-in Claude Code — Pro/Max subscription, no API charges)",
82
- )
83
- .option("--llm-model <id>", "override the provider's default model")
84
- .option(
85
- "--llm-fast-model <id>",
86
- "model for mechanical calls (repair, scene props); 'same' disables tiering",
87
- )
88
- .option(
89
- "--speaker <who>",
90
- 'who is on camera, e.g. "Ahsan, host of Code with Ahsan" — helps repair recognise mangled names',
91
- )
92
- .option("--scenes <path>", "hand-authored scenes JSON (Scene[]) — no LLM in the loop")
93
- .option(
94
- "--no-repair",
95
- "skip the ASR mishearing repair pass (captions then show the raw transcription)",
96
- )
97
- .option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
98
- .option(
99
- "--force-component <id>",
100
- "debug: render every graphic with this component (e.g. FlowDiagram) to exercise it on real copy",
101
- )
102
- .option(
103
- "--source-fit <mode>",
104
- "cover | contain. cover (default) crops the source to fill the vertical " +
105
- "frame; contain shows the WHOLE frame inset against the backdrop — the " +
106
- "answer for a landscape take whose content matters beyond the speaker",
107
- "cover",
108
- )
109
- .option(
110
- "--source-is-edited",
111
- "the source is already an edited reel with burned-in text — keep ossclip's graphics off it without waiting on detection",
112
- )
113
- .option(
114
- "--blooper-marker <word>",
115
- "cut the flubbed take whenever you say this word out loud (e.g. blooper): " +
116
- "removal runs back to the start of the sentence it spoiled. Off unless given",
117
- )
118
- .option("--no-cover", "skip the cover image written beside the video")
119
- .option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
120
- .action(async (input: string, opts) => {
121
- // Say which keys came from a file — never the keys themselves. A run that
122
- // picks a provider from a `.env` should say where that came from.
123
- if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
124
- const cleanup = CleanupLevelSchema.parse(opts.cleanup);
125
- const provider = opts.llm
126
- ? z.enum(["claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
127
- : undefined;
128
- const forceComponent = opts.forceComponent
129
- ? SceneComponentIdSchema.parse(opts.forceComponent)
130
- : undefined;
131
- // Parsed, not coerced: a typo'd `--source-fit containn` silently falling
132
- // back to cover is exactly the crop the flag exists to prevent.
133
- const sourceFit = z.enum(["cover", "contain"]).parse(opts.sourceFit);
134
- await produce(input, {
135
- out: opts.out,
136
- cleanup,
137
- transcript: opts.transcript,
138
- render: opts.render,
139
- mezzanine: opts.mezzanine,
140
- workdir: opts.workdir,
141
- aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
142
- noiseDb: opts.noiseDb,
143
- produce: opts.produce,
144
- intent: opts.intent,
145
- provider,
146
- llmModel: opts.llmModel,
147
- llmFastModel: opts.llmFastModel,
148
- speaker: opts.speaker,
149
- scenes: opts.scenes,
150
- repair: opts.repair,
151
- whisperModel: opts.whisperModel,
152
- forceComponent,
153
- // commander gives `--no-cover` as cover:false and `--cover <path>` as a
154
- // string on the same key.
155
- sourceIsEdited: opts.sourceIsEdited === true,
156
- blooperMarker: opts.blooperMarker,
157
- sourceFit,
158
- cover: opts.cover !== false,
159
- coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
160
- clip: opts.clip,
161
- clipWindow: opts.clipWindow,
162
- });
163
- });
164
-
165
- program
166
- .command("transcribe")
167
- .description("run the pipeline up to the transcript and cut report, no render")
168
- .argument("<input>", "input video file")
169
- .option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
170
- .option("--transcript <path>", "inject a transcript JSON instead of running whisper")
171
- .option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
172
- .option("--workdir <dir>", "cache/work directory")
173
- .option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
174
- .action(async (input: string, opts) => {
175
- const cleanup = CleanupLevelSchema.parse(opts.cleanup);
176
- await produce(input, {
177
- cleanup,
178
- transcript: opts.transcript,
179
- render: false,
180
- mezzanine: false,
181
- workdir: opts.workdir,
182
- noiseDb: opts.noiseDb,
183
- whisperModel: opts.whisperModel,
184
- });
185
- });
186
-
187
- program
188
- .command("studio")
189
- .description("open Remotion Studio on a produced composition (visual debugging)")
190
- .argument("<renderProps>", "path to a work dir's render-props.json")
191
- .option("--video-dir <dir>", "directory containing the source video (public dir)")
192
- .action(async (renderProps: string, opts) => {
193
- const propsPath = resolve(renderProps);
194
- const publicDir = opts.videoDir ? resolve(opts.videoDir) : dirname(propsPath);
195
- // Resolve Remotion's CLI through module resolution instead of spawning
196
- // `pnpm` — a global `npm i -g ossclip` has no pnpm and no workspace, and
197
- // Windows would need the .cmd shim. `@remotion/cli` is a dependency of
198
- // @ossclip/renderer, so resolving from THERE works in both a clone and a
199
- // published install, on every OS, run via the node that's running us.
200
- const { createRequire } = await import("node:module");
201
- let remotionCliJs: string;
202
- try {
203
- const require = createRequire(import.meta.url);
204
- const rendererDir = dirname(require.resolve("@ossclip/renderer/package.json"));
205
- const fromRenderer = createRequire(join(rendererDir, "package.json"));
206
- const cliPkgPath = fromRenderer.resolve("@remotion/cli/package.json");
207
- const cliPkg = JSON.parse(readFileSync(cliPkgPath, "utf8")) as {
208
- bin: string | Record<string, string>;
209
- };
210
- const binRel = typeof cliPkg.bin === "string" ? cliPkg.bin : cliPkg.bin.remotion;
211
- if (!binRel) throw new Error("no remotion bin entry");
212
- remotionCliJs = join(dirname(cliPkgPath), binRel);
213
- } catch {
214
- throw new Error(
215
- "couldn't resolve @remotion/cli — in a clone, run `pnpm install` first",
216
- );
217
- }
218
- const child = spawn(
219
- process.execPath,
220
- [remotionCliJs, "studio", STUDIO_ENTRY, `--props=${propsPath}`, `--public-dir=${publicDir}`],
221
- { stdio: "inherit" },
222
- );
223
- child.on("error", (e) => {
224
- console.error(`✗ failed to start Remotion Studio: ${e.message}`);
225
- process.exit(1);
226
- });
227
- child.on("exit", (code) => process.exit(code ?? 0));
228
- });
229
-
230
- program
231
- .command("edit")
232
- .description("open the editing page on a produced workdir")
233
- // OPTIONAL since R17 §83: with no argument the editor opens on a project
234
- // picker — recent produce runs plus a folder browser — and the top bar's
235
- // Open button switches projects without restarting the server.
236
- .argument("[workdir]", "a work directory containing render-props.json")
237
- .option("--port <n>", "port to listen on", (v) => Number.parseInt(v, 10), 5174)
238
- .option("--no-open", "do not open a browser")
239
- .action(async (workdir: string | undefined, opts) => {
240
- const { startEditServer, resolveEditorPageDir } = await import("./edit");
241
- // An npm install ships the page prebuilt (editor-dist/); a clone builds
242
- // it once with `pnpm build`. A server that starts fine but 404s every
243
- // page request is the worst version of missing — fail loudly with the
244
- // fix instead.
245
- const pageDir = resolveEditorPageDir();
246
- if (pageDir === null) {
247
- throw new Error(
248
- "editor UI isn't built yet — run `pnpm build` " +
249
- "(or `pnpm --filter @ossclip/editor build`) once, then re-run `ossclip edit`.",
250
- );
251
- }
252
- const server = await startEditServer(workdir, { port: opts.port, pageDir });
253
- console.log(`▸ editor at ${server.url}`);
254
- if (opts.open) {
255
- const { openInBrowser } = await import("./open");
256
- openInBrowser(server.url);
257
- }
258
- });
259
-
260
- program
261
- .command("setup")
262
- .description(
263
- "install everything ossclip needs (ffmpeg, whisper.cpp, the transcription model) " +
264
- "into ~/.ossclip — the one-command onboarding on macOS, Linux, and Windows",
265
- )
266
- .option("--model <name>", "transcription model to download (default: config, i.e. small.en)")
267
- .option("--skip-llm", "don't ask about an LLM provider (only --produce needs one)", false)
268
- .option("--force", "re-download the pieces setup manages, even if present", false)
269
- .option("-y, --yes", "no questions — accept the plan and skip the provider prompt", false)
270
- .action(async (opts) => {
271
- if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
272
- const { setup } = await import("./setup/setup");
273
- await setup({ model: opts.model, skipLlm: opts.skipLlm, force: opts.force, yes: opts.yes });
274
- });
275
-
276
- program
277
- .command("doctor")
278
- .description("check every prerequisite and print the exact fix for anything missing")
279
- .action(async () => {
280
- // Env files are loaded at module top (R16 §77) — BEFORE this runs — so a
281
- // provider key living in a `.env` is visible here, not a false negative.
282
- if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
283
- const { runDoctor, formatDoctor, realProbes } = await import("./doctor");
284
- const { resolveEditorPageDir } = await import("./edit");
285
- const { loadConfig } = await import("@ossclip/core");
286
- const checks = await runDoctor(loadConfig(), realProbes(resolveEditorPageDir()));
287
- console.log(formatDoctor(checks));
288
- if (checks.some((c) => !c.ok)) process.exit(1);
289
- });
290
-
291
- program.parseAsync().catch((err) => {
4
+ // The side effect (loadEnvFiles, R16 §77) stays at import time inside
5
+ // program.ts: bin/ossclip.mjs imports this module and expects it to run, and
6
+ // this file's first statement importing program.ts is what preserves that
7
+ // ordering — before anything reads a provider key.
8
+ buildProgram().parseAsync().catch((err) => {
292
9
  console.error(`\n✗ ${err instanceof Error ? err.message : err}`);
293
10
  process.exit(1);
294
11
  });
@@ -0,0 +1,20 @@
1
+ import { renderCommand } from "./render";
2
+
3
+ /**
4
+ * The closing signpost of a produce run.
5
+ *
6
+ * `▸ workdir <path>` is printed at the START of a run, which after six
7
+ * minutes of transcription and rendering is thousands of lines up the
8
+ * scrollback. The reported user had the written guide open and still could
9
+ * not find the directory, because the last thing on screen named neither it
10
+ * nor the command that opens it.
11
+ */
12
+ export function editHint(workdir: string, platform: NodeJS.Platform = process.platform): string {
13
+ // The platform is a parameter, defaulted to the host, for the same reason
14
+ // resolveWorkdir takes `sep`: the Windows rendering has to be assertable
15
+ // from a macOS dev machine and an ubuntu CI leg. It was previously PINNED
16
+ // to "linux", which handed a Windows user under `D:\My Videos\` POSIX
17
+ // single quotes that cmd.exe passes through literally — the branch's
18
+ // headline artifact, broken on the platform the bug report came from.
19
+ return `▸ edit it: ${renderCommand(["edit", workdir], platform)}`;
20
+ }
@@ -0,0 +1,32 @@
1
+ import { assertInteractive, intro, select, unwrap } from "./prompts";
2
+
3
+ export type MenuChoice = "produce" | "edit" | "setup" | "doctor";
4
+
5
+ /**
6
+ * What each menu entry runs. Produce is the exception — it needs answers
7
+ * before it has an argv, so it returns null and the caller hands off to the
8
+ * wizard.
9
+ */
10
+ export function menuArgv(choice: MenuChoice): string[] | null {
11
+ if (choice === "produce") return null;
12
+ // Edit with NO argument is deliberate: that is the project picker over
13
+ // recent runs (R17 §83), which is exactly what somebody who reached a menu
14
+ // instead of typing a command needs.
15
+ return [choice];
16
+ }
17
+
18
+ export async function chooseFromMenu(): Promise<MenuChoice> {
19
+ assertInteractive("main menu");
20
+ intro("ossclip");
21
+ return unwrap(
22
+ await select({
23
+ message: "What do you want to do?",
24
+ options: [
25
+ { value: "produce", label: "Produce a video", hint: "cut, caption, frame, render" },
26
+ { value: "edit", label: "Edit a produced project", hint: "pick from recent runs" },
27
+ { value: "setup", label: "Set up my install", hint: "ffmpeg, whisper, the model" },
28
+ { value: "doctor", label: "Check what's missing" },
29
+ ],
30
+ }),
31
+ ) as MenuChoice;
32
+ }
@@ -0,0 +1,71 @@
1
+ import { loadConfig, saveConfigPatch, type OpenEditorPref } from "@ossclip/core";
2
+ import type { ProduceResult } from "../produce";
3
+ import { answerToDecision, decideOpenEditor, type OpenEditorAnswer } from "./prefs";
4
+ import { isInteractive, select, unwrap } from "./prompts";
5
+ import { renderCommand } from "./render";
6
+
7
+ /**
8
+ * The offer at the end of a produce run. The user who prompted this work
9
+ * asked "how can I open the editor?" BEFORE running anything — the answer
10
+ * belongs at the moment there is finally something to open.
11
+ */
12
+ export async function offerEditor(
13
+ result: ProduceResult,
14
+ opts: { flag: boolean | undefined; port: number },
15
+ ): Promise<void> {
16
+ const pref: OpenEditorPref = loadConfig().openEditorAfterProduce ?? "ask";
17
+ const decision = decideOpenEditor({
18
+ flag: opts.flag,
19
+ pref,
20
+ interactive: isInteractive(),
21
+ rendered: result.rendered,
22
+ });
23
+
24
+ if (decision === "skip") return;
25
+
26
+ let open = decision === "open";
27
+ if (decision === "ask") {
28
+ const answer = unwrap(
29
+ await select({
30
+ message: "Open the editor on this project?",
31
+ options: [
32
+ { value: "yes", label: "Yes" },
33
+ { value: "no", label: "No" },
34
+ { value: "always", label: "Yes, and stop asking" },
35
+ { value: "never", label: "No, and stop asking" },
36
+ ],
37
+ }),
38
+ ) as OpenEditorAnswer;
39
+
40
+ // The mapping itself lives in prefs.ts, where four answers are asserted
41
+ // without a TTY — this file is I/O and a manual walk was its only cover.
42
+ const decided = answerToDecision(answer);
43
+ if (decided.pref !== undefined) {
44
+ const path = saveConfigPatch({ openEditorAfterProduce: decided.pref });
45
+ // Say where the answer went, and how to take it back — a preference
46
+ // saved silently is one the user cannot find again.
47
+ console.log(`▸ saved openEditorAfterProduce="${decided.pref}" to ${path}`);
48
+ }
49
+ open = decided.open;
50
+ }
51
+
52
+ if (!open) return;
53
+
54
+ const { startEditServer, resolveEditorPageDir } = await import("../edit");
55
+ const pageDir = resolveEditorPageDir();
56
+ if (pageDir === null) {
57
+ // Not fatal here: the render succeeded. Say what is missing and stop.
58
+ // Through renderCommand like every other `ossclip edit <path>` we print:
59
+ // hand-built, this one quoted nothing, so a workdir with a space in it
60
+ // printed a command that fails.
61
+ console.log(
62
+ "▸ editor UI isn't built — run `pnpm build` once, then " +
63
+ renderCommand(["edit", result.workdir]),
64
+ );
65
+ return;
66
+ }
67
+ const server = await startEditServer(result.workdir, { port: opts.port, pageDir });
68
+ console.log(`▸ editor at ${server.url}`);
69
+ const { openInBrowser } = await import("../open");
70
+ openInBrowser(server.url);
71
+ }
@@ -0,0 +1,21 @@
1
+ import { basename } from "node:path";
2
+ import type { Candidate } from "./resolve-workdir";
3
+ import { assertInteractive, select, unwrap } from "./prompts";
4
+
5
+ /**
6
+ * The "several runs under .ossclip" rung. Newest first is already guaranteed
7
+ * by resolveWorkdir; this only renders the choice.
8
+ */
9
+ export async function pickWorkdir(candidates: Candidate[]): Promise<string> {
10
+ assertInteractive("workdir picker");
11
+ return unwrap(
12
+ await select({
13
+ message: "Several produce runs here — which one?",
14
+ options: candidates.map((c, i) => ({
15
+ value: c.path,
16
+ label: basename(c.path),
17
+ hint: i === 0 ? "most recent" : undefined,
18
+ })),
19
+ }),
20
+ ) as string;
21
+ }
@@ -0,0 +1,51 @@
1
+ import type { OpenEditorPref } from "@ossclip/core";
2
+
3
+ export type OpenEditorDecision = "open" | "skip" | "ask";
4
+
5
+ /**
6
+ * Whether a finished produce run opens the editor, asks, or says nothing.
7
+ *
8
+ * Pure so the whole precedence order is tested without a produce run: flags
9
+ * beat the stored preference, the stored preference beats asking, and no TTY
10
+ * means never ask.
11
+ */
12
+ export function decideOpenEditor(i: {
13
+ flag: boolean | undefined;
14
+ pref: OpenEditorPref;
15
+ interactive: boolean;
16
+ rendered: boolean;
17
+ }): OpenEditorDecision {
18
+ // An explicit flag is a deliberate instruction and wins outright — including
19
+ // over `rendered`, because the editor reads render-props.json, which a
20
+ // --no-render run does write.
21
+ if (i.flag === true) return "open";
22
+ if (i.flag === false) return "skip";
23
+ // Above the stored preference, not below it: a persisted "always" (or
24
+ // OSSCLIP_OPEN_EDITOR=always) is not a per-run instruction, and starting a
25
+ // long-lived edit server in `ossclip produce take.mp4 > build.log 2>&1`
26
+ // holds the event loop open with nobody there to see it or close it. Only
27
+ // the explicit flag above may do that.
28
+ if (!i.interactive) return "skip";
29
+ // Otherwise a run with no render has nothing to look at, so the offer is noise.
30
+ if (!i.rendered) return "skip";
31
+ if (i.pref === "always") return "open";
32
+ if (i.pref === "never") return "skip";
33
+ return "ask";
34
+ }
35
+
36
+ export type OpenEditorAnswer = "yes" | "no" | "always" | "never";
37
+
38
+ /**
39
+ * What each answer to the end-of-run offer means: whether to open now, and
40
+ * the preference to persist if the answer was one of the two that stop the
41
+ * asking. Pure so all four are asserted without a prompt — the interactive
42
+ * path is then only the I/O around it.
43
+ */
44
+ export function answerToDecision(answer: OpenEditorAnswer): {
45
+ pref?: OpenEditorPref;
46
+ open: boolean;
47
+ } {
48
+ const open = answer === "yes" || answer === "always";
49
+ if (answer === "always" || answer === "never") return { pref: answer, open };
50
+ return { open };
51
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Wizard answers → the argv a user could have typed.
3
+ *
4
+ * This is the load-bearing shape of the whole interactive layer: the wizard
5
+ * produces ARGUMENTS, not a ProduceOptions, so the zod parses in program.ts
6
+ * stay the only validation path and the printed command is the executed one.
7
+ */
8
+
9
+ export interface ProduceExtras {
10
+ clip?: number;
11
+ sourceFit?: "cover" | "contain";
12
+ speaker?: string;
13
+ whisperModel?: string;
14
+ blooperMarker?: string;
15
+ sourceIsEdited?: boolean;
16
+ llm?: "claude" | "claude-cli" | "gemini" | "mock";
17
+ }
18
+
19
+ export interface ProduceAnswers {
20
+ input: string;
21
+ aspect: "9:16" | "16:9";
22
+ cleanup: "exact" | "light" | "standard" | "aggressive";
23
+ graphics: boolean;
24
+ intent?: string;
25
+ out?: string;
26
+ extras: ProduceExtras;
27
+ }
28
+
29
+ export function produceArgv(a: ProduceAnswers): string[] {
30
+ const argv = ["produce", a.input];
31
+
32
+ // A flag whose value equals the default is NEVER emitted. A wizard run
33
+ // where every answer was the default must teach `ossclip produce <file>`
34
+ // and nothing more — anything longer becomes a command line the user
35
+ // copies forever without knowing which parts mattered.
36
+ if (a.aspect !== "9:16") argv.push("--aspect", a.aspect);
37
+ if (a.cleanup !== "standard") argv.push("--cleanup", a.cleanup);
38
+ if (a.out) argv.push("--out", a.out);
39
+
40
+ if (a.graphics) {
41
+ argv.push("--produce");
42
+ // Intent feeds the producer brain, which only runs under --produce —
43
+ // emitting it alone would be a flag with nothing to act on.
44
+ if (a.intent) argv.push("--intent", a.intent);
45
+ }
46
+
47
+ const e = a.extras;
48
+ if (e.clip !== undefined) argv.push("--clip", String(e.clip));
49
+ if (e.sourceFit === "contain") argv.push("--source-fit", "contain");
50
+ if (e.speaker) argv.push("--speaker", e.speaker);
51
+ if (e.whisperModel) argv.push("--whisper-model", e.whisperModel);
52
+ if (e.blooperMarker) argv.push("--blooper-marker", e.blooperMarker);
53
+ if (e.sourceIsEdited === true) argv.push("--source-is-edited");
54
+ if (e.llm) argv.push("--llm", e.llm);
55
+
56
+ return argv;
57
+ }