@gentbajko/slopify 0.5.1 → 0.7.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/README.md +36 -1
- package/SUBTITLES.md +21 -0
- package/dist/adapter-registry.js +18 -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/image/google.js +12 -15
- package/dist/adapters/image/models.js +72 -0
- package/dist/adapters/image/openai.js +3 -5
- package/dist/adapters/llm/catalogue-files.js +23 -0
- package/dist/adapters/llm/claude-code.js +9 -7
- package/dist/adapters/llm/codex-models.js +46 -0
- package/dist/adapters/llm/codex.js +7 -12
- package/dist/adapters/llm/gemini-models.js +85 -0
- package/dist/adapters/llm/gemini-workspace.js +70 -0
- package/dist/adapters/llm/gemini.js +138 -0
- package/dist/adapters/llm/openrouter.js +11 -2
- package/dist/adapters/llm/run-cli.js +6 -9
- package/dist/adapters/tts/cartesia.js +9 -1
- package/dist/adapters/tts/elevenlabs.js +33 -2
- package/dist/adapters/tts/inworld-async.js +150 -0
- package/dist/adapters/tts/inworld-text.js +32 -0
- package/dist/adapters/tts/inworld.js +170 -0
- package/dist/adapters/tts/openai.js +31 -4
- 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/cli.js +5 -0
- package/dist/edge/events/hub.js +9 -2
- package/dist/edge/events/preview-cache.js +40 -0
- package/dist/edge/http/actions.js +2 -0
- package/dist/edge/http/app.js +30 -1
- package/dist/edge/http/audio-preview.js +46 -0
- package/dist/edge/http/fonts.js +147 -0
- package/dist/edge/http/projects.js +23 -1
- package/dist/edge/http/providers.js +29 -0
- package/dist/edge/http/subtitles.js +92 -0
- package/dist/edge/http/update.js +57 -0
- package/dist/edge/update-worker.js +44 -0
- package/dist/kernel/audio-preview.js +188 -0
- package/dist/kernel/cli-command.js +45 -0
- package/dist/kernel/ports/subtitles.js +1 -0
- package/dist/kernel/runner/providers.js +75 -24
- package/dist/main.js +106 -25
- package/dist/model-catalog.js +37 -0
- package/dist/slices/admission/repo.js +2 -0
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/control/index.js +1 -0
- package/dist/slices/control/providers.js +2 -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/narration/live.js +21 -0
- package/dist/slices/narration/run.js +9 -9
- package/dist/slices/research/run.js +3 -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 +11 -0
- package/dist/slices/settings/models.js +89 -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 +91 -0
- package/dist/slices/subtitles/layout.js +19 -0
- package/dist/slices/subtitles/model.js +27 -0
- package/dist/slices/subtitles/prepare.js +167 -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/updater/candidate.js +28 -0
- package/dist/updater/forward.js +35 -0
- package/dist/updater/install-flow.js +26 -0
- package/dist/updater/install.js +50 -0
- package/dist/updater/model.js +24 -0
- package/dist/updater/plan.js +135 -0
- package/dist/updater/readiness.js +15 -0
- package/dist/updater/registry.js +19 -0
- package/dist/updater/service.js +128 -0
- package/dist/updater/worker.js +136 -0
- package/dist/web/assets/index-6QIQ-TzO.css +1 -0
- package/dist/web/assets/index-CNgWL-ct.js +130 -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,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,21 @@
|
|
|
1
|
+
export function observeNarration(store, projectId, key, label) {
|
|
2
|
+
if (store === undefined)
|
|
3
|
+
return undefined;
|
|
4
|
+
let sink;
|
|
5
|
+
return (event) => {
|
|
6
|
+
switch (event.type) {
|
|
7
|
+
case "start":
|
|
8
|
+
sink = store.begin(projectId, key, label);
|
|
9
|
+
break;
|
|
10
|
+
case "chunk":
|
|
11
|
+
sink?.append(event.bytes);
|
|
12
|
+
break;
|
|
13
|
+
case "complete":
|
|
14
|
+
sink?.complete();
|
|
15
|
+
break;
|
|
16
|
+
case "interrupted":
|
|
17
|
+
sink?.interrupt();
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -11,6 +11,7 @@ import { insertOutput, outputsOf } from "../storage/repo.js";
|
|
|
11
11
|
import { probeDurationMs } from "../video/ffmpeg.js";
|
|
12
12
|
import { chunkNarration, defaultChunking } from "./chunk.js";
|
|
13
13
|
import { joinNarration } from "./concat.js";
|
|
14
|
+
import { observeNarration } from "./live.js";
|
|
14
15
|
// An empty narration source fails the stage immediately with no retries. Thrown from the
|
|
15
16
|
// slice rather than from a provider call, so the attempt wrapper never sees it.
|
|
16
17
|
export const nothingToNarrate = "nothing to narrate";
|
|
@@ -31,6 +32,7 @@ const segmentPayload = z.object({
|
|
|
31
32
|
const chunkDir = "audio-chunks";
|
|
32
33
|
export async function runNarration(deps, context, providers) {
|
|
33
34
|
const { projectId } = context.stage;
|
|
35
|
+
deps.audioPreviews?.clear(projectId);
|
|
34
36
|
const project = projectById(deps.db, projectId);
|
|
35
37
|
if (project === undefined) {
|
|
36
38
|
throw new Error(`project ${projectId} has no row`);
|
|
@@ -110,9 +112,10 @@ async function speakChunks(deps, context, providers, choice, pieces) {
|
|
|
110
112
|
try {
|
|
111
113
|
const spoken = await providers.forPiece(piece.id).tts({
|
|
112
114
|
provider: choice.provider,
|
|
115
|
+
model: choice.model,
|
|
113
116
|
voiceId: choice.voice,
|
|
114
117
|
text: carried.text,
|
|
115
|
-
});
|
|
118
|
+
}, observeNarration(deps.audioPreviews, projectId, piece.id, total === 1 ? "Body" : `Body part ${piece.idx} of ${total}`));
|
|
116
119
|
write(deps, projectId, file, spoken.bytes);
|
|
117
120
|
// The file is on disk before the row says so: a crash between the two leaves a
|
|
118
121
|
// file the reconcile collects, where the other order would leave a `done` chunk
|
|
@@ -180,9 +183,10 @@ async function speakSegments(deps, context, providers, choice, outputs) {
|
|
|
180
183
|
}
|
|
181
184
|
const spoken = await providers.forPiece(piece.id).tts({
|
|
182
185
|
provider: choice.provider,
|
|
186
|
+
model: choice.model,
|
|
183
187
|
voiceId: choice.voice,
|
|
184
188
|
text,
|
|
185
|
-
});
|
|
189
|
+
}, observeNarration(deps.audioPreviews, projectId, piece.id, segment.category === "intro" ? "Intro" : "Outro"));
|
|
186
190
|
const name = outputFileName(role, 1, ".mp3", "audio");
|
|
187
191
|
write(deps, projectId, name, spoken.bytes);
|
|
188
192
|
// Measured the same way the body is, because the render adds all three and the gaps to
|
|
@@ -208,14 +212,13 @@ function segmentPieces(deps, projectId) {
|
|
|
208
212
|
}
|
|
209
213
|
return piecesOf(deps.db, article.id, "segment");
|
|
210
214
|
}
|
|
211
|
-
// Audio seconds come from the measured duration per segment, never from the text's length
|
|
212
|
-
// the real duration goes on the row and the video timeline is built from it. No model is
|
|
213
|
-
// named because the TTS port carries none - the same reason `store` above leaves it off.
|
|
215
|
+
// Audio seconds come from the measured duration per segment, never from the text's length.
|
|
214
216
|
function counted(deps, segment, durationMs, choice) {
|
|
215
217
|
deps.count("stage.completed", {
|
|
216
218
|
stage: "audio",
|
|
217
219
|
segment,
|
|
218
220
|
provider: choice.provider,
|
|
221
|
+
model: choice.model,
|
|
219
222
|
audioSeconds: durationMs / 1000,
|
|
220
223
|
});
|
|
221
224
|
}
|
|
@@ -246,9 +249,6 @@ function write(deps, projectId, path, bytes) {
|
|
|
246
249
|
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
247
250
|
writeFileSync(target, bytes, { mode: 0o600 });
|
|
248
251
|
}
|
|
249
|
-
// The provider and the voice are stored with the audio they made. The model is not:
|
|
250
|
-
// the TTS port carries no model, so every request went to the adapter's own, and writing
|
|
251
|
-
// the run's dropdown value here would record something that was never sent.
|
|
252
252
|
function store(deps, projectId, role, path, durationMs, choice) {
|
|
253
253
|
insertOutput(deps.db, {
|
|
254
254
|
id: deps.ids.next(),
|
|
@@ -259,7 +259,7 @@ function store(deps, projectId, role, path, durationMs, choice) {
|
|
|
259
259
|
originalFilename: null,
|
|
260
260
|
bytes: statSync(outputPath(deps.paths, projectId, path)).size,
|
|
261
261
|
durationMs,
|
|
262
|
-
meta: { provider: choice.provider, voice: choice.voice },
|
|
262
|
+
meta: { provider: choice.provider, model: choice.model, voice: choice.voice },
|
|
263
263
|
createdAt: deps.clock.now().toISOString(),
|
|
264
264
|
});
|
|
265
265
|
}
|
|
@@ -53,6 +53,7 @@ async function research(deps, context, providers, choice, brief) {
|
|
|
53
53
|
provider: choice.provider,
|
|
54
54
|
model: choice.model,
|
|
55
55
|
messages: synthesisMessages(brief, findings),
|
|
56
|
+
previewLabel: "Writing research notes",
|
|
56
57
|
check: (given) => sourcedAnswer("the synthesis", given.text),
|
|
57
58
|
});
|
|
58
59
|
add(answer.usage);
|
|
@@ -89,6 +90,7 @@ async function plan(deps, context, providers, choice, brief, add) {
|
|
|
89
90
|
provider: choice.provider,
|
|
90
91
|
model: choice.model,
|
|
91
92
|
messages: plannerMessages(brief),
|
|
93
|
+
previewLabel: "Planning research",
|
|
92
94
|
// An empty answer, or one with no chapter in it, is a failed attempt.
|
|
93
95
|
check: (given) => chaptersFrom(given.text).length === 0 ? "the planner named no chapters" : undefined,
|
|
94
96
|
});
|
|
@@ -129,6 +131,7 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
|
|
|
129
131
|
provider: choice.provider,
|
|
130
132
|
model: choice.model,
|
|
131
133
|
messages: subAgentMessages(brief, kept.title, outline),
|
|
134
|
+
previewLabel: kept.title,
|
|
132
135
|
// Grounding is asked for explicitly, so a model without it says so
|
|
133
136
|
// instead of answering from what it already knows.
|
|
134
137
|
webSearch: true,
|
|
@@ -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,9 +8,11 @@ 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",
|
|
15
|
+
"inworld",
|
|
14
16
|
"fal",
|
|
15
17
|
"replicate",
|
|
16
18
|
"openai-image",
|
|
@@ -34,9 +36,18 @@ export const providers = [
|
|
|
34
36
|
binary: "codex",
|
|
35
37
|
versionArgs: ["--version"],
|
|
36
38
|
},
|
|
39
|
+
{
|
|
40
|
+
id: "gemini",
|
|
41
|
+
family: "llm",
|
|
42
|
+
displayName: "Gemini CLI",
|
|
43
|
+
auth: "cli",
|
|
44
|
+
binary: "gemini",
|
|
45
|
+
versionArgs: ["--version"],
|
|
46
|
+
},
|
|
37
47
|
{ id: "elevenlabs", family: "tts", displayName: "ElevenLabs", auth: "key" },
|
|
38
48
|
{ id: "openai-tts", family: "tts", displayName: "OpenAI", auth: "key" },
|
|
39
49
|
{ id: "cartesia", family: "tts", displayName: "Cartesia", auth: "key" },
|
|
50
|
+
{ id: "inworld", family: "tts", displayName: "Inworld", auth: "key" },
|
|
40
51
|
{ id: "fal", family: "image", displayName: "fal.ai", auth: "key" },
|
|
41
52
|
{ id: "replicate", family: "image", displayName: "Replicate", auth: "key" },
|
|
42
53
|
{ id: "openai-image", family: "image", displayName: "OpenAI", auth: "key" },
|