ossclip 0.1.4 → 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/editor-dist/assets/{index-B5H_dKAW.js → index-DZhkQB1i.js} +24 -24
- package/editor-dist/index.html +1 -1
- package/package.json +5 -4
- package/src/doctor.ts +32 -10
- package/src/edit.ts +12 -2
- package/src/index.ts +6 -237
- package/src/interactive/edit-hint.ts +20 -0
- package/src/interactive/menu.ts +32 -0
- package/src/interactive/offer-editor.ts +71 -0
- package/src/interactive/pick-workdir.ts +21 -0
- package/src/interactive/prefs.ts +51 -0
- package/src/interactive/produce-argv.ts +57 -0
- package/src/interactive/produce-wizard.ts +180 -0
- package/src/interactive/prompts.ts +6 -0
- package/src/interactive/render.ts +30 -0
- package/src/interactive/resolve-workdir.ts +115 -0
- package/src/interactive/tty.ts +62 -0
- package/src/interactive/workdir-probe.ts +70 -0
- package/src/open.ts +30 -0
- package/src/produce.ts +138 -16
- package/src/program.ts +401 -0
- package/src/setup/download.ts +122 -0
- package/src/setup/extract.ts +91 -0
- package/src/setup/manifest.ts +159 -0
- package/src/setup/plan.ts +211 -0
- package/src/setup/provider.ts +51 -0
- package/src/setup/setup.ts +233 -0
package/editor-dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>ossclip editor</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DZhkQB1i.js"></script>
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
10
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ossclip",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
39
|
-
"@ossclip/renderer": "0.1.
|
|
40
|
-
"@ossclip/scenes": "0.1.
|
|
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
|
@@ -10,10 +10,15 @@ import type { OssclipConfig } from "@ossclip/core";
|
|
|
10
10
|
* every one that fails silently becomes a GitHub issue instead of a
|
|
11
11
|
* one-line fix the tool could have printed itself.
|
|
12
12
|
*
|
|
13
|
+
* Since `ossclip setup` exists, doctor and setup are two halves of one
|
|
14
|
+
* contract: setup is the fix doctor prints first, and doctor is the
|
|
15
|
+
* verification setup ends with. The manual command stays on every fix line
|
|
16
|
+
* for people who'd rather own their toolchain.
|
|
17
|
+
*
|
|
13
18
|
* Checks are pure over injected probes so the table is unit-testable; the
|
|
14
19
|
* CLI wires the real spawn/existsSync in. The provider check MUST run after
|
|
15
20
|
* `loadEnvFiles` (R16 §77) or a key living in `.env` reports a false
|
|
16
|
-
* negative — `
|
|
21
|
+
* negative — `program.ts` loads env at module top, before any command runs.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
export interface DoctorCheck {
|
|
@@ -36,9 +41,18 @@ export interface DoctorProbes {
|
|
|
36
41
|
editorPageDir: string | null;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
/** brew on mac, apt
|
|
40
|
-
const installHint = (
|
|
41
|
-
platform
|
|
44
|
+
/** brew on mac, apt on linux, winget/prebuilt on windows — generic covers the rest. */
|
|
45
|
+
const installHint = (
|
|
46
|
+
platform: NodeJS.Platform,
|
|
47
|
+
brew: string,
|
|
48
|
+
apt: string,
|
|
49
|
+
win: string,
|
|
50
|
+
generic: string,
|
|
51
|
+
): string =>
|
|
52
|
+
platform === "darwin" ? brew : platform === "linux" ? apt : platform === "win32" ? win : generic;
|
|
53
|
+
|
|
54
|
+
/** Every installable prerequisite leads with the one-command ramp. */
|
|
55
|
+
const viaSetup = (manual: string): string => "run `ossclip setup` — or manually: " + manual;
|
|
42
56
|
|
|
43
57
|
export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<DoctorCheck[]> {
|
|
44
58
|
const checks: DoctorCheck[] = [];
|
|
@@ -60,13 +74,15 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
60
74
|
...(ffmpegOk
|
|
61
75
|
? {}
|
|
62
76
|
: {
|
|
63
|
-
fix:
|
|
77
|
+
fix: viaSetup(
|
|
64
78
|
installHint(
|
|
65
79
|
p.platform,
|
|
66
80
|
"brew install ffmpeg",
|
|
67
81
|
"sudo apt install ffmpeg",
|
|
82
|
+
"winget install ffmpeg",
|
|
68
83
|
"install ffmpeg from https://ffmpeg.org",
|
|
69
84
|
) + " — or point OSSCLIP_FFMPEG (or config.json ffmpegPath) at the binary",
|
|
85
|
+
),
|
|
70
86
|
}),
|
|
71
87
|
});
|
|
72
88
|
|
|
@@ -78,13 +94,15 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
78
94
|
...(ffprobeOk
|
|
79
95
|
? {}
|
|
80
96
|
: {
|
|
81
|
-
fix:
|
|
97
|
+
fix: viaSetup(
|
|
82
98
|
installHint(
|
|
83
99
|
p.platform,
|
|
84
100
|
"brew install ffmpeg (provides ffprobe)",
|
|
85
101
|
"sudo apt install ffmpeg (provides ffprobe)",
|
|
102
|
+
"winget install ffmpeg (provides ffprobe)",
|
|
86
103
|
"ffprobe ships with ffmpeg — https://ffmpeg.org",
|
|
87
104
|
) + " — or set OSSCLIP_FFPROBE",
|
|
105
|
+
),
|
|
88
106
|
}),
|
|
89
107
|
});
|
|
90
108
|
|
|
@@ -96,13 +114,15 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
96
114
|
...(whisperOk
|
|
97
115
|
? {}
|
|
98
116
|
: {
|
|
99
|
-
fix:
|
|
117
|
+
fix: viaSetup(
|
|
100
118
|
installHint(
|
|
101
119
|
p.platform,
|
|
102
120
|
"brew install whisper-cpp",
|
|
103
|
-
"
|
|
121
|
+
"download a prebuilt from https://github.com/ggml-org/whisper.cpp/releases",
|
|
122
|
+
"download whisper-blas-bin-x64.zip from https://github.com/ggml-org/whisper.cpp/releases",
|
|
104
123
|
"build whisper.cpp from source: https://github.com/ggml-org/whisper.cpp",
|
|
105
124
|
) + " — or point OSSCLIP_WHISPER at your whisper-cli",
|
|
125
|
+
),
|
|
106
126
|
}),
|
|
107
127
|
});
|
|
108
128
|
|
|
@@ -117,9 +137,10 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
117
137
|
...(modelOk
|
|
118
138
|
? {}
|
|
119
139
|
: {
|
|
120
|
-
fix:
|
|
140
|
+
fix: viaSetup(
|
|
121
141
|
`mkdir -p ${cfg.modelDir} && curl -L -o ${modelPath} ` +
|
|
122
|
-
|
|
142
|
+
`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${cfg.model}.bin`,
|
|
143
|
+
),
|
|
123
144
|
}),
|
|
124
145
|
});
|
|
125
146
|
|
|
@@ -141,6 +162,7 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
141
162
|
? {}
|
|
142
163
|
: {
|
|
143
164
|
fix:
|
|
165
|
+
"run `ossclip setup` (it can save a key for you), or " +
|
|
144
166
|
"export ANTHROPIC_API_KEY or GEMINI_API_KEY (a .env file works — see README), " +
|
|
145
167
|
"or install Claude Code (https://claude.com/claude-code) and log in",
|
|
146
168
|
}),
|
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
|
-
|
|
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,242 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
|
-
import {
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { dirname, 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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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("--no-cover", "skip the cover image written beside the video")
|
|
114
|
-
.option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
|
|
115
|
-
.action(async (input: string, opts) => {
|
|
116
|
-
// Say which keys came from a file — never the keys themselves. A run that
|
|
117
|
-
// picks a provider from a `.env` should say where that came from.
|
|
118
|
-
if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
|
|
119
|
-
const cleanup = CleanupLevelSchema.parse(opts.cleanup);
|
|
120
|
-
const provider = opts.llm
|
|
121
|
-
? z.enum(["claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
|
|
122
|
-
: undefined;
|
|
123
|
-
const forceComponent = opts.forceComponent
|
|
124
|
-
? SceneComponentIdSchema.parse(opts.forceComponent)
|
|
125
|
-
: undefined;
|
|
126
|
-
// Parsed, not coerced: a typo'd `--source-fit containn` silently falling
|
|
127
|
-
// back to cover is exactly the crop the flag exists to prevent.
|
|
128
|
-
const sourceFit = z.enum(["cover", "contain"]).parse(opts.sourceFit);
|
|
129
|
-
await produce(input, {
|
|
130
|
-
out: opts.out,
|
|
131
|
-
cleanup,
|
|
132
|
-
transcript: opts.transcript,
|
|
133
|
-
render: opts.render,
|
|
134
|
-
mezzanine: opts.mezzanine,
|
|
135
|
-
workdir: opts.workdir,
|
|
136
|
-
aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
|
|
137
|
-
noiseDb: opts.noiseDb,
|
|
138
|
-
produce: opts.produce,
|
|
139
|
-
intent: opts.intent,
|
|
140
|
-
provider,
|
|
141
|
-
llmModel: opts.llmModel,
|
|
142
|
-
llmFastModel: opts.llmFastModel,
|
|
143
|
-
speaker: opts.speaker,
|
|
144
|
-
scenes: opts.scenes,
|
|
145
|
-
repair: opts.repair,
|
|
146
|
-
whisperModel: opts.whisperModel,
|
|
147
|
-
forceComponent,
|
|
148
|
-
// commander gives `--no-cover` as cover:false and `--cover <path>` as a
|
|
149
|
-
// string on the same key.
|
|
150
|
-
sourceIsEdited: opts.sourceIsEdited === true,
|
|
151
|
-
sourceFit,
|
|
152
|
-
cover: opts.cover !== false,
|
|
153
|
-
coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
|
|
154
|
-
clip: opts.clip,
|
|
155
|
-
clipWindow: opts.clipWindow,
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
program
|
|
160
|
-
.command("transcribe")
|
|
161
|
-
.description("run the pipeline up to the transcript and cut report, no render")
|
|
162
|
-
.argument("<input>", "input video file")
|
|
163
|
-
.option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
|
|
164
|
-
.option("--transcript <path>", "inject a transcript JSON instead of running whisper")
|
|
165
|
-
.option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
|
|
166
|
-
.option("--workdir <dir>", "cache/work directory")
|
|
167
|
-
.option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
|
|
168
|
-
.action(async (input: string, opts) => {
|
|
169
|
-
const cleanup = CleanupLevelSchema.parse(opts.cleanup);
|
|
170
|
-
await produce(input, {
|
|
171
|
-
cleanup,
|
|
172
|
-
transcript: opts.transcript,
|
|
173
|
-
render: false,
|
|
174
|
-
mezzanine: false,
|
|
175
|
-
workdir: opts.workdir,
|
|
176
|
-
noiseDb: opts.noiseDb,
|
|
177
|
-
whisperModel: opts.whisperModel,
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
program
|
|
182
|
-
.command("studio")
|
|
183
|
-
.description("open Remotion Studio on a produced composition (visual debugging)")
|
|
184
|
-
.argument("<renderProps>", "path to a work dir's render-props.json")
|
|
185
|
-
.option("--video-dir <dir>", "directory containing the source video (public dir)")
|
|
186
|
-
.action(async (renderProps: string, opts) => {
|
|
187
|
-
const propsPath = resolve(renderProps);
|
|
188
|
-
const publicDir = opts.videoDir ? resolve(opts.videoDir) : dirname(propsPath);
|
|
189
|
-
const child = spawn(
|
|
190
|
-
"pnpm",
|
|
191
|
-
["exec", "remotion", "studio", STUDIO_ENTRY, `--props=${propsPath}`, `--public-dir=${publicDir}`],
|
|
192
|
-
{ stdio: "inherit" },
|
|
193
|
-
);
|
|
194
|
-
child.on("exit", (code) => process.exit(code ?? 0));
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
program
|
|
198
|
-
.command("edit")
|
|
199
|
-
.description("open the editing page on a produced workdir")
|
|
200
|
-
// OPTIONAL since R17 §83: with no argument the editor opens on a project
|
|
201
|
-
// picker — recent produce runs plus a folder browser — and the top bar's
|
|
202
|
-
// Open button switches projects without restarting the server.
|
|
203
|
-
.argument("[workdir]", "a work directory containing render-props.json")
|
|
204
|
-
.option("--port <n>", "port to listen on", (v) => Number.parseInt(v, 10), 5174)
|
|
205
|
-
.option("--no-open", "do not open a browser")
|
|
206
|
-
.action(async (workdir: string | undefined, opts) => {
|
|
207
|
-
const { startEditServer, resolveEditorPageDir } = await import("./edit");
|
|
208
|
-
// An npm install ships the page prebuilt (editor-dist/); a clone builds
|
|
209
|
-
// it once with `pnpm build`. A server that starts fine but 404s every
|
|
210
|
-
// page request is the worst version of missing — fail loudly with the
|
|
211
|
-
// fix instead.
|
|
212
|
-
const pageDir = resolveEditorPageDir();
|
|
213
|
-
if (pageDir === null) {
|
|
214
|
-
throw new Error(
|
|
215
|
-
"editor UI isn't built yet — run `pnpm build` " +
|
|
216
|
-
"(or `pnpm --filter @ossclip/editor build`) once, then re-run `ossclip edit`.",
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
const server = await startEditServer(workdir, { port: opts.port, pageDir });
|
|
220
|
-
console.log(`▸ editor at ${server.url}`);
|
|
221
|
-
if (opts.open) spawn("open", [server.url], { stdio: "ignore" });
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
program
|
|
225
|
-
.command("doctor")
|
|
226
|
-
.description("check every prerequisite and print the exact fix for anything missing")
|
|
227
|
-
.action(async () => {
|
|
228
|
-
// Env files are loaded at module top (R16 §77) — BEFORE this runs — so a
|
|
229
|
-
// provider key living in a `.env` is visible here, not a false negative.
|
|
230
|
-
if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
|
|
231
|
-
const { runDoctor, formatDoctor, realProbes } = await import("./doctor");
|
|
232
|
-
const { resolveEditorPageDir } = await import("./edit");
|
|
233
|
-
const { loadConfig } = await import("@ossclip/core");
|
|
234
|
-
const checks = await runDoctor(loadConfig(), realProbes(resolveEditorPageDir()));
|
|
235
|
-
console.log(formatDoctor(checks));
|
|
236
|
-
if (checks.some((c) => !c.ok)) process.exit(1);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
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) => {
|
|
240
9
|
console.error(`\n✗ ${err instanceof Error ? err.message : err}`);
|
|
241
10
|
process.exit(1);
|
|
242
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
|
+
}
|