@gentbajko/slopify 0.5.0 → 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/events/hub.js +4 -0
- package/dist/edge/http/actions.js +68 -14
- package/dist/edge/http/app.js +4 -0
- package/dist/edge/http/fonts.js +147 -0
- package/dist/edge/http/projects.js +40 -11
- 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/db/migrations/0002-project-controls.sql +4 -0
- package/dist/kernel/pipeline.js +10 -5
- package/dist/kernel/ports/subtitles.js +1 -0
- package/dist/kernel/runner/graph.js +18 -18
- package/dist/kernel/runner/index.js +10 -6
- package/dist/kernel/runner/piece-repo.js +8 -1
- package/dist/kernel/runner/providers.js +3 -2
- package/dist/main.js +32 -4
- package/dist/slices/admission/model.js +2 -2
- package/dist/slices/admission/repo.js +22 -4
- package/dist/slices/admission/rules.js +32 -12
- package/dist/slices/admission/start.js +3 -1
- package/dist/slices/article/provided-entries.js +62 -0
- package/dist/slices/article/run.js +63 -101
- package/dist/slices/article/segments.js +73 -0
- package/dist/slices/cancel/index.js +27 -4
- package/dist/slices/control/index.js +155 -0
- package/dist/slices/control/lock.js +19 -0
- package/dist/slices/control/providers.js +59 -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/library/slots.js +3 -1
- package/dist/slices/reruns/cascade.js +11 -7
- package/dist/slices/reruns/index.js +23 -1
- 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 +12 -0
- package/dist/slices/storage/model.js +6 -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 +69 -0
- package/dist/slices/video/audio-inputs.js +32 -0
- package/dist/slices/video/ffmpeg.js +19 -12
- package/dist/slices/video/plan.js +13 -12
- package/dist/slices/video/run.js +28 -141
- package/dist/slices/video/write-export.js +163 -0
- 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-BZvZKvIv.js +0 -81
- package/dist/web/assets/index-cpjfvs5E.css +0 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { discoverSystemFonts } from "./discovery.js";
|
|
6
|
+
import { readFontFile, unavailable } from "./files.js";
|
|
7
|
+
import { fontSummary } from "./model.js";
|
|
8
|
+
import { readFontMetadata } from "./sfnt.js";
|
|
9
|
+
export const fontIdPattern = /^(?:default|(?:system|uploaded)-[a-f0-9]{64})$/;
|
|
10
|
+
const uploadName = /^(uploaded-[a-f0-9]{64})\.(ttf|otf)$/;
|
|
11
|
+
const bundled = fileURLToPath(new URL("../../assets/fonts/Barlow-Regular.ttf", import.meta.url));
|
|
12
|
+
export async function listFonts(paths) {
|
|
13
|
+
const [defaultFont, uploaded, system] = await Promise.all([
|
|
14
|
+
bundledFont(),
|
|
15
|
+
uploadedFonts(paths),
|
|
16
|
+
discoverSystemFonts(),
|
|
17
|
+
]);
|
|
18
|
+
return [defaultFont, ...uploaded, ...system].map(fontSummary);
|
|
19
|
+
}
|
|
20
|
+
export async function resolveFont(paths, id) {
|
|
21
|
+
if (!fontIdPattern.test(id))
|
|
22
|
+
throw missingFont();
|
|
23
|
+
if (id === "default")
|
|
24
|
+
return bundledFont();
|
|
25
|
+
const fonts = id.startsWith("uploaded-")
|
|
26
|
+
? await uploadedFonts(paths, id)
|
|
27
|
+
: await discoverSystemFonts();
|
|
28
|
+
const font = fonts.find((font) => font.id === id);
|
|
29
|
+
if (font === undefined)
|
|
30
|
+
throw missingFont();
|
|
31
|
+
return font;
|
|
32
|
+
}
|
|
33
|
+
export function missingFont() {
|
|
34
|
+
return new Error("The selected font is no longer available. Choose another font.", {
|
|
35
|
+
cause: "font-not-found",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function isMissingFont(error) {
|
|
39
|
+
return error instanceof Error && error.cause === "font-not-found";
|
|
40
|
+
}
|
|
41
|
+
async function bundledFont() {
|
|
42
|
+
const bytes = await readFontFile(bundled);
|
|
43
|
+
const face = bytes === undefined ? undefined : readFontMetadata(bytes)?.[0];
|
|
44
|
+
if (face === undefined)
|
|
45
|
+
throw new Error("Slopify's bundled Barlow font is missing or invalid. Reinstall Slopify.");
|
|
46
|
+
return {
|
|
47
|
+
id: "default",
|
|
48
|
+
name: face.name,
|
|
49
|
+
family: face.family,
|
|
50
|
+
source: "bundled",
|
|
51
|
+
path: bundled,
|
|
52
|
+
extension: ".ttf",
|
|
53
|
+
assName: face.assName,
|
|
54
|
+
faceIndex: 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function uploadedFonts(paths, wanted) {
|
|
58
|
+
const root = join(paths.dataDir, "fonts");
|
|
59
|
+
let names;
|
|
60
|
+
try {
|
|
61
|
+
if (!(await lstat(root)).isDirectory())
|
|
62
|
+
return [];
|
|
63
|
+
names = wanted === undefined ? await readdir(root) : [`${wanted}.ttf`, `${wanted}.otf`];
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (unavailable(error))
|
|
67
|
+
return [];
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
const result = [];
|
|
71
|
+
for (const name of names.toSorted()) {
|
|
72
|
+
const id = uploadName.exec(name)?.[1];
|
|
73
|
+
if (id === undefined)
|
|
74
|
+
continue;
|
|
75
|
+
const path = join(root, name);
|
|
76
|
+
const bytes = await readFontFile(path);
|
|
77
|
+
if (bytes === undefined ||
|
|
78
|
+
`uploaded-${createHash("sha256").update(bytes).digest("hex")}` !== id)
|
|
79
|
+
continue;
|
|
80
|
+
const face = readFontMetadata(bytes)?.[0];
|
|
81
|
+
if (face === undefined || face.extension === ".ttc")
|
|
82
|
+
continue;
|
|
83
|
+
result.push({
|
|
84
|
+
id,
|
|
85
|
+
name: face.name,
|
|
86
|
+
family: face.family,
|
|
87
|
+
source: "uploaded",
|
|
88
|
+
path,
|
|
89
|
+
extension: face.extension,
|
|
90
|
+
assName: face.assName,
|
|
91
|
+
faceIndex: 0,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// the picked templates, the slot names they ask for, and the rendered text stored on the
|
|
3
3
|
// project. The bodies are read at the click, not at selection, so an edit made in between
|
|
4
4
|
// is the one that runs.
|
|
5
|
+
import { normaliseDraft } from "../admission/rules.js";
|
|
5
6
|
import { collectFields, render } from "../admission/substitute.js";
|
|
6
7
|
import { entryByName, promptByName } from "./repo.js";
|
|
7
|
-
export function pickTemplates(db,
|
|
8
|
+
export function pickTemplates(db, input) {
|
|
9
|
+
const draft = normaliseDraft(input);
|
|
8
10
|
const { sources } = draft;
|
|
9
11
|
const missing = [];
|
|
10
12
|
// Only the prompts of stages set to Generate, plus the picked
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
|
-
import {
|
|
2
|
+
import { dependenciesOf } from "../../kernel/runner/graph.js";
|
|
3
3
|
// The stages to redo, in pipeline order, each with how much of itself it throws away.
|
|
4
4
|
export function redoPlan(input) {
|
|
5
5
|
const stale = staleStages(input);
|
|
@@ -11,7 +11,7 @@ function staleStages(input) {
|
|
|
11
11
|
const { action } = input;
|
|
12
12
|
switch (action.kind) {
|
|
13
13
|
case "rerun":
|
|
14
|
-
return withDependents([action.stage]);
|
|
14
|
+
return withDependents([action.stage], input);
|
|
15
15
|
case "article-edit": {
|
|
16
16
|
// An article edit re-runs audio, LLM-mode intro/outro text, the LLM-written
|
|
17
17
|
// thumbnail and the video, leaving prompt-based images untouched. The video comes
|
|
@@ -20,14 +20,14 @@ function staleStages(input) {
|
|
|
20
20
|
if (input.thumbnailSource === "prompt_by_llm") {
|
|
21
21
|
roots.push("thumbnail");
|
|
22
22
|
}
|
|
23
|
-
return withDependents(roots);
|
|
23
|
+
return withDependents(roots, input);
|
|
24
24
|
}
|
|
25
25
|
case "image-deleted":
|
|
26
26
|
// The image is removed from the set and the video re-renders. The remaining
|
|
27
27
|
// images stand, so the images stage itself is not redone.
|
|
28
|
-
return withDependents(["video"]);
|
|
28
|
+
return input.videoSource === "off" ? new Set() : withDependents(["video"], input);
|
|
29
29
|
case "image-regenerated":
|
|
30
|
-
return withDependents(["images"]);
|
|
30
|
+
return withDependents(["images"], input);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
// `provided` → `running` and `skipped` → `running` are forbidden, so a stage whose output
|
|
@@ -49,11 +49,15 @@ function clearsOf(action, kind) {
|
|
|
49
49
|
}
|
|
50
50
|
// The transitive closure of the dependents of `roots` over `kernel/runner/graph.ts`. The
|
|
51
51
|
// graph has six nodes, so one pass per node closes it whatever the edges are.
|
|
52
|
-
function withDependents(roots) {
|
|
52
|
+
function withDependents(roots, input) {
|
|
53
53
|
const found = new Set(roots);
|
|
54
54
|
for (let pass = 0; pass < stageKinds.length; pass += 1) {
|
|
55
55
|
for (const kind of stageKinds) {
|
|
56
|
-
|
|
56
|
+
const dependencies = dependenciesOf(kind, {
|
|
57
|
+
thumbnail: input.thumbnailSource,
|
|
58
|
+
video: input.videoSource ?? "generate",
|
|
59
|
+
});
|
|
60
|
+
if (dependencies.some((dependency) => found.has(dependency))) {
|
|
57
61
|
found.add(kind);
|
|
58
62
|
}
|
|
59
63
|
}
|
|
@@ -142,13 +142,19 @@ function load(deps, projectId) {
|
|
|
142
142
|
stages,
|
|
143
143
|
outputs: outputsOf(deps.db, projectId),
|
|
144
144
|
thumbnailSource: project.config.sources.thumbnail,
|
|
145
|
+
videoSource: project.config.sources.video,
|
|
145
146
|
};
|
|
146
147
|
}
|
|
147
148
|
// One transaction for every row the action touches, then the filesystem: an unlink cannot
|
|
148
149
|
// be rolled back, so it waits for the commit. A file with no row is what the boot reconcile
|
|
149
150
|
// collects, where a row with no file is a broken download.
|
|
150
151
|
function apply(deps, projectId, action, loaded, own) {
|
|
151
|
-
const plan = redoPlan({
|
|
152
|
+
const plan = redoPlan({
|
|
153
|
+
action,
|
|
154
|
+
stages: loaded.stages,
|
|
155
|
+
thumbnailSource: loaded.thumbnailSource,
|
|
156
|
+
videoSource: loaded.videoSource,
|
|
157
|
+
});
|
|
152
158
|
// `own` is the action's own change - the new article, the image that goes - and it runs
|
|
153
159
|
// first and inside the same transaction, so it lands or rolls back with the cascade it
|
|
154
160
|
// triggers. The files it writes take the names they already had, replacing in place
|
|
@@ -190,6 +196,22 @@ function clearStage(deps, stage, outputs) {
|
|
|
190
196
|
deletePieces(deps.db, stage.id);
|
|
191
197
|
return files;
|
|
192
198
|
}
|
|
199
|
+
// Switching narrator halfway through a stage must not join two different voices.
|
|
200
|
+
// The caller includes these row changes with the config update's transaction and
|
|
201
|
+
// runs the returned cleanup only after commit. Finished narration is left intact.
|
|
202
|
+
export function clearUnfinishedAudio(deps, projectId) {
|
|
203
|
+
const stage = stagesOf(deps.db, projectId).find((one) => one.kind === "audio");
|
|
204
|
+
if (stage === undefined ||
|
|
205
|
+
stage.state === "done" ||
|
|
206
|
+
stage.state === "provided" ||
|
|
207
|
+
stage.state === "skipped")
|
|
208
|
+
return [];
|
|
209
|
+
const orphaned = clearStage(deps, stage, outputsOf(deps.db, projectId));
|
|
210
|
+
deps.db
|
|
211
|
+
.prepare("UPDATE stages SET progress_current = NULL, progress_total = NULL WHERE id = ?")
|
|
212
|
+
.run(stage.id);
|
|
213
|
+
return [() => removeFiles(deps, projectId, orphaned)];
|
|
214
|
+
}
|
|
193
215
|
// A regenerated image is replaced in place at the same index, so the piece keeps the prompt
|
|
194
216
|
// and the index it was planned with and only stops naming a file: `slices/images/run.ts`
|
|
195
217
|
// then sees one image of its plan still to make, and the file it named can be removed.
|