ossclip 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env tsx
2
+ import { spawn } from "node:child_process";
3
+ import { dirname, resolve } from "node:path";
4
+ import { Command, InvalidArgumentError } from "commander";
5
+ import { z } from "zod/v4";
6
+ import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
7
+ import { STUDIO_ENTRY } from "@ossclip/renderer";
8
+ import { loadEnvFiles } from "./env";
9
+ import { produce } from "./produce";
10
+
11
+ // Before anything reads a provider key (R16 §77) — including the auto-detect
12
+ // order in `defaultProviderName`, which decides which model runs.
13
+ const envFiles = loadEnvFiles();
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name("ossclip")
19
+ .description(
20
+ "local-first video producer: cuts silence and fillers, word-timed captions, " +
21
+ "face-aware framing, LLM-planned code-rendered graphics",
22
+ )
23
+ .version("0.1.0");
24
+
25
+ program
26
+ .command("produce")
27
+ .description("transcribe → analyze → cut → captions → render")
28
+ .argument("<input>", "input video file")
29
+ .option("-o, --out <path>", "output video path (default: <input>.ossclip.mp4)")
30
+ .option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
31
+ .option("--transcript <path>", "inject a transcript JSON instead of running whisper")
32
+ .option("--no-render", "stop after writing production.json / render props")
33
+ .option(
34
+ "--no-mezzanine",
35
+ "render straight from the source instead of a dense-keyframe mezzanine " +
36
+ "(also makes the source's folder the render server's public dir)",
37
+ )
38
+ .option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
39
+ .option("--workdir <dir>", "cache/work directory (default: <input dir>/.ossclip)")
40
+ .option(
41
+ "--aspect <ratio>",
42
+ "output shape: 9:16 (vertical, default) or 16:9 (landscape, 1920x1080)",
43
+ "9:16",
44
+ )
45
+ .option("--produce", "run the LLM producer brain to plan title cards & graphics", false)
46
+ .option(
47
+ "--clip <seconds>",
48
+ "produce only the strongest ~N-second window of a long take (requires --produce; " +
49
+ "a source already at or under the target is produced whole)",
50
+ (v: string) => {
51
+ // §93a: reject rather than coerce — `--clip 0`, negatives and typos must
52
+ // not silently become "no clip" or NaN-length windows.
53
+ const n = Number.parseFloat(v);
54
+ if (!Number.isFinite(n) || n <= 0) {
55
+ throw new InvalidArgumentError(`--clip wants a positive number of seconds, got "${v}"`);
56
+ }
57
+ return n;
58
+ },
59
+ )
60
+ .option(
61
+ "--clip-window <start:end>",
62
+ "internal: the resolved highlight's word range, recorded into command.json by --clip " +
63
+ "runs so the editor's Render replays the same window without an LLM call",
64
+ )
65
+ .option("--intent <text>", "what the video should be ('educational video about agents…')")
66
+ .option(
67
+ "--llm <provider>",
68
+ "claude | claude-cli | gemini | mock. Default: claude if ANTHROPIC_API_KEY is set, " +
69
+ "else claude-cli (your logged-in Claude Code — Pro/Max subscription, no API charges)",
70
+ )
71
+ .option("--llm-model <id>", "override the provider's default model")
72
+ .option(
73
+ "--llm-fast-model <id>",
74
+ "model for mechanical calls (repair, scene props); 'same' disables tiering",
75
+ )
76
+ .option(
77
+ "--speaker <who>",
78
+ 'who is on camera, e.g. "Ahsan, host of Code with Ahsan" — helps repair recognise mangled names',
79
+ )
80
+ .option("--scenes <path>", "hand-authored scenes JSON (Scene[]) — no LLM in the loop")
81
+ .option(
82
+ "--no-repair",
83
+ "skip the ASR mishearing repair pass (captions then show the raw transcription)",
84
+ )
85
+ .option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
86
+ .option(
87
+ "--force-component <id>",
88
+ "debug: render every graphic with this component (e.g. FlowDiagram) to exercise it on real copy",
89
+ )
90
+ .option(
91
+ "--source-fit <mode>",
92
+ "cover | contain. cover (default) crops the source to fill the vertical " +
93
+ "frame; contain shows the WHOLE frame inset against the backdrop — the " +
94
+ "answer for a landscape take whose content matters beyond the speaker",
95
+ "cover",
96
+ )
97
+ .option(
98
+ "--source-is-edited",
99
+ "the source is already an edited reel with burned-in text — keep ossclip's graphics off it without waiting on detection",
100
+ )
101
+ .option("--no-cover", "skip the cover image written beside the video")
102
+ .option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
103
+ .action(async (input: string, opts) => {
104
+ // Say which keys came from a file — never the keys themselves. A run that
105
+ // picks a provider from a `.env` should say where that came from.
106
+ if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
107
+ const cleanup = CleanupLevelSchema.parse(opts.cleanup);
108
+ const provider = opts.llm
109
+ ? z.enum(["claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
110
+ : undefined;
111
+ const forceComponent = opts.forceComponent
112
+ ? SceneComponentIdSchema.parse(opts.forceComponent)
113
+ : undefined;
114
+ // Parsed, not coerced: a typo'd `--source-fit containn` silently falling
115
+ // back to cover is exactly the crop the flag exists to prevent.
116
+ const sourceFit = z.enum(["cover", "contain"]).parse(opts.sourceFit);
117
+ await produce(input, {
118
+ out: opts.out,
119
+ cleanup,
120
+ transcript: opts.transcript,
121
+ render: opts.render,
122
+ mezzanine: opts.mezzanine,
123
+ workdir: opts.workdir,
124
+ aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
125
+ noiseDb: opts.noiseDb,
126
+ produce: opts.produce,
127
+ intent: opts.intent,
128
+ provider,
129
+ llmModel: opts.llmModel,
130
+ llmFastModel: opts.llmFastModel,
131
+ speaker: opts.speaker,
132
+ scenes: opts.scenes,
133
+ repair: opts.repair,
134
+ whisperModel: opts.whisperModel,
135
+ forceComponent,
136
+ // commander gives `--no-cover` as cover:false and `--cover <path>` as a
137
+ // string on the same key.
138
+ sourceIsEdited: opts.sourceIsEdited === true,
139
+ sourceFit,
140
+ cover: opts.cover !== false,
141
+ coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
142
+ clip: opts.clip,
143
+ clipWindow: opts.clipWindow,
144
+ });
145
+ });
146
+
147
+ program
148
+ .command("transcribe")
149
+ .description("run the pipeline up to the transcript and cut report, no render")
150
+ .argument("<input>", "input video file")
151
+ .option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
152
+ .option("--transcript <path>", "inject a transcript JSON instead of running whisper")
153
+ .option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
154
+ .option("--workdir <dir>", "cache/work directory")
155
+ .option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
156
+ .action(async (input: string, opts) => {
157
+ const cleanup = CleanupLevelSchema.parse(opts.cleanup);
158
+ await produce(input, {
159
+ cleanup,
160
+ transcript: opts.transcript,
161
+ render: false,
162
+ mezzanine: false,
163
+ workdir: opts.workdir,
164
+ noiseDb: opts.noiseDb,
165
+ whisperModel: opts.whisperModel,
166
+ });
167
+ });
168
+
169
+ program
170
+ .command("studio")
171
+ .description("open Remotion Studio on a produced composition (visual debugging)")
172
+ .argument("<renderProps>", "path to a work dir's render-props.json")
173
+ .option("--video-dir <dir>", "directory containing the source video (public dir)")
174
+ .action(async (renderProps: string, opts) => {
175
+ const propsPath = resolve(renderProps);
176
+ const publicDir = opts.videoDir ? resolve(opts.videoDir) : dirname(propsPath);
177
+ const child = spawn(
178
+ "pnpm",
179
+ ["exec", "remotion", "studio", STUDIO_ENTRY, `--props=${propsPath}`, `--public-dir=${publicDir}`],
180
+ { stdio: "inherit" },
181
+ );
182
+ child.on("exit", (code) => process.exit(code ?? 0));
183
+ });
184
+
185
+ program
186
+ .command("edit")
187
+ .description("open the editing page on a produced workdir")
188
+ // OPTIONAL since R17 §83: with no argument the editor opens on a project
189
+ // picker — recent produce runs plus a folder browser — and the top bar's
190
+ // Open button switches projects without restarting the server.
191
+ .argument("[workdir]", "a work directory containing render-props.json")
192
+ .option("--port <n>", "port to listen on", (v) => Number.parseInt(v, 10), 5174)
193
+ .option("--no-open", "do not open a browser")
194
+ .action(async (workdir: string | undefined, opts) => {
195
+ const { startEditServer, resolveEditorPageDir } = await import("./edit");
196
+ // An npm install ships the page prebuilt (editor-dist/); a clone builds
197
+ // it once with `pnpm build`. A server that starts fine but 404s every
198
+ // page request is the worst version of missing — fail loudly with the
199
+ // fix instead.
200
+ const pageDir = resolveEditorPageDir();
201
+ if (pageDir === null) {
202
+ throw new Error(
203
+ "editor UI isn't built yet — run `pnpm build` " +
204
+ "(or `pnpm --filter @ossclip/editor build`) once, then re-run `ossclip edit`.",
205
+ );
206
+ }
207
+ const server = await startEditServer(workdir, { port: opts.port, pageDir });
208
+ console.log(`▸ editor at ${server.url}`);
209
+ if (opts.open) spawn("open", [server.url], { stdio: "ignore" });
210
+ });
211
+
212
+ program
213
+ .command("doctor")
214
+ .description("check every prerequisite and print the exact fix for anything missing")
215
+ .action(async () => {
216
+ // Env files are loaded at module top (R16 §77) — BEFORE this runs — so a
217
+ // provider key living in a `.env` is visible here, not a false negative.
218
+ if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
219
+ const { runDoctor, formatDoctor, realProbes } = await import("./doctor");
220
+ const { resolveEditorPageDir } = await import("./edit");
221
+ const { loadConfig } = await import("@ossclip/core");
222
+ const checks = await runDoctor(loadConfig(), realProbes(resolveEditorPageDir()));
223
+ console.log(formatDoctor(checks));
224
+ if (checks.some((c) => !c.ok)) process.exit(1);
225
+ });
226
+
227
+ program.parseAsync().catch((err) => {
228
+ console.error(`\n✗ ${err instanceof Error ? err.message : err}`);
229
+ process.exit(1);
230
+ });