@gentbajko/slopify 0.5.1 → 0.6.1
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/SUBTITLES.md +21 -0
- package/dist/adapter-registry.js +8 -3
- package/dist/adapters/alignment/audio.js +45 -0
- package/dist/adapters/alignment/cache.js +91 -0
- package/dist/adapters/alignment/ctc.js +128 -0
- package/dist/adapters/alignment/index.js +34 -0
- package/dist/adapters/alignment/lock.js +58 -0
- package/dist/adapters/alignment/numbers.js +91 -0
- package/dist/adapters/alignment/protocol.js +23 -0
- package/dist/adapters/alignment/quality.js +17 -0
- package/dist/adapters/alignment/runner.js +76 -0
- package/dist/adapters/alignment/text.js +61 -0
- package/dist/adapters/alignment/vocabulary.js +69 -0
- package/dist/adapters/alignment/worker.js +163 -0
- package/dist/adapters/llm/claude-code.js +6 -3
- package/dist/adapters/llm/codex.js +3 -2
- package/dist/adapters/llm/gemini-workspace.js +70 -0
- package/dist/adapters/llm/gemini.js +141 -0
- package/dist/adapters/llm/run-cli.js +6 -9
- package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
- package/dist/assets/fonts/OFL.txt +93 -0
- package/dist/assets/fonts/SOURCE.txt +7 -0
- package/dist/edge/http/app.js +4 -0
- package/dist/edge/http/fonts.js +147 -0
- package/dist/edge/http/projects.js +23 -1
- package/dist/edge/http/providers.js +13 -0
- package/dist/edge/http/subtitles.js +92 -0
- package/dist/kernel/cli-command.js +45 -0
- package/dist/kernel/ports/subtitles.js +1 -0
- package/dist/kernel/runner/providers.js +3 -2
- package/dist/main.js +2 -1
- package/dist/slices/admission/repo.js +2 -0
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/fonts/catalog.js +95 -0
- package/dist/slices/fonts/discovery.js +81 -0
- package/dist/slices/fonts/files.js +37 -0
- package/dist/slices/fonts/index.js +5 -0
- package/dist/slices/fonts/model.js +4 -0
- package/dist/slices/fonts/preview.js +14 -0
- package/dist/slices/fonts/sfnt.js +262 -0
- package/dist/slices/fonts/upload.js +40 -0
- package/dist/slices/settings/cli-paths.js +98 -0
- package/dist/slices/settings/cli-status.js +21 -12
- package/dist/slices/settings/model.js +9 -0
- package/dist/slices/settings/readiness.js +13 -9
- package/dist/slices/storage/downloads.js +2 -0
- package/dist/slices/storage/layout.js +10 -0
- package/dist/slices/storage/model.js +5 -0
- package/dist/slices/storage/repo.js +1 -0
- package/dist/slices/subtitles/captions.js +88 -0
- package/dist/slices/subtitles/model.js +18 -0
- package/dist/slices/subtitles/prepare.js +162 -0
- package/dist/slices/subtitles/transcript.js +34 -0
- package/dist/slices/video/audio-export.js +3 -0
- package/dist/slices/video/ffmpeg.js +14 -7
- package/dist/slices/video/plan.js +4 -4
- package/dist/slices/video/run.js +5 -2
- package/dist/slices/video/write-export.js +85 -20
- package/dist/web/assets/index-CNYs0noB.js +130 -0
- package/dist/web/assets/index-Mk0bvBg-.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-C1fsrIP5.css +0 -1
- package/dist/web/assets/index-DwJ_XTl3.js +0 -81
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { opendir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { extname, join, posix, win32 } from "node:path";
|
|
5
|
+
import { readFontFile, unavailable } from "./files.js";
|
|
6
|
+
import { readFontMetadata } from "./sfnt.js";
|
|
7
|
+
export function systemFontDirectories(platform, env, home) {
|
|
8
|
+
if (platform === "win32")
|
|
9
|
+
return [
|
|
10
|
+
win32.join(env.WINDIR ?? "C:\\Windows", "Fonts"),
|
|
11
|
+
...(env.LOCALAPPDATA ? [win32.join(env.LOCALAPPDATA, "Microsoft", "Windows", "Fonts")] : []),
|
|
12
|
+
];
|
|
13
|
+
if (platform === "darwin")
|
|
14
|
+
return ["/System/Library/Fonts", "/Library/Fonts", posix.join(home, "Library/Fonts")];
|
|
15
|
+
return [
|
|
16
|
+
"/usr/share/fonts",
|
|
17
|
+
"/usr/local/share/fonts",
|
|
18
|
+
posix.join(env.XDG_DATA_HOME ?? posix.join(home, ".local/share"), "fonts"),
|
|
19
|
+
posix.join(home, ".fonts"),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
export async function discoverSystemFonts() {
|
|
23
|
+
return scanFontDirectories(systemFontDirectories(process.platform, process.env, homedir()));
|
|
24
|
+
}
|
|
25
|
+
// Fixed roots only, no symlink traversal. Bounds also cover nested font-manager
|
|
26
|
+
// folders and damaged font files, without making the Settings request unbounded.
|
|
27
|
+
export async function scanFontDirectories(roots) {
|
|
28
|
+
const pending = [...new Set(roots)].map((path) => ({ path, depth: 0 }));
|
|
29
|
+
const files = new Set();
|
|
30
|
+
let entries = 0;
|
|
31
|
+
for (let at = 0; at < pending.length && entries < 10000 && files.size < 2048; at += 1) {
|
|
32
|
+
const dir = pending[at];
|
|
33
|
+
if (dir === undefined)
|
|
34
|
+
continue;
|
|
35
|
+
try {
|
|
36
|
+
for await (const entry of await opendir(dir.path)) {
|
|
37
|
+
entries += 1;
|
|
38
|
+
const path = join(dir.path, entry.name);
|
|
39
|
+
if (entry.isDirectory() && dir.depth < 8)
|
|
40
|
+
pending.push({ path, depth: dir.depth + 1 });
|
|
41
|
+
else if (entry.isFile() &&
|
|
42
|
+
[".ttf", ".otf", ".ttc"].includes(extname(entry.name).toLowerCase()))
|
|
43
|
+
files.add(path);
|
|
44
|
+
if (entries >= 10000 || files.size >= 2048)
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!unavailable(error))
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const fonts = [];
|
|
54
|
+
let bytesRead = 0;
|
|
55
|
+
for (const path of [...files].sort()) {
|
|
56
|
+
if (bytesRead >= 256 * 1024 * 1024)
|
|
57
|
+
break;
|
|
58
|
+
const bytes = await readFontFile(path);
|
|
59
|
+
if (bytes === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
bytesRead += bytes.length;
|
|
62
|
+
for (const face of readFontMetadata(bytes) ?? []) {
|
|
63
|
+
const id = createHash("sha256")
|
|
64
|
+
.update(path)
|
|
65
|
+
.update("\0")
|
|
66
|
+
.update(String(face.index))
|
|
67
|
+
.digest("hex");
|
|
68
|
+
fonts.push({
|
|
69
|
+
id: `system-${id}`,
|
|
70
|
+
name: face.name,
|
|
71
|
+
family: face.family,
|
|
72
|
+
source: "system",
|
|
73
|
+
path,
|
|
74
|
+
extension: face.extension,
|
|
75
|
+
assName: face.assName,
|
|
76
|
+
faceIndex: face.index,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return fonts;
|
|
81
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { fontMaxBytes } from "./model.js";
|
|
4
|
+
export function unavailable(error) {
|
|
5
|
+
return (error instanceof Error &&
|
|
6
|
+
"code" in error &&
|
|
7
|
+
["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP"].includes(String(error.code)));
|
|
8
|
+
}
|
|
9
|
+
export async function readFontFile(path) {
|
|
10
|
+
try {
|
|
11
|
+
if (!(await lstat(path)).isFile())
|
|
12
|
+
return undefined;
|
|
13
|
+
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
14
|
+
try {
|
|
15
|
+
const stat = await file.stat();
|
|
16
|
+
if (!stat.isFile() || stat.size < 12 || stat.size > fontMaxBytes)
|
|
17
|
+
return undefined;
|
|
18
|
+
const bytes = Buffer.alloc(stat.size);
|
|
19
|
+
let offset = 0;
|
|
20
|
+
while (offset < bytes.length) {
|
|
21
|
+
const result = await file.read(bytes, offset, bytes.length - offset, offset);
|
|
22
|
+
if (result.bytesRead === 0)
|
|
23
|
+
return undefined;
|
|
24
|
+
offset += result.bytesRead;
|
|
25
|
+
}
|
|
26
|
+
return bytes;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await file.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (unavailable(error))
|
|
34
|
+
return undefined;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { missingFont, resolveFont } from "./catalog.js";
|
|
2
|
+
import { readFontFile } from "./files.js";
|
|
3
|
+
import { standaloneFont } from "./sfnt.js";
|
|
4
|
+
export async function previewFont(paths, id) {
|
|
5
|
+
const font = await resolveFont(paths, id);
|
|
6
|
+
const bytes = await readFontFile(font.path);
|
|
7
|
+
const content = bytes === undefined ? undefined : standaloneFont(bytes, font.faceIndex);
|
|
8
|
+
if (content === undefined)
|
|
9
|
+
throw missingFont();
|
|
10
|
+
return {
|
|
11
|
+
content,
|
|
12
|
+
contentType: Buffer.from(content).readUInt32BE(0) === 0x4f54544f ? "font/otf" : "font/ttf",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { fontMaxBytes } from "./model.js";
|
|
2
|
+
const trueType = 0x00010000;
|
|
3
|
+
const openType = 0x4f54544f;
|
|
4
|
+
const collection = 0x74746366;
|
|
5
|
+
// Metadata only: outlines remain FreeType's responsibility. Every read and allocation
|
|
6
|
+
// is bounded before using file-controlled offsets, lengths or counts.
|
|
7
|
+
export function readFontMetadata(content) {
|
|
8
|
+
const bytes = Buffer.from(content.buffer, content.byteOffset, content.byteLength);
|
|
9
|
+
const faces = directories(bytes);
|
|
10
|
+
if (faces === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
const result = [];
|
|
13
|
+
for (const [index, face] of faces.entries()) {
|
|
14
|
+
const names = fontNames(bytes, face.tables.get("name"));
|
|
15
|
+
if (names === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
const family = names.get(1) ?? names.get(16);
|
|
18
|
+
const name = names.get(4) ?? family;
|
|
19
|
+
const cff = face.tables.has("CFF ") || face.tables.has("CFF2");
|
|
20
|
+
const assName = cff ? (names.get(6) ?? family) : (names.get(4) ?? names.get(1));
|
|
21
|
+
if (!usable(family) || !usable(name) || !usable(assName))
|
|
22
|
+
return undefined;
|
|
23
|
+
result.push({
|
|
24
|
+
family,
|
|
25
|
+
name,
|
|
26
|
+
assName,
|
|
27
|
+
index,
|
|
28
|
+
extension: bytes.readUInt32BE(0) === collection ? ".ttc" : cff ? ".otf" : ".ttf",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
// Browsers do not consistently address individual TTC faces. Repackage just the
|
|
34
|
+
// selected face, retaining its original tables and names, with fresh SFNT checksums.
|
|
35
|
+
export function standaloneFont(content, index) {
|
|
36
|
+
const bytes = Buffer.from(content.buffer, content.byteOffset, content.byteLength);
|
|
37
|
+
const face = directories(bytes)?.[index];
|
|
38
|
+
if (face === undefined || readFontMetadata(content) === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (bytes.readUInt32BE(0) !== collection)
|
|
41
|
+
return Buffer.from(bytes);
|
|
42
|
+
const tables = [...face.tables.entries()].filter(([tag]) => tag !== "DSIG");
|
|
43
|
+
const header = 12 + tables.length * 16;
|
|
44
|
+
const size = tables.reduce((sum, [, table]) => sum + padded(table.length), header);
|
|
45
|
+
if (size > fontMaxBytes)
|
|
46
|
+
return undefined;
|
|
47
|
+
const result = Buffer.alloc(size);
|
|
48
|
+
result.writeUInt32BE(bytes.readUInt32BE(face.offset), 0);
|
|
49
|
+
result.writeUInt16BE(tables.length, 4);
|
|
50
|
+
const power = Math.floor(Math.log2(tables.length));
|
|
51
|
+
result.writeUInt16BE(2 ** power * 16, 6);
|
|
52
|
+
result.writeUInt16BE(power, 8);
|
|
53
|
+
result.writeUInt16BE(tables.length * 16 - 2 ** power * 16, 10);
|
|
54
|
+
let offset = header;
|
|
55
|
+
let head = 0;
|
|
56
|
+
for (const [index, [tag, table]] of tables.entries()) {
|
|
57
|
+
const record = 12 + index * 16;
|
|
58
|
+
result.write(tag, record, 4, "ascii");
|
|
59
|
+
bytes.copy(result, offset, table.offset, table.offset + table.length);
|
|
60
|
+
if (tag === "head") {
|
|
61
|
+
head = offset;
|
|
62
|
+
result.writeUInt32BE(0, head + 8);
|
|
63
|
+
}
|
|
64
|
+
result.writeUInt32BE(checksum(result.subarray(offset, offset + padded(table.length))), record + 4);
|
|
65
|
+
result.writeUInt32BE(offset, record + 8);
|
|
66
|
+
result.writeUInt32BE(table.length, record + 12);
|
|
67
|
+
offset += padded(table.length);
|
|
68
|
+
}
|
|
69
|
+
result.writeUInt32BE((0xb1b0afba - checksum(result)) >>> 0, head + 8);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
function directories(bytes) {
|
|
73
|
+
if (bytes.length < 12 || bytes.length > fontMaxBytes)
|
|
74
|
+
return undefined;
|
|
75
|
+
const offsets = [0];
|
|
76
|
+
if (bytes.readUInt32BE(0) === collection) {
|
|
77
|
+
const version = bytes.readUInt32BE(4);
|
|
78
|
+
const count = bytes.readUInt32BE(8);
|
|
79
|
+
if (![0x00010000, 0x00020000].includes(version) ||
|
|
80
|
+
count < 1 ||
|
|
81
|
+
count > 256 ||
|
|
82
|
+
12 + count * 4 > bytes.length)
|
|
83
|
+
return undefined;
|
|
84
|
+
offsets.splice(0);
|
|
85
|
+
for (let at = 0; at < count; at += 1)
|
|
86
|
+
offsets.push(bytes.readUInt32BE(12 + at * 4));
|
|
87
|
+
if (new Set(offsets).size !== offsets.length)
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const faces = [];
|
|
91
|
+
for (const offset of offsets) {
|
|
92
|
+
if (!within(bytes, offset, 12) || ![trueType, openType].includes(bytes.readUInt32BE(offset)))
|
|
93
|
+
return undefined;
|
|
94
|
+
const count = bytes.readUInt16BE(offset + 4);
|
|
95
|
+
if (count < 1 || count > 256 || !within(bytes, offset + 12, count * 16))
|
|
96
|
+
return undefined;
|
|
97
|
+
const tables = new Map();
|
|
98
|
+
for (let at = 0; at < count; at += 1) {
|
|
99
|
+
const record = offset + 12 + at * 16;
|
|
100
|
+
const tag = bytes.toString("ascii", record, record + 4);
|
|
101
|
+
const table = {
|
|
102
|
+
offset: bytes.readUInt32BE(record + 8),
|
|
103
|
+
length: bytes.readUInt32BE(record + 12),
|
|
104
|
+
};
|
|
105
|
+
if (tables.has(tag) || table.offset % 4 !== 0 || !within(bytes, table.offset, table.length))
|
|
106
|
+
return undefined;
|
|
107
|
+
tables.set(tag, table);
|
|
108
|
+
}
|
|
109
|
+
const spans = [...tables.values()]
|
|
110
|
+
.filter((table) => table.length > 0)
|
|
111
|
+
.toSorted((a, b) => a.offset - b.offset);
|
|
112
|
+
if (spans.some((table, at) => at > 0 && (spans[at - 1]?.offset ?? 0) + (spans[at - 1]?.length ?? 0) > table.offset))
|
|
113
|
+
return undefined;
|
|
114
|
+
const cff = tables.has("CFF ") || tables.has("CFF2");
|
|
115
|
+
if ((bytes.readUInt32BE(offset) === openType) !== cff || !validTables(bytes, tables))
|
|
116
|
+
return undefined;
|
|
117
|
+
faces.push({ offset, tables });
|
|
118
|
+
}
|
|
119
|
+
return faces;
|
|
120
|
+
}
|
|
121
|
+
function validTables(bytes, tables) {
|
|
122
|
+
const minimum = {
|
|
123
|
+
head: 54,
|
|
124
|
+
maxp: 6,
|
|
125
|
+
cmap: 4,
|
|
126
|
+
hhea: 36,
|
|
127
|
+
hmtx: 4,
|
|
128
|
+
name: 6,
|
|
129
|
+
};
|
|
130
|
+
for (const [tag, size] of Object.entries(minimum))
|
|
131
|
+
if ((tables.get(tag)?.length ?? 0) < size)
|
|
132
|
+
return false;
|
|
133
|
+
const head = tables.get("head");
|
|
134
|
+
const maxp = tables.get("maxp");
|
|
135
|
+
const hhea = tables.get("hhea");
|
|
136
|
+
const hmtx = tables.get("hmtx");
|
|
137
|
+
if (!head || !maxp || !hhea || !hmtx)
|
|
138
|
+
return false;
|
|
139
|
+
const glyphs = bytes.readUInt16BE(maxp.offset + 4);
|
|
140
|
+
const metrics = bytes.readUInt16BE(hhea.offset + 34);
|
|
141
|
+
const units = bytes.readUInt16BE(head.offset + 18);
|
|
142
|
+
if (bytes.readUInt32BE(head.offset + 12) !== 0x5f0f3cf5 ||
|
|
143
|
+
units < 16 ||
|
|
144
|
+
units > 16384 ||
|
|
145
|
+
glyphs === 0 ||
|
|
146
|
+
metrics === 0 ||
|
|
147
|
+
metrics > glyphs ||
|
|
148
|
+
hmtx.length < metrics * 4 + (glyphs - metrics) * 2)
|
|
149
|
+
return false;
|
|
150
|
+
const cff = tables.get("CFF ") ?? tables.get("CFF2");
|
|
151
|
+
if (!validCmap(bytes, tables.get("cmap")))
|
|
152
|
+
return false;
|
|
153
|
+
if (cff)
|
|
154
|
+
return cff.length >= 4 && [1, 2].includes(bytes[cff.offset] ?? 0);
|
|
155
|
+
const glyf = tables.get("glyf");
|
|
156
|
+
const loca = tables.get("loca");
|
|
157
|
+
const format = bytes.readInt16BE(head.offset + 50);
|
|
158
|
+
if (!glyf ||
|
|
159
|
+
!loca ||
|
|
160
|
+
(format !== 0 && format !== 1) ||
|
|
161
|
+
loca.length < (glyphs + 1) * (format === 0 ? 2 : 4))
|
|
162
|
+
return false;
|
|
163
|
+
let previous = 0;
|
|
164
|
+
for (let at = 0; at <= glyphs; at += 1) {
|
|
165
|
+
const next = format === 0
|
|
166
|
+
? bytes.readUInt16BE(loca.offset + at * 2) * 2
|
|
167
|
+
: bytes.readUInt32BE(loca.offset + at * 4);
|
|
168
|
+
if (next < previous || next > glyf.length)
|
|
169
|
+
return false;
|
|
170
|
+
previous = next;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
function validCmap(bytes, table) {
|
|
175
|
+
if (!table || table.length < 4 || bytes.readUInt16BE(table.offset) !== 0)
|
|
176
|
+
return false;
|
|
177
|
+
const count = bytes.readUInt16BE(table.offset + 2);
|
|
178
|
+
if (count < 1 || count > 512 || table.length < 4 + count * 8)
|
|
179
|
+
return false;
|
|
180
|
+
let supported = false;
|
|
181
|
+
for (let at = 0; at < count; at += 1) {
|
|
182
|
+
const offset = bytes.readUInt32BE(table.offset + 4 + at * 8 + 4);
|
|
183
|
+
if (offset < 4 + count * 8 || offset + 4 > table.length)
|
|
184
|
+
return false;
|
|
185
|
+
const format = bytes.readUInt16BE(table.offset + offset);
|
|
186
|
+
const long = [8, 10, 12, 13].includes(format);
|
|
187
|
+
if (long && offset + 8 > table.length)
|
|
188
|
+
return false;
|
|
189
|
+
const length = format === 14
|
|
190
|
+
? offset + 6 <= table.length
|
|
191
|
+
? bytes.readUInt32BE(table.offset + offset + 2)
|
|
192
|
+
: 0
|
|
193
|
+
: long
|
|
194
|
+
? bytes.readUInt32BE(table.offset + offset + 4)
|
|
195
|
+
: bytes.readUInt16BE(table.offset + offset + 2);
|
|
196
|
+
if (length < 4 || offset + length > table.length)
|
|
197
|
+
return false;
|
|
198
|
+
if ([0, 4, 6, 10, 12, 13].includes(format))
|
|
199
|
+
supported = true;
|
|
200
|
+
}
|
|
201
|
+
return supported;
|
|
202
|
+
}
|
|
203
|
+
function fontNames(bytes, table) {
|
|
204
|
+
if (!table || table.length < 6)
|
|
205
|
+
return undefined;
|
|
206
|
+
const format = bytes.readUInt16BE(table.offset);
|
|
207
|
+
const count = bytes.readUInt16BE(table.offset + 2);
|
|
208
|
+
const storage = bytes.readUInt16BE(table.offset + 4);
|
|
209
|
+
if (format > 1 || count > 4096 || storage < 6 + count * 12 || storage > table.length)
|
|
210
|
+
return undefined;
|
|
211
|
+
const names = new Map();
|
|
212
|
+
for (let at = 0; at < count; at += 1) {
|
|
213
|
+
const record = table.offset + 6 + at * 12;
|
|
214
|
+
const platform = bytes.readUInt16BE(record);
|
|
215
|
+
const encoding = bytes.readUInt16BE(record + 2);
|
|
216
|
+
const language = bytes.readUInt16BE(record + 4);
|
|
217
|
+
const id = bytes.readUInt16BE(record + 6);
|
|
218
|
+
const length = bytes.readUInt16BE(record + 8);
|
|
219
|
+
const offset = bytes.readUInt16BE(record + 10);
|
|
220
|
+
if (storage + offset + length > table.length)
|
|
221
|
+
return undefined;
|
|
222
|
+
if (![1, 4, 6, 16].includes(id) ||
|
|
223
|
+
(platform !== 0 && !(platform === 3 && [0, 1, 10].includes(encoding))))
|
|
224
|
+
continue;
|
|
225
|
+
if (length === 0 || length > 1024 || length % 2 !== 0)
|
|
226
|
+
return undefined;
|
|
227
|
+
const score = (platform === 3 ? 2 : 0) + (language === 0x409 ? 1 : 0);
|
|
228
|
+
if ((names.get(id)?.score ?? -1) > score)
|
|
229
|
+
continue;
|
|
230
|
+
let value;
|
|
231
|
+
try {
|
|
232
|
+
value = new TextDecoder("utf-16be", { fatal: true }).decode(bytes.subarray(table.offset + storage + offset, table.offset + storage + offset + length));
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
names.set(id, { value, score });
|
|
238
|
+
}
|
|
239
|
+
return new Map([...names].map(([id, entry]) => [id, entry.value]));
|
|
240
|
+
}
|
|
241
|
+
function usable(value) {
|
|
242
|
+
return (value !== undefined &&
|
|
243
|
+
value.length > 0 &&
|
|
244
|
+
value.length <= 256 &&
|
|
245
|
+
!value.includes(",") &&
|
|
246
|
+
![...value].some((character) => {
|
|
247
|
+
const point = character.codePointAt(0) ?? 0;
|
|
248
|
+
return point < 0x20 || (point >= 0x7f && point <= 0x9f);
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
function within(bytes, offset, length) {
|
|
252
|
+
return offset >= 0 && length >= 0 && offset <= bytes.length && length <= bytes.length - offset;
|
|
253
|
+
}
|
|
254
|
+
function padded(value) {
|
|
255
|
+
return Math.ceil(value / 4) * 4;
|
|
256
|
+
}
|
|
257
|
+
function checksum(bytes) {
|
|
258
|
+
let sum = 0;
|
|
259
|
+
for (let at = 0; at < bytes.length; at += 4)
|
|
260
|
+
sum = (sum + bytes.readUInt32BE(at)) >>> 0;
|
|
261
|
+
return sum;
|
|
262
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { extname, join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { fontMaxBytes } from "./model.js";
|
|
6
|
+
import { readFontMetadata } from "./sfnt.js";
|
|
7
|
+
const filename = z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(255)
|
|
11
|
+
.refine((value) => !value.includes("/") &&
|
|
12
|
+
!value.includes("\\") &&
|
|
13
|
+
!value.includes(":") &&
|
|
14
|
+
![...value].some((character) => (character.codePointAt(0) ?? 0) < 32));
|
|
15
|
+
export async function uploadFont(paths, input) {
|
|
16
|
+
if (!filename.safeParse(input.filename).success)
|
|
17
|
+
return { ok: false, reason: "unsafe-filename" };
|
|
18
|
+
if (![".ttf", ".otf"].includes(extname(input.filename).toLowerCase()))
|
|
19
|
+
return { ok: false, reason: "unsupported-format" };
|
|
20
|
+
if (input.content.byteLength > fontMaxBytes)
|
|
21
|
+
return { ok: false, reason: "too-large" };
|
|
22
|
+
const metadata = readFontMetadata(input.content);
|
|
23
|
+
const face = metadata?.[0];
|
|
24
|
+
if (face === undefined || metadata?.length !== 1 || face.extension === ".ttc")
|
|
25
|
+
return { ok: false, reason: "invalid-font" };
|
|
26
|
+
const id = `uploaded-${createHash("sha256").update(input.content).digest("hex")}`;
|
|
27
|
+
const root = join(paths.dataDir, "fonts");
|
|
28
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
29
|
+
if (!(await lstat(root)).isDirectory())
|
|
30
|
+
throw new Error("The font storage directory is unavailable.");
|
|
31
|
+
const temporary = join(root, `.upload-${randomUUID()}`);
|
|
32
|
+
try {
|
|
33
|
+
await writeFile(temporary, input.content, { mode: 0o600, flag: "wx" });
|
|
34
|
+
await rename(temporary, join(root, `${id}${face.extension}`));
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await rm(temporary, { force: true });
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, font: { id, name: face.name, family: face.family, source: "uploaded" } };
|
|
40
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, stat } from "node:fs/promises";
|
|
3
|
+
import { extname, isAbsolute } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { cliProbeTimeoutMs, readinessFromProbe } from "./cli-status.js";
|
|
6
|
+
import { providerById } from "./model.js";
|
|
7
|
+
import { readSetting, writeSetting } from "./repo.js";
|
|
8
|
+
export const cliPathMaxLength = 4096;
|
|
9
|
+
const pathSchema = z
|
|
10
|
+
.string()
|
|
11
|
+
.trim()
|
|
12
|
+
.max(cliPathMaxLength)
|
|
13
|
+
.refine((value) => value === "" || (isAbsolute(value) && !/[\p{Cc}]/u.test(value)));
|
|
14
|
+
const storedPathSchema = z.string().max(cliPathMaxLength).nullable();
|
|
15
|
+
const saves = new WeakMap();
|
|
16
|
+
export function cliPathStatus(db, id) {
|
|
17
|
+
const provider = providerById(id);
|
|
18
|
+
if (provider.auth !== "cli")
|
|
19
|
+
throw new Error(`${id} is not a CLI provider`);
|
|
20
|
+
const stored = readSetting(db, `cli.path.${id}`);
|
|
21
|
+
const configured = stored === undefined ? null : storedPathSchema.parse(JSON.parse(stored));
|
|
22
|
+
return { configured, command: configured ?? provider.binary };
|
|
23
|
+
}
|
|
24
|
+
// Read on each attempt, so a saved path reaches the next invocation without a restart.
|
|
25
|
+
export function cliBinary(db, id) {
|
|
26
|
+
return cliPathStatus(db, id).command;
|
|
27
|
+
}
|
|
28
|
+
export async function saveCliPath(deps, id, raw) {
|
|
29
|
+
// A slow version probe must not overwrite a later Save or Reset from another tab.
|
|
30
|
+
let queue = saves.get(deps.db);
|
|
31
|
+
if (queue === undefined) {
|
|
32
|
+
queue = new Map();
|
|
33
|
+
saves.set(deps.db, queue);
|
|
34
|
+
}
|
|
35
|
+
const previous = queue.get(id) ?? Promise.resolve();
|
|
36
|
+
const next = previous.then(() => save(deps, id, raw));
|
|
37
|
+
const settled = next.then(() => { }, () => { });
|
|
38
|
+
queue.set(id, settled);
|
|
39
|
+
try {
|
|
40
|
+
return await next;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (queue.get(id) === settled)
|
|
44
|
+
queue.delete(id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function save(deps, id, raw) {
|
|
48
|
+
const provider = providerById(id);
|
|
49
|
+
if (provider.auth !== "cli")
|
|
50
|
+
return { ok: false, message: `${provider.displayName} uses an API key, not a CLI executable.` };
|
|
51
|
+
const parsed = pathSchema.safeParse(raw);
|
|
52
|
+
if (!parsed.success)
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
message: "Enter an absolute executable file path without quotes or arguments, or leave it blank to use PATH.",
|
|
56
|
+
};
|
|
57
|
+
const configured = parsed.data === "" ? null : parsed.data;
|
|
58
|
+
if (configured !== null) {
|
|
59
|
+
try {
|
|
60
|
+
if (!(await stat(configured)).isFile())
|
|
61
|
+
return { ok: false, message: "Choose an executable file, not a directory." };
|
|
62
|
+
await access(configured, process.platform === "win32" ||
|
|
63
|
+
[".js", ".mjs", ".cjs"].includes(extname(configured).toLowerCase())
|
|
64
|
+
? constants.R_OK
|
|
65
|
+
: constants.X_OK);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof Error &&
|
|
69
|
+
"code" in error &&
|
|
70
|
+
["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP"].includes(String(error.code)))
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
message: "That executable file cannot be found or run. Check its path and permissions.",
|
|
74
|
+
};
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const command = configured ?? provider.binary;
|
|
79
|
+
const probed = await deps.probe(command, provider.versionArgs, cliProbeTimeoutMs);
|
|
80
|
+
const readiness = readinessFromProbe(probed);
|
|
81
|
+
if (configured !== null && readiness.kind === "cli" && !readiness.installed)
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
message: probed.error ??
|
|
85
|
+
`${provider.displayName} did not answer --version successfully within ${cliProbeTimeoutMs / 1000} seconds. Choose its executable file without arguments.`,
|
|
86
|
+
};
|
|
87
|
+
writeSetting(deps.db, `cli.path.${id}`, JSON.stringify(configured));
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
status: {
|
|
91
|
+
id,
|
|
92
|
+
family: provider.family,
|
|
93
|
+
displayName: provider.displayName,
|
|
94
|
+
readiness,
|
|
95
|
+
cliPath: { configured, command },
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
export const cliProbeTimeoutMs = 2000;
|
|
2
|
+
import { cliCommand } from "../../kernel/cli-command.js";
|
|
3
|
+
// Gemini's Node startup can take several seconds on Windows. Providers are probed in
|
|
4
|
+
// parallel; a hung process is bounded without treating ordinary startup as missing.
|
|
5
|
+
export const cliProbeTimeoutMs = 15_000;
|
|
7
6
|
const probeOutputMax = 64 * 1024;
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// answer, not a failure. ceiling: POSIX only. A Windows install puts `claude` on PATH as a
|
|
11
|
-
// `.cmd` shim, which execFile cannot run without a shell; a shim-aware lookup is the upgrade
|
|
12
|
-
// when Windows is supported.
|
|
7
|
+
// Known Windows package-manager shims are resolved to an executable plus fixed args;
|
|
8
|
+
// user input never becomes a shell command. Missing/unsupported commands mean not ready.
|
|
13
9
|
export function nodeCliProbe(binary, args, timeoutMs) {
|
|
10
|
+
let command;
|
|
11
|
+
try {
|
|
12
|
+
command = cliCommand(binary);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
return Promise.resolve({
|
|
16
|
+
ran: false,
|
|
17
|
+
stdout: "",
|
|
18
|
+
error: error instanceof Error ? error.message : "The CLI launcher could not be resolved.",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
14
21
|
return new Promise((resolve) => {
|
|
15
|
-
execFile(
|
|
22
|
+
execFile(command.file, [...command.args, ...args], { timeout: timeoutMs, maxBuffer: probeOutputMax, windowsHide: true }, (error, stdout) => {
|
|
16
23
|
resolve(error === null ? { ran: true, stdout } : { ran: false, stdout: "" });
|
|
17
24
|
});
|
|
18
25
|
});
|
|
19
26
|
}
|
|
20
27
|
export async function cliReadiness(probe, provider) {
|
|
21
|
-
|
|
28
|
+
return readinessFromProbe(await probe(provider.binary, provider.versionArgs, cliProbeTimeoutMs));
|
|
29
|
+
}
|
|
30
|
+
export function readinessFromProbe(result) {
|
|
22
31
|
if (!result.ran) {
|
|
23
32
|
return { kind: "cli", installed: false };
|
|
24
33
|
}
|
|
@@ -8,6 +8,7 @@ export const providerIds = [
|
|
|
8
8
|
"openrouter",
|
|
9
9
|
"claude-code",
|
|
10
10
|
"codex",
|
|
11
|
+
"gemini",
|
|
11
12
|
"elevenlabs",
|
|
12
13
|
"openai-tts",
|
|
13
14
|
"cartesia",
|
|
@@ -34,6 +35,14 @@ export const providers = [
|
|
|
34
35
|
binary: "codex",
|
|
35
36
|
versionArgs: ["--version"],
|
|
36
37
|
},
|
|
38
|
+
{
|
|
39
|
+
id: "gemini",
|
|
40
|
+
family: "llm",
|
|
41
|
+
displayName: "Gemini CLI",
|
|
42
|
+
auth: "cli",
|
|
43
|
+
binary: "gemini",
|
|
44
|
+
versionArgs: ["--version"],
|
|
45
|
+
},
|
|
37
46
|
{ id: "elevenlabs", family: "tts", displayName: "ElevenLabs", auth: "key" },
|
|
38
47
|
{ id: "openai-tts", family: "tts", displayName: "OpenAI", auth: "key" },
|
|
39
48
|
{ id: "cartesia", family: "tts", displayName: "Cartesia", auth: "key" },
|
|
@@ -1,19 +1,23 @@
|
|
|
1
|
+
import { cliPathStatus } from "./cli-paths.js";
|
|
1
2
|
import { cliReadiness } from "./cli-status.js";
|
|
2
3
|
import { providers } from "./model.js";
|
|
3
4
|
import { keyedProviders } from "./repo.js";
|
|
4
5
|
// Every supported provider is listed, keyed or not, found or not, so Play can grey one out with
|
|
5
6
|
// a reason instead of hiding it. A keyed provider is ready when a key is stored; a CLI provider
|
|
6
|
-
// when its binary answers. Readiness is computed per request
|
|
7
|
+
// when its binary answers. Readiness is computed per request using the latest saved executable override.
|
|
7
8
|
export async function providerStatuses(deps) {
|
|
8
9
|
const keyed = keyedProviders(deps.db);
|
|
9
|
-
return await Promise.all(providers.map(async (provider) =>
|
|
10
|
-
id: provider.id,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
return await Promise.all(providers.map(async (provider) => {
|
|
11
|
+
const base = { id: provider.id, family: provider.family, displayName: provider.displayName };
|
|
12
|
+
if (provider.auth !== "cli")
|
|
13
|
+
return { ...base, readiness: keyedReadiness(keyed, provider.id) };
|
|
14
|
+
const cliPath = cliPathStatus(deps.db, provider.id);
|
|
15
|
+
return {
|
|
16
|
+
...base,
|
|
17
|
+
cliPath,
|
|
18
|
+
readiness: await cliReadiness(deps.probe, { ...provider, binary: cliPath.command }),
|
|
19
|
+
};
|
|
20
|
+
}));
|
|
17
21
|
}
|
|
18
22
|
function keyedReadiness(keyed, id) {
|
|
19
23
|
return { kind: "keyed", hasKey: keyed.has(id) };
|
|
@@ -19,6 +19,8 @@ const contentTypes = {
|
|
|
19
19
|
".ogg": "audio/ogg",
|
|
20
20
|
".opus": "audio/opus",
|
|
21
21
|
".mp4": "video/mp4",
|
|
22
|
+
".srt": "application/x-subrip; charset=utf-8",
|
|
23
|
+
".vtt": "text/vtt; charset=utf-8",
|
|
22
24
|
};
|
|
23
25
|
// Leaves room for "-<asset>.<ext>" inside a 255-byte filename, and keeps a download
|
|
24
26
|
// folder readable when a title runs to the 200 characters the schema allows.
|