opencaptions 0.0.0-stage → 1.2.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/LICENSE +661 -0
- package/README.md +32 -2
- package/dist/captions.js +267 -0
- package/dist/cli.js +121 -0
- package/dist/client.js +56 -0
- package/dist/engine.js +44 -0
- package/dist/generated/api.js +5 -0
- package/dist/generated/presets.json +687 -0
- package/dist/generated/schemas.json +438 -0
- package/dist/looks.js +57 -0
- package/dist/mcp.js +228 -0
- package/dist/version.js +3 -0
- package/engine/opencaptions_engine.wasm +0 -0
- package/package.json +48 -4
- package/server.json +36 -0
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencaptions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Animated word-by-word captions for your videos, from Claude Code, Claude Desktop, Cursor or any MCP
|
|
4
|
+
client, or from the command line. It runs on your own [OpenCaptions](https://github.com/leogaudin/opencaptions)
|
|
5
|
+
server: your video goes there and back, and nowhere else.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
export OPENCAPTIONS_KEY=oc_... # an API key from your server's Account page
|
|
9
|
+
npx opencaptions caption reel.mp4 --look Bold --language fr
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
As an MCP server:
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"opencaptions": {
|
|
18
|
+
"command": "npx",
|
|
19
|
+
"args": ["-y", "opencaptions", "mcp"],
|
|
20
|
+
"env": { "OPENCAPTIONS_KEY": "oc_...", "OPENCAPTIONS_URL": "http://localhost:5173" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
In Claude Code: `/plugin install opencaptions --marketplace leogaudin/opencaptions`.
|
|
27
|
+
|
|
28
|
+
The tools caption a video, list the looks, change any style setting, read and fix the transcript, save
|
|
29
|
+
again in another format or as captions alone over green, and export SRT or VTT. Setup, every tool and
|
|
30
|
+
every option: [docs.opencaptions.app/guides/agents](https://docs.opencaptions.app/guides/agents/).
|
|
31
|
+
|
|
32
|
+
Needs Node.js 20.10 or later and a running OpenCaptions server
|
|
33
|
+
([quick start](https://docs.opencaptions.app/getting-started/quick-start/)). AGPL-3.0-only.
|
package/dist/captions.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the command line and the MCP server do, over the server's API: open a video as a project,
|
|
3
|
+
* transcribe it, give it a look, fix words, and save the captioned video or its subtitles. The server
|
|
4
|
+
* transcribes and draws; nothing here draws a caption.
|
|
5
|
+
*/
|
|
6
|
+
import { createWriteStream, openAsBlob } from "node:fs";
|
|
7
|
+
import { rename, rm, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
9
|
+
import { Readable } from "node:stream";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
11
|
+
import { OpenCaptionsError, unwrap, } from "./client.js";
|
|
12
|
+
import { captionLines, setWord, shuffleVariants } from "./engine.js";
|
|
13
|
+
import { findLook, LOOKS } from "./looks.js";
|
|
14
|
+
/** How often a job is asked about, in milliseconds. */
|
|
15
|
+
const pollMs = () => Number(process.env.OPENCAPTIONS_POLL_MS) || 1000;
|
|
16
|
+
const isUrl = (source) => /^https?:\/\//i.test(source);
|
|
17
|
+
/**
|
|
18
|
+
* Where a save writes when no output is given: next to the source, marked as captioned, without
|
|
19
|
+
* the extension, which is the format's.
|
|
20
|
+
*/
|
|
21
|
+
export function defaultOutput(source, captionsOnly = false) {
|
|
22
|
+
const name = isUrl(source)
|
|
23
|
+
? basename(new URL(source).pathname) || "video"
|
|
24
|
+
: basename(source, extname(source));
|
|
25
|
+
const stem = name.replace(extname(name), "") || "video";
|
|
26
|
+
const folder = isUrl(source) ? process.cwd() : dirname(resolve(source));
|
|
27
|
+
return join(folder, `${stem}.${captionsOnly ? "captions" : "captioned"}`);
|
|
28
|
+
}
|
|
29
|
+
export async function openProject(client, source) {
|
|
30
|
+
if (isUrl(source)) {
|
|
31
|
+
const title = basename(new URL(source).pathname) || "Video";
|
|
32
|
+
return unwrap(await client.POST("/api/v1/projects", {
|
|
33
|
+
body: { title, video_url: source },
|
|
34
|
+
bodySerializer: (body) => form({ title: body.title, video_url: source }),
|
|
35
|
+
})).id;
|
|
36
|
+
}
|
|
37
|
+
const path = resolve(source);
|
|
38
|
+
const file = await stat(path).catch(() => undefined);
|
|
39
|
+
if (!file?.isFile())
|
|
40
|
+
throw new OpenCaptionsError(`There is no video at ${path}.`);
|
|
41
|
+
const video = await openAsBlob(path);
|
|
42
|
+
return unwrap(await client.POST("/api/v1/projects", {
|
|
43
|
+
body: { title: basename(path, extname(path)) },
|
|
44
|
+
bodySerializer: (body) => form({ title: body.title }, ["video", video, basename(path)]),
|
|
45
|
+
})).id;
|
|
46
|
+
}
|
|
47
|
+
function form(fields, file) {
|
|
48
|
+
const data = new FormData();
|
|
49
|
+
for (const [key, value] of Object.entries(fields))
|
|
50
|
+
data.append(key, value);
|
|
51
|
+
if (file)
|
|
52
|
+
data.append(...file);
|
|
53
|
+
return data;
|
|
54
|
+
}
|
|
55
|
+
export async function getProject(client, projectId) {
|
|
56
|
+
return unwrap(await client.GET("/api/v1/projects/{project_id}", {
|
|
57
|
+
params: { path: { project_id: projectId } },
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
async function update(client, projectId, body) {
|
|
61
|
+
return unwrap(await client.PATCH("/api/v1/projects/{project_id}", {
|
|
62
|
+
params: { path: { project_id: projectId } },
|
|
63
|
+
body,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
export async function startTranscription(client, projectId, request) {
|
|
67
|
+
return unwrap(await client.POST("/api/v1/projects/{project_id}/transcribe", {
|
|
68
|
+
params: { path: { project_id: projectId } },
|
|
69
|
+
body: {
|
|
70
|
+
language: request.language ?? "auto",
|
|
71
|
+
model: request.model ?? null,
|
|
72
|
+
provider: request.provider ?? null,
|
|
73
|
+
},
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Waits for a job, reporting as it goes. True once it has finished; false when `deadline` (a time
|
|
78
|
+
* in milliseconds) came first and it is still going. A job that failed or was cancelled throws.
|
|
79
|
+
*/
|
|
80
|
+
export async function waitForJob(client, jobId, progress, deadline = Number.POSITIVE_INFINITY) {
|
|
81
|
+
for (;;) {
|
|
82
|
+
const job = unwrap(await client.GET("/api/v1/jobs/{job_id}", { params: { path: { job_id: jobId } } }));
|
|
83
|
+
if (job.status === "completed")
|
|
84
|
+
return true;
|
|
85
|
+
if (job.status === "failed") {
|
|
86
|
+
throw new OpenCaptionsError(job.error ?? job.message ?? "The job failed on the server.");
|
|
87
|
+
}
|
|
88
|
+
if (job.status === "cancelled")
|
|
89
|
+
throw new OpenCaptionsError("The job was cancelled.");
|
|
90
|
+
progress(job.progress, job.message ?? (job.type === "transcription" ? "Transcribing" : "Saving"));
|
|
91
|
+
if (Date.now() + pollMs() > deadline)
|
|
92
|
+
return false;
|
|
93
|
+
await new Promise((done) => setTimeout(done, pollMs()));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The project's style set to a look, with changes over it; without a look the changes go over the
|
|
98
|
+
* style it has. A look with word variants deals them to the words, as picking it in an editor does.
|
|
99
|
+
*/
|
|
100
|
+
export async function setStyle(client, projectId, look, changes = {}) {
|
|
101
|
+
const project = await getProject(client, projectId);
|
|
102
|
+
// A project never styled draws as the first look, so changes alone go over that.
|
|
103
|
+
const base = look ? findLook(look).config : (project.style_config ?? LOOKS[0]?.config);
|
|
104
|
+
const style = { ...base, ...changes };
|
|
105
|
+
const transcript = look ? await dealt(project.transcript ?? undefined, style) : undefined;
|
|
106
|
+
return ((await update(client, projectId, { style_config: style, ...(transcript && { transcript }) }))
|
|
107
|
+
.style_config ?? style);
|
|
108
|
+
}
|
|
109
|
+
async function dealt(transcript, style) {
|
|
110
|
+
const count = style.variants?.length ?? 0;
|
|
111
|
+
return transcript && count > 0 ? shuffleVariants(transcript, count) : undefined;
|
|
112
|
+
}
|
|
113
|
+
/** The words with their place in the captions, for checking names and spelling. */
|
|
114
|
+
export async function readTranscript(client, projectId) {
|
|
115
|
+
const project = await getProject(client, projectId);
|
|
116
|
+
if (!project.transcript)
|
|
117
|
+
throw new OpenCaptionsError("This project has no captions yet.");
|
|
118
|
+
const words = project.transcript.segments
|
|
119
|
+
.flatMap((s) => s.words)
|
|
120
|
+
.map((w, index) => ({ index, text: w.text, start: w.start }));
|
|
121
|
+
const lines = await captionLines(project.transcript, project.style_config?.words_per_line ?? 3, project.caption_offset_ms);
|
|
122
|
+
return { lines, words };
|
|
123
|
+
}
|
|
124
|
+
/** Sets words by their index; empty text removes a word. The indices are the ones read before. */
|
|
125
|
+
export async function fixWords(client, projectId, fixes) {
|
|
126
|
+
const project = await getProject(client, projectId);
|
|
127
|
+
if (!project.transcript)
|
|
128
|
+
throw new OpenCaptionsError("This project has no captions yet.");
|
|
129
|
+
// Last first: removing a word moves the ones after it, never the ones before.
|
|
130
|
+
let transcript = project.transcript;
|
|
131
|
+
for (const fix of [...fixes].sort((a, b) => b.index - a.index)) {
|
|
132
|
+
transcript = await setWord(transcript, fix.index, fix.text);
|
|
133
|
+
}
|
|
134
|
+
await update(client, projectId, { transcript });
|
|
135
|
+
return fixes.length;
|
|
136
|
+
}
|
|
137
|
+
function renderRequest(save) {
|
|
138
|
+
return {
|
|
139
|
+
format: save.format ?? "mp4",
|
|
140
|
+
resolution: save.resolution ?? "original",
|
|
141
|
+
frame_rate: save.frame_rate ?? "original",
|
|
142
|
+
green_screen: save.captions_only ?? false,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Asks for the video; it comes back at once when an identical one was made before. */
|
|
146
|
+
export async function requestVideo(client, projectId, save) {
|
|
147
|
+
const answer = unwrap(await client.POST("/api/v1/projects/{project_id}/download", {
|
|
148
|
+
params: { path: { project_id: projectId } },
|
|
149
|
+
body: renderRequest(save),
|
|
150
|
+
}));
|
|
151
|
+
return answer.ready ? undefined : (answer.job_id ?? undefined);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Writes the finished video to `output`, or to `stem` with the extension of the format the server
|
|
155
|
+
* made. Returns the path written.
|
|
156
|
+
*/
|
|
157
|
+
export async function downloadVideo(client, projectId, save) {
|
|
158
|
+
const request = renderRequest(save);
|
|
159
|
+
const { data, response } = await client.GET("/api/v1/projects/{project_id}/download/{format_id}", {
|
|
160
|
+
params: {
|
|
161
|
+
path: { project_id: projectId, format_id: request.format },
|
|
162
|
+
query: {
|
|
163
|
+
resolution: request.resolution,
|
|
164
|
+
frame_rate: request.frame_rate,
|
|
165
|
+
green_screen: request.green_screen,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
parseAs: "stream",
|
|
169
|
+
});
|
|
170
|
+
if (!response.ok || !data)
|
|
171
|
+
unwrap({ error: await response.json().catch(() => null), response });
|
|
172
|
+
const named = /filename="[^"]*?(\.[a-z0-9]+)"/i.exec(response.headers.get("content-disposition") ?? "");
|
|
173
|
+
const output = save.output ?? `${save.stem}${named?.[1] ?? ".mp4"}`;
|
|
174
|
+
const partial = `${output}.part`;
|
|
175
|
+
try {
|
|
176
|
+
await pipeline(Readable.fromWeb(data), createWriteStream(partial));
|
|
177
|
+
await rename(partial, output);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
await rm(partial, { force: true });
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
return output;
|
|
184
|
+
}
|
|
185
|
+
/** Saves the captioned video, waiting for it up to `deadline`. */
|
|
186
|
+
export async function saveVideo(client, projectId, save, progress, deadline) {
|
|
187
|
+
const job = await requestVideo(client, projectId, save);
|
|
188
|
+
if (job && !(await waitForJob(client, job, progress, deadline))) {
|
|
189
|
+
return pending({ project_id: projectId, job_id: job, step: "save", save });
|
|
190
|
+
}
|
|
191
|
+
progress(1, "Writing the file");
|
|
192
|
+
return {
|
|
193
|
+
done: true,
|
|
194
|
+
project_id: projectId,
|
|
195
|
+
output: await downloadVideo(client, projectId, save),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** The captions as SRT or VTT, or the transcript as JSON, written next to `output`. */
|
|
199
|
+
export async function exportSubtitles(client, projectId, kind, output) {
|
|
200
|
+
const path = extname(output) === `.${kind}` ? output : `${output}.${kind}`;
|
|
201
|
+
const options = { params: { path: { project_id: projectId } }, parseAs: "text" };
|
|
202
|
+
const { data, response } = kind === "srt"
|
|
203
|
+
? await client.GET("/api/v1/projects/{project_id}/export.srt", options)
|
|
204
|
+
: kind === "vtt"
|
|
205
|
+
? await client.GET("/api/v1/projects/{project_id}/export.vtt", options)
|
|
206
|
+
: await client.GET("/api/v1/projects/{project_id}/export.json", options);
|
|
207
|
+
if (!response.ok || data === undefined) {
|
|
208
|
+
unwrap({ error: await response.json().catch(() => null), response });
|
|
209
|
+
}
|
|
210
|
+
await writeFile(path, data ?? "");
|
|
211
|
+
return path;
|
|
212
|
+
}
|
|
213
|
+
function pending(ticket) {
|
|
214
|
+
const what = ticket.step === "transcribe" ? "Transcribing" : "Saving the video";
|
|
215
|
+
return { done: false, ticket, message: `${what} is still going on the server.` };
|
|
216
|
+
}
|
|
217
|
+
/** Opens, transcribes, styles and saves: the whole of "caption this video". */
|
|
218
|
+
export async function captionVideo(client, request, progress, deadline) {
|
|
219
|
+
progress(0, "Sending the video");
|
|
220
|
+
const projectId = await openProject(client, request.source);
|
|
221
|
+
const save = {
|
|
222
|
+
...request,
|
|
223
|
+
stem: defaultOutput(request.source, request.captions_only),
|
|
224
|
+
};
|
|
225
|
+
if (request.look !== undefined || request.style !== undefined) {
|
|
226
|
+
await setStyle(client, projectId, request.look, request.style);
|
|
227
|
+
}
|
|
228
|
+
if (request.transcript) {
|
|
229
|
+
await update(client, projectId, { transcript: request.transcript });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
const job = await startTranscription(client, projectId, request);
|
|
233
|
+
if (!(await waitForJob(client, job.id, progress, deadline))) {
|
|
234
|
+
return pending({
|
|
235
|
+
project_id: projectId,
|
|
236
|
+
job_id: job.id,
|
|
237
|
+
step: "transcribe",
|
|
238
|
+
save: saveOnly(save),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
await afterTranscript(client, projectId);
|
|
243
|
+
return saveVideo(client, projectId, saveOnly(save), progress, deadline);
|
|
244
|
+
}
|
|
245
|
+
/** A look's word variants go to the words once there are words to give them to. */
|
|
246
|
+
async function afterTranscript(client, projectId) {
|
|
247
|
+
const project = await getProject(client, projectId);
|
|
248
|
+
const transcript = project.style_config && (await dealt(project.transcript ?? undefined, project.style_config));
|
|
249
|
+
if (transcript)
|
|
250
|
+
await update(client, projectId, { transcript });
|
|
251
|
+
}
|
|
252
|
+
function saveOnly(save) {
|
|
253
|
+
const { format, resolution, frame_rate, captions_only, output, stem } = save;
|
|
254
|
+
return { format, resolution, frame_rate, captions_only, output, stem };
|
|
255
|
+
}
|
|
256
|
+
/** Carries on from where a ticket was left. */
|
|
257
|
+
export async function resume(client, ticket, progress, deadline) {
|
|
258
|
+
if (!(await waitForJob(client, ticket.job_id, progress, deadline)))
|
|
259
|
+
return pending(ticket);
|
|
260
|
+
if (ticket.step === "save") {
|
|
261
|
+
progress(1, "Writing the file");
|
|
262
|
+
const output = await downloadVideo(client, ticket.project_id, ticket.save);
|
|
263
|
+
return { done: true, project_id: ticket.project_id, output };
|
|
264
|
+
}
|
|
265
|
+
await afterTranscript(client, ticket.project_id);
|
|
266
|
+
return saveVideo(client, ticket.project_id, ticket.save, progress, deadline);
|
|
267
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `opencaptions`: captions a video from the command line, or serves the same actions to an agent
|
|
4
|
+
* over MCP (`opencaptions mcp`).
|
|
5
|
+
*/
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { captionVideo, exportSubtitles } from "./captions.js";
|
|
9
|
+
import { api, connectionFromEnv, OpenCaptionsError } from "./client.js";
|
|
10
|
+
import { describeLook, describeStyle, LOOKS } from "./looks.js";
|
|
11
|
+
import { VERSION } from "./version.js";
|
|
12
|
+
const HELP = `opencaptions ${VERSION}: animated captions for your videos, on your OpenCaptions server.
|
|
13
|
+
|
|
14
|
+
opencaptions caption <video or link> [options] transcribe, style and save the captioned video
|
|
15
|
+
opencaptions looks the built-in looks
|
|
16
|
+
opencaptions style every style setting, for --style
|
|
17
|
+
opencaptions mcp serve these to an agent over MCP (stdio)
|
|
18
|
+
|
|
19
|
+
Options for caption:
|
|
20
|
+
-o, --output <path> where to write the video (default: <name>.captioned next to it)
|
|
21
|
+
--look <name> a built-in look (default: Purple Punch)
|
|
22
|
+
--style <json> style settings over the look, e.g. '{"position_y":0.7}'
|
|
23
|
+
--language <code> ISO 639-1, or auto (default)
|
|
24
|
+
--model <id> a speech model the server offers
|
|
25
|
+
--transcript <file> use these captions (the API's Transcript JSON) instead of transcribing
|
|
26
|
+
--format <id> mp4 (default), mp4-hevc, webm or mov
|
|
27
|
+
--resolution <side> original (default), 2160, 1080 or 720
|
|
28
|
+
--fps <rate> original (default), 30 or 60
|
|
29
|
+
--captions-only the captions alone over green, no sound, to key out
|
|
30
|
+
--subtitles <kinds> also write subtitles: srt, vtt, json, comma separated
|
|
31
|
+
|
|
32
|
+
The server: OPENCAPTIONS_URL (default http://localhost:5173) and OPENCAPTIONS_KEY, an API key
|
|
33
|
+
from its Account page.`;
|
|
34
|
+
/** Progress on one line of the terminal, rewritten as it moves. */
|
|
35
|
+
const report = (fraction, message) => {
|
|
36
|
+
if (process.stderr.isTTY) {
|
|
37
|
+
process.stderr.write(`\r\x1b[2K${Math.round(fraction * 100)}% ${message}`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
async function caption(args) {
|
|
41
|
+
const { values, positionals } = parseArgs({
|
|
42
|
+
args,
|
|
43
|
+
allowPositionals: true,
|
|
44
|
+
options: {
|
|
45
|
+
output: { type: "string", short: "o" },
|
|
46
|
+
look: { type: "string" },
|
|
47
|
+
style: { type: "string" },
|
|
48
|
+
language: { type: "string" },
|
|
49
|
+
model: { type: "string" },
|
|
50
|
+
transcript: { type: "string" },
|
|
51
|
+
format: { type: "string" },
|
|
52
|
+
resolution: { type: "string" },
|
|
53
|
+
fps: { type: "string" },
|
|
54
|
+
"captions-only": { type: "boolean" },
|
|
55
|
+
subtitles: { type: "string" },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const source = positionals[0];
|
|
59
|
+
if (!source)
|
|
60
|
+
throw new OpenCaptionsError("Which video? opencaptions caption <video or link>");
|
|
61
|
+
const request = {
|
|
62
|
+
source,
|
|
63
|
+
output: values.output,
|
|
64
|
+
look: values.look,
|
|
65
|
+
style: values.style ? JSON.parse(values.style) : undefined,
|
|
66
|
+
language: values.language,
|
|
67
|
+
model: values.model,
|
|
68
|
+
transcript: values.transcript
|
|
69
|
+
? JSON.parse(await readFile(values.transcript, "utf8"))
|
|
70
|
+
: undefined,
|
|
71
|
+
format: values.format,
|
|
72
|
+
resolution: values.resolution,
|
|
73
|
+
frame_rate: values.fps,
|
|
74
|
+
captions_only: values["captions-only"],
|
|
75
|
+
};
|
|
76
|
+
const client = api(connectionFromEnv());
|
|
77
|
+
const result = await captionVideo(client, request, report);
|
|
78
|
+
if (process.stderr.isTTY)
|
|
79
|
+
process.stderr.write("\r\x1b[2K");
|
|
80
|
+
if (!result.done)
|
|
81
|
+
throw new OpenCaptionsError(result.message);
|
|
82
|
+
console.log(result.output);
|
|
83
|
+
for (const kind of (values.subtitles ?? "").split(",").filter(Boolean)) {
|
|
84
|
+
if (kind !== "srt" && kind !== "vtt" && kind !== "json") {
|
|
85
|
+
throw new OpenCaptionsError(`Subtitles are srt, vtt or json, not ${kind}.`);
|
|
86
|
+
}
|
|
87
|
+
console.log(await exportSubtitles(client, result.project_id, kind, result.output.replace(/\.[^.]+$/, "")));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function main(argv) {
|
|
91
|
+
const [command, ...rest] = argv;
|
|
92
|
+
switch (command) {
|
|
93
|
+
case "caption":
|
|
94
|
+
return caption(rest);
|
|
95
|
+
case "looks":
|
|
96
|
+
console.log(LOOKS.map(describeLook).join("\n"));
|
|
97
|
+
return;
|
|
98
|
+
case "style":
|
|
99
|
+
console.log(describeStyle());
|
|
100
|
+
return;
|
|
101
|
+
case "mcp": {
|
|
102
|
+
const { serve } = await import("./mcp.js");
|
|
103
|
+
return serve();
|
|
104
|
+
}
|
|
105
|
+
case "--version":
|
|
106
|
+
case "-v":
|
|
107
|
+
console.log(VERSION);
|
|
108
|
+
return;
|
|
109
|
+
default:
|
|
110
|
+
console.log(HELP);
|
|
111
|
+
if (command && command !== "help" && command !== "--help" && command !== "-h") {
|
|
112
|
+
process.exitCode = 2;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
117
|
+
if (process.stderr.isTTY)
|
|
118
|
+
process.stderr.write("\r\x1b[2K");
|
|
119
|
+
console.error(error instanceof OpenCaptionsError ? error.message : error);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
});
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenCaptions server, reached through its API with a key. Every call is typed against the API's
|
|
3
|
+
* generated types (`generated/api.ts`), so a route or a body that drifts from the server fails `tsc`.
|
|
4
|
+
*/
|
|
5
|
+
import createClient, {} from "openapi-fetch";
|
|
6
|
+
/** A failure in words a person (or an agent) can act on. */
|
|
7
|
+
export class OpenCaptionsError extends Error {
|
|
8
|
+
}
|
|
9
|
+
export const DEFAULT_URL = "http://localhost:5173";
|
|
10
|
+
/** The server named by OPENCAPTIONS_URL and OPENCAPTIONS_KEY. */
|
|
11
|
+
export function connectionFromEnv(env = process.env) {
|
|
12
|
+
const key = env.OPENCAPTIONS_KEY?.trim();
|
|
13
|
+
if (!key) {
|
|
14
|
+
throw new OpenCaptionsError("OPENCAPTIONS_KEY is not set. Create an API key on your OpenCaptions server's Account page " +
|
|
15
|
+
"and set it, with OPENCAPTIONS_URL if the server is not at " +
|
|
16
|
+
`${DEFAULT_URL}.`);
|
|
17
|
+
}
|
|
18
|
+
return { url: (env.OPENCAPTIONS_URL?.trim() || DEFAULT_URL).replace(/\/+$/, ""), key };
|
|
19
|
+
}
|
|
20
|
+
export function api(connection) {
|
|
21
|
+
const client = createClient({
|
|
22
|
+
baseUrl: connection.url,
|
|
23
|
+
headers: { Authorization: `Bearer ${connection.key}` },
|
|
24
|
+
});
|
|
25
|
+
client.use({
|
|
26
|
+
async onError({ error }) {
|
|
27
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
28
|
+
return new OpenCaptionsError(`Cannot reach OpenCaptions at ${connection.url} (${cause}). Is the server running?`);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
return client;
|
|
32
|
+
}
|
|
33
|
+
/** The value of a call, or what the server said was wrong with it. */
|
|
34
|
+
export function unwrap(result) {
|
|
35
|
+
const { data, error, response } = result;
|
|
36
|
+
if (response.ok)
|
|
37
|
+
return data;
|
|
38
|
+
if (response.status === 401) {
|
|
39
|
+
throw new OpenCaptionsError("The server refused the key: check OPENCAPTIONS_KEY.");
|
|
40
|
+
}
|
|
41
|
+
throw new OpenCaptionsError(describe(error) ?? `The server answered ${response.status}.`);
|
|
42
|
+
}
|
|
43
|
+
function describe(error) {
|
|
44
|
+
if (typeof error !== "object" || error === null || !("detail" in error))
|
|
45
|
+
return undefined;
|
|
46
|
+
const { detail } = error;
|
|
47
|
+
if (typeof detail === "string")
|
|
48
|
+
return detail;
|
|
49
|
+
// A validation error: each problem, and where it is.
|
|
50
|
+
if (Array.isArray(detail)) {
|
|
51
|
+
return detail
|
|
52
|
+
.map((d) => `${(d.loc ?? []).slice(1).join(".")}: ${d.msg}`)
|
|
53
|
+
.join("; ");
|
|
54
|
+
}
|
|
55
|
+
return JSON.stringify(detail);
|
|
56
|
+
}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caption engine's edits (apps/engine/src/edit.rs), through the same WebAssembly file the web
|
|
3
|
+
* editor loads: a word is fixed, and a look's word variants dealt, by the code every editor uses,
|
|
4
|
+
* never by a second implementation here.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
const WASM = new URL("../engine/opencaptions_engine.wasm", import.meta.url);
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const decoder = new TextDecoder();
|
|
10
|
+
let loaded;
|
|
11
|
+
function exports() {
|
|
12
|
+
loaded ??= readFile(WASM).then(async (bytes) => {
|
|
13
|
+
const { instance } = await WebAssembly.instantiate(bytes, {});
|
|
14
|
+
return instance.exports;
|
|
15
|
+
});
|
|
16
|
+
return loaded;
|
|
17
|
+
}
|
|
18
|
+
function copyIn(x, bytes) {
|
|
19
|
+
const ptr = x.oc_alloc(bytes.length);
|
|
20
|
+
new Uint8Array(x.memory.buffer, ptr, bytes.length).set(bytes);
|
|
21
|
+
return [ptr, bytes.length];
|
|
22
|
+
}
|
|
23
|
+
function result(x, ok) {
|
|
24
|
+
const text = decoder.decode(new Uint8Array(x.memory.buffer, x.oc_result_ptr(), x.oc_result_len()));
|
|
25
|
+
if (!ok)
|
|
26
|
+
throw new Error(`the caption engine refused the edit: ${text}`);
|
|
27
|
+
return JSON.parse(text);
|
|
28
|
+
}
|
|
29
|
+
const json = (x, t) => copyIn(x, encoder.encode(JSON.stringify(t)));
|
|
30
|
+
export async function captionLines(t, wordsPerLine, offsetMs) {
|
|
31
|
+
const x = await exports();
|
|
32
|
+
return result(x, x.oc_caption_lines(...json(x, t), wordsPerLine, offsetMs));
|
|
33
|
+
}
|
|
34
|
+
/** Word `index` (counted across segments) given `text`; empty text removes it. */
|
|
35
|
+
export async function setWord(t, index, text) {
|
|
36
|
+
const x = await exports();
|
|
37
|
+
return result(x, x.oc_set_word(...json(x, t), index, ...copyIn(x, encoder.encode(text))));
|
|
38
|
+
}
|
|
39
|
+
/** Deals every word one of `count` looks, or the style's own, as picking such a look does. */
|
|
40
|
+
export async function shuffleVariants(t, count) {
|
|
41
|
+
const x = await exports();
|
|
42
|
+
const seed = crypto.getRandomValues(new Uint32Array(1))[0] ?? 0;
|
|
43
|
+
return result(x, x.oc_shuffle_variants(...json(x, t), count, seed));
|
|
44
|
+
}
|