opencaptions 1.2.0 → 1.3.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.
Files changed (36) hide show
  1. package/README.md +10 -7
  2. package/dist/captioner.js +31 -0
  3. package/dist/captions.js +33 -19
  4. package/dist/cli.js +10 -9
  5. package/dist/engine.js +78 -8
  6. package/dist/generated/render_formats.json +42 -0
  7. package/dist/local/cache.js +8 -0
  8. package/dist/local/fonts.js +81 -0
  9. package/dist/local/index.js +213 -0
  10. package/dist/local/media.js +90 -0
  11. package/dist/local/transcribe.js +260 -0
  12. package/dist/mcp.js +34 -26
  13. package/engine/fonts/BarlowCondensed-MediumItalic.ttf +0 -0
  14. package/engine/fonts/BarlowCondensed-OFL.txt +93 -0
  15. package/engine/fonts/InstrumentSerif-Italic.ttf +0 -0
  16. package/engine/fonts/InstrumentSerif-OFL.txt +93 -0
  17. package/engine/fonts/InstrumentSerif-Regular.ttf +0 -0
  18. package/engine/fonts/Inter-OFL.txt +93 -0
  19. package/engine/fonts/Inter.ttf +0 -0
  20. package/engine/fonts/Montserrat-Black.ttf +0 -0
  21. package/engine/fonts/Montserrat-OFL.txt +93 -0
  22. package/engine/fonts/NimbusRoman-Bold.ttf +0 -0
  23. package/engine/fonts/NimbusRoman-LICENSE.txt +14 -0
  24. package/engine/fonts/NotoSansArabic-OFL.txt +93 -0
  25. package/engine/fonts/NotoSansArabic.ttf +0 -0
  26. package/engine/fonts/NotoSansDevanagari-OFL.txt +93 -0
  27. package/engine/fonts/NotoSansDevanagari.ttf +0 -0
  28. package/engine/fonts/NotoSansHebrew-OFL.txt +93 -0
  29. package/engine/fonts/NotoSansHebrew.ttf +0 -0
  30. package/engine/fonts/NotoSansThai-OFL.txt +93 -0
  31. package/engine/fonts/NotoSansThai.ttf +0 -0
  32. package/engine/fonts/Poppins-ExtraBold.ttf +0 -0
  33. package/engine/fonts/Poppins-OFL.txt +93 -0
  34. package/engine/opencaptions_engine.wasm +0 -0
  35. package/package.json +4 -2
  36. package/server.json +15 -8
package/README.md CHANGED
@@ -1,14 +1,19 @@
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.
4
+ client, or from the command line. It runs on your computer, with nothing to sign up for: the speech
5
+ model and the caption engine of [OpenCaptions](https://github.com/leogaudin/opencaptions) run locally,
6
+ and your video stays where it is.
6
7
 
7
8
  ```bash
8
- export OPENCAPTIONS_KEY=oc_... # an API key from your server's Account page
9
9
  npx opencaptions caption reel.mp4 --look Bold --language fr
10
10
  ```
11
11
 
12
+ It needs [FFmpeg](https://ffmpeg.org/download.html), and downloads the speech model (about 700 MB)
13
+ the first time. It transcribes 25 European languages. To use an OpenCaptions server instead (other
14
+ languages, Whisper models, a GPU, the web editor), set `OPENCAPTIONS_KEY` to an API key from its
15
+ Account page, and `OPENCAPTIONS_URL` if it is not at `http://localhost:5173`.
16
+
12
17
  As an MCP server:
13
18
 
14
19
  ```json
@@ -16,8 +21,7 @@ As an MCP server:
16
21
  "mcpServers": {
17
22
  "opencaptions": {
18
23
  "command": "npx",
19
- "args": ["-y", "opencaptions", "mcp"],
20
- "env": { "OPENCAPTIONS_KEY": "oc_...", "OPENCAPTIONS_URL": "http://localhost:5173" }
24
+ "args": ["-y", "opencaptions", "mcp"]
21
25
  }
22
26
  }
23
27
  }
@@ -29,5 +33,4 @@ The tools caption a video, list the looks, change any style setting, read and fi
29
33
  again in another format or as captions alone over green, and export SRT or VTT. Setup, every tool and
30
34
  every option: [docs.opencaptions.app/guides/agents](https://docs.opencaptions.app/guides/agents/).
31
35
 
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.
36
+ Needs Node.js 20.10 or later. AGPL-3.0-only.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The actions the command line and the MCP server offer, on a server or on this computer. With
3
+ * neither OPENCAPTIONS_URL nor OPENCAPTIONS_KEY set, the work is done here (`local/`); otherwise on
4
+ * the server they name. A project is an id on a server and a file beside the video here.
5
+ */
6
+ import { captionVideo, defaultOutput, exportSubtitles, fixWords, getProject, readTranscript, resume, saveVideo, setStyle, } from "./captions.js";
7
+ import { api, connectionFromEnv } from "./client.js";
8
+ export function serverCaptioner(client) {
9
+ return {
10
+ caption: (request, progress, deadline) => captionVideo(client, request, progress, deadline),
11
+ resume: (ticket, progress, deadline) => resume(client, ticket, progress, deadline),
12
+ setStyle: (id, look, changes) => setStyle(client, id, look, changes),
13
+ readTranscript: (id) => readTranscript(client, id),
14
+ fixWords: (id, fixes) => fixWords(client, id, fixes),
15
+ save: async (id, options, progress, deadline) => {
16
+ const stem = defaultOutput((await getProject(client, id)).title, options.captions_only);
17
+ return saveVideo(client, id, { ...options, stem }, progress, deadline);
18
+ },
19
+ exportSubtitles: async (id, kind, output) => exportSubtitles(client, id, kind, output ?? defaultOutput((await getProject(client, id)).title)),
20
+ };
21
+ }
22
+ /** Whether the environment names a server; without one, the work is done on this computer. */
23
+ export function wantsServer(env = process.env) {
24
+ return Boolean(env.OPENCAPTIONS_URL?.trim() || env.OPENCAPTIONS_KEY?.trim());
25
+ }
26
+ export async function captionerFromEnv(env = process.env) {
27
+ if (wantsServer(env))
28
+ return serverCaptioner(api(connectionFromEnv(env)));
29
+ const { localCaptioner } = await import("./local/index.js");
30
+ return localCaptioner();
31
+ }
package/dist/captions.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * What the command line and the MCP server do, over the server's API: open a video as a project,
3
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.
4
+ * transcribes and draws. `local/` does the same on this computer, with the pieces here that do not
5
+ * depend on where it runs (`styled`, `applyFixes`, `viewTranscript`).
5
6
  */
6
7
  import { createWriteStream, openAsBlob } from "node:fs";
7
8
  import { rename, rm, stat, writeFile } from "node:fs/promises";
@@ -9,7 +10,7 @@ import { basename, dirname, extname, join, resolve } from "node:path";
9
10
  import { Readable } from "node:stream";
10
11
  import { pipeline } from "node:stream/promises";
11
12
  import { OpenCaptionsError, unwrap, } from "./client.js";
12
- import { captionLines, setWord, shuffleVariants } from "./engine.js";
13
+ import { captionLines, mergeWords, setWord, shuffleVariants } from "./engine.js";
13
14
  import { findLook, LOOKS } from "./looks.js";
14
15
  /** How often a job is asked about, in milliseconds. */
15
16
  const pollMs = () => Number(process.env.OPENCAPTIONS_POLL_MS) || 1000;
@@ -99,39 +100,52 @@ export async function waitForJob(client, jobId, progress, deadline = Number.POSI
99
100
  */
100
101
  export async function setStyle(client, projectId, look, changes = {}) {
101
102
  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 };
103
+ const style = styled(project.style_config, look, changes);
105
104
  const transcript = look ? await dealt(project.transcript ?? undefined, style) : undefined;
106
105
  return ((await update(client, projectId, { style_config: style, ...(transcript && { transcript }) }))
107
106
  .style_config ?? style);
108
107
  }
109
- async function dealt(transcript, style) {
108
+ /** A look with changes over it; without a look the changes go over `current`. */
109
+ export function styled(current, look, changes = {}) {
110
+ // A project never styled draws as the first look, so changes alone go over that.
111
+ const base = look ? findLook(look).config : (current ?? LOOKS[0]?.config);
112
+ return { ...base, ...changes };
113
+ }
114
+ export async function dealt(transcript, style) {
110
115
  const count = style.variants?.length ?? 0;
111
116
  return transcript && count > 0 ? shuffleVariants(transcript, count) : undefined;
112
117
  }
113
118
  /** 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)
119
+ export async function viewTranscript(transcript, style, offsetMs) {
120
+ if (!transcript)
117
121
  throw new OpenCaptionsError("This project has no captions yet.");
118
- const words = project.transcript.segments
122
+ const words = transcript.segments
119
123
  .flatMap((s) => s.words)
120
124
  .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);
125
+ const lines = await captionLines(transcript, style?.words_per_line ?? 3, offsetMs);
122
126
  return { lines, words };
123
127
  }
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) {
128
+ export async function readTranscript(client, projectId) {
126
129
  const project = await getProject(client, projectId);
127
- if (!project.transcript)
130
+ return viewTranscript(project.transcript, project.style_config, project.caption_offset_ms);
131
+ }
132
+ /** The fixes applied by the engine; the indices are the ones read before. */
133
+ export async function applyFixes(transcript, fixes) {
134
+ if (!transcript)
128
135
  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;
136
+ // Last first: removing or joining words moves the ones after them, never the ones before.
137
+ let fixed = transcript;
131
138
  for (const fix of [...fixes].sort((a, b) => b.index - a.index)) {
132
- transcript = await setWord(transcript, fix.index, fix.text);
139
+ fixed =
140
+ (fix.count ?? 1) > 1
141
+ ? await mergeWords(fixed, fix.index, fix.count ?? 1, fix.text)
142
+ : await setWord(fixed, fix.index, fix.text);
133
143
  }
134
- await update(client, projectId, { transcript });
144
+ return fixed;
145
+ }
146
+ export async function fixWords(client, projectId, fixes) {
147
+ const project = await getProject(client, projectId);
148
+ await update(client, projectId, { transcript: await applyFixes(project.transcript, fixes) });
135
149
  return fixes.length;
136
150
  }
137
151
  function renderRequest(save) {
@@ -249,7 +263,7 @@ async function afterTranscript(client, projectId) {
249
263
  if (transcript)
250
264
  await update(client, projectId, { transcript });
251
265
  }
252
- function saveOnly(save) {
266
+ export function saveOnly(save) {
253
267
  const { format, resolution, frame_rate, captions_only, output, stem } = save;
254
268
  return { format, resolution, frame_rate, captions_only, output, stem };
255
269
  }
package/dist/cli.js CHANGED
@@ -5,11 +5,11 @@
5
5
  */
6
6
  import { readFile } from "node:fs/promises";
7
7
  import { parseArgs } from "node:util";
8
- import { captionVideo, exportSubtitles } from "./captions.js";
9
- import { api, connectionFromEnv, OpenCaptionsError } from "./client.js";
8
+ import { captionerFromEnv } from "./captioner.js";
9
+ import { OpenCaptionsError } from "./client.js";
10
10
  import { describeLook, describeStyle, LOOKS } from "./looks.js";
11
11
  import { VERSION } from "./version.js";
12
- const HELP = `opencaptions ${VERSION}: animated captions for your videos, on your OpenCaptions server.
12
+ const HELP = `opencaptions ${VERSION}: animated captions for your videos.
13
13
 
14
14
  opencaptions caption <video or link> [options] transcribe, style and save the captioned video
15
15
  opencaptions looks the built-in looks
@@ -21,7 +21,7 @@ Options for caption:
21
21
  --look <name> a built-in look (default: Purple Punch)
22
22
  --style <json> style settings over the look, e.g. '{"position_y":0.7}'
23
23
  --language <code> ISO 639-1, or auto (default)
24
- --model <id> a speech model the server offers
24
+ --model <id> a speech model the server offers (without one: parakeet-ultra)
25
25
  --transcript <file> use these captions (the API's Transcript JSON) instead of transcribing
26
26
  --format <id> mp4 (default), mp4-hevc, webm or mov
27
27
  --resolution <side> original (default), 2160, 1080 or 720
@@ -29,8 +29,9 @@ Options for caption:
29
29
  --captions-only the captions alone over green, no sound, to key out
30
30
  --subtitles <kinds> also write subtitles: srt, vtt, json, comma separated
31
31
 
32
- The server: OPENCAPTIONS_URL (default http://localhost:5173) and OPENCAPTIONS_KEY, an API key
33
- from its Account page.`;
32
+ It runs on this computer, with FFmpeg installed; the speech model (700 MB) is downloaded on first
33
+ use. To use an OpenCaptions server instead, set OPENCAPTIONS_KEY (an API key from its Account
34
+ page) and OPENCAPTIONS_URL (default http://localhost:5173).`;
34
35
  /** Progress on one line of the terminal, rewritten as it moves. */
35
36
  const report = (fraction, message) => {
36
37
  if (process.stderr.isTTY) {
@@ -73,8 +74,8 @@ async function caption(args) {
73
74
  frame_rate: values.fps,
74
75
  captions_only: values["captions-only"],
75
76
  };
76
- const client = api(connectionFromEnv());
77
- const result = await captionVideo(client, request, report);
77
+ const captioner = await captionerFromEnv();
78
+ const result = await captioner.caption(request, report);
78
79
  if (process.stderr.isTTY)
79
80
  process.stderr.write("\r\x1b[2K");
80
81
  if (!result.done)
@@ -84,7 +85,7 @@ async function caption(args) {
84
85
  if (kind !== "srt" && kind !== "vtt" && kind !== "json") {
85
86
  throw new OpenCaptionsError(`Subtitles are srt, vtt or json, not ${kind}.`);
86
87
  }
87
- console.log(await exportSubtitles(client, result.project_id, kind, result.output.replace(/\.[^.]+$/, "")));
88
+ console.log(await captioner.exportSubtitles(result.project_id, kind, result.output.replace(/\.[^.]+$/, "")));
88
89
  }
89
90
  }
90
91
  async function main(argv) {
package/dist/engine.js CHANGED
@@ -1,10 +1,12 @@
1
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.
2
+ * The caption engine, through the same WebAssembly file the web editor loads: a word is fixed, a
3
+ * look's word variants dealt, a frame drawn and a file's FFmpeg command made by the code every
4
+ * editor and the render server use, never by a second implementation here.
5
5
  */
6
- import { readFile } from "node:fs/promises";
6
+ import { readdir, readFile } from "node:fs/promises";
7
7
  const WASM = new URL("../engine/opencaptions_engine.wasm", import.meta.url);
8
+ /** The faces the render server bundles (apps/engine/fonts), copied beside the WebAssembly. */
9
+ const FONTS = new URL("../engine/fonts/", import.meta.url);
8
10
  const encoder = new TextEncoder();
9
11
  const decoder = new TextDecoder();
10
12
  let loaded;
@@ -23,18 +25,24 @@ function copyIn(x, bytes) {
23
25
  function result(x, ok) {
24
26
  const text = decoder.decode(new Uint8Array(x.memory.buffer, x.oc_result_ptr(), x.oc_result_len()));
25
27
  if (!ok)
26
- throw new Error(`the caption engine refused the edit: ${text}`);
28
+ throw new Error(`the caption engine refused it: ${text}`);
27
29
  return JSON.parse(text);
28
30
  }
29
- const json = (x, t) => copyIn(x, encoder.encode(JSON.stringify(t)));
31
+ const json = (x, value) => copyIn(x, encoder.encode(JSON.stringify(value)));
32
+ const text = (x, value) => copyIn(x, encoder.encode(value));
30
33
  export async function captionLines(t, wordsPerLine, offsetMs) {
31
34
  const x = await exports();
32
35
  return result(x, x.oc_caption_lines(...json(x, t), wordsPerLine, offsetMs));
33
36
  }
34
37
  /** Word `index` (counted across segments) given `text`; empty text removes it. */
35
- export async function setWord(t, index, text) {
38
+ export async function setWord(t, index, word) {
36
39
  const x = await exports();
37
- return result(x, x.oc_set_word(...json(x, t), index, ...copyIn(x, encoder.encode(text))));
40
+ return result(x, x.oc_set_word(...json(x, t), index, ...text(x, word)));
41
+ }
42
+ /** `count` words from `index` replaced by one word `text`, spanning them all. */
43
+ export async function mergeWords(t, index, count, word) {
44
+ const x = await exports();
45
+ return result(x, x.oc_merge_words(...json(x, t), index, count, ...text(x, word)));
38
46
  }
39
47
  /** Deals every word one of `count` looks, or the style's own, as picking such a look does. */
40
48
  export async function shuffleVariants(t, count) {
@@ -42,3 +50,65 @@ export async function shuffleVariants(t, count) {
42
50
  const seed = crypto.getRandomValues(new Uint32Array(1))[0] ?? 0;
43
51
  return result(x, x.oc_shuffle_variants(...json(x, t), count, seed));
44
52
  }
53
+ /** The captions as SubRip or WebVTT, cue for cue the caption lines. */
54
+ export async function subtitles(t, wordsPerLine, offsetMs, format) {
55
+ const x = await exports();
56
+ return result(x, x.oc_subtitles(...json(x, t), wordsPerLine, offsetMs, format === "srt" ? 0 : 1));
57
+ }
58
+ let bundled;
59
+ /** The bundled faces, registered once, in name order as the render server loads them. */
60
+ function bundledFonts(x) {
61
+ bundled ??= readdir(FONTS).then(async (files) => {
62
+ for (const file of files.filter((f) => /\.(ttf|otf)$/i.test(f)).sort()) {
63
+ x.oc_add_font(...copyIn(x, await readFile(new URL(file, FONTS))));
64
+ }
65
+ });
66
+ return bundled;
67
+ }
68
+ /** Whether a family can be drawn without fetching it. */
69
+ export async function hasFont(family) {
70
+ const x = await exports();
71
+ await bundledFonts(x);
72
+ return x.oc_has_font(...text(x, family)) === 1;
73
+ }
74
+ /** Registers a family fetched for the style (`fonts.ts`). False if it is not a usable font. */
75
+ export async function addFont(family, bytes) {
76
+ const x = await exports();
77
+ await bundledFonts(x);
78
+ return x.oc_add_requested_font(...text(x, family), ...copyIn(x, bytes)) === 1;
79
+ }
80
+ /** A scene laid out once and drawn frame by frame, as the render server draws an export. */
81
+ export class Scene {
82
+ x;
83
+ handle;
84
+ width;
85
+ constructor(x, handle, width) {
86
+ this.x = x;
87
+ this.handle = handle;
88
+ this.width = width;
89
+ }
90
+ static async create(input) {
91
+ const x = await exports();
92
+ await bundledFonts(x);
93
+ const handle = x.oc_scene_new(...json(x, input));
94
+ if (!handle)
95
+ result(x, 0);
96
+ return new Scene(x, handle, input.width);
97
+ }
98
+ plan(output) {
99
+ return result(this.x, this.x.oc_encode_plan(this.handle, ...json(this.x, output)));
100
+ }
101
+ /** The `band` (x, y, w, h) of the frame at `t` seconds, as RGBA rows, into `into`. */
102
+ band(t, [bx, by, bw, bh], into) {
103
+ this.x.oc_render(this.handle, t);
104
+ const frame = new Uint8Array(this.x.memory.buffer, this.x.oc_frame_ptr(this.handle));
105
+ const row = bw * 4;
106
+ for (let y = 0; y < bh; y++) {
107
+ const start = ((by + y) * this.width + bx) * 4;
108
+ into.set(frame.subarray(start, start + row), y * row);
109
+ }
110
+ }
111
+ free() {
112
+ this.x.oc_scene_free(this.handle);
113
+ }
114
+ }
@@ -0,0 +1,42 @@
1
+ [
2
+ {
3
+ "id": "mp4",
4
+ "label": "MP4 (H.264)",
5
+ "codec": "h264",
6
+ "extension": ".mp4",
7
+ "mime": "video/mp4",
8
+ "crf": 15,
9
+ "pro_res_profile": null,
10
+ "note": null
11
+ },
12
+ {
13
+ "id": "mp4-hevc",
14
+ "label": "MP4 (H.265)",
15
+ "codec": "h265",
16
+ "extension": ".mp4",
17
+ "mime": "video/mp4",
18
+ "crf": 20,
19
+ "pro_res_profile": null,
20
+ "note": null
21
+ },
22
+ {
23
+ "id": "webm",
24
+ "label": "WebM (VP9)",
25
+ "codec": "vp9",
26
+ "extension": ".webm",
27
+ "mime": "video/webm",
28
+ "crf": 24,
29
+ "pro_res_profile": null,
30
+ "note": "VP9 encoding is very slow, expect 5-10x real-time on a modern CPU."
31
+ },
32
+ {
33
+ "id": "mov",
34
+ "label": "MOV (ProRes)",
35
+ "codec": "prores",
36
+ "extension": ".mov",
37
+ "mime": "video/quicktime",
38
+ "crf": null,
39
+ "pro_res_profile": "hq",
40
+ "note": "ProRes files are very large (~220 Mbps, roughly 1 GB per 40 seconds of video)."
41
+ }
42
+ ]
@@ -0,0 +1,8 @@
1
+ /** Where captioning without a server keeps what it downloads: the speech model and fonts. */
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export function cacheDir() {
5
+ const base = process.env.OPENCAPTIONS_CACHE ||
6
+ (process.env.XDG_CACHE_HOME ? join(process.env.XDG_CACHE_HOME, "opencaptions") : "");
7
+ return base || join(homedir(), ".cache", "opencaptions");
8
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The style's font, for captioning without a server: a bundled face when there is one, else the
3
+ * family from Google Fonts at its weight nearest 800, fetched once into the cache. The same choice
4
+ * as the API's (`apps/api/app/services/fonts.py`), so a look draws the same on both.
5
+ */
6
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { addFont, hasFont } from "../engine.js";
9
+ import { cacheDir } from "./cache.js";
10
+ const CATALOG_URL = "https://fonts.google.com/metadata/fonts";
11
+ const CSS_URL = "https://fonts.googleapis.com/css2";
12
+ // The CSS API picks the format from the client: one it does not take for a browser gets
13
+ // TrueType, which the engine reads and woff2 is not.
14
+ const HEADERS = { "User-Agent": "OpenCaptions" };
15
+ const TARGET_WEIGHT = 800;
16
+ /** The family's upright weight nearest 800; ties go to the heavier one. */
17
+ export function nearestWeight(styles) {
18
+ const upright = Object.keys(styles)
19
+ .filter((k) => /^\d+$/.test(k))
20
+ .map(Number);
21
+ if (upright.length === 0)
22
+ return 400;
23
+ return upright.reduce((best, w) => {
24
+ const d = Math.abs(w - TARGET_WEIGHT) - Math.abs(best - TARGET_WEIGHT);
25
+ return d < 0 || (d === 0 && w > best) ? w : best;
26
+ });
27
+ }
28
+ async function weightOf(family) {
29
+ const path = join(cacheDir(), "fonts", "catalog.json");
30
+ let weights;
31
+ try {
32
+ weights = JSON.parse(await readFile(path, "utf8"));
33
+ }
34
+ catch {
35
+ const response = await fetch(CATALOG_URL, { headers: HEADERS });
36
+ if (!response.ok)
37
+ return undefined;
38
+ const data = JSON.parse((await response.text()).replace(/^\)\]\}'/, ""));
39
+ weights = Object.fromEntries(data.familyMetadataList.map((f) => [f.family, nearestWeight(f.fonts ?? {})]));
40
+ await mkdir(join(cacheDir(), "fonts"), { recursive: true });
41
+ await writeFile(path, JSON.stringify(weights));
42
+ }
43
+ return weights[family];
44
+ }
45
+ async function download(family) {
46
+ const weight = await weightOf(family);
47
+ if (weight === undefined)
48
+ return undefined;
49
+ const css = await fetch(`${CSS_URL}?family=${encodeURIComponent(`${family}:wght@${weight}`)}`, {
50
+ headers: HEADERS,
51
+ });
52
+ if (!css.ok)
53
+ return undefined;
54
+ const url = /url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)/.exec(await css.text());
55
+ if (!url?.[1])
56
+ return undefined;
57
+ const font = await fetch(url[1], { headers: HEADERS });
58
+ return font.ok ? new Uint8Array(await font.arrayBuffer()) : undefined;
59
+ }
60
+ /**
61
+ * Makes `family` drawable. False when it is neither bundled nor to be had: the engine then draws
62
+ * its default face, as the server does in the same case.
63
+ */
64
+ export async function ensureFont(family) {
65
+ if (await hasFont(family))
66
+ return true;
67
+ const slug = family.replace(/[^A-Za-z0-9]+/g, "-");
68
+ const path = join(cacheDir(), "fonts", `${slug}.ttf`);
69
+ let bytes;
70
+ try {
71
+ bytes = await readFile(path);
72
+ }
73
+ catch {
74
+ bytes = await download(family).catch(() => undefined);
75
+ if (bytes) {
76
+ await mkdir(join(cacheDir(), "fonts"), { recursive: true });
77
+ await writeFile(path, bytes);
78
+ }
79
+ }
80
+ return bytes !== undefined && (await addFont(family, bytes));
81
+ }